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,220 @@
|
||||
//! 「检查 → 取包 → 长度/SHA256 双检 → 原子替换」的一条龙:**GUI 与 CLI 的唯一入口**。
|
||||
//!
|
||||
//! # 为什么要有本模块
|
||||
//!
|
||||
//! mission 的硬要求是「GUI 与 CLI 行为一致,不许两套逻辑」。两个壳子各自拼一遍「选源 + 定目标 +
|
||||
//! 暂存 + 校验 + 替换」,迟早会漂:一处忘了摘要校验,一处把暂存放在数据根之外,一处把「没有更新」
|
||||
//! 当成错误。所以这些决定收成一个 [`Job`]:
|
||||
//!
|
||||
//! ```text
|
||||
//! Job { source, target, staging, dest }
|
||||
//! ├─ check(current, channel) → CheckOutcome (版本比较/渠道准入都在 checker)
|
||||
//! └─ apply(current, channel) → Applied (复查 → 取包 → 双检 → 替换)
|
||||
//! ```
|
||||
//!
|
||||
//! # 应用为什么先复查一次
|
||||
//!
|
||||
//! 用户点「安装」离上次检查可能已过几分钟,期间发布侧可能换了包。清单只有几 KB,复查一次换来
|
||||
//! 两个好处:装的一定是**此刻**最新的那一份;`apply` 自带「现在到底有没有得装」的答案,
|
||||
//! 不必让调用方信任内存里的旧候选。
|
||||
//!
|
||||
//! # 失败时的文件去哪
|
||||
//!
|
||||
//! 校验失败:暂存文件由 [`stage`] 就地删掉([`super::verify::StagedPackage`] 的 `Drop` 再兜一层),
|
||||
//! **不留在原地**。替换失败:由 [`install_staged`] 回滚,旧版本仍在原路径上。两件事都不需要调用方记得做。
|
||||
//!
|
||||
//! # 换源
|
||||
//!
|
||||
//! 生产源是 D50 定死的 GitHub Releases([`default_source`]);`ULL_UPDATE_DIR` 指向本地目录时
|
||||
//! 改走 [`super::source::DirSource`]——那是取证/内网手工分发的口子,**不是**第二个更新源。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::download::DownloadEngine;
|
||||
|
||||
use super::channel::Channel;
|
||||
use super::checker::{CheckOutcome, UpdateChecker};
|
||||
use super::error::UpdateError;
|
||||
use super::github::GitHubReleaseSource;
|
||||
use super::install::{install_staged, InstallReceipt, ReplacePolicy};
|
||||
use super::source::{DirSource, UpdateSource};
|
||||
use super::target::Target;
|
||||
use super::verify::stage;
|
||||
use super::version::Version;
|
||||
|
||||
/// 本机更新源覆盖(取证 / 内网手工分发):指向一个 `DirSource` 根目录(`<root>/stable.json`)。
|
||||
///
|
||||
/// 生产不设它——真实网络下 `releases/latest` 是唯一入口。它的用途是把「发现新版本」这条分支
|
||||
/// 在没有稳定 Release 的情况下也能整环验一遍(本地清单 + 本地产物,一个字节都不出网)。
|
||||
pub const UPDATE_DIR_ENV: &str = "ULL_UPDATE_DIR";
|
||||
|
||||
/// 一次应用的结局。
|
||||
#[derive(Debug)]
|
||||
pub enum Applied {
|
||||
/// 替换成功(新版本已经就位,等重启)。
|
||||
Installed {
|
||||
/// 收据(目标路径 + 旧版本备份 + 字节数)。
|
||||
receipt: InstallReceipt,
|
||||
/// 装上的版本号。
|
||||
version: String,
|
||||
},
|
||||
/// 复查时已经没有可装的更新(例如这期间别人替你升过)。
|
||||
Nothing(CheckOutcome),
|
||||
}
|
||||
|
||||
/// 一次检查/应用的全部输入。
|
||||
///
|
||||
/// 不含任何 UI 状态:谁在查、查完怎么展示是壳层的事。字段都可读,日志/诊断要什么有什么。
|
||||
pub struct Job {
|
||||
source: Box<dyn UpdateSource>,
|
||||
target: Target,
|
||||
staging: PathBuf,
|
||||
dest: PathBuf,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Job {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Job")
|
||||
.field("source", &self.source.source_id())
|
||||
.field("target", &self.target.as_str())
|
||||
.field("staging", &self.staging)
|
||||
.field("dest", &self.dest)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Job {
|
||||
/// 显式装配(测试用:源、目标平台、暂存、替换目标全都自己给)。
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
source: Box<dyn UpdateSource>,
|
||||
target: Target,
|
||||
staging: impl Into<PathBuf>,
|
||||
dest: impl Into<PathBuf>,
|
||||
) -> Self {
|
||||
Self {
|
||||
source,
|
||||
target,
|
||||
staging: staging.into(),
|
||||
dest: dest.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 本进程的默认装配:目标平台 = 编译目标,替换目标 = 本进程 exe,
|
||||
/// 暂存 = `<cache_root>/update/staging`。
|
||||
#[must_use]
|
||||
pub fn for_process(source: Box<dyn UpdateSource>, cache_root: &Path) -> Self {
|
||||
Self::new(
|
||||
source,
|
||||
Target::current(),
|
||||
staging_dir(cache_root),
|
||||
process_dest(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 换替换目标(`ull update apply --dest <path>`、单测都走它)。
|
||||
#[must_use]
|
||||
pub fn with_dest(mut self, dest: impl Into<PathBuf>) -> Self {
|
||||
self.dest = dest.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// 源 id(日志/事件里的 `source` 字段)。
|
||||
#[must_use]
|
||||
pub fn source_id(&self) -> &str {
|
||||
self.source.source_id()
|
||||
}
|
||||
|
||||
/// 绑定的目标平台。
|
||||
#[must_use]
|
||||
pub const fn target(&self) -> Target {
|
||||
self.target
|
||||
}
|
||||
|
||||
/// 暂存目录。
|
||||
#[must_use]
|
||||
pub fn staging(&self) -> &Path {
|
||||
&self.staging
|
||||
}
|
||||
|
||||
/// 替换目标(本进程 exe)。
|
||||
#[must_use]
|
||||
pub fn dest(&self) -> &Path {
|
||||
&self.dest
|
||||
}
|
||||
|
||||
/// 查一次更新。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 清单取不到 / 清单不合法 / 渠道不符都返回 [`UpdateError`];「没有更新」不是错误,
|
||||
/// 走 [`CheckOutcome::UpToDate`]。
|
||||
pub fn check(&self, current: &Version, channel: Channel) -> Result<CheckOutcome, UpdateError> {
|
||||
UpdateChecker::new(SourceRef(&*self.source), self.target).check(current, channel)
|
||||
}
|
||||
|
||||
/// 下载 → 双检 → 原子替换。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 复查失败、取包失败、长度/摘要不符、替换失败都返回 [`UpdateError`];
|
||||
/// 「复查之后没有可装的更新」不是错误,走 [`Applied::Nothing`]。
|
||||
pub fn apply(&self, current: &Version, channel: Channel) -> Result<Applied, UpdateError> {
|
||||
let outcome = self.check(current, channel)?;
|
||||
let Some(candidate) = outcome.candidate() else {
|
||||
return Ok(Applied::Nothing(outcome));
|
||||
};
|
||||
let staged = stage(&*self.source, &candidate.artifact, &self.staging)?;
|
||||
let version = candidate.release.version.to_string();
|
||||
match install_staged(&staged, &self.dest, ReplacePolicy::default()) {
|
||||
Ok(receipt) => Ok(Applied::Installed { receipt, version }),
|
||||
Err(failure) => Err(failure.error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `&dyn UpdateSource` 也能当源用([`UpdateChecker`] 是泛型的,这里省掉一次装箱)。
|
||||
struct SourceRef<'a>(&'a dyn UpdateSource);
|
||||
|
||||
impl UpdateSource for SourceRef<'_> {
|
||||
fn source_id(&self) -> &str {
|
||||
self.0.source_id()
|
||||
}
|
||||
|
||||
fn fetch_manifest(
|
||||
&self,
|
||||
channel: Channel,
|
||||
) -> Result<Option<super::manifest::ReleaseManifest>, UpdateError> {
|
||||
self.0.fetch_manifest(channel)
|
||||
}
|
||||
|
||||
fn fetch_artifact(
|
||||
&self,
|
||||
artifact: &super::manifest::Artifact,
|
||||
dest: &Path,
|
||||
) -> Result<u64, UpdateError> {
|
||||
self.0.fetch_artifact(artifact, dest)
|
||||
}
|
||||
}
|
||||
|
||||
/// 本进程要替换的目标(拿不到 `current_exe` 时留一个占位名,让错误信息里至少有个名字)。
|
||||
#[must_use]
|
||||
pub fn process_dest() -> PathBuf {
|
||||
std::env::current_exe().unwrap_or_else(|_| PathBuf::from("unlucky-launcher"))
|
||||
}
|
||||
|
||||
/// 暂存目录(`<cache_root>/update/staging`)。
|
||||
#[must_use]
|
||||
pub fn staging_dir(cache_root: &Path) -> PathBuf {
|
||||
cache_root.join("update").join("staging")
|
||||
}
|
||||
|
||||
/// 生产源:GitHub Releases(D50);`ULL_UPDATE_DIR` 设了就走本地目录源。
|
||||
#[must_use]
|
||||
pub fn default_source(engine: Arc<DownloadEngine>) -> Box<dyn UpdateSource> {
|
||||
match std::env::var(UPDATE_DIR_ENV) {
|
||||
Ok(root) if !root.trim().is_empty() => Box::new(DirSource::new(root.trim())),
|
||||
_ => Box::new(GitHubReleaseSource::new(engine)),
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
//! source UpdateSource trait + 本地目录源 github GitHub Releases 源(D50)
|
||||
//! verify 暂存 + 长度/摘要校验 checker 选择哪一份发布、急不急
|
||||
//! install 原子替换 + 回滚 error 分类与文案
|
||||
//! job 检查/应用的一条龙(GUI 与 CLI 共用同一入口)
|
||||
//! ```
|
||||
//!
|
||||
//! # 刻意不做的事
|
||||
@@ -43,6 +44,7 @@ pub mod checker;
|
||||
pub mod error;
|
||||
pub mod github;
|
||||
pub mod install;
|
||||
pub mod job;
|
||||
pub mod manifest;
|
||||
pub mod source;
|
||||
pub mod target;
|
||||
@@ -59,6 +61,7 @@ pub use self::github::GitHubReleaseSource;
|
||||
pub use self::install::{
|
||||
backup_path, install_staged, InstallFailure, InstallReceipt, ReplacePolicy,
|
||||
};
|
||||
pub use self::job::{default_source, process_dest, staging_dir, Applied, Job, UPDATE_DIR_ENV};
|
||||
pub use self::manifest::{Artifact, Release, ReleaseManifest};
|
||||
pub use self::source::{DirSource, UpdateSource};
|
||||
pub use self::target::{Arch, PackageFormat, Target};
|
||||
|
||||
@@ -52,6 +52,25 @@ pub trait UpdateSource: Send + Sync {
|
||||
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError>;
|
||||
}
|
||||
|
||||
/// `Box<dyn UpdateSource>` 也是 `UpdateSource`。
|
||||
///
|
||||
/// 为什么需要:壳层要**运行期**才决定用哪个源(生产走 GitHub、测试/内网走本地目录),而
|
||||
/// [`super::UpdateChecker`] 与 [`super::verify::stage`] 都是按 trait 泛型化的。没有这条转发,
|
||||
/// 调用方只能给每种源写一份检查/下载流程——那正是 mission 禁止的「两套逻辑」。
|
||||
impl<T: UpdateSource + ?Sized> UpdateSource for Box<T> {
|
||||
fn source_id(&self) -> &str {
|
||||
(**self).source_id()
|
||||
}
|
||||
|
||||
fn fetch_manifest(&self, channel: Channel) -> Result<Option<ReleaseManifest>, UpdateError> {
|
||||
(**self).fetch_manifest(channel)
|
||||
}
|
||||
|
||||
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError> {
|
||||
(**self).fetch_artifact(artifact, dest)
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地目录源(也接受 `file://` 根地址)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DirSource {
|
||||
|
||||
Reference in new issue
Block a user