diff --git a/apps/desktop/src/components/boot-failure-cause.ts b/apps/desktop/src/components/boot-failure-cause.ts index 92f1234358..3d061cf281 100644 --- a/apps/desktop/src/components/boot-failure-cause.ts +++ b/apps/desktop/src/components/boot-failure-cause.ts @@ -21,7 +21,7 @@ const CAUSE_PATTERNS: readonly [LocalBootCause, RegExp][] = [ ['portInUse', /address already in use|\bEADDRINUSE\b|port .* (?:is )?(?:already )?in use/i], [ 'installMissing', - /installation is missing|is missing or incomplete|venv missing|no module named|modulenotfounderror/i + /installation is missing|is missing or incomplete|missing or damaged|venv missing|no module named|modulenotfounderror/i ], ['timedOut', /timed out|timeout/i], ['exitedEarly', /exited before|exited \(|process exited|exited with|traceback \(most recent call last\)/i] diff --git a/apps/desktop/src/components/boot-failure-overlay.test.tsx b/apps/desktop/src/components/boot-failure-overlay.test.tsx index 34be04e604..e20b3fb7a9 100644 --- a/apps/desktop/src/components/boot-failure-overlay.test.tsx +++ b/apps/desktop/src/components/boot-failure-overlay.test.tsx @@ -267,27 +267,27 @@ describe('BootFailureOverlay', () => { } }) - it('swaps Repair for "Reinstall the app" on a bundled install', async () => { - const openExternal = vi.fn().mockResolvedValue(undefined) + const bundledState = { + active: false, + manifest: null, + stages: {}, + error: null, + log: [], + startedAt: null, + completedAt: null, + setupChoice: null, + unsupportedPlatform: null, + bundled: true + } - const restore = stubDesktop( - { mode: 'local' }, - { - getBootstrapState: async () => ({ - active: false, - manifest: null, - stages: {}, - error: null, - log: [], - startedAt: null, - completedAt: null, - setupChoice: null, - unsupportedPlatform: null, - bundled: true - }), - openExternal - } - ) + it('offers "Reinstall the app" on a bundled install only when the payload itself is damaged', async () => { + const openExternal = vi.fn().mockResolvedValue(undefined) + const restore = stubDesktop({ mode: 'local' }, { getBootstrapState: async () => bundledState, openExternal }) + $desktopBoot.set({ + ...$desktopBoot.get(), + error: + 'This app bundles its own Hermes runtime, but the runtime files are missing or damaged. Reinstall Hermes Desktop to restore it.' + }) try { render() @@ -307,6 +307,23 @@ describe('BootFailureOverlay', () => { } }) + it('a bundled install with an unrelated failure gets neither Repair nor Reinstall', async () => { + const restore = stubDesktop({ mode: 'local' }, { getBootstrapState: async () => bundledState }) + $desktopBoot.set({ ...$desktopBoot.get(), error: 'listen EADDRINUSE: address already in use 127.0.0.1:8642' }) + + try { + render() + + expect(await screen.findByRole('button', { name: /retry/i })).toBeTruthy() + // Wait for the bundled snapshot to land before asserting the negatives. + await waitFor(() => expect(screen.queryByRole('button', { name: /repair install/i })).toBeNull()) + expect(screen.queryByRole('button', { name: /reinstall the app/i })).toBeNull() + expect(screen.queryByText(/reinstall the app to restore/i)).toBeNull() + } finally { + restore() + } + }) + it.each(['refused', 'thrown', 'unavailable'])('preserves a %s repair failure without reloading', async failure => { const reload = vi.fn() const originalLocation = Object.getOwnPropertyDescriptor(window, 'location')! @@ -336,7 +353,7 @@ describe('BootFailureOverlay', () => { failure === 'thrown' ? 'installer permission denied' : failure === 'refused' - ? 'bundled-immutable' + ? en.boot.failure.bundledReinstallHint : en.boot.errors.ipcBridgeUnavailable await waitFor(() => diff --git a/apps/desktop/src/components/boot-failure-overlay.tsx b/apps/desktop/src/components/boot-failure-overlay.tsx index 6894c1b64b..8040044b88 100644 --- a/apps/desktop/src/components/boot-failure-overlay.tsx +++ b/apps/desktop/src/components/boot-failure-overlay.tsx @@ -16,7 +16,7 @@ import { $desktopBoot } from '@/store/boot' import { notify, notifyError } from '@/store/notifications' import { $desktopOnboarding } from '@/store/onboarding' -import { type LocalBootFailureCopy, localBootFailureCopy } from './boot-failure-cause' +import { classifyLocalBootFailure, type LocalBootFailureCopy, localBootFailureCopy } from './boot-failure-cause' import type { RemoteReauth } from './boot-failure-reauth' import { deriveProviderShape, @@ -83,9 +83,9 @@ export function BootFailureOverlay() { // A remote/cloud backend that failed to boot is fixable from gateway settings, // so the escape hatch earns emphasis (local failures keep it as a quiet ghost). const [remoteFailure, setRemoteFailure] = useState(false) - // A bundled install (payload ships in-app) has no installer to repair with — - // the only recovery is reinstalling the app. Read from the bootstrap state - // snapshot so the Repair affordance is replaced by "Reinstall the app". + // A bundled install (payload ships in-app) has no installer to repair with. + // Read from the bootstrap state snapshot so Repair is never offered there; + // "Reinstall the app" replaces it only when the payload itself is damaged. const [bundled, setBundled] = useState(false) // Swap the card body to the embedded Gateway settings panel in place of routing // to the full Settings page (keeps the user on the recovery surface, no z-index @@ -216,6 +216,12 @@ export function BootFailureOverlay() { const result = await window.hermesDesktop.repairBootstrap() + // Main refuses repair on a bundled install (its stamp is authoritative; + // our snapshot may be stale) — say what to do instead of the raw code. + if (result?.error === 'bundled-immutable') { + throw new Error(t.boot.failure.bundledReinstallHint) + } + if (!result?.ok) { throw new Error(result?.error || t.boot.errors.desktopBootFailed) } @@ -410,18 +416,22 @@ export function BootFailureOverlay() { } else { // Local failure: Use-local is redundant with Retry (both re-target local), so // it's dropped here; keep it for remote failures where it's the fall-back. - // On a bundled install the Repair affordance is replaced by "Reinstall the - // app" — the payload is immutable, so there is no installer to re-run. - actions = [ - retryAction, - bundled - ? { - key: 'reinstall', - label: copy.reinstallApp, - onClick: () => openExternalLink(DESKTOP_DOCS_URL), - icon: , - variant: 'secondary' - } + // A bundled install's payload is immutable, so there is no installer to + // re-run: Repair is dropped, and "Reinstall the app" is offered only when + // the payload itself is what's broken — a port clash or timeout on a + // bundled install is not fixed by reinstalling. + const damagedPayload: boolean = bundled && classifyLocalBootFailure(boot.error) === 'installMissing' + + const fixAction: RecoveryAction | null = damagedPayload + ? { + key: 'reinstall', + label: copy.reinstallApp, + onClick: () => openExternalLink(DESKTOP_DOCS_URL), + icon: , + variant: 'secondary' + } + : bundled + ? null : { key: 'repair', label: copy.repairInstall, @@ -429,10 +439,10 @@ export function BootFailureOverlay() { icon: , variant: 'secondary', busy: 'repair' - }, - { ...settingsAction, variant: 'ghost' } - ] - hint = bundled ? copy.bundledReinstallHint : copy.repairHint + } + + actions = [retryAction, ...(fixAction ? [fixAction] : []), { ...settingsAction, variant: 'ghost' }] + hint = damagedPayload ? copy.bundledReinstallHint : bundled ? '' : copy.repairHint } if (view === 'connect') { @@ -505,7 +515,7 @@ export function BootFailureOverlay() { {copy.openLogs} -

{hint}

+ {hint ?

{hint}

: null} {logs.length > 0 ? (