From 7a37df9769690bca753c2a61bb30d7ecbe5cb6ee Mon Sep 17 00:00:00 2001 From: Andrew Thal <467872+athal7@users.noreply.github.com> Date: Thu, 8 Jan 2026 11:50:19 -0600 Subject: [PATCH 1/2] feat(desktop): support configuring default server URL Allow desktop app to connect to an existing server instead of always spawning its own sidecar. On startup, if a default URL is configured, the app will attempt to connect to it. If unreachable, shows a dialog with Retry/Start Local options. - Add get_default_server_url/set_default_server_url Tauri commands - Add check_server_health() for remote server validation - Update startup logic to check configured URL before spawning sidecar - Pass serverUrl instead of port to frontend via __OPENCODE__ - Add Platform API methods for frontend access --- .gitignore | 1 + packages/app/src/app.tsx | 5 +- .../src/components/dialog-select-server.tsx | 58 ++++- packages/app/src/context/platform.tsx | 6 + packages/desktop/src-tauri/Cargo.lock | 1 + packages/desktop/src-tauri/Cargo.toml | 1 + packages/desktop/src-tauri/src/lib.rs | 226 ++++++++++++++++-- packages/desktop/src/index.tsx | 9 + 8 files changed, 281 insertions(+), 26 deletions(-) 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, setDefaultUrl] = createSignal(null) + const isDesktop = platform.platform === "desktop" + + onMount(async () => { + if (platform.getDefaultServerUrl) { + const url = await platform.getDefaultServerUrl() + setDefaultUrl(url) + } + }) const items = createMemo(() => { const current = server.url @@ -173,6 +182,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..ec8fc235987 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -20,6 +20,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 +151,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 +291,70 @@ 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) +} + +async fn spawn_and_wait_for_sidecar(app: &AppHandle, port: u32) -> Option { + let child = spawn_sidecar(app, port); + + let timestamp = Instant::now(); + loop { + if timestamp.elapsed() > Duration::from_secs(7) { + let res = app + .dialog() + .message("Failed to spawn OpenCode Server. Copy logs using the button below and send them to the team for assistance.") + .title("Startup Failed") + .buttons(MessageDialogButtons::OkCancelCustom( + "Copy Logs And Exit".to_string(), + "Exit".to_string(), + )) + .blocking_show_with_result(); + + if matches!(&res, MessageDialogResult::Custom(name) if name == "Copy Logs And Exit") { + match copy_logs_to_clipboard(app.clone()).await { + Ok(()) => println!("Logs copied to clipboard successfully"), + Err(e) => println!("Failed to copy logs to clipboard: {}", e), + } + } + + app.exit(1); + return None; + } + + tokio::time::sleep(Duration::from_millis(10)).await; + + 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; + break; + } + } + + println!("Server ready after {:?}", timestamp.elapsed()); + Some(child) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some(); @@ -278,8 +380,12 @@ pub fn run() { .plugin(PinchZoomDisablePlugin) .invoke_handler(tauri::generate_handler![ kill_sidecar, + copy_logs_to_clipboard, + get_logs, 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 +449,115 @@ 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() + )); + } + + tokio::time::sleep(Duration::from_millis(10)).await; - (Some(child), res) + 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() From d332c0dc7942a37f629bbe6923762077710aecb1 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 9 Jan 2026 14:14:24 +0800 Subject: [PATCH 2/2] cleanup --- .../src/components/dialog-select-server.tsx | 15 ++---- packages/desktop/src-tauri/src/lib.rs | 50 ++----------------- 2 files changed, 8 insertions(+), 57 deletions(-) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index a18ddcaf6cb..7e2bcc181ad 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" +import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" @@ -35,16 +35,9 @@ export function DialogSelectServer() { error: "", status: {} as Record, }) - const [defaultUrl, setDefaultUrl] = createSignal(null) + const [defaultUrl, defaultUrlActions] = createResource(() => platform.getDefaultServerUrl?.()) const isDesktop = platform.platform === "desktop" - onMount(async () => { - if (platform.getDefaultServerUrl) { - const url = await platform.getDefaultServerUrl() - setDefaultUrl(url) - } - }) - const items = createMemo(() => { const current = server.url const list = server.list @@ -204,7 +197,7 @@ export function DialogSelectServer() { size="small" onClick={async () => { await platform.setDefaultServerUrl?.(server.url) - setDefaultUrl(server.url) + defaultUrlActions.refetch(server.url) }} > Set current server as default @@ -220,7 +213,7 @@ export function DialogSelectServer() { size="small" onClick={async () => { await platform.setDefaultServerUrl?.(null) - setDefaultUrl(null) + defaultUrlActions.refetch() }} > Clear diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index ec8fc235987..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; @@ -315,46 +316,6 @@ fn get_configured_server_url(app: &AppHandle) -> Option { value.as_str().map(String::from) } -async fn spawn_and_wait_for_sidecar(app: &AppHandle, port: u32) -> Option { - let child = spawn_sidecar(app, port); - - let timestamp = Instant::now(); - loop { - if timestamp.elapsed() > Duration::from_secs(7) { - let res = app - .dialog() - .message("Failed to spawn OpenCode Server. Copy logs using the button below and send them to the team for assistance.") - .title("Startup Failed") - .buttons(MessageDialogButtons::OkCancelCustom( - "Copy Logs And Exit".to_string(), - "Exit".to_string(), - )) - .blocking_show_with_result(); - - if matches!(&res, MessageDialogResult::Custom(name) if name == "Copy Logs And Exit") { - match copy_logs_to_clipboard(app.clone()).await { - Ok(()) => println!("Logs copied to clipboard successfully"), - Err(e) => println!("Failed to copy logs to clipboard: {}", e), - } - } - - app.exit(1); - return None; - } - - tokio::time::sleep(Duration::from_millis(10)).await; - - 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; - break; - } - } - - println!("Server ready after {:?}", timestamp.elapsed()); - Some(child) -} - #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some(); @@ -380,8 +341,6 @@ pub fn run() { .plugin(PinchZoomDisablePlugin) .invoke_handler(tauri::generate_handler![ kill_sidecar, - copy_logs_to_clipboard, - get_logs, install_cli, ensure_server_started, get_default_server_url, @@ -475,7 +434,7 @@ pub fn run() { match res { MessageDialogResult::Custom(name) if name == "Retry" => { continue; - } + }, _ => { should_fallback = true; break; @@ -553,9 +512,8 @@ pub fn run() { // 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 _ = window.eval(format!( + "window.__OPENCODE__.serverUrl = \"{escaped_url}\";", )); } }