From a47afa6b3e9df8222d3553f763b7db4403feaa52 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 14:14:24 -0500 Subject: [PATCH] refactor(desktop): extract file-preview/clipboard/image IPC from main.cjs into media-ipc.cjs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- apps/desktop/electron/main.cjs | 108 +++----------------- apps/desktop/electron/media-ipc.cjs | 121 +++++++++++++++++++++++ apps/desktop/electron/media-ipc.test.cjs | 94 ++++++++++++++++++ 3 files changed, 228 insertions(+), 95 deletions(-) create mode 100644 apps/desktop/electron/media-ipc.cjs create mode 100644 apps/desktop/electron/media-ipc.test.cjs diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 618726b259b..5d9dfbd3e3a 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -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) => diff --git a/apps/desktop/electron/media-ipc.cjs b/apps/desktop/electron/media-ipc.cjs new file mode 100644 index 00000000000..c0f8c612ae6 --- /dev/null +++ b/apps/desktop/electron/media-ipc.cjs @@ -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 } diff --git a/apps/desktop/electron/media-ipc.test.cjs b/apps/desktop/electron/media-ipc.test.cjs new file mode 100644 index 00000000000..73705448bf7 --- /dev/null +++ b/apps/desktop/electron/media-ipc.test.cjs @@ -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']]) +})