use std::time::{Duration, Instant}; use tauri::AppHandle; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult}; use tauri_plugin_shell::process::CommandChild; use tauri_plugin_store::StoreExt; use tokio::task::JoinHandle; use crate::{ cli, constants::{DEFAULT_SERVER_URL_KEY, SETTINGS_STORE}, }; #[tauri::command] #[specta::specta] pub 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))?; let value = store.get(DEFAULT_SERVER_URL_KEY); match value { Some(v) => Ok(v.as_str().map(String::from)), None => Ok(None), } } #[tauri::command] #[specta::specta] pub async fn set_default_server_url(app: AppHandle, url: Option) -> 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(()) } pub async fn get_saved_server_url(app: &tauri::AppHandle) -> Option { if let Some(url) = get_default_server_url(app.clone()).ok().flatten() { println!("Using desktop-specific custom URL: {url}"); return Some(url); } if 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}"); return Some(url); } None } pub fn spawn_local_server( app: AppHandle, hostname: String, port: u32, password: String, ) -> (CommandChild, HealthCheck) { let child = cli::serve(&app, &hostname, port, &password); let health_check = HealthCheck(tokio::spawn(async move { let url = format!("http://{hostname}:{port}"); let timestamp = Instant::now(); loop { tokio::time::sleep(Duration::from_millis(100)).await; if check_health(&url, Some(&password)).await { println!("Server ready after {:?}", timestamp.elapsed()); break; } } })); (child, health_check) } pub struct HealthCheck(pub JoinHandle<()>); pub async fn check_health(url: &str, password: Option<&str>) -> bool { let Ok(url) = reqwest::Url::parse(url) else { return false; }; let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(3)); if url_is_localhost(&url) { // Some environments set proxy variables (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY) without // excluding loopback. reqwest respects these by default, which can prevent the desktop // app from reaching its own local sidecar server. builder = builder.no_proxy(); }; let Ok(client) = builder.build() else { return false; }; let Ok(health_url) = url.join("/global/health") else { return false; }; let mut req = client.get(health_url); if let Some(password) = password { req = req.basic_auth("opencode", Some(password)); } req.send() .await .map(|r| r.status().is_success()) .unwrap_or(false) } fn url_is_localhost(url: &reqwest::Url) -> bool { url.host_str().is_some_and(|host| { host.eq_ignore_ascii_case("localhost") || host .parse::() .is_ok_and(|ip| ip.is_loopback()) }) } /// Converts a bind address hostname to a valid URL hostname for connection. /// - `0.0.0.0` and `::` are wildcard bind addresses, not valid connect targets /// - IPv6 addresses need brackets in URLs (e.g., `::1` -> `[::1]`) fn normalize_hostname_for_url(hostname: &str) -> String { // Wildcard bind addresses -> localhost equivalents if hostname == "0.0.0.0" { return "127.0.0.1".to_string(); } if hostname == "::" { return "[::1]".to_string(); } // IPv6 addresses need brackets in URLs if hostname.contains(':') && !hostname.starts_with('[') { return format!("[{}]", hostname); } hostname.to_string() } 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() .map(|v| normalize_hostname_for_url(v)) .unwrap_or_else(|| "127.0.0.1".to_string()); Some(format!("http://{}:{}", hostname, port)) } pub async fn check_health_or_ask_retry(app: &AppHandle, url: &str) -> bool { println!("Checking health for {url}"); loop { if check_health(url, None).await { return true; } 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; } } } false }