Four related desktop session-management bugs: - Pins lost until refresh: pinned sessions are joined against the paginated in-memory session list, so a pinned chat that aged off the most-recent page got evicted on the next refresh (every message.complete triggers one) and the Pinned section went empty. mergeWorkingSessions -> mergeSessionPage now also preserves pinned rows (matched by live id or lineage root). Pin id checks in the chat header, command center, and delete/archive are normalized to the durable sessionPinId so pins survive auto-compression. - Stuck on "Starting Hermes" after sleep: macOS sleep drops the renderer WebSocket; nothing reconnected on wake so the composer stayed disabled. The gateway boot hook now auto-reconnects with backoff on close/error and on wake signals (powerMonitor resume/unlock-screen IPC, window online, visibilitychange). connect() gains an open timeout so a hung reconnect can't deadlock in 'connecting'. Composer placeholder distinguishes "Reconnecting to Hermes" from a cold start. - Loses chats from itself: the same hard-replace that dropped pins also dropped loaded sessions; mergeSessionPage keeps them. - Multiple copies/branches in search: /api/sessions/search deduped only by raw session_id, so compression segments and branches surfaced as separate hits. It now dedupes by lineage root and returns the live compression tip, matching the session_search tool's behavior.
4289 lines
146 KiB
JavaScript
4289 lines
146 KiB
JavaScript
const {
|
||
app,
|
||
BrowserWindow,
|
||
Menu,
|
||
Notification,
|
||
clipboard,
|
||
dialog,
|
||
ipcMain,
|
||
nativeImage,
|
||
nativeTheme,
|
||
net: electronNet,
|
||
powerMonitor,
|
||
protocol,
|
||
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 { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
||
const {
|
||
DATA_URL_READ_MAX_BYTES,
|
||
DEFAULT_FETCH_TIMEOUT_MS,
|
||
TEXT_PREVIEW_SOURCE_MAX_BYTES,
|
||
encryptDesktopSecret: encryptDesktopSecretStrict,
|
||
resolveReadableFileForIpc,
|
||
resolveTimeoutMs
|
||
} = require('./hardening.cjs')
|
||
|
||
let nodePty = null
|
||
|
||
try {
|
||
nodePty = require('node-pty')
|
||
} catch {
|
||
// Packaged builds set `files:` in package.json, which excludes node_modules
|
||
// from the asar. Workspace dedup also hoists this native dep to the repo
|
||
// root's node_modules, out of reach of electron-builder's collector. We
|
||
// ship a minimal copy under resources/native-deps/ via extraResources +
|
||
// scripts/stage-native-deps.cjs; resolve from there when the normal
|
||
// require() fails. Dev mode never reaches this branch -- the hoisted
|
||
// resolve succeeds via Node's normal module lookup.
|
||
try {
|
||
const path = require('node:path')
|
||
const resourcesPath = process.resourcesPath
|
||
if (resourcesPath) {
|
||
nodePty = require(path.join(resourcesPath, 'native-deps', 'node-pty'))
|
||
}
|
||
} catch {
|
||
nodePty = null
|
||
}
|
||
}
|
||
|
||
const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR
|
||
if (USER_DATA_OVERRIDE) {
|
||
const resolvedUserData = path.resolve(USER_DATA_OVERRIDE)
|
||
fs.mkdirSync(resolvedUserData, { recursive: true })
|
||
app.setPath('userData', resolvedUserData)
|
||
}
|
||
|
||
const PORT_FLOOR = 9120
|
||
const PORT_CEILING = 9199
|
||
const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER
|
||
const IS_PACKAGED = app.isPackaged
|
||
const IS_MAC = process.platform === 'darwin'
|
||
const IS_WINDOWS = process.platform === 'win32'
|
||
const IS_WSL = isWslEnvironment()
|
||
const APP_ROOT = app.getAppPath()
|
||
|
||
// Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU
|
||
// compositor flicker — accelerated layers can't be presented cleanly over the
|
||
// wire, so the window flashes during scroll/streaming/animation. Local
|
||
// Windows/macOS (and WSLg, which renders locally via vGPU) composite on the
|
||
// GPU and never see it. Fall back to software rendering when a remote display
|
||
// is detected; it's rock-steady over the wire and the CPU cost is negligible
|
||
// next to the connection's latency. Must run before app `ready` — these
|
||
// switches only apply pre-launch. Override with HERMES_DESKTOP_DISABLE_GPU
|
||
// (1/true → always disable, 0/false → keep GPU on).
|
||
const REMOTE_DISPLAY_REASON = detectRemoteDisplay()
|
||
if (REMOTE_DISPLAY_REASON) {
|
||
app.disableHardwareAcceleration()
|
||
// Belt-and-suspenders for X11/VNC, where the Viz compositor can still glitch
|
||
// with only --disable-gpu: force compositing onto the CPU too.
|
||
app.commandLine.appendSwitch('disable-gpu-compositing')
|
||
console.log(
|
||
`[hermes] remote display detected (${REMOTE_DISPLAY_REASON}); disabling GPU hardware acceleration to prevent flicker`
|
||
)
|
||
}
|
||
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. The GUI work has merged to main, so this
|
||
// tracks main. User can also override at runtime via
|
||
// hermesDesktop.updates.setBranch().
|
||
const DEFAULT_UPDATE_BRANCH = 'main'
|
||
// 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'
|
||
})
|
||
|
||
// Custom scheme for streaming local media (video/audio) into the renderer.
|
||
// Reading large media through `readFileDataUrl` failed: it base64-loads the
|
||
// whole file into memory and is hard-capped at DATA_URL_READ_MAX_BYTES (16 MB),
|
||
// so any non-trivial video silently refused to load. Streaming via a protocol
|
||
// handler removes the size cap and gives the <video> element seekable,
|
||
// range-aware playback. Must be registered before the app is ready.
|
||
const MEDIA_PROTOCOL = 'hermes-media'
|
||
// Only audio/video may be streamed. Without this the handler would read any
|
||
// non-blocklisted local file (no size cap) for any `fetch(hermes-media://…)`.
|
||
const STREAMABLE_MEDIA_EXTS = new Set([
|
||
'.avi',
|
||
'.flac',
|
||
'.m4a',
|
||
'.mkv',
|
||
'.mov',
|
||
'.mp3',
|
||
'.mp4',
|
||
'.ogg',
|
||
'.opus',
|
||
'.wav',
|
||
'.webm'
|
||
])
|
||
|
||
protocol.registerSchemesAsPrivileged([
|
||
{
|
||
scheme: MEDIA_PROTOCOL,
|
||
privileges: {
|
||
secure: true,
|
||
standard: true,
|
||
stream: true,
|
||
supportFetchAPI: true
|
||
}
|
||
}
|
||
])
|
||
|
||
function registerMediaProtocol() {
|
||
protocol.handle(MEDIA_PROTOCOL, async request => {
|
||
let resolvedPath
|
||
try {
|
||
const url = new URL(request.url)
|
||
const filePath = decodeURIComponent(url.pathname.replace(/^\/+/, ''))
|
||
;({ resolvedPath } = await resolveReadableFileForIpc(filePath, { purpose: 'Media stream' }))
|
||
} catch {
|
||
return new Response('Media not found', { status: 404 })
|
||
}
|
||
|
||
if (!STREAMABLE_MEDIA_EXTS.has(path.extname(resolvedPath).toLowerCase())) {
|
||
return new Response('Unsupported media type', { status: 415 })
|
||
}
|
||
|
||
// Delegate to Electron's net stack on a file:// URL — it resolves the
|
||
// content-type and honors Range requests so seeking works. Forward the
|
||
// renderer's headers (notably Range) and skip custom-protocol re-entry.
|
||
return electronNet.fetch(pathToFileURL(resolvedPath).toString(), {
|
||
bypassCustomProtocolHandlers: true,
|
||
headers: request.headers
|
||
})
|
||
})
|
||
}
|
||
|
||
let mainWindow = null
|
||
let hermesProcess = null
|
||
let connectionPromise = null
|
||
// Auto-reload budget for renderer crashes. A deterministic startup crash would
|
||
// otherwise loop forever (reload → crash → reload), pinning CPU and spamming
|
||
// logs. Allow a few reloads per rolling window, then stop and leave the dead
|
||
// window so the user can read the error / quit.
|
||
const RENDERER_RELOAD_WINDOW_MS = 60_000
|
||
const RENDERER_RELOAD_MAX = 3
|
||
let rendererReloadTimes = []
|
||
// 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
|
||
// Active first-launch install, so the renderer's Cancel button (and app quit)
|
||
// can abort the in-flight install.sh/ps1 instead of leaving it running.
|
||
let bootstrapAbortController = null
|
||
// Set by the renderer's "Repair install" IPC. While true, resolution skips the
|
||
// existing-install adopt branch (3b) so repair re-drives the installer instead
|
||
// of re-adopting the install we're repairing. Cleared once a bootstrap runs.
|
||
let forceBootstrapRepair = false
|
||
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
|
||
}
|
||
|
||
// `file://` URLs come from the artifacts panel (the renderer can't open
|
||
// them itself because Chromium blocks file:// navigation from the app
|
||
// origin). Hand them to `shell.openPath`, which dispatches to the OS
|
||
// file association. If the OS can't open it (`error` is a non-empty
|
||
// string), fall back to revealing the file in the system file manager.
|
||
if (parsed.protocol === 'file:') {
|
||
let localPath
|
||
try {
|
||
localPath = fileURLToPath(parsed.toString())
|
||
} catch {
|
||
return false
|
||
}
|
||
|
||
void shell
|
||
.openPath(localPath)
|
||
.then(error => {
|
||
if (!error) {
|
||
return
|
||
}
|
||
|
||
rememberLog(`[file] openPath failed: ${error}; revealing in folder instead`)
|
||
|
||
try {
|
||
shell.showItemInFolder(localPath)
|
||
} catch (revealError) {
|
||
rememberLog(`[file] showItemInFolder failed: ${revealError.message}`)
|
||
}
|
||
})
|
||
.catch(error => rememberLog(`[file] openPath rejected: ${error.message}`))
|
||
|
||
return true
|
||
}
|
||
|
||
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 ensureWslWindowsFonts() {
|
||
if (!IS_WSL) return
|
||
|
||
const fontsDir = ['/mnt/c/Windows/Fonts', '/mnt/c/windows/fonts'].find(candidate => {
|
||
try {
|
||
return fs.statSync(candidate).isDirectory()
|
||
} catch {
|
||
return false
|
||
}
|
||
})
|
||
if (!fontsDir) return
|
||
|
||
try {
|
||
const confDir = path.join(app.getPath('home'), '.config', 'fontconfig', 'conf.d')
|
||
const confPath = path.join(confDir, '99-hermes-wsl-windows-fonts.conf')
|
||
let existing = ''
|
||
try {
|
||
existing = fs.readFileSync(confPath, 'utf8')
|
||
} catch {
|
||
existing = ''
|
||
}
|
||
if (existing.includes(fontsDir)) return
|
||
|
||
fs.mkdirSync(confDir, { recursive: true })
|
||
fs.writeFileSync(
|
||
confPath,
|
||
`<?xml version="1.0"?>\n<!DOCTYPE fontconfig SYSTEM "fonts.dtd">\n<fontconfig>\n <dir>${fontsDir}</dir>\n</fontconfig>\n`
|
||
)
|
||
rememberLog(`[fonts] wired WSL Windows fonts for renderer: ${fontsDir}`)
|
||
|
||
const cache = spawn('fc-cache', ['-f', fontsDir], { detached: true, stdio: 'ignore' })
|
||
cache.on('error', () => undefined)
|
||
cache.unref()
|
||
} catch (error) {
|
||
rememberLog(`[fonts] WSL font setup skipped: ${error.message}`)
|
||
}
|
||
}
|
||
|
||
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, stream: ev.stream || 'stdout' })
|
||
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 normalizeExecutablePathForCompare(commandPath) {
|
||
if (!commandPath) return null
|
||
|
||
let resolved = path.resolve(String(commandPath))
|
||
try {
|
||
resolved = fs.realpathSync.native ? fs.realpathSync.native(resolved) : fs.realpathSync(resolved)
|
||
} catch {
|
||
// Fallback to path.resolve() above.
|
||
}
|
||
|
||
return IS_WINDOWS ? resolved.toLowerCase() : resolved
|
||
}
|
||
|
||
function looksLikeDesktopAppBinary(commandPath) {
|
||
if (!IS_WINDOWS || !commandPath) return false
|
||
|
||
const normalizedCandidate = normalizeExecutablePathForCompare(commandPath)
|
||
const normalizedCurrentExec = normalizeExecutablePathForCompare(process.execPath)
|
||
if (normalizedCandidate && normalizedCurrentExec && normalizedCandidate === normalizedCurrentExec) {
|
||
return true
|
||
}
|
||
|
||
let resolved = path.resolve(String(commandPath))
|
||
try {
|
||
resolved = fs.realpathSync.native ? fs.realpathSync.native(resolved) : fs.realpathSync(resolved)
|
||
} catch {
|
||
// Keep resolved path fallback.
|
||
}
|
||
|
||
const resourcesDir = path.join(path.dirname(resolved), 'resources')
|
||
return (
|
||
fileExists(path.join(resourcesDir, 'app.asar')) || directoryExists(path.join(resourcesDir, 'app.asar.unpacked'))
|
||
)
|
||
}
|
||
|
||
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'))
|
||
}
|
||
|
||
// resolveGitBinary — locate git.exe on Windows. A fresh installer-driven
|
||
// install only has PortableGit under %LOCALAPPDATA%\hermes\git (never on
|
||
// PATH), so a bare spawn('git') ENOENTs and self-update checks fail with
|
||
// "Couldn't check for updates". Mirror findGitBash: PortableGit first, then
|
||
// standard Git-for-Windows locations, then PATH. Cached after first probe.
|
||
let _gitBinaryCache = null
|
||
function resolveGitBinary() {
|
||
if (_gitBinaryCache) return _gitBinaryCache
|
||
if (!IS_WINDOWS) {
|
||
_gitBinaryCache = findOnPath('git') || 'git'
|
||
return _gitBinaryCache
|
||
}
|
||
|
||
const localAppData = process.env.LOCALAPPDATA || ''
|
||
const candidates = []
|
||
if (localAppData) {
|
||
candidates.push(path.join(localAppData, 'hermes', 'git', 'cmd', 'git.exe'))
|
||
candidates.push(path.join(localAppData, 'hermes', 'git', 'bin', 'git.exe'))
|
||
}
|
||
candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'Git', 'cmd', 'git.exe'))
|
||
candidates.push(path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'cmd', 'git.exe'))
|
||
if (localAppData) {
|
||
candidates.push(path.join(localAppData, 'Programs', 'Git', 'cmd', 'git.exe'))
|
||
}
|
||
|
||
_gitBinaryCache = candidates.find(fileExists) || findOnPath('git') || 'git'
|
||
return _gitBinaryCache
|
||
}
|
||
|
||
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(resolveGitBinary(), 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)
|
||
}
|
||
}
|
||
|
||
// Self-heal the tracked update branch: if origin no longer publishes it (e.g.
|
||
// bb/gui was merged into main and deleted), fall back to main and persist so
|
||
// every later check/apply follows main — no manual flip, even for already-
|
||
// installed clients. Read-only ls-remote probe; only flips on a definitive
|
||
// "ref absent" (exit 2), never on a transient network error, so a flaky
|
||
// connection can't strand a user on the wrong branch.
|
||
async function resolveHealedBranch(updateRoot, branch) {
|
||
if (!branch || branch === 'main') {
|
||
return branch || 'main'
|
||
}
|
||
|
||
const probe = await runGit(['ls-remote', '--exit-code', '--heads', 'origin', branch], { cwd: updateRoot })
|
||
if (probe.code !== 2) {
|
||
return branch
|
||
}
|
||
|
||
rememberLog(`[updates] origin/${branch} is gone (merged?); falling back to main`)
|
||
const config = readDesktopUpdateConfig()
|
||
if (config.branch !== 'main') {
|
||
writeDesktopUpdateConfig({ ...config, branch: 'main' })
|
||
}
|
||
return 'main'
|
||
}
|
||
|
||
async function checkUpdates() {
|
||
const updateRoot = resolveUpdateRoot()
|
||
let { 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
|
||
}
|
||
}
|
||
|
||
branch = await resolveHealedBranch(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
|
||
|
||
// Resolve the staged updater binary. The Tauri installer copies itself to
|
||
// HERMES_HOME/hermes-setup.exe on a successful install (see
|
||
// apps/bootstrap-installer paths::copy_self_to_hermes_home). That binary owns
|
||
// ALL repo mutation — running `hermes update` + rebuilding the desktop — so
|
||
// the desktop never touches its own bits while running. Returns null when the
|
||
// updater isn't staged (e.g. a dev/source run that never went through the
|
||
// installer); callers degrade gracefully.
|
||
function resolveUpdaterBinary() {
|
||
const name = IS_WINDOWS ? 'hermes-setup.exe' : 'hermes-setup'
|
||
const candidate = path.join(HERMES_HOME, name)
|
||
return fileExists(candidate) ? candidate : null
|
||
}
|
||
|
||
// applyUpdates — hand off to the installer's --update flow, then exit.
|
||
//
|
||
// The desktop is a pure consumer: it does NOT git pull / pip install / rebuild
|
||
// itself (the old open-coded git dance lived here and drifted from
|
||
// `hermes update`). Instead we spawn the staged Hermes-Setup binary with
|
||
// --update and quit, so it can run `hermes update` (which refuses while we
|
||
// hold the venv shim) and rebuild the desktop with our exe already gone.
|
||
//
|
||
// Detection (checkUpdates / commit changelog / "N behind") stays in the UI;
|
||
// only this apply action changed.
|
||
async function applyUpdates(opts = {}) {
|
||
if (updateInFlight) {
|
||
throw new Error('An update is already in progress.')
|
||
}
|
||
updateInFlight = true
|
||
|
||
try {
|
||
const updater = resolveUpdaterBinary()
|
||
if (!updater && !IS_WINDOWS) {
|
||
// macOS/Linux drag-install: no staged Tauri hermes-setup. Unlike Windows
|
||
// (where a venv-shim file lock forces the quit→hand-off→rebuild dance),
|
||
// there's no mandatory file locking here, so the desktop can drive the
|
||
// whole update itself: `hermes update` (backend) + `hermes desktop
|
||
// --build-only` (OS-aware GUI rebuild), then swap the running .app bundle
|
||
// with the freshly built one and relaunch.
|
||
return await applyUpdatesPosixInApp(opts)
|
||
}
|
||
if (!updater) {
|
||
// No staged updater binary — this is a CLI-installed user (they ran
|
||
// `hermes desktop`, never the Tauri installer that self-copies
|
||
// hermes-setup.exe into HERMES_HOME). They DO have a working `hermes`
|
||
// on PATH / in the venv, so the correct path is the one-liner in their
|
||
// native medium. We show the EXACT command, branch-pinned to the
|
||
// checkout they're on — bare `hermes update` defaults to main and would
|
||
// silently switch a bb/gui (or any non-main) install off-branch. Mirror
|
||
// the GUI button's contract: append --branch <current> for non-main
|
||
// checkouts, keep it bare for main so the card stays clean.
|
||
const updateRoot = resolveUpdateRoot()
|
||
let command = 'hermes update'
|
||
try {
|
||
const head = await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: updateRoot })
|
||
const current = (head.stdout || '').trim()
|
||
if (head.code === 0 && current && current !== 'HEAD') {
|
||
const branch = await resolveHealedBranch(updateRoot, current)
|
||
if (branch !== 'main') command = `hermes update --branch ${branch}`
|
||
}
|
||
} catch {
|
||
// Best-effort: fall back to bare `hermes update` if branch detection fails.
|
||
}
|
||
rememberLog(`[updates] no staged updater; surfacing manual \`${command}\` for CLI install at ${updateRoot}`)
|
||
emitUpdateProgress({ stage: 'manual', message: command, percent: null })
|
||
return { ok: true, manual: true, command, hermesRoot: updateRoot }
|
||
}
|
||
|
||
emitUpdateProgress({ stage: 'restart', message: 'Handing off to the Hermes updater…', percent: 100 })
|
||
|
||
const updateRoot = resolveUpdateRoot()
|
||
const { branch: configuredBranch } = readDesktopUpdateConfig()
|
||
const branch = await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH)
|
||
const updaterArgs = ['--update', '--branch', branch]
|
||
const targetApp = IS_MAC ? runningAppBundle() : null
|
||
if (targetApp) {
|
||
updaterArgs.push('--target-app', targetApp)
|
||
}
|
||
const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin')
|
||
|
||
// Detached so the updater outlives this process — it needs us GONE before
|
||
// `hermes update` will run (the venv shim is locked while we live).
|
||
const child = spawn(updater, updaterArgs, {
|
||
cwd: HERMES_HOME,
|
||
env: {
|
||
...process.env,
|
||
HERMES_HOME,
|
||
PATH: [path.join(HERMES_HOME, 'node', 'bin'), venvBin, process.env.PATH]
|
||
.filter(Boolean)
|
||
.join(path.delimiter)
|
||
},
|
||
detached: true,
|
||
stdio: 'ignore',
|
||
windowsHide: false
|
||
})
|
||
child.unref()
|
||
|
||
rememberLog(`[updates] launched updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release venv shim`)
|
||
|
||
// Give the OS a beat to register the new process, then quit. The updater
|
||
// rebuilds and relaunches us when it's done.
|
||
setTimeout(() => {
|
||
app.quit()
|
||
}, 600)
|
||
|
||
return { ok: true, handedOff: true, updater }
|
||
} finally {
|
||
updateInFlight = false
|
||
}
|
||
}
|
||
|
||
// Resolve the hermes CLI to drive an in-app update: prefer the venv shim in
|
||
// the install we're updating, fall back to `hermes` on PATH.
|
||
function resolveHermesCliBinary(updateRoot) {
|
||
const venvHermes = path.join(updateRoot, 'venv', 'bin', 'hermes')
|
||
if (fileExists(venvHermes)) return venvHermes
|
||
return findOnPath('hermes') || null
|
||
}
|
||
|
||
// Spawn a command and stream each output line to the update progress channel.
|
||
function runStreamedUpdate(command, args, { cwd, env, stage } = {}) {
|
||
return new Promise(resolve => {
|
||
let child
|
||
try {
|
||
child = spawn(command, args, {
|
||
cwd,
|
||
env: { ...process.env, ...(env || {}) },
|
||
stdio: ['ignore', 'pipe', 'pipe']
|
||
})
|
||
} catch (err) {
|
||
resolve({ code: 1, error: err.message })
|
||
return
|
||
}
|
||
const emitLines = chunk => {
|
||
for (const line of chunk.toString().split('\n')) {
|
||
const trimmed = line.trim()
|
||
if (trimmed) emitUpdateProgress({ stage, message: trimmed, percent: null })
|
||
}
|
||
}
|
||
child.stdout.on('data', emitLines)
|
||
child.stderr.on('data', emitLines)
|
||
child.once('error', err => resolve({ code: 1, error: err.message }))
|
||
child.once('exit', code => resolve({ code }))
|
||
})
|
||
}
|
||
|
||
// The running app's .app bundle (packaged macOS): execPath is
|
||
// <App>.app/Contents/MacOS/<exe>; climb three levels to the bundle root.
|
||
function runningAppBundle() {
|
||
if (!IS_MAC) return null
|
||
let dir = path.dirname(app.getPath('exe')) // .../Contents/MacOS
|
||
for (let i = 0; i < 2; i++) dir = path.dirname(dir) // -> .../X.app
|
||
return dir.endsWith('.app') ? dir : null
|
||
}
|
||
|
||
function shellQuote(value) {
|
||
return `'${String(value).replace(/'/g, `'\\''`)}'`
|
||
}
|
||
|
||
// macOS/Linux in-app update: backend (`hermes update`) + OS-aware GUI rebuild
|
||
// (`hermes desktop --build-only`), then atomically swap the running .app bundle
|
||
// with the freshly built one and relaunch. Degrades to "backend updated,
|
||
// restart to load the new GUI" if the swap can't be performed.
|
||
async function applyUpdatesPosixInApp(opts = {}) {
|
||
const updateRoot = resolveUpdateRoot()
|
||
const hermes = resolveHermesCliBinary(updateRoot)
|
||
if (!hermes) {
|
||
emitUpdateProgress({ stage: 'manual', message: 'hermes update', percent: null })
|
||
return { ok: true, manual: true, command: 'hermes update', hermesRoot: updateRoot }
|
||
}
|
||
|
||
// Put the Hermes-managed Node and the venv on PATH so `hermes desktop`'s
|
||
// npm build can find them on a machine with no system Node.
|
||
const extraPath = [path.join(HERMES_HOME, 'node', 'bin'), path.join(updateRoot, 'venv', 'bin')]
|
||
.filter(Boolean)
|
||
.join(path.delimiter)
|
||
const env = {
|
||
HERMES_HOME,
|
||
PATH: [extraPath, process.env.PATH].filter(Boolean).join(path.delimiter)
|
||
}
|
||
|
||
// `hermes update` reaps stale `hermes dashboard` backends (a code update
|
||
// leaves the running process serving old Python against the freshly-updated
|
||
// JS bundle). But OUR backend is one of those processes, and killing it
|
||
// mid-update produces the boot→kill→crash loop in #37532 — the desktop
|
||
// already restarts its own backend via the rebuild+relaunch below, so the
|
||
// reap must spare it. Hand the live backend's PID to the update process;
|
||
// _kill_stale_dashboard_processes reads HERMES_DESKTOP_CHILD_PID and excludes
|
||
// it while still reaping any genuinely-orphaned dashboards. (#37532)
|
||
if (hermesProcess && Number.isInteger(hermesProcess.pid)) {
|
||
env.HERMES_DESKTOP_CHILD_PID = String(hermesProcess.pid)
|
||
}
|
||
|
||
// Branch-pin so a non-main checkout doesn't get switched to main (and self-heal
|
||
// to main when the pinned branch no longer exists on origin).
|
||
let branchArgs = []
|
||
try {
|
||
const head = await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: updateRoot })
|
||
const current = (head.stdout || '').trim()
|
||
if (head.code === 0 && current && current !== 'HEAD') {
|
||
branchArgs = ['--branch', await resolveHealedBranch(updateRoot, current)]
|
||
}
|
||
} catch {
|
||
// best effort
|
||
}
|
||
|
||
emitUpdateProgress({ stage: 'update', message: 'Updating Hermes (git + dependencies)…', percent: 10 })
|
||
const updated = await runStreamedUpdate(hermes, ['update', '--yes', ...branchArgs], {
|
||
cwd: updateRoot,
|
||
env,
|
||
stage: 'update'
|
||
})
|
||
if (updated.code !== 0) {
|
||
emitUpdateProgress({ stage: 'error', message: 'hermes update failed.', error: updated.error || 'update-failed' })
|
||
return { ok: false, error: 'hermes update failed' }
|
||
}
|
||
|
||
emitUpdateProgress({ stage: 'rebuild', message: 'Rebuilding the desktop app…', percent: 60 })
|
||
const rebuilt = await runStreamedUpdate(hermes, ['desktop', '--build-only'], {
|
||
cwd: updateRoot,
|
||
env,
|
||
stage: 'rebuild'
|
||
})
|
||
if (rebuilt.code !== 0) {
|
||
emitUpdateProgress({
|
||
stage: 'error',
|
||
message: 'Backend updated, but the desktop rebuild failed. Restart Hermes to retry.',
|
||
error: rebuilt.error || 'rebuild-failed'
|
||
})
|
||
return { ok: false, backendUpdated: true, error: 'desktop rebuild failed' }
|
||
}
|
||
|
||
const rebuiltApp = [
|
||
path.join(updateRoot, 'apps', 'desktop', 'release', 'mac-arm64', 'Hermes.app'),
|
||
path.join(updateRoot, 'apps', 'desktop', 'release', 'mac', 'Hermes.app')
|
||
].find(directoryExists)
|
||
const targetApp = runningAppBundle()
|
||
|
||
// No bundle to swap (dev run, Linux AppImage, or unresolved paths): the
|
||
// backend is updated; the next launch picks up the rebuilt GUI.
|
||
if (!rebuiltApp || !targetApp) {
|
||
emitUpdateProgress({
|
||
stage: 'done',
|
||
message: 'Backend updated. Restart Hermes to load the new version.',
|
||
percent: 100
|
||
})
|
||
return { ok: true, backendUpdated: true, rebuiltApp: rebuiltApp || null }
|
||
}
|
||
|
||
emitUpdateProgress({ stage: 'restart', message: 'Installing the updated app and restarting…', percent: 95 })
|
||
|
||
// Detached swapper: wait for THIS process to exit (so the bundle is free),
|
||
// ditto the rebuilt app over the running one, clear quarantine, relaunch.
|
||
const swapScript = `#!/bin/bash
|
||
set -u
|
||
APP_PID=${process.pid}
|
||
SRC=${shellQuote(rebuiltApp)}
|
||
DST=${shellQuote(targetApp)}
|
||
for _ in $(seq 1 240); do
|
||
kill -0 "$APP_PID" 2>/dev/null || break
|
||
sleep 0.5
|
||
done
|
||
if [ "$SRC" != "$DST" ]; then
|
||
if /usr/bin/ditto "$SRC" "$DST.hermes-update-new"; then
|
||
rm -rf "$DST.hermes-update-old" 2>/dev/null || true
|
||
mv "$DST" "$DST.hermes-update-old" 2>/dev/null || rm -rf "$DST"
|
||
mv "$DST.hermes-update-new" "$DST"
|
||
rm -rf "$DST.hermes-update-old" 2>/dev/null || true
|
||
fi
|
||
fi
|
||
/usr/bin/xattr -dr com.apple.quarantine "$DST" 2>/dev/null || true
|
||
/usr/bin/open "$DST"
|
||
`
|
||
const scriptPath = path.join(app.getPath('temp'), `hermes-desktop-update-${Date.now()}.sh`)
|
||
try {
|
||
fs.writeFileSync(scriptPath, swapScript, { mode: 0o755 })
|
||
} catch (err) {
|
||
emitUpdateProgress({
|
||
stage: 'done',
|
||
message: 'Backend + app updated. Restart Hermes to load the new version.',
|
||
percent: 100
|
||
})
|
||
rememberLog(`[updates] could not write swap script: ${err.message}; rebuilt app at ${rebuiltApp}`)
|
||
return { ok: true, backendUpdated: true, rebuiltApp }
|
||
}
|
||
|
||
const child = spawn('/bin/bash', [scriptPath], { detached: true, stdio: 'ignore' })
|
||
child.unref()
|
||
rememberLog(`[updates] launched mac swap+relaunch: ${scriptPath} (${rebuiltApp} -> ${targetApp})`)
|
||
|
||
setTimeout(() => app.quit(), 600)
|
||
return { ok: true, handedOff: true, rebuiltApp, targetApp }
|
||
}
|
||
|
||
function readJson(filePath) {
|
||
try {
|
||
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
||
} 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>" | null, // what install.ps1 was driven against;
|
||
// // may be null for adopted installs
|
||
// pinnedBranch: "<branch name>" | null,
|
||
// adopted: <bool>, // true when we adopted a pre-existing
|
||
// // install rather than bootstrapping it;
|
||
// // treated as authoritative even sans commit
|
||
// completedAt: "<ISO 8601>",
|
||
// desktopVersion: "<app.getVersion()>" // for forensics
|
||
// }
|
||
function readBootstrapMarker() {
|
||
return readJson(BOOTSTRAP_COMPLETE_MARKER)
|
||
}
|
||
|
||
// Marker-independent: is the canonical install at ACTIVE_HERMES_ROOT actually
|
||
// runnable right now? A complete CLI install (`install.sh --include-desktop`)
|
||
// or a DMG launch over a prior CLI install satisfies this WITHOUT the desktop
|
||
// ever having written the bootstrap marker -- so we must be able to recognise
|
||
// "already installed" off the filesystem alone, not just the marker.
|
||
function isActiveRuntimeUsable() {
|
||
return isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(getVenvPython(VENV_ROOT))
|
||
}
|
||
|
||
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) {
|
||
// Adopted markers (an existing install we detected and took ownership of,
|
||
// possibly without a resolvable commit) are still authoritative -- they
|
||
// attest a runnable install we deliberately decided to forward to.
|
||
if (marker.adopted !== true) 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." We DO additionally require
|
||
// a runnable venv: an interrupted or split-home install can leave the marker
|
||
// + checkout without a venv, and trusting that spawns a dead backend
|
||
// ("gateway offline") instead of re-running bootstrap to repair it.
|
||
return isActiveRuntimeUsable()
|
||
}
|
||
|
||
// HEAD commit of ACTIVE_HERMES_ROOT so an adopted marker carries the same
|
||
// provenance a freshly-bootstrapped one would. null when git is unavailable or
|
||
// the root isn't a checkout -- the marker stays valid via its `adopted` flag.
|
||
function readActiveHeadCommit() {
|
||
try {
|
||
const sha = execFileSync(resolveGitBinary(), ['-C', ACTIVE_HERMES_ROOT, 'rev-parse', 'HEAD'], {
|
||
encoding: 'utf8',
|
||
stdio: ['ignore', 'pipe', 'ignore']
|
||
}).trim()
|
||
return /^[0-9a-f]{7,40}$/i.test(sha) ? sha : null
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
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,
|
||
adopted: Boolean(payload.adopted),
|
||
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() {
|
||
// In a packaged build, `process.cwd()` resolves to the install root (e.g.
|
||
// `…/win-unpacked` on Windows or `/Applications/Hermes.app/Contents/...`
|
||
// on macOS). Sessions spawned there leave files inside the app bundle
|
||
// and bewilder users when "where did my files go?" is the install dir.
|
||
// The user-configurable default project directory wins over everything,
|
||
// followed by env hints (only honored when packaged if they point at a
|
||
// real directory), then the home dir.
|
||
const candidates = [
|
||
readDefaultProjectDir(),
|
||
process.env.HERMES_DESKTOP_CWD,
|
||
process.env.INIT_CWD,
|
||
IS_PACKAGED ? null : 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')
|
||
}
|
||
|
||
// Persisted "Default project directory" — surfaced as a setting in the
|
||
// renderer (see app/settings/sessions-settings.tsx). Stored as JSON in
|
||
// userData so it survives self-updates without bleeding into the new
|
||
// install. `null` means "no preference, fall back to the usual chain".
|
||
const DEFAULT_PROJECT_DIR_CONFIG_FILENAME = 'project-dir.json'
|
||
|
||
function defaultProjectDirConfigPath() {
|
||
return path.join(app.getPath('userData'), DEFAULT_PROJECT_DIR_CONFIG_FILENAME)
|
||
}
|
||
|
||
function readDefaultProjectDir() {
|
||
try {
|
||
const raw = fs.readFileSync(defaultProjectDirConfigPath(), 'utf8')
|
||
const parsed = JSON.parse(raw)
|
||
|
||
if (parsed && typeof parsed.dir === 'string' && parsed.dir.trim()) {
|
||
const resolved = path.resolve(parsed.dir)
|
||
|
||
if (directoryExists(resolved)) {
|
||
return resolved
|
||
}
|
||
}
|
||
} catch {
|
||
// Missing / unreadable / malformed → fall through to the rest of the
|
||
// candidate chain.
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
function writeDefaultProjectDir(dir) {
|
||
const target = defaultProjectDirConfigPath()
|
||
const payload = dir ? JSON.stringify({ dir: path.resolve(dir) }, null, 2) : JSON.stringify({}, null, 2)
|
||
|
||
try {
|
||
fs.mkdirSync(path.dirname(target), { recursive: true })
|
||
fs.writeFileSync(target, payload, 'utf8')
|
||
} catch (error) {
|
||
rememberLog(`[settings] write default project dir failed: ${error.message}`)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
// 3b. Existing-but-unmarked install at ACTIVE_HERMES_ROOT. The marker is
|
||
// written only by OUR bootstrap, so a runtime from `install.sh
|
||
// --include-desktop` (or a DMG launch over a prior CLI install) is
|
||
// runnable yet markerless -- without this we'd fall to step 6 and re-run
|
||
// the WHOLE install on top of a working one. ACTIVE_HERMES_ROOT is our
|
||
// canonical location (unlike a random `hermes` on PATH), so adopt it:
|
||
// stamp the marker once and forward straight to the app. Repair skips
|
||
// this so a broken-but-present venv still gets rebuilt.
|
||
if (!forceBootstrapRepair && isActiveRuntimeUsable()) {
|
||
rememberLog(`[bootstrap] adopting existing install at ${ACTIVE_HERMES_ROOT}; skipping first-launch setup`)
|
||
try {
|
||
writeBootstrapMarker({ pinnedCommit: readActiveHeadCommit(), pinnedBranch: null, adopted: true })
|
||
} catch (err) {
|
||
rememberLog(`[bootstrap] could not stamp adopted marker: ${err.message}`)
|
||
}
|
||
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) {
|
||
if (looksLikeDesktopAppBinary(hermesCommand)) {
|
||
rememberLog(`Ignoring desktop app executable on PATH while resolving Hermes CLI: ${hermesCommand}`)
|
||
hermesCommand = null
|
||
}
|
||
}
|
||
|
||
if (hermesCommand) {
|
||
// Smoke-test the candidate before trusting it. A `hermes` shim
|
||
// left behind by a half-uninstalled pip install (or a venv
|
||
// entry-point pointing at a deleted interpreter) still resolves
|
||
// via findOnPath but explodes on spawn -- the user then sees a
|
||
// dead backend instead of the first-launch installer. The cheap
|
||
// `--version` probe (see backend-probes.cjs) catches that case
|
||
// and lets the resolver fall through to step 6 / bootstrap.
|
||
const shellForProbe = isCommandScript(hermesCommand)
|
||
if (verifyHermesCli(hermesCommand, { shell: shellForProbe })) {
|
||
return {
|
||
label: `existing Hermes CLI at ${hermesCommand}`,
|
||
command: hermesCommand,
|
||
args: dashboardArgs,
|
||
bootstrap: false,
|
||
env: {},
|
||
kind: 'command',
|
||
shell: shellForProbe
|
||
}
|
||
}
|
||
rememberLog(
|
||
`Ignoring existing Hermes CLI at ${hermesCommand}: --version probe failed; falling through to bootstrap.`
|
||
)
|
||
}
|
||
}
|
||
|
||
// 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) {
|
||
// Same smoke-test rationale as step 4: a system Python in the
|
||
// SUPPORTED_VERSIONS range can be registered (PEP 514) without
|
||
// having hermes_cli installed -- common on dev boxes that have
|
||
// a python.org install from prior unrelated work. Returning that
|
||
// backend hands the spawn step a guaranteed ModuleNotFoundError.
|
||
// Verify the import works before trusting the candidate; on
|
||
// failure, fall through to step 6 so the bootstrap runner pulls
|
||
// a uv-managed 3.11 into %LOCALAPPDATA%\hermes\hermes-agent\venv.
|
||
if (canImportHermesCli(python)) {
|
||
return {
|
||
kind: 'python',
|
||
label: `installed hermes_cli module via ${python}`,
|
||
command: python,
|
||
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
||
bootstrap: false,
|
||
env: {},
|
||
shell: false
|
||
}
|
||
}
|
||
rememberLog(`Ignoring system Python ${python}: hermes_cli is not importable; falling through to bootstrap.`)
|
||
}
|
||
|
||
// 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 the
|
||
// platform installer, 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') {
|
||
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 {}
|
||
|
||
bootstrapAbortController = new AbortController()
|
||
|
||
const bootstrapResult = await runBootstrap({
|
||
installStamp: backend.installStamp,
|
||
activeRoot: backend.activeRoot,
|
||
sourceRepoRoot: SOURCE_REPO_ROOT,
|
||
hermesHome: HERMES_HOME,
|
||
logRoot: path.join(HERMES_HOME, 'logs'),
|
||
abortSignal: bootstrapAbortController.signal,
|
||
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
|
||
})
|
||
|
||
bootstrapAbortController = null
|
||
|
||
if (bootstrapResult.cancelled) {
|
||
const cancelledError = new Error('Hermes install was cancelled.')
|
||
cancelledError.isBootstrapFailure = true
|
||
cancelledError.bootstrapCancelled = true
|
||
bootstrapFailure = cancelledError
|
||
throw cancelledError
|
||
}
|
||
|
||
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.')
|
||
// A repair (if any) has now re-run, so clear the gate -- the re-resolution
|
||
// below SHOULD land on the fresh marker fast-path rather than skip it.
|
||
forceBootstrapRepair = false
|
||
// 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
|
||
}
|
||
if (!text) {
|
||
resolve(null)
|
||
return
|
||
}
|
||
// A 2xx response whose body is HTML means the request fell through
|
||
// to the SPA index.html (e.g. an unregistered /api path). JSON.parse
|
||
// would throw an opaque `Unexpected token '<'` here, so surface a
|
||
// clear diagnostic with the offending URL instead.
|
||
const looksHtml = /^\s*<(?:!doctype|html)/i.test(text)
|
||
const contentType = String(res.headers['content-type'] || '')
|
||
if (looksHtml || contentType.includes('text/html')) {
|
||
reject(
|
||
new Error(
|
||
`Expected JSON from ${url} but got HTML (status ${res.statusCode}). ` +
|
||
'The endpoint is likely missing on the Hermes backend.'
|
||
)
|
||
)
|
||
return
|
||
}
|
||
try {
|
||
resolve(JSON.parse(text))
|
||
} catch {
|
||
reject(new Error(`Invalid JSON from ${url} (status ${res.statusCode}): ${text.slice(0, 200)}`))
|
||
}
|
||
})
|
||
}
|
||
)
|
||
|
||
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')
|
||
}
|
||
|
||
// Tell the renderer the machine just woke. Sleep silently drops the
|
||
// renderer's WebSocket to the local backend; the renderer reconnects on this
|
||
// signal so the chat composer doesn't stay stuck on "Starting Hermes...".
|
||
function sendPowerResume() {
|
||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||
const { webContents } = mainWindow
|
||
if (!webContents || webContents.isDestroyed()) return
|
||
webContents.send('hermes:power-resume')
|
||
}
|
||
|
||
let powerResumeRegistered = false
|
||
|
||
function registerPowerResumeListeners() {
|
||
if (powerResumeRegistered) return
|
||
powerResumeRegistered = true
|
||
try {
|
||
// 'resume' covers sleep/wake; 'unlock-screen' covers lock/unlock without a
|
||
// full suspend. Either can drop an idle socket.
|
||
powerMonitor.on('resume', sendPowerResume)
|
||
powerMonitor.on('unlock-screen', sendPowerResume)
|
||
} catch {
|
||
// powerMonitor is unavailable before app 'ready' on some platforms; the
|
||
// caller registers after 'ready', so this should not normally throw.
|
||
}
|
||
}
|
||
|
||
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' },
|
||
{
|
||
label: 'Actual Size',
|
||
accelerator: 'CommandOrControl+0',
|
||
click: () => { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.setZoomLevel(0) }
|
||
},
|
||
{
|
||
label: 'Zoom In',
|
||
accelerator: 'CommandOrControl+Plus',
|
||
click: () => {
|
||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||
const next = Math.min(mainWindow.webContents.getZoomLevel() + 0.1, 9)
|
||
mainWindow.webContents.setZoomLevel(next)
|
||
}
|
||
}
|
||
},
|
||
{
|
||
label: 'Zoom Out',
|
||
accelerator: 'CommandOrControl+-',
|
||
click: () => {
|
||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||
const next = Math.max(mainWindow.webContents.getZoomLevel() - 0.1, -9)
|
||
mainWindow.webContents.setZoomLevel(next)
|
||
}
|
||
}
|
||
},
|
||
{ 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 installZoomShortcuts(window) {
|
||
// Override Ctrl/Cmd + +/-/0 with half the default zoom step (0.1 vs 0.2).
|
||
// The menu items handle this on macOS (where the menu is always present),
|
||
// but on Linux/Windows the menu is null and Chromium's default handler
|
||
// would use the full 0.2 step, so we intercept here for consistency.
|
||
const ZOOM_STEP = 0.1
|
||
window.webContents.on('before-input-event', (event, input) => {
|
||
const mod = IS_MAC ? input.meta : input.control
|
||
if (!mod || input.alt || input.shift) return
|
||
|
||
const key = input.key
|
||
if (key === '0') {
|
||
event.preventDefault()
|
||
window.webContents.setZoomLevel(0)
|
||
} else if (key === '=' || key === '+') {
|
||
event.preventDefault()
|
||
const next = Math.min(window.webContents.getZoomLevel() + ZOOM_STEP, 9)
|
||
window.webContents.setZoomLevel(next)
|
||
} else if (key === '-') {
|
||
event.preventDefault()
|
||
const next = Math.max(window.webContents.getZoomLevel() - ZOOM_STEP, -9)
|
||
window.webContents.setZoomLevel(next)
|
||
}
|
||
})
|
||
}
|
||
|
||
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)
|
||
}
|
||
)
|
||
}
|
||
|
||
// Spell-check suggestions for the misspelled word under the caret.
|
||
// Chromium surfaces them on `params.dictionarySuggestions`; we offer the
|
||
// top 5 plus a "Add to dictionary" affordance.
|
||
const suggestions = Array.isArray(params.dictionarySuggestions) ? params.dictionarySuggestions : []
|
||
|
||
if (isEditable && params.misspelledWord && suggestions.length > 0) {
|
||
if (template.length) template.push({ type: 'separator' })
|
||
|
||
for (const suggestion of suggestions.slice(0, 5)) {
|
||
template.push({
|
||
label: suggestion,
|
||
click: () => window.webContents.replaceMisspelling(suggestion)
|
||
})
|
||
}
|
||
|
||
template.push({ type: 'separator' })
|
||
template.push({
|
||
label: 'Add to dictionary',
|
||
click: () => window.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord)
|
||
})
|
||
}
|
||
|
||
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 })
|
||
})
|
||
}
|
||
|
||
// Microphone capture for the voice composer. The renderer drives mic access
|
||
// through getUserMedia, which Chromium gates behind these two session hooks.
|
||
//
|
||
// The naive `details.mediaTypes.includes('audio')` check works on macOS but
|
||
// breaks on Windows: Chromium frequently fires the mic permission request with
|
||
// an empty/undefined `mediaTypes`, so the strict check denies it and
|
||
// getUserMedia throws NotAllowedError ("Microphone permission was denied").
|
||
// We therefore treat an audio-capture request as allowed whenever it's the
|
||
// 'media'/'audioCapture' permission AND mediaTypes either includes 'audio' OR
|
||
// is empty/absent (the Windows case). Video is still denied.
|
||
function isAudioCapturePermission(permission, details) {
|
||
if (permission === 'audioCapture') {
|
||
return true
|
||
}
|
||
if (permission !== 'media') {
|
||
return false
|
||
}
|
||
const mediaTypes = details?.mediaTypes
|
||
if (!Array.isArray(mediaTypes) || mediaTypes.length === 0) {
|
||
// Windows: mediaTypes is often empty for a mic request. Don't deny on
|
||
// missing metadata. (A video request would carry mediaTypes:['video'].)
|
||
return true
|
||
}
|
||
return mediaTypes.includes('audio') && !mediaTypes.includes('video')
|
||
}
|
||
|
||
function installMediaPermissions() {
|
||
// Async request handler: the prompt-style path (most platforms).
|
||
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback, details) => {
|
||
callback(isAudioCapturePermission(permission, details))
|
||
})
|
||
|
||
// Synchronous check handler: Chromium consults this for getUserMedia on
|
||
// Windows in addition to (or instead of) the request handler. Without it,
|
||
// the check defaults to false and the mic is denied before the request
|
||
// handler ever runs.
|
||
session.defaultSession.setPermissionCheckHandler((_webContents, permission, _origin, details) => {
|
||
if (permission === 'media' || permission === 'audioCapture') {
|
||
// details.mediaType is a single string here (not the mediaTypes array).
|
||
const mediaType = details?.mediaType
|
||
if (mediaType === 'video') {
|
||
return false
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
return 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)
|
||
installZoomShortcuts(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)
|
||
})
|
||
|
||
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
||
rememberLog(`[renderer] render-process-gone reason=${details?.reason} exitCode=${details?.exitCode}`)
|
||
|
||
if (details?.reason === 'crashed' || details?.reason === 'oom') {
|
||
const now = Date.now()
|
||
rendererReloadTimes = rendererReloadTimes.filter(t => now - t < RENDERER_RELOAD_WINDOW_MS)
|
||
|
||
if (rendererReloadTimes.length >= RENDERER_RELOAD_MAX) {
|
||
rememberLog(
|
||
`[renderer] suppressing reload: ${rendererReloadTimes.length} crashes within ${RENDERER_RELOAD_WINDOW_MS}ms (likely a crash loop)`
|
||
)
|
||
|
||
return
|
||
}
|
||
|
||
rendererReloadTimes.push(now)
|
||
setImmediate(() => {
|
||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||
try {
|
||
mainWindow.webContents.reload()
|
||
} catch (err) {
|
||
rememberLog(`[renderer] reload after crash failed: ${err?.message || err}`)
|
||
}
|
||
})
|
||
}
|
||
})
|
||
|
||
mainWindow.webContents.on('unresponsive', () => rememberLog('[renderer] webContents became unresponsive'))
|
||
|
||
// Electron always passes the event first. The canonical (Electron 36+) shape
|
||
// is (event, messageDetails); the deprecated positional shape is
|
||
// (event, level, message, line, sourceId). Handle both. `level` is numeric
|
||
// (0..3), where 3 === error.
|
||
mainWindow.webContents.on('console-message', (_event, detailsOrLevel, message, line, sourceId) => {
|
||
const details = detailsOrLevel && typeof detailsOrLevel === 'object' ? detailsOrLevel : null
|
||
const level = details ? details.level : detailsOrLevel
|
||
|
||
if (level !== 3) return
|
||
|
||
const text = details ? details.message : message
|
||
const src = details ? details.sourceUrl : sourceId
|
||
const lineNo = details ? details.lineNumber : line
|
||
rememberLog(`[renderer console] ${text} (${src}:${lineNo})`)
|
||
})
|
||
|
||
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
|
||
forceBootstrapRepair = false
|
||
connectionPromise = null
|
||
bootstrapState = {
|
||
active: false,
|
||
manifest: null,
|
||
stages: {},
|
||
error: null,
|
||
log: [],
|
||
startedAt: null,
|
||
completedAt: null,
|
||
unsupportedPlatform: null
|
||
}
|
||
return { ok: true }
|
||
})
|
||
ipcMain.handle('hermes:bootstrap:repair', async () => {
|
||
// Forceful repair: drop the bootstrap-complete marker so the next
|
||
// startHermes() re-runs the full installer (refreshing a broken/partial
|
||
// venv), and clear any latched failure + live connection. The renderer
|
||
// reloads afterwards to re-drive the boot flow from scratch.
|
||
rememberLog('[bootstrap] repair requested by renderer; clearing marker + latched failure')
|
||
try {
|
||
if (fileExists(BOOTSTRAP_COMPLETE_MARKER)) {
|
||
fs.rmSync(BOOTSTRAP_COMPLETE_MARKER, { force: true })
|
||
}
|
||
} catch (error) {
|
||
rememberLog(`[bootstrap] failed to remove marker during repair: ${error.message}`)
|
||
}
|
||
bootstrapFailure = null
|
||
// Force the next resolution past both the marker fast-path and the adopt
|
||
// branch so the installer actually re-runs (the whole point of repair).
|
||
forceBootstrapRepair = true
|
||
resetHermesConnection()
|
||
return { ok: true }
|
||
})
|
||
ipcMain.handle('hermes:bootstrap:cancel', async () => {
|
||
// Renderer's Cancel button during first-launch install. Abort the running
|
||
// install script (SIGTERM via the runner's abortSignal). runBootstrap
|
||
// resolves with { cancelled: true }, which surfaces the recovery overlay.
|
||
if (bootstrapAbortController) {
|
||
try {
|
||
bootstrapAbortController.abort()
|
||
} catch {}
|
||
return { ok: true, cancelled: true }
|
||
}
|
||
return { ok: false, cancelled: false }
|
||
})
|
||
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 = options?.directories ? ['openDirectory'] : ['openFile']
|
||
if (options?.multiple !== false) properties.push('multiSelections')
|
||
|
||
let resolvedDefaultPath
|
||
if (options?.defaultPath) {
|
||
try {
|
||
resolvedDefaultPath = path.resolve(String(options.defaultPath))
|
||
} catch {
|
||
resolvedDefaultPath = undefined
|
||
}
|
||
}
|
||
|
||
const result = await dialog.showOpenDialog(mainWindow, {
|
||
title: options?.title || 'Add context',
|
||
defaultPath: resolvedDefaultPath,
|
||
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')
|
||
}
|
||
})
|
||
|
||
// User-configurable default project directory. The renderer reads this on
|
||
// settings mount and seeds the value into the picker; writing back persists
|
||
// it via writeDefaultProjectDir so resolveHermesCwd picks it up on the next
|
||
// session spawn (no app restart needed).
|
||
ipcMain.handle('hermes:setting:defaultProjectDir:get', async () => ({
|
||
dir: readDefaultProjectDir(),
|
||
defaultLabel: path.join(app.getPath('home'), 'hermes-projects')
|
||
}))
|
||
|
||
ipcMain.handle('hermes:setting:defaultProjectDir:set', async (_event, dir) => {
|
||
const next = typeof dir === 'string' && dir.trim() ? dir.trim() : null
|
||
|
||
if (next) {
|
||
try {
|
||
fs.mkdirSync(next, { recursive: true })
|
||
} catch (error) {
|
||
throw new Error(`Could not create directory: ${error.message}`)
|
||
}
|
||
}
|
||
|
||
writeDefaultProjectDir(next)
|
||
|
||
return { dir: next }
|
||
})
|
||
|
||
ipcMain.handle('hermes:setting:defaultProjectDir:pick', async () => {
|
||
const result = await dialog.showOpenDialog({
|
||
title: 'Choose default project directory',
|
||
properties: ['openDirectory', 'createDirectory'],
|
||
defaultPath: readDefaultProjectDir() || app.getPath('home')
|
||
})
|
||
|
||
if (result.canceled || result.filePaths.length === 0) {
|
||
return { canceled: true, dir: null }
|
||
}
|
||
|
||
return { canceled: false, dir: result.filePaths[0] }
|
||
})
|
||
|
||
ipcMain.handle('hermes:fetchLinkTitle', (_event, url) => fetchLinkTitle(url))
|
||
|
||
ipcMain.handle('hermes:logs:reveal', async () => {
|
||
try {
|
||
await fs.promises.mkdir(path.dirname(DESKTOP_LOG_PATH), { recursive: true })
|
||
if (!fileExists(DESKTOP_LOG_PATH)) {
|
||
await fs.promises.appendFile(DESKTOP_LOG_PATH, '')
|
||
}
|
||
shell.showItemInFolder(DESKTOP_LOG_PATH)
|
||
return { ok: true, path: DESKTOP_LOG_PATH }
|
||
} catch (error) {
|
||
return { ok: false, path: DESKTOP_LOG_PATH, error: error.message }
|
||
}
|
||
})
|
||
|
||
ipcMain.handle('hermes:logs:recent', async () => ({ path: DESKTOP_LOG_PATH, lines: hermesLog.slice(-200) }))
|
||
|
||
// 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 }
|
||
})
|
||
|
||
// Resolve the canonical Hermes version (the one `release.py` bumps in
|
||
// hermes_cli/__init__.py + pyproject.toml) so the desktop About panel shows the
|
||
// real Hermes version instead of the Electron app's own package.json version,
|
||
// which historically drifted (stuck at 0.0.2). Falls back to app.getVersion()
|
||
// when the source tree can't be read (e.g. a packaged build without the repo).
|
||
function resolveHermesVersion() {
|
||
try {
|
||
const root = resolveUpdateRoot()
|
||
const initPath = path.join(root, 'hermes_cli', '__init__.py')
|
||
if (fileExists(initPath)) {
|
||
const raw = fs.readFileSync(initPath, 'utf8')
|
||
const match = raw.match(/__version__\s*=\s*["']([^"']+)["']/)
|
||
if (match) {
|
||
return match[1]
|
||
}
|
||
}
|
||
} catch {
|
||
// Fall through to the Electron app version below.
|
||
}
|
||
return app.getVersion()
|
||
}
|
||
|
||
ipcMain.handle('hermes:version', async () => ({
|
||
appVersion: resolveHermesVersion(),
|
||
electronVersion: process.versions.electron,
|
||
nodeVersion: process.versions.node,
|
||
platform: process.platform,
|
||
hermesRoot: resolveUpdateRoot()
|
||
}))
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// macOS first-launch placement: move into /Applications and pin to the Dock
|
||
// ---------------------------------------------------------------------------
|
||
//
|
||
// The DMG and CLI-built apps launch from wherever the user left them (a DMG
|
||
// mount, ~/Downloads, ~/.hermes/...) -- which means Gatekeeper translocation,
|
||
// no Dock tile, and "which icon do I click?" confusion. On first packaged
|
||
// launch we relocate into /Applications (Electron relaunches from there) and,
|
||
// once we're that canonical copy, pin to the Dock. Both macOS-only,
|
||
// packaged-only, best-effort, run at most once.
|
||
|
||
// Move the bundle into /Applications and relaunch. Returns true when a relaunch
|
||
// is underway (caller must stop init). No-op in dev, off macOS, or already in
|
||
// /Applications. `existsAndRunning` -> another copy owns the slot; don't fight
|
||
// it. `exists` -> stale copy; replace it so there's exactly one current app.
|
||
function maybeRelocateToApplications() {
|
||
if (!IS_MAC || !IS_PACKAGED || process.env.HERMES_DESKTOP_NO_AUTO_MOVE === '1') return false
|
||
try {
|
||
if (app.isInApplicationsFolder()) return false
|
||
const moved = app.moveToApplicationsFolder({ conflictHandler: type => type !== 'existsAndRunning' })
|
||
if (moved) rememberLog('[install] relocated into /Applications; relaunching')
|
||
return moved
|
||
} catch (err) {
|
||
rememberLog(`[install] move to /Applications skipped: ${err.message}`)
|
||
return false
|
||
}
|
||
}
|
||
|
||
const DOCK_PINNED_MARKER = 'dock-pinned.json'
|
||
|
||
// Pin the /Applications copy to the Dock once. macOS has no Electron API for
|
||
// this, so we append to com.apple.dock's persistent-apps and restart the Dock.
|
||
// Guarded by a userData marker + membership check so we never duplicate the tile.
|
||
function maybePinToDock() {
|
||
if (!IS_MAC || !IS_PACKAGED || process.env.HERMES_DESKTOP_NO_DOCK_PIN === '1') return
|
||
const marker = path.join(app.getPath('userData'), DOCK_PINNED_MARKER)
|
||
if (fileExists(marker)) return
|
||
|
||
let bundle
|
||
try {
|
||
if (!app.isInApplicationsFolder()) return // don't pin a soon-to-be-stale path
|
||
bundle = runningAppBundle()
|
||
} catch {
|
||
return
|
||
}
|
||
if (!bundle) return
|
||
|
||
// The Dock stores tiles as file-reference URLs (type 15), e.g.
|
||
// file:///Applications/Hermes.app/ -- NOT a raw POSIX path. A type-0/raw-path
|
||
// tile is silently dropped when the Dock rewrites persistent-apps on restart.
|
||
const url = pathToFileURL(bundle.endsWith('/') ? bundle : `${bundle}/`).href
|
||
|
||
const done = (note = {}) => {
|
||
try {
|
||
fs.writeFileSync(marker, JSON.stringify({ bundle, pinnedAt: new Date().toISOString(), ...note }) + '\n')
|
||
} catch {
|
||
// best-effort; we re-check next launch (membership guard dedupes)
|
||
}
|
||
}
|
||
|
||
try {
|
||
const apps = execFileSync('defaults', ['read', 'com.apple.dock', 'persistent-apps'], {
|
||
encoding: 'utf8',
|
||
stdio: ['ignore', 'pipe', 'ignore']
|
||
})
|
||
if (apps.includes(url)) return done({ alreadyPresent: true })
|
||
} catch {
|
||
// persistent-apps may not exist yet; -array-add creates it
|
||
}
|
||
|
||
const tile =
|
||
'<dict><key>tile-data</key><dict><key>file-data</key><dict>' +
|
||
`<key>_CFURLString</key><string>${url}</string><key>_CFURLStringType</key><integer>15</integer>` +
|
||
'</dict></dict></dict>'
|
||
try {
|
||
execFileSync('defaults', ['write', 'com.apple.dock', 'persistent-apps', '-array-add', tile], { stdio: 'ignore' })
|
||
// Flush the write through cfprefsd before restarting the Dock, otherwise the
|
||
// Dock reloads stale prefs and our tile is lost in the race.
|
||
execFileSync('defaults', ['read', 'com.apple.dock', 'persistent-apps'], { stdio: 'ignore' })
|
||
execFileSync('killall', ['Dock'], { stdio: 'ignore' })
|
||
done()
|
||
rememberLog(`[install] pinned to Dock: ${url}`)
|
||
} catch (err) {
|
||
rememberLog(`[install] Dock pin skipped: ${err.message}`)
|
||
}
|
||
}
|
||
|
||
app.whenReady().then(() => {
|
||
// macOS: relocate into /Applications before anything else so setup + state
|
||
// land in the final location; on success this relaunches, so bail here.
|
||
if (maybeRelocateToApplications()) return
|
||
maybePinToDock()
|
||
|
||
if (IS_MAC) {
|
||
Menu.setApplicationMenu(buildApplicationMenu())
|
||
} else {
|
||
Menu.setApplicationMenu(null)
|
||
}
|
||
installMediaPermissions()
|
||
registerMediaProtocol()
|
||
ensureWslWindowsFonts()
|
||
configureSpellChecker()
|
||
registerPowerResumeListeners()
|
||
createWindow()
|
||
|
||
app.on('activate', () => {
|
||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||
})
|
||
})
|
||
|
||
// Seed Chromium's spellchecker with the system locale (falling back to en-US).
|
||
// On macOS Electron uses the native spellchecker which ignores this list, but
|
||
// on Windows/Linux Chromium downloads Hunspell dictionaries on demand and
|
||
// won't enable any without an explicit language.
|
||
function configureSpellChecker() {
|
||
try {
|
||
const defaultSession = session.defaultSession
|
||
|
||
if (!defaultSession || typeof defaultSession.setSpellCheckerLanguages !== 'function') {
|
||
return
|
||
}
|
||
|
||
const available = defaultSession.availableSpellCheckerLanguages || []
|
||
const locale = (app.getLocale && app.getLocale()) || 'en-US'
|
||
const candidates = [locale, locale.split('-')[0], 'en-US', 'en']
|
||
const chosen = candidates.find(lang => available.includes(lang)) || 'en-US'
|
||
|
||
defaultSession.setSpellCheckerLanguages([chosen])
|
||
} catch (error) {
|
||
rememberLog(`Spellchecker setup failed: ${error.message}`)
|
||
}
|
||
}
|
||
|
||
app.on('before-quit', () => {
|
||
// Quitting mid-install should stop the installer, not orphan it.
|
||
if (bootstrapAbortController) {
|
||
try {
|
||
bootstrapAbortController.abort()
|
||
} catch {}
|
||
}
|
||
|
||
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()
|
||
})
|