§6 欠账:更新链路的 GUI 端从来没验过,而 M167 发现它根本没法在本机验——`ULL_UPDATE_DIR` 只在 `debug_assertions` 下生效,发布产物却是 release;`apply` 又会替换本进程 exe,于是没人敢跑。 判据换档(tower 定案 B):`cfg!(debug_assertions)` → 编译期 cargo feature `update-test-source` (core 定义,cli/presenter/app-iced 链式转发,**默认关**)。带 feature 的构建里还要两档环境变量 (`ULL_UPDATE_DIR` + `ULL_UPDATE_TEST_SOURCE=1`),只给路径什么都不会发生。官方发布二进制里那段 分支根本编不进来——README「release builds do not contain that path at all」这句因此继续为真。 - core: `job::default_source` 的本地源分支整段 `#[cfg(feature = "update-test-source")]`;判据抽成 纯函数 `test_source_root_from` 并逐条单测(半开一律关、空路径、承认值只认 `1`); `the_test_channel_is_a_compile_time_opt_in` 在**两种构建下各断各的**(带 feature ⇒ 换源; 不带 ⇒ 环境变量设满也走 GitHub)。 - cli: `update check/apply` 的 `--json` 增加 `test_source` 字段(判据派生自实际用了哪个源,不是回读 环境变量),人读输出多一行点名;`help.rs` 的 `ULL_UPDATE_DIR` 说明按新判据重写。 - 验收:`crates/cli/tests/update_e2e_test.rs` + `update_e2e_tamper_test.rs` + 共用夹具 `tests/support/update_e2e.rs`。临时数据根 + 本地源 + 真形状清单(与 `manifest.golden.json` 同 字段)+ 真 PE 产物(现场构建的 ull.exe 前 1 MiB,理由写在 `PAYLOAD_LIMIT`)。断言覆盖:发现 新版本(source=file:… / test_source=true / size / notes)、下载 → 双检 → 就位(dest 字节、旧字节 进备份、暂存无残留)、重复 apply 的幂等终态、篡改 sha256(清单撒谎 / 包体被换)与截断都必须 拒绝且不动现有安装、修复后能重装。**永远带 `--dest`**:不带会真的改名换掉测试二进制。 - 守门:`packaging/check_test_source_off.py`(stdlib-only)扫 `.github/workflows/**`、 `packaging/*.py|*.sh` 与各 `Cargo.toml` 的 `default`,任何一处打开 feature(或 `--all-features`) 就红;`--self-test` 注入 5 条真实回归证明每条都会红。CI 挂在 release-guard.yml 两步。 验证(本机):core update 单测 80 绿 / 带 feature 6 绿;E2E debug 6 绿; `cargo test --release --features update-test-source` 6 绿(tower 要求 release 档验收); `cargo test -p unlucky-cli -p unlucky-presenter` 全绿;守门 self-test + 正式扫描绿(真注入后红)。 已知没验:真 GitHub Release 下载、GUI 点击与重启替换(人工);跨卷替换(另开 finding)。
375 lines
12 KiB
Rust
375 lines
12 KiB
Rust
//! 自更新在宿主层的**装配与翻译**(M159)。
|
||
//!
|
||
//! # 分工
|
||
//!
|
||
//! ```text
|
||
//! core::update::Job ← 检查/应用的做法(版本比较、渠道准入、双检、原子替换全在 core)
|
||
//! ▲
|
||
//! │ 本模块只做四件事
|
||
//! Factory ← 用哪个源、暂存放哪、替换谁(生产:GitHub + 本进程 exe)
|
||
//! outcome_of ← core 的结论 → DTO(GUI 与 CLI 共用同一条判据)
|
||
//! Paths / Migration ← 两个开关的落点,以及旧 `cache/update/prefs.json` 的迁移(M161)
|
||
//! ```
|
||
//!
|
||
//! 「GUI 与 CLI 行为一致」由结构保证:两边都只构造 `core::update::Job` 并调用 `check` / `apply`,
|
||
//! 没有第二处版本比较、也没有第二个下载/校验流程(CLI 侧见 `crates/cli/src/commands/update.rs`)。
|
||
//!
|
||
//! # 静默检查的边界
|
||
//!
|
||
//! 启动时的检查能被关掉(`update.auto_check`):关掉之后 host 的装配路径**根本不派发**
|
||
//! (判据在 `CoreHost::refresh_all` 的那一行 `if self.update.should_check_on_start()`)。
|
||
//!
|
||
//! # 两个开关住在主配置里(M161)
|
||
//!
|
||
//! `update.auto_check` / `update.channel` 是 `crates/core/src/setting/keys.rs` 的 schema 键,
|
||
//! 落在 `<数据根>/config/launcher.json`——与「所有配置走 `core::setting`」的约定一致,
|
||
//! 于是 `ull config get update.auto_check` 读到的是**同一个值**。
|
||
//!
|
||
//! M159 曾把这两项临时放进更新域自己的文件 `<数据根>/cache/update/prefs.json`(当时
|
||
//! `crates/core/src/setting/**` 不在那条 mission 的 scope),代价是「GUI 里关掉、CLI 仍报 true」。
|
||
//! 存量值的迁移在 [`unlucky_core::setting::update_prefs`]:幂等(源指纹闸门)、不丢 `false`、
|
||
//! 新值胜出、旧文件改名为 `prefs.json.migrated` 后不再有任何读路径。`Runtime::with_factory`
|
||
//! 在建会话时跑一次并记住结局([`Runtime::migration`],`null` = 没有遗留文件)。
|
||
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::Arc;
|
||
|
||
use crate::dto::{UpdateCandidateView, UpdateFailureView, UpdateOutcome};
|
||
use unlucky_core::download::DownloadEngine;
|
||
use unlucky_core::launch::time::{format_rfc3339_millis, now};
|
||
use unlucky_core::setting::update_prefs;
|
||
use unlucky_core::update::{
|
||
default_source, staging_dir, Applied, CheckOutcome, DirSource, Job, Target, UpdateError,
|
||
UpdateSource, Version,
|
||
};
|
||
|
||
use super::engine;
|
||
use super::env::HostEnv;
|
||
use super::logs::display_time;
|
||
|
||
/// 本进程版本。整个 workspace 同版本(根 `Cargo.toml` 的 `[workspace.package]`)。
|
||
#[must_use]
|
||
pub fn current_version() -> &'static str {
|
||
env!("CARGO_PKG_VERSION")
|
||
}
|
||
|
||
/// 更新域的三个落点(都由数据根派生,core 不猜布局)。
|
||
#[derive(Debug, Clone)]
|
||
pub struct Paths {
|
||
/// `<数据根>/config/launcher.json`(两个开关的真相源)
|
||
pub launcher: PathBuf,
|
||
/// `<数据根>/cache/update/prefs.json`(M159 的旧文件;迁移后改名 `.migrated`)
|
||
pub legacy: PathBuf,
|
||
/// `<数据根>/config/update-prefs-migration.json`(迁移收据)
|
||
pub receipt: PathBuf,
|
||
}
|
||
|
||
impl Paths {
|
||
/// 按数据根装配。
|
||
#[must_use]
|
||
pub fn for_env(env: &HostEnv) -> Self {
|
||
Self {
|
||
launcher: env.launcher_config(),
|
||
legacy: env.layout.cache().join("update").join("prefs.json"),
|
||
receipt: env.layout.config().join(update_prefs::RECEIPT_FILE),
|
||
}
|
||
}
|
||
|
||
/// 迁移要的三个路径。
|
||
#[must_use]
|
||
pub fn targets(&self) -> update_prefs::Targets<'_> {
|
||
update_prefs::Targets {
|
||
launcher: &self.launcher,
|
||
legacy: &self.legacy,
|
||
receipt: &self.receipt,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 怎么造一个 [`Job`](源 / 暂存 / 替换目标)。
|
||
///
|
||
/// 每派发一次就造一个新的(`Job` 里装的源是 `Box<dyn UpdateSource>`,不共享、不复用):
|
||
/// 这样「上一次用的源」不会跟着下一次跑,测试也能各用例各给一份本地目录。
|
||
#[derive(Clone)]
|
||
pub enum Factory {
|
||
/// 生产/spec 形态:GitHub Releases(**带 `update-test-source` feature 的验收构建**里,两档
|
||
/// 环境变量给全时是本地目录源,见 `unlucky_core::update::job` 的 `test_source_root`),
|
||
/// 引擎懒建一次。
|
||
Default {
|
||
/// 下载引擎(镜像/地区/健康度策略都在里面)。
|
||
engine: Arc<DownloadEngine>,
|
||
/// 目标平台。
|
||
target: Target,
|
||
/// 暂存目录。
|
||
staging: PathBuf,
|
||
/// 替换目标。
|
||
dest: PathBuf,
|
||
},
|
||
/// 单测/取证:钉死一个本地目录源。
|
||
Dir {
|
||
/// 源根目录(`<root>/stable.json` + 产物)。
|
||
root: PathBuf,
|
||
/// 目标平台。
|
||
target: Target,
|
||
/// 暂存目录。
|
||
staging: PathBuf,
|
||
/// 替换目标。
|
||
dest: PathBuf,
|
||
},
|
||
}
|
||
|
||
impl std::fmt::Debug for Factory {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
f.debug_struct("Factory")
|
||
.field("dir", &self.dir())
|
||
.field("dest", &self.dest())
|
||
.finish()
|
||
}
|
||
}
|
||
|
||
impl Factory {
|
||
/// 生产装配:数据根的缓存做暂存,替换目标是**本进程的 exe**。
|
||
#[must_use]
|
||
pub fn for_env(env: &HostEnv) -> Self {
|
||
Self::Default {
|
||
engine: engine::engine(env),
|
||
target: Target::current(),
|
||
staging: staging_dir(&env.layout.cache()),
|
||
dest: unlucky_core::update::process_dest(),
|
||
}
|
||
}
|
||
|
||
/// 换替换目标(单测专用:测试**绝不**替换真的测试二进制)。
|
||
#[must_use]
|
||
pub fn with_dest(self, dest: impl Into<PathBuf>) -> Self {
|
||
let dest = dest.into();
|
||
match self {
|
||
Self::Default {
|
||
engine,
|
||
target,
|
||
staging,
|
||
..
|
||
} => Self::Default {
|
||
engine,
|
||
target,
|
||
staging,
|
||
dest,
|
||
},
|
||
Self::Dir {
|
||
root,
|
||
target,
|
||
staging,
|
||
..
|
||
} => Self::Dir {
|
||
root,
|
||
target,
|
||
staging,
|
||
dest,
|
||
},
|
||
}
|
||
}
|
||
|
||
/// 换暂存目录(单测:落在临时目录里)。
|
||
#[must_use]
|
||
pub fn with_staging(self, staging: impl Into<PathBuf>) -> Self {
|
||
let staging = staging.into();
|
||
match self {
|
||
Self::Default {
|
||
engine,
|
||
target,
|
||
dest,
|
||
..
|
||
} => Self::Default {
|
||
engine,
|
||
target,
|
||
staging,
|
||
dest,
|
||
},
|
||
Self::Dir {
|
||
root, target, dest, ..
|
||
} => Self::Dir {
|
||
root,
|
||
target,
|
||
staging,
|
||
dest,
|
||
},
|
||
}
|
||
}
|
||
|
||
/// 换成本地目录源(单测/取证)。
|
||
#[must_use]
|
||
pub fn with_dir(self, root: impl Into<PathBuf>) -> Self {
|
||
let root = root.into();
|
||
match self {
|
||
Self::Default {
|
||
target,
|
||
staging,
|
||
dest,
|
||
..
|
||
} => Self::Dir {
|
||
root,
|
||
target,
|
||
staging,
|
||
dest,
|
||
},
|
||
Self::Dir {
|
||
target,
|
||
staging,
|
||
dest,
|
||
..
|
||
} => Self::Dir {
|
||
root,
|
||
target,
|
||
staging,
|
||
dest,
|
||
},
|
||
}
|
||
}
|
||
|
||
/// 本地目录源根(`None` = 走 GitHub)。
|
||
#[must_use]
|
||
pub fn dir(&self) -> Option<&Path> {
|
||
match self {
|
||
Self::Default { .. } => None,
|
||
Self::Dir { root, .. } => Some(root),
|
||
}
|
||
}
|
||
|
||
/// 替换目标。
|
||
#[must_use]
|
||
pub fn dest(&self) -> &Path {
|
||
match self {
|
||
Self::Default { dest, .. } | Self::Dir { dest, .. } => dest,
|
||
}
|
||
}
|
||
|
||
/// 造一个 [`Job`]。
|
||
#[must_use]
|
||
pub fn job(&self) -> Job {
|
||
match self {
|
||
Self::Default {
|
||
engine,
|
||
target,
|
||
staging,
|
||
dest,
|
||
} => Job::new(
|
||
default_source(Arc::clone(engine)),
|
||
*target,
|
||
staging.clone(),
|
||
dest.clone(),
|
||
),
|
||
Self::Dir {
|
||
root,
|
||
target,
|
||
staging,
|
||
dest,
|
||
} => Job::new(
|
||
Box::new(DirSource::new(root.clone())) as Box<dyn UpdateSource>,
|
||
*target,
|
||
staging.clone(),
|
||
dest.clone(),
|
||
),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 后台线程 → host 的**更新**回报(只含 DTO:`job.rs` 不必认识 core 类型)。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum Report {
|
||
/// 检查结论
|
||
Checked(UpdateOutcome),
|
||
/// 应用结局
|
||
Applied(ApplyReport),
|
||
}
|
||
|
||
/// 应用结局。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum ApplyReport {
|
||
/// 成功:新版本已就位
|
||
Installed {
|
||
/// 新版本号
|
||
version: String,
|
||
/// 替换后的路径
|
||
path: String,
|
||
/// 旧版本备份路径(`None` = 原本没有文件 / 已清掉)
|
||
backup: Option<String>,
|
||
},
|
||
/// 复查时已经没有可装的更新
|
||
Nothing(UpdateOutcome),
|
||
/// 失败(分类 + 人话)
|
||
Failed(UpdateFailureView),
|
||
}
|
||
|
||
impl Report {
|
||
/// 应用结局 → 回报(映射只在这一处)。
|
||
#[must_use]
|
||
pub fn applied(result: Result<Applied, UpdateError>) -> Self {
|
||
match result {
|
||
Ok(Applied::Installed { receipt, version }) => Self::Applied(ApplyReport::Installed {
|
||
version,
|
||
path: receipt.dest.display().to_string(),
|
||
backup: receipt.backup.map(|backup| backup.display().to_string()),
|
||
}),
|
||
Ok(Applied::Nothing(outcome)) => {
|
||
Self::Applied(ApplyReport::Nothing(outcome_of(Ok(outcome))))
|
||
}
|
||
Err(error) => Self::Applied(ApplyReport::Failed(failure_of(&error))),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// core 的检查结论 → DTO(**唯一**的映射点)。
|
||
///
|
||
/// 三条不许折叠的边界在这里体现:`UpToDate` 与 `Failed` 是两个变体;
|
||
/// 「有更高版本但没有本平台产物」单独一个变体([`UpdateOutcome::MissingArtifact`])。
|
||
#[must_use]
|
||
pub fn outcome_of(result: Result<CheckOutcome, UpdateError>) -> UpdateOutcome {
|
||
match result {
|
||
Ok(CheckOutcome::UpToDate { latest, .. }) => UpdateOutcome::UpToDate {
|
||
latest: latest.map(|version| version.to_string()),
|
||
},
|
||
Ok(CheckOutcome::Available(candidate)) => {
|
||
UpdateOutcome::Available(Box::new(UpdateCandidateView {
|
||
version: candidate.release.version.to_string(),
|
||
size: candidate.artifact.size,
|
||
notes: notes_summary(candidate.release.notes.as_deref()),
|
||
forced: candidate.urgency == unlucky_core::update::Urgency::Forced,
|
||
}))
|
||
}
|
||
Ok(CheckOutcome::MissingArtifact { release }) => UpdateOutcome::MissingArtifact {
|
||
version: release.version.to_string(),
|
||
},
|
||
Err(error) => UpdateOutcome::Failed(failure_of(&error)),
|
||
}
|
||
}
|
||
|
||
/// core 的错误 → 失败视图(分类 id + 人话 + 可重试)。
|
||
#[must_use]
|
||
pub fn failure_of(error: &UpdateError) -> UpdateFailureView {
|
||
UpdateFailureView::new(error.kind.as_str(), error.to_string(), error.is_retryable())
|
||
}
|
||
|
||
/// 更新说明摘要的字符上限(提示条上只放一段;整篇说明在 Release 页)。
|
||
const NOTES_LIMIT: usize = 160;
|
||
|
||
fn notes_summary(notes: Option<&str>) -> String {
|
||
let text = notes.unwrap_or("").trim();
|
||
if text.chars().count() <= NOTES_LIMIT {
|
||
return text.to_string();
|
||
}
|
||
let mut short: String = text.chars().take(NOTES_LIMIT).collect();
|
||
short.push('…');
|
||
short
|
||
}
|
||
|
||
/// 当前时刻的人读戳(`MM-DD HH:MM`,与日志页同一个格式函数)。
|
||
pub(super) fn stamp() -> String {
|
||
display_time(&format_rfc3339_millis(now()))
|
||
}
|
||
|
||
/// 把版本号解析成 core 的 [`Version`](`None` = 编译进来的版本号不合法,理论上不可能)。
|
||
#[must_use]
|
||
pub(super) fn parse_version(text: &str) -> Option<Version> {
|
||
Version::parse(text).ok()
|
||
}
|