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 三种形态。
This commit is contained in:
AskaEth committed 2026-11-11 13:29:56 +08:00
1 parent 3e852331f4
commit c921b9c185
28 files changed
+3016 -216

No files matched your search

+2 -2
View File
@@ -88,8 +88,8 @@ pub use settings::{
};
// M235: 实例存档 / 截图的只读列表
pub use saves::{
SavesArea, SavesCommand, SavesFailureKind, SavesFailureView, SavesListView, SavesSkipKind,
SavesSkipView, ScreenshotView, WorldView,
SavesArea, SavesCommand, SavesFailureKind, SavesFailureOp, SavesFailureView, SavesListView,
SavesOped, SavesSkipKind, SavesSkipView, ScreenshotView, WorldView,
};
// M159: 自更新
pub use update::{
+126 -88
View File
@@ -1,26 +1,33 @@
//! 实例**存档 / 截图的只读快照**与「打开文件夹」的 IPC 面(M235 · STOREMVP)。
//! 实例**存档 / 截图的快照**与写操作的 IPC 面(M235 · STOREMVP,M247 · SAVESOPS 加写路径)。
//!
//! # 三层里的第二层
//!
//! 塔裁决 U3 把这一版钉成「只读列表 + 打开文件夹」,不做缩略图墙。三层是:
//! `core::game::saves`(读盘,唯一的真相)→ 本模块(IPC 形状)→
//! `crates/app-iced/src/pages/instances/saves.rs`(画)。
//! 这一版是「只读列表 + 打开文件夹」**加上**三个写操作:`core::game::saves`(读盘/写盘,唯一的
//! 真相)→ 本模块(IPC 形状)→ `crates/app-iced/src/pages/instances/saves.rs`(画)。
//!
//! | 方向 | 类型 |
//! |---|---|
//! | app → host | [`SavesCommand`](列目录 / 打开文件夹) |
//! | host → app | [`SavesListView`](整份快照)或 [`SavesFailureView`](具名原因) |
//! | app → host | [`SavesCommand`](列目录 / 打开文件夹 / 重命名 / 删除 / 备份) |
//! | host → app | [`SavesListView`](整份快照 + 上一次写操作的[回执](SavesOped))或 [`SavesFailureView`] |
//!
//! # 写操作为什么走同一条回程
//!
//! 三个写操作的结果都是**一份新快照**(`Ok`)+ 一个[回执](SavesOped):写完之后磁盘变了,
//! 界面必须跟着变;而回执是「这次动的是哪一条、动成了什么」。失败则走同一个事件的 `Err` 一支,
//! 带上一个**具名类别**(重命名撞名 / 删除越界 / 备份超限……),见 [`SavesFailureKind`]。
//! 两条都不静默:成功有回执、失败有类别 + OS 原文。
//!
//! # 为什么另开一族,不并进 `ModListView`
//!
//! 两者形状相近(都是"列一个实例的某个目录"),但语义不同:mod 有启停(会写盘、要看游戏在不在跑),
//! 存档这一版**没有任何写路径**。合并会让 mod 那边的 `pending` / `running` 字段在存档页变成
//! 永远为假的分支(M224 拒绝给内容页加第五个 `ContentKind` 是同一条理由:别让穷尽 match 为不相干
//! 的字段服务)。
//! 两者形状相近(都是"列一个实例的某个目录"),但语义不同:mod 有启停(看游戏在不在跑),
//! 存档这边有三个写操作(重命名 / 删除 / 备份)。合并会让两边的 `pending` 字段互相变成
//! 永远为假的分支(M224 拒绝给内容页加第五个 `ContentKind` 是同一条理由:别让穷尽 match 为
//! 不相干的字段服务)。
//!
//! # 文案纪律(M161)
//!
//! 本模块**不带人读散文**:失败给的是结构([`SavesFailureKind`] + 底层 `detail` 串),
//! 本模块**不带人读散文**:失败给的是结构([`SavesFailureKind`] + 底层 `detail` 串)、
//! 回执给的是事实(谁改成什么、备份落到哪个路径、几个字节、跳过几个链接),
//! 时间戳给的是**毫秒数**(UTC 毫秒,UI 自己格式化)。文案一律由 app 层按 i18n key 现求。
//!
//! # 时间戳为什么是 `Option<u64>` 的毫秒
@@ -45,6 +52,76 @@ pub enum SavesCommand {
/// 实例 id
id: InstanceId,
},
/// 重命名一个世界(M247):`saves/<dir>` → `saves/<to>`。
///
/// 名字规则、撞名、边界校验全在 core(host 只转发):UI 传坏了也不会落到 `saves/` 之外。
RenameWorld {
/// 实例 id
id: InstanceId,
/// 现在的磁盘目录名
dir: String,
/// 新名字(单段;不许带 `/` `\` `:` 与 `..`)
to: String,
},
/// 删除一个世界(M247):递归删 `saves/<dir>`。
///
/// **UI 必须先二次确认**:这是本页唯一不可逆的动作。host 侧再走一遍边界校验
/// (越界 / 符号链接一律拒),不靠确认框把调用方「教好」。
DeleteWorld {
/// 实例 id
id: InstanceId,
/// 磁盘目录名
dir: String,
},
/// 备份一个世界(M247):把一个世界导出成 zip。
///
/// `dest` 是**父目录**(不是文件):`None` = 启动器自己的 `<data>/exports/saves/`。
/// 文件名由 core 按「世界名 + UTC 时戳」生成,撞名顺延,**绝不覆盖**上一份。
BackupWorld {
/// 实例 id
id: InstanceId,
/// 磁盘目录名
dir: String,
/// 导出到哪个父目录(`None` = 启动器自己的 exports)
dest: Option<String>,
},
}
/// 一次写操作成功后的事实(**不含人读文案**:文案由 app 按 i18n key 现求)。
///
/// 为什么成功也要有回执:重命名 / 删除的结果在列表上自己会说话(行改名、行消失),
/// 而**备份成功没有话可说**——zip 落到了文件系统某处,用户必须知道完整路径 + 字节数,
/// 否则「点了备份然后什么也没发生」就是一次静默失败。
///
/// 回执挂在快照上([`SavesListView::last_op`]):下一次普通 `List` 会让它消失,
/// 所以它表达的是「最近这一次操作」,不是一段历史。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavesOped {
/// 改名成功。
Renamed {
/// 原磁盘名
from: String,
/// 新磁盘名
to: String,
},
/// 删除成功。
Deleted {
/// 被删掉的磁盘名
dir: String,
},
/// 备份成功。
BackedUp {
/// 被备份的世界(磁盘名)
dir: String,
/// 落点文件名
file: String,
/// 落点完整路径
path: String,
/// zip 的字节数
bytes: u64,
/// 被跳过的符号链接数(不跟随;0 = 一个都没跳)
skipped: usize,
},
}
/// 一个世界目录。
@@ -125,6 +202,8 @@ pub struct SavesListView {
pub screenshots: Vec<ScreenshotView>,
/// 跳过的条目(UI 只需计数 + 一句"为什么")。
pub skipped: Vec<SavesSkipView>,
/// 最近一次写操作的回执(`None` = 这一拍只是列举,没写东西)。
pub last_op: Option<SavesOped>,
}
impl SavesListView {
@@ -158,13 +237,31 @@ impl SavesListView {
pub struct SavesFailureView {
/// 实例 id(迟到/串台的结果靠它判)。
pub instance: InstanceId,
/// 涉及的那个世界(写操作才有;读路径给空串)。
///
/// 为什么要这一格:失败那句话是「重命名 **A** 失败:同名世界已存在」——没有名字,
/// 用户不知道该改哪一行(与 `ModFailureView::file` 同一条理由)。
pub file: String,
/// 这是哪一类**写**操作(`None` = 读路径 / 打开文件夹的失败)。
pub op: Option<SavesFailureOp>,
/// 失败大类。
pub kind: SavesFailureKind,
/// 底层原文(OS 错误 / 路径细节);只进括号,不进主句。
pub detail: String,
}
/// 失败大类。
/// 失败出自哪一类写操作(决定失败那句主句用哪个动词)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavesFailureOp {
/// 重命名。
Rename,
/// 删除。
Delete,
/// 备份。
Backup,
}
/// 失败大类(**每一种在 UI 上都有一句话**,没有静默失败)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavesFailureKind {
/// 实例不在了(快照里找不到),或隔离档位派生不出运行目录。
@@ -173,82 +270,23 @@ pub enum SavesFailureKind {
DirUnusable,
/// 「打开文件夹」失败(目录不存在 / 拉不起文件管理器)。
OpenFailed,
/// 写操作:名字过不了规则(空、`.`、`..`、含 `/` `\` `:` NUL)。
WriteUnsafeName,
/// 写操作:目标或路径上的一段是符号链接(不跟随)。
WriteSymlink,
/// 写操作:那个世界(或 `saves/`)已经不在了。
WriteMissing,
/// 写操作:那个名字底下不是目录。
WriteNotADirectory,
/// 写操作:同名世界已存在 / 备份文件名撞满。
WriteConflict,
/// 写操作:路径落在 `saves/` 之外(含层级异常深)。
WriteBoundary,
/// 写操作:备份超过体积或条目上限。
WriteTooLarge,
/// 写操作:其它系统错误(权限 / 被别的进程占用),OS 原文进 `detail`。
WriteFailed,
}
#[cfg(test)]
mod tests {
use super::*;
fn view() -> SavesListView {
SavesListView {
instance: "inst".to_string(),
dir: "/tmp/run/saves".to_string(),
exists: true,
worlds: vec![WorldView {
dir: "World".to_string(),
icon: true,
modified_ms: Some(1_700_000_000_000),
}],
screenshots_dir: "/tmp/run/screenshots".to_string(),
screenshots_exists: true,
screenshots: vec![ScreenshotView {
file: "2024-05-01_10.00.00.png".to_string(),
bytes: 1024,
modified_ms: None,
}],
skipped: vec![SavesSkipView {
area: SavesArea::Screenshots,
file: "notes.txt".to_string(),
reason: SavesSkipKind::NotAnImage,
}],
}
}
/// 计数与 IPC round-trip(AGENTS §6:跨界类型必须能序列化)。
#[test]
fn view_counts_and_round_trips() {
let view = view();
assert_eq!(view.world_count(), 1);
assert_eq!(view.screenshot_count(), 1);
assert_eq!(view.skipped_count(), 1);
assert!(!view.is_empty());
let text = serde_json::to_string(&view).expect("序列化");
assert_eq!(
serde_json::from_str::<SavesListView>(&text).expect("反序列化"),
view
);
}
/// 两条指令都要能过 IPC(含「打开文件夹」——它的载荷只有实例 id)。
#[test]
fn commands_round_trip() {
for command in [
SavesCommand::List("inst".to_string()),
SavesCommand::OpenFolder {
id: "inst".to_string(),
},
] {
let text = serde_json::to_string(&command).expect("序列化");
assert_eq!(
serde_json::from_str::<SavesCommand>(&text).expect("反序列化"),
command
);
}
}
/// 两个目录都不存在 ⇒ 空(`is_empty` 只看内容,不看 `exists`:一个目录有空世界也算非空)。
#[test]
fn empty_means_no_worlds_and_no_screenshots() {
let mut view = view();
view.worlds.clear();
view.screenshots.clear();
assert!(view.is_empty(), "没有世界也没有截图就该算空");
view.screenshots.push(ScreenshotView {
file: "a.png".to_string(),
bytes: 0,
modified_ms: None,
});
assert!(!view.is_empty());
}
}
mod tests;
+126
View File
@@ -0,0 +1,126 @@
//! 存档 IPC 面的用例:**IPC round-trip**(生成物必须能反序列化回来)。
use super::*;
fn view() -> SavesListView {
SavesListView {
instance: "inst".to_string(),
dir: "/tmp/run/saves".to_string(),
exists: true,
worlds: vec![WorldView {
dir: "World".to_string(),
icon: true,
modified_ms: Some(1_700_000_000_000),
}],
screenshots_dir: "/tmp/run/screenshots".to_string(),
screenshots_exists: true,
screenshots: vec![ScreenshotView {
file: "2024-05-01_10.00.00.png".to_string(),
bytes: 1024,
modified_ms: None,
}],
skipped: vec![SavesSkipView {
area: SavesArea::Screenshots,
file: "notes.txt".to_string(),
reason: SavesSkipKind::NotAnImage,
}],
last_op: None,
}
}
/// 计数与 IPC round-trip(AGENTS §6:跨界类型必须能序列化)。
#[test]
fn view_counts_and_round_trips() {
let view = view();
assert_eq!(view.world_count(), 1);
assert_eq!(view.screenshot_count(), 1);
assert_eq!(view.skipped_count(), 1);
assert!(!view.is_empty());
let text = serde_json::to_string(&view).expect("序列化");
assert_eq!(
serde_json::from_str::<SavesListView>(&text).expect("反序列化"),
view
);
}
/// 五条指令都要能过 IPC(含写操作三条——它们带世界名,名字里可能有空格与中文)。
#[test]
fn commands_round_trip() {
for command in [
SavesCommand::List("inst".to_string()),
SavesCommand::OpenFolder {
id: "inst".to_string(),
},
SavesCommand::RenameWorld {
id: "inst".to_string(),
dir: "Better World".to_string(),
to: "更好的世界".to_string(),
},
SavesCommand::DeleteWorld {
id: "inst".to_string(),
dir: "Better World".to_string(),
},
SavesCommand::BackupWorld {
id: "inst".to_string(),
dir: "Better World".to_string(),
dest: Some("D:/backups".to_string()),
},
SavesCommand::BackupWorld {
id: "inst".to_string(),
dir: "Better World".to_string(),
dest: None,
},
] {
let text = serde_json::to_string(&command).expect("序列化");
assert_eq!(
serde_json::from_str::<SavesCommand>(&text).expect("反序列化"),
command
);
}
}
/// 成功回执也要能过 IPC(备份那一支字段最多:路径 / 字节 / 跳过数)。
#[test]
fn op_receipts_round_trip() {
let cases = [
SavesOped::Renamed {
from: "Old".to_string(),
to: "New".to_string(),
},
SavesOped::Deleted {
dir: "Old".to_string(),
},
SavesOped::BackedUp {
dir: "Old".to_string(),
file: "Old-20240501-100000.zip".to_string(),
path: "D:/exports/Old-20240501-100000.zip".to_string(),
bytes: 4096,
skipped: 2,
},
];
for op in cases {
let mut view = view();
view.last_op = Some(op);
let text = serde_json::to_string(&view).expect("序列化");
assert_eq!(
serde_json::from_str::<SavesListView>(&text).expect("反序列化"),
view
);
}
}
/// 两个目录都不存在 ⇒ 空(`is_empty` 只看内容,不看 `exists`:一个目录有空世界也算非空)。
#[test]
fn empty_means_no_worlds_and_no_screenshots() {
let mut view = view();
view.worlds.clear();
view.screenshots.clear();
assert!(view.is_empty(), "没有世界也没有截图就该算空");
view.screenshots.push(ScreenshotView {
file: "a.png".to_string(),
bytes: 0,
modified_ms: None,
});
assert!(!view.is_empty());
}