Commit Graph
852 Commits
Author SHA1 Message Date
AskaEth 583a6cf24b perf(launch): M304 —— 点启动/停止不再在 UI 线程扫盘
M302 把 `refresh_instances` 的扫盘搬进了后台线程,但「点 Play」那条路上还留着两次
`game::instances(&self.env)`(`launch_target` 与 `running_pid`):官方根 200 个版本
236 ms、叠加 10 份外部档案 1.35 s,全部落在 UI 线程上。

改法:两条查询都读**已发布快照**(`CoreHost::send` 发 `InstancesChanged` 时留一份),
并在受理那一刻补一道**单实例**复核(`game::Scan::revalidate`),于是

- 调用方线程一次盘都不扫(一次内存查表 + 微秒级复核);
- 快照过期不会拉错东西:档案实例查 `profiles.json` 是否还在、版本有没有被改;
  自动发现的 `ext-` 实例用「逐个根重算 id」定位 (根, 版本),再让 `adopt::inspect`
  只判这一个版本(不扫 `versions/`)。复核不过 ⇒ 拒绝 + 具名原因 + 立刻重扫。
- 同名不猜:名字兜底只在唯一命中时用,两个同名实例直接拒绝(多根之后很常见)。
- 「点下去立刻有反馈」不变:`Busy` 仍在 `dispatch` 返回前发出(`launch::accept`)。

拆分:`launch_target` / `in_flight` / `mark_pending` / `start_launch` / `running_pid`
从 `host/mod.rs`(原 395/400 行)搬进 `host/launch/accept.rs`;解析与拒绝形状在
`host/launch/target.rs`,单实例复核在 `host/game/refresh/live.rs`。

判据(`host/launch/target_tests.rs`,5 条):
- 同一拍进 Busy(无 tick、无后台回程),且受理不派扫描(代次不变);
- 快照落地后**新增**的实例点启动必须被拒绝(解析改回现场扫盘 ⇒ 红,实测);
- 快照落地后**删掉**的实例点启动必须拒绝并给具名原因(去掉复核 ⇒ 红,实测);
- 两个同名实例不猜;停止读快照里的 pid。

门禁:`cargo test -p unlucky-presenter -p unlucky-app-iced --all-targets`(423 + 1075 全绿)、
`cargo clippy --workspace --all-targets -- -D warnings`(0)、`cargo fmt --all --check`(0)、
文件行数全 ≤400(`host/mod.rs` 340、`refresh.rs` 382、`launch/` 90/114/242/280)。
2026-11-13 20:14:37 +08:00
AskaEth bdb97f8189 Merge branch 'feat/m302-instoffload-instance-list-refresh-o'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-13 19:56:27 +08:00
AskaEth 23c7beee8d test(instances): M302 r2 —— 四条 P2:线程判据真能红 + 两处相位守卫 + 「只报一次」改成可数
- P2-1:`spawn_scan` 的闭包提成 `scan_body`,单测可装探针 + 闸门(只拦同一数据根的扫描)。
  新用例 `a_refresh_returns_while_the_scan_body_is_still_parked` 断言四件事:扫描体跑在
  **另一条线程**、返回时**一趟都没跑完**、闸门**还没放行**、返回耗时 < 闸门上限的一半。
  把 `spawn("instances", job)` 换成直接 `job()` ⇒ 该用例红(原文见报告);旧用例
  `a_refresh_returns_before_the_scan_is_done` 的 docstring 改成诚实口径(它证明不了不在调用方线程,
  inline 时它照样绿——正是复审 r2 点的那条)。
- P2-2(真缺陷):`check_scan_stall` 补上与 `refresh_instances` 同一个守卫——已就绪
  (`adopted().is_some()`)之后不再报 `Stalled`,否则一轮慢刷新会把有数据的一屏换成
  「扫描实例比平时久 + 重新扫描」。新用例 `a_slow_refresh_after_ready_never_reports_stalled`。
- P2-3:`a_stalled_scan_reports_once_and_a_late_snapshot_still_lands` 原来把 `drain_events` 调了
  两次(第二次必空 ⇒ 断言空转)。改成数实际发出的条数:第一拍恰好 1 条,之后三拍必须 0 条。
- P2-4:新用例 `a_refresh_after_ready_emits_no_scanning_phase`(真 host:已就绪后再刷新一条相位
  事件都不许发)+ app 级 `a_refresh_after_ready_keeps_the_list_and_the_tree`(相位仍 `Ready`、
  真控件树的文字/按钮节点数与刷新前逐项相等、列表没被清空)。

四条变异(inline / 去 P2-2 守卫 / 去「只报一次」锁 / 去 Scanning 守卫)都实测红,原文进报告。
2026-11-13 19:36:33 +08:00
AskaEth f03411d58b fix(instances): M302 r1 P2 —— 冷启动首帧三态(占位 / 真空态 / 有实例)+ N1~N4
复审 P2-1:扫盘搬后台之后,`App::dispatch(RefreshAll)` 返回时快照还没到(`pump` 只抽队列、
不 `tick`),**首帧必然**在快照之前。改前那一帧画的是「计数 0 + 还没有实例 + 实心导入 CTA」
——一句假话,而且 CTA 能点。改后三态分开:

- 真源放 presenter:`dto::InstancePhase { Scanning, Stalled, Ready }`,`AppState::instances_phase`
  在 `CoreEvent::InstancesChanged` 落地时置 `Ready`;**不从 `instances.is_empty()` 推**(那就是 bug 形态)。
- `Scanning` = 首个快照未到:详情列画「正在扫描实例…」占位(新 key `InstancesScanning`,golden 两语),
  列头**不写计数**(藏起来,不写 0 也不写「—」),**没有**空态 CTA。
- `Stalled` = 有界兜底:`host::game::SCAN_STALL = 15 s`(= 仓库内最坏实测 1.35 s 的约 11 倍,
  理由在常量文档里)。超时只**报**一次、不掐线程;迟到的结果照样落地(代次没变)。
  那一屏给一枚「重新扫描」→ `InstanceMessage::RetryScan` → 新命令 `AppCommand::RefreshInstances`
  (只重扫实例目录,不重拉版本清单)。那一支在 `selected_instance_id()` 守卫**之前**——冷启动没有选中项。
- `Ready` = 收到过快照(空列表也算):这时才轮到 M273 的两种空态(没选中 / 一个都没有)。
- 已就绪时**不发** `Scanning`:否则每次刷新列表都退回占位(把卡顿换成闪屏)。

判据(都能红,变异原文见报告):
- `pages/instances/tests/cold_boot.rs`:真控件树(未就绪 7 枚按钮 = 有实例那一屏,零实例 = 8 枚,
  多的那枚就是 CTA;每行 2 条文字节点的差分数行数)+ 源码结构(`Scanning`/`Stalled` 两支不碰
  `detail_empty` 与空态文案、超时那支接 `RetryScan`、三个 key 真的被读)。
- `app/update/instances/scan_phase.rs`:首帧 = `Scanning`、空快照 = `Ready`、`Stalled` 经重试回到
  `Scanning`、重试只派 `RefreshInstances`。夹具 `ULL_INSTANCE_PHASE` 只给出图(与 `ULL_SAVES_FIXTURE` 同一条纪律)。

顺手收 N1~N4(全是措辞 / 观测口径,不动行为):
- N1 两处 docstring 不再声称「`admit` 写成 `>=` 会红」(实测等价),改成「去掉或恒真才红」;
- N2 收尾用例那条 Idle 断言的失败消息改准(它不区分新旧快照,区分靠条数/代次断言);
- N3 boot 双刷新必然丢 1 份 ⇒ 丢弃日志分成「此前没有快照落地(启动期,非交错)」与「已有落地(真交错)」,
  `Scan::dropped` 的文档登记这条已知噪音;
- N4 app 用例 docstring 收窄成「一拍无事件不清表、不钉时钟」(它绕过 `App::dispatch`,抓不住那 5 行)。

拆文件守 400 行:`detail/scan.rs`(分派 + 占位)、`refresh_stall_tests.rs`(空根 / 超时)、
`app/update/instances/scan_phase.rs`(app 级三态用例);`i18n` 的 `PAGE_SOURCES` 同步加上新页面文件。
2026-11-13 19:00:30 +08:00
AskaEth a0413a5f94 feat(host): M302 实例列表刷新搬出 UI 线程(扫盘进后台 + 代次丢弃过期结果)
`refresh_instances()` 以前把「扫盘」与「盖回运行时态」写在一起,而 `dispatch(RefreshAll)`
那一支是唯一没走 `job::spawn` 的(邻座 ListVersions / ListLoaderVersions 都走了)⇒ 同步跑在
UI 线程:官方根 200 版本约 236 ms、叠加 10 份外部档案 1.35 s(仓库内旧实测 M297)。

两半分家:
- `game::instances(&env)` 是纯 env 函数 ⇒ 搬进 `job::spawn("instances", …)`,回程是新载荷
  `HostTask::Instances { epoch, instances, elapsed_ms }`;
- 「按 `launches/pending/failed` 覆写 Running/Busy/Failed」要读 host 自己的表 ⇒ 留在 host 线程,
  挪到结果回来那一刻(`handle::apply_instances`),结论与之前逐字一致。

顺序:每次刷新领一个只增不减的代次(`game::Scan`),只采纳当代次那份。并发两次刷新 / 扫描途中
删实例 / 收尾触发第三次这三种交错下,先发后到的扫描一律丢(不发事件:清空再重填只是把卡顿换成
白屏)。不做线程取消 —— 扫描是纯读,没有值得取消的东西,丢结果比掐线程安全。

可观测:每次采纳打一行 `[instances] N 个实例 · 扫盘 X ms · epoch E · 丢旧代次 D 份`,
丢弃也各打一行;账本 `game::Scan` 可直接断言(实例数 / 耗时 / 丢包数)。

判据(都真构造:真临时根 + 真 profiles.json + 真线程,只有交付顺序是摆的):
- `a_refresh_returns_before_the_scan_is_done`(改回 inline 即红)
- `two_refreshes_in_flight_publish_only_the_newest`
- `an_older_scan_cannot_resurrect_what_a_newer_one_saw_deleted`
- `a_launch_end_refresh_beats_the_two_that_were_in_flight`
- app 侧 `a_refresh_in_flight_keeps_the_list_and_does_not_pin_the_clock`(不清空 + M298 的
  view_dirty 不永久置真)
- `launch_state_tests` 的 5 处 `refresh_instances()` 后补一行 `pump_instances()`(断言一字未动)
2026-11-13 17:58:59 +08:00
AskaEth 38fbd2a778 Merge branch 'feat/m301-railwidth-page-width-follows-live-r'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-13 17:24:48 +08:00
AskaEth cb580a257e feat(layout): M301 页面宽度跟随活轨宽(折叠 −64 / 展开 −88 / 动效插值)
用户口径「折叠腾出空间」只完成一半:M300 落了折叠态(ui.nav.collapsed + 一条并列宽度轨)
与折叠档令牌 nav_collapsed_width = 64,但 `pages/layout.rs` 的 `rail_width()` 仍是
`const fn` 恒返回 88 ⇒ 折叠后页面照旧按 88 扣宽,内容区右侧留一条 24px 空白带。

## 轨宽从常量改成**当帧活值**(不存第二份状态)
- 新增 `pages/layout/rail.rs`:thread-local `Option<f32>` 采样槽,`rail_width()` 读它,
  未采样回落展开档令牌 88(不 panic、不 unwrap;NaN/inf/负数不写入)。
- 写入口**只有一个**:`app/view/rail.rs` 的 `rail_account()` —— M300 自己点名的「折叠档与
  过渡进度的唯一构造点」,一次 `App::view()` 调用恰好一次,且排在页面树之前
  (`app/view.rs` 的 `let account = self.rail_account();`),所以页面扣的宽度与外壳画的轨
  是同一帧同一个数。折叠的**决定**仍在 presenter `prefs.nav_collapsed`,进度仍在 motion 轨。
- `rail_width()` 签名不变 ⇒ `pages/mod.rs`、`pages/agent/**`、20 个页面/测试文件的调用面
  **零改动**(`git diff --stat` 里那两处都没有出现)。
- 动效期间自动插值:采样值就是 `RailAccount::nav_width` 的 64⇄88 插值结果。
- 门槛跟着动(公式里本来就是 `+ rail`):720/816 → 折叠 696/792。

## 测试
- `pages/tests/rail_width.rs`(新):折叠=窗口−64、展开=窗口−88、差正好 24、块右缘贴死窗口;
  无外壳通道回落 88;采样不粘滞;门槛跟活值;**唯一写入口**(扫 `src/**` 源码);
  **先写后读**(扫 `app/view.rs` 语句顺序)。期望值全从令牌取,不再写 88.0 / 64.0。
- `app/view/rail.rs` 新增接线用例:折叠→采样→展开→再采样必须回到 88(不粘滞)、
  动效中间帧页面可用宽跟着插值(首尾逐位落在端值)。
- 原有钉 88 的三条改为「跟随模型」并逐条给了新算式(标题左缘 1180−88−48、块宽、
  主列 pane);`pages/tests.rs` 400 行门禁前先拆出 `tests/demo_model.rs`。

## 判据
- app-iced:1064 passed(M300 基线 1053 + 11 条新用例);no_hardcoded_tokens 6 passed。
- `clippy --workspace --all-targets -- -D warnings` / `fmt --all -- --check` 均 0。
- 变异:`rail_width()` 改回常量 88 ⇒ 2 条几何判据 + 门槛判据红(1068 vs 1044);
  删掉外壳那一行写入 ⇒ 2 条接线用例红(折叠仍按 88 扣宽)。
- 像素(DevDocs/artifacts/m301/,本地不进仓):日志页折叠态内容右缘 1107 = 展开态 1107
  (24px 全进内容),变异版折叠态 1083(那条 24px 空白带);`instances` 页因详情列被 424
  封顶、列表列固定 336,改前改后**逐像素相同**,所以举证必须用单列页。
2026-11-13 17:06:12 +08:00
AskaEth e9090d5668 Merge branch 'feat/m300-navcollapse-nav-rail-collapse-butto'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-13 16:42:58 +08:00
AskaEth fa53d72f73 feat(nav): M300 导航轨折叠按钮 + 折叠宽度令牌 + 宽度过渡与落盘
用户原话「默认左侧固定,允许折叠」:M297 盘查确认 iced 从来没接过折叠按钮,
presenter 的 toggle_nav/nav_collapsed 是 Slint 遗留、唯一调用方在已退役的 crates/app。

## 令牌(先定数值再动手)
- nav_collapsed_width = 56 + 2 × Space::X1 = **64**:胶囊(M283 定稿 56×32,本轮不动)
  两侧各留 4px 才不被裁切;旧值 48 的死因写进注释(48 − 2×4 = 40 < 56)。
- 断言:折叠宽 ≥ 胶囊宽 + 2×X1、≥ touch_target 48、在 4px 栅格上。

## 折叠按钮与折叠态
- 按钮放**轨底、头像位之上**:轨顶一个像素都不动(M283/M299 的展开态对照图仍可比),
  轨底本来就是「固定贴底」的槽,两态下位置不变。几何与导航项同一套(56×32 药丸 + 52 命中高)。
- 折叠态:隐藏文字标签与账户名,每项/头像/按钮都补 M3 气泡 tooltip(折叠态不许是一列无名符号);
  纵节奏不变(项高仍 52),所以切换时图标不上下跳。五项图标两两不同由用例钉住。

## 动效(复用底盘、零新常数)
- 帧泵加**第二条并列宽度轨**(nav ⇄ rail),时长/曲线复用同一张 rail_spec();
  借用右栗那条会让左轨折叠改掉右栗的目标(暗耦合),tower 已否掉那个方案。
- 起跑在 update 侧;启动按落盘档对表(幂等,仅在没有轨道在跑时);折叠**不置 view_dirty**
  —— 宽度在 view 里逐帧采样,帧由轨道自己续,跑完 needs_frames() 回 false。

## 落盘(任务要求「重启读回仍折叠」)
- core schema 登记 ui.nav.collapsed(纯增量 bool,不碰 schemaVersion);presenter 桥加
  load/persist 一对(走唯一写入口 write_with,现读→改→落盘);启动 construct 读回。
- 截图通道不读用户配置(D30),折叠态出图走 ULL_NAV_COLLAPSED=1。

## 判据
- app-iced: 1053 passed(新增 3 条接线用例 + 7 条 shell/几何/树结构用例 + 6 条帧泵用例);
  presenter: 408 passed(新增落盘往返 1 条);fmt / clippy --workspace --all-targets 均 0。
- 变异实验:删 observe_nav ⇒ 宽度停在 88 红;删启动对表 ⇒ 首次展开硬跳红。
- 像素(DevDocs/artifacts/m300/):折叠态内容左缘 112→88(24px 真回收)、药丸两态都 56 宽
  且折叠档右缘 59 < 64(不裁切)、图标行是展开态子集(纵节奏不变)。

## 已知偏离(已登记,tower 批了紧随 mission)
crates/app-iced/src/pages/layout.rs 的 rail_width() 仍是 const fn(恒 88),所以折叠态内容块
宽度不变、整块左移 24px ⇒ 右侧多一条 24px 空白带(像素图里保留、未裁)。
2026-11-13 16:15:16 +08:00
AskaEth 1ce494631c Merge branch 'feat/m299-pillpages-settings-frame-tabs-chip'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-13 15:38:17 +08:00
AskaEth fa36ccbb66 fix(gate): M299 r1 —— tabs 判据改测闭包本身 + 反例判据抗重排
P2-1:tabs 的样式闭包抽成 tab_style(..)(返回闭包本身,照 chip_style / choice_style
的写法),单测喂的就是交给 button 的那个闭包。上一版只测闭包里的助手 tab_fill(..),
复审把 fill 挪到闭包外、闭包写回 |_theme, _status| 时用例仍全绿——本 mission 在 tabs 上
唯一的代码修复当时没有判据。现在同一变异红(hover 必须叠状态层)。

P2-2:反例判据改成「去空白 + 去花括号」后比对 .or_else(||Some(ui.color( 与
.unwrap_or_else(||ui.color(,换行 / 缩进 / 闭包加括号都躲不过;扫描跳过 .../tests/ 与
tests.rs;新增 KNOWN_COPIES 豁免表(slider handle,scope 外,已报 finding)与
every_known_copy_is_still_there(豁免不许挂空条目)。自证:名单外第 12 个站点用
换行+花括号写法 ⇒ 红(两种形态各一次),删掉后三条全绿。

行数:tab_style 拆到 components/navigation/tabs/style.rs(tabs.rs 398 → 370)。
像素:new 与 new2 两套 24 张逐张 md5 相同 ⇒ 行为中性。
2026-11-13 15:24:44 +08:00
AskaEth 8144714f30 docs(ui): M299 补三处与行为对齐的文档(chip 选中支、tabs 状态层、分组导航的半径实测)
只改注释,零行为变化:cargo test -p unlucky-app-iced --all-targets 全绿(1040 + 6)。
2026-11-13 14:56:20 +08:00
AskaEth 0d917e88da feat(settings): M299 选中项收口到 selected_fill + 分组导航改真按钮 + tabs 用 status
- pages/settings/frame.rs:分组导航从 mouse_area 换成 button(四态可辨),
  几何不变(ShapeScale::Full 胶囊),颜色走唯一的 selected_fill;forced 从
  settings::view 递下来,于是 --force-state 能拍到这一处
- language / theme / appearance choice 三处「选中档位」补状态层
- components/selection/paint/chip.rs:选中分支用上 state 参数
- shell/nav.rs / shell/nav/account.rs:两处私有拷贝收口到 selected_fill(几何没动)
- components/navigation/tabs.rs:状态层在样式闭包里跟 button::Status 求值
- 新增门禁 selection/tests/single_entry.rs:11 个站点必须调 selected_fill +
  产品代码里不许再出现 or_else(|| Some(ui.color( 这个私有拷贝形态
- 行数:frame.rs / nav.rs / appearance::section 的单测按「纯搬移」拆出去
2026-11-13 14:41:57 +08:00
AskaEth 9b6e92c7b4 Merge branch 'feat/m298-notifclose-crash-exit-popup-and-toa'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-13 14:21:11 +08:00
AskaEth 3b4f8bb997 fix(notify): M298 r1 —— toast 退场腿接上触发点,四条 nit 一并收
复审 P2-1:进场腿自己续帧所以能跑,但进场结束 view_dirty 回 false,
3.6s 后 remaining_ms 掉进 150ms 退场窗口时没有任何代码重新置脏 ⇒ 那一段
收缩从不发生,expire 一抹胶囊整块消失(复审探针:120 拍后 progress 仍 1.0)。

- update/clock.rs:expire 之后用无副作用的 ToastTracks::needs_frame 置脏,
  落到既有的 if view_dirty { refresh_view() } 上采样;refresh_view 仍是门控,
  没有变成每帧无条件调用(静止态判据不变)。
- state/toast.rs:进出两条腿改成**同一个时钟**(寿命 = 首次看见时的
  remaining_ms,已活 = 寿命 - remaining),去掉 Instant 与节拍的二时钟错位;
  新增 needs_frame;新增两条真节拍用例(退场腿必被抽到 / 到期后不再续帧)。
- N1:装配失败与 spawn 失败也用 publish_before_start 的返回值填 session。
- N2:m298 用例注释改准(它抓不住删守卫,那条由 a_report_from_another_session 守)。
- N3:before_start 文档不再把两种来源都说成「进程没起来」。
- N4:refresh_view 末尾的死赋值 view_dirty = false 删掉。

红灯原文(摘掉那 3 行触发点,其余为最终代码):
  退场窗口内必须真的开始收(remaining=Some(40)ms, progress=1)
2026-11-13 13:50:41 +08:00
AskaEth aa25583806 test(launch): M298 用例搬到 dialog/tests/m298.rs(守 400 行单文件门禁) 2026-11-13 13:21:24 +08:00
AskaEth 97768b7e3f fix(launch): M298 收尾失败不再静默、Toast 接上渲染点
三条提示通道各漏一半,用户才会「启动失败但找不到崩溃日志」:

1) 收尾失败静默不弹:finish() 报错那条出口把 LaunchFinished.session 发成真实
   会话,而它登记的现场走 before_start 写死空串 ⇒ dialog.rs 的并发守卫
   (!finished.session.is_empty() && report.session != finished.session)成立后
   return。改成**会话归属单一来源**:before_start 新增可选 SessionAttribution,
   presenter 的 publish_before_start 把它写进报告后**返回同一个值**给 DTO;
   两处不可能再各写一遍。守卫原样保留(A 场的现场不许解释 B 场,有用例钉住)。

2) Toast 只 push 不渲染:新增 app/view/toast.rs 覆盖层(MD3E 胶囊 =
   ShapeScale::Full,底部居中,InverseSurface 底),接在页面树之上、模态之下;
   过期仍走 clock.rs 的 expire;进/出过渡复用模态那一对腿(motion::modal_spec /
   modal_scale / scaled),时间轴在 update 侧由 state/toast.rs 采样,关掉动效时
   恒为终值(出图可复现)。两个拷贝路径补上 view_dirty(推了不置脏要等下一次事件)。

3) 三个贴线文件先拆再改(纯搬移 + pub use 保路径,机器核对:搬出的 14 个函数体
   逐行相同):crash_report.rs 394→318(+context.rs / slot.rs)、
   host/launch.rs 399→233(+build.rs)、app/dialog.rs 390→178(+model.rs)。

证据:DevDocs/artifacts/m298/toast-capsule.png(--screenshot + ULL_TOAST_FIXTURE,
图上可见「游戏崩溃 · 12.0 秒」胶囊)。
2026-11-13 13:18:11 +08:00
AskaEth fc8f5c6794 Merge branch 'feat/m296-selstate-selected-items-get-hover-a'
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-13 13:12:42 +08:00
AskaEth 638e2a3efe feat(app-iced): 选中项叠状态层收口到一处,导入向导的版本行不再「选中就没反馈」(M296)
用户 2026-11-13:已选中的控件在悬停/按下时没有任何反馈。塔裁决「要加」——
MD3 的行为是选中容器(SecondaryContainer)上**再叠**状态层,两者不互斥。

枚举(全仓 grep SecondaryContainer / selected + fill,表见报告)后事实是 3/4 已做:
菜单项(inputs/menu.rs)、列表行(navigation/list_item.rs)、导航项药丸
(navigation/drawer/item.rs)各自写了一遍同一个 6 行形状;真正「死」的是导入向导的
版本选择行(app/adopt/pick.rs:底色写死在容器样式里,按钮样式恒 None)。

三件事:
1. 抽 interaction::selected_fill(ui, state, content, container)——第三次法则的落点,
   与未选中态的 layer_color 分两条(未选中静息没有底,选中项静息有底);
2. 三处拷贝收敛到它(menu / list_item 从 overlay_color 升到带时间轴的 layer_color,
   无取样控件时逐位相同;drawer 只换入口);
3. pick.rs 的候选行底色搬进 button 的样式闭包 → 选中行按 hover 8% / pressed 10% 叠层;
   未选中行仍恒 None。

判据:rest 图 16/16 md5 与改动前全等(含 adopt-pick-selected);列表选中行
rest/hover/pressed 三色互异(浅 212,228,246 / 196,212,230 / 192,208,225);变异把
selected_fill 改回「不叠」→ 7 条单测红 + 该行三张图逐像素相同(1/3)。
全量 cargo test -p unlucky-app-iced --lib:1017 passed / 0 failed。
2026-11-13 12:58:50 +08:00
AskaEth 0e5944045a Merge branch 'feat/m295-proxywire-wire-proxy-mode-and-url-i'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-13 11:58:42 +08:00
AskaEth 422f198c84 fix(download): M295 r2 —— 代理地址四类原因各一句中文,不再摊 reqwest / url 的原文
r2 复审退回的形态:`代理地址无法解析(http://127.0.0.1:99999):builder error`。第一版靠
`Proxy::all` 的报错(只有这一句),第二版靠 `url::ParseError` 的 Display(英文 `invalid port
number`,而且 reqwest 的宽口径会让「解析成功/失败」本身不可靠)——两种都还是把别人的原文当结论。

- `validate()` 自己判四类,各给一句中文:scheme 不支持 / 缺少主机名 / 端口(空、0、越界)/ 空地址;
  `manual_proxy` 先过它,再 `Proxy::all`。
- 残余情形(validate 放行但 reqwest 不认,例如 userinfo 有非法字符):中文结论在前,reqwest 原文
  只作为括号里的证据。
- 判据改成「文案里命中该类中文原因,且不含 `builder error` / `invalid port number`」,四类表驱动
  + 合法写法(含 `[::1]:8080` 与少写 scheme)不许被拒。
- 实测(真二进制,`ull install 1.20.1 --dry-run`,口令样本 PWMUSTNOTLEAK):
  `代理地址用不了(http://user:***@127.0.0.1:99999):端口 99999 不是 1–65535 之间的数字`
  `代理地址用不了(socks5://user:***@127.0.0.1:1080):本构建不支持 socks5 代理(只支持 http / https)`
  口令出现 0 次。
- 变异(本轮重做):关掉端口判定 ⇒ 红,且红的样子正是「地址写法不被接受(builder error:invalid
  port number)」——证明没有自己的判定时原文就会漏出来;关掉主机名判定 ⇒ 红;scheme 白名单写
  `if false` ⇒ 红;`redact_proxy_url` 返回原文 ⇒ 3 条红(口令泄漏)。见 docs §6 M23/M25/M26/M27。
- 门禁(含塔点名的整棵 `-p unlucky-core --lib`):914 passed / 0 failed;四 crate 测试 45 个
  binary、3206 passed / 0 failed;clippy `-D warnings`、`fmt --all --check`、
  `check --workspace --all-targets` 全 0。
2026-11-13 11:47:21 +08:00
AskaEth c182d8d5c9 fix(download): M295 r1 —— 代理口令脱敏、回落可见、socks5 与错因有话说
复审 r1(p2-2items / fix-then-merge)两条 p2 都是本轮新开的「用户被骗」面,三条 p3 一并收。

p2-1(凭据泄漏面):`proxy_url` 允许 userinfo,而这个键在本仓就是凭据(schema 是
`AiPolicy::sensitive()`、`config get` 打 `<redacted>`、`config_secret_test.rs` 专门守它)。第一版把
manual 的原串整条写进 `summary()`(→ CLI stderr → CI 日志 / 截图)与解析失败文案,**不需要用户犯错**。

- `redact_proxy_url` 是唯一实现:`user:password@` → `user:***@`(用户名保留、口令打掉;只有用户名
  没有口令 ⇒ 原样;路径/查询里的 `@` `:` 一律不碰,也不做任何 URL 归一)。
- 四个出口全过它:`describe()`(`summary()`/stderr)、`apply()` 的错误文案(`proxy_problem()`/
  GUI 提示)、手写的 `Debug`(配置与引擎被打进日志时)、`problem()`。
- 刻意例外(写进 docs §5.3):设置页那个输入框仍显示文件原值——它是编辑面,回填掩码会在用户点
  「应用」时把真口令冲掉。

p2-2(GUI 对「非法地址 ⇒ 回落直连」完全静默):`proxy_problem()` 的消费者只有 CLI。

- `ProxySetting::problem()` 是新增的**纯判定**(与 `apply` 共用 `manual_proxy`):宿主用它给提示,
  不必造 client。两个宿主都调它,优先级写清:GUI 常驻(设置页「HTTP 代理」行第二段
  `\n已回落到直连:<原因>`)、CLI 每次装配引擎一行 stderr。
- 取舍写进 docs §5.3:回落直连(而不是拒绝下载)不挡用户,代价是「以为走代理」,所以回落**必须**
  可见;fail-closed 是产品决定,不在本轮。

p3-1:`socks5://` 在 `Proxy::all` 里返回 `Ok`,但本构建没开 socks feature ⇒ 以前静默变成「所有候选
源都失败了」。现在 `manual_proxy` 先自己 `Url::parse` 拿 scheme 做白名单,不支持的直接有界错误
(「本构建不支持 socks5 代理(只支持 http / https)」)⇒ 走已有的回落 + 告知路径。
p3-3:错因不再只有 `builder error`:`reason()` 沿 `source()` 链拼一句;宽口径只补到「少写 scheme」
为止(已带 `://` 不再套一层 `http://`,否则真错因会被掩盖成「解析成功」)。实测文案:
`代理地址无法解析(http://127.0.0.1:99999):invalid port number`。
p3-2:两个测试文件已在 scope 里登记,本轮未动。

判据(都落在最终字符串 / 真正可见面上):
- `a_password_in_the_proxy_url_never_reaches_a_user_visible_string`:生效值行 / 错误文案 / `Debug`
  四处都不含口令子串且保留 `user:***@`;`a_proxy_url_without_a_password_is_left_byte_identical`
  守「没口令的地址逐字不变」。
- `a_broken_proxy_url_is_shown_as_falling_back_to_direct`(presenter):设置页那一行含
  `\n已回落到直连:`、带(脱敏后的)地址、不含口令,且与 `engine.proxy_problem()` 是同一串;
  合规地址没有这条提示。
- `the_console_line_never_carries_the_proxy_password`(CLI):`announce` 那一行与回落提示都不含口令。
- `unsupported_schemes_and_bad_ports_explain_themselves`(core)+ `an_unsupported_proxy_scheme_is_
  refused_with_a_readable_reason`(engine)。
- 变异 M23-M26 记在 docs §6(redact 返回原文 ⇒ 3 条红;删 `proxy_note` ⇒ GUI 提示红;白名单写
  `if false` ⇒ socks 红;错因换回 `builder error` ⇒ 红)。
- 端到端(真二进制 `ull install 1.20.1 --dry-run`,`CARGO_TARGET_DIR` 同前):口令样本
  `PWMUSTNOTLEAK` 在 stdout+stderr 里 **0 次**,两行都是 `user:***@`;socks5 那档打出
  「本构建不支持 socks5 代理(只支持 http / https)」。
- 门禁:四 crate `--all-targets` check / 测试(3206 passed, 0 failed, 45 binaries)/
  `clippy --all-targets -- -D warnings` / `fmt --all --check`,另 `check --workspace --all-targets`。
2026-11-13 11:10:30 +08:00
AskaEth d584a2c42e feat(download): 把 download.proxy_mode / download.proxy_url 接进代理三态(M295)
台账 P26:两个键引擎侧零消费者——`http::build_client` 只设 UA / 连接超时 / 重定向 / 默认头,
从不 `.proxy()`。设置页画着三档 chip 与一个地址框,改它对任何一次下载都不生效(`system` 那一
档「碰巧能用」只是因为 reqwest 默认读环境变量,不是这段代码做的)。

- core::download::proxy(新):`ProxySetting::from_raw` 是**唯一**翻译层,三档落到 client 上:
  `none` ⇒ `.no_proxy()`(真直连,连 `HTTP_PROXY` 也不读)/ `system` ⇒ 不调任何代理 API
  (跟随环境变量 = M295 之前的行为)/ `manual` ⇒ `.proxy(Proxy::all(url))`;未知值 ⇒ `none`。
- 两条决定写进模块头:`proxy_url` 在非 manual 档**忽略**(不校验、不进结构,值原样留在文件
  里);`manual` + 空地址 ⇒ `system`(设置页那行文案一直这么承诺)。
- 非法地址不穿透:`Proxy::all` 解析失败 ⇒ 有界错误(`InvalidRequest` + 带原串的文案),不
  panic;`DownloadEngine::new` 回落**直连**重建(不用 `reqwest::Client::new()`——那会跟随环境
  变量代理,回落就不确定),原因留在 `proxy_problem()`,CLI 的 `download::engine` 打一行
  stderr;`config().proxy` 不撒谎(仍是用户写的 `manual`)。
- `EngineConfig.proxy` 默认 `System`(=「没读设置」的档:auth 登录 / JDK 下载页 / 地区探测都
  直接用它,保持老行为),`DownloadTuning.proxy` 默认 `Direct`(与 schema 的 `none` 一致)。
- 两键进 `RawTuning`(与 `gh_proxy` 同形),presenter 的 `tuning_of` 与 CLI 的 `tuning` 各加
  两行;两个宿主都是完整字段字面量 ⇒ 「忘了读」是编译错误。`summary()` 加 `代理=…` 一档。
- 判据是「请求真的经过代理」:新夹具 `FakeProxy`(本地 TcpListener)+ 目标 `.invalid`(解析不
  了)⇒ 拿到正文只可能来自代理,且代理记到的请求行是**绝对 URI**。三条用例:manual 真走代理
  / direct 无视环境变量而 system 跟随(同一份 `HTTP_PROXY`)/ 非法地址回落直连不 panic。
  变异 M17-M22 记在 docs/download-settings.md §6。
- 行为变化(doc §5.2 写明):`none` 从「碰巧跟随环境变量」变成真直连,靠 `HTTP_PROXY` 上网的
  用户要选一次 `system`。发布版 GUI 的「下载 JDK」页仍不读这两个键(finding 已开)。
- 门禁:四 crate `check --all-targets` / 测试(3201 passed, 0 failed)/ `clippy -D warnings` /
  `fmt --check`;另跑了 `check --workspace --all-targets`。判据用 `$?` 与 `test result:` 行验。
2026-11-13 10:43:09 +08:00
AskaEth c731d3fa54 Merge branch 'feat/m294-mirrorwire-wire-mirror-preference-a'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-13 09:08:19 +08:00
worker-mirrorwire 69e3e198bf fix(download): M294 r1 —— JDK 下载页与 CLI 也接上源选择,文档同步
复审 p1:发布版 GUI 的「下载 JDK」页是第二处 DownloadEngine::new(要 with_progress
拿走引擎),此前只读了 gh_proxy,region / mirror_preference 到不了那里;报告却把 GUI
写成「已接」。复审 p2:CLI 四条命令同样不认这两个键 —— 同一份 launcher.json 在 GUI 与
CLI 上表现不同,等于把「设置项说假话」挪到 CLI 侧。

- app-iced downloads/engine.rs:新增 SourceSelection(只读键、不翻译)+
  engine_with(.., &SourceSelection),在 gh-proxy 三态之前叠
  DownloadTuning::with_source_selection(..).apply_source_selection(..)
- Request 加 source_selection 字段,构造点(app/update/settings/java/downloads.rs)
  用 SourceSelection::of_config(&config) 显式传值(不从缓存根反推配置路径:
  反推失败会静默按 auto 收场,正是本 mission 要消灭的形状)
- cli download.rs 的 tuning():链 .with_source_selection(..),四条命令共用这一处装配
- core 的 summary():把「地区 / 镜像偏好」两档印进 CLI 那行生效值(CLI 既然读了,
  少印两项就是新的半真话)
- shared.rs 注释说清「共享引擎」与本文件的第二处装配点;app-iced 模块头补对照表
- 新判据:JDK 下载页与 CLI 各一条断候选源顺序的用例,均配变异验证(换回裸
  EngineConfig::default() / 裸 tuning 即红);补一条 region=cn + gh_proxy=off 的
  叠加次序守卫(复审 P3-2)
- docs/download-settings.md:§1 表格两行、§5 拆「已接 / 未接」、新增 §3.1.1 覆盖面
  表、§6 补 M14-M16 三条变异、§7 补「镜像可达性没验」

连带(不加就编不过,已报备 tower):downloads/tests.rs 的 Request 字面量 +1 行、
downloads/tests/gh_proxy.rs 两处 engine_with 调用点补第四参。
2026-11-13 09:00:00 +08:00
worker-mirrorwire 0f64acbd05 feat(download): 把 download.region / download.mirror_preference 接进源选择(M294)
两个键此前在引擎侧零消费者:设置页画着三档 chip,改它不影响任何一次下载。
现在它们是 RegionProfile 之上的一层显式覆盖(读时叠加,不落盘,region.json
仍是自动判定的结论):

  显式 mirror_preference(official/mirror)
    > 显式 region(cn/global)
      > 自动探测(时区+语言+系统信号)与 24h 缓存

- core::download::preference(新):两个枚举的 from_raw(未知值/空串 ⇒ auto)
  与 apply;两个键都落到 RegionMode 这一个「谁在前」开关上,不造第二套排序
- region::RegionProfile::overridden:在已有判定上叠一层模式(探测逻辑不动)
- DownloadTuning 加 region / mirror_preference 与 apply_source_selection
  (组合顺序 = 优先级),shared::build 改用它;默认 Auto ⇒ 逐值等于今天
- 未进 RawTuning:CLI 的 tuning() 用完整字段字面量构造它,加字段会连累
  crates/cli(不在本 mission scope),改走 with_source_selection 入口
- presenter 的 tuning_of 读这两个键(否则 GUI 侧仍是假行)

判据断具体源名序列:不设/auto/未知 ⇒ ["bmclapi","official"] /
["official","bmclapi"] 逐项不变;official/mirror/cn/global ⇒ 顺序跟着换;
另有一条从设置原文到引擎 plan() 的端到端用例。
2026-11-13 08:26:30 +08:00
AskaEth c2fc4b7f26 Merge branch 'feat/m293-chunkresume-chunked-download-writes'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-13 07:08:15 +08:00
AskaEth 1004af81ca fix(download): 同源换表示时不再沿用上一份的校验器 + 三条 p3 收口(M293 r2)
复审 r2(p2-1):r1 的 `same_source` 只挡了「源不同」,没挡「同一 URL、换了一份表示」。
`done` 为空(Restart:`SizeChanged` / `HashChanged` / `ChunkLayout` …)时没有任何字节要接着用,
但 `journal.begin` 仍把上一份表示的 ETag 写进**新一轮**元数据;`record` 又只在缺值时写 ⇒ 本轮
3 段(新内容)配的是旧校验器 ⇒ 重试拿它发 `If-Range` ⇒ 服务器按 RFC 7233 回 200 + 完整正文 ⇒
`RangeUnsupported` ⇒ 丢整份 ⇒ 单连接从头下。实测 113664 字节(2.775×),比「干脆不续传」的
72704(1.775×)还多一整份。

修法:「谁能当校验器」收成一个 `carry`,两个维度缺一不可 ——
  let carry = if done.is_empty() { None } else { same_source };
`if_range`、`etag`、`last_modified` 三处全部走它。清掉之后本轮响应里的真实校验器由
`ChunkJournal::record` 填进来;最坏也只是 `NoValidator` ⇒ 整份重下(1.775× 基线),不会更坏。
文档(`chunk_resume` 模块头的「校验器跟着源与表示走」、`record`、`carry` 处)同步改成两维口径。

判据(新用例 `chunked/resume.rs::stale_validator_from_the_previous_representation_is_not_carried`):
同一 URL、盘上是上一份表示的元数据(旧总长/旧段区间/旧 ETag `"v1"`/旧摘要)⇒ 本轮因
`HashChanged` 走 Restart、服务器此刻发 `"v2"`。断言:重试的 `If-Range` 只许是 `"v2"`、字节数
41984(1.025×,而不是 113664)、hits 5、resumed_from 30720、终态文件逐字节正确。

变异(两种,均实测红):
* 只把元数据那两行退回 `same_source`(`if_range` 仍按 `done` 门控)⇒ 复现复审的探针数字:
  PROBE stale_validator if_range=["\"v1\""] hits=6 bytes=113664 resumed_from=0,
  断言红在 `left: ["\"v1\""] right: ["\"v2\""]`;
* 把 `carry` 整体退回 `same_source`(连带首轮也发 `If-Range`)⇒ 红在
  `left: ["\"v1\"","\"v1\"","\"v1\"","\"v1\""]`。

三条 p3:
* p3-1 `Last-Modified` 支路端到端补上了:夹具新增 `last_modified` 字段 / `with_last_modified()`
  / 发出该响应头,`validator_ok` 扩展成「ETag 或 HTTP-date 比得上」(RFC 7233 的弱校验器兜底)。
  新用例 `chunked/last_modified.rs`:服务器**只给** Last-Modified,重试必须带这个日期发
  `If-Range`、只补 1 段(41984 = 1.025×)。变异(`record` 不写 last_modified)⇒ 红在
  「只该在重试那一次发条件请求:[]」(变成 NoValidator 干净重下)。用例同时写明它抓不住真实
  服务器的 HTTP-date 比较语义。
* p3-2 `ChunkJournal::save` 的同步原子写:判断「可以接受」并给了量级 —— 一个作业 ≤17 次
  (16 段 + 建账)、每次 1–3 KiB,SSD ≈ ≤30 ms / 网络盘 ≈ ≤1 s,摊在分钟到小时的作业上占比
  1e-4 量级;且顺序有意义(record-then-trust),挪 `spawn_blocking` 要为几毫秒引入跨 await
  加锁的顺序风险;与单连接路径同口径。退路写在文档里。
* p3-3 `integrity=None` 时末尾摘要空转的一致性边界写进 `chunk_resume` 模块头「已知代价」+
  `verify_part` 文档:只剩旧段自证与 `If-Range` 两道,「同长度换内容」不可察觉;现实入口是
  上游没给合法 sha1 的 `ModFile`(`addon/online/download.rs` 只在 sha1 合法时才设 integrity),
  以及 `Library` 元数据 / `download.bin` 缓存等无摘要调用点;要收紧属于产品决策。

字节判据复测(三条探针形态):
  healthy    bytes=41984 size=40960 ratio=1.025 hits=5 resumed_from=30720
  nofeature  bytes=72704 size=40960 ratio=1.775 hits=8 resumed_from=0
  staleval   bytes=41984 size=40960 ratio=1.025 hits=5 resumed_from=30720 if_range=["\"v2\""]
即 stale-validator 那条从 2.775× 回到 1.025×,healthy 档未变差。

门禁:lib 903 / engine_mock 17 / chunked 22 / resume 12 / partfile 7 / cache 6 全绿;
clippy -D warnings 0、fmt --check 0。改动的单文件最大 391 行(chunked_test.rs),无超 400。
2026-11-13 06:59:58 +08:00
AskaEth ffca0f4454 fix(download): 换源后不再沿用旧源校验器 + 三条 p3 收口(M293 r1)
复审 r1(p2-1)指出的链条:源 A 落 3/4 段失败 ⇒ 元数据 url=A + A 的 ETag;换源 B 时
`decide` 判 UrlChanged、删盘重来,但 `ChunkJournal::begin` 把 A 的校验器原样写进 url=B 的
新元数据,而 `record` 只在 `etag.is_none()` 时写入 ⇒ B 的真实校验器永远盖不上。于是拿 **A 的
ETag** 对 B 发 `If-Range`:按 RFC 7233 §3.2 服务器回 200 + 完整正文 ⇒ `fetch_chunk` 判
RangeUnsupported ⇒ 丢掉整份 ⇒ 单连接从头下 —— 债务以另一条路径复现。

修法(复审给的选项 A):校验器与**记下它的 URL** 绑定,`same_source = previous.filter(|m|
m.url == candidate.url)`,`etag` / `last_modified` / `If-Range` 三处全部走它,让「谁能当校验器」
只有一个判定点。不选 B(换源时清空)是因为 B 只是 A 在「URL 变了」这一特例上的写法,条件放在
读取处比散到写入处更难漏;不选 C(多源各记一份)是因为 `ResumeMeta` 本来就有 url,再加一个来源
字段会多一处可与 url 漂移的状态。

夹具此前不解析 `If-Range`,这一跳只能读代码推。现在 `support/` 记录每个请求的 `If-Range` 取值,
并按 RFC 7233 §3.2 处置(对不上就忽略 Range、回 200 + 完整正文),于是「真实服务器上的后果」也
进了用例。

判据(新用例 `chunked/switch.rs`,两个夹具服务器当一个官方源 + 一个镜像源):
* 源 B 收到的 `If-Range` 只许是它自己的 `"src-b"`(源 A 同源续传照旧带 `"src-a"` 作对照);
* 源 B 的字节数仍是 3 段 + 卡住的 1KiB + 补 1 段 = 41984(1.025×),换源没把同源续传打回整份重下;
* 最终文件逐字节正确;
* 第二条用例走「新源自己不发 ETag」那一支:`NoValidator` ⇒ 新源重试整份重下(`resumed_from=0`、
  8 个请求、72704 字节),一个条件请求都不许发 —— 这是「无法证明就不复用」的明确取舍。
变异(去掉 URL 过滤)⇒ 两条都红,实测原文:
  assertion `left == right` failed: 源 B 收到了不属于它的校验器
  left: ["\"src-a\""]  right: ["\"src-b\""]
  新源没有任何校验器,就不许发条件请求(带 A 的值更是串源):["\"src-a\""]
同一变异下还实测到了后果:`b_bytes=113664 b_hits=6 resumed_from=0`(200 白下一份 + 单连接整份
重下 = 2.77×),即复审预测的那一跳确实存在。

三条 p3 一并收口:
* p3-1:`abort_all()` 之前用 `try_join_next` 把「已落完但还没被收走」的段记进账
  (`absorb_completed`,总长判据与正常回收同一道)。这是尽力减少浪费、不是正确性前提,且
  它是「谁先完成」的竞态 ⇒ 没有确定性用例能钉住,已在文档里写明边界。
* p3-2:`verify_chunks` 改为返回 `Option<u64>`(可复用字节数),`AttemptOk.resumed_from`
  直接取它,删掉原来「记录另算一遍」的双份口径。
* p3-3:`chunk_resume` 模块头新增「已知代价」:重读量正比于**复用的字节**(不是整份文件)、
  为什么不能用「相信摘要」来省掉、以及超时后 `spawn_blocking` 线程不可取消在 Windows 上的
  句柄风险。

拆文件守 400 行硬线:`await_chunks` + `absorb_completed` 移到 `transfer_chunk/join.rs`
(逐字节搬移),`MockResponse` 移到 `support/response.rs`(逐字节搬移,只加 `with_etag` /
`without_etag` 两个构造器)。

门禁:lib 903 / engine_mock 17 / chunked 20 / resume 12 / partfile 7 / cache 6 全绿,
clippy -D warnings 0、fmt --check 0;字节判据复测未变差(resume 41984 = 1.025×,
noresume 72704 = 1.775×)。
2026-11-13 06:45:55 +08:00
AskaEth 3ade2d14a8 feat(download): 分块路径写续传元数据并自证旧字节(M293)
台账 P21 第 2 条 + P19(b):分块路径此前进来先 `part.discard()`、失败也 `discard()`,
188MB 的 JDK 任何一次失败都从 0 重下;M289 又给预算耗尽加了「不再重试、不再换源」的
短路,慢链路上一次晚到的失败就变成「这个文件下不来」。

写这一侧:
* `ResumeMeta` 新增 `chunks: Option<Vec<ChunkRecord>>`(段区间 + 该段 SHA-1),
  由新增的 `chunk_resume::ChunkJournal` 每成功回收一段就原子落一次盘;
* `fetch_chunk` 顺带带回段摘要、`Content-Range` 总长与 ETag/Last-Modified,
  所以元数据里同时有 total / 源 URL / 分块清单 / 校验所需信息;
* 分块失败不再丢中间文件,只有 `RangeUnsupported`(单连接接不住这种形状)才丢。

判这一侧:
* `chunk_resume::decide`(纯函数)要求策略允许、URL / 总长 / 摘要 / 校验器全对得上,
  且每条记录精确命中本次分块规划里的一段,否则从头再来;
* `chunk_resume::verify_records` 按记录重读旧段、重算摘要:任何一段对不上就整份丢掉
  (元数据本身已不可信,同批其它记录也不再被信任);
* 续传的段请求带 `If-Range`,让服务器回答「内容变了没有」;
* 整文件摘要照旧在末尾整份重算,覆盖新下 + 复用的全部字节。

单连接路径零改动:`resume::decide` 的判定一字未动,只新增
「元数据里带分块清单 ⇒ 强行判 Restart 交给分块路径」这一条,避免预分配全长文件被
单连接的 `Complete` 误判成「已下满」。

判据(都是「服务端实际吐了多少字节」,不是「最终 hash 对」):
* 3/4 处失败后续传完成:41984 字节 / 40960 文件 = 1.025×(断言 < 1.5×);
* 变异(关掉续传同样夹具):72704 = 1.775×,`< 1.5×` 必红(已实测);
* 变异(去掉旧段摘要自证):坏段被复用 ⇒ 51200 ≠ 40960 必红(已实测);
* 变异(去掉总长比较):旧大小的段被复用 ⇒ 61440 ≠ 40960 必红(已实测)。
2026-11-13 06:26:18 +08:00
AskaEth 6e1d5fa82e Merge branch 'feat/m292-dlsettings-wire-download-timeouts-i'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-13 04:29:11 +08:00
AskaEth f6cdbff885 test(download): 补强最后一跳 + 写明「抓得住/抓不住」(M292 r1)
复审与塔的门禁在本 worktree 报了一条 `left: 120s / right: 300s`
(`changed_settings_reach_the_engine_config_and_the_budget`)。归因结论:**不是源码缺陷**,
是目标目录里跑到了不属于这棵树的 `unlucky_core` 测试二进制。本轮只做两件事:

## 1. 最后一跳补强(presenter)

`the_three_timeout_keys_travel_from_the_file_to_the_engine_config` 此前是自己拼
`tuning.apply_to(EngineConfig::default())`,即「调优 → 引擎」这一跳是**用例自己接的**,
守不住「宿主有没有把调优交给 core」。现在改走 GUI 的生产装配点
`crate::host::engine::engine(&env)`(内部 `apply_tuning` → `shared::set_tuning` → 按数据根重建),
断言读 `engine.config()`。这一步断了(宿主没调 `apply_tuning`、或 `shared` 装配点没用
`apply_to`),三条断言全红。

## 2. 用例文档写清边界(config/tests.rs)

`changed_settings_reach_the_engine_config_and_the_budget` 的文档补上返工取证:

- **抓得住**(三键逐一变异,删掉 `apply_to` 里对应赋值即红):
  `response_timeout` ⇒ 278 行 `60s/180s`;`chunk_stall_timeout` ⇒ 279 行 `120s/300s`;
  `min_avg_throughput` ⇒ 280 行 `32768/16384`。另抓 `from_raw` 的 KiB→字节换算与
  `chunk_job_budget` 忽略下限。
- **抓不住**:①宿主有没有调用 `tuning`/`apply_to`(前半段归宿主用例);②跑的是不是这棵树的
  二进制(编译期常量与代码内容都不在它视野里 —— 本轮就栽在这里)。

`config/tests.rs` 因此从 394 → 398 行(仍在 400 硬线内)。
2026-11-13 04:06:42 +08:00
AskaEth 2ecb33afae feat(download): download.* 三个超时/阈值键接进 schema 与引擎(M292 2/2)
P21 第 1 条:M289 的四个超时/阈值只挂在 `EngineConfig`,`DownloadTuning::apply_to` 只搬并发与
每源尝试次数 ⇒ 慢链路用户没有任何自救手段。出厂等价于「平均吞吐 ≥32 KiB/s 才活得下来」:
188MB 在 20 KiB/s 上要 160min > 100.3min 预算,必被杀;被杀后不再重试、不再换源,所以是
「下不完」而不是「慢一点」。

## 键(schema 1.9.0,新键推 minor)

| 键 | 默认 | 范围 | 落到 |
|---|---|---|---|
| `download.throughput_floor_kib` | 32 KiB/s | 4~4096 | `EngineConfig::min_avg_throughput`(×1024 → 字节/秒)|
| `download.stall_timeout_secs` | 120s | 30~600 | `EngineConfig::chunk_stall_timeout` |
| `download.response_timeout_secs` | 60s | 10~600 | `EngineConfig::response_timeout` |

三个默认值与 M289 的硬编码**逐值相等** ⇒ 不设 = 当前行为。上界(`MAX_*` 常量)就是「把挂住变成
无界」那道兜底:越界值在写入口被 schema 拒、引擎侧再回落一次出厂值。

## 通路

`RawTuning`(宿主只按 `KEY_*` 常量取裸值)→ `DownloadTuning::from_raw`(默认/区间/回落/KiB→字节
全在 core 一处)→ `apply_to` → `EngineConfig` → `stall::job_budget(total, floor, min_throughput)`。
`job_budget` 内部 `min_throughput.max(1)` 挡除零(设置层已拒 0,这是纵深防御)。

两个宿主都读六个键:`crates/cli/src/download.rs::tuning`、`presenter::host::settings::download::
tuning_of`。CLI 在**非出厂值**时往 stderr 打一行 `DownloadTuning::summary()`(`--dry-run` 也会经过
它;实测 `ull --data-dir … java install 21 --dry-run` 的 stderr 是
「下载调优:并发=8 单源尝试=3 吞吐下限=16KiB/s 无进展窗口=300s 单次请求上界=180s gh_proxy=跟随内置默认」,
出厂/非法值时一行都不打)。

## 文档口径(各只写一处)

- `stall.rs` 模块头:「改这几个数要付的代价」(调低下限换到的是「慢」不是「无界」;两档窗口是
  单次尝试的界,最坏要乘候选数×尝试次数)+「数字从哪来」改成「默认值 / 能不能改」+ 补上 P21
  第 3 条要的「`permit_timeout` 是按一次尝试反推,不是一个作业」。

## 两个纯搬移(为 400 行硬线)

- `config.rs` 423 → 261:`DownloadTuning` / `RawTuning` / `secs_in_range` / `impl` 整段搬到
  `config/tuning.rs`(194 行),`pub use tuning::{DownloadTuning, RawTuning};` 保住原路径;
  搬移块 172 行原样 diff = 0(无归一化)。
- `stall.rs` 440 → 343:测试搬到 `stall/tests.rs`(103 行),唯一变换是整体去 4 格缩进;两个
  未改动的用例函数体与 HEAD 逐字符(去空白)相同。

## 用例

core(`config/tests.rs` + `stall/tests.rs` + `keys/download.rs`):默认值与 schema/引擎三处同源、
改设置 ⇒ 引擎配置与总期限都变、0 与越界回落且不除零、doc 里的数字、`summary()` 的生效值、键顺序。
cli:生产入口 `build_engine` 拿到三个新值 + `announce` 只在非出厂值时给行 + 非法值回落。
presenter:文件 → `tuning_of` → `EngineConfig` 三段一致 + 键名常量与 schema 登记绑定。
2026-11-13 03:35:08 +08:00
AskaEth f4b5a73594 refactor(setting): download 四键纯搬移到 setting/keys/download.rs(M292 1/2)
`keys.rs` 396 行、贴着全仓 400 行硬线,M292 要再加三个键 ⇒ 先纯搬移腾地方。

- 新增 `crates/core/src/setting/keys/download.rs`:并发 / 单源尝试次数 / 地区判定 /
  镜像偏好四条登记项,**逐字搬来**(唯一机械变换是 `.with(` 外壳换成 `vec![` 项、
  `))` 换成 `),` 定界符)。
- `keys.rs` 原地换成一行 `.with_all(download::specs())`,登记顺序不变。

逐字节证明(搬移块 28 行):
  git show HEAD~1:crates/core/src/setting/keys.rs | sed -n '109,136p' \
    | sed -e 's/^        \.with(/        /' -e 's/^        ))$/        ),/' > old.txt
  sed -n '/^    vec!\[$/,/^    \]$/p' keys/download.rs | sed '1d;$d' > new.txt
  diff -u old.txt new.txt; echo $?   # 0

两条归一化规则都是**行首精确缩进锚定**:块内 `.with(` 只出现在四个项首、`))` 只出现在
四个项尾(各 4 处,与项数一一对应),所以它们不可能顺手改掉块内的真实差异。
2026-11-13 03:20:00 +08:00
AskaEth bba98d5898 Merge branch 'feat/m291-quietspawn-route-presenter-junction'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-13 00:55:30 +08:00
AskaEth 7d16317411 fix(m291): 守卫文档在 400 行内收(⑧ 并入 ⑦ 段,净增 0 行),make_dir_link 可见性收窄到 pub(super)(M291 r1) 2026-11-13 00:48:38 +08:00
AskaEth 375cc4f1fa test(presenter): junction 夹具改成逐段 join(带正斜杠的路径让 mklink 报无效语法,用例一直静默跳过),守卫补记 M291 变异(M291) 2026-11-13 00:31:14 +08:00
AskaEth 27980cf5b3 fix(presenter): junction 夹具改走 platform::child::quiet,两处副本收成一份共享 helper(M291) 2026-11-13 00:28:32 +08:00
AskaEth 5617709792 Merge branch 'feat/m290-dlbound-bound-the-remaining-bare-se'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-13 00:24:26 +08:00
AskaEth bde6649aab fix(download): text_bounded 不再信 Content-Length 预分配,补两道内存闸;api/version 第四处裸 send 也上界(M290 r1)
复审 r1(p2-1 / p3-1 / p3-2 / p3-5)返工,三处既有结论(send_bounded 逐字节未动、三个既有调用方
零改动、阶段只用两档)原样保留。

**p2-1 `http.rs::text_bounded` 的内存**(复审判据成立):`Content-Length` 是「对面说了算」的数字,
拿它喂 `Vec::with_capacity` 等于让远端决定我们的内存,而分配失败是 abort、进不了 `Result`。
改成三道闸:
1. 头里声明 > `MAX_IN_MEMORY_BYTES`(64 MiB)⇒ 读完头立刻有界失败,**不预分配、不读正文**;
2. 容量只按真实到达增长(`Vec::new()`);
3. 读循环逐块卡同一道上限(在累加前判,不会先撑过上限一块)。
两道闸的 `message` 分别说「声明的长度」与「内存上限」,`kind` 都是 `InvalidRequest` ⇒ 与超时
(`Timeout` + 阶段)可区分。

**p3-1 第四处裸 `send()`**:`game/java/api/version.rs::fetch_release_page` 原本
`client.get(url).send()` + `response.text()`、整文件 0 处 timeout(生产路径:`source::pinned` →
`api::fetch_version`)。改走 `http::send_bounded` + `http::text_bounded`,界是参数(生产
`http::default_deadlines()`,用例压到 200ms)。

**p3-2 `download/stall.rs` 模块头**:把「`game/java/api.rs`、`game/java/net.rs`、`update/github.rs`
是裸 `send()`——未审」这句假事实换成实测清单(谁已收、谁可以留、为什么留:`auth/http.rs` 自带
`.timeout`、`preflight.rs` 由 `check_with_deadline` 套住、`region.rs` 自带 3s),并给
`http::text_bounded` 补一行「入口/上界/阶段」。

**p3-5 doc**:删掉「与 `Response::text()` 的唯一差别是 charset 解码」这条过度披露——本仓 reqwest
没开 `charset` feature,`text()` 本来就是 `from_utf8_lossy`,解码逐字节等价;改成真实差别
(读空闲 + 内存上限)。

离线用例(全在 `#[cfg(test)]`,夹具 `game::java::net::probe` 新增 `Mode::Declared{declared,sent}`
与 `Mode::Flood(n)`,外加共用的 `bounded()` 护栏助手):
* `api/version.rs::bounded_probes`:静默 ⇒ 阶段等响应头;正文停顿 ⇒ 阶段等字节;
  声明 `Content-Length: 999999999999` 且不发正文 ⇒ <2s 返回 `InvalidRequest` 且文案含
  「声明的长度」;不声明长度灌 65 MiB ⇒ 读循环在 64 MiB 处拒。
* `net.rs::tests` 的六条(4 条原有 + 2 条正常路径)改用同一个 `bounded()` 助手,断言一条未放宽。

变异实跑(贴红原文见 mission 报告):
1. 退回 `Vec::with_capacity(declared)` 且删提前拒绝 ⇒ `memory allocation of 999999999999 bytes
   failed` + 进程 abort(0xc0000409),测试二进制消失——不是一条红;
2. 只删提前拒绝(预分配仍是 `Vec::new()`)⇒ 干净红:`left: Timeout / right: InvalidRequest`(0.21s);
3. 删循环里的上限卡口 ⇒ 干净红:`expect_err` 拿到 `Ok("xxxx…")`(0.54s);
4. `fetch_release_page` 退回裸 `send()` + `text()` ⇒ 两条 version 用例各在护栏 5s 处 panic(5.01s)。

门禁(真实退出码):check=0 / lib=896 passed / game=120 passed / launch=91 passed /
engine_mock=17 passed / clippy -D warnings=0 / fmt=0。行数:http 389、stall 378、api 389、
net 390、version 397、github 363(全 ≤400)。
2026-11-13 00:22:22 +08:00
AskaEth ad99303a95 fix(java/update): 三处模块外的一锤子 GET 也上界,失败时报出卡在哪一段(M290)
`download_bytes`(M289)修掉之后,core 里还剩三处裸 `send()`,都在「拿上游文本/JSON」的
路径上:`game/java/api.rs::fetch_latest`(Adoptium latest)、`game/java/net.rs::get_text`
(四家发行版的一锤子 GET)、`update/github.rs::get_candidate_text`(更新清单)。上游接了连接
就装死时它们永不返回,与 M289 修掉的是同族形态。

改动(全部复用 M289 的构造点,没有第二套超时包装):
* `download/http.rs` 新增 `text_bounded`(正文按 `read_step` 的空闲判定读成 `String`,与
  `bytes.rs` 的读循环同口径)与 `default_deadlines()`(取 `EngineConfig::default()` 的两段界,
  不另抄秒数);`send_bounded` 的签名逐字节未动,`transfer.rs`/`chunked.rs`/`bytes.rs` 三处
  调用方零改动。
* 三处一律 `send_bounded`(等响应头)+ `text_bounded`(等字节):失败时 `message` 里带
  `StallReport` 的阶段(`await_headers` / `await_bytes`)与已收/预期/无进展三个数。
  这三条路径只用到这两档:没有分块回收、也没有引擎许可等待,所以 `await_chunk_join` /
  `acquire_permit` 不适用。
* `update/github.rs` 的界取 `engine.config().deadlines()`(手里有引擎就不抄默认值)。

正常路径零改变:`fetch_latest` / `get_text` 的公开签名与错误分类一字未动(`fetch_latest` 的
404 文案与 hint 保留),`update` 的 `github_cases` 全部断言未改。

离线用例(`game::java::net::tests`,6 条;夹具 `net::probe::Hanging` 也是 M290 新增):
silent 服务端 ⇒ 引擎自己在 200ms 上界内返回(外层 5s 护栏没触发),并断言 message 含
「等响应头/await_headers」;正文停住 ⇒ 「等字节/await_bytes」;200 + 真 JSON ⇒ 正文逐字节
原样(含中文)。变异已实跑:`send_bounded` 退回裸 `send()` 三处各自在外层 5s 处挂断;
`text_bounded` 退回 `response.text()` ⇒ 等字节用例 5s 挂断;正文改成空串 ⇒ 正常路径用例红。

门禁:cargo check(core+cli, --all-targets)/ test --lib(892 passed)/ --test game(120)/
--test launch(91)/ --test engine_mock(17)/ clippy -D warnings / fmt --check,退出码全 0。
2026-11-13 00:22:21 +08:00
AskaEth 53de2a3922 Merge branch 'feat/m288-tokenguard-test-colors-no-longer-tr'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-13 00:04:21 +08:00
AskaEth 9780fca1c7 fix(app-iced): blend 用例端点改用真实令牌色(M288)
`blend_hits_both_endpoints_bit_for_bit` 原先用 `Color::from_rgb(0.1, 0.2, 0.3)` /
`(0.7, 0.8, 0.9)` 当端点,撞上 `tests/no_hardcoded_tokens.rs` 的色值守门(它扫 `src/**`,
测试模块也算在里面),使 base 上「全量测试绿」永不可达。

修法在**被判的那一侧**:端点取状态层真正会插值的一对角色——`Role::Surface`(容器)与
`Role::OnSurface`(内容)——经产品同一条令牌路径(`BrandBoard::for_flavor(..).scheme()`
→ `to_iced`)取值,浅/深两套板各跑一遍;判据值全部现算(每通道取两端 min/max)。

三条判据一条不少:端点逐位相等、中点**严格**落在两端之间(比原先的闭区间更强)、α 全程 1.0。
守卫文件一行未改(仍 400 行),扫描范围没有放宽。
2026-11-12 23:55:24 +08:00
AskaEth 224aa7d9ff Merge branch 'feat/m287-listradius-list-row-corner-from-com'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-12 23:41:34 +08:00
AskaEth 5174845cda fix(app-iced): 列表行圆角改从组件表取(M287)
`list_item.rs` 硬写 `ui.radius_dp(ShapeScale::None)`,旁边还写着「4.x:ListItem 的形状
是 `CornerNone`(直角),没有圆角档」——而令牌表 `component_map::table` 的 `ListItem`
是 `Small(8)`。产品侧根本没读表,注释在替错误站台。

改为 `row_border(ui)` → `ui.radius_dp(Component::ListItem.spec().shape)`,数值只留在
令牌表。变异实测(只改表的那一行 `Small→Large`):行圆角 8 → 16,240×60 选中行的顶行
缩进 6 → 13;改回后 11 张图逐字节复原(`DevDocs/artifacts/listradius/`,不入库)。

同 commit 改掉那条直角注释、模块文档补 4.9 行,并加两条单测:渲染路径的
`Border.radius` = 表值折算;源码里不许再出现硬编码的 `ShapeScale::*` 档。

外观影响的边界(写进取证报告):行只在 选中/悬停/按下 时有容器色,未选中静息与禁用
两态 `fill` 为 `None`,那两态逐字节不变是**正确**的,不是没生效。
2026-11-12 23:38:56 +08:00
AskaEth a07cda34cb Merge branch 'feat/m289-dltimeout-bound-every-download-awai'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-12 23:27:19 +08:00
AskaEth d6f862a43c fix(download): 补齐 download_bytes 与等许可两处无界 await,预算耗尽不再重试(M289 r1)
r1 复审 p2-1:`stall.rs` 那句「全仓只有两处无界 await」是假话。真实清单五处,
M289 之前只修了两处;这次补齐:

* `bytes.rs::fetch_bytes` 的 `send()` 改走 `http::send_bounded`(与文件下载同一个构造点);
  整包 `response.bytes()` 换成 `bytes_stream` + `http::read_step` 的空闲判定,并边读边卡
  `MAX_IN_MEMORY_BYTES`(`Content-Length` 可缺失/可撒谎)。生产调用点:版本清单、
  版本 JSON、Modrinth 元数据。
* `engine::acquire` 的 `acquire_owned()` 加上界(`EngineConfig::permit_timeout`,默认
  120min ≥ 单个作业总期限),到点报新阶段 `StallStage::AcquirePermit`(「等许可」),
  不冒充「服务器不给数据」。这就是「卡死作业攥着许可把队列堵死」的连坐形态。
* `stall.rs` 模块头改成一张「哪些入口有界、谁托管给谁」的表,并写清五处 await 的真实清单。

r1 复审 p2-2:总期限用完是链路级判决,不再按满预算重试/换源:
* `DownloadError::job_budget_exhausted()` + `DownloadError::is_retryable()`(覆盖
  `kind.is_retryable()`),引擎与 `download_bytes` 都改用它;
* 引擎在预算耗尽时直接结束候选循环,不再换源(同一份字节、同一条链路,换源只会再
  烧满一整个预算)。188MB/20 KiB/s 的用户不再从「1.6 小时慢慢下完」变成「几小时反复重下」。
* `stall.rs` 写明「慢链路照样活」的前提 = **平均吞吐 ≥ 32 KiB/s**,以及它挡掉哪类链路;
  `download.*` schema 接线留给另一个 mission(`setting/keys.rs` 不在 scope)。

顺带(复审 p3):
* `transfer.rs` 的读空闲统一走 `EngineConfig::deadlines()`(不再各读字段);
* `verify_part` 补一条代价说明:`spawn_blocking` 到点不可取消,可能让同名重建拿到
  `Access is denied`(**该假设未验**,注释里已标);
* `engine.rs` 因这轮新增又贴到 410 行 ⇒ `finish` / `materialize` / `millis` 逐字节搬到
  `engine/finish.rs`(`engine::millis` 路径用 `pub(crate) use` 保持可用)。

新增用例(全离线、<2s):`download_bytes` 静默服务端 / 正文中途卡住、等许可超时、
预算耗尽不重试(`max_attempts_per_source = 3` 下仍只发一轮)、预算耗尽不换源
(官方源 `hits == 0`)。
2026-11-12 23:24:19 +08:00
AskaEth cc0def3415 feat(download): 给每条下载 await 上界并在超时时报出「卡在哪一段」(M289 / P19)
全仓下载路径此前只有两处无界 await(分块与单连接的 `send()`),reqwest 自己的
`timeout` 又是 `None`:服务器接了连接不回一个字节 ⇒ future 永不结束,
`ull java install` 那条 `silent()` 路径零事件零日志,用户只看到「卡住不动」。

上界选「单次请求 + 读空闲」,**不是**「整作业总预算」:
* `region.rs` 的 3s 是探测极小响应(整作业 = 一次 GET,两者同一件事);
* `font.rs` 的 ATTEMPT/TOTAL 是几百 KB 的字体(整作业几秒就完);
* 188MB 的 JDK 两者都不能照抄,所以分三段互不顶替的界,理由与取数**只写在**
  `crates/core/src/download/stall.rs` 模块头一处(含「别改成 reqwest 的 timeout,
  它覆盖整个响应体读取」)。

改动:
* 新增 `download::stall`:`StallStage`(连接 / 等响应头 / 等字节 / 等分块回收 / 校验)+
  `StallReason`(期限用完 / 没有新字节)+ `StallReport`(阶段 + 已收 / 预期字节 +
  无进展时长)+ `job_budget`(总期限按 32 KiB/s 平均吞吐反推)。
* `http::send_bounded` / `http::read_step`:两处 `send()` 与两处读循环的**唯一**构造点;
  超时错误带阶段与三个数,`message` 里也抄一份(整体失败时 `suppressed` 只搬 message)。
* `transfer_chunk::await_chunks`:`JoinSet::join_next` 加总期限与无进度窗口,两者靠
  `StallReason` 分开报;窗口内有新字节就续期(慢但有效不误杀)。
* `verify_part` 挪到 blocking 池并套上界:校验阶段卡住也报得出来(阶段=校验)。
* `engine.rs`:整体失败时把**最后一次**尝试的失速现场带上去,并写进用户可见的那行字。
* 配置:`response_timeout` 60s / `chunk_stall_timeout` 120s / `chunk_job_floor` 10min /
  `verify_timeout` 120s(`EngineConfig`,未动设置 schema)。

测试(全离线、`127.0.0.1`、<2s):夹具新增 `silent()` / `stalling_after` / `short_body` /
`oversize_body` / `dripping`;用例覆盖静默服务端、中途卡住、短包、超长包、慢但持续
(4 倍于上界也要成功)、超时后引擎仍能下载(RAII 许可自还,不写手动归还)、以及分块
作业的「无进度 vs 总期限」两条。

另:`config/tests.rs` 的一处表达式在去缩进后行宽变化,随本 commit 由 rustfmt 合成一行
(纯搬移 commit 提交时它仍是逐字节原文)。
2026-11-12 23:24:19 +08:00
AskaEth 61c8a4eff9 refactor(download): 纯搬移为 M289 腾出行数余量(config 单测 / engine 只读查询面)
两个文件都是 400 行零余量的硬线,本 mission 要加超时字段与失速诊断,先纯搬移:

* `config.rs` 的 `#[cfg(test)] mod tests`(174 行)→ `config/tests.rs`,
  原文件用 `#[cfg(test)] #[path = "config/tests.rs"] mod tests;` 声明;
* `engine.rs` 的七个只读 getter(`config` / `concurrency_probe` / `client` /
  `region` / `mirrors` / `health` / `cache`,44 行)→ `engine/inspect.rs`。

逐字节未改:搬移块去缩进后与 HEAD 原文 `diff` 均为 exit 0。

行为零变化(同一批测试仍全绿);新文件都在 `crates/core/src/download/**` 内,
外部路径(`download::config::EngineConfig` 等)不变。
2026-11-12 23:24:18 +08:00
AskaEth 8b48c494cd Merge branch 'feat/forcedwire-force-state-through-settings'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
2026-11-12 23:15:05 +08:00