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

@@ -0,0 +1,145 @@
//! 会话状态的用例:拒绝后不再打扰、关掉自动检查后启动不派发、偏好跨重启、GUI/CLI 同一内核。
//!
//! 夹具在父模块,本文件 `use super::*`。
use super::*;
// ------------------------------------------------------------------ 会话状态
/// 拒绝之后提示条消失,但候选留着;手动检查把「拒绝」清掉。
#[test]
fn dismissed_stops_the_banner_but_a_manual_check_revives_it() {
let tmp = TempDir::new("dismiss");
let mut runtime = Runtime::with_factory(&HostEnv::at_root(tmp.join("root")), factory(&tmp));
runtime.adopt(Report::Checked(UpdateOutcome::Available(Box::new(
crate::dto::UpdateCandidateView {
version: "0.9.0".to_string(),
size: 12,
notes: String::new(),
forced: false,
},
))));
assert!(matches!(
runtime.view().banner(),
Some(UpdateBanner::Available(_))
));
runtime.dismiss();
assert!(runtime.view().banner().is_none(), "拒绝后本次不再打扰");
assert!(
runtime.view().outcome.candidate().is_some(),
"候选不能丢:设置页还要能手动装"
);
// 静默检查**回来之后**也不许把拒绝弹回来(`adopt` 才是真正写状态的那一步)
let _ = runtime.begin_check(false).expect("静默检查派发");
runtime.adopt(Report::Checked(UpdateOutcome::Available(Box::new(
crate::dto::UpdateCandidateView {
version: "0.9.0".to_string(),
size: 12,
notes: String::new(),
forced: false,
},
))));
assert!(
runtime.view().dismissed,
"静默检查的结论回来时也不能清掉用户的拒绝"
);
assert!(
runtime.view().banner().is_none(),
"被拒绝的版本不许因为一次静默检查又弹出来"
);
// 手动检查 ⇒ 清掉拒绝,重新查
let job = runtime.begin_check(true).expect("手动检查派发");
assert!(!runtime.view().dismissed, "手动检查要清掉拒绝标记");
assert!(runtime.view().outcome.is_checking());
// 静默检查(启动时)**不**清拒绝标记
runtime.adopt(Report::Checked(UpdateOutcome::UpToDate { latest: None }));
runtime.dismiss();
let _ = runtime.begin_check(false).expect("静默检查派发");
assert!(runtime.view().dismissed, "静默检查不许把用户的拒绝弹回来");
drop(job);
}
/// 关掉「启动时检查」之后:状态上是 no-op、启动路径上根本不派发。
#[test]
fn auto_check_off_means_no_request_on_start() {
let tmp = TempDir::new("autocheck");
let env = HostEnv::at_root(tmp.join("root"));
let prefs_path = env.layout.cache().join("update").join("prefs.json");
Prefs { auto_check: false }
.save(&prefs_path)
.expect("写偏好");
let runtime = Runtime::with_factory(&env, factory(&tmp));
assert!(!runtime.view().auto_check, "开关要读进快照");
assert!(
!runtime.should_check_on_start(),
"关掉开关后启动不许派发检查"
);
// 结构性判据:`refresh_all` 里那次静默检查必须挂在 `should_check_on_start` 上,
// 否则单测绿着而启动仍然联网(这条读源码,理由同 `instances/tests.rs` 的源码断言)。
let host = include_str!("mod.rs");
assert!(
host.contains("if self.update.should_check_on_start()"),
"refresh_all 的静默检查必须由 should_check_on_start 门控"
);
}
/// 偏好能落盘、能读回(换一份 Runtime 仍记得上次的选择)。
#[test]
fn the_auto_check_preference_survives_a_restart() {
let tmp = TempDir::new("prefs");
let env = HostEnv::at_root(tmp.join("root"));
let mut runtime = Runtime::with_factory(&env, factory(&tmp));
assert!(runtime.prefs().auto_check, "缺省开");
runtime.set_auto_check(false).expect("落盘");
let restarted = Runtime::with_factory(&env, factory(&tmp));
assert!(!restarted.prefs().auto_check, "重启后仍记得关掉");
assert!(!restarted.view().auto_check);
}
/// GUI 与 CLI 共用**同一个** `Job::apply`(mission:不许两套逻辑)。
///
/// 判据是源码锚点:CLI 的 `update apply` 必须调 `Job::apply`,而不是自己拼一遍
/// 「下载 + 校验 + 替换」。
#[test]
fn the_gui_and_the_cli_share_one_kernel_path() {
let cli = include_str!("../../../cli/src/commands/update.rs");
assert!(
cli.contains("Job::") && cli.contains(".apply("),
"CLI 必须复用 presenter 的 Job::apply(同一内核),实际没有调用"
);
// 反向:check 也只许走 Job::check
assert!(
cli.contains(".check("),
"CLI 的 update check 必须走 Job::check"
);
}
/// 辅助:装配自证用的是哪份源与哪个替换目标(日志/诊断用)。
#[test]
fn the_factory_names_its_source_and_target() {
let tmp = TempDir::new("source-id");
let factory = factory(&tmp);
assert_eq!(factory.dir(), Some(tmp.join("dist").as_path()));
assert_eq!(factory.dest(), tmp.join("bin/unlucky-launcher.exe"));
assert_eq!(CHANNEL.as_str(), "stable", "D50:先只发 stable");
}
/// CLI 与 GUI 用的是**同一个** core Job:源码锚点,防止有人另起一条流程。
#[test]
fn the_core_job_is_the_single_kernel() {
let job = include_str!("../../../core/src/update/job.rs");
assert!(
job.contains("pub fn apply(") && job.contains("pub fn check("),
"内核必须同时提供 check / apply"
);
assert!(
job.contains("install_staged(") && job.contains("stage("),
"替换与校验必须在内核里做(壳层不许自己拼)"
);
}