refactor(desktop): extract IPC clusters from electron/main.ts
52 handlers move into five registrars — git, pet overlay, hud, fs and terminal. Each takes injected deps (window handles, binary resolvers, path hardening) following the existing electron/ module pattern rather than closing over main.ts locals, and terminal-ipc returns its dispose helpers so SSH teardown and app shutdown keep working.
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
// IPC surface for local filesystem operations the renderer's project/file
|
||||
// surfaces use: directory reads, reveal/open in the OS file manager, plugin
|
||||
// roots + git installs, rename/write/trash. Extracted from main.ts; path
|
||||
// hardening, HERMES_HOME resolution, and the git binary stay injected.
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { ipcMain, shell } from 'electron'
|
||||
|
||||
import { installDesktopPluginFromGit, probePluginRepo } from './desktop-plugin-install'
|
||||
import { readDirForIpc } from './fs-read-dir'
|
||||
import { gitRootForIpc } from './git-root'
|
||||
|
||||
export interface FsIpcDeps {
|
||||
hermesHome: string
|
||||
readActiveDesktopProfile: () => null | string
|
||||
expandUserPath: (value: string) => string
|
||||
resolveRequestedPathForIpc: (value: string, options: { purpose: string }) => string
|
||||
directoryExists: (value: string) => boolean
|
||||
resolveGitBinary: () => string
|
||||
}
|
||||
|
||||
export function registerFsIpc({
|
||||
hermesHome,
|
||||
readActiveDesktopProfile,
|
||||
expandUserPath,
|
||||
resolveRequestedPathForIpc,
|
||||
directoryExists,
|
||||
resolveGitBinary
|
||||
}: FsIpcDeps) {
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
// Open a DIRECTORY in the OS file manager, creating it first if needed. Unlike
|
||||
// `reveal` (which selects an existing item and silently no-ops on a missing
|
||||
// path — the "Open plugins folder" Windows bug), this is for the plugins door,
|
||||
// which often doesn't exist on first use. `shell.openPath` returns '' on
|
||||
// success or an error string; both mkdir + openPath failures are surfaced.
|
||||
ipcMain.handle('hermes:fs:openDir', async (_event, dirPath) => {
|
||||
const dir = String(dirPath || '').trim()
|
||||
|
||||
if (!dir) {
|
||||
return { ok: false, error: 'no path' }
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
const error = await shell.openPath(path.normalize(dir))
|
||||
|
||||
return error ? { ok: false, error } : { ok: true }
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
// The LOCAL Desktop runtime-plugin root: `<HERMES_HOME>/desktop-plugins`,
|
||||
// resolved from the main-process HERMES_HOME (see resolveHermesHome) — NOT from
|
||||
// the connected backend. A remote backend reports its own `hermes_home` over
|
||||
// the gateway, which is a path on the REMOTE box; deriving the plugin dir from
|
||||
// it yields `undefined/desktop-plugins` (or a non-existent remote path) and the
|
||||
// on-disk plugin door silently breaks (#66899). Electron owns this resolution
|
||||
// so it stays valid in every connection mode. Created on demand, like openDir.
|
||||
async function localPluginsRoot(dirName: string): Promise<string> {
|
||||
// Profile-aware: a named Desktop profile gets its own plugin root under
|
||||
// profiles/<name>/, matching the profile-scoped hermes_home the backend
|
||||
// reported before this resolver existed. 'default'/unset pins the global root.
|
||||
const profile = readActiveDesktopProfile()
|
||||
const base = profile && profile !== 'default' ? path.join(hermesHome, 'profiles', profile) : hermesHome
|
||||
const dir = path.join(base, dirName)
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
} catch {
|
||||
// Best-effort create; return the path regardless so the reveal action can
|
||||
// still surface a real openPath error and the scanner can retry later.
|
||||
}
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:fs:desktopPluginsRoot', async () => localPluginsRoot('desktop-plugins'))
|
||||
|
||||
// The LOCAL agent-plugin root (`<HERMES_HOME>/plugins`), same Electron-local
|
||||
// resolution as above. This is the desktop half of a UNIFIED plugin package:
|
||||
// an agent plugin may ship `desktop/plugin.js` alongside its Python code (the
|
||||
// same shape as `dashboard/manifest.json`), and the renderer's disk door scans
|
||||
// this root for it — one installable folder serving both SDKs.
|
||||
ipcMain.handle('hermes:fs:agentPluginsRoot', async () => localPluginsRoot('plugins'))
|
||||
|
||||
ipcMain.handle('hermes:plugin:probe', async (_event, payload) => {
|
||||
const identifier = String(payload?.identifier || payload?.repo || '').trim()
|
||||
|
||||
if (!identifier) {
|
||||
return { ok: false, error: 'identifier is required', agent: false, desktop: false, warnings: [] }
|
||||
}
|
||||
|
||||
return probePluginRepo(resolveGitBinary(), identifier)
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:plugin:installDesktop', async (_event, payload) => {
|
||||
const identifier = String(payload?.identifier || payload?.repo || '').trim()
|
||||
|
||||
if (!identifier) {
|
||||
return { ok: false, error: 'identifier is required' }
|
||||
}
|
||||
|
||||
const desktopPluginsRoot = await localPluginsRoot('desktop-plugins')
|
||||
|
||||
return installDesktopPluginFromGit(resolveGitBinary(), identifier, desktopPluginsRoot, Boolean(payload?.force))
|
||||
})
|
||||
|
||||
// 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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// IPC surface for git-driven features: worktree management ("Start work"),
|
||||
// the composer coding rail's repo status, the Codex-style review pane, and
|
||||
// repo-first project discovery. Extracted from main.ts; the git/gh binary
|
||||
// resolvers stay injected because main.ts also uses them for self-update and
|
||||
// plugin installs.
|
||||
import { ipcMain } from 'electron'
|
||||
|
||||
import { scanGitRepos } from './git-repo-scan'
|
||||
import {
|
||||
fileDiffVsHead,
|
||||
repoStatus,
|
||||
reviewCommit,
|
||||
reviewCommitContext,
|
||||
reviewCreatePr,
|
||||
reviewDiff,
|
||||
reviewFetchPrComment,
|
||||
reviewList,
|
||||
reviewPrList,
|
||||
reviewPush,
|
||||
reviewRevert,
|
||||
reviewRevParse,
|
||||
reviewShipInfo,
|
||||
reviewStage,
|
||||
reviewUnstage
|
||||
} from './git-review-ops'
|
||||
import {
|
||||
addWorktree,
|
||||
listBaseBranches,
|
||||
listBranches,
|
||||
listWorktrees,
|
||||
removeWorktree,
|
||||
switchBranch
|
||||
} from './git-worktree-ops'
|
||||
|
||||
export interface GitIpcDeps {
|
||||
resolveGitBinary: () => string
|
||||
resolveGhBinary: () => string
|
||||
}
|
||||
|
||||
export function registerGitIpc({ resolveGitBinary, resolveGhBinary }: GitIpcDeps) {
|
||||
// Git-driven worktree management ("Start work" flow). Errors surface to the
|
||||
// renderer as rejected promises so it can toast a friendly message.
|
||||
ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => listWorktrees(repoPath, resolveGitBinary()))
|
||||
|
||||
ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) =>
|
||||
addWorktree(repoPath, options || {}, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) =>
|
||||
removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
|
||||
switchBranch(repoPath, branch, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary()))
|
||||
|
||||
ipcMain.handle('hermes:git:baseBranchList', async (_event, repoPath) =>
|
||||
listBaseBranches(repoPath, resolveGitBinary())
|
||||
)
|
||||
|
||||
// Compact repo status (branch, ahead/behind, change counts + files) for the
|
||||
// composer coding rail. Returns null on a non-repo / remote backend so the rail
|
||||
// hides cleanly rather than erroring.
|
||||
ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary()))
|
||||
|
||||
// Codex-style review pane: list changed files for a scope, fetch one file's
|
||||
// unified diff, and stage / unstage / revert. Reads return empty on failure;
|
||||
// mutations reject so the renderer can toast.
|
||||
ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) =>
|
||||
reviewList(repoPath, scope, baseRef, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) =>
|
||||
reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary())
|
||||
)
|
||||
// Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view).
|
||||
ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) =>
|
||||
fileDiffVsHead(repoPath, filePath, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) =>
|
||||
reviewStage(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) =>
|
||||
reviewUnstage(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) =>
|
||||
reviewRevert(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) =>
|
||||
reviewRevParse(repoPath, ref, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) =>
|
||||
reviewCommit(repoPath, message, Boolean(push), resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) =>
|
||||
reviewCommitContext(repoPath, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary()))
|
||||
ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary()))
|
||||
ipcMain.handle('hermes:git:review:prList', async (_event, repoPath, branches, numbers) =>
|
||||
reviewPrList(repoPath, resolveGhBinary(), branches, numbers)
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:fetchPrComment', async (_event, repoPath, url) =>
|
||||
reviewFetchPrComment(repoPath, resolveGhBinary(), url)
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) =>
|
||||
reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary())
|
||||
)
|
||||
|
||||
// Repo-first project discovery: scan bounded roots for git repos (pure fs walk,
|
||||
// no native addon). Never throws to the renderer — failures yield an empty list.
|
||||
ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => {
|
||||
try {
|
||||
return await scanGitRepos(roots || [], options || {})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -3,31 +3,23 @@ import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// Relative, not `@hermes/shared`: the electron bundle is built by esbuild with
|
||||
// no tsconfig path resolution (see scripts/bundle-electron-main.mjs), so a bare
|
||||
// specifier would typecheck and then fail to bundle.
|
||||
import {
|
||||
clampDataUrlReadMaxMb,
|
||||
DATA_URL_READ_DEFAULT_MAX_MB,
|
||||
DATA_URL_READ_MAX_MAX_MB,
|
||||
DATA_URL_READ_MIN_MAX_MB
|
||||
} from '../../shared/src/data-url-read-max'
|
||||
|
||||
const DEFAULT_FETCH_TIMEOUT_MS = 15_000
|
||||
// Default / floor / ceiling for Desktop's data-URL file load (composer attach,
|
||||
// image preview, etc.). The whole file is base64-buffered in main, so this is
|
||||
// a memory guard — not a model limit. Settings → Chat takes a free-form MB
|
||||
// value; 16 MB ships as default. The ceiling is only a typo guard (very large
|
||||
// values can OOM / crash the app).
|
||||
const DATA_URL_READ_DEFAULT_MAX_MB = 16
|
||||
const DATA_URL_READ_MIN_MAX_MB = 1
|
||||
const DATA_URL_READ_MAX_MAX_MB = 4096
|
||||
// Remote file.attach sends one base64 JSON-RPC frame. Cap the dedicated attach
|
||||
// reader so the payload still fits uvicorn's raised ws_max_size (384 MiB)
|
||||
// after base64 + framing. Preview stays on the Settings-configurable path.
|
||||
const ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES = 256 * 1024 * 1024
|
||||
const TEXT_PREVIEW_SOURCE_MAX_BYTES = 64 * 1024 * 1024
|
||||
|
||||
function clampDataUrlReadMaxMb(value) {
|
||||
const parsed = Number(value)
|
||||
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DATA_URL_READ_DEFAULT_MAX_MB
|
||||
}
|
||||
|
||||
return Math.min(DATA_URL_READ_MAX_MAX_MB, Math.max(DATA_URL_READ_MIN_MAX_MB, Math.round(parsed)))
|
||||
}
|
||||
|
||||
function dataUrlReadMaxBytesFromMb(maxMb) {
|
||||
return clampDataUrlReadMaxMb(maxMb) * 1024 * 1024
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// IPC surface for HUD mode (the chrome-free floating chat band). Extracted
|
||||
// from main.ts; the HUD window handle and session-id latch stay injected
|
||||
// because main.ts owns the window lifecycle and the close broadcast reads the
|
||||
// latch when handing the session back to the app window.
|
||||
import { type BrowserWindow, ipcMain } from 'electron'
|
||||
|
||||
export interface HudIpcDeps {
|
||||
isMac: boolean
|
||||
getHudWindow: () => BrowserWindow | null
|
||||
openHudWindow: (sessionId: null | string, profile: null | string) => void
|
||||
closeHudWindow: () => void
|
||||
setHudSessionId: (sessionId: null | string) => void
|
||||
}
|
||||
|
||||
export function registerHudIpc({ isMac, getHudWindow, openHudWindow, closeHudWindow, setHudSessionId }: HudIpcDeps) {
|
||||
ipcMain.handle('hermes:hud:open', async (_event, request) => {
|
||||
openHudWindow(
|
||||
typeof request?.sessionId === 'string' ? request.sessionId : null,
|
||||
typeof request?.profile === 'string' ? request.profile : null
|
||||
)
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// Real frosted glass behind the band — the thing CSS backdrop-filter cannot do,
|
||||
// because Chromium composites a transparent window's page against nothing and
|
||||
// the desktop is not in its backdrop root. Vibrancy IS the window's content
|
||||
// view, so it frosts the whole rectangle; the HUD's layout leaves no dead
|
||||
// margins for that reason, and the renderer only turns it on while the band is
|
||||
// showing (idle HUD mode must be the bar and nothing else).
|
||||
ipcMain.handle('hermes:hud:vibrancy', (_event, on) => {
|
||||
const hudWindow = getHudWindow()
|
||||
|
||||
if (hudWindow && !hudWindow.isDestroyed() && isMac) {
|
||||
hudWindow.setVibrancy(on ? 'hud' : null)
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// Let clicks fall through the HUD wherever it isn't really there. An
|
||||
// always-on-top window eats every click inside its rectangle, and most of that
|
||||
// rectangle is a faded-out band over whatever the user is actually working in.
|
||||
// `forward` keeps mousemove flowing so the renderer can re-arm when the cursor
|
||||
// reaches the bar.
|
||||
ipcMain.on('hermes:hud:ignore-mouse', (_event, ignore) => {
|
||||
const hudWindow = getHudWindow()
|
||||
|
||||
if (hudWindow && !hudWindow.isDestroyed()) {
|
||||
hudWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true })
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.on('hermes:hud:move-by', (event, delta) => {
|
||||
const hudWindow = getHudWindow()
|
||||
|
||||
if (!hudWindow || hudWindow.isDestroyed() || event.sender !== hudWindow.webContents) {
|
||||
return
|
||||
}
|
||||
|
||||
const dx = Number(delta?.x)
|
||||
const dy = Number(delta?.y)
|
||||
const width = Number(delta?.width)
|
||||
const height = Number(delta?.height)
|
||||
|
||||
if (!Number.isFinite(dx) || !Number.isFinite(dy) || !Number.isFinite(width) || !Number.isFinite(height)) {
|
||||
return
|
||||
}
|
||||
|
||||
const [x, y] = hudWindow.getPosition()
|
||||
|
||||
// setBounds — NOT setPosition: on Windows, a transparent frameless window
|
||||
// silently grows ~1px per setPosition call (worse at >100% DPI). The renderer
|
||||
// snapshots outerWidth/outerHeight when the composer drag arms and re-pins
|
||||
// to that size on every moveBy (same pattern as the pet overlay drag).
|
||||
hudWindow.setBounds({
|
||||
x: Math.round(x + dx),
|
||||
y: Math.round(y + dy),
|
||||
width: Math.round(width),
|
||||
height: Math.round(height)
|
||||
})
|
||||
})
|
||||
|
||||
// Resize from the HUD's corner handle. The window is created non-resizable
|
||||
// (see spawnHudWindow — a transparent frameless window must not expose a
|
||||
// system resize hot-zone, or dragging grows it), which on Windows/Linux also
|
||||
// blocks programmatic setBounds sizing — so briefly flip resizable on while
|
||||
// the size actually changes, exactly like the pet overlay's wheel-scale does.
|
||||
ipcMain.on('hermes:hud:set-bounds', (event, bounds) => {
|
||||
const hudWindow = getHudWindow()
|
||||
|
||||
if (!hudWindow || hudWindow.isDestroyed() || event.sender !== hudWindow.webContents || !bounds) {
|
||||
return
|
||||
}
|
||||
|
||||
const win = hudWindow
|
||||
const width = Math.max(380, Math.round(Number(bounds.width)))
|
||||
const height = Math.max(160, Math.round(Number(bounds.height)))
|
||||
const [curW, curH] = win.getSize()
|
||||
const resizing = width !== curW || height !== curH
|
||||
|
||||
if (resizing && !win.isResizable()) {
|
||||
win.setResizable(true)
|
||||
}
|
||||
|
||||
win.setBounds({ x: Math.round(Number(bounds.x)), y: Math.round(Number(bounds.y)), width, height })
|
||||
|
||||
if (resizing) {
|
||||
win.setResizable(false)
|
||||
}
|
||||
})
|
||||
|
||||
// The HUD renderer reporting which session it is on, so the close broadcast
|
||||
// can hand it back to the app window (see hudSessionId).
|
||||
ipcMain.on('hermes:hud:session', (event, sessionId) => {
|
||||
const hudWindow = getHudWindow()
|
||||
|
||||
if (hudWindow && !hudWindow.isDestroyed() && event.sender === hudWindow.webContents) {
|
||||
setHudSessionId(typeof sessionId === 'string' && sessionId ? sessionId : null)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:hud:close', async () => {
|
||||
closeHudWindow()
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
+43
-831
@@ -29,7 +29,6 @@ import {
|
||||
shell,
|
||||
systemPreferences
|
||||
} from 'electron'
|
||||
import nodePty from 'node-pty'
|
||||
|
||||
import { classifyActiveRuntime } from './active-runtime-state'
|
||||
import { stopBackendChild as stopBackendChildImpl, stopBackendTreesForUpdate } from './backend-child'
|
||||
@@ -60,7 +59,7 @@ import {
|
||||
import { decideBootstrapRepair } from './bootstrap-repair-guard'
|
||||
import { runBootstrap } from './bootstrap-runner'
|
||||
import { detectBundleSkew } from './bundle-skew'
|
||||
import { applyConnectionChange, resolveTerminalConnection } from './connection-apply'
|
||||
import { applyConnectionChange } from './connection-apply'
|
||||
import {
|
||||
apiRequestRegistryConnectionId,
|
||||
authModeFromStatus,
|
||||
@@ -120,7 +119,6 @@ import { describeCrashReason, installCrashForensics } from './crash-forensics'
|
||||
import { adoptServedDashboardToken } from './dashboard-token'
|
||||
import { loadOrCreateInstallationId, sshOwnershipId } from './desktop-installation'
|
||||
import { formatDesktopLogLine } from './desktop-log-line'
|
||||
import { installDesktopPluginFromGit, probePluginRepo } from './desktop-plugin-install'
|
||||
import { resolveDesktopRemoteRoute } from './desktop-remote-route'
|
||||
import {
|
||||
buildPosixCleanupScript,
|
||||
@@ -149,7 +147,7 @@ import {
|
||||
stopFind
|
||||
} from './find-in-page'
|
||||
import { createFirstRunSetupGate } from './first-run-setup-gate'
|
||||
import { readDirForIpc } from './fs-read-dir'
|
||||
import { registerFsIpc } from './fs-ipc'
|
||||
import {
|
||||
filenameFromContentDisposition,
|
||||
gatewayFilePath,
|
||||
@@ -158,33 +156,7 @@ import {
|
||||
pumpStreamToFile
|
||||
} from './gateway-file-download'
|
||||
import { probeGatewayWebSocket } from './gateway-ws-probe'
|
||||
import { scanGitRepos } from './git-repo-scan'
|
||||
import {
|
||||
fileDiffVsHead,
|
||||
repoStatus,
|
||||
reviewCommit,
|
||||
reviewCommitContext,
|
||||
reviewCreatePr,
|
||||
reviewDiff,
|
||||
reviewFetchPrComment,
|
||||
reviewList,
|
||||
reviewPrList,
|
||||
reviewPush,
|
||||
reviewRevert,
|
||||
reviewRevParse,
|
||||
reviewShipInfo,
|
||||
reviewStage,
|
||||
reviewUnstage
|
||||
} from './git-review-ops'
|
||||
import { gitRootForIpc } from './git-root'
|
||||
import {
|
||||
addWorktree,
|
||||
listBaseBranches,
|
||||
listBranches,
|
||||
listWorktrees,
|
||||
removeWorktree,
|
||||
switchBranch
|
||||
} from './git-worktree-ops'
|
||||
import { registerGitIpc } from './git-ipc'
|
||||
import { clearStaleGitLocks } from './gitlock'
|
||||
import { readAndConsumeHandoffResult } from './handoff-result'
|
||||
import {
|
||||
@@ -206,6 +178,7 @@ import {
|
||||
writeSecretFileAtomic
|
||||
} from './hardening'
|
||||
import { cursorPointInWindow } from './hud-cursor'
|
||||
import { registerHudIpc } from './hud-ipc'
|
||||
import { snapHudBounds } from './hud-snap'
|
||||
import { createHudSnapShortcut } from './hud-snap-shortcut'
|
||||
import { buildHudWindowUrl } from './hud-url'
|
||||
@@ -234,6 +207,7 @@ import {
|
||||
electronProcessStartMarker,
|
||||
parentWatchdogEnv
|
||||
} from './parent-process-identity'
|
||||
import { registerPetOverlayIpc } from './pet-overlay-ipc'
|
||||
import {
|
||||
buildRegistryProfileRoutes,
|
||||
localRouteFallbackProfiles,
|
||||
@@ -289,17 +263,11 @@ import {
|
||||
SESSION_WINDOW_MIN_WIDTH
|
||||
} from './session-windows'
|
||||
import { ensureLoginShellPath } from './shell-path'
|
||||
import { ensureSpawnHelperExecutable } from './spawn-helper-perms'
|
||||
import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator'
|
||||
import { collectSshConfigHosts, parseSshGOutput } from './ssh-config'
|
||||
import {
|
||||
buildInteractiveSshArgs,
|
||||
createSshProbeConnection,
|
||||
pickLocalPort,
|
||||
redactSecrets,
|
||||
SshConnection
|
||||
} from './ssh-connection'
|
||||
import { createSshProbeConnection, pickLocalPort, redactSecrets, SshConnection } from './ssh-connection'
|
||||
import { createStreamThrottle } from './stream-throttle'
|
||||
import { registerTerminalIpc } from './terminal-ipc'
|
||||
import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width'
|
||||
import {
|
||||
glassActive,
|
||||
@@ -355,12 +323,7 @@ import {
|
||||
getVenvSitePackagesEntries,
|
||||
resolveVenvHermesCommand
|
||||
} from './windows-hermes-path'
|
||||
import {
|
||||
buildWindowsInteractiveCommand,
|
||||
connectWindowsRemote,
|
||||
detectRemotePlatform,
|
||||
helper
|
||||
} from './windows-remote-lifecycle'
|
||||
import { connectWindowsRemote, detectRemotePlatform, helper } from './windows-remote-lifecycle'
|
||||
import {
|
||||
alreadyHasNoSandbox,
|
||||
buildNoSandboxRelaunchArgs,
|
||||
@@ -851,7 +814,6 @@ const APP_ICON_PATHS = [
|
||||
]
|
||||
|
||||
let rendererTitleBarTheme = null
|
||||
const terminalSessions = new Map()
|
||||
|
||||
// Force the NATIVE window appearance (vibrancy material, titlebar, the
|
||||
// pre-first-paint window background) to follow the APP theme instead of the
|
||||
@@ -8862,11 +8824,7 @@ async function teardownSshConnection(profile) {
|
||||
|
||||
sshConnections.delete(scope)
|
||||
|
||||
for (const [id, info] of [...terminalSessions.entries()]) {
|
||||
if (info.sshScope === scope) {
|
||||
disposeTerminalSession(id)
|
||||
}
|
||||
}
|
||||
terminalIpc.disposeTerminalSessionsForSshScope(scope)
|
||||
|
||||
try {
|
||||
if (state.localPort && state.remotePort) {
|
||||
@@ -12139,232 +12097,23 @@ ipcMain.on('hermes:zoom:set-percent', (event, percent) => {
|
||||
setAndPersistZoomLevel(window, percentToZoomLevel(Number(percent)))
|
||||
})
|
||||
|
||||
// --- Pet overlay (pop-out mascot) -----------------------------------------
|
||||
// `request` is `{ bounds, screen }`. A fresh pop-out passes viewport-space
|
||||
// bounds (screen=false): convert to screen space by adding the main window's
|
||||
// content origin so the pet lands where it sat in-window. A remembered/dragged
|
||||
// spot passes screen-space bounds (screen=true) and is used as-is. We return the
|
||||
// resolved screen bounds so the renderer can persist exactly where it opened.
|
||||
ipcMain.handle('hermes:pet-overlay:open', async (_event, request) => {
|
||||
const bounds = request && request.bounds ? request.bounds : request
|
||||
const isScreen = Boolean(request && request.screen)
|
||||
let screenBounds = bounds
|
||||
|
||||
try {
|
||||
if (bounds && !isScreen && mainWindow && !mainWindow.isDestroyed()) {
|
||||
const content = mainWindow.getContentBounds()
|
||||
screenBounds = {
|
||||
x: content.x + (bounds.x || 0),
|
||||
y: content.y + (bounds.y || 0),
|
||||
width: bounds.width,
|
||||
height: bounds.height
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to raw bounds if the window geometry is unavailable.
|
||||
}
|
||||
|
||||
openPetOverlay(screenBounds)
|
||||
|
||||
return { ok: true, bounds: screenBounds }
|
||||
})
|
||||
ipcMain.handle('hermes:pet-overlay:close', async () => {
|
||||
closePetOverlay()
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
// Drag/resize: the overlay reports new absolute screen bounds (it already knows
|
||||
// the pointer's screen coords). Drag keeps the size constant; the wheel-to-scale
|
||||
// gesture grows/shrinks it so the sprite is never cropped by the window edge.
|
||||
// The window is created non-resizable (no stray edge-drag on the transparent
|
||||
// frameless panel), which on Windows/Linux also blocks programmatic setBounds
|
||||
// sizing — so briefly flip resizable on whenever the size actually changes.
|
||||
ipcMain.on('hermes:pet-overlay:set-bounds', (_event, bounds) => {
|
||||
if (!petOverlayWindow || petOverlayWindow.isDestroyed() || !bounds) {
|
||||
return
|
||||
}
|
||||
|
||||
const win = petOverlayWindow
|
||||
const width = Math.max(80, Math.round(bounds.width))
|
||||
const height = Math.max(80, Math.round(bounds.height))
|
||||
const [curW, curH] = win.getSize()
|
||||
const resizing = width !== curW || height !== curH
|
||||
|
||||
if (resizing && !win.isResizable()) {
|
||||
win.setResizable(true)
|
||||
}
|
||||
|
||||
win.setBounds({ x: Math.round(bounds.x), y: Math.round(bounds.y), width, height })
|
||||
|
||||
if (resizing) {
|
||||
win.setResizable(false)
|
||||
}
|
||||
})
|
||||
// Click-through: the overlay window is a full rectangle but only the pet pixels
|
||||
// should be interactive. The renderer toggles this as the cursor enters/leaves
|
||||
// the sprite so transparent margins pass clicks to whatever is behind.
|
||||
ipcMain.on('hermes:pet-overlay:ignore-mouse', (_event, ignore) => {
|
||||
if (petOverlayWindow && !petOverlayWindow.isDestroyed()) {
|
||||
petOverlayWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true })
|
||||
}
|
||||
})
|
||||
// The overlay is a non-activating panel (focusable:false) so it never steals
|
||||
// the app's cmd/alt-tab anchor from the main window. But the pop-up composer
|
||||
// needs the keyboard, so the renderer asks us to flip it focusable + focus it
|
||||
// while the composer is open, then back to non-activating when it closes.
|
||||
ipcMain.on('hermes:pet-overlay:set-focusable', (_event, focusable) => {
|
||||
if (!petOverlayWindow || petOverlayWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
petOverlayWindow.setFocusable(Boolean(focusable))
|
||||
|
||||
if (focusable) {
|
||||
petOverlayWindow.focus()
|
||||
}
|
||||
})
|
||||
// Main renderer → overlay: forward the latest pet state for the overlay to render.
|
||||
ipcMain.on('hermes:pet-overlay:state', (_event, payload) => {
|
||||
if (petOverlayWindow && !petOverlayWindow.isDestroyed()) {
|
||||
petOverlayWindow.webContents.send('hermes:pet-overlay:state', payload)
|
||||
}
|
||||
})
|
||||
// Overlay → main renderer: control messages (pop back in, composer submit).
|
||||
ipcMain.on('hermes:pet-overlay:control', (_event, payload) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Double-click toggles the app window: hide it away if it's up front, bring it
|
||||
// back if it's minimized/buried. Pure window control — nothing for the
|
||||
// renderer to do, so don't forward it.
|
||||
if (payload && payload.type === 'toggle-app') {
|
||||
if (mainWindow.isMinimized() || !mainWindow.isVisible()) {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
} else {
|
||||
mainWindow.minimize()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// The mail icon means "take me to the app": raise the main window (it may be
|
||||
// minimized or buried) before the renderer navigates to the latest thread.
|
||||
if (payload && payload.type === 'open-app') {
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore()
|
||||
}
|
||||
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
|
||||
mainWindow.webContents.send('hermes:pet-overlay:control', payload)
|
||||
// --- Pet overlay (pop-out mascot) — see pet-overlay-ipc.ts. ---------------
|
||||
registerPetOverlayIpc({
|
||||
getMainWindow: () => mainWindow,
|
||||
getPetOverlayWindow: () => petOverlayWindow,
|
||||
openPetOverlay,
|
||||
closePetOverlay
|
||||
})
|
||||
|
||||
// --- HUD mode (chrome-free floating chat) -----------------------------------
|
||||
ipcMain.handle('hermes:hud:open', async (_event, request) => {
|
||||
openHudWindow(
|
||||
typeof request?.sessionId === 'string' ? request.sessionId : null,
|
||||
typeof request?.profile === 'string' ? request.profile : null
|
||||
)
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// Real frosted glass behind the band — the thing CSS backdrop-filter cannot do,
|
||||
// because Chromium composites a transparent window's page against nothing and
|
||||
// the desktop is not in its backdrop root. Vibrancy IS the window's content
|
||||
// view, so it frosts the whole rectangle; the HUD's layout leaves no dead
|
||||
// margins for that reason, and the renderer only turns it on while the band is
|
||||
// showing (idle HUD mode must be the bar and nothing else).
|
||||
ipcMain.handle('hermes:hud:vibrancy', (_event, on) => {
|
||||
if (hudWindow && !hudWindow.isDestroyed() && IS_MAC) {
|
||||
hudWindow.setVibrancy(on ? 'hud' : null)
|
||||
// --- HUD mode (chrome-free floating chat) — see hud-ipc.ts. ---------------
|
||||
registerHudIpc({
|
||||
isMac: IS_MAC,
|
||||
getHudWindow: () => hudWindow,
|
||||
openHudWindow,
|
||||
closeHudWindow,
|
||||
setHudSessionId: value => {
|
||||
hudSessionId = value
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// Let clicks fall through the HUD wherever it isn't really there. An
|
||||
// always-on-top window eats every click inside its rectangle, and most of that
|
||||
// rectangle is a faded-out band over whatever the user is actually working in.
|
||||
// `forward` keeps mousemove flowing so the renderer can re-arm when the cursor
|
||||
// reaches the bar.
|
||||
ipcMain.on('hermes:hud:ignore-mouse', (_event, ignore) => {
|
||||
if (hudWindow && !hudWindow.isDestroyed()) {
|
||||
hudWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true })
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.on('hermes:hud:move-by', (event, delta) => {
|
||||
if (!hudWindow || hudWindow.isDestroyed() || event.sender !== hudWindow.webContents) {
|
||||
return
|
||||
}
|
||||
|
||||
const dx = Number(delta?.x)
|
||||
const dy = Number(delta?.y)
|
||||
const width = Number(delta?.width)
|
||||
const height = Number(delta?.height)
|
||||
|
||||
if (!Number.isFinite(dx) || !Number.isFinite(dy) || !Number.isFinite(width) || !Number.isFinite(height)) {
|
||||
return
|
||||
}
|
||||
|
||||
const [x, y] = hudWindow.getPosition()
|
||||
|
||||
// setBounds — NOT setPosition: on Windows, a transparent frameless window
|
||||
// silently grows ~1px per setPosition call (worse at >100% DPI). The renderer
|
||||
// snapshots outerWidth/outerHeight when the composer drag arms and re-pins
|
||||
// to that size on every moveBy (same pattern as the pet overlay drag).
|
||||
hudWindow.setBounds({
|
||||
x: Math.round(x + dx),
|
||||
y: Math.round(y + dy),
|
||||
width: Math.round(width),
|
||||
height: Math.round(height)
|
||||
})
|
||||
})
|
||||
|
||||
// Resize from the HUD's corner handle. The window is created non-resizable
|
||||
// (see spawnHudWindow — a transparent frameless window must not expose a
|
||||
// system resize hot-zone, or dragging grows it), which on Windows/Linux also
|
||||
// blocks programmatic setBounds sizing — so briefly flip resizable on while
|
||||
// the size actually changes, exactly like the pet overlay's wheel-scale does.
|
||||
ipcMain.on('hermes:hud:set-bounds', (event, bounds) => {
|
||||
if (!hudWindow || hudWindow.isDestroyed() || event.sender !== hudWindow.webContents || !bounds) {
|
||||
return
|
||||
}
|
||||
|
||||
const win = hudWindow
|
||||
const width = Math.max(380, Math.round(Number(bounds.width)))
|
||||
const height = Math.max(160, Math.round(Number(bounds.height)))
|
||||
const [curW, curH] = win.getSize()
|
||||
const resizing = width !== curW || height !== curH
|
||||
|
||||
if (resizing && !win.isResizable()) {
|
||||
win.setResizable(true)
|
||||
}
|
||||
|
||||
win.setBounds({ x: Math.round(Number(bounds.x)), y: Math.round(Number(bounds.y)), width, height })
|
||||
|
||||
if (resizing) {
|
||||
win.setResizable(false)
|
||||
}
|
||||
})
|
||||
|
||||
// The HUD renderer reporting which session it is on, so the close broadcast
|
||||
// can hand it back to the app window (see hudSessionId).
|
||||
ipcMain.on('hermes:hud:session', (event, sessionId) => {
|
||||
if (hudWindow && !hudWindow.isDestroyed() && event.sender === hudWindow.webContents) {
|
||||
hudSessionId = typeof sessionId === 'string' && sessionId ? sessionId : null
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:hud:close', async () => {
|
||||
closeHudWindow()
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('hermes:bootstrap:reset', async () => {
|
||||
// Renderer's "Reload and retry" path. Clear the latched failure and
|
||||
@@ -14383,565 +14132,30 @@ ipcMain.on('hermes:logs:renderer-error', (_event, report) => {
|
||||
flushDesktopLogBufferSync()
|
||||
})
|
||||
|
||||
function isExecutableFile(filePath) {
|
||||
if (!filePath || !path.isAbsolute(filePath)) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.X_OK)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function posixShellSpec(shellPath) {
|
||||
const shellName = path.basename(shellPath)
|
||||
const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i']
|
||||
|
||||
return { args: interactiveArgs, command: shellPath, name: shellName }
|
||||
}
|
||||
|
||||
// Windows PowerShell 5.1 ships at a fixed System32 path on every Windows box;
|
||||
// prefer it only after PowerShell 7+ (`pwsh`).
|
||||
function windowsPowerShellPath() {
|
||||
const systemRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows'
|
||||
const builtin = path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
||||
|
||||
return isExecutableFile(builtin) ? builtin : findOnPath('powershell.exe')
|
||||
}
|
||||
|
||||
// Map a resolved shell path to its spawn spec, picking interactive flags by
|
||||
// family: PowerShell drops its logo banner (so the prompt sits flush like the
|
||||
// POSIX shells), cmd needs nothing, and everything else (zsh/bash/fish/sh…)
|
||||
// gets POSIX interactive-login flags.
|
||||
function shellSpecFor(shellPath) {
|
||||
const name = path.basename(shellPath).toLowerCase()
|
||||
|
||||
if (name.startsWith('pwsh') || name.startsWith('powershell')) {
|
||||
return { args: ['-NoLogo'], command: shellPath, name }
|
||||
}
|
||||
|
||||
if (name.startsWith('cmd')) {
|
||||
return { args: [], command: shellPath, name }
|
||||
}
|
||||
|
||||
return posixShellSpec(shellPath)
|
||||
}
|
||||
|
||||
// Best installed Windows shell: PowerShell 7+ (`pwsh`), then Windows PowerShell
|
||||
// 5.1, then comspec/cmd.exe as the universal fallback.
|
||||
function windowsShellSpec() {
|
||||
const command =
|
||||
findOnPath('pwsh.exe') || findOnPath('pwsh') || windowsPowerShellPath() || process.env.COMSPEC || 'cmd.exe'
|
||||
|
||||
return shellSpecFor(command)
|
||||
}
|
||||
|
||||
// Resolve the interactive shell for the embedded terminal: an explicit user
|
||||
// override wins, otherwise auto-detect the best one installed for the platform.
|
||||
function terminalShellCommand() {
|
||||
// HERMES_DESKTOP_SHELL is the cross-platform escape hatch (a path or a bare
|
||||
// name on PATH); $SHELL is honored on POSIX, where it's the user's canonical
|
||||
// choice, but ignored on Windows, where it's usually a stray MSYS/Git path
|
||||
// node-pty can't spawn natively.
|
||||
const override = (process.env.HERMES_DESKTOP_SHELL || (IS_WINDOWS ? '' : process.env.SHELL) || '').trim()
|
||||
|
||||
if (override) {
|
||||
const resolved = isExecutableFile(override) ? override : findOnPath(override)
|
||||
|
||||
if (resolved) {
|
||||
return shellSpecFor(resolved)
|
||||
}
|
||||
}
|
||||
|
||||
if (IS_WINDOWS) {
|
||||
return windowsShellSpec()
|
||||
}
|
||||
|
||||
const shellPath = ['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => isExecutableFile(candidate))
|
||||
|
||||
return posixShellSpec(shellPath || '/bin/sh')
|
||||
}
|
||||
|
||||
function safeTerminalCwd(cwd) {
|
||||
const candidate = path.resolve(String(cwd || app.getPath('home')))
|
||||
|
||||
try {
|
||||
const stat = fs.statSync(candidate)
|
||||
|
||||
return stat.isDirectory() ? candidate : path.dirname(candidate)
|
||||
} catch {
|
||||
return app.getPath('home')
|
||||
}
|
||||
}
|
||||
|
||||
function terminalShellEnv() {
|
||||
const env = { ...process.env }
|
||||
|
||||
// Electron is commonly launched through `npm run dev`; do not leak npm's
|
||||
// managed prefix into a user's interactive shell (nvm/proto warn loudly).
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key === 'npm_config_prefix' || key.startsWith('npm_config_') || key.startsWith('npm_package_')) {
|
||||
delete env[key]
|
||||
}
|
||||
}
|
||||
|
||||
// Strip color/theme-detection vars that ride along when Electron is launched
|
||||
// from a non-tty agent shell (Cursor's runner sets NO_COLOR/FORCE_COLOR=0
|
||||
// /TERM=dumb; some terminals set COLORFGBG which would flip Hermes' TUI into
|
||||
// light-mode). Our PTY is a real xterm-compat terminal — force truecolor.
|
||||
delete env.NO_COLOR
|
||||
delete env.FORCE_COLOR
|
||||
delete env.COLORFGBG
|
||||
|
||||
env.COLORTERM = 'truecolor'
|
||||
env.LC_CTYPE = env.LC_CTYPE || 'UTF-8'
|
||||
env.TERM = 'xterm-256color'
|
||||
env.TERM_PROGRAM = 'Hermes'
|
||||
env.TERM_PROGRAM_VERSION = app.getVersion()
|
||||
|
||||
// Let a hermes/--tui launched in this pane know it's embedded in the desktop
|
||||
// GUI (build_environment_hints surfaces this). Distinct from HERMES_DESKTOP,
|
||||
// which marks the agent *backend* and gates cron/gateway behavior.
|
||||
env.HERMES_DESKTOP_TERMINAL = '1'
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
function terminalChannel(id, suffix) {
|
||||
return `hermes:terminal:${id}:${suffix}`
|
||||
}
|
||||
|
||||
// Best-effort read of a live PTY child's current working directory so a
|
||||
// reopened tab can restart the shell where the user last `cd`'d, instead of the
|
||||
// tab's original launch dir. Shell-agnostic (no prompt/OSC config needed) on
|
||||
// POSIX; Windows has no cheap per-process cwd query without a native module, so
|
||||
// it returns null and the caller falls back to the launch cwd.
|
||||
function readProcessCwd(pid) {
|
||||
return new Promise(resolve => {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
resolve(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
fs.promises
|
||||
.readlink(`/proc/${pid}/cwd`)
|
||||
.then(target => resolve(target || null))
|
||||
.catch(() => resolve(null))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
// lsof ships with macOS; -Fn emits the cwd fd's path on an `n<path>` line.
|
||||
execFile('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { timeout: 2000 }, (err, stdout) => {
|
||||
if (err) {
|
||||
resolve(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const line = String(stdout || '')
|
||||
.split('\n')
|
||||
.find(entry => entry.startsWith('n'))
|
||||
|
||||
resolve(line ? line.slice(1) : null)
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
resolve(null)
|
||||
})
|
||||
}
|
||||
|
||||
function disposeTerminalSession(id) {
|
||||
const sessionInfo = terminalSessions.get(id)
|
||||
|
||||
if (!sessionInfo) {
|
||||
return false
|
||||
}
|
||||
|
||||
terminalSessions.delete(id)
|
||||
|
||||
try {
|
||||
sessionInfo.pty.kill()
|
||||
} catch {
|
||||
// Process may already be gone.
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
// Local filesystem + plugin-root IPC (readDir/reveal/rename/trash/…) — see fs-ipc.ts.
|
||||
registerFsIpc({
|
||||
hermesHome: HERMES_HOME,
|
||||
readActiveDesktopProfile,
|
||||
expandUserPath,
|
||||
resolveRequestedPathForIpc,
|
||||
directoryExists,
|
||||
resolveGitBinary
|
||||
})
|
||||
|
||||
// Open a DIRECTORY in the OS file manager, creating it first if needed. Unlike
|
||||
// `reveal` (which selects an existing item and silently no-ops on a missing
|
||||
// path — the "Open plugins folder" Windows bug), this is for the plugins door,
|
||||
// which often doesn't exist on first use. `shell.openPath` returns '' on
|
||||
// success or an error string; both mkdir + openPath failures are surfaced.
|
||||
ipcMain.handle('hermes:fs:openDir', async (_event, dirPath) => {
|
||||
const dir = String(dirPath || '').trim()
|
||||
// Git-driven features (worktrees, review pane, repo scan) — see git-ipc.ts.
|
||||
registerGitIpc({ resolveGitBinary, resolveGhBinary })
|
||||
|
||||
if (!dir) {
|
||||
return { ok: false, error: 'no path' }
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
const error = await shell.openPath(path.normalize(dir))
|
||||
|
||||
return error ? { ok: false, error } : { ok: true }
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
// Embedded terminal PTY host (hermes:terminal:*) — see terminal-ipc.ts.
|
||||
const terminalIpc = registerTerminalIpc({
|
||||
isWindows: IS_WINDOWS,
|
||||
findOnPath,
|
||||
rememberLog,
|
||||
activeSshTerminalTarget,
|
||||
ensureBackend: () => ensureBackend(primaryProfileKey()),
|
||||
getSshConnectionState: scope => sshConnections.get(scope)
|
||||
})
|
||||
|
||||
// The LOCAL Desktop runtime-plugin root: `<HERMES_HOME>/desktop-plugins`,
|
||||
// resolved from the main-process HERMES_HOME (see resolveHermesHome) — NOT from
|
||||
// the connected backend. A remote backend reports its own `hermes_home` over
|
||||
// the gateway, which is a path on the REMOTE box; deriving the plugin dir from
|
||||
// it yields `undefined/desktop-plugins` (or a non-existent remote path) and the
|
||||
// on-disk plugin door silently breaks (#66899). Electron owns this resolution
|
||||
// so it stays valid in every connection mode. Created on demand, like openDir.
|
||||
async function localPluginsRoot(dirName: string): Promise<string> {
|
||||
// Profile-aware: a named Desktop profile gets its own plugin root under
|
||||
// profiles/<name>/, matching the profile-scoped hermes_home the backend
|
||||
// reported before this resolver existed. 'default'/unset pins the global root.
|
||||
const profile = readActiveDesktopProfile()
|
||||
const base = profile && profile !== 'default' ? path.join(HERMES_HOME, 'profiles', profile) : HERMES_HOME
|
||||
const dir = path.join(base, dirName)
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
} catch {
|
||||
// Best-effort create; return the path regardless so the reveal action can
|
||||
// still surface a real openPath error and the scanner can retry later.
|
||||
}
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:fs:desktopPluginsRoot', async () => localPluginsRoot('desktop-plugins'))
|
||||
|
||||
// The LOCAL agent-plugin root (`<HERMES_HOME>/plugins`), same Electron-local
|
||||
// resolution as above. This is the desktop half of a UNIFIED plugin package:
|
||||
// an agent plugin may ship `desktop/plugin.js` alongside its Python code (the
|
||||
// same shape as `dashboard/manifest.json`), and the renderer's disk door scans
|
||||
// this root for it — one installable folder serving both SDKs.
|
||||
ipcMain.handle('hermes:fs:agentPluginsRoot', async () => localPluginsRoot('plugins'))
|
||||
|
||||
ipcMain.handle('hermes:plugin:probe', async (_event, payload) => {
|
||||
const identifier = String(payload?.identifier || payload?.repo || '').trim()
|
||||
|
||||
if (!identifier) {
|
||||
return { ok: false, error: 'identifier is required', agent: false, desktop: false, warnings: [] }
|
||||
}
|
||||
|
||||
return probePluginRepo(resolveGitBinary(), identifier)
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:plugin:installDesktop', async (_event, payload) => {
|
||||
const identifier = String(payload?.identifier || payload?.repo || '').trim()
|
||||
|
||||
if (!identifier) {
|
||||
return { ok: false, error: 'identifier is required' }
|
||||
}
|
||||
|
||||
const desktopPluginsRoot = await localPluginsRoot('desktop-plugins')
|
||||
|
||||
return installDesktopPluginFromGit(resolveGitBinary(), identifier, desktopPluginsRoot, Boolean(payload?.force))
|
||||
})
|
||||
|
||||
// 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
|
||||
})
|
||||
|
||||
// Git-driven worktree management ("Start work" flow). Errors surface to the
|
||||
// renderer as rejected promises so it can toast a friendly message.
|
||||
ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => listWorktrees(repoPath, resolveGitBinary()))
|
||||
|
||||
ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) =>
|
||||
addWorktree(repoPath, options || {}, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) =>
|
||||
removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
|
||||
switchBranch(repoPath, branch, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary()))
|
||||
|
||||
ipcMain.handle('hermes:git:baseBranchList', async (_event, repoPath) => listBaseBranches(repoPath, resolveGitBinary()))
|
||||
|
||||
// Compact repo status (branch, ahead/behind, change counts + files) for the
|
||||
// composer coding rail. Returns null on a non-repo / remote backend so the rail
|
||||
// hides cleanly rather than erroring.
|
||||
ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary()))
|
||||
|
||||
// Codex-style review pane: list changed files for a scope, fetch one file's
|
||||
// unified diff, and stage / unstage / revert. Reads return empty on failure;
|
||||
// mutations reject so the renderer can toast.
|
||||
ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) =>
|
||||
reviewList(repoPath, scope, baseRef, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) =>
|
||||
reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary())
|
||||
)
|
||||
// Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view).
|
||||
ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) =>
|
||||
fileDiffVsHead(repoPath, filePath, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) =>
|
||||
reviewStage(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) =>
|
||||
reviewUnstage(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) =>
|
||||
reviewRevert(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) =>
|
||||
reviewRevParse(repoPath, ref, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) =>
|
||||
reviewCommit(repoPath, message, Boolean(push), resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) =>
|
||||
reviewCommitContext(repoPath, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary()))
|
||||
ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary()))
|
||||
ipcMain.handle('hermes:git:review:prList', async (_event, repoPath, branches, numbers) =>
|
||||
reviewPrList(repoPath, resolveGhBinary(), branches, numbers)
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:fetchPrComment', async (_event, repoPath, url) =>
|
||||
reviewFetchPrComment(repoPath, resolveGhBinary(), url)
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) =>
|
||||
reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary())
|
||||
)
|
||||
|
||||
// Repo-first project discovery: scan bounded roots for git repos (pure fs walk,
|
||||
// no native addon). Never throws to the renderer — failures yield an empty list.
|
||||
ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => {
|
||||
try {
|
||||
return await scanGitRepos(roots || [], options || {})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
// node-pty's published tarball ships the POSIX `spawn-helper` without an exec
|
||||
// bit; the dev flow resolves node-pty straight from node_modules (nothing
|
||||
// chmods it there), so the first terminal spawn dies with `posix_spawnp
|
||||
// failed`. Restore the bit once, lazily, right before the first spawn. Packaged
|
||||
// builds already stage an executable copy, so this is a no-op there.
|
||||
let _spawnHelperEnsured = false
|
||||
|
||||
function ensureNodePtySpawnHelper() {
|
||||
if (_spawnHelperEnsured || IS_WINDOWS) {
|
||||
return
|
||||
}
|
||||
|
||||
_spawnHelperEnsured = true
|
||||
|
||||
try {
|
||||
const nodePtyRoot = path.dirname(require.resolve('node-pty/package.json'))
|
||||
const { fixed, errors } = ensureSpawnHelperExecutable(nodePtyRoot)
|
||||
|
||||
for (const helperPath of fixed) {
|
||||
rememberLog(`[terminal] restored +x on node-pty spawn-helper: ${helperPath}`)
|
||||
}
|
||||
|
||||
for (const failure of errors) {
|
||||
rememberLog(`[terminal] could not chmod spawn-helper ${failure.path}: ${failure.error}`)
|
||||
}
|
||||
} catch (error) {
|
||||
rememberLog(`[terminal] spawn-helper exec check skipped: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
|
||||
ensureNodePtySpawnHelper()
|
||||
|
||||
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 sshTarget = await resolveTerminalConnection(activeSshTerminalTarget, () => ensureBackend(primaryProfileKey()))
|
||||
const remote = Boolean(sshTarget)
|
||||
const remoteState = remote ? sshConnections.get(sshTarget.scope) : null
|
||||
|
||||
const remoteCommand =
|
||||
remoteState?.remotePlatform === 'Windows'
|
||||
? buildWindowsInteractiveCommand(String(payload?.cwd || '').trim())
|
||||
: undefined
|
||||
|
||||
const ptyProcess = remote
|
||||
? nodePty.spawn(
|
||||
process.platform === 'win32'
|
||||
? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe')
|
||||
: 'ssh',
|
||||
buildInteractiveSshArgs(sshTarget.ssh, String(payload?.cwd || '').trim(), undefined, remoteCommand),
|
||||
{ cols, cwd: app.getPath('home'), env: terminalShellEnv(), name: 'xterm-256color', rows }
|
||||
)
|
||||
: nodePty.spawn(command, args, { cols, cwd, env: terminalShellEnv(), name: 'xterm-256color', rows })
|
||||
|
||||
terminalSessions.set(id, {
|
||||
pty: ptyProcess,
|
||||
webContentsId: event.sender.id,
|
||||
...(remote ? { sshScope: sshTarget.scope, remoteCwd: String(payload?.cwd || '') } : {})
|
||||
})
|
||||
|
||||
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: remote ? null : cwd, id, shell: remote ? 'ssh' : 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:cwd', async (_event, id) => {
|
||||
const sessionInfo = terminalSessions.get(String(id || ''))
|
||||
|
||||
if (!sessionInfo) {
|
||||
return null
|
||||
}
|
||||
|
||||
return sessionInfo.sshScope !== undefined ? null : readProcessCwd(sessionInfo.pty.pid)
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || '')))
|
||||
const disposeTerminalSession = terminalIpc.disposeTerminalSession
|
||||
|
||||
ipcMain.handle('hermes:updates:check', async () =>
|
||||
checkUpdates().catch(error => ({
|
||||
@@ -15644,9 +14858,7 @@ app.on('before-quit', event => {
|
||||
|
||||
// Kill open PTYs before environment teardown to avoid the node-pty#904
|
||||
// ThreadSafeFunction SIGABRT race.
|
||||
for (const id of [...terminalSessions.keys()]) {
|
||||
disposeTerminalSession(id)
|
||||
}
|
||||
terminalIpc.disposeAllTerminalSessions()
|
||||
|
||||
void backendShutdown.run()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// IPC surface for the pop-out pet overlay (mascot window). Extracted from
|
||||
// main.ts; window handles stay injected because main.ts owns their lifecycle.
|
||||
import { type BrowserWindow, ipcMain } from 'electron'
|
||||
|
||||
export interface PetOverlayIpcDeps {
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
getPetOverlayWindow: () => BrowserWindow | null
|
||||
openPetOverlay: (bounds: unknown) => void
|
||||
closePetOverlay: () => void
|
||||
}
|
||||
|
||||
export function registerPetOverlayIpc({
|
||||
getMainWindow,
|
||||
getPetOverlayWindow,
|
||||
openPetOverlay,
|
||||
closePetOverlay
|
||||
}: PetOverlayIpcDeps) {
|
||||
// `request` is `{ bounds, screen }`. A fresh pop-out passes viewport-space
|
||||
// bounds (screen=false): convert to screen space by adding the main window's
|
||||
// content origin so the pet lands where it sat in-window. A remembered/dragged
|
||||
// spot passes screen-space bounds (screen=true) and is used as-is. We return the
|
||||
// resolved screen bounds so the renderer can persist exactly where it opened.
|
||||
ipcMain.handle('hermes:pet-overlay:open', async (_event, request) => {
|
||||
const bounds = request && request.bounds ? request.bounds : request
|
||||
const isScreen = Boolean(request && request.screen)
|
||||
const mainWindow = getMainWindow()
|
||||
let screenBounds = bounds
|
||||
|
||||
try {
|
||||
if (bounds && !isScreen && mainWindow && !mainWindow.isDestroyed()) {
|
||||
const content = mainWindow.getContentBounds()
|
||||
screenBounds = {
|
||||
x: content.x + (bounds.x || 0),
|
||||
y: content.y + (bounds.y || 0),
|
||||
width: bounds.width,
|
||||
height: bounds.height
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to raw bounds if the window geometry is unavailable.
|
||||
}
|
||||
|
||||
openPetOverlay(screenBounds)
|
||||
|
||||
return { ok: true, bounds: screenBounds }
|
||||
})
|
||||
ipcMain.handle('hermes:pet-overlay:close', async () => {
|
||||
closePetOverlay()
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
// Drag/resize: the overlay reports new absolute screen bounds (it already knows
|
||||
// the pointer's screen coords). Drag keeps the size constant; the wheel-to-scale
|
||||
// gesture grows/shrinks it so the sprite is never cropped by the window edge.
|
||||
// The window is created non-resizable (no stray edge-drag on the transparent
|
||||
// frameless panel), which on Windows/Linux also blocks programmatic setBounds
|
||||
// sizing — so briefly flip resizable on whenever the size actually changes.
|
||||
ipcMain.on('hermes:pet-overlay:set-bounds', (_event, bounds) => {
|
||||
const petOverlayWindow = getPetOverlayWindow()
|
||||
|
||||
if (!petOverlayWindow || petOverlayWindow.isDestroyed() || !bounds) {
|
||||
return
|
||||
}
|
||||
|
||||
const win = petOverlayWindow
|
||||
const width = Math.max(80, Math.round(bounds.width))
|
||||
const height = Math.max(80, Math.round(bounds.height))
|
||||
const [curW, curH] = win.getSize()
|
||||
const resizing = width !== curW || height !== curH
|
||||
|
||||
if (resizing && !win.isResizable()) {
|
||||
win.setResizable(true)
|
||||
}
|
||||
|
||||
win.setBounds({ x: Math.round(bounds.x), y: Math.round(bounds.y), width, height })
|
||||
|
||||
if (resizing) {
|
||||
win.setResizable(false)
|
||||
}
|
||||
})
|
||||
// Click-through: the overlay window is a full rectangle but only the pet pixels
|
||||
// should be interactive. The renderer toggles this as the cursor enters/leaves
|
||||
// the sprite so transparent margins pass clicks to whatever is behind.
|
||||
ipcMain.on('hermes:pet-overlay:ignore-mouse', (_event, ignore) => {
|
||||
const petOverlayWindow = getPetOverlayWindow()
|
||||
|
||||
if (petOverlayWindow && !petOverlayWindow.isDestroyed()) {
|
||||
petOverlayWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true })
|
||||
}
|
||||
})
|
||||
// The overlay is a non-activating panel (focusable:false) so it never steals
|
||||
// the app's cmd/alt-tab anchor from the main window. But the pop-up composer
|
||||
// needs the keyboard, so the renderer asks us to flip it focusable + focus it
|
||||
// while the composer is open, then back to non-activating when it closes.
|
||||
ipcMain.on('hermes:pet-overlay:set-focusable', (_event, focusable) => {
|
||||
const petOverlayWindow = getPetOverlayWindow()
|
||||
|
||||
if (!petOverlayWindow || petOverlayWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
petOverlayWindow.setFocusable(Boolean(focusable))
|
||||
|
||||
if (focusable) {
|
||||
petOverlayWindow.focus()
|
||||
}
|
||||
})
|
||||
// Main renderer → overlay: forward the latest pet state for the overlay to render.
|
||||
ipcMain.on('hermes:pet-overlay:state', (_event, payload) => {
|
||||
const petOverlayWindow = getPetOverlayWindow()
|
||||
|
||||
if (petOverlayWindow && !petOverlayWindow.isDestroyed()) {
|
||||
petOverlayWindow.webContents.send('hermes:pet-overlay:state', payload)
|
||||
}
|
||||
})
|
||||
// Overlay → main renderer: control messages (pop back in, composer submit).
|
||||
ipcMain.on('hermes:pet-overlay:control', (_event, payload) => {
|
||||
const mainWindow = getMainWindow()
|
||||
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Double-click toggles the app window: hide it away if it's up front, bring it
|
||||
// back if it's minimized/buried. Pure window control — nothing for the
|
||||
// renderer to do, so don't forward it.
|
||||
if (payload && payload.type === 'toggle-app') {
|
||||
if (mainWindow.isMinimized() || !mainWindow.isVisible()) {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
} else {
|
||||
mainWindow.minimize()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// The mail icon means "take me to the app": raise the main window (it may be
|
||||
// minimized or buried) before the renderer navigates to the latest thread.
|
||||
if (payload && payload.type === 'open-app') {
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore()
|
||||
}
|
||||
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
|
||||
mainWindow.webContents.send('hermes:pet-overlay:control', payload)
|
||||
})
|
||||
}
|
||||
@@ -28,6 +28,7 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import { parseRemoteProfileListing } from './connection-registry'
|
||||
import { assertBootstrapNotSuperseded } from './ssh-connection'
|
||||
|
||||
const LOCKFILE_SCHEMA_VERSION = 2
|
||||
// Bumped when the desktop<->dashboard reuse contract changes in a way that makes
|
||||
@@ -558,7 +559,7 @@ async function scrapeReadyPort(ssh, logPath, { timeoutMs = DEFAULT_READY_TIMEOUT
|
||||
const remoteLog = expandRemotePath(logPath)
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
assertNotAborted(signal)
|
||||
assertBootstrapNotSuperseded(signal)
|
||||
|
||||
if (isAlive && !(await isAlive())) {
|
||||
const err: any = new Error('Remote dashboard process exited before announcing its port.')
|
||||
@@ -696,14 +697,6 @@ async function cancelForwardSafe(deps, localPort, remotePort) {
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotAborted(signal) {
|
||||
if (signal?.aborted) {
|
||||
const error: any = new Error('SSH bootstrap was cancelled.')
|
||||
error.kind = 'superseded'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function isForwardBindCollision(error) {
|
||||
return /address already in use|cannot listen to port|bind.*failed/i.test(String(error?.message || error || ''))
|
||||
}
|
||||
@@ -769,7 +762,7 @@ async function connect(deps) {
|
||||
|
||||
const log = msg => rememberLog(`[ssh-lifecycle] ${msg}`)
|
||||
|
||||
assertNotAborted(signal)
|
||||
assertBootstrapNotSuperseded(signal)
|
||||
const platform = await probeRemotePlatform(ssh)
|
||||
log(`remote platform ${platform.os}/${platform.arch}`)
|
||||
const hermesPath = await locateHermes(ssh, remoteHermesPath)
|
||||
@@ -810,7 +803,7 @@ async function connect(deps) {
|
||||
lock.hermesHome === hermesHome
|
||||
|
||||
if (reusable) {
|
||||
assertNotAborted(signal)
|
||||
assertBootstrapNotSuperseded(signal)
|
||||
const localPort = await openForward(deps, lock.port)
|
||||
|
||||
try {
|
||||
@@ -827,7 +820,7 @@ async function connect(deps) {
|
||||
}
|
||||
|
||||
if (reuseClassification === 'authenticated-stale') {
|
||||
assertNotAborted(signal)
|
||||
assertBootstrapNotSuperseded(signal)
|
||||
await cancelForwardSafe(deps, localPort, lock.port)
|
||||
await cleanupStale(ssh, ownershipId, lock)
|
||||
} else if (reuseClassification === 'authenticated-ok') {
|
||||
@@ -840,7 +833,7 @@ async function connect(deps) {
|
||||
'reused remote dashboard'
|
||||
)
|
||||
|
||||
assertNotAborted(signal)
|
||||
assertBootstrapNotSuperseded(signal)
|
||||
log(`reusing remote dashboard pid=${lock.pid} port=${lock.port}`)
|
||||
|
||||
return {
|
||||
@@ -868,12 +861,12 @@ async function connect(deps) {
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
assertNotAborted(signal)
|
||||
assertBootstrapNotSuperseded(signal)
|
||||
await cleanupStale(ssh, ownershipId, lock, pidAlive)
|
||||
}
|
||||
}
|
||||
|
||||
assertNotAborted(signal)
|
||||
assertBootstrapNotSuperseded(signal)
|
||||
const spawnToken = mintToken()
|
||||
|
||||
const { pid, spawnNonce, logPath, tokenFilePath } = await spawnRemoteDashboard(ssh, {
|
||||
@@ -914,21 +907,21 @@ async function connect(deps) {
|
||||
isAlive: () => remotePidAlive(ssh, pid),
|
||||
signal
|
||||
})
|
||||
assertNotAborted(signal)
|
||||
assertBootstrapNotSuperseded(signal)
|
||||
log(`remote dashboard bound port ${remotePort}`)
|
||||
|
||||
localPort = await openForward(deps, remotePort)
|
||||
assertNotAborted(signal)
|
||||
assertBootstrapNotSuperseded(signal)
|
||||
const baseUrl = `http://127.0.0.1:${localPort}`
|
||||
await waitForHermes(baseUrl, spawnToken)
|
||||
assertNotAborted(signal)
|
||||
assertBootstrapNotSuperseded(signal)
|
||||
|
||||
const token = await adoptOwnedServedToken(adoptServedToken, baseUrl, spawnToken, ssh, pid, 'remote dashboard')
|
||||
|
||||
assertNotAborted(signal)
|
||||
assertBootstrapNotSuperseded(signal)
|
||||
const tokenFingerprint = fingerprintToken(token)
|
||||
await writeLockfile(ssh, ownershipId, { ...ownedSpawn, port: remotePort, tokenFingerprint })
|
||||
assertNotAborted(signal)
|
||||
assertBootstrapNotSuperseded(signal)
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
|
||||
@@ -988,7 +988,19 @@ function createSshProbeConnection(config, options: any = {}) {
|
||||
return new SshConnection(config, { ...options, mux: false })
|
||||
}
|
||||
|
||||
// Bootstrap loops poll a remote for readiness; a newer attempt aborts the
|
||||
// signal so the stale one stops polling and unwinds. `superseded` tells the
|
||||
// caller this was replaced, not that it failed.
|
||||
function assertBootstrapNotSuperseded(signal) {
|
||||
if (signal?.aborted) {
|
||||
const error: any = new Error('SSH bootstrap was cancelled.')
|
||||
error.kind = 'superseded'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
assertBootstrapNotSuperseded,
|
||||
baseSshOptions,
|
||||
buildControlArgs,
|
||||
buildExecArgs,
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
// The embedded terminal's PTY host: shell resolution, env scrubbing, session
|
||||
// registry, and the hermes:terminal:* IPC surface. Extracted from main.ts; the
|
||||
// factory owns the session map and returns the dispose helpers main.ts needs
|
||||
// for SSH teardown. findOnPath / logging / connection routing stay injected.
|
||||
import { execFile } from 'node:child_process'
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { app, ipcMain } from 'electron'
|
||||
import nodePty from 'node-pty'
|
||||
|
||||
import { resolveTerminalConnection } from './connection-apply'
|
||||
import { ensureSpawnHelperExecutable } from './spawn-helper-perms'
|
||||
import { buildInteractiveSshArgs } from './ssh-connection'
|
||||
import { buildWindowsInteractiveCommand } from './windows-remote-lifecycle'
|
||||
|
||||
export interface TerminalIpcDeps {
|
||||
isWindows: boolean
|
||||
findOnPath: (command: string) => null | string
|
||||
rememberLog: (line: string) => void
|
||||
activeSshTerminalTarget: () => unknown
|
||||
ensureBackend: () => Promise<unknown>
|
||||
getSshConnectionState: (scope: string) => undefined | { remotePlatform?: string }
|
||||
}
|
||||
|
||||
export interface TerminalIpcApi {
|
||||
disposeTerminalSession: (id: string) => boolean
|
||||
disposeTerminalSessionsForSshScope: (scope: string) => void
|
||||
disposeAllTerminalSessions: () => void
|
||||
}
|
||||
|
||||
export function registerTerminalIpc({
|
||||
isWindows,
|
||||
findOnPath,
|
||||
rememberLog,
|
||||
activeSshTerminalTarget,
|
||||
ensureBackend,
|
||||
getSshConnectionState
|
||||
}: TerminalIpcDeps): TerminalIpcApi {
|
||||
const terminalSessions = new Map()
|
||||
|
||||
function isExecutableFile(filePath) {
|
||||
if (!filePath || !path.isAbsolute(filePath)) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.X_OK)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function posixShellSpec(shellPath) {
|
||||
const shellName = path.basename(shellPath)
|
||||
const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i']
|
||||
|
||||
return { args: interactiveArgs, command: shellPath, name: shellName }
|
||||
}
|
||||
|
||||
// Windows PowerShell 5.1 ships at a fixed System32 path on every Windows box;
|
||||
// prefer it only after PowerShell 7+ (`pwsh`).
|
||||
function windowsPowerShellPath() {
|
||||
const systemRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows'
|
||||
const builtin = path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
||||
|
||||
return isExecutableFile(builtin) ? builtin : findOnPath('powershell.exe')
|
||||
}
|
||||
|
||||
// Map a resolved shell path to its spawn spec, picking interactive flags by
|
||||
// family: PowerShell drops its logo banner (so the prompt sits flush like the
|
||||
// POSIX shells), cmd needs nothing, and everything else (zsh/bash/fish/sh…)
|
||||
// gets POSIX interactive-login flags.
|
||||
function shellSpecFor(shellPath) {
|
||||
const name = path.basename(shellPath).toLowerCase()
|
||||
|
||||
if (name.startsWith('pwsh') || name.startsWith('powershell')) {
|
||||
return { args: ['-NoLogo'], command: shellPath, name }
|
||||
}
|
||||
|
||||
if (name.startsWith('cmd')) {
|
||||
return { args: [], command: shellPath, name }
|
||||
}
|
||||
|
||||
return posixShellSpec(shellPath)
|
||||
}
|
||||
|
||||
// Best installed Windows shell: PowerShell 7+ (`pwsh`), then Windows PowerShell
|
||||
// 5.1, then comspec/cmd.exe as the universal fallback.
|
||||
function windowsShellSpec() {
|
||||
const command =
|
||||
findOnPath('pwsh.exe') || findOnPath('pwsh') || windowsPowerShellPath() || process.env.COMSPEC || 'cmd.exe'
|
||||
|
||||
return shellSpecFor(command)
|
||||
}
|
||||
|
||||
// Resolve the interactive shell for the embedded terminal: an explicit user
|
||||
// override wins, otherwise auto-detect the best one installed for the platform.
|
||||
function terminalShellCommand() {
|
||||
// HERMES_DESKTOP_SHELL is the cross-platform escape hatch (a path or a bare
|
||||
// name on PATH); $SHELL is honored on POSIX, where it's the user's canonical
|
||||
// choice, but ignored on Windows, where it's usually a stray MSYS/Git path
|
||||
// node-pty can't spawn natively.
|
||||
const override = (process.env.HERMES_DESKTOP_SHELL || (isWindows ? '' : process.env.SHELL) || '').trim()
|
||||
|
||||
if (override) {
|
||||
const resolved = isExecutableFile(override) ? override : findOnPath(override)
|
||||
|
||||
if (resolved) {
|
||||
return shellSpecFor(resolved)
|
||||
}
|
||||
}
|
||||
|
||||
if (isWindows) {
|
||||
return windowsShellSpec()
|
||||
}
|
||||
|
||||
const shellPath = ['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => isExecutableFile(candidate))
|
||||
|
||||
return posixShellSpec(shellPath || '/bin/sh')
|
||||
}
|
||||
|
||||
function safeTerminalCwd(cwd) {
|
||||
const candidate = path.resolve(String(cwd || app.getPath('home')))
|
||||
|
||||
try {
|
||||
const stat = fs.statSync(candidate)
|
||||
|
||||
return stat.isDirectory() ? candidate : path.dirname(candidate)
|
||||
} catch {
|
||||
return app.getPath('home')
|
||||
}
|
||||
}
|
||||
|
||||
function terminalShellEnv() {
|
||||
const env = { ...process.env }
|
||||
|
||||
// Electron is commonly launched through `npm run dev`; do not leak npm's
|
||||
// managed prefix into a user's interactive shell (nvm/proto warn loudly).
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key === 'npm_config_prefix' || key.startsWith('npm_config_') || key.startsWith('npm_package_')) {
|
||||
delete env[key]
|
||||
}
|
||||
}
|
||||
|
||||
// Strip color/theme-detection vars that ride along when Electron is launched
|
||||
// from a non-tty agent shell (Cursor's runner sets NO_COLOR/FORCE_COLOR=0
|
||||
// /TERM=dumb; some terminals set COLORFGBG which would flip Hermes' TUI into
|
||||
// light-mode). Our PTY is a real xterm-compat terminal — force truecolor.
|
||||
delete env.NO_COLOR
|
||||
delete env.FORCE_COLOR
|
||||
delete env.COLORFGBG
|
||||
|
||||
env.COLORTERM = 'truecolor'
|
||||
env.LC_CTYPE = env.LC_CTYPE || 'UTF-8'
|
||||
env.TERM = 'xterm-256color'
|
||||
env.TERM_PROGRAM = 'Hermes'
|
||||
env.TERM_PROGRAM_VERSION = app.getVersion()
|
||||
|
||||
// Let a hermes/--tui launched in this pane know it's embedded in the desktop
|
||||
// GUI (build_environment_hints surfaces this). Distinct from HERMES_DESKTOP,
|
||||
// which marks the agent *backend* and gates cron/gateway behavior.
|
||||
env.HERMES_DESKTOP_TERMINAL = '1'
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
function terminalChannel(id, suffix) {
|
||||
return `hermes:terminal:${id}:${suffix}`
|
||||
}
|
||||
|
||||
// Best-effort read of a live PTY child's current working directory so a
|
||||
// reopened tab can restart the shell where the user last `cd`'d, instead of the
|
||||
// tab's original launch dir. Shell-agnostic (no prompt/OSC config needed) on
|
||||
// POSIX; Windows has no cheap per-process cwd query without a native module, so
|
||||
// it returns null and the caller falls back to the launch cwd.
|
||||
function readProcessCwd(pid) {
|
||||
return new Promise(resolve => {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
resolve(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
fs.promises
|
||||
.readlink(`/proc/${pid}/cwd`)
|
||||
.then(target => resolve(target || null))
|
||||
.catch(() => resolve(null))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
// lsof ships with macOS; -Fn emits the cwd fd's path on an `n<path>` line.
|
||||
execFile('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { timeout: 2000 }, (err, stdout) => {
|
||||
if (err) {
|
||||
resolve(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const line = String(stdout || '')
|
||||
.split('\n')
|
||||
.find(entry => entry.startsWith('n'))
|
||||
|
||||
resolve(line ? line.slice(1) : null)
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
resolve(null)
|
||||
})
|
||||
}
|
||||
|
||||
function disposeTerminalSession(id: string) {
|
||||
const sessionInfo = terminalSessions.get(id)
|
||||
|
||||
if (!sessionInfo) {
|
||||
return false
|
||||
}
|
||||
|
||||
terminalSessions.delete(id)
|
||||
|
||||
try {
|
||||
sessionInfo.pty.kill()
|
||||
} catch {
|
||||
// Process may already be gone.
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// SSH teardown: close every pane whose PTY rode the disconnected tunnel.
|
||||
function disposeTerminalSessionsForSshScope(scope: string) {
|
||||
for (const [id, info] of [...terminalSessions.entries()]) {
|
||||
if (info.sshScope === scope) {
|
||||
disposeTerminalSession(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// App shutdown: kill every open PTY before environment teardown.
|
||||
function disposeAllTerminalSessions() {
|
||||
for (const id of [...terminalSessions.keys()]) {
|
||||
disposeTerminalSession(id)
|
||||
}
|
||||
}
|
||||
|
||||
// node-pty's published tarball ships the POSIX `spawn-helper` without an exec
|
||||
// bit; the dev flow resolves node-pty straight from node_modules (nothing
|
||||
// chmods it there), so the first terminal spawn dies with `posix_spawnp
|
||||
// failed`. Restore the bit once, lazily, right before the first spawn. Packaged
|
||||
// builds already stage an executable copy, so this is a no-op there.
|
||||
let _spawnHelperEnsured = false
|
||||
|
||||
function ensureNodePtySpawnHelper() {
|
||||
if (_spawnHelperEnsured || isWindows) {
|
||||
return
|
||||
}
|
||||
|
||||
_spawnHelperEnsured = true
|
||||
|
||||
try {
|
||||
const nodePtyRoot = path.dirname(require.resolve('node-pty/package.json'))
|
||||
const { fixed, errors } = ensureSpawnHelperExecutable(nodePtyRoot)
|
||||
|
||||
for (const helperPath of fixed) {
|
||||
rememberLog(`[terminal] restored +x on node-pty spawn-helper: ${helperPath}`)
|
||||
}
|
||||
|
||||
for (const failure of errors) {
|
||||
rememberLog(`[terminal] could not chmod spawn-helper ${failure.path}: ${failure.error}`)
|
||||
}
|
||||
} catch (error) {
|
||||
rememberLog(
|
||||
`[terminal] spawn-helper exec check skipped: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
|
||||
ensureNodePtySpawnHelper()
|
||||
|
||||
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 sshTarget = await resolveTerminalConnection(activeSshTerminalTarget, ensureBackend)
|
||||
const remote = Boolean(sshTarget)
|
||||
const remoteState = remote ? getSshConnectionState(sshTarget.scope) : null
|
||||
|
||||
const remoteCommand =
|
||||
remoteState?.remotePlatform === 'Windows'
|
||||
? buildWindowsInteractiveCommand(String(payload?.cwd || '').trim())
|
||||
: undefined
|
||||
|
||||
const ptyProcess = remote
|
||||
? nodePty.spawn(
|
||||
process.platform === 'win32'
|
||||
? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe')
|
||||
: 'ssh',
|
||||
buildInteractiveSshArgs(sshTarget.ssh, String(payload?.cwd || '').trim(), undefined, remoteCommand),
|
||||
{ cols, cwd: app.getPath('home'), env: terminalShellEnv(), name: 'xterm-256color', rows }
|
||||
)
|
||||
: nodePty.spawn(command, args, { cols, cwd, env: terminalShellEnv(), name: 'xterm-256color', rows })
|
||||
|
||||
terminalSessions.set(id, {
|
||||
pty: ptyProcess,
|
||||
webContentsId: event.sender.id,
|
||||
...(remote ? { sshScope: sshTarget.scope, remoteCwd: String(payload?.cwd || '') } : {})
|
||||
})
|
||||
|
||||
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: remote ? null : cwd, id, shell: remote ? 'ssh' : 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:cwd', async (_event, id) => {
|
||||
const sessionInfo = terminalSessions.get(String(id || ''))
|
||||
|
||||
if (!sessionInfo) {
|
||||
return null
|
||||
}
|
||||
|
||||
return sessionInfo.sshScope !== undefined ? null : readProcessCwd(sessionInfo.pty.pid)
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || '')))
|
||||
|
||||
return { disposeTerminalSession, disposeTerminalSessionsForSshScope, disposeAllTerminalSessions }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import { redactSecrets, SSH_ERROR } from './ssh-connection'
|
||||
import { assertBootstrapNotSuperseded, redactSecrets, SSH_ERROR } from './ssh-connection'
|
||||
|
||||
const LOCKFILE_SCHEMA_VERSION = 2
|
||||
const PROTOCOL_VERSION = 1
|
||||
@@ -162,14 +162,6 @@ function reusableWindowsLock(lock, state, profile, reuseToken, runtime) {
|
||||
)
|
||||
}
|
||||
|
||||
function assertCurrent(signal) {
|
||||
if (signal?.aborted) {
|
||||
const error: any = new Error('SSH bootstrap was cancelled.')
|
||||
error.kind = 'superseded'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function processState(ssh, runtime, lock) {
|
||||
return helper(ssh, runtime, 'process-state', [
|
||||
String(lock.pid),
|
||||
@@ -215,7 +207,7 @@ async function waitReady(ssh, runtime, ownershipId, lock, timeoutMs, signal) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
assertCurrent(signal)
|
||||
assertBootstrapNotSuperseded(signal)
|
||||
let state
|
||||
|
||||
try {
|
||||
@@ -286,7 +278,7 @@ async function connectWindowsRemote(deps) {
|
||||
readyTimeoutMs = 45_000
|
||||
} = deps
|
||||
|
||||
assertCurrent(signal)
|
||||
assertBootstrapNotSuperseded(signal)
|
||||
const runtime = await probeWindowsRemote(ssh, remoteHermesPath)
|
||||
const inspection = await helper(ssh, runtime, 'inspect', [runtime.hermesPath])
|
||||
|
||||
@@ -356,7 +348,7 @@ async function connectWindowsRemote(deps) {
|
||||
await helper(ssh, runtime, 'remove-lock', [ownershipId])
|
||||
}
|
||||
|
||||
assertCurrent(signal)
|
||||
assertBootstrapNotSuperseded(signal)
|
||||
const token = crypto.randomBytes(32).toString('hex')
|
||||
const spawnNonce = crypto.randomBytes(8).toString('hex')
|
||||
await helper(ssh, runtime, 'upload-token', [ownershipId, spawnNonce], token)
|
||||
@@ -405,7 +397,7 @@ async function connectWindowsRemote(deps) {
|
||||
await forward(localPort, remotePort)
|
||||
const baseUrl = `http://127.0.0.1:${localPort}`
|
||||
await waitForHermes(baseUrl, token)
|
||||
assertCurrent(signal)
|
||||
assertBootstrapNotSuperseded(signal)
|
||||
await helper(ssh, runtime, 'write-lock', [ownershipId], JSON.stringify({ ...owned, port: remotePort }))
|
||||
|
||||
return {
|
||||
|
||||
@@ -16,6 +16,6 @@
|
||||
"rootDir": "..",
|
||||
"outDir": "build/electron-types"
|
||||
},
|
||||
"include": ["electron", "../shared/src/translucency.ts"],
|
||||
"include": ["electron", "../shared/src/data-url-read-max.ts", "../shared/src/translucency.ts"],
|
||||
"exclude": ["src", "electron/**/*.e2e.mts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user