Files
Unlucky-Launcher/crates/presenter/src/dto/update.rs
T
AskaEth 04ac90911e 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。
2026-11-07 13:03:18 +08:00

335 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 自更新 DTO(M159):一次检查的结论、候选、失败分类、就位状态。
//!
//! 口径四条:
//!
//! 1. **「检查失败」不是「已是最新」**——两者是 [`UpdateOutcome`] 的两个变体,各有自己的文案。
//! 把它们折叠成一个(例如失败时也发 `UpToDate`)会让用户以为「已经最新」,而网络其实一次都没通;
//! 2. **「有更高版本但没给你的平台发包」也不是「已是最新」**([`UpdateOutcome::MissingArtifact`]):
//! 那是发布侧的漏项,说成「最新」等于把发布的错报成用户的好消息;
//! 3. **版本比较只发生在 core**([`unlucky_core::update::UpdateChecker`]):本模块只搬运结论,
//! UI 与 CLI 都不许自己比版本号(mission:GUI 与 CLI 行为一致,不许两套逻辑);
//! 4. 本模块不含 core 类型(D42:dto 只放扁平 IPC 类型)。
//!
//! 「忽略这个版本」([`UpdateView::skipped`])记的是**版本号**,不是「别再问我了」:
//!
//! * 用户点「忽略这个版本」时写下**当时那个版本号**(会话级,不落盘);
//! * 同一次运行里如果又出现**更高**的版本(0.2.3 被忽略后冒出 0.2.4),提示条**照常弹**——
//! 忽略的是那一个版本,文案也确实是「忽略这个版本」;
//! * 设置页的「立即检查」会清掉它(手动检查 = 用户要看真结果),静默检查**永不**清。
use serde::{Deserialize, Serialize};
/// 检查更新的一次结论。
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum UpdateOutcome {
/// 还没查过(启动那一刻的状态)。
#[default]
Idle,
/// 正在查(网络在后台线程)。
Checking,
/// 已是最新。
UpToDate {
/// 该渠道准入的最高版本;清单里还没有任何发布时为 `None`(「还没发过版」不是错误)。
latest: Option<String>,
},
/// 有可装的更新。
Available(Box<UpdateCandidateView>),
/// 有更高的版本,但里面没有本平台的产物(发布侧漏项,不能对用户说「已是最新」)。
MissingArtifact {
/// 那个更高版本。
version: String,
},
/// 检查失败(连不上 / 清单坏了 / 渠道不符 …)。
Failed(UpdateFailureView),
}
impl UpdateOutcome {
/// 是不是正在查。
#[must_use]
pub const fn is_checking(&self) -> bool {
matches!(self, Self::Checking)
}
/// 可装的候选(没有则为 `None`)。
#[must_use]
pub fn candidate(&self) -> Option<&UpdateCandidateView> {
match self {
Self::Available(candidate) => Some(candidate),
_ => None,
}
}
/// 失败信息(不是失败则为 `None`)。
#[must_use]
pub const fn failure(&self) -> Option<&UpdateFailureView> {
match self {
Self::Failed(failure) => Some(failure),
_ => None,
}
}
/// 稳定 id(日志 / 测试 / 事件流用;**不要改名**)。
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Idle => "idle",
Self::Checking => "checking",
Self::UpToDate { .. } => "up_to_date",
Self::Available(_) => "available",
Self::MissingArtifact { .. } => "missing_artifact",
Self::Failed(_) => "failed",
}
}
}
/// 一个可安装的候选(提示条与设置页共用的四件)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpdateCandidateView {
/// 新版本号(清单里的原文,如 `0.2.3`)。
pub version: String,
/// 包体字节数(提示条上的「体积」)。
pub size: u64,
/// 更新说明摘要(清单里的 `notes`,已截断;空串 = 发布没写说明)。
pub notes: String,
/// 急不急(`true` = 发布标了强制,或当前版本低于发布侧的最低支持版本)。
pub forced: bool,
}
/// 检查失败的分类与人话。
///
/// `message` 直接来自 core 的错误(中文、含路径/URL 细节),UI 只在它前面加一句自己的话;
/// `kind` 是 core 的稳定分类 id(`network` / `invalid_manifest` / `digest_mismatch` …),
/// 给「要不要显示重试」这类判据用。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpdateFailureView {
/// 稳定分类 id(`UpdateErrorKind::as_str`)。
pub kind: String,
/// 人话原因。
pub message: String,
/// 换个时间/换个源再试是否可能变好。
pub retryable: bool,
}
impl UpdateFailureView {
/// 建一条。
pub fn new(kind: &str, message: impl Into<String>, retryable: bool) -> Self {
Self {
kind: kind.to_string(),
message: message.into(),
retryable,
}
}
}
/// 已经就位的新版本(下载 + 校验 + 替换都成功,等用户重启)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReadyUpdate {
/// 新版本号。
pub version: String,
/// 替换后的目标路径(人读展示;「下次启动生效」说的就是它)。
pub path: String,
/// 旧版本被挪到哪(`None` = 原本没有文件 / 已清理)。
pub backup: Option<String>,
}
/// 提示条此刻要显示什么。
///
/// 「检查失败」只有在**用户主动查**的时候才弹条([`UpdateView::silent`] = `false`):
/// 启动时的静默检查失败是常态(离线、公司网、镜像没同步),拿一条红条迎面上来等于每次开
/// 启动器先骂网络一次。失败原因仍然进设置页的「更新」区,那里随时能看。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpdateBanner<'a> {
/// 有新版本(可下载)。
Available(&'a UpdateCandidateView),
/// 正在下载/校验/替换。
Applying,
/// 已就位,等重启。
Ready(&'a ReadyUpdate),
/// 检查/应用失败(只在用户主动查之后出现)。
Failed(&'a UpdateFailureView),
}
/// 更新页 / 提示条的全部状态(一条快照整体替换)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpdateView {
/// 当前版本(`CARGO_PKG_VERSION`,由 host 填)。
pub current_version: String,
/// 渠道(D50:先只发 stable)。
pub channel: String,
/// 启动时是否静默检查(设置页开关)。
pub auto_check: bool,
/// 检查结论。
pub outcome: UpdateOutcome,
/// 上次检查时间(`MM-DD HH:MM`;空串 = 没查过)。
pub checked_at: String,
/// 这一份结论来自**静默**检查(启动时/自动)而不是用户点的「立即检查」。
pub silent: bool,
/// 正在下载/校验/替换(一条慢操作,期间禁用「立即安装」)。
pub applying: bool,
/// 已经就位的新版本(等重启)。
pub ready: Option<ReadyUpdate>,
/// 用户点了「忽略这个版本」时记下的**版本号**(`None` = 没忽略)。
///
/// 只压住**这一个**版本:0.2.3 被忽略后 0.2.4 仍然提示(文案是「忽略这个版本」,
/// 不是「别再问我」)。设置页手动检查会把它清空。
pub skipped: Option<String>,
}
impl Default for UpdateView {
/// 出厂状态:当前版本取本进程的版本号(**不是空串**——mock 后端/未接线的路径下,
/// 设置页的「当前版本」那一行至少要显示自己的版本,而不是一片空白)。
fn default() -> Self {
Self::new(env!("CARGO_PKG_VERSION"), "stable", true)
}
}
impl UpdateView {
/// 建一份初始状态。
#[must_use]
pub fn new(current: impl Into<String>, channel: &str, auto_check: bool) -> Self {
Self {
current_version: current.into(),
channel: channel.to_string(),
auto_check,
outcome: UpdateOutcome::Idle,
checked_at: String::new(),
silent: false,
applying: false,
ready: None,
skipped: None,
}
}
/// 提示条此刻要显示什么(`None` = 不显示)。
///
/// 优先级:就位 > 进行中 > 失败(仅主动查) > 有新版本(未被忽略)。
#[must_use]
pub fn banner(&self) -> Option<UpdateBanner<'_>> {
if let Some(ready) = &self.ready {
return Some(UpdateBanner::Ready(ready));
}
if self.applying {
return Some(UpdateBanner::Applying);
}
if let Some(failure) = self.outcome.failure() {
return (!self.silent).then_some(UpdateBanner::Failed(failure));
}
self.outcome
.candidate()
.filter(|candidate| self.skipped.as_deref() != Some(candidate.version.as_str()))
.map(UpdateBanner::Available)
}
/// 有没有「需要用户看一眼」的结论(提示条 / 就位后的重启提示 / 强制更新)。
#[must_use]
pub fn needs_attention(&self) -> bool {
self.banner().is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn candidate(version: &str) -> UpdateCandidateView {
UpdateCandidateView {
version: version.to_string(),
size: 21_000_000,
notes: "修掉启动崩溃".to_string(),
forced: false,
}
}
#[test]
fn failure_is_not_up_to_date() {
let mut view = UpdateView {
outcome: UpdateOutcome::Failed(UpdateFailureView::new(
"network",
"取更新清单失败:连接被重置",
true,
)),
..UpdateView::default()
};
assert!(view.outcome.failure().is_some());
assert!(view.outcome.candidate().is_none());
assert!(!matches!(view.outcome, UpdateOutcome::UpToDate { .. }));
assert_eq!(view.outcome.as_str(), "failed");
// 用户主动查出来的失败 → 提示条要说清原因(不是「已是最新」)。
assert!(matches!(view.banner(), Some(UpdateBanner::Failed(_))));
// 静默检查的失败 → 不弹条(离线是常态,别每次开机先骂网络一次)。
view.silent = true;
assert!(view.banner().is_none(), "静默检查失败不该变成提示条");
assert!(
view.outcome.failure().is_some(),
"但失败原因仍留在快照里(设置页要看)"
);
}
#[test]
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.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]
fn ready_state_replaces_the_banner() {
let view = UpdateView {
outcome: UpdateOutcome::Available(Box::new(candidate("0.2.3"))),
ready: Some(ReadyUpdate {
version: "0.2.3".to_string(),
path: "C:/ULL/unlucky-launcher.exe".to_string(),
backup: Some("C:/ULL/.unlucky-launcher.exe.ullold-1-0".to_string()),
}),
..UpdateView::default()
};
assert!(matches!(view.banner(), Some(UpdateBanner::Ready(_))));
assert!(view.needs_attention(), "就位之后要提示重启");
}
#[test]
fn up_to_date_and_idle_show_no_banner() {
let mut view = UpdateView::default();
assert!(view.banner().is_none(), "Idle 还没查过,不该弹条");
view.outcome = UpdateOutcome::UpToDate {
latest: Some("0.2.2".to_string()),
};
assert!(view.banner().is_none(), "已是最新没有任何要说的");
view.outcome = UpdateOutcome::MissingArtifact {
version: "0.2.3".to_string(),
};
assert!(view.banner().is_none(), "缺产物不进提示条,进设置页原因");
}
#[test]
fn view_round_trips_as_ipc() {
let mut view = UpdateView::new("0.2.2", "stable", false);
view.checked_at = "11-08 12:00".to_string();
view.outcome = UpdateOutcome::UpToDate {
latest: Some("0.2.2".to_string()),
};
let text = serde_json::to_string(&view).expect("序列化");
let back: UpdateView = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, view);
assert!(!back.auto_check);
}
}