CI / typecheck + test + build (windows) (push) Canceled after 0s
- grouped view actually clusters: drop the priority column's defaultSortOrder that made antd re-sort the dataSource and undo the category clustering - replace import is Popconfirm-guarded and the import mode resets to append each time the dialog opens (it stayed on the destructive choice) - rule editor exposes the basic flag (basic-mode membership) with a switch; clearing it on edit now actually removes the flag - rule/profile writes read the store at call time instead of the render-scoped array, so two writes in one React batch no longer drop the first - profile editor lists the rules a non-empty profile leaves out (uses the previously dead excludedByProfile helper) - bands editor keeps rows sorted by min; band preview keys by index (duplicate min no longer collides); clear-background also closes the bg picker - TerminalView pushes compiled rules into the HighlightStream from an effect instead of during render
16 KiB
16 KiB
OpenTerminal 开发备忘
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,然后依次跑可离线运行的 10 个测试:ssh-loopback、commands-store、settings-store、lock-store、lock-controller、hl-split-smoke、hl-rules、zmodem-e2e、ssh-session-e2e、sysinfo-e2e;真实服务器测试需 JD_* 凭据,不在此列) - 打包:
npm run dist(生命周期先自动跑predist→npm test,即类型检查 + 10 个离线测试全部通过后才 build/package,typecheck 全程只跑一次),产物在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
- GitHub Actions:
仓库与远程
origin= Giteahttps://git.codingplan.site/admin/OpenTerminal.git(主仓库 + 更新通道)github=https://github.com/billowliu2/OpenTerminal.git(镜像 + 备用更新源)- ⚠️ Gitea 的 upload-pack 有故障,
git fetch origin会报bad pack header;查询远端状态用 API 或git ls-remote。推送正常。 - 2026-09:远端 main 旧历史(M3–M8,至 v0.8.0 d3a628f)被 force-push 覆盖为当前历史,旧提交仍由 tag v0.6.0 / v0.7.0 / v0.8.0 保留。
凭据
.env(已 gitignore):GIT_TOKEN(Gitea)、GH_TOKEN(GitHub)等。git 推送通过 credential helper 使用该令牌。
发布新版本
package.json版本号 +1,写RELEASE_NOTES.md(仓库根,已 gitignore;可选.zh-TW/.en/.ja译文,缺译文回退简体)node scripts/sync-changelog.cjs把发布说明并入CHANGELOG*.md—— 必须在npm run dist之前:更新日志以?raw打进安装包(「关于」页离线读),release.cjs也校验CHANGELOG.md已含该版本号npm run dist构建node scripts/release.cjs <版本号> --skip-github,例如node scripts/release.cjs 1.0.14 --skip-github- 只发国内(2026-09-15 起的产品决定):Gitea release(msi + exe 资产)→ Gitea 更新通道(
api/packages/admin/generic/openterminal-update/stable:exe.blockmap → exe → release-notes.md → latest.yml 最后) - 通道不再先删旧版:新版本文件全部传完、latest.yml 生效后才清掉上一版 exe/blockmap,中途失败不会把通道打空;同一版本可重复运行(release 复用、已传资产跳过)
- 只补通道:
node scripts/release.cjs <版本号> --channel-only(不建 release、不发 GitHub) - 国内通道全程直连,不需要设代理;代理只在显式补发 GitHub 时才用(
HTTPS_PROXY=http://127.0.0.1:7897,脚本只把它用于 GitHub 请求) - GitHub 那一步的状态:
README与更新机制里仍保留 GitHub 作为更新回退源,但release 资产不再随版本发布同步;若某天需要补,跑一次node scripts/release.cjs <版本号> --skip-gitea即可
- 只发国内(2026-09-15 起的产品决定):Gitea release(msi + exe 资产)→ Gitea 更新通道(
- 验证更新通道:
curl https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/latest.yml应返回新版本号 git tag v<版本号>并推送两个远程(代码/tag 的镜像保持同步,仅 release 资产不发 GitHub)
更新机制
- 检查更新:先 Gitea 更新通道(强制直连,不走系统代理),失败回退 GitHub(走系统代理)。 electron-updater 用独立 session(partition
electron-updater),代理模式在useFeed里按源切换 - 更新日志:Gitea 仓库是私有的(匿名 API 404),改为从更新通道的
release-notes.md读取(直连 sessionopenterminal-update-direct),再回退 Gitea/GitHub releases API - electron-updater 不支持 MSI 自动更新,自动更新只走 NSIS exe
主题机制
- 终端主题由 xterm 主题派生 UI 配色:
src/renderer/src/theme/chrome.ts的applyChromeTheme写入--chrome-bg/-bg-deep/-border/-hoverCSS 变量,antd token 在main.tsxThemedConfigProvider 派生 - 标签强调色:
settings.tabAccentColor(默认#3fb950),经--tab-accentCSS 变量生效
终端尺寸同步
TerminalView.scheduleFit:fit 后去抖 100ms 再把 cols/rows 发给 PTY,并跳过与上次相同的尺寸。每次 ResizeObserver 都戳 PTY 会让全屏 TUI(Claude Code 等)在最大化/还原的中间尺寸上反复重绘,留下重复帧- 拖动窗口期间 xterm 网格立即更新,PTY 尺寸在停止后 100ms 生效
锁屏
- 只使用主窗口内的不透明遮罩(
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 仍能走进遮罩后面的浮层 - 密码 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/密码永不出主进程,渲染层从不自行判定锁定 - 锁标志落盘在
<userData>/lock-state.json(LockStateStore),locked/failures/cooldownUntil 每次变化立即写,所以托盘退出、任务管理器强杀、崩溃后重启仍然是锁的——lockAtStartup只是额外一层。没有 verifier 时启动会删掉该文件;从磁盘恢复的cooldownUntil夹紧到now+30s,防系统时间回拨导致永久锁死 - 冷却阶梯 1s→2s→5s→10s→30s,失败计数与冷却同样落盘;
setPassword/clearPassword/unlock走内部串行队列(serialize),否则并发调用会同时通过闸门绕过冷却 - 闲置锁屏:
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):设置页文案承诺「清除后锁屏会一并关闭」,留着会让用户下次设密码时被静默重新武装 - 锁屏期间主进程在
win.webContents.on('before-input-event')里吞掉 F5/Ctrl+R、Ctrl+±0(含 Shift 拼写)、Ctrl+Shift+I/J/C:遮罩是 DOM 层,拦不住浏览器进程处理的 Electron 默认菜单加速键,而重载会触发beforeunload把遮罩后面的会话全杀掉。渲染层的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 的清屏;已锁定时不再拦截 - 启动时不要用
locked: true作渲染层初值再直接画锁屏:App.tsx用null表示「主进程还没答复」,此时只画.lock-screen-boot纯色层,否则每次启动都会给没设密码的用户闪一帧锁屏。getLockState()失败时要落到「locked 且未配置」的状态,让输入框可达(主进程对无 verifier 的解锁请求直接放行) - 相关测试:
node tests/lock-store.mjs(verifier + 状态存储)、node tests/lock-controller.mjs(冷却阶梯、并发串行化、落盘恢复、闲置触发、清除联动)
关键词高亮
- 预设规则表在
src/shared/settings.ts的DEFAULT_HIGHLIGHT_RULES(22 条),引擎在src/renderer/src/terminal/highlightEngine.ts;规则按 priority 升序应用,先匹配到的 span 归先跑的规则,后续规则遇到重叠直接跳过 - 状态类预设(danger/okstate/warnstate/badstate)priority 排在
shellkw之前:done既是 shell 关键字又是成功词、if还在dd if=里,先跑谁就由谁着色 - 状态符号(
✓ ✔ ✅ ✗ ✘ ✖ ❌ ⚠)不能放进\b…\b组:\b✓永不成立。预设把它们写在\b(?:…)\b|[✓✔✅]\uFE0F?的第二个分支里,尾随的\uFE0F?是为了把 emoji 变体选择符一起圈进着色范围 - 严重级别按颜色拆开:成功(okstate 绿)/ 警告(warnstate 黄)/ 错误·致命(badstate 红,含
CRITICAL、FATAL、PANIC)/ 删除·移动·覆盖(delop 橙)/ 新建·创建·安装(createop 亮绿);badstate里的NOT …分支负责not ok/not found,okstate的反向断言保证它不会被染绿 - delop/createop 只列操作动词(delete/remove/rm/mkdir/touch/add/install/clone/…),
export等 shell 关键字仍归shellkw,别把两边都写进去抢 span - 数值分级(
bands):匹配里的第一个数字决定颜色(最后一个min <= 值的分级胜出),无数字或低于最小分级时回落到color.fg;预设percent用它做百分比(<20% 红 / 20–50% 黄 / 50–80% 浅绿 / ≥80% 绿)。它的 priority 22 必须早于numbers(25),否则45%会先被数字规则整段吃掉;numbers规则本身不含百分比分支 - 词干要自带词尾(
DELET(?:E|ED|ES|ING|ION)而非DELETE(?:D|S|ING)?,否则漏deleting);不成词的词干(MOV/SAV/CLON/PURG/ERAS/WIP/REVOK)必须强制要求词尾 - 上下文敏感的规则用 lookbehind/lookahead 只圈住关键词本身,否则分级会读到错误的数字:
(?<=\bHTTP/\d(?:\.\d)?\s)[1-5]\d\d\b让HTTP/1.1 404只着色404(若把HTTP/1.1一起匹配,bands 会读到版本号1)。同理[1-5]\d\d(?=\s+OK|…)靠先行断言限定"后面跟原因短语"才算状态码,避免把123这类普通数字当成 404 - 裸 3 位数(
[1-5]\d\d)不能单独成规则,必须有上下文锚点;同理 MAC、短哈希这类高误伤模式不进预设 - 导入 / 导出与实时预览:
src/shared/highlightIO.ts管 JSON 信封(kind/version,外来 JSON 直接拒绝)与 replace/append 合并;渲染层用剪贴板 + FileReader + Blob 下载完成,不新增 IPC。编辑器预览走引擎的previewSpans(跑真实高亮再解析它自己的 SGR 输出),并且带上当前其它规则,这样 span 被别的规则抢走时能一眼看出来 - 分类(
category,safety|status|file|net|text|metric):预设的分类放在PRESET_CATEGORIES一张表里,basic集合必须始终是 safety+status 的子集(有测试守);settings.terminal.highlightGroupByCategory只影响设置页(加分类列 + 按分类聚簇,组内仍按 priority),热路径完全不涉及 - 跟随主题(
settings.terminal.highlightThemeColors,默认关):src/renderer/src/theme/highlightColors.ts按色相分桶把规则颜色映射到当前主题的 ANSI 调色板(不用"最近色",否则语义会漂移),亮度决定用普通色还是 bright 色(这样 percent 的两档绿仍能区分);饱和度低于 0.15 的中性色保持原样;背景色不映射(它是文字底块,不是语义信号)。映射在编译期一次性完成,热路径零成本;编辑器预览走同一函数,否则预览会与终端不一致 - 统计(
settings.terminal.highlightStats,默认关):引擎的applyHighlights/HighlightStream接受可选StatsSink,只在传了 sink 时才计数与计时(默认路径不插桩);TerminalView只在开关打开时挂 sink,并每秒发布一次快照(不是每块),否则忙碌的终端会把设置页重渲染到卡死;设置页通过subscribeHighlightStats订阅,多出「命中/耗时」两列(耗时按 µs/ms 格式化) - 性能护栏(别拆):
MAX_CHUNK512KB 整块跳过、MAX_LINE_LEN4KB 超长行不跑规则(挡(a+)+b这类回溯)、MAX_PER_RULE300 每条规则每块上限。实测 180–200KB 混合输出:全预设 4–9ms/块(0.02–0.05ms/KB,取决于转义序列密度;典型 4KB chunk ≈ 0.05–0.26ms)、basic 档 ~1.3ms/块(≈49µs/chunk)、off 档 0;单条规则最贵的是http(0.82ms)、percent、danger - 总开关是三档模式
settings.terminal.highlightMode(all/basic/off,读取一律过highlightModeOf兜底):basic只跑带basic: true的规则(预设里是 danger/secret/okstate/warnstate/badstate 这 5 条;用户可在规则编辑器里用「基础规则」开关给自建规则打标),off时TerminalView把规则集清空而不是绕过HighlightStream——stream 会 hold 住尾部文本,绕过会丢字节;空规则集在 tokenize 之前就返回,几乎零成本 - 按主机绑定规则集(
settings.terminal.highlightPerHost,默认关):HighlightProfile { id, name, ruleIds }存在AppSettings.highlightProfiles(顶层数组,仿customThemes),清洗在src/shared/highlightProfiles.ts;ruleIds为空 = 全部规则,rulesForProfile对未绑定 / 绑到不存在的 id / 空 profile 一律回落到全集,excludedByProfile给设置页算「这个 profile 排除了哪些规则」。绑定存在connections.json(SshConnection.highlightProfileId)而不是 settings,TerminalView只在 mount 时查一次连接——改了绑定要重开会话才生效。新增连接字段时必须同时改三处:PUBLIC_KEYS(update 路径靠它回写)、toPublic()、saveConnection()的新记录字面量,漏一处该字段会在某条路径上静默丢失 - 编辑器预览的输入要截断(前 2000 字符):200KB 样本会产出 7000+ 个 span,React 每敲一个键重渲染会卡
caseInsensitive: true的规则编译成gi(规则级开关,默认关闭);预设里okstate带(?<!not\s)反向断言,把not ok让给badstate- 词边界是这套预设的全部难点:新增关键词后请跑
node tests/.hl-rules.cjs(invalid不能点亮valid、disabled不能点亮enabled等) - 升级旧安装:
settingsStore.refreshBuiltinRules只把仍是出厂 pattern 的内置规则升到新预设(保留用户的颜色/优先级/启停),且仅当规则集恰好等于旧预设集时才追加新增的内置规则——否则用户删掉的规则会每次加载都复活。改预设 pattern 时同步更新LEGACY_BUILTIN_PATTERNS/LEGACY_BUILTIN_IDS