Commit Graph
276 Commits
Author SHA1 Message Date
AskaEth e7b04c4afe Merge branch 'feat/m113-packui-modpack-import-and-export-su' 2026-11-05 04:59:48 +08:00
AskaEth 134b0a2aa4 fix(packui): M113 r2 复审整改 —— RAII 在跑句柄、per-host 代次、注释与代码对齐
复审 r2(p2-3items / fix-then-merge)逐条整改。**P2-6 与 P3-3/4/5 按塔台指示不动**,只登记。

## 必修 1:`PACK_RUNNING` 在早退路径不清零(跨功能泄漏)

改前:`fresh_cancel()` 置 bool,`super::finished()` 在 `block_on(import_pack(..))` **之后**才清;
探测失败 / 目标目录不可用两条 `return` 都在它之前 ⇒ bool 永久留在「在跑」。
后果:之后**任何**一次 `CancelInstall`(包括下载页的「取消安装」)①不再发 `InstallCancelled`、
②还给内容库发 `PackEvent::Cancelled`(一句「导入还在后台跑」的假话)。

改后:`InFlight` **RAII 句柄**(`Drop` 减一)取代手工 `finished()`;计数用「每个 host 一格」的
`in_flight: usize` 而不是 bool(连点两次 Import 各自进出)。
`InFlight::begin(&env)` 在**派发点**创建、随线程移动到出口 ⇒ 正常收尾、两条早退、`?`、panic
展开**任何**退出路径都会减一,以后再加早退分支也不用记得补一行。

## 必修 2:`dir_name` 注释与实测不一致

改前:注释与报告写「`Better MC` → `Better-MC`(不加哈希)」,而 `faithful` 只认 `[A-Za-z0-9_-]`,
空格不算 ⇒ 实际 `Better-MC-c7c9c149`;测试只钉了无空格形态。

改后:**选「空格算 faithful」这边**,注释、代码、测试三处对齐 —— 名字只含 ASCII 字母数字、
空格、`-`、`_` 时保持原样(空格折成 `-`);折过**别的**字符(中文、`.`、括号…)才补 `-<hash8>`。
测试钉住四例:`Better-MC` → 原样、`Better MC` → `Better-MC`(不加哈希)、`pack` → `pack`、
`Survival 1.20.1` → `Survival-1-20-1-<hash>`(折过 `.` 必须补)。

## 必修 3:两处注释与代码相反

- `host/job.rs` 的 `HostTask::Pack`:原写「代次与 `HostTask::Install` 共用同一计数器」,改为
  「pack 自己的一条(`PACK_EPOCH`),不与 `install_epoch` 共用」并写明为什么(P2-7 的教训)。
- `host/addon/pack/import.rs` 的 `target_of`:`<slug>` → `<dir_name>`,并注明中文名不会被折成同一个 `pack`。

## 必修 4:两条 P3

- `04` 图里「Back」出现两次:有预检结果时字段区那行只留「预检」,`Back` 只由预览块给一个
  (没有预检时才在字段区给 `Back`)。
- 导入侧 `rejected` 列表补 `bounded`(与 missing / conflicts 同一口径)。

## 必修 5:app 级用例(P2-1 的护栏)

新增 `a_failed_import_releases_the_in_flight_flag_and_keeps_install_cancel_intact`:
`CoreHost::headless` + 真派 `PackCommand::Import`(包不存在 ⇒ 探测早退)→ 泵到 `Probe` →
断言 `in_flight == 0` → 再派 `CancelInstall` → 断言**仍会**发 `InstallCancelled` 且**不发**
`PackEvent::Cancelled`。

## P3-6(裸静态量):按塔台要求正面回答并已改掉

不再用裸静态量:`PACK_EPOCH` / `PACK_RUNNING` / `PACK_CANCEL` 换成**按 host 键控**的一张表
(键 = `LauncherLayout::home()`),理由是实测撞到过串扰 —— `host/cancel_tests` 派一次
`CancelInstall` 就 bump 了全局代次,我这条在飞的导入事件被按代次丢掉(新用例因此红过)。
为什么不挪进 `CoreHost`:那要给 `host/mod.rs` 加字段(不在 scope)。这张表等价,`host/mod.rs`
一旦进 scope,搬过去只是把表删掉 —— 报告里也这么写了。
新增用例 `pack_epoch_is_per_host_and_is_its_own_counter` 钉住「另一个 host 的代次不受影响」。

## 顺手的两条

- `pack/tests.rs` 加完用例后 429 行超线 ⇒ 夹具(临时目录 / 写文件 / 泵 host)拆到
  `pack/tests/harness.rs`,`tests.rs` 回到 3xx 行。
- clippy `-D warnings` 抓到 `== false`、两处多余的 `&`、一个未用的再导出,都改掉了。

门禁:`cargo fmt --all --check` 通过;clippy 三 crate `--all-targets -- -D warnings` EXIT=0;
`cargo test -p unlucky-app-iced --lib` **507 passed**;`cargo test -p unlucky-presenter --lib`
**185 passed**(新增的 app 级用例在内,且是在**全量并行**下跑的);改动文件全部 ≤400 行。
2026-11-05 04:12:39 +08:00
AskaEth 99538e45b9 fix(packui): M113 r1 复审整改 —— 2 处「界面说假话」+ 7 条必修 P2
复审 r1(p1-2items / fix-then-merge)逐条整改。**P2-6(core 的本地化契约)按塔台指示本轮不动**,
已登记为独立 mission 的债。

## P1

1. **「Save to」是死输入框**:`ExportRequestView` 的唯一构造点硬编码 `target: None`,用户敲的路径
   从不进请求。现在构造收成纯函数 `packui::model::export::request_of`(预检与确认共用同一份),
   `target` 由字段去空白后给出(空 ⇒ `None`,交给 host 给缺省)。
2. **取消导入的 toast 撒谎**:下载页那条说「安装已取消、实例未创建」,而导入会在后台跑完并
   **登记实例**。现在 `cancel_pack()` 回报「刚才有没有导入在跑」,`dispatch` 据此改发
   `PackEvent::Cancelled`(不再发 `InstallCancelled`),界面与提示改成
   「导入会在后台继续跑完 —— 本次结果作废;已写下的文件会留下」,并把这句话**留在屏上**
   (`ImportState.cancelled` + 面板位)。

## P2 必修(7 条)

3. **`Dismiss` 误伤下载页安装**(跨功能污染,最高优先):pack 原来共用 `CoreHost::install_epoch`,
   关一次导入面板会把下载页正在跑的安装静默丢掉。现在 pack 有自己的
   `PACK_EPOCH`(模块级 `AtomicU64`),`handle` 的 pack 分支只比它,`Dismiss`/取消只动它。
4. **中文实例名塌成 `pack`**:新增 `convert::dir_name` —— 名字全 ASCII 安全字符时保持原样
   (`Better MC` → `Better-MC`),一旦折过东西(中文/空格/符号)就补 `-<hash8>`(FNV-1a),
   保证两个中文名得到不同目录。
5. **失败回滚后空目录挡住重试**:`target_of` 对「已存在但空」的目录**复用**(非空才拒),
   并在失败回滚后清掉自己建的空目录;`failed_report` 的 `target` 语义改正
   (以前把包路径塞进「目标实例目录」)。
6. **新实例流程下「以包为准」必然失败**:`can_overwrite` 追加 `page.instance.is_some()` ——
   新建实例那条路上不再显示一个必然失败的按钮。
7. **阶段词打架且文档说修好了**:`progress.rs` 的模块文档改成与屏幕一致(粗/细两层 + 已知重叠),
   并写清彻底解决要靠「安装面板提成共享组件」那条 finding。
8. **导入侧标题计数与行数对不上**:被拒 component 有了自己的标题(`The client does not support
   these: {count}`);冲突标题改成中性的 `Conflicts: {count}`(逐条自带成因与处置)。
9. **预检「没进包」列表没有有界滚动**:13 条是真实形态。列表改成有界滚动(110px),
   并且**把动作按钮提到摘要下面**(`Write the pack` / `Back`)—— 明细再长也顶不掉按钮。
10. **导入成功后不刷新实例列表**:`handle` 的 pack 分支在收到 `Report` 时调 `refresh_instances()`
    (与 `InstallDone` 同一条),新实例不用等下一次无关的 `RefreshAll`。

## 证据

- 新增测试(都跑真管线/真函数):`the_save_to_field_really_reaches_the_request`、
  `pack_epoch_is_its_own_counter`、`chinese_instance_names_get_distinct_directories`、
  `a_failed_new_instance_directory_can_be_reused_for_retry`、
  `overwrite_is_offered_only_when_importing_into_an_existing_instance`、
  `cancelled_state_is_visible_and_says_the_import_keeps_running`。
- 出图重出 12 张(6 态 × 浅深),新增 `06-cancelled-*`;`04-export-*` 用 13 条 skipped 的真实形态。

门禁:`cargo fmt --all --check` 通过;`cargo clippy -p unlucky-app-iced -p unlucky-presenter
-p unlucky-i18n --all-targets -- -D warnings` EXIT=0;`cargo test -p unlucky-app-iced --lib`
507 passed;`cargo test -p unlucky-presenter --lib` 184 passed;`cargo test -p unlucky-i18n --lib`
24 passed;全部改动文件 ≤400 行。
2026-11-05 04:06:42 +08:00
AskaEth 27bbc74eaa fix(packui): M113 收口 —— 导出的「Save to」字段形状与「没进包」标题承诺
复审看图挑出的两处,只改这两处,其余不动。

1. **Save to 字段**:原来是 `label("Save to (empty = suggested)")` + 空值,看起来像「这句提示就是字段值」,
   与上面 Pack name / Version 的「标签在上、值在下」不一致。改成短标签 `Save to` + 占位
   `empty = suggested path`(新增 `PackExportOutputPlaceholder` 一个键)。

2. **`Not in the pack (listed one by one)` 与 `...and 6 more files` 自相矛盾**:13 条没进包的其实全列了,
   但紧随其后的「还有 6 个」说的是**内联样例**的截断,两句挨着会被读成一件事。
   改法:「没进包」标题带真实计数(`Not in the pack: {count}`),内联样例加自己的标题
   `Copied into overrides/ (first {shown}):`,截断那句改成 `…and {count} more files (not shown here)`
   —— 谁被截断、截了几条,写在它自己的那一块里。

`tolerable_title` 因此改成收 `String`(计数是运行时 format 出来的),`golden.rs` 同步三个键。

