initialize Electron desktop app and remove unused files
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user