Commit Graph
46 Commits
Author SHA1 Message Date
AskaEth df331f6a60 feat(m175): Java 设置页接真运行时(扫描 / 手动路径 / 默认 JDK / 内存 / JVM 参数)
用户实测四条抱怨逐条对应:
- 「没有自动检测本机 Java」:首屏自动扫一次盘(托管根 + JAVA_HOME + PATH + 标准安装位置 +
  注册表),之后由「立即扫描」手动触发;扫描在后台线程里跑,带 done/total 进度与取消
- 「显示 Temurin 21.0.4 不知道是不是硬编码」:演示行与绊线测试一起删掉,列表来自真读盘
- 「没一个能改」:默认内存(MB,512~65536,范围与 schema 同源)与 JVM 参数可真改、真落盘、读回
- 「不能手动指定 Java 路径 / 不能改默认版本」:手动添加走严格校验(真跑 java -version),
  设为默认写 java.selected_path 并给出「为什么选它」(当前实例的版本门槛 + 每行满足与否)

core:java::manual(严格探测 + 厂商识别 + 名单持久化)、java::staged(候选收集 + 带进度与
取消的扫描)、java::proc(带超时且可打断的子进程执行:卡住的 java -version 会被 kill)。
i18n:catalog/settings/java.rs 55 条中英 + 金表按装配顺序插入。

复审 r1(p2-5items)全修:
- P2-1 「设为默认」不再清脏:只写 java.selected_path,内存/JVM 草稿一个字节都不动
- P2-2 错误文案全部走 i18n(三档「写不进去」/「路径太长」/「名单写不进去」各自一个 key),
  不再把 Rust 枚举名当人话
- P2-3 手动名单落盘有正面断言(加一条 → 从盘上新读一份 → 在)
- P2-4 版本号显示到小版本(17.0.13 / 17.0.9 可区分),解析不出来时给原始版本串
- P2-5 每候选 8 秒超时并 kill、取消打断正在跑的探测、扫描槽挂在 State 上、线程活着期间
  不许起第二个(界面显示「正在取消…」)

复审 r2(p2-2items)全修:
- P2-1 空态不再指向不存在的「Java 页」,改成真实存在的 `ull java install 21` 命令行入口
- P2-2 `auto_scan_due` 增加 `!report.cancelled`:取消过一次之后本进程不再自动扫,
  配置文件再变(存内存 / 改主题)也不会把取消无声撤销;手动「立即扫描」不受影响
- 顺带:proc 的 kill+wait 补「子进程真死了」的回归守卫(标记文件从未出现)、
  core 的 Schema 错误改成页面自己的 key(不再把中文 Display 直出给英文界面)、
  默认态 chip 用新的 `Chip::badge`(不再挂一条永远收不到的消息)
2026-11-08 19:52:04 +08:00
AskaEth 93d02ab12b fix(m174): 复审 r1 五项 —— 失败带 target、密钥消息掩码、请求体断言、桥写前重读、结构化分类
P2-1(假绿截图):「测试连接」失败类也带上被请求的 target。`ProbeOutcome` 的五个失败变体
改成 `{ target, detail }`,`ProbeResult::from_outcome` 不再把端点/模型填成空串;view 另加一条
「目标为空就整行不画」的保险。**截图种子改走 `from_outcome`**(新增
`ProbeResult::screenshot_seed`):种子只伪造「结论是哪一档」,字段形状由产品路径决定——上一版
手填字段,于是「失败态目标为空」只在产品上出现、截图永远看不见。

P2-2(明文进日志):`Message::EditKey` 的载荷从 `String` 换成 `ai::Secret`(`Debug` 掩码)。
用例 `the_key_message_never_prints_the_key` 同时钉住裸消息与整条 `pages::Message` 漏斗。

P2-3(模型没被证上线):假服务器按 `Content-Length` 读请求正文,新用例
`the_request_carries_the_configured_model` 断言正文里 `"model":"gpt-test"`。变异实测暴露出
一处真相:`ChatRequest.model` 被 `RigProvider::build_request` 静默忽略,线上模型来自
`ProviderConfig.model`(已另开 finding,并写进 probe 的注释)。

P2-4(数据丢失):`SettingsBridge::persist` 写前 `store.reload()`。`SettingStore::save` 是整份
文档落盘,不重读就会拿 ≤1 秒前的旧快照盖掉刚写的 `ai.*`。两条回归用例:
`presenter::settings_bridge::persist_keeps_keys_another_writer_just_saved` 与 app-iced 侧的
`ai::store::appearance_write_back_keeps_the_ai_keys`(保存 AI → 改主题 → 值还在)。

P2-5(子串匹配):删掉 `classify()`。分类改由 `brain::rig::map_error` 从 rig 0.45 的结构化
报告(`ErrorReport` 的 `kind` / `http_status` / `code`)翻译成新的 `ProviderFailure`
(`brain/provider/failure.rs`),`ProviderError` 因此带上 `kind`。`closed_port_is_unreachable`
不再依赖错误文本:连接被拒恒为 `ErrorKind::Http` ⇒ unreachable。防退化用例
`factory_error_carries_the_structured_classification` 断言「纯文本 401 不许被猜成 auth」。

顺带(400 行硬线):`provider.rs` / `rig.rs` 的用例各自拆到 `provider/tests.rs` /
`rig/tests.rs`(纯搬移)。
2026-11-08 16:20:34 +08:00
AskaEth 9cb605f316 feat(m174): 助手(AI)提供商配置真接线 + 「测试连接」
设置页新增第 6 组「助手」:提供商四态 / 端点 base_url / 模型名 / API 密钥(默认掩码,
点「显示」才明文)+ 保存 + 测试连接,四类失败(鉴权 / 模型不存在 / 不可达 / 超时)各自
一句文案 + 目标与耗时。

取舍三条:

1. 读写走 `core::setting` 的 schema API(`SettingStore::load` 现读 → `set` → `save`),
   不经 `AppCommand::SetSetting`——那条指令是可序列化 DTO,`ai.api_key` 走它等于让明文进
   事件流/日志。与 `SettingsBridge::persist` 写外观是同一条做法。
2. 「测试连接」不另写一套 HTTP:`unlucky_cli::commands::agent::probe` 现读设置文件、
   交给 `RigProvider`(真 provider)发一次最小补全,带 8 秒时限;分类只看错误文本
   (rig 只给 Display),四类各有一条真 TCP 假服务器用例钉住。
3. `ProviderKind` 补 `from_setting` / `setting_value`:serde 的 `rename_all = snake_case`
   产出 `open_ai_compatible`,与 schema 的 `openai_compatible` 不是同一个词——这正是
   M174 之前「agent 侧一次都没读过设置」的根因。

守卫(都做变异验证,见 mission 报告):密钥不出 `Debug`/`detail`/DTO;未配置时工厂构造
计数为 0;改完 base_url 后探测打到新地址;空/非法值被 schema 拦下且文件不动。

i18n:49 条中英各一(金表按 `Key::ALL` 位置插入);presenter 的 ai_group 补上 schema 允许
的 `disabled` 选项与一行「已配置 N 字符」的只读状态(仍然不显示值)。
2026-11-08 15:57:25 +08:00
AskaEth b9009c0e61 fix(m168): 复审 r2 三项 —— 夹具不再空转、启动不再替英文用户下载、时限不再判慢线下不完
复审 r2(p2-3 / fix-then-merge)三项:

1. **P2-1 爬行服务端是空转的**(`font/tests/support.rs`,从 `tests.rs` 拆出守 400 行):
   `seen.windows(4).any(|w| w == b"\n\n")` 拿 4 字节窗口比 2 字节切片 ⇒ `PartialEq` 先比长度 ⇒
   恒 false ⇒ 请求头永远读不完、一个字节不发,用例证明的其实只是「连接挂着被掐断」。上一版
   这里是 Python 生成时把 `\r\n` 落成了真实换行字节。现在两处都是转义写法
   (`b"\r\n\r\n"` + 完整 CRLF 响应头),并且**把「夹具真的在说话」变成断言**:
   `serve_trickle()` 返回 `TrickleProof { header, dripped }`,用例断言「真的发过头」「真的挤过
   字节」——否则夹具再坏一次也会绿。拆成两条用例:`a_trickling_candidate_is_cut_by_the_attempt_deadline`
   ([爬行, 正常],300 ms 切断第一个后第二个成功,0.38 s)与
   `the_total_deadline_cuts_a_single_crawling_candidate`(单候选 + 400 ms 总预算)。
   变异证明:把服务端改成「一个字节都不发」⇒ 两条用例红在「爬行服务端必须真的发出响应头」。

2. **P2-2 启动替英文用户下 17 MiB(真行为 bug)**:
   - `fonts::resolve_with` **不再触发下载**(启动那一拍只报告判据)。唯一的触发点收在
     `fonts::cjk_for`(`ui/lang.rs::log_cjk`),那一拍的 `setting` 与 `LangChoice` 同源
     (ULL_LANG > 配置 > 系统 locale)= 「界面实际用哪种语言」。
   - `plan` 把 `is_downloading()` / `Ready` 的闸提到**所有策略之前**:上一版只在 `NeedsDownload`
     分支里,"下载中 + Unprobeable" 会把状态改回「无法探测」而线程还在飞,下完又翻回「已就绪」。
     当时那条理由(「否则界面停在正在下载」)只对僵死成立 ⇒ worker 现在用 `catch_unwind`
     兜底「任何 panic 都写终态」,闸才敢前移。
   - 出图通道:新增 `install::set_offline(true)`(`App::with_presenter` 在
     `options.screenshot.is_some()` 时调),`plan_with_offline` 在 offline 下永不 `Start`。
     于是 `pages/harness/app.rs` 与 `--screenshot` 都不会在 zh-locale 无字体机器上真下 17 MiB。
   - 被钉住的旧判据(`Unprobeable + Downloading ⇒ Apply`)改成 `Skip` 并写明旧行为为什么是错的;
     新增 `every_strategy_skips_while_a_download_is_in_flight`(四策略打表)与
     `the_screenshot_channel_never_starts_a_download`。

3. **P2-3 90 s 单次上限把「慢但能下完」变永久失败**:改 `ATTEMPT_DEADLINE=120 s` /
   `TOTAL_DEADLINE=360 s`,并且**最后一个候选拿整个剩余总预算**(前 n-1 个仍 `min(单次, 剩余)`)。
   算账写在常量文档里:17 772 300 B / 360 s ≈ 49 KB/s,比 r1 实测最慢那条 IPv6(67~77 KB/s)
   还低一档 ⇒ 慢而能下完的线路仍有一次成功机会;健康线路(1.1~16.6 s)在第一/第二个候选就结束。
   代价(最坏 6 分钟)也是刻意取舍,写在同一处。

