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:
@@ -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]
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user