mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 15:55:37 +08:00
Compare commits
4 Commits
bb/desktop
...
bb/main-lo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ed0c2d39f | ||
|
|
c147270a1c | ||
|
|
880f5837a1 | ||
|
|
f3ce17bf9e |
105
apps/desktop/electron/fs-ipc.cjs
Normal file
105
apps/desktop/electron/fs-ipc.cjs
Normal file
@@ -0,0 +1,105 @@
|
||||
'use strict'
|
||||
|
||||
const { shell } = require('electron')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
|
||||
|
||||
// Filesystem IPC: read-dir, git-root, reveal, rename, write-text, trash. Path
|
||||
// hardening + `~` expansion + dir-existence checks live in the main process and
|
||||
// are injected so this module stays side-effect free.
|
||||
function registerFsIpc({ directoryExists, expandUserPath, ipcMain }) {
|
||||
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath))
|
||||
|
||||
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
|
||||
|
||||
// Reveal a path in the OS file manager (Finder / Explorer / Files).
|
||||
ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => {
|
||||
const target = String(targetPath || '').trim()
|
||||
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
shell.showItemInFolder(target)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// Rename a file/folder in place. The renderer passes the existing path + a new
|
||||
// base name; the destination is resolved in the SAME parent dir so a rename can
|
||||
// never move the item elsewhere or traverse out. Rejects on a name collision.
|
||||
ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => {
|
||||
const src = String(targetPath || '').trim()
|
||||
const name = String(newName || '').trim()
|
||||
|
||||
if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
|
||||
throw new Error('Invalid rename')
|
||||
}
|
||||
|
||||
const dst = path.join(path.dirname(src), name)
|
||||
|
||||
if (dst === src) {
|
||||
return { path: dst }
|
||||
}
|
||||
|
||||
if (fs.existsSync(dst)) {
|
||||
throw new Error(`"${name}" already exists`)
|
||||
}
|
||||
|
||||
await fs.promises.rename(src, dst)
|
||||
|
||||
return { path: dst }
|
||||
})
|
||||
|
||||
// Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path
|
||||
// is hardened (resolveRequestedPathForIpc) and the parent must already exist —
|
||||
// this never creates directory trees or escapes the allowed roots, and content
|
||||
// is size-capped so it can't be abused as a bulk-write primitive.
|
||||
ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => {
|
||||
const raw = String(filePath || '').trim()
|
||||
|
||||
if (!raw) {
|
||||
throw new Error('Invalid path')
|
||||
}
|
||||
|
||||
const text = String(content ?? '')
|
||||
|
||||
if (text.length > 1_000_000) {
|
||||
throw new Error('Content too large')
|
||||
}
|
||||
|
||||
const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' })
|
||||
|
||||
if (!directoryExists(path.dirname(resolved))) {
|
||||
throw new Error('Parent directory does not exist')
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(resolved, text, 'utf8')
|
||||
|
||||
return { path: resolved }
|
||||
})
|
||||
|
||||
// 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) => {
|
||||
const target = String(targetPath || '').trim()
|
||||
|
||||
if (!target) {
|
||||
throw new Error('Invalid delete')
|
||||
}
|
||||
|
||||
await shell.trashItem(target)
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { registerFsIpc }
|
||||
49
apps/desktop/electron/fs-ipc.test.cjs
Normal file
49
apps/desktop/electron/fs-ipc.test.cjs
Normal file
@@ -0,0 +1,49 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { registerFsIpc } = require('./fs-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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('registerFsIpc wires only hermes:fs:* channels, each to a handler fn', () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerFsIpc({ ipcMain, directoryExists: () => true, expandUserPath: p => p })
|
||||
|
||||
assert.ok(ipcMain.handlers.size >= 6, `expected the full fs surface, got ${ipcMain.handlers.size}`)
|
||||
|
||||
for (const [channel, handler] of ipcMain.handlers) {
|
||||
assert.match(channel, /^hermes:fs:/, `${channel} is not an fs channel`)
|
||||
assert.equal(typeof handler, 'function', `${channel} should register a handler`)
|
||||
}
|
||||
|
||||
for (const channel of ['hermes:fs:readDir', 'hermes:fs:rename', 'hermes:fs:trash']) {
|
||||
assert.ok(ipcMain.handlers.has(channel), `missing ${channel}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('rename rejects names that traverse out of the parent dir', async () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerFsIpc({ ipcMain, directoryExists: () => true, expandUserPath: p => p })
|
||||
|
||||
for (const bad of ['..', '.', 'a/b', 'a\\b']) {
|
||||
await assert.rejects(
|
||||
() => ipcMain.handlers.get('hermes:fs:rename')({}, '/tmp/x', bad),
|
||||
/Invalid rename/,
|
||||
`"${bad}" should be rejected`
|
||||
)
|
||||
}
|
||||
})
|
||||
27
apps/desktop/electron/logs-ipc.cjs
Normal file
27
apps/desktop/electron/logs-ipc.cjs
Normal file
@@ -0,0 +1,27 @@
|
||||
'use strict'
|
||||
|
||||
const { shell } = require('electron')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
// Desktop-log IPC: reveal the log file in the OS file manager + return the
|
||||
// recent in-memory tail. The log path, the in-memory ring buffer, and the
|
||||
// file-exists probe live in the main process and are injected.
|
||||
function registerLogsIpc({ DESKTOP_LOG_PATH, fileExists, hermesLog, ipcMain }) {
|
||||
ipcMain.handle('hermes:logs:reveal', async () => {
|
||||
try {
|
||||
await fs.promises.mkdir(path.dirname(DESKTOP_LOG_PATH), { recursive: true })
|
||||
if (!fileExists(DESKTOP_LOG_PATH)) {
|
||||
await fs.promises.appendFile(DESKTOP_LOG_PATH, '')
|
||||
}
|
||||
shell.showItemInFolder(DESKTOP_LOG_PATH)
|
||||
return { ok: true, path: DESKTOP_LOG_PATH }
|
||||
} catch (error) {
|
||||
return { ok: false, path: DESKTOP_LOG_PATH, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:logs:recent', async () => ({ path: DESKTOP_LOG_PATH, lines: hermesLog.slice(-200) }))
|
||||
}
|
||||
|
||||
module.exports = { registerLogsIpc }
|
||||
44
apps/desktop/electron/logs-ipc.test.cjs
Normal file
44
apps/desktop/electron/logs-ipc.test.cjs
Normal file
@@ -0,0 +1,44 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { registerLogsIpc } = require('./logs-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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('registerLogsIpc wires only hermes:logs:* channels, each to a handler fn', () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerLogsIpc({ ipcMain, DESKTOP_LOG_PATH: '/tmp/desktop.log', fileExists: () => true, hermesLog: [] })
|
||||
|
||||
assert.deepEqual([...ipcMain.handlers.keys()].sort(), ['hermes:logs:recent', 'hermes:logs:reveal'])
|
||||
|
||||
for (const handler of ipcMain.handlers.values()) {
|
||||
assert.equal(typeof handler, 'function')
|
||||
}
|
||||
})
|
||||
|
||||
test('logs:recent returns the injected path and the last 200 buffered lines', async () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
const hermesLog = Array.from({ length: 250 }, (_, i) => `line ${i}`)
|
||||
|
||||
registerLogsIpc({ ipcMain, DESKTOP_LOG_PATH: '/tmp/desktop.log', fileExists: () => true, hermesLog })
|
||||
|
||||
const res = await ipcMain.handlers.get('hermes:logs:recent')({})
|
||||
|
||||
assert.equal(res.path, '/tmp/desktop.log')
|
||||
assert.equal(res.lines.length, 200)
|
||||
assert.equal(res.lines[0], 'line 50')
|
||||
assert.equal(res.lines.at(-1), 'line 249')
|
||||
})
|
||||
@@ -46,7 +46,6 @@ const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-e
|
||||
const { readWindowsUserEnvVar } = require('./windows-user-env.cjs')
|
||||
const { readWslWindowsClipboardImage } = require('./wsl-clipboard-image.cjs')
|
||||
const { nativeOverlayWidth: computeNativeOverlayWidth } = require('./titlebar-overlay-width.cjs')
|
||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||
const { readLiveUpdateMarker } = require('./update-marker.cjs')
|
||||
const {
|
||||
resolveUnpackedRelease,
|
||||
@@ -57,8 +56,11 @@ const {
|
||||
collectRelaunchEnv,
|
||||
buildRelaunchScript
|
||||
} = require('./update-relaunch.cjs')
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
const { registerGitIpc } = require('./git-ipc.cjs')
|
||||
const { registerFsIpc } = require('./fs-ipc.cjs')
|
||||
const { registerTerminalIpc } = require('./terminal-ipc.cjs')
|
||||
const { registerUpdatesIpc } = require('./updates-ipc.cjs')
|
||||
const { registerLogsIpc } = require('./logs-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')
|
||||
@@ -6701,20 +6703,8 @@ ipcMain.handle('hermes:setting:defaultProjectDir:pick', async () => {
|
||||
|
||||
ipcMain.handle('hermes:fetchLinkTitle', (_event, url) => fetchLinkTitle(url))
|
||||
|
||||
ipcMain.handle('hermes:logs:reveal', async () => {
|
||||
try {
|
||||
await fs.promises.mkdir(path.dirname(DESKTOP_LOG_PATH), { recursive: true })
|
||||
if (!fileExists(DESKTOP_LOG_PATH)) {
|
||||
await fs.promises.appendFile(DESKTOP_LOG_PATH, '')
|
||||
}
|
||||
shell.showItemInFolder(DESKTOP_LOG_PATH)
|
||||
return { ok: true, path: DESKTOP_LOG_PATH }
|
||||
} catch (error) {
|
||||
return { ok: false, path: DESKTOP_LOG_PATH, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:logs:recent', async () => ({ path: DESKTOP_LOG_PATH, lines: hermesLog.slice(-200) }))
|
||||
// Desktop-log IPC lives in logs-ipc.cjs; log path + ring buffer are injected.
|
||||
registerLogsIpc({ DESKTOP_LOG_PATH, fileExists, hermesLog, ipcMain })
|
||||
|
||||
function isExecutableFile(filePath) {
|
||||
if (!filePath || !path.isAbsolute(filePath)) {
|
||||
@@ -6898,191 +6888,36 @@ function disposeTerminalSession(id) {
|
||||
return true
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath))
|
||||
|
||||
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
|
||||
|
||||
// Reveal a path in the OS file manager (Finder / Explorer / Files).
|
||||
ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => {
|
||||
const target = String(targetPath || '').trim()
|
||||
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
shell.showItemInFolder(target)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// Rename a file/folder in place. The renderer passes the existing path + a new
|
||||
// base name; the destination is resolved in the SAME parent dir so a rename can
|
||||
// never move the item elsewhere or traverse out. Rejects on a name collision.
|
||||
ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => {
|
||||
const src = String(targetPath || '').trim()
|
||||
const name = String(newName || '').trim()
|
||||
|
||||
if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
|
||||
throw new Error('Invalid rename')
|
||||
}
|
||||
|
||||
const dst = path.join(path.dirname(src), name)
|
||||
|
||||
if (dst === src) {
|
||||
return { path: dst }
|
||||
}
|
||||
|
||||
if (fs.existsSync(dst)) {
|
||||
throw new Error(`"${name}" already exists`)
|
||||
}
|
||||
|
||||
await fs.promises.rename(src, dst)
|
||||
|
||||
return { path: dst }
|
||||
})
|
||||
|
||||
// Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path
|
||||
// is hardened (resolveRequestedPathForIpc) and the parent must already exist —
|
||||
// this never creates directory trees or escapes the allowed roots, and content
|
||||
// is size-capped so it can't be abused as a bulk-write primitive.
|
||||
ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => {
|
||||
const raw = String(filePath || '').trim()
|
||||
|
||||
if (!raw) {
|
||||
throw new Error('Invalid path')
|
||||
}
|
||||
|
||||
const text = String(content ?? '')
|
||||
|
||||
if (text.length > 1_000_000) {
|
||||
throw new Error('Content too large')
|
||||
}
|
||||
|
||||
const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' })
|
||||
|
||||
if (!directoryExists(path.dirname(resolved))) {
|
||||
throw new Error('Parent directory does not exist')
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(resolved, text, 'utf8')
|
||||
|
||||
return { path: resolved }
|
||||
})
|
||||
|
||||
// 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) => {
|
||||
const target = String(targetPath || '').trim()
|
||||
|
||||
if (!target) {
|
||||
throw new Error('Invalid delete')
|
||||
}
|
||||
|
||||
await shell.trashItem(target)
|
||||
|
||||
return true
|
||||
})
|
||||
// Filesystem IPC lives in fs-ipc.cjs; main-process path helpers are injected.
|
||||
registerFsIpc({ ipcMain, directoryExists, expandUserPath })
|
||||
|
||||
// Git/worktree/review IPC lives in git-ipc.cjs; the git + gh binary resolvers
|
||||
// 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,
|
||||
branch: readDesktopUpdateConfig().branch,
|
||||
error: 'check-failed',
|
||||
message: error?.message || String(error),
|
||||
fetchedAt: Date.now()
|
||||
}))
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:updates:apply', async (_event, payload) =>
|
||||
applyUpdates(payload || {}).catch(error => ({
|
||||
ok: false,
|
||||
error: 'apply-failed',
|
||||
message: error?.message || String(error)
|
||||
}))
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:updates:branch:get', async () => readDesktopUpdateConfig())
|
||||
|
||||
ipcMain.handle('hermes:updates:branch:set', async (_event, name) => {
|
||||
const branch = typeof name === 'string' && name.trim() ? name.trim() : DEFAULT_UPDATE_BRANCH
|
||||
writeDesktopUpdateConfig({ branch })
|
||||
return { branch }
|
||||
// Auto-update IPC lives in updates-ipc.cjs; the update engine + on-disk
|
||||
// config stay in the main process and are injected.
|
||||
registerUpdatesIpc({
|
||||
applyUpdates,
|
||||
checkUpdates,
|
||||
DEFAULT_UPDATE_BRANCH,
|
||||
ipcMain,
|
||||
readDesktopUpdateConfig,
|
||||
writeDesktopUpdateConfig
|
||||
})
|
||||
|
||||
// Resolve the canonical Hermes version (the one `release.py` bumps in
|
||||
|
||||
89
apps/desktop/electron/terminal-ipc.cjs
Normal file
89
apps/desktop/electron/terminal-ipc.cjs
Normal file
@@ -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 }
|
||||
69
apps/desktop/electron/terminal-ipc.test.cjs
Normal file
69
apps/desktop/electron/terminal-ipc.test.cjs
Normal file
@@ -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/
|
||||
)
|
||||
})
|
||||
41
apps/desktop/electron/updates-ipc.cjs
Normal file
41
apps/desktop/electron/updates-ipc.cjs
Normal file
@@ -0,0 +1,41 @@
|
||||
'use strict'
|
||||
|
||||
// Auto-update IPC: check / apply / branch get+set. The update engine
|
||||
// (checkUpdates/applyUpdates) and the on-disk update config live in the main
|
||||
// process and are injected, so this module owns only the request wiring.
|
||||
function registerUpdatesIpc({
|
||||
applyUpdates,
|
||||
checkUpdates,
|
||||
DEFAULT_UPDATE_BRANCH,
|
||||
ipcMain,
|
||||
readDesktopUpdateConfig,
|
||||
writeDesktopUpdateConfig
|
||||
}) {
|
||||
ipcMain.handle('hermes:updates:check', async () =>
|
||||
checkUpdates().catch(error => ({
|
||||
supported: true,
|
||||
branch: readDesktopUpdateConfig().branch,
|
||||
error: 'check-failed',
|
||||
message: error?.message || String(error),
|
||||
fetchedAt: Date.now()
|
||||
}))
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:updates:apply', async (_event, payload) =>
|
||||
applyUpdates(payload || {}).catch(error => ({
|
||||
ok: false,
|
||||
error: 'apply-failed',
|
||||
message: error?.message || String(error)
|
||||
}))
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:updates:branch:get', async () => readDesktopUpdateConfig())
|
||||
|
||||
ipcMain.handle('hermes:updates:branch:set', async (_event, name) => {
|
||||
const branch = typeof name === 'string' && name.trim() ? name.trim() : DEFAULT_UPDATE_BRANCH
|
||||
writeDesktopUpdateConfig({ branch })
|
||||
return { branch }
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { registerUpdatesIpc }
|
||||
76
apps/desktop/electron/updates-ipc.test.cjs
Normal file
76
apps/desktop/electron/updates-ipc.test.cjs
Normal file
@@ -0,0 +1,76 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { registerUpdatesIpc } = require('./updates-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 {
|
||||
applyUpdates: async () => ({ ok: true }),
|
||||
checkUpdates: async () => ({ supported: true }),
|
||||
DEFAULT_UPDATE_BRANCH: 'main',
|
||||
readDesktopUpdateConfig: () => ({ branch: 'main' }),
|
||||
writeDesktopUpdateConfig: () => {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
test('registerUpdatesIpc wires only hermes:updates:* channels, each to a handler fn', () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerUpdatesIpc({ ipcMain, ...deps() })
|
||||
|
||||
assert.ok(ipcMain.handlers.size >= 4, `expected the full updates surface, got ${ipcMain.handlers.size}`)
|
||||
|
||||
for (const [channel, handler] of ipcMain.handlers) {
|
||||
assert.match(channel, /^hermes:updates:/, `${channel} is not an updates channel`)
|
||||
assert.equal(typeof handler, 'function', `${channel} should register a handler`)
|
||||
}
|
||||
|
||||
for (const channel of ['hermes:updates:check', 'hermes:updates:apply', 'hermes:updates:branch:set']) {
|
||||
assert.ok(ipcMain.handlers.has(channel), `missing ${channel}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('branch:set falls back to the default branch for blank input and persists it', async () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
const writes = []
|
||||
|
||||
registerUpdatesIpc({ ipcMain, ...deps({ writeDesktopUpdateConfig: c => writes.push(c) }) })
|
||||
|
||||
assert.deepEqual(await ipcMain.handlers.get('hermes:updates:branch:set')({}, ' '), { branch: 'main' })
|
||||
assert.deepEqual(await ipcMain.handlers.get('hermes:updates:branch:set')({}, 'dev'), { branch: 'dev' })
|
||||
assert.deepEqual(writes, [{ branch: 'main' }, { branch: 'dev' }])
|
||||
})
|
||||
|
||||
test('check swallows engine failures into a structured error payload', async () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerUpdatesIpc({
|
||||
ipcMain,
|
||||
...deps({
|
||||
checkUpdates: async () => {
|
||||
throw new Error('network down')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const res = await ipcMain.handlers.get('hermes:updates:check')({})
|
||||
|
||||
assert.equal(res.error, 'check-failed')
|
||||
assert.equal(res.message, 'network down')
|
||||
assert.equal(res.branch, 'main')
|
||||
})
|
||||
Reference in New Issue
Block a user