Files
OpenTerminal/src/shared/linkRules.ts
T
Bill cee616a23a
CI / typecheck + test + build (windows) (push) Canceled after 0s
feat: 字体与高亮系统改进(CJK 对齐、ReDoS 防护、链接规则、崩溃类设置修复)
基于两轮专项调研(字体/高亮各两路,子代理实测探针交叉印证)实施的完整改进。

## 修复的真实 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 已同步。
2026-10-17 20:23:56 +08:00

275 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { LinkRule } from './settings'
/**
* User-defined clickable link rules (WezTerm `hyperlink_rules` in spirit).
*
* A rule is a regex over the terminal's *logical* line plus a URL template: the
* match becomes an underlined range, and a Ctrl/Cmd+Click opens the template
* expanded with the capture groups. This module is the whole engine minus the
* xterm plumbing — validation, template expansion and the non-overlapping span
* merge — so it stays a zero-import pure module the Node test can load
* directly (`tests/link-rules.mjs`), and nothing here can touch a terminal.
*
* Safety: `findRuleLinks` is the only producer of a rule-made target, and it
* drops anything whose scheme is not http/https. A template is user input, and
* `file://`, `javascript:` or an `ms-settings:` handler must never reach the
* shell just because a pattern matched some output.
*/
/** Longest pattern / template we accept; longer entries are dropped. */
export const LINK_PATTERN_MAX = 500
export const LINK_TEMPLATE_MAX = 500
/** Note is a table caption, not documentation — same budget as a highlight rule. */
export const LINK_NOTE_MAX = 60
/** Upper bound on stored rules, so a runaway import cannot bloat settings.json. */
export const LINK_RULES_MAX = 100
/**
* User patterns run against the rebuilt logical line. Past this length they are
* skipped entirely: built-in URL matching is a fixed set of regexes, but a
* user-supplied one can backtrack catastrophically, and a terminal can emit a
* single-line log record of arbitrary length.
*/
export const LINK_TEXT_MAX = 4096
/** Per-rule match cap for one line, same reasoning as the text cap. */
const MAX_PER_RULE = 200
/**
* Stable empty list for `settings.linkRules`. The field is optional, and a
* selector that returned a fresh `[]` on every read would hand zustand's
* equality check a new identity each time — re-rendering every terminal pane on
* unrelated settings writes. Never mutated: every writer builds a new array.
*/
export const EMPTY_LINK_RULES: LinkRule[] = []
export interface CompiledLinkRule {
id: string
regex: RegExp
template: string
}
/** A clickable range, in the same shape `urlLinks.findUrls` returns. */
export interface LinkSpan {
/** absolute offset of the first character, within the logical line */
start: number
/** absolute offset one past the last character */
end: number
/** the clickable target, scheme already checked */
url: string
}
/**
* Validate a rule list coming from disk. Ids, patterns and templates are
* required; a pattern that does not compile, an empty template and duplicate
* ids are dropped (the settings table keys rows by id, and an uncompilable
* regex would otherwise throw on every line the terminal draws).
*/
export function sanitizeLinkRules(
raw: unknown,
warn?: (message: string) => void
): LinkRule[] {
if (!Array.isArray(raw)) return []
const rules: LinkRule[] = []
const seen = new Set<string>()
raw.forEach((entry, index) => {
if (entry === null || typeof entry !== 'object') {
warn?.(`linkRules[${index}]: not an object — skipped`)
return
}
const candidate = entry as Record<string, unknown>
const { id, pattern, template } = candidate
if (typeof id !== 'string' || id === '') {
warn?.(`linkRules[${index}]: missing id — skipped`)
return
}
if (seen.has(id)) {
warn?.(`linkRules[${index}]: duplicate id — skipped`)
return
}
if (typeof pattern !== 'string' || pattern.trim() === '') {
warn?.(`linkRules[${index}]: missing pattern — skipped`)
return
}
if (typeof template !== 'string' || template.trim() === '') {
warn?.(`linkRules[${index}]: missing template — skipped`)
return
}
if (pattern.length > LINK_PATTERN_MAX) {
warn?.(`linkRules[${index}]: pattern too long — skipped`)
return
}
if (template.length > LINK_TEMPLATE_MAX) {
warn?.(`linkRules[${index}]: template too long — skipped`)
return
}
if (!compiles(pattern)) {
warn?.(`linkRules[${index}]: invalid regex — skipped`)
return
}
if (rules.length >= LINK_RULES_MAX) {
warn?.(`linkRules[${index}]: too many rules — skipped`)
return
}
seen.add(id)
const note = typeof candidate.note === 'string' ? candidate.note.trim() : ''
rules.push({
id,
pattern,
template,
// Absent means on: a rule the user just created and did not switch off.
enabled: candidate.enabled !== false,
...(note ? { note: note.slice(0, LINK_NOTE_MAX) } : {})
})
})
return rules
}
function compiles(pattern: string): boolean {
try {
// eslint-disable-next-line no-new
new RegExp(pattern)
return true
} catch {
return false
}
}
/**
* Expand a template against one match: `$0` is the whole match, `$1`–`$9` the
* capture groups (a group that did not participate expands to nothing), `$$`
* is a literal `$`, and a `$` before anything else stays a literal `$`.
*
* Only one digit is ever consumed, so `$10` is group 1 followed by a literal
* `0` — a rule with ten groups is out of scope, and resolving `$10` to a group
* the match may not have would be the more surprising of the two readings.
*/
export function formatLinkTemplate(template: string, match: RegExpExecArray): string {
let out = ''
for (let i = 0; i < template.length; i++) {
const char = template[i]
if (char !== '$' || i + 1 >= template.length) {
out += char
continue
}
const next = template[i + 1]
if (next === '$') {
out += '$'
i++
continue
}
if (next >= '0' && next <= '9') {
out += match[Number(next)] ?? ''
i++
continue
}
out += char
}
return out
}
/**
* Whether a generated target may be handed to the OS. Only the web schemes are
* allowed — the same gate the main process applies to `window.open`, repeated
* here so an unusable target never even becomes an underlined range.
*/
export function isSafeLinkTarget(url: string): boolean {
const trimmed = url.trim()
if (trimmed === '') return false
try {
const { protocol } = new URL(trimmed)
return protocol === 'http:' || protocol === 'https:'
} catch {
// unparsable / relative: nothing a browser could open
return false
}
}
/**
* Whether a *template* can produce an openable target — the check the rule
* editor needs before a match exists, so it judges the shape rather than one
* expansion:
*
* - a template that names `http://` or `https://` itself is fine;
* - one that starts with `$0`/`$1`–`$9` takes the scheme from the match, so it
* is accepted here and left to `isSafeLinkTarget` at click time (a `$0`
* rule over `https?://\S+` is the legitimate case);
* - anything else — `file:`, `javascript:`, a bare path, a leading space
* followed by a foreign scheme — is refused.
*/
export function isSafeLinkTemplate(template: string): boolean {
const trimmed = template.trim()
if (trimmed === '') return false
if (/^https?:\/\//i.test(trimmed)) return true
return /^\$[0-9]/.test(trimmed)
}
/** Keep only the rules that can actually run. */
export function compileLinkRules(rules: readonly LinkRule[] | undefined): CompiledLinkRule[] {
if (!rules) return []
const out: CompiledLinkRule[] = []
for (const rule of rules) {
if (rule.enabled === false) continue
if (rule.pattern.trim() === '' || rule.template.trim() === '') continue
try {
out.push({ id: rule.id, regex: new RegExp(rule.pattern, 'g'), template: rule.template })
} catch {
// sanitizeLinkRules already dropped these; a hand-edited settings.json or
// a rule list built in memory could still carry one.
}
}
return out
}
/**
* Every rule-made link on one logical line, in rule order (an earlier rule
* claims its span first, like the highlight engine) and non-overlapping.
* Targets failing the scheme gate are dropped without a sound.
*/
export function findRuleLinks(text: string, rules: readonly CompiledLinkRule[]): LinkSpan[] {
if (rules.length === 0 || text === '' || text.length > LINK_TEXT_MAX) return []
const out: LinkSpan[] = []
for (const rule of rules) {
const claimed: LinkSpan[] = []
rule.regex.lastIndex = 0
let match: RegExpExecArray | null
while ((match = rule.regex.exec(text)) !== null) {
// A pattern that can match the empty string would spin forever on the
// same index: step past it instead of trusting lastIndex to advance.
if (match[0] === '') {
rule.regex.lastIndex = match.index + 1
continue
}
if (claimed.length >= MAX_PER_RULE) break
const start = match.index
const end = start + match[0].length
if (overlaps(out, start, end) || overlaps(claimed, start, end)) continue
const url = formatLinkTemplate(rule.template, match).trim()
if (!isSafeLinkTarget(url)) continue
const span = { start, end, url }
claimed.push(span)
out.push(span)
}
}
return out
}
function overlaps(spans: readonly LinkSpan[], start: number, end: number): boolean {
return spans.some((span) => start < span.end && end > span.start)
}
/**
* Combine the two link sources into one non-overlapping list: the primary
* (user rules — a deliberate rule beats a heuristic) keeps every span it
* claimed, the secondary (built-in URL detection) contributes only what does
* not collide. Sorted by start, which is what the provider maps to cells.
*/
export function mergeLinkSpans(
primary: readonly LinkSpan[],
secondary: readonly LinkSpan[]
): LinkSpan[] {
const kept: LinkSpan[] = [...primary]
for (const span of secondary) {
if (!overlaps(primary, span.start, span.end)) kept.push(span)
}
return kept.sort((a, b) => a.start - b.start || b.end - a.end)
}