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 三种形态。
79 lines
4.0 KiB
Rust
79 lines
4.0 KiB
Rust
//! 存档 / 截图的列举与「打开文件夹」(M235 · STOREMVP)+ 三个写操作(M247 · SAVESOPS)。
|
||
//!
|
||
//! # 读的那一半(M235)
|
||
//!
|
||
//! 用户原话是「存档截图 / 导入」,M230 审计确认三层全缺(core 没有读路径,`saves/` 只在
|
||
//! `isolation.rs` 的切换探测里被计数;presenter 没有 DTO;i18n 没有键;没有页面)。
|
||
//! U3 把 M235 的边界钉成:**只读列表 + 打开文件夹,不做缩略图墙**。理由有两条:
|
||
//!
|
||
//! 1. 缩略图要引图片解码依赖,而 `AGENTS.md` §2 只允许 `palette-extract` 之外不引 image 类依赖;
|
||
//! 2. 截图/导入的产物形态还没定型,先不进产品。
|
||
//!
|
||
//! 所以 [`list`] **只读**:只调 `read_dir` / `metadata` / `symlink_metadata`,不建目录、不写文件、
|
||
//! 不改名字、不读文件内容(`icon.png` 只判存在——那是将来缩略图墙的输入,不是这一版的解码对象)。
|
||
//!
|
||
//! # 写的那一半(M247)
|
||
//!
|
||
//! 用户接着拍板:「存档按照主流的方式来」——主流第三方启动器(HMCL / PCL2)的存档区都有
|
||
//! **重命名世界 / 删除世界(二次确认)/ 备份导出**。这三个落在 [`write`]:
|
||
//!
|
||
//! | 操作 | 入口 | 边界 |
|
||
//! |---|---|---|
|
||
//! | 重命名 | [`write::rename_world`] | 只动 `saves/<old>` → `saves/<new>`;撞名拒 |
|
||
//! | 删除 | [`write::delete_world`] | 先过边界校验,再递归删;不跟随符号链接 |
|
||
//! | 备份 | [`write::backup_world`] | 流式 zip 到调用方给的目录,时戳命名,不覆盖 |
|
||
//!
|
||
//! **写操作一律自己重新校验 `run_dir` 与目标路径**(不靠调用方传对)——判据在
|
||
//! [`write`] 的模块文档里一条条列着。
|
||
//!
|
||
//! # 单根语义(D20)
|
||
//!
|
||
//! 入口是**已经派生好的运行目录**([`crate::game::isolation::derive_game_dir`] 的结果),
|
||
//! 本模块只往下取两层:`<run_dir>/saves/` 与 `<run_dir>/screenshots/`。
|
||
//! 不自己去猜游戏根,也不做「隔离侧 + 共享侧合并」——派生是启动、mod 列表、存档列表**同一个**
|
||
//! 答案的唯一来源。与 [`crate::addon::mods`] 同一条纪律。
|
||
//!
|
||
//! # 目录不存在不是错误
|
||
//!
|
||
//! 新实例两个目录都不存在,这**不是**异常(`exists = false` + 空列表)。只有「存在但不可用」
|
||
//! (符号链接 / 不是目录 / 读不了)才是 [`SavesError`];**并且照样不建目录**。
|
||
//! 写操作那边不同:目标不存在就是 [`write::SavesWriteKind::NotFound`](写不下去,得说清楚)。
|
||
//!
|
||
//! # 收纳口径
|
||
//!
|
||
//! | 位置 | 收什么 | 不收什么(逐条登记原因,不静默丢) |
|
||
//! |---|---|---|
|
||
//! | `saves/` | 子目录 = 一个世界 | 符号链接(不跟随)、非目录(散落文件)、名字过不了路径规则、元数据读不出 |
|
||
//! | `screenshots/` | 扩展名是 [`IMAGE_EXTENSIONS`] 的普通文件 | 同上 + 不是图片的文件(`NotAnImage`) |
|
||
//!
|
||
//! 「不静默丢」是 M224 定下的口径:列表要能和磁盘对得上账,用户看到「跳过 3 项」时得知道
|
||
//! 那 3 项不是我们忘了。
|
||
//!
|
||
//! # 模块
|
||
//!
|
||
//! | 文件 | 职责 |
|
||
//! |---|---|
|
||
//! | [`error`] | 「目录存在但不可用」的失败形状(带 OS 原文) |
|
||
//! | [`list`] | 列 `<run_dir>/saves` 与 `<run_dir>/screenshots`(纯只读) |
|
||
//! | [`write`] | 重命名 / 删除 / 备份导出(三个写操作 + 边界校验) |
|
||
//!
|
||
//! 「打开文件夹」不在本模块:平台差异收敛在 [`crate::platform::opener::open_dir`]
|
||
//! (`AGENTS.md` §6),本模块一个 `cfg` 都不写。
|
||
|
||
mod error;
|
||
mod list;
|
||
mod write;
|
||
|
||
pub use error::SavesError;
|
||
pub use list::{
|
||
saves_dir, scan, screenshots_dir, Area, SavesScan, SavesSkip, ScreenshotEntry, SkipKind,
|
||
WorldEntry, IMAGE_EXTENSIONS, SAVES_DIR, SCREENSHOTS_DIR, WORLD_ICON,
|
||
};
|
||
pub use write::{
|
||
backup_world, delete_world, rename_world, Backup, SavesWriteError, SavesWriteKind, BACKUP_EXT,
|
||
MAX_BACKUP_ENTRIES, MAX_BACKUP_TOTAL_BYTES,
|
||
};
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|