const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, 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 { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs') const { DATA_URL_READ_MAX_BYTES, DEFAULT_FETCH_TIMEOUT_MS, TEXT_PREVIEW_SOURCE_MAX_BYTES, encryptDesktopSecret: encryptDesktopSecretStrict, resolveReadableFileForIpc, resolveTimeoutMs } = require('./hardening.cjs') const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR if (USER_DATA_OVERRIDE) { const resolvedUserData = path.resolve(USER_DATA_OVERRIDE) fs.mkdirSync(resolvedUserData, { recursive: true }) app.setPath('userData', resolvedUserData) } const PORT_FLOOR = 9120 const PORT_CEILING = 9199 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, '../..') // 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') const RUNTIME_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-desktop-runtime.json') // FACTORY_HERMES_ROOT — read-only payload that ships inside the .app/.exe. // On first run (or after an installer-driven upgrade) we sync it into // ACTIVE_HERMES_ROOT, unless ACTIVE is a git checkout (developer install via // install.ps1) in which case we leave it alone. const FACTORY_HERMES_ROOT = path.join(process.resourcesPath, 'hermes-agent') 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 RUNTIME_SCHEMA_VERSION = 4 const RUNTIME_IMPORT_CHECK = bundledRuntimeImportCheck() 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') ] 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 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 sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)) } function clampBootProgress(value) { const numeric = Number(value) if (!Number.isFinite(numeric)) return 0 return Math.max(0, Math.min(100, Math.round(numeric))) } function broadcastBootProgress() { if (!mainWindow || mainWindow.isDestroyed()) return const { webContents } = mainWindow if (!webContents || webContents.isDestroyed()) return webContents.send('hermes:boot-progress', bootProgressState) } function updateBootProgress(update, options = {}) { const nextProgressRaw = typeof update.progress === 'number' ? clampBootProgress(update.progress) : bootProgressState.progress const nextProgress = options.allowDecrease ? nextProgressRaw : Math.max(bootProgressState.progress, nextProgressRaw) bootProgressState = { ...bootProgressState, ...update, error: update.error === undefined ? bootProgressState.error : update.error, fakeMode: BOOT_FAKE_MODE || Boolean(update.fakeMode), progress: nextProgress, timestamp: Date.now() } if (update.message) { rememberLog(`[boot] ${update.message}`) } broadcastBootProgress() } async function advanceBootProgress(phase, message, progress) { updateBootProgress({ phase, message, progress, running: true, error: null }) if (BOOT_FAKE_MODE) { await sleep(BOOT_FAKE_STEP_MS) } } function fileExists(filePath) { try { return fs.statSync(filePath).isFile() } 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 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')) } function runProcess(command, args, options = {}) { return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd: options.cwd, env: options.env || process.env, shell: Boolean(options.shell), stdio: ['ignore', 'pipe', 'pipe'] }) child.stdout.on('data', rememberLog) child.stderr.on('data', rememberLog) child.once('error', reject) child.once('exit', code => { if (code === 0) { resolve() } else { reject(new Error(`${path.basename(command)} exited with code ${code}: ${recentHermesLog()}`)) } }) }) } 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('git', 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 async function applyUpdates(opts = {}) { if (updateInFlight) { throw new Error('An update is already in progress.') } updateInFlight = true const dirtyStrategy = opts.dirtyStrategy === 'force' || opts.dirtyStrategy === 'stash' ? opts.dirtyStrategy : 'abort' try { const updateRoot = resolveUpdateRoot() const gitDir = path.join(updateRoot, '.git') if (!directoryExists(gitDir)) { const message = `${updateRoot} isn't a git checkout — cannot self-update.` emitUpdateProgress({ stage: 'error', error: 'not-a-git-checkout', message }) throw new Error(message) } const { branch } = readDesktopUpdateConfig() emitUpdateProgress({ stage: 'prepare', message: 'Checking working tree…', percent: 5 }) const dirtyResult = await runGit(['status', '--porcelain'], { cwd: updateRoot }) const isDirty = dirtyResult.stdout.trim().length > 0 let stashRef = null if (isDirty) { if (dirtyStrategy === 'abort') { const message = 'Uncommitted changes detected. Choose how to handle them and try again.' emitUpdateProgress({ stage: 'error', error: 'dirty-tree', message }) throw new Error(message) } if (dirtyStrategy === 'stash') { emitUpdateProgress({ stage: 'prepare', message: 'Stashing local changes…', percent: 10 }) const stashed = await runGit(['stash', 'push', '-u', '-m', `hermes-desktop-auto-${Date.now()}`], { cwd: updateRoot }) if (stashed.code !== 0) { const message = firstLine(stashed.stderr) || 'git stash failed.' emitUpdateProgress({ stage: 'error', error: 'stash-failed', message }) throw new Error(message) } stashRef = 'stash@{0}' } // dirtyStrategy === 'force' → pull --ff-only will refuse if anything // conflicts, surfacing a clean error rather than us guessing. } const pyprojectBefore = sha256OfFile(path.join(updateRoot, 'pyproject.toml')) emitUpdateProgress({ stage: 'fetch', message: `Fetching origin/${branch}…`, percent: 20 }) const fetched = await runGit(['fetch', 'origin', branch], { cwd: updateRoot }) if (fetched.code !== 0) { const message = firstLine(fetched.stderr) || 'git fetch failed.' emitUpdateProgress({ stage: 'error', error: 'fetch-failed', message }) throw new Error(message) } emitUpdateProgress({ stage: 'pull', message: `Fast-forward merging origin/${branch}…`, percent: 45 }) const pulled = await runGit(['pull', '--ff-only', 'origin', branch], { cwd: updateRoot, onLine: (_stream, text) => { const line = firstLine(text) if (line) emitUpdateProgress({ stage: 'pull', message: line.slice(0, 200), percent: 50 }) } }) if (pulled.code !== 0) { const message = firstLine(pulled.stderr || pulled.stdout) || 'git pull failed.' if (stashRef) { await runGit(['stash', 'pop'], { cwd: updateRoot }).catch(() => {}) } emitUpdateProgress({ stage: 'error', error: 'pull-failed', message }) throw new Error(message) } if (stashRef) { emitUpdateProgress({ stage: 'pull', message: 'Restoring stashed changes…', percent: 60 }) const popped = await runGit(['stash', 'pop'], { cwd: updateRoot }) if (popped.code !== 0) { emitUpdateProgress({ stage: 'pull', message: 'Stash pop had conflicts — your changes are preserved in `git stash list`.', percent: 60 }) } } // findPythonForRoot picks the venv beside the resolved checkout (.venv or // venv), matching how the backend discovers its Python. const pyprojectAfter = sha256OfFile(path.join(updateRoot, 'pyproject.toml')) const pyprojectChanged = pyprojectBefore && pyprojectAfter && pyprojectBefore !== pyprojectAfter const venvPython = pyprojectChanged ? findPythonForRoot(updateRoot) : null if (venvPython && fileExists(venvPython)) { emitUpdateProgress({ stage: 'pydeps', message: 'Updating Python dependencies…', percent: 75 }) await runProcess(venvPython, ['-m', 'pip', 'install', '-e', updateRoot, '--disable-pip-version-check']) } emitUpdateProgress({ stage: 'restart', message: 'Update complete. Restarting…', percent: 100 }) setTimeout(() => { app.relaunch() app.quit() }, 1500) return { ok: true, branch } } finally { updateInFlight = false } } function readJson(filePath) { try { return JSON.parse(fs.readFileSync(filePath, 'utf8')) } catch { return null } } 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. Existing `hermes` on PATH — installed via install.ps1 / install.sh, or // pip-installed system-wide. Skip when HERMES_DESKTOP_IGNORE_EXISTING=1 // (used by test:desktop:fresh to force the factory-image bootstrap path). 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) { return { label: `existing Hermes CLI at ${hermesCommand}`, command: hermesCommand, args: dashboardArgs, bootstrap: false, env: {}, kind: 'command', shell: isCommandScript(hermesCommand) } } } // 4. ACTIVE_HERMES_ROOT — the canonical mutable install at // %LOCALAPPDATA%\hermes\hermes-agent (Windows) or ~/.hermes/hermes-agent. // On packaged installs this is populated from FACTORY_HERMES_ROOT during // ensureRuntime(). On install.ps1 / install.sh setups it's already there. if (isHermesSourceRoot(ACTIVE_HERMES_ROOT)) { return createActiveBackend(dashboardArgs) } // 5. Packaged: FACTORY_HERMES_ROOT exists but ACTIVE doesn't yet. Return a // bootstrap-flagged backend; ensureRuntime() will sync factory → active // and provision the venv before launch. if (IS_PACKAGED && isHermesSourceRoot(FACTORY_HERMES_ROOT)) { return createActiveBackend(dashboardArgs) } // 6. Last-ditch: pip-installed hermes_cli module via system Python. const python = findSystemPython() if (python) { return { kind: 'python', label: `installed hermes_cli module via ${python}`, command: python, args: ['-m', 'hermes_cli.main', ...dashboardArgs], bootstrap: false, env: {}, shell: false } } // Nothing worked. Distinguish the "no payload" and "no Python" cases so the // user gets actionable guidance instead of "install the Hermes CLI". const factoryPresent = isHermesSourceRoot(FACTORY_HERMES_ROOT) const activePresent = isHermesSourceRoot(ACTIVE_HERMES_ROOT) if (factoryPresent || activePresent) { throw new Error( 'Hermes payload is present but no Python 3.11+ interpreter could be found. ' + 'Install Python 3.11+ from https://www.python.org/downloads/ or the Microsoft Store, ' + 'then relaunch Hermes.' ) } throw new Error( 'Could not find Hermes. Install the Hermes CLI ' + '(https://github.com/NousResearch/hermes-agent#install) or set HERMES_DESKTOP_HERMES_ROOT.' ) } async function ensureRuntime(backend) { if (!backend.bootstrap) { await advanceBootProgress('runtime.external', `Using ${backend.label}`, 32) return backend } // Step 1: Ensure ACTIVE_HERMES_ROOT is populated. On packaged installs we // sync from FACTORY_HERMES_ROOT (the read-only payload bundled into the // .app/.exe). We DON'T overwrite a developer install: presence of a .git // dir or a Hermes-managed venv at the same place means the user set this // up via install.ps1 / install.sh / git clone, and that install owns the // updates (via `hermes update`). const isGitCheckout = directoryExists(path.join(ACTIVE_HERMES_ROOT, '.git')) const factoryAvailable = IS_PACKAGED && isHermesSourceRoot(FACTORY_HERMES_ROOT) if (factoryAvailable && !isGitCheckout) { const factoryVersion = readPyprojectVersion(FACTORY_HERMES_ROOT) ?? readJson(path.join(FACTORY_HERMES_ROOT, 'package.json'))?.version ?? app.getVersion() const marker = readJson(RUNTIME_MARKER) const pyprojectHash = sha256OfFile(path.join(FACTORY_HERMES_ROOT, 'pyproject.toml')) const activeFresh = isHermesSourceRoot(ACTIVE_HERMES_ROOT) && marker?.runtimeSchemaVersion === RUNTIME_SCHEMA_VERSION && marker?.factoryVersion === factoryVersion && marker?.pyprojectHash === pyprojectHash if (!activeFresh) { await advanceBootProgress('runtime.sync', 'Installing Hermes', 30) rememberLog(`Syncing Hermes payload ${FACTORY_HERMES_ROOT} → ${ACTIVE_HERMES_ROOT}`) fs.mkdirSync(ACTIVE_HERMES_ROOT, { recursive: true }) // Copy in factory contents. We do NOT delete venv/ — preserving it // across upgrades skips re-install when deps haven't moved. await syncTreeExcludingVenv(FACTORY_HERMES_ROOT, ACTIVE_HERMES_ROOT) } } 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.' ) } // Step 2: Ensure venv exists at /venv — same place // install.ps1 / install.sh put it. A user who installed via the CLI script // already has this; we reuse it as-is. const venvPython = getVenvPython(VENV_ROOT) if (!fileExists(venvPython)) { const systemPython = findSystemPython() if (!systemPython) { throw new Error( 'Python 3.11+ is required to bootstrap Hermes. Install Python from ' + 'https://www.python.org/downloads/ (or the Microsoft Store on Windows), then relaunch Hermes.' ) } await advanceBootProgress('runtime.venv', 'Creating Hermes virtual environment', 50) await runProcess(systemPython, ['-m', 'venv', VENV_ROOT]) } // Step 2b: On Windows, preflight Git Bash. Hermes' terminal tool calls // bash.exe directly (tools/environments/local.py); without it the agent // can't run a terminal command. We surface this here as a clear, actionable // error rather than letting the user discover it on their first chat // ("hey, run `ls`" → opaque tool failure). The NSIS prereq page handles // this for installer users; this check catches everyone else (.msi users, // npm run dev with a fresh checkout, manual installs, etc.). 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.' ) } // Step 3: Ensure deps are installed. We compare a marker against the // active pyproject.toml's hash and only run pip when something changed — // keeps `npm run dev` boots fast on a stable repo. const expectedMarker = { runtimeSchemaVersion: RUNTIME_SCHEMA_VERSION, pyprojectHash: sha256OfFile(path.join(ACTIVE_HERMES_ROOT, 'pyproject.toml')), factoryVersion: factoryAvailable ? (readPyprojectVersion(FACTORY_HERMES_ROOT) ?? app.getVersion()) : null } const currentMarker = readJson(RUNTIME_MARKER) const depsFresh = currentMarker?.runtimeSchemaVersion === expectedMarker.runtimeSchemaVersion && currentMarker?.pyprojectHash === expectedMarker.pyprojectHash && (await hasRuntimeImports(venvPython)) if (!depsFresh) { await advanceBootProgress('runtime.dependencies', 'Installing Hermes dependencies', 66) await runProcess(venvPython, [ '-m', 'pip', 'install', '--disable-pip-version-check', '--no-warn-script-location', '--upgrade', '-e', ACTIVE_HERMES_ROOT ]) await advanceBootProgress('runtime.verify', 'Validating Hermes dependencies', 78) await runProcess(venvPython, ['-c', RUNTIME_IMPORT_CHECK]) fs.writeFileSync( RUNTIME_MARKER, JSON.stringify({ ...expectedMarker, installedAt: new Date().toISOString() }, null, 2) ) } else { await advanceBootProgress('runtime.ready', 'Reusing existing Hermes runtime', 78) } 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 } async function hasRuntimeImports(python) { try { await runProcess(python, ['-c', RUNTIME_IMPORT_CHECK]) return true } catch { rememberLog('Hermes runtime is missing required imports; reinstalling.') return false } } // Read pyproject.toml's [project].version with a regex — avoids pulling in a // TOML parser for one field. Returns null if the file is missing or the // version line can't be matched. function readPyprojectVersion(root) { try { const text = fs.readFileSync(path.join(root, 'pyproject.toml'), 'utf8') const match = text.match(/^version\s*=\s*"([^"]+)"/m) return match ? match[1] : null } catch { return null } } function sha256OfFile(filePath) { try { const buf = fs.readFileSync(filePath) return crypto.createHash('sha256').update(buf).digest('hex') } catch { return null } } // Copy from src → dst, preserving any existing venv/ at dst. // // In practice src (FACTORY_HERMES_ROOT) never contains a venv — // stage-hermes-payload.mjs explicitly excludes venvs from the bundled // payload. The venv-preservation filter below is defensive: if a future // payload ever does include a venv directory, we still won't clobber the // user's existing one at ACTIVE_HERMES_ROOT/venv. // // Excludes .git, __pycache__, .pyc/.pyo, etc. — same set // stage-hermes-payload.mjs uses on the build side. async function syncTreeExcludingVenv(src, dst) { const EXCLUDED = new Set([ '.git', '.mypy_cache', '.pytest_cache', '.ruff_cache', '__pycache__', 'node_modules', '.DS_Store' ]) const srcVenv = path.join(src, 'venv') const venvPreserved = directoryExists(path.join(dst, 'venv')) await fs.promises.cp(src, dst, { recursive: true, force: true, filter: source => { const name = path.basename(source) if (EXCLUDED.has(name)) return false if (name.endsWith('.pyc') || name.endsWith('.pyo')) return false // Defensive: skip any venv/ inside src so we never clobber dst's venv. // (The source path the filter receives is rooted at src; that's why we // check srcVenv here, not dstVenv.) if (venvPreserved && (source === srcVenv || source.startsWith(srcVenv + path.sep))) { return false } return true } }) } 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 } try { resolve(text ? JSON.parse(text) : null) } catch (error) { reject(error) } }) } ) 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) { if (!DEV_SERVER) return const { webContents } = window if (webContents.isDevToolsOpened()) { webContents.closeDevTools() } else { webContents.openDevTools({ mode: 'detach' }) } } function installDevToolsShortcut(window) { if (!DEV_SERVER) return 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:')) { void shell.openExternal(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: () => void shell.openExternal(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() { 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: IS_MAC ? { height: TITLEBAR_HEIGHT } : { color: '#f7f7f7', height: TITLEBAR_HEIGHT, symbolColor: '#242424' }, 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: Boolean(DEV_SERVER) } }) if (IS_MAC) { mainWindow.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION) if (icon) { app.dock?.setIcon(icon) } } 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) 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:boot-progress:get', async () => bootProgressState) 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.handle('hermes:openExternal', (_event, url) => shell.openExternal(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', 'node_modules', '__pycache__', '.next', '.venv', '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 } 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: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() 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() })