Files
Unlucky-Launcher/crates/core/src/game/saves/mod.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

79 lines
4.0 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.
//! 存档 / 截图的列举与「打开文件夹」(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;