Răsfoiți Sursa

feat: support configuring default server URL for desktop (#7363)

Andrew Thal 3 luni în urmă
părinte
comite
02b7eb59f8

+ 1 - 0
.gitignore

@@ -24,3 +24,4 @@ target
 # Local dev files
 opencode-dev
 logs/
+*.bun-build

+ 3 - 2
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"}`
 

+ 50 - 1
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>
   )

+ 6 - 0
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({

+ 1 - 0
packages/desktop/src-tauri/Cargo.lock

@@ -2795,6 +2795,7 @@ dependencies = [
  "futures",
  "gtk",
  "listeners",
+ "reqwest",
  "semver",
  "serde",
  "serde_json",

+ 1 - 0
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"

+ 161 - 23
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);

+ 9 - 0
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()