Files
openhuman/app/src-tauri/src/lib.rs
T

3379 lines
153 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.");
mod cdp;
#[cfg(any(target_os = "macos", target_os = "linux"))]
mod cef_preflight;
mod cef_profile;
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;
mod notification_settings;
mod process_kill;
mod process_recovery;
#[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 <token>`.
///
/// 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<String> {
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<Vec<process_recovery::ProcessInfo>, 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<AppRuntime>) {
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<AppRuntime>) {
// 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<serde_json::Value, String> {
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<AppRuntime>,
) -> 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<core_process::RecoveryOutcome, String> {
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<AppRuntime>,
) -> 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<AppRuntime>) -> Result<(), String> {
log::info!("[app] app_quit: quit requested from frontend");
app.exit(0);
Ok(())
}
#[tauri::command]
async fn restart_app(app: tauri::AppHandle<AppRuntime>) -> 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<Option<String>, 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<String>) -> Result<String, String> {
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<String>,
/// Release notes / body for the new version, if the manifest provided one.
body: Option<String>,
}
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<AppRuntime>) -> Result<AppUpdateInfo, String> {
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<AppRuntime>,
) -> 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<u8>,
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<Option<PendingAppUpdate>>);
/// 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<String>,
/// Release notes for the staged update.
body: Option<String>,
}
/// 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<AppRuntime>,
state: tauri::State<'_, PendingAppUpdateState>,
) -> Result<AppUpdateDownloadResult, String> {
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<AppRuntime>,
) -> 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<AppRuntime>,
shortcut: String,
) -> Result<(), String> {
log::info!("[dictation] register_dictation_hotkey: shortcut={shortcut}");
let old_shortcuts = {
let state = app.state::<dictation_hotkeys::DictationHotkeyState>();
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<String> = 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<String> = 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::<dictation_hotkeys::DictationHotkeyState>();
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<AppRuntime>) -> Result<(), String> {
log::info!("[dictation] unregister_dictation_hotkey: called");
let state = app.state::<dictation_hotkeys::DictationHotkeyState>();
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")
}
/// 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<AppRuntime>) -> 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<AppRuntime>) -> 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<AppRuntime>) -> 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
}
/// 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<AppRuntime>,
) -> Option<tauri::WebviewWindow<AppRuntime>> {
#[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<AppRuntime>) -> 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<AppRuntime> = 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<AppRuntime>) -> tauri::Result<Menu<AppRuntime>> {
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,
&copy,
&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<AppRuntime>) -> 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<AppRuntime>) -> 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(<other non-empty string>)` → 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<AppRuntime>) -> 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<R: tauri::Runtime>(app: &AppHandle<R>) -> 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<R: tauri::Runtime>(app: &AppHandle<R>) -> Vec<String> {
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::<webview_accounts::WebviewAccountsState>() {
closed_labels.extend(state.shutdown_all(app));
}
closed_labels
}
fn shutdown_imessage_scanner<R: tauri::Runtime>(app: &AppHandle<R>) {
if let Some(registry) = app.try_state::<std::sync::Arc<imessage_scanner::ScannerRegistry>>() {
registry.inner().shutdown();
}
}
fn pending_cef_webview_labels<R: tauri::Runtime>(
app: &AppHandle<R>,
labels: &[String],
) -> Vec<String> {
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<R: tauri::Runtime>(
app: &AppHandle<R>,
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<AppRuntime>) {
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::<core_process::CoreProcessHandle>() {
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<AppRuntime>, 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<AppRuntime>) {
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::<core_process::CoreProcessHandle>() {
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<AppRuntime>, 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<CefCommandLineArg>,
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<ErrorHandler>) -> Option<ErrorHandler>;
}
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; bumping the worker stack
// itself gives the rest of the tower comfortable headroom so future
// additions don't immediately re-tip the same scale. 8 MiB matches
// the OS-default pthread main-thread stack on macOS, so we can
// assume "as much room as the main thread" everywhere.
//
// 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(8 * 1024 * 1024)
.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("<no message>")
);
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("<no message>")
);
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;
}
// 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. Mirrors the
// standalone `openhuman-core` binary's filter in `src/main.rs`.
// Empty/missing on early-startup events (cache populates after
// the first `auth_get_me` RPC); that's expected.
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
// `<data_dir>/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<u16> = "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();
// ── 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: if another OpenHuman instance holds the CEF
// user-data-dir SingletonLock, `cef_initialize` returns 0 and the vendored
// runtime panics (`left: 0, right: 1`). Catch the collision here and exit
// cleanly. Stale locks (PID dead) are removed so crashed processes don't
// block subsequent launches. macOS: issue #864. Linux: OPENHUMAN-TAURI-K1.
#[cfg(any(target_os = "macos", target_os = "linux"))]
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<CefCommandLineArg> = 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 <audio>) are unreliable in
// CEF: AudioContext starts suspended pre-gesture; the muted
// <audio> trick depends on the renderer being polled at all.
// The canonical fix is the Chromium command-line flag set
// Electron / Puppeteer use for the same scenario.
//
// Process-wide is fine: every CEF webview we own is part of
// the agent flow (no idle low-priority background tabs we
// care about saving battery on).
("--disable-background-timer-throttling", None),
("--disable-renderer-backgrounding", None),
("--disable-backgrounding-occluded-windows", None),
];
// Mascot fake-camera: bake the SVG into a one-frame Y4M and
// point Chromium's fake-video-capture pipeline at it so any
// CEF webview that calls `getUserMedia({video:true})` sees the
// mascot as the agent's webcam. `--use-fake-ui-for-media-stream`
// auto-allows the permission prompt so Meet's join page doesn't
// get stuck behind it. The flags are process-level (affect every
// CEF webview), which is fine today: only the Meet call window
// intentionally requests a camera, and other webviews don't ask
// for one. The path string is leaked with `Box::leak` so its
// `&str` outlives the args vec we hand to `command_line_args`.
let fake_camera_arg: Option<&'static str> =
match fake_camera::ensure_mascot_y4m(&file_logging::resolve_data_dir()) {
Ok(path) => {
let leaked: &'static str =
Box::leak(path.to_string_lossy().into_owned().into_boxed_str());
log::info!("[cef-startup] fake-camera y4m path={leaked}");
Some(leaked)
}
Err(err) => {
log::warn!(
"[cef-startup] mascot fake-camera unavailable: {err} \
(Meet will see no camera)"
);
None
}
};
if let Some(path) = fake_camera_arg {
// `--use-file-for-fake-video-capture` alone (CEF 146 / Chromium 128+)
// injects the Y4M as the video capture source without replacing the
// audio capture device. The old belt-and-suspenders flag
// `--use-fake-device-for-media-stream` is deliberately omitted here:
// it replaced ALL media capture devices — including audio — with fake
// ones, causing a sine-wave test tone (beeping) to be recorded instead
// of the real microphone whenever `getUserMedia({audio:true})` was
// called from the main app WebView (e.g. the mascot voice composer).
// `--use-fake-ui-for-media-stream` is kept so Meet's permission prompt
// is auto-granted without interrupting the join flow.
args.push(("--use-fake-ui-for-media-stream", None));
args.push(("--use-file-for-fake-video-capture", Some(path)));
}
// 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")));
let force_gpu_env = std::env::var("OPENHUMAN_FORCE_GPU").ok();
append_platform_cef_gpu_workarounds(
&mut args,
std::env::consts::OS,
std::env::consts::ARCH,
force_gpu_env.as_deref(),
);
tauri::Builder::<tauri::Cef>::new().command_line_args::<&str, &str>(args)
};
#[cfg(target_os = "macos")]
let builder = builder
// Use an app-owned Quit item for Cmd+Q instead of the native
// predefined Quit action. The predefined path calls
// NSApplication::terminate, which reaches CEF shutdown before
// OpenHuman's child-webview/core teardown can run.
.menu(macos_app_menu)
.on_menu_event(|app, event| {
if event.id().as_ref() == APP_QUIT_MENU_ID {
log::info!("[app-menu] action=quit");
shutdown_app_sync(app, 0);
}
});
// Single-instance guard — MUST be the first plugin registered so the
// secondary-process exit path triggers before any other plugin setup
// (and before `Builder::build()` reaches `CefRuntime::init`). Without
// this, launching a second instance races into CEF init while the
// primary still holds the cache lock; `cef::initialize` returns 0 and
// the vendored runtime asserts (Sentry OPENHUMAN-TAURI-A — 442 events
// across Win10/11 + Linux, all releases). The callback receives the
// secondary's argv/cwd; we forward deep-link args and focus the main
// window. Deep-link payloads stay handled by `tauri-plugin-deep-link`
// — we just need to wake the primary so it observes them.
//
// On Linux the plugin's `setup()` calls
// `zbus::blocking::connection::Builder::session().unwrap()`, which panics
// with `Address("unsupported transport 'disabled'")` when the D-Bus
// session bus is unreachable (WSL2-without-WSLg, minimal containers, root
// launches without `$XDG_RUNTIME_DIR/bus`) — Sentry OPENHUMAN-TAURI-TM.
// Probe for a usable session bus first and skip the plugin if absent;
// single-instance enforcement is best-effort in those environments
// anyway, and a graceful skip is strictly better than crashing at
// startup.
let builder = if can_register_single_instance_plugin() {
builder.plugin(tauri_plugin_single_instance::init(
|app: &AppHandle<AppRuntime>, args, cwd| {
// Don't log raw argv/cwd: deep-link callbacks (OAuth codes,
// magic links) can carry auth tokens that would otherwise leak
// into desktop logs and Sentry breadcrumbs. CodeRabbit on #1510.
log::info!(
"[single-instance] secondary launch detected, focusing primary (argc={}, cwd_present={})",
args.len(),
!cwd.is_empty()
);
if let Err(err) = show_main_window(app) {
log::warn!("[single-instance] failed to focus main window: {err}");
}
},
))
} else {
log::warn!(
"[single-instance] D-Bus session bus unreachable (DBUS_SESSION_BUS_ADDRESS={:?}, \
XDG_RUNTIME_DIR={:?}); skipping tauri-plugin-single-instance to avoid \
OPENHUMAN-TAURI-TM panic. Multiple OpenHuman instances will not be deduplicated.",
std::env::var("DBUS_SESSION_BUS_ADDRESS").ok(),
std::env::var("XDG_RUNTIME_DIR").ok()
);
builder
};
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
// gitbooks/overview/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(companion_commands::CompanionHotkeyState(
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(wechat_scanner::ScannerRegistry::new());
let builder = builder.manage(screen_capture::ScreenShareState::new());
let builder = builder.manage(meet_call::MeetCallState::new());
let builder = builder.manage(meet_audio::MeetAudioState::new());
let builder = builder.manage(meet_video::frame_bus::MeetVideoFrameBusState::new());
builder
.setup(move |app| {
#[cfg(windows)]
{
// `register_all` writes HKCU\Software\Classes\openhuman so the
// browser can hand `openhuman://auth?...` callbacks back to
// the running instance. The plugin only returns an Err — and
// it only logs at `warn` — when its single internal write
// fails outright; it does not verify what's on disk. Issue
// #2699 reports OAuth callbacks silently disappearing on
// some Windows installs, which traced back to a missing or
// stale `command` value here. Read it back and log loudly
// (Sentry-level `error`) so the failure mode is observable
// in support logs; we deliberately do NOT auto-repair —
// writing the wrong exe path can brick a working install.
let register_err = app.deep_link().register_all().err();
let status = deep_link_registration_check::verify_protocol_registration();
let status_log = status.redacted();
if register_err.is_none() && status.is_healthy() {
log::info!("[deep-link] openhuman:// scheme registered ({status_log})");
} else {
// Use the redacted form so per-user install paths
// (`C:\Users\<username>\...`) do not land in Sentry / user
// logs — basenames are kept so the diagnostic still
// identifies the registered exe.
log::error!(
"[deep-link] openhuman:// scheme registration unhealthy — \
OAuth callbacks may never reach the app. \
register_all_error={register_err:?}, hkcu_status={status_log}. \
See gitbooks/overview/troubleshooting-sign-in.md \
(\"Windows: openhuman:// handler not registered\") for the manual repair."
);
}
deep_link_ipc_windows::drain_pending_urls(app.app_handle());
}
#[cfg(target_os = "linux")]
{
// `tauri-plugin-deep-link::register_all` on Linux shells out
// to `xdg-mime`, `update-desktop-database`, and
// `xdg-icon-resource` in sequence to install MIME-type
// associations for our custom URL schemes. On Linux installs
// that ship without one or more of those binaries — WSL2
// without a desktop env, headless servers, minimal
// containers (OPENHUMAN-TAURI-AS: WSL2 user in BR;
// OPENHUMAN-TAURI-5V: same shape but `xdg-mime` was
// installed while `update-desktop-database` was missing) —
// the plugin fires
// `log::error!("Failed to run OS command \`<name>\`…")`
// *internally* before returning the Err. That internal
// error log is scooped up by `sentry-tracing` into a Sentry
// event even though our `if let Err` arm below already
// demotes the user-visible failure to a warn.
//
// Pre-flight every binary the plugin will shell out to and
// skip `register_all` entirely if any of them is missing —
// partial registration can't succeed because the plugin
// runs all three in sequence inside `register_all`, so the
// first missing binary kills the whole flow. Registration
// only matters on systems with a desktop environment,
// where xdg-utils ships as a single package.
const XDG_BINARIES: &[&str] =
&["xdg-mime", "update-desktop-database", "xdg-icon-resource"];
let missing: Vec<&str> = XDG_BINARIES
.iter()
.copied()
.filter(|name| !path_has_executable(name))
.collect();
if missing.is_empty() {
if let Err(err) = app.deep_link().register_all() {
log::warn!("[deep-link] register_all failed (non-fatal): {err}");
}
} else {
log::warn!(
"[deep-link] skipping register_all — xdg-utils binaries missing on PATH: {} \
(xdg-utils not installed; deep-link MIME registration unavailable on this host)",
missing.join(", ")
);
}
// Drain any deep-link URLs that arrived via the IPC socket
// before setup() ran (issue #2359). Also installs the live
// handler so URLs arriving after setup() are emitted directly.
deep_link_ipc::drain_pending_urls(app.app_handle());
}
// 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());
}
// Purge stray LaunchAgent left over from a prior worktree's
// `service install`. KeepAlive=true on the plist re-spawns the
// daemon after every SIGKILL, fighting `ensure_running`'s
// stale-listener takeover and re-binding port 7788 on cold boot.
// (Symptom: "Failed to start local core: signaled pid <X> but
// port 7788 remained bound after 5000ms".)
//
// Tightly scoped to avoid clobbering a legitimate `service
// install`:
// - dev builds only (`cfg!(debug_assertions)`)
// - skip when this process IS the daemon (`!daemon_mode`)
// - only purge when the plist's ProgramArguments[0] points
// somewhere other than the currently-running executable —
// i.e. a sibling worktree's stale binary, not us.
#[cfg(target_os = "macos")]
if cfg!(debug_assertions) && !daemon_mode {
const STALE_LABEL: &str = "com.openhuman.core";
if let Ok(home) = std::env::var("HOME") {
let plist = std::path::PathBuf::from(&home)
.join("Library")
.join("LaunchAgents")
.join(format!("{STALE_LABEL}.plist"));
let plist_targets_us = std::fs::read_to_string(&plist)
.ok()
.and_then(|contents| {
// ProgramArguments[0] is the first <string>...</string>
// after the <key>ProgramArguments</key> marker. The
// service installer always writes it as an absolute
// path to the openhuman-core binary (see
// src/openhuman/service/macos.rs).
let after_key = contents.split("<key>ProgramArguments</key>").nth(1)?;
let start = after_key.find("<string>")? + "<string>".len();
let rest = &after_key[start..];
let end = rest.find("</string>")?;
Some(std::path::PathBuf::from(rest[..end].trim()))
})
.zip(std::env::current_exe().ok())
.map(|(plist_bin, self_bin)| plist_bin == self_bin)
.unwrap_or(false);
if plist.exists() && !plist_targets_us {
let uid = std::process::Command::new("id")
.arg("-u")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string());
if let Some(uid) = uid {
let target = format!("gui/{uid}/{STALE_LABEL}");
let _ = std::process::Command::new("launchctl")
.arg("bootout")
.arg(&target)
.status();
}
match std::fs::remove_file(&plist) {
Ok(()) => log::warn!(
"[boot] removed stale LaunchAgent plist at {} \
(points at a different binary than this build — \
likely a sibling worktree's `service install`)",
plist.display()
),
Err(err) => log::warn!(
"[boot] failed to remove stale LaunchAgent plist {}: {err}",
plist.display()
),
}
}
}
}
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());
// NOTE: the core is NOT auto-spawned here. The BootCheckGate UI
// calls `start_core_process` (Local mode) after the user picks a
// mode, which lets the frontend surface startup failures and
// version mismatches before the rest of the app mounts.
//
// In daemon mode (headless) we spawn immediately so the tray
// agent is available without waiting for a UI interaction.
if daemon_mode {
let core_handle_daemon = core_handle.clone();
tauri::async_runtime::spawn(async move {
if let Err(err) = core_handle_daemon.ensure_running().await {
log::error!("[core] daemon_mode — failed to start embedded core: {err}");
return;
}
log::info!("[core] daemon_mode — 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}");
}
// CEF keyboard routing fix — cold launch:
//
// `window.show()` does not wire the renderer as the
// keyboard input target. `Window::set_focus` only
// dispatches `WindowMessage::SetFocus` → `request_focus`,
// which lifts the OS window but does not call
// `CefBrowserHost::SetFocus(true)`. Without that
// CEF-level focus, the textarea accepts focus on cold
// launch (cursor blinks) but `WM_KEYDOWN` messages
// never reach the renderer — typing is silently dead
// until the user click-outside / click-back triggers
// `WM_KILLFOCUS`+`WM_SETFOCUS`, which CEF's window
// handler routes through `host.set_focus(1)` internally.
//
// We need to call `webview.set_focus()` (which dispatches
// `WebviewMessage::SetFocus` → `host.set_focus(1)`)
// *after* CEF has finished creating the browser — too
// early and `browser()`/`host()` return None and the
// call silently no-ops. Defer the call to a spawned
// task with a small delay so CEF's browser-create
// settles. Then call it again after another delay as
// belt-and-suspenders for slower init paths.
// Previous attempts at calling `webview.set_focus()` alone
// confirmed the dispatch reaches CEF (both returned Ok),
// but keyboard routing stayed broken. `host.set_focus(1)`
// alone is insufficient — CEF's internal focus state
// needs a blur-then-focus *cycle* to wire keyboard
// routing on cold launch (matches the user-discovered
// workaround: click outside the window, then click back).
//
// The vendored tauri-cef doesn't expose `set_focus(false)`,
// so we mimic the cycle at the OS-window level:
// minimize triggers `WM_KILLFOCUS` (CEF's window handler
// propagates this to `host.set_focus(0)`), unminimize
// restores the window and triggers `WM_SETFOCUS` →
// `host.set_focus(1)`. Pair with explicit `set_focus`
// calls on both Window and Webview to cover the case
// where minimize/unminimize raced ahead of CEF's
// browser-create.
// Windows-only: the bug class (CEF host-renderer focus
// desync after a `visible: false` → `show()` transition
// without a real `WM_KILLFOCUS`+`WM_SETFOCUS` edge)
// manifests on the Windows CEF integration. macOS and
// Linux CEF use different focus propagation paths and
// don't exhibit the symptom, so running the
// minimize/unminimize cycle there would just be a
// visible flicker for no benefit. (Per CodeRabbit
// review on PR #1528.)
#[cfg(target_os = "windows")]
{
log::info!("[focus-fix] scheduling deferred CEF focus-cycle");
let webview_window_clone = window.clone();
tauri::async_runtime::spawn(async move {
// Wait for CEF to finish creating the browser host
// (synchronous setup() returns before this completes).
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
// Blur-then-focus cycle via minimize/unminimize.
// This is what the manual click-outside / click-back
// workaround does at the Win32 level.
log::info!("[focus-fix] starting minimize→unminimize focus cycle");
if let Err(err) = webview_window_clone.minimize() {
log::warn!("[focus-fix] minimize failed: {err}");
}
// Tiny pause so Windows actually processes the
// minimize before we ask to restore.
tokio::time::sleep(std::time::Duration::from_millis(80)).await;
if let Err(err) = webview_window_clone.unminimize() {
log::warn!("[focus-fix] unminimize failed: {err}");
}
// Belt-and-suspenders: explicit Window + Webview focus
// after the cycle in case the minimize→restore path
// didn't propagate.
tokio::time::sleep(std::time::Duration::from_millis(40)).await;
if let Err(err) = webview_window_clone.set_focus() {
log::warn!("[focus-fix] post-cycle window.set_focus failed: {err}");
}
let webview: &tauri::Webview<AppRuntime> = webview_window_clone.as_ref();
if let Err(err) = webview.set_focus() {
log::warn!("[focus-fix] post-cycle webview.set_focus failed: {err}");
}
log::info!("[focus-fix] focus cycle complete");
});
}
}
}
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.
{
#[cfg(target_os = "linux")]
let wayland_display_set = has_non_empty_env("WAYLAND_DISPLAY");
#[cfg(not(target_os = "linux"))]
let wayland_display_set = false;
let env_override = std::env::var("OPENHUMAN_CEF_PREWARM").ok();
if cef_prewarm_enabled(env_override.as_deref(), wayland_display_set) {
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 if wayland_display_set && env_override.is_none() {
log::info!(
"[cef-prewarm] auto-disabled: WAYLAND_DISPLAY is set (Wayland/XWayland \
session) — prevents X_ConfigureWindow BadWindow crash in CEF \
subprocesses (issue #2463); set OPENHUMAN_CEF_PREWARM=1 to override"
);
} else {
log::info!("[cef-prewarm] disabled via OPENHUMAN_CEF_PREWARM");
}
}
// Dev convenience: if OPENHUMAN_DEV_AUTO_WHATSAPP=<account-id>
// 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::<webview_accounts::WebviewAccountsState>();
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,
}),
prewarm: false,
};
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=<uuid>
// 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::<webview_accounts::WebviewAccountsState>();
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,
}),
prewarm: false,
};
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=<uuid>
// 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::<webview_accounts::WebviewAccountsState>();
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,
}),
prewarm: false,
};
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=<uuid> 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::<webview_accounts::WebviewAccountsState>();
// 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,
}),
prewarm: false,
};
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
),
}
});
}
}
// Dev helper: OPENHUMAN_DEV_AUTO_MEET_CALL=<https://meet.google.com/...>
// auto-spawns a meet-call window at startup so the camera +
// audio bridges + frame-bus + producer pipeline can be
// exercised end-to-end without manual UI clicks. Pair with
// `tail -F ~/.openhuman/logs/openhuman.<date>.log` to see
// the periodic [meet-camera] bridge stats logged by the
// diagnostics poller in meet_video::inject.
if let Ok(meet_url) = std::env::var("OPENHUMAN_DEV_AUTO_MEET_CALL") {
let meet_url = meet_url.trim().to_string();
if !meet_url.is_empty() {
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
// Wait for the main window + core to be ready.
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
let state = app_handle.state::<meet_call::MeetCallState>();
let request_id = format!(
"dev-auto-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
);
let args = meet_call::OpenWindowArgs {
request_id: request_id.clone(),
meet_url: meet_url.clone(),
display_name: "OpenHuman Dev".to_string(),
// Dev-auto launch has no real user identity — the
// wake gate will fail-closed (no wakes fire) which
// is the safe posture for an automated harness.
owner_display_name: String::new(),
};
match meet_call::meet_call_open_window(app_handle.clone(), state, args)
.await
{
Ok(label) => log::info!(
"[dev-auto-meet] spawned label={label} request_id={request_id} url={meet_url}"
),
Err(e) => log::error!(
"[dev-auto-meet] failed: {e} (url={meet_url})"
),
}
});
}
}
#[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::<Arc<imessage_scanner::ScannerRegistry>>() {
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,
process_diagnostics_list_owned,
check_core_update,
apply_core_update,
check_app_update,
apply_app_update,
download_app_update,
install_app_update,
restart_core_process,
recover_port_conflict,
start_core_process,
local_data_reset::reset_local_data,
app_quit,
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_prewarm,
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,
native_notifications::notification_permission_state,
native_notifications::notification_permission_request,
activate_main_window,
native_notifications::show_native_notification,
mascot_window_show,
mascot_window_hide,
file_logging::reveal_logs_folder,
file_logging::logs_folder_path,
workspace_paths::open_workspace_path,
workspace_paths::reveal_workspace_path,
workspace_paths::preview_workspace_text,
workspace_paths::resolve_workspace_absolute_path,
meet_call::meet_call_open_window,
meet_call::meet_call_close_window,
companion_commands::register_companion_hotkey,
companion_commands::unregister_companion_hotkey,
companion_commands::companion_activate,
mcp_commands::mcp_resolve_binary_path,
mcp_commands::mcp_open_client_config,
loopback_oauth::start_loopback_oauth_listener,
loopback_oauth::stop_loopback_oauth_listener
])
.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}"
);
}
}
// Intercept the main window's close request on macOS so the user
// can re-open the app from the tray icon. Letting the OS destroy
// the webview makes `get_webview_window("main")` return None on
// the next tray-click and surfaces as `[tray] failed to show main
// window from tray click: main window not found`
// (OPENHUMAN-TAURI-2X — 21 events, Windows only).
//
// macOS: hide the whole application on close instead of
// destroying the window. `AppHandle::hide()` calls
// `[NSApp hide:]` via `set_application_visibility(false)`,
// the standard macOS mechanism that reliably hides all
// windows. The vendored CEF runtime's per-window
// `WebviewWindow::hide()` sends `WindowMessage::Hide` →
// `cef::Window::hide()`, which does NOT propagate to the
// visible NSWindow frame and leaves the window on screen
// (issue #2049). Dock-click fires `RunEvent::Reopen` which
// calls `show_main_window()` to restore.
//
// Windows is handled in the separate arm below — see #1607.
//
// Linux is left out: `setup_tray` early-returns on Linux
// because tray creation panics inside GTK during packaged
// runs, so hide-on-close would strand the user with no way
// back.
#[cfg(target_os = "macos")]
RunEvent::WindowEvent {
label,
event: WindowEvent::CloseRequested { api, .. },
..
} if label == "main" => {
log::info!(
"[window] close requested on main window — hiding app"
);
api.prevent_close();
if let Err(err) = app_handle.hide() {
log::warn!(
"[window] app_handle.hide() failed on close request: {err}"
);
}
}
// Windows: full hide-to-tray.
//
// PR #1548 routed Windows X click into the same prevent_close +
// `window.hide()` branch as macOS, but on Windows the vendored
// CEF runtime's WindowMessage::Hide / Minimize / Restore
// (`tauri-runtime-cef/src/cef_impl.rs`) only operate on a
// `cef::Window` internal handle that does not correspond to the
// visible `Chrome_WidgetWin_1` top-level frame — `ShowWindow`
// calls against it are silent no-ops. We bypass the runtime
// entirely and walk the OS window list to issue SW_HIDE / SW_SHOW
// directly on the matching top-level frame (issue #1607).
#[cfg(target_os = "windows")]
RunEvent::WindowEvent {
label,
event: WindowEvent::CloseRequested { api, .. },
..
} if label == "main" => {
log::info!(
"[window] close requested on main window — hiding to tray"
);
api.prevent_close();
// Hide the OS top-level Chrome_WidgetWin_1 frame via
// EnumWindows + SW_HIDE — full hide-to-tray as PR #1548
// intended. `window.hide()` and `window.minimize()` through
// the vendored CEF runtime are no-ops on Windows because
// `WebviewWindow::hwnd()` returns a cef::Window proxy handle
// rather than the visible top-level frame; we walk the OS
// window list directly instead (#1607). SW_HIDE on the host
// frame cascades to all child HWNDs (including the CEF
// browser surface), so no separate `webview.hide()` is
// needed and `show_main_window` only has to issue SW_SHOW.
set_main_window_hidden(true);
}
#[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_once(app_handle, "exit_requested");
}
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. Sweep its
// children before this process actually exits.
//
// We don't `wait()` on them: the kernel will reap them as our exit
// unwinds. Give stubborn helpers a short grace period, then force-kill
// anything still parented to us so the GUI exit leaves no background
// processes behind.
process_kill::sweep_orphan_children();
}
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 <args>` 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 — desktop only)
// ---------------------------------------------------------------------------
/// Canonical release tag: `openhuman@<version>[+<short_sha>]`.
///
/// 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.
/// Return `true` when the Sentry event is a "Failed to request
/// http://localhost:…" message originating from the vendored
/// `tauri-runtime-cef` dev-server proxy.
///
/// The proxy logs this message via `log::error!` (see
/// `app/src-tauri/vendor/tauri-cef/crates/tauri/src/protocol/tauri.rs`)
/// every time the CEF webview asks for an asset on the Vite dev URL
/// (`http://localhost:1420` per `tauri.conf.json`). In packaged
/// staging/production builds Vite isn't running, so the request fails —
/// but the failure is benign and shouldn't be reported.
///
/// The match is conservative: it checks the exact `Failed to request ` +
/// `http://localhost` / `http://127.0.0.1` prefix that only the dev-proxy
/// emits. Production HTTP errors from elsewhere in the shell or core use
/// different message shapes and won't be filtered.
fn event_is_localhost_dev_fetch_noise(event: &sentry::protocol::Event<'_>) -> bool {
// sentry-tracing 0.47 (with default `attach_stacktrace=false`) stores the
// log message in `event.message`. Check there first; fall back to the
// last exception's `value` for the (currently unused) stacktrace-enabled
// path so the filter stays correct if attach_stacktrace ever flips.
let direct = event.message.as_deref();
let from_exception = event.exception.last().and_then(|e| e.value.as_deref());
[direct, from_exception]
.into_iter()
.flatten()
.any(message_is_localhost_dev_fetch_noise)
}
/// Pure prefix check, separated from `event_is_localhost_dev_fetch_noise`
/// so the matching rule can be unit-tested without constructing a full
/// Sentry `Event`.
fn message_is_localhost_dev_fetch_noise(message: &str) -> bool {
// The tauri-cef dev proxy formats the message as:
// `Failed to request {url}: {err}`
// so anchoring on `Failed to request http://localhost` / `127.0.0.1` is
// sufficient and avoids matching unrelated "Failed to request …" errors
// elsewhere in the codebase that target real hosts.
//
// Note: no `[::1]` (IPv6 loopback) entry — the vendored tauri-cef dev
// proxy resolves `localhost` to IPv4 via reqwest's default resolver, so
// dev-server fetches always surface as `http://localhost:` or
// `http://127.0.0.1:`. Add an `[::1]` prefix if that ever changes
// (per graycyrus note on PR #1545).
const PREFIXES: &[&str] = &[
"Failed to request http://localhost:",
"Failed to request http://127.0.0.1:",
];
PREFIXES.iter().any(|p| message.starts_with(p))
}
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()
}
/// Returns the macOS product version string (e.g. `"14.5"`) by reading
/// `sw_vers -productVersion`. Returns `None` on non-macOS targets or when
/// the command is unavailable. Used to tag Sentry events and startup logs
/// with OS version so Intel-specific crashes (issue #1012) can be filtered
/// by macOS release.
#[cfg(target_os = "macos")]
fn macos_os_version() -> Option<String> {
std::process::Command::new("sw_vers")
.arg("-productVersion")
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
#[cfg(test)]
#[path = "lib_tests.rs"]
mod tests;