* fix(desktop): triage 24 GUI quality-of-life fixes across sidebar, composer, tool cards, messaging, and platform plumbing
A grab-bag of high-leverage UX fixes plus a few backend touches that the
GUI needs to behave correctly on Windows.
Sidebar / sessions
- Decrement $sessionsTotal on delete + archive so "Load N more" stops
claiming removed rows are still on the server.
- Hide the "Group by workspace" toggle when no unpinned sessions exist.
- Accept Cmd/Ctrl+N as a "new session" accelerator (in addition to bare
Shift+N), and render the kbd hint per-platform.
- Switch the statusbar to overflow-x-clip so untitled sessions don't
paint a horizontal scrollbar at the bottom of the window.
Messaging + Cron
- Add [-webkit-app-region: no-drag] to the page-search input so clicks
reach the field instead of routing to the OS window-drag handler.
- Replace single-letter PlatformAvatar with brand glyphs from
@icons-pack/react-simple-icons (telegram, discord, matrix, signal,
whatsapp, mattermost, wechat, qq, ...). Letter monogram fallback for
Slack / Dingtalk / Feishu / WeCom (removed from Simple Icons at brand
owner request).
- Drop the duplicate "Create first cron" button in the empty state.
Composer
- Dedupe pasted images by (name, size, lastModified, type) instead of
Blob identity; Chromium hands us the same screenshot via both
clipboard.items and clipboard.files with fresh File instances.
- Enable spellcheck on the contentEditable, configure Chromium's
spellchecker with the system locale on whenReady, and add
replaceMisspelling + "Add to dictionary" entries to the context menu.
- Render user messages through a minimal markdown pipeline (inline
backtick code + fenced ``` blocks) while keeping @file:/@image:
directive chips intact.
- max-h-[60vh] overflow-y-auto + collisionPadding on the prompt-snippet
submenu.
- Bake cursor-pointer into the <Button> primitive (with
disabled:cursor-default) and into titlebarButtonClass.
Dialogs + tabs + version
- Default DialogContent now has max-h-[85vh] overflow-y-auto so long
bodies scroll instead of falling off-screen.
- Right-rail preview tabs close on middle-click (button === 1), with an
onMouseDown swallow to suppress Chromium autoscroll.
- New refreshDesktopVersion() helper called from About mount, after
every update check, and on throttled window focus so About reflects
the just-installed binary.
Keys + Artifacts + Terminal
- Drop the global "Show advanced" toggle in KeysSettings. Provider
groups now default-expand when they have any key set.
- Extend openExternalUrl to handle file:// via shell.openPath, with
showItemInFolder fallback when the OS can't open the file.
- New lib/ansi.ts SGR parser + <AnsiText> component, applied to
terminal/execute_code tool output.
- ToolView gained stdout / stderr / rendersAnsi; tool-fallback renders
the two streams as separate labeled blocks with stderr in a neutral
tone (not destructive — many CLIs log info on stderr).
- Drop 'stderr' from ERROR_MSG_KEYS in tool-result-summary.
Paths + platform
- resolveHermesCwd skips process.cwd() when packaged and prefers a
user-configurable default project directory.
- New hermes:setting:defaultProjectDir:{get,set,pick} IPC handlers +
preload bridge + global.d.ts typing + a "Default project directory"
row in Sessions settings.
- FileOperations.delete_path(path, recursive=True) on the abstract
base; ShellFileOperations.delete_file rewritten to run a cross-
platform python3 -c snippet so deletes work on Windows shells (which
have no rm/rm -rf). Fallback to `python` when `python3` isn't on PATH.
- README troubleshooting block split into macOS/Linux + Windows
PowerShell recipes.
- Tightened renderer favicon links in index.html + added color-scheme
and theme-color meta.
Backend lifecycle (renderer-side mitigation)
- New noteSessionActivity() heartbeat + session.ts watchdog: an
8-minute silence on the stream auto-clears stuck $workingSessionIds
entries so "Session Busy" never gets permanently wedged. Wired into
useSessionStateCache so every state update refreshes the timer.
i18n spike
- docs/desktop-i18n-rfc.md scoping a future language-switcher PR
(recommends react-intl, audits IME/RTL/CJK in the composer +
chat bubbles, 4-PR rollout plan, ~3-4 eng-weeks for the first
non-English locale).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): replace native OS scrollbar in portaled dropdown menus
Radix's DropdownMenuPrimitive.Portal renders content under document.body,
outside the `.scrollbar-dt` scope on #root. Whenever a menu's max-height
clipped its content (even by a pixel — common for the composer "+" menu
that opens upward near the bottom of the window), the user saw the OS's
chunky native scrollbar painted across the whole menu.
Bake a thin, slot-styled scrollbar onto DropdownMenuContent and
DropdownMenuSubContent via [scrollbar-width:thin] + WebKit pseudo-element
arbitrary variants. The submenu also gets a max-h tied to
--radix-dropdown-menu-content-available-height so long snippet lists scroll
cleanly instead of running off the bottom of the viewport. Drop the now-
redundant max-h-[60vh] override on the prompt-snippet submenu.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): unbork dropdown menu — submenu opens, parent isn't a circle
Two regressions from the previous dropdown-scrollbar fix:
- The parent menu rendered as a rounded oval. Long Tailwind v4 arbitrary-
variant strings like [&::-webkit-scrollbar-thumb]:rounded-full inside a
cn() call were being mis-resolved so the `rounded-full` leaked onto the
menu container itself. Replaced the whole tower of arbitrary variants
with a real `.dt-portal-scrollbar` class in styles.css that mirrors what
`.scrollbar-dt` already does for #root descendants. Plain CSS, no Tailwind
parser ambiguity.
- The Prompt snippets submenu didn't open. Radix publishes
--radix-dropdown-menu-content-available-height on Content but NOT on
SubContent, so the `max-h` bound to that variable computed to 0 and the
submenu collapsed to zero height. Switched SubContent to a fixed
max-h-80 (≈20rem) which is plenty for a snippet list and never collapses.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): promote prompt snippets from Radix submenu to a real Dialog
The submenu refused to open when the parent dropdown was anchored at the
bottom of the window (composer "+" button) — Radix's collision detection +
SubContent positioning was fighting us. Rather than keep tuning side /
sideOffset / collisionPadding / max-h until something stuck, replace the
DropdownMenuSub with a clicked DropdownMenuItem that opens a proper
Dialog.
Side benefits over the submenu:
- Each snippet gets a description line, so a glance is enough to pick one.
- Focus management is handled by Dialog automatically.
- Easy to grow (search, custom user snippets, categories) without
another round of Radix positioning bugs.
Also extract types/interfaces to the bottom of the file per workspace
convention.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): move cron 'New cron' button off the top bar into the body
Reverses the previous direction on cron empty-state dedup. The body
button is more discoverable for first-time users (it's anchored next to
the "No scheduled jobs yet" copy that explains the feature) and frees
the top bar from a global CTA that wasn't pulling its weight.
- Empty (zero jobs): EmptyState renders the "Create first cron" button
again, like the original design.
- Empty (search filtered out all jobs): no button, just "Try a broader
search query" copy.
- Has jobs: small inline header above the list shows `N/M active` plus
a single "New cron" button (right-aligned). The rows themselves
already cover edit/pause/trigger/delete, so this is the only "create"
affordance.
Also drop the dead `<div className="hidden">…</div>` enabledCount line
the previous patch left behind; the count is now visible in the new
header instead of hidden.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): address Copilot review on PR 37536
- sessions-settings: guard the WHOLE bridge call rather than chaining
`?.settings.foo().then(...)` — the latter throws when
`window.hermesDesktop` is undefined (non-Electron / Vitest contexts)
because the chain short-circuits to `undefined.then(...)`.
- file_operations: drop `Path.unlink(missing_ok=True)` (Py>=3.8) so the
generated delete snippet still works on remote backends running
Python 3.7. The existing FileNotFoundError handler covers the same
case and works back to 3.4.
- ansi.test.ts: add focused Vitest coverage for the SGR parser
(basic/bright colors, bold toggles, default-fg reset, coalescing,
256-color / truecolor arg consumption, non-SGR CSI drop, empty SGR
full-reset) so future refactors can't silently regress terminal
rendering.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop/updates): swallow refreshDesktopVersion bridge errors
`refreshDesktopVersion()` is called best-effort with `void` from
`checkUpdates()`, `startUpdatePoller()`, and the window focus handler.
If the IPC bridge rejects (main process shutting down during reload,
bridge not yet ready on first paint), the rejection surfaces as an
unhandled promise rejection in the renderer. Wrap the call in try/catch
and return null on failure so callers can keep the existing
fire-and-forget pattern safely.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore(desktop): drop work duplicated by other in-flight PRs
- composer/text-utils.ts: revert paste-image dedupe — PR #37596
ships the same fix with a cleaner content-key approach and a
Vitest file (text-utils.test.ts). Letting that PR own the change.
- docs/desktop-i18n-rfc.md: delete the i18n scoping RFC — PR #37568
has already shipped a working i18n surface (homegrown nanostores
`t()` helper over en/zh dictionaries), so the RFC's framework
recommendation (`react-intl`) is now obsolete and would just
contradict the implementation that's actually landing.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
4007 lines
133 KiB
JavaScript
4007 lines
133 KiB
JavaScript
const {
|
||
app,
|
||
BrowserWindow,
|
||
Menu,
|
||
Notification,
|
||
clipboard,
|
||
dialog,
|
||
ipcMain,
|
||
nativeImage,
|
||
nativeTheme,
|
||
net: electronNet,
|
||
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 { 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()
|
||
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
|
||
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 })
|
||
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 })
|
||
|
||
// 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, ['--update'], {
|
||
detached: true,
|
||
stdio: 'ignore',
|
||
windowsHide: false
|
||
})
|
||
child.unref()
|
||
|
||
rememberLog(`[updates] launched updater: ${updater} --update; 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)
|
||
}
|
||
|
||
// 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>", // what install.ps1 was driven against
|
||
// pinnedBranch: "<branch name>" | null,
|
||
// completedAt: "<ISO 8601>",
|
||
// desktopVersion: "<app.getVersion()>" // for forensics
|
||
// }
|
||
function readBootstrapMarker() {
|
||
return readJson(BOOTSTRAP_COMPLETE_MARKER)
|
||
}
|
||
|
||
function isBootstrapComplete() {
|
||
const marker = readBootstrapMarker()
|
||
if (!marker || typeof marker !== 'object') return false
|
||
if (marker.schemaVersion !== BOOTSTRAP_MARKER_SCHEMA_VERSION) return false
|
||
if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) return false
|
||
// We DELIBERATELY do NOT verify that the checkout is currently at the
|
||
// pinned commit -- users update via the in-app update path or `hermes
|
||
// update`, which moves HEAD legitimately. The marker just attests "we
|
||
// ran the bootstrap successfully at least once." 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 isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(getVenvPython(VENV_ROOT))
|
||
}
|
||
|
||
function writeBootstrapMarker(payload) {
|
||
fs.mkdirSync(path.dirname(BOOTSTRAP_COMPLETE_MARKER), { recursive: true })
|
||
const merged = {
|
||
schemaVersion: BOOTSTRAP_MARKER_SCHEMA_VERSION,
|
||
pinnedCommit: payload.pinnedCommit || null,
|
||
pinnedBranch: payload.pinnedBranch || null,
|
||
completedAt: new Date().toISOString(),
|
||
desktopVersion: app.getVersion()
|
||
}
|
||
fs.writeFileSync(BOOTSTRAP_COMPLETE_MARKER, JSON.stringify(merged, null, 2) + '\n', 'utf8')
|
||
return merged
|
||
}
|
||
|
||
function resolveWebDist() {
|
||
const override = process.env.HERMES_DESKTOP_WEB_DIST
|
||
if (override && directoryExists(path.resolve(override))) return path.resolve(override)
|
||
|
||
const unpackedDist = path.join(unpackedPathFor(APP_ROOT), 'dist')
|
||
if (directoryExists(unpackedDist)) return unpackedDist
|
||
|
||
return path.join(APP_ROOT, 'dist')
|
||
}
|
||
|
||
function resolveRendererIndex() {
|
||
const candidates = [path.join(APP_ROOT, 'dist', 'index.html'), path.join(resolveWebDist(), 'index.html')]
|
||
return candidates.find(fileExists) || candidates[0]
|
||
}
|
||
|
||
function resolveHermesCwd() {
|
||
// 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)
|
||
}
|
||
|
||
// 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.')
|
||
// 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')
|
||
}
|
||
|
||
function getAppIconPath() {
|
||
return APP_ICON_PATHS.find(fileExists)
|
||
}
|
||
|
||
function sendOpenUpdatesRequested() {
|
||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||
const { webContents } = mainWindow
|
||
if (!webContents || webContents.isDestroyed()) return
|
||
webContents.send('hermes:open-updates')
|
||
if (!mainWindow.isVisible()) mainWindow.show()
|
||
mainWindow.focus()
|
||
}
|
||
|
||
function sendWindowStateChanged(nextIsFullscreen) {
|
||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||
const { webContents } = mainWindow
|
||
if (!webContents || webContents.isDestroyed()) return
|
||
const state = getWindowState()
|
||
|
||
if (typeof nextIsFullscreen === 'boolean') {
|
||
state.isFullscreen = nextIsFullscreen
|
||
}
|
||
|
||
webContents.send('hermes:window-state-changed', state)
|
||
}
|
||
|
||
function buildApplicationMenu() {
|
||
const template = []
|
||
const checkForUpdatesItem = {
|
||
label: 'Check for Updates…',
|
||
click: () => sendOpenUpdatesRequested()
|
||
}
|
||
if (IS_MAC) {
|
||
template.push({
|
||
label: APP_NAME,
|
||
submenu: [
|
||
{ role: 'about', label: `About ${APP_NAME}` },
|
||
checkForUpdatesItem,
|
||
{ type: 'separator' },
|
||
{ role: 'services' },
|
||
{ type: 'separator' },
|
||
{ role: 'hide' },
|
||
{ role: 'hideOthers' },
|
||
{ role: 'unhide' },
|
||
{ type: 'separator' },
|
||
{ role: 'quit' }
|
||
]
|
||
})
|
||
}
|
||
|
||
template.push({
|
||
label: 'File',
|
||
submenu: [
|
||
IS_MAC
|
||
? {
|
||
accelerator: 'CommandOrControl+W',
|
||
click: () => {
|
||
if (previewShortcutActive) {
|
||
sendClosePreviewRequested()
|
||
} else {
|
||
mainWindow?.close()
|
||
}
|
||
},
|
||
label: 'Close'
|
||
}
|
||
: { role: 'quit' }
|
||
]
|
||
})
|
||
template.push({
|
||
label: 'Edit',
|
||
submenu: [
|
||
{ role: 'undo' },
|
||
{ role: 'redo' },
|
||
{ type: 'separator' },
|
||
{ role: 'cut' },
|
||
{ role: 'copy' },
|
||
{ role: 'paste' },
|
||
{ role: 'delete' },
|
||
{ role: 'selectAll' }
|
||
]
|
||
})
|
||
template.push({
|
||
label: 'View',
|
||
submenu: [
|
||
{ role: 'reload' },
|
||
{ role: 'forceReload' },
|
||
{ role: 'toggleDevTools' },
|
||
{ type: 'separator' },
|
||
{ role: 'resetZoom' },
|
||
{ role: 'zoomIn' },
|
||
{ role: 'zoomOut' },
|
||
{ type: 'separator' },
|
||
{ role: 'togglefullscreen' }
|
||
]
|
||
})
|
||
template.push({
|
||
label: 'Window',
|
||
submenu: IS_MAC
|
||
? [{ role: 'minimize' }, { role: 'zoom' }, { role: 'front' }]
|
||
: [{ role: 'minimize' }, { role: 'close' }]
|
||
})
|
||
template.push({
|
||
label: 'Help',
|
||
role: 'help',
|
||
submenu: [checkForUpdatesItem]
|
||
})
|
||
|
||
return Menu.buildFromTemplate(template)
|
||
}
|
||
|
||
function toggleDevTools(window) {
|
||
// DevTools is enabled in packaged builds so users can diagnose renderer
|
||
// issues without needing a dev build. Trade-off: tiny attack surface
|
||
// increase versus a much better support story when WS connection or
|
||
// CSP issues surface in the field.
|
||
const { webContents } = window
|
||
if (webContents.isDevToolsOpened()) {
|
||
webContents.closeDevTools()
|
||
} else {
|
||
webContents.openDevTools({ mode: 'detach' })
|
||
}
|
||
}
|
||
|
||
function installDevToolsShortcut(window) {
|
||
// F12 / Cmd+Opt+I works in both dev and packaged builds.
|
||
window.webContents.on('before-input-event', (event, input) => {
|
||
const key = input.key.toLowerCase()
|
||
const isInspectShortcut =
|
||
input.key === 'F12' ||
|
||
(IS_MAC && input.meta && input.alt && key === 'i') ||
|
||
(!IS_MAC && input.control && input.shift && key === 'i')
|
||
if (!isInspectShortcut) return
|
||
event.preventDefault()
|
||
toggleDevTools(window)
|
||
})
|
||
}
|
||
|
||
function installPreviewShortcut(window) {
|
||
window.webContents.on('before-input-event', (event, input) => {
|
||
const key = String(input.key || '').toLowerCase()
|
||
const isPreviewCloseShortcut = key === 'w' && (IS_MAC ? input.meta : input.control) && !input.alt && !input.shift
|
||
|
||
if (!isPreviewCloseShortcut || !previewShortcutActive) return
|
||
|
||
event.preventDefault()
|
||
sendClosePreviewRequested()
|
||
})
|
||
}
|
||
|
||
function installContextMenu(window) {
|
||
window.webContents.on('context-menu', (_event, params) => {
|
||
const template = []
|
||
const hasSelection = Boolean(params.selectionText?.trim())
|
||
const hasImage = params.mediaType === 'image' && Boolean(params.srcURL)
|
||
const hasLink = Boolean(params.linkURL)
|
||
const isEditable = Boolean(params.isEditable)
|
||
|
||
if (hasImage) {
|
||
template.push(
|
||
{
|
||
label: 'Open Image',
|
||
click: () => {
|
||
if (params.srcURL && !params.srcURL.startsWith('data:')) {
|
||
openExternalUrl(params.srcURL)
|
||
}
|
||
},
|
||
enabled: !params.srcURL.startsWith('data:')
|
||
},
|
||
{
|
||
label: 'Copy Image',
|
||
click: () => {
|
||
void copyImageFromUrl(params.srcURL).catch(error => rememberLog(`Copy image failed: ${error.message}`))
|
||
}
|
||
},
|
||
{
|
||
label: 'Copy Image Address',
|
||
click: () => clipboard.writeText(params.srcURL)
|
||
},
|
||
{
|
||
label: 'Save Image As...',
|
||
click: () => {
|
||
void saveImageFromUrl(params.srcURL).catch(error => rememberLog(`Save image failed: ${error.message}`))
|
||
}
|
||
}
|
||
)
|
||
}
|
||
|
||
if (hasLink) {
|
||
if (template.length) template.push({ type: 'separator' })
|
||
template.push(
|
||
{
|
||
label: 'Open Link',
|
||
click: () => openExternalUrl(params.linkURL)
|
||
},
|
||
{
|
||
label: 'Copy Link',
|
||
click: () => clipboard.writeText(params.linkURL)
|
||
}
|
||
)
|
||
}
|
||
|
||
// 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)
|
||
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
|
||
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
|
||
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()
|
||
}))
|
||
|
||
app.whenReady().then(() => {
|
||
if (IS_MAC) {
|
||
Menu.setApplicationMenu(buildApplicationMenu())
|
||
} else {
|
||
Menu.setApplicationMenu(null)
|
||
}
|
||
installMediaPermissions()
|
||
registerMediaProtocol()
|
||
ensureWslWindowsFonts()
|
||
configureSpellChecker()
|
||
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()
|
||
})
|