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:
Brooklyn Nicholson 2026-05-07 22:33:44 -04:00
parent fc9d18b03f
commit 89d5ee4b10
17 changed files with 1056 additions and 145 deletions

View file

@ -196,6 +196,8 @@ jobs:
- name: Build desktop installers
shell: bash
env:
NODE_OPTIONS: --max-old-space-size=16384
run: |
set -euo pipefail
npm --prefix apps/desktop exec electron-builder -- \

View file

@ -33,10 +33,16 @@ HERMES_DESKTOP_HERMES_ROOT=/path/to/hermes-agent npm run dev
HERMES_DESKTOP_PYTHON=/path/to/python npm run dev
HERMES_DESKTOP_CWD=/path/to/project npm run dev
HERMES_DESKTOP_IGNORE_EXISTING=1 npm run dev
HERMES_DESKTOP_BOOT_FAKE=1 npm run dev
HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=900 npm run dev
```
`HERMES_DESKTOP_IGNORE_EXISTING=1` skips any `hermes` CLI already on `PATH`, which is useful when testing the bundled/runtime bootstrap path.
`HERMES_DESKTOP_BOOT_FAKE=1` adds deterministic per-phase delays to desktop startup so you can validate the startup overlay and progress bar. For convenience, `npm run dev:fake-boot` enables fake mode with defaults.
On a fresh Hermes profile, Desktop shows a first-run setup overlay after boot. The overlay saves the minimum required provider credential (for example `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, or `OPENAI_API_KEY`) to the active Hermes `.env`, reloads the backend env, and then lets the user continue without opening Settings manually.
## Dashboard Dev
Run the Python dashboard backend with embedded chat enabled:
@ -115,14 +121,20 @@ npm run test:desktop:all
npm run test:desktop:existing
npm run test:desktop:fresh
npm run test:desktop:dmg
npm run test:desktop:platforms
```
`test:desktop:existing` builds the packaged app and opens it normally. It should use an existing `hermes` CLI if one is on `PATH`, preserving the users real `~/.hermes` config.
`test:desktop:fresh` builds the packaged app, deletes the bundled desktop runtime, sets `HERMES_DESKTOP_IGNORE_EXISTING=1`, and launches the app through the bundled payload path. Use this repeatedly to test first-run bootstrap.
`test:desktop:fresh` builds the packaged app and launches it in a throwaway fresh-install sandbox. It sets `HERMES_DESKTOP_IGNORE_EXISTING=1`, points Electron `userData` at a temp dir, points `HERMES_HOME` at a temp dir, and launches through the bundled payload path without touching your real desktop runtime or `~/.hermes`.
`test:desktop:dmg` builds and opens the DMG.
`test:desktop:platforms` runs platform bootstrap-path assertions, including:
- existing vs bundled runtime path selection semantics
- WSL2 protection against Windows `.exe/.cmd/.bat/.ps1` overrides
- platform-specific bundled runtime import checks (`winpty` vs `ptyprocess`)
For fast reruns without rebuilding:
```bash

View file

@ -0,0 +1,30 @@
function isWslEnvironment(env = process.env, platform = process.platform) {
if (platform !== 'linux') return false
return Boolean(env.WSL_DISTRO_NAME || env.WSL_INTEROP)
}
function isWindowsBinaryPathInWsl(filePath, options = {}) {
const isWsl = options.isWsl ?? isWslEnvironment(options.env, options.platform)
if (!isWsl) return false
const normalized = String(filePath || '')
.replace(/\\/g, '/')
.toLowerCase()
return (
normalized.endsWith('.exe') ||
normalized.endsWith('.cmd') ||
normalized.endsWith('.bat') ||
normalized.endsWith('.ps1')
)
}
function bundledRuntimeImportCheck(platform = process.platform) {
return platform === 'win32' ? 'import fastapi, uvicorn, winpty' : 'import fastapi, uvicorn, ptyprocess'
}
module.exports = {
bundledRuntimeImportCheck,
isWindowsBinaryPathInWsl,
isWslEnvironment
}

View file

