feat(m159): 自更新 GUI 客户端接线 + CLI update check/apply 同一内核
D50 的「检查 → 提示 → 可拒绝 → 下载校验 → 下一次启动生效」整环接线,GUI 与
CLI 共用 core 的一条内核(`update::Job`),并顺带修掉 M157 最后一轮复审的三处 P3。
内核(core::update)
- 新增 job.rs:`Job { source, target, staging, dest }` + `check` / `apply`(复查 →
取包 → 长度/SHA256 双检 → 原子替换),`default_source`(GitHub,`ULL_UPDATE_DIR`
可切本地目录夹具)、`process_dest`、`staging_dir`。
- `mod.rs` 导出 job 一族;`source.rs` 为 `Box<dyn UpdateSource>` 补 trait 转发
(壳层要运行期选源,`UpdateChecker` 是泛型的)。
presenter(宿主通道)
- `dto/update.rs`:`UpdateView` / `UpdateOutcome`(Idle/Checking/UpToDate/Available/
MissingArtifact/Failed 六态,**失败与最新不折叠**)/ `UpdateBanner` 优先级判据。
- `host/update.rs`:Factory(生产 GitHub / 单测本地目录)+ core 结论 → DTO 的唯一映射
+ 更新域偏好(`cache/update/prefs.json`)。
- `host/update_state.rs`:Runtime(会话状态机:begin_check/adopt/begin_apply/dismiss/
set_auto_check)+ `CoreHost` 派发与后台线程。
- `AppCommand::{CheckUpdate, ApplyUpdate, DismissUpdate, SetUpdateAutoCheck}`、
`CoreEvent::Update`、`AppState.update`;`refresh_all` 按开关派发静默检查。
app-iced
- 设置页新增第 5 组「更新」(当前版本 / 渠道 / 自动检查开关 / 立即检查 / 上次检查与
结果 / 下载并安装 / 就位后立即重启),走 `settings::view` 的 `update` 形参 + 按下标
分发(与 M106 语言行同一取舍)。
- 顶部提示条(每页可见):有新版本(版本号 + 体积 + 说明摘要,可安装/可忽略)、下载中、
已就位(立即重启);静默检查失败**不**弹条(原因进设置页)。
- `pages::Message::Update(UpdateAction)` → `app/update/update_action.rs`;「立即重启」
拉同参数的新进程后退出(截图通道不重启)。
CLI
- `ull update check` / `ull update apply [--dest <path>]`:复用同一 `Job`;四种结论分开
报(`--json` 的 `status`),失败退出码 1(`update_check_failed` / `update_apply_failed`)。
i18n
- 新增 `catalog/update.rs`(30 条,链尾追加)+ 金表对应 30 行。
M157 复审 P3 三件
- `app/update/instances.rs` 模块文档改成「清脏的唯一时机是回程快照等于表单」。
- `pages/instances/demo.rs` 的 `demo_form` 注释改成现状(两个参数框早已是单行输入)。
- `pages/instances/launch_args.rs`:门限不满足时只灰 `opengl`/`vulkan`,`default` 仍可点
(它是「不下发」的安全档);判据抽成 `backend_chip_enabled` 并补 3 条用例。浅色 + 灰化
两张图:`C:/Temp/m159-shots/instances-launch-args-{light,gated}.png`。
顺带:harness 支持 `ULL_SETTINGS_GROUP=<下标>`(拍第 5 组的验收图需要它)。
验证:cargo fmt --check 绿;cargo clippy --workspace --all-targets -D warnings 绿;
cargo test --workspace --all-targets 51 个测试二进制全绿(唯一例外:core::addon::pack
一族要造 4 GiB 稀疏文件,本机磁盘 99% 满 ⇒ StorageFull,与本次改动无关);CLI 用本地
目录夹具跑通「发现 9.9.9 → 下载 → 校验 → 替换运行中的 exe → 备份留旧文件」,并验证
摘要不符时拒绝应用、目标文件零改动、暂存目录零残留。
This commit is contained in:
1 parent
a80362915a
commit
1c8f6aa1b3
44 files changed
+3341
-22
No files matched your search
@@ -245,6 +245,21 @@ impl CoreHost {
|
||||
let epoch = self.next_install_epoch();
|
||||
addon::spawn_install(self.env.clone(), self.tx.clone(), *request, cancel, epoch);
|
||||
}
|
||||
// M159: 自更新 —— 检查 / 下载替换 / 拒绝 / 开关
|
||||
AppCommand::CheckUpdate => self.start_update_check(true),
|
||||
AppCommand::ApplyUpdate => self.start_update_apply(),
|
||||
AppCommand::DismissUpdate => {
|
||||
self.update.dismiss();
|
||||
self.publish_update();
|
||||
}
|
||||
AppCommand::SetUpdateAutoCheck(on) => match self.update.set_auto_check(on) {
|
||||
Ok(()) => self.publish_update(),
|
||||
Err(reason) => {
|
||||
// 写不进去也要把这次的选择留在会话里(不把开关弹回去),并说清原因。
|
||||
self.publish_update();
|
||||
self.toast(ToastKind::Warn, reason);
|
||||
}
|
||||
},
|
||||
// AI 对话:agent 的 GUI host 未落地前用脚本回复(不接真实模型)
|
||||
other @ (AppCommand::AskAgent(_) | AppCommand::StopAgent | AppCommand::SetYolo(_)) => {
|
||||
self.chat.dispatch(other);
|
||||
|
||||
@@ -184,6 +184,11 @@ impl CoreHost {
|
||||
self.send(CoreEvent::DiagnosisReady(*report));
|
||||
}
|
||||
HostTask::Notice(notice) => self.push_notice(*notice),
|
||||
// M159:更新回报(检查结论 / 应用结局)→ 快照整体替换
|
||||
HostTask::Update(report) => {
|
||||
self.update.adopt(*report);
|
||||
self.publish_update();
|
||||
}
|
||||
HostTask::Toast(toast) => self.events.push_back(AppEvent::Toast(*toast)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,8 @@ pub enum HostTask {
|
||||
Toast(Box<Toast>),
|
||||
/// 会话列表刷新(只换左栏)
|
||||
Sessions(Vec<LogSession>),
|
||||
/// 自更新(M159):检查/应用的回报(载荷已翻成 DTO,见 `host::update::Report`)
|
||||
Update(Box<crate::host::update::Report>),
|
||||
}
|
||||
|
||||
/// 后台线程用的发送端(`Sender` 克隆即可)。
|
||||
|
||||
@@ -32,6 +32,8 @@ mod logs;
|
||||
mod meter;
|
||||
mod opener;
|
||||
mod settings;
|
||||
mod update;
|
||||
mod update_state;
|
||||
mod versions;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
@@ -103,6 +105,8 @@ pub struct CoreHost {
|
||||
/// 一眼能看出「这是旧任务的」,于是既不会被取消掉的安装重新点亮面板,也不会串到下一轮安装
|
||||
/// (两轮安装同时存在时,旧任务的事件曾经把新任务的进度条顶掉)。
|
||||
install_epoch: u64,
|
||||
/// 自更新(M159):检查结论 / 应用状态 / 更新域偏好。
|
||||
update: update_state::Runtime,
|
||||
}
|
||||
|
||||
impl CoreHost {
|
||||
@@ -125,6 +129,9 @@ impl CoreHost {
|
||||
fn build(env: HostEnv, refresh: bool) -> Self {
|
||||
let (tx, rx) = job::channel();
|
||||
game::ensure_root(&env);
|
||||
// M159:更新状态要在构造时就有一份(提示条/设置页都读它),静默检查在 `refresh_all`
|
||||
// 里按开关派发——`headless` 走 `refresh=false`,于是单测不会发任何网络请求。
|
||||
let update = update_state::Runtime::for_env(&env);
|
||||
let mut host = Self {
|
||||
env,
|
||||
events: VecDeque::new(),
|
||||
@@ -149,6 +156,7 @@ impl CoreHost {
|
||||
chat: MockBackend::default(),
|
||||
install_cancel: None,
|
||||
install_epoch: 0,
|
||||
update,
|
||||
};
|
||||
if refresh {
|
||||
host.refresh_all();
|
||||
@@ -184,7 +192,7 @@ impl CoreHost {
|
||||
}));
|
||||
}
|
||||
|
||||
/// 全量刷新:实例 / 会话 / 账户 / 设置 / Java,并触发一次版本清单拉取。
|
||||
/// 全量刷新:实例 / 会话 / 账户 / 设置 / Java / 更新,并触发一次版本清单拉取。
|
||||
pub fn refresh_all(&mut self) {
|
||||
self.refresh_instances();
|
||||
self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env)));
|
||||
@@ -192,6 +200,12 @@ impl CoreHost {
|
||||
self.send(CoreEvent::SettingsChanged(settings::groups(&self.env)));
|
||||
self.refresh_java();
|
||||
versions::spawn_list(self.env.clone(), self.tx.clone());
|
||||
// M159:更新快照先发一份(设置页首帧就有「当前版本 / 上次结果」),
|
||||
// 再按开关决定要不要静默查一次。关掉开关 ⇒ 这里一行网络都不发。
|
||||
self.publish_update();
|
||||
if self.update.should_check_on_start() {
|
||||
self.start_update_check(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 重建实例快照,并把**真实运行时**盖回去。
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
//! 自更新在宿主层的**装配与翻译**(M159)。
|
||||
//!
|
||||
//! # 分工
|
||||
//!
|
||||
//! ```text
|
||||
//! core::update::Job ← 检查/应用的做法(版本比较、渠道准入、双检、原子替换全在 core)
|
||||
//! ▲
|
||||
//! │ 本模块只做三件事
|
||||
//! Factory ← 用哪个源、暂存放哪、替换谁(生产:GitHub + 本进程 exe)
|
||||
//! outcome_of ← core 的结论 → DTO(GUI 与 CLI 共用同一条判据)
|
||||
//! Prefs ← 更新域自己的开关(启动时静默检查)
|
||||
//! ```
|
||||
//!
|
||||
//! 「GUI 与 CLI 行为一致」由结构保证:两边都只构造 `core::update::Job` 并调用 `check` / `apply`,
|
||||
//! 没有第二处版本比较、也没有第二个下载/校验流程(CLI 侧见 `crates/cli/src/commands/update.rs`)。
|
||||
//!
|
||||
//! # 静默检查的边界
|
||||
//!
|
||||
//! 启动时的检查能被关掉([`Prefs::auto_check`]):关掉之后 host 的装配路径**根本不派发**
|
||||
//! (判据在 `CoreHost::refresh_all` 的那一行 `if self.update.should_check_on_start()`)。
|
||||
//!
|
||||
//! # 偏好文件为什么不在 `launcher.json` 里
|
||||
//!
|
||||
//! `launcher.json` 的每个键都归 `core::setting::keys` 的 schema 管,而本 mission 的 scope 不含
|
||||
//! `crates/core/src/setting/**`。所以这一条先落在更新域自己的文件
|
||||
//! `<数据根>/cache/update/prefs.json`(与 `cache/region.json` 同族:域内状态,坏了就回默认)。
|
||||
//! **加 `update.auto_check` schema 键之后应当迁过去**(已作为 finding 上报)。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use unlucky_core::download::DownloadEngine;
|
||||
use unlucky_core::launch::time::{format_rfc3339_millis, now};
|
||||
use unlucky_core::update::{
|
||||
default_source, staging_dir, Applied, Channel, CheckOutcome, DirSource, Job, Target,
|
||||
UpdateError, UpdateSource, Version,
|
||||
};
|
||||
|
||||
use crate::dto::{UpdateCandidateView, UpdateFailureView, UpdateOutcome};
|
||||
|
||||
use super::engine;
|
||||
use super::env::HostEnv;
|
||||
use super::logs::display_time;
|
||||
|
||||
/// 渠道(D50:先只发 stable)。
|
||||
pub const CHANNEL: Channel = Channel::Stable;
|
||||
|
||||
/// 本进程版本。整个 workspace 同版本(根 `Cargo.toml` 的 `[workspace.package]`)。
|
||||
#[must_use]
|
||||
pub fn current_version() -> &'static str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
|
||||
/// 更新域偏好(现在只有一项)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Prefs {
|
||||
/// 启动时静默检查。缺省开:检查本身不打扰用户,发现新版本才有提示条。
|
||||
#[serde(default = "default_auto_check")]
|
||||
pub auto_check: bool,
|
||||
}
|
||||
|
||||
fn default_auto_check() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
fn default() -> Self {
|
||||
Self { auto_check: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl Prefs {
|
||||
/// 读偏好:文件不存在 / 坏 JSON 一律回默认(偏好坏了不该让启动器起不来)。
|
||||
#[must_use]
|
||||
pub fn load(path: &Path) -> Self {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|text| serde_json::from_str(&text).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 写偏好。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 目录建不出来或写盘失败时返回人读文案。
|
||||
pub fn save(&self, path: &Path) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("建更新偏好目录失败:{error}"))?;
|
||||
}
|
||||
let text = serde_json::to_string_pretty(self)
|
||||
.map_err(|error| format!("序列化更新偏好失败:{error}"))?;
|
||||
std::fs::write(path, text).map_err(|error| format!("写更新偏好失败:{error}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 怎么造一个 [`Job`](源 / 暂存 / 替换目标)。
|
||||
///
|
||||
/// 每派发一次就造一个新的(`Job` 里装的源是 `Box<dyn UpdateSource>`,不共享、不复用):
|
||||
/// 这样「上一次用的源」不会跟着下一次跑,测试也能各用例各给一份本地目录。
|
||||
#[derive(Clone)]
|
||||
pub enum Factory {
|
||||
/// 生产/spec 形态:GitHub(或 `ULL_UPDATE_DIR` 指定的本地目录),引擎懒建一次。
|
||||
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: Arc::new(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()
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! 应用(下载 → 双检 → 替换)的用例:校验失败不许碰目标、成功要留备份。
|
||||
//!
|
||||
//! 夹具(`TempDir` / `factory` / `job` / `manifest_for`)在父模块,本文件 `use super::*`。
|
||||
|
||||
use super::*;
|
||||
|
||||
// ------------------------------------------------------------------ 应用
|
||||
|
||||
/// 摘要不符 ⇒ 拒绝应用 + 暂存文件不残留 + 目标文件没被换掉。
|
||||
#[test]
|
||||
fn sha256_mismatch_refuses_the_apply_and_leaves_no_temp_file() {
|
||||
let tmp = TempDir::new("digest");
|
||||
let good = b"new-binary-v2";
|
||||
let evil = b"evil-binary-x"; // 同样长度:先过长度判据,才能测到摘要这一关
|
||||
assert_eq!(good.len(), evil.len());
|
||||
// 清单声明的是 good 的摘要,盘上给的却是 evil。
|
||||
dir_source(&tmp, &manifest_for("0.9.0", good, ""), evil);
|
||||
|
||||
let dest = tmp.join("bin/unlucky-launcher.exe");
|
||||
std::fs::create_dir_all(dest.parent().expect("父目录")).expect("bin");
|
||||
std::fs::write(&dest, b"old-binary-v1").expect("旧版本");
|
||||
|
||||
let report = Report::applied(job(&tmp).apply(&version("0.2.2"), CHANNEL));
|
||||
match report {
|
||||
Report::Applied(ApplyReport::Failed(failure)) => {
|
||||
assert_eq!(failure.kind, "digest_mismatch", "失败分类要能区分摘要不符");
|
||||
assert!(failure.retryable, "摘要不符是可重试的(换源/重下可能变好)");
|
||||
}
|
||||
other => panic!("摘要不符必须拒绝应用,实际 {other:?}"),
|
||||
}
|
||||
|
||||
// 目标文件一个字节都没动
|
||||
assert_eq!(
|
||||
std::fs::read(&dest).expect("读旧版本"),
|
||||
b"old-binary-v1",
|
||||
"校验失败不许碰目标文件"
|
||||
);
|
||||
// 暂存目录里不许留 .ullupd 中间文件
|
||||
let staging = tmp.join("staging");
|
||||
let leftovers: Vec<String> = std::fs::read_dir(&staging)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.file_name().to_string_lossy().into_owned())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
leftovers.is_empty(),
|
||||
"校验失败后暂存目录里还有文件:{leftovers:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 正常应用:新字节就位、旧字节进备份、回报是「已就位」。
|
||||
#[test]
|
||||
fn a_successful_apply_leaves_the_new_file_and_a_backup() {
|
||||
let tmp = TempDir::new("apply");
|
||||
let payload = b"new-binary-v2";
|
||||
dir_source(
|
||||
&tmp,
|
||||
&manifest_for("0.9.0", payload, "修掉启动崩溃"),
|
||||
payload,
|
||||
);
|
||||
|
||||
let dest = tmp.join("bin/unlucky-launcher.exe");
|
||||
std::fs::create_dir_all(dest.parent().expect("父目录")).expect("bin");
|
||||
std::fs::write(&dest, b"old-binary-v1").expect("旧版本");
|
||||
|
||||
let applied = job(&tmp)
|
||||
.apply(&version("0.2.2"), CHANNEL)
|
||||
.expect("应用成功");
|
||||
let Applied::Installed { receipt, version } = applied else {
|
||||
panic!("应当装上");
|
||||
};
|
||||
assert_eq!(version, "0.9.0");
|
||||
assert_eq!(std::fs::read(&dest).expect("读新版本"), payload);
|
||||
let backup = receipt.backup.clone().expect("旧版本应留备份");
|
||||
assert_eq!(
|
||||
std::fs::read(&backup).expect("读备份"),
|
||||
b"old-binary-v1",
|
||||
"回滚用的旧版本要在备份里"
|
||||
);
|
||||
|
||||
// 回报 → 快照:「就位」状态让提示条变成「重启后生效」
|
||||
let mut runtime = Runtime::with_factory(&HostEnv::at_root(tmp.join("root")), factory(&tmp));
|
||||
runtime.adopt(Report::applied(Ok(Applied::Installed { receipt, version })));
|
||||
let view = runtime.snapshot();
|
||||
assert!(view.ready.is_some(), "就位状态要进快照");
|
||||
assert!(matches!(view.banner(), Some(UpdateBanner::Ready(_))));
|
||||
assert!(!view.applying);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! 会话状态的用例:拒绝后不再打扰、关掉自动检查后启动不派发、偏好跨重启、GUI/CLI 同一内核。
|
||||
//!
|
||||
//! 夹具在父模块,本文件 `use super::*`。
|
||||
|
||||
use super::*;
|
||||
|
||||
// ------------------------------------------------------------------ 会话状态
|
||||
|
||||
/// 拒绝之后提示条消失,但候选留着;手动检查把「拒绝」清掉。
|
||||
#[test]
|
||||
fn dismissed_stops_the_banner_but_a_manual_check_revives_it() {
|
||||
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,
|
||||
},
|
||||
))));
|
||||
assert!(matches!(
|
||||
runtime.view().banner(),
|
||||
Some(UpdateBanner::Available(_))
|
||||
));
|
||||
|
||||
runtime.dismiss();
|
||||
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,
|
||||
"静默检查的结论回来时也不能清掉用户的拒绝"
|
||||
);
|
||||
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();
|
||||
let _ = runtime.begin_check(false).expect("静默检查派发");
|
||||
assert!(runtime.view().dismissed, "静默检查不许把用户的拒绝弹回来");
|
||||
drop(job);
|
||||
}
|
||||
|
||||
/// 关掉「启动时检查」之后:状态上是 no-op、启动路径上根本不派发。
|
||||
#[test]
|
||||
fn auto_check_off_means_no_request_on_start() {
|
||||
let tmp = TempDir::new("autocheck");
|
||||
let env = HostEnv::at_root(tmp.join("root"));
|
||||
let prefs_path = env.layout.cache().join("update").join("prefs.json");
|
||||
Prefs { auto_check: false }
|
||||
.save(&prefs_path)
|
||||
.expect("写偏好");
|
||||
|
||||
let runtime = Runtime::with_factory(&env, factory(&tmp));
|
||||
assert!(!runtime.view().auto_check, "开关要读进快照");
|
||||
assert!(
|
||||
!runtime.should_check_on_start(),
|
||||
"关掉开关后启动不许派发检查"
|
||||
);
|
||||
|
||||
// 结构性判据:`refresh_all` 里那次静默检查必须挂在 `should_check_on_start` 上,
|
||||
// 否则单测绿着而启动仍然联网(这条读源码,理由同 `instances/tests.rs` 的源码断言)。
|
||||
let host = include_str!("mod.rs");
|
||||
assert!(
|
||||
host.contains("if self.update.should_check_on_start()"),
|
||||
"refresh_all 的静默检查必须由 should_check_on_start 门控"
|
||||
);
|
||||
}
|
||||
|
||||
/// 偏好能落盘、能读回(换一份 Runtime 仍记得上次的选择)。
|
||||
#[test]
|
||||
fn the_auto_check_preference_survives_a_restart() {
|
||||
let tmp = TempDir::new("prefs");
|
||||
let env = HostEnv::at_root(tmp.join("root"));
|
||||
let mut runtime = Runtime::with_factory(&env, factory(&tmp));
|
||||
assert!(runtime.prefs().auto_check, "缺省开");
|
||||
|
||||
runtime.set_auto_check(false).expect("落盘");
|
||||
let restarted = Runtime::with_factory(&env, factory(&tmp));
|
||||
assert!(!restarted.prefs().auto_check, "重启后仍记得关掉");
|
||||
assert!(!restarted.view().auto_check);
|
||||
}
|
||||
|
||||
/// GUI 与 CLI 共用**同一个** `Job::apply`(mission:不许两套逻辑)。
|
||||
///
|
||||
/// 判据是源码锚点:CLI 的 `update apply` 必须调 `Job::apply`,而不是自己拼一遍
|
||||
/// 「下载 + 校验 + 替换」。
|
||||
#[test]
|
||||
fn the_gui_and_the_cli_share_one_kernel_path() {
|
||||
let cli = include_str!("../../../cli/src/commands/update.rs");
|
||||
assert!(
|
||||
cli.contains("Job::") && cli.contains(".apply("),
|
||||
"CLI 必须复用 presenter 的 Job::apply(同一内核),实际没有调用"
|
||||
);
|
||||
// 反向:check 也只许走 Job::check
|
||||
assert!(
|
||||
cli.contains(".check("),
|
||||
"CLI 的 update check 必须走 Job::check"
|
||||
);
|
||||
}
|
||||
|
||||
/// 辅助:装配自证用的是哪份源与哪个替换目标(日志/诊断用)。
|
||||
#[test]
|
||||
fn the_factory_names_its_source_and_target() {
|
||||
let tmp = TempDir::new("source-id");
|
||||
let factory = factory(&tmp);
|
||||
assert_eq!(factory.dir(), Some(tmp.join("dist").as_path()));
|
||||
assert_eq!(factory.dest(), tmp.join("bin/unlucky-launcher.exe"));
|
||||
assert_eq!(CHANNEL.as_str(), "stable", "D50:先只发 stable");
|
||||
}
|
||||
|
||||
/// CLI 与 GUI 用的是**同一个** core Job:源码锚点,防止有人另起一条流程。
|
||||
#[test]
|
||||
fn the_core_job_is_the_single_kernel() {
|
||||
let job = include_str!("../../../core/src/update/job.rs");
|
||||
assert!(
|
||||
job.contains("pub fn apply(") && job.contains("pub fn check("),
|
||||
"内核必须同时提供 check / apply"
|
||||
);
|
||||
assert!(
|
||||
job.contains("install_staged(") && job.contains("stage("),
|
||||
"替换与校验必须在内核里做(壳层不许自己拼)"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//! 更新状态的**会话层**:一份 `UpdateView`、更新域偏好、以及把 `CoreHost` 接上后台线程。
|
||||
//!
|
||||
//! 与 [`super::update`] 的分工:那边是「一次检查/应用的纯做法」(选源、调 core、映射 DTO),
|
||||
//! 这边是「谁来记着它、谁来派发」。拆开是因为两边都各有一批判据要写清(静默检查的开关、
|
||||
//! 拒绝后不再打扰、应用失败不许丢掉候选),塞在一个文件里会越过 400 行硬线。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::update::Version;
|
||||
|
||||
use crate::dto::{CoreEvent, ReadyUpdate, UpdateOutcome, UpdateView};
|
||||
|
||||
use super::env::HostEnv;
|
||||
use super::update::{current_version, outcome_of, ApplyReport, Factory, Prefs, Report, CHANNEL};
|
||||
use super::CoreHost;
|
||||
|
||||
/// 会话级更新状态(提示条 / 设置页共用一份)。
|
||||
#[derive(Debug)]
|
||||
pub struct Runtime {
|
||||
view: UpdateView,
|
||||
prefs: Prefs,
|
||||
factory: Factory,
|
||||
prefs_path: PathBuf,
|
||||
/// 有一条检查/应用正在后台跑(防重复派发;进程内独占)。
|
||||
inflight: bool,
|
||||
}
|
||||
|
||||
impl Runtime {
|
||||
/// 按数据根装配。
|
||||
#[must_use]
|
||||
pub fn for_env(env: &HostEnv) -> Self {
|
||||
Self::with_factory(env, Factory::for_env(env))
|
||||
}
|
||||
|
||||
/// 用给定装配(单测:临时目录 + 本地目录源)。
|
||||
#[must_use]
|
||||
pub fn with_factory(env: &HostEnv, factory: Factory) -> Self {
|
||||
let prefs_path = env.layout.cache().join("update").join("prefs.json");
|
||||
let prefs = Prefs::load(&prefs_path);
|
||||
Self {
|
||||
view: UpdateView::new(current_version(), CHANNEL.as_str(), prefs.auto_check),
|
||||
prefs,
|
||||
factory,
|
||||
prefs_path,
|
||||
inflight: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前快照。
|
||||
#[must_use]
|
||||
pub fn view(&self) -> &UpdateView {
|
||||
&self.view
|
||||
}
|
||||
|
||||
/// 当前快照(克隆给事件/线程)。
|
||||
#[must_use]
|
||||
pub fn snapshot(&self) -> UpdateView {
|
||||
self.view.clone()
|
||||
}
|
||||
|
||||
/// 偏好。
|
||||
#[must_use]
|
||||
pub const fn prefs(&self) -> Prefs {
|
||||
self.prefs
|
||||
}
|
||||
|
||||
/// 后台有没有一条在跑。
|
||||
#[must_use]
|
||||
pub const fn inflight(&self) -> bool {
|
||||
self.inflight
|
||||
}
|
||||
|
||||
/// 本进程版本解析(失败时 `None`:版本号是编译进来的常量,理论上不可能失败)。
|
||||
#[must_use]
|
||||
pub fn current(&self) -> Option<Version> {
|
||||
Version::parse(current_version()).ok()
|
||||
}
|
||||
|
||||
/// 启动时要不要静默检查(开关关掉 ⇒ 启动**一次网络都不发**)。
|
||||
#[must_use]
|
||||
pub fn should_check_on_start(&self) -> bool {
|
||||
self.prefs.auto_check && !self.inflight
|
||||
}
|
||||
|
||||
/// 开关「启动时静默检查」并落盘。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 落盘失败返回人读文案(本会话的取值仍然生效——不因为写不进去就把开关弹回去)。
|
||||
pub fn set_auto_check(&mut self, on: bool) -> Result<(), String> {
|
||||
self.prefs.auto_check = on;
|
||||
self.view.auto_check = on;
|
||||
self.prefs.save(&self.prefs_path)
|
||||
}
|
||||
|
||||
/// 开始一次检查:置「正在查」并返回要交给后台线程的 [`unlucky_core::update::Job`]
|
||||
/// (`None` = 已经有一条在跑)。
|
||||
///
|
||||
/// `manual` = 用户点的「立即检查」:会清掉「忽略这个版本」,并让失败也弹提示条。
|
||||
pub fn begin_check(&mut self, manual: bool) -> Option<unlucky_core::update::Job> {
|
||||
if self.inflight {
|
||||
return None;
|
||||
}
|
||||
self.inflight = true;
|
||||
self.view.outcome = UpdateOutcome::Checking;
|
||||
self.view.silent = !manual;
|
||||
self.view.applying = false;
|
||||
if manual {
|
||||
self.view.dismissed = false;
|
||||
}
|
||||
Some(self.factory.job())
|
||||
}
|
||||
|
||||
/// 开始应用:`None` = 没有在跑的必要(正在忙 / 没有候选 / 已经就位)。
|
||||
pub fn begin_apply(&mut self) -> Option<unlucky_core::update::Job> {
|
||||
if self.inflight || self.view.ready.is_some() || self.view.outcome.candidate().is_none() {
|
||||
return None;
|
||||
}
|
||||
self.inflight = true;
|
||||
self.view.applying = true;
|
||||
Some(self.factory.job())
|
||||
}
|
||||
|
||||
/// 后台回报进快照(**唯一**的写入口:检查与应用都走这里)。
|
||||
pub fn adopt(&mut self, report: Report) {
|
||||
self.inflight = false;
|
||||
self.view.checked_at = super::update::stamp();
|
||||
match report {
|
||||
Report::Checked(outcome) => {
|
||||
self.view.outcome = outcome;
|
||||
// 只有**用户主动查**才清掉「忽略这个版本」;静默检查(启动时/自动)一律不许把
|
||||
// 用户的拒绝弹回来(mission:拒绝之后本次不再打扰)。手动那一路在
|
||||
// `begin_check` 里就已经清过了,这里的条件只是让「谁清」这件事只有一条判据。
|
||||
if !self.view.silent && self.view.outcome.candidate().is_some() {
|
||||
self.view.dismissed = false;
|
||||
}
|
||||
}
|
||||
Report::Applied(ApplyReport::Installed {
|
||||
version,
|
||||
path,
|
||||
backup,
|
||||
}) => {
|
||||
self.view.applying = false;
|
||||
self.view.outcome = UpdateOutcome::UpToDate {
|
||||
latest: Some(version.clone()),
|
||||
};
|
||||
self.view.ready = Some(ReadyUpdate {
|
||||
version,
|
||||
path,
|
||||
backup,
|
||||
});
|
||||
}
|
||||
Report::Applied(ApplyReport::Nothing(outcome)) => {
|
||||
self.view.applying = false;
|
||||
self.view.outcome = outcome;
|
||||
}
|
||||
Report::Applied(ApplyReport::Failed(failure)) => {
|
||||
// 应用失败要说清是哪一步(校验不符 / 替换被占用)。候选随 `outcome` 一起换成
|
||||
// `Failed`,**重试走「立即检查」重新复查**(清单只有几 KB,apply 本来也要复查一次):
|
||||
// 这样重试拿到的永远是最新那一份,而不是几分钟前的旧候选。失败也不再算「静默」,
|
||||
// 提示条要把原因摆出来。
|
||||
self.view.applying = false;
|
||||
self.view.outcome = UpdateOutcome::Failed(failure);
|
||||
self.view.silent = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户拒绝这次更新:本次会话不再弹提示条(候选仍在快照里,设置页可手动装)。
|
||||
pub fn dismiss(&mut self) {
|
||||
self.view.dismissed = true;
|
||||
}
|
||||
}
|
||||
|
||||
impl CoreHost {
|
||||
/// 发布更新快照(唯一的出口;`CoreEvent::Update` 是整份替换)。
|
||||
pub(super) fn publish_update(&mut self) {
|
||||
let view = self.update.snapshot();
|
||||
self.send(CoreEvent::Update(Box::new(view)));
|
||||
}
|
||||
|
||||
/// 派一次检查(`manual` = 设置页点的「立即检查」;`false` = 启动时的静默检查)。
|
||||
pub(super) fn start_update_check(&mut self, manual: bool) {
|
||||
let Some(current) = self.update.current() else {
|
||||
return;
|
||||
};
|
||||
let Some(job) = self.update.begin_check(manual) else {
|
||||
return;
|
||||
};
|
||||
// 先把「正在查」发出去:设置页的按钮据此转圈,而不是看起来没反应。
|
||||
self.publish_update();
|
||||
spawn_check(job, current, self.tx.clone());
|
||||
}
|
||||
|
||||
/// 应用更新(下载 → 长度/SHA256 双检 → 替换;全部在后台线程)。
|
||||
pub(super) fn start_update_apply(&mut self) {
|
||||
let Some(current) = self.update.current() else {
|
||||
return;
|
||||
};
|
||||
let Some(job) = self.update.begin_apply() else {
|
||||
return;
|
||||
};
|
||||
self.publish_update();
|
||||
spawn_apply(job, current, self.tx.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// 起一条检查线程(结果经 [`super::HostTask::Update`] 回来)。
|
||||
pub fn spawn_check(job: unlucky_core::update::Job, current: Version, tx: super::JobSender) {
|
||||
super::job::spawn("update-check", move || {
|
||||
let report = Report::Checked(outcome_of(job.check(¤t, CHANNEL)));
|
||||
let _ = tx.send(super::HostTask::Update(Box::new(report)));
|
||||
});
|
||||
}
|
||||
|
||||
/// 起一条应用线程(下载 + 双检 + 替换都在里面)。
|
||||
pub fn spawn_apply(job: unlucky_core::update::Job, current: Version, tx: super::JobSender) {
|
||||
super::job::spawn("update-apply", move || {
|
||||
let report = Report::applied(job.apply(¤t, CHANNEL));
|
||||
let _ = tx.send(super::HostTask::Update(Box::new(report)));
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "update_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,244 @@
|
||||
//! 自更新宿主通道的单测(M159)。
|
||||
//!
|
||||
//! 全部离线:清单与产物都在临时目录里造([`Source::Dir`]),**没有一个用例发公网请求**。
|
||||
//! 覆盖 mission 的验收条目:
|
||||
//!
|
||||
//! | 用例 | 守什么 |
|
||||
//! |---|---|
|
||||
//! | [`version_matrix_decides_who_gets_prompted`] | 版本更高才提示;同版本/更低一律不提示;`v0.2.3` 这类真实 tag 形态 |
|
||||
//! | [`a_broken_manifest_degrades_to_failure_not_up_to_date`] | 清单坏 JSON → 「检查失败」,**不是**「已是最新」 |
|
||||
//! | [`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`] | 拒绝后不再打扰;手动检查清掉拒绝 |
|
||||
//! | [`auto_check_off_means_no_request_on_start`] | 关掉开关后启动不派发检查(结构性 + 状态两层) |
|
||||
//! | [`the_gui_and_the_cli_share_one_kernel_path`] | GUI/CLI 走同一个 `Job`(源码锚点,不是口号) |
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use unlucky_core::download::verify::digest_bytes;
|
||||
use unlucky_core::download::HashAlgo;
|
||||
use unlucky_core::update::{Applied, Target, Version};
|
||||
|
||||
use crate::dto::{UpdateBanner, UpdateOutcome};
|
||||
|
||||
use super::super::env::HostEnv;
|
||||
use super::super::update::{outcome_of, ApplyReport, Factory, Prefs, Report, CHANNEL};
|
||||
use super::Runtime;
|
||||
|
||||
/// 一次性临时目录。
|
||||
struct TempDir {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TempDir {
|
||||
fn new(tag: &str) -> Self {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("ull-m159-{}-{tag}-{unique}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
std::fs::create_dir_all(&path).expect("建临时目录");
|
||||
Self { path }
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
fn join(&self, name: &str) -> PathBuf {
|
||||
self.path.join(name)
|
||||
}
|
||||
|
||||
fn write(&self, name: &str, bytes: &[u8]) -> PathBuf {
|
||||
let path = self.join(name);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("建父目录");
|
||||
}
|
||||
std::fs::write(&path, bytes).expect("写文件");
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn sha256(bytes: &[u8]) -> String {
|
||||
digest_bytes(HashAlgo::Sha256, bytes)
|
||||
}
|
||||
|
||||
/// 本平台在清单里的 `target` 写法。
|
||||
fn here() -> String {
|
||||
Target::current().as_str().to_string()
|
||||
}
|
||||
|
||||
/// 一份「本平台有产物」的清单(摘要按 `payload` 真实值算)。
|
||||
fn manifest_for(version: &str, payload: &[u8], notes: &str) -> String {
|
||||
format!(
|
||||
r#"{{"channel":"stable","releases":[{{"version":"{version}","channel":"stable","notes":"{notes}",
|
||||
"artifacts":[{{"target":"{}","format":"portable-exe","url":"asset.exe","size":{},"sha256":"{}"}}]}}]}}"#,
|
||||
here(),
|
||||
payload.len(),
|
||||
sha256(payload)
|
||||
)
|
||||
}
|
||||
|
||||
/// 造一个本地更新源目录(`dist/stable.json` + `dist/asset.exe`)。
|
||||
fn dir_source(tmp: &TempDir, manifest: &str, payload: &[u8]) {
|
||||
tmp.write("dist/stable.json", manifest.as_bytes());
|
||||
tmp.write("dist/asset.exe", payload);
|
||||
}
|
||||
|
||||
/// 造一份装配:本地源 + 临时目标 + 临时暂存。
|
||||
fn factory(tmp: &TempDir) -> Factory {
|
||||
Factory::for_env(&HostEnv::at_root(tmp.join("root")))
|
||||
.with_dir(tmp.join("dist"))
|
||||
.with_dest(tmp.join("bin/unlucky-launcher.exe"))
|
||||
.with_staging(tmp.join("staging"))
|
||||
}
|
||||
|
||||
/// 一份 job(走同一份装配)。
|
||||
fn job(tmp: &TempDir) -> unlucky_core::update::Job {
|
||||
factory(tmp).job()
|
||||
}
|
||||
|
||||
fn version(text: &str) -> Version {
|
||||
Version::parse(text).expect("合法版本")
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 版本矩阵
|
||||
|
||||
/// 版本更高才提示;同版本 / 更低一律不提示;真实 tag 形态(`v0.2.3`)照常解析。
|
||||
///
|
||||
/// 这条是 mission 第二条任务的核心判据,**判定本身在 core**(`UpdateChecker`),这里守的是
|
||||
/// 「host 把它翻成 DTO 之后仍然如此」——两处任何一处把 `<=` 写成 `<` 都会红。
|
||||
#[test]
|
||||
fn version_matrix_decides_who_gets_prompted() {
|
||||
// (当前版本, 清单里的版本, 期望「有更新」)
|
||||
let cases = [
|
||||
("0.2.2", "0.2.3", true),
|
||||
("0.2.2", "v0.2.3", true),
|
||||
("0.2.2", "0.2.2", false),
|
||||
("0.2.2", "v0.2.2", false),
|
||||
("0.2.2", "0.2.1", false),
|
||||
("0.2.2", "0.10.0", true),
|
||||
("0.2.2", "0.2.10", true),
|
||||
("0.2.2", "0.2.2-rc.1", false),
|
||||
("0.2.2-rc.1", "0.2.2", true),
|
||||
];
|
||||
for (current, listed, expected) in cases {
|
||||
let tmp = TempDir::new("matrix");
|
||||
let payload = b"new";
|
||||
dir_source(&tmp, &manifest_for(listed, payload, ""), payload);
|
||||
let outcome = outcome_of(job(&tmp).check(&version(current), CHANNEL));
|
||||
assert_eq!(
|
||||
outcome.candidate().is_some(),
|
||||
expected,
|
||||
"当前 {current} vs 清单 {listed}:期望 available={expected},实际 {outcome:?}"
|
||||
);
|
||||
if !expected {
|
||||
assert!(
|
||||
matches!(outcome, UpdateOutcome::UpToDate { .. }),
|
||||
"不提示的时候必须是「已是最新」,实际 {outcome:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- 清单契约降级
|
||||
|
||||
/// 坏 JSON / 缺字段 ⇒ **检查失败**,绝不是「已是最新」。
|
||||
///
|
||||
/// 两个变体的文案与后果完全不同:说成「已是最新」会让用户以为网络通、版本新,实际一个字节都没取到。
|
||||
#[test]
|
||||
fn a_broken_manifest_degrades_to_failure_not_up_to_date() {
|
||||
for (label, manifest) in [
|
||||
("not json", "{ this is not json".to_string()),
|
||||
(
|
||||
"wrong field type",
|
||||
r#"{"channel":"stable","releases":42}"#.to_string(),
|
||||
),
|
||||
(
|
||||
"bad digest",
|
||||
format!(
|
||||
r#"{{"channel":"stable","releases":[{{"version":"0.9.0","channel":"stable","artifacts":[{{"target":"{}","format":"portable-exe","url":"a.exe","size":3,"sha256":"zz"}}]}}]}}"#,
|
||||
here()
|
||||
),
|
||||
),
|
||||
(
|
||||
"missing sha256",
|
||||
format!(
|
||||
r#"{{"channel":"stable","releases":[{{"version":"0.9.0","channel":"stable","artifacts":[{{"target":"{}","format":"portable-exe","url":"asset.exe","size":3}}]}}]}}"#,
|
||||
here()
|
||||
),
|
||||
),
|
||||
("bad version", manifest_for("not-a-version", b"abc", "")),
|
||||
] {
|
||||
let tmp = TempDir::new("badmanifest");
|
||||
tmp.write("dist/stable.json", manifest.as_bytes());
|
||||
let outcome = outcome_of(job(&tmp).check(&version("0.2.2"), CHANNEL));
|
||||
assert!(
|
||||
matches!(outcome, UpdateOutcome::Failed(_)),
|
||||
"{label}:坏清单必须降级成「检查失败」,实际 {outcome:?}"
|
||||
);
|
||||
let failure = outcome.failure().expect("失败带原因");
|
||||
assert!(
|
||||
!failure.message.trim().is_empty(),
|
||||
"{label}:失败要有人话原因"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 清单里**一条发布都没有**(`{"channel":"stable"}` / 空 `releases`)⇒ 已是最新,不是失败。
|
||||
///
|
||||
/// 这是 core 的既定口径(「该渠道还没发过版」不是错误),与上面那条「坏清单」要分得清清楚楚:
|
||||
/// 少一个字段是坏文件,空列表是「还没发」。两者都被报成同一种状态的话,发布脚本第一次上线
|
||||
/// 之前所有人都会看到红字。
|
||||
#[test]
|
||||
fn an_empty_manifest_means_nothing_published_yet() {
|
||||
for manifest in [
|
||||
r#"{"channel":"stable"}"#.to_string(),
|
||||
r#"{"channel":"stable","releases":[]}"#.to_string(),
|
||||
] {
|
||||
let tmp = TempDir::new("empty-manifest");
|
||||
tmp.write("dist/stable.json", manifest.as_bytes());
|
||||
let outcome = outcome_of(job(&tmp).check(&version("0.2.2"), CHANNEL));
|
||||
assert!(
|
||||
matches!(&outcome, UpdateOutcome::UpToDate { latest: None }),
|
||||
"空清单 = 还没发过版,实际 {outcome:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 有更高的版本、但里面没有本平台的产物 ⇒ 单独的变体(不是「已是最新」,也不是「有更新」)。
|
||||
#[test]
|
||||
fn a_higher_version_without_our_platform_is_not_up_to_date() {
|
||||
let tmp = TempDir::new("missing-artifact");
|
||||
tmp.write(
|
||||
"dist/stable.json",
|
||||
format!(
|
||||
r#"{{"channel":"stable","releases":[{{"version":"0.9.0","channel":"stable","artifacts":[{{"target":"{}","format":"portable-exe","url":"asset.exe","size":3,"sha256":"{}"}}]}}]}}"#,
|
||||
// 故意给一个不可能匹配本机的目标(`unknown` 架构永远对不上)
|
||||
"linux-x86",
|
||||
sha256(b"abc")
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
let outcome = outcome_of(job(&tmp).check(&version("0.2.2"), CHANNEL));
|
||||
assert!(
|
||||
matches!(&outcome, UpdateOutcome::MissingArtifact { version } if version == "0.9.0"),
|
||||
"缺本平台产物要单独报出来,实际 {outcome:?}"
|
||||
);
|
||||
assert_eq!(outcome.as_str(), "missing_artifact");
|
||||
}
|
||||
|
||||
// 两个子模块各守一段(拆开只为守 400 行硬线;夹具在本模块,子模块 `use super::*` 共用,
|
||||
// 不重复抄一份)。
|
||||
#[path = "update_apply_tests.rs"]
|
||||
mod apply;
|
||||
#[path = "update_session_tests.rs"]
|
||||
mod session;
|
||||
Reference in new issue
Block a user