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
@@ -35,6 +35,8 @@ use crate::dto::{
|
||||
PackEvent,
|
||||
SessionId,
|
||||
SettingGroupView,
|
||||
// M159: 自更新 —— 一条快照整体替换
|
||||
UpdateView,
|
||||
VersionCategory,
|
||||
VersionSummary,
|
||||
};
|
||||
@@ -129,6 +131,10 @@ pub enum CoreEvent {
|
||||
// M113: import/export
|
||||
/// 导入/导出(M113):进度、结果、探测诊断、导出预检(子枚举见 [`PackEvent`])
|
||||
Pack(Box<PackEvent>),
|
||||
/// 自更新(M159):整份状态快照(检查结论 + 就位状态 + 用户是否已拒绝)。
|
||||
///
|
||||
/// 与 `SettingsChanged` 同一条形态:快照整体替换,UI 不做增量合并。
|
||||
Update(Box<UpdateView>),
|
||||
}
|
||||
|
||||
/// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路,
|
||||
@@ -237,6 +243,15 @@ pub enum AppCommand {
|
||||
// M113: import/export
|
||||
/// 整合包导入/导出(M113):导入 / 预检 / 确认写盘 / 关面板(子枚举见 [`PackCommand`])
|
||||
Pack(Box<PackCommand>),
|
||||
// M159: 自更新
|
||||
/// 查一次更新(设置页「立即检查」;会清掉「忽略这个版本」的标记并重新请求网络)
|
||||
CheckUpdate,
|
||||
/// 把当前候选下载下来、校验、替换本程序(结果经 [`CoreEvent::Update`] 回来)
|
||||
ApplyUpdate,
|
||||
/// 忽略这个版本(本次会话不再弹提示条;候选仍留在快照里,设置页可手动装)
|
||||
DismissUpdate,
|
||||
/// 开关「启动时静默检查」(写进更新域自己的偏好文件)
|
||||
SetUpdateAutoCheck(bool),
|
||||
}
|
||||
|
||||
impl AppCommand {
|
||||
|
||||
@@ -28,6 +28,8 @@ mod notices;
|
||||
// M113: import/export —— 整合包导入/导出的 DTO(M111/M112 管线的 IPC 面)
|
||||
mod pack;
|
||||
mod settings;
|
||||
// M159: 自更新(检查结论 / 候选 / 就位状态)
|
||||
mod update;
|
||||
mod versions;
|
||||
|
||||
pub use accounts::{
|
||||
@@ -59,4 +61,8 @@ pub use pack::{
|
||||
MissingReasonView, PackCommand, PackEvent, PackFormatView, PackState, ProbeView,
|
||||
};
|
||||
pub use settings::{SettingControl, SettingGroupView, SettingRowView};
|
||||
// M159: 自更新
|
||||
pub use update::{
|
||||
ReadyUpdate, UpdateBanner, UpdateCandidateView, UpdateFailureView, UpdateOutcome, UpdateView,
|
||||
};
|
||||
pub use versions::{AncientKind, VersionCategory, VersionKind, VersionSummary};
|
||||
@@ -0,0 +1,318 @@
|
||||
//! 自更新 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::dismissed`])是**会话级**状态:用户点了「忽略这个版本」,本次运行
|
||||
//! 不再自动弹提示条;但设置页的「立即检查」不受影响(它会把这一位清掉重查)。
|
||||
|
||||
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>,
|
||||
/// 用户点了「忽略这个版本」:本次会话不再弹提示条(设置页手动检查会清掉它)。
|
||||
pub dismissed: bool,
|
||||
}
|
||||
|
||||
impl Default for UpdateView {
|
||||
fn default() -> Self {
|
||||
Self::new(String::new(), "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,
|
||||
dismissed: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 提示条此刻要显示什么(`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(|_| !self.dismissed)
|
||||
.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 dismissed_hides_the_banner_but_keeps_the_candidate() {
|
||||
let mut view = UpdateView {
|
||||
outcome: UpdateOutcome::Available(Box::new(candidate("0.2.3"))),
|
||||
..UpdateView::default()
|
||||
};
|
||||
assert!(matches!(view.banner(), Some(UpdateBanner::Available(_))));
|
||||
|
||||
view.dismissed = true;
|
||||
assert!(view.banner().is_none(), "拒绝之后本次不再打扰");
|
||||
// 候选没被丢掉:设置页仍能显示「有新版本 0.2.3」并手动装
|
||||
assert_eq!(
|
||||
view.outcome.candidate().map(|item| item.version.as_str()),
|
||||
Some("0.2.3")
|
||||
);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -25,6 +25,7 @@ use crate::dto::{
|
||||
PackState,
|
||||
Progress,
|
||||
SettingGroupView,
|
||||
UpdateView,
|
||||
VersionSummary,
|
||||
};
|
||||
use crate::prefs::UiPrefs;
|
||||
@@ -115,6 +116,8 @@ pub struct AppState {
|
||||
pub settings_tab: String,
|
||||
/// 横幅通知
|
||||
pub notices: Vec<NoticeView>,
|
||||
/// 自更新(M159):检查结论 / 就位状态 / 是否已拒绝(提示条与设置页共用)
|
||||
pub update: UpdateView,
|
||||
/// 新建实例向导
|
||||
pub wizard: WizardState,
|
||||
/// 正在实时尾随的会话
|
||||
@@ -161,6 +164,7 @@ impl AppState {
|
||||
settings_groups: Vec::new(),
|
||||
settings_tab: "download".to_string(),
|
||||
notices: Vec::new(),
|
||||
update: UpdateView::default(),
|
||||
wizard: WizardState::new(),
|
||||
live_session: None,
|
||||
content: ContentState::default(),
|
||||
@@ -281,6 +285,7 @@ impl AppState {
|
||||
.insert(mc_version, result);
|
||||
}
|
||||
CoreEvent::NoticesChanged(notices) => self.notices = notices,
|
||||
CoreEvent::Update(view) => self.update = *view,
|
||||
// M113: import/export
|
||||
CoreEvent::Pack(event) => self.pack.apply(*event),
|
||||
CoreEvent::ContentResults(results) => self.apply_content(*results),
|
||||
|
||||
Reference in new issue
Block a user