Commit Graph
16 Commits
Author SHA1 Message Date
AskaEth 75aa5ee82c fix(cli): 启动用的 Java 钉死 D4 校验过的那个 + M214 复审 nit1/nit2/nit3
- override_path 五来源统一钉住 D4 选中的运行时:以前只有 managed 钉路径,
  JAVA_HOME/PATH/system 三种会被 snapshot::prepare 的 selection() 按环境再挑一遍,
  D4 判过的 Java 根本不是真启动的那个。现在 `plan_java` 只做「选中 → 钉路径」,
  人读行写出「版本(来源 · 具体可执行文件)」,--json 的 java 块并排给出
  binary(校验过的)/ launch_binary(装配落地的)/ source,两者不一致时当场告警。
  实例设置/全局设置里的 javaPath 也并入「谁指定了 Java」,不会被自动挑的结果顶掉。
- nit3:D4 的 Java 不符拦截挪到 identity::resolve 之前(微软轨解析身份会联网刷新
  令牌、还会回写凭据),拦下的启动不再先触发一次刷新;不符提示补上「挑中的是哪个
  java」,文案只加不减。
- nit1:error.rs / launcher.rs 的人读串里残留的 ** 标记去掉,并补两条回归断言。
- nit2:java_launch.rs 里「只提示不阻断」等旧注释对齐 D4 现状。
- launch.rs(399 行零余量):D20 的隔离档位派生与切换检测整体搬到新文件
  launch_isolation.rs,纯挪函数体(逐行 diff 只多 6 处 pub(super)),
  用 #[path] 挂载以免改 commands/mod.rs。

用例:钉路径纯函数单测 3 条 + 端到端 2 条(JAVA_HOME 置空证明非 managed 来源也钉住;
数据根放两个账户证明 D4 拦在身份解析之前)+ 人读串无 ** 的 2 条回归。
两条变异(①退回只钉 Managed ②退回身份解析之后)各自让新用例变红,已实测。
2026-11-09 21:30:55 +08:00
AskaEth aa61565fe7 fix(coexist): r2 返工——帮助写真实例路径 + 加载器安装也说出「原版清单被替换」+ --json 给出绑定成败(M216)
r2 复审两条 P2 必修 + 一条「能一行接上就接」的 P3。

P2-1(`ull help pack` 的落点少一层 `.ULL/data`)
- 「数据根」在这份帮助自己的定义里是「含 `.ULL/` 与 `.minecraft/` 的父目录」(`--data-dir` 那行),
  所以真路径是 `<数据根>/.ULL/data/instances/<实例目录名>`。r1 写成 `<数据根>/instances/<实例名>`,
  照读会去一个不存在的位置找包内容。
- help.rs 与 model.rs 的字段文档改成真路径,并点明目录名是归一化后的结果(`Better-MC`)。
- 用例改成 `help_describes_the_new_default_target_with_the_real_path`:先断言真路径整串在、
  再断言 `<数据根>/instances/` **不在**(这一条就是抓「少写 .ULL/data」那种错法)。

P2-2(装加载器时原版那一步的「替换了清单」没人说)
- `loader.rs`:`plan.vanilla_install_needed` 那条路以前只拿 `vanilla_report` 判 `is_complete()`
  就丢掉;现在把它留在 `vanilla_report` 里传给 `report_loader::build`。
- `report_loader.rs`:`build` / `human_lines` 各多一个 `vanilla: Option<&InstallReport>`;
  新增 `vanilla_lines`(人读:`告警(原版): …` + `原版版本清单已替换(旧的那份备份在 …)`)与
  `vanilla_json`(`--json` 的 `vanilla` 段:warnings / version_json / manifest{kind,backup})。
- 用例 `a_replaced_vanilla_manifest_is_said_out_loud_in_the_loader_output`:用真
  `LoaderInstallPlan`(最小 Fabric,零网络)调 `human_lines`,断言告警与备份路径都在;
  未先装原版时不多说话;`vanilla_json` 的 `kind`/`backup` 结构正确、`None` 时没有该段。

P3(复审「能一行接上就接」):`--json` 给出导入绑定的**结构化成败**
- core `pack_dir::bind_created_instance` 返回 `Option<Result<String, String>>`(None=不需要绑 /
  Ok=已绑 / Err=绑失败),一句话就能分出成败,不必猜文案。
- CLI:`outcome` 多写一个 `"instance_bound": true|false`(人读行照旧给原因);
  GUI:`import.rs` 按 Ok/Err 分别记 `instance_bound` / `instance_binding_failed` 注记。
- 端到端用例里补断言 `data.instance_bound == true`。
2026-11-09 20:43:46 +08:00
AskaEth 4e1323212b fix(coexist): 复审 r1 返工——帮助文案跟上新落点 + 「替换了清单」进人读通道 + 导入建实例绑到内容目录(M216 P2/P3/§5)
P2-1 ② 改了缺省落点,CLI 自己的文档还在写旧行为
- `cli/help.rs`「目标目录缺省 = 数据根的共享游戏目录(.minecraft/)」→ 新实例目录
  `<数据根>/instances/<实例名>`(并登记实例);
- `cli/model.rs` 的 `PackImportArgs::target` 字段文档同步。
- 用例 `help_describes_the_new_default_target`(断言帮助含新落点、不含旧落点)。

