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)
269 lines
11 KiB
TypeScript
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
|
|
})
|
|
} |