mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-17 09:41:58 +00:00
Adds a VSCode-style "focus terminal" toggle to the right sidebar's Terminal tab that takes over the chat pane area without unmounting the shell. The xterm host is mounted once at the layout root and CSS-overlayed onto whichever <TerminalSlot /> is currently active, so the PTY session, scrollback, selection, focus, and WebGL renderer survive every toggle. Also: - WebGL renderer (matching dashboard ChatPage) so Hermes' TUI skins paint faithfully instead of muting through xterm's default DOM renderer - File drag/drop from the project tree or OS into xterm — paths are shell-quoted (zsh/bash/pwsh/cmd) and written straight into the PTY - Solarized dark canvas with brights promoted to real accent variants (Schoonover's UI-gray brights washed out every TUI accent) - Strip NO_COLOR/FORCE_COLOR/COLORFGBG/TERM=dumb leaking from non-tty parents (CI runners, Cursor's agent shell) so the embedded shell gets truecolor regardless of how Electron was launched - rAF-debounced ResizeObserver — running fit.fit() synchronously during sibling pane transitions crashed the WebGL texture-atlas rebuild
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('@homebridge/node-pty-prebuilt-multiarch')
|
||
} 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', '@homebridge', 'node-pty-prebuilt-multiarch')
|
||
)
|
||
}
|
||
} 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()
|
||
})
|