docs: record this round's fixes in AGENTS.md; add download-stats script; plan M11 in ROADMAP

This commit is contained in:
Bill committed 2026-10-07 20:44:46 +08:00
1 parent 36627ee4b7
commit f5acf26d1f
3 files changed
+179 -4

No files matched your search

+14 -4
View File
@@ -7,8 +7,9 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
- 开发:`npm run dev`(主进程改动不热重建,需重启) - 开发:`npm run dev`(主进程改动不热重建,需重启)
- dev 实例使用独立用户数据目录 `%APPDATA%\OpenTerminal-dev` 与独立单实例锁(`src/main/index.ts` 顶部 `!app.isPackaged` 分支),窗口标题带 `(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 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,然后依次跑可离线运行的 11 个测试:ssh-loopback、commands-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,然后依次跑可离线运行的 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 run dist`(**生命周期先自动跑 `predist` → `npm test`,即类型检查 + 11 个离线测试全部通过后才 build/package**,typecheck 全程只跑一次;predist 末尾的 `npm install --package-lock-only` 会把 `package-lock.json` 根版本号对齐 `package.json`,**发布提交必须带上 package-lock.json**),产物在 `release/`(msi + exe + latest.yml + blockmap) - 下载量统计:`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)
- GitHub Actions:`.github/workflows/ci.yml` 在 windows-latest + Node 22 上跑 `npm ci` / `npm test`(含 pretest typecheck)/ `npm run build`,只做验证,不打包安装器、不发布 - 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` - 国内网络需镜像:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ npm run dist`
@@ -32,6 +33,7 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
- Gitea:release(msi + exe 资产)→ Gitea 更新通道(`api/packages/admin/generic/openterminal-update/stable`:exe.blockmap → exe → release-notes.md → **latest.yml 最后**) - Gitea:release(msi + exe 资产)→ Gitea 更新通道(`api/packages/admin/generic/openterminal-update/stable`:exe.blockmap → exe → release-notes.md → **latest.yml 最后**)
- GitHub:release 资产再次随版本同步发布(exe + exe.blockmap + latest.yml),electron-updater 标准 GitHub provider 直接吃 release 资产 - GitHub:release 资产再次随版本同步发布(exe + exe.blockmap + latest.yml),electron-updater 标准 GitHub provider 直接吃 release 资产
- 通道不再先删旧版:新版本文件全部传完、latest.yml 生效后才清掉上一版 exe/blockmap,中途失败不会把通道打空;同一版本可重复运行(release 复用、已传资产跳过) - 通道不再先删旧版:新版本文件全部传完、latest.yml 生效后才清掉上一版 exe/blockmap,中途失败不会把通道打空;同一版本可重复运行(release 复用、已传资产跳过)
- 上传前先比通道版本:`assertNoDowngrade()` 读通道 latest.yml,若线上版本**高于**待发布版本就直接拒绝——三条本地护栏只比本地产物,旧分支发旧版本号会一路通过,覆盖 latest.yml 之后 `pruneChannel` 会把线上新版本的 exe/blockmap 删掉
- 只补通道:`node scripts/release.cjs <版本号> --channel-only`(不建 release、不发 GitHub) - 只补通道:`node scripts/release.cjs <版本号> --channel-only`(不建 release、不发 GitHub)
- 国内通道全程直连,**不需要设代理**;GitHub 请求走 `HTTPS_PROXY=http://127.0.0.1:7897`(脚本只把它用于 GitHub 请求) - 国内通道全程直连,**不需要设代理**;GitHub 请求走 `HTTPS_PROXY=http://127.0.0.1:7897`(脚本只把它用于 GitHub 请求)
5. 验证更新通道:`curl https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/latest.yml` 应返回新版本号 5. 验证更新通道:`curl https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/latest.yml` 应返回新版本号
@@ -42,6 +44,8 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
- 检查更新:**GitHub 优先**(走系统代理;前置 20 秒连通性探测 `probeGithub`——探测失败/超时直接兜底 Gitea,不给 electron-updater 挂起的机会),失败回退国内 Gitea 通用包通道(强制直连,不走系统代理)。 electron-updater 用独立 session(partition `electron-updater`),代理模式在 `useFeed` 里按源切换 - 检查更新:**GitHub 优先**(走系统代理;前置 20 秒连通性探测 `probeGithub`——探测失败/超时直接兜底 Gitea,不给 electron-updater 挂起的机会),失败回退国内 Gitea 通用包通道(强制直连,不走系统代理)。 electron-updater 用独立 session(partition `electron-updater`),代理模式在 `useFeed` 里按源切换
- 更新日志:Gitea 仓库是私有的(匿名 API 404),改为从更新通道的 `release-notes.md` 读取(直连 session `openterminal-update-direct`),再回退 Gitea/GitHub releases API - 更新日志:Gitea 仓库是私有的(匿名 API 404),改为从更新通道的 `release-notes.md` 读取(直连 session `openterminal-update-direct`),再回退 Gitea/GitHub releases API
- electron-updater 不支持 MSI 自动更新,自动更新只走 NSIS exe - electron-updater 不支持 MSI 自动更新,自动更新只走 NSIS exe
- 每次 checkForUpdates 都被 **30s 整体超时**包住(`withTimeout`):electron-updater 自带的 60s 只是 socket 空闲超时,慢滴流响应能一直占住它。超时按普通失败走 GitHub→Gitea 回退,但**被放弃的检查取消不掉**,而 electron-updater 对并发检查去重(返回同一个 in-flight promise),所以兜底那次共用被放弃的 promise——它同样被超时兜住,最终落到错误态,不会永远停在「检查中」
- 更新日志 / releases API 的 fetch 都带 `AbortSignal.timeout(15s)`(`directFetch` 与 `fetchChangelog` 里的 `net.fetch`):卡住的通道必须让位给下一个来源,不能把「关于」页吊住
## 主题机制 ## 主题机制
@@ -55,7 +59,7 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
## 锁屏 ## 锁屏
- 只使用**主窗口内的不透明遮罩**(`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 仍能走进遮罩后面的浮层 - 只使用**主窗口内的不透明遮罩**(`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**
- 密码 verifier 在 `<userData>/lock.json`(`src/main/lockStore.ts` 的 `LockStore`):scrypt(N=16384,r=8,p=1) + 每次写入重新生成的 16 字节 salt + `timingSafeEqual`,**不存明文**;缺 `version: 1`、salt/hash 尺寸不符一律当「未配置」(宁失效也不崩启动路径),但 `version` 不认识会**每进程 warn 一次**——将来改格式不许静默失锁 - 密码 verifier 在 `<userData>/lock.json`(`src/main/lockStore.ts` 的 `LockStore`):scrypt(N=16384,r=8,p=1) + 每次写入重新生成的 16 字节 salt + `timingSafeEqual`,**不存明文**;缺 `version: 1`、salt/hash 尺寸不符一律当「未配置」(宁失效也不崩启动路径),但 `version` 不认识会**每进程 warn 一次**——将来改格式不许静默失锁
- 锁状态由主进程独占(`src/main/lockController.ts`):`LockSettingsState` 只有 configured/enabled/autoLockMinutes/lockAtStartup/locked/cooldownMs,**salt/hash/密码永不出主进程**,渲染层从不自行判定锁定 - 锁状态由主进程独占(`src/main/lockController.ts`):`LockSettingsState` 只有 configured/enabled/autoLockMinutes/lockAtStartup/locked/cooldownMs,**salt/hash/密码永不出主进程**,渲染层从不自行判定锁定
- 锁标志落盘在 `<userData>/lock-state.json`(`LockStateStore`),**locked/failures/cooldownUntil 每次变化立即写**,所以托盘退出、任务管理器强杀、崩溃后重启仍然是锁的——`lockAtStartup` 只是额外一层。没有 verifier 时启动会删掉该文件;从磁盘恢复的 `cooldownUntil` 夹紧到 `now+30s`,防系统时间回拨导致永久锁死 - 锁标志落盘在 `<userData>/lock-state.json`(`LockStateStore`),**locked/failures/cooldownUntil 每次变化立即写**,所以托盘退出、任务管理器强杀、崩溃后重启仍然是锁的——`lockAtStartup` 只是额外一层。没有 verifier 时启动会删掉该文件;从磁盘恢复的 `cooldownUntil` 夹紧到 `now+30s`,防系统时间回拨导致永久锁死
@@ -63,10 +67,16 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
- 闲置锁屏:`powerMonitor.getSystemIdleTime()`,15s 轮询;读不到(无会话/工作站已锁)一律当「不闲置」。`settings.lock.autoLockMinutes` 是白名单 `{0,1,5,15,30,60}`(`src/shared/settings.ts` 的 `LOCK_AUTO_DELAYS`),0 = 从不 - 闲置锁屏:`powerMonitor.getSystemIdleTime()`,15s 轮询;读不到(无会话/工作站已锁)一律当「不闲置」。`settings.lock.autoLockMinutes` 是白名单 `{0,1,5,15,30,60}`(`src/shared/settings.ts` 的 `LOCK_AUTO_DELAYS`),0 = 从不
- **清除密码会一并把 `settings.lock.enabled`/`lockAtStartup` 置 false**(`LockControllerOptions.clearLockPreferences`,默认走 `mutateSettings`):设置页文案承诺「清除后锁屏会一并关闭」,留着会让用户下次设密码时被静默重新武装 - **清除密码会一并把 `settings.lock.enabled`/`lockAtStartup` 置 false**(`LockControllerOptions.clearLockPreferences`,默认走 `mutateSettings`):设置页文案承诺「清除后锁屏会一并关闭」,留着会让用户下次设密码时被静默重新武装
- 锁屏期间主进程在 `win.webContents.on('before-input-event')` 里吞掉 F5/Ctrl+R、Ctrl+±0(含 Shift 拼写)、Ctrl+Shift+I/J/C:遮罩是 DOM 层,拦不住浏览器进程处理的 Electron 默认菜单加速键,而重载会触发 `beforeunload` 把遮罩后面的会话全杀掉。**键盘判定抽在纯函数模块 `src/main/lockShortcuts.ts`(`isLockBlockedShortcut`/`isPanicLockChord`,无 Electron 依赖,表驱动测试 `tests/lock-shortcuts.mjs`)**——v1.0.17 的回归就是死在闭包里没法测。键盘之外还有鼠标路径:默认菜单按 Alt 就能唤出,菜单项点击不走 before-input-event,所以**锁定期间 `src/main/lockMenu.ts` 把整个应用菜单置 null(解锁时按 Electron 默认模板重建)**;菜单摘除挂在 `LockController` 的默认 publish 上,启动恢复锁定不经过 publish,由 `index.ts` 在 `initLockController()` 后按 `isLocked()` 直接补一次。渲染层的 `document.documentElement.dataset.locked` 守卫(字体快捷键、`Ctrl+PgUp/PgDn`)**只允许 `return` 跳过自身逻辑,绝不能 `preventDefault`**——keydown 的默认动作就是「往聚焦输入框插字符」,窗口级 preventDefault 会把锁屏密码框的全部输入杀掉(v1.0.17 就是这么坏的,v1.0.18 修复) - 锁屏期间主进程在 `win.webContents.on('before-input-event')` 里吞掉 F5/Ctrl+R、Ctrl+±0(含 Shift 拼写)、Ctrl+Shift+I/J/C:遮罩是 DOM 层,拦不住浏览器进程处理的 Electron 默认菜单加速键,而重载会触发 `beforeunload` 把遮罩后面的会话全杀掉。**键盘判定抽在纯函数模块 `src/main/lockShortcuts.ts`(`isLockBlockedShortcut`/`isPanicLockChord`,无 Electron 依赖,表驱动测试 `tests/lock-shortcuts.mjs`)**——v1.0.17 的回归就是死在闭包里没法测。键盘之外还有鼠标路径:默认菜单按 Alt 就能唤出,菜单项点击不走 before-input-event,所以**锁定期间 `src/main/lockMenu.ts` 把整个应用菜单置 null(解锁时按 Electron 默认模板重建)**;菜单摘除挂在 `LockController` 的默认 publish 上,启动恢复锁定不经过 publish,由 `index.ts` 在 `initLockController()` 后按 `isLocked()` 直接补一次。渲染层的 `document.documentElement.dataset.locked` 守卫(字体快捷键、`Ctrl+PgUp/PgDn`)**只允许 `return` 跳过自身逻辑,绝不能 `preventDefault`**——keydown 的默认动作就是「往聚焦输入框插字符」,窗口级 preventDefault 会把锁屏密码框的全部输入杀掉(v1.0.17 就是这么坏的,v1.0.18 修复)
- **Ctrl+L = 立即锁屏**(同一 `before-input-event` 里捕获,终端里也生效——这正是它的意义):仅在锁定真的生效时才 `preventDefault`,未设置密码的应用保留 Ctrl+L 给 shell 的清屏;已锁定时不再拦截。长按的自动重复要跳过(`input.isAutoRepeat`),否则未配置密码时每次重复都同步读 lock.json + settings.json。**Ctrl+L 是保留键**:全局唤起快捷键的录制器(`SettingsTabs.tsx` 的 `RESERVED_EXACT_ACCELERATORS`)拒绝它——globalShortcut 在 OS 层拦截,绑上去会让锁屏快捷键静默失效。窗口藏进托盘后 Ctrl+L 无效(before-input-event 只对聚焦窗口触发),这是刻意的取舍:全局注册会从所有应用手里抢走这个组合键 - **Ctrl+L = 立即锁屏**(同一 `before-input-event` 里捕获,终端里也生效——这正是它的意义):仅在锁定真的生效时才 `preventDefault`,未设置密码的应用保留 Ctrl+L 给 shell 的清屏;已锁定时不再拦截。长按的自动重复要跳过(`input.isAutoRepeat`),否则未配置密码时每次重复都同步读 lock.json + settings.json。**Ctrl+L 是保留键**:全局唤起快捷键的录制器(`SettingsTabs.tsx` 的 `RESERVED_EXACT_ACCELERATORS`)拒绝它——globalShortcut 在 OS 层拦截,绑上去会让锁屏快捷键静默失效。窗口藏进托盘后 Ctrl+L 无效(before-input-event 只对聚焦窗口触发),这是刻意的取舍:全局注册会从所有应用手里抢走这个组合键。**主进程侧也拒绝注册它**:`applyGlobalShortcut`(globalShortcuts.ts)对 `Control+L` / `Ctrl+L` / `CommandOrControl+L`(大小写、修饰键别名都不敏感)直接跳过并 warn——录制器只拦得住它上线之后录入的值,老版本存下的 `Control+L` 仍会走到注册这一步
- 启动时**不要**用 `locked: true` 作渲染层初值再直接画锁屏:`App.tsx` 用 `null` 表示「主进程还没答复」,此时只画 `.lock-screen-boot` 纯色层,否则每次启动都会给没设密码的用户闪一帧锁屏。`getLockState()` 失败时要落到「locked 且未配置」的状态,让输入框可达(主进程对无 verifier 的解锁请求直接放行) - 启动时**不要**用 `locked: true` 作渲染层初值再直接画锁屏:`App.tsx` 用 `null` 表示「主进程还没答复」,此时只画 `.lock-screen-boot` 纯色层,否则每次启动都会给没设密码的用户闪一帧锁屏。`getLockState()` 失败时要落到「locked 且未配置」的状态,让输入框可达(主进程对无 verifier 的解锁请求直接放行)
- 相关测试:`node tests/lock-store.mjs`(verifier + 状态存储)、`node tests/lock-controller.mjs`(冷却阶梯、并发串行化、落盘恢复、闲置触发、清除联动)、`node tests/lock-shortcuts.mjs`(键盘分类器表驱动用例) - 相关测试:`node tests/lock-store.mjs`(verifier + 状态存储)、`node tests/lock-controller.mjs`(冷却阶梯、并发串行化、落盘恢复、闲置触发、清除联动)、`node tests/lock-shortcuts.mjs`(键盘分类器表驱动用例)
## 布局模板(工作区)
- 应用模板(`Workspace.handleApplyTemplate`)读的是**外来 JSON**。`fromJSON` 一旦中途失败(别的版本写的模板、面板组件已不存在),dockview 会**先把目标 dockview 清空再抛错**(`failed to deserialize layout. Reverting changes`)。清空是逐面板走 `onDidRemovePanel` 的,所以**旧会话在抛错之前就已经被 `killSession` 杀掉了**——恢复出来的面板接不回它们,必须换新会话
- 失败路径:应用前 `toJSON()` 快照两个 dockview → 抛错时只对**这次真的调用过 `fromJSON` 的** dockview 回灌快照(回灌本身会清空该 dockview;把快照灌进没被碰过的那个会连带杀掉它活着的会话)→ 对恢复出来的面板跑 `rebindRestoredPanels` → 再把「没有任何面板引用的 `previousSessions`」kill 掉 → `message.error`。`JSON.parse` 失败同样要提示,不能静默 return
- 会话计数不靠累加器:`releaseSession` 直接扫 `api.panels` 判断还有没有面板在显示该会话——累加器与「`updateParameters` 原地换会话」「整块布局替换」这类无事件变化脱节,会漏杀或误杀
## 关键词高亮 ## 关键词高亮
- 预设规则表在 `src/shared/settings.ts` 的 `DEFAULT_HIGHLIGHT_RULES`(22 条),引擎在 `src/renderer/src/terminal/highlightEngine.ts`;规则按 priority 升序应用,**先匹配到的 span 归先跑的规则,后续规则遇到重叠直接跳过** - 预设规则表在 `src/shared/settings.ts` 的 `DEFAULT_HIGHLIGHT_RULES`(22 条),引擎在 `src/renderer/src/terminal/highlightEngine.ts`;规则按 priority 升序应用,**先匹配到的 span 归先跑的规则,后续规则遇到重叠直接跳过**
+40
View File
@@ -17,6 +17,7 @@
| M8 | 工作区分离(Terminal/SSH 双工作区)+ SSH 底部文件面板 + 右键菜单 + 监控美化 + 补全修复 | ✅ 已完成(v0.8) | | M8 | 工作区分离(Terminal/SSH 双工作区)+ SSH 底部文件面板 + 右键菜单 + 监控美化 + 补全修复 | ✅ 已完成(v0.8) |
| M9 | 体验打磨:系统托盘 + 关闭行为 + 单实例 + 主题联动标题栏 + 布局菜单重做 + 补全交互修正 + 应用图标 | ✅ 已完成 | | M9 | 体验打磨:系统托盘 + 关闭行为 + 单实例 + 主题联动标题栏 + 布局菜单重做 + 补全交互修正 + 应用图标 | ✅ 已完成 |
| M10 | 多语言界面(zh-CN / zh-TW / en / ja)+ 离线多语言更新日志 + 快捷键录制 + 可配终端工具条 + 设置健壮性 | ✅ 已完成(v1.0.13) | | M10 | 多语言界面(zh-CN / zh-TW / en / ja)+ 离线多语言更新日志 + 快捷键录制 + 可配终端工具条 + 设置健壮性 | ✅ 已完成(v1.0.13) |
| M11 | 审查修复第二轮:渲染层性能 + asar 瘦身 + 安全纵深 + 测试补全 + 文档刷新 | 📋 已规划(2026-10 审查) |
## M2 — SSH 远程会话 ✅ ## M2 — SSH 远程会话 ✅
@@ -106,6 +107,45 @@
- [x] 设置健壮性:清洗器修复而不丢弃(priority 字符串、0/1 布尔、缺前景色),异常写 `settings-warnings.log`;设置写入串行化;「每次询问」关闭行为不再被静默改回 - [x] 设置健壮性:清洗器修复而不丢弃(priority 字符串、0/1 布尔、缺前景色),异常写 `settings-warnings.log`;设置写入串行化;「每次询问」关闭行为不再被静默改回
- [x] 「输入建议」「记录命令历史」默认关闭;浅色主题标签栏/设置弹窗对比度修复;会话日志改为每文件缓冲写入 - [x] 「输入建议」「记录命令历史」默认关闭;浅色主题标签栏/设置弹窗对比度修复;会话日志改为每文件缓冲写入
## M11 — 审查修复第二轮(计划)
背景:2026-10 全面审查(6 个子代理分域审查 + 逐条核实)已在 Dev_20261001 落地第一轮修复(store 损坏兜底、zmodem 错误/节流/终结、release 降级护栏、主进程加固、模板应用恢复、锁屏 portal inert、设置页/SFTP 界面)。以下按优先级排队,均可独立交付。
### 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 重建(应只建一次)
### 2. asar 瘦身(~85MB)
- [ ] 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 依赖树提升)
### 3. 安全纵深
- [ ] SFTP/zmodem/`keyPath` 的本地路径包含性校验(参照 `commands.ts:273-299` 的 realpath 包含性检查);当前只有受信任帧 guard 兜底
- [ ] `webPreferences` 显式写出 `contextIsolation: true`、`nodeIntegration: false`、`webSecurity: true`(现靠默认值,防将来被改)
- [ ] 代码签名(M7 遗留,可选项;顺带关闭更新通道的完整性纵深缺口)
### 4. 测试补全
- [ ] 零测试高风险模块:`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` 而非重写连接参数
### 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 等
## 工程约定(贯穿各阶段) ## 工程约定(贯穿各阶段)
1. **契约先行**:新增功能先在 `src/shared/` 定义通道与模型,再实现两端 1. **契约先行**:新增功能先在 `src/shared/` 定义通道与模型,再实现两端
+125
View File
@@ -0,0 +1,125 @@
// Download stats: Gitea releases (domestic channel) + GitHub releases (intl).
// Usage: node scripts/download-stats.cjs
// Credentials from .env (repo root, gitignored): GIT_TOKEN, GH_TOKEN.
// GitHub traffic honors HTTPS_PROXY / HTTP_PROXY (env or .env); Gitea is
// always direct — same policy as scripts/release.cjs.
//
// Caveats the printed report also states:
// - The auto-update channel (Gitea generic package `openterminal-update`)
// does not expose download counts, so updater traffic is not included.
// - Counts reflect currently existing release assets only; re-uploaded
// assets restart from zero.
const fs = require('node:fs')
const path = require('node:path')
const ROOT = path.join(__dirname, '..')
const env = Object.fromEntries(
fs.readFileSync(path.join(ROOT, '.env'), 'utf8').split('\n')
.map((l) => l.trim())
.filter((l) => l.includes('=') && !l.startsWith('#'))
.map((l) => { const i = l.indexOf('='); return [l.slice(0, i).trim(), l.slice(i + 1).trim()] })
)
const proxy = process.env.HTTPS_PROXY || process.env.https_proxy || env.HTTPS_PROXY || env.PROXY
// Fallback for shells without the env set: the local proxy documented in
// AGENTS.md is how GitHub traffic gets out on this network; a direct call
// dies with ECONNRESET.
const ghAgent = (p) => (p ? new (require('undici').ProxyAgent)(p) : undefined)
const GH_FALLBACK_PROXY = 'http://127.0.0.1:7897'
const GITEA = 'https://git.codingplan.site/api/v1/repos/admin/OpenTerminal'
const GITHUB = 'https://api.github.com/repos/billowliu2/OpenTerminal'
/** Sum download counts per release, split by exe/msi/other. */
function tally(releases) {
let total = 0, exe = 0, msi = 0
const rows = []
for (const r of releases) {
let n = 0, e = 0, m = 0
for (const a of r.assets || []) {
n += a.download_count || 0
if (a.name.endsWith('.exe')) e += a.download_count || 0
else if (a.name.endsWith('.msi')) m += a.download_count || 0
}
rows.push({ tag: r.tag_name || r.name, total: n, exe: e, msi: m })
total += n; exe += e; msi += m
}
rows.sort((a, b) => b.total - a.total)
return { rows, total, exe, msi }
}
async function giteaReleases() {
// Paginated: Gitea caps `limit` at 50.
const all = []
for (let page = 1; ; page++) {
const resp = await fetch(`${GITEA}/releases?limit=50&page=${page}`, {
headers: { Authorization: `token ${env.GIT_TOKEN}` }
})
if (!resp.ok) throw new Error(`gitea HTTP ${resp.status}`)
const batch = await resp.json()
all.push(...batch)
if (batch.length < 50) return all
}
}
async function githubFetch(url) {
const headers = {
Accept: 'application/vnd.github+json',
'User-Agent': 'openterminal-download-stats',
...(env.GH_TOKEN ? { Authorization: `Bearer ${env.GH_TOKEN}` } : {})
}
const attempts = [{}, { dispatcher: ghAgent(proxy) }, { dispatcher: ghAgent(GH_FALLBACK_PROXY) }]
let lastErr
for (const extra of attempts) {
try {
const resp = await fetch(url, { headers, ...extra })
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
return resp.json()
} catch (e) { lastErr = e }
}
throw lastErr
}
async function githubReleases() {
const all = []
for (let page = 1; ; page++) {
const batch = await githubFetch(`${GITHUB}/releases?per_page=100&page=${page}`)
all.push(...batch)
if (batch.length < 100) return all
}
}
function print(source, t) {
console.log(`\n== ${source} ==`)
for (const r of t.rows) {
if (!r.total) continue
console.log(`${r.tag.padEnd(12)} total ${String(r.total).padStart(5)} exe ${String(r.exe).padStart(5)} msi ${String(r.msi).padStart(4)}`)
}
console.log(` 小计 ${t.total}(exe ${t.exe} / msi ${t.msi} / 其他 ${t.total - t.exe - t.msi})`)
}
async function main() {
let gh, gt
const fails = []
try { gt = tally(await giteaReleases()) } catch (e) { fails.push(`Gitea: ${e.message}`) }
try { gh = tally(await githubReleases()) } catch (e) { fails.push(`GitHub: ${e.message}`) }
if (gt) print('国内 · Gitea Releases', gt)
if (gh) print('国外 · GitHub Releases', gh)
if (gt && gh) {
const total = gt.total + gh.total
console.log(`\n合计(可计数部分): ${total}`)
}
console.log('\n口径说明:')
console.log('- 应用内自动更新走 Gitea 通用包通道(openterminal-update),该通道无下载计数接口,未计入。')
console.log('- 计数只反映当前存活的 release 资产,重传过的资产从零累计。')
if (fails.length) {
console.error('\n部分来源失败:')
for (const f of fails) console.error(' -', f)
process.exitCode = 1
}
}
main()