Files
Unlucky-Launcher/crates/app-iced/src/pages/instances/state.rs
T
AskaEth c921b9c185 feat(core,presenter,app-iced): 存档三个写操作(重命名 / 删除 / 备份)+ 行操作区(M247 SAVESOPS)
core::game::saves 新增 write 模块,三个入口的第一句都是 boundary::guard:

- rename_world:saves/<old> → saves/<new>,撞名/同名/非法名字各自具名拒绝
- delete_world:递归删(不跟随符号链接、层级上限 64),先过边界校验
- backup_world:流式 zip(限额预检 → 临时文件 → 原子改名),
  <世界名>-YYYYMMDD-HHMMSS.zip,撞名顺延,绝不覆盖上一份

guard 三道判据(每次写前重做,不信任调用方):名字必须是单段裸名字;
saves/ 必须是运行目录的真实直属子目录;世界目录必须是 saves/ 下的真目录
(解析后比父级,不比字符串前缀)。落点目录要求绝对路径(相对路径会落到进程 CWD,
实测过一次)。

presenter:SavesCommand 增 RenameWorld/DeleteWorld/BackupWorld,host 三个后台入口
写成功后重新列一遍目录并把「回执」挂在快照上(SavesOped:改名两端 / 被删的名字 /
备份落点+字节数+跳过的链接数)。SavesFailureView 增 file 与 op,写失败八类一一对应。
SavesState 增 draft / pending / confirm 三样交互态(草稿是行内输入缓冲,
pending 与 ModsState::pending 同一条先例,confirm 是删除确认框在等谁)。

app-iced:世界行右侧重命名/备份/删除;行内输入框改名(现成模态队列没有「输入变了」
这一条消息,用输入模态等于重写一套);删除走现成 modal 的 Confirm,主按钮落点接在
app/update/dialog.rs。回执行画在卡片里(备份落点看不见就是静默失败)。

i18n:新增 25 条中英成对 key + 金表同步;删掉 M235 的「这一版只读」那条(写操作接上
之后它是假的)。出图夹具 ULL_SAVES_FIXTURE=rename|confirm|backed-up 三种形态。
2026-11-11 13:29:56 +08:00

