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:
@@ -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$/)
|
||||
})
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Vendored
+5
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user