217 lines
9.7 KiB
TypeScript
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)
|
|
}
|