diff --git a/crates/app-iced/src/app/view/update.rs b/crates/app-iced/src/app/view/update.rs index 4715203..ce179dd 100644 --- a/crates/app-iced/src/app/view/update.rs +++ b/crates/app-iced/src/app/view/update.rs @@ -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()); } diff --git a/crates/app-iced/src/pages/settings/tests/update_section.rs b/crates/app-iced/src/pages/settings/tests/update_section.rs index f97423c..33859c2 100644 --- a/crates/app-iced/src/pages/settings/tests/update_section.rs +++ b/crates/app-iced/src/pages/settings/tests/update_section.rs @@ -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!( diff --git a/crates/app-iced/src/pages/settings/update.rs b/crates/app-iced/src/pages/settings/update.rs index afc42d7..e01c11c 100644 --- a/crates/app-iced/src/pages/settings/update.rs +++ b/crates/app-iced/src/pages/settings/update.rs @@ -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( diff --git a/crates/app-iced/src/pages/update.rs b/crates/app-iced/src/pages/update.rs index 0d5cdfe..89d9aa8 100644 --- a/crates/app-iced/src/pages/update.rs +++ b/crates/app-iced/src/pages/update.rs @@ -93,8 +93,11 @@ pub struct Update { pub applying: bool, /// 已就位的新版本号(`None` = 没有) pub ready: Option, - /// 用户是否已忽略这个版本(本次会话) - pub dismissed: bool, + /// 用户点了「忽略这个版本」时记下的**版本号**(`None` = 没忽略)。 + /// + /// 只压住**这一个**版本:0.2.3 被忽略后同一次运行里冒出 0.2.4 仍然提示 + /// (文案是「忽略这个版本」,不是「别再问我」)。 + pub skipped: Option, /// 这一份结论来自静默检查(启动时/自动):失败时**不**弹提示条 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))); diff --git a/crates/cli/src/cli/parse.rs b/crates/cli/src/cli/parse.rs index 0f0247f..f210e28 100644 --- a/crates/cli/src/cli/parse.rs +++ b/crates/cli/src/cli/parse.rs @@ -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 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 { } } -/// `update check` / `update apply [--dest ]`。 -fn parse_update(tail: &[String]) -> Result { - 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 { match tail.first().map(String::as_str) { None | Some("list") => { diff --git a/crates/cli/src/cli/parse/update.rs b/crates/cli/src/cli/parse/update.rs new file mode 100644 index 0000000..3542f6c --- /dev/null +++ b/crates/cli/src/cli/parse/update.rs @@ -0,0 +1,46 @@ +//! `ull update` 子命令的解析(从 `parse.rs` 拆出,避免解析器主文件顶到 400 行)。 +//! +//! 语法: +//! - `ull update check` +//! - `ull update apply [--dest ]` +//! +//! 两条命令都只是「造一个 `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 { + 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}`" + ))), + } +} diff --git a/crates/cli/src/commands/update.rs b/crates/cli/src/commands/update.rs index 673c1cf..c7b99e8 100644 --- a/crates/cli/src/commands/update.rs +++ b/crates/cli/src/commands/update.rs @@ -48,7 +48,18 @@ fn job(ctx: &Ctx, dest: Option) -> 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) } diff --git a/crates/cli/src/error.rs b/crates/cli/src/error.rs index 9416e32..cf96719 100644 --- a/crates/cli/src/error.rs +++ b/crates/cli/src/error.rs @@ -54,6 +54,17 @@ pub struct CliError { code: &'static str, message: String, hint: Option, + /// `--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>` 只占 8 字节,错误对象保持轻量。 + detail: Option>, } /// 把模板里的程序名占位符渲染掉(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 }) } } diff --git a/crates/cli/tests/parse_test.rs b/crates/cli/tests/parse_test.rs index 62a17cb..aa2e602 100644 --- a/crates/cli/tests/parse_test.rs +++ b/crates/cli/tests/parse_test.rs @@ -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 = 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(), diff --git a/crates/core/src/update/install.rs b/crates/core/src/update/install.rs index 0a68d39..9dbc0fd 100644 --- a/crates/core/src/update/install.rs +++ b/crates/core/src/update/install.rs @@ -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`] 是给上层的判据。 //! * **不重启、不删备份**。备份何时删由上层决定(新版启动成功后再 diff --git a/crates/core/src/update/job.rs b/crates/core/src/update/job.rs index 94656eb..bd1929b 100644 --- a/crates/core/src/update/job.rs +++ b/crates/core/src/update/job.rs @@ -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` 根目录(`/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) -> Box { - 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 { + 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)) +} diff --git a/crates/core/src/update/tests/job_cases.rs b/crates/core/src/update/tests/job_cases.rs new file mode 100644 index 0000000..8be47f7 --- /dev/null +++ b/crates/core/src/update/tests/job_cases.rs @@ -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, + 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(发布构建里整段被折叠掉)" + ); +} diff --git a/crates/core/src/update/tests/mod.rs b/crates/core/src/update/tests/mod.rs index 275079c..8a7fcca 100644 --- a/crates/core/src/update/tests/mod.rs +++ b/crates/core/src/update/tests/mod.rs @@ -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; diff --git a/crates/i18n/src/catalog/golden/tail.rs b/crates/i18n/src/catalog/golden/tail.rs index c155643..e5e38de 100644 --- a/crates/i18n/src/catalog/golden/tail.rs +++ b/crates/i18n/src/catalog/golden/tail.rs @@ -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", "重试"), ]; diff --git a/crates/i18n/src/catalog/update.rs b/crates/i18n/src/catalog/update.rs index 9aadb7e..d513d15 100644 --- a/crates/i18n/src/catalog/update.rs +++ b/crates/i18n/src/catalog/update.rs @@ -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", "重试"), } } }; diff --git a/crates/presenter/src/dto/update.rs b/crates/presenter/src/dto/update.rs index 2f94004..36caf77 100644 --- a/crates/presenter/src/dto/update.rs +++ b/crates/presenter/src/dto/update.rs @@ -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, - /// 用户点了「忽略这个版本」:本次会话不再弹提示条(设置页手动检查会清掉它)。 - pub dismissed: bool, + /// 用户点了「忽略这个版本」时记下的**版本号**(`None` = 没忽略)。 + /// + /// 只压住**这一个**版本:0.2.3 被忽略后 0.2.4 仍然提示(文案是「忽略这个版本」, + /// 不是「别再问我」)。设置页手动检查会把它清空。 + pub skipped: Option, } 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] diff --git a/crates/presenter/src/host/update_session_tests.rs b/crates/presenter/src/host/update_session_tests.rs index 79fc0cd..b571ca5 100644 --- a/crates/presenter/src/host/update_session_tests.rs +++ b/crates/presenter/src/host/update_session_tests.rs @@ -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、启动路径上根本不派发。 diff --git a/crates/presenter/src/host/update_state.rs b/crates/presenter/src/host/update_state.rs index 04b0a0c..d1a1da7 100644 --- a/crates/presenter/src/host/update_state.rs +++ b/crates/presenter/src/host/update_state.rs @@ -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()); + } } } diff --git a/crates/presenter/src/host/update_tests.rs b/crates/presenter/src/host/update_tests.rs index b8a5ab5..3275a3f 100644 --- a/crates/presenter/src/host/update_tests.rs +++ b/crates/presenter/src/host/update_tests.rs @@ -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`(源码锚点,不是口号) |