feat(installer): drive in-app updates through the Tauri installer

Converge update on the same principle as bootstrap: one driver owns all
repo mutation. The desktop becomes a pure consumer that hands off to
Hermes-Setup.exe --update instead of re-implementing git/pip in Electron.

- hermes desktop --build-only: build without launching, so the installer
  owns the post-update launch (CLI keeps build logic single-sourced).
- Installer AppMode {Install,Update} from argv; get_mode exposed to the UI.
- Installer self-copies to HERMES_HOME/hermes-setup.exe on install success
  (no-op guard during --update re-invocation to avoid the locked-exe copy).
- Installer --update flow (update.rs): wait for the desktop to release the
  venv shim, run 'hermes update --yes --gateway' (branch on exit 0/2/other),
  then 'hermes desktop --build-only', then launch the rebuilt desktop. Reuses
  the bootstrap event channel + progress UI via a synthetic two-stage manifest.
- Desktop applyUpdates() gutted (~105 lines of git/stash/pull/pyproject/pip
  removed) -> thin handoff: spawn updater, app.quit() to free the shim.
  Detection (checkUpdates, commit changelog, behind-count) kept intact.
- install.ps1 creates Start Menu + Desktop shortcuts to the packed Hermes.exe
  (never bare 'hermes desktop', which would rebuild every launch).
