mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
refactor(desktop): extract file-preview/clipboard/image IPC from main.cjs into media-ipc.cjs
Tenth main.cjs cluster peel. Seven handlers — readFileDataUrl, readFileText,
selectPaths, writeClipboard, saveImageFromUrl, saveImageBuffer, saveClipboardImage
— move verbatim into electron/media-ipc.cjs behind a registerMediaIpc({...})
registrar. The file-hardening + WSL-clipboard sibling modules are required
directly; the preview helpers (mimeTypeForPath/looksBinary/PREVIEW_LANGUAGE_BY_EXT/
TEXT_PREVIEW_MAX_BYTES) and image writers (saveImageFromUrl/writeComposerImage)
are injected. selectPaths parents its native dialog on an injected getMainWindow()
so it tracks the live window instead of a captured reference.
Channel names unchanged → preload + renderer untouched. Dead hardening/wsl
requires in main.cjs removed. Adds electron/media-ipc.test.cjs (surface +
saveImageFromUrl/saveImageBuffer delegation; clipboard/dialog/fs paths in-app only).
This commit is contained in:
parent
9d19cfbb78
commit
a47afa6b3e
3 changed files with 228 additions and 95 deletions
|
|
@ -43,7 +43,6 @@ const { dashboardFallbackArgs, sourceDeclaresServe } = require('./backend-comman
|
|||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
|
||||
const { readWindowsUserEnvVar } = require('./windows-user-env.cjs')
|
||||
const { readWslWindowsClipboardImage } = require('./wsl-clipboard-image.cjs')
|
||||
const { nativeOverlayWidth: computeNativeOverlayWidth } = require('./titlebar-overlay-width.cjs')
|
||||
const { readLiveUpdateMarker } = require('./update-marker.cjs')
|
||||
const {
|
||||
|
|
@ -64,6 +63,7 @@ const { registerProjectDirIpc } = require('./project-dir-ipc.cjs')
|
|||
const { registerVscodeThemeIpc } = require('./vscode-theme-ipc.cjs')
|
||||
const { registerUninstallIpc } = require('./uninstall-ipc.cjs')
|
||||
const { registerVersionIpc } = require('./version-ipc.cjs')
|
||||
const { registerMediaIpc } = require('./media-ipc.cjs')
|
||||
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
|
||||
const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
|
||||
const { runRebuildWithRetry } = require('./update-rebuild.cjs')
|
||||
|
|
@ -100,9 +100,7 @@ const {
|
|||
tokenPreview
|
||||
} = require('./connection-config.cjs')
|
||||
const {
|
||||
DATA_URL_READ_MAX_BYTES,
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
TEXT_PREVIEW_SOURCE_MAX_BYTES,
|
||||
encryptDesktopSecret: encryptDesktopSecretStrict,
|
||||
resolveReadableFileForIpc,
|
||||
resolveRequestedPathForIpc,
|
||||
|
|
@ -6507,98 +6505,18 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
|
|||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:readFileDataUrl', async (_event, filePath) => {
|
||||
const { resolvedPath } = await resolveReadableFileForIpc(filePath, {
|
||||
maxBytes: DATA_URL_READ_MAX_BYTES,
|
||||
purpose: 'File preview'
|
||||
})
|
||||
const data = await fs.promises.readFile(resolvedPath)
|
||||
return `data:${mimeTypeForPath(resolvedPath)};base64,${data.toString('base64')}`
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:readFileText', async (_event, filePath) => {
|
||||
const { resolvedPath, stat } = await resolveReadableFileForIpc(filePath, {
|
||||
maxBytes: TEXT_PREVIEW_SOURCE_MAX_BYTES,
|
||||
purpose: 'Text preview'
|
||||
})
|
||||
const ext = path.extname(resolvedPath).toLowerCase()
|
||||
const handle = await fs.promises.open(resolvedPath, 'r')
|
||||
const bytesToRead = Math.min(stat.size, TEXT_PREVIEW_MAX_BYTES)
|
||||
|
||||
try {
|
||||
const buffer = Buffer.alloc(bytesToRead)
|
||||
const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0)
|
||||
|
||||
return {
|
||||
binary: looksBinary(buffer.subarray(0, Math.min(bytesRead, 4096))),
|
||||
byteSize: stat.size,
|
||||
language: PREVIEW_LANGUAGE_BY_EXT[ext] || 'text',
|
||||
mimeType: mimeTypeForPath(resolvedPath),
|
||||
path: resolvedPath,
|
||||
text: buffer.subarray(0, bytesRead).toString('utf8'),
|
||||
truncated: stat.size > TEXT_PREVIEW_MAX_BYTES
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:selectPaths', async (_event, options = {}) => {
|
||||
const properties = options?.directories ? ['openDirectory'] : ['openFile']
|
||||
if (options?.multiple !== false) properties.push('multiSelections')
|
||||
|
||||
let resolvedDefaultPath
|
||||
if (options?.defaultPath) {
|
||||
try {
|
||||
resolvedDefaultPath = path.resolve(String(options.defaultPath))
|
||||
} catch {
|
||||
resolvedDefaultPath = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
title: options?.title || 'Add context',
|
||||
defaultPath: resolvedDefaultPath,
|
||||
properties,
|
||||
filters: Array.isArray(options?.filters) ? options.filters : undefined
|
||||
})
|
||||
|
||||
if (result.canceled) return []
|
||||
return result.filePaths
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:writeClipboard', (_event, text) => {
|
||||
clipboard.writeText(String(text || ''))
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:saveImageFromUrl', (_event, url) => saveImageFromUrl(String(url || '')))
|
||||
|
||||
ipcMain.handle('hermes:saveImageBuffer', async (_event, payload) => {
|
||||
const data = payload?.data
|
||||
if (!data) throw new Error('saveImageBuffer: missing data')
|
||||
|
||||
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data)
|
||||
return writeComposerImage(buffer, payload?.ext || '.png')
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:saveClipboardImage', async () => {
|
||||
const image = clipboard.readImage()
|
||||
if (image && !image.isEmpty()) {
|
||||
return writeComposerImage(image.toPNG(), '.png')
|
||||
}
|
||||
|
||||
// WSL2/WSLg doesn't bridge clipboard *images* from the Windows host to the
|
||||
// Linux clipboard Electron reads, so a host screenshot looks empty above.
|
||||
// Pull it straight off the Windows clipboard via PowerShell as a fallback.
|
||||
if (IS_WSL) {
|
||||
const png = readWslWindowsClipboardImage()
|
||||
if (png) {
|
||||
return writeComposerImage(png, '.png')
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
// File-preview/clipboard/image IPC lives in media-ipc.cjs; preview helpers,
|
||||
// image writers, and a live-main-window getter are injected.
|
||||
registerMediaIpc({
|
||||
getMainWindow: () => mainWindow,
|
||||
ipcMain,
|
||||
IS_WSL,
|
||||
looksBinary,
|
||||
mimeTypeForPath,
|
||||
PREVIEW_LANGUAGE_BY_EXT,
|
||||
saveImageFromUrl,
|
||||
TEXT_PREVIEW_MAX_BYTES,
|
||||
writeComposerImage
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:normalizePreviewTarget', (_event, target, baseDir) =>
|
||||
|
|
|
|||
121
apps/desktop/electron/media-ipc.cjs
Normal file
121
apps/desktop/electron/media-ipc.cjs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
'use strict'
|
||||
|
||||
const { clipboard, dialog } = require('electron')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const { DATA_URL_READ_MAX_BYTES, resolveReadableFileForIpc, TEXT_PREVIEW_SOURCE_MAX_BYTES } = require('./hardening.cjs')
|
||||
const { readWslWindowsClipboardImage } = require('./wsl-clipboard-image.cjs')
|
||||
|
||||
// File-preview + clipboard + image-save IPC: read a file as a data URL / text
|
||||
// preview, native file picker, write clipboard text, and persist composer images
|
||||
// (from URL, raw buffer, or the system clipboard, with a WSL host-clipboard
|
||||
// fallback). The preview helpers, image writers, and the live main window are
|
||||
// injected; selectPaths parents its dialog on getMainWindow().
|
||||
function registerMediaIpc({
|
||||
getMainWindow,
|
||||
ipcMain,
|
||||
IS_WSL,
|
||||
looksBinary,
|
||||
mimeTypeForPath,
|
||||
PREVIEW_LANGUAGE_BY_EXT,
|
||||
saveImageFromUrl,
|
||||
TEXT_PREVIEW_MAX_BYTES,
|
||||
writeComposerImage
|
||||
}) {
|
||||
ipcMain.handle('hermes:readFileDataUrl', async (_event, filePath) => {
|
||||
const { resolvedPath } = await resolveReadableFileForIpc(filePath, {
|
||||
maxBytes: DATA_URL_READ_MAX_BYTES,
|
||||
purpose: 'File preview'
|
||||
})
|
||||
const data = await fs.promises.readFile(resolvedPath)
|
||||
return `data:${mimeTypeForPath(resolvedPath)};base64,${data.toString('base64')}`
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:readFileText', async (_event, filePath) => {
|
||||
const { resolvedPath, stat } = await resolveReadableFileForIpc(filePath, {
|
||||
maxBytes: TEXT_PREVIEW_SOURCE_MAX_BYTES,
|
||||
purpose: 'Text preview'
|
||||
})
|
||||
const ext = path.extname(resolvedPath).toLowerCase()
|
||||
const handle = await fs.promises.open(resolvedPath, 'r')
|
||||
const bytesToRead = Math.min(stat.size, TEXT_PREVIEW_MAX_BYTES)
|
||||
|
||||
try {
|
||||
const buffer = Buffer.alloc(bytesToRead)
|
||||
const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0)
|
||||
|
||||
return {
|
||||
binary: looksBinary(buffer.subarray(0, Math.min(bytesRead, 4096))),
|
||||
byteSize: stat.size,
|
||||
language: PREVIEW_LANGUAGE_BY_EXT[ext] || 'text',
|
||||
mimeType: mimeTypeForPath(resolvedPath),
|
||||
path: resolvedPath,
|
||||
text: buffer.subarray(0, bytesRead).toString('utf8'),
|
||||
truncated: stat.size > TEXT_PREVIEW_MAX_BYTES
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:selectPaths', async (_event, options = {}) => {
|
||||
const properties = options?.directories ? ['openDirectory'] : ['openFile']
|
||||
if (options?.multiple !== false) properties.push('multiSelections')
|
||||
|
||||
let resolvedDefaultPath
|
||||
if (options?.defaultPath) {
|
||||
try {
|
||||
resolvedDefaultPath = path.resolve(String(options.defaultPath))
|
||||
} catch {
|
||||
resolvedDefaultPath = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const result = await dialog.showOpenDialog(getMainWindow(), {
|
||||
title: options?.title || 'Add context',
|
||||
defaultPath: resolvedDefaultPath,
|
||||
properties,
|
||||
filters: Array.isArray(options?.filters) ? options.filters : undefined
|
||||
})
|
||||
|
||||
if (result.canceled) return []
|
||||
return result.filePaths
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:writeClipboard', (_event, text) => {
|
||||
clipboard.writeText(String(text || ''))
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:saveImageFromUrl', (_event, url) => saveImageFromUrl(String(url || '')))
|
||||
|
||||
ipcMain.handle('hermes:saveImageBuffer', async (_event, payload) => {
|
||||
const data = payload?.data
|
||||
if (!data) throw new Error('saveImageBuffer: missing data')
|
||||
|
||||
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data)
|
||||
return writeComposerImage(buffer, payload?.ext || '.png')
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:saveClipboardImage', async () => {
|
||||
const image = clipboard.readImage()
|
||||
if (image && !image.isEmpty()) {
|
||||
return writeComposerImage(image.toPNG(), '.png')
|
||||
}
|
||||
|
||||
// WSL2/WSLg doesn't bridge clipboard *images* from the Windows host to the
|
||||
// Linux clipboard Electron reads, so a host screenshot looks empty above.
|
||||
// Pull it straight off the Windows clipboard via PowerShell as a fallback.
|
||||
if (IS_WSL) {
|
||||
const png = readWslWindowsClipboardImage()
|
||||
if (png) {
|
||||
return writeComposerImage(png, '.png')
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { registerMediaIpc }
|
||||
94
apps/desktop/electron/media-ipc.test.cjs
Normal file
94
apps/desktop/electron/media-ipc.test.cjs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { registerMediaIpc } = require('./media-ipc.cjs')
|
||||
|
||||
function fakeIpcMain() {
|
||||
const handlers = new Map()
|
||||
|
||||
return {
|
||||
handlers,
|
||||
handle(channel, handler) {
|
||||
assert.ok(!handlers.has(channel), `duplicate registration for ${channel}`)
|
||||
handlers.set(channel, handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deps(overrides = {}) {
|
||||
return {
|
||||
getMainWindow: () => null,
|
||||
IS_WSL: false,
|
||||
looksBinary: () => false,
|
||||
mimeTypeForPath: () => 'text/plain',
|
||||
PREVIEW_LANGUAGE_BY_EXT: {},
|
||||
saveImageFromUrl: async () => '/img/from-url.png',
|
||||
TEXT_PREVIEW_MAX_BYTES: 1024,
|
||||
writeComposerImage: async () => '/img/written.png',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
test('registerMediaIpc wires the file-preview / clipboard / image-save channels', () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerMediaIpc({ ipcMain, ...deps() })
|
||||
|
||||
assert.deepEqual([...ipcMain.handlers.keys()].sort(), [
|
||||
'hermes:readFileDataUrl',
|
||||
'hermes:readFileText',
|
||||
'hermes:saveClipboardImage',
|
||||
'hermes:saveImageBuffer',
|
||||
'hermes:saveImageFromUrl',
|
||||
'hermes:selectPaths',
|
||||
'hermes:writeClipboard'
|
||||
])
|
||||
|
||||
for (const handler of ipcMain.handlers.values()) {
|
||||
assert.equal(typeof handler, 'function')
|
||||
}
|
||||
})
|
||||
|
||||
test('saveImageFromUrl delegates to the injected writer', async () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
const seen = []
|
||||
|
||||
registerMediaIpc({
|
||||
ipcMain,
|
||||
...deps({
|
||||
saveImageFromUrl: async url => {
|
||||
seen.push(url)
|
||||
|
||||
return '/p.png'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
assert.equal(await ipcMain.handlers.get('hermes:saveImageFromUrl')({}, 'https://x/y.png'), '/p.png')
|
||||
assert.deepEqual(seen, ['https://x/y.png'])
|
||||
})
|
||||
|
||||
test('saveImageBuffer rejects missing data and otherwise writes via the injected writer', async () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
const writes = []
|
||||
|
||||
registerMediaIpc({
|
||||
ipcMain,
|
||||
...deps({
|
||||
writeComposerImage: async (buf, ext) => {
|
||||
writes.push([buf.toString('utf8'), ext])
|
||||
|
||||
return '/written.gif'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await assert.rejects(() => ipcMain.handlers.get('hermes:saveImageBuffer')({}, {}), /missing data/)
|
||||
|
||||
const out = await ipcMain.handlers.get('hermes:saveImageBuffer')({}, { data: Buffer.from('hi'), ext: '.gif' })
|
||||
|
||||
assert.equal(out, '/written.gif')
|
||||
assert.deepEqual(writes, [['hi', '.gif']])
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue