diff --git a/AGENTS.md b/AGENTS.md index bd107e6..e8ccbfd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,9 +7,10 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。 - 开发:`npm run dev`(主进程改动不热重建,需重启) - dev 实例使用独立用户数据目录 `%APPDATA%\OpenTerminal-dev` 与独立单实例锁(`src/main/index.ts` 顶部 `!app.isPackaged` 分支),窗口标题带 `(dev)`:**可与已安装的正式版同时运行,互不干扰**,也不会把测试设置/会话写进真实配置 - 类型检查:`npm run typecheck`(tsconfig.node.json + tsconfig.web.json;只看渲染层可单跑 `npx tsc --noEmit -p tsconfig.web.json`) -- 测试:`npm test`(**npm 生命周期先自动跑 `pretest` 做类型检查**,再 `node tests/build-bundles.cjs` 重建 esbuild bundle,然后依次跑可离线运行的 12 个测试:ssh-loopback、commands-store、connections-store、settings-store、lock-store、lock-controller、lock-shortcuts、hl-split-smoke、hl-rules、zmodem-e2e、ssh-session-e2e、sysinfo-e2e;真实服务器测试需 JD_* 凭据,不在此列) +- 测试:`npm test`(**npm 生命周期先自动跑 `pretest` 做类型检查**,再 `node tests/build-bundles.cjs` 重建 esbuild bundle,然后依次跑可离线运行的 17 个测试:ssh-loopback、commands-store、connections-store、settings-store、local-path-grants、lock-store、lock-controller、lock-shortcuts、hl-split-smoke、hl-rules、reserved-accelerators、ipc-guard、updater-fallback、log-sanitizer、sftp-timeout、zmodem-e2e、ssh-session-e2e、sysinfo-e2e;真实服务器测试需 JD_* 凭据,不在此列) +- 依赖分类规则:**只有 `src/main/`/`src/preload/` 实际 import 的包才能进 `dependencies`**(node-pty/ssh2/zmodem.js/font-list/electron-updater);纯渲染层依赖一律 devDependencies(Vite 全量打包进 out/renderer,`externalizeDepsPlugin` 不作用渲染层)——这条让 asar 从 98MB 瘦到 8.1MB,加新依赖时别放错边 - 下载量统计:`node scripts/download-stats.cjs`(Gitea + GitHub release 资产的 download_count 汇总;GitHub 优先直连、失败自动回退 `HTTPS_PROXY`/本地 7897;更新通道无计数接口不计入) -- 打包:`npm run dist`(**生命周期先自动跑 `predist` → `npm test`,即类型检查 + 12 个离线测试全部通过后才 build/package**,typecheck 全程只跑一次;predist 末尾的 `npm install --package-lock-only` 会把 `package-lock.json` 根版本号对齐 `package.json`,**发布提交必须带上 package-lock.json**),产物在 `release/`(msi + exe + latest.yml + blockmap) +- 打包:`npm run dist`(**生命周期先自动跑 `predist` → `npm test`,即类型检查 + 17 个离线测试全部通过后才 build/package**,typecheck 全程只跑一次;predist 末尾的 `npm install --package-lock-only` 会把 `package-lock.json` 根版本号对齐 `package.json`,**发布提交必须带上 package-lock.json**),产物在 `release/`(msi + exe + latest.yml + blockmap) - GitHub Actions:`.github/workflows/ci.yml` 在 windows-latest + Node 22 上跑 `npm ci` / `npm test`(含 pretest typecheck)/ `npm run build`,只做验证,不打包安装器、不发布 - 国内网络需镜像:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ npm run dist` @@ -58,6 +59,18 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。 - `TerminalView.scheduleFit`:fit 后**去抖 100ms** 再把 cols/rows 发给 PTY,并跳过与上次相同的尺寸。每次 ResizeObserver 都戳 PTY 会让全屏 TUI(Claude Code 等)在最大化/还原的中间尺寸上反复重绘,留下重复帧 - 拖动窗口期间 xterm 网格立即更新,PTY 尺寸在停止后 100ms 生效 +## 性能与安全边界(M11 第二轮) + +- `TerminalView` 用 **5 组 `useShallow` 字段级订阅**(fontOpts/themeOpts/bgOpts/highlightOpts/toolbar + rendererMode),**不要再回到整对象订阅**——主进程 `mutateSettings` 回推整个 settings 对象,任何写入都会变身份,整对象订阅会让所有 pane 重写 options + refit。`useResolvedTheme` 也是 shallow + memo 后的稳定引用 +- `TerminalView` **已无 imperative handle**(`TerminalHandle`/forwardRef 全删,无人传 ref);要加「外部聚焦/清屏」需重新引入 +- `FilePanel` 虚拟化:固定行高自实现 windowing(>200 条目启用)。**`.sftp-row` 的 `height: 24px`(`sftp.css`)与 `FilePanel.tsx` 的 `ROW_HEIGHT` 必须同步改**——本仓库没有全局 box-sizing reset,行高写成 `box-sizing: border-box` 是有意的,去掉会让虚拟列表算术错位 +- 每个 dockview pane 内有 `PanelErrorBoundary`(`ErrorBoundary.tsx`):pane 崩溃只卸载自己,根部边界仍兜底 +- **本地路径准入**(`src/main/localPathGrants.ts`):SFTP 上传/下载与 zmodem 收发的本地路径必须来自系统对话框授权(pickFiles/pickDirectory 是唯一授权源,进程级内存注册表、不落盘),realpath+stat 双重校验、Windows 大小写折叠、解析后的路径才是要打开的路径。新增「渲染层构造本地路径传给主进程」的调用点时**必须过这道门**,别绕 +- `keyPath`(SSH 私钥):realpath → stat → 普通文件且 ≤1MB 才读(防设备文件永久阻塞 UI 线程/符号链接逃逸) +- `src/shared/reservedAccelerators.ts`:设置页录制器与主进程 `applyGlobalShortcut` **共用同一张保留键表**(Ctrl+L、Ctrl+=/-/0/PgUp/PgDn),两处分表曾漂移出洞,加新全局快捷键时两边自动一致 +- SFTP 操作有 per-op 超时(`sftp.ts` 的 `bounded()`:元数据 30s / 传输块 60s / open 10s),超时按 transport 错误驱逐半死通道并重试一次;`setSftpTimeouts`/`setUpdateTimeouts` 是**测试缝**,生产无调用者,别接设置项 +- `webPreferences` 显式写死 `contextIsolation: true / nodeIntegration: false / webSecurity: true`(`index.ts` 唯一窗口创建点),防默认值被将来改动 + ## 锁屏 - 只使用**主窗口内的不透明遮罩**(`src/renderer/src/lock/LockScreen.tsx` + `lock.css` 的 `.lock-screen`,z-index 4000),**不创建第二个 Electron 窗口**。锁定时 `App.tsx` 把 `.app-root` 设为 `inert` 并**保持挂载**——卸载会杀掉遮罩后面的本地/SSH 会话与传输列表;antd portal(Modal/Dropdown/Tooltip)挂在 `document.body` 上、不在 `#root` 内,锁定时**要把 body 下 `#root` 以外的子节点也设为 `inert`**,否则键盘 Tab 仍能走进遮罩后面的浮层。**这个一次性快照不够**:锁定之后才挂上的 portal 不在其中(典型场景是 SSH 连接发出后闲置自动锁屏,主机密钥弹窗此刻才弹出,antd 的 autoFocus 还会抢走密码框焦点),所以锁定期间 `App.tsx` 用 `MutationObserver` 盯着 `document.body` 的 childList,给新加的非 `#root` 子节点补 `inert`,解锁时断开 observer 并按记录恢复;observer 只在锁定时存在,平时零开销,且**锁屏遮罩自身在 `#root` 内,永远不参与 inert** diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0849aed..b4e50cd 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -17,7 +17,7 @@ | M8 | 工作区分离(Terminal/SSH 双工作区)+ SSH 底部文件面板 + 右键菜单 + 监控美化 + 补全修复 | ✅ 已完成(v0.8) | | M9 | 体验打磨:系统托盘 + 关闭行为 + 单实例 + 主题联动标题栏 + 布局菜单重做 + 补全交互修正 + 应用图标 | ✅ 已完成 | | M10 | 多语言界面(zh-CN / zh-TW / en / ja)+ 离线多语言更新日志 + 快捷键录制 + 可配终端工具条 + 设置健壮性 | ✅ 已完成(v1.0.13) | -| M11 | 审查修复第二轮:渲染层性能 + asar 瘦身 + 安全纵深 + 测试补全 + 文档刷新 | 📋 已规划(2026-10 审查) | +| M11 | 审查修复第二轮:渲染层性能 + asar 瘦身 + 安全纵深 + 测试补全 + 文档刷新 | ✅ 已完成(2026-10,Dev_20261001) | ## M2 — SSH 远程会话 ✅ @@ -107,44 +107,46 @@ - [x] 设置健壮性:清洗器修复而不丢弃(priority 字符串、0/1 布尔、缺前景色),异常写 `settings-warnings.log`;设置写入串行化;「每次询问」关闭行为不再被静默改回 - [x] 「输入建议」「记录命令历史」默认关闭;浅色主题标签栏/设置弹窗对比度修复;会话日志改为每文件缓冲写入 -## M11 — 审查修复第二轮(计划) +## M11 — 审查修复第二轮 ✅ -背景:2026-10 全面审查(6 个子代理分域审查 + 逐条核实)已在 Dev_20261001 落地第一轮修复(store 损坏兜底、zmodem 错误/节流/终结、release 降级护栏、主进程加固、模板应用恢复、锁屏 portal inert、设置页/SFTP 界面)。以下按优先级排队,均可独立交付。 +背景:2026-10 全面审查(6 个子代理分域审查 + 逐条核实)已在 Dev_20261001 落地两轮修复(第一轮:store 损坏兜底、zmodem 错误/节流/终结、release 降级护栏、主进程加固、模板应用恢复、锁屏 portal inert、设置页/SFTP 界面、背景图浅色主题可读性 + 压暗 scrim;第二轮即下列各项)。 -### 1. 渲染层性能 +### 1. 渲染层性能 ✅ -- [ ] `TerminalView` settings 字段级订阅:现状是整对象订阅 + options effect 依赖 `settings.terminal` 身份,而主进程 `mutateSettings` 回推整个对象,任何设置写入都让所有 pane 重写 options + fit。需要在 store 层做浅比较/selector 拆分,或 effect 按字段分组 -- [ ] `FilePanel` 虚拟化:上万条目目录(node_modules)一次性挂载 + 每行一个 Dropdown 会卡死窗口;无虚拟化库依赖,可自实现固定行高 windowing 或引入 rc-virtual-list(antd 系) -- [ ] per-panel ErrorBoundary:现在只有根部一个(`main.tsx`),任一 pane 崩溃卸载整个工作区;dockview panel 内容各加一层边界 -- [ ] `SshBottomPanel` 拖动期间 FilePanel 按帧重渲染:`React.memo(FilePanel)` 或拖动期只改 CSS 变量、pointerup 落 state -- [ ] `TransferPanel` 每事件 Map 全量复制(zmodem 节流已缓解数量级,仍可改增量更新);`MonitorPanel` 图表 ResizeObserver 每 3s 重建(应只建一次) +- [x] `TerminalView` settings 字段级订阅:5 组 `useShallow` 分组订阅,主题写 options 不再 refit;`useResolvedTheme` 改 shallow + memo +- [x] `FilePanel` 虚拟化:自实现固定行高(24px)windowing,>200 条目启用;无新依赖 +- [x] per-panel ErrorBoundary:pane 崩溃只显示该 pane 占位 + 重试,不再卸载整个工作区 +- [x] `SshBottomPanel` 拖动:`React.memo(FilePanel)`(唯一 prop 恒定) +- [x] `TransferPanel` 改 ref+version 增量更新 + memo 行;`MonitorPanel` ResizeObserver 只建一次 -### 2. asar 瘦身(~85MB) +### 2. asar 瘦身 ✅(98MB → 8.1MB,↓91.7%) -- [ ] 10 个纯渲染层依赖(`@xterm/*`、`antd`、`dockview-react`、`react`、`react-dom`、`zustand`)从 dependencies 移到 devDependencies:渲染层由 Vite 全量打包进 `out/renderer`,`externalizeDepsPlugin` 只作用 main/preload,安全;实测 asar 98MB 中 node_modules 占 95.7MB,其中渲染层包 ≈85MB 是重复体积 -- [ ] 验收:`npm run dist` 后 asar 内不再有这些包,win-unpacked 冒烟通过 -- [ ] `undici` 显式加入 devDependencies(`release.cjs`/`download-stats.cjs` 直接 require,目前靠 electron-builder 依赖树提升) +- [x] 10 个纯渲染层依赖移到 devDependencies,顺手删死依赖 `@xterm/addon-serialize`;dependencies 只剩 main 实际 import 的 5 个(node-pty/ssh2/zmodem.js/font-list/electron-updater) +- [x] 验收:dist:dir 后 asar 内零渲染层包,asarUnpack 原生件完好,win-unpacked 冒烟通过 +- [x] `undici` 显式 devDependencies -### 3. 安全纵深 +### 3. 安全纵深 ✅ -- [ ] SFTP/zmodem/`keyPath` 的本地路径包含性校验(参照 `commands.ts:273-299` 的 realpath 包含性检查);当前只有受信任帧 guard 兜底 -- [ ] `webPreferences` 显式写出 `contextIsolation: true`、`nodeIntegration: false`、`webSecurity: true`(现靠默认值,防将来被改) -- [ ] 代码签名(M7 遗留,可选项;顺带关闭更新通道的完整性纵深缺口) +- [x] 本地路径准入层 `src/main/localPathGrants.ts`:SFTP/zmodem/keyPath 只接受对话框授权路径,realpath+stat 双重校验,防符号链接逃逸;`keyPath` 限普通文件 ≤1MB +- [x] `webPreferences` 显式 `contextIsolation/nodeIntegration/webSecurity` +- [ ] 代码签名(M7 遗留可选项,仍未做;更新通道完整性缺口随之保留) -### 4. 测试补全 +### 4. 测试补全 ✅(离线测试 12 → 17) -- [ ] 零测试高风险模块:`updater.ts`(feed 回退/超时)、`ipc.ts`(sender guard,安全关键)、`logSanitizer.ts`(ANSI 状态机);`connectionsStore` 本轮已补 -- [ ] 保留键判定抽纯函数(仿 `lockShortcuts.ts`)+ 表驱动测试 -- [ ] SFTP per-op 超时(静默半死通道不 emit error 的场景,本轮只兜住子系统打开) -- [ ] 渲染层测试框架评估(vitest + jsdom);`ssh-loopback.mjs` 改为加载真实 `ssh.ts` 而非重写连接参数 +- [x] `updater.ts`(feed 回退/超时预算,82 断言)、`ipc.ts`(sender guard 真实注册路径灌伪造帧,43)、`logSanitizer.ts`(ANSI 状态机 + 字节切分 fuzz,71) +- [x] 保留键判定抽纯函数 `src/shared/reservedAccelerators.ts`,渲染层/主进程两表合一(56 断言) +- [x] SFTP per-op 超时(元数据 30s/传输块 60s/open 10s,驱逐半死通道重试一次,39 断言) +- [x] `ssh-loopback.mjs` 改加载真实 `ssh.ts` bundle(50 断言:TOFU/认证门/连接预算) +- [x] 渲染层测试框架评估:结论暂不引入 vitest/jsdom,纯逻辑继续抽函数走 bundle 测试 -### 5. 文档与杂物 +### 5. 文档与杂物 ✅ -- [ ] README 测试清单/架构图刷新、锁屏功能补录;`docs/STATE.md` 版本与发布流程刷新(还停在 v1.0.19、`--skip-github` 旧流程) -- [ ] CI 缓存 Electron 二进制(每次省 ~110MB 下载);`.gitignore` 补 `.env.*` -- [ ] 归档脚本纳入版本控制并修危险默认值(`tmp-test/archive-releases.cjs` 的 `KEEP_TAG` 硬编码 v1.0.12);`release/` 已 9.1GB,定清理策略 -- [ ] `isPanicLockChord` 改判 `input.code`(Dvorak/非拉丁布局下 `input.key` 不可靠) -- [ ] P3 零散项:设置页「更新通道」无效控件、面板标题存已翻译字符串(切语言错位)、`TerminalHandle` 死 API、`layoutMenu`/`IconRail` memo 等 +- [x] README 测试清单/锁屏补录;`docs/STATE.md` 刷新到 v1.0.20 现行发布流程 +- [x] CI 缓存 Electron 二进制;`.gitignore` 补 `.env.*`(保留模板豁免) +- [x] 归档脚本入 `scripts/`,`KEEP_TAG` 改必传参数 +- [x] `isPanicLockChord` 改判 `input.code`(物理键位,Dvorak/非拉丁布局正确) +- [x] P3:设置页「更新通道」死控件移除、layoutMenu/IconRail memo + onOpenSettings useCallback、TerminalHandle 死 API 移除 +- [ ] 面板标题存已翻译字符串(切语言错位)——** deferred**:牵涉 5 个消费点 + 旧持久化数据兼容,另开一轮处理 ## 工程约定(贯穿各阶段)