//! Desktop (Tauri) entry point — compiled only under `--features desktop`. //! //! Wraps the headless Skald backend in a Tauri event loop. The backend runs on //! Tauri's shared tokio runtime (no dual runtime). A system-tray icon provides //! `Open` (show+focus the main window) and `Quit` (graceful shutdown). //! //! ## Window policy //! The main window starts hidden. The traffic-light red / window X button //! *hides* it instead of closing — the app keeps running in the tray. Only the //! tray's `Quit` menu item (or Cmd+Q / system termination) actually shuts the //! backend down and exits. //! //! ## Restart safety //! The `tauri::RunEvent::ExitRequested` handler is re-entrant-guarded by an //! `AtomicBool`: the first trigger prevents the exit, runs the async backend //! shutdown, then calls `app.exit(0)` (which would otherwise loop). //! //! See `docs/desktop.md` for the architecture overview and build instructions. use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use tauri::{ menu::{MenuBuilder, MenuItemBuilder}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, Manager, RunEvent, WebviewUrl, WebviewWindowBuilder, WindowEvent, }; use tracing::{error, info, warn}; use crate::{config::Config, run_backend, shutdown_backend, Backend}; /// Slot for the backend handle, kept in Tauri's managed state. /// /// `None` until the async `run_backend()` completes; `Some(Backend)` afterwards. /// The exit handler takes ownership when the user quits, so we need an /// `Option` rather than a plain `Backend`. type BackendSlot = Mutex>; /// Process-wide handle to the Tauri app, populated once in the setup hook. /// /// Used by code paths that don't naturally receive an `AppHandle` (notably the /// `restart` tool, which is constructed deep inside the tool registry but needs /// to trigger `AppHandle::restart()` in desktop mode). static APP_HANDLE: OnceLock = OnceLock::new(); /// Take a clone of the Tauri `AppHandle`, if the desktop runtime is up. /// Always `None` in headless mode (or before the setup hook has run). pub fn app_handle() -> Option { APP_HANDLE.get().cloned() } /// Desktop entry point. Builds the Tauri app, spawns the backend on its /// shared tokio runtime, wires the system-tray menu, and runs the event loop. pub fn run() -> anyhow::Result<()> { info!(version = env!("CARGO_PKG_VERSION"), "starting skald (desktop mode)"); // Re-entrancy guard: the first `ExitRequested` triggers async shutdown and // then calls `app.exit(0)`, which would itself re-emit `ExitRequested`. The // flag short-circuits the second trigger so we actually leave the process. let exiting = Arc::new(AtomicBool::new(false)); tauri::Builder::default() // Pre-register the backend slot so it exists before the setup hook // (the setup hook spawns the backend async; state must already be there). .manage::(Mutex::new(None)) .setup(|app| { // Stash the app handle for code paths without a natural handle // reference (notably the `restart` tool, reached through the handler // installed just below). let _ = APP_HANDLE.set(app.handle().clone()); // Teach the core how to restart *this* shell. A bundled app has no // supervisor reading its exit code, and the binary is read-only, so // "restart" means: tear down Tauri, spawn a fresh copy, exit. This // mirrors what `tauri-plugin-process`'s JS `restart` does internally. skald_core::tools::restart::set_restart_handler(Box::new(|| { let handle = app_handle() .ok_or_else(|| anyhow::anyhow!("Tauri app handle is not set yet"))?; let exe = std::env::current_exe() .map_err(|e| anyhow::anyhow!("failed to resolve current_exe: {e}"))?; // Tauri-side teardown (webview, tray, event loop, windows). handle.cleanup_before_exit(); // Spawn a fresh copy of the current binary (detached). let _ = std::process::Command::new(exe).spawn(); std::process::exit(0); })); build_tray(app)?; // Resolve the backend port from config so the webview URL is always // in sync with where Axum will actually bind. We load the config // sync here just to read the port; the backend task re-loads it // (cheap — single YAML parse). // In desktop mode this also performs the cwd relocation (no-op in // dev, real relocation inside an `.app` bundle). let port = match std::panic::catch_unwind(|| { crate::config::bootstrap_data_dir() .and_then(|_| Config::load()) .map(|c| c.server.port) }) { Ok(Ok(port)) => port, Ok(Err(e)) => { error!(error = %e, "failed to load config for window URL"); app.handle().exit(1); return Ok(()); } Err(_) => { error!("config load panicked"); app.handle().exit(1); return Ok(()); } }; let url = format!("http://127.0.0.1:{port}"); info!(%url, "creating main window"); let parsed_url = tauri::Url::parse(&url) .map_err(tauri::Error::InvalidUrl)?; WebviewWindowBuilder::new(app, "main", WebviewUrl::External(parsed_url)) .title("Skald") .inner_size(1200.0, 800.0) .min_inner_size(800.0, 600.0) .visible(false) .build()?; let app_handle = app.handle().clone(); // Spawn the backend on Tauri's shared tokio runtime. tauri::async_runtime::spawn(async move { match run_backend().await { Ok(backend) => { info!("backend ready — desktop mode"); let slot = app_handle.state::(); *slot.lock().unwrap() = Some(backend); // Reveal the main window now that the backend is serving. if let Some(window) = app_handle.get_webview_window("main") { let _ = window.show(); let _ = window.set_focus(); } } Err(e) => { error!(error = %e, "backend startup failed"); app_handle.exit(1); } } }); Ok(()) }) // Close button (traffic-light red / X) → hide instead of close. // The window stays alive in the tray; only "Quit" terminates. .on_window_event(|window, event| { if let WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); let _ = window.hide(); } }) .build(tauri::generate_context!())? .run({ let exiting = exiting.clone(); move |app_handle, event| { if let RunEvent::ExitRequested { api, .. } = event { // Second trigger (from our own app.exit(0)) — let it proceed. if exiting.swap(true, Ordering::SeqCst) { return; } api.prevent_exit(); let app_handle = app_handle.clone(); tauri::async_runtime::spawn(async move { // Scope the MutexGuard so it is dropped before any `.await`: // std::sync::MutexGuard is !Send, so holding it across an // await point would make the whole future !Send (Tauri's // runtime requires Send futures). let backend = { let slot = app_handle.state::(); slot.lock().unwrap().take() }; if let Some(backend) = backend { info!("graceful shutdown — desktop mode"); shutdown_backend(backend).await; info!("shutdown complete — desktop mode"); } else { warn!("exit requested before backend was ready"); } // Actually leave the process now. app_handle.exit(0); }); } } }); Ok(()) } /// Build the system-tray icon, its menu (Open / Quit), and the event handlers. fn build_tray(app: &tauri::App) -> tauri::Result<()> { let open = MenuItemBuilder::with_id("open", "Open").build(app)?; let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?; let menu = MenuBuilder::new(app).items(&[&open, &quit]).build()?; // Tray icon: reuse the app's bundled window icon for now. On macOS the // system auto-recolors template images for the menubar theme; we set // `icon_as_template(true)` accordingly. A dedicated monochrome tray PNG // (loaded via the right Tauri image API for this version) can replace this // later — see the icon sources under `icons/`. let icon = app.default_window_icon().cloned() .ok_or_else(|| tauri::Error::AssetNotFound("default window icon".into()))?; TrayIconBuilder::with_id("main") .tooltip("Skald") .icon(icon) .icon_as_template(true) .menu(&menu) .show_menu_on_left_click(false) .on_menu_event(|app, event| match event.id().as_ref() { "open" => { if let Some(window) = app.get_webview_window("main") { let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); } } "quit" => { // Trigger the graceful path via ExitRequested. The handler in // `run()` will drain the backend and then call `exit(0)`. app.exit(0); } _ => (), }) .on_tray_icon_event(|tray, event| { // Single left-click toggles the main window (show+focus or hide). if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event { let app = tray.app_handle(); if let Some(window) = app.get_webview_window("main") { if window.is_visible().unwrap_or(false) { let _ = window.hide(); } else { let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); } } } }) .build(app)?; Ok(()) }