Commit Graph
39 Commits
Author SHA1 Message Date
AskaEth f6b7759aa8 docs(m126 r3): 收窄「唯一一份」的说法,并把 ③ 的承诺改成真话
纯文档,零逻辑改动(`git diff` 里每一行都是 `//!` / `///`)。

P2-1「唯一一份」不成立:`cli/src/cli/help.rs` 里 `ull help instances` 的词表行是
**会打印给用户看的第二份逐字副本**。四处说法收窄为「源头表在 core + 另一处逐字副本在
help.rs,由 `help_lists_every_stable_id` 按 `ALL` 遍历守着」:`game/loader.rs` 的模块
文档(原「生产代码里这七个串只有一处」)与 `as_str` doc(原「生产代码里唯一一份 id
字面量」)、`cli/commands/instances.rs` 的模块文档(原「全仓唯一一份 id 字面量」)与
`loader_json` doc(原「两样都只该有一份」)、`presenter/dto/instances.rs` 的 as_str doc
与契约测试 doc、`addon/loader/mod.rs` 的两处「唯一真相源」。

P2-2「③ 拦得住静默变原版」对显示路径不成立:`presenter/src/host/game.rs::dto_kind` 里
糊一支 `=> Vanilla`、同时把契约与 help 补齐,三个 crate 全绿而磁贴静默显示「原版」。
现在写的是「③ 拦的是**对外词表**(加种类、改词表都红),但**拦不住显示路径**,那处目前
没有守卫(已知缺口,另行记账)」。

顺带只做排版级的行数回收:loader.rs 400 → 397(≤400,仍留余量)。
门禁:cargo fmt --all --check → FMT_OK(本轮按要求只跑 fmt)。
2026-11-05 18:09:00 +08:00
AskaEth 14ac0dc248 test(m126 r2): 加一支枚举不更新对外契约现在必然红(集合断言 + help 守卫)
r2 复审指出「加一家加载器会被逼着表态」只做到一半:编译期只拦「漏 match」,三处消费方
用最省事的 `=> Vanilla` 糊过去既编得过、测试也全绿——新加载器会静默显示成原版,正是本
mission 要消灭的形态(r1 里「Rift 会红」是负样本名字撞上的巧合,不是守卫)。

- presenter 的 `loader_kind_ids_are_the_released_contract` 增加**第 ② 段**:把 core 的
  `LoaderKind::ALL.map(as_str)` 与独立手写的七个 id 做**集合相等**比较。加一支而不更新
  契约 → 红;改词表 → 红。(r1 删掉 `assert!(ALL.len()==7)` 后缺的正是这一环:数组宽度
  只保证「表里那一行写成 7 个」,不保证枚举没多一支。)
- 新增 `cli::commands::instances::help_vocabulary_tests::help_lists_every_stable_id`:
  按 `LoaderKind::ALL` 遍历 `ull help instances` 的词表行,漏写一个新 id 必红(此前
  `help.rs:67` 那段词表没有任何测试,而旧的 `help_documents_the_loader_field_contract`
  只查关键词,加一家新加载器时它照样绿)。
- `addon::loader::tests::vocabulary` 补 `installable_list_matches_the_from_core_mapping`:
  把唯一还手写的 `LoaderKind::all()` 与 `from_core` 认可的集合对齐(不重抄字面量)。
- 模块文档第 ③ 步改成真话:契约测试会拦「改词表」与「加新种类」;但 `=> Vanilla` 这种
  糊法**编得过**,拦住它的是那条集合断言,不是编译器(改名同理,只有契约测试会红)。
- 一并修掉 presenter 那条测试 doc 里指向已被删除的 `MARKERS` /
  `markers_cover_every_kind_that_has_one` 的引用,改指向现在真实存在的守卫。
- loader.rs 从 398 压到 395(≤400,留 5 行余量;拆测试到子模块需要 scope 放宽到
  `crates/core/src/game/loader/**`,超出本任务文件清单,已按规矩记账)。

