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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
+68
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user