import { app, BrowserWindow, dialog, Menu, screen, session, shell, utilityProcess } from 'electron' import type { UtilityProcess, MenuItemConstructorOptions } from 'electron' import { appendFileSync, mkdirSync, readFileSync, statSync, truncateSync, writeFileSync } from 'node:fs' import { createServer as createTcpServer } from 'node:net' import { join } from 'node:path' import { desktopEnvironment, desktopStateHome, isDesktopPermissionAllowed, isExternalUrl, isInternalUrl, storedPort } from './runtime' import { desktopStrings } from './strings' import type { ViteDevServer } from 'vite' const development = !app.isPackaged && process.argv.includes('--dev') let backend: UtilityProcess | undefined let devServer: ViteDevServer | undefined let window: BrowserWindow | undefined let stateHome = '' let origin = '' let quitting = false let shutdownComplete = false let startupComplete = false let backendExited = false // Tests and parallel development can use an entirely separate Chromium profile. if (process.env.HERMES_ELECTRON_USER_DATA) app.setPath('userData', process.env.HERMES_ELECTRON_USER_DATA) app.setName('Hermes Desktop') function readState(): Record { try { const state = JSON.parse(readFileSync(join(app.getPath('userData'), 'desktop.json'), 'utf8')) return state && typeof state === 'object' && !Array.isArray(state) ? state : {} } catch { return {} } } function saveState(value: Record) { mkdirSync(app.getPath('userData'), { recursive: true }) writeFileSync(join(app.getPath('userData'), 'desktop.json'), JSON.stringify({ ...readState(), ...value }), { mode: 0o600 }) } function openExternal(url: string) { if (isExternalUrl(url)) void shell.openExternal(url).catch(console.error) } function createWindow() { if (quitting) return const saved = readState() const bounds = saved.bounds const validBounds = bounds && ['width', 'height', 'x', 'y'].every(key => Number.isFinite(bounds[key])) const visible = validBounds && screen.getAllDisplays().some(({ workArea }) => bounds.x < workArea.x + workArea.width && bounds.x + bounds.width > workArea.x && bounds.y < workArea.y + workArea.height && bounds.y + bounds.height > workArea.y) window = new BrowserWindow({ width: 1440, height: 960, minWidth: 960, minHeight: 640, ...(visible ? bounds : {}), title: 'Hermes Desktop', backgroundColor: '#101014', show: false, webPreferences: { preload: join(__dirname, 'preload.js'), nodeIntegration: false, contextIsolation: true, sandbox: true, webviewTag: false, navigateOnDragDrop: false, }, }) const current = window if (saved.maximized) current.maximize() current.once('ready-to-show', () => current.show()) current.on('page-title-updated', event => event.preventDefault()) current.on('close', () => saveState({ bounds: current.getNormalBounds(), maximized: current.isMaximized() })) current.on('closed', () => { if (window === current) window = undefined }) current.webContents.setWindowOpenHandler(({ url }) => { openExternal(url); return { action: 'deny' } }) current.webContents.on('will-navigate', (event, url) => { if (!isInternalUrl(url, origin)) { event.preventDefault(); openExternal(url) } }) current.webContents.on('will-attach-webview', event => event.preventDefault()) current.webContents.on('will-redirect', (event, url) => { if (!isInternalUrl(url, origin)) event.preventDefault() }) void current.loadURL(origin).catch(error => { if (!quitting) void fail(error) }) } async function fail(error: unknown) { if (quitting) return const text = desktopStrings(app.getLocale()) console.error(error) const result = await dialog.showMessageBox({ type: 'error', title: text.failed, message: error instanceof Error ? error.message : String(error), detail: `${text.logHint} ${join(stateHome, 'logs')}`, buttons: [text.quit, text.logs], defaultId: 0, cancelId: 0, }) if (result.response === 1) await shell.openPath(join(stateHome, 'logs')) app.quit() } async function startBackend(): Promise { // Windows has no Unix-domain bridge socket; reserve a free private TCP endpoint. let bridgeEndpoint = process.env.HERMES_AGENT_BRIDGE_ENDPOINT if (process.platform === 'win32' && !bridgeEndpoint) { bridgeEndpoint = await new Promise((resolve, reject) => { const probe = createTcpServer() probe.once('error', reject) probe.listen(0, '127.0.0.1', () => { const address = probe.address() as { port: number } probe.close(error => error ? reject(error) : resolve(`tcp://127.0.0.1:${address.port}`)) }) }) } return new Promise((resolve, reject) => { const root = app.getAppPath() const env = desktopEnvironment(root, stateHome, storedPort(readState().port)) if (development) env.CORS_ORIGINS = `http://127.0.0.1:${process.env.HERMES_WEB_UI_FRONTEND_PORT || '8649'}` // The MCP Python subprocess launches the app executable in Node mode. env.HERMES_AGENT_NODE = process.execPath if (bridgeEndpoint) env.HERMES_AGENT_BRIDGE_ENDPOINT = bridgeEndpoint backend = utilityProcess.fork(join(root, 'dist/electron/backend.js'), [], { cwd: stateHome, env, stdio: 'pipe', serviceName: 'Hermes local service', }) mkdirSync(join(stateHome, 'logs'), { recursive: true }) const output = (data: Buffer) => { const file = join(stateHome, 'logs/desktop.log') try { try { if (statSync(file).size > 3 * 1024 * 1024) truncateSync(file, 0) } catch { /* First output creates the file. */ } appendFileSync(file, data) } catch (error) { console.error('Unable to write desktop log', error) } process.stdout.write(data) } backend.stdout?.on('data', output) backend.stderr?.on('data', output) const timeout = setTimeout(() => reject(new Error('Local service startup timed out (90s)')), 90_000) backend.on('message', message => { if (message?.type !== 'hermes:ready') return const url = new URL(message.url) if (url.hostname !== '127.0.0.1' || url.protocol !== 'http:') return clearTimeout(timeout) saveState({ port: Number(url.port) }) resolve(url.origin) }) backend.on('exit', code => { backendExited = true clearTimeout(timeout) reject(new Error(`Local service exited during startup (${code})`)) if (startupComplete && !quitting) void fail(new Error(desktopStrings(app.getLocale()).stopped)) }) }) } async function stopBackend() { const child = backend if (!child || backendExited) return await new Promise(resolve => { const timeout = setTimeout(() => { child.kill(); resolve() }, 18_000) child.once('exit', () => { clearTimeout(timeout); resolve() }) child.postMessage({ type: 'hermes:shutdown' }) }) } function installMenu() { const text = desktopStrings(app.getLocale()) const items: MenuItemConstructorOptions[] = [ ...(process.platform === 'darwin' ? [{ role: 'appMenu' as const }] : []), { role: 'fileMenu' }, { role: 'editMenu' }, { role: 'viewMenu' }, { role: 'windowMenu' }, { role: 'help', submenu: [ { label: text.about, click: () => app.showAboutPanel() }, { label: text.logs, click: () => { void shell.openPath(join(stateHome, 'logs')) } }, { label: text.state, click: () => { void shell.openPath(stateHome) } }, ] }, ] Menu.setApplicationMenu(Menu.buildFromTemplate(items)) app.setAboutPanelOptions({ applicationName: 'Hermes Desktop', applicationVersion: app.getVersion() }) } const singleInstance = app.requestSingleInstanceLock() if (!singleInstance) app.quit() else { app.on('second-instance', () => { if (!window && startupComplete) createWindow() if (window?.isMinimized()) window.restore() window?.show() window?.focus() }) app.on('activate', () => { if (!window && startupComplete) createWindow() }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() }) app.on('before-quit', event => { if (shutdownComplete) return event.preventDefault() if (quitting) return quitting = true // Close renderer WebSockets first so terminal shells are disposed before HTTP shutdown. for (const current of BrowserWindow.getAllWindows()) { saveState({ bounds: current.getNormalBounds(), maximized: current.isMaximized() }) current.destroy() } void (async () => { try { await devServer?.close(); await stopBackend() } finally { shutdownComplete = true; app.quit() } })() }) app.whenReady().then(async () => { stateHome = desktopStateHome(app.getPath('userData')) mkdirSync(stateHome, { recursive: true }) installMenu() const backendOrigin = await startBackend() if (quitting) return origin = backendOrigin if (development) { process.env.HERMES_WEB_UI_BACKEND_PORT = new URL(backendOrigin).port const { createServer } = await import('vite') devServer = await createServer({ configFile: join(app.getAppPath(), 'vite.config.ts'), server: { host: '127.0.0.1' } }) await devServer.listen() origin = devServer.resolvedUrls!.local[0]!.replace(/\/$/, '') } const ses = session.defaultSession ses.setPermissionCheckHandler((_contents, permission, requestingOrigin, details) => isDesktopPermissionAllowed(permission, requestingOrigin, origin, details.mediaType ? [details.mediaType] : [])) ses.setPermissionRequestHandler((contents, permission, callback, details) => callback(isDesktopPermissionAllowed(permission, contents.getURL(), origin, 'mediaTypes' in details ? details.mediaTypes : []))) // Electron's native Save dialog handles all browser download links. ses.on('will-download', (_event, item) => item.setSaveDialogOptions({ defaultPath: join(app.getPath('downloads'), item.getFilename()) })) startupComplete = true createWindow() }).catch(fail) }