Files
Hermes-ui/packages/desktop/src/main/index.ts
T

245 lines
7.0 KiB
TypeScript

import { app, BrowserWindow, Menu, Tray, shell, ipcMain, nativeImage } from 'electron'
import { join } from 'node:path'
import { startWebUiServer, stopWebUiServer, getToken } from './webui-server'
import { desktopIcon, desktopTrayTemplateIcon, hermesBinExists, hermesBin } from './paths'
import { checkForDesktopUpdates, initAutoUpdater } from './updater'
import { t } from './desktop-i18n'
const PORT = Number(process.env.HERMES_DESKTOP_PORT) || 8748
const START_HIDDEN = process.argv.includes('--hidden')
let mainWindow: BrowserWindow | null = null
let serverUrl: string | null = null
let tray: Tray | null = null
let isQuitting = false
function showMainWindow() {
if (!mainWindow) {
createWindow()
}
if (!mainWindow) return
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.show()
mainWindow.focus()
}
function quitApp() {
isQuitting = true
app.quit()
}
function getOpenAtLogin(): boolean {
return app.getLoginItemSettings().openAtLogin
}
function setOpenAtLogin(openAtLogin: boolean) {
app.setLoginItemSettings({
openAtLogin,
openAsHidden: true,
path: process.execPath,
args: ['--hidden'],
})
}
function updateTrayMenu() {
if (!tray) return
const isVisible = !!mainWindow && mainWindow.isVisible()
const menu = Menu.buildFromTemplate([
{
label: isVisible ? t('tray.hide') : t('tray.show'),
click: () => {
if (mainWindow?.isVisible()) {
mainWindow.hide()
} else {
showMainWindow()
}
updateTrayMenu()
},
},
{
label: t('tray.checkForUpdates'),
click: () => {
checkForDesktopUpdates(true).catch(err => {
console.error('[tray] update check failed:', err)
})
},
},
{
label: t('tray.openAtLogin'),
type: 'checkbox',
checked: getOpenAtLogin(),
click: (item) => {
setOpenAtLogin(item.checked)
updateTrayMenu()
},
},
{ type: 'separator' },
{
label: t('tray.quit'),
click: quitApp,
},
])
tray.setContextMenu(menu)
}
function createTray() {
if (tray) return
const source = process.platform === 'darwin' ? desktopTrayTemplateIcon() : desktopIcon()
const icon = nativeImage.createFromPath(source).resize({
width: process.platform === 'darwin' ? 18 : 16,
height: process.platform === 'darwin' ? 18 : 16,
quality: 'best',
})
if (process.platform === 'darwin') {
icon.setTemplateImage(true)
}
tray = new Tray(icon)
tray.setToolTip('Hermes Studio')
tray.on('click', () => {
showMainWindow()
updateTrayMenu()
})
updateTrayMenu()
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1280,
height: 820,
minWidth: 960,
minHeight: 600,
title: 'Hermes Studio',
backgroundColor: '#1a1a1a',
autoHideMenuBar: true,
show: !START_HIDDEN,
...(process.platform === 'linux' ? { icon: desktopIcon() } : {}),
webPreferences: {
preload: join(__dirname, '..', 'preload', 'index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
})
mainWindow.on('close', (event) => {
if (isQuitting) return
event.preventDefault()
mainWindow?.hide()
updateTrayMenu()
})
mainWindow.on('show', updateTrayMenu)
mainWindow.on('hide', updateTrayMenu)
// External links → system browser
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('http://127.0.0.1') || url.startsWith('http://localhost')) {
return { action: 'allow' }
}
shell.openExternal(url).catch(() => undefined)
return { action: 'deny' }
})
// If the Web UI server is already up (re-opening window after close on
// macOS), go straight to it. Otherwise show a loading splash; bootstrap()
// will swap in the real URL once the server is ready.
if (serverUrl) {
mainWindow.loadURL(serverUrl)
} else {
mainWindow.loadURL(splashHtml())
}
updateTrayMenu()
}
function splashHtml(): string {
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Hermes Studio</title>
<style>
html,body{margin:0;height:100%;background:#1a1a1a;color:#e5e5e5;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;}
.wrap{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:24px}
.dot{width:10px;height:10px;border-radius:50%;background:#888;animation:pulse 1.2s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:.3}50%{opacity:1}}
.row{display:flex;gap:8px}
.row .dot:nth-child(2){animation-delay:.2s}.row .dot:nth-child(3){animation-delay:.4s}
.label{font-size:14px;color:#999}
h1{font-weight:500;margin:0;font-size:18px}
</style></head><body><div class="wrap">
<h1>Hermes Studio</h1>
<div class="row"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>
<div class="label">Starting local services…</div>
</div></body></html>`
return 'data:text/html;charset=utf-8,' + encodeURIComponent(html)
}
async function bootstrap() {
if (!hermesBinExists()) {
console.error(`hermes binary missing at ${hermesBin()}`)
console.error('Run: npm run prepare:python (to bundle Python + hermes-agent)')
}
try {
const url = await startWebUiServer(PORT)
serverUrl = url
if (mainWindow) await mainWindow.loadURL(url)
} catch (err) {
console.error('Failed to start Web UI server:', err)
if (mainWindow) {
const msg = String(err instanceof Error ? err.message : err).replace(/[<>]/g, '')
mainWindow.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(
`<html><body style="font-family:system-ui;padding:32px;background:#1a1a1a;color:#eee">
<h2>Failed to start local services</h2><pre style="white-space:pre-wrap;color:#f88">${msg}</pre>
</body></html>`,
))
}
}
}
ipcMain.handle('hermes-desktop:get-token', () => getToken())
const gotLock = app.requestSingleInstanceLock()
if (!gotLock) {
app.quit()
} else {
app.on('second-instance', () => {
showMainWindow()
})
app.whenReady().then(() => {
// Drop the default File/Edit/View/Window menu on Windows/Linux. The web
// UI provides its own in-page controls, so the native menu bar is just
// visual clutter. macOS keeps a menu (system requirement) but Electron's
// default is fine there.
if (process.platform !== 'darwin') Menu.setApplicationMenu(null)
createTray()
createWindow()
bootstrap()
initAutoUpdater({
beforeQuitAndInstall: () => {
isQuitting = true
},
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
} else if (mainWindow) {
showMainWindow()
}
})
})
app.on('window-all-closed', () => {
if (isQuitting && process.platform !== 'darwin') app.quit()
})
app.on('before-quit', async (e) => {
if (!isQuitting && process.platform !== 'darwin') {
e.preventDefault()
mainWindow?.hide()
updateTrayMenu()
return
}
e.preventDefault()
await stopWebUiServer().catch(() => undefined)
app.exit(0)
})
}