门禁:`cargo fmt --all --check` 通过;`cargo test -p unlucky-app-iced --lib -j 2`
504 passed / 0 failed(TEST_EXIT=0);`cargo check -p unlucky-app-iced --all-targets` 零 warning。
出图:`04-export-light.png` / `04-export-dark.png` 已重出(其余 8 张未受影响、未改动)。
2026-11-05 04:06:40 +08:00
AskaEth 5f09b8113a feat(packui): M113 phase 2 —— 导入导出真正接线(三个共享枚举各只多一个变体)
搬到 M109 rebase 后的新 main(26e764e)之后按**新 main 的形状**写一次,不写两遍。

## 接线

- `dto/pack/event.rs`(新):`PackCommand` / `PackEvent` / `PackState::apply`。
  整条流程在三个最挤的共享枚举里各只占**一个**变体(`AppCommand::Pack` / `CoreEvent::Pack` /
  `HostTask::Pack{event, epoch}`),子枚举承载细节 —— rebase 时不用替别人定序。
- `events.rs` / `job.rs` / `handle.rs` / `dispatch.rs` / `state.rs`:变体与分支,全部带
  `// M113: import/export` 锚点。`state.rs` 只多 1 个字段 + 1 条 apply 分支(399 行)。
- `host/addon/**`(新目录,父文件只加 `mod pack;` + 再导出):
  - `pack.rs`:`CoreHost::pack` 落点 + 取消标记 + 实例目录/快照/格式/缺省路径解析
  - `pack/import.rs`:探测 → 目标目录 → `import_pack` → 报告(默认 KeepUser,显式才 Overwrite)
  - `pack/export.rs`:预检(`scan_instance`,**不写盘**)与写盘(`export_instance`)
  - `pack/convert.rs`:core 报告 → DTO,逐字段照搬(三态/五阶段/四处置/三成因/十一原因)
  - `pack/diagnose.rs`:探测诊断「像 X 缺 Y」(按标记文件的有无,不看文件名)
- `app/update/content.rs`:导入 / 以包为准 / 预检 / 确认写盘 / 关面板真的派指令;
  预检时先记下那份请求,确认时**原样发回**(防「预检 A、写 B」)。
- `pages/content/model.rs`:读 `state.pack`(不再是默认空态)。
- 面板接口按新形状改:`install_panel_model(&InstallProgressView, catalog) -> Option<Download>`
  (`InstallView` 不再从 `pages/download` 导出)。

## 取消(如实)

`CancelInstall` 现在同时置导入的取消标记 + 代次 +1:面板立刻收回、旧事件按代次丢弃。
但 core 的 `import_pack` 没有取消令牌、进度回调也拿不到控制流 ⇒ **后台那次导入会跑完**。
所以「取消」的真实含义是「不再显示 + 结果作废」,已在模块文档与报告里写清,并单开 finding。
取消标记用 `Arc<AtomicBool>` 而不是裸静态量:裸静态量会在并行测试间泄漏取消状态(实测到过)。

## 图看出来的三处(塔台要求)

1. `no download source (CurseForge file API not wired)` → `no download source available yet`,
   API 名只留在 DTO 的 detail 与日志里;顺带把 `sha512` / `projectID/fileID` 也改成用户话。
2. 右栏 `Installing modpacks is not available yet` → `Installing modpacks from the library into
   an instance is not available yet`(那句在 i18n 词表里,不在 M109 页面代码里,所以我自己改)。
3. 结果面板接线后可以关:`Dismiss` / `AbortExport` 发 `PackCommand::Dismiss` → host 回
   `PackEvent::Cleared` → 回到入口/浏览态,不再常驻。

## 证据(三条都是真跑,不是假数据)

- `bad_packs_get_a_named_diagnosis`:把文本改名成 .mrpack → 有具名诊断(不是静默、不 panic);
  目录形态有 overrides/ 无 manifest.json → 说得出「缺哪个标记」。
- `a_real_pack_runs_through_the_pipeline_end_to_end`:造一个真 MultiMC 实例目录当包,
  走 `import::run` 真路径,断言事件流里有进度 + 结果、格式=MutliMC、报告有写盘文件、文件真落地、
  代次原样带回。
- `preview_counts_match_the_written_report`:同一实例上跑预检与 `export_instance`,
  inline / skipped / remote **三项计数逐项相等**,且逐条路径的归类一致。

门禁:cargo check ✓ / clippy -D warnings(三 crate)EXIT=0 /
cargo test(app-iced 504 + 6 守卫,i18n 24,presenter 181)全绿 / rustfmt --check ✓ /
所有改动文件 ≤400 行(`state.rs` 399、`addon.rs` 106,pack 子目录最大 338)。
2026-11-05 04:06:39 +08:00
AskaEth 2129d8cee6 fix(packui): M113 第一批修正 —— 一次只摊开一件事、有界滚动、阶段词不再打架
D30 出图实测暴露的四个问题,都在本批范围内修掉:

1. **动作按钮被顶出屏幕**:内容库页正列没有整页滚动(只有结果列表自己滚),入口 + 报告 +
   导出三块同时摊开时,「确认写盘 / 关闭 / 以包为准重新导入」落在 900px 以下。
   改成一态一块(导出面板 > 进度 > 结果 > 入口);拖拽是窗口级事件,收起入口不丢功能。
2. **阶段词打架**:复用面板自带的阶段词是安装语义(Building the version),与
   「Step 2/5 · Unpacking overrides」并排时两种说法。现在面板阶段词留空、由步骤行说真话
   —— 仍然只有一份进度条 / 计数 / 取消按钮,没有第二套进度 UI。
3. **列表过长**:缺文件 / 冲突 / 预检样例与已知损失都套上「超过 n 条才滚」的有界容器
   (packui/view/parts.rs 的 bounded),少量条目时不浪费空间。
4. **英文单复数**:计数行改成「标签:值」(Files written: 2、cannot export: 1)。
   PackPathPlaceholder / PackFormatModrinth 的中文与英文雷同,被
   chinese_catalog_is_actually_chinese 抓到,一并改掉。

另:app/update.rs 加完拖拽分支后 408 行超线 → 两个钩子改成返回 Task、三个分支各一行,
现在 397 行。新增致命态出图夹具(ULL_CONTENT_PACK=failed),用来证明「容忍的缺文件」与
「致命回滚」在视觉上可区分。

门禁:cargo check ✓ / clippy -D warnings ✓ / cargo test -p unlucky-app-iced(467+6)✓ /
cargo test -p unlucky-i18n(24)✓ / rustfmt --check ✓ / wc -l 全表 ≤400 ✓
2026-11-05 04:06:38 +08:00
AskaEth fbeff94e55 feat(packui): M113 第一批 —— 内容库页的导入/导出区(界面 + DTO + i18n + 拖拽落点)
导入入口(拖拽 + 路径输入 + 目标实例)与导出(格式选择 + 预检)的**界面层**,
数据面镜像 M111/M112 的管线报告(presenter DTO),进度整块复用下载页的安装面板。

## 落了什么

- pages/content/packui*:入口 / 进度块 / 结果块 / 导出预检块(每文件 ≤400)
  - 拖拽区(hover 变主色描边)+ 路径输入框 + 目标实例 chips
  - 进度:一行「步骤 n/5 · 解包覆盖内容」+ **下载页那块安装面板**(不另画进度条)
  - 结果:三态(Success/Partial/Failed)+ 缺文件逐条(Warn/Tertiary)+ 冲突逐条
    (成因 + 本次处置 + 可展开三方 SHA-1)+ 致命块(Error/ErrorContainer)
  - 导出:mrpack / CF 二选一 + 包名/版本/路径 + 预检「将内联 N 个文件 · 无法导出 M 项」
    + 逐条无法导出项 + 已知损失 + 确认写盘
- presenter/src/dto/pack{,/format,/import,/conflict,/export}.rs:镜像 core 报告的 DTO
  (三态、五阶段、三分类、十一条原因,全部 from_slug 与 core 稳定码逐字对齐)
- i18n:81 条 Pack* 键(content_entries! 尾部追加;golden.rs 同序同文)
- app/update.rs:FileDropped / FileHovered / FilesHoveredLeft → 内容库页(本版唯一的选文件通道)
- M109 的四个文件各一处插入:mod 声明 + Pack(..) 变体 + pack 字段 + 正列插入点

## 边界(如实)

- 没有系统文件选择框:依赖树里无对话框依赖,加它要动根 Cargo.toml + 许可门禁 +
  Linux 后端选型(影响单文件产物)。入口是拖拽 + 路径输入,原因写在界面上。
- 冲突没有逐文件决策:core 的 ConflictPolicy 是整次导入一个全局档,ConflictFile 是事后账。
  本批给「逐文件看 + 整体翻档(以包为准重新导入)」,文案明写「两者都留(改名)暂不支持」。
  默认一律 KeepUser——绝不默认覆盖用户改过的文件。
- 本批按钮半通:拖拽/路径/格式/实例/展开都真生效;「点导入真的开跑」「预检/确认写盘」
  要等第二批的 AppCommand 与事件通道(M109 rebase 到新 main 后按新形状写一次)。
2026-11-05 04:06:37 +08:00
AskaEth 0367073d32 Merge branch 'feat/m109-contentdl-content-library-page-modp' 2026-11-05 04:06:09 +08:00
AskaEth 1c5283058b refactor(m109): rebase 到 5adac23 后按功能拆两条超 400 行的文件
rebase 到 M107/M108 之后的 main(5adac23),并集解冲突后 `app/state.rs`(401)与
`app/view.rs`(402)双双顶破 400 硬线。按功能拆,不压注释:

- `app/state/boot_probe.rs`:启动期取证两件——`app_options`(启动参数 → 后端选项)与
  `log_first_frame`(首帧耗时一行)+ 它们的两个全局量 `STARTED` / `FIRST_FRAME_LOGGED`;
  `state.rs` 用 `pub(crate) use` 再导出,`app/boot.rs`(`use super::state::STARTED`)与
  `app/view.rs`(`log_first_frame`)的路径一字未变。state.rs 401 → 376。
- `app/view/tests.rs`:`view.rs` 的守门测试独立成文件(`#[path] mod tests;`)。**内容取自
  当前 main 的测试体**(M106 的 `Lang` 形参 / `at()` 入口都在),不是旧分支那一份;view.rs 402 → 255。
- `presenter/src/state/content.rs` 与 `presenter/src/state/content_tests.rs`:上一轮已拆,本轮不变。

