feat(desktop): cancellable first-launch install
The install overlay had no way to stop a running install — the runner already supported an abortSignal, but nothing drove it. Wire it end to end: - main.cjs holds an AbortController for the active runBootstrap and aborts it on a new hermes:bootstrap:cancel IPC and on app quit, so quitting/cancelling mid-install actually kills install.sh/ps1 instead of orphaning it. - runBootstrap bails before spawning anything if the signal is already aborted. - Install overlay gains a "Cancel install" button while a bootstrap is active; a cancel surfaces the recovery overlay (retry/repair). Test: electron/bootstrap-runner.test.cjs asserts the already-aborted early return (no spawn) via `node --test`.
This commit is contained in:
parent
5b71f7dd72
commit
4239230957
7 changed files with 124 additions and 41 deletions
|
|
@ -482,6 +482,18 @@ async function runBootstrap(opts) {
|
|||
writeMarker // callback to write the bootstrap-complete marker; main.cjs provides
|
||||
} = opts
|
||||
|
||||
// Bail before spawning anything if the user already cancelled — otherwise an
|
||||
// already-aborted signal would still fetch the manifest (a spawn) before the
|
||||
// in-loop abort check fires.
|
||||
if (abortSignal && abortSignal.aborted) {
|
||||
if (typeof onEvent === 'function') {
|
||||
try {
|
||||
onEvent({ type: 'failed', error: 'bootstrap cancelled by user' })
|
||||
} catch {}
|
||||
}
|
||||
return { ok: false, cancelled: true }
|
||||
}
|
||||
|
||||
const runLog = openRunLog(logRoot || path.join(hermesHome, 'logs'))
|
||||
|
||||
// Tee every event to the runLog AND the caller's onEvent. This gives us a
|
||||
|
|
|
|||
27
apps/desktop/electron/bootstrap-runner.test.cjs
Normal file
27
apps/desktop/electron/bootstrap-runner.test.cjs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
|
||||
test('runBootstrap bails immediately when the signal is already aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
const events = []
|
||||
const result = await runBootstrap({
|
||||
installStamp: null,
|
||||
activeRoot: '/tmp/hermes-runner-test',
|
||||
sourceRepoRoot: null,
|
||||
hermesHome: '/tmp/hermes-runner-test',
|
||||
logRoot: '/tmp/hermes-runner-test',
|
||||
onEvent: ev => events.push(ev),
|
||||
abortSignal: controller.signal
|
||||
})
|
||||
|
||||
// Cancelled before any install script is spawned.
|
||||
assert.deepEqual(result, { ok: false, cancelled: true })
|
||||
assert.ok(
|
||||
events.some(ev => ev.type === 'failed' && /cancelled/i.test(ev.error)),
|
||||
'should emit a cancelled failure event'
|
||||
)
|
||||
})
|
||||
|
|
@ -435,6 +435,9 @@ let connectionPromise = null
|
|||
// instead of re-running install.ps1 in a hot loop. Cleared explicitly by
|
||||
// the renderer's "Reload and retry" path or by quitting the app.
|
||||
let bootstrapFailure = null
|
||||
// Active first-launch install, so the renderer's Cancel button (and app quit)
|
||||
// can abort the in-flight install.sh/ps1 instead of leaving it running.
|
||||
let bootstrapAbortController = null
|
||||
let connectionConfigCache = null
|
||||
const hermesLog = []
|
||||
const previewWatchers = new Map()
|
||||
|
|
@ -1740,12 +1743,15 @@ async function ensureRuntime(backend) {
|
|||
})
|
||||
} catch {}
|
||||
|
||||
bootstrapAbortController = new AbortController()
|
||||
|
||||
const bootstrapResult = await runBootstrap({
|
||||
installStamp: backend.installStamp,
|
||||
activeRoot: backend.activeRoot,
|
||||
sourceRepoRoot: SOURCE_REPO_ROOT,
|
||||
hermesHome: HERMES_HOME,
|
||||
logRoot: path.join(HERMES_HOME, 'logs'),
|
||||
abortSignal: bootstrapAbortController.signal,
|
||||
onEvent: ev => {
|
||||
// Tee every bootstrap event to (a) the desktop log for forensics
|
||||
// and (b) the renderer for live progress UI. Either may be absent;
|
||||
|
|
@ -1761,6 +1767,16 @@ async function ensureRuntime(backend) {
|
|||
writeMarker: writeBootstrapMarker
|
||||
})
|
||||
|
||||
bootstrapAbortController = null
|
||||
|
||||
if (bootstrapResult.cancelled) {
|
||||
const cancelledError = new Error('Hermes install was cancelled.')
|
||||
cancelledError.isBootstrapFailure = true
|
||||
cancelledError.bootstrapCancelled = true
|
||||
bootstrapFailure = cancelledError
|
||||
throw cancelledError
|
||||
}
|
||||
|
||||
if (!bootstrapResult.ok) {
|
||||
const bootstrapError = new Error(
|
||||
`Hermes bootstrap failed${bootstrapResult.failedStage ? ` at stage '${bootstrapResult.failedStage}'` : ''}: ` +
|
||||
|
|
@ -3256,6 +3272,18 @@ ipcMain.handle('hermes:bootstrap:repair', async () => {
|
|||
resetHermesConnection()
|
||||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('hermes:bootstrap:cancel', async () => {
|
||||
// Renderer's Cancel button during first-launch install. Abort the running
|
||||
// install script (SIGTERM via the runner's abortSignal). runBootstrap
|
||||
// resolves with { cancelled: true }, which surfaces the recovery overlay.
|
||||
if (bootstrapAbortController) {
|
||||
try {
|
||||
bootstrapAbortController.abort()
|
||||
} catch {}
|
||||
return { ok: true, cancelled: true }
|
||||
}
|
||||
return { ok: false, cancelled: false }
|
||||
})
|
||||
ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState)
|
||||
ipcMain.handle('hermes:bootstrap:get', async () => getBootstrapState())
|
||||
ipcMain.handle('hermes:connection-config:get', async () => sanitizeDesktopConnectionConfig())
|
||||
|
|
@ -3726,6 +3754,13 @@ app.whenReady().then(() => {
|
|||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
// Quitting mid-install should stop the installer, not orphan it.
|
||||
if (bootstrapAbortController) {
|
||||
try {
|
||||
bootstrapAbortController.abort()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (desktopLogFlushTimer) {
|
||||
clearTimeout(desktopLogFlushTimer)
|
||||
desktopLogFlushTimer = null
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
getBootstrapState: () => ipcRenderer.invoke('hermes:bootstrap:get'),
|
||||
resetBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:reset'),
|
||||
repairBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:repair'),
|
||||
cancelBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:cancel'),
|
||||
onBootstrapEvent: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:bootstrap:event', listener)
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs",
|
||||
"type-check": "tsc -b",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
|
|
|
|||
|
|
@ -62,9 +62,7 @@ function formatStageName(name: string): string {
|
|||
if (name.length <= 3) return name
|
||||
return name
|
||||
.split('-')
|
||||
.map((word, i) =>
|
||||
i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word
|
||||
)
|
||||
.map((word, i) => (i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
|
|
@ -116,17 +114,10 @@ function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) {
|
|||
state === 'failed' && 'bg-destructive/10'
|
||||
)}
|
||||
>
|
||||
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
|
||||
{icon}
|
||||
</div>
|
||||
<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'
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
|
|
@ -135,9 +126,7 @@ function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) {
|
|||
{state === 'failed' ? STATE_LABEL[state] : null}
|
||||
</span>
|
||||
</div>
|
||||
{reason && state !== 'pending' && (
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">{reason}</p>
|
||||
)}
|
||||
{reason && state !== 'pending' && <p className="mt-0.5 truncate text-xs text-muted-foreground">{reason}</p>}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
|
|
@ -180,7 +169,7 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
|
|||
durationMs: ev.durationMs ?? null,
|
||||
// Stamp the start time on the running transition so the UI can show
|
||||
// a live elapsed timer; preserve it across repeated running events.
|
||||
startedAt: ev.state === 'running' ? prev?.startedAt ?? Date.now() : prev?.startedAt ?? null,
|
||||
startedAt: ev.state === 'running' ? (prev?.startedAt ?? Date.now()) : (prev?.startedAt ?? null),
|
||||
json: ev.json ?? null,
|
||||
error: ev.error ?? null
|
||||
}
|
||||
|
|
@ -217,6 +206,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
|||
const [state, setState] = useState<DesktopBootstrapState>(EMPTY_STATE)
|
||||
const [logOpen, setLogOpen] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [cancelling, setCancelling] = useState(false)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const logEndRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
|
|
@ -293,8 +283,8 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
|||
<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.
|
||||
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">
|
||||
|
|
@ -328,11 +318,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
|||
<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()}
|
||||
>
|
||||
<Button variant="default" size="sm" onClick={() => window.location.reload()}>
|
||||
I{'\u2019'}ve run it -- retry
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -382,10 +368,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
|||
</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'
|
||||
)}
|
||||
className={cn('h-full transition-all duration-300', failed ? 'bg-destructive' : 'bg-primary')}
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -431,14 +414,18 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
|||
>
|
||||
{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>
|
||||
<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'
|
||||
)}>
|
||||
<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>
|
||||
) : (
|
||||
|
|
@ -457,12 +444,38 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active footer: let the user actually cancel a running install. */}
|
||||
{state.active && !failed && (
|
||||
<div className="flex-shrink-0 border-t bg-card p-4">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
disabled={cancelling}
|
||||
onClick={async () => {
|
||||
setCancelling(true)
|
||||
|
||||
try {
|
||||
await window.hermesDesktop?.cancelBootstrap?.()
|
||||
} catch {
|
||||
// ignore -- the failed/cancelled event will surface the result
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
{cancelling ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{cancelling ? 'Cancelling...' : 'Cancel install'}
|
||||
</Button>
|
||||
</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>
|
||||
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
|
||||
|
|
|
|||
9
apps/desktop/src/global.d.ts
vendored
9
apps/desktop/src/global.d.ts
vendored
|
|
@ -48,6 +48,7 @@ declare global {
|
|||
getBootstrapState: () => Promise<DesktopBootstrapState>
|
||||
resetBootstrap: () => Promise<{ ok: boolean }>
|
||||
repairBootstrap: () => Promise<{ ok: boolean }>
|
||||
cancelBootstrap: () => Promise<{ ok: boolean; cancelled: boolean }>
|
||||
onBootstrapEvent: (callback: (payload: DesktopBootstrapEvent) => void) => () => void
|
||||
getVersion: () => Promise<DesktopVersionInfo>
|
||||
updates: {
|
||||
|
|
@ -194,12 +195,7 @@ export interface DesktopBootstrapStageDescriptor {
|
|||
needs_user_input?: boolean
|
||||
}
|
||||
|
||||
export type DesktopBootstrapStageState =
|
||||
| 'pending'
|
||||
| 'running'
|
||||
| 'succeeded'
|
||||
| 'skipped'
|
||||
| 'failed'
|
||||
export type DesktopBootstrapStageState = 'pending' | 'running' | 'succeeded' | 'skipped' | 'failed'
|
||||
|
||||
export interface DesktopBootstrapStageResult {
|
||||
state: DesktopBootstrapStageState
|
||||
|
|
@ -248,7 +244,6 @@ export type DesktopBootstrapEvent =
|
|||
docsUrl: string
|
||||
}
|
||||
|
||||
|
||||
export interface HermesApiRequest {
|
||||
path: string
|
||||
method?: string
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue