Files
hermes-agent/apps/desktop/electron/preload.ts
teknium1 aa83040ca4 fix(telemetry): renderer-crash consent and pending crashes are per window and profile
M11. RendererCrashRecorder held one install-wide `enabled` flag that every window
overwrote with its own profile's switch, so an opted-out window's crash was
written to disk and reported into another profile, and an opted-out window
wiped the opted-in profile's pending crash.

- Consent is kept per webContents id (the IPC sender), with a hash of the
  window's focused profile key; set-enabled now carries that key.
- A crash is recorded only when the crashed window's own profile collects;
  each pending entry is tagged with the profile hash (file v2: {entries:[{profile,
  reason}]}; an untagged v1 file is ignored — whose it was is unknown).
- take() returns only the calling window's profile's reasons; one claim per
  profile (the same window may re-claim after its renderer reloaded); ack drops
  only the claimed count. Turning a profile off drops only that profile's entries.
- main.ts passes the window's webContents id to recordRendererGone.

Tests: renderer-crash-metrics.test.ts moves to the per-window API (contract
change: every call names its window, set-enabled names the profile); one new
invariant test (opted-out window records nothing, another profile neither
drains nor purges) — the base API cannot express it, so RED is shown by the
reviewer's probe instead.
Probe (review/desktop/jsrun/.../renderer-crash-metrics.probe.test.ts, adapted to
window ids):
  before: after opted-out window crash, file exists: true {"v":1,"reasons":["crash"]}
          take by window 2: {"reasons":["crash"]}; A crash record after B-window off: false
  after:  file exists: false; take by window 2: null; A crash record after B-window off: true
2026-09-28 12:43:03 -07:00

676 lines
36 KiB
TypeScript

