// Desktop targets: Windows, macOS, Linux. iOS + Android live in // `app/src-tauri-mobile/`. #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] compile_error!("src-tauri host supports desktop (Windows/macOS/Linux) only. Mobile lives in app/src-tauri-mobile."); #[cfg(any(target_os = "macos", target_os = "linux"))] mod artifact_commands; mod cdp; // macOS/Linux only: depends on the `nix` crate (a `cfg(unix)` dependency) and // resolves a platform cache path that is only defined for those targets. On // Windows it must not be compiled — see issue: Windows release build failed // with E0433 (`nix` unresolved) + E0425 (`cache_path` undefined). #[cfg(any(target_os = "macos", target_os = "linux"))] mod cef_preflight; mod cef_profile; // Windows-only pre-CEF wait for a dying prior instance to release the cache // lock (Sentry TAURI-RUST-F). Compiled under `test` too so the pure decision // logic is unit-tested on any host; the Win32 glue is windows-only. #[cfg(any(target_os = "windows", test))] mod cef_singleton_wait; mod claude_code; mod companion_commands; mod core_process; mod core_rpc; #[cfg(target_os = "linux")] mod deep_link_ipc; #[cfg(target_os = "windows")] mod deep_link_ipc_windows; // Cross-platform module: the registry-reading function is windows-only, but // the parsing helpers compile (and test) everywhere so `cargo test` on the // developer host covers them. mod deep_link_registration_check; mod dictation_hotkeys; mod discord_scanner; mod fake_camera; mod file_logging; mod gmessages_scanner; mod imessage_scanner; mod local_data_reset; mod loopback_oauth; #[cfg(target_os = "macos")] mod mascot_native_window; mod mcp_commands; mod meet_audio; mod meet_call; mod meet_scanner; mod meet_video; mod native_notifications; #[cfg(target_os = "macos")] mod notch_window; mod notification_settings; mod process_kill; mod process_recovery; mod ptt_hotkeys; mod ptt_overlay; #[cfg(target_os = "windows")] mod reset_reboot_schedule; mod screen_capture; mod slack_scanner; mod telegram_scanner; mod webview_accounts; mod webview_apis; mod wechat_scanner; mod whatsapp_scanner; mod window_state; mod workspace_paths; #[cfg(target_os = "macos")] use tauri::menu::{PredefinedMenuItem, Submenu}; #[cfg(any(target_os = "macos", target_os = "windows"))] use tauri::WindowEvent; #[cfg(not(target_os = "linux"))] use tauri::{ menu::{Menu, MenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, }; use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, RunEvent, WebviewWindow}; use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; use tauri_plugin_notification::NotificationExt; #[cfg(any(windows, target_os = "linux"))] use tauri_plugin_deep_link::DeepLinkExt; #[cfg(target_os = "macos")] use objc2::runtime::{AnyClass, AnyObject}; #[cfg(target_os = "macos")] use objc2::ClassType; #[cfg(target_os = "macos")] use objc2_app_kit::{NSPanel, NSWindowCollectionBehavior, NSWindowStyleMask}; // CEF is the only runtime; alias kept so command handlers thread the runtime generic uniformly. pub(crate) type AppRuntime = tauri::Cef; static EARLY_TEARDOWN_RAN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); #[cfg(target_os = "macos")] const APP_QUIT_MENU_ID: &str = "app_quit"; #[tauri::command] fn core_rpc_url() -> String { crate::core_rpc::core_rpc_url_value() } /// Tauri command: return the per-process bearer token that must be sent with /// every core RPC request as `Authorization: Bearer `. /// /// The token is generated by the Tauri shell at startup (inside /// [`CoreProcessHandle::new`]), injected into the core child process via /// `OPENHUMAN_CORE_TOKEN`, and stored in the handle — available immediately /// with no file I/O or timing issues. #[tauri::command] fn core_rpc_token(state: tauri::State<'_, core_process::CoreProcessHandle>) -> String { log::debug!("[auth] core_rpc_token: returning token to frontend"); state.inner().rpc_token().to_string() } #[tauri::command] fn overlay_parent_rpc_url() -> Option { let url = std::env::var("OPENHUMAN_CORE_RPC_URL").ok()?; let trimmed = url.trim(); if trimmed.is_empty() { return None; } Some(trimmed.to_string()) } #[tauri::command] fn process_diagnostics_list_owned() -> Result, String> { match process_recovery::enumerate_openhuman_processes() { Ok(processes) => { log::info!( "[startup-recovery] diagnostics listed {} owned OpenHuman processes", processes.len() ); Ok(processes) } Err(err) => { log::warn!("[startup-recovery] diagnostics process enumeration failed: {err}"); Err(err) } } } #[allow(dead_code)] // Overlay disabled in tauri.conf.json; helper kept for future re-enable. fn pin_overlay_bottom_right(window: &WebviewWindow) { let Ok(Some(monitor)) = window.current_monitor() else { log::warn!("[overlay] could not resolve current monitor for positioning"); return; }; let Ok(size) = window.outer_size() else { log::warn!("[overlay] could not resolve overlay size for positioning"); return; }; let margin = 20i32; let x = monitor.position().x + monitor.size().width as i32 - size.width as i32 - margin; let y = monitor.position().y + monitor.size().height as i32 - size.height as i32 - margin; if let Err(err) = window.set_position(PhysicalPosition::new(x, y)) { log::warn!("[overlay] failed to pin overlay bottom-right: {err}"); } else { log::info!("[overlay] pinned overlay bottom-right at {},{}", x, y); } } #[cfg(target_os = "macos")] #[allow(dead_code)] // Overlay disabled in tauri.conf.json; helper kept for future re-enable. fn configure_overlay_window_macos(window: &WebviewWindow) { // Standard NSWindow cannot float above fullscreen apps on macOS because // fullscreen apps run in a separate Space. Only NSPanel can do this. // // Tauri/tao hardcodes NSWindow as the window class, so we use // object_setClass() to reclass the existing NSWindow into an NSPanel // at runtime. This avoids creating a new window (which crashes because // Tao's window delegate is tightly coupled to the original NSWindow). // // After reclassing, we set the NonactivatingPanel style mask and // Transient collection behavior — matching the working Swift overlay // helper (accessibility/helper.rs OverlayController) which is confirmed // to float above fullscreen apps on macOS Sonoma. // // Previous attempts that FAILED: // 1. CGShieldingWindowLevel + CanJoinAllSpaces + FullScreenAuxiliary → hidden // 2. Window level i32::MAX-17 + Stationary → hidden // 3. CGS private API CGSSetWindowTags sticky bit → hidden // 4. object_setClass WITHOUT NonactivatingPanel style mask → hidden // 5. Create new NSPanel + reparent webview → CRASH (Tao delegate panic) // // See: https://github.com/tauri-apps/tauri/issues/11488 match window.ns_window() { Ok(ns_window_raw) => unsafe { let ns_window = ns_window_raw as *mut AnyObject; // ── Reclass NSWindow → NSPanel ────────────────────────── let panel_class: *const AnyClass = NSPanel::class(); objc2::ffi::object_setClass(ns_window, panel_class); log::info!("[overlay] reclassed NSWindow → NSPanel via object_setClass"); // Cast to NSPanel for method calls let panel: &NSPanel = &*(ns_window as *const NSPanel); // ── Style mask: add NonactivatingPanel ────────────────── // This is the KEY piece the Swift helper uses. Without it, // the panel doesn't behave as a proper non-activating panel // and won't float above fullscreen Spaces. let current_style = panel.styleMask(); panel.setStyleMask(current_style | NSWindowStyleMask::NonactivatingPanel); // ── Collection behavior ───────────────────────────────── // The Swift helper uses .canJoinAllSpaces + .transient // (NOT .stationary or .fullScreenAuxiliary alone). // Transient means the panel follows the active Space and // appears above fullscreen apps. panel.setCollectionBehavior( NSWindowCollectionBehavior::CanJoinAllSpaces | NSWindowCollectionBehavior::Transient | NSWindowCollectionBehavior::FullScreenAuxiliary | NSWindowCollectionBehavior::IgnoresCycle, ); // ── Window level: status bar tier ─────────────────────── // NSStatusWindowLevel = 25. The Swift helper uses .statusBar // which is the same value. panel.setLevel(25); // ── Panel-specific properties ─────────────────────────── panel.setFloatingPanel(true); panel.setHidesOnDeactivate(false); panel.setBecomesKeyOnlyIfNeeded(true); panel.setWorksWhenModal(true); // Make sure it's ordered front panel.orderFrontRegardless(); log::info!( "[overlay] NSPanel configured — level=25, \ NonactivatingPanel+canJoinAllSpaces+transient, \ floatingPanel={}, hidesOnDeactivate={}", panel.isFloatingPanel(), panel.hidesOnDeactivate(), ); }, Err(err) => { log::warn!("[overlay] failed to access native NSWindow handle: {err}"); } } } /// Core update is handled by the Tauri shell auto-updater (`tauri-plugin-updater`) /// since the core ships in-process with the app. This command is kept as a /// no-op stub so the frontend's `checkCoreUpdate` keeps working without errors; /// it always reports the running version as up-to-date. #[tauri::command] async fn check_core_update( _state: tauri::State<'_, core_process::CoreProcessHandle>, ) -> Result { let version = env!("CARGO_PKG_VERSION"); Ok(serde_json::json!({ "running_version": version, "minimum_version": version, "outdated": false, "latest_version": version, "update_available": false, })) } /// Stub kept for frontend compatibility — use `apply_app_update` instead. #[tauri::command] async fn apply_core_update( _state: tauri::State<'_, core_process::CoreProcessHandle>, _app: tauri::AppHandle, ) -> Result<(), String> { Err("core ships in-process; use the Tauri shell updater (apply_app_update) instead".into()) } #[tauri::command] async fn restart_core_process( state: tauri::State<'_, core_process::CoreProcessHandle>, ) -> Result<(), String> { log::info!("[core] restart_core_process: command invoked from frontend"); let _guard = state.inner().restart_lock().await; log::debug!("[core] restart_core_process: acquired restart lock"); state.inner().restart().await } /// Attempt to auto-recover from a port conflict by reaping stale OpenHuman /// processes (cross-platform) and restarting the embedded core. /// /// Called by the BootCheckGate "Fix Automatically" button when the core is /// unreachable due to a port conflict. #[tauri::command] async fn recover_port_conflict( state: tauri::State<'_, core_process::CoreProcessHandle>, ) -> Result { log::info!("[core] recover_port_conflict: command invoked from frontend"); let _guard = state.inner().restart_lock().await; log::debug!("[core] recover_port_conflict: acquired restart lock"); let outcome = state.inner().recover_port_conflict().await; log::debug!( "[core] recover_port_conflict: result success={} message={}", outcome.success, outcome.message ); Ok(outcome) } /// Start the embedded core process on demand. /// /// Called by the BootCheckGate (Local mode) before the version check. The /// core no longer auto-spawns at Tauri setup — the UI is responsible for /// driving the lifecycle so it can surface startup failures and version /// mismatches to the user. /// /// Idempotent: `ensure_running` is a no-op if the core is already up. #[tauri::command] async fn start_core_process( state: tauri::State<'_, core_process::CoreProcessHandle>, app: tauri::AppHandle, ) -> Result<(), String> { log::info!("[core] start_core_process: command invoked from frontend"); state.inner().ensure_running().await?; if let Some(notice) = state.inner().take_last_port_fallback_notice() { let body = format!( "OpenHuman is using port {} because {} was busy", notice.chosen_port, notice.preferred_port ); if let Err(err) = app .notification() .builder() .title("OpenHuman") .body(&body) .show() { log::warn!("[core] fallback toast notification failed: {err}"); } else { log::info!("[core] fallback toast shown: {body}"); } } Ok(()) } /// Cleanly exit the application. /// /// Called by the BootCheckGate "Quit" button when the core is unreachable and /// the user chooses to close the app rather than switch modes. #[tauri::command] async fn app_quit(app: tauri::AppHandle) -> Result<(), String> { log::info!("[app] app_quit: quit requested from frontend"); app.exit(0); Ok(()) } #[tauri::command] async fn restart_app(app: tauri::AppHandle) -> Result<(), String> { log::info!("[app] restart_app invoked from frontend"); // Persist main-window geometry and hide the window before exit so // the macOS WindowServer doesn't briefly black-out the desktop layer // on the (now defunct) display when the focused app dies, and so // the new process can land its window on the same display+position // the user had it on. (#900 secondary fixes) if let Some(window) = app.get_webview_window("main") { window_state::save_main(&window); if let Err(err) = window.hide() { log::warn!("[app] hide main window before restart failed: {err}"); } } log::info!("[app] restart_app — starting early teardown before restart"); perform_early_teardown_async(&app).await; log::info!("[app] restart_app — early teardown complete, restarting"); app.restart(); // restart() does not return, but we must satisfy the signature Ok(()) } /// Read the authoritative active user id from `active_user.toml` so the /// frontend can seed `userScopedStorage` BEFORE redux-persist hydrates. /// /// The previous frontend-only seed (a `localStorage` key) was bound to the /// per-user CEF profile dir, so on every restart-driven user flip the new /// process read whatever value the new profile's `localStorage` happened to /// hold from a prior session — usually stale, triggering a false re-flip and /// a restart loop. The Rust core writes `active_user.toml` atomically as part /// of `auth_store_session`, so it's the only profile-independent source of /// truth available to the UI at boot. Reuses /// `cef_profile::default_root_openhuman_dir()` so the lookup honors /// `OPENHUMAN_WORKSPACE` overrides used in test harnesses. (#900) #[tauri::command] fn get_active_user_id() -> Result, String> { let dir = cef_profile::default_root_openhuman_dir()?; Ok(cef_profile::read_active_user_id(&dir)) } #[tauri::command] async fn schedule_cef_profile_purge(user_id: Option) -> Result { let queued = cef_profile::queue_profile_purge_for_user(user_id.as_deref())?; Ok(queued.display().to_string()) } /// Information about an available shell-app update returned to the frontend. #[derive(Debug, Clone, serde::Serialize)] struct AppUpdateInfo { /// The currently-running app version (matches `tauri.conf.json::version`). current_version: String, /// True when the configured updater endpoint advertises a newer version. available: bool, /// Newer version reported by the updater endpoint, if any. available_version: Option, /// Release notes / body for the new version, if the manifest provided one. body: Option, } fn no_app_update_available(current_version: String) -> AppUpdateInfo { AppUpdateInfo { current_version, available: false, available_version: None, body: None, } } /// Probe the updater endpoint and report whether a newer shell build is available. /// Does NOT download or install. Pair with `apply_app_update` to actually upgrade. #[tauri::command] async fn check_app_update(app: tauri::AppHandle) -> Result { use tauri_plugin_updater::UpdaterExt; let current_version = app.package_info().version.to_string(); log::info!("[app-update] check requested (current: {current_version})"); let updater = app .updater() .map_err(|e| format!("updater plugin not initialized: {e}"))?; match updater.check().await { Ok(Some(update)) => { log::info!( "[app-update] update available: {} -> {}", current_version, update.version ); Ok(AppUpdateInfo { current_version, available: true, available_version: Some(update.version.clone()), body: update.body.clone(), }) } Ok(None) => { log::info!("[app-update] no update available"); Ok(no_app_update_available(current_version)) } Err(e) => { log::warn!( "[app-update] check failed; treating as no update available for this probe: {e}" ); Ok(no_app_update_available(current_version)) } } } /// Download and install the latest shell update, then relaunch. /// /// Shuts the core sidecar down before download begins so the install step /// (which on macOS replaces the entire `.app` bundle) does not race against /// a live sidecar holding file handles inside `Contents/Resources/`. The /// new bundled sidecar is launched fresh after `app.restart()`. /// /// Emits Tauri events `app-update:status` and `app-update:progress` so the /// frontend can show a snackbar / progress bar. #[tauri::command] async fn apply_app_update( state: tauri::State<'_, core_process::CoreProcessHandle>, app: tauri::AppHandle, ) -> Result<(), String> { use tauri::Emitter; use tauri_plugin_updater::UpdaterExt; log::info!("[app-update] manual apply_app_update invoked from frontend"); let updater = app .updater() .map_err(|e| format!("updater plugin not initialized: {e}"))?; let _ = app.emit("app-update:status", "checking"); let update = match updater.check().await { Ok(Some(u)) => u, Ok(None) => { log::info!("[app-update] no update available"); let _ = app.emit("app-update:status", "up_to_date"); return Ok(()); } Err(e) => { log::warn!("[app-update] check failed: {e}"); let _ = app.emit("app-update:status", "error"); return Err(format!("update check failed: {e}")); } }; let new_version = update.version.clone(); log::info!( "[app-update] downloading {} (size hint: {:?})", new_version, update.signature ); let _ = app.emit("app-update:status", "downloading"); // Shut the core sidecar down before the install step replaces the .app. // We hold the restart lock until app.restart() so nothing tries to // respawn the sidecar from the in-flight (or freshly-replaced) bundle. let _guard = state.inner().restart_lock().await; log::debug!("[app-update] acquired core restart lock"); state.inner().shutdown().await; let progress_app = app.clone(); let install_app = app.clone(); let download_result = update .download_and_install( move |chunk_length, content_length| { let payload = serde_json::json!({ "chunk": chunk_length, "total": content_length, }); let _ = progress_app.emit("app-update:progress", payload); }, move || { log::info!("[app-update] download complete — installing"); let _ = install_app.emit("app-update:status", "installing"); }, ) .await; if let Err(e) = download_result { log::error!("[app-update] download/install failed: {e}"); // Same recovery as `install_app_update`: the .app wasn't swapped, // so revive the in-process core we shut down above. if let Err(start_err) = state.inner().ensure_running().await { log::error!("[app-update] failed to restart core after apply error: {start_err}"); } let _ = app.emit("app-update:status", "error"); return Err(format!("download_and_install failed: {e}")); } log::info!("[app-update] install complete — relaunching"); let _ = app.emit("app-update:status", "restarting"); log::info!("[app-update] starting early teardown before restart"); perform_early_teardown_async(&app).await; // Note: app.restart() never returns. Anything after this is unreachable. app.restart(); } /// Holds an `Update` handle plus its downloaded bytes between the /// `download_app_update` (background) and `install_app_update` (user /// confirmed restart) commands. Sized at ~100MB on macOS for the .app /// bundle, which is fine to keep in RAM until the user is ready. struct PendingAppUpdate { update: tauri_plugin_updater::Update, bytes: Vec, version: String, } /// Tauri-managed state slot for the in-flight pending update. `None` means /// "no update has been downloaded since launch"; `Some(_)` means the bytes /// are ready and `install_app_update` can finalize without re-downloading. #[derive(Default)] struct PendingAppUpdateState(tokio::sync::Mutex>); /// Result returned to the frontend after a download attempt. #[derive(Debug, Clone, serde::Serialize)] struct AppUpdateDownloadResult { /// True when an update was found and the bytes are now staged. ready: bool, /// Version of the staged update (if any). version: Option, /// Release notes for the staged update. body: Option, } /// Probe the updater endpoint and, if a newer build is advertised, download /// the bundle bytes into memory but do NOT install. The frontend can then /// surface a "Restart to apply" prompt at a moment that's safe for the user /// (no in-flight conversation, etc.) before calling `install_app_update`. /// /// Emits the same `app-update:status` and `app-update:progress` events as /// `apply_app_update`, so the React state machine can drive a single UI off /// either path. Status sequence: `checking` → `downloading` → `ready_to_install`, /// or `up_to_date` / `error`. #[tauri::command] async fn download_app_update( app: tauri::AppHandle, state: tauri::State<'_, PendingAppUpdateState>, ) -> Result { use tauri::Emitter; use tauri_plugin_updater::UpdaterExt; log::info!("[app-update] download_app_update invoked from frontend"); let updater = app .updater() .map_err(|e| format!("updater plugin not initialized: {e}"))?; let _ = app.emit("app-update:status", "checking"); let update = match updater.check().await { Ok(Some(u)) => u, Ok(None) => { log::info!("[app-update] no update available"); let _ = app.emit("app-update:status", "up_to_date"); return Ok(AppUpdateDownloadResult { ready: false, version: None, body: None, }); } Err(e) => { log::warn!("[app-update] check failed: {e}"); let _ = app.emit("app-update:status", "error"); return Err(format!("update check failed: {e}")); } }; let new_version = update.version.clone(); let body = update.body.clone(); log::info!("[app-update] downloading {} (background)", new_version); let _ = app.emit("app-update:status", "downloading"); let progress_app = app.clone(); let download_result = update .download( move |chunk_length, content_length| { let payload = serde_json::json!({ "chunk": chunk_length, "total": content_length, }); let _ = progress_app.emit("app-update:progress", payload); }, || { log::info!("[app-update] download complete — staging for install"); }, ) .await; let bytes = match download_result { Ok(b) => b, Err(e) => { log::error!("[app-update] download failed: {e}"); let _ = app.emit("app-update:status", "error"); return Err(format!("download failed: {e}")); } }; let mut slot = state.0.lock().await; *slot = Some(PendingAppUpdate { update, bytes, version: new_version.clone(), }); drop(slot); log::info!( "[app-update] staged {} — awaiting user-initiated install", new_version ); let _ = app.emit("app-update:status", "ready_to_install"); Ok(AppUpdateDownloadResult { ready: true, version: Some(new_version), body, }) } /// Install the previously-downloaded update bytes (staged by /// `download_app_update`), then relaunch. Errors with `no pending update` /// if `download_app_update` hasn't run yet — the frontend should fall back /// to a fresh `apply_app_update` in that case. /// /// Acquires the core restart lock + shuts the in-process core server down /// before install, same as `apply_app_update`, so the macOS .app bundle /// replacement does not race against a live core holding file handles. #[tauri::command] async fn install_app_update( core_state: tauri::State<'_, core_process::CoreProcessHandle>, pending: tauri::State<'_, PendingAppUpdateState>, app: tauri::AppHandle, ) -> Result<(), String> { use tauri::Emitter; log::info!("[app-update] install_app_update invoked from frontend"); let staged = { let mut slot = pending.0.lock().await; slot.take() }; let staged = match staged { Some(s) => s, None => { log::warn!("[app-update] install requested but no staged update"); let _ = app.emit("app-update:status", "error"); return Err("no pending update — call download_app_update first".into()); } }; log::info!("[app-update] installing staged version {}", staged.version); let _guard = core_state.inner().restart_lock().await; log::debug!("[app-update] acquired core restart lock"); core_state.inner().shutdown().await; let _ = app.emit("app-update:status", "installing"); if let Err(e) = staged.update.install(staged.bytes) { log::error!("[app-update] install failed: {e}"); // The .app on disk wasn't replaced, so we keep running the // pre-install build — bring the core back up before returning // so the user can keep working instead of being silently offline. if let Err(start_err) = core_state.inner().ensure_running().await { log::error!("[app-update] failed to restart core after install error: {start_err}"); } let _ = app.emit("app-update:status", "error"); return Err(format!("install failed: {e}")); } log::info!("[app-update] install complete — relaunching"); let _ = app.emit("app-update:status", "restarting"); log::info!("[app-update] starting early teardown before restart"); perform_early_teardown_async(&app).await; // Note: app.restart() never returns. Anything after this is unreachable. app.restart(); } /// Register (or re-register) the global dictation toggle hotkey. /// Emits `dictation://toggle` to all webviews when the shortcut is pressed. #[tauri::command] async fn register_dictation_hotkey( app: AppHandle, shortcut: String, ) -> Result<(), String> { log::info!("[dictation] register_dictation_hotkey: shortcut={shortcut}"); let old_shortcuts = { let state = app.state::(); let guard = state.0.lock().unwrap(); guard.clone() }; let expanded_shortcuts = dictation_hotkeys::expand_dictation_shortcuts(&shortcut); if expanded_shortcuts.is_empty() { return Err("Shortcut cannot be empty".to_string()); } log::info!( "[dictation] expanded shortcuts: {}", expanded_shortcuts.join(", ") ); // Reject overlap with the currently-registered PTT hotkey. let ptt_current = { let state = app.state::(); let guard = state.shortcut.lock().unwrap(); guard.clone() }; if let Some(conflict) = ptt_hotkeys::first_conflict_with(&expanded_shortcuts, &ptt_current) { return Err(format!( "dictation shortcut '{conflict}' conflicts with the push-to-talk hotkey" )); } let register_shortcut = |shortcut_variant: &str| -> Result<(), String> { let app_clone = app.clone(); app.global_shortcut() .on_shortcut(shortcut_variant, move |_app, _sc, event| { if event.state == ShortcutState::Pressed { log::debug!("[dictation] hotkey pressed — emitting dictation://toggle"); if let Err(e) = app_clone.emit("dictation://toggle", ()) { log::warn!("[dictation] emit failed: {e}"); } } }) .map_err(|e| format!("Failed to register shortcut '{shortcut_variant}': {e}")) }; let mut unregistered_old: Vec = Vec::new(); for old in &old_shortcuts { log::debug!("[dictation] unregistering previous shortcut: {old}"); if let Err(e) = app.global_shortcut().unregister(old.as_str()) { for restored in &unregistered_old { if let Err(restore_err) = register_shortcut(restored.as_str()) { log::warn!( "[dictation] rollback failed while restoring old shortcut '{restored}': {restore_err}" ); } } return Err(format!( "Failed to unregister previous shortcut '{old}': {e}" )); } unregistered_old.push(old.clone()); } let mut newly_registered: Vec = Vec::new(); for shortcut_variant in &expanded_shortcuts { if let Err(err) = register_shortcut(shortcut_variant.as_str()) { log::error!("[dictation] failed to register shortcut '{shortcut_variant}': {err}"); for registered in &newly_registered { if let Err(unregister_err) = app.global_shortcut().unregister(registered.as_str()) { log::warn!( "[dictation] rollback failed while unregistering '{registered}': {unregister_err}" ); } } for old in &old_shortcuts { if let Err(restore_err) = register_shortcut(old.as_str()) { log::warn!( "[dictation] rollback failed while restoring old shortcut '{old}': {restore_err}" ); } } return Err(err); } newly_registered.push(shortcut_variant.clone()); } // Persist all newly registered shortcuts. { let state = app.state::(); let mut guard = state.0.lock().unwrap(); *guard = expanded_shortcuts.clone(); } log::info!( "[dictation] shortcuts registered: {}", expanded_shortcuts.join(", ") ); Ok(()) } /// Unregister the global dictation hotkey (if any). #[tauri::command] async fn unregister_dictation_hotkey(app: AppHandle) -> Result<(), String> { log::info!("[dictation] unregister_dictation_hotkey: called"); let state = app.state::(); let mut guard = state.0.lock().unwrap(); if guard.is_empty() { log::debug!("[dictation] no shortcut registered — nothing to unregister"); } else { let old_shortcuts = guard.clone(); guard.clear(); for old in old_shortcuts { log::debug!("[dictation] unregistering shortcut: {old}"); app.global_shortcut() .unregister(old.as_str()) .map_err(|e| { log::warn!("[dictation] failed to unregister '{old}': {e}"); format!("Failed to unregister shortcut '{old}': {e}") })?; log::info!("[dictation] shortcut unregistered: {old}"); } } Ok(()) } /// Register (or re-register) the global push-to-talk hotkey. Emits /// `ptt://start { session_id }` on press and `ptt://stop { session_id }` /// on release. #[tauri::command] async fn register_ptt_hotkey(app: AppHandle, shortcut: String) -> Result<(), String> { log::info!("[ptt] register_ptt_hotkey: shortcut={shortcut}"); let expanded = ptt_hotkeys::expand_ptt_shortcuts(&shortcut).map_err(|e| e.to_string())?; // Reject overlap with the currently-registered dictation hotkey. let dictation_current = { let state = app.state::(); let guard = state.0.lock().unwrap(); guard.clone() }; if let Some(conflict) = ptt_hotkeys::first_conflict_with(&expanded, &dictation_current) { return Err(ptt_hotkeys::PttError::ConflictsWithDictation(conflict).to_string()); } let old_shortcuts = { let state = app.state::(); let guard = state.shortcut.lock().unwrap(); guard.clone() }; // Lazy-instantiate the overlay window so it's ready before the first press. if let Err(e) = ptt_overlay::ensure_window(&app) { log::warn!("[ptt] overlay window create failed (continuing): {e}"); } let register_shortcut = |variant: &str| -> Result<(), String> { let app_pressed = app.clone(); let app_released = app.clone(); let variant_owned = variant.to_string(); app.global_shortcut() .on_shortcut(variant, move |app_inner, _sc, event| { let state = app_inner.state::(); match event.state { ShortcutState::Pressed => { // Drop OS key-repeat events; only the first Pressed of a hold opens a session. if state .is_held .compare_exchange( false, true, std::sync::atomic::Ordering::AcqRel, std::sync::atomic::Ordering::Acquire, ) .is_err() { log::trace!( "[ptt] press dropped (already held) shortcut={variant_owned}" ); return; } let session_id = state .session_counter .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; log::debug!( "[ptt] pressed shortcut={variant_owned} session_id={session_id}" ); if let Err(e) = app_pressed.emit( "ptt://start", serde_json::json!({ "session_id": session_id, }), ) { log::warn!("[ptt] emit start failed: {e}"); } } ShortcutState::Released => { if !state .is_held .swap(false, std::sync::atomic::Ordering::AcqRel) { // No corresponding Pressed in our state — stale event, drop. log::trace!( "[ptt] release dropped (not held) shortcut={variant_owned}" ); return; } let session_id = state .session_counter .load(std::sync::atomic::Ordering::Relaxed); log::debug!( "[ptt] released shortcut={variant_owned} session_id={session_id}" ); if let Err(e) = app_released.emit( "ptt://stop", serde_json::json!({ "session_id": session_id, }), ) { log::warn!("[ptt] emit stop failed: {e}"); } } } }) .map_err(|e| format!("Failed to register ptt shortcut '{variant}': {e}")) }; // Unregister previous PTT variants. let mut unregistered: Vec = Vec::new(); for old in &old_shortcuts { if let Err(e) = app.global_shortcut().unregister(old.as_str()) { // Rollback already-unregistered ones. for r in &unregistered { if let Err(re) = register_shortcut(r) { log::warn!("[ptt] rollback failed for '{r}': {re}"); } } return Err(format!( "Failed to unregister previous ptt shortcut '{old}': {e}" )); } unregistered.push(old.clone()); } // Register the new variants. Rollback on first failure. let mut newly_registered: Vec = Vec::new(); for v in &expanded { if let Err(e) = register_shortcut(v) { for r in &newly_registered { if let Err(re) = app.global_shortcut().unregister(r.as_str()) { log::warn!("[ptt] rollback failed for '{r}': {re}"); } } for old in &old_shortcuts { if let Err(re) = register_shortcut(old) { log::warn!("[ptt] rollback failed for '{old}': {re}"); } } return Err(e); } newly_registered.push(v.clone()); } { let state = app.state::(); let mut guard = state.shortcut.lock().unwrap(); *guard = expanded.clone(); } log::info!("[ptt] registered: {}", expanded.join(", ")); Ok(()) } /// Unregister the global PTT hotkey (if any). #[tauri::command] async fn unregister_ptt_hotkey(app: AppHandle) -> Result<(), String> { log::info!("[ptt] unregister_ptt_hotkey: called"); let state = app.state::(); let old = { let guard = state.shortcut.lock().unwrap(); guard.clone() }; let mut still_registered: Vec = Vec::new(); for s in &old { if let Err(e) = app.global_shortcut().unregister(s.as_str()) { log::warn!("[ptt] unregister '{s}' failed: {e}"); still_registered.push(s.clone()); } } // Only retain variants that genuinely failed to unregister; the rest are gone. { let mut guard = state.shortcut.lock().unwrap(); *guard = still_registered; } // Destroy the overlay window so resources are released. ptt_overlay::destroy_window(&app); Ok(()) } fn is_daemon_mode() -> bool { std::env::args().any(|arg| arg == "daemon" || arg == "--daemon") } /// Returns true when an executable named `name` is discoverable on `$PATH`. /// /// Inline `which`-style lookup so the deep-link pre-flight on Linux can /// skip `tauri-plugin-deep-link::register_all` cleanly when `xdg-mime` is /// missing (OPENHUMAN-TAURI-AS). Walks `$PATH` entries, joins `name`, and /// returns true on the first hit that is a regular file. The metadata check /// is `is_file()` rather than an executable-bit check: on Linux any file in /// `$PATH` that is named like the binary is enough to gate the plugin call /// (the plugin itself will surface the real exec error to its own warn), /// and an executable-bit check would require unix-specific /// `MetadataExt::mode` plumbing that isn't worth the platform branch for a /// single discoverability gate. #[cfg(target_os = "linux")] fn path_has_executable(name: &str) -> bool { let Some(path_var) = std::env::var_os("PATH") else { return false; }; std::env::split_paths(&path_var).any(|dir| dir.join(name).is_file()) } /// Tauri command: bring the main window to front from any webview (e.g. overlay orb click). #[tauri::command] fn activate_main_window(app: AppHandle) -> Result<(), String> { log::debug!("[window] activate_main_window called from overlay"); show_main_window(&app) } /// Show the floating mascot. macOS: native NSPanel + WKWebView (so the /// window is actually transparent — vendored tauri-cef can't render /// transparent windowed-mode browsers). Loads the Vite dev URL in /// development and the bundled `index.html` in production. Other OSes: /// not yet wired up. #[tauri::command] fn mascot_window_show(app: AppHandle) -> Result<(), String> { log::info!("[mascot-window] show requested"); #[cfg(target_os = "macos")] { return mascot_native_window::show(&app); } #[cfg(not(target_os = "macos"))] { let _ = app; Err("floating mascot window is macOS-only for now".into()) } } /// Hide the floating mascot. #[tauri::command] fn mascot_window_hide(app: AppHandle) -> Result<(), String> { log::info!("[mascot-window] hide requested"); #[cfg(target_os = "macos")] { let _ = app; mascot_native_window::hide(); Ok(()) } #[cfg(not(target_os = "macos"))] { let _ = app; Ok(()) } } #[cfg(target_os = "macos")] fn mascot_native_window_is_open() -> bool { mascot_native_window::is_open() } #[cfg(not(target_os = "macos"))] fn mascot_native_window_is_open() -> bool { false } /// Dispatch a notch-panel mutation onto the app main thread. /// /// The notch is a native NSPanel + WKWebView; AppKit requires it to be built /// and torn down on the main thread (and its `thread_local` storage lives /// there). Tauri runs these IPC command handlers on a worker thread, so we hop /// to the main thread via `run_on_main_thread`. Fire-and-forget: notch /// visibility is cosmetic (the frontend swallows errors), so we log the result /// on the main thread rather than blocking the command to propagate it back. #[cfg(target_os = "macos")] fn dispatch_notch_on_main( app: AppHandle, op: impl FnOnce(&AppHandle) + Send + 'static, ) -> Result<(), String> { app.clone() .run_on_main_thread(move || op(&app)) .map_err(|e| format!("run_on_main_thread dispatch failed: {e}")) } /// Show the notch activity indicator. macOS only — transparent NSPanel + WKWebView /// anchored to the top-centre of the primary screen. Displays live voice and /// agent status (listening, thinking, executing) in a pill that emerges from /// the physical notch on supported MacBook Pros. #[tauri::command] fn notch_window_show(app: AppHandle) -> Result<(), String> { log::info!("[notch-window] show requested"); #[cfg(target_os = "macos")] { return dispatch_notch_on_main(app, |app| { if let Err(e) = notch_window::show(app) { log::warn!("[notch-window] show failed: {e}"); } }); } #[cfg(not(target_os = "macos"))] { let _ = app; Ok(()) // No-op on non-macOS } } /// Hide the notch activity indicator. #[tauri::command] fn notch_window_hide(app: AppHandle) -> Result<(), String> { log::info!("[notch-window] hide requested"); #[cfg(target_os = "macos")] { return dispatch_notch_on_main(app, |_app| notch_window::hide()); } #[cfg(not(target_os = "macos"))] { let _ = app; Ok(()) } } /// Hide or show the OS top-level main-window frame on Windows by enumerating /// this process's top-level windows and matching the visible /// `Chrome_WidgetWin_1` host. `WebviewWindow::hwnd()` from the vendored CEF /// runtime returns a `cef::Window` internal handle that `ShowWindow` rejects, /// so we walk the OS window list instead (#1607). Empirically there is one /// matching top-level frame; the single-instance lock window uses class /// `com.openhuman.app-sic` and is excluded. /// /// `SW_HIDE` removes the frame from screen AND taskbar — full hide-to-tray as /// PR #1548 intended. On restore, the IsWindowVisible filter excludes hidden /// windows, so we also accept currently-hidden Chrome_WidgetWin_1 frames when /// `hide=false`. #[cfg(target_os = "windows")] fn set_main_window_hidden(hide: bool) { use std::ffi::OsString; use std::os::windows::ffi::OsStringExt; use windows_sys::Win32::Foundation::{BOOL, HWND, LPARAM}; use windows_sys::Win32::UI::WindowsAndMessaging::{ EnumWindows, GetClassNameW, GetWindowThreadProcessId, IsWindowVisible, ShowWindow, SW_HIDE, SW_SHOW, }; struct EnumCtx { target_pid: u32, action: i32, require_visible: bool, matched: u32, } unsafe extern "system" fn enum_proc(hwnd: HWND, lparam: LPARAM) -> BOOL { let ctx = unsafe { &mut *(lparam as *mut EnumCtx) }; let mut pid: u32 = 0; unsafe { GetWindowThreadProcessId(hwnd, &mut pid) }; if pid != ctx.target_pid { return 1; } if ctx.require_visible && unsafe { IsWindowVisible(hwnd) } == 0 { return 1; } let mut class_buf = [0u16; 64]; let n = unsafe { GetClassNameW(hwnd, class_buf.as_mut_ptr(), class_buf.len() as i32) }; if n <= 0 { return 1; } let class = OsString::from_wide(&class_buf[..n as usize]) .to_string_lossy() .into_owned(); if class != "Chrome_WidgetWin_1" { return 1; } unsafe { ShowWindow(hwnd, ctx.action) }; ctx.matched += 1; 1 } let action = if hide { SW_HIDE } else { SW_SHOW }; let mut ctx = EnumCtx { target_pid: std::process::id(), action, // Hide path: only touch currently-visible frames. Show path: also // pick up frames already in the SW_HIDE state. require_visible: hide, matched: 0, }; unsafe { EnumWindows(Some(enum_proc), &mut ctx as *mut _ as LPARAM) }; log::info!( "[window-hide] EnumWindows: action={} matched={} pid={}", if hide { "SW_HIDE" } else { "SW_SHOW" }, ctx.matched, ctx.target_pid, ); } /// Look up the main `WebviewWindow`, optionally waiting briefly on Windows /// for the Tauri runtime to re-track the window after SW_SHOW. /// /// Why this exists (OPENHUMAN-TAURI-3A): on Windows the close button routes /// through [`set_main_window_hidden`] which uses raw-HWND `SW_HIDE`. CEF /// treats the hidden host as gone and the Tauri runtime drops its /// `WebviewWindow` record for `"main"` until the next event-loop tick after /// SW_SHOW restores visibility. A tray "Show window" callback that runs /// `set_main_window_hidden(false)` and then immediately calls /// `app.get_webview_window("main")` can race the re-track step and observe /// `None` even though the OS window is visible — Sentry sees a /// `[tray] failed to show main window from menu: main window not found` /// warn even though, from the user's perspective, the window came back. /// /// Bounded retry budget: up to 5 lookups with 10 ms between attempts (≤ 50 ms /// worst case). The tray menu is closed during this window, so the small /// blocking delay is invisible. After the budget expires the original /// error path still triggers, preserving the signal if the runtime never /// re-tracks (which would indicate a real lifecycle bug, not a race). /// /// Non-Windows platforms use a single lookup — the close-to-tray flow that /// produces the race is Windows-specific (the macOS close button routes /// through `app.hide()` per PR #2049, and Linux/X11 keeps the /// `WebviewWindow` record across `WM_DELETE_WINDOW` handling). fn get_main_webview_window_with_retry( app: &AppHandle, ) -> Option> { #[cfg(target_os = "windows")] { const ATTEMPTS: usize = 5; const BACKOFF: std::time::Duration = std::time::Duration::from_millis(10); for attempt in 0..ATTEMPTS { if let Some(window) = app.get_webview_window("main") { if attempt > 0 { log::debug!( "[show_main_window] runtime re-tracked main window after {} retries", attempt ); } return Some(window); } if attempt + 1 < ATTEMPTS { std::thread::sleep(BACKOFF); } } None } #[cfg(not(target_os = "windows"))] { app.get_webview_window("main") } } fn show_main_window(app: &AppHandle) -> Result<(), String> { // On Windows: surface the OS top-level Chrome_WidgetWin_1 frame BEFORE // any Tauri lookups. After our close handler's SW_HIDE the runtime // briefly drops the `WebviewWindow` record for "main" (CEF treats the // hidden host as gone), so `get_webview_window("main")` returns None // and the early `?` below would abort before SW_SHOW fires (#1607). // EnumWindows + SW_SHOW operates directly on the OS HWND that // survived independently, and the runtime re-tracks the window once // it's visible again — but re-tracking lands on the next event-loop // tick, not synchronously with SW_SHOW. `get_main_webview_window_with_retry` // bounds the wait to ~50 ms total so the tray callback can pick up the // re-tracked window without re-emitting OPENHUMAN-TAURI-3A. #[cfg(target_os = "windows")] { set_main_window_hidden(false); if let Some(webview) = app.get_webview("main") { let _ = webview.show(); let _ = webview.set_focus(); } } let window = get_main_webview_window_with_retry(app) .ok_or_else(|| "main window not found".to_string())?; window .show() .map_err(|err| format!("failed to show main window: {err}"))?; window .unminimize() .map_err(|err| format!("failed to unminimize main window: {err}"))?; window .set_focus() .map_err(|err| format!("failed to focus main window: {err}"))?; // `WebviewWindow::set_focus` only dispatches `WindowMessage::SetFocus` // (vendor/tauri-cef cef_impl.rs `WindowMessage::SetFocus` → `window.request_focus()`), // which lifts the OS window but does NOT call `CefBrowserHost::SetFocus(true)`. // Without that CEF-level focus call the renderer never gets wired as the // keyboard input target on cold launch: the chat textarea accepts focus // (cursor blinks) but `WM_KEYDOWN` messages aren't forwarded to it, so // typing is silently dead until the user click-outside / click-back // triggers `WM_KILLFOCUS`+`WM_SETFOCUS` and CEF's window handler routes // through `host.set_focus(1)` internally. // // Explicitly dispatch `WebviewMessage::SetFocus` (cef_impl.rs handler // for that variant), which is what actually invokes // `CefBrowserHost::SetFocus(true)`. let webview: &tauri::Webview = window.as_ref(); if let Err(err) = webview.set_focus() { log::warn!( "[show_main_window] CEF webview set_focus failed (non-fatal — \ keyboard routing may not initialize until user click-outside-and-back): {err}" ); } Ok(()) } #[cfg(target_os = "macos")] fn macos_app_menu(app: &AppHandle) -> tauri::Result> { let about = PredefinedMenuItem::about(app, None, None)?; let hide = PredefinedMenuItem::hide(app, None)?; let hide_others = PredefinedMenuItem::hide_others(app, None)?; let show_all = PredefinedMenuItem::show_all(app, None)?; let quit = MenuItem::with_id( app, APP_QUIT_MENU_ID, "Quit OpenHuman", true, Some("CmdOrCtrl+Q"), )?; let app_sep_1 = PredefinedMenuItem::separator(app)?; let app_sep_2 = PredefinedMenuItem::separator(app)?; let app_menu = Submenu::with_items( app, "OpenHuman", true, &[ &about, &app_sep_1, &hide, &hide_others, &show_all, &app_sep_2, &quit, ], )?; let undo = PredefinedMenuItem::undo(app, None)?; let redo = PredefinedMenuItem::redo(app, None)?; let cut = PredefinedMenuItem::cut(app, None)?; let copy = PredefinedMenuItem::copy(app, None)?; let paste = PredefinedMenuItem::paste(app, None)?; let select_all = PredefinedMenuItem::select_all(app, None)?; let edit_sep_1 = PredefinedMenuItem::separator(app)?; let edit_sep_2 = PredefinedMenuItem::separator(app)?; let edit_menu = Submenu::with_items( app, "Edit", true, &[ &undo, &redo, &edit_sep_1, &cut, ©, &paste, &edit_sep_2, &select_all, ], )?; let close = PredefinedMenuItem::close_window(app, None)?; let minimize = PredefinedMenuItem::minimize(app, None)?; let fullscreen = PredefinedMenuItem::fullscreen(app, None)?; let window_menu = Submenu::with_items(app, "Window", true, &[&close, &minimize, &fullscreen])?; Menu::with_items(app, &[&app_menu, &edit_menu, &window_menu]) } #[cfg(target_os = "linux")] fn setup_tray(app: &AppHandle) -> tauri::Result<()> { let _ = app; log::warn!( "[tray] skipping tray setup on linux: tray menu creation still panics inside GTK during packaged runs" ); Ok(()) } #[cfg(not(target_os = "linux"))] fn setup_tray(app: &AppHandle) -> tauri::Result<()> { log::info!("[tray] setting up tray icon"); let show_item = MenuItem::with_id( app, "tray_show_window", "Open OpenHuman", true, None::<&str>, )?; let quit_item = MenuItem::with_id(app, "tray_quit", "Quit", true, None::<&str>)?; // The floating mascot has a native NSPanel + WKWebView host, so the // tray entry only does anything on macOS. Don't surface a menu item // on Windows that's guaranteed to error — gate it to the platform // where `mascot_window_show` actually works. #[cfg(target_os = "macos")] let menu = { let mascot_item = MenuItem::with_id( app, "tray_toggle_mascot", "Toggle floating mascot", true, None::<&str>, )?; Menu::with_items(app, &[&show_item, &mascot_item, &quit_item])? }; #[cfg(not(target_os = "macos"))] let menu = Menu::with_items(app, &[&show_item, &quit_item])?; let icon = app .default_window_icon() .cloned() .ok_or_else(|| tauri::Error::AssetNotFound("default window icon".to_string()))?; TrayIconBuilder::with_id("openhuman-tray") .icon(icon) .menu(&menu) .on_menu_event(|app, event| match event.id().as_ref() { "tray_show_window" => { log::info!("[tray] action=show_window source=menu"); if let Err(err) = show_main_window(app) { log::warn!("[tray] failed to show main window from menu: {err}"); } } "tray_toggle_mascot" => { log::info!("[tray] action=toggle_mascot source=menu"); if mascot_native_window_is_open() { if let Err(err) = mascot_window_hide(app.clone()) { log::error!("[tray] failed to hide mascot window: {err}"); } } else if let Err(err) = mascot_window_show(app.clone()) { log::error!("[tray] failed to show mascot window: {err}"); } } "tray_quit" => { log::info!("[tray] action=quit source=menu"); shutdown_app_sync(app, 0); } _ => {} }) .on_tray_icon_event(|tray, event| { if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event { log::info!("[tray] action=show_window source=left_click"); if let Err(err) = show_main_window(tray.app_handle()) { log::warn!("[tray] failed to show main window from tray click: {err}"); } } }) .build(app)?; log::info!("[tray] tray icon ready"); Ok(()) } const CEF_PREWARM_LABEL: &str = "cef-prewarm"; /// Decide whether to spawn the CEF cold-start prewarm webview. /// /// Testable pure function — callers pass the relevant env values directly. /// /// Decision matrix: /// - `env_override` = `Some("0"|"false"|"no"|"off")` → disabled (explicit) /// - `env_override` = `Some()` → enabled (explicit opt-in; /// overrides even the Wayland guard so ops can re-enable if CEF subprocess /// X handling improves) /// - `env_override` = `None` (env var unset, default path): /// - `wayland_display_set` = `true` → **disabled** — auto-guard against the /// fatal `X_ConfigureWindow BadWindow` crash that fires in CEF render /// subprocesses on Wayland/XWayland sessions (issue #2463). The main-process /// silent X error handler (`install_silent_x_error_handler`) does not reach /// CEF subprocesses; until subprocess-level coverage is available, skipping /// the prewarm child webview is the safest mitigation. /// - `wayland_display_set` = `false` → enabled fn cef_prewarm_enabled(env_override: Option<&str>, wayland_display_set: bool) -> bool { if let Some(v) = env_override { let v = v.trim().to_ascii_lowercase(); return !(v == "0" || v == "false" || v == "no" || v == "off"); } !wayland_display_set } /// Spawn a hidden 1×1 child webview at `about:blank` on the main window so /// CEF's child-webview render path is hot before the user clicks an /// account. The first `webview_account_open` then skips the cold /// renderer-process spinup. Idempotent — bails if the prewarm webview /// already exists. fn spawn_cef_prewarm(app: &AppHandle) -> Result<(), String> { use tauri::webview::WebviewBuilder; use tauri::WebviewUrl; if app.get_webview(CEF_PREWARM_LABEL).is_some() { return Ok(()); } let parent = app .get_window("main") .ok_or_else(|| "main window not found".to_string())?; let url: tauri::Url = "about:blank" .parse() .map_err(|e| format!("about:blank parse: {e}"))?; let builder = WebviewBuilder::new(CEF_PREWARM_LABEL, WebviewUrl::External(url)); parent .add_child( builder, tauri::LogicalPosition::new(-20000.0, -20000.0), tauri::LogicalSize::new(1.0, 1.0), ) .map_err(|e| format!("add_child failed: {e}"))?; log::info!("[cef-prewarm] hidden warmup webview spawned"); Ok(()) } /// Drop the prewarm webview if still alive. Called from `RunEvent::Exit` /// so its CEF browser is torn down before `cef::shutdown()` runs. fn teardown_cef_prewarm(app: &AppHandle) -> Result<(), String> { let Some(wv) = app.get_webview(CEF_PREWARM_LABEL) else { return Err("no prewarm webview".into()); }; wv.close().map_err(|e| e.to_string())?; log::info!("[cef-prewarm] teardown ok"); Ok(()) } const CEF_CLOSE_FIXED_YIELD: std::time::Duration = std::time::Duration::from_millis(20); const CEF_CLOSE_POLL_BUDGET: std::time::Duration = std::time::Duration::from_millis(300); const CEF_CLOSE_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(20); fn close_early_cef_webviews(app: &AppHandle) -> Vec { let mut closed_labels = Vec::new(); if teardown_cef_prewarm(app).is_ok() { closed_labels.push(CEF_PREWARM_LABEL.to_string()); } if let Some(state) = app.try_state::() { closed_labels.extend(state.shutdown_all(app)); } closed_labels } fn shutdown_imessage_scanner(app: &AppHandle) { if let Some(registry) = app.try_state::>() { registry.inner().shutdown(); } } fn pending_cef_webview_labels( app: &AppHandle, labels: &[String], ) -> Vec { let mut seen = std::collections::HashSet::new(); labels .iter() .filter(|label| seen.insert((*label).clone())) .filter(|label| app.get_webview(label.as_str()).is_some()) .cloned() .collect() } async fn wait_for_cef_webviews_to_close_async( app: &AppHandle, labels: &[String], ) { if labels.is_empty() { return; } log::info!( "[app] waiting for CEF webview close requests labels={:?}", labels ); tokio::time::sleep(CEF_CLOSE_FIXED_YIELD).await; let start = std::time::Instant::now(); let mut pending = pending_cef_webview_labels(app, labels); while !pending.is_empty() && start.elapsed() < CEF_CLOSE_POLL_BUDGET { tokio::time::sleep(CEF_CLOSE_POLL_INTERVAL).await; pending = pending_cef_webview_labels(app, labels); } if pending.is_empty() { log::info!( "[app] CEF webview close poll drained labels={:?} elapsed_ms={}", labels, start.elapsed().as_millis() ); } else { log::info!( "[app] CEF webview close poll still pending labels={:?} elapsed_ms={} (will continue in runtime shutdown)", pending, start.elapsed().as_millis() ); } } /// Shared early teardown logic before CEF's shutdown to prevent races and zombie processes. /// /// Synchronous entry used from `RunEvent::ExitRequested` and tray quit. We intentionally /// **do not** poll here with `std::thread::sleep` — that would block the Tauri / CEF main /// event loop and prevent close messages from being processed. Close requests are issued /// in [`close_early_cef_webviews`]; the exit pump drains them. Use /// [`perform_early_teardown_async`] when an async caller can await /// [`wait_for_cef_webviews_to_close_async`] without starving the UI loop. fn perform_early_teardown_sync(app_handle: &AppHandle) { log::info!("[app] perform_early_teardown_sync — early teardown"); let closed_labels = close_early_cef_webviews(app_handle); shutdown_imessage_scanner(app_handle); webview_apis::server::stop(); if let Some(core) = app_handle.try_state::() { let core = core.inner().clone(); // Aborts the embedded server task. Synchronous and safe on // the UI thread — `JoinHandle::abort` returns immediately. tauri::async_runtime::block_on(async move { core.send_terminate_signal().await; }); } if !closed_labels.is_empty() { log::info!( "[app] sync early teardown: close requested for labels={:?} — skipping main-thread poll so the event loop can drain CEF", closed_labels ); } log::info!("[app] perform_early_teardown_sync — early teardown complete"); } fn perform_early_teardown_sync_once(app_handle: &AppHandle, reason: &str) { if EARLY_TEARDOWN_RAN.swap(true, std::sync::atomic::Ordering::SeqCst) { log::info!( "[app] perform_early_teardown_sync_once — already ran, skipping reason={reason}" ); return; } log::info!("[app] perform_early_teardown_sync_once — reason={reason}"); perform_early_teardown_sync(app_handle); } /// Shared early teardown logic before CEF's shutdown to prevent races and zombie processes. /// Asynchronous version to be called from async Tauri commands (e.g. `restart_app`, updates). async fn perform_early_teardown_async(app_handle: &AppHandle) { log::info!("[app] perform_early_teardown_async — early teardown"); let closed_labels = close_early_cef_webviews(app_handle); shutdown_imessage_scanner(app_handle); webview_apis::server::stop(); if let Some(core) = app_handle.try_state::() { let core = core.inner().clone(); core.send_terminate_signal().await; } wait_for_cef_webviews_to_close_async(app_handle, &closed_labels).await; log::info!("[app] perform_early_teardown_async — early teardown complete"); } /// Explicitly winds down CEF and Tauri before an app.exit(0) fn shutdown_app_sync(app_handle: &AppHandle, exit_code: i32) { log::info!("[app] shutdown_app_sync — starting early teardown"); perform_early_teardown_sync_once(app_handle, "shutdown_app_sync"); log::info!("[app] shutdown_app_sync — early teardown complete, exiting"); app_handle.exit(exit_code); } #[cfg(target_os = "linux")] const WSL_X11_DESKTOP_WARNING: &str = "[startup] likely unsupported desktop environment: WSL with classic X11 forwarding detected (DISPLAY is set, but WAYLAND_DISPLAY/WSLg markers are absent). OpenHuman's Tauri/CEF desktop flow is fragile in this setup; use native Windows development or Windows 11 WSLg for desktop GUI work."; #[cfg(any(target_os = "linux", test))] fn should_warn_for_wsl_x11_desktop( is_wsl: bool, display_set: bool, wayland_display_set: bool, wslg_marker_set: bool, ) -> bool { is_wsl && display_set && !wayland_display_set && !wslg_marker_set } #[cfg(target_os = "linux")] fn is_wsl_environment() -> bool { if std::env::var("WSL_DISTRO_NAME") .ok() .filter(|v| !v.trim().is_empty()) .is_some() { return true; } std::fs::read_to_string("/proc/sys/kernel/osrelease") .ok() .map(|release| release.to_ascii_lowercase().contains("microsoft")) .unwrap_or(false) } #[cfg(target_os = "linux")] fn has_non_empty_env(key: &str) -> bool { std::env::var(key) .ok() .filter(|v| !v.trim().is_empty()) .is_some() } #[cfg(target_os = "linux")] fn has_wslg_marker() -> bool { has_non_empty_env("WSLG_RUNTIME_DIR") || std::path::Path::new("/mnt/wslg").exists() } #[cfg(target_os = "linux")] fn warn_if_wsl_x11_desktop_launch() { if should_warn_for_wsl_x11_desktop( is_wsl_environment(), has_non_empty_env("DISPLAY"), has_non_empty_env("WAYLAND_DISPLAY"), has_wslg_marker(), ) { log::warn!("{WSL_X11_DESKTOP_WARNING}"); eprintln!("{WSL_X11_DESKTOP_WARNING}"); } } #[cfg(not(target_os = "linux"))] fn warn_if_wsl_x11_desktop_launch() {} /// Returns `true` if a display server is available on Linux. /// Testable pure function: takes the env-presence booleans directly. #[cfg(any(target_os = "linux", test))] fn linux_display_server_present(display: bool, wayland_display: bool) -> bool { display || wayland_display } /// Pre-CEF display-server check for Linux (Sentry OPENHUMAN-TAURI-K1). /// /// CEF/Chromium requires X11 (`DISPLAY`) or Wayland (`WAYLAND_DISPLAY`) to /// initialise. Without either, `cef_initialize` returns 0 and the vendored /// `tauri-runtime-cef` asserts `result == 1` → panic `left: 0, right: 1`. /// This is fatal and silent on WSL2 without WSLg and on any headless Linux box. /// Detect it here and exit with a clear message before `CefRuntime::init` runs. #[cfg(target_os = "linux")] fn check_linux_display_server() { if linux_display_server_present( has_non_empty_env("DISPLAY"), has_non_empty_env("WAYLAND_DISPLAY"), ) { log::debug!( "[cef-preflight] Linux display server present: DISPLAY={:?} WAYLAND_DISPLAY={:?}", std::env::var("DISPLAY").ok(), std::env::var("WAYLAND_DISPLAY").ok() ); return; } let msg = "[openhuman] no display server found (DISPLAY and WAYLAND_DISPLAY are both unset).\n\ OpenHuman requires an X11 or Wayland display to run.\n\ On WSL2: install WSLg or configure X11 forwarding from Windows.\n\ Set DISPLAY (e.g. export DISPLAY=:0) or WAYLAND_DISPLAY before launching."; log::error!( "[cef-preflight] Linux display server missing — CEF cannot initialize \ (OPENHUMAN-TAURI-K1): DISPLAY and WAYLAND_DISPLAY both unset" ); eprintln!("\n{msg}\n"); std::process::exit(1); } #[cfg(not(target_os = "linux"))] fn check_linux_display_server() {} /// Pure predicate: given a candidate `DBUS_SESSION_BUS_ADDRESS` value, decide /// whether it points to a transport `zbus` can actually open. /// /// `tauri-plugin-single-instance` on Linux calls /// `zbus::blocking::connection::Builder::session().unwrap()` in its `setup()` /// closure. When `DBUS_SESSION_BUS_ADDRESS` is missing or set to `disabled` /// (the literal string WSL2-without-WSLg sets), zbus returns /// `Address("unsupported transport 'disabled'")` and the unwrap blows up the /// whole process before any window is created (Sentry OPENHUMAN-TAURI-TM). /// /// We treat an address as reachable only when at least one alternative listed /// in the env var uses a transport `zbus` ships with: `unix:`, `tcp:`, /// `launchd:`, or `autolaunch:`. Anything else (`disabled`, empty, unknown /// scheme) is treated as unreachable so we can skip registering the plugin /// instead of crashing. #[cfg(any(target_os = "linux", test))] fn dbus_address_is_supported(addr: &str) -> bool { addr.split(';').any(|entry| { let entry = entry.trim(); if entry.is_empty() { return false; } let transport = entry.split(':').next().unwrap_or("").trim(); matches!(transport, "unix" | "tcp" | "launchd" | "autolaunch") }) } /// Pure predicate: decide whether the Linux D-Bus session bus is reachable /// based on the relevant env vars and (optionally) the existence of the /// `$XDG_RUNTIME_DIR/bus` socket. /// /// Used to gate registration of `tauri-plugin-single-instance` on Linux so /// WSL2-without-WSLg / minimal-container launches don't crash on the plugin's /// `unwrap()` (Sentry OPENHUMAN-TAURI-TM). #[cfg(any(target_os = "linux", test))] fn linux_dbus_session_reachable( dbus_session_bus_address: Option<&str>, xdg_runtime_bus_socket_exists: bool, ) -> bool { match dbus_session_bus_address { Some(addr) => dbus_address_is_supported(addr), // When the env var is unset, zbus falls back to autolaunch which on // most distros requires `$XDG_RUNTIME_DIR/bus` to exist. Treat its // absence as "no D-Bus". None => xdg_runtime_bus_socket_exists, } } /// Returns `true` when this process can safely register /// `tauri-plugin-single-instance` without tripping the /// `Address("unsupported transport 'disabled'")` panic on Linux. /// Always `true` on non-Linux platforms. #[cfg(target_os = "linux")] fn can_register_single_instance_plugin() -> bool { let env_addr = std::env::var("DBUS_SESSION_BUS_ADDRESS").ok(); let runtime_bus_present = std::env::var("XDG_RUNTIME_DIR") .ok() .map(|dir| std::path::Path::new(&dir).join("bus").exists()) .unwrap_or(false); linux_dbus_session_reachable(env_addr.as_deref(), runtime_bus_present) } #[cfg(not(target_os = "linux"))] fn can_register_single_instance_plugin() -> bool { true } type CefCommandLineArg = (&'static str, Option<&'static str>); /// Returns `true` when the process is running as root (UID 0) on Linux. /// Testable pure function; takes the uid directly. #[cfg(any(target_os = "linux", test))] fn linux_is_root_uid(uid: u32) -> bool { uid == 0 } /// Whether to skip the Linux `--disable-gpu` / `--disable-gpu-compositing` /// workaround. /// /// The workaround was added for issue #1697 — on Arch/Manjaro-family Linux /// systems, the AppImage can abort during CEF GPU process startup when EGL /// context creation fails before Chromium's own fallback path gets a usable /// renderer. The unconditional disable keeps packaged builds launchable on /// those configurations, but the side effect is that WebGL2 surfaces (the /// Rive mascot on the Human tab; any other GPU-accelerated canvas) cannot /// initialise. Users on working GPU stacks (most Ubuntu, WSL2 with proper /// driver passthrough, Fedora, etc.) can opt back into hardware /// acceleration by setting `OPENHUMAN_FORCE_GPU=1`. /// /// Uses the same recognized truthy tokens as [`cef_prewarm_enabled`], but /// this control is explicit opt-in: `1` / `true` / `yes` / `on` /// (case-insensitive) enables the override, and anything else (including /// unset) preserves the default disable. fn cef_force_gpu_enabled(env_override: Option<&str>) -> bool { match env_override { Some(v) => { let v = v.trim().to_ascii_lowercase(); v == "1" || v == "true" || v == "yes" || v == "on" } None => false, } } fn append_platform_cef_gpu_workarounds( args: &mut Vec, os: &str, arch: &str, force_gpu_override: Option<&str>, ) { // Issue #1697: on Arch/Manjaro-family Linux systems, the AppImage can // abort during CEF GPU process startup when EGL context creation fails // before Chromium's own fallback path gets a usable renderer. Disable the // hardware GPU path on Linux so packaged builds can still launch via // software compositing. Users with working GPU stacks can opt back into // hardware acceleration via `OPENHUMAN_FORCE_GPU=1` — required for the // Rive mascot on the Human tab and any other WebGL2 surface to render. if os == "linux" { if cef_force_gpu_enabled(force_gpu_override) { log::info!( "[cef-startup] OPENHUMAN_FORCE_GPU set — skipping --disable-gpu / --disable-gpu-compositing (issue #1697). If the app fails to launch with a GPU process abort, unset the env var." ); } else { args.push(("--disable-gpu", None)); args.push(("--disable-gpu-compositing", None)); log::info!( "[cef-startup] Linux detected: adding --disable-gpu and --disable-gpu-compositing (issue #1697); set OPENHUMAN_FORCE_GPU=1 to re-enable hardware compositing (needed for WebGL2 surfaces like the Rive mascot)" ); } } // Issue #1012: Intel macOS (x86_64) crashes with EXC_CRASH (SIGABRT) // inside CrBrowserMain when CEF 146 tries to use GPU compositing via // Metal on Intel GPU hardware/drivers. Disable GPU compositing on // x86_64 macOS so the browser process falls back to software compositing // instead of aborting. if os == "macos" && arch == "x86_64" { args.push(("--disable-gpu-compositing", None)); log::info!( "[cef-startup] Intel macOS detected: adding --disable-gpu-compositing (issue #1012)" ); } // Sentry OPENHUMAN-TAURI-K1: `cef::initialize` returns 0 when running as // root (uid 0) on Linux unless `--no-sandbox` is passed as a command-line // argument. The `no_sandbox: 1` field in `cef::Settings` disables the // sub-process sandbox but does NOT satisfy Chromium's separate root-user // check in the browser process — that check requires the CLI flag. // // This hits CI / coder-bot / Docker environments (e.g. // `/root/.hermes/profiles/coder-bot/home`) that run as root inside a // container. Without the flag, `cef_initialize` returns 0 and the vendored // runtime assertion fires (`left: 0, right: 1`). #[cfg(target_os = "linux")] { let uid = nix::unistd::getuid().as_raw(); // Dev-only: also honor OPENHUMAN_CEF_NO_SANDBOX=1 so a non-root headless // box (no sudo to chown chrome-sandbox root:4755) can launch over RDP. // // SECURITY: gated to debug builds only. Disabling Chromium's process // sandbox in a release binary would let anyone with env-write access // on the host silently turn off a meaningful defense-in-depth layer // (graycyrus review on PR #2875). In release builds `forced` stays // false so only the `linux_is_root_uid` path can opt in (uid=0 // already implies root-equivalent trust). #[cfg(debug_assertions)] let forced = std::env::var("OPENHUMAN_CEF_NO_SANDBOX") .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false); #[cfg(not(debug_assertions))] let forced = false; if os == "linux" && (linux_is_root_uid(uid) || forced) { args.push(("--no-sandbox", None)); log::info!( "[cef-startup] Linux: adding --no-sandbox (root uid or OPENHUMAN_CEF_NO_SANDBOX) \ (OPENHUMAN-TAURI-K1)" ); } } } /// Linux only: replace Xlib's default error handler with a logging no-op. /// /// Why: on Wayland sessions (GNOME/KDE/Hyprland) running CEF via XWayland, /// the CEF browser process issues `XConfigureWindow` against a window the /// XWayland server hasn't fully realized yet, and Xlib's *default* error /// handler reacts to the resulting `BadWindow` by calling `exit(1)` — which /// kills the entire app before the main window ever paints. This reproduces /// reliably on Ubuntu 26.04 + GNOME-Wayland with the locally-built AppImage /// (issue #2001 item #2 — was punted as "separate display-side concerns" /// in PR #2032 but blocks any actual use of the app on a Wayland host). /// /// XSetErrorHandler is a process-global registration; safe to install before /// any X display is opened. libX11 is already a runtime dep (verified via /// ldd of the compiled OpenHuman binary). #[cfg(target_os = "linux")] fn install_silent_x_error_handler() { use std::ffi::c_void; use std::os::raw::{c_int, c_uchar, c_ulong}; use std::sync::Once; #[repr(C)] struct XErrorEvent { type_: c_int, display: *mut c_void, resourceid: c_ulong, serial: c_ulong, error_code: c_uchar, request_code: c_uchar, minor_code: c_uchar, } type ErrorHandler = unsafe extern "C" fn(*mut c_void, *mut XErrorEvent) -> c_int; unsafe extern "C" { fn XSetErrorHandler(handler: Option) -> Option; } unsafe extern "C" fn silent_handler(_display: *mut c_void, ev: *mut XErrorEvent) -> c_int { if !ev.is_null() { let e = unsafe { &*ev }; log::warn!( "[x11] suppressed X protocol error: code={} request={} minor={} resource=0x{:x} serial={}", e.error_code, e.request_code, e.minor_code, e.resourceid, e.serial ); } else { log::warn!("[x11] suppressed X protocol error (null event)"); } 0 } static INIT: Once = Once::new(); INIT.call_once(|| { unsafe { XSetErrorHandler(Some(silent_handler)); } log::info!( "[x11] installed silent X error handler to prevent BadWindow exits on Wayland-XWayland (issue #2001 item #2)" ); }); } #[cfg(not(target_os = "linux"))] fn install_silent_x_error_handler() {} pub fn run() { // Must run before any GTK/CEF code that could trigger X calls — otherwise // Xlib's default handler calls exit(1) on the first BadWindow and we never // reach this line. See helper doc above for the full reasoning. install_silent_x_error_handler(); // ── Install a custom tokio runtime for tauri::async_runtime ───────── // // Tauri's default async runtime uses tokio multi-thread workers with // a ~2 MB stack. The in-process core (spawned by // `core_process::CoreProcessHandle::ensure_running` via // `tokio::spawn(run_server_embedded(..))`) runs *on* that runtime, so // every JSON-RPC handler — including the deep tower // `web channel chat → orchestrator turn → delegate_to_integrations_agent // → sub-agent → composio_list_tools → load_config_with_timeout` — // burns through the same 2 MB. In `crahs.log` (2026-05-17, build // 0.53.49) that tower plus the serde-monomorphised `Config` Visitor // frames pushed past the guard page and aborted with // `SIGBUS / KERN_PROTECTION_FAILURE`. // // The structural fix (`spawn_blocking` for the TOML parse + cache in // `src/openhuman/config/{schema/load.rs, ops.rs}`) moves the largest // contributor off the worker. An initial 8 MiB bump shipped here was // enough for that single tower, but sub-agent delegation (issue #3159 // / PR #3155) re-tipped the scale: the standalone `openhuman-core` // CLI server still aborted with `Abort trap: 6 / fatal runtime error: // stack overflow` once an orchestrator delegated. PR #3155 raised the // standalone server to 16 MiB; the desktop Tauri host is the *same* // tower running on a *different* runtime and needs the same headroom. // Share the constant with the rest of `src/core/*` via // [`openhuman_core::core::runtime::AGENT_WORKER_STACK_BYTES`] so all // multi-thread runtimes that may host an agent turn stay in sync. // // Must happen before any `tauri::async_runtime::*` call, otherwise // `set(...)` panics with "runtime already initialized". { let custom_runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .thread_stack_size(openhuman_core::core::runtime::AGENT_WORKER_STACK_BYTES) .build() .expect("build custom tokio runtime for tauri async surface"); let handle = custom_runtime.handle().clone(); // Tauri docs: "you cannot drop the underlying TokioRuntime." // Leak it so its lifetime matches the process. std::mem::forget(custom_runtime); tauri::async_runtime::set(handle); } // Initialize Sentry for the Tauri shell (desktop host) process before any // other startup work. Reads `OPENHUMAN_TAURI_SENTRY_DSN` at runtime first, // then falls back to the value baked in at compile time via the release // workflow. Missing/empty DSN ⇒ `sentry::init` returns a no-op guard. // // The guard is held for the entire lifetime of `run()` so events queued // during shutdown still flush. Only invoked here (and not in `main.rs`) // so renderer/GPU CEF helper subprocesses (re-exec'd via // `tauri::cef_entry_point`) and the `OpenHuman core …` in-process core // path do NOT spin up a second client — those have their own reporting // surfaces. let _sentry_guard = sentry::init(sentry::ClientOptions { dsn: std::env::var("OPENHUMAN_TAURI_SENTRY_DSN") .ok() .filter(|s| !s.is_empty()) .or_else(|| option_env!("OPENHUMAN_TAURI_SENTRY_DSN").map(|s| s.to_string())) .filter(|s| !s.is_empty()) .and_then(|s| s.parse().ok()), release: Some(std::borrow::Cow::Owned(build_sentry_release_tag())), environment: Some(std::borrow::Cow::Owned(resolve_sentry_environment())), send_default_pii: false, before_send: Some(std::sync::Arc::new(|mut event| { // Drop "dev-server fetch failed" noise: the vendored // `tauri-runtime-cef` dev proxy // (vendor/tauri-cef/crates/tauri/src/protocol/tauri.rs) calls // `log::error!("Failed to request {url}: {err}")` whenever the // CEF webview asks for an asset on `http://localhost:1420` (the // Vite dev URL baked into `tauri.conf.json`). That `log::error!` // is bridged into `tracing` and picked up by the sentry-tracing // layer as an Event — see `src/core/logging.rs::sentry_tracing_layer`. // In packaged staging/production builds Vite isn't running, so // the request correctly fails — but the failure is noise we // don't want in Sentry (issue OPENHUMAN-TAURI-V, 66+ events). // See [sentry-localhost-filter] log line below for diagnostics. if event_is_localhost_dev_fetch_noise(&event) { log::debug!( "[sentry-localhost-filter] dropping dev-server fetch noise event: {:?}", event.message.as_deref().unwrap_or("") ); return None; } if openhuman_core::core::observability::is_budget_event(&event) { // Log only structured tag metadata — `event.message` can carry // upstream provider error text including tokens / pasted-through // secrets, and per `CLAUDE.md` "never log secrets or full PII". // The (domain, status) pair is sufficient diagnostic since // those are the tags `is_budget_event` gates on. log::debug!( "[sentry-budget-filter] dropping budget-exhausted event (domain={:?}, status={:?})", event.tags.get("domain"), event.tags.get("status") ); return None; } // Defense-in-depth: drop max-tool-iterations cap events that // slipped past the call-site filters in the core (see // `openhuman_core::core::observability::is_max_iterations_event` // for the rationale). The shell links the core in-process so // any captured event for this deterministic agent-state // outcome is filtered here too (OPENHUMAN-TAURI-99 / -98). if openhuman_core::core::observability::is_max_iterations_event(&event) { log::debug!( "[sentry-max-iter-filter] dropping max-iteration cap noise event: {:?}", event.message.as_deref().unwrap_or("") ); return None; } if openhuman_core::core::observability::is_transient_backend_api_failure(&event) || openhuman_core::core::observability::is_transient_integrations_failure(&event) || openhuman_core::core::observability::is_updater_transient_event(&event) { return None; } // Defense-in-depth: drop managed-backend `errorCode` events (#870) // the backend owns (F2/F4). The shell links the core in-process, // so a managed inference error captured here must be filtered // identically to the core binary's main.rs chain. The malformed // `BAD_REQUEST` carve-out (F8) is excluded by the underlying // decision, so a client-built bad payload still pages. if openhuman_core::core::observability::is_backend_error_code_event(&event) { log::debug!( "[sentry-error-code-filter] dropping backend-owned errorCode event_id={:?}", event.event_id ); return None; } // Defense-in-depth: drop transient streaming transport blips // (domain=llm_provider, failure=transport) — flaky-network // timeouts/resets recovered by retry/fallback (F7). Mirrors the // core binary's main.rs filter. if openhuman_core::core::observability::is_transient_provider_transport_failure(&event) { log::debug!( "[sentry-transport-filter] dropping transient provider transport event_id={:?}", event.event_id ); return None; } // Drop 401 "Session expired. Please log in again." bodies and // pre-flight "no session token stored" guards — mirrors the // core binary's before_send chain. Since #1061 the Tauri shell // links the core in-process, so any session-expired event // captured by either surface lands in the same Sentry client // here and must be filtered identically. Keeps // OPENHUMAN-TAURI-25 / -1Q / -27 / -1G off Sentry. if openhuman_core::core::observability::is_session_expired_event(&event) { // Metadata-only log shape — `event.message` carries the raw // backend response body which CLAUDE.md forbids from local // logs. Mirror the core binary's main.rs filter. log::debug!( "[sentry-session-expired-filter] dropping session-expired event_id={:?}", event.event_id ); return None; } // Strip server_name (hostname) to avoid leaking machine identity. event.server_name = None; // Attach the cached account uid so Sentry can count unique users // affected by an issue. We only carry `id` — never email, name, // or IP — so this stays consistent with `send_default_pii: false`. // Since #1061 the core runs in-process inside this shell, so this // is the surface that tags ~all desktop events. // // Issue #3135: the primary source for `event.user` is now the // Sentry scope, bound proactively at session boundaries // (credentials::store_session / clear_session) and at server boot // (run_server_inner). The `app_state_snapshot` cache is kept as a // fallback for legacy cache-warming paths, but we only consult it // when the scope hasn't already bound a user — otherwise we'd // silently clobber the scope binding when the cache is empty // (the original userCount=0 root cause). if event.user.is_none() { event.user = openhuman_core::openhuman::app_state::peek_cached_current_user_identity() .and_then(|identity| identity.id) .map(|id| sentry::User { id: Some(id), ..Default::default() }); } Some(event) })), sample_rate: 1.0, ..sentry::ClientOptions::default() }); // Tag every Sentry event with CPU architecture and OS so Intel-specific // crashes (issue #1012 — SIGABRT in CrBrowserMain on x86_64 macOS) are // clearly identified without needing a separate build identifier. sentry::configure_scope(|scope| { scope.set_tag("cpu_arch", std::env::consts::ARCH); scope.set_tag("os_name", std::env::consts::OS); #[cfg(target_os = "macos")] if let Some(ver) = macos_os_version() { scope.set_tag("os_version", ver); } }); // Optional smoke trigger for verifying the Sentry pipeline end-to-end. // Run with `OPENHUMAN_TAURI_SENTRY_TEST=panic` to fire a panic, or // `=message` to send a captured-message event. No-op when unset. if let Ok(mode) = std::env::var("OPENHUMAN_TAURI_SENTRY_TEST") { match mode.as_str() { "panic" => panic!("OPENHUMAN_TAURI_SENTRY_TEST=panic — local Sentry smoke test"), "message" => { sentry::capture_message( "OPENHUMAN_TAURI_SENTRY_TEST=message — local Sentry smoke test", sentry::Level::Error, ); let _ = sentry::Hub::current().client().map(|c| c.flush(None)); } other => log::warn!( "OPENHUMAN_TAURI_SENTRY_TEST={other:?} — unknown mode (use 'panic' or 'message')" ), } } let daemon_mode = is_daemon_mode(); // Install the unified tracing subscriber + daily-rotated file appender // before any other startup work so CEF preflight failures, sentry // smoke-test events, and the rest of `run()` are captured in // `/logs/openhuman-YYYY-MM-DD.log`. The shell's `log::*` calls // are bridged into the same subscriber via `tracing_log::LogTracer`, // replacing the previous stderr-only `env_logger`. file_logging::init(); // Log platform identity early so every log session is tagged with arch // and OS version — essential for reproducing and triaging Intel-only // crashes like issue #1012 (SIGABRT in CrBrowserMain on x86_64 macOS). { let arch = std::env::consts::ARCH; let os = std::env::consts::OS; #[cfg(target_os = "macos")] let os_ver = macos_os_version().unwrap_or_else(|| "unknown".to_string()); #[cfg(not(target_os = "macos"))] let os_ver = "n/a".to_string(); log::info!("[startup] platform: arch={arch} os={os} os_version={os_ver}"); } warn_if_wsl_x11_desktop_launch(); // Exit before CEF if no display server is available — prevents the // `assert_eq!(cef_initialize(…), 1)` panic (OPENHUMAN-TAURI-K1). check_linux_display_server(); // The vendored tauri-cef dev-server proxy builds a reqwest 0.13 client // (see vendor/tauri-cef/crates/tauri/src/protocol/tauri.rs) which calls // rustls 0.23's `CryptoProvider::get_default()`. rustls 0.23 no longer // picks a provider implicitly — without one installed, the proxy panics // with "No provider set" the first time `tauri dev` forwards a request. // Install the ring provider once before any HTTPS client is built. let _ = rustls::crypto::ring::default_provider().install_default(); // ── Windows pre-CEF single-instance guard (Sentry OPENHUMAN-TAURI-A) ── // // `tauri_plugin_single_instance` detects a second launch inside its // `.setup()` hook — but `.setup()` runs AFTER `Builder::build()` which // calls `CefRuntime::init` → `cef::initialize()`. On a second launch, // `cef::initialize()` returns 0 because the primary holds the CEF // cache lock; the vendored runtime asserts `result == 1` and panics // (left: 0, right: 1, fatal, Windows-only, 598 events). // // Fix: acquire a named Win32 mutex at the very top of `run()` — before // any CEF or builder work — so any secondary instance sees // `ERROR_ALREADY_EXISTS` and exits immediately. If the secondary was // launched for an `openhuman://` OAuth callback, forward that URL to the // primary through our pre-CEF pipe before exiting; the Tauri deep-link // plugin cannot run on this early secondary path. // // The RAII guard holds the mutex handle for the lifetime of `run()`. // Windows releases all process handles automatically on exit, so // explicit cleanup is only needed if `run()` returns normally. #[cfg(windows)] let _cef_init_mutex_guard = { use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, ERROR_ALREADY_EXISTS}; use windows_sys::Win32::System::Threading::CreateMutexW; // Must match the bundle identifier in tauri.conf.json. // Changing the app identifier requires updating this string too. let mutex_name: Vec = "com.openhuman.app-cef-init\0".encode_utf16().collect(); // SAFETY: mutex_name is null-terminated UTF-16; handle is checked below. let handle = unsafe { CreateMutexW(std::ptr::null(), 0, mutex_name.as_ptr()) }; // Capture GetLastError immediately after CreateMutexW so no intervening // syscall (e.g. logging) can clobber the thread-local error code. let last_error = unsafe { GetLastError() }; // Primary: hold the handle until run() returns. struct OwnedMutex(isize); impl Drop for OwnedMutex { fn drop(&mut self) { if self.0 != 0 { unsafe { CloseHandle(self.0 as _) }; } } } if handle.is_null() { // CreateMutexW failed for a reason other than "already exists" // (which returns a valid handle plus ERROR_ALREADY_EXISTS). Likely // causes: out-of-memory, security-descriptor fault, or other // Win32-level anomaly. Without the guard, a concurrent second // launch can re-trigger the cef::initialize panic this block was // added to prevent — but refusing to start at all is strictly // worse for the user. Log loudly so the failure is observable in // Sentry / log files and continue best-effort. log::error!( "[single-instance] CreateMutexW returned NULL handle (GetLastError={last_error}); continuing without pre-CEF single-instance guard — concurrent launches may hit OPENHUMAN-TAURI-A" ); OwnedMutex(0) } else if last_error == ERROR_ALREADY_EXISTS { // Another instance is already past this point — exit before we // touch CEF at all. Forward deep links first so OAuth callbacks // are not dropped by this early pre-plugin exit. match deep_link_ipc_windows::try_forward_deep_links() { deep_link_ipc_windows::ForwardResult::Forwarded | deep_link_ipc_windows::ForwardResult::NoUrls => {} deep_link_ipc_windows::ForwardResult::NoPrimary => { log::warn!( "[single-instance] secondary had deep-link argv but could not reach primary pipe" ); } } unsafe { CloseHandle(handle) }; log::info!( "[single-instance] pre-CEF mutex held by primary; secondary exiting (OPENHUMAN-TAURI-A fix)" ); std::process::exit(0); } else { OwnedMutex(handle as isize) } }; #[cfg(windows)] let _deep_link_pipe_guard = deep_link_ipc_windows::bind_and_listen(); // CEF cache-lock preflight (macOS only): if another OpenHuman instance // is already holding the CEF user-data-dir, the vendored // `tauri-runtime-cef` panics inside `cef::initialize` with a Rust // backtrace and no actionable message (issue #864). Catch the collision // here and exit cleanly with a message that names the lock-holder PID // and the workaround. Stale locks (PID dead) are removed and we // continue, matching Chromium's own startup recovery. match cef_profile::prepare_process_cache_path() { Ok(path) => log::debug!("[cef-profile] startup cache path={}", path.display()), Err(error) => { log::error!( "[cef-profile] failed to configure per-user CEF cache; refusing to start with shared/global cache: {error}" ); std::process::exit(1); } } #[cfg(target_os = "macos")] process_recovery::reap_stale_openhuman_processes(); // ── Windows pre-CEF cache-lock wait (Sentry TAURI-RUST-F) ───────────── // The Win32 mutex above stops a *concurrent* second launch, but on a // *sequential* relaunch (auto-update, fast quit+reopen, restart) the prior // instance can still hold the CEF cache lock for a short teardown window // after releasing the mutex. Calling `cef::initialize()` into that live // lock returns 0 and the vendored runtime asserts `== 1` → panic. Wait // (bounded) for the prior instance to exit before proceeding; this never // suppresses a crash — it prevents `cef::initialize()` from running // against a locked cache. Analogous to the macOS reap above. #[cfg(target_os = "windows")] cef_singleton_wait::wait_for_cache_release(); // ── Linux pre-CEF deep-link forwarding guard (issue #2359) ──────────── // On Linux, a secondary instance with an openhuman:// URL in argv exits // at the CEF preflight check before Builder::setup() runs, silently // dropping the OAuth callback. Detect and forward the URL here, before // CEF preflight can exit(1). #[cfg(target_os = "linux")] let _deep_link_socket_guard = { use deep_link_ipc::ForwardResult; match deep_link_ipc::try_forward_deep_links() { ForwardResult::Forwarded => { std::process::exit(0); } ForwardResult::NoPrimary | ForwardResult::NoUrls => {} } deep_link_ipc::bind_and_listen() }; // CEF cache-lock preflight (macOS + Linux): if another OpenHuman instance // holds the CEF user-data-dir SingletonLock, `cef_initialize` returns 0 and // the vendored runtime used to panic (`left: 0, right: 1`). The common // cause is a *sequential relaunch race* where the prior instance is still // tearing down, so rather than exit on the first collision we wait // (bounded, exponential backoff — the macOS/Linux analogue of the Windows // pre-CEF wait) for the lock to clear, then proceed. If it is still held // after the budget we exit cleanly (code 0). Stale locks (PID dead) are // removed so crashed processes don't block launches. macOS: issue #864. // Linux: OPENHUMAN-TAURI-K1. Sentry: TAURI-RUST-F. #[cfg(any(target_os = "macos", target_os = "linux"))] cef_preflight::wait_for_cache_release(); let builder = { // Bypass macOS Keychain. Without this, every embedded service that // touches password / cookie / encryption-key storage triggers a // "Allow access to your keychain?" prompt — WhatsApp Web hits it on // every cold start, Chromium's own component-update store also does. // `use-mock-keychain` swaps the Keychain backend for an in-process // mock; `password-store=basic` is the equivalent for the password // manager. Both are no-ops on Windows/Linux, so safe to always set. // // CDP attach goes through the in-process channel only — see // `app/src-tauri/src/cdp/in_process.rs`. The legacy // `--remote-debugging-port` flag is no longer passed: every // scanner attaches via `Webview::send_dev_tools_message` and // there is no remaining loopback DevTools listener. // // NOTE: flags must be prefixed with `--`. The runtime's // `on_before_command_line_processing` dispatch (in // `tauri-runtime-cef/src/cef_impl.rs`) routes value-less args that // don't start with `-` to `append_argument` (positional) instead of // `append_switch`, which means Chromium silently ignores them. let mut args: Vec = vec![ ("--use-mock-keychain", None), ("--password-store", Some("basic")), // Enable SharedArrayBuffer so embedded apps that need WebRTC // audio worklets / Opus encoders (Slack Huddles, Meet // real-time features, Discord voice) can actually initialise. // Chromium gates SharedArrayBuffer behind cross-origin // isolation by default; web apps embedded inside CEF rarely // send COOP/COEP headers, so without this flag the feature // silently disappears and huddle/call buttons no-op. ("--enable-features", Some("SharedArrayBuffer")), // Defeat Chromium's modern throttlers that ignore the // legacy `--disable-background-timer-throttling` flag. // Empirically with that flag *alone*, the producer in the // (visible but non-key) main window still got pinned to // 1Hz worker timers as soon as the off-screen Meet window // opened. These three feature flags are the canonical // additional knobs (Electron / Puppeteer use them). ( "--disable-features", Some( "IntensiveWakeUpThrottling,CalculateNativeWinOcclusion,\ AutofillActorMode,GlicActorUi,LensOverlay", ), ), // Allow autoplay (audio + video) without a prior user gesture. // CEF inherits Chromium's default policy, which leaves an // AudioContext suspended until the user interacts with the // page; @remotion/player gates its rAF frame loop on // AudioContext.state === 'running', so on a cold tab the // mascot SVG paints frame 0 and freezes there until the user // alt-tabs / clicks somewhere (which counts as a gesture and // resumes the context — the "fast on revisit" symptom). With // this flag the AudioContext starts in 'running' immediately // and the mascot animates from first paint. We control every // surface in this webview, so dropping the gesture gate is // safe. ("--autoplay-policy", Some("no-user-gesture-required")), // Background-throttling defeaters. The MeetCallProducer // pumps mascot frames at 24 fps from the *main* OpenHuman // window, but as soon as the off-screen Meet webview opens // (or the user clicks anywhere outside main), macOS demotes // the renderer's priority and Chromium throttles its // setInterval / worker timers / rAF down to ~1 Hz — the // mascot stream collapses to 1 fps. Page-level workarounds // (silent AudioContext, muted