feat(desktop): thin installer + first-launch install.ps1 bootstrap

Converges the Windows packaged desktop installer onto a single canonical
install topology: drop the Electron shell only (~80MB instead of ~500MB),
clone Hermes Agent at a build-time-pinned commit on first launch via
install.ps1's stage protocol, and treat the resulting git checkout at
%LOCALAPPDATA%\hermes\hermes-agent\ as the canonical install location
(same path the CLI installer uses).  Future updates flow through the
existing applyUpdates() git-pull path.

Replaces the previous fat-installer architecture where the .exe bundled
a pre-staged hermes-agent source tree under resources/hermes-agent/ that
was then sync'd into ACTIVE_HERMES_ROOT at launch -- a complicated
factory-vs-active dance with several footguns (FACTORY_HERMES_ROOT
mismatch on path resolve, isGitCheckout guard regressions, pyproject
hash drift detection inside the sync loop).

Architecture overview
---------------------

  Build time
    apps/desktop/scripts/write-build-stamp.cjs writes
    apps/desktop/build/install-stamp.json with {commit, branch, builtAt,
    dirty}.  Honours $GITHUB_SHA / $GITHUB_REF_NAME in CI, falls back to
    `git rev-parse HEAD` locally.

    apps/desktop/scripts/stage-native-deps.cjs copies the runtime subset
    of @homebridge/node-pty-prebuilt-multiarch from the workspace-root
    node_modules into apps/desktop/build/native-deps/.  Workspace dedup
    hoists this dep to the root, out of reach of electron-builder's
    `files:`-restricted collector; staging gives us a deterministic
    path to extraResources.

    electron-builder ships both into resources/install-stamp.json and
    resources/native-deps/ respectively.

  Boot resolver (electron/main.cjs)
    Resolver order:
      1. HERMES_DESKTOP_HERMES_ROOT override
      2. SOURCE_REPO_ROOT (dev mode)
      3. ACTIVE_HERMES_ROOT git checkout WITH .hermes-bootstrap-complete
         marker -- the post-install fast path
      4. `hermes` on PATH (CLI-installed user adding the desktop)
      5. pip-installed hermes_cli via system Python
      6. bootstrap-needed sentinel -> hand off to runBootstrap

    Deletes the entire FACTORY_HERMES_ROOT / RUNTIME_MARKER /
    syncTreeExcludingVenv machinery (-200 lines).  The isGitCheckout
    guard that bit us in the install.ps1 PR is gone.

  First-launch bootstrap (electron/bootstrap-runner.cjs)
    1. Resolve install.ps1: prefer SOURCE_REPO_ROOT/scripts (dev), else
       download from GitHub raw at INSTALL_STAMP.commit (cached at
       HERMES_HOME\bootstrap-cache\install-<sha>.ps1).
    2. Fetch the stage manifest via install.ps1 -Manifest -Commit X
       -Branch Y.
    3. Iterate stages: install.ps1 -Stage <name> -NonInteractive -Json
       -Commit X -Branch Y per stage.
    4. On all stages green: write the .hermes-bootstrap-complete
       marker with {schemaVersion, pinnedCommit, pinnedBranch,
       completedAt, desktopVersion}.

    Per-run log to HERMES_HOME\logs\bootstrap-<ts>.log.  Cancellation
    via AbortSignal.  Manifest cache so retries don't re-download.

  Install overlay (src/components/desktop-install-overlay.tsx)
    Mounted alongside the existing onboarding overlay; flexbox card
    with header (static) + middle (scrollable) + footer (failure-only,
    static).  Subscribes to hermes:bootstrap:event IPC + resyncs from
    hermes:bootstrap:get on mount/reload.  Renders:
      - 14-stage checklist with per-stage state icons
      - Overall progress bar + current-stage spotlight
      - Auto-expanded installer-output panel on failure
      - "Copy output" button (full ring buffer + error to clipboard)
      - "Reload and retry" wired through hermes:bootstrap:reset to
        clear main.cjs's latched failure
    Synthetic empty-manifest event from main.cjs flips the overlay to
    'active' immediately so the slow install.ps1 download doesn't
    leave the user staring at the generic Preparing splash.

  Failure latching (main.cjs)
    bootstrapFailure module-scope variable holds the rejection after
    install.ps1 fails.  startHermes() throws the latched error
    immediately when set, bypassing the entire ensureRuntime +
    runBootstrap chain.  Without this, the renderer's ensureGatewayOpen
    retries would re-run install.ps1 in a 5-10 min hot loop while the
    user was still reading the failure overlay.  Cleared via
    hermes:bootstrap:reset on user-driven retry.

  Unsupported-platform overlay (1F)
    macOS / Linux packaged builds (no install.sh stage protocol yet)
    emit an unsupported-platform event with a copy-pasteable install
    command + docs URL.  Dedicated overlay branch with "Copy command"
    + "I've run it -- retry" buttons.