门禁(新 tip,`-j 2`,逐条前台跑并落盘退出码):fmt 0 / clippy -D warnings 0 /
`cargo test -p unlucky-app-iced --lib` 0(522 passed)/
`cargo test --workspace --exclude unlucky-app` 0。
2026-11-05 03:39:50 +08:00
AskaEth 42099d4336 fix(app-iced): M109 r1 —— CF 来源装入闸门、has_more 读最近一页、末行切半与文案/图标
复审 p1-2items 的两条 P1 与三条 P2,逐条收口。

`blockers_of` 只看版本/加载器/目标实例,不看来源;而 host 的 `resolve_file` 对 CF 直接
返回「未接通」⇒ 切到 CF 源后每条都必然失败,UI 一声不响。

- 新增 `Blocker::SourceNotWired` + 文案 `ContentSourceNotWired`(中英);
- `blockers_of` 的入参从 `Option<(&[String], &[String])>` 收成 `Option<HitFacts>`(版本 /
  加载器 / **来源**),两处调用点(产品路径、演示数据)一起改;
- 判据同构于整合包的 `NeedsPipeline`:**点之前**禁用并给一条原因;CF 文件端点接通后删掉这条。

DTO 的 `has_more()` 判「本页装满」是对的,但 reducer 在 `offset>0` 时先 extend 再判,
被判定的是**累计条数** ⇒ 首页满 20 条之后恒真:「加载更多」永不消失、「没有更多了」在
真实翻页路径上不可达,且每点一次白发一次网络请求。

- `ContentState` 新增 `last_page_filled`(**先**按这一页记一位,再并进累计列表);
- 页面的 `has_more` 读它(`&& !pending`);换条件(offset 归零)时清位;
- 新增 `crates/presenter/src/state/content_tests.rs` 四条用例(state.rs 贴 400 行上限,拆文件):
  `a_short_last_page_clears_has_more` / `an_empty_last_page_keeps_has_more_false` /
  `changing_the_query_resets_the_page_flag` / `a_stale_batch_does_not_touch_the_page_flag`;
- 变异自证:把判据改回「读累计」后前两条红(`末页不满 ⇒ 不许说还有更多`、`空页 ⇒ 没有更多`),
  改回绿。

- **末行被 footer 切半**(复审定位的真因):`view.rs` 的 body 列漏了 `.spacing(..)`,而列表
  纵向内边距是 0 ⇒ 视口底边正好切在半行上。补 `Space::X2`,并说明为什么必须给。
- **文案去掉开发者术语与内部编号**:`ContentSourceUnavailable` 不再写 `ULL_CF_API_KEY`
  (改「CurseForge 需要在设置里配置密钥才能搜索」);`ContentModpackNotReady` 不再写
  mission 编号(改「整合包安装尚未开放」);host 侧那条 `InstallProgress` 失败文案同步去编号。
- **图标**:四类改为 `Folder` / `Plus` / `Restore` / `Expand`——不借用导航五枚(同屏撞形,
  复审 P2-3)、不用填充字形(`no_hardcoded_tokens` 门禁);偏离表按**实际取值**重写,并补一条
  「CF 的装入也是禁用态」的登记。
- 演示数据加 `ULL_CONTENT_STATE=cf` 一屏,出图证明 P1-1 的禁用态(`12-cf-not-wired-light.png`)。

门禁(逐条落盘退出码,不走管道):fmt 0 / clippy `-D warnings`(app-iced+presenter+i18n)0 /
`cargo test -p unlucky-app-iced --lib` 0(451 passed)/ `cargo test -p unlucky-presenter` 0
(174 passed)/ `cargo test --workspace --exclude unlucky-app` 0。改动文件最大 397 行。

取证:`DevDocs/artifacts/m109/`(12 张,全部重出)
2026-11-05 03:20:22 +08:00
AskaEth 6b161227d8 feat(app-iced): M109 CONTENTDL —— 内容库页(整合包/Mod/资源包/光影)
用户令「下载页只负责下载整合包和Mod/光影」:新开一页做**内容发现与装入**,
检索走 Modrinth(优先,无需 key)/ CurseForge(`ULL_CF_API_KEY` 环境变量,日志只打
set/unset),装入走 core 的下载引擎真下到实例目录,进度/取消/失败复用下载页那块安装面板。

- **入口(B1)**:`Route::Content` 是新顶层路由,但 `Route::nav()` 复用 `NavSection::Download`
  ——导航轨仍五项,页头右上是「版本管理 | 内容库」一级切换(版本管理页也加了同一个切换,
  否则内容库只有 `ULL_START_ROUTE` 能到)
- **页内四条一级侧栏**(`pages/content/layout.rs` 四档几何:992/856/648 三个门槛都是
  「Σ栏宽 + 间距」算出来的):整合包 / Mod / 资源包 / 光影;标签栏 200(`Resource packs`
  比 M107 的版本类型名长,140 会切字,已登记偏离)
- **筛选**:关键词(草稿 + 显式「搜索」按钮,不逐字打网络)、来源、游戏版本、加载器
  (Fabric/Forge/NeoForge/Quilt)、排序;**分页**(「加载更多」)而不是无限滚动——快照只有一个槽、
  翻页要看得见、状态要可截图复现
- **四态**:加载 / 空 / 失败 / 有数据 + 「没有更多了」;筛选条在任何状态下都在(加载中藏筛选条
  等于把入口收走)
- **一键装入**:`Content::blockers()` 是纯函数——版本不匹配、加载器不匹配、没有目标实例、
  整合包缺管线**四条原因逐条**给出,按钮点之前就禁用;判定与画法分开(model/tests 直接钉)
- **整合包今天不可装**:`ContentKind::Modpack.dir()` 是 `None`,UI 禁用 + 说明,host 侧再挡
  一次(绕过 UI 也只会回一条 Failed)。真流程是 M111 的导入管线
- **零 UI 网络**:`presenter/host/addon.rs` + `addon/{sources,http,install}.rs`;无 header 的请求走
  core 的 `download_bytes`(信号量限流 + 健康度 + 退避全现成),带 `X-API-KEY` 的只有 CF 一条,
  自带 429/5xx 退避(优先吃 `Retry-After`,封顶 30s);密钥不进 DTO/日志/仓库
- 文案 41 条中英进 `crates/i18n`(新域接在装配链末尾 ⇒ `Key::ALL` 与金表只追加)
- 演示数据 `ContentHit → Hit::of` 与产品路径同源(格式化不会两套),`ULL_CONTENT_KIND` /
  `ULL_CONTENT_STATE` 供出图矩阵选类型与状态

取证:`DevDocs/artifacts/m109/`(10 张:四类内容 × 浅/深 + 搜索无结果 + 加载器不兼容禁用)

门禁:fmt ✓ / clippy `-D warnings`(三 crate)✓ / app-iced 450+6、presenter 170、i18n 24 全绿;
`cargo tree` 两条零 UI 门禁仍空;全部改动文件 ≤400 行。
2026-11-05 03:20:21 +08:00
AskaEth 5adac230fe Merge branch 'feat/m108-versionmgmt-rename-download-page-to' 2026-11-05 03:13:18 +08:00
AskaEth bbf96de9b3 feat(app-iced): M108 下载页显示名改成「版本管理 / Version management」
用户令(2026-11-04):当前下载页改名叫版本管理,下载页只负责下载整合包和 Mod/光影。
本 mission 只改显示层,标识符(Route::Download / Screen::Download / NavItem::Download /
文件名)一个都没动,标识符改名另登 finding 后置。

i18n(catalog/nav.rs + catalog/download.rs,金表同步):
- NavDownload = Versions/版本管理 —— 88px 收起轨用短名
- NavDownloadExpanded = Version management/版本管理 —— 240px 展开抽屉用全名
- DownloadTitle = Version management/版本管理 —— 页标题
- DownloadSubtitle = Manage versions and loaders: install, upgrade, repair/
  管理版本与加载器:安装、升级、修复
拆两条 key 的理由:18 字符英文 ≈101px 塞不进 88px 轨(实测两通道都被裁:产品通道只显示
Version,组件版轨被切成 Version manage);中文 4 字实测 48.0px(四种 CJK 字体同值)放得下,
所以中文两条同值、不缩。

- pages/nav.rs:NAV 第 3 列 &str → Key;label_key(label, expanded) 是两态切换点
  (用 bool 参数而非 enum:240 态在产品里还没有渲染路径,枚举变体永远构造不到会被
  -D warnings 的 dead_code 拦下)。
- shell/nav.rs:NavItem::Download 显示 Versions(产品路径的轨;i18n 接线归 M106,
  本 mission 只换文案值)。
- 守门:pages/tests.rs 断言导航/页标题取值 + 两态切换 + 目录里凡指向这一页的文案都不许再说
  「下载」(豁免 DownloadStageDownloading);shell/nav.rs 断言标签是 Versions 且 ≤12 字符。
2026-11-05 02:54:04 +08:00
AskaEth c31036dea8 Merge branch 'feat/m107-dlvrail-version-management-page-dua' 2026-11-05 02:35:50 +08:00
AskaEth c560b66df6 chore(app-iced): M107 rebase 到 M102 之后的收尾(文件行数 + 未用 import)
解 rebase 冲突时把 M102 的 `extra_width_goes_to_the_page_block_*` 留在
`download/tests.rs` 会让该文件到 404 行(超 400 硬线),挪到 `download/tests/rails.rs`
(同一批判据,模块路径改 `crate::pages::frame::body_width`);顺手去掉挪走后多余的
`measure_cap_px` import。

无行为变化:只搬测试 + 删 import。门禁见报告(fmt 0 / clippy 0 / lib 481 passed / workspace 0)。
2026-11-05 02:02:22 +08:00
AskaEth f408bf5312 fix(app-iced): M107 r2 复审 —— clippy needless_lifetimes + 搜索中点桶立刻生效
- **P1-1**:`group::active_bucket` 的显式生命周期是多余的(`needless_lifetimes`),省掉之后
  `cargo clippy -p unlucky-app-iced -p unlucky-i18n -p unlucky-presenter --all-targets
  -j 2 -- -D warnings` 才真的	exit 0。上一轮报告的那条「clippy 本轮未复跑」正是红的根因——
  本轮把 clippy 纳入每次门禁(见报告)。
- **P2-1(b)**:搜索期间第二级侧栏原来「可点但没反应」(一致性放宽让桶失效,点击要等清空搜索
  才生效)。现在**点桶 = 退出搜索 + 按该桶过滤**:`SelectMajor` 发现 `has_query()` 就先落选择
  再 `rebuild_after_query("")`,一次点击做完「我要看这个桶」这件事。
  判据:app 层 `clicking_a_bucket_during_a_search_leaves_the_search`(432 passed)。

