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:
1 parent
3e852331f4
commit
c921b9c185
28 files changed
+3016
-216
No files matched your search
@@ -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::{
|
||||
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
Reference in new issue
Block a user