mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-15 09:21:36 +00:00
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:
parent
fc9d18b03f
commit
89d5ee4b10
17 changed files with 1056 additions and 145 deletions
2
.github/workflows/desktop-release.yml
vendored
2
.github/workflows/desktop-release.yml
vendored
|
|
@ -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 -- \
|
||||
|
|
|
|||
|
|
@ -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 user’s 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
|
||||
|
|
|
|||
30
apps/desktop/electron/bootstrap-platform.cjs
Normal file
30
apps/desktop/electron/bootstrap-platform.cjs
Normal 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
|
||||
}
|
||||
50
apps/desktop/electron/bootstrap-platform.test.cjs
Normal file
50
apps/desktop/electron/bootstrap-platform.test.cjs
Normal 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`)
|
||||
}
|
||||
})
|
||||
|
|
@ -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() }))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
155
apps/desktop/package-lock.json
generated
155
apps/desktop/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
9
apps/desktop/scripts/before-build.cjs
Normal file
9
apps/desktop/scripts/before-build.cjs
Normal 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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
58
apps/desktop/src/components/desktop-boot-overlay.tsx
Normal file
58
apps/desktop/src/components/desktop-boot-overlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
309
apps/desktop/src/components/desktop-onboarding-overlay.tsx
Normal file
309
apps/desktop/src/components/desktop-onboarding-overlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
12
apps/desktop/src/global.d.ts
vendored
12
apps/desktop/src/global.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
90
apps/desktop/src/store/boot.ts
Normal file
90
apps/desktop/src/store/boot.ts
Normal 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
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue