417 lines
11 KiB
JavaScript
417 lines
11 KiB
JavaScript
const { app, BrowserWindow, dialog, ipcMain, shell } = require('electron')
|
|
const { join } = require('path')
|
|
const fs = require('fs')
|
|
const { existsSync } = fs
|
|
const { spawn, execSync } = require('child_process')
|
|
const http = require('http')
|
|
let autoUpdater = null
|
|
try {
|
|
;({ autoUpdater } = require('electron-updater'))
|
|
} catch (error) {
|
|
console.warn(
|
|
'[hermes-workspace] electron-updater unavailable, disabling built-in updater:',
|
|
error?.message || error,
|
|
)
|
|
}
|
|
|
|
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()
|
|
|
|
let updateState = {
|
|
checking: false,
|
|
available: false,
|
|
downloaded: false,
|
|
error: null,
|
|
version: app.getVersion(),
|
|
}
|
|
|
|
function broadcastUpdateState() {
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send('desktop:update-state', updateState)
|
|
}
|
|
}
|
|
|
|
function configureAutoUpdater() {
|
|
if (!autoUpdater) {
|
|
updateState = {
|
|
...updateState,
|
|
checking: false,
|
|
available: false,
|
|
downloaded: false,
|
|
error: 'built-in updater unavailable in this build',
|
|
}
|
|
broadcastUpdateState()
|
|
return
|
|
}
|
|
|
|
autoUpdater.autoDownload = false
|
|
autoUpdater.autoInstallOnAppQuit = true
|
|
|
|
autoUpdater.on('checking-for-update', () => {
|
|
updateState = { ...updateState, checking: true, error: null }
|
|
broadcastUpdateState()
|
|
})
|
|
autoUpdater.on('update-available', async (info) => {
|
|
updateState = {
|
|
...updateState,
|
|
checking: false,
|
|
available: true,
|
|
downloaded: false,
|
|
error: null,
|
|
latestVersion: info?.version || null,
|
|
}
|
|
broadcastUpdateState()
|
|
const result = await dialog.showMessageBox({
|
|
type: 'info',
|
|
buttons: ['Download update', 'Later'],
|
|
defaultId: 0,
|
|
cancelId: 1,
|
|
title: 'Update available',
|
|
message: `A new hermes-workspace version (${info?.version || 'latest'}) is available.`,
|
|
detail: 'Download and install it from inside the app?',
|
|
})
|
|
if (result.response === 0) {
|
|
await autoUpdater.downloadUpdate()
|
|
}
|
|
})
|
|
autoUpdater.on('update-not-available', () => {
|
|
updateState = {
|
|
...updateState,
|
|
checking: false,
|
|
available: false,
|
|
downloaded: false,
|
|
error: null,
|
|
}
|
|
broadcastUpdateState()
|
|
})
|
|
autoUpdater.on('update-downloaded', async (info) => {
|
|
updateState = {
|
|
...updateState,
|
|
checking: false,
|
|
available: true,
|
|
downloaded: true,
|
|
error: null,
|
|
latestVersion: info?.version || null,
|
|
}
|
|
broadcastUpdateState()
|
|
const result = await dialog.showMessageBox({
|
|
type: 'info',
|
|
buttons: ['Install and restart', 'Later'],
|
|
defaultId: 0,
|
|
cancelId: 1,
|
|
title: 'Update ready',
|
|
message: `hermes-workspace ${info?.version || ''} is ready to install.`,
|
|
detail: 'The app will restart to finish the update.',
|
|
})
|
|
if (result.response === 0) {
|
|
autoUpdater.quitAndInstall()
|
|
}
|
|
})
|
|
autoUpdater.on('error', (error) => {
|
|
updateState = {
|
|
...updateState,
|
|
checking: false,
|
|
error: error?.message || String(error),
|
|
}
|
|
broadcastUpdateState()
|
|
})
|
|
}
|
|
|
|
async function checkForAppUpdates() {
|
|
if (!autoUpdater) {
|
|
return { ok: false, error: 'built-in updater unavailable in this build' }
|
|
}
|
|
try {
|
|
await autoUpdater.checkForUpdates()
|
|
return { ok: true }
|
|
} catch (error) {
|
|
updateState = {
|
|
...updateState,
|
|
checking: false,
|
|
error: error?.message || String(error),
|
|
}
|
|
broadcastUpdateState()
|
|
return { ok: false, error: updateState.error }
|
|
}
|
|
}
|
|
|
|
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 {
|
|
const cmd = process.platform === 'win32' ? 'where hermes' : 'which hermes'
|
|
execSync(cmd, {
|
|
timeout: 5000,
|
|
stdio: 'ignore',
|
|
shell: true,
|
|
})
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function getTempDir() {
|
|
return process.env.TEMP || process.env.TMP || (process.platform === 'win32' ? 'C:\\Windows\\Temp' : '/tmp')
|
|
}
|
|
|
|
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, label) {
|
|
const logDir = getTempDir()
|
|
const logFile = join(logDir, `hermes-workspace-${label}.log`)
|
|
|
|
let child
|
|
if (process.platform === 'win32') {
|
|
const logFd = fs.openSync(logFile, 'a')
|
|
child = spawn('cmd', ['/c', command], {
|
|
detached: true,
|
|
stdio: ['ignore', logFd, logFd],
|
|
env: {
|
|
...process.env,
|
|
HERMES_WORKSPACE_DESKTOP: '1',
|
|
API_SERVER_ENABLED: process.env.API_SERVER_ENABLED || 'true',
|
|
},
|
|
windowsHide: true,
|
|
})
|
|
fs.closeSync(logFd)
|
|
} else {
|
|
child = spawn('bash', ['-lc', `nohup ${command} >> '${logFile}' 2>&1 &`], {
|
|
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' }
|
|
}
|
|
// Windows: pip install (no curl|bash). macOS/Linux: use install script.
|
|
const installCmd = process.platform === 'win32'
|
|
? 'pip install hermes-agent'
|
|
: HERMES_INSTALL_SCRIPT
|
|
const shell = process.platform === 'win32' ? 'cmd' : 'bash'
|
|
const args = process.platform === 'win32' ? ['/c', installCmd] : ['-lc', installCmd]
|
|
installProcess = spawn(shell, args, {
|
|
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', 'gateway')
|
|
}
|
|
if (!dashboardReachable) {
|
|
const dashboardCmd = process.platform === 'win32'
|
|
? 'hermes dashboard --port 9119 --host 127.0.0.1 --no-open'
|
|
: 'hermes dashboard --port 9119 --host 127.0.0.1 --no-open'
|
|
spawnDetached(dashboardCmd, 'dashboard')
|
|
}
|
|
|
|
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) => {
|
|
let resolved = false
|
|
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,
|
|
ELECTRON_RUN_AS_NODE: '1',
|
|
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
|
|
resolved = true
|
|
cleanup()
|
|
resolve()
|
|
}
|
|
}
|
|
const onExit = (code) => {
|
|
cleanup()
|
|
if (!resolved) {
|
|
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()))
|
|
|
|
setTimeout(() => {
|
|
if (!resolved) {
|
|
cleanup()
|
|
reject(new Error('desktop server startup timed out after 20s'))
|
|
}
|
|
}, 20_000)
|
|
})
|
|
}
|
|
|
|
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()
|
|
setTimeout(() => {
|
|
void checkForAppUpdates()
|
|
}, 15000)
|
|
|
|
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(getTempDir())
|
|
return { ok: true }
|
|
})
|
|
ipcMain.handle('desktop:update-check', async () => checkForAppUpdates())
|
|
ipcMain.handle('desktop:update-state', async () => updateState)
|
|
|
|
app.whenReady().then(async () => {
|
|
configureAutoUpdater()
|
|
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')
|