feat: scaffold electron desktop packaging

This commit is contained in:
Aurora release bot
2026-05-01 19:33:09 -04:00
parent d23a63921a
commit 69de480775
7 changed files with 2130 additions and 3 deletions

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

View File

@@ -0,0 +1,50 @@
module.exports = {
appId: 'com.hermesworkspace.app',
productName: 'Hermes Workspace',
copyright: 'Copyright © 2026 Hermes Workspace',
icon: 'assets/icon.png',
directories: {
output: 'release',
buildResources: 'assets',
},
files: [
'dist/client/**/*',
'dist/server/**/*',
'electron/main.cjs',
'electron/preload.cjs',
'electron/prod-server.cjs',
'assets/**/*',
'public/**/*',
'package.json',
'!**/puppeteer-extra-plugin-stealth/**/*',
'!**/playwright-extra/**/*',
],
npmArgs: ['--ignore-scripts'],
nodeGypRebuild: false,
mac: {
category: 'public.app-category.developer-tools',
target: [{ target: 'dmg', arch: ['arm64', 'x64'] }],
darkModeSupport: true,
hardenedRuntime: false,
gatekeeperAssess: false,
},
dmg: {
title: 'Hermes Workspace',
iconSize: 80,
contents: [
{ x: 130, y: 220 },
{ x: 410, y: 220, type: 'link', path: '/Applications' },
],
},
win: {
target: [{ target: 'nsis', arch: ['x64'] }],
},
nsis: {
oneClick: true,
perMachine: false,
allowToChangeInstallationDirectory: false,
deleteAppDataOnUninstall: false,
},
asar: false,
compression: 'maximum',
}

239
electron/main.cjs Normal file
View File

