0e927c914d
* feat(desktop): port guided onboarding substrate Add seeded session creation, transcript directives, profile routing, and the shared window and pane primitives needed by the guided flow. Keep later-step mounts deferred and exclude provider selection and retry machinery. * refactor(desktop): anti-slop cleanup for substrate Assemble seed parameters in the existing create helper and use the owning transcript attribute type. Read the guaranteed gateway and connection contracts directly to remove runtime type probes and unchecked assertions. * test(desktop): create-overrides invariants Verify that reasoning and title overrides do not select a provider or model. Empty overrides and seeds add no parameters. * feat(desktop): port first-run cinematic window Play the cinematic behind the guest onboarding launch flag using bundled Collapse and JetBrains Mono. Give the native window its own controller and restore the app on skip, renderer deadman or native watchdog. Drop the perf scenario because it depends on the removed replay hook. Guided chat kickoff and app-shell gate wiring remain with their later steps. * refactor(desktop): anti-slop cleanup for cinematic Preserve audio and canvas behavior through named types and inferred results. Split the viewport node and frame drawing to keep control flow bounded. Cut comments that only repeat the code. * feat(desktop): add onboarding gate and answers stores Track cinematic, guided chat, handoff and completion in one phase record. Queue the guide after the intro and share pending kickoff work between callers. Keep existing saved answers while dropping retired preferences. Leave intro seen-state ownership with the cinematic store. * feat(desktop): port guided onboarding chat Add guided setup cards, runbooks, machine context, and onboarding presence. Connect transcript rendering and first-build progress to the desktop behind the onboarding flag. Leave session kickoff and handoff execution for the next step. * refactor(desktop): anti-slop cleanup for guided chat Keep directive and layout lookups typed. Remove unsafe test casts and isolate onboarding transcript calculations without changing the flow. * feat(desktop): connect guided onboarding to durable first-build handoff Start the guide only after its profile backend confirms bootstrap readiness. Seed or adopt the welcome chat, then transfer the first build to default with a durable receipt and explicit retry. Wire cinematic completion, screen stand-down, layout growth and progress check-ins. Save agreed preferences before creating the build and release prompt slots after storage refusal. * refactor(desktop): anti-slop cleanup for onboarding handoff Reuse the gateway request and error contracts. Isolate guide adoption and snapshot validation while preserving receipt recovery and reasoning overrides. Validate persisted receipt fields at the JSON boundary without coercion. Keep corrupt identities rejected and retain only the permitted test mocks. * fix(desktop): guided chat review fixes Wire the native machine probe so guided setup can suggest a name and offer the right first task. Restore the comments that explain the flow boundaries. The directive registration uses the launch flag to preserve ordinary chat. Ruling 6 folds active.ts into assembly to keep activity ownership together and removes the second greeting source so the seeded and visible greetings agree. * fix(desktop): handoff review fixes Probe the guide backend before switching profiles so a readiness refusal keeps classic onboarding on the current backend. Restore list-valued personalization coverage and routing rationale. Remove the obsolete setup status fixture. * chore(desktop): onboarding script cull and rehearsal recipe Document a temporary-state rehearsal using the existing onboarding flag and optional portal stand-in. Keep the main scripts unchanged and retain window growth for the guided chat. * fix(connectors): reject incomplete catalog responses * feat(gateway): scope connector controls to the owning session * feat(desktop): connect apps through native session-owned controls * feat(desktop): gate connector cards and enable free-tier access Use the launch flag before mounting connector controls so classic transcripts add no status requests. Allow existing free-tier identities through the read-only tool gateway gate and test the owning-profile RPC path with A’s launch gate. Keep authorization links out of previews. * style(desktop): format connector translations Apply Prettier to the connector copy blocks while preserving upstream translations and free-tier wording. * refactor(desktop): anti-slop cleanup for connector card Use the transcript JSON contract and concrete RPC parameters. Preserve malformed-value filtering at one string boundary and make the fixture and row types explicit. Keep connector execution and cancellation behavior unchanged. * feat(desktop): detect initial language from the OS Use the native machine locale when no supported language is saved. Preserve explicit choices and leave inferred languages out of config. * refactor(desktop): anti-slop cleanup for initial locale detection Keep unvalidated config values at the existing validation boundary. Pass no saved choice after that boundary has ruled it out, preserving locale precedence. * test(desktop): onboarding port test set Make native window tests reject duplicate IPC handlers and isolate disabled onboarding. Assert the active gate mock when onboarding re-enables. Keep the test set limited to behavior carried by the port. * fix(desktop): recover failed guide kickoff and reveal once The review found that a failed guide create stranded the solo shell and draft profile, and solo boot faded an already visible window a second time. Restore the prior route and layout, release onboarding through its existing phase record, and surface create failures. Let the film own the reveal while solo boot animates the visible resize. * fix(desktop): preserve transcript ownership across cards and handoff The review reproduced answers submitted to the focused chat, repeated questions disabled across sessions, handoff recovery using foreground identity, and mount-dependent progress history. Target each card’s own composer, scope settlement to its message and session, carry the issuing guide through handoff, and derive progress from its transcript with streaming activity. Reuse the existing owner ladder for exact and profile-only routes. * fix(gateway): preserve connector ownership with profile routing The review found that shared-primary profile metadata was rejected before connector dispatch, while desktop controls treated a missing registry id as missing ownership. Accept profile only as routing metadata and keep the live transport as authorization. Resolve card ownership through the existing exact/profile ladder, retaining ambient routing only for the single-backend case. * fix(desktop): resolve plugin roots and gate the Basic layout The review found that the first plugin build was seeded with a different installation’s fixed path, and the director ruled that flag-off layouts must match main. Resolve the running desktop’s plugin root before seeding a plugin build and register Basic only when onboarding is enabled. Keep the runbook wording and the ordinary four layout presets intact. * fix(desktop): clear review-fix slop findings The slop gate flagged an undocumented layout-data assertion and unknown-return types in the new test selectors. Record the layout registry invariant and preserve each selector’s return type. The only remaining production finding is the accepted connector-tools baseline. * fix(desktop): detect the OS language on a fresh install The review found that the merged English config default prevented the desktop from probing the OS language on a fresh install. Add an opt-in saved-values read so an absent choice remains distinct from saved English. Preserve default-valued English only for explicit language saves; unrelated settings saves must not turn a merged default into a language choice. Older backends ignore the new query options and keep returning merged English, preserving their existing desktop behavior. * test(desktop): make the flag-off layout registry test deterministic The flag-off test awaited the full controller import, pulling in the UI graph and installing application watchers just to read layout presets. That import took 9.5 seconds locally and timed out in the director's run. Move the existing trees and registration into a small layout-presets module. Production and the synchronous test use the same flag-gated registration, without starting the controller in the test. Keep the real registry invariant and dispose the test's contributions after completion. * fix(desktop): keep the transcript parser and ::ask behind the onboarding flag Register the guided chat's question card only with onboarding enabled. Restore main's whole-paragraph parser and contribution rendering when the flag is off, including its streaming prose behavior. Keep segmentation for the guided flow until B4 decides the parser's wider use. Restore main's two parser test files so its existing product and plugin contracts remain the flag-off check. * test: drop the onboarding and connector tests pending a later ticket Apply the director's ruling to remove B1's added test files and restore main's existing suites. Keep only the gateway route-reader mock contract that main's profile tests need against the shipped activation behavior; their cases and assertions stay intact. The flow's shape is not settled and B3/B4 rewrite it. The connector layer will also be reworked. The live CDP run is the flow check until a follow-up ticket brings tests back. --------- Co-authored-by: brooklyn! <brooklyn.bb.nicholson@gmail.com>
571 lines
31 KiB
TypeScript
571 lines
31 KiB
TypeScript
import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron'
|
|
|
|
// 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 = ipcRenderer.sendSync('hermes:launch-flags')
|
|
|
|
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,
|
|
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 => ipcRenderer.invoke('hermes:backend:touch', profile),
|
|
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: () => ipcRenderer.invoke('hermes:window:openInstance'),
|
|
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),
|
|
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')
|
|
},
|
|
introReveal: {
|
|
open: (payload?: { hideMain?: boolean }) => ipcRenderer.invoke('hermes:intro-reveal:open', payload),
|
|
close: (payload?: { showMain?: boolean }) => ipcRenderer.invoke('hermes:intro-reveal:close', payload),
|
|
skip: () => ipcRenderer.send('hermes:intro-reveal:skip'),
|
|
ready: () => ipcRenderer.send('hermes:intro-reveal:ready'),
|
|
onSkip: callback => {
|
|
const listener = () => callback()
|
|
|
|
ipcRenderer.on('hermes:intro-reveal:skip', listener)
|
|
|
|
return () => ipcRenderer.removeListener('hermes:intro-reveal:skip', listener)
|
|
},
|
|
onClosed: callback => {
|
|
const listener = () => callback()
|
|
|
|
ipcRenderer.on('hermes:intro-reveal:closed', listener)
|
|
|
|
return () => ipcRenderer.removeListener('hermes:intro-reveal:closed', listener)
|
|
}
|
|
},
|
|
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)
|
|
}
|
|
},
|
|
// 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),
|
|
oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl),
|
|
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: {
|
|
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),
|
|
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),
|
|
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),
|
|
setDisableF12: blocked => ipcRenderer.send('hermes:devtools:disable-f12', blocked),
|
|
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),
|
|
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: () => ipcRenderer.invoke('hermes:fs:logsRoot'),
|
|
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),
|
|
fetchPrComment: (repoPath, url) => ipcRenderer.invoke('hermes:git:review:fetchPrComment', repoPath, url),
|
|
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),
|
|
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)
|
|
},
|
|
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)
|
|
},
|
|
// 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'),
|
|
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)
|
|
}
|
|
},
|
|
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)
|
|
}
|
|
})
|