Hermes-Setup.exe replaced install.ps1's .hermes-bootstrap-complete with its own copy: completedAtUnix instead of the completedAt every other writer and reader uses (install.ps1, Electron main, hermes_cli/source_stamp.py, scripts/verify-bootstrap-version-stamp.py), and a pinnedCommit from a bare `git rev-parse HEAD`, which is null on a machine where git exists only as the PM-staged binary install.ps1 uses. The receipt now carries completedAt (UTC ISO-8601, milliseconds), and the commit resolves like install.ps1's Stage-Complete: the pinned commit, else the checkout HEAD read from its ref files (loose ref or packed-refs), else the full sha install.ps1 already recorded (read BOM-tolerant). Fixes #124949
1520 lines
54 KiB
Rust
1520 lines
54 KiB
Rust
//! Bootstrap orchestration.
|
|
//!
|
|
//! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.ts`.
|
|
//! Drives install.ps1 / install.sh stage-by-stage, emits progress events
|
|
//! over the Tauri `bootstrap` channel, writes a forensic log to
|
|
//! HERMES_HOME/logs/bootstrap-<timestamp>.log.
|
|
//!
|
|
//! Lifecycle:
|
|
//! 1. `start_bootstrap` (Tauri command) → spawns the worker task.
|
|
//! 2. Worker resolves install script (dev/cache/download).
|
|
//! 3. Worker calls `install.ps1 -Manifest` → emits `manifest` event.
|
|
//! 4. Worker iterates stages, calling `install.ps1 -Stage NAME -NonInteractive -Json`.
|
|
//! 5. On success → `complete`. On any stage failure → `failed`. On cancel → `failed`.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Stdio;
|
|
use std::sync::Arc;
|
|
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
|
|
|
use anyhow::{anyhow, Context, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
use tauri::{AppHandle, Emitter, State};
|
|
use tokio::sync::{mpsc, Mutex};
|
|
|
|
use crate::events::{BootstrapEvent, LogStream, Manifest, StageState};
|
|
use crate::install_script::{self, Pin, ScriptKind, ScriptSource};
|
|
use crate::powershell::{self, StreamSink};
|
|
use crate::AppState;
|
|
|
|
const MAX_STAGE_ATTEMPTS: usize = 3;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public Tauri commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Frontend → Rust: kick off the install.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct StartBootstrapArgs {
|
|
/// Optional override for the commit pin. Defaults to the build-time
|
|
/// pin baked in via `BUILD_PIN_COMMIT`.
|
|
pub commit: Option<String>,
|
|
/// Optional override for the branch pin. Defaults to `BUILD_PIN_BRANCH`.
|
|
pub branch: Option<String>,
|
|
/// Include Stage-Desktop (build apps/desktop) in the manifest. The
|
|
/// signed bootstrap installer passes true; the deprecated Electron-side
|
|
/// bootstrap-runner passes false to avoid building-while-running.
|
|
#[serde(default = "default_true")]
|
|
pub include_desktop: bool,
|
|
/// Optional override for HERMES_HOME. Tests use this; production
|
|
/// almost always falls back to the OS default.
|
|
pub hermes_home: Option<String>,
|
|
}
|
|
|
|
fn default_true() -> bool {
|
|
true
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct BootstrapStatus {
|
|
pub running: bool,
|
|
pub completed: bool,
|
|
pub install_root: Option<String>,
|
|
pub last_error: Option<String>,
|
|
}
|
|
|
|
/// Handle stored in AppState while a bootstrap run is in flight. Carries
|
|
/// the cancellation channel and the most recent terminal status so the
|
|
/// frontend can re-query after a window refresh.
|
|
pub struct BootstrapHandle {
|
|
pub cancel_tx: mpsc::Sender<()>,
|
|
pub started_at: Instant,
|
|
pub status: BootstrapStatus,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn start_bootstrap(
|
|
app: AppHandle,
|
|
state: State<'_, Arc<AppState>>,
|
|
args: StartBootstrapArgs,
|
|
) -> Result<(), String> {
|
|
let mut guard = state.bootstrap.lock().await;
|
|
if let Some(h) = guard.as_ref() {
|
|
if h.status.running {
|
|
return Err("Bootstrap is already running".into());
|
|
}
|
|
}
|
|
|
|
let (cancel_tx, cancel_rx) = mpsc::channel::<()>(1);
|
|
let handle = BootstrapHandle {
|
|
cancel_tx,
|
|
started_at: Instant::now(),
|
|
status: BootstrapStatus {
|
|
running: true,
|
|
completed: false,
|
|
install_root: None,
|
|
last_error: None,
|
|
},
|
|
};
|
|
*guard = Some(handle);
|
|
drop(guard);
|
|
|
|
let app_for_task = app.clone();
|
|
let state_for_task = state.inner().clone();
|
|
let args_for_task = args;
|
|
let cancel_rx = Arc::new(Mutex::new(Some(cancel_rx)));
|
|
|
|
tokio::spawn(async move {
|
|
let result = run_bootstrap(app_for_task.clone(), args_for_task, cancel_rx).await;
|
|
|
|
// Reflect terminal state into AppState so get_bootstrap_status()
|
|
// can serve it after the task exits.
|
|
let mut guard = state_for_task.bootstrap.lock().await;
|
|
if let Some(h) = guard.as_mut() {
|
|
h.status.running = false;
|
|
match &result {
|
|
Ok(install_root) => {
|
|
h.status.completed = true;
|
|
h.status.install_root = Some(install_root.clone());
|
|
h.status.last_error = None;
|
|
}
|
|
Err(err) => {
|
|
h.status.completed = false;
|
|
h.status.last_error = Some(err.to_string());
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn cancel_bootstrap(state: State<'_, Arc<AppState>>) -> Result<(), String> {
|
|
let guard = state.bootstrap.lock().await;
|
|
if let Some(h) = guard.as_ref() {
|
|
let _ = h.cancel_tx.try_send(());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_bootstrap_status(
|
|
state: State<'_, Arc<AppState>>,
|
|
) -> Result<BootstrapStatus, String> {
|
|
let guard = state.bootstrap.lock().await;
|
|
Ok(match guard.as_ref() {
|
|
Some(h) => BootstrapStatus {
|
|
running: h.status.running,
|
|
completed: h.status.completed,
|
|
install_root: h.status.install_root.clone(),
|
|
last_error: h.status.last_error.clone(),
|
|
},
|
|
None => BootstrapStatus {
|
|
running: false,
|
|
completed: false,
|
|
install_root: None,
|
|
last_error: None,
|
|
},
|
|
})
|
|
}
|
|
|
|
/// Spawn the locally-built Hermes desktop binary, then close the installer
|
|
/// window. Caller resolves the binary path from `install_root`.
|
|
///
|
|
/// Returns Err with a human-readable message if the binary doesn't exist
|
|
/// (e.g. when Stage-Desktop was skipped) so the frontend can present
|
|
/// actionable failure UI rather than silently doing nothing.
|
|
#[tauri::command]
|
|
pub async fn launch_hermes_desktop(
|
|
app: AppHandle,
|
|
install_root: String,
|
|
) -> Result<(), String> {
|
|
let install_root = PathBuf::from(install_root);
|
|
let exe_path = resolve_hermes_desktop_exe(&install_root).ok_or_else(|| {
|
|
format!(
|
|
"Couldn't find a built Hermes desktop at {}. The desktop build step \
|
|
may have been skipped or failed. Run `hermes desktop` from a \
|
|
terminal to build and launch it.",
|
|
install_root.join("apps").join("desktop").join("release").display()
|
|
)
|
|
})?;
|
|
|
|
tracing::info!(?exe_path, "launching Hermes desktop");
|
|
|
|
// Detach from us — the installer is about to exit. On macOS launch the
|
|
// bundle through LaunchServices instead of exec'ing Contents/MacOS/Hermes
|
|
// directly; this matches user double-click/open behavior and avoids cwd /
|
|
// quarantine oddities after a self-update rebuild.
|
|
let mut cmd = desktop_launch_command(&exe_path, &install_root);
|
|
|
|
spawn_detached_desktop(cmd.as_std_mut()).map_err(|e| {
|
|
format!(
|
|
"failed to launch {}: {e}",
|
|
exe_path.display()
|
|
)
|
|
})?;
|
|
|
|
// Give Windows ~150ms to actually start the new process before we exit.
|
|
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
|
|
|
// Exit the installer cleanly. Tauri's process plugin gives us the
|
|
// right hook regardless of platform.
|
|
app.exit(0);
|
|
Ok(())
|
|
}
|
|
|
|
/// Walks the well-known electron-builder unpacked-app paths under
|
|
/// `install_root`. Mirrors the resolver in `cmd_gui` (apps/desktop/release/
|
|
/// <os>-unpacked/<exe>).
|
|
pub(crate) fn resolve_hermes_desktop_exe(install_root: &std::path::Path) -> Option<PathBuf> {
|
|
let release_dir = install_root.join("apps").join("desktop").join("release");
|
|
let candidates: &[(&str, &str)] = if cfg!(target_os = "windows") {
|
|
&[
|
|
("win-unpacked", "Hermes.exe"),
|
|
("win-arm64-unpacked", "Hermes.exe"),
|
|
]
|
|
} else if cfg!(target_os = "macos") {
|
|
&[
|
|
("mac/Hermes.app/Contents/MacOS", "Hermes"),
|
|
("mac-arm64/Hermes.app/Contents/MacOS", "Hermes"),
|
|
]
|
|
} else {
|
|
// electron-builder names the x64 dir `linux-unpacked` and every other
|
|
// arch `linux-<arch>-unpacked` (#94703).
|
|
&[("linux-unpacked", "hermes"), ("linux-arm64-unpacked", "hermes")]
|
|
};
|
|
for (subdir, exe) in candidates {
|
|
let p = release_dir.join(subdir).join(exe);
|
|
if p.exists() {
|
|
return Some(p);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub(crate) fn resolve_hermes_desktop_app(install_root: &std::path::Path) -> Option<PathBuf> {
|
|
let exe = resolve_hermes_desktop_exe(install_root)?;
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
// .../Hermes.app/Contents/MacOS/Hermes -> .../Hermes.app
|
|
let app = exe.parent()?.parent()?.parent()?.to_path_buf();
|
|
if app.extension().and_then(|e| e.to_str()) == Some("app") && app.is_dir() {
|
|
return Some(app);
|
|
}
|
|
}
|
|
#[cfg(not(target_os = "macos"))]
|
|
{
|
|
return Some(exe);
|
|
}
|
|
#[allow(unreachable_code)]
|
|
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()
|
|
}
|
|
|
|
fn is_full_sha(value: &str) -> bool {
|
|
value.len() == 40
|
|
&& value
|
|
.bytes()
|
|
.all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
|
|
}
|
|
|
|
/// HEAD of the checkout read from its ref files. install.ps1 resolves git
|
|
/// through the PM-staged binary, which is not on PATH, so a bare `git` spawn
|
|
/// here returns nothing on a git-less machine.
|
|
fn read_checkout_head(install_root: &Path) -> Option<String> {
|
|
let git_dir = install_root.join(".git");
|
|
let head = std::fs::read_to_string(git_dir.join("HEAD")).ok()?;
|
|
let head = head.trim();
|
|
let sha = match head.strip_prefix("ref: ") {
|
|
None => head.to_string(),
|
|
Some(name) => match std::fs::read_to_string(git_dir.join(name)) {
|
|
Ok(loose) => loose.trim().to_string(),
|
|
Err(_) => std::fs::read_to_string(git_dir.join("packed-refs"))
|
|
.ok()?
|
|
.lines()
|
|
.find_map(|line| {
|
|
let (sha, ref_name) = line.split_once(' ')?;
|
|
(ref_name == name).then(|| sha.to_string())
|
|
})?,
|
|
},
|
|
};
|
|
is_full_sha(&sha).then_some(sha)
|
|
}
|
|
|
|
/// The receipt install.ps1's Stage-Complete published moments earlier in this
|
|
/// run. Windows PowerShell writes it with a UTF-8 BOM.
|
|
fn read_existing_marker_commit(marker_path: &Path) -> Option<String> {
|
|
let raw = std::fs::read(marker_path).ok()?;
|
|
let body = raw.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(&raw);
|
|
let marker: serde_json::Value = serde_json::from_slice(body).ok()?;
|
|
let commit = marker.get("pinnedCommit")?.as_str()?;
|
|
is_full_sha(commit).then(|| commit.to_string())
|
|
}
|
|
|
|
/// Same order as install.ps1's Stage-Complete: the pinned commit, else the
|
|
/// checkout's HEAD; the prior receipt is the last resort.
|
|
fn resolve_marker_commit(install_root: &Path, pin: &Pin, marker_path: &Path) -> Option<String> {
|
|
pin.commit
|
|
.clone()
|
|
.filter(|commit| !commit.trim().is_empty())
|
|
.or_else(|| read_checkout_head(install_root))
|
|
.or_else(|| read_existing_marker_commit(marker_path))
|
|
}
|
|
|
|
/// UTC ISO-8601 with milliseconds, the `completedAt` format install.ps1,
|
|
/// Electron (`toISOString`) and hermes_cli/source_stamp.py all write.
|
|
fn iso8601_utc(since_epoch: std::time::Duration) -> String {
|
|
let secs = since_epoch.as_secs();
|
|
let (days, rem) = ((secs / 86_400) as i64, secs % 86_400);
|
|
// Days-to-civil conversion (proleptic Gregorian), valid for any post-1970 date.
|
|
let z = days + 719_468;
|
|
let era = z.div_euclid(146_097);
|
|
let doe = z.rem_euclid(146_097);
|
|
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
|
let mp = (5 * doy + 2) / 153;
|
|
let day = doy - (153 * mp + 2) / 5 + 1;
|
|
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
|
let year = yoe + era * 400 + i64::from(month <= 2);
|
|
format!(
|
|
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{:03}Z",
|
|
rem / 3_600,
|
|
rem % 3_600 / 60,
|
|
rem % 60,
|
|
since_epoch.subsec_millis()
|
|
)
|
|
}
|
|
|
|
fn write_bootstrap_complete_marker(install_root: &Path, pin: &Pin) -> Result<serde_json::Value> {
|
|
use std::io::Write;
|
|
|
|
let marker_path = crate::paths::likely_bootstrap_marker(install_root);
|
|
if let Some(parent) = marker_path.parent() {
|
|
std::fs::create_dir_all(parent).with_context(|| {
|
|
format!(
|
|
"could not create bootstrap marker directory {}",
|
|
parent.display()
|
|
)
|
|
})?;
|
|
}
|
|
|
|
let completed_at = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default();
|
|
let marker = serde_json::json!({
|
|
"schemaVersion": 1,
|
|
"pinnedCommit": resolve_marker_commit(install_root, pin, &marker_path),
|
|
"pinnedBranch": pin.branch.clone(),
|
|
"completedAt": iso8601_utc(completed_at),
|
|
});
|
|
let mut body = serde_json::to_vec_pretty(&marker)?;
|
|
body.push(b'\n');
|
|
|
|
// Atomic publish (temp sibling + flush + rename), matching Electron's
|
|
// writeFileAtomic(). hermes_is_installed() only checks existence, so a
|
|
// partial direct write would incorrectly enable the launcher fast path.
|
|
let tmp_path = install_root.join(".hermes-bootstrap-complete.tmp");
|
|
{
|
|
let mut file = std::fs::File::create(&tmp_path).with_context(|| {
|
|
format!(
|
|
"could not create temp bootstrap marker {}",
|
|
tmp_path.display()
|
|
)
|
|
})?;
|
|
file.write_all(&body).with_context(|| {
|
|
format!(
|
|
"could not write temp bootstrap marker {}",
|
|
tmp_path.display()
|
|
)
|
|
})?;
|
|
file.sync_all().with_context(|| {
|
|
format!(
|
|
"could not flush temp bootstrap marker {}",
|
|
tmp_path.display()
|
|
)
|
|
})?;
|
|
}
|
|
// Windows rename fails if the destination already exists; drop any prior
|
|
// marker first so a re-run can still publish a fresh payload.
|
|
if marker_path.exists() {
|
|
std::fs::remove_file(&marker_path).with_context(|| {
|
|
format!(
|
|
"could not replace existing bootstrap marker {}",
|
|
marker_path.display()
|
|
)
|
|
})?;
|
|
}
|
|
if let Err(err) = std::fs::rename(&tmp_path, &marker_path) {
|
|
let _ = std::fs::remove_file(&tmp_path);
|
|
return Err(err).with_context(|| {
|
|
format!(
|
|
"could not publish bootstrap marker {} → {}",
|
|
tmp_path.display(),
|
|
marker_path.display()
|
|
)
|
|
});
|
|
}
|
|
|
|
tracing::info!(path = %marker_path.display(), "bootstrap marker written");
|
|
Ok(marker)
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn detach_inheritable_std_handles() {
|
|
use windows_sys::Win32::Foundation::{
|
|
SetHandleInformation, HANDLE_FLAG_INHERIT, INVALID_HANDLE_VALUE,
|
|
};
|
|
use windows_sys::Win32::System::Console::{
|
|
GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE,
|
|
};
|
|
|
|
for (kind, name) in [
|
|
(STD_INPUT_HANDLE, "STD_INPUT_HANDLE"),
|
|
(STD_OUTPUT_HANDLE, "STD_OUTPUT_HANDLE"),
|
|
(STD_ERROR_HANDLE, "STD_ERROR_HANDLE"),
|
|
] {
|
|
// SAFETY: GetStdHandle has no preconditions; the second call receives its validated result.
|
|
let result = unsafe {
|
|
let h = GetStdHandle(kind);
|
|
if h.is_null() || h == INVALID_HANDLE_VALUE {
|
|
continue;
|
|
}
|
|
SetHandleInformation(h, HANDLE_FLAG_INHERIT, 0)
|
|
};
|
|
if result == 0 {
|
|
tracing::warn!(std_handle = name, "could not detach inheritable standard handle");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn spawn_detached_desktop(cmd: &mut std::process::Command) -> std::io::Result<std::process::Child> {
|
|
#[cfg(windows)]
|
|
{
|
|
use std::os::windows::process::CommandExt;
|
|
|
|
// The installer's stdout/stderr may be pipes the user's shell is reading.
|
|
// Windows duplicates every inheritable handle into the child regardless of
|
|
// its stdio (rust-lang/rust#54760), so clear the flags immediately before
|
|
// this handoff; the installer exits moments later.
|
|
// DETACHED_PROCESS = 0x00000008
|
|
cmd.creation_flags(0x0000_0008);
|
|
detach_inheritable_std_handles();
|
|
}
|
|
|
|
cmd.spawn()
|
|
}
|
|
|
|
/// 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 = desktop_launch_command_std(&exe, install_root);
|
|
spawn_detached_desktop(&mut cmd).map(|_child| ())
|
|
}
|
|
|
|
// The installer exits right after launch, so the Desktop must not keep the
|
|
// installer's stdout/stderr open (#112856).
|
|
#[cfg(target_os = "macos")]
|
|
pub(crate) fn open_macos_app_detached(app_bundle: &std::path::Path) -> std::io::Result<()> {
|
|
let mut cmd = std::process::Command::new("/usr/bin/open");
|
|
cmd.arg(app_bundle);
|
|
cmd.current_dir(crate::paths::hermes_home());
|
|
cmd.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null());
|
|
cmd.spawn().map(|_child| ())
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn app_bundle_for_exe(exe: &std::path::Path) -> Option<PathBuf> {
|
|
let app = exe.parent()?.parent()?.parent()?.to_path_buf();
|
|
if app.extension().and_then(|e| e.to_str()) == Some("app") && app.is_dir() {
|
|
Some(app)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn desktop_launch_command(
|
|
exe_path: &std::path::Path,
|
|
install_root: &std::path::Path,
|
|
) -> tokio::process::Command {
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
if let Some(app_bundle) = app_bundle_for_exe(exe_path) {
|
|
let mut cmd = tokio::process::Command::new("/usr/bin/open");
|
|
cmd.arg(app_bundle);
|
|
cmd.current_dir(crate::paths::hermes_home());
|
|
cmd.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null());
|
|
return cmd;
|
|
}
|
|
}
|
|
|
|
let mut cmd = tokio::process::Command::new(exe_path);
|
|
cmd.current_dir(exe_path.parent().unwrap_or(install_root));
|
|
cmd.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null());
|
|
cmd
|
|
}
|
|
|
|
fn desktop_launch_command_std(
|
|
exe_path: &std::path::Path,
|
|
install_root: &std::path::Path,
|
|
) -> std::process::Command {
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
if let Some(app_bundle) = app_bundle_for_exe(exe_path) {
|
|
let mut cmd = std::process::Command::new("/usr/bin/open");
|
|
cmd.arg(app_bundle);
|
|
cmd.current_dir(crate::paths::hermes_home());
|
|
cmd.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null());
|
|
return cmd;
|
|
}
|
|
}
|
|
|
|
let mut cmd = std::process::Command::new(exe_path);
|
|
cmd.current_dir(exe_path.parent().unwrap_or(install_root));
|
|
cmd.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null());
|
|
cmd
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bootstrap implementation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async fn run_bootstrap(
|
|
app: AppHandle,
|
|
args: StartBootstrapArgs,
|
|
cancel_rx_holder: Arc<Mutex<Option<mpsc::Receiver<()>>>>,
|
|
) -> Result<String> {
|
|
let kind = ScriptKind::for_current_os();
|
|
|
|
let pin = Pin {
|
|
commit: args.commit.or_else(|| option_env_string("BUILD_PIN_COMMIT")),
|
|
branch: args.branch.or_else(|| option_env_string("BUILD_PIN_BRANCH")),
|
|
};
|
|
|
|
tracing::info!(
|
|
?pin,
|
|
kind = ?kind,
|
|
include_desktop = args.include_desktop,
|
|
"bootstrap starting"
|
|
);
|
|
|
|
let app_for_log = app.clone();
|
|
let emit_log = move |line: &str| {
|
|
emit_event(
|
|
&app_for_log,
|
|
BootstrapEvent::Log {
|
|
stage: None,
|
|
line: line.to_string(),
|
|
stream: LogStream::Stdout,
|
|
},
|
|
);
|
|
// Bump to info-level so the line shows in bootstrap-installer.log
|
|
// under the default INFO filter. Previously this was debug! which
|
|
// got dropped on the floor, leaving us blind whenever install.ps1
|
|
// failed — the log only had the "bootstrap starting" banner.
|
|
tracing::info!(target: "bootstrap.log", "{line}");
|
|
};
|
|
|
|
// 1. Resolve install.ps1
|
|
let script = install_script::resolve(kind, &pin, &emit_log)
|
|
.await
|
|
.map_err(|e| {
|
|
let msg = format!("resolve install script failed: {e:#}");
|
|
emit_event(
|
|
&app,
|
|
BootstrapEvent::Failed {
|
|
stage: None,
|
|
error: msg.clone(),
|
|
},
|
|
);
|
|
anyhow!(msg)
|
|
})?;
|
|
|
|
let source_note = match &script.source {
|
|
ScriptSource::DevCheckout => "dev checkout",
|
|
ScriptSource::Bundled => "bundled",
|
|
ScriptSource::Downloaded => "downloaded",
|
|
};
|
|
emit_log(&format!(
|
|
"[bootstrap] script {} via {}",
|
|
script.path.display(),
|
|
source_note
|
|
));
|
|
|
|
// 2. Fetch manifest
|
|
//
|
|
// -IncludeDesktop MUST be passed to the manifest call too — install.ps1
|
|
// gates the desktop stage inclusion on this flag, so without it here
|
|
// the manifest comes back missing the desktop stage and we never run
|
|
// it. The per-stage call below also passes -IncludeDesktop to keep
|
|
// the contracts identical.
|
|
let manifest_args = build_pin_args(&script);
|
|
let mut manifest_args_full = vec!["-Manifest".to_string()];
|
|
manifest_args_full.extend(manifest_args.clone());
|
|
if args.include_desktop {
|
|
manifest_args_full.push("-IncludeDesktop".to_string());
|
|
}
|
|
|
|
let mut manifest_cancel_rx = None;
|
|
let manifest_result = run_install_script(
|
|
&app,
|
|
&script.path,
|
|
&manifest_args_full,
|
|
args.hermes_home.as_deref(),
|
|
&mut manifest_cancel_rx,
|
|
Some("__manifest__".to_string()),
|
|
)
|
|
.await?;
|
|
|
|
if manifest_result.exit_code != Some(0) {
|
|
let err = format!(
|
|
"install.ps1 -Manifest failed: exit {:?}\n{}",
|
|
manifest_result.exit_code,
|
|
crate::events::strip_ansi(manifest_result.stderr.trim())
|
|
);
|
|
emit_event(
|
|
&app,
|
|
BootstrapEvent::Failed {
|
|
stage: None,
|
|
error: err.clone(),
|
|
},
|
|
);
|
|
return Err(anyhow!(err));
|
|
}
|
|
|
|
let manifest: Manifest = powershell::parse_manifest(&manifest_result.stdout).ok_or_else(|| {
|
|
let err = format!(
|
|
"install.ps1 -Manifest produced no parseable JSON payload\n{}",
|
|
truncate(&manifest_result.stdout, 4000)
|
|
);
|
|
emit_event(
|
|
&app,
|
|
BootstrapEvent::Failed {
|
|
stage: None,
|
|
error: err.clone(),
|
|
},
|
|
);
|
|
anyhow!(err)
|
|
})?;
|
|
|
|
emit_event(
|
|
&app,
|
|
BootstrapEvent::Manifest {
|
|
stages: manifest.stages.clone(),
|
|
protocol_version: manifest.protocol_version,
|
|
},
|
|
);
|
|
|
|
// 3. Iterate stages.
|
|
for stage in &manifest.stages {
|
|
// Skip Stage-Desktop unless explicitly requested. install.ps1 may
|
|
// or may not include it in the manifest depending on the flag we
|
|
// pass, but if it slipped in, gate client-side too.
|
|
if !args.include_desktop && stage.name.eq_ignore_ascii_case("desktop") {
|
|
emit_event(
|
|
&app,
|
|
BootstrapEvent::Stage {
|
|
name: stage.name.clone(),
|
|
state: StageState::Skipped,
|
|
duration_ms: Some(0),
|
|
result: None,
|
|
error: Some("skipped by include_desktop=false".into()),
|
|
},
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if cancellation_signalled(&cancel_rx_holder).await {
|
|
let err = "bootstrap cancelled by user".to_string();
|
|
emit_event(
|
|
&app,
|
|
BootstrapEvent::Failed {
|
|
stage: Some(stage.name.clone()),
|
|
error: err.clone(),
|
|
},
|
|
);
|
|
return Err(anyhow!(err));
|
|
}
|
|
|
|
let started = Instant::now();
|
|
emit_event(
|
|
&app,
|
|
BootstrapEvent::Stage {
|
|
name: stage.name.clone(),
|
|
state: StageState::Running,
|
|
duration_ms: None,
|
|
result: None,
|
|
error: None,
|
|
},
|
|
);
|
|
|
|
let mut stage_args = vec![
|
|
"-Stage".to_string(),
|
|
stage.name.clone(),
|
|
"-NonInteractive".to_string(),
|
|
"-Json".to_string(),
|
|
];
|
|
stage_args.extend(manifest_args.clone());
|
|
if args.include_desktop {
|
|
stage_args.push("-IncludeDesktop".to_string());
|
|
}
|
|
|
|
// A Windows PowerShell host can occasionally terminate with raw status
|
|
// 0xffffffff while a long-running native child (npm / Playwright) is
|
|
// still active. That bypasses install.ps1's finally block, so there is
|
|
// no JSON frame to distinguish success from failure. Stage workers are
|
|
// required to be idempotent; retry only this exact abrupt-host shape,
|
|
// and keep it bounded so ordinary script failures remain immediate.
|
|
let mut attempt = 1;
|
|
let mut local_cancel_rx = cancel_rx_holder.lock().await.take();
|
|
let (stage_result, result_frame) = loop {
|
|
let mut result = run_install_script(
|
|
&app,
|
|
&script.path,
|
|
&stage_args,
|
|
args.hermes_home.as_deref(),
|
|
&mut local_cancel_rx,
|
|
Some(stage.name.clone()),
|
|
)
|
|
.await?;
|
|
let frame = powershell::parse_stage_result(&result.stdout);
|
|
|
|
if should_retry_missing_stage_frame(result.exit_code, result.killed, attempt)
|
|
&& frame.is_none()
|
|
{
|
|
if retry_backoff_cancelled(local_cancel_rx.as_mut()).await {
|
|
result.killed = true;
|
|
break (result, frame);
|
|
}
|
|
attempt += 1;
|
|
let line = format!(
|
|
"[bootstrap] {} stage host exited unexpectedly before its JSON result; retrying ({attempt}/{MAX_STAGE_ATTEMPTS})",
|
|
stage.name
|
|
);
|
|
tracing::warn!(
|
|
stage = %stage.name,
|
|
exit = ?result.exit_code,
|
|
attempt,
|
|
"stage host exited without a result frame; retrying"
|
|
);
|
|
emit_event(
|
|
&app,
|
|
BootstrapEvent::Log {
|
|
stage: Some(stage.name.clone()),
|
|
line,
|
|
stream: LogStream::Stderr,
|
|
},
|
|
);
|
|
continue;
|
|
}
|
|
|
|
break (result, frame);
|
|
};
|
|
*cancel_rx_holder.lock().await = local_cancel_rx;
|
|
|
|
let duration_ms = started.elapsed().as_millis() as u64;
|
|
|
|
if stage_result.killed {
|
|
emit_event(
|
|
&app,
|
|
BootstrapEvent::Stage {
|
|
name: stage.name.clone(),
|
|
state: StageState::Failed,
|
|
duration_ms: Some(duration_ms),
|
|
result: None,
|
|
error: Some("cancelled by user".into()),
|
|
},
|
|
);
|
|
emit_event(
|
|
&app,
|
|
BootstrapEvent::Failed {
|
|
stage: Some(stage.name.clone()),
|
|
error: "cancelled by user".into(),
|
|
},
|
|
);
|
|
return Err(anyhow!("cancelled by user"));
|
|
}
|
|
|
|
match result_frame {
|
|
None => {
|
|
let err = format!(
|
|
"install.ps1 -Stage {} produced no JSON result frame (exit={:?})",
|
|
stage.name, stage_result.exit_code
|
|
);
|
|
emit_event(
|
|
&app,
|
|
BootstrapEvent::Stage {
|
|
name: stage.name.clone(),
|
|
state: StageState::Failed,
|
|
duration_ms: Some(duration_ms),
|
|
result: None,
|
|
error: Some(err.clone()),
|
|
},
|
|
);
|
|
emit_event(
|
|
&app,
|
|
BootstrapEvent::Failed {
|
|
stage: Some(stage.name.clone()),
|
|
error: err.clone(),
|
|
},
|
|
);
|
|
return Err(anyhow!(err));
|
|
}
|
|
Some(frame) if frame.ok && frame.skipped => {
|
|
emit_event(
|
|
&app,
|
|
BootstrapEvent::Stage {
|
|
name: stage.name.clone(),
|
|
state: StageState::Skipped,
|
|
duration_ms: Some(duration_ms),
|
|
result: Some(frame),
|
|
error: None,
|
|
},
|
|
);
|
|
}
|
|
Some(frame) if frame.ok => {
|
|
emit_event(
|
|
&app,
|
|
BootstrapEvent::Stage {
|
|
name: stage.name.clone(),
|
|
state: StageState::Succeeded,
|
|
duration_ms: Some(duration_ms),
|
|
result: Some(frame),
|
|
error: None,
|
|
},
|
|
);
|
|
}
|
|
Some(frame) => {
|
|
let err = frame
|
|
.reason
|
|
.clone()
|
|
.unwrap_or_else(|| format!("exit code {:?}", stage_result.exit_code));
|
|
emit_event(
|
|
&app,
|
|
BootstrapEvent::Stage {
|
|
name: stage.name.clone(),
|
|
state: StageState::Failed,
|
|
duration_ms: Some(duration_ms),
|
|
result: Some(frame),
|
|
error: Some(err.clone()),
|
|
},
|
|
);
|
|
emit_event(
|
|
&app,
|
|
BootstrapEvent::Failed {
|
|
stage: Some(stage.name.clone()),
|
|
error: err.clone(),
|
|
},
|
|
);
|
|
return Err(anyhow!(err));
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Resolve install_root. install.ps1 doesn't (yet) report this back
|
|
// explicitly; we infer it from $HermesHome which Stage-Repository clones
|
|
// the repo INTO at $HermesHome\hermes-agent. Mirrors hermes_constants.
|
|
let hermes_home = args
|
|
.hermes_home
|
|
.clone()
|
|
.unwrap_or_else(|| crate::paths::hermes_home().to_string_lossy().into_owned());
|
|
let install_root = PathBuf::from(&hermes_home).join("hermes-agent");
|
|
|
|
// Marker publish is terminal for this run: a write failure must emit Failed
|
|
// so the UI leaves the progress state (it does not poll get_bootstrap_status).
|
|
let marker = match write_bootstrap_complete_marker(&install_root, &pin) {
|
|
Ok(marker) => marker,
|
|
Err(err) => {
|
|
let msg = format!("write bootstrap marker failed: {err:#}");
|
|
emit_event(
|
|
&app,
|
|
BootstrapEvent::Failed {
|
|
stage: None,
|
|
error: msg.clone(),
|
|
},
|
|
);
|
|
return Err(anyhow!(msg));
|
|
}
|
|
};
|
|
|
|
// 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 {
|
|
install_root: install_root.to_string_lossy().into_owned(),
|
|
marker: Some(marker),
|
|
},
|
|
);
|
|
|
|
Ok(install_root.to_string_lossy().into_owned())
|
|
}
|
|
|
|
fn should_retry_missing_stage_frame(
|
|
exit_code: Option<i32>,
|
|
killed: bool,
|
|
attempt: usize,
|
|
) -> bool {
|
|
!killed && exit_code == Some(-1) && attempt < MAX_STAGE_ATTEMPTS
|
|
}
|
|
|
|
async fn retry_backoff_cancelled(cancel_rx: Option<&mut mpsc::Receiver<()>>) -> bool {
|
|
let backoff = tokio::time::sleep(std::time::Duration::from_millis(500));
|
|
tokio::pin!(backoff);
|
|
|
|
match cancel_rx {
|
|
Some(rx) => tokio::select! {
|
|
biased;
|
|
signal = rx.recv() => signal.is_some(),
|
|
_ = &mut backoff => false,
|
|
},
|
|
None => {
|
|
backoff.await;
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn cancellation_signalled(holder: &Arc<Mutex<Option<mpsc::Receiver<()>>>>) -> bool {
|
|
let mut guard = holder.lock().await;
|
|
if let Some(rx) = guard.as_mut() {
|
|
rx.try_recv().is_ok()
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
async fn run_install_script(
|
|
app: &AppHandle,
|
|
script_path: &std::path::Path,
|
|
args: &[String],
|
|
hermes_home_override: Option<&str>,
|
|
cancel_rx: &mut Option<mpsc::Receiver<()>>,
|
|
stage_name: Option<String>,
|
|
) -> Result<powershell::ScriptResult> {
|
|
let app_for_stdout = app.clone();
|
|
let stage_for_stdout = stage_name.clone();
|
|
let app_for_stderr = app.clone();
|
|
let stage_for_stderr = stage_name.clone();
|
|
let stage_for_stdout_log = stage_name.clone();
|
|
let stage_for_stderr_log = stage_name.clone();
|
|
|
|
let sink = StreamSink {
|
|
on_stdout_line: Box::new(move |line: &str| {
|
|
emit_event(
|
|
&app_for_stdout,
|
|
BootstrapEvent::Log {
|
|
stage: stage_for_stdout.clone(),
|
|
line: line.to_string(),
|
|
stream: LogStream::Stdout,
|
|
},
|
|
);
|
|
// Tee to the rolling installer log so we have a persistent
|
|
// record of every install.ps1 line. Without this, the only
|
|
// log evidence of a failure was the Tauri event stream —
|
|
// which gets discarded the moment the failure route mounts.
|
|
match &stage_for_stdout_log {
|
|
Some(name) => {
|
|
tracing::info!(target: "bootstrap.log", stage = %name, "{line}")
|
|
}
|
|
None => tracing::info!(target: "bootstrap.log", "{line}"),
|
|
}
|
|
}),
|
|
on_stderr_line: Box::new(move |line: &str| {
|
|
emit_event(
|
|
&app_for_stderr,
|
|
BootstrapEvent::Log {
|
|
stage: stage_for_stderr.clone(),
|
|
line: line.to_string(),
|
|
stream: LogStream::Stderr,
|
|
},
|
|
);
|
|
// stderr-level lines get warn! so they're visually distinct
|
|
// when scrolling through the log later.
|
|
match &stage_for_stderr_log {
|
|
Some(name) => {
|
|
tracing::warn!(target: "bootstrap.log", stage = %name, "stderr: {line}")
|
|
}
|
|
None => tracing::warn!(target: "bootstrap.log", "stderr: {line}"),
|
|
}
|
|
}),
|
|
};
|
|
|
|
powershell::run_script(script_path, args, sink, hermes_home_override, cancel_rx)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!(?e, "install script invocation failed");
|
|
anyhow!("install script invocation failed: {e:#}")
|
|
})
|
|
}
|
|
|
|
fn build_pin_args(script: &install_script::ResolvedScript) -> Vec<String> {
|
|
let mut out = Vec::new();
|
|
if let Some(c) = &script.commit {
|
|
out.push("-Commit".to_string());
|
|
out.push(c.clone());
|
|
}
|
|
if let Some(b) = &script.branch {
|
|
out.push("-Branch".to_string());
|
|
out.push(b.clone());
|
|
}
|
|
out
|
|
}
|
|
|
|
fn emit_event(app: &AppHandle, event: BootstrapEvent) {
|
|
// The webview shows log lines as plain text, so ANSI styling/cursor
|
|
// bytes from install.sh must not cross the event boundary (#112675).
|
|
// The disk tee keeps the raw bytes — only the UI payload is sanitized.
|
|
let event = event.sanitized_for_ui();
|
|
// Tee important state transitions to the rolling installer log so
|
|
// bootstrap-installer.log isn't just "starting" + final summary.
|
|
// Log lines (the noisy stuff) handle their own tracing in
|
|
// run_install_script's sink; here we cover the lifecycle frames.
|
|
match &event {
|
|
BootstrapEvent::Manifest { stages, .. } => {
|
|
tracing::info!(
|
|
stage_count = stages.len(),
|
|
names = ?stages.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
|
|
"manifest received"
|
|
);
|
|
}
|
|
BootstrapEvent::Stage {
|
|
name,
|
|
state,
|
|
duration_ms,
|
|
error,
|
|
..
|
|
} => {
|
|
tracing::info!(
|
|
stage = %name,
|
|
?state,
|
|
duration_ms = ?duration_ms,
|
|
error = ?error,
|
|
"stage transition"
|
|
);
|
|
}
|
|
BootstrapEvent::Complete { install_root, .. } => {
|
|
tracing::info!(install_root = %install_root, "bootstrap complete");
|
|
}
|
|
BootstrapEvent::Failed { stage, error } => {
|
|
tracing::error!(stage = ?stage, error = %error, "bootstrap FAILED");
|
|
}
|
|
BootstrapEvent::Log { .. } => {
|
|
// Log lines are teed via the sink callbacks in
|
|
// run_install_script — don't double-emit here.
|
|
}
|
|
}
|
|
if let Err(e) = app.emit(BootstrapEvent::CHANNEL, &event) {
|
|
tracing::warn!(?e, "failed to emit bootstrap event");
|
|
}
|
|
}
|
|
|
|
fn option_env_string(key: &str) -> Option<String> {
|
|
// option_env! only accepts literals, so we hardcode the known keys.
|
|
let val = match key {
|
|
"BUILD_PIN_COMMIT" => option_env!("BUILD_PIN_COMMIT"),
|
|
"BUILD_PIN_BRANCH" => option_env!("BUILD_PIN_BRANCH"),
|
|
_ => None,
|
|
};
|
|
val.map(|s| s.to_string())
|
|
}
|
|
|
|
fn truncate(s: &str, max: usize) -> String {
|
|
if s.len() <= max {
|
|
s.to_string()
|
|
} else {
|
|
format!("{}...", &s[..max])
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
#[cfg(windows)]
|
|
use std::io::{Read, Write};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
#[cfg(windows)]
|
|
const STDIO_HELPER_ENV: &str = "HERMES_BOOTSTRAP_STDIO_HELPER";
|
|
#[cfg(windows)]
|
|
const STDIO_SLEEPER_ENV: &str = "HERMES_BOOTSTRAP_STDIO_SLEEPER";
|
|
#[cfg(windows)]
|
|
const STDIO_HELPER_TEST: &str = "bootstrap::tests::stdio_helper_launch";
|
|
#[cfg(windows)]
|
|
const STDIO_SLEEPER_TEST: &str = "bootstrap::tests::stdio_sleeper";
|
|
#[cfg(windows)]
|
|
const STDIO_SENTINEL: &str = "helper-launched";
|
|
|
|
fn unique_tmp_dir(tag: &str) -> PathBuf {
|
|
let base = std::env::temp_dir().join(format!(
|
|
"hermes-bootstrap-test-{tag}-{}-{}",
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos()
|
|
));
|
|
std::fs::create_dir_all(&base).unwrap();
|
|
base
|
|
}
|
|
|
|
// Build a fake built-desktop release tree at the platform's expected path
|
|
// and return (install_root, expected_app_bundle_or_exe).
|
|
fn make_release_tree(install_root: &Path) -> PathBuf {
|
|
let release = install_root.join("apps").join("desktop").join("release");
|
|
if cfg!(target_os = "macos") {
|
|
let macos_dir = release
|
|
.join("mac-arm64")
|
|
.join("Hermes.app")
|
|
.join("Contents")
|
|
.join("MacOS");
|
|
std::fs::create_dir_all(&macos_dir).unwrap();
|
|
std::fs::write(macos_dir.join("Hermes"), b"#!/bin/sh\n").unwrap();
|
|
macos_dir.parent().unwrap().parent().unwrap().to_path_buf() // .../Hermes.app
|
|
} else if cfg!(target_os = "windows") {
|
|
let dir = release.join("win-unpacked");
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let exe = dir.join("Hermes.exe");
|
|
std::fs::write(&exe, b"stub").unwrap();
|
|
exe
|
|
} else {
|
|
let dir = release.join("linux-unpacked");
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let exe = dir.join("hermes");
|
|
std::fs::write(&exe, b"stub").unwrap();
|
|
exe
|
|
}
|
|
}
|
|
|
|
// The relaunch / install target is derived from the rebuilt desktop app.
|
|
// On macOS this MUST resolve to the .app bundle (what `open` relaunches and
|
|
// what the updater ditto's over /Applications/Hermes.app). A regression in
|
|
// this derivation breaks the post-update auto-relaunch, so guard it.
|
|
#[test]
|
|
fn resolve_hermes_desktop_app_finds_built_bundle() {
|
|
let root = unique_tmp_dir("app-ok");
|
|
let expected = make_release_tree(&root);
|
|
|
|
let resolved = resolve_hermes_desktop_app(&root)
|
|
.expect("should resolve the freshly-built desktop app");
|
|
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
assert_eq!(resolved, expected, "must resolve to the .app bundle");
|
|
assert_eq!(
|
|
resolved.extension().and_then(|e| e.to_str()),
|
|
Some("app"),
|
|
"relaunch target must be a .app bundle on macOS"
|
|
);
|
|
}
|
|
#[cfg(not(target_os = "macos"))]
|
|
{
|
|
assert_eq!(resolved, expected);
|
|
}
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
}
|
|
|
|
// electron-builder writes ARM64 Linux builds to `linux-arm64-unpacked`; only
|
|
// x64 uses the bare `linux-unpacked` name (#94703).
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn resolve_hermes_desktop_exe_finds_arm64_linux_build() {
|
|
let root = unique_tmp_dir("app-linux-arm64");
|
|
let dir = root.join("apps/desktop/release/linux-arm64-unpacked");
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let exe = dir.join("hermes");
|
|
std::fs::write(&exe, b"stub").unwrap();
|
|
|
|
assert_eq!(resolve_hermes_desktop_exe(&root), Some(exe));
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_hermes_desktop_app_is_none_without_a_build() {
|
|
let root = unique_tmp_dir("app-none");
|
|
// No release tree created.
|
|
assert!(
|
|
resolve_hermes_desktop_app(&root).is_none(),
|
|
"no resolved app when nothing has been built"
|
|
);
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[test]
|
|
fn bootstrap_complete_marker_uses_desktop_compatible_schema() {
|
|
let root = unique_tmp_dir("marker-schema");
|
|
let pin = Pin {
|
|
commit: Some("abcdef1234567890".to_string()),
|
|
branch: Some("main".to_string()),
|
|
};
|
|
|
|
let marker =
|
|
write_bootstrap_complete_marker(&root, &pin).expect("marker write should succeed");
|
|
let marker_path = root.join(".hermes-bootstrap-complete");
|
|
let from_disk: serde_json::Value =
|
|
serde_json::from_slice(&std::fs::read(&marker_path).unwrap()).unwrap();
|
|
|
|
assert_eq!(marker, from_disk);
|
|
assert_eq!(from_disk["schemaVersion"], 1);
|
|
assert_eq!(from_disk["pinnedCommit"], "abcdef1234567890");
|
|
assert_eq!(from_disk["pinnedBranch"], "main");
|
|
assert!(
|
|
from_disk.get("completedAtUnix").is_none(),
|
|
"receipt must use the shared completedAt field"
|
|
);
|
|
assert_eq!(
|
|
iso8601_utc(std::time::Duration::from_millis(1_709_251_199_123)),
|
|
"2024-02-29T23:59:59.123Z"
|
|
);
|
|
let completed_at = from_disk["completedAt"].as_str().unwrap();
|
|
assert!(
|
|
completed_at.len() == 24 && completed_at.ends_with('Z'),
|
|
"completedAt must be ISO-8601 UTC: {completed_at}"
|
|
);
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[test]
|
|
fn branch_pin_marker_resolves_commit_from_checkout_without_git() {
|
|
// A branch pin has no commit. The checkout's refs name it; the dir is
|
|
// not a real repo, so any `git` spawn cannot resolve HEAD here.
|
|
let root = unique_tmp_dir("marker-branch-pin");
|
|
let head = "0123456789abcdef0123456789abcdef01234567";
|
|
std::fs::create_dir_all(root.join(".git")).unwrap();
|
|
std::fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
|
|
std::fs::write(
|
|
root.join(".git/packed-refs"),
|
|
format!("# pack-refs with: peeled fully-peeled sorted\n{head} refs/heads/main\n"),
|
|
)
|
|
.unwrap();
|
|
let pin = Pin {
|
|
commit: None,
|
|
branch: Some("main".to_string()),
|
|
};
|
|
|
|
let marker = write_bootstrap_complete_marker(&root, &pin).unwrap();
|
|
assert_eq!(marker["pinnedCommit"], head);
|
|
|
|
// Without readable refs, the commit install.ps1 already recorded
|
|
// (UTF-8 BOM, as Windows PowerShell writes it) survives the rewrite.
|
|
std::fs::remove_dir_all(root.join(".git")).unwrap();
|
|
let receipt = format!(
|
|
"\u{feff}{{\"schemaVersion\":1,\"pinnedCommit\":\"{head}\",\"pinnedBranch\":\"main\",\"completedAt\":\"2026-01-01T00:00:00.000Z\"}}"
|
|
);
|
|
std::fs::write(root.join(".hermes-bootstrap-complete"), receipt).unwrap();
|
|
let marker = write_bootstrap_complete_marker(&root, &pin).unwrap();
|
|
assert_eq!(marker["pinnedCommit"], head);
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[test]
|
|
fn bootstrap_complete_marker_is_published_atomically() {
|
|
let root = unique_tmp_dir("marker-atomic");
|
|
make_release_tree(&root);
|
|
let pin = Pin {
|
|
commit: Some("abcdef1234567890".to_string()),
|
|
branch: Some("main".to_string()),
|
|
};
|
|
|
|
write_bootstrap_complete_marker(&root, &pin).expect("marker write should succeed");
|
|
|
|
let marker_path = root.join(".hermes-bootstrap-complete");
|
|
let tmp_path = root.join(".hermes-bootstrap-complete.tmp");
|
|
assert!(
|
|
marker_path.is_file(),
|
|
"final marker must exist after atomic publish"
|
|
);
|
|
assert!(
|
|
!tmp_path.exists(),
|
|
"temp sibling must not remain after atomic publish"
|
|
);
|
|
assert!(
|
|
hermes_is_installed(&root),
|
|
"atomically published marker must enable the installer fast path"
|
|
);
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[test]
|
|
fn hermes_is_installed_treats_marker_existence_as_sufficient() {
|
|
// Documents why write_bootstrap_complete_marker must publish atomically:
|
|
// the launcher predicate only checks existence, so a partial/corrupt
|
|
// final marker would still enable the fast path.
|
|
let root = unique_tmp_dir("marker-existence-only");
|
|
make_release_tree(&root);
|
|
std::fs::write(root.join(".hermes-bootstrap-complete"), b"").unwrap();
|
|
|
|
assert!(
|
|
hermes_is_installed(&root),
|
|
"empty/partial marker content still counts as installed"
|
|
);
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[test]
|
|
fn marker_write_failure_leaves_no_final_marker() {
|
|
// install_root is a regular file → create_dir_all on its path fails
|
|
// before any marker bytes are published under the final name.
|
|
let base = unique_tmp_dir("marker-fail");
|
|
let not_a_dir = base.join("not-a-dir");
|
|
std::fs::write(¬_a_dir, b"not a directory").unwrap();
|
|
let pin = Pin {
|
|
commit: Some("abcdef1234567890".to_string()),
|
|
branch: Some("main".to_string()),
|
|
};
|
|
|
|
let err = write_bootstrap_complete_marker(¬_a_dir, &pin)
|
|
.expect_err("marker write against a non-directory root must fail");
|
|
let msg = format!("{err:#}");
|
|
assert!(
|
|
msg.contains("bootstrap marker"),
|
|
"error should mention the marker path: {msg}"
|
|
);
|
|
assert!(
|
|
!not_a_dir.join(".hermes-bootstrap-complete").exists(),
|
|
"failed write must not leave a final marker that enables the fast path"
|
|
);
|
|
assert!(
|
|
!not_a_dir.join(".hermes-bootstrap-complete.tmp").exists(),
|
|
"failed write must not leave a temp marker sibling either"
|
|
);
|
|
let _ = std::fs::remove_dir_all(&base);
|
|
}
|
|
|
|
#[test]
|
|
fn abrupt_windows_stage_exit_is_retried_but_never_forever() {
|
|
assert!(should_retry_missing_stage_frame(Some(-1), false, 1));
|
|
assert!(should_retry_missing_stage_frame(Some(-1), false, 2));
|
|
assert!(
|
|
!should_retry_missing_stage_frame(Some(-1), false, MAX_STAGE_ATTEMPTS),
|
|
"the retry policy must stay bounded"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ordinary_failure_or_cancellation_is_not_retried_without_a_frame() {
|
|
assert!(!should_retry_missing_stage_frame(Some(1), false, 1));
|
|
assert!(!should_retry_missing_stage_frame(Some(0), false, 1));
|
|
assert!(!should_retry_missing_stage_frame(None, false, 1));
|
|
assert!(!should_retry_missing_stage_frame(Some(-1), true, 1));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cancellation_during_retry_backoff_stops_the_retry() {
|
|
let (tx, mut rx) = mpsc::channel(1);
|
|
tx.send(()).await.unwrap();
|
|
|
|
assert!(retry_backoff_cancelled(Some(&mut rx)).await);
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn stdio_helper_launch() {
|
|
let mode = match std::env::var(STDIO_HELPER_ENV) {
|
|
Ok(mode) => mode,
|
|
Err(_) => return,
|
|
};
|
|
let (builder, stream) = mode
|
|
.split_once(':')
|
|
.expect("stdio helper mode must be <builder>:<stream>");
|
|
assert!(
|
|
matches!(stream, "stdout" | "stderr"),
|
|
"unknown stdio helper stream: {stream}"
|
|
);
|
|
|
|
let exe_path = std::env::current_exe().expect("resolve current test executable");
|
|
let install_root = unique_tmp_dir("stdio-helper");
|
|
let child = match builder {
|
|
"std" => {
|
|
let mut command = desktop_launch_command_std(&exe_path, &install_root);
|
|
command
|
|
.args(["--exact", STDIO_SLEEPER_TEST, "--nocapture"])
|
|
.env(STDIO_HELPER_ENV, &mode)
|
|
.env(STDIO_SLEEPER_ENV, "1");
|
|
spawn_detached_desktop(&mut command)
|
|
}
|
|
"tokio" => {
|
|
let mut command = desktop_launch_command(&exe_path, &install_root);
|
|
command
|
|
.args(["--exact", STDIO_SLEEPER_TEST, "--nocapture"])
|
|
.env(STDIO_HELPER_ENV, &mode)
|
|
.env(STDIO_SLEEPER_ENV, "1");
|
|
spawn_detached_desktop(command.as_std_mut())
|
|
}
|
|
_ => panic!("unknown stdio helper builder: {builder}"),
|
|
}
|
|
.expect("spawn detached stdio sleeper");
|
|
drop(child);
|
|
let _ = std::fs::remove_dir_all(&install_root);
|
|
|
|
match stream {
|
|
"stdout" => {
|
|
println!("{STDIO_SENTINEL}");
|
|
std::io::stdout()
|
|
.flush()
|
|
.expect("flush stdio helper stdout");
|
|
}
|
|
"stderr" => {
|
|
eprintln!("{STDIO_SENTINEL}");
|
|
std::io::stderr()
|
|
.flush()
|
|
.expect("flush stdio helper stderr");
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn stdio_sleeper() {
|
|
if matches!(
|
|
std::env::var(STDIO_SLEEPER_ENV).as_deref(),
|
|
Ok("1")
|
|
) {
|
|
std::thread::sleep(std::time::Duration::from_secs(4));
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn assert_desktop_launch_pipe_closes(builder: &str, stream: &str) {
|
|
let mode = format!("{builder}:{stream}");
|
|
let mut command =
|
|
std::process::Command::new(std::env::current_exe().expect("resolve test executable"));
|
|
command
|
|
.args(["--exact", STDIO_HELPER_TEST, "--nocapture"])
|
|
.env(STDIO_HELPER_ENV, mode)
|
|
.stdin(Stdio::null());
|
|
|
|
match stream {
|
|
"stdout" => {
|
|
command.stdout(Stdio::piped()).stderr(Stdio::null());
|
|
}
|
|
"stderr" => {
|
|
command.stdout(Stdio::null()).stderr(Stdio::piped());
|
|
}
|
|
_ => panic!("unknown captured stream: {stream}"),
|
|
}
|
|
|
|
let mut helper = command.spawn().expect("spawn stdio helper");
|
|
let started = std::time::Instant::now();
|
|
let mut output = String::new();
|
|
match stream {
|
|
"stdout" => helper
|
|
.stdout
|
|
.take()
|
|
.expect("stdio helper stdout pipe")
|
|
.read_to_string(&mut output)
|
|
.expect("read stdio helper stdout to EOF"),
|
|
"stderr" => helper
|
|
.stderr
|
|
.take()
|
|
.expect("stdio helper stderr pipe")
|
|
.read_to_string(&mut output)
|
|
.expect("read stdio helper stderr to EOF"),
|
|
_ => unreachable!(),
|
|
};
|
|
let eof_elapsed = started.elapsed();
|
|
let status = helper.wait().expect("wait for stdio helper");
|
|
|
|
assert!(
|
|
output.contains(STDIO_SENTINEL),
|
|
"stdio helper test did not run or emit its sentinel; output: {output:?}"
|
|
);
|
|
assert!(
|
|
status.success(),
|
|
"stdio helper exited unsuccessfully: {status}; output: {output:?}"
|
|
);
|
|
assert!(
|
|
eof_elapsed < std::time::Duration::from_secs(2),
|
|
"{stream} pipe stayed open for {eof_elapsed:?}; the detached Desktop inherited it"
|
|
);
|
|
}
|
|
|
|
// One invariant per launch builder (std = launcher fast path, tokio =
|
|
// `--update` handoff); stdout and stderr share the same inheritance
|
|
// mechanism, so each builder is paired with a different stream rather
|
|
// than running the full 2x2 matrix. Regression coverage for #112856.
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn desktop_launch_stdout_pipe_closes_when_installer_exits_std() {
|
|
assert_desktop_launch_pipe_closes("std", "stdout");
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn desktop_launch_stderr_pipe_closes_when_installer_exits_tokio() {
|
|
assert_desktop_launch_pipe_closes("tokio", "stderr");
|
|
}
|
|
}
|