门禁(真实退出码,无管道):
`cargo fmt --all --check` = 0;`clippy … -D warnings -j 2` = 0;`cargo test -p unlucky-app-iced --lib -j 2` = 432 passed / 0 failed
2026-11-05 02:01:12 +08:00
AskaEth 45544791e5 fix(app-iced): M107 r1 复审五条 p2 —— 搜索与主版本桶一致、chips 可滚、单复数、端到端
复审 r1(p2-5items / fix-then-merge)的五条可达问题,逐条落点:

- **P2-1** 搜索命中数与主版本桶不再自相矛盾:`group::active_bucket` 让第二级在搜索期间
  **自动放宽到「全部」**(选择本身留在模型里,清空搜索后回来),侧栏点亮「全部版本」⇒ 没有幽灵选中;
  `rebuild_after_query` 回填时校验桶仍存在(新桶列表里没有的选择直接丢弃)。
  判据:`a_search_hit_never_lands_on_an_empty_list`(4 分类 × 5 桶值 × 4 查询词的矩阵)+ 出图 11
- **P2-2** chips 档的主版本行套**横向滚动**(显式滚动条),竖栏与 chips 吃同一份 `major_entries`;
  22 桶夹具断言最后一个桶 `1.0` 在列表里,出图 12 看得到滚动条
- **P2-3** 只做搜索这条路径(`rebuild_after_query` + 校验 + app 层测试);`app/state.rs::refresh_view`
  的全量修法**留到 M105 之后**(不在 scope,且 M105 正改同一片文件)——报告里明写
- **P2-4** `1 matches` 修掉:新增 `VersionSearchHitsOne` / `VersionSearchElsewhereOne` 两条单数 key,
  「在别处」用独立标题(不再拿命中数当标题,同屏一个数只报一次)
- **P2-5** 搜索链路补 4 条 **app 层端到端**(键入 → 快照 → 重建 → 列表);变异自证:
  删 `self.state.download_query = query` ⇒ 2 条红;`from_snapshot` 忽略 query ⇒ 8 条红;
  去掉 `active_bucket` 的放宽 ⇒ 4 条红
- 顺带:64 宽图标条每项挂 tooltip(复审建议);`install.rs` 那段自相矛盾的文档订正

门禁:fmt 0 / app-iced lib 431 passed / workspace --exclude unlucky-app 全绿(见报告)
2026-11-05 02:01:11 +08:00
AskaEth 89a51a63b1 feat(app-iced): M107 DLVRAIL —— 版本管理页三栏侧栏 + 跨类型搜索
用户令(信息架构):这一页从「下载」改称「版本管理」,版面从「一排 tab + 平铺列表」
改成**页面内三栏**:版本类型栏 + 主版本栏 + 列表/详情,并加搜索框。

- **三栏几何**(`pages/layout/rails.rs`,纯函数 + 数值表):类型栏 140/64、主版本栏 140/64、
  间距 24、列表列下限 424(行宽红线)。四档由**可用宽**裁决:Wide ≥888 / Narrow ≥676 /
  Compact ≥600 / Chips <600,三个门槛都是 `栏宽 + 间距 + 424` 算出来的,不是拍的
- **主版本分组规则**(`download/group.rs`):正式版/预发布按 `major.minor`(`1.21.9-pre3` → `1.21`)、
  周更按年桶(`24w14a` → `24w`)、远古版**没有主版本**(`rd-132211`/`a1.2.6` → 第二级只有「全部」);
  判据用真实版本样本钉住,不按「第一个点」切
- **跨类型搜索**(`search_bar` + 清空 + 命中数):查询词写进 `AppState.download_query`(页面无状态,
  只能这么活),`hits` 是**跨分类**读数;三条口径分开:「有命中」/「没有匹配」/「命中在别的类型里」
- 两级侧栏都用 `navigation::list_item` / `tabs` / `chip` / `buttons::icon`:Tab 可达、hover/pressed/
  选中态全部来自组件,页面不自己写状态层
- 右栏展开(M104 `content_width` 852)⇒ 可用宽 716 ⇒ Narrow 档,列表 464(任务书那句 492 漏扣
  96px 的页边距与栏间距,报告里给了完整数值表)
- 文案 11 条中英进 `crates/i18n`(新域宏接在装配链末尾 ⇒ `Key::ALL` 只追加,金表只追加)
- 护栏:两层侧栏都存在 + 列表按第二级选中态过滤(变异可证)、三档门槛、424 下限扫描、
  版本行单行宽账、搜索条真的装着
- `install.rs` 只动了「已装」分支(加一句「已安装」解释禁用按钮),没碰 M105 的选项区

取证:`DevDocs/artifacts/m107/`(10 张:三档 × 浅深 + 搜索命中 + 空 + 异地 + 远古版)
2026-11-05 01:59:13 +08:00
AskaEth 72b0c9d465 Merge branch 'feat/m106-i18nfollow-ui-follows-system-langua' 2026-11-05 01:39:57 +08:00
AskaEth 954a129e01 fix(app-iced): M106 r2 —— 两处「改了行为、旧口径还在树里」(复审 p2-1 / p2-2)
p2-1(它在撒谎):`renderer/acceptance/locale.rs::Chain::line()` 还打 `ui_text=pinned-en`,
注释写着「pages/mod.rs::render 里下载页固定传 Lang::En」、`setting` 回退 `<未接线>`——M106 之后
两句同时为假,于是 `ULL_ACCEPTANCE=1` 时启动日志两行互相矛盾(旧行说「文案钉在英文」,新行说
「生效中文」)。修法:`Chain` 增 `machine`(只看平台源 + 环境变量,供 `[env] language=` 用)与
`detection`(含 `ui.language` 手选,与 App 的 `[i18n] choice=…` 同源);`host()` 真读
`SettingsBridge::load_ui_language()`,`line()` 删掉 `ui_text`、`setting` 打真实值。
实测三行现在一致:
  [i18n] setting=<未设置=跟随系统> platform=registry:zh-CN env=env:<未设置> -> lang=zh-CN locale=zh-CN source=registry
  [i18n] choice=system lang=zh-CN locale=zh-CN source=registry effective=zh-CN
`locale_tests.rs` 把钉假串的那条改成「不许出现 pinned-en」的回归绊线,并新增
`i18n_line_honours_the_explicit_setting_without_rewriting_the_machine_language`。

p2-2(doc-only):`pages/model.rs` 的「只是留口,产品路径先给 Lang::En」与 `pages/download.rs`
的「产品路径现传 Lang::En,接上探测结果时只改调用点」都已被实际代码推翻(`app/state.rs` +
`refresh_view` 在填、调用点已是 `model.lang`),按事实重写。顺带修掉 `fonts/cjk.rs` 里同样的
「设置层的读取点尚未接线」。

门禁:`cargo fmt --all -- --check` 退出码 0;`cargo test -p unlucky-app-iced --lib` 退出码 0
(409 passed, 0 failed)。全改动文件 ≤400 行(最大 269)。
2026-11-05 01:17:36 +08:00
AskaEth 89dcb6bdf3 feat(app-iced,i18n): M106 —— 界面跟随系统语言 + 设置页真实语言行
用户原话:「我系统语言是中文 为什么打开软件是英文 并且设置页里面显示的是 English(system)」。
两处根源:①M92 的探测链一直是对的,但 `pages::Model.lang` 在 `app/view.rs` 里写死成
`Lang::En`(`pages::render` 还把下载页写死成英文),探测结果没人用;②设置页语言行是硬编码
字面量 `("Language", "English (System)")`。

新增/改动(按层):
- i18n:新域 `catalog/general.rs`(24 条中英双份 key,装配链尾追加)+ `catalog/golden.rs`
  只追加不重排;`choice.rs`(`LangChoice`:跟随系统/手选 + D28 四级优先级真值表 + 字体优先级真值表)。
- app-iced 语言入口 `src/ui.rs` + `src/ui/lang.rs`:`LangState` = 用户档位 + 探测结论;
  启动判定 `ULL_LANG` > 配置 `ui.language` > 系统 locale > 英文;两行日志(探测/生效 + 中文策略)。
- `app/state.rs`:`App` 持有 `LangState`,切语言 = 换目录 + 落盘 + 重建视图模型;
  `app/view.rs` 的 `model()` 收 `lang`,不再写死英文。
- 设置页:语言行换真控件(跟随系统 / 简体中文 / 英文,回显「界面当前使用:简体中文」);
  页头、左列分组名、逐实例空态、导航轨标签全部走 catalog。
- presenter:`settings_bridge_lang.rs`(`load_ui_language` / `persist_ui_language`)。
  「跟随系统」= **删键**(schema 允许值只有 zh_cn/en_us),读的是 `doc().get()` 而不是
  `effective_str`(后者会把默认值 `zh_cn` 当成用户手选,英文机器一启动就被切成中文)。

验证:408 条 app-iced lib 测试 + 28 条 i18n 测试 + 全仓 `cargo test --workspace --exclude
unlucky-app` 全绿;fmt / clippy `-D warnings` 干净;出图 6 张(en/zh × 浅/深 + 下载页对照)
在 `DevDocs/artifacts/m106/`(该目录被 .gitignore 忽略,按仓内惯例作为本地验收产物)。
2026-11-04 22:50:09 +08:00
AskaEth 777d02e7cb Merge branch 'feat/m105-dlflow-install-options-step-and-can' 2026-11-04 22:41:08 +08:00
AskaEth 609bcb3044 fix(dlflow): M105 r1 —— 取消后不再逐个派发剩余文件;取消证据补强;取消提示进 i18n
复审三条(p2-3items / fix-then-merge,其中两条按 p1 对待):

**P2-1 取消后仍为每个剩余文件各发一次请求(p1)**
`select` 先轮询左支 ⇒ `install_one` 第一次 poll 就把 `engine.download(…)` 派出去了;
只把标志检查写在右支,等于取消后「每一个剩余 item 都创建并至少 poll 一次」
(一次整文件 sha1 + 一次请求),盘上已就绪的还会第一 poll 就 `Ready` 并回调 `sink`。
修:`async move` 第一句就 `if is_cancelled(cancel) { return None; }`,这些 item 连
`install_one` 都不进。守门用例 `cancelling_mid_run_stops_creating_remaining_files`
(三文件全在盘上、并发 1、sink 首次回调即置标志 ⇒ 数 sink 回调次数):
修后 1,删掉那两行变异后 **3**(见报告,红/绿均实跑)。
模块文档同步改成与实现一致,并把复审点名的固有边界写进注释:取消瞬间最多有
`concurrency`(GUI 传 16)个 future 已经过了那道门,其中「盘上已就绪」的仍会跑完自己的
回调(靠 presenter 的安装代次丢掉这些进度事件),真在下载的会在同一次 poll 被取消。

