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

This commit is contained in:
Brendan Allan
2026-01-12 17:58:13 +08:00
committed by GitHub
parent 087473be6e
commit ebbb4dd88a
5 changed files with 181 additions and 199 deletions

View File

@@ -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>

View File

@@ -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

View File

@@ -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)])
};
}

View File

@@ -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");
println!("spawning sidecar on port {port}");
#[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}")])
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,116 +273,29 @@ 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 mut custom_url = None;
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)
};
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}\";",
));
}
if let Some(url) = get_default_server_url(app.clone()).ok().flatten() {
println!("Using desktop-specific custom URL: {url}");
custom_url = Some(url);
}
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);
}
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);
}
}
}

View File

@@ -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>
)
}