Просмотр исходного кода

fix(desktop): improve server detection & connection logic (#7962)

Brendan Allan 1 месяц назад
Родитель
Сommit
ebbb4dd88a

+ 6 - 7
packages/app/src/app.tsx

@@ -33,7 +33,7 @@ const Loading = () => <div class="size-full flex items-center justify-center tex
 
 declare global {
   interface Window {
-    __OPENCODE__?: { updaterEnabled?: boolean; port?: number; serverReady?: boolean; serverUrl?: string }
+    __OPENCODE__?: { updaterEnabled?: boolean; }
   }
 }
 
@@ -65,19 +65,18 @@ function ServerKey(props: ParentProps) {
   )
 }
 
-export function AppInterface() {
-  const defaultServerUrl = iife(() => {
+export function AppInterface(props: { defaultUrl?: string }) {
+  const defaultServerUrl = () => {
+    if (props.defaultUrl) return props.defaultUrl;
     if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
-    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"}`
 
     return window.location.origin
-  })
+  };
 
   return (
-    <ServerProvider defaultUrl={defaultServerUrl}>
+    <ServerProvider defaultUrl={defaultServerUrl()}>
       <ServerKey>
         <GlobalSDKProvider>
           <GlobalSyncProvider>

+ 1 - 1
packages/desktop/src-tauri/Cargo.toml

@@ -3,7 +3,7 @@ name = "opencode-desktop"
 version = "0.0.0"
 description = "The open source AI coding agent"
 authors = ["Anomaly Innovations"]
-edition = "2021"
+edition = "2024"
 
 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
 

+ 55 - 1
packages/desktop/src-tauri/src/cli.rs

@@ -1,8 +1,30 @@
-use tauri::Manager;
+use tauri::{path::BaseDirectory, AppHandle, Manager};
+use tauri_plugin_shell::{process::Command, ShellExt};
 
 const CLI_INSTALL_DIR: &str = ".opencode/bin";
 const CLI_BINARY_NAME: &str = "opencode";
 
+#[derive(serde::Deserialize)]
+pub struct ServerConfig {
+    pub hostname: Option<String>,
+    pub port: Option<u32>,
+}
+
+#[derive(serde::Deserialize)]
+pub struct Config {
+    pub server: Option<ServerConfig>,
+}
+
+pub async fn get_config(app: &AppHandle) -> Option<Config> {
+    create_command(app, "debug config")
+        .output()
+        .await
+        .inspect_err(|e| eprintln!("Failed to read OC config: {e}"))
+        .ok()
+        .and_then(|out| String::from_utf8(out.stdout.to_vec()).ok())
+        .and_then(|s| serde_json::from_str::<Config>(&s).ok())
+}
+
 fn get_cli_install_path() -> Option<std::path::PathBuf> {
     std::env::var("HOME").ok().map(|home| {
         std::path::PathBuf::from(home)
@@ -117,3 +139,35 @@ pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> {
 
     Ok(())
 }
+
+fn get_user_shell() -> String {
+    std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
+}
+
+pub fn create_command(app: &tauri::AppHandle, args: &str) -> Command {
+    let state_dir = app
+        .path()
+        .resolve("", BaseDirectory::AppLocalData)
+        .expect("Failed to resolve app local data dir");
+
+    #[cfg(target_os = "windows")]
+    return app
+        .shell()
+        .sidecar("opencode-cli")
+        .unwrap()
+        .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
+        .env("OPENCODE_CLIENT", "desktop")
+        .env("XDG_STATE_HOME", &state_dir);
+
+    #[cfg(not(target_os = "windows"))]
+    return {
+        let sidecar = get_sidecar_path(app);
+        let shell = get_user_shell();
+        app.shell()
+            .command(&shell)
+            .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
+            .env("OPENCODE_CLIENT", "desktop")
+            .env("XDG_STATE_HOME", &state_dir)
+            .args(["-il", "-c", &format!("\"{}\" {}", sidecar.display(), args)])
+    };
+}

+ 109 - 177
packages/desktop/src-tauri/src/lib.rs

@@ -1,23 +1,18 @@
 mod cli;
 mod window_customizer;
 
-use cli::{get_sidecar_path, install_cli, sync_cli};
+use cli::{install_cli, sync_cli};
 use futures::FutureExt;
 use std::{
     collections::VecDeque,
-    net::{SocketAddr, TcpListener},
+    net::TcpListener,
     sync::{Arc, Mutex},
     time::{Duration, Instant},
 };
-use tauri::{
-    path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl,
-    WebviewWindow,
-};
+use tauri::{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;
-use tokio::net::TcpSocket;
 
 use crate::window_customizer::PinchZoomDisablePlugin;
 
@@ -27,13 +22,13 @@ const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl";
 #[derive(Clone)]
 struct ServerState {
     child: Arc<Mutex<Option<CommandChild>>>,
-    status: futures::future::Shared<tokio::sync::oneshot::Receiver<Result<(), String>>>,
+    status: futures::future::Shared<tokio::sync::oneshot::Receiver<Result<String, String>>>,
 }
 
 impl ServerState {
     pub fn new(
         child: Option<CommandChild>,
-        status: tokio::sync::oneshot::Receiver<Result<(), String>>,
+        status: tokio::sync::oneshot::Receiver<Result<String, String>>,
     ) -> Self {
         Self {
             child: Arc::new(Mutex::new(child)),
@@ -85,7 +80,7 @@ async fn get_logs(app: AppHandle) -> Result<String, String> {
 }
 
 #[tauri::command]
-async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), String> {
+async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<String, String> {
     state
         .status
         .clone()
@@ -94,7 +89,7 @@ async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), Stri
 }
 
 #[tauri::command]
-async fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
+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))?;
@@ -142,49 +137,16 @@ fn get_sidecar_port() -> u32 {
         }) as u32
 }
 
-fn get_user_shell() -> String {
-    std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
-}
-
 fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
     let log_state = app.state::<LogState>();
     let log_state_clone = log_state.inner().clone();
 
-    let state_dir = app
-        .path()
-        .resolve("", BaseDirectory::AppLocalData)
-        .expect("Failed to resolve app local data dir");
-
-    #[cfg(target_os = "windows")]
-    let (mut rx, child) = app
-        .shell()
-        .sidecar("opencode-cli")
-        .unwrap()
-        .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
-        .env("OPENCODE_CLIENT", "desktop")
-        .env("XDG_STATE_HOME", &state_dir)
-        .args(["serve", &format!("--port={port}")])
+    println!("spawning sidecar on port {port}");
+
+    let (mut rx, child) = cli::create_command(app, format!("serve --port {port}").as_str())
         .spawn()
         .expect("Failed to spawn opencode");
 
-    #[cfg(not(target_os = "windows"))]
-    let (mut rx, child) = {
-        let sidecar = get_sidecar_path(app);
-        let shell = get_user_shell();
-        app.shell()
-            .command(&shell)
-            .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
-            .env("OPENCODE_CLIENT", "desktop")
-            .env("XDG_STATE_HOME", &state_dir)
-            .args([
-                "-il",
-                "-c",
-                &format!("\"{}\" serve --port={}", sidecar.display(), port),
-            ])
-            .spawn()
-            .expect("Failed to spawn opencode")
-    };
-
     tauri::async_runtime::spawn(async move {
         while let Some(event) = rx.recv().await {
             match event {
@@ -222,17 +184,6 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
     child
 }
 
-async fn is_server_running(port: u32) -> bool {
-    TcpSocket::new_v4()
-        .unwrap()
-        .connect(SocketAddr::new(
-            "127.0.0.1".parse().expect("Failed to parse IP"),
-            port as u16,
-        ))
-        .await
-        .is_ok()
-}
-
 async fn check_server_health(url: &str) -> bool {
     let health_url = format!("{}/health", url.trim_end_matches('/'));
     let client = reqwest::Client::builder()
@@ -251,12 +202,6 @@ async fn check_server_health(url: &str) -> bool {
         .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();
@@ -283,7 +228,7 @@ pub fn run() {
         .invoke_handler(tauri::generate_handler![
             kill_sidecar,
             install_cli,
-            ensure_server_started,
+            ensure_server_ready,
             get_default_server_url,
             set_default_server_url
         ])
@@ -293,15 +238,11 @@ pub fn run() {
             // Initialize log state
             app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
 
-            // Get port and create window immediately for faster perceived startup
-            let port = get_sidecar_port();
-
             let primary_monitor = app.primary_monitor().ok().flatten();
             let size = primary_monitor
                 .map(|m| m.size().to_logical(m.scale_factor()))
                 .unwrap_or(LogicalSize::new(1920, 1080));
 
-            // Create window immediately with serverReady = false
             #[allow(unused_mut)]
             let mut window_builder =
                 WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
@@ -314,7 +255,6 @@ pub fn run() {
                         r#"
                       window.__OPENCODE__ ??= {{}};
                       window.__OPENCODE__.updaterEnabled = {updater_enabled};
-                      window.__OPENCODE__.port = {port};
                     "#
                     ));
 
@@ -325,7 +265,7 @@ pub fn run() {
                     .hidden_title(true);
             }
 
-            let window = window_builder.build().expect("Failed to create window");
+            window_builder.build().expect("Failed to create window");
 
             let (tx, rx) = tokio::sync::oneshot::channel();
             app.manage(ServerState::new(None, rx));
@@ -333,115 +273,28 @@ pub fn run() {
             {
                 let app = app.clone();
                 tauri::async_runtime::spawn(async move {
-                    // Check for configured default server URL
-                    let configured_url = get_configured_server_url(&app);
-
-                    let (child, res, server_url) = if let Some(ref url) = configured_url {
-                        println!("Configured default server URL: {}", url);
-
-                        // Try to connect to the configured server
-                        let mut healthy = false;
-                        let mut should_fallback = false;
-
-                        loop {
-                            if check_server_health(url).await {
-                                healthy = true;
-                                println!("Connected to configured server: {}", url);
-                                break;
-                            }
-
-                            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;
-
-                                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 {
-                        // 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)
-                    };
+                    let mut custom_url = None;
 
-                    app.state::<ServerState>().set_child(child);
+                    if let Some(url) = get_default_server_url(app.clone()).ok().flatten() {
+                        println!("Using desktop-specific custom URL: {url}");
+                        custom_url = Some(url);
+                    }
 
-                    if res.is_ok() {
-                        let _ = window.eval("window.__OPENCODE__.serverReady = true;");
+                    if custom_url.is_none()
+                        && let Some(cli_config) = cli::get_config(&app).await
+                        && let Some(url) = get_server_url_from_config(&cli_config)
+                    {
+                        println!("Using custom server URL from config: {url}");
+                        custom_url = Some(url);
+                    }
 
-                        // 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 res = match setup_server_connection(&app, custom_url).await {
+                        Ok((child, url)) => {
+                            app.state::<ServerState>().set_child(child);
+                            Ok(url)
                         }
-                    }
+                        Err(e) => Err(e),
+                    };
 
                     let _ = tx.send(res);
                 });
@@ -474,3 +327,82 @@ pub fn run() {
             }
         });
 }
+
+fn get_server_url_from_config(config: &cli::Config) -> Option<String> {
+    let server = config.server.as_ref()?;
+    let port = server.port?;
+    println!("server.port found in OC config: {port}");
+    let hostname = server.hostname.as_ref();
+
+    Some(format!(
+        "http://{}:{}",
+        hostname.map(|v| v.as_str()).unwrap_or("127.0.0.1"),
+        port
+    ))
+}
+
+async fn setup_server_connection(
+    app: &AppHandle,
+    custom_url: Option<String>,
+) -> Result<(Option<CommandChild>, String), String> {
+    if let Some(url) = custom_url {
+        loop {
+            if check_server_health(&url).await {
+                println!("Connected to custom server: {}", url);
+                return Ok((None, url.clone()));
+            }
+
+            const RETRY: &str = "Retry";
+
+            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;
+                }
+                _ => {
+                    break;
+                }
+            }
+        }
+    }
+
+    let local_port = get_sidecar_port();
+    let local_url = format!("http://127.0.0.1:{local_port}");
+
+    if !check_server_health(&local_url).await {
+        match spawn_local_server(app, local_port).await {
+            Ok(child) => Ok(Some(child)),
+            Err(err) => Err(err),
+        }
+    } else {
+        Ok(None)
+    }
+    .map(|child| (child, local_url))
+}
+
+async fn spawn_local_server(app: &AppHandle, port: u32) -> Result<CommandChild, String> {
+    let child = spawn_sidecar(app, port);
+    let url = format!("http://127.0.0.1:{port}");
+
+    let timestamp = Instant::now();
+    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 check_server_health(&url).await {
+            println!("Server ready after {:?}", timestamp.elapsed());
+            break Ok(child);
+        }
+    }
+}

+ 9 - 12
packages/desktop/src/index.tsx

@@ -13,7 +13,7 @@ import { AsyncStorage } from "@solid-primitives/storage"
 import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
 import { Store } from "@tauri-apps/plugin-store"
 import { Logo } from "@opencode-ai/ui/logo"
-import { Suspense, createResource, ParentProps } from "solid-js"
+import { Accessor, JSX, createResource } from "solid-js"
 
 import { UPDATER_ENABLED } from "./updater"
 import { createMenu } from "./menu"
@@ -283,7 +283,9 @@ render(() => {
       )}
       <AppBaseProviders>
         <ServerGate>
-          <AppInterface />
+          {serverUrl =>
+            <AppInterface defaultUrl={serverUrl()} />
+          }
         </ServerGate>
       </AppBaseProviders>
     </PlatformProvider>
@@ -291,26 +293,21 @@ render(() => {
 }, root!)
 
 // Gate component that waits for the server to be ready
-function ServerGate(props: ParentProps) {
-  const [status] = createResource(async () => {
-    if (window.__OPENCODE__?.serverReady) return
-    return await invoke("ensure_server_started")
-  })
+function ServerGate(props: { children: (url: Accessor<string>) => JSX.Element }) {
+  const [serverUrl] = createResource<string>(() => invoke("ensure_server_ready"))
 
   return (
     // Not using suspense as not all components are compatible with it (undefined refs)
     <Show
-      when={status.state !== "pending"}
+      when={serverUrl.state !== "pending" && serverUrl()}
       fallback={
         <div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
           <Logo class="w-xl opacity-12 animate-pulse" />
-          <div class="mt-8 text-14-regular text-text-weak">Starting server...</div>
+          <div class="mt-8 text-14-regular text-text-weak">Initializing...</div>
         </div>
       }
     >
-      {/* Trigger error boundary without rendering the returned value */}
-      {(status(), null)}
-      {props.children}
+      {serverUrl => props.children(serverUrl)}
     </Show>
   )
 }