feat(desktop): add startup and onboarding flow
Add phase-based desktop boot progress, fresh-install sandbox testing, and first-run provider credential onboarding so packaged installs can start cleanly without manual settings detours.
This commit is contained in:
@@ -3,6 +3,8 @@ 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 { DesktopBootOverlay } from '@/components/desktop-boot-overlay'
|
||||
import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay'
|
||||
import { Pane, PaneMain } from '@/components/pane-shell'
|
||||
import { useSkinCommand } from '@/themes/use-skin-command'
|
||||
|
||||
@@ -395,6 +397,17 @@ export function DesktopController() {
|
||||
|
||||
const overlays = (
|
||||
<>
|
||||
<DesktopBootOverlay />
|
||||
<DesktopOnboardingOverlay
|
||||
enabled={gatewayState === 'open'}
|
||||
onCompleted={() => {
|
||||
void refreshHermesConfig()
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
}}
|
||||
onOpenSettings={openSettings}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
|
||||
|
||||
{settingsOpen && (
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { HermesGateway } from '@/hermes'
|
||||
import {
|
||||
$desktopBoot,
|
||||
applyDesktopBootProgress,
|
||||
completeDesktopBoot,
|
||||
failDesktopBoot,
|
||||
setDesktopBootStep
|
||||
} from '@/store/boot'
|
||||
import { setGateway } from '@/store/gateway'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { setConnection, setGatewayState, setSessionsLoading } from '@/store/session'
|
||||
@@ -44,11 +51,24 @@ export function useGatewayBoot({
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop) {
|
||||
failDesktopBoot('Desktop IPC bridge is unavailable.')
|
||||
setSessionsLoading(false)
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}
|
||||
|
||||
const offBootProgress = desktop.onBootProgress(payload => applyDesktopBootProgress(payload))
|
||||
void desktop
|
||||
.getBootProgress()
|
||||
.then(snapshot => applyDesktopBootProgress(snapshot))
|
||||
.catch(() => undefined)
|
||||
|
||||
setDesktopBootStep({
|
||||
phase: 'renderer.boot',
|
||||
message: 'Starting desktop connection',
|
||||
progress: 6
|
||||
})
|
||||
|
||||
const gateway = new HermesGateway()
|
||||
callbacksRef.current.onGatewayReady(gateway)
|
||||
setGateway(gateway)
|
||||
@@ -57,6 +77,10 @@ export function useGatewayBoot({
|
||||
const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent(event))
|
||||
|
||||
const offExit = desktop.onBackendExit(() => {
|
||||
if ($desktopBoot.get().running || $desktopBoot.get().visible) {
|
||||
failDesktopBoot('Hermes background process exited during startup.')
|
||||
}
|
||||
|
||||
notify({
|
||||
kind: 'error',
|
||||
title: 'Backend stopped',
|
||||
@@ -73,6 +97,11 @@ export function useGatewayBoot({
|
||||
return
|
||||
}
|
||||
|
||||
setDesktopBootStep({
|
||||
phase: 'renderer.gateway.connect',
|
||||
message: 'Connecting live desktop gateway',
|
||||
progress: 95
|
||||
})
|
||||
callbacksRef.current.onConnectionReady(conn)
|
||||
setConnection(conn)
|
||||
await gateway.connect(conn.wsUrl)
|
||||
@@ -81,15 +110,28 @@ export function useGatewayBoot({
|
||||
return
|
||||
}
|
||||
|
||||
setDesktopBootStep({
|
||||
phase: 'renderer.config',
|
||||
message: 'Loading Hermes settings',
|
||||
progress: 97
|
||||
})
|
||||
await callbacksRef.current.refreshHermesConfig()
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setDesktopBootStep({
|
||||
phase: 'renderer.sessions',
|
||||
message: 'Loading recent sessions',
|
||||
progress: 99
|
||||
})
|
||||
await callbacksRef.current.refreshSessions()
|
||||
completeDesktopBoot()
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
failDesktopBoot(message)
|
||||
notifyError(err, 'Desktop boot failed')
|
||||
setSessionsLoading(false)
|
||||
}
|
||||
@@ -103,6 +145,7 @@ export function useGatewayBoot({
|
||||
offState()
|
||||
offEvent()
|
||||
offExit()
|
||||
offBootProgress()
|
||||
gateway.close()
|
||||
callbacksRef.current.onConnectionReady(null)
|
||||
callbacksRef.current.onGatewayReady(null)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $desktopBoot } from '@/store/boot'
|
||||
|
||||
export function DesktopBootOverlay() {
|
||||
const boot = useStore($desktopBoot)
|
||||
|
||||
if (!boot.visible) {
|
||||
return null
|
||||
}
|
||||
|
||||
const progress = Math.max(2, Math.min(100, Math.round(boot.progress)))
|
||||
const hasError = Boolean(boot.error)
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-busy={boot.running}
|
||||
aria-live={hasError ? 'assertive' : 'polite'}
|
||||
className="fixed inset-0 z-1400 grid place-items-center bg-background/88 backdrop-blur-sm"
|
||||
role="status"
|
||||
>
|
||||
<div className="w-[min(32rem,calc(100%-2rem))] rounded-xl border border-border/80 bg-card/95 p-5 shadow-xl shadow-black/8">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Loader
|
||||
aria-hidden="true"
|
||||
className={cn('size-7 text-primary/80', hasError && 'text-destructive')}
|
||||
role="presentation"
|
||||
strokeScale={0.8}
|
||||
type="rose-curve"
|
||||
/>
|
||||
<h2 className="truncate text-sm font-semibold text-foreground">Preparing Hermes Desktop</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 min-h-5 text-sm text-foreground">{boot.message}</p>
|
||||
{hasError ? <p className="mt-1 text-xs text-destructive">{boot.error}</p> : null}
|
||||
|
||||
<div className="mt-4 h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full bg-primary transition-[width] duration-300 ease-out',
|
||||
hasError && 'bg-destructive'
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between text-[0.68rem] text-muted-foreground">
|
||||
<span className="max-w-[78%] truncate font-mono">{boot.phase}</span>
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { getEnvVars, setEnvVar } from '@/hermes'
|
||||
import { AlertCircle, Check, ExternalLink, KeyRound, Loader2, Settings2, X } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
interface DesktopOnboardingOverlayProps {
|
||||
enabled: boolean
|
||||
onCompleted?: () => void
|
||||
onOpenSettings?: () => void
|
||||
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
interface SetupStatus {
|
||||
provider_configured?: boolean
|
||||
}
|
||||
|
||||
interface ProviderOption {
|
||||
key: string
|
||||
label: string
|
||||
helper: string
|
||||
}
|
||||
|
||||
const DISMISS_KEY = 'desktop.onboarding.dismissed_until_reload'
|
||||
|
||||
const PREFERRED_PROVIDER_KEYS: ProviderOption[] = [
|
||||
{
|
||||
key: 'OPENROUTER_API_KEY',
|
||||
label: 'OpenRouter',
|
||||
helper: 'Works with many hosted models and is a good default for new installs.'
|
||||
},
|
||||
{
|
||||
key: 'ANTHROPIC_API_KEY',
|
||||
label: 'Anthropic',
|
||||
helper: 'Use Claude models directly.'
|
||||
},
|
||||
{
|
||||
key: 'OPENAI_API_KEY',
|
||||
label: 'OpenAI',
|
||||
helper: 'Use OpenAI models directly.'
|
||||
},
|
||||
{
|
||||
key: 'GEMINI_API_KEY',
|
||||
label: 'Gemini',
|
||||
helper: 'Use Google Gemini models.'
|
||||
},
|
||||
{
|
||||
key: 'XAI_API_KEY',
|
||||
label: 'xAI',
|
||||
helper: 'Use Grok models.'
|
||||
},
|
||||
{
|
||||
key: 'OPENAI_BASE_URL',
|
||||
label: 'Local / OpenAI-compatible',
|
||||
helper: 'Use a local or self-hosted OpenAI-compatible endpoint. API key may not be required.'
|
||||
}
|
||||
]
|
||||
|
||||
function isDismissedForSession() {
|
||||
try {
|
||||
return window.sessionStorage.getItem(DISMISS_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function dismissForSession() {
|
||||
try {
|
||||
window.sessionStorage.setItem(DISMISS_KEY, '1')
|
||||
} catch {
|
||||
// Ignore storage failures; in-memory state still dismisses the overlay.
|
||||
}
|
||||
}
|
||||
|
||||
function optionLabel(option: ProviderOption, info?: EnvVarInfo) {
|
||||
return info?.description ? `${option.label} (${option.key})` : option.label
|
||||
}
|
||||
|
||||
export function DesktopOnboardingOverlay({
|
||||
enabled,
|
||||
onCompleted,
|
||||
onOpenSettings,
|
||||
requestGateway
|
||||
}: DesktopOnboardingOverlayProps) {
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [dismissed, setDismissed] = useState(isDismissedForSession)
|
||||
const [envVars, setEnvVars] = useState<Record<string, EnvVarInfo> | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [providerConfigured, setProviderConfigured] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [selectedKey, setSelectedKey] = useState(PREFERRED_PROVIDER_KEYS[0].key)
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || dismissed) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
async function checkSetup() {
|
||||
setChecking(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const [status, vars] = await Promise.all([requestGateway<SetupStatus>('setup.status'), getEnvVars()])
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setProviderConfigured(Boolean(status.provider_configured))
|
||||
setEnvVars(vars)
|
||||
|
||||
const firstAvailable = PREFERRED_PROVIDER_KEYS.find(option => vars[option.key])
|
||||
|
||||
if (firstAvailable) {
|
||||
setSelectedKey(current => (vars[current] ? current : firstAvailable.key))
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setProviderConfigured(false)
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void checkSetup()
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}, [dismissed, enabled, requestGateway])
|
||||
|
||||
const providerOptions = useMemo(
|
||||
() => PREFERRED_PROVIDER_KEYS.filter(option => !envVars || envVars[option.key]),
|
||||
[envVars]
|
||||
)
|
||||
|
||||
const selectedInfo = envVars?.[selectedKey]
|
||||
const selectedOption = providerOptions.find(option => option.key === selectedKey) ?? PREFERRED_PROVIDER_KEYS[0]
|
||||
const canSave = selectedKey === 'OPENAI_BASE_URL' ? value.trim().length > 0 : value.trim().length > 8
|
||||
|
||||
async function handleSave() {
|
||||
if (!canSave || saving) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await setEnvVar(selectedKey, value.trim())
|
||||
await requestGateway('reload.env').catch(() => undefined)
|
||||
const status = await requestGateway<SetupStatus>('setup.status')
|
||||
|
||||
if (!status.provider_configured) {
|
||||
setError('Credential was saved, but Hermes still does not see a configured provider.')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
notify({ kind: 'success', title: 'Hermes is ready', message: `${selectedKey} saved.` })
|
||||
setProviderConfigured(true)
|
||||
setValue('')
|
||||
onCompleted?.()
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to save ${selectedKey}`)
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDismiss() {
|
||||
dismissForSession()
|
||||
setDismissed(true)
|
||||
}
|
||||
|
||||
function handleOpenSettings() {
|
||||
handleDismiss()
|
||||
onOpenSettings?.()
|
||||
}
|
||||
|
||||
if (!enabled || dismissed || providerConfigured) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-1300 flex items-center justify-center bg-background/80 p-6 backdrop-blur-xl">
|
||||
<div className="w-full max-w-2xl overflow-hidden rounded-3xl border border-border bg-card/95 shadow-2xl">
|
||||
<div className="border-b border-border bg-muted/30 px-6 py-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<KeyRound className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Set up Hermes</h2>
|
||||
<p className="mt-1 max-w-xl text-sm leading-6 text-muted-foreground">
|
||||
Add one inference provider before starting your first chat. This writes to the current Hermes
|
||||
profile's `.env` file and takes effect immediately.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleDismiss} size="icon-sm" title="Configure later" variant="ghost">
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 p-6">
|
||||
{checking ? (
|
||||
<div className="flex items-center gap-2 rounded-2xl bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Checking provider setup...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Provider</label>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{providerOptions.map(option => (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-2xl border bg-background/60 p-3 text-left transition hover:bg-accent/50',
|
||||
selectedKey === option.key ? 'border-primary ring-2 ring-primary/20' : 'border-border'
|
||||
)}
|
||||
key={option.key}
|
||||
onClick={() => {
|
||||
setSelectedKey(option.key)
|
||||
setValue('')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">{optionLabel(option, envVars?.[option.key])}</span>
|
||||
{selectedKey === option.key ? <Check className="size-4 text-primary" /> : null}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{option.helper}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{selectedKey}
|
||||
</label>
|
||||
{selectedInfo?.url ? (
|
||||
<Button asChild size="xs" variant="ghost">
|
||||
<a href={selectedInfo.url} rel="noreferrer" target="_blank">
|
||||
Docs
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
className="font-mono"
|
||||
onChange={event => setValue(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
void handleSave()
|
||||
}
|
||||
}}
|
||||
placeholder={selectedKey === 'OPENAI_BASE_URL' ? 'http://127.0.0.1:8000/v1' : 'Paste API key'}
|
||||
type={selectedInfo?.is_password === false || selectedKey === 'OPENAI_BASE_URL' ? 'text' : 'password'}
|
||||
value={value}
|
||||
/>
|
||||
<p className="text-xs leading-5 text-muted-foreground">{selectedOption.helper}</p>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="flex gap-2 rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border pt-5">
|
||||
<Button onClick={handleOpenSettings} variant="outline">
|
||||
<Settings2 className="size-4" />
|
||||
Open full settings
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleDismiss} variant="ghost">
|
||||
Configure later
|
||||
</Button>
|
||||
<Button disabled={!canSave || saving} onClick={() => void handleSave()}>
|
||||
{saving ? <Loader2 className="size-4 animate-spin" /> : <KeyRound className="size-4" />}
|
||||
{saving ? 'Saving' : 'Save and continue'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Vendored
+12
@@ -4,6 +4,7 @@ declare global {
|
||||
interface Window {
|
||||
hermesDesktop: {
|
||||
getConnection: () => Promise<HermesConnection>
|
||||
getBootProgress: () => Promise<DesktopBootProgress>
|
||||
api: <T>(request: HermesApiRequest) => Promise<T>
|
||||
notify: (payload: HermesNotification) => Promise<boolean>
|
||||
requestMicrophoneAccess: () => Promise<boolean>
|
||||
@@ -25,6 +26,7 @@ declare global {
|
||||
onClosePreviewRequested?: (callback: () => void) => () => void
|
||||
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
|
||||
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
|
||||
onBootProgress: (callback: (payload: DesktopBootProgress) => void) => () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +39,16 @@ export interface HermesConnection {
|
||||
windowButtonPosition: { x: number; y: number } | null
|
||||
}
|
||||
|
||||
export interface DesktopBootProgress {
|
||||
error: string | null
|
||||
fakeMode: boolean
|
||||
message: string
|
||||
phase: string
|
||||
progress: number
|
||||
running: boolean
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface HermesApiRequest {
|
||||
path: string
|
||||
method?: string
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import type { DesktopBootProgress } from '@/global'
|
||||
|
||||
export interface DesktopBootState extends DesktopBootProgress {
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
const INITIAL_BOOT_STATE: DesktopBootState = {
|
||||
error: null,
|
||||
fakeMode: false,
|
||||
message: 'Starting Hermes Desktop…',
|
||||
phase: 'renderer.init',
|
||||
progress: 2,
|
||||
running: true,
|
||||
timestamp: Date.now(),
|
||||
visible: true
|
||||
}
|
||||
|
||||
export const $desktopBoot = atom<DesktopBootState>(INITIAL_BOOT_STATE)
|
||||
|
||||
function clampProgress(value: number) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(100, Math.round(value)))
|
||||
}
|
||||
|
||||
export function applyDesktopBootProgress(progress: DesktopBootProgress) {
|
||||
const current = $desktopBoot.get()
|
||||
const nextProgress = clampProgress(progress.progress)
|
||||
const mergedProgress = progress.running ? Math.max(current.progress, nextProgress) : nextProgress
|
||||
|
||||
$desktopBoot.set({
|
||||
...current,
|
||||
...progress,
|
||||
error: progress.error ?? null,
|
||||
progress: mergedProgress,
|
||||
visible: progress.running || mergedProgress < 100 || Boolean(progress.error)
|
||||
})
|
||||
}
|
||||
|
||||
export function setDesktopBootStep(step: {
|
||||
phase: string
|
||||
message: string
|
||||
progress: number
|
||||
running?: boolean
|
||||
fakeMode?: boolean
|
||||
error?: string | null
|
||||
}) {
|
||||
const current = $desktopBoot.get()
|
||||
applyDesktopBootProgress({
|
||||
error: step.error ?? null,
|
||||
fakeMode: step.fakeMode ?? current.fakeMode,
|
||||
message: step.message,
|
||||
phase: step.phase,
|
||||
progress: step.progress,
|
||||
running: step.running ?? true,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
export function completeDesktopBoot(message = 'Hermes Desktop is ready') {
|
||||
const current = $desktopBoot.get()
|
||||
$desktopBoot.set({
|
||||
...current,
|
||||
error: null,
|
||||
message,
|
||||
phase: 'renderer.ready',
|
||||
progress: 100,
|
||||
running: false,
|
||||
timestamp: Date.now(),
|
||||
visible: false
|
||||
})
|
||||
}
|
||||
|
||||
export function failDesktopBoot(message: string) {
|
||||
const current = $desktopBoot.get()
|
||||
$desktopBoot.set({
|
||||
...current,
|
||||
error: message,
|
||||
message: `Desktop boot failed: ${message}`,
|
||||
phase: 'renderer.error',
|
||||
progress: clampProgress(current.progress),
|
||||
running: false,
|
||||
timestamp: Date.now(),
|
||||
visible: true
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user