P2-2 ③ 的「显式说明」进不了任何人读通道
- CLI:`human_lines` 原只遍历 `plan.warnings`,而运行期告警(含「已替换版本清单 …旧的那份
  备份在 …」)在 `report.warnings` 里 ⇒ `ull install` 人读输出从没说过这句话。现在有 report 就
  报 report.warnings(它开头就是 plan 告警的副本),`manifest=Replaced` 再单独打一行备份路径。
- GUI:`install.rs` 新增 `Job::journal_report`(把 `report.warnings` 与 `manifest` 的备份路径写进
  安装日志),`install/routes.rs` 两处 `ensure_complete` 之前各加一行调用(tower 批的两行)。
  此前 `report.manifest` 全仓零消费者。
- 用例 `a_replaced_manifest_is_said_out_loud_in_the_human_output`(走真 `InstallPlan` 排版)。

P3-1 缺省目录被占的 hint 删掉不成立的 `--overwrite` 那半句(它绕过不了这条预检)。
P3-2 GUI 落点改成委托 core 的 `pack_dir::instance_dir`(此前 presenter 自己拼 `data()/instances/…`);
`convert::dir_name` 缩成 `#[cfg(test)]` 的委托入口(生产侧已无调用点,留着继续钉归一化判据)。
P3-3 补 `the_default_target_is_a_new_instance_dir_and_the_instance_is_bound_to_it`:不带 `--target`
跑通整条 CLI,断言落点、内容、登记、以及实例设置里的 `isolation`/`gameDir`。

§5 导入建出来的实例原先「导入成功但内容永远不会被启动用到」
- 新增 `pack_dir::bind_instance_dir`(`InstanceConfig.isolation = Custom` + `game_dir = 导入目录`)
  与 `pack_dir::bind_created_instance`(判据+文案,CLI 与 GUI 同一份);
- CLI 在缺省建实例后、GUI 在 `import.rs::run` 里各调一次;只在「我们新建了实例目录且档案是新建」
  时绑(显式 `--target` / `--no-register` / 复用已有档案都不动);
- 运行目录 = 导入目录(saves/mods/logs/config),**versions/libraries/assets 仍按 D20 恒共享**;
- 未改 `derive_game_dir`。
- 用例:正向 `binding_an_imported_instance_points_it_at_the_import_dir`、反向
  `a_freshly_registered_instance_still_inherits_the_global_mode`。
2026-11-09 19:46:41 +08:00
AskaEth fbb4a79955 fix(agent): MCP 读日志/诊断崩溃改走 session_index::event_file(D2 第三处读取点)
M212(M211 复审观察 7 确认的真 bug):

- `agent/ports/events.rs::event_path` 仍在 `root.join("game").join(&entry.log_path)`,
  而 `log_path` 已经是**相对会话根**的 `game/<id>.jsonl` ⇒ 对每个真实会话解析成
  `game/game/<id>.jsonl`。后果是 `ull agent` / MCP 的 read_game_log 与 diagnose_crash
  两个工具对真实会话全报「读会话事件流失败」——内置 AI Agent 的看家本领是坏的。
  现在解析只走 M211 建立的唯一真相源 `session_index::event_file`(新旧两个根都走它)。
- 夹具:`ports/session.rs`、`ports/diagnose.rs` 的索引夹具此前写裸文件名
  `20261029T124501-9f3c2a.jsonl`,与生产差一层 `game/`,所以这个 bug 在测试里永远看不见。
  改成 `session_index::log_path_for(id)` + `session_index::event_file` 落盘。
- 用例:`ports/events.rs` 新增三条(解析结果 = 写入侧落盘的那一个 / 文件缺失时指向的仍是
  生产形状 / 旧根回落走同一套解析);`cli/tests/agent_mcp_stdio_test.rs` 新增一条真机
  MCP 用例(起 `ull agent serve --stdio`,对一份生产形状的会话调 list_game_sessions →
  read_game_log → diagnose_crash)。旧实现下两条路径都红,红的是
  `...\logs\game\game\20261030T101500-abcdef.jsonl`。
2026-11-09 14:28:42 +08:00
AskaEth 4b30bf07f0 fix(launch): 会话事件流路径只留一个真相源 + crash_found 只认真实证据 + ull launch 也写 launcher.log
M211(来自 M208 实机 e2e 的 D2/D3/D5):

- D2「game/ 双写」:log_path 的契约原来有两种说法(字段文档写「相对 game/」,写入侧一直
  写「相对会话根」的 game/<sid>.jsonl),于是两处读取点各自又多 join 一层 game/,
  ull logs / ull diag / GUI 日志页对每个真实会话都报「找不到事件流」。现在解析只走 core 的
  session_index::event_file(CLI 与 presenter 共用),写入侧只走
  session_index::log_path_for。方向选的是「读取侧不再 join」:存量索引里的 log_path 从第一版
  起就是 game/<id>.jsonl,改读取侧能把用户盘上已有的旧会话一起救回来(零迁移、零改写)。
- D2 夹具:cli/tests/support 与 presenter/host/logs_tests 的索引夹具改成生产形状
  (game/<id>.jsonl)。夹具此前自己说了第三种形状,所以这个 bug 在测试里永远看不见——
  修夹具比修路径更重要。
- D3「crash_found 恒为 true」:logs/latest.log 每局都写,不再被当现场文件(只在已经找到
  权威现场时作为补充证据)。崩溃结论改由 crash_scan::has_crash_evidence 判定(权威现场文件
  / 非 0 退出 + 错误行),索引旗标由 SessionWriter::mark_crash 写入;crash_found 事件恢复
  成纯事实记录。
- D5「ull launch 从不写 launcher.log」:现在与 GUI 共用
  launcher_log::record_start / record_end(字段名与顺序只在一个地方定义),写的是同一份
  <data>/logs/launcher.log。

测试:core 新增 session_path_tests(写入形状 = 读取解析结果 = 真落点的往返)、crash_scan 的
干净退出用例、launcher_log 的行格式锁;CLI 新增 a_launch_attempt_writes_the_launcher_log。
2026-11-09 14:12:23 +08:00
AskaEth 918d826645 fix(m190): r1 复审——0x01 修成 \1 + YAML 门禁、被调方声明 secret、拆两个超限测试文件、验签换 ed25519-dalek
四条 P1 + 四条 P2 一并处置(不变的是判据,变的是「谁在承重」)。

P1-1 `.github/workflows/release-build.yml` 的 sed 替换串是裸 0x01(本意 `\1`):GitHub 解析该文件会
失败 ⇒ 整个 run 起不来;即便容忍,版本号也会被求值成单字节,PE 判据报「与 Cargo.toml 不同源?」把
排障引向错方向。修成字面 `\1`,并新增 `packaging/check_workflows_yaml.py`(真 YAML 解析器 parse
全部 workflow + 裸 C0 控制字符字节判据;带 --self-test 注入 0x01/坏结构断言必红),接进
`release-guard.yml` 两步。文本扫描型守卫看不见这类字节,这道门禁专门挡它。

P1-2 `release-publish.yml` 没在 `on.workflow_call.secrets` 里声明 `ULL_MANIFEST_SIGNING_KEY`,调用方
却传了:可复用工作流只拿得到自己声明过的 secret ⇒ 用户配好钥也会在签名步红,而错误文案反过来指控
Secret 没配。补声明(required: false,fail-closed 由步内 ::error:: + exit 1 承担,同
ULL_MSA_CLIENT_ID 的模式)。

P1-3 两个测试支撑文件越过 400 行守卫:签名夹具拆到 `tests/support_sign.rs`(support.rs 458→392),
CLI e2e 的签名部分拆到 `support/update_e2e_sign.rs`(update_e2e.rs 406→348)。纯搬家。

P2-2 「唯一收口点」改成结构事实:从 `UpdateSource` 删掉 `fetch_manifest` 默认实现,调用方只能拿
`verified_manifest(&source, channel)`(源只交原文,想绕过验签就得自己重写解析与渠道判据)。
P2-3 文档写的第 ④ 条命令补进流水线:`sign_manifest.py --verify-pinned` 用 trust.rs 里钉住的公钥
回验刚落盘的 .sig(不碰私钥),发布侧因此验证的是「客户端的判据」而不是「签名脚本的自我声明」。
P2-4 随自写实现一起删除(`Fe` 的错误 PartialEq 不再存在)。
P2-5 app-iced/build.rs 注释里那个 windows_subsystem 属性本分支不存在(M196 才有),改为说明
「本文件只写版本资源,子系统由 rustc 属性决定,check_pe_version.py 会把 subsystem 打出来」。
P2-6 坏清单不再带 error.path 是有意降级(来源 id 在 message 里),保持。

裁定项:自写 Ed25519 换成 `ed25519-dalek::verify_strict`(信任锚不许自写密码学;此刻信任表还是空表,
零兼容负担)。删除 `update/ed25519/{field,group,mod}.rs`(含只服务它们的单测),新 `update/ed25519.rs`
只剩一个薄封装;拒绝类用例(可锻 S+L / 小阶公钥 / 非规范 R / 空签名 / 未知 key)**一条没删**,
改成钉 dalek 的 strict 语义——实测 dalek 对全部五类的结论与旧实现一致(非规范 R 的拒绝理由不同:
dalek 走「小阶点」而非「编码非规范」,用例注释里写明)。签名侧留在 `packaging/ed25519_ref.py`
(纯 Python、零 pip 依赖),`--self-test` 用 RFC 8032 同一组向量钉两边。
2026-11-09 02:14:32 +08:00
AskaEth b7d9b99e12 feat(m190): 产物署名(PE VERSIONINFO)+ 清单 Ed25519 多签名与更新前验签
两件事:让「这是谁发的」在产物与清单上都留下可验证的痕迹。

① PE 版本资源(两个 bin):`packaging/pe_version.rs` 是唯一口径(CompanyName
   「Unlucky Launcher Project」/ ProductName、FileDescription「Unlucky Launcher」/
   LegalCopyright「© 2026 … · Apache-2.0」/ 四段式版本号与 Cargo.toml 同源),被
   `crates/{cli,app-iced}/build.rs` 各 include 一次;非 windows 目标整段不执行。
   判据是读产物字节的 `packaging/check_pe_version.py`(CI 的 Windows 槽位)。

② 清单签名:`manifest.json` 旁边多一份 `manifest.json.sig`(分离签名,签的是清单**原始
   字节**),信封是 `{format, signatures:[{alg,key_id,sig,manifest_sha256}]}` —— 多签名数组,
   任意一条命中钉住的钥即放行(换钥窗口)。信任锚是 `crates/core/src/update/trust.rs`
   里的编译期公钥表(正式表为空 = fail-closed,测试钥只在 cfg(test)/update-test-source 里)。
   管线顺序改成「先验签、再解析」:`UpdateSource` 只交原文 + `.sig`,`verified_manifest`
   是唯一收口点,`Job::apply` 因此在取包**之前**就拒。新增五类错误分类
   (signature_missing / malformed / unknown_key / keyring_empty / invalid ),全部不可重试,
   CLI 的 hint 单独说人话。