顺手:`i18n::CjkFont::source` 的「尚未钉死」改成指向 `Source::Pinned`;`clock.rs` 与
`general_section.rs` 注释里的 17.0 MB → 16.9 MB。

验证(`CARGO_TARGET_DIR=C:/Temp/ull-target/wt-168`,全量重编;取退出码,不截断):
- `cargo test -p unlucky-core --all-targets` → **16 个 target / 1189 passed / 0 failed / exit 0**
  (lib 625 + setting 115 + 其余 14 个)
- `cargo test -p unlucky-i18n` → 31 passed / 0 failed / exit 0
- `cargo test -p unlucky-app-iced`(含 `no_hardcoded_tokens` integration)→ **693 passed / 0 failed / exit 0**
- `cargo clippy -p unlucky-i18n -p unlucky-core -p unlucky-app-iced --all-targets -- -D warnings` → exit 0
- `cargo fmt --all -- --check` 干净;全仓受检文件 ≤400(本分支最大 361)
2026-11-08 14:38:25 +08:00
AskaEth 449ded2cb7 feat(m168): 中文字体 CDN 下载真接线(D28 后半)
M167 调研的结论是「中文字体 CDN 下载至今未实装」:`i18n::font` 只有 `Fetcher` trait 与
假实现,`needs-download` 分支什么也不做。这条把它接成一条真链路。

判据 → 下载 → 注册(一条链,四个 crate 各管一段):

* `unlucky_i18n::font`(零依赖):`Source::Pinned { urls, sha256, bytes }` 把「下什么」
  钉死——google/fonts 的 `NotoSansSC[wght].ttf` 钉到 commit `2894aab…`(不是 `@main`),
  17 772 300 B / sha256 `a3041811…`。可变字体一份覆盖 wght 100–900,省掉三份静态子集的 25 MiB。
  `FetchError` 补第三档 `Disk`(网络 / 校验 / 磁盘,UI 按类别选词)。
* `unlucky_core::download::font`(新):缓存命中 → 共享引擎 `download_bytes` → 复验(sha256 +
  sfnt 魔数 + 32 MiB 上限)→ 原子落盘。**复验在写盘之前**,所以「哈希不符」时盘上什么都没有;
  缓存按 `<sha256 前 16 位>` 命名,pin 一换自动换名。
* `unlucky_app_iced::fonts::{cdn,install}`(新):`CdnFetcher` 把 pin 接进 core(`ui.font_cdn`
  可覆盖 URL,哈希不换);`install` 是状态机 + 后台线程,只有 `NeedsDownload` 才起线程
  (英文 / 系统已有 / 探测不出一律零网络)。注册走 `iced_graphics::text::FontSystem::load_font`,
  族名从字体库读回来核对。失败只降级(继续用系统字体),不阻塞、不崩。
* 接入两条路径:`fonts::resolve_with`(启动)与 `fonts::cjk_for`(设置页切语言)都调
  `install::after_probe`;设置页 General 组多一行状态(下载中 / 完成 / 失败 / 无需下载 + 三类失败
  原因),文案进 catalog 金表(链尾追加,既有顺序不动);`App::advance` 每秒比对状态快照后重建
  视图模型,所以那一行会自己刷新。

配置:新增 `ui.font_cdn`(`keys/font.rs`,schema 1.7.0),空 = 内置 jsDelivr 镜像(含备用边缘)。

守卫(三条都能变红,变异证明见报告):core 侧 8 条(缓存不联网 / 哈希不符拒落盘 / HTML 不当字体
/ 坏缓存自愈 / 上限 / pin 校验),app 侧 6 条(三种闲置策略零调用、失败不注册、类别不串、幂等、
截图钉子、单行日志),页面侧 1 条(四态 × 两语言无缺译、不写假百分比)。

测试:`cargo test -p unlucky-i18n --lib` 31 绿;`-p unlucky-core --lib` 622 绿;
`-p unlucky-app-iced --lib` 684 绿;`cargo fmt --check` 干净。截图三张见报告。
2026-11-08 14:38:23 +08:00
AskaEth b394316df4 fix(m162-r1): 叶子成因穿出引擎 + 安装路径切 core 在线源 + 详情迟到结果闸门
P1-1(p1-1items 的根因):
- download/error.rs:`DownloadError` 增 `leaf: Option<DownloadErrorKind>` 与
  `effective_kind()`;`leaf_of()` 汇总规则只此一处:全 404 ⇒ NotFound、有
  Network/Timeout ⇒ Network、全 5xx ⇒ ServerError、混合按「网络错 > 5xx > 404」
  取最可诊断的那个(**不从 suppressed 文本猜状态码**)。
- download/bytes.rs:候选源循环收集逐源 kind,收尾时把叶子挂到 AllSourcesFailed 上。
- online/transport.rs:`From<DownloadError>` 改用 `effective_kind()`,于是
  NotFound/Server/Integrity 在生产路径可达(项目被删/id 拼错/限速不再画成「未联网」)。
- import/fetch.rs:新增 `FetchErrorKind::Offline`(真正的「没联网」)并与 Network 分开;
  `From<DownloadError>`/`From<OnlineError>` 都按叶子/大类映射。
- import/complete.rs:补全只在 `Offline` 时早停;404(真实 mrpack 大多托管在 CF CDN,
  Modrinth 必然 404)与 5xx 只影响那一条,不再掐掉整轮;注记「因未联网提前停止」因此
  只会在真断网时出现。

P2-1:presenter 安装路径不再自己拼 Modrinth URL / 自己解析 JSON——改用
`core::addon::online::Modrinth::versions` + `primary_file()` + `VersionFile::sha1/sha512`;
`HostTask::InstallDone.result` 改成 `Result<String, ContentFailure>`,
`InstallProgressView` 增 `failure_kind`(离线 ≠ 查无此项)。

P2-2:`app/update/content.rs` 把被详情方法抢走的「装入闸门」注释还回
`content_install_request`,详情方法只留自己那一行。

P2-3:`ContentState::apply_detail/fail_detail` 带上 project(事件与 HostTask 都携带请求时的
项目 id),迟到结果一律丢弃;`request_detail` 记下在飞项目,`detail_pending` 不再被迟到
结果清掉。

顺手项:`usable_hash` 让形状非法的那一档摘要被**丢弃**(sha512 仍校验,不再整条跳过;
两档都不可用才拒绝);`digest_file` 的 IO 失败先删落盘文件再返回。i18n 的
ContentOfflineBody 改成不暗示「有缓存」的措辞(golden 同步)。

用例:download/error.rs 叶子四档 + effective_kind;online/tests/classify.rs 5 条
(AllSourcesFailed+leaf 404 ⇒ NotFound、无叶子 ⇒ NotConnected 保守、叶子一一映射、
单源自身 kind、导入侧 404≠Offline);import_online 增「404 不停」「真离线才停」
(共 7 条);download 增「sha1 形状非法仍用 sha512 校验成功」;presenter 增详情迟到闸门。
2026-11-07 17:28:36 +08:00
AskaEth 81278f55af feat(m162): Modrinth 在线内容源(匿名)+ 远端条目按 sha512 补全 + 离线态
core:新增 addon::online —— 按 Modrinth v2 真实形状建模(search facets / project /
project versions / version_file by hash),网络出口只有 Transport 一个 trait,生产实现
EngineTransport 走既有 DownloadEngine::download_bytes(零新依赖);错误分大类,网络类
失败映射成 NotConnected(= 未联网),不再用空列表冒充「没有结果」。

core:online::download::download_verified —— sha1 交给引擎流式校验、sha512 落盘后复核
(download::HashAlgo 没有 Sha512,改它超出 scope,已登记 finding),不符/失败删文件重试,
最多 3 次尝试且不留半截文件。

core:远端条目补全 —— PackFetcher::modrinth_file_by_sha512(默认 Unsupported = 补不出),
import 管线与新增的 scan_pack_online 都会在「有 path 但缺 hashes.sha1」时按 sha512 反查;
补出的 sha1 走 M160 同一套 judge 与合并判定,补不出仍进 unknown_remote 计数(口径未改)。
DownloadEngineFetcher 改用 download_verified,FetchRequest 增 sha512。

presenter:Modrinth 检索/详情改由 core 的 online 客户端提供(不再自己拼 URL、不再读错
date_modified);ContentFailure{kind} 取代裸字符串,无网 = Offline;新增
ContentDetailRequest/ContentDetail + LoadContentDetail 命令与 CoreEvent::ContentDetail 通道;
预检改走 scan_pack_online;内容库安装也走 download_verified(sha1+sha512)。

app-iced:Status::Offline 独立一态(未联网 ≠ 空列表 ≠ 普通失败),详情面板消费新 DTO
显示可用版本;选中项目即拉详情。i18n 新增 6 个键并同步金表。

测试:online 模块 24 个离线用例(录制 JSON + 假字节:搜索/详情/版本/按摘要反查/校验失败
即删/重试/离线分类/补全),import 侧 6 个(离线计数不变、补全后判冲突、补错摘要 ⇒ Failed),
presenter/app-iced 各补失败分类与详情状态机用例。
2026-11-07 17:28:36 +08:00
AskaEth 03741ecd2d fix(m161): 收更新键/按钮/矮窗口/启动错误/导入结果五笔债
① update.auto_check / update.channel 登记进 core setting schema(1.6.0)
   - 新增 setting/keys/update.rs(两键 + UPDATE_CHANNELS,只允许 stable);
     SettingSchema 加 with_all 供域键表批量登记。
   - 新增 setting/update_prefs.rs:cache/update/prefs.json → launcher.json 的一次性迁移。
     四条硬约束:源指纹闸门(幂等)、不丢 false、新值胜出、旧文件改名 .migrated 后不再被读;
     主配置不可写时不动旧文件。收据 config/update-prefs-migration.json。
   - presenter Runtime 建会话时迁一次并把结局留在 migration();prefs 字段换成
     auto_check + 每次派发从主配置读 channel(不再硬编码 Channel::Stable)。
     保留旧字段 auto_check 的真实来源(原有测试 cases 会断言)。
   - CLI config get/set/dump 先跑同一次迁移(幂等),输出里给 migration 段与收据路径。

② presenter/dto/events.rs 的「本次会话不再弹提示条」注释改成真实语义
   (记的是版本号:同版本压住、更高版本照弹、手动检查清掉)。

③ 检查/下载进行中按钮禁用:pages/update/busy.rs 的 busy/can_check/can_apply;
   设置页两颗按钮改画禁用(不是消失),app 层 on_update_action 再挡一道
   (忙碌时 CheckNow/Apply 不派发);新增两条用例(含「忙完恢回可点」)。

④ 内容库低高度:content_layout::MIN_BODY_HEIGHT=460 + BodyMode(按固定件+三整行算出来,
   用例守着算式);frame::body_height 从 Ui::metrics 算正文可用高,pages::Model 带 window_height。
   ≥460 列表自己滚;<460 主列整列一个滚动条、列表按自然高度铺开。

⑤ 启动错误文案本地化:presenter 删掉 launch_summary / 「启动失败:」前缀 / 五条中文阶段前缀,
   改发 LaunchFailureView{stage,detail};LaunchFinished 改带 exit_code/uptime_ms/failure,
   Toast 由 app 层按 i18n key 现求(与 install_cancelled 同构)。InstancesFailed 拆成
   InstancesFailedStage(列表单行)+ LaunchStageFailed(详情/Toast 全文)。

⑥ 导入结果块:ImportReportView 加 failure_stage(NotStarted/RolledBack/RollbackIncomplete,
   由 core 的 rollback 机器位推出);标题、图标、底色三处分档,预检失败不再说「已回滚」。

顺带:六处注释/文档漂移(update.rs 的分工表与偏好落点、pack 的「已整包回滚」、
build_request 的 Errors、settings/update 的按钮禁用说明等),
以及为守 400 行把 update/cli-config/core-keys/presenter-dto/presenter-launch/i18n-golden
里贴线或越线的文件按模块拆开(纯搬移,行为不变)。
2026-11-07 15:02:13 +08:00
worker-modpackpick d568cc6f37 fix(m160-r1): 预检失败不再静默空转 + 写侧条目数闸 + 判据归一 + 冲突页对齐
复审 reviewer-modpackpick-r1(p2-1items / fix-then-merge)的返工。

P2-1 预检失败被吞掉(错误呈现回归)
- host::import::spawn_scan 在 `scan.error.is_some()` 时**再发一条 PackEvent::Report**
  (`convert::failed_report_of` 把 core 的 ImportError 含 hint 逐字段搬过去),于是页面回到
  既有的「致命 —— 已回滚」结果块,不再落回入口态。
- convert 里把失败报告的组装收成一处(`failed_report` / `failed_report_of` → `failed_report_with`)。
- 证据:`a_failed_scan_still_reaches_the_screen_as_a_report` 断言**事件序列**
  `["scan-with-error", "report-failed"]` + 报告里 code/message 与预检同源(MultiMC
  `formatVersion: 2`,detect 认得、read 才失败);`a_clean_scan_does_not_emit_a_report` 是对照。
  变异验证:删掉那条 Report 发送 ⇒ 该用例红。

P3-1 写侧补条目数闸
- `WriteLimits` 增加 `entries`(生产口径 = `PACK_MAX_ENTRIES`);`preflight` 在**读盘之前**
  按「清单 1 条 + 覆盖文件 N 条」判 `count > limit`。
- 证据:`the_write_side_refuses_a_pack_over_the_unified_entry_count`(20001 条声明只放 1 个
  真文件 ⇒ 报 `pack_limits` 而不是 `pack_io`,即闸在读盘前)、
  `the_entry_count_gate_is_the_same_number_as_the_read_side`(两侧同一个数)。
  变异验证:注释掉 `guard_entries` 调用 ⇒ 前一条红。

P3-2 「内容相同」判据归一
- 删掉 `scan::judge` 里与 `merge::decide` 重复的 `disk.sha1 == pack_sha1` 早退,只留
  `decide().reason?`;两处文档互相指路(`merge.rs` 注明这是唯一判据)。
- 变异验证:把 `decide` 的相同分支改成"冲突" ⇒ **8 条**红(含 4 条 scan 用例与 merge 单测)。

P3-3/P3-4 口径与注释
- `TogglePick` 注释改成与代码一致(保留项留在表里,靠 `decisions()` 过滤)。
- `unknown_remote` 的计数与文案统一:判不了的两种来路(补全前没路径 / 有路径但缺 sha1)都算,
  注记载明两者;新增 `a_remote_entry_with_a_path_but_no_sha1_is_counted_as_unjudgeable`。

视觉(tower 看图给的三条)
- 冲突行两种取值都改成**同几何的 pill**(保留=描边、覆盖=tonal),各自右对齐进同宽度的槽 ⇒
  右边缘同一条线;路径列吃 `Fill` 把槽推到行右缘。
- 列表与动作行之间加 8dp 呼吸;分页行的 Previous/Next 用 `Space::Fill` 顶到右端,
  与内容列对齐(不再是右侧一大片留白)。
- 重新出图:C:/Temp/m160-shots/pick-light.png / pick-dark.png。

顺手
- `format/write.rs` 的单测拆到 `format/write/tests.rs`(纯搬移,守 400 行),并把里面两个
  自己拼 `%TEMP%` 路径的用例改用 `TempRoot`(断言红了也不留垃圾)。
- `import_decisions.rs` 的夹具拆到 `tests/decisions_support.rs`(纯搬移,守 400 行)。

证据:fmt 干净、clippy -D warnings 干净、`cargo test --workspace --all-targets -j 4`
= 2512 passed / 0 failed / **4 ignored**(51 个 test binary);跑前/跑后 `%TEMP%` 的
`ull-pack-*` 计数 1922→1922、`ull-pack-*-zip64-*` 0→0、`ull-import-*` 1360→1360(增量 0),
df 106G→106G 余量不变。
2026-11-07 13:52:44 +08:00
worker-modpackpick 4defc41e8d feat(m160): 整合包逐文件覆盖/保留决策 + 导入前冲突清单 + 限额口径统一
主任务(逐文件覆盖/保留)
- core: 新增 import/scan.rs —— 只读预检产出「冲突清单」(目标已存在且 SHA-1 不同才进),
  逐条给路径/来源/两侧体积与哈希;用户删过的、内容相同的、被叠层遮蔽的一律不打扰。
- core: 新增 import/decision.rs —— ConflictDecisions(整批档 + 逐项表,逐项优先),
  缺省档恒为 KeepUser;ImportRequest.conflict_policy 改由它承载,CLI 调用点不变。
- core: phase.rs 的 settle 按 path 取决策;非冲突路径不受决策影响。
- presenter: ImportScanView / ImportConflictView / ConflictDecisionsView(全部保留 /
  全部覆盖 / 逐项{default,items})+ PackCommand::Scan / PackEvent::Scan;host 新增只读
  spawn_scan,且新实例路径不再提前建目录(target_of 只算路径,prepare_target 才建)。
- app-iced: 导入前冲突清单页(分页 40 条/页、三入口、计数摘要、确认/取消、两行式行布局);
  预检干净时直接往下导,不打扰用户。出图见 C:/Temp/m160-shots/{pick-light,pick-dark}.png。
- i18n: 新增 18 条 en/zh 文案 + 更新「两者都留」那条口径说明。

限额口径统一
- pack/archive/limits.rs 成为唯一来源:PACK_MAX_ENTRIES/UNCOMPRESSED_BYTES/ENTRY_BYTES
  (20k / 512 MiB / 64 MiB);读侧 PackLimits::default 与写侧 WriteLimits::production 同源。
- 写侧不再直接用 zip::ZIP64_BYTES_THR;新增单条目闸,于是「能导出的包一定能导入」。
  不等式在 pack/tests/limits.rs 里用 const 断言钉死。

测试临时物泄漏(塔台追加项)
- addon/temp.rs:TempRoot —— Drop 即删(panic 展开同样删),Deref/AsRef 到落点路径,
  under()/at() 支持"子树落点、删父根"。形状照 core::update::tests::support::TempDir。
- addon::pack 与 addon::tests 两族全部改用 TempRoot(含 fixtures::Fixture 持守卫、
  multimc/fabric_instance 等返回守卫);4 GiB 稀疏文件的语义一字未改,只改"谁负责删"。
- 自证:pack/tests/mod.rs 三条用例(drop 删 / panic 也删 / 子树删父根)。

测试
- import_decisions.rs(11 例):预检清单、不打扰、逐项决策后的最终文件集、基线记包内哈希。
- roundtrip_three.rs(2 例):叠层 + 冲突夹具 import→export→import 三轮文件集逐字节一致,
  以及「选保留就按设计分叉」的反向证据。
- pack/tests/limits.rs(5 例):单条/总量边界、读侧用同一批常量、写出即可读回。

证据:cargo fmt --check 干净;cargo clippy --workspace --all-targets -D warnings 干净;
cargo test --workspace --all-targets -j 4 → 2507 passed / 0 failed(51 个 test binary)。
2026-11-07 13:52:43 +08:00
AskaEth 1c8f6aa1b3 feat(m159): 自更新 GUI 客户端接线 + CLI update check/apply 同一内核
D50 的「检查 → 提示 → 可拒绝 → 下载校验 → 下一次启动生效」整环接线,GUI 与
CLI 共用 core 的一条内核(`update::Job`),并顺带修掉 M157 最后一轮复审的三处 P3。

内核(core::update)
- 新增 job.rs:`Job { source, target, staging, dest }` + `check` / `apply`(复查 →
  取包 → 长度/SHA256 双检 → 原子替换),`default_source`(GitHub,`ULL_UPDATE_DIR`
  可切本地目录夹具)、`process_dest`、`staging_dir`。
- `mod.rs` 导出 job 一族;`source.rs` 为 `Box<dyn UpdateSource>` 补 trait 转发
  (壳层要运行期选源,`UpdateChecker` 是泛型的)。

presenter(宿主通道)
- `dto/update.rs`:`UpdateView` / `UpdateOutcome`(Idle/Checking/UpToDate/Available/
  MissingArtifact/Failed 六态,**失败与最新不折叠**)/ `UpdateBanner` 优先级判据。
- `host/update.rs`:Factory(生产 GitHub / 单测本地目录)+ core 结论 → DTO 的唯一映射
  + 更新域偏好(`cache/update/prefs.json`)。
- `host/update_state.rs`:Runtime(会话状态机:begin_check/adopt/begin_apply/dismiss/
  set_auto_check)+ `CoreHost` 派发与后台线程。
- `AppCommand::{CheckUpdate, ApplyUpdate, DismissUpdate, SetUpdateAutoCheck}`、
  `CoreEvent::Update`、`AppState.update`;`refresh_all` 按开关派发静默检查。

app-iced
- 设置页新增第 5 组「更新」(当前版本 / 渠道 / 自动检查开关 / 立即检查 / 上次检查与
  结果 / 下载并安装 / 就位后立即重启),走 `settings::view` 的 `update` 形参 + 按下标
  分发(与 M106 语言行同一取舍)。
- 顶部提示条(每页可见):有新版本(版本号 + 体积 + 说明摘要,可安装/可忽略)、下载中、
  已就位(立即重启);静默检查失败**不**弹条(原因进设置页)。
- `pages::Message::Update(UpdateAction)` → `app/update/update_action.rs`;「立即重启」
  拉同参数的新进程后退出(截图通道不重启)。

CLI
- `ull update check` / `ull update apply [--dest <path>]`:复用同一 `Job`;四种结论分开
  报(`--json` 的 `status`),失败退出码 1(`update_check_failed` / `update_apply_failed`)。

i18n
- 新增 `catalog/update.rs`(30 条,链尾追加)+ 金表对应 30 行。

M157 复审 P3 三件
- `app/update/instances.rs` 模块文档改成「清脏的唯一时机是回程快照等于表单」。
- `pages/instances/demo.rs` 的 `demo_form` 注释改成现状(两个参数框早已是单行输入)。
- `pages/instances/launch_args.rs`:门限不满足时只灰 `opengl`/`vulkan`,`default` 仍可点
  (它是「不下发」的安全档);判据抽成 `backend_chip_enabled` 并补 3 条用例。浅色 + 灰化
  两张图:`C:/Temp/m159-shots/instances-launch-args-{light,gated}.png`。

顺带:harness 支持 `ULL_SETTINGS_GROUP=<下标>`(拍第 5 组的验收图需要它)。

验证:cargo fmt --check 绿;cargo clippy --workspace --all-targets -D warnings 绿;
cargo test --workspace --all-targets 51 个测试二进制全绿(唯一例外:core::addon::pack
一族要造 4 GiB 稀疏文件,本机磁盘 99% 满 ⇒ StorageFull,与本次改动无关);CLI 用本地
目录夹具跑通「发现 9.9.9 → 下载 → 校验 → 替换运行中的 exe → 备份留旧文件」,并验证
摘要不符时拒绝应用、目标文件零改动、暂存目录零残留。
2026-11-07 12:09:42 +08:00
AskaEth 211e07b470 fix(launch): M157 r3 周快照门限按继承根版本 + 保存失败保留草稿(复审 P2-1/P2-2)
P2-1 周快照 + 加载器的 --graphicsBackend 静默丢失:presenter host::launch::build_request 与 cli snapshot::prepare 给 SnapshotInput.game_version 传 None,装配按带加载器后缀的 version_id(26w14a-fabric-0.16.5)判门限,GameVersion::is_weekly_snapshot 的 6 字符形态认不出来。两个调用点改为传继承链的 MC 根版本(25 行注释说明「门槛与拼装必须同一口径」)。回归:core launch::overrides 的周快照实际装配用例(正/负两档)、presenter host::launch::tests、cli snapshot::tests 各一条根版本断言。

P2-2 保存失败丢草稿:app/update/instances.rs 的 Save 分支派发前就把 launch_form.dirty 清掉,失败事件后 pump 用旧快照重建视图并覆盖表单。改为派发不清脏;LaunchForm::sync 只在「回程快照已等于表单」(= 落盘确认)时清脏并重播种,否则保留草稿。回归:failed_save_keeps_the_draft / confirmed_save_clears_dirty_and_reseeds / save_dispatch_keeps_the_dirty_flag。

顺带:launch::overrides 测试拆到 overrides/tests.rs(守 400 行)。
2026-11-07 10:36:37 +08:00
AskaEth e2021645d7 feat(launch): M157 实例级启动参数(五项回落 + graphicsBackend 三态)
core: 新增 game/launch_overrides(LaunchOverrides 五项 All-Option + 三级回落 + 文本补丁校验)与 launch/overrides(拼装落位:全局 java.extra_args 进 user.jvm、实例 jvmArgs 进 override_jvm、gameArgs 进 user.game、memory 进 -Xmx、backend 进 --graphicsBackend 并复用既有版本门槛告警);实例 schema 登记 launch.* 五键。

presenter: dto/LaunchArgsView+LaunchArgsPatch,主机侧读/写与门槛判定(backend_allowed),AppCommand::SetInstanceLaunchArgs。

cli: 新命令 instances launch-args(读/写/--clear),launch 装配链消费回落值。

app-iced: 实例详情页「启动参数」区(JVM/游戏参数、内存、Java、三态后端 chips + 灰化提示 + 保存/恢复),表单状态归 App、i18n 中英文案同步。
2026-11-07 10:36:35 +08:00
AskaEth 0e48ea15b8 fix(app-iced): M119 r2 —— 四处「模拟」文案全由 simulated 驱动;确认条不再替用户 arm 键盘
复审 r2 的两条 p2 都是「上一轮各差一步」:字段/焦点都真的做了,但**另有三处没接上**、
**键盘闸是代码自己打开的**。

## P2-1:四处文案只认一个开关

r1 只有标题胶囊由 `simulated` 驱动;副标题 / 输入框占位符 / 空态正文是**无条件取词**,
而它们的译文里写着「模拟 / 没接真实模型」——接上真 transport 之后这三句会**反向**留着
(`simulated == false` 时界面还在声明「没接真实模型」)。而且没有一条测试钉得住那个声明。

现在:

- 新增 `pages/agent::Disclaimer { simulated, subtitle, placeholder, empty_body }`,
  `Disclaimer::of(catalog, simulated)` 一次算好;`Conversation.simulated: bool` 换成
  `Conversation.note: Disclaimer`,header / composer / transcript 三处各自从它取词——
  **视图里没有第二种取词路径**;
- i18n 配成对:模拟态用既有的 `AgentSubtitle` / `AgentPlaceholder` / `AgentEmptyBody`,
  真实态新增 `AgentSubtitleReal` / `AgentPlaceholderReal` / `AgentEmptyBodyReal`(中性话)。
  (没有把三句改成中性话:那句告知本身有价值,接真模型后它才该消失。)
- 证据:`crates/app-iced/src/pages/agent/tests/disclaimer.rs`
  —— `all_four_disclaimers_appear_when_the_backend_is_simulated`(四处都提模拟)、
  `no_disclaimer_survives_when_a_real_backend_is_wired`(四处都不提,且不许用空串糊)、
  `the_two_states_use_different_keys`(两态不能共用同一个 key)。

**golden.rs 的 400 行边界**:+3 条会破线。按「按功能拆、不压注释」处理——把这张表的
**说明文档**(35 行)原样搬到 `catalog.rs` 里 `mod golden;` 的文档上(一个字的说明都没删),
`golden.rs` 只留表数据 + 3 行指针。现在 `golden.rs` 374 行、`catalog.rs` 328 行。

## P2-2:键盘闸不许由代码替用户打开

r1 在 `sync_confirm_focus`(`on_agent` 的无条件尾巴,`Events` 又由心跳驱动)里顺手
`keyboard_mode = true`——于是**只要冒出待确认,全局键盘闸就开了**;用户发完那句话接着打下一句,
第一个空格就成了「批准写操作」。复审用临时用例复现过(`rail.confirm` 变 `None`)。

现在:

- `sync_confirm_focus` **只挪焦点**到「允许」,不再碰 `keyboard_mode`(字段文档里写清了为什么);
- 键盘用户的表态是 `Tab`:`AgentUi::step_focus` 加了一条 **arming 规则**——待确认、焦点已在
  确认条上、但键盘闸还关着时,这一次 `Tab` **只点亮焦点环、不移动焦点**(否则第一次 Tab 会把
  「允许」挪到「拒绝」,用户按 Enter 就是反着裁决);之后再 Tab 才逐格走;
- 反向回归:`a_stray_space_or_enter_never_approves_a_pending_write`(打字 → 空格 / 回车都不得裁决,
  审计也不许留);`a_blocking_confirm_can_be_decided_with_the_keyboard_alone` 改成
  「确认到达时未 armed → Tab 点亮 → Enter 允许;第二轮 Tab·Tab 到拒绝 → Space」。

## 出图

`DevDocs/artifacts/m119/` 18 张全部重出(新增取证夹具 `ULL_AGENT_FIXTURE=armed`:
跑一轮真提交后再按一次 Tab,专供「焦点环可见」那张图):

- `08-agent-simulated-{light,dark}`:`simulated = true` 下四处「模拟」同时可见;
- `09-agent-confirm-focus-{light,dark}`:**按过 Tab 之后**的确认条(焦点环在「允许」上);
- `05-agent-confirm-{light,dark}`:确认条刚出现、**还没 arm**(无焦点环)——与 09 成对,
  正好把 r2 的行为差异拍下来。

## 门禁(真实退出码,最终树)

| 命令 | 退出码 |
|---|---|
| `cargo fmt --all --check` | 0 |
| `cargo clippy -p unlucky-app-iced -p unlucky-presenter -p unlucky-agent -p unlucky-i18n --all-targets -- -D warnings` | 0(0 warning)|
| `cargo test -p unlucky-app-iced --lib` | 0(605 passed / 0 failed / 2 ignored)|
| `cargo test --workspace --exclude unlucky-app` | 0(58 套 2281 passed / 0 failed / 11 ignored)|

全树受检文件无 >400(`golden.rs` 374、`catalog.rs` 328、`pages/agent/mod.rs` 371)。
2026-11-05 10:43:22 +08:00
AskaEth a22baebe70 fix(app-iced): M119 r1 —— 常驻「模拟」标识 + 确认条的键盘通路
复审 r1 的两条 p2,都是「说成真的、其实是假的」与「能走进死路」这个级别的问题。

## P2-1:界面必须自己说清「这是模拟」

原判:生产默认后端是**脚本化 rig**(`ULL_AGENT_SCRIPT` 的 6 种剧情),而「后端未接 / 模型还没
接上」两句诚实文案被我改成了中性话,理由写着「由端点标签说」——**那条端点标签在壳里根本不存在**。
于是默认配置下用户敲一句话得到一段像是模型编的回答。判据:没读过代码的用户,打开右栏、敲一句话,
必须能自己看出来这不是真模型(不接受 tooltip、不接受藏在设置里)。

修法(①恢复诚实文案 + ②常驻可见标记,并用):

| 位置 | 文案 / 形态 | 谁给的 |
|---|---|---|
| 标题右侧**常驻胶囊**(`SurfaceVariant` tonal)| `Key::AgentSimulated`(Simulated / 模拟)| `AgentHost::is_simulated()` → `Session.simulated` → 视图 |
| 标题下副标题 | 「模拟助手 · 还没接真实模型」 | `Key::AgentSubtitle`(原本是「未接」被改成中性的「会话」,现在写回来且更明确)|
| 空会话正文 | 「…当前是模拟助手,没有接真实模型。」 | `Key::AgentEmptyBody` |
| 输入框占位符 | 「给模拟助手发消息…」 | `Key::AgentPlaceholder` |

