summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrew Thal <[email protected]>2026-01-10 08:56:48 -0600
committerGitHub <[email protected]>2026-01-10 08:56:48 -0600
commit02b7eb59f8ce9d80a9a80210c0a8ca9de716962e (patch)
treeca46a3c7e598e64c4885cb0ba802e52450572c92
parenta8f23fb54812c4cbd60a8b1057d234200a7f6e02 (diff)
downloadopencode-02b7eb59f8ce9d80a9a80210c0a8ca9de716962e.tar.gz
opencode-02b7eb59f8ce9d80a9a80210c0a8ca9de716962e.zip
feat: support configuring default server URL for desktop (#7363)
-rw-r--r--.gitignore1
-rw-r--r--packages/app/src/app.tsx5
-rw-r--r--packages/app/src/components/dialog-select-server.tsx51
-rw-r--r--packages/app/src/context/platform.tsx6
-rw-r--r--packages/desktop/src-tauri/Cargo.lock1
-rw-r--r--packages/desktop/src-tauri/Cargo.toml1
-rw-r--r--packages/desktop/src-tauri/src/lib.rs184
-rw-r--r--packages/desktop/src/index.tsx9
8 files changed, 232 insertions, 26 deletions
diff --git a/.gitignore b/.gitignore
index fc175568d..75fa054a5 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 dc7b976fc..bf6da3b4d 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -33,13 +33,14 @@ const Loading = () => <div class="size-full flex items-center justify-center tex
declare global {
interface Window {
- __OPENCODE__?: { updaterEnabled?: boolean; port?: number; serverReady?: boolean }
+ __OPENCODE__?: { updaterEnabled?: boolean; port?: number; serverReady?: boolean; serverUrl?: string }
}
}
const defaultServerUrl = iife(() => {
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
- if (window.__OPENCODE__) return `http://127.0.0.1:${window.__OPENCODE__.port}`
+ if (window.__OPENCODE__?.serverUrl) return window.__OPENCODE__.serverUrl
+ if (window.__OPENCODE__?.port) return `http://127.0.0.1:${window.__OPENCODE__.port}`
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx
index 6d224c6c3..7e2bcc181 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, onCleanup } 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,6 +35,8 @@ export function DialogSelectServer() {
error: "",
status: {} as Record<string, ServerStatus | undefined>,
})
+ 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() {
</div>
</form>
</div>
+
+ <Show when={isDesktop}>
+ <div class="mt-6 px-3 flex flex-col gap-1.5">
+ <div class="px-3">
+ <h3 class="text-14-regular text-text-weak">Default server</h3>
+ <p class="text-12-regular text-text-weak mt-1">
+ Connect to this server on app launch instead of starting a local server. Requires restart.
+ </p>
+ </div>
+ <div class="flex items-center gap-2 px-3 py-2">
+ <Show
+ when={defaultUrl()}
+ fallback={
+ <Show
+ when={server.url}
+ fallback={<span class="text-14-regular text-text-weak">No server selected</span>}
+ >
+ <Button
+ variant="secondary"
+ size="small"
+ onClick={async () => {
+ await platform.setDefaultServerUrl?.(server.url)
+ defaultUrlActions.refetch(server.url)
+ }}
+ >
+ Set current server as default
+ </Button>
+ </Show>
+ }
+ >
+ <div class="flex items-center gap-2 flex-1 min-w-0">
+ <span class="truncate text-14-regular">{serverDisplayName(defaultUrl()!)}</span>
+ </div>
+ <Button
+ variant="ghost"
+ size="small"
+ onClick={async () => {
+ await platform.setDefaultServerUrl?.(null)
+ defaultUrlActions.refetch()
+ }}
+ >
+ Clear
+ </Button>
+ </Show>
+ </div>
+ </div>
+ </Show>
</div>
</Dialog>
)
diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx
index 7fcbb620a..b0822e707 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<string | null>
+
+ /** Set the default server URL to use on app startup (desktop only) */
+ setDefaultServerUrl?(url: string | null): Promise<void>
}
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock
index c533bf9e9..92953ea19 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 af8adc119..c8eb0846c 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 bdff73820..3f0131834 100644
--- a/packages/desktop/src-tauri/src/lib.rs
+++ b/packages/desktop/src-tauri/src/lib.rs
@@ -13,12 +13,16 @@ 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 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<Mutex<Option<CommandChild>>>,
@@ -88,6 +92,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<Option<String>, 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<String>) -> 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())
@@ -193,6 +232,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<String> {
+ 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();
@@ -219,7 +282,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();
@@ -266,41 +331,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::<ServerState>().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 941ea8df7..ffb178672 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<string | null>("get_default_server_url").catch(() => null)
+ return result
+ },
+
+ setDefaultServerUrl: async (url: string | null) => {
+ await invoke("set_default_server_url", { url })
+ },
}
createMenu()