diff --git a/.gitignore b/.gitignore index fc175568df6..75fa054a5e4 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ target # Local dev files opencode-dev logs/ +*.bun-build diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 13d9d147e25..3504e00acb4 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -33,7 +33,7 @@ const Loading = () =>
, }) + const [defaultUrl, defaultUrlActions] = createResource(() => platform.getDefaultServerUrl?.()) + const isDesktop = platform.platform === "desktop" const items = createMemo(() => { const current = server.url @@ -173,6 +175,53 @@ export function DialogSelectServer() {
+ + +
+
+

Default server

+

+ Connect to this server on app launch instead of starting a local server. Requires restart. +

+
+
+ No server selected} + > + + + } + > +
+ {serverDisplayName(defaultUrl()!)} +
+ + +
+
+
) diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 7fcbb620ac1..b0822e70787 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -37,6 +37,12 @@ export type Platform = { /** Fetch override */ fetch?: typeof fetch + + /** Get the configured default server URL (desktop only) */ + getDefaultServerUrl?(): Promise + + /** Set the default server URL to use on app startup (desktop only) */ + setDefaultServerUrl?(url: string | null): Promise } export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({ diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index c533bf9e95d..92953ea19ca 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2795,6 +2795,7 @@ dependencies = [ "futures", "gtk", "listeners", + "reqwest", "semver", "serde", "serde_json", diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index af8adc1197a..c8eb0846c8d 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -38,6 +38,7 @@ listeners = "0.3" tauri-plugin-os = "2" futures = "0.3.31" semver = "1.0.27" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } [target.'cfg(target_os = "linux")'.dependencies] gtk = "0.18.2" diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index c31c0df3530..40297d6536a 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ use tauri::{ path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl, WebviewWindow, }; +use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult}; use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use tauri_plugin_shell::ShellExt; use tauri_plugin_store::StoreExt; @@ -20,6 +21,9 @@ use tokio::net::TcpSocket; use crate::window_customizer::PinchZoomDisablePlugin; +const SETTINGS_STORE: &str = "opencode.settings.dat"; +const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl"; + #[derive(Clone)] struct ServerState { child: Arc>>, @@ -148,6 +152,41 @@ async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), Stri .map_err(|_| "Failed to get server status".to_string())? } +#[tauri::command] +async fn get_default_server_url(app: AppHandle) -> Result, String> { + let store = app + .store(SETTINGS_STORE) + .map_err(|e| format!("Failed to open settings store: {}", e))?; + + let value = store.get(DEFAULT_SERVER_URL_KEY); + match value { + Some(v) => Ok(v.as_str().map(String::from)), + None => Ok(None), + } +} + +#[tauri::command] +async fn set_default_server_url(app: AppHandle, url: Option) -> Result<(), String> { + let store = app + .store(SETTINGS_STORE) + .map_err(|e| format!("Failed to open settings store: {}", e))?; + + match url { + Some(u) => { + store.set(DEFAULT_SERVER_URL_KEY, serde_json::Value::String(u)); + } + None => { + store.delete(DEFAULT_SERVER_URL_KEY); + } + } + + store + .save() + .map_err(|e| format!("Failed to save settings: {}", e))?; + + Ok(()) +} + fn get_sidecar_port() -> u32 { option_env!("OPENCODE_PORT") .map(|s| s.to_string()) @@ -253,6 +292,30 @@ async fn is_server_running(port: u32) -> bool { .is_ok() } +async fn check_server_health(url: &str) -> bool { + let health_url = format!("{}/health", url.trim_end_matches('/')); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(3)) + .build(); + + let Ok(client) = client else { + return false; + }; + + client + .get(&health_url) + .send() + .await + .map(|r| r.status().is_success()) + .unwrap_or(false) +} + +fn get_configured_server_url(app: &AppHandle) -> Option { + let store = app.store(SETTINGS_STORE).ok()?; + let value = store.get(DEFAULT_SERVER_URL_KEY)?; + value.as_str().map(String::from) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some(); @@ -279,7 +342,9 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ kill_sidecar, install_cli, - ensure_server_started + ensure_server_started, + get_default_server_url, + set_default_server_url ]) .setup(move |app| { let app = app.handle().clone(); @@ -343,41 +408,114 @@ pub fn run() { { let app = app.clone(); tauri::async_runtime::spawn(async move { - let should_spawn_sidecar = !is_server_running(port).await; - - let (child, res) = if should_spawn_sidecar { - let child = spawn_sidecar(&app, port); - - let timestamp = Instant::now(); - let res = loop { - if timestamp.elapsed() > Duration::from_secs(7) { - break Err(format!( - "Failed to spawn OpenCode Server. Logs:\n{}", - get_logs(app.clone()).await.unwrap() - )); - } + // Check for configured default server URL + let configured_url = get_configured_server_url(&app); - tokio::time::sleep(Duration::from_millis(10)).await; + let (child, res, server_url) = if let Some(ref url) = configured_url { + println!("Configured default server URL: {}", url); - if is_server_running(port).await { - // give the server a little bit more time to warm up - tokio::time::sleep(Duration::from_millis(10)).await; + // Try to connect to the configured server + let mut healthy = false; + let mut should_fallback = false; - break Ok(()); + loop { + if check_server_health(url).await { + healthy = true; + println!("Connected to configured server: {}", url); + break; } - }; - println!("Server ready after {:?}", timestamp.elapsed()); + let res = app.dialog() + .message(format!("Could not connect to configured server:\n{}\n\nWould you like to retry or start a local server instead?", url)) + .title("Connection Failed") + .buttons(MessageDialogButtons::OkCancelCustom("Retry".to_string(), "Start Local".to_string())) + .blocking_show_with_result(); + + match res { + MessageDialogResult::Custom(name) if name == "Retry" => { + continue; + }, + _ => { + should_fallback = true; + break; + } + } + } + + if healthy { + (None, Ok(()), Some(url.clone())) + } else if should_fallback { + // Fall back to spawning local sidecar + let child = spawn_sidecar(&app, port); + + let timestamp = Instant::now(); + let res = loop { + if timestamp.elapsed() > Duration::from_secs(7) { + break Err(format!( + "Failed to spawn OpenCode Server. Logs:\n{}", + get_logs(app.clone()).await.unwrap() + )); + } - (Some(child), res) + tokio::time::sleep(Duration::from_millis(10)).await; + + if is_server_running(port).await { + tokio::time::sleep(Duration::from_millis(10)).await; + break Ok(()); + } + }; + + println!("Server ready after {:?}", timestamp.elapsed()); + (Some(child), res, None) + } else { + (None, Err("User cancelled".to_string()), None) + } } else { - (None, Ok(())) + // No configured URL, spawn local sidecar as before + let should_spawn_sidecar = !is_server_running(port).await; + + let (child, res) = if should_spawn_sidecar { + let child = spawn_sidecar(&app, port); + + let timestamp = Instant::now(); + let res = loop { + if timestamp.elapsed() > Duration::from_secs(7) { + break Err(format!( + "Failed to spawn OpenCode Server. Logs:\n{}", + get_logs(app.clone()).await.unwrap() + )); + } + + tokio::time::sleep(Duration::from_millis(10)).await; + + if is_server_running(port).await { + tokio::time::sleep(Duration::from_millis(10)).await; + break Ok(()); + } + }; + + println!("Server ready after {:?}", timestamp.elapsed()); + + (Some(child), res) + } else { + (None, Ok(())) + }; + + (child, res, None) }; app.state::().set_child(child); if res.is_ok() { let _ = window.eval("window.__OPENCODE__.serverReady = true;"); + + // If using a configured server URL, inject it + if let Some(url) = server_url { + let escaped_url = url.replace('\\', "\\\\").replace('"', "\\\""); + let _ = window.eval(format!( + "window.__OPENCODE__.serverUrl = \"{escaped_url}\";", + )); + } } let _ = tx.send(res); diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 941ea8df707..ffb178672cb 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -257,6 +257,15 @@ const platform: Platform = { // @ts-expect-error fetch: tauriFetch, + + getDefaultServerUrl: async () => { + const result = await invoke("get_default_server_url").catch(() => null) + return result + }, + + setDefaultServerUrl: async (url: string | null) => { + await invoke("set_default_server_url", { url }) + }, } createMenu()