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