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());
}
}
+8
View File
@@ -106,6 +106,14 @@ pub fn run(request: Request) -> Result<(), String> {
request.flavor,
);
state.apply_env();
// M159:`ULL_SETTINGS_GROUP=<下标>` 在 harness 里也生效(产品路径走
// `app/state/construct.rs` 的同一条判据)。没有它就只能拍默认的「General」组,
// 而设置页有五组——「更新」组的验收图正是靠它拍出来的。
if let Some(index) =
crate::ui::lang::screenshot_settings_group(request.screenshot.is_some())
{
state.selected_group = index;
}
eprintln!(
"[harness] 外观真状态:window={:.2} panel={:.2}(生效 {:.2})bg={} fill={:?} dim={:.2} \
配色来源={:?} 选中分组={}",
+3 -2
View File
@@ -92,8 +92,9 @@ pub(crate) fn launch_facts() -> LaunchFacts {
/// 演示用的表单初值(页面状态的所有者是 `App`;harness 没有 `App`,自己持有一份)。
///
/// 返回**拥有**的表单而不是 `&'static`:`text_editor::Content` 内含 `RefCell`,不满足
/// `Sync`,做不成 `static`;harness/测试各自持有即可(一帧借用一次)。
/// 返回**拥有**的表单而不是 `&'static`:出图/单测各自持有一份即可(一帧借用一次),
/// 而 `static` 会让所有出图路径共享同一份可编辑状态(两个参数的控件现在都是单行
/// `field::text_field` + `String`,不再是早先的多行编辑器)。
pub(crate) fn demo_form() -> LaunchForm {
LaunchForm::from_view(Some(DEMO[SELECTED].name), &demo_view())
}
@@ -6,7 +6,7 @@
//! | 游戏参数(多行) | 实例层,追加在命令行最后 | [`Message::Game`] |
//! | 最大内存(单行数字) | 实例层;留空 = 跟随全局默认 | [`Message::Memory`] |
//! | Java 可执行文件(单行) | 实例层;留空 = 跟随全局 → 自动挑 | [`Message::Java`] |
//! | 图形后端(三态 chips) | `default` / `opengl` / `vulkan`;**版本不够时整体灰化 + 提示** | [`Message::Backend`] |
//! | 图形后端(三态 chips) | `default` / `opengl` / `vulkan`;**版本不够时只灰化后两档**(`default` 始终可点)+ 提示 | [`Message::Backend`] |
//!
//! 两条判据刻意不在这里重算:
//! 1. **能不能用 `--graphicsBackend`** 来自 [`LaunchFacts::allowed`](core 的 `graphics_backend_allowed`,
@@ -150,7 +150,26 @@ fn text_field_with_hint<'a>(
.into()
}
/// 三态后端:版本不够时整体禁用(chips 不带 `on_press`)+ 一行门槛提示。
/// 三态后端的「不下发」档(`GraphicsBackend::Default` 的稳定 id)。
///
/// UI 侧不依赖 core(分层:UI → presenter → core),所以在页面里落一个常量;
/// 词表本身仍来自 presenter 快照([`LaunchFacts::options`]),这里只认「哪一个是安全档」。
const BACKEND_DEFAULT: &str = "default";
/// 某一档 chip 在门限不满足时**是否仍可点**。
///
/// 抽成纯函数是为了让它可断言(`Element` 没有内省接口,画出来的禁用态测不到;
/// 见 `instances/tests/launch_args.rs`)。
pub(crate) fn backend_chip_enabled(allowed: bool, id: &str) -> bool {
allowed || id == BACKEND_DEFAULT
}
/// 三态后端:**版本不够时只灰掉 `opengl` / `vulkan`**,`default` 始终可点。
///
/// 为什么 `default` 不许一起灰:它是「不下发 `--graphicsBackend`」的安全档,写它永远不会被
/// core 的版本门限拒绝。一起灰掉的话,用户手改实例文件、或在 CLI 里给旧版本实例写下
/// `graphicsBackend: vulkan` 之后,UI 上就**回不到 `default`**——唯一的出路是「恢复跟随全局」,
/// 而那会连带清空 JVM / 游戏参数 / 内存 / Java 四项(M157 复审 P3)。
fn backend_row<'a>(
ui: &'a Ui,
facts: &'a LaunchFacts,
@@ -162,10 +181,11 @@ fn backend_row<'a>(
for id in &facts.options {
let label = texts.backend_label(id).unwrap_or(id.as_str());
let selected = form.backend == *id;
let spec = if allowed {
Chip::filter(label, selected, page(Message::Backend(id.clone())))
let spec = Chip::filter(label, selected, page(Message::Backend(id.clone())));
let spec = if backend_chip_enabled(allowed, id) {
spec
} else {
Chip::filter(label, selected, page(Message::Backend(id.clone()))).disabled()
spec.disabled()
};
chips = chips.push(chip(ui, spec));
}
@@ -4,6 +4,9 @@ use super::*;
/// M120 的文案守门(中文无英文词 / 切语言真的变 / 页面里不许写死英文案·白名单)。
mod i18n;
/// M157 的门限判据(三态 chips 在版本不够时谁可点)。
mod launch_args;
use super::super::layout::source;
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass};
@@ -0,0 +1,55 @@
//! 「启动参数」区(M157)的**门限**判据:三态 chips 在版本不够时谁可点。
//!
//! 为什么只测一个纯函数:iced 0.14 的 `Element` 没有内省接口,画出来的「禁用态」在测试里
//! 拿不到(`Chip::disabled` 只是把 `on_press` 清成 `None`)。所以把判据抽成
//! [`backend_chip_enabled`] 并在这里钉住——页面那一行必须调它(否则这条绿着也没意义,
//! 守门形状与 `instances/tests.rs` 的源码断言同一取舍)。
use super::super::launch_args::backend_chip_enabled;
use crate::pages::LaunchFacts;
/// 门限满足:三档全可点。
#[test]
fn every_backend_chip_is_clickable_when_the_gate_is_open() {
for id in ["default", "opengl", "vulkan"] {
assert!(backend_chip_enabled(true, id), "门限满足时 {id} 必须可点");
}
}
/// 门限不满足:只有 `default` 可点(它是「不下发」的安全档)。
///
/// 抓得住:把 `default` 一起灰掉(M157 复审 P3 的原状)——那时旧版本实例上写了
/// `graphicsBackend: vulkan` 的用户在 UI 里回不到 `default`,只能「恢复跟随全局」
/// (连带清空 JVM / 游戏参数 / 内存 / Java 四项)。
#[test]
fn the_default_chip_stays_clickable_when_the_gate_is_closed() {
assert!(
backend_chip_enabled(false, "default"),
"门限不满足时 default 也必须可点:它是唯一永远合法的档"
);
for id in ["opengl", "vulkan"] {
assert!(
!backend_chip_enabled(false, id),
"门限不满足时 {id} 必须灰掉(下发会被 core 拒绝)"
);
}
}
/// 词表与判据不许脱节:`default` 必须真在这份快照的 options 里,否则「安全档」无从点起。
#[test]
fn the_gate_rule_covers_the_demo_option_table() {
let facts = LaunchFacts {
options: ["default", "opengl", "vulkan"]
.iter()
.map(|id| (*id).to_string())
.collect(),
..LaunchFacts::default()
};
assert!(
facts
.options
.iter()
.any(|id| backend_chip_enabled(false, id)),
"门限关闭时至少有一档可点(否则三态整体锁死)"
);
}
+22
View File
@@ -93,12 +93,34 @@ pub enum Message {
Content(content::Message),
/// 实例页「启动参数」区的消息(**M157**):编辑写进页面状态,保存才 dispatch。
Instances(super::instances::InstanceMessage),
/// 自更新动作(**M159**):提示条与设置页「更新」区共用一支。
///
/// 为什么是顶层变体而不是又一个 `GroupMessage`:更新动作是**跨页的一次性意图**
/// (检查 / 下载安装 / 忽略 / 立即重启),不是某一组的内部状态;设置页的「更新」组也不持有
/// 任何字段(快照在 presenter 里)。与 M106 的 [`Self::Language`] 同一取舍,偏离已登记在
/// `pages/settings/update.rs` 的模块文档里。
Update(UpdateAction),
/// 账户页消息(**M114**:三轨登录 / 列表动作 / 表单输入;app 层翻成 `AppCommand`)。
///
/// 与 `Settings` 同一种做法:账户页自己的消息枚举在 `pages/account.rs`,这里只加一支入口。
Account(crate::pages::account::Message),
}
/// 自更新的用户动作(M159)。落点是 `app/update/update_action.rs`。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpdateAction {
/// 立即检查(手动;会清掉「忽略这个版本」)
CheckNow,
/// 下载 → 校验 → 替换(提示条与设置页共用一个动作)
Apply,
/// 忽略这个版本(本次会话不再弹提示条;设置页仍可手动装)
Dismiss,
/// 立即重启(新版本已就位,重启后生效)
RestartNow,
/// 开关「启动时检查更新」
SetAutoCheck(bool),
}
/// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
+22 -4
View File
@@ -108,6 +108,7 @@ pub mod frame;
pub mod harness;
pub mod layout;
pub mod model;
pub mod update;
pub mod account;
pub mod content;
@@ -130,15 +131,17 @@ pub(crate) use instances::demo_form as demo_launch_form;
pub use instances::{InstanceMessage, LaunchForm};
pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome};
pub use message::{Action, Message};
pub use message::{Action, Message, UpdateAction};
pub use model::{Detail, Instances, LaunchFacts, Meta, Model, Row};
pub use screen::Screen;
pub use settings::Language;
pub use update::{Banner, Update, UpdateStatus};
use iced::widget::{column, container, row, space};
use iced::{Element, Length};
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::spacing::Space;
use crate::theme::Ui;
@@ -222,14 +225,27 @@ fn render<'a>(
) -> Element<'a, Message> {
let metrics = layout::frame_metrics(model.screen.kit(), model.window_width);
match model.screen {
let page = match model.screen {
Screen::Instances => instances::view(ui, metrics, model, launch_form),
// M106:下载页此前写死成 `Lang::En`,现在跟 `model.lang`(页面文案跟着界面语言走)。
Screen::Download => download::view(ui, metrics, &model.download, model.lang),
// M109:内容库页同样跟 `model.lang`
Screen::Content => content::view(ui, metrics, &model.content, model.lang),
Screen::Settings => settings::view(ui, metrics, states, language),
// M159:设置页「更新」组要画 presenter 的更新快照(不在 `States` 里,理由见该组文档)。
Screen::Settings => settings::view(ui, metrics, states, language, &model.update),
Screen::Logs => logs::view(ui, metrics, &model.logs, model.lang),
};
// M159:自更新提示条挂在**每一页**的页头之上(有新版 / 正在下载 / 已就位时才出现)。
// 为什么在这里而不是外壳里:外壳(`shell`)只认窗口装饰与导航,页面层才能按快照决定画什么;
// 而「每一页都要能看见」这条要求又决定了它不能只挂在实例页上。
match update::banner(ui, &model.update, model.lang.catalog()) {
Some(banner) => column![banner, page]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.height(Length::Fill)
.into(),
None => page,
}
}
@@ -295,7 +311,9 @@ pub fn demo_model(screen: Screen, window_width: f32) -> Model {
Screen::Download => model.download = download::demo(unlucky_i18n::Lang::En.catalog()),
Screen::Content => model.content = content::demo(content::ContentKind::Mod, model.lang),
Screen::Logs => model.logs = logs::demo(model.lang),
Screen::Settings => {}
// M159:设置页「更新」组的出图夹具(一个可装的新版本 + 一次成功检查)。
// 只给这一页:其它页的基线图(尤其实例页的启动参数区)不该因为更新提示条而整体下移。
Screen::Settings => model.update = update::demo(),
}
model
}
+4
View File
@@ -21,6 +21,7 @@ use crate::app::components::interaction::Interaction;
use super::content::Content;
use super::logs::Logs;
use super::update::Update;
use super::{Download, Screen};
/// 实例列表的一行(`ListItem` 的槽位:overline = 运行状态,headline = 名字,supporting = 版本)。
@@ -116,6 +117,8 @@ pub struct Model {
pub logs: Logs,
/// 内容库页的数据(M109:检索条件 / 命中项 / 装入闸门,见 [`super::content`])。
pub content: Content,
/// 自更新的展示状态(M159:设置页「更新」区 + 顶部提示条,见 [`super::update`])。
pub update: Update,
/// 界面语言(M106):页面文案走 i18n key,语言由 app 层决定后灌进来。
///
/// 谁填:`app/state.rs` 的 `App::refresh_view`(`LangState::lang()` → `view::model(..)`),
@@ -141,6 +144,7 @@ impl Model {
logs: Logs::default(),
// 演示模型也从这个空壳起步(`demo_model` 再按页面填数据)
content: super::content::demo(unlucky_presenter::dto::ContentKind::Mod, Lang::En),
update: Update::default(),
lang: Lang::En,
forced: None,
}
+31 -4
View File
@@ -50,6 +50,12 @@
//! 字段、[`message::GroupMessage`] 加一个变体、`app/update/settings.rs` 加一条臂。**不需要**动 `frame.rs`、
//! 别的分组文件或 `pages/mod.rs`。
//!
//! **M159 的第五组(更新)是另一条形状**:它不持有组状态、也不走 `GroupMessage`,而是读
//! `settings::view` 的 `update` 形参(presenter 快照),由 [`UPDATE_GROUP`] 按下标分发——与 M106 的
//! 语言行同一取舍(那两样都不是「某一组的内部状态」)。所以加它只动了 ① `pages/settings/update.rs`、
//! ② 本文件的 [`GROUPS`] / [`GROUP_LABELS`] / `render` 的形参、③ `pages/mod.rs` 与 `pages/model.rs`
//! (模型多一格 `update`),**没有**碰 `state::Groups` 与 `message::GroupMessage`。
//!
//! ## 消息:一个投递口,四条线各写各的(M101 `SETTINGSMSG`)
//!
//! 四条填页线都要往 `pages::Message` 里加变体,而那是**一个**枚举加上
@@ -135,6 +141,7 @@ pub(crate) mod java;
pub(crate) mod language_appearance;
mod message;
mod state;
pub(crate) mod update;
#[cfg(test)]
mod tests;
@@ -154,6 +161,7 @@ use unlucky_i18n::{Lang, LangChoice};
use crate::theme::Ui;
use super::layout::{FrameMetrics, PageLayout};
use super::update::Update;
use super::Message;
/// 设置页 General 分组要的两件(M106):用户选的档位 + 这一次生效的语言。
@@ -207,11 +215,14 @@ struct Group {
/// 分组注册表:**顺序 = 左列自上而下**,也是 `crate::pages::appearance::APPEARANCE_GROUP` 的下标来源。
///
/// 每一项都指向它自己文件里的 `GROUP`(M99 之前是四份字面量挤在本文件里)。
const GROUPS: [Group; 4] = [
const GROUPS: [Group; 5] = [
general::GROUP,
appearance::GROUP,
java::GROUP,
download::GROUP,
// M159:自更新只**追加**在末尾(插在中间会让 `APPEARANCE_GROUP` / `GENERAL_GROUP` 两个下标
// 全部失效,`tests/mod.rs` 的「左列顺序」那条也会红)。
update::GROUP,
];
/// 演示选中的分组(没有真实外观状态时——`pages_demo` harness——用它)。
@@ -230,13 +241,21 @@ const GENERAL_GROUP: usize = 0;
///
/// 为什么不放进 [`Group`]:那会让另外三组的 `GROUP` 字面量(各自文件、各自填页线)编译不过——
/// 与 [`GENERAL_GROUP`] 同一条理由。`Group.name` 因此退化为**id**(测试按它认组;显示走这张表)。
pub(super) const GROUP_LABELS: [unlucky_i18n::Key; 4] = [
pub(super) const GROUP_LABELS: [unlucky_i18n::Key; 5] = [
unlucky_i18n::Key::SettingsGroupGeneral,
unlucky_i18n::Key::SettingsGroupAppearance,
unlucky_i18n::Key::SettingsGroupJava,
unlucky_i18n::Key::SettingsGroupDownloads,
// M159
unlucky_i18n::Key::SettingsGroupUpdate,
];
/// 「更新」分组在 [`GROUPS`] 里的下标(真源在 `settings/update.rs`,这里只转发)。
///
/// 与 [`GENERAL_GROUP`] 同一种做法:这一组要画的是 **presenter 快照**(不在 [`States`] 里),
/// 所以由 `view` 的 `update` 形参一路递到下标的 [`form`] 分发点,`GROUP.body` 保持 `None`。
const UPDATE_GROUP: usize = update::UPDATE_GROUP;
/// 整页。
///
/// `states` 是「画一帧时那份只读状态」:[`States::appearance`] 是 M93 接进来的外观运行期状态
@@ -246,6 +265,7 @@ pub fn view<'a>(
metrics: FrameMetrics,
states: States<'a>,
language: Language,
update: &'a Update,
) -> Element<'a, Message> {
// 选中分组:有真实外观状态时看状态(点左列可切),否则退回演示值 0。
let selected = states
@@ -256,9 +276,12 @@ pub fn view<'a>(
PageLayout::SupportingPane => frame::panes(
metrics,
frame::group_nav(ui, selected, language.effective),
frame::left_aligned(metrics.column, form(ui, Some(selected), states, language)),
frame::left_aligned(
metrics.column,
form(ui, Some(selected), states, language, update),
),
),
_ => frame::single_column(metrics, form(ui, None, states, language)),
_ => frame::single_column(metrics, form(ui, None, states, language, update)),
};
frame::page(ui, metrics, frame::header(ui, language.effective), body)
@@ -277,6 +300,7 @@ fn form<'a>(
only: Option<usize>,
states: States<'a>,
language: Language,
update: &'a Update,
) -> Element<'a, Message> {
let catalog = language.effective.catalog();
let mut stack = column![].spacing(ui.space(Space::X6)).width(Length::Fill);
@@ -294,6 +318,9 @@ fn form<'a>(
// 无关(harness 那条路径也画得出真控件)。
let card = if index == GENERAL_GROUP {
general::body(ui, language)
} else if index == UPDATE_GROUP {
// M159:更新组读 presenter 快照(`view` 的 `update` 形参),与 General 同一条按下标分发。
update::body(ui, update, language.effective)
} else {
group
.body
@@ -40,6 +40,7 @@ mod appearance_section;
mod download_section;
mod general_section;
mod java_section;
mod update_section;
use super::super::layout::{self, frame_metrics, measure_cap_px, PaneKit, SIDE_NAV_WIDTH};
use super::*;
@@ -128,7 +129,10 @@ fn the_appearance_group_is_where_the_module_says_it_is() {
#[test]
fn the_split_kept_the_left_column_order() {
let names: Vec<&str> = GROUPS.iter().map(|group| group.name).collect();
assert_eq!(names, vec!["General", "Appearance", "Java", "Downloads"]);
assert_eq!(
names,
vec!["General", "Appearance", "Java", "Downloads", "Updates"]
);
}
/// M101 r2:`settings/mod.rs` 里**不许出现任何一组的 `State` 类型**(r1 复审 P1 的判据钉在这里)。
@@ -193,6 +197,6 @@ fn the_settings_module_does_not_name_any_group_state() {
fn every_line_can_be_wired_alone() {
// 判据是「每条线只改自己的文件」,不是任何一条具体断言:这里只钉住架子本身的两件事——
// ① 每组都有 `GROUP`(成员齐);② 演示下标合法。真正的 per-group 断言在各自文件里。
assert_eq!(GROUPS.len(), 4, "四条线各一个分组");
assert_eq!(GROUPS.len(), 5, "四条填页线 + M159 的更新组");
assert!(SELECTED < GROUPS.len(), "演示下标仍在范围内");
}
@@ -0,0 +1,123 @@
//! 设置页「更新」分组(M159)的断言:注册表位置、四条状态各有各的句子。
//!
//! 为什么只测「词表 + 判据」而不测控件:iced 的 `Element` 没有内省接口(`tests/mod.rs` 顶部那条
//! 写过理由),上屏文本只能从**取词的那一个入口**取。这里守的是「四种结论的句子不是同一句」
//! ——正是 mission 里「检查失败不等于已是最新」那条硬要求的最小可测形态。
use unlucky_i18n::{Catalog, Key, Lang};
use super::super::update::{GROUP as UPDATE_GROUP_META, UPDATE_GROUP};
use super::super::{GROUPS, SELECTED};
use crate::pages::update::UpdateStatus;
use crate::pages::{Screen, Update};
/// 更新组**必须是最后一组**:加组只追加不重排,插到中间会让 `APPEARANCE_GROUP` / `GENERAL_GROUP`
/// 两个下标一起失效。
#[test]
fn the_update_group_sits_at_the_end_of_the_registry() {
assert_eq!(GROUPS.len(), UPDATE_GROUP + 1, "更新组之后不该还有别的组");
assert_eq!(GROUPS[UPDATE_GROUP].name, UPDATE_GROUP_META.name);
assert_eq!(GROUPS[UPDATE_GROUP].name, "Updates");
assert!(SELECTED < GROUPS.len());
}
/// 四种结论的句子**互不相同**,且「失败」那一句必须把原因带出来。
///
/// 抓得住:把「检查失败」写成「已是最新」(那是 mission 明令禁止的撒谎:断网时会说「你是最新」)。
/// 抓不住:句子翻得顺不顺、颜色对不对(那是人读与截图的事)。
#[test]
fn the_four_outcomes_read_differently() {
let catalog = Lang::ZhCn.catalog();
let up_to_date = catalog.render(Key::SettingsUpdateUpToDate, &[("version", "0.2.2")]);
let available = catalog.render(
Key::SettingsUpdateAvailable,
&[("version", "0.2.3"), ("size", "21.0 MB")],
);
let missing = catalog.render(Key::SettingsUpdateMissingArtifact, &[("version", "0.2.3")]);
let failed = catalog.render(
Key::SettingsUpdateFailed,
&[("reason", "取更新清单失败:连接被重置")],
);
let all = [&up_to_date, &available, &missing, &failed];
for (index, left) in all.iter().enumerate() {
for right in all.iter().skip(index + 1) {
assert_ne!(left, right, "两条结论说的是同一句话:{left} / {right}");
}
}
assert!(
failed.contains("连接被重置"),
"失败那一句必须带出原因,实际:{failed}"
);
assert!(
!failed.contains("0.2.2"),
"失败那一句不该冒出当前版本号(读起来像「已是最新」):{failed}"
);
}
/// 页面视图模型与 presenter 的判据同序:就位 > 进行中 > 失败(仅主动查) > 有新版本。
///
/// 两处各算一次(presenter 判据 + 页面视图),顺序必须一致,否则同一份状态在提示条与设置页上
/// 会各说一套。这里只钉页面这一侧的顺序;presenter 那一侧在 `presenter` 的 `dto::update` 单测里。
#[test]
fn the_banner_priority_is_ready_then_applying_then_failure_then_available() {
let mut update = Update {
current_version: "0.2.2".to_string(),
channel: "stable".to_string(),
auto_check: true,
status: UpdateStatus::Failed {
reason: "连不上".to_string(),
retryable: true,
},
checked_at: String::new(),
applying: true,
ready: None,
dismissed: false,
silent: false,
};
assert!(matches!(
update.banner(),
Some(crate::pages::Banner::Applying)
));
update.applying = false;
assert!(matches!(
update.banner(),
Some(crate::pages::Banner::Failed { .. })
));
update.status = UpdateStatus::Available {
version: "0.2.3".to_string(),
size: 1024,
notes: String::new(),
forced: false,
};
assert!(matches!(
update.banner(),
Some(crate::pages::Banner::Available { .. })
));
update.ready = Some("0.2.3".to_string());
assert!(matches!(
update.banner(),
Some(crate::pages::Banner::Ready { .. })
));
}
/// 演示模型里更新组能画(`pages_demo` 的 harness 路径):`Model.update` 有默认值,不是 `None` 才画。
#[test]
fn the_demo_model_carries_an_update_snapshot() {
let model = crate::pages::demo_model(Screen::Settings, 1180.0);
assert_eq!(model.update, crate::pages::update::demo());
assert!(
model.update.available().is_some(),
"演示快照要带一个可装的版本"
);
// 其它页不掺演示更新状态(实例页/下载页的基线图不该多出一条提示条)。
assert_eq!(
crate::pages::demo_model(Screen::Instances, 1180.0).update,
Update::default()
);
let catalog: Catalog = Lang::En.catalog();
assert!(!catalog.text(Key::SettingsGroupUpdate).is_empty());
}
@@ -0,0 +1,257 @@
//! 设置页「更新」分组(M159):当前版本 / 渠道 / 自动检查开关 / 立即检查 / 上次结果。
//!
//! ## 为什么这一组不走 `Group.body`
//!
//! `Group.body` 的签名是 `fn(&Ui, States) -> Option<Element>`,而 `States` 只有「外观状态 + 各组
//! 自己的状态」两格。更新状态是 **presenter 快照**(不是某一组的字段),与 M106 的语言行同一种
//! 情形:`settings/mod.rs` 按下标([`UPDATE_GROUP`])把 `settings::view` 的 `update` 形参交给
//! [`body`],`GROUP.body` 仍是 `None`。
//!
//! 消息也**不**走 `GroupMessage`:更新动作是「跨页的一次性意图」(检查 / 下载安装 / 忽略 /
//! 重启),落点是 `app/update/update_action.rs`,`pages::Message::Update(UpdateAction)` 一支就够。
//! 偏离已登记在本文件与 mission 报告里(与 M106 的 `Message::Language` 同一取舍:这两个都不是
//! 「某一组的内部状态」,折进 `GroupMessage` 只会多一层没人用的转运)。
//!
//! ## 四条状态行必须分开
//!
//! 「已是最新」「有新版本」「没有本平台产物」「检查失败」是四种不同的结论(mission 硬要求:
//! 失败不许说成最新)。第四种一定要把原因摆出来(连不上 / 清单坏了 / 渠道不符),
//! 否则用户只会看到一句「检查失败」而无从下手。
use iced::widget::{column, row};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key, Lang};
use unlucky_presenter::dto::human_bytes;
use crate::app::components::buttons::{self, Button};
use crate::app::components::selection::{chip, Chip};
use crate::pages::frame::text_role_owned;
use crate::pages::update::{Update, UpdateStatus};
use crate::pages::Message;
use crate::pages::UpdateAction;
use crate::theme::Ui;
use super::frame::{card, divider};
use super::Group;
/// 「更新」分组在 [`super::GROUPS`] 里的下标(**必须是最后一个**:加组只追加不重排)。
pub(super) const UPDATE_GROUP: usize = 4;
/// 更新分组(左列名固定英文:左列是导航,与导航轨同源,不进 catalog)。
pub(super) const GROUP: Group = Group {
name: "Updates",
icon: IconKind::Download,
// 只给 harness / 窄窗「按组堆叠」路径用的演示行:产品路径走 [`body`]。
rows: &[
("Current version", "0.2.2"),
("Channel", "Stable"),
("Check for updates on startup", "On"),
],
body: None,
};
/// 渲染整组正文。
pub(super) fn body<'a>(ui: &'a Ui, update: &'a Update, lang: Lang) -> Element<'a, Message> {
let catalog = lang.catalog();
let mut stacked = column![].spacing(0.0).width(Length::Fill);
stacked = stacked.push(info_row(
ui,
catalog.text(Key::SettingsUpdateCurrent),
update.current_version.clone(),
));
stacked = stacked.push(divider(ui));
stacked = stacked.push(info_row(
ui,
catalog.text(Key::SettingsUpdateChannel),
catalog.text(Key::SettingsUpdateChannelStable).to_string(),
));
stacked = stacked.push(divider(ui));
stacked = stacked.push(auto_check_row(ui, catalog, update.auto_check));
stacked = stacked.push(divider(ui));
stacked = stacked.push(status_block(ui, catalog, update));
card(ui, stacked)
}
/// 「标签 + 值」一行(与 `frame::setting_row` 同一套排版,但值要在调用处现算)。
fn info_row<'a>(ui: &'a Ui, label: &'a str, value: String) -> Element<'a, Message> {
let height = ui.metrics().control_height + Space::X2.dp();
row![
iced::widget::container(text_role_owned(
ui,
TypeRole::BodyMedium,
false,
label.to_string(),
ui.color(Role::OnSurface),
))
.width(Length::Fill)
.align_y(Alignment::Center),
text_role_owned(
ui,
TypeRole::BodySmall,
false,
value,
ui.color(Role::OnSurfaceVariant),
),
]
.height(height)
.width(Length::Fill)
.align_y(Alignment::Center)
.into()
}
/// 自动检查开关 + 「立即检查」按钮。
fn auto_check_row<'a>(ui: &'a Ui, catalog: Catalog, auto_check: bool) -> Element<'a, Message> {
row![
chip(
ui,
Chip::filter(
catalog.text(Key::SettingsUpdateAutoCheck),
auto_check,
Message::Update(UpdateAction::SetAutoCheck(!auto_check)),
),
),
buttons::text(
ui,
Button::new(
catalog.text(Key::SettingsUpdateCheckNow),
Message::Update(UpdateAction::CheckNow),
),
),
]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.align_y(Alignment::Center)
.padding(iced::Padding::from([0.0_f32, 0.0]))
.into()
}
/// 上次检查时间 + 结论 + (有候选时)下载按钮 + 说明摘要 + 一句校验提示。
fn status_block<'a>(ui: &'a Ui, catalog: Catalog, update: &'a Update) -> Element<'a, Message> {
let mut block = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
let checked = if update.checked_at.trim().is_empty() {
catalog.text(Key::SettingsUpdateNever).to_string()
} else {
update.checked_at.clone()
};
block = block.push(info_row(
ui,
catalog.text(Key::SettingsUpdateLastChecked),
checked,
));
let (text, error) = status_line(catalog, update);
let color = ui.color(if error {
Role::Error
} else {
Role::OnSurfaceVariant
});
block = block.push(text_role_owned(ui, TypeRole::BodySmall, false, text, color));
if let Some((_version, _size, notes, forced)) = update.available() {
let mut actions = row![buttons::filled(
ui,
Button::new(
catalog.text(Key::SettingsUpdateApply),
Message::Update(UpdateAction::Apply),
),
)]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
if forced {
actions = actions.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::SettingsUpdateForced).to_string(),
ui.color(Role::Error),
));
}
block = block.push(actions);
if !notes.trim().is_empty() {
block = block.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.render(Key::SettingsUpdateNotes, &[("notes", notes.trim())]),
ui.color(Role::OnSurfaceVariant),
));
}
}
if let Some(version) = &update.ready {
block = block.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.render(Key::SettingsUpdateReady, &[("version", version)]),
ui.color(Role::OnSurface),
));
block = block.push(buttons::tonal(
ui,
Button::new(
catalog.text(Key::UpdateBannerRestartNow),
Message::Update(UpdateAction::RestartNow),
),
));
}
block = block.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::SettingsUpdateHint).to_string(),
ui.color(Role::OnSurfaceVariant),
));
block.into()
}
/// 一条状态行(文案 + 是不是「错」那一类)。
///
/// 四种结论各写各的句子,**不共用**「已是最新」那句:失败/缺产物时那句就是撒谎。
fn status_line(catalog: Catalog, update: &Update) -> (String, bool) {
let (key, args, error) = match &update.status {
UpdateStatus::Idle => (Key::SettingsUpdateNever, Vec::new(), false),
UpdateStatus::Checking => (Key::SettingsUpdateChecking, Vec::new(), false),
UpdateStatus::UpToDate { latest } => {
let version = latest
.clone()
.unwrap_or_else(|| update.current_version.clone());
(
Key::SettingsUpdateUpToDate,
vec![("version", version)],
false,
)
}
UpdateStatus::Available { version, size, .. } => (
Key::SettingsUpdateAvailable,
vec![("version", version.clone()), ("size", human_bytes(*size))],
false,
),
UpdateStatus::MissingArtifact { version } => (
Key::SettingsUpdateMissingArtifact,
vec![("version", version.clone())],
true,
),
UpdateStatus::Failed { reason, .. } => (
Key::SettingsUpdateFailed,
vec![("reason", reason.clone())],
true,
),
};
let args: Vec<(&str, &str)> = args
.iter()
.map(|(name, value)| (*name, value.as_str()))
.collect();
(catalog.render(key, &args), error)
}
+379
View File
@@ -0,0 +1,379 @@
//! 自更新(M159)的**页面视图模型**:只放「要画什么」。
//!
//! 与 `presenter::dto::UpdateView` 的分工和本目录其它页一致(见 `pages/model.rs` 模块文档):
//! DTO 是 IPC 契约(含 core 语义字段),这里是纯展示字段,于是页面可以用演示数据喂同一个渲染函数。
//! 转换点只有一个:`app/view/update.rs`。
//!
//! ## 为什么状态是「一个有载荷的枚举」
//!
//! 「检查失败」与「已是最新」必须是两个变体(mission 硬要求):把失败折叠成
//! `UpToDate` 会让界面在断网时说「已是最新」,那是撒谎。`MissingArtifact`(有新版但没本平台产物)
//! 同理——它既不是「有新版本可装」,也不是「已是最新」。
/// 一次检查的结论(页面视图)。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum UpdateStatus {
/// 还没查过
#[default]
Idle,
/// 正在查
Checking,
/// 已是最新
UpToDate {
/// 该渠道内的最高版本(`None` = 还没有任何发布)
latest: Option<String>,
},
/// 有可装的更新
Available {
/// 新版本号
version: String,
/// 包体字节数
size: u64,
/// 更新说明(可能为空)
notes: String,
/// 是否强制
forced: bool,
},
/// 有更高版本,但没有本平台的产物
MissingArtifact {
/// 那个版本
version: String,
},
/// 检查/应用失败(**不是**已是最新)
Failed {
/// 人话原因
reason: String,
/// 换个时间再试是否可能变好
retryable: bool,
},
}
/// 顶部提示条此刻的形态。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Banner<'a> {
/// 有新版本
Available {
/// 新版本号
version: &'a str,
/// 包体字节数
size: u64,
/// 说明摘要
notes: &'a str,
},
/// 正在下载/校验
Applying,
/// 已就位,等重启
Ready {
/// 新版本号
version: &'a str,
},
/// 失败(只在用户主动查之后出现)
Failed {
/// 人话原因
reason: &'a str,
/// 是否可重试
retryable: bool,
},
}
/// 更新页 / 提示条的全部展示状态。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Update {
/// 当前版本
pub current_version: String,
/// 渠道(`stable`)
pub channel: String,
/// 启动时是否静默检查
pub auto_check: bool,
/// 检查结论
pub status: UpdateStatus,
/// 上次检查时间(空串 = 没查过)
pub checked_at: String,
/// 正在下载/校验/替换
pub applying: bool,
/// 已就位的新版本号(`None` = 没有)
pub ready: Option<String>,
/// 用户是否已忽略这个版本(本次会话)
pub dismissed: bool,
/// 这一份结论来自静默检查(启动时/自动):失败时**不**弹提示条
pub silent: bool,
}
impl Update {
/// 有没有可装的候选(有的话设置页给「下载并安装」)。
#[must_use]
pub fn available(&self) -> Option<(&str, u64, &str, bool)> {
match &self.status {
UpdateStatus::Available {
version,
size,
notes,
forced,
} => Some((version, *size, notes, *forced)),
_ => None,
}
}
/// 提示条此刻要显示什么(`None` = 不显示)。
///
/// 优先级与 presenter 侧同一条(就位 > 进行中 > 失败(仅主动查) > 有新版本(未被忽略)),
/// 这里再算一次是因为页面层拿不到 DTO 的那份判据——两处判据的顺序必须一致,
/// `pages/tests.rs` 有对拍用例守着。
#[must_use]
pub fn banner(&self) -> Option<Banner<'_>> {
if let Some(version) = &self.ready {
return Some(Banner::Ready { version });
}
if self.applying {
return Some(Banner::Applying);
}
if let UpdateStatus::Failed { reason, retryable } = &self.status {
return (!self.silent).then_some(Banner::Failed {
reason,
retryable: *retryable,
});
}
if self.dismissed {
return None;
}
match &self.status {
UpdateStatus::Available {
version,
size,
notes,
..
} => Some(Banner::Available {
version,
size: *size,
notes,
}),
_ => None,
}
}
}
/// 出图 / 单测用的演示快照(一个可装的新版本 + 一次成功的检查)。
///
/// **不是产品数据**:产品路径的更新状态只来自 presenter(`app/view/update.rs` 转换)。
#[must_use]
pub fn demo() -> Update {
Update {
current_version: "0.2.2".to_string(),
channel: "stable".to_string(),
auto_check: true,
status: UpdateStatus::Available {
version: "0.2.3".to_string(),
size: 21_500_000,
notes: "修掉启动崩溃;实例页加载更快".to_string(),
forced: false,
},
checked_at: "11-08 12:00".to_string(),
applying: false,
ready: None,
dismissed: false,
silent: false,
}
}
/// 「有新版本」那条提示条的正文:**体积与更新说明摘要都要给**(mission 明列的三件之二)。
///
/// 拆成函数是为了可断言(`Element` 没有内省接口):条数与措辞由 `tests` 的两条用例钉住。
#[must_use]
pub fn available_body(catalog: Catalog, size: u64, notes: &str) -> String {
let size = human_bytes(size);
let body = catalog.render(Key::UpdateBannerBodyAvailable, &[("size", &size)]);
if notes.trim().is_empty() {
body
} else {
format!("{body} · {}", notes.trim())
}
}
// --------------------------------------------------------------- 提示条
use iced::widget::{column, container, row};
use iced::{Alignment, Background, Border, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::human_bytes;
use crate::app::components::buttons::{self, Button};
use crate::pages::frame::text_role_owned;
use crate::pages::message::UpdateAction;
use crate::pages::Message;
use crate::theme::Ui;
/// 顶部提示条(每一页都在页头之上;`None` = 现在没有要说的)。
///
/// 形态三种:有新版本(可下载 / 可忽略)、正在下载、已就位(可立即重启)。**失败不在这里**
/// ——静默检查失败是常态,弹一条红条等于每次开机先骂网络一次;失败原因在设置页「更新」区看。
pub fn banner<'a>(
ui: &'a Ui,
update: &'a Update,
catalog: Catalog,
) -> Option<Element<'a, Message>> {
let kind = update.banner()?;
// 三种形态各自的底色:有新版 = 次级容器(可操作、非错误);进行中 = 同色但只读;
// 就位 = 主容器(「已经好了,重启即生效」是正反馈)。
let (title, body, actions, role): (String, String, Vec<Element<'a, Message>>, Role) = match kind
{
Banner::Available {
version,
size,
notes,
} => {
let mut actions = vec![
buttons::filled(
ui,
Button::new(
catalog.text(Key::UpdateBannerInstall),
Message::Update(UpdateAction::Apply),
),
),
buttons::text(
ui,
Button::new(
catalog.text(Key::UpdateBannerDismiss),
Message::Update(UpdateAction::Dismiss),
),
),
];
let _ = &mut actions;
(
catalog.render(Key::UpdateBannerTitleAvailable, &[("version", version)]),
available_body(catalog, size, notes),
actions,
Role::SecondaryContainer,
)
}
Banner::Applying => (
catalog.text(Key::UpdateBannerApplying).to_string(),
String::new(),
Vec::new(),
Role::SecondaryContainer,
),
Banner::Ready { version } => (
catalog.text(Key::UpdateBannerTitleReady).to_string(),
catalog.render(Key::UpdateBannerBodyReady, &[("version", version)]),
vec![buttons::filled(
ui,
Button::new(
catalog.text(Key::UpdateBannerRestartNow),
Message::Update(UpdateAction::RestartNow),
),
)],
Role::PrimaryContainer,
),
Banner::Failed { reason, .. } => (
catalog.text(Key::UpdateBannerTitleFailed).to_string(),
reason.to_string(),
vec![buttons::text(
ui,
Button::new(
catalog.text(Key::UpdateBannerRetry),
Message::Update(UpdateAction::CheckNow),
),
)],
Role::ErrorContainer,
),
};
let on_container = match role {
Role::PrimaryContainer => Role::OnPrimaryContainer,
Role::ErrorContainer => Role::OnErrorContainer,
_ => Role::OnSecondaryContainer,
};
let mut text_column = column![text_role_owned(
ui,
TypeRole::LabelLarge,
true,
title,
ui.color(on_container),
)]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
if !body.is_empty() {
text_column = text_column.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
body,
ui.color(on_container),
));
}
let mut line = row![text_column]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.align_y(Alignment::Center);
for action in actions {
line = line.push(action);
}
let padding = Padding::from([ui.space(Space::X3), ui.space(Space::X4)]);
Some(
container(line)
.width(Length::Fill)
.padding(padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(role))),
border: Border {
radius: ui.radius(ShapeScale::Medium, 0.0),
..Border::default()
},
..container::Style::default()
})
.into(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_i18n::Lang;
/// 提示条的正文**同时**给体积与说明摘要(mission:提示条含版本号、体积、说明摘要)。
#[test]
fn the_available_banner_carries_both_size_and_notes() {
for lang in [Lang::En, Lang::ZhCn] {
let body = available_body(lang.catalog(), 21_500_000, "修掉启动崩溃");
assert!(body.contains("20.5 MB"), "{lang:?} 缺体积:{body}");
assert!(body.contains("修掉启动崩溃"), "{lang:?} 缺说明:{body}");
}
// 发布没写说明时只留体积,不留一个空尾巴。
let bare = available_body(Lang::En.catalog(), 1024, " ");
assert!(bare.contains("1.0 KB"), "{bare}");
assert!(!bare.contains('·'), "没有说明时不该留分隔符:{bare}");
}
/// 视图模型的提示条判据与 presenter 同序(就位 > 进行中 > 失败 > 新版本)。
#[test]
fn the_banner_priority_matches_the_presenter() {
let mut update = Update {
status: UpdateStatus::Available {
version: "0.2.3".to_string(),
size: 1,
notes: String::new(),
forced: false,
},
..Update::default()
};
assert!(matches!(update.banner(), Some(Banner::Available { .. })));
update.dismissed = true;
assert!(update.banner().is_none(), "拒绝之后不再打扰");
update.dismissed = false;
update.applying = true;
assert!(matches!(update.banner(), Some(Banner::Applying)));
update.ready = Some("0.2.3".to_string());
assert!(matches!(update.banner(), Some(Banner::Ready { .. })));
}
}