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
@@ -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 {
+6
View File
@@ -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};
+318
View File
@@ -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);
}
}