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:
AskaEth committed 2026-11-07 12:09:42 +08:00
1 parent a80362915a
commit 1c8f6aa1b3
44 files changed
+3341 -22

No files matched your search

+15
View File
@@ -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);
+5
View File
@@ -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)),
}
}
+2
View File
@@ -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` 克隆即可)。
+15 -1
View File
@@ -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);
}
}
/// 重建实例快照,并把**真实运行时**盖回去。
+382
View File
@@ -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("),
"替换与校验必须在内核里做(壳层不许自己拼)"
);
}
+226
View File
@@ -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(&current, 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(&current, CHANNEL));
let _ = tx.send(super::HostTask::Update(Box::new(report)));
});
}
#[cfg(test)]
#[path = "update_tests.rs"]
mod tests;
+244
View File
@@ -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;