**P2-3 真网络装置的断言比声称的弱,且有一条探的是恒不成立的路径(p1)**
① `.ullpart` 原来只断言「5 s 后不再增长」⇒「留下一个不增长的非 0 中间文件」会绿;
改成 `assert_eq!(parts_after, 0)`。
② 实例登记原来探 `<game>/instances`(根本不存在 ⇒ 恒 false,什么也没证明);
改成读 `LauncherLayout::profiles_config()` 的 `read_profiles()` 并断言 0 条。
③ 模块文档里那条探针的路径一并订正。
变异自证:留一个 20 字节 `.ullpart` ⇒ 红(`left: 20 / right: 0`);
走真 `instance::register()` 造一条 profile ⇒ 红(`profiles.json 里 1 条实例`)。

**P2-2 取消文案仍是硬编码中文,与新选项区同屏一中一英**
`presenter` 没有 i18n 依赖,所以不是搬常量能解决的。改成发稳定信号:
新增 `CoreEvent::InstallCancelled`(不带文本),`AppState::install_cancelled` 只计数;
app 层在 `refresh_view` 里比对计数、按 `Key::DownloadCancelled` 现求文本弹一次 Toast
(`pages::download::cancelled_toast`)。`dispatch.rs` 的中文 Toast 删掉。
语言入口收成 `app::view::ui_lang()` 一处(现恒为英文;M106 接上语言只改这里,
M106 合并前中文译文在产品路径不可达)。守门:`cancelling_announces_once_in_the_page_language`
(只弹一条、文案来自目录、不含中文、重复 refresh 不重复弹)。

门禁(真实退出码):fmt=0、clippy(--workspace --all-targets -j 2 -D warnings)=0、
`cargo test -p unlucky-app-iced --lib`=0(440 passed)、`cargo test -p unlucky-core`=0、
`cargo test --workspace --exclude unlucky-app -j 2`=0(57 个 target 全 ok,0 failed)。
2026-11-04 22:12:22 +08:00
AskaEth 30b1ff6f48 feat(dlflow): M105 安装选项区(四家加载器 + 官方版本列表 + 实例名)与取消安装真停
用户实跑反馈两条,本轮各自定位到根因并修掉:

1. 「下载页没有前置加载器选项」= 功能未做:`Message::InstallVersion` 直接派
   `AppCommand::InstallVersion`(= 只装原版),core 早在 M32/M35/M45 就有四家加载器的
   安装管线。现在选中版本之后面板先给「安装选项」区(原版 / Forge / NeoForge / Fabric /
   Quilt + 该加载器的官方版本列表 + 实例名),点「安装」才派
   `AppCommand::CreateInstance`。
   - 加载器版本列表复用 core 的 `LoaderSource::loader_versions`,经新增的
     `AppCommand::ListLoaderVersions` / `CoreEvent::LoaderVersions` 回流;
     缓存键是 `(加载器, 原版版本)`,切版本不会串列表;取不到给具名原因并**禁掉确认**。
   - 不兼容组合用静态规则在**选中那一刻**说清(NeoForge 需 1.20.1+、Fabric/Quilt 需
     1.14+、远古版四家都不支持),不让用户「选完才失败」,也不为不可能的组合作无谓联网。
   - 选项草稿(选中 + 加载器 + 加载器版本 + 实例名)住在 `App`,`refresh_view` 带进页面
     模型(与分类筛选同一做法;页面自己存不住跨事件的东西)。

2. 「点取消没反应」= 真 bug,两跳各断一半:
   - 第一跳(UI 侧):取消确实生效了,但**还在跑的后台线程继续发进度**,`InstallProgress(None)`
     刚把面板收回,下一条「下载中」立刻又点亮它 —— 用户看到的就是「点了没反应」。
     修法:`CoreHost` 加安装代次 `install_epoch`(开工与取消都 +1),`HostTask::Install` /
     `InstallDone` 带代次回来,代次不符一律丢。
   - 第二跳(core 侧):`execute` 没有任何取消入参,取消只停在段边界,传输根本没停。
     新增 `execute_with_cancel(…, cancel: Option<&AtomicBool>)`(既有 `execute` 原样包装,
     CLI 一字未改):取消后不再起新文件,在飞的那个与「取消 future」`select` 赛跑,取消赢就
     drop 掉下载 future(响应流关闭、`.ullpart` 由 `PartFile::Drop` 清掉),并且不写版本清单,
     返回新的 `InstallErrorKind::Cancelled`。
   - 另外:取消后**绝不登记实例**(文件段跑完但用户已取消的那一路也拦住);取消文案把
     「哪些留着、哪些一定没发生」说清(D21)。

实测(真网络装置 `host/cancel_tests.rs`,`#[ignore]` 守着,跑法见文件头):
取消前 files_done=2 / `.ullpart` 613 字节;取消后 20 s 进度事件 **0 条**、`.ullpart` **0 字节**
(-613)、版本清单未落盘、实例目录不存在。取消前是 +201466 字节(同一个装置量的)。

文案全部进 i18n(中英,9 条新 key + 金表同步)。
2026-11-04 22:12:22 +08:00
AskaEth d690d09b84 Merge branch 'feat/m112-packexport-mrpack-and-curseforge-ex' 2026-11-04 22:01:07 +08:00
AskaEth 90b233088f feat(core): M112 整合包导出(mrpack 优先 + CF 清单,逐文件远程判定与内联兜底)
新增 addon::export(实例目录 → .mrpack / CF manifest.json):
- 逐文件三分:远程引用 / 内联进 overrides/ / 跳过(读不了、符号链接、路径不安全)。
  「能否被远程替代」= 目录能证明身份(Modrinth SHA-1、CF 指纹)**且**目标格式表达得了;
  证明不了的一律内联,绝不丢、绝不编,逐条进结构化报告(ExportReport)。
- game / dependencies 来自实例快照:versions/<id>/<id>.json 的继承链「叶子」+ 库坐标推加载器,
  不解析 <mc>-<loader>-<ver> 字符串(社区实例名不统一,那是猜)。
- .disabled 走路径后缀(不是 env.client=optional:后者装回来默认启用,跨启动器不保真)。
- CF files[] 只写能确定 projectID/fileID 的项;禁用文件 CF 表达不了,内联并记原因。
  CF modLoaders[].id 按官方写法:NeoForge 在 1.20.1 上写 neoforge-1.20.1-<ver>。
- 尺寸护栏:fileSize 超 u32 报 pack_limits;整包撞 ZIP64 阈值由 format::write 拒绝。

配套改动:
- pack/curseforge/fingerprint:MurmurHash2-32 + 0x09/0x0A/0x0D/0x20 过滤、seed=1、
  长度用过滤后长度、哨兵 811513880 短路;金标准由一份独立 Java 实现算出(双向验证)。
- pack/format/payload:PackPayload 加 with_sources(包内条目名 → 实例源文件),
  让「实例里的 mods/x.jar → 包里的 overrides/mods/x.jar」不用拼路径。
- pack/format:写盘时按未压缩总量守 ZIP64 阈值。
- pack/modrinth:禁用位改写路径后缀;fileSize 按 u32 校验。
- pack/manifest:PackLoader::curse_id_for 处理 NeoForge 的 MC 前缀特例。

测试:导出管线 10 条 + 往返保真 4 条(三种格式夹具;允许的差异逐条在测试文件头登记,
并在报告 notes 里给出)。M111 未合并,故本轮「导入」用 M110 的 read + 同形实例目录,
真 import_pack → export → import_pack 端到端留作欠账。
2026-11-04 21:32:37 +08:00
AskaEth 3ffb1c4fee Merge branch 'feat/m111-packimport-curseforge-mrpack-multim' 2026-11-04 21:30:40 +08:00
AskaEth 29f531db23 fix(addon): M111 r6 —— 失败文案按 rolled_back 分叉,补「回滚不完整」报告级用例
复审 r6 的两条(都在同两个文件里十来行):

1. 文案位自相矛盾
- 改前:`pipeline::failed()` **无条件**推「目标目录保持导入前的样子」,`record_rollback()`
  随后可能再推「有 N 项未能回滚(目标目录可能与导入前不同)」——同一份 warnings 自相矛盾;
  CLI `status_label` 也恒写「失败(已回滚)」。
- 改后:`failed()` 只落错误、**不宣称目标目录状态**;口径拆成两个具名入口——
  `abandoned()`(失败发生在碰目标目录之前,才说「目标目录未被写入」)与
  `record_rollback()`(完整 ⇒ `rolled_back=true` + 「恢复为导入前的样子」;不完整 ⇒
  `rolled_back=false` + 逐项列失败项)。CLI `status_label` 按 `rolled_back` 分叉成
  「失败(已回滚)」/「失败(回滚不完整)」;`keys.rs` 的状态文档与 pipeline 模块文档表
  同步改口径。

2. 新接线零测试(这是 fix-then-merge 的理由)
- 新增 `pipeline::tests` 三条报告级用例:
  `incomplete_rollback_never_claims_the_target_is_untouched`(`rolled_back=false` + warnings
  逐项列出 + **不含**任何「保持导入前的样子 / 未被写入 / 恢复为导入前」)、
  `complete_rollback_claims_consistency_exactly_once`、
  `pre_commit_failure_claims_untouched_only_via_abandoned`。

变异自证(本轮实测,均已还原):
- A:把 `record_rollback` 的分叉整体跳过(no-op)→ 2 条红(`失败项要逐条列出:[]`、
  `assertion failed: report.rolled_back`)。
- B:把 `failed()` 恢复成无条件推「保持导入前的样子」→ 2 条红,报错原文即自相矛盾现场:
  `["…目标目录保持导入前的样子…", "有 1 项未能回滚(目标目录可能与导入前不同)…"]`。
2026-11-04 21:19:58 +08:00
AskaEth 7f657d225c fix(addon): M111 r5 —— 基线读写同源(.ull)、plan.rs 拆回 400 内、中间产物/回滚/包身份
复审 r5 两条 P1 + 三条 P2。

