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

+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()
}