Files
OpenTerminal/src/main/ipc.ts
T
Bill 35583b2c15 feat(lock): main-window lock screen; harden env gating, sandbox and ssh teardown
Lock screen (main-window overlay, no second window):
- scrypt password verifier in <userData>/lock.json (per-write salt,
  timingSafeEqual); salt/hash/password never leave the main process
- lock now / idle auto-lock / lock at startup, growing failure cooldown,
  lock flags persisted so a quit-and-relaunch cannot bypass the lock
- locked shell and body portals go inert while sessions keep running;
  menu accelerators (reload, DevTools, zoom) are swallowed while locked
- settings gains a Lock tab; all copy in zh-CN/zh-TW/en/ja

Security and stability:
- packaged builds ignore ELECTRON_RENDERER_URL / OT_UPDATE_URL (devEnv)
- renderer preload runs with sandbox: true
- unreadable known_hosts store fails closed instead of being overwritten
- connect-time secrets gated by the bookmark's auth method (connectPromptFor)
- ssh stream teardown is idempotent: PTY_EXIT broadcasts exactly once
- sysinfo polling is refcounted for split panes (forceStopPolling on close)
- session-log index entries are path-contained; settings store writes
  atomically with EPERM/EBUSY retry
- sync-changelog tolerates CRLF checkouts (was a silent no-op)
- retry ssh2 host-key generation (flaky malformed key, ~1/500)

Tests: lock-store + lock-controller suites; transport-death PTY_EXIT e2e;
GitHub Actions CI (typecheck + 10 offline tests + build)
2026-09-24 22:16:43 +08:00

269 lines
11 KiB
TypeScript

