feat(desktop): electron — openDir IPC + ⌘W menu bridge (tabs, not windows)

fix/verification-admin-route-recovery
Brooklyn Nicholson 2026-07-13 17:28:32 -04:00
parent 7f74b324c3
commit 10fbade64b
5 changed files with 179 additions and 20 deletions

View File

@ -3597,9 +3597,35 @@ async function ensureRuntime(backend) {
return backend
}
// Assemble a single-file multipart/form-data body (FastAPI `UploadFile`
// endpoints, e.g. kanban attachments). Hand-rolled because node's http has no
// FormData and the payload is one file — a dependency would be overkill.
function multipartBody(upload) {
const boundary = `----hermes-${crypto.randomBytes(12).toString('hex')}`
const filename = String(upload.filename || 'file').replace(/["\r\n]/g, '_')
const body = Buffer.concat([
Buffer.from(
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="file"; filename="${filename}"\r\n` +
`Content-Type: ${upload.contentType || 'application/octet-stream'}\r\n\r\n`
),
Buffer.from(upload.bytes),
Buffer.from(`\r\n--${boundary}--\r\n`)
])
return { body, contentType: `multipart/form-data; boundary=${boundary}` }
}
function fetchJson(url, token, options: any = {}) {
return new Promise((resolve, reject) => {
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
const { body, contentType } = options.upload
? multipartBody(options.upload)
: {
body: options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)),
contentType: 'application/json'
}
const parsed = new URL(url)
const client = parsed.protocol === 'https:' ? https : http
const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
@ -3615,7 +3641,7 @@ function fetchJson(url, token, options: any = {}) {
{
method: options.method || 'GET',
headers: {
'Content-Type': 'application/json',
'Content-Type': contentType,
'X-Hermes-Session-Token': token,
...(body ? { 'Content-Length': String(body.length) } : {})
}
@ -4576,14 +4602,13 @@ function buildApplicationMenu() {
submenu: [
IS_MAC
? {
accelerator: 'CommandOrControl+W',
click: () => {
if (previewShortcutActive) {
sendClosePreviewRequested()
} else {
mainWindow?.close()
}
},
// NO accelerator: on macOS a registered ⌘W is consumed by the OS
// menu before the web contents ever sees it (and registerAccelerator
// false is a no-op on mac — electron#18295). Leaving it off lets the
// `before-input-event` handler below intercept ⌘W and route it to the
// renderer's close-active-tab. Clicking the item still closes the tab
// (or window) via the same request.
click: () => sendClosePreviewRequested(),
label: 'Close'
}
: { role: 'quit' }
@ -4689,9 +4714,12 @@ function installDevToolsShortcut(window) {
function installPreviewShortcut(window) {
window.webContents.on('before-input-event', (event, input) => {
const key = String(input.key || '').toLowerCase()
const isPreviewCloseShortcut = key === 'w' && (IS_MAC ? input.meta : input.control) && !input.alt && !input.shift
const isCloseTabShortcut = key === 'w' && (IS_MAC ? input.meta : input.control) && !input.alt && !input.shift
if (!isPreviewCloseShortcut || !previewShortcutActive) {
// Always claim ⌘W here (the File>Close item deliberately has no
// accelerator, so nothing else does). The renderer decides tab-vs-window
// — no `previewShortcutActive` gate, so it works for every closeable tab.
if (!isCloseTabShortcut) {
return
}
@ -7870,6 +7898,12 @@ ipcMain.handle('hermes:api', async (_event, request) => {
// session so the cookie attaches automatically. Token/local modes keep using
// the static session-token header.
if (connection.authMode === 'oauth') {
// The OAuth path rides electron.net with JSON headers; multipart isn't
// wired there. Fail loudly rather than corrupting the upload.
if (request?.upload) {
throw new Error('File uploads are not supported against OAuth-gated remote backends yet.')
}
return fetchJsonViaOauthSession(url, {
method: request?.method,
body: request?.body,
@ -7880,6 +7914,7 @@ ipcMain.handle('hermes:api', async (_event, request) => {
return fetchJson(url, connection.token, {
method: request?.method,
body: request?.body,
upload: request?.upload,
timeoutMs
})
})
@ -8380,6 +8415,28 @@ ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => {
}
})
// Open a DIRECTORY in the OS file manager, creating it first if needed. Unlike
// `reveal` (which selects an existing item and silently no-ops on a missing
// path — the "Open plugins folder" Windows bug), this is for the plugins door,
// which often doesn't exist on first use. `shell.openPath` returns '' on
// success or an error string; both mkdir + openPath failures are surfaced.
ipcMain.handle('hermes:fs:openDir', async (_event, dirPath) => {
const dir = String(dirPath || '').trim()
if (!dir) {
return { ok: false, error: 'no path' }
}
try {
await fs.promises.mkdir(dir, { recursive: true })
const error = await shell.openPath(path.normalize(dir))
return error ? { ok: false, error } : { ok: true }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) }
}
})
// Rename a file/folder in place. The renderer passes the existing path + a new
// base name; the destination is resolved in the SAME parent dir so a rename can
// never move the item elsewhere or traverse out. Rejects on a name collision.

View File

@ -107,6 +107,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
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),
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),

View File

@ -109,6 +109,8 @@ declare global {
gitRoot?: (path: string) => Promise<string | null>
// Reveal a path in the OS file manager (Finder / Explorer).
revealPath?: (path: string) => Promise<boolean>
// Open a DIRECTORY (created if missing) in the OS file manager.
openDir?: (path: string) => Promise<{ ok: boolean; error?: string }>
// Rename a file/folder in place (new base name, same parent dir).
renamePath?: (path: string, newName: string) => Promise<{ path: string }>
// Write a small UTF-8 text file (hardened path, parent must exist).
@ -606,6 +608,10 @@ export interface HermesApiRequest {
path: string
method?: string
body?: unknown
// Single-file multipart upload (FastAPI UploadFile endpoints). Mutually
// exclusive with `body`; bytes transfer over IPC as a structured-clone
// ArrayBuffer. Token-mode backends only.
upload?: { filename: string; contentType?: string; bytes: ArrayBuffer }
timeoutMs?: number
// Route this REST call to a specific profile's backend. Omit for the primary
// (window) backend. Read-only cross-profile data is served by the primary, so

View File

@ -195,6 +195,109 @@ function profileScoped(): { profile?: string } {
return _apiProfile ? { profile: _apiProfile } : {}
}
/** Options for a plugin REST call mirrors the app's own `hermesDesktop.api`
* shape, minus the path (which is namespace-derived). */
export interface PluginRestOptions {
method?: string
body?: unknown
/** Single-file multipart upload (see HermesApiRequest.upload). */
upload?: { filename: string; contentType?: string; bytes: ArrayBuffer }
timeoutMs?: number
}
// Normalize `path` to a leading-slash suffix relative to `/api/plugins/<id>`.
// The namespace is the boundary — reject `..` so a relative segment can't
// normalize out into another plugin's API or a core route. Check the path
// portion only (before any query/hash).
function pluginPathSuffix(caller: string, path: string): string {
const suffix = path.startsWith('/') ? path : `/${path}`
if (suffix.split(/[?#]/, 1)[0].split('/').includes('..')) {
throw new Error(`${caller}: illegal path traversal in "${path}"`)
}
return suffix
}
/** The plugin REST door. Every call is scoped BY CONSTRUCTION to the plugin's
* own backend namespace `path` is relative to `/api/plugins/<pluginId>`
* ('/board' `/api/plugins/kanban/board`), so a plugin can't address another
* plugin's API or a core route through it. Profile-aware like every desktop
* REST call. Broader reach (core endpoints, another namespace) is the future
* declared-capability seam; today the namespace IS the boundary. */
export async function pluginRest<T>(pluginId: string, path: string, opts: PluginRestOptions = {}): Promise<T> {
if (!window.hermesDesktop?.api) {
throw new Error('Hermes desktop bridge unavailable')
}
const suffix = pluginPathSuffix('pluginRest', path)
return window.hermesDesktop.api<T>({
path: `/api/plugins/${pluginId}${suffix}`,
method: opts.method,
body: opts.body,
upload: opts.upload,
timeoutMs: opts.timeoutMs,
...profileScoped()
})
}
/** The plugin WebSocket door the live twin of `pluginRest`, scoped the same
* way: `path` is relative to `/api/plugins/<pluginId>` ('/events' the
* plugin's own event stream). Token-mode backends auth via the same query
* credential the app's own sockets use; OAuth remotes resolve null (callers
* keep their polling fallback every consumer must have one anyway, since a
* socket can drop). Auto-reconnects with backoff until disposed. */
export function pluginSocket(pluginId: string, path: string, onMessage: (data: unknown) => void): () => void {
const suffix = pluginPathSuffix('pluginSocket', path)
let socket: null | WebSocket = null
let disposed = false
let attempt = 0
const connect = async () => {
const connection = await window.hermesDesktop.getConnection().catch(() => null)
// No bridge / OAuth cookie auth (WS tickets are single-use, core-managed):
// stay on the polling fallback rather than half-working.
if (disposed || !connection || connection.authMode === 'oauth') {
return
}
const base = connection.baseUrl.replace(/^http/, 'ws')
const join = suffix.includes('?') ? '&' : '?'
socket = new WebSocket(
`${base}/api/plugins/${pluginId}${suffix}${join}token=${encodeURIComponent(connection.token)}`
)
socket.onmessage = event => {
attempt = 0
try {
onMessage(JSON.parse(String(event.data)))
} catch {
// Non-JSON frame — plugin streams are JSON by contract; skip it.
}
}
socket.onclose = () => {
socket = null
if (!disposed) {
attempt += 1
window.setTimeout(() => void connect(), Math.min(30_000, 1_000 * 2 ** attempt))
}
}
}
void connect()
return () => {
disposed = true
socket?.close()
}
}
export async function listSessions(
limit = 40,
minMessages = 0,

View File

@ -17,18 +17,10 @@ import { ThemeProvider } from './themes/context'
installClipboardShim()
// Dev-only: install __PERF_DRIVE__ + __PERF_PROBE__ on window so the
// scripts/ harnesses can drive a synthetic stream + record render cost.
// Tree-shaken out of production builds. (Uses MODE rather than DEV because
// our Vite setup currently bundles with PROD=true even in `vite dev`; see
// scripts/dev-no-hmr.mjs for the surrounding workarounds.)
if (import.meta.env.MODE !== 'production') {
import('./app/chat/perf-probe')
}
// The pet overlay rides this same bundle (`?win=overlay`) but mounts a tiny,
// transparent, gateway-less surface instead of the full app. Branch before any
// app-shell work so the overlay window stays cheap.
if (new URLSearchParams(window.location.search).get('win') === 'overlay') {
void import('./app/pet-overlay/overlay-root').then(({ mountPetOverlay }) => mountPetOverlay())
} else {