面板 id 曾直接复用 pty 会话 id(`addPanel({ id: sessionId })`),而
`updateParameters` 只能换会话、不能改面板 id。于是恢复时面板 id 指向一个
已不存在的会话:分屏少一个、剩下的 pane 背后没有 pty,界面看起来是空白,
也无法输入。
- 新增 `panelId()`,面板 id 与会话 id 彻底分离,并用于所有建面板处
(新建终端、SSH 连接、分屏复制)
- 新增 `breakIdCoincidence()`:旧快照仍带 `panelId === sessionId`,
在 `fromJSON` 之前重写 grid/panels/views/activeView 中的 id,
返回 old→new 映射
- `rebindSessionPanels` 用该映射回查快照,恢复每个 pane 自己的目录
(否则新 id 查不到记录,cwd 会静默退回 home)
验证(重启恢复 E2E,legacy 快照 + 2 分屏):
2/2 pane 挂载、2 个独立存活会话、输入可回环、
cwd 分别回到 D:/AIGC/OpenTerminal 与 C:/Windows、回写快照无 id 冲突;
restoreSession=false 时正确忽略快照只建 1 个终端。
184 lines
7.8 KiB
TypeScript
184 lines
7.8 KiB
TypeScript
import { app, ipcMain } from 'electron'
|
|
import fontList from 'font-list'
|
|
import { homedir } from 'os'
|
|
import { Ipc, type AppInfo, type LayoutMeta, type PtyCreateOptions } from '../shared/ipc'
|
|
import type { HostKeyAction, SessionOpenOptions, SshConnection, SshConnectionInput } from '../shared/connections'
|
|
import { getLayout, listLayouts, saveLayout, deleteLayout } from './layouts'
|
|
import { startPolling, stopPolling } from './sysinfo'
|
|
import { registerSettingsIpc } from './settingsStore'
|
|
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'
|
|
|
|
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 {
|
|
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(`连接书签不存在 (${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))
|
|
ipcMain.handle(Ipc.SESSION_OPEN, (_event, opts: SessionOpenOptions) => openSession(opts))
|
|
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()
|
|
|
|
// 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))
|
|
} |