Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions apps/desktop/electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3148,6 +3148,23 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
}
return { ok: true }
})
ipcMain.handle('hermes:bootstrap:repair', async () => {
// Forceful repair: drop the bootstrap-complete marker so the next
// startHermes() re-runs the full installer (refreshing a broken/partial
// venv), and clear any latched failure + live connection. The renderer
// reloads afterwards to re-drive the boot flow from scratch.
rememberLog('[bootstrap] repair requested by renderer; clearing marker + latched failure')
try {
if (fileExists(BOOTSTRAP_COMPLETE_MARKER)) {
fs.rmSync(BOOTSTRAP_COMPLETE_MARKER, { force: true })
}
} catch (error) {
rememberLog(`[bootstrap] failed to remove marker during repair: ${error.message}`)
}
bootstrapFailure = null
resetHermesConnection()
return { ok: true }
})
ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState)
ipcMain.handle('hermes:bootstrap:get', async () => getBootstrapState())
ipcMain.handle('hermes:connection-config:get', async () => sanitizeDesktopConnectionConfig())
Expand Down Expand Up @@ -3303,6 +3320,21 @@ ipcMain.handle('hermes:openExternal', (_event, url) => {

ipcMain.handle('hermes:fetchLinkTitle', (_event, url) => fetchLinkTitle(url))

ipcMain.handle('hermes:logs:reveal', async () => {
try {
await fs.promises.mkdir(path.dirname(DESKTOP_LOG_PATH), { recursive: true })
if (!fileExists(DESKTOP_LOG_PATH)) {
await fs.promises.appendFile(DESKTOP_LOG_PATH, '')
}
shell.showItemInFolder(DESKTOP_LOG_PATH)
return { ok: true, path: DESKTOP_LOG_PATH }
} catch (error) {
return { ok: false, path: DESKTOP_LOG_PATH, error: error.message }
}
})

ipcMain.handle('hermes:logs:recent', async () => ({ path: DESKTOP_LOG_PATH, lines: hermesLog.slice(-200) }))

// Always-hidden noise (covers non-git projects too — gitignore would catch
// these anyway when present, but we want the same hygiene without one).
const FS_READDIR_HIDDEN = new Set([
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/electron/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
revealLogs: () => ipcRenderer.invoke('hermes:logs:reveal'),
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
terminal: {
Expand Down Expand Up @@ -88,6 +90,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
// reload mid-bootstrap.
getBootstrapState: () => ipcRenderer.invoke('hermes:bootstrap:get'),
resetBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:reset'),
repairBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:repair'),
onBootstrapEvent: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:bootstrap:event', listener)
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/app/desktop-controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query'
import { lazy, Suspense, useCallback, useEffect, useRef } from 'react'
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'

import { BootFailureOverlay } from '@/components/boot-failure-overlay'
import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay'
import { Pane, PaneMain } from '@/components/pane-shell'
Expand Down Expand Up @@ -484,6 +485,7 @@ export function DesktopController() {
/>
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
<UpdatesOverlay />
<BootFailureOverlay />

{settingsOpen && (
<Suspense fallback={null}>
Expand Down
15 changes: 14 additions & 1 deletion apps/desktop/src/app/settings/gateway-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'

import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { AlertCircle, Check, Globe, Loader2, Monitor } from '@/lib/icons'
import { AlertCircle, Check, FileText, Globe, Loader2, Monitor } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'

Expand Down Expand Up @@ -289,6 +289,19 @@ export function GatewaySettings() {
Save and reconnect
</Button>
</div>

<div className="mt-6 divide-y divide-border/40">
<ListRow
action={
<Button onClick={() => void window.hermesDesktop?.revealLogs()} variant="outline">
<FileText className="size-4" />
Open logs
</Button>
}
description="Reveal desktop.log in your file manager — useful when the gateway fails to start."
title="Diagnostics"
/>
</div>
</SettingsContent>
)
}
129 changes: 129 additions & 0 deletions apps/desktop/src/components/boot-failure-overlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'

import { Button } from '@/components/ui/button'
import { AlertTriangle, FileText, Loader2, RefreshCw, Wrench } from '@/lib/icons'
import { $desktopBoot } from '@/store/boot'
import { $desktopOnboarding } from '@/store/onboarding'

type BusyAction = 'local' | 'repair' | 'retry' | null

// Recovery surface for a hard boot failure (gateway never came up, backend
// exited during startup, bootstrap latched, …). Without this the app shell
// renders dead — "gateway offline", no composer, only a toast — with no way
// to retry, repair the install, switch the gateway, or find the logs.
export function BootFailureOverlay() {
const boot = useStore($desktopBoot)
const onboarding = useStore($desktopOnboarding)
const [busy, setBusy] = useState<BusyAction>(null)
const [logs, setLogs] = useState<string[]>([])
const [showLogs, setShowLogs] = useState(false)

const visible = Boolean(boot.error) && !boot.running
// While first-run onboarding owns the picker/flow we let it surface its own
// progress; the recovery overlay is for hard failures, which it covers via a
// higher z-index regardless of onboarding state.
const suppressed = onboarding.flow.status !== 'idle' && onboarding.flow.status !== 'error'

useEffect(() => {
if (!visible) {
return
}

void window.hermesDesktop
?.getRecentLogs()
.then(res => setLogs(res.lines ?? []))
.catch(() => undefined)
}, [visible])

if (!visible || suppressed) {
return null
}

const retry = async () => {
setBusy('retry')
await window.hermesDesktop?.resetBootstrap().catch(() => undefined)
window.location.reload()
}

const repair = async () => {
setBusy('repair')
await window.hermesDesktop?.repairBootstrap().catch(() => undefined)
window.location.reload()
}

const switchToLocalGateway = async () => {
setBusy('local')
// applyConnectionConfig reloads the window from the main process.
await window.hermesDesktop?.applyConnectionConfig({ mode: 'local' }).catch(() => undefined)
setBusy(null)
}

const openLogs = () => void window.hermesDesktop?.revealLogs().catch(() => undefined)

return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
<div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
<div className="flex items-start gap-3 border-b border-(--ui-stroke-tertiary) px-5 py-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10 text-destructive">
<AlertTriangle className="size-5" />
</div>
<div>
<h2 className="text-[0.9375rem] font-semibold tracking-tight">Hermes couldn't start</h2>
<p className="mt-1 text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
The background gateway didn't come up. Try one of the recovery steps below — nothing here deletes your
chats or settings.
</p>
</div>
</div>

<div className="grid gap-4 p-5">
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-xs text-destructive">
{boot.error}
</div>

<div className="grid gap-2">
<div className="flex flex-wrap gap-2">
<Button disabled={Boolean(busy)} onClick={() => void retry()}>
{busy === 'retry' ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
Retry
</Button>
<Button disabled={Boolean(busy)} onClick={() => void repair()} variant="outline">
{busy === 'repair' ? <Loader2 className="size-4 animate-spin" /> : <Wrench className="size-4" />}
Repair install
</Button>
<Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="outline">
{busy === 'local' ? <Loader2 className="size-4 animate-spin" /> : null}
Use local gateway
</Button>
<Button onClick={openLogs} variant="ghost">
<FileText className="size-4" />
Open logs
</Button>
</div>
<p className="text-xs text-muted-foreground">
Repair re-runs the installer and can take a few minutes on a fresh machine.
</p>
</div>

{logs.length > 0 ? (
<div className="grid gap-2">
<button
className="self-start text-xs font-medium text-muted-foreground transition hover:text-foreground"
onClick={() => setShowLogs(v => !v)}
type="button"
>
{showLogs ? 'Hide' : 'Show'} recent logs
</button>
{showLogs ? (
<pre className="max-h-48 overflow-auto rounded-2xl border border-border bg-secondary/30 p-3 font-mono text-[0.7rem] leading-4 text-muted-foreground">
{logs.slice(-40).join('')}
</pre>
) : null}
</div>
) : null}
</div>
</div>
</div>
)
}
15 changes: 1 addition & 14 deletions apps/desktop/src/components/desktop-onboarding-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,10 +200,6 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
const hasError = Boolean(boot.error)
const installing = boot.phase.startsWith('runtime.')

const resetToLocalGateway = async () => {
await window.hermesDesktop?.applyConnectionConfig({ mode: 'local' })
}

return (
<div className="grid gap-3" role="status">
<p className="text-sm text-muted-foreground">
Expand All @@ -224,16 +220,7 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
<span className="truncate">{boot.message}</span>
<span>{progress}%</span>
</div>
{hasError ? (
<div className="grid gap-3">
<p className="text-xs text-destructive">{boot.error}</p>
<div>
<Button onClick={() => void resetToLocalGateway()} size="sm" variant="outline">
Use local gateway
</Button>
</div>
</div>
) : null}
{hasError ? <p className="text-xs text-destructive">{boot.error}</p> : null}
</div>
)
}
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ declare global {
setPreviewShortcutActive?: (active: boolean) => void
openExternal: (url: string) => Promise<void>
fetchLinkTitle: (url: string) => Promise<string>
revealLogs: () => Promise<{ ok: boolean; path: string; error?: string }>
getRecentLogs: () => Promise<{ path: string; lines: string[] }>
readDir: (path: string) => Promise<HermesReadDirResult>
gitRoot?: (path: string) => Promise<string | null>
terminal: {
Expand All @@ -45,6 +47,7 @@ declare global {
onBootProgress: (callback: (payload: DesktopBootProgress) => void) => () => void
getBootstrapState: () => Promise<DesktopBootstrapState>
resetBootstrap: () => Promise<{ ok: boolean }>
repairBootstrap: () => Promise<{ ok: boolean }>
onBootstrapEvent: (callback: (payload: DesktopBootstrapEvent) => void) => () => void
getVersion: () => Promise<DesktopVersionInfo>
updates: {
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,17 @@ export function setEnvVar(key: string, value: string): Promise<{ ok: boolean }>
})
}

export function validateProviderCredential(
key: string,
value: string
): Promise<{ ok: boolean; reachable: boolean; message: string }> {
return window.hermesDesktop.api<{ ok: boolean; reachable: boolean; message: string }>({
path: '/api/providers/validate',
method: 'POST',
body: { key, value }
})
}

export function deleteEnvVar(key: string): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
path: '/api/env',
Expand Down
16 changes: 15 additions & 1 deletion apps/desktop/src/store/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
setEnvVar,
setModelAssignment,
startOAuthLogin,
submitOAuthCode
submitOAuthCode,
validateProviderCredential
} from '@/hermes'
import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { notify, notifyError } from '@/store/notifications'
Expand Down Expand Up @@ -577,6 +578,19 @@ export async function saveOnboardingApiKey(envKey: string, value: string, label:
return { ok: false, message: 'Enter a value first.' }
}

// Live-probe the credential BEFORE persisting so a mistyped key never lands
// in .env. A rejected key (reachable && !ok) hard-blocks; an unreachable
// probe (offline / provider down) falls through and saves with the usual
// runtime check, so we don't strand offline users.
try {
const probe = await validateProviderCredential(envKey, trimmed)
if (!probe.ok && probe.reachable) {
return { ok: false, message: probe.message || `That ${label} key was rejected.` }
}
} catch {
// Validation endpoint unavailable — don't block; fall through to save.
}

try {
await setEnvVar(envKey, trimmed)
let stillFailing = false
Expand Down
Loading
Loading