Files
hermes-desktop/electron/main.ts
T
toor 01d9e301fc
Build / build (push) Canceled after 0s
Playwright / browser (push) Canceled after 0s
initialize Electron desktop app and remove unused files
2026-10-25 01:09:01 +08:00

217 lines
9.7 KiB
TypeScript

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<string, any> {
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<string, unknown>) {
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<string> {
// 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<string>((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<void>(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)
}