mod cli; mod constants; #[cfg(target_os = "linux")] pub mod linux_display; #[cfg(target_os = "linux")] pub mod linux_windowing; mod logging; mod markdown; mod server; mod window_customizer; mod windows; use crate::cli::CommandChild; use futures::{ FutureExt, TryFutureExt, future::{self, Shared}, }; use std::{ env, net::TcpListener, path::PathBuf, process::Command, sync::{Arc, Mutex}, time::Duration, }; use tauri::{AppHandle, Listener, Manager, RunEvent, State, ipc::Channel}; #[cfg(any(target_os = "linux", all(debug_assertions, windows)))] use tauri_plugin_deep_link::DeepLinkExt; use tauri_specta::Event; use tokio::{ sync::{oneshot, watch}, time::{sleep, timeout}, }; use crate::cli::{sqlite_migration::SqliteMigrationProgress, sync_cli}; use crate::constants::*; use crate::server::get_saved_server_url; use crate::windows::{LoadingWindow, MainWindow}; #[derive(Clone, serde::Serialize, specta::Type, Debug)] struct ServerReadyData { url: String, password: Option, } #[derive(Clone, Copy, serde::Serialize, specta::Type, Debug)] #[serde(tag = "phase", rename_all = "snake_case")] enum InitStep { ServerWaiting, SqliteWaiting, Done, } #[derive(serde::Deserialize, specta::Type)] #[serde(rename_all = "snake_case")] enum WslPathMode { Windows, Linux, } struct InitState { current: watch::Receiver, } #[derive(Clone)] struct ServerState { child: Arc>>, status: future::Shared>>, } impl ServerState { pub fn new( child: Option, status: Shared>>, ) -> Self { Self { child: Arc::new(Mutex::new(child)), status, } } pub fn set_child(&self, child: Option) { *self.child.lock().unwrap() = child; } } #[tauri::command] #[specta::specta] fn kill_sidecar(app: AppHandle) { let Some(server_state) = app.try_state::() else { tracing::info!("Server not running"); return; }; let Some(server_state) = server_state .child .lock() .expect("Failed to acquire mutex lock") .take() else { tracing::info!("Server state missing"); return; }; let _ = server_state.kill(); tracing::info!("Killed server"); } fn get_logs() -> String { logging::tail() } #[tauri::command] #[specta::specta] async fn await_initialization( state: State<'_, ServerState>, init_state: State<'_, InitState>, events: Channel, ) -> Result { let mut rx = init_state.current.clone(); let events = async { let e = *rx.borrow(); let _ = events.send(e); while rx.changed().await.is_ok() { let step = *rx.borrow_and_update(); let _ = events.send(step); if matches!(step, InitStep::Done) { break; } } }; future::join(state.status.clone(), events) .await .0 .map_err(|_| "Failed to get server status".to_string())? } #[tauri::command] #[specta::specta] fn check_app_exists(app_name: &str) -> bool { #[cfg(target_os = "windows")] { check_windows_app(app_name) } #[cfg(target_os = "macos")] { check_macos_app(app_name) } #[cfg(target_os = "linux")] { check_linux_app(app_name) } } #[cfg(target_os = "windows")] fn check_windows_app(_app_name: &str) -> bool { // Check if command exists in PATH, including .exe return true; } #[cfg(target_os = "windows")] fn resolve_windows_app_path(app_name: &str) -> Option { use std::path::{Path, PathBuf}; // Try to find the command using 'where' let output = Command::new("where").arg(app_name).output().ok()?; if !output.status.success() { return None; } let paths = String::from_utf8_lossy(&output.stdout) .lines() .map(str::trim) .filter(|line| !line.is_empty()) .map(PathBuf::from) .collect::>(); let has_ext = |path: &Path, ext: &str| { path.extension() .and_then(|v| v.to_str()) .map(|v| v.eq_ignore_ascii_case(ext)) .unwrap_or(false) }; if let Some(path) = paths.iter().find(|path| has_ext(path, "exe")) { return Some(path.to_string_lossy().to_string()); } let resolve_cmd = |path: &Path| -> Option { let content = std::fs::read_to_string(path).ok()?; for token in content.split('"') { let lower = token.to_ascii_lowercase(); if !lower.contains(".exe") { continue; } if let Some(index) = lower.find("%~dp0") { let base = path.parent()?; let suffix = &token[index + 5..]; let mut resolved = PathBuf::from(base); for part in suffix.replace('/', "\\").split('\\') { if part.is_empty() || part == "." { continue; } if part == ".." { let _ = resolved.pop(); continue; } resolved.push(part); } if resolved.exists() { return Some(resolved.to_string_lossy().to_string()); } } let resolved = PathBuf::from(token); if resolved.exists() { return Some(resolved.to_string_lossy().to_string()); } } None }; for path in &paths { if has_ext(path, "cmd") || has_ext(path, "bat") { if let Some(resolved) = resolve_cmd(path) { return Some(resolved); } } if path.extension().is_none() { let cmd = path.with_extension("cmd"); if cmd.exists() { if let Some(resolved) = resolve_cmd(&cmd) { return Some(resolved); } } let bat = path.with_extension("bat"); if bat.exists() { if let Some(resolved) = resolve_cmd(&bat) { return Some(resolved); } } } } let key = app_name .chars() .filter(|v| v.is_ascii_alphanumeric()) .flat_map(|v| v.to_lowercase()) .collect::(); if !key.is_empty() { for path in &paths { let dirs = [ path.parent(), path.parent().and_then(|dir| dir.parent()), path.parent() .and_then(|dir| dir.parent()) .and_then(|dir| dir.parent()), ]; for dir in dirs.into_iter().flatten() { if let Ok(entries) = std::fs::read_dir(dir) { for entry in entries.flatten() { let candidate = entry.path(); if !has_ext(&candidate, "exe") { continue; } let Some(stem) = candidate.file_stem().and_then(|v| v.to_str()) else { continue; }; let name = stem .chars() .filter(|v| v.is_ascii_alphanumeric()) .flat_map(|v| v.to_lowercase()) .collect::(); if name.contains(&key) || key.contains(&name) { return Some(candidate.to_string_lossy().to_string()); } } } } } } paths.first().map(|path| path.to_string_lossy().to_string()) } #[tauri::command] #[specta::specta] fn resolve_app_path(app_name: &str) -> Option { #[cfg(target_os = "windows")] { resolve_windows_app_path(app_name) } #[cfg(not(target_os = "windows"))] { // On macOS/Linux, just return the app_name as-is since // the opener plugin handles them correctly Some(app_name.to_string()) } } #[cfg(target_os = "macos")] fn check_macos_app(app_name: &str) -> bool { // Check common installation locations let mut app_locations = vec![ format!("/Applications/{}.app", app_name), format!("/System/Applications/{}.app", app_name), ]; if let Ok(home) = std::env::var("HOME") { app_locations.push(format!("{}/Applications/{}.app", home, app_name)); } for location in app_locations { if std::path::Path::new(&location).exists() { return true; } } // Also check if command exists in PATH Command::new("which") .arg(app_name) .output() .map(|output| output.status.success()) .unwrap_or(false) } #[derive(serde::Serialize, serde::Deserialize, specta::Type)] #[serde(rename_all = "camelCase")] pub enum LinuxDisplayBackend { Wayland, Auto, } #[tauri::command] #[specta::specta] fn get_display_backend() -> Option { #[cfg(target_os = "linux")] { let prefer = linux_display::read_wayland().unwrap_or(false); return Some(if prefer { LinuxDisplayBackend::Wayland } else { LinuxDisplayBackend::Auto }); } #[cfg(not(target_os = "linux"))] None } #[tauri::command] #[specta::specta] fn set_display_backend(_app: AppHandle, _backend: LinuxDisplayBackend) -> Result<(), String> { #[cfg(target_os = "linux")] { let prefer = matches!(_backend, LinuxDisplayBackend::Wayland); return linux_display::write_wayland(&_app, prefer); } #[cfg(not(target_os = "linux"))] Ok(()) } #[cfg(target_os = "linux")] fn check_linux_app(app_name: &str) -> bool { return true; } #[tauri::command] #[specta::specta] fn wsl_path(path: String, mode: Option) -> Result { if !cfg!(windows) { return Ok(path); } let flag = match mode.unwrap_or(WslPathMode::Linux) { WslPathMode::Windows => "-w", WslPathMode::Linux => "-u", }; let output = if path.starts_with('~') { let suffix = path.strip_prefix('~').unwrap_or(""); let escaped = suffix.replace('"', "\\\""); let cmd = format!("wslpath {flag} \"$HOME{escaped}\""); Command::new("wsl") .args(["-e", "sh", "-lc", &cmd]) .output() .map_err(|e| format!("Failed to run wslpath: {e}"))? } else { Command::new("wsl") .args(["-e", "wslpath", flag, &path]) .output() .map_err(|e| format!("Failed to run wslpath: {e}"))? }; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); if stderr.is_empty() { return Err("wslpath failed".to_string()); } return Err(stderr); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let builder = make_specta_builder(); #[cfg(debug_assertions)] // <- Only export on non-release builds export_types(&builder); #[cfg(all(target_os = "macos", not(debug_assertions)))] let _ = std::process::Command::new("killall") .arg("opencode-cli") .output(); let mut builder = tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { // Focus existing window when another instance is launched if let Some(window) = app.get_webview_window(MainWindow::LABEL) { let _ = window.set_focus(); let _ = window.unminimize(); } })) .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_os::init()) .plugin( tauri_plugin_window_state::Builder::new() .with_state_flags(window_state_flags()) .with_denylist(&[LoadingWindow::LABEL]) .build(), ) .plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_notification::init()) .plugin(crate::window_customizer::PinchZoomDisablePlugin) .plugin(tauri_plugin_decorum::init()) .invoke_handler(builder.invoke_handler()) .setup(move |app| { let handle = app.handle().clone(); let log_dir = app .path() .app_log_dir() .expect("failed to resolve app log dir"); // Hold the guard in managed state so it lives for the app's lifetime, // ensuring all buffered logs are flushed on shutdown. handle.manage(logging::init(&log_dir)); builder.mount_events(&handle); tauri::async_runtime::spawn(initialize(handle)); Ok(()) }); if UPDATER_ENABLED { builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); } builder .build(tauri::generate_context!()) .expect("error while running tauri application") .run(|app, event| { if let RunEvent::Exit = event { tracing::info!("Received Exit"); kill_sidecar(app.clone()); } }); } fn make_specta_builder() -> tauri_specta::Builder { tauri_specta::Builder::::new() // Then register them (separated by a comma) .commands(tauri_specta::collect_commands![ kill_sidecar, cli::install_cli, await_initialization, server::get_default_server_url, server::set_default_server_url, server::get_wsl_config, server::set_wsl_config, get_display_backend, set_display_backend, markdown::parse_markdown_command, check_app_exists, wsl_path, resolve_app_path ]) .events(tauri_specta::collect_events![ LoadingWindowComplete, SqliteMigrationProgress ]) .error_handling(tauri_specta::ErrorHandlingMode::Throw) } fn export_types(builder: &tauri_specta::Builder) { builder .export( specta_typescript::Typescript::default(), "../src/bindings.ts", ) .expect("Failed to export typescript bindings"); } #[cfg(test)] #[test] fn test_export_types() { let builder = make_specta_builder(); export_types(&builder); } #[derive(tauri_specta::Event, serde::Deserialize, specta::Type)] struct LoadingWindowComplete; async fn initialize(app: AppHandle) { tracing::info!("Initializing app"); let (init_tx, init_rx) = watch::channel(InitStep::ServerWaiting); setup_app(&app, init_rx); spawn_cli_sync_task(app.clone()); let (server_ready_tx, server_ready_rx) = oneshot::channel(); let server_ready_rx = server_ready_rx.shared(); app.manage(ServerState::new(None, server_ready_rx.clone())); let loading_window_complete = event_once_fut::(&app); tracing::info!("Main and loading windows created"); // SQLite migration handling: // We only do this if the sqlite db doesn't exist, and we're expecting the sidecar to create it // First, we spawn a task that listens for SqliteMigrationProgress events that can // come from any invocation of the sidecar CLI. The progress is captured by a stdout stream interceptor. // Then in the loading task, we wait for sqlite migration to complete before // starting our health check against the server, otherwise long migrations could result in a timeout. let needs_sqlite_migration = !sqlite_file_exists(); let sqlite_done = needs_sqlite_migration.then(|| { tracing::info!( path = %opencode_db_path().expect("failed to get db path").display(), "Sqlite file not found, waiting for it to be generated" ); let (done_tx, done_rx) = oneshot::channel::<()>(); let done_tx = Arc::new(Mutex::new(Some(done_tx))); let init_tx = init_tx.clone(); let id = SqliteMigrationProgress::listen(&app, move |e| { let _ = init_tx.send(InitStep::SqliteWaiting); if matches!(e.payload, SqliteMigrationProgress::Done) && let Some(done_tx) = done_tx.lock().unwrap().take() { let _ = done_tx.send(()); } }); let app = app.clone(); tokio::spawn(done_rx.map(async move |_| { app.unlisten(id); })) }); let loading_task = tokio::spawn({ let app = app.clone(); async move { tracing::info!("Setting up server connection"); let server_connection = setup_server_connection(app.clone()).await; tracing::info!("Server connection setup"); // we delay spawning this future so that the timeout is created lazily let cli_health_check = match server_connection { ServerConnection::CLI { child, health_check, url, password, } => { let app = app.clone(); Some( async move { let res = timeout(Duration::from_secs(30), health_check.0).await; let err = match res { Ok(Ok(Ok(()))) => None, Ok(Ok(Err(e))) => Some(e), Ok(Err(e)) => Some(format!("Health check task failed: {e}")), Err(_) => Some("Health check timed out".to_string()), }; if let Some(err) = err { let _ = child.kill(); return Err(format!( "Failed to spawn OpenCode Server ({err}). Logs:\n{}", get_logs() )); } tracing::info!("CLI health check OK"); app.state::().set_child(Some(child)); Ok(ServerReadyData { url, password }) } .map(move |res| { let _ = server_ready_tx.send(res); }), ) } ServerConnection::Existing { url } => { let _ = server_ready_tx.send(Ok(ServerReadyData { url: url.to_string(), password: None, })); None } }; tracing::info!("server connection started"); if let Some(cli_health_check) = cli_health_check { if let Some(sqlite_done_rx) = sqlite_done { let _ = sqlite_done_rx.await; } tokio::spawn(cli_health_check); } let _ = server_ready_rx.await; tracing::info!("Loading task finished"); } }) .map_err(|_| ()) .shared(); let loading_window = if needs_sqlite_migration && timeout(Duration::from_secs(1), loading_task.clone()) .await .is_err() { tracing::debug!("Loading task timed out, showing loading window"); let loading_window = LoadingWindow::create(&app).expect("Failed to create loading window"); sleep(Duration::from_secs(1)).await; Some(loading_window) } else { tracing::debug!("Showing main window without loading window"); MainWindow::create(&app).expect("Failed to create main window"); None }; let _ = loading_task.await; tracing::info!("Loading done, completing initialisation"); let _ = init_tx.send(InitStep::Done); if loading_window.is_some() { loading_window_complete.await; tracing::info!("Loading window completed"); } MainWindow::create(&app).expect("Failed to create main window"); if let Some(loading_window) = loading_window { let _ = loading_window.close(); } } fn setup_app(app: &tauri::AppHandle, init_rx: watch::Receiver) { #[cfg(any(target_os = "linux", all(debug_assertions, windows)))] app.deep_link().register_all().ok(); app.manage(InitState { current: init_rx }); } fn spawn_cli_sync_task(app: AppHandle) { tokio::spawn(async move { if let Err(e) = sync_cli(app) { tracing::error!("Failed to sync CLI: {e}"); } }); } enum ServerConnection { Existing { url: String, }, CLI { url: String, password: Option, child: CommandChild, health_check: server::HealthCheck, }, } async fn setup_server_connection(app: AppHandle) -> ServerConnection { let custom_url = get_saved_server_url(&app).await; tracing::info!(?custom_url, "Attempting server connection"); if let Some(url) = custom_url && server::check_health_or_ask_retry(&app, &url).await { tracing::info!(%url, "Connected to custom server"); return ServerConnection::Existing { url: url.clone() }; } let local_port = get_sidecar_port(); let hostname = "127.0.0.1"; let local_url = format!("http://{hostname}:{local_port}"); tracing::debug!(url = %local_url, "Checking health of local server"); if server::check_health(&local_url, None).await { tracing::info!(url = %local_url, "Health check OK, using existing server"); return ServerConnection::Existing { url: local_url }; } let password = uuid::Uuid::new_v4().to_string(); tracing::info!("Spawning new local server"); let (child, health_check) = server::spawn_local_server(app, hostname.to_string(), local_port, password.clone()); ServerConnection::CLI { url: local_url, password: Some(password), child, health_check, } } fn get_sidecar_port() -> u32 { option_env!("OPENCODE_PORT") .map(|s| s.to_string()) .or_else(|| std::env::var("OPENCODE_PORT").ok()) .and_then(|port_str| port_str.parse().ok()) .unwrap_or_else(|| { TcpListener::bind("127.0.0.1:0") .expect("Failed to bind to find free port") .local_addr() .expect("Failed to get local address") .port() }) as u32 } fn sqlite_file_exists() -> bool { let Ok(path) = opencode_db_path() else { return true; }; path.exists() } fn opencode_db_path() -> Result { let xdg_data_home = env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty()); let data_home = match xdg_data_home { Some(v) => PathBuf::from(v), None => { let home = dirs::home_dir().ok_or("cannot determine home directory")?; home.join(".local").join("share") } }; Ok(data_home.join("opencode").join("opencode.db")) } // Creates a `once` listener for the specified event and returns a future that resolves // when the listener is fired. // Since the future creation and awaiting can be done separately, it's possible to create the listener // synchronously before doing something, then awaiting afterwards. fn event_once_fut( app: &AppHandle, ) -> impl Future { let (tx, rx) = oneshot::channel(); T::once(app, |_| { let _ = tx.send(()); }); async { let _ = rx.await; } }