| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601 |
- 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 os;
- mod server;
- mod window_customizer;
- mod windows;
- use crate::cli::CommandChild;
- use futures::{FutureExt, TryFutureExt};
- use std::{
- env,
- future::Future,
- 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::windows::{LoadingWindow, MainWindow};
- #[derive(Clone, serde::Serialize, specta::Type, Debug)]
- struct ServerReadyData {
- url: String,
- username: Option<String>,
- password: Option<String>,
- }
- #[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<InitStep>,
- }
- struct ServerState {
- child: Arc<Mutex<Option<CommandChild>>>,
- }
- /// Resolves with sidecar credentials as soon as the sidecar is spawned (before health check).
- struct SidecarReady(futures::future::Shared<oneshot::Receiver<ServerReadyData>>);
- #[tauri::command]
- #[specta::specta]
- fn kill_sidecar(app: AppHandle) {
- let Some(server_state) = app.try_state::<ServerState>() 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");
- }
- #[tauri::command]
- #[specta::specta]
- async fn await_initialization(
- state: State<'_, SidecarReady>,
- init_state: State<'_, InitState>,
- events: Channel<InitStep>,
- ) -> Result<ServerReadyData, String> {
- let mut rx = init_state.current.clone();
- let stream = 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;
- }
- }
- };
- // Wait for sidecar credentials (available immediately after spawn, before health check)
- let data = async {
- state
- .inner()
- .0
- .clone()
- .await
- .map_err(|_| "Failed to get sidecar data".to_string())
- };
- let (result, _) = futures::future::join(data, stream).await;
- result
- }
- #[tauri::command]
- #[specta::specta]
- fn check_app_exists(app_name: &str) -> bool {
- #[cfg(target_os = "windows")]
- {
- 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)
- }
- }
- #[tauri::command]
- #[specta::specta]
- fn resolve_app_path(app_name: &str) -> Option<String> {
- #[cfg(target_os = "windows")]
- {
- 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())
- }
- }
- #[tauri::command]
- #[specta::specta]
- fn open_path(_app: AppHandle, path: String, app_name: Option<String>) -> Result<(), String> {
- #[cfg(target_os = "windows")]
- {
- let app_name = app_name.map(|v| os::windows::resolve_windows_app_path(&v).unwrap_or(v));
- let is_powershell = app_name.as_ref().is_some_and(|v| {
- std::path::Path::new(v)
- .file_name()
- .and_then(|name| name.to_str())
- .is_some_and(|name| {
- name.eq_ignore_ascii_case("powershell")
- || name.eq_ignore_ascii_case("powershell.exe")
- })
- });
- if is_powershell {
- return os::windows::open_in_powershell(path);
- }
- return tauri_plugin_opener::open_path(path, app_name.as_deref())
- .map_err(|e| format!("Failed to open path: {e}"));
- }
- #[cfg(not(target_os = "windows"))]
- tauri_plugin_opener::open_path(path, app_name.as_deref())
- .map_err(|e| format!("Failed to open path: {e}"))
- }
- #[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<LinuxDisplayBackend> {
- #[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<WslPathMode>) -> Result<String, String> {
- 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::Wry> {
- tauri_specta::Builder::<tauri::Wry>::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,
- open_path
- ])
- .events(tauri_specta::collect_events![
- LoadingWindowComplete,
- SqliteMigrationProgress
- ])
- .error_handling(tauri_specta::ErrorHandlingMode::Throw)
- }
- fn export_types(builder: &tauri_specta::Builder<tauri::Wry>) {
- 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());
- // Spawn sidecar immediately - credentials are known before health check
- let port = get_sidecar_port();
- let hostname = "127.0.0.1";
- let url = format!("http://{hostname}:{port}");
- let password = uuid::Uuid::new_v4().to_string();
- tracing::info!("Spawning sidecar on {url}");
- let (child, health_check) =
- server::spawn_local_server(app.clone(), hostname.to_string(), port, password.clone());
- // Make sidecar credentials available immediately (before health check completes)
- let (ready_tx, ready_rx) = oneshot::channel();
- let _ = ready_tx.send(ServerReadyData {
- url: url.clone(),
- username: Some("opencode".to_string()),
- password: Some(password),
- });
- app.manage(SidecarReady(ready_rx.shared()));
- app.manage(ServerState {
- child: Arc::new(Mutex::new(Some(child))),
- });
- let loading_window_complete = event_once_fut::<LoadingWindowComplete>(&app);
- // SQLite migration handling:
- // We only do this if the sqlite db doesn't exist, and we're expecting the sidecar to create it.
- // A separate loading window is shown for long migrations.
- let needs_migration = !sqlite_file_exists();
- let sqlite_done = needs_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);
- }))
- });
- // The loading task waits for SQLite migration (if needed) then for the sidecar health check.
- // This is only used to drive the loading window progress - the main window is shown immediately.
- let loading_task = tokio::spawn({
- async move {
- if let Some(sqlite_done_rx) = sqlite_done {
- let _ = sqlite_done_rx.await;
- }
- // Wait for sidecar to become healthy (for loading window progress)
- let res = timeout(Duration::from_secs(30), health_check.0).await;
- match res {
- Ok(Ok(Ok(()))) => tracing::info!("Sidecar health check OK"),
- Ok(Ok(Err(e))) => tracing::error!("Sidecar health check failed: {e}"),
- Ok(Err(e)) => tracing::error!("Sidecar health check task failed: {e}"),
- Err(_) => tracing::error!("Sidecar health check timed out"),
- }
- tracing::info!("Loading task finished");
- }
- })
- .map_err(|_| ())
- .shared();
- // Show loading window for SQLite migrations if they take >1s
- let loading_window = if needs_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 {
- None
- };
- // Create main window immediately - the web app handles its own loading/health gate
- MainWindow::create(&app).expect("Failed to create main window");
- 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");
- }
- if let Some(loading_window) = loading_window {
- let _ = loading_window.close();
- }
- }
- fn setup_app(app: &tauri::AppHandle, init_rx: watch::Receiver<InitStep>) {
- #[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}");
- }
- });
- }
- 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<PathBuf, &'static str> {
- 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<T: tauri_specta::Event + serde::de::DeserializeOwned>(
- app: &AppHandle,
- ) -> impl Future<Output = ()> {
- let (tx, rx) = oneshot::channel();
- T::once(app, |_| {
- let _ = tx.send(());
- });
- async {
- let _ = rx.await;
- }
- }
|