@@ -0,0 +1,239 @@
const { app, BrowserWindow, ipcMain, shell } = require('electron')
const { join } = require('path')
const { existsSync } = require('fs')
const { spawn, execSync } = require('child_process')
const http = require('http')
const APP_PORT = 3847
const HERMES_GATEWAY_URL = 'http://127.0.0.1:8642/health'
const HERMES_DASHBOARD_URL = 'http://127.0.0.1:9119/api/status'
const HERMES_INSTALL_SCRIPT =
'curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup'
let mainWindow = null
let localServer = null
let localServerPort = APP_PORT
let localServerReady = false
let installProcess = null
const gotLock = app.requestSingleInstanceLock()
if (!gotLock) app.quit()
function checkHttp(url, timeoutMs = 2500) {
return new Promise((resolve) => {
const request = http.get(url, { timeout: timeoutMs }, (response) => {
resolve((response.statusCode || 500) < 500)
response.resume()
})
request.on('error', () => resolve(false))
request.on('timeout', () => {
request.destroy()
resolve(false)
})
})
}
function isHermesInstalled() {
try {
execSync('which hermes || where hermes', {
timeout: 5000,
stdio: 'ignore',
shell: true,
})
return true
} catch {
return false
}
}
async function getBootstrapStatus() {
return {
hermesInstalled: isHermesInstalled(),
gatewayReachable: await checkHttp(HERMES_GATEWAY_URL),
dashboardReachable: await checkHttp(HERMES_DASHBOARD_URL),
installerRunning: Boolean(installProcess && !installProcess.killed),
localServerReady,
localServerPort,
}
}
function spawnDetached(command) {
const child = spawn('bash', ['-lc', command], {
detached: true,
stdio: 'ignore',
env: {
...process.env,
HERMES_WORKSPACE_DESKTOP: '1',
API_SERVER_ENABLED: process.env.API_SERVER_ENABLED || 'true',
},
})
child.unref()
return child
}
async function installHermesInBackground() {
if (installProcess) {
return { started: false, reason: 'already-running' }
}
installProcess = spawn('bash', ['-lc', HERMES_INSTALL_SCRIPT], {
detached: false,
stdio: 'ignore',
env: { ...process.env },
})
installProcess.on('exit', () => {
installProcess = null
void ensureHermesBackend()
})
return { started: true }
}
async function ensureHermesBackend() {
const gatewayReachable = await checkHttp(HERMES_GATEWAY_URL)
const dashboardReachable = await checkHttp(HERMES_DASHBOARD_URL)
if (!isHermesInstalled()) {
await installHermesInBackground()
return { installed: false, gatewayReachable, dashboardReachable }
}
if (!gatewayReachable) {
spawnDetached('hermes gateway run >/tmp/hermes-workspace-gateway.log 2>&1')
}
if (!dashboardReachable) {
spawnDetached(
'hermes dashboard --no-open >/tmp/hermes-workspace-dashboard.log 2>&1',
)
}
return {
installed: true,
gatewayReachable: await checkHttp(HERMES_GATEWAY_URL, 4000),
dashboardReachable: await checkHttp(HERMES_DASHBOARD_URL, 4000),
}
}
function getAppUrl() {
if (process.env.NODE_ENV === 'development') {
return 'http://127.0.0.1:3002/?desktop=1'
}
return `http://127.0.0.1:${localServerPort}/?desktop=1`
}
function startLocalServer() {
return new Promise((resolve, reject) => {
if (process.env.NODE_ENV === 'development') {
localServerReady = true
resolve()
return
}
localServer = spawn(
process.execPath,
[join(__dirname, 'prod-server.cjs'), '--port', String(APP_PORT)],
{
cwd: join(__dirname, '..'),
env: {
...process.env,
NODE_ENV: 'production',
PORT: String(APP_PORT),
HERMES_WORKSPACE_DESKTOP: '1',
HERMES_API_URL: process.env.HERMES_API_URL || 'http://127.0.0.1:8642',
HERMES_DASHBOARD_URL:
process.env.HERMES_DASHBOARD_URL || 'http://127.0.0.1:9119',
},
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
},
)
const onReady = (message) => {
if (message && message.type === 'ready') {
localServerReady = true
localServerPort = message.port || APP_PORT
cleanup()
resolve()
}
}
const onExit = (code) => {
cleanup()
reject(new Error(`desktop server exited early (${code})`))
}
const cleanup = () => {
localServer?.off('message', onReady)
localServer?.off('exit', onExit)
}
localServer.on('message', onReady)
localServer.on('exit', onExit)
localServer.stdout?.on('data', (data) => console.log(String(data).trim()))
localServer.stderr?.on('data', (data) => console.error(String(data).trim()))
})
}
async function createWindow() {
await startLocalServer()
mainWindow = new BrowserWindow({
width: 1480,
height: 940,
minWidth: 980,
minHeight: 680,
title: 'Hermes Workspace',
icon: existsSync(join(__dirname, '..', 'assets', 'icon.png'))
? join(__dirname, '..', 'assets', 'icon.png')
: undefined,
titleBarStyle: 'hiddenInset',
trafficLightPosition: { x: 16, y: 16 },
backgroundColor: '#0A0E1A',
show: false,
webPreferences: {
preload: join(__dirname, 'preload.cjs'),
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
},
})
mainWindow.once('ready-to-show', () => {
mainWindow?.show()
mainWindow?.focus()
})
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('http')) shell.openExternal(url)
return { action: 'deny' }
})
await mainWindow.loadURL(getAppUrl())
void ensureHermesBackend()
mainWindow.on('closed', () => {
mainWindow = null
})
}
ipcMain.handle('desktop:status', async () => getBootstrapStatus())
ipcMain.handle('desktop:install-hermes', async () =>
installHermesInBackground(),
)
ipcMain.handle('desktop:start-backend', async () => ensureHermesBackend())
ipcMain.handle('desktop:open-logs', async () => {
shell.openPath('/tmp')
return { ok: true }
})
app.whenReady().then(async () => {
await createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) void createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
app.on('before-quit', () => {
localServer?.kill()
})
app.setName('Hermes Workspace')

15
electron/preload.cjs Normal file
View File

@@ -0,0 +1,15 @@
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('hermesDesktop', {
bootstrap: {
status: () => ipcRenderer.invoke('desktop:status'),
installHermes: () => ipcRenderer.invoke('desktop:install-hermes'),
startBackend: () => ipcRenderer.invoke('desktop:start-backend'),
openLogs: () => ipcRenderer.invoke('desktop:open-logs'),
},
app: {
version: process.env.npm_package_version || '2.1.3',
platform: process.platform,
isElectron: true,
},
})

126
electron/prod-server.cjs Normal file
View File

@@ -0,0 +1,126 @@
const http = require('http')
const fs = require('fs')
const path = require('path')
const portArg = process.argv.find(
(value, index, arr) => arr[index - 1] === '--port',
)
const PORT = Number.parseInt(process.env.PORT || portArg || '3847', 10)
const DIST_CLIENT = path.join(__dirname, '..', 'dist', 'client')
const DIST_SERVER = path.join(__dirname, '..', 'dist', 'server', 'server.js')
const MIME_TYPES = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.webmanifest': 'application/manifest+json',
}
async function main() {
process.env.NODE_ENV = process.env.NODE_ENV || 'production'
process.env.HERMES_WORKSPACE_DESKTOP =
process.env.HERMES_WORKSPACE_DESKTOP || '1'
process.env.HERMES_API_URL =
process.env.HERMES_API_URL || 'http://127.0.0.1:8642'
process.env.HERMES_DASHBOARD_URL =
process.env.HERMES_DASHBOARD_URL || 'http://127.0.0.1:9119'
const serverModule = await import(`file://${DIST_SERVER}`)
const serverBuild = serverModule.default
const server = http.createServer(async (req, res) => {
const url = req.url || '/'
const pathname = url.split('?')[0]
if (pathname !== '/' && !pathname.startsWith('/api/')) {
const filePath = path.join(DIST_CLIENT, pathname)
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
const ext = path.extname(filePath)
const mime = MIME_TYPES[ext] || 'application/octet-stream'
const content = fs.readFileSync(filePath)
res.writeHead(200, {
'Content-Type': mime,
'Cache-Control': pathname.includes('/assets/')
? 'public, max-age=31536000, immutable'
: 'public, max-age=3600',
})
res.end(content)
return
}
}
try {
const headers = new Headers()
for (const [key, value] of Object.entries(req.headers)) {
if (value)
headers.set(key, Array.isArray(value) ? value.join(', ') : value)
}
const protocol = req.headers['x-forwarded-proto'] || 'http'
const host = req.headers.host || `127.0.0.1:${PORT}`
const fullUrl = `${protocol}://${host}${url}`
const webRequest = new Request(fullUrl, {
method: req.method,
headers,
body:
req.method !== 'GET' && req.method !== 'HEAD'
? await new Promise((resolve) => {
const chunks = []
req.on('data', (chunk) => chunks.push(chunk))
req.on('end', () => resolve(Buffer.concat(chunks)))
})
: undefined,
duplex: 'half',
})
const webResponse = await serverBuild.fetch(webRequest)
const resHeaders = {}
webResponse.headers.forEach((value, key) => {
resHeaders[key] = value
})
res.writeHead(
webResponse.status,
webResponse.statusText || '',
resHeaders,
)
if (webResponse.body) {
const reader = webResponse.body.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
res.write(value)
}
}
res.end()
} catch (error) {
console.error('[Hermes Workspace desktop] SSR error:', error)
res.writeHead(500, { 'Content-Type': 'text/plain' })
res.end('Internal Server Error')
}
})
server.listen(PORT, '127.0.0.1', () => {
console.log(
`[Hermes Workspace desktop] server listening on http://127.0.0.1:${PORT}`,
)
if (process.send) process.send({ type: 'ready', port: PORT })
})
}
main().catch((error) => {
console.error('[Hermes Workspace desktop] fatal:', error)
process.exit(1)
})

