feat: scaffold electron desktop packaging
This commit is contained in:
BIN
assets/icon.png
Normal file
BIN
assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
50
electron-builder.config.cjs
Normal file
50
electron-builder.config.cjs
Normal 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
239
electron/main.cjs
Normal 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
15
electron/preload.cjs
Normal 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
126
electron/prod-server.cjs
Normal 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)
|
||||
})
|
||||
13
package.json
13
package.json
@@ -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
1690
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user