This commit is contained in:
emozilla
2026-05-28 23:48:21 -04:00
parent a4cfc8b740
commit 6381e70448
9 changed files with 709 additions and 128 deletions
@@ -539,6 +539,18 @@ async fn run_bootstrap(
.unwrap_or_else(|| crate::paths::hermes_home().to_string_lossy().into_owned());
let install_root = PathBuf::from(&hermes_home).join("hermes-agent");
// Copy ourselves to HERMES_HOME/hermes-setup.exe so the desktop app can
// re-invoke us with `--update` and shortcuts have a stable target. This is
// a one-shot install concern; an `--update` re-invocation no-ops because
// we're already running from that path. Best-effort — a failure here must
// not fail an otherwise-successful install.
if let Err(err) = crate::paths::copy_self_to_hermes_home() {
tracing::warn!(?err, "failed to copy installer into HERMES_HOME (non-fatal)");
emit_log(&format!(
"[bootstrap] warning: could not stage updater binary: {err}"
));
}
emit_event(
&app,
BootstrapEvent::Complete {
+72 -4
View File
@@ -13,10 +13,43 @@ 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
}
}
/// Process-wide install state, shared across Tauri commands.
///
/// The bootstrap is a one-shot, single-tenant process — we only need one
@@ -24,16 +57,26 @@ use tokio::sync::Mutex;
/// 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 Default for AppState {
fn default() -> Self {
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
@@ -41,19 +84,24 @@ pub fn run() {
// debug builds.
let _guard = paths::init_logging();
tracing::info!("Hermes Setup starting");
let mode = AppMode::from_args(std::env::args().skip(1));
tracing::info!(?mode, "Hermes Setup 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::default()))
.manage(Arc::new(AppState::new(mode)))
.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
@@ -64,3 +112,23 @@ pub fn run() {
.run(tauri::generate_context!())
.expect("error while running Hermes Setup");
}
#[cfg(test)]
mod tests {
use super::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
);
}
}
@@ -58,6 +58,55 @@ pub fn bootstrap_cache_dir() -> PathBuf {
hermes_home().join("bootstrap-cache")
}
/// Stable location the installer copies itself to after a successful install.
/// The desktop app re-invokes this with `--update`, and the start-menu /
/// desktop shortcuts can point users back to it. Lives directly under
/// HERMES_HOME so it survives repo checkout deletion (unlike anything under
/// hermes-agent/).
///
/// On Windows this is `%LOCALAPPDATA%\hermes\hermes-setup.exe`; on other
/// platforms the extension differs but the directory is the same.
pub fn installer_dest() -> PathBuf {
let name = if cfg!(target_os = "windows") {
"hermes-setup.exe"
} else {
"hermes-setup"
};
hermes_home().join(name)
}
/// Copy the currently-running installer binary to `installer_dest()` so it's
/// available for future `--update` runs and shortcut launches.
///
/// No-ops (returns Ok) when the running exe is ALREADY the destination — which
/// is exactly the case during an `--update` run (the desktop launched us FROM
/// that path), where copying onto ourselves would be a Windows sharing
/// violation. Best-effort: a failure here must not fail the install, so the
/// caller logs and continues.
pub fn copy_self_to_hermes_home() -> std::io::Result<()> {
let src = std::env::current_exe()?;
let dest = installer_dest();
// Skip if we're already running from the destination (update re-invocation
// or a prior copy). canonicalize both so symlinks / 8.3 short paths / case
// differences don't trick us into a self-copy.
let same = match (src.canonicalize(), dest.canonicalize()) {
(Ok(a), Ok(b)) => a == b,
_ => src == dest,
};
if same {
tracing::info!(?dest, "installer already at destination; skipping self-copy");
return Ok(());
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&src, &dest)?;
tracing::info!(?src, ?dest, "copied installer to HERMES_HOME");
Ok(())
}
/// Where install.ps1 writes the bootstrap-complete marker (existence-only file
/// the Electron app also checks). Per main.cjs:
/// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete')
@@ -0,0 +1,435 @@
//! Update orchestration.
//!
//! Driven when the installer is launched as `Hermes-Setup.exe --update` (see
//! `AppMode` in lib.rs). The desktop app hands off to us — it exits, then we:
//!
//! 1. wait for the old Hermes desktop process to fully exit (so the venv
//! shim is free; otherwise `hermes update` aborts with exit code 2),
//! 2. run `hermes update --yes --gateway` (Python/repo update; this does NOT
//! rebuild apps/desktop by design — see cmd_update in hermes_cli/main.py),
//! 3. run `hermes desktop --build-only` (the rebuild step update skips),
//! 4. launch the freshly-built desktop (reuses bootstrap::launch logic).
//!
//! We reuse the `BootstrapEvent` channel + the existing progress UI by
//! emitting a synthetic two-stage manifest ("update", "rebuild"). To the
//! frontend an update looks like a short bootstrap.
//!
//! Cross-platform note: `hermes update` already handles macOS/Linux (git/pip).
//! The only OS-specific bits here are the venv shim path (resolve_hermes) and
//! the no-window creation flag — both already cfg-gated. Keep new logic
//! OS-agnostic so the mac/linux port stays "fill in the paths".
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::{Duration, Instant};
use anyhow::{anyhow, Result};
use tauri::{AppHandle, Emitter};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use crate::events::{BootstrapEvent, StageInfo, StageState};
/// `hermes update` exit code meaning "another hermes process is holding the
/// venv shim open / dirty precondition" — see _cmd_update_impl in
/// hermes_cli/main.py (sys.exit(2)). We surface a targeted message for this.
const UPDATE_EXIT_CONCURRENT: i32 = 2;
/// How long to wait for the old desktop process to release the venv shim
/// before giving up and letting `hermes update`'s own guard decide.
const DESKTOP_EXIT_WAIT: Duration = Duration::from_secs(20);
const DESKTOP_EXIT_POLL: Duration = Duration::from_millis(500);
/// Frontend → Rust: kick off the update flow. Mirrors `start_bootstrap`'s
/// fire-and-forget shape; progress arrives on the `bootstrap` event channel.
#[tauri::command]
pub async fn start_update(app: AppHandle) -> Result<(), String> {
tokio::spawn(async move {
if let Err(err) = run_update(app.clone()).await {
// run_update already emits a Failed event on the paths that matter;
// this catches anything that escaped. Emit defensively.
emit(
&app,
BootstrapEvent::Failed {
stage: None,
error: format!("{err:#}"),
},
);
}
});
Ok(())
}
async fn run_update(app: AppHandle) -> Result<()> {
let hermes_home = crate::paths::hermes_home();
let install_root = hermes_home.join("hermes-agent");
let hermes = resolve_hermes(&install_root).ok_or_else(|| {
let msg = format!(
"Could not find the hermes CLI under {}. Is Hermes installed? \
Re-run the installer to repair the install.",
install_root.display()
);
emit(
&app,
BootstrapEvent::Failed {
stage: None,
error: msg.clone(),
},
);
anyhow!(msg)
})?;
// Synthetic manifest so the existing progress UI renders our two stages.
emit(
&app,
BootstrapEvent::Manifest {
stages: vec![
stage_info("update", "Updating Hermes"),
stage_info("rebuild", "Rebuilding the desktop app"),
],
protocol_version: None,
},
);
// ---- pre-step: wait for the old desktop to die -----------------------
// The desktop exec'd us then called app.exit(), but process teardown is
// async on Windows. If it still holds the venv shim, `hermes update`
// aborts with exit 2. Give it a bounded window to clear.
wait_for_venv_free(&install_root, &app).await;
// ---- stage 1: hermes update -----------------------------------------
emit_stage(&app, "update", StageState::Running, None, None);
let started = Instant::now();
let update = run_streamed(
&app,
&hermes,
&["update", "--yes", "--gateway"],
&install_root,
Some("update"),
)
.await?;
let update_ms = started.elapsed().as_millis() as u64;
match update.exit_code {
Some(0) => {
emit_stage(&app, "update", StageState::Succeeded, Some(update_ms), None);
}
Some(code) if code == UPDATE_EXIT_CONCURRENT => {
let msg = "Hermes is still running. Close all Hermes windows and try \
the update again."
.to_string();
emit_stage(
&app,
"update",
StageState::Failed,
Some(update_ms),
Some(msg.clone()),
);
emit(
&app,
BootstrapEvent::Failed {
stage: Some("update".into()),
error: msg.clone(),
},
);
return Err(anyhow!(msg));
}
other => {
let msg = format!(
"hermes update failed (exit {:?}). See {} for details.",
other,
crate::paths::hermes_home()
.join("logs")
.join("update.log")
.display()
);
emit_stage(
&app,
"update",
StageState::Failed,
Some(update_ms),
Some(msg.clone()),
);
emit(
&app,
BootstrapEvent::Failed {
stage: Some("update".into()),
error: msg.clone(),
},
);
return Err(anyhow!(msg));
}
}
// ---- stage 2: hermes desktop --build-only ----------------------------
// `hermes update` deliberately does NOT build apps/desktop (it installs
// repo-root deps with --workspaces=false). This is the rebuild it skips.
emit_stage(&app, "rebuild", StageState::Running, None, None);
let started = Instant::now();
let rebuild = run_streamed(
&app,
&hermes,
&["desktop", "--build-only"],
&install_root,
Some("rebuild"),
)
.await?;
let rebuild_ms = started.elapsed().as_millis() as u64;
if rebuild.exit_code != Some(0) {
let msg = format!(
"Rebuilding the desktop app failed (exit {:?}). The update was \
applied but the app could not be rebuilt; run `hermes desktop` \
from a terminal to see the error.",
rebuild.exit_code
);
emit_stage(
&app,
"rebuild",
StageState::Failed,
Some(rebuild_ms),
Some(msg.clone()),
);
emit(
&app,
BootstrapEvent::Failed {
stage: Some("rebuild".into()),
error: msg.clone(),
},
);
return Err(anyhow!(msg));
}
emit_stage(&app, "rebuild", StageState::Succeeded, Some(rebuild_ms), None);
// ---- done: signal complete, then launch the fresh desktop ------------
emit(
&app,
BootstrapEvent::Complete {
install_root: install_root.to_string_lossy().into_owned(),
marker: None,
},
);
// Reuse the same detached-launch + app.exit(0) used post-install.
if let Err(err) =
crate::bootstrap::launch_hermes_desktop(app.clone(), install_root.to_string_lossy().into_owned())
.await
{
// Launch failed: don't hard-fail the update (it succeeded); surface a
// log line so the success screen can still tell the user to launch
// manually.
emit_log(
&app,
None,
&format!("[update] could not auto-launch desktop: {err}. Launch Hermes manually."),
);
}
Ok(())
}
/// Poll until the venv shim is no longer locked (Windows) or a bounded timeout
/// elapses. On non-Windows this is a short fixed grace since file locking
/// isn't the failure mode there.
async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
let shim = venv_hermes(install_root);
let deadline = Instant::now() + DESKTOP_EXIT_WAIT;
emit_log(app, Some("update"), "[update] waiting for Hermes to exit…");
loop {
if !is_locked(&shim) {
return;
}
if Instant::now() >= deadline {
emit_log(
app,
Some("update"),
"[update] timed out waiting for Hermes to exit; proceeding anyway",
);
return;
}
tokio::time::sleep(DESKTOP_EXIT_POLL).await;
}
}
/// Best-effort lock probe: try to open the file for read+write. On Windows an
/// exclusively-held running .exe refuses the open with a sharing violation.
/// On Unix this almost always succeeds (no mandatory locking), which is fine —
/// the venv-shim contention is a Windows-only problem.
fn is_locked(path: &Path) -> bool {
if !path.exists() {
return false;
}
match std::fs::OpenOptions::new().read(true).write(true).open(path) {
Ok(_) => false,
Err(_) => true,
}
}
/// Spawn `hermes <args>` from `cwd`, stream stdout/stderr as Log events on the
/// bootstrap channel, and return the exit code. Mirrors powershell::run_script
/// but for an arbitrary command (no install.ps1 -File wrapping).
async fn run_streamed(
app: &AppHandle,
program: &Path,
args: &[&str],
cwd: &Path,
stage: Option<&str>,
) -> Result<CmdResult> {
let mut cmd = Command::new(program);
cmd.args(args)
.current_dir(cwd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
// CREATE_NO_WINDOW = 0x08000000 — no flashing console behind the GUI.
cmd.creation_flags(0x0800_0000);
}
let mut child = cmd
.spawn()
.map_err(|e| anyhow!("spawning {} {:?}: {e}", program.display(), args))?;
let stdout = child.stdout.take().expect("stdout piped");
let stderr = child.stderr.take().expect("stderr piped");
let mut out = BufReader::new(stdout).lines();
let mut err = BufReader::new(stderr).lines();
let stage_owned = stage.map(|s| s.to_string());
loop {
tokio::select! {
line = out.next_line() => match line {
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), &l),
Ok(None) => break,
Err(e) => { tracing::warn!("stdout read error: {e}"); break; }
},
line = err.next_line() => match line {
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), &format!("stderr: {l}")),
Ok(None) => {}
Err(e) => { tracing::warn!("stderr read error: {e}"); }
},
}
}
while let Ok(Some(l)) = out.next_line().await {
emit_log(app, stage_owned.as_deref(), &l);
}
while let Ok(Some(l)) = err.next_line().await {
emit_log(app, stage_owned.as_deref(), &format!("stderr: {l}"));
}
let status = child.wait().await.map_err(|e| anyhow!("waiting for child: {e}"))?;
Ok(CmdResult {
exit_code: status.code(),
})
}
struct CmdResult {
exit_code: Option<i32>,
}
/// Path to the venv hermes shim under an install root, regardless of existence.
fn venv_hermes(install_root: &Path) -> PathBuf {
if cfg!(target_os = "windows") {
install_root.join("venv").join("Scripts").join("hermes.exe")
} else {
install_root.join("venv").join("bin").join("hermes")
}
}
/// Resolve the hermes CLI to drive. Prefer the venv shim in the install we
/// just updated; fall back to `hermes` on PATH.
fn resolve_hermes(install_root: &Path) -> Option<PathBuf> {
let shim = venv_hermes(install_root);
if shim.exists() {
return Some(shim);
}
// PATH fallback. which-style probe via env, kept dependency-free.
let exe = if cfg!(target_os = "windows") { "hermes.exe" } else { "hermes" };
if let Ok(path) = std::env::var("PATH") {
let sep = if cfg!(target_os = "windows") { ';' } else { ':' };
for dir in path.split(sep) {
let cand = Path::new(dir).join(exe);
if cand.exists() {
return Some(cand);
}
}
}
None
}
// ---------------------------------------------------------------------------
// Event helpers — keep emit shape identical to bootstrap.rs so the UI is reused
// ---------------------------------------------------------------------------
fn stage_info(name: &str, title: &str) -> StageInfo {
StageInfo {
name: name.to_string(),
title: title.to_string(),
category: "update".to_string(),
needs_user_input: false,
}
}
fn emit(app: &AppHandle, event: BootstrapEvent) {
if let Err(e) = app.emit(BootstrapEvent::CHANNEL, &event) {
tracing::warn!(?e, "failed to emit update event");
}
}
fn emit_stage(
app: &AppHandle,
name: &str,
state: StageState,
duration_ms: Option<u64>,
error: Option<String>,
) {
tracing::info!(stage = %name, ?state, ?duration_ms, ?error, "update stage");
emit(
app,
BootstrapEvent::Stage {
name: name.to_string(),
state,
duration_ms,
result: None,
error,
},
);
}
fn emit_log(app: &AppHandle, stage: Option<&str>, line: &str) {
match stage {
Some(s) => tracing::info!(target: "bootstrap.log", stage = %s, "{line}"),
None => tracing::info!(target: "bootstrap.log", "{line}"),
}
emit(
app,
BootstrapEvent::Log {
stage: stage.map(|s| s.to_string()),
line: line.to_string(),
},
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn venv_hermes_is_under_install_root() {
let root = Path::new("/x/hermes-agent");
let shim = venv_hermes(root);
assert!(shim.starts_with(root));
assert!(shim.to_string_lossy().contains("venv"));
}
#[test]
fn missing_file_is_not_locked() {
assert!(!is_locked(Path::new("/nonexistent/does/not/exist/xyz")));
}
}