install.ps1 additions (Phase 1F.3 + 1F.5)
-----------------------------------------

  New -Commit and -Tag string params.  Precedence Commit > Tag >
  Branch.  Honoured by all three code paths (update / fresh clone /
  ZIP fallback), with archive URL selection that handles each
  ref-type variant.  Detached-HEAD checkouts intentionally -- they're
  pins, not branches the user pulls into.

  EAP=Continue wrap around the new pin-step git invocations.  `git
  fetch origin <commit>` writes the routine 'From <url>' info line to
  stderr; under the script's global EAP=Stop that terminates the
  script even though fetch+checkout succeed.  Matches the established
  pattern in Install-Uv, Test-Python, _Run-NpmInstall.

Backend fix (hermes_cli/web_server.py)
--------------------------------------

  CORS allow_origin_regex now accepts Origin: 'null'.  Packaged
  Electron loads index.html via file://; Chromium sets the WebSocket
  upgrade Origin header to the opaque origin 'null', which the old
  regex rejected with HTTP 403 before gateway_ws() ever ran.  This
  failure mode was masked in the older FACTORY_HERMES_ROOT
  architecture because the resolver often found an existing hermes
  on PATH with different binding behavior.

  Security maintained: localhost-only bind keeps cross-machine pages
  out; per-process session token still gates every authenticated
  /api/ endpoint regardless of Origin.

Desktop QoL
-----------

  DevTools is now enabled in packaged builds (F12 / Cmd+Opt+I).
  Field-debugging trade-off: tiny attack surface increase versus
  a much better support story when CSP / WS / theme issues surface.

  NSIS prereq-check page deleted (-767 lines).  The standard
  Welcome -> License -> Directory -> InstallFiles -> Finish wizard
  now installs without custom Python/Git/ripgrep detection -- those
  prereqs are install.ps1's job at first launch.

Test infrastructure (Phase 1G)
------------------------------

  apps/desktop/scripts/test-desktop.mjs rewritten as a cross-platform
  bundle validator (was darwin-only and asserted on dead factory-
  payload paths):
    NEGATIVE: hermes_cli/main.py is NOT shipped (regression guard)
    POSITIVE: install-stamp.json carries a real commit + branch
    POSITIVE: node-pty native deps shipped under resources/native-deps
    POSITIVE: renderer dist/index.html reachable (asar or unpacked)
  New nsis mode and npm run test:desktop:nsis script.

Validated end-to-end on clean Win10 VM
--------------------------------------

  Confirmed: NSIS installer drops Electron shell, app launches,
  install overlay shows progress, install.ps1 clones the pinned
  commit, 14 stages run to completion, marker written, backend
  spawns, WebSocket connects, onboarding overlay asks for API key,
  main UI loads, integrated terminal works.

  Failures handled: bootstrap stays failed (no hot-loop retry),
  "Copy output" gives actionable transcript, "Reload and retry"
  explicitly re-runs install.ps1.

What's deferred
---------------

  - MSIX wrapping (Phase 2): same Electron .exe under MSIX manifest
    with runFullTrust, signed and submitted to Microsoft Store.
  - install.sh stage protocol parity (Phase 2): once shipped, the
    unsupported-platform overlay becomes drive-it-yourself and
    macOS/Linux packaged installers gain feature parity with Windows.