③ Ed25519 验签自带实现(`update/ed25519/*`,零新依赖):scope 不含 core 的 Cargo.toml,
   用 RFC 8032 §7.1 向量 + 可锻/小阶/非规范编码的拒绝用例钉住;签名侧是
   `packaging/sign_manifest.py`(纯 Python 参考实现,零 pip 依赖,`--self-test` 同向量)。

④ 守门:`release-publish.yml` 加签名步(`set +x`,只回显公钥;没 Secret / 钥没钉 / 签不上
   一律红 ⇒ 不出 Release);`release-build.yml` 加 PE 资源读回;`release-guard.yml` 跑两个
   脚本的 `--self-test`。密钥纪律与轮换(旧钥签新钥)写在 `packaging/docs/signing.md`。

Cargo.lock:新增 winresource(build-dependency)与 toml 系;toml_datetime 1.1.2→1.1.3 是
toml 1.2.1 的硬要求(`cargo update --precise 1.1.2` 已实测被拒)。
2026-11-09 01:59:05 +08:00
AskaEth ef77c67d3c fix(m171): r2 —— 跨卷守卫不许静默跳过,崩溃现场不删唯一旧副本
按首轮复审 p2-1items 的三条就地修。

P2-1(唯一阻塞项)跨卷守卫在只有一块卷的机器上静默变 ok:探不到第二块卷时
是 `eprintln! + return`,libtest 默认捕获 stdout/stderr,于是默认输出只有
`... ok`,跳过的记录只有 `--nocapture` 才看得见(复审 M3 变异实测)。
按复审给的方案 (b) 把裁决收成一条常跑用例(本仓口径同
`options_shot/feed.rs::not_fed`:跳过必须是响的):

  探到第二块卷            → 绿(真跨卷用例真跑)
  探不到 + ULL_XDEV_TEST_WAIVE=1 → 绿(显式、记录在案的降级,打印一行)
  探不到 + 没豁免         → 红(默认判红,失败正文给出三条出路)
  另有 ULL_XDEV_TEST_REQUIRE=1:声明「必须有」,与豁免同给时以它为准(不许自相矛盾)

裁决是纯函数 `xdev_guard(found, waiver, require)`:逐条用例走同一个入口
(`cross_volume_scene`),没人能靠 `return` 静静跳过;core 与 CLI e2e 两处政策一致
(源头在 `install_xdev_cases`,CLI 侧 `support::cross_volume_waived` 照抄并注明)。

CI 选择:**windows 作业显式声明豁免**(不挂 VHD)。理由:VHD 要多一步 diskpart、
要管盘符、易碎;豁免是一行 env、写在 ci.yml 里可见,且只在探测失败时被咨询——
将来 runner 真有第二块盘,真跨卷用例照样真跑。ubuntu 侧不豁免:`/tmp` 与
`/dev/shm` 是不同设备(本机 docker debian:bookworm-slim 实测设备号 119 / 158),
真跨卷守卫在 Linux 上应当是真跑的。ci.yml 不在本 mission 的改动范围,已用
TowerFinding 登记那一行补丁。

P3-1 `update_e2e_test.rs` 里 `install::prune_stale_backups` → `prune_stale_artifacts`。

P3-2 `prune_stale_artifacts(None, dest)` 会删掉崩溃现场唯一幸存的旧 exe:上一次装到
「dest → .ullold-*」之后被杀时,dest 空着、旧字节只剩备份那一份;下次成功安装时
`symlink_metadata(dest)` 失败 ⇒ keep=None ⇒ 连它一起删,用户失去手工恢复的可能
(修前不会删)。改成 keep=None 时**只清 `.ullnew-*`、一个 `.ullold-*` 都不动**,
模块文档补上这条例外与其来由。

验证(本机,CARGO_TARGET_DIR=C:/Temp/ull-target/wt-171):
* `cargo test -p unlucky-core --lib update::tests` → 90 passed / 0 failed
* `cargo test -p unlucky-core` → 623 passed(+ 各集成目标全绿),exit 0
* `cargo clippy -p unlucky-core --all-targets` / `-p unlucky-cli --all-targets
  --features update-test-source` → exit 0
* `cargo test -p unlucky-cli --features update-test-source --test update_e2e_test
  --test update_e2e_tamper_test` → 3 + 4 passed,含 `installs_across_volumes_from_the_cli`
* fmt clean
变异实测(都验完再还原):
  M3 让 probe 恒 None ⇒ 默认 `cargo test` 输出 2 failed(无须 --nocapture),
     正文「没有第二块可写卷,也没有显式豁免:跨卷守卫在这台机器上是空跑。」
  M3 + WAIVE=1 ⇒ 3 passed;M3 + REQUIRE=1 ⇒ 2 failed
  去掉 keep.is_some() 那一半 ⇒ `a_crashed_install_keeps_the_only_surviving_old_copy` 红
2026-11-08 13:34:29 +08:00
AskaEth 36ff193609 fix(m171): 跨卷替换改成「目标同目录中转名 + 同卷原子改名」
已发布版本上的真 bug:暂存包在 <数据根>/cache/update/staging,目标 exe 在安装目录,
两者不同卷(便携版放 U 盘 / 数据根在 D 盘而程序在 C 盘 / 部分 macOS 配置)时
`rename(staged → dest)` 直接报 EXDEV / ERROR_NOT_SAME_DEVICE(io::ErrorKind::CrossesDevices),
更新必然失败,而 is_transient 不含 CrossesDevices,也没有任何降级路径。

