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 REPO_ROOT = path.resolve(__dirname, '../../..') const DESKTOP_ROOT = path.resolve(__dirname, '..') const IS_MAC = process.platform === 'darwin' 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_PATH = path.join(DESKTOP_ROOT, 'public', 'apple-touch-icon.png') app.setName(APP_NAME) let mainWindow = null let hermesProcess = null let connectionPromise = null const hermesLog = [] function rememberLog(chunk) { const text = String(chunk || '').trim() if (!text) return hermesLog.push(...text.split(/\r?\n/).map(line => `[hermes] ${line}`)) if (hermesLog.length > 300) { hermesLog.splice(0, hermesLog.length - 300) } } function findPython() { const local = [path.join(REPO_ROOT, '.venv', 'bin', 'python'), path.join(REPO_ROOT, 'venv', 'bin', 'python')] return local.find(candidate => fs.existsSync(candidate)) || 'python3' } 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() if (ext === '.png') return 'image/png' if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg' if (ext === '.gif') return 'image/gif' if (ext === '.webp') return 'image/webp' if (ext === '.svg') return 'image/svg+xml' if (ext === '.bmp') return 'image/bmp' return '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 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 getAppIconPath() { return fs.existsSync(APP_ICON_PATH) ? APP_ICON_PATH : undefined } function resolveHermesCwd() { const candidates = [process.env.HERMES_DESKTOP_CWD, process.env.INIT_CWD, process.cwd(), REPO_ROOT] for (const candidate of candidates) { if (!candidate) continue const resolved = path.resolve(String(candidate)) try { if (fs.statSync(resolved).isDirectory()) return resolved } catch { // Try the next candidate. } } return REPO_ROOT } 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 python = findPython() const args = [ '-m', 'hermes_cli.main', 'dashboard', '--no-open', '--tui', '--host', '127.0.0.1', '--port', String(port) ] const hermesCwd = resolveHermesCwd() hermesProcess = spawn(python, args, { cwd: hermesCwd, env: { ...process.env, PYTHONPATH: [REPO_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter), HERMES_DASHBOARD_SESSION_TOKEN: token, HERMES_DASHBOARD_TUI: '1', HERMES_WEB_DIST: path.join(REPO_ROOT, 'apps', 'desktop', 'dist') }, stdio: ['ignore', 'pipe', 'pipe'] }) hermesProcess.stdout.on('data', rememberLog) hermesProcess.stderr.on('data', rememberLog) hermesProcess.once('exit', (code, signal) => { rememberLog(`Hermes dashboard exited (${signal || code})`) hermesProcess = null connectionPromise = null mainWindow?.webContents.send('hermes:backend-exit', { code, signal }) }) const baseUrl = `http://127.0.0.1:${port}` await waitForHermes(baseUrl, token) 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, 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(path.join(__dirname, '..', 'dist', 'index.html')).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: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', () => { if (hermesProcess && !hermesProcess.killed) { hermesProcess.kill('SIGTERM') } }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() })