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