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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user