**标记不是硬编码在视图里**:`AgentHost.simulated` 是事实字段(今天 transport 只有 rig ⇒ 恒 true)。
接真 `AgentTransport` 那天把它传 `false`,四个位置的「模拟」措辞会一起消失——这是为什么用字段而不是
写死一句话。

## P2-2:确认条必须有键盘通路

原判:Allow/Deny 只有鼠标路径 ⇒ 键盘用户在写操作确认处**永久卡死**(`busy` 锁发送、
`Stop` 走主后端、清不掉 presenter 的 pending confirm)。

- 焦点环多两格:`Focus::ConfirmAllow` / `Focus::ConfirmDeny`,**只在有待确认时**存在
  (鼠标用户不会 Tab 到不存在的按钮);
- 待确认出现 ⇒ `sync_confirm_focus()` 把焦点直接落到「允许」并**打开键盘巡航**(阻塞态主动亮环),
  消失 ⇒ 焦点收回输入框、巡航关闭;
- `Enter` 与 `Space` 都映射到 `Activate`(`Space` 不抢输入框:`keyboard_mode` 那道闸在
  鼠标点进输入框时是关的);
- 焦点环不是 10% 状态层——在**实心主色**按钮上那层几乎看不见(出图实测过一版就是没有)。
  改成 2dp `Primary` 描边 + 3px 留白,两个按钮恒定加这层 padding,所以焦点变化不移动布局。

测试(`crates/app-iced/src/app/update/agent/tests.rs`):
`a_blocking_confirm_can_be_decided_with_the_keyboard_alone` —— 全程只有键盘消息:
确认到达后断言 `focus == ConfirmAllow && keyboard_mode`,发一条 `KeyPressed(Enter)` 完成允许;
第二轮 `Tab`(走到 Deny)+ `KeyPressed(Space)` 完成拒绝;两轮各记一条审计、焦点都收回 Composer。
另有 `the_default_backend_reports_itself_as_simulated` 钉住 P2-1 的事实来源。

## 视图入参顺带收成结构体

`docked`/`page` 本来是 6 个形参(`ui, agent, rail, failure, catalog, width`),这一轮还要加
`simulated` ⇒ 7 个。按塔台登记的可维护性口径改成 `Session { agent, rail, failure, simulated }`:
两个宿主各 3 个形参,以后再加展示事实只改 `Session`。

## 出图(18 张,浅深各一)

`DevDocs/artifacts/m119/`:新增 `08-agent-simulated-*`(空会话 + 四处「模拟」措辞)与
`09-agent-confirm-focus-*`(确认条 + 焦点环落在「允许」);其余 7 组全部重出(文案与焦点环都变了)。

## 门禁(真实退出码)

| 命令 | 退出码 |
|---|---|
| `cargo fmt --all --check` | 0 |
| `cargo clippy -p unlucky-app-iced -p unlucky-presenter -p unlucky-agent -p unlucky-i18n --all-targets -- -D warnings` | 0(0 warning)|
| `cargo test -p unlucky-app-iced --lib` | 0(601 passed / 0 failed / 2 ignored)|
| `cargo test --workspace --exclude unlucky-app` | 0(58 套 2277 passed / 0 failed / 11 ignored)|

`golden.rs` 400 行(+1 条 `AgentSimulated` 正好顶线);全树受检文件无 >400。
2026-11-05 09:17:50 +08:00
AskaEth d4fd675935 feat(app-iced): M119 把 presenter 的 agent 四契约与账户窄入口接进 iced 壳
M103 把 AI 会话的投影(`AgentRail` + `submit/mark_read/confirm`)做完了,M114 把账户三轨做完,
但 iced 壳一直没接:M103 复审确认 `Presenter::` 零非测试消费者,M104 的右栏会话数据来自内置样本。
这一个提交就是那一步——**右栏与账户页吃 presenter 的真实状态**。

## 接线层(新增 `crates/app-iced/src/presenter/`)

| 文件 | 是什么 |
|---|---|
| `agent.rs` | `AgentHost`:presenter 句柄(四契约)+ 事件队列 + **具名**失败(`AgentFailure`:`unreachable` / `not_implemented`)|
| `transport.rs` | `AgentTransport` 的 iced 侧实现(脚本化 rig + 五种剧情 + 两种失败注入)|
| `rig.rs`(仅测试) | 账户轨的 `Backend`:`AppCommand` → host 形状的 `CoreEvent` |
| `mod.rs` | 逻辑路径说明 + **线程/通道边界** |

物理目录 `src/presenter/`,逻辑名字 `crate::app::state::presenter`——由 `app/state.rs` 用
`#[path]` 挂载(与 `shell::agent` 挂 `pages/agent/` 同一个先例:scope 不含 `lib.rs`)。

## 事件回流

`transport` 只**入队**、UI 只**出队**:`advance`→`pump` 里同拍抽干,抽出来的这批经
**既有 `Message::Agent(..)` 通道**(新变体 `Events(Vec<AppEvent>)`)回到 `on_agent`,再由
`Presenter::apply` 落地。同一条漏斗,所以用户点击与后端事件不会各改一次状态。

## 会话内容只留一份

`AgentUi` 里的 `transcript/busy/unread` **删掉**,右栏与独立页直接读 `agent_rail()`
(`pages/agent::spec` 零拷贝借)。M104 的样本会话(`demo::transcript()`)一并删除:出厂就是
空会话,取证夹具要内容就**跑一轮真提交**(`ULL_AGENT_FIXTURE=turn`),消息只能由 presenter 产生。

## 视图

- 气泡按 `ChatMessage.role` 画(User/Assistant/Tool/System),没有任何壳自己的会话类型;
- 新增两条横幅(`conversation/banners.rs`):**失败态**(catalog 标题 + 已脱敏正文)与
  **写操作确认**(摘要 + Allow/Deny → `agent_confirm`);
- 拖动缝与折叠条按 400 行硬线拆到 `mod.rs` 之外(`strip.rs`、`conversation/{transcript,banners}.rs`)。

## 未读口径

presenter 判「栏可见吗」用的是它自己的 router + `prefs.ai_collapsed`,而这两件的真源在 `App`。
`refresh_view` 用 presenter 的两个**窄入口**(`navigate` / `prefs_mut().ai_collapsed`)单点写回,
带幂等闸(相同就不重复写,避免 `navigate(Route::Agent)` 顺手 mark_read 吃掉用户攒的未读)。

## 账户页

账户列表与登录事件仍走 `AppCommand`/`CoreEvent`(M114 的形状没动),本轮把**登录指令与
列表消费**纳入同一套树测试:添加离线档案 → 列表出现 → 切换 → 删除;设备码「进行中可见 + 可取消」;
失败态(名字不合法 / 凭据失效)都给具名错误。

## 树测试(9 条,`cargo test -p unlucky-app-iced --lib` 全绿:599 passed / 0 failed)

- `app/update/agent/tests.rs`:提交 → 事件回流 → 右栏出现该轮 + `busy` 翻转 + 标记已读归零;
  确认条可裁决且记审计;三种失败各给具名横幅;草稿逐字同步。
- `app/update/account/tests.rs`:增/切/删端到端;设备码进行中与取消;两种失败态;
  M114 的三条回归闸(原内联测试搬出来)。
- `pages/agent/tests.rs`:视图契约(`spec` 原样借 rail)、a11y 标签、焦点环、两种宿主全态构图。

顺带:`app/tests.rs` 里三条 `agent.unread` 断言跟着 rail 走(出厂 0 未读),`view/account_page.rs`
的 `docked` 调用点补上 rail 与失败横幅。
2026-11-05 08:02:58 +08:00
AskaEth 3c61a2397e refactor(app-iced): 拆 state.rs(400 行) 与 update.rs(399 行) 为功能子文件
两个文件都贴着 400 行硬线、零余量,M119 要往里加字段与一行 drain。**这一个提交只搬不改**
(下一次提交才是改动),搬的位置按功能切:

| 原位置 | 现在 | 内容 |
|---|---|---|
| `app/state.rs` | `state.rs`(172) | 只剩 crate 文档 + 常量 + `struct App` 的字段与注释 |
| 同上 | `state/construct.rs` | 两个构造器 + 字段初始化表 |
| 同上 | `state/refresh.rs` | `refresh_view`(快照 → 视图模型的唯一转换点) |
| 同上 | `state/theme.rs` | `Ui` 重算 / 主题档 / `has_screenshot` / `ui()` |
| `app/update.rs` | `update/clock.rs` | 推进一拍 / 派发 / 抽干事件 / 自动选中 |

一处**非字面**的改动:搬进子模块之后 `pub(super)` 的语义从「crate::app」变成「crate::app::state」,
所以这些方法改成 `pub(in crate::app)`——可见范围与搬家前逐字相同,只是写法得跟着模块路径走。
除此之外照抄原文(含原有注释与文档)。

搬完 `cargo check -p unlucky-app-iced --all-targets` 零 error 零 warning。
2026-11-05 08:02:45 +08:00
AskaEth abc51a957b fix(app-iced): M116 r4/r5 —— 产品窗口不是 WS_POPUP(真凶是整窗 α 的 layered)+ 裁定 A 落地
r4 复审 p1(证据链前提错误)+ 裁定 A(D47:整窗透明度优先于 DWM 圆角)。

## 事实订正(四处错前提 + 两处残留断言)
- `probe.rs`:删掉「winit 在 decorations=false 下给 WS_POPUP」与「popup+layered = 本产品
  窗口的完整形状」。产品样式按 winit `window_state.rs::to_window_styles` 的来源写成
  `PRODUCT_STYLE`(`WS_CAPTION|WS_BORDER|WS_CLIPSIBLINGS|WS_SYSMENU|WS_THICKFRAME|
  WS_MINIMIZEBOX|WS_MAXIMIZEBOX` = `0x04CF0000`,**不含 WS_POPUP**),并新增
  `product` / `product+alpha` 两行(五行实验)。裸 popup 行改名为 `probe:popup*`,
  明确标注「对照,不是产品形状」。
- `corner.rs`:平台矩阵第 ③ 条改成「调整带只给带 WS_CAPTION|WS_THICKFRAME 的窗口;
  裸 popup 假形状上的 `+0,+0` 不能解释产品」;补第 ④ 条「整窗 α 会关掉圆角(裁定 A)」
  并说明日志里 `pref=` 有值即证明请求确实发出。
