feat: 字体与高亮系统改进(CJK 对齐、ReDoS 防护、链接规则、崩溃类设置修复)
CI / typecheck + test + build (windows) (push) Canceled after 0s

基于两轮专项调研(字体/高亮各两路,子代理实测探针交叉印证)实施的完整改进。

## 修复的真实 bug

**设置越界导致窗格永久死亡(用户报告)**
行高设 0.95 → xterm 构造/热更抛错 → 错误边界卸载整个 dockview 窗格;坏值已
落盘,每次重启都崩。根因是一整类缺陷:UI 允许值 / 配置能写值 与 xterm 硬约束
不一致,且主进程不校验。同类还有 scrollback<0(抛错)、cursorStyle 非法值
(更隐蔽:构造期被吞掉、只有热更期抛错,导致「重试」按钮永远无效、窗格死循环)。

修复:FONT_LIMITS/CURSOR_STYLES/RENDERER_MODES 收拢到 shared 层单一来源,
主进程加载时钳制 + 白名单化,healClampedFontSettings() 回写自愈;设置页
InputNumber、字体热键共用同一常量(照 LOCK_AUTO_DELAYS 先例,杜绝再次漂移)。

**perm 预设锚定失效**
^[-dlbcps]... 的 ^ 锚定的是「转义序列之间的 text run 起点」而非行首,ls -l
多行输出只有第一行权限位上色;带颜色的 ls(run 以转义切开)完全不触发。
改为 (?<=^|\s)[-dlbcps][rwxstST-]{9}(?![rwxstST-])——用负向前瞻而不是
(?=\s|$) 是因为后者会让带 ACL `+` / macOS `@` 后缀的真实 ls -l 输出不再染色。
旧 pattern 登记进 LEGACY_BUILTIN_PATTERNS,存量安装下次启动自动升级。