@ -0,0 +1,50 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
const {
bundledRuntimeImportCheck,
isWindowsBinaryPathInWsl,
isWslEnvironment
} = require('./bootstrap-platform.cjs')
test('isWslEnvironment detects WSL2 env vars on linux', () => {
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true)
assert.equal(isWslEnvironment({ WSL_INTEROP: '/run/WSL/123_interop' }, 'linux'), true)
assert.equal(isWslEnvironment({}, 'linux'), false)
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'darwin'), false)
})
test('isWindowsBinaryPathInWsl blocks Windows binary types on WSL', () => {
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/hermes.exe', { isWsl: true }), true)
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/hermes.cmd', { isWsl: true }), true)
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/hermes.bat', { isWsl: true }), true)
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/install.ps1', { isWsl: true }), true)
assert.equal(isWindowsBinaryPathInWsl('/usr/local/bin/hermes', { isWsl: true }), false)
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/hermes.exe', { isWsl: false }), false)
})
test('bundledRuntimeImportCheck selects platform-specific import checks', () => {
assert.equal(bundledRuntimeImportCheck('win32'), 'import fastapi, uvicorn, winpty')
assert.equal(bundledRuntimeImportCheck('darwin'), 'import fastapi, uvicorn, ptyprocess')
assert.equal(bundledRuntimeImportCheck('linux'), 'import fastapi, uvicorn, ptyprocess')
})
test('packaged electron entrypoints do not require unpackaged npm modules', () => {
const electronDir = __dirname
const entrypoints = ['main.cjs', 'preload.cjs', 'bootstrap-platform.cjs']
const allowedBareRequires = new Set(['electron'])
const requirePattern = /require\(['"]([^'"]+)['"]\)/g
for (const entrypoint of entrypoints) {
const source = fs.readFileSync(path.join(electronDir, entrypoint), 'utf8')
const bareRequires = Array.from(source.matchAll(requirePattern))
.map(match => match[1])
.filter(specifier => !specifier.startsWith('node:'))
.filter(specifier => !specifier.startsWith('.'))
.filter(specifier => !allowedBareRequires.has(specifier))
assert.deepEqual(bareRequires, [], `${entrypoint} has unpackaged runtime requires`)
}
})

View file