- `app/update/appearance.rs` 与 `transparency/{dwm.rs,transparency.rs}` 的注释:把「第一嫌疑是
  无边框 WS_POPUP」改成「整窗 α 引入的 WS_EX_LAYERED」;`frame=` 的真进程取值写明是
  `overlapped`(关 min/max 则 `other`),**永远不会是 popup**。
- 实测复核(`m116_corner_controlled_experiment`,本机 Server 2025 / RDP 断开):
  `product` 行 `Δframe = +0,-7`(与 `decorated` 同格),`product+alpha` 也是 `+0,-7`
  ⇒ 分层位不改变那条带子,`Δframe` 不能解释圆角失效。

## 裁定 A 落地
- 日志新增中立观测栏 `suppressed_by=window_alpha`(`format_line` 多一个参数;进 `CornerApplied`
  指纹,α 1.0↔<1 各触发一次重下/一行日志)。只陈述「分层位来自我们的整窗 α」。
- 设置页:外观说明句写明「开启整窗透明度时 Windows 不会给窗口圆角,回到 100% 自动恢复」;
  新增 `SettingsAppearanceCornerSuspended` 一档,仅在「跟随系统 + α<1」时作为**当前状态提示**
  出现;档位本身照常显示用户选的值(不偷偷改成「直角」)。中英双份 + golden。
- 未动:per-pixel α、D36 的 LWA_ALPHA 实现、α 与圆角的优先级。

## 顺带
- `presenter/prefs.rs` 的 `AppearanceBase` 文档「三个键/只读」→「四个键」(r4 P3)。
- 证据目录(DevDocs/artifacts/m116/{README.md,probe-table.md})按上面重写:五行真值表原样贴出、
  产品行标明「跑出来的不是手写的」、旧的「无边框 ⇒ 没有边框带」结论改为「只在假形状上成立,
  产品不用这个形状」。

门禁:check 0 / fmt --check 0 / `cargo test -p unlucky-app-iced --lib` 0(448 passed, 0 failed,
2 ignored)。ignore 探针实跑:`PROBE_RC=0`,两行产品读数见报告。
2026-11-05 06:35:19 +08:00
AskaEth 41746d1612 fix(app-iced): M116 r1 复审 —— 口径降级为「不承诺」、修掉伪造读数与两处错注释、圆角下发去重
四条 P1(全是「口径与证据不符」,行为未变):

1. 把「Win11 一定有系统圆角、边缘平滑」从事实降级:`corner.rs` 的平台矩阵改成
   「实测 / 解释 / 推断 / 未验」四列,并明写本机只测到「写入被接受 / 回读 E_INVALIDARG /
   无边框窗口没有 DWM 调整带」;设置页与 golden 的说明句改成「我们**请求**系统圆角,
   是否圆、边缘什么样由合成器决定」,不再承诺结果。
2. 删掉证据目录里那条伪造读数 `readback=2`:本机三种样式回读全是 `E_INVALIDARG`,
   该值物理上产不出来,也不可能由 `format_line` 产出。改成显式的「合成示例(非实测)」,
   并把每一栏标注成真实/示例来源。probe-table 的「没有一条是推断」标题一并拆开,
   结论 4/5 明确标为**解释**(并写明 Δframe 之差是样式位的同义反复,不能反推 DWM 决策)。
3. `keys.rs` 的「旧读新」语义按实际链路改写:1.4.0 读 1.5.0 文件走
   `SchemaCompat::check_url` → `ReadOnlyPreserveSchema` → `SchemaCompat::access` →
   `FileAccess::ReadOnly`,**整份配置只读**(值照读、`store.set` 报 `SettingError::ReadOnly`、
   `save` 跳过),不是「只是少一档」。
4. 两处错注释订正:
   - `hresult_ok` 的理由改成 `S_FALSE` 的通用语义,并明写 `0x7FF8FFA9` **不是** DWM 的返回值
     (本机实测是 `E_INVALIDARG 0x80070057`),那是我早期用 `unsigned_abs()` 打印 HRESULT 的产物;
     对应单测也把该常量标为「合成值,只用来钉规则」。
   - `DWMWA_VISIBLE_FRAME_BORDER_THICKNESS` 的文档改成「本机三样式三态实测全 1,不能当判据,
     仅作诊断读数」。

两条裁定:
- CPU 档在 Win11 仍走 DWM(tower 裁定保留):理由写进 `plan_corner` 文档——D36 约束的是我们自己的
  渲染路径,DWM 圆角是合成器侧的,不增加我方负担;设置页说明同步加了「软件渲染也一样」。
- 「圆角窗口上拖边缩放仍命中」实测未做(RDP 断开、无指针注入手段):mission 里以 note 记为 dropped
  (store 的 task_drop 对已完成任务不生效,工具层面无法取消勾选,已在 note 里写清)。

六条 P2:
1. probe-table 拆「实测 / 解释 / 未验」并删掉绝对化标题。
2. CPU 档裁定见上(任务书由 tower 侧调整)。
3. 拖边缩放任务记账更正见上。
4. **圆角下发去重**:新增 `corner::{CornerApplied, needs_redispatch}`(指纹 = 策略 / 来源 /
   原因码 / 有没有句柄,不含只有真下发才拿得到的读数),`App::apply_window_corner` 改成
   `&mut self` 并在判定未变时**直接返回**(不动窗口、不打日志);`App` 新增 `corner_applied` 字段。
   新增测试 `the_same_policy_is_dispatched_only_once`:连续 20 次同策略只下发/记 1 次,
   换档与「首次拿到句柄」必须重下。
5. `dwm_get_rect` 的 `hr == 0` 改成 `hresult_ok(hr)`,「判据只有一处」名副其实。
6. 报告口径修正(诊断已产出、UI 接线待做——`SettingsBridge` 未暴露 doc,接线要改 presenter 的
   `settings_bridge.rs`,不在本 mission scope),本轮不动代码。

门禁:fmt --check exit 0;clippy(5 个 crate)--all-targets -D warnings 零 warning;
lib 测试 app-iced 419(+1 去重测试)/ core 266 / design 163 / i18n 24 / presenter 129 全绿;
改动文件仍全部 ≤400 行(最大 keys.rs 398)。
2026-11-05 06:35:17 +08:00
AskaEth 88dbf6d955 feat(app-iced): M116 窗口外框圆角策略(跟随系统/直角)+ DWM 读写的受控对照实验
用户问「为什么窗口没有圆角」。核实:组件层圆角齐,但窗口外框**零处理**——代码里没有任何
Dwm* / SetWindowRgn。本 mission 先做实测,再按平台分开落策略。

判据(纯函数、可真值表):
- `platform::plan_corner(策略 × 系统版本 × 渲染档)` → `source=dwm|square` + `excluded_reason`。
  Win11 / Server 2025(build ≥ 22000)→ DWM;Win10 及更早 → 直角(不做 SetWindowRgn 毛边默认);
  探测不到版本(macOS / X11 / Wayland)→ 直角 + `unknown-os`(「不承诺」而不是「做不到」)。
  CPU 档在 Win11 上照样用 DWM(D36 限制的是我们自己的合成技巧,不是系统合成器)。

落点:`platform/win32/transparency.rs` 仍是全仓唯一 unsafe 文件,新增 DWM 的 FFI 与安全包装;
`alpha.rs` / `dwm.rs` / `probe.rs` 三个新子模块零 unsafe、零 extern(沿 exstyle.rs 的既有形状)。

实测(本机 Server 2025 build 26100、RDP 会话断开、同进程三窗口对照):
- `DwmSetWindowAttribute(DWMWA_WINDOW_CORNER_PREFERENCE)` 在 WS_OVERLAPPEDWINDOW / WS_POPUP /
  WS_POPUP|WS_EX_LAYERED(α=0.8) 上**全部成功** ⇒ 无边框与整窗 α 不会让属性设不上;
- `DwmGetWindowAttribute` 同一属性三种样式**全部 E_INVALIDARG** ⇒ 本机不支持回读,与样式无关;
- `DWMWA_EXTENDED_FRAME_BOUNDS − GetWindowRect`:有边框 = +0,−7,popup = +0,+0
  ⇒ DWM 只给有边框窗口加不可见调整带,我们的窗口没有可画的圆角区域(结构性解释)。
- 像素(角上透明/方块)**未验**:RDP 断连导致 CopyFromScreen / gdigrab 全废。

同时修掉一个自己踩到的判据错误:`i32::unsigned_abs()` 不能用来打印 HRESULT
(E_INVALIDARG 0x80070057 会被印成 0x7FF8FFA9)。现在 HRESULT 用 `hr as u32` + 严重性位,
GetLastError 的 DWORD 语义保持 `unsigned_abs` 并写死注释(两者不同源,别统一)。

配置:新键 `appearance.window_corner ∈ {system, square}`(出厂 system),schema 1.5.0 只推 minor、
不写迁移:老文件缺键 = 取默认 = 跟随系统;旧版本读新文件时未知键被登记并原样写回(`RenderMode::Full`),
只在显式 `store.set` 该键时才 `UnknownKey`。

设置 UI:外观组只留两档(跟随系统 / 直角)+ 一句按平台说清的说明,文案走 i18n 中英。
日志:`[corner] <ctx> corner=policy=… source=… os=… excluded_reason=… radius=system pref=… readback=… frame=… layered=…`
(形状由 `corner::format_line` 的格式化单测钉住,用探针窗口真实读数构造)。

证据:DevDocs/artifacts/m116/{README.md,probe-table.md}
2026-11-05 06:35:03 +08:00
AskaEth a183adc072 refactor(app-iced, presenter): M114 rebase 后守 400 行 —— 四处纯搬移(不动语义、不压注释、不挪断言)
主线六条合入后,四个被我碰过的文件越过 400 行;按模块拆开,只搬不改:

- `presenter/host/mod.rs` 421→366:账户刷新/皮肤三段(refresh_accounts / attach_skins /
  request_skins)搬进 `host/accounts/refresh.rs`(`impl CoreHost`,pub(in crate::host))
- `presenter/state.rs` 410→392:`TileCell` + `grid_positions` 搬进 `state/tiles.rs`,
  `state.rs` 用 `pub use` 再导出,老路径不变
- `app-iced/app/state.rs` 401→394:账户页重建那一段搬进 `app/view/account_page.rs`
  的 `App::rebuild_account_page`
- `app-iced/app/update.rs` 407→388:`ULL_START_ROUTE` 取值表搬进 `app/update/boot_env.rs`
  (纯函数、无状态)
