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
@@ -178,7 +178,7 @@ impl App {
|
|||||||
// 出图取证(`ULL_SAVES_FIXTURE=1`):后台往返赶不上第 3 帧出图,注入一份固定快照。
|
// 出图取证(`ULL_SAVES_FIXTURE=1`):后台往返赶不上第 3 帧出图,注入一份固定快照。
|
||||||
// 产品路径读不到这个变量 ⇒ 下面这行什么都不做(见 `saves_shot` 的模块文档)。
|
// 产品路径读不到这个变量 ⇒ 下面这行什么都不做(见 `saves_shot` 的模块文档)。
|
||||||
if saves_shot::enabled() {
|
if saves_shot::enabled() {
|
||||||
self.state.saves.changed(saves_shot::view(&id));
|
saves_shot::seed(self, &id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// **不走 `App::dispatch`**:那条路会为「等第一条事件」把帧订阅打开 3 秒,
|
// **不走 `App::dispatch`**:那条路会为「等第一条事件」把帧订阅打开 3 秒,
|
||||||
|
|||||||
@@ -77,5 +77,76 @@ pub fn view(instance: &str) -> unlucky_presenter::dto::SavesListView {
|
|||||||
shot("2024-04-20_08.30.00.png", 964_608, None),
|
shot("2024-04-20_08.30.00.png", 964_608, None),
|
||||||
],
|
],
|
||||||
skipped: Vec::new(),
|
skipped: Vec::new(),
|
||||||
|
// 出图夹具不造回执:默认那一张就是「刚列完目录」的样子。
|
||||||
|
// 要看「备份成功那句话」长什么样,用 `ULL_SAVES_FIXTURE=backed-up`(见 `seed`)。
|
||||||
|
last_op: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 夹具形态(由 `ULL_SAVES_FIXTURE` 的取值决定;未知值 = 默认那一种)。
|
||||||
|
///
|
||||||
|
/// | 取值 | 出图抓什么 |
|
||||||
|
/// |---|---|
|
||||||
|
/// | `1`(或任何未知值) | 有内容的列表:世界行右侧的重命名 / 备份 / 删除 |
|
||||||
|
/// | `rename` | 行内重命名进行中(输入框 + 改名/取消) |
|
||||||
|
/// | `confirm` | 删除的二次确认模态框 |
|
||||||
|
/// | `backed-up` | 备份成功的回执那句话(带落点路径与大小) |
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Mode {
|
||||||
|
/// 默认:列表 + 行操作区。
|
||||||
|
Plain,
|
||||||
|
/// 行内重命名进行中。
|
||||||
|
Rename,
|
||||||
|
/// 删除确认模态。
|
||||||
|
Confirm,
|
||||||
|
/// 备份成功的回执。
|
||||||
|
BackedUp,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mode() -> Mode {
|
||||||
|
match std::env::var("ULL_SAVES_FIXTURE")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
{
|
||||||
|
"rename" => Mode::Rename,
|
||||||
|
"confirm" => Mode::Confirm,
|
||||||
|
"backed-up" => Mode::BackedUp,
|
||||||
|
_ => Mode::Plain,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注入这一屏需要的状态(**只走产品路径的入口**:`changed` / `start_rename` / `ask_delete` /
|
||||||
|
/// `dialogs.push`,一处新渲染分支都没有)。
|
||||||
|
pub fn seed(app: &mut crate::app::state::App, instance: &str) {
|
||||||
|
let mode = mode();
|
||||||
|
let mut view = view(instance);
|
||||||
|
if mode == Mode::BackedUp {
|
||||||
|
view.last_op = Some(unlucky_presenter::dto::SavesOped::BackedUp {
|
||||||
|
dir: "Better World".to_string(),
|
||||||
|
file: "Better World-20240501-100000.zip".to_string(),
|
||||||
|
path: "D:/ULL/.ULL/data/exports/saves/Better World-20240501-100000.zip".to_string(),
|
||||||
|
bytes: 84_213_760,
|
||||||
|
skipped: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let first = view.worlds.first().map(|world| world.dir.clone());
|
||||||
|
app.state.saves.changed(view);
|
||||||
|
let Some(dir) = first else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match mode {
|
||||||
|
Mode::Rename => app.state.saves.start_rename(&dir),
|
||||||
|
Mode::Confirm => {
|
||||||
|
app.state.saves.ask_delete(&dir);
|
||||||
|
let (title, body, primary, secondary) =
|
||||||
|
crate::pages::saves_delete_prompt(app.lang().catalog(), &dir);
|
||||||
|
app.state.dialogs.push(
|
||||||
|
unlucky_presenter::dialogs::DialogSource::UserAction,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
unlucky_presenter::dialogs::DialogKind::Confirm { primary, secondary },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Mode::Plain | Mode::BackedUp => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,24 @@ use crate::app::state::App;
|
|||||||
pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::app::Message> {
|
pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::app::Message> {
|
||||||
match message {
|
match message {
|
||||||
DialogMessage::Primary => {
|
DialogMessage::Primary => {
|
||||||
|
// M247:删除存档的二次确认——主按钮就是**真删**(这是本页唯一不可逆的动作)。
|
||||||
|
// 目标记在 presenter 的状态里(`SavesState::confirm`):模态队列只画框,不记"要删谁"。
|
||||||
|
// 取走即清,所以连点两下不会删两个(第二下拿不到目标 ⇒ 只关框)。
|
||||||
|
if let Some(dir) = app.state.saves.take_confirm() {
|
||||||
|
app.close_dialog();
|
||||||
|
if let Some(instance) = app.selected_instance_id() {
|
||||||
|
app.state
|
||||||
|
.saves
|
||||||
|
.mark_pending(unlucky_presenter::state::SavesOp::Delete {
|
||||||
|
dir: dir.clone(),
|
||||||
|
});
|
||||||
|
app.dispatch(unlucky_presenter::dto::AppCommand::Saves(Box::new(
|
||||||
|
unlucky_presenter::dto::SavesCommand::DeleteWorld { id: instance, dir },
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
app.refresh_view();
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
// 崩溃卡片的主按钮是「导出诊断包」;普通确认框的主按钮就是「关掉」。
|
// 崩溃卡片的主按钮是「导出诊断包」;普通确认框的主按钮就是「关掉」。
|
||||||
let Some(card) = app.dialog_card.clone() else {
|
let Some(card) = app.dialog_card.clone() else {
|
||||||
app.close_dialog();
|
app.close_dialog();
|
||||||
@@ -38,6 +56,8 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
|
|||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
DialogMessage::Secondary | DialogMessage::Dismiss => {
|
DialogMessage::Secondary | DialogMessage::Dismiss => {
|
||||||
|
// M247:取消/关掉删除确认框 ⇒ 忘掉那个目标(不然下一次点确定会删上一个)。
|
||||||
|
app.state.saves.clear_confirm();
|
||||||
app.close_dialog();
|
app.close_dialog();
|
||||||
// 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒
|
// 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒
|
||||||
// (r1 复审 P3-1)。
|
// (r1 复审 P3-1)。
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
//! 要等回程事件,写盘失败时旧快照回来必须留住用户敲的值(M157 r3 复审 P2-2)。
|
//! 要等回程事件,写盘失败时旧快照回来必须留住用户敲的值(M157 r3 复审 P2-2)。
|
||||||
|
|
||||||
use iced::Task;
|
use iced::Task;
|
||||||
|
use unlucky_presenter::dialogs::{DialogKind, DialogSource};
|
||||||
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch, ModsCommand, SavesCommand};
|
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch, ModsCommand, SavesCommand};
|
||||||
|
use unlucky_presenter::state::SavesOp;
|
||||||
|
|
||||||
use crate::pages::InstanceMessage;
|
use crate::pages::InstanceMessage;
|
||||||
|
|
||||||
@@ -56,6 +58,73 @@ impl App {
|
|||||||
})));
|
})));
|
||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
|
// M247:行内重命名的三个动作。**输入过程不 dispatch**(只改 presenter 的草稿),
|
||||||
|
// 提交才发命令——与启动参数表单「编辑只改状态,保存才派命令」同一条纪律。
|
||||||
|
InstanceMessage::StartRenameWorld { dir } => {
|
||||||
|
self.state.saves.start_rename(&dir);
|
||||||
|
self.view_dirty = true;
|
||||||
|
self.refresh_view();
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
InstanceMessage::RenameWorldChanged(text) => {
|
||||||
|
self.state.saves.edit_rename(text);
|
||||||
|
self.view_dirty = true;
|
||||||
|
self.refresh_view();
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
InstanceMessage::CommitRenameWorld => {
|
||||||
|
let Some(draft) = self.state.saves.take_rename() else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
// 空名字 / 没改 → 直接收起,不打后台:这两个不是"操作失败",是"用户改主意了"。
|
||||||
|
if draft.text.is_empty() || draft.text == draft.dir {
|
||||||
|
self.view_dirty = true;
|
||||||
|
self.refresh_view();
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
self.state.saves.mark_pending(SavesOp::Rename {
|
||||||
|
dir: draft.dir.clone(),
|
||||||
|
});
|
||||||
|
self.dispatch(AppCommand::Saves(Box::new(SavesCommand::RenameWorld {
|
||||||
|
id: instance,
|
||||||
|
dir: draft.dir,
|
||||||
|
to: draft.text,
|
||||||
|
})));
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
InstanceMessage::CancelRenameWorld => {
|
||||||
|
self.state.saves.cancel_rename();
|
||||||
|
self.view_dirty = true;
|
||||||
|
self.refresh_view();
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
// M247:删除**先弹确认框**。确认目标是"哪个世界"必须记在状态里——
|
||||||
|
// 主按钮落点在 `app/update/dialog.rs`,那里只有 `App`,没有这个页面。
|
||||||
|
InstanceMessage::RequestDeleteWorld { dir } => {
|
||||||
|
self.state.saves.ask_delete(&dir);
|
||||||
|
let (title, body, primary, secondary) =
|
||||||
|
crate::pages::saves_delete_prompt(self.lang().catalog(), &dir);
|
||||||
|
self.state.dialogs.push(
|
||||||
|
DialogSource::UserAction,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
DialogKind::Confirm { primary, secondary },
|
||||||
|
);
|
||||||
|
self.view_dirty = true;
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
// M247:备份——落点由 host 定(`<data>/exports/saves/`),回执里带完整路径。
|
||||||
|
InstanceMessage::BackupWorld { dir } => {
|
||||||
|
self.state
|
||||||
|
.saves
|
||||||
|
.mark_pending(SavesOp::Backup { dir: dir.clone() });
|
||||||
|
self.dispatch(AppCommand::Saves(Box::new(SavesCommand::BackupWorld {
|
||||||
|
id: instance,
|
||||||
|
dir,
|
||||||
|
dest: None,
|
||||||
|
})));
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
// 表单还没对着这个实例(首帧还没播种):不接编辑,免得改到别的实例上。
|
// 表单还没对着这个实例(首帧还没播种):不接编辑,免得改到别的实例上。
|
||||||
_ if !self.launch_form.bound_to(&instance) => return Task::none(),
|
_ if !self.launch_form.bound_to(&instance) => return Task::none(),
|
||||||
InstanceMessage::Jvm(text) => {
|
InstanceMessage::Jvm(text) => {
|
||||||
@@ -96,7 +165,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 详情页当前选中的实例 id(不在详情页时为 `None`)。
|
/// 详情页当前选中的实例 id(不在详情页时为 `None`)。
|
||||||
fn selected_instance_id(&self) -> Option<String> {
|
///
|
||||||
|
/// `pub(in crate::app)`(M247):删除确认框的主按钮在 `app/update/dialog.rs`,也要问同一个问题。
|
||||||
|
pub(in crate::app) fn selected_instance_id(&self) -> Option<String> {
|
||||||
match self.state.route() {
|
match self.state.route() {
|
||||||
unlucky_presenter::router::Route::Detail(id) => Some(id.clone()),
|
unlucky_presenter::router::Route::Detail(id) => Some(id.clone()),
|
||||||
_ => None,
|
_ => None,
|
||||||
|
|||||||
@@ -75,6 +75,23 @@ pub fn saves_failure_line(
|
|||||||
saves::failure_line(failure, Texts::of(catalog))
|
saves::failure_line(failure, Texts::of(catalog))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 删除世界的确认框文案:`(标题, 正文, 主按钮, 次按钮)`。
|
||||||
|
///
|
||||||
|
/// 模态框在 app 层建(`state.dialogs.push`),但**取词入口仍是本页的 [`Texts`]**——与
|
||||||
|
/// [`saves_failure_line`] 同一条口径:页面文案只有一个入口,测试扫的也是那一份源码。
|
||||||
|
pub fn saves_delete_prompt(
|
||||||
|
catalog: unlucky_i18n::Catalog,
|
||||||
|
dir: &str,
|
||||||
|
) -> (String, String, String, String) {
|
||||||
|
let texts = Texts::of(catalog);
|
||||||
|
(
|
||||||
|
texts.saves_delete_title().to_string(),
|
||||||
|
texts.saves_delete_body(dir),
|
||||||
|
texts.saves_delete_confirm().to_string(),
|
||||||
|
texts.saves_cancel().to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// 启停失败/读目录失败的一句话(app 层弹 Toast 用;取词入口仍是 [`Texts`])。
|
/// 启停失败/读目录失败的一句话(app 层弹 Toast 用;取词入口仍是 [`Texts`])。
|
||||||
pub fn mods_failure_line(
|
pub fn mods_failure_line(
|
||||||
failure: &unlucky_presenter::dto::ModFailureView,
|
failure: &unlucky_presenter::dto::ModFailureView,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! 实例详情页的 **SAVES 区**(M235 · STOREMVP):存档 / 截图的**只读**列表 + 打开文件夹。
|
//! 实例详情页的 **SAVES 区**(M235 · STOREMVP 读路径 + M247 · SAVESOPS 写操作):
|
||||||
|
//! 存档 / 截图列表 + 打开文件夹 + **每行的重命名 / 备份 / 删除**。
|
||||||
//!
|
//!
|
||||||
//! # 位置与形状
|
//! # 位置与形状
|
||||||
//!
|
//!
|
||||||
@@ -14,17 +15,42 @@
|
|||||||
//! 详情列**整列**已经是滚动区(M157 r2)。M224 踩过一次「滚动条里的滚动条」:1180×1000 下两个条
|
//! 详情列**整列**已经是滚动区(M157 r2)。M224 踩过一次「滚动条里的滚动条」:1180×1000 下两个条
|
||||||
//! 都认得出来,用户不知道该拖哪一个。这里同样只排内容,长度交给外层那一条。
|
//! 都认得出来,用户不知道该拖哪一个。这里同样只排内容,长度交给外层那一条。
|
||||||
//!
|
//!
|
||||||
//! # 只读(U3 的边界,页面固定画出来)
|
//! # 行操作区(M247)
|
||||||
//!
|
//!
|
||||||
//! 塔裁决 U3 把这一版钉成「只读列表 + 打开文件夹」:没有删除、没有重命名、没有导入、**没有缩略图**
|
//! 每个**世界**行右侧三个文字按钮:**重命名 / 备份 / 删除**(截图行没有操作区:截图不在这三个
|
||||||
//! (缩略图要引图片解码依赖,`AGENTS.md` §2 不允许)。所以卡片里第一行永远画
|
//! 写操作的范围里)。三档状态:
|
||||||
//! [`Texts::saves_read_only`]——用户点开就看见"这一版只读",不会以为漏了删除按钮。
|
//!
|
||||||
|
//! | 状态 | 画什么 | 为什么 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | 空闲 | 三个按钮 | — |
|
||||||
|
//! | 这一行的请求在飞 | 一句「正在处理…」顶替按钮 | 连点两次会发两条 rename/delete,第二条必然「世界已不在」 |
|
||||||
|
//! | 正在行内改名 | 输入框 + 「改名」/「取消」 | 见下 |
|
||||||
|
//!
|
||||||
|
//! **不做乐观回显**:行上的名字只来自快照,点完等 host 回一份新快照才变(与 MODS 区同一纪律)。
|
||||||
|
//!
|
||||||
|
//! # 重命名为什么是行内输入,不是模态输入框
|
||||||
|
//!
|
||||||
|
//! 现成的模态队列有 `DialogKind::Input`,但 app 侧的 `dialog::Message` 只有主/次按钮与关闭,
|
||||||
|
//! **没有「输入变了」这一条**——用输入模态等于重写一套可编辑模态。行内输入复用
|
||||||
|
//! `components::inputs` 的单行框(与启动参数区同一个控件),一处新控件都不加。
|
||||||
|
//! 输入缓冲住在 presenter 的 [`SavesState::draft`](页面模型每帧从快照重建,缓冲留在页面里会被
|
||||||
|
//! 每一次事件冲掉)。
|
||||||
|
//!
|
||||||
|
//! # 删除为什么必须二次确认
|
||||||
|
//!
|
||||||
|
//! 这是本页唯一**不可逆**的动作。点「删除」只发一条
|
||||||
|
//! [`PageMessage::RequestDeleteWorld`],真正的删除要等模态确认框的主按钮
|
||||||
|
//! (`app/update/dialog.rs` 里那一支)。确认框复用仓内现成的模态队列(`state.dialogs`),
|
||||||
|
//! 页面不新写弹窗。
|
||||||
//!
|
//!
|
||||||
//! # 失败与重试
|
//! # 失败与重试
|
||||||
//!
|
//!
|
||||||
//! 读盘失败画一条红字 + 一个「重试」(再发一条 `SavesCommand::List`)。**打开文件夹失败不画重试**:
|
//! 读盘失败画一条红字 + 一个「重试」(再发一条 `SavesCommand::List`)。**打开文件夹失败与写操作
|
||||||
//! 那个动作的重试就是再点一次按钮,给一个会去重新列目录的「重试」是错的affordance
|
//! 失败不画重试**:那个动作的重试就是再点一次按钮,给一个会去重新列目录的「重试」是错的
|
||||||
//! (两种失败共用 `detail`,只有大类不同,见 [`Saves::retryable`])。
|
//! affordance(三种失败共用 `detail`,靠大类与 `op` 分别,见 [`failure_line`])。
|
||||||
|
//!
|
||||||
|
//! 写操作**成功**也有一句话([`Saves::notice`]):重命名 / 删除的结果列表上看得见,但备份的落点
|
||||||
|
//! 看不见——「点了备份然后什么也没发生」是静默失败,所以回执里带上完整路径与字节数。
|
||||||
|
|
||||||
use iced::widget::{column, container, row};
|
use iced::widget::{column, container, row};
|
||||||
use iced::{Alignment, Element, Length};
|
use iced::{Alignment, Element, Length};
|
||||||
@@ -33,7 +59,7 @@ use unlucky_design::color::Role;
|
|||||||
use unlucky_design::spacing::Space;
|
use unlucky_design::spacing::Space;
|
||||||
use unlucky_design::typography::TypeRole;
|
use unlucky_design::typography::TypeRole;
|
||||||
use unlucky_i18n::Catalog;
|
use unlucky_i18n::Catalog;
|
||||||
use unlucky_presenter::dto::{human_bytes, SavesFailureKind, SavesFailureView};
|
use unlucky_presenter::dto::{SavesFailureKind, SavesFailureOp, SavesFailureView};
|
||||||
use unlucky_presenter::state::SavesState;
|
use unlucky_presenter::state::SavesState;
|
||||||
|
|
||||||
use crate::app::components::buttons::{self, Button};
|
use crate::app::components::buttons::{self, Button};
|
||||||
@@ -46,15 +72,28 @@ use super::forced_state;
|
|||||||
use super::state::Message as PageMessage;
|
use super::state::Message as PageMessage;
|
||||||
use super::Texts;
|
use super::Texts;
|
||||||
|
|
||||||
/// 列表里的一行(世界 / 截图共用:一个名字 + 一条副文案)。
|
mod rows;
|
||||||
|
/// 写操作成功那句话的产地(实现在 [`rows`] 里,页面与测试都从这一条路取)。
|
||||||
|
pub(super) use rows::op_line;
|
||||||
|
|
||||||
|
/// 列表里的一行(一个世界 / 一张截图)。
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Row {
|
pub struct Row {
|
||||||
/// 世界目录名或截图文件名(磁盘原样)。
|
/// 世界目录名或截图文件名(磁盘原样;写操作把它原样带回去当「源」)。
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// 右侧/下方副文案(时间、大小、图标标记,已由这一层拼好)。
|
/// 右侧/下方副文案(时间、大小、图标标记,已由这一层拼好)。
|
||||||
pub detail: String,
|
pub detail: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 正在行内重命名的那一行(来源是 presenter 的 `SavesState::draft`)。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Editing {
|
||||||
|
/// 哪个世界。
|
||||||
|
pub dir: String,
|
||||||
|
/// 输入框里的内容(原样)。
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// SAVES 区的展示模型(`Model::saves`)。
|
/// SAVES 区的展示模型(`Model::saves`)。
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
pub struct Saves {
|
pub struct Saves {
|
||||||
@@ -62,9 +101,9 @@ pub struct Saves {
|
|||||||
pub bound: Option<String>,
|
pub bound: Option<String>,
|
||||||
/// 正在读盘(进详情页首帧:请求已发出、快照还没回来)。
|
/// 正在读盘(进详情页首帧:请求已发出、快照还没回来)。
|
||||||
pub loading: bool,
|
pub loading: bool,
|
||||||
/// 读盘/打开失败的人读原因(app 层按 i18n 拼好)。
|
/// 读盘/打开/写操作失败的人读原因(app 层按 i18n 拼好)。
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
/// 这一条失败能不能靠「再列一次」解决(打开文件夹失败不算)。
|
/// 这一条失败能不能靠「再列一次」解决(只有读盘失败算)。
|
||||||
pub retryable: bool,
|
pub retryable: bool,
|
||||||
/// 手里有没有一份**快照**(`false` = 只拿到了失败,"还没有世界"那几句不许画)。
|
/// 手里有没有一份**快照**(`false` = 只拿到了失败,"还没有世界"那几句不许画)。
|
||||||
pub has_view: bool,
|
pub has_view: bool,
|
||||||
@@ -84,6 +123,12 @@ pub struct Saves {
|
|||||||
pub skipped: usize,
|
pub skipped: usize,
|
||||||
/// 跳过提示(`跳过 3 项`)。
|
/// 跳过提示(`跳过 3 项`)。
|
||||||
pub skipped_note: String,
|
pub skipped_note: String,
|
||||||
|
/// 正在行内重命名的那一行(`None` = 没有在改名)。
|
||||||
|
pub editing: Option<Editing>,
|
||||||
|
/// 有写操作在飞的那个世界(`None` = 空闲)。
|
||||||
|
pub busy: Option<String>,
|
||||||
|
/// 上一次写操作成功的那句话(`None` = 这一拍没有回执)。
|
||||||
|
pub notice: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Saves {
|
impl Saves {
|
||||||
@@ -98,21 +143,33 @@ impl Saves {
|
|||||||
pub fn nothing_yet(&self) -> bool {
|
pub fn nothing_yet(&self) -> bool {
|
||||||
self.has_view && !self.missing && self.worlds.is_empty() && self.screenshots.is_empty()
|
self.has_view && !self.missing && self.worlds.is_empty() && self.screenshots.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 这个世界的写操作在飞吗(那一行画「正在处理…」并把按钮拿掉)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn busy_with(&self, dir: &str) -> bool {
|
||||||
|
self.busy.as_deref() == Some(dir)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// presenter 的视图态 → 页面要画的东西(**唯一**的转换点,`app/view/instances.rs` 调)。
|
/// presenter 的视图态 → 页面要画的东西(**唯一**的转换点,`app/view/instances.rs` 调)。
|
||||||
///
|
///
|
||||||
/// 两条口径:
|
/// 三条口径:
|
||||||
///
|
///
|
||||||
/// 1. **失败不清列表**:list 失败时保留上一份 `worlds`/`screenshots`(用户还能看见旧列表,
|
/// 1. **失败不清列表**:list 失败时保留上一份 `worlds`/`screenshots`(用户还能看见旧列表,
|
||||||
/// 只是多一条红字),只有"从来没有过快照"时才是一条纯错误态;
|
/// 只是多一条红字),只有"从来没有过快照"时才是一条纯错误态;
|
||||||
/// 2. **只读**:这里没有任何"乐观更新"可言——快照就是磁盘,下一次进详情页或点重试才刷新。
|
/// 2. **不做乐观更新**:行上的名字只来自快照,写操作的"在飞"只用来置灰那一行;
|
||||||
|
/// 3. **回执只画一次**:`view.last_op` 是 host 写在快照里的,下一次普通 List 就没了。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
|
pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
|
||||||
let texts = Texts::of(catalog);
|
let texts = Texts::of(catalog);
|
||||||
let mut out = Saves {
|
let mut out = Saves {
|
||||||
bound: state.bound.clone(),
|
bound: state.bound.clone(),
|
||||||
loading: state.bound.is_some() && state.view.is_none() && state.failed.is_none(),
|
loading: state.bound.is_some() && state.view.is_none() && state.failed.is_none(),
|
||||||
|
editing: state.draft.as_ref().map(|draft| Editing {
|
||||||
|
dir: draft.dir.clone(),
|
||||||
|
text: draft.text.clone(),
|
||||||
|
}),
|
||||||
|
busy: state.pending.as_ref().map(|op| op.dir().to_string()),
|
||||||
..Saves::default()
|
..Saves::default()
|
||||||
};
|
};
|
||||||
if let Some(view) = &state.view {
|
if let Some(view) = &state.view {
|
||||||
@@ -125,7 +182,7 @@ pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|world| Row {
|
.map(|world| Row {
|
||||||
name: world.dir.clone(),
|
name: world.dir.clone(),
|
||||||
detail: world_detail(world.icon, world.modified_ms, texts),
|
detail: rows::world_detail(world.icon, world.modified_ms, texts),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
out.screenshots = view
|
out.screenshots = view
|
||||||
@@ -133,30 +190,33 @@ pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|shot| Row {
|
.map(|shot| Row {
|
||||||
name: shot.file.clone(),
|
name: shot.file.clone(),
|
||||||
detail: shot_detail(shot.bytes, shot.modified_ms, texts),
|
detail: rows::shot_detail(shot.bytes, shot.modified_ms, texts),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
out.count = texts.saves_count(view.world_count(), view.screenshot_count());
|
out.count = texts.saves_count(view.world_count(), view.screenshot_count());
|
||||||
out.skipped = view.skipped_count();
|
out.skipped = view.skipped_count();
|
||||||
out.skipped_note = texts.saves_skipped(out.skipped);
|
out.skipped_note = texts.saves_skipped(out.skipped);
|
||||||
|
out.notice = view.last_op.as_ref().map(|op| op_line(op, texts));
|
||||||
}
|
}
|
||||||
if let Some(failure) = &state.failed {
|
if let Some(failure) = &state.failed {
|
||||||
out.loading = false;
|
out.loading = false;
|
||||||
out.error = Some(failure_line(failure, texts));
|
out.error = Some(failure_line(failure, texts));
|
||||||
out.retryable = failure.kind != SavesFailureKind::OpenFailed;
|
// 只有「读盘 / 列目录」失败才值得重试;写操作的重试就是再点一次那个按钮。
|
||||||
|
out.retryable = failure.op.is_none() && failure.kind != SavesFailureKind::OpenFailed;
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 失败的一句话(**唯一产地**:页面内红字用它;本版没有 Toast——只读列表的失败就地说明更近)。
|
/// 失败的一句话(**唯一产地**:页面内红字用它,本版没有 Toast)。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn failure_line(failure: &SavesFailureView, texts: Texts) -> String {
|
pub fn failure_line(failure: &SavesFailureView, texts: Texts) -> String {
|
||||||
let reason = texts.saves_reason(failure.kind);
|
let reason = texts.saves_reason(failure.kind);
|
||||||
let sentence = match failure.kind {
|
let sentence = match (failure.op, failure.kind) {
|
||||||
SavesFailureKind::OpenFailed => texts.saves_open_failed(reason),
|
(Some(SavesFailureOp::Rename), _) => texts.saves_rename_failed(&failure.file, reason),
|
||||||
SavesFailureKind::DirUnusable | SavesFailureKind::InstanceUnavailable => {
|
(Some(SavesFailureOp::Delete), _) => texts.saves_delete_failed(&failure.file, reason),
|
||||||
texts.saves_failed(reason)
|
(Some(SavesFailureOp::Backup), _) => texts.saves_backup_failed(&failure.file, reason),
|
||||||
}
|
(None, SavesFailureKind::OpenFailed) => texts.saves_open_failed(reason),
|
||||||
|
(None, _) => texts.saves_failed(reason),
|
||||||
};
|
};
|
||||||
if failure.detail.is_empty() {
|
if failure.detail.is_empty() {
|
||||||
return sentence;
|
return sentence;
|
||||||
@@ -164,44 +224,6 @@ pub fn failure_line(failure: &SavesFailureView, texts: Texts) -> String {
|
|||||||
format!("{sentence}({})", failure.detail)
|
format!("{sentence}({})", failure.detail)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 世界那一行的副文案:`2024-05-01 10:00 UTC`,有图标时再挂一个「有图标」。
|
|
||||||
fn world_detail(icon: bool, modified_ms: Option<u64>, texts: Texts) -> String {
|
|
||||||
let stamp = stamp(modified_ms, texts);
|
|
||||||
if icon {
|
|
||||||
format!("{stamp} · {}", texts.saves_world_icon())
|
|
||||||
} else {
|
|
||||||
stamp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 截图那一行的副文案:`1.2 MiB · 2024-05-01 10:00 UTC`。
|
|
||||||
fn shot_detail(bytes: u64, modified_ms: Option<u64>, texts: Texts) -> String {
|
|
||||||
format!("{} · {}", human_bytes(bytes), stamp(modified_ms, texts))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// mtime(UTC 毫秒)→ `2024-05-01 10:00 UTC`;取不到就说"时间未知"(不拿 0 或当前时间糊)。
|
|
||||||
///
|
|
||||||
/// 换算走 `core::launch::time` 的 RFC3339 格式化,再按 `app/view/instances.rs::last_played`
|
|
||||||
/// 同一刀切开——**UTC 是 core 的口径,不做本地时区换算**(那属时间格式 mission)。
|
|
||||||
fn stamp(modified_ms: Option<u64>, texts: Texts) -> String {
|
|
||||||
let Some(text) = modified_ms.and_then(stamp_utc) else {
|
|
||||||
return texts.saves_time_unknown().to_string();
|
|
||||||
};
|
|
||||||
text
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 毫秒 → `YYYY-MM-DD HH:MM UTC`(`None` = 早于 1970 / 溢出,不猜)。
|
|
||||||
fn stamp_utc(ms: u64) -> Option<String> {
|
|
||||||
let at = std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_millis(ms))?;
|
|
||||||
let text = unlucky_core::launch::time::format_rfc3339_millis(at);
|
|
||||||
// `2024-05-01T10:00:00.000Z` → `2024-05-01 10:00 UTC`(切两段,不解析、不重排)。
|
|
||||||
// 模板与 `app/view/instances.rs::last_played` **一字不差**:同一句话只能有一份写法,
|
|
||||||
// 页面源码的字面量白名单里也只有这一条(`instances/tests/i18n.rs` 的 `ALLOWED_LITERALS`)。
|
|
||||||
let date = text.get(0..10)?;
|
|
||||||
let time = text.get(11..16)?;
|
|
||||||
Some(format!("{date} {time} UTC"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 画 SAVES 区(实例页唯一入口,`detail.rs` 调它)。
|
/// 画 SAVES 区(实例页唯一入口,`detail.rs` 调它)。
|
||||||
pub(super) fn section<'a>(
|
pub(super) fn section<'a>(
|
||||||
ui: &'a Ui,
|
ui: &'a Ui,
|
||||||
@@ -212,8 +234,6 @@ pub(super) fn section<'a>(
|
|||||||
let mut body = column![head(ui, saves, forced, texts)]
|
let mut body = column![head(ui, saves, forced, texts)]
|
||||||
.spacing(ui.space(Space::X3))
|
.spacing(ui.space(Space::X3))
|
||||||
.width(Length::Fill);
|
.width(Length::Fill);
|
||||||
// 只读边界固定画在第一行(见模块文档)。
|
|
||||||
body = body.push(note(ui, texts.saves_read_only()));
|
|
||||||
|
|
||||||
if saves.loading {
|
if saves.loading {
|
||||||
body = body.push(note(ui, texts.saves_loading()));
|
body = body.push(note(ui, texts.saves_loading()));
|
||||||
@@ -255,6 +275,10 @@ pub(super) fn section<'a>(
|
|||||||
body = body.push(note(ui, texts.saves_empty()));
|
body = body.push(note(ui, texts.saves_empty()));
|
||||||
return frame::card(ui, body);
|
return frame::card(ui, body);
|
||||||
}
|
}
|
||||||
|
// 写操作成功的回执(重命名 / 删除 / 备份)——放在列表**上面**:用户刚点的就是这里。
|
||||||
|
if let Some(notice) = saves.notice.as_deref() {
|
||||||
|
body = body.push(note(ui, notice));
|
||||||
|
}
|
||||||
|
|
||||||
// 世界 → 截图,两组各一个小标;某一组为空时只给一句话,不留空小标。
|
// 世界 → 截图,两组各一个小标;某一组为空时只给一句话,不留空小标。
|
||||||
body = body.push(sub_head(ui, texts.saves_worlds()));
|
body = body.push(sub_head(ui, texts.saves_worlds()));
|
||||||
@@ -262,7 +286,7 @@ pub(super) fn section<'a>(
|
|||||||
body = body.push(note(ui, texts.saves_no_worlds()));
|
body = body.push(note(ui, texts.saves_no_worlds()));
|
||||||
} else {
|
} else {
|
||||||
for item in &saves.worlds {
|
for item in &saves.worlds {
|
||||||
body = body.push(entry_row(ui, item));
|
body = body.push(rows::world(ui, item, saves, forced, texts));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
body = body.push(sub_head(ui, texts.saves_screenshots()));
|
body = body.push(sub_head(ui, texts.saves_screenshots()));
|
||||||
@@ -270,7 +294,7 @@ pub(super) fn section<'a>(
|
|||||||
body = body.push(note(ui, texts.saves_no_screenshots()));
|
body = body.push(note(ui, texts.saves_no_screenshots()));
|
||||||
} else {
|
} else {
|
||||||
for item in &saves.screenshots {
|
for item in &saves.screenshots {
|
||||||
body = body.push(entry_row(ui, item));
|
body = body.push(rows::world_plain(ui, item));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if saves.skipped > 0 {
|
if saves.skipped > 0 {
|
||||||
@@ -350,29 +374,6 @@ fn sub_head<'a>(ui: &'a Ui, text: &'a str) -> Element<'a, Message> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 一行:名字 + 副文案(只读,所以没有开关、没有按钮)。
|
|
||||||
fn entry_row<'a>(ui: &'a Ui, item: &'a Row) -> Element<'a, Message> {
|
|
||||||
column![
|
|
||||||
frame::text_role(
|
|
||||||
ui,
|
|
||||||
TypeRole::BodyMedium,
|
|
||||||
false,
|
|
||||||
item.name.as_str(),
|
|
||||||
ui.color(Role::OnSurface),
|
|
||||||
),
|
|
||||||
frame::text_role(
|
|
||||||
ui,
|
|
||||||
TypeRole::BodySmall,
|
|
||||||
false,
|
|
||||||
item.detail.as_str(),
|
|
||||||
ui.color(Role::OnSurfaceVariant),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
.spacing(ui.space(Space::X1))
|
|
||||||
.width(Length::Fill)
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn note<'a>(ui: &'a Ui, text: &'a str) -> Element<'a, Message> {
|
fn note<'a>(ui: &'a Ui, text: &'a str) -> Element<'a, Message> {
|
||||||
frame::text_role(
|
frame::text_role(
|
||||||
ui,
|
ui,
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
//! SAVES 区的一行与它的操作区(M247)。
|
||||||
|
//!
|
||||||
|
//! 从 `saves.rs` 拆出:那个文件加完写操作的行内输入与三个按钮之后会越过 400 行硬线
|
||||||
|
//! (拆法与仓库其它 `-p1/-p2` 一致——**纯搬移 + 一个函数一族**,判据留在 `saves.rs`)。
|
||||||
|
//!
|
||||||
|
//! | 函数 | 画什么 |
|
||||||
|
//! |---|---|
|
||||||
|
//! | [`world`] | 一个世界:名字 + 副文案 +(空闲)重命名/备份/删除、(在飞)「正在处理…」、(改名中)输入框 + 改名/取消 |
|
||||||
|
//! | [`world_plain`] | 只读的一行(截图用它:截图不在三个写操作的范围里) |
|
||||||
|
//! | [`world_detail`] / [`shot_detail`] | 两行副文案 |
|
||||||
|
//!
|
||||||
|
//! 三枚按钮与「打开存档文件夹」同一条口径:`buttons::text` + `frame::BASE`(M237:卡片里的控件
|
||||||
|
//! 把底传成卡底,hover/禁用状态才叠在卡实色上),出图取证时整行一起跟 `--force-state`。
|
||||||
|
//!
|
||||||
|
//! 输入框用 `components::inputs::text_field`(与启动参数区同一个控件)。**回车即提交**这件事
|
||||||
|
//! 由 `text_input` 的 `on_submit` 给不了(`Field` 没有开这个口子),所以提交靠右边的「改名」按钮
|
||||||
|
//! ——少一个键,但多一条谁都能看见的路。
|
||||||
|
|
||||||
|
use iced::widget::{column, row};
|
||||||
|
use iced::{Alignment, Element, Length};
|
||||||
|
|
||||||
|
use unlucky_design::color::Role;
|
||||||
|
use unlucky_design::spacing::Space;
|
||||||
|
use unlucky_design::typography::TypeRole;
|
||||||
|
use unlucky_presenter::dto::{human_bytes, SavesOped};
|
||||||
|
|
||||||
|
use crate::app::components::buttons::{self, Button};
|
||||||
|
use crate::app::components::inputs::{text_field, Field};
|
||||||
|
use crate::app::components::interaction::Interaction;
|
||||||
|
use crate::theme::Ui;
|
||||||
|
|
||||||
|
use super::super::forced_state;
|
||||||
|
use super::super::frame;
|
||||||
|
use super::super::state::Message as PageMessage;
|
||||||
|
use super::super::Message;
|
||||||
|
use super::super::Texts;
|
||||||
|
use super::{Row, Saves};
|
||||||
|
|
||||||
|
/// 一个世界行:名字 + 副文案 + 操作区(三档状态见模块文档与 `saves.rs`)。
|
||||||
|
pub(super) fn world<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
item: &'a Row,
|
||||||
|
saves: &'a Saves,
|
||||||
|
forced: Option<Interaction>,
|
||||||
|
texts: Texts,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
if let Some(editing) = saves.editing.as_ref().filter(|edit| edit.dir == item.name) {
|
||||||
|
return rename_row(ui, editing.text.as_str(), forced, texts);
|
||||||
|
}
|
||||||
|
let label = labels(ui, item, Role::OnSurface);
|
||||||
|
let dir = item.name.clone();
|
||||||
|
let actions: Element<'a, Message> = if saves.busy_with(&dir) {
|
||||||
|
text_note(ui, texts.saves_busy(), Role::OnSurfaceVariant)
|
||||||
|
} else {
|
||||||
|
row![
|
||||||
|
action(
|
||||||
|
ui,
|
||||||
|
texts.saves_rename(),
|
||||||
|
PageMessage::StartRenameWorld { dir: dir.clone() },
|
||||||
|
forced
|
||||||
|
),
|
||||||
|
action(
|
||||||
|
ui,
|
||||||
|
texts.saves_backup(),
|
||||||
|
PageMessage::BackupWorld { dir: dir.clone() },
|
||||||
|
forced
|
||||||
|
),
|
||||||
|
action(
|
||||||
|
ui,
|
||||||
|
texts.saves_delete(),
|
||||||
|
PageMessage::RequestDeleteWorld { dir },
|
||||||
|
forced
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.spacing(ui.space(Space::X1))
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.into()
|
||||||
|
};
|
||||||
|
row![label, actions]
|
||||||
|
.spacing(ui.space(Space::X2))
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 只读的一行(截图)。与 [`world`] 的区别只有一条:没有操作区。
|
||||||
|
pub(super) fn world_plain<'a>(ui: &'a Ui, item: &'a Row) -> Element<'a, Message> {
|
||||||
|
labels(ui, item, Role::OnSurface).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 行内重命名:输入框 + 「改名」/「取消」(占满整行,副文案让位)。
|
||||||
|
fn rename_row<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
text: &'a str,
|
||||||
|
forced: Option<Interaction>,
|
||||||
|
texts: Texts,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let field = text_field(
|
||||||
|
ui,
|
||||||
|
Field::new(text)
|
||||||
|
// M237:输入框住在卡里 ⇒ 状态层与描边叠卡底。
|
||||||
|
.base(frame::BASE)
|
||||||
|
.placeholder(texts.saves_rename_placeholder())
|
||||||
|
.width(Length::Fill),
|
||||||
|
|value| Message::Instances(PageMessage::RenameWorldChanged(value)),
|
||||||
|
);
|
||||||
|
row![
|
||||||
|
field,
|
||||||
|
action(
|
||||||
|
ui,
|
||||||
|
texts.saves_rename_confirm(),
|
||||||
|
PageMessage::CommitRenameWorld,
|
||||||
|
forced
|
||||||
|
),
|
||||||
|
action(
|
||||||
|
ui,
|
||||||
|
texts.saves_cancel(),
|
||||||
|
PageMessage::CancelRenameWorld,
|
||||||
|
forced
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.spacing(ui.space(Space::X2))
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 名字 + 副文案(两行)。
|
||||||
|
fn labels<'a>(ui: &'a Ui, item: &'a Row, color: Role) -> iced::widget::Column<'a, Message> {
|
||||||
|
column![
|
||||||
|
frame::text_role(
|
||||||
|
ui,
|
||||||
|
TypeRole::BodyMedium,
|
||||||
|
false,
|
||||||
|
item.name.as_str(),
|
||||||
|
ui.color(color)
|
||||||
|
),
|
||||||
|
frame::text_role(
|
||||||
|
ui,
|
||||||
|
TypeRole::BodySmall,
|
||||||
|
false,
|
||||||
|
item.detail.as_str(),
|
||||||
|
ui.color(Role::OnSurfaceVariant),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.spacing(ui.space(Space::X1))
|
||||||
|
.width(Length::Fill)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一枚行内文字按钮(统一套 `frame::BASE` 与 `--force-state`)。
|
||||||
|
fn action<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
label: &'a str,
|
||||||
|
message: PageMessage,
|
||||||
|
forced: Option<Interaction>,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
buttons::text(
|
||||||
|
ui,
|
||||||
|
forced_state(Button::new(label, Message::Instances(message)), forced).base(frame::BASE),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_note<'a>(ui: &'a Ui, text: &'a str, color: Role) -> Element<'a, Message> {
|
||||||
|
frame::text_role(ui, TypeRole::BodySmall, false, text, ui.color(color))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写操作成功后的一句话(**唯一产地**:卡片里那条成功提示)。
|
||||||
|
///
|
||||||
|
/// 备份那句必须带**完整路径**:落点在文件系统某处,用户得知道去哪找。
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn op_line(op: &SavesOped, texts: Texts) -> String {
|
||||||
|
match op {
|
||||||
|
SavesOped::Renamed { from, to } => texts.saves_renamed(from, to),
|
||||||
|
SavesOped::Deleted { dir } => texts.saves_deleted(dir),
|
||||||
|
SavesOped::BackedUp {
|
||||||
|
dir,
|
||||||
|
path,
|
||||||
|
bytes,
|
||||||
|
skipped,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let mut line = texts.saves_backed_up(dir, path, &human_bytes(*bytes));
|
||||||
|
if *skipped > 0 {
|
||||||
|
// 备份跳过链接这件事必须说出来:静默少打包几个文件比失败更坏。
|
||||||
|
line.push_str(" · ");
|
||||||
|
line.push_str(&texts.saves_backup_skipped(*skipped));
|
||||||
|
}
|
||||||
|
line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 世界那一行的副文案:`2024-05-01 10:00 UTC`,有图标时再挂一个「有图标」。
|
||||||
|
pub(super) fn world_detail(icon: bool, modified_ms: Option<u64>, texts: Texts) -> String {
|
||||||
|
let stamp = stamp(modified_ms, texts);
|
||||||
|
if icon {
|
||||||
|
format!("{stamp} · {}", texts.saves_world_icon())
|
||||||
|
} else {
|
||||||
|
stamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 截图那一行的副文案:`1.2 MiB · 2024-05-01 10:00 UTC`。
|
||||||
|
pub(super) fn shot_detail(bytes: u64, modified_ms: Option<u64>, texts: Texts) -> String {
|
||||||
|
format!("{} · {}", human_bytes(bytes), stamp(modified_ms, texts))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// mtime(UTC 毫秒)→ `2024-05-01 10:00 UTC`;取不到就说"时间未知"(不拿 0 或当前时间糊)。
|
||||||
|
///
|
||||||
|
/// 换算走 `core::launch::time` 的 RFC3339 格式化,再按 `app/view/instances.rs::last_played`
|
||||||
|
/// 同一刀切开——**UTC 是 core 的口径,不做本地时区换算**(那属时间格式 mission)。
|
||||||
|
fn stamp(modified_ms: Option<u64>, texts: Texts) -> String {
|
||||||
|
let Some(text) = modified_ms.and_then(stamp_utc) else {
|
||||||
|
return texts.saves_time_unknown().to_string();
|
||||||
|
};
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 毫秒 → `YYYY-MM-DD HH:MM UTC`(`None` = 早于 1970 / 溢出,不猜)。
|
||||||
|
fn stamp_utc(ms: u64) -> Option<String> {
|
||||||
|
let at = std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_millis(ms))?;
|
||||||
|
let text = unlucky_core::launch::time::format_rfc3339_millis(at);
|
||||||
|
// `2024-05-01T10:00:00.000Z` → `2024-05-01 10:00 UTC`(切两段,不解析、不重排)。
|
||||||
|
// 模板与 `app/view/instances.rs::last_played` **一字不差**:同一句话只能有一份写法,
|
||||||
|
// 页面源码的字面量白名单里也只有这一条(`instances/tests/i18n.rs` 的 `ALLOWED_LITERALS`)。
|
||||||
|
let date = text.get(0..10)?;
|
||||||
|
let time = text.get(11..16)?;
|
||||||
|
Some(format!("{date} {time} UTC"))
|
||||||
|
}
|
||||||
@@ -170,6 +170,31 @@ pub enum Message {
|
|||||||
ReloadSaves,
|
ReloadSaves,
|
||||||
/// 打开存档文件夹(M235):拉起哪个目录由 host 决定(`saves/` → `screenshots/` → 运行目录)。
|
/// 打开存档文件夹(M235):拉起哪个目录由 host 决定(`saves/` → `screenshots/` → 运行目录)。
|
||||||
OpenSavesFolder,
|
OpenSavesFolder,
|
||||||
|
/// 开始**行内重命名**一个世界(M247):那一行就地换成输入框 + 确定/取消。
|
||||||
|
///
|
||||||
|
/// 为什么是行内而不是模态输入框:现成的模态队列有 `DialogKind::Input`,但 app 侧的
|
||||||
|
/// `dialog::Message` 只有「主按钮 / 次按钮 / 关闭」,**没有「输入变了」**这一条——
|
||||||
|
/// 用输入模态等于重写一套可编辑模态。行内复用 `components::inputs` 的单行框,一处新控件都不用加。
|
||||||
|
StartRenameWorld {
|
||||||
|
/// 磁盘目录名
|
||||||
|
dir: String,
|
||||||
|
},
|
||||||
|
/// 行内输入框的内容变了(原样存进 presenter 的草稿,不在前端过滤)。
|
||||||
|
RenameWorldChanged(String),
|
||||||
|
/// 提交行内重命名(空名字 / 没改 → 直接收起,不打扰后台)。
|
||||||
|
CommitRenameWorld,
|
||||||
|
/// 取消行内重命名。
|
||||||
|
CancelRenameWorld,
|
||||||
|
/// 请求删除一个世界(M247):**先弹确认框**,不直接删。
|
||||||
|
RequestDeleteWorld {
|
||||||
|
/// 磁盘目录名
|
||||||
|
dir: String,
|
||||||
|
},
|
||||||
|
/// 备份一个世界(M247):导出 zip 到启动器自己的 `exports/saves/`。
|
||||||
|
BackupWorld {
|
||||||
|
/// 磁盘目录名
|
||||||
|
dir: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -43,10 +43,13 @@ const ALLOWED: &[&str] = &[
|
|||||||
// 翻成「存档目录」用户就对不上那两层文件夹了。
|
// 翻成「存档目录」用户就对不上那两层文件夹了。
|
||||||
"saves",
|
"saves",
|
||||||
"screenshots",
|
"screenshots",
|
||||||
|
// M247:`zip` 是**归档格式名**(备份导出成什么,玩家拿去给别的东西解压时看的也是这个词)。
|
||||||
|
// 中文界面上写「压缩包」会让「备份出来的是 .zip 吗」这个问题没有答案。
|
||||||
|
"zip",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。
|
/// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。
|
||||||
const PAGE_SOURCES: [(&str, &str); 8] = [
|
const PAGE_SOURCES: [(&str, &str); 9] = [
|
||||||
("instances.rs", include_str!("../../instances.rs")),
|
("instances.rs", include_str!("../../instances.rs")),
|
||||||
("instances/detail.rs", include_str!("../detail.rs")),
|
("instances/detail.rs", include_str!("../detail.rs")),
|
||||||
("instances/demo.rs", include_str!("../demo.rs")),
|
("instances/demo.rs", include_str!("../demo.rs")),
|
||||||
@@ -63,6 +66,8 @@ const PAGE_SOURCES: [(&str, &str); 8] = [
|
|||||||
("instances/mods.rs", include_str!("../mods.rs")),
|
("instances/mods.rs", include_str!("../mods.rs")),
|
||||||
// M235:SAVES 区(存档 / 截图的只读列表 + 打开文件夹)
|
// M235:SAVES 区(存档 / 截图的只读列表 + 打开文件夹)
|
||||||
("instances/saves.rs", include_str!("../saves.rs")),
|
("instances/saves.rs", include_str!("../saves.rs")),
|
||||||
|
// M247:SAVES 区的行与操作区(重命名 / 备份 / 删除 + 行内输入框)
|
||||||
|
("instances/saves/rows.rs", include_str!("../saves/rows.rs")),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// 页面五件套里**允许出现的字符串字面量**(凡是带 ASCII 单词的都必须在这张表里)。
|
/// 页面五件套里**允许出现的字符串字面量**(凡是带 ASCII 单词的都必须在这张表里)。
|
||||||
@@ -80,7 +85,7 @@ const PAGE_SOURCES: [(&str, &str); 8] = [
|
|||||||
/// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 |
|
/// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 |
|
||||||
/// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) |
|
/// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) |
|
||||||
/// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) |
|
/// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) |
|
||||||
const ALLOWED_LITERALS: [&str; 30] = [
|
const ALLOWED_LITERALS: [&str; 35] = [
|
||||||
"{date} {time} UTC",
|
"{date} {time} UTC",
|
||||||
"isolated",
|
"isolated",
|
||||||
"shared",
|
"shared",
|
||||||
@@ -125,6 +130,13 @@ const ALLOWED_LITERALS: [&str; 30] = [
|
|||||||
// M235:存档区计数那条的**占位符名**(`{worlds}` / `{shots}`),同样不是文案。
|
// M235:存档区计数那条的**占位符名**(`{worlds}` / `{shots}`),同样不是文案。
|
||||||
"worlds",
|
"worlds",
|
||||||
"shots",
|
"shots",
|
||||||
|
// M247:删除确认与写操作回执的**占位符名**(`{dir}` 世界名 / `{from}` `{to}` 改名两端 /
|
||||||
|
// `{path}` 备份落点 / `{size}` 人的可读字节数),都不是文案。
|
||||||
|
"dir",
|
||||||
|
"from",
|
||||||
|
"to",
|
||||||
|
"path",
|
||||||
|
"size",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。
|
/// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。
|
||||||
|
|||||||
@@ -8,12 +8,12 @@
|
|||||||
//! 结构断言盯着 `detail_column` 里 MODS 与 SAVES 的先后;这里守模型)。
|
//! 结构断言盯着 `detail_column` 里 MODS 与 SAVES 的先后;这里守模型)。
|
||||||
|
|
||||||
use unlucky_presenter::dto::{
|
use unlucky_presenter::dto::{
|
||||||
SavesArea, SavesFailureKind, SavesFailureView, SavesListView, SavesSkipKind, SavesSkipView,
|
SavesArea, SavesFailureKind, SavesFailureOp, SavesFailureView, SavesListView, SavesOped,
|
||||||
ScreenshotView, WorldView,
|
SavesSkipKind, SavesSkipView, ScreenshotView, WorldView,
|
||||||
};
|
};
|
||||||
use unlucky_presenter::state::SavesState;
|
use unlucky_presenter::state::{SavesOp, SavesState};
|
||||||
|
|
||||||
use super::super::saves::{failure_line, from_state};
|
use super::super::saves::{failure_line, from_state, op_line};
|
||||||
use super::super::Texts;
|
use super::super::Texts;
|
||||||
use unlucky_i18n::Lang;
|
use unlucky_i18n::Lang;
|
||||||
|
|
||||||
@@ -42,6 +42,7 @@ fn view(instance: &str) -> SavesListView {
|
|||||||
file: "notes.txt".to_string(),
|
file: "notes.txt".to_string(),
|
||||||
reason: SavesSkipKind::NotAnImage,
|
reason: SavesSkipKind::NotAnImage,
|
||||||
}],
|
}],
|
||||||
|
last_op: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +152,8 @@ fn only_list_failures_offer_a_retry() {
|
|||||||
state.changed(view("a"));
|
state.changed(view("a"));
|
||||||
state.failed(SavesFailureView {
|
state.failed(SavesFailureView {
|
||||||
instance: "a".to_string(),
|
instance: "a".to_string(),
|
||||||
|
file: String::new(),
|
||||||
|
op: None,
|
||||||
kind,
|
kind,
|
||||||
detail: "Access is denied.".to_string(),
|
detail: "Access is denied.".to_string(),
|
||||||
});
|
});
|
||||||
@@ -172,6 +175,8 @@ fn a_failure_without_a_snapshot_is_not_an_empty_list() {
|
|||||||
let mut state = bound_state("a");
|
let mut state = bound_state("a");
|
||||||
state.failed(SavesFailureView {
|
state.failed(SavesFailureView {
|
||||||
instance: "a".to_string(),
|
instance: "a".to_string(),
|
||||||
|
file: String::new(),
|
||||||
|
op: None,
|
||||||
kind: SavesFailureKind::DirUnusable,
|
kind: SavesFailureKind::DirUnusable,
|
||||||
detail: "Access is denied.".to_string(),
|
detail: "Access is denied.".to_string(),
|
||||||
});
|
});
|
||||||
@@ -187,6 +192,8 @@ fn a_failure_without_a_snapshot_is_not_an_empty_list() {
|
|||||||
fn the_failure_line_follows_the_language_and_keeps_the_detail_in_brackets() {
|
fn the_failure_line_follows_the_language_and_keeps_the_detail_in_brackets() {
|
||||||
let failure = SavesFailureView {
|
let failure = SavesFailureView {
|
||||||
instance: "a".to_string(),
|
instance: "a".to_string(),
|
||||||
|
file: String::new(),
|
||||||
|
op: None,
|
||||||
kind: SavesFailureKind::DirUnusable,
|
kind: SavesFailureKind::DirUnusable,
|
||||||
detail: "Access is denied.".to_string(),
|
detail: "Access is denied.".to_string(),
|
||||||
};
|
};
|
||||||
@@ -207,6 +214,137 @@ fn the_failure_line_follows_the_language_and_keeps_the_detail_in_brackets() {
|
|||||||
assert!(!text.contains('('), "空原文留下了一对括号:{text}");
|
assert!(!text.contains('('), "空原文留下了一对括号:{text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 写操作失败:**不画重试**(重试按钮会去重新列目录,那是错的动作),并且主句带**哪个世界**。
|
||||||
|
///
|
||||||
|
/// 抓得住:三种写失败被折叠成同一句「读取 saves/ 失败」——用户看不出是哪一行出了什么事。
|
||||||
|
#[test]
|
||||||
|
fn write_failures_are_named_per_operation_and_never_retryable() {
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
SavesFailureOp::Rename,
|
||||||
|
SavesFailureKind::WriteConflict,
|
||||||
|
"重命名 Better World 失败:同名世界已存在",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SavesFailureOp::Delete,
|
||||||
|
SavesFailureKind::WriteMissing,
|
||||||
|
"删除 Better World 失败:那个世界已不在",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SavesFailureOp::Backup,
|
||||||
|
SavesFailureKind::WriteTooLarge,
|
||||||
|
"备份 Better World 失败:这个世界太大,一个 zip 装不下",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (op, kind, expected) in cases {
|
||||||
|
let failure = SavesFailureView {
|
||||||
|
instance: "a".to_string(),
|
||||||
|
file: "Better World".to_string(),
|
||||||
|
op: Some(op),
|
||||||
|
kind,
|
||||||
|
detail: String::new(),
|
||||||
|
};
|
||||||
|
let line = failure_line(&failure, Texts::of(Lang::ZhCn.catalog()));
|
||||||
|
assert_eq!(line, expected);
|
||||||
|
|
||||||
|
let mut state = bound_state("a");
|
||||||
|
state.changed(view("a"));
|
||||||
|
state.failed(failure);
|
||||||
|
let saves = from_state(&state, Lang::ZhCn.catalog());
|
||||||
|
assert!(!saves.retryable, "{op:?} 失败了还画着重试按钮");
|
||||||
|
assert!(saves.error.is_some());
|
||||||
|
assert_eq!(saves.worlds.len(), 1, "{op:?} 失败把列表清了");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 三样交互态(草稿 / 在飞 / 回执)都要变成页面字段:改名中的那一行、置灰的那一行、成功那句话。
|
||||||
|
#[test]
|
||||||
|
fn draft_pending_and_receipt_become_page_fields() {
|
||||||
|
let mut state = bound_state("a");
|
||||||
|
state.changed(view("a"));
|
||||||
|
state.start_rename("Better World");
|
||||||
|
state.edit_rename("Renamed".to_string());
|
||||||
|
let saves = from_state(&state, Lang::En.catalog());
|
||||||
|
let editing = saves.editing.as_ref().expect("草稿没进页面模型");
|
||||||
|
assert_eq!(editing.dir, "Better World");
|
||||||
|
assert_eq!(editing.text, "Renamed");
|
||||||
|
assert!(!saves.busy_with("Better World"), "还在打字就算在飞了");
|
||||||
|
|
||||||
|
state.mark_pending(SavesOp::Backup {
|
||||||
|
dir: "Better World".to_string(),
|
||||||
|
});
|
||||||
|
let saves = from_state(&state, Lang::En.catalog());
|
||||||
|
assert!(saves.editing.is_none(), "发请求时草稿该收起来");
|
||||||
|
assert!(saves.busy_with("Better World"));
|
||||||
|
assert!(!saves.busy_with("Other"));
|
||||||
|
|
||||||
|
// 回执:host 写在快照里,下一次普通 List 就没了。
|
||||||
|
let mut done = view("a");
|
||||||
|
done.last_op = Some(SavesOped::BackedUp {
|
||||||
|
dir: "Better World".to_string(),
|
||||||
|
file: "Better World-20240501-100000.zip".to_string(),
|
||||||
|
path: "D:/ULL/.ULL/data/exports/saves/Better World-20240501-100000.zip".to_string(),
|
||||||
|
bytes: 2_048,
|
||||||
|
skipped: 2,
|
||||||
|
});
|
||||||
|
state.changed(done);
|
||||||
|
let saves = from_state(&state, Lang::ZhCn.catalog());
|
||||||
|
let notice = saves.notice.as_deref().expect("回执没进页面模型");
|
||||||
|
assert!(notice.contains("2.0 KB"), "回执要带大小:{notice}");
|
||||||
|
assert!(
|
||||||
|
notice.contains("Better World-20240501-100000.zip"),
|
||||||
|
"{notice}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
notice.contains("跳过 2 个符号链接"),
|
||||||
|
"跳过的链接要报出来:{notice}"
|
||||||
|
);
|
||||||
|
assert!(saves.busy.is_none(), "回程之后还在飞");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 三种回执的措辞(重命名 / 删除 / 备份)。
|
||||||
|
#[test]
|
||||||
|
fn every_receipt_says_what_happened() {
|
||||||
|
let texts = Texts::of(Lang::ZhCn.catalog());
|
||||||
|
assert_eq!(
|
||||||
|
op_line(
|
||||||
|
&SavesOped::Renamed {
|
||||||
|
from: "A".to_string(),
|
||||||
|
to: "B".to_string()
|
||||||
|
},
|
||||||
|
texts
|
||||||
|
),
|
||||||
|
"已把 A 改名为 B。"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
op_line(
|
||||||
|
&SavesOped::Deleted {
|
||||||
|
dir: "A".to_string()
|
||||||
|
},
|
||||||
|
texts
|
||||||
|
),
|
||||||
|
"已删除 A。"
|
||||||
|
);
|
||||||
|
let backed_up = op_line(
|
||||||
|
&SavesOped::BackedUp {
|
||||||
|
dir: "A".to_string(),
|
||||||
|
file: "A.zip".to_string(),
|
||||||
|
path: "D:/x/A.zip".to_string(),
|
||||||
|
bytes: 0,
|
||||||
|
skipped: 0,
|
||||||
|
},
|
||||||
|
texts,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
backed_up.starts_with("已把 A 备份到 D:/x/A.zip"),
|
||||||
|
"{backed_up}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!backed_up.contains("符号链接"),
|
||||||
|
"没跳过就别提链接:{backed_up}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 时间换算:毫秒 → `YYYY-MM-DD HH:MM UTC`(跨年/闰日各钉一条,别只信一条)。
|
/// 时间换算:毫秒 → `YYYY-MM-DD HH:MM UTC`(跨年/闰日各钉一条,别只信一条)。
|
||||||
#[test]
|
#[test]
|
||||||
fn millisecond_stamps_are_rendered_in_utc() {
|
fn millisecond_stamps_are_rendered_in_utc() {
|
||||||
|
|||||||
@@ -216,10 +216,6 @@ impl Texts {
|
|||||||
pub(super) fn saves_title(self) -> &'static str {
|
pub(super) fn saves_title(self) -> &'static str {
|
||||||
self.text(Key::InstancesSavesTitle)
|
self.text(Key::InstancesSavesTitle)
|
||||||
}
|
}
|
||||||
/// 「这一版只读」——U3 的边界固定画出来(见 `catalog/instances.rs` 的那段注释)。
|
|
||||||
pub(super) fn saves_read_only(self) -> &'static str {
|
|
||||||
self.text(Key::InstancesSavesReadOnly)
|
|
||||||
}
|
|
||||||
/// 「3 个世界 · 12 张截图」。
|
/// 「3 个世界 · 12 张截图」。
|
||||||
pub(super) fn saves_count(self, worlds: usize, shots: usize) -> String {
|
pub(super) fn saves_count(self, worlds: usize, shots: usize) -> String {
|
||||||
self.0.render(
|
self.0.render(
|
||||||
@@ -279,15 +275,103 @@ impl Texts {
|
|||||||
self.0
|
self.0
|
||||||
.render(Key::InstancesSavesOpenFailed, &[("reason", reason)])
|
.render(Key::InstancesSavesOpenFailed, &[("reason", reason)])
|
||||||
}
|
}
|
||||||
/// 失败大类的短句(`SavesFailureKind` → 一句话)。
|
/// 失败大类的短句(`SavesFailureKind` → 一句话;写操作那八类各有自己的话)。
|
||||||
pub(super) fn saves_reason(self, kind: SavesFailureKind) -> &'static str {
|
pub(super) fn saves_reason(self, kind: SavesFailureKind) -> &'static str {
|
||||||
self.text(match kind {
|
self.text(match kind {
|
||||||
SavesFailureKind::DirUnusable => Key::InstancesSavesReasonDir,
|
SavesFailureKind::DirUnusable => Key::InstancesSavesReasonDir,
|
||||||
SavesFailureKind::InstanceUnavailable => Key::InstancesSavesReasonInstance,
|
SavesFailureKind::InstanceUnavailable => Key::InstancesSavesReasonInstance,
|
||||||
SavesFailureKind::OpenFailed => Key::InstancesSavesReasonOpen,
|
SavesFailureKind::OpenFailed => Key::InstancesSavesReasonOpen,
|
||||||
|
SavesFailureKind::WriteUnsafeName => Key::InstancesSavesReasonUnsafeName,
|
||||||
|
SavesFailureKind::WriteSymlink => Key::InstancesSavesReasonSymlink,
|
||||||
|
SavesFailureKind::WriteMissing => Key::InstancesSavesReasonMissing,
|
||||||
|
SavesFailureKind::WriteNotADirectory => Key::InstancesSavesReasonNotDir,
|
||||||
|
SavesFailureKind::WriteConflict => Key::InstancesSavesReasonConflict,
|
||||||
|
SavesFailureKind::WriteBoundary => Key::InstancesSavesReasonBoundary,
|
||||||
|
SavesFailureKind::WriteTooLarge => Key::InstancesSavesReasonTooLarge,
|
||||||
|
SavesFailureKind::WriteFailed => Key::InstancesSavesReasonWrite,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------- 存档写操作(M247 · SAVESOPS)
|
||||||
|
pub(super) fn saves_rename(self) -> &'static str {
|
||||||
|
self.text(Key::InstancesSavesRename)
|
||||||
|
}
|
||||||
|
pub(super) fn saves_backup(self) -> &'static str {
|
||||||
|
self.text(Key::InstancesSavesBackup)
|
||||||
|
}
|
||||||
|
pub(super) fn saves_delete(self) -> &'static str {
|
||||||
|
self.text(Key::InstancesSavesDelete)
|
||||||
|
}
|
||||||
|
/// 行内输入框的占位符(不是按钮文案:它说「该填什么」)。
|
||||||
|
pub(super) fn saves_rename_placeholder(self) -> &'static str {
|
||||||
|
self.text(Key::InstancesSavesRenamePlaceholder)
|
||||||
|
}
|
||||||
|
pub(super) fn saves_rename_confirm(self) -> &'static str {
|
||||||
|
self.text(Key::InstancesSavesRenameConfirm)
|
||||||
|
}
|
||||||
|
pub(super) fn saves_cancel(self) -> &'static str {
|
||||||
|
self.text(Key::InstancesSavesCancel)
|
||||||
|
}
|
||||||
|
/// 「正在处理…」:写请求在飞时顶替那一行的操作区。
|
||||||
|
pub(super) fn saves_busy(self) -> &'static str {
|
||||||
|
self.text(Key::InstancesSavesBusy)
|
||||||
|
}
|
||||||
|
pub(super) fn saves_delete_title(self) -> &'static str {
|
||||||
|
self.text(Key::InstancesSavesDeleteTitle)
|
||||||
|
}
|
||||||
|
/// 删除确认框的正文:`{dir} 和它里面的全部内容都会被删除,且不能撤销。`
|
||||||
|
pub(super) fn saves_delete_body(self, dir: &str) -> String {
|
||||||
|
self.0
|
||||||
|
.render(Key::InstancesSavesDeleteBody, &[("dir", dir)])
|
||||||
|
}
|
||||||
|
pub(super) fn saves_delete_confirm(self) -> &'static str {
|
||||||
|
self.text(Key::InstancesSavesDeleteConfirm)
|
||||||
|
}
|
||||||
|
/// 改名成功的回执:`已把 A 改名为 B。`
|
||||||
|
pub(super) fn saves_renamed(self, from: &str, to: &str) -> String {
|
||||||
|
self.0
|
||||||
|
.render(Key::InstancesSavesRenamed, &[("from", from), ("to", to)])
|
||||||
|
}
|
||||||
|
/// 删除成功的回执:`已删除 A。`
|
||||||
|
pub(super) fn saves_deleted(self, dir: &str) -> String {
|
||||||
|
self.0.render(Key::InstancesSavesDeleted, &[("dir", dir)])
|
||||||
|
}
|
||||||
|
/// 备份成功的回执:`已把 A 备份到 D:/…/A-…zip(1.2 MiB)。`
|
||||||
|
pub(super) fn saves_backed_up(self, dir: &str, path: &str, size: &str) -> String {
|
||||||
|
self.0.render(
|
||||||
|
Key::InstancesSavesBackedUp,
|
||||||
|
&[("dir", dir), ("path", path), ("size", size)],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/// 「跳过 N 个符号链接」(备份时没跟随的那些)。
|
||||||
|
pub(super) fn saves_backup_skipped(self, count: usize) -> String {
|
||||||
|
self.0.render(
|
||||||
|
Key::InstancesSavesBackupSkipped,
|
||||||
|
&[("count", &count.to_string())],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/// 重命名失败的主句:`重命名 A 失败:同名世界已存在`。
|
||||||
|
pub(super) fn saves_rename_failed(self, file: &str, reason: &str) -> String {
|
||||||
|
self.0.render(
|
||||||
|
Key::InstancesSavesRenameFailed,
|
||||||
|
&[("file", file), ("reason", reason)],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/// 删除失败的主句。
|
||||||
|
pub(super) fn saves_delete_failed(self, file: &str, reason: &str) -> String {
|
||||||
|
self.0.render(
|
||||||
|
Key::InstancesSavesDeleteFailed,
|
||||||
|
&[("file", file), ("reason", reason)],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/// 备份失败的主句。
|
||||||
|
pub(super) fn saves_backup_failed(self, file: &str, reason: &str) -> String {
|
||||||
|
self.0.render(
|
||||||
|
Key::InstancesSavesBackupFailed,
|
||||||
|
&[("file", file), ("reason", reason)],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn text(self, key: Key) -> &'static str {
|
fn text(self, key: Key) -> &'static str {
|
||||||
self.0.text(key)
|
self.0.text(key)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,8 +129,8 @@ mod tests;
|
|||||||
pub use download::{cancelled_toast, Category, Download, InstallDraft, Loader};
|
pub use download::{cancelled_toast, Category, Download, InstallDraft, Loader};
|
||||||
pub(crate) use instances::demo_form as demo_launch_form;
|
pub(crate) use instances::demo_form as demo_launch_form;
|
||||||
pub use instances::{
|
pub use instances::{
|
||||||
mods_failure_line, mods_from_state, saves_failure_line, saves_from_state, InstanceMessage,
|
mods_failure_line, mods_from_state, saves_delete_prompt, saves_failure_line, saves_from_state,
|
||||||
LaunchForm, Mods, Saves,
|
InstanceMessage, LaunchForm, Mods, Saves,
|
||||||
};
|
};
|
||||||
pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
|
pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
|
||||||
pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome};
|
pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome};
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
//! 存档 / 截图的**只读**列举与「打开文件夹」(M235 · STOREMVP)。
|
//! 存档 / 截图的列举与「打开文件夹」(M235 · STOREMVP)+ 三个写操作(M247 · SAVESOPS)。
|
||||||
//!
|
//!
|
||||||
//! # 这一版做到哪、不做什么(塔裁决 U3)
|
//! # 读的那一半(M235)
|
||||||
//!
|
//!
|
||||||
//! 用户原话是「存档截图 / 导入」,M230 审计确认三层全缺(core 没有读路径,`saves/` 只在
|
//! 用户原话是「存档截图 / 导入」,M230 审计确认三层全缺(core 没有读路径,`saves/` 只在
|
||||||
//! `isolation.rs` 的切换探测里被计数;presenter 没有 DTO;i18n 没有键;没有页面)。
|
//! `isolation.rs` 的切换探测里被计数;presenter 没有 DTO;i18n 没有键;没有页面)。
|
||||||
//! U3 把本 mission 的边界钉成:**只读列表 + 打开文件夹,不做缩略图墙**。理由有两条:
|
//! U3 把 M235 的边界钉成:**只读列表 + 打开文件夹,不做缩略图墙**。理由有两条:
|
||||||
//!
|
//!
|
||||||
//! 1. 缩略图要引图片解码依赖,而 `AGENTS.md` §2 只允许 `palette-extract` 之外不引 image 类依赖;
|
//! 1. 缩略图要引图片解码依赖,而 `AGENTS.md` §2 只允许 `palette-extract` 之外不引 image 类依赖;
|
||||||
//! 2. 截图/导入的产物形态还没定型,先不进产品。
|
//! 2. 截图/导入的产物形态还没定型,先不进产品。
|
||||||
//!
|
//!
|
||||||
//! 所以这里**只读**:只调 `read_dir` / `metadata` / `symlink_metadata`,不建目录、不写文件、
|
//! 所以 [`list`] **只读**:只调 `read_dir` / `metadata` / `symlink_metadata`,不建目录、不写文件、
|
||||||
//! 不改名字、不读文件内容(`icon.png` 只判存在——那是将来缩略图墙的输入,不是这一版的解码对象)。
|
//! 不改名字、不读文件内容(`icon.png` 只判存在——那是将来缩略图墙的输入,不是这一版的解码对象)。
|
||||||
//!
|
//!
|
||||||
|
//! # 写的那一半(M247)
|
||||||
|
//!
|
||||||
|
//! 用户接着拍板:「存档按照主流的方式来」——主流第三方启动器(HMCL / PCL2)的存档区都有
|
||||||
|
//! **重命名世界 / 删除世界(二次确认)/ 备份导出**。这三个落在 [`write`]:
|
||||||
|
//!
|
||||||
|
//! | 操作 | 入口 | 边界 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | 重命名 | [`write::rename_world`] | 只动 `saves/<old>` → `saves/<new>`;撞名拒 |
|
||||||
|
//! | 删除 | [`write::delete_world`] | 先过边界校验,再递归删;不跟随符号链接 |
|
||||||
|
//! | 备份 | [`write::backup_world`] | 流式 zip 到调用方给的目录,时戳命名,不覆盖 |
|
||||||
|
//!
|
||||||
|
//! **写操作一律自己重新校验 `run_dir` 与目标路径**(不靠调用方传对)——判据在
|
||||||
|
//! [`write`] 的模块文档里一条条列着。
|
||||||
|
//!
|
||||||
//! # 单根语义(D20)
|
//! # 单根语义(D20)
|
||||||
//!
|
//!
|
||||||
//! 入口是**已经派生好的运行目录**([`crate::game::isolation::derive_game_dir`] 的结果),
|
//! 入口是**已经派生好的运行目录**([`crate::game::isolation::derive_game_dir`] 的结果),
|
||||||
@@ -23,6 +37,7 @@
|
|||||||
//!
|
//!
|
||||||
//! 新实例两个目录都不存在,这**不是**异常(`exists = false` + 空列表)。只有「存在但不可用」
|
//! 新实例两个目录都不存在,这**不是**异常(`exists = false` + 空列表)。只有「存在但不可用」
|
||||||
//! (符号链接 / 不是目录 / 读不了)才是 [`SavesError`];**并且照样不建目录**。
|
//! (符号链接 / 不是目录 / 读不了)才是 [`SavesError`];**并且照样不建目录**。
|
||||||
|
//! 写操作那边不同:目标不存在就是 [`write::SavesWriteKind::NotFound`](写不下去,得说清楚)。
|
||||||
//!
|
//!
|
||||||
//! # 收纳口径
|
//! # 收纳口径
|
||||||
//!
|
//!
|
||||||
@@ -40,18 +55,24 @@
|
|||||||
//! |---|---|
|
//! |---|---|
|
||||||
//! | [`error`] | 「目录存在但不可用」的失败形状(带 OS 原文) |
|
//! | [`error`] | 「目录存在但不可用」的失败形状(带 OS 原文) |
|
||||||
//! | [`list`] | 列 `<run_dir>/saves` 与 `<run_dir>/screenshots`(纯只读) |
|
//! | [`list`] | 列 `<run_dir>/saves` 与 `<run_dir>/screenshots`(纯只读) |
|
||||||
|
//! | [`write`] | 重命名 / 删除 / 备份导出(三个写操作 + 边界校验) |
|
||||||
//!
|
//!
|
||||||
//! 「打开文件夹」不在本模块:平台差异收敛在 [`crate::platform::opener::open_dir`]
|
//! 「打开文件夹」不在本模块:平台差异收敛在 [`crate::platform::opener::open_dir`]
|
||||||
//! (`AGENTS.md` §6),本模块一个 `cfg` 都不写。
|
//! (`AGENTS.md` §6),本模块一个 `cfg` 都不写。
|
||||||
|
|
||||||
mod error;
|
mod error;
|
||||||
mod list;
|
mod list;
|
||||||
|
mod write;
|
||||||
|
|
||||||
pub use error::SavesError;
|
pub use error::SavesError;
|
||||||
pub use list::{
|
pub use list::{
|
||||||
saves_dir, scan, screenshots_dir, Area, SavesScan, SavesSkip, ScreenshotEntry, SkipKind,
|
saves_dir, scan, screenshots_dir, Area, SavesScan, SavesSkip, ScreenshotEntry, SkipKind,
|
||||||
WorldEntry, IMAGE_EXTENSIONS, SAVES_DIR, SCREENSHOTS_DIR, WORLD_ICON,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
//! 存档的**三个写操作**(M247 · SAVESOPS):重命名世界、删除世界、备份导出(zip)。
|
||||||
|
//!
|
||||||
|
//! # 这一版做到哪
|
||||||
|
//!
|
||||||
|
//! 用户原话是「存档按照主流的方式来」——HMCL / PCL2 的存档区都有**重命名 / 删除(二次确认)/
|
||||||
|
//! 备份导出**。M235 只做了只读列表,本模块补写的那一半。
|
||||||
|
//!
|
||||||
|
//! | 操作 | 入口 | 落盘形态 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | 重命名世界 | [`rename_world`] | `saves/<old>` → `saves/<new>`(一次 `rename`,不搬字节) |
|
||||||
|
//! | 删除世界 | [`delete_world`] | 递归删 `saves/<name>`(**不跟随符号链接**) |
|
||||||
|
//! | 备份导出 | [`backup_world`] | `<dest>/<世界名>-YYYYMMDD-HHMMSS.zip`(流式 zip) |
|
||||||
|
//!
|
||||||
|
//! # 边界校验是**每次自己重做**的,不靠调用方传对
|
||||||
|
//!
|
||||||
|
//! 三个入口的第一件事都是 [`boundary::guard`]:它重新解析 `<run_dir>`、重新确认
|
||||||
|
//! `<run_dir>/saves` 是**直属子目录且不是链接**、重新确认目标那条世界目录是**真的目录而不是链接**。
|
||||||
|
//! 调用方(presenter host)拿到的是「实例 id + 世界名」这种可被 UI 传坏的东西,所以
|
||||||
|
//! **r1 复审那种「信任上游」在这里一律不成立**:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! 名字 → 必须是单段裸名字(`crate::addon::pack::path::normalize_file` 后原样相等)
|
||||||
|
//! saves/ → 必须是 <run_dir> 的真实直属子目录(解析后比父级,不拿字符串前缀当判据)
|
||||||
|
//! 世界 → 必须是 saves/ 下的真目录(symlink_metadata:符号链接、非目录一律拒)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! 三条各自对应 [`SavesWriteKind`] 里的一个具名类别,测试逐条钉住(见 `write/tests.rs`)。
|
||||||
|
//!
|
||||||
|
//! # 为什么写操作不走 `saves::scan` 的结果
|
||||||
|
//!
|
||||||
|
//! 扫描结果是**上一拍**的事实。用户在文件管理器里换了名字、别的程序删了一个目录,扫描里都还是
|
||||||
|
//! 旧值。所以写路径上不「先查快照再下手」,而是把边界与目标重新查一遍——快照只用来画界面。
|
||||||
|
//!
|
||||||
|
//! # 不跟随符号链接(`AGENTS.md` 的写盘纪律)
|
||||||
|
//!
|
||||||
|
//! | 位置 | 判据 | 为什么 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | `saves/` 本身是链接 | 拒绝([`SavesWriteKind::SymbolicLink`]) | 跟下去就写到实例目录之外了 |
|
||||||
|
//! | 世界目录是链接 | 拒绝 | 同上;删除更危险(会把链接指向的整棵树删掉) |
|
||||||
|
//! | 世界**内部**的链接 | 删除:只删链接本身;备份:跳过并计数 | 两种都不跟随,但都不静默丢(`skipped` 会计数) |
|
||||||
|
//!
|
||||||
|
//! # 失败形状
|
||||||
|
//!
|
||||||
|
//! [`SavesWriteError`] = **具名类别 + 出事的那个路径 + OS 原文**。不拼人读句子(M161 纪律:
|
||||||
|
//! 文案由 app 层按 i18n key 现求)。UI 上每一种都有话可说——见 [`SavesWriteKind`] 每一个变体的文档。
|
||||||
|
//!
|
||||||
|
//! # 模块
|
||||||
|
//!
|
||||||
|
//! | 文件 | 职责 |
|
||||||
|
//! |---|---|
|
||||||
|
//! | [`boundary`] | 名字规则 + `<run_dir>/saves/<name>` 的三道校验 + 重命名 |
|
||||||
|
//! | [`remove`] | 递归删除(不跟随链接,层级有上限) |
|
||||||
|
//! | [`backup`] | 流式 zip(限额预检 → 写临时文件 → 原子改名) |
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
mod backup;
|
||||||
|
mod boundary;
|
||||||
|
mod remove;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
|
pub use backup::{backup_world, Backup, BACKUP_EXT, MAX_BACKUP_ENTRIES, MAX_BACKUP_TOTAL_BYTES};
|
||||||
|
pub use boundary::rename_world;
|
||||||
|
pub use remove::delete_world;
|
||||||
|
|
||||||
|
/// 写操作失败的类别(**结构**;文案由 app 层按 i18n key 现求)。
|
||||||
|
///
|
||||||
|
/// 每个变体在 UI 上都有一句对应的话,没有一种是「静默失败」:
|
||||||
|
///
|
||||||
|
/// | 类别 | 用户看到什么 |
|
||||||
|
/// |---|---|
|
||||||
|
/// | [`Boundary`](Self::Boundary) | 这个路径落在 `saves/` 之外(或层级深得不像一个世界) |
|
||||||
|
/// | [`UnsafeName`](Self::UnsafeName) | 名字里有不能用的字符(`/` `\` `:` `..` …) |
|
||||||
|
/// | [`SymbolicLink`](Self::SymbolicLink) | 这个位置是符号链接,不跟随 |
|
||||||
|
/// | [`NotFound`](Self::NotFound) | 那个世界(或 `saves/` 目录)已经不在了 |
|
||||||
|
/// | [`NotADirectory`](Self::NotADirectory) | 那个名字底下不是目录 |
|
||||||
|
/// | [`Conflict`](Self::Conflict) | 同名世界已存在 / 备份文件名撞满 |
|
||||||
|
/// | [`TooLarge`](Self::TooLarge) | 备份超过体积或条目上限(附上限数值) |
|
||||||
|
/// | [`Io`](Self::Io) | 其它系统错误(权限 / 被占用),OS 原文进括号 |
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum SavesWriteKind {
|
||||||
|
/// 目标越出 `<run_dir>/saves`(名字可穿越、或解析后落在外面、或层级异常深)。
|
||||||
|
Boundary,
|
||||||
|
/// 名字过不了路径规则(空串、`.`、`..`、含 `/` `\` `:` NUL,或规整后与原串不等)。
|
||||||
|
UnsafeName,
|
||||||
|
/// 目标(`saves/` 那一层或世界那一条)是符号链接:不跟随。
|
||||||
|
SymbolicLink,
|
||||||
|
/// 目标不在了(`run_dir` / `saves/` / 世界目录三者之一)。
|
||||||
|
NotFound,
|
||||||
|
/// 目标存在但不是目录。
|
||||||
|
NotADirectory,
|
||||||
|
/// 目标已存在(重命名撞名、备份文件名撞满)。
|
||||||
|
Conflict,
|
||||||
|
/// 备份超过体积 / 条目上限。
|
||||||
|
TooLarge,
|
||||||
|
/// 其它 IO 失败(权限、被别的进程占用……OS 原文进 `detail`)。
|
||||||
|
Io,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次写操作失败。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SavesWriteError {
|
||||||
|
/// 出事的那个路径(能确定时给最内层的那一条)。
|
||||||
|
pub path: PathBuf,
|
||||||
|
/// 失败类别。
|
||||||
|
pub kind: SavesWriteKind,
|
||||||
|
/// 人读原因(OS 原文或判据本身),**只进括号**,不进主句。
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SavesWriteError {
|
||||||
|
pub(super) fn new(
|
||||||
|
path: &std::path::Path,
|
||||||
|
kind: SavesWriteKind,
|
||||||
|
detail: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
kind,
|
||||||
|
detail: detail.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一个 IO 错误:类别 [`SavesWriteKind::Io`],`detail` 是 OS 原文。
|
||||||
|
pub(super) fn io(path: &std::path::Path, error: &std::io::Error) -> Self {
|
||||||
|
Self::new(path, SavesWriteKind::Io, error.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for SavesWriteError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{}({}):{}",
|
||||||
|
self.path.display(),
|
||||||
|
kind_label(self.kind),
|
||||||
|
self.detail
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for SavesWriteError {}
|
||||||
|
|
||||||
|
/// 类别的中文短名(**日志与测试诊断用**,不是界面文案:界面按 i18n key 现求)。
|
||||||
|
fn kind_label(kind: SavesWriteKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
SavesWriteKind::Boundary => "路径越界",
|
||||||
|
SavesWriteKind::UnsafeName => "名字不可用",
|
||||||
|
SavesWriteKind::SymbolicLink => "是符号链接",
|
||||||
|
SavesWriteKind::NotFound => "已不在",
|
||||||
|
SavesWriteKind::NotADirectory => "不是目录",
|
||||||
|
SavesWriteKind::Conflict => "同名冲突",
|
||||||
|
SavesWriteKind::TooLarge => "超出上限",
|
||||||
|
SavesWriteKind::Io => "系统错误",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
//! 世界目录 → zip 备份(流式、限额预检、原子落点)。
|
||||||
|
//!
|
||||||
|
//! # 落点与命名
|
||||||
|
//!
|
||||||
|
//! `<dest_dir>/<世界名>-YYYYMMDD-HHMMSS.zip`(UTC 时间戳)。落点名撞上已有文件就顺延
|
||||||
|
//! `-2` … `-99`,撞满报 [`SavesWriteKind::Conflict`]——**绝不覆盖**上一次备份。
|
||||||
|
//!
|
||||||
|
//! `dest_dir` 由调用方给(presenter host 的默认值是启动器自己的 `exports/saves/`,见
|
||||||
|
//! `core::launch::crash_bundle::default_target_dir` 的同一先例)。本模块不猜「用户想要哪个目录」,
|
||||||
|
//! 也不在 `saves/` 里偷偷建备份目录:`saves/` 下多一个 `backups/` 会在列表里长成一个世界。
|
||||||
|
//!
|
||||||
|
//! # 为什么先预检再写第一个字节
|
||||||
|
//!
|
||||||
|
//! 一条写死的边界(与 `addon::pack::format::write` 的 `preflight` 同一条纪律):**超限要在
|
||||||
|
//! 内容进内存之前就拒**。这里的预检只走 `metadata().len()`:顶层递归一遍算条目数与未压缩总量,
|
||||||
|
//! 超限时连临时文件都不建。之后写循环里还有第二道(**已写出**的字节数),因为预检与写入之间
|
||||||
|
//! 文件可能变大(实例目录不是快照)。
|
||||||
|
//!
|
||||||
|
//! 上限本身是 [`MAX_BACKUP_TOTAL_BYTES`](3 GiB)与 [`MAX_BACKUP_ENTRIES`](20 万条):
|
||||||
|
//! 前者压在 zip 的 32 位尺寸阈值(4 GiB)之下,所以**永远不会写 ZIP64**——一个能被自己与
|
||||||
|
//! 常见第三方工具读回来的包,比一个「理论上更大」的包有用。
|
||||||
|
//!
|
||||||
|
//! # 为什么写临时文件再改名
|
||||||
|
//!
|
||||||
|
//! 半截的备份文件比没有备份更坏:用户会以为它是一份完整的备份。所以写到 `<名字>.part`,
|
||||||
|
//! 收尾成功才 `rename` 到最终名字;任何一步失败都把临时文件删掉(同 `addon::pack` 的
|
||||||
|
//! `temporary_path` 口径)。
|
||||||
|
//!
|
||||||
|
//! # 符号链接
|
||||||
|
//!
|
||||||
|
//! **不跟随**:世界内部遇到链接只**跳过并计数**([`Backup::skipped`]),不打包链接指向的内容。
|
||||||
|
//! 跳过不是静默的——`skipped` 会进 UI 的那句话(「跳过了 2 个符号链接」),与 M235「不静默丢」同一条纪律。
|
||||||
|
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
use super::boundary::guard;
|
||||||
|
use super::{SavesWriteError, SavesWriteKind};
|
||||||
|
|
||||||
|
/// 备份文件的扩展名。
|
||||||
|
pub const BACKUP_EXT: &str = "zip";
|
||||||
|
|
||||||
|
/// 未压缩总量上限(3 GiB:压在 zip 的 32 位尺寸阈值之下,见模块文档)。
|
||||||
|
pub const MAX_BACKUP_TOTAL_BYTES: u64 = 3 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// 条目数上限(20 万条;一个巨型世界的 `region/` 通常是几千条)。
|
||||||
|
pub const MAX_BACKUP_ENTRIES: usize = 200_000;
|
||||||
|
|
||||||
|
/// 搬内容时的缓冲(流式,不把文件整份读进内存)。
|
||||||
|
const COPY_CHUNK: usize = 1024 * 1024;
|
||||||
|
|
||||||
|
/// 撞名时最多顺延多少个后缀(`-2` … `-99`)。
|
||||||
|
const MAX_SUFFIX: u32 = 99;
|
||||||
|
|
||||||
|
/// 一次成功的备份。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Backup {
|
||||||
|
/// 落点(最终名字,不是临时文件)。
|
||||||
|
pub path: PathBuf,
|
||||||
|
/// 写出来的 zip 字节数。
|
||||||
|
pub bytes: u64,
|
||||||
|
/// 源目录的**未压缩**字节数(预检算出来的)。
|
||||||
|
pub sourced: u64,
|
||||||
|
/// 进了包的条目数(含目录条目)。
|
||||||
|
pub entries: usize,
|
||||||
|
/// 被跳过的符号链接数(不跟随;这个数要能报到界面上)。
|
||||||
|
pub skipped: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写操作的限额(生产口径见 [`Limits::production`];测试可以用极小的值钉住边界)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(super) struct Limits {
|
||||||
|
/// 未压缩总量上限。
|
||||||
|
pub(super) total: u64,
|
||||||
|
/// 条目数上限。
|
||||||
|
pub(super) entries: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Limits {
|
||||||
|
pub(super) const fn production() -> Self {
|
||||||
|
Self {
|
||||||
|
total: MAX_BACKUP_TOTAL_BYTES,
|
||||||
|
entries: MAX_BACKUP_ENTRIES,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(super) const fn new(total: u64, entries: usize) -> Self {
|
||||||
|
Self { total, entries }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 备份一个世界目录到 `dest_dir`,返回落点与统计。
|
||||||
|
///
|
||||||
|
/// 第一句永远是 [`guard`]:源必须是 `<run_dir>/saves` 下的真目录。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// | 情况 | 类别 |
|
||||||
|
/// |---|---|
|
||||||
|
/// | 源越界 / 是链接 / 不在 / 不是目录 | `Boundary` / `SymbolicLink` / `NotFound` / `NotADirectory` |
|
||||||
|
/// | 超过 [`MAX_BACKUP_TOTAL_BYTES`] 或 [`MAX_BACKUP_ENTRIES`] | [`SavesWriteKind::TooLarge`] |
|
||||||
|
/// | 撞名顺延到 `-99` 还被占 | [`SavesWriteKind::Conflict`] |
|
||||||
|
/// | 建目录 / 读文件 / 写 zip / 改名失败 | [`SavesWriteKind::Io`](`path` 指出卡在哪一条) |
|
||||||
|
pub fn backup_world(
|
||||||
|
run_dir: &Path,
|
||||||
|
name: &str,
|
||||||
|
dest_dir: &Path,
|
||||||
|
) -> Result<Backup, SavesWriteError> {
|
||||||
|
backup_world_at(
|
||||||
|
run_dir,
|
||||||
|
name,
|
||||||
|
dest_dir,
|
||||||
|
SystemTime::now(),
|
||||||
|
Limits::production(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 带显式时钟与限额的那一份(测试用它钉「时戳命名」与「限额拦在内容进内存之前」)。
|
||||||
|
pub(super) fn backup_world_at(
|
||||||
|
run_dir: &Path,
|
||||||
|
name: &str,
|
||||||
|
dest_dir: &Path,
|
||||||
|
at: SystemTime,
|
||||||
|
limits: Limits,
|
||||||
|
) -> Result<Backup, SavesWriteError> {
|
||||||
|
let target = guard(run_dir, name)?;
|
||||||
|
// 落点目录必须是**绝对且非空**的:相对路径会落到进程当前目录(测试里真的发生过——
|
||||||
|
// 一次 `dest = ""` 把 zip 写进了 crate 根),这属于「写到用户没想到的地方」,直接拒。
|
||||||
|
if dest_dir.as_os_str().is_empty() || !dest_dir.is_absolute() {
|
||||||
|
return Err(SavesWriteError::new(
|
||||||
|
dest_dir,
|
||||||
|
SavesWriteKind::Boundary,
|
||||||
|
format!(
|
||||||
|
"落点目录需要绝对路径,收到 `{}`(相对路径会落到进程当前目录)",
|
||||||
|
dest_dir.display()
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// 预检:只走 metadata,不读内容;超限时下面的临时文件连建都不建。
|
||||||
|
let plan = plan(&target.world, limits)?;
|
||||||
|
std::fs::create_dir_all(dest_dir).map_err(|error| SavesWriteError::io(dest_dir, &error))?;
|
||||||
|
let path = unique_target(dest_dir, name, at)?;
|
||||||
|
let temp = temporary_path(&path);
|
||||||
|
// 上一轮被打断留下的临时文件:先清掉(同名字段是 `.part`,扫不到就继续)。
|
||||||
|
let _ = std::fs::remove_file(&temp);
|
||||||
|
|
||||||
|
match write_zip(&plan, &temp, limits) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(error) => {
|
||||||
|
let _ = std::fs::remove_file(&temp);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Err(error) = std::fs::rename(&temp, &path) {
|
||||||
|
let _ = std::fs::remove_file(&temp);
|
||||||
|
return Err(SavesWriteError::io(&path, &error));
|
||||||
|
}
|
||||||
|
let bytes = std::fs::metadata(&path).map(|meta| meta.len()).unwrap_or(0);
|
||||||
|
Ok(Backup {
|
||||||
|
path,
|
||||||
|
bytes,
|
||||||
|
sourced: plan.total,
|
||||||
|
entries: plan.dirs.len() + plan.files.len(),
|
||||||
|
skipped: plan.skipped,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预检结果:要打进包的那一份清单(`saves/` 之外一个字节都没碰)。
|
||||||
|
struct Plan {
|
||||||
|
/// 目录条目(zip 内名字,带结尾 `/`)。
|
||||||
|
dirs: Vec<String>,
|
||||||
|
/// 文件条目(zip 内名字 + 磁盘路径 + 字节数)。
|
||||||
|
files: Vec<(String, PathBuf, u64)>,
|
||||||
|
/// 未压缩总量。
|
||||||
|
total: u64,
|
||||||
|
/// 跳过的符号链接数。
|
||||||
|
skipped: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 走一遍世界目录,算条目数与总量(**只读**,不读内容)。
|
||||||
|
fn plan(world: &Path, limits: Limits) -> Result<Plan, SavesWriteError> {
|
||||||
|
let mut out = Plan {
|
||||||
|
dirs: Vec::new(),
|
||||||
|
files: Vec::new(),
|
||||||
|
total: 0,
|
||||||
|
skipped: 0,
|
||||||
|
};
|
||||||
|
// (磁盘目录, zip 内前缀):显式栈,避免深目录上的递归栈。
|
||||||
|
let mut stack: Vec<(PathBuf, String)> = vec![(world.to_path_buf(), String::new())];
|
||||||
|
while let Some((dir, prefix)) = stack.pop() {
|
||||||
|
let read = std::fs::read_dir(&dir).map_err(|error| SavesWriteError::io(&dir, &error))?;
|
||||||
|
// 同一个目录里的顺序不影响包内容,但排序能让同名世界两次备份的条目序一致(可比对)。
|
||||||
|
let mut children: Vec<_> = read
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|error| SavesWriteError::io(&dir, &error))?;
|
||||||
|
children.sort_by_key(std::fs::DirEntry::file_name);
|
||||||
|
for child in children {
|
||||||
|
let raw = child.file_name().to_string_lossy().to_string();
|
||||||
|
let path = child.path();
|
||||||
|
let name = if prefix.is_empty() {
|
||||||
|
raw.clone()
|
||||||
|
} else {
|
||||||
|
format!("{prefix}/{raw}")
|
||||||
|
};
|
||||||
|
let meta = match std::fs::symlink_metadata(&path) {
|
||||||
|
Ok(meta) => meta,
|
||||||
|
// 预检与写入之间被删掉:当成"不在这一包里",不让整份备份失败。
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||||
|
Err(error) => return Err(SavesWriteError::io(&path, &error)),
|
||||||
|
};
|
||||||
|
if meta.file_type().is_symlink() {
|
||||||
|
out.skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if meta.is_dir() {
|
||||||
|
out.dirs.push(format!("{name}/"));
|
||||||
|
guard_entries(&path, out.dirs.len() + out.files.len(), limits)?;
|
||||||
|
stack.push((path, name));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let length = meta.len();
|
||||||
|
out.total = out.total.saturating_add(length);
|
||||||
|
out.files.push((name, path, length));
|
||||||
|
guard_total(world, out.total, limits)?;
|
||||||
|
guard_entries(world, out.dirs.len() + out.files.len(), limits)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn guard_total(path: &Path, total: u64, limits: Limits) -> Result<(), SavesWriteError> {
|
||||||
|
if total >= limits.total {
|
||||||
|
return Err(SavesWriteError::new(
|
||||||
|
path,
|
||||||
|
SavesWriteKind::TooLarge,
|
||||||
|
format!(
|
||||||
|
"世界未压缩总量超过上限 {} 字节(写出的包不写 ZIP64,所以上限压在 4 GiB 之下)",
|
||||||
|
limits.total
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn guard_entries(path: &Path, count: usize, limits: Limits) -> Result<(), SavesWriteError> {
|
||||||
|
if count > limits.entries {
|
||||||
|
return Err(SavesWriteError::new(
|
||||||
|
path,
|
||||||
|
SavesWriteKind::TooLarge,
|
||||||
|
format!("条目数 {count} 超过上限 {}", limits.entries),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 真正的 zip 写入(`temp` 由调用方负责在失败时删掉)。
|
||||||
|
fn write_zip(plan: &Plan, temp: &Path, limits: Limits) -> Result<(), SavesWriteError> {
|
||||||
|
let file = File::create(temp).map_err(|error| SavesWriteError::io(temp, &error))?;
|
||||||
|
let mut writer = zip::ZipWriter::new(file);
|
||||||
|
let options = zip::write::SimpleFileOptions::default()
|
||||||
|
.compression_method(zip::CompressionMethod::Deflated)
|
||||||
|
.unix_permissions(0o644);
|
||||||
|
|
||||||
|
for dir in &plan.dirs {
|
||||||
|
writer
|
||||||
|
.add_directory(dir.as_str(), options)
|
||||||
|
.map_err(|error| SavesWriteError::new(temp, SavesWriteKind::Io, error.to_string()))?;
|
||||||
|
}
|
||||||
|
// 第二道闸:**已写出**的未压缩字节数从 0 起算,与预检结果无关(不重复累计)。
|
||||||
|
let mut written = Written::default();
|
||||||
|
let mut buffer = vec![0_u8; COPY_CHUNK];
|
||||||
|
for (name, source, _) in &plan.files {
|
||||||
|
let mut handle = File::open(source).map_err(|error| SavesWriteError::io(source, &error))?;
|
||||||
|
writer
|
||||||
|
.start_file(name.as_str(), options)
|
||||||
|
.map_err(|error| SavesWriteError::new(source, SavesWriteKind::Io, error.to_string()))?;
|
||||||
|
loop {
|
||||||
|
let read = handle
|
||||||
|
.read(&mut buffer)
|
||||||
|
.map_err(|error| SavesWriteError::io(source, &error))?;
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
writer.write_all(&buffer[..read]).map_err(|error| {
|
||||||
|
SavesWriteError::new(temp, SavesWriteKind::Io, error.to_string())
|
||||||
|
})?;
|
||||||
|
written.spend(temp, read as u64, limits)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writer
|
||||||
|
.finish()
|
||||||
|
.map_err(|error| SavesWriteError::new(temp, SavesWriteKind::Io, error.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写循环的第二道闸(**已写出**的未压缩字节数)。
|
||||||
|
///
|
||||||
|
/// 起点是 0,**与预检的结果无关**:拿预检的返回值当基数会把全量声明尺寸再累加一遍
|
||||||
|
/// (`addon::pack::format::write::Written` 上记着同一条 P2-1 教训)。
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Written {
|
||||||
|
total: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Written {
|
||||||
|
/// 记一笔实际写出的字节,每一块之后过闸(大文件能在中途被拦下)。
|
||||||
|
fn spend(&mut self, path: &Path, bytes: u64, limits: Limits) -> Result<(), SavesWriteError> {
|
||||||
|
self.total = self.total.saturating_add(bytes);
|
||||||
|
guard_total(path, self.total, limits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 落点名:`<世界名>-<时戳>.zip`,撞名顺延(见模块文档)。
|
||||||
|
fn unique_target(dest_dir: &Path, world: &str, at: SystemTime) -> Result<PathBuf, SavesWriteError> {
|
||||||
|
let stem = format!("{world}-{}", stamp(at));
|
||||||
|
for suffix in 1..=MAX_SUFFIX {
|
||||||
|
let name = if suffix == 1 {
|
||||||
|
format!("{stem}.{BACKUP_EXT}")
|
||||||
|
} else {
|
||||||
|
format!("{stem}-{suffix}.{BACKUP_EXT}")
|
||||||
|
};
|
||||||
|
let candidate = dest_dir.join(name);
|
||||||
|
match std::fs::symlink_metadata(&candidate) {
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(candidate),
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(error) => return Err(SavesWriteError::io(&candidate, &error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(SavesWriteError::new(
|
||||||
|
dest_dir,
|
||||||
|
SavesWriteKind::Conflict,
|
||||||
|
format!("`{stem}` 的同名备份已经有 {MAX_SUFFIX} 份,换个落点目录再试"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 与目标同目录的临时文件名(同卷 `rename` 才能原子替换)。
|
||||||
|
fn temporary_path(target: &Path) -> PathBuf {
|
||||||
|
let mut name = target.file_name().map_or_else(
|
||||||
|
|| std::ffi::OsString::from("backup"),
|
||||||
|
std::ffi::OsStr::to_os_string,
|
||||||
|
);
|
||||||
|
name.push(".part");
|
||||||
|
target.with_file_name(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `SystemTime` → `YYYYMMDD-HHMMSS`(**UTC**,与 `core::launch::time` 同一个时间口径)。
|
||||||
|
///
|
||||||
|
/// 从 RFC3339 的固定位置切片,不解析、不重排(与页面 `stamp_utc` 同一条做法)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn stamp(at: SystemTime) -> String {
|
||||||
|
let text = crate::launch::time::format_rfc3339_millis(at);
|
||||||
|
let part = |range: std::ops::Range<usize>| text.get(range).unwrap_or_default();
|
||||||
|
format!(
|
||||||
|
"{}{}{}-{}{}{}",
|
||||||
|
part(0..4),
|
||||||
|
part(5..7),
|
||||||
|
part(8..10),
|
||||||
|
part(11..13),
|
||||||
|
part(14..16),
|
||||||
|
part(17..19)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
//! 写操作的**边界校验**与重命名。
|
||||||
|
//!
|
||||||
|
//! # 三道校验,每次重做
|
||||||
|
//!
|
||||||
|
//! [`guard`] 是三个写操作共同的第一句:
|
||||||
|
//!
|
||||||
|
//! | 第几道 | 查什么 | 失败类别 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | 1 | 名字是单段裸名字(`normalize_file` 之后与原串**原样相等**) | [`SavesWriteKind::UnsafeName`] |
|
||||||
|
//! | 2 | `<run_dir>/saves` 是**运行目录的直属子目录**、不是链接、是目录 | `SymbolicLink` / `NotADirectory` / `Boundary` |
|
||||||
|
//! | 3 | `saves/<name>` 是真目录(`symlink_metadata`:链接与非目录一律拒) | `SymbolicLink` / `NotADirectory` / `Boundary` |
|
||||||
|
//!
|
||||||
|
//! ## 为什么第 2 道比的是「父级」而不是字符串前缀
|
||||||
|
//!
|
||||||
|
//! `Path::starts_with` 是**逐段**比较,看起来够用;但它对「`saves/` 被换成一个指向实例外的
|
||||||
|
//! 目录联接」这种情况答不了——解析之后父级已经不是运行目录了。所以两道都比**解析后的父级**:
|
||||||
|
//! `saves` 的父级必须**就是**运行目录本身,世界目录的父级必须**就是** `saves`。这样
|
||||||
|
//! 「把 `saves` 换成链接」「把世界目录换成链接」两类越界都在同一条判据上被挡住,
|
||||||
|
//! 不依赖字符串长什么样。
|
||||||
|
//!
|
||||||
|
//! ## 为什么第 1 道要「原样相等」而不是「规整后能用」
|
||||||
|
//!
|
||||||
|
//! `normalize_file` 会**丢弃** `.` 段与空段(`a//b` → `a/b`)。对 zip 条目名那是好意,对
|
||||||
|
//! 磁盘上的目录名却是**改名**:用户输入 `a//b`,我们悄悄去动 `a/b`。所以这里要求规整结果与输入
|
||||||
|
//! 一字不差——超出的部分(`..`、`/`、`\`、`:`、NUL、绝对路径)由 `normalize_file` 一律拒掉。
|
||||||
|
//!
|
||||||
|
//! # 重命名的失败顺序
|
||||||
|
//!
|
||||||
|
//! 先校验**源**(必须是一个真世界),再校验**目标名字**,最后才碰磁盘。目标位置上
|
||||||
|
//! **存在任何东西**(目录、文件、坏链接——`symlink_metadata` 都看得见)都算冲突:
|
||||||
|
//! 「覆盖别人」这件事不在本 mission 的语义里。
|
||||||
|
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use super::{SavesWriteError, SavesWriteKind};
|
||||||
|
use crate::game::saves::{saves_dir, SAVES_DIR};
|
||||||
|
|
||||||
|
/// 一次写操作**已校验**的目标。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(super) struct Target {
|
||||||
|
/// `<run_dir>/saves`(**解析后的真实路径**:后续拼名字都拿它当父级)。
|
||||||
|
pub(super) saves: PathBuf,
|
||||||
|
/// `<run_dir>/saves/<name>`(**解析后的真实目录**)。
|
||||||
|
///
|
||||||
|
/// 它就是这一拍要动的那棵树。解析过 ⇒ 后面 `rename` / 递归删 / 打包都不必再猜路径形态。
|
||||||
|
pub(super) world: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 名字 → 单段裸名字(不过规则就给具名错误)。
|
||||||
|
///
|
||||||
|
/// `anchor` 只用于错误里的路径:给「用户以为自己在动的那一条」。
|
||||||
|
pub(super) fn check_name(anchor: &Path, name: &str) -> Result<(), SavesWriteError> {
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(SavesWriteError::new(
|
||||||
|
anchor,
|
||||||
|
SavesWriteKind::UnsafeName,
|
||||||
|
"名字为空",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// 判据复用整合包侧那一份(不在存档这边写第二套规则),再补一条「规整后必须原样相等且只有一段」。
|
||||||
|
// `normalize_file("a/b")` 会把 `a/b` 原样返回(那是包内路径,本来就允许多段),可磁盘上的
|
||||||
|
// **目录名**多一段就落到别处去了——所以这里还要判「只有一段」。
|
||||||
|
match crate::addon::pack::path::normalize_file(name) {
|
||||||
|
Ok(normalized) if normalized == name && !normalized.contains('/') => Ok(()),
|
||||||
|
Ok(_) => Err(SavesWriteError::new(
|
||||||
|
anchor,
|
||||||
|
SavesWriteKind::UnsafeName,
|
||||||
|
format!("名字 `{name}` 不是单段(`/` `\\` `:` `.` 段都不许出现)"),
|
||||||
|
)),
|
||||||
|
Err(error) => Err(SavesWriteError::new(
|
||||||
|
anchor,
|
||||||
|
SavesWriteKind::UnsafeName,
|
||||||
|
format!("名字 `{name}` 过不了路径规则:{error}"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<run_dir>` + 世界名 → 已校验的目标(见模块文档的三道)。
|
||||||
|
pub(super) fn guard(run_dir: &Path, name: &str) -> Result<Target, SavesWriteError> {
|
||||||
|
check_name(run_dir, name)?;
|
||||||
|
|
||||||
|
let run_real = real_dir(run_dir)?;
|
||||||
|
let saves = saves_dir(run_dir);
|
||||||
|
let saves_real = real_dir(&saves)?;
|
||||||
|
// 第 2 道:`saves` 必须是**运行目录的直属子目录**(解析后比父级,不比字符串前缀)。
|
||||||
|
if saves_real.parent() != Some(run_real.as_path())
|
||||||
|
|| saves_real.file_name() != Some(OsStr::new(SAVES_DIR))
|
||||||
|
{
|
||||||
|
return Err(SavesWriteError::new(
|
||||||
|
&saves,
|
||||||
|
SavesWriteKind::Boundary,
|
||||||
|
format!(
|
||||||
|
"{} 解析到 {},不是 {} 的直属 {SAVES_DIR} 目录:拒绝沿它写盘",
|
||||||
|
saves.display(),
|
||||||
|
saves_real.display(),
|
||||||
|
run_real.display()
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let world = saves_real.join(name);
|
||||||
|
let world_real = real_dir(&world)?;
|
||||||
|
// 第 3 道:世界目录必须**直接**挂在 `saves/` 下面(解析后比父级)。
|
||||||
|
if world_real.parent() != Some(saves_real.as_path()) {
|
||||||
|
return Err(SavesWriteError::new(
|
||||||
|
&world,
|
||||||
|
SavesWriteKind::Boundary,
|
||||||
|
format!(
|
||||||
|
"{} 解析到 {},不在 {} 之下:拒绝沿它写盘",
|
||||||
|
world.display(),
|
||||||
|
world_real.display(),
|
||||||
|
saves_real.display()
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Target {
|
||||||
|
saves: saves_real,
|
||||||
|
world: world_real,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 路径 → **解析后的真实目录**(不存在 / 是链接 / 不是目录各给具名类别)。
|
||||||
|
///
|
||||||
|
/// `symlink_metadata` 先判形态,`canonicalize` 再解析:只解析不看形态的话,`canonicalize`
|
||||||
|
/// 会把链接一路跟到底,越界就变成了「正常」。
|
||||||
|
fn real_dir(path: &Path) -> Result<PathBuf, SavesWriteError> {
|
||||||
|
let meta = match std::fs::symlink_metadata(path) {
|
||||||
|
Ok(meta) => meta,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
return Err(SavesWriteError::new(
|
||||||
|
path,
|
||||||
|
SavesWriteKind::NotFound,
|
||||||
|
"目录不存在",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(error) => return Err(SavesWriteError::io(path, &error)),
|
||||||
|
};
|
||||||
|
if meta.file_type().is_symlink() {
|
||||||
|
return Err(SavesWriteError::new(
|
||||||
|
path,
|
||||||
|
SavesWriteKind::SymbolicLink,
|
||||||
|
"是符号链接(不跟随:跟下去就动到实例目录之外的东西了)",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !meta.is_dir() {
|
||||||
|
return Err(SavesWriteError::new(
|
||||||
|
path,
|
||||||
|
SavesWriteKind::NotADirectory,
|
||||||
|
"不是目录",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
std::fs::canonicalize(path).map_err(|error| SavesWriteError::io(path, &error))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重命名一个世界目录:`saves/<from>` → `saves/<to>`。
|
||||||
|
///
|
||||||
|
/// 返回**新的世界目录**(解析后的路径)。判据顺序见模块文档:源、目标名字、目标位置,最后才动盘。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// | 情况 | 类别 |
|
||||||
|
/// |---|---|
|
||||||
|
/// | 源不是 `saves/` 里的真目录(越界 / 链接 / 不在 / 不是目录) | `Boundary` / `SymbolicLink` / `NotFound` / `NotADirectory` |
|
||||||
|
/// | 新名字过不了名字规则 | [`SavesWriteKind::UnsafeName`] |
|
||||||
|
/// | 新名字与原名字相同,或目标位置已存在任何东西 | [`SavesWriteKind::Conflict`] |
|
||||||
|
/// | `rename` 本身失败(权限 / 被占用) | [`SavesWriteKind::Io`] |
|
||||||
|
pub fn rename_world(run_dir: &Path, from: &str, to: &str) -> Result<PathBuf, SavesWriteError> {
|
||||||
|
let target = guard(run_dir, from)?;
|
||||||
|
// 目标名字先在**已校验的 saves 目录**上查规则(锚点用 saves,比 run_dir 更贴用户看到的那一条)。
|
||||||
|
check_name(&target.saves, to)?;
|
||||||
|
let dest = target.saves.join(to);
|
||||||
|
if to == from {
|
||||||
|
return Err(SavesWriteError::new(
|
||||||
|
&dest,
|
||||||
|
SavesWriteKind::Conflict,
|
||||||
|
"新名字与原名字相同",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
match std::fs::symlink_metadata(&dest) {
|
||||||
|
// 目标位置上有东西(目录 / 文件 / 坏链接)就拒:本操作不覆盖别人。
|
||||||
|
Ok(_) => {
|
||||||
|
return Err(SavesWriteError::new(
|
||||||
|
&dest,
|
||||||
|
SavesWriteKind::Conflict,
|
||||||
|
"目标位置已存在(先给它换个名字,或换一个名字给这个世界)",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(error) => return Err(SavesWriteError::io(&dest, &error)),
|
||||||
|
}
|
||||||
|
std::fs::rename(&target.world, &dest)
|
||||||
|
.map_err(|error| SavesWriteError::io(&target.world, &error))?;
|
||||||
|
Ok(dest)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
//! 递归删除一个世界目录(**不跟随符号链接**,层级有上限)。
|
||||||
|
//!
|
||||||
|
//! # 为什么不直接 `std::fs::remove_dir_all`
|
||||||
|
//!
|
||||||
|
//! 两个理由,都不是洁癖:
|
||||||
|
//!
|
||||||
|
//! 1. **要能说清「删了什么」**。`remove_dir_all` 是一次黑盒调用,失败时只给「最后一个错误」;
|
||||||
|
//! 这里逐条走,出错时能指认是哪一条路径([`SavesWriteError::path`] 就是它)。
|
||||||
|
//! 2. **要有一条自己写死的不跟随判据**。标准库文档保证 `remove_dir_all` 不跟随符号链接,
|
||||||
|
//! 但它是「实现保证」而不是「本模块的判据」——本模块把这件事写进自己的循环里:
|
||||||
|
//! `symlink_metadata` 判形态,链接只删链接本身([`remove_link`]),绝不递归进去。
|
||||||
|
//! unix 下 `remove_file` 对「指向目录的链接」也有效(unlink 删的是链接),
|
||||||
|
//! Windows 下目录链接要 `remove_dir`——两个都试一次,这就是 `remove_link` 的全部内容。
|
||||||
|
//!
|
||||||
|
//! # 层级上限
|
||||||
|
//!
|
||||||
|
//! 超过 [`MAX_DEPTH`] 层就报 [`SavesWriteKind::Boundary`]:真实的存档是
|
||||||
|
//! `world/{region,DIM-1/region,...}` 这种三到四层,六十层只可能是「这不是一个世界目录」。
|
||||||
|
//! 没有这道闸时,一次误传的路径会在删除上耗掉用户不想要的长时间(而删除是不可逆的)。
|
||||||
|
//!
|
||||||
|
//! # 边界
|
||||||
|
//!
|
||||||
|
//! 判据是「检查那一刻」的快照:检查与 `read_dir` 之间被人换掉组件(TOCTOU)看不到。
|
||||||
|
//! 本命令不是并发/对抗入口,不做跨进程锁(与 `game::adopt::run_dir` 同一条已知边界)。
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use super::boundary::guard;
|
||||||
|
use super::{SavesWriteError, SavesWriteKind};
|
||||||
|
|
||||||
|
/// 递归删除的最大层级(见模块文档)。
|
||||||
|
pub const MAX_DEPTH: usize = 64;
|
||||||
|
|
||||||
|
/// 删除一个世界目录(递归),返回被删掉的那个目录(**解析后的真实路径**)。
|
||||||
|
///
|
||||||
|
/// 第一句永远是 [`guard`]:源必须是 `<run_dir>/saves` 下的真目录。越界、符号链接、
|
||||||
|
/// 不存在的目标各自给具名类别,**一个字节都不删**。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// | 情况 | 类别 |
|
||||||
|
/// |---|---|
|
||||||
|
/// | 源越界 / 是链接 / 不在 / 不是目录 | `Boundary` / `SymbolicLink` / `NotFound` / `NotADirectory` |
|
||||||
|
/// | 目录层级超过 [`MAX_DEPTH`] | [`SavesWriteKind::Boundary`] |
|
||||||
|
/// | 删某一条失败(权限 / 被占用) | [`SavesWriteKind::Io`](`path` 指出卡在哪一条) |
|
||||||
|
pub fn delete_world(run_dir: &Path, name: &str) -> Result<PathBuf, SavesWriteError> {
|
||||||
|
let target = guard(run_dir, name)?;
|
||||||
|
remove_tree(&target.world, 0)?;
|
||||||
|
Ok(target.world)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删掉 `dir` 这一棵(`depth` 从 0 起算)。
|
||||||
|
fn remove_tree(dir: &Path, depth: usize) -> Result<(), SavesWriteError> {
|
||||||
|
if depth > MAX_DEPTH {
|
||||||
|
return Err(SavesWriteError::new(
|
||||||
|
dir,
|
||||||
|
SavesWriteKind::Boundary,
|
||||||
|
format!("目录层级超过 {MAX_DEPTH} 层,不像一个世界目录:拒绝继续往下删"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let read = std::fs::read_dir(dir).map_err(|error| SavesWriteError::io(dir, &error))?;
|
||||||
|
for entry in read {
|
||||||
|
let entry = entry.map_err(|error| SavesWriteError::io(dir, &error))?;
|
||||||
|
let path = entry.path();
|
||||||
|
let meta = match std::fs::symlink_metadata(&path) {
|
||||||
|
Ok(meta) => meta,
|
||||||
|
// 刚被判据挡住又在两步之间消失:目标已经没了,继续删剩下的。
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||||
|
Err(error) => return Err(SavesWriteError::io(&path, &error)),
|
||||||
|
};
|
||||||
|
let file_type = meta.file_type();
|
||||||
|
if file_type.is_symlink() {
|
||||||
|
// **只删链接本身**:链接指向的东西不在本操作的范围里。
|
||||||
|
remove_link(&path)?;
|
||||||
|
} else if file_type.is_dir() {
|
||||||
|
remove_tree(&path, depth + 1)?;
|
||||||
|
} else {
|
||||||
|
std::fs::remove_file(&path).map_err(|error| SavesWriteError::io(&path, &error))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::fs::remove_dir(dir).map_err(|error| SavesWriteError::io(dir, &error))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删一条符号链接自身(unix 用 `remove_file`,Windows 的目录链接要用 `remove_dir`)。
|
||||||
|
fn remove_link(path: &Path) -> Result<(), SavesWriteError> {
|
||||||
|
match std::fs::remove_file(path) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(first) => std::fs::remove_dir(path).map_err(|_| SavesWriteError::io(path, &first)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
//! 写操作的判据:**真的在盘上建目录再动它**(不 mock 文件系统)。
|
||||||
|
//!
|
||||||
|
//! # 每个用例抓得住什么
|
||||||
|
//!
|
||||||
|
//! | 用例 | 抓得住 |
|
||||||
|
//! |---|---|
|
||||||
|
//! | [`rename_moves_only_the_named_world`] | 改名真的搬了目录、世界内容原样、别的世界不动 |
|
||||||
|
//! | [`rename_refuses_conflicts_and_bad_names`] | 撞名 / 同名 / 非法字符 / 分隔符 / 穿越全部拒,且**盘上一个字节没变** |
|
||||||
|
//! | [`traversal_is_refused_before_touching_the_disk`] | `saves/../..` 这类名字被挡在名字规则上,`saves/` 之外的东西不受影响 |
|
||||||
|
//! | [`saves_must_be_a_plain_child_of_the_run_dir`] | `saves/` 被占成文件 ⇒ 具名错误;被换成链接 ⇒ 拒(`cfg(unix)`) |
|
||||||
|
//! | [`symlinks_are_never_followed`] | 世界目录是链接 ⇒ 三个操作都拒、链接指向的那棵树**一根毫毛没少**(`cfg(unix)`) |
|
||||||
|
//! | [`nested_symlinks_are_removed_or_skipped`] | 世界**内部**的链接:删除只删链接、备份跳过并计数(`cfg(unix)`) |
|
||||||
|
//! | [`delete_removes_one_world_and_bumps_the_parent_mtime`] | 只删指名的那一个;`saves/` 的 mtime 变了、别的世界的 mtime 没变 |
|
||||||
|
//! | [`backup_writes_a_timestamped_zip`] | 时戳命名、条目与内容能读回来、目录条目也在 |
|
||||||
|
//! | [`backup_never_overwrites_an_earlier_export`] | 同一时刻两份备份 ⇒ `-2` 后缀,前一份内容不变 |
|
||||||
|
//! | [`backup_limits_are_checked_before_any_byte_is_written`] | 超限 ⇒ `TooLarge` 且落点目录**一个文件都没有** |
|
||||||
|
//! | [`missing_targets_are_named`] | 不存在的世界 / 不是目录 / 没有 `saves/` 各给具名类别 |
|
||||||
|
//!
|
||||||
|
//! # 变异自证(M247 的门禁要求)
|
||||||
|
//!
|
||||||
|
//! 把 `boundary::guard` 里那三道的**第二、三道**(`saves` 与世界的父级判据 + `real_dir` 的
|
||||||
|
//! 链接判据)删掉之后,[`symlinks_are_never_followed`] 与
|
||||||
|
//! [`saves_must_be_a_plain_child_of_the_run_dir`] 必红——报告里附的是实测输出,不是推测。
|
||||||
|
//! 只删第一道(名字规则)则 [`traversal_is_refused_before_touching_the_disk`] 与
|
||||||
|
//! [`rename_refuses_conflicts_and_bad_names`] 必红。
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::addon::temp::TempRoot;
|
||||||
|
use crate::game::saves::{saves_dir, SAVES_DIR};
|
||||||
|
|
||||||
|
mod backup;
|
||||||
|
|
||||||
|
/// 一个只跑一帧的临时运行目录(`TempRoot::under` 返回时父级已被删,所以要自己建)。
|
||||||
|
fn run_dir(tag: &str) -> TempRoot {
|
||||||
|
let root = TempRoot::under(tag, "run");
|
||||||
|
std::fs::create_dir_all(&root).expect("建运行目录");
|
||||||
|
root
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在 `saves/` 下造一个世界(两个文件 + 两层目录)。
|
||||||
|
fn world(root: &Path, name: &str) -> PathBuf {
|
||||||
|
let dir = saves_dir(root).join(name);
|
||||||
|
std::fs::create_dir_all(dir.join("region")).expect("建世界子目录");
|
||||||
|
std::fs::write(dir.join("level.dat"), b"nbt").expect("写 level.dat");
|
||||||
|
std::fs::write(dir.join("region/r.0.0.mca"), b"chunk").expect("写 region");
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `(相对路径, 是否目录, 字节数, mtime 毫秒)` 的排序列表——「盘上没变」的判据本体。
|
||||||
|
fn fingerprint(root: &Path) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut stack = vec![root.to_path_buf()];
|
||||||
|
while let Some(dir) = stack.pop() {
|
||||||
|
let Ok(read) = std::fs::read_dir(&dir) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for entry in read.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
let Ok(meta) = std::fs::symlink_metadata(&path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let rel = path
|
||||||
|
.strip_prefix(root)
|
||||||
|
.unwrap_or(&path)
|
||||||
|
.display()
|
||||||
|
.to_string();
|
||||||
|
out.push(format!(
|
||||||
|
"{rel}|dir={}|len={}|mtime={}",
|
||||||
|
meta.is_dir(),
|
||||||
|
meta.len(),
|
||||||
|
meta.modified()
|
||||||
|
.ok()
|
||||||
|
.and_then(|time| time.duration_since(SystemTime::UNIX_EPOCH).ok())
|
||||||
|
.map_or(0, |since| since.as_millis())
|
||||||
|
));
|
||||||
|
if meta.is_dir() {
|
||||||
|
stack.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 改名真的搬了目录、世界内容原样、别的世界不动。
|
||||||
|
#[test]
|
||||||
|
fn rename_moves_only_the_named_world() {
|
||||||
|
let root = run_dir("m247-rename");
|
||||||
|
world(&root, "Alpha");
|
||||||
|
world(&root, "Beta");
|
||||||
|
let beta_before = fingerprint(&saves_dir(&root).join("Beta"));
|
||||||
|
|
||||||
|
let moved = rename_world(&root, "Alpha", "Alpha Renamed").expect("改名");
|
||||||
|
assert!(moved.ends_with("Alpha Renamed"));
|
||||||
|
assert!(!saves_dir(&root).join("Alpha").exists(), "旧目录还在");
|
||||||
|
let new = saves_dir(&root).join("Alpha Renamed");
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(new.join("level.dat")).expect("读 level.dat"),
|
||||||
|
b"nbt",
|
||||||
|
"改名把内容搬丢了"
|
||||||
|
);
|
||||||
|
assert!(new.join("region/r.0.0.mca").exists(), "子目录没跟过来");
|
||||||
|
assert_eq!(
|
||||||
|
fingerprint(&saves_dir(&root).join("Beta")),
|
||||||
|
beta_before,
|
||||||
|
"改名动了别的世界"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 撞名 / 同名 / 非法字符 / 分隔符 / 穿越全部拒,且**盘上一个字节没变**。
|
||||||
|
#[test]
|
||||||
|
fn rename_refuses_conflicts_and_bad_names() {
|
||||||
|
let root = run_dir("m247-rename-bad");
|
||||||
|
world(&root, "Alpha");
|
||||||
|
world(&root, "Beta");
|
||||||
|
let before = fingerprint(&root);
|
||||||
|
|
||||||
|
let cases: &[(&str, &str, SavesWriteKind)] = &[
|
||||||
|
("Alpha", "Beta", SavesWriteKind::Conflict),
|
||||||
|
("Alpha", "Alpha", SavesWriteKind::Conflict),
|
||||||
|
("Alpha", "../escape", SavesWriteKind::UnsafeName),
|
||||||
|
("Alpha", "a/b", SavesWriteKind::UnsafeName),
|
||||||
|
("Alpha", "a\\b", SavesWriteKind::UnsafeName),
|
||||||
|
("Alpha", "C:evil", SavesWriteKind::UnsafeName),
|
||||||
|
("Alpha", "..", SavesWriteKind::UnsafeName),
|
||||||
|
("Alpha", ".", SavesWriteKind::UnsafeName),
|
||||||
|
("Alpha", "", SavesWriteKind::UnsafeName),
|
||||||
|
// 源自己也不许穿越
|
||||||
|
("../Alpha", "Other", SavesWriteKind::UnsafeName),
|
||||||
|
("saves/../Alpha", "Other", SavesWriteKind::UnsafeName),
|
||||||
|
("", "Other", SavesWriteKind::UnsafeName),
|
||||||
|
];
|
||||||
|
for (from, to, kind) in cases {
|
||||||
|
let error =
|
||||||
|
rename_world(&root, from, to).expect_err(&format!("{from} → {to:?} 居然成功了"));
|
||||||
|
assert_eq!(error.kind, *kind, "{from} → {to:?}:{}", error.detail);
|
||||||
|
}
|
||||||
|
assert_eq!(fingerprint(&root), before, "被拒的改名动了磁盘");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `saves/../..` 这类名字被挡在名字规则上,`saves/` 之外的东西不受影响。
|
||||||
|
#[test]
|
||||||
|
fn traversal_is_refused_before_touching_the_disk() {
|
||||||
|
let root = run_dir("m247-traversal");
|
||||||
|
world(&root, "Alpha");
|
||||||
|
// `saves/` 之外的哨兵:任何一次越界删除都会把它带走。
|
||||||
|
std::fs::write(root.join("sentinel.txt"), b"keep me").expect("写哨兵");
|
||||||
|
let outside = root.parent().expect("临时目录一定有父级");
|
||||||
|
let before = fingerprint(&root);
|
||||||
|
|
||||||
|
for name in [
|
||||||
|
"../..",
|
||||||
|
"..",
|
||||||
|
"../../sentinel.txt",
|
||||||
|
"saves/../..",
|
||||||
|
"/etc",
|
||||||
|
"C:/Windows",
|
||||||
|
".",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
let error = delete_world(&root, name).expect_err(&format!("删 {name:?} 居然成功了"));
|
||||||
|
assert_eq!(
|
||||||
|
error.kind,
|
||||||
|
SavesWriteKind::UnsafeName,
|
||||||
|
"{name:?}:{}",
|
||||||
|
error.detail
|
||||||
|
);
|
||||||
|
let error = backup_world(&root, name, &root.join("exports"))
|
||||||
|
.expect_err(&format!("备份 {name:?} 居然成功了"));
|
||||||
|
assert_eq!(error.kind, SavesWriteKind::UnsafeName);
|
||||||
|
}
|
||||||
|
assert_eq!(fingerprint(&root), before, "越界尝试动了磁盘");
|
||||||
|
assert!(root.join("sentinel.txt").exists(), "哨兵被删了");
|
||||||
|
assert!(!outside.join("exports").exists(), "备份在实例外建了目录");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `saves/` 必须是运行目录下的**真目录**:被占成文件 ⇒ 具名错误;被换成链接 ⇒ 拒。
|
||||||
|
#[test]
|
||||||
|
fn saves_must_be_a_plain_child_of_the_run_dir() {
|
||||||
|
// 被占成文件
|
||||||
|
let root = run_dir("m247-saves-file");
|
||||||
|
std::fs::write(saves_dir(&root), b"not a dir").expect("用文件占住 saves");
|
||||||
|
let error = delete_world(&root, "Alpha").expect_err("saves 是文件,居然删成功了");
|
||||||
|
assert_eq!(error.kind, SavesWriteKind::NotADirectory);
|
||||||
|
|
||||||
|
// 没有 saves/
|
||||||
|
let empty = run_dir("m247-no-saves");
|
||||||
|
let error = delete_world(&empty, "Alpha").expect_err("没有 saves/,居然删成功了");
|
||||||
|
assert_eq!(error.kind, SavesWriteKind::NotFound);
|
||||||
|
|
||||||
|
// 被换成符号链接(指到实例之外)
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
let linked = run_dir("m247-saves-link");
|
||||||
|
let elsewhere = linked.parent().expect("父级").join("elsewhere-saves");
|
||||||
|
std::fs::create_dir_all(elsewhere.join("Alpha")).expect("建实例外世界");
|
||||||
|
std::fs::write(elsewhere.join("Alpha/level.dat"), b"nbt").expect("写实例外 level.dat");
|
||||||
|
symlink(&elsewhere, saves_dir(&linked)).expect("建 saves 链接");
|
||||||
|
let error = delete_world(&linked, "Alpha").expect_err("saves 是链接,居然删成功了");
|
||||||
|
assert_eq!(error.kind, SavesWriteKind::SymbolicLink, "{}", error.detail);
|
||||||
|
assert!(
|
||||||
|
elsewhere.join("Alpha/level.dat").exists(),
|
||||||
|
"顺着 saves 链接把实例外的世界删了"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 世界目录是链接 ⇒ 三个操作都拒、链接指向的那棵树一根毫毛没少(`cfg(unix)`)。
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn symlinks_are_never_followed() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let root = run_dir("m247-symlink-world");
|
||||||
|
std::fs::create_dir_all(saves_dir(&root)).expect("建 saves");
|
||||||
|
let elsewhere = root.parent().expect("父级").join("shared-world");
|
||||||
|
std::fs::create_dir_all(elsewhere.join("region")).expect("建实例外世界");
|
||||||
|
std::fs::write(elsewhere.join("level.dat"), b"nbt").expect("写 level.dat");
|
||||||
|
symlink(&elsewhere, saves_dir(&root).join("Linked")).expect("建世界链接");
|
||||||
|
|
||||||
|
let before = fingerprint(&elsewhere);
|
||||||
|
for error in [
|
||||||
|
rename_world(&root, "Linked", "Copied").expect_err("链接世界居然改名成功了"),
|
||||||
|
delete_world(&root, "Linked").expect_err("链接世界居然删成功了"),
|
||||||
|
backup_world(&root, "Linked", &root.join("exports")).expect_err("链接世界居然备份成功了"),
|
||||||
|
] {
|
||||||
|
assert_eq!(error.kind, SavesWriteKind::SymbolicLink, "{}", error.detail);
|
||||||
|
}
|
||||||
|
assert_eq!(fingerprint(&elsewhere), before, "跟着链接动了实例外的目录");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Windows 上「不跟随链接」的可执行证据:**目录联接(junction)不需要管理员权限**,
|
||||||
|
/// 所以这条判据在 Windows 上真的跑得起来(`symlink_dir` 要提权 / 开发者模式,跑不了)。
|
||||||
|
///
|
||||||
|
/// 判据是「拒 + 实例外那棵树一根毫毛没少」;具体的具名类别两档都算过:
|
||||||
|
/// 联接被 `symlink_metadata` 认成链接 ⇒ `SymbolicLink`(**M247 实测就是这一档**);
|
||||||
|
/// 只被解析成目录 ⇒「`saves` 的直属子目录」那道父级判据把它拦成 `Boundary`。
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn junctions_are_never_followed() {
|
||||||
|
let root = run_dir("m247-junction-world");
|
||||||
|
std::fs::create_dir_all(saves_dir(&root)).expect("建 saves");
|
||||||
|
let elsewhere = root.parent().expect("父级").join("shared-junction");
|
||||||
|
std::fs::create_dir_all(elsewhere.join("region")).expect("建实例外世界");
|
||||||
|
std::fs::write(elsewhere.join("level.dat"), b"nbt").expect("写 level.dat");
|
||||||
|
let link = saves_dir(&root).join("Linked");
|
||||||
|
// `quiet` 不是洁癖:Windows 上裸 `Command::new` 会给每个子进程开一块控制台,
|
||||||
|
// GUI 父进程下就是桌面上「黑框闪一下」(`tests/child_quiet_guard.rs` 盯着这一条)。
|
||||||
|
let status = crate::platform::child::quiet("cmd")
|
||||||
|
.args(["/C", "mklink", "/J"])
|
||||||
|
.arg(&link)
|
||||||
|
.arg(&elsewhere)
|
||||||
|
.output()
|
||||||
|
.expect("拉起 mklink");
|
||||||
|
assert!(
|
||||||
|
status.status.success(),
|
||||||
|
"mklink /J 失败:{}",
|
||||||
|
String::from_utf8_lossy(&status.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
let before = fingerprint(&elsewhere);
|
||||||
|
let error = delete_world(&root, "Linked").expect_err("联接世界居然删成功了");
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
error.kind,
|
||||||
|
SavesWriteKind::SymbolicLink | SavesWriteKind::Boundary
|
||||||
|
),
|
||||||
|
"联接被当成普通目录放过了:{:?} / {}",
|
||||||
|
error.kind,
|
||||||
|
error.detail
|
||||||
|
);
|
||||||
|
let error = backup_world(&root, "Linked", &root.join("exports")).expect_err("居然备份了联接");
|
||||||
|
assert!(matches!(
|
||||||
|
error.kind,
|
||||||
|
SavesWriteKind::SymbolicLink | SavesWriteKind::Boundary
|
||||||
|
));
|
||||||
|
assert_eq!(fingerprint(&elsewhere), before, "跟着联接动了实例外的目录");
|
||||||
|
// 收尾:先摘掉联接,别让临时根的清理去碰它(`TempRoot` 的 Drop 在下面自己删)。
|
||||||
|
let _ = std::fs::remove_dir(&link);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 世界**内部**的链接:删除只删链接、备份跳过并计数(`cfg(unix)`)。
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn nested_symlinks_are_removed_or_skipped() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let root = run_dir("m247-nested-link");
|
||||||
|
let dir = world(&root, "Alpha");
|
||||||
|
let target = root.join("outside.bin");
|
||||||
|
std::fs::write(&target, b"outside").expect("写实例外文件");
|
||||||
|
symlink(&target, dir.join("region/link.bin")).expect("建世界内链接");
|
||||||
|
|
||||||
|
let exports = root.join("exports");
|
||||||
|
let backup = backup_world(&root, "Alpha", &exports).expect("备份");
|
||||||
|
assert_eq!(backup.skipped, 1, "世界内的链接要跳过并计数");
|
||||||
|
|
||||||
|
delete_world(&root, "Alpha").expect("删世界");
|
||||||
|
assert!(!dir.exists(), "世界目录还在");
|
||||||
|
assert!(target.exists(), "删世界时把链接指向的实例外文件也删了");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 只删指名的那一个;`saves/` 的 mtime 变了、别的世界的 mtime 没变。
|
||||||
|
#[test]
|
||||||
|
fn delete_removes_one_world_and_bumps_the_parent_mtime() {
|
||||||
|
let root = run_dir("m247-delete");
|
||||||
|
world(&root, "Alpha");
|
||||||
|
world(&root, "Beta");
|
||||||
|
let saves = saves_dir(&root);
|
||||||
|
let parent_before = mtime(&saves);
|
||||||
|
let beta_before = mtime(&saves.join("Beta"));
|
||||||
|
let beta_tree = fingerprint(&saves.join("Beta"));
|
||||||
|
// mtime 的粒度可能是一秒(FAT / 一些网络盘),睡一下再删,判据才有分辨力。
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
|
||||||
|
delete_world(&root, "Alpha").expect("删世界");
|
||||||
|
assert!(!saves.join("Alpha").exists(), "世界目录还在");
|
||||||
|
assert_eq!(
|
||||||
|
fingerprint(&saves.join("Beta")),
|
||||||
|
beta_tree,
|
||||||
|
"删除动了别的世界"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
mtime(&saves.join("Beta")),
|
||||||
|
beta_before,
|
||||||
|
"别的世界的 mtime 变了"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
mtime(&saves) >= parent_before,
|
||||||
|
"saves/ 的 mtime 应该跟着变({} → {})",
|
||||||
|
parent_before,
|
||||||
|
mtime(&saves)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 不存在的世界 / 不是目录 / 没有 `saves/` 各给具名类别。
|
||||||
|
#[test]
|
||||||
|
fn missing_targets_are_named() {
|
||||||
|
let root = run_dir("m247-missing");
|
||||||
|
world(&root, "Alpha");
|
||||||
|
std::fs::write(saves_dir(&root).join("loose.txt"), b"x").expect("写散落文件");
|
||||||
|
|
||||||
|
let error = delete_world(&root, "Nope").expect_err("不存在的世界居然删成功了");
|
||||||
|
assert_eq!(error.kind, SavesWriteKind::NotFound);
|
||||||
|
let error = delete_world(&root, "loose.txt").expect_err("文件居然被当成世界删了");
|
||||||
|
assert_eq!(error.kind, SavesWriteKind::NotADirectory);
|
||||||
|
let error = backup_world(&root, "Nope", &root.join("exports2")).expect_err("居然备份成功");
|
||||||
|
assert_eq!(error.kind, SavesWriteKind::NotFound);
|
||||||
|
assert!(
|
||||||
|
saves_dir(&root).join("loose.txt").exists(),
|
||||||
|
"散落文件被动了"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
saves_dir(&root).file_name().unwrap().to_string_lossy(),
|
||||||
|
SAVES_DIR,
|
||||||
|
"路径拼接口径"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// mtime(毫秒;取不到给 0)。
|
||||||
|
fn mtime(path: &Path) -> u128 {
|
||||||
|
std::fs::metadata(path)
|
||||||
|
.and_then(|meta| meta.modified())
|
||||||
|
.ok()
|
||||||
|
.and_then(|time| time.duration_since(SystemTime::UNIX_EPOCH).ok())
|
||||||
|
.map_or(0, |since| since.as_millis())
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
//! 备份(zip 导出)那几条判据——从 `write/tests.rs` 拆出:那边加完 11 条用例之后越过 400 行硬线
|
||||||
|
//! (拆法与仓库其它 `-p1/-p2` / `tail*` 一致:**纯搬移,判据一条不改**)。
|
||||||
|
//!
|
||||||
|
//! | 用例 | 抓得住 |
|
||||||
|
//! |---|---|
|
||||||
|
//! | [`backup_writes_a_timestamped_zip`] | 时戳命名、条目与内容能读回来、空目录条目也在 |
|
||||||
|
//! | [`backup_never_overwrites_an_earlier_export`] | 同一时刻两份备份 ⇒ `-2` 后缀,前一份不变 |
|
||||||
|
//! | [`backup_limits_are_checked_before_any_byte_is_written`] | 超限 ⇒ `TooLarge` 且落点目录一个文件都没有 |
|
||||||
|
//! | [`backup_refuses_a_relative_or_empty_dest`] | 空 / 相对的落点 ⇒ `Boundary`(相对路径会落到进程当前目录) |
|
||||||
|
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
|
use super::super::backup::{backup_world_at, stamp, Limits};
|
||||||
|
use super::super::*;
|
||||||
|
use super::{fingerprint, run_dir, world};
|
||||||
|
|
||||||
|
/// 2024-05-01 10:00:00 UTC(写死的时刻:时戳命名要能逐字断言)。
|
||||||
|
fn fixed_at() -> SystemTime {
|
||||||
|
SystemTime::UNIX_EPOCH + Duration::from_millis(1_714_557_600_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 时戳命名、条目与内容能读回来、目录条目也在。
|
||||||
|
#[test]
|
||||||
|
fn backup_writes_a_timestamped_zip() {
|
||||||
|
let root = run_dir("m247-backup");
|
||||||
|
let dir = world(&root, "Alpha");
|
||||||
|
std::fs::create_dir_all(dir.join("empty-dir")).expect("建空目录");
|
||||||
|
let exports = root.join("exports");
|
||||||
|
|
||||||
|
let backup =
|
||||||
|
backup_world_at(&root, "Alpha", &exports, fixed_at(), Limits::production()).expect("备份");
|
||||||
|
assert_eq!(
|
||||||
|
backup.path.file_name().unwrap().to_string_lossy(),
|
||||||
|
"Alpha-20240501-100000.zip",
|
||||||
|
"落点名要带 UTC 时戳"
|
||||||
|
);
|
||||||
|
assert_eq!(backup.skipped, 0);
|
||||||
|
assert!(backup.bytes > 0);
|
||||||
|
assert_eq!(backup.sourced, 8, "level.dat + region 共 8 字节");
|
||||||
|
assert_eq!(
|
||||||
|
backup.entries, 4,
|
||||||
|
"region/ 与 empty-dir/ 两个目录条目 + 两个文件条目"
|
||||||
|
);
|
||||||
|
|
||||||
|
let file = std::fs::File::open(&backup.path).expect("开备份");
|
||||||
|
let mut archive = zip::ZipArchive::new(file).expect("读 zip");
|
||||||
|
let names: Vec<String> = (0..archive.len())
|
||||||
|
.map(|index| {
|
||||||
|
archive
|
||||||
|
.by_index(index)
|
||||||
|
.expect("读条目")
|
||||||
|
.name()
|
||||||
|
.expect("条目名")
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert!(names.contains(&"level.dat".to_string()), "条目:{names:?}");
|
||||||
|
assert!(
|
||||||
|
names.contains(&"region/r.0.0.mca".to_string()),
|
||||||
|
"条目:{names:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
names.contains(&"empty-dir/".to_string()),
|
||||||
|
"空目录丢了:{names:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut body = Vec::new();
|
||||||
|
archive
|
||||||
|
.by_name("level.dat")
|
||||||
|
.expect("取 level.dat")
|
||||||
|
.read_to_end(&mut body)
|
||||||
|
.expect("读 level.dat");
|
||||||
|
assert_eq!(body, b"nbt", "包里的内容与盘上不一致");
|
||||||
|
|
||||||
|
// 时戳换算本身(同一张固定时刻表)。
|
||||||
|
assert_eq!(stamp(fixed_at()), "20240501-100000");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同一时刻两份备份 ⇒ `-2` 后缀,前一份内容不变。
|
||||||
|
#[test]
|
||||||
|
fn backup_never_overwrites_an_earlier_export() {
|
||||||
|
let root = run_dir("m247-backup-twice");
|
||||||
|
world(&root, "Alpha");
|
||||||
|
let exports = root.join("exports");
|
||||||
|
let first = backup_world_at(&root, "Alpha", &exports, fixed_at(), Limits::production())
|
||||||
|
.expect("第一次");
|
||||||
|
let body = std::fs::read(&first.path).expect("读第一份");
|
||||||
|
let second = backup_world_at(&root, "Alpha", &exports, fixed_at(), Limits::production())
|
||||||
|
.expect("第二次");
|
||||||
|
|
||||||
|
assert_ne!(first.path, second.path, "第二份覆盖了第一份");
|
||||||
|
assert_eq!(
|
||||||
|
second.path.file_name().unwrap().to_string_lossy(),
|
||||||
|
"Alpha-20240501-100000-2.zip"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(&first.path).expect("读第一份"),
|
||||||
|
body,
|
||||||
|
"第一份被改了"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 超限 ⇒ `TooLarge` 且落点目录**一个文件都没有**(闸在第一个字节之前)。
|
||||||
|
#[test]
|
||||||
|
fn backup_limits_are_checked_before_any_byte_is_written() {
|
||||||
|
let root = run_dir("m247-backup-limit");
|
||||||
|
world(&root, "Alpha");
|
||||||
|
let exports = root.join("exports");
|
||||||
|
|
||||||
|
// 总量闸:世界有 8 字节,上限给 4。
|
||||||
|
let error = backup_world_at(&root, "Alpha", &exports, fixed_at(), Limits::new(4, 100))
|
||||||
|
.expect_err("超限居然备份成功了");
|
||||||
|
assert_eq!(error.kind, SavesWriteKind::TooLarge, "{}", error.detail);
|
||||||
|
assert!(!exports.exists(), "超限时连落点目录都不该建");
|
||||||
|
|
||||||
|
// 条目闸:4 个条目 + 上限给 2。
|
||||||
|
let error = backup_world_at(
|
||||||
|
&root,
|
||||||
|
"Alpha",
|
||||||
|
&exports,
|
||||||
|
fixed_at(),
|
||||||
|
Limits::new(1 << 30, 2),
|
||||||
|
)
|
||||||
|
.expect_err("条目超限居然备份成功了");
|
||||||
|
assert_eq!(error.kind, SavesWriteKind::TooLarge);
|
||||||
|
assert!(
|
||||||
|
!exports.exists()
|
||||||
|
|| std::fs::read_dir(&exports)
|
||||||
|
.expect("读落点")
|
||||||
|
.next()
|
||||||
|
.is_none(),
|
||||||
|
"超限时写出了文件"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 落点目录必须是绝对路径:相对路径会落到进程当前目录(一次实测里 `dest = ""` 把 zip
|
||||||
|
/// 写进了 crate 根),所以「空 / 相对」直接拒。
|
||||||
|
#[test]
|
||||||
|
fn backup_refuses_a_relative_or_empty_dest() {
|
||||||
|
let root = run_dir("m247-backup-dest");
|
||||||
|
world(&root, "Alpha");
|
||||||
|
let before = fingerprint(&root);
|
||||||
|
for dest in [
|
||||||
|
PathBuf::new(),
|
||||||
|
PathBuf::from("exports"),
|
||||||
|
PathBuf::from("./exports"),
|
||||||
|
] {
|
||||||
|
let error = backup_world(&root, "Alpha", &dest).expect_err("相对落点居然备份成功了");
|
||||||
|
assert_eq!(error.kind, SavesWriteKind::Boundary, "{}", error.detail);
|
||||||
|
}
|
||||||
|
assert_eq!(fingerprint(&root), before, "被拒的备份动了磁盘");
|
||||||
|
}
|
||||||
@@ -106,7 +106,6 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
|
|||||||
("InstancesModsReasonCollision", "a file with that name already exists", "同名文件已存在"),
|
("InstancesModsReasonCollision", "a file with that name already exists", "同名文件已存在"),
|
||||||
("InstancesModsReasonRename", "the rename failed", "改名失败"),
|
("InstancesModsReasonRename", "the rename failed", "改名失败"),
|
||||||
("InstancesSavesTitle", "SAVES", "存档"),
|
("InstancesSavesTitle", "SAVES", "存档"),
|
||||||
("InstancesSavesReadOnly", "Read-only in this version: nothing here is moved or deleted.", "这一版只读:这里不移动、也不删除任何东西。"),
|
|
||||||
("InstancesSavesCount", "{worlds} worlds · {shots} screenshots", "{worlds} 个世界 · {shots} 张截图"),
|
("InstancesSavesCount", "{worlds} worlds · {shots} screenshots", "{worlds} 个世界 · {shots} 张截图"),
|
||||||
("InstancesSavesLoading", "Reading saves/…", "正在读取 saves/…"),
|
("InstancesSavesLoading", "Reading saves/…", "正在读取 saves/…"),
|
||||||
("InstancesSavesEmpty", "No worlds or screenshots yet — play once and they show up here.", "还没有世界或截图——玩一次就会出现在这里。"),
|
("InstancesSavesEmpty", "No worlds or screenshots yet — play once and they show up here.", "还没有世界或截图——玩一次就会出现在这里。"),
|
||||||
@@ -125,6 +124,31 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
|
|||||||
("InstancesSavesReasonDir", "the directory is not usable", "目录不可用"),
|
("InstancesSavesReasonDir", "the directory is not usable", "目录不可用"),
|
||||||
("InstancesSavesReasonInstance", "the instance is gone", "实例已不存在"),
|
("InstancesSavesReasonInstance", "the instance is gone", "实例已不存在"),
|
||||||
("InstancesSavesReasonOpen", "the file manager did not start", "文件管理器没起来"),
|
("InstancesSavesReasonOpen", "the file manager did not start", "文件管理器没起来"),
|
||||||
|
("InstancesSavesRename", "Rename", "重命名"),
|
||||||
|
("InstancesSavesBackup", "Back up", "备份"),
|
||||||
|
("InstancesSavesDelete", "Delete", "删除"),
|
||||||
|
("InstancesSavesRenamePlaceholder", "New name for this world", "这个世界的新名字"),
|
||||||
|
("InstancesSavesRenameConfirm", "Rename", "改名"),
|
||||||
|
("InstancesSavesCancel", "Cancel", "取消"),
|
||||||
|
("InstancesSavesBusy", "Working…", "正在处理…"),
|
||||||
|
("InstancesSavesDeleteTitle", "Delete this world?", "删除这个世界?"),
|
||||||
|
("InstancesSavesDeleteBody", "{dir} and everything inside it will be deleted. This cannot be undone.", "{dir} 和它里面的全部内容都会被删除,且不能撤销。"),
|
||||||
|
("InstancesSavesDeleteConfirm", "Delete world", "删除世界"),
|
||||||
|
("InstancesSavesRenamed", "Renamed {from} → {to}.", "已把 {from} 改名为 {to}。"),
|
||||||
|
("InstancesSavesDeleted", "Deleted {dir}.", "已删除 {dir}。"),
|
||||||
|
("InstancesSavesBackedUp", "Backed up {dir} to {path} ({size}).", "已把 {dir} 备份到 {path}({size})。"),
|
||||||
|
("InstancesSavesBackupSkipped", "{count} symlinks skipped", "跳过 {count} 个符号链接"),
|
||||||
|
("InstancesSavesRenameFailed", "Could not rename {file}: {reason}", "重命名 {file} 失败:{reason}"),
|
||||||
|
("InstancesSavesDeleteFailed", "Could not delete {file}: {reason}", "删除 {file} 失败:{reason}"),
|
||||||
|
("InstancesSavesBackupFailed", "Could not back up {file}: {reason}", "备份 {file} 失败:{reason}"),
|
||||||
|
("InstancesSavesReasonUnsafeName", "the name is not usable", "名字不可用"),
|
||||||
|
("InstancesSavesReasonSymlink", "that path is a symbolic link (not followed)", "那个路径是符号链接(不跟随)"),
|
||||||
|
("InstancesSavesReasonMissing", "the world is gone", "那个世界已不在"),
|
||||||
|
("InstancesSavesReasonNotDir", "that is not a directory", "那不是目录"),
|
||||||
|
("InstancesSavesReasonConflict", "a world with that name already exists", "同名世界已存在"),
|
||||||
|
("InstancesSavesReasonBoundary", "that path is outside saves/", "那个路径落在 saves/ 之外"),
|
||||||
|
("InstancesSavesReasonTooLarge", "the world is too large for one zip", "这个世界太大,一个 zip 装不下"),
|
||||||
|
("InstancesSavesReasonWrite", "the write failed", "写盘失败"),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。
|
/// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。
|
||||||
|
|||||||
@@ -159,7 +159,6 @@ macro_rules! instances_entries {
|
|||||||
// 「这一版只读」,不会以为漏了删除按钮。`saves/` / `screenshots/` 是**目录名**,
|
// 「这一版只读」,不会以为漏了删除按钮。`saves/` / `screenshots/` 是**目录名**,
|
||||||
// 两套语言都原样保留(与 MODS 区的 `mods` 同一条口径)。
|
// 两套语言都原样保留(与 MODS 区的 `mods` 同一条口径)。
|
||||||
InstancesSavesTitle => ("SAVES", "存档"),
|
InstancesSavesTitle => ("SAVES", "存档"),
|
||||||
InstancesSavesReadOnly => ("Read-only in this version: nothing here is moved or deleted.", "这一版只读:这里不移动、也不删除任何东西。"),
|
|
||||||
InstancesSavesCount => ("{worlds} worlds · {shots} screenshots", "{worlds} 个世界 · {shots} 张截图"),
|
InstancesSavesCount => ("{worlds} worlds · {shots} screenshots", "{worlds} 个世界 · {shots} 张截图"),
|
||||||
InstancesSavesLoading => ("Reading saves/…", "正在读取 saves/…"),
|
InstancesSavesLoading => ("Reading saves/…", "正在读取 saves/…"),
|
||||||
InstancesSavesEmpty => ("No worlds or screenshots yet — play once and they show up here.", "还没有世界或截图——玩一次就会出现在这里。"),
|
InstancesSavesEmpty => ("No worlds or screenshots yet — play once and they show up here.", "还没有世界或截图——玩一次就会出现在这里。"),
|
||||||
@@ -178,6 +177,34 @@ macro_rules! instances_entries {
|
|||||||
InstancesSavesReasonDir => ("the directory is not usable", "目录不可用"),
|
InstancesSavesReasonDir => ("the directory is not usable", "目录不可用"),
|
||||||
InstancesSavesReasonInstance => ("the instance is gone", "实例已不存在"),
|
InstancesSavesReasonInstance => ("the instance is gone", "实例已不存在"),
|
||||||
InstancesSavesReasonOpen => ("the file manager did not start", "文件管理器没起来"),
|
InstancesSavesReasonOpen => ("the file manager did not start", "文件管理器没起来"),
|
||||||
|
// ── 存档**写操作**(M247 · SAVESOPS):重命名 / 删除(二次确认)/ 备份导出。
|
||||||
|
// M235 那条「这一版只读」的 `InstancesSavesReadOnly` **已删**:写操作接上之后它就是假的
|
||||||
|
// (金表同步少了那一行,不是重排)。删除确认框的三句是**不可逆动作**的兜底,不许省。
|
||||||
|
InstancesSavesRename => ("Rename", "重命名"),
|
||||||
|
InstancesSavesBackup => ("Back up", "备份"),
|
||||||
|
InstancesSavesDelete => ("Delete", "删除"),
|
||||||
|
InstancesSavesRenamePlaceholder => ("New name for this world", "这个世界的新名字"),
|
||||||
|
InstancesSavesRenameConfirm => ("Rename", "改名"),
|
||||||
|
InstancesSavesCancel => ("Cancel", "取消"),
|
||||||
|
InstancesSavesBusy => ("Working…", "正在处理…"),
|
||||||
|
InstancesSavesDeleteTitle => ("Delete this world?", "删除这个世界?"),
|
||||||
|
InstancesSavesDeleteBody => ("{dir} and everything inside it will be deleted. This cannot be undone.", "{dir} 和它里面的全部内容都会被删除,且不能撤销。"),
|
||||||
|
InstancesSavesDeleteConfirm => ("Delete world", "删除世界"),
|
||||||
|
InstancesSavesRenamed => ("Renamed {from} → {to}.", "已把 {from} 改名为 {to}。"),
|
||||||
|
InstancesSavesDeleted => ("Deleted {dir}.", "已删除 {dir}。"),
|
||||||
|
InstancesSavesBackedUp => ("Backed up {dir} to {path} ({size}).", "已把 {dir} 备份到 {path}({size})。"),
|
||||||
|
InstancesSavesBackupSkipped => ("{count} symlinks skipped", "跳过 {count} 个符号链接"),
|
||||||
|
InstancesSavesRenameFailed => ("Could not rename {file}: {reason}", "重命名 {file} 失败:{reason}"),
|
||||||
|
InstancesSavesDeleteFailed => ("Could not delete {file}: {reason}", "删除 {file} 失败:{reason}"),
|
||||||
|
InstancesSavesBackupFailed => ("Could not back up {file}: {reason}", "备份 {file} 失败:{reason}"),
|
||||||
|
InstancesSavesReasonUnsafeName => ("the name is not usable", "名字不可用"),
|
||||||
|
InstancesSavesReasonSymlink => ("that path is a symbolic link (not followed)", "那个路径是符号链接(不跟随)"),
|
||||||
|
InstancesSavesReasonMissing => ("the world is gone", "那个世界已不在"),
|
||||||
|
InstancesSavesReasonNotDir => ("that is not a directory", "那不是目录"),
|
||||||
|
InstancesSavesReasonConflict => ("a world with that name already exists", "同名世界已存在"),
|
||||||
|
InstancesSavesReasonBoundary => ("that path is outside saves/", "那个路径落在 saves/ 之外"),
|
||||||
|
InstancesSavesReasonTooLarge => ("the world is too large for one zip", "这个世界太大,一个 zip 装不下"),
|
||||||
|
InstancesSavesReasonWrite => ("the write failed", "写盘失败"),
|
||||||
} }
|
} }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ pub use settings::{
|
|||||||
};
|
};
|
||||||
// M235: 实例存档 / 截图的只读列表
|
// M235: 实例存档 / 截图的只读列表
|
||||||
pub use saves::{
|
pub use saves::{
|
||||||
SavesArea, SavesCommand, SavesFailureKind, SavesFailureView, SavesListView, SavesSkipKind,
|
SavesArea, SavesCommand, SavesFailureKind, SavesFailureOp, SavesFailureView, SavesListView,
|
||||||
SavesSkipView, ScreenshotView, WorldView,
|
SavesOped, SavesSkipKind, SavesSkipView, ScreenshotView, WorldView,
|
||||||
};
|
};
|
||||||
// M159: 自更新
|
// M159: 自更新
|
||||||
pub use update::{
|
pub use update::{
|
||||||
|
|||||||
@@ -1,26 +1,33 @@
|
|||||||
//! 实例**存档 / 截图的只读快照**与「打开文件夹」的 IPC 面(M235 · STOREMVP)。
|
//! 实例**存档 / 截图的快照**与写操作的 IPC 面(M235 · STOREMVP,M247 · SAVESOPS 加写路径)。
|
||||||
//!
|
//!
|
||||||
//! # 三层里的第二层
|
//! # 三层里的第二层
|
||||||
//!
|
//!
|
||||||
//! 塔裁决 U3 把这一版钉成「只读列表 + 打开文件夹」,不做缩略图墙。三层是:
|
//! 这一版是「只读列表 + 打开文件夹」**加上**三个写操作:`core::game::saves`(读盘/写盘,唯一的
|
||||||
//! `core::game::saves`(读盘,唯一的真相)→ 本模块(IPC 形状)→
|
//! 真相)→ 本模块(IPC 形状)→ `crates/app-iced/src/pages/instances/saves.rs`(画)。
|
||||||
//! `crates/app-iced/src/pages/instances/saves.rs`(画)。
|
|
||||||
//!
|
//!
|
||||||
//! | 方向 | 类型 |
|
//! | 方向 | 类型 |
|
||||||
//! |---|---|
|
//! |---|---|
|
||||||
//! | app → host | [`SavesCommand`](列目录 / 打开文件夹) |
|
//! | app → host | [`SavesCommand`](列目录 / 打开文件夹 / 重命名 / 删除 / 备份) |
|
||||||
//! | host → app | [`SavesListView`](整份快照)或 [`SavesFailureView`](具名原因) |
|
//! | host → app | [`SavesListView`](整份快照 + 上一次写操作的[回执](SavesOped))或 [`SavesFailureView`] |
|
||||||
|
//!
|
||||||
|
//! # 写操作为什么走同一条回程
|
||||||
|
//!
|
||||||
|
//! 三个写操作的结果都是**一份新快照**(`Ok`)+ 一个[回执](SavesOped):写完之后磁盘变了,
|
||||||
|
//! 界面必须跟着变;而回执是「这次动的是哪一条、动成了什么」。失败则走同一个事件的 `Err` 一支,
|
||||||
|
//! 带上一个**具名类别**(重命名撞名 / 删除越界 / 备份超限……),见 [`SavesFailureKind`]。
|
||||||
|
//! 两条都不静默:成功有回执、失败有类别 + OS 原文。
|
||||||
//!
|
//!
|
||||||
//! # 为什么另开一族,不并进 `ModListView`
|
//! # 为什么另开一族,不并进 `ModListView`
|
||||||
//!
|
//!
|
||||||
//! 两者形状相近(都是"列一个实例的某个目录"),但语义不同:mod 有启停(会写盘、要看游戏在不在跑),
|
//! 两者形状相近(都是"列一个实例的某个目录"),但语义不同:mod 有启停(看游戏在不在跑),
|
||||||
//! 存档这一版**没有任何写路径**。合并会让 mod 那边的 `pending` / `running` 字段在存档页变成
|
//! 存档这边有三个写操作(重命名 / 删除 / 备份)。合并会让两边的 `pending` 字段互相变成
|
||||||
//! 永远为假的分支(M224 拒绝给内容页加第五个 `ContentKind` 是同一条理由:别让穷尽 match 为不相干
|
//! 永远为假的分支(M224 拒绝给内容页加第五个 `ContentKind` 是同一条理由:别让穷尽 match 为
|
||||||
//! 的字段服务)。
|
//! 不相干的字段服务)。
|
||||||
//!
|
//!
|
||||||
//! # 文案纪律(M161)
|
//! # 文案纪律(M161)
|
||||||
//!
|
//!
|
||||||
//! 本模块**不带人读散文**:失败给的是结构([`SavesFailureKind`] + 底层 `detail` 串),
|
//! 本模块**不带人读散文**:失败给的是结构([`SavesFailureKind`] + 底层 `detail` 串)、
|
||||||
|
//! 回执给的是事实(谁改成什么、备份落到哪个路径、几个字节、跳过几个链接),
|
||||||
//! 时间戳给的是**毫秒数**(UTC 毫秒,UI 自己格式化)。文案一律由 app 层按 i18n key 现求。
|
//! 时间戳给的是**毫秒数**(UTC 毫秒,UI 自己格式化)。文案一律由 app 层按 i18n key 现求。
|
||||||
//!
|
//!
|
||||||
//! # 时间戳为什么是 `Option<u64>` 的毫秒
|
//! # 时间戳为什么是 `Option<u64>` 的毫秒
|
||||||
@@ -45,6 +52,76 @@ pub enum SavesCommand {
|
|||||||
/// 实例 id
|
/// 实例 id
|
||||||
id: InstanceId,
|
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>,
|
pub screenshots: Vec<ScreenshotView>,
|
||||||
/// 跳过的条目(UI 只需计数 + 一句"为什么")。
|
/// 跳过的条目(UI 只需计数 + 一句"为什么")。
|
||||||
pub skipped: Vec<SavesSkipView>,
|
pub skipped: Vec<SavesSkipView>,
|
||||||
|
/// 最近一次写操作的回执(`None` = 这一拍只是列举,没写东西)。
|
||||||
|
pub last_op: Option<SavesOped>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SavesListView {
|
impl SavesListView {
|
||||||
@@ -158,13 +237,31 @@ impl SavesListView {
|
|||||||
pub struct SavesFailureView {
|
pub struct SavesFailureView {
|
||||||
/// 实例 id(迟到/串台的结果靠它判)。
|
/// 实例 id(迟到/串台的结果靠它判)。
|
||||||
pub instance: InstanceId,
|
pub instance: InstanceId,
|
||||||
|
/// 涉及的那个世界(写操作才有;读路径给空串)。
|
||||||
|
///
|
||||||
|
/// 为什么要这一格:失败那句话是「重命名 **A** 失败:同名世界已存在」——没有名字,
|
||||||
|
/// 用户不知道该改哪一行(与 `ModFailureView::file` 同一条理由)。
|
||||||
|
pub file: String,
|
||||||
|
/// 这是哪一类**写**操作(`None` = 读路径 / 打开文件夹的失败)。
|
||||||
|
pub op: Option<SavesFailureOp>,
|
||||||
/// 失败大类。
|
/// 失败大类。
|
||||||
pub kind: SavesFailureKind,
|
pub kind: SavesFailureKind,
|
||||||
/// 底层原文(OS 错误 / 路径细节);只进括号,不进主句。
|
/// 底层原文(OS 错误 / 路径细节);只进括号,不进主句。
|
||||||
pub detail: String,
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum SavesFailureKind {
|
pub enum SavesFailureKind {
|
||||||
/// 实例不在了(快照里找不到),或隔离档位派生不出运行目录。
|
/// 实例不在了(快照里找不到),或隔离档位派生不出运行目录。
|
||||||
@@ -173,82 +270,23 @@ pub enum SavesFailureKind {
|
|||||||
DirUnusable,
|
DirUnusable,
|
||||||
/// 「打开文件夹」失败(目录不存在 / 拉不起文件管理器)。
|
/// 「打开文件夹」失败(目录不存在 / 拉不起文件管理器)。
|
||||||
OpenFailed,
|
OpenFailed,
|
||||||
|
/// 写操作:名字过不了规则(空、`.`、`..`、含 `/` `\` `:` NUL)。
|
||||||
|
WriteUnsafeName,
|
||||||
|
/// 写操作:目标或路径上的一段是符号链接(不跟随)。
|
||||||
|
WriteSymlink,
|
||||||
|
/// 写操作:那个世界(或 `saves/`)已经不在了。
|
||||||
|
WriteMissing,
|
||||||
|
/// 写操作:那个名字底下不是目录。
|
||||||
|
WriteNotADirectory,
|
||||||
|
/// 写操作:同名世界已存在 / 备份文件名撞满。
|
||||||
|
WriteConflict,
|
||||||
|
/// 写操作:路径落在 `saves/` 之外(含层级异常深)。
|
||||||
|
WriteBoundary,
|
||||||
|
/// 写操作:备份超过体积或条目上限。
|
||||||
|
WriteTooLarge,
|
||||||
|
/// 写操作:其它系统错误(权限 / 被别的进程占用),OS 原文进 `detail`。
|
||||||
|
WriteFailed,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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`——**与启动同一个答案**,不另派生、不做双根合并 |
|
//! | 单根语义(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()`
|
//! ## 为什么不复用启动路径的 `run_dir()`
|
||||||
//!
|
//!
|
||||||
@@ -22,21 +34,30 @@
|
|||||||
//! 真正拉起文件管理器的是 [`unlucky_core::platform::opener::open_dir`](`AGENTS.md` §6:
|
//! 真正拉起文件管理器的是 [`unlucky_core::platform::opener::open_dir`](`AGENTS.md` §6:
|
||||||
//! 平台差异只许收敛在 `core::platform`,本文件一个 `cfg` 都没有,也没有一次裸 spawn)。
|
//! 平台差异只许收敛在 `core::platform`,本文件一个 `cfg` 都没有,也没有一次裸 spawn)。
|
||||||
//!
|
//!
|
||||||
|
//! ## 备份落点为什么是启动器自己的 `exports/`
|
||||||
|
//!
|
||||||
|
//! 本仓没有 `rfd` 这类原生文件选择框依赖(`core::launch::crash_bundle::default_target_dir` 的
|
||||||
|
//! 文档里记着同一条理由),所以「用户选定目录」先退化成:`<data>/exports/saves/`。
|
||||||
|
//! 命令带一个可选的 `dest`,将来接了选择框、或 CLI 想指定别处,都不用改 core。
|
||||||
|
//! 成功的回执里带**完整路径 + 字节数**,用户不用猜落点。
|
||||||
|
//!
|
||||||
//! ## 失败口径
|
//! ## 失败口径
|
||||||
//!
|
//!
|
||||||
//! 一律走 [`crate::dto::SavesFailureView`](结构 + OS 原文),**不拼中文**:文案由 app 层按
|
//! 一律走 [`crate::dto::SavesFailureView`](结构 + OS 原文),**不拼中文**:文案由 app 层按
|
||||||
//! i18n key 现求(M161 的纪律)。三类失败见 [`crate::dto::SavesFailureKind`]。
|
//! i18n key 现求(M161 的纪律)。读路径三类失败见 [`crate::dto::SavesFailureKind`],
|
||||||
|
//! 写路径八类见同一个枚举的 `Write*` 那一族——每一种在 UI 上都有一句话。
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use unlucky_core::game::install::instance::read_instance_config;
|
use unlucky_core::game::install::instance::read_instance_config;
|
||||||
use unlucky_core::game::isolation::derive_game_dir;
|
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 unlucky_core::platform::opener;
|
||||||
|
|
||||||
use crate::dto::{
|
use crate::dto::{
|
||||||
locate, InstanceId, InstanceSummary, SavesArea, SavesCommand, SavesFailureKind,
|
locate, InstanceId, InstanceSummary, SavesArea, SavesCommand, SavesFailureKind, SavesFailureOp,
|
||||||
SavesFailureView, SavesListView, SavesSkipKind, SavesSkipView, ScreenshotView, WorldView,
|
SavesFailureView, SavesListView, SavesOped, SavesSkipKind, SavesSkipView, ScreenshotView,
|
||||||
|
WorldView,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::env::HostEnv;
|
use super::env::HostEnv;
|
||||||
@@ -81,14 +102,135 @@ pub fn dispatch(env: HostEnv, tx: JobSender, command: SavesCommand) {
|
|||||||
match command {
|
match command {
|
||||||
SavesCommand::List(id) => spawn_list(env, tx, id),
|
SavesCommand::List(id) => spawn_list(env, tx, id),
|
||||||
SavesCommand::OpenFolder { id } => spawn_open_folder(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> {
|
fn list(env: &HostEnv, id: &str) -> Result<SavesListView, SavesFailureView> {
|
||||||
let (instance, run_dir) = target(env, id)?;
|
let (instance, run_dir) = target(env, id)?;
|
||||||
let scan = saves::scan(&run_dir).map_err(|error| unusable(&instance.id, &error))?;
|
snapshot(&instance, &run_dir, None)
|
||||||
Ok(view(&instance, &scan))
|
}
|
||||||
|
|
||||||
|
/// 重新扫一遍目录 → 快照(可挂一条写操作的回执)。
|
||||||
|
///
|
||||||
|
/// 写成功后**一律**走这里:界面画的是磁盘上的真相,不是"我以为我改成了什么"。
|
||||||
|
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);
|
let target = open_target(&scan, &run_dir);
|
||||||
opener::open_dir(&target)
|
opener::open_dir(&target)
|
||||||
.map_err(|detail| failure(&instance.id, SavesFailureKind::OpenFailed, detail))?;
|
.map_err(|detail| failure(&instance.id, SavesFailureKind::OpenFailed, detail))?;
|
||||||
Ok(view(&instance, &scan))
|
Ok(view(&instance.id, &scan, None))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 打开哪一个目录:`saves/` → `screenshots/` → 运行目录(取第一个存在的)。
|
/// 打开哪一个目录:`saves/` → `screenshots/` → 运行目录(取第一个存在的)。
|
||||||
@@ -144,9 +286,9 @@ fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 快照映射:core 的扫描结果 → DTO(路径串在 host 这一侧才成形)。
|
/// 快照映射:core 的扫描结果 → DTO(路径串在 host 这一侧才成形)。
|
||||||
fn view(instance: &InstanceSummary, scan: &SavesScan) -> SavesListView {
|
fn view(instance: &str, scan: &SavesScan, last_op: Option<SavesOped>) -> SavesListView {
|
||||||
SavesListView {
|
SavesListView {
|
||||||
instance: instance.id.clone(),
|
instance: instance.to_string(),
|
||||||
dir: scan.dir.display().to_string(),
|
dir: scan.dir.display().to_string(),
|
||||||
exists: scan.exists,
|
exists: scan.exists,
|
||||||
worlds: scan
|
worlds: scan
|
||||||
@@ -188,12 +330,15 @@ fn view(instance: &InstanceSummary, scan: &SavesScan) -> SavesListView {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
|
last_op,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn failure(instance: &str, kind: SavesFailureKind, detail: impl Into<String>) -> SavesFailureView {
|
fn failure(instance: &str, kind: SavesFailureKind, detail: impl Into<String>) -> SavesFailureView {
|
||||||
SavesFailureView {
|
SavesFailureView {
|
||||||
instance: instance.to_string(),
|
instance: instance.to_string(),
|
||||||
|
file: String::new(),
|
||||||
|
op: None,
|
||||||
kind,
|
kind,
|
||||||
detail: detail.into(),
|
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)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
@@ -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` 被当成"读到了"、跳过原因被折叠、字节数丢了。
|
/// 抓得住:派生用错目录、`icon.png` 被当成"读到了"、跳过原因被折叠、字节数丢了。
|
||||||
@@ -194,6 +200,179 @@ fn every_core_skip_reason_keeps_its_own_kind() {
|
|||||||
reason,
|
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ mod tiles;
|
|||||||
|
|
||||||
pub use live::MAX_LIVE_LINES;
|
pub use live::MAX_LIVE_LINES;
|
||||||
pub use mods::ModsState;
|
pub use mods::ModsState;
|
||||||
pub use saves::SavesState;
|
pub use saves::{RenameDraft, SavesOp, SavesState};
|
||||||
pub use tiles::{grid_positions, TileCell};
|
pub use tiles::{grid_positions, TileCell};
|
||||||
|
|
||||||
/// 一份加载器版本清单查询的结果(`Ok` = 最新在前;`Err` = 人读原因)。
|
/// 一份加载器版本清单查询的结果(`Ok` = 最新在前;`Err` = 人读原因)。
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
//! 实例存档 / 截图快照的落地(M235 · STOREMVP)。
|
//! 实例存档 / 截图快照的落地(M235 · STOREMVP)+ 写操作的三样交互态(M247 · SAVESOPS)。
|
||||||
//!
|
//!
|
||||||
//! 从 `state.rs` 拆出(那个文件贴着 400 行硬线),形状与 [`super::mods`] 一致:
|
//! 从 `state.rs` 拆出(那个文件贴着 400 行硬线),形状与 [`super::mods`] 一致:
|
||||||
//! **另一个 `impl AppState` 块**,路径与可见性都不变,`apply_core` 只有一行投递。
|
//! **另一个 `impl AppState` 块**,路径与可见性都不变,`apply_core` 只有一行投递。
|
||||||
//!
|
//!
|
||||||
|
//! # 写操作的三样交互态(M247)
|
||||||
|
//!
|
||||||
|
//! | 字段 | 是什么 | 为什么必须住在这里 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | [`SavesState::draft`] | 行内重命名的输入缓冲 | 页面模型每帧从快照重建(`from_state`),打字过程中的内容不在快照里 ⇒ 不留在这里就会每打一个字被事件冲掉 |
|
||||||
|
//! | [`SavesState::pending`] | 哪个世界的哪个写操作在飞 | 与 [`super::mods::ModsState::pending`] **同一条先例**:只用来把那一行的按钮置灰,**不做乐观回显**(列表只跟快照走) |
|
||||||
|
//! | [`SavesState::confirm`] | 删除确认框**正在等谁** | 模态队列只画框、不记得"确认之后要删哪个世界";而点主按钮的那一刻是 `app/update/dialog.rs`,那里只能从状态里取 |
|
||||||
|
//!
|
||||||
//! # 为什么快照住在这里而不是页面
|
//! # 为什么快照住在这里而不是页面
|
||||||
//!
|
//!
|
||||||
//! 「列表画的永远是磁盘上的真相」是本模块唯一要保证的事,所以:
|
//! 「列表画的永远是磁盘上的真相」是本模块唯一要保证的事,所以:
|
||||||
@@ -22,6 +30,45 @@ use crate::dto::{SavesFailureView, SavesListView};
|
|||||||
|
|
||||||
use super::AppState;
|
use super::AppState;
|
||||||
|
|
||||||
|
/// 行内重命名的输入缓冲(见模块文档的三样交互态)。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RenameDraft {
|
||||||
|
/// 哪个世界(磁盘名,提交时原样带回去当「源」)。
|
||||||
|
pub dir: String,
|
||||||
|
/// 输入框里的内容(**原样**:合不合法由 core 判,页面只管显示,不做前端过滤)。
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次在飞的写操作(只用来置灰那一行,列表仍然只跟快照)。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum SavesOp {
|
||||||
|
/// 重命名中。
|
||||||
|
Rename {
|
||||||
|
/// 磁盘名
|
||||||
|
dir: String,
|
||||||
|
},
|
||||||
|
/// 删除中。
|
||||||
|
Delete {
|
||||||
|
/// 磁盘名
|
||||||
|
dir: String,
|
||||||
|
},
|
||||||
|
/// 备份中。
|
||||||
|
Backup {
|
||||||
|
/// 磁盘名
|
||||||
|
dir: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SavesOp {
|
||||||
|
/// 这个操作动的是哪个世界。
|
||||||
|
#[must_use]
|
||||||
|
pub fn dir(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Rename { dir } | Self::Delete { dir } | Self::Backup { dir } => dir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 存档区的视图态。
|
/// 存档区的视图态。
|
||||||
#[derive(Debug, Clone, Default, PartialEq)]
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
pub struct SavesState {
|
pub struct SavesState {
|
||||||
@@ -31,10 +78,19 @@ pub struct SavesState {
|
|||||||
pub view: Option<SavesListView>,
|
pub view: Option<SavesListView>,
|
||||||
/// 最近一条失败(具名原因;成功一次就清掉)。
|
/// 最近一条失败(具名原因;成功一次就清掉)。
|
||||||
pub failed: Option<SavesFailureView>,
|
pub failed: Option<SavesFailureView>,
|
||||||
|
/// 行内重命名的输入缓冲(`None` = 没有在改名)。
|
||||||
|
pub draft: Option<RenameDraft>,
|
||||||
|
/// 在飞的写操作(`None` = 空闲)。
|
||||||
|
pub pending: Option<SavesOp>,
|
||||||
|
/// 删除确认框正在等哪个世界(点「确定」时才真的删)。
|
||||||
|
pub confirm: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SavesState {
|
impl SavesState {
|
||||||
/// 切到另一个实例(换实例必须清空:A 的存档不能画在 B 的详情页上)。
|
/// 切到另一个实例(换实例必须清空:A 的存档不能画在 B 的详情页上)。
|
||||||
|
///
|
||||||
|
/// 三样交互态一起清:换实例之后还留着「A 的行内草稿 / A 的删除确认」是错位状态
|
||||||
|
/// (点确定会去删 B 页上的 A,或者把 A 的草稿提交成 B 的改名)。
|
||||||
pub(super) fn bind(&mut self, instance: Option<&str>) {
|
pub(super) fn bind(&mut self, instance: Option<&str>) {
|
||||||
if self.bound.as_deref() == instance {
|
if self.bound.as_deref() == instance {
|
||||||
return;
|
return;
|
||||||
@@ -42,9 +98,19 @@ impl SavesState {
|
|||||||
self.bound = instance.map(str::to_string);
|
self.bound = instance.map(str::to_string);
|
||||||
self.view = None;
|
self.view = None;
|
||||||
self.failed = None;
|
self.failed = None;
|
||||||
|
self.reset_interaction();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清掉三样交互态(换实例 / 操作回程时用)。
|
||||||
|
fn reset_interaction(&mut self) {
|
||||||
|
self.draft = None;
|
||||||
|
self.pending = None;
|
||||||
|
self.confirm = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 一条结果落地(`Ok` = 快照整体替换,`Err` = 记一条具名失败;迟到/串台的丢掉)。
|
/// 一条结果落地(`Ok` = 快照整体替换,`Err` = 记一条具名失败;迟到/串台的丢掉)。
|
||||||
|
///
|
||||||
|
/// **无论成败都清「在飞」与「确认中」**:这是一次后台往返的收尾,操作已经不在飞了。
|
||||||
pub fn apply(&mut self, result: Result<SavesListView, SavesFailureView>) {
|
pub fn apply(&mut self, result: Result<SavesListView, SavesFailureView>) {
|
||||||
match result {
|
match result {
|
||||||
Ok(view) => self.changed(view),
|
Ok(view) => self.changed(view),
|
||||||
@@ -59,6 +125,8 @@ impl SavesState {
|
|||||||
}
|
}
|
||||||
self.failed = None;
|
self.failed = None;
|
||||||
self.view = Some(view);
|
self.view = Some(view);
|
||||||
|
// 写操作成功之后草稿与在飞标记都该没了:行已经按新名字画出来了。
|
||||||
|
self.reset_interaction();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 失败落地(只记**当前绑定实例**的;old 列表保留——用户还能看见旧列表,只是多一条红字)。
|
/// 失败落地(只记**当前绑定实例**的;old 列表保留——用户还能看见旧列表,只是多一条红字)。
|
||||||
@@ -67,6 +135,61 @@ impl SavesState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.failed = Some(failure);
|
self.failed = Some(failure);
|
||||||
|
// 失败也收尾:按钮不能永远灰着(用户要能直接再点一次重试)。
|
||||||
|
self.pending = None;
|
||||||
|
self.confirm = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 开一个行内重命名(输入框预填当前磁盘名:改个后缀比从零打更常见)。
|
||||||
|
pub fn start_rename(&mut self, dir: &str) {
|
||||||
|
self.draft = Some(RenameDraft {
|
||||||
|
dir: dir.to_string(),
|
||||||
|
text: dir.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 输入框变了(**照原样存**,不做前端过滤:非法名字由 core 拒并给出具名原因)。
|
||||||
|
pub fn edit_rename(&mut self, text: String) {
|
||||||
|
if let Some(draft) = &mut self.draft {
|
||||||
|
draft.text = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取走草稿并收起来(提交时用;`None` = 当前没有草稿)。
|
||||||
|
pub fn take_rename(&mut self) -> Option<RenameDraft> {
|
||||||
|
self.draft.take()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消行内重命名。
|
||||||
|
pub fn cancel_rename(&mut self) {
|
||||||
|
self.draft = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记下「删除确认框正在等谁」。
|
||||||
|
pub fn ask_delete(&mut self, dir: &str) {
|
||||||
|
self.confirm = Some(dir.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取走确认目标(点「确定」时用;取走即清,避免同一次确认被点两次)。
|
||||||
|
pub fn take_confirm(&mut self) -> Option<String> {
|
||||||
|
self.confirm.take()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 确认框被取消/关掉:清掉目标。
|
||||||
|
pub fn clear_confirm(&mut self) {
|
||||||
|
self.confirm = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标记一次写操作在飞(草稿同时收起来:请求已经发出去了)。
|
||||||
|
pub fn mark_pending(&mut self, op: SavesOp) {
|
||||||
|
self.pending = Some(op);
|
||||||
|
self.draft = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 这个世界的写操作在飞吗(用来把那一行置灰)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_pending(&self, dir: &str) -> bool {
|
||||||
|
self.pending.as_ref().is_some_and(|op| op.dir() == dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 当前绑定实例的列表是否已经有一份快照。
|
/// 当前绑定实例的列表是否已经有一份快照。
|
||||||
@@ -102,12 +225,15 @@ mod tests {
|
|||||||
screenshots_exists: false,
|
screenshots_exists: false,
|
||||||
screenshots: Vec::new(),
|
screenshots: Vec::new(),
|
||||||
skipped: Vec::new(),
|
skipped: Vec::new(),
|
||||||
|
last_op: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn failure(instance: &str) -> SavesFailureView {
|
fn failure(instance: &str) -> SavesFailureView {
|
||||||
SavesFailureView {
|
SavesFailureView {
|
||||||
instance: instance.to_string(),
|
instance: instance.to_string(),
|
||||||
|
file: String::new(),
|
||||||
|
op: None,
|
||||||
kind: SavesFailureKind::DirUnusable,
|
kind: SavesFailureKind::DirUnusable,
|
||||||
detail: "Access is denied.".to_string(),
|
detail: "Access is denied.".to_string(),
|
||||||
}
|
}
|
||||||
@@ -152,4 +278,86 @@ mod tests {
|
|||||||
assert!(!idle.has_view());
|
assert!(!idle.has_view());
|
||||||
assert!(idle.failed.is_none());
|
assert!(idle.failed.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 换实例要把三样交互态一起清掉(草稿 / 在飞 / 确认目标)。
|
||||||
|
///
|
||||||
|
/// 抓得住:换到 B 之后「A 的草稿」还留着 ⇒ 用户点确定会去改 B 页上的一个不存在的名字。
|
||||||
|
#[test]
|
||||||
|
fn switching_instances_drops_draft_pending_and_confirm() {
|
||||||
|
let mut saves = SavesState::default();
|
||||||
|
saves.bind(Some("a"));
|
||||||
|
saves.changed(view("a"));
|
||||||
|
saves.start_rename("World");
|
||||||
|
saves.edit_rename("Renamed".to_string());
|
||||||
|
saves.ask_delete("World");
|
||||||
|
saves.mark_pending(SavesOp::Backup {
|
||||||
|
dir: "World".to_string(),
|
||||||
|
});
|
||||||
|
assert!(saves.draft.is_none(), "发请求时草稿该收起来");
|
||||||
|
assert!(saves.pending.is_some());
|
||||||
|
|
||||||
|
saves.bind(Some("b"));
|
||||||
|
assert!(saves.draft.is_none(), "换实例后草稿还在");
|
||||||
|
assert!(saves.pending.is_none(), "换实例后在飞标记还在");
|
||||||
|
assert!(saves.confirm.is_none(), "换实例后删除确认目标还在");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 回程(成功或失败)都要把「在飞」收掉:按钮不能永远灰着。
|
||||||
|
#[test]
|
||||||
|
fn both_outcomes_clear_the_pending_marker() {
|
||||||
|
let mut saves = SavesState::default();
|
||||||
|
saves.bind(Some("a"));
|
||||||
|
saves.changed(view("a"));
|
||||||
|
saves.mark_pending(SavesOp::Delete {
|
||||||
|
dir: "World".to_string(),
|
||||||
|
});
|
||||||
|
assert!(saves.is_pending("World"));
|
||||||
|
saves.failed(failure("a"));
|
||||||
|
assert!(
|
||||||
|
!saves.is_pending("World"),
|
||||||
|
"失败后在飞标记没清:重试按钮会一直是灰的"
|
||||||
|
);
|
||||||
|
|
||||||
|
saves.mark_pending(SavesOp::Rename {
|
||||||
|
dir: "World".to_string(),
|
||||||
|
});
|
||||||
|
assert!(saves.is_pending("World"));
|
||||||
|
saves.changed(view("a"));
|
||||||
|
assert!(!saves.is_pending("World"), "成功后没清在飞标记");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 确认目标只发一次(同一次确认连点两下不该发两条删除)。
|
||||||
|
#[test]
|
||||||
|
fn the_delete_confirmation_hands_out_once() {
|
||||||
|
let mut saves = SavesState::default();
|
||||||
|
saves.bind(Some("a"));
|
||||||
|
saves.ask_delete("Doomed");
|
||||||
|
assert_eq!(saves.take_confirm().as_deref(), Some("Doomed"));
|
||||||
|
assert!(
|
||||||
|
saves.take_confirm().is_none(),
|
||||||
|
"第二次取还拿得到:连点会删两次"
|
||||||
|
);
|
||||||
|
|
||||||
|
saves.ask_delete("Other");
|
||||||
|
saves.clear_confirm();
|
||||||
|
assert!(saves.take_confirm().is_none(), "取消之后还留着目标");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 行内草稿照原样存:非法名字不在前端过滤(core 会拒并给出具名原因)。
|
||||||
|
#[test]
|
||||||
|
fn the_rename_draft_keeps_the_typed_text_verbatim() {
|
||||||
|
let mut saves = SavesState::default();
|
||||||
|
saves.bind(Some("a"));
|
||||||
|
saves.start_rename("World");
|
||||||
|
assert_eq!(
|
||||||
|
saves.draft.as_ref().expect("草稿").text,
|
||||||
|
"World",
|
||||||
|
"应预填当前名字"
|
||||||
|
);
|
||||||
|
saves.edit_rename("../escape".to_string());
|
||||||
|
let draft = saves.take_rename().expect("草稿");
|
||||||
|
assert_eq!(draft.dir, "World");
|
||||||
|
assert_eq!(draft.text, "../escape", "输入被前端偷偷改了");
|
||||||
|
assert!(saves.take_rename().is_none(), "取走之后草稿还在");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user