No accelerator (⌘O stays a rebindable renderer keybind, matching New
Window's rationale); clicking routes hermes:open-folder-requested through
the preload bridge to the same openFolderAsProject flow.
Two residual gaps from the #72707/#72632 probe-hardening series:
- backendSupportsServe never forwarded backend.shell to the serve
--help probe. A .cmd/.bat shim backend (which carries shell: true in
its step-4 descriptor) makes execFileSync throw EINVAL on modern
Node; the bare catch then caches supported=false for the process
lifetime, permanently routing that backend through the legacy
dashboard form. Forward the flag.
- The Windows python-discovery probes ran with no timeout at all:
reg query (registry read) and py.exe -c 'import sys;...' (bare
interpreter startup) are both synchronous execs on the boot path;
a wedged reg.exe or python.exe would hang the resolver forever.
Bound reg query at 5s and the py.exe probe at the shared
PROBE_TIMEOUT_MS budget.
Follow-up to #73907 (probe timeout 15s + env override + timeout-only
retry), widening the same fix to the two sibling sites it missed:
- backendSupportsServe's serve --help probe kept a bare execFileSync
with its own 15s literal: same cold-Windows Python-startup class
(#72632 measured ~10.5s for --version cold), no retry, and a false
negative is cached for the process lifetime - silently routing a
modern runtime through the legacy dashboard form. Route it through
execProbeSync with the shared PROBE_TIMEOUT_MS (honours
HERMES_PROBE_TIMEOUT_MS) and the timeout-only retry.
- resolveHermesBackend step 4 called unwrapWindowsVenvHermesCommand
twice; the second call re-ran the same un-memoized import probe,
costing up to another full probe timeout on a hung interpreter for
an answer the first call already gave. Drop the redundant re-probe.
Part of the #72707 bug class (transient disconnect must not strand a
healthy install).
The old test asserted _warm_gateway_module was fire-and-forget (startup
completes in << SLOW_SECONDS). PR #73291 intentionally reversed this:
the import now runs synchronously before the lifespan yield because
run_in_executor didn't release the GIL on Windows + Python 3.11.
Updated the test to assert startup blocks for >= SLOW_SECONDS.
On Windows, applyUpdates kills its own backend (releaseBackendLock)
BEFORE the venv-blocker preflight but only writes the on-disk update
marker AFTER the scan. Killing the backend drops the renderer's
WebSocket; the renderer reconnects within ~1s and the marker-only
waitForUpdateToFinish gate happily spawns a fresh 'hermes serve' inside
the update's own critical section. scanVenvBlockers then finds that
brand-new process and aborts with 'another Hermes process is using this
installation' — a different PID on every attempt, so Desktop self-update
can never succeed.
Fix: extract the gate into update-gate.ts (pure, DI-testable) and make
it consult BOTH signals — the on-disk marker AND the in-process
updateInFlight flag. The success path writes the marker before the flag
clears in applyUpdates' finally, so there is no instant where both are
false and a waiter can slip through. Also gate spawnPoolBackend, which
previously had no waitForLocalStart at all — a background profile window
could respawn a pool backend during the same window with the identical
abort.
Tests: update-gate.test.ts covers the open gate, the flag-only window
(the #73822 shape), the flag→marker handoff with no gap, and timeout.
Follow-ups on top of #66911's salvaged commit:
- hermes:fs:desktopPluginsRoot now resolves the ACTIVE desktop profile
(readActiveDesktopProfile) so named profiles keep their own
profiles/<name>/desktop-plugins root instead of sharing the global one
(profile-scope concern raised on the PR thread).
- startDirWatch in runtime-loader.ts was a third sibling site still
deriving the watch path from the backend's hermes_home (added by the
later fs-watch commit); routed through the same Electron-local
resolver, with regression coverage.
The Settings "Open plugins folder" action and the runtime disk-plugin
scanner both derived the plugin directory from getStatus().hermes_home.
Against a remote backend that value is a path on the REMOTE box (or
undefined), producing `undefined/desktop-plugins` — the folder action
errors ("Could not open the plugins folder undefined") and disk-plugin
discovery silently finds nothing, even with a valid local plugin.js.
Add an Electron-owned IPC resolver (hermes:fs:desktopPluginsRoot) that
returns <HERMES_HOME>/desktop-plugins computed from the main-process
HERMES_HOME — the local Electron path, valid in every connection mode —
creating it on demand. Route both the Settings folder action and the
runtime scanner through it, so a remote backend never determines the
local filesystem location used for Desktop runtime plugins.
Fixes#66899
Keep Settings-configurable preview/image loads on readFileDataUrl, and
route remote non-image attach through a 256 MiB IPC so uploads are not
stuck on the 16 MiB default after #73221.
Co-authored-by: Börje <borje@dqsverige.se>
xterm paints to a canvas, so its selection is not a DOM selection: the Edit
menu's Copy and the right-click Copy both call webContents.copy(), find
nothing, and copy nothing. On macOS the menu also swallows the Cmd+C
accelerator before the renderer sees it. That left Ctrl+C as the only key that
did anything, and xterm correctly forwards it to the PTY as SIGINT.
Mirror the selection into xterm's hidden helper textarea (the mechanism xterm
already uses for Linux middle-click paste) so the OS sees a real selection and
every platform copy path works, and add explicit chords on top: Cmd+C/Cmd+V on
macOS, Ctrl+Shift+C/V elsewhere, matching VS Code. Plain Ctrl+C copies only
when text is selected -- the behavior Windows Terminal and Tabby ship -- and
stays SIGINT otherwise, so interrupting a process never breaks.
Reads go through a new hermes:readClipboard IPC handler for the same reason
writes already do: the renderer's clipboard API throws whenever the document
isn't focused.
- watchDirectory IPC (same registry/channel as the preview file watchers)
replaces the disk-plugin door's 5s readdir poll; older shells without
the capability keep the poll, which self-upgrades to the watch once the
plugins dir exists.
- Status snapshot: 15s → 60s, skips round-trips while hidden, and
refreshes immediately on visibilitychange so re-focus never shows stale
health.
Part of #73618.
CI's electron vitest project collects electron/**/*.test.ts; the
node:test-based suite reported 'No test suite found'. Import
describe/it from vitest like sibling electron tests.
The preflight's execFileSync froze the Electron UI event loop for up to
15s while psutil scanned the full process table. Convert
scanVenvBlockers to async execFile and await it in applyUpdates; tests
updated to async DI stubs. Also adds trailing newlines.
* fix(desktop): allow camera capture through the permission handlers
The session permission hooks were written for the voice composer and denied
video outright, so any renderer getUserMedia({video}) failed with
NotAllowedError before the OS was ever consulted.
Rename isAudioCapturePermission to isMediaCapturePermission and accept video
alongside audio in both the request and check handlers. The OS capture
permission still applies, so the user keeps a real allow/deny.
* build(desktop): declare camera usage for signed macOS builds
A hardened-runtime build needs the camera entitlement and an
NSCameraUsageDescription string, or the packaged app is killed on first
camera access instead of prompting.
Diagnosed from a Windows desktop's logs: the backend TTS was generating
and saving the reply mp3 every turn (provider: openai), so synthesis was
fine — the DESKTOP wasn't playing the first reply. Root cause is Chromium's
autoplay policy: audio (HTMLAudioElement.play() and AudioContext) is
suspended until the frame sees a user gesture. A voice conversation started
by the 'Hey Hermes' wake word has no preceding click, so the first reply's
play() was rejected with NotAllowedError (silently swallowed) and only
turn 2+ spoke. Manual voice-start worked because the button click WAS the
gesture — exactly the 'first message in a new voice session is silent,
manual start is fine' report.
Fix (defense in depth):
- chatWindowWebPreferences sets autoplayPolicy: 'no-user-gesture-required'
on every chat window (primary + secondary), so a deliberately-launched
native app never gates audio on a gesture. Primary fix.
- voice-playback.ts resumes a suspended AudioContext on stream 'start' and
retries HTMLAudioElement.play() once via an unlock context after a
NotAllowedError — covers the dashboard-embedded surface that doesn't get
the Electron policy.
Tests: 16 electron session-window tests (incl. new autoplay assertion),
tsc + eslint clean.
A hardened-runtime build needs the camera entitlement and an
NSCameraUsageDescription string, or the packaged app is killed on first
camera access instead of prompting.
The session permission hooks were written for the voice composer and denied
video outright, so any renderer getUserMedia({video}) failed with
NotAllowedError before the OS was ever consulted.
Rename isAudioCapturePermission to isMediaCapturePermission and accept video
alongside audio in both the request and check handlers. The OS capture
permission still applies, so the user keeps a real allow/deny.
Hard 16 MB cap on readFileDataUrl blocked larger local attaches with no way to raise it. Settings -> Chat now has a free-form MB field. Main process owns the persisted value and clamps only absurd inputs.
Ship Ctrl/Cmd+- ×5 from Chromium 0 as DEFAULT_ZOOM_LEVEL so fresh
installs open tighter. Actual Size / Ctrl+0 and mandatory garbage
fallbacks all land on the same level; existing zoom-state.json is
left alone.
Gating this behind an opt-in was the wrong call. A dev server already
executes arbitrary local JS — vite's module graph, every postinstall in
node_modules — so a loopback debugging port does not meaningfully widen
what a `npm run dev` session can already do, and `perf:serve` has opened
one unconditionally all along.
Requiring the variable also defeated the point: the tooling exists to be
reached for mid-task, and a capability you must remember to enable before
launching is one you don't have when you need it.
So the port opens on 9222 — the same port scripts/eval.mjs and
scripts/perf/lib/cdp.mjs already default to — for any dev-server run.
HERMES_DESKTOP_CDP_PORT stops being an on-switch and becomes an
override: a different port, or `off` to disable.
The hard gate is unchanged and still checked first: a packaged build
never opens the port, and no env value talks it into it. Neither does an
unpackaged `electron .` against dist/, which is how the packaged app
gets smoke tested.
Refusals only log when they contradict something the developer asked for
(a typo'd port, an explicit `off`). Packaged and dist runs are closed by
design and stay quiet.
The renderer is a Chromium page, and apps/desktop already carries a whole
CDP toolkit for it — scripts/eval.mjs, scripts/perf/lib/cdp.mjs with its
shared SELECTORS map, and the diag-*/probe-* family. None of it can
attach to `hgui` or `npm run dev`, because neither passes
--remote-debugging-port. The only launcher that opens one is
`npm run perf:serve`, which is a separate isolated instance rather than
the app you're looking at.
Add HERMES_DESKTOP_CDP_PORT. When set, the shell opens a CDP port on
loopback so that existing tooling can read the live DOM: computed
styles, geometry, which rule actually won.
Three independent gates, all required, resolved by a pure function in
electron/dev-cdp.ts so the policy is testable without an Electron app:
1. not packaged — a shipped build never opens the port, and this is
checked first so no env combination can talk it into doing so;
2. HERMES_DESKTOP_DEV_SERVER present — an unpackaged `electron .`
against dist/ is how the packaged app gets smoke tested, so it
behaves like the packaged app here;
3. the port explicitly requested and a valid integer.
Default `npm run dev` is unchanged and silent: no port, no nag. An
opt-in that gets refused always logs why, so nobody loses an hour
wondering what isn't listening.
The address is pinned to 127.0.0.1 rather than left to Chromium's
default, and is deliberately not configurable — there's no reason to
expose a renderer debugger off-host and offering the knob invites
someone to try.
scripts/eval.mjs hardcoded :9222 and threw a raw ECONNREFUSED stack when
nothing was there. It now honours the same variable and explains itself.
Playwright closes the app with a turn still in flight, so the new quit
confirmation waited on a click nobody was there to make and the E2E
worker died on a 90s teardown timeout.
Cmd-Q went straight through to teardown, killing the backend mid-tool-call
— the turn is gone and whatever the agent was part-way through writing
stays part-way written, with nothing on screen to warn about it.
Renderers now report which chats are mid-turn; before-quit merges the
reports and asks, naming them, defaulting to Keep Running. Update, swap,
and uninstall relaunches skip the prompt: those are the app replacing
itself, and a modal there would strand the detached script waiting on a
PID that never exits.
Three helpers each re-derived part of the same decision: which backend
serves profile P, and does its REST path need a `?profile=` scope.
profileUsesPrimaryBackend answered the first half, pathWithGlobalRemoteProfile
answered the second, and ensureBackend re-checked globalRemoteActive() around
both. Splitting one table across three predicates is how the global-remote
case ended up registering reapable pool entries for a backend it never owned.
resolveProfileBackendRoute() states the four routes in one place and returns
the backend, the descriptor scope, and whether the path needs a query
parameter. The call sites read the answer instead of recomputing it.
One behavior change falls out: `hermes:api` now passes the primary profile
through, so the primary no longer sends itself a redundant `?profile=<self>`
on a global remote that already serves it.
Electron pre-installs its own uncaughtException listener and only warns on
unhandled rejections, so a main-process fault usually leaves the app running
with the reason on stderr — which nothing captures when the app is launched
from Finder or the Start menu. The fault never reaches desktop.log, so it is
absent from `hermes debug share` and the user can only describe symptoms.
Record both to desktop.log and flush synchronously, since a fault that does
prove fatal leaves no chance for the batched async flush. Five loadURL calls
were also unhandled, each able to leave a blank window with no explanation
anywhere the user can send us; they now name the surface that failed.
Co-authored-by: Rodrigo Fernandez <rod@nxtlevel.dev>
A pooled backend entry pointing at a remote host has no child process, so
the 'exit' handler that clears a dead local backend never fires. The
renderer's 60s keepalive touch also spares it from the idle reaper. Nothing
was left to retire the descriptor, so once the host went away the pool kept
serving it and every profile bound to that host stayed broken until restart.
Pooled remote descriptors now share the primary's liveness policy: probed on
the same revalidate tick, keyed per base URL, and dropped only after the
same consecutive-failure limit, so the next ensureBackend() rebuilds.
Co-authored-by: Rodrigo Fernandez <rod@nxtlevel.dev>
Keep non-primary profiles that inherit the app-global remote on the primary connection descriptor instead of creating processless pool entries that the idle reaper repeatedly removes.
Preserve per-profile remote overrides and local pooled backends, and cover the routing policy with behavioral tests.
Co-authored-by: Rodrigo Fernandez <rodrigo@nxtlevelsaas.com>
Vitest coverage for apps/desktop/electron/find-in-page.ts.
The helpers and the IPC handlers in main.ts are the only
consumers, so the tests pin the wire shape (match counter
shape, options defaults, no-throw-on-destroyed) and the
multi-window correctness that the original CJS PR missed:
- formatFoundInPage:
- Maps { activeMatchOrdinal, matches } → wire payload.
- Coerces missing fields to zero (the renderer never
sees NaN).
- Tolerates null / undefined inputs.
- performFind:
- Forwards query + options to webContents.findInPage.
- Defaults forward=true and findNext=false when omitted.
- Treats null / non-object options as "all defaults".
- Coerces a non-string query to string (defensive against
a misbehaving renderer).
- Is a no-op on null webContents.
- Is a no-op on destroyed webContents (does not throw
across the IPC boundary).
- stopFind:
- Calls stopFindInPage with the default action
('clearSelection').
- Honors an explicit action argument.
- Is a no-op on null or destroyed webContents.
- installFoundInPageForwarder:
- Forwards 'found-in-page' to the sender as a formatted
payload.
- Handles missing fields without throwing.
- Skips send when webContents is destroyed at fire time.
- Returned uninstall removes the listener.
- Returned uninstall on null/destroyed webContents is a
safe no-op.
- Regression: two forwarders installed on distinct
webContents do not cross-fire. This is the bug the
original PR shipped — the global mainWindow listener
routed results to the primary regardless of which
renderer invoked findInPage. Pinning this here keeps
the per-sender routing from regressing.
Run with:
cd apps/desktop && npx vitest run electron/find-in-page.test.ts --project electron
The original PR targeted the CJS Electron files
(apps/desktop/electron/main.cjs and preload.cjs), but commit
39d09453f "feat(desktop): ts-ify everything" renamed them to
main.ts and preload.ts on current main. The PR's diff therefore
targeted files that no longer exist on main.
Brings the bridge forward to the current TypeScript Electron
files and extracts the IPC bridge helpers into a focused
pure-helpers module:
- apps/desktop/electron/find-in-page.ts (new):
- performFind(webContents, query, options) — wraps
webContents.findInPage with default-coercing options.
- stopFind(webContents, action) — clears highlights.
- formatFoundInPage(result) — pure projection of
Electron's FoundInPageResult onto the wire payload shape
({ activeMatchOrdinal, count }).
- installFoundInPageForwarder(webContents) — wires a
sender-scoped 'found-in-page' forwarder; returns an
uninstall function. Returns a no-op uninstall for null
or destroyed webContents so callers don't need guards.
- apps/desktop/electron/main.ts:
- ipcMain.handle('hermes:find-in-page', event => ...)
resolves the requesting window via
BrowserWindow.fromWebContents(event.sender) and routes
the search to THAT window, not the global primary. This
fixes a multi-window bug where Cmd+F pressed in a
secondary session window (one per chat, spawned via
hermes🪟openSession) searched the primary window
instead of the focused surface.
- ipcMain.handle('hermes:stop-find-in-page', event => ...)
routes stopFind through the requesting window for
multi-window correctness.
- A per-sender lazy forwarder registry
(foundInPageForwarders: Map<webContentsId, () => void>)
installs installFoundInPageForwarder on first
findInPage call, scoped to the sender's webContents.
Cleans up automatically via webContents.once('destroyed',
...). The forwarder sends results back to the SAME
renderer that initiated the search, never the global
primary — so a secondary session window's Cmd+F shows
matches from THAT window and the match counter reports
matches from THAT window's DOM.
- apps/desktop/electron/preload.ts:
- hermesDesktop.findInPage(query, options) — invokes
the IPC handler.
- hermesDesktop.stopFindInPage() — invokes the IPC
handler.
- hermesDesktop.onFoundInPage(callback) — subscribes to
'hermes:found-in-page' results from the sender;
returns an unsubscribe function so the FindBar can
clean up on unmount.
- apps/desktop/src/global.d.ts:
- Three new hermesDesktop method declarations:
findInPage, stopFindInPage, onFoundInPage. The new
forwarder install registers a 'found-in-page' listener
bound to the sender's webContents and emits
'hermes:found-in-page' results back to the sender.
The multi-window fix is part of the same port — the old
PR's behavior (Cmd+F in a secondary session window searched
the global primary) was a bug present in the CJS files,
not a design constraint we wanted to preserve. The new
helper module uses event.sender by design, so the
multi-window correctness lands with the TS port.
Fixes#46169
Repair signalled "reinstall me" by deleting the bootstrap marker. That was
already destructive -- repair is reachable from a transient backend error on a
fully working install -- and it stranded users in first-run setup with no way
back short of hand-writing the marker file.
Carry the intent in an explicit flag instead. Repair forces the next resolve
through the installer and clears itself once the reinstall starts, so a forced
reinstall still works without destroying provenance about how the install was
created.
Closes#72166