CI / typecheck + test + build (windows) (push) Waiting to run
- README.md 增加中英文切换,新增 README.en.md 全译;修正测试计数(19→36)与主机指纹超时(30s→120s) - AGENTS.md 压缩 53KB→25KB,保留全部硬规则与踩坑结论 - 五份已完成/过期文档(旧计划、设计稿、STATE 快照)打归档横幅 - superpowers 两份旧计划勾选收尾并归档
24 KiB
24 KiB
OpenTerminal 开发备忘
Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。本文只留硬规则与踩坑结论;完整背景见 docs/(ROADMAP / STATE / PLAN-*),历史细节看 git log。
常用命令
- 开发:
npm run dev(主进程改动不热重建,需重启)。dev 实例用独立%APPDATA%\OpenTerminal-dev+ 独立单实例锁,可与正式版并存,测试数据不进真实配置 - 类型检查:
npm run typecheck;渲染层单独:npx tsc --noEmit -p tsconfig.web.json - 测试:
npm test(pretest 先 typecheck →tests/build-bundles.cjs重建 → 36 个离线测试,清单见package.json的test脚本)。渲染层纯函数测试用 Node 22.18+ 原生类型剥离直连.ts源文件,Node 20/22.17 以下报ERR_UNKNOWN_FILE_EXTENSION;真实服务器测试需JD_*凭据,不在列 - 依赖分类:只有
src/main//src/preload/实际 import 的包才能进dependencies;纯渲染层依赖一律 devDependencies(Vite 全量打包进 out/renderer,externalizeDepsPlugin不作用渲染层) - 打包:
npm run dist(predist = verify-deps → npm test → build;build 后跑tests/worker-artifact.mjs防?nodeWorker产物静默丢失;末尾npm install --package-lock-only对齐 lockfile 根版本,发布提交必须带 package-lock.json)。CI(.github/workflows/ci.yml,windows-latest + Node 22)只验证不打包不发布 - 国内镜像:
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ npm run dist - 改依赖版本后必须真实
npm install/npm ci:lockfile 对齐 ≠ 树里换了包(scripts/verify-deps.cjs会拦;v1.0.21 的 @xterm 回归正是此坑)。换机/重装后第一次打包前先npm ci - EPERM rename
win-unpacked.tmp= 安全软件扫描持锁:NODE_OPTIONS="--require $(pwd -W)/scripts/rename-retry-shim.cjs" npm run dist;根治是把仓库目录加进安全软件信任区 - 下载量统计:
node scripts/download-stats.cjs
仓库与远程
origin= Gitea(主仓库 + 更新通道);github= GitHub 镜像 + 备用更新源(具体 URL 看git remote -v)- ⚠️ Gitea upload-pack 故障,
git fetch origin报bad pack header;查远端用 API 或git ls-remote,推送正常 - 2026-09 远端旧历史(M3–M8,至 v0.8.0)被 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(可选 .zh-TW/.en/.ja 译文,缺译回退简体;RELEASE_NOTES*.md已 gitignore,不随仓库提交)node scripts/sync-changelog.cjs并入CHANGELOG*.md—— 必须在 dist 之前(更新日志以?raw打进安装包,release.cjs 也校验)npm run dist构建node scripts/release.cjs <版本号>(默认 Gitea + GitHub 一起发;同版本可重复跑,已传资产跳过)- 更新通道上传顺序:exe.blockmap → exe → release-notes.md → latest.yml 最后;新版全部传完才清上一版,中途失败不会打空通道
--channel-only只补通道;--skip-github跳 GitHub;--skip-gitea-release只跳 release、通道照常;--skip-gitea两者都跳(国内用户收不到,慎用)。--channel-only --skip-gitea组合直接 exit 1- 防降级
assertNoDowngrade()fail-closed:通道版本更高 / 网络错误 / 解析失败一律中止发布,只有通道真空(404)才放行;pruneChannel 前那次探测是 best-effort(失败只跳过清理并打日志) - GitHub 走
HTTPS_PROXY=http://127.0.0.1:7897(脚本只对 GitHub 请求用它);Gitea 通道直连不需要代理
- 验证:
curl https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/latest.yml应返回新版本号 git tag v<版本号>并推两个远程。tag 必须先推再跑 release.cjs:否则 Gitea/GitHub 会在默认分支 HEAD 自动建错位 tag,事后要git push -f修正(v1.0.22 踩过)
诊断日志
- 落盘
<userData>/logs/app-YYYY-MM-DD.log(本地日期),保留 7 天(今天 + 前 7 天;启动清扫一次 + 每 24h unref 定时器再扫,长开机跨午夜也会清),只动app-*.log命名匹配的文件;会话录制日志是用户文件,永远不走这里 - 开关
settings.system.diagnosticLog(默认开);关闭 = 什么都不记,但保留期清扫照常(只删不写) appLog()走异步串行队列、失败静默、单行 2000 字符 —— 日志永远不能搞挂应用本身- 排障口径:
[main] event loop stalled= 主进程阻塞;renderer long task= 渲染层真实长任务;scheduler delay/unclassified只证明计时器迟到,不是卡死证据;再对照PERF fit/PERF write/PERF renderer判断 reflow、xterm 积压还是 WebGL 生命周期。所有字段是元数据,绝不含终端内容 - PERF 时间线只观测不改变执行行为;日志落盘时间是恢复时刻,行内
window/est才是故障区间;PERF fit超FIT_SLOW_LOG_MS=200才记且每 pane 30s 限频(带 suppressed=N),fit/write/webgl 共享每分钟 5 行预算,严重看门狗报告 urgent 不受预算吞掉
更新机制
- 检查顺序:GitHub 优先(前置 20s 连通探测
probeGithub,失败直接兜底不给挂起机会)→ Gitea 通道(强制直连)。electron-updater 用独立 session(partitionelectron-updater),代理模式在useFeed按源切换 - 每次 checkForUpdates 包 30s 整体超时;changelog / releases API fetch 带
AbortSignal.timeout(15s) - 三种来源(启动 5s / 周期定时 / 手动)共用一条路径;周期
settings.system.updateCheckIntervalHours白名单{0,4,12,24}默认 4,0 = 从不;所有 timer 必须.unref();已有在途检查返回同一 promise(checkInFlight) - 调度器由
index.ts注入(settingsStore.setUpdateScheduleApplier)避免 import 环;applier 幂等,interval 未变不重建定时器;启动检查startupArmed每进程只武装一次 autoUpdater.autoDownload恒 false,单一事实源在 settings(autoDownloadUpdate默认关);下载失败落 error 不重试;绝不自动退出安装,autoInstallOnAppQuit只覆盖用户自己退出- 差量下载已禁用(
disableDifferentialDownload):GitHub 资产多段 Range 返回 501、通道被 prune 只剩最新 blockmap、NSIS LZMA 固相漂移 —— 注定失败且流量近乎翻倍 actedRelease单槽去重(announced/fetchHandled/downloaded/downloadedAnnounced);downloaded与fetchHandled必须分开判「已完成」;手动检查无条件清空记录;update-not-available/error不清空;下载失败回滚该次fetchHandled;update-available时该版本已downloaded必须先setState等价 downloaded 状态再早退(裸 return 会让「关于」页永远转圈)- 通知走托盘气泡:非 win32 / 无 tray / 有可见窗口都不弹;自动下载时不弹「发现新版本」,手动下载完成也不弹
- 只走 NSIS exe(1.0.22 起无 MSI);更新日志从通道
release-notes.md读(直连 session),回退 releases API
主题机制
- 终端主题派生 UI 配色:
theme/chrome.ts的applyChromeTheme写--chrome-*并派生--ot-*语义变量 +color-scheme theme/semantic.ts是零 import 纯模块(测试经 Node 原生类型剥离直连,加 import 前先想清楚);深色分支锁 SEMANTIC 锚点原值,浅色分支darkenToRatio按 WCAG 派生(正文 ≥4.5:1,语义基色 ≥3:1)- 单一命名体系:
semanticVars()的键就是最终 CSS 自定义属性名(如'--ot-text-muted');加变量时OtVarName联合与semanticVars()两个分支三处一起改。键名拼错在读取处是 TS 编译错;tests/ot-var-names.mjs锁「CSS 引用名 = JS 写入名 = global.css 声明名」 - CSS 一律
var(--ot-*, 锚点兜底),禁止裸颜色字面量(tests/css-theme-vars.mjsfail-closed 扫描);canvas 绘制吃不到 var(),图表类用useResolvedTheme()+semanticVars()取实色 - 浅色主题:内置 ANSI 调色板 16 色 + fg 全部 ≥4.5:1(
tests/theme-contrast.mjs守门;改色用darkenToRatio统一算法,不许手挑);minimumContrastRatio矩阵 = 背景图 4.5|浅色无图 4.5|深色无图 1(构造 + 热更两处都改;正确拼写是minimumContrastRatio);浅色关drawBoldTextInBrightColors;antd 浅色用defaultAlgorithm(没有 lightAlgorithm) - 背景图三铁律:①
.terminal-view.has-bg-image .terminal-view-dock底衬强制#0d1117,不能用--chrome-bg(浅色下会把图洗白);② MCR 提到 4.5(xterm 把透明背景当黑色亮度,会自动提亮深色前景);③ 压暗层settings.terminal.backgroundImageDim(0–90,默认 0)纯黑 scrim 靠 DOM 序压图(z-index 同层、位于图后 xterm 前),dim=0 不渲染零成本 - 标签强调色:
settings.tabAccentColor(默认#3fb950)经--tab-accent生效;--tab-accent-fg明暗自适应
终端字体
- 引号包裹在消费点做:
fontStack.ts的quoteFontStack(幂等)只在传给 xterm(构造 + 热更)与预览 inline style 时调用,settings 存用户原文;CJK 回退槽位settings.terminal.cjkFontFamily在appendCjk里先拼栈再 quote(一次切分);枚举端font-list的disableQuoting: true不动 @xterm/addon-unicode11@0.10.0-beta.301精确锁定(peer 恰好接受 6.1.0-beta.304);升 xterm 必须同步重配 addon 版本。加载后unicode.activeVersion = '11',addon 随 term.dispose 连带清理rescaleOverlappingGlyphs: true仅 WebGL 渲染器有效(把超宽字形压回格内)- 字体下拉等宽徽标:离屏 canvas
measureText逐字符采样,误判只会漏徽标不会错标
终端尺寸同步
scheduleFit:fit 后 100ms 去抖再发 PTY,跳过相同尺寸(防全屏 TUI 中间尺寸反复重绘);fit 本身 150ms 节流(fitThrottle.ts,拖动窗口防主线程占满);首次 fit 绝不等待(PTY 以占位尺寸启动,shell 可能已在画),重绑时重置- PTY 输出经
WriteBatcher批量进 xterm(~16ms 窗口合并,顺序不变)。两个接线点别漏:markDead先flushNow()再置 dead 标志(PTY_EXIT 在数据之后、flush 定时器之前到达);effect cleanup 里batcher.dispose()
性能与安全边界
TerminalView用 5 组useShallow字段级订阅(fontOpts/themeOpts/bgOpts/highlightOpts/toolbar + rendererMode),不要回到整对象订阅(主进程任何设置写入都会让所有 pane 重写 options + refit);useResolvedTheme同理 shallow + memoTerminalView已无 imperative handle;要「外部聚焦/清屏」需重新引入FilePanel虚拟化:行高/字号单一数据源在FilePanel.tsx(BASE_ROW_HEIGHT=26× scale 0.8–1.8,localStorage: openterminal.sftpFontScale,写成 CSS 变量--sftp-row-h/--sftp-fs/--sftp-fs-meta/--sftp-scale),sftp.css只消费变量、不得写死行高字号;box-sizing: border-box是有意的;antd Dropdown 会把ant-dropdown-trigger类克隆到子元素本身,禁止写.xxx .ant-dropdown-trigger后代选择器(特异性会顶掉display: grid);行与滚动容器各挂一个 contextMenu Dropdown,容器的onContextMenuCapture记录「是否点在行内」、onOpenChange据此拒绝打开,防两个菜单叠加- 每个 dockview pane 内有
PanelErrorBoundary,pane 崩溃只卸载自己 - 本地路径准入(
localPathGrants.ts):SFTP/ZMODEM 的本地路径必须来自系统对话框授权(进程级内存注册表 + realpath/stat 双重校验);新增「渲染层构造本地路径传主进程」的调用点必须过这道门,别绕 keyPath(SSH 私钥):realpath → stat → 普通文件且 ≤1MB 才读;src/shared/reservedAccelerators.ts是设置录制器与主进程applyGlobalShortcut共用的保留键表,加新全局快捷键两边自动一致- SFTP 操作有 per-op 超时(
bounded():元数据 30s / 传输块 60s / open 10s),超时驱逐半死通道并重试一次;setSftpTimeouts/setUpdateTimeouts是测试缝,生产无调用者,别接设置项 - PTY_EXIT 一次性广播不重放:晚绑定的窗格靠
sessionState()+sessionExits(FIFO 上限 512)补偿;SSH 飞行中连接登记pendingOpens,killPtysByOwner/killAllPtys靠它中止握手 - zmodem 接收路径背压靠
pauseSource/resumeSource钩子(fs WriteStream 缓冲无上限);touchActivity只在写入被接受或 drain 恢复时计数,否则 stall 看门狗会把「堆在缓冲里」当进展;删除路径 ENOENT 一律视为已删成功(重跑必须容忍「目标已消失」) webPreferences显式写死contextIsolation: true / nodeIntegration: false / webSecurity: true- 标签自动标题是存储的显示文本:
shared/terminalTitle.ts用全部 4 语言 pattern 反解(任何语言生成的都认出),渲染用当前语言;SSH 面板标题是用户起的连接名,永不动
锁屏
- 只用主窗口内不透明遮罩(
.lock-screen,z-index 4000),不建第二个窗口;锁定时.app-root设 inert 并保持挂载(卸载会杀会话);antd portal 挂 body 不在#root内,锁定时 body 下#root以外子节点也要 inert,且用MutationObserver盯新挂 portal 补 inert(observer 只在锁定时存在,解锁恢复) - 密码 verifier 在
<userData>/lock.json(lockStore.ts):scrypt(N=16384,r=8,p=1) + 每次写入重新生成盐 +timingSafeEqual,不存明文;格式不对当「未配置」,version不认识每进程 warn 一次 - 锁状态由主进程独占(
lockController.ts),salt/hash/密码永不出主进程;lock-state.json每次变化立即写(强杀/崩溃重启仍锁);恢复的 cooldownUntil 夹紧 now+30s 防时间回拨;冷却阶梯 1s→2s→5s→10s→30s;setPassword/clearPassword/unlock走串行队列 - 闲置锁屏:
powerMonitor.getSystemIdleTime()15s 轮询,读不到当「不闲置」;autoLockMinutes白名单{0,1,5,15,30,60},0 = 从不 - 清除密码会一并关
settings.lock.enabled/lockAtStartup(设置页文案承诺,别留着静默重新武装) - 锁定期间:
before-input-event吞 F5/Ctrl+R、Ctrl+±0、Ctrl+Shift+I/J/C(判定抽在纯模块lockShortcuts.ts,无 Electron 依赖可测);应用菜单整块置 null(菜单项点击不走 before-input-event;启动恢复锁定由 index.ts 直接补一次);渲染层dataset.locked守卫只许 return 跳过自身逻辑,绝不 preventDefault(窗口级 preventDefault 会把密码框输入杀掉,v1.0.17 回归) - Ctrl+L = 立即锁屏:仅锁定真生效时才 preventDefault,未设密码保留给 shell 清屏;长按 auto-repeat 跳过;已锁定不再拦截;窗口藏托盘后无效(刻意取舍:全局注册会抢所有应用的键);Ctrl+L 是保留键,录制器与主进程
applyGlobalShortcut两侧都拒绝注册 - 启动初值用
null+.lock-screen-boot纯色层(防给无密码用户闪一帧锁屏);getLockState()失败落到「locked 且未配置」(主进程对无 verifier 的解锁直接放行) - 测试:
node tests/lock-store.mjs/lock-controller.mjs/lock-shortcuts.mjs
布局模板(工作区)
- 应用外来模板时
fromJSON中途失败,dockview 先清空再抛错——旧会话在抛错前已被onDidRemovePanel杀掉。失败路径:先toJSON()快照 → 只回灌这次真的被 fromJSON 触碰的 dockview(回灌会清空,别灌没碰过的)→rebindRestoredPanels→ kill 没有面板引用的旧会话 →message.error;JSON.parse失败同样要提示 - 会话计数不靠累加器:
releaseSession直接扫api.panels(累加器与「updateParameters 原地换会话」「整块布局替换」脱节) - 旧版(M6.1 前单 dockview)模板只喂 terminal dockview:rebind 与收尾 kill 按
sshTouched/live门控,否则会把活 SSH 面板降级成终端再杀会话 - 主机密钥队列只渲染队头;TTL
DEFAULT_TIMEOUTS.prompt(120s)超时后主进程不再广播,渲染层同 TTL 定时清扫(HOST_KEY_PROMPT_TTL_MS,两处必须同步改);respondHostKey等 IPC 调用不得写进 setState updater(React 可能重放) - tab 关闭二次确认(
settings.terminal.confirmTabClose/confirmSshTabClose,默认关):dockview 8.2.0 没有可否决的关闭前钩子,杀会话在onDidRemovePanel之后无法回滚——所有用户关闭入口(× 按钮、右键菜单、Delete/Backspace)必须在api.close()之前过TerminalTab.requestClose(键盘路径靠.dv-tab外壳上的原生捕获监听);程序性路径(会话已死自动关、模板应用)不走
关键词高亮
- 预设
DEFAULT_HIGHLIGHT_RULES(22 条)在shared/settings.ts,引擎highlightEngine.ts;规则按 priority 升序,先匹配先得 span,重叠跳过 - 词边界是全部难点(改关键词跑
node tests/.hl-rules.cjs):状态符号(✓✔✅✗❌⚠)不进\b组(\b✓永不成立,写第二分支 +\uFE0F?);状态类 priority 在shellkw之前(done/if归属);delop/createop 只列操作动词,shell 关键字仍归 shellkw;词干自带词尾(DELET(?:E|ED|ES|ING|ION)),不成词词干强制词尾;裸 3 位数 / MAC / 短哈希不能单独成规则(须上下文锚点);lookbehind/lookahead 只圈关键词本身(圈多了 bands 会读错数字,如HTTP/1.1 404只该着色404) - bands 数值分级:匹配里第一个数字定色,最后一个
min <= 值胜出;percentpriority 22 必须早于numbers(25) - 锚定:规则作用于转义序列之间的 text run,默认无 m flag,裸
^锚 run 起点非行首;行首锚定用(?<=^|\s)或lineAnchor: true(编译成 gm/gim) - SGR 回放四护栏(全踩过坑,别拆):① reset 判定必须解析参数(
ESC[0;32m= 复位+设色,只认两种规范写法会让累积器逐行膨胀直至恢复永久失效);② 回放闸门按序列条数MAX_REPLAY_SEQUENCES=3,不是字节数(同义写法长度差 4 倍;MAX_RESTORE_LEN=256只是累积器内存上限);③ 恢复串只插在可见间隙(MIN_GAP_FOR_RESTORE=3个可见字符)——每 span 都恢复会让密集真彩 TUI 膨胀 4.3x、渲染线程卡 18s,现在 3.05x;④ 改以上任何参数必须重跑tests/hl-rules.mjs的[restore cost budget]段(断言 < 3.2x) ESCAPE_RE的 CSI 参数类必须含私有前缀与中间字节([0-9;:?<>=]*[ -/]*[A-Za-z]):漏了<>=会让ESC[>4;1m(modifyOtherKeys)/ESC[<0;1;2M(SGR 鼠标)这类序列所在行之后的关键词全部失高亮(vim/tmux 正好发这些)- 超长行降级:run 有 >4KB 行时按
\n逐行,超长行原样透传不上色(该 run 放弃跨行匹配);不变式:任何 >4KB 的行不进用户正则。性能护栏:MAX_CHUNK512KB 整块跳过 /MAX_LINE_LEN4KB /MAX_PER_RULE300;实测全预设 4–9ms/块,最贵的是 http/percent/danger - ReDoS 三层别混:①
isReDoSProne只是编辑器即时提示(有漏判误判,不当执行依据,文案只能说「通过当前探针」);② 执行闸门在compileRules/compileLinkRules——内置预设按 pattern+flags 精确命中白名单放行,自定义规则须RuleValidation.state='passed'且 pattern/flags/probeVersion/runtime 四项全匹配,pending/quarantined/失配一律跳过,编辑器预览与链接 provider 同一闸门无旁路;③ 动态校验redosValidator+redosProbeWorker(?nodeWorker产物):24 条固定探针 ≤4KB、500ms 硬超时由主控计时并 terminate → quarantined,Worker 崩溃重试一次回 pending,不可用也回 pending;并发上限 2、同 key 去重、空闲回收;默认纯预设安装不启动 Worker。runtime 指纹由ruleRuntime安装、renderer 经AppInfo.runtimeKey对齐;validation 是规则内部元数据——导入的裁决不作数,导出剥离 - 模式三档
highlightModeall/basic/off(读取一律过highlightModeOf兜底):basic 只跑basic: true的规则(预设 5 条:danger/secret/okstate/warnstate/badstate,用户可给自建规则打标;basic集合必须是 safety+status 子集,有测试守);off = 清空规则集而不是绕过HighlightStream(stream 会 hold 尾部文本,绕过丢字节) - 开关键名:
highlightStats/highlightThemeColors/highlightPerHost(均默认关)与highlightGroupByCategory(只影响设置页分组展示,热路径不涉及);分类集合safety|status|file|net|text|metric单表在PRESET_CATEGORIES - 按主机绑定:
HighlightProfile存AppSettings.highlightProfiles(清洗在shared/highlightProfiles.ts),ruleIds空 = 全部规则;绑定存在 connections.json(SshConnection.highlightProfileId),TerminalView只在 mount 查一次,改绑定要重开会话。新增连接字段必须同时改三处:PUBLIC_KEYS、toPublic()、saveConnection()新记录字面量,漏一处该字段在某条路径静默丢失 - 跟随主题
highlightColors.ts:按色相分桶映射到当前主题 ANSI 调色板(不用最近色,防语义漂移),亮度决定普通/bright 色,饱和度 <0.15 中性色保持原样,背景色不映射;编译期一次完成,编辑器预览走同一函数 - 统计按 pane 实例聚合(
crypto.randomUUID()的 pane id,不是 sessionId);sink 只在开关开时挂(默认路径不插桩),每秒发一次快照不是每块;卸载与关开关都注销,重绑定不注销;编辑器预览输入截断 2000 字符 - 导入/导出:
shared/highlightIO.ts管 JSON 信封(kind/version,外来 JSON 拒绝);渲染层用剪贴板 + FileReader + Blob 下载,不新增 IPC;预览走previewSpans且带上当前其它规则(span 被抢走能看出来) caseInsensitive: true编译成gi;okstate带(?<!not\s)把not ok让给 badstate- 升级旧安装:
settingsStore.refreshBuiltinRules只升级仍是出厂 pattern 的内置规则(保留用户颜色/优先级/启停),且仅当规则集恰好等于旧预设集才追加新增;改预设 pattern 同步更新LEGACY_BUILTIN_PATTERNS/LEGACY_BUILTIN_IDS(当前已含url: 'https?://[^\\s]+')
可点击链接规则
- 内置链接
urlLinks.ts的findUrls(4 条写死正则)不动;用户自定义规则叠加其上 - 引擎在
shared/linkRules.ts(可被tests/link-rules.mjs直连;它 import 共享ruleValidation.ts/redosProbes.ts,说明符必须带.ts+ 局部@ts-expect-error TS5097,别复制闸门/常量造成分表):sanitizeLinkRules(id/pattern/template 必填、非法正则丢弃、模板空串丢弃、enabled 缺省 true、note 截断 60、上限 100 条)、formatLinkTemplate($0整段 /$1–$9分组 /$$字面,只吃一位数字,未参与分组展开为空串)、compileLinkRules/findRuleLinks/mergeLinkSpans - 安全闸门:① ReDoS 执行闸门——用户链接规则没有内置白名单,pending/quarantined 不编译;②
isSafeLinkTarget——生成 span 时就把非 http/https 目标丢掉;主进程openExternal协议白名单是第三道 - 接线在
TerminalViewlink provider:mergeLinkSpans(findRuleLinks(...), findUrls(...)),用户规则优先,重叠的内置链接让位;hover 提示显示展开后的真实目标;热更走linkRulesRef(只换 ref 指向,绝不重建终端);EMPTY_LINK_RULES是共享稳定空数组(选择器返回新建[]会让 zustand 每次拿到新身份) - 性能护栏:
LINK_TEXT_MAX4KB 以上逻辑行整行跳过;每规则每行上限 200;能匹配空串的 pattern 强制lastIndex前进 - 设置字段
AppSettings.linkRules?: LinkRule[](默认[]);接线三处照 highlightProfiles:settingsStoredeepMerge+ sanitize 调用 +defaultSettings(),渲染层setLinkRules+ persist 白名单 - UI 在
HighlightTab.tsx底部ClickableLinkRules;导入导出与 profile 联动尚未做,属后续可加