import { app, ipcMain, shell } from 'electron'
import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron'
import fontList from 'font-list'
import { homedir } from 'os'
import { statSync } from 'fs'
import { join } from 'path'
import { pathToFileURL } from 'url'
import { Ipc, type AppInfo, type LayoutMeta, type PtyCreateOptions } from '../shared/ipc'
import { t } from '../shared/i18n'
import type { HostKeyAction, SessionOpenOptions, SshConnection, SshConnectionInput } from '../shared/connections'
import { devRendererUrl } from './devEnv'
import { getLayout, listLayouts, saveLayout, deleteLayout } from './layouts'
import { startPolling, stopPolling } from './sysinfo'
import { registerSettingsIpc } from './settingsStore'
import { registerLockIpc } from './lockController'
import { registerSessionStateIpc } from './sessionState'
import { normalizeReportedCwd, resolveCwd } from './cwd'
import { ConnectionsStore, defaultConnectionsPath } from './connectionsStore'
import { KnownHostsStore, defaultKnownHostsPath } from './knownHosts'
import { resolveHostKey } from './ssh'
import {
listRemote,
mkdirRemote,
renameRemote,
deleteRemote,
chmodRemote,
chownRemote,
uploadRemote,
downloadRemote,
cancelTransfer,
pickFiles,
pickDirectory
} from './sftp'
import { broadcast } from './broadcast'
import { CommandsStore, defaultCommandsPath } from './commands'
import type { CommandItem } from '../shared/commands'
import { createPty, killPty, resizePty, writePty, openSession, configureSessionRuntime, getSessionReplay, registerLogHooks } from './pty'
import { respondZmodem } from './zmodem'
import type { ZmodemResponse } from '../shared/ipc'
/** The renderer document this app loads (dev builds load it from vite instead). */
const RENDERER_FILE = join(__dirname, '../renderer/index.html')
/**
* True only for a document the app itself loaded: the bundled renderer file, or
* in dev anything served by the vite dev server (ELECTRON_RENDERER_URL is read
* in dev builds only — a packaged build always trusts the production file URL,
* never a remote origin). Used both to refuse a navigation away from the app
* page and to refuse IPC from a frame that is not it — a window that navigated
* elsewhere would still hold this preload bridge, which is the whole
* main-process API (`createPty` included).
*/
export function isTrustedRendererUrl(raw: string): boolean {
try {
const target = new URL(raw)
const devUrl = devRendererUrl()
if (devUrl) return target.origin === new URL(devUrl).origin
return target.protocol === 'file:' && target.pathname === pathToFileURL(RENDERER_FILE).pathname
} catch {
return false
}
}
type InvokeListener = (event: IpcMainInvokeEvent, ...args: any[]) => unknown
type EventListener = (event: IpcMainEvent, ...args: any[]) => void
let senderGuardInstalled = false
/**
* Channels are registered from four modules (this one, settingsStore,
* sessionState, updater) and Electron exposes no global IPC hook, so the sender
* check is installed once here — the single entry point all of them are
* registered through — rather than repeated at every registration site.
*
* Handlers never saw the sender before: any frame that managed to navigate
* could drive the main process. Refused invokes reject the renderer's promise;
* refused sends are dropped.
*/
function installSenderGuard(): void {
if (senderGuardInstalled) return
senderGuardInstalled = true
const rawHandle = ipcMain.handle.bind(ipcMain) as (
channel: string,
listener: InvokeListener
) => void
const rawOn = ipcMain.on.bind(ipcMain) as (channel: string, listener: EventListener) => void
ipcMain.handle = ((channel: string, listener: InvokeListener) =>
rawHandle(channel, (event, ...args) => {
if (!isTrustedRendererUrl(event.senderFrame?.url ?? '')) {
throw new Error(`[ipc] refused "${channel}" from an untrusted frame`)
}
return listener(event, ...args)
})) as typeof ipcMain.handle
ipcMain.on = ((channel: string, listener: EventListener) =>
rawOn(channel, (event, ...args) => {
if (!isTrustedRendererUrl(event.senderFrame?.url ?? '')) return
listener(event, ...args)
})) as typeof ipcMain.on
}
let commandsStore: CommandsStore | undefined
/** M5: inject a custom command store (tests) or default to userData-backed. */
export function setCommandsStore(store: CommandsStore): void {
commandsStore = store
}
export function getCommandsStore(): CommandsStore {
if (!commandsStore) {
commandsStore = new CommandsStore(defaultCommandsPath(app.getPath('userData')))
}
return commandsStore
}
export function registerIpc(): void {
installSenderGuard()
const connectionsStore = new ConnectionsStore(defaultConnectionsPath(app.getPath('userData')))
const knownHostsStore = new KnownHostsStore(defaultKnownHostsPath(app.getPath('userData')))
const cmds = getCommandsStore()
// Route PTY output into the session log (M5). logWrite decides whether a
// session is actively logging; logStop finalizes on session close / kill.
registerLogHooks(
(id, data) => cmds.logWrite(id, data),
(id) => cmds.logStop(id)
)
// Runtime deps for the session layer (pty.ts routes into ssh.ts, which stays
// Electron-free).
configureSessionRuntime({
broadcast: (channel, ...args) => broadcast(channel, ...args),
getConnection: (connectionId) => {
const found = connectionsStore.listConnections().find((c) => c.id === connectionId)
if (!found) throw new Error(t('main.ipc.connectionMissing', { id: connectionId }))
return found
},
getSecret: (conn, field) => connectionsStore.getSecret(conn, field),
touch: (id) => connectionsStore.touch(id),
knownHosts: {
check: (host, port, key) => knownHostsStore.check(host, port, key),
accept: (host, port, key, fingerprint) => knownHostsStore.accept(host, port, key, fingerprint)
},
promptHostKey: (prompt) => broadcast(Ipc.HOSTKEY_PROMPT, prompt)
})
ipcMain.handle(
Ipc.APP_INFO,
(): AppInfo => ({ platform: process.platform, appVersion: app.getVersion(), homeDir: homedir() })
)
ipcMain.handle(Ipc.PTY_CREATE, (event, opts?: PtyCreateOptions) => createPty(opts, event.sender.id))
ipcMain.handle(Ipc.SESSION_OPEN, (event, opts: SessionOpenOptions) => openSession(opts, event.sender.id))
ipcMain.handle(Ipc.SESSION_REPLAY, (_event, id: string) => getSessionReplay(id))
ipcMain.on(Ipc.PTY_WRITE, (_event, id: string, data: string) => writePty(id, data))
ipcMain.on(Ipc.PTY_RESIZE, (_event, id: string, cols: number, rows: number) =>
resizePty(id, cols, rows)
)
ipcMain.on(Ipc.PTY_KILL, (_event, id: string) => killPty(id))
// ---- zmodem (M6: main-process engine over ssh sessions) ----
// ZMODEM_OFFER / ZMODEM_DONE are broadcasts (main -> renderer); this is the
// renderer's answer, forwarded to the engine (which routes on resp.id).
ipcMain.on(Ipc.ZMODEM_RESPOND, (_event, resp: ZmodemResponse) => respondZmodem(resp))
// ---- sysinfo (M3: remote hardware monitoring, ssh sessions only) ----
ipcMain.on(Ipc.SYSINFO_START, (_event, id: string) => startPolling(id))
ipcMain.on(Ipc.SYSINFO_STOP, (_event, id: string) => stopPolling(id))
// ---- ssh connections (bookmarks) ----
ipcMain.handle(Ipc.CONNECTIONS_LIST, (): SshConnection[] => connectionsStore.listConnections())
ipcMain.handle(Ipc.CONNECTIONS_SAVE, (_event, input: SshConnectionInput): SshConnection =>
connectionsStore.saveConnection(input)
)
ipcMain.handle(Ipc.CONNECTIONS_DELETE, (_event, id: string) => {
connectionsStore.deleteConnection(id)
})
// HOSTKEY_PROMPT is broadcast; the renderer answers here (send, not handle).
ipcMain.on(Ipc.HOSTKEY_RESPOND, (_event, promptId: string, action: HostKeyAction) => {
resolveHostKey(promptId, action)
})
// ---- sftp (M4) ----
ipcMain.handle(Ipc.SFTP_LIST, (_e, sessionId: string, dir: string) => listRemote(sessionId, dir))
ipcMain.handle(Ipc.SFTP_MKDIR, (_e, sessionId: string, dir: string, name: string) =>
mkdirRemote(sessionId, dir, name)
)
ipcMain.handle(Ipc.SFTP_RENAME, (_e, sessionId: string, from: string, to: string) =>
renameRemote(sessionId, from, to)
)
ipcMain.handle(Ipc.SFTP_DELETE, (_e, sessionId: string, paths: string[]) =>
deleteRemote(sessionId, paths)
)
ipcMain.handle(Ipc.SFTP_CHMOD, (_e, sessionId: string, path: string, mode: string) =>
chmodRemote(sessionId, path, mode)
)
ipcMain.handle(Ipc.SFTP_CHOWN, (_e, sessionId: string, path: string, uid: number, gid: number) =>
chownRemote(sessionId, path, uid, gid)
)
ipcMain.handle(Ipc.SFTP_UPLOAD, (_e, sessionId: string, localPaths: string[], remoteDir: string) =>
uploadRemote(sessionId, localPaths, remoteDir, broadcast)
)
ipcMain.handle(
Ipc.SFTP_DOWNLOAD,
(_e, sessionId: string, remotePaths: string[], localDir: string) =>
downloadRemote(sessionId, remotePaths, localDir, broadcast)
)
ipcMain.on(Ipc.TRANSFER_CANCEL, (_e, transferId: string) => cancelTransfer(transferId))
ipcMain.handle(Ipc.DIALOG_PICK_FILES, () => pickFiles())
ipcMain.handle(Ipc.DIALOG_PICK_DIR, () => pickDirectory())
ipcMain.handle(Ipc.FONTS_LIST, async () => {
try {
return await fontList.getFonts({ disableQuoting: true })
} catch {
return []
}
})
ipcMain.handle(Ipc.LAYOUTS_LIST, (): LayoutMeta[] => listLayouts())
ipcMain.handle(Ipc.LAYOUTS_GET, (_event, id: string) => getLayout(id))
ipcMain.handle(Ipc.LAYOUTS_SAVE, (_event, meta: LayoutMeta, json: string) =>
saveLayout(meta, json)
)
ipcMain.handle(Ipc.LAYOUTS_DELETE, (_event, id: string) => deleteLayout(id))
// ---- command history / library + session logs (M5) ----
ipcMain.handle(Ipc.COMMANDS_RECORD, (_event, cmd: string) => cmds.recordCommand(cmd))
ipcMain.handle(Ipc.COMMANDS_HISTORY_LIST, () => cmds.listHistory())
ipcMain.handle(Ipc.COMMANDS_HISTORY_CLEAR, () => cmds.clearHistory())
ipcMain.handle(Ipc.COMMANDS_LIBRARY_LIST, () => cmds.listLibrary())
ipcMain.handle(Ipc.COMMANDS_LIBRARY_SAVE, (_event, item: CommandItem) =>
cmds.saveLibraryItem(item)
)
ipcMain.handle(Ipc.COMMANDS_LIBRARY_DELETE, (_event, id: string) =>
cmds.deleteLibraryItem(id)
)
ipcMain.handle(Ipc.LOG_START, (_event, sessionId: string) => cmds.logStart(sessionId))
ipcMain.handle(Ipc.LOG_STOP, (_event, sessionId: string) => cmds.logStop(sessionId))
ipcMain.handle(Ipc.LOG_LIST, () => cmds.listSessionLogs())
ipcMain.on(Ipc.LOG_OPEN_DIR, () => cmds.openLogsDir())
registerSettingsIpc()
registerSessionStateIpc()
// ---- lock screen (main owns the state; the renderer only draws the overlay) ----
registerLockIpc()
// Resolve a cd-style argument against the current cwd (platform-aware; only
// the main process has node's `path`).
ipcMain.handle(Ipc.CWD_RESOLVE, (_event, current: string | undefined, arg: string) =>
resolveCwd(current, arg)
)
ipcMain.handle(Ipc.CWD_REPORT, (_event, payload: string) => normalizeReportedCwd(payload))
// Open a local directory in the file manager. Validates the path is an
// existing directory so renderer-supplied values can't open arbitrary files.
ipcMain.handle(Ipc.CWD_OPEN, (_event, dir: unknown): boolean => {
if (typeof dir !== 'string' || dir === '') return false
try {
if (!statSync(dir).isDirectory()) return false
} catch {
return false
}
void shell.openPath(dir)
return true
})
}