const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, nativeTheme, safeStorage, session, shell, systemPreferences } = require('electron') const crypto = require('node:crypto') const fs = require('node:fs') const http = require('node:http') const https = require('node:https') const net = require('node:net') const path = require('node:path') const { fileURLToPath, pathToFileURL } = require('node:url') const { execFileSync, spawn } = require('node:child_process') const { isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs') const { runBootstrap } = require('./bootstrap-runner.cjs') const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs') const { DATA_URL_READ_MAX_BYTES, DEFAULT_FETCH_TIMEOUT_MS, TEXT_PREVIEW_SOURCE_MAX_BYTES, encryptDesktopSecret: encryptDesktopSecretStrict, resolveReadableFileForIpc, resolveTimeoutMs } = require('./hardening.cjs') let nodePty = null try { nodePty = require('node-pty') } catch { // Packaged builds set `files:` in package.json, which excludes node_modules // from the asar. Workspace dedup also hoists this native dep to the repo // root's node_modules, out of reach of electron-builder's collector. We // ship a minimal copy under resources/native-deps/ via extraResources + // scripts/stage-native-deps.cjs; resolve from there when the normal // require() fails. Dev mode never reaches this branch -- the hoisted // resolve succeeds via Node's normal module lookup. try { const path = require('node:path') const resourcesPath = process.resourcesPath if (resourcesPath) { nodePty = require(path.join(resourcesPath, 'native-deps', 'node-pty')) } } catch { nodePty = null } } 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 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, '../..') // Build-time install stamp -- the git ref this .exe was built against. // // Written by apps/desktop/scripts/write-build-stamp.cjs during `npm run build` // and bundled into packaged apps via electron-builder's extraResources entry, // so the runtime stamp ends up at process.resourcesPath/install-stamp.json // after install. The bootstrap runner (Phase 1D) reads it to know which // commit to clone when running install.ps1 stages at first launch. // // Returns null when the file is missing (dev runs from a checkout where // build hasn't been invoked, or schema mismatch). Callers must handle null. // // Schema: // { schemaVersion: 1, commit, branch, builtAt, dirty, source } const INSTALL_STAMP_SCHEMA_VERSION = 1 function loadInstallStamp() { // Try packaged location first (resources/install-stamp.json), then the // dev/local build output (apps/desktop/build/install-stamp.json) so // someone running `npm run start` after a local `npm run build` also // sees a stamp without needing a packaged build. const candidates = [ process.resourcesPath ? path.join(process.resourcesPath, 'install-stamp.json') : null, path.join(APP_ROOT, 'build', 'install-stamp.json') ].filter(Boolean) for (const p of candidates) { try { const raw = fs.readFileSync(p, 'utf8') const parsed = JSON.parse(raw) if (parsed && typeof parsed === 'object' && typeof parsed.commit === 'string' && parsed.commit.length >= 7) { if (parsed.schemaVersion !== INSTALL_STAMP_SCHEMA_VERSION) { console.warn( `[hermes] install-stamp.json schemaVersion ${parsed.schemaVersion} != expected ${INSTALL_STAMP_SCHEMA_VERSION}; ignoring` ) continue } return Object.freeze({ schemaVersion: parsed.schemaVersion, commit: parsed.commit, branch: parsed.branch || null, builtAt: parsed.builtAt || null, dirty: Boolean(parsed.dirty), source: parsed.source || null, path: p }) } } catch { // Either ENOENT or malformed JSON; try the next candidate } } return null } const INSTALL_STAMP = loadInstallStamp() if (INSTALL_STAMP) { console.log( `[hermes] install stamp: ${INSTALL_STAMP.commit.slice(0, 12)}${INSTALL_STAMP.branch ? ` (${INSTALL_STAMP.branch})` : ''}${INSTALL_STAMP.dirty ? ' [DIRTY]' : ''} from ${INSTALL_STAMP.source || 'unknown'}` ) } else if (IS_PACKAGED) { // Dev builds without a stamp are normal; packaged builds without one // mean the bootstrap won't know what to clone. Surface clearly. console.error( '[hermes] WARNING: no install-stamp.json found in packaged build. First-launch bootstrap will not have a pinned ref to install.' ) } // HERMES_HOME — the user-facing root for everything Hermes-related. Mirrors // scripts/install.ps1's $HermesHome and scripts/install.sh's $HERMES_HOME. // // Defaults: // Windows: %LOCALAPPDATA%\hermes (matches install.ps1) // macOS / Linux: ~/.hermes (matches install.sh) // // Special case for Windows: if the user has a legacy ~/.hermes directory // (e.g., from a prior pip install or a manual setup) AND no // %LOCALAPPDATA%\hermes yet, prefer the legacy path so we don't orphan their // existing config / sessions / .env. New installs go to %LOCALAPPDATA%. // // HERMES_DESKTOP_USER_DATA_DIR (used by test:desktop:fresh) puts the sandbox // HERMES_HOME beneath the throwaway userData dir so a fresh-install run never // touches the user's real ~/.hermes / %LOCALAPPDATA%\hermes. function resolveHermesHome() { if (process.env.HERMES_HOME) return path.resolve(process.env.HERMES_HOME) if (USER_DATA_OVERRIDE) return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home') if (IS_WINDOWS && process.env.LOCALAPPDATA) { const localappdata = path.join(process.env.LOCALAPPDATA, 'hermes') const legacy = path.join(app.getPath('home'), '.hermes') // Migrate transparently to LOCALAPPDATA, but honour an existing legacy // ~/.hermes setup (no LOCALAPPDATA install yet) so users don't lose state. if (!directoryExists(localappdata) && directoryExists(legacy)) return legacy return localappdata } return path.join(app.getPath('home'), '.hermes') } const HERMES_HOME = resolveHermesHome() // ACTIVE_HERMES_ROOT — the canonical mutable Hermes install. Same path // install.ps1 / install.sh use, so a desktop-only user and a CLI-only user end // up with identical layouts and can share one install. const ACTIVE_HERMES_ROOT = path.join(HERMES_HOME, 'hermes-agent') // VENV_ROOT — venv lives inside the repo, exactly like install.ps1 does it. const VENV_ROOT = path.join(ACTIVE_HERMES_ROOT, 'venv') // BOOTSTRAP_COMPLETE_MARKER — written by the first-launch bootstrap runner // (Phase 1D) after install.ps1 has completed all stages and the user has // finished initial configuration. Presence of this marker means the install // is in a known-good state and we can skip the bootstrap flow on subsequent // boots, going straight to `resolveHermesBackend()`. Missing or stale marker // means we re-run the bootstrap; install.ps1's stages are idempotent so a // re-run on an already-good install just discovers everything in place. // // We deliberately put the marker INSIDE ACTIVE_HERMES_ROOT (not alongside) // so that deleting the checkout to start fresh also deletes the marker -- // avoids the confusing "marker exists but checkout is gone" state. const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete') const BOOTSTRAP_MARKER_SCHEMA_VERSION = 1 const DESKTOP_CONNECTION_CONFIG_PATH = path.join(app.getPath('userData'), 'connection.json') const DESKTOP_UPDATE_CONFIG_PATH = path.join(app.getPath('userData'), 'updates.json') // Branch we track for self-update. Flip to 'main' once the GUI work merges — // single field edit, no rebuild required. User can also override at runtime // via hermesDesktop.updates.setBranch(). const DEFAULT_UPDATE_BRANCH = 'bb/gui' // desktop.log lives under HERMES_HOME/logs/ so it sits next to agent.log, // errors.log, gateway.log produced by hermes_logging.setup_logging — one log // directory per user, regardless of which UI surface produced the line. const DESKTOP_LOG_PATH = path.join(HERMES_HOME, 'logs', '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 APP_NAME = 'Hermes' const TITLEBAR_HEIGHT = 34 const MACOS_TRAFFIC_LIGHTS_HEIGHT = 14 const WINDOW_BUTTON_POSITION = { x: 24, y: TITLEBAR_HEIGHT / 2 - MACOS_TRAFFIC_LIGHTS_HEIGHT / 2 } // Width Electron reserves for the Windows/Linux native min/max/close cluster // when `titleBarOverlay` is enabled. The OS paints these buttons in the // top-right corner of the renderer; we have to leave that much room on the // right edge so our system tools (file browser, haptics, settings) don't sit // underneath them. macOS uses left-side traffic lights instead and reports a // position via getWindowButtonPosition(), so this width is non-zero only on // non-macOS platforms. const NATIVE_OVERLAY_BUTTON_WIDTH = 144 const APP_ICON_PATHS = [ path.join(APP_ROOT, 'public', 'apple-touch-icon.png'), path.join(APP_ROOT, 'dist', 'apple-touch-icon.png'), path.join(unpackedPathFor(APP_ROOT), 'dist', 'apple-touch-icon.png') ] let rendererTitleBarTheme = null const terminalSessions = new Map() function isHexColor(value) { return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value) } function getTitleBarOverlayOptions() { if (IS_MAC) { return { height: TITLEBAR_HEIGHT } } if (rendererTitleBarTheme) { return { color: rendererTitleBarTheme.background, height: TITLEBAR_HEIGHT, symbolColor: rendererTitleBarTheme.foreground } } const useDarkColors = nativeTheme.shouldUseDarkColors return { color: useDarkColors ? '#111111' : '#f7f7f7', height: TITLEBAR_HEIGHT, symbolColor: useDarkColors ? '#f7f7f7' : '#242424' } } const MEDIA_MIME_TYPES = { '.avi': 'video/x-msvideo', '.bmp': 'image/bmp', '.flac': 'audio/flac', '.gif': 'image/gif', '.jpeg': 'image/jpeg', '.jpg': 'image/jpeg', '.m4a': 'audio/mp4', '.mkv': 'video/x-matroska', '.mov': 'video/quicktime', '.mp3': 'audio/mpeg', '.mp4': 'video/mp4', '.ogg': 'audio/ogg', '.opus': 'audio/ogg; codecs=opus', '.png': 'image/png', '.svg': 'image/svg+xml', '.wav': 'audio/wav', '.webm': 'video/webm', '.webp': 'image/webp' } const PREVIEW_HTML_EXTENSIONS = new Set(['.html', '.htm']) const PREVIEW_WATCH_DEBOUNCE_MS = 120 const LOCAL_PREVIEW_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::1', '[::1]', 'localhost']) const TEXT_PREVIEW_MAX_BYTES = 512 * 1024 const PREVIEW_LANGUAGE_BY_EXT = { '.c': 'c', '.conf': 'ini', '.cpp': 'cpp', '.css': 'css', '.csv': 'csv', '.go': 'go', '.graphql': 'graphql', '.h': 'c', '.hpp': 'cpp', '.html': 'html', '.java': 'java', '.js': 'javascript', '.json': 'json', '.jsx': 'jsx', '.kt': 'kotlin', '.lua': 'lua', '.md': 'markdown', '.mjs': 'javascript', '.py': 'python', '.rb': 'ruby', '.rs': 'rust', '.sh': 'shell', '.sql': 'sql', '.svg': 'xml', '.toml': 'toml', '.ts': 'typescript', '.tsx': 'tsx', '.txt': 'text', '.xml': 'xml', '.yaml': 'yaml', '.yml': 'yaml', '.zsh': 'shell' } function looksBinary(buffer) { if (!buffer.length) return false let suspicious = 0 for (const byte of buffer) { if (byte === 0) return true // Allow common whitespace controls: tab, LF, CR. if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) suspicious += 1 } return suspicious / buffer.length > 0.12 } function previewFileMetadata(filePath, mimeType) { let byteSize = 0 let binary = false try { const stat = fs.statSync(filePath) byteSize = stat.size if (!mimeType.startsWith('image/')) { const fd = fs.openSync(filePath, 'r') try { const sample = Buffer.alloc(Math.min(byteSize, 4096)) const bytesRead = fs.readSync(fd, sample, 0, sample.length, 0) binary = looksBinary(sample.subarray(0, bytesRead)) } finally { fs.closeSync(fd) } } } catch { // Metadata is best-effort; the read handlers surface hard errors later. } return { binary, byteSize, large: byteSize > TEXT_PREVIEW_MAX_BYTES } } app.setName(APP_NAME) app.setAboutPanelOptions({ applicationName: APP_NAME, copyright: 'Copyright © 2026 Nous Research' }) let mainWindow = null let hermesProcess = null let connectionPromise = null // Latched bootstrap failure: when the first-launch install fails, we hold // onto the error so subsequent startHermes() calls (e.g. the renderer's // ensureGatewayOpen retrying after the WS won't open) return the same error // instead of re-running install.ps1 in a hot loop. Cleared explicitly by // the renderer's "Reload and retry" path or by quitting the app. let bootstrapFailure = null let connectionConfigCache = null const hermesLog = [] const previewWatchers = new Map() 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 const chunk = desktopLogBuffer desktopLogBuffer = '' try { fs.mkdirSync(path.dirname(DESKTOP_LOG_PATH), { recursive: true }) fs.appendFileSync(DESKTOP_LOG_PATH, chunk) } catch { // Logging must never block app startup/shutdown. } } function flushDesktopLogBufferAsync() { if (!desktopLogBuffer) return desktopLogFlushPromise const chunk = desktopLogBuffer desktopLogBuffer = '' desktopLogFlushPromise = desktopLogFlushPromise .then(async () => { await fs.promises.mkdir(path.dirname(DESKTOP_LOG_PATH), { recursive: true }) await fs.promises.appendFile(DESKTOP_LOG_PATH, chunk) }) .catch(() => { // Logging must never crash the desktop shell. }) return desktopLogFlushPromise } function scheduleDesktopLogFlush() { if (desktopLogFlushTimer) return desktopLogFlushTimer = setTimeout(() => { desktopLogFlushTimer = null void flushDesktopLogBufferAsync() }, DESKTOP_LOG_FLUSH_MS) } function rememberLog(chunk) { const text = String(chunk || '').trim() if (!text) return const lines = text.split(/\r?\n/).map(line => `[hermes] ${line}`) hermesLog.push(...lines) if (hermesLog.length > 300) { hermesLog.splice(0, hermesLog.length - 300) } desktopLogBuffer += `${lines.join('\n')}\n` if (desktopLogBuffer.length >= DESKTOP_LOG_BUFFER_MAX_CHARS) { if (desktopLogFlushTimer) { clearTimeout(desktopLogFlushTimer) desktopLogFlushTimer = null } void flushDesktopLogBufferAsync() return } scheduleDesktopLogFlush() } function openExternalUrl(rawUrl) { const raw = String(rawUrl || '').trim() if (!raw) return false let parsed try { parsed = new URL(raw) } catch { return false } if (!['http:', 'https:', 'mailto:'].includes(parsed.protocol)) { return false } const url = parsed.toString() if (IS_WSL) { rememberLog(`[link] opening via WSL→Windows: ${url}`) const proc = spawn('cmd.exe', ['/c', 'start', '""', url], { detached: true, stdio: 'ignore', windowsHide: true }) proc.on('error', error => { rememberLog(`[link] cmd.exe start failed: ${error.message}; falling back to xdg-open`) shell.openExternal(url).catch(fallback => rememberLog(`[link] xdg-open failed: ${fallback.message}`)) }) proc.unref() return true } shell.openExternal(url).catch(error => rememberLog(`[link] openExternal failed: ${error.message}`)) return true } function ensureWslWindowsFonts() { if (!IS_WSL) return const fontsDir = ['/mnt/c/Windows/Fonts', '/mnt/c/windows/fonts'].find(candidate => { try { return fs.statSync(candidate).isDirectory() } catch { return false } }) if (!fontsDir) return try { const confDir = path.join(app.getPath('home'), '.config', 'fontconfig', 'conf.d') const confPath = path.join(confDir, '99-hermes-wsl-windows-fonts.conf') let existing = '' try { existing = fs.readFileSync(confPath, 'utf8') } catch { existing = '' } if (existing.includes(fontsDir)) return fs.mkdirSync(confDir, { recursive: true }) fs.writeFileSync( confPath, `\n\n\n ${fontsDir}\n\n` ) rememberLog(`[fonts] wired WSL Windows fonts for renderer: ${fontsDir}`) const cache = spawn('fc-cache', ['-f', fontsDir], { detached: true, stdio: 'ignore' }) cache.on('error', () => undefined) cache.unref() } catch (error) { rememberLog(`[fonts] WSL font setup skipped: ${error.message}`) } } 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) } // Bootstrap-event broadcast channel + state. The bootstrap runner emits a // stream of events (manifest, stage, log, complete, failed) that the renderer // install overlay subscribes to. We also keep a running snapshot: // - manifest: the stage list (rendered as a checklist in the overlay) // - stages: per-stage state ('pending' | 'running' | 'succeeded' | // 'skipped' | 'failed') keyed by stage name // - active: true while a bootstrap is in flight; false otherwise // - error: last 'failed' event's error message // - log: bounded ring buffer of the last 200 log lines for the // "Show details" affordance in the overlay // // The snapshot is queryable via the hermes:bootstrap:get IPC handler so a // reloaded renderer (e.g. devtools reload during dev) recovers state. // Bootstrap log ring: bounded buffer so a long install (npm + playwright // downloads can emit thousands of lines) doesn't grow unbounded in memory // AND so the renderer's getBootstrapState() reply stays a reasonable size. // We keep enough to cover an entire failed stage's transcript so the // 'Copy output' button gives the user actually-actionable context, not // just the last few lines. const BOOTSTRAP_LOG_RING_MAX = 500 let bootstrapState = { active: false, manifest: null, stages: {}, error: null, log: [], startedAt: null, completedAt: null, unsupportedPlatform: null } function broadcastBootstrapEvent(ev) { if (ev.type === 'manifest') { bootstrapState.manifest = ev bootstrapState.active = true bootstrapState.startedAt = bootstrapState.startedAt || Date.now() bootstrapState.stages = {} for (const stage of ev.stages || []) { bootstrapState.stages[stage.name] = { state: 'pending', json: null, durationMs: null, error: null } } } else if (ev.type === 'stage') { bootstrapState.stages[ev.name] = { state: ev.state, durationMs: ev.durationMs ?? null, json: ev.json ?? null, error: ev.error ?? null } } else if (ev.type === 'log') { bootstrapState.log.push({ ts: Date.now(), stage: ev.stage || null, line: ev.line }) if (bootstrapState.log.length > BOOTSTRAP_LOG_RING_MAX) { bootstrapState.log.splice(0, bootstrapState.log.length - BOOTSTRAP_LOG_RING_MAX) } } else if (ev.type === 'complete') { bootstrapState.active = false bootstrapState.completedAt = Date.now() bootstrapState.error = null bootstrapState.unsupportedPlatform = null } else if (ev.type === 'failed') { bootstrapState.active = false bootstrapState.error = ev.error || 'unknown error' } else if (ev.type === 'unsupported-platform') { bootstrapState.active = false bootstrapState.unsupportedPlatform = { platform: ev.platform, activeRoot: ev.activeRoot, installCommand: ev.installCommand, docsUrl: ev.docsUrl } } if (!mainWindow || mainWindow.isDestroyed()) return const { webContents } = mainWindow if (!webContents || webContents.isDestroyed()) return webContents.send('hermes:bootstrap:event', ev) } function getBootstrapState() { return bootstrapState } 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() } catch { return false } } function directoryExists(filePath) { try { return fs.statSync(filePath).isDirectory() } catch { return false } } function unpackedPathFor(filePath) { return filePath.replace(/app\.asar(?=$|[\\/])/, 'app.asar.unpacked') } function findOnPath(command) { if (!command) return null if (path.isAbsolute(command) || command.includes(path.sep) || (IS_WINDOWS && command.includes('/'))) { if (!fileExists(command)) return null if (isWindowsBinaryPathInWsl(command, { isWsl: IS_WSL })) return null return command } const pathEntries = String(process.env.PATH || '') .split(path.delimiter) .filter(Boolean) const extensions = IS_WINDOWS ? ['', ...(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)] : [''] for (const entry of pathEntries) { for (const extension of extensions) { const candidate = path.join(entry, `${command}${extension}`) if (fileExists(candidate)) return candidate } } return null } function isCommandScript(command) { return IS_WINDOWS && /\.(cmd|bat)$/i.test(command || '') } function normalizeExecutablePathForCompare(commandPath) { if (!commandPath) return null let resolved = path.resolve(String(commandPath)) try { resolved = fs.realpathSync.native ? fs.realpathSync.native(resolved) : fs.realpathSync(resolved) } catch { // Fallback to path.resolve() above. } return IS_WINDOWS ? resolved.toLowerCase() : resolved } function looksLikeDesktopAppBinary(commandPath) { if (!IS_WINDOWS || !commandPath) return false const normalizedCandidate = normalizeExecutablePathForCompare(commandPath) const normalizedCurrentExec = normalizeExecutablePathForCompare(process.execPath) if (normalizedCandidate && normalizedCurrentExec && normalizedCandidate === normalizedCurrentExec) { return true } let resolved = path.resolve(String(commandPath)) try { resolved = fs.realpathSync.native ? fs.realpathSync.native(resolved) : fs.realpathSync(resolved) } catch { // Keep resolved path fallback. } const resourcesDir = path.join(path.dirname(resolved), 'resources') return ( fileExists(path.join(resourcesDir, 'app.asar')) || directoryExists(path.join(resourcesDir, 'app.asar.unpacked')) ) } function isHermesSourceRoot(root) { return directoryExists(root) && fileExists(path.join(root, 'hermes_cli', 'main.py')) } function findPythonForRoot(root) { const override = process.env.HERMES_DESKTOP_PYTHON if (override && fileExists(override)) return override const relativePaths = IS_WINDOWS ? [path.join('.venv', 'Scripts', 'python.exe'), path.join('venv', 'Scripts', 'python.exe')] : [path.join('.venv', 'bin', 'python'), path.join('venv', 'bin', 'python')] for (const relativePath of relativePaths) { const candidate = path.join(root, relativePath) if (fileExists(candidate)) return candidate } return findSystemPython() } function findSystemPython() { if (!IS_WINDOWS) { // POSIX systems: PATH lookup is safe. for (const command of ['python3', 'python']) { const candidate = findOnPath(command) if (candidate) return candidate } return null } // Windows: PATH-based detection has TWO landmines we have to dodge. // // (1) The Microsoft Store "Python stub" lives at // %LOCALAPPDATA%\Microsoft\WindowsApps\python.exe and is on PATH // by default on modern Windows. It's a redirector that opens the // Store window if no Store Python is installed. Running it for // `-m venv` would either succeed (real Store install — fine) or // pop the Store dialog (bad UX during boot). // (2) `py.exe` (Python launcher) is missing from per-user installs // that didn't check the launcher option, so PATH-only checks // miss real Python 3.13 installs (user-reported case). // // We also restrict ourselves to Python 3.11–3.13. 3.14 is the latest // CPython but several Hermes deps (notably pywinpty's Rust-built // windows_x86_64_msvc crate) don't yet publish 3.14 wheels, and // `pip install -e .` falls back to source-build, which fails without // a Rust toolchain. install.ps1 sidesteps this by pinning to 3.11 // via uv; until we add the same uv-managed Python pathway here, the // simplest fix is to refuse 3.14 detection and let the NSIS prereq // page offer to install 3.11 alongside. // // Strategy: probe in three passes, in order from most-precise to // least-precise, and ONLY use PATH lookup as a last resort after // confirming the candidate isn't the WindowsApps redirector. // // Pass 1: PEP 514 registry — every standards-compliant Python // installer registers itself at SOFTWARE\Python\PythonCore. // The MS Store stub does NOT register here, so a hit means // a real Python install. Versions are explicit so we // inherently filter 3.14 out. // Pass 2: Filesystem probe of standard install locations // (Program Files, LocalAppData\Programs\Python). Same // version filtering by directory name. // Pass 3: PATH lookup of `py.exe` (the launcher itself never // triggers the Store) — but call it with a version flag so // we resolve to a SPECIFIC supported version, not whatever // py.exe's default is (which on a 3.14-only box would be // 3.14). const SUPPORTED_VERSIONS = ['3.11', '3.12', '3.13'] const SUPPORTED_VERSIONS_NO_DOT = ['311', '312', '313'] // Pass 1: registry. Use `reg query` since main process doesn't have // a reliable in-process registry API across all electron versions. for (const hive of ['HKLM', 'HKCU']) { for (const version of SUPPORTED_VERSIONS) { try { const out = execFileSync( 'reg', ['query', `${hive}\\SOFTWARE\\Python\\PythonCore\\${version}\\InstallPath`, '/ve', '/reg:64'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] } ) // Output format: " (Default) REG_SZ C:\Path\To\Python\" const match = out.match(/REG_SZ\s+(.+?)\s*$/m) if (match) { const installPath = match[1].trim() const pythonExe = path.join(installPath, 'python.exe') if (fileExists(pythonExe)) return pythonExe } } catch { // Key not present — try next. } } } // Pass 2: filesystem probe of standard locations. const programFiles = process.env['ProgramFiles'] || 'C:\\Program Files' const localAppData = process.env.LOCALAPPDATA || '' for (const versionDir of SUPPORTED_VERSIONS_NO_DOT) { const systemWide = path.join(programFiles, `Python${versionDir}`, 'python.exe') if (fileExists(systemWide)) return systemWide if (localAppData) { const perUser = path.join(localAppData, 'Programs', 'Python', `Python${versionDir}`, 'python.exe') if (fileExists(perUser)) return perUser } } // Pass 3: py.exe with explicit version flag. The launcher itself is // safe to invoke (no Store popup) and `py -3.13 -c "import sys; // print(sys.executable)"` resolves to the actual python.exe path of // the requested version. We try in version-priority order so the // first hit wins. const pyExe = findOnPath('py.exe') if (pyExe) { for (const version of SUPPORTED_VERSIONS) { try { const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) const candidate = out.trim() if (candidate && fileExists(candidate)) return candidate } catch { // py couldn't find that version — try next. } } } // We deliberately do NOT fall back to plain `python.exe` on PATH. // Without a way to verify the version safely (running `python -V` // risks the Microsoft Store popup), accepting whatever's there // could land us on 3.14 and trigger the Rust-build-from-source // failure. Better to return null and let the NSIS prereq page // offer to install a known-good 3.11 via winget. return null } // findGitBash — locate bash.exe on Windows. Hermes' terminal tool requires // bash (POSIX shell), and on Windows that's almost always Git for Windows' // bundled Git Bash. We check the same set of locations tools/environments/ // local.py:_find_bash() checks at runtime, so a positive result here means // the agent will be able to start a terminal too. // // On non-Windows hosts bash is part of the OS and this just returns the // first bash on PATH. function findGitBash() { if (!IS_WINDOWS) { return findOnPath('bash') } // install.ps1 drops PortableGit at %LOCALAPPDATA%\hermes\git\... — checked // first so users who installed via install.ps1 are detected before we // start probing system-wide locations. const localAppData = process.env.LOCALAPPDATA || '' const candidates = [] if (localAppData) { candidates.push(path.join(localAppData, 'hermes', 'git', 'bin', 'bash.exe')) candidates.push(path.join(localAppData, 'hermes', 'git', 'usr', 'bin', 'bash.exe')) } // Standard Git for Windows install locations. candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'Git', 'bin', 'bash.exe')) candidates.push(path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe')) if (localAppData) { candidates.push(path.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe')) } for (const candidate of candidates) { if (fileExists(candidate)) return candidate } // Last resort — bash on PATH (covers WSL bash, MSYS2, custom installs). // On WSL hosts findOnPath itself filters out Windows-binary paths via // isWindowsBinaryPathInWsl, so we won't hand back a wsl.exe shim either. return findOnPath('bash') } function getVenvPython(venvRoot) { return path.join(venvRoot, IS_WINDOWS ? path.join('Scripts', 'python.exe') : path.join('bin', 'python')) } // resolveGitBinary — locate git.exe on Windows. A fresh installer-driven // install only has PortableGit under %LOCALAPPDATA%\hermes\git (never on // PATH), so a bare spawn('git') ENOENTs and self-update checks fail with // "Couldn't check for updates". Mirror findGitBash: PortableGit first, then // standard Git-for-Windows locations, then PATH. Cached after first probe. let _gitBinaryCache = null function resolveGitBinary() { if (_gitBinaryCache) return _gitBinaryCache if (!IS_WINDOWS) { _gitBinaryCache = findOnPath('git') || 'git' return _gitBinaryCache } const localAppData = process.env.LOCALAPPDATA || '' const candidates = [] if (localAppData) { candidates.push(path.join(localAppData, 'hermes', 'git', 'cmd', 'git.exe')) candidates.push(path.join(localAppData, 'hermes', 'git', 'bin', 'git.exe')) } candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'Git', 'cmd', 'git.exe')) candidates.push(path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'cmd', 'git.exe')) if (localAppData) { candidates.push(path.join(localAppData, 'Programs', 'Git', 'cmd', 'git.exe')) } _gitBinaryCache = candidates.find(fileExists) || findOnPath('git') || 'git' return _gitBinaryCache } function recentHermesLog() { return hermesLog.slice(-20).join('\n') } // ─── Self-update (git-pull against the running backend's hermes root) ────── function readDesktopUpdateConfig() { try { const parsed = JSON.parse(fs.readFileSync(DESKTOP_UPDATE_CONFIG_PATH, 'utf8')) const branch = typeof parsed?.branch === 'string' ? parsed.branch.trim() : '' return { branch: branch || DEFAULT_UPDATE_BRANCH } } catch { return { branch: DEFAULT_UPDATE_BRANCH } } } function writeDesktopUpdateConfig(config) { fs.mkdirSync(path.dirname(DESKTOP_UPDATE_CONFIG_PATH), { recursive: true }) fs.writeFileSync(DESKTOP_UPDATE_CONFIG_PATH, JSON.stringify(config, null, 2)) } // Match the backend's source resolution but bias toward a real git checkout. // Dev → SOURCE_REPO_ROOT. Packaged/CLI install → ACTIVE_HERMES_ROOT. // HERMES_DESKTOP_HERMES_ROOT always wins so devs can pin a worktree. function resolveUpdateRoot() { const candidates = [ process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT), !IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT) ? SOURCE_REPO_ROOT : null, isHermesSourceRoot(ACTIVE_HERMES_ROOT) ? ACTIVE_HERMES_ROOT : null ].filter(Boolean) return candidates.find(c => directoryExists(path.join(c, '.git'))) || candidates[0] || ACTIVE_HERMES_ROOT } function runGit(args, options = {}) { return new Promise((resolve, reject) => { const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, { cwd: options.cwd, env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' }, stdio: ['ignore', 'pipe', 'pipe'] }) let stdout = '' let stderr = '' child.stdout.on('data', chunk => { const text = chunk.toString() stdout += text options.onLine?.('stdout', text) }) child.stderr.on('data', chunk => { const text = chunk.toString() stderr += text options.onLine?.('stderr', text) }) child.once('error', reject) child.once('exit', code => resolve({ code, stdout, stderr })) }) } const firstLine = text => (text || '').split('\n').find(Boolean) || '' function emitUpdateProgress(payload) { const merged = { stage: 'idle', message: '', percent: null, error: null, ...payload, at: Date.now() } rememberLog(`[updates] ${merged.stage}: ${merged.message || merged.error || ''}`) for (const window of BrowserWindow.getAllWindows()) { window.webContents.send('hermes:updates:progress', merged) } } async function checkUpdates() { const updateRoot = resolveUpdateRoot() const { branch } = readDesktopUpdateConfig() const gitDir = path.join(updateRoot, '.git') if (!directoryExists(gitDir)) { return { supported: false, reason: 'not-a-git-checkout', message: `${updateRoot} isn't a git checkout — desktop self-update only runs against a source install.`, hermesRoot: updateRoot, branch } } const fetched = await runGit(['fetch', '--quiet', 'origin', branch], { cwd: updateRoot }) if (fetched.code !== 0) { return { supported: true, branch, error: 'fetch-failed', message: firstLine(fetched.stderr) || 'git fetch failed.', hermesRoot: updateRoot, fetchedAt: Date.now() } } const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim()) const [currentSha, targetSha, countStr, dirtyStr, currentBranch] = await Promise.all([ git(['rev-parse', 'HEAD']), git(['rev-parse', `origin/${branch}`]), git(['rev-list', `HEAD..origin/${branch}`, '--count']), git(['status', '--porcelain']), git(['rev-parse', '--abbrev-ref', 'HEAD']) ]) const behind = Number.parseInt(countStr, 10) || 0 const commits = behind > 0 ? await readCommitLog(updateRoot, branch) : [] return { supported: true, branch, currentBranch, behind, currentSha, targetSha, commits, dirty: dirtyStr.length > 0, hermesRoot: updateRoot, fetchedAt: Date.now() } } async function readCommitLog(cwd, branch) { const SEP = '\x1f' const REC = '\x1e' const { stdout } = await runGit( ['log', `HEAD..origin/${branch}`, `--pretty=format:%H${SEP}%s${SEP}%an${SEP}%at${REC}`, '-n', '40'], { cwd } ) return stdout .split(REC) .map(line => line.trim()) .filter(Boolean) .map(line => { const [sha, summary, author, at] = line.split(SEP) return { sha, summary, author, at: Number.parseInt(at, 10) * 1000 } }) } let updateInFlight = false // Resolve the staged updater binary. The Tauri installer copies itself to // HERMES_HOME/hermes-setup.exe on a successful install (see // apps/bootstrap-installer paths::copy_self_to_hermes_home). That binary owns // ALL repo mutation — running `hermes update` + rebuilding the desktop — so // the desktop never touches its own bits while running. Returns null when the // updater isn't staged (e.g. a dev/source run that never went through the // installer); callers degrade gracefully. function resolveUpdaterBinary() { const name = IS_WINDOWS ? 'hermes-setup.exe' : 'hermes-setup' const candidate = path.join(HERMES_HOME, name) return fileExists(candidate) ? candidate : null } // applyUpdates — hand off to the installer's --update flow, then exit. // // The desktop is a pure consumer: it does NOT git pull / pip install / rebuild // itself (the old open-coded git dance lived here and drifted from // `hermes update`). Instead we spawn the staged Hermes-Setup binary with // --update and quit, so it can run `hermes update` (which refuses while we // hold the venv shim) and rebuild the desktop with our exe already gone. // // Detection (checkUpdates / commit changelog / "N behind") stays in the UI; // only this apply action changed. async function applyUpdates(opts = {}) { if (updateInFlight) { throw new Error('An update is already in progress.') } updateInFlight = true try { const updater = resolveUpdaterBinary() if (!updater && !IS_WINDOWS) { // macOS/Linux drag-install: no staged Tauri hermes-setup. Unlike Windows // (where a venv-shim file lock forces the quit→hand-off→rebuild dance), // there's no mandatory file locking here, so the desktop can drive the // whole update itself: `hermes update` (backend) + `hermes desktop // --build-only` (OS-aware GUI rebuild), then swap the running .app bundle // with the freshly built one and relaunch. return await applyUpdatesPosixInApp(opts) } if (!updater) { // No staged updater binary — this is a CLI-installed user (they ran // `hermes desktop`, never the Tauri installer that self-copies // hermes-setup.exe into HERMES_HOME). They DO have a working `hermes` // on PATH / in the venv, so the correct path is the one-liner in their // native medium. We show the EXACT command, branch-pinned to the // checkout they're on — bare `hermes update` defaults to main and would // silently switch a bb/gui (or any non-main) install off-branch. Mirror // the GUI button's contract: append --branch for non-main // checkouts, keep it bare for main so the card stays clean. const updateRoot = resolveUpdateRoot() let command = 'hermes update' try { const head = await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: updateRoot }) const branch = (head.stdout || '').trim() if (head.code === 0 && branch && branch !== 'HEAD' && branch !== 'main') { command = `hermes update --branch ${branch}` } } catch { // Best-effort: fall back to bare `hermes update` if branch detection fails. } rememberLog(`[updates] no staged updater; surfacing manual \`${command}\` for CLI install at ${updateRoot}`) emitUpdateProgress({ stage: 'manual', message: command, percent: null }) return { ok: true, manual: true, command, hermesRoot: updateRoot } } emitUpdateProgress({ stage: 'restart', message: 'Handing off to the Hermes updater…', percent: 100 }) // Detached so the updater outlives this process — it needs us GONE before // `hermes update` will run (the venv shim is locked while we live). const child = spawn(updater, ['--update'], { detached: true, stdio: 'ignore', windowsHide: false }) child.unref() rememberLog(`[updates] launched updater: ${updater} --update; exiting desktop to release venv shim`) // Give the OS a beat to register the new process, then quit. The updater // rebuilds and relaunches us when it's done. setTimeout(() => { app.quit() }, 600) return { ok: true, handedOff: true, updater } } finally { updateInFlight = false } } // Resolve the hermes CLI to drive an in-app update: prefer the venv shim in // the install we're updating, fall back to `hermes` on PATH. function resolveHermesCliBinary(updateRoot) { const venvHermes = path.join(updateRoot, 'venv', 'bin', 'hermes') if (fileExists(venvHermes)) return venvHermes return findOnPath('hermes') || null } // Spawn a command and stream each output line to the update progress channel. function runStreamedUpdate(command, args, { cwd, env, stage } = {}) { return new Promise(resolve => { let child try { child = spawn(command, args, { cwd, env: { ...process.env, ...(env || {}) }, stdio: ['ignore', 'pipe', 'pipe'] }) } catch (err) { resolve({ code: 1, error: err.message }) return } const emitLines = chunk => { for (const line of chunk.toString().split('\n')) { const trimmed = line.trim() if (trimmed) emitUpdateProgress({ stage, message: trimmed, percent: null }) } } child.stdout.on('data', emitLines) child.stderr.on('data', emitLines) child.once('error', err => resolve({ code: 1, error: err.message })) child.once('exit', code => resolve({ code })) }) } // The running app's .app bundle (packaged macOS): execPath is // .app/Contents/MacOS/; climb three levels to the bundle root. function runningAppBundle() { if (!IS_MAC) return null let dir = path.dirname(app.getPath('exe')) // .../Contents/MacOS for (let i = 0; i < 2; i++) dir = path.dirname(dir) // -> .../X.app return dir.endsWith('.app') ? dir : null } function shellQuote(value) { return `'${String(value).replace(/'/g, `'\\''`)}'` } // macOS/Linux in-app update: backend (`hermes update`) + OS-aware GUI rebuild // (`hermes desktop --build-only`), then atomically swap the running .app bundle // with the freshly built one and relaunch. Degrades to "backend updated, // restart to load the new GUI" if the swap can't be performed. async function applyUpdatesPosixInApp(opts = {}) { const updateRoot = resolveUpdateRoot() const hermes = resolveHermesCliBinary(updateRoot) if (!hermes) { emitUpdateProgress({ stage: 'manual', message: 'hermes update', percent: null }) return { ok: true, manual: true, command: 'hermes update', hermesRoot: updateRoot } } // Put the Hermes-managed Node and the venv on PATH so `hermes desktop`'s // npm build can find them on a machine with no system Node. const extraPath = [path.join(HERMES_HOME, 'node', 'bin'), path.join(updateRoot, 'venv', 'bin')] .filter(Boolean) .join(path.delimiter) const env = { HERMES_HOME, PATH: [extraPath, process.env.PATH].filter(Boolean).join(path.delimiter) } // Branch-pin so a non-main checkout doesn't get switched to main. let branchArgs = [] try { const head = await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: updateRoot }) const branch = (head.stdout || '').trim() if (head.code === 0 && branch && branch !== 'HEAD') branchArgs = ['--branch', branch] } catch { // best effort } emitUpdateProgress({ stage: 'update', message: 'Updating Hermes (git + dependencies)…', percent: 10 }) const updated = await runStreamedUpdate(hermes, ['update', '--yes', ...branchArgs], { cwd: updateRoot, env, stage: 'update' }) if (updated.code !== 0) { emitUpdateProgress({ stage: 'error', message: 'hermes update failed.', error: updated.error || 'update-failed' }) return { ok: false, error: 'hermes update failed' } } emitUpdateProgress({ stage: 'rebuild', message: 'Rebuilding the desktop app…', percent: 60 }) const rebuilt = await runStreamedUpdate(hermes, ['desktop', '--build-only'], { cwd: updateRoot, env, stage: 'rebuild' }) if (rebuilt.code !== 0) { emitUpdateProgress({ stage: 'error', message: 'Backend updated, but the desktop rebuild failed. Restart Hermes to retry.', error: rebuilt.error || 'rebuild-failed' }) return { ok: false, backendUpdated: true, error: 'desktop rebuild failed' } } const rebuiltApp = [ path.join(updateRoot, 'apps', 'desktop', 'release', 'mac-arm64', 'Hermes.app'), path.join(updateRoot, 'apps', 'desktop', 'release', 'mac', 'Hermes.app') ].find(directoryExists) const targetApp = runningAppBundle() // No bundle to swap (dev run, Linux AppImage, or unresolved paths): the // backend is updated; the next launch picks up the rebuilt GUI. if (!rebuiltApp || !targetApp) { emitUpdateProgress({ stage: 'done', message: 'Backend updated. Restart Hermes to load the new version.', percent: 100 }) return { ok: true, backendUpdated: true, rebuiltApp: rebuiltApp || null } } emitUpdateProgress({ stage: 'restart', message: 'Installing the updated app and restarting…', percent: 95 }) // Detached swapper: wait for THIS process to exit (so the bundle is free), // ditto the rebuilt app over the running one, clear quarantine, relaunch. const swapScript = `#!/bin/bash set -u APP_PID=${process.pid} SRC=${shellQuote(rebuiltApp)} DST=${shellQuote(targetApp)} for _ in $(seq 1 240); do kill -0 "$APP_PID" 2>/dev/null || break sleep 0.5 done if [ "$SRC" != "$DST" ]; then if /usr/bin/ditto "$SRC" "$DST.hermes-update-new"; then rm -rf "$DST.hermes-update-old" 2>/dev/null || true mv "$DST" "$DST.hermes-update-old" 2>/dev/null || rm -rf "$DST" mv "$DST.hermes-update-new" "$DST" rm -rf "$DST.hermes-update-old" 2>/dev/null || true fi fi /usr/bin/xattr -dr com.apple.quarantine "$DST" 2>/dev/null || true /usr/bin/open "$DST" ` const scriptPath = path.join(app.getPath('temp'), `hermes-desktop-update-${Date.now()}.sh`) try { fs.writeFileSync(scriptPath, swapScript, { mode: 0o755 }) } catch (err) { emitUpdateProgress({ stage: 'done', message: 'Backend + app updated. Restart Hermes to load the new version.', percent: 100 }) rememberLog(`[updates] could not write swap script: ${err.message}; rebuilt app at ${rebuiltApp}`) return { ok: true, backendUpdated: true, rebuiltApp } } const child = spawn('/bin/bash', [scriptPath], { detached: true, stdio: 'ignore' }) child.unref() rememberLog(`[updates] launched mac swap+relaunch: ${scriptPath} (${rebuiltApp} -> ${targetApp})`) setTimeout(() => app.quit(), 600) return { ok: true, handedOff: true, rebuiltApp, targetApp } } function readJson(filePath) { try { return JSON.parse(fs.readFileSync(filePath, 'utf8')) } catch { return null } } // Bootstrap-complete marker helpers. The marker is written ONCE by the // first-launch bootstrap runner (Phase 1D) after install.ps1 stages succeed // AND the user has finished initial configuration. On every subsequent boot // we check `isBootstrapComplete()` and skip the bootstrap flow entirely if // the marker is present and current-schema. // // Marker schema (version 1): // { // schemaVersion: 1, // pinnedCommit: "<40-char SHA>", // what install.ps1 was driven against // pinnedBranch: "" | null, // completedAt: "", // desktopVersion: "" // for forensics // } function readBootstrapMarker() { return readJson(BOOTSTRAP_COMPLETE_MARKER) } function isBootstrapComplete() { const marker = readBootstrapMarker() if (!marker || typeof marker !== 'object') return false if (marker.schemaVersion !== BOOTSTRAP_MARKER_SCHEMA_VERSION) return false if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) return false // We DELIBERATELY do NOT verify that the checkout is currently at the // pinned commit -- users update via the in-app update path or `hermes // update`, which moves HEAD legitimately. The marker just attests "we // ran the bootstrap successfully at least once." We DO additionally require // a runnable venv: an interrupted or split-home install can leave the marker // + checkout without a venv, and trusting that spawns a dead backend // ("gateway offline") instead of re-running bootstrap to repair it. return isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(getVenvPython(VENV_ROOT)) } function writeBootstrapMarker(payload) { fs.mkdirSync(path.dirname(BOOTSTRAP_COMPLETE_MARKER), { recursive: true }) const merged = { schemaVersion: BOOTSTRAP_MARKER_SCHEMA_VERSION, pinnedCommit: payload.pinnedCommit || null, pinnedBranch: payload.pinnedBranch || null, completedAt: new Date().toISOString(), desktopVersion: app.getVersion() } fs.writeFileSync(BOOTSTRAP_COMPLETE_MARKER, JSON.stringify(merged, null, 2) + '\n', 'utf8') return merged } function resolveWebDist() { const override = process.env.HERMES_DESKTOP_WEB_DIST if (override && directoryExists(path.resolve(override))) return path.resolve(override) const unpackedDist = path.join(unpackedPathFor(APP_ROOT), 'dist') if (directoryExists(unpackedDist)) return unpackedDist return path.join(APP_ROOT, 'dist') } function resolveRendererIndex() { const candidates = [path.join(APP_ROOT, 'dist', 'index.html'), path.join(resolveWebDist(), 'index.html')] return candidates.find(fileExists) || candidates[0] } function resolveHermesCwd() { const candidates = [ process.env.HERMES_DESKTOP_CWD, process.env.INIT_CWD, process.cwd(), !IS_PACKAGED ? SOURCE_REPO_ROOT : null, app.getPath('home') ] for (const candidate of candidates) { if (!candidate) continue const resolved = path.resolve(String(candidate)) if (directoryExists(resolved)) return resolved } return app.getPath('home') } function createPythonBackend(root, label, dashboardArgs, options = {}) { const python = findPythonForRoot(root) if (!python) return null return { kind: 'python', label, command: python, args: ['-m', 'hermes_cli.main', ...dashboardArgs], env: { PYTHONPATH: [root, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter) }, root, bootstrap: Boolean(options.bootstrap), shell: false } } // createActiveBackend — build a backend pointing at ACTIVE_HERMES_ROOT, the // canonical install location shared with the CLI installer. The venv at // VENV_ROOT may not exist yet on first run; bootstrap=true tells // ensureRuntime() to create / refresh it before launch. function createActiveBackend(dashboardArgs) { const venvPython = getVenvPython(VENV_ROOT) return { kind: 'python', label: `Hermes at ${ACTIVE_HERMES_ROOT}`, command: fileExists(venvPython) ? venvPython : findSystemPython(), args: ['-m', 'hermes_cli.main', ...dashboardArgs], env: { PYTHONPATH: [ACTIVE_HERMES_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter) }, root: ACTIVE_HERMES_ROOT, bootstrap: true, shell: false } } function resolveHermesBackend(dashboardArgs) { // 1. Explicit override -- HERMES_DESKTOP_HERMES_ROOT points at a developer // checkout. Honour it as-is (no bootstrap; the user is driving). const overrideRoot = process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT) if (overrideRoot && isHermesSourceRoot(overrideRoot)) { const backend = createPythonBackend(overrideRoot, `Hermes source at ${overrideRoot}`, dashboardArgs) if (backend) return backend } // 2. Development source -- when running `npm run dev` from a checkout, the // cloned repo at SOURCE_REPO_ROOT takes precedence over ACTIVE and any // installed `hermes` on PATH so local Python edits are actually exercised. // (In dev with no checkout, SOURCE_REPO_ROOT won't pass isHermesSourceRoot.) if (!IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT)) { const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, dashboardArgs) if (backend) return backend } // 3. Bootstrap-complete ACTIVE_HERMES_ROOT -- the canonical install at // %LOCALAPPDATA%\hermes\hermes-agent (Windows) or ~/.hermes/hermes-agent. // The bootstrap marker means install.ps1 stages finished and the user // completed initial configuration; we trust the install and go straight // to spawning hermes. Updates flow through the in-app update path // (applyUpdates -> git pull) or `hermes update` from the CLI. if (isBootstrapComplete()) { return createActiveBackend(dashboardArgs) } // 4. Existing `hermes` on PATH -- installed via install.ps1 / install.sh from // a previous tool-only setup, or pip-installed system-wide. Use it but // do NOT write a bootstrap marker; the user did this themselves and we // don't want to take ownership of an install we didn't perform. // HERMES_DESKTOP_IGNORE_EXISTING=1 forces the bootstrap path for testing. if (process.env.HERMES_DESKTOP_IGNORE_EXISTING !== '1') { 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) { if (looksLikeDesktopAppBinary(hermesCommand)) { rememberLog(`Ignoring desktop app executable on PATH while resolving Hermes CLI: ${hermesCommand}`) hermesCommand = null } } if (hermesCommand) { // Smoke-test the candidate before trusting it. A `hermes` shim // left behind by a half-uninstalled pip install (or a venv // entry-point pointing at a deleted interpreter) still resolves // via findOnPath but explodes on spawn -- the user then sees a // dead backend instead of the first-launch installer. The cheap // `--version` probe (see backend-probes.cjs) catches that case // and lets the resolver fall through to step 6 / bootstrap. const shellForProbe = isCommandScript(hermesCommand) if (verifyHermesCli(hermesCommand, { shell: shellForProbe })) { return { label: `existing Hermes CLI at ${hermesCommand}`, command: hermesCommand, args: dashboardArgs, bootstrap: false, env: {}, kind: 'command', shell: shellForProbe } } rememberLog( `Ignoring existing Hermes CLI at ${hermesCommand}: --version probe failed; falling through to bootstrap.` ) } } // 5. Last-ditch: pip-installed hermes_cli module via system Python. // Same rationale as #4 -- the user installed this; we use it but don't // take ownership. const python = findSystemPython() if (python) { // Same smoke-test rationale as step 4: a system Python in the // SUPPORTED_VERSIONS range can be registered (PEP 514) without // having hermes_cli installed -- common on dev boxes that have // a python.org install from prior unrelated work. Returning that // backend hands the spawn step a guaranteed ModuleNotFoundError. // Verify the import works before trusting the candidate; on // failure, fall through to step 6 so the bootstrap runner pulls // a uv-managed 3.11 into %LOCALAPPDATA%\hermes\hermes-agent\venv. if (canImportHermesCli(python)) { return { kind: 'python', label: `installed hermes_cli module via ${python}`, command: python, args: ['-m', 'hermes_cli.main', ...dashboardArgs], bootstrap: false, env: {}, shell: false } } rememberLog(`Ignoring system Python ${python}: hermes_cli is not importable; falling through to bootstrap.`) } // 6. Nothing usable yet -- signal the bootstrap runner that we need to // clone+install. Phase 1D's bootstrap-runner consumes this sentinel // and drives install.ps1 stages with a progress UI. Until 1D lands, // callers see the sentinel and surface it as a user-facing error // explaining what's missing. // // We deliberately do NOT throw here -- throwing inside // resolveHermesBackend was the old "no payload" path and forced the // user into a dead end. With the bootstrap protocol, "no install yet" // is a recoverable state the GUI can drive through. return { kind: 'bootstrap-needed', label: 'Hermes Agent not installed yet; bootstrap required', command: null, args: dashboardArgs, bootstrap: true, env: {}, shell: false, // Hints for the bootstrap runner / UI layer: activeRoot: ACTIVE_HERMES_ROOT, installStamp: INSTALL_STAMP, // may be null in dev isPackaged: IS_PACKAGED, platform: process.platform } } async function ensureRuntime(backend) { if (!backend.bootstrap) { await advanceBootProgress('runtime.external', `Using ${backend.label}`, 32) return backend } // backend.kind === 'bootstrap-needed' means resolveHermesBackend couldn't // find anything to spawn. Hand off to the bootstrap runner which drives the // platform installer, writes the bootstrap-complete marker on success, then // we re-resolve to get the now-installed backend. // // Phase 1D status: bootstrap runs but events go to desktop.log only // (renderer window isn't created until later in startBackend). Phase 1E // will rewire startup to spawn the window first and route bootstrap events // to a renderer-side install overlay. if (backend.kind === 'bootstrap-needed') { rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap') // Eagerly flip the bootstrap UI state to 'active' so the renderer // shows the install overlay BEFORE the runner finishes fetching the // manifest (which on slow networks can take tens of seconds and would // otherwise leave the user staring at the generic 'Preparing' splash). // We emit a synthetic manifest with an empty stages list -- the real // manifest event will overwrite it once install.ps1 -Manifest returns. try { broadcastBootstrapEvent({ type: 'manifest', stages: [], protocolVersion: null }) } catch {} const bootstrapResult = await runBootstrap({ installStamp: backend.installStamp, activeRoot: backend.activeRoot, sourceRepoRoot: SOURCE_REPO_ROOT, hermesHome: HERMES_HOME, logRoot: path.join(HERMES_HOME, 'logs'), onEvent: ev => { // Tee every bootstrap event to (a) the desktop log for forensics // and (b) the renderer for live progress UI. Either may be absent; // tolerate both gracefully so a renderer crash doesn't stall the // bootstrap and a log-write failure doesn't suppress the UI signal. try { rememberLog(`[bootstrap] ${JSON.stringify(ev)}`) } catch {} try { broadcastBootstrapEvent(ev) } catch {} }, writeMarker: writeBootstrapMarker }) if (!bootstrapResult.ok) { const bootstrapError = new Error( `Hermes bootstrap failed${bootstrapResult.failedStage ? ` at stage '${bootstrapResult.failedStage}'` : ''}: ` + `${bootstrapResult.error || 'unknown error'}. ` + `Check ${path.join(HERMES_HOME, 'logs', 'desktop.log')} for the full transcript.` ) bootstrapError.isBootstrapFailure = true bootstrapError.failedStage = bootstrapResult.failedStage || null // Latch the failure so subsequent startHermes() calls return this // same error without re-running install.ps1. Cleared by the // hermes:bootstrap:reset IPC (renderer's "Reload and retry"). bootstrapFailure = bootstrapError throw bootstrapError } rememberLog('[bootstrap] bootstrap complete; marker written. Re-resolving backend.') // Re-resolve now that the install exists. The new resolution lands in // step 3 (bootstrap-complete marker) and we recurse to wire venvPython. return ensureRuntime(resolveHermesBackend(backend.args)) } // bootstrap=true with a real backend (createActiveBackend path) means we // have a checkout and need to ensure the venv-derived Python command is // wired into the backend before launch. Same code path the old factory // sync flow exited through, minus all the factory/pip/marker machinery // (install.ps1 owns those concerns now and the bootstrap-complete marker // attests they ran successfully). if (!isHermesSourceRoot(ACTIVE_HERMES_ROOT)) { throw new Error( `Hermes install at ${ACTIVE_HERMES_ROOT} is missing or incomplete. ` + 'Reinstall via the desktop installer or scripts/install.ps1.' ) } // On Windows, preflight Git Bash. Hermes' terminal tool calls bash.exe // directly (tools/environments/local.py); without it the agent can't run // terminal commands. install.ps1's Stage-Git puts PortableGit at // %LOCALAPPDATA%\hermes\git\, which findGitBash() picks up, so for any // user who completed the bootstrap this is a no-op. For users who got // here via an external `hermes` on PATH, this check still helps. if (IS_WINDOWS && !findGitBash()) { throw new Error( 'Git for Windows is required for Hermes on Windows (provides Git Bash, ' + "which the agent's terminal tool uses). Install it from " + 'https://git-scm.com/download/win or run `winget install -e --id Git.Git`, ' + 'then relaunch Hermes.' ) } const venvPython = getVenvPython(VENV_ROOT) if (!fileExists(venvPython)) { // No venv at the expected location AND no bootstrap-needed sentinel // means we have a half-installed checkout: .git exists, source files // exist, but venv is missing or broken. This shouldn't happen in // normal flow because isBootstrapComplete() requires // isHermesSourceRoot() and the bootstrap writes the marker only after // install.ps1 succeeds. If we hit this, the user (or a deleted venv) // broke the invariant; tell them to re-run the install. throw new Error( `Hermes venv missing at ${VENV_ROOT}. Re-run the desktop installer or ` + '`scripts/install.ps1` to rebuild it.' ) } backend.command = venvPython backend.label = `Hermes at ${ACTIVE_HERMES_ROOT} (venv: ${VENV_ROOT})` updateBootProgress({ phase: 'runtime.ready', message: 'Hermes runtime is ready', progress: 82, running: true, error: null }) return backend } function isPortAvailable(port) { return new Promise(resolve => { const server = net.createServer() server.once('error', () => resolve(false)) server.once('listening', () => { server.close(() => resolve(true)) }) server.listen(port, '127.0.0.1') }) } async function pickPort() { for (let port = PORT_FLOOR; port <= PORT_CEILING; port += 1) { if (await isPortAvailable(port)) return port } throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`) } function fetchJson(url, token, options = {}) { return new Promise((resolve, reject) => { const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)) const parsed = new URL(url) const client = parsed.protocol === 'https:' ? https : http const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) return } const req = client.request( parsed, { method: options.method || 'GET', headers: { 'Content-Type': 'application/json', 'X-Hermes-Session-Token': token, ...(body ? { 'Content-Length': String(body.length) } : {}) } }, res => { const chunks = [] res.on('data', chunk => chunks.push(chunk)) res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8') if ((res.statusCode || 500) >= 400) { reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`)) return } if (!text) { resolve(null) return } // A 2xx response whose body is HTML means the request fell through // to the SPA index.html (e.g. an unregistered /api path). JSON.parse // would throw an opaque `Unexpected token '<'` here, so surface a // clear diagnostic with the offending URL instead. const looksHtml = /^\s*<(?:!doctype|html)/i.test(text) const contentType = String(res.headers['content-type'] || '') if (looksHtml || contentType.includes('text/html')) { reject( new Error( `Expected JSON from ${url} but got HTML (status ${res.statusCode}). ` + 'The endpoint is likely missing on the Hermes backend.' ) ) return } try { resolve(JSON.parse(text)) } catch { reject(new Error(`Invalid JSON from ${url} (status ${res.statusCode}): ${text.slice(0, 200)}`)) } }) } ) req.on('error', reject) req.setTimeout(timeoutMs, () => { req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) }) if (body) req.write(body) req.end() }) } function mimeTypeForPath(filePath) { const ext = path.extname(filePath || '').toLowerCase() return MEDIA_MIME_TYPES[ext] || 'application/octet-stream' } function extensionForMimeType(mimeType) { const type = String(mimeType || '') .split(';')[0] .trim() .toLowerCase() if (type === 'image/png') return '.png' if (type === 'image/jpeg') return '.jpg' if (type === 'image/gif') return '.gif' if (type === 'image/webp') return '.webp' if (type === 'image/bmp') return '.bmp' if (type === 'image/svg+xml') return '.svg' return '' } function filenameFromUrl(rawUrl, fallback = 'image') { try { const parsed = new URL(rawUrl) const base = path.basename(decodeURIComponent(parsed.pathname || '')) return base && base.includes('.') ? base : fallback } catch { return fallback } } // Link title resolution — curl (tier 1) → hidden BrowserWindow (tier 2). const titleCache = new Map() const titleInflight = new Map() const TITLE_CACHE_LIMIT = 500 const TITLE_BYTE_BUDGET = 96 * 1024 const TITLE_TIMEOUT_MS = 5000 const TITLE_MAX_REDIRECTS = 3 // Browser-shaped UA — many bot-walled sites (GetYourGuide, Cloudflare-protected // pages) refuse anything that doesn't look like a real Chrome. const TITLE_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36' const TITLE_ERROR_RE = /\b(access denied|attention required|captcha|error|forbidden|just a moment|request blocked|too many requests)\b/i const HTML_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', '#39': "'" } // Tier-2 renderer fallback config. Only invoked when curl came back empty or // matched TITLE_ERROR_RE — keeps cold/CDN-cached pages on the cheap path. const RENDER_TITLE_MAX_CONCURRENT = 2 const RENDER_TITLE_TIMEOUT_MS = 8000 const RENDER_TITLE_GRACE_MS = 700 // Resource types we cancel before the network even fires — keeps the hidden // renderer fast and cuts third-party tracking noise. const RENDER_TITLE_BLOCKED_RESOURCES = new Set([ 'cspReport', 'font', 'imageset', 'media', 'object', 'ping', 'stylesheet' ]) let linkTitleSession = null let renderTitleInFlight = 0 const renderTitleQueue = [] function canonicalTitleCacheKey(rawUrl) { const value = String(rawUrl || '').trim() if (!value) return '' try { const url = new URL(value) const host = url.hostname.replace(/^www\./i, '').toLowerCase() const pathname = url.pathname === '/' ? '/' : url.pathname.replace(/\/+$/, '') || '/' return `${host}${pathname}${url.search || ''}` } catch { return value } } function cacheTitle(key, title) { if (titleCache.size >= TITLE_CACHE_LIMIT) titleCache.delete(titleCache.keys().next().value) titleCache.set(key, title) } function decodeHtmlEntities(value) { return value .replace(/&(amp|lt|gt|quot|apos|nbsp|#39);/gi, (_, k) => HTML_ENTITIES[k.toLowerCase()] ?? '') .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16) || 32)) .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10) || 32)) } function parseHtmlTitle(html) { const raw = html.match(/]*>([\s\S]*?)<\/title>/i)?.[1] return raw ? decodeHtmlEntities(raw).replace(/\s+/g, ' ').trim() : '' } function fetchHtmlTitleWithCurl(rawUrl) { return new Promise(resolve => { const url = String(rawUrl || '').trim() if (!url) return resolve('') const args = [ '--silent', '--show-error', '--location', '--max-redirs', String(TITLE_MAX_REDIRECTS), '--max-time', String(Math.max(2, Math.ceil(TITLE_TIMEOUT_MS / 1000))), '--connect-timeout', '4', '--user-agent', TITLE_USER_AGENT, '--header', 'Accept: text/html,application/xhtml+xml;q=0.9,*/*;q=0.5', '--header', 'Accept-Language: en-US,en;q=0.7', '--header', 'Accept-Encoding: identity', '--raw', url ] const child = spawn('curl', args, { stdio: ['ignore', 'pipe', 'ignore'] }) const chunks = [] let bytes = 0 child.stdout.on('data', chunk => { if (bytes >= TITLE_BYTE_BUDGET) return const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) const remaining = TITLE_BYTE_BUDGET - bytes const next = buffer.length > remaining ? buffer.subarray(0, remaining) : buffer chunks.push(next) bytes += next.length }) child.on('error', () => resolve('')) child.on('close', () => { if (!chunks.length) return resolve('') resolve(parseHtmlTitle(Buffer.concat(chunks).toString('utf8'))) }) }) } function getLinkTitleSession() { if (linkTitleSession || !app.isReady()) return linkTitleSession linkTitleSession = session.fromPartition('hermes:link-titles', { cache: false }) linkTitleSession.webRequest.onBeforeRequest((details, callback) => { callback({ cancel: RENDER_TITLE_BLOCKED_RESOURCES.has(details.resourceType) }) }) return linkTitleSession } function dequeueRenderTitle() { while (renderTitleInFlight < RENDER_TITLE_MAX_CONCURRENT && renderTitleQueue.length) { const item = renderTitleQueue.shift() renderTitleInFlight += 1 runRenderTitleJob(item.url).then(title => { renderTitleInFlight -= 1 item.resolve(title) dequeueRenderTitle() }) } } function runRenderTitleJob(rawUrl) { return new Promise(resolve => { if (!app.isReady()) return resolve('') const partitionSession = getLinkTitleSession() if (!partitionSession) return resolve('') let settled = false let window = null let hardTimer = null let graceTimer = null const finish = title => { if (settled) return settled = true if (hardTimer) clearTimeout(hardTimer) if (graceTimer) clearTimeout(graceTimer) const value = (title || '').replace(/\s+/g, ' ').trim() try { if (window && !window.isDestroyed()) window.destroy() } catch { // BrowserWindow may already be torn down; ignore. } resolve(value) } try { window = new BrowserWindow({ show: false, width: 1280, height: 800, webPreferences: { backgroundThrottling: false, contextIsolation: true, javascript: true, nodeIntegration: false, sandbox: true, session: partitionSession, webSecurity: true } }) } catch { return finish('') } const readTitle = () => window?.webContents?.getTitle?.() || '' const scheduleGrace = () => { if (graceTimer) clearTimeout(graceTimer) graceTimer = setTimeout(() => finish(readTitle()), RENDER_TITLE_GRACE_MS) } hardTimer = setTimeout(() => finish(readTitle()), RENDER_TITLE_TIMEOUT_MS) window.webContents.setUserAgent(TITLE_USER_AGENT) window.webContents.on('page-title-updated', scheduleGrace) window.webContents.on('did-finish-load', scheduleGrace) window.webContents.on('did-fail-load', (_event, _code, _desc, _validatedURL, isMainFrame) => { if (isMainFrame) finish('') }) window .loadURL(rawUrl, { httpReferrer: 'https://www.google.com/', userAgent: TITLE_USER_AGENT }) .catch(() => finish('')) }) } function fetchHtmlTitleWithRenderer(rawUrl) { return new Promise(resolve => { renderTitleQueue.push({ resolve, url: rawUrl }) dequeueRenderTitle() }) } // Strips known error/captcha titles (e.g. "GetYourGuide – Error", "Just a // moment...") so they don't get cached as the resolved title. const usableTitle = value => (value && !TITLE_ERROR_RE.test(value) ? value : '') function fetchLinkTitle(rawUrl) { const url = String(rawUrl || '').trim() const key = canonicalTitleCacheKey(url) if (!key) return Promise.resolve('') if (titleCache.has(key)) return Promise.resolve(titleCache.get(key)) if (titleInflight.has(key)) return titleInflight.get(key) const pending = fetchHtmlTitleWithCurl(url) .catch(() => '') .then(value => usableTitle((value || '').slice(0, 240))) .then( async value => value || usableTitle(((await fetchHtmlTitleWithRenderer(url).catch(() => '')) || '').slice(0, 240)) ) .then(clean => { cacheTitle(key, clean) titleInflight.delete(key) return clean }) titleInflight.set(key, pending) return pending } async function resourceBufferFromUrl(rawUrl) { if (!rawUrl) throw new Error('Missing URL') if (rawUrl.startsWith('data:')) { const match = rawUrl.match(/^data:([^;,]+)?(;base64)?,(.*)$/s) if (!match) throw new Error('Invalid data URL') const mimeType = match[1] || 'application/octet-stream' const encoded = match[3] || '' const buffer = match[2] ? Buffer.from(encoded, 'base64') : Buffer.from(decodeURIComponent(encoded), 'utf8') return { buffer, mimeType } } if (rawUrl.startsWith('file:')) { const filePath = fileURLToPath(rawUrl) const buffer = await fs.promises.readFile(filePath) return { buffer, mimeType: mimeTypeForPath(filePath) } } const parsed = new URL(rawUrl) const client = parsed.protocol === 'https:' ? https : http return new Promise((resolve, reject) => { const req = client.get(parsed, res => { if ((res.statusCode || 500) >= 400) { reject(new Error(`Failed to fetch ${rawUrl}: ${res.statusCode}`)) res.resume() return } const chunks = [] res.on('data', chunk => chunks.push(chunk)) res.on('end', () => { resolve({ buffer: Buffer.concat(chunks), mimeType: res.headers['content-type'] || 'application/octet-stream' }) }) }) req.on('error', reject) }) } async function copyImageFromUrl(rawUrl) { const { buffer } = await resourceBufferFromUrl(rawUrl) const image = nativeImage.createFromBuffer(buffer) if (image.isEmpty()) throw new Error('Could not read image') clipboard.writeImage(image) } async function saveImageFromUrl(rawUrl) { const { buffer, mimeType } = await resourceBufferFromUrl(rawUrl) const fallbackName = filenameFromUrl(rawUrl, `image${extensionForMimeType(mimeType) || '.png'}`) const result = await dialog.showSaveDialog(mainWindow, { title: 'Save Image', defaultPath: fallbackName }) if (result.canceled || !result.filePath) return false await fs.promises.writeFile(result.filePath, buffer) return true } async function writeComposerImage(buffer, ext = '.png') { const rawExt = String(ext || '.png') .trim() .toLowerCase() const normalizedExt = rawExt.startsWith('.') ? rawExt : `.${rawExt}` const safeExt = /^\.[a-z0-9]{1,5}$/.test(normalizedExt) ? normalizedExt : '.png' const dir = path.join(app.getPath('userData'), 'composer-images') await fs.promises.mkdir(dir, { recursive: true }) const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').replace('Z', '') const random = crypto.randomBytes(3).toString('hex') const filePath = path.join(dir, `composer_${stamp}_${random}${safeExt}`) await fs.promises.writeFile(filePath, buffer) return filePath } function previewLabelForUrl(url) { return `${url.host}${url.pathname === '/' ? '' : url.pathname}` } function expandUserPath(filePath) { const value = String(filePath || '').trim() if (value === '~') { return app.getPath('home') } if (value.startsWith(`~${path.sep}`) || value.startsWith('~/')) { return path.join(app.getPath('home'), value.slice(2)) } return value } function previewFileTarget(rawTarget, baseDir) { const raw = String(rawTarget || '').trim() const base = baseDir ? path.resolve(expandUserPath(baseDir)) : resolveHermesCwd() const filePath = raw.startsWith('file:') ? fileURLToPath(raw) : path.resolve(base, expandUserPath(raw)) let resolved = filePath if (directoryExists(resolved)) { resolved = path.join(resolved, 'index.html') } const ext = path.extname(resolved).toLowerCase() if (!fileExists(resolved)) { return null } const mimeType = mimeTypeForPath(resolved) const metadata = previewFileMetadata(resolved, mimeType) const isHtml = PREVIEW_HTML_EXTENSIONS.has(ext) const isImage = mimeType.startsWith('image/') const previewKind = isHtml ? 'html' : isImage ? 'image' : metadata.binary ? 'binary' : 'text' return { binary: metadata.binary, byteSize: metadata.byteSize, kind: 'file', large: metadata.large, label: path.basename(resolved), language: PREVIEW_LANGUAGE_BY_EXT[ext] || 'text', mimeType, path: resolved, previewKind, source: raw, url: pathToFileURL(resolved).toString() } } function previewUrlTarget(rawTarget) { const raw = String(rawTarget || '').trim() const url = new URL(raw) if (!['http:', 'https:'].includes(url.protocol)) { return null } if (!LOCAL_PREVIEW_HOSTS.has(url.hostname.toLowerCase())) { return null } if (url.hostname === '0.0.0.0') { url.hostname = '127.0.0.1' } return { kind: 'url', label: previewLabelForUrl(url), source: raw, url: url.toString() } } function normalizePreviewTarget(rawTarget, baseDir) { const raw = String(rawTarget || '').trim() if (!raw) { return null } try { if (/^https?:\/\//i.test(raw)) { return previewUrlTarget(raw) } return previewFileTarget(raw, baseDir) } catch { return null } } function filePathFromPreviewUrl(rawUrl) { const filePath = fileURLToPath(String(rawUrl || '')) if (!fileExists(filePath)) { throw new Error('Preview file is not readable') } return filePath } function sendPreviewFileChanged(payload) { if (!mainWindow || mainWindow.isDestroyed()) return const { webContents } = mainWindow if (!webContents || webContents.isDestroyed()) return webContents.send('hermes:preview-file-changed', payload) } function watchPreviewFile(rawUrl) { const filePath = filePathFromPreviewUrl(rawUrl) const watchDir = path.dirname(filePath) const targetName = path.basename(filePath) const id = crypto.randomBytes(12).toString('base64url') let timer = null const watcher = fs.watch(watchDir, (_eventType, filename) => { const changedName = filename ? path.basename(String(filename)) : '' if (changedName && changedName !== targetName) { return } if (timer) clearTimeout(timer) timer = setTimeout(() => { timer = null if (!fileExists(filePath)) return sendPreviewFileChanged({ id, path: filePath, url: pathToFileURL(filePath).toString() }) }, PREVIEW_WATCH_DEBOUNCE_MS) }) previewWatchers.set(id, { close: () => { if (timer) clearTimeout(timer) watcher.close() } }) return { id, path: filePath } } function stopPreviewFileWatch(id) { const watcher = previewWatchers.get(id) if (!watcher) { return false } watcher.close() previewWatchers.delete(id) return true } function closePreviewWatchers() { for (const id of previewWatchers.keys()) { stopPreviewFileWatch(id) } } async function waitForHermes(baseUrl, token) { const deadline = Date.now() + 45_000 let lastError = null while (Date.now() < deadline) { try { await fetchJson(`${baseUrl}/api/status`, token) return } catch (error) { lastError = error await new Promise(resolve => setTimeout(resolve, 500)) } } throw new Error(`Hermes backend did not become ready: ${lastError?.message || 'timeout'}`) } function getWindowButtonPosition() { if (!IS_MAC) return null return mainWindow?.getWindowButtonPosition?.() || WINDOW_BUTTON_POSITION } function getNativeOverlayWidth() { // macOS reports traffic-light coords via windowButtonPosition; the // titlebarOverlay there doesn't reserve right-edge space. Windows/Linux // render the native window-controls overlay on the right, so the renderer // needs to inset its right cluster by this much to clear them. return IS_MAC ? 0 : NATIVE_OVERLAY_BUTTON_WIDTH } function getWindowState() { return { isFullscreen: Boolean(mainWindow?.isFullScreen?.()), nativeOverlayWidth: getNativeOverlayWidth(), windowButtonPosition: getWindowButtonPosition() } } function sendBackendExit(payload) { if (!mainWindow || mainWindow.isDestroyed()) return const { webContents } = mainWindow if (!webContents || webContents.isDestroyed()) return webContents.send('hermes:backend-exit', payload) } function sendClosePreviewRequested() { if (!mainWindow || mainWindow.isDestroyed()) return const { webContents } = mainWindow if (!webContents || webContents.isDestroyed()) return webContents.send('hermes:close-preview-requested') } function getAppIconPath() { return APP_ICON_PATHS.find(fileExists) } function sendOpenUpdatesRequested() { if (!mainWindow || mainWindow.isDestroyed()) return const { webContents } = mainWindow if (!webContents || webContents.isDestroyed()) return webContents.send('hermes:open-updates') if (!mainWindow.isVisible()) mainWindow.show() mainWindow.focus() } function sendWindowStateChanged(nextIsFullscreen) { if (!mainWindow || mainWindow.isDestroyed()) return const { webContents } = mainWindow if (!webContents || webContents.isDestroyed()) return const state = getWindowState() if (typeof nextIsFullscreen === 'boolean') { state.isFullscreen = nextIsFullscreen } webContents.send('hermes:window-state-changed', state) } function buildApplicationMenu() { const template = [] const checkForUpdatesItem = { label: 'Check for Updates…', click: () => sendOpenUpdatesRequested() } if (IS_MAC) { template.push({ label: APP_NAME, submenu: [ { role: 'about', label: `About ${APP_NAME}` }, checkForUpdatesItem, { type: 'separator' }, { role: 'services' }, { type: 'separator' }, { role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }, { role: 'quit' } ] }) } template.push({ label: 'File', submenu: [ IS_MAC ? { accelerator: 'CommandOrControl+W', click: () => { if (previewShortcutActive) { sendClosePreviewRequested() } else { mainWindow?.close() } }, label: 'Close' } : { role: 'quit' } ] }) template.push({ label: 'Edit', submenu: [ { role: 'undo' }, { role: 'redo' }, { type: 'separator' }, { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'delete' }, { role: 'selectAll' } ] }) template.push({ label: 'View', submenu: [ { role: 'reload' }, { role: 'forceReload' }, { role: 'toggleDevTools' }, { type: 'separator' }, { role: 'resetZoom' }, { role: 'zoomIn' }, { role: 'zoomOut' }, { type: 'separator' }, { role: 'togglefullscreen' } ] }) template.push({ label: 'Window', submenu: IS_MAC ? [{ role: 'minimize' }, { role: 'zoom' }, { role: 'front' }] : [{ role: 'minimize' }, { role: 'close' }] }) template.push({ label: 'Help', role: 'help', submenu: [checkForUpdatesItem] }) return Menu.buildFromTemplate(template) } function toggleDevTools(window) { // DevTools is enabled in packaged builds so users can diagnose renderer // issues without needing a dev build. Trade-off: tiny attack surface // increase versus a much better support story when WS connection or // CSP issues surface in the field. const { webContents } = window if (webContents.isDevToolsOpened()) { webContents.closeDevTools() } else { webContents.openDevTools({ mode: 'detach' }) } } function installDevToolsShortcut(window) { // F12 / Cmd+Opt+I works in both dev and packaged builds. window.webContents.on('before-input-event', (event, input) => { const key = input.key.toLowerCase() const isInspectShortcut = input.key === 'F12' || (IS_MAC && input.meta && input.alt && key === 'i') || (!IS_MAC && input.control && input.shift && key === 'i') if (!isInspectShortcut) return event.preventDefault() toggleDevTools(window) }) } function installPreviewShortcut(window) { window.webContents.on('before-input-event', (event, input) => { const key = String(input.key || '').toLowerCase() const isPreviewCloseShortcut = key === 'w' && (IS_MAC ? input.meta : input.control) && !input.alt && !input.shift if (!isPreviewCloseShortcut || !previewShortcutActive) return event.preventDefault() sendClosePreviewRequested() }) } function installContextMenu(window) { window.webContents.on('context-menu', (_event, params) => { const template = [] const hasSelection = Boolean(params.selectionText?.trim()) const hasImage = params.mediaType === 'image' && Boolean(params.srcURL) const hasLink = Boolean(params.linkURL) const isEditable = Boolean(params.isEditable) if (hasImage) { template.push( { label: 'Open Image', click: () => { if (params.srcURL && !params.srcURL.startsWith('data:')) { openExternalUrl(params.srcURL) } }, enabled: !params.srcURL.startsWith('data:') }, { label: 'Copy Image', click: () => { void copyImageFromUrl(params.srcURL).catch(error => rememberLog(`Copy image failed: ${error.message}`)) } }, { label: 'Copy Image Address', click: () => clipboard.writeText(params.srcURL) }, { label: 'Save Image As...', click: () => { void saveImageFromUrl(params.srcURL).catch(error => rememberLog(`Save image failed: ${error.message}`)) } } ) } if (hasLink) { if (template.length) template.push({ type: 'separator' }) template.push( { label: 'Open Link', click: () => openExternalUrl(params.linkURL) }, { label: 'Copy Link', click: () => clipboard.writeText(params.linkURL) } ) } if (hasSelection || isEditable) { if (template.length) template.push({ type: 'separator' }) if (isEditable) { template.push( { role: 'cut', enabled: params.editFlags.canCut }, { role: 'copy', enabled: params.editFlags.canCopy }, { role: 'paste', enabled: params.editFlags.canPaste }, { type: 'separator' }, { role: 'selectAll', enabled: params.editFlags.canSelectAll } ) } else { template.push({ role: 'copy', enabled: params.editFlags.canCopy }) } } if (!template.length) { template.push({ role: 'selectAll' }) } Menu.buildFromTemplate(template).popup({ window }) }) } function installMediaPermissions() { session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback, details) => { if (permission === 'media' && details?.mediaTypes?.includes('audio')) { callback(true) return } callback(false) }) } function normalizeRemoteBaseUrl(rawUrl) { const value = String(rawUrl || '').trim() if (!value) { throw new Error('Remote gateway URL is required.') } let parsed try { parsed = new URL(value) } catch (error) { throw new Error(`Remote gateway URL is not valid: ${error.message}`) } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new Error(`Remote gateway URL must be http:// or https://, got ${parsed.protocol}`) } parsed.hash = '' parsed.search = '' parsed.pathname = parsed.pathname.replace(/\/+$/, '') return parsed.toString().replace(/\/+$/, '') } function buildGatewayWsUrl(baseUrl, token) { const parsed = new URL(baseUrl) const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws' const prefix = parsed.pathname.replace(/\/+$/, '') return `${wsScheme}://${parsed.host}${prefix}/api/ws?token=${encodeURIComponent(token)}` } function tokenPreview(value) { const raw = String(value || '') if (!raw) { return null } return raw.length <= 8 ? 'set' : `...${raw.slice(-6)}` } function encryptDesktopSecret(value) { return encryptDesktopSecretStrict(value, safeStorage) } function decryptDesktopSecret(secret) { if (!secret || typeof secret !== 'object') { return '' } const value = String(secret.value || '') if (!value) { return '' } if (secret.encoding === 'safeStorage') { try { return safeStorage.decryptString(Buffer.from(value, 'base64')) } catch { return '' } } return value } function readDesktopConnectionConfig() { if (connectionConfigCache) { return connectionConfigCache } let config = { mode: 'local', remote: {} } try { const raw = fs.readFileSync(DESKTOP_CONNECTION_CONFIG_PATH, 'utf8') const parsed = JSON.parse(raw) if (parsed && typeof parsed === 'object') { config = { mode: parsed.mode === 'remote' ? 'remote' : 'local', remote: parsed.remote && typeof parsed.remote === 'object' ? parsed.remote : {} } } } catch { // Missing or malformed connection settings should fall back to local. } connectionConfigCache = config return config } function writeDesktopConnectionConfig(config) { fs.mkdirSync(path.dirname(DESKTOP_CONNECTION_CONFIG_PATH), { recursive: true }) fs.writeFileSync(DESKTOP_CONNECTION_CONFIG_PATH, JSON.stringify(config, null, 2)) connectionConfigCache = config } function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig()) { const remoteToken = decryptDesktopSecret(config.remote?.token) return { mode: config.mode === 'remote' ? 'remote' : 'local', remoteUrl: String(config.remote?.url || ''), remoteTokenPreview: tokenPreview(remoteToken), remoteTokenSet: Boolean(remoteToken), envOverride: Boolean(process.env.HERMES_DESKTOP_REMOTE_URL) } } function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) { const persistToken = options.persistToken !== false const mode = input.mode === 'remote' ? 'remote' : 'local' const remoteUrl = String(input.remoteUrl ?? existing.remote?.url ?? '').trim() const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : '' const existingToken = existing.remote?.token const nextRemote = { url: remoteUrl, token: incomingToken ? persistToken ? encryptDesktopSecret(incomingToken) : { encoding: 'plain', value: incomingToken } : existingToken } if (mode === 'remote') { nextRemote.url = normalizeRemoteBaseUrl(remoteUrl) if (!decryptDesktopSecret(nextRemote.token)) { throw new Error('Remote gateway session token is required.') } } else if (remoteUrl) { nextRemote.url = normalizeRemoteBaseUrl(remoteUrl) } return { mode, remote: nextRemote } } function resolveRemoteBackend() { const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN if (rawEnvUrl) { if (!rawEnvToken) { throw new Error( 'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' + 'Both must be provided to connect to a remote Hermes backend.' ) } const baseUrl = normalizeRemoteBaseUrl(rawEnvUrl) return { baseUrl, mode: 'remote', source: 'env', token: rawEnvToken, wsUrl: buildGatewayWsUrl(baseUrl, rawEnvToken) } } const config = readDesktopConnectionConfig() if (config.mode !== 'remote') { return null } const token = decryptDesktopSecret(config.remote?.token) if (!token) { throw new Error( 'Remote Hermes gateway is selected, but no session token is saved. ' + 'Open Settings → Gateway and save a token, or switch back to Local.' ) } const baseUrl = normalizeRemoteBaseUrl(config.remote?.url) return { baseUrl, mode: 'remote', source: 'settings', token, wsUrl: buildGatewayWsUrl(baseUrl, token) } } async function testDesktopConnectionConfig(input = {}) { const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false }) const remote = config.mode === 'remote' ? { baseUrl: normalizeRemoteBaseUrl(config.remote.url), token: decryptDesktopSecret(config.remote.token) } : resolveRemoteBackend() || (await startHermes()) const status = await fetchJson(`${remote.baseUrl}/api/status`, remote.token, { timeoutMs: 8_000 }) return { ok: true, baseUrl: remote.baseUrl, version: status?.version || null } } function resetBootProgressForReconnect() { updateBootProgress( { error: null, message: 'Restarting desktop connection', phase: 'backend.resolve', progress: 4, running: true }, { allowDecrease: true } ) } function resetHermesConnection() { connectionPromise = null if (hermesProcess && !hermesProcess.killed) { hermesProcess.kill('SIGTERM') } hermesProcess = null resetBootProgressForReconnect() } async function startHermes() { // Latched-failure short-circuit: once bootstrap has failed in this // process, every subsequent startHermes() call re-throws the same error // without re-running install.ps1. This prevents the renderer's // ensureGatewayOpen retries (and any other getConnection callers) from // restarting a 5-10 minute install loop while the user is still reading // the failure overlay. if (bootstrapFailure) { throw bootstrapFailure } if (connectionPromise) return connectionPromise 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, mode: 'remote', source: remote.source, token: remote.token, wsUrl: remote.wsUrl, logs: hermesLog.slice(-80), ...getWindowState() } } 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 ensureRuntime(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, { cwd: hermesCwd, env: { ...process.env, // Explicitly pin HERMES_HOME for the child so Python's get_hermes_home() // resolves to the SAME location our resolveHermesHome() picked. Without // this pin, Python falls back to ~/.hermes on every platform — fine on // mac/linux (where our default matches), but on Windows our default is // %LOCALAPPDATA%\hermes, which differs from C:\Users\\.hermes. // Mismatch would split config / sessions / .env / logs across two // directories. install.ps1 sets HERMES_HOME via setx; the desktop // can't reliably do that, so we set it inline for every spawn. HERMES_HOME, ...backend.env, HERMES_DASHBOARD_SESSION_TOKEN: token, HERMES_DASHBOARD_TUI: '1', HERMES_WEB_DIST: webDist }, shell: backend.shell, stdio: ['ignore', 'pipe', 'pipe'] }) hermesProcess.stdout.on('data', rememberLog) hermesProcess.stderr.on('data', rememberLog) let backendReady = false let rejectBackendStart = null const backendStartFailed = new Promise((_resolve, reject) => { rejectBackendStart = reject }) 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 }) rejectBackendStart?.(error) }) hermesProcess.once('exit', (code, signal) => { rememberLog(`Hermes backend exited (${signal || code})`) hermesProcess = null connectionPromise = null sendBackendExit({ code, signal }) if (!backendReady) { const message = `Hermes backend exited before it became ready (${signal || code}).` updateBootProgress( { error: message, message, phase: 'backend.error', running: false }, { allowDecrease: true } ) rejectBackendStart?.( new Error( `Hermes backend exited before it became ready (${signal || code}). Log: ${DESKTOP_LOG_PATH}\n${recentHermesLog()}` ) ) } }) const baseUrl = `http://127.0.0.1:${port}` await advanceBootProgress('backend.wait', 'Waiting for Hermes backend 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, mode: 'local', source: 'local', token, wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`, logs: hermesLog.slice(-80), ...getWindowState() } })().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 } function createWindow() { const icon = getAppIconPath() mainWindow = new BrowserWindow({ width: 1220, height: 800, minWidth: 900, minHeight: 620, title: 'Hermes', // Frameless title bar on every platform so the renderer can paint the // "hide sidebar" button (and other left-side titlebar tools) flush with // the top edge — matching the macOS layout where the traffic lights sit // inside the same band. On Windows/Linux, titleBarOverlay tells Electron // to paint native min/max/close in the top-right of the renderer; on // macOS it just reserves a content inset alongside the traffic lights. titleBarStyle: 'hidden', titleBarOverlay: getTitleBarOverlayOptions(), trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined, vibrancy: IS_MAC ? 'sidebar' : undefined, icon, backgroundColor: '#f7f7f7', webPreferences: { preload: path.join(__dirname, 'preload.cjs'), contextIsolation: true, webviewTag: true, sandbox: true, nodeIntegration: false, devTools: true } }) if (IS_MAC) { mainWindow.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION) if (icon) { app.dock?.setIcon(icon) } } if (!IS_MAC) { nativeTheme.on('updated', () => { mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions()) }) } mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true)) mainWindow.on('enter-full-screen', () => sendWindowStateChanged(true)) mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false)) mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false)) installPreviewShortcut(mainWindow) installDevToolsShortcut(mainWindow) installContextMenu(mainWindow) mainWindow.webContents.setWindowOpenHandler(details => { openExternalUrl(details.url) return { action: 'deny' } }) mainWindow.webContents.on('will-navigate', (event, url) => { if ((DEV_SERVER && url.startsWith(DEV_SERVER)) || (!DEV_SERVER && url.startsWith('file:'))) { return } event.preventDefault() openExternalUrl(url) }) if (DEV_SERVER) { mainWindow.loadURL(DEV_SERVER) } else { mainWindow.loadURL(pathToFileURL(resolveRendererIndex()).toString()) } mainWindow.webContents.once('did-finish-load', () => { broadcastBootProgress() sendWindowStateChanged() startHermes().catch(error => rememberLog(error.stack || error.message)) }) } ipcMain.handle('hermes:connection', async () => startHermes()) ipcMain.handle('hermes:bootstrap:reset', async () => { // Renderer's "Reload and retry" path. Clear the latched failure and // reset connection state so the next startHermes() call restarts the // full backend flow (including a fresh runBootstrap pass). rememberLog('[bootstrap] reset requested by renderer; clearing latched failure') bootstrapFailure = null connectionPromise = null bootstrapState = { active: false, manifest: null, stages: {}, error: null, log: [], startedAt: null, completedAt: null, unsupportedPlatform: null } return { ok: true } }) ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState) ipcMain.handle('hermes:bootstrap:get', async () => getBootstrapState()) ipcMain.handle('hermes:connection-config:get', async () => sanitizeDesktopConnectionConfig()) ipcMain.handle('hermes:connection-config:test', async (_event, payload) => testDesktopConnectionConfig(payload)) ipcMain.handle('hermes:connection-config:save', async (_event, payload) => { const config = coerceDesktopConnectionConfig(payload) writeDesktopConnectionConfig(config) return sanitizeDesktopConnectionConfig(config) }) ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => { const config = coerceDesktopConnectionConfig(payload) writeDesktopConnectionConfig(config) resetHermesConnection() setTimeout(() => mainWindow?.reload(), 150) return sanitizeDesktopConnectionConfig(config) }) ipcMain.on('hermes:previewShortcutActive', (_event, active) => { previewShortcutActive = Boolean(active) }) ipcMain.handle('hermes:requestMicrophoneAccess', async () => { if (!IS_MAC || typeof systemPreferences.askForMediaAccess !== 'function') { return true } return systemPreferences.askForMediaAccess('microphone') }) ipcMain.handle('hermes:api', async (_event, request) => { const connection = await startHermes() const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) return fetchJson(`${connection.baseUrl}${request.path}`, connection.token, { method: request?.method, body: request?.body, timeoutMs }) }) ipcMain.handle('hermes:notify', (_event, payload) => { if (!Notification.isSupported()) return false new Notification({ title: payload?.title || 'Hermes', body: payload?.body || '', silent: Boolean(payload?.silent) }).show() return true }) ipcMain.handle('hermes:readFileDataUrl', async (_event, filePath) => { const { resolvedPath } = await resolveReadableFileForIpc(filePath, { maxBytes: DATA_URL_READ_MAX_BYTES, purpose: 'File preview' }) const data = await fs.promises.readFile(resolvedPath) return `data:${mimeTypeForPath(resolvedPath)};base64,${data.toString('base64')}` }) ipcMain.handle('hermes:readFileText', async (_event, filePath) => { const { resolvedPath, stat } = await resolveReadableFileForIpc(filePath, { maxBytes: TEXT_PREVIEW_SOURCE_MAX_BYTES, purpose: 'Text preview' }) const ext = path.extname(resolvedPath).toLowerCase() const handle = await fs.promises.open(resolvedPath, 'r') const bytesToRead = Math.min(stat.size, TEXT_PREVIEW_MAX_BYTES) try { const buffer = Buffer.alloc(bytesToRead) const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0) return { binary: looksBinary(buffer.subarray(0, Math.min(bytesRead, 4096))), byteSize: stat.size, language: PREVIEW_LANGUAGE_BY_EXT[ext] || 'text', mimeType: mimeTypeForPath(resolvedPath), path: resolvedPath, text: buffer.subarray(0, bytesRead).toString('utf8'), truncated: stat.size > TEXT_PREVIEW_MAX_BYTES } } finally { await handle.close() } }) ipcMain.handle('hermes:selectPaths', async (_event, options = {}) => { const properties = ['openFile'] if (options?.directories) properties.push('openDirectory') if (options?.multiple !== false) properties.push('multiSelections') const result = await dialog.showOpenDialog(mainWindow, { title: options?.title || 'Add context', defaultPath: options?.defaultPath ? path.resolve(String(options.defaultPath)) : undefined, properties, filters: Array.isArray(options?.filters) ? options.filters : undefined }) if (result.canceled) return [] return result.filePaths }) ipcMain.handle('hermes:writeClipboard', (_event, text) => { clipboard.writeText(String(text || '')) return true }) ipcMain.handle('hermes:saveImageFromUrl', (_event, url) => saveImageFromUrl(String(url || ''))) ipcMain.handle('hermes:saveImageBuffer', async (_event, payload) => { const data = payload?.data if (!data) throw new Error('saveImageBuffer: missing data') const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data) return writeComposerImage(buffer, payload?.ext || '.png') }) ipcMain.handle('hermes:saveClipboardImage', async () => { const image = clipboard.readImage() if (!image || image.isEmpty()) { return '' } return writeComposerImage(image.toPNG(), '.png') }) ipcMain.handle('hermes:normalizePreviewTarget', (_event, target, baseDir) => normalizePreviewTarget(String(target || ''), baseDir ? String(baseDir) : '') ) ipcMain.handle('hermes:watchPreviewFile', (_event, url) => watchPreviewFile(String(url || ''))) ipcMain.handle('hermes:stopPreviewFileWatch', (_event, id) => stopPreviewFileWatch(String(id || ''))) ipcMain.on('hermes:titlebar-theme', (_event, payload) => { if (!payload || !isHexColor(payload.background) || !isHexColor(payload.foreground)) { return } rendererTitleBarTheme = { background: payload.background, foreground: payload.foreground } mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions()) }) ipcMain.handle('hermes:openExternal', (_event, url) => { if (!openExternalUrl(url)) { throw new Error('Invalid external URL') } }) ipcMain.handle('hermes:fetchLinkTitle', (_event, url) => fetchLinkTitle(url)) // Always-hidden noise (covers non-git projects too — gitignore would catch // these anyway when present, but we want the same hygiene without one). const FS_READDIR_HIDDEN = new Set([ '.git', '.hg', '.svn', '.cache', '.next', '.turbo', '.venv', '__pycache__', 'build', 'dist', 'node_modules', 'target', 'venv' ]) function findGitRoot(start) { let dir = start for (let i = 0; i < 50; i += 1) { try { if (fs.existsSync(path.join(dir, '.git'))) { return dir } } catch { return null } const parent = path.dirname(dir) if (parent === dir) { return null } dir = parent } return null } function terminalShellCommand() { if (IS_WINDOWS) { return { args: [], command: process.env.COMSPEC || 'cmd.exe' } } const configuredShell = process.env.SHELL || '' const shellPath = (path.isAbsolute(configuredShell) && fs.existsSync(configuredShell) && configuredShell) || ['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => fs.existsSync(candidate)) || '/bin/sh' const shellName = path.basename(shellPath) const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i'] return { args: interactiveArgs, command: shellPath, name: shellName } } function safeTerminalCwd(cwd) { const candidate = path.resolve(String(cwd || app.getPath('home'))) try { const stat = fs.statSync(candidate) return stat.isDirectory() ? candidate : path.dirname(candidate) } catch { return app.getPath('home') } } function terminalShellEnv() { const env = { ...process.env } // Electron is commonly launched through `npm run dev`; do not leak npm's // managed prefix into a user's interactive shell (nvm/proto warn loudly). for (const key of Object.keys(env)) { if (key === 'npm_config_prefix' || key.startsWith('npm_config_') || key.startsWith('npm_package_')) { delete env[key] } } // Strip color/theme-detection vars that ride along when Electron is launched // from a non-tty agent shell (Cursor's runner sets NO_COLOR/FORCE_COLOR=0 // /TERM=dumb; some terminals set COLORFGBG which would flip Hermes' TUI into // light-mode). Our PTY is a real xterm-compat terminal — force truecolor. delete env.NO_COLOR delete env.FORCE_COLOR delete env.COLORFGBG env.COLORTERM = 'truecolor' env.LC_CTYPE = env.LC_CTYPE || 'UTF-8' env.TERM = 'xterm-256color' env.TERM_PROGRAM = 'Hermes' env.TERM_PROGRAM_VERSION = app.getVersion() return env } function terminalChannel(id, suffix) { return `hermes:terminal:${id}:${suffix}` } function disposeTerminalSession(id) { const sessionInfo = terminalSessions.get(id) if (!sessionInfo) { return false } terminalSessions.delete(id) try { sessionInfo.pty.kill() } catch { // Process may already be gone. } return true } ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => { const resolved = path.resolve(String(dirPath || '')) if (!resolved) { return { entries: [], error: 'invalid-path' } } try { const dirents = await fs.promises.readdir(resolved, { withFileTypes: true }) const entries = dirents .filter(d => { if (FS_READDIR_HIDDEN.has(d.name)) { return false } return true }) .map(d => ({ name: d.name, path: path.join(resolved, d.name), isDirectory: d.isDirectory() })) .sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name)) return { entries } } catch (error) { return { entries: [], error: error?.code || 'read-error' } } }) ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => { const input = String(startPath || '') const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input) try { const stat = await fs.promises.stat(resolved) const start = stat.isDirectory() ? resolved : path.dirname(resolved) return findGitRoot(start) } catch { return findGitRoot(resolved) } }) ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { if (!nodePty) { throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.') } const id = crypto.randomUUID() const { args, command, name } = terminalShellCommand() const cwd = safeTerminalCwd(payload?.cwd) const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80) const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24) const ptyProcess = nodePty.spawn(command, args, { cols, cwd, env: terminalShellEnv(), name: 'xterm-256color', rows }) terminalSessions.set(id, { pty: ptyProcess, webContentsId: event.sender.id }) const send = (suffix, payload) => { if (event.sender.isDestroyed()) { return } event.sender.send(terminalChannel(id, suffix), payload) } ptyProcess.onData(data => send('data', data)) ptyProcess.onExit(({ exitCode, signal }) => { terminalSessions.delete(id) send('exit', { code: exitCode, signal: signal || null }) }) event.sender.once('destroyed', () => disposeTerminalSession(id)) return { cwd, id, shell: name } }) ipcMain.handle('hermes:terminal:write', (_event, id, data) => { const sessionInfo = terminalSessions.get(String(id || '')) if (!sessionInfo) { return false } sessionInfo.pty.write(String(data || '')) return true }) ipcMain.handle('hermes:terminal:resize', (_event, id, size = {}) => { const sessionInfo = terminalSessions.get(String(id || '')) if (!sessionInfo) { return false } const cols = Math.max(2, Number.parseInt(String(size?.cols || 80), 10) || 80) const rows = Math.max(2, Number.parseInt(String(size?.rows || 24), 10) || 24) sessionInfo.pty.resize(cols, rows) return true }) ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || ''))) ipcMain.handle('hermes:updates:check', async () => checkUpdates().catch(error => ({ supported: true, branch: readDesktopUpdateConfig().branch, error: 'check-failed', message: error?.message || String(error), fetchedAt: Date.now() })) ) ipcMain.handle('hermes:updates:apply', async (_event, payload) => applyUpdates(payload || {}).catch(error => ({ ok: false, error: 'apply-failed', message: error?.message || String(error) })) ) ipcMain.handle('hermes:updates:branch:get', async () => readDesktopUpdateConfig()) ipcMain.handle('hermes:updates:branch:set', async (_event, name) => { const branch = typeof name === 'string' && name.trim() ? name.trim() : DEFAULT_UPDATE_BRANCH writeDesktopUpdateConfig({ branch }) return { branch } }) ipcMain.handle('hermes:version', async () => ({ appVersion: app.getVersion(), electronVersion: process.versions.electron, nodeVersion: process.versions.node, platform: process.platform, hermesRoot: resolveUpdateRoot() })) app.whenReady().then(() => { if (IS_MAC) { Menu.setApplicationMenu(buildApplicationMenu()) } else { Menu.setApplicationMenu(null) } installMediaPermissions() ensureWslWindowsFonts() createWindow() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() }) }) app.on('before-quit', () => { if (desktopLogFlushTimer) { clearTimeout(desktopLogFlushTimer) desktopLogFlushTimer = null } flushDesktopLogBufferSync() closePreviewWatchers() if (hermesProcess && !hermesProcess.killed) { hermesProcess.kill('SIGTERM') } }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() })