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 ? (