mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-29 22:23:01 +00:00
2015 lines
84 KiB
Rust
2015 lines
84 KiB
Rust
#[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.
|
||
}
|
||
}
|