@ -19,6 +19,18 @@ const net = require('node:net')
const path = require('node:path')
const { fileURLToPath, pathToFileURL } = require('node:url')
const { spawn } = require('node:child_process')
const {
bundledRuntimeImportCheck,
isWindowsBinaryPathInWsl,
isWslEnvironment
} = require('./bootstrap-platform.cjs')
const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR
if (USER_DATA_OVERRIDE) {
const resolvedUserData = path.resolve(USER_DATA_OVERRIDE)
fs.mkdirSync(resolvedUserData, { recursive: true })
app.setPath('userData', resolvedUserData)
}
const PORT_FLOOR = 9120
const PORT_CEILING = 9199
@ -26,6 +38,7 @@ const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER
const IS_PACKAGED = app.isPackaged
const IS_MAC = process.platform === 'darwin'
const IS_WINDOWS = process.platform === 'win32'
const IS_WSL = isWslEnvironment()
const APP_ROOT = app.getAppPath()
const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..')
const BUNDLED_HERMES_ROOT = path.join(process.resourcesPath, 'hermes-agent')
@ -34,6 +47,12 @@ const BUNDLED_VENV_MARKER = path.join(BUNDLED_VENV_ROOT, '.hermes-desktop-runtim
const DESKTOP_LOG_PATH = path.join(app.getPath('userData'), 'desktop.log')
const DESKTOP_LOG_FLUSH_MS = 120
const DESKTOP_LOG_BUFFER_MAX_CHARS = 64 * 1024
const BOOT_FAKE_MODE = process.env.HERMES_DESKTOP_BOOT_FAKE === '1'
const BOOT_FAKE_STEP_MS = (() => {
const raw = Number.parseInt(String(process.env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS || ''), 10)
if (!Number.isFinite(raw) || raw <= 0) return 650
return Math.max(120, raw)
})()
const RUNTIME_SCHEMA_VERSION = 3
const BUNDLED_RUNTIME_REQUIREMENTS = [
'openai>=2.21.0,<3',
@ -59,6 +78,7 @@ const BUNDLED_RUNTIME_REQUIREMENTS = [
'uvicorn[standard]>=0.24.0,<1',
IS_WINDOWS ? 'pywinpty>=2.0.0,<3' : 'ptyprocess>=0.7.0,<1'
]
const BUNDLED_RUNTIME_IMPORT_CHECK = bundledRuntimeImportCheck()
const APP_NAME = 'Hermes'
const TITLEBAR_HEIGHT = 34
const MACOS_TRAFFIC_LIGHTS_HEIGHT = 14
@ -191,6 +211,15 @@ let previewShortcutActive = false
let desktopLogBuffer = ''
let desktopLogFlushTimer = null
let desktopLogFlushPromise = Promise.resolve()
let bootProgressState = {
error: null,
fakeMode: BOOT_FAKE_MODE,
message: 'Waiting to start Hermes backend',
phase: 'idle',
progress: 0,
running: false,
timestamp: Date.now()
}
function flushDesktopLogBufferSync() {
if (!desktopLogBuffer) return
@ -254,6 +283,58 @@ function rememberLog(chunk) {
scheduleDesktopLogFlush()
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
function clampBootProgress(value) {
const numeric = Number(value)
if (!Number.isFinite(numeric)) return 0
return Math.max(0, Math.min(100, Math.round(numeric)))
}
function broadcastBootProgress() {
if (!mainWindow || mainWindow.isDestroyed()) return
const { webContents } = mainWindow
if (!webContents || webContents.isDestroyed()) return
webContents.send('hermes:boot-progress', bootProgressState)
}
function updateBootProgress(update, options = {}) {
const nextProgressRaw =
typeof update.progress === 'number' ? clampBootProgress(update.progress) : bootProgressState.progress
const nextProgress = options.allowDecrease ? nextProgressRaw : Math.max(bootProgressState.progress, nextProgressRaw)
bootProgressState = {
...bootProgressState,
...update,
error: update.error === undefined ? bootProgressState.error : update.error,
fakeMode: BOOT_FAKE_MODE || Boolean(update.fakeMode),
progress: nextProgress,
timestamp: Date.now()
}
if (update.message) {
rememberLog(`[boot] ${update.message}`)
}
broadcastBootProgress()
}
async function advanceBootProgress(phase, message, progress) {
updateBootProgress({
phase,
message,
progress,
running: true,
error: null
})
if (BOOT_FAKE_MODE) {
await sleep(BOOT_FAKE_STEP_MS)
}
}
function fileExists(filePath) {
try {
return fs.statSync(filePath).isFile()
@ -278,7 +359,9 @@ function findOnPath(command) {
if (!command) return null
if (path.isAbsolute(command) || command.includes(path.sep) || (IS_WINDOWS && command.includes('/'))) {
return fileExists(command) ? command : null
if (!fileExists(command)) return null
if (isWindowsBinaryPathInWsl(command, { isWsl: IS_WSL })) return null
return command
}
const pathEntries = String(process.env.PATH || '')
@ -447,9 +530,22 @@ function resolveHermesBackend(dashboardArgs) {
}
if (process.env.HERMES_DESKTOP_IGNORE_EXISTING !== '1') {
const hermesCommand = process.env.HERMES_DESKTOP_HERMES
? findOnPath(process.env.HERMES_DESKTOP_HERMES) || process.env.HERMES_DESKTOP_HERMES
: findOnPath('hermes')
let hermesCommand = null
const hermesOverride = process.env.HERMES_DESKTOP_HERMES
if (hermesOverride) {
const resolvedOverride = findOnPath(hermesOverride)
if (resolvedOverride) {
hermesCommand = resolvedOverride
} else if (!isWindowsBinaryPathInWsl(hermesOverride, { isWsl: IS_WSL })) {
hermesCommand = hermesOverride
} else {
rememberLog(`Ignoring Windows Hermes override under WSL: ${hermesOverride}`)
}
} else {
hermesCommand = findOnPath('hermes')
}
if (hermesCommand) {
return {
label: `existing Hermes CLI at ${hermesCommand}`,
@ -490,7 +586,10 @@ function resolveHermesBackend(dashboardArgs) {
}
async function ensureBundledRuntime(backend) {
if (!backend.bootstrap) return backend
if (!backend.bootstrap) {
await advanceBootProgress('runtime.external', `Using ${backend.label}`, 32)
return backend
}
const sourceVersion = readJson(path.join(backend.root, 'package.json'))?.version || app.getVersion()
const marker = readJson(BUNDLED_VENV_MARKER)
@ -503,6 +602,7 @@ async function ensureBundledRuntime(backend) {
(await hasBundledRuntimeImports(venvPython))
if (runtimeReady) {
await advanceBootProgress('runtime.ready', 'Reusing bundled Hermes runtime', 58)
backend.command = venvPython
backend.label = `${backend.label} runtime at ${BUNDLED_VENV_ROOT}`
return backend
@ -513,13 +613,16 @@ async function ensureBundledRuntime(backend) {
throw new Error('Python 3.11+ is required to bootstrap the bundled Hermes runtime.')
}
await advanceBootProgress('runtime.prepare', 'Preparing bundled Hermes runtime', 42)
rememberLog(`Preparing bundled Hermes runtime in ${BUNDLED_VENV_ROOT}`)
fs.mkdirSync(BUNDLED_VENV_ROOT, { recursive: true })
if (!fileExists(venvPython)) {
await advanceBootProgress('runtime.venv', 'Creating desktop runtime virtual environment', 50)
await runProcess(systemPython, ['-m', 'venv', BUNDLED_VENV_ROOT])
}
await advanceBootProgress('runtime.dependencies', 'Installing desktop runtime dependencies', 66)
await runProcess(venvPython, [
'-m',
'pip',
@ -530,7 +633,8 @@ async function ensureBundledRuntime(backend) {
...BUNDLED_RUNTIME_REQUIREMENTS
])
await runProcess(venvPython, ['-c', 'import fastapi, uvicorn, ptyprocess'])
await advanceBootProgress('runtime.verify', 'Validating bundled runtime dependencies', 78)
await runProcess(venvPython, ['-c', BUNDLED_RUNTIME_IMPORT_CHECK])
fs.writeFileSync(
BUNDLED_VENV_MARKER,
@ -547,12 +651,19 @@ async function ensureBundledRuntime(backend) {
backend.command = venvPython
backend.label = `${backend.label} runtime at ${BUNDLED_VENV_ROOT}`
updateBootProgress({
phase: 'runtime.ready',
message: 'Bundled runtime is ready',
progress: 82,
running: true,
error: null
})
return backend
}
async function hasBundledRuntimeImports(python) {
try {
await runProcess(python, ['-c', 'import fastapi, uvicorn, ptyprocess'])
await runProcess(python, ['-c', BUNDLED_RUNTIME_IMPORT_CHECK])
return true
} catch {
rememberLog('Bundled Hermes runtime is missing required dashboard dependencies; reinstalling.')
@ -1149,11 +1260,19 @@ function resolveRemoteBackend() {
async function startHermes() {
if (connectionPromise) return connectionPromise
const remote = resolveRemoteBackend()
if (remote) {
connectionPromise = (async () => {
rememberLog(`Using remote Hermes backend at ${remote.baseUrl}`)
connectionPromise = (async () => {
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
const remote = resolveRemoteBackend()
if (remote) {
await advanceBootProgress('backend.remote', `Connecting to remote Hermes backend at ${remote.baseUrl}`, 24)
await waitForHermes(remote.baseUrl, remote.token)
updateBootProgress({
phase: 'backend.ready',
message: 'Remote Hermes backend is ready',
progress: 94,
running: true,
error: null
})
return {
baseUrl: remote.baseUrl,
token: remote.token,
@ -1161,21 +1280,18 @@ async function startHermes() {
logs: hermesLog.slice(-80),
windowButtonPosition: getWindowButtonPosition()
}
})().catch(error => {
connectionPromise = null
throw error
})
return connectionPromise
}
}
connectionPromise = (async () => {
await advanceBootProgress('backend.port', 'Finding an open local port', 16)
const port = await pickPort()
const token = crypto.randomBytes(32).toString('base64url')
const dashboardArgs = ['dashboard', '--no-open', '--tui', '--host', '127.0.0.1', '--port', String(port)]
await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28)
const backend = await ensureBundledRuntime(resolveHermesBackend(dashboardArgs))
const hermesCwd = resolveHermesCwd()
const webDist = resolveWebDist()
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
rememberLog(`Starting Hermes backend via ${backend.label}`)
hermesProcess = spawn(backend.command, backend.args, {
@ -1200,6 +1316,15 @@ async function startHermes() {
})
hermesProcess.once('error', error => {
rememberLog(`Hermes backend failed to start: ${error.message}`)
updateBootProgress(
{
error: error.message,
message: `Hermes backend failed to start: ${error.message}`,
phase: 'backend.error',
running: false
},
{ allowDecrease: true }
)
hermesProcess = null
connectionPromise = null
sendBackendExit({ code: null, signal: null, error: error.message })
@ -1211,6 +1336,16 @@ async function startHermes() {
connectionPromise = null
sendBackendExit({ code, signal })
if (!backendReady) {
const message = `Hermes dashboard exited before it became ready (${signal || code}).`
updateBootProgress(
{
error: message,
message,
phase: 'backend.error',
running: false
},
{ allowDecrease: true }
)
rejectBackendStart?.(
new Error(
`Hermes dashboard exited before it became ready (${signal || code}). Log: ${DESKTOP_LOG_PATH}\n${recentHermesLog()}`
@ -1220,8 +1355,16 @@ async function startHermes() {
})
const baseUrl = `http://127.0.0.1:${port}`
await advanceBootProgress('backend.wait', 'Waiting for Hermes dashboard to become ready', 90)
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
backendReady = true
updateBootProgress({
phase: 'backend.ready',
message: 'Hermes backend is ready. Finalizing desktop startup',
progress: 94,
running: true,
error: null
})
return {
baseUrl,
@ -1230,7 +1373,20 @@ async function startHermes() {
logs: hermesLog.slice(-80),
windowButtonPosition: getWindowButtonPosition()
}
})()
})().catch(error => {
const message = error instanceof Error ? error.message : String(error)
updateBootProgress(
{
error: message,
message: `Desktop boot failed: ${message}`,
phase: 'backend.error',
running: false
},
{ allowDecrease: true }
)
connectionPromise = null
throw error
})
return connectionPromise
}
@ -1277,11 +1433,13 @@ function createWindow() {
}
mainWindow.webContents.once('did-finish-load', () => {
broadcastBootProgress()
startHermes().catch(error => rememberLog(error.stack || error.message))
})
}
ipcMain.handle('hermes:connection', async () => startHermes())
ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState)
ipcMain.on('hermes:previewShortcutActive', (_event, active) => {
previewShortcutActive = Boolean(active)
@ -1400,12 +1558,6 @@ ipcMain.handle('hermes:openExternal', (_event, url) => shell.openExternal(url))
// these anyway when present, but we want the same hygiene without one).
const FS_READDIR_HIDDEN = new Set(['.git', '.hg', '.svn', 'node_modules', '__pycache__', '.next', '.venv', 'venv'])
const ignore = require('ignore')
// Cache one Ignore instance per .gitignore path keyed by mtime so edits
// invalidate automatically without us having to wire a watcher.
const gitignoreCache = new Map() // gitignorePath → { mtime: number, ig: Ignore, base: string }
function findGitRoot(start) {
let dir = start
@ -1430,84 +1582,6 @@ function findGitRoot(start) {
return null
}
function getGitignoreFile(giPath) {
let stat = null
try {
stat = fs.statSync(giPath)
} catch {
return null
}
if (!stat.isFile()) {
return null
}
const cached = gitignoreCache.get(giPath)
if (cached && cached.mtime === stat.mtimeMs) {
return cached
}
try {
const entry = {
base: path.dirname(giPath),
ig: ignore().add(fs.readFileSync(giPath, 'utf8')),
mtime: stat.mtimeMs
}
gitignoreCache.set(giPath, entry)
return entry
} catch {
return null
}
}
function gitignoreRulesFor(root, dir) {
const rules = []
const rel = path.relative(root, dir)
const dirs = [root]
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
const parts = rel.split(path.sep).filter(Boolean)
let current = root
for (const part of parts) {
current = path.join(current, part)
dirs.push(current)
}
}
for (const ruleDir of dirs) {
const rule = getGitignoreFile(path.join(ruleDir, '.gitignore'))
if (rule) {
rules.push(rule)
}
}
return rules
}
function ignoredByRules(rules, abs, isDirectory) {
for (const rule of rules) {
const rel = path.relative(rule.base, abs)
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
continue
}
const probe = `${rel.split(path.sep).join('/')}${isDirectory ? '/' : ''}`
if (rule.ig.ignores(probe)) {
return true
}
}
return false
}
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => {
const resolved = path.resolve(String(dirPath || ''))
@ -1517,8 +1591,6 @@ ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => {
try {
const dirents = await fs.promises.readdir(resolved, { withFileTypes: true })
const root = findGitRoot(resolved)
const gitignoreRules = root ? gitignoreRulesFor(root, resolved) : []
const entries = dirents
.filter(d => {
@ -1526,14 +1598,6 @@ ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => {
return false
}
if (gitignoreRules.length > 0) {
const abs = path.join(resolved, d.name)
if (ignoredByRules(gitignoreRules, abs, d.isDirectory())) {
return false
}
}
return true
})
.map(d => ({ name: d.name, path: path.join(resolved, d.name), isDirectory: d.isDirectory() }))

View file

@ -2,6 +2,7 @@ const { contextBridge, ipcRenderer, webUtils } = require('electron')
contextBridge.exposeInMainWorld('hermesDesktop', {
getConnection: () => ipcRenderer.invoke('hermes:connection'),
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
api: request => ipcRenderer.invoke('hermes:api', request),
notify: payload => ipcRenderer.invoke('hermes:notify', payload),
requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'),
@ -40,5 +41,10 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:backend-exit', listener)
return () => ipcRenderer.removeListener('hermes:backend-exit', listener)
},
onBootProgress: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:boot-progress', listener)
return () => ipcRenderer.removeListener('hermes:boot-progress', listener)
}
})

View file

@ -16,20 +16,24 @@
"@nanostores/react": "^1.1.0",
"@radix-ui/react-slot": "^1.2.4",
"@streamdown/code": "^1.1.1",
"@tabler/icons-react": "^3.41.1",
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-query": "^5.100.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"ignore": "^7.0.5",
"liquid-glass-react": "^1.1.1",
"lucide-react": "^0.577.0",
"nanostores": "^1.3.0",
"radix-ui": "^1.4.3",
"react": "^19.2.5",
"react-arborist": "^3.5.0",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2",
"react-shiki": "^0.9.3",
"shiki": "^4.0.2",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
"tw-shimmer": "^0.4.11",
@ -5529,6 +5533,21 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@react-dnd/asap": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz",
"integrity": "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg=="
},
"node_modules/@react-dnd/invariant": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz",
"integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw=="
},
"node_modules/@react-dnd/shallowequal": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz",
"integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg=="
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
@ -5999,6 +6018,30 @@
"node": ">=10"
}
},
"node_modules/@tabler/icons": {
"version": "3.43.0",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.43.0.tgz",
"integrity": "sha512-qXwS17Op9jqr3Asvu31fejyw8+OnRDKH7oR8nQXyUgW1pI44ET8OKG9kssy+XIvvAIyej6gZdGmviNUn1VMfPw==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
}
},
"node_modules/@tabler/icons-react": {
"version": "3.43.0",
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.43.0.tgz",
"integrity": "sha512-rXUuCQEeRbEk3lJxs3gwzdtaaITSwc/JUbp+AkqsGff5uBpzZw7eKPDk53xKoKLyjrbj82Ai4GuVG0kO89Jf5g==",
"dependencies": {
"@tabler/icons": "3.43.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
},
"peerDependencies": {
"react": ">= 16"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz",
@ -9467,6 +9510,24 @@
"node": ">=8"
}
},
"node_modules/dnd-core": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz",
"integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==",
"dependencies": {
"@react-dnd/asap": "^4.0.0",
"@react-dnd/invariant": "^2.0.0",
"redux": "^4.1.1"
}
},
"node_modules/dnd-core/node_modules/redux": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
"dependencies": {
"@babel/runtime": "^7.9.2"
}
},
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@ -10498,7 +10559,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-json-stable-stringify": {
@ -11325,6 +11385,19 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"dependencies": {
"react-is": "^16.7.0"
}
},
"node_modules/hoist-non-react-statics/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/hosted-git-info": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
@ -11496,7 +11569,6 @@
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
@ -13092,6 +13164,11 @@
"dev": true,
"license": "CC0-1.0"
},
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
},
"node_modules/mermaid": {
"version": "11.14.0",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz",
@ -14796,6 +14873,59 @@
"node": ">=0.10.0"
}
},
"node_modules/react-arborist": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-3.5.0.tgz",
"integrity": "sha512-FdXOICSt7P2h+Pxin1ULN02b4qrXJznNcshgwwWVtuYMLWSJcD245PQ4HOSj/Lr2T1uEegmnEm5Lbns2hUUsqg==",
"dependencies": {
"react-dnd": "^14.0.3",
"react-dnd-html5-backend": "^14.0.3",
"react-window": "^1.8.11",
"redux": "^5.0.0",
"use-sync-external-store": "^1.2.0"
},
"peerDependencies": {
"react": ">= 16.14",
"react-dom": ">= 16.14"
}
},
"node_modules/react-dnd": {
"version": "14.0.5",
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz",
"integrity": "sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==",
"dependencies": {
"@react-dnd/invariant": "^2.0.0",
"@react-dnd/shallowequal": "^2.0.0",
"dnd-core": "14.0.1",
"fast-deep-equal": "^3.1.3",
"hoist-non-react-statics": "^3.3.2"
},
"peerDependencies": {
"@types/hoist-non-react-statics": ">= 3.3.1",
"@types/node": ">= 12",
"@types/react": ">= 16",
"react": ">= 16.14"
},
"peerDependenciesMeta": {
"@types/hoist-non-react-statics": {
"optional": true
},
"@types/node": {
"optional": true
},
"@types/react": {
"optional": true
}
}
},
"node_modules/react-dnd-html5-backend": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz",
"integrity": "sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==",
"dependencies": {
"dnd-core": "14.0.1"
}
},
"node_modules/react-dom": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
@ -14967,6 +15097,22 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-window": {
"version": "1.8.11",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz",
"integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==",
"dependencies": {
"@babel/runtime": "^7.0.0",
"memoize-one": ">=3.1.1 <6"
},
"engines": {
"node": ">8.0.0"
},
"peerDependencies": {
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/read-binary-file-arch": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
@ -14980,6 +15126,11 @@
"read-binary-file-arch": "cli.js"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",

View file

@ -9,6 +9,7 @@
"main": "electron/main.cjs",
"scripts": {
"dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"",
"dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev",
"dev:renderer": "vite --host 127.0.0.1 --port 5174",
"dev:electron": "wait-on http://127.0.0.1:5174 && cross-env HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"profile:main": "wait-on http://127.0.0.1:5174 && cross-env HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
@ -16,19 +17,21 @@
"start": "npm run build && electron .",
"build": "tsc -b && vite build",
"stage:hermes": "node scripts/stage-hermes-payload.mjs",
"pack": "npm run build && npm run stage:hermes && electron-builder --dir",
"dist": "npm run build && npm run stage:hermes && electron-builder",
"dist:mac": "npm run build && npm run stage:hermes && electron-builder --mac",
"dist:mac:dmg": "npm run build && npm run stage:hermes && electron-builder --mac dmg",
"dist:mac:zip": "npm run build && npm run stage:hermes && electron-builder --mac zip",
"dist:win": "npm run build && npm run stage:hermes && electron-builder --win",
"dist:win:msi": "npm run build && npm run stage:hermes && electron-builder --win msi",
"dist:win:nsis": "npm run build && npm run stage:hermes && electron-builder --win nsis",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder",
"pack": "npm run build && npm run stage:hermes && npm run builder -- --dir",
"dist": "npm run build && npm run stage:hermes && npm run builder",
"dist:mac": "npm run build && npm run stage:hermes && npm run builder -- --mac",
"dist:mac:dmg": "npm run build && npm run stage:hermes && npm run builder -- --mac dmg",
"dist:mac:zip": "npm run build && npm run stage:hermes && npm run builder -- --mac zip",
"dist:win": "npm run build && npm run stage:hermes && npm run builder -- --win",
"dist:win:msi": "npm run build && npm run stage:hermes && npm run builder -- --win msi",
"dist:win:nsis": "npm run build && npm run stage:hermes && npm run builder -- --win nsis",
"test:desktop": "node scripts/test-desktop.mjs",
"test:desktop:all": "node scripts/test-desktop.mjs all",
"test:desktop:dmg": "node scripts/test-desktop.mjs dmg",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs",
"type-check": "tsc -b",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@ -114,6 +117,7 @@
"public/**",
"package.json"
],
"beforeBuild": "scripts/before-build.cjs",
"extraResources": [
{
"from": "build/hermes-agent",

View file

@ -0,0 +1,9 @@
/**
* Desktop bundles ship precompiled renderer assets and a staged Hermes payload
* from extraResources. Returning false here tells electron-builder to skip the
* node_modules collector/install step, which avoids workspace dependency graph
* explosions and keeps packaging deterministic across environments.
*/
module.exports = async function beforeBuild() {
return false
}

View file

@ -3,6 +3,7 @@ import os from 'node:os'
import path from 'node:path'
import { spawn, spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { listPackage } from '@electron/asar'
const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(DESKTOP_ROOT, 'package.json'), 'utf8'))
@ -11,9 +12,9 @@ const ARCH = process.arch === 'arm64' ? 'arm64' : 'x64'
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
const APP_PATH = path.join(RELEASE_ROOT, `mac-${ARCH}`, 'Hermes.app')
const APP_BIN = path.join(APP_PATH, 'Contents', 'MacOS', 'Hermes')
const DMG_PATH = path.join(RELEASE_ROOT, `Hermes-${PACKAGE_JSON.version}-${ARCH}.dmg`)
const USER_DATA = path.join(os.homedir(), 'Library', 'Application Support', 'Hermes')
const RUNTIME_ROOT = path.join(USER_DATA, 'hermes-runtime')
const FRESH_SANDBOX_ROOT = path.join(os.tmpdir(), 'hermes-desktop-fresh-install')
function die(message) {
console.error(`\n${message}`)
@ -46,6 +47,30 @@ function exists(target) {
return fs.existsSync(target)
}
function resolveDmgPath() {
if (!exists(RELEASE_ROOT)) {
return path.join(RELEASE_ROOT, `Hermes-${PACKAGE_JSON.version}-${ARCH}.dmg`)
}
const prefix = `Hermes-${PACKAGE_JSON.version}`
const candidates = fs
.readdirSync(RELEASE_ROOT)
.filter(name => name.endsWith('.dmg'))
.filter(name => name.startsWith(prefix))
.filter(name => name.includes(ARCH))
.sort((a, b) => {
const aMtime = fs.statSync(path.join(RELEASE_ROOT, a)).mtimeMs
const bMtime = fs.statSync(path.join(RELEASE_ROOT, b)).mtimeMs
return bMtime - aMtime
})
if (candidates.length > 0) {
return path.join(RELEASE_ROOT, candidates[0])
}
return path.join(RELEASE_ROOT, `Hermes-${PACKAGE_JSON.version}-${ARCH}.dmg`)
}
function ensureMac() {
if (process.platform !== 'darwin') {
die('Desktop launch tests are macOS-only from this script.')
@ -61,7 +86,7 @@ function ensurePackagedApp() {
}
function ensureDmg() {
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && exists(DMG_PATH)) {
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && exists(resolveDmgPath())) {
return
}
@ -77,11 +102,12 @@ function openApp() {
}
function openDmg() {
if (!exists(DMG_PATH)) {
die(`Missing DMG: ${DMG_PATH}`)
const dmgPath = resolveDmgPath()
if (!exists(dmgPath)) {
die(`Missing DMG: ${dmgPath}`)
}
run('open', [DMG_PATH])
run('open', [dmgPath])
}
function launchFresh() {
@ -89,17 +115,27 @@ function launchFresh() {
die(`Missing app executable: ${APP_BIN}`)
}
fs.rmSync(RUNTIME_ROOT, { force: true, recursive: true })
const python = output('which', ['python3'])
if (!python) {
die('python3 is required for fresh bundled-runtime bootstrap.')
}
const sandbox = fs.mkdtempSync(`${FRESH_SANDBOX_ROOT}-`)
const userDataDir = path.join(sandbox, 'electron-user-data')
const hermesHome = path.join(sandbox, 'hermes-home')
const cwd = path.join(sandbox, 'workspace')
fs.mkdirSync(userDataDir, { recursive: true })
fs.mkdirSync(hermesHome, { recursive: true })
fs.mkdirSync(cwd, { recursive: true })
const env = {
...process.env,
HERMES_DESKTOP_CWD: cwd,
HERMES_DESKTOP_IGNORE_EXISTING: '1',
HERMES_DESKTOP_TEST_MODE: 'fresh-bundled-runtime'
HERMES_DESKTOP_TEST_MODE: 'fresh-install',
HERMES_DESKTOP_USER_DATA_DIR: userDataDir,
HERMES_HOME: hermesHome
}
delete env.HERMES_DESKTOP_HERMES
delete env.HERMES_DESKTOP_HERMES_ROOT
@ -111,13 +147,22 @@ function launchFresh() {
stdio: 'ignore'
})
child.unref()
console.log('\nFresh install sandbox:')
console.log(` root: ${sandbox}`)
console.log(` electron userData: ${userDataDir}`)
console.log(` HERMES_HOME: ${hermesHome}`)
console.log(` cwd: ${cwd}`)
return { runtimeRoot: path.join(userDataDir, 'hermes-runtime') }
}
function validateBundle() {
const appAsar = path.join(APP_PATH, 'Contents', 'Resources', 'app.asar')
const unpackedIndex = path.join(APP_PATH, 'Contents', 'Resources', 'app.asar.unpacked', 'dist', 'index.html')
const required = [
APP_BIN,
path.join(APP_PATH, 'Contents', 'Resources', 'hermes-agent', 'hermes_cli', 'main.py'),
path.join(APP_PATH, 'Contents', 'Resources', 'app.asar.unpacked', 'dist', 'index.html')
path.join(APP_PATH, 'Contents', 'Resources', 'hermes-agent', 'hermes_cli', 'main.py')
]
for (const target of required) {
@ -125,19 +170,34 @@ function validateBundle() {
die(`Missing packaged payload file: ${target}`)
}
}
if (exists(unpackedIndex)) {
return
}
if (!exists(appAsar)) {
die(`Missing renderer payload: neither ${unpackedIndex} nor ${appAsar} exists`)
}
const files = listPackage(appAsar)
if (!files.includes('/dist/index.html') && !files.includes('dist/index.html')) {
die(`Missing renderer payload file in app.asar: ${appAsar} (expected dist/index.html)`)
}
}
function printArtifacts() {
function printArtifacts(options = {}) {
const runtimeRoot = options.runtimeRoot || RUNTIME_ROOT
console.log('\nDesktop artifacts:')
console.log(` app: ${APP_PATH}`)
console.log(` dmg: ${DMG_PATH}`)
console.log(` runtime: ${RUNTIME_ROOT}`)
console.log(` dmg: ${resolveDmgPath()}`)
console.log(` runtime: ${runtimeRoot}`)
}
function help() {
console.log(`Usage:
npm run test:desktop:existing # build packaged app, launch with normal PATH/existing Hermes
npm run test:desktop:fresh # build packaged app, delete bundled runtime, hide existing Hermes, launch
npm run test:desktop:fresh # build packaged app, launch with temp userData + HERMES_HOME
npm run test:desktop:dmg # build DMG and open it
npm run test:desktop:all # build DMG, validate app payload, print paths
@ -156,8 +216,7 @@ if (MODE === 'existing') {
} else if (MODE === 'fresh') {
ensurePackagedApp()
validateBundle()
launchFresh()
printArtifacts()
printArtifacts(launchFresh())
} else if (MODE === 'dmg') {
ensureDmg()
openDmg()

View file

@ -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 && (

View file

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

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

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

View file

@ -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
})
}

View file

@ -7,12 +7,11 @@ export default defineConfig({
base: './',
plugins: [react(), tailwindcss()],
build: {
rollupOptions: {
// Keep desktop packaging stable: Shiki ships many dynamic chunks by
// default, and electron-builder can OOM scanning thousands of files.
rolldownOptions: {
output: {
manualChunks: {
shiki: ['react-shiki', 'shiki'],
streamdown: ['@assistant-ui/react-streamdown', '@streamdown/code', 'streamdown']
}
codeSplitting: false
}
}
},