const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, nativeTheme, net: electronNet, powerMonitor, protocol, safeStorage, session, shell, systemPreferences } = require('electron') const crypto = require('node:crypto') const fs = require('node:fs') const http = require('node:http') const https = require('node:https') const net = require('node:net') const path = require('node:path') const { fileURLToPath, pathToFileURL } = require('node:url') const { execFileSync, spawn } = require('node:child_process') const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs') const { runBootstrap } = require('./bootstrap-runner.cjs') const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs') const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') const { authModeFromStatus, buildGatewayWsUrl, buildGatewayWsUrlWithTicket, cookiesHaveSession, cookiesHaveLiveSession, normalizeRemoteBaseUrl, resolveAuthMode, resolveTestWsUrl, tokenPreview } = require('./connection-config.cjs') const { DATA_URL_READ_MAX_BYTES, DEFAULT_FETCH_TIMEOUT_MS, TEXT_PREVIEW_SOURCE_MAX_BYTES, encryptDesktopSecret: encryptDesktopSecretStrict, resolveReadableFileForIpc, resolveTimeoutMs } = require('./hardening.cjs') let nodePty = null try { nodePty = require('node-pty') } catch { // Packaged builds set `files:` in package.json, which excludes node_modules // from the asar. Workspace dedup also hoists this native dep to the repo // root's node_modules, out of reach of electron-builder's collector. We // ship a minimal copy under resources/native-deps/ via extraResources + // scripts/stage-native-deps.cjs; resolve from there when the normal // require() fails. Dev mode never reaches this branch -- the hoisted // resolve succeeds via Node's normal module lookup. try { const path = require('node:path') const resourcesPath = process.resourcesPath if (resourcesPath) { nodePty = require(path.join(resourcesPath, 'native-deps', 'node-pty')) } } catch { nodePty = null } } const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR if (USER_DATA_OVERRIDE) { const resolvedUserData = path.resolve(USER_DATA_OVERRIDE) fs.mkdirSync(resolvedUserData, { recursive: true }) app.setPath('userData', resolvedUserData) } const PORT_FLOOR = 9120 const PORT_CEILING = 9199 const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER const IS_PACKAGED = app.isPackaged const IS_MAC = process.platform === 'darwin' const IS_WINDOWS = process.platform === 'win32' const IS_WSL = isWslEnvironment() const APP_ROOT = app.getAppPath() // Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU // compositor flicker — accelerated layers can't be presented cleanly over the // wire, so the window flashes during scroll/streaming/animation. Local // Windows/macOS (and WSLg, which renders locally via vGPU) composite on the // GPU and never see it. Fall back to software rendering when a remote display // is detected; it's rock-steady over the wire and the CPU cost is negligible // next to the connection's latency. Must run before app `ready` — these // switches only apply pre-launch. Override with HERMES_DESKTOP_DISABLE_GPU // (1/true → always disable, 0/false → keep GPU on). const REMOTE_DISPLAY_REASON = detectRemoteDisplay() if (REMOTE_DISPLAY_REASON) { app.disableHardwareAcceleration() // Belt-and-suspenders for X11/VNC, where the Viz compositor can still glitch // with only --disable-gpu: force compositing onto the CPU too. app.commandLine.appendSwitch('disable-gpu-compositing') console.log( `[hermes] remote display detected (${REMOTE_DISPLAY_REASON}); disabling GPU hardware acceleration to prevent flicker` ) } const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..') // Build-time install stamp -- the git ref this .exe was built against. // // Written by apps/desktop/scripts/write-build-stamp.cjs during `npm run build` // and bundled into packaged apps via electron-builder's extraResources entry, // so the runtime stamp ends up at process.resourcesPath/install-stamp.json // after install. The bootstrap runner (Phase 1D) reads it to know which // commit to clone when running install.ps1 stages at first launch. // // Returns null when the file is missing (dev runs from a checkout where // build hasn't been invoked, or schema mismatch). Callers must handle null. // // Schema: // { schemaVersion: 1, commit, branch, builtAt, dirty, source } const INSTALL_STAMP_SCHEMA_VERSION = 1 function loadInstallStamp() { // Try packaged location first (resources/install-stamp.json), then the // dev/local build output (apps/desktop/build/install-stamp.json) so // someone running `npm run start` after a local `npm run build` also // sees a stamp without needing a packaged build. const candidates = [ process.resourcesPath ? path.join(process.resourcesPath, 'install-stamp.json') : null, path.join(APP_ROOT, 'build', 'install-stamp.json') ].filter(Boolean) for (const p of candidates) { try { const raw = fs.readFileSync(p, 'utf8') const parsed = JSON.parse(raw) if (parsed && typeof parsed === 'object' && typeof parsed.commit === 'string' && parsed.commit.length >= 7) { if (parsed.schemaVersion !== INSTALL_STAMP_SCHEMA_VERSION) { console.warn( `[hermes] install-stamp.json schemaVersion ${parsed.schemaVersion} != expected ${INSTALL_STAMP_SCHEMA_VERSION}; ignoring` ) continue } return Object.freeze({ schemaVersion: parsed.schemaVersion, commit: parsed.commit, branch: parsed.branch || null, builtAt: parsed.builtAt || null, dirty: Boolean(parsed.dirty), source: parsed.source || null, path: p }) } } catch { // Either ENOENT or malformed JSON; try the next candidate } } return null } const INSTALL_STAMP = loadInstallStamp() if (INSTALL_STAMP) { console.log( `[hermes] install stamp: ${INSTALL_STAMP.commit.slice(0, 12)}${INSTALL_STAMP.branch ? ` (${INSTALL_STAMP.branch})` : ''}${INSTALL_STAMP.dirty ? ' [DIRTY]' : ''} from ${INSTALL_STAMP.source || 'unknown'}` ) } else if (IS_PACKAGED) { // Dev builds without a stamp are normal; packaged builds without one // mean the bootstrap won't know what to clone. Surface clearly. console.error( '[hermes] WARNING: no install-stamp.json found in packaged build. First-launch bootstrap will not have a pinned ref to install.' ) } // HERMES_HOME — the user-facing root for everything Hermes-related. Mirrors // scripts/install.ps1's $HermesHome and scripts/install.sh's $HERMES_HOME. // // Defaults: // Windows: %LOCALAPPDATA%\hermes (matches install.ps1) // macOS / Linux: ~/.hermes (matches install.sh) // // Special case for Windows: if the user has a legacy ~/.hermes directory // (e.g., from a prior pip install or a manual setup) AND no // %LOCALAPPDATA%\hermes yet, prefer the legacy path so we don't orphan their // existing config / sessions / .env. New installs go to %LOCALAPPDATA%. // // HERMES_DESKTOP_USER_DATA_DIR (used by test:desktop:fresh) puts the sandbox // HERMES_HOME beneath the throwaway userData dir so a fresh-install run never // touches the user's real ~/.hermes / %LOCALAPPDATA%\hermes. function resolveHermesHome() { if (process.env.HERMES_HOME) return path.resolve(process.env.HERMES_HOME) if (USER_DATA_OVERRIDE) return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home') if (IS_WINDOWS && process.env.LOCALAPPDATA) { const localappdata = path.join(process.env.LOCALAPPDATA, 'hermes') const legacy = path.join(app.getPath('home'), '.hermes') // Migrate transparently to LOCALAPPDATA, but honour an existing legacy // ~/.hermes setup (no LOCALAPPDATA install yet) so users don't lose state. if (!directoryExists(localappdata) && directoryExists(legacy)) return legacy return localappdata } return path.join(app.getPath('home'), '.hermes') } const HERMES_HOME = resolveHermesHome() // ACTIVE_HERMES_ROOT — the canonical mutable Hermes install. Same path // install.ps1 / install.sh use, so a desktop-only user and a CLI-only user end // up with identical layouts and can share one install. const ACTIVE_HERMES_ROOT = path.join(HERMES_HOME, 'hermes-agent') // VENV_ROOT — venv lives inside the repo, exactly like install.ps1 does it. const VENV_ROOT = path.join(ACTIVE_HERMES_ROOT, 'venv') // BOOTSTRAP_COMPLETE_MARKER — written by the first-launch bootstrap runner // (Phase 1D) after install.ps1 has completed all stages and the user has // finished initial configuration. Presence of this marker means the install // is in a known-good state and we can skip the bootstrap flow on subsequent // boots, going straight to `resolveHermesBackend()`. Missing or stale marker // means we re-run the bootstrap; install.ps1's stages are idempotent so a // re-run on an already-good install just discovers everything in place. // // We deliberately put the marker INSIDE ACTIVE_HERMES_ROOT (not alongside) // so that deleting the checkout to start fresh also deletes the marker -- // avoids the confusing "marker exists but checkout is gone" state. const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete') const BOOTSTRAP_MARKER_SCHEMA_VERSION = 1 const DESKTOP_CONNECTION_CONFIG_PATH = path.join(app.getPath('userData'), 'connection.json') const DESKTOP_UPDATE_CONFIG_PATH = path.join(app.getPath('userData'), 'updates.json') // active-profile.json records which Hermes profile the desktop launches its // local backend as. When set, startHermes() passes `hermes --profile // dashboard …`, which deterministically pins HERMES_HOME (see // _apply_profile_override in hermes_cli/main.py) and bypasses the sticky // ~/.hermes/active_profile file. Unset (null) preserves the legacy behavior: // no --profile flag, so the backend honors active_profile / default. const DESKTOP_PROFILE_CONFIG_PATH = path.join(app.getPath('userData'), 'active-profile.json') // Mirrors hermes_cli.profiles._PROFILE_ID_RE so we never hand the backend a // value its profile resolver would reject and exit on. const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/ // 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