diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index e89e369ba..de2e7c68a 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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. diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 6c1ebc5bf..952fdfdc8 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -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), diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 0ac5ace52..a37091cee 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -109,6 +109,8 @@ declare global { gitRoot?: (path: string) => Promise // Reveal a path in the OS file manager (Finder / Explorer). revealPath?: (path: string) => Promise + // 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 diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index a902e4b53..517c7628b 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -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/`. +// 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/` + * ('/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(pluginId: string, path: string, opts: PluginRestOptions = {}): Promise { + if (!window.hermesDesktop?.api) { + throw new Error('Hermes desktop bridge unavailable') + } + + const suffix = pluginPathSuffix('pluginRest', path) + + return window.hermesDesktop.api({ + 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/` ('/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, diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 5b7621aa0..159270c01 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -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 {