diff --git a/ROADMAP.md b/ROADMAP.md index b47b506b..01d85bd1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -243,6 +243,9 @@ ADR-147:Terminal正常实际提交“你”及wq前缀确认后的failure/INT/ - [~] I2/IT-10/11、16…22:工程部分与机器可判部分已完成(报告197 对比度 26 图、T-ITER-16…21);**综合产品走查(IT-22)仍待 0.1.15 定稿包**。 - [x] 2026-10-27 IT-22 执行清单(`docs/tasks/T-ITER-22.md`):11 节 60 项,含开始前取证命令、`PASS/FAIL/BLOCKED/SKIP` 判定口径、UX-1 与搜狗日志噪音两条「已知接受项」,以及报告215 的 VoiceOver 反例条件(VO 开 → 宿主重启 → 立即打字)。**清单本身不是通过证据**,需在锁定候选上人工走一遍。 - [ ] **2026-10-27 MVP 正式发布(目标 `1.0.0`,ADR-298)**:阶段 A 在 0.1.16/26 上演练 IT-22(`docs/tasks/T-ITER-22.md`)→ 修复入库 → 阶段 B 递增 1.0.0(build 27)并双机装同一包(同时闭环「更新器自更新」)→ 阶段 C 只在 1.0.0 上做正式 IT-22、IT-15 独立重建、IT-23 五工作日 → 阶段 D `tag v1.0.0` 打在验收候选提交 → Release → Pages。 +- [x] 2026-10-28 **锁定候选 1.0.0 / build 29**(报告217):提交 `11abece3`、ZIP `aca4ed5c…`、宿主执行 `4ad949e6…`,air 已装同包。装机定向复验:2.1 多词拼接 PASS、9.1 编码播报 PASS、11.1 候选跟随(6 张截图:下方/镜像/底部翻上,无压字)PASS。**IT-22 限定范围通过**(必查 11 + 抽查 8 全 PASS,其余沿用报告216 基线)。 +- [x] 2026-10-28 **IT-15 独立离线重建通过(报告218)**:用户在 air 上从候选自带源码包离线重建 1.0.0/build 29,身份链与逐架构网络导入 PASS、**默认数据 4/4 逐字节一致**、空闲 0 外联;可执行文件因本机 SDK 27.0 vs 发布 26.5 不同(ADR-033 边界)。顺带修掉 `run-repro.sh` 的缺陷:原来 `cmp` 可执行文件失败会中断,导致数据表比对根本没跑,且漏比 `variant-s2t.tsv`。 +- [ ] 2026-10-28 IT-23 五工作日试用(**1.0.0 / build 29**,air 与 pro 执行 SHA 均为 `4ad949e6…`):两台设备已装同一包(报告217 §8),pro 装前数据与备份已核对;从下一个工作日开始逐日记录,换包即作废。 - [~] I3/IT-12/23:IT-12 性能/内存/久用已 PASS(报告191/192/199/207);**IT-23 五工作日试用尚未开始**(模板报告211)。 - [~] I4/IT-13…15:IT-13 附件绑定已在两次 Release 对账(报告194/208);**IT-14 最终候选锁定、IT-15 第三方核验待 0.1.15**。 diff --git a/docs/macos-release-checklist.md b/docs/macos-release-checklist.md index 5866245c..57cccfad 100644 --- a/docs/macos-release-checklist.md +++ b/docs/macos-release-checklist.md @@ -41,9 +41,9 @@ ADR-084:必须先满足 [MVP产品验收标准](MVP产品验收标准.md)。 | 门禁 | 缺什么 | 阻断/依赖 | |---|---|---| | **IT-14** 最终候选锁定 | **锁定 `1.0.0`**(ADR-298):0.1.16 上先演练 IT-22 → 修复入库 → 递增 1.0.0 → 双机装同一个包 → 只在它上面做 IT-22/IT-15/IT-23 | 演练与修复完成后再冻结 | -| **IT-22** 最终包产品走查 | 执行清单已就绪:`docs/tasks/T-ITER-22.md`(11 节 60 项 + 取证命令 + 判定口径 + VO 反例条件);**先在 0.1.16/26 演练,正式结果必须在 1.0.0 上重跑** | 演练可立即开始 | +| **IT-22** 最终包产品走查 | **已通过(限定范围,报告217 §6)**:锁定候选 1.0.0/build 29 上必查 11 项 + 抽查 8 项全 PASS;其余项沿用 0.1.16 演练结果(报告216)并注明基线,不宣称全量重跑 | — | | **IT-23** 五工作日试用 | 模板已备(报告211),一天未记;必须在 **1.0.0 候选**上连续 5 个工作日(换包当天作废) | 需先冻结 1.0.0 | -| **IT-15 / P1-M5** 第三方独立重建 + 零外联 | 预演通过(报告208),正式结果必须用户本人执行;逐字节一致需与构建同 SDK 的机器 | 用户执行 | +| **IT-15 / P1-M5** 第三方独立重建 + 零外联 | **已通过(报告218)**:用户本人在 air 上离线重建 1.0.0/build 29 —— 身份链 PASS、逐架构网络导入 PASS、**默认数据 4/4 逐字节一致**、空闲态 0 外联;可执行文件因 SDK 27.0 vs 26.5 不一致(ADR-033 边界,不判失败) | — | | **更新器自更新 / 真实跨版本升级** | 报告213 第 4 项 BLOCKED:`isolated-update-drill.yml` 只有 `build` 作业(ADR-288 所述 publish 不存在);旧版无内置更新程序,0.1.14 → 0.1.15 升级只能用旧脚本或外部更新程序 | 需授权补 publish 作业;自更新需再出一个更高构建号 | 门禁全绿后才进入 tag/Release 操作;二进制与对应源码须同页长期提供(ADR-064/065)。 diff --git a/docs/reviews/211-it23-five-day-template.md b/docs/reviews/211-it23-five-day-template.md index fa29f285..d5d97d47 100644 --- a/docs/reviews/211-it23-five-day-template.md +++ b/docs/reviews/211-it23-five-day-template.md @@ -1,6 +1,6 @@ # 211 IT-23 五工作日试用记录(v0.1.14) -> 用途:给**用户本人**(ADR-125:一位试用者即满足人数)在 `v0.1.14` 上做累计 5 个工作日的日常试用,逐日留证。 +> 用途:给**用户本人**(ADR-125:一位试用者即满足人数)在 **1.0.0 / build 29**(锁定候选,报告217)上做累计 5 个工作日的日常试用,逐日留证。 > 这不是工程自测,代理签字的记录不算;本文件由用户填写,代理只负责整理与最后补进 schema 2 报告。 > 门禁要求见 `docs/MVP产品验收标准.md` 与 `docs/MVP单人验收清单.md` §「五工作日观察记录」。 @@ -8,12 +8,12 @@ | 项 | 值 | |---|---| -| 版本 / 构建 | **0.1.14 / build 23** | -| 源码提交 | `0b2aa244f536fe9db9f4c76221a4d294fbe0e627`(tag `v0.1.14`) | -| 执行文件 SHA-256 | `a165f05c0b704796380a39f5666da5e2cfa1a8cb2d23868dfd3d1532d429e4dc` | -| 发布资产 | ZIP `7741cdde…`、DMG `c247f626…`、源码 `964f32f1…` | +| 版本 / 构建 | **1.0.0 / build 29** | +| 源码提交 | `11abece3a348213429441950af667235486a482d`(锁定候选,报告217) | +| 执行文件 SHA-256 | `4ad949e6c3608c689c07e8a3f092a53babc788b290e424b4784bebc1d3aae31c` | +| 候选资产 | ZIP `aca4ed5c…`(= 候选,逐字节)、源码 `40d88f36…`;Release 资产待发布后对账 | | 签名 | Developer ID + 公证(`spctl` = `accepted source=Notarized Developer ID`) | -| 设备 | pro(Intel / macOS 13.7.8,执行 `a165f05c…`)、air(arm64 / macOS 27.0.1,执行 `a165f05c…`) | +| 设备 | air(arm64 / macOS 27.0.1)与 pro(Intel / macOS 13.7.8)**执行 SHA 相同**,均为 `4ad949e6…` | > 每天开始前花 5 秒确认:`shasum -a 256 "$HOME/Library/Input Methods/Lampyris.app/Contents/MacOS/Lampyris"` 应为上面那个 SHA。 > 如果哪天换了包(哪怕只是 build 变了),**当天记录作废重来**,否则 5 天不在同一个候选上。 diff --git a/docs/reviews/217-1.0.0-freeze.md b/docs/reviews/217-1.0.0-freeze.md new file mode 100644 index 00000000..714ca655 --- /dev/null +++ b/docs/reviews/217-1.0.0-freeze.md @@ -0,0 +1,142 @@ +# 217 锁定候选 1.0.0 / build 29 + +> ADR-298 阶段 C 的起点:从这一刻起,**1.0.0 / build 29 就是唯一候选**, +> 门禁(IT-22 / IT-15 / IT-23)都必须在它上面做;任何换包都让 IT-23 当天作废重来。 + +## 1. 身份 + +| 项 | 值 | +|---|---| +| 版本 / 构建 | **1.0.0 / 29** | +| 源码提交 | `11abece3a348213429441950af667235486a482d`(PR #209 merge) | +| 候选构建 | `macOS Acceptance Package` run `40559560484`(success) | +| 候选 ZIP | `aca4ed5c8db8ed5320055a3f6bfd2f089efee7b8cd45e81ff49e779b5fc34f6e` | +| 宿主执行 SHA-256 | `4ad949e6c3608c689c07e8a3f092a53babc788b290e424b4784bebc1d3aae31c` | +| 签名 | Developer ID + 公证(`spctl` = Notarized Developer ID) | +| air 已装 | 宿主 1.0.0/29(执行 `4ad949e6…`),更新程序 1.0.0/29 | + +## 2. 这一版包含的、上一发布版没有的行为 + +- 更新链路:装包自带更新程序、更新器自更新与「重新打开更新程序」、窗口内卸载(ADR-294/295/296)、`--purge-data` 数据根修复、更新程序定位修复(报告213)。 +- 输入质量:拼音**多词分段拼接**(ADR-299)——`woaini→我爱你`、`zhongguoren→中国人`、`nihaoma→你好吗`、`jintiantianqihenhao→今天天气很好`。 +- 光标跟随:`CaretPlacement` 统一几何(ADR-301)——行框归一化、候选窗**先下后上**、右边缘**镜像**、三条不变量。 +- 可访问性:组合开始播报输入编码(ADR-300)。 +- 卸载语义:`keepData` 只保留 `config.toml` 与个人词库(ADR-297)。 + +## 3. 装机后的定向复验(build 29,用户执行) + +| 项 | 结果 | 证据 | +|---|---|---| +| 2.1 多词分段拼接 | PASS | 用户实测 `woaini` 等出正确整串 | +| 9.1 编码播报(VO) | PASS | 组合开始即念「编码 wq」 | +| 11.1 候选框跟随光标 | **PASS(6 张截图)** | `platforms/macos/build/acceptance-air-1.0.0-b29/shots/shot-1…6` | + +六张截图对应的位置与判定: + +| 截图 | 场景 | 观察 | 判定 | +|---|---|---|---| +| 1 | 首行、屏幕顶部 | 面板在行**下方**,有留白 | PASS | +| 2 | 超长行行末、贴右边缘 | 面板在**下方**且**镜像到插入点左侧**,不越界 | PASS | +| 3 | 接近屏幕底部 | 面板**翻到行上方** | PASS | +| 4 | 首行右侧 | 下方 + 镜像;间距偏紧但未压字 | PASS(间距待观察) | +| 5 | 中部行行末右侧 | 下方 + 镜像;间距偏紧但未压字 | PASS(间距待观察) | +| 6 | 中部行 | 面板在下方,留白正常 | PASS | + +## 4. 门禁分工 + +| 门禁 | 谁做 | 状态 | +|---|---|---| +| IT-22 正式走查 | 用户(代理整理清单/证据) | 待做(见 §5) | +| IT-15 第三方独立重建 | **用户本人**(ADR-125);本机重建不算独立结果 | 待做 | +| IT-23 五工作日试用 | 用户 | 待开始(第 1 天起算前先记一次 `walkthrough-evidence.sh`) | + +## 5. IT-22 正式走查范围(不必 60 项全重跑) + +build 29 相对 0.1.16(演练基线)改动的只有「候选几何 + 拼音拼接 + VO 播报 + 版本号」,所以正式走查按**受影响 + 抽查**执行,并在报告里如实写明范围,不假装全量重跑: + +- **必查(受影响)**:1.3/1.7(拼音候选)、4.1–4.6(候选/长词/多页/贴边)、9.1(VO 播报)、11.1(长文本跟随)。 +- **抽查(未受影响但属核心)**:1.1/1.2(五笔)、2.1–2.4(长组合与撤销)、3.1(TextEdit)、5.2(设置即时生效)、8.2(菜单开更新窗口)。 +- 其余项引用报告 216 在 0.1.16 上的结果,并注明基线与差异。 + +## 6. IT-22 受查项清单(build 29,用户执行,约 15 分钟) + +**必查(本版改动直接影响)** + +| # | 操作 | 预期 | 结果 | +|---|---|---|---| +| 1.3 | 打 `nihao` | 候选含「你好」 | | +| 1.7 | `nihao` 后空格 | 首选上屏(不误上屏) | | +| 1.3+ | 打 `woaini`、`zhongguoren`、`nihaoma` | 分别出「我爱你」「中国人」「你好吗」(ADR-299) | | +| 4.1 | 亮色外观 ⇢ 输入 | 候选与状态芯片对比清晰、无白底白字 | | +| 4.2 | 暗色外观 ⇢ 输入 | 同上 | | +| 4.3 | 长候选(`kwwl`、`zhonghuarenmingongheguo`) | 不截断、序号与来源可辨 | | +| 4.4 | 鼠标悬停某个候选 | 悬停态与首选态可区分 | | +| 4.5 | 多页候选翻到第 2 页 | 页码/箭头正确、头部不跳 | | +| 4.6 | 插入点贴屏幕右/下边缘输入 | 面板**镜像/翻上**、不越界、不压字(ADR-301) | | +| 9.1 | 开 VoiceOver 打 `wq`,再按 ← | 第一声「编码 wq」;← 念「第N项,文字,来源」(ADR-300) | | +| 11.1 | 长文档行末输入 | 面板贴光标、留白 ≥8pt、不压当前行 | | + +**抽查(本版未改,属核心路径)** + +| # | 操作 | 预期 | 结果 | +|---|---|---|---| +| 1.1 | `wq` | 首选「你」 | | +| 1.2 | `kwwl` | 候选含「中华人民共和国」 | | +| 2.1 | 连续 8–10 个字母不选字 | 不丢字;能 Esc 取消(现在拼音会给拼接候选,五笔超过 4 码仍无候选) | | +| 2.3 | 组合中 Esc | 组合取消,文档不变 | | +| 2.4 | 上屏后 `⌘Z` | 撤销掉刚上屏的文本 | | +| 3.1 | TextEdit 空白文档输入 | 输入/翻页/撤销正常 | | +| 5.2 | 设置里改一项后立即输入 | 立即生效,无需重启宿主 | | +| 8.2 | 菜单「检查更新…」 | 直接开更新窗口,标题显示「0.1.16 · 构建 26」→ 现在应是 **1.0.0 · 构建 29** | | + +**执行结果(2026-10-28,用户操作,build 29):必查 11 项 + 抽查 8 项全部 PASS。** + +其余项引用报告 216 在 0.1.16 上的结果(同清单、同判定口径),本次不重复执行,并在结论里注明基线差异。 + +因此 IT-22 门禁的结论是:**受影响项在锁定候选上通过,未受影响项沿用 0.1.16 演练结果并已注明基线**; +不宣称 60 项在 build 29 上全量重跑。 + +## 7. IT-15 独立重建(用户本人执行,约 30–40 分钟) + +候选已自带**对应源码包**,无需先发布: + +| 项 | 值 | +|---|---| +| 候选 ZIP | `aca4ed5c8db8ed5320055a3f6bfd2f089efee7b8cd45e81ff49e779b5fc34f6e` | +| 源码包 | `40d88f366f725eeb5ccd344dd3cc29ac544a8b5a005e2d1280c92ef12e27d696` | +| 位置 | `platforms/macos/build/acceptance-air-1.0.0-b29/candidate-mwFXhy/`(不入库) | + +一条命令跑完全部比对(校验和 → 离线重建 → 载荷/数据表比对): + +```sh +cd ~/Documents/EasyInputMethod +platforms/macos/tests/run-repro.sh \ + platforms/macos/build/acceptance-air-1.0.0-b29/candidate-mwFXhy/Lampyris-1.0.0-universal.zip \ + | tee ~/Desktop/it15-run-repro.log +``` + +判定口径(ADR-033,报告 208 §4 已实证): + +- **身份链**:`.sha256` 校验通过、`SOURCE-COMMIT` = `11abece3…`、`Cargo.toml` 1.0.0 / `build-number` 29 与 `BUILD-INFO.txt` 一致 → 必须一致。 +- **数据表**:`wubi86-core.tsv` / `pinyin-chars.tsv` / `pinyin-core.tsv` / `variant-s2t.tsv` → 必须 **4/4 逐字节一致**。 +- **可执行文件**:本机 SDK 27.0 / Swift 6.4 vs 构建机 SDK 26.5 / Swift 6.3.3 → `cmp` **预期不同**,记录 `otool -l` 的 `sdk`/链接器差异即可,不据此判失败。 +- **静态零外联**:`./scripts/verify-no-network.sh`(或 `make verify-no-network`)通过。 +- **运行时零外联**:一边打字一边 `lsof -i -a -p "$(pgrep -x Lampyris)"`,预期**无输出**。 + +## 8. IT-23 试用基线:两台设备装同一个包(2026-10-28) + +| 项 | air(本机,arm64 / macOS 27.0.1) | pro(x86_64 / macOS 13.7.8) | +|---|---|---| +| 版本 / 构建 | **1.0.0 / 29** | **1.0.0 / 29** | +| 宿主执行 SHA-256 | `4ad949e6c3608c689c07e8a3f092a53babc788b290e424b4784bebc1d3aae31c` | **同值**(同一个二进制) | +| 更新程序构建号 | 29 | 29 | +| 签名 / 公证 | Developer ID + `spctl` Notarized | 同(`codesign --strict`、`stapler validate` 均 PASS) | +| 用户数据 | config `cfc86cff…`、词库 800+ 行 | config `2bd3fde6…`、词库 **593 行**(装前装后一致) | +| 旧版本备份 | `updates/`、`uninstalled/` 历史项 | `uninstalled/bundle.aPZKev`(0.1.16) | +| 输入源 | `cn.jokerhub.inputmethod.Lampyris.default` 已启用 | 已启用;当前停在 ABC,使用时按 ⌃Space 切 | + +pro 的安装记录:`ev-95-pro-stage.log`(ZIP 校验 → 解包 → 停宿主 → `scripts/install.sh --app` → 回读)。 +第一次尝试被安装器的「宿主仍在运行」安全检查挡下(IMK 在 `pkill` 后 3 秒内重新拉起宿主), +改为「停宿主 → 确认 `pgrep -x Lampyris` 为空 → 立即安装」后成功;这是安装器**正确的**保护行为,不是缺陷。 + +**IT-23 从下一个工作日开始**:两台设备都固定在 1.0.0/29,期间任何换包都会让当天记录作废。 diff --git a/docs/reviews/218-it15-independent-rebuild.md b/docs/reviews/218-it15-independent-rebuild.md new file mode 100644 index 00000000..b9005c46 --- /dev/null +++ b/docs/reviews/218-it15-independent-rebuild.md @@ -0,0 +1,57 @@ +# 218 IT-15 独立离线重建(1.0.0 / build 29) + +> ADR-125:独立核验必须由用户本人执行,代理在本机跑的重建不算独立结果。 +> 本次重建由用户在 air 上执行(2026-10-28 03:30),代理只负责核对日志、修工具缺陷与写报告。 + +## 1. 输入 + +| 项 | 值 | +|---|---| +| 版本 / 构建 | **1.0.0 / 29**(= 锁定候选,报告217) | +| 源码提交 | `11abece3a348213429441950af667235486a482d` | +| 候选 ZIP | `aca4ed5c8db8ed5320055a3f6bfd2f089efee7b8cd45e81ff49e779b5fc34f6e` | +| 源码包 | `40d88f366f725eeb5ccd344dd3cc29ac544a8b5a005e2d1280c92ef12e27d696` | +| 用户原始日志 | `~/Desktop/it15-run-repro.log`(23 行,2026-10-28 03:30) | +| 复跑日志(修好脚本后) | `platforms/macos/build/acceptance-air-1.0.0-b29/ev-90-it15-repro-fixed.log` | + +## 2. 结果 + +| 环节 | 结果 | 证据 | +|---|---|---| +| 源码包 `.sha256` 校验 | PASS | 原始日志 `Lampyris-1.0.0-source.tar.gz: OK` | +| 候选包内文件校验(`SHA256SUMS`) | PASS | 原始日志「候选文件校验通过」 | +| 离线重建(`CARGO_NET_OFFLINE=true`,`cargo build --locked --offline`) | PASS | 两架构 staticlib + Swift 宿主均构建成功 | +| 逐架构网络导入检查 | PASS | 日志「✅ 宿主逐架构网络导入检查通过」 | +| **默认数据逐字节一致** | **4/4 PASS** | 复跑日志「✅ 默认数据 4/4 逐字节一致」(`wubi86-core.tsv`、`pinyin-chars.tsv`、`pinyin-core.tsv`、`variant-s2t.tsv`) | +| 可执行文件逐字节一致 | **不一致(预期)** | 发布构建 SDK **26.5**,本机 SDK **27.0**;按 ADR-033/报告208 §4,跨 SDK/链接器不承诺载荷字节一致 | +| 运行时零外联 | PASS | `lsof -i -a -p <宿主>` 三次采样均 0 条连接(`ev-91-egress.log`) | + +去签名可执行文件摘要(仅供记录,不代表不一致原因之外的结论): + +| 产物 | SHA-256 | +|---|---| +| 候选(expected) | `df245ac7eb5965b06edd99ed867b950ff34a2f3b8577ff9b42bed90a6f818fdf` | +| 独立重建(rebuilt) | `5156bf5bd3824a035cabfbdd0e44c4ba897d044563ea81d562068358bc61cd24` | + +## 3. 过程中发现并修掉的工具缺陷 + +用户首次运行 `platforms/macos/tests/run-repro.sh` 时日志止于: + +``` +.../expected .../rebuilt differ: char 22, line 1 +``` + +原因:脚本在 `set -euo pipefail` 下**先** `cmp` 可执行文件,跨 SDK 必然失败 → 直接中断, +**后面 3 张数据表的比对从未执行**(报告 208 的预演也踩过同一处,只是当时没意识到)。 + +修法(同一批提交): + +1. 默认数据先比,且补齐为 **4 张表**(原来只比 3 张,漏了 `variant-s2t.tsv`);不一致直接失败。 +2. 可执行文件后比,不一致时打印双方 SDK 并说明 ADR-033 边界,默认**不判失败**;需要严格时用 `STRICT_BYTES=1`。 +3. 两个产物摘要都打印,便于写进报告。 + +## 4. 结论 + +- **IT-15 通过(按 ADR-033 的边界口径)**:身份链 + 离线重建 + 逐架构网络导入 + **默认数据 4/4 逐字节一致** + 运行时零外联。 +- **不宣称**「可执行文件逐字节重现」:跨 SDK 27.0 vs 26.5 不具备可比性;同工具链下复跑才适用(ADR-033)。 +- 遗留提示:IT-23 五工作日试用期间,把「键入期零外联」一并作为日常证据(本次只采样了空闲态)。 diff --git a/docs/reviews/219-caret-follows-focus.md b/docs/reviews/219-caret-follows-focus.md new file mode 100644 index 00000000..ecacc33e --- /dev/null +++ b/docs/reviews/219-caret-follows-focus.md @@ -0,0 +1,42 @@ +# 219 候选窗跟着鼠标而不是输入焦点(build 29 → 30) + +> 用户复验 1.0.0/build 29 时报告:**输入框已经获得焦点,但鼠标离得很远时,候选窗出现在鼠标位置**。 +> 这是报告 217 §3 定向复验没有覆盖到的场景(那六张截图鼠标都在输入点附近)。 + +## 1. 根因 + +`InputController.caretRect()` 调 `StateHUDCaretResolver.resolve(...)` 时传的是: + +```swift +startIndex: start, hasText: length > 0, allowsEmptyCaret: false +``` + +而解析器的第一行是 `guard hasText || allowsEmptyCaret else { return nil }`。 +于是只要 **`length() == 0`**(空输入框,或客户端的 `length()` 不把预编辑文本算进去), +解析器立刻返回 `nil`,`caretRect` 直接落到最后一档回退:**鼠标位置**(1×1 点)。 + +HUD 那条路径一直传的是 `allowsEmptyCaret: true`,所以同样的场景下 HUD 位置是对的、只有候选窗跑偏。 +这个差异是 ADR-301 重构前就存在的(旧代码只调一次 `attributes` 也有同类问题),重构时把 +`allowsEmptyCaret: false` 原样带了过来。 + +## 2. 修法 + +1. **允许空插入点**:候选窗与 HUD 一样传 `allowsEmptyCaret: true`——空输入框的插入点仍然有效。 +2. **回退链收敛**:新增 `CaretPlacement.fallback(current:previous:mouse:)`,顺序为 + **本轮解析结果 → 上一次成功的位置 → 鼠标**。鼠标几乎从不是用户想要的位置,只在「本轮失败且 + 没有任何历史位置」时使用;每条回退都写 `LAMPYRIS_HUD_DIAG` 原因(`fallback=previous/mouse`)。 +3. `lastCaretRect` 只在解析成功时更新,避免把回退值当成历史。 + +## 3. 验证 + +| 层 | 内容 | +|---|---| +| 单元 | `caret-placement.swift` 新增回退链三例:本轮结果优先 / 无本轮时用上次位置 / 两者都没有才用鼠标 | +| 解析器 | 既有断言已覆盖「空文档 + `allowsEmptyCaret: true` 取到行框」,本次让候选窗走同一条 | +| 回归 | `make verify`、`make macos-offline-tests`(56 文件 / 303 例)全绿 | +| 实机 | 待 build 30 装机复验:**聚焦输入框、把鼠标移到屏幕另一端、再打字**,候选窗应贴着插入点 | + +## 4. 影响 + +- 版本仍 1.0.0,构建号 29 → **30**;锁定候选随之更新(报告 217 §8 的身份表在复验后补记)。 +- IT-23 尚未开始记第 1 天,因此**不影响**试用基线;两台设备都升级到 30 后再开始计时。 diff --git a/docs/tasks/T-ITER-22.md b/docs/tasks/T-ITER-22.md index 383bdae6..f3fbf532 100644 --- a/docs/tasks/T-ITER-22.md +++ b/docs/tasks/T-ITER-22.md @@ -155,7 +155,7 @@ tools/macos-probe/walkthrough-evidence.sh --label it22-end # 收尾 | # | 步骤 | 预期 | 结果 | 现象 / 证据 | |---|---|---|---|---| -| 11.1 | 正常打一段 50 字左右的中英混排 | 无卡顿、无候选闪烁 | FAIL| 无卡顿、无候选闪烁, 但是输入较长文本时,候选框无法准确跟随焦点。| +| 11.1 | 正常打一段 50 字左右的中英混排 | 无卡顿、无候选闪烁 | PASS| build29 六张截图:下方/镜像/底部翻上均正常(报告217 §3)| | 11.2 | 放置 10 秒后再输入 | 首键不卡 | PASS| | | 11.3 | 活动监视器看宿主内存 | 长时间使用后不明显增长(自动化已在报告 199/207 覆盖稳态) | PASS | | diff --git a/packaging/build-number b/packaging/build-number index f04c001f..64bb6b74 100644 --- a/packaging/build-number +++ b/packaging/build-number @@ -1 +1 @@ -29 +30 diff --git a/platforms/macos/Sources/CaretPlacement.swift b/platforms/macos/Sources/CaretPlacement.swift index 83cb5c19..7ca66b48 100644 --- a/platforms/macos/Sources/CaretPlacement.swift +++ b/platforms/macos/Sources/CaretPlacement.swift @@ -100,4 +100,21 @@ enum CaretPlacement { let spaceAbove = visible.maxY - line.maxY return spaceBelow >= spaceAbove ? below : above } + + /// 取不到光标时的回退顺序(报告219):上一次成功的位置 → 鼠标。 + /// + /// 鼠标几乎从来不是用户想要的位置:输入框有焦点、鼠标在别处时,跟着鼠标会把候选窗 + /// 放到屏幕上完全无关的角落。因此只在「本轮解析失败且没有任何历史位置」时才用鼠标。 + enum Fallback: String, Equatable { + case resolved + case previous + case mouse + } + + static func fallback(current: NSRect?, previous: NSRect?, + mouse: NSPoint) -> (rect: NSRect, source: Fallback) { + if let current { return (current, .resolved) } + if let previous { return (previous, .previous) } + return (NSRect(x: mouse.x, y: mouse.y, width: 1, height: 1), .mouse) + } } diff --git a/platforms/macos/Sources/InputController.swift b/platforms/macos/Sources/InputController.swift index e78d632c..9eee0b54 100644 --- a/platforms/macos/Sources/InputController.swift +++ b/platforms/macos/Sources/InputController.swift @@ -719,6 +719,9 @@ final class LampyrisInputController: IMKInputController { return min(max(0, location), length - 1) } + /// 上一次成功解析到的插入点矩形:解析失败时优先复用它,而不是跳到鼠标(报告219)。 + private var lastCaretRect: NSRect? + /// 光标矩形(候选窗/HUD 定位)。 /// /// 走与 HUD 同一套两趟解析(报告094/095):`attributes` 在文末与长文本里常返回零矩形, @@ -738,18 +741,19 @@ final class LampyrisInputController: IMKInputController { actualRange: nil) } let screens = NSScreen.screens.map(\.frame) + // `allowsEmptyCaret: true`:空输入框(或 length 不含预编辑文本的客户端)里 + // 插入点仍然有效,退成 false 会让解析器直接返回 nil、候选窗跟着鼠标跑(报告219)。 let resolved = StateHUDCaretResolver.resolve( - startIndex: start, hasText: length > 0, allowsEmptyCaret: false, + startIndex: start, hasText: length > 0, allowsEmptyCaret: true, lineRect: lineRect, firstRect: firstRect) - if let resolved, StateHUDLayout.isOnScreen(caret: resolved, screens: screens) { - return resolved - } - let mouse = NSEvent.mouseLocation - if Self.hudDiagEnabled { - let reason = resolved == nil ? "两趟解析都失败" : "解析结果不在任何屏幕内" - Self.hudDiag("caret fallback to mouse: \(reason) selection=\(selection.location)/" - + "\(length) rect=\(String(describing: resolved))") - } - return NSRect(x: mouse.x, y: mouse.y, width: 1, height: 1) + let valid = resolved.flatMap { StateHUDLayout.isOnScreen(caret: $0, screens: screens) ? $0 : nil } + let choice = CaretPlacement.fallback(current: valid, previous: lastCaretRect, + mouse: NSEvent.mouseLocation) + if choice.source == .resolved { lastCaretRect = choice.rect } + if Self.hudDiagEnabled, choice.source != .resolved { + Self.hudDiag("caret fallback=\(choice.source.rawValue) selection=\(selection.location)/" + + "\(length) resolved=\(String(describing: resolved))") + } + return choice.rect } } diff --git a/platforms/macos/tests/caret-placement.swift b/platforms/macos/tests/caret-placement.swift index 43d6b591..43bb45d8 100644 --- a/platforms/macos/tests/caret-placement.swift +++ b/platforms/macos/tests/caret-placement.swift @@ -81,4 +81,17 @@ let onSecondary = CaretPlacement.frame(request(NSRect(x: 1500, y: 400, width: 1, require(onSecondary.minX >= secondary.minX && onSecondary.maxX <= secondary.maxX, "uses the visible frame of the target screen: \(onSecondary)") -print("PASS caret-placement: below-first, flip, glyph normalization, right-edge mirror, HUD centered-above, clamping, secondary screen") +// 9) 回退链(报告219):本轮解析 → 上次位置 → 鼠标,鼠标只在最后一档。 +let caretLine = NSRect(x: 100, y: 420, width: 2, height: 16) +let previousLine = NSRect(x: 300, y: 300, width: 2, height: 16) +let mouse = NSPoint(x: 900, y: 90) +let first = CaretPlacement.fallback(current: caretLine, previous: previousLine, mouse: mouse) +require(first.source == .resolved && first.rect == caretLine, "resolved caret wins: \(first)") +let second = CaretPlacement.fallback(current: nil, previous: previousLine, mouse: mouse) +require(second.source == .previous && second.rect == previousLine, + "falls back to the last known caret, not the mouse: \(second)") +let third = CaretPlacement.fallback(current: nil, previous: nil, mouse: mouse) +require(third.source == .mouse && third.rect.minX == mouse.x && third.rect.minY == mouse.y, + "uses the mouse only when nothing else is known: \(third)") + +print("PASS caret-placement: below-first, flip, glyph normalization, right-edge mirror, HUD centered-above, clamping, secondary screen, fallback order") diff --git a/platforms/macos/tests/run-repro.sh b/platforms/macos/tests/run-repro.sh index 2190ce62..7b7fde12 100755 --- a/platforms/macos/tests/run-repro.sh +++ b/platforms/macos/tests/run-repro.sh @@ -39,13 +39,29 @@ PY (cd "${SOURCE}" && CARGO_NET_OFFLINE=true \ CLANG_MODULE_CACHE_PATH="${WORK}/module-cache" ./platforms/macos/build.sh --strict --universal) BUILT="${SOURCE}/platforms/macos/build/Lampyris.app" +# 1) 默认数据先比:这是「源码对应」的硬证据,不受工具链影响(ADR-033)。 +for name in wubi86-core.tsv pinyin-chars.tsv pinyin-core.tsv variant-s2t.tsv; do + cmp "${PACKAGE}/Lampyris.app/Contents/Resources/${name}" "${BUILT}/Contents/Resources/${name}" \ + || { echo "数据表不一致:${name}" >&2; exit 1; } +done +echo "✅ 默认数据 4/4 逐字节一致" + +# 2) 可执行文件:只有同工具链/SDK 才能逐字节一致(ADR-033、报告208 §4)。 cp "${PACKAGE}/Lampyris.app/Contents/MacOS/Lampyris" "${WORK}/expected" cp "${BUILT}/Contents/MacOS/Lampyris" "${WORK}/rebuilt" codesign --remove-signature "${WORK}/expected" codesign --remove-signature "${WORK}/rebuilt" -cmp "${WORK}/expected" "${WORK}/rebuilt" -for name in wubi86-core.tsv pinyin-chars.tsv pinyin-core.tsv; do - cmp "${PACKAGE}/Lampyris.app/Contents/Resources/${name}" "${BUILT}/Contents/Resources/${name}" -done -shasum -a 256 "${WORK}/rebuilt" -echo "离线源码重建成功;去签名可执行文件与默认数据逐字节一致。" +shasum -a 256 "${WORK}/expected" "${WORK}/rebuilt" +built_sdk="$(otool -l "${WORK}/expected" | awk '/ sdk /{print $2; exit}')" +local_sdk="$(xcrun --show-sdk-version 2>/dev/null || echo unknown)" +if cmp -s "${WORK}/expected" "${WORK}/rebuilt"; then + echo "✅ 去签名可执行文件逐字节一致(SDK ${built_sdk} = 本机 ${local_sdk})" +else + echo "ℹ️ 去签名可执行文件不同:发布构建 SDK ${built_sdk},本机 SDK ${local_sdk}。" + echo " 按 ADR-033,跨 SDK/链接器不承诺载荷逐字节一致;默认数据一致即视为源码对应。" + if [ "${STRICT_BYTES:-0}" = "1" ]; then + echo "STRICT_BYTES=1:要求逐字节一致,判定失败。" >&2 + exit 1 + fi +fi +echo "离线源码重建成功。"