feat(launch): M157 实例级启动参数(五项回落 + graphicsBackend 三态)

core: 新增 game/launch_overrides(LaunchOverrides 五项 All-Option + 三级回落 + 文本补丁校验)与 launch/overrides(拼装落位:全局 java.extra_args 进 user.jvm、实例 jvmArgs 进 override_jvm、gameArgs 进 user.game、memory 进 -Xmx、backend 进 --graphicsBackend 并复用既有版本门槛告警);实例 schema 登记 launch.* 五键。

presenter: dto/LaunchArgsView+LaunchArgsPatch,主机侧读/写与门槛判定(backend_allowed),AppCommand::SetInstanceLaunchArgs。

cli: 新命令 instances launch-args(读/写/--clear),launch 装配链消费回落值。

app-iced: 实例详情页「启动参数」区(JVM/游戏参数、内存、Java、三态后端 chips + 灰化提示 + 保存/恢复),表单状态归 App、i18n 中英文案同步。
This commit is contained in:
AskaEth committed 2026-11-07 10:36:35 +08:00
1 parent d68523b02a
commit e2021645d7
56 files changed
+3776 -607

No files matched your search

@@ -0,0 +1,190 @@
//! 实例页的**页面状态**(M157):启动参数区的表单。
//!
//! ## 为什么状态在 app、页面只借一帧
//!
//! iced 的 `view` 不能持有跨帧状态,而启动参数区是可编辑的(两个多行框 + 两行单行框 +
//! 三态 chips)。所以表单状态的所有者是 `App`(`app/state.rs` 的 `launch_form` 字段),
//! 页面层只在这一帧借用;这与设置页「组状态归 `App`」是同一条纪律(`pages/settings/state.rs`)。
//!
//! ## 与快照的关系(唯一的重播种点)
//!
//! [`LaunchForm::sync`] 由 `App::refresh_view` 调用:
//!
//! | 情况 | 行为 |
//! |---|---|
//! | 换了一个实例 | **重播种**(用户没写完的草稿不能串到别的实例上) |
//! | 同一个实例、没改过 | 重播种(保存/外部改了配置后跟着刷新) |
//! | 同一个实例、改过(`dirty`) | **不覆盖**(每来一条事件就把输入抹掉是不可接受的) |
//!
//! 「留空 = 跟随下一层」由提交时把空串原样交给 host 表达(`Some("")` = 清空),
//! 表单自己不做任何回落计算——回落只发生在 core。
use unlucky_presenter::dto::{LaunchArgsPatch, LaunchArgsView};
/// 启动参数表单(五项 + 脏标记 + 绑定的实例 id)。
#[derive(Debug)]
pub struct LaunchForm {
/// 这份草稿属于哪个实例(`None` = 还没播种)。
pub bound: Option<String>,
/// 实例级 JVM 参数(空白分隔的文本)。
pub jvm: String,
/// 实例级游戏参数(空白分隔的文本)。
pub game: String,
/// 最大堆(文本形态,空 = 跟随全局)。
pub memory: String,
/// Java 可执行文件(空 = 跟随全局 / 自动挑)。
pub java: String,
/// 图形后端(`default` / `opengl` / `vulkan`)。
pub backend: String,
/// 用户改过、还没保存。
pub dirty: bool,
}
impl Default for LaunchForm {
fn default() -> Self {
Self::from_view(None, &LaunchArgsView::default())
}
}
impl LaunchForm {
/// 用一份快照播种(`bound` 为 `None` = 还没绑定实例)。
#[must_use]
pub fn from_view(bound: Option<&str>, view: &LaunchArgsView) -> Self {
Self {
bound: bound.map(str::to_string),
jvm: view.jvm_args.clone(),
game: view.game_args.clone(),
memory: view.memory_mb.map(|mb| mb.to_string()).unwrap_or_default(),
java: view.java_path.clone(),
backend: view.graphics_backend.clone(),
dirty: false,
}
}
/// 这份草稿是否正对着该实例。
#[must_use]
pub fn bound_to(&self, id: &str) -> bool {
self.bound.as_deref() == Some(id)
}
/// 重播种(见模块文档的三档)。
pub fn sync(&mut self, view: &LaunchArgsView) {
let same_instance = self.bound_to(&view.instance_id);
if same_instance && self.dirty {
return;
}
*self = Self::from_view(Some(&view.instance_id), view);
}
/// 提交补丁:**五项全给**(表单是整份可见的,不用增量语义会让「清空」无法表达)。
#[must_use]
pub fn patch(&self) -> LaunchArgsPatch {
LaunchArgsPatch {
jvm_args: Some(self.jvm.clone()),
game_args: Some(self.game.clone()),
memory_mb: Some(self.memory.clone()),
java_path: Some(self.java.clone()),
graphics_backend: Some(self.backend.clone()),
}
}
/// 「恢复跟随全局」:五项清空,脏标记置位(等用户点保存)。
pub fn reset(&mut self) {
self.jvm.clear();
self.game.clear();
self.memory.clear();
self.java.clear();
self.backend = "default".to_string();
self.dirty = true;
}
}
/// 启动参数区的消息(`pages::Message::Instances` 的载荷)。
///
/// 只表达**页面意图**:编辑写进表单,保存/恢复才需要 app 层理解(前者 dispatch 一条
/// `AppCommand::SetInstanceLaunchArgs`,后者只是清表单)。
#[derive(Debug, Clone)]
pub enum Message {
/// 编辑 JVM 参数。
Jvm(String),
/// 编辑游戏参数。
Game(String),
/// 编辑最大内存(单行文本)。
Memory(String),
/// 编辑 Java 路径(单行文本)。
Java(String),
/// 选图形后端(三态 chips)。
Backend(String),
/// 保存(派发命令)。
Save,
/// 恢复跟随全局(清表单,仍需保存)。
Reset,
}
#[cfg(test)]
mod tests {
use super::*;
fn view(instance: &str, jvm: &str, memory: Option<u32>, backend: &str) -> LaunchArgsView {
LaunchArgsView {
instance_id: instance.to_string(),
jvm_args: jvm.to_string(),
game_args: String::new(),
memory_mb: memory,
memory_effective_mb: memory.unwrap_or(2048),
java_path: String::new(),
java_effective: String::new(),
graphics_backend: backend.to_string(),
graphics_backend_effective: backend.to_string(),
graphics_backend_allowed: true,
mc_version: "26.2".to_string(),
graphics_backend_min_version: "26.2-snapshot-2".to_string(),
graphics_backend_options: vec![
"default".to_string(),
"opengl".to_string(),
"vulkan".to_string(),
],
}
}
/// 换实例必重播种;同实例的脏草稿不被事件冲掉。
#[test]
fn sync_reseeds_per_instance_but_keeps_dirty_edits() {
let mut form = LaunchForm::default();
form.sync(&view("a", "-Xss4m", Some(4096), "vulkan"));
assert_eq!(form.jvm, "-Xss4m");
assert_eq!(form.memory, "4096");
assert!(!form.dirty);
// 用户改动 → 脏 → 同实例的快照刷新不许覆盖。
form.memory = "8192".to_string();
form.dirty = true;
form.sync(&view("a", "-Xss4m", Some(4096), "vulkan"));
assert_eq!(form.memory, "8192", "脏草稿被事件冲掉了");
// 换实例 → 重播种(草稿不串台)。
form.sync(&view("b", "", None, "default"));
assert_eq!(form.memory, "");
assert!(!form.dirty);
assert!(form.bound_to("b"));
}
/// 补丁五项全给:空串就是「清空」,不留 `None` 让 host 猜。
#[test]
fn patch_always_covers_all_five_fields() {
let mut form = LaunchForm::from_view(Some("a"), &view("a", "", None, "default"));
form.memory = "6144".to_string();
form.backend = "opengl".to_string();
let patch = form.patch();
assert_eq!(patch.jvm_args.as_deref(), Some(""));
assert_eq!(patch.memory_mb.as_deref(), Some("6144"));
assert_eq!(patch.graphics_backend.as_deref(), Some("opengl"));
form.reset();
assert!(form.dirty);
let patch = form.patch();
assert_eq!(patch.jvm_args.as_deref(), Some(""));
assert_eq!(patch.memory_mb.as_deref(), Some(""));
assert_eq!(patch.graphics_backend.as_deref(), Some("default"));
}
}