#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] compile_error!("src-tauri host is desktop-only. Non-desktop targets are not supported."); mod cdp; #[cfg(target_os = "macos")] mod cef_preflight; mod cef_profile; mod core_process; mod core_rpc; mod dictation_hotkeys; mod discord_scanner; mod gmessages_scanner; mod imessage_scanner; #[cfg(target_os = "macos")] mod mascot_native_window; mod notification_settings; mod screen_capture; mod slack_scanner; mod telegram_scanner; mod webview_accounts; mod webview_apis; mod whatsapp_scanner; mod window_state; #[cfg(target_os = "macos")] 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}; #[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; #[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()) } #[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 } #[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, } /// 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(AppUpdateInfo { current_version, available: false, available_version: None, body: None, }) } Err(e) => { log::warn!("[app-update] check failed: {e}"); Err(format!("update check failed: {e}")) } } } /// 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(", ") ); 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(()) } fn is_daemon_mode() -> bool { std::env::args().any(|arg| arg == "daemon" || arg == "--daemon") } /// 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 } /// Tauri command: fire a native OS notification from the frontend. Used by /// the in-app notification center to banner events (agent completions, /// connection drops, etc.) when the window is not focused. #[tauri::command] fn show_native_notification( app: AppHandle, title: String, body: String, tag: Option, ) -> Result<(), String> { use tauri_plugin_notification::NotificationExt; let permission_state = app .notification() .permission_state() .map(|s| format!("{s:?}")) .unwrap_or_else(|e| format!("err({e})")); log::debug!( "[notify] show_native_notification title_chars={} body_chars={} tag={:?} permission={permission_state}", title.len(), body.len(), tag ); let mut builder = app.notification().builder().title(&title); if !body.is_empty() { builder = builder.body(&body); } #[cfg(target_os = "macos")] { builder = builder.sound("default"); } builder .show() .map_err(|e| format!("notification show failed: {e}")) } #[cfg(target_os = "macos")] fn macos_notification_permission_state_inner() -> Result { use std::ptr::NonNull; use std::sync::mpsc; use block2::RcBlock; use objc2_user_notifications::{ UNAuthorizationStatus, UNNotificationSettings, UNUserNotificationCenter, }; let center = UNUserNotificationCenter::currentNotificationCenter(); let (tx, rx) = mpsc::channel::(); let completion = RcBlock::new(move |settings: NonNull| { let status = unsafe { settings.as_ref().authorizationStatus() }; let state = if status == UNAuthorizationStatus::Authorized { "granted" } else if status == UNAuthorizationStatus::Denied { "denied" } else if status == UNAuthorizationStatus::NotDetermined { "not_determined" } else if status == UNAuthorizationStatus::Provisional { "provisional" } else if status == UNAuthorizationStatus::Ephemeral { "ephemeral" } else { "unknown" }; let _ = tx.send(state.to_string()); }); center.getNotificationSettingsWithCompletionHandler(&completion); rx.recv_timeout(std::time::Duration::from_secs(2)) .map_err(|_| "timed out waiting for macOS notification settings".to_string()) } #[cfg(target_os = "macos")] fn macos_notification_permission_request_inner() -> Result { use block2::RcBlock; use objc2::runtime::Bool; use objc2_foundation::NSError; use objc2_user_notifications::{UNAuthorizationOptions, UNUserNotificationCenter}; use std::sync::mpsc; let center = UNUserNotificationCenter::currentNotificationCenter(); let (tx, rx) = mpsc::channel::(); let options = UNAuthorizationOptions::Alert | UNAuthorizationOptions::Badge | UNAuthorizationOptions::Sound; let completion = RcBlock::new(move |granted: Bool, _error: *mut NSError| { let _ = tx.send(granted.as_bool()); }); center.requestAuthorizationWithOptions_completionHandler(options, &completion); let granted = rx .recv_timeout(std::time::Duration::from_secs(5)) .map_err(|_| "timed out waiting for macOS permission prompt result".to_string())?; if granted { Ok("granted".to_string()) } else { // If the user denies or notifications are disabled for the app, // macOS reports `false` here. Ok("denied".to_string()) } } #[tauri::command] fn notification_permission_state() -> Result { #[cfg(target_os = "macos")] { return macos_notification_permission_state_inner(); } #[cfg(not(target_os = "macos"))] { Ok("granted".to_string()) } } #[tauri::command] fn notification_permission_request() -> Result { #[cfg(target_os = "macos")] { return macos_notification_permission_request_inner(); } #[cfg(not(target_os = "macos"))] { Ok("granted".to_string()) } } fn show_main_window(app: &AppHandle) -> Result<(), String> { let window = app .get_webview_window("main") .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}"))?; Ok(()) } #[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::error!("[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::error!("[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"; /// 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"); } /// 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(app_handle); log::info!("[app] shutdown_app_sync — early teardown complete, exiting"); app_handle.exit(exit_code); } pub fn run() { // 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| { // Strip server_name (hostname) to avoid leaking machine identity. event.server_name = None; event.user = None; Some(event) })), sample_rate: 1.0, ..sentry::ClientOptions::default() }); // 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(); let default_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()); let _ = env_logger::Builder::new() .parse_filters(&default_filter) .try_init(); // 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(); // 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")] if let Err(e) = cef_preflight::check_default_cache() { eprintln!("\n[openhuman] {e}\n"); std::process::exit(1); } 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. // // In debug builds we additionally expose the Chrome DevTools // Protocol on localhost:19222 so every CEF webview can be // inspected from a regular browser (right-click "Inspect" does // not propagate to CEF child webviews on macOS). Release builds // intentionally do NOT open the CDP port — it would let any // process on the machine drive the embedded WhatsApp/Slack/etc. // webviews. // // The port was 9222 (Chromium's default) but ollama's // OpenAI-compatible server squats on 127.0.0.1:9222 in some // installs, which silently broke CDP attach (our client hit // ollama, the WS handshake failed, child webviews stayed at // about:blank → black screen). Picked 19222 to dodge that // collision; if you change it here also update // `cdp::CDP_PORT` and `whatsapp_scanner::CDP_PORT`. // // 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<(&str, Option<&str>)> = 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")), ]; // Always expose the CDP port, not just in debug. The webview-accounts // CDP session opener navigates each embedded provider webview from its // `about:blank#openhuman-acct-...` placeholder to the real provider URL // via `Page.navigate`. Without this port available in release builds, // the CDP client can't attach (`browser_ws_url()` 404s on /json/version), // the navigation never fires, and the embedded webview stays on // `about:blank` (blank panel for Telegram / WhatsApp / Slack / Discord). // Same port the `cdp::CDP_HOST`/`cdp::CDP_PORT` constants expect. args.push(("--remote-debugging-port", Some("19222"))); tauri::Builder::::new().command_line_args::<&str, &str>(args) }; let builder = builder // Explicitly disable `open_js_links_on_click`: tauri-plugin-opener // defaults to injecting `init-iife.js` into *every* webview — a // global click listener that invokes `plugin:opener|open_url` via // HTTP-IPC. That violates our "no JS injection into CEF child // webviews" rule (see CLAUDE.md) and also fails in practice // because third-party origins (web.telegram.org, linkedin, …) // trip Tauri's Origin header check and return 500. External link // handling for `acct_*` webviews runs natively via // `on_navigation` / `on_new_window` in webview_accounts/mod.rs; // the main window uses `openUrl()` from `utils/openUrl.ts` when // it needs to hand off a URL. .plugin( tauri_plugin_opener::Builder::default() .open_js_links_on_click(false) .build(), ) .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_global_shortcut::Builder::new().build()) // Auto-updater for the Tauri shell. Endpoint and minisign pubkey live // in `tauri.conf.json` under `plugins.updater`. Releases are signed at // build time with `TAURI_SIGNING_PRIVATE_KEY` (+ `_PASSWORD`); see // docs/AUTO_UPDATE.md for the full pipeline. .plugin(tauri_plugin_updater::Builder::new().build()) .manage(dictation_hotkeys::DictationHotkeyState( std::sync::Mutex::new(Vec::new()), )) .manage(webview_accounts::WebviewAccountsState::default()) .manage(notification_settings::NotificationSettingsState::new()) .manage(PendingAppUpdateState::default()); let builder = builder.manage(std::sync::Arc::new(imessage_scanner::ScannerRegistry::new())); let builder = builder.manage(std::sync::Arc::new( gmessages_scanner::ScannerRegistry::new(), )); let builder = builder.manage(whatsapp_scanner::ScannerRegistry::new()); let builder = builder.manage(slack_scanner::ScannerRegistry::new()); let builder = builder.manage(discord_scanner::ScannerRegistry::new()); let builder = builder.manage(telegram_scanner::ScannerRegistry::new()); let builder = builder.manage(screen_capture::ScreenShareState::new()); builder .setup(move |app| { #[cfg(any(windows, target_os = "linux"))] { if let Err(err) = app.deep_link().register_all() { log::warn!("[deep-link] register_all failed (non-fatal): {err}"); } } // Start the webview_apis WebSocket bridge BEFORE spawning core — // core reads OPENHUMAN_WEBVIEW_APIS_PORT on first connect, and // connects lazily, so the env var must be set before the spawn. // // If the bridge fails to bind we clear any inherited port env so // the core child can't accidentally connect to whichever loopback // process already owns that port, then abort setup — the bridge // is load-bearing for every webview_apis RPC method. let bridge_ok = tauri::async_runtime::block_on(async { match webview_apis::start().await { Ok(port) => { std::env::set_var(webview_apis::server::PORT_ENV, port.to_string()); log::info!("[webview_apis] bridge ready on port {port}"); true } Err(err) => { log::error!("[webview_apis] failed to start bridge: {err}"); std::env::remove_var(webview_apis::server::PORT_ENV); false } } }); if !bridge_ok { return Err("webview_apis bridge failed to start — aborting setup".into()); } let _ = daemon_mode; let core_handle = core_process::CoreProcessHandle::new(core_process::default_core_port()); std::env::set_var("OPENHUMAN_CORE_RPC_URL", core_handle.rpc_url()); // Expose the shared CEF cookies SQLite path to the core sidecar // so `check_onboarding_status` can detect which webview // providers (whatsapp, slack, telegram, …) already have a live // session cookie. Best-effort — if we can't resolve the path // the core treats every provider as logged_out. if let Some(cache_dir) = cef_profile::configured_cache_path_from_env() { let cookies_db = cache_dir.join("Default").join("Cookies"); log::debug!("[webview_accounts] exposing cookies DB path to core"); std::env::set_var("OPENHUMAN_CEF_COOKIES_DB", &cookies_db); } else { // Clear any inherited value so the core can't pick up a // stale path from a previous run or the parent shell. std::env::remove_var("OPENHUMAN_CEF_COOKIES_DB"); log::warn!( "[webview_accounts] could not resolve configured CEF cache dir — core \ will report all webview providers as logged_out" ); } app.manage(core_handle.clone()); tauri::async_runtime::spawn(async move { if let Err(err) = core_handle.ensure_running().await { log::error!("[core] failed to start embedded core: {err}"); return; } log::info!("[core] embedded core ready"); }); // Restore last-known window position+size before showing the // window so the user's first paint after a restart-driven flow // (#900 identity flip) lands on the same display they used, // not back at the default centered initial size on the // primary monitor. `tauri.conf.json` ships `visible: false` // / `center: false` for the main window so the placement // happens before the first paint and there's no jump. if let Some(window) = app.get_webview_window("main") { if !window_state::restore_main(&window) { window_state::center_main(&window); } if !daemon_mode { if let Err(err) = window.show() { log::warn!("[window-state] show main window failed: {err}"); } } } if daemon_mode { if let Some(window) = app.get_webview_window("main") { let _ = window.hide(); log::info!("[tray] daemon_mode=true window_hidden_on_startup"); } } // Overlay window is currently disabled in `tauri.conf.json` (the // `overlay` entry under `app.windows` was removed), so we skip // the macOS NSPanel reclass + bottom-right pin + initial show // here. The helpers (`configure_overlay_window_macos`, // `pin_overlay_bottom_right`) and the React entry point // (`src/overlay/OverlayApp.tsx`) are kept intact so the overlay // can be re-enabled by restoring the config entry and the two // setup blocks below. // // #[cfg(target_os = "macos")] // if let Some(window) = app.get_webview_window("overlay") { // configure_overlay_window_macos(&window); // } // if let Some(window) = app.get_webview_window("overlay") { // pin_overlay_bottom_right(&window); // let _ = window.show(); // } // Tray icon setup moved to RunEvent::Ready (see below) — GTK is only // initialized after the event loop starts, so we must delay tray creation // until the Ready event fires. Creating the tray here would panic on // Linux with "GTK has not been initialized". log::info!("[tray] deferring tray setup to RunEvent::Ready"); // CEF cold-start warmup. Spawns a 1×1 hidden child webview on // the main window at `about:blank` so CEF's render-process / // compositor for child webviews is hot before the user clicks // an account — first cold open of a real provider drops from // "spin up renderer + navigate" to just "navigate". // // Earlier builds had this disabled because of a "blank webview // on first onboarding open" report; we now park the warmup at // a far off-screen position and never reveal it (matching the // 1×1-on-screen pattern used for cold account spawns), and // tear it down in the shutdown sequence below. Disable at // runtime with `OPENHUMAN_CEF_PREWARM=0` if it regresses. { let prewarm_enabled = std::env::var("OPENHUMAN_CEF_PREWARM") .map(|v| { let v = v.trim().to_ascii_lowercase(); !(v == "0" || v == "false" || v == "no" || v == "off") }) .unwrap_or(true); if prewarm_enabled { let app_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { // Defer one tick so the main window finishes its // first paint before we attach a sibling webview. tokio::time::sleep(std::time::Duration::from_millis(300)).await; if let Err(e) = spawn_cef_prewarm(&app_handle) { log::warn!("[cef-prewarm] failed (non-fatal): {e}"); } }); } else { log::info!("[cef-prewarm] disabled via OPENHUMAN_CEF_PREWARM"); } } // Dev convenience: if OPENHUMAN_DEV_AUTO_WHATSAPP= // is set, spawn that account's webview at startup so the // CDP/IndexedDB scanner can iterate without manual UI clicks. // The same account-id reuses the persistent data dir, so a // previously-logged-in WhatsApp session stays logged in. if let Ok(account_id) = std::env::var("OPENHUMAN_DEV_AUTO_WHATSAPP") { let account_id = account_id.trim().to_string(); if !account_id.is_empty() { let app_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { // Wait for the window to be fully ready. tokio::time::sleep(std::time::Duration::from_secs(2)).await; let state = app_handle.state::(); let args = webview_accounts::OpenArgs { account_id: account_id.clone(), provider: "whatsapp".to_string(), url: None, bounds: Some(webview_accounts::Bounds { x: 100.0, y: 100.0, width: 900.0, height: 700.0, }), }; match webview_accounts::webview_account_open( app_handle.clone(), state, args, ) .await { Ok(label) => log::info!( "[dev-auto-whatsapp] spawned label={} account={}", label, account_id ), Err(e) => log::error!( "[dev-auto-whatsapp] failed: {} (account={})", e, account_id ), } }); } } // Same dev helper, Slack flavour. OPENHUMAN_DEV_AUTO_SLACK= // opens the Slack account webview on startup so the CDP scanner // can iterate without manual UI clicks. if let Ok(account_id) = std::env::var("OPENHUMAN_DEV_AUTO_SLACK") { let account_id = account_id.trim().to_string(); if !account_id.is_empty() { let app_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { tokio::time::sleep(std::time::Duration::from_secs(2)).await; let state = app_handle.state::(); let args = webview_accounts::OpenArgs { account_id: account_id.clone(), provider: "slack".to_string(), url: None, bounds: Some(webview_accounts::Bounds { x: 100.0, y: 100.0, width: 900.0, height: 700.0, }), }; match webview_accounts::webview_account_open( app_handle.clone(), state, args, ) .await { Ok(label) => log::info!( "[dev-auto-slack] spawned label={} account={}", label, account_id ), Err(e) => log::error!( "[dev-auto-slack] failed: {} (account={})", e, account_id ), } }); } } // Same dev helper, Telegram flavour. OPENHUMAN_DEV_AUTO_TELEGRAM= // opens the Telegram Web K account webview on startup so the CDP // scanner can iterate without manual UI clicks. if let Ok(account_id) = std::env::var("OPENHUMAN_DEV_AUTO_TELEGRAM") { let account_id = account_id.trim().to_string(); if !account_id.is_empty() { let app_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { tokio::time::sleep(std::time::Duration::from_secs(2)).await; let state = app_handle.state::(); let args = webview_accounts::OpenArgs { account_id: account_id.clone(), provider: "telegram".to_string(), url: None, bounds: Some(webview_accounts::Bounds { x: 100.0, y: 100.0, width: 900.0, height: 700.0, }), }; match webview_accounts::webview_account_open( app_handle.clone(), state, args, ) .await { Ok(label) => log::info!( "[dev-auto-telegram] spawned label={} account={}", label, account_id ), Err(e) => log::error!( "[dev-auto-telegram] failed: {} (account={})", e, account_id ), } }); } } // Same dev helper, Google Meet flavour. // OPENHUMAN_DEV_AUTO_GOOGLE_MEET= opens the gmeet account // webview at startup so the caption-capture recipe runs // without manual UI clicks. Use in combination with: // tail -F /tmp/oh-cef.log | grep -E --line-buffered \ // "\[gmeet\]|memory_doc_ingest|orchestrator" // to verify captions flow → transcript persist → thread handoff. if let Ok(account_id) = std::env::var("OPENHUMAN_DEV_AUTO_GOOGLE_MEET") { let account_id = account_id.trim().to_string(); if !account_id.is_empty() { let app_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { tokio::time::sleep(std::time::Duration::from_secs(2)).await; let state = app_handle.state::(); // Dev mode: size the child webview to the parent // window's inner bounds so Meet controls (CC toggle, // mic/cam, leave) are reachable without overflowing. let (w, h) = app_handle .get_webview_window("main") .and_then(|main| { let scale = main.scale_factor().unwrap_or(1.0); main.inner_size() .ok() .map(|s| ((s.width as f64) / scale, (s.height as f64) / scale)) }) .unwrap_or((1100.0, 780.0)); let args = webview_accounts::OpenArgs { account_id: account_id.clone(), provider: "google-meet".to_string(), url: None, bounds: Some(webview_accounts::Bounds { x: 0.0, y: 0.0, width: w, height: h, }), }; match webview_accounts::webview_account_open( app_handle.clone(), state, args, ) .await { Ok(label) => log::info!( "[dev-auto-gmeet] spawned label={} account={}", label, account_id ), Err(e) => log::error!( "[dev-auto-gmeet] failed: {} (account={})", e, account_id ), } }); } } #[cfg(target_os = "macos")] { use std::sync::Arc; // The scanner task self-gates on `channels_config.imessage` via // JSON-RPC each tick — it stays idle until the user connects // iMessage and stops ingesting as soon as they disconnect. We // spawn it here just so the loop is live and picks up state // changes without requiring an app restart. if let Some(registry) = app.try_state::>() { let registry = registry.inner().clone(); let app_handle = app.handle().clone(); registry.ensure_scanner(app_handle, "default".to_string()); log::info!("[imessage] scanner scheduled (gates on config each tick)"); } } Ok(()) }) .invoke_handler(tauri::generate_handler![ core_rpc_url, core_rpc_token, overlay_parent_rpc_url, check_core_update, apply_core_update, check_app_update, apply_app_update, download_app_update, install_app_update, restart_core_process, restart_app, get_active_user_id, schedule_cef_profile_purge, register_dictation_hotkey, unregister_dictation_hotkey, webview_accounts::webview_account_open, webview_accounts::webview_account_close, webview_accounts::webview_account_purge, webview_accounts::webview_account_bounds, webview_accounts::webview_account_reveal, webview_accounts::webview_account_hide, webview_accounts::webview_account_show, webview_accounts::webview_recipe_event, webview_accounts::webview_notification_permission_state, webview_accounts::webview_notification_permission_request, webview_accounts::webview_notification_set_dnd, webview_accounts::webview_notification_mute_account, webview_accounts::webview_notification_get_bypass_prefs, webview_accounts::webview_set_focused_account, notification_settings::notification_settings_get, notification_settings::notification_settings_set, screen_capture::screen_share_begin_session, screen_capture::screen_share_thumbnail, screen_capture::screen_share_finalize_session, notification_permission_state, notification_permission_request, activate_main_window, show_native_notification, mascot_window_show, mascot_window_hide ]) .build(tauri::generate_context!()) .expect("error while building tauri application") .run(move |app_handle, event| match event { RunEvent::Ready => { log::info!("[app] RunEvent::Ready — GTK initialized, setting up tray"); if let Err(err) = setup_tray(app_handle) { log::warn!( "[tray] failed to setup tray icon (non-fatal in headless environment): {err}" ); } } #[cfg(target_os = "macos")] RunEvent::WindowEvent { label, event: WindowEvent::CloseRequested { api, .. }, .. } if label == "main" => { log::info!( "[window] close requested on main window — hiding instead of destroying" ); api.prevent_close(); if let Some(window) = app_handle.get_webview_window("main") { let _ = window.hide(); } } #[cfg(target_os = "macos")] RunEvent::Reopen { .. } => { log::info!("[window] reopen event — showing main window"); if let Err(err) = show_main_window(app_handle) { log::error!("[macos] failed to show main window on reopen: {err}"); } } RunEvent::ExitRequested { .. } => { // Run our cleanup BEFORE CEF's own Exit handler does // `close_all_windows() → cef::shutdown()`. Doing this in // RunEvent::Exit instead races CEF's teardown and the // `browser_count == 0` CHECK in `cef::shutdown` panics on // macOS Cmd+Q (issue #920). The order matters: // 1. close our child webviews so CEF processes the // close requests during the Exit-phase message pump // (gives them time to settle before cef::shutdown). // 2. abort our long-lived tokio tasks so they're not // driving CDP traffic against CEF as it tears down. // 3. stop the webview_apis WS listener so its accept // loop releases the loopback port. // 4. SIGTERM the core sidecar (non-blocking). Tauri // spawned the child so we own its lifecycle, but we // do not wait — that would block the main thread // and starve CEF's UI loop. The kernel reaps the // child after Tauri exits. perform_early_teardown_sync(app_handle); } RunEvent::Exit => { log::info!("[app] RunEvent::Exit — cef::shutdown follows"); } _ => {} }); // Belt-and-suspenders sweep: after Tauri's event loop returns the // vendored runtime has already called `cef::shutdown()`. In normal // operation every CEF helper (GPU / Network / Utility / Renderer) is // gone by now. If anything is still alive — e.g. a renderer that was // mid-spawn when the user quit — it would otherwise be re-parented to // launchd on macOS / init on Linux and survive the GUI exit. SIGTERM // its children before this process actually exits. // // We don't `wait()` on them: the kernel will reap them as our exit // unwinds, and any helper that ignores SIGTERM is a CEF bug we'd // rather see in Activity Monitor than silently SIGKILL. sweep_orphan_children(); } /// Send SIGTERM to every direct child of the current process. No-op on /// non-Unix platforms (Windows job objects already kill CEF helpers when /// the parent exits). fn sweep_orphan_children() { #[cfg(unix)] { let pid = std::process::id(); match std::process::Command::new("pkill") .args(["-TERM", "-P", &pid.to_string()]) .status() { Ok(status) => { // pkill exits 0 if it killed at least one process, 1 if no // matches (the healthy case after cef::shutdown), 2/3 on // error. Both 0 and 1 are expected; log 0 loudly so we // notice when the safety net actually catches something. match status.code() { Some(0) => log::warn!( "[app] sweep: SIGTERM'd leftover child processes after cef::shutdown" ), Some(1) => log::info!("[app] sweep: no leftover children (clean exit)"), other => log::warn!("[app] sweep: pkill exited with {:?}", other), } } Err(e) => log::warn!("[app] sweep: failed to invoke pkill: {e}"), } } #[cfg(not(unix))] { log::debug!("[app] sweep: skipped on non-unix platform"); } } pub fn run_core_from_args(args: &[String]) -> Result<(), String> { // Core lives in-process: dispatch directly through the linked `openhuman_core` // library instead of shelling out to a separate binary. The Tauri main() // routes `OpenHuman core ` here so users can still drive the core CLI // from the bundled app. openhuman_core::run_core_from_args(args).map_err(|e| format!("{e:#}")) } // --------------------------------------------------------------------------- // Sentry release / environment resolution (Tauri shell) // --------------------------------------------------------------------------- /// Canonical release tag: `openhuman@[+]`. /// /// Mirrors `build_release_tag` in the core sidecar's `src/main.rs` and the /// `SENTRY_RELEASE` value computed in `app/vite.config.ts` so events from /// every surface (React frontend, core sidecar, Tauri shell) group under the /// same release in Sentry and benefit from the same source-map / debug-info /// upload. fn build_sentry_release_tag() -> String { let version = env!("CARGO_PKG_VERSION"); let sha = option_env!("OPENHUMAN_BUILD_SHA").unwrap_or("").trim(); let sha_short: String = sha.chars().take(12).collect(); if sha_short.is_empty() { format!("openhuman@{version}") } else { format!("openhuman@{version}+{sha_short}") } } /// Resolve the Sentry environment tag from `OPENHUMAN_APP_ENV` (runtime) or /// `VITE_OPENHUMAN_APP_ENV` (compile-time fallback). Defaults to /// `production` so unmarked release builds don't pollute the dev/staging /// streams. fn resolve_sentry_environment() -> String { if let Ok(value) = std::env::var("OPENHUMAN_APP_ENV") { let trimmed = value.trim(); if !trimmed.is_empty() { return trimmed.to_string(); } } if let Some(value) = option_env!("VITE_OPENHUMAN_APP_ENV") { let trimmed = value.trim(); if !trimmed.is_empty() { return trimmed.to_string(); } } "production".to_string() } #[cfg(test)] mod tests { use super::*; /// Test that is_daemon_mode correctly detects daemon flag variations #[test] fn is_daemon_mode_detects_daemon_flag() { // Note: This test relies on the current process args, so in test mode // it will typically return false. We verify the function is callable. let _result = is_daemon_mode(); } /// Test core_rpc_url returns expected format #[test] fn core_rpc_url_returns_expected_format() { // Save original env let original = std::env::var("OPENHUMAN_CORE_RPC_URL").ok(); // Test with env var set std::env::set_var("OPENHUMAN_CORE_RPC_URL", "http://localhost:9999/rpc"); let url = core_rpc_url(); assert_eq!(url, "http://localhost:9999/rpc"); // Test fallback when env not set std::env::remove_var("OPENHUMAN_CORE_RPC_URL"); let url = core_rpc_url(); assert_eq!(url, "http://127.0.0.1:7788/rpc"); // Restore original match original { Some(v) => std::env::set_var("OPENHUMAN_CORE_RPC_URL", v), None => std::env::remove_var("OPENHUMAN_CORE_RPC_URL"), } } /// Test overlay_parent_rpc_url handles empty env var #[test] fn overlay_parent_rpc_url_handles_empty() { // Save original env let original = std::env::var("OPENHUMAN_CORE_RPC_URL").ok(); // Test with empty string (should return None) std::env::set_var("OPENHUMAN_CORE_RPC_URL", ""); let result = overlay_parent_rpc_url(); assert!(result.is_none()); // Test with whitespace only (should return None) std::env::set_var("OPENHUMAN_CORE_RPC_URL", " "); let result = overlay_parent_rpc_url(); assert!(result.is_none()); // Test with valid URL std::env::set_var("OPENHUMAN_CORE_RPC_URL", "http://127.0.0.1:7788/rpc"); let result = overlay_parent_rpc_url(); assert_eq!(result, Some("http://127.0.0.1:7788/rpc".to_string())); // Restore original match original { Some(v) => std::env::set_var("OPENHUMAN_CORE_RPC_URL", v), None => std::env::remove_var("OPENHUMAN_CORE_RPC_URL"), } } /// Tests for setup_tray conditional compilation /// The PR adds two versions of setup_tray(): /// 1. No-op for linux + cef: logs warning and returns Ok(()) /// 2. Full implementation for other platforms /// /// These tests verify the function signatures are correct and /// the compile-time cfg blocks are properly set up. /// Verify setup_tray function exists and has correct signature /// This test passes if the code compiles, as the function signature /// is validated by the compiler. #[test] fn setup_tray_function_signature_compiles() { // This test exists to ensure the conditional compilation // of setup_tray is valid. The function is not actually called // here because it requires a full Tauri AppHandle. // The cfg attributes ensure only one version exists at compile time. } /// Test that AppRuntime is defined for the current feature set #[test] fn app_runtime_type_exists() { // This test verifies AppRuntime is properly defined // based on the cef feature flag. // The type alias exists at module scope and is used throughout. fn _check_runtime() {} // _check_runtime::(); // Would require importing } /// Verify tray logging patterns exist (grep-friendly) #[test] fn tray_setup_logging_patterns_exist() { // These log patterns from the PR are grep-friendly: // "[tray] skipping tray setup on linux+cef: ..." // "[tray] setting up tray icon" // "[tray] tray icon ready" // "[tray] action=show_window ..." // "[tray] action=quit ..." // "[tray] failed to setup tray icon ..." // "[app] RunEvent::Ready — GTK initialized, setting up tray" // // This test passes if the code compiles with these log messages. } }