diff --git a/apps/bootstrap-installer/index.html b/apps/bootstrap-installer/index.html index 1b34980a9..f9f7da034 100644 --- a/apps/bootstrap-installer/index.html +++ b/apps/bootstrap-installer/index.html @@ -3,7 +3,7 @@ - Hermes Setup + Hermes
diff --git a/apps/bootstrap-installer/src-tauri/src/bootstrap.rs b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs index 529b3b447..9b5897168 100644 --- a/apps/bootstrap-installer/src-tauri/src/bootstrap.rs +++ b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs @@ -208,7 +208,7 @@ pub async fn launch_hermes_desktop( /// Walks the well-known electron-builder unpacked-app paths under /// `install_root`. Mirrors the resolver in `cmd_gui` (apps/desktop/release/ /// -unpacked/). -fn resolve_hermes_desktop_exe(install_root: &std::path::Path) -> Option { +pub(crate) fn resolve_hermes_desktop_exe(install_root: &std::path::Path) -> Option { let release_dir = install_root.join("apps").join("desktop").join("release"); let candidates: &[(&str, &str)] = if cfg!(target_os = "windows") { &[ @@ -232,6 +232,35 @@ fn resolve_hermes_desktop_exe(install_root: &std::path::Path) -> Option None } +/// True when a prior install completed (bootstrap-complete marker present) AND a +/// launchable desktop app exists on disk. Used by the installer's launcher fast +/// path so a bare re-open just opens Hermes instead of re-running setup. +pub(crate) fn hermes_is_installed(install_root: &std::path::Path) -> bool { + install_root.join(".hermes-bootstrap-complete").exists() + && resolve_hermes_desktop_exe(install_root).is_some() +} + +/// Spawn the already-built desktop app, detached. Returns Err if no built app +/// exists or the spawn fails, so the caller can fall back to showing the +/// installer UI. +pub(crate) fn spawn_installed_desktop(install_root: &std::path::Path) -> std::io::Result<()> { + let exe = resolve_hermes_desktop_exe(install_root).ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::NotFound, "no built Hermes desktop app") + })?; + let mut cmd = std::process::Command::new(&exe); + cmd.current_dir(exe.parent().unwrap_or(install_root)); + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + // DETACHED_PROCESS = 0x00000008 — keep the desktop alive after the + // installer exits, mirroring launch_hermes_desktop. Kept correct here + // even though the only caller is macOS-gated today, so future reuse on + // Windows doesn't reintroduce the relaunch race. + cmd.creation_flags(0x0000_0008); + } + cmd.spawn().map(|_child| ()) +} + // --------------------------------------------------------------------------- // Bootstrap implementation // --------------------------------------------------------------------------- diff --git a/apps/bootstrap-installer/src-tauri/src/lib.rs b/apps/bootstrap-installer/src-tauri/src/lib.rs index a710ce9b5..bed06b971 100644 --- a/apps/bootstrap-installer/src-tauri/src/lib.rs +++ b/apps/bootstrap-installer/src-tauri/src/lib.rs @@ -50,6 +50,20 @@ impl AppMode { } } +/// Returns true when the args request a forced installer UI (repair/reinstall) +/// via `--reinstall` or `--repair`, which overrides the macOS launcher +/// fast-path so a broken install can be repaired. Arg-iterator generic so it's +/// unit-testable, mirroring `AppMode::from_args`. Independent of mode selection: +/// these flags never flip Install<->Update. +pub fn force_setup_from_args(args: I) -> bool +where + I: IntoIterator, + S: AsRef, +{ + args.into_iter() + .any(|a| a.as_ref() == "--reinstall" || a.as_ref() == "--repair") +} + /// Process-wide install state, shared across Tauri commands. /// /// The bootstrap is a one-shot, single-tenant process — we only need one @@ -85,7 +99,11 @@ pub fn run() { let _guard = paths::init_logging(); let mode = AppMode::from_args(std::env::args().skip(1)); - tracing::info!(?mode, "Hermes Setup starting"); + // Escape hatch: `--reinstall`/`--repair` forces the installer UI even when + // Hermes is already installed, so users can re-run setup to repair a broken + // install instead of the launcher fast path silently relaunching the app. + let force_setup = force_setup_from_args(std::env::args().skip(1)); + tracing::info!(?mode, force_setup, "Hermes installer starting"); tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) @@ -93,6 +111,60 @@ pub fn run() { .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_shell::init()) .manage(Arc::new(AppState::new(mode))) + .setup(move |app| { + use tauri::Manager; + // Launcher fast path (macOS only): a bare ("Install") launch when + // Hermes is already installed should NOT show the installer or + // rebuild — it should just open the app, so the /Applications + // "Hermes" doubles as a normal launcher (first run installs, every + // later run launches instantly). The window is kept hidden until + // here via `"visible": false` so this path never flashes a window. + // + // Gated to macOS deliberately: on Windows/Linux the installer keeps + // its existing behavior (Windows users relaunch via the Start + // Menu/Desktop "Hermes" shortcuts that install.ps1 creates, and a + // reliable detached relaunch there needs the DETACHED_PROCESS + + // startup-grace handling used by launch_hermes_desktop — out of + // scope here). So this is a pure no-op on non-macOS. + // + // `--reinstall`/`--repair` opts out so a broken install can be + // repaired by re-running setup instead of launching the bad app. + if cfg!(target_os = "macos") && mode == AppMode::Install && !force_setup { + let install_root = paths::hermes_home().join("hermes-agent"); + if bootstrap::hermes_is_installed(&install_root) { + match bootstrap::spawn_installed_desktop(&install_root) { + Ok(()) => { + // Brief grace so the spawned app is registered + // before we exit (mirrors launch_hermes_desktop). + std::thread::sleep(std::time::Duration::from_millis(200)); + tracing::info!( + "hermes already installed — relaunched desktop; exiting installer" + ); + app.handle().exit(0); + return Ok(()); + } + Err(err) => { + tracing::warn!( + ?err, + "relaunch of installed desktop failed; showing installer UI" + ); + } + } + } + } + // First run / repair install, or Update mode: reveal the UI. + match app.get_webview_window("main") { + Some(win) => { + if let Err(err) = win.show() { + tracing::error!(?err, "failed to show main installer window"); + } + } + None => { + tracing::error!("main installer window not found; installer UI will not appear"); + } + } + Ok(()) + }) .invoke_handler(tauri::generate_handler![ // Mode (install vs update) get_mode, @@ -115,7 +187,7 @@ pub fn run() { #[cfg(test)] mod tests { - use super::AppMode; + use super::{force_setup_from_args, AppMode}; #[test] fn bare_args_are_install() { @@ -131,4 +203,30 @@ mod tests { AppMode::Update ); } + + #[test] + fn reinstall_and_repair_flags_force_setup() { + assert!(force_setup_from_args(["--reinstall"])); + assert!(force_setup_from_args(["--repair"])); + assert!(force_setup_from_args(["--foo", "--repair", "--bar"])); + } + + #[test] + fn bare_or_unrelated_args_do_not_force_setup() { + assert!(!force_setup_from_args(Vec::::new())); + assert!(!force_setup_from_args(["--foo", "bar"])); + // --update must not be mistaken for a force-setup flag. + assert!(!force_setup_from_args(["--update"])); + } + + #[test] + fn force_setup_flags_do_not_affect_mode_selection() { + // The repair flags must never flip Install<->Update. + assert_eq!(AppMode::from_args(["--reinstall"]), AppMode::Install); + assert_eq!(AppMode::from_args(["--repair"]), AppMode::Install); + assert_eq!( + AppMode::from_args(["--update", "--reinstall"]), + AppMode::Update + ); + } } diff --git a/apps/bootstrap-installer/src-tauri/tauri.conf.json b/apps/bootstrap-installer/src-tauri/tauri.conf.json index daf4d2c6e..35ad53bc0 100644 --- a/apps/bootstrap-installer/src-tauri/tauri.conf.json +++ b/apps/bootstrap-installer/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "Hermes Setup", + "productName": "Hermes", "version": "0.0.1", "identifier": "com.nousresearch.hermes.setup", "build": { @@ -13,7 +13,7 @@ "windows": [ { "label": "main", - "title": "Hermes Setup", + "title": "Hermes", "width": 880, "height": 620, "minWidth": 720, @@ -22,7 +22,8 @@ "fullscreen": false, "decorations": true, "transparent": false, - "center": true + "center": true, + "visible": false } ], "security": { @@ -33,7 +34,7 @@ "bundle": { "active": true, "category": "DeveloperTool", - "shortDescription": "Hermes Setup", + "shortDescription": "Hermes", "longDescription": "Installs Hermes Agent on your machine. Drives scripts/install.ps1 (Windows) and scripts/install.sh (macOS/Linux).", "publisher": "Nous Research", "copyright": "Copyright © 2026 Nous Research",