diff options
| author | Brendan Allan <[email protected]> | 2026-01-12 17:58:13 +0800 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-01-12 17:58:13 +0800 |
| commit | ebbb4dd88a55a69ea1722eb0622b91d697acf507 (patch) | |
| tree | f58ecab8b64f0a2abc4ce725b0073ab6e42e935d | |
| parent | 087473be6ec4ad21220752de2391e8b882f7058f (diff) | |
| download | opencode-ebbb4dd88a55a69ea1722eb0622b91d697acf507.tar.gz opencode-ebbb4dd88a55a69ea1722eb0622b91d697acf507.zip | |
fix(desktop): improve server detection & connection logic (#7962)
| -rw-r--r-- | packages/app/src/app.tsx | 13 | ||||
| -rw-r--r-- | packages/desktop/src-tauri/Cargo.toml | 2 | ||||
| -rw-r--r-- | packages/desktop/src-tauri/src/cli.rs | 56 | ||||
| -rw-r--r-- | packages/desktop/src-tauri/src/lib.rs | 286 | ||||
| -rw-r--r-- | packages/desktop/src/index.tsx | 21 |
5 files changed, 180 insertions, 198 deletions
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 91e4ae39b..5dfc59a4e 100644 --- a/packages/app/src/app.tsx +++ b/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> diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index c8eb0846c..8033d4f14 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/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 diff --git a/packages/desktop/src-tauri/src/cli.rs b/packages/desktop/src-tauri/src/cli.rs index 8b76d1a7f..87ecf4997 100644 --- a/packages/desktop/src-tauri/src/cli.rs +++ b/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)]) + }; +} diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 5ed03fc66..b479ed0b6 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/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); + } + } +} diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index ffb178672..6393b8b45 100644 --- a/packages/desktop/src/index.tsx +++ b/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> ) } |