P1-1 基线读、写走了两条路径(ext4 上是两个文件,Windows/macOS 上假绿)
- 写侧 pipeline 用 `LAUNCHER_HOME_DIR`(`.ULL`,启动器**数据根**的名字),读侧
  `PackBaseline::path()` 用 `GameLayout::ull_root()`(`.ull`,实例**游戏根**内的私有目录)。
- 现在读写都从 `ull_root()` 派生:`PackBaseline::path()`(绝对)+ 新增
  `PackBaseline::relative_path()`(相对,空根求 `ull_root()`),pipeline 的
  `baseline_relative()` 只做 `/` 分隔,不再出现第二份目录名字符串;`LAUNCHER_HOME_DIR`
  的引用从导入链路里删掉。
- 新断言 `baseline_read_and_write_paths_are_the_same_file`:`path(root) ==
  root.join(relative_path())`、`read.parent() == ull_root()`、写侧字符串不含 `.ULL`
  (路径比较是大小写敏感的,所以在大小写不敏感的系统上也抓得住)。
- 选 `.ull` 的理由:基线住在**实例的游戏根**里,那是 `GameLayout` 的地盘(且
  `crates/core/tests/game/layout.rs:45` 早已钉死 `.ull`);`.ULL` 是 portable 模式下
  exe 旁边的启动器数据根(`config/ data/ cache/ logs/`),层级不同,不能混用。
- 同时清掉 4 个测试里手抄的 `.ULL/pack-baseline.json`,改用 `baseline_rel()`。

P1-2 `import/plan.rs` 401 行撞穿 file-size 守门
- 把 `suffix_note()`(后缀注记)连同它的单测搬进 `plan/paths.rs`(那里本来就有
  `suffix_variant`),plan.rs 401 → 373;paths.rs 119 → 157。没有压注释换行数。

P2-1 引擎中间产物会被当成内容提交
- `Staging::purge_engine_artifacts()`:提交前递归剔除 `.ullpart-*` 与 `.ullmeta.json`
  (判据 `is_engine_artifact`,后缀取自 `core::download::atomic`),删掉的个数进报告注记。
- 测试:`StubFetcher::with_engine_artifacts()` 像真引擎那样在每次"下载"后留下残片,
  断言实例目录里没有任何 `.ullpart` / `.ullmeta`。

P2-2 回滚失败被 `let _ =` 吞掉
- 新增 `RollbackOutcome { attempted, failed }`:`rollback_merge` 逐项记账,
  `CommitGuard::rollback()` 返回它,提交阶段失败时 `CommitFailure { error, rollback }`
  把两者一起交回调用方;`ImportReport` 新增 `rollback` 字段,`rolled_back` 改成
  "回滚完整 / 从未写过目标目录"才为 true,不完整时 `false` 并在 warnings 里列失败项。
- CLI 人读输出相应分叉(`回滚未完整:N 项没回去`),不再无条件宣称一致。
- `Drop` 那条兜底路径仍拿不到调用方(文档写明是"防漏"不是"承诺")。
- 单测:`complete_rollback_has_no_failures` 与 `rollback_failures_are_reported_not_swallowed`
  (用非空目录占住目标位置,让 remove/rename 都失败,跨平台确定)。

P2-3 基线不校验包身份
- `PackBaseline::matches_pack(format, name)`:同一目录先后导入两个包时,别人的基线一律
  不拿来当 base(否则"用户改过"会被误判成"整合包升级"而静默覆盖),降级为"无基线"并
  记 `pack.import.note.baseline_pack_mismatch`。
- 测试 `baseline_of_another_pack_is_not_used_as_base`:断言告警 key + 冲突降级为
  pre_existing + 用户内容保留。

门禁(落盘读 $?):fmt 0 / clippy(workspace,all-targets,-D warnings) 0 /
test -p unlucky-core 0(363 lib)/ test --workspace --exclude unlucky-app 0(1993 passed)。
改动文件最大 373 行。core 依赖表未动(core-no-ui 不受影响)。
2026-11-04 20:54:05 +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 78bcc3038b Merge branch 'feat/m102-pagelayout-fix-column-alignment-row' 2026-11-04 20:19:45 +08:00
AskaEth 4b26a0a9dc test(app-iced): M102 r5 行距护栏追绑定(M4 假绿做死)+ 等价重构不再假红(M3/M6)
r4 复审的结论是「产品代码没问题,两条 p1 都在护栏的『抓得住』与实测不符」:

- **M4(真正的假绿,做死)**:`rows = rows.spacing(ui.space(Space::X1));` 只要另起一条语句
  就能绕过「只锁构建链」的旧护栏。现在 `list_column_spaces_rows_only_from_the_shared_constant`
  用 `function_region` 取整段函数,再用 `source::call_arguments` 拿每一处 `rows.spacing(…)`
  的实参,并用新增的 `source::derived_from` 顺 `let` / `Some(x) =` 绑定往回追(最多三跳):
  实参必须追回 `ROW_SPACING`。于是
  · 直接 `ui.space(spacing)`(来自 `if let Some(spacing) = ROW_SPACING`)→ 绿;
  · `let spacing = ROW_SPACING;` 再中转(H3)→ 绿;
  · 先把令牌挪进 `let extra = Space::X1;` 再 `rows.spacing(ui.space(extra))`(M4)→ 红。
- **M3(假红)**:日志接线护栏改用 `function_region`,于是 `wrap::breakable` 外面套一层同义
  包装(`fn soft(…) { mono_owned(ui, wrap::breakable(…)) }`)不再假红;整条链路都没碰
  `wrap::breakable` 照样红。
- **M6(假红)**:新增 `source::region_across`(多文件转调跟踪,`frame.rs` + `layout/block.rs`),
  把盒子原样搬进 `block::body_box` 再转调不再假红;`align_x` 改判**实参**(`Alignment::Start`
  与 `iced::Alignment::Start` 同等放行),于是搬进别的模块后路径写全也不假红。
- **M1/M2 按塔的裁定不加断言**:与 `frame::body` 逐像素等价的另写就是等价重构;docstring 写明
  「抓得住:语义变化;抓不住:逐像素等价的另写」。

最终变异矩阵(本轮自跑、每条跑完 `git checkout --` 回滚、白名单已核):
基线 429 passed / 0 failed;M4 红(只红 list_column_spaces_rows_only_from_the_shared_constant);
M3 绿;M6 绿;G 红(只红 single_column_pages_call_the_body_box_not_the_centred_one);
I 红(只红 the_page_wires_the_soft_breaks_into_both_rendering_paths);
旧 p1 复跑 A 红、D 红,等价重构 J 绿。
2026-11-04 19:53:45 +08:00
AskaEth f499961e5b test(app-iced): M102 r3 护栏补到「页面调用点」——回退 body/软断点接线一律红,等价重构不再假红
r2 复审抓的两条 p1 都是「守卫只到函数内部」:把页面调用点改回 frame::single_column(变体 G)、
或把日志页两处 wrap::breakable 接线摘掉(变体 I),420 条全绿,而视觉复审 ① / ③ 原样回来。
r3 用 r2 自建的 layout/source.rs 把守卫推进到调用点,并把三条 p2 的假红/假强一并修掉:

- 新增 `single_column_pages_call_the_body_box_not_the_centred_one`:四个页面文件
  (download / logs / instances / instances/detail,共 7 个调用点)必须出现 `frame::body(`、
  不许出现 `frame::single_column(`;narrow_detail 单独再断言一次。(变体 G → 红)
- 新增 `the_page_wires_the_soft_breaks_into_both_rendering_paths`:扫 `lines::entry` 与
  `diagnostic::summary_block`,必须含 `wrap::breakable(`(变体 I → 红);
  同时把 `log_text_carries_soft_breaks_but_keeps_the_original` 的 docstring 改成它实际守的东西
  (断点表 + 原文可还原),不再冒充「抓得住接线」。
- p2-1:`frame_body_is_built_from_the_block_quantities` 改用新的
  `source::function_region`(函数体 + 本文件内被转调的函数,最多两层)⇒ 把 body 拆成
  `body` → `body_impl` 的等价重构不再假红(H1 → 绿),换回 centered 仍红(A → 红)。
  顺带修掉 region 的泛型签名 bug:早先拿 `fn {name}(` 当定义 needle,
  遇到 `fn body_impl<'a>(` 会 panic(护栏自己假红);现在按 `fn {name}` 找定义,并补了一条
  `the_region_follows_delegation_into_a_generic_function` 自测。
- p2-2:`list_column_spaces_rows_only_from_the_shared_constant` 的禁词收窄到
  `let mut rows = column(…)` **这一条**构建链(新工具 `source::statement`,停在第一个 `;`),
  于是 `column![head, scroller]` 上加合法间距不再假红(J → 绿),行容器叠间距仍红(D → 红)。
- p2-3:`frame::body` 的 align_x 改成「出现就必须是 `.align_x(Alignment::Start)`」,
  不再被 `.align_y(Alignment::Start)` 顺手满足;「干脆不写 align_x」(默认 Start)视为等价重构放行。
- p2-4(措辞):`layout/block.rs` 文档写明 1440 上限是**有意行为变化**(≥1576 窗宽),
  1180/900 上才是零像素变化。

变异矩阵(本轮自跑,逐条回滚,`git status` 已核):A/C/D/E/G/I 全红且各自只红对应那一条;
H1(body_impl 转调)/H2b(删显式 align_x)/H3(ROW_SPACING 经局部变量)/J(head/scroller 加合法间距)
全绿。基线 `cargo test -p unlucky-app-iced --lib` = 426 passed / 0 failed。
2026-11-04 19:53:43 +08:00
AskaEth 824bb36f8e test(app-iced): M102 r2 护栏改成「抓得住页面换摆法」——块偏移/行距/散文接线全部有源码结构断言
r1 复审的两条 P1 都是假护栏:把修复原样回滚(A:frame::body 换回居中 424;
C:内缩 X2→X1;D:行距加回 4px)后测试仍全绿,因为断言只喂 frame_metrics() 与常量重算,
既不观察 frame::body,也不引用页面常量。本轮把「摆法」做成可断言的东西:

- 新增 `pages/layout/block.rs`:body_width(pane 宽封顶 1440)、body_offset(恒 0 = 贴左)、
  prose_width(= metrics.column ≤424)三个纯量 + 单测。frame::body / frame::prose 从它们建盒,
  frame::body_width 改成 pub use(不另算一份)。
