The shipped artwork bakes in the macOS-style ~10% transparent margin
(content covered only ~80% of the canvas), so the taskbar/titlebar icon
rendered visibly smaller than neighboring Windows apps, which draw
full-bleed.
- Regenerate assets/icon.ico full-bleed (~95% coverage) from the same
art, with the standard 16-256px frames. This feeds both the exe stamp
(set-exe-identity via rcedit) and the installer.
- On Windows, resolve the BrowserWindow icon from the full-bleed ico
(resources/icon.ico, shipped via extraResources) before falling back
to the padded apple-touch PNG.
macOS is untouched: the dock icon and icon.icns keep the padded art,
which is correct there.
Issue #74874. The renderer's 'Repair' button treated every transient
backend GIL stall (event loop stalled ... ws ready frame send failed)
as a fatal backend fault, asking the bootstrap to force-reinstall +
restart, which then stalled again for the same reason — looping the
user through 30+ minutes of reinstall cycles.
Distinguish 'venv is genuinely broken' from 'backend is just transiently
stalled' before honouring a repair request. Probe the live backend
process (exitCode === null && signalCode === null) and an in-flight
repair-attempt counter:
attempt <= 3 AND primary alive → soft restart (skip installer)
attempt <= 3 AND primary dead → soft restart (verify before reinstall)
attempt > 3 → hard reinstall (escalate)
Counter resets on a clean backend.ready so a later, unrelated failure
episode starts at attempt 1. The guard is a pure helper (decideBootstrap
Repair in electron/bootstrap-repair-guard.ts) so the decision logic is
unit-tested in isolation; main.ts only wires the existing flag and
counters to it.
Refs #74874
resolveUpdaterBinary() picked up a staged hermes-setup on every platform, so a
macOS binary predating the update hand-off protocol took over the update, held
the marker, and had its `hermes update` child refuse its own parent. The in-app
Update button then failed for good, with no route -- update, re-download or
reinstall -- back to a capable binary (#74836).
Move the decision into a pure resolveStagedUpdaterBinary() helper in
updater-process.ts and return null off Windows. The installer self-copies into
HERMES_HOME on every platform (paths::installer_dest,
bootstrap::copy_self_to_hermes_home), so finding that binary on macOS or Linux
is expected rather than leftover junk: declining to hand it an update is a
policy decision, and the comments now say so instead of describing the binary
as Windows-specific.
Cover the resolver in updater-process.test.ts: Windows accepts a staged
hermes-setup.exe, macOS/Linux return null even when hermes-setup exists, and
Windows returns null when nothing is staged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
resolveUpdaterBinary() returned a path on macOS if hermes-setup existed
in HERMES_HOME, routing macOS into the Windows-style quit→hand-off→rebuild
update dance. A stale hermes-setup (e.g. from 2026-06-08, predating the
applyUpdatesPosixInApp path) permanently breaks the in-app Update button.
The Tauri hermes-setup binary is a Windows-specific mechanism; macOS and
Linux use applyUpdatesPosixInApp instead. Always return null on non-Windows
so the native drag-and-drop updater is used on those platforms.
Fixes#74836
Users pasting a Tailscale IP or LAN host as 'host:port' (no http://) hit
either a hard 'URL is not valid' error in the main process or, worse, a
silent dead probe in the renderer: the ^https?:// gates in the settings
and first-run forms never fired, so the field sat idle with no feedback.
- normalizeRemoteBaseUrl() (electron/connection-config.ts) now prepends
http:// when the input has no scheme:// prefix; explicit non-http
schemes (ws://, ftp://) still reach the protocol check and get a clear
rejection.
- New renderer twin coerceRemoteUrlScheme() (src/lib/remote-url.ts),
wired into both probe gates (gateway-settings.tsx and
first-run-remote-form.tsx) so the debounced /api/status probe, sign-in,
test, and save all see the coerced URL.
- Tests for both sides (electron/connection-config.test.ts,
src/lib/remote-url.test.ts).
powerMonitor's AC/battery state is mirrored to the renderers
(store/power.ts) and visiblePoll quadruples its cadence on battery. Only
the safety-net refreshes slow down — event-driven refreshes and live
streaming are untouched.
It loads arbitrary user-linked pages offscreen; unthrottled, a heavy page
burns full CPU for the window's whole lifetime. Title resolution rides
load events and main-process timers, which throttling doesn't touch.
The process-wide disable-background-timer-throttling /
disable-backgrounding-occluded-windows switches plus a static
backgroundThrottling: false on every chat window pinned each renderer's
document.visibilityState to 'visible' for the life of the window. Every
visibility-gated backstop poll and clock tick in the renderer became an
always-on timer: an idle, minimized Hermes burned ~20% CPU around the
clock, on battery too.
Throttling is now a runtime dial. A small controller (stream-throttle.ts)
rides the merged hermes:active-work reports the quit guard already
receives: while any turn is in flight every chat window gets
setBackgroundThrottling(false) — a live answer keeps painting while
blurred, occluded, or minimized, exactly as before — and once all turns
settle (plus a 5s trailing window so the final flush lands at full
cadence) Chromium's default throttling returns and hidden windows go
quiet.
disable-renderer-backgrounding stays: process priority only, no timer
semantics, and it keeps hidden streaming fast.
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.