mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
The previous dependency, @homebridge/node-pty-prebuilt-multiarch@0.13.1,
publishes no win32-arm64 prebuilds on its v0.13.x line, and its v0.14.x
betas (which do add an arm64 Windows build) ship no electron-vXXX-win32-
arm64 prebuilds at all -- so packaged Electron 40 builds (NMV 143) would
fail at runtime even on a successful npm install. Net effect: the
desktop's integrated terminal was unbuildable on Windows-on-ARM, in
both dev (npm install fails: 404 fetching the node-vXXX-win32-arm64
prebuilt) and packaged builds (no Electron-ABI prebuilt exists).
The homebridge fork was originally created because upstream node-pty
shipped no prebuilds at all. That hasn't been true since node-pty@1.0
(April 2024), which:
- bundles prebuilts for mac (arm64+x64) and Windows (arm64+x64) directly
inside the npm tarball -- no GitHub-Releases fetch, no missing-binary
failure mode
- uses N-API (node-addon-api) for ABI stability across Node and Electron
major versions, so the same pty.node binary loads under Node 22 (dev)
and Electron 40+ (packaged) without per-ABI rebuilds
- is what VS Code, Hyper, and Theia actually ship
API surface is identical (spawn / onData / onExit / write / resize /
kill) -- no call-site changes needed.
Specifically:
- apps/desktop/package.json: replace the @homebridge fork with
node-pty@1.1.0 (exact pin). Widen `asarUnpack` from `["**/*.node"]`
to also unpack `**/prebuilds/**`, because node-pty ships runtime-
execed helpers alongside its .node files (darwin spawn-helper has no
extension and would not be matched by `**/*.node`; conpty.dll,
OpenConsole.exe, winpty.dll, winpty-agent.exe on Windows are also
exec'd at runtime and cannot live inside asar).
- apps/desktop/electron/main.cjs: update both require() strings to
match the new package name and the new staged path under
resources/native-deps/node-pty/.
- apps/desktop/scripts/stage-native-deps.cjs: point at node_modules/
node-pty. node-pty's prebuilts live under prebuilds/<plat>-<arch>/
(not build/Release/), so update the include glob to copy that dir.
Per-arch staging keeps the resource bundle small (target arch comes
from npm_config_arch when electron-builder cross-builds, else
process.arch). Explicitly enumerate file types in the prebuilds glob
so the ~25 MB of .pdb debug symbols that prebuild-install bundles
for Windows crash analysis don't bloat the installer (29 MB -> 2.6 MB
staged on win32-arm64). Re-assert +x on the darwin spawn-helper
defensively, since a stripped mode bit would manifest as a silent
ENOENT at first pty.spawn().
- apps/desktop/scripts/test-desktop.mjs: update expectedNativeDepPaths()
and its assertion site to look at prebuilds/<plat>-<arch>/ instead of
build/Release/. Add an explicit spawn-helper-exists check on darwin
so a regression in the asarUnpack glob would fail loudly in CI rather
than at first PTY spawn.
Trade-off: Linux end-users lose prebuilts and fall back to building
node-pty from source on `npm install`. Acceptable because Hermes
ships no Linux desktop builds (desktop-release.yml matrix is mac + win
only, package.json declares no `linux` target), and Linux developers
hacking on the desktop already need a C++ toolchain for the rest of
the stack.
Verified on Windows 11 ARM64 (Snapdragon):
npm install -> exit 0
node -e "require('node-pty').spawn(...)" round-trip -> OK
stage-native-deps -> 27 files, 2.6 MB
load from staged tree (simulates packaged fallback) -> ConPTY
round-trip OK
3308 lines
106 KiB
JavaScript
3308 lines
106 KiB
JavaScript
const {
|
||
app,
|
||
BrowserWindow,
|
||
Menu,
|
||
Notification,
|
||
clipboard,
|
||
dialog,
|
||
ipcMain,
|
||
nativeImage,
|
||
nativeTheme,
|
||
safeStorage,
|
||
session,
|
||
shell,
|
||
systemPreferences
|
||
} = require('electron')
|
||
const crypto = require('node:crypto')
|
||
const fs = require('node:fs')
|
||
const http = require('node:http')
|
||
const https = require('node:https')
|
||
const net = require('node:net')
|
||
const path = require('node:path')
|
||
const { fileURLToPath, pathToFileURL } = require('node:url')
|
||
const { execFileSync, spawn } = require('node:child_process')
|
||
const { isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||
const {
|
||
DATA_URL_READ_MAX_BYTES,
|
||
DEFAULT_FETCH_TIMEOUT_MS,
|
||
TEXT_PREVIEW_SOURCE_MAX_BYTES,
|
||
encryptDesktopSecret: encryptDesktopSecretStrict,
|
||
resolveReadableFileForIpc,
|
||
resolveTimeoutMs
|
||
} = require('./hardening.cjs')
|
||
|
||
let nodePty = null
|
||
|
||
try {
|
||
nodePty = require('node-pty')
|
||
} catch {
|
||
// Packaged builds set `files:` in package.json, which excludes node_modules
|
||
// from the asar. Workspace dedup also hoists this native dep to the repo
|
||
// root's node_modules, out of reach of electron-builder's collector. We
|
||
// ship a minimal copy under resources/native-deps/ via extraResources +
|
||
// scripts/stage-native-deps.cjs; resolve from there when the normal
|
||
// require() fails. Dev mode never reaches this branch -- the hoisted
|
||
// resolve succeeds via Node's normal module lookup.
|
||
try {
|
||
const path = require('node:path')
|
||
const resourcesPath = process.resourcesPath
|
||
if (resourcesPath) {
|
||
nodePty = require(
|
||
path.join(resourcesPath, 'native-deps', 'node-pty')
|
||
)
|
||
}
|
||
} catch {
|
||
nodePty = null
|
||
}
|
||
}
|
||
|
||
const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR
|
||
if (USER_DATA_OVERRIDE) {
|
||
const resolvedUserData = path.resolve(USER_DATA_OVERRIDE)
|
||
fs.mkdirSync(resolvedUserData, { recursive: true })
|
||
app.setPath('userData', resolvedUserData)
|
||
}
|
||
|
||
const PORT_FLOOR = 9120
|
||
const PORT_CEILING = 9199
|
||
const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER
|
||
const IS_PACKAGED = app.isPackaged
|
||
const IS_MAC = process.platform === 'darwin'
|
||
const IS_WINDOWS = process.platform === 'win32'
|
||
const IS_WSL = isWslEnvironment()
|
||
const APP_ROOT = app.getAppPath()
|
||
const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..')
|
||
|
||
// Build-time install stamp -- the git ref this .exe was built against.
|
||
//
|
||
// Written by apps/desktop/scripts/write-build-stamp.cjs during `npm run build`
|
||
// and bundled into packaged apps via electron-builder's extraResources entry,
|
||
// so the runtime stamp ends up at process.resourcesPath/install-stamp.json
|
||
// after install. The bootstrap runner (Phase 1D) reads it to know which
|
||
// commit to clone when running install.ps1 stages at first launch.
|
||
//
|
||
// Returns null when the file is missing (dev runs from a checkout where
|
||
// build hasn't been invoked, or schema mismatch). Callers must handle null.
|
||
//
|
||
// Schema:
|
||
// { schemaVersion: 1, commit, branch, builtAt, dirty, source }
|
||
const INSTALL_STAMP_SCHEMA_VERSION = 1
|
||
function loadInstallStamp() {
|
||
// Try packaged location first (resources/install-stamp.json), then the
|
||
// dev/local build output (apps/desktop/build/install-stamp.json) so
|
||
// someone running `npm run start` after a local `npm run build` also
|
||
// sees a stamp without needing a packaged build.
|
||
const candidates = [
|
||
process.resourcesPath ? path.join(process.resourcesPath, 'install-stamp.json') : null,
|
||
path.join(APP_ROOT, 'build', 'install-stamp.json')
|
||
].filter(Boolean)
|
||
for (const p of candidates) {
|
||
try {
|
||
const raw = fs.readFileSync(p, 'utf8')
|
||
const parsed = JSON.parse(raw)
|
||
if (parsed && typeof parsed === 'object' && typeof parsed.commit === 'string' && parsed.commit.length >= 7) {
|
||
if (parsed.schemaVersion !== INSTALL_STAMP_SCHEMA_VERSION) {
|
||
console.warn(`[hermes] install-stamp.json schemaVersion ${parsed.schemaVersion} != expected ${INSTALL_STAMP_SCHEMA_VERSION}; ignoring`)
|
||
continue
|
||
}
|
||
return Object.freeze({
|
||
schemaVersion: parsed.schemaVersion,
|
||
commit: parsed.commit,
|
||
branch: parsed.branch || null,
|
||
builtAt: parsed.builtAt || null,
|
||
dirty: Boolean(parsed.dirty),
|
||
source: parsed.source || null,
|
||
path: p
|
||
})
|
||
}
|
||
} catch {
|
||
// Either ENOENT or malformed JSON; try the next candidate
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
const INSTALL_STAMP = loadInstallStamp()
|
||
if (INSTALL_STAMP) {
|
||
console.log(`[hermes] install stamp: ${INSTALL_STAMP.commit.slice(0, 12)}${INSTALL_STAMP.branch ? ` (${INSTALL_STAMP.branch})` : ''}${INSTALL_STAMP.dirty ? ' [DIRTY]' : ''} from ${INSTALL_STAMP.source || 'unknown'}`)
|
||
} else if (IS_PACKAGED) {
|
||
// Dev builds without a stamp are normal; packaged builds without one
|
||
// mean the bootstrap won't know what to clone. Surface clearly.
|
||
console.error('[hermes] WARNING: no install-stamp.json found in packaged build. First-launch bootstrap will not have a pinned ref to install.')
|
||
}
|
||
|
||
// HERMES_HOME — the user-facing root for everything Hermes-related. Mirrors
|
||
// scripts/install.ps1's $HermesHome and scripts/install.sh's $HERMES_HOME.
|
||
//
|
||
// Defaults:
|
||
// Windows: %LOCALAPPDATA%\hermes (matches install.ps1)
|
||
// macOS / Linux: ~/.hermes (matches install.sh)
|
||
//
|
||
// Special case for Windows: if the user has a legacy ~/.hermes directory
|
||
// (e.g., from a prior pip install or a manual setup) AND no
|
||
// %LOCALAPPDATA%\hermes yet, prefer the legacy path so we don't orphan their
|
||
// existing config / sessions / .env. New installs go to %LOCALAPPDATA%.
|
||
//
|
||
// HERMES_DESKTOP_USER_DATA_DIR (used by test:desktop:fresh) puts the sandbox
|
||
// HERMES_HOME beneath the throwaway userData dir so a fresh-install run never
|
||
// touches the user's real ~/.hermes / %LOCALAPPDATA%\hermes.
|
||
function resolveHermesHome() {
|
||
if (process.env.HERMES_HOME) return path.resolve(process.env.HERMES_HOME)
|
||
if (USER_DATA_OVERRIDE) return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home')
|
||
if (IS_WINDOWS && process.env.LOCALAPPDATA) {
|
||
const localappdata = path.join(process.env.LOCALAPPDATA, 'hermes')
|
||
const legacy = path.join(app.getPath('home'), '.hermes')
|
||
// Migrate transparently to LOCALAPPDATA, but honour an existing legacy
|
||
// ~/.hermes setup (no LOCALAPPDATA install yet) so users don't lose state.
|
||
if (!directoryExists(localappdata) && directoryExists(legacy)) return legacy
|
||
return localappdata
|
||
}
|
||
return path.join(app.getPath('home'), '.hermes')
|
||
}
|
||
|
||
const HERMES_HOME = resolveHermesHome()
|
||
// ACTIVE_HERMES_ROOT — the canonical mutable Hermes install. Same path
|
||
// install.ps1 / install.sh use, so a desktop-only user and a CLI-only user end
|
||
// up with identical layouts and can share one install.
|
||
const ACTIVE_HERMES_ROOT = path.join(HERMES_HOME, 'hermes-agent')
|
||
// VENV_ROOT — venv lives inside the repo, exactly like install.ps1 does it.
|
||
const VENV_ROOT = path.join(ACTIVE_HERMES_ROOT, 'venv')
|
||
// BOOTSTRAP_COMPLETE_MARKER — written by the first-launch bootstrap runner
|
||
// (Phase 1D) after install.ps1 has completed all stages and the user has
|
||
// finished initial configuration. Presence of this marker means the install
|
||
// is in a known-good state and we can skip the bootstrap flow on subsequent
|
||
// boots, going straight to `resolveHermesBackend()`. Missing or stale marker
|
||
// means we re-run the bootstrap; install.ps1's stages are idempotent so a
|
||
// re-run on an already-good install just discovers everything in place.
|
||
//
|
||
// We deliberately put the marker INSIDE ACTIVE_HERMES_ROOT (not alongside)
|
||
// so that deleting the checkout to start fresh also deletes the marker --
|
||
// avoids the confusing "marker exists but checkout is gone" state.
|
||
const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete')
|
||
const BOOTSTRAP_MARKER_SCHEMA_VERSION = 1
|
||
|
||
const DESKTOP_CONNECTION_CONFIG_PATH = path.join(app.getPath('userData'), 'connection.json')
|
||
const DESKTOP_UPDATE_CONFIG_PATH = path.join(app.getPath('userData'), 'updates.json')
|
||
// Branch we track for self-update. Flip to 'main' once the GUI work merges —
|
||
// single field edit, no rebuild required. User can also override at runtime
|
||
// via hermesDesktop.updates.setBranch().
|
||
const DEFAULT_UPDATE_BRANCH = 'bb/gui'
|
||
// desktop.log lives under HERMES_HOME/logs/ so it sits next to agent.log,
|
||
// errors.log, gateway.log produced by hermes_logging.setup_logging — one log
|
||
// directory per user, regardless of which UI surface produced the line.
|
||
const DESKTOP_LOG_PATH = path.join(HERMES_HOME, 'logs', 'desktop.log')
|
||
const DESKTOP_LOG_FLUSH_MS = 120
|
||
const DESKTOP_LOG_BUFFER_MAX_CHARS = 64 * 1024
|
||
const BOOT_FAKE_MODE = process.env.HERMES_DESKTOP_BOOT_FAKE === '1'
|
||
const BOOT_FAKE_STEP_MS = (() => {
|
||
const raw = Number.parseInt(String(process.env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS || ''), 10)
|
||
if (!Number.isFinite(raw) || raw <= 0) return 650
|
||
return Math.max(120, raw)
|
||
})()
|
||
const APP_NAME = 'Hermes'
|
||
const TITLEBAR_HEIGHT = 34
|
||
const MACOS_TRAFFIC_LIGHTS_HEIGHT = 14
|
||
const WINDOW_BUTTON_POSITION = {
|
||
x: 24,
|
||
y: TITLEBAR_HEIGHT / 2 - MACOS_TRAFFIC_LIGHTS_HEIGHT / 2
|
||
}
|
||
// Width Electron reserves for the Windows/Linux native min/max/close cluster
|
||
// when `titleBarOverlay` is enabled. The OS paints these buttons in the
|
||
// top-right corner of the renderer; we have to leave that much room on the
|
||
// right edge so our system tools (file browser, haptics, settings) don't sit
|
||
// underneath them. macOS uses left-side traffic lights instead and reports a
|
||
// position via getWindowButtonPosition(), so this width is non-zero only on
|
||
// non-macOS platforms.
|
||
const NATIVE_OVERLAY_BUTTON_WIDTH = 144
|
||
const APP_ICON_PATHS = [
|
||
path.join(APP_ROOT, 'public', 'apple-touch-icon.png'),
|
||
path.join(APP_ROOT, 'dist', 'apple-touch-icon.png'),
|
||
path.join(unpackedPathFor(APP_ROOT), 'dist', 'apple-touch-icon.png')
|
||
]
|
||
|
||
let rendererTitleBarTheme = null
|
||
const terminalSessions = new Map()
|
||
|
||
function isHexColor(value) {
|
||
return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)
|
||
}
|
||
|
||
function getTitleBarOverlayOptions() {
|
||
if (IS_MAC) {
|
||
return { height: TITLEBAR_HEIGHT }
|
||
}
|
||
|
||
if (rendererTitleBarTheme) {
|
||
return {
|
||
color: rendererTitleBarTheme.background,
|
||
height: TITLEBAR_HEIGHT,
|
||
symbolColor: rendererTitleBarTheme.foreground
|
||
}
|
||
}
|
||
|
||
const useDarkColors = nativeTheme.shouldUseDarkColors
|
||
|
||
return {
|
||
color: useDarkColors ? '#111111' : '#f7f7f7',
|
||
height: TITLEBAR_HEIGHT,
|
||
symbolColor: useDarkColors ? '#f7f7f7' : '#242424'
|
||
}
|
||
}
|
||
|
||
const MEDIA_MIME_TYPES = {
|
||
'.avi': 'video/x-msvideo',
|
||
'.bmp': 'image/bmp',
|
||
'.flac': 'audio/flac',
|
||
'.gif': 'image/gif',
|
||
'.jpeg': 'image/jpeg',
|
||
'.jpg': 'image/jpeg',
|
||
'.m4a': 'audio/mp4',
|
||
'.mkv': 'video/x-matroska',
|
||
'.mov': 'video/quicktime',
|
||
'.mp3': 'audio/mpeg',
|
||
'.mp4': 'video/mp4',
|
||
'.ogg': 'audio/ogg',
|
||
'.opus': 'audio/ogg; codecs=opus',
|
||
'.png': 'image/png',
|
||
'.svg': 'image/svg+xml',
|
||
'.wav': 'audio/wav',
|
||
'.webm': 'video/webm',
|
||
'.webp': 'image/webp'
|
||
}
|
||
|
||
const PREVIEW_HTML_EXTENSIONS = new Set(['.html', '.htm'])
|
||
const PREVIEW_WATCH_DEBOUNCE_MS = 120
|
||
const LOCAL_PREVIEW_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::1', '[::1]', 'localhost'])
|
||
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024
|
||
const PREVIEW_LANGUAGE_BY_EXT = {
|
||
'.c': 'c',
|
||
'.conf': 'ini',
|
||
'.cpp': 'cpp',
|
||
'.css': 'css',
|
||
'.csv': 'csv',
|
||
'.go': 'go',
|
||
'.graphql': 'graphql',
|
||
'.h': 'c',
|
||
'.hpp': 'cpp',
|
||
'.html': 'html',
|
||
'.java': 'java',
|
||
'.js': 'javascript',
|
||
'.json': 'json',
|
||
'.jsx': 'jsx',
|
||
'.kt': 'kotlin',
|
||
'.lua': 'lua',
|
||
'.md': 'markdown',
|
||
'.mjs': 'javascript',
|
||
'.py': 'python',
|
||
'.rb': 'ruby',
|
||
'.rs': 'rust',
|
||
'.sh': 'shell',
|
||
'.sql': 'sql',
|
||
'.svg': 'xml',
|
||
'.toml': 'toml',
|
||
'.ts': 'typescript',
|
||
'.tsx': 'tsx',
|
||
'.txt': 'text',
|
||
'.xml': 'xml',
|
||
'.yaml': 'yaml',
|
||
'.yml': 'yaml',
|
||
'.zsh': 'shell'
|
||
}
|
||
|
||
function looksBinary(buffer) {
|
||
if (!buffer.length) return false
|
||
|
||
let suspicious = 0
|
||
|
||
for (const byte of buffer) {
|
||
if (byte === 0) return true
|
||
// Allow common whitespace controls: tab, LF, CR.
|
||
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) suspicious += 1
|
||
}
|
||
|
||
return suspicious / buffer.length > 0.12
|
||
}
|
||
|
||
function previewFileMetadata(filePath, mimeType) {
|
||
let byteSize = 0
|
||
let binary = false
|
||
|
||
try {
|
||
const stat = fs.statSync(filePath)
|
||
byteSize = stat.size
|
||
|
||
if (!mimeType.startsWith('image/')) {
|
||
const fd = fs.openSync(filePath, 'r')
|
||
|
||
try {
|
||
const sample = Buffer.alloc(Math.min(byteSize, 4096))
|
||
const bytesRead = fs.readSync(fd, sample, 0, sample.length, 0)
|
||
binary = looksBinary(sample.subarray(0, bytesRead))
|
||
} finally {
|
||
fs.closeSync(fd)
|
||
}
|
||
}
|
||
} catch {
|
||
// Metadata is best-effort; the read handlers surface hard errors later.
|
||
}
|
||
|
||
return {
|
||
binary,
|
||
byteSize,
|
||
large: byteSize > TEXT_PREVIEW_MAX_BYTES
|
||
}
|
||
}
|
||
|
||
app.setName(APP_NAME)
|
||
app.setAboutPanelOptions({
|
||
applicationName: APP_NAME,
|
||
copyright: 'Copyright © 2026 Nous Research'
|
||
})
|
||
|
||
let mainWindow = null
|
||
let hermesProcess = null
|
||
let connectionPromise = null
|
||
// Latched bootstrap failure: when the first-launch install fails, we hold
|
||
// onto the error so subsequent startHermes() calls (e.g. the renderer's
|
||
// ensureGatewayOpen retrying after the WS won't open) return the same error
|
||
// instead of re-running install.ps1 in a hot loop. Cleared explicitly by
|
||
// the renderer's "Reload and retry" path or by quitting the app.
|
||
let bootstrapFailure = null
|
||
let connectionConfigCache = null
|
||
const hermesLog = []
|
||
const previewWatchers = new Map()
|
||
let previewShortcutActive = false
|
||
let desktopLogBuffer = ''
|
||
let desktopLogFlushTimer = null
|
||
let desktopLogFlushPromise = Promise.resolve()
|
||
let bootProgressState = {
|
||
error: null,
|
||
fakeMode: BOOT_FAKE_MODE,
|
||
message: 'Waiting to start Hermes backend',
|
||
phase: 'idle',
|
||
progress: 0,
|
||
running: false,
|
||
timestamp: Date.now()
|
||
}
|
||
|
||
function flushDesktopLogBufferSync() {
|
||
if (!desktopLogBuffer) return
|
||
const chunk = desktopLogBuffer
|
||
desktopLogBuffer = ''
|
||
|
||
try {
|
||
fs.mkdirSync(path.dirname(DESKTOP_LOG_PATH), { recursive: true })
|
||
fs.appendFileSync(DESKTOP_LOG_PATH, chunk)
|
||
} catch {
|
||
// Logging must never block app startup/shutdown.
|
||
}
|
||
}
|
||
|
||
function flushDesktopLogBufferAsync() {
|
||
if (!desktopLogBuffer) return desktopLogFlushPromise
|
||
const chunk = desktopLogBuffer
|
||
desktopLogBuffer = ''
|
||
|
||
desktopLogFlushPromise = desktopLogFlushPromise
|
||
.then(async () => {
|
||
await fs.promises.mkdir(path.dirname(DESKTOP_LOG_PATH), { recursive: true })
|
||
await fs.promises.appendFile(DESKTOP_LOG_PATH, chunk)
|
||
})
|
||
.catch(() => {
|
||
// Logging must never crash the desktop shell.
|
||
})
|
||
|
||
return desktopLogFlushPromise
|
||
}
|
||
|
||
function scheduleDesktopLogFlush() {
|
||
if (desktopLogFlushTimer) return
|
||
desktopLogFlushTimer = setTimeout(() => {
|
||
desktopLogFlushTimer = null
|
||
void flushDesktopLogBufferAsync()
|
||
}, DESKTOP_LOG_FLUSH_MS)
|
||
}
|
||
|
||
function rememberLog(chunk) {
|
||
const text = String(chunk || '').trim()
|
||
if (!text) return
|
||
const lines = text.split(/\r?\n/).map(line => `[hermes] ${line}`)
|
||
hermesLog.push(...lines)
|
||
if (hermesLog.length > 300) {
|
||
hermesLog.splice(0, hermesLog.length - 300)
|
||
}
|
||
|
||
desktopLogBuffer += `${lines.join('\n')}\n`
|
||
|
||
if (desktopLogBuffer.length >= DESKTOP_LOG_BUFFER_MAX_CHARS) {
|
||
if (desktopLogFlushTimer) {
|
||
clearTimeout(desktopLogFlushTimer)
|
||
desktopLogFlushTimer = null
|
||
}
|
||
void flushDesktopLogBufferAsync()
|
||
|
||
return
|
||
}
|
||
|
||
scheduleDesktopLogFlush()
|
||
}
|
||
|
||
function openExternalUrl(rawUrl) {
|
||
const raw = String(rawUrl || '').trim()
|
||
if (!raw) return false
|
||
|
||
let parsed
|
||
try {
|
||
parsed = new URL(raw)
|
||
} catch {
|
||
return false
|
||
}
|
||
|
||
if (!['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {
|
||
return false
|
||
}
|
||
|
||
const url = parsed.toString()
|
||
|
||
if (IS_WSL) {
|
||
rememberLog(`[link] opening via WSL→Windows: ${url}`)
|
||
const proc = spawn('cmd.exe', ['/c', 'start', '""', url], {
|
||
detached: true,
|
||
stdio: 'ignore',
|
||
windowsHide: true
|
||
})
|
||
proc.on('error', error => {
|
||
rememberLog(`[link] cmd.exe start failed: ${error.message}; falling back to xdg-open`)
|
||
shell.openExternal(url).catch(fallback => rememberLog(`[link] xdg-open failed: ${fallback.message}`))
|
||
})
|
||
proc.unref()
|
||
|
||
return true
|
||
}
|
||
|
||
shell.openExternal(url).catch(error => rememberLog(`[link] openExternal failed: ${error.message}`))
|
||
|
||
return true
|
||
}
|
||
|
||
function sleep(ms) {
|
||
return new Promise(resolve => setTimeout(resolve, ms))
|
||
}
|
||
|
||
function clampBootProgress(value) {
|
||
const numeric = Number(value)
|
||
if (!Number.isFinite(numeric)) return 0
|
||
return Math.max(0, Math.min(100, Math.round(numeric)))
|
||
}
|
||
|
||
function broadcastBootProgress() {
|
||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||
const { webContents } = mainWindow
|
||
if (!webContents || webContents.isDestroyed()) return
|
||
webContents.send('hermes:boot-progress', bootProgressState)
|
||
}
|
||
|
||
// Bootstrap-event broadcast channel + state. The bootstrap runner emits a
|
||
// stream of events (manifest, stage, log, complete, failed) that the renderer
|
||
// install overlay subscribes to. We also keep a running snapshot:
|
||
// - manifest: the stage list (rendered as a checklist in the overlay)
|
||
// - stages: per-stage state ('pending' | 'running' | 'succeeded' |
|
||
// 'skipped' | 'failed') keyed by stage name
|
||
// - active: true while a bootstrap is in flight; false otherwise
|
||
// - error: last 'failed' event's error message
|
||
// - log: bounded ring buffer of the last 200 log lines for the
|
||
// "Show details" affordance in the overlay
|
||
//
|
||
// The snapshot is queryable via the hermes:bootstrap:get IPC handler so a
|
||
// reloaded renderer (e.g. devtools reload during dev) recovers state.
|
||
// Bootstrap log ring: bounded buffer so a long install (npm + playwright
|
||
// downloads can emit thousands of lines) doesn't grow unbounded in memory
|
||
// AND so the renderer's getBootstrapState() reply stays a reasonable size.
|
||
// We keep enough to cover an entire failed stage's transcript so the
|
||
// 'Copy output' button gives the user actually-actionable context, not
|
||
// just the last few lines.
|
||
const BOOTSTRAP_LOG_RING_MAX = 500
|
||
let bootstrapState = {
|
||
active: false,
|
||
manifest: null,
|
||
stages: {},
|
||
error: null,
|
||
log: [],
|
||
startedAt: null,
|
||
completedAt: null,
|
||
unsupportedPlatform: null
|
||
}
|
||
|
||
function broadcastBootstrapEvent(ev) {
|
||
if (ev.type === 'manifest') {
|
||
bootstrapState.manifest = ev
|
||
bootstrapState.active = true
|
||
bootstrapState.startedAt = bootstrapState.startedAt || Date.now()
|
||
bootstrapState.stages = {}
|
||
for (const stage of ev.stages || []) {
|
||
bootstrapState.stages[stage.name] = { state: 'pending', json: null, durationMs: null, error: null }
|
||
}
|
||
} else if (ev.type === 'stage') {
|
||
bootstrapState.stages[ev.name] = {
|
||
state: ev.state,
|
||
durationMs: ev.durationMs ?? null,
|
||
json: ev.json ?? null,
|
||
error: ev.error ?? null
|
||
}
|
||
} else if (ev.type === 'log') {
|
||
bootstrapState.log.push({ ts: Date.now(), stage: ev.stage || null, line: ev.line })
|
||
if (bootstrapState.log.length > BOOTSTRAP_LOG_RING_MAX) {
|
||
bootstrapState.log.splice(0, bootstrapState.log.length - BOOTSTRAP_LOG_RING_MAX)
|
||
}
|
||
} else if (ev.type === 'complete') {
|
||
bootstrapState.active = false
|
||
bootstrapState.completedAt = Date.now()
|
||
bootstrapState.error = null
|
||
bootstrapState.unsupportedPlatform = null
|
||
} else if (ev.type === 'failed') {
|
||
bootstrapState.active = false
|
||
bootstrapState.error = ev.error || 'unknown error'
|
||
} else if (ev.type === 'unsupported-platform') {
|
||
bootstrapState.active = false
|
||
bootstrapState.unsupportedPlatform = {
|
||
platform: ev.platform,
|
||
activeRoot: ev.activeRoot,
|
||
installCommand: ev.installCommand,
|
||
docsUrl: ev.docsUrl
|
||
}
|
||
}
|
||
|
||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||
const { webContents } = mainWindow
|
||
if (!webContents || webContents.isDestroyed()) return
|
||
webContents.send('hermes:bootstrap:event', ev)
|
||
}
|
||
|
||
function getBootstrapState() {
|
||
return bootstrapState
|
||
}
|
||
|
||
function updateBootProgress(update, options = {}) {
|
||
const nextProgressRaw =
|
||
typeof update.progress === 'number' ? clampBootProgress(update.progress) : bootProgressState.progress
|
||
const nextProgress = options.allowDecrease ? nextProgressRaw : Math.max(bootProgressState.progress, nextProgressRaw)
|
||
|
||
bootProgressState = {
|
||
...bootProgressState,
|
||
...update,
|
||
error: update.error === undefined ? bootProgressState.error : update.error,
|
||
fakeMode: BOOT_FAKE_MODE || Boolean(update.fakeMode),
|
||
progress: nextProgress,
|
||
timestamp: Date.now()
|
||
}
|
||
|
||
if (update.message) {
|
||
rememberLog(`[boot] ${update.message}`)
|
||
}
|
||
|
||
broadcastBootProgress()
|
||
}
|
||
|
||
async function advanceBootProgress(phase, message, progress) {
|
||
updateBootProgress({
|
||
phase,
|
||
message,
|
||
progress,
|
||
running: true,
|
||
error: null
|
||
})
|
||
|
||
if (BOOT_FAKE_MODE) {
|
||
await sleep(BOOT_FAKE_STEP_MS)
|
||
}
|
||
}
|
||
|
||
function fileExists(filePath) {
|
||
try {
|
||
return fs.statSync(filePath).isFile()
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
function directoryExists(filePath) {
|
||
try {
|
||
return fs.statSync(filePath).isDirectory()
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
function unpackedPathFor(filePath) {
|
||
return filePath.replace(/app\.asar(?=$|[\\/])/, 'app.asar.unpacked')
|
||
}
|
||
|
||
function findOnPath(command) {
|
||
if (!command) return null
|
||
|
||
if (path.isAbsolute(command) || command.includes(path.sep) || (IS_WINDOWS && command.includes('/'))) {
|
||
if (!fileExists(command)) return null
|
||
if (isWindowsBinaryPathInWsl(command, { isWsl: IS_WSL })) return null
|
||
return command
|
||
}
|
||
|
||
const pathEntries = String(process.env.PATH || '')
|
||
.split(path.delimiter)
|
||
.filter(Boolean)
|
||
const extensions = IS_WINDOWS
|
||
? ['', ...(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)]
|
||
: ['']
|
||
|
||
for (const entry of pathEntries) {
|
||
for (const extension of extensions) {
|
||
const candidate = path.join(entry, `${command}${extension}`)
|
||
if (fileExists(candidate)) return candidate
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
function isCommandScript(command) {
|
||
return IS_WINDOWS && /\.(cmd|bat)$/i.test(command || '')
|
||
}
|
||
|
||
function isHermesSourceRoot(root) {
|
||
return directoryExists(root) && fileExists(path.join(root, 'hermes_cli', 'main.py'))
|
||
}
|
||
|
||
function findPythonForRoot(root) {
|
||
const override = process.env.HERMES_DESKTOP_PYTHON
|
||
if (override && fileExists(override)) return override
|
||
|
||
const relativePaths = IS_WINDOWS
|
||
? [path.join('.venv', 'Scripts', 'python.exe'), path.join('venv', 'Scripts', 'python.exe')]
|
||
: [path.join('.venv', 'bin', 'python'), path.join('venv', 'bin', 'python')]
|
||
|
||
for (const relativePath of relativePaths) {
|
||
const candidate = path.join(root, relativePath)
|
||
if (fileExists(candidate)) return candidate
|
||
}
|
||
|
||
return findSystemPython()
|
||
}
|
||
|
||
function findSystemPython() {
|
||
if (!IS_WINDOWS) {
|
||
// POSIX systems: PATH lookup is safe.
|
||
for (const command of ['python3', 'python']) {
|
||
const candidate = findOnPath(command)
|
||
if (candidate) return candidate
|
||
}
|
||
return null
|
||
}
|
||
|
||
// Windows: PATH-based detection has TWO landmines we have to dodge.
|
||
//
|
||
// (1) The Microsoft Store "Python stub" lives at
|
||
// %LOCALAPPDATA%\Microsoft\WindowsApps\python.exe and is on PATH
|
||
// by default on modern Windows. It's a redirector that opens the
|
||
// Store window if no Store Python is installed. Running it for
|
||
// `-m venv` would either succeed (real Store install — fine) or
|
||
// pop the Store dialog (bad UX during boot).
|
||
// (2) `py.exe` (Python launcher) is missing from per-user installs
|
||
// that didn't check the launcher option, so PATH-only checks
|
||
// miss real Python 3.13 installs (user-reported case).
|
||
//
|
||
// We also restrict ourselves to Python 3.11–3.13. 3.14 is the latest
|
||
// CPython but several Hermes deps (notably pywinpty's Rust-built
|
||
// windows_x86_64_msvc crate) don't yet publish 3.14 wheels, and
|
||
// `pip install -e .` falls back to source-build, which fails without
|
||
// a Rust toolchain. install.ps1 sidesteps this by pinning to 3.11
|
||
// via uv; until we add the same uv-managed Python pathway here, the
|
||
// simplest fix is to refuse 3.14 detection and let the NSIS prereq
|
||
// page offer to install 3.11 alongside.
|
||
//
|
||
// Strategy: probe in three passes, in order from most-precise to
|
||
// least-precise, and ONLY use PATH lookup as a last resort after
|
||
// confirming the candidate isn't the WindowsApps redirector.
|
||
//
|
||
// Pass 1: PEP 514 registry — every standards-compliant Python
|
||
// installer registers itself at SOFTWARE\Python\PythonCore.
|
||
// The MS Store stub does NOT register here, so a hit means
|
||
// a real Python install. Versions are explicit so we
|
||
// inherently filter 3.14 out.
|
||
// Pass 2: Filesystem probe of standard install locations
|
||
// (Program Files, LocalAppData\Programs\Python). Same
|
||
// version filtering by directory name.
|
||
// Pass 3: PATH lookup of `py.exe` (the launcher itself never
|
||
// triggers the Store) — but call it with a version flag so
|
||
// we resolve to a SPECIFIC supported version, not whatever
|
||
// py.exe's default is (which on a 3.14-only box would be
|
||
// 3.14).
|
||
|
||
const SUPPORTED_VERSIONS = ['3.11', '3.12', '3.13']
|
||
const SUPPORTED_VERSIONS_NO_DOT = ['311', '312', '313']
|
||
|
||
// Pass 1: registry. Use `reg query` since main process doesn't have
|
||
// a reliable in-process registry API across all electron versions.
|
||
for (const hive of ['HKLM', 'HKCU']) {
|
||
for (const version of SUPPORTED_VERSIONS) {
|
||
try {
|
||
const out = execFileSync(
|
||
'reg',
|
||
['query', `${hive}\\SOFTWARE\\Python\\PythonCore\\${version}\\InstallPath`, '/ve', '/reg:64'],
|
||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
||
)
|
||
// Output format: " (Default) REG_SZ C:\Path\To\Python\"
|
||
const match = out.match(/REG_SZ\s+(.+?)\s*$/m)
|
||
if (match) {
|
||
const installPath = match[1].trim()
|
||
const pythonExe = path.join(installPath, 'python.exe')
|
||
if (fileExists(pythonExe)) return pythonExe
|
||
}
|
||
} catch {
|
||
// Key not present — try next.
|
||
}
|
||
}
|
||
}
|
||
|
||
// Pass 2: filesystem probe of standard locations.
|
||
const programFiles = process.env['ProgramFiles'] || 'C:\\Program Files'
|
||
const localAppData = process.env.LOCALAPPDATA || ''
|
||
for (const versionDir of SUPPORTED_VERSIONS_NO_DOT) {
|
||
const systemWide = path.join(programFiles, `Python${versionDir}`, 'python.exe')
|
||
if (fileExists(systemWide)) return systemWide
|
||
if (localAppData) {
|
||
const perUser = path.join(localAppData, 'Programs', 'Python', `Python${versionDir}`, 'python.exe')
|
||
if (fileExists(perUser)) return perUser
|
||
}
|
||
}
|
||
|
||
// Pass 3: py.exe with explicit version flag. The launcher itself is
|
||
// safe to invoke (no Store popup) and `py -3.13 -c "import sys;
|
||
// print(sys.executable)"` resolves to the actual python.exe path of
|
||
// the requested version. We try in version-priority order so the
|
||
// first hit wins.
|
||
const pyExe = findOnPath('py.exe')
|
||
if (pyExe) {
|
||
for (const version of SUPPORTED_VERSIONS) {
|
||
try {
|
||
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], {
|
||
encoding: 'utf8',
|
||
stdio: ['ignore', 'pipe', 'ignore']
|
||
})
|
||
const candidate = out.trim()
|
||
if (candidate && fileExists(candidate)) return candidate
|
||
} catch {
|
||
// py couldn't find that version — try next.
|
||
}
|
||
}
|
||
}
|
||
|
||
// We deliberately do NOT fall back to plain `python.exe` on PATH.
|
||
// Without a way to verify the version safely (running `python -V`
|
||
// risks the Microsoft Store popup), accepting whatever's there
|
||
// could land us on 3.14 and trigger the Rust-build-from-source
|
||
// failure. Better to return null and let the NSIS prereq page
|
||
// offer to install a known-good 3.11 via winget.
|
||
return null
|
||
}
|
||
|
||
// findGitBash — locate bash.exe on Windows. Hermes' terminal tool requires
|
||
// bash (POSIX shell), and on Windows that's almost always Git for Windows'
|
||
// bundled Git Bash. We check the same set of locations tools/environments/
|
||
// local.py:_find_bash() checks at runtime, so a positive result here means
|
||
// the agent will be able to start a terminal too.
|
||
//
|
||
// On non-Windows hosts bash is part of the OS and this just returns the
|
||
// first bash on PATH.
|
||
function findGitBash() {
|
||
if (!IS_WINDOWS) {
|
||
return findOnPath('bash')
|
||
}
|
||
|
||
// install.ps1 drops PortableGit at %LOCALAPPDATA%\hermes\git\... — checked
|
||
// first so users who installed via install.ps1 are detected before we
|
||
// start probing system-wide locations.
|
||
const localAppData = process.env.LOCALAPPDATA || ''
|
||
const candidates = []
|
||
if (localAppData) {
|
||
candidates.push(path.join(localAppData, 'hermes', 'git', 'bin', 'bash.exe'))
|
||
candidates.push(path.join(localAppData, 'hermes', 'git', 'usr', 'bin', 'bash.exe'))
|
||
}
|
||
|
||
// Standard Git for Windows install locations.
|
||
candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'Git', 'bin', 'bash.exe'))
|
||
candidates.push(path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'))
|
||
if (localAppData) {
|
||
candidates.push(path.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'))
|
||
}
|
||
|
||
for (const candidate of candidates) {
|
||
if (fileExists(candidate)) return candidate
|
||
}
|
||
|
||
// Last resort — bash on PATH (covers WSL bash, MSYS2, custom installs).
|
||
// On WSL hosts findOnPath itself filters out Windows-binary paths via
|
||
// isWindowsBinaryPathInWsl, so we won't hand back a wsl.exe shim either.
|
||
return findOnPath('bash')
|
||
}
|
||
|
||
function getVenvPython(venvRoot) {
|
||
return path.join(venvRoot, IS_WINDOWS ? path.join('Scripts', 'python.exe') : path.join('bin', 'python'))
|
||
}
|
||
|
||
function runProcess(command, args, options = {}) {
|
||
return new Promise((resolve, reject) => {
|
||
const child = spawn(command, args, {
|
||
cwd: options.cwd,
|
||
env: options.env || process.env,
|
||
shell: Boolean(options.shell),
|
||
stdio: ['ignore', 'pipe', 'pipe']
|
||
})
|
||
|
||
child.stdout.on('data', rememberLog)
|
||
child.stderr.on('data', rememberLog)
|
||
child.once('error', reject)
|
||
child.once('exit', code => {
|
||
if (code === 0) {
|
||
resolve()
|
||
} else {
|
||
reject(new Error(`${path.basename(command)} exited with code ${code}: ${recentHermesLog()}`))
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
function recentHermesLog() {
|
||
return hermesLog.slice(-20).join('\n')
|
||
}
|
||
|
||
// ─── Self-update (git-pull against the running backend's hermes root) ──────
|
||
|
||
function readDesktopUpdateConfig() {
|
||
try {
|
||
const parsed = JSON.parse(fs.readFileSync(DESKTOP_UPDATE_CONFIG_PATH, 'utf8'))
|
||
const branch = typeof parsed?.branch === 'string' ? parsed.branch.trim() : ''
|
||
return { branch: branch || DEFAULT_UPDATE_BRANCH }
|
||
} catch {
|
||
return { branch: DEFAULT_UPDATE_BRANCH }
|
||
}
|
||
}
|
||
|
||
function writeDesktopUpdateConfig(config) {
|
||
fs.mkdirSync(path.dirname(DESKTOP_UPDATE_CONFIG_PATH), { recursive: true })
|
||
fs.writeFileSync(DESKTOP_UPDATE_CONFIG_PATH, JSON.stringify(config, null, 2))
|
||
}
|
||
|
||
// Match the backend's source resolution but bias toward a real git checkout.
|
||
// Dev → SOURCE_REPO_ROOT. Packaged/CLI install → ACTIVE_HERMES_ROOT.
|
||
// HERMES_DESKTOP_HERMES_ROOT always wins so devs can pin a worktree.
|
||
function resolveUpdateRoot() {
|
||
const candidates = [
|
||
process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT),
|
||
!IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT) ? SOURCE_REPO_ROOT : null,
|
||
isHermesSourceRoot(ACTIVE_HERMES_ROOT) ? ACTIVE_HERMES_ROOT : null
|
||
].filter(Boolean)
|
||
|
||
return candidates.find(c => directoryExists(path.join(c, '.git'))) || candidates[0] || ACTIVE_HERMES_ROOT
|
||
}
|
||
|
||
function runGit(args, options = {}) {
|
||
return new Promise((resolve, reject) => {
|
||
const child = spawn('git', IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, {
|
||
cwd: options.cwd,
|
||
env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' },
|
||
stdio: ['ignore', 'pipe', 'pipe']
|
||
})
|
||
|
||
let stdout = ''
|
||
let stderr = ''
|
||
child.stdout.on('data', chunk => {
|
||
const text = chunk.toString()
|
||
stdout += text
|
||
options.onLine?.('stdout', text)
|
||
})
|
||
child.stderr.on('data', chunk => {
|
||
const text = chunk.toString()
|
||
stderr += text
|
||
options.onLine?.('stderr', text)
|
||
})
|
||
child.once('error', reject)
|
||
child.once('exit', code => resolve({ code, stdout, stderr }))
|
||
})
|
||
}
|
||
|
||
const firstLine = text => (text || '').split('\n').find(Boolean) || ''
|
||
|
||
function emitUpdateProgress(payload) {
|
||
const merged = { stage: 'idle', message: '', percent: null, error: null, ...payload, at: Date.now() }
|
||
rememberLog(`[updates] ${merged.stage}: ${merged.message || merged.error || ''}`)
|
||
for (const window of BrowserWindow.getAllWindows()) {
|
||
window.webContents.send('hermes:updates:progress', merged)
|
||
}
|
||
}
|
||
|
||
async function checkUpdates() {
|
||
const updateRoot = resolveUpdateRoot()
|
||
const { branch } = readDesktopUpdateConfig()
|
||
const gitDir = path.join(updateRoot, '.git')
|
||
if (!directoryExists(gitDir)) {
|
||
return {
|
||
supported: false,
|
||
reason: 'not-a-git-checkout',
|
||
message: `${updateRoot} isn't a git checkout — desktop self-update only runs against a source install.`,
|
||
hermesRoot: updateRoot,
|
||
branch
|
||
}
|
||
}
|
||
|
||
const fetched = await runGit(['fetch', '--quiet', 'origin', branch], { cwd: updateRoot })
|
||
if (fetched.code !== 0) {
|
||
return {
|
||
supported: true,
|
||
branch,
|
||
error: 'fetch-failed',
|
||
message: firstLine(fetched.stderr) || 'git fetch failed.',
|
||
hermesRoot: updateRoot,
|
||
fetchedAt: Date.now()
|
||
}
|
||
}
|
||
|
||
const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim())
|
||
const [currentSha, targetSha, countStr, dirtyStr, currentBranch] = await Promise.all([
|
||
git(['rev-parse', 'HEAD']),
|
||
git(['rev-parse', `origin/${branch}`]),
|
||
git(['rev-list', `HEAD..origin/${branch}`, '--count']),
|
||
git(['status', '--porcelain']),
|
||
git(['rev-parse', '--abbrev-ref', 'HEAD'])
|
||
])
|
||
|
||
const behind = Number.parseInt(countStr, 10) || 0
|
||
const commits = behind > 0 ? await readCommitLog(updateRoot, branch) : []
|
||
|
||
return {
|
||
supported: true,
|
||
branch,
|
||
currentBranch,
|
||
behind,
|
||
currentSha,
|
||
targetSha,
|
||
commits,
|
||
dirty: dirtyStr.length > 0,
|
||
hermesRoot: updateRoot,
|
||
fetchedAt: Date.now()
|
||
}
|
||
}
|
||
|
||
async function readCommitLog(cwd, branch) {
|
||
const SEP = '\x1f'
|
||
const REC = '\x1e'
|
||
const { stdout } = await runGit(
|
||
['log', `HEAD..origin/${branch}`, `--pretty=format:%H${SEP}%s${SEP}%an${SEP}%at${REC}`, '-n', '40'],
|
||
{ cwd }
|
||
)
|
||
|
||
return stdout
|
||
.split(REC)
|
||
.map(line => line.trim())
|
||
.filter(Boolean)
|
||
.map(line => {
|
||
const [sha, summary, author, at] = line.split(SEP)
|
||
return { sha, summary, author, at: Number.parseInt(at, 10) * 1000 }
|
||
})
|
||
}
|
||
|
||
let updateInFlight = false
|
||
|
||
async function applyUpdates(opts = {}) {
|
||
if (updateInFlight) {
|
||
throw new Error('An update is already in progress.')
|
||
}
|
||
updateInFlight = true
|
||
|
||
const dirtyStrategy = opts.dirtyStrategy === 'force' || opts.dirtyStrategy === 'stash' ? opts.dirtyStrategy : 'abort'
|
||
|
||
try {
|
||
const updateRoot = resolveUpdateRoot()
|
||
const gitDir = path.join(updateRoot, '.git')
|
||
if (!directoryExists(gitDir)) {
|
||
const message = `${updateRoot} isn't a git checkout — cannot self-update.`
|
||
emitUpdateProgress({ stage: 'error', error: 'not-a-git-checkout', message })
|
||
throw new Error(message)
|
||
}
|
||
|
||
const { branch } = readDesktopUpdateConfig()
|
||
|
||
emitUpdateProgress({ stage: 'prepare', message: 'Checking working tree…', percent: 5 })
|
||
const dirtyResult = await runGit(['status', '--porcelain'], { cwd: updateRoot })
|
||
const isDirty = dirtyResult.stdout.trim().length > 0
|
||
|
||
let stashRef = null
|
||
if (isDirty) {
|
||
if (dirtyStrategy === 'abort') {
|
||
const message = 'Uncommitted changes detected. Choose how to handle them and try again.'
|
||
emitUpdateProgress({ stage: 'error', error: 'dirty-tree', message })
|
||
throw new Error(message)
|
||
}
|
||
if (dirtyStrategy === 'stash') {
|
||
emitUpdateProgress({ stage: 'prepare', message: 'Stashing local changes…', percent: 10 })
|
||
const stashed = await runGit(['stash', 'push', '-u', '-m', `hermes-desktop-auto-${Date.now()}`], {
|
||
cwd: updateRoot
|
||
})
|
||
if (stashed.code !== 0) {
|
||
const message = firstLine(stashed.stderr) || 'git stash failed.'
|
||
emitUpdateProgress({ stage: 'error', error: 'stash-failed', message })
|
||
throw new Error(message)
|
||
}
|
||
stashRef = 'stash@{0}'
|
||
}
|
||
// dirtyStrategy === 'force' → pull --ff-only will refuse if anything
|
||
// conflicts, surfacing a clean error rather than us guessing.
|
||
}
|
||
|
||
const pyprojectBefore = sha256OfFile(path.join(updateRoot, 'pyproject.toml'))
|
||
|
||
emitUpdateProgress({ stage: 'fetch', message: `Fetching origin/${branch}…`, percent: 20 })
|
||
const fetched = await runGit(['fetch', 'origin', branch], { cwd: updateRoot })
|
||
if (fetched.code !== 0) {
|
||
const message = firstLine(fetched.stderr) || 'git fetch failed.'
|
||
emitUpdateProgress({ stage: 'error', error: 'fetch-failed', message })
|
||
throw new Error(message)
|
||
}
|
||
|
||
emitUpdateProgress({ stage: 'pull', message: `Fast-forward merging origin/${branch}…`, percent: 45 })
|
||
const pulled = await runGit(['pull', '--ff-only', 'origin', branch], {
|
||
cwd: updateRoot,
|
||
onLine: (_stream, text) => {
|
||
const line = firstLine(text)
|
||
if (line) emitUpdateProgress({ stage: 'pull', message: line.slice(0, 200), percent: 50 })
|
||
}
|
||
})
|
||
if (pulled.code !== 0) {
|
||
const message = firstLine(pulled.stderr || pulled.stdout) || 'git pull failed.'
|
||
if (stashRef) {
|
||
await runGit(['stash', 'pop'], { cwd: updateRoot }).catch(() => {})
|
||
}
|
||
emitUpdateProgress({ stage: 'error', error: 'pull-failed', message })
|
||
throw new Error(message)
|
||
}
|
||
|
||
if (stashRef) {
|
||
emitUpdateProgress({ stage: 'pull', message: 'Restoring stashed changes…', percent: 60 })
|
||
const popped = await runGit(['stash', 'pop'], { cwd: updateRoot })
|
||
if (popped.code !== 0) {
|
||
emitUpdateProgress({
|
||
stage: 'pull',
|
||
message: 'Stash pop had conflicts — your changes are preserved in `git stash list`.',
|
||
percent: 60
|
||
})
|
||
}
|
||
}
|
||
|
||
// findPythonForRoot picks the venv beside the resolved checkout (.venv or
|
||
// venv), matching how the backend discovers its Python.
|
||
const pyprojectAfter = sha256OfFile(path.join(updateRoot, 'pyproject.toml'))
|
||
const pyprojectChanged = pyprojectBefore && pyprojectAfter && pyprojectBefore !== pyprojectAfter
|
||
const venvPython = pyprojectChanged ? findPythonForRoot(updateRoot) : null
|
||
if (venvPython && fileExists(venvPython)) {
|
||
emitUpdateProgress({ stage: 'pydeps', message: 'Updating Python dependencies…', percent: 75 })
|
||
await runProcess(venvPython, ['-m', 'pip', 'install', '-e', updateRoot, '--disable-pip-version-check'])
|
||
}
|
||
|
||
emitUpdateProgress({ stage: 'restart', message: 'Update complete. Restarting…', percent: 100 })
|
||
setTimeout(() => {
|
||
app.relaunch()
|
||
app.quit()
|
||
}, 1500)
|
||
|
||
return { ok: true, branch }
|
||
} finally {
|
||
updateInFlight = false
|
||
}
|
||
}
|
||
|
||
function readJson(filePath) {
|
||
try {
|
||
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
// Used by applyUpdates() to detect pyproject.toml drift after `git pull` so
|
||
// we know whether to re-run `pip install -e .` against the venv. Returns
|
||
// null on read failure.
|
||
function sha256OfFile(filePath) {
|
||
try {
|
||
const buf = fs.readFileSync(filePath)
|
||
return crypto.createHash('sha256').update(buf).digest('hex')
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
// Bootstrap-complete marker helpers. The marker is written ONCE by the
|
||
// first-launch bootstrap runner (Phase 1D) after install.ps1 stages succeed
|
||
// AND the user has finished initial configuration. On every subsequent boot
|
||
// we check `isBootstrapComplete()` and skip the bootstrap flow entirely if
|
||
// the marker is present and current-schema.
|
||
//
|
||
// Marker schema (version 1):
|
||
// {
|
||
// schemaVersion: 1,
|
||
// pinnedCommit: "<40-char SHA>", // what install.ps1 was driven against
|
||
// pinnedBranch: "<branch name>" | null,
|
||
// completedAt: "<ISO 8601>",
|
||
// desktopVersion: "<app.getVersion()>" // for forensics
|
||
// }
|
||
function readBootstrapMarker() {
|
||
return readJson(BOOTSTRAP_COMPLETE_MARKER)
|
||
}
|
||
|
||
function isBootstrapComplete() {
|
||
const marker = readBootstrapMarker()
|
||
if (!marker || typeof marker !== 'object') return false
|
||
if (marker.schemaVersion !== BOOTSTRAP_MARKER_SCHEMA_VERSION) return false
|
||
if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) return false
|
||
// We DELIBERATELY do NOT verify that the checkout is currently at the
|
||
// pinned commit -- users update via the in-app update path or `hermes
|
||
// update`, which moves HEAD legitimately. The marker just attests "we
|
||
// ran the bootstrap successfully at least once."
|
||
return isHermesSourceRoot(ACTIVE_HERMES_ROOT)
|
||
}
|
||
|
||
function writeBootstrapMarker(payload) {
|
||
fs.mkdirSync(path.dirname(BOOTSTRAP_COMPLETE_MARKER), { recursive: true })
|
||
const merged = {
|
||
schemaVersion: BOOTSTRAP_MARKER_SCHEMA_VERSION,
|
||
pinnedCommit: payload.pinnedCommit || null,
|
||
pinnedBranch: payload.pinnedBranch || null,
|
||
completedAt: new Date().toISOString(),
|
||
desktopVersion: app.getVersion()
|
||
}
|
||
fs.writeFileSync(BOOTSTRAP_COMPLETE_MARKER, JSON.stringify(merged, null, 2) + '\n', 'utf8')
|
||
return merged
|
||
}
|
||
|
||
function resolveWebDist() {
|
||
const override = process.env.HERMES_DESKTOP_WEB_DIST
|
||
if (override && directoryExists(path.resolve(override))) return path.resolve(override)
|
||
|
||
const unpackedDist = path.join(unpackedPathFor(APP_ROOT), 'dist')
|
||
if (directoryExists(unpackedDist)) return unpackedDist
|
||
|
||
return path.join(APP_ROOT, 'dist')
|
||
}
|
||
|
||
function resolveRendererIndex() {
|
||
const candidates = [path.join(APP_ROOT, 'dist', 'index.html'), path.join(resolveWebDist(), 'index.html')]
|
||
return candidates.find(fileExists) || candidates[0]
|
||
}
|
||
|
||
function resolveHermesCwd() {
|
||
const candidates = [
|
||
process.env.HERMES_DESKTOP_CWD,
|
||
process.env.INIT_CWD,
|
||
process.cwd(),
|
||
!IS_PACKAGED ? SOURCE_REPO_ROOT : null,
|
||
app.getPath('home')
|
||
]
|
||
|
||
for (const candidate of candidates) {
|
||
if (!candidate) continue
|
||
const resolved = path.resolve(String(candidate))
|
||
if (directoryExists(resolved)) return resolved
|
||
}
|
||
|
||
return app.getPath('home')
|
||
}
|
||
|
||
function createPythonBackend(root, label, dashboardArgs, options = {}) {
|
||
const python = findPythonForRoot(root)
|
||
if (!python) return null
|
||
|
||
return {
|
||
kind: 'python',
|
||
label,
|
||
command: python,
|
||
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
||
env: {
|
||
PYTHONPATH: [root, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
|
||
},
|
||
root,
|
||
bootstrap: Boolean(options.bootstrap),
|
||
shell: false
|
||
}
|
||
}
|
||
|
||
// createActiveBackend — build a backend pointing at ACTIVE_HERMES_ROOT, the
|
||
// canonical install location shared with the CLI installer. The venv at
|
||
// VENV_ROOT may not exist yet on first run; bootstrap=true tells
|
||
// ensureRuntime() to create / refresh it before launch.
|
||
function createActiveBackend(dashboardArgs) {
|
||
const venvPython = getVenvPython(VENV_ROOT)
|
||
|
||
return {
|
||
kind: 'python',
|
||
label: `Hermes at ${ACTIVE_HERMES_ROOT}`,
|
||
command: fileExists(venvPython) ? venvPython : findSystemPython(),
|
||
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
||
env: {
|
||
PYTHONPATH: [ACTIVE_HERMES_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
|
||
},
|
||
root: ACTIVE_HERMES_ROOT,
|
||
bootstrap: true,
|
||
shell: false
|
||
}
|
||
}
|
||
|
||
function resolveHermesBackend(dashboardArgs) {
|
||
// 1. Explicit override -- HERMES_DESKTOP_HERMES_ROOT points at a developer
|
||
// checkout. Honour it as-is (no bootstrap; the user is driving).
|
||
const overrideRoot = process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT)
|
||
if (overrideRoot && isHermesSourceRoot(overrideRoot)) {
|
||
const backend = createPythonBackend(overrideRoot, `Hermes source at ${overrideRoot}`, dashboardArgs)
|
||
if (backend) return backend
|
||
}
|
||
|
||
// 2. Development source -- when running `npm run dev` from a checkout, the
|
||
// cloned repo at SOURCE_REPO_ROOT takes precedence over ACTIVE and any
|
||
// installed `hermes` on PATH so local Python edits are actually exercised.
|
||
// (In dev with no checkout, SOURCE_REPO_ROOT won't pass isHermesSourceRoot.)
|
||
if (!IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT)) {
|
||
const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, dashboardArgs)
|
||
if (backend) return backend
|
||
}
|
||
|
||
// 3. Bootstrap-complete ACTIVE_HERMES_ROOT -- the canonical install at
|
||
// %LOCALAPPDATA%\hermes\hermes-agent (Windows) or ~/.hermes/hermes-agent.
|
||
// The bootstrap marker means install.ps1 stages finished and the user
|
||
// completed initial configuration; we trust the install and go straight
|
||
// to spawning hermes. Updates flow through the in-app update path
|
||
// (applyUpdates -> git pull) or `hermes update` from the CLI.
|
||
if (isBootstrapComplete()) {
|
||
return createActiveBackend(dashboardArgs)
|
||
}
|
||
|
||
// 4. Existing `hermes` on PATH -- installed via install.ps1 / install.sh from
|
||
// a previous tool-only setup, or pip-installed system-wide. Use it but
|
||
// do NOT write a bootstrap marker; the user did this themselves and we
|
||
// don't want to take ownership of an install we didn't perform.
|
||
// HERMES_DESKTOP_IGNORE_EXISTING=1 forces the bootstrap path for testing.
|
||
if (process.env.HERMES_DESKTOP_IGNORE_EXISTING !== '1') {
|
||
let hermesCommand = null
|
||
const hermesOverride = process.env.HERMES_DESKTOP_HERMES
|
||
|
||
if (hermesOverride) {
|
||
const resolvedOverride = findOnPath(hermesOverride)
|
||
if (resolvedOverride) {
|
||
hermesCommand = resolvedOverride
|
||
} else if (!isWindowsBinaryPathInWsl(hermesOverride, { isWsl: IS_WSL })) {
|
||
hermesCommand = hermesOverride
|
||
} else {
|
||
rememberLog(`Ignoring Windows Hermes override under WSL: ${hermesOverride}`)
|
||
}
|
||
} else {
|
||
hermesCommand = findOnPath('hermes')
|
||
}
|
||
|
||
if (hermesCommand) {
|
||
return {
|
||
label: `existing Hermes CLI at ${hermesCommand}`,
|
||
command: hermesCommand,
|
||
args: dashboardArgs,
|
||
bootstrap: false,
|
||
env: {},
|
||
kind: 'command',
|
||
shell: isCommandScript(hermesCommand)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 5. Last-ditch: pip-installed hermes_cli module via system Python.
|
||
// Same rationale as #4 -- the user installed this; we use it but don't
|
||
// take ownership.
|
||
const python = findSystemPython()
|
||
if (python) {
|
||
return {
|
||
kind: 'python',
|
||
label: `installed hermes_cli module via ${python}`,
|
||
command: python,
|
||
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
||
bootstrap: false,
|
||
env: {},
|
||
shell: false
|
||
}
|
||
}
|
||
|
||
// 6. Nothing usable yet -- signal the bootstrap runner that we need to
|
||
// clone+install. Phase 1D's bootstrap-runner consumes this sentinel
|
||
// and drives install.ps1 stages with a progress UI. Until 1D lands,
|
||
// callers see the sentinel and surface it as a user-facing error
|
||
// explaining what's missing.
|
||
//
|
||
// We deliberately do NOT throw here -- throwing inside
|
||
// resolveHermesBackend was the old "no payload" path and forced the
|
||
// user into a dead end. With the bootstrap protocol, "no install yet"
|
||
// is a recoverable state the GUI can drive through.
|
||
return {
|
||
kind: 'bootstrap-needed',
|
||
label: 'Hermes Agent not installed yet; bootstrap required',
|
||
command: null,
|
||
args: dashboardArgs,
|
||
bootstrap: true,
|
||
env: {},
|
||
shell: false,
|
||
// Hints for the bootstrap runner / UI layer:
|
||
activeRoot: ACTIVE_HERMES_ROOT,
|
||
installStamp: INSTALL_STAMP, // may be null in dev
|
||
isPackaged: IS_PACKAGED,
|
||
platform: process.platform
|
||
}
|
||
}
|
||
|
||
async function ensureRuntime(backend) {
|
||
if (!backend.bootstrap) {
|
||
await advanceBootProgress('runtime.external', `Using ${backend.label}`, 32)
|
||
return backend
|
||
}
|
||
|
||
// backend.kind === 'bootstrap-needed' means resolveHermesBackend couldn't
|
||
// find anything to spawn. Hand off to the bootstrap runner which drives
|
||
// install.ps1's stage protocol, writes the bootstrap-complete marker on
|
||
// success, then we re-resolve to get the now-installed backend.
|
||
//
|
||
// Phase 1D status: bootstrap runs but events go to desktop.log only
|
||
// (renderer window isn't created until later in startBackend). Phase 1E
|
||
// will rewire startup to spawn the window first and route bootstrap events
|
||
// to a renderer-side install overlay.
|
||
if (backend.kind === 'bootstrap-needed') {
|
||
if (process.platform !== 'win32') {
|
||
// macOS/Linux: install.sh doesn't yet support the stage protocol that
|
||
// install.ps1 does, so we can't drive a first-launch bootstrap. Emit
|
||
// a platform-unsupported event so the renderer's install overlay can
|
||
// render a 'run install.sh manually' guide instead of a generic
|
||
// 'desktop boot failed' toast. Mark the bootstrap state as inactive
|
||
// with an explanatory error so the overlay's failure branch picks
|
||
// it up immediately. THEN throw -- so the existing 'desktop boot
|
||
// failed' path still trips and prevents the rest of startHermes
|
||
// from running against a missing install.
|
||
const guidanceUrl = 'https://github.com/NousResearch/hermes-agent#install'
|
||
const installShUrl = 'https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh'
|
||
try {
|
||
broadcastBootstrapEvent({
|
||
type: 'unsupported-platform',
|
||
platform: process.platform,
|
||
activeRoot: backend.activeRoot,
|
||
installCommand: `bash <(curl -fsSL ${installShUrl})`,
|
||
docsUrl: guidanceUrl
|
||
})
|
||
} catch {}
|
||
throw new Error(
|
||
`Hermes Agent is not installed at ${backend.activeRoot}. On macOS/Linux ` +
|
||
'first-launch install is not yet automated -- run scripts/install.sh ' +
|
||
'from the Hermes repo manually, then relaunch this app.'
|
||
)
|
||
}
|
||
|
||
rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap')
|
||
|
||
// Eagerly flip the bootstrap UI state to 'active' so the renderer
|
||
// shows the install overlay BEFORE the runner finishes fetching the
|
||
// manifest (which on slow networks can take tens of seconds and would
|
||
// otherwise leave the user staring at the generic 'Preparing' splash).
|
||
// We emit a synthetic manifest with an empty stages list -- the real
|
||
// manifest event will overwrite it once install.ps1 -Manifest returns.
|
||
try {
|
||
broadcastBootstrapEvent({
|
||
type: 'manifest',
|
||
stages: [],
|
||
protocolVersion: null
|
||
})
|
||
} catch {}
|
||
|
||
const bootstrapResult = await runBootstrap({
|
||
installStamp: backend.installStamp,
|
||
activeRoot: backend.activeRoot,
|
||
sourceRepoRoot: SOURCE_REPO_ROOT,
|
||
hermesHome: HERMES_HOME,
|
||
logRoot: path.join(HERMES_HOME, 'logs'),
|
||
onEvent: ev => {
|
||
// Tee every bootstrap event to (a) the desktop log for forensics
|
||
// and (b) the renderer for live progress UI. Either may be absent;
|
||
// tolerate both gracefully so a renderer crash doesn't stall the
|
||
// bootstrap and a log-write failure doesn't suppress the UI signal.
|
||
try {
|
||
rememberLog(`[bootstrap] ${JSON.stringify(ev)}`)
|
||
} catch {}
|
||
try {
|
||
broadcastBootstrapEvent(ev)
|
||
} catch {}
|
||
},
|
||
writeMarker: writeBootstrapMarker
|
||
})
|
||
|
||
if (!bootstrapResult.ok) {
|
||
const bootstrapError = new Error(
|
||
`Hermes bootstrap failed${bootstrapResult.failedStage ? ` at stage '${bootstrapResult.failedStage}'` : ''}: ` +
|
||
`${bootstrapResult.error || 'unknown error'}. ` +
|
||
`Check ${path.join(HERMES_HOME, 'logs', 'desktop.log')} for the full transcript.`
|
||
)
|
||
bootstrapError.isBootstrapFailure = true
|
||
bootstrapError.failedStage = bootstrapResult.failedStage || null
|
||
// Latch the failure so subsequent startHermes() calls return this
|
||
// same error without re-running install.ps1. Cleared by the
|
||
// hermes:bootstrap:reset IPC (renderer's "Reload and retry").
|
||
bootstrapFailure = bootstrapError
|
||
throw bootstrapError
|
||
}
|
||
|
||
rememberLog('[bootstrap] bootstrap complete; marker written. Re-resolving backend.')
|
||
// Re-resolve now that the install exists. The new resolution lands in
|
||
// step 3 (bootstrap-complete marker) and we recurse to wire venvPython.
|
||
return ensureRuntime(resolveHermesBackend(backend.args))
|
||
}
|
||
|
||
// bootstrap=true with a real backend (createActiveBackend path) means we
|
||
// have a checkout and need to ensure the venv-derived Python command is
|
||
// wired into the backend before launch. Same code path the old factory
|
||
// sync flow exited through, minus all the factory/pip/marker machinery
|
||
// (install.ps1 owns those concerns now and the bootstrap-complete marker
|
||
// attests they ran successfully).
|
||
if (!isHermesSourceRoot(ACTIVE_HERMES_ROOT)) {
|
||
throw new Error(
|
||
`Hermes install at ${ACTIVE_HERMES_ROOT} is missing or incomplete. ` +
|
||
'Reinstall via the desktop installer or scripts/install.ps1.'
|
||
)
|
||
}
|
||
|
||
// On Windows, preflight Git Bash. Hermes' terminal tool calls bash.exe
|
||
// directly (tools/environments/local.py); without it the agent can't run
|
||
// terminal commands. install.ps1's Stage-Git puts PortableGit at
|
||
// %LOCALAPPDATA%\hermes\git\, which findGitBash() picks up, so for any
|
||
// user who completed the bootstrap this is a no-op. For users who got
|
||
// here via an external `hermes` on PATH, this check still helps.
|
||
if (IS_WINDOWS && !findGitBash()) {
|
||
throw new Error(
|
||
'Git for Windows is required for Hermes on Windows (provides Git Bash, ' +
|
||
"which the agent's terminal tool uses). Install it from " +
|
||
'https://git-scm.com/download/win or run `winget install -e --id Git.Git`, ' +
|
||
'then relaunch Hermes.'
|
||
)
|
||
}
|
||
|
||
const venvPython = getVenvPython(VENV_ROOT)
|
||
if (!fileExists(venvPython)) {
|
||
// No venv at the expected location AND no bootstrap-needed sentinel
|
||
// means we have a half-installed checkout: .git exists, source files
|
||
// exist, but venv is missing or broken. This shouldn't happen in
|
||
// normal flow because isBootstrapComplete() requires
|
||
// isHermesSourceRoot() and the bootstrap writes the marker only after
|
||
// install.ps1 succeeds. If we hit this, the user (or a deleted venv)
|
||
// broke the invariant; tell them to re-run the install.
|
||
throw new Error(
|
||
`Hermes venv missing at ${VENV_ROOT}. Re-run the desktop installer or ` +
|
||
'`scripts/install.ps1` to rebuild it.'
|
||
)
|
||
}
|
||
|
||
backend.command = venvPython
|
||
backend.label = `Hermes at ${ACTIVE_HERMES_ROOT} (venv: ${VENV_ROOT})`
|
||
updateBootProgress({
|
||
phase: 'runtime.ready',
|
||
message: 'Hermes runtime is ready',
|
||
progress: 82,
|
||
running: true,
|
||
error: null
|
||
})
|
||
return backend
|
||
}
|
||
|
||
function isPortAvailable(port) {
|
||
return new Promise(resolve => {
|
||
const server = net.createServer()
|
||
server.once('error', () => resolve(false))
|
||
server.once('listening', () => {
|
||
server.close(() => resolve(true))
|
||
})
|
||
server.listen(port, '127.0.0.1')
|
||
})
|
||
}
|
||
|
||
async function pickPort() {
|
||
for (let port = PORT_FLOOR; port <= PORT_CEILING; port += 1) {
|
||
if (await isPortAvailable(port)) return port
|
||
}
|
||
throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`)
|
||
}
|
||
|
||
function fetchJson(url, token, options = {}) {
|
||
return new Promise((resolve, reject) => {
|
||
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
|
||
const parsed = new URL(url)
|
||
const client = parsed.protocol === 'https:' ? https : http
|
||
const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
||
|
||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||
reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`))
|
||
return
|
||
}
|
||
|
||
const req = client.request(
|
||
parsed,
|
||
{
|
||
method: options.method || 'GET',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-Hermes-Session-Token': token,
|
||
...(body ? { 'Content-Length': String(body.length) } : {})
|
||
}
|
||
},
|
||
res => {
|
||
const chunks = []
|
||
res.on('data', chunk => chunks.push(chunk))
|
||
res.on('end', () => {
|
||
const text = Buffer.concat(chunks).toString('utf8')
|
||
if ((res.statusCode || 500) >= 400) {
|
||
reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`))
|
||
return
|
||
}
|
||
try {
|
||
resolve(text ? JSON.parse(text) : null)
|
||
} catch (error) {
|
||
reject(error)
|
||
}
|
||
})
|
||
}
|
||
)
|
||
|
||
req.on('error', reject)
|
||
req.setTimeout(timeoutMs, () => {
|
||
req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`))
|
||
})
|
||
if (body) req.write(body)
|
||
req.end()
|
||
})
|
||
}
|
||
|
||
function mimeTypeForPath(filePath) {
|
||
const ext = path.extname(filePath || '').toLowerCase()
|
||
|
||
return MEDIA_MIME_TYPES[ext] || 'application/octet-stream'
|
||
}
|
||
|
||
function extensionForMimeType(mimeType) {
|
||
const type = String(mimeType || '')
|
||
.split(';')[0]
|
||
.trim()
|
||
.toLowerCase()
|
||
if (type === 'image/png') return '.png'
|
||
if (type === 'image/jpeg') return '.jpg'
|
||
if (type === 'image/gif') return '.gif'
|
||
if (type === 'image/webp') return '.webp'
|
||
if (type === 'image/bmp') return '.bmp'
|
||
if (type === 'image/svg+xml') return '.svg'
|
||
return ''
|
||
}
|
||
|
||
function filenameFromUrl(rawUrl, fallback = 'image') {
|
||
try {
|
||
const parsed = new URL(rawUrl)
|
||
const base = path.basename(decodeURIComponent(parsed.pathname || ''))
|
||
return base && base.includes('.') ? base : fallback
|
||
} catch {
|
||
return fallback
|
||
}
|
||
}
|
||
|
||
// Link title resolution — curl (tier 1) → hidden BrowserWindow (tier 2).
|
||
const titleCache = new Map()
|
||
const titleInflight = new Map()
|
||
const TITLE_CACHE_LIMIT = 500
|
||
const TITLE_BYTE_BUDGET = 96 * 1024
|
||
const TITLE_TIMEOUT_MS = 5000
|
||
const TITLE_MAX_REDIRECTS = 3
|
||
// Browser-shaped UA — many bot-walled sites (GetYourGuide, Cloudflare-protected
|
||
// pages) refuse anything that doesn't look like a real Chrome.
|
||
const TITLE_USER_AGENT =
|
||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36'
|
||
const TITLE_ERROR_RE =
|
||
/\b(access denied|attention required|captcha|error|forbidden|just a moment|request blocked|too many requests)\b/i
|
||
const HTML_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', '#39': "'" }
|
||
|
||
// Tier-2 renderer fallback config. Only invoked when curl came back empty or
|
||
// matched TITLE_ERROR_RE — keeps cold/CDN-cached pages on the cheap path.
|
||
const RENDER_TITLE_MAX_CONCURRENT = 2
|
||
const RENDER_TITLE_TIMEOUT_MS = 8000
|
||
const RENDER_TITLE_GRACE_MS = 700
|
||
// Resource types we cancel before the network even fires — keeps the hidden
|
||
// renderer fast and cuts third-party tracking noise.
|
||
const RENDER_TITLE_BLOCKED_RESOURCES = new Set([
|
||
'cspReport',
|
||
'font',
|
||
'imageset',
|
||
'media',
|
||
'object',
|
||
'ping',
|
||
'stylesheet'
|
||
])
|
||
|
||
let linkTitleSession = null
|
||
let renderTitleInFlight = 0
|
||
const renderTitleQueue = []
|
||
|
||
function canonicalTitleCacheKey(rawUrl) {
|
||
const value = String(rawUrl || '').trim()
|
||
if (!value) return ''
|
||
|
||
try {
|
||
const url = new URL(value)
|
||
const host = url.hostname.replace(/^www\./i, '').toLowerCase()
|
||
const pathname = url.pathname === '/' ? '/' : url.pathname.replace(/\/+$/, '') || '/'
|
||
|
||
return `${host}${pathname}${url.search || ''}`
|
||
} catch {
|
||
return value
|
||
}
|
||
}
|
||
|
||
function cacheTitle(key, title) {
|
||
if (titleCache.size >= TITLE_CACHE_LIMIT) titleCache.delete(titleCache.keys().next().value)
|
||
titleCache.set(key, title)
|
||
}
|
||
|
||
function decodeHtmlEntities(value) {
|
||
return value
|
||
.replace(/&(amp|lt|gt|quot|apos|nbsp|#39);/gi, (_, k) => HTML_ENTITIES[k.toLowerCase()] ?? '')
|
||
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16) || 32))
|
||
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10) || 32))
|
||
}
|
||
|
||
function parseHtmlTitle(html) {
|
||
const raw = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]
|
||
return raw ? decodeHtmlEntities(raw).replace(/\s+/g, ' ').trim() : ''
|
||
}
|
||
|
||
function fetchHtmlTitleWithCurl(rawUrl) {
|
||
return new Promise(resolve => {
|
||
const url = String(rawUrl || '').trim()
|
||
if (!url) return resolve('')
|
||
|
||
const args = [
|
||
'--silent',
|
||
'--show-error',
|
||
'--location',
|
||
'--max-redirs',
|
||
String(TITLE_MAX_REDIRECTS),
|
||
'--max-time',
|
||
String(Math.max(2, Math.ceil(TITLE_TIMEOUT_MS / 1000))),
|
||
'--connect-timeout',
|
||
'4',
|
||
'--user-agent',
|
||
TITLE_USER_AGENT,
|
||
'--header',
|
||
'Accept: text/html,application/xhtml+xml;q=0.9,*/*;q=0.5',
|
||
'--header',
|
||
'Accept-Language: en-US,en;q=0.7',
|
||
'--header',
|
||
'Accept-Encoding: identity',
|
||
'--raw',
|
||
url
|
||
]
|
||
const child = spawn('curl', args, { stdio: ['ignore', 'pipe', 'ignore'] })
|
||
const chunks = []
|
||
let bytes = 0
|
||
|
||
child.stdout.on('data', chunk => {
|
||
if (bytes >= TITLE_BYTE_BUDGET) return
|
||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||
const remaining = TITLE_BYTE_BUDGET - bytes
|
||
const next = buffer.length > remaining ? buffer.subarray(0, remaining) : buffer
|
||
chunks.push(next)
|
||
bytes += next.length
|
||
})
|
||
|
||
child.on('error', () => resolve(''))
|
||
child.on('close', () => {
|
||
if (!chunks.length) return resolve('')
|
||
resolve(parseHtmlTitle(Buffer.concat(chunks).toString('utf8')))
|
||
})
|
||
})
|
||
}
|
||
|
||
function getLinkTitleSession() {
|
||
if (linkTitleSession || !app.isReady()) return linkTitleSession
|
||
linkTitleSession = session.fromPartition('hermes:link-titles', { cache: false })
|
||
linkTitleSession.webRequest.onBeforeRequest((details, callback) => {
|
||
callback({ cancel: RENDER_TITLE_BLOCKED_RESOURCES.has(details.resourceType) })
|
||
})
|
||
return linkTitleSession
|
||
}
|
||
|
||
function dequeueRenderTitle() {
|
||
while (renderTitleInFlight < RENDER_TITLE_MAX_CONCURRENT && renderTitleQueue.length) {
|
||
const item = renderTitleQueue.shift()
|
||
renderTitleInFlight += 1
|
||
runRenderTitleJob(item.url).then(title => {
|
||
renderTitleInFlight -= 1
|
||
item.resolve(title)
|
||
dequeueRenderTitle()
|
||
})
|
||
}
|
||
}
|
||
|
||
function runRenderTitleJob(rawUrl) {
|
||
return new Promise(resolve => {
|
||
if (!app.isReady()) return resolve('')
|
||
|
||
const partitionSession = getLinkTitleSession()
|
||
if (!partitionSession) return resolve('')
|
||
|
||
let settled = false
|
||
let window = null
|
||
let hardTimer = null
|
||
let graceTimer = null
|
||
|
||
const finish = title => {
|
||
if (settled) return
|
||
settled = true
|
||
if (hardTimer) clearTimeout(hardTimer)
|
||
if (graceTimer) clearTimeout(graceTimer)
|
||
const value = (title || '').replace(/\s+/g, ' ').trim()
|
||
try {
|
||
if (window && !window.isDestroyed()) window.destroy()
|
||
} catch {
|
||
// BrowserWindow may already be torn down; ignore.
|
||
}
|
||
resolve(value)
|
||
}
|
||
|
||
try {
|
||
window = new BrowserWindow({
|
||
show: false,
|
||
width: 1280,
|
||
height: 800,
|
||
webPreferences: {
|
||
backgroundThrottling: false,
|
||
contextIsolation: true,
|
||
javascript: true,
|
||
nodeIntegration: false,
|
||
sandbox: true,
|
||
session: partitionSession,
|
||
webSecurity: true
|
||
}
|
||
})
|
||
} catch {
|
||
return finish('')
|
||
}
|
||
|
||
const readTitle = () => window?.webContents?.getTitle?.() || ''
|
||
const scheduleGrace = () => {
|
||
if (graceTimer) clearTimeout(graceTimer)
|
||
graceTimer = setTimeout(() => finish(readTitle()), RENDER_TITLE_GRACE_MS)
|
||
}
|
||
|
||
hardTimer = setTimeout(() => finish(readTitle()), RENDER_TITLE_TIMEOUT_MS)
|
||
|
||
window.webContents.setUserAgent(TITLE_USER_AGENT)
|
||
window.webContents.on('page-title-updated', scheduleGrace)
|
||
window.webContents.on('did-finish-load', scheduleGrace)
|
||
window.webContents.on('did-fail-load', (_event, _code, _desc, _validatedURL, isMainFrame) => {
|
||
if (isMainFrame) finish('')
|
||
})
|
||
|
||
window
|
||
.loadURL(rawUrl, {
|
||
httpReferrer: 'https://www.google.com/',
|
||
userAgent: TITLE_USER_AGENT
|
||
})
|
||
.catch(() => finish(''))
|
||
})
|
||
}
|
||
|
||
function fetchHtmlTitleWithRenderer(rawUrl) {
|
||
return new Promise(resolve => {
|
||
renderTitleQueue.push({ resolve, url: rawUrl })
|
||
dequeueRenderTitle()
|
||
})
|
||
}
|
||
|
||
// Strips known error/captcha titles (e.g. "GetYourGuide – Error", "Just a
|
||
// moment...") so they don't get cached as the resolved title.
|
||
const usableTitle = value => (value && !TITLE_ERROR_RE.test(value) ? value : '')
|
||
|
||
function fetchLinkTitle(rawUrl) {
|
||
const url = String(rawUrl || '').trim()
|
||
const key = canonicalTitleCacheKey(url)
|
||
if (!key) return Promise.resolve('')
|
||
if (titleCache.has(key)) return Promise.resolve(titleCache.get(key))
|
||
if (titleInflight.has(key)) return titleInflight.get(key)
|
||
|
||
const pending = fetchHtmlTitleWithCurl(url)
|
||
.catch(() => '')
|
||
.then(value => usableTitle((value || '').slice(0, 240)))
|
||
.then(
|
||
async value => value || usableTitle(((await fetchHtmlTitleWithRenderer(url).catch(() => '')) || '').slice(0, 240))
|
||
)
|
||
.then(clean => {
|
||
cacheTitle(key, clean)
|
||
titleInflight.delete(key)
|
||
return clean
|
||
})
|
||
|
||
titleInflight.set(key, pending)
|
||
return pending
|
||
}
|
||
|
||
async function resourceBufferFromUrl(rawUrl) {
|
||
if (!rawUrl) throw new Error('Missing URL')
|
||
if (rawUrl.startsWith('data:')) {
|
||
const match = rawUrl.match(/^data:([^;,]+)?(;base64)?,(.*)$/s)
|
||
if (!match) throw new Error('Invalid data URL')
|
||
const mimeType = match[1] || 'application/octet-stream'
|
||
const encoded = match[3] || ''
|
||
const buffer = match[2] ? Buffer.from(encoded, 'base64') : Buffer.from(decodeURIComponent(encoded), 'utf8')
|
||
return { buffer, mimeType }
|
||
}
|
||
if (rawUrl.startsWith('file:')) {
|
||
const filePath = fileURLToPath(rawUrl)
|
||
const buffer = await fs.promises.readFile(filePath)
|
||
return { buffer, mimeType: mimeTypeForPath(filePath) }
|
||
}
|
||
|
||
const parsed = new URL(rawUrl)
|
||
const client = parsed.protocol === 'https:' ? https : http
|
||
return new Promise((resolve, reject) => {
|
||
const req = client.get(parsed, res => {
|
||
if ((res.statusCode || 500) >= 400) {
|
||
reject(new Error(`Failed to fetch ${rawUrl}: ${res.statusCode}`))
|
||
res.resume()
|
||
return
|
||
}
|
||
const chunks = []
|
||
res.on('data', chunk => chunks.push(chunk))
|
||
res.on('end', () => {
|
||
resolve({
|
||
buffer: Buffer.concat(chunks),
|
||
mimeType: res.headers['content-type'] || 'application/octet-stream'
|
||
})
|
||
})
|
||
})
|
||
req.on('error', reject)
|
||
})
|
||
}
|
||
|
||
async function copyImageFromUrl(rawUrl) {
|
||
const { buffer } = await resourceBufferFromUrl(rawUrl)
|
||
const image = nativeImage.createFromBuffer(buffer)
|
||
if (image.isEmpty()) throw new Error('Could not read image')
|
||
clipboard.writeImage(image)
|
||
}
|
||
|
||
async function saveImageFromUrl(rawUrl) {
|
||
const { buffer, mimeType } = await resourceBufferFromUrl(rawUrl)
|
||
const fallbackName = filenameFromUrl(rawUrl, `image${extensionForMimeType(mimeType) || '.png'}`)
|
||
const result = await dialog.showSaveDialog(mainWindow, {
|
||
title: 'Save Image',
|
||
defaultPath: fallbackName
|
||
})
|
||
if (result.canceled || !result.filePath) return false
|
||
await fs.promises.writeFile(result.filePath, buffer)
|
||
return true
|
||
}
|
||
|
||
async function writeComposerImage(buffer, ext = '.png') {
|
||
const rawExt = String(ext || '.png')
|
||
.trim()
|
||
.toLowerCase()
|
||
const normalizedExt = rawExt.startsWith('.') ? rawExt : `.${rawExt}`
|
||
const safeExt = /^\.[a-z0-9]{1,5}$/.test(normalizedExt) ? normalizedExt : '.png'
|
||
const dir = path.join(app.getPath('userData'), 'composer-images')
|
||
await fs.promises.mkdir(dir, { recursive: true })
|
||
const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').replace('Z', '')
|
||
const random = crypto.randomBytes(3).toString('hex')
|
||
const filePath = path.join(dir, `composer_${stamp}_${random}${safeExt}`)
|
||
await fs.promises.writeFile(filePath, buffer)
|
||
return filePath
|
||
}
|
||
|
||
function previewLabelForUrl(url) {
|
||
return `${url.host}${url.pathname === '/' ? '' : url.pathname}`
|
||
}
|
||
|
||
function expandUserPath(filePath) {
|
||
const value = String(filePath || '').trim()
|
||
|
||
if (value === '~') {
|
||
return app.getPath('home')
|
||
}
|
||
|
||
if (value.startsWith(`~${path.sep}`) || value.startsWith('~/')) {
|
||
return path.join(app.getPath('home'), value.slice(2))
|
||
}
|
||
|
||
return value
|
||
}
|
||
|
||
function previewFileTarget(rawTarget, baseDir) {
|
||
const raw = String(rawTarget || '').trim()
|
||
const base = baseDir ? path.resolve(expandUserPath(baseDir)) : resolveHermesCwd()
|
||
const filePath = raw.startsWith('file:') ? fileURLToPath(raw) : path.resolve(base, expandUserPath(raw))
|
||
let resolved = filePath
|
||
|
||
if (directoryExists(resolved)) {
|
||
resolved = path.join(resolved, 'index.html')
|
||
}
|
||
|
||
const ext = path.extname(resolved).toLowerCase()
|
||
if (!fileExists(resolved)) {
|
||
return null
|
||
}
|
||
|
||
const mimeType = mimeTypeForPath(resolved)
|
||
const metadata = previewFileMetadata(resolved, mimeType)
|
||
const isHtml = PREVIEW_HTML_EXTENSIONS.has(ext)
|
||
const isImage = mimeType.startsWith('image/')
|
||
const previewKind = isHtml ? 'html' : isImage ? 'image' : metadata.binary ? 'binary' : 'text'
|
||
|
||
return {
|
||
binary: metadata.binary,
|
||
byteSize: metadata.byteSize,
|
||
kind: 'file',
|
||
large: metadata.large,
|
||
label: path.basename(resolved),
|
||
language: PREVIEW_LANGUAGE_BY_EXT[ext] || 'text',
|
||
mimeType,
|
||
path: resolved,
|
||
previewKind,
|
||
source: raw,
|
||
url: pathToFileURL(resolved).toString()
|
||
}
|
||
}
|
||
|
||
function previewUrlTarget(rawTarget) {
|
||
const raw = String(rawTarget || '').trim()
|
||
const url = new URL(raw)
|
||
|
||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||
return null
|
||
}
|
||
|
||
if (!LOCAL_PREVIEW_HOSTS.has(url.hostname.toLowerCase())) {
|
||
return null
|
||
}
|
||
|
||
if (url.hostname === '0.0.0.0') {
|
||
url.hostname = '127.0.0.1'
|
||
}
|
||
|
||
return {
|
||
kind: 'url',
|
||
label: previewLabelForUrl(url),
|
||
source: raw,
|
||
url: url.toString()
|
||
}
|
||
}
|
||
|
||
function normalizePreviewTarget(rawTarget, baseDir) {
|
||
const raw = String(rawTarget || '').trim()
|
||
|
||
if (!raw) {
|
||
return null
|
||
}
|
||
|
||
try {
|
||
if (/^https?:\/\//i.test(raw)) {
|
||
return previewUrlTarget(raw)
|
||
}
|
||
|
||
return previewFileTarget(raw, baseDir)
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function filePathFromPreviewUrl(rawUrl) {
|
||
const filePath = fileURLToPath(String(rawUrl || ''))
|
||
|
||
if (!fileExists(filePath)) {
|
||
throw new Error('Preview file is not readable')
|
||
}
|
||
|
||
return filePath
|
||
}
|
||
|
||
function sendPreviewFileChanged(payload) {
|
||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||
const { webContents } = mainWindow
|
||
if (!webContents || webContents.isDestroyed()) return
|
||
webContents.send('hermes:preview-file-changed', payload)
|
||
}
|
||
|
||
function watchPreviewFile(rawUrl) {
|
||
const filePath = filePathFromPreviewUrl(rawUrl)
|
||
const watchDir = path.dirname(filePath)
|
||
const targetName = path.basename(filePath)
|
||
const id = crypto.randomBytes(12).toString('base64url')
|
||
let timer = null
|
||
const watcher = fs.watch(watchDir, (_eventType, filename) => {
|
||
const changedName = filename ? path.basename(String(filename)) : ''
|
||
|
||
if (changedName && changedName !== targetName) {
|
||
return
|
||
}
|
||
|
||
if (timer) clearTimeout(timer)
|
||
timer = setTimeout(() => {
|
||
timer = null
|
||
if (!fileExists(filePath)) return
|
||
sendPreviewFileChanged({ id, path: filePath, url: pathToFileURL(filePath).toString() })
|
||
}, PREVIEW_WATCH_DEBOUNCE_MS)
|
||
})
|
||
|
||
previewWatchers.set(id, {
|
||
close: () => {
|
||
if (timer) clearTimeout(timer)
|
||
watcher.close()
|
||
}
|
||
})
|
||
|
||
return { id, path: filePath }
|
||
}
|
||
|
||
function stopPreviewFileWatch(id) {
|
||
const watcher = previewWatchers.get(id)
|
||
|
||
if (!watcher) {
|
||
return false
|
||
}
|
||
|
||
watcher.close()
|
||
previewWatchers.delete(id)
|
||
|
||
return true
|
||
}
|
||
|
||
function closePreviewWatchers() {
|
||
for (const id of previewWatchers.keys()) {
|
||
stopPreviewFileWatch(id)
|
||
}
|
||
}
|
||
|
||
async function waitForHermes(baseUrl, token) {
|
||
const deadline = Date.now() + 45_000
|
||
let lastError = null
|
||
|
||
while (Date.now() < deadline) {
|
||
try {
|
||
await fetchJson(`${baseUrl}/api/status`, token)
|
||
return
|
||
} catch (error) {
|
||
lastError = error
|
||
await new Promise(resolve => setTimeout(resolve, 500))
|
||
}
|
||
}
|
||
|
||
throw new Error(`Hermes backend did not become ready: ${lastError?.message || 'timeout'}`)
|
||
}
|
||
|
||
function getWindowButtonPosition() {
|
||
if (!IS_MAC) return null
|
||
return mainWindow?.getWindowButtonPosition?.() || WINDOW_BUTTON_POSITION
|
||
}
|
||
|
||
function getNativeOverlayWidth() {
|
||
// macOS reports traffic-light coords via windowButtonPosition; the
|
||
// titlebarOverlay there doesn't reserve right-edge space. Windows/Linux
|
||
// render the native window-controls overlay on the right, so the renderer
|
||
// needs to inset its right cluster by this much to clear them.
|
||
return IS_MAC ? 0 : NATIVE_OVERLAY_BUTTON_WIDTH
|
||
}
|
||
|
||
function getWindowState() {
|
||
return {
|
||
isFullscreen: Boolean(mainWindow?.isFullScreen?.()),
|
||
nativeOverlayWidth: getNativeOverlayWidth(),
|
||
windowButtonPosition: getWindowButtonPosition()
|
||
}
|
||
}
|
||
|
||
function sendBackendExit(payload) {
|
||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||
const { webContents } = mainWindow
|
||
if (!webContents || webContents.isDestroyed()) return
|
||
webContents.send('hermes:backend-exit', payload)
|
||
}
|
||
|
||
function sendClosePreviewRequested() {
|
||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||
const { webContents } = mainWindow
|
||
if (!webContents || webContents.isDestroyed()) return
|
||
webContents.send('hermes:close-preview-requested')
|
||
}
|
||
|
||
function getAppIconPath() {
|
||
return APP_ICON_PATHS.find(fileExists)
|
||
}
|
||
|
||
function sendOpenUpdatesRequested() {
|
||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||
const { webContents } = mainWindow
|
||
if (!webContents || webContents.isDestroyed()) return
|
||
webContents.send('hermes:open-updates')
|
||
if (!mainWindow.isVisible()) mainWindow.show()
|
||
mainWindow.focus()
|
||
}
|
||
|
||
function sendWindowStateChanged(nextIsFullscreen) {
|
||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||
const { webContents } = mainWindow
|
||
if (!webContents || webContents.isDestroyed()) return
|
||
const state = getWindowState()
|
||
|
||
if (typeof nextIsFullscreen === 'boolean') {
|
||
state.isFullscreen = nextIsFullscreen
|
||
}
|
||
|
||
webContents.send('hermes:window-state-changed', state)
|
||
}
|
||
|
||
function buildApplicationMenu() {
|
||
const template = []
|
||
const checkForUpdatesItem = {
|
||
label: 'Check for Updates…',
|
||
click: () => sendOpenUpdatesRequested()
|
||
}
|
||
if (IS_MAC) {
|
||
template.push({
|
||
label: APP_NAME,
|
||
submenu: [
|
||
{ role: 'about', label: `About ${APP_NAME}` },
|
||
checkForUpdatesItem,
|
||
{ type: 'separator' },
|
||
{ role: 'services' },
|
||
{ type: 'separator' },
|
||
{ role: 'hide' },
|
||
{ role: 'hideOthers' },
|
||
{ role: 'unhide' },
|
||
{ type: 'separator' },
|
||
{ role: 'quit' }
|
||
]
|
||
})
|
||
}
|
||
|
||
template.push({
|
||
label: 'File',
|
||
submenu: [
|
||
IS_MAC
|
||
? {
|
||
accelerator: 'CommandOrControl+W',
|
||
click: () => {
|
||
if (previewShortcutActive) {
|
||
sendClosePreviewRequested()
|
||
} else {
|
||
mainWindow?.close()
|
||
}
|
||
},
|
||
label: 'Close'
|
||
}
|
||
: { role: 'quit' }
|
||
]
|
||
})
|
||
template.push({
|
||
label: 'Edit',
|
||
submenu: [
|
||
{ role: 'undo' },
|
||
{ role: 'redo' },
|
||
{ type: 'separator' },
|
||
{ role: 'cut' },
|
||
{ role: 'copy' },
|
||
{ role: 'paste' },
|
||
{ role: 'delete' },
|
||
{ role: 'selectAll' }
|
||
]
|
||
})
|
||
template.push({
|
||
label: 'View',
|
||
submenu: [
|
||
{ role: 'reload' },
|
||
{ role: 'forceReload' },
|
||
{ role: 'toggleDevTools' },
|
||
{ type: 'separator' },
|
||
{ role: 'resetZoom' },
|
||
{ role: 'zoomIn' },
|
||
{ role: 'zoomOut' },
|
||
{ type: 'separator' },
|
||
{ role: 'togglefullscreen' }
|
||
]
|
||
})
|
||
template.push({
|
||
label: 'Window',
|
||
submenu: IS_MAC
|
||
? [{ role: 'minimize' }, { role: 'zoom' }, { role: 'front' }]
|
||
: [{ role: 'minimize' }, { role: 'close' }]
|
||
})
|
||
template.push({
|
||
label: 'Help',
|
||
role: 'help',
|
||
submenu: [checkForUpdatesItem]
|
||
})
|
||
|
||
return Menu.buildFromTemplate(template)
|
||
}
|
||
|
||
function toggleDevTools(window) {
|
||
// DevTools is enabled in packaged builds so users can diagnose renderer
|
||
// issues without needing a dev build. Trade-off: tiny attack surface
|
||
// increase versus a much better support story when WS connection or
|
||
// CSP issues surface in the field.
|
||
const { webContents } = window
|
||
if (webContents.isDevToolsOpened()) {
|
||
webContents.closeDevTools()
|
||
} else {
|
||
webContents.openDevTools({ mode: 'detach' })
|
||
}
|
||
}
|
||
|
||
function installDevToolsShortcut(window) {
|
||
// F12 / Cmd+Opt+I works in both dev and packaged builds.
|
||
window.webContents.on('before-input-event', (event, input) => {
|
||
const key = input.key.toLowerCase()
|
||
const isInspectShortcut =
|
||
input.key === 'F12' ||
|
||
(IS_MAC && input.meta && input.alt && key === 'i') ||
|
||
(!IS_MAC && input.control && input.shift && key === 'i')
|
||
if (!isInspectShortcut) return
|
||
event.preventDefault()
|
||
toggleDevTools(window)
|
||
})
|
||
}
|
||
|
||
function installPreviewShortcut(window) {
|
||
window.webContents.on('before-input-event', (event, input) => {
|
||
const key = String(input.key || '').toLowerCase()
|
||
const isPreviewCloseShortcut = key === 'w' && (IS_MAC ? input.meta : input.control) && !input.alt && !input.shift
|
||
|
||
if (!isPreviewCloseShortcut || !previewShortcutActive) return
|
||
|
||
event.preventDefault()
|
||
sendClosePreviewRequested()
|
||
})
|
||
}
|
||
|
||
function installContextMenu(window) {
|
||
window.webContents.on('context-menu', (_event, params) => {
|
||
const template = []
|
||
const hasSelection = Boolean(params.selectionText?.trim())
|
||
const hasImage = params.mediaType === 'image' && Boolean(params.srcURL)
|
||
const hasLink = Boolean(params.linkURL)
|
||
const isEditable = Boolean(params.isEditable)
|
||
|
||
if (hasImage) {
|
||
template.push(
|
||
{
|
||
label: 'Open Image',
|
||
click: () => {
|
||
if (params.srcURL && !params.srcURL.startsWith('data:')) {
|
||
openExternalUrl(params.srcURL)
|
||
}
|
||
},
|
||
enabled: !params.srcURL.startsWith('data:')
|
||
},
|
||
{
|
||
label: 'Copy Image',
|
||
click: () => {
|
||
void copyImageFromUrl(params.srcURL).catch(error => rememberLog(`Copy image failed: ${error.message}`))
|
||
}
|
||
},
|
||
{
|
||
label: 'Copy Image Address',
|
||
click: () => clipboard.writeText(params.srcURL)
|
||
},
|
||
{
|
||
label: 'Save Image As...',
|
||
click: () => {
|
||
void saveImageFromUrl(params.srcURL).catch(error => rememberLog(`Save image failed: ${error.message}`))
|
||
}
|
||
}
|
||
)
|
||
}
|
||
|
||
if (hasLink) {
|
||
if (template.length) template.push({ type: 'separator' })
|
||
template.push(
|
||
{
|
||
label: 'Open Link',
|
||
click: () => openExternalUrl(params.linkURL)
|
||
},
|
||
{
|
||
label: 'Copy Link',
|
||
click: () => clipboard.writeText(params.linkURL)
|
||
}
|
||
)
|
||
}
|
||
|
||
if (hasSelection || isEditable) {
|
||
if (template.length) template.push({ type: 'separator' })
|
||
if (isEditable) {
|
||
template.push(
|
||
{ role: 'cut', enabled: params.editFlags.canCut },
|
||
{ role: 'copy', enabled: params.editFlags.canCopy },
|
||
{ role: 'paste', enabled: params.editFlags.canPaste },
|
||
{ type: 'separator' },
|
||
{ role: 'selectAll', enabled: params.editFlags.canSelectAll }
|
||
)
|
||
} else {
|
||
template.push({ role: 'copy', enabled: params.editFlags.canCopy })
|
||
}
|
||
}
|
||
|
||
if (!template.length) {
|
||
template.push({ role: 'selectAll' })
|
||
}
|
||
|
||
Menu.buildFromTemplate(template).popup({ window })
|
||
})
|
||
}
|
||
|
||
function installMediaPermissions() {
|
||
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback, details) => {
|
||
if (permission === 'media' && details?.mediaTypes?.includes('audio')) {
|
||
callback(true)
|
||
|
||
return
|
||
}
|
||
|
||
callback(false)
|
||
})
|
||
}
|
||
|
||
function normalizeRemoteBaseUrl(rawUrl) {
|
||
const value = String(rawUrl || '').trim()
|
||
|
||
if (!value) {
|
||
throw new Error('Remote gateway URL is required.')
|
||
}
|
||
|
||
let parsed
|
||
try {
|
||
parsed = new URL(value)
|
||
} catch (error) {
|
||
throw new Error(`Remote gateway URL is not valid: ${error.message}`)
|
||
}
|
||
|
||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||
throw new Error(`Remote gateway URL must be http:// or https://, got ${parsed.protocol}`)
|
||
}
|
||
|
||
parsed.hash = ''
|
||
parsed.search = ''
|
||
parsed.pathname = parsed.pathname.replace(/\/+$/, '')
|
||
|
||
return parsed.toString().replace(/\/+$/, '')
|
||
}
|
||
|
||
function buildGatewayWsUrl(baseUrl, token) {
|
||
const parsed = new URL(baseUrl)
|
||
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
|
||
const prefix = parsed.pathname.replace(/\/+$/, '')
|
||
|
||
return `${wsScheme}://${parsed.host}${prefix}/api/ws?token=${encodeURIComponent(token)}`
|
||
}
|
||
|
||
function tokenPreview(value) {
|
||
const raw = String(value || '')
|
||
|
||
if (!raw) {
|
||
return null
|
||
}
|
||
|
||
return raw.length <= 8 ? 'set' : `...${raw.slice(-6)}`
|
||
}
|
||
|
||
function encryptDesktopSecret(value) {
|
||
return encryptDesktopSecretStrict(value, safeStorage)
|
||
}
|
||
|
||
function decryptDesktopSecret(secret) {
|
||
if (!secret || typeof secret !== 'object') {
|
||
return ''
|
||
}
|
||
|
||
const value = String(secret.value || '')
|
||
|
||
if (!value) {
|
||
return ''
|
||
}
|
||
|
||
if (secret.encoding === 'safeStorage') {
|
||
try {
|
||
return safeStorage.decryptString(Buffer.from(value, 'base64'))
|
||
} catch {
|
||
return ''
|
||
}
|
||
}
|
||
|
||
return value
|
||
}
|
||
|
||
function readDesktopConnectionConfig() {
|
||
if (connectionConfigCache) {
|
||
return connectionConfigCache
|
||
}
|
||
|
||
let config = { mode: 'local', remote: {} }
|
||
|
||
try {
|
||
const raw = fs.readFileSync(DESKTOP_CONNECTION_CONFIG_PATH, 'utf8')
|
||
const parsed = JSON.parse(raw)
|
||
|
||
if (parsed && typeof parsed === 'object') {
|
||
config = {
|
||
mode: parsed.mode === 'remote' ? 'remote' : 'local',
|
||
remote: parsed.remote && typeof parsed.remote === 'object' ? parsed.remote : {}
|
||
}
|
||
}
|
||
} catch {
|
||
// Missing or malformed connection settings should fall back to local.
|
||
}
|
||
|
||
connectionConfigCache = config
|
||
|
||
return config
|
||
}
|
||
|
||
function writeDesktopConnectionConfig(config) {
|
||
fs.mkdirSync(path.dirname(DESKTOP_CONNECTION_CONFIG_PATH), { recursive: true })
|
||
fs.writeFileSync(DESKTOP_CONNECTION_CONFIG_PATH, JSON.stringify(config, null, 2))
|
||
connectionConfigCache = config
|
||
}
|
||
|
||
function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig()) {
|
||
const remoteToken = decryptDesktopSecret(config.remote?.token)
|
||
|
||
return {
|
||
mode: config.mode === 'remote' ? 'remote' : 'local',
|
||
remoteUrl: String(config.remote?.url || ''),
|
||
remoteTokenPreview: tokenPreview(remoteToken),
|
||
remoteTokenSet: Boolean(remoteToken),
|
||
envOverride: Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
|
||
}
|
||
}
|
||
|
||
function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) {
|
||
const persistToken = options.persistToken !== false
|
||
const mode = input.mode === 'remote' ? 'remote' : 'local'
|
||
const remoteUrl = String(input.remoteUrl ?? existing.remote?.url ?? '').trim()
|
||
const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : ''
|
||
const existingToken = existing.remote?.token
|
||
const nextRemote = {
|
||
url: remoteUrl,
|
||
token: incomingToken
|
||
? persistToken
|
||
? encryptDesktopSecret(incomingToken)
|
||
: { encoding: 'plain', value: incomingToken }
|
||
: existingToken
|
||
}
|
||
|
||
if (mode === 'remote') {
|
||
nextRemote.url = normalizeRemoteBaseUrl(remoteUrl)
|
||
|
||
if (!decryptDesktopSecret(nextRemote.token)) {
|
||
throw new Error('Remote gateway session token is required.')
|
||
}
|
||
} else if (remoteUrl) {
|
||
nextRemote.url = normalizeRemoteBaseUrl(remoteUrl)
|
||
}
|
||
|
||
return { mode, remote: nextRemote }
|
||
}
|
||
|
||
function resolveRemoteBackend() {
|
||
const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL
|
||
const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN
|
||
|
||
if (rawEnvUrl) {
|
||
if (!rawEnvToken) {
|
||
throw new Error(
|
||
'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' +
|
||
'Both must be provided to connect to a remote Hermes backend.'
|
||
)
|
||
}
|
||
|
||
const baseUrl = normalizeRemoteBaseUrl(rawEnvUrl)
|
||
|
||
return {
|
||
baseUrl,
|
||
mode: 'remote',
|
||
source: 'env',
|
||
token: rawEnvToken,
|
||
wsUrl: buildGatewayWsUrl(baseUrl, rawEnvToken)
|
||
}
|
||
}
|
||
|
||
const config = readDesktopConnectionConfig()
|
||
|
||
if (config.mode !== 'remote') {
|
||
return null
|
||
}
|
||
|
||
const token = decryptDesktopSecret(config.remote?.token)
|
||
|
||
if (!token) {
|
||
throw new Error(
|
||
'Remote Hermes gateway is selected, but no session token is saved. ' +
|
||
'Open Settings → Gateway and save a token, or switch back to Local.'
|
||
)
|
||
}
|
||
|
||
const baseUrl = normalizeRemoteBaseUrl(config.remote?.url)
|
||
|
||
return {
|
||
baseUrl,
|
||
mode: 'remote',
|
||
source: 'settings',
|
||
token,
|
||
wsUrl: buildGatewayWsUrl(baseUrl, token)
|
||
}
|
||
}
|
||
|
||
async function testDesktopConnectionConfig(input = {}) {
|
||
const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false })
|
||
const remote =
|
||
config.mode === 'remote'
|
||
? {
|
||
baseUrl: normalizeRemoteBaseUrl(config.remote.url),
|
||
token: decryptDesktopSecret(config.remote.token)
|
||
}
|
||
: resolveRemoteBackend() || (await startHermes())
|
||
const status = await fetchJson(`${remote.baseUrl}/api/status`, remote.token, { timeoutMs: 8_000 })
|
||
|
||
return {
|
||
ok: true,
|
||
baseUrl: remote.baseUrl,
|
||
version: status?.version || null
|
||
}
|
||
}
|
||
|
||
function resetBootProgressForReconnect() {
|
||
updateBootProgress(
|
||
{
|
||
error: null,
|
||
message: 'Restarting desktop connection',
|
||
phase: 'backend.resolve',
|
||
progress: 4,
|
||
running: true
|
||
},
|
||
{ allowDecrease: true }
|
||
)
|
||
}
|
||
|
||
function resetHermesConnection() {
|
||
connectionPromise = null
|
||
|
||
if (hermesProcess && !hermesProcess.killed) {
|
||
hermesProcess.kill('SIGTERM')
|
||
}
|
||
|
||
hermesProcess = null
|
||
resetBootProgressForReconnect()
|
||
}
|
||
|
||
async function startHermes() {
|
||
// Latched-failure short-circuit: once bootstrap has failed in this
|
||
// process, every subsequent startHermes() call re-throws the same error
|
||
// without re-running install.ps1. This prevents the renderer's
|
||
// ensureGatewayOpen retries (and any other getConnection callers) from
|
||
// restarting a 5-10 minute install loop while the user is still reading
|
||
// the failure overlay.
|
||
if (bootstrapFailure) {
|
||
throw bootstrapFailure
|
||
}
|
||
if (connectionPromise) return connectionPromise
|
||
|
||
connectionPromise = (async () => {
|
||
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
|
||
const remote = resolveRemoteBackend()
|
||
if (remote) {
|
||
await advanceBootProgress('backend.remote', `Connecting to remote Hermes backend at ${remote.baseUrl}`, 24)
|
||
await waitForHermes(remote.baseUrl, remote.token)
|
||
updateBootProgress({
|
||
phase: 'backend.ready',
|
||
message: 'Remote Hermes backend is ready',
|
||
progress: 94,
|
||
running: true,
|
||
error: null
|
||
})
|
||
return {
|
||
baseUrl: remote.baseUrl,
|
||
mode: 'remote',
|
||
source: remote.source,
|
||
token: remote.token,
|
||
wsUrl: remote.wsUrl,
|
||
logs: hermesLog.slice(-80),
|
||
...getWindowState()
|
||
}
|
||
}
|
||
|
||
await advanceBootProgress('backend.port', 'Finding an open local port', 16)
|
||
const port = await pickPort()
|
||
const token = crypto.randomBytes(32).toString('base64url')
|
||
const dashboardArgs = ['dashboard', '--no-open', '--tui', '--host', '127.0.0.1', '--port', String(port)]
|
||
await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28)
|
||
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
|
||
const hermesCwd = resolveHermesCwd()
|
||
const webDist = resolveWebDist()
|
||
|
||
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
|
||
rememberLog(`Starting Hermes backend via ${backend.label}`)
|
||
|
||
hermesProcess = spawn(backend.command, backend.args, {
|
||
cwd: hermesCwd,
|
||
env: {
|
||
...process.env,
|
||
// Explicitly pin HERMES_HOME for the child so Python's get_hermes_home()
|
||
// resolves to the SAME location our resolveHermesHome() picked. Without
|
||
// this pin, Python falls back to ~/.hermes on every platform — fine on
|
||
// mac/linux (where our default matches), but on Windows our default is
|
||
// %LOCALAPPDATA%\hermes, which differs from C:\Users\<u>\.hermes.
|
||
// Mismatch would split config / sessions / .env / logs across two
|
||
// directories. install.ps1 sets HERMES_HOME via setx; the desktop
|
||
// can't reliably do that, so we set it inline for every spawn.
|
||
HERMES_HOME,
|
||
...backend.env,
|
||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||
HERMES_DASHBOARD_TUI: '1',
|
||
HERMES_WEB_DIST: webDist
|
||
},
|
||
shell: backend.shell,
|
||
stdio: ['ignore', 'pipe', 'pipe']
|
||
})
|
||
|
||
hermesProcess.stdout.on('data', rememberLog)
|
||
hermesProcess.stderr.on('data', rememberLog)
|
||
let backendReady = false
|
||
let rejectBackendStart = null
|
||
const backendStartFailed = new Promise((_resolve, reject) => {
|
||
rejectBackendStart = reject
|
||
})
|
||
hermesProcess.once('error', error => {
|
||
rememberLog(`Hermes backend failed to start: ${error.message}`)
|
||
updateBootProgress(
|
||
{
|
||
error: error.message,
|
||
message: `Hermes backend failed to start: ${error.message}`,
|
||
phase: 'backend.error',
|
||
running: false
|
||
},
|
||
{ allowDecrease: true }
|
||
)
|
||
hermesProcess = null
|
||
connectionPromise = null
|
||
sendBackendExit({ code: null, signal: null, error: error.message })
|
||
rejectBackendStart?.(error)
|
||
})
|
||
hermesProcess.once('exit', (code, signal) => {
|
||
rememberLog(`Hermes backend exited (${signal || code})`)
|
||
hermesProcess = null
|
||
connectionPromise = null
|
||
sendBackendExit({ code, signal })
|
||
if (!backendReady) {
|
||
const message = `Hermes backend exited before it became ready (${signal || code}).`
|
||
updateBootProgress(
|
||
{
|
||
error: message,
|
||
message,
|
||
phase: 'backend.error',
|
||
running: false
|
||
},
|
||
{ allowDecrease: true }
|
||
)
|
||
rejectBackendStart?.(
|
||
new Error(
|
||
`Hermes backend exited before it became ready (${signal || code}). Log: ${DESKTOP_LOG_PATH}\n${recentHermesLog()}`
|
||
)
|
||
)
|
||
}
|
||
})
|
||
|
||
const baseUrl = `http://127.0.0.1:${port}`
|
||
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
|
||
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
|
||
backendReady = true
|
||
updateBootProgress({
|
||
phase: 'backend.ready',
|
||
message: 'Hermes backend is ready. Finalizing desktop startup',
|
||
progress: 94,
|
||
running: true,
|
||
error: null
|
||
})
|
||
|
||
return {
|
||
baseUrl,
|
||
mode: 'local',
|
||
source: 'local',
|
||
token,
|
||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
|
||
logs: hermesLog.slice(-80),
|
||
...getWindowState()
|
||
}
|
||
})().catch(error => {
|
||
const message = error instanceof Error ? error.message : String(error)
|
||
updateBootProgress(
|
||
{
|
||
error: message,
|
||
message: `Desktop boot failed: ${message}`,
|
||
phase: 'backend.error',
|
||
running: false
|
||
},
|
||
{ allowDecrease: true }
|
||
)
|
||
connectionPromise = null
|
||
throw error
|
||
})
|
||
|
||
return connectionPromise
|
||
}
|
||
|
||
function createWindow() {
|
||
const icon = getAppIconPath()
|
||
mainWindow = new BrowserWindow({
|
||
width: 1220,
|
||
height: 800,
|
||
minWidth: 900,
|
||
minHeight: 620,
|
||
title: 'Hermes',
|
||
// Frameless title bar on every platform so the renderer can paint the
|
||
// "hide sidebar" button (and other left-side titlebar tools) flush with
|
||
// the top edge — matching the macOS layout where the traffic lights sit
|
||
// inside the same band. On Windows/Linux, titleBarOverlay tells Electron
|
||
// to paint native min/max/close in the top-right of the renderer; on
|
||
// macOS it just reserves a content inset alongside the traffic lights.
|
||
titleBarStyle: 'hidden',
|
||
titleBarOverlay: getTitleBarOverlayOptions(),
|
||
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
|
||
vibrancy: IS_MAC ? 'sidebar' : undefined,
|
||
icon,
|
||
backgroundColor: '#f7f7f7',
|
||
webPreferences: {
|
||
preload: path.join(__dirname, 'preload.cjs'),
|
||
contextIsolation: true,
|
||
webviewTag: true,
|
||
sandbox: true,
|
||
nodeIntegration: false,
|
||
devTools: true
|
||
}
|
||
})
|
||
|
||
if (IS_MAC) {
|
||
mainWindow.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
|
||
if (icon) {
|
||
app.dock?.setIcon(icon)
|
||
}
|
||
}
|
||
|
||
if (!IS_MAC) {
|
||
nativeTheme.on('updated', () => {
|
||
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
|
||
})
|
||
}
|
||
|
||
mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true))
|
||
mainWindow.on('enter-full-screen', () => sendWindowStateChanged(true))
|
||
mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
|
||
mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false))
|
||
|
||
installPreviewShortcut(mainWindow)
|
||
installDevToolsShortcut(mainWindow)
|
||
installContextMenu(mainWindow)
|
||
mainWindow.webContents.setWindowOpenHandler(details => {
|
||
openExternalUrl(details.url)
|
||
|
||
return { action: 'deny' }
|
||
})
|
||
mainWindow.webContents.on('will-navigate', (event, url) => {
|
||
if ((DEV_SERVER && url.startsWith(DEV_SERVER)) || (!DEV_SERVER && url.startsWith('file:'))) {
|
||
return
|
||
}
|
||
|
||
event.preventDefault()
|
||
openExternalUrl(url)
|
||
})
|
||
|
||
if (DEV_SERVER) {
|
||
mainWindow.loadURL(DEV_SERVER)
|
||
} else {
|
||
mainWindow.loadURL(pathToFileURL(resolveRendererIndex()).toString())
|
||
}
|
||
|
||
mainWindow.webContents.once('did-finish-load', () => {
|
||
broadcastBootProgress()
|
||
sendWindowStateChanged()
|
||
startHermes().catch(error => rememberLog(error.stack || error.message))
|
||
})
|
||
}
|
||
|
||
ipcMain.handle('hermes:connection', async () => startHermes())
|
||
ipcMain.handle('hermes:bootstrap:reset', async () => {
|
||
// Renderer's "Reload and retry" path. Clear the latched failure and
|
||
// reset connection state so the next startHermes() call restarts the
|
||
// full backend flow (including a fresh runBootstrap pass).
|
||
rememberLog('[bootstrap] reset requested by renderer; clearing latched failure')
|
||
bootstrapFailure = null
|
||
connectionPromise = null
|
||
bootstrapState = {
|
||
active: false,
|
||
manifest: null,
|
||
stages: {},
|
||
error: null,
|
||
log: [],
|
||
startedAt: null,
|
||
completedAt: null,
|
||
unsupportedPlatform: null
|
||
}
|
||
return { ok: true }
|
||
})
|
||
ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState)
|
||
ipcMain.handle('hermes:bootstrap:get', async () => getBootstrapState())
|
||
ipcMain.handle('hermes:connection-config:get', async () => sanitizeDesktopConnectionConfig())
|
||
ipcMain.handle('hermes:connection-config:test', async (_event, payload) => testDesktopConnectionConfig(payload))
|
||
ipcMain.handle('hermes:connection-config:save', async (_event, payload) => {
|
||
const config = coerceDesktopConnectionConfig(payload)
|
||
writeDesktopConnectionConfig(config)
|
||
|
||
return sanitizeDesktopConnectionConfig(config)
|
||
})
|
||
ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => {
|
||
const config = coerceDesktopConnectionConfig(payload)
|
||
writeDesktopConnectionConfig(config)
|
||
resetHermesConnection()
|
||
setTimeout(() => mainWindow?.reload(), 150)
|
||
|
||
return sanitizeDesktopConnectionConfig(config)
|
||
})
|
||
|
||
ipcMain.on('hermes:previewShortcutActive', (_event, active) => {
|
||
previewShortcutActive = Boolean(active)
|
||
})
|
||
|
||
ipcMain.handle('hermes:requestMicrophoneAccess', async () => {
|
||
if (!IS_MAC || typeof systemPreferences.askForMediaAccess !== 'function') {
|
||
return true
|
||
}
|
||
|
||
return systemPreferences.askForMediaAccess('microphone')
|
||
})
|
||
|
||
ipcMain.handle('hermes:api', async (_event, request) => {
|
||
const connection = await startHermes()
|
||
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
||
return fetchJson(`${connection.baseUrl}${request.path}`, connection.token, {
|
||
method: request?.method,
|
||
body: request?.body,
|
||
timeoutMs
|
||
})
|
||
})
|
||
|
||
ipcMain.handle('hermes:notify', (_event, payload) => {
|
||
if (!Notification.isSupported()) return false
|
||
new Notification({
|
||
title: payload?.title || 'Hermes',
|
||
body: payload?.body || '',
|
||
silent: Boolean(payload?.silent)
|
||
}).show()
|
||
return true
|
||
})
|
||
|
||
ipcMain.handle('hermes:readFileDataUrl', async (_event, filePath) => {
|
||
const { resolvedPath } = await resolveReadableFileForIpc(filePath, {
|
||
maxBytes: DATA_URL_READ_MAX_BYTES,
|
||
purpose: 'File preview'
|
||
})
|
||
const data = await fs.promises.readFile(resolvedPath)
|
||
return `data:${mimeTypeForPath(resolvedPath)};base64,${data.toString('base64')}`
|
||
})
|
||
|
||
ipcMain.handle('hermes:readFileText', async (_event, filePath) => {
|
||
const { resolvedPath, stat } = await resolveReadableFileForIpc(filePath, {
|
||
maxBytes: TEXT_PREVIEW_SOURCE_MAX_BYTES,
|
||
purpose: 'Text preview'
|
||
})
|
||
const ext = path.extname(resolvedPath).toLowerCase()
|
||
const handle = await fs.promises.open(resolvedPath, 'r')
|
||
const bytesToRead = Math.min(stat.size, TEXT_PREVIEW_MAX_BYTES)
|
||
|
||
try {
|
||
const buffer = Buffer.alloc(bytesToRead)
|
||
const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0)
|
||
|
||
return {
|
||
binary: looksBinary(buffer.subarray(0, Math.min(bytesRead, 4096))),
|
||
byteSize: stat.size,
|
||
language: PREVIEW_LANGUAGE_BY_EXT[ext] || 'text',
|
||
mimeType: mimeTypeForPath(resolvedPath),
|
||
path: resolvedPath,
|
||
text: buffer.subarray(0, bytesRead).toString('utf8'),
|
||
truncated: stat.size > TEXT_PREVIEW_MAX_BYTES
|
||
}
|
||
} finally {
|
||
await handle.close()
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('hermes:selectPaths', async (_event, options = {}) => {
|
||
const properties = ['openFile']
|
||
if (options?.directories) properties.push('openDirectory')
|
||
if (options?.multiple !== false) properties.push('multiSelections')
|
||
|
||
const result = await dialog.showOpenDialog(mainWindow, {
|
||
title: options?.title || 'Add context',
|
||
defaultPath: options?.defaultPath ? path.resolve(String(options.defaultPath)) : undefined,
|
||
properties,
|
||
filters: Array.isArray(options?.filters) ? options.filters : undefined
|
||
})
|
||
|
||
if (result.canceled) return []
|
||
return result.filePaths
|
||
})
|
||
|
||
ipcMain.handle('hermes:writeClipboard', (_event, text) => {
|
||
clipboard.writeText(String(text || ''))
|
||
return true
|
||
})
|
||
|
||
ipcMain.handle('hermes:saveImageFromUrl', (_event, url) => saveImageFromUrl(String(url || '')))
|
||
|
||
ipcMain.handle('hermes:saveImageBuffer', async (_event, payload) => {
|
||
const data = payload?.data
|
||
if (!data) throw new Error('saveImageBuffer: missing data')
|
||
|
||
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data)
|
||
return writeComposerImage(buffer, payload?.ext || '.png')
|
||
})
|
||
|
||
ipcMain.handle('hermes:saveClipboardImage', async () => {
|
||
const image = clipboard.readImage()
|
||
if (!image || image.isEmpty()) {
|
||
return ''
|
||
}
|
||
|
||
return writeComposerImage(image.toPNG(), '.png')
|
||
})
|
||
|
||
ipcMain.handle('hermes:normalizePreviewTarget', (_event, target, baseDir) =>
|
||
normalizePreviewTarget(String(target || ''), baseDir ? String(baseDir) : '')
|
||
)
|
||
|
||
ipcMain.handle('hermes:watchPreviewFile', (_event, url) => watchPreviewFile(String(url || '')))
|
||
|
||
ipcMain.handle('hermes:stopPreviewFileWatch', (_event, id) => stopPreviewFileWatch(String(id || '')))
|
||
|
||
ipcMain.on('hermes:titlebar-theme', (_event, payload) => {
|
||
if (!payload || !isHexColor(payload.background) || !isHexColor(payload.foreground)) {
|
||
return
|
||
}
|
||
|
||
rendererTitleBarTheme = {
|
||
background: payload.background,
|
||
foreground: payload.foreground
|
||
}
|
||
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
|
||
})
|
||
|
||
ipcMain.handle('hermes:openExternal', (_event, url) => {
|
||
if (!openExternalUrl(url)) {
|
||
throw new Error('Invalid external URL')
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('hermes:fetchLinkTitle', (_event, url) => fetchLinkTitle(url))
|
||
|
||
// Always-hidden noise (covers non-git projects too — gitignore would catch
|
||
// these anyway when present, but we want the same hygiene without one).
|
||
const FS_READDIR_HIDDEN = new Set([
|
||
'.git',
|
||
'.hg',
|
||
'.svn',
|
||
'.cache',
|
||
'.next',
|
||
'.turbo',
|
||
'.venv',
|
||
'__pycache__',
|
||
'build',
|
||
'dist',
|
||
'node_modules',
|
||
'target',
|
||
'venv'
|
||
])
|
||
|
||
function findGitRoot(start) {
|
||
let dir = start
|
||
|
||
for (let i = 0; i < 50; i += 1) {
|
||
try {
|
||
if (fs.existsSync(path.join(dir, '.git'))) {
|
||
return dir
|
||
}
|
||
} catch {
|
||
return null
|
||
}
|
||
|
||
const parent = path.dirname(dir)
|
||
|
||
if (parent === dir) {
|
||
return null
|
||
}
|
||
|
||
dir = parent
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
function terminalShellCommand() {
|
||
if (IS_WINDOWS) {
|
||
return { args: [], command: process.env.COMSPEC || 'cmd.exe' }
|
||
}
|
||
|
||
const configuredShell = process.env.SHELL || ''
|
||
const shellPath =
|
||
(path.isAbsolute(configuredShell) && fs.existsSync(configuredShell) && configuredShell) ||
|
||
['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => fs.existsSync(candidate)) ||
|
||
'/bin/sh'
|
||
const shellName = path.basename(shellPath)
|
||
const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i']
|
||
|
||
return { args: interactiveArgs, command: shellPath, name: shellName }
|
||
}
|
||
|
||
function safeTerminalCwd(cwd) {
|
||
const candidate = path.resolve(String(cwd || app.getPath('home')))
|
||
|
||
try {
|
||
const stat = fs.statSync(candidate)
|
||
|
||
return stat.isDirectory() ? candidate : path.dirname(candidate)
|
||
} catch {
|
||
return app.getPath('home')
|
||
}
|
||
}
|
||
|
||
function terminalShellEnv() {
|
||
const env = { ...process.env }
|
||
|
||
// Electron is commonly launched through `npm run dev`; do not leak npm's
|
||
// managed prefix into a user's interactive shell (nvm/proto warn loudly).
|
||
for (const key of Object.keys(env)) {
|
||
if (key === 'npm_config_prefix' || key.startsWith('npm_config_') || key.startsWith('npm_package_')) {
|
||
delete env[key]
|
||
}
|
||
}
|
||
|
||
// Strip color/theme-detection vars that ride along when Electron is launched
|
||
// from a non-tty agent shell (Cursor's runner sets NO_COLOR/FORCE_COLOR=0
|
||
// /TERM=dumb; some terminals set COLORFGBG which would flip Hermes' TUI into
|
||
// light-mode). Our PTY is a real xterm-compat terminal — force truecolor.
|
||
delete env.NO_COLOR
|
||
delete env.FORCE_COLOR
|
||
delete env.COLORFGBG
|
||
|
||
env.COLORTERM = 'truecolor'
|
||
env.LC_CTYPE = env.LC_CTYPE || 'UTF-8'
|
||
env.TERM = 'xterm-256color'
|
||
env.TERM_PROGRAM = 'Hermes'
|
||
env.TERM_PROGRAM_VERSION = app.getVersion()
|
||
|
||
return env
|
||
}
|
||
|
||
function terminalChannel(id, suffix) {
|
||
return `hermes:terminal:${id}:${suffix}`
|
||
}
|
||
|
||
function disposeTerminalSession(id) {
|
||
const sessionInfo = terminalSessions.get(id)
|
||
|
||
if (!sessionInfo) {
|
||
return false
|
||
}
|
||
|
||
terminalSessions.delete(id)
|
||
|
||
try {
|
||
sessionInfo.pty.kill()
|
||
} catch {
|
||
// Process may already be gone.
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => {
|
||
const resolved = path.resolve(String(dirPath || ''))
|
||
|
||
if (!resolved) {
|
||
return { entries: [], error: 'invalid-path' }
|
||
}
|
||
|
||
try {
|
||
const dirents = await fs.promises.readdir(resolved, { withFileTypes: true })
|
||
|
||
const entries = dirents
|
||
.filter(d => {
|
||
if (FS_READDIR_HIDDEN.has(d.name)) {
|
||
return false
|
||
}
|
||
|
||
return true
|
||
})
|
||
.map(d => ({ name: d.name, path: path.join(resolved, d.name), isDirectory: d.isDirectory() }))
|
||
.sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name))
|
||
|
||
return { entries }
|
||
} catch (error) {
|
||
return { entries: [], error: error?.code || 'read-error' }
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => {
|
||
const input = String(startPath || '')
|
||
const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input)
|
||
|
||
try {
|
||
const stat = await fs.promises.stat(resolved)
|
||
const start = stat.isDirectory() ? resolved : path.dirname(resolved)
|
||
|
||
return findGitRoot(start)
|
||
} catch {
|
||
return findGitRoot(resolved)
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
|
||
if (!nodePty) {
|
||
throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.')
|
||
}
|
||
|
||
const id = crypto.randomUUID()
|
||
const { args, command, name } = terminalShellCommand()
|
||
const cwd = safeTerminalCwd(payload?.cwd)
|
||
const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80)
|
||
const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24)
|
||
const ptyProcess = nodePty.spawn(command, args, {
|
||
cols,
|
||
cwd,
|
||
env: terminalShellEnv(),
|
||
name: 'xterm-256color',
|
||
rows
|
||
})
|
||
|
||
terminalSessions.set(id, { pty: ptyProcess, webContentsId: event.sender.id })
|
||
|
||
const send = (suffix, payload) => {
|
||
if (event.sender.isDestroyed()) {
|
||
return
|
||
}
|
||
|
||
event.sender.send(terminalChannel(id, suffix), payload)
|
||
}
|
||
|
||
ptyProcess.onData(data => send('data', data))
|
||
ptyProcess.onExit(({ exitCode, signal }) => {
|
||
terminalSessions.delete(id)
|
||
send('exit', { code: exitCode, signal: signal || null })
|
||
})
|
||
event.sender.once('destroyed', () => disposeTerminalSession(id))
|
||
|
||
return { cwd, id, shell: name }
|
||
})
|
||
|
||
ipcMain.handle('hermes:terminal:write', (_event, id, data) => {
|
||
const sessionInfo = terminalSessions.get(String(id || ''))
|
||
|
||
if (!sessionInfo) {
|
||
return false
|
||
}
|
||
|
||
sessionInfo.pty.write(String(data || ''))
|
||
|
||
return true
|
||
})
|
||
|
||
ipcMain.handle('hermes:terminal:resize', (_event, id, size = {}) => {
|
||
const sessionInfo = terminalSessions.get(String(id || ''))
|
||
|
||
if (!sessionInfo) {
|
||
return false
|
||
}
|
||
|
||
const cols = Math.max(2, Number.parseInt(String(size?.cols || 80), 10) || 80)
|
||
const rows = Math.max(2, Number.parseInt(String(size?.rows || 24), 10) || 24)
|
||
|
||
sessionInfo.pty.resize(cols, rows)
|
||
|
||
return true
|
||
})
|
||
ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || '')))
|
||
|
||
ipcMain.handle('hermes:updates:check', async () =>
|
||
checkUpdates().catch(error => ({
|
||
supported: true,
|
||
branch: readDesktopUpdateConfig().branch,
|
||
error: 'check-failed',
|
||
message: error?.message || String(error),
|
||
fetchedAt: Date.now()
|
||
}))
|
||
)
|
||
|
||
ipcMain.handle('hermes:updates:apply', async (_event, payload) =>
|
||
applyUpdates(payload || {}).catch(error => ({
|
||
ok: false,
|
||
error: 'apply-failed',
|
||
message: error?.message || String(error)
|
||
}))
|
||
)
|
||
|
||
ipcMain.handle('hermes:updates:branch:get', async () => readDesktopUpdateConfig())
|
||
|
||
ipcMain.handle('hermes:updates:branch:set', async (_event, name) => {
|
||
const branch = typeof name === 'string' && name.trim() ? name.trim() : DEFAULT_UPDATE_BRANCH
|
||
writeDesktopUpdateConfig({ branch })
|
||
return { branch }
|
||
})
|
||
|
||
ipcMain.handle('hermes:version', async () => ({
|
||
appVersion: app.getVersion(),
|
||
electronVersion: process.versions.electron,
|
||
nodeVersion: process.versions.node,
|
||
platform: process.platform,
|
||
hermesRoot: resolveUpdateRoot()
|
||
}))
|
||
|
||
app.whenReady().then(() => {
|
||
if (IS_MAC) {
|
||
Menu.setApplicationMenu(buildApplicationMenu())
|
||
} else {
|
||
Menu.setApplicationMenu(null)
|
||
}
|
||
installMediaPermissions()
|
||
createWindow()
|
||
|
||
app.on('activate', () => {
|
||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||
})
|
||
})
|
||
|
||
app.on('before-quit', () => {
|
||
if (desktopLogFlushTimer) {
|
||
clearTimeout(desktopLogFlushTimer)
|
||
desktopLogFlushTimer = null
|
||
}
|
||
flushDesktopLogBufferSync()
|
||
closePreviewWatchers()
|
||
|
||
if (hermesProcess && !hermesProcess.killed) {
|
||
hermesProcess.kill('SIGTERM')
|
||
}
|
||
})
|
||
|
||
app.on('window-all-closed', () => {
|
||
if (process.platform !== 'darwin') app.quit()
|
||
})
|