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 → 备份留旧文件」,并验证
摘要不符时拒绝应用、目标文件零改动、暂存目录零残留。
234 lines
8.4 KiB
Rust
234 lines
8.4 KiB
Rust
//! 实例详情页的「启动参数」区(M157)。
|
||
//!
|
||
//! | 控件 | 语义 | 消息 |
|
||
//! |---|---|---|
|
||
//! | JVM 参数(多行) | 实例层,与全局 `java.extra_args` **叠加**(实例段最高优先级) | [`Message::Jvm`] |
|
||
//! | 游戏参数(多行) | 实例层,追加在命令行最后 | [`Message::Game`] |
|
||
//! | 最大内存(单行数字) | 实例层;留空 = 跟随全局默认 | [`Message::Memory`] |
|
||
//! | Java 可执行文件(单行) | 实例层;留空 = 跟随全局 → 自动挑 | [`Message::Java`] |
|
||
//! | 图形后端(三态 chips) | `default` / `opengl` / `vulkan`;**版本不够时只灰化后两档**(`default` 始终可点)+ 提示 | [`Message::Backend`] |
|
||
//!
|
||
//! 两条判据刻意不在这里重算:
|
||
//! 1. **能不能用 `--graphicsBackend`** 来自 [`LaunchFacts::allowed`](core 的 `graphics_backend_allowed`,
|
||
//! presenter 的 host 已经算好)——UI 不复制版本号比较;
|
||
//! 2. **生效值**(内存/Java/后端)同样来自 facts,表单里画的是实例层原文。
|
||
//!
|
||
//! 文案全走 [`Texts`](守门见 `instances/tests/i18n.rs` 的白名单与死 key 扫描)。
|
||
|
||
use iced::widget::{column, row};
|
||
use iced::{Element, Length};
|
||
|
||
use unlucky_design::color::Role;
|
||
use unlucky_design::spacing::Space;
|
||
use unlucky_design::typography::TypeRole;
|
||
|
||
use crate::app::components::buttons::{self, Button};
|
||
use crate::app::components::inputs::{text_field, Field};
|
||
use crate::app::components::selection::{chip, Chip};
|
||
|
||
use crate::theme::Ui;
|
||
|
||
use super::super::model::LaunchFacts;
|
||
use super::super::Message as PageMessage;
|
||
use super::state::{LaunchForm, Message};
|
||
use super::Texts;
|
||
|
||
/// 页内消息 → 页面消息(本文件的控件树统一画 `pages::Message`)。
|
||
fn page(message: Message) -> PageMessage {
|
||
PageMessage::Instances(message)
|
||
}
|
||
|
||
/// 「启动参数」区(在详情列的正文段之后、动作行之前)。
|
||
pub(super) fn section<'a>(
|
||
ui: &'a Ui,
|
||
facts: &'a LaunchFacts,
|
||
form: &'a LaunchForm,
|
||
texts: Texts,
|
||
) -> Element<'a, PageMessage> {
|
||
// 段间距 X2(8dp):卡内 5 个字段 + 三态 + 动作行在 424 宽下已经很长,
|
||
// 用 X3 会让详情列更长一屏;行内间距仍是 X1(4dp),读起来不挤。
|
||
let mut body = column![frame_title(ui, texts.launch_title())]
|
||
.spacing(ui.space(Space::X2))
|
||
.width(Length::Fill);
|
||
|
||
body = body.push(args_field(
|
||
ui,
|
||
texts.launch_jvm(),
|
||
texts.launch_jvm_hint(),
|
||
form.jvm.as_str(),
|
||
|value| page(Message::Jvm(value)),
|
||
));
|
||
body = body.push(args_field(
|
||
ui,
|
||
texts.launch_game(),
|
||
texts.launch_game_hint(),
|
||
form.game.as_str(),
|
||
|value| page(Message::Game(value)),
|
||
));
|
||
body = body.push(text_field_with_hint(
|
||
ui,
|
||
texts.launch_memory(),
|
||
form.memory.as_str(),
|
||
super::super::frame::text_role_owned(
|
||
ui,
|
||
TypeRole::BodySmall,
|
||
false,
|
||
texts.launch_memory_hint(facts.memory_effective_mb),
|
||
ui.color(Role::OnSurfaceVariant),
|
||
),
|
||
|value| page(Message::Memory(value)),
|
||
));
|
||
body = body.push(text_field_with_hint(
|
||
ui,
|
||
texts.launch_java(),
|
||
form.java.as_str(),
|
||
super::super::frame::text_role_owned(
|
||
ui,
|
||
TypeRole::BodySmall,
|
||
false,
|
||
texts.launch_java_hint().to_string(),
|
||
ui.color(Role::OnSurfaceVariant),
|
||
),
|
||
|value| page(Message::Java(value)),
|
||
));
|
||
body = body.push(backend_row(ui, facts, form, texts));
|
||
body = body.push(actions_row(ui, texts));
|
||
|
||
// 与相邻信息卡**同一套**卡壳(`frame::card`:`card_padding` 21dp + OutlinedCard 的圆角/描边)。
|
||
super::super::frame::card(ui, body)
|
||
}
|
||
|
||
/// 卡片标题:**与信息卡的标签同款**(`LabelMedium` 不加粗、`OnSurfaceVariant`),
|
||
/// 复审 ③⑤ 抓的就是这里(原来用了 `emphasized = true`,比同屏其它标签重一档)。
|
||
fn frame_title<'a>(ui: &'a Ui, title: &'a str) -> Element<'a, PageMessage> {
|
||
super::super::frame::text_role(
|
||
ui,
|
||
TypeRole::LabelMedium,
|
||
false,
|
||
title,
|
||
ui.color(Role::OnSurfaceVariant),
|
||
)
|
||
}
|
||
|
||
/// 一段参数输入 + 一行提示(单行受控输入;参数按空白切分,与 core 的 `tokenize` 同口径)。
|
||
fn args_field<'a>(
|
||
ui: &'a Ui,
|
||
label: &'a str,
|
||
hint: &'a str,
|
||
value: &'a str,
|
||
on_input: impl Fn(String) -> PageMessage + 'a,
|
||
) -> Element<'a, PageMessage> {
|
||
column![
|
||
text_field(ui, Field::new(value).label(label), on_input),
|
||
super::super::frame::text_role(
|
||
ui,
|
||
TypeRole::BodySmall,
|
||
false,
|
||
hint,
|
||
ui.color(Role::OnSurfaceVariant),
|
||
),
|
||
]
|
||
.spacing(ui.space(Space::X1))
|
||
.width(Length::Fill)
|
||
.into()
|
||
}
|
||
|
||
/// 单行输入 + 一行提示(提示是**拥有的**字符串,所以不能走 `Field::supporting` 的借用口径)。
|
||
fn text_field_with_hint<'a>(
|
||
ui: &'a Ui,
|
||
label: &'a str,
|
||
value: &'a str,
|
||
hint: Element<'a, PageMessage>,
|
||
on_input: impl Fn(String) -> PageMessage + 'a,
|
||
) -> Element<'a, PageMessage> {
|
||
column![
|
||
text_field(ui, Field::new(value).label(label), on_input),
|
||
hint,
|
||
]
|
||
.spacing(ui.space(Space::X1))
|
||
.width(Length::Fill)
|
||
.into()
|
||
}
|
||
|
||
/// 三态后端的「不下发」档(`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,
|
||
form: &'a LaunchForm,
|
||
texts: Texts,
|
||
) -> Element<'a, PageMessage> {
|
||
let allowed = facts.allowed;
|
||
let mut chips = row![].spacing(ui.space(Space::X2));
|
||
for id in &facts.options {
|
||
let label = texts.backend_label(id).unwrap_or(id.as_str());
|
||
let selected = form.backend == *id;
|
||
let spec = Chip::filter(label, selected, page(Message::Backend(id.clone())));
|
||
let spec = if backend_chip_enabled(allowed, id) {
|
||
spec
|
||
} else {
|
||
spec.disabled()
|
||
};
|
||
chips = chips.push(chip(ui, spec));
|
||
}
|
||
|
||
let mut meta = column![
|
||
super::super::frame::text_role(
|
||
ui,
|
||
TypeRole::BodySmall,
|
||
false,
|
||
texts.launch_backend(),
|
||
ui.color(Role::OnSurface),
|
||
),
|
||
chips,
|
||
super::super::frame::text_role_owned(
|
||
ui,
|
||
TypeRole::BodySmall,
|
||
false,
|
||
texts.launch_effective(&facts.backend_effective),
|
||
ui.color(Role::OnSurfaceVariant),
|
||
),
|
||
]
|
||
.spacing(ui.space(Space::X1))
|
||
.width(Length::Fill);
|
||
|
||
if !allowed {
|
||
// 灰化 + 明确提示:既不说「已生效」,也不静默吞掉用户的选择。
|
||
meta = meta.push(super::super::frame::text_role_owned(
|
||
ui,
|
||
TypeRole::BodySmall,
|
||
false,
|
||
texts.launch_backend_gated(&facts.mc_version, &facts.min_version),
|
||
ui.color(Role::Error),
|
||
));
|
||
}
|
||
meta.into()
|
||
}
|
||
|
||
fn actions_row<'a>(ui: &'a Ui, texts: Texts) -> Element<'a, PageMessage> {
|
||
row![
|
||
buttons::filled(ui, Button::new(texts.launch_save(), page(Message::Save))),
|
||
buttons::text(ui, Button::new(texts.launch_reset(), page(Message::Reset))),
|
||
]
|
||
.spacing(ui.space(Space::X2))
|
||
.into()
|
||
}
|