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

+1
View File
@@ -51,6 +51,7 @@ mod download;
mod instances;
mod page_messages;
mod settings;
mod update_action;
impl App {
/// 一条消息进来,改状态并返回后续任务。
+3 -2
View File
@@ -6,8 +6,9 @@
//! `InstancesChanged` 快照刷新页面(不做乐观回显,与 M90 的启动/停止同一条纪律);
//! 3. **恢复跟随全局**只清表单(仍需用户点保存)——「点一下就写盘」会让误触直接生效。
//!
//! 脏标记的清理点在保存之后:`refresh_view` 的 `LaunchForm::sync` 看到「同实例 + 脏」不会覆盖,
//! 所以保存后要显式清脏,让下一次快照(host 写完文件回来的那份)重播种。
//! **清脏的唯一时机是「回程快照等于表单」**:`LaunchForm::sync` 在「同实例 + 脏」时只在
//! `matches(&view)` 成立时才清脏并重播种,其余情况原样保留草稿。保存本身**不清脏**——落盘成败
//! 要等回程事件,写盘失败时旧快照回来必须留住用户敲的值(M157 r3 复审 P2-2)。
use iced::Task;
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch};
@@ -54,6 +54,8 @@ impl App {
}
}
pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)),
// M159:自更新动作(检查 / 下载安装 / 忽略 / 立即重启 / 开关自动检查)
pages::Message::Update(action) => return self.on_update_action(action),
pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)),
pages::Message::Appearance(change) => self.on_appearance(change),
// M101:设置页的四条填页线共用一个投递口;组内变体在 `update/settings.rs` 里穷尽分发
@@ -0,0 +1,158 @@
//! 自更新动作的落点(M159):`pages::Message::Update(UpdateAction)` → presenter 指令 / 重启。
//!
//! 三条纪律:
//!
//! 1. **不做乐观回显**:点了「下载并安装」不改本地状态,等 `CoreEvent::Update` 回来才变
//! (与启动/安装同一条:本地状态与事件流打架时,界面会自说一套);
//! 2. 更新的**判断**(要不要提示、失败算什么、版本比较)全在 core/presenter,这里只转发;
//! 3. 「立即重启」是本文件唯一碰进程的地方:先按同样的命令行拉起新进程,再退出自己——
//! 正因为替换已经完成(新 exe 就在原路径上),拉起来的就是新版本。
use iced::Task;
use unlucky_presenter::dto::AppCommand;
use crate::pages::UpdateAction;
use super::super::message::Message;
use super::super::state::App;
impl App {
/// 一条更新动作。
pub(super) fn on_update_action(&mut self, action: UpdateAction) -> Task<Message> {
match action {
UpdateAction::CheckNow => self.dispatch(AppCommand::CheckUpdate),
UpdateAction::Apply => self.dispatch(AppCommand::ApplyUpdate),
UpdateAction::Dismiss => self.dispatch(AppCommand::DismissUpdate),
UpdateAction::SetAutoCheck(on) => self.dispatch(AppCommand::SetUpdateAutoCheck(on)),
UpdateAction::RestartNow => return self.restart_now(),
}
Task::none()
}
/// 立即重启:按同样的命令行拉起自己,然后退出。
///
/// 截图通道(`--screenshot`)**不重启**:那条路每跑一次就该落一张图然后退出,重启会覆盖同一
/// 文件、并把取证进程拖成死循环。
fn restart_now(&mut self) -> Task<Message> {
if self.options.screenshot.is_some() {
eprintln!("[update] 截图通道不重启(忽略「立即重启」)");
return Task::none();
}
match std::env::current_exe() {
Ok(exe) => {
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
match std::process::Command::new(&exe).args(&args).spawn() {
Ok(child) => eprintln!(
"[update] 已拉起新进程 pid={}:{}",
child.id(),
exe.display()
),
Err(error) => {
eprintln!("[update] 拉起新进程失败:{error}(请手动重启启动器)");
return Task::none();
}
}
}
Err(error) => {
eprintln!("[update] 取 current_exe 失败:{error}");
return Task::none();
}
}
iced::exit()
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use iced::Task;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::{AppCommand, AppEvent, Backend};
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::app::state::App;
use crate::cli::Options;
use crate::pages::UpdateAction;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 记录命令流的假后端:把 `dispatch` 收到的每一条抄进共享表(动作接线断没断靠它判)。
struct Recorder {
seen: Arc<Mutex<Vec<AppCommand>>>,
}
impl Backend for Recorder {
fn drain_events(&mut self) -> Vec<AppEvent> {
Vec::new()
}
fn dispatch(&mut self, command: AppCommand) {
self.seen
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.push(command);
}
fn tick(&mut self, _elapsed: std::time::Duration) {}
}
fn app() -> (App, Arc<Mutex<Vec<AppCommand>>>) {
let seen = Arc::new(Mutex::new(Vec::new()));
let app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(Recorder {
seen: Arc::clone(&seen),
}),
SettingsBridge::memory_only(),
);
(app, seen)
}
fn last(seen: &Arc<Mutex<Vec<AppCommand>>>) -> AppCommand {
seen.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.last()
.cloned()
.expect("应当派发过一条命令")
}
/// 四个转发动作各自交给后端**对应的**指令(串线会红)。
#[test]
fn every_update_action_reaches_the_backend() {
let (mut app, seen) = app();
let _: Task<_> = app.on_update_action(UpdateAction::CheckNow);
assert_eq!(last(&seen), AppCommand::CheckUpdate);
let _ = app.on_update_action(UpdateAction::Apply);
assert_eq!(last(&seen), AppCommand::ApplyUpdate);
let _ = app.on_update_action(UpdateAction::Dismiss);
assert_eq!(last(&seen), AppCommand::DismissUpdate);
let _ = app.on_update_action(UpdateAction::SetAutoCheck(false));
assert_eq!(last(&seen), AppCommand::SetUpdateAutoCheck(false));
}
/// 截图通道不重启:取证进程不许把自己重启成死循环。
#[test]
fn the_screenshot_channel_never_restarts() {
let (mut app, seen) = app();
app.options.screenshot = Some(std::path::PathBuf::from("C:/Temp/shot.png"));
let _ = app.on_update_action(UpdateAction::RestartNow);
assert!(
seen.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.is_empty(),
"重启不是一条后端指令(不该派发任何命令)"
);
}
}
+4
View File
@@ -24,9 +24,11 @@ mod account_page;
mod instances;
mod logs;
mod rail;
mod update;
use instances::{detail_of, runtime_status, supporting_line};
use logs::logs_model;
use update::update_of;
impl App {
/// 主题名(iced 只在 `Theme::name()` 变化时重算受主题影响的缓存)。
@@ -178,6 +180,8 @@ pub(super) fn model(
// M109:检索条件/结果来自快照,页面内选择(类型/选中/目标/草稿)来自 `App::content`
content: pages::content::Content::from_snapshot(state, content, lang.catalog()),
logs: logs_model(state).with_status(lang),
// M159:更新快照 → 页面视图(设置页「更新」区 + 顶部提示条都读它)
update: update_of(&state.update),
lang,
forced: forced.map(|state| match state {
// `Rest` = 常态(压掉系统光标 hover);`Interaction::Enabled` 就是静息态,不叠状态层。
+157
View File
@@ -0,0 +1,157 @@
//! presenter 的更新快照 → 页面视图模型(M159 的**唯一**展示转换点)。
//!
//! 与 `view/instances.rs` 同一条分工:判断(要不要提示、是不是失败)在 presenter / core 已经做完,
//! 这里只把字段摆成「要画什么」。三条映射必须与 DTO 的判据保持一致:
//!
//! | DTO | 页面视图 |
//! |---|---|
//! | `UpdateOutcome::UpToDate` | [`UpdateStatus::UpToDate`](**不是**失败) |
//! | `UpdateOutcome::Failed` | [`UpdateStatus::Failed`](带原因,**不许**画成「已是最新」) |
//! | `UpdateOutcome::MissingArtifact` | [`UpdateStatus::MissingArtifact`](发布侧漏项,单独一句话) |
use unlucky_presenter::dto::{UpdateOutcome, UpdateView};
use crate::pages::{Update, UpdateStatus};
/// 快照 → 视图模型。
pub(super) fn update_of(view: &UpdateView) -> Update {
Update {
current_version: view.current_version.clone(),
channel: view.channel.clone(),
auto_check: view.auto_check,
status: status_of(&view.outcome),
checked_at: view.checked_at.clone(),
applying: view.applying,
ready: view.ready.as_ref().map(|ready| ready.version.clone()),
dismissed: view.dismissed,
silent: view.silent,
}
}
/// 结论 → 页面状态(四种结论一一对应,**不折叠**)。
fn status_of(outcome: &UpdateOutcome) -> UpdateStatus {
match outcome {
UpdateOutcome::Idle => UpdateStatus::Idle,
UpdateOutcome::Checking => UpdateStatus::Checking,
UpdateOutcome::UpToDate { latest } => UpdateStatus::UpToDate {
latest: latest.clone(),
},
UpdateOutcome::Available(candidate) => UpdateStatus::Available {
version: candidate.version.clone(),
size: candidate.size,
notes: candidate.notes.clone(),
forced: candidate.forced,
},
UpdateOutcome::MissingArtifact { version } => UpdateStatus::MissingArtifact {
version: version.clone(),
},
UpdateOutcome::Failed(failure) => UpdateStatus::Failed {
reason: failure.message.clone(),
retryable: failure.retryable,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_presenter::dto::{ReadyUpdate, UpdateFailureView};
fn view(outcome: UpdateOutcome) -> UpdateView {
let mut view = UpdateView::new("0.2.2", "stable", true);
view.outcome = outcome;
view
}
#[test]
fn failure_is_never_mapped_to_up_to_date() {
let mapped = update_of(&view(UpdateOutcome::Failed(UpdateFailureView::new(
"network",
"取更新清单失败:连接被重置",
true,
))));
assert!(
matches!(&mapped.status, UpdateStatus::Failed { reason, retryable } if *retryable && !reason.is_empty()),
"失败必须原样带原因,实际 {:?}",
mapped.status
);
assert!(mapped.banner().is_some(), "主动查出来的失败要弹提示条");
// 静默检查的失败:快照里仍是失败,但不弹条(离线是常态)。
let mut silent = view(UpdateOutcome::Failed(UpdateFailureView::new(
"network",
"连不上",
true,
)));
silent.silent = true;
let mapped = update_of(&silent);
assert!(matches!(mapped.status, UpdateStatus::Failed { .. }));
assert!(mapped.banner().is_none());
}
#[test]
fn the_four_outcomes_stay_distinct() {
let available = update_of(&view(UpdateOutcome::Available(Box::new(
unlucky_presenter::dto::UpdateCandidateView {
version: "0.2.3".to_string(),
size: 21_000_000,
notes: "修掉启动崩溃".to_string(),
forced: false,
},
))));
assert!(available.available().is_some());
assert!(matches!(
available.banner(),
Some(crate::pages::Banner::Available { .. })
));
let up_to_date = update_of(&view(UpdateOutcome::UpToDate {
latest: Some("0.2.2".to_string()),
}));
assert!(matches!(up_to_date.status, UpdateStatus::UpToDate { .. }));
assert!(up_to_date.banner().is_none());
let missing = update_of(&view(UpdateOutcome::MissingArtifact {
version: "0.2.3".to_string(),
}));
assert!(matches!(
missing.status,
UpdateStatus::MissingArtifact { .. }
));
}
#[test]
fn a_ready_update_turns_the_banner_into_the_restart_prompt() {
let mut view = view(UpdateOutcome::UpToDate {
latest: Some("0.2.3".to_string()),
});
view.ready = Some(ReadyUpdate {
version: "0.2.3".to_string(),
path: "C:/ULL/unlucky-launcher.exe".to_string(),
backup: None,
});
let mapped = update_of(&view);
assert_eq!(mapped.ready.as_deref(), Some("0.2.3"));
assert!(matches!(
mapped.banner(),
Some(crate::pages::Banner::Ready { .. })
));
}
#[test]
fn a_dismissed_update_keeps_the_snapshot_but_hides_the_banner() {
let mut view = view(UpdateOutcome::Available(Box::new(
unlucky_presenter::dto::UpdateCandidateView {
version: "0.2.3".to_string(),
size: 1,
notes: String::new(),
forced: false,
},
)));
view.dismissed = true;
let mapped = update_of(&view);
assert!(mapped.dismissed);
assert!(mapped.available().is_some(), "候选要留着给设置页");
assert!(mapped.banner().is_none());
}
}