feat(desktop): hermes:fs:openExternal IPC with receipt-returning OS open

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-09-17 22:35:34 +08:00
parent 3ca91c55f3
commit c25a3748bb
5 changed files with 112 additions and 2 deletions
@@ -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<unknown>
}
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 <userData>/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$/)
})
+21 -1
View File
@@ -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) => {
+3 -1
View File
@@ -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.
+1
View File
@@ -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'),
+5
View File
@@ -420,6 +420,11 @@ declare global {
revealPath?: (path: string) => Promise<boolean>
// 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 (<HERMES_HOME>/desktop-plugins),
// resolved by Electron independently of the connected backend (#66899).
// Created on demand; returns the normalized absolute path.