feat(desktop): add WSL backend mode (#12914)

This commit is contained in:
Brendan Allan
2026-02-11 15:49:48 +08:00
committed by GitHub
parent d98bd4bd52
commit 213a87234d
27 changed files with 690 additions and 354 deletions

View File

@@ -43,7 +43,7 @@ function UiI18nBridge(props: ParentProps) {
declare global {
interface Window {
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[] }
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[]; wsl?: boolean }
}
}

View File

@@ -367,6 +367,34 @@ export const SettingsGeneral: Component = () => {
</div>
</div>
<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
{(_) => {
const [enabledResource, actions] = createResource(() => platform.getWslEnabled?.())
const enabled = () => (enabledResource.state === "pending" ? undefined : enabledResource.latest)
return (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.desktop.section.wsl")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.desktop.wsl.title")}
description={language.t("settings.desktop.wsl.description")}
>
<div data-action="settings-wsl">
<Switch
checked={enabled() ?? false}
disabled={enabledResource.state === "pending"}
onChange={(checked) => platform.setWslEnabled?.(checked)?.finally(() => actions.refetch())}
/>
</div>
</SettingsRow>
</div>
</div>
)
}}
</Show>
{/* Updates Section */}
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>

View File

@@ -57,6 +57,12 @@ export type Platform = {
/** Set the default server URL to use on app startup (platform-specific) */
setDefaultServerUrl?(url: string | null): Promise<void> | void
/** Get the configured WSL integration (desktop only) */
getWslEnabled?(): Promise<boolean>
/** Set the configured WSL integration (desktop only) */
setWslEnabled?(config: boolean): Promise<void> | void
/** Get the preferred display backend (desktop only) */
getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null

View File

@@ -508,6 +508,9 @@ export const dict = {
"settings.section.server": "الخادم",
"settings.tab.general": "عام",
"settings.tab.shortcuts": "اختصارات",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "المظهر",
"settings.general.section.notifications": "إشعارات النظام",

View File

@@ -512,6 +512,9 @@ export const dict = {
"settings.section.server": "Servidor",
"settings.tab.general": "Geral",
"settings.tab.shortcuts": "Atalhos",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Aparência",
"settings.general.section.notifications": "Notificações do sistema",

View File

@@ -539,6 +539,9 @@ export const dict = {
"settings.section.server": "Server",
"settings.tab.general": "Opšte",
"settings.tab.shortcuts": "Prečice",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Izgled",
"settings.general.section.notifications": "Sistemske obavijesti",

View File

@@ -512,6 +512,9 @@ export const dict = {
"settings.section.server": "Server",
"settings.tab.general": "Generelt",
"settings.tab.shortcuts": "Genveje",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Udseende",
"settings.general.section.notifications": "Systemmeddelelser",

View File

@@ -556,6 +556,9 @@ export const dict = {
"settings.section.server": "Server",
"settings.tab.general": "Allgemein",
"settings.tab.shortcuts": "Tastenkombinationen",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Erscheinungsbild",
"settings.general.section.notifications": "Systembenachrichtigungen",

View File

@@ -583,6 +583,9 @@ export const dict = {
"settings.section.server": "Server",
"settings.tab.general": "General",
"settings.tab.shortcuts": "Shortcuts",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Appearance",
"settings.general.section.notifications": "System notifications",

View File

@@ -515,6 +515,9 @@ export const dict = {
"settings.section.server": "Servidor",
"settings.tab.general": "General",
"settings.tab.shortcuts": "Atajos",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Apariencia",
"settings.general.section.notifications": "Notificaciones del sistema",

View File

@@ -522,6 +522,9 @@ export const dict = {
"settings.section.server": "Serveur",
"settings.tab.general": "Général",
"settings.tab.shortcuts": "Raccourcis",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Apparence",
"settings.general.section.notifications": "Notifications système",

View File

@@ -507,6 +507,9 @@ export const dict = {
"settings.section.server": "サーバー",
"settings.tab.general": "一般",
"settings.tab.shortcuts": "ショートカット",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "外観",
"settings.general.section.notifications": "システム通知",

View File

@@ -513,6 +513,9 @@ export const dict = {
"settings.section.server": "서버",
"settings.tab.general": "일반",
"settings.tab.shortcuts": "단축키",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "모양",
"settings.general.section.notifications": "시스템 알림",

View File

@@ -515,6 +515,9 @@ export const dict = {
"settings.section.server": "Server",
"settings.tab.general": "Generelt",
"settings.tab.shortcuts": "Snarveier",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Utseende",
"settings.general.section.notifications": "Systemvarsler",

View File

@@ -514,6 +514,9 @@ export const dict = {
"settings.section.server": "Serwer",
"settings.tab.general": "Ogólne",
"settings.tab.shortcuts": "Skróty",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Wygląd",
"settings.general.section.notifications": "Powiadomienia systemowe",

View File

@@ -517,6 +517,9 @@ export const dict = {
"settings.section.server": "Сервер",
"settings.tab.general": "Основные",
"settings.tab.shortcuts": "Горячие клавиши",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Внешний вид",
"settings.general.section.notifications": "Системные уведомления",

View File

@@ -516,6 +516,9 @@ export const dict = {
"settings.section.server": "เซิร์ฟเวอร์",
"settings.tab.general": "ทั่วไป",
"settings.tab.shortcuts": "ทางลัด",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "รูปลักษณ์",
"settings.general.section.notifications": "การแจ้งเตือนระบบ",

View File

@@ -548,6 +548,9 @@ export const dict = {
"settings.section.server": "服务器",
"settings.tab.general": "通用",
"settings.tab.shortcuts": "快捷键",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "外观",
"settings.general.section.notifications": "系统通知",

View File

@@ -545,6 +545,9 @@ export const dict = {
"settings.section.server": "伺服器",
"settings.tab.general": "一般",
"settings.tab.shortcuts": "快速鍵",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "外觀",
"settings.general.section.notifications": "系統通知",

View File

@@ -0,0 +1,4 @@
# Desktop package notes
- Never call `invoke` manually in this package.
- Use the generated bindings in `packages/desktop/src/bindings.ts` for core commands/events.

View File

@@ -3,8 +3,12 @@ use tauri_plugin_shell::{
ShellExt,
process::{Command, CommandChild, CommandEvent},
};
use tauri_plugin_store::StoreExt;
use crate::{LogState, constants::MAX_LOG_ENTRIES};
use crate::{
LogState,
constants::{MAX_LOG_ENTRIES, SETTINGS_STORE, WSL_ENABLED_KEY},
};
const CLI_INSTALL_DIR: &str = ".opencode/bin";
const CLI_BINARY_NAME: &str = "opencode";
@@ -21,7 +25,7 @@ pub struct Config {
}
pub async fn get_config(app: &AppHandle) -> Option<Config> {
create_command(app, "debug config")
create_command(app, "debug config", &[])
.output()
.await
.inspect_err(|e| eprintln!("Failed to read OC config: {e}"))
@@ -150,25 +154,106 @@ fn get_user_shell() -> String {
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
}
pub fn create_command(app: &tauri::AppHandle, args: &str) -> Command {
fn is_wsl_enabled(app: &tauri::AppHandle) -> bool {
let Ok(store) = app.store(SETTINGS_STORE) else {
return false;
};
store
.get(WSL_ENABLED_KEY)
.as_ref()
.and_then(|value| value.as_bool())
.unwrap_or(false)
}
fn shell_escape(input: &str) -> String {
if input.is_empty() {
return "''".to_string();
}
let mut escaped = String::from("'");
escaped.push_str(&input.replace("'", "'\"'\"'"));
escaped.push('\'');
escaped
}
pub fn create_command(app: &tauri::AppHandle, args: &str, extra_env: &[(&str, String)]) -> Command {
let state_dir = app
.path()
.resolve("", BaseDirectory::AppLocalData)
.expect("Failed to resolve app local data dir");
#[cfg(target_os = "windows")]
return app
.shell()
.sidecar("opencode-cli")
.unwrap()
.args(args.split_whitespace())
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
.env("OPENCODE_EXPERIMENTAL_FILEWATCHER", "true")
.env("OPENCODE_CLIENT", "desktop")
.env("XDG_STATE_HOME", &state_dir);
let mut envs = vec![
(
"OPENCODE_EXPERIMENTAL_ICON_DISCOVERY".to_string(),
"true".to_string(),
),
(
"OPENCODE_EXPERIMENTAL_FILEWATCHER".to_string(),
"true".to_string(),
),
("OPENCODE_CLIENT".to_string(), "desktop".to_string()),
(
"XDG_STATE_HOME".to_string(),
state_dir.to_string_lossy().to_string(),
),
];
envs.extend(
extra_env
.iter()
.map(|(key, value)| (key.to_string(), value.clone())),
);
#[cfg(not(target_os = "windows"))]
return {
if cfg!(windows) {
if is_wsl_enabled(app) {
println!("WSL is enabled, spawning CLI server in WSL.");
let version = app.package_info().version.to_string();
let mut script = vec![
"set -e".to_string(),
"BIN=\"$HOME/.opencode/bin/opencode\"".to_string(),
"if [ ! -x \"$BIN\" ]; then".to_string(),
format!(
" curl -fsSL https://opencode.ai/install | bash -s -- --version {} --no-modify-path",
shell_escape(&version)
),
"fi".to_string(),
];
let mut env_prefix = vec![
"OPENCODE_EXPERIMENTAL_ICON_DISCOVERY=true".to_string(),
"OPENCODE_EXPERIMENTAL_FILEWATCHER=true".to_string(),
"OPENCODE_CLIENT=desktop".to_string(),
"XDG_STATE_HOME=\"$HOME/.local/state\"".to_string(),
];
env_prefix.extend(
envs.iter()
.filter(|(key, _)| key != "OPENCODE_EXPERIMENTAL_ICON_DISCOVERY")
.filter(|(key, _)| key != "OPENCODE_EXPERIMENTAL_FILEWATCHER")
.filter(|(key, _)| key != "OPENCODE_CLIENT")
.filter(|(key, _)| key != "XDG_STATE_HOME")
.map(|(key, value)| format!("{}={}", key, shell_escape(value))),
);
script.push(format!("{} exec \"$BIN\" {}", env_prefix.join(" "), args));
return app
.shell()
.command("wsl")
.args(["-e", "bash", "-lc", &script.join("\n")]);
} else {
let mut cmd = app
.shell()
.sidecar("opencode-cli")
.unwrap()
.args(args.split_whitespace());
for (key, value) in envs {
cmd = cmd.env(key, value);
}
return cmd;
}
} else {
let sidecar = get_sidecar_path(app);
let shell = get_user_shell();
@@ -178,14 +263,14 @@ pub fn create_command(app: &tauri::AppHandle, args: &str) -> Command {
format!("\"{}\" {}", sidecar.display(), args)
};
app.shell()
.command(&shell)
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
.env("OPENCODE_EXPERIMENTAL_FILEWATCHER", "true")
.env("OPENCODE_CLIENT", "desktop")
.env("XDG_STATE_HOME", &state_dir)
.args(["-il", "-c", &cmd])
};
let mut cmd = app.shell().command(&shell).args(["-il", "-c", &cmd]);
for (key, value) in envs {
cmd = cmd.env(key, value);
}
cmd
}
}
pub fn serve(app: &AppHandle, hostname: &str, port: u32, password: &str) -> CommandChild {
@@ -194,12 +279,16 @@ pub fn serve(app: &AppHandle, hostname: &str, port: u32, password: &str) -> Comm
println!("spawning sidecar on port {port}");
let envs = [
("OPENCODE_SERVER_USERNAME", "opencode".to_string()),
("OPENCODE_SERVER_PASSWORD", password.to_string()),
];
let (mut rx, child) = create_command(
app,
format!("serve --hostname {hostname} --port {port}").as_str(),
&envs,
)
.env("OPENCODE_SERVER_USERNAME", "opencode")
.env("OPENCODE_SERVER_PASSWORD", password)
.spawn()
.expect("Failed to spawn opencode");

View File

@@ -2,6 +2,7 @@ use tauri_plugin_window_state::StateFlags;
pub const SETTINGS_STORE: &str = "opencode.settings.dat";
pub const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl";
pub const WSL_ENABLED_KEY: &str = "wslEnabled";
pub const UPDATER_ENABLED: bool = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
pub const MAX_LOG_ENTRIES: usize = 200;

View File

@@ -52,6 +52,13 @@ enum InitStep {
Done,
}
#[derive(serde::Deserialize, specta::Type)]
#[serde(rename_all = "snake_case")]
enum WslPathMode {
Windows,
Linux,
}
struct InitState {
current: watch::Receiver<InitStep>,
}
@@ -392,32 +399,50 @@ fn check_linux_app(app_name: &str) -> bool {
return true;
}
#[tauri::command]
#[specta::specta]
fn wsl_path(path: String, mode: Option<WslPathMode>) -> Result<String, String> {
if !cfg(windows) {
return Ok(path);
}
let flag = match mode.unwrap_or(WslPathMode::Linux) {
WslPathMode::Windows => "-w",
WslPathMode::Linux => "-u",
};
let output = if path.starts_with('~') {
let suffix = path.strip_prefix('~').unwrap_or("");
let escaped = suffix.replace('"', "\\\"");
let cmd = format!("wslpath {flag} \"$HOME{escaped}\"");
Command::new("wsl")
.args(["-e", "sh", "-lc", &cmd])
.output()
.map_err(|e| format!("Failed to run wslpath: {e}"))?
} else {
Command::new("wsl")
.args(["-e", "wslpath", flag, &path])
.output()
.map_err(|e| format!("Failed to run wslpath: {e}"))?
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
return Err("wslpath failed".to_string());
}
return Err(stderr);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let builder = tauri_specta::Builder::<tauri::Wry>::new()
// Then register them (separated by a comma)
.commands(tauri_specta::collect_commands![
kill_sidecar,
cli::install_cli,
await_initialization,
server::get_default_server_url,
server::set_default_server_url,
get_display_backend,
set_display_backend,
markdown::parse_markdown_command,
check_app_exists,
resolve_app_path
])
.events(tauri_specta::collect_events![LoadingWindowComplete])
.error_handling(tauri_specta::ErrorHandlingMode::Throw);
let builder = make_specta_builder();
#[cfg(debug_assertions)] // <- Only export on non-release builds
builder
.export(
specta_typescript::Typescript::default(),
"../src/bindings.ts",
)
.expect("Failed to export typescript bindings");
export_types(&builder);
#[cfg(all(target_os = "macos", not(debug_assertions)))]
let _ = std::process::Command::new("killall")
@@ -476,6 +501,44 @@ pub fn run() {
});
}
fn make_specta_builder() -> tauri_specta::Builder<tauri::Wry> {
tauri_specta::Builder::<tauri::Wry>::new()
// Then register them (separated by a comma)
.commands(tauri_specta::collect_commands![
kill_sidecar,
cli::install_cli,
await_initialization,
server::get_default_server_url,
server::set_default_server_url,
server::get_wsl_config,
server::set_wsl_config,
get_display_backend,
set_display_backend,
markdown::parse_markdown_command,
check_app_exists,
wsl_path,
resolve_app_path
])
.events(tauri_specta::collect_events![LoadingWindowComplete])
.error_handling(tauri_specta::ErrorHandlingMode::Throw)
}
fn export_types(builder: &tauri_specta::Builder<tauri::Wry>) {
builder
.export(
specta_typescript::Typescript::default(),
"../src/bindings.ts",
)
.expect("Failed to export typescript bindings");
}
#[cfg(test)]
#[test]
fn test_export_types() {
let builder = make_specta_builder();
export_types(&builder);
}
#[derive(tauri_specta::Event, serde::Deserialize, specta::Type)]
struct LoadingWindowComplete;

View File

@@ -8,9 +8,20 @@ use tokio::task::JoinHandle;
use crate::{
cli,
constants::{DEFAULT_SERVER_URL_KEY, SETTINGS_STORE},
constants::{DEFAULT_SERVER_URL_KEY, SETTINGS_STORE, WSL_ENABLED_KEY},
};
#[derive(Clone, serde::Serialize, serde::Deserialize, specta::Type, Debug)]
pub struct WslConfig {
pub enabled: bool,
}
impl Default for WslConfig {
fn default() -> Self {
Self { enabled: false }
}
}
#[tauri::command]
#[specta::specta]
pub fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
@@ -48,6 +59,38 @@ pub async fn set_default_server_url(app: AppHandle, url: Option<String>) -> Resu
Ok(())
}
#[tauri::command]
#[specta::specta]
pub fn get_wsl_config(app: AppHandle) -> Result<WslConfig, String> {
let store = app
.store(SETTINGS_STORE)
.map_err(|e| format!("Failed to open settings store: {}", e))?;
let enabled = store
.get(WSL_ENABLED_KEY)
.as_ref()
.and_then(|v| v.as_bool())
.unwrap_or(false);
Ok(WslConfig { enabled })
}
#[tauri::command]
#[specta::specta]
pub fn set_wsl_config(app: AppHandle, config: WslConfig) -> Result<(), String> {
let store = app
.store(SETTINGS_STORE)
.map_err(|e| format!("Failed to open settings store: {}", e))?;
store.set(WSL_ENABLED_KEY, serde_json::Value::Bool(config.enabled));
store
.save()
.map_err(|e| format!("Failed to save settings: {}", e))?;
Ok(())
}
pub async fn get_saved_server_url(app: &tauri::AppHandle) -> Option<String> {
if let Some(url) = get_default_server_url(app.clone()).ok().flatten() {
println!("Using desktop-specific custom URL: {url}");

View File

@@ -1,4 +1,7 @@
use crate::constants::{UPDATER_ENABLED, window_state_flags};
use crate::{
constants::{UPDATER_ENABLED, window_state_flags},
server::get_wsl_config,
};
use std::{ops::Deref, time::Duration};
use tauri::{AppHandle, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
use tauri_plugin_window_state::AppHandleExt;
@@ -22,6 +25,11 @@ impl MainWindow {
return Ok(Self(window));
}
let wsl_enabled = get_wsl_config(app.clone())
.ok()
.map(|v| v.enabled)
.unwrap_or(false);
let window_builder = base_window_config(
WebviewWindowBuilder::new(app, Self::LABEL, WebviewUrl::App("/".into())),
app,
@@ -36,6 +44,7 @@ impl MainWindow {
r#"
window.__OPENCODE__ ??= {{}};
window.__OPENCODE__.updaterEnabled = {UPDATER_ENABLED};
window.__OPENCODE__.wsl = {wsl_enabled};
"#
));

View File

@@ -10,10 +10,13 @@ export const commands = {
awaitInitialization: (events: Channel) => __TAURI_INVOKE<ServerReadyData>("await_initialization", { events }),
getDefaultServerUrl: () => __TAURI_INVOKE<string | null>("get_default_server_url"),
setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE<null>("set_default_server_url", { url }),
getWslConfig: () => __TAURI_INVOKE<WslConfig>("get_wsl_config"),
setWslConfig: (config: WslConfig) => __TAURI_INVOKE<null>("set_wsl_config", { config }),
getDisplayBackend: () => __TAURI_INVOKE<"wayland" | "auto" | null>("get_display_backend"),
setDisplayBackend: (backend: LinuxDisplayBackend) => __TAURI_INVOKE<null>("set_display_backend", { backend }),
parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE<string>("parse_markdown_command", { markdown }),
checkAppExists: (appName: string) => __TAURI_INVOKE<boolean>("check_app_exists", { appName }),
wslPath: (path: string, mode: "windows" | "linux" | null) => __TAURI_INVOKE<string>("wsl_path", { path, mode }),
resolveAppPath: (appName: string) => __TAURI_INVOKE<string | null>("resolve_app_path", { appName }),
};
@@ -34,6 +37,12 @@ export type ServerReadyData = {
password: string | null,
};
export type WslConfig = {
enabled: boolean,
};
export type WslPathMode = "windows" | "linux";
/* Tauri Specta runtime */
function makeEvent<T>(name: string) {
const base = {

View File

@@ -16,7 +16,6 @@ import { open as shellOpen } from "@tauri-apps/plugin-shell"
import { type as ostype } from "@tauri-apps/plugin-os"
import { check, Update } from "@tauri-apps/plugin-updater"
import { getCurrentWindow } from "@tauri-apps/api/window"
import { invoke } from "@tauri-apps/api/core"
import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
import { relaunch } from "@tauri-apps/plugin-process"
import { AsyncStorage } from "@solid-primitives/storage"
@@ -30,7 +29,7 @@ import { UPDATER_ENABLED } from "./updater"
import { initI18n, t } from "./i18n"
import pkg from "../package.json"
import "./styles.css"
import { commands, InitStep } from "./bindings"
import { commands, InitStep, type WslConfig } from "./bindings"
import { Channel } from "@tauri-apps/api/core"
import { createMenu } from "./menu"
@@ -59,338 +58,374 @@ const listenForDeepLinks = async () => {
await onOpenUrl((urls) => emitDeepLinks(urls)).catch(() => undefined)
}
const createPlatform = (password: Accessor<string | null>): Platform => ({
platform: "desktop",
os: (() => {
const createPlatform = (password: Accessor<string | null>): Platform => {
const os = (() => {
const type = ostype()
if (type === "macos" || type === "windows" || type === "linux") return type
return undefined
})(),
version: pkg.version,
})()
async openDirectoryPickerDialog(opts) {
const result = await open({
directory: true,
multiple: opts?.multiple ?? false,
title: opts?.title ?? t("desktop.dialog.chooseFolder"),
})
return result
},
const wslHome = async () => {
if (os !== "windows" || !window.__OPENCODE__?.wsl) return undefined
return commands.wslPath("~", "windows").catch(() => undefined)
}
async openFilePickerDialog(opts) {
const result = await open({
directory: false,
multiple: opts?.multiple ?? false,
title: opts?.title ?? t("desktop.dialog.chooseFile"),
})
return result
},
async saveFilePickerDialog(opts) {
const result = await save({
title: opts?.title ?? t("desktop.dialog.saveFile"),
defaultPath: opts?.defaultPath,
})
return result
},
openLink(url: string) {
void shellOpen(url).catch(() => undefined)
},
async openPath(path: string, app?: string) {
const os = ostype()
if (os === "windows" && app) {
const resolvedApp = await commands.resolveAppPath(app)
return openerOpenPath(path, resolvedApp || app)
const handleWslPicker = async <T extends string | string[]>(result: T | null): Promise<T | null> => {
if (!result || !window.__OPENCODE__?.wsl) return result
if (Array.isArray(result)) {
return Promise.all(result.map((path) => commands.wslPath(path, "linux").catch(() => path))) as any
}
return openerOpenPath(path, app)
},
return commands.wslPath(result, "linux").catch(() => result) as any
}
back() {
window.history.back()
},
return {
platform: "desktop",
os,
version: pkg.version,
forward() {
window.history.forward()
},
async openDirectoryPickerDialog(opts) {
const defaultPath = await wslHome()
const result = await open({
directory: true,
multiple: opts?.multiple ?? false,
title: opts?.title ?? t("desktop.dialog.chooseFolder"),
defaultPath,
})
return await handleWslPicker(result)
},
storage: (() => {
type StoreLike = {
get(key: string): Promise<string | null | undefined>
set(key: string, value: string): Promise<unknown>
delete(key: string): Promise<unknown>
clear(): Promise<unknown>
keys(): Promise<string[]>
length(): Promise<number>
}
async openFilePickerDialog(opts) {
const result = await open({
directory: false,
multiple: opts?.multiple ?? false,
title: opts?.title ?? t("desktop.dialog.chooseFile"),
})
return handleWslPicker(result)
},
const WRITE_DEBOUNCE_MS = 250
async saveFilePickerDialog(opts) {
const result = await save({
title: opts?.title ?? t("desktop.dialog.saveFile"),
defaultPath: opts?.defaultPath,
})
return handleWslPicker(result)
},
const storeCache = new Map<string, Promise<StoreLike>>()
const apiCache = new Map<string, AsyncStorage & { flush: () => Promise<void> }>()
const memoryCache = new Map<string, StoreLike>()
openLink(url: string) {
void shellOpen(url).catch(() => undefined)
},
async openPath(path: string, app?: string) {
const os = ostype()
if (os === "windows") {
const resolvedApp = (app && (await commands.resolveAppPath(app))) || app
const resolvedPath = await (async () => {
if (window.__OPENCODE__?.wsl) {
const converted = await commands.wslPath(path, "windows").catch(() => null)
if (converted) return converted
}
const flushAll = async () => {
const apis = Array.from(apiCache.values())
await Promise.all(apis.map((api) => api.flush().catch(() => undefined)))
}
return path
})()
return openerOpenPath(resolvedPath, resolvedApp)
}
return openerOpenPath(path, app)
},
if ("addEventListener" in globalThis) {
const handleVisibility = () => {
if (document.visibilityState !== "hidden") return
void flushAll()
back() {
window.history.back()
},
forward() {
window.history.forward()
},
storage: (() => {
type StoreLike = {
get(key: string): Promise<string | null | undefined>
set(key: string, value: string): Promise<unknown>
delete(key: string): Promise<unknown>
clear(): Promise<unknown>
keys(): Promise<string[]>
length(): Promise<number>
}
window.addEventListener("pagehide", () => void flushAll())
document.addEventListener("visibilitychange", handleVisibility)
}
const WRITE_DEBOUNCE_MS = 250
const createMemoryStore = () => {
const data = new Map<string, string>()
const store: StoreLike = {
get: async (key) => data.get(key),
set: async (key, value) => {
data.set(key, value)
},
delete: async (key) => {
data.delete(key)
},
clear: async () => {
data.clear()
},
keys: async () => Array.from(data.keys()),
length: async () => data.size,
const storeCache = new Map<string, Promise<StoreLike>>()
const apiCache = new Map<string, AsyncStorage & { flush: () => Promise<void> }>()
const memoryCache = new Map<string, StoreLike>()
const flushAll = async () => {
const apis = Array.from(apiCache.values())
await Promise.all(apis.map((api) => api.flush().catch(() => undefined)))
}
return store
}
const getStore = (name: string) => {
const cached = storeCache.get(name)
if (cached) return cached
if ("addEventListener" in globalThis) {
const handleVisibility = () => {
if (document.visibilityState !== "hidden") return
void flushAll()
}
const store = Store.load(name).catch(() => {
const cached = memoryCache.get(name)
window.addEventListener("pagehide", () => void flushAll())
document.addEventListener("visibilitychange", handleVisibility)
}
const createMemoryStore = () => {
const data = new Map<string, string>()
const store: StoreLike = {
get: async (key) => data.get(key),
set: async (key, value) => {
data.set(key, value)
},
delete: async (key) => {
data.delete(key)
},
clear: async () => {
data.clear()
},
keys: async () => Array.from(data.keys()),
length: async () => data.size,
}
return store
}
const getStore = (name: string) => {
const cached = storeCache.get(name)
if (cached) return cached
const memory = createMemoryStore()
memoryCache.set(name, memory)
return memory
})
const store = Store.load(name).catch(() => {
const cached = memoryCache.get(name)
if (cached) return cached
storeCache.set(name, store)
return store
}
const memory = createMemoryStore()
memoryCache.set(name, memory)
return memory
})
const createStorage = (name: string) => {
const pending = new Map<string, string | null>()
let timer: ReturnType<typeof setTimeout> | undefined
let flushing: Promise<void> | undefined
storeCache.set(name, store)
return store
}
const flush = async () => {
if (flushing) return flushing
const createStorage = (name: string) => {
const pending = new Map<string, string | null>()
let timer: ReturnType<typeof setTimeout> | undefined
let flushing: Promise<void> | undefined
flushing = (async () => {
const store = await getStore(name)
while (pending.size > 0) {
const batch = Array.from(pending.entries())
pending.clear()
for (const [key, value] of batch) {
if (value === null) {
await store.delete(key).catch(() => undefined)
} else {
await store.set(key, value).catch(() => undefined)
const flush = async () => {
if (flushing) return flushing
flushing = (async () => {
const store = await getStore(name)
while (pending.size > 0) {
const batch = Array.from(pending.entries())
pending.clear()
for (const [key, value] of batch) {
if (value === null) {
await store.delete(key).catch(() => undefined)
} else {
await store.set(key, value).catch(() => undefined)
}
}
}
}
})().finally(() => {
flushing = undefined
})
})().finally(() => {
flushing = undefined
})
return flushing
}
const schedule = () => {
if (timer) return
timer = setTimeout(() => {
timer = undefined
void flush()
}, WRITE_DEBOUNCE_MS)
}
const api: AsyncStorage & { flush: () => Promise<void> } = {
flush,
getItem: async (key: string) => {
const next = pending.get(key)
if (next !== undefined) return next
const store = await getStore(name)
const value = await store.get(key).catch(() => null)
if (value === undefined) return null
return value
},
setItem: async (key: string, value: string) => {
pending.set(key, value)
schedule()
},
removeItem: async (key: string) => {
pending.set(key, null)
schedule()
},
clear: async () => {
pending.clear()
const store = await getStore(name)
await store.clear().catch(() => undefined)
},
key: async (index: number) => {
const store = await getStore(name)
return (await store.keys().catch(() => []))[index]
},
getLength: async () => {
const store = await getStore(name)
return await store.length().catch(() => 0)
},
get length() {
return api.getLength()
},
}
return api
}
return (name = "default.dat") => {
const cached = apiCache.get(name)
if (cached) return cached
const api = createStorage(name)
apiCache.set(name, api)
return api
}
})(),
checkUpdate: async () => {
if (!UPDATER_ENABLED) return { updateAvailable: false }
const next = await check().catch(() => null)
if (!next) return { updateAvailable: false }
const ok = await next
.download()
.then(() => true)
.catch(() => false)
if (!ok) return { updateAvailable: false }
update = next
return { updateAvailable: true, version: next.version }
},
update: async () => {
if (!UPDATER_ENABLED || !update) return
if (ostype() === "windows") await commands.killSidecar().catch(() => undefined)
await update.install().catch(() => undefined)
},
restart: async () => {
await commands.killSidecar().catch(() => undefined)
await relaunch()
},
notify: async (title, description, href) => {
const granted = await isPermissionGranted().catch(() => false)
const permission = granted ? "granted" : await requestPermission().catch(() => "denied")
if (permission !== "granted") return
const win = getCurrentWindow()
const focused = await win.isFocused().catch(() => document.hasFocus())
if (focused) return
await Promise.resolve()
.then(() => {
const notification = new Notification(title, {
body: description ?? "",
icon: "https://opencode.ai/favicon-96x96-v3.png",
})
notification.onclick = () => {
const win = getCurrentWindow()
void win.show().catch(() => undefined)
void win.unminimize().catch(() => undefined)
void win.setFocus().catch(() => undefined)
if (href) {
window.history.pushState(null, "", href)
window.dispatchEvent(new PopStateEvent("popstate"))
}
notification.close()
return flushing
}
const schedule = () => {
if (timer) return
timer = setTimeout(() => {
timer = undefined
void flush()
}, WRITE_DEBOUNCE_MS)
}
const api: AsyncStorage & { flush: () => Promise<void> } = {
flush,
getItem: async (key: string) => {
const next = pending.get(key)
if (next !== undefined) return next
const store = await getStore(name)
const value = await store.get(key).catch(() => null)
if (value === undefined) return null
return value
},
setItem: async (key: string, value: string) => {
pending.set(key, value)
schedule()
},
removeItem: async (key: string) => {
pending.set(key, null)
schedule()
},
clear: async () => {
pending.clear()
const store = await getStore(name)
await store.clear().catch(() => undefined)
},
key: async (index: number) => {
const store = await getStore(name)
return (await store.keys().catch(() => []))[index]
},
getLength: async () => {
const store = await getStore(name)
return await store.length().catch(() => 0)
},
get length() {
return api.getLength()
},
}
return api
}
return (name = "default.dat") => {
const cached = apiCache.get(name)
if (cached) return cached
const api = createStorage(name)
apiCache.set(name, api)
return api
}
})(),
checkUpdate: async () => {
if (!UPDATER_ENABLED) return { updateAvailable: false }
const next = await check().catch(() => null)
if (!next) return { updateAvailable: false }
const ok = await next
.download()
.then(() => true)
.catch(() => false)
if (!ok) return { updateAvailable: false }
update = next
return { updateAvailable: true, version: next.version }
},
update: async () => {
if (!UPDATER_ENABLED || !update) return
if (ostype() === "windows") await commands.killSidecar().catch(() => undefined)
await update.install().catch(() => undefined)
},
restart: async () => {
await commands.killSidecar().catch(() => undefined)
await relaunch()
},
notify: async (title, description, href) => {
const granted = await isPermissionGranted().catch(() => false)
const permission = granted ? "granted" : await requestPermission().catch(() => "denied")
if (permission !== "granted") return
const win = getCurrentWindow()
const focused = await win.isFocused().catch(() => document.hasFocus())
if (focused) return
await Promise.resolve()
.then(() => {
const notification = new Notification(title, {
body: description ?? "",
icon: "https://opencode.ai/favicon-96x96-v3.png",
})
notification.onclick = () => {
const win = getCurrentWindow()
void win.show().catch(() => undefined)
void win.unminimize().catch(() => undefined)
void win.setFocus().catch(() => undefined)
if (href) {
window.history.pushState(null, "", href)
window.dispatchEvent(new PopStateEvent("popstate"))
}
notification.close()
}
})
.catch(() => undefined)
},
fetch: (input, init) => {
const pw = password()
const addHeader = (headers: Headers, password: string) => {
headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`)
}
if (input instanceof Request) {
if (pw) addHeader(input.headers, pw)
return tauriFetch(input)
} else {
const headers = new Headers(init?.headers)
if (pw) addHeader(headers, pw)
return tauriFetch(input, {
...(init as any),
headers: headers,
})
}
},
getWslEnabled: async () => {
const next = await commands.getWslConfig().catch(() => null)
if (next) return next.enabled
return window.__OPENCODE__!.wsl ?? false
},
setWslEnabled: async (enabled) => {
await commands.setWslConfig({ enabled })
},
getDefaultServerUrl: async () => {
const result = await commands.getDefaultServerUrl().catch(() => null)
return result
},
setDefaultServerUrl: async (url: string | null) => {
await commands.setDefaultServerUrl(url)
},
getDisplayBackend: async () => {
const result = await commands.getDisplayBackend().catch(() => null)
return result
},
setDisplayBackend: async (backend) => {
await commands.setDisplayBackend(backend)
},
parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown),
webviewZoom,
checkAppExists: async (appName: string) => {
return commands.checkAppExists(appName)
},
async readClipboardImage() {
const image = await readImage().catch(() => null)
if (!image) return null
const bytes = await image.rgba().catch(() => null)
if (!bytes || bytes.length === 0) return null
const size = await image.size().catch(() => null)
if (!size) return null
const canvas = document.createElement("canvas")
canvas.width = size.width
canvas.height = size.height
const ctx = canvas.getContext("2d")
if (!ctx) return null
const imageData = ctx.createImageData(size.width, size.height)
imageData.data.set(bytes)
ctx.putImageData(imageData, 0, 0)
return new Promise<File | null>((resolve) => {
canvas.toBlob((blob) => {
if (!blob) return resolve(null)
resolve(new File([blob], `pasted-image-${Date.now()}.png`, { type: "image/png" }))
}, "image/png")
})
.catch(() => undefined)
},
fetch: (input, init) => {
const pw = password()
const addHeader = (headers: Headers, password: string) => {
headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`)
}
if (input instanceof Request) {
if (pw) addHeader(input.headers, pw)
return tauriFetch(input)
} else {
const headers = new Headers(init?.headers)
if (pw) addHeader(headers, pw)
return tauriFetch(input, {
...(init as any),
headers: headers,
})
}
},
getDefaultServerUrl: async () => {
const result = await commands.getDefaultServerUrl().catch(() => null)
return result
},
setDefaultServerUrl: async (url: string | null) => {
await commands.setDefaultServerUrl(url)
},
getDisplayBackend: async () => {
const result = await invoke<DisplayBackend | null>("get_display_backend").catch(() => null)
return result
},
setDisplayBackend: async (backend) => {
await invoke("set_display_backend", { backend }).catch(() => undefined)
},
parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown),
webviewZoom,
checkAppExists: async (appName: string) => {
return commands.checkAppExists(appName)
},
async readClipboardImage() {
const image = await readImage().catch(() => null)
if (!image) return null
const bytes = await image.rgba().catch(() => null)
if (!bytes || bytes.length === 0) return null
const size = await image.size().catch(() => null)
if (!size) return null
const canvas = document.createElement("canvas")
canvas.width = size.width
canvas.height = size.height
const ctx = canvas.getContext("2d")
if (!ctx) return null
const imageData = ctx.createImageData(size.width, size.height)
imageData.data.set(bytes)
ctx.putImageData(imageData, 0, 0)
return new Promise<File | null>((resolve) => {
canvas.toBlob((blob) => {
if (!blob) return resolve(null)
resolve(new File([blob], `pasted-image-${Date.now()}.png`, { type: "image/png" }))
}, "image/png")
})
},
})
},
}
}
let menuTrigger = null as null | ((id: string) => void)
createMenu((id) => {
@@ -400,6 +435,7 @@ void listenForDeepLinks()
render(() => {
const [serverPassword, setServerPassword] = createSignal<string | null>(null)
const platform = createPlatform(() => serverPassword())
function handleClick(e: MouseEvent) {