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