复现(本机真跨卷:C:/Temp NVMe ↔ D:/Temp 机械盘,原实现):
  InstallFailure { error: UpdateError { kind: Io, message: "系统无法将文件移到不同的磁盘驱动器。
  (os error 17)", path: Some("D:/Temp\ull-xdev-…\app\ull.exe") }, rolled_back: true }
失败点就是 `rename(暂存包 → dest)`;唯一调用方是 core::update::job::Job::apply(CLI/GUI 共用)。

修法(不把原子替换退化成可能留半截的复制):
  0. 新包 → <目标同目录>/.<文件名>.ullnew-<pid>-<seq>  同卷 rename;跨卷才 copy 到该中转名,
     并核对写出字节数(少了直接判失败)
  1. dest → <同目录 .ullold-…>                         旧版本先安全落地
  2. rename(中转名 → dest)                             同卷,原子
  3a. 成功 → InstallReceipt;3b. 失败 → 回滚 + 清中转名
目标路径上的新字节只来自一次原子命名,任何时刻都不是半截文件;同卷路径一个字节都不多搬
(注入用例断言 copies == 0,rename 恰好 3 次)。

备份清理策略一并定死:成功后只留最近一份 .ullold-*(更早的与残留的 .ullnew-* 删掉,
别的程序名的备份不碰)。旧行为是每次 apply 留一份、全仓无人调 remove_backup ⇒ 无限堆积。

顺手把文件动作抽成 replace_io::ReplaceFs(真实现 = std::fs),让「跨卷被拒」「复制到一半
失败」「改名失败」成为确定性输入;判据与顺序仍在 install 里,注入改不了。

守卫(都做过变异实测,均能变红):
  * install_xdev_cases:真跨卷安装成功且目标字节完整(去掉降级 → 红,报 os error 17)
  * install_io_cases:跨卷降级只复制一次 / 半截复制后目标仍是旧字节且无 .ullnew- 残留 /
    短复制被字节数判据拒掉 / 最后一步改名失败回滚并清中转名 / 同卷 copies == 0
  * cli update_e2e_test::installs_across_volumes_from_the_cli:数据根一卷、安装目录另一卷,
    整环从 CLI 入口跑通(去掉降级 → 红,输出 update_apply_failed + os error 17)
2026-11-08 13:06:57 +08:00
worker-updatee2e 68a1f90828 fix(m169): 复审 r2 —— 守门归属改成「列表项结构判定 + 默认拒绝」,堵掉无名步骤/无名 job/flow 步骤的继承放行
r2 复审(reviewer-updatee2e-r2,p2-4items;被抓出一个真洞,mission 翻回 active)逐条落地。

P2-1(本轮核心,真洞)allowlist 的归属原来按「最近一行 `- name:`」切块,于是任何不写成
`- name: <x>` 的步骤都继承上一块的名字;被放行的 `update-e2e` 恰好是 ci.yml 里最后一个
`- name:`,所以追加到文件末尾的一切都落在它的块里。复审三条真实注入(无名 `- run:`、无名步骤的
整个 job、flow 风格步骤)当时全绿。现在:
- `split_items()` 按 YAML **列表项**切块(每个 `- ` 起一项;比当前项深的行归当前项;同级或更浅的
  非列表行**结束**当前项)。项名 = 该项内**同级**的 `name:` 键(键序无关;`run: |` 块里的
  `name: dummy` 不算;`run: echo "name: x"` 也不算)。
- **默认拒绝**:落不到列表项上的行(`jobs:` 之前的孤儿行、job 级 `run:` 这类项外键)带 feature 即红;
  没有 `name:` 的项带 feature 即红。不再有「继承上一块」这回事。
- 三条复审注入 + 本轮新增三条(`jobs:` 之前的孤儿行、`run: |` 里夹带并伪造 name、shell 变量拼接)
  都进了自测向量;在**真仓库文件**上重跑也是全红。

P2-2(同族误杀):
- 键序无关:允许的那一步把 `run:` 写在 `name:` 之前仍绿(旧实现会报到上一块的名字上)。
- `cargo hack check --all-targets` 不再被误判成 `--all`(正则收紧到 `--all(?![-\w])`)。
- Cargo 的 `update-test-source = [ … ]` **跨行声明**不再被误判成「写进了别的 feature 列表」
  (`scan_manifest` 增加 `in_declaration` 状态)。

P2-3 `Fixture::run` 的第二条断言比错对象:原来比夹具字段 `self.dest`,手写
`fx.run(&["apply","--dest","<测试二进制>","--json"])` 两条都过。现在从 `args` 里解析真实 `--dest`
值(含 `--dest=` 形式)再 canonicalize 比较;夹具字段那份作为额外一道保留。

P2-4 `release-guard.yml` 的步骤级注释还停在 r1 的扫描面:改成「含复合 action / packaging 递归 /
Makefile 等 + 归属按列表项判 + 唯一允许 ci.yml 的 update-e2e 一步」。

自测数据搬到 `packaging/tests/guard_vectors.json`(基线 8 个文件 + 26 条向量:21 红 / 6 绿),
脚本本身从 379 行降到 373 行(守门仍 ≤400);README §14 补上归属规则与「抓不住」清单。

验证(本机,全部 exit 0):
- 守门正式扫描绿;`--self-test` 21 条注入全红 + 6 条基线/放行绿;向量文件缺失时 exit 1(不静默)。
- **真仓注入**(在真 ci.yml / release-build.yml / cli/Cargo.toml 上改完即还原):复审三条(无名步骤 /
  无名 job / flow 步骤)全红;本轮新增三条(孤儿行 / `run: |` 夹带 / shell 拼接)全红;
  三条合法写法(键序、`--all-targets`、跨行声明)全绿;还原后基线绿。
- E2E:debug 6 绿、release 6 绿(`running 3 tests` ×2,0.26s / 0.23s,非 0 measured)。
- `cargo clippy -p unlucky-cli --all-targets --features update-test-source -- -D warnings` 干净;
  `cargo fmt --all -- --check` 干净;全仓 400 行扫描无超限(守门 373 / 向量 194 / ci.yml 317)。

约束遵守:未 push;`PUBLISH_SLOTS` 只列 `windows-x64`(未动);release 侧仍无该 feature;跨卷 bug 未碰。
⚠ 仍未验:CI 里 E2E 真跑起来本机验不了(只证明 workflow 写对 + 守门放行,首跑要等推送后看 Actions)。
2026-11-08 11:12:11 +08:00
worker-updatee2e 28bf104041 fix(m169): 复审 r1 五条 p2 —— 守门开一个极窄 allowlist 并把 E2E 真接进 CI
r1 复审(reviewer-updatee2e-r1,p2-5items,均不挡合并)逐条落地:

P2-3(本轮主产出)守门把「E2E 进 CI」这条路堵死了:`scan_enabling_text` 对 workflow 里任何该
feature 字面量判红,于是 `cargo test --release … --features update-test-source --test
update_e2e_test` 这种**合法调用**也红,自己登记的 follow-up 无法实现。现在:
- `ALLOWED_STEP = {workflow: .github/workflows/ci.yml, name: update-e2e,
  must_contain: --test update_e2e_test}`;按 `- name:` 把 workflow 切成步骤块再判,文件名 / 步骤名
  / 命令行**三者任一不符即红**(job 名不参与判据:改 job 名不该解锁新能力)。
- `ci.yml` **新增一个独立 job** `update-e2e`(windows-latest、timeout 45m、release 档;现有 job
  一行未动,纯追加 27 行)。只在 Windows:夹具的「真形状」是裸 `.exe` + `portable-exe`,Linux/macOS
  的真实产物是 `.tar.gz` + `portable-zip`(会被 `ensure_self_replaceable` 直接拒),在 ubuntu 上跑绿
  是假绿。不降 debug:`debug_assertions`/优化/panic 行为都不同。
- self-test 覆盖 allowlist 六向量(允许 / 换文件 / 换步骤名 / 缺 `--test` / 发布工作流 / ci 内另一步)。

P2-2 守门 8 条绕过:注释剥离改**引号感知**(`echo "#"; cargo …` 不再吃掉真命令);扫描面扩到
`.github/actions/**`、`packaging/**` 递归子目录、`Makefile|justfile|Taskfile` 第二构建入口;
`--all-features` 之外补 `cargo hack --feature-powerset|--each-feature|--all`。大小写/下划线变体按
复审结论写进「抓不住」表(cargo feature 名敏感,写错编不过,不算漏检)。

P2-1 GUI 人工验收命令根本跑不起来:`-p unlucky-launcher-iced` 是 bin 名,改成 package 名
`-p unlucky-app-iced`(app-iced / presenter 两处,并写明 `-p` 只认 package)。

P2-5 夹具缺「不给 --dest 就 panic」的结构性保险:`Fixture::run` 加两条断言——`apply` 必带
`--dest`、`--dest` 不许等于本进程 exe 的 canonical 路径。抓得住「以后有人手写 fx.run(&["apply"])」,
抓不住「绕开 Fixture 直接调 unlucky_cli::run」。

P2-4 夹具「真形状」只在 Windows 成立:写进 `tests/support/update_e2e.rs` 文件头(说明 Linux 上跑绿
是假绿、CI 因此只在 Windows),**不改断言**。另按复审 ⑤ 补一行:失败路径三例抓不住回滚/备份
(摘要失败发生在 install_staged 之前),回滚靠正路备份断言 + install_cases。

跨卷替换 bug 按 tower 指令**未动**(另开 mission)。

验证:守门正式扫描 + self-test 绿(14 红 / 3 绿);真仓注入 6 组(基线绿、步骤改名红、缺 --test 红、
搬进 release-build 红、改 --all-features 红、还原绿);E2E debug 6 绿、**release 6 绿**;
`cargo clippy -p unlucky-cli --all-targets --features update-test-source -- -D warnings` 干净;
`cargo fmt --all -- --check` 干净;全仓 400 行扫描无超限(守门脚本 379 行、ci.yml 317 行)。

⚠ 未验:CI 里 E2E 真的跑起来这件事本机验不了——只证明 workflow 写对 + 守门放行,首次真实运行要等
推送后看 Actions。
2026-11-08 11:12:11 +08:00
worker-updatee2e 24826c1a7c feat(m169): 更新通道端到端验收 —— 本地源改成编译期 feature,补一条可一键跑的 E2E + CI 守门
§6 欠账:更新链路的 GUI 端从来没验过,而 M167 发现它根本没法在本机验——`ULL_UPDATE_DIR` 只在
`debug_assertions` 下生效,发布产物却是 release;`apply` 又会替换本进程 exe,于是没人敢跑。

判据换档(tower 定案 B):`cfg!(debug_assertions)` → 编译期 cargo feature `update-test-source`
(core 定义,cli/presenter/app-iced 链式转发,**默认关**)。带 feature 的构建里还要两档环境变量
(`ULL_UPDATE_DIR` + `ULL_UPDATE_TEST_SOURCE=1`),只给路径什么都不会发生。官方发布二进制里那段
分支根本编不进来——README「release builds do not contain that path at all」这句因此继续为真。

- core: `job::default_source` 的本地源分支整段 `#[cfg(feature = "update-test-source")]`;判据抽成
  纯函数 `test_source_root_from` 并逐条单测(半开一律关、空路径、承认值只认 `1`);
  `the_test_channel_is_a_compile_time_opt_in` 在**两种构建下各断各的**(带 feature ⇒ 换源;
  不带 ⇒ 环境变量设满也走 GitHub)。
- cli: `update check/apply` 的 `--json` 增加 `test_source` 字段(判据派生自实际用了哪个源,不是回读
  环境变量),人读输出多一行点名;`help.rs` 的 `ULL_UPDATE_DIR` 说明按新判据重写。
- 验收:`crates/cli/tests/update_e2e_test.rs` + `update_e2e_tamper_test.rs` + 共用夹具
  `tests/support/update_e2e.rs`。临时数据根 + 本地源 + 真形状清单(与 `manifest.golden.json` 同
  字段)+ 真 PE 产物(现场构建的 ull.exe 前 1 MiB,理由写在 `PAYLOAD_LIMIT`)。断言覆盖:发现
  新版本(source=file:… / test_source=true / size / notes)、下载 → 双检 → 就位(dest 字节、旧字节
  进备份、暂存无残留)、重复 apply 的幂等终态、篡改 sha256(清单撒谎 / 包体被换)与截断都必须
  拒绝且不动现有安装、修复后能重装。**永远带 `--dest`**:不带会真的改名换掉测试二进制。
- 守门:`packaging/check_test_source_off.py`(stdlib-only)扫 `.github/workflows/**`、
  `packaging/*.py|*.sh` 与各 `Cargo.toml` 的 `default`,任何一处打开 feature(或 `--all-features`)
  就红;`--self-test` 注入 5 条真实回归证明每条都会红。CI 挂在 release-guard.yml 两步。

验证(本机):core update 单测 80 绿 / 带 feature 6 绿;E2E debug 6 绿;
`cargo test --release --features update-test-source` 6 绿(tower 要求 release 档验收);
`cargo test -p unlucky-cli -p unlucky-presenter` 全绿;守门 self-test + 正式扫描绿(真注入后红)。

已知没验:真 GitHub Release 下载、GUI 点击与重启替换(人工);跨卷替换(另开 finding)。
2026-11-08 11:12:10 +08:00
AskaEth 04ac90911e fix(m159): r1 复审返工 —— parse.rs 拆出子模块 + 五条 P3 真问题
按独立复审 `reviewer-updategui-r1` 的 p1-1 / fix-then-merge 返工。

P1-1(挡门)
- `crates/cli/src/cli/parse.rs` 406 → **382** 行:`fn parse_update` 搬到
  `crates/cli/src/cli/parse/update.rs`(与 `parse/{accounts,agent,instances}.rs` 同形状)。
  全仓 `crates/**/*.rs` 最大仍是 400(`design/tests/card_neutral.rs` 等三处,未越线)。
  上一轮报告里「所有改动文件都在 400 行内」那句是错的,已在 r2 报告里更正。

P3-6 `Job::apply` 不查包形态
- 新增 `self_replaceable(format)`(只有 `PortableExe` 允许)+ `ensure_self_replaceable`,
  **挡在取包之前**;错误分类 `InvalidManifest`(发布侧挂错,换源/重试无用)。
  比复审建议的 `needs_installer()` 更严:`portable-zip`/`appimage` 同样不能原地当 exe。
- 用例:`crates/core/src/update/tests/job_cases.rs`(msi/zip 被拒 + 目标零改动 + 暂存目录
  没被建 + 便携 exe 整环成功 + Nothing 不碰目标 + 闸门的源码锚点)。

P3-7 `ULL_UPDATE_DIR` 生产可用
- `default_source` 改走 `local_dir_override()`:`if !cfg!(debug_assertions) { return None }`,
  发布构建里整段被常量折叠掉(这条路径在 release 二进制里不存在)。判据与三档取舍写在
  `job.rs` 的文档表里(为什么不用 CLI 参数、为什么不用运行期开关)。

P3-1「忽略这个版本」语义
- `UpdateView.dismissed: bool` → `skipped: Option<String>`(记**版本号**):忽略 0.2.3 之后
  同一次运行里冒出 0.2.4 **照常提示**;手动检查清空(`begin_check(manual)`),静默检查不清。
  文案保持「忽略这个版本」。presenter / app 侧视图模型 / 两处用例同步。

P3-3 CLI `--json` 失败信封缺结构化分类
- `CliError` 加 `detail`(`Option<Box<Value>>`,装箱避免 `result_large_err`)+ `with_detail`,
  `to_json` 输出 `error.detail`;`commands/update.rs` 的 failure 填
  `{"kind": core 分类 id, "retryable": 可重试位}`。
- 上一轮报告里引的 `{"kind":"digest_mismatch"}` 形状确实不存在(那是 CLI 的 user/internal),
  r2 报告换成了实机 dump。

P3-4 update 命令没有解析测试
- `crates/cli/tests/parse_test.rs`:`update_subcommands`(check / apply / `--dest` 两种写法)
  + `update_usage_errors`(无子命令 / 未知子命令 / 多余位置参数 / 缺少取值 / 未知选项);
  `help_text_covers_every_command` 的 topic 列表补 `"update"`。

P3-5 `install.rs` 模块文档与实测矛盾
- 改成「可以改名、不能覆盖/删除」,写明 M159 在 Windows 真机验过运行中的 exe 自替换,
  并提醒别再照旧文档去设计「退出自身 → 另一个进程搬运」的双进程方案。

顺手(复审 P3-2/P3-8 里低成本的两条)
- 设置页补「下载中」态(`SettingsUpdateApplying` 不再是死 key,下载期间不画「下载并安装」);
  渠道显示改读 `update.channel`(`stable` 走 key,其它原样);删掉从未使用的
  `UpdateBannerRestartLater`(catalog + 金表同步,29 条 key 全部有引用)。
- `adopt` 只让**检查**刷新 `checked_at`(应用回报不再冒充「上次检查」);
  `UpdateView::default()` 的 `current_version` 取本进程版本号(mock 下不再空白)。

门禁(`CARGO_TARGET_DIR=C:/Temp/ull-target/wt-159`):fmt 绿、clippy -D warnings 绿、
`cargo test --workspace --all-targets --no-fail-fast -j 4` **exit 0**(51 个测试二进制全 ok,
`core::addon::pack` 未被 skip)。该族用例复现了复审记的泄漏:一次全量在 `/tmp` 留 72 个
`ull-pack-*`(`/tmp` 149M → 8853M),跑完已 `rm -rf` 清回 150M。
2026-11-07 13:03:18 +08:00
AskaEth 4760714743 feat(addon): M111 整合包导入管线(CF / mrpack / MultiMC)+ ull pack import
接 M110 的 pack 模型与安全闸,做导入:三格式 → 实例目录,落 D10 §2 三条硬要求。

core(`addon::import`):
- 三方合并:`merge::decide` 判据表(磁盘 × 基线 × 包内),base 侧落在
  `<实例根>/.ULL/pack-baseline.json`;无基线时磁盘同名文件算用户已有内容 ⇒ 不覆盖。
  冲突逐条进报告(user_modified / user_deleted / pre_existing + 可选处置)。
- 两阶段:先 overrides 再补远端;失败分级只有一条判据(是不是"某个文件搞不到"):
  缺文件 ⇒ Partial;穿越/符号链接/限额/清单坏/摘要不符/IO ⇒ Failed 且整包回滚。
- 事务:`Staging` 临时目录 + `CommitGuard`(全新导入 rename 整目录、合并态 journal 逆序
  回滚、Drop 即回滚),基线作为最后一个暂存文件同生共死。
- 三格式语义:mrpack `client-overrides` 胜 `overrides`、`server-overrides` 不解压;
  `.disabled`/`.old` 按路径后缀识别并原样保留;MultiMC `formatVersion != 1` 整份拒绝、
  非加载器 component 逐项进报告;CF `classId` 认不出就不猜。
- 加载器只做翻译(`LoaderTarget`:kind + 原版 + 加载器版本 + 版本 id,含 CF NeoForge
  `1.20.1-47.1.106` 的 MC 前缀剥离),网络全部经注入的 `PackFetcher`
  (生产实现 `DownloadEngineFetcher` 只走 core::download,无第二个 reqwest client)。
- 报告:三态 + 逐项原因 + 稳定文案 key 表(`report::keys`,含 ALL 守门表)。

CLI(`ull pack import <file> [--target] [--name] [--overwrite] [--no-register]`):
- 退出码三态 0 / 3 / 1(`Outcome::exit_code` + runner 透传,其它命令仍 0);
- `--json` 恒有信封(`data.status` / `data.error.code` / `data.stages`),Failed 不抛栈。

测试:29 条 core(三格式端到端、合并 6 条、必须先失败 8 条含双检互补样本与确定性
提交失败回滚、报告契约 5 条)+ 5 条 CLI(三态退出码、JSON 契约、既有命令回归、help)。
fmt / clippy(core+cli, -D warnings) 绿;文件全部 ≤400 行。
2026-11-04 20:54:04 +08:00
AskaEth c9e6236a87 refactor(cli): 判据收敛到 unlucky_cli::cli,banner 按被调用的二进制名自称(M82 复审)
去重:`looks_like_cli(args) -> bool` 落在 `unlucky_cli::cli`,两个 GUI 的 main.rs 只调用它,
判据从两份变一份。cli crate 里**不**出现 GUI 的 flag 表——语义仍是「首个 token 是裸词就接管;
首个 token 是 flag 时只有解析出真命令才接管,解析失败不接管」。原来两个 main.rs 里的 5+5 条
单测合并成 cli crate 的一份(用例集按复审要求原样搬,含 --screenshot/--renderer/--diff/
--mock/--force-state/--scale-factor、--help/-h/--version/-V、10 个已知子命令、--json 前置、
未知子命令),两个 main.rs 的旧测试已删除。

banner:新增 cli::program 模块,从 argv[0] 的 basename 取程序名(剥目录、剥 .exe、空值回落
ull),把帮助/版本/错误提示里独立出现的 `ull` 换成它——`unlucky-launcher --version` 不再自称
`ull`,也不会把人指去敲 `ull --help`。`run()` 签名未改;`ull` 自己跑时输出逐字不变(替换是
恒等),打包脚本的 `ull --version` 冒烟不受影响。
2026-11-02 08:08:12 +08:00
AskaEth bc41dfcf7d Unlucky Launcher v0.0.1-alpha
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
2026-10-31 21:54:10 +08:00