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

+187 -13
View File
@@ -1,12 +1,24 @@
//! 实例存档 / 截图的宿主实现(M235 · STOREMVP)。
//! 实例存档 / 截图的宿主实现(M235 · STOREMVP 读路径 + M247 · SAVESOPS 写路径)。
//!
//! ## 三条纪律
//! ## 四条纪律
//!
//! | 纪律 | 落点 |
//! |---|---|
//! | 磁盘只在后台线程碰 | [`spawn_list`] / [`spawn_open_folder`] 走 `job::spawn`,UI 只收快照 |
//! | 磁盘只在后台线程碰 | [`spawn_list`] / [`spawn_open_folder`] / [`spawn_rename`] / [`spawn_delete`] / [`spawn_backup`] 全走 `job::spawn`,UI 只收快照 |
//! | 单根语义(D20) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生、不做双根合并 |
//! | 只读 | 本模块一次写盘都没有;`saves/` 不存在也不替玩家建(core 的 `saves::scan` 同理) |
//! | 读路径只读 | [`list`] 一次写盘都没有;`saves/` 不存在也不替玩家建(core 的 `saves::scan` 同理) |
//! | 写路径自己重校边界 | 三个写操作**不在 host 这边信任自己**:core 的 `saves::write` 每次重新校验 `run_dir` 与目标路径(越界 / 符号链接 / 撞名都在那里拒) |
//!
//! ## 三个写操作的回程形状
//!
//! | 操作 | 成功 | 失败 |
//! |---|---|---|
//! | 重命名 | 新快照 + [`SavesOped::Renamed`] | 具名 [`SavesFailureKind`](撞名 / 名字不可用 / 越界 / 链接 / IO) |
//! | 删除 | 新快照 + [`SavesOped::Deleted`] | 同上 |
//! | 备份 | 新快照 + [`SavesOped::BackedUp`](落点路径 / 字节数 / 跳过的链接数) | 同上 + `TooLarge` |
//!
//! **写成功后一律重新列一遍**(`snapshot`):界面画的永远是磁盘上的真相,不是"我以为我改成了什么"。
//! 回执只是"这次动的是哪一条",与快照一起回去,所以 UI 不会出现「列表变了但不知道谁变的」。
//!
//! ## 为什么不复用启动路径的 `run_dir()`
//!
@@ -22,21 +34,30 @@
//! 真正拉起文件管理器的是 [`unlucky_core::platform::opener::open_dir`](`AGENTS.md` §6:
//! 平台差异只许收敛在 `core::platform`,本文件一个 `cfg` 都没有,也没有一次裸 spawn)。
//!
//! ## 备份落点为什么是启动器自己的 `exports/`
//!
//! 本仓没有 `rfd` 这类原生文件选择框依赖(`core::launch::crash_bundle::default_target_dir` 的
//! 文档里记着同一条理由),所以「用户选定目录」先退化成:`<data>/exports/saves/`。
//! 命令带一个可选的 `dest`,将来接了选择框、或 CLI 想指定别处,都不用改 core。
//! 成功的回执里带**完整路径 + 字节数**,用户不用猜落点。
//!
//! ## 失败口径
//!
//! 一律走 [`crate::dto::SavesFailureView`](结构 + OS 原文),**不拼中文**:文案由 app 层按
//! i18n key 现求(M161 的纪律)。三类失败见 [`crate::dto::SavesFailureKind`]。
//! i18n key 现求(M161 的纪律)。读路径三类失败见 [`crate::dto::SavesFailureKind`],
//! 写路径八类见同一个枚举的 `Write*` 那一族——每一种在 UI 上都有一句话。
use std::path::PathBuf;
use unlucky_core::game::install::instance::read_instance_config;
use unlucky_core::game::isolation::derive_game_dir;
use unlucky_core::game::saves::{self, SavesError, SavesScan};
use unlucky_core::game::saves::{self, SavesError, SavesScan, SavesWriteError, SavesWriteKind};
use unlucky_core::platform::opener;
use crate::dto::{
locate, InstanceId, InstanceSummary, SavesArea, SavesCommand, SavesFailureKind,
SavesFailureView, SavesListView, SavesSkipKind, SavesSkipView, ScreenshotView, WorldView,
locate, InstanceId, InstanceSummary, SavesArea, SavesCommand, SavesFailureKind, SavesFailureOp,
SavesFailureView, SavesListView, SavesOped, SavesSkipKind, SavesSkipView, ScreenshotView,
WorldView,
};
use super::env::HostEnv;
@@ -81,14 +102,135 @@ pub fn dispatch(env: HostEnv, tx: JobSender, command: SavesCommand) {
match command {
SavesCommand::List(id) => spawn_list(env, tx, id),
SavesCommand::OpenFolder { id } => spawn_open_folder(env, tx, id),
SavesCommand::RenameWorld { id, dir, to } => spawn_rename(env, tx, id, dir, to),
SavesCommand::DeleteWorld { id, dir } => spawn_delete(env, tx, id, dir),
SavesCommand::BackupWorld { id, dir, dest } => spawn_backup(env, tx, id, dir, dest),
}
}
/// 后台重命名一个世界,**并把刷新后的快照与回执一起带回来**。
pub fn spawn_rename(env: HostEnv, tx: JobSender, id: InstanceId, dir: String, to: String) {
spawn("saves-rename", move || {
let report = Report {
instance: id.clone(),
list: rename(&env, &id, &dir, &to),
};
let _ = tx.send(super::HostTask::Saves(Box::new(report)));
});
}
/// 后台删除一个世界(**UI 已经二次确认过**;host 侧照样重新过边界校验)。
pub fn spawn_delete(env: HostEnv, tx: JobSender, id: InstanceId, dir: String) {
spawn("saves-delete", move || {
let report = Report {
instance: id.clone(),
list: delete(&env, &id, &dir),
};
let _ = tx.send(super::HostTask::Saves(Box::new(report)));
});
}
/// 后台把一个世界备份成 zip(落点见 [`export_dir`]),并带回新快照。
pub fn spawn_backup(
env: HostEnv,
tx: JobSender,
id: InstanceId,
dir: String,
dest: Option<String>,
) {
spawn("saves-backup", move || {
let report = Report {
instance: id.clone(),
list: backup(&env, &id, &dir, dest.as_deref()),
};
let _ = tx.send(super::HostTask::Saves(Box::new(report)));
});
}
/// 列目录(后台线程里调)。
fn list(env: &HostEnv, id: &str) -> Result<SavesListView, SavesFailureView> {
let (instance, run_dir) = target(env, id)?;
let scan = saves::scan(&run_dir).map_err(|error| unusable(&instance.id, &error))?;
Ok(view(&instance, &scan))
snapshot(&instance, &run_dir, None)
}
/// 重新扫一遍目录 → 快照(可挂一条写操作的回执)。
///
/// 写成功后**一律**走这里:界面画的是磁盘上的真相,不是"我以为我改成了什么"。
fn snapshot(
instance: &InstanceSummary,
run_dir: &std::path::Path,
last_op: Option<SavesOped>,
) -> Result<SavesListView, SavesFailureView> {
let scan = saves::scan(run_dir).map_err(|error| unusable(&instance.id, &error))?;
Ok(view(&instance.id, &scan, last_op))
}
/// 重命名 → 再列一遍(回执里记「谁改成了什么」)。
fn rename(
env: &HostEnv,
id: &str,
from: &str,
to: &str,
) -> Result<SavesListView, SavesFailureView> {
let (instance, run_dir) = target(env, id)?;
saves::rename_world(&run_dir, from, to)
.map_err(|error| write_failure(&instance.id, SavesFailureOp::Rename, from, &error))?;
snapshot(
&instance,
&run_dir,
Some(SavesOped::Renamed {
from: from.to_string(),
to: to.to_string(),
}),
)
}
/// 删除 → 再列一遍(回执里记被删掉的那个名字)。
fn delete(env: &HostEnv, id: &str, dir: &str) -> Result<SavesListView, SavesFailureView> {
let (instance, run_dir) = target(env, id)?;
saves::delete_world(&run_dir, dir)
.map_err(|error| write_failure(&instance.id, SavesFailureOp::Delete, dir, &error))?;
snapshot(
&instance,
&run_dir,
Some(SavesOped::Deleted {
dir: dir.to_string(),
}),
)
}
/// 备份 → 再列一遍(回执里记落点路径 / 字节数 / 被跳过的链接数)。
fn backup(
env: &HostEnv,
id: &str,
dir: &str,
dest: Option<&str>,
) -> Result<SavesListView, SavesFailureView> {
let (instance, run_dir) = target(env, id)?;
let dest = dest.map_or_else(|| export_dir(env), PathBuf::from);
let done = saves::backup_world(&run_dir, dir, &dest)
.map_err(|error| write_failure(&instance.id, SavesFailureOp::Backup, dir, &error))?;
let file = done
.path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
snapshot(
&instance,
&run_dir,
Some(SavesOped::BackedUp {
dir: dir.to_string(),
file,
path: done.path.display().to_string(),
bytes: done.bytes,
skipped: done.skipped,
}),
)
}
/// 备份的默认落点:`<data>/exports/saves/`(见模块文档「备份落点」一节)。
fn export_dir(env: &HostEnv) -> PathBuf {
env.layout.data().join("exports").join("saves")
}
/// 打开存档目录 → 再列一遍(失败只说"打不开",原文进括号)。
@@ -98,7 +240,7 @@ fn open_folder(env: &HostEnv, id: &str) -> Result<SavesListView, SavesFailureVie
let target = open_target(&scan, &run_dir);
opener::open_dir(&target)
.map_err(|detail| failure(&instance.id, SavesFailureKind::OpenFailed, detail))?;
Ok(view(&instance, &scan))
Ok(view(&instance.id, &scan, None))
}
/// 打开哪一个目录:`saves/` → `screenshots/` → 运行目录(取第一个存在的)。
@@ -144,9 +286,9 @@ fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String>
}
/// 快照映射:core 的扫描结果 → DTO(路径串在 host 这一侧才成形)。
fn view(instance: &InstanceSummary, scan: &SavesScan) -> SavesListView {
fn view(instance: &str, scan: &SavesScan, last_op: Option<SavesOped>) -> SavesListView {
SavesListView {
instance: instance.id.clone(),
instance: instance.to_string(),
dir: scan.dir.display().to_string(),
exists: scan.exists,
worlds: scan
@@ -188,12 +330,15 @@ fn view(instance: &InstanceSummary, scan: &SavesScan) -> SavesListView {
},
})
.collect(),
last_op,
}
}
fn failure(instance: &str, kind: SavesFailureKind, detail: impl Into<String>) -> SavesFailureView {
SavesFailureView {
instance: instance.to_string(),
file: String::new(),
op: None,
kind,
detail: detail.into(),
}
@@ -208,5 +353,34 @@ fn unusable(instance: &str, error: &SavesError) -> SavesFailureView {
)
}
/// core 的写失败 → 具名类别 + 「路径:原文」(**不翻译**,翻译在 app 层)。
///
/// 八个类别一一对应,没有一个落到「未知」兜底里:兜底会变成静默(用户只看到"失败了")。
fn write_failure(
instance: &str,
op: SavesFailureOp,
file: &str,
error: &SavesWriteError,
) -> SavesFailureView {
let kind = match error.kind {
SavesWriteKind::UnsafeName => SavesFailureKind::WriteUnsafeName,
SavesWriteKind::SymbolicLink => SavesFailureKind::WriteSymlink,
SavesWriteKind::NotFound => SavesFailureKind::WriteMissing,
SavesWriteKind::NotADirectory => SavesFailureKind::WriteNotADirectory,
SavesWriteKind::Conflict => SavesFailureKind::WriteConflict,
SavesWriteKind::Boundary => SavesFailureKind::WriteBoundary,
SavesWriteKind::TooLarge => SavesFailureKind::WriteTooLarge,
SavesWriteKind::Io => SavesFailureKind::WriteFailed,
};
let mut failure = failure(
instance,
kind,
format!("{}:{}", error.path.display(), error.detail),
);
failure.file = file.to_string();
failure.op = Some(op);
failure
}
#[cfg(test)]
mod tests;
+180 -1
View File
@@ -73,6 +73,12 @@ fn instance() -> InstanceSummary {
}
}
/// 一个真世界(`saves/<name>/level.dat`)。
fn world(run: &std::path::Path, name: &str) {
std::fs::create_dir_all(run.join("saves").join(name)).expect("建世界");
std::fs::write(run.join("saves").join(name).join("level.dat"), b"nbt").expect("写 level.dat");
}
/// **端到端**(真盘 + 真派生):世界 / 截图 / 跳过原因都过桥,且快照里的路径就是派生目录。
///
/// 抓得住:派生用错目录、`icon.png` 被当成"读到了"、跳过原因被折叠、字节数丢了。
@@ -194,6 +200,179 @@ fn every_core_skip_reason_keeps_its_own_kind() {
reason,
}],
};
assert_eq!(view(&instance(), &scan).skipped[0].reason, expected);
assert_eq!(view(ID, &scan, None).skipped[0].reason, expected);
}
}
/// **端到端**:重命名 → 真盘改名 + 新快照 + 回执。
///
/// 抓得住:host 用错运行目录、写完成却忘了重新列(界面还画旧名字)、回执与快照对不上。
#[test]
fn rename_round_trips_on_a_real_root() {
let (_root, env, run) = fixture("saves-rename");
world(&run, "Old World");
world(&run, "Other");
let listed = rename(&env, ID, "Old World", "New World").expect("改名");
assert!(!run.join("saves/Old World").exists(), "旧目录还在");
assert!(
run.join("saves/New World/level.dat").exists(),
"新目录里没有内容"
);
let names: Vec<&str> = listed
.worlds
.iter()
.map(|world| world.dir.as_str())
.collect();
assert_eq!(names, ["New World", "Other"], "快照没跟着刷新");
assert_eq!(
listed.last_op,
Some(SavesOped::Renamed {
from: "Old World".to_string(),
to: "New World".to_string()
})
);
}
/// **端到端**:删除 → 真盘没了 + 新快照 + 回执。
#[test]
fn delete_round_trips_on_a_real_root() {
let (_root, env, run) = fixture("saves-delete");
world(&run, "Doomed");
world(&run, "Kept");
let listed = delete(&env, ID, "Doomed").expect("删除");
assert!(!run.join("saves/Doomed").exists(), "目录还在");
let names: Vec<&str> = listed
.worlds
.iter()
.map(|world| world.dir.as_str())
.collect();
assert_eq!(names, ["Kept"], "别的世界被连带删了 / 快照没刷新");
assert_eq!(
listed.last_op,
Some(SavesOped::Deleted {
dir: "Doomed".to_string()
})
);
}
/// **端到端**:备份 → zip 落在启动器自己的 `exports/saves/`,回执带路径与字节数。
#[test]
fn backup_round_trips_to_the_exports_dir() {
let (_root, env, run) = fixture("saves-backup");
world(&run, "My World");
let listed = backup(&env, ID, "My World", None).expect("备份");
let exports = env.layout.data().join("exports").join("saves");
let Some(SavesOped::BackedUp {
dir,
file,
path,
bytes,
skipped,
}) = listed.last_op.clone()
else {
panic!("备份成功却没有回执:{:?}", listed.last_op);
};
assert_eq!(dir, "My World");
assert!(
file.starts_with("My World-"),
"文件名要带世界名与时戳:{file}"
);
assert!(file.ends_with(".zip"), "文件名要带 .zip:{file}");
assert_eq!(std::path::PathBuf::from(&path), exports.join(&file));
assert!(std::path::Path::new(&path).exists(), "zip 没真的落盘");
assert!(bytes > 0);
assert_eq!(skipped, 0);
// 备份不改世界本身:列表还是一行。
assert_eq!(listed.world_count(), 1);
// 显式落点也要照用(将来接了目录选择框就走这条)。
let custom = backup(
&env,
ID,
"My World",
Some(&run.join("elsewhere").display().to_string()),
)
.expect("备份到指定目录");
assert!(matches!(custom.last_op, Some(SavesOped::BackedUp { .. })));
assert!(run.join("elsewhere").is_dir(), "显式落点没被采用");
}
/// 八种写失败各给**自己的**类别(没有一个落到「未知」兜底)。
#[test]
fn every_write_failure_keeps_its_own_kind() {
let (_root, env, run) = fixture("saves-write-kinds");
world(&run, "Taken");
world(&run, "Other");
let cases = [
("Other", "Taken", SavesFailureKind::WriteConflict),
("Other", "..", SavesFailureKind::WriteUnsafeName),
("Nope", "Fresh", SavesFailureKind::WriteMissing),
];
for (from, to, expected) in cases {
let failure = rename(&env, ID, from, to).expect_err("改成功了");
assert_eq!(failure.kind, expected, "{from} → {to}:{}", failure.detail);
}
let failure = delete(&env, ID, "Nope").expect_err("删成功了");
assert_eq!(failure.kind, SavesFailureKind::WriteMissing);
// 空 / 相对的落点目录:会落到进程当前目录,所以是"越界"而不是"IO 失败"。
let failure = backup(&env, ID, "Taken", Some("")).expect_err("备份到空目录居然成功了");
assert_eq!(
failure.kind,
SavesFailureKind::WriteBoundary,
"{}",
failure.detail
);
// core 的八个类别 → DTO 八个类别(一一对应,不折叠)。
let pairs = [
(
SavesWriteKind::UnsafeName,
SavesFailureKind::WriteUnsafeName,
),
(SavesWriteKind::SymbolicLink, SavesFailureKind::WriteSymlink),
(SavesWriteKind::NotFound, SavesFailureKind::WriteMissing),
(
SavesWriteKind::NotADirectory,
SavesFailureKind::WriteNotADirectory,
),
(SavesWriteKind::Conflict, SavesFailureKind::WriteConflict),
(SavesWriteKind::Boundary, SavesFailureKind::WriteBoundary),
(SavesWriteKind::TooLarge, SavesFailureKind::WriteTooLarge),
(SavesWriteKind::Io, SavesFailureKind::WriteFailed),
];
for (core, expected) in pairs {
let error = SavesWriteError {
path: PathBuf::from("C:/mc/saves/x"),
kind: core,
detail: "原文".to_string(),
};
let failure = write_failure(ID, SavesFailureOp::Backup, "Taken", &error);
assert_eq!(failure.kind, expected, "{core:?}");
assert!(
failure.detail.contains("saves"),
"原文丢了:{}",
failure.detail
);
assert_eq!(failure.instance, ID);
assert_eq!(failure.file, "Taken", "失败那句话要指名是哪一行");
assert_eq!(failure.op, Some(SavesFailureOp::Backup));
}
}
/// 找不到实例的写操作 ⇒ 同一个具名失败(不是"写成功了个空的")。
#[test]
fn write_ops_on_a_vanished_instance_are_named() {
let (_root, env, _run) = fixture("saves-write-unknown");
for failure in [
rename(&env, "gone", "A", "B").expect_err("改名成功了"),
delete(&env, "gone", "A").expect_err("删除成功了"),
backup(&env, "gone", "A", None).expect_err("备份成功了"),
] {
assert_eq!(failure.kind, SavesFailureKind::InstanceUnavailable);
assert_eq!(failure.instance, "gone");
}
}