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
@@ -51,6 +51,7 @@ mod download;
|
||||
mod instances;
|
||||
mod page_messages;
|
||||
mod settings;
|
||||
mod update_action;
|
||||
|
||||
impl App {
|
||||
/// 一条消息进来,改状态并返回后续任务。
|
||||
|
||||
@@ -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(),
|
||||
"重启不是一条后端指令(不该派发任何命令)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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` 就是静息态,不叠状态层。
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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} \
|
||||
配色来源={:?} 选中分组={}",
|
||||
|
||||
@@ -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)),
|
||||
"门限关闭时至少有一档可点(否则三态整体锁死)"
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 { .. })));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user