This commit is contained in:
emozilla
2026-05-18 02:26:46 -04:00
parent 046f0c01cb
commit 705eaa054a
17 changed files with 2072 additions and 1212 deletions
@@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query'
import { lazy, Suspense, useCallback, useEffect, useRef } from 'react'
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'
import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay'
import { Pane, PaneMain } from '@/components/pane-shell'
import { useSkinCommand } from '@/themes/use-skin-command'
@@ -464,6 +465,7 @@ export function DesktopController() {
const overlays = (
<>
<DesktopInstallOverlay />
<DesktopOnboardingOverlay
enabled={gatewayState === 'open'}
onCompleted={() => {
@@ -0,0 +1,484 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { AlertTriangle, Check, ChevronDown, ChevronRight, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type {
DesktopBootstrapEvent,
DesktopBootstrapStageDescriptor,
DesktopBootstrapStageResult,
DesktopBootstrapStageState,
DesktopBootstrapState
} from '@/global'
/**
* DesktopInstallOverlay
*
* Renders the first-launch install progress for Hermes Agent. Mounted always;
* shows itself only when main.cjs reports an in-flight bootstrap (state.active)
* OR an error from a completed-failed bootstrap (state.error). When the
* bootstrap finishes successfully the overlay fades out and the rest of the
* app (existing onboarding overlay -> main UI) takes over.
*
* Subscribes to two channels:
* - getBootstrapState() -- initial snapshot on mount
* - onBootstrapEvent(callback) -- live event stream
*
* The reducer is intentionally simple: every event mutates an in-component
* snapshot the same way main.cjs mutates its server-side snapshot. We don't
* try to reconcile -- if we miss an event (shouldn't happen) the initial
* getBootstrapState() call will resync the picture on the next render.
*
* Stages flagged needs_user_input render with a deliberately subdued style:
* they're expected to come back as skipped=true (install.ps1 short-circuits
* them under -NonInteractive). The post-install configuration flow that
* those stages cover (API key, model, persona, gateway autostart) is handled
* by the existing DesktopOnboardingOverlay, NOT by the install overlay.
*/
interface DesktopInstallOverlayProps {
/** When false, the overlay never renders -- useful for dev when we want
* to suppress it entirely. */
enabled?: boolean
}
interface StageRowProps {
descriptor: DesktopBootstrapStageDescriptor
result: DesktopBootstrapStageResult | undefined
isCurrent: boolean
}
const STATE_LABEL: Record<DesktopBootstrapStageState, string> = {
pending: 'Pending',
running: 'Installing',
succeeded: 'Done',
skipped: 'Skipped',
failed: 'Failed'
}
function formatStageName(name: string): string {
// 'system-packages' -> 'System packages'; 'uv' stays 'uv'
if (name.length <= 3) return name
return name
.split('-')
.map((word, i) =>
i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word
)
.join(' ')
}
function formatDuration(ms: number | null | undefined): string {
if (typeof ms !== 'number' || !Number.isFinite(ms)) return ''
if (ms < 1000) return `${ms} ms`
const s = ms / 1000
if (s < 60) return `${s.toFixed(1)}s`
const m = Math.floor(s / 60)
const rs = Math.round(s - m * 60)
return `${m}m ${rs}s`
}
function StageRow({ descriptor, result, isCurrent }: StageRowProps) {
const state: DesktopBootstrapStageState = result?.state || 'pending'
const icon = useMemo(() => {
switch (state) {
case 'running':
return <Loader2 className="h-4 w-4 animate-spin text-primary" />
case 'succeeded':
return <Check className="h-4 w-4 text-emerald-600" />
case 'skipped':
return <Check className="h-4 w-4 text-muted-foreground" />
case 'failed':
return <AlertTriangle className="h-4 w-4 text-destructive" />
case 'pending':
default:
return <div className="h-2 w-2 rounded-full border border-muted-foreground/40" />
}
}, [state])
const reason = result?.json?.reason || result?.error || null
return (
<li
className={cn(
'flex items-start gap-3 rounded-md px-3 py-2 transition-colors',
isCurrent && 'bg-muted/60',
state === 'failed' && 'bg-destructive/10'
)}
>
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
{icon}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span
className={cn(
'truncate text-sm font-medium',
state === 'pending' && 'text-muted-foreground'
)}
>
{formatStageName(descriptor.name)}
</span>
<span className="flex-shrink-0 text-xs tabular-nums text-muted-foreground">
{state === 'running' ? STATE_LABEL[state] : null}
{state === 'succeeded' || state === 'skipped' ? formatDuration(result?.durationMs) : null}
{state === 'failed' ? STATE_LABEL[state] : null}
</span>
</div>
{reason && state !== 'pending' && (
<p className="mt-0.5 truncate text-xs text-muted-foreground">{reason}</p>
)}
</div>
</li>
)
}
const EMPTY_STATE: DesktopBootstrapState = {
active: false,
manifest: null,
stages: {},
error: null,
log: [],
startedAt: null,
completedAt: null,
unsupportedPlatform: null
}
function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): DesktopBootstrapState {
if (ev.type === 'manifest') {
const stages: Record<string, DesktopBootstrapStageResult> = {}
for (const stage of ev.stages) {
stages[stage.name] = { state: 'pending', durationMs: null, json: null, error: null }
}
return {
...state,
active: true,
manifest: { type: 'manifest', stages: ev.stages, protocolVersion: ev.protocolVersion },
stages,
error: null,
startedAt: state.startedAt || Date.now()
}
}
if (ev.type === 'stage') {
return {
...state,
stages: {
...state.stages,
[ev.name]: {
state: ev.state,
durationMs: ev.durationMs ?? null,
json: ev.json ?? null,
error: ev.error ?? null
}
}
}
}
if (ev.type === 'log') {
const next = state.log.concat({ ts: Date.now(), stage: ev.stage ?? null, line: ev.line })
while (next.length > 500) next.shift()
return { ...state, log: next }
}
if (ev.type === 'complete') {
return { ...state, active: false, completedAt: Date.now(), error: null }
}
if (ev.type === 'failed') {
return { ...state, active: false, error: ev.error || 'unknown error' }
}
if (ev.type === 'unsupported-platform') {
return {
...state,
active: false,
unsupportedPlatform: {
platform: ev.platform,
activeRoot: ev.activeRoot,
installCommand: ev.installCommand,
docsUrl: ev.docsUrl
}
}
}
return state
}
export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayProps) {
const [state, setState] = useState<DesktopBootstrapState>(EMPTY_STATE)
const [logOpen, setLogOpen] = useState(false)
const [copied, setCopied] = useState(false)
const logEndRef = useRef<HTMLDivElement | null>(null)
// Subscribe to bootstrap events + load initial snapshot
useEffect(() => {
if (!enabled) return
const desktop = window.hermesDesktop
if (!desktop || typeof desktop.onBootstrapEvent !== 'function') return
let cancelled = false
desktop
.getBootstrapState()
.then(snapshot => {
if (!cancelled && snapshot) setState(snapshot)
})
.catch(() => {
// Older Electron build without the IPC handler -- bootstrap UI just
// stays empty, app falls through to existing onboarding flow.
})
const off = desktop.onBootstrapEvent(ev => setState(prev => applyEvent(prev, ev)))
return () => {
cancelled = true
off?.()
}
}, [enabled])
// Autoscroll log to bottom when new lines arrive AND the log is open
useEffect(() => {
if (logOpen && logEndRef.current) {
logEndRef.current.scrollIntoView({ behavior: 'auto', block: 'end' })
}
}, [state.log.length, logOpen])
// Auto-expand the log panel when a bootstrap fails so the user immediately
// sees the install.ps1 output. Without this, the failure block shows just
// the top-level error message and the user has to click "Show installer
// output" to see WHY the stage failed.
useEffect(() => {
if (state.error) setLogOpen(true)
}, [state.error])
// Mount logic: show whenever a bootstrap is in flight, completed-with-error,
// or actively running with a manifest. Hide entirely after a successful
// completion so the rest of the UI can take over.
const shouldShow = useMemo(() => {
if (!enabled) return false
if (state.active) return true
if (state.error) return true
if (state.unsupportedPlatform) return true
return false
}, [enabled, state.active, state.error, state.unsupportedPlatform])
if (!shouldShow) return null
// Unsupported-platform branch: macOS/Linux packaged builds hit this when
// there's no Hermes Agent installed yet and we can't drive install.sh
// (no stage protocol equivalent yet). Show a copy-paste install command
// and the docs URL; user runs it from Terminal and relaunches the app.
if (state.unsupportedPlatform) {
const ups = state.unsupportedPlatform
const platformLabel = ups.platform === 'darwin' ? 'macOS' : ups.platform === 'linux' ? 'Linux' : ups.platform
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 backdrop-blur-md">
<div className="w-full max-w-xl rounded-xl border bg-card p-8 shadow-xl">
<h2 className="text-2xl font-semibold tracking-tight">Hermes needs a one-time install</h2>
<p className="mt-2 text-sm text-muted-foreground">
Automated first-launch install isn{'\u2019'}t available on {platformLabel} yet. Open Terminal and
run the command below, then relaunch this app. Subsequent launches will skip this step.
</p>
<div className="mt-4">
<div className="mb-1.5 text-xs font-medium text-muted-foreground">Install command</div>
<pre className="overflow-x-auto rounded-md border bg-muted/50 px-3 py-2.5 font-mono text-[12px]">
<code>{ups.installCommand}</code>
</pre>
<div className="mt-2 flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => {
void navigator.clipboard?.writeText(ups.installCommand).catch(() => {})
}}
>
Copy command
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
window.hermesDesktop?.openExternal?.(ups.docsUrl)
}}
>
View install docs
</Button>
</div>
</div>
<div className="mt-6 flex items-center justify-between border-t pt-4">
<span className="text-xs text-muted-foreground">
Will install to <code className="rounded bg-muted/50 px-1 py-0.5 font-mono">{ups.activeRoot}</code>
</span>
<Button
variant="default"
size="sm"
onClick={() => window.location.reload()}
>
I{'\u2019'}ve run it -- retry
</Button>
</div>
</div>
</div>
)
}
const stages = state.manifest?.stages || []
const currentStage = stages.find(s => state.stages[s.name]?.state === 'running')?.name
const completedCount = stages.filter(
s => state.stages[s.name]?.state === 'succeeded' || state.stages[s.name]?.state === 'skipped'
).length
const totalCount = stages.length
const failed = Boolean(state.error)
const progressPct = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 backdrop-blur-md p-4">
<div className="flex w-full max-w-2xl max-h-[90vh] flex-col rounded-xl border bg-card shadow-xl">
{/* Header -- always visible, never scrolls */}
<div className="flex-shrink-0 p-8 pb-4">
<h2 className="text-2xl font-semibold tracking-tight">
{failed ? 'Installation failed' : state.active ? 'Setting up Hermes Agent' : 'Finishing up'}
</h2>
<p className="mt-1.5 text-sm text-muted-foreground">
{failed
? 'One of the install steps failed. Check the details below or the desktop log for the full transcript.'
: 'This is a one-time setup. The Hermes installer is downloading dependencies and configuring your machine. ' +
'Subsequent launches will skip this step.'}
</p>
</div>
{/* Scrollable middle: progress, stages, error block, log */}
<div className="min-h-0 flex-1 overflow-y-auto px-8 pb-2">
{totalCount > 0 && (
<div className="mb-4">
<div className="mb-1 flex items-center justify-between text-xs text-muted-foreground">
<span>
{completedCount} of {totalCount} steps complete
{currentStage && ` -- now: ${formatStageName(currentStage)}`}
</span>
<span className="tabular-nums">{progressPct}%</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn(
'h-full transition-all duration-300',
failed ? 'bg-destructive' : 'bg-primary'
)}
style={{ width: `${progressPct}%` }}
/>
</div>
</div>
)}
{totalCount === 0 && state.active && (
<div className="mb-4 flex items-center gap-2 rounded-md border border-dashed bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Fetching installer manifest...</span>
</div>
)}
{failed && state.error && (
<div className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm">
<div className="mb-1 flex items-center gap-1.5 font-medium text-destructive">
<AlertTriangle className="h-4 w-4" />
<span>Error</span>
</div>
<p className="whitespace-pre-wrap break-words text-foreground/90">{state.error}</p>
</div>
)}
{stages.length > 0 && (
<ol className="mb-4 space-y-1">
{stages.map(stage => (
<StageRow
key={stage.name}
descriptor={stage}
result={state.stages[stage.name]}
isCurrent={stage.name === currentStage}
/>
))}
</ol>
)}
<div className="border-t pt-3">
<button
type="button"
onClick={() => setLogOpen(v => !v)}
className="flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
{logOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
<span>{logOpen ? 'Hide installer output' : 'Show installer output'}</span>
<span className="ml-1 tabular-nums">({state.log.length} line{state.log.length === 1 ? '' : 's'})</span>
</button>
{logOpen && (
<div className={cn(
'mt-2 overflow-auto rounded-md border bg-muted/30 p-2 font-mono text-[11px] leading-relaxed',
failed ? 'max-h-96' : 'max-h-64'
)}>
{state.log.length === 0 ? (
<div className="text-muted-foreground">No output yet.</div>
) : (
<>
{state.log.map((entry, i) => (
<div key={i} className="whitespace-pre-wrap break-words">
{entry.stage ? <span className="text-muted-foreground/70">[{entry.stage}] </span> : null}
<span>{entry.line}</span>
</div>
))}
<div ref={logEndRef} />
</>
)}
</div>
)}
</div>
</div>
{/* Footer -- always visible, never scrolls; only renders on failure */}
{failed && (
<div className="flex-shrink-0 border-t bg-card p-4">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
Full transcript saved to <code className="rounded bg-muted/50 px-1 py-0.5 font-mono">%LOCALAPPDATA%\hermes\logs\</code>
</span>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={async () => {
const text = state.log
.map(entry => (entry.stage ? `[${entry.stage}] ${entry.line}` : entry.line))
.join('\n')
const fullText = state.error ? `Error: ${state.error}\n\n${text}` : text
try {
await navigator.clipboard.writeText(fullText)
setCopied(true)
window.setTimeout(() => setCopied(false), 1500)
} catch {
// ignore -- some environments forbid clipboard writes
}
}}
>
{copied ? 'Copied!' : 'Copy output'}
</Button>
<Button
variant="default"
size="sm"
onClick={async () => {
// Tell main.cjs to clear its latched failure BEFORE we
// reload. Otherwise the renderer reload calls getConnection
// and main short-circuits to the latched error without
// re-running install.ps1.
try {
await window.hermesDesktop?.resetBootstrap?.()
} catch {
// best-effort -- continue with reload regardless
}
window.location.reload()
}}
>
Reload and retry
</Button>
</div>
</div>
</div>
)}
</div>
</div>
)
}
+68
View File
@@ -43,6 +43,9 @@ declare global {
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
onBootProgress: (callback: (payload: DesktopBootProgress) => void) => () => void
getBootstrapState: () => Promise<DesktopBootstrapState>
resetBootstrap: () => Promise<{ ok: boolean }>
onBootstrapEvent: (callback: (payload: DesktopBootstrapEvent) => void) => () => void
getVersion: () => Promise<DesktopVersionInfo>
updates: {
check: () => Promise<DesktopUpdateStatus>
@@ -172,6 +175,71 @@ export interface DesktopBootProgress {
timestamp: number
}
// First-launch install ("bootstrap") event types -- emitted by
// electron/bootstrap-runner.cjs and observed by the renderer install overlay.
// Mirrors the event shapes emitted by runBootstrap()'s onEvent callback.
export interface DesktopBootstrapStageDescriptor {
name: string
title?: string
category?: string
needs_user_input?: boolean
}
export type DesktopBootstrapStageState =
| 'pending'
| 'running'
| 'succeeded'
| 'skipped'
| 'failed'
export interface DesktopBootstrapStageResult {
state: DesktopBootstrapStageState
durationMs: number | null
json: { ok: boolean; skipped?: boolean; reason?: string | null; stage: string } | null
error: string | null
}
export interface DesktopBootstrapUnsupportedPlatform {
platform: string
activeRoot: string
installCommand: string
docsUrl: string
}
export interface DesktopBootstrapState {
active: boolean
manifest: { type: 'manifest'; stages: DesktopBootstrapStageDescriptor[]; protocolVersion: number | null } | null
stages: Record<string, DesktopBootstrapStageResult>
error: string | null
log: Array<{ ts: number; stage: string | null; line: string }>
startedAt: number | null
completedAt: number | null
unsupportedPlatform: DesktopBootstrapUnsupportedPlatform | null
}
export type DesktopBootstrapEvent =
| { type: 'manifest'; stages: DesktopBootstrapStageDescriptor[]; protocolVersion: number | null }
| {
type: 'stage'
name: string
state: DesktopBootstrapStageState
durationMs?: number
json?: DesktopBootstrapStageResult['json']
error?: string | null
}
| { type: 'log'; stage?: string | null; line: string }
| { type: 'complete'; marker: Record<string, unknown> }
| { type: 'failed'; stage?: string | null; error: string }
| {
type: 'unsupported-platform'
platform: string
activeRoot: string
installCommand: string
docsUrl: string
}
export interface HermesApiRequest {
path: string
method?: string