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

2015 lines
84 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.
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
compile_error!("src-tauri host is desktop-only. Non-desktop targets are not supported.");
mod cdp;
#[cfg(target_os = "macos")]
mod cef_preflight;
mod cef_profile;
mod core_process;
mod core_rpc;
mod dictation_hotkeys;
mod discord_scanner;
mod gmessages_scanner;
mod imessage_scanner;
#[cfg(target_os = "macos")]
mod mascot_native_window;
mod notification_settings;
mod screen_capture;
mod slack_scanner;
mod telegram_scanner;
mod webview_accounts;
mod webview_apis;
mod whatsapp_scanner;
mod window_state;
#[cfg(target_os = "macos")]
use tauri::WindowEvent;
#[cfg(not(target_os = "linux"))]
use tauri::{
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
};
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, RunEvent, WebviewWindow};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
#[cfg(any(windows, target_os = "linux"))]
use tauri_plugin_deep_link::DeepLinkExt;
#[cfg(target_os = "macos")]
use objc2::runtime::{AnyClass, AnyObject};
#[cfg(target_os = "macos")]
use objc2::ClassType;
#[cfg(target_os = "macos")]
use objc2_app_kit::{NSPanel, NSWindowCollectionBehavior, NSWindowStyleMask};
// CEF is the only runtime; alias kept so command handlers thread the runtime generic uniformly.
pub(crate) type AppRuntime = tauri::Cef;
#[tauri::command]
fn core_rpc_url() -> String {
crate::core_rpc::core_rpc_url_value()
}
/// Tauri command: return the per-process bearer token that must be sent with
/// every core RPC request as `Authorization: Bearer <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())
}
#[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
}
#[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>,
}
/// 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(AppUpdateInfo {
current_version,
available: false,
available_version: None,
body: None,
})
}
Err(e) => {
log::warn!("[app-update] check failed: {e}");
Err(format!("update check failed: {e}"))
}
}
}
/// Download and install the latest shell update, then relaunch.
///
/// Shuts the core sidecar down before download begins so the install step
/// (which on macOS replaces the entire `.app` bundle) does not race against
/// a live sidecar holding file handles inside `Contents/Resources/`. The
/// new bundled sidecar is launched fresh after `app.restart()`.
///
/// Emits Tauri events `app-update:status` and `app-update:progress` so the
/// frontend can show a snackbar / progress bar.
#[tauri::command]
async fn apply_app_update(
state: tauri::State<'_, core_process::CoreProcessHandle>,
app: tauri::AppHandle<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")
}
/// 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
}
/// Tauri command: fire a native OS notification from the frontend. Used by
/// the in-app notification center to banner events (agent completions,
/// connection drops, etc.) when the window is not focused.
#[tauri::command]
fn show_native_notification(
app: AppHandle<AppRuntime>,
title: String,
body: String,
tag: Option<String>,
) -> Result<(), String> {
use tauri_plugin_notification::NotificationExt;
let permission_state = app
.notification()
.permission_state()
.map(|s| format!("{s:?}"))
.unwrap_or_else(|e| format!("err({e})"));
log::debug!(
"[notify] show_native_notification title_chars={} body_chars={} tag={:?} permission={permission_state}",
title.len(),
body.len(),
tag
);
let mut builder = app.notification().builder().title(&title);
if !body.is_empty() {
builder = builder.body(&body);
}
#[cfg(target_os = "macos")]
{
builder = builder.sound("default");
}
builder
.show()
.map_err(|e| format!("notification show failed: {e}"))
}
#[cfg(target_os = "macos")]
fn macos_notification_permission_state_inner() -> Result<String, String> {
use std::ptr::NonNull;
use std::sync::mpsc;
use block2::RcBlock;
use objc2_user_notifications::{
UNAuthorizationStatus, UNNotificationSettings, UNUserNotificationCenter,
};
let center = UNUserNotificationCenter::currentNotificationCenter();
let (tx, rx) = mpsc::channel::<String>();
let completion = RcBlock::new(move |settings: NonNull<UNNotificationSettings>| {
let status = unsafe { settings.as_ref().authorizationStatus() };
let state = if status == UNAuthorizationStatus::Authorized {
"granted"
} else if status == UNAuthorizationStatus::Denied {
"denied"
} else if status == UNAuthorizationStatus::NotDetermined {
"not_determined"
} else if status == UNAuthorizationStatus::Provisional {
"provisional"
} else if status == UNAuthorizationStatus::Ephemeral {
"ephemeral"
} else {
"unknown"
};
let _ = tx.send(state.to_string());
});
center.getNotificationSettingsWithCompletionHandler(&completion);
rx.recv_timeout(std::time::Duration::from_secs(2))
.map_err(|_| "timed out waiting for macOS notification settings".to_string())
}
#[cfg(target_os = "macos")]
fn macos_notification_permission_request_inner() -> Result<String, String> {
use block2::RcBlock;
use objc2::runtime::Bool;
use objc2_foundation::NSError;
use objc2_user_notifications::{UNAuthorizationOptions, UNUserNotificationCenter};
use std::sync::mpsc;
let center = UNUserNotificationCenter::currentNotificationCenter();
let (tx, rx) = mpsc::channel::<bool>();
let options = UNAuthorizationOptions::Alert
| UNAuthorizationOptions::Badge
| UNAuthorizationOptions::Sound;
let completion = RcBlock::new(move |granted: Bool, _error: *mut NSError| {
let _ = tx.send(granted.as_bool());
});
center.requestAuthorizationWithOptions_completionHandler(options, &completion);
let granted = rx
.recv_timeout(std::time::Duration::from_secs(5))
.map_err(|_| "timed out waiting for macOS permission prompt result".to_string())?;
if granted {
Ok("granted".to_string())
} else {
// If the user denies or notifications are disabled for the app,
// macOS reports `false` here.
Ok("denied".to_string())
}
}
#[tauri::command]
fn notification_permission_state() -> Result<String, String> {
#[cfg(target_os = "macos")]
{
return macos_notification_permission_state_inner();
}
#[cfg(not(target_os = "macos"))]
{
Ok("granted".to_string())
}
}
#[tauri::command]
fn notification_permission_request() -> Result<String, String> {
#[cfg(target_os = "macos")]
{
return macos_notification_permission_request_inner();
}
#[cfg(not(target_os = "macos"))]
{
Ok("granted".to_string())
}
}
fn show_main_window(app: &AppHandle<AppRuntime>) -> Result<(), String> {
let window = app
.get_webview_window("main")
.ok_or_else(|| "main window not found".to_string())?;
window
.show()
.map_err(|err| format!("failed to show main window: {err}"))?;
window
.unminimize()
.map_err(|err| format!("failed to unminimize main window: {err}"))?;
window
.set_focus()
.map_err(|err| format!("failed to focus main window: {err}"))?;
Ok(())
}
#[cfg(target_os = "linux")]
fn setup_tray(app: &AppHandle<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::error!("[tray] failed to show main window from menu: {err}");
}
}
"tray_toggle_mascot" => {
log::info!("[tray] action=toggle_mascot source=menu");
if mascot_native_window_is_open() {
if let Err(err) = mascot_window_hide(app.clone()) {
log::error!("[tray] failed to hide mascot window: {err}");
}
} else if let Err(err) = mascot_window_show(app.clone()) {
log::error!("[tray] failed to show mascot window: {err}");
}
}
"tray_quit" => {
log::info!("[tray] action=quit source=menu");
shutdown_app_sync(app, 0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
log::info!("[tray] action=show_window source=left_click");
if let Err(err) = show_main_window(tray.app_handle()) {
log::error!("[tray] failed to show main window from tray click: {err}");
}
}
})
.build(app)?;
log::info!("[tray] tray icon ready");
Ok(())
}
const CEF_PREWARM_LABEL: &str = "cef-prewarm";
/// Spawn a hidden 1×1 child webview at `about:blank` on the main window so
/// CEF's child-webview render path is hot before the user clicks an
/// account. The first `webview_account_open` then skips the cold
/// renderer-process spinup. Idempotent — bails if the prewarm webview
/// already exists.
fn spawn_cef_prewarm(app: &AppHandle<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");
}
/// 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(app_handle);
log::info!("[app] shutdown_app_sync — early teardown complete, exiting");
app_handle.exit(exit_code);
}
pub fn run() {
// Initialize Sentry for the Tauri shell (desktop host) process before any
// other startup work. Reads `OPENHUMAN_TAURI_SENTRY_DSN` at runtime first,
// then falls back to the value baked in at compile time via the release
// workflow. Missing/empty DSN ⇒ `sentry::init` returns a no-op guard.
//
// The guard is held for the entire lifetime of `run()` so events queued
// during shutdown still flush. Only invoked here (and not in `main.rs`)
// so renderer/GPU CEF helper subprocesses (re-exec'd via
// `tauri::cef_entry_point`) and the `OpenHuman core …` in-process core
// path do NOT spin up a second client — those have their own reporting
// surfaces.
let _sentry_guard = sentry::init(sentry::ClientOptions {
dsn: std::env::var("OPENHUMAN_TAURI_SENTRY_DSN")
.ok()
.filter(|s| !s.is_empty())
.or_else(|| option_env!("OPENHUMAN_TAURI_SENTRY_DSN").map(|s| s.to_string()))
.filter(|s| !s.is_empty())
.and_then(|s| s.parse().ok()),
release: Some(std::borrow::Cow::Owned(build_sentry_release_tag())),
environment: Some(std::borrow::Cow::Owned(resolve_sentry_environment())),
send_default_pii: false,
before_send: Some(std::sync::Arc::new(|mut event| {
// Strip server_name (hostname) to avoid leaking machine identity.
event.server_name = None;
event.user = None;
Some(event)
})),
sample_rate: 1.0,
..sentry::ClientOptions::default()
});
// Optional smoke trigger for verifying the Sentry pipeline end-to-end.
// Run with `OPENHUMAN_TAURI_SENTRY_TEST=panic` to fire a panic, or
// `=message` to send a captured-message event. No-op when unset.
if let Ok(mode) = std::env::var("OPENHUMAN_TAURI_SENTRY_TEST") {
match mode.as_str() {
"panic" => panic!("OPENHUMAN_TAURI_SENTRY_TEST=panic — local Sentry smoke test"),
"message" => {
sentry::capture_message(
"OPENHUMAN_TAURI_SENTRY_TEST=message — local Sentry smoke test",
sentry::Level::Error,
);
let _ = sentry::Hub::current().client().map(|c| c.flush(None));
}
other => log::warn!(
"OPENHUMAN_TAURI_SENTRY_TEST={other:?} — unknown mode (use 'panic' or 'message')"
),
}
}
let daemon_mode = is_daemon_mode();
let default_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string());
let _ = env_logger::Builder::new()
.parse_filters(&default_filter)
.try_init();
// The vendored tauri-cef dev-server proxy builds a reqwest 0.13 client
// (see vendor/tauri-cef/crates/tauri/src/protocol/tauri.rs) which calls
// rustls 0.23's `CryptoProvider::get_default()`. rustls 0.23 no longer
// picks a provider implicitly — without one installed, the proxy panics
// with "No provider set" the first time `tauri dev` forwards a request.
// Install the ring provider once before any HTTPS client is built.
let _ = rustls::crypto::ring::default_provider().install_default();
// CEF cache-lock preflight (macOS only): if another OpenHuman instance
// is already holding the CEF user-data-dir, the vendored
// `tauri-runtime-cef` panics inside `cef::initialize` with a Rust
// backtrace and no actionable message (issue #864). Catch the collision
// here and exit cleanly with a message that names the lock-holder PID
// and the workaround. Stale locks (PID dead) are removed and we
// continue, matching Chromium's own startup recovery.
match cef_profile::prepare_process_cache_path() {
Ok(path) => log::debug!("[cef-profile] startup cache path={}", path.display()),
Err(error) => {
log::error!(
"[cef-profile] failed to configure per-user CEF cache; refusing to start with shared/global cache: {error}"
);
std::process::exit(1);
}
}
#[cfg(target_os = "macos")]
if let Err(e) = cef_preflight::check_default_cache() {
eprintln!("\n[openhuman] {e}\n");
std::process::exit(1);
}
let builder = {
// Bypass macOS Keychain. Without this, every embedded service that
// touches password / cookie / encryption-key storage triggers a
// "Allow access to your keychain?" prompt — WhatsApp Web hits it on
// every cold start, Chromium's own component-update store also does.
// `use-mock-keychain` swaps the Keychain backend for an in-process
// mock; `password-store=basic` is the equivalent for the password
// manager. Both are no-ops on Windows/Linux, so safe to always set.
//
// In debug builds we additionally expose the Chrome DevTools
// Protocol on localhost:19222 so every CEF webview can be
// inspected from a regular browser (right-click "Inspect" does
// not propagate to CEF child webviews on macOS). Release builds
// intentionally do NOT open the CDP port — it would let any
// process on the machine drive the embedded WhatsApp/Slack/etc.
// webviews.
//
// The port was 9222 (Chromium's default) but ollama's
// OpenAI-compatible server squats on 127.0.0.1:9222 in some
// installs, which silently broke CDP attach (our client hit
// ollama, the WS handshake failed, child webviews stayed at
// about:blank → black screen). Picked 19222 to dodge that
// collision; if you change it here also update
// `cdp::CDP_PORT` and `whatsapp_scanner::CDP_PORT`.
//
// NOTE: flags must be prefixed with `--`. The runtime's
// `on_before_command_line_processing` dispatch (in
// `tauri-runtime-cef/src/cef_impl.rs`) routes value-less args that
// don't start with `-` to `append_argument` (positional) instead of
// `append_switch`, which means Chromium silently ignores them.
let mut args: Vec<(&str, Option<&str>)> = vec![
("--use-mock-keychain", None),
("--password-store", Some("basic")),
// Enable SharedArrayBuffer so embedded apps that need WebRTC
// audio worklets / Opus encoders (Slack Huddles, Meet
// real-time features, Discord voice) can actually initialise.
// Chromium gates SharedArrayBuffer behind cross-origin
// isolation by default; web apps embedded inside CEF rarely
// send COOP/COEP headers, so without this flag the feature
// silently disappears and huddle/call buttons no-op.
("--enable-features", Some("SharedArrayBuffer")),
];
// Always expose the CDP port, not just in debug. The webview-accounts
// CDP session opener navigates each embedded provider webview from its
// `about:blank#openhuman-acct-...` placeholder to the real provider URL
// via `Page.navigate`. Without this port available in release builds,
// the CDP client can't attach (`browser_ws_url()` 404s on /json/version),
// the navigation never fires, and the embedded webview stays on
// `about:blank` (blank panel for Telegram / WhatsApp / Slack / Discord).
// Same port the `cdp::CDP_HOST`/`cdp::CDP_PORT` constants expect.
args.push(("--remote-debugging-port", Some("19222")));
tauri::Builder::<tauri::Cef>::new().command_line_args::<&str, &str>(args)
};
let builder = builder
// Explicitly disable `open_js_links_on_click`: tauri-plugin-opener
// defaults to injecting `init-iife.js` into *every* webview — a
// global click listener that invokes `plugin:opener|open_url` via
// HTTP-IPC. That violates our "no JS injection into CEF child
// webviews" rule (see CLAUDE.md) and also fails in practice
// because third-party origins (web.telegram.org, linkedin, …)
// trip Tauri's Origin header check and return 500. External link
// handling for `acct_*` webviews runs natively via
// `on_navigation` / `on_new_window` in webview_accounts/mod.rs;
// the main window uses `openUrl()` from `utils/openUrl.ts` when
// it needs to hand off a URL.
.plugin(
tauri_plugin_opener::Builder::default()
.open_js_links_on_click(false)
.build(),
)
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
// Auto-updater for the Tauri shell. Endpoint and minisign pubkey live
// in `tauri.conf.json` under `plugins.updater`. Releases are signed at
// build time with `TAURI_SIGNING_PRIVATE_KEY` (+ `_PASSWORD`); see
// docs/AUTO_UPDATE.md for the full pipeline.
.plugin(tauri_plugin_updater::Builder::new().build())
.manage(dictation_hotkeys::DictationHotkeyState(
std::sync::Mutex::new(Vec::new()),
))
.manage(webview_accounts::WebviewAccountsState::default())
.manage(notification_settings::NotificationSettingsState::new())
.manage(PendingAppUpdateState::default());
let builder = builder.manage(std::sync::Arc::new(imessage_scanner::ScannerRegistry::new()));
let builder = builder.manage(std::sync::Arc::new(
gmessages_scanner::ScannerRegistry::new(),
));
let builder = builder.manage(whatsapp_scanner::ScannerRegistry::new());
let builder = builder.manage(slack_scanner::ScannerRegistry::new());
let builder = builder.manage(discord_scanner::ScannerRegistry::new());
let builder = builder.manage(telegram_scanner::ScannerRegistry::new());
let builder = builder.manage(screen_capture::ScreenShareState::new());
builder
.setup(move |app| {
#[cfg(any(windows, target_os = "linux"))]
{
if let Err(err) = app.deep_link().register_all() {
log::warn!("[deep-link] register_all failed (non-fatal): {err}");
}
}
// Start the webview_apis WebSocket bridge BEFORE spawning core —
// core reads OPENHUMAN_WEBVIEW_APIS_PORT on first connect, and
// connects lazily, so the env var must be set before the spawn.
//
// If the bridge fails to bind we clear any inherited port env so
// the core child can't accidentally connect to whichever loopback
// process already owns that port, then abort setup — the bridge
// is load-bearing for every webview_apis RPC method.
let bridge_ok = tauri::async_runtime::block_on(async {
match webview_apis::start().await {
Ok(port) => {
std::env::set_var(webview_apis::server::PORT_ENV, port.to_string());
log::info!("[webview_apis] bridge ready on port {port}");
true
}
Err(err) => {
log::error!("[webview_apis] failed to start bridge: {err}");
std::env::remove_var(webview_apis::server::PORT_ENV);
false
}
}
});
if !bridge_ok {
return Err("webview_apis bridge failed to start — aborting setup".into());
}
let _ = daemon_mode;
let core_handle =
core_process::CoreProcessHandle::new(core_process::default_core_port());
std::env::set_var("OPENHUMAN_CORE_RPC_URL", core_handle.rpc_url());
// Expose the shared CEF cookies SQLite path to the core sidecar
// so `check_onboarding_status` can detect which webview
// providers (whatsapp, slack, telegram, …) already have a live
// session cookie. Best-effort — if we can't resolve the path
// the core treats every provider as logged_out.
if let Some(cache_dir) = cef_profile::configured_cache_path_from_env() {
let cookies_db = cache_dir.join("Default").join("Cookies");
log::debug!("[webview_accounts] exposing cookies DB path to core");
std::env::set_var("OPENHUMAN_CEF_COOKIES_DB", &cookies_db);
} else {
// Clear any inherited value so the core can't pick up a
// stale path from a previous run or the parent shell.
std::env::remove_var("OPENHUMAN_CEF_COOKIES_DB");
log::warn!(
"[webview_accounts] could not resolve configured CEF cache dir — core \
will report all webview providers as logged_out"
);
}
app.manage(core_handle.clone());
tauri::async_runtime::spawn(async move {
if let Err(err) = core_handle.ensure_running().await {
log::error!("[core] failed to start embedded core: {err}");
return;
}
log::info!("[core] embedded core ready");
});
// Restore last-known window position+size before showing the
// window so the user's first paint after a restart-driven flow
// (#900 identity flip) lands on the same display they used,
// not back at the default centered initial size on the
// primary monitor. `tauri.conf.json` ships `visible: false`
// / `center: false` for the main window so the placement
// happens before the first paint and there's no jump.
if let Some(window) = app.get_webview_window("main") {
if !window_state::restore_main(&window) {
window_state::center_main(&window);
}
if !daemon_mode {
if let Err(err) = window.show() {
log::warn!("[window-state] show main window failed: {err}");
}
}
}
if daemon_mode {
if let Some(window) = app.get_webview_window("main") {
let _ = window.hide();
log::info!("[tray] daemon_mode=true window_hidden_on_startup");
}
}
// Overlay window is currently disabled in `tauri.conf.json` (the
// `overlay` entry under `app.windows` was removed), so we skip
// the macOS NSPanel reclass + bottom-right pin + initial show
// here. The helpers (`configure_overlay_window_macos`,
// `pin_overlay_bottom_right`) and the React entry point
// (`src/overlay/OverlayApp.tsx`) are kept intact so the overlay
// can be re-enabled by restoring the config entry and the two
// setup blocks below.
//
// #[cfg(target_os = "macos")]
// if let Some(window) = app.get_webview_window("overlay") {
// configure_overlay_window_macos(&window);
// }
// if let Some(window) = app.get_webview_window("overlay") {
// pin_overlay_bottom_right(&window);
// let _ = window.show();
// }
// Tray icon setup moved to RunEvent::Ready (see below) — GTK is only
// initialized after the event loop starts, so we must delay tray creation
// until the Ready event fires. Creating the tray here would panic on
// Linux with "GTK has not been initialized".
log::info!("[tray] deferring tray setup to RunEvent::Ready");
// CEF cold-start warmup. Spawns a 1×1 hidden child webview on
// the main window at `about:blank` so CEF's render-process /
// compositor for child webviews is hot before the user clicks
// an account — first cold open of a real provider drops from
// "spin up renderer + navigate" to just "navigate".
//
// Earlier builds had this disabled because of a "blank webview
// on first onboarding open" report; we now park the warmup at
// a far off-screen position and never reveal it (matching the
// 1×1-on-screen pattern used for cold account spawns), and
// tear it down in the shutdown sequence below. Disable at
// runtime with `OPENHUMAN_CEF_PREWARM=0` if it regresses.
{
let prewarm_enabled = std::env::var("OPENHUMAN_CEF_PREWARM")
.map(|v| {
let v = v.trim().to_ascii_lowercase();
!(v == "0" || v == "false" || v == "no" || v == "off")
})
.unwrap_or(true);
if prewarm_enabled {
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
// Defer one tick so the main window finishes its
// first paint before we attach a sibling webview.
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
if let Err(e) = spawn_cef_prewarm(&app_handle) {
log::warn!("[cef-prewarm] failed (non-fatal): {e}");
}
});
} else {
log::info!("[cef-prewarm] disabled via OPENHUMAN_CEF_PREWARM");
}
}
// Dev convenience: if OPENHUMAN_DEV_AUTO_WHATSAPP=<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,
}),
};
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,
}),
};
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,
}),
};
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,
}),
};
match webview_accounts::webview_account_open(
app_handle.clone(),
state,
args,
)
.await
{
Ok(label) => log::info!(
"[dev-auto-gmeet] spawned label={} account={}",
label,
account_id
),
Err(e) => log::error!(
"[dev-auto-gmeet] failed: {} (account={})",
e,
account_id
),
}
});
}
}
#[cfg(target_os = "macos")]
{
use std::sync::Arc;
// The scanner task self-gates on `channels_config.imessage` via
// JSON-RPC each tick — it stays idle until the user connects
// iMessage and stops ingesting as soon as they disconnect. We
// spawn it here just so the loop is live and picks up state
// changes without requiring an app restart.
if let Some(registry) = app.try_state::<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,
check_core_update,
apply_core_update,
check_app_update,
apply_app_update,
download_app_update,
install_app_update,
restart_core_process,
restart_app,
get_active_user_id,
schedule_cef_profile_purge,
register_dictation_hotkey,
unregister_dictation_hotkey,
webview_accounts::webview_account_open,
webview_accounts::webview_account_close,
webview_accounts::webview_account_purge,
webview_accounts::webview_account_bounds,
webview_accounts::webview_account_reveal,
webview_accounts::webview_account_hide,
webview_accounts::webview_account_show,
webview_accounts::webview_recipe_event,
webview_accounts::webview_notification_permission_state,
webview_accounts::webview_notification_permission_request,
webview_accounts::webview_notification_set_dnd,
webview_accounts::webview_notification_mute_account,
webview_accounts::webview_notification_get_bypass_prefs,
webview_accounts::webview_set_focused_account,
notification_settings::notification_settings_get,
notification_settings::notification_settings_set,
screen_capture::screen_share_begin_session,
screen_capture::screen_share_thumbnail,
screen_capture::screen_share_finalize_session,
notification_permission_state,
notification_permission_request,
activate_main_window,
show_native_notification,
mascot_window_show,
mascot_window_hide
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(move |app_handle, event| match event {
RunEvent::Ready => {
log::info!("[app] RunEvent::Ready — GTK initialized, setting up tray");
if let Err(err) = setup_tray(app_handle) {
log::warn!(
"[tray] failed to setup tray icon (non-fatal in headless environment): {err}"
);
}
}
#[cfg(target_os = "macos")]
RunEvent::WindowEvent {
label,
event: WindowEvent::CloseRequested { api, .. },
..
} if label == "main" => {
log::info!(
"[window] close requested on main window — hiding instead of destroying"
);
api.prevent_close();
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.hide();
}
}
#[cfg(target_os = "macos")]
RunEvent::Reopen { .. } => {
log::info!("[window] reopen event — showing main window");
if let Err(err) = show_main_window(app_handle) {
log::error!("[macos] failed to show main window on reopen: {err}");
}
}
RunEvent::ExitRequested { .. } => {
// Run our cleanup BEFORE CEF's own Exit handler does
// `close_all_windows() → cef::shutdown()`. Doing this in
// RunEvent::Exit instead races CEF's teardown and the
// `browser_count == 0` CHECK in `cef::shutdown` panics on
// macOS Cmd+Q (issue #920). The order matters:
// 1. close our child webviews so CEF processes the
// close requests during the Exit-phase message pump
// (gives them time to settle before cef::shutdown).
// 2. abort our long-lived tokio tasks so they're not
// driving CDP traffic against CEF as it tears down.
// 3. stop the webview_apis WS listener so its accept
// loop releases the loopback port.
// 4. SIGTERM the core sidecar (non-blocking). Tauri
// spawned the child so we own its lifecycle, but we
// do not wait — that would block the main thread
// and starve CEF's UI loop. The kernel reaps the
// child after Tauri exits.
perform_early_teardown_sync(app_handle);
}
RunEvent::Exit => {
log::info!("[app] RunEvent::Exit — cef::shutdown follows");
}
_ => {}
});
// Belt-and-suspenders sweep: after Tauri's event loop returns the
// vendored runtime has already called `cef::shutdown()`. In normal
// operation every CEF helper (GPU / Network / Utility / Renderer) is
// gone by now. If anything is still alive — e.g. a renderer that was
// mid-spawn when the user quit — it would otherwise be re-parented to
// launchd on macOS / init on Linux and survive the GUI exit. SIGTERM
// its children before this process actually exits.
//
// We don't `wait()` on them: the kernel will reap them as our exit
// unwinds, and any helper that ignores SIGTERM is a CEF bug we'd
// rather see in Activity Monitor than silently SIGKILL.
sweep_orphan_children();
}
/// Send SIGTERM to every direct child of the current process. No-op on
/// non-Unix platforms (Windows job objects already kill CEF helpers when
/// the parent exits).
fn sweep_orphan_children() {
#[cfg(unix)]
{
let pid = std::process::id();
match std::process::Command::new("pkill")
.args(["-TERM", "-P", &pid.to_string()])
.status()
{
Ok(status) => {
// pkill exits 0 if it killed at least one process, 1 if no
// matches (the healthy case after cef::shutdown), 2/3 on
// error. Both 0 and 1 are expected; log 0 loudly so we
// notice when the safety net actually catches something.
match status.code() {
Some(0) => log::warn!(
"[app] sweep: SIGTERM'd leftover child processes after cef::shutdown"
),
Some(1) => log::info!("[app] sweep: no leftover children (clean exit)"),
other => log::warn!("[app] sweep: pkill exited with {:?}", other),
}
}
Err(e) => log::warn!("[app] sweep: failed to invoke pkill: {e}"),
}
}
#[cfg(not(unix))]
{
log::debug!("[app] sweep: skipped on non-unix platform");
}
}
pub fn run_core_from_args(args: &[String]) -> Result<(), String> {
// Core lives in-process: dispatch directly through the linked `openhuman_core`
// library instead of shelling out to a separate binary. The Tauri main()
// routes `OpenHuman core <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)
// ---------------------------------------------------------------------------
/// 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.
fn build_sentry_release_tag() -> String {
let version = env!("CARGO_PKG_VERSION");
let sha = option_env!("OPENHUMAN_BUILD_SHA").unwrap_or("").trim();
let sha_short: String = sha.chars().take(12).collect();
if sha_short.is_empty() {
format!("openhuman@{version}")
} else {
format!("openhuman@{version}+{sha_short}")
}
}
/// Resolve the Sentry environment tag from `OPENHUMAN_APP_ENV` (runtime) or
/// `VITE_OPENHUMAN_APP_ENV` (compile-time fallback). Defaults to
/// `production` so unmarked release builds don't pollute the dev/staging
/// streams.
fn resolve_sentry_environment() -> String {
if let Ok(value) = std::env::var("OPENHUMAN_APP_ENV") {
let trimmed = value.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
if let Some(value) = option_env!("VITE_OPENHUMAN_APP_ENV") {
let trimmed = value.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
"production".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
/// Test that is_daemon_mode correctly detects daemon flag variations
#[test]
fn is_daemon_mode_detects_daemon_flag() {
// Note: This test relies on the current process args, so in test mode
// it will typically return false. We verify the function is callable.
let _result = is_daemon_mode();
}
/// Test core_rpc_url returns expected format
#[test]
fn core_rpc_url_returns_expected_format() {
// Save original env
let original = std::env::var("OPENHUMAN_CORE_RPC_URL").ok();
// Test with env var set
std::env::set_var("OPENHUMAN_CORE_RPC_URL", "http://localhost:9999/rpc");
let url = core_rpc_url();
assert_eq!(url, "http://localhost:9999/rpc");
// Test fallback when env not set
std::env::remove_var("OPENHUMAN_CORE_RPC_URL");
let url = core_rpc_url();
assert_eq!(url, "http://127.0.0.1:7788/rpc");
// Restore original
match original {
Some(v) => std::env::set_var("OPENHUMAN_CORE_RPC_URL", v),
None => std::env::remove_var("OPENHUMAN_CORE_RPC_URL"),
}
}
/// Test overlay_parent_rpc_url handles empty env var
#[test]
fn overlay_parent_rpc_url_handles_empty() {
// Save original env
let original = std::env::var("OPENHUMAN_CORE_RPC_URL").ok();
// Test with empty string (should return None)
std::env::set_var("OPENHUMAN_CORE_RPC_URL", "");
let result = overlay_parent_rpc_url();
assert!(result.is_none());
// Test with whitespace only (should return None)
std::env::set_var("OPENHUMAN_CORE_RPC_URL", " ");
let result = overlay_parent_rpc_url();
assert!(result.is_none());
// Test with valid URL
std::env::set_var("OPENHUMAN_CORE_RPC_URL", "http://127.0.0.1:7788/rpc");
let result = overlay_parent_rpc_url();
assert_eq!(result, Some("http://127.0.0.1:7788/rpc".to_string()));
// Restore original
match original {
Some(v) => std::env::set_var("OPENHUMAN_CORE_RPC_URL", v),
None => std::env::remove_var("OPENHUMAN_CORE_RPC_URL"),
}
}
/// Tests for setup_tray conditional compilation
/// The PR adds two versions of setup_tray():
/// 1. No-op for linux + cef: logs warning and returns Ok(())
/// 2. Full implementation for other platforms
///
/// These tests verify the function signatures are correct and
/// the compile-time cfg blocks are properly set up.
/// Verify setup_tray function exists and has correct signature
/// This test passes if the code compiles, as the function signature
/// is validated by the compiler.
#[test]
fn setup_tray_function_signature_compiles() {
// This test exists to ensure the conditional compilation
// of setup_tray is valid. The function is not actually called
// here because it requires a full Tauri AppHandle.
// The cfg attributes ensure only one version exists at compile time.
}
/// Test that AppRuntime is defined for the current feature set
#[test]
fn app_runtime_type_exists() {
// This test verifies AppRuntime is properly defined
// based on the cef feature flag.
// The type alias exists at module scope and is used throughout.
fn _check_runtime<R: tauri::Runtime>() {}
// _check_runtime::<AppRuntime>(); // Would require importing
}
/// Verify tray logging patterns exist (grep-friendly)
#[test]
fn tray_setup_logging_patterns_exist() {
// These log patterns from the PR are grep-friendly:
// "[tray] skipping tray setup on linux+cef: ..."
// "[tray] setting up tray icon"
// "[tray] tray icon ready"
// "[tray] action=show_window ..."
// "[tray] action=quit ..."
// "[tray] failed to setup tray icon ..."
// "[app] RunEvent::Ready — GTK initialized, setting up tray"
//
// This test passes if the code compiles with these log messages.
}
}