diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 5bbe86e2093d..e49b725a1975 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -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 } } } diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 383490f99dab..54e24a6fbb06 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" +import { createEffect, createMemo, onCleanup, Show } from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import { useParams } from "@solidjs/router" @@ -18,7 +18,6 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Button } from "@opencode-ai/ui/button" import { AppIcon } from "@opencode-ai/ui/app-icon" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { Spinner } from "@opencode-ai/ui/spinner" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Popover } from "@opencode-ai/ui/popover" import { TextField } from "@opencode-ai/ui/text-field" @@ -168,7 +167,6 @@ export function SessionHeader() { const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp })) const [menu, setMenu] = createStore({ open: false }) - const [openRequest, setOpenRequest] = createStore({ app: undefined as OpenApp | undefined, version: 0 }) const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0]) @@ -181,32 +179,20 @@ export function SessionHeader() { setPrefs("app", options()[0]?.id ?? "finder") }) - const [openTask] = createResource( - () => openRequest.app && openRequest.version, - async () => { - const app = openRequest.app - const directory = projectDirectory() - if (!app || !directory || !canOpen()) return - - const item = options().find((o) => o.id === app) - const openWith = item && "openWith" in item ? item.openWith : undefined - await platform.openPath?.(directory, openWith) - }, - ) - - createEffect(() => { - const err = openTask.error - if (!err) return - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) - }) - const openDir = (app: OpenApp) => { - if (openTask.loading) return - setOpenRequest({ app, version: openRequest.version + 1 }) + const directory = projectDirectory() + if (!directory) return + if (!canOpen()) return + + const item = options().find((o) => o.id === app) + const openWith = item && "openWith" in item ? item.openWith : undefined + Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) + }) } const copyPath = () => { @@ -362,18 +348,12 @@ export function SessionHeader() {
@@ -388,11 +368,7 @@ export function SessionHeader() { as={IconButton} icon="chevron-down" variant="ghost" - disabled={openTask.loading} - class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default" - classList={{ - "bg-surface-raised-base-active": openTask.loading, - }} + class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active" aria-label={language.t("session.header.open.menu")} /> @@ -409,7 +385,6 @@ export function SessionHeader() { {options().map((o) => ( { setMenu("open", false) openDir(o.id) diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index db057a4c41f7..72135c342e53 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -367,6 +367,34 @@ export const SettingsGeneral: Component = () => {
+ + {(_) => { + const [enabledResource, actions] = createResource(() => platform.getWslEnabled?.()) + const enabled = () => (enabledResource.state === "pending" ? undefined : enabledResource.latest) + + return ( +
+

{language.t("settings.desktop.section.wsl")}

+ +
+ +
+ platform.setWslEnabled?.(checked)?.finally(() => actions.refetch())} + /> +
+
+
+
+ ) + }} +
+ {/* Updates Section */}

{language.t("settings.general.section.updates")}

diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 7aa6c6554006..e260c1977ed9 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -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 + /** Get the configured WSL integration (desktop only) */ + getWslEnabled?(): Promise + + /** Set the configured WSL integration (desktop only) */ + setWslEnabled?(config: boolean): Promise | void + /** Get the preferred display backend (desktop only) */ getDisplayBackend?(): Promise | DisplayBackend | null diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 201d63660a39..7a09edc51842 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -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": "إشعارات النظام", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index b7f2d74857f7..ba09fbe03db0 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -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", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 05eca1628e5e..38d6b79c94d9 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -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", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 8ea4907c1b67..e36fb16d5b74 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -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", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index a4884a1033dc..633d51d05289 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -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", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index b0ffa70f84de..c138c7b61456 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -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", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 50d9060703ea..ff4198228a5b 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -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", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 7ad39f340639..402c095ba59d 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -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", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index a39bfbaf331b..312ac3262c78 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -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": "システム通知", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index b5927b210767..b162ab3916ef 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -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": "시스템 알림", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 7d8cdd27f3dd..001b9eda6568 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -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", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 76a47ea26f8d..2a20cd57e39c 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -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", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index e83ce37618c5..698c8db5819e 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -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": "Системные уведомления", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 2be19d15b174..161f37f3ba2e 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -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": "การแจ้งเตือนระบบ", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index a48f9e549415..a2931cf98c81 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -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": "系统通知", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 60363fc99eff..cae0c75b46c4 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -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": "系統通知", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index e8435c3f9676..13d15bdce29f 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1917,10 +1917,10 @@ export default function Layout(props: ParentProps) { renderPanel={() => } />
- - {(project) => ( + + {(worktree) => (
- +
)}
diff --git a/packages/desktop/AGENTS.md b/packages/desktop/AGENTS.md new file mode 100644 index 000000000000..3839db1a9041 --- /dev/null +++ b/packages/desktop/AGENTS.md @@ -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. diff --git a/packages/desktop/src-tauri/src/cli.rs b/packages/desktop/src-tauri/src/cli.rs index 48d9276a147e..b9e1ed4bd504 100644 --- a/packages/desktop/src-tauri/src/cli.rs +++ b/packages/desktop/src-tauri/src/cli.rs @@ -3,8 +3,11 @@ use tauri_plugin_shell::{ ShellExt, process::{Command, CommandChild, CommandEvent, TerminatedPayload}, }; +use tauri_plugin_store::StoreExt; use tokio::sync::oneshot; +use crate::constants::{SETTINGS_STORE, WSL_ENABLED_KEY}; + const CLI_INSTALL_DIR: &str = ".opencode/bin"; const CLI_BINARY_NAME: &str = "opencode"; @@ -20,7 +23,7 @@ pub struct Config { } pub async fn get_config(app: &AppHandle) -> Option { - create_command(app, "debug config") + create_command(app, "debug config", &[]) .output() .await .inspect_err(|e| tracing::warn!("Failed to read OC config: {e}")) @@ -149,25 +152,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); - - #[cfg(not(target_os = "windows"))] - return { + 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())), + ); + + if cfg!(windows) { + if is_wsl_enabled(app) { + tracing::info!("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(); @@ -177,14 +261,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( @@ -197,12 +281,16 @@ pub fn serve( tracing::info!(port, "Spawning sidecar"); + let envs = [ + ("OPENCODE_SERVER_USERNAME", "opencode".to_string()), + ("OPENCODE_SERVER_PASSWORD", password.to_string()), + ]; + let (mut rx, child) = create_command( app, format!("--print-logs --log-level WARN serve --hostname {hostname} --port {port}").as_str(), + &envs, ) - .env("OPENCODE_SERVER_USERNAME", "opencode") - .env("OPENCODE_SERVER_PASSWORD", password) .spawn() .expect("Failed to spawn opencode"); diff --git a/packages/desktop/src-tauri/src/constants.rs b/packages/desktop/src-tauri/src/constants.rs index a75495b543bb..9d50d00e2025 100644 --- a/packages/desktop/src-tauri/src/constants.rs +++ b/packages/desktop/src-tauri/src/constants.rs @@ -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 fn window_state_flags() -> StateFlags { diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 6a8e5e5dc63f..e0187a76bc35 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -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, } @@ -155,211 +162,28 @@ fn check_app_exists(app_name: &str) -> bool { } #[cfg(target_os = "windows")] -fn check_windows_app(app_name: &str) -> bool { - resolve_windows_app_path(app_name).is_some() +fn check_windows_app(_app_name: &str) -> bool { + // Check if command exists in PATH, including .exe + return true; } #[cfg(target_os = "windows")] fn resolve_windows_app_path(app_name: &str) -> Option { use std::path::{Path, PathBuf}; - fn expand_env(value: &str) -> String { - let mut out = String::with_capacity(value.len()); - let mut index = 0; - - while let Some(start) = value[index..].find('%') { - let start = index + start; - out.push_str(&value[index..start]); - - let Some(end_rel) = value[start + 1..].find('%') else { - out.push_str(&value[start..]); - return out; - }; - - let end = start + 1 + end_rel; - let key = &value[start + 1..end]; - if key.is_empty() { - out.push('%'); - index = end + 1; - continue; - } - - if let Ok(v) = std::env::var(key) { - out.push_str(&v); - index = end + 1; - continue; - } - - out.push_str(&value[start..=end]); - index = end + 1; - } - - out.push_str(&value[index..]); - out - } - - fn extract_exe(value: &str) -> Option { - let value = value.trim(); - if value.is_empty() { - return None; - } - - if let Some(rest) = value.strip_prefix('"') { - if let Some(end) = rest.find('"') { - let inner = rest[..end].trim(); - if inner.to_ascii_lowercase().contains(".exe") { - return Some(inner.to_string()); - } - } - } - - let lower = value.to_ascii_lowercase(); - let end = lower.find(".exe")?; - Some(value[..end + 4].trim().trim_matches('"').to_string()) - } - - fn candidates(app_name: &str) -> Vec { - let app_name = app_name.trim().trim_matches('"'); - if app_name.is_empty() { - return vec![]; - } - - let mut out = Vec::::new(); - let mut push = |value: String| { - let value = value.trim().trim_matches('"').to_string(); - if value.is_empty() { - return; - } - if out.iter().any(|v| v.eq_ignore_ascii_case(&value)) { - return; - } - out.push(value); - }; - - push(app_name.to_string()); - - let lower = app_name.to_ascii_lowercase(); - if !lower.ends_with(".exe") { - push(format!("{app_name}.exe")); - } - - let snake = { - let mut s = String::new(); - let mut underscore = false; - for c in lower.chars() { - if c.is_ascii_alphanumeric() { - s.push(c); - underscore = false; - continue; - } - if underscore { - continue; - } - s.push('_'); - underscore = true; - } - s.trim_matches('_').to_string() - }; - - if !snake.is_empty() { - push(snake.clone()); - if !snake.ends_with(".exe") { - push(format!("{snake}.exe")); - } - } - - let alnum = lower - .chars() - .filter(|c| c.is_ascii_alphanumeric()) - .collect::(); - - if !alnum.is_empty() { - push(alnum.clone()); - push(format!("{alnum}.exe")); - } - - match lower.as_str() { - "sublime text" | "sublime-text" | "sublime_text" | "sublime text.exe" => { - push("subl".to_string()); - push("subl.exe".to_string()); - push("sublime_text".to_string()); - push("sublime_text.exe".to_string()); - } - _ => {} - } + // Try to find the command using 'where' + let output = Command::new("where").arg(app_name).output().ok()?; - out - } - - fn reg_app_path(exe: &str) -> Option { - let exe = exe.trim().trim_matches('"'); - if exe.is_empty() { - return None; - } - - let keys = [ - format!( - r"HKCU\Software\Microsoft\Windows\CurrentVersion\App Paths\{exe}" - ), - format!( - r"HKLM\Software\Microsoft\Windows\CurrentVersion\App Paths\{exe}" - ), - format!( - r"HKLM\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\{exe}" - ), - ]; - - for key in keys { - let Some(output) = Command::new("reg") - .args(["query", &key, "/ve"]) - .output() - .ok() - else { - continue; - }; - - if !output.status.success() { - continue; - } - - let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines() { - let tokens = line.split_whitespace().collect::>(); - let Some(index) = tokens.iter().position(|v| v.starts_with("REG_")) else { - continue; - }; - - let value = tokens[index + 1..].join(" "); - let Some(exe) = extract_exe(&value) else { - continue; - }; - - let exe = expand_env(&exe); - let path = Path::new(exe.trim().trim_matches('"')); - if path.exists() { - return Some(path.to_string_lossy().to_string()); - } - } - } - - None - } - - let app_name = app_name.trim().trim_matches('"'); - if app_name.is_empty() { + if !output.status.success() { return None; } - let direct = Path::new(app_name); - if direct.is_absolute() && direct.exists() { - return Some(direct.to_string_lossy().to_string()); - } - - let key = app_name - .chars() - .filter(|v| v.is_ascii_alphanumeric()) - .flat_map(|v| v.to_lowercase()) - .collect::(); + let paths = String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(PathBuf::from) + .collect::>(); let has_ext = |path: &Path, ext: &str| { path.extension() @@ -368,19 +192,22 @@ fn resolve_windows_app_path(app_name: &str) -> Option { .unwrap_or(false) }; + if let Some(path) = paths.iter().find(|path| has_ext(path, "exe")) { + return Some(path.to_string_lossy().to_string()); + } + let resolve_cmd = |path: &Path| -> Option { - let bytes = std::fs::read(path).ok()?; - let content = String::from_utf8_lossy(&bytes); + let content = std::fs::read_to_string(path).ok()?; for token in content.split('"') { - let Some(exe) = extract_exe(token) else { + let lower = token.to_ascii_lowercase(); + if !lower.contains(".exe") { continue; - }; + } - let lower = exe.to_ascii_lowercase(); if let Some(index) = lower.find("%~dp0") { let base = path.parent()?; - let suffix = &exe[index + 5..]; + let suffix = &token[index + 5..]; let mut resolved = PathBuf::from(base); for part in suffix.replace('/', "\\").split('\\') { @@ -397,11 +224,9 @@ fn resolve_windows_app_path(app_name: &str) -> Option { if resolved.exists() { return Some(resolved.to_string_lossy().to_string()); } - - continue; } - let resolved = PathBuf::from(expand_env(&exe)); + let resolved = PathBuf::from(token); if resolved.exists() { return Some(resolved.to_string_lossy().to_string()); } @@ -410,130 +235,74 @@ fn resolve_windows_app_path(app_name: &str) -> Option { None }; - let resolve_where = |query: &str| -> Option { - let output = Command::new("where").arg(query).output().ok()?; - if !output.status.success() { - return None; - } - - let paths = String::from_utf8_lossy(&output.stdout) - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(PathBuf::from) - .collect::>(); - - if paths.is_empty() { - return None; - } - - if let Some(path) = paths.iter().find(|path| has_ext(path, "exe")) { - return Some(path.to_string_lossy().to_string()); + for path in &paths { + if has_ext(path, "cmd") || has_ext(path, "bat") { + if let Some(resolved) = resolve_cmd(path) { + return Some(resolved); + } } - for path in &paths { - if has_ext(path, "cmd") || has_ext(path, "bat") { - if let Some(resolved) = resolve_cmd(path) { + if path.extension().is_none() { + let cmd = path.with_extension("cmd"); + if cmd.exists() { + if let Some(resolved) = resolve_cmd(&cmd) { return Some(resolved); } } - if path.extension().is_none() { - let cmd = path.with_extension("cmd"); - if cmd.exists() { - if let Some(resolved) = resolve_cmd(&cmd) { - return Some(resolved); - } - } - - let bat = path.with_extension("bat"); - if bat.exists() { - if let Some(resolved) = resolve_cmd(&bat) { - return Some(resolved); - } + let bat = path.with_extension("bat"); + if bat.exists() { + if let Some(resolved) = resolve_cmd(&bat) { + return Some(resolved); } } } + } - if !key.is_empty() { - for path in &paths { - let dirs = [ - path.parent(), - path.parent().and_then(|dir| dir.parent()), - path.parent() - .and_then(|dir| dir.parent()) - .and_then(|dir| dir.parent()), - ]; - - for dir in dirs.into_iter().flatten() { - if let Ok(entries) = std::fs::read_dir(dir) { - for entry in entries.flatten() { - let candidate = entry.path(); - if !has_ext(&candidate, "exe") { - continue; - } + let key = app_name + .chars() + .filter(|v| v.is_ascii_alphanumeric()) + .flat_map(|v| v.to_lowercase()) + .collect::(); - let Some(stem) = candidate.file_stem().and_then(|v| v.to_str()) else { - continue; - }; + if !key.is_empty() { + for path in &paths { + let dirs = [ + path.parent(), + path.parent().and_then(|dir| dir.parent()), + path.parent() + .and_then(|dir| dir.parent()) + .and_then(|dir| dir.parent()), + ]; + + for dir in dirs.into_iter().flatten() { + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let candidate = entry.path(); + if !has_ext(&candidate, "exe") { + continue; + } - let name = stem - .chars() - .filter(|v| v.is_ascii_alphanumeric()) - .flat_map(|v| v.to_lowercase()) - .collect::(); + let Some(stem) = candidate.file_stem().and_then(|v| v.to_str()) else { + continue; + }; - if name.contains(&key) || key.contains(&name) { - return Some(candidate.to_string_lossy().to_string()); - } + let name = stem + .chars() + .filter(|v| v.is_ascii_alphanumeric()) + .flat_map(|v| v.to_lowercase()) + .collect::(); + + if name.contains(&key) || key.contains(&name) { + return Some(candidate.to_string_lossy().to_string()); } } } } } - - paths.first().map(|path| path.to_string_lossy().to_string()) - }; - - let list = candidates(app_name); - for query in &list { - if let Some(path) = resolve_where(query) { - return Some(path); - } - } - - let mut exes = Vec::::new(); - for query in &list { - let query = query.trim().trim_matches('"'); - if query.is_empty() { - continue; - } - - let name = Path::new(query) - .file_name() - .and_then(|v| v.to_str()) - .unwrap_or(query); - - let exe = if name.to_ascii_lowercase().ends_with(".exe") { - name.to_string() - } else { - format!("{name}.exe") - }; - - if exes.iter().any(|v| v.eq_ignore_ascii_case(&exe)) { - continue; - } - - exes.push(exe); - } - - for exe in exes { - if let Some(path) = reg_app_path(&exe) { - return Some(path); - } } - None + paths.first().map(|path| path.to_string_lossy().to_string()) } #[tauri::command] @@ -620,32 +389,50 @@ fn check_linux_app(app_name: &str) -> bool { return true; } +#[tauri::command] +#[specta::specta] +fn wsl_path(path: String, mode: Option) -> Result { + 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::::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") @@ -712,6 +499,44 @@ pub fn run() { }); } +fn make_specta_builder() -> tauri_specta::Builder { + tauri_specta::Builder::::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) { + 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; diff --git a/packages/desktop/src-tauri/src/server.rs b/packages/desktop/src-tauri/src/server.rs index 81382f100c62..81e0595af714 100644 --- a/packages/desktop/src-tauri/src/server.rs +++ b/packages/desktop/src-tauri/src/server.rs @@ -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, String> { @@ -48,6 +59,38 @@ pub async fn set_default_server_url(app: AppHandle, url: Option) -> Resu Ok(()) } +#[tauri::command] +#[specta::specta] +pub fn get_wsl_config(app: AppHandle) -> Result { + 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 { if let Some(url) = get_default_server_url(app.clone()).ok().flatten() { tracing::info!(%url, "Using desktop-specific custom URL"); diff --git a/packages/desktop/src-tauri/src/windows.rs b/packages/desktop/src-tauri/src/windows.rs index cf3e399e34bf..2ddcb0506d8f 100644 --- a/packages/desktop/src-tauri/src/windows.rs +++ b/packages/desktop/src-tauri/src/windows.rs @@ -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}; "# )); diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index c7bcaba9c638..3d588a17155f 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -10,10 +10,13 @@ export const commands = { awaitInitialization: (events: Channel) => __TAURI_INVOKE("await_initialization", { events }), getDefaultServerUrl: () => __TAURI_INVOKE("get_default_server_url"), setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), + getWslConfig: () => __TAURI_INVOKE("get_wsl_config"), + setWslConfig: (config: WslConfig) => __TAURI_INVOKE("set_wsl_config", { config }), getDisplayBackend: () => __TAURI_INVOKE<"wayland" | "auto" | null>("get_display_backend"), setDisplayBackend: (backend: LinuxDisplayBackend) => __TAURI_INVOKE("set_display_backend", { backend }), parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), checkAppExists: (appName: string) => __TAURI_INVOKE("check_app_exists", { appName }), + wslPath: (path: string, mode: "windows" | "linux" | null) => __TAURI_INVOKE("wsl_path", { path, mode }), resolveAppPath: (appName: string) => __TAURI_INVOKE("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(name: string) { const base = { diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 0e2fcb7feacf..ca603da5f973 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -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): Platform => ({ - platform: "desktop", - os: (() => { +const createPlatform = (password: Accessor): 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 - }, - - 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) - } - return openerOpenPath(path, app) - }, - - back() { - window.history.back() - }, - - forward() { - window.history.forward() - }, - - storage: (() => { - type StoreLike = { - get(key: string): Promise - set(key: string, value: string): Promise - delete(key: string): Promise - clear(): Promise - keys(): Promise - length(): Promise + const wslHome = async () => { + if (os !== "windows" || !window.__OPENCODE__?.wsl) return undefined + return commands.wslPath("~", "windows").catch(() => undefined) + } + + const handleWslPicker = async (result: T | null): Promise => { + 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 commands.wslPath(result, "linux").catch(() => result) as any + } - const WRITE_DEBOUNCE_MS = 250 + return { + platform: "desktop", + os, + version: pkg.version, + + 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) + }, + + async openFilePickerDialog(opts) { + const result = await open({ + directory: false, + multiple: opts?.multiple ?? false, + title: opts?.title ?? t("desktop.dialog.chooseFile"), + }) + return handleWslPicker(result) + }, - const storeCache = new Map>() - const apiCache = new Map Promise }>() - const memoryCache = new Map() + async saveFilePickerDialog(opts) { + const result = await save({ + title: opts?.title ?? t("desktop.dialog.saveFile"), + defaultPath: opts?.defaultPath, + }) + return handleWslPicker(result) + }, + + 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) + }, + + back() { + window.history.back() + }, + + forward() { + window.history.forward() + }, + + storage: (() => { + type StoreLike = { + get(key: string): Promise + set(key: string, value: string): Promise + delete(key: string): Promise + clear(): Promise + keys(): Promise + length(): Promise + } - if ("addEventListener" in globalThis) { - const handleVisibility = () => { - if (document.visibilityState !== "hidden") return - void flushAll() + const WRITE_DEBOUNCE_MS = 250 + + const storeCache = new Map>() + const apiCache = new Map Promise }>() + const memoryCache = new Map() + + const flushAll = async () => { + const apis = Array.from(apiCache.values()) + await Promise.all(apis.map((api) => api.flush().catch(() => undefined))) } - window.addEventListener("pagehide", () => void flushAll()) - document.addEventListener("visibilitychange", handleVisibility) - } + if ("addEventListener" in globalThis) { + const handleVisibility = () => { + if (document.visibilityState !== "hidden") return + void flushAll() + } - const createMemoryStore = () => { - const data = new Map() - 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, + window.addEventListener("pagehide", () => void flushAll()) + document.addEventListener("visibilitychange", handleVisibility) } - return store - } - const getStore = (name: string) => { - const cached = storeCache.get(name) - if (cached) return cached + const createMemoryStore = () => { + const data = new Map() + 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 store = Store.load(name).catch(() => { - const cached = memoryCache.get(name) + const getStore = (name: string) => { + const cached = storeCache.get(name) if (cached) return cached - const memory = createMemoryStore() - memoryCache.set(name, memory) - return memory - }) - - storeCache.set(name, store) - return store - } + const store = Store.load(name).catch(() => { + const cached = memoryCache.get(name) + if (cached) return cached - const createStorage = (name: string) => { - const pending = new Map() - let timer: ReturnType | undefined - let flushing: Promise | undefined + const memory = createMemoryStore() + memoryCache.set(name, memory) + return memory + }) - const flush = async () => { - if (flushing) return flushing + storeCache.set(name, store) + return store + } - 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 createStorage = (name: string) => { + const pending = new Map() + let timer: ReturnType | undefined + let flushing: Promise | 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 - } + return flushing + } - const schedule = () => { - if (timer) return - timer = setTimeout(() => { - timer = undefined - void flush() - }, WRITE_DEBOUNCE_MS) - } + const schedule = () => { + if (timer) return + timer = setTimeout(() => { + timer = undefined + void flush() + }, WRITE_DEBOUNCE_MS) + } - const api: AsyncStorage & { flush: () => Promise } = { - 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() - }, - } + const api: AsyncStorage & { flush: () => Promise } = { + 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 api + } - return (name = "default.dat") => { - const cached = apiCache.get(name) - if (cached) return cached + 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")) + 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() } - notification.close() - } - }) - .catch(() => undefined) - }, + }) + .catch(() => undefined) + }, - fetch: (input, init) => { - const pw = password() + fetch: (input, init) => { + const pw = password() - const addHeader = (headers: Headers, password: string) => { - headers.append("Authorization", `Basic ${btoa(`opencode:${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, + 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((resolve) => { + canvas.toBlob((blob) => { + if (!blob) return resolve(null) + resolve(new File([blob], `pasted-image-${Date.now()}.png`, { type: "image/png" })) + }, "image/png") }) - } - }, - - 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("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((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(null) + const platform = createPlatform(() => serverPassword()) function handleClick(e: MouseEvent) {