diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index c99c6f1b947..2affa04b0b4 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -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 -- \ diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 644edd30882..2f3dc4e0791 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -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 diff --git a/apps/desktop/electron/bootstrap-platform.cjs b/apps/desktop/electron/bootstrap-platform.cjs new file mode 100644 index 00000000000..0a9eb1f1780 --- /dev/null +++ b/apps/desktop/electron/bootstrap-platform.cjs @@ -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 +} diff --git a/apps/desktop/electron/bootstrap-platform.test.cjs b/apps/desktop/electron/bootstrap-platform.test.cjs new file mode 100644 index 00000000000..be9e475b015 --- /dev/null +++ b/apps/desktop/electron/bootstrap-platform.test.cjs @@ -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`) + } +}) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index ea469c7333a..395dccfe165 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -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() })) diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 20621c1d06e..2c078e6cad6 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -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) } }) diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index b36b0830df3..e7973dd27b6 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -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", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 544738f8843..09d36a9e728 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/apps/desktop/scripts/before-build.cjs b/apps/desktop/scripts/before-build.cjs new file mode 100644 index 00000000000..5b02750438c --- /dev/null +++ b/apps/desktop/scripts/before-build.cjs @@ -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 +} diff --git a/apps/desktop/scripts/test-desktop.mjs b/apps/desktop/scripts/test-desktop.mjs index b06026b52b2..7436e91d867 100644 --- a/apps/desktop/scripts/test-desktop.mjs +++ b/apps/desktop/scripts/test-desktop.mjs @@ -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() diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 255005b3b09..706c84f7061 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -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 = ( <> + + { + void refreshHermesConfig() + void refreshCurrentModel() + void queryClient.invalidateQueries({ queryKey: ['model-options'] }) + }} + onOpenSettings={openSettings} + requestGateway={requestGateway} + /> {settingsOpen && ( diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index 5d8cf140c91..5f4b0f16b86 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -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) diff --git a/apps/desktop/src/components/desktop-boot-overlay.tsx b/apps/desktop/src/components/desktop-boot-overlay.tsx new file mode 100644 index 00000000000..0c0352b909f --- /dev/null +++ b/apps/desktop/src/components/desktop-boot-overlay.tsx @@ -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 ( +
+
+
+
+
+
+ +

{boot.message}

+ {hasError ?

{boot.error}

: null} + +
+
+
+ +
+ {boot.phase} + {progress}% +
+
+
+ ) +} diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.tsx new file mode 100644 index 00000000000..893dad55031 --- /dev/null +++ b/apps/desktop/src/components/desktop-onboarding-overlay.tsx @@ -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: (method: string, params?: Record) => Promise +} + +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 | null>(null) + const [error, setError] = useState(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('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('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 ( +
+
+
+
+
+
+ +
+
+

Set up Hermes

+

+ Add one inference provider before starting your first chat. This writes to the current Hermes + profile's `.env` file and takes effect immediately. +

+
+
+ +
+
+ +
+ {checking ? ( +
+ + Checking provider setup... +
+ ) : null} + +
+ +
+ {providerOptions.map(option => ( + + ))} +
+
+ +
+
+ + {selectedInfo?.url ? ( + + ) : null} +
+ 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} + /> +

{selectedOption.helper}

+
+ + {error ? ( +
+ + {error} +
+ ) : null} + +
+ +
+ + +
+
+
+
+
+ ) +} diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 23259b351af..c52a00d67df 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -4,6 +4,7 @@ declare global { interface Window { hermesDesktop: { getConnection: () => Promise + getBootProgress: () => Promise api: (request: HermesApiRequest) => Promise notify: (payload: HermesNotification) => Promise requestMicrophoneAccess: () => Promise @@ -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 diff --git a/apps/desktop/src/store/boot.ts b/apps/desktop/src/store/boot.ts new file mode 100644 index 00000000000..dfbd6d5f3cd --- /dev/null +++ b/apps/desktop/src/store/boot.ts @@ -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(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 + }) +} diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 1d2c9b39558..364dbf2b7a2 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -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 } } },