desktop: offer 'Reinstall the app' only for a damaged bundled payload

On a bundled install the boot-failure overlay swapped Repair for Reinstall
for every local failure, so a port clash or a timeout told the user to
reinstall. Classify the electron 'runtime files are missing or damaged'
error as installMissing and offer Reinstall only then; other bundled
failures get Retry/settings/logs with no installer action, since Repair
is refused there anyway. When main refuses repair (stale bundled=false
snapshot) show the reinstall hint instead of the raw 'bundled-immutable'.
This commit is contained in:
ethernet
2026-09-21 18:41:17 -04:00
parent da32e8596a
commit 1bd4af6297
3 changed files with 70 additions and 43 deletions

View File

@@ -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]

View File

@@ -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(<BootFailureOverlay />)
@@ -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(<BootFailureOverlay />)
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(() =>

View File

@@ -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: <ExternalLink />,
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: <ExternalLink />,
variant: 'secondary'
}
: bundled
? null
: {
key: 'repair',
label: copy.repairInstall,
@@ -429,10 +439,10 @@ export function BootFailureOverlay() {
icon: <Wrench />,
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}
</Button>
</div>
<p className="text-xs text-muted-foreground">{hint}</p>
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
</div>
{logs.length > 0 ? (