mod cli; mod constants; #[cfg(windows)] mod job_object; mod markdown; mod server; mod window_customizer; mod windows; use futures::{ FutureExt, TryFutureExt, future::{self, Shared}, }; #[cfg(windows)] use job_object::*; use std::{ collections::VecDeque, env, net::TcpListener, path::PathBuf, sync::{Arc, Mutex}, time::Duration, process::Command, }; use tauri::{AppHandle, Manager, RunEvent, State, ipc::Channel}; #[cfg(any(target_os = "linux", all(debug_assertions, windows)))] use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_shell::process::CommandChild; use tokio::{ sync::{oneshot, watch}, time::{sleep, timeout}, }; use crate::cli::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, } 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; } } #[derive(Clone)] struct LogState(Arc>>); #[tauri::command] #[specta::specta] fn kill_sidecar(app: AppHandle) { let Some(server_state) = app.try_state::() else { println!("Server not running"); return; }; let Some(server_state) = server_state .child .lock() .expect("Failed to acquire mutex lock") .take() else { println!("Server state missing"); return; }; let _ = server_state.kill(); println!("Killed server"); } async fn get_logs(app: AppHandle) -> Result { let log_state = app.try_state::().ok_or("Log state not found")?; let logs = log_state .0 .lock() .map_err(|_| "Failed to acquire log lock")?; Ok(logs.iter().cloned().collect::>().join("")) } #[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()).clone(); let _ = events.send(e).unwrap(); 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 = "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) } #[cfg(target_os = "linux")] fn check_linux_app(app_name: &str) -> bool { return true; } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let 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, markdown::parse_markdown_command, check_app_exists ]) .events(tauri_specta::collect_events![LoadingWindowComplete]) .error_handling(tauri_specta::ErrorHandlingMode::Throw); #[cfg(debug_assertions)] // <- Only export on non-release builds builder .export( specta_typescript::Typescript::default(), "../src/bindings.ts", ) .expect("Failed to export typescript bindings"); #[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 app = app.handle().clone(); builder.mount_events(&app); tauri::async_runtime::spawn(initialize(app)); 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 { println!("Received Exit"); kill_sidecar(app.clone()); } }); } #[derive(tauri_specta::Event, serde::Deserialize, specta::Type)] struct LoadingWindowComplete; // #[tracing::instrument(skip_all)] async fn initialize(app: AppHandle) { println!("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); println!("Main and loading windows created"); let sqlite_enabled = option_env!("OPENCODE_SQLITE").is_some(); let loading_task = tokio::spawn({ let init_tx = init_tx.clone(); let app = app.clone(); async move { let mut sqlite_exists = sqlite_file_exists(); println!("Setting up server connection"); let server_connection = setup_server_connection(app.clone()).await; // 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 Ok(Ok(_)) = timeout(Duration::from_secs(30), health_check.0).await else { let _ = child.kill(); return Err(format!( "Failed to spawn OpenCode Server. Logs:\n{}", get_logs(app.clone()).await.unwrap() )); }; println!("CLI health check OK"); #[cfg(windows)] { let job_state = app.state::(); job_state.assign_pid(child.pid()); } 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 } }; if let Some(cli_health_check) = cli_health_check { if sqlite_enabled { println!("Does sqlite file exist: {sqlite_exists}"); if !sqlite_exists { println!( "Sqlite file not found at {}, waiting for it to be generated", opencode_db_path().expect("failed to get db path").display() ); let _ = init_tx.send(InitStep::SqliteWaiting); while !sqlite_exists { sleep(Duration::from_secs(1)).await; sqlite_exists = sqlite_file_exists(); } } } tokio::spawn(cli_health_check); } let _ = server_ready_rx.await; } }) .map_err(|_| ()) .shared(); let loading_window = if sqlite_enabled && timeout(Duration::from_secs(1), loading_task.clone()) .await .is_err() { println!("Loading task timed out, showing loading window"); let app = app.clone(); let loading_window = LoadingWindow::create(&app).expect("Failed to create loading window"); sleep(Duration::from_secs(1)).await; Some(loading_window) } else { MainWindow::create(&app).expect("Failed to create main window"); None }; let _ = loading_task.await; println!("Loading done, completing initialisation"); let _ = init_tx.send(InitStep::Done); if loading_window.is_some() { loading_window_complete.await; println!("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(); // Initialize log state app.manage(LogState(Arc::new(Mutex::new(VecDeque::new())))); #[cfg(windows)] app.manage(JobObjectState::new()); app.manage(InitState { current: init_rx }); } fn spawn_cli_sync_task(app: AppHandle) { tokio::spawn(async move { if let Err(e) = sync_cli(app) { eprintln!("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; println!("Attempting server connection to custom url: {custom_url:?}"); if let Some(url) = custom_url && server::check_health_or_ask_retry(&app, &url).await { println!("Connected to custom server: {}", url); 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}"); println!("Checking health of server '{}'", local_url); if server::check_health(&local_url, None).await { println!("Health check OK, using existing server"); return ServerConnection::Existing { url: local_url }; } let password = uuid::Uuid::new_v4().to_string(); println!("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; } }