diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 91e4ae39ba2..5dfc59a4e17 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -33,7 +33,7 @@ const Loading = () =>
+ diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index c8eb0846c8d..8033d4f147b 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 8b76d1a7f8a..87ecf4997d0 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, + pub port: Option, +} + +#[derive(serde::Deserialize)] +pub struct Config { + pub server: Option, +} + +pub async fn get_config(app: &AppHandle) -> Option { + 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::(&s).ok()) +} + fn get_cli_install_path() -> Option { 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 5ed03fc66e7..b479ed0b61f 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>>, - status: futures::future::Shared>>, + status: futures::future::Shared>>, } impl ServerState { pub fn new( child: Option, - status: tokio::sync::oneshot::Receiver>, + status: tokio::sync::oneshot::Receiver>, ) -> Self { Self { child: Arc::new(Mutex::new(child)), @@ -85,7 +80,7 @@ async fn get_logs(app: AppHandle) -> Result { } #[tauri::command] -async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), String> { +async fn ensure_server_ready(state: State<'_, ServerState>) -> Result { 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, String> { +fn get_default_server_url(app: AppHandle) -> Result, 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::(); 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 { - 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::().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::().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 { + 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, +) -> Result<(Option, 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 { + 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 ffb178672cb..6393b8b45da 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(() => { )} - + {serverUrl => + + } @@ -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) => JSX.Element }) { + const [serverUrl] = createResource(() => invoke("ensure_server_ready")) return ( // Not using suspense as not all components are compatible with it (undefined refs) -
Starting server...
+
Initializing...
} > - {/* Trigger error boundary without rendering the returned value */} - {(status(), null)} - {props.children} + {serverUrl => props.children(serverUrl)} ) }