**高亮 span 吃掉应用自身 SGR**
wrap 固定追加 \x1b[0m,把应用设的颜色一并清掉。改为 SgrState 跟踪并重放。
四条护栏(每条都踩过坑,详见 AGENTS.md):
- reset 判定解析参数:ESC[00m/ESC[0;0m/ESC[0;32m(Go/Java 日志库常写)原先
  被追加进累积器,逐行增长直到撞上限,之后所有 span 恢复永久失效
- 回放闸门按序列条数而非字节数:同一个"单个前景色"长度差近 4 倍,按字节卡
  会砍掉真彩色却放过索引色
- 恢复串只插在可见间隙和最后一个 span 后:每 span 都重放会让 Kimi 这类全彩
  TUI(~19 span/行)把 9.5KB 输出膨胀到 40.8KB(4.30x),渲染线程实测卡
  18.4 秒。现为 3.05x 且长间隙着色正常
- 基准测试守门(膨胀 < 3.2x),改参数必须重跑

**CSI 私有参数导致整行失去高亮**
ESCAPE_RE 漏了 <>= 前缀与中间字节,\x1b[>4;1m(modifyOtherKeys)、
\x1b[<0;1;2M(SGR 鼠标)、\x1b[=1;1$y(DECRQM) 让整行成含裸 ESC 的文本,该行
之后所有关键词失去高亮(vim/tmux 常发)。

**超长行拖垮整段高亮**:一条 >4KB 的行让整个 text run 失去高亮。改为逐行降级。

**统计「最后写入者赢」**:模块级 totals 被各 pane 覆盖,设置页数据在窗格间
跳动。改为按 pane 实例 UUID 聚合(不能用 sessionId——SSH 分屏同一 session 挂
两个 pane,正好复制该 bug)。

## 新增能力

- **CJK 对齐三件套**:@xterm/addon-unicode11@0.10.0-beta.301(peer 恰好接受
  锁定的 6.1.0-beta.304,升 xterm 必须同步重配)、rescaleOverlappingGlyphs、
  cjkFontFamily 回退槽位。默认字体栈没有任何 CJK 字体,中文靠 Chromium 回退
  到比例字体导致列错位
- **字体名引号包裹**:quoteFontStack() 逐段判定(含反斜杠转义——只转义 " 会
  让尾部反斜杠把闭合引号转义掉,CSS 字符串不终止)
- **等宽徽标**:canvas measureText 逐字符采样,等宽排前
- **规则按行匹配**(lineAnchor → m flag)
- **命中提醒**(notify + onMatch 事件出口,60s 冷却,只显示规则名不显示原文)
- **可点击链接规则**(WezTerm hyperlink_rules 式,安全闸门在生成 span 时就
  丢弃非 http/https 目标)

## 其它

- 主题语义变量改为单一命名体系:semanticVars() 键就是最终 CSS 属性名,字面量
  键联合让拼错成为编译错误;删除 cssVarName()/semanticCssVars() 中间层
  (自定义属性名大小写敏感,转换被绕过会静默回落到深色兜底值)
- 删除死代码:automaticFontFallback 与顶层 overviewRuler 在 6.1.0-beta.304
  中均不存在,且移除了让任何伪造键都能通过 tsc 的 as 断言
- ReDoS 防护(MAX_PATTERN_LEN + 编辑器 backtracksBadly 实时探测)——注意这
  只是止血,(a+)+$ 配 30 字符实测仍卡 8.2 秒,根治需把正则执行挪进 Worker

测试 29 → 32(新增 font-stack、link-rules),全部通过;AGENTS.md 已同步。
This commit is contained in:
Bill committed 2026-10-17 20:23:56 +08:00
1 parent 619e4ac57f
commit cee616a23a
29 files changed
+3071 -216

No files matched your search

+93
View File
@@ -20,6 +20,7 @@ import { mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync } from 'no
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createRequire } from 'node:module'
import { DEFAULT_HIGHLIGHT_RULES } from '../src/shared/settings.ts'
const require = createRequire(import.meta.url)
const userData = mkdtempSync(join(tmpdir(), 'ot-settings-'))
@@ -60,6 +61,33 @@ for (const action of ['tray', 'exit', 'ask']) {
ok(back === 'tray', `unknown closeAction falls back to 'tray' (got '${back}')`)
}
// ---- 1b. numeric font settings are clamped on load ---------------------------
// xterm's constructor THROWS on lineHeight < 1 and the throw unmounts the whole
// dockview pane. The value is persisted, so one bad entry bricked every start
// until the file was hand-edited. The sanitizer must repair it instead.
console.log('[font settings clamp]')
{
const term = store.loadSettings().terminal
for (const [key, bad, want] of [
['lineHeight', 0.85, 1],
['lineHeight', 0.95, 1],
['lineHeight', 9, 2],
['fontSize', 4, 8],
['fontSize', 99, 40],
['letterSpacing', -3, 0],
['letterSpacing', 50, 10]
]) {
const back = (await roundTrip({ terminal: { ...term, [key]: bad } })).terminal[key]
ok(back === want, `${key} ${bad} is clamped to ${want} on load (got ${back})`)
}
// A legal value must survive untouched — the clamp is not a reset.
const legal = (await roundTrip({ terminal: { ...term, lineHeight: 1.35, fontSize: 18, letterSpacing: 1.5 } })).terminal
ok(
legal.lineHeight === 1.35 && legal.fontSize === 18 && legal.letterSpacing === 1.5,
'legal font values are preserved verbatim'
)
}
// ---- 2. highlight-rule repair ----------------------------------------------
console.log('[highlight rules]')
{
@@ -177,6 +205,51 @@ console.log('[value bands]')
const dropped = (await roundTrip({ highlightRules: [rule({ basic: 'yes' })] })).highlightRules
ok(dropped.length === 1 && dropped[0].basic === undefined, 'a non-boolean basic flag is dropped')
}
{
// lineAnchor: a rule anchored per line must come back still anchored, and a
// rule that never opted in must stay absent (not `false`) so settings.json
// stays compact.
const kept = (await roundTrip({ highlightRules: [rule({ lineAnchor: true })] })).highlightRules
ok(kept.length === 1 && kept[0].lineAnchor === true, 'a line-anchored rule keeps its flag')
const absent = (await roundTrip({ highlightRules: [rule()] })).highlightRules
ok(absent.length === 1 && absent[0].lineAnchor === undefined, 'a rule without the flag keeps it undefined')
const dropped = (await roundTrip({ highlightRules: [rule({ lineAnchor: 'yes' })] })).highlightRules
ok(
dropped.length === 1 && dropped[0].lineAnchor === undefined,
'a non-boolean lineAnchor flag is dropped'
)
}
{
// notify (per-rule "alert on hit"): same shape as lineAnchor — `true` survives,
// absent stays absent, anything non-boolean is dropped rather than coerced.
const kept = (await roundTrip({ highlightRules: [rule({ notify: true })] })).highlightRules
ok(kept.length === 1 && kept[0].notify === true, 'a notify rule keeps its flag')
const absent = (await roundTrip({ highlightRules: [rule()] })).highlightRules
ok(absent.length === 1 && absent[0].notify === undefined, 'a rule without the flag keeps it undefined')
const dropped = (await roundTrip({ highlightRules: [rule({ notify: 'yes' })] })).highlightRules
ok(
dropped.length === 1 && dropped[0].notify === undefined,
'a non-boolean notify flag is dropped'
)
// An explicit `false` is indistinct from absent after a save: the sanitizer
// normalises it away so settings.json never grows a dead key.
const off = (await roundTrip({ highlightRules: [rule({ notify: false })] })).highlightRules
ok(off.length === 1 && off[0].notify === undefined, 'notify: false is normalised to undefined')
}
{
// The preset rule set ships `secret` with the alert on (a leaked key is what
// the feature exists for) and nothing else flagged. Read from the preset
// constant rather than from disk: the user's stored set may legitimately have
// the flag off (see the upgrade note in the report), so this asserts the ship
// default, not the loaded result.
const flagged = DEFAULT_HIGHLIGHT_RULES.filter((r) => r.notify === true).map((r) => r.id)
ok(
flagged.length === 1 && flagged[0] === 'secret',
`only the secret preset notifies by default (got ${flagged.join(',')})`
)
const secret = DEFAULT_HIGHLIGHT_RULES.find((r) => r.id === 'secret')
ok(secret?.basic === true && secret?.notify === true, 'the secret preset notifies in both highlight modes')
}
{
for (const on of [true, false]) {
const back = await roundTrip({
@@ -196,6 +269,26 @@ console.log('[value bands]')
)
}
}
{
// cjkFontFamily: the CJK fallback slot appended to the font stack. Empty is
// the default (append nothing, unchanged behaviour); a real family name and
// a non-string — the settings file is hand-editable, and a number here would
// otherwise reach `appendCjk` and be concatenated into the stack as text.
ok(store.loadSettings().terminal.cjkFontFamily === '', 'the CJK fallback defaults to empty')
for (const cjk of ['Microsoft YaHei', "'Noto Sans CJK SC'", '']) {
const back = await roundTrip({
terminal: { ...store.loadSettings().terminal, cjkFontFamily: cjk }
})
ok(back.terminal.cjkFontFamily === cjk, `CJK fallback '${cjk}' survives the round trip`)
}
const dropped = await roundTrip({
terminal: { ...store.loadSettings().terminal, cjkFontFamily: 42 }
})
ok(
dropped.terminal.cjkFontFamily === '',
`a non-string CJK fallback is dropped back to empty (got '${dropped.terminal.cjkFontFamily}')`
)
}
{
const rules = [rule({ id: 'keep-1' }), rule({ id: 'keep-2' })]
await roundTrip({ highlightRules: rules })