View File

@@ -1,11 +1,12 @@
{
"name": "hermes-workspace",
"version": "2.1.3",
"description": "Desktop workspace for Hermes Agent \u2014 chat, orchestration, and multi-agent coding pipelines",
"description": "Desktop workspace for Hermes Agent chat, orchestration, and multi-agent coding pipelines",
"author": "Eric (https://github.com/outsourc-e)",
"license": "MIT",
"private": true,
"type": "module",
"main": "electron/main.cjs",
"scripts": {
"dev": "NODE_OPTIONS=\"--max-old-space-size=2048\" vite dev",
"build": "vite build",
@@ -17,7 +18,11 @@
"smoke:managed": "node scripts/managed-companion-smoke.mjs",
"lint": "eslint",
"format": "prettier",
"check": "prettier --write . && eslint --fix"
"check": "prettier --write . && eslint --fix",
"electron:dev": "NODE_ENV=development electron .",
"electron:build": "pnpm build && electron-builder --config electron-builder.config.cjs",
"electron:build:mac": "pnpm build && electron-builder --mac --config electron-builder.config.cjs",
"electron:build:win": "pnpm build && electron-builder --win --config electron-builder.config.cjs"
},
"dependencies": {
"@base-ui/react": "^1.1.0",
@@ -77,6 +82,8 @@
"typescript": "^5.7.2",
"vite": "^7.3.2",
"vitest": "^3.0.5",
"web-vitals": "^5.1.0"
"web-vitals": "^5.1.0",
"electron": "^40.8.2",
"electron-builder": "^26.8.1"
}
}

1690
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff