Merge branch 'feat/saves-write-operations-rename-delete-bac'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
This commit is contained in:
commit
81710ba38c
35 files changed
+4032
-293
No files matched your search
@@ -68,6 +68,9 @@ impl App {
|
||||
self.sync_saves();
|
||||
// M224:启停失败的提示语(presenter 只发结构,文案在这里按 i18n 现求)。
|
||||
self.note_mods_failure();
|
||||
// M247(复审 r1 P2-1):被确认的世界已经从快照里消失 ⇒ 收起那个确认框。
|
||||
// 不这样的话主按钮就是一次静默空操作(点了没删、也不说一句)。
|
||||
self.settle_saves_confirm();
|
||||
|
||||
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
|
||||
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
|
||||
@@ -100,6 +103,27 @@ impl App {
|
||||
self.refreshes += 1;
|
||||
}
|
||||
|
||||
/// 收掉「被确认的世界已经不在快照里」的那个删除确认框(M247,复审 r1 P2-1)。
|
||||
///
|
||||
/// 判据由 presenter 给(`SavesState::take_closed_confirm`:那个世界从快照里消失了),
|
||||
/// **框的 id 也由它带着**——所以只收我们自己那个框,崩溃卡片/进度框一概不碰。
|
||||
///
|
||||
/// 为什么必须收:目标已经作废,主按钮就成了一次**静默空操作**(用户点了「删除世界」,
|
||||
/// 框关了、什么也没发生、也没一句解释)。少删是安全的,但"看起来坏了"不是可接受的结果。
|
||||
fn settle_saves_confirm(&mut self) {
|
||||
let Some(dialog) = self.state.saves.take_closed_confirm() else {
|
||||
return;
|
||||
};
|
||||
if self
|
||||
.state
|
||||
.dialogs
|
||||
.current()
|
||||
.is_some_and(|current| current.id == dialog)
|
||||
{
|
||||
self.close_dialog();
|
||||
}
|
||||
}
|
||||
|
||||
/// 把详情页当前实例的启动参数快照灌进表单(唯一的重播种点,见 `pages/instances/state.rs`)。
|
||||
///
|
||||
/// 不在详情页(或实例已不在快照里)时解绑:下次进详情页会重新播种,
|
||||
@@ -178,7 +202,7 @@ impl App {
|
||||
// 出图取证(`ULL_SAVES_FIXTURE=1`):后台往返赶不上第 3 帧出图,注入一份固定快照。
|
||||
// 产品路径读不到这个变量 ⇒ 下面这行什么都不做(见 `saves_shot` 的模块文档)。
|
||||
if saves_shot::enabled() {
|
||||
self.state.saves.changed(saves_shot::view(&id));
|
||||
saves_shot::seed(self, &id);
|
||||
return;
|
||||
}
|
||||
// **不走 `App::dispatch`**:那条路会为「等第一条事件」把帧订阅打开 3 秒,
|
||||
|
||||
@@ -77,5 +77,91 @@ pub fn view(instance: &str) -> unlucky_presenter::dto::SavesListView {
|
||||
shot("2024-04-20_08.30.00.png", 964_608, None),
|
||||
],
|
||||
skipped: Vec::new(),
|
||||
// 出图夹具不造回执:默认那一张就是「刚列完目录」的样子。
|
||||
// 要看「备份成功那句话」长什么样,用 `ULL_SAVES_FIXTURE=backed-up`(见 `seed`)。
|
||||
last_op: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 夹具形态(由 `ULL_SAVES_FIXTURE` 的取值决定;未知值 = 默认那一种)。
|
||||
///
|
||||
/// | 取值 | 出图抓什么 |
|
||||
/// |---|---|
|
||||
/// | `1`(或任何未知值) | 有内容的列表:世界行右侧的重命名 / 备份 / 删除 |
|
||||
/// | `rename` | 行内重命名进行中(输入框 + 改名/取消) |
|
||||
/// | `confirm` | 删除的二次确认模态框 |
|
||||
/// | `backed-up` | 备份成功的回执那句话(带落点路径与大小) |
|
||||
/// | `running` | 「游戏正在运行,先退出再操作」那条写失败(M247 的在飞闸门) |
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
/// 默认:列表 + 行操作区。
|
||||
Plain,
|
||||
/// 行内重命名进行中。
|
||||
Rename,
|
||||
/// 删除确认模态。
|
||||
Confirm,
|
||||
/// 备份成功的回执。
|
||||
BackedUp,
|
||||
/// 写操作被「游戏在运行」挡下。
|
||||
Running,
|
||||
}
|
||||
|
||||
fn mode() -> Mode {
|
||||
match std::env::var("ULL_SAVES_FIXTURE")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
{
|
||||
"rename" => Mode::Rename,
|
||||
"confirm" => Mode::Confirm,
|
||||
"backed-up" => Mode::BackedUp,
|
||||
"running" => Mode::Running,
|
||||
_ => 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),
|
||||
// 在飞闸门那条失败(`host::saves::dispatch` 在 `running` 时直接回它)。
|
||||
Mode::Running => app
|
||||
.state
|
||||
.saves
|
||||
.failed(unlucky_presenter::dto::SavesFailureView {
|
||||
instance: instance.to_string(),
|
||||
file: dir,
|
||||
op: Some(unlucky_presenter::dto::SavesFailureOp::Rename),
|
||||
kind: unlucky_presenter::dto::SavesFailureKind::GameRunning,
|
||||
detail: String::new(),
|
||||
}),
|
||||
Mode::Confirm => {
|
||||
let (title, body, primary, secondary) =
|
||||
crate::pages::saves_delete_prompt(app.lang().catalog(), &dir);
|
||||
let dialog = app.state.dialogs.push(
|
||||
unlucky_presenter::dialogs::DialogSource::UserAction,
|
||||
title,
|
||||
body,
|
||||
unlucky_presenter::dialogs::DialogKind::Confirm { primary, secondary },
|
||||
);
|
||||
app.state.saves.ask_delete(dialog, &dir);
|
||||
}
|
||||
Mode::Plain | Mode::BackedUp => {}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,27 @@ use crate::app::state::App;
|
||||
pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::app::Message> {
|
||||
match message {
|
||||
DialogMessage::Primary => {
|
||||
// M247:删除存档的二次确认——主按钮就是**真删**(这是本页唯一不可逆的动作)。
|
||||
// 目标记在 presenter 的状态里(`SavesState::confirm`):模态队列只画框,不记"要删谁"。
|
||||
//
|
||||
// **认框不认人**(复审 r1 P1-1):只核销"当前这个框"登记的目标。哪怕某条关闭路径漏清,
|
||||
// 崩溃卡片的「导出诊断包」也拿不走它——否则就是一次静默删世界。
|
||||
let current = app.state.dialogs.current().map(|request| request.id);
|
||||
if let Some(dir) = current.and_then(|id| app.state.saves.take_confirm(id)) {
|
||||
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 {
|
||||
app.close_dialog();
|
||||
@@ -38,6 +59,11 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
|
||||
Task::none()
|
||||
}
|
||||
DialogMessage::Secondary | DialogMessage::Dismiss => {
|
||||
// M247:取消/关掉删除确认框 ⇒ 忘掉那个目标(不然下一次点确定会删上一个)。
|
||||
// **只核销当前这个框的目标**(复审 r2 加固):别人的框关掉时不该动我们的。
|
||||
if let Some(current) = app.state.dialogs.current().map(|request| request.id) {
|
||||
app.state.saves.close_confirm(current);
|
||||
}
|
||||
app.close_dialog();
|
||||
// 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒
|
||||
// (r1 复审 P3-1)。
|
||||
@@ -95,10 +121,18 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
|
||||
|
||||
/// `Esc` 关弹窗(有弹窗时**先吃这一条**,不让它落到 AI 栏的 Esc 收起上)。
|
||||
pub(super) fn escape(app: &mut App) -> bool {
|
||||
if app.state.dialogs.current().is_none() {
|
||||
let Some(current) = app.state.dialogs.current().map(|request| request.id) else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// M247(复审 r1 P1-1):**三条关闭路径都要清**——次按钮/遮罩清了,`Esc` 这条以前漏了,
|
||||
// 于是"删存档 → Esc → 崩溃卡片 → 导出诊断包"会把那个世界静默删掉。
|
||||
// 现在还有 id 绑定兜一道(`take_confirm(current)`),这里是纵深防御的第二层。
|
||||
// 复审 r2 加固:清的时候**只清这个框自己的目标**(`close_confirm`),不碰别人的。
|
||||
app.state.saves.close_confirm(current);
|
||||
app.close_dialog();
|
||||
app.refresh_view();
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,262 @@
|
||||
//! 主按钮 / Esc 的落点判据(M247,复审 r1 P1-1 / P2-1):**接线**层,不是 presenter 的状态机。
|
||||
//! 一个 App + 一个只记账的后端,看"点了主按钮之后下发了什么"——「盘上的世界还在」在这里等价于
|
||||
//! 「没有下发 `DeleteWorld`」(app 不写盘,唯一写入口是 `host::saves::dispatch`)。
|
||||
//! 真实 `export()` 写没写 zip 由 `app/dialog/tests.rs` 的夹具覆盖,这里不造崩溃卡片。
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_presenter::dialogs::{DialogKind, DialogSource};
|
||||
use unlucky_presenter::dto::{
|
||||
AppCommand, AppEvent, Backend, CoreEvent, SavesCommand, SavesListView, WorldView,
|
||||
};
|
||||
use unlucky_presenter::mock_data::mock_instances;
|
||||
use unlucky_presenter::router::Route;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
use super::{escape, on_dialog};
|
||||
use crate::app::dialog::Message as DialogMessage;
|
||||
use crate::app::message::Message;
|
||||
use crate::app::state::App;
|
||||
use crate::cli::Options;
|
||||
use crate::pages;
|
||||
use crate::theme::Families;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
const DIR: &str = "Better World";
|
||||
|
||||
/// 只记账、按需喂事件的后端(与 `app/tests/presenter.rs` 的 recorder 同款)。
|
||||
#[derive(Debug, Default)]
|
||||
struct SpyBackend {
|
||||
dispatched: Rc<RefCell<Vec<AppCommand>>>,
|
||||
events: Rc<RefCell<Vec<AppEvent>>>,
|
||||
}
|
||||
|
||||
impl Backend for SpyBackend {
|
||||
fn drain_events(&mut self) -> Vec<AppEvent> {
|
||||
std::mem::take(&mut *self.events.borrow_mut())
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, command: AppCommand) {
|
||||
self.dispatched.borrow_mut().push(command);
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个进到详情页、可喂事件、可记账的 App。
|
||||
struct Harness {
|
||||
app: App,
|
||||
dispatched: Rc<RefCell<Vec<AppCommand>>>,
|
||||
events: Rc<RefCell<Vec<AppEvent>>>,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
fn new() -> Self {
|
||||
let dispatched = Rc::new(RefCell::new(Vec::new()));
|
||||
let events = Rc::new(RefCell::new(vec![AppEvent::Core(
|
||||
CoreEvent::InstancesChanged(mock_instances()),
|
||||
)]));
|
||||
let backend = SpyBackend {
|
||||
dispatched: Rc::clone(&dispatched),
|
||||
events: Rc::clone(&events),
|
||||
};
|
||||
let mut harness = Self {
|
||||
app: App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(backend),
|
||||
SettingsBridge::memory_only(),
|
||||
),
|
||||
dispatched,
|
||||
events,
|
||||
};
|
||||
let _ = harness.app.update(Message::Boot);
|
||||
assert_eq!(
|
||||
harness.app.state.route(),
|
||||
&Route::Detail("survival".to_string()),
|
||||
"首屏该落在详情页(后面每一步都以它为前提交付目标)"
|
||||
);
|
||||
harness
|
||||
}
|
||||
|
||||
/// 喂一条事件再抽干(`pump` 是唯一的 apply 入口,它顺带 `refresh_view`)。
|
||||
fn feed(&mut self, event: AppEvent) {
|
||||
self.events.borrow_mut().push(event);
|
||||
self.app.pump();
|
||||
self.app.refresh_view();
|
||||
}
|
||||
|
||||
/// 点一次「删除 X」(页面消息的真实入口)。
|
||||
fn request_delete(&mut self) {
|
||||
let _ = self.app.update(Message::Page(pages::Message::Instances(
|
||||
pages::InstanceMessage::RequestDeleteWorld {
|
||||
dir: DIR.to_string(),
|
||||
},
|
||||
)));
|
||||
}
|
||||
|
||||
/// 有没有下发过 `SavesCommand::DeleteWorld`(= core 会不会去删盘上的世界)。
|
||||
fn deleted(&self) -> bool {
|
||||
self.dispatched.borrow().iter().any(|command| {
|
||||
matches!(
|
||||
command,
|
||||
AppCommand::Saves(command)
|
||||
if matches!(**command, SavesCommand::DeleteWorld { .. })
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 一份 `saves/` 快照(`worlds` 决定"那个世界还在不在")。
|
||||
fn snapshot(worlds: &[&str]) -> AppEvent {
|
||||
AppEvent::Core(CoreEvent::Saves(Box::new(Ok(SavesListView {
|
||||
instance: "survival".to_string(),
|
||||
dir: "C:/mc/saves".to_string(),
|
||||
exists: true,
|
||||
worlds: worlds
|
||||
.iter()
|
||||
.map(|dir| WorldView {
|
||||
dir: (*dir).to_string(),
|
||||
icon: false,
|
||||
modified_ms: None,
|
||||
})
|
||||
.collect(),
|
||||
screenshots_dir: "C:/mc/screenshots".to_string(),
|
||||
screenshots_exists: false,
|
||||
screenshots: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
last_op: None,
|
||||
}))))
|
||||
}
|
||||
|
||||
/// **P1-1 事故链**:删存档 → `Esc` → 一个不相干的 `Confirm`(崩溃卡片的主按钮是「导出诊断包」)
|
||||
/// → 点主按钮 ⇒ **没有** `DeleteWorld` 下发,框走它自己那条路(这里没有崩溃卡片 ⇒ 关框)。
|
||||
#[test]
|
||||
fn escaping_the_delete_dialog_disarms_it_for_the_next_one() {
|
||||
let mut harness = Harness::new();
|
||||
harness.request_delete();
|
||||
assert!(
|
||||
harness.app.state.dialogs.current().is_some(),
|
||||
"确认框没弹出来"
|
||||
);
|
||||
assert!(harness.app.state.saves.confirm.is_some(), "目标没登记");
|
||||
|
||||
assert!(escape(&mut harness.app), "有框时 Esc 该被吃掉");
|
||||
assert!(
|
||||
harness.app.state.saves.confirm.is_none(),
|
||||
"Esc 之后目标还留着 —— 下一个框的主按钮会把它兑现"
|
||||
);
|
||||
assert!(harness.app.state.dialogs.current().is_none());
|
||||
|
||||
// 不相干的框:崩溃卡片就是这个形态(`Confirm`,主按钮「导出诊断包」)。
|
||||
let _ = harness.app.state.dialogs.push(
|
||||
DialogSource::UserAction,
|
||||
"启动失败",
|
||||
"退出码 1",
|
||||
DialogKind::Confirm {
|
||||
primary: "导出诊断包".to_string(),
|
||||
secondary: "关闭".to_string(),
|
||||
},
|
||||
);
|
||||
let _ = on_dialog(&mut harness.app, DialogMessage::Primary);
|
||||
|
||||
assert!(
|
||||
!harness.deleted(),
|
||||
"Esc 之后那个目标被下一个框兑现了:世界被静默删除"
|
||||
);
|
||||
assert!(
|
||||
harness.app.state.dialogs.current().is_none(),
|
||||
"主按钮该走它自己那条路(这里没有崩溃卡片 ⇒ 关框)"
|
||||
);
|
||||
}
|
||||
|
||||
/// **纵深防御(P1-1 的第二层)**:万一某条路径只关了框、**漏清**目标,下一个不相干的框
|
||||
/// 点主按钮也**拿不走**它 —— 因为 `take_confirm` 只有在"登记时那个框 == 当前框"时才交付。
|
||||
///
|
||||
/// 这条直接构造"漏清"状态(登记一个不存在的框 id),所以它**不依赖** `Esc` 那条路有没有清:
|
||||
/// 去掉 id 校验,它立刻红。
|
||||
#[test]
|
||||
fn a_leaked_target_cannot_be_redeemed_by_another_dialog() {
|
||||
let mut harness = Harness::new();
|
||||
// 模拟事故形态:目标还挂着,但那个框早就不在了(登记 999 = 一个不存在的框)。
|
||||
harness.app.state.saves.ask_delete(999, DIR);
|
||||
let _ = harness.app.state.dialogs.push(
|
||||
DialogSource::UserAction,
|
||||
"启动失败",
|
||||
"退出码 1",
|
||||
DialogKind::Confirm {
|
||||
primary: "导出诊断包".to_string(),
|
||||
secondary: "关闭".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
let _ = on_dialog(&mut harness.app, DialogMessage::Primary);
|
||||
|
||||
assert!(
|
||||
!harness.deleted(),
|
||||
"漏清的目标被别的框兑现了:世界被静默删除(P1-1 的事故形态)"
|
||||
);
|
||||
assert!(
|
||||
harness.app.state.saves.confirm.is_some(),
|
||||
"别人的框不该顺手清掉我们的目标(清它的是关闭路径,不是主按钮)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 反向:目标属于**当前**这个框时,主按钮仍然真的下发删除(没把功能关死)。
|
||||
#[test]
|
||||
fn the_owning_dialog_still_deletes() {
|
||||
let mut harness = Harness::new();
|
||||
harness.request_delete();
|
||||
let _ = on_dialog(&mut harness.app, DialogMessage::Primary);
|
||||
|
||||
assert!(harness.deleted(), "自己的框点主按钮居然没删");
|
||||
assert!(harness.app.state.saves.confirm.is_none(), "取走即清");
|
||||
assert!(
|
||||
harness.app.state.saves.pending.is_some(),
|
||||
"该把那一行标成在飞(否则能连点)"
|
||||
);
|
||||
assert!(harness.app.state.dialogs.current().is_none(), "框该关掉");
|
||||
}
|
||||
|
||||
/// **P2-1**:世界还在 ⇒ 一拍无关快照(备份回程 / 打开文件夹回程)之后,主按钮照样删。
|
||||
#[test]
|
||||
fn an_unrelated_snapshot_keeps_the_confirmation_redeemable() {
|
||||
let mut harness = Harness::new();
|
||||
harness.request_delete();
|
||||
harness.feed(snapshot(&[DIR, "Other"]));
|
||||
|
||||
assert!(
|
||||
harness.app.state.saves.confirm.is_some(),
|
||||
"无关快照把目标吃掉了:主按钮会变成静默空操作"
|
||||
);
|
||||
assert!(
|
||||
harness.app.state.dialogs.current().is_some(),
|
||||
"框不该被收掉"
|
||||
);
|
||||
let _ = on_dialog(&mut harness.app, DialogMessage::Primary);
|
||||
assert!(harness.deleted(), "快照之后主按钮点了没反应");
|
||||
}
|
||||
|
||||
/// **P2-1 的另一半**:世界从快照里消失 ⇒ 框被**收起来**(不是留一个点了没反应的按钮),
|
||||
/// 而且不下发删除(那个世界已经不存在了,下发只会换来一条 `WriteMissing` 噪音)。
|
||||
#[test]
|
||||
fn a_vanished_world_closes_its_confirmation() {
|
||||
let mut harness = Harness::new();
|
||||
harness.request_delete();
|
||||
harness.feed(snapshot(&["Other"]));
|
||||
|
||||
assert!(
|
||||
harness.app.state.saves.confirm.is_none(),
|
||||
"作废的目标还留着"
|
||||
);
|
||||
assert!(
|
||||
harness.app.state.dialogs.current().is_none(),
|
||||
"框没被收起:主按钮会是一次静默空操作"
|
||||
);
|
||||
assert!(!harness.deleted(), "世界都不在了还下发删除");
|
||||
}
|
||||
@@ -11,7 +11,9 @@
|
||||
//! 要等回程事件,写盘失败时旧快照回来必须留住用户敲的值(M157 r3 复审 P2-2)。
|
||||
|
||||
use iced::Task;
|
||||
use unlucky_presenter::dialogs::{DialogKind, DialogSource};
|
||||
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch, ModsCommand, SavesCommand};
|
||||
use unlucky_presenter::state::SavesOp;
|
||||
|
||||
use crate::pages::InstanceMessage;
|
||||
|
||||
@@ -56,6 +58,76 @@ impl App {
|
||||
})));
|
||||
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 } => {
|
||||
let (title, body, primary, secondary) =
|
||||
crate::pages::saves_delete_prompt(self.lang().catalog(), &dir);
|
||||
// **先压框、拿到它的 id,再登记目标**:目标绑在那个框上(复审 r1 P1-1)——
|
||||
// 反过来(先登记再压框)会让"这一拍还没有框"的中间态挂在状态里,别的框的主按钮
|
||||
// 就有机会把它兑掉。
|
||||
let dialog = self.state.dialogs.push(
|
||||
DialogSource::UserAction,
|
||||
title,
|
||||
body,
|
||||
DialogKind::Confirm { primary, secondary },
|
||||
);
|
||||
self.state.saves.ask_delete(dialog, &dir);
|
||||
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(),
|
||||
InstanceMessage::Jvm(text) => {
|
||||
@@ -96,7 +168,9 @@ impl App {
|
||||
}
|
||||
|
||||
/// 详情页当前选中的实例 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() {
|
||||
unlucky_presenter::router::Route::Detail(id) => Some(id.clone()),
|
||||
_ => None,
|
||||
|
||||
@@ -75,6 +75,23 @@ pub fn saves_failure_line(
|
||||
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`])。
|
||||
pub fn mods_failure_line(
|
||||
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 下两个条
|
||||
//! 都认得出来,用户不知道该拖哪一个。这里同样只排内容,长度交给外层那一条。
|
||||
//!
|
||||
//! # 只读(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`)。**打开文件夹失败不画重试**:
|
||||
//! 那个动作的重试就是再点一次按钮,给一个会去重新列目录的「重试」是错的affordance
|
||||
//! (两种失败共用 `detail`,只有大类不同,见 [`Saves::retryable`])。
|
||||
//! 读盘失败画一条红字 + 一个「重试」(再发一条 `SavesCommand::List`)。**打开文件夹失败与写操作
|
||||
//! 失败不画重试**:那个动作的重试就是再点一次按钮,给一个会去重新列目录的「重试」是错的
|
||||
//! affordance(三种失败共用 `detail`,靠大类与 `op` 分别,见 [`failure_line`])。
|
||||
//!
|
||||
//! 写操作**成功**也有一句话([`Saves::notice`]):重命名 / 删除的结果列表上看得见,但备份的落点
|
||||
//! 看不见——「点了备份然后什么也没发生」是静默失败,所以回执里带上完整路径与字节数。
|
||||
|
||||
use iced::widget::{column, container, row};
|
||||
use iced::{Alignment, Element, Length};
|
||||
@@ -33,7 +59,7 @@ use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
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 crate::app::components::buttons::{self, Button};
|
||||
@@ -46,15 +72,28 @@ use super::forced_state;
|
||||
use super::state::Message as PageMessage;
|
||||
use super::Texts;
|
||||
|
||||
/// 列表里的一行(世界 / 截图共用:一个名字 + 一条副文案)。
|
||||
mod rows;
|
||||
/// 写操作成功那句话的产地(实现在 [`rows`] 里,页面与测试都从这一条路取)。
|
||||
pub(super) use rows::op_line;
|
||||
|
||||
/// 列表里的一行(一个世界 / 一张截图)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Row {
|
||||
/// 世界目录名或截图文件名(磁盘原样)。
|
||||
/// 世界目录名或截图文件名(磁盘原样;写操作把它原样带回去当「源」)。
|
||||
pub name: String,
|
||||
/// 右侧/下方副文案(时间、大小、图标标记,已由这一层拼好)。
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// 正在行内重命名的那一行(来源是 presenter 的 `SavesState::draft`)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Editing {
|
||||
/// 哪个世界。
|
||||
pub dir: String,
|
||||
/// 输入框里的内容(原样)。
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// SAVES 区的展示模型(`Model::saves`)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Saves {
|
||||
@@ -62,9 +101,9 @@ pub struct Saves {
|
||||
pub bound: Option<String>,
|
||||
/// 正在读盘(进详情页首帧:请求已发出、快照还没回来)。
|
||||
pub loading: bool,
|
||||
/// 读盘/打开失败的人读原因(app 层按 i18n 拼好)。
|
||||
/// 读盘/打开/写操作失败的人读原因(app 层按 i18n 拼好)。
|
||||
pub error: Option<String>,
|
||||
/// 这一条失败能不能靠「再列一次」解决(打开文件夹失败不算)。
|
||||
/// 这一条失败能不能靠「再列一次」解决(只有读盘失败算)。
|
||||
pub retryable: bool,
|
||||
/// 手里有没有一份**快照**(`false` = 只拿到了失败,"还没有世界"那几句不许画)。
|
||||
pub has_view: bool,
|
||||
@@ -84,6 +123,12 @@ pub struct Saves {
|
||||
pub skipped: usize,
|
||||
/// 跳过提示(`跳过 3 项`)。
|
||||
pub skipped_note: String,
|
||||
/// 正在行内重命名的那一行(`None` = 没有在改名)。
|
||||
pub editing: Option<Editing>,
|
||||
/// 有写操作在飞的那个世界(`None` = 空闲)。
|
||||
pub busy: Option<String>,
|
||||
/// 上一次写操作成功的那句话(`None` = 这一拍没有回执)。
|
||||
pub notice: Option<String>,
|
||||
}
|
||||
|
||||
impl Saves {
|
||||
@@ -98,21 +143,33 @@ impl Saves {
|
||||
pub fn nothing_yet(&self) -> bool {
|
||||
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` 调)。
|
||||
///
|
||||
/// 两条口径:
|
||||
/// 三条口径:
|
||||
///
|
||||
/// 1. **失败不清列表**:list 失败时保留上一份 `worlds`/`screenshots`(用户还能看见旧列表,
|
||||
/// 只是多一条红字),只有"从来没有过快照"时才是一条纯错误态;
|
||||
/// 2. **只读**:这里没有任何"乐观更新"可言——快照就是磁盘,下一次进详情页或点重试才刷新。
|
||||
/// 2. **不做乐观更新**:行上的名字只来自快照,写操作的"在飞"只用来置灰那一行;
|
||||
/// 3. **回执只画一次**:`view.last_op` 是 host 写在快照里的,下一次普通 List 就没了。
|
||||
#[must_use]
|
||||
pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
|
||||
let texts = Texts::of(catalog);
|
||||
let mut out = Saves {
|
||||
bound: state.bound.clone(),
|
||||
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()
|
||||
};
|
||||
if let Some(view) = &state.view {
|
||||
@@ -125,7 +182,7 @@ pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
|
||||
.iter()
|
||||
.map(|world| Row {
|
||||
name: world.dir.clone(),
|
||||
detail: world_detail(world.icon, world.modified_ms, texts),
|
||||
detail: rows::world_detail(world.icon, world.modified_ms, texts),
|
||||
})
|
||||
.collect();
|
||||
out.screenshots = view
|
||||
@@ -133,30 +190,33 @@ pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
|
||||
.iter()
|
||||
.map(|shot| Row {
|
||||
name: shot.file.clone(),
|
||||
detail: shot_detail(shot.bytes, shot.modified_ms, texts),
|
||||
detail: rows::shot_detail(shot.bytes, shot.modified_ms, texts),
|
||||
})
|
||||
.collect();
|
||||
out.count = texts.saves_count(view.world_count(), view.screenshot_count());
|
||||
out.skipped = view.skipped_count();
|
||||
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 {
|
||||
out.loading = false;
|
||||
out.error = Some(failure_line(failure, texts));
|
||||
out.retryable = failure.kind != SavesFailureKind::OpenFailed;
|
||||
// 只有「读盘 / 列目录」失败才值得重试;写操作的重试就是再点一次那个按钮。
|
||||
out.retryable = failure.op.is_none() && failure.kind != SavesFailureKind::OpenFailed;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 失败的一句话(**唯一产地**:页面内红字用它;本版没有 Toast——只读列表的失败就地说明更近)。
|
||||
/// 失败的一句话(**唯一产地**:页面内红字用它,本版没有 Toast)。
|
||||
#[must_use]
|
||||
pub fn failure_line(failure: &SavesFailureView, texts: Texts) -> String {
|
||||
let reason = texts.saves_reason(failure.kind);
|
||||
let sentence = match failure.kind {
|
||||
SavesFailureKind::OpenFailed => texts.saves_open_failed(reason),
|
||||
SavesFailureKind::DirUnusable | SavesFailureKind::InstanceUnavailable => {
|
||||
texts.saves_failed(reason)
|
||||
}
|
||||
let sentence = match (failure.op, failure.kind) {
|
||||
(Some(SavesFailureOp::Rename), _) => texts.saves_rename_failed(&failure.file, reason),
|
||||
(Some(SavesFailureOp::Delete), _) => texts.saves_delete_failed(&failure.file, 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() {
|
||||
return sentence;
|
||||
@@ -164,44 +224,6 @@ pub fn failure_line(failure: &SavesFailureView, texts: Texts) -> String {
|
||||
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` 调它)。
|
||||
pub(super) fn section<'a>(
|
||||
ui: &'a Ui,
|
||||
@@ -212,8 +234,6 @@ pub(super) fn section<'a>(
|
||||
let mut body = column![head(ui, saves, forced, texts)]
|
||||
.spacing(ui.space(Space::X3))
|
||||
.width(Length::Fill);
|
||||
// 只读边界固定画在第一行(见模块文档)。
|
||||
body = body.push(note(ui, texts.saves_read_only()));
|
||||
|
||||
if 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()));
|
||||
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()));
|
||||
@@ -262,7 +286,7 @@ pub(super) fn section<'a>(
|
||||
body = body.push(note(ui, texts.saves_no_worlds()));
|
||||
} else {
|
||||
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()));
|
||||
@@ -270,7 +294,7 @@ pub(super) fn section<'a>(
|
||||
body = body.push(note(ui, texts.saves_no_screenshots()));
|
||||
} else {
|
||||
for item in &saves.screenshots {
|
||||
body = body.push(entry_row(ui, item));
|
||||
body = body.push(rows::world_plain(ui, item));
|
||||
}
|
||||
}
|
||||
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> {
|
||||
frame::text_role(
|
||||
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,
|
||||
/// 打开存档文件夹(M235):拉起哪个目录由 host 决定(`saves/` → `screenshots/` → 运行目录)。
|
||||
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)]
|
||||
|
||||
@@ -43,10 +43,13 @@ const ALLOWED: &[&str] = &[
|
||||
// 翻成「存档目录」用户就对不上那两层文件夹了。
|
||||
"saves",
|
||||
"screenshots",
|
||||
// M247:`zip` 是**归档格式名**(备份导出成什么,玩家拿去给别的东西解压时看的也是这个词)。
|
||||
// 中文界面上写「压缩包」会让「备份出来的是 .zip 吗」这个问题没有答案。
|
||||
"zip",
|
||||
];
|
||||
|
||||
/// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。
|
||||
const PAGE_SOURCES: [(&str, &str); 8] = [
|
||||
const PAGE_SOURCES: [(&str, &str); 9] = [
|
||||
("instances.rs", include_str!("../../instances.rs")),
|
||||
("instances/detail.rs", include_str!("../detail.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")),
|
||||
// M235:SAVES 区(存档 / 截图的只读列表 + 打开文件夹)
|
||||
("instances/saves.rs", include_str!("../saves.rs")),
|
||||
// M247:SAVES 区的行与操作区(重命名 / 备份 / 删除 + 行内输入框)
|
||||
("instances/saves/rows.rs", include_str!("../saves/rows.rs")),
|
||||
];
|
||||
|
||||
/// 页面五件套里**允许出现的字符串字面量**(凡是带 ASCII 单词的都必须在这张表里)。
|
||||
@@ -80,7 +85,7 @@ const PAGE_SOURCES: [(&str, &str); 8] = [
|
||||
/// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 |
|
||||
/// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) |
|
||||
/// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) |
|
||||
const ALLOWED_LITERALS: [&str; 30] = [
|
||||
const ALLOWED_LITERALS: [&str; 35] = [
|
||||
"{date} {time} UTC",
|
||||
"isolated",
|
||||
"shared",
|
||||
@@ -125,6 +130,13 @@ const ALLOWED_LITERALS: [&str; 30] = [
|
||||
// M235:存档区计数那条的**占位符名**(`{worlds}` / `{shots}`),同样不是文案。
|
||||
"worlds",
|
||||
"shots",
|
||||
// M247:删除确认与写操作回执的**占位符名**(`{dir}` 世界名 / `{from}` `{to}` 改名两端 /
|
||||
// `{path}` 备份落点 / `{size}` 人的可读字节数),都不是文案。
|
||||
"dir",
|
||||
"from",
|
||||
"to",
|
||||
"path",
|
||||
"size",
|
||||
];
|
||||
|
||||
/// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。
|
||||
|
||||
@@ -42,6 +42,7 @@ fn view(instance: &str) -> SavesListView {
|
||||
file: "notes.txt".to_string(),
|
||||
reason: SavesSkipKind::NotAnImage,
|
||||
}],
|
||||
last_op: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +152,8 @@ fn only_list_failures_offer_a_retry() {
|
||||
state.changed(view("a"));
|
||||
state.failed(SavesFailureView {
|
||||
instance: "a".to_string(),
|
||||
file: String::new(),
|
||||
op: None,
|
||||
kind,
|
||||
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");
|
||||
state.failed(SavesFailureView {
|
||||
instance: "a".to_string(),
|
||||
file: String::new(),
|
||||
op: None,
|
||||
kind: SavesFailureKind::DirUnusable,
|
||||
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() {
|
||||
let failure = SavesFailureView {
|
||||
instance: "a".to_string(),
|
||||
file: String::new(),
|
||||
op: None,
|
||||
kind: SavesFailureKind::DirUnusable,
|
||||
detail: "Access is denied.".to_string(),
|
||||
};
|
||||
@@ -234,3 +241,5 @@ fn millisecond_stamps_are_rendered_in_utc() {
|
||||
stamp(1_709_251_140_000)
|
||||
);
|
||||
}
|
||||
|
||||
mod write;
|
||||
@@ -0,0 +1,201 @@
|
||||
//! 存档**写操作**的页面模型判据(M247)——从 `tests/saves.rs` 拆出(那边越过 400 行)。
|
||||
//!
|
||||
//! 三条都在 `from_state` 这一层钉:写失败的主句(哪一行 + 哪个动作 + 什么原因)、
|
||||
//! 「游戏在运行」不是「写盘失败」(不留重试按钮)、以及草稿 / 在飞 / 回执变成页面字段。
|
||||
|
||||
use super::super::super::saves::{failure_line, from_state, op_line};
|
||||
use super::super::super::Texts;
|
||||
use super::{bound_state, view};
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dto::{SavesFailureKind, SavesFailureOp, SavesFailureView, SavesOped};
|
||||
use unlucky_presenter::state::SavesOp;
|
||||
|
||||
/// 写操作失败:**不画重试**(重试按钮会去重新列目录,那是错的动作),并且主句带**哪个世界**。
|
||||
///
|
||||
/// 抓得住:三种写失败被折叠成同一句「读取 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:?} 失败把列表清了");
|
||||
}
|
||||
}
|
||||
|
||||
/// 「游戏在跑」不是「写盘失败」:主句要直说先退出,而且**不画重试**。
|
||||
///
|
||||
/// 抓得住:把这条折进 `WriteFailed` —— 用户会看到「删除 A 失败:写盘失败」,然后以为是权限问题,
|
||||
/// 反复重试;真正该做的事(退出游戏)一个字都没说。
|
||||
#[test]
|
||||
fn a_running_game_says_exit_first_not_write_failed() {
|
||||
let cases = [
|
||||
(
|
||||
SavesFailureOp::Rename,
|
||||
"重命名 Better World 失败:游戏正在运行(先退出再操作)",
|
||||
),
|
||||
(
|
||||
SavesFailureOp::Delete,
|
||||
"删除 Better World 失败:游戏正在运行(先退出再操作)",
|
||||
),
|
||||
(
|
||||
SavesFailureOp::Backup,
|
||||
"备份 Better World 失败:游戏正在运行(先退出再操作)",
|
||||
),
|
||||
];
|
||||
for (op, expected) in cases {
|
||||
let failure = SavesFailureView {
|
||||
instance: "a".to_string(),
|
||||
file: "Better World".to_string(),
|
||||
op: Some(op),
|
||||
kind: SavesFailureKind::GameRunning,
|
||||
detail: String::new(),
|
||||
};
|
||||
assert_eq!(
|
||||
failure_line(&failure, Texts::of(Lang::ZhCn.catalog())),
|
||||
expected
|
||||
);
|
||||
assert!(
|
||||
!expected.contains("写盘失败"),
|
||||
"「游戏在跑」被说成了「写盘失败」"
|
||||
);
|
||||
|
||||
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_eq!(saves.worlds.len(), 1, "{op:?}:被拒把列表清了");
|
||||
}
|
||||
// 英文那一侧同一条判据(主语不同、意思相同)。
|
||||
let failure = SavesFailureView {
|
||||
instance: "a".to_string(),
|
||||
file: "Better World".to_string(),
|
||||
op: Some(SavesFailureOp::Delete),
|
||||
kind: SavesFailureKind::GameRunning,
|
||||
detail: String::new(),
|
||||
};
|
||||
let en = failure_line(&failure, Texts::of(Lang::En.catalog()));
|
||||
assert_eq!(
|
||||
en,
|
||||
"Could not delete Better World: the game is running (exit it first)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 三样交互态(草稿 / 在飞 / 回执)都要变成页面字段:改名中的那一行、置灰的那一行、成功那句话。
|
||||
#[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}"
|
||||
);
|
||||
}
|
||||
@@ -216,10 +216,6 @@ impl Texts {
|
||||
pub(super) fn saves_title(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesTitle)
|
||||
}
|
||||
/// 「这一版只读」——U3 的边界固定画出来(见 `catalog/instances.rs` 的那段注释)。
|
||||
pub(super) fn saves_read_only(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesReadOnly)
|
||||
}
|
||||
/// 「3 个世界 · 12 张截图」。
|
||||
pub(super) fn saves_count(self, worlds: usize, shots: usize) -> String {
|
||||
self.0.render(
|
||||
@@ -279,15 +275,104 @@ impl Texts {
|
||||
self.0
|
||||
.render(Key::InstancesSavesOpenFailed, &[("reason", reason)])
|
||||
}
|
||||
/// 失败大类的短句(`SavesFailureKind` → 一句话)。
|
||||
/// 失败大类的短句(`SavesFailureKind` → 一句话;写操作那八类各有自己的话)。
|
||||
pub(super) fn saves_reason(self, kind: SavesFailureKind) -> &'static str {
|
||||
self.text(match kind {
|
||||
SavesFailureKind::DirUnusable => Key::InstancesSavesReasonDir,
|
||||
SavesFailureKind::InstanceUnavailable => Key::InstancesSavesReasonInstance,
|
||||
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::GameRunning => Key::InstancesSavesReasonRunning,
|
||||
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 {
|
||||
self.0.text(key)
|
||||
}
|
||||
|
||||
@@ -129,8 +129,8 @@ mod tests;
|
||||
pub use download::{cancelled_toast, Category, Download, InstallDraft, Loader};
|
||||
pub(crate) use instances::demo_form as demo_launch_form;
|
||||
pub use instances::{
|
||||
mods_failure_line, mods_from_state, saves_failure_line, saves_from_state, InstanceMessage,
|
||||
LaunchForm, Mods, Saves,
|
||||
mods_failure_line, mods_from_state, saves_delete_prompt, saves_failure_line, saves_from_state,
|
||||
InstanceMessage, LaunchForm, Mods, Saves,
|
||||
};
|
||||
pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
|
||||
pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome};
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
//! 存档 / 截图的**只读**列举与「打开文件夹」(M235 · STOREMVP)。
|
||||
//! 存档 / 截图的列举与「打开文件夹」(M235 · STOREMVP)+ 三个写操作(M247 · SAVESOPS)。
|
||||
//!
|
||||
//! # 这一版做到哪、不做什么(塔裁决 U3)
|
||||
//! # 读的那一半(M235)
|
||||
//!
|
||||
//! 用户原话是「存档截图 / 导入」,M230 审计确认三层全缺(core 没有读路径,`saves/` 只在
|
||||
//! `isolation.rs` 的切换探测里被计数;presenter 没有 DTO;i18n 没有键;没有页面)。
|
||||
//! U3 把本 mission 的边界钉成:**只读列表 + 打开文件夹,不做缩略图墙**。理由有两条:
|
||||
//! U3 把 M235 的边界钉成:**只读列表 + 打开文件夹,不做缩略图墙**。理由有两条:
|
||||
//!
|
||||
//! 1. 缩略图要引图片解码依赖,而 `AGENTS.md` §2 只允许 `palette-extract` 之外不引 image 类依赖;
|
||||
//! 2. 截图/导入的产物形态还没定型,先不进产品。
|
||||
//!
|
||||
//! 所以这里**只读**:只调 `read_dir` / `metadata` / `symlink_metadata`,不建目录、不写文件、
|
||||
//! 所以 [`list`] **只读**:只调 `read_dir` / `metadata` / `symlink_metadata`,不建目录、不写文件、
|
||||
//! 不改名字、不读文件内容(`icon.png` 只判存在——那是将来缩略图墙的输入,不是这一版的解码对象)。
|
||||
//!
|
||||
//! # 写的那一半(M247)
|
||||
//!
|
||||
//! 用户接着拍板:「存档按照主流的方式来」——主流第三方启动器(HMCL / PCL2)的存档区都有
|
||||
//! **重命名世界 / 删除世界(二次确认)/ 备份导出**。这三个落在 [`write`]:
|
||||
//!
|
||||
//! | 操作 | 入口 | 边界 |
|
||||
//! |---|---|---|
|
||||
//! | 重命名 | [`write::rename_world`] | 只动 `saves/<old>` → `saves/<new>`;撞名拒 |
|
||||
//! | 删除 | [`write::delete_world`] | 先过边界校验,再递归删;不跟随符号链接 |
|
||||
//! | 备份 | [`write::backup_world`] | 流式 zip 到调用方给的目录,时戳命名,不覆盖 |
|
||||
//!
|
||||
//! **写操作一律自己重新校验 `run_dir` 与目标路径**(不靠调用方传对)——判据在
|
||||
//! [`write`] 的模块文档里一条条列着。
|
||||
//!
|
||||
//! # 单根语义(D20)
|
||||
//!
|
||||
//! 入口是**已经派生好的运行目录**([`crate::game::isolation::derive_game_dir`] 的结果),
|
||||
@@ -23,6 +37,7 @@
|
||||
//!
|
||||
//! 新实例两个目录都不存在,这**不是**异常(`exists = false` + 空列表)。只有「存在但不可用」
|
||||
//! (符号链接 / 不是目录 / 读不了)才是 [`SavesError`];**并且照样不建目录**。
|
||||
//! 写操作那边不同:目标不存在就是 [`write::SavesWriteKind::NotFound`](写不下去,得说清楚)。
|
||||
//!
|
||||
//! # 收纳口径
|
||||
//!
|
||||
@@ -40,18 +55,24 @@
|
||||
//! |---|---|
|
||||
//! | [`error`] | 「目录存在但不可用」的失败形状(带 OS 原文) |
|
||||
//! | [`list`] | 列 `<run_dir>/saves` 与 `<run_dir>/screenshots`(纯只读) |
|
||||
//! | [`write`] | 重命名 / 删除 / 备份导出(三个写操作 + 边界校验) |
|
||||
//!
|
||||
//! 「打开文件夹」不在本模块:平台差异收敛在 [`crate::platform::opener::open_dir`]
|
||||
//! (`AGENTS.md` §6),本模块一个 `cfg` 都不写。
|
||||
|
||||
mod error;
|
||||
mod list;
|
||||
mod write;
|
||||
|
||||
pub use error::SavesError;
|
||||
pub use list::{
|
||||
saves_dir, scan, screenshots_dir, Area, SavesScan, SavesSkip, ScreenshotEntry, SkipKind,
|
||||
WorldEntry, IMAGE_EXTENSIONS, SAVES_DIR, SCREENSHOTS_DIR, WORLD_ICON,
|
||||
};
|
||||
pub use write::{
|
||||
backup_world, delete_world, rename_world, Backup, SavesWriteError, SavesWriteKind, BACKUP_EXT,
|
||||
MAX_BACKUP_ENTRIES, MAX_BACKUP_TOTAL_BYTES,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -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", "同名文件已存在"),
|
||||
("InstancesModsReasonRename", "the rename failed", "改名失败"),
|
||||
("InstancesSavesTitle", "SAVES", "存档"),
|
||||
("InstancesSavesReadOnly", "Read-only in this version: nothing here is moved or deleted.", "这一版只读:这里不移动、也不删除任何东西。"),
|
||||
("InstancesSavesCount", "{worlds} worlds · {shots} screenshots", "{worlds} 个世界 · {shots} 张截图"),
|
||||
("InstancesSavesLoading", "Reading saves/…", "正在读取 saves/…"),
|
||||
("InstancesSavesEmpty", "No worlds or screenshots yet — play once and they show up here.", "还没有世界或截图——玩一次就会出现在这里。"),
|
||||
@@ -125,6 +124,32 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
|
||||
("InstancesSavesReasonDir", "the directory is not usable", "目录不可用"),
|
||||
("InstancesSavesReasonInstance", "the instance is gone", "实例已不存在"),
|
||||
("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", "写盘失败"),
|
||||
("InstancesSavesReasonRunning", "the game is running (exit it first)", "游戏正在运行(先退出再操作)"),
|
||||
];
|
||||
|
||||
/// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。
|
||||
|
||||
@@ -154,12 +154,11 @@ macro_rules! instances_entries {
|
||||
InstancesModsReasonNotFile => ("that is not a regular file", "那不是普通文件"),
|
||||
InstancesModsReasonCollision => ("a file with that name already exists", "同名文件已存在"),
|
||||
InstancesModsReasonRename => ("the rename failed", "改名失败"),
|
||||
// ── 存档区(M235 · STOREMVP):**只读**列 `saves/` 与 `screenshots/` + 打开文件夹。
|
||||
// U3 的边界写在 `InstancesSavesReadOnly` 里,页面固定画出来——用户点开就看见
|
||||
// 「这一版只读」,不会以为漏了删除按钮。`saves/` / `screenshots/` 是**目录名**,
|
||||
// 两套语言都原样保留(与 MODS 区的 `mods` 同一条口径)。
|
||||
// ── 存档区(M235 · STOREMVP 读路径 / M247 · SAVESOPS 写操作)。
|
||||
// M235 那条「这一版只读」(`InstancesSavesReadOnly`)**已删**:写操作接上之后它是假的,
|
||||
// 边界改由 core 的 `game::saves::write::boundary` 守(越界 / 符号链接 / 撞名都在那里拒)。
|
||||
// `saves/` / `screenshots/` 是**目录名**,两套语言都原样保留(与 MODS 区的 `mods` 同一条口径)。
|
||||
InstancesSavesTitle => ("SAVES", "存档"),
|
||||
InstancesSavesReadOnly => ("Read-only in this version: nothing here is moved or deleted.", "这一版只读:这里不移动、也不删除任何东西。"),
|
||||
InstancesSavesCount => ("{worlds} worlds · {shots} screenshots", "{worlds} 个世界 · {shots} 张截图"),
|
||||
InstancesSavesLoading => ("Reading saves/…", "正在读取 saves/…"),
|
||||
InstancesSavesEmpty => ("No worlds or screenshots yet — play once and they show up here.", "还没有世界或截图——玩一次就会出现在这里。"),
|
||||
@@ -178,6 +177,37 @@ macro_rules! instances_entries {
|
||||
InstancesSavesReasonDir => ("the directory is not usable", "目录不可用"),
|
||||
InstancesSavesReasonInstance => ("the instance is gone", "实例已不存在"),
|
||||
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", "写盘失败"),
|
||||
// 实例在启动/运行中:三个写操作**主动不写盘**(不是"写盘失败"——Linux 上删一个正在跑的
|
||||
// 世界是会成功的,所以这条要直说"先退出",别让用户以为是权限问题)。
|
||||
InstancesSavesReasonRunning => ("the game is running (exit it first)", "游戏正在运行(先退出再操作)"),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,8 +88,8 @@ pub use settings::{
|
||||
};
|
||||
// M235: 实例存档 / 截图的只读列表
|
||||
pub use saves::{
|
||||
SavesArea, SavesCommand, SavesFailureKind, SavesFailureView, SavesListView, SavesSkipKind,
|
||||
SavesSkipView, ScreenshotView, WorldView,
|
||||
SavesArea, SavesCommand, SavesFailureKind, SavesFailureOp, SavesFailureView, SavesListView,
|
||||
SavesOped, SavesSkipKind, SavesSkipView, ScreenshotView, WorldView,
|
||||
};
|
||||
// M159: 自更新
|
||||
pub use update::{
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
//! 实例**存档 / 截图的只读快照**与「打开文件夹」的 IPC 面(M235 · STOREMVP)。
|
||||
//! 实例**存档 / 截图的快照**与写操作的 IPC 面(M235 · STOREMVP,M247 · SAVESOPS 加写路径)。
|
||||
//!
|
||||
//! # 三层里的第二层
|
||||
//!
|
||||
//! 塔裁决 U3 把这一版钉成「只读列表 + 打开文件夹」,不做缩略图墙。三层是:
|
||||
//! `core::game::saves`(读盘,唯一的真相)→ 本模块(IPC 形状)→
|
||||
//! `crates/app-iced/src/pages/instances/saves.rs`(画)。
|
||||
//! 这一版是「只读列表 + 打开文件夹」**加上**三个写操作:`core::game::saves`(读盘/写盘,唯一的
|
||||
//! 真相)→ 本模块(IPC 形状)→ `crates/app-iced/src/pages/instances/saves.rs`(画)。
|
||||
//!
|
||||
//! | 方向 | 类型 |
|
||||
//! |---|---|
|
||||
//! | app → host | [`SavesCommand`](列目录 / 打开文件夹) |
|
||||
//! | host → app | [`SavesListView`](整份快照)或 [`SavesFailureView`](具名原因) |
|
||||
//! | app → host | [`SavesCommand`](列目录 / 打开文件夹 / 重命名 / 删除 / 备份) |
|
||||
//! | host → app | [`SavesListView`](整份快照 + 上一次写操作的[回执](SavesOped))或 [`SavesFailureView`] |
|
||||
//!
|
||||
//! # 写操作为什么走同一条回程
|
||||
//!
|
||||
//! 三个写操作的结果都是**一份新快照**(`Ok`)+ 一个[回执](SavesOped):写完之后磁盘变了,
|
||||
//! 界面必须跟着变;而回执是「这次动的是哪一条、动成了什么」。失败则走同一个事件的 `Err` 一支,
|
||||
//! 带上一个**具名类别**(重命名撞名 / 删除越界 / 备份超限……),见 [`SavesFailureKind`]。
|
||||
//! 两条都不静默:成功有回执、失败有类别 + OS 原文。
|
||||
//!
|
||||
//! # 为什么另开一族,不并进 `ModListView`
|
||||
//!
|
||||
//! 两者形状相近(都是"列一个实例的某个目录"),但语义不同:mod 有启停(会写盘、要看游戏在不在跑),
|
||||
//! 存档这一版**没有任何写路径**。合并会让 mod 那边的 `pending` / `running` 字段在存档页变成
|
||||
//! 永远为假的分支(M224 拒绝给内容页加第五个 `ContentKind` 是同一条理由:别让穷尽 match 为不相干
|
||||
//! 的字段服务)。
|
||||
//! 两者形状相近(都是"列一个实例的某个目录"),但语义不同:mod 有启停(看游戏在不在跑),
|
||||
//! 存档这边有三个写操作(重命名 / 删除 / 备份)。合并会让两边的 `pending` 字段互相变成
|
||||
//! 永远为假的分支(M224 拒绝给内容页加第五个 `ContentKind` 是同一条理由:别让穷尽 match 为
|
||||
//! 不相干的字段服务)。
|
||||
//!
|
||||
//! # 文案纪律(M161)
|
||||
//!
|
||||
//! 本模块**不带人读散文**:失败给的是结构([`SavesFailureKind`] + 底层 `detail` 串),
|
||||
//! 本模块**不带人读散文**:失败给的是结构([`SavesFailureKind`] + 底层 `detail` 串)、
|
||||
//! 回执给的是事实(谁改成什么、备份落到哪个路径、几个字节、跳过几个链接),
|
||||
//! 时间戳给的是**毫秒数**(UTC 毫秒,UI 自己格式化)。文案一律由 app 层按 i18n key 现求。
|
||||
//!
|
||||
//! # 时间戳为什么是 `Option<u64>` 的毫秒
|
||||
@@ -45,6 +52,76 @@ pub enum SavesCommand {
|
||||
/// 实例 id
|
||||
id: InstanceId,
|
||||
},
|
||||
/// 重命名一个世界(M247):`saves/<dir>` → `saves/<to>`。
|
||||
///
|
||||
/// 名字规则、撞名、边界校验全在 core(host 只转发):UI 传坏了也不会落到 `saves/` 之外。
|
||||
RenameWorld {
|
||||
/// 实例 id
|
||||
id: InstanceId,
|
||||
/// 现在的磁盘目录名
|
||||
dir: String,
|
||||
/// 新名字(单段;不许带 `/` `\` `:` 与 `..`)
|
||||
to: String,
|
||||
},
|
||||
/// 删除一个世界(M247):递归删 `saves/<dir>`。
|
||||
///
|
||||
/// **UI 必须先二次确认**:这是本页唯一不可逆的动作。host 侧再走一遍边界校验
|
||||
/// (越界 / 符号链接一律拒),不靠确认框把调用方「教好」。
|
||||
DeleteWorld {
|
||||
/// 实例 id
|
||||
id: InstanceId,
|
||||
/// 磁盘目录名
|
||||
dir: String,
|
||||
},
|
||||
/// 备份一个世界(M247):把一个世界导出成 zip。
|
||||
///
|
||||
/// `dest` 是**父目录**(不是文件):`None` = 启动器自己的 `<data>/exports/saves/`。
|
||||
/// 文件名由 core 按「世界名 + UTC 时戳」生成,撞名顺延,**绝不覆盖**上一份。
|
||||
BackupWorld {
|
||||
/// 实例 id
|
||||
id: InstanceId,
|
||||
/// 磁盘目录名
|
||||
dir: String,
|
||||
/// 导出到哪个父目录(`None` = 启动器自己的 exports)
|
||||
dest: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 一次写操作成功后的事实(**不含人读文案**:文案由 app 按 i18n key 现求)。
|
||||
///
|
||||
/// 为什么成功也要有回执:重命名 / 删除的结果在列表上自己会说话(行改名、行消失),
|
||||
/// 而**备份成功没有话可说**——zip 落到了文件系统某处,用户必须知道完整路径 + 字节数,
|
||||
/// 否则「点了备份然后什么也没发生」就是一次静默失败。
|
||||
///
|
||||
/// 回执挂在快照上([`SavesListView::last_op`]):下一次普通 `List` 会让它消失,
|
||||
/// 所以它表达的是「最近这一次操作」,不是一段历史。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SavesOped {
|
||||
/// 改名成功。
|
||||
Renamed {
|
||||
/// 原磁盘名
|
||||
from: String,
|
||||
/// 新磁盘名
|
||||
to: String,
|
||||
},
|
||||
/// 删除成功。
|
||||
Deleted {
|
||||
/// 被删掉的磁盘名
|
||||
dir: String,
|
||||
},
|
||||
/// 备份成功。
|
||||
BackedUp {
|
||||
/// 被备份的世界(磁盘名)
|
||||
dir: String,
|
||||
/// 落点文件名
|
||||
file: String,
|
||||
/// 落点完整路径
|
||||
path: String,
|
||||
/// zip 的字节数
|
||||
bytes: u64,
|
||||
/// 被跳过的符号链接数(不跟随;0 = 一个都没跳)
|
||||
skipped: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// 一个世界目录。
|
||||
@@ -125,6 +202,8 @@ pub struct SavesListView {
|
||||
pub screenshots: Vec<ScreenshotView>,
|
||||
/// 跳过的条目(UI 只需计数 + 一句"为什么")。
|
||||
pub skipped: Vec<SavesSkipView>,
|
||||
/// 最近一次写操作的回执(`None` = 这一拍只是列举,没写东西)。
|
||||
pub last_op: Option<SavesOped>,
|
||||
}
|
||||
|
||||
impl SavesListView {
|
||||
@@ -158,13 +237,31 @@ impl SavesListView {
|
||||
pub struct SavesFailureView {
|
||||
/// 实例 id(迟到/串台的结果靠它判)。
|
||||
pub instance: InstanceId,
|
||||
/// 涉及的那个世界(写操作才有;读路径给空串)。
|
||||
///
|
||||
/// 为什么要这一格:失败那句话是「重命名 **A** 失败:同名世界已存在」——没有名字,
|
||||
/// 用户不知道该改哪一行(与 `ModFailureView::file` 同一条理由)。
|
||||
pub file: String,
|
||||
/// 这是哪一类**写**操作(`None` = 读路径 / 打开文件夹的失败)。
|
||||
pub op: Option<SavesFailureOp>,
|
||||
/// 失败大类。
|
||||
pub kind: SavesFailureKind,
|
||||
/// 底层原文(OS 错误 / 路径细节);只进括号,不进主句。
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// 失败大类。
|
||||
/// 失败出自哪一类写操作(决定失败那句主句用哪个动词)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SavesFailureOp {
|
||||
/// 重命名。
|
||||
Rename,
|
||||
/// 删除。
|
||||
Delete,
|
||||
/// 备份。
|
||||
Backup,
|
||||
}
|
||||
|
||||
/// 失败大类(**每一种在 UI 上都有一句话**,没有静默失败)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SavesFailureKind {
|
||||
/// 实例不在了(快照里找不到),或隔离档位派生不出运行目录。
|
||||
@@ -173,82 +270,28 @@ pub enum SavesFailureKind {
|
||||
DirUnusable,
|
||||
/// 「打开文件夹」失败(目录不存在 / 拉不起文件管理器)。
|
||||
OpenFailed,
|
||||
/// 写操作:名字过不了规则(空、`.`、`..`、含 `/` `\` `:` NUL)。
|
||||
WriteUnsafeName,
|
||||
/// 写操作:目标或路径上的一段是符号链接(不跟随)。
|
||||
WriteSymlink,
|
||||
/// 写操作:那个世界(或 `saves/`)已经不在了。
|
||||
WriteMissing,
|
||||
/// 写操作:那个名字底下不是目录。
|
||||
WriteNotADirectory,
|
||||
/// 写操作:同名世界已存在 / 备份文件名撞满。
|
||||
WriteConflict,
|
||||
/// 写操作:路径落在 `saves/` 之外(含层级异常深)。
|
||||
WriteBoundary,
|
||||
/// 写操作:备份超过体积或条目上限。
|
||||
WriteTooLarge,
|
||||
/// 写操作:实例正在启动/运行中,**一个字节都没碰盘**(见 `host::saves::dispatch`)。
|
||||
///
|
||||
/// 单独一类而不是折进 [`WriteFailed`](Self::WriteFailed):Linux 上删一个正在跑的世界**会成功**,
|
||||
/// 所以这不是"写盘失败了",是"我们主动没写"。界面必须说「游戏正在运行,先退出再操作」。
|
||||
GameRunning,
|
||||
/// 写操作:其它系统错误(权限 / 被别的进程占用),OS 原文进 `detail`。
|
||||
WriteFailed,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn view() -> SavesListView {
|
||||
SavesListView {
|
||||
instance: "inst".to_string(),
|
||||
dir: "/tmp/run/saves".to_string(),
|
||||
exists: true,
|
||||
worlds: vec![WorldView {
|
||||
dir: "World".to_string(),
|
||||
icon: true,
|
||||
modified_ms: Some(1_700_000_000_000),
|
||||
}],
|
||||
screenshots_dir: "/tmp/run/screenshots".to_string(),
|
||||
screenshots_exists: true,
|
||||
screenshots: vec![ScreenshotView {
|
||||
file: "2024-05-01_10.00.00.png".to_string(),
|
||||
bytes: 1024,
|
||||
modified_ms: None,
|
||||
}],
|
||||
skipped: vec![SavesSkipView {
|
||||
area: SavesArea::Screenshots,
|
||||
file: "notes.txt".to_string(),
|
||||
reason: SavesSkipKind::NotAnImage,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
/// 计数与 IPC round-trip(AGENTS §6:跨界类型必须能序列化)。
|
||||
#[test]
|
||||
fn view_counts_and_round_trips() {
|
||||
let view = view();
|
||||
assert_eq!(view.world_count(), 1);
|
||||
assert_eq!(view.screenshot_count(), 1);
|
||||
assert_eq!(view.skipped_count(), 1);
|
||||
assert!(!view.is_empty());
|
||||
|
||||
let text = serde_json::to_string(&view).expect("序列化");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<SavesListView>(&text).expect("反序列化"),
|
||||
view
|
||||
);
|
||||
}
|
||||
|
||||
/// 两条指令都要能过 IPC(含「打开文件夹」——它的载荷只有实例 id)。
|
||||
#[test]
|
||||
fn commands_round_trip() {
|
||||
for command in [
|
||||
SavesCommand::List("inst".to_string()),
|
||||
SavesCommand::OpenFolder {
|
||||
id: "inst".to_string(),
|
||||
},
|
||||
] {
|
||||
let text = serde_json::to_string(&command).expect("序列化");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<SavesCommand>(&text).expect("反序列化"),
|
||||
command
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 两个目录都不存在 ⇒ 空(`is_empty` 只看内容,不看 `exists`:一个目录有空世界也算非空)。
|
||||
#[test]
|
||||
fn empty_means_no_worlds_and_no_screenshots() {
|
||||
let mut view = view();
|
||||
view.worlds.clear();
|
||||
view.screenshots.clear();
|
||||
assert!(view.is_empty(), "没有世界也没有截图就该算空");
|
||||
view.screenshots.push(ScreenshotView {
|
||||
file: "a.png".to_string(),
|
||||
bytes: 0,
|
||||
modified_ms: None,
|
||||
});
|
||||
assert!(!view.is_empty());
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -0,0 +1,126 @@
|
||||
//! 存档 IPC 面的用例:**IPC round-trip**(生成物必须能反序列化回来)。
|
||||
|
||||
use super::*;
|
||||
|
||||
fn view() -> SavesListView {
|
||||
SavesListView {
|
||||
instance: "inst".to_string(),
|
||||
dir: "/tmp/run/saves".to_string(),
|
||||
exists: true,
|
||||
worlds: vec![WorldView {
|
||||
dir: "World".to_string(),
|
||||
icon: true,
|
||||
modified_ms: Some(1_700_000_000_000),
|
||||
}],
|
||||
screenshots_dir: "/tmp/run/screenshots".to_string(),
|
||||
screenshots_exists: true,
|
||||
screenshots: vec![ScreenshotView {
|
||||
file: "2024-05-01_10.00.00.png".to_string(),
|
||||
bytes: 1024,
|
||||
modified_ms: None,
|
||||
}],
|
||||
skipped: vec![SavesSkipView {
|
||||
area: SavesArea::Screenshots,
|
||||
file: "notes.txt".to_string(),
|
||||
reason: SavesSkipKind::NotAnImage,
|
||||
}],
|
||||
last_op: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 计数与 IPC round-trip(AGENTS §6:跨界类型必须能序列化)。
|
||||
#[test]
|
||||
fn view_counts_and_round_trips() {
|
||||
let view = view();
|
||||
assert_eq!(view.world_count(), 1);
|
||||
assert_eq!(view.screenshot_count(), 1);
|
||||
assert_eq!(view.skipped_count(), 1);
|
||||
assert!(!view.is_empty());
|
||||
|
||||
let text = serde_json::to_string(&view).expect("序列化");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<SavesListView>(&text).expect("反序列化"),
|
||||
view
|
||||
);
|
||||
}
|
||||
|
||||
/// 五条指令都要能过 IPC(含写操作三条——它们带世界名,名字里可能有空格与中文)。
|
||||
#[test]
|
||||
fn commands_round_trip() {
|
||||
for command in [
|
||||
SavesCommand::List("inst".to_string()),
|
||||
SavesCommand::OpenFolder {
|
||||
id: "inst".to_string(),
|
||||
},
|
||||
SavesCommand::RenameWorld {
|
||||
id: "inst".to_string(),
|
||||
dir: "Better World".to_string(),
|
||||
to: "更好的世界".to_string(),
|
||||
},
|
||||
SavesCommand::DeleteWorld {
|
||||
id: "inst".to_string(),
|
||||
dir: "Better World".to_string(),
|
||||
},
|
||||
SavesCommand::BackupWorld {
|
||||
id: "inst".to_string(),
|
||||
dir: "Better World".to_string(),
|
||||
dest: Some("D:/backups".to_string()),
|
||||
},
|
||||
SavesCommand::BackupWorld {
|
||||
id: "inst".to_string(),
|
||||
dir: "Better World".to_string(),
|
||||
dest: None,
|
||||
},
|
||||
] {
|
||||
let text = serde_json::to_string(&command).expect("序列化");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<SavesCommand>(&text).expect("反序列化"),
|
||||
command
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 成功回执也要能过 IPC(备份那一支字段最多:路径 / 字节 / 跳过数)。
|
||||
#[test]
|
||||
fn op_receipts_round_trip() {
|
||||
let cases = [
|
||||
SavesOped::Renamed {
|
||||
from: "Old".to_string(),
|
||||
to: "New".to_string(),
|
||||
},
|
||||
SavesOped::Deleted {
|
||||
dir: "Old".to_string(),
|
||||
},
|
||||
SavesOped::BackedUp {
|
||||
dir: "Old".to_string(),
|
||||
file: "Old-20240501-100000.zip".to_string(),
|
||||
path: "D:/exports/Old-20240501-100000.zip".to_string(),
|
||||
bytes: 4096,
|
||||
skipped: 2,
|
||||
},
|
||||
];
|
||||
for op in cases {
|
||||
let mut view = view();
|
||||
view.last_op = Some(op);
|
||||
let text = serde_json::to_string(&view).expect("序列化");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<SavesListView>(&text).expect("反序列化"),
|
||||
view
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 两个目录都不存在 ⇒ 空(`is_empty` 只看内容,不看 `exists`:一个目录有空世界也算非空)。
|
||||
#[test]
|
||||
fn empty_means_no_worlds_and_no_screenshots() {
|
||||
let mut view = view();
|
||||
view.worlds.clear();
|
||||
view.screenshots.clear();
|
||||
assert!(view.is_empty(), "没有世界也没有截图就该算空");
|
||||
view.screenshots.push(ScreenshotView {
|
||||
file: "a.png".to_string(),
|
||||
bytes: 0,
|
||||
modified_ms: None,
|
||||
});
|
||||
assert!(!view.is_empty());
|
||||
}
|
||||
@@ -290,10 +290,17 @@ impl CoreHost {
|
||||
let running = self.in_flight(&id);
|
||||
mods::dispatch(self.env.clone(), self.tx.clone(), *command, running);
|
||||
}
|
||||
// M235:实例存档 / 截图的只读列表 + 打开文件夹
|
||||
// (只读,所以不必问"游戏在不在跑";拉起文件管理器在 core 的 platform opener 里)
|
||||
// M235:实例存档 / 截图的只读列表 + 打开文件夹;M247 加了三个写操作。
|
||||
//
|
||||
// **写操作要看"游戏在不在跑"**(M247 补齐,与 `AppCommand::Mods` 同款判据):
|
||||
// Windows 上进程占着世界目录 ⇒ rename/删除会撞共享冲突;**Linux 上删一个正在跑的世界
|
||||
// 会成功**——那是在用户正在玩的时候把存档删掉。所以判据放在 host 这一侧、落 core 之前,
|
||||
// 在飞就回一条结构化失败「游戏正在运行,先退出再操作」,一个字节都不碰盘。
|
||||
// 读路径与「打开文件夹」不受影响(它们不改盘)。
|
||||
AppCommand::Saves(command) => {
|
||||
saves::dispatch(self.env.clone(), self.tx.clone(), *command);
|
||||
let id = saves::instance_of(&command).to_string();
|
||||
let running = self.in_flight(&id);
|
||||
saves::dispatch(self.env.clone(), self.tx.clone(), *command, running);
|
||||
}
|
||||
// AI 对话:agent 的 GUI host 未落地前用脚本回复(不接真实模型)
|
||||
other @ (AppCommand::AskAgent(_) | AppCommand::StopAgent | AppCommand::SetYolo(_)) => {
|
||||
|
||||
@@ -1,13 +1,42 @@
|
||||
//! 实例存档 / 截图的宿主实现(M235 · STOREMVP)。
|
||||
//! 实例存档 / 截图的宿主实现(M235 · STOREMVP 读路径 + M247 · SAVESOPS 写路径)。
|
||||
//!
|
||||
//! ## 三条纪律
|
||||
//! ## 四条纪律
|
||||
//!
|
||||
//! | 纪律 | 落点 |
|
||||
//! |---|---|
|
||||
//! | 磁盘只在后台线程碰 | [`spawn_list`] / [`spawn_open_folder`] 走 `job::spawn`,UI 只收快照 |
|
||||
//! | 磁盘只在后台线程碰 | [`spawn_list`] / [`spawn_open_folder`] / [`spawn_rename`] / [`spawn_delete`] / [`spawn_backup`] 全走 `job::spawn`,UI 只收快照 |
|
||||
//! | 单根语义(D20) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生、不做双根合并 |
|
||||
//! | 只读 | 本模块一次写盘都没有;`saves/` 不存在也不替玩家建(core 的 `saves::scan` 同理) |
|
||||
//! | 读路径只读 | [`list`] 一次写盘都没有;`saves/` 不存在也不替玩家建(core 的 `saves::scan` 同理) |
|
||||
//! | 写路径自己重校边界 | 三个写操作**不在 host 这边信任自己**:core 的 `saves::write` 每次重新校验 `run_dir` 与目标路径(越界 / 符号链接 / 撞名都在那里拒) |
|
||||
//! | 游戏在跑就不写 | [`dispatch`] 的 `running` 为真时三条写命令**不 spawn、不碰盘**,直接回 [`crate::dto::SavesFailureKind::GameRunning`] |
|
||||
//!
|
||||
//! ## 三个写操作的回程形状
|
||||
//!
|
||||
//! | 操作 | 成功 | 失败 |
|
||||
//! |---|---|---|
|
||||
//! | 重命名 | 新快照 + [`SavesOped::Renamed`] | 具名 [`SavesFailureKind`](撞名 / 名字不可用 / 越界 / 链接 / IO) |
|
||||
//! | 删除 | 新快照 + [`SavesOped::Deleted`] | 同上 |
|
||||
//! | 备份 | 新快照 + [`SavesOped::BackedUp`](落点路径 / 字节数 / 跳过的链接数) | 同上 + `TooLarge` |
|
||||
//!
|
||||
//! **写成功后一律重新列一遍**(`snapshot`):界面画的永远是磁盘上的真相,不是"我以为我改成了什么"。
|
||||
//! 回执只是"这次动的是哪一条",与快照一起回去,所以 UI 不会出现「列表变了但不知道谁变的」。
|
||||
//!
|
||||
//! ## 「游戏在跑」为什么挡在 host 这一侧(M247)
|
||||
//!
|
||||
//! 判据是 `CoreHost::in_flight(&id)`(与 `AppCommand::Mods` 同一个),由 `host::dispatch` 传进来。
|
||||
//! 为真时 [`dispatch`] **不 spawn 线程**、直接发一条 [`SavesFailureKind::GameRunning`] 的失败回去:
|
||||
//!
|
||||
//! | 平台 | 不挡的后果 |
|
||||
//! |---|---|
|
||||
//! | Windows | 进程占着世界目录 ⇒ rename/删除撞共享冲突 ⇒ 用户读一句 `os error 32` |
|
||||
//! | **Linux** | **删一个正在跑的世界会成功** —— 用户在玩到一半时丢了存档 |
|
||||
//!
|
||||
//! 所以这不是"说得更清楚",是**安全性**。也不能只靠界面灰按钮:判据留在 host,UI 那一侧只是
|
||||
//! 尽力而为的提示(本版没加"灰按钮"的形态,理由是快照里没有 `running` 字段——加它要动列表 DTO,
|
||||
//! 而这一版已经有那条明确的失败句子兜住)。
|
||||
//!
|
||||
//! 读路径([`SavesCommand::List`])与「打开文件夹」**不受影响**:它们不改盘。
|
||||
|
||||
//! ## 为什么不复用启动路径的 `run_dir()`
|
||||
//!
|
||||
//! 与 [`super::mods`] 同一条理由:`host::launch` 的 `run_dir()` 顺带把「这次用哪个档位」写进实例
|
||||
@@ -22,10 +51,18 @@
|
||||
//! 真正拉起文件管理器的是 [`unlucky_core::platform::opener::open_dir`](`AGENTS.md` §6:
|
||||
//! 平台差异只许收敛在 `core::platform`,本文件一个 `cfg` 都没有,也没有一次裸 spawn)。
|
||||
//!
|
||||
//! ## 备份落点为什么是启动器自己的 `exports/`
|
||||
//!
|
||||
//! 本仓没有 `rfd` 这类原生文件选择框依赖(`core::launch::crash_bundle::default_target_dir` 的
|
||||
//! 文档里记着同一条理由),所以「用户选定目录」先退化成:`<data>/exports/saves/`。
|
||||
//! 命令带一个可选的 `dest`,将来接了选择框、或 CLI 想指定别处,都不用改 core。
|
||||
//! 成功的回执里带**完整路径 + 字节数**,用户不用猜落点。
|
||||
//!
|
||||
//! ## 失败口径
|
||||
//!
|
||||
//! 一律走 [`crate::dto::SavesFailureView`](结构 + OS 原文),**不拼中文**:文案由 app 层按
|
||||
//! i18n key 现求(M161 的纪律)。三类失败见 [`crate::dto::SavesFailureKind`]。
|
||||
//! i18n key 现求(M161 的纪律)。读路径三类失败见 [`crate::dto::SavesFailureKind`],
|
||||
//! 写路径八类见同一个枚举的 `Write*` 那一族——每一种在 UI 上都有一句话。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -35,13 +72,16 @@ use unlucky_core::game::saves::{self, SavesError, SavesScan};
|
||||
use unlucky_core::platform::opener;
|
||||
|
||||
use crate::dto::{
|
||||
locate, InstanceId, InstanceSummary, SavesArea, SavesCommand, SavesFailureKind,
|
||||
SavesFailureView, SavesListView, SavesSkipKind, SavesSkipView, ScreenshotView, WorldView,
|
||||
locate, InstanceId, InstanceSummary, SavesArea, SavesCommand, SavesFailureKind, SavesFailureOp,
|
||||
SavesFailureView, SavesListView, SavesOped, SavesSkipKind, SavesSkipView, ScreenshotView,
|
||||
WorldView,
|
||||
};
|
||||
|
||||
use super::env::HostEnv;
|
||||
use super::job::{spawn, JobSender};
|
||||
|
||||
mod write;
|
||||
|
||||
/// 后台线程回报的一次结果(`HostTask::Saves` 的载荷)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Report {
|
||||
@@ -76,19 +116,86 @@ pub fn spawn_open_folder(env: HostEnv, tx: JobSender, id: InstanceId) {
|
||||
});
|
||||
}
|
||||
|
||||
/// 一条指令属于哪个实例(host 侧算「游戏在不在跑」要用它)。
|
||||
///
|
||||
/// 五条命令都带实例 id,所以这里不返回 `Option`:少一条分支处理会让闸门在那条命令上
|
||||
/// **静默失效**,而这正是 M247 要修的东西。
|
||||
#[must_use]
|
||||
pub fn instance_of(command: &SavesCommand) -> &str {
|
||||
match command {
|
||||
SavesCommand::List(id)
|
||||
| SavesCommand::OpenFolder { id }
|
||||
| SavesCommand::RenameWorld { id, .. }
|
||||
| SavesCommand::DeleteWorld { id, .. }
|
||||
| SavesCommand::BackupWorld { id, .. } => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// 这条命令是不是**改盘**的写操作(读路径与打开文件夹不是)。
|
||||
fn write_target(command: &SavesCommand) -> Option<(&str, SavesFailureOp)> {
|
||||
match command {
|
||||
SavesCommand::RenameWorld { dir, .. } => Some((dir, SavesFailureOp::Rename)),
|
||||
SavesCommand::DeleteWorld { dir, .. } => Some((dir, SavesFailureOp::Delete)),
|
||||
SavesCommand::BackupWorld { dir, .. } => Some((dir, SavesFailureOp::Backup)),
|
||||
SavesCommand::List(_) | SavesCommand::OpenFolder { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条指令 → 后台线程(`CoreHost::dispatch` 的唯一入口)。
|
||||
pub fn dispatch(env: HostEnv, tx: JobSender, command: SavesCommand) {
|
||||
///
|
||||
/// `running` = 这个实例正在启动/运行中(`CoreHost::in_flight`)。为真时**三个写操作不落 core**:
|
||||
/// 直接回一条 [`SavesFailureKind::GameRunning`] 的具名失败,一个字节都不碰盘。
|
||||
///
|
||||
/// 为什么这一层必须挡:Windows 上进程占着世界目录,rename/删除会撞共享冲突(难看但至少失败);
|
||||
/// **Linux 上删一个正在跑的世界会成功**——那是在用户玩到一半时把存档删掉。所以判据不能只靠
|
||||
/// 界面灰按钮(那也是同一条纪律:`ModsCommand::SetEnabled` 同样挡在 host 这一侧)。
|
||||
///
|
||||
/// 不 spawn 线程:没有磁盘工作要排队,直接发一条结果回去更诚实(`tx` 是同步通道)。
|
||||
pub fn dispatch(env: HostEnv, tx: JobSender, command: SavesCommand, running: bool) {
|
||||
if running {
|
||||
if let Some((dir, op)) = write_target(&command) {
|
||||
let id = instance_of(&command);
|
||||
let report = Report {
|
||||
instance: id.to_string(),
|
||||
list: Err(blocked(id, dir, op)),
|
||||
};
|
||||
let _ = tx.send(super::HostTask::Saves(Box::new(report)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
match command {
|
||||
SavesCommand::List(id) => spawn_list(env, tx, id),
|
||||
SavesCommand::OpenFolder { id } => spawn_open_folder(env, tx, id),
|
||||
SavesCommand::RenameWorld { id, dir, to } => write::spawn_rename(env, tx, id, dir, to),
|
||||
SavesCommand::DeleteWorld { id, dir } => write::spawn_delete(env, tx, id, dir),
|
||||
SavesCommand::BackupWorld { id, dir, dest } => write::spawn_backup(env, tx, id, dir, dest),
|
||||
}
|
||||
}
|
||||
|
||||
/// 写操作被「游戏在跑」挡下时的那条失败(**结构化,带世界名与操作**,文案由 app 层现求)。
|
||||
fn blocked(instance: &str, dir: &str, op: SavesFailureOp) -> SavesFailureView {
|
||||
let mut failure = failure(instance, SavesFailureKind::GameRunning, String::new());
|
||||
failure.file = dir.to_string();
|
||||
failure.op = Some(op);
|
||||
failure
|
||||
}
|
||||
|
||||
/// 列目录(后台线程里调)。
|
||||
fn list(env: &HostEnv, id: &str) -> Result<SavesListView, SavesFailureView> {
|
||||
let (instance, run_dir) = target(env, id)?;
|
||||
let scan = saves::scan(&run_dir).map_err(|error| unusable(&instance.id, &error))?;
|
||||
Ok(view(&instance, &scan))
|
||||
snapshot(&instance, &run_dir, None)
|
||||
}
|
||||
|
||||
/// 重新扫一遍目录 → 快照(可挂一条写操作的回执)。
|
||||
///
|
||||
/// 写成功后**一律**走这里:界面画的是磁盘上的真相,不是"我以为我改成了什么"。
|
||||
pub(super) 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))
|
||||
}
|
||||
|
||||
/// 打开存档目录 → 再列一遍(失败只说"打不开",原文进括号)。
|
||||
@@ -98,7 +205,7 @@ fn open_folder(env: &HostEnv, id: &str) -> Result<SavesListView, SavesFailureVie
|
||||
let target = open_target(&scan, &run_dir);
|
||||
opener::open_dir(&target)
|
||||
.map_err(|detail| failure(&instance.id, SavesFailureKind::OpenFailed, detail))?;
|
||||
Ok(view(&instance, &scan))
|
||||
Ok(view(&instance.id, &scan, None))
|
||||
}
|
||||
|
||||
/// 打开哪一个目录:`saves/` → `screenshots/` → 运行目录(取第一个存在的)。
|
||||
@@ -113,7 +220,10 @@ fn open_target(scan: &SavesScan, run_dir: &std::path::Path) -> PathBuf {
|
||||
}
|
||||
|
||||
/// 实例 + 它的运行目录(找不到实例 / 派生不出目录都给具名失败)。
|
||||
fn target(env: &HostEnv, id: &str) -> Result<(InstanceSummary, PathBuf), SavesFailureView> {
|
||||
pub(super) fn target(
|
||||
env: &HostEnv,
|
||||
id: &str,
|
||||
) -> Result<(InstanceSummary, PathBuf), SavesFailureView> {
|
||||
let instances = super::game::instances(env);
|
||||
let Some(instance) = locate(&instances, id) else {
|
||||
return Err(failure(
|
||||
@@ -128,7 +238,7 @@ fn target(env: &HostEnv, id: &str) -> Result<(InstanceSummary, PathBuf), SavesFa
|
||||
}
|
||||
|
||||
/// 实例的运行目录(D20 覆盖链,与启动同一份派生;**不写盘**)。
|
||||
fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String> {
|
||||
pub(super) fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String> {
|
||||
let global = super::game::global_mode(env);
|
||||
let config = read_instance_config(&env.layout, &instance.id)
|
||||
.map_err(|error| format!("读实例设置失败:{error}"))?;
|
||||
@@ -144,9 +254,9 @@ fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String>
|
||||
}
|
||||
|
||||
/// 快照映射:core 的扫描结果 → DTO(路径串在 host 这一侧才成形)。
|
||||
fn view(instance: &InstanceSummary, scan: &SavesScan) -> SavesListView {
|
||||
fn view(instance: &str, scan: &SavesScan, last_op: Option<SavesOped>) -> SavesListView {
|
||||
SavesListView {
|
||||
instance: instance.id.clone(),
|
||||
instance: instance.to_string(),
|
||||
dir: scan.dir.display().to_string(),
|
||||
exists: scan.exists,
|
||||
worlds: scan
|
||||
@@ -188,12 +298,19 @@ fn view(instance: &InstanceSummary, scan: &SavesScan) -> SavesListView {
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
last_op,
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(instance: &str, kind: SavesFailureKind, detail: impl Into<String>) -> SavesFailureView {
|
||||
pub(super) fn failure(
|
||||
instance: &str,
|
||||
kind: SavesFailureKind,
|
||||
detail: impl Into<String>,
|
||||
) -> SavesFailureView {
|
||||
SavesFailureView {
|
||||
instance: instance.to_string(),
|
||||
file: String::new(),
|
||||
op: None,
|
||||
kind,
|
||||
detail: detail.into(),
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
//! 抓不住什么:`OpenFolder` **不在这里真跑**(它会拉起用户的文件管理器);它唯一的判断
|
||||
//! (打开哪个目录)由 [`open_target_degrades_from_saves_to_run_dir`] 这条纯函数用例钉住,
|
||||
//! 真正 spawn 的那一步在 core 的 `platform::opener` 用例里。
|
||||
//!
|
||||
//! 「游戏在跑就拒」(M247)由 [`write_commands_do_not_touch_the_disk_while_the_game_is_running`]
|
||||
//! 从 **`dispatch` 那一层**验:三条写命令一条都不落 core(盘上指纹前后一致 + 备份落点没被建),
|
||||
//! 并且回一条 `GameRunning` 的具名失败。
|
||||
|
||||
use super::*;
|
||||
use crate::dto::{InstanceRuntime, LaunchArgsView, LoaderInfo, LoaderKind};
|
||||
@@ -73,6 +77,12 @@ fn instance() -> InstanceSummary {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个真世界(`saves/<name>/level.dat`)。
|
||||
fn world(run: &std::path::Path, name: &str) {
|
||||
std::fs::create_dir_all(run.join("saves").join(name)).expect("建世界");
|
||||
std::fs::write(run.join("saves").join(name).join("level.dat"), b"nbt").expect("写 level.dat");
|
||||
}
|
||||
|
||||
/// **端到端**(真盘 + 真派生):世界 / 截图 / 跳过原因都过桥,且快照里的路径就是派生目录。
|
||||
///
|
||||
/// 抓得住:派生用错目录、`icon.png` 被当成"读到了"、跳过原因被折叠、字节数丢了。
|
||||
@@ -194,6 +204,44 @@ fn every_core_skip_reason_keeps_its_own_kind() {
|
||||
reason,
|
||||
}],
|
||||
};
|
||||
assert_eq!(view(&instance(), &scan).skipped[0].reason, expected);
|
||||
assert_eq!(view(ID, &scan, None).skipped[0].reason, expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// `(相对路径, 是否目录, 字节数, mtime 毫秒)` 的排序列表:「这一拍有没有写盘」的判据本体。
|
||||
fn fingerprint(root: &std::path::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(std::time::SystemTime::UNIX_EPOCH).ok())
|
||||
.map_or(0, |since| since.as_millis())
|
||||
));
|
||||
if meta.is_dir() {
|
||||
stack.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
mod write;
|
||||
@@ -0,0 +1,341 @@
|
||||
//! 三个**写操作**与「游戏在跑」闸门的用例(M247)——从 `host/saves/tests.rs` 拆出
|
||||
//! (那边加完 7 条之后越过 400 行硬线;判据一条不改)。
|
||||
//!
|
||||
//! | 用例 | 抓得住 |
|
||||
//! |---|---|
|
||||
//! | [`rename_round_trips_on_a_real_root`] / [`delete_round_trips_on_a_real_root`] | 真盘改动 + 快照刷新 + 回执对得上 |
|
||||
//! | [`backup_round_trips_to_the_exports_dir`] | zip 落到 `<data>/exports/saves/`,回执带路径/字节/跳过数;显式落点也照用 |
|
||||
//! | [`every_write_failure_keeps_its_own_kind`] | core 八类 → DTO 八类一一对应(不折叠),带世界名与操作 |
|
||||
//! | [`write_commands_do_not_touch_the_disk_while_the_game_is_running`] | **在飞时一条都不落 core**:盘上指纹不变、世界还在、备份落点没建、回 `GameRunning` |
|
||||
//! | [`write_commands_run_when_the_game_is_not_running`] | 闸门没有把写操作一并关死 |
|
||||
//! | [`read_commands_are_not_blocked_while_the_game_is_running`] | 读路径与打开文件夹不受影响 |
|
||||
//! | [`every_command_names_its_instance`] | 五条命令都答得上「哪个实例」(闸门靠它算在不在跑) |
|
||||
|
||||
use super::super::write::{backup, delete, rename, write_failure};
|
||||
use super::super::*;
|
||||
use super::{fingerprint, fixture, world, ID};
|
||||
use crate::dto::{SavesCommand, SavesFailureOp, SavesFailureView, SavesOped};
|
||||
use unlucky_core::game::saves::SavesWriteKind;
|
||||
|
||||
/// **端到端**:重命名 → 真盘改名 + 新快照 + 回执。
|
||||
///
|
||||
/// 抓得住: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 = unlucky_core::game::saves::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));
|
||||
}
|
||||
}
|
||||
|
||||
/// **在飞闸门**:三条写命令一条都不落 core —— 盘上指纹前后一致、世界还在、备份落点没被建,
|
||||
/// 而且回的是 `GameRunning`(不是"写盘失败")。
|
||||
///
|
||||
/// 抓得住:把闸门放回 UI(只灰按钮)——那时 `dispatch(..., running = true)` 会真的删掉世界,
|
||||
/// 这条用例立刻红(指纹变了 / 世界不在 / `kind` 不是 `GameRunning`)。
|
||||
#[test]
|
||||
fn write_commands_do_not_touch_the_disk_while_the_game_is_running() {
|
||||
let (_root, env, run) = fixture("saves-running");
|
||||
world(&run, "Doomed");
|
||||
let before = fingerprint(&run);
|
||||
let (tx, rx) = super::super::super::job::channel();
|
||||
|
||||
let commands = [
|
||||
SavesCommand::RenameWorld {
|
||||
id: ID.to_string(),
|
||||
dir: "Doomed".to_string(),
|
||||
to: "Renamed".to_string(),
|
||||
},
|
||||
SavesCommand::DeleteWorld {
|
||||
id: ID.to_string(),
|
||||
dir: "Doomed".to_string(),
|
||||
},
|
||||
SavesCommand::BackupWorld {
|
||||
id: ID.to_string(),
|
||||
dir: "Doomed".to_string(),
|
||||
dest: None,
|
||||
},
|
||||
];
|
||||
for command in commands {
|
||||
let expected = match &command {
|
||||
SavesCommand::RenameWorld { .. } => SavesFailureOp::Rename,
|
||||
SavesCommand::DeleteWorld { .. } => SavesFailureOp::Delete,
|
||||
_ => SavesFailureOp::Backup,
|
||||
};
|
||||
dispatch(env.clone(), tx.clone(), command, true);
|
||||
let task = rx
|
||||
.recv_timeout(std::time::Duration::from_secs(5))
|
||||
.expect("在飞时 host 没有回结果");
|
||||
let crate::host::HostTask::Saves(report) = task else {
|
||||
panic!("回程不是存档事件");
|
||||
};
|
||||
assert_eq!(report.instance, ID);
|
||||
let failure = report.list.expect_err("在飞时居然写成功了");
|
||||
assert_eq!(
|
||||
failure.kind,
|
||||
SavesFailureKind::GameRunning,
|
||||
"{}",
|
||||
failure.detail
|
||||
);
|
||||
assert_eq!(failure.file, "Doomed", "失败那句话要指名哪一行");
|
||||
assert_eq!(failure.op, Some(expected));
|
||||
}
|
||||
|
||||
assert_eq!(fingerprint(&run), before, "在飞时写了盘");
|
||||
assert!(
|
||||
run.join("saves/Doomed/level.dat").exists(),
|
||||
"世界被删/被改名了"
|
||||
);
|
||||
assert!(
|
||||
!env.layout.data().join("exports").exists(),
|
||||
"在飞时备份落了盘"
|
||||
);
|
||||
}
|
||||
|
||||
/// 反向:不在飞时同一条 `dispatch` 该真的干(闸门不是把写操作一并关死)。
|
||||
#[test]
|
||||
fn write_commands_run_when_the_game_is_not_running() {
|
||||
let (_root, env, run) = fixture("saves-idle-dispatch");
|
||||
world(&run, "Doomed");
|
||||
let (tx, rx) = super::super::super::job::channel();
|
||||
|
||||
dispatch(
|
||||
env.clone(),
|
||||
tx.clone(),
|
||||
SavesCommand::DeleteWorld {
|
||||
id: ID.to_string(),
|
||||
dir: "Doomed".to_string(),
|
||||
},
|
||||
false,
|
||||
);
|
||||
let task = rx
|
||||
.recv_timeout(std::time::Duration::from_secs(5))
|
||||
.expect("host 没有回结果");
|
||||
let crate::host::HostTask::Saves(report) = task else {
|
||||
panic!("回程不是存档事件");
|
||||
};
|
||||
let listed = report.list.expect("不在飞时该照常删");
|
||||
assert!(!run.join("saves/Doomed").exists(), "闸门把写操作一并关死了");
|
||||
assert!(matches!(listed.last_op, Some(SavesOped::Deleted { .. })));
|
||||
}
|
||||
|
||||
/// 读路径**不受**在飞闸门影响(它不改盘)。
|
||||
#[test]
|
||||
fn read_commands_are_not_blocked_while_the_game_is_running() {
|
||||
let (_root, env, run) = fixture("saves-running-read");
|
||||
world(&run, "Kept");
|
||||
let (tx, rx) = super::super::super::job::channel();
|
||||
|
||||
dispatch(
|
||||
env.clone(),
|
||||
tx.clone(),
|
||||
SavesCommand::List(ID.to_string()),
|
||||
true,
|
||||
);
|
||||
let task = rx
|
||||
.recv_timeout(std::time::Duration::from_secs(5))
|
||||
.expect("host 没有回结果");
|
||||
let crate::host::HostTask::Saves(report) = task else {
|
||||
panic!("回程不是存档事件");
|
||||
};
|
||||
let listed = report.list.expect("在飞时读列表不该被拒");
|
||||
assert_eq!(listed.world_count(), 1);
|
||||
assert!(listed.last_op.is_none());
|
||||
}
|
||||
|
||||
/// 每一层都认得同一个实例 id(闸门用它算「在不在跑」——漏一条分支就等于那条命令没有闸门)。
|
||||
#[test]
|
||||
fn every_command_names_its_instance() {
|
||||
let commands = [
|
||||
SavesCommand::List(ID.to_string()),
|
||||
SavesCommand::OpenFolder { id: ID.to_string() },
|
||||
SavesCommand::RenameWorld {
|
||||
id: ID.to_string(),
|
||||
dir: "A".to_string(),
|
||||
to: "B".to_string(),
|
||||
},
|
||||
SavesCommand::DeleteWorld {
|
||||
id: ID.to_string(),
|
||||
dir: "A".to_string(),
|
||||
},
|
||||
SavesCommand::BackupWorld {
|
||||
id: ID.to_string(),
|
||||
dir: "A".to_string(),
|
||||
dest: None,
|
||||
},
|
||||
];
|
||||
assert_eq!(commands.len(), 5, "新增了命令就要在这里补一条");
|
||||
for command in &commands {
|
||||
assert_eq!(instance_of(command), ID, "{command:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 找不到实例的写操作 ⇒ 同一个具名失败(不是"写成功了个空的")。
|
||||
#[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");
|
||||
}
|
||||
// 这个用例只借 `SavesFailureView` 的类型做一次构造检查:字段齐了才算「结构化失败」。
|
||||
let _probe = SavesFailureView {
|
||||
instance: "gone".to_string(),
|
||||
file: String::new(),
|
||||
op: None,
|
||||
kind: SavesFailureKind::InstanceUnavailable,
|
||||
detail: String::new(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! 三个**写操作**的宿主实现(M247):重命名 / 删除 / 备份导出。
|
||||
//!
|
||||
//! 从 `host/saves.rs` 拆出(那个文件加完在飞闸门与写失败映射之后越过 400 行硬线)。
|
||||
//! 判据与读路径共用的部分留在父模块([`super::target`] / [`super::run_dir`] / [`super::snapshot`] /
|
||||
//! [`super::failure`])——**派生运行目录只有一条路**,写路径不另算一份。
|
||||
//!
|
||||
//! | 函数 | 干什么 |
|
||||
//! |---|---|
|
||||
//! | [`spawn_rename`] / [`spawn_delete`] / [`spawn_backup`] | 起后台线程跑完再把**新快照 + 回执**送回去 |
|
||||
//! | `rename` / `delete` / `backup` | 前台实现(测试直接调它们,不必绕线程) |
|
||||
//! | [`export_dir`] | 备份的默认落点 `<data>/exports/saves/` |
|
||||
//! | [`write_failure`] | core 的八类写失败 → DTO 的八类(一一对应,不折叠) |
|
||||
//!
|
||||
//! 三个 `spawn_*` 都**先跑 core 的写**再 `snapshot`:界面画的永远是磁盘上的真相,
|
||||
//! 回执只说这件事动的是哪一条(见父模块的「三个写操作的回程形状」)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::game::saves::{self, SavesWriteError, SavesWriteKind};
|
||||
|
||||
use crate::dto::{
|
||||
InstanceId, SavesFailureKind, SavesFailureOp, SavesFailureView, SavesListView, SavesOped,
|
||||
};
|
||||
|
||||
use super::super::env::HostEnv;
|
||||
use super::super::job::{spawn, JobSender};
|
||||
use super::{failure, snapshot, target, Report};
|
||||
|
||||
/// 后台重命名一个世界,**并把刷新后的快照与回执一起带回来**。
|
||||
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(crate::host::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(crate::host::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(crate::host::HostTask::Saves(Box::new(report)));
|
||||
});
|
||||
}
|
||||
|
||||
/// 重命名 → 再列一遍(回执里记「谁改成了什么」)。
|
||||
pub(super) 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(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// 删除 → 再列一遍(回执里记被删掉的那个名字)。
|
||||
pub(super) 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(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// 备份 → 再列一遍(回执里记落点路径 / 字节数 / 被跳过的链接数)。
|
||||
pub(super) 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")
|
||||
}
|
||||
|
||||
/// core 的写失败 → 具名类别 + 「路径:原文」(**不翻译**,翻译在 app 层)。
|
||||
///
|
||||
/// 八个类别一一对应,没有一个落到「未知」兜底里:兜底会变成静默(用户只看到"失败了")。
|
||||
pub(super) 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
|
||||
}
|
||||
@@ -53,7 +53,7 @@ mod tiles;
|
||||
|
||||
pub use live::MAX_LIVE_LINES;
|
||||
pub use mods::ModsState;
|
||||
pub use saves::SavesState;
|
||||
pub use saves::{RenameDraft, SavesOp, SavesState};
|
||||
pub use tiles::{grid_positions, TileCell};
|
||||
|
||||
/// 一份加载器版本清单查询的结果(`Ok` = 最新在前;`Err` = 人读原因)。
|
||||
|
||||
@@ -1,8 +1,41 @@
|
||||
//! 实例存档 / 截图快照的落地(M235 · STOREMVP)。
|
||||
//! 实例存档 / 截图快照的落地(M235 · STOREMVP)+ 写操作的三样交互态(M247 · SAVESOPS)。
|
||||
//!
|
||||
//! 从 `state.rs` 拆出(那个文件贴着 400 行硬线),形状与 [`super::mods`] 一致:
|
||||
//! **另一个 `impl AppState` 块**,路径与可见性都不变,`apply_core` 只有一行投递。
|
||||
//!
|
||||
//! # 写操作的三样交互态(M247)
|
||||
//!
|
||||
//! | 字段 | 是什么 | 为什么必须住在这里 |
|
||||
//! |---|---|---|
|
||||
//! | [`SavesState::draft`] | 行内重命名的输入缓冲 | 页面模型每帧从快照重建(`from_state`),打字过程中的内容不在快照里 ⇒ 不留在这里就会每打一个字被事件冲掉 |
|
||||
//! | [`SavesState::pending`] | 哪个世界的哪个写操作在飞 | 与 [`super::mods::ModsState::pending`] **同一条先例**:只用来把那一行的按钮置灰,**不做乐观回显**(列表只跟快照走) |
|
||||
//! | [`SavesState::confirm`] | 删除确认框**正在等谁** | 模态队列只画框、不记得"确认之后要删哪个世界";而点主按钮的那一刻是 `app/update/dialog.rs`,那里只能从状态里取 |
|
||||
//!
|
||||
//! ## 生命周期(谁写、谁清、何时必须清)
|
||||
//!
|
||||
//! | 字段 | 谁写 | 谁清 | 必须清的时刻 |
|
||||
//! |---|---|---|---|
|
||||
//! | `draft` | `start_rename`(预填当前磁盘名)、`edit_rename`(每敲一个字) | `cancel_rename` / `take_rename`(提交时取走)/ `mark_pending`(请求已发出,草稿没用了)/ `bind` / `changed` | **换实例**、**请求发出**、**快照回来**:三者之后草稿都属于"上一次对话",留着会让「改名」按钮改到一个已经在别的实例上的名字 |
|
||||
//! | `pending` | `mark_pending`(发请求之前,一跳都不等) | `changed`(成功)/ `failed`(失败也要清——**按钮不能永远灰着**,用户要能直接再点一次)/ `bind` | **任何一次后台往返收尾**、**换实例** |
|
||||
//! | `confirm` | `ask_delete(dialog, dir)`(app 层先 `dialogs.push` 拿到 id 再登记) | [`SavesState::take_confirm(current)`](SavesState::take_confirm)(**只核销同一个框、取走即清**)/ [`SavesState::clear_confirm`](次按钮 / 遮罩 / `Esc` / 换实例)/ [`SavesState::changed`](**只在被确认的世界已从快照里消失时**) | **点主按钮那一刻**、**取消/关闭模态框**、**换实例**、**那个世界没了**(`closed_confirm` 会一并收框) |
|
||||
//! | `closed_confirm` | `changed`(世界从快照里消失时登记作废的那个框) | app 层 `refresh_view` → `settle_saves_confirm`(收框后取走)/ `ask_delete` / `clear_confirm` / `bind` | **同一拍或下一拍**:留着会让"世界已经不在了"的确认框一直挡在屏幕中央 |
|
||||
//!
|
||||
//! ### `confirm` 是**绑定到具体对话框**的一条安全不变量
|
||||
//!
|
||||
//! 三条判据,缺一条就会出安全事故(复审 r1 P1-1 的事故形态就是第三条缺失):
|
||||
//!
|
||||
//! 1. **取走即清**:主按钮拿到目标的同时字段被置空 ⇒ 同一次确认连点两下只发一条删除。
|
||||
//! 2. **认框不认人**:`take_confirm(current)` 只在 `current` 等于登记时那个框的 id 时才交付。
|
||||
//! 「删存档 → `Esc` → 崩溃卡片 → 点『导出诊断包』」这条链上,第二个框的主按钮**拿不走**目标
|
||||
//! (即使 `Esc` 那条关闭路径漏清——而它现在也清了)。
|
||||
//! 3. **世界没了就把框收起来**:快照回来时被确认的世界已不在列表里 ⇒ 清 `confirm` 并登记
|
||||
//! `closed_confirm`,app 层据此收框。否则主按钮会变成一次**静默空操作**(点了没删、也不说一句)。
|
||||
//!
|
||||
//! 用例(presenter 层):`the_delete_confirmation_hands_out_once`(取走即清 + 取消后不再有目标)、
|
||||
//! `a_dialog_can_only_redeem_its_own_target`(别的框的主按钮拿不走)、
|
||||
//! `an_unrelated_snapshot_no_longer_drops_the_confirmation`(P2-1:世界还在 ⇒ 目标还兑现得了)、
|
||||
//! `the_confirmation_is_dropped_and_reported_when_the_world_vanishes`(世界没了 ⇒ 清 + 登记收框)。
|
||||
//!
|
||||
//! # 为什么快照住在这里而不是页面
|
||||
//!
|
||||
//! 「列表画的永远是磁盘上的真相」是本模块唯一要保证的事,所以:
|
||||
@@ -18,10 +51,52 @@
|
||||
//! 用户很可能点开 A 的详情页、还没等后台回来就切到 B。所以 `view.instance` 与
|
||||
//! [`SavesState::bound`] 不一致的结果**直接丢掉**(与 [`super::mods`] 同一条纪律)。
|
||||
|
||||
use crate::dialogs::DialogId;
|
||||
use crate::dto::{SavesFailureView, SavesListView};
|
||||
|
||||
mod interaction;
|
||||
|
||||
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)]
|
||||
pub struct SavesState {
|
||||
@@ -31,10 +106,25 @@ pub struct SavesState {
|
||||
pub view: Option<SavesListView>,
|
||||
/// 最近一条失败(具名原因;成功一次就清掉)。
|
||||
pub failed: Option<SavesFailureView>,
|
||||
/// 行内重命名的输入缓冲(`None` = 没有在改名)。
|
||||
pub draft: Option<RenameDraft>,
|
||||
/// 在飞的写操作(`None` = 空闲)。
|
||||
pub pending: Option<SavesOp>,
|
||||
/// 删除确认框正在等谁:**(框的 id, 世界名)**(点「确定」时才真的删)。
|
||||
///
|
||||
/// 为什么带框 id(复审 r1 P1-1):`take_confirm(current)` 只核销**同一个框**。即使某条
|
||||
/// 关闭路径漏清,目标也不会被下一个不相干的对话框(例如崩溃卡片的「导出诊断包」)兑成一次删除。
|
||||
pub confirm: Option<(DialogId, String)>,
|
||||
/// 刚刚因为「被确认的世界已经不在快照里」而作废的那个框(app 层据此收框,见
|
||||
/// [`SavesState::take_closed_confirm`])。
|
||||
pub closed_confirm: Option<DialogId>,
|
||||
}
|
||||
|
||||
impl SavesState {
|
||||
/// 切到另一个实例(换实例必须清空:A 的存档不能画在 B 的详情页上)。
|
||||
///
|
||||
/// 三样交互态一起清:换实例之后还留着「A 的行内草稿 / A 的删除确认」是错位状态
|
||||
/// (点确定会去删 B 页上的 A,或者把 A 的草稿提交成 B 的改名)。
|
||||
pub(super) fn bind(&mut self, instance: Option<&str>) {
|
||||
if self.bound.as_deref() == instance {
|
||||
return;
|
||||
@@ -42,9 +132,27 @@ impl SavesState {
|
||||
self.bound = instance.map(str::to_string);
|
||||
self.view = None;
|
||||
self.failed = None;
|
||||
self.reset_interaction();
|
||||
}
|
||||
|
||||
/// 清掉三样交互态(换实例 / 操作回程时用)。
|
||||
fn reset_interaction(&mut self) {
|
||||
self.draft = None;
|
||||
self.pending = None;
|
||||
self.confirm = None;
|
||||
self.closed_confirm = None;
|
||||
}
|
||||
|
||||
/// 一条结果落地(`Ok` = 快照整体替换,`Err` = 记一条具名失败;迟到/串台的丢掉)。
|
||||
///
|
||||
/// **成败对三样交互态的处理不一样**(复审 r1 P2-1;与模块文档的安全判据第 3 条同源):
|
||||
///
|
||||
/// | 支 | `draft` / `pending` | `confirm` |
|
||||
/// |---|---|---|
|
||||
/// | `Ok`(快照) | 清(这一拍写操作收尾了) | **只在「被确认的那个世界已不在快照里」时**清,并登记 [`SavesState::closed_confirm`] 让 app 收框 |
|
||||
/// | `Err`(失败) | 只清 `pending`(按钮不能永远灰着) | **不动**:一条读盘失败不该把用户刚点出来的确认框变成"点了没反应" |
|
||||
///
|
||||
/// 换言之「无论成败都清确认中」是**旧的**说法,已经不成立:无条件清会让主按钮静默空操作。
|
||||
pub fn apply(&mut self, result: Result<SavesListView, SavesFailureView>) {
|
||||
match result {
|
||||
Ok(view) => self.changed(view),
|
||||
@@ -53,12 +161,35 @@ impl SavesState {
|
||||
}
|
||||
|
||||
/// 快照落地(整体替换;迟到/串台的丢掉)。
|
||||
///
|
||||
/// 三样交互态在这里**不是一起清**(复审 r1 P2-1):
|
||||
///
|
||||
/// | 字段 | 这一拍怎么处理 |
|
||||
/// |---|---|
|
||||
/// | `draft` / `pending` | **清**:写操作已经收尾,行按新快照画 |
|
||||
/// | `confirm` | **只在「被确认的那个世界已经从快照里消失」时清**,并记下那个框([`Self::closed_confirm`])让 app 收框 |
|
||||
///
|
||||
/// 为什么不能无条件清 `confirm`:备份一个大世界还在飞、「打开存档文件夹」的回程还在路上时,
|
||||
/// 用户点「删除 X」→ 框刚弹出来 → 一拍无关快照先到 → 目标被清 → 用户点「删除世界」只关框、
|
||||
/// 不删、也不说一句。少删是安全的,但**静默就是不安全的另一种形式**(用户以为删了)。
|
||||
pub fn changed(&mut self, view: SavesListView) {
|
||||
if self.bound.as_deref() != Some(view.instance.as_str()) {
|
||||
return;
|
||||
}
|
||||
self.failed = None;
|
||||
// 目标世界还在不在这一份快照里(`dir` 是磁盘上的名字,与列表同一口径)。
|
||||
let gone = self
|
||||
.confirm
|
||||
.as_ref()
|
||||
.map(|(_, dir)| !view.worlds.iter().any(|world| &world.dir == dir));
|
||||
self.view = Some(view);
|
||||
self.draft = None;
|
||||
self.pending = None;
|
||||
if gone == Some(true) {
|
||||
if let Some((dialog, _)) = self.confirm.take() {
|
||||
self.closed_confirm = Some(dialog);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 失败落地(只记**当前绑定实例**的;old 列表保留——用户还能看见旧列表,只是多一条红字)。
|
||||
@@ -67,6 +198,10 @@ impl SavesState {
|
||||
return;
|
||||
}
|
||||
self.failed = Some(failure);
|
||||
// 失败也收尾:按钮不能永远灰着(用户要能直接再点一次重试)。
|
||||
// `confirm` **不动**(与 [`Self::changed`] 同一条理由):一条读盘失败不该把用户
|
||||
// 刚点出来的删除确认框变成"点了没反应"。
|
||||
self.pending = None;
|
||||
}
|
||||
|
||||
/// 当前绑定实例的列表是否已经有一份快照。
|
||||
@@ -84,72 +219,4 @@ impl AppState {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dto::{SavesFailureKind, WorldView};
|
||||
|
||||
fn view(instance: &str) -> SavesListView {
|
||||
SavesListView {
|
||||
instance: instance.to_string(),
|
||||
dir: "C:/mc/saves".to_string(),
|
||||
exists: true,
|
||||
worlds: vec![WorldView {
|
||||
dir: "World".to_string(),
|
||||
icon: false,
|
||||
modified_ms: None,
|
||||
}],
|
||||
screenshots_dir: "C:/mc/screenshots".to_string(),
|
||||
screenshots_exists: false,
|
||||
screenshots: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(instance: &str) -> SavesFailureView {
|
||||
SavesFailureView {
|
||||
instance: instance.to_string(),
|
||||
kind: SavesFailureKind::DirUnusable,
|
||||
detail: "Access is denied.".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 换实例必须清空(A 的存档不许画在 B 的详情页上)。
|
||||
#[test]
|
||||
fn binding_another_instance_drops_the_old_view() {
|
||||
let mut saves = SavesState::default();
|
||||
saves.bind(Some("a"));
|
||||
saves.changed(view("a"));
|
||||
assert!(saves.has_view());
|
||||
|
||||
saves.bind(Some("b"));
|
||||
assert!(!saves.has_view(), "换实例后还留着 A 的存档");
|
||||
assert_eq!(saves.bound.as_deref(), Some("b"));
|
||||
|
||||
// 同一个实例再 bind 一次不该清(`refresh_view` 每帧都调)。
|
||||
saves.changed(view("b"));
|
||||
saves.bind(Some("b"));
|
||||
assert!(saves.has_view());
|
||||
}
|
||||
|
||||
/// 迟到/串台的结果被丢掉;成功一次清掉旧失败(重试成功后红字要消失)。
|
||||
#[test]
|
||||
fn late_results_are_dropped_and_success_clears_the_failure() {
|
||||
let mut saves = SavesState::default();
|
||||
saves.bind(Some("a"));
|
||||
saves.changed(view("b"));
|
||||
assert!(!saves.has_view(), "B 的列表画到了 A 上");
|
||||
|
||||
saves.failed(failure("a"));
|
||||
assert!(saves.failed.is_some());
|
||||
assert!(saves.bound.is_some(), "失败不许把绑定清掉(要能重试)");
|
||||
saves.changed(view("a"));
|
||||
assert!(saves.failed.is_none(), "成功后旧失败没清掉");
|
||||
|
||||
// 不在详情页(`bound = None`)时任何结果都不落地。
|
||||
let mut idle = SavesState::default();
|
||||
idle.changed(view("a"));
|
||||
idle.failed(failure("a"));
|
||||
assert!(!idle.has_view());
|
||||
assert!(idle.failed.is_none());
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -0,0 +1,101 @@
|
||||
//! `SavesState` 的三样**交互态**:行内草稿 / 在飞标记 / 删除确认目标(M247)。
|
||||
//!
|
||||
//! 从 `state/saves.rs` 拆出(那个文件加完安全不变量文档之后越过 400 行硬线)。生命周期表在
|
||||
//! 父模块的文档里一张写全——**这里只放实现**,不重复判据。
|
||||
//!
|
||||
//! 三条边界(父模块文档的展开):
|
||||
//! 1. `draft` / `pending` 只跟"这一拍在编辑/在飞什么"有关,快照落地一律清;
|
||||
//! 2. `confirm` **认框**:`take_confirm(current)` 只在 id 相等时交付(见 [`SavesState::take_confirm`]);
|
||||
//! 3. `closed_confirm` 是"这个框该收起来了"的信号,app 层 `refresh_view` 取走并真的收框。
|
||||
|
||||
use crate::dialogs::DialogId;
|
||||
|
||||
use super::{RenameDraft, SavesOp, SavesState};
|
||||
|
||||
impl SavesState {
|
||||
/// 开一个行内重命名(输入框预填当前磁盘名:改个后缀比从零打更常见)。
|
||||
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;
|
||||
}
|
||||
|
||||
/// 记下「删除确认框正在等谁」:**框的 id + 世界名**(`dialog` 由调用方 `dialogs.push` 拿到)。
|
||||
pub fn ask_delete(&mut self, dialog: DialogId, dir: &str) {
|
||||
self.confirm = Some((dialog, dir.to_string()));
|
||||
self.closed_confirm = None;
|
||||
}
|
||||
|
||||
/// 取走确认目标(点「确定」时用)——**只核销 `current` 那一个框**。
|
||||
///
|
||||
/// 「取走即清」+「认框不认人」两条合起来是**安全不变量**:
|
||||
/// ①同一次确认连点两下只发一条删除(第二下拿不到目标);
|
||||
/// ②别的框(崩溃卡片的「导出诊断包」、进度框……)点主按钮**拿不走**这个目标——
|
||||
/// 哪怕某条关闭路径漏清(复审 r1 P1-1 的事故形态),也只能核销它自己那个框。
|
||||
pub fn take_confirm(&mut self, current: DialogId) -> Option<String> {
|
||||
match &self.confirm {
|
||||
Some((dialog, _)) if *dialog == current => {
|
||||
self.closed_confirm = None;
|
||||
self.confirm.take().map(|(_, dir)| dir)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// **某个**确认框被取消/关掉(次按钮、遮罩、`Esc` 三条路径都走它):**只清它自己那个目标**。
|
||||
///
|
||||
/// 为什么不无条件清(复审 r2 的加固):`clear_confirm` 不认框,理论上够得着"清掉别人的删除目标"
|
||||
/// ——今天三条关闭路径关的都是当前框,够不着,但把判据写死在这里比依赖调用顺序稳。
|
||||
pub fn close_confirm(&mut self, current: DialogId) {
|
||||
if self.confirm_dialog() == Some(current) {
|
||||
self.clear_confirm();
|
||||
}
|
||||
}
|
||||
|
||||
/// 清掉目标(**无条件**):换实例 / 复位时用——那时"哪个框"已经不重要了。
|
||||
pub fn clear_confirm(&mut self) {
|
||||
self.confirm = None;
|
||||
self.closed_confirm = None;
|
||||
}
|
||||
|
||||
/// 取走「因为世界已经不在快照里而作废」的那个框(app 层据此收框,避免主按钮空操作)。
|
||||
pub fn take_closed_confirm(&mut self) -> Option<DialogId> {
|
||||
self.closed_confirm.take()
|
||||
}
|
||||
|
||||
/// 当前确认目标属于哪个框(`None` = 没有在等的目标)。
|
||||
#[must_use]
|
||||
pub fn confirm_dialog(&self) -> Option<DialogId> {
|
||||
self.confirm.as_ref().map(|(dialog, _)| *dialog)
|
||||
}
|
||||
|
||||
/// 标记一次写操作在飞(草稿同时收起来:请求已经发出去了)。
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! `SavesState` 的判据:绑定 / 迟到丢弃 / 三样交互态(含删除确认的安全不变量)。
|
||||
//!
|
||||
//! 从 `state/saves.rs` 拆出(父模块守 400 行)。**判据一条没改**,另外补了复审 r1 P1-1 / P2-1
|
||||
//! 那两条:认框不认人、无关快照不再吃掉确认目标。
|
||||
|
||||
use super::*;
|
||||
use crate::dto::{SavesFailureKind, WorldView};
|
||||
|
||||
fn view(instance: &str) -> SavesListView {
|
||||
SavesListView {
|
||||
instance: instance.to_string(),
|
||||
dir: "C:/mc/saves".to_string(),
|
||||
exists: true,
|
||||
worlds: vec![WorldView {
|
||||
dir: "World".to_string(),
|
||||
icon: false,
|
||||
modified_ms: None,
|
||||
}],
|
||||
screenshots_dir: "C:/mc/screenshots".to_string(),
|
||||
screenshots_exists: false,
|
||||
screenshots: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
last_op: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(instance: &str) -> SavesFailureView {
|
||||
SavesFailureView {
|
||||
instance: instance.to_string(),
|
||||
file: String::new(),
|
||||
op: None,
|
||||
kind: SavesFailureKind::DirUnusable,
|
||||
detail: "Access is denied.".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 换实例必须清空(A 的存档不许画在 B 的详情页上)。
|
||||
#[test]
|
||||
fn binding_another_instance_drops_the_old_view() {
|
||||
let mut saves = SavesState::default();
|
||||
saves.bind(Some("a"));
|
||||
saves.changed(view("a"));
|
||||
assert!(saves.has_view());
|
||||
|
||||
saves.bind(Some("b"));
|
||||
assert!(!saves.has_view(), "换实例后还留着 A 的存档");
|
||||
assert_eq!(saves.bound.as_deref(), Some("b"));
|
||||
|
||||
// 同一个实例再 bind 一次不该清(`refresh_view` 每帧都调)。
|
||||
saves.changed(view("b"));
|
||||
saves.bind(Some("b"));
|
||||
assert!(saves.has_view());
|
||||
}
|
||||
|
||||
/// 迟到/串台的结果被丢掉;成功一次清掉旧失败(重试成功后红字要消失)。
|
||||
#[test]
|
||||
fn late_results_are_dropped_and_success_clears_the_failure() {
|
||||
let mut saves = SavesState::default();
|
||||
saves.bind(Some("a"));
|
||||
saves.changed(view("b"));
|
||||
assert!(!saves.has_view(), "B 的列表画到了 A 上");
|
||||
|
||||
saves.failed(failure("a"));
|
||||
assert!(saves.failed.is_some());
|
||||
assert!(saves.bound.is_some(), "失败不许把绑定清掉(要能重试)");
|
||||
saves.changed(view("a"));
|
||||
assert!(saves.failed.is_none(), "成功后旧失败没清掉");
|
||||
|
||||
// 不在详情页(`bound = None`)时任何结果都不落地。
|
||||
let mut idle = SavesState::default();
|
||||
idle.changed(view("a"));
|
||||
idle.failed(failure("a"));
|
||||
assert!(!idle.has_view());
|
||||
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(7, "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(7, "Doomed");
|
||||
assert_eq!(saves.take_confirm(7).as_deref(), Some("Doomed"));
|
||||
assert!(
|
||||
saves.take_confirm(7).is_none(),
|
||||
"第二次取还拿得到:连点会删两次"
|
||||
);
|
||||
|
||||
saves.ask_delete(8, "Other");
|
||||
saves.clear_confirm();
|
||||
assert!(saves.take_confirm(8).is_none(), "取消之后还留着目标");
|
||||
}
|
||||
|
||||
/// **复审 r2 加固**:别人那个框被关掉时,不该清掉我们的删除目标。
|
||||
///
|
||||
/// `close_confirm(current)` 只在 `current` 等于登记的那个框时才清;`clear_confirm()` 仍然无条件
|
||||
/// (换实例复位时用——那时"哪个框"已经不重要了)。
|
||||
#[test]
|
||||
fn closing_another_dialog_leaves_our_target_alone() {
|
||||
let mut saves = SavesState::default();
|
||||
saves.bind(Some("a"));
|
||||
saves.ask_delete(7, "Doomed");
|
||||
|
||||
saves.close_confirm(99);
|
||||
assert_eq!(
|
||||
saves.take_confirm(7).as_deref(),
|
||||
Some("Doomed"),
|
||||
"别的框关掉时把我们的目标清掉了"
|
||||
);
|
||||
|
||||
saves.ask_delete(8, "Other");
|
||||
saves.close_confirm(8);
|
||||
assert!(
|
||||
saves.take_confirm(8).is_none(),
|
||||
"自己的框关掉了,目标还在(下一次点确定会删上一个)"
|
||||
);
|
||||
}
|
||||
|
||||
/// **P1-1 的核心**:目标只属于登记它的那个框。别的框点主按钮**拿不走**它。
|
||||
///
|
||||
/// 事故形态:`Esc` 关掉删除确认框(某条关闭路径漏清)→ 崩溃卡片弹出来 →
|
||||
/// 点「导出诊断包」(也是 `Confirm` 的主按钮)→ 若 `take_confirm` 无条件交付,
|
||||
/// 那个世界就被静默删掉了。判据是 **id 不等就不交付、也不清**(目标还是那个框的)。
|
||||
#[test]
|
||||
fn a_dialog_can_only_redeem_its_own_target() {
|
||||
let mut saves = SavesState::default();
|
||||
saves.bind(Some("a"));
|
||||
saves.ask_delete(7, "Doomed");
|
||||
|
||||
assert!(
|
||||
saves.take_confirm(99).is_none(),
|
||||
"别的框的主按钮拿走了删除目标"
|
||||
);
|
||||
assert_eq!(saves.confirm_dialog(), Some(7), "拿不走也不该被顺手清掉");
|
||||
assert_eq!(
|
||||
saves.take_confirm(7).as_deref(),
|
||||
Some("Doomed"),
|
||||
"自己的框反而兑不了"
|
||||
);
|
||||
}
|
||||
|
||||
/// **P2-1**:一拍无关快照(世界还在)不许把确认目标吃掉——否则主按钮就是静默空操作。
|
||||
#[test]
|
||||
fn an_unrelated_snapshot_no_longer_drops_the_confirmation() {
|
||||
let mut saves = SavesState::default();
|
||||
saves.bind(Some("a"));
|
||||
saves.changed(view("a"));
|
||||
saves.ask_delete(7, "World");
|
||||
|
||||
// 「打开存档文件夹」的回程 / 备份在飞时的列表刷新:世界还在快照里。
|
||||
saves.changed(view("a"));
|
||||
assert_eq!(
|
||||
saves.take_confirm(7).as_deref(),
|
||||
Some("World"),
|
||||
"无关快照把确认目标吃掉了:主按钮会变成静默空操作"
|
||||
);
|
||||
|
||||
// 读盘失败同理:一条失败不该把用户刚点出来的框弄成"点了没反应"。
|
||||
saves.ask_delete(8, "World");
|
||||
saves.failed(failure("a"));
|
||||
assert_eq!(saves.take_confirm(8).as_deref(), Some("World"));
|
||||
}
|
||||
|
||||
/// **P2-1 的另一半**:被确认的世界从快照里消失 ⇒ 目标清掉,并且**登记那个框该收了**。
|
||||
///
|
||||
/// 只清不登记就是"框还在、主按钮点了什么也不发生";只登记不清就是留着一个作废的目标。
|
||||
#[test]
|
||||
fn the_confirmation_is_dropped_and_reported_when_the_world_vanishes() {
|
||||
let mut saves = SavesState::default();
|
||||
saves.bind(Some("a"));
|
||||
saves.changed(view("a"));
|
||||
saves.ask_delete(7, "World");
|
||||
|
||||
// 快照回来时那个世界已经不在列表里(别的进程删掉了 / 改名了)。
|
||||
let mut gone = view("a");
|
||||
gone.worlds.clear();
|
||||
saves.changed(gone);
|
||||
|
||||
assert!(saves.confirm.is_none(), "目标还留着一个已经不存在的世界");
|
||||
assert_eq!(
|
||||
saves.take_closed_confirm(),
|
||||
Some(7),
|
||||
"没有登记那个框 ⇒ 它会一直挡在屏幕中央、主按钮点了没反应"
|
||||
);
|
||||
assert!(saves.take_confirm(7).is_none(), "作废的目标还能被兑现");
|
||||
assert!(saves.take_closed_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