diff --git a/apps/bootstrap-installer/.gitignore b/apps/bootstrap-installer/.gitignore new file mode 100644 index 00000000000..97f17a951be --- /dev/null +++ b/apps/bootstrap-installer/.gitignore @@ -0,0 +1,34 @@ +# Rust / Cargo +/src-tauri/target/ +/src-tauri/Cargo.lock + +# Vite / build output +/dist/ +/dist-ssr/ +*.local + +# Tauri generated artifacts (regenerated on each build) +/src-tauri/gen/schemas/ + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Editor +.vscode/* +!.vscode/extensions.json +.idea/ +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Node +node_modules/ + +# Internal placeholder (re-create if needed) +.tauri-note diff --git a/apps/bootstrap-installer/index.html b/apps/bootstrap-installer/index.html new file mode 100644 index 00000000000..1b34980a92e --- /dev/null +++ b/apps/bootstrap-installer/index.html @@ -0,0 +1,12 @@ + + + + + + Hermes Setup + + +
+ + + diff --git a/apps/bootstrap-installer/package.json b/apps/bootstrap-installer/package.json new file mode 100644 index 00000000000..6b7991eafd1 --- /dev/null +++ b/apps/bootstrap-installer/package.json @@ -0,0 +1,46 @@ +{ + "name": "@hermes/bootstrap-installer", + "private": true, + "version": "0.0.1", + "description": "Hermes Setup — signed installer that drives scripts/install.ps1 with a polished native UI.", + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1 --port 5175", + "build": "tsc -b && vite build", + "preview": "vite preview", + "tauri": "tauri", + "tauri:dev": "tauri dev", + "tauri:build": "tauri build", + "tauri:build:debug": "tauri build --debug" + }, + "dependencies": { + "@nous-research/ui": "0.16.0", + "@tailwindcss/vite": "^4.2.1", + "@tailwindcss/typography": "^0.5.19", + "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-dialog": "^2.0.0", + "@tauri-apps/plugin-opener": "^2.0.0", + "@tauri-apps/plugin-process": "^2.0.0", + "@tauri-apps/plugin-shell": "^2.0.0", + "@vscode/codicons": "^0.0.45", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "katex": "^0.16.45", + "lucide-react": "^0.577.0", + "nanostores": "^1.3.0", + "radix-ui": "^1.4.3", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.1", + "tw-shimmer": "^0.4.11" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.0.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.2.0", + "typescript": "~5.9.3", + "vite": "^7.3.1" + } +} diff --git a/apps/bootstrap-installer/src-tauri/Cargo.toml b/apps/bootstrap-installer/src-tauri/Cargo.toml new file mode 100644 index 00000000000..fe65ff9aa7b --- /dev/null +++ b/apps/bootstrap-installer/src-tauri/Cargo.toml @@ -0,0 +1,75 @@ +[package] +name = "hermes-bootstrap" +version = "0.0.1" +description = "Hermes Setup — signed installer that drives scripts/install.ps1" +authors = ["Nous Research "] +edition = "2021" +rust-version = "1.77" + +# Rename the output binary so the distributed artifact is literally +# `Hermes-Setup.exe` on disk — not `hermes-bootstrap.exe`. Grandma sees +# what we hand her, period. Tauri honors [[bin]] over [package].name +# for the produced executable name. +[[bin]] +name = "Hermes-Setup" +path = "src/main.rs" + +# The library target name MUST match the `withGlobalTauri` binding name that +# tauri.conf.json's `app.windows[].label` references. We don't ship a separate +# lib for now; everything is in src/. +[lib] +name = "hermes_bootstrap_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +# Tauri runtime + plugins +tauri = { version = "2", features = [] } +tauri-plugin-dialog = "2" +tauri-plugin-opener = "2" +tauri-plugin-process = "2" +tauri-plugin-shell = "2" + +# Async + IO +tokio = { version = "1", features = ["full"] } +futures = "0.3" + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# HTTP — rustls so we don't need OpenSSL on the build box +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } + +# Logging — emitted to a file under HERMES_HOME/logs/ and (optionally) the +# webview console via Tauri's event channel. +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +tracing-appender = "0.2" + +# Paths + utils +dirs = "5" +which = "6" +anyhow = "1" +thiserror = "1" +once_cell = "1" +uuid = { version = "1", features = ["v4"] } + +# Process control on Windows (CREATE_NO_WINDOW etc.) +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.59", features = [ + "Win32_Foundation", + "Win32_System_Threading", + "Win32_System_Console", + "Win32_UI_WindowsAndMessaging", +] } + +[profile.release] +# A 5-10MB signed installer is the goal. LTO + size-opt + single codegen unit. +panic = "abort" +codegen-units = 1 +lto = true +opt-level = "s" +strip = true diff --git a/apps/bootstrap-installer/src-tauri/build.rs b/apps/bootstrap-installer/src-tauri/build.rs new file mode 100644 index 00000000000..dbf3ba5fe56 --- /dev/null +++ b/apps/bootstrap-installer/src-tauri/build.rs @@ -0,0 +1,150 @@ +use std::process::Command; + +fn main() { + // ----------------------------------------------------------------- + // Bake the install.ps1 pin into the binary at compile time. + // + // BUILD_PIN_COMMIT and BUILD_PIN_BRANCH are read by bootstrap.rs's + // `option_env!()` macro to default the install-script reference. + // Precedence (matches install.ps1's own arg precedence): commit > branch. + // + // Resolution order: + // 1. Env var override at build time (HERMES_BUILD_PIN_COMMIT, etc.). + // Useful for CI builds that want to pin to a tagged release SHA + // rather than whatever the checkout's HEAD happens to be. + // 2. `git rev-parse HEAD` + `git rev-parse --abbrev-ref HEAD` against + // the repo this build.rs lives in. Default for `cargo tauri build` + // from a dev machine — pins the produced .exe to your current + // checkout state. + // 3. Last-resort fallback: hardcoded `main` branch, no commit. The + // installer will fetch HEAD-of-main at runtime. Used when the + // build is happening outside a git checkout (e.g. cargo install + // from a packaged crate, unlikely for this binary but defensive). + // + // Build script reruns on git HEAD change so a new commit triggers + // a rebuild without `cargo clean`. + // ----------------------------------------------------------------- + + let commit = resolve_commit_pin(); + let branch = resolve_branch_pin(); + + if let Some(c) = &commit { + println!("cargo:rustc-env=BUILD_PIN_COMMIT={c}"); + println!("cargo:warning=hermes-bootstrap: pinning to commit {}", short(c)); + } + if let Some(b) = &branch { + println!("cargo:rustc-env=BUILD_PIN_BRANCH={b}"); + println!("cargo:warning=hermes-bootstrap: pinning to branch {b}"); + } + if commit.is_none() && branch.is_none() { + // Fail loudly rather than silently produce a binary that errors + // at runtime with "no install-script pin supplied". A build that + // can't resolve a pin almost certainly indicates a misconfigured + // build environment. + println!( + "cargo:warning=hermes-bootstrap: no pin resolved at build time; binary will fail at runtime without HERMES_SETUP_DEV_REPO_ROOT or runtime args" + ); + } + + // Rerun build.rs when HEAD moves so successive builds pick up new + // commits without needing `cargo clean`. .git/HEAD changes on every + // commit / branch switch / rebase. + let git_dir = locate_git_dir(); + if let Some(gd) = &git_dir { + println!("cargo:rerun-if-changed={}/HEAD", gd.display()); + // .git/HEAD often points at a ref (e.g. `ref: refs/heads/bb/gui`); + // also watch the ref itself so a new commit on the same branch + // re-triggers. + if let Ok(head) = std::fs::read_to_string(gd.join("HEAD")) { + if let Some(rest) = head.trim().strip_prefix("ref: ") { + println!("cargo:rerun-if-changed={}/{}", gd.display(), rest); + } + } + } + println!("cargo:rerun-if-env-changed=HERMES_BUILD_PIN_COMMIT"); + println!("cargo:rerun-if-env-changed=HERMES_BUILD_PIN_BRANCH"); + + // ----------------------------------------------------------------- + // Tauri windows manifest. See hermes-setup.manifest for rationale — + // declares level="asInvoker" so Windows's installer-detection + // heuristic doesn't refuse to launch us without UAC elevation. + // ----------------------------------------------------------------- + #[cfg(target_os = "windows")] + let attrs = { + let manifest = include_str!("hermes-setup.manifest"); + let win = tauri_build::WindowsAttributes::new().app_manifest(manifest); + tauri_build::Attributes::new().windows_attributes(win) + }; + + #[cfg(not(target_os = "windows"))] + let attrs = tauri_build::Attributes::new(); + + tauri_build::try_build(attrs).expect("failed to run tauri-build"); +} + +fn resolve_commit_pin() -> Option { + if let Ok(v) = std::env::var("HERMES_BUILD_PIN_COMMIT") { + if !v.trim().is_empty() { + return Some(v.trim().to_string()); + } + } + let out = Command::new("git") + .args(["rev-parse", "HEAD"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8(out.stdout).ok()?.trim().to_string(); + if s.is_empty() { + None + } else { + Some(s) + } +} + +fn resolve_branch_pin() -> Option { + if let Ok(v) = std::env::var("HERMES_BUILD_PIN_BRANCH") { + if !v.trim().is_empty() { + return Some(v.trim().to_string()); + } + } + let out = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8(out.stdout).ok()?.trim().to_string(); + // "HEAD" is what you get on a detached checkout — no meaningful branch + // to pin to. The commit pin still applies; just don't emit a branch. + if s.is_empty() || s == "HEAD" { + None + } else { + Some(s) + } +} + +fn locate_git_dir() -> Option { + let out = Command::new("git") + .args(["rev-parse", "--git-dir"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8(out.stdout).ok()?.trim().to_string(); + if s.is_empty() { + return None; + } + Some(std::path::PathBuf::from(s)) +} + +fn short(commit: &str) -> &str { + if commit.len() >= 12 { + &commit[..12] + } else { + commit + } +} diff --git a/apps/bootstrap-installer/src-tauri/capabilities/default.json b/apps/bootstrap-installer/src-tauri/capabilities/default.json new file mode 100644 index 00000000000..e07617ce0ce --- /dev/null +++ b/apps/bootstrap-installer/src-tauri/capabilities/default.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://schema.tauri.app/config/2/capability", + "identifier": "default", + "description": "Capabilities required by Hermes Setup. Narrowly scoped: we don't write user files outside HERMES_HOME, we don't read arbitrary paths, and the only external network call goes through reqwest (Rust side, not exposed to the webview).", + "windows": ["main"], + "permissions": [ + "core:default", + "core:window:allow-close", + "core:window:allow-minimize", + "core:event:default", + "opener:default", + "dialog:default", + "process:default", + "shell:default" + ] +} diff --git a/apps/bootstrap-installer/src-tauri/hermes-setup.manifest b/apps/bootstrap-installer/src-tauri/hermes-setup.manifest new file mode 100644 index 00000000000..d7da599b3ad --- /dev/null +++ b/apps/bootstrap-installer/src-tauri/hermes-setup.manifest @@ -0,0 +1,75 @@ + + + + + Hermes Setup + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PerMonitorV2 + UTF-8 + + + + + + + + + + diff --git a/apps/bootstrap-installer/src-tauri/icons/128x128.png b/apps/bootstrap-installer/src-tauri/icons/128x128.png new file mode 100644 index 00000000000..e0f04fe7255 Binary files /dev/null and b/apps/bootstrap-installer/src-tauri/icons/128x128.png differ diff --git a/apps/bootstrap-installer/src-tauri/icons/128x128@2x.png b/apps/bootstrap-installer/src-tauri/icons/128x128@2x.png new file mode 100644 index 00000000000..e0f04fe7255 Binary files /dev/null and b/apps/bootstrap-installer/src-tauri/icons/128x128@2x.png differ diff --git a/apps/bootstrap-installer/src-tauri/icons/32x32.png b/apps/bootstrap-installer/src-tauri/icons/32x32.png new file mode 100644 index 00000000000..e0f04fe7255 Binary files /dev/null and b/apps/bootstrap-installer/src-tauri/icons/32x32.png differ diff --git a/apps/bootstrap-installer/src-tauri/icons/icon.icns b/apps/bootstrap-installer/src-tauri/icons/icon.icns new file mode 100644 index 00000000000..e173b26ee23 Binary files /dev/null and b/apps/bootstrap-installer/src-tauri/icons/icon.icns differ diff --git a/apps/bootstrap-installer/src-tauri/icons/icon.ico b/apps/bootstrap-installer/src-tauri/icons/icon.ico new file mode 100644 index 00000000000..eaa48ff2dd6 Binary files /dev/null and b/apps/bootstrap-installer/src-tauri/icons/icon.ico differ diff --git a/apps/bootstrap-installer/src-tauri/src/bootstrap.rs b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs new file mode 100644 index 00000000000..787afe5d959 --- /dev/null +++ b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs @@ -0,0 +1,619 @@ +//! Bootstrap orchestration. +//! +//! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.cjs`. +//! 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-.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::PathBuf; +use std::sync::Arc; +use std::time::Instant; + +use anyhow::{anyhow, Result}; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, State}; +use tokio::sync::{mpsc, Mutex}; + +use crate::events::{BootstrapEvent, Manifest, StageState}; +use crate::install_script::{self, Pin, ScriptKind, ScriptSource}; +use crate::powershell::{self, StreamSink}; +use crate::AppState; + +// --------------------------------------------------------------------------- +// 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, + /// Optional override for the branch pin. Defaults to `BUILD_PIN_BRANCH`. + pub branch: Option, + /// 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, +} + +fn default_true() -> bool { + true +} + +#[derive(Debug, Serialize)] +pub struct BootstrapStatus { + pub running: bool, + pub completed: bool, + pub install_root: Option, + pub last_error: Option, +} + +/// 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>, + 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>) -> 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>, +) -> Result { + 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`. +#[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(|| "Could not locate a built Hermes desktop binary".to_string())?; + + tracing::info!(?exe_path, "launching Hermes desktop"); + + // Detach from us — the installer is about to exit. + let mut cmd = tokio::process::Command::new(&exe_path); + cmd.current_dir(exe_path.parent().unwrap_or(&install_root)); + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + // DETACHED_PROCESS = 0x00000008 + cmd.creation_flags(0x0000_0008); + } + + cmd.spawn().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/ +/// -unpacked/). +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") { + &[ + ("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 { + &[("linux-unpacked", "hermes")] + }; + for (subdir, exe) in candidates { + let p = release_dir.join(subdir).join(exe); + if p.exists() { + return Some(p); + } + } + None +} + +// --------------------------------------------------------------------------- +// Bootstrap implementation +// --------------------------------------------------------------------------- + +async fn run_bootstrap( + app: AppHandle, + args: StartBootstrapArgs, + cancel_rx_holder: Arc>>>, +) -> Result { + 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(), + }, + ); + tracing::debug!(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::Cached => "cached", + ScriptSource::Downloaded => "downloaded", + }; + emit_log(&format!( + "[bootstrap] script {} via {}", + script.path.display(), + source_note + )); + + // 2. Fetch manifest + let manifest_args = build_pin_args(&script); + let mut manifest_args_full = vec!["-Manifest".to_string()]; + manifest_args_full.extend(manifest_args.clone()); + + let manifest_result = run_install_script( + &app, + &script.path, + &manifest_args_full, + args.hermes_home.as_deref(), + None, + 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, + 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()); + } + + // Each stage gets its own cancel receiver because tokio::select! + // in run_script consumes it. Take/return through the Arc. + let local_cancel_rx = cancel_rx_holder.lock().await.take(); + + let stage_result = run_install_script( + &app, + &script.path, + &stage_args, + args.hermes_home.as_deref(), + local_cancel_rx, + Some(stage.name.clone()), + ) + .await?; + + 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")); + } + + let result_frame = powershell::parse_stage_result(&stage_result.stdout); + + 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"); + + emit_event( + &app, + BootstrapEvent::Complete { + install_root: install_root.to_string_lossy().into_owned(), + marker: Some(serde_json::json!({ + "pinnedCommit": pin.commit, + "pinnedBranch": pin.branch, + })), + }, + ); + + Ok(install_root.to_string_lossy().into_owned()) +} + +async fn cancellation_signalled(holder: &Arc>>>) -> 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: Option>, + stage_name: Option, +) -> Result { + 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 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(), + }, + ); + }), + on_stderr_line: Box::new(move |line: &str| { + emit_event( + &app_for_stderr, + BootstrapEvent::Log { + stage: stage_for_stderr.clone(), + line: format!("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 { + 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) { + if let Err(e) = app.emit(BootstrapEvent::CHANNEL, &event) { + tracing::warn!(?e, "failed to emit bootstrap event"); + } +} + +fn option_env_string(key: &str) -> Option { + // 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]) + } +} diff --git a/apps/bootstrap-installer/src-tauri/src/events.rs b/apps/bootstrap-installer/src-tauri/src/events.rs new file mode 100644 index 00000000000..2add0f54be4 --- /dev/null +++ b/apps/bootstrap-installer/src-tauri/src/events.rs @@ -0,0 +1,99 @@ +//! Event types streamed from Rust → React. +//! +//! These mirror `apps/desktop/electron/bootstrap-runner.cjs`'s event shape +//! 1:1 so the React installer code can be roughly identical to the Electron +//! install-overlay we'll replace. +//! +//! The Tauri event channel name is `"bootstrap"` for all of these — the +//! `type` discriminator on each payload is how the frontend routes. + +use serde::{Deserialize, Serialize}; + +/// Stage definition as reported by `install.ps1 -Manifest`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StageInfo { + pub name: String, + pub title: String, + pub category: String, + /// `needs_user_input=true` stages run with -NonInteractive and emit + /// skipped=true; the post-install wizard takes over for those. + #[serde(rename = "needs_user_input", alias = "needsUserInput")] + pub needs_user_input: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Manifest { + pub stages: Vec, + #[serde(rename = "protocol_version", alias = "protocolVersion", default)] + pub protocol_version: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StageResultPayload { + pub stage: String, + pub ok: bool, + #[serde(default)] + pub skipped: bool, + #[serde(default)] + pub reason: Option, + /// install.ps1 may attach stage-specific structured data here. + #[serde(default)] + pub data: Option, +} + +/// Run-state for a single stage as we transition through it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum StageState { + Running, + Succeeded, + Skipped, + Failed, +} + +/// The single event channel `bootstrap` emits these. `type` discriminates. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum BootstrapEvent { + /// Sent once at the start with the full stage list. + Manifest { + stages: Vec, + #[serde(rename = "protocolVersion")] + protocol_version: Option, + }, + /// Stage state transition. `result` populated only on terminal states. + Stage { + name: String, + state: StageState, + #[serde(rename = "durationMs", skip_serializing_if = "Option::is_none")] + duration_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + }, + /// Raw stdout/stderr line from install.ps1 (or our wrapper). + Log { + #[serde(skip_serializing_if = "Option::is_none")] + stage: Option, + line: String, + }, + /// Sent once when all stages complete successfully. + Complete { + #[serde(rename = "installRoot")] + install_root: String, + marker: Option, + }, + /// Sent once if the run aborts. + Failed { + #[serde(skip_serializing_if = "Option::is_none")] + stage: Option, + error: String, + }, +} + +impl BootstrapEvent { + /// Tauri event name. Single channel for all bootstrap events; the + /// `type` tag tells the renderer how to interpret the payload. + pub const CHANNEL: &'static str = "bootstrap"; +} diff --git a/apps/bootstrap-installer/src-tauri/src/install_script.rs b/apps/bootstrap-installer/src-tauri/src/install_script.rs new file mode 100644 index 00000000000..217ee9fef5a --- /dev/null +++ b/apps/bootstrap-installer/src-tauri/src/install_script.rs @@ -0,0 +1,273 @@ +//! Resolves and downloads `scripts/install.ps1` (and `install.sh`). +//! +//! Resolution order: +//! 1. Dev shortcut: a sibling repo checkout via $HERMES_SETUP_DEV_REPO_ROOT +//! env var. Lets devs iterate without re-publishing the script. +//! 2. Bundled fallback: if the installer was bundled with a script (e.g. +//! tauri's `resource` mechanism), serve from there. Not used today. +//! 3. Network: download from GitHub raw at a pinned commit or branch. +//! Commit pins are immutable; branch pins are HEAD-tracking. +//! +//! Mirrors `apps/desktop/electron/bootstrap-runner.cjs`'s `resolveInstallScript`, +//! but the dev-checkout resolution is driven by an env var rather than the +//! Electron app's APP_ROOT/../.. trick, because Hermes-Setup.exe is meant +//! to live OUTSIDE any repo checkout. + +use anyhow::{anyhow, Context, Result}; +use std::path::{Path, PathBuf}; +use tokio::io::AsyncWriteExt; + +use crate::paths; + +/// Identity of the install.ps1 we'll execute. Used by both the manifest +/// fetch and the per-stage runs. +#[derive(Debug, Clone)] +pub struct ResolvedScript { + pub path: PathBuf, + pub source: ScriptSource, + /// Commit pin (40-char SHA) if known. install.ps1's `-Commit` arg is + /// what makes the repo stage clone the exact tested SHA. + pub commit: Option, + pub branch: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ScriptSource { + DevCheckout, + Bundled, + Cached, + Downloaded, +} + +/// What flavor of script (Windows .ps1 vs Unix .sh). +#[derive(Debug, Clone, Copy)] +pub enum ScriptKind { + Ps1, + Sh, +} + +impl ScriptKind { + pub fn for_current_os() -> Self { + if cfg!(target_os = "windows") { + Self::Ps1 + } else { + Self::Sh + } + } + + fn filename(&self) -> &'static str { + match self { + Self::Ps1 => "install.ps1", + Self::Sh => "install.sh", + } + } +} + +/// Validates a string looks like a git SHA (7+ hex chars). Mirrors +/// `STAMP_COMMIT_RE` from bootstrap-runner.cjs. +fn is_valid_commit(s: &str) -> bool { + let len = s.len(); + (7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit()) +} + +/// Resolves the install script to use for this run. +/// +/// `pin` is the commit-or-branch from either Hermes-Setup's build-time +/// constant (compiled into the installer) or a runtime override. +pub async fn resolve( + kind: ScriptKind, + pin: &Pin, + emit_log: &impl Fn(&str), +) -> Result { + // 1. Dev shortcut. + if let Ok(repo_root) = std::env::var("HERMES_SETUP_DEV_REPO_ROOT") { + let candidate = PathBuf::from(repo_root).join("scripts").join(kind.filename()); + if candidate.exists() { + emit_log(&format!( + "[bootstrap] dev mode — using local {} at {}", + kind.filename(), + candidate.display() + )); + return Ok(ResolvedScript { + path: candidate, + source: ScriptSource::DevCheckout, + commit: pin.commit.clone(), + branch: pin.branch.clone(), + }); + } + } + + // 2. (Not implemented) bundled fallback. + + // 3. Network. Pin must be a real commit or a branch ref. + let commit_or_ref = match (&pin.commit, &pin.branch) { + (Some(c), _) if is_valid_commit(c) => c.clone(), + (_, Some(b)) if !b.trim().is_empty() => b.clone(), + (Some(other), _) => { + return Err(anyhow!( + "install script pin commit `{other}` is not a valid git SHA" + )); + } + _ => { + return Err(anyhow!( + "no install-script pin supplied — installer cannot resolve a script source" + )); + } + }; + + let cached = cached_path(kind, &commit_or_ref); + if cached.exists() { + emit_log(&format!( + "[bootstrap] using cached {} for {}", + kind.filename(), + truncate_ref(&commit_or_ref) + )); + return Ok(ResolvedScript { + path: cached, + source: ScriptSource::Cached, + commit: pin.commit.clone(), + branch: pin.branch.clone(), + }); + } + + emit_log(&format!( + "[bootstrap] downloading {} for {} from GitHub", + kind.filename(), + truncate_ref(&commit_or_ref) + )); + + download(kind, &commit_or_ref, &cached).await?; + + emit_log(&format!("[bootstrap] cached to {}", cached.display())); + + Ok(ResolvedScript { + path: cached, + source: ScriptSource::Downloaded, + commit: pin.commit.clone(), + branch: pin.branch.clone(), + }) +} + +#[derive(Debug, Clone, Default)] +pub struct Pin { + pub commit: Option, + pub branch: Option, +} + +fn cached_path(kind: ScriptKind, commit_or_ref: &str) -> PathBuf { + let safe = sanitize_ref(commit_or_ref); + let filename = match kind { + ScriptKind::Ps1 => format!("install-{safe}.ps1"), + ScriptKind::Sh => format!("install-{safe}.sh"), + }; + paths::bootstrap_cache_dir().join(filename) +} + +/// Replace anything that's not [A-Za-z0-9._-] with `_`. Branch refs can +/// contain `/`, dots, etc.; we want a flat filename. +fn sanitize_ref(s: &str) -> String { + s.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect() +} + +fn truncate_ref(s: &str) -> &str { + if is_valid_commit(s) && s.len() >= 12 { + &s[..12] + } else { + s + } +} + +/// Downloads to `dest_path` via reqwest with rustls. Atomically renames +/// `dest_path.tmp` → `dest_path` so partial writes don't poison the cache. +async fn download(kind: ScriptKind, commit_or_ref: &str, dest_path: &Path) -> Result<()> { + let url = format!( + "https://raw.githubusercontent.com/NousResearch/hermes-agent/{}/scripts/{}", + commit_or_ref, + kind.filename() + ); + + if let Some(parent) = dest_path.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!("creating bootstrap-cache parent dir {}", parent.display()) + })?; + } + + let tmp_path = dest_path.with_extension({ + let ext = dest_path + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("tmp"); + format!("{ext}.tmp") + }); + + let response = reqwest::Client::new() + .get(&url) + .header("User-Agent", "hermes-setup/0.0.1") + .send() + .await + .with_context(|| format!("GET {url}"))?; + + if !response.status().is_success() { + return Err(anyhow!( + "Failed to download {}: HTTP {} from {}", + kind.filename(), + response.status(), + url + )); + } + + let bytes = response + .bytes() + .await + .with_context(|| format!("reading body of {url}"))?; + + let mut file = tokio::fs::File::create(&tmp_path) + .await + .with_context(|| format!("creating temp file {}", tmp_path.display()))?; + file.write_all(&bytes) + .await + .with_context(|| format!("writing temp file {}", tmp_path.display()))?; + file.flush().await.context("flushing temp file")?; + drop(file); + + tokio::fs::rename(&tmp_path, dest_path) + .await + .with_context(|| { + format!( + "renaming {} → {}", + tmp_path.display(), + dest_path.display() + ) + })?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_valid_commit_accepts_short_and_full_shas() { + assert!(is_valid_commit("02d26981d3d4ad50e142399b8476f59ad5953ff0")); + assert!(is_valid_commit("02d2698")); + assert!(!is_valid_commit("02d269")); + assert!(!is_valid_commit("not-a-sha")); + assert!(!is_valid_commit("")); + } + + #[test] + fn sanitize_ref_replaces_slashes() { + assert_eq!(sanitize_ref("bb/gui"), "bb_gui"); + assert_eq!(sanitize_ref("main"), "main"); + assert_eq!(sanitize_ref("release/1.2.3"), "release_1.2.3"); + } +} diff --git a/apps/bootstrap-installer/src-tauri/src/lib.rs b/apps/bootstrap-installer/src-tauri/src/lib.rs new file mode 100644 index 00000000000..f26ebe47556 --- /dev/null +++ b/apps/bootstrap-installer/src-tauri/src/lib.rs @@ -0,0 +1,66 @@ +//! 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; + +use std::sync::Arc; +use tokio::sync::Mutex; + +/// 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>` lets command handlers grab it +/// without lifetime gymnastics. +pub struct AppState { + pub bootstrap: Mutex>, +} + +impl Default for AppState { + fn default() -> Self { + Self { + bootstrap: Mutex::new(None), + } + } +} + +#[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(); + + tracing::info!("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())) + .invoke_handler(tauri::generate_handler![ + // Bootstrap lifecycle + bootstrap::start_bootstrap, + bootstrap::cancel_bootstrap, + bootstrap::get_bootstrap_status, + // 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"); +} diff --git a/apps/bootstrap-installer/src-tauri/src/main.rs b/apps/bootstrap-installer/src-tauri/src/main.rs new file mode 100644 index 00000000000..f1f3e26b23e --- /dev/null +++ b/apps/bootstrap-installer/src-tauri/src/main.rs @@ -0,0 +1,19 @@ +// Hermes Setup — process entrypoint. All logic lives in lib.rs so it can +// be unit-tested as a library; this file just calls into it. +// +// The windows_subsystem attribute MUST live here on the binary crate +// (not lib.rs) — placing it on the lib was the bug that left a stray +// cmd window behind Hermes-Setup.exe on release builds. +// +// `windows_subsystem = "windows"` strips the console allocation that +// the default `windows_subsystem = "console"` would do, so double-clicking +// the .exe gives you ONLY the Tauri window. +// +// debug_assertions guard: dev builds keep the console so tracing output +// is visible during `cargo tauri dev`. + +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + hermes_bootstrap_lib::run() +} diff --git a/apps/bootstrap-installer/src-tauri/src/paths.rs b/apps/bootstrap-installer/src-tauri/src/paths.rs new file mode 100644 index 00000000000..54e9c138df3 --- /dev/null +++ b/apps/bootstrap-installer/src-tauri/src/paths.rs @@ -0,0 +1,119 @@ +//! Filesystem paths + logging setup. +//! +//! Mirrors `hermes_constants.get_hermes_home()` from the Python CLI: +//! Windows: %LOCALAPPDATA%\hermes +//! macOS: ~/Library/Application Support/hermes +//! Linux: ~/.hermes (XDG override via $HERMES_HOME) +//! +//! IMPORTANT: this must match exactly. Drift here means install.ps1 +//! writes to one place and the installer reads from another, breaking +//! the bootstrap-complete check. + +use std::path::{Path, PathBuf}; +use tracing_appender::non_blocking::WorkerGuard; + +/// Returns the canonical Hermes home directory, respecting $HERMES_HOME if set. +pub fn hermes_home() -> PathBuf { + if let Ok(override_path) = std::env::var("HERMES_HOME") { + if !override_path.trim().is_empty() { + return PathBuf::from(override_path); + } + } + + #[cfg(target_os = "windows")] + { + // %LOCALAPPDATA%\hermes — matches scripts/install.ps1's $HermesHome. + if let Some(local_app_data) = dirs::data_local_dir() { + return local_app_data.join("hermes"); + } + } + + #[cfg(target_os = "macos")] + { + // ~/Library/Application Support/hermes + if let Some(home) = dirs::home_dir() { + return home.join("Library/Application Support/hermes"); + } + } + + // Linux + fallback: ~/.hermes + if let Some(home) = dirs::home_dir() { + return home.join(".hermes"); + } + + // Last resort — current dir, almost certainly wrong but at least + // doesn't panic. + PathBuf::from(".hermes") +} + +pub fn log_dir() -> PathBuf { + hermes_home().join("logs") +} + +pub fn log_path() -> PathBuf { + log_dir().join("bootstrap-installer.log") +} + +pub fn bootstrap_cache_dir() -> PathBuf { + hermes_home().join("bootstrap-cache") +} + +/// 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') +/// We don't always know ACTIVE_HERMES_ROOT until install.ps1 reports it, so +/// this is a probe helper, not a definitive path. +pub fn likely_bootstrap_marker(install_root: &Path) -> PathBuf { + install_root.join(".hermes-bootstrap-complete") +} + +/// Initializes tracing to bootstrap-installer.log under HERMES_HOME/logs/. +/// Returns a guard that flushes the appender on drop — keep it alive for +/// the lifetime of the process. +pub fn init_logging() -> Option { + let dir = log_dir(); + if let Err(err) = std::fs::create_dir_all(&dir) { + // No log dir → log to stderr only. Don't panic; the installer + // should still be usable on an exotic filesystem. + eprintln!("[hermes-setup] could not create log dir {dir:?}: {err}"); + return None; + } + + let file_appender = tracing_appender::rolling::never(&dir, "bootstrap-installer.log"); + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + + let env_filter = tracing_subscriber::EnvFilter::try_from_env("HERMES_BOOTSTRAP_LOG") + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); + + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_writer(non_blocking) + .with_ansi(false) + .with_target(true) + .init(); + + Some(guard) +} + +// --------------------------------------------------------------------------- +// Tauri commands +// --------------------------------------------------------------------------- + +#[tauri::command] +pub fn get_log_path() -> String { + log_path().to_string_lossy().into_owned() +} + +#[tauri::command] +pub fn get_hermes_home() -> String { + hermes_home().to_string_lossy().into_owned() +} + +#[tauri::command] +pub fn open_log_dir(app: tauri::AppHandle) -> Result<(), String> { + use tauri_plugin_opener::OpenerExt; + let path = log_dir(); + app.opener() + .open_path(path.to_string_lossy(), None::<&str>) + .map_err(|e| e.to_string()) +} diff --git a/apps/bootstrap-installer/src-tauri/src/powershell.rs b/apps/bootstrap-installer/src-tauri/src/powershell.rs new file mode 100644 index 00000000000..c85d0ee55b9 --- /dev/null +++ b/apps/bootstrap-installer/src-tauri/src/powershell.rs @@ -0,0 +1,267 @@ +//! Drives PowerShell (Windows) or bash (Unix) for install.ps1 / install.sh. +//! +//! Port of `spawnPowerShell` from bootstrap-runner.cjs, with the same +//! line-buffered stdout/stderr streaming + cancellation semantics. +//! +//! On Windows we pass `-NoProfile -ExecutionPolicy Bypass -File