- 新增 `pages/layout/source.rs`(cfg(test)):把某个函数的实现文本切出来。iced 的 Element
  没有几何内省,量不到「块摆在哪」,只能断言「照哪个数建的」;仓里已有 no_hardcoded_tokens
  扫源码、download/tests 用 include_str! 扫 key 的先例。
- 新护栏(每条都做过变异实测,见报告):
  · frame_body_is_built_from_the_block_quantities(A 变异红)
  · frame_prose_is_built_from_the_prose_width(E 变异红)
  · list_column_spaces_rows_only_from_the_shared_constant(D 变异红)
  · instances 几何断言改用页面真源 LIST_BOTTOM_INSET.dp() / ROW_SPACING(C 变异红)
  · the_detail_note_goes_through_the_prose_box(散文接线)
- P2:wrap::strip 的「会删原文自带 U+200B」写成限界 + 一条断言;docs 口径从 layout.rs
  挪到 layout/block.rs(layout.rs 399 → 389)。

零像素变化:改后 9 张图与 r1 的 m102-after 逐张 cmp 完全相同;2560×760 新出三张证明块宽封顶
1440(112..1551)且仍贴左。
2026-11-04 19:53:41 +08:00
AskaEth 89d98184c1 fix(app-iced): M102 页面几何 —— 正文块贴左吃满页宽、列表末行不再被裁半行、日志按词断行
① 正文列与页头不对齐(下载 / 日志):根因是 `frame::single_column` 把**行宽上限 424**
当成**整页块宽度**并水平居中(1180 下 1044 里只摆 424,左缘 422 对页头标题 112)。
现在两个量分开:`layout` 的 `column` 只管成段散文(≤424),页面块宽走新的
`frame::body_width`(= available / main_pane)。新增 `frame::body`(贴左吃满)与
`frame::prose`(散文锁 424)。1180×760 下下载 / 日志的块左缘 = 112 = 页头标题左缘。

② 实例列表末行被裁半行:页级行距 `Space::X1`(4) 让行节奏变成 64、与可视高度除不尽,
且滚动区直贴卡片 1px 圆角描边。行距归零(下载页同改)+ 滚动区底部内缩 `Space::X2`(8)
⇒ 可视区 580−32−8 = 540 = 9×60,Redstone Lab 及其副标题完整可见,第 10 行整体在裁切线外。

③ 日志断行把单词劈开(`graphics.gl_o` / `peration_failure`):新增
`layout::wrap::breakable`,在 `.` `_` `:` `/` `\` 后插零宽空格(U+200B 软断点),
日志行正文与一行摘要都过它;悬挂缩进不变(前缀仍是独立文本控件),
`Line::text` 与 `Logs::summary()`(剪贴板)仍是原文。工具函数放 `pages/layout/`:
断点是「列宽之后」的另一半排版判据,纯函数、可单测、不碰控件树。

护栏:`body_block_starts_at_the_header_title_and_fills_the_page`、
`list_viewport_fits_whole_rows_and_still_overflow`(把 580/32/8/540/60 五个数钉住)、
`wrap::tests`(断点位置 / 不重复插 / 可还原 / 幂等)、`log_text_carries_soft_breaks_but_keeps_the_original`。
`download/tests.rs` 的「宽窗留白」口径按新分工改成「宽度归块、不归散文列」。
2026-11-04 19:53:38 +08:00
AskaEth 8574d6ba2d Merge branch 'feat/m104-agentrail-right-side-agent-rail-thr' 2026-11-04 12:34:21 +08:00
AskaEth 9253684387 fix(app-iced): M104 r2 —— Route::Agent ⟺ Detached 的不变式 + 复审 P3 六条
p2-1(唯一 p2):`Route::Agent` 与 `RailState::Detached` 之前没有绑定——从左侧导航第五项
(本 mission 点名的入口)进独立页时档位还是 Collapsed/Expanded,于是页头出现「Open assistant」
「Collapse/Pop out」、挂着不化的未读胶囊、Esc 也收不走页面(复审用产品 bin 复现)。

- **单一入口**:`App::sync_agent_route()`(update/agent.rs)在 `refresh_view` 开头调用,
  两条对向规则:在独立页 ⇒ Detached(顺带清未读);离开独立页而档位还是 Detached ⇒ 收回右栗。
  每一次路由变化都经过 refresh_view,所以导航 / ULL_START_ROUTE / detach / back 共用这一处。
- **结构性兜底**:`conversation::actions` 先按宿主分流,`Host::Page` 的动作恒为「Dock back」。
- **启动顺序**:`boot_agent()`(窗口尺寸 adopt + 拆出档直达独立页)必须在 `dispatch(RefreshAll)`
  之前——那次 dispatch 会 pump→auto_select 把路由拉到实例详情,同步器随即把档位收回(实测踩到)。
- 测试:`entering_the_agent_route_detaches_the_rail`(走 `Message::Select(NavItem::Ai)` 真路径,
  含未读清零 / Esc 离开 / 从页上点别的导航收回)、`the_page_navigation_entry_also_detaches`。
  变异自证:把 `sync_agent_route` 改成 no-op ⇒ 3 红(含这两条)。
- 图 13/14:导航入口(折叠档带 2 条未读 / 展开档)进页后都是 Dock back、无未读胶囊、副标题单行。

P3-2 Enter/Shift+Enter 有测试了:分流抽成纯函数 `composer::binding_for`,
`enter_sends_and_shift_enter_breaks_the_line` 覆盖「发送 / 换行 / 未聚焦不抢 / 其它键交回默认」;
`AgentUi::edit` 里与 fallthrough 等价的冗余 Enter 分支删掉;三档测试改走 `agent.edit(...)`。
P3-4 自证式断言换字面量(320+8+852=1180 等);变异 RESIZE_GUTTER 8→0 ⇒ 6 红(含这条)。
P3-5 两条死文案真用上:8px 拖拽缝有了 tooltip(AgentResize + AgentWidthLabel「当前多宽」)。
P3-6 `on_boot` 的两块逻辑搬进 `update/agent.rs::boot_agent`:update.rs 372→383(+11,见报告逐行)。
P3-7 拖拽层补 on_press/on_exit:窗外松手不再僵住(窗内按下→清态;离窗→收在当前值)。
P3-9 顺手:只有几何/路由会变的消息才重建视图模型(每个字符、每次指针移动不再重建)。

门禁:fmt 0 / clippy(workspace,-D warnings) 0 / pages_demo 0 / 全文件 ≤400(最大 view.rs 373 之外
conversation.rs 383、update.rs 383)
2026-11-04 00:21:33 +08:00
AskaEth 8db1c5ab16 test(app-iced): M104 数值表补两行(900 Detached = 404、1180@360 = 812)
报告里贴的那张表要和测试里的表逐行对上:新增的两行证明「拆出后中栗拿回整窗宽,实例详情列 404」
与「360 档把实例页挤成单列(812 < 816 的两列门槛)」这两条结论都是断言的,不是手算的。
2026-11-04 00:21:32 +08:00
AskaEth 6eb2846313 fix(app-iced): M104 App::width 改名 rail_width,别撞 tests/no_hardcoded_tokens 的尺寸字面量门禁
那条门禁扫 src/{shell,pages,app},把任何 `.width(数字)` 都当成「布局尺寸写死」拦下来
——测试里 `agent.width(1180.0)` 被误判成控件调用点。改名后语义还更准(它返回的是右栗宽度,
不是「这个控件多宽」)。cargo test -p unlucky-app-iced(含集成测试)418 + 6 全绿。
2026-11-04 00:21:32 +08:00
AskaEth ecb3d08445 fix(i18n): M104 折叠条标签的中文改成「助手」,别让 AgentStripLabel 两套译文相同
chinese_catalog_is_actually_chinese 抓到的(en == zh 就是漏译):`("AI", "AI")` 在 48px 折叠条上
两边都能排下,但中文表里塞一条与英文逐字相同的条目等于漏译。中文用「助手」(12px 下 24px 宽,条上放得下)。
2026-11-04 00:21:32 +08:00
AskaEth c3f20784a8 feat(app-iced): M104 AGENTRAIL —— 右栗三档 + 布局数学 + 独立页复用同一会话视图
用户定案(D16):右侧常驻可折叠、允许拆分成独立页、中栗宽度可调(磁铁吸附)。
本 mission 只做壳:三档状态 + 布局数学 + 一份会话视图;真数据仍归 M103,一行 presenter 都不调。

- 右栗三档:Collapsed(48) / Expanded(280~360,默认 320,锚=令牌 ai_panel_width) / Detached(Route::Agent);
  会话、草稿、未读在切档时一条不丢(拆出去再收回,话说了一半还在)
- 布局数学:中栗 = 窗口 − 右栗 − 拖拽缝(8),下限 472(= 行宽上限 424 + 两侧页边距);
  右栗一展开,页面按更窄的宽度重新排版(收缩而不是溢出);拖拽磁铁吸附到 280/320/360,超上限夹紧
- 独立页与右栗共用 conversation::view 这一份实现(输入=纯数据,输出=agent::Message)
- 控件:多行输入框(Enter 发送 / Shift+Enter 换行,走 text_editor::key_binding 的 modifiers 分流)、
  发送禁用态、流式中断、未读点 + 可读计数、⌄ 收起 / ⇥ 拆出 / ⇤ 收回;Esc 收栗、Tab 焦点环、Enter 激活
- 文案全部进 crates/i18n 的新域 catalog/agent.rs(+ golden 追加 20 行),零硬编码中文
- 取证:ULL_AGENT_RAIL / ULL_AGENT_FIXTURE / ULL_AGENT_WIDTH(截图走产品 bin,标题栏与三键可见)
- 消息不走 pages::Message(外壳级视图),走 app::Message::Agent(..) → app/update/agent.rs
- 删掉 M68 骨架回落(shell::view + shell/fallback.rs):Route::Agent 现在有真页面

门禁:fmt 0 / clippy(workspace,-D warnings) 0 / pages_demo 0 / app-iced lib 418 条全绿
2026-11-04 00:21:32 +08:00
AskaEth 875ac159ee Merge branch 'feat/m103-agentstream-presenter-agent-rail-mo' 2026-11-04 00:21:22 +08:00
AskaEth a2b1a06901 test(presenter): M103 r2 —— 出站脱敏补守卫,另三处站点与 Bearer 右边界
P1(守卫缺失,产品代码是对的):
- 新增 RecordingTransport + outbound_prompt_is_scrubbed_before_it_reaches_the_backend:
  断言真正发给后端的那份 prompt 里没有 JWT(rail 侧也脱敏,「界面干净」证明不了出站安全)
- 变异:删掉 agent_submit 的出站 scrub ⇒ 该测试红(输出见完成报告)

另三处同类站点(r2 P2-1):
- confirm_tool_name_is_scrubbed(ConfirmRequest.tool)
- tool_message_from_the_backend_has_its_tool_view_scrubbed(MessageAppended 的 ToolCallView 分支)
- outbound_error_text_is_scrubbed(Err 文案,provider 401 常回显 key)
- 三条各自变异后都变红

其他:
- bearer_right_boundary_is_pinned(tests.rs):钉住 mask_bearer 的停字符集,收窄成 [' '] 即红
- 文档订正:rail.rs 模块 §2 改成「两道闸门(出站/显示)各守一侧」+ 与 agent 侧的实际关系
  (名单逐字相同、path 语义相同、mask_bearer 是行为不完全等价的自写迁移;重复实现是欠账,
  不读成「已无差异」也不读成「已经漂移」)
- agent_set_draft 与 rail_text 各写明「draft 不在断言覆盖范围」及原因
- tests_red_line.rs 4 行 405 → 380;各文件均 ≤400
2026-11-04 00:21:19 +08:00
AskaEth 06461df91b fix(presenter): M103 r1 —— 脱敏闸门补守卫,P2 五条带走
P1(守卫缺失,产品代码本来是对的):
- 新增 agent/tests_red_line.rs:MessageAppended(绕过 agent_submit)、
  ToolCallFinished(name+summary)、非 JWT 形状的 details(键名 / 嵌套 / 数组 /
  值随 path 敏感)各一条;变异 A/H/C2 复跑确认变红
- tests.rs 拆出红线组(547 → 334 行,单文件 ≤400)

P2:
- scrub_details 补「值随 path 敏感」规则(对齐 agent::redact::redact_args)
- scrub_text 补短 Bearer 掩码(对齐 agent::redact::mask_bearer)
- pending_confirm_forces_busy_without_yolo 先 finish_stream 再断言(不再空过),
  变异 F 现在两条测试同时变红
- MessageDelta 越界下标 debug_assert;ToolCallFinished 没有 pending 时忽略,
  不再改写已收敛的历史条目
- Presenter::state_mut() 收窄成 #[cfg(test)],换成 navigate/back/toggle_ai/
  prefs_mut/agent_set_yolo/agent_set_draft 等窄入口
- agent_submit 成功后立刻 begin_turn() 置忙,事件投影完 settle_streaming() 重算,
  异步后端空窗不会再让用户点两次发送
- 补测:begin_turn 置忙、静默后端不留假忙、工具收尾不覆盖历史
2026-11-04 00:21:19 +08:00
AskaEth a8d4cce3e2 feat(presenter): M103 AI 助手栏视图模型与事件投影
- 新增 crates/presenter/src/agent.rs(Presenter 门面:agent_rail / agent_submit /
  agent_mark_read / agent_confirm + AgentTransport 端口 + 裁决审计镜像)
- 新增 agent/rail.rs:AgentRail(messages / unread / busy / draft / confirm / streaming /
  yolo / endpoint_label / error),四类 AgentEvent 的投影与唯一脱敏闸门
- 新增 agent/mock.rs:脚本化后端(正常问答 / 流式中途出错 / 需确认的写操作)
- dto/agent.rs:ConfirmRequest / Sensitivity + AgentEvent::{Failed, ConfirmRequested,
  ConfirmResolved};AgentEvent 去掉 Eq(details 含 serde_json::Value)
- state.rs:AiSession 迁成 AgentRail,apply_agent 走 rail.ingest + 未读规则(可见性 =
  独立页或栏展开);navigate/toggle_ai 展开时清未读
- 测试:agent/tests.rs 14 条(四条路径 + JWT 红线 + YOLO 语义 + 提交前置条件),
  state_tests.rs 补 4 条(Failed / ConfirmRequested / 未读可见性 / 用户消息不计未读)
- 两行声明补进 lib.rs(pub mod agent)与 dto/mod.rs(重导出 ConfirmRequest/Sensitivity)
2026-11-04 00:21:19 +08:00
AskaEth 15eec1160d Merge branch 'feat/m110-packmodel-canonical-pack-manifest-a' 2026-11-04 00:20:27 +08:00
AskaEth 34f3db359a fix(core): M110 r2 —— duplicate_override_paths 两种冲突同时报,补 mrpack 同侧分支用例
- p2-2:`duplicate_override_paths` 改成「先按 path 分组,再在组内报 (侧集合, 条目份数)」,
  判据 `侧数 > 1 || 份数 > 侧数`。r1 修好了跨侧(Both×Client)但按侧去重把「同侧两个组
  撞同一路径」这条旧能力换掉了(实测返回 []);现在两种冲突同时报得出来,返回值升为
  `Vec<(String, Vec<PackSide>, usize)>`(M111 注意:这是最后一次改形状的窗口)。
- p3-a:mrpack `check_overrides` 的「每侧只能有一个覆盖目录」分支补用例(此前零测试,
  删掉它 54 条全绿;现在删掉即红)。
- p3-b:把 ReadBudget 的「每次调用一份额度,不是整包一份」写进 `override_files` /
  `ReadBudget` 文档(mrpack 连调三次 ⇒ 理论上限 3×;要整包共用额度就走
  `override_files_with`;`open` 阶段的声明总量仍是整包一次)。
- 顺带(复审点名、各一行):mrpack 非表达目录的 hint 里 21 个连续空格;
  curseforge 文档里 `.minecraft/.minecraft/…` 的说法按 CF 语义订正(overrides 是 zip 内前缀,
  内容铺开不丢;要不要多拷一层取决于导入器)。
2026-11-04 00:20:25 +08:00
AskaEth 6dd652aced fix(core): M110 r1 复审修复:导出安全闸、跨侧重名、第二检用例、半包与失败分级
按 tower 裁定清单逐条改,不扩战场(模型与扩展点未动)。

- p2-1 跨格式导出不再静默丢内容:`PackFormat::check_overrides` 在默认 `write` 写第一个
  字节前判定"这份模型的覆盖目录集合能否被本格式表达"。mrpack 要求目录名由 side 决定
  (overrides / client-overrides / server-overrides)、CF 只接受单一 Both 侧组;表达不了
  即 `pack_unsupported` + hint。`format.rs` 模块文档写死"默认 write 只保证同格式往返",
  并列出 M112 必须补的四件事(目录映射、side 合并、加载器 id 规范化、extra 分桶)。
- p2-2 `duplicate_override_paths` 改为按 `path` 跨侧计数,返回 `(路径, 侧集合)`;
  Both×Client 同路径这条 mrpack 真实冲突现在查得出来。
- p2-3 补 `#[cfg(windows)]` 第二检用例(CON / NUL / COM1.txt / `a.` / 尾空格 / 通配符),
  并订正 `archive.rs` 文档里"保留设备名这里不拦"的自相矛盾(实际由 enclosed_name 在
  Windows 宿主上拦,第一检放行)。
