diff options
| author | Brendan Allan <[email protected]> | 2026-02-11 15:49:48 +0800 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-11 07:49:48 +0000 |
| commit | 213a87234dd7579873787070e5f8ad98eb88bce8 (patch) | |
| tree | 1a73c4c52fe0f1431896d83ee6910130db6907ca /packages/desktop/src-tauri/src | |
| parent | d98bd4bd523bd5ceb6411143020713c3ee4d7e7c (diff) | |
| download | opencode-213a87234dd7579873787070e5f8ad98eb88bce8.tar.gz opencode-213a87234dd7579873787070e5f8ad98eb88bce8.zip | |
feat(desktop): add WSL backend mode (#12914)
Diffstat (limited to 'packages/desktop/src-tauri/src')
| -rw-r--r-- | packages/desktop/src-tauri/src/cli.rs | 141 | ||||
| -rw-r--r-- | packages/desktop/src-tauri/src/constants.rs | 1 | ||||
| -rw-r--r-- | packages/desktop/src-tauri/src/lib.rs | 107 | ||||
| -rw-r--r-- | packages/desktop/src-tauri/src/server.rs | 45 | ||||
| -rw-r--r-- | packages/desktop/src-tauri/src/windows.rs | 11 |
5 files changed, 255 insertions, 50 deletions
diff --git a/packages/desktop/src-tauri/src/cli.rs b/packages/desktop/src-tauri/src/cli.rs index 98c38677b..6882d369e 100644 --- a/packages/desktop/src-tauri/src/cli.rs +++ b/packages/desktop/src-tauri/src/cli.rs @@ -3,8 +3,12 @@ use tauri_plugin_shell::{ ShellExt, process::{Command, CommandChild, CommandEvent}, }; +use tauri_plugin_store::StoreExt; -use crate::{LogState, constants::MAX_LOG_ENTRIES}; +use crate::{ + LogState, + constants::{MAX_LOG_ENTRIES, SETTINGS_STORE, WSL_ENABLED_KEY}, +}; const CLI_INSTALL_DIR: &str = ".opencode/bin"; const CLI_BINARY_NAME: &str = "opencode"; @@ -21,7 +25,7 @@ pub struct Config { } pub async fn get_config(app: &AppHandle) -> Option<Config> { - create_command(app, "debug config") + create_command(app, "debug config", &[]) .output() .await .inspect_err(|e| eprintln!("Failed to read OC config: {e}")) @@ -150,25 +154,106 @@ fn get_user_shell() -> String { std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()) } -pub fn create_command(app: &tauri::AppHandle, args: &str) -> Command { +fn is_wsl_enabled(app: &tauri::AppHandle) -> bool { + let Ok(store) = app.store(SETTINGS_STORE) else { + return false; + }; + + store + .get(WSL_ENABLED_KEY) + .as_ref() + .and_then(|value| value.as_bool()) + .unwrap_or(false) +} + +fn shell_escape(input: &str) -> String { + if input.is_empty() { + return "''".to_string(); + } + + let mut escaped = String::from("'"); + escaped.push_str(&input.replace("'", "'\"'\"'")); + escaped.push('\''); + escaped +} + +pub fn create_command(app: &tauri::AppHandle, args: &str, extra_env: &[(&str, String)]) -> Command { let state_dir = app .path() .resolve("", BaseDirectory::AppLocalData) .expect("Failed to resolve app local data dir"); - #[cfg(target_os = "windows")] - return app - .shell() - .sidecar("opencode-cli") - .unwrap() - .args(args.split_whitespace()) - .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true") - .env("OPENCODE_EXPERIMENTAL_FILEWATCHER", "true") - .env("OPENCODE_CLIENT", "desktop") - .env("XDG_STATE_HOME", &state_dir); - - #[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) { + println!("WSL is enabled, spawning CLI server in WSL."); + let version = app.package_info().version.to_string(); + let mut script = vec![ + "set -e".to_string(), + "BIN=\"$HOME/.opencode/bin/opencode\"".to_string(), + "if [ ! -x \"$BIN\" ]; then".to_string(), + format!( + " curl -fsSL https://opencode.ai/install | bash -s -- --version {} --no-modify-path", + shell_escape(&version) + ), + "fi".to_string(), + ]; + + let mut env_prefix = vec![ + "OPENCODE_EXPERIMENTAL_ICON_DISCOVERY=true".to_string(), + "OPENCODE_EXPERIMENTAL_FILEWATCHER=true".to_string(), + "OPENCODE_CLIENT=desktop".to_string(), + "XDG_STATE_HOME=\"$HOME/.local/state\"".to_string(), + ]; + env_prefix.extend( + envs.iter() + .filter(|(key, _)| key != "OPENCODE_EXPERIMENTAL_ICON_DISCOVERY") + .filter(|(key, _)| key != "OPENCODE_EXPERIMENTAL_FILEWATCHER") + .filter(|(key, _)| key != "OPENCODE_CLIENT") + .filter(|(key, _)| key != "XDG_STATE_HOME") + .map(|(key, value)| format!("{}={}", key, shell_escape(value))), + ); + + script.push(format!("{} exec \"$BIN\" {}", env_prefix.join(" "), args)); + + return app + .shell() + .command("wsl") + .args(["-e", "bash", "-lc", &script.join("\n")]); + } else { + let mut cmd = app + .shell() + .sidecar("opencode-cli") + .unwrap() + .args(args.split_whitespace()); + + for (key, value) in envs { + cmd = cmd.env(key, value); + } + + return cmd; + } + } else { let sidecar = get_sidecar_path(app); let shell = get_user_shell(); @@ -178,14 +263,14 @@ pub fn create_command(app: &tauri::AppHandle, args: &str) -> Command { format!("\"{}\" {}", sidecar.display(), args) }; - app.shell() - .command(&shell) - .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true") - .env("OPENCODE_EXPERIMENTAL_FILEWATCHER", "true") - .env("OPENCODE_CLIENT", "desktop") - .env("XDG_STATE_HOME", &state_dir) - .args(["-il", "-c", &cmd]) - }; + let mut cmd = app.shell().command(&shell).args(["-il", "-c", &cmd]); + + for (key, value) in envs { + cmd = cmd.env(key, value); + } + + cmd + } } pub fn serve(app: &AppHandle, hostname: &str, port: u32, password: &str) -> CommandChild { @@ -194,12 +279,16 @@ pub fn serve(app: &AppHandle, hostname: &str, port: u32, password: &str) -> Comm println!("spawning sidecar on port {port}"); + let envs = [ + ("OPENCODE_SERVER_USERNAME", "opencode".to_string()), + ("OPENCODE_SERVER_PASSWORD", password.to_string()), + ]; + let (mut rx, child) = create_command( app, format!("serve --hostname {hostname} --port {port}").as_str(), + &envs, ) - .env("OPENCODE_SERVER_USERNAME", "opencode") - .env("OPENCODE_SERVER_PASSWORD", password) .spawn() .expect("Failed to spawn opencode"); diff --git a/packages/desktop/src-tauri/src/constants.rs b/packages/desktop/src-tauri/src/constants.rs index ac3e1d02a..cdf05fb45 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 const MAX_LOG_ENTRIES: usize = 200; diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 82f0441ad..5c3915e81 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<InitStep>, } @@ -392,32 +399,50 @@ fn check_linux_app(app_name: &str) -> bool { return true; } +#[tauri::command] +#[specta::specta] +fn wsl_path(path: String, mode: Option<WslPathMode>) -> Result<String, String> { + if !cfg(windows) { + return Ok(path); + } + + let flag = match mode.unwrap_or(WslPathMode::Linux) { + WslPathMode::Windows => "-w", + WslPathMode::Linux => "-u", + }; + + let output = if path.starts_with('~') { + let suffix = path.strip_prefix('~').unwrap_or(""); + let escaped = suffix.replace('"', "\\\""); + let cmd = format!("wslpath {flag} \"$HOME{escaped}\""); + Command::new("wsl") + .args(["-e", "sh", "-lc", &cmd]) + .output() + .map_err(|e| format!("Failed to run wslpath: {e}"))? + } else { + Command::new("wsl") + .args(["-e", "wslpath", flag, &path]) + .output() + .map_err(|e| format!("Failed to run wslpath: {e}"))? + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + return Err("wslpath failed".to_string()); + } + return Err(stderr); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - let builder = tauri_specta::Builder::<tauri::Wry>::new() - // Then register them (separated by a comma) - .commands(tauri_specta::collect_commands![ - kill_sidecar, - cli::install_cli, - await_initialization, - server::get_default_server_url, - server::set_default_server_url, - get_display_backend, - set_display_backend, - markdown::parse_markdown_command, - check_app_exists, - resolve_app_path - ]) - .events(tauri_specta::collect_events![LoadingWindowComplete]) - .error_handling(tauri_specta::ErrorHandlingMode::Throw); + let builder = make_specta_builder(); #[cfg(debug_assertions)] // <- Only export on non-release builds - builder - .export( - specta_typescript::Typescript::default(), - "../src/bindings.ts", - ) - .expect("Failed to export typescript bindings"); + export_types(&builder); #[cfg(all(target_os = "macos", not(debug_assertions)))] let _ = std::process::Command::new("killall") @@ -476,6 +501,44 @@ pub fn run() { }); } +fn make_specta_builder() -> tauri_specta::Builder<tauri::Wry> { + tauri_specta::Builder::<tauri::Wry>::new() + // Then register them (separated by a comma) + .commands(tauri_specta::collect_commands![ + kill_sidecar, + cli::install_cli, + await_initialization, + server::get_default_server_url, + server::set_default_server_url, + server::get_wsl_config, + server::set_wsl_config, + get_display_backend, + set_display_backend, + markdown::parse_markdown_command, + check_app_exists, + wsl_path, + resolve_app_path + ]) + .events(tauri_specta::collect_events![LoadingWindowComplete]) + .error_handling(tauri_specta::ErrorHandlingMode::Throw) +} + +fn export_types(builder: &tauri_specta::Builder<tauri::Wry>) { + builder + .export( + specta_typescript::Typescript::default(), + "../src/bindings.ts", + ) + .expect("Failed to export typescript bindings"); +} + +#[cfg(test)] +#[test] +fn test_export_types() { + let builder = make_specta_builder(); + export_types(&builder); +} + #[derive(tauri_specta::Event, serde::Deserialize, specta::Type)] struct LoadingWindowComplete; diff --git a/packages/desktop/src-tauri/src/server.rs b/packages/desktop/src-tauri/src/server.rs index 2a78411a4..8113fc7e3 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<Option<String>, String> { @@ -48,6 +59,38 @@ pub async fn set_default_server_url(app: AppHandle, url: Option<String>) -> Resu Ok(()) } +#[tauri::command] +#[specta::specta] +pub fn get_wsl_config(app: AppHandle) -> Result<WslConfig, String> { + let store = app + .store(SETTINGS_STORE) + .map_err(|e| format!("Failed to open settings store: {}", e))?; + + let enabled = store + .get(WSL_ENABLED_KEY) + .as_ref() + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + Ok(WslConfig { enabled }) +} + +#[tauri::command] +#[specta::specta] +pub fn set_wsl_config(app: AppHandle, config: WslConfig) -> Result<(), String> { + let store = app + .store(SETTINGS_STORE) + .map_err(|e| format!("Failed to open settings store: {}", e))?; + + store.set(WSL_ENABLED_KEY, serde_json::Value::Bool(config.enabled)); + + store + .save() + .map_err(|e| format!("Failed to save settings: {}", e))?; + + Ok(()) +} + pub async fn get_saved_server_url(app: &tauri::AppHandle) -> Option<String> { if let Some(url) = get_default_server_url(app.clone()).ok().flatten() { println!("Using desktop-specific custom URL: {url}"); diff --git a/packages/desktop/src-tauri/src/windows.rs b/packages/desktop/src-tauri/src/windows.rs index cf3e399e3..2ddcb0506 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}; "# )); |
