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 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 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 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 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 LOCAL_PREVIEW_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::1', '[::1]', 'localhost']) 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() 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) } try { fs.mkdirSync(path.dirname(DESKTOP_LOG_PATH), { recursive: true }) fs.appendFileSync(DESKTOP_LOG_PATH, `${lines.join('\n')}\n`) } catch { // Logging must never block app startup. } } 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('/'))) { return fileExists(command) ? command : null } 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') { const hermesCommand = process.env.HERMES_DESKTOP_HERMES ? findOnPath(process.env.HERMES_DESKTOP_HERMES) || process.env.HERMES_DESKTOP_HERMES : 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) 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) { 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.') } rememberLog(`Preparing bundled Hermes runtime in ${BUNDLED_VENV_ROOT}`) fs.mkdirSync(BUNDLED_VENV_ROOT, { recursive: true }) if (!fileExists(venvPython)) { await runProcess(systemPython, ['-m', 'venv', BUNDLED_VENV_ROOT]) } await runProcess(venvPython, [ '-m', 'pip', 'install', '--disable-pip-version-check', '--no-warn-script-location', '--upgrade', ...BUNDLED_RUNTIME_REQUIREMENTS ]) await runProcess(venvPython, ['-c', 'import fastapi, uvicorn, ptyprocess']) 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}` return backend } async function hasBundledRuntimeImports(python) { try { await runProcess(python, ['-c', 'import fastapi, uvicorn, ptyprocess']) 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 (!PREVIEW_HTML_EXTENSIONS.has(ext) || !fileExists(resolved)) { return null } return { kind: 'file', label: path.basename(resolved), 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 previewFilePathFromUrl(rawUrl) { const filePath = fileURLToPath(String(rawUrl || '')) const ext = path.extname(filePath).toLowerCase() if (!PREVIEW_HTML_EXTENSIONS.has(ext) || !fileExists(filePath)) { throw new Error('Preview file is not a readable HTML file') } 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 = previewFilePathFromUrl(rawUrl) const id = crypto.randomBytes(12).toString('base64url') let timer = null const watcher = fs.watch(filePath, () => { if (timer) clearTimeout(timer) timer = setTimeout(() => { sendPreviewFileChanged({ id, path: filePath, url: pathToFileURL(filePath).toString() }) }, 120) }) 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 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 ? { role: '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 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) }) } async function startHermes() { if (connectionPromise) return connectionPromise connectionPromise = (async () => { 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)] const backend = await ensureBundledRuntime(resolveHermesBackend(dashboardArgs)) const hermesCwd = resolveHermesCwd() const webDist = resolveWebDist() 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}`) 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) { 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 Promise.race([waitForHermes(baseUrl, token), backendStartFailed]) backendReady = true return { baseUrl, token, wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`, logs: hermesLog.slice(-80), windowButtonPosition: getWindowButtonPosition() } })() 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, nodeIntegration: false, devTools: Boolean(DEV_SERVER) } }) if (IS_MAC) { mainWindow.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION) if (icon) { app.dock?.setIcon(icon) } } installDevToolsShortcut(mainWindow) installContextMenu(mainWindow) if (DEV_SERVER) { mainWindow.loadURL(DEV_SERVER) } else { mainWindow.loadURL(pathToFileURL(resolveRendererIndex()).toString()) } } ipcMain.handle('hermes:connection', async () => startHermes()) 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 resolved = path.resolve(String(filePath || '')) const data = await fs.promises.readFile(resolved) return `data:${mimeTypeForPath(resolved)};base64,${data.toString('base64')}` }) 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)) app.whenReady().then(() => { Menu.setApplicationMenu(buildApplicationMenu()) installMediaPermissions() createWindow() startHermes().catch(error => rememberLog(error.stack || error.message)) app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() }) }) app.on('before-quit', () => { closePreviewWatchers() if (hermesProcess && !hermesProcess.killed) { hermesProcess.kill('SIGTERM') } }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() })