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:
Brooklyn Nicholson 2026-06-30 14:14:24 -05:00
parent 9d19cfbb78
commit a47afa6b3e
3 changed files with 228 additions and 95 deletions

View file

@ -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) =>

View 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 }

View 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']])
})