From c25a3748bbf239c8d691644bb406ee6fd02464ec Mon Sep 17 00:00:00 2001 From: m4 Date: Thu, 17 Sep 2026 22:35:34 +0800 Subject: [PATCH] feat(desktop): hermes:fs:openExternal IPC with receipt-returning OS open Co-Authored-By: Claude Opus 4.7 --- .../electron/fs-ipc-open-external.test.ts | 82 +++++++++++++++++++ apps/desktop/electron/fs-ipc.ts | 22 ++++- apps/desktop/electron/main.ts | 4 +- apps/desktop/electron/preload.ts | 1 + apps/desktop/src/global.d.ts | 5 ++ 5 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/electron/fs-ipc-open-external.test.ts diff --git a/apps/desktop/electron/fs-ipc-open-external.test.ts b/apps/desktop/electron/fs-ipc-open-external.test.ts new file mode 100644 index 0000000000..9a54b18549 --- /dev/null +++ b/apps/desktop/electron/fs-ipc-open-external.test.ts @@ -0,0 +1,82 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { beforeEach, expect, it, vi } from 'vitest' + +const host = vi.hoisted(() => ({ + handle: vi.fn(), + opened: [] as string[] +})) + +vi.mock('electron', () => ({ + ipcMain: { handle: host.handle }, + shell: { + openPath: vi.fn(async (target: string) => { + host.opened.push(target) + + return '' + }), + showItemInFolder: vi.fn(), + trashItem: vi.fn() + } +})) + +import { registerFsIpc } from './fs-ipc' + +function registerDeps() { + return { + hermesHome: '/tmp/hermes-home', + readActiveDesktopProfile: () => null, + expandUserPath: (value: string) => value, + resolveRequestedPathForIpc: vi.fn((value: string) => value), + resolveReadableFileForIpc: vi.fn(async (value: string) => { + const stat = fs.statSync(value) + + return { resolvedPath: value, stat } + }), + directoryExists: vi.fn(() => false), + resolveGitBinary: () => 'git', + userDataDir: () => os.tmpdir() + } +} + +function handlerFor(channel: string) { + const call = host.handle.mock.calls.find(([name]) => name === channel) + + if (!call) { + throw new Error(`no handler registered for ${channel}`) + } + + return call[1] as (event: unknown, payload: unknown) => Promise +} + +beforeEach(() => { + host.handle.mockReset() + host.opened.length = 0 + registerFsIpc(registerDeps()) +}) + +it('registers hermes:fs:openExternal and routes local paths through the resolver', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-fs-ipc-open-')) + const file = path.join(dir, 'a.docx') + + fs.writeFileSync(file, 'PK') + + const handler = handlerFor('hermes:fs:openExternal') + + await expect(handler({}, { path: file })).resolves.toEqual({ ok: true, path: file }) + expect(host.opened).toEqual([file]) +}) + +it('stages byte payloads under /external-open', async () => { + const handler = handlerFor('hermes:fs:openExternal') + const result = (await handler({}, { data: [0x50, 0x4b], ext: '.docx', name: 'remote doc' })) as { + ok: boolean + path?: string + } + + expect(result.ok).toBe(true) + expect(result.path).toContain('external-open') + expect(path.basename(result.path ?? '')).toMatch(/^remote_doc_[0-9a-f]{6}\.docx$/) +}) diff --git a/apps/desktop/electron/fs-ipc.ts b/apps/desktop/electron/fs-ipc.ts index 8f57bf979b..76b3961d5d 100644 --- a/apps/desktop/electron/fs-ipc.ts +++ b/apps/desktop/electron/fs-ipc.ts @@ -15,6 +15,7 @@ import { reconcileUnifiedDesktopHalves } from './desktop-plugins-root' import { readDirForIpc } from './fs-read-dir' +import { openExternalFileForIpc, type OpenExternalFilePayload } from './fs-open-external' import { gitRootForIpc } from './git-root' export interface FsIpcDeps { @@ -22,8 +23,13 @@ export interface FsIpcDeps { readActiveDesktopProfile: () => null | string expandUserPath: (value: string) => string resolveRequestedPathForIpc: (value: string, options: { purpose: string }) => string + resolveReadableFileForIpc: ( + filePath: string, + options?: { purpose?: string } + ) => Promise<{ resolvedPath: string; stat: fs.Stats }> directoryExists: (value: string) => boolean resolveGitBinary: () => string + userDataDir: () => string } export function registerFsIpc({ @@ -31,8 +37,10 @@ export function registerFsIpc({ readActiveDesktopProfile, expandUserPath, resolveRequestedPathForIpc, + resolveReadableFileForIpc, directoryExists, - resolveGitBinary + resolveGitBinary, + userDataDir }: FsIpcDeps) { ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath)) @@ -203,6 +211,18 @@ export function registerFsIpc({ return { path: resolved } }) + // Open a file with the OS default application. `{ path }` is a local file + // (hardened via resolveReadableFileForIpc); `{ data, ext, name }` is a remote + // gateway file the renderer already downloaded, staged under userData so the + // OS association can open it. Unlike hermes:openExternal (URLs, errors only + // logged), openPath failures come back as { ok, error } for a renderer toast. + ipcMain.handle('hermes:fs:openExternal', (_event, payload: OpenExternalFilePayload) => + openExternalFileForIpc(payload, { + resolveReadableFileForIpc, + stagingRoot: userDataDir + }) + ) + // Move a file/folder to the OS trash (recoverable) — the VS Code "Delete" // default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform. ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => { diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 5540f16b77..a941daf76d 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -17577,8 +17577,10 @@ registerFsIpc({ readActiveDesktopProfile, expandUserPath, resolveRequestedPathForIpc, + resolveReadableFileForIpc, directoryExists, - resolveGitBinary + resolveGitBinary, + userDataDir: () => app.getPath('userData') }) // Git-driven features (worktrees, review pane, repo scan) — see git-ipc.ts. diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 042f03ab6b..70d725125c 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -361,6 +361,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath), revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath), openDir: dirPath => ipcRenderer.invoke('hermes:fs:openDir', dirPath), + openExternalFile: payload => ipcRenderer.invoke('hermes:fs:openExternal', payload), desktopPluginsRoot: () => ipcRenderer.invoke('hermes:fs:desktopPluginsRoot'), reconcileDesktopPlugins: () => ipcRenderer.invoke('hermes:fs:reconcileDesktopPlugins'), logsRoot: () => ipcRenderer.invoke('hermes:fs:logsRoot'), diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index f130240d26..99b02c5d39 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -420,6 +420,11 @@ declare global { revealPath?: (path: string) => Promise // Open a DIRECTORY (created if missing) in the OS file manager. openDir?: (path: string) => Promise<{ ok: boolean; error?: string }> + // Open a FILE with the OS default application: `{ path }` for local + // files, `{ data, ext, name }` for remote-gateway bytes staged locally. + openExternalFile?: (payload: + | { data?: Uint8Array | number[]; ext?: string; name?: string } + | { path: string }) => Promise<{ error?: string; ok: boolean; path?: string }> // Local Desktop runtime-plugin root (/desktop-plugins), // resolved by Electron independently of the connected backend (#66899). // Created on demand; returns the normalized absolute path.