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(), checked_at: view.checked_at.clone(),
applying: view.applying, applying: view.applying,
ready: view.ready.as_ref().map(|ready| ready.version.clone()), ready: view.ready.as_ref().map(|ready| ready.version.clone()),
dismissed: view.dismissed, skipped: view.skipped.clone(),
silent: view.silent, silent: view.silent,
} }
} }
@@ -139,7 +139,7 @@ mod tests {
} }
#[test] #[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( let mut view = view(UpdateOutcome::Available(Box::new(
unlucky_presenter::dto::UpdateCandidateView { unlucky_presenter::dto::UpdateCandidateView {
version: "0.2.3".to_string(), version: "0.2.3".to_string(),
@@ -148,9 +148,9 @@ mod tests {
forced: false, forced: false,
}, },
))); )));
view.dismissed = true; view.skipped = Some("0.2.3".to_string());
let mapped = update_of(&view); 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.available().is_some(), "候选要留着给设置页");
assert!(mapped.banner().is_none()); 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(), checked_at: String::new(),
applying: true, applying: true,
ready: None, ready: None,
dismissed: false, skipped: None,
silent: false, silent: false,
}; };
assert!(matches!( 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( stacked = stacked.push(info_row(
ui, ui,
catalog.text(Key::SettingsUpdateChannel), catalog.text(Key::SettingsUpdateChannel),
catalog.text(Key::SettingsUpdateChannelStable).to_string(), channel_label(catalog, &update.channel),
)); ));
stacked = stacked.push(divider(ui)); stacked = stacked.push(divider(ui));
stacked = stacked.push(auto_check_row(ui, catalog, update.auto_check)); 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) 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` 同一套排版,但值要在调用处现算)。 /// 「标签 + 值」一行(与 `frame::setting_row` 同一套排版,但值要在调用处现算)。
fn info_row<'a>(ui: &'a Ui, label: &'a str, value: String) -> Element<'a, Message> { fn info_row<'a>(ui: &'a Ui, label: &'a str, value: String) -> Element<'a, Message> {
let height = ui.metrics().control_height + Space::X2.dp(); 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, 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 { let color = ui.color(if error {
Role::Error Role::Error
} else { } 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)); 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( let mut actions = row![buttons::filled(
ui, ui,
Button::new( Button::new(
+22 -10
View File
@@ -93,8 +93,11 @@ pub struct Update {
pub applying: bool, pub applying: bool,
/// 已就位的新版本号(`None` = 没有) /// 已就位的新版本号(`None` = 没有)
pub ready: Option<String>, pub ready: Option<String>,
/// 用户是否已忽略这个版本(本次会话) /// 用户点了「忽略这个版本」时记下的**版本号**(`None` = 没忽略)。
pub dismissed: bool, ///
/// 只压住**这一个**版本:0.2.3 被忽略后同一次运行里冒出 0.2.4 仍然提示
/// (文案是「忽略这个版本」,不是「别再问我」)。
pub skipped: Option<String>,
/// 这一份结论来自静默检查(启动时/自动):失败时**不**弹提示条 /// 这一份结论来自静默检查(启动时/自动):失败时**不**弹提示条
pub silent: bool, pub silent: bool,
} }
@@ -133,16 +136,13 @@ impl Update {
retryable: *retryable, retryable: *retryable,
}); });
} }
if self.dismissed {
return None;
}
match &self.status { match &self.status {
UpdateStatus::Available { UpdateStatus::Available {
version, version,
size, size,
notes, notes,
.. ..
} => Some(Banner::Available { } if self.skipped.as_deref() != Some(version.as_str()) => Some(Banner::Available {
version, version,
size: *size, size: *size,
notes, notes,
@@ -170,7 +170,7 @@ pub fn demo() -> Update {
checked_at: "11-08 12:00".to_string(), checked_at: "11-08 12:00".to_string(),
applying: false, applying: false,
ready: None, ready: None,
dismissed: false, skipped: None,
silent: false, silent: false,
} }
} }
@@ -366,10 +366,22 @@ mod tests {
}; };
assert!(matches!(update.banner(), Some(Banner::Available { .. }))); assert!(matches!(update.banner(), Some(Banner::Available { .. })));
update.dismissed = true; update.skipped = Some("0.2.3".to_string());
assert!(update.banner().is_none(), "拒绝之后不再打扰"); 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; update.applying = true;
assert!(matches!(update.banner(), Some(Banner::Applying))); assert!(matches!(update.banner(), Some(Banner::Applying)));
+3 -27
View File
@@ -10,10 +10,11 @@ use crate::error::CliError;
mod accounts; mod accounts;
mod agent; mod agent;
mod instances; mod instances;
mod update;
use super::model::{ use super::model::{
Command, ConfigGetArgs, ConfigSetArgs, DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs, Command, ConfigGetArgs, ConfigSetArgs, DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs,
LogsArgs, PackImportArgs, UpdateApplyArgs, UpdateCheckArgs, LogsArgs, PackImportArgs,
}; };
/// `logs` 缺省尾巴行数。 /// `logs` 缺省尾巴行数。
@@ -93,7 +94,7 @@ fn parse_command(rest: &[String], globals: &Globals) -> Result<Command, CliError
"java" => parse_java(tail), "java" => parse_java(tail),
"accounts" => accounts::parse_accounts(tail), "accounts" => accounts::parse_accounts(tail),
"agent" => agent::parse_agent(tail), "agent" => agent::parse_agent(tail),
"update" => parse_update(tail), "update" => update::parse_update(tail),
other => { other => {
Err(CliError::usage(format!("未知命令 `{other}`")) Err(CliError::usage(format!("未知命令 `{other}`"))
.with_hint("%prog% --help 查看命令列表")) .with_hint("%prog% --help 查看命令列表"))
@@ -242,31 +243,6 @@ fn parse_config(tail: &[String]) -> Result<Command, CliError> {
} }
} }
/// `update check` / `update apply [--dest <path>]`。
fn parse_update(tail: &[String]) -> Result<Command, CliError> {
let Some(sub) = tail.first().map(String::as_str) else {
return Err(CliError::usage("update 需要子命令 check|apply")
.with_hint("%prog% update check / %prog% update apply"));
};
let rest = tail.get(1..).unwrap_or_default();
match sub {
"check" => {
ensure_no_extra(rest, "update check")?;
Ok(Command::UpdateCheck(UpdateCheckArgs {}))
}
"apply" => {
let parsed = parse_flags(rest, &["dest"], &[])?;
ensure_no_positionals(&parsed, "update apply")?;
Ok(Command::UpdateApply(UpdateApplyArgs {
dest: parsed.value("dest").map(PathBuf::from),
}))
}
other => Err(CliError::usage(format!(
"update 的子命令只有 check|apply,收到 `{other}`"
))),
}
}
fn parse_java(tail: &[String]) -> Result<Command, CliError> { fn parse_java(tail: &[String]) -> Result<Command, CliError> {
match tail.first().map(String::as_str) { match tail.first().map(String::as_str) {
None | Some("list") => { None | Some("list") => {
+46
View File
@@ -0,0 +1,46 @@
//! `ull update` 子命令的解析(从 `parse.rs` 拆出,避免解析器主文件顶到 400 行)。
//!
//! 语法:
//! - `ull update check`
//! - `ull update apply [--dest <path>]`
//!
//! 两条命令都只是「造一个 `core::update::Job` → 调 `check` / `apply` → 排版输出」,
//! 判定(版本比较、渠道准入、清单校验)与替换(双检 + 原子改名)全在 core。
//! `--dest` 钉替换目标,缺省是本进程的 exe(取证 / 自定义安装位置时才给)。
//!
//! ## 为什么单独一个文件
//!
//! `parse.rs` 在加进这两条之后是 406 行,撞仓库 400 行硬线(`guards.yml` 的 `file-size`
//! 作业按 `git ls-files '*.rs'` + `wc -l > 400` 判红)。拆法与 `parse/{accounts,agent,
//! instances}.rs` 一致:一个子命令族一个文件,主文件只留分发。
use std::path::PathBuf;
use super::{ensure_no_extra, ensure_no_positionals, parse_flags};
use crate::cli::model::{Command, UpdateApplyArgs, UpdateCheckArgs};
use crate::error::CliError;
/// 解析 `update ...`。
pub(super) fn parse_update(tail: &[String]) -> Result<Command, CliError> {
let Some(sub) = tail.first().map(String::as_str) else {
return Err(CliError::usage("update 需要子命令 check|apply")
.with_hint("%prog% update check / %prog% update apply"));
};
let rest = tail.get(1..).unwrap_or_default();
match sub {
"check" => {
ensure_no_extra(rest, "update check")?;
Ok(Command::UpdateCheck(UpdateCheckArgs {}))
}
"apply" => {
let parsed = parse_flags(rest, &["dest"], &[])?;
ensure_no_positionals(&parsed, "update apply")?;
Ok(Command::UpdateApply(UpdateApplyArgs {
dest: parsed.value("dest").map(PathBuf::from),
}))
}
other => Err(CliError::usage(format!(
"update 的子命令只有 check|apply,收到 `{other}`"
))),
}
}
+16 -1
View File
@@ -48,7 +48,18 @@ fn job(ctx: &Ctx, dest: Option<PathBuf>) -> Job {
} }
} }
/// 失败 → CLI 错误(保留 core 的稳定分类 id 在 code 里,脚本可分流)。 /// 失败 → CLI 错误(`code` 说「哪条命令的哪一类失败」,`detail.kind` 说**到底为什么**)。
///
/// `--json` 信封示例(复审 P3-3 之后的样子):
///
/// ```json
/// {"ok":false,"error":{"code":"update_apply_failed","kind":"user","message":"应用更新失败(digest_mismatch):…",
/// "hint":"…","detail":{"kind":"digest_mismatch","retryable":true}}}
/// ```
///
/// 为什么要两层:`code` 是命令级的(脚本据此决定「要不要提示用户重跑」),`detail.kind` 是
/// **底层原因**(`digest_mismatch` / `network` / `invalid_manifest` / `io` …,据此决定「重试有没有意义」)。
/// 以前原因只写在人读 message 里,脚本不解析散文就分不出来。
fn failure(code: &'static str, action: &str, error: &UpdateError) -> CliError { fn failure(code: &'static str, action: &str, error: &UpdateError) -> CliError {
let hint = if error.is_retryable() { let hint = if error.is_retryable() {
"网络/占用类失败,过一会儿重试一次通常就好" "网络/占用类失败,过一会儿重试一次通常就好"
@@ -59,6 +70,10 @@ fn failure(code: &'static str, action: &str, error: &UpdateError) -> CliError {
code, code,
format!("{action}失败({}):{error}", error.kind.as_str()), format!("{action}失败({}):{error}", error.kind.as_str()),
) )
.with_detail(json!({
"kind": error.kind.as_str(),
"retryable": error.is_retryable(),
}))
.with_hint(hint) .with_hint(hint)
} }
+33
View File
@@ -54,6 +54,17 @@ pub struct CliError {
code: &'static str, code: &'static str,
message: String, message: String,
hint: Option<String>, hint: Option<String>,
/// `--json` 信封里 `error.detail` 的结构化载荷(可选)。
///
/// 为什么要有它:`code` 只分到「哪条命令的哪一类失败」(`update_apply_failed`),
/// 而脚本真正要分流的是**底层原因**(`digest_mismatch` / `network` / `invalid_manifest`)。
/// 那些原因以前只出现在人读 `message` 里(`应用更新失败(digest_mismatch):…`),
/// 脚本不解析散文就分不出来。`detail` 把它们挪进机器可读字段。
///
/// **装箱**:`serde_json::Value` 有 32 字节,直接塞进 `CliError` 会把它顶到 144 字节,
/// 于是所有 `Result<_, CliError>` 都触发 `clippy::result_large_err`(阈值 128)。
/// `Option<Box<_>>` 只占 8 字节,错误对象保持轻量。
detail: Option<Box<Value>>,
} }
/// 把模板里的程序名占位符渲染掉(M82 r3)。 /// 把模板里的程序名占位符渲染掉(M82 r3)。
@@ -73,6 +84,7 @@ impl CliError {
code: "usage", code: "usage",
message: render(message), message: render(message),
hint: None, hint: None,
detail: None,
} }
} }
@@ -84,6 +96,7 @@ impl CliError {
code, code,
message: render(message), message: render(message),
hint: None, hint: None,
detail: None,
} }
} }
@@ -95,6 +108,7 @@ impl CliError {
code, code,
message: render(message), message: render(message),
hint: None, hint: None,
detail: None,
} }
} }
@@ -111,6 +125,22 @@ impl CliError {
self self
} }
/// 补一段**机器可读**的细节(`--json` 里落在 `error.detail`)。
///
/// 约定:`detail` 里只放脚本要分流的结构化字段(底层分类 id、可重试位、目标路径…),
/// 人话仍在 `message` / `hint` 里——两条出口各管一件事,别把散文塞进 `detail`。
#[must_use]
pub fn with_detail(mut self, detail: Value) -> Self {
self.detail = Some(Box::new(detail));
self
}
/// 机器可读细节(没有则为 `None`)。
#[must_use]
pub fn detail(&self) -> Option<&Value> {
self.detail.as_deref()
}
/// 错误大类。 /// 错误大类。
#[must_use] #[must_use]
pub const fn kind(&self) -> ErrorKind { pub const fn kind(&self) -> ErrorKind {
@@ -152,6 +182,9 @@ impl CliError {
if let Some(hint) = &self.hint { if let Some(hint) = &self.hint {
error["hint"] = Value::String(hint.clone()); error["hint"] = Value::String(hint.clone());
} }
if let Some(detail) = &self.detail {
error["detail"] = (**detail).clone();
}
json!({ "ok": false, "error": error }) json!({ "ok": false, "error": error })
} }
} }
+46
View File
@@ -257,6 +257,51 @@ fn java_subcommands() {
assert_eq!(invoke(&["java", "vendors"]).command, Command::JavaVendors); assert_eq!(invoke(&["java", "vendors"]).command, Command::JavaVendors);
} }
#[test]
fn update_subcommands() {
use unlucky_cli::cli::{UpdateApplyArgs, UpdateCheckArgs};
assert_eq!(
invoke(&["update", "check"]).command,
Command::UpdateCheck(UpdateCheckArgs {})
);
assert_eq!(
invoke(&["update", "apply"]).command,
Command::UpdateApply(UpdateApplyArgs { dest: None })
);
// `--dest` 两种写法都要认(钉替换目标 = 取证 / 自定义安装位置)
for args in [
vec!["update", "apply", "--dest", "D:/tmp/ull.exe"],
vec!["update", "apply", "--dest=D:/tmp/ull.exe"],
] {
assert_eq!(
invoke(&args).command,
Command::UpdateApply(UpdateApplyArgs {
dest: Some(std::path::PathBuf::from("D:/tmp/ull.exe")),
})
);
}
}
/// `update` 的用法错误:没子命令 / 未知子命令 / 多余位置参数 / 未知选项——四条都是 `usage`
/// (退出码 1),不许静默退化成某个默认动作。
#[test]
fn update_usage_errors() {
for args in [
vec!["update"],
vec!["update", "install"],
vec!["update", "check", "extra"],
vec!["update", "apply", "extra"],
vec!["update", "apply", "--dest"],
vec!["update", "apply", "--nope"],
] {
let owned: Vec<String> = args.iter().map(|arg| arg.to_string()).collect();
let error = parse(&owned).expect_err(&format!("{args:?} 应当报用法错误"));
assert_eq!(error.kind(), ErrorKind::User, "{args:?}");
assert_eq!(error.code(), "usage", "{args:?}");
}
}
#[test] #[test]
fn usage_errors_are_user_errors() { fn usage_errors_are_user_errors() {
let cases: &[&[&str]] = &[ let cases: &[&[&str]] = &[
@@ -328,6 +373,7 @@ fn help_text_covers_every_command() {
"diag", "diag",
"config", "config",
"java", "java",
"update",
] { ] {
assert!( assert!(
unlucky_cli::cli::help::topic(topic).is_some(), unlucky_cli::cli::help::topic(topic).is_some(),
+8 -2
View File
@@ -20,8 +20,14 @@
//! //!
//! # 本模块**不**负责的事 //! # 本模块**不**负责的事
//! //!
//! * **不判断现在能不能替换**。正在跑的 exe 只能由「退出自身 → 由另一个进程搬运」这条 //! * **不判断现在能不能替换**:占用/杀软/权限都在 [`ReplacePolicy`] 的重试里撞,撞不动就回滚。
//! 路换掉(HMCL 的 `--apply-to` 模式,见 analyse/07 §4.3)。这里只保证搬一半失败能回滚。 //! **正在运行的 exe 照样能换**——这一点 M159 在 Windows 真机验过(`ull update apply` 在自己
//! 运行中完成了 `rename(dest→备份)` + `rename(暂存→dest)`,替换后的 exe 仍能启动):
//! Windows 加载器打开映像时带 `FILE_SHARE_DELETE`,所以**可以改名**,只是**不能覆盖/删除**
//! (改字节这条路根本不存在)。这也是本模块「先改名、再搬入」而不是「直接覆盖」的原因。
//! 别照早先那版文档去设计「退出自身 → 由另一个进程搬运」的双进程方案(HMCL 的 `--apply-to`):
//! 那条路在 Windows 上不是必需的。(Unix 上 `rename` 本就可以覆盖,两步写法是为了两边同一条
//! 回滚路径,见下。)
//! * **不装安装器形态**(msi/dmg/deb/rpm):那些该交给系统包管理器, //! * **不装安装器形态**(msi/dmg/deb/rpm):那些该交给系统包管理器,
//! [`crate::update::PackageFormat::needs_installer`] 是给上层的判据。 //! [`crate::update::PackageFormat::needs_installer`] 是给上层的判据。
//! * **不重启、不删备份**。备份何时删由上层决定(新版启动成功后再 //! * **不重启、不删备份**。备份何时删由上层决定(新版启动成功后再
+69 -7
View File
@@ -38,15 +38,17 @@ use super::checker::{CheckOutcome, UpdateChecker};
use super::error::UpdateError; use super::error::UpdateError;
use super::github::GitHubReleaseSource; use super::github::GitHubReleaseSource;
use super::install::{install_staged, InstallReceipt, ReplacePolicy}; use super::install::{install_staged, InstallReceipt, ReplacePolicy};
use super::manifest::Artifact;
use super::source::{DirSource, UpdateSource}; use super::source::{DirSource, UpdateSource};
use super::target::Target; use super::target::{PackageFormat, Target};
use super::verify::stage; use super::verify::stage;
use super::version::Version; use super::version::Version;
/// 本机更新源覆盖(取证 / 内网手工分发):指向一个 `DirSource` 根目录(`<root>/stable.json`)。 /// 本机更新源覆盖(取证 / 内网手工分发):指向一个 `DirSource` 根目录(`<root>/stable.json`)。
/// ///
/// 生产不设它——真实网络下 `releases/latest` 是唯一入口。它的用途是把「发现新版本」这条分支 /// **只在调试构建里生效**(判据与理由见 [`local_dir_override`]):发布构建忽略它,
/// 在没有稳定 Release 的情况下也能整环验一遍(本地清单 + 本地产物,一个字节都不出网)。 /// 真实网络下 `releases/latest` 是唯一入口。它的用途是把「发现新版本」这条分支在没有稳定
/// Release 的情况下也能整环验一遍(本地清单 + 本地产物,一个字节都不出网)。
pub const UPDATE_DIR_ENV: &str = "ULL_UPDATE_DIR"; pub const UPDATE_DIR_ENV: &str = "ULL_UPDATE_DIR";
/// 一次应用的结局。 /// 一次应用的结局。
@@ -165,6 +167,10 @@ impl Job {
let Some(candidate) = outcome.candidate() else { let Some(candidate) = outcome.candidate() else {
return Ok(Applied::Nothing(outcome)); return Ok(Applied::Nothing(outcome));
}; };
// P3-6:清单只有 `target` 参与挑选(`Release::artifact_for` 只比平台),所以清单可以给
// win-x64 挂一个 msi;以前这里会把它直接 rename 成本进程的 exe(D50 明说「不做 MSI」)。
// 先按包形态挡一次,**挡在取包之前**:既不浪费下载,也不会在目标路径上留下任何东西。
ensure_self_replaceable(&candidate.artifact)?;
let staged = stage(&*self.source, &candidate.artifact, &self.staging)?; let staged = stage(&*self.source, &candidate.artifact, &self.staging)?;
let version = candidate.release.version.to_string(); let version = candidate.release.version.to_string();
match install_staged(&staged, &self.dest, ReplacePolicy::default()) { match install_staged(&staged, &self.dest, ReplacePolicy::default()) {
@@ -174,6 +180,32 @@ impl Job {
} }
} }
/// 这个包形态能不能**原地**变成启动器本体。
///
/// 只有「便携单文件 exe」([`PackageFormat::PortableExe`])可以:
///
/// | 形态 | 为什么不行 |
/// |---|---|
/// | `msi` / `dmg` / `deb` / `rpm` | D50 明说「不做 MSI」——那是系统包管理器的活(`needs_installer()`) |
/// | `portable-zip` | 是压缩包,不是启动器本体:rename 到 exe 路径只会得到一个跑不起来的文件 |
/// | `appimage` | Linux 的可执行镜像,Windows/macOS 上没有意义;且本模块只做「同目录改名换 exe」 |
pub(super) fn self_replaceable(format: PackageFormat) -> bool {
matches!(format, PackageFormat::PortableExe)
}
/// 包形态不合规就拒绝(错误分类是 `InvalidManifest`:清单挂错了东西,换源/重试都没用)。
fn ensure_self_replaceable(artifact: &Artifact) -> Result<(), UpdateError> {
if self_replaceable(artifact.format) {
return Ok(());
}
Err(UpdateError::invalid_manifest(format!(
"发布里的 {} 产物是 {} 形态,不能原地替换启动器:本启动器只做便携单文件 exe 的自替换\
(安装器形态请用系统包管理器;压缩包请解压后再用)",
artifact.target.as_str(),
artifact.format.as_str()
)))
}
/// `&dyn UpdateSource` 也能当源用([`UpdateChecker`] 是泛型的,这里省掉一次装箱)。 /// `&dyn UpdateSource` 也能当源用([`UpdateChecker`] 是泛型的,这里省掉一次装箱)。
struct SourceRef<'a>(&'a dyn UpdateSource); struct SourceRef<'a>(&'a dyn UpdateSource);
@@ -210,11 +242,41 @@ pub fn staging_dir(cache_root: &Path) -> PathBuf {
cache_root.join("update").join("staging") cache_root.join("update").join("staging")
} }
/// 生产源:GitHub Releases(D50);`ULL_UPDATE_DIR` 设了就走本地目录源。 /// 生产源:GitHub Releases(D50);**只在调试构建里**,`ULL_UPDATE_DIR` 设了就走本地目录源。
#[must_use] #[must_use]
pub fn default_source(engine: Arc<DownloadEngine>) -> Box<dyn UpdateSource> { pub fn default_source(engine: Arc<DownloadEngine>) -> Box<dyn UpdateSource> {
match std::env::var(UPDATE_DIR_ENV) { match local_dir_override() {
Ok(root) if !root.trim().is_empty() => Box::new(DirSource::new(root.trim())), Some(root) => Box::new(DirSource::new(root)),
_ => Box::new(GitHubReleaseSource::new(engine)), None => Box::new(GitHubReleaseSource::new(engine)),
} }
} }
/// `ULL_UPDATE_DIR` 的落点:**只认调试构建**。
///
/// # 为什么判据是 `debug_assertions` 而不是别的
///
/// 这个后门能绕过 GitHub 源:谁能让启动器读到一份本地清单,谁就能让它把任意字节 rename 成本进程
/// 的 exe。清单里的摘要也来自同一份清单,所以**这条路上没有可信根**——它不是权限提升(要能设用户
/// 环境变量 + 用户自己点「下载并安装」),但发布产物里没有任何理由保留它。
///
/// 三档选一,我选了「按构建画像判」:
///
/// | 判据 | 为什么不选 |
/// |---|---|
/// | 命令行显式参数(`--update-dir`) | 要给 GUI 与 CLI 各加一条参数、还要穿到 `Job` 里;而它只服务取证/内网,不该出现在正式用法里 |
/// | 运行期开关(设置键 / 环境变量再加一层鉴权) | 等于「发布版也留着这条路」,只是多了一道可被一起设好的门 |
/// | **`cfg!(debug_assertions)`** | 发布构建(`--release`)里这个常量是 `false`,整段分支被常量折叠掉——**release 二进制里这条路径根本不存在**,而且是编译期事实,不靠用户/环境配合 |
///
/// 调试构建仍认它:本 mission 的「发现新版本 → 下载 → 双检 → 自替换」整环就是用 `cargo build`
/// 出来的二进制 + 本地夹具验的(真实 GitHub 上 v0.2.2 还是 prerelease,取不到 stable 清单)。
fn local_dir_override() -> Option<std::path::PathBuf> {
if !cfg!(debug_assertions) {
return None;
}
let root = std::env::var(UPDATE_DIR_ENV).ok()?;
let root = root.trim();
if root.is_empty() {
return None;
}
Some(std::path::PathBuf::from(root))
}
+159
View File
@@ -0,0 +1,159 @@
//! `Job`(检查/应用的一条龙)的用例:包形态闸门 + 一条成功的自替换。
//!
//! 为什么这层要单独测:`Release::artifact_for` 只比 `target`、不看 `format`,所以「清单给
//! win-x64 挂了一个 msi/zip」是完全可能的输入(复审 P3-6)。以前 `apply` 会把它直接 rename
//! 成本进程的 exe——D50 明说「不做 MSI」,而 zip 当 exe 更是跑不起来。闸门在本模块,
//! 用例就在这里钉住(presenter / CLI 的测试覆盖不到「形态」这一维)。
use crate::platform::os_info::OsFamily;
use crate::update::job::self_replaceable;
use crate::update::{
Applied, Channel, DirSource, Job, PackageFormat, Target, UpdateErrorKind, Version,
};
use super::support::{artifact, manifest, release, TempDir};
const PAYLOAD: &[u8] = b"job payload";
fn target() -> Target {
Target::new(OsFamily::Windows, crate::update::Arch::X64)
}
/// 造一个本地源 + 一个替换目标;返回的 `TempDir` 要活到用例结束。
fn fixture(format: &str, version: &str) -> (TempDir, Job) {
let dir = TempDir::new("job");
dir.write(
"stable.json",
manifest(
"stable",
&[release(
version,
"",
&[artifact("win-x64", format, "asset.bin", PAYLOAD)],
)],
)
.as_bytes(),
);
dir.write("asset.bin", PAYLOAD);
let dest = dir.join("bin/unlucky-launcher.exe");
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).expect("建目标目录");
}
std::fs::write(&dest, b"old-bytes").expect("旧版本");
let staging = dir.join("staging");
let job = Job::new(
Box::new(DirSource::new(dir.path())),
target(),
staging,
dest,
);
(dir, job)
}
/// 只有便携单文件 exe 能原地变成启动器本体。
///
/// 抓得住:把闸门写成 `!needs_installer()`(那样 `portable-zip` 会漏过去,zip 会被 rename 成
/// exe,得到一个跑不起来的启动器)。
#[test]
fn only_portable_exe_is_self_replaceable() {
assert!(self_replaceable(PackageFormat::PortableExe));
for format in [
PackageFormat::Msi,
PackageFormat::Dmg,
PackageFormat::Deb,
PackageFormat::Rpm,
PackageFormat::AppImage,
PackageFormat::PortableZip,
] {
assert!(
!self_replaceable(format),
"{} 不该被当成可原地自替换的形态",
format.as_str()
);
}
}
/// 安装器形态被拒绝,且**目标文件一个字节都没动、暂存目录没建**(闸门在取包之前)。
#[test]
fn an_installer_artifact_is_refused_before_any_download() {
for format in ["msi", "portable-zip"] {
let (dir, job) = fixture(format, "9.9.9");
let error = job
.apply(&Version::parse("0.2.2").expect("版本"), Channel::Stable)
.expect_err("不该装上");
assert_eq!(
error.kind,
UpdateErrorKind::InvalidManifest,
"{format}:清单挂错形态属发布侧问题(换源/重试都没用),实际 {error:?}"
);
assert_eq!(
std::fs::read(job.dest()).expect("读目标"),
b"old-bytes",
"{format}:拒绝之后目标文件不许被动过"
);
assert!(
!job.staging().exists(),
"{format}:闸门在取包之前,暂存目录都不该被建出来"
);
drop(dir);
}
}
/// 便携 exe 形态走完整条路:新字节就位、旧字节进备份。
#[test]
fn a_portable_exe_runs_the_whole_chain() {
let (_dir, job) = fixture("portable-exe", "9.9.9");
let applied = job
.apply(&Version::parse("0.2.2").expect("版本"), Channel::Stable)
.expect("应当装上");
let Applied::Installed { receipt, version } = applied else {
panic!("应当装上");
};
assert_eq!(version, "9.9.9");
assert_eq!(std::fs::read(job.dest()).expect("读目标"), PAYLOAD);
let backup = receipt.backup.expect("旧版本应留备份");
assert_eq!(std::fs::read(backup).expect("读备份"), b"old-bytes");
}
/// 发布里挂的是「没有可装更新」时 `apply` 返回 `Nothing`,不碰任何文件。
#[test]
fn nothing_to_apply_does_not_touch_the_target() {
let dir = TempDir::new("job-none");
dir.write(
"stable.json",
manifest(
"stable",
&[release(
"0.2.2",
"",
&[artifact("win-x64", "portable-exe", "asset.bin", PAYLOAD)],
)],
)
.as_bytes(),
);
let dest = dir.join("bin/unlucky-launcher.exe");
std::fs::create_dir_all(dest.parent().expect("父目录")).expect("建目录");
std::fs::write(&dest, b"old-bytes").expect("旧版本");
let job = Job::new(
Box::new(DirSource::new(dir.path())) as Box<dyn crate::update::UpdateSource>,
target(),
dir.join("staging"),
&dest,
);
let applied = job
.apply(&Version::parse("0.2.2").expect("版本"), Channel::Stable)
.expect("不是错误");
assert!(matches!(applied, Applied::Nothing(_)));
assert_eq!(std::fs::read(&dest).expect("读目标"), b"old-bytes");
}
/// 调试构建认 `ULL_UPDATE_DIR`、发布构建不认——判据是靠 `cfg!(debug_assertions)` 折叠掉的,
/// 所以这里用源码锚点把「闸门还在」钉住(release 分支在测试里跑不到,只能这样守)。
#[test]
fn the_local_dir_override_is_debug_only() {
let source = include_str!("../job.rs");
assert!(
source.contains("if !cfg!(debug_assertions) {"),
"`ULL_UPDATE_DIR` 的闸门必须是 debug_assertions(发布构建里整段被折叠掉)"
);
}
+3
View File
@@ -7,6 +7,8 @@
//! * 渠道准入:阶梯比较 + 预发布阶段表; //! * 渠道准入:阶梯比较 + 预发布阶段表;
//! * 校验失败:摘要不符、包体截断(短了/多了)、失败后暂存文件不残留; //! * 校验失败:摘要不符、包体截断(短了/多了)、失败后暂存文件不残留;
//! * 替换与回滚:成功后备份仍在、搬一半失败回滚、目标目录不可写、Windows 占用; //! * 替换与回滚:成功后备份仍在、搬一半失败回滚、目标目录不可写、Windows 占用;
//! * 一条龙(`job`):只有便携单文件 exe 能原地自替换(msi/zip 在取包之前就被拒)、
//! 「没有可装更新」不碰目标、`ULL_UPDATE_DIR` 只在调试构建里生效;
//! * 更新源:缺清单不是错误、渠道不符报错、远端地址拒绝; //! * 更新源:缺清单不是错误、渠道不符报错、远端地址拒绝;
//! * GitHub 源:固定清单地址、产物 URL 解析、非 stable 无更新、与 `DirSource` 对拍。 //! * GitHub 源:固定清单地址、产物 URL 解析、非 stable 无更新、与 `DirSource` 对拍。
//! //!
@@ -16,6 +18,7 @@ mod channel_cases;
mod checker_cases; mod checker_cases;
mod github_cases; mod github_cases;
mod install_cases; mod install_cases;
mod job_cases;
mod manifest_cases; mod manifest_cases;
mod source_cases; mod source_cases;
mod support; mod support;
-1
View File
@@ -389,6 +389,5 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
("UpdateBannerInstall", "Download and install", "下载并安装"), ("UpdateBannerInstall", "Download and install", "下载并安装"),
("UpdateBannerDismiss", "Skip this version", "忽略这个版本"), ("UpdateBannerDismiss", "Skip this version", "忽略这个版本"),
("UpdateBannerRestartNow", "Restart now", "立即重启"), ("UpdateBannerRestartNow", "Restart now", "立即重启"),
("UpdateBannerRestartLater", "Not now", "稍后"),
("UpdateBannerRetry", "Try again", "重试"), ("UpdateBannerRetry", "Try again", "重试"),
]; ];
-1
View File
@@ -51,7 +51,6 @@ macro_rules! update_entries {
UpdateBannerInstall => ("Download and install", "下载并安装"), UpdateBannerInstall => ("Download and install", "下载并安装"),
UpdateBannerDismiss => ("Skip this version", "忽略这个版本"), UpdateBannerDismiss => ("Skip this version", "忽略这个版本"),
UpdateBannerRestartNow => ("Restart now", "立即重启"), UpdateBannerRestartNow => ("Restart now", "立即重启"),
UpdateBannerRestartLater => ("Not now", "稍后"),
UpdateBannerRetry => ("Try again", "重试"), UpdateBannerRetry => ("Try again", "重试"),
} } } }
}; };
+26 -10
View File
@@ -10,8 +10,12 @@
//! UI 与 CLI 都不许自己比版本号(mission:GUI 与 CLI 行为一致,不许两套逻辑); //! UI 与 CLI 都不许自己比版本号(mission:GUI 与 CLI 行为一致,不许两套逻辑);
//! 4. 本模块不含 core 类型(D42:dto 只放扁平 IPC 类型)。 //! 4. 本模块不含 core 类型(D42:dto 只放扁平 IPC 类型)。
//! //!
//! 「拒绝更新」([`UpdateView::dismissed`])是**会话级**状态:用户点了「忽略这个版本」,本次运行 //! 「忽略这个版本」([`UpdateView::skipped`])记的是**版本号**,不是「别再问我了」:
//! 不再自动弹提示条;但设置页的「立即检查」不受影响(它会把这一位清掉重查)。 //!
//! * 用户点「忽略这个版本」时写下**当时那个版本号**(会话级,不落盘);
//! * 同一次运行里如果又出现**更高**的版本(0.2.3 被忽略后冒出 0.2.4),提示条**照常弹**——
//! 忽略的是那一个版本,文案也确实是「忽略这个版本」;
//! * 设置页的「立即检查」会清掉它(手动检查 = 用户要看真结果),静默检查**永不**清。
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -164,13 +168,18 @@ pub struct UpdateView {
pub applying: bool, pub applying: bool,
/// 已经就位的新版本(等重启)。 /// 已经就位的新版本(等重启)。
pub ready: Option<ReadyUpdate>, pub ready: Option<ReadyUpdate>,
/// 用户点了「忽略这个版本」:本次会话不再弹提示条(设置页手动检查会清掉它)。 /// 用户点了「忽略这个版本」时记下的**版本号**(`None` = 没忽略)。
pub dismissed: bool, ///
/// 只压住**这一个**版本:0.2.3 被忽略后 0.2.4 仍然提示(文案是「忽略这个版本」,
/// 不是「别再问我」)。设置页手动检查会把它清空。
pub skipped: Option<String>,
} }
impl Default for UpdateView { impl Default for UpdateView {
/// 出厂状态:当前版本取本进程的版本号(**不是空串**——mock 后端/未接线的路径下,
/// 设置页的「当前版本」那一行至少要显示自己的版本,而不是一片空白)。
fn default() -> Self { fn default() -> Self {
Self::new(String::new(), "stable", true) Self::new(env!("CARGO_PKG_VERSION"), "stable", true)
} }
} }
@@ -187,7 +196,7 @@ impl UpdateView {
silent: false, silent: false,
applying: false, applying: false,
ready: None, ready: None,
dismissed: false, skipped: None,
} }
} }
@@ -207,7 +216,7 @@ impl UpdateView {
} }
self.outcome self.outcome
.candidate() .candidate()
.filter(|_| !self.dismissed) .filter(|candidate| self.skipped.as_deref() != Some(candidate.version.as_str()))
.map(UpdateBanner::Available) .map(UpdateBanner::Available)
} }
@@ -258,20 +267,27 @@ mod tests {
} }
#[test] #[test]
fn dismissed_hides_the_banner_but_keeps_the_candidate() { fn skipping_one_version_does_not_silence_the_next_one() {
let mut view = UpdateView { let mut view = UpdateView {
outcome: UpdateOutcome::Available(Box::new(candidate("0.2.3"))), outcome: UpdateOutcome::Available(Box::new(candidate("0.2.3"))),
..UpdateView::default() ..UpdateView::default()
}; };
assert!(matches!(view.banner(), Some(UpdateBanner::Available(_)))); assert!(matches!(view.banner(), Some(UpdateBanner::Available(_))));
view.dismissed = true; view.skipped = Some("0.2.3".to_string());
assert!(view.banner().is_none(), "拒绝之后本次不再打扰"); assert!(view.banner().is_none(), "忽略 0.2.3 之后它自己不再打扰");
// 候选没被丢掉:设置页仍能显示「有新版本 0.2.3」并手动装 // 候选没被丢掉:设置页仍能显示「有新版本 0.2.3」并手动装
assert_eq!( assert_eq!(
view.outcome.candidate().map(|item| item.version.as_str()), view.outcome.candidate().map(|item| item.version.as_str()),
Some("0.2.3") Some("0.2.3")
); );
// 同一次运行里冒出更高的版本:**照常提示**(文案是「忽略这个版本」,不是「别再问我」)。
view.outcome = UpdateOutcome::Available(Box::new(candidate("0.2.4")));
assert!(
matches!(view.banner(), Some(UpdateBanner::Available(c)) if c.version == "0.2.4"),
"0.2.3 被忽略不该压住 0.2.4"
);
} }
#[test] #[test]
@@ -6,60 +6,78 @@ use super::*;
// ------------------------------------------------------------------ 会话状态 // ------------------------------------------------------------------ 会话状态
/// 拒绝之后提示条消失,但候选留着;手动检查把「拒绝」清掉。 /// 忽略**某一个版本**之后:它自己不弹了,候选留着;**更高版本照弹**;手动检查清掉忽略。
///
/// 这条是复审 P3-1 的守门:`skipped` 曾经是会话级 bool,于是「忽略 0.2.3」把同一次运行里
/// 后来发布的 0.2.4 也一起压住了——而文案写的是「忽略这个版本」。
#[test] #[test]
fn dismissed_stops_the_banner_but_a_manual_check_revives_it() { fn skipping_a_version_does_not_silence_the_next_one() {
fn available(version: &str) -> Report {
Report::Checked(UpdateOutcome::Available(Box::new(
crate::dto::UpdateCandidateView {
version: version.to_string(),
size: 12,
notes: String::new(),
forced: false,
},
)))
}
let tmp = TempDir::new("dismiss"); let tmp = TempDir::new("dismiss");
let mut runtime = Runtime::with_factory(&HostEnv::at_root(tmp.join("root")), factory(&tmp)); let mut runtime = Runtime::with_factory(&HostEnv::at_root(tmp.join("root")), factory(&tmp));
runtime.adopt(Report::Checked(UpdateOutcome::Available(Box::new( runtime.adopt(available("0.9.0"));
crate::dto::UpdateCandidateView {
version: "0.9.0".to_string(),
size: 12,
notes: String::new(),
forced: false,
},
))));
assert!(matches!( assert!(matches!(
runtime.view().banner(), runtime.view().banner(),
Some(UpdateBanner::Available(_)) Some(UpdateBanner::Available(_))
)); ));
runtime.dismiss(); runtime.dismiss();
assert!(runtime.view().banner().is_none(), "拒绝后本次不再打扰"); assert_eq!(
runtime.view().skipped.as_deref(),
Some("0.9.0"),
"忽略要记下**那一个**版本号"
);
assert!(runtime.view().banner().is_none(), "被忽略的版本不再打扰");
assert!( assert!(
runtime.view().outcome.candidate().is_some(), runtime.view().outcome.candidate().is_some(),
"候选不能丢:设置页还要能手动装" "候选不能丢:设置页还要能手动装"
); );
// 静默检查**回来之后**也不许把拒绝弹回来(`adopt` 才是真正写状态的那一步) // 静默检查回来的是**同一个**版本:仍然压住(用户刚说过忽略它)。
let _ = runtime.begin_check(false).expect("静默检查派发"); let _ = runtime.begin_check(false).expect("静默检查派发");
runtime.adopt(Report::Checked(UpdateOutcome::Available(Box::new( runtime.adopt(available("0.9.0"));
crate::dto::UpdateCandidateView {
version: "0.9.0".to_string(),
size: 12,
notes: String::new(),
forced: false,
},
))));
assert!(
runtime.view().dismissed,
"静默检查的结论回来时也不能清掉用户的拒绝"
);
assert!( assert!(
runtime.view().banner().is_none(), runtime.view().banner().is_none(),
"被拒绝的版本不许因为一次静默检查又弹出来" "同一版本不许因为一次静默检查又弹出来"
); );
// 手动检查 ⇒ 清掉拒绝,重新查 // 静默检查回来的是**更高**版本:照弹(忽略的是 0.9.0,不是「以后别再问」)。
let job = runtime.begin_check(true).expect("手动检查派发");
assert!(!runtime.view().dismissed, "手动检查要清掉拒绝标记");
assert!(runtime.view().outcome.is_checking());
// 静默检查(启动时)**不**清拒绝标记
runtime.adopt(Report::Checked(UpdateOutcome::UpToDate { latest: None }));
runtime.dismiss();
let _ = runtime.begin_check(false).expect("静默检查派发"); let _ = runtime.begin_check(false).expect("静默检查派发");
assert!(runtime.view().dismissed, "静默检查不许把用户的拒绝弹回来"); runtime.adopt(available("0.9.1"));
drop(job); assert!(
matches!(runtime.view().banner(), Some(UpdateBanner::Available(c)) if c.version == "0.9.1"),
"0.9.0 被忽略不该压住 0.9.1"
);
// 手动检查 ⇒ 清掉忽略标记(用户要看真结果),重新查。
let _ = runtime.begin_check(true).expect("手动检查派发");
assert!(
runtime.view().skipped.is_none(),
"手动检查要清掉「忽略这个版本」"
);
assert!(runtime.view().outcome.is_checking());
}
/// 没有候选时点「忽略」是空操作:不该写进一个「你别问」的状态。
#[test]
fn dismissing_without_a_candidate_does_nothing() {
let tmp = TempDir::new("dismiss-none");
let mut runtime = Runtime::with_factory(&HostEnv::at_root(tmp.join("root")), factory(&tmp));
runtime.adopt(Report::Checked(UpdateOutcome::UpToDate {
latest: Some("0.2.2".to_string()),
}));
runtime.dismiss();
assert!(runtime.view().skipped.is_none());
} }
/// 关掉「启动时检查」之后:状态上是 no-op、启动路径上根本不派发。 /// 关掉「启动时检查」之后:状态上是 no-op、启动路径上根本不派发。
+13 -10
View File
@@ -106,7 +106,8 @@ impl Runtime {
self.view.silent = !manual; self.view.silent = !manual;
self.view.applying = false; self.view.applying = false;
if manual { if manual {
self.view.dismissed = false; // 手动检查 = 用户要看真结果,把「忽略这个版本」清掉。
self.view.skipped = None;
} }
Some(self.factory.job()) Some(self.factory.job())
} }
@@ -124,16 +125,14 @@ impl Runtime {
/// 后台回报进快照(**唯一**的写入口:检查与应用都走这里)。 /// 后台回报进快照(**唯一**的写入口:检查与应用都走这里)。
pub fn adopt(&mut self, report: Report) { pub fn adopt(&mut self, report: Report) {
self.inflight = false; self.inflight = false;
self.view.checked_at = super::update::stamp();
match report { match report {
Report::Checked(outcome) => { Report::Checked(outcome) => {
// 「上次检查」只该被**检查**刷新:应用回来的那一刻不是一次检查。
self.view.checked_at = super::update::stamp();
// 「忽略这个版本」记的是版本号,所以**这里不需要动它**:
// 同一版本回来仍被压住,更高版本自然就提示了(见 `UpdateView::skipped`)。
// 手动检查那一支的清空在 `begin_check(manual)` 里。
self.view.outcome = outcome; self.view.outcome = outcome;
// 只有**用户主动查**才清掉「忽略这个版本」;静默检查(启动时/自动)一律不许把
// 用户的拒绝弹回来(mission:拒绝之后本次不再打扰)。手动那一路在
// `begin_check` 里就已经清过了,这里的条件只是让「谁清」这件事只有一条判据。
if !self.view.silent && self.view.outcome.candidate().is_some() {
self.view.dismissed = false;
}
} }
Report::Applied(ApplyReport::Installed { Report::Applied(ApplyReport::Installed {
version, version,
@@ -166,9 +165,13 @@ impl Runtime {
} }
} }
/// 用户拒绝这次更新:本次会话不再弹提示条(候选仍在快照里,设置页可手动装)。 /// 用户忽略**当前这个候选版本**:记下版本号(候选仍留在快照里,设置页可手动装)。
///
/// 没有候选时是空操作(`skipped` 保持原样):没版本可忽略就不该写进一个「你别问」的状态。
pub fn dismiss(&mut self) { pub fn dismiss(&mut self) {
self.view.dismissed = true; if let Some(candidate) = self.view.outcome.candidate() {
self.view.skipped = Some(candidate.version.clone());
}
} }
} }
+1 -1
View File
@@ -10,7 +10,7 @@
//! | [`a_higher_version_without_our_platform_is_not_up_to_date`] | 有新版但没本平台产物 → 单独的变体 | //! | [`a_higher_version_without_our_platform_is_not_up_to_date`] | 有新版但没本平台产物 → 单独的变体 |
//! | [`sha256_mismatch_refuses_the_apply_and_leaves_no_temp_file`] | 摘要不符拒绝应用、暂存文件不残留、目标没被换掉 | //! | [`sha256_mismatch_refuses_the_apply_and_leaves_no_temp_file`] | 摘要不符拒绝应用、暂存文件不残留、目标没被换掉 |
//! | [`a_successful_apply_leaves_the_new_file_and_a_backup`] | 换上去的是新字节、旧字节留在备份里、回报是「就位」 | //! | [`a_successful_apply_leaves_the_new_file_and_a_backup`] | 换上去的是新字节、旧字节留在备份里、回报是「就位」 |
//! | [`dismissed_stops_the_banner_but_a_manual_check_revives_it`] | 拒绝后不再打扰;手动检查清掉拒绝 | //! | [`skipping_a_version_does_not_silence_the_next_one`] | 忽略的语义是**那一个版本**:同版本不再弹、更高版本照弹;手动检查清掉忽略 |
//! | [`auto_check_off_means_no_request_on_start`] | 关掉开关后启动不派发检查(结构性 + 状态两层) | //! | [`auto_check_off_means_no_request_on_start`] | 关掉开关后启动不派发检查(结构性 + 状态两层) |
//! | [`the_gui_and_the_cli_share_one_kernel_path`] | GUI/CLI 走同一个 `Job`(源码锚点,不是口号) | //! | [`the_gui_and_the_cli_share_one_kernel_path`] | GUI/CLI 走同一个 `Job`(源码锚点,不是口号) |