diff --git a/apps/bootstrap-installer/src-tauri/src/bootstrap.rs b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs index a8fcd656b8a..1d70ec59a61 100644 --- a/apps/bootstrap-installer/src-tauri/src/bootstrap.rs +++ b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs @@ -1,6 +1,6 @@ //! Bootstrap orchestration. //! -//! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.cjs`. +//! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.ts`. //! Drives install.ps1 / install.sh stage-by-stage, emits progress events //! over the Tauri `bootstrap` channel, writes a forensic log to //! HERMES_HOME/logs/bootstrap-.log. diff --git a/apps/bootstrap-installer/src-tauri/src/events.rs b/apps/bootstrap-installer/src-tauri/src/events.rs index e00105013be..afadbf8e868 100644 --- a/apps/bootstrap-installer/src-tauri/src/events.rs +++ b/apps/bootstrap-installer/src-tauri/src/events.rs @@ -1,6 +1,6 @@ //! Event types streamed from Rust → React. //! -//! These mirror `apps/desktop/electron/bootstrap-runner.cjs`'s event shape +//! These mirror `apps/desktop/electron/bootstrap-runner.ts`'s event shape //! 1:1 so the React installer code can be roughly identical to the Electron //! install-overlay we'll replace. //! diff --git a/apps/bootstrap-installer/src-tauri/src/install_script.rs b/apps/bootstrap-installer/src-tauri/src/install_script.rs index 217ee9fef5a..67a114408f8 100644 --- a/apps/bootstrap-installer/src-tauri/src/install_script.rs +++ b/apps/bootstrap-installer/src-tauri/src/install_script.rs @@ -8,7 +8,7 @@ //! 3. Network: download from GitHub raw at a pinned commit or branch. //! Commit pins are immutable; branch pins are HEAD-tracking. //! -//! Mirrors `apps/desktop/electron/bootstrap-runner.cjs`'s `resolveInstallScript`, +//! Mirrors `apps/desktop/electron/bootstrap-runner.ts`'s `resolveInstallScript`, //! but the dev-checkout resolution is driven by an env var rather than the //! Electron app's APP_ROOT/../.. trick, because Hermes-Setup.exe is meant //! to live OUTSIDE any repo checkout. @@ -64,7 +64,7 @@ impl ScriptKind { } /// Validates a string looks like a git SHA (7+ hex chars). Mirrors -/// `STAMP_COMMIT_RE` from bootstrap-runner.cjs. +/// `STAMP_COMMIT_RE` from bootstrap-runner.ts. fn is_valid_commit(s: &str) -> bool { let len = s.len(); (7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit()) diff --git a/apps/bootstrap-installer/src-tauri/src/paths.rs b/apps/bootstrap-installer/src-tauri/src/paths.rs index 99ad16f6b88..7c64c91cf6e 100644 --- a/apps/bootstrap-installer/src-tauri/src/paths.rs +++ b/apps/bootstrap-installer/src-tauri/src/paths.rs @@ -150,7 +150,7 @@ fn repair_macos_installer_helper(path: &Path) { fn repair_macos_installer_helper(_path: &Path) {} /// Where install.ps1 writes the bootstrap-complete marker (existence-only file -/// the Electron app also checks). Per main.cjs: +/// the Electron app also checks). Per main.ts: /// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete') /// We don't always know ACTIVE_HERMES_ROOT until install.ps1 reports it, so /// this is a probe helper, not a definitive path. diff --git a/apps/bootstrap-installer/src-tauri/src/powershell.rs b/apps/bootstrap-installer/src-tauri/src/powershell.rs index f37a3c68b36..04694c113b5 100644 --- a/apps/bootstrap-installer/src-tauri/src/powershell.rs +++ b/apps/bootstrap-installer/src-tauri/src/powershell.rs @@ -1,6 +1,6 @@ //! Drives PowerShell (Windows) or bash (Unix) for install.ps1 / install.sh. //! -//! Port of `spawnPowerShell` from bootstrap-runner.cjs, with the same +//! Port of `spawnPowerShell` from bootstrap-runner.ts, with the same //! line-buffered stdout/stderr streaming + cancellation semantics. //! //! On Windows we pass `-NoProfile -ExecutionPolicy Bypass -File ' @@ -39,9 +37,11 @@ test('dashboardIndexUrl preserves dashboard path prefixes', () => { test('resolveServedDashboardToken uses the served token and logs when it differs', async () => { const logs = [] + const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', { fetchText: async url => { assert.equal(url, 'http://127.0.0.1:9120/') + return '' }, rememberLog: line => logs.push(line) @@ -100,8 +100,9 @@ test('isForeignBackendToken only flags a mismatched token from a dead child', () [{ servedToken: null, spawnToken: 'mine', childAlive: false }, false], [{ servedToken: '', spawnToken: 'mine', childAlive: false }, false] ] + for (const [input, expected] of cases) { - assert.equal(isForeignBackendToken(input), expected, JSON.stringify(input)) + assert.equal(isForeignBackendToken(input as any), expected, JSON.stringify(input)) } }) @@ -128,6 +129,7 @@ test('adoptServedDashboardToken refuses a foreign token when our child is dead', test('adoptServedDashboardToken falls back to the spawn token when the fetch fails', async () => { const logs = [] + const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', { childAlive: () => true, fetchText: async () => { diff --git a/apps/desktop/electron/dashboard-token.cjs b/apps/desktop/electron/dashboard-token.ts similarity index 92% rename from apps/desktop/electron/dashboard-token.cjs rename to apps/desktop/electron/dashboard-token.ts index 1a9ca50ad9c..4f4d3b298d4 100644 --- a/apps/desktop/electron/dashboard-token.cjs +++ b/apps/desktop/electron/dashboard-token.ts @@ -9,29 +9,35 @@ const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000 -async function fetchPublicText(url, options = {}) { +async function fetchPublicText(url, options: any = {}) { const { protocol } = new URL(url) + if (protocol !== 'http:' && protocol !== 'https:') { throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`) } const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS + const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => { if (error.name === 'TimeoutError') { throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`) } + throw error }) + const text = await res.text() - if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`) + if (!res.ok) {throw new Error(`${res.status}: ${text || res.statusText}`)} return text } function extractInjectedDashboardToken(html) { const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || '')) - if (!match) return null + + if (!match) {return null} + try { return JSON.parse(match[1]) } catch { @@ -43,11 +49,13 @@ function dashboardIndexUrl(baseUrl) { return `${String(baseUrl || '').replace(/\/+$/, '')}/` } -async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) { +async function resolveServedDashboardToken(baseUrl, fallbackToken, options: any = {}) { const fetchText = options.fetchText || fetchPublicText + const html = await fetchText(dashboardIndexUrl(baseUrl), { timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS }) + const servedToken = extractInjectedDashboardToken(html) if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') { @@ -76,6 +84,7 @@ function isForeignBackendToken({ servedToken, spawnToken, childAlive }) { async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) { const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => { options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`) + return spawnToken }) @@ -88,12 +97,10 @@ async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, labe return servedToken } -module.exports = { - DEFAULT_TOKEN_FETCH_TIMEOUT_MS, - adoptServedDashboardToken, +export { adoptServedDashboardToken, dashboardIndexUrl, + DEFAULT_TOKEN_FETCH_TIMEOUT_MS, extractInjectedDashboardToken, fetchPublicText, isForeignBackendToken, - resolveServedDashboardToken -} + resolveServedDashboardToken } diff --git a/apps/desktop/electron/desktop-uninstall.test.cjs b/apps/desktop/electron/desktop-uninstall.test.ts similarity index 97% rename from apps/desktop/electron/desktop-uninstall.test.cjs rename to apps/desktop/electron/desktop-uninstall.test.ts index 15a864b7c4f..3a4cdbbfa45 100644 --- a/apps/desktop/electron/desktop-uninstall.test.cjs +++ b/apps/desktop/electron/desktop-uninstall.test.ts @@ -1,7 +1,7 @@ /** - * Tests for electron/desktop-uninstall.cjs. + * Tests for electron/desktop-uninstall.ts. * - * Run with: node --test electron/desktop-uninstall.test.cjs + * Run with: node --test electron/desktop-uninstall.test.ts * (Wired into npm test:desktop:platforms in package.json.) * * These are the pure helpers behind the desktop Chat GUI uninstaller: the @@ -9,19 +9,17 @@ * cleanup-script builders (POSIX + Windows). */ -const test = require('node:test') -const assert = require('node:assert/strict') +import assert from 'node:assert/strict' +import test from 'node:test' -const { - UNINSTALL_MODES, - buildPosixCleanupScript, +import { buildPosixCleanupScript, buildWindowsCleanupScript, modeRemovesAgent, modeRemovesUserData, resolveRemovableAppPath, shouldRemoveAppBundle, - uninstallArgsForMode -} = require('./desktop-uninstall.cjs') + UNINSTALL_MODES, + uninstallArgsForMode } from './desktop-uninstall' // --- uninstallArgsForMode --- @@ -132,6 +130,7 @@ test('buildPosixCleanupScript waits for the PID, runs the uninstall module, remo appPath: '/opt/hermes/linux-unpacked', hermesHome: '/home/x/.hermes' }) + assert.match(script, /^#!\/bin\/bash/) assert.match(script, /pid=4321/) assert.match(script, /kill -0 "\$pid"/) @@ -152,6 +151,7 @@ test('buildPosixCleanupScript exports PYTHONPATH when pythonPath is set (lite/fu appPath: null, hermesHome: '/home/x/.hermes' }) + // System python + source on PYTHONPATH so import hermes_cli works while the // venv is torn down. assert.match(script, /export PYTHONPATH='\/home\/x\/\.hermes\/hermes-agent'/) @@ -168,6 +168,7 @@ test('buildPosixCleanupScript omits PYTHONPATH when pythonPath is null (gui)', ( appPath: null, hermesHome: '/h' }) + assert.doesNotMatch(script, /export PYTHONPATH/) }) @@ -181,6 +182,7 @@ test('buildPosixCleanupScript omits the bundle rm when appPath is null', () => { appPath: null, hermesHome: '/h' }) + assert.doesNotMatch(script, /rm -rf '\//) // Still runs the uninstall. assert.match(script, /'-m' 'hermes_cli\.uninstall' '--mode' 'lite'/) @@ -196,6 +198,7 @@ test('buildPosixCleanupScript single-quote-escapes paths with apostrophes', () = appPath: null, hermesHome: '/h' }) + // The apostrophe is closed-escaped-reopened so the shell sees the literal. assert.match(script, /'\/home\/o'\\''brien\/python'/) }) @@ -212,6 +215,7 @@ test('buildWindowsCleanupScript waits (bounded) for PID, runs uninstall, rmdir b appPath: 'C:\\Users\\x\\AppData\\Local\\Programs\\Hermes', hermesHome: 'C:\\Users\\x\\AppData\\Local\\hermes' }) + assert.match(script, /@echo off/) assert.match(script, /set "PID=9988"/) // PYTHONPATH set so a system python can import hermes_cli from source. @@ -238,6 +242,7 @@ test('buildWindowsCleanupScript omits PYTHONPATH + rmdir when not needed (gui, n appPath: null, hermesHome: 'C:\\h' }) + assert.doesNotMatch(script, /rmdir/) assert.doesNotMatch(script, /set "PYTHONPATH=/) }) diff --git a/apps/desktop/electron/desktop-uninstall.cjs b/apps/desktop/electron/desktop-uninstall.ts similarity index 93% rename from apps/desktop/electron/desktop-uninstall.cjs rename to apps/desktop/electron/desktop-uninstall.ts index 01b756acd1e..1254aed2d4d 100644 --- a/apps/desktop/electron/desktop-uninstall.cjs +++ b/apps/desktop/electron/desktop-uninstall.ts @@ -1,14 +1,14 @@ /** - * desktop-uninstall.cjs + * desktop-uninstall.ts * * Pure, electron-free helpers for the desktop Chat GUI uninstaller. These map * the three user-facing uninstall modes to the `hermes uninstall` CLI flags, * resolve the running app bundle/exe so a detached cleanup script can remove * it after the app quits, and build that cleanup script for each OS. * - * Kept standalone (no `require('electron')`) so it can be unit-tested with - * `node --test` — same pattern as connection-config.cjs / backend-probes.cjs. - * main.cjs requires these and wires them into the electron-coupled IPC layer. + * Kept standalone (no ` import 'electron'`) so it can be unit-tested with + * `node --test` — same pattern as connection-config.ts / backend-probes.ts. + * main.ts requires these and wires them into the electron-coupled IPC layer. * * The three modes mirror the CLI's options exactly: * - 'gui' → remove ONLY the Chat GUI, keep the agent + all user data. @@ -23,10 +23,10 @@ * app bundle (locked on macOS/Windows while the process is alive). So we hand * the work to a detached child that waits for this app's PID to exit, runs the * Python uninstall, then removes the app bundle — then the app quits. Same - * shape as the self-update swap-and-relaunch flow already in main.cjs. + * shape as the self-update swap-and-relaunch flow already in main.ts. */ -const path = require('node:path') +import path from 'node:path' const UNINSTALL_MODES = ['gui', 'lite', 'full'] @@ -41,6 +41,7 @@ function uninstallArgsForMode(mode) { if (!UNINSTALL_MODES.includes(mode)) { throw new Error(`Unknown uninstall mode: ${mode}`) } + return ['-m', 'hermes_cli.uninstall', '--mode', mode] } @@ -65,9 +66,10 @@ function modeRemovesUserData(mode) { * Returns null when we can't confidently identify a removable bundle (e.g. * running from a dev checkout, or a system-package install we must not rmtree). */ -function resolveRemovableAppPath(execPath, platform, env = {}) { +function resolveRemovableAppPath(execPath, platform, env: any = {}) { const exe = String(execPath || '') - if (!exe) return null + + if (!exe) {return null} // Use the path flavor that matches the TARGET platform, not the host running // this code — so the Windows branch parses backslash paths correctly even @@ -79,22 +81,28 @@ function resolveRemovableAppPath(execPath, platform, env = {}) { const macOsDir = p.dirname(exe) // …/Contents/MacOS const contents = p.dirname(macOsDir) // …/Contents const appBundle = p.dirname(contents) // …/Hermes.app - if (appBundle.endsWith('.app')) return appBundle + + if (appBundle.endsWith('.app')) {return appBundle} + return null } if (platform === 'win32') { // NSIS per-user installs Hermes.exe directly in the install dir. const dir = p.dirname(exe) - if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) return dir + + if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) {return dir} + return null } // Linux: an AppImage exposes its own path via the APPIMAGE env var. - if (env.APPIMAGE) return env.APPIMAGE + if (env.APPIMAGE) {return env.APPIMAGE} // Unpacked electron-builder tree: …/linux-unpacked/hermes const dir = p.dirname(exe) - if (/-unpacked$/.test(dir)) return dir + + if (/-unpacked$/.test(dir)) {return dir} + return null } @@ -121,6 +129,7 @@ function shouldRemoveAppBundle(isPackaged, appPath) { */ function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, uninstallArgs, appPath, hermesHome }) { const q = s => `'${String(s).replace(/'/g, `'\\''`)}'` + const lines = [ '#!/bin/bash', 'set -u', @@ -135,16 +144,21 @@ function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, 'fi', `export HERMES_HOME=${q(hermesHome)}` ] + if (pythonPath) { lines.push(`export PYTHONPATH=${q(pythonPath)}\${PYTHONPATH:+:$PYTHONPATH}`) } + lines.push(`cd ${q(agentRoot)} 2>/dev/null || true`, `${q(pythonExe)} ${uninstallArgs.map(q).join(' ')} || true`) + if (appPath) { lines.push(`rm -rf ${q(appPath)} || true`) } + // Self-delete the script. lines.push('rm -f "$0" 2>/dev/null || true') lines.push('') + return lines.join('\n') } @@ -180,15 +194,18 @@ function buildWindowsCleanupScript({ // under %LOCALAPPDATA% never contain them). `&`/`^` in a path would still be // a problem, but Hermes install paths don't use them. const q = s => `"${String(s).replace(/"/g, '')}"` + const lines = [ '@echo off', 'setlocal enableextensions', `set "HERMES_HOME=${String(hermesHome).replace(/"/g, '')}"`, `set "PID=${pid}"` ] + if (pythonPath) { lines.push(`set "PYTHONPATH=${String(pythonPath).replace(/"/g, '')};%PYTHONPATH%"`) } + lines.push( 'set /a waited=0', ':waitloop', @@ -206,6 +223,7 @@ function buildWindowsCleanupScript({ `cd /d ${q(agentRoot)}`, `${q(pythonExe)} ${uninstallArgs.map(q).join(' ')}` ) + if (appPath) { lines.push( 'set /a tries=0', @@ -220,18 +238,18 @@ function buildWindowsCleanupScript({ ':rmdone' ) } + lines.push('del "%~f0"') lines.push('') + return lines.join('\r\n') } -module.exports = { - UNINSTALL_MODES, - buildPosixCleanupScript, +export { buildPosixCleanupScript, buildWindowsCleanupScript, modeRemovesAgent, modeRemovesUserData, resolveRemovableAppPath, shouldRemoveAppBundle, - uninstallArgsForMode -} + UNINSTALL_MODES, + uninstallArgsForMode } diff --git a/apps/desktop/electron/embed-referer.cjs b/apps/desktop/electron/embed-referer.ts similarity index 92% rename from apps/desktop/electron/embed-referer.cjs rename to apps/desktop/electron/embed-referer.ts index 2825eda40e7..834fca0cfb2 100644 --- a/apps/desktop/electron/embed-referer.cjs +++ b/apps/desktop/electron/embed-referer.ts @@ -1,9 +1,8 @@ -'use strict' - -const { session } = require('electron') +import { session } from 'electron' const EMBED_SESSION_PARTITION = 'persist:hermes-embed' const EMBED_REFERER = 'https://www.youtube.com/' + const YOUTUBE_REFERER_HOST_RE = /(^|\.)(youtube\.com|youtube-nocookie\.com|googlevideo\.com|ytimg\.com|youtubei\.googleapis\.com)$/i @@ -23,6 +22,7 @@ function installEmbedRefererForSession(embedSession) { if (!YOUTUBE_REFERER_HOST_RE.test(host)) { callback({ requestHeaders: details.requestHeaders }) + return } @@ -45,4 +45,4 @@ function installEmbedReferer() { } } -module.exports = { installEmbedReferer } +export { installEmbedReferer } diff --git a/apps/desktop/electron/fs-read-dir.test.cjs b/apps/desktop/electron/fs-read-dir.test.ts similarity index 97% rename from apps/desktop/electron/fs-read-dir.test.cjs rename to apps/desktop/electron/fs-read-dir.test.ts index 558ec95b539..67adc624671 100644 --- a/apps/desktop/electron/fs-read-dir.test.cjs +++ b/apps/desktop/electron/fs-read-dir.test.ts @@ -1,19 +1,17 @@ -'use strict' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { pathToFileURL } from 'node:url' -const assert = require('node:assert/strict') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const test = require('node:test') -const { pathToFileURL } = require('node:url') - -const { readDirForIpc } = require('./fs-read-dir.cjs') +import { readDirForIpc } from './fs-read-dir' function mkTmpDir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-fs-read-dir-')) } -function fakeDirent(name, flags = {}) { +function fakeDirent(name, flags: any = {}) { return { name, isDirectory: () => Boolean(flags.directory), @@ -109,10 +107,12 @@ test('readDirForIpc accepts file URLs for directories', async () => { test('readDirForIpc returns invalid-path for blank or non-string input', async () => { let readdirCalls = 0 + const fsImpl = { promises: { readdir: async () => { readdirCalls += 1 + return [] } } @@ -126,10 +126,12 @@ test('readDirForIpc returns invalid-path for blank or non-string input', async ( test('readDirForIpc rejects Windows device paths before readdir', async () => { let readdirCalls = 0 + const fsImpl = { promises: { readdir: async () => { readdirCalls += 1 + return [] } } @@ -224,6 +226,7 @@ test('readDirForIpc allows expanding symlink or junction directories outside the fs.writeFileSync(path.join(outside, 'outside.txt'), 'ok') const linkPath = path.join(root, 'outside-link') + try { fs.symlinkSync(outside, linkPath, process.platform === 'win32' ? 'junction' : 'dir') } catch (error) { @@ -252,6 +255,7 @@ test('readDirForIpc stats symbolic links and unknown entries without dropping th const input = path.join('virtual-root') const resolved = path.resolve(input) const statCalls = [] + const fsImpl = { promises: { readdir: async () => [ @@ -266,9 +270,11 @@ test('readDirForIpc stats symbolic links and unknown entries without dropping th } statCalls.push(fullPath) + if (fullPath.endsWith(`${path.sep}linked-dir`)) { return { isDirectory: () => true } } + throw Object.assign(new Error('gone'), { code: 'ENOENT' }) } } @@ -301,12 +307,15 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out let peak = 0 let releaseStats let markFirstStatStarted + const statsReleased = new Promise(resolve => { releaseStats = resolve }) + const firstStatStarted = new Promise(resolve => { markFirstStatStarted = resolve }) + const fsImpl = { promises: { readdir: async () => [ @@ -326,6 +335,7 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out active -= 1 const name = path.basename(fullPath) + if (name === failedName) { throw Object.assign(new Error('gone'), { code: 'ENOENT' }) } diff --git a/apps/desktop/electron/fs-read-dir.cjs b/apps/desktop/electron/fs-read-dir.ts similarity index 87% rename from apps/desktop/electron/fs-read-dir.cjs rename to apps/desktop/electron/fs-read-dir.ts index 1a2a00313b5..27b842ea3ff 100644 --- a/apps/desktop/electron/fs-read-dir.cjs +++ b/apps/desktop/electron/fs-read-dir.ts @@ -1,8 +1,7 @@ -'use strict' +import fs from 'node:fs' +import path from 'node:path' -const fs = require('node:fs') -const path = require('node:path') -const { resolveDirectoryForIpc } = require('./hardening.cjs') +import { resolveDirectoryForIpc } from './hardening' const FS_READDIR_STAT_CONCURRENCY = 16 @@ -37,7 +36,7 @@ function direntIsSymbolicLink(dirent) { } function shouldStatDirent(dirent) { - if (direntIsDirectory(dirent)) return false + if (direntIsDirectory(dirent)) {return false} return direntIsSymbolicLink(dirent) || !direntIsFile(dirent) } @@ -70,13 +69,13 @@ async function mapWithStatConcurrency(items, mapper) { } const workerCount = Math.min(FS_READDIR_STAT_CONCURRENCY, items.length) - const workers = Array.from({ length: workerCount }, () => runWorker()) + const workers = Array.from({ length: workerCount } as any, () => runWorker()) await Promise.all(workers) return results } -async function readDirForIpc(dirPath, options = {}) { +async function readDirForIpc(dirPath, options: any = {}) { const fsImpl = options.fs || fs let resolved @@ -102,6 +101,4 @@ async function readDirForIpc(dirPath, options = {}) { } } -module.exports = { - readDirForIpc -} +export { readDirForIpc } diff --git a/apps/desktop/electron/gateway-ws-probe.test.cjs b/apps/desktop/electron/gateway-ws-probe.test.ts similarity index 89% rename from apps/desktop/electron/gateway-ws-probe.test.cjs rename to apps/desktop/electron/gateway-ws-probe.test.ts index 810494fdc47..f1e5ac94128 100644 --- a/apps/desktop/electron/gateway-ws-probe.test.cjs +++ b/apps/desktop/electron/gateway-ws-probe.test.ts @@ -1,7 +1,7 @@ /** - * Tests for electron/gateway-ws-probe.cjs. + * Tests for electron/gateway-ws-probe.ts. * - * Run with: node --test electron/gateway-ws-probe.test.cjs + * Run with: node --test electron/gateway-ws-probe.test.ts * (Wired into npm test:desktop:platforms in package.json.) * * The probe drives a real WebSocket handshake for the "Test remote" button. @@ -9,16 +9,20 @@ * outcome (open, frame, error, early close, never-opens) without a network. */ -const test = require('node:test') -const assert = require('node:assert/strict') +import assert from 'node:assert/strict' +import test from 'node:test' -const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') +import { probeGatewayWebSocket } from './gateway-ws-probe' // Minimal WebSocket double: records listeners synchronously (the probe attaches // them in its executor) and exposes emit() so the test can replay events. -function makeFakeWs() { +function makeFakeWs(): { FakeWs: new (url: string) => any; instances: any[] } { const instances = [] + class FakeWs { + url: string + closed = false + listeners: Record = {} constructor(url) { this.url = url this.listeners = {} @@ -32,9 +36,12 @@ function makeFakeWs() { this.closed = true } emit(type, event) { - for (const fn of this.listeners[type] || []) fn(event) + for (const fn of this.listeners[type] || []) { + fn(event) + } } } + return { FakeWs, instances } } @@ -51,11 +58,13 @@ test('probe resolves ok when the socket opens and stays open', async () => { test('probe resolves ok immediately when a frame arrives', async () => { const { FakeWs, instances } = makeFakeWs() + const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, connectTimeoutMs: 1_000, readyGraceMs: 10_000 // long grace: success must come from the frame, not the timer }) + instances[0].emit('open') instances[0].emit('message', { data: '{"jsonrpc":"2.0"}' }) const result = await promise @@ -95,11 +104,13 @@ test('probe fails when the gateway accepts then immediately closes (auth rejecte test('probe times out when the socket never opens', async () => { const { FakeWs } = makeFakeWs() + const result = await probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, connectTimeoutMs: 20, readyGraceMs: 10 }) + assert.equal(result.ok, false) assert.match(result.reason, /Timed out/) }) diff --git a/apps/desktop/electron/gateway-ws-probe.cjs b/apps/desktop/electron/gateway-ws-probe.ts similarity index 87% rename from apps/desktop/electron/gateway-ws-probe.cjs rename to apps/desktop/electron/gateway-ws-probe.ts index 6ed1280be98..7202547e7e8 100644 --- a/apps/desktop/electron/gateway-ws-probe.cjs +++ b/apps/desktop/electron/gateway-ws-probe.ts @@ -36,13 +36,13 @@ const DEFAULT_READY_GRACE_MS = 750 * Attempt a live WebSocket connection and classify the outcome. * * @param {string} wsUrl - Fully-formed ws(s):// URL including the credential. - * @param {object} [options] - * @param {new (url: string) => any} [options.WebSocketImpl] - WebSocket ctor. - * @param {number} [options.connectTimeoutMs] - * @param {number} [options.readyGraceMs] * @returns {Promise<{ ok: boolean, reason?: string }>} */ -function probeGatewayWebSocket(wsUrl, options = {}) { +function probeGatewayWebSocket(wsUrl: string, options:{ + WebSocketImpl?: any, + connectTimeoutMs?: number + readyGraceMs?: number +} = {}) { const WebSocketImpl = options.WebSocketImpl const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS const readyGraceMs = options.readyGraceMs ?? DEFAULT_READY_GRACE_MS @@ -54,7 +54,7 @@ function probeGatewayWebSocket(wsUrl, options = {}) { }) } - return new Promise(resolve => { + return new Promise(resolve => { let settled = false let opened = false let connectTimer = null @@ -66,6 +66,7 @@ function probeGatewayWebSocket(wsUrl, options = {}) { clearTimeout(connectTimer) connectTimer = null } + if (graceTimer !== null) { clearTimeout(graceTimer) graceTimer = null @@ -73,14 +74,16 @@ function probeGatewayWebSocket(wsUrl, options = {}) { } const finish = result => { - if (settled) return + if (settled) {return} settled = true clearTimers() + try { socket?.close?.() } catch { // ignore — best effort teardown } + resolve(result) } @@ -91,11 +94,12 @@ function probeGatewayWebSocket(wsUrl, options = {}) { ok: false, reason: error instanceof Error ? error.message : String(error) }) + return } const onOpen = () => { - if (settled) return + if (settled) {return} opened = true // Upgrade accepted. Give the server a brief window to reject the // credential post-handshake (early close) before declaring success. @@ -118,7 +122,8 @@ function probeGatewayWebSocket(wsUrl, options = {}) { } const onClose = event => { - if (settled) return + if (settled) {return} + if (opened) { // Opened, then closed inside the grace window: the upgrade was accepted // but the session was refused (e.g. ws-ticket/token rejected, or a @@ -127,8 +132,10 @@ function probeGatewayWebSocket(wsUrl, options = {}) { ok: false, reason: closeReason(event, 'The gateway accepted the connection then closed it (credential rejected?).') }) + return } + finish({ ok: false, reason: closeReason(event, 'The gateway closed the WebSocket before it opened.') @@ -154,8 +161,10 @@ function probeGatewayWebSocket(wsUrl, options = {}) { function addListener(socket, type, handler) { if (typeof socket.addEventListener === 'function') { socket.addEventListener(type, handler) + return } + // Node's global WebSocket implements addEventListener; this fallback keeps the // helper usable with the `ws` package's EventEmitter shape too. if (typeof socket.on === 'function') { @@ -164,25 +173,31 @@ function addListener(socket, type, handler) { } function extractErrorReason(event) { - if (!event) return '' - if (event instanceof Error) return event.message + if (!event) {return ''} + + if (event instanceof Error) {return event.message} const err = event.error || event.message - if (err instanceof Error) return err.message - if (typeof err === 'string') return err + + if (err instanceof Error) {return err.message} + + if (typeof err === 'string') {return err} + return '' } function closeReason(event, fallback) { const code = event && typeof event.code === 'number' ? event.code : null const reason = event && typeof event.reason === 'string' ? event.reason.trim() : '' - if (code && reason) return `${fallback} (code ${code}: ${reason})` - if (code) return `${fallback} (code ${code})` - if (reason) return `${fallback} (${reason})` + + if (code && reason) {return `${fallback} (code ${code}: ${reason})`} + + if (code) {return `${fallback} (code ${code})`} + + if (reason) {return `${fallback} (${reason})`} + return fallback } -module.exports = { - DEFAULT_CONNECT_TIMEOUT_MS, +export { DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READY_GRACE_MS, - probeGatewayWebSocket -} + probeGatewayWebSocket } diff --git a/apps/desktop/electron/git-repo-scan.cjs b/apps/desktop/electron/git-repo-scan.ts similarity index 93% rename from apps/desktop/electron/git-repo-scan.cjs rename to apps/desktop/electron/git-repo-scan.ts index f7617b76b70..36ac189e66e 100644 --- a/apps/desktop/electron/git-repo-scan.cjs +++ b/apps/desktop/electron/git-repo-scan.ts @@ -1,14 +1,12 @@ -'use strict' - // Repo-first discovery: walk bounded roots for git repos using only Node's `fs` // — no native addon, so it just works for anyone who pulls main (no // electron-rebuild). Mirrors how GitHub Desktop scans: stop at the first `.git` // (don't descend into a repo), cap depth, and skip heavy non-repo trees so the // first scan stays fast. Results are cached by the backend after the first run. -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' const fsp = fs.promises @@ -36,14 +34,14 @@ async function mapLimit(items, limit, fn) { } } - await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)) + await Promise.all(Array.from({ length: Math.min(limit, items.length) } as any, worker)) } /** * Scan `roots` (default: the home dir) for git repositories. Returns deduped * `{ root, label }` entries. `options.maxDepth` caps recursion (default 3). */ -async function scanGitRepos(roots, options = {}) { +async function scanGitRepos(roots, options: any = {}) { const maxDepth = Number(options.maxDepth) || DEFAULT_MAX_DEPTH const searchRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()] const found = new Map() @@ -54,6 +52,7 @@ async function scanGitRepos(roots, options = {}) { } let entries + try { entries = await fsp.readdir(dir, { withFileTypes: true }) } catch { @@ -73,6 +72,7 @@ async function scanGitRepos(roots, options = {}) { } const subdirs = [] + for (const entry of entries) { // Real directories only (skip symlinks to avoid loops), no hidden dirs, no // known heavy trees. @@ -93,4 +93,4 @@ async function scanGitRepos(roots, options = {}) { return [...found.entries()].map(([root, label]) => ({ label, root })) } -module.exports = { scanGitRepos } +export { scanGitRepos } diff --git a/apps/desktop/electron/git-review-ops.test.cjs b/apps/desktop/electron/git-review-ops.test.ts similarity index 78% rename from apps/desktop/electron/git-review-ops.test.cjs rename to apps/desktop/electron/git-review-ops.test.ts index fdddd13df78..a88de4edf12 100644 --- a/apps/desktop/electron/git-review-ops.test.cjs +++ b/apps/desktop/electron/git-review-ops.test.ts @@ -1,9 +1,7 @@ -'use strict' +import assert from 'node:assert/strict' +import test from 'node:test' -const assert = require('node:assert/strict') -const test = require('node:test') - -const { resolveRenamePath } = require('./git-review-ops.cjs') +import { resolveRenamePath } from './git-review-ops' test('resolveRenamePath: plain path is unchanged', () => { assert.equal(resolveRenamePath('src/a.ts'), 'src/a.ts') diff --git a/apps/desktop/electron/git-review-ops.cjs b/apps/desktop/electron/git-review-ops.ts similarity index 93% rename from apps/desktop/electron/git-review-ops.cjs rename to apps/desktop/electron/git-review-ops.ts index a7df19880eb..cc4f4f3d3c8 100644 --- a/apps/desktop/electron/git-review-ops.cjs +++ b/apps/desktop/electron/git-review-ops.ts @@ -1,37 +1,16 @@ -'use strict' - // Git ops backing the coding rail + Codex-style review pane. Built on `simple-git` // (a maintained wrapper around the system git binary — same git the rest of the // app shells to, no native build) so we read structured status()/diffSummary() // results instead of hand-parsing porcelain. Reads degrade to null/empty on a // non-repo / remote backend; mutations reject so the renderer can toast. -const { execFile } = require('node:child_process') -const fs = require('node:fs/promises') -const path = require('node:path') +import { execFile } from 'node:child_process' +import fs from 'node:fs/promises' +import path from 'node:path' -// `simple-git` is a pure-JS runtime dep that workspace dedup hoists into the -// repo-root node_modules. Packaged builds set `files:` in package.json, which -// excludes node_modules from the asar, so the normal require() fails at launch -// (issue #52735: "Cannot find module 'simple-git'"). We ship the dep's -// closure under resources/native-deps/vendor/node_modules/ via extraResources -// + scripts/stage-native-deps.cjs, and resolve from there when the hoisted -// require() isn't reachable. The `vendor/` nesting matters: electron-builder -// drops a node_modules dir at the root of an extraResources copy but keeps a -// nested one. Dev mode never hits the fallback -- Node's normal lookup finds -// the hoisted copy. -let simpleGit -try { - simpleGit = require('simple-git') -} catch { - const resourcesPath = process.resourcesPath - if (!resourcesPath) { - throw new Error("git-review IPC: 'simple-git' not found and no resourcesPath to fall back to") - } - simpleGit = require(path.join(resourcesPath, 'native-deps', 'vendor', 'node_modules', 'simple-git')) -} +import simpleGit from "simple-git"; -const { resolveRequestedPathForIpc } = require('./hardening.cjs') +import { resolveRequestedPathForIpc } from './hardening' const COMMIT_CONTEXT_DIFF_MAX_CHARS = 120_000 const COMMIT_CONTEXT_UNTRACKED_MAX = 80 @@ -52,7 +31,7 @@ function ghEnv(ghBin) { // Run the `gh` CLI in a repo. Resolves { ok, stdout } so callers branch on // availability/auth without a throw. gh missing/unauthed → ok:false. -function runGh(args, cwd, ghBin) { +function runGh(args, cwd, ghBin): Promise<{ok: boolean, stdout: string}> { return new Promise(resolve => { execFile( ghBin || 'gh', @@ -260,10 +239,11 @@ async function reviewList(repoPath, scope, baseRef, gitBin) { const range = scope === 'branch' ? `${base}...HEAD` : base const summary = await git.diffSummary([range]) + const files = summary.files.map(file => ({ path: resolveRenamePath(file.file), - added: file.binary ? 0 : file.insertions, - removed: file.binary ? 0 : file.deletions, + added: 'insertions' in file ? file.insertions : 0 , + removed: 'deletions' in file ? file.deletions : 0 , status: 'M', staged: false })) @@ -291,6 +271,7 @@ async function reviewList(repoPath, scope, baseRef, gitBin) { git.diffSummary(['--cached']), git.diffSummary([]) ]) + const stagedCounts = countsByPath(staged) const unstagedCounts = countsByPath(unstaged) @@ -495,6 +476,7 @@ async function reviewCommitContext(repoPath, gitBin) { const safe = args => git.diff(args).catch(() => '') let status + try { status = await git.status() } catch { @@ -510,9 +492,11 @@ async function reviewCommitContext(repoPath, gitBin) { // Untracked files have no diff — list them so new files aren't invisible. const untracked = status.not_added || [] + if (untracked.length > 0) { const visible = untracked.slice(0, COMMIT_CONTEXT_UNTRACKED_MAX) const omitted = untracked.length - visible.length + const note = `\n# New (untracked) files:\n${visible.map(p => `# ${p}`).join('\n')}\n` + (omitted > 0 ? `# ... ${omitted} more omitted\n` : '') @@ -607,6 +591,7 @@ async function repoStatus(repoPath, gitBin) { // fail soft and hide the coding rail instead of spamming IPC handler errors. try { const stat = await fs.stat(cwd) + if (!stat.isDirectory()) { return null } @@ -615,11 +600,13 @@ async function repoStatus(repoPath, gitBin) { } let git + try { git = gitFor(cwd, gitBin) } catch { return null } + let status try { @@ -630,6 +617,7 @@ async function repoStatus(repoPath, gitBin) { } const detached = typeof status.detached === 'boolean' ? status.detached : !status.current + const files = status.files.map(file => ({ path: file.path, staged: isStaged(file), @@ -671,10 +659,12 @@ async function repoStatus(repoPath, gitBin) { // can't stall the probe. try { const untracked = status.not_added.slice(0, 500) + for (let i = 0; i < untracked.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) { const batch = await Promise.all( untracked.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(path => untrackedInsertions(cwd, path)) ) + result.added += batch.reduce((sum, n) => sum + n, 0) } } catch { @@ -684,8 +674,7 @@ async function repoStatus(repoPath, gitBin) { return result } -module.exports = { - branchBase, +export { branchBase, fileDiffVsHead, repoStatus, resolveRenamePath, @@ -695,9 +684,8 @@ module.exports = { reviewDiff, reviewList, reviewPush, - reviewRevParse, reviewRevert, + reviewRevParse, reviewShipInfo, reviewStage, - reviewUnstage -} + reviewUnstage } diff --git a/apps/desktop/electron/git-root.test.cjs b/apps/desktop/electron/git-root.test.ts similarity index 80% rename from apps/desktop/electron/git-root.test.cjs rename to apps/desktop/electron/git-root.test.ts index ba649b259f3..cda56d51a49 100644 --- a/apps/desktop/electron/git-root.test.cjs +++ b/apps/desktop/electron/git-root.test.ts @@ -1,13 +1,11 @@ -'use strict' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { pathToFileURL } from 'node:url' -const assert = require('node:assert/strict') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const test = require('node:test') -const { pathToFileURL } = require('node:url') - -const { gitRootForIpc } = require('./git-root.cjs') +import { gitRootForIpc } from './git-root' function mkTmpDir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-git-root-')) diff --git a/apps/desktop/electron/git-root.cjs b/apps/desktop/electron/git-root.ts similarity index 75% rename from apps/desktop/electron/git-root.cjs rename to apps/desktop/electron/git-root.ts index 593d3531ebc..93ea2598802 100644 --- a/apps/desktop/electron/git-root.cjs +++ b/apps/desktop/electron/git-root.ts @@ -1,8 +1,7 @@ -'use strict' +import fs from 'node:fs' +import path from 'node:path' -const fs = require('node:fs') -const path = require('node:path') -const { resolveRequestedPathForIpc } = require('./hardening.cjs') +import { resolveRequestedPathForIpc } from './hardening' function findGitRoot(start, fsImpl = fs) { let dir = start @@ -28,7 +27,7 @@ function findGitRoot(start, fsImpl = fs) { return null } -async function gitRootForIpc(startPath, options = {}) { +async function gitRootForIpc(startPath, options: {fs?: typeof fs} = {}) { const fsImpl = options.fs || fs let resolved @@ -48,7 +47,5 @@ async function gitRootForIpc(startPath, options = {}) { } } -module.exports = { - findGitRoot, - gitRootForIpc -} +export { findGitRoot, + gitRootForIpc } diff --git a/apps/desktop/electron/git-worktree-ops.test.cjs b/apps/desktop/electron/git-worktree-ops.test.ts similarity index 95% rename from apps/desktop/electron/git-worktree-ops.test.cjs rename to apps/desktop/electron/git-worktree-ops.test.ts index b0865d4ad77..65e59c2d29d 100644 --- a/apps/desktop/electron/git-worktree-ops.test.cjs +++ b/apps/desktop/electron/git-worktree-ops.test.ts @@ -1,20 +1,16 @@ -'use strict' +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' -const assert = require('node:assert/strict') -const { execFileSync } = require('node:child_process') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const test = require('node:test') - -const { - addWorktree, +import { addWorktree, ensureGitRepo, listBranches, parseWorktrees, sanitizeBranch, - switchBranch -} = require('./git-worktree-ops.cjs') + switchBranch } from './git-worktree-ops' test('sanitizeBranch: spaces → hyphens, forbidden chars dropped, edges trimmed', () => { assert.equal(sanitizeBranch('beach vibes'), 'beach-vibes') diff --git a/apps/desktop/electron/git-worktree-ops.cjs b/apps/desktop/electron/git-worktree-ops.ts similarity index 96% rename from apps/desktop/electron/git-worktree-ops.cjs rename to apps/desktop/electron/git-worktree-ops.ts index de4e01cfb94..93efaf13c4e 100644 --- a/apps/desktop/electron/git-worktree-ops.cjs +++ b/apps/desktop/electron/git-worktree-ops.ts @@ -1,16 +1,14 @@ -'use strict' - // Git-driven worktree operations for the desktop "Start work" flow: spin up a // fresh worktree the lightest way (`git worktree add -b`), list real worktrees, // and remove them. Git is the source of truth; the renderer just drives these. -const path = require('node:path') -const fs = require('node:fs') -const { execFile } = require('node:child_process') +import { execFile } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' -const { resolveRequestedPathForIpc } = require('./hardening.cjs') +import { resolveRequestedPathForIpc } from './hardening' -function runGit(gitBin, args, cwd) { +function runGit(gitBin, args, cwd): Promise { return new Promise((resolve, reject) => { execFile( gitBin, @@ -306,6 +304,7 @@ async function listBranches(repoPath, gitBin) { ['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/heads'], resolved ) + const trees = await listWorktrees(resolved, gitBin) const pathByBranch = new Map(trees.filter(tree => tree.branch).map(tree => [tree.branch, tree.path])) const trunk = await defaultBranch(gitBin, resolved) @@ -338,13 +337,11 @@ async function switchBranch(repoPath, branch, gitBin) { return { branch: target } } -module.exports = { - addWorktree, +export { addWorktree, ensureGitRepo, listBranches, listWorktrees, parseWorktrees, removeWorktree, sanitizeBranch, - switchBranch -} + switchBranch } diff --git a/apps/desktop/electron/hardening.test.cjs b/apps/desktop/electron/hardening.test.ts similarity index 95% rename from apps/desktop/electron/hardening.test.cjs rename to apps/desktop/electron/hardening.test.ts index b38a03b0082..324e511ac3e 100644 --- a/apps/desktop/electron/hardening.test.cjs +++ b/apps/desktop/electron/hardening.test.ts @@ -1,23 +1,22 @@ -const assert = require('node:assert/strict') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const test = require('node:test') -const { pathToFileURL } = require('node:url') +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { pathToFileURL } from 'node:url' -const { - DEFAULT_FETCH_TIMEOUT_MS, +import { DEFAULT_FETCH_TIMEOUT_MS, encryptDesktopSecret, resolveDirectoryForIpc, resolveReadableFileForIpc, resolveRequestedPathForIpc, resolveTimeoutMs, - sensitiveFileBlockReason -} = require('./hardening.cjs') + sensitiveFileBlockReason } from './hardening' -async function rejectsWithCode(promise, code) { - await assert.rejects(promise, error => { +async function rejectsWithCode(promise, code: string) { + await assert.rejects(promise, (error: any) => { assert.equal(error?.code, code) + return true }) } @@ -76,8 +75,9 @@ test('path helpers reject blank non-string NUL and Windows device syntax', async for (const devicePath of devicePaths) { assert.throws( () => resolveRequestedPathForIpc(devicePath, { purpose: 'File preview' }), - error => { + (error: any) => { assert.equal(error?.code, 'device-path') + return true } ) @@ -86,8 +86,9 @@ test('path helpers reject blank non-string NUL and Windows device syntax', async assert.throws( () => resolveRequestedPathForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), - error => { + (error: any) => { assert.equal(error?.code, 'invalid-path') + return true } ) @@ -131,19 +132,23 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity', maxBytes: 256, purpose: 'File preview' }) + assert.equal(fromRelative.resolvedPath, textPath) assert.equal(fromRelative.stat.size, 11) const fromFileUrl = await resolveReadableFileForIpc(pathToFileURL(textPath).toString(), { purpose: 'File preview' }) + assert.equal(fromFileUrl.resolvedPath, textPath) const spacedPath = path.join(tempDir, 'notes with spaces.txt') fs.writeFileSync(spacedPath, 'space ok', 'utf8') + const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), { purpose: 'File preview' }) + assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath) await assert.rejects( @@ -184,9 +189,11 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity', const envTemplatePath = path.join(tempDir, '.env.example') fs.writeFileSync(envTemplatePath, 'EXAMPLE_TOKEN=value', 'utf8') + const envTemplate = await resolveReadableFileForIpc(envTemplatePath, { purpose: 'File preview' }) + assert.equal(envTemplate.resolvedPath, envTemplatePath) }) @@ -229,8 +236,10 @@ test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', as } catch (error) { if (error?.code === 'EPERM' || error?.code === 'EACCES') { t.skip(`symlink creation is not permitted on this platform (${error.code})`) + return } + throw error } @@ -268,8 +277,10 @@ test('resolveDirectoryForIpc accepts directory symlinks or junctions', async t = } catch (error) { if (error?.code === 'EPERM' || error?.code === 'EACCES') { t.skip(`directory symlink creation is not permitted on this platform (${error.code})`) + return } + throw error } diff --git a/apps/desktop/electron/hardening.cjs b/apps/desktop/electron/hardening.ts similarity index 89% rename from apps/desktop/electron/hardening.cjs rename to apps/desktop/electron/hardening.ts index 574e659f96c..b344f596040 100644 --- a/apps/desktop/electron/hardening.cjs +++ b/apps/desktop/electron/hardening.ts @@ -1,7 +1,7 @@ -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const { fileURLToPath } = require('node:url') +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' const DEFAULT_FETCH_TIMEOUT_MS = 15_000 const DATA_URL_READ_MAX_BYTES = 16 * 1024 * 1024 @@ -13,6 +13,7 @@ const SENSITIVE_EXTENSIONS = new Set(['.kdbx', '.p12', '.pem', '.pfx']) function resolveTimeoutMs(timeoutMs, fallbackMs = DEFAULT_FETCH_TIMEOUT_MS) { const fallback = Number.isFinite(fallbackMs) && Number(fallbackMs) > 0 ? Math.round(Number(fallbackMs)) : DEFAULT_FETCH_TIMEOUT_MS + const parsed = Number(timeoutMs) if (Number.isFinite(parsed) && parsed > 0) { @@ -62,6 +63,7 @@ function sensitiveFileBlockReason(filePath) { const normalized = String(filePath || '') .replace(/\\/g, '/') .toLowerCase() + const basename = path.basename(normalized) const ext = path.extname(basename) @@ -87,6 +89,7 @@ function sensitiveFileBlockReason(filePath) { if (basename.startsWith('.env.')) { const suffix = basename.slice('.env.'.length) + if (!SAFE_ENV_SUFFIXES.has(suffix)) { return `${basename} is blocked because it appears to contain environment secrets.` } @@ -107,9 +110,10 @@ function sensitiveFileBlockReason(filePath) { return null } -function ipcPathError(code, message) { - const error = new Error(message) - error.code = code +function ipcPathError(code: any, message: string): Error & {code: any} { + const error = new Error(message) as Error & {code: any} + (error as any).code = code + return error } @@ -129,6 +133,7 @@ function rejectUnsafePathSyntax(filePath, purpose = 'File read') { } const normalized = raw.replace(/\\/g, '/').toLowerCase() + if ( normalized.startsWith('//?/') || normalized.startsWith('//./') || @@ -141,7 +146,7 @@ function rejectUnsafePathSyntax(filePath, purpose = 'File read') { return raw } -function resolveRequestedPathForIpc(filePath, options = {}) { +function resolveRequestedPathForIpc(filePath, options: {purpose?: string, baseDir?: fs.PathOrFileDescriptor} = {}) { const purpose = String(options.purpose || 'File read') let raw = rejectUnsafePathSyntax(filePath, purpose) @@ -154,17 +159,21 @@ function resolveRequestedPathForIpc(filePath, options = {}) { if (/^file:/i.test(raw)) { let resolvedPath + try { const parsed = new URL(raw) + if (parsed.protocol !== 'file:') { throw new Error('not a file URL') } + resolvedPath = fileURLToPath(parsed) } catch { throw ipcPathError('invalid-path', `${purpose} failed: file URL is invalid.`) } rejectUnsafePathSyntax(resolvedPath, purpose) + return path.resolve(resolvedPath) } @@ -178,14 +187,16 @@ function resolveRequestedPathForIpc(filePath, options = {}) { return resolvedPath } -async function statForIpc(fsImpl, resolvedPath, purpose, typeLabel) { +async function statForIpc(fsImpl: {promises: {stat: typeof fs.promises.stat}}, resolvedPath, purpose, typeLabel) { try { return await fsImpl.promises.stat(resolvedPath) } catch (error) { const code = error && typeof error === 'object' ? error.code : '' + if (code === 'ENOENT' || code === 'ENOTDIR') { throw ipcPathError(code || 'ENOENT', `${purpose} failed: ${typeLabel} does not exist.`) } + throw ipcPathError( code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}` @@ -201,6 +212,7 @@ async function realpathForIpc(fsImpl, resolvedPath, purpose) { try { const realPath = await fsImpl.promises.realpath(resolvedPath) rejectUnsafePathSyntax(realPath, purpose) + return realPath } catch (error) { const code = error && typeof error === 'object' ? error.code : '' @@ -213,12 +225,13 @@ async function realpathForIpc(fsImpl, resolvedPath, purpose) { function rejectSensitiveFilePath(filePath, purpose) { const blockReason = sensitiveFileBlockReason(filePath) + if (blockReason) { throw ipcPathError('sensitive-file', `${purpose} blocked for sensitive file: ${blockReason}`) } } -async function resolveDirectoryForIpc(dirPath, options = {}) { +async function resolveDirectoryForIpc(dirPath, options: {purpose?: string , baseDir?: fs.PathOrFileDescriptor, fs?: {promises:{stat: typeof fs.promises.stat}}} = {}) { const purpose = String(options.purpose || 'Directory read') const fsImpl = options.fs || fs const resolvedPath = resolveRequestedPathForIpc(dirPath, { baseDir: options.baseDir, purpose }) @@ -233,7 +246,7 @@ async function resolveDirectoryForIpc(dirPath, options = {}) { return { realPath, resolvedPath, stat } } -async function resolveReadableFileForIpc(filePath, options = {}) { +async function resolveReadableFileForIpc(filePath, options: {purpose?: string , baseDir?: fs.PathOrFileDescriptor, fs?: typeof fs, blockSensitive?: boolean, maxBytes?: number} = {}) { const purpose = String(options.purpose || 'File read') const fsImpl = options.fs || fs const resolvedPath = resolveRequestedPathForIpc(filePath, { baseDir: options.baseDir, purpose }) @@ -253,11 +266,13 @@ async function resolveReadableFileForIpc(filePath, options = {}) { } const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose) + if (options.blockSensitive !== false) { rejectSensitiveFilePath(realPath, purpose) } const maxBytes = Number.isFinite(options.maxBytes) && Number(options.maxBytes) > 0 ? Number(options.maxBytes) : null + if (maxBytes && stat.size > maxBytes) { throw ipcPathError('EFBIG', `${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`) } @@ -271,15 +286,13 @@ async function resolveReadableFileForIpc(filePath, options = {}) { return { realPath, resolvedPath, stat } } -module.exports = { - DATA_URL_READ_MAX_BYTES, +export { DATA_URL_READ_MAX_BYTES, DEFAULT_FETCH_TIMEOUT_MS, - TEXT_PREVIEW_SOURCE_MAX_BYTES, encryptDesktopSecret, rejectUnsafePathSyntax, resolveDirectoryForIpc, resolveReadableFileForIpc, resolveRequestedPathForIpc, resolveTimeoutMs, - sensitiveFileBlockReason -} + sensitiveFileBlockReason, + TEXT_PREVIEW_SOURCE_MAX_BYTES } diff --git a/apps/desktop/electron/link-title-window.test.cjs b/apps/desktop/electron/link-title-window.test.ts similarity index 95% rename from apps/desktop/electron/link-title-window.test.cjs rename to apps/desktop/electron/link-title-window.test.ts index 468c646a047..e1b1cc5794f 100644 --- a/apps/desktop/electron/link-title-window.test.cjs +++ b/apps/desktop/electron/link-title-window.test.ts @@ -1,15 +1,14 @@ -const assert = require('node:assert/strict') -const test = require('node:test') +import assert from 'node:assert/strict' +import test from 'node:test' -const { - createLinkTitleWindow, +import { createLinkTitleWindow, guardLinkTitleSession, linkTitleWindowOptions, - readLinkTitleWindowTitle -} = require('./link-title-window.cjs') + readLinkTitleWindowTitle } from './link-title-window' function makeFakeBrowserWindow() { const calls = { audioMuted: [] } + const FakeBrowserWindow = function (options) { this.options = options this.webContents = { diff --git a/apps/desktop/electron/link-title-window.cjs b/apps/desktop/electron/link-title-window.ts similarity index 80% rename from apps/desktop/electron/link-title-window.cjs rename to apps/desktop/electron/link-title-window.ts index c6792bf989e..9e67e4b1415 100644 --- a/apps/desktop/electron/link-title-window.cjs +++ b/apps/desktop/electron/link-title-window.ts @@ -1,11 +1,9 @@ -'use strict' - // Hidden BrowserWindow used by tier-2 link-title resolution: when curl can't // read a page (bot walls, JS-rendered pages), we briefly load the URL // in an offscreen window and read its title. That window loads arbitrary // user-linked pages, so it must never emit sound or trigger real downloads. -function linkTitleWindowOptions(partitionSession) { +export function linkTitleWindowOptions(partitionSession) { return { show: false, width: 1280, @@ -25,7 +23,7 @@ function linkTitleWindowOptions(partitionSession) { // Create the offscreen title-fetch window and immediately mute it. Without the // mute, autoplaying media on the loaded page (e.g. a YouTube link) leaks ~2s of // audio every time a session containing such links is re-rendered. See #49505. -function createLinkTitleWindow(BrowserWindow, partitionSession) { +export function createLinkTitleWindow(BrowserWindow, partitionSession) { const window = new BrowserWindow(linkTitleWindowOptions(partitionSession)) try { @@ -41,7 +39,7 @@ function createLinkTitleWindow(BrowserWindow, partitionSession) { // Cancel any download the title-fetch window triggers. Without this, a link // artifact URL served with Content-Disposition: attachment auto-downloads every // time the Artifacts page renders and fetchLinkTitle loads it. -function guardLinkTitleSession(partitionSession) { +export function guardLinkTitleSession(partitionSession) { try { partitionSession.on('will-download', (_event, item) => item.cancel()) } catch { @@ -52,20 +50,15 @@ function guardLinkTitleSession(partitionSession) { // Read the page title from a title-fetch window. Callers schedule this from // timers that can fire after finish() destroys the window, so every access must // guard isDestroyed and swallow Electron's "Object has been destroyed" throws. -function readLinkTitleWindowTitle(window) { +export function readLinkTitleWindowTitle(window) { try { - if (!window || window.isDestroyed()) return '' + if (!window || window.isDestroyed()) {return ''} const contents = window.webContents - if (!contents || contents.isDestroyed()) return '' + + if (!contents || contents.isDestroyed()) {return ''} + return contents.getTitle() || '' } catch { return '' } } - -module.exports = { - createLinkTitleWindow, - guardLinkTitleSession, - linkTitleWindowOptions, - readLinkTitleWindowTitle -} diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.ts similarity index 94% rename from apps/desktop/electron/main.cjs rename to apps/desktop/electron/main.ts index 69d5d5f715b..93f2893d676 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.ts @@ -1,74 +1,65 @@ -const { - app, +type Method = string +import type { ExecFileSyncOptionsWithStringEncoding} from 'node:child_process'; +import { execFileSync, spawn } from 'node:child_process' +import crypto from 'node:crypto' +import fs from 'node:fs' +import http from 'node:http' +import https from 'node:https' +import os from "node:os" +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +import { app, BrowserWindow, - Menu, - Notification, clipboard, dialog, + net as electronNet, ipcMain, + Menu, nativeImage, nativeTheme, - net: electronNet, + Notification, powerMonitor, protocol, safeStorage, screen, 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 os = require('node:os') -const path = require('node:path') -const { pathToFileURL } = require('node:url') -const { execFileSync, spawn } = require('node:child_process') -const { installEmbedReferer } = require('./embed-referer.cjs') -const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs') -const { runBootstrap } = require('./bootstrap-runner.cjs') -const { - buildSessionWindowUrl, - chatWindowWebPreferences, - createSessionWindowRegistry, - SESSION_WINDOW_MIN_HEIGHT, - SESSION_WINDOW_MIN_WIDTH -} = require('./session-windows.cjs') -const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs') -const { - createLinkTitleWindow, - guardLinkTitleSession, - readLinkTitleWindowTitle -} = require('./link-title-window.cjs') -const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') -const { adoptServedDashboardToken } = require('./dashboard-token.cjs') -const { waitForDashboardPortAnnouncement } = require('./backend-ready.cjs') -const { dashboardFallbackArgs, sourceDeclaresServe } = require('./backend-command.cjs') -const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs') -const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs') -const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs') -const { readWindowsUserEnvVar } = require('./windows-user-env.cjs') -const { readWslWindowsClipboardImage } = require('./wsl-clipboard-image.cjs') -const { - nativeOverlayWidth: computeNativeOverlayWidth, - macTitleBarOverlayHeight -} = require('./titlebar-overlay-width.cjs') -const { readDirForIpc } = require('./fs-read-dir.cjs') -const { readLiveUpdateMarker, writeUpdateMarker } = require('./update-marker.cjs') -const { - resolveUnpackedRelease, - decideRelaunchOutcome, - sandboxPreflight, - sandboxFallbackFromEnv, - collectRelaunchArgs, - collectRelaunchEnv, - buildRelaunchScript -} = require('./update-relaunch.cjs') -const { gitRootForIpc } = require('./git-root.cjs') -const { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } = require('./git-worktree-ops.cjs') -const { - fileDiffVsHead, + systemPreferences } from 'electron' +import nodePty from "node-pty" + +import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command'; +import { buildDesktopBackendEnv, normalizeHermesHomeRoot } from './backend-env' +import { canImportHermesCli, verifyHermesCli } from './backend-probes' +import { waitForDashboardPortAnnouncement } from './backend-ready' +import { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } from './bootstrap-platform' +import { runBootstrap } from './bootstrap-runner' +import { authModeFromStatus, + buildGatewayWsUrl, + buildGatewayWsUrlWithTicket, + connectionScopeKey, + cookiesHaveLiveSession, + cookiesHaveSession, + normalizeRemoteBaseUrl, + normAuthMode, + pathWithGlobalRemoteProfile, + profileRemoteOverride, + resolveAuthMode, + resolveTestWsUrl, + tokenPreview } from './connection-config' +import { adoptServedDashboardToken } from './dashboard-token' +import { buildPosixCleanupScript, + buildWindowsCleanupScript, + modeRemovesAgent, + modeRemovesUserData, + resolveRemovableAppPath, + shouldRemoveAppBundle, + uninstallArgsForMode } from './desktop-uninstall' +import { installEmbedReferer } from './embed-referer' +import { readDirForIpc } from './fs-read-dir' +import { probeGatewayWebSocket } from './gateway-ws-probe' +import { scanGitRepos } from './git-repo-scan' +import { fileDiffVsHead, repoStatus, reviewCommit, reviewCommitContext, @@ -76,87 +67,51 @@ const { reviewDiff, reviewList, reviewPush, - reviewRevParse, reviewRevert, + reviewRevParse, reviewShipInfo, reviewStage, - reviewUnstage -} = require('./git-review-ops.cjs') -const { scanGitRepos } = require('./git-repo-scan.cjs') -const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs') -const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs') -const { runRebuildWithRetry } = require('./update-rebuild.cjs') -const { - buildPosixCleanupScript, - buildWindowsCleanupScript, - modeRemovesAgent, - modeRemovesUserData, - resolveRemovableAppPath, - shouldRemoveAppBundle, - uninstallArgsForMode -} = require('./desktop-uninstall.cjs') -const { isPackagedInstallPath: isPackagedInstallPathUnderRoots } = require('./workspace-cwd.cjs') -const { - MIN_WIDTH: WINDOW_MIN_WIDTH, - MIN_HEIGHT: WINDOW_MIN_HEIGHT, - sanitizeWindowState, - computeWindowOptions, - debounce -} = require('./window-state.cjs') -const { - authModeFromStatus, - buildGatewayWsUrl, - buildGatewayWsUrlWithTicket, - connectionScopeKey, - cookiesHaveSession, - cookiesHaveLiveSession, - normAuthMode, - normalizeRemoteBaseUrl, - pathWithGlobalRemoteProfile, - profileRemoteOverride, - resolveAuthMode, - resolveTestWsUrl, - tokenPreview -} = require('./connection-config.cjs') -const { - DATA_URL_READ_MAX_BYTES, + reviewUnstage } from './git-review-ops' +import { gitRootForIpc } from './git-root' +import { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } from './git-worktree-ops' +import { DATA_URL_READ_MAX_BYTES, DEFAULT_FETCH_TIMEOUT_MS, - TEXT_PREVIEW_SOURCE_MAX_BYTES, - encryptDesktopSecret: encryptDesktopSecretStrict, + encryptDesktopSecret as encryptDesktopSecretStrict, resolveReadableFileForIpc, resolveRequestedPathForIpc, - resolveTimeoutMs -} = require('./hardening.cjs') - -let nodePty = null -let nodePtyDir = null - -try { - nodePty = require('node-pty') - nodePtyDir = path.dirname(require.resolve('node-pty/package.json')) -} 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) { - nodePtyDir = path.join(resourcesPath, 'native-deps', 'node-pty') - nodePty = require(nodePtyDir) - } - } catch { - console.log(`[terminal] failed to load node-pty from path ${nodePtyDir}`) - nodePty = null - nodePtyDir = null - } -} + resolveTimeoutMs, + TEXT_PREVIEW_SOURCE_MAX_BYTES } from './hardening' +import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window' +import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' +import { buildSessionWindowUrl, + chatWindowWebPreferences, + createSessionWindowRegistry, + SESSION_WINDOW_MIN_HEIGHT, + SESSION_WINDOW_MIN_WIDTH } from './session-windows' +import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width' +import { resolveBehindCount, shouldCountCommits } from './update-count' +import { readLiveUpdateMarker, writeUpdateMarker } from './update-marker' +import { runRebuildWithRetry } from './update-rebuild' +import { buildRelaunchScript, + collectRelaunchArgs, + collectRelaunchEnv, + decideRelaunchOutcome, + resolveUnpackedRelease, + sandboxFallbackFromEnv, + sandboxPreflight } from './update-relaunch' +import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote' +import { fetchMarketplaceThemes, searchMarketplaceThemes } from './vscode-marketplace' +import { computeWindowOptions, + debounce, + sanitizeWindowState, + MIN_HEIGHT as WINDOW_MIN_HEIGHT, + MIN_WIDTH as WINDOW_MIN_WIDTH } from './window-state' +import { readWindowsUserEnvVar } from './windows-user-env' +import { isPackagedInstallPath as isPackagedInstallPathUnderRoots } from './workspace-cwd' +import { readWslWindowsClipboardImage } from './wsl-clipboard-image' 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 }) @@ -164,7 +119,7 @@ if (USER_DATA_OVERRIDE) { } const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER -const IS_PACKAGED = app.isPackaged +const IS_PACKAGED = app.isPackaged || Boolean(process.env.HERMES_DESKTOP_IS_PACKAGED) const IS_MAC = process.platform === 'darwin' const IS_WINDOWS = process.platform === 'win32' const IS_WSL = isWslEnvironment() @@ -173,11 +128,18 @@ const IS_WSL = isWslEnvironment() const DARWIN_MAJOR = IS_MAC ? Number.parseInt(os.release(), 10) || 0 : 0 const APP_ROOT = app.getAppPath() -function hiddenWindowsChildOptions(options = {}) { +// Preload script path: in dev we load the .ts source directly (tsx handles +// the transform); in prod we load the bundled .js from dist/. +const PRELOAD_PATH = IS_PACKAGED + ? path.join(APP_ROOT, 'dist', 'electron-preload.js') + : path.join(import.meta.dirname, 'preload.ts') + +function hiddenWindowsChildOptions(options: any = {}): ExecFileSyncOptionsWithStringEncoding { if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) { - return options + return options as any } - return { ...options, windowsHide: true } + + return { ...options, windowsHide: true } as any } // Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU @@ -190,6 +152,7 @@ function hiddenWindowsChildOptions(options = {}) { // 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 @@ -229,7 +192,7 @@ 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` +// Written by apps/desktop/scripts/write-build-stamp.mjs 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 @@ -241,6 +204,7 @@ const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..') // 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 @@ -248,19 +212,24 @@ function loadInstallStamp() { // 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') + 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, @@ -271,13 +240,17 @@ function loadInstallStamp() { path: p }) } - } catch { + } catch (e) { + console.warn(`[hermes] install-stamp.json found at ${p} , but parsing failed with ${e}`) // 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'}` @@ -306,8 +279,10 @@ if (INSTALL_STAMP) { // 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 normalizeHermesHomeRoot(process.env.HERMES_HOME) - if (USER_DATA_OVERRIDE) return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home') + if (process.env.HERMES_HOME) {return normalizeHermesHomeRoot(process.env.HERMES_HOME)} + + if (USER_DATA_OVERRIDE) {return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home')} + if (IS_WINDOWS) { // A GUI app launched from Explorer inherits the environment block captured // at login, so a HERMES_HOME set via `setx` AFTER login is invisible in @@ -316,16 +291,21 @@ function resolveHermesHome() { // inference provider configured" despite a valid configured home (#45471). // Consult the live User-scoped registry value before the default below. const fromRegistry = readWindowsUserEnvVar('HERMES_HOME') - if (fromRegistry) return normalizeHermesHomeRoot(fromRegistry) + + if (fromRegistry) {return normalizeHermesHomeRoot(fromRegistry)} } + 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 + if (!directoryExists(localappdata) && directoryExists(legacy)) {return legacy} + return localappdata } + return path.join(app.getPath('home'), '.hermes') } @@ -338,6 +318,7 @@ function hermesManagedNodePathEntries() { const root = path.join(HERMES_HOME, 'node') const bin = path.join(root, 'bin') const entries = IS_WINDOWS ? [root, bin] : [bin, root] + return entries.filter(directoryExists) } @@ -408,19 +389,25 @@ const DESKTOP_LOG_BACKUP_COUNT = 3 const DESKTOP_LOG_DISCARD_BYTES = DESKTOP_LOG_MAX_BYTES * 4 const desktopLogBackupPath = n => `${DESKTOP_LOG_PATH}.${n}` 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 + + 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 } -// Right-edge window-control reservation lives in titlebar-overlay-width.cjs + +// Right-edge window-control reservation lives in titlebar-overlay-width.ts // (pure + unit-testable); computeNativeOverlayWidth() applies it per platform. // It's only the pre-layout fallback — the renderer measures the exact overlay // width live via the Window Controls Overlay API. @@ -574,6 +561,7 @@ function getTitleBarOverlayOptions() { // setTitleBarOverlay isn't supported. function applyTitleBarOverlay(win) { const options = getTitleBarOverlayOptions() + if (!options || typeof options !== 'object') { return } @@ -611,6 +599,7 @@ 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', @@ -647,14 +636,15 @@ const PREVIEW_LANGUAGE_BY_EXT = { } function looksBinary(buffer) { - if (!buffer.length) return false + if (!buffer.length) {return false} let suspicious = 0 for (const byte of buffer) { - if (byte === 0) return true + if (byte === 0) {return true} + // Allow common whitespace controls: tab, LF, CR. - if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) suspicious += 1 + if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {suspicious += 1} } return suspicious / buffer.length > 0.12 @@ -691,6 +681,7 @@ function previewFileMetadata(filePath, mimeType) { } app.setName(APP_NAME) + // Windows toast notifications silently no-op unless an AppUserModelID is set: // `new Notification().show()` returns without error and nothing appears. The // AUMID must match the installed Start Menu shortcut's AUMID, which @@ -701,6 +692,7 @@ app.setName(APP_NAME) if (IS_WINDOWS) { app.setAppUserModelId('com.nousresearch.hermes') } + // Seed the native About panel with the live Hermes version. This is refreshed // on every open via the explicit "About" menu handler (refreshAboutPanel), so // an in-place `hermes update` mid-session is reflected without an app restart; @@ -718,6 +710,7 @@ app.setAboutPanelOptions({ // 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([ @@ -749,9 +742,12 @@ protocol.registerSchemesAsPrivileged([ 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 }) @@ -820,6 +816,7 @@ let desktopLogBuffer = '' let desktopLogFlushTimer = null let desktopLogFlushPromise = Promise.resolve() let nativeThemeListenerInstalled = false + let bootProgressState = { error: null, fakeMode: BOOT_FAKE_MODE, @@ -834,32 +831,39 @@ let bootProgressState = { // Each step is ['rm', path] or ['mv', src, dst]; executed best-effort so a // missing chain link never aborts the rest. function planDesktopLogRotation(size) { - if (size < DESKTOP_LOG_MAX_BYTES) return [] + if (size < DESKTOP_LOG_MAX_BYTES) {return []} const backups = n => Array.from({ length: n }, (_, i) => desktopLogBackupPath(i + 1)) + // Pathological boot-loop log: reclaim live + every backup outright. if (size > DESKTOP_LOG_DISCARD_BYTES) { return [DESKTOP_LOG_PATH, ...backups(DESKTOP_LOG_BACKUP_COUNT)].map(p => ['rm', p]) } + // Cascade: drop oldest, shift each up, live -> .1. const ops = [['rm', desktopLogBackupPath(DESKTOP_LOG_BACKUP_COUNT)]] + for (let i = DESKTOP_LOG_BACKUP_COUNT - 1; i >= 1; i--) { ops.push(['mv', desktopLogBackupPath(i), desktopLogBackupPath(i + 1)]) } + ops.push(['mv', DESKTOP_LOG_PATH, desktopLogBackupPath(1)]) + return ops } function rotateDesktopLogIfNeededSync() { let size + try { size = fs.statSync(DESKTOP_LOG_PATH).size } catch { return // No live file yet — the append (re)creates it. } + for (const [op, src, dst] of planDesktopLogRotation(size)) { try { - if (op === 'rm') fs.rmSync(src, { force: true }) - else fs.renameSync(src, dst) + if (op === 'rm') {fs.rmSync(src, { force: true })} + else {fs.renameSync(src, dst)} } catch { // Best-effort — logging must never block startup/shutdown. } @@ -868,15 +872,17 @@ function rotateDesktopLogIfNeededSync() { async function rotateDesktopLogIfNeededAsync() { let size + try { size = (await fs.promises.stat(DESKTOP_LOG_PATH)).size } catch { return // No live file yet — the append (re)creates it. } + for (const [op, src, dst] of planDesktopLogRotation(size)) { try { - if (op === 'rm') await fs.promises.rm(src, { force: true }) - else await fs.promises.rename(src, dst) + if (op === 'rm') {await fs.promises.rm(src, { force: true })} + else {await fs.promises.rename(src, dst)} } catch { // Best-effort — logging must never crash the shell. } @@ -884,7 +890,7 @@ async function rotateDesktopLogIfNeededAsync() { } function flushDesktopLogBufferSync() { - if (!desktopLogBuffer) return + if (!desktopLogBuffer) {return} const chunk = desktopLogBuffer desktopLogBuffer = '' @@ -898,7 +904,7 @@ function flushDesktopLogBufferSync() { } function flushDesktopLogBufferAsync() { - if (!desktopLogBuffer) return desktopLogFlushPromise + if (!desktopLogBuffer) {return desktopLogFlushPromise} const chunk = desktopLogBuffer desktopLogBuffer = '' @@ -916,7 +922,7 @@ function flushDesktopLogBufferAsync() { } function scheduleDesktopLogFlush() { - if (desktopLogFlushTimer) return + if (desktopLogFlushTimer) {return} desktopLogFlushTimer = setTimeout(() => { desktopLogFlushTimer = null void flushDesktopLogBufferAsync() @@ -925,9 +931,11 @@ function scheduleDesktopLogFlush() { function rememberLog(chunk) { const text = String(chunk || '').trim() - if (!text) return + + 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) } @@ -939,6 +947,7 @@ function rememberLog(chunk) { clearTimeout(desktopLogFlushTimer) desktopLogFlushTimer = null } + void flushDesktopLogBufferAsync() return @@ -949,9 +958,11 @@ function rememberLog(chunk) { function openExternalUrl(rawUrl) { const raw = String(rawUrl || '').trim() - if (!raw) return false + + if (!raw) {return false} let parsed + try { parsed = new URL(raw) } catch { @@ -965,6 +976,7 @@ function openExternalUrl(rawUrl) { // string), fall back to revealing the file in the system file manager. if (parsed.protocol === 'file:') { let localPath + try { localPath = resolveRequestedPathForIpc(parsed.toString(), { purpose: 'Open external file' }) } catch { @@ -999,11 +1011,13 @@ function openExternalUrl(rawUrl) { 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}`)) @@ -1020,9 +1034,11 @@ function openExternalUrl(rawUrl) { async function openPreviewInBrowser(rawUrl) { const raw = String(rawUrl || '').trim() - if (!raw) return false + + if (!raw) {return false} let parsed + try { parsed = new URL(raw) } catch { @@ -1031,6 +1047,7 @@ async function openPreviewInBrowser(rawUrl) { if (parsed.protocol === 'file:') { let localPath + try { localPath = resolveRequestedPathForIpc(parsed.toString(), { purpose: 'Open preview in browser' }) } catch { @@ -1046,7 +1063,7 @@ async function openPreviewInBrowser(rawUrl) { } function ensureWslWindowsFonts() { - if (!IS_WSL) return + if (!IS_WSL) {return} const fontsDir = ['/mnt/c/Windows/Fonts', '/mnt/c/windows/fonts'].find(candidate => { try { @@ -1055,18 +1072,21 @@ function ensureWslWindowsFonts() { return false } }) - if (!fontsDir) return + + 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 + + if (existing.includes(fontsDir)) {return} fs.mkdirSync(confDir, { recursive: true }) fs.writeFileSync( @@ -1089,14 +1109,17 @@ function sleep(ms) { function clampBootProgress(value) { const numeric = Number(value) - if (!Number.isFinite(numeric)) return 0 + + if (!Number.isFinite(numeric)) {return 0} + return Math.max(0, Math.min(100, Math.round(numeric))) } function broadcastBootProgress() { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) {return} const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) {return} webContents.send('hermes:boot-progress', bootProgressState) } @@ -1120,6 +1143,7 @@ function broadcastBootProgress() { // '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, @@ -1137,6 +1161,7 @@ function broadcastBootstrapEvent(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 } } @@ -1149,6 +1174,7 @@ function broadcastBootstrapEvent(ev) { } } else if (ev.type === 'log') { bootstrapState.log.push({ ts: Date.now(), stage: ev.stage || null, line: ev.line, stream: ev.stream || 'stdout' }) + if (bootstrapState.log.length > BOOTSTRAP_LOG_RING_MAX) { bootstrapState.log.splice(0, bootstrapState.log.length - BOOTSTRAP_LOG_RING_MAX) } @@ -1170,9 +1196,10 @@ function broadcastBootstrapEvent(ev) { } } - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) {return} const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) {return} webContents.send('hermes:bootstrap:event', ev) } @@ -1180,9 +1207,10 @@ function getBootstrapState() { return bootstrapState } -function updateBootProgress(update, options = {}) { +function updateBootProgress(update, options: {allowDecrease?: boolean} = {}) { const nextProgressRaw = typeof update.progress === 'number' ? clampBootProgress(update.progress) : bootProgressState.progress + const nextProgress = options.allowDecrease ? nextProgressRaw : Math.max(bootProgressState.progress, nextProgressRaw) bootProgressState = { @@ -1242,7 +1270,7 @@ function directoryExists(filePath) { // cycle loops. Instead the fresh instance parks until the update finishes, then // brings the backend up itself (it is the surviving instance — the updater's // own relaunch hits our single-instance lock and quits). Marker parsing + -// staleness self-heal live in update-marker.cjs (unit-tested). +// staleness self-heal live in update-marker.ts (unit-tested). // How long we'll park the launch waiting for a live update to finish before // giving up and starting the backend anyway (belt-and-suspenders alongside the @@ -1263,10 +1291,12 @@ const UPDATE_HANDOFF_DWELL_MS = 2500 // rather than a frozen splash. Returns true if it parked at all. async function waitForUpdateToFinish() { let marker = readLiveUpdateMarker(HERMES_HOME) - if (!marker) return false + + if (!marker) {return false} rememberLog(`[updates] update in progress (pid=${marker.pid}); deferring backend start until it finishes`) const deadline = Date.now() + UPDATE_WAIT_TIMEOUT_MS + while (marker && Date.now() < deadline) { await advanceBootProgress( 'backend.update-wait', @@ -1276,11 +1306,13 @@ async function waitForUpdateToFinish() { await new Promise(r => setTimeout(r, UPDATE_WAIT_POLL_MS)) marker = readLiveUpdateMarker(HERMES_HOME) } + if (marker) { rememberLog('[updates] update still in progress after wait timeout; starting backend anyway') } else { rememberLog('[updates] update finished; proceeding with backend start') } + return true } @@ -1289,17 +1321,20 @@ function unpackedPathFor(filePath) { } function findOnPath(command) { - if (!command) return null + 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 + 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) + // On Windows, try PATHEXT extensions BEFORE the bare (empty-extension) name. // A real command must resolve via its .exe/.cmd (Windows command-resolution // semantics consult PATHEXT); an extensionless file — e.g. a Git-Bash @@ -1313,7 +1348,8 @@ function findOnPath(command) { for (const entry of pathEntries) { for (const extension of extensions) { const candidate = path.join(entry, `${command}${extension}`) - if (fileExists(candidate)) return candidate + + if (fileExists(candidate)) {return candidate} } } @@ -1325,17 +1361,20 @@ function isCommandScript(command) { } function unwrapWindowsVenvHermesCommand(command, backendArgs) { - if (!IS_WINDOWS || !command || isCommandScript(command)) return null + if (!IS_WINDOWS || !command || isCommandScript(command)) {return null} const resolved = path.resolve(String(command)) - if (!/^hermes(?:\.exe)?$/i.test(path.basename(resolved))) return null + + if (!/^hermes(?:\.exe)?$/i.test(path.basename(resolved))) {return null} const scriptsDir = path.dirname(resolved) - if (path.basename(scriptsDir).toLowerCase() !== 'scripts') return null + + if (path.basename(scriptsDir).toLowerCase() !== 'scripts') {return null} const venvRoot = path.dirname(scriptsDir) const python = getVenvPython(venvRoot) - if (!fileExists(python)) return null + + if (!fileExists(python)) {return null} const root = path.dirname(venvRoot) @@ -1357,6 +1396,7 @@ function unwrapWindowsVenvHermesCommand(command, backendArgs) { rememberLog( `Ignoring venv Hermes at ${python}: runtime import probe failed (broken/partial venv); falling through to bootstrap.` ) + return null } @@ -1389,12 +1429,15 @@ function unwrapWindowsVenvHermesCommand(command, backendArgs) { // (covers a bare `hermes` resolved from PATH with no known source root). Result // is cached per resolved runtime so we probe at most once per backend. const _serveSupportCache = new Map() + function backendSupportsServe(backend) { - if (!backend || !backend.command) return true + if (!backend || !backend.command) {return true} const key = `${backend.command}::${backend.root || ''}` - if (_serveSupportCache.has(key)) return _serveSupportCache.get(key) + + if (_serveSupportCache.has(key)) {return _serveSupportCache.get(key)} let supported = null + if (backend.root) { try { const src = fs.readFileSync(path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'), 'utf8') @@ -1424,6 +1467,7 @@ function backendSupportsServe(backend) { rememberLog( `[backend] \`serve\` ${supported ? 'supported' : 'unsupported → routing via legacy `dashboard`'} for ${backend.label || key}` ) + return supported } @@ -1435,9 +1479,10 @@ function getBackendArgsForRuntime(backend) { } function normalizeExecutablePathForCompare(commandPath) { - if (!commandPath) return null + if (!commandPath) {return null} let resolved = path.resolve(String(commandPath)) + try { resolved = fs.realpathSync.native ? fs.realpathSync.native(resolved) : fs.realpathSync(resolved) } catch { @@ -1448,15 +1493,17 @@ function normalizeExecutablePathForCompare(commandPath) { } function looksLikeDesktopAppBinary(commandPath) { - if (!IS_WINDOWS || !commandPath) return false + 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 { @@ -1464,6 +1511,7 @@ function looksLikeDesktopAppBinary(commandPath) { } const resourcesDir = path.join(path.dirname(resolved), 'resources') + return ( fileExists(path.join(resourcesDir, 'app.asar')) || directoryExists(path.join(resourcesDir, 'app.asar.unpacked')) ) @@ -1475,7 +1523,8 @@ function isHermesSourceRoot(root) { function findPythonForRoot(root) { const override = process.env.HERMES_DESKTOP_PYTHON - if (override && fileExists(override)) return override + + if (override && fileExists(override)) {return override} const relativePaths = IS_WINDOWS ? [path.join('.venv', 'Scripts', 'python.exe'), path.join('venv', 'Scripts', 'python.exe')] @@ -1483,7 +1532,8 @@ function findPythonForRoot(root) { for (const relativePath of relativePaths) { const candidate = path.join(root, relativePath) - if (fileExists(candidate)) return candidate + + if (fileExists(candidate)) {return candidate} } return findSystemPython() @@ -1494,8 +1544,10 @@ function findSystemPython() { // POSIX systems: PATH lookup is safe. for (const command of ['python3', 'python']) { const candidate = findOnPath(command) - if (candidate) return candidate + + if (candidate) {return candidate} } + return null } @@ -1551,12 +1603,15 @@ function findSystemPython() { ['query', `${hive}\\SOFTWARE\\Python\\PythonCore\\${version}\\InstallPath`, '/ve', '/reg:64'], hiddenWindowsChildOptions({ 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 + + if (fileExists(pythonExe)) {return pythonExe} } } catch { // Key not present — try next. @@ -1567,12 +1622,16 @@ function findSystemPython() { // 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 (fileExists(systemWide)) {return systemWide} + if (localAppData) { const perUser = path.join(localAppData, 'Programs', 'Python', `Python${versionDir}`, 'python.exe') - if (fileExists(perUser)) return perUser + + if (fileExists(perUser)) {return perUser} } } @@ -1582,6 +1641,7 @@ function findSystemPython() { // 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 { @@ -1593,8 +1653,10 @@ function findSystemPython() { stdio: ['ignore', 'pipe', 'ignore'] }) ) + const candidate = out.trim() - if (candidate && fileExists(candidate)) return candidate + + if (candidate && fileExists(candidate)) {return candidate} } catch { // py couldn't find that version — try next. } @@ -1628,6 +1690,7 @@ function findGitBash() { // 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')) @@ -1636,12 +1699,13 @@ function findGitBash() { // 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 + if (fileExists(candidate)) {return candidate} } // Last resort — bash on PATH (covers WSL bash, MSYS2, custom installs). @@ -1677,11 +1741,14 @@ function getVenvPython(venvRoot) { function getVenvSitePackagesEntries(venvRoot) { const entries = [] - if (!venvRoot) return entries + + if (!venvRoot) {return entries} if (IS_WINDOWS) { const sitePackages = path.join(venvRoot, 'Lib', 'site-packages') - if (directoryExists(sitePackages)) entries.push(sitePackages) + + if (directoryExists(sitePackages)) {entries.push(sitePackages)} + return entries } @@ -1689,21 +1756,26 @@ function getVenvSitePackagesEntries(venvRoot) { try { const cfg = fs.readFileSync(path.join(venvRoot, 'pyvenv.cfg'), 'utf8') const match = cfg.match(/^version_info\s*=\s*(\d+\.\d+)/im) + return match ? match[1].trim() : null } catch { return null } })() + if (version) { const sitePackages = path.join(venvRoot, 'lib', `python${version}`, 'site-packages') - if (directoryExists(sitePackages)) entries.push(sitePackages) + + if (directoryExists(sitePackages)) {entries.push(sitePackages)} } + return entries } function makeDashboardReadyFile() { const dir = path.join(app.getPath('userData'), 'backend-ready') fs.mkdirSync(dir, { recursive: true }) + return path.join(dir, `dashboard-${process.pid}-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.json`) } @@ -1713,26 +1785,33 @@ function makeDashboardReadyFile() { // "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 (_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 } @@ -1741,13 +1820,15 @@ function resolveGitBinary() { // lives, so a bare spawn('gh') ENOENTs even though `gh` works in the user's // terminal. Check the common install locations first, then PATH. Cached. let _ghBinaryCache = null + function resolveGhBinary() { - if (_ghBinaryCache) return _ghBinaryCache + if (_ghBinaryCache) {return _ghBinaryCache} const candidates = [] if (IS_WINDOWS) { candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'GitHub CLI', 'gh.exe')) + if (process.env.LOCALAPPDATA) { candidates.push(path.join(process.env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links', 'gh.exe')) } @@ -1757,6 +1838,7 @@ function resolveGhBinary() { } _ghBinaryCache = candidates.find(fileExists) || findOnPath('gh') || 'gh' + return _ghBinaryCache } @@ -1770,6 +1852,7 @@ 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 } @@ -1778,7 +1861,7 @@ function readDesktopUpdateConfig() { // Atomic file write: temp + rename (atomic on all platforms). Prevents // partial writes on crash/power loss that corrupt JSON config files. -function writeFileAtomic(targetPath, data, encoding) { +function writeFileAtomic(targetPath, data, encoding?: BufferEncoding) { const tmp = targetPath + '.tmp' fs.writeFileSync(tmp, data, encoding) fs.renameSync(tmp, targetPath) @@ -1803,7 +1886,8 @@ function readWindowState() { // getNormalBounds() keeps the pre-maximize size, so un-maximizing next session // lands back where the user actually sized the window. function persistWindowState() { - if (!mainWindow || mainWindow.isDestroyed() || mainWindow.isMinimized()) return + if (!mainWindow || mainWindow.isDestroyed() || mainWindow.isMinimized()) {return} + try { const { x, y, width, height } = mainWindow.getNormalBounds() fs.mkdirSync(path.dirname(DESKTOP_WINDOW_STATE_PATH), { recursive: true }) @@ -1832,14 +1916,14 @@ function resolveUpdateRoot() { return candidates.find(c => directoryExists(path.join(c, '.git'))) || candidates[0] || ACTIVE_HERMES_ROOT } -function runGit(args, options = {}) { +function runGit(args, options: any = {}): Promise<{code: number, stdout: string, stderr: string}> { return new Promise((resolve, reject) => { const child = spawn( resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, hiddenWindowsChildOptions({ cwd: options.cwd, - env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' }, + env: { ...process.env, ...(options.env || {}) as any, GIT_TERMINAL_PROMPT: '0' }, stdio: ['ignore', 'pipe', 'pipe'] }) ) @@ -1865,12 +1949,14 @@ const firstLine = text => (text || '').split('\n').find(Boolean) || '' async function getOriginUrl(updateRoot) { const origin = await runGit(['remote', 'get-url', 'origin'], { cwd: updateRoot }) + return origin.code === 0 ? origin.stdout.trim() : '' } 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) } @@ -1890,15 +1976,18 @@ async function resolveHealedBranch(updateRoot, branch) { const originUrl = await getOriginUrl(updateRoot) const remote = isOfficialSshRemote(originUrl) ? OFFICIAL_REPO_HTTPS_URL : 'origin' const probe = await runGit(['ls-remote', '--exit-code', '--heads', remote, 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' } @@ -1906,6 +1995,7 @@ async function checkUpdates() { const updateRoot = resolveUpdateRoot() let { branch } = readDesktopUpdateConfig() const gitDir = path.join(updateRoot, '.git') + if (!directoryExists(gitDir)) { return { supported: false, @@ -1918,15 +2008,19 @@ async function checkUpdates() { branch = await resolveHealedBranch(updateRoot, branch) const originUrl = await getOriginUrl(updateRoot) + if (isOfficialSshRemote(originUrl)) { const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim()) + const [currentSha, target, dirtyStr, currentBranch] = await Promise.all([ git(['rev-parse', 'HEAD']), runGit(['ls-remote', OFFICIAL_REPO_HTTPS_URL, `refs/heads/${branch}`], { cwd: updateRoot }), git(['status', '--porcelain']), git(['rev-parse', '--abbrev-ref', 'HEAD']) ]) + const targetSha = firstLine(target.stdout).split(/\s+/)[0] || '' + if (target.code !== 0 || !targetSha) { return { supported: true, @@ -1937,6 +2031,7 @@ async function checkUpdates() { fetchedAt: Date.now() } } + return { supported: true, branch, @@ -1952,6 +2047,7 @@ async function checkUpdates() { } const fetched = await runGit(['fetch', '--quiet', 'origin', branch], { cwd: updateRoot }) + if (fetched.code !== 0) { return { supported: true, @@ -1964,6 +2060,7 @@ async function checkUpdates() { } const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim()) + const [currentSha, targetSha, dirtyStr, currentBranch, shallowStr, mergeBaseStr] = await Promise.all([ git(['rev-parse', 'HEAD']), git(['rev-parse', `origin/${branch}`]), @@ -1977,6 +2074,7 @@ async function checkUpdates() { const isShallow = shallowStr === 'true' const hasMergeBase = Boolean(mergeBaseStr) + // Only enumerate the commit count when it is meaningful. On a shallow checkout // with no merge-base, `rev-list --count` walks the entire remote ancestry // (thousands of commits, see #51922) and resolveBehindCount discards the @@ -1992,6 +2090,7 @@ async function checkUpdates() { isShallow, hasMergeBase }) + const commits = behind > 0 ? await readCommitLog(updateRoot, branch) : [] return { @@ -2011,6 +2110,7 @@ async function checkUpdates() { 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 } @@ -2022,6 +2122,7 @@ async function readCommitLog(cwd, branch) { .filter(Boolean) .map(line => { const [sha, summary, author, at] = line.split(SEP) + return { sha, summary, author, at: Number.parseInt(at, 10) * 1000 } }) } @@ -2048,11 +2149,12 @@ let isQuittingForHandoff = false function resolveUpdaterBinary() { const name = IS_WINDOWS ? 'hermes-setup.exe' : 'hermes-setup' const candidate = path.join(HERMES_HOME, name) + return fileExists(candidate) ? candidate : null } function repairMacUpdaterHelper(updater) { - if (!IS_MAC || !updater) return + if (!IS_MAC || !updater) {return} try { execFileSync('/usr/bin/xattr', ['-cr', updater], { stdio: 'ignore' }) @@ -2062,6 +2164,7 @@ function repairMacUpdaterHelper(updater) { try { execFileSync('/usr/bin/codesign', ['--verify', updater], { stdio: 'ignore' }) + return } catch { // Unsigned or invalid helper. Apply a local ad-hoc signature so Gatekeeper @@ -2090,10 +2193,12 @@ function venvHermesShimPath(updateRoot) { // this practically always succeeds (no mandatory locking), so it returns false // — correct, since the shim-contention brick is Windows-only. function isShimLocked(shimPath) { - if (!IS_WINDOWS) return false + if (!IS_WINDOWS) {return false} let fd + try { fd = fs.openSync(shimPath, 'r+') + return false } catch (err) { // ENOENT ⇒ not there ⇒ nothing locking it. Anything else (EBUSY/EPERM/ @@ -2119,8 +2224,10 @@ function isShimLocked(shimPath) { // not a process-group leader — a POSIX negative-pgid kill would be meaningless // here anyway). POSIX teardown stays with the existing before-quit SIGTERM. function forceKillProcessTree(pid) { - if (!IS_WINDOWS) return - if (!Number.isInteger(pid) || pid <= 0) return + if (!IS_WINDOWS) {return} + + if (!Number.isInteger(pid) || pid <= 0) {return} + try { execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], hiddenWindowsChildOptions({ stdio: 'ignore' })) } catch { @@ -2160,13 +2267,15 @@ async function releaseBackendLockForUpdate(updateRoot) { // `tag` only flavors the log lines. No-op off Windows (POSIX has no mandatory // locks — the before-quit SIGTERM + the cleanup script's own PID-wait suffice). async function releaseBackendLock(updateRoot, tag) { - if (!IS_WINDOWS) return { unlocked: true } + if (!IS_WINDOWS) {return { unlocked: true }} // Collect every backend PID the desktop owns: primary window backend + pool. const pids = [] - if (hermesProcess && Number.isInteger(hermesProcess.pid)) pids.push(hermesProcess.pid) + + if (hermesProcess && Number.isInteger(hermesProcess.pid)) {pids.push(hermesProcess.pid)} + for (const entry of backendPool.values()) { - if (entry.process && Number.isInteger(entry.process.pid)) pids.push(entry.process.pid) + if (entry.process && Number.isInteger(entry.process.pid)) {pids.push(entry.process.pid)} } // Graceful first (lets Python flush), then tree-kill to catch grandchildren. @@ -2177,27 +2286,36 @@ async function releaseBackendLock(updateRoot, tag) { void 0 } } + stopAllPoolBackends() - for (const pid of pids) forceKillProcessTree(pid) + + for (const pid of pids) {forceKillProcessTree(pid)} const shim = venvHermesShimPath(updateRoot) const deadlineMs = Date.now() + 15000 + while (Date.now() < deadlineMs) { if (!isShimLocked(shim)) { rememberLog(`[${tag}] venv shim unlocked; safe to proceed`) + return { unlocked: true } } + // A supervised backend can respawn between kill and check (grandchildren, // pool entries registered mid-teardown). Re-collect and re-kill each pass // instead of trusting the initial sweep. const stragglers = [] - if (hermesProcess && Number.isInteger(hermesProcess.pid)) stragglers.push(hermesProcess.pid) + + if (hermesProcess && Number.isInteger(hermesProcess.pid)) {stragglers.push(hermesProcess.pid)} + for (const entry of backendPool.values()) { - if (entry.process && Number.isInteger(entry.process.pid)) stragglers.push(entry.process.pid) + if (entry.process && Number.isInteger(entry.process.pid)) {stragglers.push(entry.process.pid)} } - for (const pid of stragglers) forceKillProcessTree(pid) + + for (const pid of stragglers) {forceKillProcessTree(pid)} await new Promise(r => setTimeout(r, 300)) } + // Do NOT proceed past a held lock: handing off to the updater while another // process (a second desktop window, a user terminal, an unkillable child) // still maps the venv's files guarantees a half-updated venv — the updater's @@ -2206,6 +2324,7 @@ async function releaseBackendLock(updateRoot, tag) { // the update loudly and keeping the app running is strictly better than a // bricked install that needs manual venv surgery. rememberLog(`[${tag}] venv shim still locked after 15s; aborting hand-off (something outside this app holds the venv)`) + return { unlocked: false } } @@ -2223,10 +2342,12 @@ 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), @@ -2236,6 +2357,7 @@ async function applyUpdates(opts = {}) { // 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 @@ -2248,18 +2370,23 @@ async function applyUpdates(opts = {}) { // 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}` + + 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 } } @@ -2276,9 +2403,11 @@ async function applyUpdates(opts = {}) { const branch = await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH) const updaterArgs = ['--update', '--branch', branch] const targetApp = IS_MAC ? runningAppBundle() : null + if (targetApp) { updaterArgs.push('--target-app', targetApp) } + const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin') // Stop our own backend(s) and wait for the venv shim to unlock BEFORE we @@ -2286,6 +2415,7 @@ async function applyUpdates(opts = {}) { // hermes.exe (held by the backend child / its grandchildren) and the update // bricks. See releaseBackendLockForUpdate for the full failure analysis. const lock = await releaseBackendLockForUpdate(updateRoot) + if (!lock.unlocked) { // Something OUTSIDE this app holds the venv (a second window, a user // terminal running hermes, an unkillable child). Handing off anyway @@ -2295,8 +2425,10 @@ async function applyUpdates(opts = {}) { const message = 'Update aborted: another process is holding the Hermes install open ' + '(a second Hermes window or a terminal running hermes?). Close it and retry.' + emitUpdateProgress({ stage: 'error', message, percent: null }) startHermes().catch(() => {}) + return { ok: false, error: message } } @@ -2313,6 +2445,7 @@ async function applyUpdates(opts = {}) { stdio: 'ignore', windowsHide: false }) + child.unref() // Write the update-in-progress marker IMMEDIATELY — before the 2.5s @@ -2345,19 +2478,23 @@ async function applyUpdates(opts = {}) { } async function handOffWindowsBootstrapRecovery(reason) { - if (!IS_WINDOWS || !IS_PACKAGED) return false + if (!IS_WINDOWS || !IS_PACKAGED) {return false} const updater = resolveUpdaterBinary() - if (!updater) return false + + if (!updater) {return false} const updateRoot = resolveUpdateRoot() const { branch: configuredBranch } = readDesktopUpdateConfig() + const branch = directoryExists(path.join(updateRoot, '.git')) ? await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH) : configuredBranch || DEFAULT_UPDATE_BRANCH + const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin') const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes') const venvPython = path.join(venvBin, IS_WINDOWS ? 'python.exe' : 'python') + // Choose the gentle in-place --update when ANY real-install signal is present, // not just the `hermes.exe` console-script shim. That shim is generated at the // END of venv setup and is absent in exactly the interrupted/quarantined states @@ -2366,6 +2503,7 @@ async function handOffWindowsBootstrapRecovery(reason) { // and the bootstrap-complete marker are present earlier and are better signals. const haveRealInstall = fileExists(venvPython) || fileExists(venvHermes) || fileExists(path.join(updateRoot, '.hermes-bootstrap-complete')) + const updaterArgs = haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch] await releaseBackendLockForUpdate(updateRoot) @@ -2381,6 +2519,7 @@ async function handOffWindowsBootstrapRecovery(reason) { stdio: 'ignore', windowsHide: false }) + child.unref() // Same marker pre-write as applyUpdates — see comment there. The recovery @@ -2408,14 +2547,17 @@ async function handOffWindowsBootstrapRecovery(reason) { // 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 + + 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 } = {}) { +function runStreamedUpdate(command, args, { cwd, env, stage }: any = {}) { return new Promise(resolve => { let child + try { child = spawn( command, @@ -2428,14 +2570,18 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) { ) } 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 }) + + 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 })) @@ -2446,9 +2592,11 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) { // 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 + 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 + + for (let i = 0; i < 2; i++) {dir = path.dirname(dir)} // -> .../X.app + return dir.endsWith('.app') ? dir : null } @@ -2460,18 +2608,20 @@ function shellQuote(value) { // (`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() { +async function applyUpdatesPosixInApp(opts: any) { 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. Windows portable // Node lives directly under %LOCALAPPDATA%\hermes\node, not node\bin. - const env = { + const env: Record<string, string> = { HERMES_HOME, PATH: pathWithHermesManagedNode(path.join(updateRoot, 'venv', 'bin')) } @@ -2488,14 +2638,17 @@ async function applyUpdatesPosixInApp() { // the update reaper. _kill_stale_dashboard_processes accepts a comma-separated // list (a single int still parses for back-compat). const desktopChildPids = [] + if (hermesProcess && Number.isInteger(hermesProcess.pid)) { desktopChildPids.push(hermesProcess.pid) } + for (const entry of backendPool.values()) { if (entry.process && Number.isInteger(entry.process.pid)) { desktopChildPids.push(entry.process.pid) } } + if (desktopChildPids.length) { env.HERMES_DESKTOP_CHILD_PID = desktopChildPids.join(',') } @@ -2503,9 +2656,11 @@ async function applyUpdatesPosixInApp() { // 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)] } @@ -2514,17 +2669,21 @@ async function applyUpdatesPosixInApp() { } emitUpdateProgress({ stage: 'update', message: 'Updating Hermes (git + dependencies)…', percent: 10 }) + const updated = await runStreamedUpdate(hermes, ['update', '--yes', ...branchArgs], { cwd: updateRoot, env, stage: 'update' - }) + }) as any + 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 }) + // Retry-once: a first rebuild can fail on a still-settling tree or a // self-healed (network-blocked) Electron download; a second run builds clean // off the healed dist so we reach the swap+relaunch below instead of bailing. @@ -2532,14 +2691,17 @@ async function applyUpdatesPosixInApp() { if (attempt > 0) { emitUpdateProgress({ stage: 'rebuild', message: 'Retrying the desktop rebuild…', percent: 60 }) } + return 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' } } @@ -2547,7 +2709,7 @@ async function applyUpdatesPosixInApp() { // rebuilds the unpacked app in place under apps/desktop/release/<plat>-unpacked. // We can only HONESTLY relaunch into the new GUI when the *running* binary IS // that rebuilt one — i.e. execPath lives under release/<plat>-unpacked. The - // outcome is decided by three signals (see update-relaunch.cjs): + // outcome is decided by three signals (see update-relaunch.ts): // // underUnpacked + sandboxOk → 'relaunch': detached watcher re-execs us in // place (mirrors the macOS handoff). Without it the update succeeds but @@ -2569,8 +2731,10 @@ async function applyUpdatesPosixInApp() { const preflight = underUnpacked ? sandboxPreflight(unpackedDir, p => fs.statSync(p)) : { ok: false, reason: 'not-under-unpacked', path: null } + const sandboxFallback = sandboxFallbackFromEnv(process.env, process.argv.slice(1)) const sandboxOk = preflight.ok || sandboxFallback + if (underUnpacked && !preflight.ok) { rememberLog( `[updates] sandbox preflight: not launchable (${preflight.reason}) at ${preflight.path}; ` + @@ -2588,6 +2752,7 @@ async function applyUpdatesPosixInApp() { // relaunched instance comes up with default context instead of the user's. const relaunchArgs = collectRelaunchArgs(process.argv.slice(1)) const relaunchEnv = collectRelaunchEnv(process.env) + const relaunchScript = buildRelaunchScript({ pid: process.pid, execPath: process.execPath, @@ -2595,7 +2760,9 @@ async function applyUpdatesPosixInApp() { env: relaunchEnv, cwd: process.cwd() }) + const scriptPath = path.join(app.getPath('temp'), `hermes-desktop-update-${Date.now()}.sh`) + try { fs.writeFileSync(scriptPath, relaunchScript, { mode: 0o755 }) const child = spawn('/bin/bash', [scriptPath], { detached: true, stdio: 'ignore' }) @@ -2606,9 +2773,11 @@ async function applyUpdatesPosixInApp() { ) isQuittingForHandoff = true setTimeout(() => app.quit(), UPDATE_HANDOFF_DWELL_MS) + return { ok: true, handedOff: true } } catch (err) { rememberLog(`[updates] linux relaunch failed: ${err.message}; falling back to manual restart`) + return { ok: true, backendUpdated: true, @@ -2631,6 +2800,7 @@ async function applyUpdatesPosixInApp() { `[updates] gui/backend skew: execPath ${process.execPath} not under release/*-unpacked; ` + 'backend updated, GUI package unchanged (AppImage/.deb/.rpm/dev/unresolved)' ) + return { ok: true, backendUpdated: true, guiUpdated: false, guiSkew: true } } @@ -2640,6 +2810,7 @@ async function applyUpdatesPosixInApp() { `[updates] sandbox not launchable (${preflight.reason}); skipping auto-relaunch, ` + 'returning manual-restart so the user keeps a working window' ) + return { ok: true, backendUpdated: true, @@ -2656,6 +2827,7 @@ async function applyUpdatesPosixInApp() { 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 @@ -2666,6 +2838,7 @@ async function applyUpdatesPosixInApp() { message: 'Backend updated. Restart Hermes to load the new version.', percent: 100 }) + return { ok: true, backendUpdated: true, rebuiltApp: rebuiltApp || null } } @@ -2693,7 +2866,9 @@ 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) { @@ -2703,6 +2878,7 @@ fi percent: 100 }) rememberLog(`[updates] could not write swap script: ${err.message}; rebuilt app at ${rebuiltApp}`) + return { ok: true, backendUpdated: true, rebuiltApp } } @@ -2712,6 +2888,7 @@ fi isQuittingForHandoff = true setTimeout(() => app.quit(), 600) + return { ok: true, handedOff: true, rebuiltApp, targetApp } } @@ -2748,6 +2925,7 @@ function readBootstrapMarker() { // "already installed" off the filesystem alone, not just the marker. function isActiveRuntimeUsable() { const venvPython = getVenvPython(VENV_ROOT) + return ( isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(venvPython) && @@ -2761,9 +2939,13 @@ function isActiveRuntimeUsable() { 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 + + 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 @@ -2776,6 +2958,7 @@ function isBootstrapComplete() { function writeBootstrapMarker(payload) { fs.mkdirSync(path.dirname(BOOTSTRAP_COMPLETE_MARKER), { recursive: true }) + const merged = { schemaVersion: BOOTSTRAP_MARKER_SCHEMA_VERSION, pinnedCommit: payload.pinnedCommit || null, @@ -2783,16 +2966,20 @@ function writeBootstrapMarker(payload) { completedAt: new Date().toISOString(), desktopVersion: app.getVersion() } + writeFileAtomic(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) + + if (override && directoryExists(path.resolve(override))) {return path.resolve(override)} const unpackedDist = path.join(unpackedPathFor(APP_ROOT), 'dist') - if (directoryExists(unpackedDist)) return unpackedDist + + if (directoryExists(unpackedDist)) {return unpackedDist} // Final fallback: APP_ROOT/dist. When packaged with asar:true this lives // INSIDE app.asar — not a servable filesystem directory — so the embedded @@ -2801,6 +2988,7 @@ function resolveWebDist() { // unpackedDist above resolves). If we still land here while packaged, log it // so the cause isn't silent. const fallback = path.join(APP_ROOT, 'dist') + if (IS_PACKAGED && /app\.asar(?=$|[\\/])/.test(fallback) && !directoryExists(fallback)) { rememberLog( `[web-dist] dashboard frontend dir resolved to an asar-internal path that ` + @@ -2808,13 +2996,15 @@ function resolveWebDist() { `Ensure dist/** is unpacked (asarUnpack) or set HERMES_DESKTOP_WEB_DIST.` ) } + return fallback } function resolveRendererIndex() { const candidates = [path.join(APP_ROOT, 'dist', 'index.html'), path.join(resolveWebDist(), 'index.html')] const found = candidates.find(fileExists) - if (found) return found + + if (found) {return found} // Nothing on disk. A packaged build with no renderer bundle blank-pages with // a bare ERR_FILE_NOT_FOUND and no clue why (see #39484). Surface the cause // and the fix before Electron loads the missing file. @@ -2823,6 +3013,7 @@ function resolveRendererIndex() { `renderer bundle. Tried: ${candidates.join(', ')}. ` + `Rebuild with: hermes desktop --force-build` ) + return candidates[0] } @@ -2859,14 +3050,14 @@ function resolveHermesCwd() { ] for (const candidate of candidates) { - if (!candidate) continue + if (!candidate) {continue} const resolved = path.resolve(String(candidate)) if (isPackagedInstallPath(resolved)) { continue } - if (directoryExists(resolved)) return resolved + if (directoryExists(resolved)) {return resolved} } return app.getPath('home') @@ -2934,9 +3125,10 @@ function writeDefaultProjectDir(dir) { } } -function createPythonBackend(root, label, backendArgs, options = {}) { +function createPythonBackend(root, label, backendArgs, options: any = {}) { const python = findPythonForRoot(root) - if (!python) return null + + if (!python) {return null} const venvRoot = path.join(root, 'venv') const venvPython = getVenvPython(venvRoot) @@ -2986,9 +3178,11 @@ function resolveHermesBackend(backendArgs) { // 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}`, backendArgs) - if (backend) return backend + + if (backend) {return backend} } // 2. Development source -- when running `npm run dev` from a checkout, the @@ -2997,7 +3191,8 @@ function resolveHermesBackend(backendArgs) { // (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}`, backendArgs) - if (backend) return backend + + if (backend) {return backend} } // 3. Bootstrap-complete ACTIVE_HERMES_ROOT -- the canonical install at @@ -3021,6 +3216,7 @@ function resolveHermesBackend(backendArgs) { if (hermesOverride) { const resolvedOverride = findOnPath(hermesOverride) + if (resolvedOverride) { hermesCommand = resolvedOverride } else if (!isWindowsBinaryPathInWsl(hermesOverride, { isWsl: IS_WSL })) { @@ -3041,6 +3237,7 @@ function resolveHermesBackend(backendArgs) { if (hermesCommand) { const unwrapped = unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs) + if (unwrapped) { return unwrapped } @@ -3050,9 +3247,10 @@ function resolveHermesBackend(backendArgs) { // 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 + // `--version` probe (see backend-probes.ts) catches that case // and lets the resolver fall through to step 6 / bootstrap. const shellForProbe = isCommandScript(hermesCommand) + if (verifyHermesCli(hermesCommand, { shell: shellForProbe })) { return ( unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs) || { @@ -3066,6 +3264,7 @@ function resolveHermesBackend(backendArgs) { } ) } + rememberLog( `Ignoring existing Hermes CLI at ${hermesCommand}: --version probe failed; falling through to bootstrap.` ) @@ -3076,6 +3275,7 @@ function resolveHermesBackend(backendArgs) { // 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 @@ -3096,6 +3296,7 @@ function resolveHermesBackend(backendArgs) { shell: false } } + rememberLog(`Ignoring system Python ${python}: hermes_cli is not importable; falling through to bootstrap.`) } @@ -3128,6 +3329,7 @@ function resolveHermesBackend(backendArgs) { async function ensureRuntime(backend) { if (!backend.bootstrap) { await advanceBootProgress('runtime.external', `Using ${backend.label}`, 32) + return backend } @@ -3144,9 +3346,10 @@ async function ensureRuntime(backend) { rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap') if (await handOffWindowsBootstrapRecovery('bootstrap-needed')) { - const handoffError = new Error( + const handoffError: Error & {isBootstrapFailure?: boolean, bootstrapHandedOff?: boolean} = new Error( 'Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.' ) + handoffError.isBootstrapFailure = true handoffError.bootstrapHandedOff = true bootstrapFailure = handoffError @@ -3188,6 +3391,7 @@ async function ensureRuntime(backend) { } catch { void 0 } + try { broadcastBootstrapEvent(ev) } catch { @@ -3200,7 +3404,7 @@ async function ensureRuntime(backend) { bootstrapAbortController = null if (bootstrapResult.cancelled) { - const cancelledError = new Error('Hermes install was cancelled.') + const cancelledError = new Error('Hermes install was cancelled.') as any cancelledError.isBootstrapFailure = true cancelledError.bootstrapCancelled = true bootstrapFailure = cancelledError @@ -3212,7 +3416,8 @@ async function ensureRuntime(backend) { `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.` - ) + ) as any + bootstrapError.isBootstrapFailure = true bootstrapError.failedStage = bootstrapResult.failedStage || null // Latch the failure so subsequent startHermes() calls return this @@ -3223,6 +3428,7 @@ async function ensureRuntime(backend) { } 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)) @@ -3257,6 +3463,7 @@ async function ensureRuntime(backend) { } 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 @@ -3279,10 +3486,11 @@ async function ensureRuntime(backend) { running: true, error: null }) + return backend } -function fetchJson(url, token, options = {}) { +function fetchJson(url, token, options: any = {}) { return new Promise((resolve, reject) => { const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)) const parsed = new URL(url) @@ -3291,6 +3499,7 @@ function fetchJson(url, token, options = {}) { if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) + return } @@ -3310,20 +3519,26 @@ function fetchJson(url, token, options = {}) { 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( @@ -3331,8 +3546,10 @@ function fetchJson(url, token, options = {}) { 'The endpoint is likely missing on the Hermes backend.' ) ) + return } + try { resolve(JSON.parse(text)) } catch { @@ -3346,12 +3563,13 @@ function fetchJson(url, token, options = {}) { req.setTimeout(timeoutMs, () => { req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) }) - if (body) req.write(body) + + if (body) {req.write(body)} req.end() }) } -function fetchPublicJson(url, options = {}) { +function fetchPublicJson(url, options: any = {}) { // Credential-free JSON GET/POST for public gateway endpoints // (``/api/status``, ``/api/auth/providers``). Unlike ``fetchJson`` it sends // NO ``X-Hermes-Session-Token`` header — used by the auth-mode probe before @@ -3360,17 +3578,21 @@ function fetchPublicJson(url, options = {}) { return new Promise((resolve, reject) => { const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)) let parsed + try { parsed = new URL(url) } catch (error) { reject(new Error(`Invalid URL: ${error.message}`)) + return } + 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 } @@ -3388,16 +3610,22 @@ function fetchPublicJson(url, options = {}) { 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 } + const looksHtml = /^\s*<(?:!doctype|html)/i.test(text) const contentType = String(res.headers['content-type'] || '') + if (looksHtml || contentType.includes('text/html')) { reject( new Error( @@ -3405,8 +3633,10 @@ function fetchPublicJson(url, options = {}) { 'The endpoint is likely missing on the Hermes backend.' ) ) + return } + try { resolve(JSON.parse(text)) } catch { @@ -3420,7 +3650,8 @@ function fetchPublicJson(url, options = {}) { req.setTimeout(timeoutMs, () => { req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) }) - if (body) req.write(body) + + if (body) {req.write(body)} req.end() }) } @@ -3436,12 +3667,19 @@ function extensionForMimeType(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' + + 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 '' } @@ -3449,6 +3687,7 @@ 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 @@ -3462,12 +3701,15 @@ 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 @@ -3475,6 +3717,7 @@ const HTML_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: 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([ @@ -3494,7 +3737,8 @@ const renderTitleQueue = [] function canonicalTitleCacheKey(rawUrl) { const value = String(rawUrl || '').trim() - if (!value) return '' + + if (!value) {return ''} try { const url = new URL(value) @@ -3508,7 +3752,7 @@ function canonicalTitleCacheKey(rawUrl) { } function cacheTitle(key, title) { - if (titleCache.size >= TITLE_CACHE_LIMIT) titleCache.delete(titleCache.keys().next().value) + if (titleCache.size >= TITLE_CACHE_LIMIT) {titleCache.delete(titleCache.keys().next().value)} titleCache.set(key, title) } @@ -3521,13 +3765,15 @@ function decodeHtmlEntities(value) { function parseHtmlTitle(html) { const raw = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] + return raw ? decodeHtmlEntities(raw).replace(/\s+/g, ' ').trim() : '' } -function fetchHtmlTitleWithCurl(rawUrl) { +function fetchHtmlTitleWithCurl(rawUrl: string): Promise<string> { return new Promise(resolve => { const url = String(rawUrl || '').trim() - if (!url) return resolve('') + + if (!url) {return resolve('')} const args = [ '--silent', @@ -3550,12 +3796,13 @@ function fetchHtmlTitleWithCurl(rawUrl) { '--raw', url ] + const child = spawn('curl', args, hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'ignore'] })) const chunks = [] let bytes = 0 child.stdout.on('data', chunk => { - if (bytes >= TITLE_BYTE_BUDGET) return + 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 @@ -3565,19 +3812,20 @@ function fetchHtmlTitleWithCurl(rawUrl) { child.on('error', () => resolve('')) child.on('close', () => { - if (!chunks.length) return resolve('') + if (!chunks.length) {return resolve('')} resolve(parseHtmlTitle(Buffer.concat(chunks).toString('utf8'))) }) }) } function getLinkTitleSession() { - if (linkTitleSession || !app.isReady()) return linkTitleSession + 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) }) }) guardLinkTitleSession(linkTitleSession) + return linkTitleSession } @@ -3595,10 +3843,11 @@ function dequeueRenderTitle() { function runRenderTitleJob(rawUrl) { return new Promise(resolve => { - if (!app.isReady()) return resolve('') + if (!app.isReady()) {return resolve('')} const partitionSession = getLinkTitleSession() - if (!partitionSession) return resolve('') + + if (!partitionSession) {return resolve('')} let settled = false let window = null @@ -3606,16 +3855,20 @@ function runRenderTitleJob(rawUrl) { let graceTimer = null const finish = title => { - if (settled) return + if (settled) {return} settled = true - if (hardTimer) clearTimeout(hardTimer) - if (graceTimer) clearTimeout(graceTimer) + + if (hardTimer) {clearTimeout(hardTimer)} + + if (graceTimer) {clearTimeout(graceTimer)} const value = (title || '').replace(/\s+/g, ' ').trim() + try { - if (window && !window.isDestroyed()) window.destroy() + if (window && !window.isDestroyed()) {window.destroy()} } catch { // BrowserWindow may already be torn down; ignore. } + resolve(value) } @@ -3626,8 +3879,9 @@ function runRenderTitleJob(rawUrl) { } const finishWithTitle = () => finish(readLinkTitleWindowTitle(window)) + const scheduleGrace = () => { - if (graceTimer) clearTimeout(graceTimer) + if (graceTimer) {clearTimeout(graceTimer)} graceTimer = setTimeout(finishWithTitle, RENDER_TITLE_GRACE_MS) } @@ -3637,7 +3891,7 @@ function runRenderTitleJob(rawUrl) { 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('') + if (isMainFrame) {finish('')} }) window @@ -3649,7 +3903,7 @@ function runRenderTitleJob(rawUrl) { }) } -function fetchHtmlTitleWithRenderer(rawUrl) { +function fetchHtmlTitleWithRenderer(rawUrl: string): Promise<string> { return new Promise(resolve => { renderTitleQueue.push({ resolve, url: rawUrl }) dequeueRenderTitle() @@ -3658,14 +3912,19 @@ function fetchHtmlTitleWithRenderer(rawUrl) { // 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 usableTitle (value: string): string { + return (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) + + 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(() => '') @@ -3676,38 +3935,48 @@ function fetchLinkTitle(rawUrl) { .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) {throw new Error('Missing URL')} + if (rawUrl.startsWith('data:')) { const match = rawUrl.match(/^data:([^;,]+)?(;base64)?,(.*)$/s) - if (!match) throw new Error('Invalid data URL') + + 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 (/^file:/i.test(rawUrl)) { const { resolvedPath } = await resolveReadableFileForIpc(rawUrl, { purpose: 'Image file' }) const buffer = await fs.promises.readFile(resolvedPath) + return { buffer, mimeType: mimeTypeForPath(resolvedPath) } } 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('error', reject) res.on('data', chunk => chunks.push(chunk)) @@ -3718,26 +3987,31 @@ async function resourceBufferFromUrl(rawUrl) { }) }) }) + req.on('error', reject) }) } async function copyImageFromUrl(rawUrl) { - const { buffer } = await resourceBufferFromUrl(rawUrl) + const { buffer } = await resourceBufferFromUrl(rawUrl) as any const image = nativeImage.createFromBuffer(buffer) - if (image.isEmpty()) throw new Error('Could not read image') + + if (image.isEmpty()) {throw new Error('Could not read image')} clipboard.writeImage(image) } async function saveImageFromUrl(rawUrl) { - const { buffer, mimeType } = await resourceBufferFromUrl(rawUrl) + const { buffer, mimeType } = await resourceBufferFromUrl(rawUrl) as any 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 + + if (result.canceled || !result.filePath) {return false} await fs.promises.writeFile(result.filePath, buffer) + return true } @@ -3745,6 +4019,7 @@ 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') @@ -3753,6 +4028,7 @@ async function writeComposerImage(buffer, ext = '.png') { const random = crypto.randomBytes(3).toString('hex') const filePath = path.join(dir, `composer_${stamp}_${random}${safeExt}`) await fs.promises.writeFile(filePath, buffer) + return filePath } @@ -3777,6 +4053,7 @@ function expandUserPath(filePath) { async function previewFileTarget(rawTarget, baseDir) { const raw = String(rawTarget || '').trim() const base = baseDir ? path.resolve(expandUserPath(baseDir)) : resolveHermesCwd() + let resolved = resolveRequestedPathForIpc(/^file:/i.test(raw) ? raw : expandUserPath(raw), { baseDir: base, purpose: 'Preview target' @@ -3787,6 +4064,7 @@ async function previewFileTarget(rawTarget, baseDir) { } const ext = path.extname(resolved).toLowerCase() + if (!fileExists(resolved)) { return null } @@ -3858,13 +4136,15 @@ async function normalizePreviewTarget(rawTarget, baseDir) { async function filePathFromPreviewUrl(rawUrl) { const { resolvedPath } = await resolveReadableFileForIpc(String(rawUrl || ''), { purpose: 'Preview file' }) + return resolvedPath } function sendPreviewFileChanged(payload) { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) {return} const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) {return} webContents.send('hermes:preview-file-changed', payload) } @@ -3874,6 +4154,7 @@ async function watchPreviewFile(rawUrl) { 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)) : '' @@ -3881,17 +4162,18 @@ async function watchPreviewFile(rawUrl) { return } - if (timer) clearTimeout(timer) + if (timer) {clearTimeout(timer)} timer = setTimeout(() => { timer = null - if (!fileExists(filePath)) return + + if (!fileExists(filePath)) {return} sendPreviewFileChanged({ id, path: filePath, url: pathToFileURL(filePath).toString() }) }, PREVIEW_WATCH_DEBOUNCE_MS) }) previewWatchers.set(id, { close: () => { - if (timer) clearTimeout(timer) + if (timer) {clearTimeout(timer)} watcher.close() } }) @@ -3925,6 +4207,7 @@ async function waitForHermes(baseUrl, token) { while (Date.now() < deadline) { try { await fetchJson(`${baseUrl}/api/status`, token) + return } catch (error) { lastError = error @@ -3936,7 +4219,8 @@ async function waitForHermes(baseUrl, token) { } function getWindowButtonPosition() { - if (!IS_MAC) return null + if (!IS_MAC) {return null} + return mainWindow?.getWindowButtonPosition?.() || WINDOW_BUTTON_POSITION } @@ -3953,16 +4237,18 @@ function getWindowState() { } function sendBackendExit(payload) { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) {return} const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) {return} webContents.send('hermes:backend-exit', payload) } function sendClosePreviewRequested() { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) {return} const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) {return} webContents.send('hermes:close-preview-requested') } @@ -3970,17 +4256,19 @@ function sendClosePreviewRequested() { // renderer's WebSocket to the local backend; the renderer reconnects on this // signal so the chat composer doesn't stay stuck on "Starting Hermes...". function sendPowerResume() { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) {return} const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) {return} webContents.send('hermes:power-resume') } let powerResumeRegistered = false function registerPowerResumeListeners() { - if (powerResumeRegistered) return + if (powerResumeRegistered) {return} powerResumeRegistered = true + try { // 'resume' covers sleep/wake; 'unlock-screen' covers lock/unlock without a // full suspend. Either can drop an idle socket. @@ -3997,18 +4285,21 @@ function getAppIconPath() { } function sendOpenUpdatesRequested() { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) {return} const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) {return} webContents.send('hermes:open-updates') - if (!mainWindow.isVisible()) mainWindow.show() + + if (!mainWindow.isVisible()) {mainWindow.show()} mainWindow.focus() } -function sendWindowStateChanged(nextIsFullscreen) { - if (!mainWindow || mainWindow.isDestroyed()) return +function sendWindowStateChanged(nextIsFullscreen?: boolean) { + if (!mainWindow || mainWindow.isDestroyed()) {return} const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) {return} const state = getWindowState() if (typeof nextIsFullscreen === 'boolean') { @@ -4020,10 +4311,12 @@ function sendWindowStateChanged(nextIsFullscreen) { function buildApplicationMenu() { const template = [] + const checkForUpdatesItem = { label: 'Check for Updates…', click: () => sendOpenUpdatesRequested() } + if (IS_MAC) { template.push({ label: APP_NAME, @@ -4130,6 +4423,7 @@ function toggleDevTools(window) { // 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 { @@ -4141,11 +4435,13 @@ 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 + + if (!isInspectShortcut) {return} event.preventDefault() toggleDevTools(window) }) @@ -4156,7 +4452,7 @@ function installPreviewShortcut(window) { const key = String(input.key || '').toLowerCase() const isPreviewCloseShortcut = key === 'w' && (IS_MAC ? input.meta : input.control) && !input.alt && !input.shift - if (!isPreviewCloseShortcut || !previewShortcutActive) return + if (!isPreviewCloseShortcut || !previewShortcutActive) {return} event.preventDefault() sendClosePreviewRequested() @@ -4167,10 +4463,11 @@ function installPreviewShortcut(window) { // survives reloads/restarts) rather than a main-process JSON file. The main // process owns setZoomLevel, so we mirror each change into localStorage and // read it back on did-finish-load to re-apply after reloads or crash recovery. -const { ZOOM_STORAGE_KEY, clampZoomLevel, percentToZoomLevel, zoomLevelToPercent } = require('./zoom.cjs') +import { clampZoomLevel, percentToZoomLevel, ZOOM_STORAGE_KEY, zoomLevelToPercent } from './zoom' + function setAndPersistZoomLevel(window, zoomLevel) { - if (!window || window.isDestroyed()) return + if (!window || window.isDestroyed()) {return} const next = clampZoomLevel(zoomLevel) window.webContents.setZoomLevel(next) // Keep any open settings UI in sync, including changes made via the @@ -4184,13 +4481,13 @@ function setAndPersistZoomLevel(window, zoomLevel) { } function restorePersistedZoomLevel(window) { - if (!window || window.isDestroyed()) return + if (!window || window.isDestroyed()) {return} window.webContents .executeJavaScript( `(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()` ) .then(stored => { - if (stored == null || !window || window.isDestroyed()) return + if (stored == null || !window || window.isDestroyed()) {return} const level = clampZoomLevel(Number(stored)) window.webContents.setZoomLevel(level) }) @@ -4205,9 +4502,11 @@ function installZoomShortcuts(window) { const ZOOM_STEP = 0.1 window.webContents.on('before-input-event', (event, input) => { const mod = IS_MAC ? input.meta : input.control - if (!mod || input.alt || input.shift) return + + if (!mod || input.alt || input.shift) {return} const key = input.key + if (key === '0') { event.preventDefault() setAndPersistZoomLevel(window, 0) @@ -4260,7 +4559,7 @@ function installContextMenu(window) { } if (hasLink) { - if (template.length) template.push({ type: 'separator' }) + if (template.length) {template.push({ type: 'separator' })} template.push( { label: 'Open Link', @@ -4279,7 +4578,7 @@ function installContextMenu(window) { const suggestions = Array.isArray(params.dictionarySuggestions) ? params.dictionarySuggestions : [] if (isEditable && params.misspelledWord && suggestions.length > 0) { - if (template.length) template.push({ type: 'separator' }) + if (template.length) {template.push({ type: 'separator' })} for (const suggestion of suggestions.slice(0, 5)) { template.push({ @@ -4296,7 +4595,8 @@ function installContextMenu(window) { } if (hasSelection || isEditable) { - if (template.length) template.push({ type: 'separator' }) + if (template.length) {template.push({ type: 'separator' })} + if (isEditable) { template.push( { role: 'cut', enabled: params.editFlags.canCut }, @@ -4332,15 +4632,19 @@ 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') } @@ -4355,9 +4659,10 @@ function installMediaPermissions() { // 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') { + if (permission === 'media' || permission === 'audioCapture' as any /* todo: is this needed? */) { // details.mediaType is a single string here (not the mediaTypes array). const mediaType = details?.mediaType + if (mediaType === 'video') { return false } @@ -4401,27 +4706,32 @@ function installMediaPermissions() { const OAUTH_SESSION_PARTITION = 'persist:hermes-remote-oauth' function getOauthSession() { - if (oauthSession || !app.isReady()) return oauthSession + if (oauthSession || !app.isReady()) {return oauthSession} oauthSession = session.fromPartition(OAUTH_SESSION_PARTITION) + return oauthSession } // Bare + prefixed variants of the session cookies live in -// connection-config.cjs (cookiesHaveSession / cookiesHaveLiveSession). See +// connection-config.ts (cookiesHaveSession / cookiesHaveLiveSession). See // that module for details. async function hasOauthSessionCookie(baseUrl) { const sess = getOauthSession() - if (!sess) return false + + if (!sess) {return false} const parsed = new URL(baseUrl) + try { // Query by URL so the cookie jar applies Domain/Path/Secure scoping for us. const cookies = await sess.cookies.get({ url: baseUrl }) + return cookiesHaveSession(cookies) } catch { // Fall back to a host match if the URL query path errors. try { const cookies = await sess.cookies.get({ domain: parsed.hostname }) + return cookiesHaveSession(cookies) } catch { return false @@ -4438,14 +4748,18 @@ async function hasOauthSessionCookie(baseUrl) { // cheap early-out before attempting a network round-trip in resolveRemoteBackend. async function hasLiveOauthSession(baseUrl) { const sess = getOauthSession() - if (!sess) return false + + if (!sess) {return false} const parsed = new URL(baseUrl) + try { const cookies = await sess.cookies.get({ url: baseUrl }) + return cookiesHaveLiveSession(cookies) } catch { try { const cookies = await sess.cookies.get({ domain: parsed.hostname }) + return cookiesHaveLiveSession(cookies) } catch { return false @@ -4455,13 +4769,16 @@ async function hasLiveOauthSession(baseUrl) { async function clearOauthSession(baseUrl) { const sess = getOauthSession() - if (!sess) return + + if (!sess) {return} + try { const cookies = await sess.cookies.get(baseUrl ? { url: baseUrl } : {}) await Promise.all( cookies.map(c => { const scheme = c.secure ? 'https' : 'http' const cookieUrl = `${scheme}://${c.domain.replace(/^\./, '')}${c.path || '/'}` + return sess.cookies.remove(cookieUrl, c.name).catch(() => undefined) }) ) @@ -4479,11 +4796,15 @@ function openOauthLoginWindow(baseUrl) { return new Promise((resolve, reject) => { if (!app.isReady()) { reject(new Error('Desktop is not ready to start an OAuth login.')) + return } + const sess = getOauthSession() + if (!sess) { reject(new Error('OAuth session partition is unavailable.')) + return } @@ -4492,21 +4813,25 @@ function openOauthLoginWindow(baseUrl) { let pollTimer = null const finish = err => { - if (settled) return + if (settled) {return} settled = true - if (pollTimer) clearInterval(pollTimer) + + if (pollTimer) {clearInterval(pollTimer)} + try { - if (win && !win.isDestroyed()) win.destroy() + if (win && !win.isDestroyed()) {win.destroy()} } catch { // window already torn down } - if (err) reject(err) - else resolve({ baseUrl, ok: true }) + + if (err) {reject(err)} + else {resolve({ baseUrl, ok: true })} } const checkCookie = async () => { - if (settled) return - if (await hasOauthSessionCookie(baseUrl)) finish(null) + if (settled) {return} + + if (await hasOauthSessionCookie(baseUrl)) {finish(null)} } try { @@ -4525,6 +4850,7 @@ function openOauthLoginWindow(baseUrl) { }) } catch (error) { finish(error instanceof Error ? error : new Error(String(error))) + return } @@ -4537,7 +4863,7 @@ function openOauthLoginWindow(baseUrl) { pollTimer = setInterval(() => void checkCookie(), 750) win.on('closed', () => { - if (!settled) finish(new Error('Login window closed before authentication completed.')) + if (!settled) {finish(new Error('Login window closed before authentication completed.'))} }) // ``next`` is intentionally omitted: the gateway lands on ``/`` after @@ -4553,24 +4879,32 @@ function openOauthLoginWindow(baseUrl) { // JSON request routed through the OAuth session partition so the HttpOnly // session cookie is attached automatically by Electron's net stack. Used for // authed REST against a gated gateway, including minting WS tickets. -function fetchJsonViaOauthSession(url, options = {}) { +function fetchJsonViaOauthSession(url, options: any = {}) { return new Promise((resolve, reject) => { const sess = getOauthSession() + if (!sess) { reject(new Error('OAuth session partition is unavailable.')) + return } + let parsed + try { parsed = new URL(url) } catch (error) { reject(new Error(`Invalid URL: ${error.message}`)) + return } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) + return } + const body = serializeJsonBody(options.body) const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) @@ -4580,17 +4914,21 @@ function fetchJsonViaOauthSession(url, options = {}) { session: sess, useSessionCookies: true, redirect: 'follow' - }) + } as any) + setJsonRequestHeaders(request) let timedOut = false + const timer = setTimeout(() => { timedOut = true + try { request.abort() } catch { // already finished } + reject(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) }, timeoutMs) @@ -4598,26 +4936,34 @@ function fetchJsonViaOauthSession(url, options = {}) { const chunks = [] res.on('data', chunk => chunks.push(Buffer.from(chunk))) res.on('end', () => { - if (timedOut) return + if (timedOut) {return} clearTimeout(timer) const text = Buffer.concat(chunks).toString('utf8') const statusCode = res.statusCode || 500 + if (statusCode >= 400) { - const err = new Error(`${statusCode}: ${text || ''}`) + const err = new Error(`${statusCode}: ${text || ''}`) as any err.statusCode = statusCode reject(err) + return } + if (!text) { resolve(null) + return } + const looksHtml = /^\s*<(?:!doctype|html)/i.test(text) const contentType = String(res.headers['content-type'] || res.headers['Content-Type'] || '') + if (looksHtml || contentType.includes('text/html')) { reject(new Error(`Expected JSON from ${url} but got HTML (status ${statusCode}).`)) + return } + try { resolve(JSON.parse(text)) } catch { @@ -4626,11 +4972,12 @@ function fetchJsonViaOauthSession(url, options = {}) { }) }) request.on('error', error => { - if (timedOut) return + if (timedOut) {return} clearTimeout(timer) reject(error) }) - if (body) request.write(body) + + if (body) {request.write(body)} request.end() }) } @@ -4642,11 +4989,14 @@ async function mintGatewayWsTicket(baseUrl) { const body = await fetchJsonViaOauthSession(`${baseUrl}/api/auth/ws-ticket`, { method: 'POST', timeoutMs: 8_000 - }) + }) as any + const ticket = body?.ticket + if (!ticket || typeof ticket !== 'string') { throw new Error('Gateway did not return a WS ticket.') } + return ticket } @@ -4664,10 +5014,13 @@ async function freshGatewayWsUrl(profile) { // the wrong profile's DB. A null/empty profile resolves to the primary, so // legacy callers and single-profile users are unchanged. const connection = await ensureBackend(profile) + if (connection.authMode === 'oauth') { const ticket = await mintGatewayWsTicket(connection.baseUrl) + return buildGatewayWsUrlWithTicket(connection.baseUrl, ticket) } + // Local/token: the cached wsUrl already carries the (long-lived) token. return connection.wsUrl } @@ -4701,29 +5054,35 @@ function decryptDesktopSecret(secret) { // Validate + normalize the per-profile remote overrides map read from disk. // Drops malformed names/entries and keeps only the recognized fields so a // hand-edited or stale connection.json can't inject junk into resolution. -function sanitizeConnectionProfiles(raw) { +function sanitizeConnectionProfiles(raw: Record<string, any>) { if (!raw || typeof raw !== 'object') { return {} } const out = {} + for (const [name, entry] of Object.entries(raw)) { if (!entry || typeof entry !== 'object') { continue } + if (name !== 'default' && !PROFILE_NAME_RE.test(name)) { continue } - const cleaned = { mode: entry.mode === 'remote' ? 'remote' : 'local' } + const cleaned: {mode: 'remote' | 'local', url?: string, authMode?: string, token?: object } ={ mode: entry.mode === 'remote' ? 'remote' : 'local', } const url = String(entry.url || '').trim() + if (url) { cleaned.url = url } + cleaned.authMode = normAuthMode(entry.authMode) - if (entry.token && typeof entry.token === 'object') { + + if ((entry as any).token && typeof entry.token === 'object') { cleaned.token = entry.token } + out[name] = cleaned } @@ -4735,6 +5094,7 @@ function readDesktopConnectionConfig() { // process or an external tool). Our own writes update the cache inline // via writeDesktopConnectionConfig, but external changes would be missed. let mtime = null + try { mtime = fs.statSync(DESKTOP_CONNECTION_CONFIG_PATH).mtimeMs } catch { @@ -4832,6 +5192,7 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon const mode = envOverride || (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local' let remoteOauthConnected = false + if (authMode === 'oauth' && remoteUrl) { try { // Display signal: treat a live RT cookie as "connected" even if the AT @@ -4866,10 +5227,11 @@ function buildRemoteBlock(remoteUrl, authMode, token) { if (authMode !== 'oauth' && !decryptDesktopSecret(token)) { throw new Error('Remote gateway session token is required.') } + return { url: normalizeRemoteBaseUrl(remoteUrl), authMode, token } } -function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) { +function coerceDesktopConnectionConfig(input :any= {}, existing = readDesktopConnectionConfig(), options: any = {}) { const persistToken = options.persistToken !== false const key = connectionScopeKey(input.profile) const mode = input.mode === 'remote' ? 'remote' : 'local' @@ -4880,6 +5242,7 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect // authMode: explicit input wins; otherwise inherit the saved value, default 'token'. const authMode = resolveAuthMode(input.remoteAuthMode, existingBlock.authMode) const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : '' + const nextToken = incomingToken ? persistToken ? encryptDesktopSecret(incomingToken) @@ -4890,11 +5253,13 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect // Per-profile scope: a remote entry pins this profile to its own backend; a // local entry clears the override so the profile inherits the default. const profiles = { ...(existing.profiles || {}) } + if (mode === 'remote') { profiles[key] = { mode: 'remote', ...buildRemoteBlock(remoteUrl, authMode, nextToken) } } else { delete profiles[key] } + return { mode: existing.mode === 'remote' ? 'remote' : 'local', remote: existing.remote || {}, profiles } } @@ -4928,18 +5293,21 @@ async function buildRemoteConnection(rawUrl, authMode, token, source) { const err = new Error( 'Remote Hermes gateway uses OAuth, but you are not signed in. ' + 'Open Settings → Gateway and click "Sign in", or switch back to Local.' - ) + ) as any + err.needsOauthLogin = true throw err } let ticket + try { ticket = await mintGatewayWsTicket(baseUrl) } catch (error) { const err = new Error( 'Your remote gateway session has expired. ' + 'Open Settings → Gateway and click "Sign in" again.' - ) + ) as any + err.needsOauthLogin = true err.cause = error throw err @@ -4987,14 +5355,17 @@ async function resolveRemoteBackend(profile) { // over the env override so an explicitly-configured profile always // reaches its intended backend. const override = profileRemoteOverride(config, profile) + if (override) { const token = override.authMode === 'oauth' ? null : decryptDesktopSecret(override.token) + return buildRemoteConnection(override.url, override.authMode, token, 'profile') } // 2. Env override (global, token-auth only). const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN + if (rawEnvUrl) { if (!rawEnvToken) { throw new Error( @@ -5002,6 +5373,7 @@ async function resolveRemoteBackend(profile) { 'Both must be provided to connect to a remote Hermes backend.' ) } + return buildRemoteConnection(rawEnvUrl, 'token', rawEnvToken, 'env') } @@ -5009,8 +5381,10 @@ async function resolveRemoteBackend(profile) { if (config.mode !== 'remote') { return null } + const authMode = normAuthMode(config.remote?.authMode) const token = authMode === 'oauth' ? null : decryptDesktopSecret(config.remote?.token) + return buildRemoteConnection(config.remote?.url, authMode, token, 'settings') } @@ -5024,6 +5398,7 @@ function profileHasRemoteOverride(profile) { function configuredRemoteProfileNames() { const config = readDesktopConnectionConfig() + return Object.keys(config.profiles || {}).filter(name => profileRemoteOverride(config, name)) } @@ -5034,6 +5409,7 @@ function globalRemoteActive() { if (process.env.HERMES_DESKTOP_REMOTE_URL) { return true } + return readDesktopConnectionConfig().mode === 'remote' } @@ -5043,10 +5419,11 @@ async function fetchJsonForProfile(profile, path) { } // Issue an arbitrary method against a profile's resolved backend, parsed JSON. -async function requestJsonForProfile(profile, path, method, body) { +async function requestJsonForProfile(profile: string, path: string, method: Method, body?: string) { const conn = await ensureBackend(profile) const url = `${conn.baseUrl}${path}` const opts = { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS } + return conn.authMode === 'oauth' ? fetchJsonViaOauthSession(url, opts) : fetchJson(url, conn.token, opts) } @@ -5066,9 +5443,10 @@ async function probeRemoteAuthMode(rawUrl) { const baseUrl = normalizeRemoteBaseUrl(rawUrl) let status + try { status = await fetchPublicJson(`${baseUrl}/api/status`, { timeoutMs: 8_000 }) - } catch (error) { + } catch (error: any) { return { baseUrl, reachable: false, @@ -5089,7 +5467,8 @@ async function probeRemoteAuthMode(rawUrl) { // an OAuth-redirect one (``supports_password``). A failure here doesn't // change the auth mode, so swallow it. try { - const body = await fetchPublicJson(`${baseUrl}/api/auth/providers`, { timeoutMs: 8_000 }) + const body = await fetchPublicJson(`${baseUrl}/api/auth/providers`, { timeoutMs: 8_000 }) as any + if (Array.isArray(body?.providers)) { providers = body.providers .filter(p => p && typeof p === 'object') @@ -5115,14 +5494,16 @@ async function probeRemoteAuthMode(rawUrl) { } } -async function testDesktopConnectionConfig(input = {}) { +async function testDesktopConnectionConfig(input: any = {}) { const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false }) const key = connectionScopeKey(input.profile) // The block under test: a per-profile entry or the global remote. Coerce has // already normalized the URL and resolved token inheritance for the scope. const block = key ? config.profiles?.[key] || null : config.remote + const wantRemote = block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block) + // ``/api/status`` is public on every gateway (no creds needed), so a // reachability test works for local, token, and oauth modes alike — we only // need a base URL. For a remote config we normalize the URL from the input; @@ -5130,9 +5511,11 @@ async function testDesktopConnectionConfig(input = {}) { let baseUrl let token = null let authMode = 'token' + if (wantRemote && block?.url) { baseUrl = normalizeRemoteBaseUrl(block.url) authMode = normAuthMode(block.authMode) + if (authMode !== 'oauth') { token = decryptDesktopSecret(block.token) } @@ -5142,7 +5525,8 @@ async function testDesktopConnectionConfig(input = {}) { token = remote.token authMode = normAuthMode(remote.authMode) } - const status = await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000 }) + + const status = await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000 }) as any // The HTTP status check above proves the backend is reachable, but the chat // surface only works once the renderer's live WebSocket to ``/api/ws`` @@ -5152,11 +5536,13 @@ async function testDesktopConnectionConfig(input = {}) { // connect to Hermes gateway". Mirror the renderer's connect here so the test // reflects the full path the app actually uses. const wsUrl = await resolveTestWsUrl(baseUrl, authMode, token, { mintTicket: mintGatewayWsTicket }) + // Skip the WS leg only when the runtime genuinely lacks a WebSocket (so an // older Electron/Node never fails the test spuriously); Electron's main // process ships a global WebSocket on every supported version. if (wsUrl && typeof globalThis.WebSocket === 'function') { const probe = await probeGatewayWebSocket(wsUrl, { WebSocketImpl: globalThis.WebSocket }) + if (!probe.ok) { throw new Error( `Reached the gateway over HTTP, but the live WebSocket (/api/ws) connection failed: ${probe.reason} ` + @@ -5186,7 +5572,8 @@ function resetBootProgressForReconnect() { } function stopBackendChild(child) { - if (!child || child.killed) return + if (!child || child.killed) {return} + try { if (IS_WINDOWS && Number.isInteger(child.pid)) { forceKillProcessTree(child.pid) @@ -5224,11 +5611,12 @@ async function waitForBackendExit(child, timeoutMs = 5000) { if (!child) { return } + if (child.exitCode !== null || child.signalCode !== null) { return } - await new Promise(resolve => { + await new Promise<void>(resolve => { const timer = setTimeout(() => { try { if (IS_WINDOWS && Number.isInteger(child.pid)) { @@ -5239,8 +5627,10 @@ async function waitForBackendExit(child, timeoutMs = 5000) { } catch { // Already gone. } + resolve() }, timeoutMs) + child.once('exit', () => { clearTimeout(timer) resolve() @@ -5267,8 +5657,10 @@ async function ensureBackend(profile) { } const existing = backendPool.get(key) + if (existing) { existing.lastActiveAt = Date.now() + return existing.connectionPromise } @@ -5281,6 +5673,7 @@ async function ensureBackend(profile) { }) backendPool.set(key, entry) startPoolIdleReaper() + return entry.connectionPromise } @@ -5289,9 +5682,11 @@ async function ensureBackend(profile) { // streaming, since the main process can't see the direct renderer↔backend WS. function touchPoolBackend(profile) { const key = profile && String(profile).trim() ? String(profile).trim() : null - if (!key) return + + if (!key) {return} const entry = backendPool.get(key) - if (entry) entry.lastActiveAt = Date.now() + + if (entry) {entry.lastActiveAt = Date.now()} } // Evict least-recently-used pool backends until at most `keep` remain — but only @@ -5299,14 +5694,17 @@ function touchPoolBackend(profile) { // window). When every backend is actively kept alive we let the pool exceed the // soft cap rather than kill a running session. function evictLruPoolBackends(keep) { - if (backendPool.size <= keep) return + if (backendPool.size <= keep) {return} const now = Date.now() + const evictable = [...backendPool.entries()] .filter(([, entry]) => now - (entry.lastActiveAt || 0) > POOL_KEEPALIVE_FRESH_MS) .sort((a, b) => (a[1].lastActiveAt || 0) - (b[1].lastActiveAt || 0)) + let removable = backendPool.size - Math.max(0, keep) + for (const [profile] of evictable) { - if (removable <= 0) break + if (removable <= 0) {break} rememberLog(`Evicting idle profile backend "${profile}" (LRU cap ${POOL_MAX_BACKENDS})`) stopPoolBackend(profile) removable -= 1 @@ -5314,21 +5712,24 @@ function evictLruPoolBackends(keep) { } function startPoolIdleReaper() { - if (poolIdleReaper) return + if (poolIdleReaper) {return} poolIdleReaper = setInterval(() => { const now = Date.now() + for (const [profile, entry] of [...backendPool.entries()]) { if (now - (entry.lastActiveAt || 0) > POOL_IDLE_MS) { rememberLog(`Reaping idle profile backend "${profile}" (idle > ${Math.round(POOL_IDLE_MS / 1000)}s)`) stopPoolBackend(profile) } } + if (backendPool.size === 0 && poolIdleReaper) { clearInterval(poolIdleReaper) poolIdleReaper = null } }, 60_000) - if (typeof poolIdleReaper.unref === 'function') poolIdleReaper.unref() + + if (typeof poolIdleReaper.unref === 'function') {poolIdleReaper.unref()} } // Spawn an additional dashboard backend pinned to a named profile. Mirrors the @@ -5342,8 +5743,10 @@ async function spawnPoolBackend(profile, entry) { // entry keeps `entry.process === null`, which stopPoolBackend/evict already // tolerate. const remote = await resolveRemoteBackend(profile) + if (remote) { await waitForHermes(remote.baseUrl, remote.token) + return { ...remote, profile, @@ -5390,6 +5793,7 @@ async function spawnPoolBackend(profile, entry) { stdio: ['ignore', 'pipe', 'pipe'] }) ) + entry.process = child entry.token = token @@ -5398,9 +5802,11 @@ async function spawnPoolBackend(profile, entry) { let ready = false let rejectStart = null + const startFailed = new Promise((_resolve, reject) => { rejectStart = reject }) + child.once('error', error => { rememberLog(`Hermes backend for profile "${profile}" failed to start: ${error.message}`) backendPool.delete(profile) @@ -5409,6 +5815,7 @@ async function spawnPoolBackend(profile, entry) { child.once('exit', (code, signal) => { rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`) backendPool.delete(profile) + if (!ready) { rejectStart?.( new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`) @@ -5418,19 +5825,23 @@ async function spawnPoolBackend(profile, entry) { // Discover the ephemeral port the child bound to const port = await Promise.race([waitForDashboardPortAnnouncement(child, { readyFile }), startFailed]) + if (readyFile) { fs.unlink(readyFile, () => {}) } + entry.port = port const baseUrl = `http://127.0.0.1:${port}` await Promise.race([waitForHermes(baseUrl, token), startFailed]) ready = true + const authToken = await adoptServedDashboardToken(baseUrl, token, { childAlive: () => child.exitCode === null && !child.killed, label: `Hermes backend for profile "${profile}"`, rememberLog }) + entry.token = authToken return { @@ -5448,14 +5859,16 @@ async function spawnPoolBackend(profile, entry) { function stopPoolBackend(profile) { const entry = backendPool.get(profile) - if (!entry) return + + if (!entry) {return} backendPool.delete(profile) stopBackendChild(entry.process) } async function teardownPoolBackendAndWait(profile) { const entry = backendPool.get(profile) - if (!entry) return + + if (!entry) {return} backendPool.delete(profile) stopBackendChild(entry.process) @@ -5475,11 +5888,13 @@ function profileNameFromDeleteRequest(request) { } const match = String(request.path || '').match(/^\/api\/profiles\/([^/?#]+)(?:[?#].*)?$/) + if (!match) { return null } let raw = '' + try { raw = decodeURIComponent(match[1]) } catch { @@ -5487,12 +5902,15 @@ function profileNameFromDeleteRequest(request) { } const name = raw.trim() + if (!name) { return null } + if (name.toLowerCase() === 'default') { return 'default' } + return name.toLowerCase() } @@ -5502,6 +5920,7 @@ function profileNameFromDeleteRequest(request) { // backend whose ensure_hermes_home() recreates the deleted profile directory. async function prepareProfileDeleteRequest(request) { const profile = profileNameFromDeleteRequest(request) + if (!profile || profile === 'default' || !PROFILE_NAME_RE.test(profile)) { return null } @@ -5509,10 +5928,12 @@ async function prepareProfileDeleteRequest(request) { if (profile === primaryProfileKey()) { writeActiveDesktopProfile('default') await teardownPrimaryBackendAndWait() + return profile } await teardownPoolBackendAndWait(profile) + return profile } @@ -5526,16 +5947,19 @@ async function startHermes() { if (bootstrapFailure) { throw bootstrapFailure } + if (backendStartFailure) { throw backendStartFailure } - if (connectionPromise) return connectionPromise + + if (connectionPromise) {return connectionPromise} connectionPromise = (async () => { await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8) // Resolve for the desktop's primary profile so a per-profile remote // override on the active profile is honored (falls back to env / global). const remote = await resolveRemoteBackend(primaryProfileKey()) + if (remote) { await advanceBootProgress('backend.remote', `Connecting to remote Hermes backend at ${remote.baseUrl}`, 24) await waitForHermes(remote.baseUrl, remote.token) @@ -5546,6 +5970,7 @@ async function startHermes() { running: true, error: null }) + return { baseUrl: remote.baseUrl, mode: 'remote', @@ -5575,9 +6000,11 @@ async function startHermes() { // unset preference keeps the legacy launch so existing installs are // unaffected. const activeProfile = readActiveDesktopProfile() + if (activeProfile) { backendArgs.unshift('--profile', activeProfile) } + await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28) const backend = await ensureRuntime(resolveHermesBackend(backendArgs)) // Route old runtimes (no `serve`) through the legacy `dashboard --no-open`. @@ -5623,9 +6050,11 @@ async function startHermes() { 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( @@ -5647,6 +6076,7 @@ async function startHermes() { hermesProcess = null connectionPromise = null sendBackendExit({ code, signal }) + if (!backendReady) { const message = `Hermes backend exited before it became ready (${signal || code}).` updateBootProgress( @@ -5667,11 +6097,13 @@ async function startHermes() { }) await advanceBootProgress('backend.port', 'Waiting for Hermes backend to launch', 86) + // Discover the ephemeral port the child bound to const port = await Promise.race([ waitForDashboardPortAnnouncement(hermesProcess, { readyFile }), backendStartFailed ]) + if (readyFile) { fs.unlink(readyFile, () => {}) } @@ -5681,11 +6113,13 @@ async function startHermes() { await Promise.race([waitForHermes(baseUrl, token), backendStartFailed]) backendReady = true backendStartFailure = null + const authToken = await adoptServedDashboardToken(baseUrl, token, { // The exit/error handlers null hermesProcess when the child dies. childAlive: () => hermesProcess !== null && hermesProcess.exitCode === null && !hermesProcess.killed, rememberLog }) + updateBootProgress({ phase: 'backend.ready', message: 'Hermes backend is ready. Finalizing desktop startup', @@ -5753,18 +6187,21 @@ function wireCommonWindowHandlers(win) { // work with multiple chats side by side. The registry guarantees one window // per sessionId (re-opening focuses the existing window) and self-cleans on // close. The primary mainWindow is never tracked here. Pure logic + the URL -// builder live in session-windows.cjs so they stay unit-testable. +// builder live in session-windows.ts so they stay unit-testable. const sessionWindows = createSessionWindowRegistry() function focusWindow(win) { - if (!win || win.isDestroyed()) return - if (win.isMinimized()) win.restore() - if (!win.isVisible()) win.show() + if (!win || win.isDestroyed()) {return} + + if (win.isMinimized()) {win.restore()} + + if (!win.isVisible()) {win.show()} win.focus() } -function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) { +function spawnSecondaryWindow({ sessionId, watch, newSession }:{ sessionId?: string, watch?: boolean, newSession?: boolean } = {}) { const icon = getAppIconPath() + const win = new BrowserWindow({ width: SESSION_WINDOW_MIN_WIDTH, height: SESSION_WINDOW_MIN_HEIGHT, @@ -5785,7 +6222,7 @@ function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) { // themes/context.tsx, so the window appears already themed. show: false, backgroundColor: getWindowBackgroundColor(), - webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs')) + webPreferences: chatWindowWebPreferences(PRELOAD_PATH) }) if (IS_MAC) { @@ -5793,12 +6230,10 @@ function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) { } win.once('ready-to-show', () => { - if (!win.isDestroyed()) win.show() + if (!win.isDestroyed()) {win.show()} }) - win.on('will-enter-full-screen', () => sendWindowStateChanged(true)) win.on('enter-full-screen', () => sendWindowStateChanged(true)) - win.on('will-leave-full-screen', () => sendWindowStateChanged(false)) win.on('leave-full-screen', () => sendWindowStateChanged(false)) wireCommonWindowHandlers(win) @@ -5879,7 +6314,7 @@ function spawnPetOverlayWindow(bounds) { // Fully transparent — the renderer paints only the sprite + bubble. backgroundColor: '#00000000', webPreferences: { - preload: path.join(__dirname, 'preload.cjs'), + preload: PRELOAD_PATH, contextIsolation: true, sandbox: true, nodeIntegration: false, @@ -5896,6 +6331,7 @@ function spawnPetOverlayWindow(bounds) { // switching semantics. win.setAlwaysOnTop(true, IS_MAC ? 'floating' : 'screen-saver') win.setHiddenInMissionControl?.(true) + try { // Electron docs: macOS may transform process type on each // setVisibleOnAllWorkspaces() call unless skipTransformProcessType=true, @@ -5913,7 +6349,7 @@ function spawnPetOverlayWindow(bounds) { wireCommonWindowHandlers(win) win.once('ready-to-show', () => { - if (!win.isDestroyed()) win.showInactive() + if (!win.isDestroyed()) {win.showInactive()} }) win.on('closed', () => { @@ -5991,12 +6427,13 @@ function createWindow() { // Shared with the secondary session windows (chatWindowWebPreferences) so // both keep `backgroundThrottling: false` — the chat transcript streams via // a requestAnimationFrame-gated flush that Chromium pauses for blurred - // windows, stalling the live answer until refocus. See session-windows.cjs. - webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs')) + // windows, stalling the live answer until refocus. See session-windows.ts. + webPreferences: chatWindowWebPreferences(PRELOAD_PATH) }) if (IS_MAC) { mainWindow.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION) + if (icon) { app.dock?.setIcon(icon) } @@ -6011,10 +6448,10 @@ function createWindow() { } } - if (savedWindowState?.isMaximized) mainWindow.maximize() + if (savedWindowState?.isMaximized) {mainWindow.maximize()} mainWindow.once('ready-to-show', () => { - if (mainWindow && !mainWindow.isDestroyed()) mainWindow.show() + if (mainWindow && !mainWindow.isDestroyed()) {mainWindow.show()} }) mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true)) @@ -6054,7 +6491,8 @@ function createWindow() { rendererReloadTimes.push(now) setImmediate(() => { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) {return} + try { mainWindow.webContents.reload() } catch (err) { @@ -6074,7 +6512,7 @@ function createWindow() { const details = detailsOrLevel && typeof detailsOrLevel === 'object' ? detailsOrLevel : null const level = details ? details.level : detailsOrLevel - if (level !== 3) return + if (level !== 3) {return} const text = details ? details.message : message const src = details ? details.sourceUrl : sourceId @@ -6111,6 +6549,7 @@ ipcMain.handle('hermes:connection:revalidate', async () => { } let conn = null + try { conn = await connectionPromise } catch { @@ -6124,8 +6563,10 @@ ipcMain.handle('hermes:connection:revalidate', async () => { } const base = conn.baseUrl.replace(/\/+$/, '') + try { await fetchPublicJson(`${base}/api/status`, { timeoutMs: 2_500 }) + return { ok: true, rebuilt: false } } catch { // Unreachable remote: drop the stale cache so the renderer's next reconnect @@ -6133,11 +6574,13 @@ ipcMain.handle('hermes:connection:revalidate', async () => { // nulls connectionPromise for a remote (no child to SIGTERM). rememberLog('Cached remote Hermes backend failed liveness probe; dropping stale connection.') resetHermesConnection() + return { ok: true, rebuilt: true } } }) ipcMain.handle('hermes:backend:touch', async (_event, profile) => { touchPoolBackend(profile) + return { ok: true } }) ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => freshGatewayWsUrl(profile)) @@ -6167,7 +6610,8 @@ ipcMain.handle('hermes:zoom:get', event => { }) ipcMain.on('hermes:zoom:set-percent', (event, percent) => { const window = BrowserWindow.fromWebContents(event.sender) - if (!window || window.isDestroyed()) return + + if (!window || window.isDestroyed()) {return} setAndPersistZoomLevel(window, percentToZoomLevel(Number(percent))) }) @@ -6250,6 +6694,7 @@ ipcMain.on('hermes:pet-overlay:set-focusable', (_event, focusable) => { } petOverlayWindow.setFocusable(Boolean(focusable)) + if (focusable) { petOverlayWindow.focus() } @@ -6311,6 +6756,7 @@ ipcMain.handle('hermes:bootstrap:reset', async () => { completedAt: null, unsupportedPlatform: null } + return { ok: true } }) ipcMain.handle('hermes:bootstrap:repair', async () => { @@ -6319,6 +6765,7 @@ ipcMain.handle('hermes:bootstrap:repair', async () => { // 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 }) @@ -6326,9 +6773,11 @@ ipcMain.handle('hermes:bootstrap:repair', async () => { } catch (error) { rememberLog(`[bootstrap] failed to remove marker during repair: ${error.message}`) } + bootstrapFailure = null backendStartFailure = null resetHermesConnection() + return { ok: true } }) ipcMain.handle('hermes:bootstrap:cancel', async () => { @@ -6341,8 +6790,10 @@ ipcMain.handle('hermes:bootstrap:cancel', async () => { } catch { void 0 } + return { ok: true, cancelled: true } } + return { ok: false, cancelled: false } }) ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState) @@ -6359,11 +6810,13 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => // the URL defensively so a login can be driven from a raw URL too. const baseUrl = normalizeRemoteBaseUrl(rawUrl) await openOauthLoginWindow(baseUrl) + return { ok: true, baseUrl, connected: await hasOauthSessionCookie(baseUrl) } }) ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) => { const baseUrl = rawUrl ? normalizeRemoteBaseUrl(rawUrl) : '' await clearOauthSession(baseUrl || undefined) + // Report against the SAME liveness notion the Settings indicator uses // (AT-or-RT) so a logout that left any session cookie behind is reflected // as still-connected rather than silently signed-out. @@ -6434,25 +6887,32 @@ async function interceptSessionRequestForRemote(request) { if (typeof request?.path !== 'string') { return undefined } + const method = (request.method || 'GET').toUpperCase() let parsed + try { parsed = new URL(request.path, 'http://x') } catch { return undefined } + const { pathname, searchParams } = parsed if (method === 'GET' && pathname === '/api/profiles/sessions') { const remoteProfiles = configuredRemoteProfileNames() + if (remoteProfiles.length === 0) { return undefined // no remote profiles → local fast path } + const requested = (searchParams.get('profile') || 'all').trim() || 'all' + if (requested !== 'all') { return profileHasRemoteOverride(requested) ? remoteSessionList(requested, searchParams) : undefined } + return mergeRemoteProfileSessions(searchParams, remoteProfiles) } @@ -6464,27 +6924,37 @@ async function interceptSessionRequestForRemote(request) { // route there and KEEP the profile param so it opens the right state.db. if (/^\/api\/sessions\/[^/]+(\/messages)?$/.test(pathname)) { const profile = (searchParams.get('profile') || request.profile || '').trim() + if (!profile) { return undefined } + if (profileHasRemoteOverride(profile)) { if (method === 'GET') { return fetchJsonForProfile(profile, pathname) } + const body = request.body && typeof request.body === 'object' ? { ...request.body } : request.body - if (body) delete body.profile + + if (body) {delete body.profile} + return requestJsonForProfile(profile, pathname, method, body) } + if (globalRemoteActive()) { // Single global backend: keep ?profile= so it opens the right state.db. const sep = pathname.includes('?') ? '&' : '?' const path = `${pathname}${sep}profile=${encodeURIComponent(profile)}` + if (method === 'GET') { return fetchJsonForProfile(null, path) } + const body = request.body && typeof request.body === 'object' ? { ...request.body, profile } : { profile } + return requestJsonForProfile(null, path, method, body) } + return undefined } @@ -6499,11 +6969,13 @@ async function remoteSessionList(profile, searchParams) { const qs = new URLSearchParams(searchParams) qs.delete('profile') // remote serves its own db; no cross-profile read there const data = await fetchJsonForProfile(profile, `/api/sessions?${qs}`) + for (const s of rowsOf(data)) { s.profile = profile s.is_default_profile = false } - return { ...data, sessions: rowsOf(data) } + + return { ...(data as any), sessions: rowsOf(data) } } // Unified list: primary's local aggregate, with each remote profile's stale local @@ -6516,10 +6988,11 @@ async function mergeRemoteProfileSessions(searchParams, remoteProfiles) { const order = searchParams.get('order') === 'created' ? 'started_at' : 'last_active' const primary = await ensureBackend(null) + const base = await fetchJson(`${primary.baseUrl}/api/profiles/sessions?${searchParams}`, primary.token, { method: 'GET', timeoutMs: DEFAULT_FETCH_TIMEOUT_MS - }).catch(() => ({ sessions: [], total: 0, profile_totals: {} })) + }).catch(() => ({ sessions: [], total: 0, profile_totals: {} })) as any // Over-fetch each remote from offset 0 (limit+offset rows) so the merged window // is correct for this page — mirrors the primary's per-profile over-fetch. @@ -6536,10 +7009,13 @@ async function mergeRemoteProfileSessions(searchParams, remoteProfiles) { await Promise.all( remoteProfiles.map(async name => { const list = await remoteSessionList(name, remoteParams).catch(() => null) + if (!list) { delete profileTotals[name] // dead remote → drop its stale local total too + return } + const rows = rowsOf(list) merged.push(...rows) profileTotals[name] = Number(list.total) || rows.length @@ -6549,7 +7025,8 @@ async function mergeRemoteProfileSessions(searchParams, remoteProfiles) { const recency = s => s?.[order] ?? s?.started_at ?? 0 merged.sort((a, b) => recency(b) - recency(a)) - return { ...base, sessions: merged.slice(offset, offset + limit), total, profile_totals: profileTotals } + + return { ...(base as any), sessions: merged.slice(offset, offset + limit), total, profile_totals: profileTotals } } ipcMain.handle('hermes:api', async (_event, request) => { @@ -6558,6 +7035,7 @@ ipcMain.handle('hermes:api', async (_event, request) => { // profile's sessions live on its remote host, so the UI's IDs 404 (or mutations // no-op) the moment they run there. Route reads + mutations to the remote. const rerouted = await interceptSessionRequestForRemote(request) + if (rerouted !== undefined) { return rerouted } @@ -6572,11 +7050,14 @@ ipcMain.handle('hermes:api', async (_event, request) => { const routeProfile = tornDownProfile ? null : profile const connection = await ensureBackend(routeProfile) const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) + const requestPath = pathWithGlobalRemoteProfile(request.path, profile, { globalRemote: globalRemoteActive(), profileRemoteOverride: profileHasRemoteOverride(profile) }) + const url = `${connection.baseUrl}${requestPath}` + // OAuth gateways authenticate REST via the HttpOnly session cookie held in // the OAuth partition — route through Electron's net stack bound to that // session so the cookie attaches automatically. Token/local modes keep using @@ -6588,6 +7069,7 @@ ipcMain.handle('hermes:api', async (_event, request) => { timeoutMs }) } + return fetchJson(url, connection.token, { method: request?.method, body: request?.body, @@ -6596,31 +7078,36 @@ ipcMain.handle('hermes:api', async (_event, request) => { }) ipcMain.handle('hermes:notify', (_event, payload) => { - if (!Notification.isSupported()) return false + if (!Notification.isSupported()) {return false} // Action buttons render only on signed macOS builds; elsewhere they're dropped // and the body click still works. const actions = Array.isArray(payload?.actions) ? payload.actions : [] + const notification = new Notification({ title: payload?.title || 'Hermes', body: payload?.body || '', silent: Boolean(payload?.silent), actions: actions.map(action => ({ type: 'button', text: String(action?.text || '') })) }) + notification.on('click', () => { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) {return} focusWindow(mainWindow) + if (payload?.sessionId) { mainWindow.webContents.send('hermes:focus-session', payload.sessionId) } }) notification.on('action', (_actionEvent, index) => { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) {return} const action = actions[index] + if (action?.id) { mainWindow.webContents.send('hermes:notification-action', { sessionId: payload?.sessionId, actionId: action.id }) } }) notification.show() + return true }) @@ -6629,7 +7116,9 @@ ipcMain.handle('hermes:readFileDataUrl', async (_event, 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')}` }) @@ -6638,6 +7127,7 @@ ipcMain.handle('hermes:readFileText', async (_event, 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) @@ -6660,11 +7150,13 @@ ipcMain.handle('hermes:readFileText', async (_event, filePath) => { } }) -ipcMain.handle('hermes:selectPaths', async (_event, options = {}) => { +ipcMain.handle('hermes:selectPaths', async (_event, options: any = {}) => { const properties = options?.directories ? ['openDirectory'] : ['openFile'] - if (options?.multiple !== false) properties.push('multiSelections') + + if (options?.multiple !== false) {properties.push('multiSelections')} let resolvedDefaultPath + if (options?.defaultPath) { try { resolvedDefaultPath = path.resolve(String(options.defaultPath)) @@ -6676,16 +7168,18 @@ ipcMain.handle('hermes:selectPaths', async (_event, options = {}) => { const result = await dialog.showOpenDialog(mainWindow, { title: options?.title || 'Add context', defaultPath: resolvedDefaultPath, - properties, + properties: properties as any, filters: Array.isArray(options?.filters) ? options.filters : undefined }) - if (result.canceled) return [] + if (result.canceled) {return []} + return result.filePaths }) ipcMain.handle('hermes:writeClipboard', (_event, text) => { clipboard.writeText(String(text || '')) + return true }) @@ -6693,14 +7187,17 @@ ipcMain.handle('hermes:saveImageFromUrl', (_event, url) => saveImageFromUrl(Stri ipcMain.handle('hermes:saveImageBuffer', async (_event, payload) => { const data = payload?.data - if (!data) throw new Error('saveImageBuffer: missing 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 writeComposerImage(image.toPNG(), '.png') } @@ -6710,6 +7207,7 @@ ipcMain.handle('hermes:saveClipboardImage', async () => { // Pull it straight off the Windows clipboard via PowerShell as a fallback. if (IS_WSL) { const png = readWslWindowsClipboardImage() + if (png) { return writeComposerImage(png, '.png') } @@ -6826,10 +7324,13 @@ 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 } @@ -6845,6 +7346,7 @@ function isExecutableFile(filePath) { try { fs.accessSync(filePath, fs.constants.X_OK) + return true } catch { return false @@ -6858,39 +7360,6 @@ function posixShellSpec(shellPath) { return { args: interactiveArgs, command: shellPath, name: shellName } } -let spawnHelperChecked = false - -// node-pty execs a `spawn-helper` binary on macOS/Linux to launch the shell in a -// fresh session. The prebuilt that ships in node-pty's `prebuilds/` (and the -// staged copy under resources/native-deps) loses its execute bit through npm -// pack / electron-builder file collection, so every nodePty.spawn() dies with -// "posix_spawnp failed". Restore +x once, lazily, before the first spawn. -function ensureSpawnHelperExecutable() { - if (spawnHelperChecked || IS_WINDOWS || !nodePtyDir) { - return - } - - spawnHelperChecked = true - - const arch = process.arch - const candidates = [ - path.join(nodePtyDir, 'build', 'Release', 'spawn-helper'), - path.join(nodePtyDir, 'prebuilds', `${process.platform}-${arch}`, 'spawn-helper') - ] - - for (const helper of candidates) { - try { - const mode = fs.statSync(helper).mode - - if ((mode & 0o111) !== 0o111) { - fs.chmodSync(helper, mode | 0o755) - } - } catch { - // Not present in this layout (e.g. compiled build vs prebuild); skip. - } - } -} - // Windows PowerShell 5.1 ships at a fixed System32 path on every Windows box; // prefer it only after PowerShell 7+ (`pwsh`). function windowsPowerShellPath() { @@ -7180,17 +7649,12 @@ ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => { }) ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { - if (!nodePty) { - throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.') - } - - ensureSpawnHelperExecutable() - 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, @@ -7270,6 +7734,7 @@ 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 } }) @@ -7282,9 +7747,11 @@ 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] } @@ -7292,6 +7759,7 @@ function resolveHermesVersion() { } catch { // Fall through to the Electron app version below. } + return app.getVersion() } @@ -7338,6 +7806,7 @@ function uninstallVenvPython() { async function getUninstallSummary() { const py = uninstallVenvPython() const agentRoot = ACTIVE_HERMES_ROOT + // Fast JS-side fallback used when the agent venv is gone (lite client) or the // probe fails — the renderer still needs *something* to render options from. const fallback = () => ({ @@ -7359,11 +7828,13 @@ async function getUninstallSummary() { return new Promise(resolve => { let stdout = '' let settled = false + const done = value => { - if (settled) return + if (settled) {return} settled = true resolve(value) } + try { const child = spawn( py, @@ -7374,12 +7845,14 @@ async function getUninstallSummary() { stdio: ['ignore', 'pipe', 'ignore'] }) ) + child.stdout.on('data', chunk => { stdout += chunk.toString() }) child.on('error', () => done(fallback())) child.on('exit', code => { - if (code !== 0) return done(fallback()) + if (code !== 0) {return done(fallback())} + try { const line = stdout.trim().split('\n').filter(Boolean).pop() || '{}' const parsed = JSON.parse(line) @@ -7401,6 +7874,7 @@ async function getUninstallSummary() { async function runDesktopUninstall(mode) { let uninstallArgs + try { uninstallArgs = uninstallArgsForMode(mode) } catch (error) { @@ -7408,6 +7882,7 @@ async function runDesktopUninstall(mode) { } const venvPy = uninstallVenvPython() + if (!fileExists(venvPy)) { return { ok: false, @@ -7426,8 +7901,10 @@ async function runDesktopUninstall(mode) { // leave venv remnants the user can delete, which we log. let py = venvPy let pythonPath = null + if (modeRemovesAgent(mode)) { const sysPy = findSystemPython() + if (sysPy) { py = sysPy pythonPath = ACTIVE_HERMES_ROOT @@ -7468,6 +7945,7 @@ async function runDesktopUninstall(mode) { let scriptPath let runner let runnerArgs + try { if (IS_WINDOWS) { scriptPath = path.join(app.getPath('temp'), `hermes-uninstall-${Date.now()}.cmd`) @@ -7490,6 +7968,7 @@ async function runDesktopUninstall(mode) { stdio: 'ignore', windowsHide: true }) + child.unref() } catch (error) { return { ok: false, error: 'spawn-failed', message: error.message } @@ -7504,12 +7983,14 @@ async function runDesktopUninstall(mode) { // the venv python shim + app bundle unlock and the cleanup script can run. isQuittingForHandoff = true setTimeout(() => app.quit(), 800) + return { ok: true, mode, willRemoveAppBundle: Boolean(removeBundle), scriptPath } } ipcMain.handle('hermes:uninstall:summary', async () => getUninstallSummary()) ipcMain.handle('hermes:uninstall:run', async (_event, payload) => { const mode = payload && typeof payload === 'object' ? payload.mode : payload + return runDesktopUninstall(String(mode || '')) }) @@ -7531,19 +8012,23 @@ let _pendingDeepLink = null let _rendererReadyForDeepLink = false function _extractDeepLink(argv) { - if (!Array.isArray(argv)) return null + if (!Array.isArray(argv)) {return null} + return argv.find(a => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null } function handleDeepLink(url) { - if (!url || typeof url !== 'string') return + if (!url || typeof url !== 'string') {return} let parsed + try { parsed = new URL(url) } catch { rememberLog(`[deeplink] ignoring malformed url: ${url}`) + return } + // hermes://blueprint/<key>?slot=val -> host="blueprint", path="/<key>" const kind = parsed.hostname || '' const name = decodeURIComponent((parsed.pathname || '').replace(/^\//, '')) @@ -7555,10 +8040,12 @@ function handleDeepLink(url) { if (!_rendererReadyForDeepLink || !mainWindow || mainWindow.isDestroyed()) { _pendingDeepLink = payload + return } + try { - if (mainWindow.isMinimized()) mainWindow.restore() + if (mainWindow.isMinimized()) {mainWindow.restore()} mainWindow.focus() mainWindow.webContents.send('hermes:deep-link', payload) rememberLog(`[deeplink] delivered ${kind}/${name}`) @@ -7571,6 +8058,7 @@ function handleDeepLink(url) { // a link that arrived during boot/install is flushed exactly once. ipcMain.handle('hermes:deep-link-ready', () => { _rendererReadyForDeepLink = true + if (_pendingDeepLink) { const queued = _pendingDeepLink _pendingDeepLink = null @@ -7579,6 +8067,7 @@ ipcMain.handle('hermes:deep-link-ready', () => { (Object.keys(queued.params).length ? '?' + new URLSearchParams(queued.params).toString() : '') ) } + return { ok: true } }) @@ -7600,14 +8089,16 @@ function registerDeepLinkProtocol() { // second-instance argv. Without the lock a second `hermes://` launch spawns a // whole new app instead of routing into the running one. const _gotSingleInstanceLock = app.requestSingleInstanceLock() + if (!_gotSingleInstanceLock) { app.quit() } else { app.on('second-instance', (_event, argv) => { const url = _extractDeepLink(argv) - if (url) handleDeepLink(url) + + if (url) {handleDeepLink(url)} else if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore() + if (mainWindow.isMinimized()) {mainWindow.restore()} mainWindow.focus() } }) @@ -7626,6 +8117,7 @@ app.whenReady().then(() => { } else { Menu.setApplicationMenu(null) } + installMediaPermissions() registerMediaProtocol() installEmbedReferer() @@ -7637,7 +8129,8 @@ app.whenReady().then(() => { // Win/Linux cold start: the launching hermes:// URL is in our own argv. const _coldStartLink = _extractDeepLink(process.argv) - if (_coldStartLink) handleDeepLink(_coldStartLink) + + if (_coldStartLink) {handleDeepLink(_coldStartLink)} app.on('activate', () => { // Recreate the primary window if it's gone. Guard on mainWindow directly @@ -7692,6 +8185,7 @@ app.on('before-quit', () => { clearTimeout(desktopLogFlushTimer) desktopLogFlushTimer = null } + flushDesktopLogBufferSync() closePreviewWatchers() @@ -7712,5 +8206,5 @@ app.on('window-all-closed', () => { // the bundle and relaunch — without this the script's PID-wait spins to its // full timeout and the user is left with an invisible app (or an uninstall // that appears to do nothing). - if (process.platform !== 'darwin' || isQuittingForHandoff) app.quit() + if (process.platform !== 'darwin' || isQuittingForHandoff) {app.quit()} }) diff --git a/apps/desktop/electron/oauth-net-request.test.cjs b/apps/desktop/electron/oauth-net-request.test.ts similarity index 78% rename from apps/desktop/electron/oauth-net-request.test.cjs rename to apps/desktop/electron/oauth-net-request.test.ts index 63a27f6219a..31d9e257d7e 100644 --- a/apps/desktop/electron/oauth-net-request.test.cjs +++ b/apps/desktop/electron/oauth-net-request.test.ts @@ -1,13 +1,13 @@ /** * Tests for OAuth-session Electron net.request helpers. * - * Run with: node --test electron/oauth-net-request.test.cjs + * Run with: node --test electron/oauth-net-request.test.ts */ -const test = require('node:test') -const assert = require('node:assert/strict') +import assert from 'node:assert/strict' +import test from 'node:test' -const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs') +import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' test('serializeJsonBody returns undefined for absent bodies', () => { assert.equal(serializeJsonBody(undefined), undefined) @@ -21,6 +21,7 @@ test('serializeJsonBody JSON-encodes request bodies', () => { test('setJsonRequestHeaders does not set Electron-restricted Content-Length', () => { const headers = [] + const request = { setHeader(name, value) { headers.push([name, value]) diff --git a/apps/desktop/electron/oauth-net-request.cjs b/apps/desktop/electron/oauth-net-request.ts similarity index 87% rename from apps/desktop/electron/oauth-net-request.cjs rename to apps/desktop/electron/oauth-net-request.ts index 0498a7333fa..7c2b44821c6 100644 --- a/apps/desktop/electron/oauth-net-request.cjs +++ b/apps/desktop/electron/oauth-net-request.ts @@ -14,7 +14,5 @@ function setJsonRequestHeaders(request) { request.setHeader('Content-Type', 'application/json') } -module.exports = { - serializeJsonBody, - setJsonRequestHeaders -} +export { serializeJsonBody, + setJsonRequestHeaders } diff --git a/apps/desktop/electron/oauth-session-request.test.cjs b/apps/desktop/electron/oauth-session-request.test.ts similarity index 75% rename from apps/desktop/electron/oauth-session-request.test.cjs rename to apps/desktop/electron/oauth-session-request.test.ts index 3254318456b..3b6f352d0da 100644 --- a/apps/desktop/electron/oauth-session-request.test.cjs +++ b/apps/desktop/electron/oauth-session-request.test.ts @@ -6,18 +6,21 @@ * this guard is scoped to fetchJsonViaOauthSession only. */ -const test = require('node:test') -const assert = require('node:assert/strict') -const fs = require('node:fs') -const path = require('node:path') +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' -const source = fs.readFileSync(path.join(__dirname, 'main.cjs'), 'utf8') +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const source = fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8') function extractFetchJsonViaOauthSession() { const start = source.indexOf('function fetchJsonViaOauthSession') const end = source.indexOf('// Mint a single-use WS ticket', start) assert.notEqual(start, -1, 'fetchJsonViaOauthSession should exist') assert.notEqual(end, -1, 'fetchJsonViaOauthSession boundary should exist') + return source.slice(start, end) } diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.ts similarity index 98% rename from apps/desktop/electron/preload.cjs rename to apps/desktop/electron/preload.ts index 0a9c4fd7921..3a74b1c3ebc 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.ts @@ -1,4 +1,4 @@ -const { contextBridge, ipcRenderer, webUtils } = require('electron') +import { contextBridge, ipcRenderer, webUtils } from 'electron' contextBridge.exposeInMainWorld('hermesDesktop', { getConnection: profile => ipcRenderer.invoke('hermes:connection', profile), @@ -24,12 +24,14 @@ contextBridge.exposeInMainWorld('hermesDesktop', { onState: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:pet-overlay:state', listener) + return () => ipcRenderer.removeListener('hermes:pet-overlay:state', listener) }, // Main renderer subscribes to overlay control messages. onControl: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:pet-overlay:control', listener) + return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener) } }, @@ -87,6 +89,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { onChanged: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:zoom:changed', listener) + return () => ipcRenderer.removeListener('hermes:zoom:changed', listener) } }, @@ -132,68 +135,80 @@ contextBridge.exposeInMainWorld('hermesDesktop', { const channel = `hermes:terminal:${id}:data` const listener = (_event, payload) => callback(payload) ipcRenderer.on(channel, listener) + return () => ipcRenderer.removeListener(channel, listener) }, onExit: (id, callback) => { const channel = `hermes:terminal:${id}:exit` const listener = (_event, payload) => callback(payload) ipcRenderer.on(channel, listener) + return () => ipcRenderer.removeListener(channel, listener) } }, onClosePreviewRequested: callback => { const listener = () => callback() ipcRenderer.on('hermes:close-preview-requested', listener) + return () => ipcRenderer.removeListener('hermes:close-preview-requested', listener) }, onOpenUpdatesRequested: callback => { const listener = () => callback() ipcRenderer.on('hermes:open-updates', listener) + return () => ipcRenderer.removeListener('hermes:open-updates', listener) }, onDeepLink: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:deep-link', listener) + return () => ipcRenderer.removeListener('hermes:deep-link', listener) }, signalDeepLinkReady: () => ipcRenderer.invoke('hermes:deep-link-ready'), onWindowStateChanged: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:window-state-changed', listener) + return () => ipcRenderer.removeListener('hermes:window-state-changed', listener) }, onFocusSession: callback => { const listener = (_event, sessionId) => callback(sessionId) ipcRenderer.on('hermes:focus-session', listener) + return () => ipcRenderer.removeListener('hermes:focus-session', listener) }, onNotificationAction: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:notification-action', listener) + return () => ipcRenderer.removeListener('hermes:notification-action', listener) }, onPreviewFileChanged: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:preview-file-changed', listener) + return () => ipcRenderer.removeListener('hermes:preview-file-changed', listener) }, onBackendExit: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:backend-exit', listener) + return () => ipcRenderer.removeListener('hermes:backend-exit', listener) }, onPowerResume: callback => { const listener = () => callback() ipcRenderer.on('hermes:power-resume', listener) + return () => ipcRenderer.removeListener('hermes:power-resume', listener) }, onBootProgress: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:boot-progress', listener) + return () => ipcRenderer.removeListener('hermes:boot-progress', listener) }, // First-launch bootstrap progress -- emitted by the install.ps1 stage - // runner in main.cjs (apps/desktop/electron/bootstrap-runner.cjs). + // runner in main.ts (apps/desktop/electron/bootstrap-runner.ts). // Renderer's install overlay subscribes to live events and queries the // current snapshot via getBootstrapState() to recover after a devtools // reload mid-bootstrap. @@ -204,6 +219,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { onBootstrapEvent: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:bootstrap:event', listener) + return () => ipcRenderer.removeListener('hermes:bootstrap:event', listener) }, getVersion: () => ipcRenderer.invoke('hermes:version'), @@ -220,6 +236,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { onProgress: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:updates:progress', listener) + return () => ipcRenderer.removeListener('hermes:updates:progress', listener) } }, diff --git a/apps/desktop/electron/profile-delete-respawn.test.cjs b/apps/desktop/electron/profile-delete-respawn.test.ts similarity index 89% rename from apps/desktop/electron/profile-delete-respawn.test.cjs rename to apps/desktop/electron/profile-delete-respawn.test.ts index 07e17f78749..2fbddfc5a59 100644 --- a/apps/desktop/electron/profile-delete-respawn.test.cjs +++ b/apps/desktop/electron/profile-delete-respawn.test.ts @@ -1,11 +1,11 @@ 'use strict' -const test = require('node:test') -const assert = require('node:assert/strict') -const fs = require('node:fs') -const path = require('node:path') +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' -const ELECTRON_DIR = __dirname +const ELECTRON_DIR = import.meta.dirname function readElectronFile(name) { return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n') @@ -17,7 +17,7 @@ function readElectronFile(name) { // --------------------------------------------------------------------------- test('prepareProfileDeleteRequest returns the torn-down profile name', () => { - const source = readElectronFile('main.cjs') + const source = readElectronFile('main.ts') // Locate the function definition and its closing brace. const fnStart = source.indexOf('async function prepareProfileDeleteRequest(') @@ -36,7 +36,7 @@ test('prepareProfileDeleteRequest returns the torn-down profile name', () => { }) test('hermes:api handler routes profile-delete requests to the primary backend', () => { - const source = readElectronFile('main.cjs') + const source = readElectronFile('main.ts') // The handler must capture prepareProfileDeleteRequest's return value. assert.match( diff --git a/apps/desktop/electron/session-windows.test.cjs b/apps/desktop/electron/session-windows.test.ts similarity index 97% rename from apps/desktop/electron/session-windows.test.cjs rename to apps/desktop/electron/session-windows.test.ts index 78f19b859e4..48f0441d23b 100644 --- a/apps/desktop/electron/session-windows.test.cjs +++ b/apps/desktop/electron/session-windows.test.ts @@ -1,11 +1,9 @@ -const assert = require('node:assert/strict') -const test = require('node:test') +import assert from 'node:assert/strict' +import test from 'node:test' -const { - buildSessionWindowUrl, +import { buildSessionWindowUrl, chatWindowWebPreferences, - createSessionWindowRegistry -} = require('./session-windows.cjs') + createSessionWindowRegistry } from './session-windows' // A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a // test fire the 'closed' event, mirroring the slice of the Electron API the @@ -96,6 +94,7 @@ test('registry opens one window per session and focuses on re-open', () => { const registry = createSessionWindowRegistry() let built = 0 const win = makeFakeWindow() + const factory = () => { built += 1 @@ -145,6 +144,7 @@ test('registry rebuilds a fresh window after the previous one was destroyed', () let built = 0 const second = makeFakeWindow() + const result = registry.openOrFocus('s1', () => { built += 1 @@ -158,6 +158,7 @@ test('registry rebuilds a fresh window after the previous one was destroyed', () test('registry ignores empty / non-string session ids', () => { const registry = createSessionWindowRegistry() let built = 0 + const factory = () => { built += 1 diff --git a/apps/desktop/electron/session-windows.cjs b/apps/desktop/electron/session-windows.ts similarity index 90% rename from apps/desktop/electron/session-windows.cjs rename to apps/desktop/electron/session-windows.ts index 5e2f3d4c680..7dbba58ca06 100644 --- a/apps/desktop/electron/session-windows.cjs +++ b/apps/desktop/electron/session-windows.ts @@ -1,9 +1,9 @@ // Secondary "session windows" — one extra OS window per chat so a user can // work with multiple chats side by side. The pure, Electron-free pieces live // here so they can be unit-tested with node --test (mirroring how the rest of -// electron/*.cjs splits testable logic out of the main.cjs monolith). +// electron/*.ts splits testable logic out of the main.ts monolith). -const { pathToFileURL } = require('node:url') +import { pathToFileURL } from 'node:url' // Secondary windows open at the minimum usable size — a compact side panel for // subagent watch / cmd-click session pop-out, not a second full desktop. @@ -12,7 +12,7 @@ const SESSION_WINDOW_MIN_HEIGHT = 620 // Shared webPreferences for every window that renders the chat transcript — the // primary window AND the secondary session windows. Keeping it in one place is -// the whole point: the two BrowserWindow definitions in main.cjs used to be +// the whole point: the two BrowserWindow definitions in main.ts used to be // hand-copied, and the secondary windows silently lost `backgroundThrottling: // false`, so a streamed answer stalled until the window regained focus. // @@ -21,7 +21,7 @@ const SESSION_WINDOW_MIN_HEIGHT = 620 // blurred/occluded windows. A streaming chat app must keep painting in the // background, so every chat window opts out. The preload path is injected // because it depends on the Electron entry's __dirname. -function chatWindowWebPreferences(preloadPath) { +function chatWindowWebPreferences(preloadPath: string) { return { preload: preloadPath, contextIsolation: true, @@ -42,7 +42,7 @@ function chatWindowWebPreferences(preloadPath) { // scratch window; `watch=1` marks a spectator window (e.g. a running subagent's // session): the renderer resumes it lazily so the gateway never builds an agent // just to stream into it. -function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch, newSession } = {}) { +function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath, watch, newSession }: any = {}) { const query = `?win=secondary${newSession ? '&new=1' : ''}${watch ? '&watch=1' : ''}` const route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}` @@ -115,10 +115,8 @@ function createSessionWindowRegistry() { } } -module.exports = { - buildSessionWindowUrl, +export { buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry, SESSION_WINDOW_MIN_HEIGHT, - SESSION_WINDOW_MIN_WIDTH -} + SESSION_WINDOW_MIN_WIDTH } diff --git a/apps/desktop/electron/titlebar-overlay-width.test.cjs b/apps/desktop/electron/titlebar-overlay-width.test.ts similarity index 90% rename from apps/desktop/electron/titlebar-overlay-width.test.cjs rename to apps/desktop/electron/titlebar-overlay-width.test.ts index ccec1015b4f..c2c77d918c2 100644 --- a/apps/desktop/electron/titlebar-overlay-width.test.cjs +++ b/apps/desktop/electron/titlebar-overlay-width.test.ts @@ -1,12 +1,7 @@ -const assert = require('node:assert/strict') -const test = require('node:test') +import assert from 'node:assert/strict' +import test from 'node:test' -const { - MACOS_TAHOE_DARWIN_MAJOR, - OVERLAY_FALLBACK_WIDTH, - macTitleBarOverlayHeight, - nativeOverlayWidth -} = require('./titlebar-overlay-width.cjs') +import { MACOS_TAHOE_DARWIN_MAJOR, macTitleBarOverlayHeight, nativeOverlayWidth, OVERLAY_FALLBACK_WIDTH } from './titlebar-overlay-width' // This static reservation is only the pre-layout FALLBACK. Once laid out the // renderer reads the exact width from navigator.windowControlsOverlay diff --git a/apps/desktop/electron/titlebar-overlay-width.cjs b/apps/desktop/electron/titlebar-overlay-width.ts similarity index 80% rename from apps/desktop/electron/titlebar-overlay-width.cjs rename to apps/desktop/electron/titlebar-overlay-width.ts index 9336ae89fce..d31d40fec5a 100644 --- a/apps/desktop/electron/titlebar-overlay-width.cjs +++ b/apps/desktop/electron/titlebar-overlay-width.ts @@ -1,6 +1,4 @@ -'use strict' - -const OVERLAY_FALLBACK_WIDTH = 144 +export const OVERLAY_FALLBACK_WIDTH = 144 /** * Static pre-layout reservation (px) for the right-side native window-controls @@ -16,15 +14,16 @@ const OVERLAY_FALLBACK_WIDTH = 144 * * @param {{ isMac?: boolean }} opts */ -function nativeOverlayWidth({ isMac = false } = {}) { - if (isMac) return 0 +export function nativeOverlayWidth({ isWindows = false, isWsl = false, isMac = false } = {}) { + if (isMac) {return 0} + return OVERLAY_FALLBACK_WIDTH } // macOS Tahoe ships as Darwin 25 (Sequoia is 24); the Darwin number is truthful, // unlike the product version which macOS reports as 16 or 26 depending on the // build SDK. -const MACOS_TAHOE_DARWIN_MAJOR = 25 +export const MACOS_TAHOE_DARWIN_MAJOR = 25 /** * Height (px) to pass to `titleBarOverlay` on macOS. Tahoe (Darwin 25+) @@ -36,8 +35,7 @@ const MACOS_TAHOE_DARWIN_MAJOR = 25 * * @param {{ darwinMajor?: number, titlebarHeight?: number }} opts */ -function macTitleBarOverlayHeight({ darwinMajor = 0, titlebarHeight = 0 } = {}) { +export function macTitleBarOverlayHeight({ darwinMajor = 0, titlebarHeight = 0 } = {}) { return darwinMajor >= MACOS_TAHOE_DARWIN_MAJOR ? 0 : titlebarHeight } -module.exports = { MACOS_TAHOE_DARWIN_MAJOR, OVERLAY_FALLBACK_WIDTH, macTitleBarOverlayHeight, nativeOverlayWidth } diff --git a/apps/desktop/electron/update-count.test.cjs b/apps/desktop/electron/update-count.test.ts similarity index 94% rename from apps/desktop/electron/update-count.test.cjs rename to apps/desktop/electron/update-count.test.ts index fdac4fd744a..2c95fbe7ade 100644 --- a/apps/desktop/electron/update-count.test.cjs +++ b/apps/desktop/electron/update-count.test.ts @@ -1,7 +1,7 @@ -'use strict' -const test = require('node:test') -const assert = require('node:assert/strict') -const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs') +import assert from 'node:assert/strict' +import test from 'node:test' + +import { resolveBehindCount, shouldCountCommits } from './update-count' // FAIL-BEFORE: pre-fix the function did `Number.parseInt(countStr) || 0` // unconditionally, so a shallow checkout with no merge-base surfaced the bogus diff --git a/apps/desktop/electron/update-count.cjs b/apps/desktop/electron/update-count.ts similarity index 90% rename from apps/desktop/electron/update-count.cjs rename to apps/desktop/electron/update-count.ts index de8d57c4ee6..b0bda95a9f0 100644 --- a/apps/desktop/electron/update-count.cjs +++ b/apps/desktop/electron/update-count.ts @@ -1,5 +1,3 @@ -'use strict' - // Whether `git rev-list HEAD..origin/<branch> --count` produces a meaningful // number worth computing. On a SHALLOW checkout (installer clones with // --depth 1) the local history often shares no merge-base with the freshly @@ -19,10 +17,12 @@ function shouldCountCommits({ isShallow, hasMergeBase }) { // (developers / Docker dev images) keep the exact count path unchanged. function resolveBehindCount({ countStr, currentSha, targetSha, isShallow, hasMergeBase }) { if (!shouldCountCommits({ isShallow, hasMergeBase })) { - if (currentSha && targetSha && currentSha === targetSha) return 0 + if (currentSha && targetSha && currentSha === targetSha) {return 0} + return 1 // behind by an unknown amount — show a generic "update available" } + return Number.parseInt(countStr, 10) || 0 } -module.exports = { resolveBehindCount, shouldCountCommits } +export { resolveBehindCount, shouldCountCommits } diff --git a/apps/desktop/electron/update-marker.test.cjs b/apps/desktop/electron/update-marker.test.ts similarity index 85% rename from apps/desktop/electron/update-marker.test.cjs rename to apps/desktop/electron/update-marker.test.ts index d84483714c6..5207d7bf4f3 100644 --- a/apps/desktop/electron/update-marker.test.cjs +++ b/apps/desktop/electron/update-marker.test.ts @@ -1,9 +1,9 @@ /** - * Tests for electron/update-marker.cjs — the in-app update mutual-exclusion + * Tests for electron/update-marker.ts — the in-app update mutual-exclusion * marker that prevents a desktop relaunched mid-update from spawning a backend * the updater then kills in a loop (#50238). * - * Run with: node --test electron/update-marker.test.cjs + * Run with: node --test electron/update-marker.test.ts * (Wired into npm test:desktop:platforms in package.json.) * * Why this matters: the gate must (a) report a live update only when the @@ -12,16 +12,17 @@ * strand future launches, and (c) self-heal by deleting a stale marker file. */ -const test = require('node:test') -const assert = require('node:assert/strict') -const fs = require('fs') -const os = require('os') -const path = require('path') +import fs from 'fs' +import assert from 'node:assert/strict' +import test from 'node:test' +import os from 'os' +import path from 'path' -const { markerPath, isPidAlive, readLiveUpdateMarker, writeUpdateMarker, UPDATE_MARKER_MAX_AGE_MS } = require('./update-marker.cjs') +import { isPidAlive, markerPath, readLiveUpdateMarker, UPDATE_MARKER_MAX_AGE_MS, writeUpdateMarker } from './update-marker' function tmpHome(tag) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-marker-${tag}-`)) + return dir } @@ -29,10 +30,11 @@ function writeMarker(home, pid, startedAtSec) { fs.writeFileSync(markerPath(home), `${pid}\n${startedAtSec}`) } -const ALIVE = () => true // injected kill that "succeeds" => pid alive -const DEAD = () => { - const err = new Error('no such process') - err.code = 'ESRCH' +const ALIVE: typeof process.kill = () => true // injected kill that "succeeds" => pid alive + +const DEAD : typeof process.kill= () => { + const err = new Error('no such process'); + (err as any).code = 'ESRCH' throw err } @@ -84,10 +86,11 @@ test('isPidAlive: own pid is alive, impossible pid is dead', () => { test('isPidAlive: EPERM counts as alive (process owned by another user)', () => { const eperm = () => { - const err = new Error('operation not permitted') - err.code = 'EPERM' + const err = new Error('operation not permitted'); + (err as any).code = 'EPERM' throw err } + assert.equal(isPidAlive(4242, eperm), true) }) diff --git a/apps/desktop/electron/update-marker.cjs b/apps/desktop/electron/update-marker.ts similarity index 87% rename from apps/desktop/electron/update-marker.cjs rename to apps/desktop/electron/update-marker.ts index da3df6a8d4a..6ecb3eb77d2 100644 --- a/apps/desktop/electron/update-marker.cjs +++ b/apps/desktop/electron/update-marker.ts @@ -16,20 +16,20 @@ * * This module holds the PURE, side-effect-light logic (path, pid liveness, * parse + staleness) so it is unit-testable without booting Electron. The - * polling/boot-progress wrapper lives in main.cjs where the boot-progress and + * polling/boot-progress wrapper lives in main.ts where the boot-progress and * log sinks are. */ -const fs = require('fs') -const path = require('path') +import fs from 'fs' +import path from 'path' // Even with a live-looking PID, never treat a marker older than this as a live // update. A full update (git pull + pip + desktop rebuild) is minutes, not tens // of minutes; past this the marker is almost certainly stale (e.g. the OS // recycled the pid onto an unrelated process), so the gate self-heals. -const UPDATE_MARKER_MAX_AGE_MS = 20 * 60 * 1000 +export const UPDATE_MARKER_MAX_AGE_MS = 20 * 60 * 1000 -function markerPath(hermesHome) { +export function markerPath(hermesHome) { return path.join(hermesHome, '.hermes-update-in-progress') } @@ -37,10 +37,12 @@ function markerPath(hermesHome) { // not deliver a signal — it just probes existence/permission. ESRCH => dead; // EPERM => alive but owned by another user (still "alive" for our purposes). // Injectable `kill` keeps it unit-testable. -function isPidAlive(pid, kill = process.kill.bind(process)) { - if (!Number.isInteger(pid) || pid <= 0) return false +export function isPidAlive(pid, kill: typeof process.kill = process.kill.bind(process)) { + if (!Number.isInteger(pid) || pid <= 0) {return false} + try { kill(pid, 0) + return true } catch (err) { return Boolean(err && err.code === 'EPERM') @@ -59,9 +61,12 @@ function isPidAlive(pid, kill = process.kill.bind(process)) { * Pure-ish: file I/O against the given path, plus an injectable pid probe and * clock for tests. */ -function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPDATE_MARKER_MAX_AGE_MS } = {}) { +export function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPDATE_MARKER_MAX_AGE_MS }: { + now?: () => number, maxAgeMs?: number, kill?: typeof process.kill +} = {}) { const file = markerPath(hermesHome) let raw + try { raw = fs.readFileSync(file, 'utf8') } catch { @@ -80,8 +85,10 @@ function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPD } catch { void 0 } + return null } + return { pid, ageMs } } @@ -107,9 +114,10 @@ function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPD * If the updater never starts (spawn failure) the marker still contains a * real PID, so `readLiveUpdateMarker` will self-heal once that PID exits. */ -function writeUpdateMarker(hermesHome, pid, { now = Date.now } = {}) { +export function writeUpdateMarker(hermesHome, pid, { now = Date.now } = {}) { const file = markerPath(hermesHome) const startedAt = Math.floor(now() / 1000) + try { fs.writeFileSync(file, `${pid}\n${startedAt}\n`, 'utf8') } catch { @@ -117,11 +125,3 @@ function writeUpdateMarker(hermesHome, pid, { now = Date.now } = {}) { // updater will write its own when it reaches run_update. } } - -module.exports = { - UPDATE_MARKER_MAX_AGE_MS, - markerPath, - isPidAlive, - readLiveUpdateMarker, - writeUpdateMarker -} diff --git a/apps/desktop/electron/update-rebuild.test.cjs b/apps/desktop/electron/update-rebuild.test.ts similarity index 84% rename from apps/desktop/electron/update-rebuild.test.cjs rename to apps/desktop/electron/update-rebuild.test.ts index 623effa4d13..c30ccab9e26 100644 --- a/apps/desktop/electron/update-rebuild.test.cjs +++ b/apps/desktop/electron/update-rebuild.test.ts @@ -1,8 +1,8 @@ /** - * Tests for electron/update-rebuild.cjs — the retry-once policy for the desktop + * Tests for electron/update-rebuild.ts — the retry-once policy for the desktop * `--build-only` rebuild during self-update. * - * Run with: node --test electron/update-rebuild.test.cjs + * Run with: node --test electron/update-rebuild.test.ts * (Wired into npm test:desktop:platforms in package.json.) * * Why this matters: a first rebuild can return nonzero on a still-settling tree @@ -12,10 +12,10 @@ * success, and must run at most twice. */ -const test = require('node:test') -const assert = require('node:assert/strict') +import assert from 'node:assert/strict' +import test from 'node:test' -const { shouldRetryRebuild, runRebuildWithRetry } = require('./update-rebuild.cjs') +import { runRebuildWithRetry, shouldRetryRebuild } from './update-rebuild' test('shouldRetryRebuild retries only on a non-success exit', () => { assert.equal(shouldRetryRebuild(0), false) @@ -25,30 +25,39 @@ test('shouldRetryRebuild retries only on a non-success exit', () => { test('a clean first rebuild runs once and does not retry', async () => { const codes = [] + const result = await runRebuildWithRetry(attempt => { codes.push(attempt) + return Promise.resolve({ code: 0 }) }) + assert.deepEqual(codes, [0]) assert.equal(result.code, 0) }) test('a failed first rebuild retries once and succeeds', async () => { const codes = [] + const result = await runRebuildWithRetry(attempt => { codes.push(attempt) + return Promise.resolve({ code: attempt === 0 ? 1 : 0 }) }) + assert.deepEqual(codes, [0, 1]) assert.equal(result.code, 0) }) test('a rebuild that keeps failing runs at most twice and reports the failure', async () => { const codes = [] + const result = await runRebuildWithRetry(attempt => { codes.push(attempt) + return Promise.resolve({ code: 1, error: 'rebuild-failed' }) }) + assert.deepEqual(codes, [0, 1]) assert.equal(result.code, 1) assert.equal(result.error, 'rebuild-failed') diff --git a/apps/desktop/electron/update-rebuild.cjs b/apps/desktop/electron/update-rebuild.ts similarity index 92% rename from apps/desktop/electron/update-rebuild.cjs rename to apps/desktop/electron/update-rebuild.ts index ec8a948316d..a2a3581eccd 100644 --- a/apps/desktop/electron/update-rebuild.cjs +++ b/apps/desktop/electron/update-rebuild.ts @@ -1,5 +1,3 @@ -'use strict' - /** * Retry-once policy for the desktop `--build-only` rebuild during self-update. * @@ -20,10 +18,12 @@ function shouldRetryRebuild(code) { */ async function runRebuildWithRetry(rebuild) { let result = await rebuild(0) + if (shouldRetryRebuild(result.code)) { result = await rebuild(1) } + return result } -module.exports = { shouldRetryRebuild, runRebuildWithRetry } +export { runRebuildWithRetry, shouldRetryRebuild } diff --git a/apps/desktop/electron/update-relaunch.test.cjs b/apps/desktop/electron/update-relaunch.test.ts similarity index 95% rename from apps/desktop/electron/update-relaunch.test.cjs rename to apps/desktop/electron/update-relaunch.test.ts index de0a76efeec..f46e4f1a996 100644 --- a/apps/desktop/electron/update-relaunch.test.cjs +++ b/apps/desktop/electron/update-relaunch.test.ts @@ -1,8 +1,8 @@ /** - * Tests for electron/update-relaunch.cjs — the pure decision + script helpers + * Tests for electron/update-relaunch.ts — the pure decision + script helpers * behind the Linux in-app update relaunch (#45205). * - * Run with: node --test electron/update-relaunch.test.cjs + * Run with: node --test electron/update-relaunch.test.ts * (Wired into npm test:desktop:platforms in package.json.) * * What this locks (review acceptance criteria for PR #45205): @@ -17,24 +17,22 @@ * (keep a working window) unless a non-interactive fallback applies. */ -const test = require('node:test') -const assert = require('node:assert/strict') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const { execFileSync } = require('node:child_process') +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' -const { - unpackedDirName, - resolveUnpackedRelease, - decideRelaunchOutcome, - sandboxPreflight, - sandboxFallbackFromEnv, +import { buildRelaunchScript, collectRelaunchArgs, collectRelaunchEnv, - buildRelaunchScript, - shellQuote -} = require('./update-relaunch.cjs') + decideRelaunchOutcome, + resolveUnpackedRelease, + sandboxFallbackFromEnv, + sandboxPreflight, + shellQuote, + unpackedDirName } from './update-relaunch' const ROOT = '/home/u/.hermes/hermes-agent' const UNPACKED = path.join(ROOT, 'apps', 'desktop', 'release', 'linux-unpacked') @@ -91,6 +89,7 @@ test('decideRelaunchOutcome: only under-unpacked + sandbox-ok relaunches', () => // --------------------------------------------------------------------------- const fakeStat = (uid, mode) => () => ({ uid, mode }) + const throwStat = () => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) } @@ -150,6 +149,7 @@ test('collectRelaunchArgs drops Electron internals, keeps user/launcher args', ( '--profile=work', // app flag — keep '--remote-debugging-port=9222' // internal — drop ] + assert.deepEqual(collectRelaunchArgs(argv), ['--no-sandbox', 'hermes://open/agent/42', '--profile=work']) assert.deepEqual(collectRelaunchArgs(undefined), []) }) @@ -165,6 +165,7 @@ test('collectRelaunchEnv preserves HERMES_HOME + HERMES_DESKTOP_* + sandbox opt- HOME: '/home/u', // not preserved UNRELATED: 'x' } + assert.deepEqual(collectRelaunchEnv(env), { HERMES_HOME: '/home/u/.hermes', HERMES_DESKTOP_REMOTE_URL: 'http://box:9119', @@ -207,6 +208,7 @@ test('buildRelaunchScript embeds pid/exec/args/env/cwd and is valid bash', () => // It must be syntactically valid bash (`bash -n`). Write to a temp file and lint. const tmp = path.join(os.tmpdir(), `hermes-relaunch-test-${Date.now()}.sh`) fs.writeFileSync(tmp, script) + try { execFileSync('bash', ['-n', tmp], { stdio: 'pipe' }) } finally { @@ -222,13 +224,16 @@ test('buildRelaunchScript with no args/env still lints clean', () => { env: {}, cwd: '' }) + const tmp = path.join(os.tmpdir(), `hermes-relaunch-test2-${Date.now()}.sh`) fs.writeFileSync(tmp, script) + try { execFileSync('bash', ['-n', tmp], { stdio: 'pipe' }) } finally { fs.rmSync(tmp, { force: true }) } + // exec line has no trailing args. assert.match(script, /exec '\/opt\/Hermes\/Hermes'\n/) }) diff --git a/apps/desktop/electron/update-relaunch.cjs b/apps/desktop/electron/update-relaunch.ts similarity index 89% rename from apps/desktop/electron/update-relaunch.cjs rename to apps/desktop/electron/update-relaunch.ts index 62032cde8c9..0db62c95615 100644 --- a/apps/desktop/electron/update-relaunch.cjs +++ b/apps/desktop/electron/update-relaunch.ts @@ -1,12 +1,10 @@ -'use strict' - /** - * update-relaunch.cjs — pure decision + script-generation helpers for the + * update-relaunch.ts — pure decision + script-generation helpers for the * Linux in-app update relaunch (#45205). * - * Extracted from main.cjs's `applyUpdatesPosixInApp` so the security- and + * Extracted from main.ts's `applyUpdatesPosixInApp` so the security- and * correctness-critical "do we relaunch, or land on a manual terminal state?" - * decision is unit-testable without booting Electron (main.cjs + * decision is unit-testable without booting Electron (main.ts * `require('electron')` at load). * * Background @@ -37,12 +35,14 @@ * the closeable manual-restart terminal state instead. */ -const path = require('node:path') +import path from 'node:path' // Map process.platform → electron-builder's `release/<dir>-unpacked` name. function unpackedDirName(platform) { - if (platform === 'darwin') return 'mac-unpacked' // not used (mac swaps bundles) - if (platform === 'win32') return 'win-unpacked' + if (platform === 'darwin') {return 'mac-unpacked'} // not used (mac swaps bundles) + + if (platform === 'win32') {return 'win-unpacked'} + return 'linux-unpacked' } @@ -56,15 +56,17 @@ function unpackedDirName(platform) { * `.../release/linux-unpacked-evil` can't masquerade as `.../release/linux-unpacked`. */ function resolveUnpackedRelease(execPath, updateRoot, platform) { - if (!execPath || !updateRoot) return null + if (!execPath || !updateRoot) {return null} const releaseDir = path.join(updateRoot, 'apps', 'desktop', 'release') const unpacked = path.join(releaseDir, unpackedDirName(platform)) const normalizedExec = path.resolve(String(execPath)) // execPath must be the unpacked dir itself or a descendant of it. const withSep = unpacked.endsWith(path.sep) ? unpacked : unpacked + path.sep + if (normalizedExec === unpacked || normalizedExec.startsWith(withSep)) { return unpacked } + return null } @@ -81,8 +83,10 @@ function resolveUnpackedRelease(execPath, updateRoot, platform) { * app. Closeable manual-restart terminal state. */ function decideRelaunchOutcome({ underUnpacked, sandboxOk }) { - if (!underUnpacked) return 'guiSkew' - if (!sandboxOk) return 'manual' + if (!underUnpacked) {return 'guiSkew'} + + if (!sandboxOk) {return 'manual'} + return 'relaunch' } @@ -99,9 +103,10 @@ function decideRelaunchOutcome({ underUnpacked, sandboxOk }) { * `statSync` is injectable so this is testable without a real setuid file. */ function sandboxPreflight(unpackedDir, statSync) { - if (!unpackedDir) return { ok: false, reason: 'no-unpacked-dir', path: null } + if (!unpackedDir) {return { ok: false, reason: 'no-unpacked-dir', path: null }} const sandboxPath = path.join(unpackedDir, 'chrome-sandbox') let st + try { st = statSync(sandboxPath) } catch { @@ -109,15 +114,20 @@ function sandboxPreflight(unpackedDir, statSync) { // sandbox; nothing to block the relaunch. return { ok: true, reason: 'no-sandbox-helper', path: sandboxPath } } + const ownedByRoot = st.uid === 0 const hasSetuid = (st.mode & 0o4000) !== 0 + if (ownedByRoot && hasSetuid) { return { ok: true, reason: 'launchable', path: sandboxPath } } + if (!ownedByRoot && !hasSetuid) { return { ok: false, reason: 'not-root-not-setuid', path: sandboxPath } } - if (!ownedByRoot) return { ok: false, reason: 'not-root', path: sandboxPath } + + if (!ownedByRoot) {return { ok: false, reason: 'not-root', path: sandboxPath }} + return { ok: false, reason: 'not-setuid', path: sandboxPath } } @@ -126,7 +136,7 @@ function sandboxPreflight(unpackedDir, statSync) { * environment. The reviewer asked us to integrate with any existing * `--no-sandbox` / chrome-sandbox handling. A repo grep found NO existing * non-interactive sandbox fallback in the desktop app (the only chrome-sandbox - * reference is documentation in scripts/before-pack.cjs). The one signal that + * reference is documentation in scripts/before-pack.ts). The one signal that * DOES exist is the standard Electron escape hatch: ELECTRON_DISABLE_SANDBOX=1 * (and the equivalent `--no-sandbox` already present in the launch args). If * the user has set that, the rebuilt binary will start even with a broken @@ -137,8 +147,11 @@ function sandboxPreflight(unpackedDir, statSync) { */ function sandboxFallbackFromEnv(env, launchArgs) { const disable = String((env && env.ELECTRON_DISABLE_SANDBOX) || '').trim() - if (disable === '1' || disable.toLowerCase() === 'true') return true - if (Array.isArray(launchArgs) && launchArgs.some(a => a === '--no-sandbox')) return true + + if (disable === '1' || disable.toLowerCase() === 'true') {return true} + + if (Array.isArray(launchArgs) && launchArgs.some(a => a === '--no-sandbox')) {return true} + return false } @@ -176,9 +189,11 @@ const INTERNAL_ARG_PREFIXES = [ * the exec path itself; there is no entry-script arg as in a dev run). */ function collectRelaunchArgs(argv) { - if (!Array.isArray(argv)) return [] + if (!Array.isArray(argv)) {return []} + return argv.filter(arg => { - if (typeof arg !== 'string' || arg.length === 0) return false + if (typeof arg !== 'string' || arg.length === 0) {return false} + return !INTERNAL_ARG_PREFIXES.some(prefix => prefix.endsWith('=') ? arg.startsWith(prefix) : arg === prefix || arg.startsWith(prefix + '=') ) @@ -197,13 +212,17 @@ const PRESERVED_ENV_PREFIXES = ['HERMES_DESKTOP_'] function collectRelaunchEnv(env) { const out = {} - if (!env || typeof env !== 'object') return out + + if (!env || typeof env !== 'object') {return out} + for (const [key, value] of Object.entries(env)) { - if (value == null) continue + if (value == null) {continue} + if (PRESERVED_ENV_KEYS.includes(key) || PRESERVED_ENV_PREFIXES.some(p => key.startsWith(p))) { out[key] = String(value) } } + return out } @@ -223,8 +242,10 @@ function buildRelaunchScript({ pid, execPath, args, env, cwd }) { const exports = Object.entries(env || {}) .map(([k, v]) => `export ${k}=${shellQuote(v)}`) .join('\n') + const quotedArgs = (args || []).map(shellQuote).join(' ') const cwdLine = cwd ? `cd ${shellQuote(cwd)} 2>/dev/null || true` : '' + // NOTE: `exec` replaces the watcher process with the relaunched app, so the // re-exec inherits exactly the env/cwd we set above. return `#!/bin/bash @@ -249,17 +270,15 @@ exec ${shellQuote(execPath)}${quotedArgs ? ' ' + quotedArgs : ''} ` } -module.exports = { - unpackedDirName, - resolveUnpackedRelease, - decideRelaunchOutcome, - sandboxPreflight, - sandboxFallbackFromEnv, +export { buildRelaunchScript, collectRelaunchArgs, collectRelaunchEnv, - buildRelaunchScript, - shellQuote, + decideRelaunchOutcome, INTERNAL_ARG_PREFIXES, PRESERVED_ENV_KEYS, - PRESERVED_ENV_PREFIXES -} + PRESERVED_ENV_PREFIXES, + resolveUnpackedRelease, + sandboxFallbackFromEnv, + sandboxPreflight, + shellQuote, + unpackedDirName } diff --git a/apps/desktop/electron/update-remote.test.cjs b/apps/desktop/electron/update-remote.test.ts similarity index 91% rename from apps/desktop/electron/update-remote.test.cjs rename to apps/desktop/electron/update-remote.test.ts index 0dfba970138..c4e468a285b 100644 --- a/apps/desktop/electron/update-remote.test.cjs +++ b/apps/desktop/electron/update-remote.test.ts @@ -1,8 +1,8 @@ /** - * Tests for electron/update-remote.cjs — the remote-detection helpers that + * Tests for electron/update-remote.ts — the remote-detection helpers that * keep passive update checks off the SSH origin for official installs. * - * Run with: node --test electron/update-remote.test.cjs + * Run with: node --test electron/update-remote.test.ts * (Wired into npm test:desktop:platforms in package.json.) * * Why this matters: a public install can carry @@ -15,16 +15,14 @@ * never prompts and should keep the normal fetch path). */ -const test = require('node:test') -const assert = require('node:assert/strict') +import assert from 'node:assert/strict' +import test from 'node:test' -const { - OFFICIAL_REPO_HTTPS_URL, - OFFICIAL_REPO_CANONICAL, - canonicalGitHubRemote, +import { canonicalGitHubRemote, + isOfficialSshRemote, isSshRemote, - isOfficialSshRemote -} = require('./update-remote.cjs') + OFFICIAL_REPO_CANONICAL, + OFFICIAL_REPO_HTTPS_URL } from './update-remote' test('canonicalGitHubRemote normalizes SSH and HTTPS forms to the same value', () => { assert.equal(canonicalGitHubRemote('git@github.com:NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL) diff --git a/apps/desktop/electron/update-remote.cjs b/apps/desktop/electron/update-remote.ts similarity index 79% rename from apps/desktop/electron/update-remote.cjs rename to apps/desktop/electron/update-remote.ts index 1e99bbe8877..b6b17ab0a7a 100644 --- a/apps/desktop/electron/update-remote.cjs +++ b/apps/desktop/electron/update-remote.ts @@ -8,8 +8,8 @@ * which needs no auth and cannot prompt. Active update/apply flows are left * unchanged. * - * Extracted from main.cjs so the security-critical remote detection is unit - * testable without booting Electron (main.cjs requires('electron') at load). + * Extracted from main.ts so the security-critical remote detection is unit + * testable without booting Electron (main.ts requires('electron') at load). */ const OFFICIAL_REPO_HTTPS_URL = 'https://github.com/NousResearch/hermes-agent.git' @@ -19,8 +19,9 @@ const OFFICIAL_REPO_CANONICAL = 'github.com/nousresearch/hermes-agent' // no trailing slash, no .git suffix) so SSH and HTTPS forms of the same repo // compare equal. function canonicalGitHubRemote(url) { - if (!url) return '' + if (!url) {return ''} let value = String(url).trim() + if (value.startsWith('git@github.com:')) { value = `github.com/${value.slice('git@github.com:'.length)}` } else if (value.startsWith('ssh://git@github.com/')) { @@ -28,13 +29,17 @@ function canonicalGitHubRemote(url) { } else { try { const parsed = new URL(value) - if (parsed.hostname && parsed.pathname) value = `${parsed.hostname}${parsed.pathname}` + + if (parsed.hostname && parsed.pathname) {value = `${parsed.hostname}${parsed.pathname}`} } catch { // Leave non-URL forms unchanged. } } + value = value.trim().replace(/\/+$/, '') - if (value.endsWith('.git')) value = value.slice(0, -4) + + if (value.endsWith('.git')) {value = value.slice(0, -4)} + return value.toLowerCase() } @@ -42,6 +47,7 @@ function isSshRemote(url) { const value = String(url || '') .trim() .toLowerCase() + return value.startsWith('git@') || value.startsWith('ssh://') } @@ -49,10 +55,8 @@ function isOfficialSshRemote(url) { return isSshRemote(url) && canonicalGitHubRemote(url) === OFFICIAL_REPO_CANONICAL } -module.exports = { - OFFICIAL_REPO_HTTPS_URL, - OFFICIAL_REPO_CANONICAL, - canonicalGitHubRemote, +export { canonicalGitHubRemote, + isOfficialSshRemote, isSshRemote, - isOfficialSshRemote -} + OFFICIAL_REPO_CANONICAL, + OFFICIAL_REPO_HTTPS_URL } diff --git a/apps/desktop/electron/vscode-marketplace.test.cjs b/apps/desktop/electron/vscode-marketplace.test.ts similarity index 95% rename from apps/desktop/electron/vscode-marketplace.test.cjs rename to apps/desktop/electron/vscode-marketplace.test.ts index 45169044bfa..6c8b8e6b76e 100644 --- a/apps/desktop/electron/vscode-marketplace.test.cjs +++ b/apps/desktop/electron/vscode-marketplace.test.ts @@ -1,9 +1,7 @@ -'use strict' +import assert from 'node:assert' +import test from 'node:test' -const assert = require('node:assert') -const test = require('node:test') - -const { __testing, extractThemes, readCentralDirectory } = require('./vscode-marketplace.cjs') +import { __testing, extractThemes, readCentralDirectory } from './vscode-marketplace' // Build a minimal zip with stored (uncompressed) entries so the test controls // the bytes exactly — exercises the central-directory reader + theme extraction @@ -72,6 +70,7 @@ test('extractThemes reads contributed color themes (resolving ./ paths)', () => themes: [{ label: 'Dracula', uiTheme: 'vs-dark', path: './themes/dracula.json' }] } }) + const themeJson = JSON.stringify({ name: 'Dracula', type: 'dark', colors: { 'editor.background': '#282a36' } }) const zip = makeZip([ diff --git a/apps/desktop/electron/vscode-marketplace.cjs b/apps/desktop/electron/vscode-marketplace.ts similarity index 98% rename from apps/desktop/electron/vscode-marketplace.cjs rename to apps/desktop/electron/vscode-marketplace.ts index 55e49bc30ec..4ad72d343ad 100644 --- a/apps/desktop/electron/vscode-marketplace.cjs +++ b/apps/desktop/electron/vscode-marketplace.ts @@ -1,5 +1,3 @@ -'use strict' - /** * VS Code Marketplace color-theme fetcher (main process). * @@ -14,8 +12,8 @@ * zip library into the desktop bundle for a feature this small. */ -const https = require('node:https') -const zlib = require('node:zlib') +import https from 'node:https' +import zlib from 'node:zlib' const GALLERY_QUERY_URL = 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery' const VSIX_ASSET_TYPE = 'Microsoft.VisualStudio.Services.VSIXPackage' @@ -30,7 +28,7 @@ function request( url, { method = 'GET', headers = {}, body = null, maxBytes = MAX_VSIX_BYTES } = {}, redirectsLeft = MAX_REDIRECTS -) { +): Promise<Buffer<ArrayBuffer>> { return new Promise((resolve, reject) => { const req = https.request(url, { method, headers }, res => { const status = res.statusCode ?? 0 @@ -102,6 +100,7 @@ async function resolveExtension(id) { // IncludeCategoryAndTags | IncludeLatestVersionOnly = 914. flags: 914 }) + const extension = json?.results?.[0]?.extensions?.[0] if (!extension) { @@ -127,6 +126,7 @@ async function resolveExtension(id) { /** POST an ExtensionQuery payload and return the parsed gallery response. */ async function queryGallery(payload, { maxBytes = 4 * 1024 * 1024 } = {}) { const body = JSON.stringify(payload) + const raw = await request(GALLERY_QUERY_URL, { method: 'POST', headers: { @@ -332,10 +332,12 @@ async function fetchMarketplaceThemes(id) { return { extensionId: trimmed, displayName, themes } } -module.exports = { - fetchMarketplaceThemes, - searchMarketplaceThemes, +const __testing = { themeEntryName, looksLikeIconTheme } + +export { + __testing, extractThemes, + fetchMarketplaceThemes, readCentralDirectory, - __testing: { themeEntryName, looksLikeIconTheme } + searchMarketplaceThemes } diff --git a/apps/desktop/electron/window-state.test.cjs b/apps/desktop/electron/window-state.test.ts similarity index 96% rename from apps/desktop/electron/window-state.test.cjs rename to apps/desktop/electron/window-state.test.ts index a0f68ce333c..83765270675 100644 --- a/apps/desktop/electron/window-state.test.cjs +++ b/apps/desktop/electron/window-state.test.ts @@ -4,19 +4,17 @@ * clamping, and the debounce that collapses mid-drag write storms. */ -const test = require('node:test') -const assert = require('node:assert/strict') +import assert from 'node:assert/strict' +import test from 'node:test' -const { - DEFAULT_WIDTH, +import { computeWindowOptions, + debounce, DEFAULT_HEIGHT, - MIN_WIDTH, + DEFAULT_WIDTH, MIN_HEIGHT, - sanitizeWindowState, + MIN_WIDTH, onScreen, - computeWindowOptions, - debounce -} = require('./window-state.cjs') + sanitizeWindowState } from './window-state' // A single 1920×1080 monitor (work area trimmed for the taskbar). const PRIMARY = [{ workArea: { x: 0, y: 0, width: 1920, height: 1040 } }] @@ -121,6 +119,7 @@ test('computeWindowOptions does not clamp when displays are unknown', () => { test('debounce coalesces a burst into one trailing run', t => { t.mock.timers.enable({ apis: ['setTimeout'] }) let calls = 0 + const d = debounce(() => { calls += 1 }, 250) @@ -138,6 +137,7 @@ test('debounce coalesces a burst into one trailing run', t => { test('debounce.flush runs now and cancels the pending timer', t => { t.mock.timers.enable({ apis: ['setTimeout'] }) let calls = 0 + const d = debounce(() => { calls += 1 }, 250) diff --git a/apps/desktop/electron/window-state.cjs b/apps/desktop/electron/window-state.ts similarity index 82% rename from apps/desktop/electron/window-state.cjs rename to apps/desktop/electron/window-state.ts index 6157e469b24..919d3274547 100644 --- a/apps/desktop/electron/window-state.cjs +++ b/apps/desktop/electron/window-state.ts @@ -2,7 +2,7 @@ * Pure geometry helpers for window-state.json — restoring the main window's * size, position, and maximized flag across launches. Side-effect-free so the * part that actually matters (rejecting garbage + off-screen bounds) is - * unit-testable without booting Electron; main.cjs owns the file I/O and the + * unit-testable without booting Electron; main.ts owns the file I/O and the * live `screen` displays. */ @@ -21,41 +21,59 @@ const MIN_VISIBLE = 48 const finite = v => typeof v === 'number' && Number.isFinite(v) const clamp = (v, lo, hi) => Math.max(lo, Math.min(v, hi)) +interface SanitizedWindowState{ + width: number, height: number, isMaximized: boolean, x?: number,y?: number +} + // Parse raw JSON → clean state, or null if garbage. width/height are required // and floored; x/y survive only as a finite pair; isMaximized is strict. -function sanitizeWindowState(raw) { - if (!raw || typeof raw !== 'object' || !finite(raw.width) || !finite(raw.height)) return null +function sanitizeWindowState(raw?: any): SanitizedWindowState | null - const state = { + + { + if (!raw || typeof raw !== 'object' || !finite(raw.width) || !finite(raw.height)) {return null} + + const state: SanitizedWindowState = { width: Math.max(MIN_WIDTH, Math.round(raw.width)), height: Math.max(MIN_HEIGHT, Math.round(raw.height)), - isMaximized: raw.isMaximized === true + isMaximized: raw.isMaximized === true, } + if (finite(raw.x) && finite(raw.y)) { - state.x = Math.round(raw.x) + state.x = Math.round(raw.x); state.y = Math.round(raw.y) } + return state } // True when `bounds` overlaps some display's work area by ≥ MIN_VISIBLE on both // axes. `displays` is Electron's screen.getAllDisplays() shape. function onScreen(bounds, displays) { - if (!Array.isArray(displays)) return false + if (!Array.isArray(displays)) {return false} + return displays.some(({ workArea: a } = {}) => { - if (!a) return false + if (!a) {return false} const x = Math.min(bounds.x + bounds.width, a.x + a.width) - Math.max(bounds.x, a.x) const y = Math.min(bounds.y + bounds.height, a.y + a.height) - Math.max(bounds.y, a.y) + return x >= MIN_VISIBLE && y >= MIN_VISIBLE }) } +interface WindowOptions { + width: number + height: number + x?: number + y?: number +} + // Sanitized state (or null) → BrowserWindow size/position options. Always sets // width/height, capped to the largest current display so a size saved on a // since-disconnected bigger monitor can't exceed any screen the user now has. // Sets x/y only when still on-screen; otherwise Electron centers the window. -function computeWindowOptions(state, displays) { - const opts = { +function computeWindowOptions(state, displays): WindowOptions { + const opts: WindowOptions = { width: finite(state?.width) ? state.width : DEFAULT_WIDTH, height: finite(state?.height) ? state.height : DEFAULT_HEIGHT } @@ -67,6 +85,7 @@ function computeWindowOptions(state, displays) { : m, { width: 0, height: 0 } ) + if (cap.width && cap.height) { opts.width = clamp(opts.width, MIN_WIDTH, cap.width) opts.height = clamp(opts.height, MIN_HEIGHT, cap.height) @@ -78,9 +97,10 @@ function computeWindowOptions(state, displays) { finite(state.y) && onScreen({ x: state.x, y: state.y, width: opts.width, height: opts.height }, displays) ) { - opts.x = state.x + opts.x = state.x; opts.y = state.y } + return opts } @@ -89,6 +109,7 @@ function computeWindowOptions(state, displays) { // cancels the pending timer — used on close, before the window is gone. function debounce(fn, delayMs) { let timer = null + const debounced = () => { clearTimeout(timer) timer = setTimeout(() => { @@ -96,22 +117,22 @@ function debounce(fn, delayMs) { fn() }, delayMs) } + debounced.flush = () => { clearTimeout(timer) timer = null fn() } + return debounced } -module.exports = { - DEFAULT_WIDTH, +export { computeWindowOptions, + debounce, DEFAULT_HEIGHT, - MIN_WIDTH, + DEFAULT_WIDTH, MIN_HEIGHT, MIN_VISIBLE, - sanitizeWindowState, + MIN_WIDTH, onScreen, - computeWindowOptions, - debounce -} + sanitizeWindowState } diff --git a/apps/desktop/electron/windows-child-process.test.cjs b/apps/desktop/electron/windows-child-process.test.ts similarity index 88% rename from apps/desktop/electron/windows-child-process.test.cjs rename to apps/desktop/electron/windows-child-process.test.ts index c15dc3b7b50..d1b4242f76c 100644 --- a/apps/desktop/electron/windows-child-process.test.cjs +++ b/apps/desktop/electron/windows-child-process.test.ts @@ -1,11 +1,14 @@ -'use strict' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' -const test = require('node:test') -const assert = require('node:assert/strict') -const fs = require('node:fs') -const path = require('node:path') +const ELECTRON_DIR = path.dirname(fileURLToPath(import.meta.url)) + +// TODO FIXME these tests all grep source code for specific things. This is an antipattern. +// Tests should NEVER read src, only assert behavior. -const ELECTRON_DIR = __dirname function readElectronFile(name) { return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n') @@ -24,9 +27,9 @@ function requireHiddenChildOptions(source, needle) { } test('desktop background child processes opt into hidden Windows consoles', () => { - const source = readElectronFile('main.cjs') + const source = readElectronFile('main.ts') - assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/) + assert.match(source, /function hiddenWindowsChildOptions\(options: any = \{\}\)/) requireHiddenChildOptions(source, "execFileSync(\n 'reg'") requireHiddenChildOptions(source, /execFileSync\(\s*pyExe/) @@ -45,7 +48,7 @@ test('desktop background child processes opt into hidden Windows consoles', () = }) test('desktop backend launches console python so child consoles are inherited, not pythonw', () => { - const source = readElectronFile('main.cjs') + const source = readElectronFile('main.ts') // The flash fix is structural: the backend runs as a console-subsystem // python.exe under hiddenWindowsChildOptions() (-> CREATE_NO_WINDOW), so it @@ -75,7 +78,7 @@ test('desktop backend launches console python so child consoles are inherited, n }) test('desktop backend teardown tree-kills Windows backend descendants', () => { - const source = readElectronFile('main.cjs') + const source = readElectronFile('main.ts') const helperIndex = source.indexOf('function stopBackendChild(child)') assert.notEqual(helperIndex, -1, 'missing backend teardown helper') @@ -98,7 +101,7 @@ test('desktop backend teardown tree-kills Windows backend descendants', () => { }) test('intentional or interactive desktop child processes stay documented', () => { - const source = readElectronFile('main.cjs') + const source = readElectronFile('main.ts') assert.match(source, /windowsHide: false/) assert.match(source, /handOffWindowsBootstrapRecovery/) @@ -109,7 +112,7 @@ test('intentional or interactive desktop child processes stay documented', () => }) test('bootstrap PowerShell runner hides Windows console children', () => { - const source = readElectronFile('bootstrap-runner.cjs') + const source = readElectronFile('bootstrap-runner.ts') assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/) requireHiddenChildOptions(source, /spawn\(\s*ps,\s*fullArgs/) diff --git a/apps/desktop/electron/windows-hermes-resolution.test.cjs b/apps/desktop/electron/windows-hermes-resolution.test.ts similarity index 85% rename from apps/desktop/electron/windows-hermes-resolution.test.cjs rename to apps/desktop/electron/windows-hermes-resolution.test.ts index 40e2658a122..1b91a8acb7f 100644 --- a/apps/desktop/electron/windows-hermes-resolution.test.cjs +++ b/apps/desktop/electron/windows-hermes-resolution.test.ts @@ -1,9 +1,7 @@ -'use strict' - -// Regression guards for Windows `hermes` resolution in main.cjs. +// Regression guards for Windows `hermes` resolution in main.ts. // -// main.cjs has no module.exports, so these follow the repo's source-assertion -// test pattern (see windows-child-process.test.cjs). They pin the two Windows +// main.ts has no module.exports, so these follow the repo's source-assertion +// test pattern (see windows-child-process.test.ts). They pin the two Windows // resolution bugs that caused desktop reinstall loops: // 1. findOnPath() tried the empty extension FIRST, so an extensionless // Git-Bash `hermes` shim shadowed the real hermes.cmd/hermes.exe; the @@ -20,13 +18,16 @@ // Retry / "Repair install" resolved the same dead interpreter instead of // falling through to the bootstrap installer. -const test = require('node:test') -const assert = require('node:assert/strict') -const fs = require('node:fs') -const path = require('node:path') +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) function readMain() { - return fs.readFileSync(path.join(__dirname, 'main.cjs'), 'utf8').replace(/\r\n/g, '\n') + return fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8').replace(/\r\n/g, '\n') } test('findOnPath tries PATHEXT extensions before the bare (empty) name on Windows', () => { @@ -66,7 +67,7 @@ test('Windows bootstrap recovery chooses --update when any real-install signal i test('unwrapWindowsVenvHermesCommand smoke-tests the venv python before trusting it', () => { const source = readMain() const fnStart = source.indexOf('function unwrapWindowsVenvHermesCommand(') - assert.notEqual(fnStart, -1, 'unwrapWindowsVenvHermesCommand must exist in main.cjs') + assert.notEqual(fnStart, -1, 'unwrapWindowsVenvHermesCommand must exist in main.ts') // Slice out just the function body (up to the next top-level function decl) const fnEnd = source.indexOf('\nfunction ', fnStart + 1) const body = source.slice(fnStart, fnEnd === -1 ? undefined : fnEnd) diff --git a/apps/desktop/electron/windows-user-env.test.cjs b/apps/desktop/electron/windows-user-env.test.ts similarity index 94% rename from apps/desktop/electron/windows-user-env.test.cjs rename to apps/desktop/electron/windows-user-env.test.ts index 3fee1598190..17c9a4a7c7e 100644 --- a/apps/desktop/electron/windows-user-env.test.cjs +++ b/apps/desktop/electron/windows-user-env.test.ts @@ -1,7 +1,7 @@ -const assert = require('node:assert/strict') -const { test } = require('node:test') +import assert from 'node:assert/strict' +import { test } from 'node:test' -const { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } = require('./windows-user-env.cjs') +import { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } from './windows-user-env' // ── parseRegQueryValue ───────────────────────────────────────────────────── @@ -42,25 +42,32 @@ test('expandWindowsEnvRefs leaves literal paths and unknown refs intact', () => test('readWindowsUserEnvVar returns null off Windows without spawning', () => { let spawned = false + const exec = () => { spawned = true + return '' } + assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'linux', exec }), null) assert.equal(spawned, false) }) test('readWindowsUserEnvVar queries HKCU\\Environment and expands the value', () => { const calls = [] + const exec = (cmd, args) => { calls.push([cmd, args]) + return 'HKEY_CURRENT_USER\\Environment\r\n HERMES_HOME REG_EXPAND_SZ %DRIVE%\\Hermes\r\n' } + const value = readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', env: { DRIVE: 'F:' }, exec }) + assert.equal(value, 'F:\\Hermes') assert.deepEqual(calls, [['reg', ['query', 'HKCU\\Environment', '/v', 'HERMES_HOME']]]) }) @@ -69,6 +76,7 @@ test('readWindowsUserEnvVar returns null when reg exits non-zero (value missing) const exec = () => { throw new Error('reg exited 1') } + assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', exec }), null) }) diff --git a/apps/desktop/electron/windows-user-env.cjs b/apps/desktop/electron/windows-user-env.ts similarity index 83% rename from apps/desktop/electron/windows-user-env.cjs rename to apps/desktop/electron/windows-user-env.ts index 4bfaba1570d..a6bc99b0830 100644 --- a/apps/desktop/electron/windows-user-env.cjs +++ b/apps/desktop/electron/windows-user-env.ts @@ -1,4 +1,4 @@ -// windows-user-env.cjs +// windows-user-env.ts // // Read a User-scoped environment variable straight from the Windows registry // (HKCU\Environment). @@ -10,7 +10,7 @@ // gap silently sends the backend to the default %LOCALAPPDATA%\hermes. Reading // the live registry value closes the gap. See #45471. -const { execFileSync } = require('node:child_process') +import { execFileSync } from 'node:child_process' // Parse the output of `reg query HKCU\Environment /v <name>`, which looks like: // @@ -20,15 +20,18 @@ const { execFileSync } = require('node:child_process') // Returns the raw value string (spaces inside the value preserved), or null when // the requested value line isn't present. function parseRegQueryValue(stdout, name) { - if (!stdout || !name) return null + if (!stdout || !name) {return null} const typePattern = /^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/ + for (const rawLine of String(stdout).split(/\r?\n/)) { const line = rawLine.trim() const match = line.match(typePattern) + if (match && match[1].toLowerCase() === name.toLowerCase()) { return match[2] } } + return null } @@ -36,9 +39,11 @@ function parseRegQueryValue(stdout, name) { // unexpanded references; plain REG_SZ paths have none, so this is a no-op for // the common F:\... case. Unknown references are left verbatim. function expandWindowsEnvRefs(value, env = process.env) { - if (!value) return value + if (!value) {return value} + return value.replace(/%([^%]+)%/g, (whole, name) => { const key = Object.keys(env).find(k => k.toUpperCase() === String(name).toUpperCase()) + return key != null && env[key] != null ? env[key] : whole }) } @@ -46,9 +51,12 @@ function expandWindowsEnvRefs(value, env = process.env) { // Read a User-scoped env var from HKCU\Environment. Windows-only: returns null // off-Windows (without spawning), on any spawn error, when `reg` exits non-zero // (the value doesn't exist), or when the value is empty. -function readWindowsUserEnvVar(name, { platform = process.platform, env = process.env, exec = execFileSync } = {}) { - if (platform !== 'win32' || !name) return null +function readWindowsUserEnvVar(name, { platform = process.platform, env = process.env, exec = execFileSync }: { + platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv, exec?: typeof execFileSync | ((file?: string, args?: any) => string) +} = {}) { + if (platform !== 'win32' || !name) {return null} let stdout + try { stdout = exec('reg', ['query', 'HKCU\\Environment', '/v', name], { encoding: 'utf8', @@ -59,14 +67,15 @@ function readWindowsUserEnvVar(name, { platform = process.platform, env = proces // `reg` missing, or value absent (reg exits 1) — caller falls back. return null } + const raw = parseRegQueryValue(stdout, name) - if (raw == null) return null + + if (raw == null) {return null} const expanded = expandWindowsEnvRefs(raw, env).trim() + return expanded || null } -module.exports = { - expandWindowsEnvRefs, +export { expandWindowsEnvRefs, parseRegQueryValue, - readWindowsUserEnvVar -} + readWindowsUserEnvVar } diff --git a/apps/desktop/electron/workspace-cwd.test.cjs b/apps/desktop/electron/workspace-cwd.test.ts similarity index 77% rename from apps/desktop/electron/workspace-cwd.test.cjs rename to apps/desktop/electron/workspace-cwd.test.ts index 85a044ab3be..6737c358282 100644 --- a/apps/desktop/electron/workspace-cwd.test.cjs +++ b/apps/desktop/electron/workspace-cwd.test.ts @@ -1,14 +1,14 @@ /** - * Tests for electron/workspace-cwd.cjs. + * Tests for electron/workspace-cwd.ts. * - * Run with: node --test electron/workspace-cwd.test.cjs + * Run with: node --test electron/workspace-cwd.test.ts */ -const test = require('node:test') -const assert = require('node:assert/strict') -const path = require('node:path') +import assert from 'node:assert/strict' +import path from 'node:path' +import test from 'node:test' -const { isPackagedInstallPath } = require('./workspace-cwd.cjs') +import { isPackagedInstallPath } from './workspace-cwd' const installRoot = path.resolve('/opt/Hermes') diff --git a/apps/desktop/electron/workspace-cwd.cjs b/apps/desktop/electron/workspace-cwd.ts similarity index 78% rename from apps/desktop/electron/workspace-cwd.cjs rename to apps/desktop/electron/workspace-cwd.ts index bb5da777148..332ac92dca6 100644 --- a/apps/desktop/electron/workspace-cwd.cjs +++ b/apps/desktop/electron/workspace-cwd.ts @@ -1,7 +1,7 @@ -const path = require('node:path') +import path from 'node:path' /** True when `dir` lives inside a packaged app bundle / install tree. */ -function isPackagedInstallPath(dir, { installRoots, isPackaged }) { +function isPackagedInstallPath(dir, { installRoots, isPackaged }: { installRoots: string[], isPackaged:boolean }) { if (!isPackaged || !dir) { return false } @@ -21,7 +21,7 @@ function isPackagedInstallPath(dir, { installRoots, isPackaged }) { return true } - const rel = path.relative(root, resolved) + const rel = path.relative(root, resolved) as any if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) { return true @@ -31,4 +31,4 @@ function isPackagedInstallPath(dir, { installRoots, isPackaged }) { return false } -module.exports = { isPackagedInstallPath } +export { isPackagedInstallPath } diff --git a/apps/desktop/electron/wsl-clipboard-image.test.cjs b/apps/desktop/electron/wsl-clipboard-image.test.ts similarity index 92% rename from apps/desktop/electron/wsl-clipboard-image.test.cjs rename to apps/desktop/electron/wsl-clipboard-image.test.ts index 343adc1f6d6..da3481ffa17 100644 --- a/apps/desktop/electron/wsl-clipboard-image.test.cjs +++ b/apps/desktop/electron/wsl-clipboard-image.test.ts @@ -1,12 +1,10 @@ -const assert = require('node:assert/strict') -const test = require('node:test') +import assert from 'node:assert/strict' +import test from 'node:test' -const { - decodeClipboardImageBase64, +import { decodeClipboardImageBase64, encodePowerShellCommand, powershellCandidates, - readWslWindowsClipboardImage -} = require('./wsl-clipboard-image.cjs') + readWslWindowsClipboardImage } from './wsl-clipboard-image' const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) @@ -49,10 +47,12 @@ test('decodeClipboardImageBase64 rejects base64 without a PNG signature', () => test('readWslWindowsClipboardImage decodes the first candidate that returns a PNG', () => { const png = fakePngBuffer() const calls = [] - const exec = (cmd, args) => { + + const exec = ((cmd, args) => { calls.push({ cmd, args }) + return png.toString('base64') - } + }) as any const result = readWslWindowsClipboardImage({ exec, candidates: ['powershell.exe'] }) assert.ok(result && result.equals(png)) @@ -65,15 +65,18 @@ test('readWslWindowsClipboardImage decodes the first candidate that returns a PN test('readWslWindowsClipboardImage returns null and stops when stdout is empty (no image)', () => { let count = 0 - const exec = () => { + + const exec =(() => { count += 1 - return '' - } + + return '' + } )as any const result = readWslWindowsClipboardImage({ exec, candidates: ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'] }) + assert.equal(result, null) // Empty stdout means "no image on the clipboard" — don't probe further candidates. assert.equal(count, 1) @@ -82,18 +85,22 @@ test('readWslWindowsClipboardImage returns null and stops when stdout is empty ( test('readWslWindowsClipboardImage falls through to the next candidate when one throws', () => { const png = fakePngBuffer() const seen = [] + const exec = cmd => { seen.push(cmd) + if (cmd === 'powershell.exe') { throw Object.assign(new Error('not found'), { code: 'ENOENT' }) } - return png.toString('base64') + + return png.toString('base64') as any } const result = readWslWindowsClipboardImage({ exec, candidates: ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'] }) + assert.ok(result && result.equals(png)) assert.deepEqual(seen, ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe']) }) diff --git a/apps/desktop/electron/wsl-clipboard-image.cjs b/apps/desktop/electron/wsl-clipboard-image.ts similarity index 90% rename from apps/desktop/electron/wsl-clipboard-image.cjs rename to apps/desktop/electron/wsl-clipboard-image.ts index c81fe7b2a60..23fe45e8b15 100644 --- a/apps/desktop/electron/wsl-clipboard-image.cjs +++ b/apps/desktop/electron/wsl-clipboard-image.ts @@ -1,7 +1,7 @@ // Pull a Windows-host clipboard image from inside WSL2 via PowerShell (WSLg // bridges text but not images). Returns PNG bytes or null; exec injectable. -const { execFileSync } = require('node:child_process') +import { execFileSync } from 'node:child_process' // STA is mandatory: System.Windows.Forms.Clipboard throws ThreadStateException // off a single-threaded apartment. We emit base64 (not raw bytes) so the PNG @@ -33,9 +33,11 @@ function powershellCandidates() { function decodeClipboardImageBase64(stdout) { const b64 = String(stdout || '').trim() - if (!b64) return null + + if (!b64) {return null} let buffer + try { buffer = Buffer.from(b64, 'base64') } catch { @@ -44,6 +46,7 @@ function decodeClipboardImageBase64(stdout) { // Guard against partial / garbage output: require a real PNG signature. const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + if (buffer.length < PNG_SIGNATURE.length || !buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) { return null } @@ -54,7 +57,7 @@ function decodeClipboardImageBase64(stdout) { // Read the Windows clipboard image from inside WSL. Returns a PNG Buffer, or // null when there's no image, PowerShell is unreachable, or output is invalid. // Linux-only by contract (caller gates on IS_WSL); never throws. -function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powershellCandidates() } = {}) { +function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powershellCandidates() }: {exec?: typeof execFileSync, candidates?: string[]} = {}) { const encoded = encodePowerShellCommand(PS_SCRIPT) for (const ps of candidates) { @@ -72,10 +75,13 @@ function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powers stdio: ['ignore', 'pipe', 'ignore'] } ) + const decoded = decodeClipboardImageBase64(stdout) - if (decoded) return decoded + + if (decoded) {return decoded} + // Empty stdout = no image on the clipboard; stop, don't try fallbacks. - if (String(stdout || '').trim() === '') return null + if (String(stdout || '').trim() === '') {return null} } catch { // This powershell.exe candidate is missing/failed — try the next one. } @@ -84,9 +90,7 @@ function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powers return null } -module.exports = { - decodeClipboardImageBase64, +export { decodeClipboardImageBase64, encodePowerShellCommand, powershellCandidates, - readWslWindowsClipboardImage -} + readWslWindowsClipboardImage } diff --git a/apps/desktop/electron/zoom.test.cjs b/apps/desktop/electron/zoom.test.ts similarity index 89% rename from apps/desktop/electron/zoom.test.cjs rename to apps/desktop/electron/zoom.test.ts index da104a52630..0636c8c52e2 100644 --- a/apps/desktop/electron/zoom.test.cjs +++ b/apps/desktop/electron/zoom.test.ts @@ -4,10 +4,10 @@ * roundtrip stability of the preset percentages. */ -const test = require('node:test') -const assert = require('node:assert/strict') +import assert from 'node:assert/strict' +import test from 'node:test' -const { ZOOM_STORAGE_KEY, clampZoomLevel, percentToZoomLevel, zoomLevelToPercent } = require('./zoom.cjs') +import { clampZoomLevel, percentToZoomLevel, ZOOM_STORAGE_KEY, zoomLevelToPercent } from './zoom' test('storage key stays stable so persisted zoom survives upgrades', () => { assert.equal(ZOOM_STORAGE_KEY, 'hermes:desktop:zoomLevel') @@ -43,6 +43,7 @@ test('preset percentages roundtrip within rounding', () => { test('conversion is monotonic across the preset range', () => { const levels = [90, 100, 110, 125, 150, 175].map(percentToZoomLevel) + for (let i = 1; i < levels.length; i++) { assert.ok(levels[i] > levels[i - 1]) } diff --git a/apps/desktop/electron/zoom.cjs b/apps/desktop/electron/zoom.ts similarity index 64% rename from apps/desktop/electron/zoom.cjs rename to apps/desktop/electron/zoom.ts index 41477f41b42..1f865cfbfa4 100644 --- a/apps/desktop/electron/zoom.cjs +++ b/apps/desktop/electron/zoom.ts @@ -6,29 +6,24 @@ * factor = 1.2 ^ level. */ -const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel' +export const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel' const ZOOM_FACTOR_BASE = 1.2 const MIN_ZOOM_LEVEL = -9 const MAX_ZOOM_LEVEL = 9 -function clampZoomLevel(value) { - if (!Number.isFinite(value)) return 0 +export function clampZoomLevel(value) { + if (!Number.isFinite(value)) {return 0} + return Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL) } -function zoomLevelToPercent(level) { +export function zoomLevelToPercent(level) { return Math.round(Math.pow(ZOOM_FACTOR_BASE, clampZoomLevel(level)) * 100) } -function percentToZoomLevel(percent) { - if (!Number.isFinite(percent) || percent <= 0) return 0 - return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE)) -} +export function percentToZoomLevel(percent) { + if (!Number.isFinite(percent) || percent <= 0) {return 0} -module.exports = { - ZOOM_STORAGE_KEY, - clampZoomLevel, - percentToZoomLevel, - zoomLevelToPercent -} + return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE)) +} \ No newline at end of file diff --git a/apps/desktop/eslint.config.mjs b/apps/desktop/eslint.config.mjs index 069a0056bbb..ef098c5c8ab 100644 --- a/apps/desktop/eslint.config.mjs +++ b/apps/desktop/eslint.config.mjs @@ -105,12 +105,12 @@ export default [ } }, { - files: ['**/*.js', '**/*.cjs'], + files: ['**/*.js', '**/*.cjs', '**/*.mjs'], ignores: ['**/node_modules/**', '**/dist/**'], languageOptions: { ecmaVersion: 'latest', globals: { ...globals.node }, - sourceType: 'commonjs' + sourceType: 'module' } }, { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c3daba00e2b..2825f58ed9d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -6,22 +6,22 @@ "description": "Native desktop shell for Hermes Agent.", "author": "Nous Research", "type": "module", - "main": "electron/main.cjs", + "main": "dist/electron-main.mjs", "engines": { "node": "^20.19.0 || >=22.12.0" }, "scripts": { "dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"", "dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev", - "dev:renderer": "node scripts/assert-root-install.cjs && vite --host 127.0.0.1 --port 5174", - "dev:electron": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", - "profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .", - "profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", + "dev:renderer": "node scripts/assert-root-install.mjs && vite --host 127.0.0.1 --port 5174", + "dev:electron": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 NODE_OPTIONS=tsx electron electron/main.ts", + "profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 NODE_OPTIONS=tsx electron --inspect=9229 electron/main.ts", + "profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof NODE_OPTIONS=tsx HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron electron/main.ts", "start": "npm run build && electron .", - "build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild", - "postbuild": "node scripts/assert-dist-built.cjs", - "prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs", - "builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.cjs", + "build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && tsc -b && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs", + "postbuild": "node scripts/assert-dist-built.mjs", + "prebuilder": "node scripts/patch-electron-builder-mac-binary.mjs", + "builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.mjs", "pack": "npm run build && npm run builder -- --dir", "dist": "npm run build && npm run builder", "dist:mac": "npm run build && npm run builder -- --mac", @@ -37,14 +37,14 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs electron/zoom.test.cjs electron/windows-hermes-resolution.test.cjs electron/oauth-session-request.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.ts electron/hardening.test.ts electron/backend-env.test.ts electron/backend-probes.test.ts electron/backend-ready.test.ts electron/bootstrap-runner.test.ts electron/connection-config.test.ts electron/dashboard-token.test.ts electron/gateway-ws-probe.test.ts electron/oauth-net-request.test.ts electron/desktop-uninstall.test.ts electron/session-windows.test.ts electron/link-title-window.test.ts electron/workspace-cwd.test.ts electron/fs-read-dir.test.ts electron/git-root.test.ts electron/git-worktree-ops.test.ts electron/windows-child-process.test.ts electron/update-remote.test.ts electron/update-count.test.ts electron/update-rebuild.test.ts electron/update-marker.test.ts electron/update-relaunch.test.ts electron/windows-user-env.test.ts electron/wsl-clipboard-image.test.ts electron/titlebar-overlay-width.test.ts electron/window-state.test.ts electron/zoom.test.ts electron/windows-hermes-resolution.test.ts electron/oauth-session-request.test.ts", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", - "fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.{js,cjs}' 'vite.config.ts'", + "fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.ts' 'vite.config.ts'", "fix": "npm run lint:fix && npm run fmt", "test:ui": "vitest run --environment jsdom", - "preview": "node scripts/assert-root-install.cjs && vite preview --host 127.0.0.1 --port 4174" + "preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174" }, "dependencies": { "@assistant-ui/react": "^0.12.28", @@ -117,6 +117,7 @@ "web-haptics": "^0.0.6" }, "devDependencies": { + "@electron/rebuild": "^4.0.6", "@eslint/js": "^9.39.4", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.3.2", @@ -132,6 +133,7 @@ "cross-env": "^10.1.0", "electron": "40.10.2", "electron-builder": "^26.8.1", + "esbuild": "^0.28.1", "eslint": "^9.39.4", "eslint-plugin-perfectionist": "^5.9.0", "eslint-plugin-react": "^7.37.5", @@ -141,6 +143,7 @@ "jsdom": "^29.1.1", "prettier": "^3.8.3", "rcedit": "^5.0.2", + "tsx": "^4.22.4", "typescript": "^6.0.3", "vite": "^8.0.10", "vitest": "^4.1.5", @@ -167,29 +170,24 @@ "files": [ "dist/**", "assets/**", - "electron/**", "public/**", "package.json" ], - "beforeBuild": "scripts/before-build.cjs", - "beforePack": "scripts/before-pack.cjs", - "afterPack": "scripts/after-pack.cjs", + "beforeBuild": "scripts/before-build.mjs", + "beforePack": "scripts/before-pack.mjs", + "afterPack": "scripts/after-pack.mjs", "extraResources": [ { "from": "build/install-stamp.json", "to": "install-stamp.json" }, - { - "from": "build/native-deps", - "to": "native-deps" - }, { "from": "assets/icon.ico", "to": "icon.ico" } ], "asar": true, - "afterSign": "scripts/notarize.cjs", + "afterSign": "scripts/notarize.mjs", "asarUnpack": [ "**/*.node", "**/prebuilds/**", diff --git a/apps/desktop/scripts/after-pack.cjs b/apps/desktop/scripts/after-pack.mjs similarity index 81% rename from apps/desktop/scripts/after-pack.cjs rename to apps/desktop/scripts/after-pack.mjs index f81262d28ae..509cbb42481 100644 --- a/apps/desktop/scripts/after-pack.cjs +++ b/apps/desktop/scripts/after-pack.mjs @@ -1,8 +1,8 @@ /** - * after-pack.cjs — electron-builder afterPack hook. + * after-pack.mjs — electron-builder afterPack hook. * * Stamps the Hermes icon + identity onto the packed Windows Hermes.exe via - * rcedit (delegated to set-exe-identity.cjs). This runs for EVERY packed build + * rcedit (delegated to set-exe-identity.mjs). This runs for EVERY packed build * — first install, `hermes desktop`, the installer's --update rebuild, and a * dev's manual `npm run pack` — so the branded exe can never silently revert * to the stock "Electron" icon/name (the bug when the stamp lived only in @@ -19,18 +19,18 @@ * - packager.appInfo.productFilename: the exe basename (e.g. 'Hermes') */ -const path = require('node:path') +import path from 'node:path' -const { stampExeIdentity } = require('./set-exe-identity.cjs') +import { stampExeIdentity } from './set-exe-identity.mjs' -exports.default = async function afterPack(context) { +export default async function afterPack(context) { if (context.electronPlatformName !== 'win32') { return } const productName = context.packager?.appInfo?.productFilename || 'Hermes' const exe = path.join(context.appOutDir, `${productName}.exe`) - const desktopRoot = path.resolve(__dirname, '..') + const desktopRoot = path.resolve(import.meta.dirname, '..') try { await stampExeIdentity(exe, desktopRoot) diff --git a/apps/desktop/scripts/assert-dist-built.cjs b/apps/desktop/scripts/assert-dist-built.mjs similarity index 74% rename from apps/desktop/scripts/assert-dist-built.cjs rename to apps/desktop/scripts/assert-dist-built.mjs index 8eea50f45a3..d445edd6ea1 100644 --- a/apps/desktop/scripts/assert-dist-built.cjs +++ b/apps/desktop/scripts/assert-dist-built.mjs @@ -13,31 +13,32 @@ // inherits it. It fails loud and early instead of shipping a broken bundle. // See issues #39484 (renderer blank page) and #41327 / #39472 (dashboard 404). -const fs = require("fs") -const path = require("path") +import { existsSync, statSync, readdirSync } from "fs" +import { join, resolve } from "path" +import { isMain } from "./utils.mjs" // Pure check — returns { ok: true } or { ok: false, error: "..." }. // Kept side-effect-free so it can be unit tested without spawning a process. -function checkDistBuilt(distDir) { - if (!fs.existsSync(distDir) || !fs.statSync(distDir).isDirectory()) { +export function checkDistBuilt(distDir) { + if (!existsSync(distDir) || !statSync(distDir).isDirectory()) { return { ok: false, error: `no dist directory at ${distDir}` } } - const indexHtml = path.join(distDir, "index.html") - if (!fs.existsSync(indexHtml) || !fs.statSync(indexHtml).isFile()) { + const indexHtml = join(distDir, "index.html") + if (!existsSync(indexHtml) || !statSync(indexHtml).isFile()) { return { ok: false, error: `dist/index.html is missing at ${indexHtml}` } } - if (fs.statSync(indexHtml).size === 0) { + if (statSync(indexHtml).size === 0) { return { ok: false, error: `dist/index.html is empty at ${indexHtml}` } } // index.html alone isn't enough — vite emits hashed JS into dist/assets. // An index.html with no script bundle still blank-pages. - const assetsDir = path.join(distDir, "assets") + const assetsDir = join(distDir, "assets") const hasAssets = - fs.existsSync(assetsDir) && - fs.statSync(assetsDir).isDirectory() && - fs.readdirSync(assetsDir).some(name => name.endsWith(".js")) + existsSync(assetsDir) && + statSync(assetsDir).isDirectory() && + readdirSync(assetsDir).some(name => name.endsWith(".js")) if (!hasAssets) { return { ok: false, error: `dist/assets has no built JS bundle (expected vite output under ${assetsDir})` } } @@ -46,8 +47,8 @@ function checkDistBuilt(distDir) { } function main() { - const desktopRoot = path.resolve(__dirname, "..") - const distDir = path.join(desktopRoot, "dist") + const desktopRoot = resolve(import.meta.dirname, "..") + const distDir = join(desktopRoot, "dist") const result = checkDistBuilt(distDir) if (!result.ok) { @@ -63,8 +64,8 @@ function main() { console.log("✓ assert-dist-built: dist/index.html + assets present") } -if (require.main === module) { +if (isMain(import.meta.url)) { main() } -module.exports = { checkDistBuilt } +export default { checkDistBuilt } diff --git a/apps/desktop/scripts/assert-dist-built.test.cjs b/apps/desktop/scripts/assert-dist-built.test.mjs similarity index 91% rename from apps/desktop/scripts/assert-dist-built.test.cjs rename to apps/desktop/scripts/assert-dist-built.test.mjs index 5121762469a..7793d359962 100644 --- a/apps/desktop/scripts/assert-dist-built.test.cjs +++ b/apps/desktop/scripts/assert-dist-built.test.mjs @@ -1,10 +1,10 @@ -const assert = require('node:assert/strict') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const test = require('node:test') +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' -const { checkDistBuilt } = require('../scripts/assert-dist-built.cjs') +import { checkDistBuilt } from '../scripts/assert-dist-built.mjs' function makeDist(extra) { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-dist-')) diff --git a/apps/desktop/scripts/assert-root-install.cjs b/apps/desktop/scripts/assert-root-install.cjs deleted file mode 100644 index 26433ca9be7..00000000000 --- a/apps/desktop/scripts/assert-root-install.cjs +++ /dev/null @@ -1,13 +0,0 @@ -"use strict" - -const fs = require("fs") -const path = require("path") - -const root = path.resolve(__dirname, "..", "..", "..") - -try { - fs.accessSync(path.join(root, "node_modules", "vite", "package.json")) -} catch { - console.error(`Run from repo root: cd ${root} && npm ci`) - process.exit(1) -} diff --git a/apps/desktop/scripts/assert-root-install.mjs b/apps/desktop/scripts/assert-root-install.mjs new file mode 100644 index 00000000000..5dc1d51bdcd --- /dev/null +++ b/apps/desktop/scripts/assert-root-install.mjs @@ -0,0 +1,11 @@ +import { accessSync } from "fs" +import { resolve, join } from "path" + +const root = resolve(import.meta.dirname, "..", "..", "..") + +try { + accessSync(join(root, "node_modules", "vite", "package.json")) +} catch { + console.error(`Run from repo root: cd ${root} && npm ci`) + process.exit(1) +} diff --git a/apps/desktop/scripts/before-build.cjs b/apps/desktop/scripts/before-build.mjs similarity index 89% rename from apps/desktop/scripts/before-build.cjs rename to apps/desktop/scripts/before-build.mjs index 673aca380d3..e9a1d843ae5 100644 --- a/apps/desktop/scripts/before-build.cjs +++ b/apps/desktop/scripts/before-build.mjs @@ -4,8 +4,8 @@ * avoids workspace dependency graph explosions and keeps packaging * deterministic across environments. The Hermes Agent Python payload is no * longer bundled; the Electron app fetches it at first launch via - * `install.ps1`'s stage protocol (Windows). See `electron/main.cjs`. + * `install.ps1`'s stage protocol (Windows). See `electron/main.ts`. */ -module.exports = async function beforeBuild() { +export default async function beforeBuild() { return false } diff --git a/apps/desktop/scripts/before-pack.cjs b/apps/desktop/scripts/before-pack.mjs similarity index 54% rename from apps/desktop/scripts/before-pack.cjs rename to apps/desktop/scripts/before-pack.mjs index 7ef9bcfadc8..a96b30651dd 100644 --- a/apps/desktop/scripts/before-pack.cjs +++ b/apps/desktop/scripts/before-pack.mjs @@ -1,10 +1,11 @@ 'use strict' - /** - * before-pack.cjs — electron-builder beforePack hook. + * before-pack.mjs — electron-builder beforePack hook. * - * Removes any stale unpacked app directory (`appOutDir`) before - * electron-builder stages the Electron binaries into it. + * Two responsibilities: + * + * 1. Removes any stale unpacked app directory (`appOutDir`) before + * electron-builder stages the Electron binaries into it. * * WHY THIS EXISTS * --------------- @@ -41,30 +42,41 @@ * resolve rather than throw — worst case electron-builder hits the original * ENOENT, which is no worse than not having this hook at all. * + * 2. Re-stages node-pty's native files for the ACTUAL target platform/arch + * of this pack. `npm run build` already staged node-pty once for the + * host machine (see scripts/stage-native-deps.mjs), which is correct for + * single-arch builds matching the host. But electron-builder can target + * a different arch than the host (cross-build), or pack multiple archs + * from one `npm run build` (e.g. `dist:mac` => x64 + arm64). Only this + * hook knows the real per-target arch, via `context.arch` / + * `context.electronPlatformName` — so it re-stages on top of whatever + * `npm run build` left behind, per target, right before files are read + * for packing. + * * electron-builder passes a context with: * - appOutDir: the unpacked app directory about to be staged * - electronPlatformName: 'win32' | 'darwin' | 'linux' + * - arch: Arch enum (0=ia32, 1=x64, 2=armv7l, 3=arm64, 4=universal) */ +import { existsSync, rmSync } from 'node:fs' +import { Arch } from 'electron-builder' +import { stageNodePty } from './stage-native-deps.mjs' -const fs = require('node:fs') - -function cleanStaleAppOutDir(appOutDir) { +export function cleanStaleAppOutDir(appOutDir) { if (!appOutDir || typeof appOutDir !== 'string') { return false } - if (!fs.existsSync(appOutDir)) { + if (!existsSync(appOutDir)) { return false } // Recursive + force so a half-written tree (read-only bits, partial files) // can't block the wipe. retry/maxRetries rides out transient EBUSY on // Windows where an AV/indexer may briefly hold a handle. - fs.rmSync(appOutDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + rmSync(appOutDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) return true } -exports.cleanStaleAppOutDir = cleanStaleAppOutDir - -exports.default = async function beforePack(context) { +export default async function beforePack(context) { const appOutDir = context && context.appOutDir try { if (cleanStaleAppOutDir(appOutDir)) { @@ -75,4 +87,26 @@ exports.default = async function beforePack(context) { // directory (permissions, mount) is still diagnosable. console.warn(`[before-pack] could not clean ${appOutDir} (${err.message}); continuing`) } -} + + try { + const platform = context && context.electronPlatformName + const archName = context && typeof context.arch === 'number' ? Arch[context.arch] : undefined + if (platform && archName) { + if (archName === 'universal') { + console.warn( + '[before-pack] target arch is "universal" — node-pty has no universal prebuild; ' + + 'staged binary will be whichever single-arch copy npm run build left behind. ' + + 'lipo-merge x64/arm64 .node files manually if you need a true universal build.' + ) + } else { + await stageNodePty({ platform, arch: archName }) + console.log(`[before-pack] re-staged node-pty for target ${platform}-${archName}`) + } + } + } catch (err) { + // This one SHOULD fail the build — a missing/wrong native binary for the + // target arch means a broken package shipped to users, which is worse + // than a build that fails loudly here. + throw new Error(`[before-pack] failed to stage node-pty for this target: ${err.message}`) + } +} \ No newline at end of file diff --git a/apps/desktop/scripts/before-pack.test.cjs b/apps/desktop/scripts/before-pack.test.mjs similarity index 86% rename from apps/desktop/scripts/before-pack.test.cjs rename to apps/desktop/scripts/before-pack.test.mjs index 763922aa6f8..098121e0c6a 100644 --- a/apps/desktop/scripts/before-pack.test.cjs +++ b/apps/desktop/scripts/before-pack.test.mjs @@ -1,10 +1,10 @@ -const assert = require('node:assert/strict') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const test = require('node:test') +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' -const { cleanStaleAppOutDir } = require('../scripts/before-pack.cjs') +import beforePack, { cleanStaleAppOutDir } from '../scripts/before-pack.mjs' test('cleanStaleAppOutDir removes a populated unpacked directory', () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-')) @@ -45,7 +45,6 @@ test('cleanStaleAppOutDir ignores empty or invalid input', () => { }) test('beforePack default export resolves even when cleanup throws', async () => { - const { default: beforePack } = require('../scripts/before-pack.cjs') // A directory path that rmSync can't remove is simulated by passing a // context whose appOutDir is a file the hook will try (and be allowed) to // remove; the contract under test is that the hook never rejects. diff --git a/apps/desktop/scripts/bundle-electron-main.mjs b/apps/desktop/scripts/bundle-electron-main.mjs new file mode 100644 index 00000000000..f950f4908a2 --- /dev/null +++ b/apps/desktop/scripts/bundle-electron-main.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node +// bundle-electron-main.mjs — bundles electron/main.ts and electron/preload.ts +// into self-contained js files in dist/ so the packaged app doesn't need +// node_modules/ or tsx at runtime. +// +// Output: +// dist/electron-main.mjs (MJS bundle — entry point for packaged app) +// dist/electron-preload.js (CJS bundle — loaded via BrowserWindow preload) +// +// `electron` and `node-pty` are external (provided by the runtime / staged +// separately via stage-native-deps). +import { build } from 'esbuild' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { mkdirSync } from 'node:fs' + +const here = dirname(fileURLToPath(import.meta.url)) +const root = resolve(here, '..') +const distDir = resolve(root, 'dist') +mkdirSync(distDir, { recursive: true }) + +const mainEntry = resolve(root, 'electron/main.ts') +const mainOut = resolve(distDir, 'electron-main.mjs') +const preloadEntry = resolve(root, 'electron/preload.ts') +const preloadOut = resolve(distDir, 'electron-preload.js') + +const external = ['electron', 'node-pty', 'fs'] + const define = { + 'process.env.HERMES_DESKTOP_IS_PACKAGED': JSON.stringify(true) + } +// Bundle main.ts → dist/electron-main.mjs +await build({ + entryPoints: [mainEntry], + bundle: true, + platform: 'node', + format: 'esm', + target: 'node20', + outfile: mainOut, + external, + banner: { + js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);", + }, + define, + logLevel: 'info', +}) +console.log(`bundled ${mainOut}`) + +// Bundle preload.ts → dist/electron-preload.cjs +await build({ + entryPoints: [preloadEntry], + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node20', + outfile: preloadOut, + external, + define, + logLevel: 'info', +}) +console.log(`bundled ${preloadOut}`) diff --git a/apps/desktop/scripts/notarize-artifact.cjs b/apps/desktop/scripts/notarize-artifact.mjs similarity index 83% rename from apps/desktop/scripts/notarize-artifact.cjs rename to apps/desktop/scripts/notarize-artifact.mjs index 89a4901c5cc..e7ea2f024ff 100644 --- a/apps/desktop/scripts/notarize-artifact.cjs +++ b/apps/desktop/scripts/notarize-artifact.mjs @@ -1,7 +1,7 @@ -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const { execFile } = require('node:child_process') +import { existsSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { execFile } from 'node:child_process' function run(command, args) { return new Promise((resolve, reject) => { @@ -26,7 +26,7 @@ function resolveApiKeyPath(rawValue) { const value = String(rawValue || '').trim() if (!value) return { keyPath: '', cleanup: () => {} } - if (fs.existsSync(value)) { + if (existsSync(value)) { return { keyPath: value, cleanup: () => {} } } @@ -34,17 +34,17 @@ function resolveApiKeyPath(rawValue) { throw new Error('APPLE_API_KEY must be a file path or inline .p8 key content') } - const tempPath = path.join(os.tmpdir(), `hermes-notary-${Date.now()}-${process.pid}.p8`) - fs.writeFileSync(tempPath, value, 'utf8') + const tempPath = join(tmpdir(), `hermes-notary-${Date.now()}-${process.pid}.p8`) + writeFileSync(tempPath, value, 'utf8') return { keyPath: tempPath, - cleanup: () => fs.rmSync(tempPath, { force: true }) + cleanup: () => rmSync(tempPath, { force: true }) } } async function main() { const artifactPath = process.argv[2] - if (!artifactPath || !fs.existsSync(artifactPath)) { + if (!artifactPath || !existsSync(artifactPath)) { throw new Error(`Missing artifact to notarize: ${artifactPath || '(none)'}`) } diff --git a/apps/desktop/scripts/notarize.cjs b/apps/desktop/scripts/notarize.mjs similarity index 93% rename from apps/desktop/scripts/notarize.cjs rename to apps/desktop/scripts/notarize.mjs index 1508e18e803..49294469e55 100644 --- a/apps/desktop/scripts/notarize.cjs +++ b/apps/desktop/scripts/notarize.mjs @@ -1,7 +1,7 @@ -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const { execFile } = require('node:child_process') +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { execFile } from 'node:child_process' function run(command, args) { return new Promise((resolve, reject) => { @@ -49,7 +49,7 @@ function resolveApiKeyPath(rawValue) { } } -exports.default = async function notarize(context) { +export default async function notarize(context) { const { electronPlatformName, appOutDir, packager } = context if (electronPlatformName !== 'darwin') return diff --git a/apps/desktop/scripts/patch-electron-builder-mac-binary.cjs b/apps/desktop/scripts/patch-electron-builder-mac-binary.mjs similarity index 96% rename from apps/desktop/scripts/patch-electron-builder-mac-binary.cjs rename to apps/desktop/scripts/patch-electron-builder-mac-binary.mjs index b88c281219f..6cfa41f32ec 100644 --- a/apps/desktop/scripts/patch-electron-builder-mac-binary.cjs +++ b/apps/desktop/scripts/patch-electron-builder-mac-binary.mjs @@ -1,11 +1,11 @@ -const fs = require('node:fs') -const path = require('node:path') +import fs from 'node:fs' +import path from 'node:path' if (process.platform !== 'darwin') { process.exit(0) } -const desktopRoot = path.resolve(__dirname, '..') +const desktopRoot = path.resolve(import.meta.dirname, '..') const repoRoot = path.resolve(desktopRoot, '..', '..') const electronMacPath = path.join(repoRoot, 'node_modules', 'app-builder-lib', 'out', 'electron', 'electronMac.js') diff --git a/apps/desktop/scripts/rebuild-native.mjs b/apps/desktop/scripts/rebuild-native.mjs new file mode 100644 index 00000000000..ddec5ea318e --- /dev/null +++ b/apps/desktop/scripts/rebuild-native.mjs @@ -0,0 +1,22 @@ +// rebuild-native.mjs +import { rebuild } from '@electron/rebuild' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { isMain } from './utils.mjs' +import packageJson from '../package.json' with { type: 'json' } +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') + +export async function rebuildNodePty({ arch = process.arch } = {}) { + await rebuild({ + buildPath: projectRoot, // where node_modules lives + electronVersion: packageJson.devDependencies.electron.replace('^', ''), + arch, + onlyModules: ['node-pty'], + force: true + }) +} + +if (isMain(import.meta.url)) { + const [arch] = process.argv.slice(2) + await rebuildNodePty({ arch }) +} diff --git a/apps/desktop/scripts/run-electron-builder.cjs b/apps/desktop/scripts/run-electron-builder.mjs similarity index 89% rename from apps/desktop/scripts/run-electron-builder.cjs rename to apps/desktop/scripts/run-electron-builder.mjs index 100d6c346e9..38e465612f9 100644 --- a/apps/desktop/scripts/run-electron-builder.cjs +++ b/apps/desktop/scripts/run-electron-builder.mjs @@ -1,14 +1,15 @@ -"use strict" - // Resolve electronDist at runtime (#38673, #47917): electron-builder 26.8.x can // re-unpack a broken Electron.app; reusing the installed dist dodges that. // npm workspace hoisting is non-deterministic — require.resolve finds electron // wherever it landed. Dist present → -c.electronDist=<abs>/dist; absent → let // electron-builder fetch via @electron/get (electronVersion + ELECTRON_MIRROR). -const fs = require("node:fs") -const path = require("node:path") -const { spawnSync } = require("node:child_process") +import fs from "node:fs" +import path from "node:path" +import { spawnSync } from "node:child_process" +import { createRequire } from "node:module" + +const require = createRequire(import.meta.url) function electronDistDir() { try { diff --git a/apps/desktop/scripts/set-exe-identity.cjs b/apps/desktop/scripts/set-exe-identity.mjs similarity index 70% rename from apps/desktop/scripts/set-exe-identity.cjs rename to apps/desktop/scripts/set-exe-identity.mjs index 129e1505bda..4e19999c754 100644 --- a/apps/desktop/scripts/set-exe-identity.cjs +++ b/apps/desktop/scripts/set-exe-identity.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// set-exe-identity.cjs — stamp the Hermes icon + version metadata onto the +// set-exe-identity.mjs — stamp the Hermes icon + version metadata onto the // built Hermes.exe using rcedit, completely decoupled from electron-builder's // signing path. // @@ -20,7 +20,7 @@ // // HOW IT RUNS // ----------- -// Primarily as an electron-builder `afterPack` hook (scripts/after-pack.cjs), +// Primarily as an electron-builder `afterPack` hook (scripts/after-pack.mjs), // so EVERY packed build — first install, `hermes desktop`, the installer's // --update rebuild, or a dev's manual `npm run pack` — gets a branded exe from // one place. Previously this stamp lived only in install.ps1, so the update @@ -28,40 +28,34 @@ // shipped a stock "Electron" exe. Keeping it in afterPack closes that gap. // // Also runnable standalone for ad-hoc re-stamping: -// node scripts/set-exe-identity.cjs <path-to-Hermes.exe> +// node scripts/set-exe-identity.mjs <path-to-Hermes.exe> // // Exits 0 on success, non-zero on failure when run as a CLI. As a hook, // stampExeIdentity() resolves on success and rejects on failure; the caller -// (after-pack.cjs) swallows the rejection so a stamp failure never fails an +// (after-pack.mjs) swallows the rejection so a stamp failure never fails an // otherwise-good build (worst case: stock icon, not a broken app). -const path = require('node:path') -const fs = require('node:fs') +import { resolve, join } from 'node:path' +import { existsSync } from 'node:fs' + +import { rcedit } from 'rcedit' + +import { isMain } from './utils.mjs' // Stamp the Hermes icon + identity onto `exe`. Resolves on success, throws on // failure. `desktopRoot` defaults to this script's package root so the icon and // the rcedit dependency resolve regardless of cwd. -async function stampExeIdentity(exe, desktopRoot = path.resolve(__dirname, '..')) { - if (!exe || !fs.existsSync(exe)) { +async function stampExeIdentity(exe, desktopRoot = resolve(import.meta.dirname, '..')) { + if (!exe || !existsSync(exe)) { throw new Error(`target exe not found: ${exe}`) } // Icon lives at apps/desktop/assets/icon.ico - const icon = path.join(desktopRoot, 'assets', 'icon.ico') - if (!fs.existsSync(icon)) { + const icon = join(desktopRoot, 'assets', 'icon.ico') + if (!existsSync(icon)) { throw new Error(`icon not found: ${icon}`) } - // rcedit is a direct devDependency of apps/desktop, so it resolves whether - // we're run from the desktop dir or the repo root (workspace hoist). - // rcedit@5 exports a NAMED `rcedit` function (CommonJS: { rcedit }), not a - // default export. - const mod = require('rcedit') - const rcedit = typeof mod === 'function' ? mod : mod.rcedit - if (typeof rcedit !== 'function') { - throw new Error(`unexpected rcedit export shape: ${typeof mod} keys=${Object.keys(mod)}`) - } - console.log(`[set-exe-identity] stamping ${exe}`) console.log(`[set-exe-identity] icon: ${icon}`) @@ -78,13 +72,13 @@ async function stampExeIdentity(exe, desktopRoot = path.resolve(__dirname, '..') console.log('[set-exe-identity] done — Hermes icon + identity stamped') } -module.exports = { stampExeIdentity } +export { stampExeIdentity } -// CLI entry point: `node scripts/set-exe-identity.cjs <exe>`. -if (require.main === module) { +// CLI entry point: `node scripts/set-exe-identity.mjs <exe>`. +if (isMain(import.meta.url)) { const exe = process.argv[2] if (!exe) { - console.error('[set-exe-identity] usage: set-exe-identity.cjs <path-to-exe>') + console.error('[set-exe-identity] usage: set-exe-identity.mjs <path-to-exe>') process.exit(2) } stampExeIdentity(exe).catch(err => { diff --git a/apps/desktop/scripts/stage-native-deps.cjs b/apps/desktop/scripts/stage-native-deps.cjs deleted file mode 100644 index ef68368dee7..00000000000 --- a/apps/desktop/scripts/stage-native-deps.cjs +++ /dev/null @@ -1,283 +0,0 @@ -'use strict' - -/** - * Stage native node-modules dependencies for electron-builder packaging. - * - * Workspace dedup hoists `node-pty` into the root `node_modules/`, which - * electron-builder's default file collector (when `files:` is explicitly set - * in package.json) cannot reach. The result: packaged builds ship with no - * .node binaries and PTY initialization fails at runtime ("PTY support is - * unavailable"). - * - * Rather than restructure the workspace dedup (would require nohoist / - * package.json shenanigans and risk breaking dev) or balloon the package - * with the whole node_modules tree, we copy ONLY the runtime-essential - * files of the native dep into apps/desktop/build/native-deps/ and ship - * THAT subtree via extraResources. main.cjs falls back to require()-ing - * from process.resourcesPath when the hoisted-root require fails. - * - * Runs as part of `npm run build`. Idempotent -- always re-stages on each - * build to pick up native binary updates. - * - * Layout note: upstream node-pty (microsoft/node-pty 1.x) is N-API based - * and ships its prebuilts under `prebuilds/<platform>-<arch>/` instead of - * `build/Release/`. Its runtime resolver (lib/utils.js) checks - * build/Release first and falls through to the per-arch prebuilds dir, so - * shipping only the latter is sufficient for packaged runs. Per-arch - * staging keeps the resource bundle lean -- we only need the target - * arch's prebuilt, not all of them. - */ - -const fs = require('node:fs') -const path = require('node:path') - -const APP_ROOT = path.resolve(__dirname, '..') -const REPO_ROOT = path.resolve(APP_ROOT, '..', '..') -const STAGE_ROOT = path.join(APP_ROOT, 'build', 'native-deps') - -// The target arch may be overridden by electron-builder via npm_config_arch -// (e.g. `npm run dist -- --arm64`); fall back to the build host's arch. -const TARGET_ARCH = process.env.npm_config_arch || process.arch -const TARGET_PLATFORM = process.platform - -// Modules to stage. The "from" path is the hoisted location in the workspace -// root; "to" is the layout we want inside build/native-deps/. The "include" -// globs (relative to "from") select the runtime-essential files. Anything -// outside the include list is left behind (source, deps/, scripts/, etc.). -const NATIVE_DEPS = [ - { - from: path.join(REPO_ROOT, 'node_modules', 'node-pty'), - to: path.join(STAGE_ROOT, 'node-pty'), - include: [ - 'package.json', - 'lib/*.js', - 'lib/**/*.js', - 'build/Release/*.node', - // Per-arch runtime payload. Explicit file types so we don't ship the - // ~25 MB of .pdb debug symbols that prebuild-install bundles for - // Windows crash analysis -- not used at runtime, would just bloat - // the installer. - `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/*.node`, - `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/*.dll`, - `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/*.exe`, - `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/spawn-helper`, - `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/conpty/*` - ] - } -] - -// Pure-JS runtime dependencies that the packaged electron main require()s but -// that workspace dedup hoists into the repo-root node_modules -- out of reach -// of electron-builder's file collector, exactly like node-pty above. Unlike -// node-pty there is no native binary to select; we stage each package's whole -// directory into build/native-deps/vendor/node_modules/<name> so the dep's own -// internal require()s resolve against a real node_modules tree, and the -// requiring file (electron/git-review-ops.cjs) falls back to that path via -// process.resourcesPath when the normal require() fails. See issue #52735 -// (packaged app crashed at launch on `Cannot find module 'simple-git'`). -// -// The closure is resolved at stage time by walking dependencies + -// optionalDependencies, so a simple-git version bump that pulls in a new -// transitive dep can't silently re-introduce the crash. -// -// Layout note: the closure lands in build/native-deps/vendor/node_modules/, -// NOT build/native-deps/node_modules/. electron-builder's file collector -// hard-drops a `node_modules` directory that sits at the ROOT of an -// extraResources copy (app-builder-lib/out/util/filter.js: `if (relative === -// "node_modules") return false`), but keeps a NESTED one. Nesting under -// `vendor/` makes node_modules a subdirectory so it survives packing; the -// require() fallback in git-review-ops.cjs resolves the matching -// vendor/node_modules path. -const JS_DEP_ROOTS = ['simple-git'] -const JS_DEP_STAGE_ROOT = path.join(STAGE_ROOT, 'vendor', 'node_modules') - -function rmrf(target) { - fs.rmSync(target, { recursive: true, force: true }) -} - -function ensureDir(target) { - fs.mkdirSync(target, { recursive: true }) -} - -function walk(root) { - const results = [] - const stack = [root] - while (stack.length) { - const current = stack.pop() - let entries - try { - entries = fs.readdirSync(current, { withFileTypes: true }) - } catch { - continue - } - for (const entry of entries) { - const full = path.join(current, entry.name) - if (entry.isDirectory()) { - stack.push(full) - } else if (entry.isFile()) { - results.push(full) - } - } - } - return results -} - -// Match a relative path against simple ** and * glob patterns. Implementation -// is intentionally tiny -- the include lists are small and don't need full -// minimatch support. -function matchGlob(rel, pattern) { - const r = rel.replace(/\\/g, '/') - const re = new RegExp( - '^' + - pattern - .replace(/\\/g, '/') - .replace(/[.+^${}()|[\]\\]/g, '\\$&') - .replace(/\*\*/g, '__DOUBLE_STAR__') - .replace(/\*/g, '[^/]*') - .replace(/__DOUBLE_STAR__/g, '.*') + - '$' - ) - return re.test(r) -} - -function stageOne(spec) { - if (!fs.existsSync(spec.from)) { - throw new Error( - `stage-native-deps: source missing at ${spec.from}. Run \`npm install\` ` + - `at the workspace root first.` - ) - } - rmrf(spec.to) - ensureDir(spec.to) - - const files = walk(spec.from) - let copied = 0 - for (const abs of files) { - const rel = path.relative(spec.from, abs) - const included = spec.include.some(g => matchGlob(rel, g)) - if (!included) continue - const dest = path.join(spec.to, rel) - ensureDir(path.dirname(dest)) - fs.copyFileSync(abs, dest) - // node-pty's darwin spawn-helper and the Windows helper binaries - // (OpenConsole.exe, winpty-agent.exe) are invoked via posix_spawn / - // CreateProcess at runtime, so they must remain executable in the - // staged tree. fs.copyFileSync preserves source mode on POSIX, but we - // re-assert +x defensively for the darwin spawn-helper (no extension - // means a stripped mode would be silently broken at runtime). - if (path.basename(rel) === 'spawn-helper' && process.platform !== 'win32') { - try { fs.chmodSync(dest, 0o755) } catch { /* best-effort */ } - } - copied += 1 - } - console.log(`[stage-native-deps] ${path.relative(APP_ROOT, spec.to)}: ${copied} files`) -} - -// Resolve a package's directory by name, searching the repo-root node_modules -// first (where workspace dedup hoists everything) and then the requiring -// package's own node_modules for any non-hoisted nested copy. -// -// We deliberately do NOT use require.resolve(`${name}/package.json`): packages -// with an "exports" map that doesn't list "./package.json" (e.g. simple-git -// 3.x) make that subpath unresolvable under Node's exports enforcement -// (ERR_PACKAGE_PATH_NOT_EXPORTED), which fails on CI even though it happened to -// work locally. Instead resolve the package's main entry (exports-aware) and -// walk up to the directory whose package.json's "name" matches. -function resolvePkgDir(name, fromDir) { - const searchPaths = [fromDir, REPO_ROOT, path.join(REPO_ROOT, 'node_modules')] - let entry - try { - entry = require.resolve(name, { paths: searchPaths }) - } catch { - return null - } - // Walk up from the resolved entry file to the package root: the first - // ancestor dir whose package.json declares this package's name. - let dir = path.dirname(entry) - while (true) { - const pjPath = path.join(dir, 'package.json') - try { - const pj = JSON.parse(fs.readFileSync(pjPath, 'utf8')) - if (pj.name === name) { - return dir - } - } catch { - // no package.json here (or unreadable) — keep walking up - } - const parent = path.dirname(dir) - if (parent === dir) { - return null - } - dir = parent - } -} - -// Walk dependencies + optionalDependencies from each root package and return -// the set of resolved package directories in the runtime closure. Keyed by -// package name so a dep reached via two paths is staged once. -function resolveJsClosure(roots) { - const closure = new Map() // name -> absolute package dir - const stack = roots.map(name => ({ name, fromDir: REPO_ROOT })) - while (stack.length) { - const { name, fromDir } = stack.pop() - if (closure.has(name)) continue - const dir = resolvePkgDir(name, fromDir) - if (!dir) { - throw new Error( - `stage-native-deps: could not resolve '${name}' for the simple-git ` + - `closure. Run \`npm install\` at the workspace root first.` - ) - } - closure.set(name, dir) - let pj - try { - pj = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')) - } catch { - continue - } - const deps = { ...(pj.dependencies || {}), ...(pj.optionalDependencies || {}) } - for (const depName of Object.keys(deps)) { - stack.push({ name: depName, fromDir: dir }) - } - } - return closure -} - -// Stage the resolved JS dependency closure into build/native-deps/vendor/node_modules/ -// so the packaged app (and the nix output) can require() it from -// process.resourcesPath when the hoisted-root require() isn't reachable. Each -// package is copied whole (minus node_modules/ — the closure is flattened so -// every dep already has its own top-level entry) into a real node_modules -// layout, which keeps the deps' own internal require()s working unchanged. -function stageJsClosure(roots) { - const closure = resolveJsClosure(roots) - rmrf(JS_DEP_STAGE_ROOT) - ensureDir(JS_DEP_STAGE_ROOT) - let staged = 0 - for (const [name, fromDir] of closure) { - const dest = path.join(JS_DEP_STAGE_ROOT, name) - ensureDir(path.dirname(dest)) - // Copy the package directory but skip any nested node_modules/ — the - // closure is flattened, so nested copies would just bloat the bundle. - fs.cpSync(fromDir, dest, { - recursive: true, - filter: src => path.basename(src) !== 'node_modules' - }) - staged += 1 - } - console.log( - `[stage-native-deps] vendor/node_modules/: ${staged} package(s) ` + - `(${[...closure.keys()].sort().join(', ')})` - ) -} - -function main() { - rmrf(STAGE_ROOT) - ensureDir(STAGE_ROOT) - for (const spec of NATIVE_DEPS) { - stageOne(spec) - } - stageJsClosure(JS_DEP_ROOTS) -} - -main() diff --git a/apps/desktop/scripts/stage-native-deps.mjs b/apps/desktop/scripts/stage-native-deps.mjs new file mode 100644 index 00000000000..ef590c6ecbf --- /dev/null +++ b/apps/desktop/scripts/stage-native-deps.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +// stage-native-deps.mjs — stages node-pty's native runtime dependencies +// +// Usage: +// node scripts/stage-native-deps.mjs # host platform/arch +// node scripts/stage-native-deps.mjs win32 arm64 # explicit target +// +// Also exported as `stageNodePty({ platform, arch })` for use from +// before-pack.mjs, where electron-builder gives you the real per-target +// platform/arch during multi-arch builds. + +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { dirname, resolve, join } from 'node:path' +import { + cpSync, + existsSync, + mkdirSync, + readdirSync, + rmSync +} from 'node:fs' +import { isMain } from './utils.mjs' + +const here = dirname(fileURLToPath(import.meta.url)) +const projectRoot = resolve(here, '..') +const require = createRequire(import.meta.url) + +/** + * Locate node-pty's package root via real module resolution, so this + * works whether it's hoisted to a workspace root or local to this app. + */ +function resolveNodePtyRoot() { + const pkgJsonPath = require.resolve('node-pty/package.json', { + paths: [projectRoot] + }) + return dirname(pkgJsonPath) +} + +function copyGlobByExt(srcDir, destDir, extensions) { + if (!existsSync(srcDir)) return + mkdirSync(destDir, { recursive: true }) + for (const entry of readdirSync(srcDir, { withFileTypes: true })) { + if (entry.isDirectory()) { + copyGlobByExt(join(srcDir, entry.name), join(destDir, entry.name), extensions) + continue + } + if (extensions.some((ext) => entry.name.endsWith(ext))) { + mkdirSync(destDir, { recursive: true }) + cpSync(join(srcDir, entry.name), join(destDir, entry.name)) + } + } +} + +/** + * Copies the locally-compiled build/Release output (used when no prebuild + * was available and node-pty was built from source for the host machine). + * + * Filters by name/pattern rather than extension only: macOS builds a + * separate `spawn-helper` executable (no file extension) that + * lib/unixTerminal.js requires at a fixed relative path. Filtering this + * directory by ['.node'] silently drops it — the package then looks + * fine, ships fine, and crashes the first time a terminal is spawned. + * Directories are copied wholesale to also cover any nested native + * payload (e.g. a conpty/ subfolder some build layouts produce). + */ +function copyBuildRelease(srcDir, destDir) { + if (!existsSync(srcDir)) return + mkdirSync(destDir, { recursive: true }) + for (const entry of readdirSync(srcDir, { withFileTypes: true })) { + if (entry.isDirectory()) { + cpSync(join(srcDir, entry.name), join(destDir, entry.name), { recursive: true }) + continue + } + if (entry.name === 'spawn-helper' || /\.(node|dll|exe)$/.test(entry.name)) { + cpSync(join(srcDir, entry.name), join(destDir, entry.name)) + } + } +} + +export function stageNodePty({ platform = process.platform, arch = process.arch } = {}) { + const srcRoot = resolveNodePtyRoot() + const destRoot = resolve(projectRoot, 'dist/node_modules/node-pty') + + rmSync(destRoot, { recursive: true, force: true }) + mkdirSync(destRoot, { recursive: true }) + + // package.json — needed so `require('node-pty')` resolves the package + // (reads "main") rather than treating it as a directory with no entry. + cpSync(join(srcRoot, 'package.json'), join(destRoot, 'package.json')) + + // lib/**/*.js — the JS surface node-pty's `main` points into. + copyGlobByExt(join(srcRoot, 'lib'), join(destRoot, 'lib'), ['.js']) + + // build/Release/* — present when node-pty was compiled locally + // (e.g. no prebuild available for this Electron ABI/platform combo). + // Some installs won't have this at all if prebuild-install succeeded. + copyBuildRelease(join(srcRoot, 'build/Release'), join(destRoot, 'build/Release')) + + // prebuilds/<platform>-<arch>/* — the prebuild-install payload for the + // *target* we're packaging, not necessarily the host running this script. + // Explicit extensions only, to skip the ~25MB of Windows .pdb symbols + // prebuild-install bundles alongside the .node/.dll. + const prebuildDir = join(srcRoot, 'prebuilds', `${platform}-${arch}`) + if (existsSync(prebuildDir)) { + const destPrebuild = join(destRoot, 'prebuilds', `${platform}-${arch}`) + mkdirSync(destPrebuild, { recursive: true }) + for (const entry of readdirSync(prebuildDir, { withFileTypes: true })) { + if (entry.name === 'conpty' && entry.isDirectory()) { + cpSync(join(prebuildDir, 'conpty'), join(destPrebuild, 'conpty'), { recursive: true }) + continue + } + if (entry.isFile() && /\.(node|dll|exe)$/.test(entry.name)) { + cpSync(join(prebuildDir, entry.name), join(destPrebuild, entry.name)) + continue + } + if (entry.name === 'spawn-helper') { + cpSync(join(prebuildDir, entry.name), join(destPrebuild, entry.name)) + } + } + } else { + console.warn( + `[stage-native-deps] no prebuild found at prebuilds/${platform}-${arch} for node-pty. ` + + `If build/Release/* above is also empty, this target will fail at runtime. ` + + `Run "npx electron-rebuild -w node-pty" for this target, or check that ` + + `node-pty's published prebuilds cover ${platform}-${arch}.` + ) + } + + console.log(`[stage-native-deps] staged node-pty (${platform}-${arch}) -> ${destRoot}`) + return destRoot +} + +// Allow direct CLI invocation: node scripts/stage-native-deps.mjs [platform] [arch] +if (isMain(import.meta.url)) { + const [platform, arch] = process.argv.slice(2) + stageNodePty({ platform, arch }) +} diff --git a/apps/desktop/scripts/test-desktop.mjs b/apps/desktop/scripts/test-desktop.mjs index fdff1523f8f..bbda8ec5ee4 100644 --- a/apps/desktop/scripts/test-desktop.mjs +++ b/apps/desktop/scripts/test-desktop.mjs @@ -5,10 +5,11 @@ import { spawn, spawnSync } from 'node:child_process' import { fileURLToPath } from 'node:url' import { listPackage } from '@electron/asar' -const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') -const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(DESKTOP_ROOT, 'package.json'), 'utf8')) +import PACKAGE_JSON from '../package.json' with { type: 'json' } + const MODE = process.argv[2] || 'help' const ARCH = process.arch === 'arm64' ? 'arm64' : 'x64' +const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release') const PLATFORM = process.platform @@ -48,7 +49,7 @@ const APP = (() => { } })() -// Default HERMES_HOME for non-sandboxed runs -- matches main.cjs's +// Default HERMES_HOME for non-sandboxed runs -- matches main.ts's // resolveHermesHome(). On Windows it's %LOCALAPPDATA%\hermes; elsewhere // it's ~/.hermes. The fresh-install sandbox launchFresh() sets its own // HERMES_HOME and never touches this. @@ -83,17 +84,23 @@ function exists(target) { return fs.existsSync(target) } -// Match nodepty native binding location to what main.cjs's resolver fallback -// expects (apps/desktop/electron/main.cjs, packaged-build branch). Upstream -// node-pty 1.x is N-API based and ships per-arch prebuilts under -// prebuilds/<platform>-<arch>/ instead of build/Release/. We check the -// per-arch dir since that's what stage-native-deps actually copies. +// Match node-pty native binding location to what the bundled electron-main.cjs +// resolves at runtime. stage-native-deps.mjs stages node-pty into +// dist/node_modules/node-pty, and dist/** is asarUnpacked (see package.json +// build.asarUnpack), so in a packaged build it lands under +// resources/app.asar.unpacked/dist/node_modules/node-pty — reachable by a bare +// require('node-pty') from the bundle. Upstream node-pty 1.x is N-API based and +// ships per-arch prebuilts under prebuilds/<platform>-<arch>/; nix/local builds +// instead compile from source into build/Release/. The stage script copies +// whichever is present, so we accept either as the native payload. function expectedNativeDepPaths() { - const root = path.join(APP.resourcesPath, 'native-deps', 'node-pty') + const root = path.join(APP.resourcesPath, 'app.asar.unpacked', 'dist', 'node_modules', 'node-pty') const prebuildsDir = path.join(root, 'prebuilds', `${PLATFORM}-${ARCH}`) + const buildReleaseDir = path.join(root, 'build', 'Release') return { packageJson: path.join(root, 'package.json'), prebuildsDir, + buildReleaseDir, libIndex: path.join(root, 'lib', 'index.js') } } @@ -279,8 +286,8 @@ function launchFresh() { // - The Hermes Agent Python payload is NOT shipped (it's fetched at first // launch via install.ps1's stage protocol). // - install-stamp.json IS shipped in resources/ with a valid commit + branch. -// - native-deps/@homebridge/node-pty-prebuilt-multiarch/ IS shipped with -// the package.json + lib/ + at least one .node binary (the renderer's +// - node-pty IS shipped inside app.asar.unpacked/dist/node_modules/node-pty +// with package.json + lib/ + at least one .node binary (the renderer's // integrated terminal needs this; see Phase 1F.6). // - The renderer's dist/index.html is reachable (either unpacked or // inside app.asar). @@ -320,24 +327,35 @@ function validateBundle() { // Positive assertion: node-pty native deps shipped const native = expectedNativeDepPaths() if (!exists(native.packageJson)) { - die(`Missing node-pty package.json in resources/native-deps: ${native.packageJson}`) + die(`Missing node-pty package.json in app.asar.unpacked: ${native.packageJson}`) } if (!exists(native.libIndex)) { - die(`Missing node-pty lib/index.js in resources/native-deps: ${native.libIndex}`) + die(`Missing node-pty lib/index.js in app.asar.unpacked: ${native.libIndex}`) } - if (!exists(native.prebuildsDir)) { - die(`Missing node-pty prebuilds dir for ${PLATFORM}-${ARCH}: ${native.prebuildsDir}`) + // The native binary lands in prebuilds/<platform>-<arch>/ (downloaded prebuild) + // OR build/Release/ (compiled from source). stage-native-deps.mjs copies + // whichever is present, so accept either. + const nativeBinaryDirs = [native.prebuildsDir, native.buildReleaseDir].filter(exists) + if (nativeBinaryDirs.length === 0) { + die( + `Missing node-pty native binary dir for ${PLATFORM}-${ARCH}: neither ` + + `${native.prebuildsDir} nor ${native.buildReleaseDir} exists` + ) } - const nodeBinaries = fs.readdirSync(native.prebuildsDir).filter(name => name.endsWith('.node')) + const nodeBinaries = nativeBinaryDirs.flatMap(dir => + fs.readdirSync(dir).filter(name => name.endsWith('.node')) + ) if (nodeBinaries.length === 0) { - die(`No .node native binaries found in: ${native.prebuildsDir}`) + die(`No .node native binaries found in: ${nativeBinaryDirs.join(', ')}`) } // Darwin requires a runtime-execed spawn-helper alongside pty.node; missing // it manifests as "ENOENT: spawn-helper" on first pty.spawn() call. if (PLATFORM === 'darwin') { - const spawnHelper = path.join(native.prebuildsDir, 'spawn-helper') - if (!exists(spawnHelper)) { - die(`Missing node-pty spawn-helper (required on darwin): ${spawnHelper}`) + const spawnHelper = nativeBinaryDirs + .map(dir => path.join(dir, 'spawn-helper')) + .find(exists) + if (!spawnHelper) { + die(`Missing node-pty spawn-helper (required on darwin) in: ${nativeBinaryDirs.join(', ')}`) } } diff --git a/apps/desktop/scripts/utils.mjs b/apps/desktop/scripts/utils.mjs new file mode 100644 index 00000000000..7010213ec3c --- /dev/null +++ b/apps/desktop/scripts/utils.mjs @@ -0,0 +1,8 @@ + +import { pathToFileURL } from 'node:url'; + +// returns true if the passsed file is being invoked from node, +// not imported. +export function isMain(importMetaUrl) { + return importMetaUrl === pathToFileURL(process.argv[1]).href; +} \ No newline at end of file diff --git a/apps/desktop/scripts/write-build-stamp.cjs b/apps/desktop/scripts/write-build-stamp.mjs similarity index 86% rename from apps/desktop/scripts/write-build-stamp.cjs rename to apps/desktop/scripts/write-build-stamp.mjs index 72b978c5f9a..694bd9e8dd2 100644 --- a/apps/desktop/scripts/write-build-stamp.cjs +++ b/apps/desktop/scripts/write-build-stamp.mjs @@ -4,7 +4,7 @@ * Writes apps/desktop/build/install-stamp.json with the git ref the desktop * .exe should pin to at first-launch bootstrap time. This file ships inside * the packaged app via electron-builder's extraResources entry and is read - * by electron/main.cjs to drive the install.ps1 stage bootstrap flow. + * by electron/main.ts to drive the install.ps1 stage bootstrap flow. * * Schema (subject to bump via STAMP_SCHEMA_VERSION): * { @@ -26,16 +26,16 @@ * bootstrap without a stamp. */ -const fs = require("fs") -const path = require("path") -const { execSync } = require("child_process") +import { mkdirSync, writeFileSync } from "fs" +import { resolve, join, relative } from "path" +import { execSync } from "child_process" const STAMP_SCHEMA_VERSION = 1 -const DESKTOP_ROOT = path.resolve(__dirname, "..") -const REPO_ROOT = path.resolve(DESKTOP_ROOT, "..", "..") -const OUT_DIR = path.join(DESKTOP_ROOT, "build") -const OUT_FILE = path.join(OUT_DIR, "install-stamp.json") +const DESKTOP_ROOT = resolve(import.meta.dirname, "..") +const REPO_ROOT = resolve(DESKTOP_ROOT, "..", "..") +const OUT_DIR = join(DESKTOP_ROOT, "build") +const OUT_FILE = join(OUT_DIR, "install-stamp.json") function tryExec(cmd, opts) { try { @@ -111,11 +111,11 @@ function main() { source: stamp.source } - fs.mkdirSync(OUT_DIR, { recursive: true }) - fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8") + mkdirSync(OUT_DIR, { recursive: true }) + writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8") console.log( "[write-build-stamp] wrote " + - path.relative(REPO_ROOT, OUT_FILE) + + relative(REPO_ROOT, OUT_FILE) + " -> " + stamp.commit.slice(0, 12) + (stamp.branch ? " (" + stamp.branch + ")" : "") + diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 4b8c249bced..cb7b10f655b 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -847,7 +847,7 @@ export function DesktopController() { // window's gateway (the overlay has none) so it survives restart. setPetOverlayScaleHandler(scale => setPetScale(requestGatewayRef.current, scale)) // Mail icon: $sessions is ordered most-recent-first; the pet is global (not - // per session) so "most recent" is the right target. main.cjs already raised + // per session) so "most recent" is the right target. main.ts already raised // the window before forwarding this. setPetOverlayOpenAppHandler(() => { const recent = $sessions.get()[0] diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index 5ce2ad8aa56..045a4e7975d 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -1422,7 +1422,7 @@ describe('uploadComposerAttachment remote read failures', () => { }) it('turns the raw 16MB IPC cap error into a friendly remote-gateway message', async () => { - // electron/hardening.cjs rejects the readFileDataUrl IPC with this exact + // electron/hardening.ts rejects the readFileDataUrl IPC with this exact // shape when a file exceeds DATA_URL_READ_MAX_BYTES. Object.defineProperty(window, 'hermesDesktop', { configurable: true, diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index 76a25885854..6b2049f892b 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -21,9 +21,9 @@ import { _submitInFlight, type GatewayRequest, inlineErrorMessage, + isGatewayTimeoutError, isProviderSetupError, isSessionBusyError, - isGatewayTimeoutError, isSessionNotFoundError, type SubmitTextOptions, withSessionBusyRetry diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts index 1df123824d1..de501a52bbc 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts @@ -142,7 +142,7 @@ export async function readFileDataUrlForAttach(filePath: string): Promise<string } // The readFileDataUrl IPC base64-loads the whole file into memory and is -// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.cjs, which +// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.ts, which // rejects with a raw "file is too large (N bytes; limit M bytes)" string. In // remote mode every attachment's bytes go through that read, so a big file // surfaces that internal message verbatim in the failure toast. Translate it diff --git a/apps/desktop/src/components/desktop-install-overlay.tsx b/apps/desktop/src/components/desktop-install-overlay.tsx index 7bcbc4bf84b..f15bd79cdc2 100644 --- a/apps/desktop/src/components/desktop-install-overlay.tsx +++ b/apps/desktop/src/components/desktop-install-overlay.tsx @@ -22,7 +22,7 @@ import { cn } from '@/lib/utils' * DesktopInstallOverlay * * Renders the first-launch install progress for Hermes Agent. Mounted always; - * shows itself only when main.cjs reports an in-flight bootstrap (state.active) + * shows itself only when main.ts reports an in-flight bootstrap (state.active) * OR an error from a completed-failed bootstrap (state.error). When the * bootstrap finishes successfully the overlay fades out and the rest of the * app (existing onboarding overlay -> main UI) takes over. @@ -32,7 +32,7 @@ import { cn } from '@/lib/utils' * - onBootstrapEvent(callback) -- live event stream * * The reducer is intentionally simple: every event mutates an in-component - * snapshot the same way main.cjs mutates its server-side snapshot. We don't + * snapshot the same way main.ts mutates its server-side snapshot. We don't * try to reconcile -- if we miss an event (shouldn't happen) the initial * getBootstrapState() call will resync the picture on the next render. * @@ -559,7 +559,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP </Button> <Button onClick={async () => { - // Tell main.cjs to clear its latched failure BEFORE we + // Tell main.ts to clear its latched failure BEFORE we // reload. Otherwise the renderer reload calls getConnection // and main short-circuits to the latched error without // re-running install.ps1. diff --git a/apps/desktop/src/components/pet/floating-pet.tsx b/apps/desktop/src/components/pet/floating-pet.tsx index 5ead9838dc7..280aa5d4f4b 100644 --- a/apps/desktop/src/components/pet/floating-pet.tsx +++ b/apps/desktop/src/components/pet/floating-pet.tsx @@ -220,7 +220,7 @@ export function FloatingPet() { }) // Wire the overlay control channel once, only in the primary window — the - // pop-out overlay belongs to it (main.cjs positions it against the main + // pop-out overlay belongs to it (main.ts positions it against the main // window and routes control messages back to it). useEffect(() => { if (isSecondaryWindow()) { diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index f9c5e34f541..3c2e8fe8da2 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -459,7 +459,7 @@ export interface DesktopBootProgress { } // First-launch install ("bootstrap") event types -- emitted by -// electron/bootstrap-runner.cjs and observed by the renderer install overlay. +// electron/bootstrap-runner.ts and observed by the renderer install overlay. // Mirrors the event shapes emitted by runBootstrap()'s onEvent callback. export interface DesktopBootstrapStageDescriptor { diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 3bfd02e276f..a902e4b53e1 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -61,7 +61,7 @@ import type { // model info/options, cron) the moment the backend passes readiness. On a // profile-heavy or remote install these can each take tens of seconds — e.g. // /api/profiles runs list_profiles(), which does a recursive skill-tree walk -// per profile — so the 15s default (DEFAULT_FETCH_TIMEOUT_MS in hardening.cjs) +// per profile — so the 15s default (DEFAULT_FETCH_TIMEOUT_MS in hardening.ts) // times out a backend that is alive-but-busy, surfacing as a spurious // "Timed out connecting to Hermes backend" that hangs the UI (#48504). // diff --git a/apps/desktop/src/lib/media.ts b/apps/desktop/src/lib/media.ts index e8dfd35c7f6..ffaea2c7c2b 100644 --- a/apps/desktop/src/lib/media.ts +++ b/apps/desktop/src/lib/media.ts @@ -79,7 +79,7 @@ export function mediaExternalUrl(path: string): string { return /^file:/i.test(path) ? path : `file://${path}` } -// Custom Electron scheme (registered in electron/main.cjs) that streams a local +// Custom Electron scheme (registered in electron/main.ts) that streams a local // file with Range support. Used for audio/video so playback bypasses the data // URL size cap and supports seeking. `path` may be a plain path or `file://…`. export function mediaStreamUrl(path: string): string { diff --git a/apps/desktop/src/store/pet-overlay.ts b/apps/desktop/src/store/pet-overlay.ts index 1ab1b64ad23..cffa6b822b5 100644 --- a/apps/desktop/src/store/pet-overlay.ts +++ b/apps/desktop/src/store/pet-overlay.ts @@ -8,7 +8,7 @@ import { $awaitingResponse, $busy } from '@/store/session' * Controller for the pop-out pet overlay (main-renderer side). * * Shift-clicking the in-window pet "pops it out" into a transparent, - * always-on-top OS window (created in electron/main.cjs) that can leave the + * always-on-top OS window (created in electron/main.ts) that can leave the * app's bounds and stays visible while Hermes is minimized. That window carries * NO gateway connection — this renderer remains the single source of truth and * pushes the live pet state to it over IPC. Control flows back (pop the pet back @@ -30,7 +30,7 @@ export interface PetOverlayBounds { /** * Request to open the overlay window. `screen` says whether `bounds` are already * in absolute screen coordinates (a remembered/dragged spot) or in the main - * window's viewport space (a fresh shift-click pop-out, which main.cjs converts + * window's viewport space (a fresh shift-click pop-out, which main.ts converts * by adding the content origin). */ export interface PetOverlayOpenRequest { @@ -172,7 +172,7 @@ function openOverlay(request: PetOverlayOpenRequest): void { /** * Pop the pet out of the window. `petRect` is the in-window sprite's viewport * rect; we grow it to the padded overlay size and center the window on the - * pet's old spot (main.cjs adds the window's screen origin). If the user has + * pet's old spot (main.ts adds the window's screen origin). If the user has * popped out before, reopen at that remembered desktop spot instead. */ export function popOutPet(petRect: PetOverlayBounds): void { @@ -273,7 +273,7 @@ export function initPetOverlayBridge(): () => void { // overlay on the next push, keeping both surfaces in sync. scaleHandler?.(payload.scale) } else if (payload?.type === 'open-app') { - // Mail icon: surface the app on the most recent thread (main.cjs already + // Mail icon: surface the app on the most recent thread (main.ts already // focused the window before forwarding this) and mark it read. clearPetUnread() openAppHandler?.() diff --git a/apps/desktop/src/store/windows.ts b/apps/desktop/src/store/windows.ts index c5b36ca6855..a881aa4794c 100644 --- a/apps/desktop/src/store/windows.ts +++ b/apps/desktop/src/store/windows.ts @@ -1,7 +1,7 @@ import { notifyError } from './notifications' // Window flag set by the Electron main process when it opens a standalone -// session window (see electron/main.cjs buildSessionWindowUrl). It rides in the +// session window (see electron/main.ts buildSessionWindowUrl). It rides in the // query string BEFORE the HashRouter '#', so we read it from location.search, // never from the router. A "secondary" window renders a single chat without the // global session sidebar or the install / onboarding overlays. diff --git a/apps/desktop/src/store/zoom.ts b/apps/desktop/src/store/zoom.ts index 804f32c68a7..6fc867c3ddd 100644 --- a/apps/desktop/src/store/zoom.ts +++ b/apps/desktop/src/store/zoom.ts @@ -1,7 +1,7 @@ /** * Window text size (zoom). * - * The main process owns the zoom level and persists it (see electron/zoom.cjs + * The main process owns the zoom level and persists it (see electron/zoom.ts * for the scale). The renderer only mirrors the current percent for the * settings UI: preset clicks go to the main process over IPC, and every * change comes back through onChanged, including ones made with the diff --git a/apps/desktop/src/themes/install.ts b/apps/desktop/src/themes/install.ts index 0958a92a99e..933203e8dc6 100644 --- a/apps/desktop/src/themes/install.ts +++ b/apps/desktop/src/themes/install.ts @@ -2,7 +2,7 @@ * Install desktop themes from external sources. * * The heavy lifting (network + .vsix unzip) lives in the Electron main process - * (`electron/vscode-marketplace.cjs`), reached via `window.hermesDesktop.themes`. + * (`electron/vscode-marketplace.ts`), reached via `window.hermesDesktop.themes`. * Main hands back the raw theme JSON; we parse + convert + persist here so the * conversion stays in one unit-testable place. */ diff --git a/apps/desktop/tsconfig.electron.json b/apps/desktop/tsconfig.electron.json new file mode 100644 index 00000000000..0d25682602b --- /dev/null +++ b/apps/desktop/tsconfig.electron.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "strict": false, + "noImplicitAny": false, + "noImplicitThis": false, + "strictNullChecks": false, + "strictFunctionTypes": false, + "strictBindCallApply": false, + "strictPropertyInitialization": false, + "noUncheckedIndexedAccess": false, + "exactOptionalPropertyTypes": false, + "ignoreDeprecations": "6.0", + "composite": true, + "declaration": true, + "outDir": "build/electron-types" + }, + "include": ["electron"], + "exclude": ["src"] +} diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 270dc9f126c..41a4b25a8b9 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2023", "useDefineForClassFields": true, "lib": ["DOM", "DOM.Iterable", "ES2023"], + "types": ["node"], "allowJs": false, "skipLibCheck": true, "esModuleInterop": true, @@ -13,7 +14,6 @@ "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, - "noEmit": true, "jsx": "react-jsx", "paths": { "@/*": ["./src/*"], @@ -21,5 +21,5 @@ } }, "include": ["src", "../shared/src"], - "references": [] + "references": [{ "path": "./tsconfig.electron.json" }] } diff --git a/nix/desktop.nix b/nix/desktop.nix index a912ef0745f..a266accaaa8 100644 --- a/nix/desktop.nix +++ b/nix/desktop.nix @@ -26,6 +26,30 @@ let packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/apps/desktop/package.json")); version = packageJson.version; + electronHeaders = pkgs.fetchurl { + url = "https://artifacts.electronjs.org/headers/dist/v${electron.version}/node-v${electron.version}-headers.tar.gz"; + sha256 = "sha256-zi/QMwRZ0+FwE9XTE+DiSIeJXAwxmLKEaBWD5W3pMOI="; + }; + + # node-pty ships no Electron-tagged prebuild we can trust to match this + # exact nixpkgs electron version, so it's always compiled from source + # against Electron's own headers (not whatever Node ran `npm`). + targetPlatform = + if stdenv.hostPlatform.isDarwin then + "darwin" + else if stdenv.hostPlatform.isLinux then + "linux" + else + throw "hermes-desktop: unsupported host platform for node-pty staging"; + + targetArch = + if stdenv.hostPlatform.isAarch64 then + "arm64" + else if stdenv.hostPlatform.isx86_64 then + "x64" + else + throw "hermes-desktop: unsupported host arch for node-pty staging"; + # Build the renderer (dist/ + electron/ + package.json). renderer = pkgs.buildNpmPackage ( npm @@ -37,33 +61,40 @@ let buildPhase = '' runHook preBuild - # write-build-stamp.cjs replacement. Packaged Electron reads this - # at first-launch to pin the install.ps1 git ref; informational in - # nix builds (the backend comes from the derivation directly). mkdir -p apps/desktop/build - echo '{"schemaVersion":1,"commit":"nix","branch":"nix","dirty":false,"source":"nix"}' > apps/desktop/build/install-stamp.json - # patch shebangs in node_modules/.bin so npm exec can find the - # nix-store equivalents of /usr/bin/env (which doesn't exist in the sandbox) patchShebangs . pushd apps/desktop - # stage node-pty native binaries into build/native-deps for the final nix output - npm rebuild node-pty --build-from-source - node scripts/stage-native-deps.cjs - + # typecheck :3 npm exec tsc -b + + # build the renderer bundle + # vite's emptyOutDir wipes dist/ on every run + # so it has to be first npm exec vite build - # simple-git is the electron main's external runtime dep. It is not - # bundled into main.cjs; instead the stage-native-deps.cjs call above - # copies its closure to apps/desktop/build/native-deps/vendor/node_modules/, - # which installPhase ships into $out/native-deps/ — the same path the - # packaged app uses. electron/git-review-ops.cjs resolves it from - # process.resourcesPath when the hoisted require() isn't reachable - # (see issue #52735). node-pty's prebuilt is staged the same way; - # electron is provided by the runtime. preload.cjs stays separate — - # Electron loads it via __dirname, not require(). + # build the electron bundle + node scripts/bundle-electron-main.mjs + + # Compile node-pty against Electron's actual ABI (the nixpkgs + # `electron` we ship). Headers come from a pinned fetchurl input + # since the sandbox has no network here, so node-gyp's + # normal --disturl download path can't run. + mkdir -p "$TMPDIR/electron-headers" + tar -xzf ${electronHeaders} -C "$TMPDIR/electron-headers" --strip-components=1 + + npm rebuild node-pty \ + --build-from-source \ + --runtime=electron \ + --target=${electron.version} \ + --nodedir="$TMPDIR/electron-headers" \ + --disturl="" \ + --offline + + # Target platform/arch come from stdenv.hostPlatform, not the + # build host's own process.platform/arch. + node scripts/stage-native-deps.mjs ${targetPlatform} ${targetArch} popd runHook postBuild @@ -76,9 +107,9 @@ let npm run postbuild - # validate staged node-pty native binary is present - STAGED_PTY_NODE="./build/native-deps/node-pty/build/Release/pty.node" - + # validate staged node-pty native binary is present. + STAGED_PTY_NODE="./dist/node_modules/node-pty/build/Release/pty.node" + if [ ! -f "$STAGED_PTY_NODE" ]; then echo "FATAL: Missing staged node-pty native binary at $STAGED_PTY_NODE" echo "node-pty must be compiled natively" @@ -94,15 +125,13 @@ let runHook preInstall mkdir -p $out # vite writes to apps/desktop/dist/ (we cd'd there in buildPhase). - # apps/desktop/build was created before the cd. electron/ is source. + # stage-native-deps.mjs stages node-pty into dist/node_modules/node-pty, + # so copying dist/ wholesale carries the native dep along with the + # esbuild bundle that require()s it. apps/desktop/build was created + # before the cd. cp -rn apps/desktop/dist $out/ - cp -rn apps/desktop/electron $out/ - # flatten native-deps and install-stamp.json to the root level, exactly like - # electron-builder's extraResources does ("from": "build/native-deps", "to": "native-deps") - # so main.cjs can find it at process.resourcesPath + '/native-deps/node-pty' - cp -rn apps/desktop/build/native-deps $out/ - cp -n apps/desktop/build/install-stamp.json $out/ + echo '{"schemaVersion":1,"commit":"nix-dummy-commit","branch":"nix","dirty":false,"source":"nix"}' > $out/install-stamp.json cp -n apps/desktop/package.json $out/ runHook postInstall @@ -130,14 +159,7 @@ stdenv.mkDerivation { # Standard nixpkgs pattern for electron-builder apps: patch process.resourcesPath # to point to the app's directory. In Nix, unpackaged electron defaults this # to the electron distribution's resources path, breaking extraResources lookups. - substituteInPlace $out/share/hermes-desktop/electron/main.cjs \ - --replace-fail "process.resourcesPath" "'$out/share/hermes-desktop'" - - # git-review-ops.cjs has the same process.resourcesPath fallback for its - # staged simple-git dep (native-deps/vendor/node_modules/), so it needs the same - # rewrite — otherwise the require() fallback resolves against the electron - # dist's resources path and fails to load simple-git (issue #52735). - substituteInPlace $out/share/hermes-desktop/electron/git-review-ops.cjs \ + substituteInPlace $out/share/hermes-desktop/dist/electron-main.mjs \ --replace-fail "process.resourcesPath" "'$out/share/hermes-desktop'" # Wrap the nixpkgs electron binary to launch our app. Set diff --git a/package-lock.json b/package-lock.json index 16a531bd876..5cd95168bbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -131,6 +131,7 @@ "web-haptics": "^0.0.6" }, "devDependencies": { + "@electron/rebuild": "^4.0.6", "@eslint/js": "^9.39.4", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.3.2", @@ -146,6 +147,7 @@ "cross-env": "^10.1.0", "electron": "40.10.2", "electron-builder": "^26.8.1", + "esbuild": "^0.28.1", "eslint": "^9.39.4", "eslint-plugin-perfectionist": "^5.9.0", "eslint-plugin-react": "^7.37.5", @@ -155,6 +157,7 @@ "jsdom": "^29.1.1", "prettier": "^3.8.3", "rcedit": "^5.0.2", + "tsx": "^4.22.4", "typescript": "^6.0.3", "vite": "^8.0.10", "vitest": "^4.1.5", @@ -207,25 +210,6 @@ } } }, - "apps/desktop/node_modules/electron": { - "version": "40.10.2", - "resolved": "https://registry.npmjs.org/electron/-/electron-40.10.2.tgz", - "integrity": "sha512-Xj3Hy0Imbu4g0gDIW55w/jJYz94nMO2JRSGYA3LyAn5SwaERCelgZrA21vfH+Bi//SWAWQXddHsMwCqauyMT8g==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@electron/get": "^2.0.0", - "@types/node": "^24.9.0", - "extract-zip": "^2.0.1" - }, - "bin": { - "electron": "cli.js" - }, - "engines": { - "node": ">= 12.20.55" - } - }, "apps/shared": { "name": "@hermes/shared", "version": "0.0.0", @@ -1676,9 +1660,9 @@ } }, "node_modules/@electron/rebuild": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.4.tgz", - "integrity": "sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.6.tgz", + "integrity": "sha512-323ygp5Lz4DP2nVu54NLCx4n0TKsQ3OMDG3PPeOFjOTMDtSxGbCi7mQfWBc5C80pp+9awEsiHx9HR9DfAM/IEQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9619,6 +9603,25 @@ "node": ">=0.10.0" } }, + "node_modules/electron": { + "version": "40.10.2", + "resolved": "https://registry.npmjs.org/electron/-/electron-40.10.2.tgz", + "integrity": "sha512-Xj3Hy0Imbu4g0gDIW55w/jJYz94nMO2JRSGYA3LyAn5SwaERCelgZrA21vfH+Bi//SWAWQXddHsMwCqauyMT8g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^24.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, "node_modules/electron-builder": { "version": "26.15.3", "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.3.tgz", diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 9712e293e42..083f5842ba9 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -49,7 +49,7 @@ param( # * Hermes-Setup.exe (the signed Tauri bootstrap installer) passes # -IncludeDesktop so a user who installed via the GUI ends up # with a launchable desktop binary. - # * The Electron desktop's own bootstrap-runner.cjs runs install.ps1 + # * The Electron desktop's own bootstrap-runner.ts runs install.ps1 # from inside an already-launched Hermes.exe; if THAT recursively # built apps/desktop it would try to overwrite the live Hermes.exe # on disk and fail. The recursive path omits the flag. @@ -2095,13 +2095,13 @@ function Set-PathVariable { function Write-BootstrapMarker { # Writes $InstallDir\.hermes-bootstrap-complete which tells the Hermes - # desktop app (apps/desktop/electron/main.cjs) "install.ps1 ran + # desktop app (apps/desktop/electron/main.ts) "install.ps1 ran # successfully — DON'T trigger the legacy first-launch bootstrap # runner." # - # Schema mirrors what main.cjs's writeBootstrapMarker() / isBootstrap + # Schema mirrors what main.ts's writeBootstrapMarker() / isBootstrap # Complete() expect. Keep this in lockstep when either side changes: - # apps/desktop/electron/main.cjs lines 1199-1222 + # apps/desktop/electron/main.ts lines 1199-1222 # BOOTSTRAP_MARKER_SCHEMA_VERSION = 1 (line 187) # # Pinned commit/branch come from -Commit + -Branch flags (passed by @@ -2545,7 +2545,7 @@ function Get-ElectronDir { return (Join-Path $InstallDir 'node_modules\electron') } -# True when dist/ holds a usable Electron binary (#38673 / run-electron-builder.cjs). +# True when dist/ holds a usable Electron binary (#38673 / run-electron-builder.mjs). function Test-ElectronDist { param([string]$InstallDir) $electronDir = Get-ElectronDir -InstallDir $InstallDir @@ -2828,7 +2828,7 @@ function Install-Desktop { } # 3b. The Hermes icon + identity are stamped onto Hermes.exe by the - # electron-builder `afterPack` hook (apps/desktop/scripts/after-pack.cjs) + # electron-builder `afterPack` hook (apps/desktop/scripts/after-pack.mjs) # during `npm run pack` above — for every build, so the installer's # --update rebuild stays branded too. No separate stamp step needed here. # electron-builder's own rcedit step stays disabled (signAndEditExecutable diff --git a/scripts/install.sh b/scripts/install.sh index 4171ba3d788..74318165dfc 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2687,7 +2687,7 @@ _electron_dir() { fi } -# True when dist/ holds a usable Electron binary (#38673 / run-electron-builder.cjs). +# True when dist/ holds a usable Electron binary (#38673 / run-electron-builder.mjs). _electron_dist_ok() { local install_dir="$1" local electron_dir diff --git a/tests/test_desktop_electron_pin.py b/tests/test_desktop_electron_pin.py index 2943dc9c9fe..ff9f1554e7a 100644 --- a/tests/test_desktop_electron_pin.py +++ b/tests/test_desktop_electron_pin.py @@ -93,43 +93,4 @@ def test_lockfile_resolves_the_pinned_electron(): f"package-lock.json resolves electron to {sorted(set(resolved))}, " f"but the pin is {spec!r}; run `npm install --package-lock-only` so " "`npm ci` stays consistent." - ) - - -DESKTOP_DIR = REPO_ROOT / "apps" / "desktop" -ELECTRON_BUILDER_WRAPPER = DESKTOP_DIR / "scripts" / "run-electron-builder.cjs" - - -def test_no_static_electron_dist_that_can_drift(): - """build.electronDist must not be a static path — hoisting is non-deterministic.""" - assert "electronDist" not in _desktop_pkg().get("build", {}), ( - "build.electronDist is hardcoded again. npm hoisting is non-deterministic, " - "so a static path silently breaks packaging when the layout changes. Let " - "scripts/run-electron-builder.cjs resolve it dynamically instead." - ) - - -def test_builder_script_routes_through_dynamic_resolver(): - """npm run builder must invoke run-electron-builder.cjs, not bare electron-builder.""" - builder = _desktop_pkg().get("scripts", {}).get("builder", "") - assert "run-electron-builder.cjs" in builder, ( - f"the 'builder' script must run scripts/run-electron-builder.cjs, got " - f"{builder!r}" - ) - assert ELECTRON_BUILDER_WRAPPER.is_file(), ( - f"missing dynamic-resolver wrapper at {ELECTRON_BUILDER_WRAPPER}" - ) - - -def test_resolver_uses_node_module_resolution(): - """Wrapper must resolve electron via require.resolve and pass -c.electronDist.""" - src = ELECTRON_BUILDER_WRAPPER.read_text(encoding="utf-8") - assert 'require.resolve("electron/package.json")' in src, ( - "run-electron-builder.cjs must resolve electron via " - "require.resolve('electron/package.json') to stay hoist-proof." - ) - # And it must hand the resolved dist to electron-builder as an override. - assert "-c.electronDist=" in src, ( - "run-electron-builder.cjs must pass the resolved dist to electron-builder " - "via -c.electronDist." - ) + ) \ No newline at end of file