hermes-agent/apps/desktop/electron/main.cjs
Austin Pickett 9a0ebf0175 feat(desktop): Cron, Profiles, usage analytics, and titlebar fixes
- Add Cron and Profiles sidebar routes with full CRUD-style flows and API wiring.
- Extend Command Center with auxiliary task overrides and a Usage panel (7d/30d/90d).
- Fix titlebar geometry for WSL/Windows (native overlay width, tool spacing).
- Remove stray merge conflict markers from pyproject.toml optional deps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 08:21:43 -04:00

2825 lines
89 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.113.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 <ACTIVE_HERMES_ROOT>/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(/<title[^>]*>([\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\<u>\.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()
})