fix(m159): r1 复审返工 —— parse.rs 拆出子模块 + 五条 P3 真问题

按独立复审 `reviewer-updategui-r1` 的 p1-1 / fix-then-merge 返工。

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

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

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

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

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

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

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

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

门禁(`CARGO_TARGET_DIR=C:/Temp/ull-target/wt-159`):fmt 绿、clippy -D warnings 绿、
`cargo test --workspace --all-targets --no-fail-fast -j 4` **exit 0**(51 个测试二进制全 ok,
`core::addon::pack` 未被 skip)。该族用例复现了复审记的泄漏:一次全量在 `/tmp` 留 72 个
`ull-pack-*`(`/tmp` 149M → 8853M),跑完已 `rm -rf` 清回 150M。
This commit is contained in:
AskaEth committed 2026-11-07 13:03:18 +08:00
1 parent 1c8f6aa1b3
commit 04ac90911e
19 files changed
+521 -112

No files matched your search

+4 -4
View File
@@ -23,7 +23,7 @@ pub(super) fn update_of(view: &UpdateView) -> Update {
checked_at: view.checked_at.clone(),
applying: view.applying,
ready: view.ready.as_ref().map(|ready| ready.version.clone()),
dismissed: view.dismissed,
skipped: view.skipped.clone(),
silent: view.silent,
}
}
@@ -139,7 +139,7 @@ mod tests {
}
#[test]
fn a_dismissed_update_keeps_the_snapshot_but_hides_the_banner() {
fn a_skipped_update_keeps_the_snapshot_but_hides_the_banner() {
let mut view = view(UpdateOutcome::Available(Box::new(
unlucky_presenter::dto::UpdateCandidateView {
version: "0.2.3".to_string(),
@@ -148,9 +148,9 @@ mod tests {
forced: false,
},
)));
view.dismissed = true;
view.skipped = Some("0.2.3".to_string());
let mapped = update_of(&view);
assert!(mapped.dismissed);
assert_eq!(mapped.skipped.as_deref(), Some("0.2.3"));
assert!(mapped.available().is_some(), "候选要留着给设置页");
assert!(mapped.banner().is_none());
}
@@ -72,7 +72,7 @@ fn the_banner_priority_is_ready_then_applying_then_failure_then_available() {
checked_at: String::new(),
applying: true,
ready: None,
dismissed: false,
skipped: None,
silent: false,
};
assert!(matches!(
+19 -3
View File
@@ -70,7 +70,7 @@ pub(super) fn body<'a>(ui: &'a Ui, update: &'a Update, lang: Lang) -> Element<'a
stacked = stacked.push(info_row(
ui,
catalog.text(Key::SettingsUpdateChannel),
catalog.text(Key::SettingsUpdateChannelStable).to_string(),
channel_label(catalog, &update.channel),
));
stacked = stacked.push(divider(ui));
stacked = stacked.push(auto_check_row(ui, catalog, update.auto_check));
@@ -80,6 +80,15 @@ pub(super) fn body<'a>(ui: &'a Ui, update: &'a Update, lang: Lang) -> Element<'a
card(ui, stacked)
}
/// 渠道显示名:`stable` 走 key,其它渠道原样上屏(D50 现在只有 stable,但开 beta/alpha 时
/// 页面不该跟着改——渠道是数据,不是写死的字面量)。
fn channel_label(catalog: Catalog, channel: &str) -> String {
match channel {
"stable" => catalog.text(Key::SettingsUpdateChannelStable).to_string(),
other => other.to_string(),
}
}
/// 「标签 + 值」一行(与 `frame::setting_row` 同一套排版,但值要在调用处现算)。
fn info_row<'a>(ui: &'a Ui, label: &'a str, value: String) -> Element<'a, Message> {
let height = ui.metrics().control_height + Space::X2.dp();
@@ -148,7 +157,13 @@ fn status_block<'a>(ui: &'a Ui, catalog: Catalog, update: &'a Update) -> Element
checked,
));
let (text, error) = status_line(catalog, update);
// 下载/校验/替换期间先说这一句:设置页的按钮在这一刻也点不动(见下面 `applying` 分支),
// 用户得知道「它正在干活」而不是「点了没反应」。
let (text, error) = if update.applying {
(catalog.text(Key::SettingsUpdateApplying).to_string(), false)
} else {
status_line(catalog, update)
};
let color = ui.color(if error {
Role::Error
} else {
@@ -156,7 +171,8 @@ fn status_block<'a>(ui: &'a Ui, catalog: Catalog, update: &'a Update) -> Element
});
block = block.push(text_role_owned(ui, TypeRole::BodySmall, false, text, color));
if let Some((_version, _size, notes, forced)) = update.available() {
if let Some((_version, _size, notes, forced)) = update.available().filter(|_| !update.applying)
{
let mut actions = row![buttons::filled(
ui,
Button::new(
+22 -10
View File
@@ -93,8 +93,11 @@ pub struct Update {
pub applying: bool,
/// 已就位的新版本号(`None` = 没有)
pub ready: Option<String>,
/// 用户是否已忽略这个版本(本次会话)
pub dismissed: bool,
/// 用户点了「忽略这个版本」时记下的**版本号**(`None` = 没忽略)。
///
/// 只压住**这一个**版本:0.2.3 被忽略后同一次运行里冒出 0.2.4 仍然提示
/// (文案是「忽略这个版本」,不是「别再问我」)。
pub skipped: Option<String>,
/// 这一份结论来自静默检查(启动时/自动):失败时**不**弹提示条
pub silent: bool,
}
@@ -133,16 +136,13 @@ impl Update {
retryable: *retryable,
});
}
if self.dismissed {
return None;
}
match &self.status {
UpdateStatus::Available {
version,
size,
notes,
..
} => Some(Banner::Available {
} if self.skipped.as_deref() != Some(version.as_str()) => Some(Banner::Available {
version,
size: *size,
notes,
@@ -170,7 +170,7 @@ pub fn demo() -> Update {
checked_at: "11-08 12:00".to_string(),
applying: false,
ready: None,
dismissed: false,
skipped: None,
silent: false,
}
}
@@ -366,10 +366,22 @@ mod tests {
};
assert!(matches!(update.banner(), Some(Banner::Available { .. })));
update.dismissed = true;
assert!(update.banner().is_none(), "拒绝之后不再打扰");
update.skipped = Some("0.2.3".to_string());
assert!(update.banner().is_none(), "忽略之后不再打扰");
update.dismissed = false;
// 忽略的是**那一个版本**:同一次运行里冒出 0.2.4 仍然提示(与 presenter 同一语义)。
update.status = UpdateStatus::Available {
version: "0.2.4".to_string(),
size: 1,
notes: String::new(),
forced: false,
};
assert!(
matches!(update.banner(), Some(Banner::Available { version, .. }) if version == "0.2.4"),
"0.2.3 被忽略不该压住 0.2.4"
);
update.skipped = None;
update.applying = true;
assert!(matches!(update.banner(), Some(Banner::Applying)));