import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron'
import type { DesktopProfileRoute } from './desktop-profile'
import type { HudModifierApi, HudModifierStatus } from './hud-modifier-types'
import { customWindowControlsEnabled } from './window-controls'
// Which translucency the OS can back. Asked synchronously because the renderer
// needs it before its first paint, and answered by main because deciding it
// needs `os.release()` — a sandboxed preload may only require electron, events,
// timers and url, so importing node:os here throws before contextBridge runs
// and takes the ENTIRE bridge down with it (window.hermesDesktop undefined =>
// "Desktop IPC bridge is unavailable"). No reply means no glass, which degrades
// to an ordinary opaque window rather than a page thinned over nothing.
const translucencySupport = ipcRenderer.sendSync('hermes:translucency:support')
const hudWindowing = ipcRenderer.sendSync('hermes:hud:windowing')
const hudNativeDrag = hudWindowing?.nativeDrag === true
const launchFlags: { localModels?: boolean; guestOnboarding?: boolean } | undefined =
ipcRenderer.sendSync('hermes:feature-flags')
// Local, sanitized skin payload for the first renderer theme paint. This does
// not wait on `gateway.ready`, so an unreachable remote primary cannot force
// the built-in palette over the skin configured on this machine.
const localSkin = ipcRenderer.sendSync('hermes:skin:local')
contextBridge.exposeInMainWorld('hermesDesktop', {
glassSupported: translucencySupport?.glass === true,
translucencySupported: translucencySupport?.translucency === true,
// Launch-flag fact: the app was started with --local, so the renderer may
// show the local-models surfaces. Static for the window's lifetime.
localModelsEnabled: launchFlags?.localModels === true,
// Launch-flag fact: the Nous free tier is on for this launch
// (HERMES_GUEST_ONBOARDING=1 or --guest-onboarding). Read-only; the same
// decision is stamped onto every backend the app spawns.
guestOnboardingEnabled: launchFlags?.guestOnboarding === true,
localSkin: localSkin && typeof localSkin === 'object' ? localSkin : null,
getConnection: (profile, opts) => ipcRenderer.invoke('hermes:connection', profile, opts),
// Registry-scoped backend resolution: { connectionId, profile } → descriptor.
getConnectionFor: payload => ipcRenderer.invoke('hermes:connection:for', payload),
getProfileRoutes: profiles => ipcRenderer.invoke('hermes:plugin-profile-routes', profiles),
revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'),
touchBackend: (profile, options) => ipcRenderer.invoke('hermes:backend:touch', profile, options),
getPoolLimits: () => ipcRenderer.invoke('hermes:pool-limits:get'),
setPoolLimits: limits => ipcRenderer.invoke('hermes:pool-limits:set', limits),
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
// Registry-scoped fresh WS URL: { connectionId, profile } → result shape of
// getGatewayWsUrl, minted against that connection's backend.
getGatewayWsUrlFor: payload => ipcRenderer.invoke('hermes:gateway:ws-url-for', payload),
// Union agent roster across every registered connection.
getAgentRoster: () => ipcRenderer.invoke('hermes:agents:roster'),
openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts),
openSessionInTerminal: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openInTerminal', sessionId, opts),
openWindow: (options?: DesktopProfileRoute) => ipcRenderer.invoke('hermes:window:openInstance', options),
openBrowserWindow: tabId => ipcRenderer.invoke('hermes:window:openBrowser', tabId),
onBrowserPopoutClosed: callback => {
const listener = (_event, tabId) => callback(tabId)
ipcRenderer.on('hermes:browser-popout:closed', listener)
return () => ipcRenderer.removeListener('hermes:browser-popout:closed', listener)
},
claimAmbientCue: key => ipcRenderer.invoke('hermes:ambient:claim', key),
windowControls: {
custom: customWindowControlsEnabled(),
minimize: () => ipcRenderer.send('hermes:window-control', 'minimize'),
toggleMaximize: () => ipcRenderer.send('hermes:window-control', 'toggle-maximize'),
close: () => ipcRenderer.send('hermes:window-control', 'close')
},
wakeIndicator: {
getState: () => ipcRenderer.invoke('hermes:wake-indicator:get'),
setState: state => ipcRenderer.send('hermes:wake-indicator:set', state),
onState: callback => {
const listener = (_event, state) => callback(state)
ipcRenderer.on('hermes:wake-indicator:state', listener)
return () => ipcRenderer.removeListener('hermes:wake-indicator:state', listener)
}
},
chatOnboarding: {
grow: request => ipcRenderer.send('hermes:chat-onboarding:grow', request),
soloBoot: () => ipcRenderer.send('hermes:chat-onboarding:solo-boot')
},
petOverlay: {
// Main renderer → main process: window lifecycle + drag. `request` is
// `{ bounds, screen }`; resolves with the screen bounds it actually used.
open: request => ipcRenderer.invoke('hermes:pet-overlay:open', request),
close: () => ipcRenderer.invoke('hermes:pet-overlay:close'),
setBounds: bounds => ipcRenderer.send('hermes:pet-overlay:set-bounds', bounds),
setIgnoreMouse: ignore => ipcRenderer.send('hermes:pet-overlay:ignore-mouse', ignore),
// Flip the overlay focusable (and focus it) while the composer needs keys.
setFocusable: focusable => ipcRenderer.send('hermes:pet-overlay:set-focusable', focusable),
// Main renderer → overlay (forwarded by main): push the latest pet state.
pushState: payload => ipcRenderer.send('hermes:pet-overlay:state', payload),
// Overlay → main renderer (forwarded by main): pop back in / composer submit.
control: payload => ipcRenderer.send('hermes:pet-overlay:control', payload),
// Overlay subscribes to state pushes.
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)
}
},
// HUD mode: the chrome-free floating chat. A full app renderer (own gateway)
// sized as a floating bar, so it mounts the real composer. Main owns the
// window; `onChanged` keeps every window's toggle truthful.
hud: {
nativeDrag: hudNativeDrag,
windowing: {
clientPlacement: hudWindowing?.clientPlacement !== false,
controlDrag: hudWindowing?.controlDrag === true,
nativeDrag: hudNativeDrag,
solid: hudWindowing?.solid === true,
workspaceTransfer: hudWindowing?.workspaceTransfer === true
},
open: request => ipcRenderer.invoke('hermes:hud:open', request),
close: () => ipcRenderer.invoke('hermes:hud:close'),
setIgnoreMouse: ignore => ipcRenderer.send('hermes:hud:ignore-mouse', ignore),
beginMove: () => ipcRenderer.send('hermes:hud:begin-move'),
endMove: () => ipcRenderer.send('hermes:hud:end-move'),
moveBy: delta => ipcRenderer.send('hermes:hud:move-by', delta),
setWorkspaceTransfer: transferring => ipcRenderer.send('hermes:hud:workspace-transfer', transferring),
setBounds: bounds => ipcRenderer.send('hermes:hud:set-bounds', bounds),
resetLayout: () => ipcRenderer.invoke('hermes:hud:reset-layout'),
// Whether the band covers the window below the bar. Main pairs it with the
// user's translucency setting to decide the native frost (macOS vibrancy /
// Windows 11 DWM backdrop) — see hudFrostFor.
setFrost: showing => ipcRenderer.invoke('hermes:hud:frost', showing),
// The HUD tells main which session it is on; main hands that back to the
// app window when the HUD closes, so the app can re-home onto it.
setSession: sessionId => ipcRenderer.send('hermes:hud:session', sessionId),
onGoto: callback => {
const listener = (_event, sessionId) => callback(sessionId)
ipcRenderer.on('hermes:hud:goto', listener)
return () => ipcRenderer.removeListener('hermes:hud:goto', listener)
},
onChanged: callback => {
const listener = (_event, state) => callback(state)
ipcRenderer.on('hermes:hud:changed', listener)
return () => ipcRenderer.removeListener('hermes:hud:changed', listener)
},
// Linux only, and silent elsewhere: where the cursor is, in page
// coordinates, or null when it has left the window. Stands in for the
// mousemove that `setIgnoreMouseEvents(true, { forward: true })` delivers on
// macOS and Windows but not here.
onCursor: callback => {
const listener = (_event, point) => callback(point)
ipcRenderer.on('hermes:hud:cursor', listener)
return () => ipcRenderer.removeListener('hermes:hud:cursor', listener)
},
// Main's game-overlay watch: whether a fullscreen app (a game) is under
// the HUD, so the renderer can step back to the low-opacity overlay
// treatment while one owns the screen.
onGameOverlay: callback => {
const listener = (_event, state) => callback(state)
ipcRenderer.on('hermes:hud:game-overlay', listener)
return () => ipcRenderer.removeListener('hermes:hud:game-overlay', listener)
}
},
hudModifier: {
getSettings: () => ipcRenderer.invoke('hermes:hud-modifier:settings:get'),
setEnabled: enabled => ipcRenderer.invoke('hermes:hud-modifier:settings:set', enabled),
openPermissionSettings: () => ipcRenderer.invoke('hermes:hud-modifier:permission'),
onStatus: callback => {
const listener = (_event: Electron.IpcRendererEvent, status: HudModifierStatus) => callback(status)
ipcRenderer.on('hermes:hud-modifier:status', listener)
return () => ipcRenderer.removeListener('hermes:hud-modifier:status', listener)
}
} satisfies HudModifierApi,
// macOS native screenshot gesture; captures require a main-issued request.
screenshot:
process.platform === 'darwin'
? {
getSettings: () => ipcRenderer.invoke('hermes:screenshot:settings:get'),
setEnabled: enabled => ipcRenderer.invoke('hermes:screenshot:settings:set', enabled),
openPermissionSettings: kind => ipcRenderer.invoke('hermes:screenshot:permission', kind),
capture: requestId => ipcRenderer.invoke('hermes:screenshot:capture', requestId),
onStatus: callback => {
const listener = (_event, status) => callback(status)
ipcRenderer.on('hermes:screenshot:status', listener)
return () => ipcRenderer.removeListener('hermes:screenshot:status', listener)
},
onRequest: callback => {
const channel = 'hermes:screenshot:request'
const listener = (_event, requestId) => callback(requestId)
if (ipcRenderer.listenerCount(channel) === 0) {
ipcRenderer.send('hermes:screenshot:subscribe', true)
}
ipcRenderer.on(channel, listener)
return () => {
ipcRenderer.removeListener(channel, listener)
if (ipcRenderer.listenerCount(channel) === 0) {
ipcRenderer.send('hermes:screenshot:subscribe', false)
}
}
}
}
: undefined,
// Quick Entry: the global-hotkey mini composer window. Main owns the OS
// shortcut + the persisted preference; the quick window only captures text
// and hands it back, and the primary renderer submits it through the normal
// prompt path.
quickEntry: {
getSettings: () => ipcRenderer.invoke('hermes:quick-entry:settings:get'),
setSettings: patch => ipcRenderer.invoke('hermes:quick-entry:settings:set', patch),
submit: payload => ipcRenderer.send('hermes:quick-entry:submit', payload),
dismiss: () => ipcRenderer.send('hermes:quick-entry:dismiss'),
// Primary renderer → main → quick window: gateway connection state + the
// recent-session options the target picker offers. Main caches the latest
// payload so a freshly spawned quick window starts from truth.
pushState: payload => ipcRenderer.send('hermes:quick-entry:state', payload),
// Quick window subscribes to those pushes.
onState: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:quick-entry:state', listener)
return () => ipcRenderer.removeListener('hermes:quick-entry:state', listener)
},
// Main → primary renderer: a submit captured by the quick window.
onSubmit: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:quick-entry:submit', listener)
return () => ipcRenderer.removeListener('hermes:quick-entry:submit', listener)
},
// Main → quick window: you were just summoned (reset draft + refocus).
onShown: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:quick-entry:shown', listener)
return () => ipcRenderer.removeListener('hermes:quick-entry:shown', listener)
}
},
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload),
testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload),
// Opt-in OS-keychain encryption for stored gateway secrets (default off —
// see secret-storage-policy.ts). get never touches the OS keychain.
getSecretStorageEncryption: () => ipcRenderer.invoke('hermes:secret-storage:get'),
setSecretStorageEncryption: (on: boolean) => ipcRenderer.invoke('hermes:secret-storage:set', on),
// v2 multi-connection registry: named agent sources (local / remote / cloud / ssh).
connections: {
list: () => ipcRenderer.invoke('hermes:connections:list'),
save: payload => ipcRenderer.invoke('hermes:connections:save', payload),
remove: id => ipcRenderer.invoke('hermes:connections:remove', id),
setPrimary: id => ipcRenderer.invoke('hermes:connections:set-primary', id),
setLaunchMode: mode => ipcRenderer.invoke('hermes:connections:set-launch-mode', mode),
setLastUsed: id => ipcRenderer.invoke('hermes:connections:set-last-used', id),
test: id => ipcRenderer.invoke('hermes:connections:test', id),
updateManaged: id => ipcRenderer.invoke('hermes:connections:update-managed', id),
// Fan out `hermes update` to every eligible registered connection.
// Optional excludeIds skips rows the caller updates through another path.
updateAll: options => ipcRenderer.invoke('hermes:connections:update-all', options),
// Registry lifecycle push (main → renderer): a connection was removed or
// materially edited, so secondaries scoped to it must be disposed (and,
// for edits, re-dialed at the new target).
onChanged: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:connections:changed', listener)
return () => ipcRenderer.removeListener('hermes:connections:changed', listener)
}
},
sshConfigHosts: () => ipcRenderer.invoke('hermes:ssh-config:hosts'),
sshResolveHost: host => ipcRenderer.invoke('hermes:ssh-config:resolve', host),
probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl),
// `options` lets a registry-editor draft sign in BEFORE it is saved: the
// main process settles the draft's connection id up front so the login
// window writes into the per-connection cookie jar the saved entry will
// read (not the legacy shared jar an unsaved URL would fall back to).
oauthLoginConnectionConfig: (remoteUrl, options) =>
ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl, options),
oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl),
// Hermes Cloud: one portal login powers discovery + silent per-agent sign-in
// (cloud-auto-discovery Phase 3).
cloud: {
status: () => ipcRenderer.invoke('hermes:cloud:status'),
login: () => ipcRenderer.invoke('hermes:cloud:login'),
logout: () => ipcRenderer.invoke('hermes:cloud:logout'),
discover: org => ipcRenderer.invoke('hermes:cloud:discover', org),
agentSignIn: dashboardUrl => ipcRenderer.invoke('hermes:cloud:agent-sign-in', dashboardUrl)
},
profile: {
getDefault: () => ipcRenderer.invoke('hermes:profile:default:get'),
setDefault: (route: DesktopProfileRoute) => ipcRenderer.invoke('hermes:profile:default:set', route),
onDefaultChanged: (callback: (route: DesktopProfileRoute | null) => void) => {
const listener = (_event: Electron.IpcRendererEvent, route: DesktopProfileRoute | null) => callback(route)
ipcRenderer.on('hermes:profile:default:changed', listener)
return () => ipcRenderer.removeListener('hermes:profile:default:changed', listener)
},
get: () => ipcRenderer.invoke('hermes:profile:get'),
remember: name => ipcRenderer.invoke('hermes:profile:remember', name),
set: name => ipcRenderer.invoke('hermes:profile:set', name)
},
api: request => ipcRenderer.invoke('hermes:api', request),
notify: payload => ipcRenderer.invoke('hermes:notify', payload),
claimStartupLatency: () => ipcRenderer.invoke('hermes:startup-latency:claim'),
requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'),
readWindowBelow: () => ipcRenderer.invoke('hermes:window:readBelow'),
readFileDataUrl: filePath => ipcRenderer.invoke('hermes:readFileDataUrl', filePath),
readFileDataUrlForAttach: filePath => ipcRenderer.invoke('hermes:readFileDataUrlForAttach', filePath),
dataUrlReadMax: {
get: () => ipcRenderer.invoke('hermes:data-url-read-max:get'),
set: maxMb => ipcRenderer.invoke('hermes:data-url-read-max:set', maxMb)
},
readFileText: filePath => ipcRenderer.invoke('hermes:readFileText', filePath),
readPluginSource: (filePath: string) => ipcRenderer.invoke('hermes:readPluginSource', filePath),
selectPaths: options => ipcRenderer.invoke('hermes:selectPaths', options),
selectSavePath: options => ipcRenderer.invoke('hermes:selectSavePath', options),
writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text),
readClipboard: () => ipcRenderer.invoke('hermes:readClipboard'),
saveGatewayFile: payload => ipcRenderer.invoke('hermes:saveGatewayFile', payload),
saveImageFromUrl: url => ipcRenderer.invoke('hermes:saveImageFromUrl', url),
contextMenuEdit: command => ipcRenderer.invoke('hermes:context-menu:edit', command),
contextMenuCopyImage: () => ipcRenderer.invoke('hermes:context-menu:copy-image'),
contextMenuSpellcheck: action => ipcRenderer.invoke('hermes:context-menu:spellcheck', action),
contextMenuGuestAddWord: payload => ipcRenderer.invoke('hermes:context-menu:guest-add-word', payload),
onContextMenuSpellcheck: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:context-menu-spellcheck', listener)
return () => ipcRenderer.removeListener('hermes:context-menu-spellcheck', listener)
},
saveImageBuffer: (data, ext, name) => ipcRenderer.invoke('hermes:saveImageBuffer', { data, ext, name }),
capturePreview: payload => ipcRenderer.invoke('hermes:capturePreview', payload),
savePastedText: text => ipcRenderer.invoke('hermes:savePastedText', { text }),
saveClipboardImage: () => ipcRenderer.invoke('hermes:saveClipboardImage'),
getPathForFile: file => {
try {
return webUtils.getPathForFile(file) || ''
} catch {
return ''
}
},
normalizePreviewTarget: (target, baseDir) => ipcRenderer.invoke('hermes:normalizePreviewTarget', target, baseDir),
watchPreviewFile: url => ipcRenderer.invoke('hermes:watchPreviewFile', url),
watchDirectory: dir => ipcRenderer.invoke('hermes:watchDirectory', dir),
stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id),
setActiveWork: payload => ipcRenderer.send('hermes:active-work', payload),
setTitleBarTheme: payload => ipcRenderer.send('hermes:titlebar-theme', payload),
setNativeTheme: mode => ipcRenderer.send('hermes:native-theme', mode),
setTranslucency: payload => ipcRenderer.send('hermes:translucency', payload),
setKeepAwake: on => ipcRenderer.send('hermes:keep-awake', on),
minimizeToTray: {
get: () => ipcRenderer.invoke('hermes:minimize-to-tray:get'),
set: on => ipcRenderer.invoke('hermes:minimize-to-tray:set', on),
onChanged: callback => {
const listener = (_event, status) => callback(status)
ipcRenderer.on('hermes:minimize-to-tray:changed', listener)
return () => ipcRenderer.removeListener('hermes:minimize-to-tray:changed', listener)
}
},
setDisableF12: blocked => ipcRenderer.send('hermes:devtools:disable-f12', blocked),
setF12ShortcutActive: active => ipcRenderer.send('hermes:f12ShortcutActive', Boolean(active)),
onF12Shortcut: callback => {
const listener = (_event, input) => callback(input)
ipcRenderer.on('hermes:f12-shortcut', listener)
return () => ipcRenderer.removeListener('hermes:f12-shortcut', listener)
},
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
mcpOauth: {
// One-shot loopback listener for MCP OAuth against remote backends: bind
// on this machine, hand redirectUri to mcp.servers.oauth.start, then wait
// for the provider redirect and relay code/state via oauth.callback.
listen: () => ipcRenderer.invoke('hermes:mcp-oauth:listen'),
wait: (id, timeoutMs) => ipcRenderer.invoke('hermes:mcp-oauth:wait', id, timeoutMs),
cancel: id => ipcRenderer.invoke('hermes:mcp-oauth:cancel', id)
},
openPreviewInBrowser: url => ipcRenderer.invoke('hermes:openPreviewInBrowser', url),
reachPreviewUrl: url => ipcRenderer.invoke('hermes:preview:reach', url),
setActiveConnectionRoute: route => ipcRenderer.send('hermes:connection:active-route', route),
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
resolveFavicon: url => ipcRenderer.invoke('hermes:resolveFavicon', url),
sanitizeWorkspaceCwd: cwd => ipcRenderer.invoke('hermes:workspace:sanitize', cwd),
settings: {
getDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:get'),
setDefaultProjectDir: dir => ipcRenderer.invoke('hermes:setting:defaultProjectDir:set', dir),
pickDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:pick')
},
zoom: {
// Current zoom of this window, as { level, percent }.
get: () => ipcRenderer.invoke('hermes:zoom:get'),
// Synchronous zoom factor (1 = 100%). Coordinate math needs it in the
// same tick as the event it converts, so no IPC round-trip here.
factor: () => webFrame.getZoomFactor(),
setPercent: percent => ipcRenderer.send('hermes:zoom:set-percent', percent),
// Fires on every zoom change, including the Ctrl/Cmd +/-/0 shortcuts,
// so the settings UI can stay in sync with the keyboard.
onChanged: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:zoom:changed', listener)
return () => ipcRenderer.removeListener('hermes:zoom:changed', listener)
}
},
revealLogs: () => ipcRenderer.invoke('hermes:logs:reveal'),
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
// Fire-and-forget: persists a renderer error-boundary catch (with component
// stack) to desktop.log so crashes survive the window (#79428).
reportRendererError: report => ipcRenderer.send('hermes:logs:renderer-error', report),
logLine: (line: string): void => ipcRenderer.send('hermes:logs:renderer-line', line),
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath),
openDir: dirPath => ipcRenderer.invoke('hermes:fs:openDir', dirPath),
desktopPluginsRoot: () => ipcRenderer.invoke('hermes:fs:desktopPluginsRoot'),
reconcileDesktopPlugins: () => ipcRenderer.invoke('hermes:fs:reconcileDesktopPlugins'),
logsRoot: (profile?: string) => ipcRenderer.invoke('hermes:fs:logsRoot', profile),
renamePath: (targetPath, newName) => ipcRenderer.invoke('hermes:fs:rename', targetPath, newName),
writeTextFile: (filePath, content) => ipcRenderer.invoke('hermes:fs:writeText', filePath, content),
trashPath: targetPath => ipcRenderer.invoke('hermes:fs:trash', targetPath),
git: {
worktreeList: repoPath => ipcRenderer.invoke('hermes:git:worktreeList', repoPath),
worktreeAdd: (repoPath, options) => ipcRenderer.invoke('hermes:git:worktreeAdd', repoPath, options),
worktreeRemove: (repoPath, worktreePath, options) =>
ipcRenderer.invoke('hermes:git:worktreeRemove', repoPath, worktreePath, options),
branchSwitch: (repoPath, branch) => ipcRenderer.invoke('hermes:git:branchSwitch', repoPath, branch),
branchList: repoPath => ipcRenderer.invoke('hermes:git:branchList', repoPath),
baseBranchList: repoPath => ipcRenderer.invoke('hermes:git:baseBranchList', repoPath),
repoStatus: repoPath => ipcRenderer.invoke('hermes:git:repoStatus', repoPath),
fileDiff: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:fileDiff', repoPath, filePath),
scanRepos: (roots, options) => ipcRenderer.invoke('hermes:git:scanRepos', roots, options),
review: {
list: (repoPath, scope, baseRef) => ipcRenderer.invoke('hermes:git:review:list', repoPath, scope, baseRef),
diff: (repoPath, filePath, scope, baseRef, staged) =>
ipcRenderer.invoke('hermes:git:review:diff', repoPath, filePath, scope, baseRef, staged),
stage: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:stage', repoPath, filePath),
unstage: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:unstage', repoPath, filePath),
revert: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:revert', repoPath, filePath),
revParse: (repoPath, ref) => ipcRenderer.invoke('hermes:git:review:revParse', repoPath, ref),
commit: (repoPath, message, push) => ipcRenderer.invoke('hermes:git:review:commit', repoPath, message, push),
commitContext: repoPath => ipcRenderer.invoke('hermes:git:review:commitContext', repoPath),
push: repoPath => ipcRenderer.invoke('hermes:git:review:push', repoPath),
shipInfo: repoPath => ipcRenderer.invoke('hermes:git:review:shipInfo', repoPath),
prList: (repoPath, branches, numbers) =>
ipcRenderer.invoke('hermes:git:review:prList', repoPath, branches, numbers),
createPr: repoPath => ipcRenderer.invoke('hermes:git:review:createPr', repoPath)
}
},
terminal: {
attach: id => ipcRenderer.invoke('hermes:terminal:attach', id),
cwd: id => ipcRenderer.invoke('hermes:terminal:cwd', id),
dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id),
resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size),
start: options => ipcRenderer.invoke('hermes:terminal:start', options),
write: (id, data) => ipcRenderer.invoke('hermes:terminal:write', id, data),
onData: (id, callback) => {
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)
},
onPreviewNav: callback => {
const listener = (_event, command) => callback(command)
ipcRenderer.on('hermes:preview-nav', listener)
return () => ipcRenderer.removeListener('hermes:preview-nav', listener)
},
onOpenFolderRequested: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:open-folder-requested', listener)
return () => ipcRenderer.removeListener('hermes:open-folder-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'),
probePluginRepo: payload => ipcRenderer.invoke('hermes:plugin:probe', payload),
installDesktopPlugin: payload => ipcRenderer.invoke('hermes:plugin:installDesktop', payload),
removeDesktopPlugin: payload => ipcRenderer.invoke('hermes:plugin:removeDesktop', payload),
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)
},
onNotificationActivate: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:notification-activate', listener)
return () => ipcRenderer.removeListener('hermes:notification-activate', listener)
},
onExternalOpenFailed: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:external-open-failed', listener)
return () => ipcRenderer.removeListener('hermes:external-open-failed', 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)
},
// Cooperative pool retirement (main → renderer): the pooled backend under
// `poolKey` is being stopped for a foreground open. Park that scope; do not
// redial into the slot it vacated.
onPoolBackendRetiring: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:pool:retiring', listener)
return () => ipcRenderer.removeListener('hermes:pool:retiring', listener)
},
// Soft gateway-mode apply finished tearing down the primary backend. Renderer
// should wipe session lists + re-dial without a window reload.
onConnectionApplied: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:connection:applied', listener)
return () => ipcRenderer.removeListener('hermes:connection:applied', listener)
},
onPowerResume: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:power-resume', listener)
return () => ipcRenderer.removeListener('hermes:power-resume', listener)
},
// AC ↔ battery transitions; renderers slow their backstop polls on battery.
getOnBattery: () => ipcRenderer.invoke('hermes:power-battery:get'),
onBatteryChanged: callback => {
const listener = (_event, onBattery) => callback(Boolean(onBattery))
ipcRenderer.on('hermes:power-battery', listener)
return () => ipcRenderer.removeListener('hermes:power-battery', 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.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.
getBootstrapState: () => ipcRenderer.invoke('hermes:bootstrap:get'),
probeLocalBackend: () => ipcRenderer.invoke('hermes:local-backend:probe'),
continueBootstrapLocal: () => ipcRenderer.invoke('hermes:bootstrap:continue-local'),
recycleBackend: profile => ipcRenderer.invoke('hermes:backend:recycle', profile),
resetBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:reset'),
repairBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:repair'),
cancelBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:cancel'),
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'),
relaunchApp: () => ipcRenderer.invoke('hermes:app:relaunch'),
getMachineProfile: () => ipcRenderer.invoke('hermes:machine:profile'),
getRemoteDisplayReason: () => ipcRenderer.invoke('hermes:get-remote-display-reason'),
uninstall: {
summary: () => ipcRenderer.invoke('hermes:uninstall:summary'),
run: mode => ipcRenderer.invoke('hermes:uninstall:run', { mode })
},
updates: {
check: opts => ipcRenderer.invoke('hermes:updates:check', opts),
apply: opts => ipcRenderer.invoke('hermes:updates:apply', opts),
getBranch: () => ipcRenderer.invoke('hermes:updates:branch:get'),
setBranch: name => ipcRenderer.invoke('hermes:updates:branch:set', name),
onProgress: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:updates:progress', listener)
return () => ipcRenderer.removeListener('hermes:updates:progress', listener)
},
takePendingRun: () => ipcRenderer.invoke('hermes:updates:metric:take'),
ackPendingRun: sent => ipcRenderer.invoke('hermes:updates:metric:ack', sent),
onPendingRun: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:updates:metric:pending', listener)
return () => ipcRenderer.removeListener('hermes:updates:metric:pending', listener)
}
},
desktopMetrics: {
setEnabled: (on, profile) => ipcRenderer.invoke('hermes:desktop-metrics:set-enabled', on, profile),
takeRendererCrashes: () => ipcRenderer.invoke('hermes:desktop-metrics:crash:take'),
ackRendererCrashes: sent => ipcRenderer.invoke('hermes:desktop-metrics:crash:ack', sent)
},
themes: {
fetchMarketplace: id => ipcRenderer.invoke('hermes:vscode-theme:fetch', id),
searchMarketplace: query => ipcRenderer.invoke('hermes:vscode-theme:search', query)
},
// Find-in-page (Ctrl/Cmd+F): delegates to Electron's
// webContents.findInPage on the IPC sender's window so a Cmd+F pressed
// in a secondary session window searches THAT window, not the primary.
// `onFoundInPage` returns the unsubscribe fn; the renderer wires it via
// `initFindInPageListener` in store/find-in-page.ts and tears it down
// when the FindBar unmounts.
findInPage: (query, options) => ipcRenderer.invoke('hermes:find-in-page', query, options),
stopFindInPage: () => ipcRenderer.invoke('hermes:stop-find-in-page'),
onFoundInPage: callback => {
const listener = (_event, result) => callback(result)
ipcRenderer.on('hermes:found-in-page', listener)
return () => ipcRenderer.removeListener('hermes:found-in-page', listener)
},
// Main-process `before-input-event` forwards Ctrl/Cmd+F here so renderer
// can open the FindBar even when the GTK compositor has already grabbed
// the chord at the windowing layer (#81727).
onOpenFindBarRequested: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:open-find-bar', listener)
return () => ipcRenderer.removeListener('hermes:open-find-bar', listener)
}
})