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 }) }