- p3-1 导出改临时文件 + rename,失败清理临时文件:目标路径不再留半个 zip。
- p3-2 三个 `detect` 不再 `.ok().flatten()` 吞错误:清单在但读不出来 = `pack_metadata`;
  注册表汇总时"命中优先于失败",一个都不命中才报,最后才退化成 `pack_unsupported`。
- p3-3 `env_extra`、CF `files[].extra`、`PackLoader::extra` 各补一条读→render→写→再读用例。
- p3-4 新增 `ReadBudget`(Cell<u64>):`override_files` 持有累计预算,按实际读出字节
  逐条扣减,声明大小撒谎的包不能靠"很多条各自合规"绕过 `max_uncompressed_bytes`。
- CF 读侧始终产出一个 Both 侧 overrides 组(字段缺省 `overrides`),与 render 对称。

每处修复都有变异证据(改坏 → 对应用例红):p2-1/p2-2/p2-3/p3-1/p3-2/p3-3a-c/p3-4 共 9 条。
门禁:fmt 0 / clippy --workspace --all-targets -D warnings 0 / cargo test --workspace -j 4
exit 0(58 个 test binary,passed 1930,failed 0);全部改动文件 ≤400 行(最大 389)。
2026-11-04 00:20:24 +08:00
AskaEth b34e86bba5 feat(core): 整合包格式无关模型与格式扩展点(M110)
从零建 `core::addon::pack`:格式无关中间模型 + 格式扩展点 + 安全读取层,
不碰网络、不碰 UI(core-no-ui 依赖表未变)。

- `PackManifest` 无损表达 CF / mrpack / MultiMC 三方字段:包名/版本/作者/简介/
  格式版本/游戏版本/加载器依赖/文件条目/覆盖目录;`files[].hashes` 保留整张摘要表
  (mrpack 允许 sha1+sha256+sha512+md5 共存),`disabled` 记语义(导出按 D10 映射成
  env.client=optional,CF 落 required=false)。
- 未知字段按条目用 serde flatten 接住(清单顶层 / CF `minecraft` 对象 / 文件条目 /
  env / 加载器条目),导出时并回且已知字段优先;两格式各有一条「读→写→再读相等」测试。
- `trait PackFormat`(id / label / metadata_entry / detect -> Option<证据> /
  read / render / 默认 write)+ `PackRegistry`:注册表顺序即优先级,写死
  CurseForge → Modrinth → MultiMC;同一份包命中多格式时 `also_matched` 暴露歧义。
  测试里用一个 toy 格式(单文件、一行注册)证明扩展点真的可扩展。
- `PackArchive` 把目录与 zip 统一成规整条目表:路径穿越双检(自研七条规则 +
  zip crate 的 enclosed_name)、符号链接条目拒绝、条目数/解压体积/单条目上限、
  规整后重名拒绝、篡改载荷不当场吞掉;每条都有恶意样本测试。
- 格式实现:CF 可读写(缺路径如实记 None)、mrpack 可读写(强制 sha512 形状、
  env 三态、三个覆盖目录成三侧)、MultiMC 只读(.minecraft 当覆盖目录,
  组件顺序与非加载器组件全保留)。
- 三种格式各一个手造夹具(含截断 TOML / 半截 jar / 零字节 jar),来源在
  `tests/fixtures.rs` 里声明为手造,不冒充真实导出。
2026-11-04 00:20:24 +08:00
AskaEth 40a4c06d42 Merge branch 'feat/m118-slintexit-drop-legacy-slint-crate-f' 2026-11-04 00:09:41 +08:00