const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, 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 { spawn } = require('node:child_process') const { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs') const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR if (USER_DATA_OVERRIDE) { const resolvedUserData = path.resolve(USER_DATA_OVERRIDE) fs.mkdirSync(resolvedUserData, { recursive: true }) app.setPath('userData', resolvedUserData) } const PORT_FLOOR = 9120 const PORT_CEILING = 9199 const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER const IS_PACKAGED = app.isPackaged const IS_MAC = process.platform === 'darwin' const IS_WINDOWS = process.platform === 'win32' const IS_WSL = isWslEnvironment() const APP_ROOT = app.getAppPath() const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..') const BUNDLED_HERMES_ROOT = path.join(process.resourcesPath, 'hermes-agent') const BUNDLED_VENV_ROOT = path.join(app.getPath('userData'), 'hermes-runtime') const BUNDLED_VENV_MARKER = path.join(BUNDLED_VENV_ROOT, '.hermes-desktop-runtime.json') const DESKTOP_LOG_PATH = path.join(app.getPath('userData'), 'desktop.log') const DESKTOP_LOG_FLUSH_MS = 120 const DESKTOP_LOG_BUFFER_MAX_CHARS = 64 * 1024 const BOOT_FAKE_MODE = process.env.HERMES_DESKTOP_BOOT_FAKE === '1' const BOOT_FAKE_STEP_MS = (() => { const raw = Number.parseInt(String(process.env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS || ''), 10) if (!Number.isFinite(raw) || raw <= 0) return 650 return Math.max(120, raw) })() const RUNTIME_SCHEMA_VERSION = 3 const BUNDLED_RUNTIME_REQUIREMENTS = [ 'openai>=2.21.0,<3', 'anthropic>=0.39.0,<1', 'python-dotenv>=1.2.1,<2', 'fire>=0.7.1,<1', 'httpx[socks]>=0.28.1,<1', 'rich>=14.3.3,<15', 'tenacity>=9.1.4,<10', 'pyyaml>=6.0.2,<7', 'requests>=2.32.0,<3', 'jinja2>=3.1.5,<4', 'pydantic>=2.12.5,<3', 'prompt_toolkit>=3.0.52,<4', 'exa-py>=2.9.0,<3', 'firecrawl-py>=4.16.0,<5', 'parallel-web>=0.4.2,<1', 'fal-client>=0.13.1,<1', 'croniter>=6.0.0,<7', 'edge-tts>=7.2.7,<8', 'PyJWT[crypto]>=2.12.0,<3', 'fastapi>=0.104.0,<1', 'uvicorn[standard]>=0.24.0,<1', IS_WINDOWS ? 'pywinpty>=2.0.0,<3' : 'ptyprocess>=0.7.0,<1' ] const BUNDLED_RUNTIME_IMPORT_CHECK = bundledRuntimeImportCheck() const APP_NAME = 'Hermes' const TITLEBAR_HEIGHT = 34 const MACOS_TRAFFIC_LIGHTS_HEIGHT = 14 const WINDOW_BUTTON_POSITION = { x: 24, y: TITLEBAR_HEIGHT / 2 - MACOS_TRAFFIC_LIGHTS_HEIGHT / 2 } 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 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() { const commands = IS_WINDOWS ? ['python.exe', 'py.exe', 'python'] : ['python3', 'python'] for (const command of commands) { const candidate = findOnPath(command) if (candidate) return candidate } return null } 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') } 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 } } function createBundledBackend(root, dashboardArgs) { const python = getVenvPython(BUNDLED_VENV_ROOT) return { kind: 'python', label: 'bundled Hermes', command: fileExists(python) ? python : findSystemPython(), args: ['-m', 'hermes_cli.main', ...dashboardArgs], env: { PYTHONPATH: [root, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter) }, root, bootstrap: true, shell: false } } function resolveHermesBackend(dashboardArgs) { 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 } 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) } } } if (IS_PACKAGED && isHermesSourceRoot(BUNDLED_HERMES_ROOT)) { const backend = createBundledBackend(BUNDLED_HERMES_ROOT, dashboardArgs) if (backend.command) return backend } if (!IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT)) { const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, dashboardArgs) if (backend) return backend } 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 } } throw new Error('Could not find Hermes. Install the Hermes CLI or set HERMES_DESKTOP_HERMES_ROOT.') } async function ensureBundledRuntime(backend) { if (!backend.bootstrap) { await advanceBootProgress('runtime.external', `Using ${backend.label}`, 32) return backend } const sourceVersion = readJson(path.join(backend.root, 'package.json'))?.version || app.getVersion() const marker = readJson(BUNDLED_VENV_MARKER) const venvPython = getVenvPython(BUNDLED_VENV_ROOT) const runtimeReady = fileExists(venvPython) && marker?.sourceVersion === sourceVersion && marker?.runtimeSchemaVersion === RUNTIME_SCHEMA_VERSION && (await hasBundledRuntimeImports(venvPython)) if (runtimeReady) { await advanceBootProgress('runtime.ready', 'Reusing bundled Hermes runtime', 58) backend.command = venvPython backend.label = `${backend.label} runtime at ${BUNDLED_VENV_ROOT}` return backend } const systemPython = findSystemPython() if (!systemPython) { throw new Error('Python 3.11+ is required to bootstrap the bundled Hermes runtime.') } await advanceBootProgress('runtime.prepare', 'Preparing bundled Hermes runtime', 42) rememberLog(`Preparing bundled Hermes runtime in ${BUNDLED_VENV_ROOT}`) fs.mkdirSync(BUNDLED_VENV_ROOT, { recursive: true }) if (!fileExists(venvPython)) { await advanceBootProgress('runtime.venv', 'Creating desktop runtime virtual environment', 50) await runProcess(systemPython, ['-m', 'venv', BUNDLED_VENV_ROOT]) } await advanceBootProgress('runtime.dependencies', 'Installing desktop runtime dependencies', 66) await runProcess(venvPython, [ '-m', 'pip', 'install', '--disable-pip-version-check', '--no-warn-script-location', '--upgrade', ...BUNDLED_RUNTIME_REQUIREMENTS ]) await advanceBootProgress('runtime.verify', 'Validating bundled runtime dependencies', 78) await runProcess(venvPython, ['-c', BUNDLED_RUNTIME_IMPORT_CHECK]) fs.writeFileSync( BUNDLED_VENV_MARKER, JSON.stringify( { runtimeSchemaVersion: RUNTIME_SCHEMA_VERSION, sourceVersion, installedAt: new Date().toISOString() }, null, 2 ) ) backend.command = venvPython backend.label = `${backend.label} runtime at ${BUNDLED_VENV_ROOT}` updateBootProgress({ phase: 'runtime.ready', message: 'Bundled runtime is ready', progress: 82, running: true, error: null }) return backend } async function hasBundledRuntimeImports(python) { try { await runProcess(python, ['-c', BUNDLED_RUNTIME_IMPORT_CHECK]) return true } catch { rememberLog('Bundled Hermes runtime is missing required dashboard dependencies; reinstalling.') return false } } 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 req = http.request( url, { 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) 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 } } 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 dashboard did not become ready: ${lastError?.message || 'timeout'}`) } function getWindowButtonPosition() { if (!IS_MAC) return null return mainWindow?.getWindowButtonPosition?.() || WINDOW_BUTTON_POSITION } 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 buildApplicationMenu() { const template = [] if (IS_MAC) { template.push({ label: APP_NAME, submenu: [ { role: 'about', label: `About ${APP_NAME}` }, { 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' }] }) 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 resolveRemoteBackend() { const rawUrl = process.env.HERMES_DESKTOP_REMOTE_URL const rawToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN if (!rawUrl) return null if (!rawToken) { 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.' ) } let parsed try { parsed = new URL(rawUrl) } catch (error) { throw new Error(`HERMES_DESKTOP_REMOTE_URL is not a valid URL: ${error.message}`) } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new Error(`HERMES_DESKTOP_REMOTE_URL must be http:// or https://, got ${parsed.protocol}`) } const baseUrl = `${parsed.protocol}//${parsed.host}` const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws' const wsUrl = `${wsScheme}://${parsed.host}/api/ws?token=${encodeURIComponent(rawToken)}` return { baseUrl, token: rawToken, wsUrl } } 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, token: remote.token, wsUrl: remote.wsUrl, logs: hermesLog.slice(-80), windowButtonPosition: getWindowButtonPosition() } } await advanceBootProgress('backend.port', 'Finding an open local port', 16) const port = await pickPort() const token = crypto.randomBytes(32).toString('base64url') const dashboardArgs = ['dashboard', '--no-open', '--tui', '--host', '127.0.0.1', '--port', String(port)] await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28) const backend = await ensureBundledRuntime(resolveHermesBackend(dashboardArgs)) const hermesCwd = resolveHermesCwd() const webDist = resolveWebDist() await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84) rememberLog(`Starting Hermes backend via ${backend.label}`) hermesProcess = spawn(backend.command, backend.args, { cwd: hermesCwd, env: { ...process.env, ...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 dashboard exited (${signal || code})`) hermesProcess = null connectionPromise = null sendBackendExit({ code, signal }) if (!backendReady) { const message = `Hermes dashboard exited before it became ready (${signal || code}).` updateBootProgress( { error: message, message, phase: 'backend.error', running: false }, { allowDecrease: true } ) rejectBackendStart?.( new Error( `Hermes dashboard exited before it became ready (${signal || code}). Log: ${DESKTOP_LOG_PATH}\n${recentHermesLog()}` ) ) } }) const baseUrl = `http://127.0.0.1:${port}` await advanceBootProgress('backend.wait', 'Waiting for Hermes dashboard to become ready', 90) await Promise.race([waitForHermes(baseUrl, token), backendStartFailed]) backendReady = true updateBootProgress({ phase: 'backend.ready', message: 'Hermes backend is ready. Finalizing desktop startup', progress: 94, running: true, error: null }) return { baseUrl, token, wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`, logs: hermesLog.slice(-80), windowButtonPosition: getWindowButtonPosition() } })().catch(error => { const message = error instanceof Error ? error.message : String(error) updateBootProgress( { error: message, message: `Desktop boot failed: ${message}`, phase: 'backend.error', running: false }, { allowDecrease: true } ) connectionPromise = null throw error }) return connectionPromise } function createWindow() { const icon = getAppIconPath() mainWindow = new BrowserWindow({ width: 1220, height: 800, minWidth: 900, minHeight: 620, title: 'Hermes', titleBarStyle: IS_MAC ? 'hidden' : 'default', titleBarOverlay: IS_MAC ? { height: TITLEBAR_HEIGHT } : undefined, 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) } } 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() startHermes().catch(error => rememberLog(error.stack || error.message)) }) } ipcMain.handle('hermes:connection', async () => startHermes()) ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState) ipcMain.on('hermes:previewShortcutActive', (_event, active) => { previewShortcutActive = Boolean(active) }) 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() return fetchJson(`${connection.baseUrl}${request.path}`, connection.token, { method: request.method, body: request.body }) }) 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 input = String(filePath || '') const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input) const data = await fs.promises.readFile(resolved) return `data:${mimeTypeForPath(resolved)};base64,${data.toString('base64')}` }) ipcMain.handle('hermes:readFileText', async (_event, filePath) => { const input = String(filePath || '') const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input) const ext = path.extname(resolved).toLowerCase() const stat = await fs.promises.stat(resolved) const handle = await fs.promises.open(resolved, '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(resolved), path: resolved, 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)) // 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) } }) app.whenReady().then(() => { Menu.setApplicationMenu(buildApplicationMenu()) 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() })