333 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 实例页的**页面状态**(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)
}
/// 重播种(见模块文档的三档)。
///
/// **脏草稿只在「落盘已确认」时被替换**:保存把命令派发出去之后,回程可能是一条错误事件
/// (内存写了 `4g`、实例文件写不进去),那时 pump 会带着**旧快照**重建视图——如果这里无条件
/// 覆盖,用户刚敲的东西就没了(M157 r3 复审 P2-2)。判据是「快照已经等于表单」:
/// 相等 ⇒ 这次保存落了盘,清脏并重播种;不相等 ⇒ 保留草稿等用户改。
pub fn sync(&mut self, view: &LaunchArgsView) {
let same_instance = self.bound_to(&view.instance_id);
if same_instance && self.dirty {
if self.matches(view) {
self.dirty = false;
*self = Self::from_view(Some(&view.instance_id), view);
}
return;
}
*self = Self::from_view(Some(&view.instance_id), view);
}
/// 表单当前内容是否与一份快照(= 盘上已持久化的值)完全一致。
///
/// **内存按数值比**:host 会把 `"04096"` / `"+4096"` 归一成 `4096` 落盘,回程快照给的是
/// `Some(4096)`——按文本比会永远不等,于是表单一直脏、也不再跟随外部改动(r4 复审 P3-b)。
/// 解析失败(用户正在敲半截)才退回文本口径。
/// 其余字段直接比原文(JVM/游戏参数由 core 的 `tokenize` 消费,不做归一)。
#[must_use]
pub fn matches(&self, view: &LaunchArgsView) -> bool {
self.jvm.trim() == view.jvm_args.trim()
&& self.game.trim() == view.game_args.trim()
&& self.memory_matches(view.memory_mb)
&& self.java.trim() == view.java_path.trim()
&& self.backend == view.graphics_backend
}
/// 内存字段是否等于快照里的值(数值优先,解析失败退文本)。
fn memory_matches(&self, persisted: Option<u32>) -> bool {
let typed = self.memory.trim();
match (typed.parse::<u32>(), persisted) {
(Ok(typed), Some(persisted)) => typed == persisted,
// 空输入 = 跟随全局:快照那边也应当是「没有实例覆盖」。
(Err(_), None) => typed.is_empty(),
_ => false,
}
}
/// 提交补丁:**五项全给**(表单是整份可见的,不用增量语义会让「清空」无法表达)。
#[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,
/// 启停一个 mod(M224):`file` 是**磁盘文件名**(含后缀),`enabled` 是期望状态。
///
/// 页面只发意图,落盘由 host 在后台线程做;回程是一份新快照(不做乐观回显)。
SetModEnabled {
/// 磁盘文件名
file: String,
/// 期望的启用状态
enabled: bool,
},
/// 重新列一次存档(M235):读盘失败时的「重试」,也用于用户手动刷新。
///
/// 只发一条 `SavesCommand::List`——没有别的状态要复位(失败不清绑定,见 presenter 的
/// `SavesState::failed`)。
ReloadSaves,
/// 打开存档文件夹(M235):拉起哪个目录由 host 决定(`saves/` → `screenshots/` → 运行目录)。
OpenSavesFolder,
/// 开始**行内重命名**一个世界(M247):那一行就地换成输入框 + 确定/取消。
///
/// 为什么是行内而不是模态输入框:现成的模态队列有 `DialogKind::Input`,但 app 侧的
/// `dialog::Message` 只有「主按钮 / 次按钮 / 关闭」,**没有「输入变了」**这一条——
/// 用输入模态等于重写一套可编辑模态。行内复用 `components::inputs` 的单行框,一处新控件都不用加。
StartRenameWorld {
/// 磁盘目录名
dir: String,
},
/// 行内输入框的内容变了(原样存进 presenter 的草稿,不在前端过滤)。
RenameWorldChanged(String),
/// 提交行内重命名(空名字 / 没改 → 直接收起,不打扰后台)。
CommitRenameWorld,
/// 取消行内重命名。
CancelRenameWorld,
/// 请求删除一个世界(M247):**先弹确认框**,不直接删。
RequestDeleteWorld {
/// 磁盘目录名
dir: String,
},
/// 备份一个世界(M247):导出 zip 到启动器自己的 `exports/saves/`。
BackupWorld {
/// 磁盘目录名
dir: String,
},
}
#[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"));
}
/// **保存失败不许吃掉草稿**(M157 r3 复审 P2-2):脏表单 + 旧快照回来 ⇒ 原样保留。
///
/// 抓得住:`sync` 在「同实例 + 脏」时无条件重播种(用户敲的 `4g` 被磁盘上的 4096 覆盖)。
/// 抓不住:真实事件时序(那在 `app/update/instances.rs` 的用例里按消息顺序模拟)。
#[test]
fn failed_save_keeps_the_draft() {
let mut form = LaunchForm::from_view(Some("a"), &view("a", "", Some(4096), "default"));
// 用户把内存改成非法值(host 会拒),此时表单是脏的。
form.memory = "4g".to_string();
form.jvm = "-Xmx3g".to_string();
form.dirty = true;
// 回程事件带来的仍是**旧**快照(保存失败 ⇒ 盘上没变)。
form.sync(&view("a", "", Some(4096), "default"));
assert_eq!(form.memory, "4g", "失败后的重播把用户正在改的内存吃掉了");
assert_eq!(form.jvm, "-Xmx3g");
assert!(form.dirty, "草稿还在,脏标记也不能掉");
}
/// **保存成功要清脏**:回程快照等于表单 ⇒ 这一步算落盘确认,之后可以跟着外部变化重播种。
#[test]
fn confirmed_save_clears_dirty_and_reseeds() {
let mut form = LaunchForm::from_view(Some("a"), &view("a", "", Some(4096), "default"));
form.memory = "8192".to_string();
form.backend = "vulkan".to_string();
form.dirty = true;
form.sync(&view("a", "", Some(8192), "vulkan"));
assert!(!form.dirty, "盘上已经是这份值了,脏标记该清");
assert_eq!(form.memory, "8192");
// 之后外部改了配置(比如 CLI 写入)⇒ 干净的表格跟着更新。
form.sync(&view("a", "-Xss4m", Some(2048), "opengl"));
assert_eq!(form.memory, "2048");
assert_eq!(form.jvm, "-Xss4m");
}
/// **内存按数值比**(r4 复审 P3-b):`04096` / `+4096` 落盘后回程是 `4096`,必须判等。
///
/// 抓得住:`matches` 退回文本比较——那样这两类输入会永远「没保存成功」,表单一直脏、
/// 之后外部改配置也不再重播种。
#[test]
fn memory_is_compared_by_value_not_by_text() {
for typed in ["04096", "+4096", " 4096 "] {
let mut form = LaunchForm::from_view(Some("a"), &view("a", "", Some(4096), "default"));
form.memory = typed.to_string();
form.dirty = true;
assert!(
form.matches(&view("a", "", Some(4096), "default")),
"`{typed}` 应判等"
);
form.sync(&view("a", "", Some(4096), "default"));
assert!(!form.dirty, "`{typed}` 保存确认后要清脏");
}
// 数值不等就是不等于;半截输入(解析失败)也不许判等。
let mut form = LaunchForm::from_view(Some("a"), &view("a", "", Some(4096), "default"));
form.memory = "8".to_string();
assert!(!form.matches(&view("a", "", Some(4096), "default")));
form.memory = "4g".to_string();
assert!(!form.matches(&view("a", "", Some(4096), "default")));
// 清空 = 跟随全局:快照也得是「没有实例覆盖」。
form.memory.clear();
assert!(form.matches(&view("a", "", None, "default")));
}
/// 补丁五项全给:空串就是「清空」,不留 `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"));
}
}