diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index b394e115c47..615c9512754 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -58,6 +58,7 @@ const { } = require('./update-relaunch.cjs') const { registerGitIpc } = require('./git-ipc.cjs') const { registerFsIpc } = require('./fs-ipc.cjs') +const { registerTerminalIpc } = require('./terminal-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') @@ -6904,74 +6905,20 @@ registerFsIpc({ ipcMain, directoryExists, expandUserPath }) // stay here (Windows PATH discovery) and are injected into the registrar. registerGitIpc({ ipcMain, resolveGitBinary, resolveGhBinary }) -ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { - if (!nodePty) { - throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.') - } - - ensureSpawnHelperExecutable() - - const id = crypto.randomUUID() - const { args, command, name } = terminalShellCommand() - const cwd = safeTerminalCwd(payload?.cwd) - const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80) - const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24) - const ptyProcess = nodePty.spawn(command, args, { - cols, - cwd, - env: terminalShellEnv(), - name: 'xterm-256color', - rows - }) - - terminalSessions.set(id, { pty: ptyProcess, webContentsId: event.sender.id }) - - const send = (suffix, payload) => { - if (event.sender.isDestroyed()) { - return - } - - event.sender.send(terminalChannel(id, suffix), payload) - } - - ptyProcess.onData(data => send('data', data)) - ptyProcess.onExit(({ exitCode, signal }) => { - terminalSessions.delete(id) - send('exit', { code: exitCode, signal: signal || null }) - }) - event.sender.once('destroyed', () => disposeTerminalSession(id)) - - return { cwd, id, shell: name } +// Terminal/PTY IPC lives in terminal-ipc.cjs; the PTY runtime, session +// registry, and shell helpers stay in the main process and are injected. +registerTerminalIpc({ + disposeTerminalSession, + ensureSpawnHelperExecutable, + ipcMain, + nodePty, + safeTerminalCwd, + terminalChannel, + terminalSessions, + terminalShellCommand, + terminalShellEnv }) -ipcMain.handle('hermes:terminal:write', (_event, id, data) => { - const sessionInfo = terminalSessions.get(String(id || '')) - - if (!sessionInfo) { - return false - } - - sessionInfo.pty.write(String(data || '')) - - return true -}) - -ipcMain.handle('hermes:terminal:resize', (_event, id, size = {}) => { - const sessionInfo = terminalSessions.get(String(id || '')) - - if (!sessionInfo) { - return false - } - - const cols = Math.max(2, Number.parseInt(String(size?.cols || 80), 10) || 80) - const rows = Math.max(2, Number.parseInt(String(size?.rows || 24), 10) || 24) - - sessionInfo.pty.resize(cols, rows) - - return true -}) -ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || ''))) - ipcMain.handle('hermes:updates:check', async () => checkUpdates().catch(error => ({ supported: true, diff --git a/apps/desktop/electron/terminal-ipc.cjs b/apps/desktop/electron/terminal-ipc.cjs new file mode 100644 index 00000000000..8c9e687eaa2 --- /dev/null +++ b/apps/desktop/electron/terminal-ipc.cjs @@ -0,0 +1,89 @@ +'use strict' + +const crypto = require('crypto') + +// Terminal (PTY) IPC: start / write / resize / dispose. The PTY runtime, the +// shared session registry, and the shell-spec/env/cwd helpers all live in the +// main process (deep Windows-PATH + app-path coupling) and are injected, so this +// module only owns the request wiring. +function registerTerminalIpc({ + disposeTerminalSession, + ensureSpawnHelperExecutable, + ipcMain, + nodePty, + safeTerminalCwd, + terminalChannel, + terminalSessions, + terminalShellCommand, + terminalShellEnv +}) { + ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { + if (!nodePty) { + throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.') + } + + ensureSpawnHelperExecutable() + + const id = crypto.randomUUID() + const { args, command, name } = terminalShellCommand() + const cwd = safeTerminalCwd(payload?.cwd) + const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80) + const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24) + const ptyProcess = nodePty.spawn(command, args, { + cols, + cwd, + env: terminalShellEnv(), + name: 'xterm-256color', + rows + }) + + terminalSessions.set(id, { pty: ptyProcess, webContentsId: event.sender.id }) + + const send = (suffix, payload) => { + if (event.sender.isDestroyed()) { + return + } + + event.sender.send(terminalChannel(id, suffix), payload) + } + + ptyProcess.onData(data => send('data', data)) + ptyProcess.onExit(({ exitCode, signal }) => { + terminalSessions.delete(id) + send('exit', { code: exitCode, signal: signal || null }) + }) + event.sender.once('destroyed', () => disposeTerminalSession(id)) + + return { cwd, id, shell: name } + }) + + ipcMain.handle('hermes:terminal:write', (_event, id, data) => { + const sessionInfo = terminalSessions.get(String(id || '')) + + if (!sessionInfo) { + return false + } + + sessionInfo.pty.write(String(data || '')) + + return true + }) + + ipcMain.handle('hermes:terminal:resize', (_event, id, size = {}) => { + const sessionInfo = terminalSessions.get(String(id || '')) + + if (!sessionInfo) { + return false + } + + const cols = Math.max(2, Number.parseInt(String(size?.cols || 80), 10) || 80) + const rows = Math.max(2, Number.parseInt(String(size?.rows || 24), 10) || 24) + + sessionInfo.pty.resize(cols, rows) + + return true + }) + ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || ''))) +} + +module.exports = { registerTerminalIpc } diff --git a/apps/desktop/electron/terminal-ipc.test.cjs b/apps/desktop/electron/terminal-ipc.test.cjs new file mode 100644 index 00000000000..129db73dfa6 --- /dev/null +++ b/apps/desktop/electron/terminal-ipc.test.cjs @@ -0,0 +1,69 @@ +'use strict' + +const assert = require('node:assert/strict') +const test = require('node:test') + +const { registerTerminalIpc } = require('./terminal-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 { + disposeTerminalSession: () => true, + ensureSpawnHelperExecutable: () => {}, + nodePty: { spawn: () => ({ onData() {}, onExit() {} }) }, + safeTerminalCwd: c => c || '/', + terminalChannel: (id, suffix) => `hermes:terminal:${id}:${suffix}`, + terminalSessions: new Map(), + terminalShellCommand: () => ({ args: [], command: 'sh', name: 'sh' }), + terminalShellEnv: () => ({}), + ...overrides + } +} + +test('registerTerminalIpc wires only hermes:terminal:* channels, each to a handler fn', () => { + const ipcMain = fakeIpcMain() + + registerTerminalIpc({ ipcMain, ...deps() }) + + assert.ok(ipcMain.handlers.size >= 4, `expected the full terminal surface, got ${ipcMain.handlers.size}`) + + for (const [channel, handler] of ipcMain.handlers) { + assert.match(channel, /^hermes:terminal:/, `${channel} is not a terminal channel`) + assert.equal(typeof handler, 'function', `${channel} should register a handler`) + } + + for (const channel of ['hermes:terminal:start', 'hermes:terminal:write', 'hermes:terminal:resize']) { + assert.ok(ipcMain.handlers.has(channel), `missing ${channel}`) + } +}) + +test('write / resize on an unknown session id return false instead of throwing', async () => { + const ipcMain = fakeIpcMain() + + registerTerminalIpc({ ipcMain, ...deps() }) + + assert.equal(await ipcMain.handlers.get('hermes:terminal:write')({}, 'nope', 'x'), false) + assert.equal(await ipcMain.handlers.get('hermes:terminal:resize')({}, 'nope', {}), false) +}) + +test('start surfaces a clear error when the PTY runtime is unavailable', async () => { + const ipcMain = fakeIpcMain() + + registerTerminalIpc({ ipcMain, ...deps({ nodePty: null }) }) + + await assert.rejects( + () => ipcMain.handlers.get('hermes:terminal:start')({ sender: {} }, {}), + /PTY support is unavailable/ + ) +})