* feat(installer): rename macOS installer to "Hermes" and make it a launcher
The bootstrap installer was branded "Hermes Setup" and always re-ran the full
install flow on every open — so the /Applications app said "Setup" and couldn't
double as a way to relaunch Hermes (the real desktop app lives in ~/.hermes,
not /Applications, with no Dock/Launchpad entry).
Two changes, macOS-focused:
1. Rename the installer's user-visible name to "Hermes" (productName, window
title, shortDescription, document title). Bundle id stays
com.nousresearch.hermes.setup (distinct from the desktop app's
com.nousresearch.hermes); the on-disk staged updater name (hermes-setup) is
unchanged, so the desktop's update hand-off still resolves it.
2. Launcher fast path: on a bare ("Install") launch, if Hermes is already
installed (bootstrap-complete marker + a built desktop app on disk), skip the
installer UI entirely and relaunch the desktop app, then exit. First run still
installs; Update mode and fresh/repair installs still show the UI. The window
now starts hidden ("visible": false) and is revealed only when the UI is
actually needed, so the launcher path never flashes a window.
Net UX: one "Hermes" in /Applications you can pin to the Dock — first click
installs, every later click opens the app instantly (same icon throughout, so
the Dock stays seamless). Nothing pins to the Dock permanently; the app shows a
normal Dock icon only while running.
Windows naming is intentionally left as-is in this change (scope: macOS).
* fix(installer): gate launcher fast path to macOS + log window-show failures
Address review feedback:
- Gate the already-installed launcher fast path to macOS (cfg!(target_os =
"macos")). On Windows/Linux the installer keeps its prior behavior, so the
change is a pure no-op there. This avoids relaunching the desktop app on
Windows via a spawn that lacks the DETACHED_PROCESS + startup-grace handling
launch_hermes_desktop uses (which could race the installer's exit).
- Add a brief startup grace before exiting on the mac fast path, mirroring
launch_hermes_desktop.
- Log (instead of silently ignoring) failures to show the main window, and log
when the "main" window can't be found, so a no-UI state is diagnosable.
* fix(installer): add --reinstall escape hatch + keep spawn detached on Windows
Address follow-up review:
- Add a `--reinstall`/`--repair` flag that forces the installer UI even when
Hermes is already installed, so a broken install can be repaired by re-running
setup instead of the launcher fast path silently relaunching the (possibly
bad) app.
- Apply DETACHED_PROCESS on Windows in spawn_installed_desktop, mirroring
launch_hermes_desktop, so the helper stays correct cross-platform even though
its only caller is macOS-gated today.
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* test(installer): unit-test --reinstall/--repair force-setup parsing
Extract the force-setup flag parsing into a unit-testable
`force_setup_from_args` helper (mirrors `AppMode::from_args`) and add tests:
- --reinstall and --repair are recognized
- bare/unrelated args (incl. --update) do not force setup
- the repair flags never affect Install<->Update mode selection
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
233 lines
8.9 KiB
Rust
233 lines
8.9 KiB
Rust
//! Hermes Setup — Tauri entrypoint.
|
|
//!
|
|
//! Spawns a single window pointed at the React frontend (apps/bootstrap-installer/src/).
|
|
//! All install-time work lives in `bootstrap.rs` and is invoked through the Tauri
|
|
//! commands registered at the bottom of `run()`.
|
|
//!
|
|
//! The Windows-subsystem strip lives on the binary crate (src/main.rs), not
|
|
//! here — a crate-level attribute on a lib doesn't propagate to the linker
|
|
//! flags of the executable that consumes it.
|
|
|
|
mod bootstrap;
|
|
mod events;
|
|
mod install_script;
|
|
mod powershell;
|
|
mod paths;
|
|
mod update;
|
|
|
|
use std::sync::Arc;
|
|
use tokio::sync::Mutex;
|
|
|
|
/// How the installer was invoked. Resolved once from the process args in
|
|
/// `run()` and exposed to the frontend via `get_mode` so it can route to the
|
|
/// install flow (first-run onboarding) or the update flow (driven by the
|
|
/// desktop app handing off via `Hermes-Setup.exe --update`).
|
|
///
|
|
/// Bare launch (double-click, first-run) => Install.
|
|
/// `--update` (spawned by the desktop's "Update" button) => Update.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum AppMode {
|
|
Install,
|
|
Update,
|
|
}
|
|
|
|
impl AppMode {
|
|
/// Resolve the mode from an argument iterator. Anything containing the
|
|
/// `--update` flag selects Update; otherwise Install. Kept arg-iterator
|
|
/// generic (not reading `std::env` directly) so it's unit-testable.
|
|
pub fn from_args<I, S>(args: I) -> Self
|
|
where
|
|
I: IntoIterator<Item = S>,
|
|
S: AsRef<str>,
|
|
{
|
|
for a in args {
|
|
if a.as_ref() == "--update" {
|
|
return AppMode::Update;
|
|
}
|
|
}
|
|
AppMode::Install
|
|
}
|
|
}
|
|
|
|
/// 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<I, S>(args: I) -> bool
|
|
where
|
|
I: IntoIterator<Item = S>,
|
|
S: AsRef<str>,
|
|
{
|
|
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
|
|
/// of these per window. `Arc<Mutex<...>>` lets command handlers grab it
|
|
/// without lifetime gymnastics.
|
|
pub struct AppState {
|
|
pub bootstrap: Mutex<Option<bootstrap::BootstrapHandle>>,
|
|
/// How this process was launched (install vs update). Immutable for the
|
|
/// lifetime of the process; read by the `get_mode` command.
|
|
pub mode: AppMode,
|
|
}
|
|
|
|
impl AppState {
|
|
fn new(mode: AppMode) -> Self {
|
|
Self {
|
|
bootstrap: Mutex::new(None),
|
|
mode,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Frontend → Rust: which flow should the UI render?
|
|
#[tauri::command]
|
|
fn get_mode(state: tauri::State<'_, Arc<AppState>>) -> AppMode {
|
|
state.mode
|
|
}
|
|
|
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
|
pub fn run() {
|
|
// Tracing → bootstrap-installer.log under HERMES_HOME/logs/ so install
|
|
// failures leave a trail for support. Console output also goes here in
|
|
// debug builds.
|
|
let _guard = paths::init_logging();
|
|
|
|
let mode = AppMode::from_args(std::env::args().skip(1));
|
|
// 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())
|
|
.plugin(tauri_plugin_opener::init())
|
|
.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,
|
|
// Bootstrap lifecycle
|
|
bootstrap::start_bootstrap,
|
|
bootstrap::cancel_bootstrap,
|
|
bootstrap::get_bootstrap_status,
|
|
// Update lifecycle
|
|
update::start_update,
|
|
// Hand-off
|
|
bootstrap::launch_hermes_desktop,
|
|
// Diagnostics
|
|
paths::get_log_path,
|
|
paths::get_hermes_home,
|
|
paths::open_log_dir,
|
|
])
|
|
.run(tauri::generate_context!())
|
|
.expect("error while running Hermes Setup");
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{force_setup_from_args, AppMode};
|
|
|
|
#[test]
|
|
fn bare_args_are_install() {
|
|
assert_eq!(AppMode::from_args(Vec::<String>::new()), AppMode::Install);
|
|
assert_eq!(AppMode::from_args(["--foo", "bar"]), AppMode::Install);
|
|
}
|
|
|
|
#[test]
|
|
fn update_flag_selects_update() {
|
|
assert_eq!(AppMode::from_args(["--update"]), AppMode::Update);
|
|
assert_eq!(
|
|
AppMode::from_args(["--something", "--update", "--else"]),
|
|
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::<String>::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
|
|
);
|
|
}
|
|
}
|