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