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

This commit is contained in:
AskaEth committed 2026-11-11 14:32:12 +08:00
commit 81710ba38c
35 files changed
+4032 -293

No files matched your search

+25 -1
View File
@@ -68,6 +68,9 @@ impl App {
self.sync_saves(); self.sync_saves();
// M224:启停失败的提示语(presenter 只发结构,文案在这里按 i18n 现求)。 // M224:启停失败的提示语(presenter 只发结构,文案在这里按 i18n 现求)。
self.note_mods_failure(); self.note_mods_failure();
// M247(复审 r1 P2-1):被确认的世界已经从快照里消失 ⇒ 收起那个确认框。
// 不这样的话主按钮就是一次静默空操作(点了没删、也不说一句)。
self.settle_saves_confirm();
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数): // 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。 // 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
@@ -100,6 +103,27 @@ impl App {
self.refreshes += 1; 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`)。 /// 把详情页当前实例的启动参数快照灌进表单(唯一的重播种点,见 `pages/instances/state.rs`)。
/// ///
/// 不在详情页(或实例已不在快照里)时解绑:下次进详情页会重新播种, /// 不在详情页(或实例已不在快照里)时解绑:下次进详情页会重新播种,
@@ -178,7 +202,7 @@ impl App {
// 出图取证(`ULL_SAVES_FIXTURE=1`):后台往返赶不上第 3 帧出图,注入一份固定快照。 // 出图取证(`ULL_SAVES_FIXTURE=1`):后台往返赶不上第 3 帧出图,注入一份固定快照。
// 产品路径读不到这个变量 ⇒ 下面这行什么都不做(见 `saves_shot` 的模块文档)。 // 产品路径读不到这个变量 ⇒ 下面这行什么都不做(见 `saves_shot` 的模块文档)。
if saves_shot::enabled() { if saves_shot::enabled() {
self.state.saves.changed(saves_shot::view(&id)); saves_shot::seed(self, &id);
return; return;
} }
// **不走 `App::dispatch`**:那条路会为「等第一条事件」把帧订阅打开 3 秒, // **不走 `App::dispatch`**:那条路会为「等第一条事件」把帧订阅打开 3 秒,
@@ -77,5 +77,91 @@ pub fn view(instance: &str) -> unlucky_presenter::dto::SavesListView {
shot("2024-04-20_08.30.00.png", 964_608, None), shot("2024-04-20_08.30.00.png", 964_608, None),
], ],
skipped: Vec::new(), skipped: Vec::new(),
// 出图夹具不造回执:默认那一张就是「刚列完目录」的样子。
// 要看「备份成功那句话」长什么样,用 `ULL_SAVES_FIXTURE=backed-up`(见 `seed`)。
last_op: None,
}
}
/// 夹具形态(由 `ULL_SAVES_FIXTURE` 的取值决定;未知值 = 默认那一种)。
///
/// | 取值 | 出图抓什么 |
/// |---|---|
/// | `1`(或任何未知值) | 有内容的列表:世界行右侧的重命名 / 备份 / 删除 |
/// | `rename` | 行内重命名进行中(输入框 + 改名/取消) |
/// | `confirm` | 删除的二次确认模态框 |
/// | `backed-up` | 备份成功的回执那句话(带落点路径与大小) |
/// | `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 => {}
} }
} }
+36 -2
View File
@@ -13,6 +13,27 @@ use crate::app::state::App;
pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::app::Message> { pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::app::Message> {
match message { match message {
DialogMessage::Primary => { DialogMessage::Primary => {
// M247:删除存档的二次确认——主按钮就是**真删**(这是本页唯一不可逆的动作)。
// 目标记在 presenter 的状态里(`SavesState::confirm`):模态队列只画框,不记"要删谁"。
//
// **认框不认人**(复审 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 { let Some(card) = app.dialog_card.clone() else {
app.close_dialog(); app.close_dialog();
@@ -38,6 +59,11 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
Task::none() Task::none()
} }
DialogMessage::Secondary | DialogMessage::Dismiss => { DialogMessage::Secondary | DialogMessage::Dismiss => {
// M247:取消/关掉删除确认框 ⇒ 忘掉那个目标(不然下一次点确定会删上一个)。
// **只核销当前这个框的目标**(复审 r2 加固):别人的框关掉时不该动我们的。
if let Some(current) = app.state.dialogs.current().map(|request| request.id) {
app.state.saves.close_confirm(current);
}
app.close_dialog(); app.close_dialog();
// 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒 // 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒
// (r1 复审 P3-1)。 // (r1 复审 P3-1)。
@@ -95,10 +121,18 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
/// `Esc` 关弹窗(有弹窗时**先吃这一条**,不让它落到 AI 栏的 Esc 收起上)。 /// `Esc` 关弹窗(有弹窗时**先吃这一条**,不让它落到 AI 栏的 Esc 收起上)。
pub(super) fn escape(app: &mut App) -> bool { 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; 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.close_dialog();
app.refresh_view(); app.refresh_view();
true 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(), "世界都不在了还下发删除");
}
+75 -1
View File
@@ -11,7 +11,9 @@
//! 要等回程事件,写盘失败时旧快照回来必须留住用户敲的值(M157 r3 复审 P2-2)。 //! 要等回程事件,写盘失败时旧快照回来必须留住用户敲的值(M157 r3 复审 P2-2)。
use iced::Task; use iced::Task;
use unlucky_presenter::dialogs::{DialogKind, DialogSource};
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch, ModsCommand, SavesCommand}; use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch, ModsCommand, SavesCommand};
use unlucky_presenter::state::SavesOp;
use crate::pages::InstanceMessage; use crate::pages::InstanceMessage;
@@ -56,6 +58,76 @@ impl App {
}))); })));
return Task::none(); return Task::none();
} }
// M247:行内重命名的三个动作。**输入过程不 dispatch**(只改 presenter 的草稿),
// 提交才发命令——与启动参数表单「编辑只改状态,保存才派命令」同一条纪律。
InstanceMessage::StartRenameWorld { dir } => {
self.state.saves.start_rename(&dir);
self.view_dirty = true;
self.refresh_view();
return Task::none();
}
InstanceMessage::RenameWorldChanged(text) => {
self.state.saves.edit_rename(text);
self.view_dirty = true;
self.refresh_view();
return Task::none();
}
InstanceMessage::CommitRenameWorld => {
let Some(draft) = self.state.saves.take_rename() else {
return Task::none();
};
// 空名字 / 没改 → 直接收起,不打后台:这两个不是"操作失败",是"用户改主意了"。
if draft.text.is_empty() || draft.text == draft.dir {
self.view_dirty = true;
self.refresh_view();
return Task::none();
}
self.state.saves.mark_pending(SavesOp::Rename {
dir: draft.dir.clone(),
});
self.dispatch(AppCommand::Saves(Box::new(SavesCommand::RenameWorld {
id: instance,
dir: draft.dir,
to: draft.text,
})));
return Task::none();
}
InstanceMessage::CancelRenameWorld => {
self.state.saves.cancel_rename();
self.view_dirty = true;
self.refresh_view();
return Task::none();
}
// M247:删除**先弹确认框**。确认目标是"哪个世界"必须记在状态里——
// 主按钮落点在 `app/update/dialog.rs`,那里只有 `App`,没有这个页面。
InstanceMessage::RequestDeleteWorld { dir } => {
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(), _ if !self.launch_form.bound_to(&instance) => return Task::none(),
InstanceMessage::Jvm(text) => { InstanceMessage::Jvm(text) => {
@@ -96,7 +168,9 @@ impl App {
} }
/// 详情页当前选中的实例 id(不在详情页时为 `None`)。 /// 详情页当前选中的实例 id(不在详情页时为 `None`)。
fn selected_instance_id(&self) -> Option<String> { ///
/// `pub(in crate::app)`(M247):删除确认框的主按钮在 `app/update/dialog.rs`,也要问同一个问题。
pub(in crate::app) fn selected_instance_id(&self) -> Option<String> {
match self.state.route() { match self.state.route() {
unlucky_presenter::router::Route::Detail(id) => Some(id.clone()), unlucky_presenter::router::Route::Detail(id) => Some(id.clone()),
_ => None, _ => None,
+17
View File
@@ -75,6 +75,23 @@ pub fn saves_failure_line(
saves::failure_line(failure, Texts::of(catalog)) saves::failure_line(failure, Texts::of(catalog))
} }
/// 删除世界的确认框文案:`(标题, 正文, 主按钮, 次按钮)`。
///
/// 模态框在 app 层建(`state.dialogs.push`),但**取词入口仍是本页的 [`Texts`]**——与
/// [`saves_failure_line`] 同一条口径:页面文案只有一个入口,测试扫的也是那一份源码。
pub fn saves_delete_prompt(
catalog: unlucky_i18n::Catalog,
dir: &str,
) -> (String, String, String, String) {
let texts = Texts::of(catalog);
(
texts.saves_delete_title().to_string(),
texts.saves_delete_body(dir),
texts.saves_delete_confirm().to_string(),
texts.saves_cancel().to_string(),
)
}
/// 启停失败/读目录失败的一句话(app 层弹 Toast 用;取词入口仍是 [`Texts`])。 /// 启停失败/读目录失败的一句话(app 层弹 Toast 用;取词入口仍是 [`Texts`])。
pub fn mods_failure_line( pub fn mods_failure_line(
failure: &unlucky_presenter::dto::ModFailureView, failure: &unlucky_presenter::dto::ModFailureView,
+90 -89
View File
@@ -1,4 +1,5 @@
//! 实例详情页的 **SAVES 区**(M235 · STOREMVP):存档 / 截图的**只读**列表 + 打开文件夹。 //! 实例详情页的 **SAVES 区**(M235 · STOREMVP 读路径 + M247 · SAVESOPS 写操作):
//! 存档 / 截图列表 + 打开文件夹 + **每行的重命名 / 备份 / 删除**。
//! //!
//! # 位置与形状 //! # 位置与形状
//! //!
@@ -14,17 +15,42 @@
//! 详情列**整列**已经是滚动区(M157 r2)。M224 踩过一次「滚动条里的滚动条」:1180×1000 下两个条 //! 详情列**整列**已经是滚动区(M157 r2)。M224 踩过一次「滚动条里的滚动条」:1180×1000 下两个条
//! 都认得出来,用户不知道该拖哪一个。这里同样只排内容,长度交给外层那一条。 //! 都认得出来,用户不知道该拖哪一个。这里同样只排内容,长度交给外层那一条。
//! //!
//! # 只读(U3 的边界,页面固定画出来) //! # 行操作区(M247)
//! //!
//! 塔裁决 U3 把这一版钉成「只读列表 + 打开文件夹」:没有删除、没有重命名、没有导入、**没有缩略图** //! 每个**世界**行右侧三个文字按钮:**重命名 / 备份 / 删除**(截图行没有操作区:截图不在这三个
//! (缩略图要引图片解码依赖,`AGENTS.md` §2 不允许)。所以卡片里第一行永远画 //! 写操作的范围里)。三档状态:
//! [`Texts::saves_read_only`]——用户点开就看见"这一版只读",不会以为漏了删除按钮。 //!
//! | 状态 | 画什么 | 为什么 |
//! |---|---|---|
//! | 空闲 | 三个按钮 | — |
//! | 这一行的请求在飞 | 一句「正在处理…」顶替按钮 | 连点两次会发两条 rename/delete,第二条必然「世界已不在」 |
//! | 正在行内改名 | 输入框 + 「改名」/「取消」 | 见下 |
//!
//! **不做乐观回显**:行上的名字只来自快照,点完等 host 回一份新快照才变(与 MODS 区同一纪律)。
//!
//! # 重命名为什么是行内输入,不是模态输入框
//!
//! 现成的模态队列有 `DialogKind::Input`,但 app 侧的 `dialog::Message` 只有主/次按钮与关闭,
//! **没有「输入变了」这一条**——用输入模态等于重写一套可编辑模态。行内输入复用
//! `components::inputs` 的单行框(与启动参数区同一个控件),一处新控件都不加。
//! 输入缓冲住在 presenter 的 [`SavesState::draft`](页面模型每帧从快照重建,缓冲留在页面里会被
//! 每一次事件冲掉)。
//!
//! # 删除为什么必须二次确认
//!
//! 这是本页唯一**不可逆**的动作。点「删除」只发一条
//! [`PageMessage::RequestDeleteWorld`],真正的删除要等模态确认框的主按钮
//! (`app/update/dialog.rs` 里那一支)。确认框复用仓内现成的模态队列(`state.dialogs`),
//! 页面不新写弹窗。
//! //!
//! # 失败与重试 //! # 失败与重试
//! //!
//! 读盘失败画一条红字 + 一个「重试」(再发一条 `SavesCommand::List`)。**打开文件夹失败不画重试**: //! 读盘失败画一条红字 + 一个「重试」(再发一条 `SavesCommand::List`)。**打开文件夹失败与写操作
//! 那个动作的重试就是再点一次按钮,给一个会去重新列目录的「重试」是错的affordance //! 失败不画重试**:那个动作的重试就是再点一次按钮,给一个会去重新列目录的「重试」是错的
//! (两种失败共用 `detail`,只有大类不同,见 [`Saves::retryable`])。 //! affordance(三种失败共用 `detail`,靠大类与 `op` 分别,见 [`failure_line`])。
//!
//! 写操作**成功**也有一句话([`Saves::notice`]):重命名 / 删除的结果列表上看得见,但备份的落点
//! 看不见——「点了备份然后什么也没发生」是静默失败,所以回执里带上完整路径与字节数。
use iced::widget::{column, container, row}; use iced::widget::{column, container, row};
use iced::{Alignment, Element, Length}; use iced::{Alignment, Element, Length};
@@ -33,7 +59,7 @@ use unlucky_design::color::Role;
use unlucky_design::spacing::Space; use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole; use unlucky_design::typography::TypeRole;
use unlucky_i18n::Catalog; use unlucky_i18n::Catalog;
use unlucky_presenter::dto::{human_bytes, SavesFailureKind, SavesFailureView}; use unlucky_presenter::dto::{SavesFailureKind, SavesFailureOp, SavesFailureView};
use unlucky_presenter::state::SavesState; use unlucky_presenter::state::SavesState;
use crate::app::components::buttons::{self, Button}; use crate::app::components::buttons::{self, Button};
@@ -46,15 +72,28 @@ use super::forced_state;
use super::state::Message as PageMessage; use super::state::Message as PageMessage;
use super::Texts; use super::Texts;
/// 列表里的一行(世界 / 截图共用:一个名字 + 一条副文案)。 mod rows;
/// 写操作成功那句话的产地(实现在 [`rows`] 里,页面与测试都从这一条路取)。
pub(super) use rows::op_line;
/// 列表里的一行(一个世界 / 一张截图)。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row { pub struct Row {
/// 世界目录名或截图文件名(磁盘原样)。 /// 世界目录名或截图文件名(磁盘原样;写操作把它原样带回去当「源」)。
pub name: String, pub name: String,
/// 右侧/下方副文案(时间、大小、图标标记,已由这一层拼好)。 /// 右侧/下方副文案(时间、大小、图标标记,已由这一层拼好)。
pub detail: String, pub detail: String,
} }
/// 正在行内重命名的那一行(来源是 presenter 的 `SavesState::draft`)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Editing {
/// 哪个世界。
pub dir: String,
/// 输入框里的内容(原样)。
pub text: String,
}
/// SAVES 区的展示模型(`Model::saves`)。 /// SAVES 区的展示模型(`Model::saves`)。
#[derive(Debug, Clone, Default, PartialEq, Eq)] #[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Saves { pub struct Saves {
@@ -62,9 +101,9 @@ pub struct Saves {
pub bound: Option<String>, pub bound: Option<String>,
/// 正在读盘(进详情页首帧:请求已发出、快照还没回来)。 /// 正在读盘(进详情页首帧:请求已发出、快照还没回来)。
pub loading: bool, pub loading: bool,
/// 读盘/打开失败的人读原因(app 层按 i18n 拼好)。 /// 读盘/打开/写操作失败的人读原因(app 层按 i18n 拼好)。
pub error: Option<String>, pub error: Option<String>,
/// 这一条失败能不能靠「再列一次」解决(打开文件夹失败不算)。 /// 这一条失败能不能靠「再列一次」解决(只有读盘失败算)。
pub retryable: bool, pub retryable: bool,
/// 手里有没有一份**快照**(`false` = 只拿到了失败,"还没有世界"那几句不许画)。 /// 手里有没有一份**快照**(`false` = 只拿到了失败,"还没有世界"那几句不许画)。
pub has_view: bool, pub has_view: bool,
@@ -84,6 +123,12 @@ pub struct Saves {
pub skipped: usize, pub skipped: usize,
/// 跳过提示(`跳过 3 项`)。 /// 跳过提示(`跳过 3 项`)。
pub skipped_note: String, pub skipped_note: String,
/// 正在行内重命名的那一行(`None` = 没有在改名)。
pub editing: Option<Editing>,
/// 有写操作在飞的那个世界(`None` = 空闲)。
pub busy: Option<String>,
/// 上一次写操作成功的那句话(`None` = 这一拍没有回执)。
pub notice: Option<String>,
} }
impl Saves { impl Saves {
@@ -98,21 +143,33 @@ impl Saves {
pub fn nothing_yet(&self) -> bool { pub fn nothing_yet(&self) -> bool {
self.has_view && !self.missing && self.worlds.is_empty() && self.screenshots.is_empty() self.has_view && !self.missing && self.worlds.is_empty() && self.screenshots.is_empty()
} }
/// 这个世界的写操作在飞吗(那一行画「正在处理…」并把按钮拿掉)。
#[must_use]
pub fn busy_with(&self, dir: &str) -> bool {
self.busy.as_deref() == Some(dir)
}
} }
/// presenter 的视图态 → 页面要画的东西(**唯一**的转换点,`app/view/instances.rs` 调)。 /// presenter 的视图态 → 页面要画的东西(**唯一**的转换点,`app/view/instances.rs` 调)。
/// ///
/// 两条口径: /// 三条口径:
/// ///
/// 1. **失败不清列表**:list 失败时保留上一份 `worlds`/`screenshots`(用户还能看见旧列表, /// 1. **失败不清列表**:list 失败时保留上一份 `worlds`/`screenshots`(用户还能看见旧列表,
/// 只是多一条红字),只有"从来没有过快照"时才是一条纯错误态; /// 只是多一条红字),只有"从来没有过快照"时才是一条纯错误态;
/// 2. **只读**:这里没有任何"乐观更新"可言——快照就是磁盘,下一次进详情页或点重试才刷新。 /// 2. **不做乐观更新**:行上的名字只来自快照,写操作的"在飞"只用来置灰那一行;
/// 3. **回执只画一次**:`view.last_op` 是 host 写在快照里的,下一次普通 List 就没了。
#[must_use] #[must_use]
pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves { pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
let texts = Texts::of(catalog); let texts = Texts::of(catalog);
let mut out = Saves { let mut out = Saves {
bound: state.bound.clone(), bound: state.bound.clone(),
loading: state.bound.is_some() && state.view.is_none() && state.failed.is_none(), loading: state.bound.is_some() && state.view.is_none() && state.failed.is_none(),
editing: state.draft.as_ref().map(|draft| Editing {
dir: draft.dir.clone(),
text: draft.text.clone(),
}),
busy: state.pending.as_ref().map(|op| op.dir().to_string()),
..Saves::default() ..Saves::default()
}; };
if let Some(view) = &state.view { if let Some(view) = &state.view {
@@ -125,7 +182,7 @@ pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
.iter() .iter()
.map(|world| Row { .map(|world| Row {
name: world.dir.clone(), name: world.dir.clone(),
detail: world_detail(world.icon, world.modified_ms, texts), detail: rows::world_detail(world.icon, world.modified_ms, texts),
}) })
.collect(); .collect();
out.screenshots = view out.screenshots = view
@@ -133,30 +190,33 @@ pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
.iter() .iter()
.map(|shot| Row { .map(|shot| Row {
name: shot.file.clone(), name: shot.file.clone(),
detail: shot_detail(shot.bytes, shot.modified_ms, texts), detail: rows::shot_detail(shot.bytes, shot.modified_ms, texts),
}) })
.collect(); .collect();
out.count = texts.saves_count(view.world_count(), view.screenshot_count()); out.count = texts.saves_count(view.world_count(), view.screenshot_count());
out.skipped = view.skipped_count(); out.skipped = view.skipped_count();
out.skipped_note = texts.saves_skipped(out.skipped); out.skipped_note = texts.saves_skipped(out.skipped);
out.notice = view.last_op.as_ref().map(|op| op_line(op, texts));
} }
if let Some(failure) = &state.failed { if let Some(failure) = &state.failed {
out.loading = false; out.loading = false;
out.error = Some(failure_line(failure, texts)); out.error = Some(failure_line(failure, texts));
out.retryable = failure.kind != SavesFailureKind::OpenFailed; // 只有「读盘 / 列目录」失败才值得重试;写操作的重试就是再点一次那个按钮。
out.retryable = failure.op.is_none() && failure.kind != SavesFailureKind::OpenFailed;
} }
out out
} }
/// 失败的一句话(**唯一产地**:页面内红字用它;本版没有 Toast——只读列表的失败就地说明更近)。 /// 失败的一句话(**唯一产地**:页面内红字用它,本版没有 Toast)。
#[must_use] #[must_use]
pub fn failure_line(failure: &SavesFailureView, texts: Texts) -> String { pub fn failure_line(failure: &SavesFailureView, texts: Texts) -> String {
let reason = texts.saves_reason(failure.kind); let reason = texts.saves_reason(failure.kind);
let sentence = match failure.kind { let sentence = match (failure.op, failure.kind) {
SavesFailureKind::OpenFailed => texts.saves_open_failed(reason), (Some(SavesFailureOp::Rename), _) => texts.saves_rename_failed(&failure.file, reason),
SavesFailureKind::DirUnusable | SavesFailureKind::InstanceUnavailable => { (Some(SavesFailureOp::Delete), _) => texts.saves_delete_failed(&failure.file, reason),
texts.saves_failed(reason) (Some(SavesFailureOp::Backup), _) => texts.saves_backup_failed(&failure.file, reason),
} (None, SavesFailureKind::OpenFailed) => texts.saves_open_failed(reason),
(None, _) => texts.saves_failed(reason),
}; };
if failure.detail.is_empty() { if failure.detail.is_empty() {
return sentence; return sentence;
@@ -164,44 +224,6 @@ pub fn failure_line(failure: &SavesFailureView, texts: Texts) -> String {
format!("{sentence}({})", failure.detail) format!("{sentence}({})", failure.detail)
} }
/// 世界那一行的副文案:`2024-05-01 10:00 UTC`,有图标时再挂一个「有图标」。
fn world_detail(icon: bool, modified_ms: Option<u64>, texts: Texts) -> String {
let stamp = stamp(modified_ms, texts);
if icon {
format!("{stamp} · {}", texts.saves_world_icon())
} else {
stamp
}
}
/// 截图那一行的副文案:`1.2 MiB · 2024-05-01 10:00 UTC`。
fn shot_detail(bytes: u64, modified_ms: Option<u64>, texts: Texts) -> String {
format!("{} · {}", human_bytes(bytes), stamp(modified_ms, texts))
}
/// mtime(UTC 毫秒)→ `2024-05-01 10:00 UTC`;取不到就说"时间未知"(不拿 0 或当前时间糊)。
///
/// 换算走 `core::launch::time` 的 RFC3339 格式化,再按 `app/view/instances.rs::last_played`
/// 同一刀切开——**UTC 是 core 的口径,不做本地时区换算**(那属时间格式 mission)。
fn stamp(modified_ms: Option<u64>, texts: Texts) -> String {
let Some(text) = modified_ms.and_then(stamp_utc) else {
return texts.saves_time_unknown().to_string();
};
text
}
/// 毫秒 → `YYYY-MM-DD HH:MM UTC`(`None` = 早于 1970 / 溢出,不猜)。
fn stamp_utc(ms: u64) -> Option<String> {
let at = std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_millis(ms))?;
let text = unlucky_core::launch::time::format_rfc3339_millis(at);
// `2024-05-01T10:00:00.000Z` → `2024-05-01 10:00 UTC`(切两段,不解析、不重排)。
// 模板与 `app/view/instances.rs::last_played` **一字不差**:同一句话只能有一份写法,
// 页面源码的字面量白名单里也只有这一条(`instances/tests/i18n.rs` 的 `ALLOWED_LITERALS`)。
let date = text.get(0..10)?;
let time = text.get(11..16)?;
Some(format!("{date} {time} UTC"))
}
/// 画 SAVES 区(实例页唯一入口,`detail.rs` 调它)。 /// 画 SAVES 区(实例页唯一入口,`detail.rs` 调它)。
pub(super) fn section<'a>( pub(super) fn section<'a>(
ui: &'a Ui, ui: &'a Ui,
@@ -212,8 +234,6 @@ pub(super) fn section<'a>(
let mut body = column![head(ui, saves, forced, texts)] let mut body = column![head(ui, saves, forced, texts)]
.spacing(ui.space(Space::X3)) .spacing(ui.space(Space::X3))
.width(Length::Fill); .width(Length::Fill);
// 只读边界固定画在第一行(见模块文档)。
body = body.push(note(ui, texts.saves_read_only()));
if saves.loading { if saves.loading {
body = body.push(note(ui, texts.saves_loading())); body = body.push(note(ui, texts.saves_loading()));
@@ -255,6 +275,10 @@ pub(super) fn section<'a>(
body = body.push(note(ui, texts.saves_empty())); body = body.push(note(ui, texts.saves_empty()));
return frame::card(ui, body); return frame::card(ui, body);
} }
// 写操作成功的回执(重命名 / 删除 / 备份)——放在列表**上面**:用户刚点的就是这里。
if let Some(notice) = saves.notice.as_deref() {
body = body.push(note(ui, notice));
}
// 世界 → 截图,两组各一个小标;某一组为空时只给一句话,不留空小标。 // 世界 → 截图,两组各一个小标;某一组为空时只给一句话,不留空小标。
body = body.push(sub_head(ui, texts.saves_worlds())); body = body.push(sub_head(ui, texts.saves_worlds()));
@@ -262,7 +286,7 @@ pub(super) fn section<'a>(
body = body.push(note(ui, texts.saves_no_worlds())); body = body.push(note(ui, texts.saves_no_worlds()));
} else { } else {
for item in &saves.worlds { for item in &saves.worlds {
body = body.push(entry_row(ui, item)); body = body.push(rows::world(ui, item, saves, forced, texts));
} }
} }
body = body.push(sub_head(ui, texts.saves_screenshots())); body = body.push(sub_head(ui, texts.saves_screenshots()));
@@ -270,7 +294,7 @@ pub(super) fn section<'a>(
body = body.push(note(ui, texts.saves_no_screenshots())); body = body.push(note(ui, texts.saves_no_screenshots()));
} else { } else {
for item in &saves.screenshots { for item in &saves.screenshots {
body = body.push(entry_row(ui, item)); body = body.push(rows::world_plain(ui, item));
} }
} }
if saves.skipped > 0 { if saves.skipped > 0 {
@@ -350,29 +374,6 @@ fn sub_head<'a>(ui: &'a Ui, text: &'a str) -> Element<'a, Message> {
) )
} }
/// 一行:名字 + 副文案(只读,所以没有开关、没有按钮)。
fn entry_row<'a>(ui: &'a Ui, item: &'a Row) -> Element<'a, Message> {
column![
frame::text_role(
ui,
TypeRole::BodyMedium,
false,
item.name.as_str(),
ui.color(Role::OnSurface),
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
item.detail.as_str(),
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
fn note<'a>(ui: &'a Ui, text: &'a str) -> Element<'a, Message> { fn note<'a>(ui: &'a Ui, text: &'a str) -> Element<'a, Message> {
frame::text_role( frame::text_role(
ui, ui,
@@ -0,0 +1,229 @@
//! SAVES 区的一行与它的操作区(M247)。
//!
//! 从 `saves.rs` 拆出:那个文件加完写操作的行内输入与三个按钮之后会越过 400 行硬线
//! (拆法与仓库其它 `-p1/-p2` 一致——**纯搬移 + 一个函数一族**,判据留在 `saves.rs`)。
//!
//! | 函数 | 画什么 |
//! |---|---|
//! | [`world`] | 一个世界:名字 + 副文案 +(空闲)重命名/备份/删除、(在飞)「正在处理…」、(改名中)输入框 + 改名/取消 |
//! | [`world_plain`] | 只读的一行(截图用它:截图不在三个写操作的范围里) |
//! | [`world_detail`] / [`shot_detail`] | 两行副文案 |
//!
//! 三枚按钮与「打开存档文件夹」同一条口径:`buttons::text` + `frame::BASE`(M237:卡片里的控件
//! 把底传成卡底,hover/禁用状态才叠在卡实色上),出图取证时整行一起跟 `--force-state`。
//!
//! 输入框用 `components::inputs::text_field`(与启动参数区同一个控件)。**回车即提交**这件事
//! 由 `text_input` 的 `on_submit` 给不了(`Field` 没有开这个口子),所以提交靠右边的「改名」按钮
//! ——少一个键,但多一条谁都能看见的路。
use iced::widget::{column, row};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_presenter::dto::{human_bytes, SavesOped};
use crate::app::components::buttons::{self, Button};
use crate::app::components::inputs::{text_field, Field};
use crate::app::components::interaction::Interaction;
use crate::theme::Ui;
use super::super::forced_state;
use super::super::frame;
use super::super::state::Message as PageMessage;
use super::super::Message;
use super::super::Texts;
use super::{Row, Saves};
/// 一个世界行:名字 + 副文案 + 操作区(三档状态见模块文档与 `saves.rs`)。
pub(super) fn world<'a>(
ui: &'a Ui,
item: &'a Row,
saves: &'a Saves,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
if let Some(editing) = saves.editing.as_ref().filter(|edit| edit.dir == item.name) {
return rename_row(ui, editing.text.as_str(), forced, texts);
}
let label = labels(ui, item, Role::OnSurface);
let dir = item.name.clone();
let actions: Element<'a, Message> = if saves.busy_with(&dir) {
text_note(ui, texts.saves_busy(), Role::OnSurfaceVariant)
} else {
row![
action(
ui,
texts.saves_rename(),
PageMessage::StartRenameWorld { dir: dir.clone() },
forced
),
action(
ui,
texts.saves_backup(),
PageMessage::BackupWorld { dir: dir.clone() },
forced
),
action(
ui,
texts.saves_delete(),
PageMessage::RequestDeleteWorld { dir },
forced
),
]
.spacing(ui.space(Space::X1))
.align_y(Alignment::Center)
.into()
};
row![label, actions]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center)
.width(Length::Fill)
.into()
}
/// 只读的一行(截图)。与 [`world`] 的区别只有一条:没有操作区。
pub(super) fn world_plain<'a>(ui: &'a Ui, item: &'a Row) -> Element<'a, Message> {
labels(ui, item, Role::OnSurface).into()
}
/// 行内重命名:输入框 + 「改名」/「取消」(占满整行,副文案让位)。
fn rename_row<'a>(
ui: &'a Ui,
text: &'a str,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
let field = text_field(
ui,
Field::new(text)
// M237:输入框住在卡里 ⇒ 状态层与描边叠卡底。
.base(frame::BASE)
.placeholder(texts.saves_rename_placeholder())
.width(Length::Fill),
|value| Message::Instances(PageMessage::RenameWorldChanged(value)),
);
row![
field,
action(
ui,
texts.saves_rename_confirm(),
PageMessage::CommitRenameWorld,
forced
),
action(
ui,
texts.saves_cancel(),
PageMessage::CancelRenameWorld,
forced
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center)
.width(Length::Fill)
.into()
}
/// 名字 + 副文案(两行)。
fn labels<'a>(ui: &'a Ui, item: &'a Row, color: Role) -> iced::widget::Column<'a, Message> {
column![
frame::text_role(
ui,
TypeRole::BodyMedium,
false,
item.name.as_str(),
ui.color(color)
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
item.detail.as_str(),
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
}
/// 一枚行内文字按钮(统一套 `frame::BASE` 与 `--force-state`)。
fn action<'a>(
ui: &'a Ui,
label: &'a str,
message: PageMessage,
forced: Option<Interaction>,
) -> Element<'a, Message> {
buttons::text(
ui,
forced_state(Button::new(label, Message::Instances(message)), forced).base(frame::BASE),
)
}
fn text_note<'a>(ui: &'a Ui, text: &'a str, color: Role) -> Element<'a, Message> {
frame::text_role(ui, TypeRole::BodySmall, false, text, ui.color(color))
}
/// 写操作成功后的一句话(**唯一产地**:卡片里那条成功提示)。
///
/// 备份那句必须带**完整路径**:落点在文件系统某处,用户得知道去哪找。
#[must_use]
pub(crate) fn op_line(op: &SavesOped, texts: Texts) -> String {
match op {
SavesOped::Renamed { from, to } => texts.saves_renamed(from, to),
SavesOped::Deleted { dir } => texts.saves_deleted(dir),
SavesOped::BackedUp {
dir,
path,
bytes,
skipped,
..
} => {
let mut line = texts.saves_backed_up(dir, path, &human_bytes(*bytes));
if *skipped > 0 {
// 备份跳过链接这件事必须说出来:静默少打包几个文件比失败更坏。
line.push_str(" · ");
line.push_str(&texts.saves_backup_skipped(*skipped));
}
line
}
}
}
/// 世界那一行的副文案:`2024-05-01 10:00 UTC`,有图标时再挂一个「有图标」。
pub(super) fn world_detail(icon: bool, modified_ms: Option<u64>, texts: Texts) -> String {
let stamp = stamp(modified_ms, texts);
if icon {
format!("{stamp} · {}", texts.saves_world_icon())
} else {
stamp
}
}
/// 截图那一行的副文案:`1.2 MiB · 2024-05-01 10:00 UTC`。
pub(super) fn shot_detail(bytes: u64, modified_ms: Option<u64>, texts: Texts) -> String {
format!("{} · {}", human_bytes(bytes), stamp(modified_ms, texts))
}
/// mtime(UTC 毫秒)→ `2024-05-01 10:00 UTC`;取不到就说"时间未知"(不拿 0 或当前时间糊)。
///
/// 换算走 `core::launch::time` 的 RFC3339 格式化,再按 `app/view/instances.rs::last_played`
/// 同一刀切开——**UTC 是 core 的口径,不做本地时区换算**(那属时间格式 mission)。
fn stamp(modified_ms: Option<u64>, texts: Texts) -> String {
let Some(text) = modified_ms.and_then(stamp_utc) else {
return texts.saves_time_unknown().to_string();
};
text
}
/// 毫秒 → `YYYY-MM-DD HH:MM UTC`(`None` = 早于 1970 / 溢出,不猜)。
fn stamp_utc(ms: u64) -> Option<String> {
let at = std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_millis(ms))?;
let text = unlucky_core::launch::time::format_rfc3339_millis(at);
// `2024-05-01T10:00:00.000Z` → `2024-05-01 10:00 UTC`(切两段,不解析、不重排)。
// 模板与 `app/view/instances.rs::last_played` **一字不差**:同一句话只能有一份写法,
// 页面源码的字面量白名单里也只有这一条(`instances/tests/i18n.rs` 的 `ALLOWED_LITERALS`)。
let date = text.get(0..10)?;
let time = text.get(11..16)?;
Some(format!("{date} {time} UTC"))
}
@@ -170,6 +170,31 @@ pub enum Message {
ReloadSaves, ReloadSaves,
/// 打开存档文件夹(M235):拉起哪个目录由 host 决定(`saves/` → `screenshots/` → 运行目录)。 /// 打开存档文件夹(M235):拉起哪个目录由 host 决定(`saves/` → `screenshots/` → 运行目录)。
OpenSavesFolder, OpenSavesFolder,
/// 开始**行内重命名**一个世界(M247):那一行就地换成输入框 + 确定/取消。
///
/// 为什么是行内而不是模态输入框:现成的模态队列有 `DialogKind::Input`,但 app 侧的
/// `dialog::Message` 只有「主按钮 / 次按钮 / 关闭」,**没有「输入变了」**这一条——
/// 用输入模态等于重写一套可编辑模态。行内复用 `components::inputs` 的单行框,一处新控件都不用加。
StartRenameWorld {
/// 磁盘目录名
dir: String,
},
/// 行内输入框的内容变了(原样存进 presenter 的草稿,不在前端过滤)。
RenameWorldChanged(String),
/// 提交行内重命名(空名字 / 没改 → 直接收起,不打扰后台)。
CommitRenameWorld,
/// 取消行内重命名。
CancelRenameWorld,
/// 请求删除一个世界(M247):**先弹确认框**,不直接删。
RequestDeleteWorld {
/// 磁盘目录名
dir: String,
},
/// 备份一个世界(M247):导出 zip 到启动器自己的 `exports/saves/`。
BackupWorld {
/// 磁盘目录名
dir: String,
},
} }
#[cfg(test)] #[cfg(test)]
@@ -43,10 +43,13 @@ const ALLOWED: &[&str] = &[
// 翻成「存档目录」用户就对不上那两层文件夹了。 // 翻成「存档目录」用户就对不上那两层文件夹了。
"saves", "saves",
"screenshots", "screenshots",
// M247:`zip` 是**归档格式名**(备份导出成什么,玩家拿去给别的东西解压时看的也是这个词)。
// 中文界面上写「压缩包」会让「备份出来的是 .zip 吗」这个问题没有答案。
"zip",
]; ];
/// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。 /// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。
const PAGE_SOURCES: [(&str, &str); 8] = [ const PAGE_SOURCES: [(&str, &str); 9] = [
("instances.rs", include_str!("../../instances.rs")), ("instances.rs", include_str!("../../instances.rs")),
("instances/detail.rs", include_str!("../detail.rs")), ("instances/detail.rs", include_str!("../detail.rs")),
("instances/demo.rs", include_str!("../demo.rs")), ("instances/demo.rs", include_str!("../demo.rs")),
@@ -63,6 +66,8 @@ const PAGE_SOURCES: [(&str, &str); 8] = [
("instances/mods.rs", include_str!("../mods.rs")), ("instances/mods.rs", include_str!("../mods.rs")),
// M235:SAVES 区(存档 / 截图的只读列表 + 打开文件夹) // M235:SAVES 区(存档 / 截图的只读列表 + 打开文件夹)
("instances/saves.rs", include_str!("../saves.rs")), ("instances/saves.rs", include_str!("../saves.rs")),
// M247:SAVES 区的行与操作区(重命名 / 备份 / 删除 + 行内输入框)
("instances/saves/rows.rs", include_str!("../saves/rows.rs")),
]; ];
/// 页面五件套里**允许出现的字符串字面量**(凡是带 ASCII 单词的都必须在这张表里)。 /// 页面五件套里**允许出现的字符串字面量**(凡是带 ASCII 单词的都必须在这张表里)。
@@ -80,7 +85,7 @@ const PAGE_SOURCES: [(&str, &str); 8] = [
/// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 | /// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 |
/// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) | /// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) |
/// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) | /// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) |
const ALLOWED_LITERALS: [&str; 30] = [ const ALLOWED_LITERALS: [&str; 35] = [
"{date} {time} UTC", "{date} {time} UTC",
"isolated", "isolated",
"shared", "shared",
@@ -125,6 +130,13 @@ const ALLOWED_LITERALS: [&str; 30] = [
// M235:存档区计数那条的**占位符名**(`{worlds}` / `{shots}`),同样不是文案。 // M235:存档区计数那条的**占位符名**(`{worlds}` / `{shots}`),同样不是文案。
"worlds", "worlds",
"shots", "shots",
// M247:删除确认与写操作回执的**占位符名**(`{dir}` 世界名 / `{from}` `{to}` 改名两端 /
// `{path}` 备份落点 / `{size}` 人的可读字节数),都不是文案。
"dir",
"from",
"to",
"path",
"size",
]; ];
/// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。 /// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。
@@ -42,6 +42,7 @@ fn view(instance: &str) -> SavesListView {
file: "notes.txt".to_string(), file: "notes.txt".to_string(),
reason: SavesSkipKind::NotAnImage, reason: SavesSkipKind::NotAnImage,
}], }],
last_op: None,
} }
} }
@@ -151,6 +152,8 @@ fn only_list_failures_offer_a_retry() {
state.changed(view("a")); state.changed(view("a"));
state.failed(SavesFailureView { state.failed(SavesFailureView {
instance: "a".to_string(), instance: "a".to_string(),
file: String::new(),
op: None,
kind, kind,
detail: "Access is denied.".to_string(), detail: "Access is denied.".to_string(),
}); });
@@ -172,6 +175,8 @@ fn a_failure_without_a_snapshot_is_not_an_empty_list() {
let mut state = bound_state("a"); let mut state = bound_state("a");
state.failed(SavesFailureView { state.failed(SavesFailureView {
instance: "a".to_string(), instance: "a".to_string(),
file: String::new(),
op: None,
kind: SavesFailureKind::DirUnusable, kind: SavesFailureKind::DirUnusable,
detail: "Access is denied.".to_string(), detail: "Access is denied.".to_string(),
}); });
@@ -187,6 +192,8 @@ fn a_failure_without_a_snapshot_is_not_an_empty_list() {
fn the_failure_line_follows_the_language_and_keeps_the_detail_in_brackets() { fn the_failure_line_follows_the_language_and_keeps_the_detail_in_brackets() {
let failure = SavesFailureView { let failure = SavesFailureView {
instance: "a".to_string(), instance: "a".to_string(),
file: String::new(),
op: None,
kind: SavesFailureKind::DirUnusable, kind: SavesFailureKind::DirUnusable,
detail: "Access is denied.".to_string(), detail: "Access is denied.".to_string(),
}; };
@@ -234,3 +241,5 @@ fn millisecond_stamps_are_rendered_in_utc() {
stamp(1_709_251_140_000) 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}"
);
}
+90 -5
View File
@@ -216,10 +216,6 @@ impl Texts {
pub(super) fn saves_title(self) -> &'static str { pub(super) fn saves_title(self) -> &'static str {
self.text(Key::InstancesSavesTitle) self.text(Key::InstancesSavesTitle)
} }
/// 「这一版只读」——U3 的边界固定画出来(见 `catalog/instances.rs` 的那段注释)。
pub(super) fn saves_read_only(self) -> &'static str {
self.text(Key::InstancesSavesReadOnly)
}
/// 「3 个世界 · 12 张截图」。 /// 「3 个世界 · 12 张截图」。
pub(super) fn saves_count(self, worlds: usize, shots: usize) -> String { pub(super) fn saves_count(self, worlds: usize, shots: usize) -> String {
self.0.render( self.0.render(
@@ -279,15 +275,104 @@ impl Texts {
self.0 self.0
.render(Key::InstancesSavesOpenFailed, &[("reason", reason)]) .render(Key::InstancesSavesOpenFailed, &[("reason", reason)])
} }
/// 失败大类的短句(`SavesFailureKind` → 一句话)。 /// 失败大类的短句(`SavesFailureKind` → 一句话;写操作那八类各有自己的话)。
pub(super) fn saves_reason(self, kind: SavesFailureKind) -> &'static str { pub(super) fn saves_reason(self, kind: SavesFailureKind) -> &'static str {
self.text(match kind { self.text(match kind {
SavesFailureKind::DirUnusable => Key::InstancesSavesReasonDir, SavesFailureKind::DirUnusable => Key::InstancesSavesReasonDir,
SavesFailureKind::InstanceUnavailable => Key::InstancesSavesReasonInstance, SavesFailureKind::InstanceUnavailable => Key::InstancesSavesReasonInstance,
SavesFailureKind::OpenFailed => Key::InstancesSavesReasonOpen, SavesFailureKind::OpenFailed => Key::InstancesSavesReasonOpen,
SavesFailureKind::WriteUnsafeName => Key::InstancesSavesReasonUnsafeName,
SavesFailureKind::WriteSymlink => Key::InstancesSavesReasonSymlink,
SavesFailureKind::WriteMissing => Key::InstancesSavesReasonMissing,
SavesFailureKind::WriteNotADirectory => Key::InstancesSavesReasonNotDir,
SavesFailureKind::WriteConflict => Key::InstancesSavesReasonConflict,
SavesFailureKind::WriteBoundary => Key::InstancesSavesReasonBoundary,
SavesFailureKind::WriteTooLarge => Key::InstancesSavesReasonTooLarge,
SavesFailureKind::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 { fn text(self, key: Key) -> &'static str {
self.0.text(key) self.0.text(key)
} }
+2 -2
View File
@@ -129,8 +129,8 @@ mod tests;
pub use download::{cancelled_toast, Category, Download, InstallDraft, Loader}; pub use download::{cancelled_toast, Category, Download, InstallDraft, Loader};
pub(crate) use instances::demo_form as demo_launch_form; pub(crate) use instances::demo_form as demo_launch_form;
pub use instances::{ pub use instances::{
mods_failure_line, mods_from_state, saves_failure_line, saves_from_state, InstanceMessage, mods_failure_line, mods_from_state, saves_delete_prompt, saves_failure_line, saves_from_state,
LaunchForm, Mods, Saves, InstanceMessage, LaunchForm, Mods, Saves,
}; };
pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass}; pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome}; pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome};
+25 -4
View File
@@ -1,17 +1,31 @@
//! 存档 / 截图的**只读**列举与「打开文件夹」(M235 · STOREMVP)。 //! 存档 / 截图的列举与「打开文件夹」(M235 · STOREMVP)+ 三个写操作(M247 · SAVESOPS)。
//! //!
//! # 这一版做到哪、不做什么(塔裁决 U3) //! # 读的那一半(M235)
//! //!
//! 用户原话是「存档截图 / 导入」,M230 审计确认三层全缺(core 没有读路径,`saves/` 只在 //! 用户原话是「存档截图 / 导入」,M230 审计确认三层全缺(core 没有读路径,`saves/` 只在
//! `isolation.rs` 的切换探测里被计数;presenter 没有 DTO;i18n 没有键;没有页面)。 //! `isolation.rs` 的切换探测里被计数;presenter 没有 DTO;i18n 没有键;没有页面)。
//! U3 把本 mission 的边界钉成:**只读列表 + 打开文件夹,不做缩略图墙**。理由有两条: //! U3 把 M235 的边界钉成:**只读列表 + 打开文件夹,不做缩略图墙**。理由有两条:
//! //!
//! 1. 缩略图要引图片解码依赖,而 `AGENTS.md` §2 只允许 `palette-extract` 之外不引 image 类依赖; //! 1. 缩略图要引图片解码依赖,而 `AGENTS.md` §2 只允许 `palette-extract` 之外不引 image 类依赖;
//! 2. 截图/导入的产物形态还没定型,先不进产品。 //! 2. 截图/导入的产物形态还没定型,先不进产品。
//! //!
//! 所以这里**只读**:只调 `read_dir` / `metadata` / `symlink_metadata`,不建目录、不写文件、 //! 所以 [`list`] **只读**:只调 `read_dir` / `metadata` / `symlink_metadata`,不建目录、不写文件、
//! 不改名字、不读文件内容(`icon.png` 只判存在——那是将来缩略图墙的输入,不是这一版的解码对象)。 //! 不改名字、不读文件内容(`icon.png` 只判存在——那是将来缩略图墙的输入,不是这一版的解码对象)。
//! //!
//! # 写的那一半(M247)
//!
//! 用户接着拍板:「存档按照主流的方式来」——主流第三方启动器(HMCL / PCL2)的存档区都有
//! **重命名世界 / 删除世界(二次确认)/ 备份导出**。这三个落在 [`write`]:
//!
//! | 操作 | 入口 | 边界 |
//! |---|---|---|
//! | 重命名 | [`write::rename_world`] | 只动 `saves/<old>` → `saves/<new>`;撞名拒 |
//! | 删除 | [`write::delete_world`] | 先过边界校验,再递归删;不跟随符号链接 |
//! | 备份 | [`write::backup_world`] | 流式 zip 到调用方给的目录,时戳命名,不覆盖 |
//!
//! **写操作一律自己重新校验 `run_dir` 与目标路径**(不靠调用方传对)——判据在
//! [`write`] 的模块文档里一条条列着。
//!
//! # 单根语义(D20) //! # 单根语义(D20)
//! //!
//! 入口是**已经派生好的运行目录**([`crate::game::isolation::derive_game_dir`] 的结果), //! 入口是**已经派生好的运行目录**([`crate::game::isolation::derive_game_dir`] 的结果),
@@ -23,6 +37,7 @@
//! //!
//! 新实例两个目录都不存在,这**不是**异常(`exists = false` + 空列表)。只有「存在但不可用」 //! 新实例两个目录都不存在,这**不是**异常(`exists = false` + 空列表)。只有「存在但不可用」
//! (符号链接 / 不是目录 / 读不了)才是 [`SavesError`];**并且照样不建目录**。 //! (符号链接 / 不是目录 / 读不了)才是 [`SavesError`];**并且照样不建目录**。
//! 写操作那边不同:目标不存在就是 [`write::SavesWriteKind::NotFound`](写不下去,得说清楚)。
//! //!
//! # 收纳口径 //! # 收纳口径
//! //!
@@ -40,18 +55,24 @@
//! |---|---| //! |---|---|
//! | [`error`] | 「目录存在但不可用」的失败形状(带 OS 原文) | //! | [`error`] | 「目录存在但不可用」的失败形状(带 OS 原文) |
//! | [`list`] | 列 `<run_dir>/saves` 与 `<run_dir>/screenshots`(纯只读) | //! | [`list`] | 列 `<run_dir>/saves` 与 `<run_dir>/screenshots`(纯只读) |
//! | [`write`] | 重命名 / 删除 / 备份导出(三个写操作 + 边界校验) |
//! //!
//! 「打开文件夹」不在本模块:平台差异收敛在 [`crate::platform::opener::open_dir`] //! 「打开文件夹」不在本模块:平台差异收敛在 [`crate::platform::opener::open_dir`]
//! (`AGENTS.md` §6),本模块一个 `cfg` 都不写。 //! (`AGENTS.md` §6),本模块一个 `cfg` 都不写。
mod error; mod error;
mod list; mod list;
mod write;
pub use error::SavesError; pub use error::SavesError;
pub use list::{ pub use list::{
saves_dir, scan, screenshots_dir, Area, SavesScan, SavesSkip, ScreenshotEntry, SkipKind, saves_dir, scan, screenshots_dir, Area, SavesScan, SavesSkip, ScreenshotEntry, SkipKind,
WorldEntry, IMAGE_EXTENSIONS, SAVES_DIR, SCREENSHOTS_DIR, WORLD_ICON, WorldEntry, IMAGE_EXTENSIONS, SAVES_DIR, SCREENSHOTS_DIR, WORLD_ICON,
}; };
pub use write::{
backup_world, delete_world, rename_world, Backup, SavesWriteError, SavesWriteKind, BACKUP_EXT,
MAX_BACKUP_ENTRIES, MAX_BACKUP_TOTAL_BYTES,
};
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
+159
View File
@@ -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 => "系统错误",
}
}
+365
View File
@@ -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)),
}
}
+371
View File
@@ -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, "被拒的备份动了磁盘");
}
+26 -1
View File
@@ -106,7 +106,6 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("InstancesModsReasonCollision", "a file with that name already exists", "同名文件已存在"), ("InstancesModsReasonCollision", "a file with that name already exists", "同名文件已存在"),
("InstancesModsReasonRename", "the rename failed", "改名失败"), ("InstancesModsReasonRename", "the rename failed", "改名失败"),
("InstancesSavesTitle", "SAVES", "存档"), ("InstancesSavesTitle", "SAVES", "存档"),
("InstancesSavesReadOnly", "Read-only in this version: nothing here is moved or deleted.", "这一版只读:这里不移动、也不删除任何东西。"),
("InstancesSavesCount", "{worlds} worlds · {shots} screenshots", "{worlds} 个世界 · {shots} 张截图"), ("InstancesSavesCount", "{worlds} worlds · {shots} screenshots", "{worlds} 个世界 · {shots} 张截图"),
("InstancesSavesLoading", "Reading saves/…", "正在读取 saves/…"), ("InstancesSavesLoading", "Reading saves/…", "正在读取 saves/…"),
("InstancesSavesEmpty", "No worlds or screenshots yet — play once and they show up here.", "还没有世界或截图——玩一次就会出现在这里。"), ("InstancesSavesEmpty", "No worlds or screenshots yet — play once and they show up here.", "还没有世界或截图——玩一次就会出现在这里。"),
@@ -125,6 +124,32 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("InstancesSavesReasonDir", "the directory is not usable", "目录不可用"), ("InstancesSavesReasonDir", "the directory is not usable", "目录不可用"),
("InstancesSavesReasonInstance", "the instance is gone", "实例已不存在"), ("InstancesSavesReasonInstance", "the instance is gone", "实例已不存在"),
("InstancesSavesReasonOpen", "the file manager did not start", "文件管理器没起来"), ("InstancesSavesReasonOpen", "the file manager did not start", "文件管理器没起来"),
("InstancesSavesRename", "Rename", "重命名"),
("InstancesSavesBackup", "Back up", "备份"),
("InstancesSavesDelete", "Delete", "删除"),
("InstancesSavesRenamePlaceholder", "New name for this world", "这个世界的新名字"),
("InstancesSavesRenameConfirm", "Rename", "改名"),
("InstancesSavesCancel", "Cancel", "取消"),
("InstancesSavesBusy", "Working…", "正在处理…"),
("InstancesSavesDeleteTitle", "Delete this world?", "删除这个世界?"),
("InstancesSavesDeleteBody", "{dir} and everything inside it will be deleted. This cannot be undone.", "{dir} 和它里面的全部内容都会被删除,且不能撤销。"),
("InstancesSavesDeleteConfirm", "Delete world", "删除世界"),
("InstancesSavesRenamed", "Renamed {from} → {to}.", "已把 {from} 改名为 {to}。"),
("InstancesSavesDeleted", "Deleted {dir}.", "已删除 {dir}。"),
("InstancesSavesBackedUp", "Backed up {dir} to {path} ({size}).", "已把 {dir} 备份到 {path}({size})。"),
("InstancesSavesBackupSkipped", "{count} symlinks skipped", "跳过 {count} 个符号链接"),
("InstancesSavesRenameFailed", "Could not rename {file}: {reason}", "重命名 {file} 失败:{reason}"),
("InstancesSavesDeleteFailed", "Could not delete {file}: {reason}", "删除 {file} 失败:{reason}"),
("InstancesSavesBackupFailed", "Could not back up {file}: {reason}", "备份 {file} 失败:{reason}"),
("InstancesSavesReasonUnsafeName", "the name is not usable", "名字不可用"),
("InstancesSavesReasonSymlink", "that path is a symbolic link (not followed)", "那个路径是符号链接(不跟随)"),
("InstancesSavesReasonMissing", "the world is gone", "那个世界已不在"),
("InstancesSavesReasonNotDir", "that is not a directory", "那不是目录"),
("InstancesSavesReasonConflict", "a world with that name already exists", "同名世界已存在"),
("InstancesSavesReasonBoundary", "that path is outside saves/", "那个路径落在 saves/ 之外"),
("InstancesSavesReasonTooLarge", "the world is too large for one zip", "这个世界太大,一个 zip 装不下"),
("InstancesSavesReasonWrite", "the write failed", "写盘失败"),
("InstancesSavesReasonRunning", "the game is running (exit it first)", "游戏正在运行(先退出再操作)"),
]; ];
/// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。 /// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。
+35 -5
View File
@@ -154,12 +154,11 @@ macro_rules! instances_entries {
InstancesModsReasonNotFile => ("that is not a regular file", "那不是普通文件"), InstancesModsReasonNotFile => ("that is not a regular file", "那不是普通文件"),
InstancesModsReasonCollision => ("a file with that name already exists", "同名文件已存在"), InstancesModsReasonCollision => ("a file with that name already exists", "同名文件已存在"),
InstancesModsReasonRename => ("the rename failed", "改名失败"), InstancesModsReasonRename => ("the rename failed", "改名失败"),
// ── 存档区(M235 · STOREMVP):**只读**列 `saves/` 与 `screenshots/` + 打开文件夹。 // ── 存档区(M235 · STOREMVP 读路径 / M247 · SAVESOPS 写操作)。
// U3 的边界写在 `InstancesSavesReadOnly` 里,页面固定画出来——用户点开就看见 // M235 那条「这一版只读」(`InstancesSavesReadOnly`)**已删**:写操作接上之后它是假的,
// 「这一版只读」,不会以为漏了删除按钮。`saves/` / `screenshots/` 是**目录名**, // 边界改由 core 的 `game::saves::write::boundary` 守(越界 / 符号链接 / 撞名都在那里拒)。
// 两套语言都原样保留(与 MODS 区的 `mods` 同一条口径)。 // `saves/` / `screenshots/` 是**目录名**,两套语言都原样保留(与 MODS 区的 `mods` 同一条口径)。
InstancesSavesTitle => ("SAVES", "存档"), InstancesSavesTitle => ("SAVES", "存档"),
InstancesSavesReadOnly => ("Read-only in this version: nothing here is moved or deleted.", "这一版只读:这里不移动、也不删除任何东西。"),
InstancesSavesCount => ("{worlds} worlds · {shots} screenshots", "{worlds} 个世界 · {shots} 张截图"), InstancesSavesCount => ("{worlds} worlds · {shots} screenshots", "{worlds} 个世界 · {shots} 张截图"),
InstancesSavesLoading => ("Reading saves/…", "正在读取 saves/…"), InstancesSavesLoading => ("Reading saves/…", "正在读取 saves/…"),
InstancesSavesEmpty => ("No worlds or screenshots yet — play once and they show up here.", "还没有世界或截图——玩一次就会出现在这里。"), InstancesSavesEmpty => ("No worlds or screenshots yet — play once and they show up here.", "还没有世界或截图——玩一次就会出现在这里。"),
@@ -178,6 +177,37 @@ macro_rules! instances_entries {
InstancesSavesReasonDir => ("the directory is not usable", "目录不可用"), InstancesSavesReasonDir => ("the directory is not usable", "目录不可用"),
InstancesSavesReasonInstance => ("the instance is gone", "实例已不存在"), InstancesSavesReasonInstance => ("the instance is gone", "实例已不存在"),
InstancesSavesReasonOpen => ("the file manager did not start", "文件管理器没起来"), InstancesSavesReasonOpen => ("the file manager did not start", "文件管理器没起来"),
// ── 存档**写操作**(M247 · SAVESOPS):重命名 / 删除(二次确认)/ 备份导出。
// M235 那条「这一版只读」的 `InstancesSavesReadOnly` **已删**:写操作接上之后它就是假的
// (金表同步少了那一行,不是重排)。删除确认框的三句是**不可逆动作**的兜底,不许省。
InstancesSavesRename => ("Rename", "重命名"),
InstancesSavesBackup => ("Back up", "备份"),
InstancesSavesDelete => ("Delete", "删除"),
InstancesSavesRenamePlaceholder => ("New name for this world", "这个世界的新名字"),
InstancesSavesRenameConfirm => ("Rename", "改名"),
InstancesSavesCancel => ("Cancel", "取消"),
InstancesSavesBusy => ("Working…", "正在处理…"),
InstancesSavesDeleteTitle => ("Delete this world?", "删除这个世界?"),
InstancesSavesDeleteBody => ("{dir} and everything inside it will be deleted. This cannot be undone.", "{dir} 和它里面的全部内容都会被删除,且不能撤销。"),
InstancesSavesDeleteConfirm => ("Delete world", "删除世界"),
InstancesSavesRenamed => ("Renamed {from} → {to}.", "已把 {from} 改名为 {to}。"),
InstancesSavesDeleted => ("Deleted {dir}.", "已删除 {dir}。"),
InstancesSavesBackedUp => ("Backed up {dir} to {path} ({size}).", "已把 {dir} 备份到 {path}({size})。"),
InstancesSavesBackupSkipped => ("{count} symlinks skipped", "跳过 {count} 个符号链接"),
InstancesSavesRenameFailed => ("Could not rename {file}: {reason}", "重命名 {file} 失败:{reason}"),
InstancesSavesDeleteFailed => ("Could not delete {file}: {reason}", "删除 {file} 失败:{reason}"),
InstancesSavesBackupFailed => ("Could not back up {file}: {reason}", "备份 {file} 失败:{reason}"),
InstancesSavesReasonUnsafeName => ("the name is not usable", "名字不可用"),
InstancesSavesReasonSymlink => ("that path is a symbolic link (not followed)", "那个路径是符号链接(不跟随)"),
InstancesSavesReasonMissing => ("the world is gone", "那个世界已不在"),
InstancesSavesReasonNotDir => ("that is not a directory", "那不是目录"),
InstancesSavesReasonConflict => ("a world with that name already exists", "同名世界已存在"),
InstancesSavesReasonBoundary => ("that path is outside saves/", "那个路径落在 saves/ 之外"),
InstancesSavesReasonTooLarge => ("the world is too large for one zip", "这个世界太大,一个 zip 装不下"),
InstancesSavesReasonWrite => ("the write failed", "写盘失败"),
// 实例在启动/运行中:三个写操作**主动不写盘**(不是"写盘失败"——Linux 上删一个正在跑的
// 世界是会成功的,所以这条要直说"先退出",别让用户以为是权限问题)。
InstancesSavesReasonRunning => ("the game is running (exit it first)", "游戏正在运行(先退出再操作)"),
} } } }
}; };
} }
+2 -2
View File
@@ -88,8 +88,8 @@ pub use settings::{
}; };
// M235: 实例存档 / 截图的只读列表 // M235: 实例存档 / 截图的只读列表
pub use saves::{ pub use saves::{
SavesArea, SavesCommand, SavesFailureKind, SavesFailureView, SavesListView, SavesSkipKind, SavesArea, SavesCommand, SavesFailureKind, SavesFailureOp, SavesFailureView, SavesListView,
SavesSkipView, ScreenshotView, WorldView, SavesOped, SavesSkipKind, SavesSkipView, ScreenshotView, WorldView,
}; };
// M159: 自更新 // M159: 自更新
pub use update::{ pub use update::{
+131 -88
View File
@@ -1,26 +1,33 @@
//! 实例**存档 / 截图的只读快照**与「打开文件夹」的 IPC 面(M235 · STOREMVP)。 //! 实例**存档 / 截图的快照**与写操作的 IPC 面(M235 · STOREMVP,M247 · SAVESOPS 加写路径)。
//! //!
//! # 三层里的第二层 //! # 三层里的第二层
//! //!
//! 塔裁决 U3 把这一版钉成「只读列表 + 打开文件夹」,不做缩略图墙。三层是: //! 这一版是「只读列表 + 打开文件夹」**加上**三个写操作:`core::game::saves`(读盘/写盘,唯一的
//! `core::game::saves`(读盘,唯一的真相)→ 本模块(IPC 形状)→ //! 真相)→ 本模块(IPC 形状)→ `crates/app-iced/src/pages/instances/saves.rs`(画)。
//! `crates/app-iced/src/pages/instances/saves.rs`(画)。
//! //!
//! | 方向 | 类型 | //! | 方向 | 类型 |
//! |---|---| //! |---|---|
//! | app → host | [`SavesCommand`](列目录 / 打开文件夹) | //! | app → host | [`SavesCommand`](列目录 / 打开文件夹 / 重命名 / 删除 / 备份) |
//! | host → app | [`SavesListView`](整份快照)或 [`SavesFailureView`](具名原因) | //! | host → app | [`SavesListView`](整份快照 + 上一次写操作的[回执](SavesOped))或 [`SavesFailureView`] |
//!
//! # 写操作为什么走同一条回程
//!
//! 三个写操作的结果都是**一份新快照**(`Ok`)+ 一个[回执](SavesOped):写完之后磁盘变了,
//! 界面必须跟着变;而回执是「这次动的是哪一条、动成了什么」。失败则走同一个事件的 `Err` 一支,
//! 带上一个**具名类别**(重命名撞名 / 删除越界 / 备份超限……),见 [`SavesFailureKind`]。
//! 两条都不静默:成功有回执、失败有类别 + OS 原文。
//! //!
//! # 为什么另开一族,不并进 `ModListView` //! # 为什么另开一族,不并进 `ModListView`
//! //!
//! 两者形状相近(都是"列一个实例的某个目录"),但语义不同:mod 有启停(会写盘、要看游戏在不在跑), //! 两者形状相近(都是"列一个实例的某个目录"),但语义不同:mod 有启停(看游戏在不在跑),
//! 存档这一版**没有任何写路径**。合并会让 mod 那边的 `pending` / `running` 字段在存档页变成 //! 存档这边有三个写操作(重命名 / 删除 / 备份)。合并会让两边的 `pending` 字段互相变成
//! 永远为假的分支(M224 拒绝给内容页加第五个 `ContentKind` 是同一条理由:别让穷尽 match 为不相干 //! 永远为假的分支(M224 拒绝给内容页加第五个 `ContentKind` 是同一条理由:别让穷尽 match 为
//! 的字段服务)。 //! 不相干的字段服务)。
//! //!
//! # 文案纪律(M161) //! # 文案纪律(M161)
//! //!
//! 本模块**不带人读散文**:失败给的是结构([`SavesFailureKind`] + 底层 `detail` 串), //! 本模块**不带人读散文**:失败给的是结构([`SavesFailureKind`] + 底层 `detail` 串)、
//! 回执给的是事实(谁改成什么、备份落到哪个路径、几个字节、跳过几个链接),
//! 时间戳给的是**毫秒数**(UTC 毫秒,UI 自己格式化)。文案一律由 app 层按 i18n key 现求。 //! 时间戳给的是**毫秒数**(UTC 毫秒,UI 自己格式化)。文案一律由 app 层按 i18n key 现求。
//! //!
//! # 时间戳为什么是 `Option<u64>` 的毫秒 //! # 时间戳为什么是 `Option<u64>` 的毫秒
@@ -45,6 +52,76 @@ pub enum SavesCommand {
/// 实例 id /// 实例 id
id: InstanceId, id: InstanceId,
}, },
/// 重命名一个世界(M247):`saves/<dir>` → `saves/<to>`。
///
/// 名字规则、撞名、边界校验全在 core(host 只转发):UI 传坏了也不会落到 `saves/` 之外。
RenameWorld {
/// 实例 id
id: InstanceId,
/// 现在的磁盘目录名
dir: String,
/// 新名字(单段;不许带 `/` `\` `:` 与 `..`)
to: String,
},
/// 删除一个世界(M247):递归删 `saves/<dir>`。
///
/// **UI 必须先二次确认**:这是本页唯一不可逆的动作。host 侧再走一遍边界校验
/// (越界 / 符号链接一律拒),不靠确认框把调用方「教好」。
DeleteWorld {
/// 实例 id
id: InstanceId,
/// 磁盘目录名
dir: String,
},
/// 备份一个世界(M247):把一个世界导出成 zip。
///
/// `dest` 是**父目录**(不是文件):`None` = 启动器自己的 `<data>/exports/saves/`。
/// 文件名由 core 按「世界名 + UTC 时戳」生成,撞名顺延,**绝不覆盖**上一份。
BackupWorld {
/// 实例 id
id: InstanceId,
/// 磁盘目录名
dir: String,
/// 导出到哪个父目录(`None` = 启动器自己的 exports)
dest: Option<String>,
},
}
/// 一次写操作成功后的事实(**不含人读文案**:文案由 app 按 i18n key 现求)。
///
/// 为什么成功也要有回执:重命名 / 删除的结果在列表上自己会说话(行改名、行消失),
/// 而**备份成功没有话可说**——zip 落到了文件系统某处,用户必须知道完整路径 + 字节数,
/// 否则「点了备份然后什么也没发生」就是一次静默失败。
///
/// 回执挂在快照上([`SavesListView::last_op`]):下一次普通 `List` 会让它消失,
/// 所以它表达的是「最近这一次操作」,不是一段历史。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavesOped {
/// 改名成功。
Renamed {
/// 原磁盘名
from: String,
/// 新磁盘名
to: String,
},
/// 删除成功。
Deleted {
/// 被删掉的磁盘名
dir: String,
},
/// 备份成功。
BackedUp {
/// 被备份的世界(磁盘名)
dir: String,
/// 落点文件名
file: String,
/// 落点完整路径
path: String,
/// zip 的字节数
bytes: u64,
/// 被跳过的符号链接数(不跟随;0 = 一个都没跳)
skipped: usize,
},
} }
/// 一个世界目录。 /// 一个世界目录。
@@ -125,6 +202,8 @@ pub struct SavesListView {
pub screenshots: Vec<ScreenshotView>, pub screenshots: Vec<ScreenshotView>,
/// 跳过的条目(UI 只需计数 + 一句"为什么")。 /// 跳过的条目(UI 只需计数 + 一句"为什么")。
pub skipped: Vec<SavesSkipView>, pub skipped: Vec<SavesSkipView>,
/// 最近一次写操作的回执(`None` = 这一拍只是列举,没写东西)。
pub last_op: Option<SavesOped>,
} }
impl SavesListView { impl SavesListView {
@@ -158,13 +237,31 @@ impl SavesListView {
pub struct SavesFailureView { pub struct SavesFailureView {
/// 实例 id(迟到/串台的结果靠它判)。 /// 实例 id(迟到/串台的结果靠它判)。
pub instance: InstanceId, pub instance: InstanceId,
/// 涉及的那个世界(写操作才有;读路径给空串)。
///
/// 为什么要这一格:失败那句话是「重命名 **A** 失败:同名世界已存在」——没有名字,
/// 用户不知道该改哪一行(与 `ModFailureView::file` 同一条理由)。
pub file: String,
/// 这是哪一类**写**操作(`None` = 读路径 / 打开文件夹的失败)。
pub op: Option<SavesFailureOp>,
/// 失败大类。 /// 失败大类。
pub kind: SavesFailureKind, pub kind: SavesFailureKind,
/// 底层原文(OS 错误 / 路径细节);只进括号,不进主句。 /// 底层原文(OS 错误 / 路径细节);只进括号,不进主句。
pub detail: String, pub detail: String,
} }
/// 失败大类。 /// 失败出自哪一类写操作(决定失败那句主句用哪个动词)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavesFailureOp {
/// 重命名。
Rename,
/// 删除。
Delete,
/// 备份。
Backup,
}
/// 失败大类(**每一种在 UI 上都有一句话**,没有静默失败)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavesFailureKind { pub enum SavesFailureKind {
/// 实例不在了(快照里找不到),或隔离档位派生不出运行目录。 /// 实例不在了(快照里找不到),或隔离档位派生不出运行目录。
@@ -173,82 +270,28 @@ pub enum SavesFailureKind {
DirUnusable, DirUnusable,
/// 「打开文件夹」失败(目录不存在 / 拉不起文件管理器)。 /// 「打开文件夹」失败(目录不存在 / 拉不起文件管理器)。
OpenFailed, OpenFailed,
/// 写操作:名字过不了规则(空、`.`、`..`、含 `/` `\` `:` NUL)。
WriteUnsafeName,
/// 写操作:目标或路径上的一段是符号链接(不跟随)。
WriteSymlink,
/// 写操作:那个世界(或 `saves/`)已经不在了。
WriteMissing,
/// 写操作:那个名字底下不是目录。
WriteNotADirectory,
/// 写操作:同名世界已存在 / 备份文件名撞满。
WriteConflict,
/// 写操作:路径落在 `saves/` 之外(含层级异常深)。
WriteBoundary,
/// 写操作:备份超过体积或条目上限。
WriteTooLarge,
/// 写操作:实例正在启动/运行中,**一个字节都没碰盘**(见 `host::saves::dispatch`)。
///
/// 单独一类而不是折进 [`WriteFailed`](Self::WriteFailed):Linux 上删一个正在跑的世界**会成功**,
/// 所以这不是"写盘失败了",是"我们主动没写"。界面必须说「游戏正在运行,先退出再操作」。
GameRunning,
/// 写操作:其它系统错误(权限 / 被别的进程占用),OS 原文进 `detail`。
WriteFailed,
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests;
use super::*;
fn view() -> SavesListView {
SavesListView {
instance: "inst".to_string(),
dir: "/tmp/run/saves".to_string(),
exists: true,
worlds: vec![WorldView {
dir: "World".to_string(),
icon: true,
modified_ms: Some(1_700_000_000_000),
}],
screenshots_dir: "/tmp/run/screenshots".to_string(),
screenshots_exists: true,
screenshots: vec![ScreenshotView {
file: "2024-05-01_10.00.00.png".to_string(),
bytes: 1024,
modified_ms: None,
}],
skipped: vec![SavesSkipView {
area: SavesArea::Screenshots,
file: "notes.txt".to_string(),
reason: SavesSkipKind::NotAnImage,
}],
}
}
/// 计数与 IPC round-trip(AGENTS §6:跨界类型必须能序列化)。
#[test]
fn view_counts_and_round_trips() {
let view = view();
assert_eq!(view.world_count(), 1);
assert_eq!(view.screenshot_count(), 1);
assert_eq!(view.skipped_count(), 1);
assert!(!view.is_empty());
let text = serde_json::to_string(&view).expect("序列化");
assert_eq!(
serde_json::from_str::<SavesListView>(&text).expect("反序列化"),
view
);
}
/// 两条指令都要能过 IPC(含「打开文件夹」——它的载荷只有实例 id)。
#[test]
fn commands_round_trip() {
for command in [
SavesCommand::List("inst".to_string()),
SavesCommand::OpenFolder {
id: "inst".to_string(),
},
] {
let text = serde_json::to_string(&command).expect("序列化");
assert_eq!(
serde_json::from_str::<SavesCommand>(&text).expect("反序列化"),
command
);
}
}
/// 两个目录都不存在 ⇒ 空(`is_empty` 只看内容,不看 `exists`:一个目录有空世界也算非空)。
#[test]
fn empty_means_no_worlds_and_no_screenshots() {
let mut view = view();
view.worlds.clear();
view.screenshots.clear();
assert!(view.is_empty(), "没有世界也没有截图就该算空");
view.screenshots.push(ScreenshotView {
file: "a.png".to_string(),
bytes: 0,
modified_ms: None,
});
assert!(!view.is_empty());
}
}
+126
View File
@@ -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());
}
+10 -3
View File
@@ -290,10 +290,17 @@ impl CoreHost {
let running = self.in_flight(&id); let running = self.in_flight(&id);
mods::dispatch(self.env.clone(), self.tx.clone(), *command, running); mods::dispatch(self.env.clone(), self.tx.clone(), *command, running);
} }
// M235:实例存档 / 截图的只读列表 + 打开文件夹 // M235:实例存档 / 截图的只读列表 + 打开文件夹;M247 加了三个写操作。
// (只读,所以不必问"游戏在不在跑";拉起文件管理器在 core 的 platform opener 里) //
// **写操作要看"游戏在不在跑"**(M247 补齐,与 `AppCommand::Mods` 同款判据):
// Windows 上进程占着世界目录 ⇒ rename/删除会撞共享冲突;**Linux 上删一个正在跑的世界
// 会成功**——那是在用户正在玩的时候把存档删掉。所以判据放在 host 这一侧、落 core 之前,
// 在飞就回一条结构化失败「游戏正在运行,先退出再操作」,一个字节都不碰盘。
// 读路径与「打开文件夹」不受影响(它们不改盘)。
AppCommand::Saves(command) => { 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 未落地前用脚本回复(不接真实模型) // AI 对话:agent 的 GUI host 未落地前用脚本回复(不接真实模型)
other @ (AppCommand::AskAgent(_) | AppCommand::StopAgent | AppCommand::SetYolo(_)) => { other @ (AppCommand::AskAgent(_) | AppCommand::StopAgent | AppCommand::SetYolo(_)) => {
+133 -16
View File
@@ -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`——**与启动同一个答案**,不另派生、不做双根合并 | //! | 单根语义(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()` //! ## 为什么不复用启动路径的 `run_dir()`
//! //!
//! 与 [`super::mods`] 同一条理由:`host::launch` 的 `run_dir()` 顺带把「这次用哪个档位」写进实例 //! 与 [`super::mods`] 同一条理由:`host::launch` 的 `run_dir()` 顺带把「这次用哪个档位」写进实例
@@ -22,10 +51,18 @@
//! 真正拉起文件管理器的是 [`unlucky_core::platform::opener::open_dir`](`AGENTS.md` §6: //! 真正拉起文件管理器的是 [`unlucky_core::platform::opener::open_dir`](`AGENTS.md` §6:
//! 平台差异只许收敛在 `core::platform`,本文件一个 `cfg` 都没有,也没有一次裸 spawn)。 //! 平台差异只许收敛在 `core::platform`,本文件一个 `cfg` 都没有,也没有一次裸 spawn)。
//! //!
//! ## 备份落点为什么是启动器自己的 `exports/`
//!
//! 本仓没有 `rfd` 这类原生文件选择框依赖(`core::launch::crash_bundle::default_target_dir` 的
//! 文档里记着同一条理由),所以「用户选定目录」先退化成:`<data>/exports/saves/`。
//! 命令带一个可选的 `dest`,将来接了选择框、或 CLI 想指定别处,都不用改 core。
//! 成功的回执里带**完整路径 + 字节数**,用户不用猜落点。
//!
//! ## 失败口径 //! ## 失败口径
//! //!
//! 一律走 [`crate::dto::SavesFailureView`](结构 + OS 原文),**不拼中文**:文案由 app 层按 //! 一律走 [`crate::dto::SavesFailureView`](结构 + OS 原文),**不拼中文**:文案由 app 层按
//! i18n key 现求(M161 的纪律)。三类失败见 [`crate::dto::SavesFailureKind`]。 //! i18n key 现求(M161 的纪律)。读路径三类失败见 [`crate::dto::SavesFailureKind`],
//! 写路径八类见同一个枚举的 `Write*` 那一族——每一种在 UI 上都有一句话。
use std::path::PathBuf; use std::path::PathBuf;
@@ -35,13 +72,16 @@ use unlucky_core::game::saves::{self, SavesError, SavesScan};
use unlucky_core::platform::opener; use unlucky_core::platform::opener;
use crate::dto::{ use crate::dto::{
locate, InstanceId, InstanceSummary, SavesArea, SavesCommand, SavesFailureKind, locate, InstanceId, InstanceSummary, SavesArea, SavesCommand, SavesFailureKind, SavesFailureOp,
SavesFailureView, SavesListView, SavesSkipKind, SavesSkipView, ScreenshotView, WorldView, SavesFailureView, SavesListView, SavesOped, SavesSkipKind, SavesSkipView, ScreenshotView,
WorldView,
}; };
use super::env::HostEnv; use super::env::HostEnv;
use super::job::{spawn, JobSender}; use super::job::{spawn, JobSender};
mod write;
/// 后台线程回报的一次结果(`HostTask::Saves` 的载荷)。 /// 后台线程回报的一次结果(`HostTask::Saves` 的载荷)。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Report { 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` 的唯一入口)。 /// 一条指令 → 后台线程(`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 { match command {
SavesCommand::List(id) => spawn_list(env, tx, id), SavesCommand::List(id) => spawn_list(env, tx, id),
SavesCommand::OpenFolder { id } => spawn_open_folder(env, tx, id), SavesCommand::OpenFolder { id } => spawn_open_folder(env, tx, id),
SavesCommand::RenameWorld { id, dir, to } => 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> { fn list(env: &HostEnv, id: &str) -> Result<SavesListView, SavesFailureView> {
let (instance, run_dir) = target(env, id)?; let (instance, run_dir) = target(env, id)?;
let scan = saves::scan(&run_dir).map_err(|error| unusable(&instance.id, &error))?; snapshot(&instance, &run_dir, None)
Ok(view(&instance, &scan)) }
/// 重新扫一遍目录 → 快照(可挂一条写操作的回执)。
///
/// 写成功后**一律**走这里:界面画的是磁盘上的真相,不是"我以为我改成了什么"。
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); let target = open_target(&scan, &run_dir);
opener::open_dir(&target) opener::open_dir(&target)
.map_err(|detail| failure(&instance.id, SavesFailureKind::OpenFailed, detail))?; .map_err(|detail| failure(&instance.id, SavesFailureKind::OpenFailed, detail))?;
Ok(view(&instance, &scan)) Ok(view(&instance.id, &scan, None))
} }
/// 打开哪一个目录:`saves/` → `screenshots/` → 运行目录(取第一个存在的)。 /// 打开哪一个目录:`saves/` → `screenshots/` → 运行目录(取第一个存在的)。
@@ -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 instances = super::game::instances(env);
let Some(instance) = locate(&instances, id) else { let Some(instance) = locate(&instances, id) else {
return Err(failure( return Err(failure(
@@ -128,7 +238,7 @@ fn target(env: &HostEnv, id: &str) -> Result<(InstanceSummary, PathBuf), SavesFa
} }
/// 实例的运行目录(D20 覆盖链,与启动同一份派生;**不写盘**)。 /// 实例的运行目录(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 global = super::game::global_mode(env);
let config = read_instance_config(&env.layout, &instance.id) let config = read_instance_config(&env.layout, &instance.id)
.map_err(|error| format!("读实例设置失败:{error}"))?; .map_err(|error| format!("读实例设置失败:{error}"))?;
@@ -144,9 +254,9 @@ fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String>
} }
/// 快照映射:core 的扫描结果 → DTO(路径串在 host 这一侧才成形)。 /// 快照映射:core 的扫描结果 → DTO(路径串在 host 这一侧才成形)。
fn view(instance: &InstanceSummary, scan: &SavesScan) -> SavesListView { fn view(instance: &str, scan: &SavesScan, last_op: Option<SavesOped>) -> SavesListView {
SavesListView { SavesListView {
instance: instance.id.clone(), instance: instance.to_string(),
dir: scan.dir.display().to_string(), dir: scan.dir.display().to_string(),
exists: scan.exists, exists: scan.exists,
worlds: scan worlds: scan
@@ -188,12 +298,19 @@ fn view(instance: &InstanceSummary, scan: &SavesScan) -> SavesListView {
}, },
}) })
.collect(), .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 { SavesFailureView {
instance: instance.to_string(), instance: instance.to_string(),
file: String::new(),
op: None,
kind, kind,
detail: detail.into(), detail: detail.into(),
} }
+49 -1
View File
@@ -6,6 +6,10 @@
//! 抓不住什么:`OpenFolder` **不在这里真跑**(它会拉起用户的文件管理器);它唯一的判断 //! 抓不住什么:`OpenFolder` **不在这里真跑**(它会拉起用户的文件管理器);它唯一的判断
//! (打开哪个目录)由 [`open_target_degrades_from_saves_to_run_dir`] 这条纯函数用例钉住, //! (打开哪个目录)由 [`open_target_degrades_from_saves_to_run_dir`] 这条纯函数用例钉住,
//! 真正 spawn 的那一步在 core 的 `platform::opener` 用例里。 //! 真正 spawn 的那一步在 core 的 `platform::opener` 用例里。
//!
//! 「游戏在跑就拒」(M247)由 [`write_commands_do_not_touch_the_disk_while_the_game_is_running`]
//! 从 **`dispatch` 那一层**验:三条写命令一条都不落 core(盘上指纹前后一致 + 备份落点没被建),
//! 并且回一条 `GameRunning` 的具名失败。
use super::*; use super::*;
use crate::dto::{InstanceRuntime, LaunchArgsView, LoaderInfo, LoaderKind}; 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` 被当成"读到了"、跳过原因被折叠、字节数丢了。 /// 抓得住:派生用错目录、`icon.png` 被当成"读到了"、跳过原因被折叠、字节数丢了。
@@ -194,6 +204,44 @@ fn every_core_skip_reason_keeps_its_own_kind() {
reason, reason,
}], }],
}; };
assert_eq!(view(&instance(), &scan).skipped[0].reason, expected); assert_eq!(view(ID, &scan, None).skipped[0].reason, expected);
} }
} }
/// `(相对路径, 是否目录, 字节数, 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(),
};
}
+167
View File
@@ -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
}
+1 -1
View File
@@ -53,7 +53,7 @@ mod tiles;
pub use live::MAX_LIVE_LINES; pub use live::MAX_LIVE_LINES;
pub use mods::ModsState; pub use mods::ModsState;
pub use saves::SavesState; pub use saves::{RenameDraft, SavesOp, SavesState};
pub use tiles::{grid_positions, TileCell}; pub use tiles::{grid_positions, TileCell};
/// 一份加载器版本清单查询的结果(`Ok` = 最新在前;`Err` = 人读原因)。 /// 一份加载器版本清单查询的结果(`Ok` = 最新在前;`Err` = 人读原因)。
+137 -70
View File
@@ -1,8 +1,41 @@
//! 实例存档 / 截图快照的落地(M235 · STOREMVP)。 //! 实例存档 / 截图快照的落地(M235 · STOREMVP)+ 写操作的三样交互态(M247 · SAVESOPS)。
//! //!
//! 从 `state.rs` 拆出(那个文件贴着 400 行硬线),形状与 [`super::mods`] 一致: //! 从 `state.rs` 拆出(那个文件贴着 400 行硬线),形状与 [`super::mods`] 一致:
//! **另一个 `impl AppState` 块**,路径与可见性都不变,`apply_core` 只有一行投递。 //! **另一个 `impl AppState` 块**,路径与可见性都不变,`apply_core` 只有一行投递。
//! //!
//! # 写操作的三样交互态(M247)
//!
//! | 字段 | 是什么 | 为什么必须住在这里 |
//! |---|---|---|
//! | [`SavesState::draft`] | 行内重命名的输入缓冲 | 页面模型每帧从快照重建(`from_state`),打字过程中的内容不在快照里 ⇒ 不留在这里就会每打一个字被事件冲掉 |
//! | [`SavesState::pending`] | 哪个世界的哪个写操作在飞 | 与 [`super::mods::ModsState::pending`] **同一条先例**:只用来把那一行的按钮置灰,**不做乐观回显**(列表只跟快照走) |
//! | [`SavesState::confirm`] | 删除确认框**正在等谁** | 模态队列只画框、不记得"确认之后要删哪个世界";而点主按钮的那一刻是 `app/update/dialog.rs`,那里只能从状态里取 |
//!
//! ## 生命周期(谁写、谁清、何时必须清)
//!
//! | 字段 | 谁写 | 谁清 | 必须清的时刻 |
//! |---|---|---|---|
//! | `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` 与 //! 用户很可能点开 A 的详情页、还没等后台回来就切到 B。所以 `view.instance` 与
//! [`SavesState::bound`] 不一致的结果**直接丢掉**(与 [`super::mods`] 同一条纪律)。 //! [`SavesState::bound`] 不一致的结果**直接丢掉**(与 [`super::mods`] 同一条纪律)。
use crate::dialogs::DialogId;
use crate::dto::{SavesFailureView, SavesListView}; use crate::dto::{SavesFailureView, SavesListView};
mod interaction;
use super::AppState; use super::AppState;
/// 行内重命名的输入缓冲(见模块文档的三样交互态)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenameDraft {
/// 哪个世界(磁盘名,提交时原样带回去当「源」)。
pub dir: String,
/// 输入框里的内容(**原样**:合不合法由 core 判,页面只管显示,不做前端过滤)。
pub text: String,
}
/// 一次在飞的写操作(只用来置灰那一行,列表仍然只跟快照)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SavesOp {
/// 重命名中。
Rename {
/// 磁盘名
dir: String,
},
/// 删除中。
Delete {
/// 磁盘名
dir: String,
},
/// 备份中。
Backup {
/// 磁盘名
dir: String,
},
}
impl SavesOp {
/// 这个操作动的是哪个世界。
#[must_use]
pub fn dir(&self) -> &str {
match self {
Self::Rename { dir } | Self::Delete { dir } | Self::Backup { dir } => dir,
}
}
}
/// 存档区的视图态。 /// 存档区的视图态。
#[derive(Debug, Clone, Default, PartialEq)] #[derive(Debug, Clone, Default, PartialEq)]
pub struct SavesState { pub struct SavesState {
@@ -31,10 +106,25 @@ pub struct SavesState {
pub view: Option<SavesListView>, pub view: Option<SavesListView>,
/// 最近一条失败(具名原因;成功一次就清掉)。 /// 最近一条失败(具名原因;成功一次就清掉)。
pub failed: Option<SavesFailureView>, pub failed: Option<SavesFailureView>,
/// 行内重命名的输入缓冲(`None` = 没有在改名)。
pub draft: Option<RenameDraft>,
/// 在飞的写操作(`None` = 空闲)。
pub pending: Option<SavesOp>,
/// 删除确认框正在等谁:**(框的 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 { impl SavesState {
/// 切到另一个实例(换实例必须清空:A 的存档不能画在 B 的详情页上)。 /// 切到另一个实例(换实例必须清空:A 的存档不能画在 B 的详情页上)。
///
/// 三样交互态一起清:换实例之后还留着「A 的行内草稿 / A 的删除确认」是错位状态
/// (点确定会去删 B 页上的 A,或者把 A 的草稿提交成 B 的改名)。
pub(super) fn bind(&mut self, instance: Option<&str>) { pub(super) fn bind(&mut self, instance: Option<&str>) {
if self.bound.as_deref() == instance { if self.bound.as_deref() == instance {
return; return;
@@ -42,9 +132,27 @@ impl SavesState {
self.bound = instance.map(str::to_string); self.bound = instance.map(str::to_string);
self.view = None; self.view = None;
self.failed = None; self.failed = None;
self.reset_interaction();
}
/// 清掉三样交互态(换实例 / 操作回程时用)。
fn reset_interaction(&mut self) {
self.draft = None;
self.pending = None;
self.confirm = None;
self.closed_confirm = None;
} }
/// 一条结果落地(`Ok` = 快照整体替换,`Err` = 记一条具名失败;迟到/串台的丢掉)。 /// 一条结果落地(`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>) { pub fn apply(&mut self, result: Result<SavesListView, SavesFailureView>) {
match result { match result {
Ok(view) => self.changed(view), 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) { pub fn changed(&mut self, view: SavesListView) {
if self.bound.as_deref() != Some(view.instance.as_str()) { if self.bound.as_deref() != Some(view.instance.as_str()) {
return; return;
} }
self.failed = None; 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.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 列表保留——用户还能看见旧列表,只是多一条红字)。 /// 失败落地(只记**当前绑定实例**的;old 列表保留——用户还能看见旧列表,只是多一条红字)。
@@ -67,6 +198,10 @@ impl SavesState {
return; return;
} }
self.failed = Some(failure); self.failed = Some(failure);
// 失败也收尾:按钮不能永远灰着(用户要能直接再点一次重试)。
// `confirm` **不动**(与 [`Self::changed`] 同一条理由):一条读盘失败不该把用户
// 刚点出来的删除确认框变成"点了没反应"。
self.pending = None;
} }
/// 当前绑定实例的列表是否已经有一份快照。 /// 当前绑定实例的列表是否已经有一份快照。
@@ -84,72 +219,4 @@ impl AppState {
} }
#[cfg(test)] #[cfg(test)]
mod tests { 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());
}
}
@@ -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)
}
}
+252
View File
@@ -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(), "取走之后草稿还在");
}