2026-11-05 05:13:54 +08:00
AskaEth f3e20b670e fix(app-iced, presenter): M114 r4 —— 登录面板 Close 不再是死按钮 + 面板态删掉原文字段(把「画原文」变成编译错)
复审 r2 P2-1:`Account::Close` 只在 `phase.busy()` 时派 `CancelDeviceLogin`,而
`Page::build` 只要非 Idle 就强制回 Microsoft ⇒ Failed/Succeeded 下 Close 点了没反应。
- 判据改成「两条轨任一非 Idle 就取消」;host 侧 `CancelDeviceLogin` 本来就把两条轨收成 Idle
- mock 补 `AppCommand::CancelDeviceLogin` 分支(按真 host 契约发 Idle),否则 mock 下不可验
- 新增 headless 回归闸:失败/成功/第三方轨三种态 → Close → `panel == None`
  (变异实证:判据退回 `busy()` ⇒ 该用例红,打印 left: Microsoft / right: None)

复审 r2 P2-2:`DevicePanel`/`AuthlibPanel` 删掉 `message`/`detail` 字段。
视图没有材料可画,「把后端原文画上屏」从测试问题变成**类型错误**;
`tests_text.rs` 的 docstring 改成真实边界(只覆盖模型 + i18n 查表,视图靠类型 + 出图)。

顺带(塔看图标出):alert 的 `Code: …` 与标题同排,省一整行高度,
避免 1180×760 把 All accounts 第三行挤到折线下;新增 `closed` 取证场景出「关掉之后」的对照图。
2026-11-05 05:10:47 +08:00
AskaEth 73badb8a02 test(app-iced, presenter): M114 出图补三个取证态(第三方站失败 / 离线失败 / 中文错误码)
- mock 增 `authlib-failed` 场景:AuthlibLoginChanged(Failed, NotAuthlibServer),原文只进日志
- `ULL_ACCOUNT_STATE=notice` 现在同时打开离线面板(截图能看到「表单 + 失败横幅」同屏)
- 新增**截图专用**语言开关 `ULL_LANG=zh`(仅在 `--screenshot` 下生效):D30 要求错误码文案
  中英各一张图,交互模式仍钉死 Lang::En(与产品行为一致)
2026-11-05 05:10:13 +08:00
AskaEth 8d8552113a fix(app-iced, presenter): M114 r2 —— 登录失败走结构化错误码 + i18n,修 4 条 p2 与 3 条看图意见
p1(语言泄漏):后端诊断串不再上屏
- presenter 新增 LoginFailure 原因码(denied / whitelist_pending / unreachable /
  not_authlib_server / bad_credentials / no_profile / store_failed / other)
- 页面按码查 i18n 出标题+正文+「Code: …」,原文只进日志/详细信息
- 进行中(Requesting)也走 i18n,不再复读后端中文状态串
- presenter/state 不再发中文 toast(成败由页面按 i18n 画)
- 新增守门测试:注入带 AADSTS 的失败事件,断言英文视图无 CJK 且含 code

p2-1 复制:on_account 返回 Task,page_messages 把 iced::clipboard::write 交回 runtime
p2-2 可见失败态:新增 AccountNotice(added/removed/switched/名字非法/重名/落盘失败/
      找不到),页面用 M3 内联 alert 画(不是 toast);离线名字先按 3–16 位规则校验,
      失败不再清空输入
p2-3 重新登录按账户类型分派:微软→授权码流;第三方→预填服务器/登录名并提示补密码;
      离线→明确说明没有可重登的服务器账户
p2-4 入口收敛:删 chip 行;空态=三卡片;有账号时三卡收进 Add an account(按钮不再绑死微软轨)

看图意见/tower 三条:
- 失败横幅改成 M3 alert(SurfaceContainerHigh + 描边,errorContainer 只作图标块,
  不再通栏实心红);色值本身经复审像素采样确认是 90/30 档,未改角色
- 当前账户卡补齐与列表一致的「服务器/登录名」行;无皮肤时明说 No skin available
- 页面级 scrollable 加 8px 右 gutter,滚动条不再压卡片
- 倒计时改 mm:ss(15:00 left),数字与冒号语言无关

p3(顺带):AuthlibRequest / Form 手写 Debug 脱敏密码;HTTP 响应体与 base64 输出加上限;
离线提示文案与行为对齐;删除无人使用的 key;皮肤测试断言帽子层真的 blend

测试:app-iced lib 418 绿、presenter 151 / i18n 24 绿(workspace 全量另跑)
2026-11-05 05:09:51 +08:00
AskaEth 1ff00bbe50 feat(app-iced): M114 账户页三轨登录(微软正版 / 第三方皮肤站 / 本地档案)
- 账户页:当前账户卡 + 全部账户列表 + 空态三入口 + 皮肤区;五项动作(添加/切换/删除/重新登录/校验)
- 三轨添加流程:设备码(用户码+链接+倒计时+过期重试+取消)、授权码(浏览器+可复制链接+取消)、
  本地档案(名字校验)、第三方站(服务地址 + 服务发现校验 + 密码登录,密码掩码且用完即弃)
- 导航:账户为一等页,入口是轨底头像位(未登录=描边剪影 / 已登录=首字母块+名字;窄高收成图标条)
- 凭据红线:DTO 侧形状脱敏 + 状态层二次脱敏 + 密码掩码;Inject-JWT 测试断言视图模型干净(已做变异取证)
- presenter:authlib 登录/服务发现/皮肤抓取(复用下载引擎的 HTTP 客户端,无新增依赖)、
  皮肤原图经 DTO 到 UI 裁剪成头像(既有 image 依赖,未改 Cargo.toml)
- i18n:新增 account 域 51 条中英文案(离线档案口径全量写入),金表同步
2026-11-05 05:08:35 +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 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 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 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 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 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 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 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 d3e959e754 docs(app-iced): M101 r4 —— 订正共享点成本数字(3 / 不可行 / 3 处)与三处 p3 措辞
r3 复审:那段「仍然共享」的成本数字与代码事实不符(三条填页线据此取 scope,故按 p1 处理)。
本轮**只改文档/注释**,无产品代码与测试逻辑改动(唯一非注释改动是 t2 断言的 message 字符串)。

- 第 4 条「读页外真值」:`States` 没有 `Default`,加字段会让 4 处 struct literal 全部 E0063
  (`pages/mod.rs:154/177/232` + `app/view.rs:70`)⇒ 把 model 塞进 `States` 是 **3 个共享文件**
  (含 M104 的落点 `app/view.rs`);改推「给 `settings::view` 加 `model` 形参」这个**真·2 个文件**
  的形状。并订正「在 `app/state.rs` 用真值初始化(1 个文件)」——今天没有语言真值源
  (`AppearancePrefs` 无 language 字段,唯一值是 `app/view.rs:128` 硬编码 `Lang::En`)。
- 第 5 条第 ② 条路(改 `Body` 返回 `Task`)**删掉**:`settings::view` → `pages::render` → `App::view`
  一路返回 `Element`(iced 0.14 的 view 亦然,且 `view(&self)` 改不了状态),view 期间产出的 `Task`
  没有出口;真做要 `settings/mod.rs` + `pages/mod.rs` + `app/view.rs` 三处且另造旁路,明确写「不建议」。
  只留第 ① 条(`app/update.rs` 的 `Message::Boot` → `on_boot`,1 个文件)。
- 第 6 条与 `state.rs::demo_groups` 文档:「harness 侧改两处」→ **三处**
  (`pages/mod.rs` 的 `page` / `page_with_appearance` / `app_with_appearance`,行 156/179/234)。
- p3:`message.rs` 里守门路径改成 `app/update/settings/tests.rs`(并点名 `settings.rs` 只是 mod 声明点);
  `appearance_section.rs` 的「绞线」错字改「绊线」;断言 message 的 `Some(appearance::body)` 改成源码
  实际的 `Some(body)`(本地 fn);`app/update/settings/tests.rs` 的「抓不住 #2」改成两种洞分开说
  (`|` 续行放行;花括号形状的多行 pattern 是**静默跳过**,不是误红)。
2026-11-03 23:59:07 +08:00
AskaEth c166944c8e docs(app-iced): M101 r3 —— 补「页外值 / 首帧取数」共享点,守门收窄,文档口径统一
r2 复审 P1:报告与代码文档的「仍然共享」清单只写了 3 项,漏了「读页外真值 / 首帧取数」这半边。
本轮按复审给的第一条路(文档/契约层面诚实交代,不动结构):

- `settings/mod.rs` 的「仍然共享」扩到 6 条,新增:
  4. 读页外真值 —— `States` 只有 appearance + groups,General 要显示 `Model.lang` 就得改
     `settings/state.rs` + `pages/mod.rs`(2 个共享文件)或在 `app/state.rs` 用真值初始化
     `Groups.language_appearance`(1 个);给出建议形状:一次性给 `States` 加 `model: &'a Model`。
  5. 首帧取数 / 异步发起 —— `Body` 返回不了 `Task`,组自己发不了首帧探测;两条路(`Boot` 分支派发
     1 个共享文件;或把 `Body` 返回类型加一条 `Task` 旁路,1 个文件一次性)与「不改的代价」都写明。
  6. 隐性代价:`Groups: Sync + Default`(`demo_groups` 的 `static LazyLock` 撑 harness),
     非 `Sync` 字段会让编译红在共享的 `settings/state.rs`;给了改法。
- `general.rs` 文档改成正确指引(消息加在本线的 `language_appearance.rs`,落点
  `app/update/settings/language_appearance.rs`,**不要**改 `pages/mod.rs` / `pages/message.rs` /
  `app/update/page_messages.rs`),并指向上面第 4、5 条。
- `tests/mod.rs` 的「接真控件改 3 个文件」订正为 2 个,并删掉对**已被删除**的
  `only_the_appearance_group_carries_a_real_control_body` 的点名;`appearance_section.rs` 的
  模块文档与断言消息同步订正(`body = Some(appearance::body)`)。
- 守门 `the_group_dispatch_has_no_catch_all_arm` 判据**结构化收窄**:用大括号深度计数只看
  `match message {` 本层(深度 1)的臂,嵌套 match 整段跳过、`|` 折行 or-pattern 续行放行;
  只有「本层里含 `=>` 且不以该组变体路径开头」才算兜底。新增两条守门自测:
  `the_guard_ignores_nested_matches_and_folded_or_patterns`(复审 P2-3 的假红探针)与
  `the_guard_still_catches_both_catch_all_shapes`(`_ =>` 与 `other =>` 仍要红);
  误伤类别如实写进测试 doc 的「抓不住什么」。
- P3:`java::Message::Ping` 的文档改成指向真实存在的绊线(`tests/java_section.rs` 的
  `the_java_group_is_still_demo_rows`);`demo_groups()` 文档写明 `Sync + Default` 的隐性要求。
