From 4239230957810d418357d5d7ef14f8d2182109b5 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 2 Jun 2026 08:50:45 -0500 Subject: [PATCH] feat(desktop): cancellable first-launch install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. --- apps/desktop/electron/bootstrap-runner.cjs | 12 +++ .../electron/bootstrap-runner.test.cjs | 27 +++++++ apps/desktop/electron/main.cjs | 35 ++++++++ apps/desktop/electron/preload.cjs | 1 + apps/desktop/package.json | 2 +- .../components/desktop-install-overlay.tsx | 79 +++++++++++-------- apps/desktop/src/global.d.ts | 9 +-- 7 files changed, 124 insertions(+), 41 deletions(-) create mode 100644 apps/desktop/electron/bootstrap-runner.test.cjs diff --git a/apps/desktop/electron/bootstrap-runner.cjs b/apps/desktop/electron/bootstrap-runner.cjs index 9e427d147..51f24090b 100644 --- a/apps/desktop/electron/bootstrap-runner.cjs +++ b/apps/desktop/electron/bootstrap-runner.cjs @@ -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 diff --git a/apps/desktop/electron/bootstrap-runner.test.cjs b/apps/desktop/electron/bootstrap-runner.test.cjs new file mode 100644 index 000000000..f105c7355 --- /dev/null +++ b/apps/desktop/electron/bootstrap-runner.test.cjs @@ -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' + ) +}) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index b07e53ee0..bd94e69a5 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -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 diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index eb0fc1fdf..fcdf789ae 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -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) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7056ec736..a9a0db7e2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/apps/desktop/src/components/desktop-install-overlay.tsx b/apps/desktop/src/components/desktop-install-overlay.tsx index 1864c6840..2ddac1b41 100644 --- a/apps/desktop/src/components/desktop-install-overlay.tsx +++ b/apps/desktop/src/components/desktop-install-overlay.tsx @@ -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' )} > -
- {icon} -
+
{icon}
- + {formatStageName(descriptor.name)} @@ -135,9 +126,7 @@ function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) { {state === 'failed' ? STATE_LABEL[state] : null}
- {reason && state !== 'pending' && ( -

{reason}

- )} + {reason && state !== 'pending' &&

{reason}

}
) @@ -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(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(null) @@ -293,8 +283,8 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP

Hermes needs a one-time install

- 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.

@@ -328,11 +318,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP Will install to {ups.activeRoot} -
@@ -382,10 +368,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
@@ -431,14 +414,18 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP > {logOpen ? : } {logOpen ? 'Hide installer output' : 'Show installer output'} - ({state.log.length} line{state.log.length === 1 ? '' : 's'}) + + ({state.log.length} line{state.log.length === 1 ? '' : 's'}) + {logOpen && ( -
+
{state.log.length === 0 ? (
No output yet.
) : ( @@ -457,12 +444,38 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
+ {/* Active footer: let the user actually cancel a running install. */} + {state.active && !failed && ( +
+
+ +
+
+ )} + {/* Footer -- always visible, never scrolls; only renders on failure */} {failed && (
- Full transcript saved to %LOCALAPPDATA%\hermes\logs\ + Full transcript saved to{' '} + %LOCALAPPDATA%\hermes\logs\