diff --git a/README.md b/README.md index c12edc9..96abd2a 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,19 @@ own output, untouched. ## How it works -Claude Code's native binary embeds its JS bundle as plain text. ccskin finds -the display-related string anchors in that text and performs **byte-length-equal -replacements** (offsets stay valid), then re-signs the binary ad-hoc. Pipeline: +ccskin locates the display-related strings, performs **byte-length-equal +replacements** (offsets stay valid), then re-signs the binary ad-hoc. + +Which copy of those strings gets patched depends on how the binary was built, +and the tool detects this automatically: + +- **2.1.26x and later** are compiled with `bun build --bytecode`. The JS source + text is still embedded, but the runtime reads the **bytecode constant pool** — + patching the source text passes every check while changing nothing on screen, + so patches go to the pool. +- **Earlier builds** have no bytecode; the JS source text is still patched. + +Pipeline: ``` analyze patch sign verify @@ -100,9 +110,24 @@ All replacements must be byte-length equal, therefore: ccskin preview design/octopus.toml # render a design without touching binaries ``` -Each slot in a design file holds visible characters; `glyphpack` re-packs them -into the exact escape/literal shape of the original slots. Add your own -`design/*.toml` and point `icon_design` at it. +Each slot in a design file holds visible characters. **Slot boundaries are +detected from the binary** — upstream shifts characters between slots across +versions (2.1.261 grew `r1E` from 5 to 6 columns and shrank `r1R` from 1 to 0) +while the total row width stays the same, so a design only has to get each +*row* width right. Add your own `design/*.toml` and point `icon_design` at it. + +Note that identical strings are deduplicated inside the binary, so variants +sharing a slot cannot be given different art (upstream itself shares `r1E` +between `default` and `arms-up`). `analyze` reports such conflicts instead of +silently picking one. + +## Version number on bytecode builds + +The startup banner and `--version` read the *same* constant, so the displayed +version cannot be faked on its own — it is skipped by default. Set +`rewrite_real_version = true` if you accept that `--version` and the update +check will report the fake version too (it must be the same length as the real +one). ## Updating Claude Code diff --git a/README.zh.md b/README.zh.md index 10c1d02..e6fbc1b 100644 --- a/README.zh.md +++ b/README.zh.md @@ -9,8 +9,14 @@ API 上报等行为完全不变。 实际效果 —— 图标 `design/octopus.toml` · 名字 `Loop Code` · 版本 `v9.99` · 主题色 `#2E9BFF`。模型那一行及以下都是 Claude Code 自己的输出,未做任何改动。 -原理一句话:Bun 单文件二进制把 JS 源码原样嵌在 Mach-O 里,找到显示相关的 -字符串锚点,做**逐字节等长替换**,再 ad-hoc 重签名即可。 +原理一句话:找到显示相关的字符串,做**逐字节等长替换**,再 ad-hoc 重签名。 + +具体改哪份字符串取决于构建方式,工具会自动判定: + +- **2.1.26x 起**用 `bun build --bytecode` 编译。二进制里那份 JS 源码文本还在, + 但运行时读的是 **bytecode 常量池** —— 改源码文本能通过一切校验而屏幕毫无变化, + 所以补丁打到常量池。 +- **更早的版本**没有 bytecode,仍改 JS 源码文本。 ## 快速开始 @@ -91,36 +97,45 @@ claude_entitlements.plist # 重签名用的授权文件 ## 显示位清单(改了哪些) -| 位置 | 内容 | -|---|---| -| 启动大标题 | `children:"Claude Code"` + 后面的 `["v",变量]` | -| 输入框边框(展开) | `("Claude Code")` 与模板 `` `v${变量}` `` | -| 输入框边框(紧凑) | `(" Claude Code ")` | -| 头部/任务面板标题 | `["Claude Code"," "]` 与 `["v",变量]` | -| 像素图标 | logo 表 4 种动画形态 + 第三行小脚 + N2p 底行 | -| HTML 登录/错误页 | 反引号模板里的 3 行 ASCII logo | +显示位随版本增减,analyze 会如实报告每一处命中与缺失。 -**没改的**:`--version` 输出、`/doctor`、更新提示、状态消息等 —— 那些用的 -是内部常量或属信息性文本,不属于启动画面。 +| 位置 | 2.1.261(bytecode) | 更早的版本(源码文本) | +|---|---|---| +| 启动大标题 | ✅ | ✅ | +| 首次运行欢迎页 | ✅ | — | +| 输入框边框 / 头部标题 | 上游已移除 | ✅ | +| 版本号显示 | ❌ 与 `--version` 共用常量 | ✅ | +| 像素图标(4 种动画形态 + 第三行 + 底行) | ✅ | ✅ | +| HTML 登录/错误页 logo | ✅ | ✅ | +| 主题色 | ✅ | ✅ | + +**没改的**:`--version` 输出(除非显式开 `rewrite_real_version`)、`/doctor`、 +更新提示、状态消息等 —— 那些用的是内部常量或属信息性文本,不属于启动画面。 ## 图标设计指南 -`design/octopus.toml` 里每个槽位填"可见字符",工具按二进制里原槽位的 -转义(`\uXXXX`,6 字节)/字面(1 字节)形状等长打包。槽位字符数约束: +`design/octopus.toml` 里每个槽位填"可见字符"。**槽位边界由二进制现场探测** +—— 上游会在版本之间挪动字符在槽位间的分配(2.1.261 就把 r1E 从 5 列加到 6 列、 +r1R 从 1 列减到 0),但整行列数不变,所以设计稿只要保证**每行总列数**对就行: -- `default` / `look-left` / `look-right`:r1L=2、r1E=5、r1R=1、r2L=2、r2R=2 - (第一行 8 列;第二行共 9 列) -- `arms-up`:r1L=2、r1E=5、r1R=2(第一行 9 列) -- `mid.row`=5:第二行机身中部(四种变体共用一个常量,无法按变体区分; - 章鱼的眼睛就在这里) +- `default` / `look-left` / `look-right`:第一行 8 列、第二行 9 列 +- `arms-up`:第一行 9 列、第二行 9 列 +- `mid.row`=5:第二行机身中部(四种变体共用一个常量;章鱼的眼睛在这里) - `feet.row`=5(第三行)、`n2p.row`=7、`html.row1/2/3`=8/9/7 +**去重约束**:相同内容的字符串在二进制里只有一条,被多个变体共用。上游自己 +就让 `default` 与 `arms-up` 共用 r1E,所以设计稿不能给它们不同图案 —— 举手 +姿势只能靠 r1L/r1R 表现。冲突时 analyze 会直接报错并指出是哪两个槽位。 + 改完设计稿先 `python3 -m patcher preview your.toml` 看效果, 再跑 analyze 生成新定义。可用字符:任意 BMP 的 `\uXXXX`(方块字符 ▟▙▛▜▐▌▄▀█▖▗▘▝、◉●○、╵ 等)+ 槽位字面位上的 ASCII。 ## 注意 +- **bytecode 构建上版本号改不了**:启动画面的版本与 `--version` 共用同一条 + 常量,没法只改显示,默认自动跳过。愿意接受 `--version` 和更新检查一起变的话, + 配 `rewrite_real_version = true`(新版本号必须与真实版本等长) - **自动更新会覆盖补丁**:在 claude 设置里关掉 autoUpdates,或设 `DISABLE_AUTOUPDATER=1`;每次升级后重跑本工具即可 - 首次运行补丁后的二进制如被系统拦(Killed: 9):设置 → 隐私与安全性 → diff --git a/SKILL.md b/SKILL.md index e3a8ed9..8c66b64 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: ccskin -version: 0.2.0 +version: 0.3.0 description: > 给 Claude Code CLI 换肤/换主题/改启动画面:替换像素图标、产品名、版本号显示、 主题色(仅显示层,不动内部逻辑)。当用户提到 claude code 换肤、改主题、改图标、 @@ -18,11 +18,23 @@ allowed-tools: Bash, Read, Write, Edit, Glob, Grep 通过"等长字节替换 + ad-hoc 重签名"修改 claude 二进制的**显示层**。 内部版本常量、更新检查、API 行为完全不变(`--version` 仍打印真实版本,这也是验证手段)。 +## 两条补丁通道(先搞清楚当前二进制走哪条) + +- **bytecode 构建**(文件里有 `// @bun @bytecode`,2.1.26x 起):运行时读的是 + bytecode 常量池,二进制里那份 JS 源码文本**不驱动 UI**。改源码文本能通过 + 一切计数与 sha1 校验而屏幕毫无变化 —— 必须打到常量池(`bytecode.py`)。 +- **老版本**:仍是源码文本通道。 + +`discovery.build_patches` 自动判定并切换,`verify` 跟着切。排障先看 +`docs/pitfalls.md` 第 0 条。 + ## 硬性规则(违反会搞坏二进制) - 任何替换必须逐字节等长;`expected` 计数或 sha1 指纹不符**必须中止**,禁止强行 patch - 只在用户自己的二进制上操作;不分发二进制;不提交 `config.toml` - 备份(`.orig`)已存在时绝不覆盖 +- 常量池按内容去重:同一条可能被多处共用。设计稿要求一条同时变成两种图案时 + **报错而不是随便挑一个**;版本号与 `--version` 共用一条,默认跳过不改 ## 怎么用(按场景) @@ -39,7 +51,10 @@ allowed-tools: Bash, Read, Write, Edit, Glob, Grep 3. **改显示内容(名字/版本/颜色/图标)**: 引导用户跑 `skin` 向导; 或编辑 `config.toml`(name ≤11 字符、version ≤5 字符、theme_color 6 位 hex、 - icon_design 指向 design/*.toml),然后 `all --apply -y` + icon_design 指向 design/*.toml),然后 `all --apply -y`。 + bytecode 构建上版本号改不了(与 `--version` 共用常量),会自动跳过; + 用户坚持要改再开 `rewrite_real_version`,并说清 `--version` 和更新检查 + 都会看到假版本 4. **新图标设计**: 复制 `design/octopus.toml` 改字符,先 `python3 -m patcher preview <新设计.toml>` 看效果,再进 skin 流程。 @@ -49,9 +64,10 @@ allowed-tools: Bash, Read, Write, Edit, Glob, Grep ## 验证清单(做完必须过) -- `verify` 图标预览形状正确;显示名/版本计数 > 0 +- `verify` 图标预览形状正确;显示名在常量池/源码里命中 > 0 - `--version` 输出**真实**版本号(证明内部没被碰) -- `--pty` 抓屏含显示名和版本 +- **bytecode 构建必须做 `--pty` 抓屏**:计数类校验证明不了 UI 真的变了, + 只有抓屏能。抓屏里看到旧图标/旧名字 = 补丁打错了通道 ## 参考文档 diff --git a/config.example.toml b/config.example.toml index b783c69..8bc86c0 100644 --- a/config.example.toml +++ b/config.example.toml @@ -5,8 +5,13 @@ # 仅限可打印 ASCII(名字会嵌进二进制内的 JS 源码,非 ASCII 无法等长替换); # 不足自动用空格居中补齐 name = 'Loop Code' -# 版本号显示。1-5 个字符,不足自动补空格;内部真实版本不受影响 +# 版本号显示。1-5 个字符,不足自动补空格;内部真实版本不受影响。 +# 注意: bun --bytecode 构建(2.1.26x 起)上启动画面的版本号与 --version +# 共用同一条常量,没法只改显示 —— 这种情况版本位会被自动跳过。 version = 'v9.99' +# 仅 bytecode 构建: 强行改写那条共用的版本常量。 +# 代价是 --version 与自动更新检查也会看到假版本,默认关闭。 +rewrite_real_version = false # 图标设计稿(design/ 目录下的文件名) icon_design = 'octopus.toml' # 终端主题色(logo/边框/高亮的橙色),6 位 hex;留空 = 保持原橙 #D97757 diff --git a/design/octopus.toml b/design/octopus.toml index 14bda5f..b29c2b3 100644 --- a/design/octopus.toml +++ b/design/octopus.toml @@ -34,7 +34,8 @@ r2R = '█▙' [slots.arms-up] r1L = '▗ ' -r1E = '▗▄▄▄▖' +# r1E 与 default 共用一条常量池条目(上游自己也共用),只能靠 r1L/r1R 表现举手 +r1E = '▄▄▄▄▖' r1R = ' ▖' r2L = ' ▟' r2R = '▙ ' diff --git a/docs/pitfalls.md b/docs/pitfalls.md index 506c4ad..f578a20 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -2,6 +2,31 @@ 每条都附规避方法,工具里已固化的用 → 标注。 +## 0. bytecode 构建上改 JS 源码文本是无效的(最大的坑) +2.1.26x 起 claude 用 `bun build --bytecode` 编译。二进制里那份 JS 源码 +**文本还在**,但运行时读的是 bytecode 常量池 —— 改源码文本能通过锚点计数、 +sha1、`--version` 全部校验,**屏幕上却毫无变化**。 +判定:文件里出现 `// @bun @bytecode` 即为 bytecode 构建。 +→ `discovery.build_patches` 检测到就整体切到常量池通道(`bytecode.py`), +`verify` 也改成查常量池;老版本(非 bytecode)仍走源码文本通道。 + +常量池条目布局: +``` + + + + ← 不是终止符,当成终止符解析会漏掉一半条目 +``` + +## 0b. 常量池按内容去重,一条可能被多处共用 +池里内容相同的字面量只有一条。后果: +- **图标**: 上游自己就让 `default` 与 `arms-up` 共用 r1E,设计稿给它们不同 + 图案物理上做不到 → `_pool_icon_map` 检测冲突并报错,不静默取其一。 +- **版本号**: 启动画面的版本与 `--version` 共用同一条常量,没法只改显示 + → 默认跳过版本位;要强改得显式开 `rewrite_real_version`。 +- **名字**: `Claude Code` 是一条,但 `--version` 里的 ` (Claude Code)` + 与机器标识 `claude-code` 是**另外的**条目,不受影响。 + ## 1. 替换必须逐字节等长 Bun 单文件二进制内嵌的是 JS 源码**文本**,任何长度变化都会让后续内容的 偏移整体错位,直接损坏二进制。所有替换(old/new)字节数必须相等。 @@ -11,10 +36,11 @@ Bun 单文件二进制内嵌的是 JS 源码**文本**,任何长度变化都会 minified bundle 经常整个模块一行,`grep -c` 数的是"含匹配的行数"不是次数。 → 统计出现次数用 `grep -abo file | wc -l`(工具内用 `bytes.count`)。 -## 3. 二进制里有"假锚点"区 -V8 字节码快照/字符串表区也能搜到 `arms-up`、`look-left` 等词,但那不是 -JS 源码,**不能改**。辨别方法:上下文不是可读的 JS。 -→ `discovery.py` 只认完整结构锚点(`r1L:" \u2590"` + 括号配对),不会误伤。 +## 3. 源码文本区与常量池区要分清 +同一个字符串在二进制里有两份: 源码文本区(\uXXXX 转义形式)和 bytecode +常量池(UTF-16 / latin1 裸字符)。**bytecode 构建下只有后者驱动 UI**(见 0)。 +→ 源码通道只认完整结构锚点(槽位正则 + 括号配对);常量池通道用 +`bytecode.find()` 按条目边界精确匹配,短串再用 `region_for()` 限定区间消歧。 ## 4. 压缩变量名每个版本都变 `M2p`/`N2p`/`Ida`/`YZy`/`Vxr` 是构建时随机生成的,换版本全变。 diff --git a/patcher/analyze.py b/patcher/analyze.py index 60635e7..ac5699a 100644 --- a/patcher/analyze.py +++ b/patcher/analyze.py @@ -46,14 +46,22 @@ def load_design(project_root: pathlib.Path, design_name: str) -> dict: return tomlmini.loads(f.read()) -def _show_preview(patches, name, version, theme_color=None): +def _show_preview(patches, name, version, theme_color=None, design=None, + rewrote_real_version=False): """原图标 vs 新图标对照 + 名字/版本/颜色汇总。""" for p in patches: - if p["name"] == "icon_table" and p.get("art_old") and p.get("art_new"): + if p.get("art_old") and p.get("art_new"): ui.info(ui.bold("图标对照(左原 / 右新):")) for old_line, new_line in zip(p["art_old"], p["art_new"]): ui.info(" " + old_line.ljust(14) + ui.dim("→") + " " + new_line) break + else: + # 常量池通道逐条替换,没有「整表」可对照,直接渲染设计稿 + art = discovery.pool_art(design) if design else None + if art and any(p.get("cat") == "icon" for p in patches): + ui.info(ui.bold("新图标:")) + for line in art: + ui.info(" " + line) n_name = sum(p["expected"] for p in patches if p.get("cat") == "name") n_ver = sum(p["expected"] for p in patches if p.get("cat") == "version") n_color = sum(p["expected"] for p in patches if p.get("cat") == "color") @@ -61,8 +69,11 @@ def _show_preview(patches, name, version, theme_color=None): ui.info(ui.bold("名字:") + " " + ui.arrow('"Claude Code"', f'"{name}"') + ui.dim(f"(共 {n_name} 处显示位)")) if n_ver: + note = ("内部版本常量已被改写,--version 与更新检查同步变化" + if rewrote_real_version else + f"共 {n_ver} 处显示位,内部版本常量不动") ui.info(ui.bold("版本:") + " " + ui.arrow("真实版本", version) - + ui.dim(f"(共 {n_ver} 处显示位,内部版本常量不动)")) + + ui.dim(f"({note})")) if n_color and theme_color: hexs = "%02X%02X%02X" % theme_color ui.info(ui.bold("主题色:") + " " + ui.arrow("品牌橙", f"#{hexs}") @@ -107,9 +118,11 @@ def run(binary: str, cfg: dict, project_root: pathlib.Path, if name != raw_name: ui.info(f"名字不足 {discovery.NAME_MAX} 字符,已居中补齐: {raw_name!r} → {name!r}") # real_version 时版本串不会被使用,不做校验 + rewrite_version = bool(display.get("rewrite_real_version")) if not real_version: try: - discovery.validate_version(version) + discovery.validate_version( + version, max_len=None if rewrite_version else discovery.VERSION_MAX) except ValueError as e: ui.fail(f"配置错误: {e}") return 1 @@ -118,14 +131,19 @@ def run(binary: str, cfg: dict, project_root: pathlib.Path, version_arg = None if real_version else version ui.step("发现锚点") - patches, report, problems = discovery.build_patches( - data, name, version_arg, design, theme_color=theme_color) + probed = probe_version(binary) + patches, report, problems, warnings = discovery.build_patches( + data, name, version_arg, design, theme_color=theme_color, + real_version=probed, + rewrite_real_version=rewrite_version) for line in report: ui.ok(line) + for line in warnings: + ui.warn(line) for line in problems: ui.fail(line) - label = safe_label(label or probe_version(binary) or "unknown") + label = safe_label(label or probed or "unknown") if label == "unknown": ui.warn("--version 探测失败,定义文件将用 unknown 命名") out = resolve_out(project_root, label, out=out, force=force) @@ -152,7 +170,9 @@ def run(binary: str, cfg: dict, project_root: pathlib.Path, return 2 ui.step("替换预览") - _show_preview(patches, name, version, theme_color) + _show_preview(patches, name, version, theme_color, design, + rewrote_real_version=rewrite_version + and any(p.get('cat') == 'version' for p in patches)) pathlib.Path(out).write_text(defio.dumps(meta, patches), encoding="utf-8") ui.step("完成") diff --git a/patcher/bytecode.py b/patcher/bytecode.py new file mode 100644 index 0000000..e18eb65 --- /dev/null +++ b/patcher/bytecode.py @@ -0,0 +1,122 @@ +"""bun/JSC bytecode 常量池: 条目定位与等长替换。 + +新版 claude 用 `bun build --bytecode` 编译。二进制里那份 JS 源码文本还在, +但运行时读的是 bytecode 常量池 —— 改源码文本能通过一切计数与 sha1 校验, +屏幕上却毫无变化。显示层补丁必须打到常量池。 + +条目布局(实测 2.1.261): + + + + + +hash 不参与运行时校验(实测改 chars 不改 hash 照常渲染),所以只替换 chars。 +池是去重的: 内容相同的字面量在整个二进制里只有一条,因此同一条会被多处 +共用 —— 调用方必须自己判断改它的连带范围。 +""" +import re +import struct +from collections import namedtuple + +Entry = namedtuple("Entry", "start chars nbytes eight text") + +_ALIGN = 4 +_EIGHT_BIT = 0x80000000 +_HEADER = 8 # len(4) + hash(4) + + +def encode(text: str, eight: bool) -> bytes: + return text.encode("latin-1") if eight else text.encode("utf-16-le") + + +def find(data: bytes, text: str, region=None): + """内容恰好等于 text 的常量池条目。池去重,正常是 0 或 1 条。 + + region=(lo, hi) 用于给短串消歧 —— 单字符条目的字节模式很短, + 全文件搜索可能撞上随机数据。 + """ + if not text: + return [] + lo, hi = region if region else (0, len(data)) + lo = max(0, lo) + out = [] + for eight in (True, False): + try: + chars = encode(text, eight) + except UnicodeEncodeError: + continue + pad = b"\x00" * (-len(chars) % _ALIGN) + head = struct.pack(" bytes: + """新 chars 字节;字符数必须与原条目一致(否则条目长度字段就得改)。""" + if len(new_text) != len(entry.text): + raise ValueError( + f"字符数不一致: 条目 {entry.text!r} 有 {len(entry.text)} 字符," + f"替换串 {new_text!r} 有 {len(new_text)} 字符") + buf = encode(new_text, entry.eight) + if len(buf) != entry.nbytes: + raise ValueError( + f"{new_text!r} 无法以原编码({'latin-1' if entry.eight else 'UTF-16'})" + f"等长写回 {entry.text!r}") + return buf + + +def is_bytecode_build(data: bytes) -> bool: + return b"// @bun @bytecode" in data + + +_ART_RE = re.compile(r"^[▀-▟─-╿ \n]+$") + + +def art_entries_in(data: bytes, lo: int, hi: int, min_len: int = 1): + """扫出区间内所有「只由制表/方块字符组成」的 UTF-16 条目。 + + 用于发现上游新增或改名的 logo 串: 已知槽位都替换完之后,这里还剩下的 + 就是没被设计稿覆盖到的图案,报出来比默默漏掉强。 + """ + out = [] + pos = max(0, lo) + hi = min(hi, len(data)) + while pos + _HEADER < hi: + (raw,) = struct.unpack_from(" hi: + pos += _ALIGN + continue + chunk = data[cstart:cstart + nbytes] + try: + text = chunk.decode("latin-1" if eight else "utf-16-le") + except UnicodeDecodeError: + pos += _ALIGN + continue + if not eight and len(text) >= min_len and _ART_RE.match(text): + out.append(Entry(pos, cstart, nbytes, eight, text)) + pos = cstart + nbytes + (-nbytes % _ALIGN) + continue + pos += _ALIGN + return out diff --git a/patcher/defio.py b/patcher/defio.py index 4ccb091..ba9d564 100644 --- a/patcher/defio.py +++ b/patcher/defio.py @@ -40,8 +40,12 @@ def dumps(meta: dict, patches: list) -> str: lines.append(f"sha1 = {tomlmini.emit_str(p['sha1'])}") if p.get("offset") is not None: lines.append(f"offset = {p['offset']}") + # 常量池条目是 UTF-16,字节里带 NUL,按 enc 存可读文本而不是裸字节 + enc = p.get("enc") or "ascii" + if enc != "ascii": + lines.append(f"enc = {tomlmini.emit_str(enc)}") lines += [ - f"new = {tomlmini.emit_str(p['new'].decode('ascii'))}", + f"new = {tomlmini.emit_str(p['new'].decode(enc))}", f"expected = {p['expected']}", ] return "\n".join(lines) + "\n" @@ -53,13 +57,16 @@ def loads(text: str): patches = [] for i, p in enumerate(doc.get("patch", [])): try: + enc = p.get("enc", "ascii") + if enc not in ("ascii", "utf-16-le", "latin-1"): + raise ValueError(f"不支持的 enc: {enc!r}") old = p["old"].encode("ascii") if "old" in p else None if old is None: if "sha1" not in p: raise KeyError("old/sha1") if "offset" not in p: raise KeyError("offset") - new = p["new"].encode("ascii") + new = p["new"].encode(enc) expected = int(p["expected"]) if old is not None and len(old) != len(new): raise ValueError(f"old/new 长度不等({len(old)} vs {len(new)})") @@ -70,6 +77,7 @@ def loads(text: str): "cat": p.get("cat", ""), "desc": p.get("desc", ""), "old": old, + "enc": enc, "sha1": p.get("sha1", ""), "offset": int(p["offset"]) if "offset" in p else None, "new": new, diff --git a/patcher/discovery.py b/patcher/discovery.py index 3420ab3..37f7da0 100644 --- a/patcher/discovery.py +++ b/patcher/discovery.py @@ -20,7 +20,7 @@ import hashlib import re -from . import glyphpack +from . import bytecode, glyphpack # ---------- 稳定锚点(raw 字符串,保留字面反斜杠) ---------- _LOGO_ANCHOR = br'r1L:" \u2590"' # logo 表第一个槽位 @@ -41,11 +41,22 @@ # ---------- 名字显示位 ---------- NAME_MAX = 11 # 原串 "Claude Code" 的字符数,替换必须等长 +# 大 logo 的第三行是与小 logo 不同的另一个常量(小 logo 走 _FEET_CONTENT) +_FEET_BIG_VISIBLE = "▝▝ ▝▝" + +# 常量池里的产品名条目(池按内容去重,一条对应所有引用它的显示位) +_POOL_NAME_SITES = [ + ("Claude Code", lambda n: n, "name"), + ("Welcome to Claude Code", lambda n: "Welcome to " + n, "name_welcome"), +] + +# 显示位在版本之间会被上游整个删掉(如 2.1.261 移除了输入框边框上的产品名)。 +# required=False 的锚点缺失只提示,不算失败;全都找不到才是结构真的变了。 NAME_SITES = [ - ("name_title", 'children:"Claude Code"', lambda n: f'children:"{n}"', "启动/面板标题"), - ("name_border", '("Claude Code")', lambda n: f'("{n}")', "输入框边框(展开)"), - ("name_border_compact", '(" Claude Code ")', lambda n: f'(" {n} ")', "输入框边框(紧凑)"), - ("name_header", '["Claude Code"," "]', lambda n: f'["{n}"," "]', "头部显示"), + ("name_title", 'children:"Claude Code"', lambda n: f'children:"{n}"', "启动/面板标题", True), + ("name_border", '("Claude Code")', lambda n: f'("{n}")', "输入框边框(展开)", False), + ("name_border_compact", '(" Claude Code ")', lambda n: f'(" {n} ")', "输入框边框(紧凑)", False), + ("name_header", '["Claude Code"," "]', lambda n: f'["{n}"," "]', "头部显示", False), ] @@ -77,8 +88,11 @@ def validate_name(raw: str) -> str: return " " * (pad // 2) + raw + " " * (pad - pad // 2) -def validate_version(raw: str) -> str: - """校验版本显示串(1-VERSION_MAX 个字符);不合法抛 ValueError,返回原串。 +def validate_version(raw: str, max_len: int = VERSION_MAX) -> str: + """校验版本显示串(1-max_len 个字符);不合法抛 ValueError,返回原串。 + + max_len=None 用于改写内部版本常量的场景: 那时长度由真实版本决定 + (必须等长),而不是由 ["v",X] 显示位的宽度决定。 和名字一样受 JS 语法约束(见 _js_bad_chars)。补齐到各显示位的实际 可用宽度在 _versions 里做,这里只做字符集与长度上限校验。 @@ -90,8 +104,8 @@ def validate_version(raw: str) -> str: raise ValueError( f"display.version 含不支持的字符 {bad[0]!r}: 只允许可打印 ASCII," "且不能是引号/反斜杠/反引号/$(会破坏二进制内嵌的 JS)") - if len(raw) > VERSION_MAX: - raise ValueError(f"display.version 超过 {VERSION_MAX} 字符({len(raw)}): {raw!r}") + if max_len is not None and len(raw) > max_len: + raise ValueError(f"display.version 超过 {max_len} 字符({len(raw)}): {raw!r}") return raw _CONTENT = r'((?:\\u[0-9A-Fa-f]{4}|[^"\\])*)' @@ -104,6 +118,24 @@ def validate_version(raw: str) -> str: r'"?(default|look-left|look-right|arms-up)"?:"' + _CONTENT + r'"' ) _SLOT_NAMES = ("r1L", "r1E", "r1R", "r2L", "r2R") +# 槽位按显示行分组: 设计稿按整行给,边界由二进制现场决定(见 glyphpack.pack_row) +_ICON_ROWS = (("r1L", "r1E", "r1R"), ("r2L", "r2R")) + + +def _layout_sig(slot_contents: dict) -> str: + """探测到的槽位列数,如 'r1L=2 r1E=6 r1R=0 | r2L=2 r2R=2'。""" + return " | ".join( + " ".join(f"{s}={len(glyphpack.tokenize(slot_contents[s]))}" for s in row) + for row in _ICON_ROWS) + + +def _report_layouts(layouts, report): + """把探测到的槽位布局写进报告(相同布局的变体合并成一行)。""" + grouped = {} + for variant, sig in layouts: + grouped.setdefault(sig, []).append(variant) + for sig, variants in grouped.items(): + report.append(f"槽位布局 {'/'.join(variants)}: {sig}") # ---------- JS 源码扫描辅助 ---------- @@ -209,19 +241,24 @@ def _icon_table(data, design, patches, report, problems): # 位置化重建: 逐个变体按正则命中位置替换槽位, # 同一原型槽位内容在不同变体里可以映射到不同图案 - new_parts, last = [], 0 + new_parts, last, layouts = [], 0, [] for m in slot_matches: variant = m.group(1) dslots = design.get("slots", {}).get(variant) if not dslots: problems.append(f"设计稿缺少变体 [{variant}] 的槽位定义") continue + old_contents = dict(zip(_SLOT_NAMES, m.groups()[1:])) + layouts.append((variant, _layout_sig(old_contents))) + packed = dict(old_contents) try: - packed = [ - glyphpack.pack(glyphpack.tokenize(old_c), dslots[slot_name]) - if slot_name in dslots else old_c - for slot_name, old_c in zip(_SLOT_NAMES, m.groups()[1:]) - ] + for row_slots in _ICON_ROWS: + if not all(s in dslots for s in row_slots): + continue + # 设计稿按整行给,现场按二进制的槽位边界切回去 + packed.update(zip(row_slots, glyphpack.pack_row( + [(s, old_contents[s]) for s in row_slots], + "".join(dslots[s] for s in row_slots)))) except ValueError as e: problems.append(f"设计稿与槽位不匹配({variant}): {e}") continue @@ -229,12 +266,12 @@ def _icon_table(data, design, patches, report, problems): new_parts.append(table1[last:m.start()]) new_parts.append( prefix - + f'r1L:"{packed[0]}",r1E:"{packed[1]}",r1R:"{packed[2]}",' - + f'r2L:"{packed[3]}",r2R:"{packed[4]}"' + "}" + + ",".join(f'{s}:"{packed[s]}"' for s in _SLOT_NAMES) + "}" ) last = m.end() if problems: return + _report_layouts(layouts, report) new_table1 = "".join(new_parts) + table1[last:] # 底脚表(N2p): 所有变体用同一设计,全局内容替换即可 @@ -384,9 +421,9 @@ def _icon_html(data, design, patches, report, problems): report.append(f"icon_html @ {offs[0]:,}: HTML 页 logo 模板 × {len(offs)}") -def _names(data, display_name, patches, report, problems): +def _names(data, display_name, patches, report, problems, warnings): offsets = {} - for entry, old_s, make_new, where in NAME_SITES: + for entry, old_s, make_new, where, required in NAME_SITES: old_b = old_s.encode("ascii") new_b = make_new(display_name).encode("ascii") if len(old_b) != len(new_b): @@ -395,7 +432,8 @@ def _names(data, display_name, patches, report, problems): f"{display_name!r}") offs = find_all(data, old_b) if not offs: - problems.append(f"名字锚点未找到: {old_s}(可能已打过补丁或结构变化)") + msg = f"名字锚点未找到: {old_s}(可能已打过补丁或结构变化)" + (problems if required else warnings).append(msg) continue patches.append({ "name": entry, "cat": "name", @@ -405,6 +443,8 @@ def _names(data, display_name, patches, report, problems): }) report.append(f"{entry} @ {offs[0]:,}: {where} × {len(offs)}") offsets[entry] = offs + if not offsets: + problems.append("所有名字锚点都未找到 —— 该版本结构变了,不要猜") return offsets @@ -430,14 +470,27 @@ def grab(kind, pos): if not found: problems.append("版本号显示位未找到(名字锚点附近没有 [\"v\",变量] / `v${变量}`)") return + + # 显示位容量取决于该版本压缩后的变量名长度(["v",ms] 比 ["v",c3e] 窄一格), + # 因此按二进制现场探测而非用固定上限。容量必须先按最窄位统一校验: + # 逐个跳过放不下的位会让一部分显示位是假版本、另一部分是真版本。 + caps = {old_b: len(old_b) - (4 if kind == "arr" else 2) + for old_b, (kind, _) in found.items()} + tightest = min(caps, key=caps.get) + # 模板位 `v${x}` 渲染时前面还有一个空格,占一格 + budget = min(caps[b] - (1 if found[b][0] == "tpl" else 0) for b in caps) + report.append( + f"版本位容量(自动探测): 最多 {budget} 字符(最窄位 {tightest.decode()})") + if len(display_version) > budget: + problems.append( + f"版本号 {display_version!r}({len(display_version)} 字符)放不下: " + f"本版本最窄显示位 {tightest.decode()} 只有 {budget} 字符 —— " + f"改短 display.version,或用 real_version 保持真实版本") + return + for old_b, (kind, offs) in sorted(found.items()): - inner = len(old_b) - (4 if kind == "arr" else 2) + inner = caps[old_b] v = (" " + display_version) if kind == "tpl" else display_version - if len(v) > inner: - problems.append( - f"版本号 {display_version!r} 放不下显示位 {old_b.decode()}(最多 {inner} 字符)" - ) - continue v = v.ljust(inner, " ") new_b = (b'["' + v.encode() + b'"]') if kind == "arr" else (b'`' + v.encode() + b'`') cnt = data.count(old_b) @@ -523,9 +576,270 @@ def _theme_color(data, color, patches, report, problems): report.append(f"theme_color @ {offs[0]:,}: {prefix} → {packed} × {len(offs)}") +def _pool_record(mapping, conflicts, old_vis, new_vis, where): + """登记「池里这条内容 → 换成什么」。池按内容去重,一条只能有一个目标。""" + if not old_vis or old_vis == new_vis: + return + prev = mapping.get(old_vis) + if prev is None: + mapping[old_vis] = (new_vis, [where]) + return + if prev[0] != new_vis: + conflicts.setdefault(old_vis, {prev[0]: "/".join(prev[1])})[new_vis] = where + return + prev[1].append(where) # 多个槽位共用同一条,记全便于人工核对 + + +def _pool_icon_map(data, design, problems, warnings): + """从源码 logo 表学到「当前可见内容 → 设计稿新内容」,供常量池替换用。 + + 源码文本虽然不驱动 UI,但它保留了「哪个变体的哪个槽位是什么内容」这层 + 语义;常量池只有一堆去重后的字符串,没有这层信息。所以用源码表做地图, + 把补丁落到池上。 + """ + p = data.find(_LOGO_ANCHOR) + if p < 0: + problems.append('logo 像素表锚点未找到(r1L:" ...")——版本结构可能变了') + return None, [], {} + start = data.rfind(b'{default:', 0, p) + if start < 0: + problems.append("logo 表起点 {default: 未找到") + return None, [], {} + try: + end1 = match_brace(data, start) + nstart = data.find(b'{', end1) + nend = match_brace(data, nstart) + except ValueError as e: + problems.append(f"logo 表括号解析失败: {e}") + return None, [], {} + table1 = data[start:end1].decode("ascii") + table2 = data[nstart:nend].decode("ascii") + + mapping, conflicts, layouts, default_vis = {}, {}, [], {} + for m in _SLOT_RE.finditer(table1): + variant = m.group(1) + dslots = design.get("slots", {}).get(variant) + if not dslots: + problems.append(f"设计稿缺少变体 [{variant}] 的槽位定义") + continue + old_vis = {s: glyphpack.decode_escapes(c) + for s, c in zip(_SLOT_NAMES, m.groups()[1:])} + if variant == "default": + default_vis = dict(old_vis) + layouts.append((variant, _layout_sig(dict(zip(_SLOT_NAMES, m.groups()[1:]))))) + for row_slots in _ICON_ROWS: + if not all(s in dslots for s in row_slots): + continue + try: + parts = glyphpack.split_row( + [len(old_vis[s]) for s in row_slots], + "".join(dslots[s] for s in row_slots), row_slots) + except ValueError as e: + problems.append(f"设计稿与槽位不匹配({variant}): {e}") + continue + for s, new in zip(row_slots, parts): + _pool_record(mapping, conflicts, old_vis[s], new, f"{variant}.{s}") + + n2p = design.get("n2p", {}).get("row") + if n2p: + for m in _ROW_RE.finditer(table2): + _pool_record(mapping, conflicts, glyphpack.decode_escapes(m.group(2)), + n2p, f"n2p.{m.group(1)}") + + mid = design.get("mid", {}).get("row") + if mid: + _pool_record(mapping, conflicts, glyphpack.decode_escapes(_MID_CONTENT), + mid, "mid.row") + feet = design.get("feet", {}).get("row") + if feet: + # 小 logo 与大 logo 的第三行是两个不同常量,设计稿的 feet 都适用 + _pool_record(mapping, conflicts, glyphpack.decode_escapes(_FEET_CONTENT), + feet, "feet.row(小 logo)") + _pool_record(mapping, conflicts, _FEET_BIG_VISIBLE, feet, "feet.row(大 logo)") + + for old_vis, targets in conflicts.items(): + problems.append( + f"常量池按内容去重,{old_vis!r} 只有一条,但设计稿要求它同时变成 " + + " / ".join(f"{n!r}({w})" for n, w in targets.items()) + + " —— 让这些槽位用同一个图案,或接受其中之一") + return (None if conflicts else mapping), layouts, default_vis + + +def pool_art(design): + """设计稿渲染成三行(default 变体),给预览用。""" + slots = design.get("slots", {}).get("default", {}) + if not slots: + return None + row1 = "".join(slots.get(s, "") for s in ("r1L", "r1E", "r1R")) + row2 = (slots.get("r2L", "") + design.get("mid", {}).get("row", "") + + slots.get("r2R", "")) + return [row1, row2, design.get("feet", {}).get("row", "")] + + +def _pool_html(data, design, warnings): + """HTML 登录/错误页的三行 logo 在池里是一条含换行的整串,不在 logo 条目区。""" + html = design.get("html", {}) + if not (html.get("row1") and html.get("row2") and html.get("row3")): + return {} + rows_new = "\n".join((html["row1"], html["row2"], html["row3"])) + # 老串由当前 logo 拼出来,不硬编码某个版本的图案 + m = _SLOT_RE.search(data[data.rfind(b'{default:', 0, data.find(_LOGO_ANCHOR)):] + .decode("ascii", "replace")) + if not m: + return {} + r1L, r1E, r1R, r2L, r2R = (glyphpack.decode_escapes(c) for c in m.groups()[1:]) + mid = glyphpack.decode_escapes(_MID_CONTENT) + row1, row2 = r1L + r1E + r1R, r2L + mid + r2R + for feet in (_FEET_BIG_VISIBLE, glyphpack.decode_escapes(_FEET_CONTENT)): + old = "\n".join((row1, row2, " " + feet)) + if bytecode.unique(data, old): + if len(old) != len(rows_new): + warnings.append( + f"HTML 页 logo 共 {len(old)} 字符,设计稿 html 行合计 " + f"{len(rows_new)} 字符,跳过") + return {} + return {old: (rows_new, "html")} + warnings.append("HTML 页 logo 未在常量池找到(非启动画面,可忽略)") + return {} + + +def _pool_emit(data, mapping, region, cat, patches, report, problems, prefix=""): + """把「内容 → 新内容」映射落成 offset+sha1 的池条目补丁。""" + done = 0 + for old_vis, (new_vis, where) in sorted(mapping.items()): + sites = where if isinstance(where, list) else [where] + label = "/".join(sites) + hits = bytecode.find(data, old_vis, region) + if not hits: + problems.append(f"常量池里找不到 {old_vis!r}({label})") + continue + if len(hits) > 1: + problems.append( + f"{old_vis!r}({label}) 在常量池命中 {len(hits)} 条,无法确定改哪条") + continue + entry = hits[0] + try: + new_b = bytecode.replacement(entry, new_vis) + except ValueError as e: + problems.append(f"{label}: {e}") + continue + old_b = data[entry.chars:entry.chars + entry.nbytes] + if old_b == new_b: + continue + patches.append({ + "name": f"{prefix}{sites[0].replace('.', '_').replace(' ', '_')}", + "cat": cat, + # 只写替换成什么,不写原内容: 定义文件是 offset+sha1 模式, + # 原文一律 patch 时从用户自己的二进制现读现验(与 defio 的约定一致) + "desc": f"{label} → {new_vis!r}", + "offset": entry.chars, + "sha1": hashlib.sha1(old_b).hexdigest(), + "enc": "latin-1" if entry.eight else "utf-16-le", + "new": new_b, + "expected": 1, + }) + done += 1 + if done: + report.append(f"常量池 [{cat}]: {done} 条条目") + return done + + +def _pool_channel(data, display_name, display_version, design, theme_color, + real_version, rewrite_real_version, + patches, report, problems, warnings): + """bytecode 构建: 补丁全部打到常量池。""" + region = None + if design: + mapping, layouts, _default_vis = _pool_icon_map( + data, design, problems, warnings) + _report_layouts(layouts, report) + if mapping is not None: + region = bytecode.region_for( + data, [s for s in mapping if len(s) >= 5]) or None + _pool_emit(data, mapping, region, "icon", patches, report, problems, + prefix="icon_") + _pool_emit(data, _pool_html(data, design, warnings), None, "icon", + patches, report, problems, prefix="icon_") + _pool_leftover_art(data, mapping, region, report, warnings) + + name_map = {} + for old_vis, make_new, where in _POOL_NAME_SITES: + if bytecode.unique(data, old_vis): + name_map[old_vis] = (make_new(display_name), where) + else: + warnings.append(f"常量池里没有 {old_vis!r},跳过({where})") + if not name_map: + problems.append("常量池里一个产品名条目都没有 —— 该版本结构变了,不要猜") + _pool_emit(data, name_map, None, "name", patches, report, problems) + + _pool_version(data, display_version, real_version, rewrite_real_version, + patches, report, problems, warnings) + + if theme_color: + try: + packed = pack_rgb(*theme_color) + except ValueError as e: + problems.append(f"主题色格式失败: {e}") + else: + color_map = {} + for site in _COLOR_SITES: + old_rgb = site.split('"', 1)[1].rstrip('"') + if bytecode.unique(data, old_rgb): + color_map[old_rgb] = (packed, site.split(":", 1)[0]) + if not color_map: + report.append("theme_color: 常量池里没有已知配色条目,跳过") + _pool_emit(data, color_map, None, "color", patches, report, problems, + prefix="color_") + + +def _pool_version(data, display_version, real_version, rewrite, + patches, report, problems, warnings): + """bytecode 构建上启动画面的版本号与 --version 共用同一条常量。""" + if not display_version: + return + if not real_version: + warnings.append("未探测到真实版本,跳过版本显示位") + return + entry = bytecode.unique(data, real_version) + if entry is None: + warnings.append(f"常量池里找不到版本常量 {real_version!r},跳过版本显示位") + return + if not rewrite: + warnings.append( + f"启动画面的版本号与 --version 共用同一条常量({real_version!r} " + f"@ {entry.chars}),改它会连内部版本一起改 —— 已跳过。" + "确要改: 配置 [display] rewrite_real_version = true") + return + if len(display_version) != len(real_version): + problems.append( + f"改内部版本常量需等长: 真实版本 {real_version!r}({len(real_version)} 字符)," + f"display.version {display_version!r}({len(display_version)} 字符)") + return + _pool_emit(data, {real_version: (display_version, "version")}, None, + "version", patches, report, problems) + warnings.append("已改写内部版本常量: --version 与更新检查都会看到假版本") + + +def _pool_leftover_art(data, mapping, region, report, warnings): + """报出区间内没被设计稿覆盖到的图案条目,让上游新增的图案不至于默默漏掉。""" + if not region: + return + leftover = [e.text for e in bytecode.art_entries_in(data, *region) + if e.text not in mapping and len(e.text) >= 4] + if leftover: + warnings.append("常量池里还有未被设计稿覆盖的图案条目: " + + " ".join(repr(t) for t in leftover[:6])) + + def build_patches(data: bytes, display_name: str, display_version: str, design: dict, - theme_color=None): - """返回 (patches, report, problems)。 + theme_color=None, real_version=None, rewrite_real_version=False): + """返回 (patches, report, problems, warnings)。 + + problems 是致命问题(草稿不可直接用),warnings 只是提示。 + + bytecode 构建走常量池通道: 运行时读的是 bytecode 常量池,二进制里那份 + JS 源码文本已经不驱动 UI —— 改它能通过一切计数校验,屏幕上却毫无变化。 + 老版本(非 bytecode)仍走源码文本通道。 design=None → 不动图标(保持原图标) display_version=None → 不动版本位(显示真实版本) @@ -533,17 +847,24 @@ def build_patches(data: bytes, display_name: str, display_version: str, design: patches: list = [] report: list = [] problems: list = [] + warnings: list = [] + if bytecode.is_bytecode_build(data): + report.append("bytecode 构建: 补丁打到常量池(源码文本不驱动 UI)") + _pool_channel(data, display_name, display_version, design, theme_color, + real_version, rewrite_real_version, + patches, report, problems, warnings) + return patches, report, problems, warnings + if design: _icon_table(data, design, patches, report, problems) _icon_feet(data, design, patches, report, problems) _icon_mid(data, design, patches, report, problems) _icon_html(data, design, patches, report, problems) - name_offsets = _names(data, display_name, patches, report, problems) - if not problems: - if display_version: - _versions(data, name_offsets, display_version, patches, report, problems) - _theme_color(data, theme_color, patches, report, problems) - return patches, report, problems + name_offsets = _names(data, display_name, patches, report, problems, warnings) + if display_version and name_offsets: + _versions(data, name_offsets, display_version, patches, report, problems) + _theme_color(data, theme_color, patches, report, problems) + return patches, report, problems, warnings def collect_contexts(data: bytes, limit_per_kind: int = 3) -> list: diff --git a/patcher/glyphpack.py b/patcher/glyphpack.py index e6e6e92..d6d5162 100644 --- a/patcher/glyphpack.py +++ b/patcher/glyphpack.py @@ -38,6 +38,38 @@ def decode_escapes(s: str) -> str: return ESC_RE.sub(lambda m: chr(int(m.group(1), 16)), s) +def split_row(widths, glyphs: str, labels=None) -> list: + """把设计稿的一整行字符,按给定的各槽位列数切开。 + + 上游会在版本之间挪动字符在槽位间的边界(如 r1E 5→6 列、r1R 1→0 列) + 而整行列数不变。按整行接收设计稿再现场切分,设计稿就不必跟着每个版本改。 + """ + total = sum(widths) + if len(glyphs) != total: + names = labels or [f"#{i}" for i in range(len(widths))] + detail = " + ".join(f"{n}={w}" for n, w in zip(names, widths)) + raise ValueError( + f"整行列数不符: 二进制槽位共 {total} 列({detail})," + f"设计稿给了 {len(glyphs)} 列: {glyphs!r}") + out, i = [], 0 + for w in widths: + out.append(glyphs[i:i + w]) + i += w + return out + + +def pack_row(slot_contents, glyphs: str) -> list: + """按二进制现有的槽位切分,把整行设计字符重新分配并打包成源码形式。 + + slot_contents: [(槽位名, 源码形式内容), ...],按显示顺序 + 返回与之等长的新源码内容列表。 + """ + token_lists = [tokenize(c) for _, c in slot_contents] + parts = split_row([len(t) for t in token_lists], glyphs, + [name for name, _ in slot_contents]) + return [pack(tokens, part) for tokens, part in zip(token_lists, parts)] + + def pack(tokens, glyphs: str) -> str: """按原 token 形状,用设计稿的可见字符重新生成源码字符串。""" chars = list(glyphs) diff --git a/patcher/verify.py b/patcher/verify.py index 2f18020..9df6aba 100644 --- a/patcher/verify.py +++ b/patcher/verify.py @@ -12,7 +12,7 @@ import subprocess import time -from . import discovery, glyphpack, ui +from . import bytecode, discovery, glyphpack, ui _PREVIEW_WINDOW = 4000 # logo 表附近查找 mid/feet 的半径 @@ -123,6 +123,44 @@ def _capture_pty(binary: str, seconds: int = 10) -> str: return out.decode("utf-8", errors="replace") +def _verify_pool(data: bytes, design, raw_name: str, padded: str) -> int: + """bytecode 构建: 只有常量池说了算,数源码文本会给出假阳性。 + + 这里用「至少命中一条」而不是「恰好一条」: 原始池按内容去重,但补丁是 + 就地覆写,几个原本不同的条目被改成同一个图案后,内容就不再唯一了。 + """ + failures = 0 + ui.step("图标预览(常量池)") + art = discovery.pool_art(design) if design else None + if art: + for line in art: + ui.info(" " + line) + for label, text in (("mid", design.get("mid", {}).get("row")), + ("feet", design.get("feet", {}).get("row")), + ("n2p", design.get("n2p", {}).get("row"))): + if not text: + continue + hits = bytecode.find(data, text) + (ui.ok if hits else ui.fail)( + f"设计稿 {label} {text!r} 在常量池: " + + (f"{len(hits)} 条 @ {hits[0].chars:,}" if hits else "未找到")) + if not hits: + failures += 1 + else: + ui.info("未选择设计稿,跳过图标校验") + + ui.step("显示内容计数(常量池)") + if raw_name: + hits = bytecode.find(data, padded) + (ui.ok if hits else ui.fail)( + f"显示名 {padded!r} 在常量池: " + + (f"{len(hits)} 条 @ {hits[0].chars:,}" if hits + else "未找到 —— 二进制可能没打补丁")) + if not hits: + failures += 1 + return failures + + def run(binary: str, cfg: dict, pty: bool = False) -> int: data = pathlib.Path(binary).read_bytes() display = cfg.get("display", {}) @@ -132,6 +170,7 @@ def run(binary: str, cfg: dict, pty: bool = False) -> int: # 定位第三行小脚的期望内容(设计稿),给预览用 feet_expected = None + design = None if not display.get("keep_icon"): try: from . import analyze @@ -145,38 +184,45 @@ def run(binary: str, cfg: dict, pty: bool = False) -> int: except Exception: # noqa: BLE001 — 预览是尽力而为,不因设计稿问题中断验证 pass - ui.step("图标预览") - _preview(data, feet_expected) + try: + padded = discovery.validate_name(raw_name) if raw_name else "" + except ValueError: + padded = raw_name failures = 0 - ui.step("显示内容计数") - if raw_name: - # 锚定 children:"..." 上下文计数;裸子串对 'Code' 这类常见词会假阳性 - try: - padded = discovery.validate_name(raw_name) - except ValueError: - padded = raw_name - anchored = f'children:"{padded}"'.encode("ascii", errors="replace") - cnt = data.count(anchored) - (ui.ok if cnt else ui.fail)(f"显示名锚点 {anchored.decode()!r}: {cnt} 次") - if cnt == 0: - ui.warn("显示名未出现在标题显示位——二进制可能没打补丁") - failures += 1 - if version: - # 按补丁写入的形状计数: ["v9.99 "](数组位)/ ` v9.99 `(模板位) - vb = re.escape(version.encode()) - cnt = len(re.findall(rb'\["' + vb + rb' *"\]', data)) \ - + len(re.findall(rb'` ?' + vb + rb' *`', data)) - (ui.ok if cnt else ui.fail)(f"显示版本 {version!r}: {cnt} 处显示位") - if cnt == 0: - failures += 1 - elif real_version: - ui.info("已选择显示真实版本,跳过版本计数") + if bytecode.is_bytecode_build(data): + failures += _verify_pool(data, design, raw_name, padded) + if version: + ui.info("bytecode 构建上版本显示位与内部版本共用一条常量,跳过版本计数") + else: + ui.step("图标预览") + _preview(data, feet_expected) + ui.step("显示内容计数") + if raw_name: + # 锚定 children:"..." 上下文计数;裸子串对 'Code' 这类常见词会假阳性 + anchored = f'children:"{padded}"'.encode("ascii", errors="replace") + cnt = data.count(anchored) + (ui.ok if cnt else ui.fail)(f"显示名锚点 {anchored.decode()!r}: {cnt} 次") + if cnt == 0: + ui.warn("显示名未出现在标题显示位——二进制可能没打补丁") + failures += 1 + if version: + # 按补丁写入的形状计数: ["v9.99 "](数组位)/ ` v9.99 `(模板位) + vb = re.escape(version.encode()) + cnt = len(re.findall(rb'\["' + vb + rb' *"\]', data)) \ + + len(re.findall(rb'` ?' + vb + rb' *`', data)) + (ui.ok if cnt else ui.fail)(f"显示版本 {version!r}: {cnt} 处显示位") + if cnt == 0: + failures += 1 + elif real_version: + ui.info("已选择显示真实版本,跳过版本计数") if failures: ui.fail("显示内容计数未通过") return 1 - ui.step("运行 --version(证明内部版本未受影响)") + rewrote = bool(display.get("rewrite_real_version")) and version + ui.step("运行 --version" + ("(已选择改写内部版本,这里会是假版本)" if rewrote + else "(证明内部版本未受影响)")) try: r = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=60) out = (r.stdout or r.stderr).strip() diff --git a/patches/2.1.261.toml b/patches/2.1.261.toml new file mode 100644 index 0000000..7020a05 --- /dev/null +++ b/patches/2.1.261.toml @@ -0,0 +1,218 @@ +# 由 patcher analyze 生成(锚点全部发现时 verified 自动为 true,可直接用) +[meta] +target_version = '2.1.261' +design = 'octopus.toml' +display_name = ' Loop Code ' +display_version = 'v9.9' +theme_color = '2E9BFF' +verified = true + +[[patch]] +name = 'icon_default_r1L' +cat = 'icon' +desc = "default.r1L/look-left.r1L/look-right.r1L → ' ▗'" +sha1 = '7fa82235158786192df2fa8f545b1e7f632e385b' +offset = 71887832 +enc = 'utf-16-le' +new = ' ▗' +expected = 1 + +[[patch]] +name = 'icon_n2p_default' +cat = 'icon' +desc = "n2p.default/n2p.arms-up → ' ╵ ╵ '" +sha1 = '9ffe32f30954ab90c14c360327f9813230b17e46' +offset = 71887976 +enc = 'utf-16-le' +new = ' ╵ ╵ ' +expected = 1 + +[[patch]] +name = 'icon_n2p_look-left' +cat = 'icon' +desc = "n2p.look-left → ' ╵ ╵ '" +sha1 = '6d79748e9a4ad9b75ca91ea11604bc828b60fdd3' +offset = 71888000 +enc = 'utf-16-le' +new = ' ╵ ╵ ' +expected = 1 + +[[patch]] +name = 'icon_arms-up_r2L' +cat = 'icon' +desc = "arms-up.r2L → ' ▟'" +sha1 = '62873b7cd1b4c3f27df342248377a6b72f46513a' +offset = 71887952 +enc = 'utf-16-le' +new = ' ▟' +expected = 1 + +[[patch]] +name = 'icon_n2p_look-right' +cat = 'icon' +desc = "n2p.look-right → ' ╵ ╵ '" +sha1 = 'd9525d308b500e6aad7c51039fce8407b57d24af' +offset = 71888024 +enc = 'utf-16-le' +new = ' ╵ ╵ ' +expected = 1 + +[[patch]] +name = 'icon_arms-up_r1R' +cat = 'icon' +desc = "arms-up.r1R → '▖'" +sha1 = '98c8b0c548da4389251a838b8b7437b001c69c35' +offset = 71887940 +enc = 'utf-16-le' +new = '▖' +expected = 1 + +[[patch]] +name = 'icon_default_r2R' +cat = 'icon' +desc = "default.r2R/look-left.r2R/look-right.r2R → '█▙'" +sha1 = '24708067485053e75d9b350ae2bf4a7066b726bc' +offset = 71887876 +enc = 'utf-16-le' +new = '█▙' +expected = 1 + +[[patch]] +name = 'icon_mid_row' +cat = 'icon' +desc = "mid.row → '█◉█◉█'" +sha1 = '1a2851d7d77422cda11d9d5bf19eebcca6039aab' +offset = 71888152 +enc = 'utf-16-le' +new = '█◉█◉█' +expected = 1 + +[[patch]] +name = 'icon_arms-up_r2R' +cat = 'icon' +desc = "arms-up.r2R → '▙ '" +sha1 = 'db47383ab40595fe21010688dd8fccdde7075620' +offset = 71887964 +enc = 'utf-16-le' +new = '▙ ' +expected = 1 + +[[patch]] +name = 'icon_look-right_r1E' +cat = 'icon' +desc = "look-right.r1E → '▄▄▄▄▖ '" +sha1 = '1f680c766f483b024aba2999b6a9ba448a2473d4' +offset = 71887908 +enc = 'utf-16-le' +new = '▄▄▄▄▖ ' +expected = 1 + +[[patch]] +name = 'icon_arms-up_r1L' +cat = 'icon' +desc = "arms-up.r1L → '▗ '" +sha1 = '48b2f79ecbac8076266aad73d011a1bb70fd1660' +offset = 71887928 +enc = 'utf-16-le' +new = '▗ ' +expected = 1 + +[[patch]] +name = 'icon_feet_row(小_logo)' +cat = 'icon' +desc = "feet.row(小 logo) → '╵╵ ╵╵'" +sha1 = '2b8f084752a0a8d27bb33226ae68773c5f80bd80' +offset = 71888216 +enc = 'utf-16-le' +new = '╵╵ ╵╵' +expected = 1 + +[[patch]] +name = 'icon_default_r1E' +cat = 'icon' +desc = "default.r1E/arms-up.r1E → '▄▄▄▄▖ '" +sha1 = 'cb894373e2166bcd43b58d442f9e33129844a55d' +offset = 71887844 +enc = 'utf-16-le' +new = '▄▄▄▄▖ ' +expected = 1 + +[[patch]] +name = 'icon_default_r2L' +cat = 'icon' +desc = "default.r2L/look-left.r2L/look-right.r2L → '▟█'" +sha1 = 'c40266bc3aa12427324e268d6734ee3e3de38cd2' +offset = 71887864 +enc = 'utf-16-le' +new = '▟█' +expected = 1 + +[[patch]] +name = 'icon_feet_row(大_logo)' +cat = 'icon' +desc = "feet.row(大 logo) → '╵╵ ╵╵'" +sha1 = '3c16187c7b50d914d912f7e98cfc0b28772a1749' +offset = 71888172 +enc = 'utf-16-le' +new = '╵╵ ╵╵' +expected = 1 + +[[patch]] +name = 'icon_look-left_r1E' +cat = 'icon' +desc = "look-left.r1E → '▄▄▄▄▖ '" +sha1 = '7cfcacc8da196eeda6b6585129fdbde82d1e6109' +offset = 71887888 +enc = 'utf-16-le' +new = '▄▄▄▄▖ ' +expected = 1 + +[[patch]] +name = 'icon_html' +cat = 'icon' +desc = "html → ' ▗▄▄▄▄▖ \\n▟██◉█◉██▙\\n ╵╵ ╵╵'" +sha1 = 'd07dc94eeba8462368684e333293ef95317c7d5f' +offset = 78321248 +enc = 'utf-16-le' +new = " ▗▄▄▄▄▖ \n▟██◉█◉██▙\n ╵╵ ╵╵" +expected = 1 + +[[patch]] +name = 'name' +cat = 'name' +desc = "name → ' Loop Code '" +sha1 = '61f2de21bc4ec3ea02fbbbcc718e953a2692d5f8' +offset = 70649376 +enc = 'latin-1' +new = ' Loop Code ' +expected = 1 + +[[patch]] +name = 'name_welcome' +cat = 'name' +desc = "name_welcome → 'Welcome to Loop Code '" +sha1 = '5dbda879ebc1ae7b03c10b815c6c0ab53587f5e3' +offset = 72548348 +enc = 'latin-1' +new = 'Welcome to Loop Code ' +expected = 1 + +[[patch]] +name = 'color_clawd_body' +cat = 'color' +desc = "clawd_body → 'rgb(46,155,255)'" +sha1 = 'e3161cd3c031125bdbed6c0a3599f3a040036d88' +offset = 76318392 +enc = 'latin-1' +new = 'rgb(46,155,255)' +expected = 1 + +[[patch]] +name = 'color_claude' +cat = 'color' +desc = "claude → 'rgb(46,155,255)'" +sha1 = '9eedcf8b3b272bea5582b21be49541e5f2713caf' +offset = 76319848 +enc = 'latin-1' +new = 'rgb(46,155,255)' +expected = 1 diff --git a/pyproject.toml b/pyproject.toml index 4b66cfa..bd166cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ccskin" -version = "0.2.0" +version = "0.3.0" description = "Display-layer theming for the Claude Code CLI binary (icon, name, version, theme color)" readme = "README.md" requires-python = ">=3.9" diff --git a/tests/test_core.py b/tests/test_core.py index 2558fea..8607033 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -3,7 +3,9 @@ 合成 bundle 只复刻结构(槽位形状/锚点模式),槽位内容是我们自己编的, 不包含任何 Claude Code 的代码。运行: python3 -m unittest discover -s tests -v """ +import hashlib import os +import struct import pathlib import sys import tempfile @@ -11,22 +13,46 @@ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) -from patcher import defio, discovery, glyphpack, locate, patch as patch_mod, tomlmini +from patcher import (bytecode, defio, discovery, glyphpack, locate, + patch as patch_mod, tomlmini) + + +def esc(s: str) -> str: + """可见字符 → 二进制里的源码形式(空格是字面位,其余走 \\uXXXX 转义)。""" + return "".join(ch if ch == " " else chr(92) + "u%04X" % ord(ch) for ch in s) # ---------- 合成 bundle(结构仿真,内容自编) ---------- -LOGO_TABLE = ( - '{default:{r1L:" \\u2590",r1E:"\\u2591\\u2592\\u2593\\u2588\\u2591",r1R:"\\u258C",' - 'r2L:"\\u259D\\u259C",r2R:"\\u259B\\u2598"},' - '"look-left":{r1L:" \\u2590",r1E:"\\u2591\\u2592\\u2593\\u2588\\u2591",r1R:"\\u258C",' - 'r2L:"\\u259D\\u259C",r2R:"\\u259B\\u2598"},' - '"look-right":{r1L:" \\u2590",r1E:"\\u2591\\u2592\\u2593\\u2588\\u2591",r1R:"\\u258C",' - 'r2L:"\\u259D\\u259C",r2R:"\\u259B\\u2598"},' - '"arms-up":{r1L:"\\u2597\\u259F",r1E:"\\u2591\\u2592\\u2593\\u2588\\u2591",' - 'r1R:"\\u2599\\u2596",r2L:" \\u259C",r2R:"\\u259B "}},' - 'N2p={default:" \\u2597 \\u2596 ","look-left":" \\u2598 \\u2598 ",' - '"look-right":" \\u259D \\u259D ","arms-up":" \\u2597 \\u2596 "}' -) +# 同一张图的两种槽位切分。上游在 2.1.261 把 r1E 从 5 列加到 6 列、r1R 从 1 列减到 0, +# 整行列数不变 —— 设计稿不该因此改动。 +ROW1 = {v: " ▐░▒▓█░▌" for v in ("default", "look-left", "look-right")} +ROW1["arms-up"] = "▗▟░▒▓█░▙▖" +ROW2 = {v: "▝▜▛▘" for v in ("default", "look-left", "look-right")} +ROW2["arms-up"] = " ▜▛ " +N2P = {"default": " ▗ ▖ ", "look-left": " ▘ ▘ ", + "look-right": " ▝ ▝ ", "arms-up": " ▗ ▖ "} +NARROW = {v: (2, 5, 1) for v in ROW1} | {"arms-up": (2, 5, 2)} +WIDE = {v: (2, 6, 0) for v in ROW1} | {"arms-up": (2, 6, 1)} + + +def make_logo_table(widths) -> str: + """按给定的槽位列数合成 logo 表(同一张图,不同的槽位边界)。""" + variants = [] + for v, (a, b, c) in widths.items(): + r1, r2 = ROW1[v], ROW2[v] + assert len(r1) == a + b + c, v + key = v if v == "default" else '"%s"' % v + variants.append( + '%s:{r1L:"%s",r1E:"%s",r1R:"%s",r2L:"%s",r2R:"%s"}' + % (key, esc(r1[:a]), esc(r1[a:a + b]), esc(r1[a + b:]), + esc(r2[:2]), esc(r2[2:]))) + feet = ",".join('%s:"%s"' % (v if v == "default" else '"%s"' % v, esc(row)) + for v, row in N2P.items()) + return "{" + ",".join(variants) + "}," + "N2p={" + feet + "}" + + +LOGO_TABLE = make_logo_table(NARROW) +LOGO_TABLE_WIDE = make_logo_table(WIDE) FEET = '"\\u2598\\u2598 \\u259D\\u259D"' MID = 'clawd_background",children:"\\u2588\\u2588\\u2588\\u2588\\u2588"' HTML_LOGO = '` \\u2590\\u259B\\u2588\\u2588\\u2588\\u259C\\u258C\n\\u259D\\u259C\\u2588\\u2588\\u2588\\u2588\\u2588\\u259B\\u2598\n \\u2598\\u2598 \\u259D\\u259D`;' @@ -39,15 +65,15 @@ COLORS = 'claude:"rgb(215,119,87)";clawd_body:"rgb(215,119,87)";' -def make_bundle() -> bytes: +def make_bundle(logo_table: str = None, names: str = None) -> bytes: parts = [ os.urandom(500), - LOGO_TABLE.encode(), + (logo_table or LOGO_TABLE).encode(), os.urandom(300), FEET.encode(), os.urandom(100), FEET.encode(), os.urandom(200), MID.encode(), os.urandom(200), HTML_LOGO.encode(), - os.urandom(300), NAMES.encode(), + os.urandom(300), (names or NAMES).encode(), os.urandom(300), COLORS.encode(), os.urandom(500), ] @@ -59,7 +85,8 @@ def make_bundle() -> bytes: "slots": { v: {"r1L": " ▗", "r1E": "▄▄▄▄▖", "r1R": " ", "r2L": "▟█", "r2R": "█▙"} for v in ("default", "look-left", "look-right") - } | {"arms-up": {"r1L": "▗ ", "r1E": "▗▄▄▄▖", "r1R": " ▖", "r2L": " ▟", "r2R": "▙ "}}, + # arms-up 的 r1E 与 default 共用同一条常量池条目,只能靠 r1L/r1R 表现举手 +} | {"arms-up": {"r1L": "▗ ", "r1E": "▄▄▄▄▖", "r1R": " ▖", "r2L": " ▟", "r2R": "▙ "}}, "mid": {"row": "█◉█◉█"}, "feet": {"row": "╵╵ ╵╵"}, "n2p": {"row": " ▄ ▄ "}, @@ -93,6 +120,31 @@ def test_decode_escapes(self): self.assertEqual(glyphpack.decode_escapes(r"\u2597\u2596"), "▗▖") + def test_pack_row_splits_by_binary_slots(self): + slots = [("r1L", esc(" ▐")), ("r1E", esc("░▒")), ("r1R", "")] + self.assertEqual( + glyphpack.pack_row(slots, " ▗▄▖"), + [esc(" ▗"), esc("▄▖"), ""]) + + def test_pack_row_regroups_same_glyphs(self): + """同一行字符,槽位边界挪动后,打包拼起来必须完全一致。""" + narrow = [("r1L", esc(" ▐")), ("r1E", esc("░▒")), ("r1R", esc("▓"))] + wide = [("r1L", esc(" ▐")), ("r1E", esc("░▒▓")), ("r1R", "")] + row = " ▗▄▖▄" + self.assertEqual( + "".join(glyphpack.pack_row(narrow, row)), + "".join(glyphpack.pack_row(wide, row))) + + def test_pack_row_reports_both_widths(self): + slots = [("r1L", esc(" ▐")), ("r1E", esc("░▒"))] + with self.assertRaises(ValueError) as cm: + glyphpack.pack_row(slots, "▗▄") + msg = str(cm.exception) + self.assertIn("共 4 列", msg) + self.assertIn("给了 2 列", msg) + self.assertIn("r1E=2", msg) + + class TestColor(unittest.TestCase): def test_pack_rgb_exact_length(self): self.assertEqual(discovery.pack_rgb(215, 119, 87), "rgb(215,119,87)") @@ -116,7 +168,7 @@ def test_parse_hex(self): class TestDiscovery(unittest.TestCase): def setUp(self): self.data = make_bundle() - self.patches, self.report, self.problems = discovery.build_patches( + self.patches, self.report, self.problems, self.warnings = discovery.build_patches( self.data, " Loop Code ", "v9.99", DESIGN, theme_color=(46, 155, 255)) def test_no_problems(self): @@ -143,7 +195,7 @@ def test_counts(self): self.assertEqual(by_name["name_title"], 1) def test_skip_icon_and_version(self): - patches, _, problems = discovery.build_patches( + patches, _, problems, _w = discovery.build_patches( self.data, " Loop Code ", None, None) self.assertEqual(problems, []) cats = {p["cat"] for p in patches} @@ -151,10 +203,267 @@ def test_skip_icon_and_version(self): self.assertNotIn("version", cats) +# ---------- 合成 bytecode 常量池 ---------- +def pool_entry(text: str) -> bytes: + """按实测布局造一条常量池条目: len|flag + hash + chars + 4 字节对齐补 0。""" + eight = all(ord(c) < 256 for c in text) + chars = text.encode("latin-1") if eight else text.encode("utf-16-le") + head = struct.pack(" bytes: + return b"".join(pool_entry(t) for t in texts) + + +def pool_strings(widths=NARROW, name="Claude Code", version="9.9.999"): + """合成 bundle 里应当出现在常量池里的全部字符串(与源码 logo 表一致)。""" + out = [] + for v, (a, b, c) in widths.items(): + r1, r2 = ROW1[v], ROW2[v] + out += [r1[:a], r1[a:a + b], r1[a + b:], r2[:2], r2[2:]] + out += list(N2P.values()) + out += ["\u2588\u2588\u2588\u2588\u2588", "\u2598\u2598 \u259d\u259d", "\u259d\u259d \u259d\u259d", + name, "Welcome to " + name, version, + "rgb(215,119,87)", "rgb(255,153,51)"] + return [t for t in dict.fromkeys(out) if t] + + +def make_bytecode_bundle(widths=NARROW, **kw) -> bytes: + """源码文本(给语义地图) + @bun @bytecode 标记 + 常量池(驱动 UI)。""" + return b"".join([ + os.urandom(400), + make_logo_table(widths).encode(), + os.urandom(200), MID.encode(), + os.urandom(200), NAMES.encode(), + os.urandom(200), b"// @bun @bytecode\n", + os.urandom(200), make_pool(pool_strings(widths, **kw)), + os.urandom(400), + ]) + + +class TestBytecodePool(unittest.TestCase): + def setUp(self): + self.data = make_bytecode_bundle() + + def test_detects_bytecode_build(self): + self.assertTrue(bytecode.is_bytecode_build(self.data)) + self.assertFalse(bytecode.is_bytecode_build(make_bundle())) + + def test_finds_utf16_and_8bit_entries(self): + u = bytecode.unique(self.data, "\u2588\u2588\u2588\u2588\u2588") + a = bytecode.unique(self.data, "Claude Code") + self.assertIsNotNone(u) + self.assertIsNotNone(a) + self.assertFalse(u.eight) + self.assertTrue(a.eight) + + def test_entry_chars_roundtrip(self): + e = bytecode.unique(self.data, "Claude Code") + self.assertEqual(self.data[e.chars:e.chars + e.nbytes], b"Claude Code") + + def test_alignment_padded_entry_found(self): + # 5 个 UTF-16 字符 = 10 字节,要补 2 字节才 4 字节对齐 + e = bytecode.unique(self.data, "\u2588\u2588\u2588\u2588\u2588") + self.assertEqual(e.nbytes, 10) + + def test_replacement_rejects_length_change(self): + e = bytecode.unique(self.data, "Claude Code") + self.assertEqual(len(bytecode.replacement(e, " Loop Code ")), e.nbytes) + with self.assertRaises(ValueError): + bytecode.replacement(e, "Loop") + + def test_replacement_rejects_non_latin1_in_8bit_entry(self): + e = bytecode.unique(self.data, "Claude Code") + with self.assertRaises(ValueError): + bytecode.replacement(e, "\u2588oop Code ") + + def test_region_narrows_search(self): + region = bytecode.region_for(self.data, ["\u2588\u2588\u2588\u2588\u2588"], pad=64) + self.assertIsNotNone(region) + self.assertEqual(bytecode.find(self.data, "Claude Code", region), []) + + +class TestPoolChannel(unittest.TestCase): + """bytecode 构建必须走常量池 —— 改源码文本能过校验但屏幕上没反应。""" + + def _build(self, widths=NARROW, design=None, display_version="v9.9", **kw): + data = make_bytecode_bundle(widths) + return data, discovery.build_patches( + data, " Loop Code ", display_version, design or DESIGN, + theme_color=(46, 155, 255), real_version="9.9.999", **kw) + + def _apply(self, patches, data): + buf = bytearray(data) + for p in patches: + buf[p["offset"]:p["offset"] + len(p["new"])] = p["new"] + return bytes(buf) + + def _rendered_row1(self, widths): + """打完补丁后 default 变体第一行实际会渲染成什么。""" + data, (patches, _, problems, _w) = self._build(widths) + self.assertEqual(problems, []) + out = self._apply(patches, data) + a, b, _c = widths["default"] + r1 = ROW1["default"] + parts = [] + for old in (r1[:a], r1[a:a + b], r1[a + b:]): + if not old: + continue + e = bytecode.find(data, old)[0] + parts.append(out[e.chars:e.chars + e.nbytes].decode("utf-16-le")) + return "".join(parts) + + def test_patches_target_pool_not_source(self): + data, (patches, _, problems, _w) = self._build() + self.assertEqual(problems, []) + src = data.find(make_logo_table(NARROW).encode()) + src_end = src + len(make_logo_table(NARROW)) + self.assertTrue(patches) + for p in patches: + self.assertFalse(src <= p["offset"] < src_end, p["name"]) + self.assertIsNone(p.get("old")) # 池补丁一律 offset+sha1 + + def test_same_rendered_row_across_slot_layouts(self): + """槽位边界变了,每条条目的内容跟着变,但渲染出来的整行必须一致。""" + want = "".join(DESIGN["slots"]["default"][s] + for s in ("r1L", "r1E", "r1R")) + self.assertEqual(self._rendered_row1(NARROW), want) + self.assertEqual(self._rendered_row1(WIDE), want) + + def test_dedup_conflict_is_reported(self): + bad = dict(DESIGN) + bad["slots"] = dict(DESIGN["slots"]) + # arms-up 的 r1E 与 default 共用一条池条目,给不同图案必须报错 + bad["slots"]["arms-up"] = dict(DESIGN["slots"]["arms-up"], r1E="\u2596\u2596\u2596\u2596\u2596") + _, (_, _, problems, _w) = self._build(design=bad) + self.assertTrue(any("去重" in p for p in problems), problems) + + def test_version_shares_constant_with_real_version(self): + _, (patches, _, problems, warnings) = self._build() + self.assertEqual(problems, []) + self.assertFalse([p for p in patches if p["cat"] == "version"]) + self.assertTrue(any("--version" in w for w in warnings), warnings) + + def test_version_rewrite_is_opt_in(self): + _, (patches, _, problems, _w) = self._build( + display_version="9.9.998", rewrite_real_version=True) + self.assertEqual(problems, []) + self.assertEqual([p["cat"] for p in patches].count("version"), 1) + + def test_version_rewrite_requires_equal_length(self): + _, (_, _, problems, _w) = self._build(rewrite_real_version=True) + self.assertTrue(any("等长" in p for p in problems), problems) + + def test_name_and_welcome_both_patched(self): + _, (patches, _, _p, _w) = self._build() + names = {p["name"] for p in patches if p["cat"] == "name"} + self.assertEqual(names, {"name", "name_welcome"}) + + def test_applying_patches_changes_pool(self): + data, (patches, _, problems, _w) = self._build() + self.assertEqual(problems, []) + for p in patches: + off, new = p["offset"], p["new"] + self.assertEqual( + hashlib.sha1(data[off:off + len(new)]).hexdigest(), p["sha1"]) + out = self._apply(patches, data) + self.assertEqual(len(out), len(data)) + self.assertIsNotNone(bytecode.unique(out, " Loop Code ")) + self.assertTrue(bytecode.find(out, DESIGN["mid"]["row"])) + self.assertIsNone(bytecode.unique(out, "Claude Code")) + + def test_defio_roundtrips_utf16_patches(self): + _, (patches, _, problems, _w) = self._build() + self.assertEqual(problems, []) + meta, loaded = defio.loads(defio.dumps({"verified": True}, patches)) + self.assertEqual(len(loaded), len(patches)) + for a, b in zip(patches, loaded): + self.assertEqual(a["new"], b["new"], a["name"]) + self.assertEqual(a["sha1"], b["sha1"]) + + +class TestSlotLayoutDrift(unittest.TestCase): + """上游挪动槽位边界后,同一份设计稿仍要打出同一张图。""" + + def _icon(self, table): + data = make_bundle(logo_table=table) + patches, report, problems, _w = discovery.build_patches( + data, " Loop Code ", "v9.9", DESIGN) + self.assertEqual(problems, []) + return data, next(p for p in patches if p["name"] == "icon_table"), report + + def test_same_art_across_layouts(self): + _, narrow, _ = self._icon(LOGO_TABLE) + _, wide, _ = self._icon(LOGO_TABLE_WIDE) + self.assertEqual(narrow["art_new"], wide["art_new"]) + + def test_span_length_and_sha1_hold(self): + for label, table in (("narrow", LOGO_TABLE), ("wide", LOGO_TABLE_WIDE)): + data, t, _ = self._icon(table) + span = data[t["offset"]:t["offset"] + len(t["new"])] + self.assertEqual(len(span), len(t["new"]), label) + self.assertEqual(hashlib.sha1(span).hexdigest(), t["sha1"], label) + + def test_detected_layout_is_reported(self): + _, _, report = self._icon(LOGO_TABLE_WIDE) + self.assertTrue(any("r1E=6 r1R=0" in line for line in report), report) + _, _, report = self._icon(LOGO_TABLE) + self.assertTrue(any("r1E=5 r1R=1" in line for line in report), report) + + def test_row_width_mismatch_names_both_sides(self): + bad = dict(DESIGN) + bad["slots"] = dict(DESIGN["slots"]) + bad["slots"]["default"] = dict(DESIGN["slots"]["default"], r1E="▄▄▄") + _, _, problems, _w = discovery.build_patches( + make_bundle(), " Loop Code ", "v9.9", bad) + self.assertTrue(any("整行列数不符" in p for p in problems), problems) + + +class TestOptionalNameAnchors(unittest.TestCase): + """上游删掉某个显示位不该连坐掉其余补丁(2.1.261 移除了边框上的产品名)。""" + + TITLE_ONLY = 'children:"Claude Code";x=["v",Ttt];' + + def setUp(self): + self.data = make_bundle(names=self.TITLE_ONLY) + self.patches, _, self.problems, self.warnings = discovery.build_patches( + self.data, " Loop Code ", "v9.9", DESIGN, theme_color=(46, 155, 255)) + + def test_missing_optional_sites_are_warnings_not_failures(self): + self.assertEqual(self.problems, []) + self.assertEqual(len(self.warnings), 3) + + def test_version_and_color_still_generated(self): + cats = {p["cat"] for p in self.patches} + self.assertIn("version", cats) + self.assertIn("color", cats) + + def test_all_names_missing_is_fatal(self): + _, _, problems, _w = discovery.build_patches( + make_bundle(names="nothing to see here;"), " Loop Code ", "v9.9", DESIGN) + self.assertTrue(any("所有名字锚点" in p for p in problems), problems) + + def test_version_capacity_comes_from_binary(self): + # 压缩后的变量名短一格,显示位就窄一格 —— 固定上限会漏判 + _, report, problems, _w = discovery.build_patches( + make_bundle(names='children:"Claude Code";x=["v",m];'), + " Loop Code ", "v9.9", DESIGN) + self.assertTrue(any("最多 3 字符" in line for line in report), report) + self.assertTrue(any("放不下" in p for p in problems), problems) + + def test_capacity_failure_is_atomic(self): + """一个位放不下就整批不改,避免一半假版本一半真版本。""" + _, _, problems, _w = discovery.build_patches( + make_bundle(names='children:"Claude Code";x=["v",m]+["v",Ttttt];'), + " Loop Code ", "v9.9", DESIGN) + self.assertTrue(any("放不下" in p for p in problems), problems) + + class TestDefio(unittest.TestCase): def test_roundtrip(self): data = make_bundle() - patches, _, problems = discovery.build_patches( + patches, _, problems, _w = discovery.build_patches( data, " Loop Code ", "v9.99", DESIGN) self.assertEqual(problems, []) text = defio.dumps({"target_version": "t", "verified": True}, patches) @@ -174,7 +483,7 @@ def _apply(self, tmpdir): target = pathlib.Path(tmpdir) / "fake-claude" target.write_bytes(make_bundle()) data = target.read_bytes() - patches, _, problems = discovery.build_patches( + patches, _, problems, _w = discovery.build_patches( data, " Loop Code ", "v9.99", DESIGN, theme_color=(46, 155, 255)) assert not problems def_path = pathlib.Path(tmpdir) / "def.toml"