feat(desktop): electron — openDir IPC + ⌘W menu bridge (tabs, not windows)
parent
7f74b324c3
commit
10fbade64b
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue