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:
1 parent
1c8f6aa1b3
commit
04ac90911e
19 files changed
+521
-112
No files matched your search
@@ -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!(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)));
|
||||
|
||||
|
||||
@@ -10,10 +10,11 @@ use crate::error::CliError;
|
||||
mod accounts;
|
||||
mod agent;
|
||||
mod instances;
|
||||
mod update;
|
||||
|
||||
use super::model::{
|
||||
Command, ConfigGetArgs, ConfigSetArgs, DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs,
|
||||
LogsArgs, PackImportArgs, UpdateApplyArgs, UpdateCheckArgs,
|
||||
LogsArgs, PackImportArgs,
|
||||
};
|
||||
|
||||
/// `logs` 缺省尾巴行数。
|
||||
@@ -93,7 +94,7 @@ fn parse_command(rest: &[String], globals: &Globals) -> Result<Command, CliError
|
||||
"java" => parse_java(tail),
|
||||
"accounts" => accounts::parse_accounts(tail),
|
||||
"agent" => agent::parse_agent(tail),
|
||||
"update" => parse_update(tail),
|
||||
"update" => update::parse_update(tail),
|
||||
other => {
|
||||
Err(CliError::usage(format!("未知命令 `{other}`"))
|
||||
.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> {
|
||||
match tail.first().map(String::as_str) {
|
||||
None | Some("list") => {
|
||||
|
||||
@@ -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}`"
|
||||
))),
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
let hint = if error.is_retryable() {
|
||||
"网络/占用类失败,过一会儿重试一次通常就好"
|
||||
@@ -59,6 +70,10 @@ fn failure(code: &'static str, action: &str, error: &UpdateError) -> CliError {
|
||||
code,
|
||||
format!("{action}失败({}):{error}", error.kind.as_str()),
|
||||
)
|
||||
.with_detail(json!({
|
||||
"kind": error.kind.as_str(),
|
||||
"retryable": error.is_retryable(),
|
||||
}))
|
||||
.with_hint(hint)
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,17 @@ pub struct CliError {
|
||||
code: &'static str,
|
||||
message: 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)。
|
||||
@@ -73,6 +84,7 @@ impl CliError {
|
||||
code: "usage",
|
||||
message: render(message),
|
||||
hint: None,
|
||||
detail: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +96,7 @@ impl CliError {
|
||||
code,
|
||||
message: render(message),
|
||||
hint: None,
|
||||
detail: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +108,7 @@ impl CliError {
|
||||
code,
|
||||
message: render(message),
|
||||
hint: None,
|
||||
detail: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +125,22 @@ impl CliError {
|
||||
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]
|
||||
pub const fn kind(&self) -> ErrorKind {
|
||||
@@ -152,6 +182,9 @@ impl CliError {
|
||||
if let Some(hint) = &self.hint {
|
||||
error["hint"] = Value::String(hint.clone());
|
||||
}
|
||||
if let Some(detail) = &self.detail {
|
||||
error["detail"] = (**detail).clone();
|
||||
}
|
||||
json!({ "ok": false, "error": error })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +257,51 @@ fn java_subcommands() {
|
||||
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]
|
||||
fn usage_errors_are_user_errors() {
|
||||
let cases: &[&[&str]] = &[
|
||||
@@ -328,6 +373,7 @@ fn help_text_covers_every_command() {
|
||||
"diag",
|
||||
"config",
|
||||
"java",
|
||||
"update",
|
||||
] {
|
||||
assert!(
|
||||
unlucky_cli::cli::help::topic(topic).is_some(),
|
||||
|
||||
@@ -20,8 +20,14 @@
|
||||
//!
|
||||
//! # 本模块**不**负责的事
|
||||
//!
|
||||
//! * **不判断现在能不能替换**。正在跑的 exe 只能由「退出自身 → 由另一个进程搬运」这条
|
||||
//! 路换掉(HMCL 的 `--apply-to` 模式,见 analyse/07 §4.3)。这里只保证搬一半失败能回滚。
|
||||
//! * **不判断现在能不能替换**:占用/杀软/权限都在 [`ReplacePolicy`] 的重试里撞,撞不动就回滚。
|
||||
//! **正在运行的 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):那些该交给系统包管理器,
|
||||
//! [`crate::update::PackageFormat::needs_installer`] 是给上层的判据。
|
||||
//! * **不重启、不删备份**。备份何时删由上层决定(新版启动成功后再
|
||||
|
||||
@@ -38,15 +38,17 @@ use super::checker::{CheckOutcome, UpdateChecker};
|
||||
use super::error::UpdateError;
|
||||
use super::github::GitHubReleaseSource;
|
||||
use super::install::{install_staged, InstallReceipt, ReplacePolicy};
|
||||
use super::manifest::Artifact;
|
||||
use super::source::{DirSource, UpdateSource};
|
||||
use super::target::Target;
|
||||
use super::target::{PackageFormat, Target};
|
||||
use super::verify::stage;
|
||||
use super::version::Version;
|
||||
|
||||
/// 本机更新源覆盖(取证 / 内网手工分发):指向一个 `DirSource` 根目录(`<root>/stable.json`)。
|
||||
///
|
||||
/// 生产不设它——真实网络下 `releases/latest` 是唯一入口。它的用途是把「发现新版本」这条分支
|
||||
/// 在没有稳定 Release 的情况下也能整环验一遍(本地清单 + 本地产物,一个字节都不出网)。
|
||||
/// **只在调试构建里生效**(判据与理由见 [`local_dir_override`]):发布构建忽略它,
|
||||
/// 真实网络下 `releases/latest` 是唯一入口。它的用途是把「发现新版本」这条分支在没有稳定
|
||||
/// Release 的情况下也能整环验一遍(本地清单 + 本地产物,一个字节都不出网)。
|
||||
pub const UPDATE_DIR_ENV: &str = "ULL_UPDATE_DIR";
|
||||
|
||||
/// 一次应用的结局。
|
||||
@@ -165,6 +167,10 @@ impl Job {
|
||||
let Some(candidate) = outcome.candidate() else {
|
||||
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 version = candidate.release.version.to_string();
|
||||
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`] 是泛型的,这里省掉一次装箱)。
|
||||
struct SourceRef<'a>(&'a dyn UpdateSource);
|
||||
|
||||
@@ -210,11 +242,41 @@ pub fn staging_dir(cache_root: &Path) -> PathBuf {
|
||||
cache_root.join("update").join("staging")
|
||||
}
|
||||
|
||||
/// 生产源:GitHub Releases(D50);`ULL_UPDATE_DIR` 设了就走本地目录源。
|
||||
/// 生产源:GitHub Releases(D50);**只在调试构建里**,`ULL_UPDATE_DIR` 设了就走本地目录源。
|
||||
#[must_use]
|
||||
pub fn default_source(engine: Arc<DownloadEngine>) -> Box<dyn UpdateSource> {
|
||||
match std::env::var(UPDATE_DIR_ENV) {
|
||||
Ok(root) if !root.trim().is_empty() => Box::new(DirSource::new(root.trim())),
|
||||
_ => Box::new(GitHubReleaseSource::new(engine)),
|
||||
match local_dir_override() {
|
||||
Some(root) => Box::new(DirSource::new(root)),
|
||||
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))
|
||||
}
|
||||
@@ -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(发布构建里整段被折叠掉)"
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,8 @@
|
||||
//! * 渠道准入:阶梯比较 + 预发布阶段表;
|
||||
//! * 校验失败:摘要不符、包体截断(短了/多了)、失败后暂存文件不残留;
|
||||
//! * 替换与回滚:成功后备份仍在、搬一半失败回滚、目标目录不可写、Windows 占用;
|
||||
//! * 一条龙(`job`):只有便携单文件 exe 能原地自替换(msi/zip 在取包之前就被拒)、
|
||||
//! 「没有可装更新」不碰目标、`ULL_UPDATE_DIR` 只在调试构建里生效;
|
||||
//! * 更新源:缺清单不是错误、渠道不符报错、远端地址拒绝;
|
||||
//! * GitHub 源:固定清单地址、产物 URL 解析、非 stable 无更新、与 `DirSource` 对拍。
|
||||
//!
|
||||
@@ -16,6 +18,7 @@ mod channel_cases;
|
||||
mod checker_cases;
|
||||
mod github_cases;
|
||||
mod install_cases;
|
||||
mod job_cases;
|
||||
mod manifest_cases;
|
||||
mod source_cases;
|
||||
mod support;
|
||||
|
||||
@@ -389,6 +389,5 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
||||
("UpdateBannerInstall", "Download and install", "下载并安装"),
|
||||
("UpdateBannerDismiss", "Skip this version", "忽略这个版本"),
|
||||
("UpdateBannerRestartNow", "Restart now", "立即重启"),
|
||||
("UpdateBannerRestartLater", "Not now", "稍后"),
|
||||
("UpdateBannerRetry", "Try again", "重试"),
|
||||
];
|
||||
@@ -51,7 +51,6 @@ macro_rules! update_entries {
|
||||
UpdateBannerInstall => ("Download and install", "下载并安装"),
|
||||
UpdateBannerDismiss => ("Skip this version", "忽略这个版本"),
|
||||
UpdateBannerRestartNow => ("Restart now", "立即重启"),
|
||||
UpdateBannerRestartLater => ("Not now", "稍后"),
|
||||
UpdateBannerRetry => ("Try again", "重试"),
|
||||
} }
|
||||
};
|
||||
|
||||
@@ -10,8 +10,12 @@
|
||||
//! UI 与 CLI 都不许自己比版本号(mission:GUI 与 CLI 行为一致,不许两套逻辑);
|
||||
//! 4. 本模块不含 core 类型(D42:dto 只放扁平 IPC 类型)。
|
||||
//!
|
||||
//! 「拒绝更新」([`UpdateView::dismissed`])是**会话级**状态:用户点了「忽略这个版本」,本次运行
|
||||
//! 不再自动弹提示条;但设置页的「立即检查」不受影响(它会把这一位清掉重查)。
|
||||
//! 「忽略这个版本」([`UpdateView::skipped`])记的是**版本号**,不是「别再问我了」:
|
||||
//!
|
||||
//! * 用户点「忽略这个版本」时写下**当时那个版本号**(会话级,不落盘);
|
||||
//! * 同一次运行里如果又出现**更高**的版本(0.2.3 被忽略后冒出 0.2.4),提示条**照常弹**——
|
||||
//! 忽略的是那一个版本,文案也确实是「忽略这个版本」;
|
||||
//! * 设置页的「立即检查」会清掉它(手动检查 = 用户要看真结果),静默检查**永不**清。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -164,13 +168,18 @@ pub struct UpdateView {
|
||||
pub applying: bool,
|
||||
/// 已经就位的新版本(等重启)。
|
||||
pub ready: Option<ReadyUpdate>,
|
||||
/// 用户点了「忽略这个版本」:本次会话不再弹提示条(设置页手动检查会清掉它)。
|
||||
pub dismissed: bool,
|
||||
/// 用户点了「忽略这个版本」时记下的**版本号**(`None` = 没忽略)。
|
||||
///
|
||||
/// 只压住**这一个**版本:0.2.3 被忽略后 0.2.4 仍然提示(文案是「忽略这个版本」,
|
||||
/// 不是「别再问我」)。设置页手动检查会把它清空。
|
||||
pub skipped: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for UpdateView {
|
||||
/// 出厂状态:当前版本取本进程的版本号(**不是空串**——mock 后端/未接线的路径下,
|
||||
/// 设置页的「当前版本」那一行至少要显示自己的版本,而不是一片空白)。
|
||||
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,
|
||||
applying: false,
|
||||
ready: None,
|
||||
dismissed: false,
|
||||
skipped: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +216,7 @@ impl UpdateView {
|
||||
}
|
||||
self.outcome
|
||||
.candidate()
|
||||
.filter(|_| !self.dismissed)
|
||||
.filter(|candidate| self.skipped.as_deref() != Some(candidate.version.as_str()))
|
||||
.map(UpdateBanner::Available)
|
||||
}
|
||||
|
||||
@@ -258,20 +267,27 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dismissed_hides_the_banner_but_keeps_the_candidate() {
|
||||
fn skipping_one_version_does_not_silence_the_next_one() {
|
||||
let mut view = UpdateView {
|
||||
outcome: UpdateOutcome::Available(Box::new(candidate("0.2.3"))),
|
||||
..UpdateView::default()
|
||||
};
|
||||
assert!(matches!(view.banner(), Some(UpdateBanner::Available(_))));
|
||||
|
||||
view.dismissed = true;
|
||||
assert!(view.banner().is_none(), "拒绝之后本次不再打扰");
|
||||
view.skipped = Some("0.2.3".to_string());
|
||||
assert!(view.banner().is_none(), "忽略 0.2.3 之后它自己不再打扰");
|
||||
// 候选没被丢掉:设置页仍能显示「有新版本 0.2.3」并手动装
|
||||
assert_eq!(
|
||||
view.outcome.candidate().map(|item| item.version.as_str()),
|
||||
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]
|
||||
|
||||
@@ -6,60 +6,78 @@ use super::*;
|
||||
|
||||
// ------------------------------------------------------------------ 会话状态
|
||||
|
||||
/// 拒绝之后提示条消失,但候选留着;手动检查把「拒绝」清掉。
|
||||
/// 忽略**某一个版本**之后:它自己不弹了,候选留着;**更高版本照弹**;手动检查清掉忽略。
|
||||
///
|
||||
/// 这条是复审 P3-1 的守门:`skipped` 曾经是会话级 bool,于是「忽略 0.2.3」把同一次运行里
|
||||
/// 后来发布的 0.2.4 也一起压住了——而文案写的是「忽略这个版本」。
|
||||
#[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 mut runtime = Runtime::with_factory(&HostEnv::at_root(tmp.join("root")), factory(&tmp));
|
||||
runtime.adopt(Report::Checked(UpdateOutcome::Available(Box::new(
|
||||
crate::dto::UpdateCandidateView {
|
||||
version: "0.9.0".to_string(),
|
||||
size: 12,
|
||||
notes: String::new(),
|
||||
forced: false,
|
||||
},
|
||||
))));
|
||||
runtime.adopt(available("0.9.0"));
|
||||
assert!(matches!(
|
||||
runtime.view().banner(),
|
||||
Some(UpdateBanner::Available(_))
|
||||
));
|
||||
|
||||
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!(
|
||||
runtime.view().outcome.candidate().is_some(),
|
||||
"候选不能丢:设置页还要能手动装"
|
||||
);
|
||||
|
||||
// 静默检查**回来之后**也不许把拒绝弹回来(`adopt` 才是真正写状态的那一步)
|
||||
// 静默检查回来的是**同一个**版本:仍然压住(用户刚说过忽略它)。
|
||||
let _ = runtime.begin_check(false).expect("静默检查派发");
|
||||
runtime.adopt(Report::Checked(UpdateOutcome::Available(Box::new(
|
||||
crate::dto::UpdateCandidateView {
|
||||
version: "0.9.0".to_string(),
|
||||
size: 12,
|
||||
notes: String::new(),
|
||||
forced: false,
|
||||
},
|
||||
))));
|
||||
assert!(
|
||||
runtime.view().dismissed,
|
||||
"静默检查的结论回来时也不能清掉用户的拒绝"
|
||||
);
|
||||
runtime.adopt(available("0.9.0"));
|
||||
assert!(
|
||||
runtime.view().banner().is_none(),
|
||||
"被拒绝的版本不许因为一次静默检查又弹出来"
|
||||
"同一版本不许因为一次静默检查又弹出来"
|
||||
);
|
||||
|
||||
// 手动检查 ⇒ 清掉拒绝,重新查
|
||||
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();
|
||||
// 静默检查回来的是**更高**版本:照弹(忽略的是 0.9.0,不是「以后别再问」)。
|
||||
let _ = runtime.begin_check(false).expect("静默检查派发");
|
||||
assert!(runtime.view().dismissed, "静默检查不许把用户的拒绝弹回来");
|
||||
drop(job);
|
||||
runtime.adopt(available("0.9.1"));
|
||||
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、启动路径上根本不派发。
|
||||
|
||||
@@ -106,7 +106,8 @@ impl Runtime {
|
||||
self.view.silent = !manual;
|
||||
self.view.applying = false;
|
||||
if manual {
|
||||
self.view.dismissed = false;
|
||||
// 手动检查 = 用户要看真结果,把「忽略这个版本」清掉。
|
||||
self.view.skipped = None;
|
||||
}
|
||||
Some(self.factory.job())
|
||||
}
|
||||
@@ -124,16 +125,14 @@ impl Runtime {
|
||||
/// 后台回报进快照(**唯一**的写入口:检查与应用都走这里)。
|
||||
pub fn adopt(&mut self, report: Report) {
|
||||
self.inflight = false;
|
||||
self.view.checked_at = super::update::stamp();
|
||||
match report {
|
||||
Report::Checked(outcome) => {
|
||||
// 「上次检查」只该被**检查**刷新:应用回来的那一刻不是一次检查。
|
||||
self.view.checked_at = super::update::stamp();
|
||||
// 「忽略这个版本」记的是版本号,所以**这里不需要动它**:
|
||||
// 同一版本回来仍被压住,更高版本自然就提示了(见 `UpdateView::skipped`)。
|
||||
// 手动检查那一支的清空在 `begin_check(manual)` 里。
|
||||
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 {
|
||||
version,
|
||||
@@ -166,9 +165,13 @@ impl Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户拒绝这次更新:本次会话不再弹提示条(候选仍在快照里,设置页可手动装)。
|
||||
/// 用户忽略**当前这个候选版本**:记下版本号(候选仍留在快照里,设置页可手动装)。
|
||||
///
|
||||
/// 没有候选时是空操作(`skipped` 保持原样):没版本可忽略就不该写进一个「你别问」的状态。
|
||||
pub fn dismiss(&mut self) {
|
||||
self.view.dismissed = true;
|
||||
if let Some(candidate) = self.view.outcome.candidate() {
|
||||
self.view.skipped = Some(candidate.version.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! | [`a_higher_version_without_our_platform_is_not_up_to_date`] | 有新版但没本平台产物 → 单独的变体 |
|
||||
//! | [`sha256_mismatch_refuses_the_apply_and_leaves_no_temp_file`] | 摘要不符拒绝应用、暂存文件不残留、目标没被换掉 |
|
||||
//! | [`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`] | 关掉开关后启动不派发检查(结构性 + 状态两层) |
|
||||
//! | [`the_gui_and_the_cli_share_one_kernel_path`] | GUI/CLI 走同一个 `Job`(源码锚点,不是口号) |
|
||||
|
||||
|
||||
Reference in new issue
Block a user