mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-22 10:32:00 +00:00
Hermes-Setup.exe is a small signed Rust+Tauri binary that drives
scripts/install.ps1 stage-by-stage with a native UI matching the
desktop's design language. Replaces the chicken-and-egg pattern of
shipping a 200MB Electron app whose first launch existed only to
run install.ps1.
The architecture:
Rust backend (src-tauri/):
bootstrap.rs orchestrator -- Tauri commands, stage iteration
install_script.rs resolve install.ps1 (dev checkout, cache, GitHub raw)
powershell.rs spawn powershell, line-stream stdout/stderr, parse JSON
events.rs BootstrapEvent types -- mirror bootstrap-runner.cjs
paths.rs HERMES_HOME resolution + tracing log setup
build.rs bakes BUILD_PIN_COMMIT / BUILD_PIN_BRANCH from
'git rev-parse HEAD' at compile time
React frontend (src/):
Tauri webview rendering 4 screens (welcome / progress / success /
failure), driven by nanostores subscribing to the Rust event stream.
Visual layer reuses the desktop's styles.css wholesale via @import
so the installer and desktop never drift visually.
Distribution:
targets = ['app', 'dmg', 'appimage'] -- no NSIS/MSI wrapper. The
raw target/release/Hermes-Setup.exe IS the artifact on Windows;
.dmg + .app on macOS; AppImage on Linux. One file, double-click,
no installer-installing-an-installer pattern.
Compile-time pinning:
build.rs reads 'git rev-parse HEAD' and emits
cargo:rustc-env=BUILD_PIN_COMMIT=<sha> + BUILD_PIN_BRANCH=<branch>.
bootstrap.rs's option_env!() picks these up so the binary fetches
install.ps1 from the exact SHA it was tested against. CI / release
builds can override via HERMES_BUILD_PIN_COMMIT env var.
Windows manifest:
hermes-setup.manifest declares level='asInvoker' so the
productName 'Hermes Setup' doesn't trip Windows's installer-
detection heuristic and refuse to launch without elevation.
Also declares PerMonitorV2 DPI + UTF-8 active code page + Common
Controls v6.
Limitations of this initial version:
* No code signing -- Windows SmartScreen will warn once on Hermes-Setup.exe
('More info -> Run anyway'). The downstream binaries it produces
(Hermes.exe in win-unpacked/, the hermes CLI) are locally-built and
therefore don't carry MOTW, so they launch without SmartScreen
intervention. Cert procurement tracked separately.
* macOS and Linux build paths defined but untested -- Windows-only V1.
247 lines
6.8 KiB
TypeScript
247 lines
6.8 KiB
TypeScript
import { atom, computed } from 'nanostores'
|
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
|
import { invoke } from '@tauri-apps/api/core'
|
|
|
|
/*
|
|
* Bootstrap state store — single source of truth for installer screens.
|
|
*
|
|
* Lives in nanostores per the project's TypeScript guidelines (apps/desktop
|
|
* AGENTS.md): "Prefer small nanostores over component state when state is
|
|
* shared, reused, or read by distant UI."
|
|
*
|
|
* One channel from Rust ('bootstrap' event), discriminated by payload.type.
|
|
* We translate those events into typed atom updates here so the rest of
|
|
* the app only deals with React-friendly state.
|
|
*/
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types — mirror src-tauri/src/events.rs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface StageInfo {
|
|
name: string
|
|
title: string
|
|
category: string
|
|
needs_user_input: boolean
|
|
}
|
|
|
|
export type StageState = 'running' | 'succeeded' | 'skipped' | 'failed'
|
|
|
|
export interface StageRecord {
|
|
info: StageInfo
|
|
state: StageState | null
|
|
durationMs?: number
|
|
error?: string
|
|
}
|
|
|
|
export interface BootstrapStateModel {
|
|
status: 'idle' | 'running' | 'completed' | 'failed'
|
|
protocolVersion: number | null
|
|
stages: Record<string, StageRecord>
|
|
stageOrder: string[]
|
|
currentStage: string | null
|
|
installRoot: string | null
|
|
error: string | null
|
|
logs: Array<{ stage?: string; line: string }>
|
|
}
|
|
|
|
const INITIAL: BootstrapStateModel = {
|
|
status: 'idle',
|
|
protocolVersion: null,
|
|
stages: {},
|
|
stageOrder: [],
|
|
currentStage: null,
|
|
installRoot: null,
|
|
error: null,
|
|
logs: []
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Atoms
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type Route = 'welcome' | 'progress' | 'success' | 'failure'
|
|
|
|
export const $route = atom<Route>('welcome')
|
|
export const $bootstrap = atom<BootstrapStateModel>(INITIAL)
|
|
export const $logPath = atom<string | null>(null)
|
|
export const $hermesHome = atom<string | null>(null)
|
|
|
|
export const $progress = computed($bootstrap, (b) => {
|
|
const total = b.stageOrder.length
|
|
if (total === 0) return { done: 0, total: 0, fraction: 0 }
|
|
let done = 0
|
|
for (const name of b.stageOrder) {
|
|
const s = b.stages[name]?.state
|
|
if (s === 'succeeded' || s === 'skipped' || s === 'failed') done += 1
|
|
}
|
|
return { done, total, fraction: done / total }
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tauri event subscription
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface BootstrapManifestEvent {
|
|
type: 'manifest'
|
|
stages: StageInfo[]
|
|
protocolVersion: number | null
|
|
}
|
|
|
|
interface BootstrapStageEvent {
|
|
type: 'stage'
|
|
name: string
|
|
state: StageState
|
|
durationMs?: number
|
|
error?: string
|
|
}
|
|
|
|
interface BootstrapLogEvent {
|
|
type: 'log'
|
|
stage?: string
|
|
line: string
|
|
}
|
|
|
|
interface BootstrapCompleteEvent {
|
|
type: 'complete'
|
|
installRoot: string
|
|
marker: unknown
|
|
}
|
|
|
|
interface BootstrapFailedEvent {
|
|
type: 'failed'
|
|
stage?: string
|
|
error: string
|
|
}
|
|
|
|
type BootstrapEvent =
|
|
| BootstrapManifestEvent
|
|
| BootstrapStageEvent
|
|
| BootstrapLogEvent
|
|
| BootstrapCompleteEvent
|
|
| BootstrapFailedEvent
|
|
|
|
let unlisten: UnlistenFn | null = null
|
|
|
|
export async function initialize(): Promise<void> {
|
|
if (unlisten) return
|
|
|
|
// Pull static info on mount for the diagnostics footer.
|
|
try {
|
|
const [logPath, hermesHome] = await Promise.all([
|
|
invoke<string>('get_log_path'),
|
|
invoke<string>('get_hermes_home')
|
|
])
|
|
$logPath.set(logPath)
|
|
$hermesHome.set(hermesHome)
|
|
} catch (err) {
|
|
console.warn('failed to fetch installer paths', err)
|
|
}
|
|
|
|
unlisten = await listen<BootstrapEvent>('bootstrap', (event) => {
|
|
const payload = event.payload
|
|
const cur = $bootstrap.get()
|
|
switch (payload.type) {
|
|
case 'manifest': {
|
|
const stages: Record<string, StageRecord> = {}
|
|
const order: string[] = []
|
|
for (const s of payload.stages) {
|
|
stages[s.name] = { info: s, state: null }
|
|
order.push(s.name)
|
|
}
|
|
$bootstrap.set({
|
|
...cur,
|
|
status: 'running',
|
|
protocolVersion: payload.protocolVersion,
|
|
stages,
|
|
stageOrder: order,
|
|
currentStage: null,
|
|
installRoot: null,
|
|
error: null,
|
|
logs: []
|
|
})
|
|
$route.set('progress')
|
|
break
|
|
}
|
|
case 'stage': {
|
|
const existing = cur.stages[payload.name]
|
|
if (!existing) {
|
|
console.warn('stage event for unknown stage', payload.name)
|
|
break
|
|
}
|
|
const next: StageRecord = {
|
|
...existing,
|
|
state: payload.state,
|
|
durationMs: payload.durationMs,
|
|
error: payload.error
|
|
}
|
|
$bootstrap.set({
|
|
...cur,
|
|
stages: { ...cur.stages, [payload.name]: next },
|
|
currentStage:
|
|
payload.state === 'running' ? payload.name : cur.currentStage
|
|
})
|
|
break
|
|
}
|
|
case 'log': {
|
|
const logs = [...cur.logs, { stage: payload.stage, line: payload.line }]
|
|
// Keep the rolling buffer bounded so the UI doesn't get OOM'd
|
|
// during a long install (playwright chromium download is ~10k lines).
|
|
const trimmed = logs.length > 2000 ? logs.slice(-2000) : logs
|
|
$bootstrap.set({ ...cur, logs: trimmed })
|
|
break
|
|
}
|
|
case 'complete':
|
|
$bootstrap.set({
|
|
...cur,
|
|
status: 'completed',
|
|
installRoot: payload.installRoot,
|
|
currentStage: null
|
|
})
|
|
$route.set('success')
|
|
break
|
|
case 'failed':
|
|
$bootstrap.set({
|
|
...cur,
|
|
status: 'failed',
|
|
error: payload.error,
|
|
currentStage: null
|
|
})
|
|
$route.set('failure')
|
|
break
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Actions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function startInstall(opts?: { branch?: string }): Promise<void> {
|
|
// Reset before kicking off so a retry from the failure screen clears
|
|
// the previous run's state.
|
|
$bootstrap.set(INITIAL)
|
|
$route.set('progress')
|
|
await invoke('start_bootstrap', {
|
|
args: {
|
|
commit: null,
|
|
branch: opts?.branch ?? null,
|
|
include_desktop: true,
|
|
hermes_home: null
|
|
}
|
|
})
|
|
}
|
|
|
|
export async function cancelInstall(): Promise<void> {
|
|
await invoke('cancel_bootstrap')
|
|
}
|
|
|
|
export async function launchHermesDesktop(): Promise<void> {
|
|
const installRoot = $bootstrap.get().installRoot
|
|
if (!installRoot) throw new Error('no install root')
|
|
await invoke('launch_hermes_desktop', { installRoot })
|
|
}
|
|
|
|
export async function openLogDir(): Promise<void> {
|
|
await invoke('open_log_dir')
|
|
}
|