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}
+ >
+ {
+ await platform.setDefaultServerUrl?.(server.url)
+ setDefaultUrl(server.url)
+ }}
+ >
+ Set current server as default
+
+
+ }
+ >
+
+ {serverDisplayName(defaultUrl()!)}
+
+
{
+ await platform.setDefaultServerUrl?.(null)
+ setDefaultUrl(null)
+ }}
+ >
+ Clear
+
+
+
+
+
)
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}\";",
));
}
}