2026-11-03 22:48:05 +08:00
AskaEth 029ece3f17 feat(app-iced): M101 r2 设置页状态漏斗 + pages/mod.rs 拆分 + r1 复审订正
r1 复审的 P1:消息漏斗只解决了「加一支消息」,一组要画真值仍要改 `settings/mod.rs`
(`Group.body` 与 `view` 只认 `appearance::State`)。本 commit 补齐与消息漏斗同构的状态漏斗:

- 新增 `pages/settings/state.rs`:`Groups`(一个组一个字段,字段类型在组自己文件里)+
  `States`(按值传给渲染器的只读视图:外观状态 + 组状态)+ `demo_groups()`(harness 的
  `'static` 默认状态)。
- `Group.body` 签名改成 `for<'a> fn(&'a Ui, States<'a>) -> Option<Element>`:每组自己从
  `States` 取自己要的数据,「没有可画的状态」返回 `None` 由 `form` 退回演示行。
  `appearance` 组照旧接 `pages/appearance/section.rs`(M93 路径行为不变)。
- `App.settings: pages::settings::Groups`(`app/state.rs` 字段列表末尾)+ `app/view.rs` 构造
  `States` 走新入口 `pages::page_with_settings`。加/改某一组的字段只改那一组的文件。
- 四个组各自的 `State` 落在各自文件;`java::Message` 留一支只有测试构造的 `Ping`,配
  `app/update/settings/tests.rs` 的 `a_group_message_reaches_its_handler_through_the_funnel`
  ——消息漏斗第一次有**运行时**证据。

r1 其余条目:
- P2-1 删掉 `only_the_appearance_group_carries_a_real_control_body`(被四条 per-group 断言蕴含),
  并在同处写明删掉之后「唯一性」没人守;三份 section 文档从「改 3 个文件」订正为 2 个。
- P2-2 `pub mod settings` → `pub(crate) mod settings`(四个消息口模块 `pub(crate)`),
  并订正两处理由(lint 按类型自身可见性判,不是可达性)。
- P2-3 `pages/mod.rs` 397 行 → 拆出 `pages/message.rs` / `pages/screen.rs`(纯搬移,`pub use`
  保证 `pages::Message` / `Action` / `Screen` 路径不变),并删掉 M101 自己那 18 行重复文档。
- P3-1 订正共享点清单(`Key` 由宏从域文件累积生成,加 key 不改 `catalog.rs`)。
- P3-2 守门改成「除已知臂外任何含 `=>` 的行都算兜底」(`other =>` 也拦),并加「恰好四臂」断言。
- P3-3 `ai.rs` → `ai_about.rs` 单独一条 R100 改名提交,`git log --follow` 不断链。
- P3-4 `message.rs` 的「加一支消息改 1 个文件」订正为 2 个。
2026-11-03 20:57:08 +08:00
AskaEth 063a1dba39 feat(app-iced): M95 日志页接真组件 + 诊断面板(等宽折行 / 按严重度分组 / 一行摘要)
日志页从 M84 占位换成真组件,数据全部来自 presenter 快照(页面不读盘):

- 日志行列表:`pages/logs/lines.rs`,等宽(`BodySmall` 12/16 + mono 族)+ 按词或字形
  折行 + 等级色条;**选折行不选横滚**的理由写在模块文档(424px × 200 字符的现实 +
  mono 族的 CJK 回退会毁掉横滚唯一的收益「列对齐」)。
- 诊断面板:`pages/logs/diagnostic.rs`,按严重度分组(CRITICAL → WARNING → NOTICE),
  每条给规则 id / 结论 / 说明 / 建议动作 / 置信度;卡片头钉住「Copy summary」,
  摘要是稳定单行的 `ULL-LOGS v1 | …`(崩溃日志换 issue 时直接粘)。
- 空态:真组件 `feedback::EmptyState`(图标块 + 标题 + 说明 + CTA 回实例页)。
- 两块都在 `pages/logs/panel.rs` 里**定高 + 内部独立滚动**(`FillPortion(3):(2)`):
  第一版让诊断卡按内容撑开,三条结论就把日志卡压成 0 高度(实拍复现后改的)。
- 视图模型:`pages/logs/model.rs`(纯展示字段)+ `app/view.rs::logs_model` 做快照转换。
- 接线(只追加):`pages/mod.rs` 的 `pub use logs::…` / `page()` 那行 /
  `Message::CopySummary` / `demo_model` 一行;`pages::Model` 加 `logs` 与 `lang` 两个字段;
  `app/update.rs` 把 `CopySummary` 落到 `iced::clipboard::write`、`ClearLogs` 清 presenter 的
  `log_lines`(呈现层操作,不造命令)。
- i18n:`crates/i18n/src/catalog.rs` 自己锚点下 16 个 key(中英同表);按塔裁决把
  `#[cfg(test)] mod tests` 逐字外置到 `crates/i18n/src/catalog/tests.rs`
  (catalog.rs 415 → 257 行,tests.rs 164 行;7 个用例逐字未动、i18n 23 个用例全绿)。

验证:`fmt --check` / `clippy -p unlucky-app-iced --all-targets -- -D warnings` 干净;
`test -p unlucky-app-iced` 272 + 6 全绿、`-p unlucky-i18n` 23 全绿;截图
`.screens/logs-{empty,crash,live}-{light,dark}.png`(1180×760,本地 gitignore)。

遗留(已报 finding):`dto::Diagnosis` 缺 core 的 `severity` 与规则表 `cause.key`;
`Action::OpenLogs` 没有对应 `AppCommand`(点了只有占位日志)。
2026-11-02 22:26:53 +08:00
tower 5340908882 refactor(download): rebase 到 M93 之后 app/update.rs 破 400 → 下载页四支拆到 update/download.rs
M93(外观三件套接线)合入后 `app/update.rs` 从 348 涨到 410 行,再加上下载页的四个 arm
会顶破 ≤400 硬线。把下载页那几支(SelectVersion / InstallVersion / CancelInstall /
SetCategory / Action::RefreshSources)挪到 `app/update/download.rs`(`app/update/**` 在本
mission scope 内),`on_page` 入口只留一句 `if self.on_download(&message) { return; }`,
match 末尾补一条 `_ => {}` 做穷尽性。

行为零变化:指令、视图模型写法、`refresh_view` 顺序都照搬;M93 的 `Appearance` arm /
`env_start_route` / `on_appearance` 一行没动。

行数:update.rs 410 → 397,download.rs 57。
2026-11-02 21:45:40 +08:00
tower 6453c76750 fix(download): M94 r2 —— 安装终态出口 + 分类真机过滤(复审 P1-1 / P2-1)
P1-1 安装一旦收尾就再也装不了第二个版本(终态面板没有出口)
- 页面侧:`Download::from_snapshot` 把 **Done / Cancelled** 当结束(`InstallView::finished`),
  面板让回列表 —— 装完一个版本可以直接选下一个继续装(图:r2-done / r2-done-next)。
  不清 `Failed`:失败态带着用户要的东西(停在哪个文件 + 去日志页)。
- 面板加真出口:失败态「Open logs」(实心)+「Dismiss」(文本 → `Message::CancelInstall`,
  host 侧对没在跑的任务是安全 no-op),修掉「失败后连重试都不可达」。
- 决策抽成纯函数 `install::panel_state()`(Hint / Ready / Install),`panel()` 只是它的画法,
  于是「能不能再装」可以被单测钉死,不用开图形环境。
- 两条新测试**不走 MockBackend**(它从不发 `InstallProgress`,终态永远跑不到 ⇒ 假绿):
  直接构造 `AppState` + `apply(CoreEvent::InstallProgress(..))`。

P2-1 分类 chip 在真 host 上是空控件
- `from_snapshot(state, category, catalog)` 收分类并按 `Category::matches` **本地过滤**
  (真 host 的 `ListVersions(_)` 丢掉分类参数、回全量清单);
- 分类不再从列表反推(`Category::of` 只留给演示首帧),`app/view.rs::model` 收分类、
  `refresh_view` 把上一份视图模型的分类带回去 ⇒ 事件回来 chip 不会跳回「全部」;
- `update.rs` 的 `SetCategory` 写分类后**立刻重建**(chip 与列表同一个谓词),再派真指令。

p3(顺带)
- 失败态与占位态共存:列表空/加载中时若安装还在跑,面板照样出现在底部(r2-installing-empty);
- 修 `install.rs` 的坏 rustdoc 链接;`install.rs` / `model.rs` 的测试模块外置到子文件守住 400 行。

测试文件:`pages/download/{model,install}/tests.rs`(新),`pages/download/tests.rs` 里的
`from_snapshot` 调用补分类参数。

实证(回退法):删掉 `.filter(|view| !view.finished())` → 两条终态测试红并点名
「Done 之后不许再占着安装面板」;删掉 `.filter(|category.matches(..))` → 两条分类测试红
(「只剩正式版」left=3 right=1)。放回修复 → 全绿。

门禁:fmt --check 干净、clippy -D warnings 干净、app-iced 280 + 集成 6 + i18n 23 全绿(-j 4)。
2026-11-02 21:36:28 +08:00
tower 57e8380b24 feat(download): M94 下载页换成真组件 + 真快照 + 真指令(PAGEDL)
页面(crates/app-iced/src/pages/download*):
- Tabs(分类)/ ListItem(版本行)/ filled 安装按钮 / progress / EmptyState / divider
  全部取自 components/ 的真组件;页头动作换成 buttons::text
- 数据只吃 pages::download::Download(app/view.rs 由 AppState 快照转,installed_only 与
  download_query 照用);页面自己不留业务状态,交互只发意图
- 状态:加载(不确定进度 + 文案)/ 空(EmptyState + 刷新 CTA)/ 有数据 / 安装中 / 安装失败
  (失败给可读文案 + 「打开日志」出口)

接线(追加式):
- pages::Message 加 SelectVersion / InstallVersion / CancelInstall / SetCategory
- app/update.rs 翻成 AppCommand::{InstallVersion, CancelInstall, ListVersions}
  (SelectVersion 只改视图模型;「刷新」= 当前分类重新拉,全部走 RefreshAll)
- pages::Model 加 download 字段

文案:28 个 Download* key 进中英两套 catalog(一行一个 key 的全语言);页面零中文字面量,
  core 的中文阶段串(InstallStage::label / message)一律不进界面

i18n 结构:catalog.rs 的既有测试模块原样外置到 catalog/tests.rs(392 行 → 282 行,
  逐字未改),新 key 才装得下 400 行硬上限

测试:app-iced 276 + i18n 23 全绿;clippy -D warnings 与 fmt --check 干净
2026-11-02 21:36:22 +08:00