验收实测(临时加 `Abyss` + 三处 `=> Vanilla` 糊过去,已还原):presenter 契约测试红
(left 8 项 / right 7 项),CLI help 测试红(漏了 `abyss`);core 往返测试与安装侧清单
守卫、CLI 的 `json_rows_*` 仍绿(它们本就管别的事)。
2026-11-05 18:08:59 +08:00
AskaEth be37c17962 refactor(core): M126 加载器稳定 id 词表收敛到 game::LoaderKind::as_str
M122 把「id → 加载器」的解析收到 core::game::loader::InstanceLoader::parse,但
「种类 → 对外稳定 id」的命名词表还散在多处,其中 install 侧那一份只靠一句「不要改名」
的注释约束,MARKERS 更是第四张无守卫字面量表。

- core::game::LoaderKind 新增 as_str(**全仓唯一一份 id 字面量**)、has_marker
  (穷尽 match:新种类必须表态认不认 id 标记)、parse(as_str 的反查)与 ALL(清单,
  顺序即标记优先级);MARKERS 的标记串改为取自 as_str,不再是独立字面量。
- addon::loader::LoaderKind::as_str 改成委派(结构映射,无字面量):安装侧拼出的
  <mc>-<kind>-<loader> 与解析侧从此同源。
- presenter::dto::instances::LoaderKind 的 as_str/parse 委派给 core(to_core/from_core
  是结构映射),删掉第四份词表;CLI 删掉 loader_kind_id 翻译层,直接调 core。
- 测试:core 新增 markers_cover_every_kind_that_has_one(MARKERS↔ALL↔has_marker↔as_str
  逐格对齐 + 按标记串 round-trip + neoforge 先于 forge);presenter 新增
  loader_kind_ids_are_the_released_contract(七个 id 逐字钉住 + 反查 + 别名隔离)。
- 对外输出未变:真二进制对同一组 7 类实例的 `instances list --json` 输出逐字节相同
  (md5 0341752658ca28e020ba48c31a51a124),人读输出同样逐字节相同。

file 行数:core/game/loader.rs 337→399(≤400 硬约束,文档按密度重排、事实未丢)。
2026-11-05 18:08:56 +08:00
AskaEth 081ce65596 fix(core): M122 版本 id → 加载器只留一份解析,并认全自家 id 口径
`presenter::host::game::loader_from_id` 只认 `fabric-loader-…` / `quilt-loader-…`,
而本启动器自己生成的 id 是 `<mc>-<kind>-<loader>`(`1.20.4-fabric-0.16.5` /
`1.20.4-quilt-0.27.0`),于是刚装好的 Fabric / Quilt 实例在实例页显示成「原版」。

- 新增 `core::game::loader`:`InstanceLoader::parse` 是全仓唯一的 id 解析入口
  (按 `-` 切段、段前缀匹配标记、跳过独立的 `loader` 段、砍老 HMCL 的 MC 尾巴)。
  覆盖自家四种口径(生成端 `addon::loader::LoaderKind::version_id`)、老 HMCL / CF
  口径、OptiFine / LiteLoader 第三方前缀;标记按整段前缀匹配,不再用「包含子串」。
- presenter 只留 DTO 翻译:`dto_kind` 穷尽 match(`dto/` 里仍不许出现 core 类型)。
- 测试:真值表**从生成端取 id**,反向锚(普通版本 id 不被认成加载器),映射单射,
  以及端到端快照(`instances()` 的磁贴数据对四个加载器各自的版本号与徽标)。
2026-11-05 14:46:22 +08:00
AskaEth bf6b0c4126 build(release): workspace 版本 0.1.0 → 0.2.0,README 的 tag 事实改成 v0.2.0 口径
- Cargo.toml [workspace.package] version = 0.2.0;Cargo.lock 由 cargo 重新解出一致
  (只有 8 个 unlucky-* 成员条目变动,第三方版本一字未动)
- 各 crate 的 `unlucky-*` path 依赖 `version = "0.1"` 同步提到 "0.2",否则工作区
  根本解析不出来(cargo metadata 会报 `failed to select a version for
  unlucky-core = "^0.1"`);crates/app 虽被 exclude、cargo 不读,也一并跟上避免将来
  重新纳入时立刻解析失败
- core::launch::LauncherConfig 兜底的 launcher_version 改成 env!("CARGO_PKG_VERSION"),
  不再是把版本写死一份(CLI / presenter 本来就这么写)
- README.md / README.zh-CN.md:删掉「v0.1.0 是目前唯一的 tag / the only tagged release」
  这类随 v0.2.0 发布即变假的陈述,改写成「v0.1.0 是最后一个 Slint 版 tag,v0.2.0 是
  重写后的第一个 tag」;所有讲 v0.1.0 资产 / Slint Royalty-Free 义务的历史记录原样保留
- packaging/README.md:命名与校验示例跟当前 tag 走(v0.2.0);§7 实测记录里的 0.1.0
  输出加一句「属实测记录、不回填」,并把过期的「四个 workspace member」改成「任何一个」
2026-11-05 14:08:37 +08:00
AskaEth 1a4e1fee12 feat(app-iced): M120 实例页与启动阶段标签跟着界面语言走
来源:M106 r1 复审点名——中文机器上首屏(实例页)仍是英中混排(页头 / New instance /
Import / ALL INSTANCES / VERSION / Present on disk / Running · pid / Launch failed)。
用户的原话是「我系统语言是中文 为什么打开软件是英文」。

改了什么
- i18n:catalog/instances.rs 从 6 条死 key 补到 34 条(页头 / 列表列头 / 详情动作 /
  窄窗说明 / 元信息标签与值 / 隔离档位 / 运行状态 / 四个启动阶段)。族在装配链第 2 位,
  新 key 追加在本族末尾,golden 对应位置插入(不是文件末尾追加)。
- 页面:新增 pages/instances/text.rs(Texts)——页面唯一的取词入口,实例页里不再有
  任何可见英文字面量;detail / demo 也改从它取。
- 阶段标签:view/instances.rs 的 Running / Launch failed / 四阶段全部走目录;
  **数字与分隔符不随语言变形**(运行中 · pid 1234 与 Running · pid 1234 只换前半句)。
- 顺手修掉两处「语言反向泄漏」:原版徽标此前直接画 presenter 的中文「原版」(英文界面
  会漏中文),现在走 LoaderVanilla;隔离档位 isolated/shared 也从 core 的稳定 id 换成人话。
- 删掉 InstancesLastPlayed:M117 加的整句 key,从未接线,形状与「标签 + 值」的元信息卡
  不符,由 InstancesMetaLastPlayed + InstancesNeverPlayed 取代。

守门(pages/instances/tests/i18n.rs + app/view/tests.rs)
- 中文态无 ASCII 英文单词(品牌 Java / 技术标识 pid 白名单);
- 每一条 Instances* 的两套译文必须不同(切语言时真的变);
- Running 的数字与 · pid 分隔符在两套语言里逐字保留;
- 页面源码里 29 条旧英文基线不许回来;每条 key 都必须被页面引用(死 key)。

出图:DevDocs/artifacts/m120/01~12(有实例 / 空态 / 运行中 × 浅深 × 中英)。
2026-11-05 13:36:44 +08:00
AskaEth 1cb36da1e2 docs(presenter): M116 r4 P3 —— settings_bridge 的「三个键」漂移对齐到四个字段
AppearanceBase 自 M116 起有四个字段(整窗 α / 标题栏三态 / 配色来源 / 窗口圆角),
load_base 的文档与测试名还写着「三个键」。只改注释与测试函数名,行为零改动。
2026-11-05 06:35:19 +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 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 0fdc889af6 refactor(presenter): M114 rebase 后守 400 行 —— 实时日志滚动窗口搬进 state/live.rs(纯搬移) 2026-11-05 05:37:43 +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 0f69eec64d refactor(presenter): M114 mock 的「取消登录」搬进 mock/accounts.rs(守 400 行) 2026-11-05 05:10:48 +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 e09411f975 refactor(presenter): M114 离线名字契约拆到 dto/accounts/name.rs(守 400 行,补边界用例)
dto/accounts.rs 因搬入 valid_offline_name 涨到 404 行;按模块拆分惯例独立成文件,
并补 16 位上限 / 17 位超限 / 空串 / 非 ascii 的边界用例。
2026-11-05 05:10:14 +08:00
AskaEth 178f6ebbff fix(presenter): M114 错误分类只看结构化字段(status/kind/server_message),不再 Debug 整条错误
- `AuthError::Response` 的判据从 `format!("{detail:?}")`(连中文 what/digest 一起扫)
  改成读 `ResponseDetail{status, kind, server_message, digest}`
- 5xx 与拦截页(Html/Text)归 `Unreachable`(「连不上」而不是「服务器拒绝了你」),
  AADSTS/consent/whitelist 标记仍优先判 `NotWhitelisted`
- 补两条分类用例:AADSTS 优先于一般拒绝、5xx/拦截页归不可达
2026-11-05 05:10:14 +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 9d0d985b1b fix(app-iced, presenter): M114 r3 —— 登录成功提示走 i18n key+参数,离线名字提交判据与 host 同源
- 新增 Key::AccountLoginSucceeded(Signed in as {name} / 已登录 {name}):
  presenter 不再拼「已登录:{name}」这类人类可读句子(复审 r1 P1-1 的收尾)
- valid_offline_name 从 host 搬到 dto(UI 与 host 共用的同一个判据),
  Form::offline_ready 改用它:按钮不再「亮着但必然失败」
- 视图模型测试:成功提示按 i18n 渲染进 on_screen;离线提交判据加「太短也拒绝」断言
2026-11-05 05:09:52 +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 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 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 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 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 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 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 da2da4cd9b fix(app-iced,presenter): M93 r4 —— Float 键写不回 / prefs.rs 超限 / CPU 档判定 / 平铺退化
复审(代码)r1 的两条 P1 + 两条必修。

P1-1 `SetSetting` 写不回 Float 键(整窗透明度重启回旧值)
- `presenter/src/host/settings.rs::parse_value` 改成**按 schema 的 `ValueKind` 取类型**,
  不再硬编码 Int/Bool 键名。历史:`appearance.window_opacity`(Float)被当 String 送进
  `store.set` → schema 的 `as_f64()` 返回 None → `InvalidValue` → **`save()` 根本没执行**,
  α 只在内存生效、重启回旧值、每次拖动弹一条 Error toast。
- 测试(presenter 层,`host/settings_tests.rs`):
  ① `float_key_round_trips_through_set_setting`:set 0.8 → **新开 store(= 重启)** 读回 0.8;
  ② `every_float_key_in_the_schema_can_be_written_back`:遍历 schema 里**所有**
  `ValueKind::Float` 的键逐个写回并读回(集合级守门,下次新增 Float 键漏接会立刻红)。

P1-2 `presenter/src/prefs.rs` 404 行 → 拆测试
- `#[cfg(test)] mod tests` 整体挪到 `crates/presenter/src/prefs/tests.rs`,结构体与 re-export
  一字未动。`wc -l`:**404 → 315**(tests.rs 90 行)。

P2-2 CPU 档判定只在 `--renderer=cpu` 时成立
- 新增 `app/backend.rs`:`effective_backend()` 把 `probe::apply` 写下的 `ICED_BACKEND`
  候选列表翻回档位(`tiny-skia` / `tiny-skia,wgpu` → CPU;`wgpu,tiny-skia` → GPU;未设 → 请求值)。
  `App::with_presenter` 用它喂 `appearance::State`,于是**默认 Auto 档在没有独显的机器上**
  也会走「面板 α / 玻璃强制 1.0」。
- 测试:`every_iced_backend_list_maps_to_a_tier` + `auto_with_a_software_renderer_forces_the_panel_opaque`。
- 实跑:`ICED_BACKEND=tiny-skia`(= probe 在无独显机器上写下的值)→
  `[appearance] boot … panel_alpha=1.000 (requested 0.550) backend=cpu`。

P2-1 平铺退化成「一条竖条」——根因不是几何,是**渲染层没吃 scale**(r3 的编辑没落到 `tiled()`)
- `picture::tiled()` 原来仍用 `ContentFit::None` + 原图尺寸,所以 `tiles()` 算出的 `scale`
  完全没被消费:小图被按 32dp 画在 129dp 的格子里 ⇒ 到处是缝。
- 现在:每块 `源尺寸 × tiles.scale`、`ContentFit::Fill`、行高显式 `Fixed`、整列 `Fill`。
- 诊断:新增 `background::small_tile_note()`(短边 < 200dp 的图在 tile 档下给一句
  「scaled up so the window stays covered」),已接进设置页诊断块(首屏可见)。
- 测试:`tile_grid_always_covers_the_viewport_within_budget`(6 组尺寸,含 32×32 / 16×16 / 1×1,
  断言覆盖 ≥ 视口且块数 ≤ 预算)、`small_images_get_a_tile_diagnostic_instead_of_silently_degrading`。
- 出图:`r5-tile-tiny32.png`(32×32 铺满全窗、右缘无裸白、提示可见)、`r5-tile-120.png`、
  `r5-tile-big.png`。

顺手(复审 P2-3/P2-4/P2-5)
- P2-4:`MAX_SOURCE_EDGE` 16384 → **8192**,并给解码器加 `image::Limits`(宽/高 + `max_alloc`
  320MB)作第二道闸;补 `an_over_sized_image_reports_too_large_instead_of_trying_to_decode`
  (`TooLarge` 这条分支此前无测试)。
- P2-5:`transparency.rs` 补一段「为什么 `LWA_ALPHA` + `WS_EX_LAYERED` 不影响命中测试与
  自绘标题栏拖动」(穿透要 `WS_EX_TRANSPARENT` 或 `LWA_COLORKEY`,两者都没用;`window::drag`
  走 `WM_NCLBUTTONDOWN(HTCAPTION)`)。
- P2-3:断链 `[background::dimmed]` → `dim_alpha`;`iced/image` → `image-without-codecs`;
  测试文档 204 → 205;马赛克残留说明只保留 r1→r2 的历史一句。
- 解码在 UI 线程同步做:**登记欠账**(挪后台线程超出本 mission)。
2026-11-02 18:46:43 +08:00
AskaEth 22e4c423fa feat(app-iced): M93 r2 —— 背景图真图直出 + 面板 α 收敛到 Ui::layer
复审前补齐两处(tower 扩 scope:Cargo.toml / pages/frame.* / theme.rs / presenter::prefs)。

1) 背景图**真图直出**
- `iced` 开 `image-without-codecs`(刻意不用 `image`:后者会打开 `image/default` 全套编解码器);
  编解码能力由本 crate 的 `image` features 决定,补了 `jpeg` / `bmp`(用户挑的背景图/壁纸多半是 jpg)。
- `pages/appearance/picture.rs`:`Cover`/`Contain` 走渲染器 `ContentFit`,`Tile` 按 `background::tiles`
  算行列(上限 64 块)真的铺 N 个图块;暗化 = 一层「版面底色 @ α=dim」叠在图上。
- 36dp 马赛克那条路**已删除**(不再是主路径,也不留死代码)。
- 踩坑记录:`Handle::from_bytes` 的纹理分配赶不上截图第 3 帧(实测画面全空),改用
  `Handle::from_path`(渲染器自己解码),已在 `picture::image_handle` 的文档里写明。

2) 面板 α **收敛到唯一来源**
- `Ui::layer()` 直接乘上面板 α(新增 `Ui::panel_alpha` / `with_panel_alpha`,默认 1.0、
  `scale_alpha(1.0)` 逐字节不变),`app::rebuild_ui` 注入生效值。
- 于是外壳(根容器/标题栏/导航轨)、`frame::card`(实例/下载/日志三页卡片)、
  `frame::header_bar`(新增底色)、外观分组卡片、空态图标块**全部同源**,
  调用点不再有任何 `panel_color(..)` 自乘(该 helper 已删)。
- 有背景图时根容器不再盖一层实色画布(否则 panel α=1 时背景图整块被挡)。

3) `presenter::prefs` 补重导出 `PANEL_ALPHA_MIN` / `BG_DIM_MAX`,app-iced 里两份 0.4 / 0.95
   字面量改为转发常量。

验收:11+3 张图(`C:\Temp\ull-shots\wt-93\r2-*.png`)——三档填充肉眼可分、`.jpg` 能加载、
GPU 档 panel55 时卡片/分组卡/页头带都透出背景、CPU 档仍强制 1.0 且背景在面板空隙可见;
另附实例/下载/日志三页 panel55 各一张,证明三页都吃到同一个 α。
2026-11-02 18:07:32 +08:00
AskaEth b8ab46662c feat(app-iced): M93 —— 外观接线:整窗 α / 面板 α / 背景图 / 配色来源
把 appearance.* 那批键接到窗口与像素上(M91 只落了键与判据,零调用方)。

整窗 α(D34/D36)
- `ensure_layered(hwnd)` 落在唯一 unsafe 文件 `platform/win32/transparency.rs`
  (`WS_EX_LAYERED` 只有 Get/SetWindowLongPtrW 能加,且 CI 的 unsafe-scope 是集合相等);
  `app/boot.rs` 只负责开窗,句柄到手后由 `Message::WindowHandle` → `window_alpha::dispatch`
  先补样式再下发。本机实测 `layered=ok outcome=Applied { byte: 205 }`(α=0.8)。
- 三条入口都会重算并下发:启动(句柄到达)、设置页改动、配置文件热切。
- `layered` 日志有三态:`n/a`(α≥1,压根不需要)/ `ok` / `FAILED`,避免把「没用到」写成假红。

面板 α 与 CPU 档(D36)
- 面板 α 独立于整窗 α:只作用到外壳层(根容器 / 标题栏 / 导航轨)与外观卡片;
  CPU 档强制 1.0、玻璃强制关(`pages/appearance/alpha.rs` 的真值表 + 单测)。
- 色卡档继续走令牌 baseline,只有取色档(①②级)才换 `Ui` 的 scheme(`Ui::from_scheme`)。

背景图(D38)
- 读盘解码(png)→ cover/contain/tile 三种填充几何 → `bg_dim` 向底色退 → 36dp 网格马赛克;
  iced 的 `image` widget 需要 `iced/image` feature(Cargo.toml 不在 scope),故为已登记降级。
- 读盘失败静默回落 + 一条可读诊断(设置页错误行 + 日志),配色经 `Provenance` 回落色卡。

设置页「外观」分组
- 整窗透明度滑块(下限 0.08)/ 面板透明度(CPU 档禁用并写明 55%→100% 的降级)/
  背景图开关与路径 / 填充三档 / 暗化 / 配色来源三档;改完即时生效并写回:
  bridge 覆盖的 5 个键走 `SettingsBridge::persist`,`window_opacity` / `palette_source`
  走 `AppCommand::SetSetting`(每个键只有一个写者)。
- `SettingsBridge::load_base()`(presenter)让 app-iced 不需要直接依赖 core 就能读回
  `window_opacity` / `palette_source`。

取证
- `ULL_APPEARANCE_*` / `ULL_START_ROUTE` 只影响本次进程(语义同 ULL_TICK_TRACE),
  截图通道不读用户配置(D30)。
2026-11-02 18:07:31 +08:00
AskaEth fa2c6560fd fix(appearance): M91 r2 —— 真交叉断言 + unsafe 门禁跨行扫描 + α 上取整
P2-1「与 design 码值一致」的守门此前不存在
- 新增 crates/app-iced/src/platform/mod.rs::palette_source_codes_match_design_level_codes:
  三个格子对照 PaletteSource::as_str() 与 unlucky_design::palette::Level::code(),
  并覆盖反方向(from_key_value(level.code()) 必须还原同一档)与「码值在允许值表里」
- core 侧那条测试改名为 value_tables_are_self_consistent,注释改成事实:
  core 不依赖 design,只能钉自己的字面量;一致性由 app-iced 的那条对照断言守
- 实测:把 design 的 Level::Wallpaper 码值改成 "desktop" → 新断言红(design 自己 178 个测试仍全绿)

P2-2 unsafe-scope 门禁:行级正则 → 跨行扫描
- 去注释 + 按方括号闭合把续行并成逻辑行,再逐个属性组判断 allow(...unsafe_code)
- 抓得住原来两种绕过:多行属性 #![allow(\n unsafe_code\n)]、同行两属性 #[inline] #[allow(unsafe_code)]
- 门禁注释补全已知边界(不再声称「依赖 fmt job 兜底」——已不依赖)

O-1 α 量化方向:round → ceil
- 0.08×255=20.4,round 得 20 → 实际下发 20/255=0.0784,低于 D36 名义下限
- ceil 得 21 → 0.0824;新增 applied_alpha_is_never_below_the_floor 断言 下发字节/255 ≥ 0.08

O-7 契约测试补三个新键
- appearance_keys_are_all_ai_writable_and_registered 的 audited 清单加 window_opacity /
  titlebar_mode / palette_source
2026-11-02 14:28:36 +08:00
AskaEth 7406785325 feat(appearance): M91 —— 外观下层三键 + win32 整窗 α FFI + unsafe 落点门禁
core(D36/D37/D38 的键与范围)
- appearance.window_opacity(0.08..=1.0,默认 1.0,越界钳位且不报错;下限 D36 定案)
- appearance.titlebar_mode(auto|always|never,默认 auto;判定逻辑不在本 mission)
- appearance.palette_source(background-image|wallpaper|preset,与 design 的 Level::code() 同名)
- schema 1.3.0 → 1.4.0:纯增量、不加迁移步骤(新键出厂值 = 老文件缺键)
- appearance 拆成目录 mod/modes/tests(单文件 ≤400 行),非法枚举由 diagnostics() 给可读回落说明

presenter(只读语义,不破 M89 零 UI 门禁)
- AppearanceBase { window_opacity, titlebar_mode, palette_source } + from_document()
- 单测:缺键→出厂值、越界→钳位、非法枚举→回落且合法值照读

app-iced(D36 唯一 unsafe 落点)
- platform::effective_window_alpha(backend, requested) 纯函数:≤0/非有限 → None(拒绝整窗穿透),
  其余夹到 0.08..=1.0;CPU 档同样允许窗口级 α(D36 对 D27 的修订),4 组真值表测试
- platform/win32/transparency.rs:SetLayeredWindowAttributes(LWA_ALPHA) 的 FFI +
  alpha_byte 量化 + apply_window_alpha;非 Windows 同签名空实现返回 Unsupported

CI
- guards.yml 新增 unsafe-scope job:全仓 allow(unsafe_code) 文件集合必须恰好等于
  crates/app-iced/src/platform/win32/transparency.rs(集合相等,不是黑名单命中)

版本号绊线
- isolation_keys / java_install_keys:字面量 1.3.0 → 1.4.0(绊线保留字面量)
- store / document 的两个关系夹具改成相对 launcher_version() 构造(「更高 minor 只读」/
  「同 minor 更高 patch 保留 $schema」),下次 bump 不再假绿或假红
2026-11-02 13:58:33 +08:00
AskaEth d03ba13c0f test(presenter): 修正 tile_viewport 单测的期望值(760/128 量化到 640 而非 768)
迁移后首次跑 test 时这条自测断言写错:函数行为与原 ui_bind 的实现逐字一致,
错的是期望值。修完 `cargo test -p unlucky-presenter` = 122 passed。
2026-11-02 10:11:55 +08:00
AskaEth f159529936 feat(presenter): 新建 unlucky-presenter,把 host/状态/DTO/设置桥从 app 迁出(M85 第 1 块)
D26(iced 重写)的关键一次性动作:UI 框架无关的宿主层独立成 crate,
Slint 版(crates/app)与 iced 版(crates/app-iced)共用,Slint 退役后不再重写一遍。

- 新建 crates/presenter(package `unlucky-presenter` / lib `unlucky_presenter`),
  workspace 登记 + crate 文档头写清「这层为什么存在」、对外表面、UI 侧允许/禁止、
  iced 接线骨架、以及哪些东西留在了 crates/app 及为什么。
- `git mv`(保留文件历史)迁入:host/(25 文件)、dto/(11 文件)、state.rs(+tests)、
  prefs.rs、settings_bridge.rs、toast.rs、router.rs、dialogs.rs、wizard.rs、
  mock.rs、mock_data.rs、backend.rs。模块路径与公开 API 名称不变(`host::install` 等),
  内部实现零改动。
- 新增 `theme`:把原本混在 ui_bind/theme_runtime 里的**纯判据**(CONFIG_POLL_TICKS /
  SYSTEM_THEME_POLL_TICKS / TILE_REGENERATE_STEP / BackgroundKey / tile_viewport /
  system_theme_value / 两个 poll 判据)抽出来,附 5 个单测;app 侧改成调用它们。
- crates/app 改为消费者:删掉迁走的文件,`crate::host|state|dto|prefs|settings_bridge|
  toast|router|dialogs|wizard|mock|mock_data|backend` 全部改写为 `unlucky_presenter::*`
  (64 处),backend.rs 的 config_path/build_backend 提为 pub。
- crates/app-iced 只登记 unlucky-presenter 依赖,本 mission 不接线。
- prefs.rs 的 2 处 slint 文档引用改写;`grep -rn slint crates/presenter` 现在无输出。
2026-11-02 09:29:31 +08:00