diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index e3073b8..0cf752e 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -71,6 +71,8 @@ mod boot_probe; mod construct; mod mods_shot; mod refresh; +// M235: 出图取证用的 SAVES 夹具(`ULL_SAVES_FIXTURE=1`) +mod saves_shot; mod theme; /// iced 侧的 presenter 接线层(M119):物理目录 `src/presenter/`,逻辑名字在 `crate::app::state` 下。 diff --git a/crates/app-iced/src/app/state/refresh.rs b/crates/app-iced/src/app/state/refresh.rs index e21d4b8..8f85cf2 100644 --- a/crates/app-iced/src/app/state/refresh.rs +++ b/crates/app-iced/src/app/state/refresh.rs @@ -4,9 +4,9 @@ //! 由 [`App::view_dirty`](super::App) 门控(M90 r2/P2-1 起):只有真的改了状态的事情才置位, //! 30 Hz 空转不重建(重建要把 N 个实例的名字/版本串克隆一遍)。 -use unlucky_presenter::dto::{AppCommand, ModsCommand}; +use unlucky_presenter::dto::{AppCommand, ModsCommand, SavesCommand}; -use super::mods_shot; +use super::{mods_shot, saves_shot}; use unlucky_presenter::toast::ToastKind; use crate::app::view; @@ -64,6 +64,8 @@ impl App { // **必须在建模型之前**:它会把快照写进 presenter 状态,而下一行 `model()` 就读那份状态 // (放在 `model()` 之后等于这一帧画的是旧快照——M224 出图实测:注入的夹具要等下一帧)。 self.sync_mods(); + // M235:详情页 ⇄ SAVES 区的绑定与取数(换实例就重新列一次存档)。同样是**建模型之前**。 + self.sync_saves(); // M224:启停失败的提示语(presenter 只发结构,文案在这里按 i18n 现求)。 self.note_mods_failure(); @@ -155,6 +157,37 @@ impl App { .dispatch(AppCommand::Mods(Box::new(ModsCommand::List(id)))); } + /// 详情页 ⇄ SAVES 区的绑定与取数(M235)。 + /// + /// 判据与 `sync_mods` **一字不差**:绑定变了(进详情页 / 换实例 / 退出详情页)才动; + /// 绑定之后手上没有快照 ⇒ 派一条 `AppCommand::Saves(List)` 去后台列目录。 + /// + /// 不弹 Toast:存档区的失败就地画在卡片里(带「重试」),再弹一条是同一件事说两遍。 + fn sync_saves(&mut self) { + let wanted = match self.state.route() { + Route::Detail(id) => Some(id.clone()), + _ => None, + }; + if self.state.saves.bound == wanted { + return; + } + self.state.bind_saves(wanted.as_deref()); + let Some(id) = wanted else { + return; + }; + // 出图取证(`ULL_SAVES_FIXTURE=1`):后台往返赶不上第 3 帧出图,注入一份固定快照。 + // 产品路径读不到这个变量 ⇒ 下面这行什么都不做(见 `saves_shot` 的模块文档)。 + if saves_shot::enabled() { + self.state.saves.changed(saves_shot::view(&id)); + return; + } + // **不走 `App::dispatch`**:那条路会为「等第一条事件」把帧订阅打开 3 秒, + // 这里只是"进详情页顺手后台列一次目录",结果在下一拍心跳落地就够了(M224 同款理由: + // 走观察窗会把 M192 的「空闲不烧帧」守卫打红)。 + self.backend + .dispatch(AppCommand::Saves(Box::new(SavesCommand::List(id)))); + } + /// 启停失败的提示语:每条**新**失败弹一次 Toast。 /// /// 判据是 `(实例, 文件, 大类)` 三元组:同一个文件连点两次、每次都失败在同一个大类上时, diff --git a/crates/app-iced/src/app/state/saves_shot.rs b/crates/app-iced/src/app/state/saves_shot.rs new file mode 100644 index 0000000..cbba64e --- /dev/null +++ b/crates/app-iced/src/app/state/saves_shot.rs @@ -0,0 +1,81 @@ +//! 出图取证用的 SAVES 夹具(`ULL_SAVES_FIXTURE=1`)。 +//! +//! # 它解决什么 +//! +//! D30 的 SAVES 区截图需要一屏**有内容的列表**(世界 + 截图)。产品路径的列表来自后台线程 +//! (列 `saves/` → 快照),而截图通道在第 3 帧就出图(`CAPTURE_AT_FRAME`):那条后台往返来不及 +//! 完成,截出来会是「正在读取 saves/…」。M157 / M222 / M223 / M224 都是同一个问题,处理方式一致: +//! **状态注入**,注入点放在「快照 → 页面模型」之前,于是转换逻辑照样被执行。 +//! +//! # 边界(不要把它读成产品能力) +//! +//! - 只在环境变量存在时生效;产品路径读不到它 ⇒ 一帧都不影响; +//! - 它注入的是**快照**([`SavesListView`]),不是页面模型——`Saves::from_state` 的时间格式化、 +//! 计数、「有图标」标记走的都是产品代码; +//! - 真实链路由端到端用例钉住:`core::game::saves::tests`(真盘只读列举)与 +//! `presenter::host::saves::tests`(派生运行目录 → 快照)。 +//! +//! ```text +//! ULL_SAVES_FIXTURE=1 ULL_INSTANCE_STATE=demo ULL_START_ROUTE=instances \ +//! unlucky-launcher-iced --screenshot=saves.png --software-renderer +//! ``` + +/// 夹具开关是否打开(`ULL_SAVES_FIXTURE` 非空且不为 `0`)。 +#[must_use] +pub fn enabled() -> bool { + std::env::var("ULL_SAVES_FIXTURE") + .map(|value| { + let value = value.trim(); + !value.is_empty() && value != "0" + }) + .unwrap_or(false) +} + +/// 2024-05-01 10:00 UTC(写死的 UTC 毫秒,不是"现在")。 +/// +/// 写死有两个原因:两张主题图要能逐像素对照;而且它能证明「毫秒 → `YYYY-MM-DD HH:MM UTC`」 +/// 这条换算在页面上真的走通了(`docs` 里的期望值就是这个时刻)。 +const NEWEST: u64 = 1_714_557_600_000; +/// 比 [`NEWEST`] 早 32 天。 +const MIDDLE: u64 = NEWEST - 32 * 24 * 3_600_000; +/// 比 [`NEWEST`] 早 42 天。 +const OLDEST: u64 = NEWEST - 42 * 24 * 3_600_000; + +/// 一份固定快照(实例 id 用调用方给的那个)。 +/// +/// 其中一个世界/一张截图的 mtime 是 `None`:那是「时间未知」这条分支的唯一出图证据。 +#[must_use] +pub fn view(instance: &str) -> unlucky_presenter::dto::SavesListView { + use unlucky_presenter::dto::{SavesListView, ScreenshotView, WorldView}; + + let world = |dir: &str, icon: bool, modified_ms: Option| WorldView { + dir: dir.to_string(), + icon, + modified_ms, + }; + let shot = |file: &str, bytes: u64, modified_ms: Option| ScreenshotView { + file: file.to_string(), + bytes, + modified_ms, + }; + + SavesListView { + instance: instance.to_string(), + dir: "D:/ULL/.ULL/data/instances/Better-MC/saves".to_string(), + exists: true, + worlds: vec![ + world("Better World", true, Some(NEWEST)), + world("Redstone Lab", true, Some(MIDDLE)), + world("Skyblock", false, Some(OLDEST)), + world("Test Flat", false, None), + ], + screenshots_dir: "D:/ULL/.ULL/data/instances/Better-MC/screenshots".to_string(), + screenshots_exists: true, + screenshots: vec![ + shot("2024-05-01_10.00.00.png", 1_887_436, Some(NEWEST)), + shot("2024-04-30_21.14.05.png", 2_097_152, Some(MIDDLE)), + shot("2024-04-20_08.30.00.png", 964_608, None), + ], + skipped: Vec::new(), + } +} diff --git a/crates/app-iced/src/app/update/instances.rs b/crates/app-iced/src/app/update/instances.rs index 0d7dbec..ba7e1a8 100644 --- a/crates/app-iced/src/app/update/instances.rs +++ b/crates/app-iced/src/app/update/instances.rs @@ -11,7 +11,7 @@ //! 要等回程事件,写盘失败时旧快照回来必须留住用户敲的值(M157 r3 复审 P2-2)。 use iced::Task; -use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch, ModsCommand}; +use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch, ModsCommand, SavesCommand}; use crate::pages::InstanceMessage; @@ -40,6 +40,22 @@ impl App { }))); return Task::none(); } + // M235:重新列一次存档(失败后的「重试」也走这条)。 + // + // 与 `SetModEnabled` 一样放在 `bound_to` 守卫**之前**:存档区只认"当前选中实例", + // 与启动参数表单播没播种无关(那条守卫是给表单草稿用的)。 + InstanceMessage::ReloadSaves => { + self.dispatch(AppCommand::Saves(Box::new(SavesCommand::List(instance)))); + return Task::none(); + } + // M235:打开存档文件夹——拉起哪个目录由 host 定(`saves/` → `screenshots/` → 运行目录), + // 真正的 opener 在 `core::platform::opener`(平台差异不散到 app 这一层)。 + InstanceMessage::OpenSavesFolder => { + self.dispatch(AppCommand::Saves(Box::new(SavesCommand::OpenFolder { + id: instance, + }))); + return Task::none(); + } // 表单还没对着这个实例(首帧还没播种):不接编辑,免得改到别的实例上。 _ if !self.launch_form.bound_to(&instance) => return Task::none(), InstanceMessage::Jvm(text) => { diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index ff108dc..b6858ed 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -257,6 +257,7 @@ pub(super) fn model( logs: logs_model(state).with_status(lang), // M224:详情页 MODS 区的列表 / 启停(presenter 的视图态 → 展示字段) mods: instances::mods_of(state, catalog), + saves: instances::saves_of(state, catalog), // M159:更新快照 → 页面视图(设置页「更新」区 + 顶部提示条都读它) update: update_of(&state.update), // M201:设置组快照(`SettingsChanged` 的回流结果)→ 页面视图。设置页的下载组从这里按 diff --git a/crates/app-iced/src/app/view/instances.rs b/crates/app-iced/src/app/view/instances.rs index 3d734a3..4cc6fa5 100644 --- a/crates/app-iced/src/app/view/instances.rs +++ b/crates/app-iced/src/app/view/instances.rs @@ -25,7 +25,7 @@ use unlucky_presenter::dto::{ }; use unlucky_presenter::state::AppState; -use crate::pages::{Detail, LaunchFacts, Meta, Mods}; +use crate::pages::{Detail, LaunchFacts, Meta, Mods, Saves}; /// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。 pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String { @@ -273,3 +273,11 @@ pub(super) fn last_played(raw: Option<&str>, catalog: Catalog) -> String { pub(super) fn mods_of(state: &AppState, catalog: Catalog) -> Mods { crate::pages::mods_from_state(&state.mods, catalog) } + +/// 存档 / 截图的展示模型(M235):presenter 的视图态 → 页面要画的东西。 +/// +/// 与 [`mods_of`] 同一条接法:转换本体在 `pages::instances::saves::from_state`(文案要用 +/// `Texts`),这里只把"当前语言"给过去。 +pub(super) fn saves_of(state: &AppState, catalog: Catalog) -> Saves { + crate::pages::saves_from_state(&state.saves, catalog) +} diff --git a/crates/app-iced/src/pages/instances.rs b/crates/app-iced/src/pages/instances.rs index 7f28ac1..1e049e7 100644 --- a/crates/app-iced/src/pages/instances.rs +++ b/crates/app-iced/src/pages/instances.rs @@ -51,6 +51,8 @@ mod detail; mod launch_args; // M224: 详情页的 MODS 区(本地 mod 列表 + 启停开关) mod mods; +// M235: 详情页的 SAVES 区(存档 / 截图的只读列表 + 打开文件夹) +mod saves; mod state; mod text; @@ -61,6 +63,17 @@ mod tests; pub(crate) use data::{DEMO, NOTE, SELECTED}; pub(crate) use demo::{demo, demo_form}; pub use mods::{from_state as mods_from_state, Mods}; +pub use saves::{from_state as saves_from_state, Saves}; + +/// 存档区失败的一句话(app 层就地画红字用;取词入口仍是 [`Texts`])。 +/// +/// 本版**不弹 Toast**:只读列表的失败离用户的眼睛很近(就在卡片里),再弹一条是刷屏。 +pub fn saves_failure_line( + failure: &unlucky_presenter::dto::SavesFailureView, + catalog: unlucky_i18n::Catalog, +) -> String { + saves::failure_line(failure, Texts::of(catalog)) +} /// 启停失败/读目录失败的一句话(app 层弹 Toast 用;取词入口仍是 [`Texts`])。 pub fn mods_failure_line( @@ -142,16 +155,30 @@ pub fn view<'a>( metrics, list_column(ui, block, forced, texts), match &block.detail { - Some(detail) => { - detail::detail_column(ui, metrics, detail, form, &model.mods, forced, texts) - } + Some(detail) => detail::detail_column( + ui, + metrics, + detail, + form, + &model.mods, + &model.saves, + forced, + texts, + ), None => detail::detail_empty(ui, texts), }, ), _ => match &block.detail { - Some(detail) => { - detail::narrow_detail(ui, metrics, detail, form, &model.mods, forced, texts) - } + Some(detail) => detail::narrow_detail( + ui, + metrics, + detail, + form, + &model.mods, + &model.saves, + forced, + texts, + ), None => frame::body(metrics, detail::detail_empty(ui, texts)), }, }; diff --git a/crates/app-iced/src/pages/instances/detail.rs b/crates/app-iced/src/pages/instances/detail.rs index 9e64361..817c010 100644 --- a/crates/app-iced/src/pages/instances/detail.rs +++ b/crates/app-iced/src/pages/instances/detail.rs @@ -35,6 +35,7 @@ pub(super) fn detail_column<'a>( detail: &'a Detail, form: &'a LaunchForm, mods: &'a super::Mods, + saves: &'a super::Saves, forced: Option, texts: Texts, ) -> Element<'a, Message> { @@ -51,6 +52,9 @@ pub(super) fn detail_column<'a>( // 排在它后面意味着「本地 mod 列表」永远在折叠线以下(M224 出图实测:1180×1000 完全看不见)。 // 顺序因此是:身份(页头 + 元信息)→ 内容(MODS)→ 选项(启动参数)→ 动作。 body = body.push(super::mods::section(ui, mods, forced, texts)); + // M235:SAVES 区紧跟 MODS 区(同为"内容"),理由与 MODS 一样:排在启动参数卡后面会落到 + // 折叠线以下——详情列是一个滚动区,而启动参数卡本身就有一屏高。 + body = body.push(super::saves::section(ui, saves, forced, texts)); // M157:启动参数区在正文段之后、动作行之前(详情列 424px 内,卡片式)。 body = body.push(super::launch_args::section(ui, &detail.launch, form, texts)); @@ -69,6 +73,7 @@ pub(super) fn narrow_detail<'a>( detail: &'a Detail, form: &'a LaunchForm, mods: &'a super::Mods, + saves: &'a super::Saves, forced: Option, texts: Texts, ) -> Element<'a, Message> { @@ -93,6 +98,7 @@ pub(super) fn narrow_detail<'a>( body = push_text(ui, metrics, body, detail); // 顺序与宽窗那一支一致:内容(MODS)在选项(启动参数)之前,理由见 `detail_column`。 body = body.push(super::mods::section(ui, mods, forced, texts)); + body = body.push(super::saves::section(ui, saves, forced, texts)); body = body.push(super::launch_args::section(ui, &detail.launch, form, texts)); body = body.push(frame::text_role( diff --git a/crates/app-iced/src/pages/instances/saves.rs b/crates/app-iced/src/pages/instances/saves.rs new file mode 100644 index 0000000..af1d5ff --- /dev/null +++ b/crates/app-iced/src/pages/instances/saves.rs @@ -0,0 +1,380 @@ +//! 实例详情页的 **SAVES 区**(M235 · STOREMVP):存档 / 截图的**只读**列表 + 打开文件夹。 +//! +//! # 位置与形状 +//! +//! 挂在详情列里、MODS 区之后、启动参数卡**之前**(`pages/instances/detail.rs` 的 `detail_column`)。 +//! 与 MODS 区同一套卡片口径:[`frame::card`] + 一个 `LabelMedium` 小标 + 内容。 +//! +//! **为什么在启动参数之前**:与 MODS 区同一个理由(M224 出图实测)——详情列整列是一个滚动区, +//! 而启动参数卡本身就有一屏高;排在它后面等于这整块落在折叠线以下、根本看不见。顺序因此是: +//! 身份(页头 + 元信息)→ 内容(MODS → SAVES)→ 选项(启动参数)→ 动作。 +//! +//! # 为什么不自己再套一层滚动区 +//! +//! 详情列**整列**已经是滚动区(M157 r2)。M224 踩过一次「滚动条里的滚动条」:1180×1000 下两个条 +//! 都认得出来,用户不知道该拖哪一个。这里同样只排内容,长度交给外层那一条。 +//! +//! # 只读(U3 的边界,页面固定画出来) +//! +//! 塔裁决 U3 把这一版钉成「只读列表 + 打开文件夹」:没有删除、没有重命名、没有导入、**没有缩略图** +//! (缩略图要引图片解码依赖,`AGENTS.md` §2 不允许)。所以卡片里第一行永远画 +//! [`Texts::saves_read_only`]——用户点开就看见"这一版只读",不会以为漏了删除按钮。 +//! +//! # 失败与重试 +//! +//! 读盘失败画一条红字 + 一个「重试」(再发一条 `SavesCommand::List`)。**打开文件夹失败不画重试**: +//! 那个动作的重试就是再点一次按钮,给一个会去重新列目录的「重试」是错的affordance +//! (两种失败共用 `detail`,只有大类不同,见 [`Saves::retryable`])。 + +use iced::widget::{column, container, row}; +use iced::{Alignment, Element, Length}; + +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::state::SavesState; + +use crate::app::components::buttons::{self, Button}; +use crate::app::components::interaction::Interaction; +use crate::theme::Ui; + +use super::super::frame; +use super::super::Message; +use super::forced_state; +use super::state::Message as PageMessage; +use super::Texts; + +/// 列表里的一行(世界 / 截图共用:一个名字 + 一条副文案)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Row { + /// 世界目录名或截图文件名(磁盘原样)。 + pub name: String, + /// 右侧/下方副文案(时间、大小、图标标记,已由这一层拼好)。 + pub detail: String, +} + +/// SAVES 区的展示模型(`Model::saves`)。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Saves { + /// 这份数据属于哪个实例(`None` = 不在详情页 / 还没请求过)。 + pub bound: Option, + /// 正在读盘(进详情页首帧:请求已发出、快照还没回来)。 + pub loading: bool, + /// 读盘/打开失败的人读原因(app 层按 i18n 拼好)。 + pub error: Option, + /// 这一条失败能不能靠「再列一次」解决(打开文件夹失败不算)。 + pub retryable: bool, + /// 手里有没有一份**快照**(`false` = 只拿到了失败,"还没有世界"那几句不许画)。 + pub has_view: bool, + /// 两个目录都不存在(新实例的常态:还没进过游戏)。 + pub missing: bool, + /// 世界行。 + pub worlds: Vec, + /// 截图行(新的在前,顺序由 core 给)。 + pub screenshots: Vec, + /// 计数行(`3 个世界 · 12 张截图`):**在这一层算好**,页面只借 `&str`。 + /// + /// 为什么不留到页面里 `format!`:iced 的 `Element` 只借这一帧的数据,页面函数返回的是 + /// **借用局部变量的树**,局部 `String` 一出栈就悬空(`E0515`)。与 [`super::mods::Mods`] + /// 的 `count` 同一条做法。 + pub count: String, + /// 跳过条目数(符号链接 / 非图片 / 读不出)。 + pub skipped: usize, + /// 跳过提示(`跳过 3 项`)。 + pub skipped_note: String, +} + +impl Saves { + /// 有没有拿到一份快照(成功或失败都算;`loading` 中不算)。 + #[must_use] + pub fn loaded(&self) -> bool { + self.bound.is_some() && !self.loading + } + + /// 两个目录都在、但里面什么都没有(**必须先有一份快照**:纯失败态不是"这里空的")。 + #[must_use] + pub fn nothing_yet(&self) -> bool { + self.has_view && !self.missing && self.worlds.is_empty() && self.screenshots.is_empty() + } +} + +/// presenter 的视图态 → 页面要画的东西(**唯一**的转换点,`app/view/instances.rs` 调)。 +/// +/// 两条口径: +/// +/// 1. **失败不清列表**:list 失败时保留上一份 `worlds`/`screenshots`(用户还能看见旧列表, +/// 只是多一条红字),只有"从来没有过快照"时才是一条纯错误态; +/// 2. **只读**:这里没有任何"乐观更新"可言——快照就是磁盘,下一次进详情页或点重试才刷新。 +#[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(), + ..Saves::default() + }; + if let Some(view) = &state.view { + out.has_view = true; + out.missing = !view.exists && !view.screenshots_exists; + // 计数直接问快照(`view.world_count()`),不从刚装好的 `out.worlds` 再数一遍—— + // M224 出图实测过"先算计数后装列表"画出 `0 启用 · 0 禁用` 的那种顺序错,这里从根上绕开。 + out.worlds = view + .worlds + .iter() + .map(|world| Row { + name: world.dir.clone(), + detail: world_detail(world.icon, world.modified_ms, texts), + }) + .collect(); + out.screenshots = view + .screenshots + .iter() + .map(|shot| Row { + name: shot.file.clone(), + detail: 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); + } + if let Some(failure) = &state.failed { + out.loading = false; + out.error = Some(failure_line(failure, texts)); + out.retryable = failure.kind != SavesFailureKind::OpenFailed; + } + out +} + +/// 失败的一句话(**唯一产地**:页面内红字用它;本版没有 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) + } + }; + if failure.detail.is_empty() { + return sentence; + } + 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, + saves: &'a Saves, + forced: Option, + texts: Texts, +) -> Element<'a, Message> { + 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())); + return frame::card(ui, body); + } + if let Some(error) = saves.error.as_deref() { + body = body.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + error, + ui.color(Role::Error), + )); + if saves.retryable { + body = body.push(buttons::text( + ui, + forced_state( + Button::new( + texts.saves_retry(), + Message::Instances(PageMessage::ReloadSaves), + ), + forced, + ), + )); + } + // 旧列表还在时照旧画出来(失败不清列表),用户能看见上一次读到的东西; + // 一份快照都没有时到此为止——**不许**在"读不了"下面再补一句"这里还没有世界"。 + if !saves.has_view { + return frame::card(ui, body); + } + } + if saves.missing { + body = body.push(note(ui, texts.saves_missing_dir())); + return frame::card(ui, body); + } + if saves.nothing_yet() { + body = body.push(note(ui, texts.saves_empty())); + return frame::card(ui, body); + } + + // 世界 → 截图,两组各一个小标;某一组为空时只给一句话,不留空小标。 + body = body.push(sub_head(ui, texts.saves_worlds())); + if saves.worlds.is_empty() { + 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(sub_head(ui, texts.saves_screenshots())); + if saves.screenshots.is_empty() { + body = body.push(note(ui, texts.saves_no_screenshots())); + } else { + for item in &saves.screenshots { + body = body.push(entry_row(ui, item)); + } + } + if saves.skipped > 0 { + body = body.push(note(ui, &saves.skipped_note)); + } + + frame::card(ui, body) +} + +/// 小标行:`SAVES` + 计数 + 右侧「打开存档文件夹」。 +fn head<'a>( + ui: &'a Ui, + saves: &'a Saves, + forced: Option, + texts: Texts, +) -> Element<'a, Message> { + // 左半是「存档 + 计数」,右半是「打开存档文件夹」:左半用一个吃满宽度的容器顶开, + // 按钮才会落在卡片右边缘(与 `head` 之外那些卡片的动作行同一套摆法)。 + let mut left = row![frame::text_role( + ui, + TypeRole::LabelMedium, + false, + texts.saves_title(), + ui.color(Role::OnSurfaceVariant), + )] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center); + + // 计数只在"真有一份可用快照"时画:读盘中 / 读失败 / 空目录画 0·0 会像"这里什么都没有"。 + let show_count = saves.has_view + && saves.error.is_none() + && !saves.missing + && !(saves.worlds.is_empty() && saves.screenshots.is_empty()); + if show_count { + left = left.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + saves.count.as_str(), + ui.color(Role::OnSurfaceVariant), + )); + } + + // 「打开文件夹」始终可点:目录还没有时 host 会退到运行目录,失败也有就地说明 + // (见 `host::saves::open_target`)。 + let open = buttons::text( + ui, + forced_state( + Button::new( + texts.saves_open_folder(), + Message::Instances(PageMessage::OpenSavesFolder), + ), + forced, + ), + ); + row![ + container(left) + .width(Length::Fill) + .align_y(Alignment::Center), + open + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center) + .width(Length::Fill) + .into() +} + +fn sub_head<'a>(ui: &'a Ui, text: &'a str) -> Element<'a, Message> { + frame::text_role( + ui, + TypeRole::LabelSmall, + false, + text, + ui.color(Role::OnSurfaceVariant), + ) +} + +/// 一行:名字 + 副文案(只读,所以没有开关、没有按钮)。 +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, + TypeRole::BodySmall, + false, + text, + ui.color(Role::OnSurfaceVariant), + ) +} diff --git a/crates/app-iced/src/pages/instances/state.rs b/crates/app-iced/src/pages/instances/state.rs index bd9528b..f67b6ff 100644 --- a/crates/app-iced/src/pages/instances/state.rs +++ b/crates/app-iced/src/pages/instances/state.rs @@ -163,6 +163,13 @@ pub enum Message { /// 期望的启用状态 enabled: bool, }, + /// 重新列一次存档(M235):读盘失败时的「重试」,也用于用户手动刷新。 + /// + /// 只发一条 `SavesCommand::List`——没有别的状态要复位(失败不清绑定,见 presenter 的 + /// `SavesState::failed`)。 + ReloadSaves, + /// 打开存档文件夹(M235):拉起哪个目录由 host 决定(`saves/` → `screenshots/` → 运行目录)。 + OpenSavesFolder, } #[cfg(test)] diff --git a/crates/app-iced/src/pages/instances/tests.rs b/crates/app-iced/src/pages/instances/tests.rs index a256cae..0fb85e0 100644 --- a/crates/app-iced/src/pages/instances/tests.rs +++ b/crates/app-iced/src/pages/instances/tests.rs @@ -7,6 +7,9 @@ mod i18n; /// M157 的门限判据(三态 chips 在版本不够时谁可点)。 mod launch_args; +/// M235 的存档区模型判据(快照 → 行 / 计数 / 失败线 / UTC 时间)。 +mod saves; + use super::super::layout::source; use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass}; diff --git a/crates/app-iced/src/pages/instances/tests/i18n.rs b/crates/app-iced/src/pages/instances/tests/i18n.rs index 9e732b5..07f97ea 100644 --- a/crates/app-iced/src/pages/instances/tests/i18n.rs +++ b/crates/app-iced/src/pages/instances/tests/i18n.rs @@ -29,14 +29,24 @@ use super::super::Texts; /// - `Java`:运行时品牌名(`JAVA` 那格的值域里也全是 `Temurin 21` 这类品牌串),翻成「爪哇」是错的; /// - `pid`:进程号的技术标识。用户拿它去任务管理器里搜进程,翻成「进程」就丢了这条可操作性。 const ALLOWED: &[&str] = &[ - "Java", "pid", "JVM", "OpenGL", "Vulkan", "MiB", + "Java", + "pid", + "JVM", + "OpenGL", + "Vulkan", + "MiB", // M224:`mods` 是**目录名**(文件系统事实)。中文界面上写成「模组目录」会让用户找不到那个 // 文件夹——`mods/` 就是他要打开的那个东西,所以两套语言都原样写它。 "mods", + // M235:`saves` / `screenshots` 同样是**目录名**(文件系统事实,玩家要照着去找)。 + // 「正在读取 saves/…」「还没有 saves/ 或 screenshots/ 目录」里的这两个词不许翻—— + // 翻成「存档目录」用户就对不上那两层文件夹了。 + "saves", + "screenshots", ]; /// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。 -const PAGE_SOURCES: [(&str, &str); 7] = [ +const PAGE_SOURCES: [(&str, &str); 8] = [ ("instances.rs", include_str!("../../instances.rs")), ("instances/detail.rs", include_str!("../detail.rs")), ("instances/demo.rs", include_str!("../demo.rs")), @@ -51,6 +61,8 @@ const PAGE_SOURCES: [(&str, &str); 7] = [ ), // M224:MODS 区(本地 mod 列表 + 启停开关) ("instances/mods.rs", include_str!("../mods.rs")), + // M235:SAVES 区(存档 / 截图的只读列表 + 打开文件夹) + ("instances/saves.rs", include_str!("../saves.rs")), ]; /// 页面五件套里**允许出现的字符串字面量**(凡是带 ASCII 单词的都必须在这张表里)。 @@ -68,7 +80,7 @@ const PAGE_SOURCES: [(&str, &str); 7] = [ /// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 | /// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) | /// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) | -const ALLOWED_LITERALS: [&str; 28] = [ +const ALLOWED_LITERALS: [&str; 30] = [ "{date} {time} UTC", "isolated", "shared", @@ -110,6 +122,9 @@ const ALLOWED_LITERALS: [&str; 28] = [ "disabled", "count", "file", + // M235:存档区计数那条的**占位符名**(`{worlds}` / `{shots}`),同样不是文案。 + "worlds", + "shots", ]; /// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。 diff --git a/crates/app-iced/src/pages/instances/tests/saves.rs b/crates/app-iced/src/pages/instances/tests/saves.rs new file mode 100644 index 0000000..bc3632c --- /dev/null +++ b/crates/app-iced/src/pages/instances/tests/saves.rs @@ -0,0 +1,236 @@ +//! 「存档区」(M235)的**模型**判据:快照 → 页面要画的东西。 +//! +//! 为什么只测一个纯函数:iced 0.14 的 `Element` 没有内省接口(与 `tests/launch_args.rs` +//! 同一取舍),画出来的行在测试里拿不到。所以把"快照 → 行/计数/失败线"全部压在 +//! [`saves::from_state`] 里,在这里逐条钉住——页面那一支只负责把这些字段画出来。 +//! +//! 守门形状:`detail.rs` 必须调 `saves::section(ui, saves, …)`(`instances/tests.rs` 的源码 +//! 结构断言盯着 `detail_column` 里 MODS 与 SAVES 的先后;这里守模型)。 + +use unlucky_presenter::dto::{ + SavesArea, SavesFailureKind, SavesFailureView, SavesListView, SavesSkipKind, SavesSkipView, + ScreenshotView, WorldView, +}; +use unlucky_presenter::state::SavesState; + +use super::super::saves::{failure_line, from_state}; +use super::super::Texts; +use unlucky_i18n::Lang; + +/// 2024-05-01 10:00 UTC(UTC 毫秒)。 +const STAMP: u64 = 1_714_557_600_000; + +fn view(instance: &str) -> SavesListView { + SavesListView { + instance: instance.to_string(), + dir: "C:/mc/saves".to_string(), + exists: true, + worlds: vec![WorldView { + dir: "Better World".to_string(), + icon: true, + modified_ms: Some(STAMP), + }], + screenshots_dir: "C:/mc/screenshots".to_string(), + screenshots_exists: true, + screenshots: vec![ScreenshotView { + file: "2024-05-01_10.00.00.png".to_string(), + bytes: 2_048, + modified_ms: None, + }], + skipped: vec![SavesSkipView { + area: SavesArea::Screenshots, + file: "notes.txt".to_string(), + reason: SavesSkipKind::NotAnImage, + }], + } +} + +/// 已绑定实例的视图态(`bind` 是 presenter 内部的 `pub(super)`,这里直接写字段—— +/// 它本来就是公开状态的一部分,与 `AppState::bind_saves` 走的是同一条路)。 +fn bound_state(instance: &str) -> SavesState { + SavesState { + bound: Some(instance.to_string()), + ..SavesState::default() + } +} + +/// 快照 → 行:世界带「有图标」,截图带大小;时间**按 UTC 切**,取不到就说"时间未知"。 +#[test] +fn the_snapshot_becomes_rows_with_stamps_and_badges() { + let mut state = bound_state("a"); + state.changed(view("a")); + let saves = from_state(&state, Lang::ZhCn.catalog()); + + assert!(!saves.loading); + assert!(!saves.retryable, "成功的快照不该带重试"); + assert_eq!(saves.worlds.len(), 1); + assert_eq!(saves.worlds[0].name, "Better World"); + let zh_icon = Texts::of(Lang::ZhCn.catalog()).saves_world_icon(); + assert!( + saves.worlds[0].detail.contains("2024-05-01 10:00 UTC"), + "世界的副文案要带 UTC 时间:{:?}", + saves.worlds[0].detail + ); + assert!( + saves.worlds[0].detail.contains(zh_icon), + "有 icon.png 的世界要带标记:{:?}", + saves.worlds[0].detail + ); + + assert_eq!(saves.screenshots.len(), 1); + assert!( + saves.screenshots[0].detail.contains("2.0 KB"), + "截图的副文案要带大小:{:?}", + saves.screenshots[0].detail + ); + assert!( + saves.screenshots[0] + .detail + .contains(Texts::of(Lang::ZhCn.catalog()).saves_time_unknown()), + "mtime 缺失时要说『时间未知』,不许拿 0 或当前时间糊:{:?}", + saves.screenshots[0].detail + ); + + // 计数与跳过:3 个世界 / 12 张截图这种句子在这一层拼好。 + assert_eq!(saves.count, "1 个世界 · 1 张截图"); + assert_eq!(saves.skipped, 1); + assert_eq!(saves.skipped_note, "跳过 1 项"); +} + +/// 首帧(已绑定、还没有快照)是 `loading`;没有绑定则什么都不算"已加载"。 +#[test] +fn a_bound_instance_without_a_snapshot_is_loading() { + let state = bound_state("a"); + let saves = from_state(&state, Lang::En.catalog()); + assert!(saves.loading); + assert!(!saves.loaded()); + + let idle = from_state(&SavesState::default(), Lang::En.catalog()); + assert!(!idle.loading, "不在详情页不该画『正在读取』"); + assert!(!idle.loaded()); +} + +/// 两个目录都不存在 ⇒ `missing`(画"还没有目录"那句,而不是"这里空空的")。 +/// 目录在但里面什么都没有 ⇒ `nothing_yet`。 +#[test] +fn missing_directories_and_empty_ones_are_different_states() { + let mut state = bound_state("a"); + let mut gone = view("a"); + gone.exists = false; + gone.screenshots_exists = false; + gone.worlds.clear(); + gone.screenshots.clear(); + gone.skipped.clear(); + state.changed(gone); + let saves = from_state(&state, Lang::En.catalog()); + assert!(saves.missing); + assert!(!saves.nothing_yet(), "缺目录不该被当成『目录在但是空的』"); + + let mut state = bound_state("a"); + let mut empty = view("a"); + empty.worlds.clear(); + empty.screenshots.clear(); + empty.skipped.clear(); + state.changed(empty); + let saves = from_state(&state, Lang::En.catalog()); + assert!(!saves.missing); + assert!(saves.nothing_yet()); +} + +/// 失败:读盘失败**可重试**,打开文件夹失败**不可重试**(重试按钮会去重新列目录,那是错的动作)。 +/// +/// 抓得住:把两种失败合成一条「重试」;也抓得住"失败顺手把列表清掉"(用户看不见上一次读到的东西)。 +#[test] +fn only_list_failures_offer_a_retry() { + for (kind, retryable) in [ + (SavesFailureKind::DirUnusable, true), + (SavesFailureKind::InstanceUnavailable, true), + (SavesFailureKind::OpenFailed, false), + ] { + let mut state = bound_state("a"); + state.changed(view("a")); + state.failed(SavesFailureView { + instance: "a".to_string(), + kind, + detail: "Access is denied.".to_string(), + }); + let saves = from_state(&state, Lang::En.catalog()); + assert!(!saves.loading); + assert_eq!(saves.retryable, retryable, "{kind:?} 的重试口径错了"); + assert!(saves.error.is_some(), "{kind:?} 没有画出失败"); + assert_eq!(saves.worlds.len(), 1, "{kind:?} 把旧列表清掉了"); + assert_eq!(saves.screenshots.len(), 1); + } +} + +/// **只拿到失败、一份快照都没有**时,不许被画成"这里还没有世界"。 +/// +/// 抓得住:`nothing_yet()` 只看"两个列表都空"——那样"读不了"会顶着两句"这里空空的", +/// 用户会把权限问题读成"我没有存档"。 +#[test] +fn a_failure_without_a_snapshot_is_not_an_empty_list() { + let mut state = bound_state("a"); + state.failed(SavesFailureView { + instance: "a".to_string(), + kind: SavesFailureKind::DirUnusable, + detail: "Access is denied.".to_string(), + }); + let saves = from_state(&state, Lang::En.catalog()); + assert!(saves.error.is_some()); + assert!(!saves.has_view, "没有快照却记成了有"); + assert!(!saves.nothing_yet(), "纯失败态被画成了空列表"); + assert_eq!(saves.count, "", "没有快照不该画计数"); +} + +/// 失败主句跟着语言与大类走,OS 原文只进括号(M161 的文案纪律)。 +#[test] +fn the_failure_line_follows_the_language_and_keeps_the_detail_in_brackets() { + let failure = SavesFailureView { + instance: "a".to_string(), + kind: SavesFailureKind::DirUnusable, + detail: "Access is denied.".to_string(), + }; + let zh = failure_line(&failure, Texts::of(Lang::ZhCn.catalog())); + assert!(zh.starts_with("读取 saves/ 失败:"), "{zh}"); + assert!(zh.ends_with("(Access is denied.)"), "{zh}"); + + let en = failure_line(&failure, Texts::of(Lang::En.catalog())); + assert!(en.starts_with("Could not read saves/:"), "{en}"); + assert!(en.contains("Access is denied."), "{en}"); + + // 没有原文时不留一对空括号。 + let bare = SavesFailureView { + detail: String::new(), + ..failure + }; + let text = failure_line(&bare, Texts::of(Lang::ZhCn.catalog())); + assert!(!text.contains('('), "空原文留下了一对括号:{text}"); +} + +/// 时间换算:毫秒 → `YYYY-MM-DD HH:MM UTC`(跨年/闰日各钉一条,别只信一条)。 +#[test] +fn millisecond_stamps_are_rendered_in_utc() { + let stamp = |ms: u64| { + let mut state = bound_state("a"); + let mut view = view("a"); + view.worlds[0].modified_ms = Some(ms); + state.changed(view); + from_state(&state, Lang::En.catalog()).worlds[0] + .detail + .clone() + }; + // 1970-01-01 00:00 UTC(纪元) + assert!(stamp(0).starts_with("1970-01-01 00:00 UTC"), "{}", stamp(0)); + // 2024-05-01 10:00 UTC + assert!( + stamp(STAMP).starts_with("2024-05-01 10:00 UTC"), + "{}", + stamp(STAMP) + ); + // 2024-02-29 23:59 UTC(闰日) + assert!( + stamp(1_709_251_140_000).starts_with("2024-02-29 23:59 UTC"), + "{}", + stamp(1_709_251_140_000) + ); +} diff --git a/crates/app-iced/src/pages/instances/text.rs b/crates/app-iced/src/pages/instances/text.rs index f048f82..9706b6a 100644 --- a/crates/app-iced/src/pages/instances/text.rs +++ b/crates/app-iced/src/pages/instances/text.rs @@ -26,7 +26,7 @@ //! `Row.status`(「阶段 · 百分比」是一整句),与元信息卡同一层——页面只画模型给的字符串。 use unlucky_i18n::{Catalog, Key}; -use unlucky_presenter::dto::ModFailureKind; +use unlucky_presenter::dto::{ModFailureKind, SavesFailureKind}; /// 实例页 + 详情列的文案(一个 `Catalog` 的薄包装,`Copy`)。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -212,10 +212,85 @@ impl Texts { }) } + // ------------------------------------------------------------- 存档区(M235) + 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( + Key::InstancesSavesCount, + &[ + ("worlds", &worlds.to_string()), + ("shots", &shots.to_string()), + ], + ) + } + pub(super) fn saves_loading(self) -> &'static str { + self.text(Key::InstancesSavesLoading) + } + pub(super) fn saves_empty(self) -> &'static str { + self.text(Key::InstancesSavesEmpty) + } + pub(super) fn saves_missing_dir(self) -> &'static str { + self.text(Key::InstancesSavesMissingDir) + } + pub(super) fn saves_worlds(self) -> &'static str { + self.text(Key::InstancesSavesWorlds) + } + pub(super) fn saves_screenshots(self) -> &'static str { + self.text(Key::InstancesSavesScreenshots) + } + pub(super) fn saves_no_worlds(self) -> &'static str { + self.text(Key::InstancesSavesNoWorlds) + } + pub(super) fn saves_no_screenshots(self) -> &'static str { + self.text(Key::InstancesSavesNoScreenshots) + } + /// 「有图标」:只判 `icon.png` 存不存在(不解码,缩略图墙不在 U3 范围内)。 + pub(super) fn saves_world_icon(self) -> &'static str { + self.text(Key::InstancesSavesWorldIcon) + } + pub(super) fn saves_time_unknown(self) -> &'static str { + self.text(Key::InstancesSavesTimeUnknown) + } + /// 「跳过 3 项」。 + pub(super) fn saves_skipped(self, count: usize) -> String { + self.0 + .render(Key::InstancesSavesSkipped, &[("count", &count.to_string())]) + } + pub(super) fn saves_open_folder(self) -> &'static str { + self.text(Key::InstancesSavesOpenFolder) + } + pub(super) fn saves_retry(self) -> &'static str { + self.text(Key::InstancesSavesRetry) + } + /// 读目录失败的主句:`读取 saves/ 失败:目录不可用`。 + pub(super) fn saves_failed(self, reason: &str) -> String { + self.0 + .render(Key::InstancesSavesFailed, &[("reason", reason)]) + } + /// 打开文件夹失败的主句:`打开文件夹失败:文件管理器没起来`。 + pub(super) fn saves_open_failed(self, reason: &str) -> String { + self.0 + .render(Key::InstancesSavesOpenFailed, &[("reason", reason)]) + } + /// 失败大类的短句(`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, + }) + } + fn text(self, key: Key) -> &'static str { self.0.text(key) } - /// 这一页的全部 key(守门测试的取样口:**从 `Key::ALL` 过滤出来**,不是手抄一份清单)。 /// /// 域判据是前缀 `Instances`——本族的 key 名是契约(`catalog/instances.rs` 里一条一条写着), diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index 991dcec..8725cf1 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -128,7 +128,10 @@ pub(crate) mod settings; 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, InstanceMessage, LaunchForm, Mods}; +pub use instances::{ + mods_failure_line, mods_from_state, 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}; pub use message::{Action, Message, UpdateAction}; diff --git a/crates/app-iced/src/pages/model.rs b/crates/app-iced/src/pages/model.rs index c7c7c3c..fbef7b9 100644 --- a/crates/app-iced/src/pages/model.rs +++ b/crates/app-iced/src/pages/model.rs @@ -145,6 +145,11 @@ pub struct Model { /// 谁填:`app/view/instances.rs` 的 `mods_of`(presenter 的 `AppState::mods` → 展示字段)。 /// 空态(不在详情页 / 还没请求)是 `Mods::default()`,页面不画这一区。 pub mods: super::instances::Mods, + /// 存档 / 截图(M235):详情页 SAVES 区的只读列表(见 [`super::instances::Saves`])。 + /// + /// 谁填:`app/view/instances.rs` 的 `saves_of`(presenter 的 `AppState::saves` → 展示字段)。 + /// 空态(不在详情页 / 还没请求)是 `Saves::default()`,页面照旧画这一次请求前的样子。 + pub saves: super::instances::Saves, /// 取证用的强制交互态(`--force-state`):`Some` 时页面里的控件按该态画。 /// /// M90 r2(P3-1)之前这个开关只作用到 `shell` 回退路径,产品页是**静默失效**的; @@ -175,6 +180,7 @@ impl Model { // 见 `pages/settings/download.rs` 的「没有快照就画演示行」)。 settings: Vec::new(), mods: super::instances::Mods::default(), + saves: super::instances::Saves::default(), lang: Lang::En, forced: None, } diff --git a/crates/core/src/game/mod.rs b/crates/core/src/game/mod.rs index 53e4325..5f0dce1 100644 --- a/crates/core/src/game/mod.rs +++ b/crates/core/src/game/mod.rs @@ -28,6 +28,10 @@ //! (`profiles.json` + 实例设置),不搬文件、不写游戏根。缺省运行目录落 ULL 数据区, //! 共用/写进对方的 `.minecraft` 必须显式选择(见 [`adopt::AdoptTarget`])。 //! +//! 存档与截图(M235,[`saves`]):**只读**列 `/saves`(世界目录)与 +//! `/screenshots`(图片),不建目录、不写盘;「打开文件夹」走 +//! [`crate::platform::opener::open_dir`](平台差异不在这里)。缩略图墙与导入不在这一版(塔裁决 U3)。 +//! //! 模块分工按"数据形状 / 纯计算 / 状态"切开,任何模块都不碰网络与 UI: //! 下载归 `core::download`、进程归 `core::launch`、配置落盘归 `core::setting`。 @@ -45,6 +49,7 @@ pub mod library; pub mod loader; pub mod manifest; pub mod profiles; +pub mod saves; pub mod snapshot; pub mod unlisted; pub mod version; @@ -75,6 +80,10 @@ pub use library::{Library, MavenCoordinate}; pub use loader::{InstanceLoader, LoaderKind}; pub use manifest::{AssetIndexRef, DownloadInfo, Downloads, JavaVersion, Logging, VersionJson}; pub use profiles::{InstanceProfile, ProfileId, ProfileList, PROFILE_SCHEMA_VERSION}; +pub use saves::{ + Area, SavesError, SavesScan, SavesSkip, ScreenshotEntry, SkipKind, WorldEntry, + IMAGE_EXTENSIONS, SAVES_DIR, SCREENSHOTS_DIR, WORLD_ICON, +}; pub use snapshot::{Draft, Snapshot, Store}; pub use version::{ LatestVersions, ReleaseType, RemoteVersion, VersionCategory, VersionList, diff --git a/crates/core/src/game/saves/error.rs b/crates/core/src/game/saves/error.rs new file mode 100644 index 0000000..17a4ae0 --- /dev/null +++ b/crates/core/src/game/saves/error.rs @@ -0,0 +1,38 @@ +//! 「目录存在但不可用」的失败形状(带 OS 原文)。 +//! +//! 只有**一层**:`/saves` 或 `/screenshots` 本身不可用。项目级的问题 +//! (某个世界目录读不出元数据、某个文件不是图片)不是错误,逐条进 `skipped`——列表宁可短, +//! 也不能因为一个坏文件整份读不出来。 +//! +//! 为什么不折叠成 `io::Error`:调用方要分得清「这个实例没有存档目录」与「有但读不了」, +//! 前者是空列表加一句说明,后者是带重试按钮的红字。`dir` 字段是这两者的唯一区分依据。 + +use std::fmt; +use std::path::PathBuf; + +/// 一次列举失败。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SavesError { + /// 出问题的那个目录(`/saves` 或 `/screenshots`)。 + pub dir: PathBuf, + /// 人读原因(OS 原文或"是符号链接"这类判据)。 + pub detail: String, +} + +impl SavesError { + /// 建一条(内部用;`dir` 一律是**被列的那个目录**,不是它的父目录)。 + pub(super) fn unusable(dir: &std::path::Path, detail: impl Into) -> Self { + Self { + dir: dir.to_path_buf(), + detail: detail.into(), + } + } +} + +impl fmt::Display for SavesError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "目录不可用({}):{}", self.dir.display(), self.detail) + } +} + +impl std::error::Error for SavesError {} diff --git a/crates/core/src/game/saves/list.rs b/crates/core/src/game/saves/list.rs new file mode 100644 index 0000000..0e0a020 --- /dev/null +++ b/crates/core/src/game/saves/list.rs @@ -0,0 +1,296 @@ +//! 列一个运行目录下的 `saves/`(世界)与 `screenshots/`(图片),**纯只读**。 +//! +//! 只碰三种系统调用:`symlink_metadata`(判目录形态)、`read_dir`(取子项)、`metadata` +//! (取大小/时间)。没有 `create_dir`、没有 `write`、没有 `rename`、没有 `remove`——`saves/` +//! 不存在时也**不替玩家建**(建出来的空目录会让「这实例还没玩过」这件事再也看不出来)。 +//! +//! 回看「调用前后版本树不变」的判据见 `tests::scan_does_not_touch_the_disk`: +//! 它在调用前后各拍一次 `(相对路径, 是否目录, 字节数, mtime)` 的指纹并逐条比对。 + +use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; + +use super::error::SavesError; + +/// `saves` 目录名(游戏与社区一致的口径)。 +pub const SAVES_DIR: &str = "saves"; + +/// `screenshots` 目录名(原版游戏把 F2 截图放这里)。 +pub const SCREENSHOTS_DIR: &str = "screenshots"; + +/// 世界图标文件名(**只判存在**,不解码:U3 不做缩略图墙)。 +pub const WORLD_ICON: &str = "icon.png"; + +/// 认作截图的扩展名(大小写不敏感)。 +/// +/// 原版游戏只写 `.png`;其余几个是玩家手动塞进去/其它 mod 的常见形态。**不收** `.gif` / +/// 视频——它们是"别的东西",与"我们忘了列"要区分开(见 [`SkipKind::NotAnImage`])。 +pub const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "webp"]; + +/// 某个运行目录下的 `saves/`(纯路径拼接,不建、不检查)。 +#[must_use] +pub fn saves_dir(run_dir: &Path) -> PathBuf { + run_dir.join(SAVES_DIR) +} + +/// 某个运行目录下的 `screenshots/`(纯路径拼接,不建、不检查)。 +#[must_use] +pub fn screenshots_dir(run_dir: &Path) -> PathBuf { + run_dir.join(SCREENSHOTS_DIR) +} + +/// `saves/` 下的一个世界目录。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorldEntry { + /// 磁盘上的目录名(原样)。**这一版它就是展示名**:`LevelName` 在 `level.dat` 里, + /// 解析 NBT 不在 U3 范围内(做缩略图墙时一起做)。 + pub dir: String, + /// 有没有 `icon.png`(只判存在,不读内容)。 + pub icon: bool, + /// 目录 mtime(毫秒 since UNIX_EPOCH;取不到为 `None`)。 + pub modified_ms: Option, +} + +/// `screenshots/` 下的一张图。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScreenshotEntry { + /// 磁盘文件名(原样,含扩展名)。 + pub file: String, + /// 字节数(UI 自己格式化,不在这里拼 `1.2 MiB`)。 + pub bytes: u64, + /// 文件 mtime(毫秒 since UNIX_EPOCH;取不到为 `None`)。 + pub modified_ms: Option, +} + +/// 被跳过的条目来自哪个目录。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Area { + /// `/saves/` + Saves, + /// `/screenshots/` + Screenshots, +} + +/// 没收进列表的原因。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SkipKind { + /// 符号链接(不跟随:跟随会把实例外的东西算成存档)。 + SymbolicLink, + /// `saves/` 下的非目录(散落文件),或 `screenshots/` 下的目录。 + NotADirectoryExpected, + /// `screenshots/` 下的非普通文件(设备 / 管道 / 目录)。 + NotAFile, + /// 名字过不了路径规则(含 `\`、`:`、`..` 之类;判据复用整合包侧那一份)。 + UnsafeName, + /// 元数据读不出来。 + Unreadable, + /// `screenshots/` 下的普通文件,但扩展名不是 [`IMAGE_EXTENSIONS`]。 + NotAnImage, +} + +/// 一个没能进列表的条目。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SavesSkip { + /// 来自哪个目录。 + pub area: Area, + /// 磁盘名字(原样;读不出来时给能拿到的那个串)。 + pub file: String, + /// 为什么没收。 + pub reason: SkipKind, +} + +/// 一次列举的结果(两个目录一次读回,UI 只收这一份快照)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SavesScan { + /// 被列的 `saves/` 目录。 + pub dir: PathBuf, + /// `saves/` 存不存在(`false` = 这实例还没存过档,不是错误)。 + pub exists: bool, + /// 世界列表(按名字排序,大小写不敏感)。 + pub worlds: Vec, + /// 被列的 `screenshots/` 目录。 + pub screenshots_dir: PathBuf, + /// `screenshots/` 存不存在。 + pub screenshots_exists: bool, + /// 截图列表(**新的在前**:文件名就是时间戳,倒序即时间倒序)。 + pub screenshots: Vec, + /// 跳过的条目与原因(两个目录并在一份里,靠 [`SavesSkip::area`] 区分)。 + pub skipped: Vec, +} + +impl SavesScan { + /// `saves/` 与 `screenshots/` 一个都不存在(新实例的常态)。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.worlds.is_empty() && self.screenshots.is_empty() + } +} + +/// 列一个运行目录下的 `saves/` 与 `screenshots/`。 +/// +/// 目录不存在 → `exists = false` 的空结果(新实例不是错误),**且不建那个目录**。 +/// +/// # Errors +/// +/// 目录存在但不可用(符号链接 / 不是目录 / 读不了)时给 [`SavesError`]。 +pub fn scan(run_dir: &Path) -> Result { + let dir = saves_dir(run_dir); + let (exists, children) = read_children(&dir)?; + let mut worlds = Vec::new(); + let mut skipped = Vec::new(); + for child in children { + let raw = child.file_name().to_string_lossy().to_string(); + let Ok(file_type) = child.file_type() else { + skipped.push(skip(Area::Saves, raw, SkipKind::Unreadable)); + continue; + }; + if file_type.is_symlink() { + skipped.push(skip(Area::Saves, raw, SkipKind::SymbolicLink)); + continue; + } + if !file_type.is_dir() { + skipped.push(skip(Area::Saves, raw, SkipKind::NotADirectoryExpected)); + continue; + } + if !plain_name(&raw) { + skipped.push(skip(Area::Saves, raw, SkipKind::UnsafeName)); + continue; + } + let icon = is_regular_file(&dir.join(&raw).join(WORLD_ICON)); + let modified_ms = child.metadata().ok().and_then(|meta| modified_ms(&meta)); + worlds.push(WorldEntry { + dir: raw, + icon, + modified_ms, + }); + } + // 名字排序(大小写不敏感;同名时按磁盘名字典序兜底,保证两帧之间不抖)。 + worlds.sort_by(|left, right| { + left.dir + .to_lowercase() + .cmp(&right.dir.to_lowercase()) + .then_with(|| left.dir.cmp(&right.dir)) + }); + + let shots_dir = screenshots_dir(run_dir); + let (shots_exists, shots) = read_children(&shots_dir)?; + let mut screenshots = Vec::new(); + for child in shots { + let raw = child.file_name().to_string_lossy().to_string(); + let Ok(file_type) = child.file_type() else { + skipped.push(skip(Area::Screenshots, raw, SkipKind::Unreadable)); + continue; + }; + if file_type.is_symlink() { + skipped.push(skip(Area::Screenshots, raw, SkipKind::SymbolicLink)); + continue; + } + if !file_type.is_file() { + skipped.push(skip(Area::Screenshots, raw, SkipKind::NotAFile)); + continue; + } + if !plain_name(&raw) { + skipped.push(skip(Area::Screenshots, raw, SkipKind::UnsafeName)); + continue; + } + if !is_image(&raw) { + skipped.push(skip(Area::Screenshots, raw, SkipKind::NotAnImage)); + continue; + } + let meta = child.metadata().ok(); + screenshots.push(ScreenshotEntry { + bytes: meta.as_ref().map_or(0, std::fs::Metadata::len), + modified_ms: meta.as_ref().and_then(modified_ms), + file: raw, + }); + } + // 新的在前:截图文件名就是 `2024-01-02_12.00.00.png`,字典序倒排就是时间倒排。 + screenshots.sort_by(|left, right| right.file.cmp(&left.file)); + skipped.sort_by(|left, right| { + area_key(left.area) + .cmp(&area_key(right.area)) + .then_with(|| left.file.cmp(&right.file)) + }); + + Ok(SavesScan { + dir, + exists, + worlds, + screenshots_dir: shots_dir, + screenshots_exists: shots_exists, + screenshots, + skipped, + }) +} + +/// 列一个目录的子项(**不存在 ⇒ `(false, 空)`**;不可用 ⇒ 具名错误;符号链接不跟随)。 +fn read_children(dir: &Path) -> Result<(bool, Vec), SavesError> { + let meta = match std::fs::symlink_metadata(dir) { + Ok(meta) => meta, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok((false, Vec::new())); + } + Err(error) => return Err(SavesError::unusable(dir, error.to_string())), + }; + if meta.file_type().is_symlink() { + return Err(SavesError::unusable( + dir, + "是符号链接(不跟随:会把实例目录之外的内容算成存档)", + )); + } + if !meta.is_dir() { + return Err(SavesError::unusable(dir, "不是目录")); + } + let read = + std::fs::read_dir(dir).map_err(|error| SavesError::unusable(dir, error.to_string()))?; + let mut children: Vec<_> = read + .collect::, _>>() + .map_err(|error| SavesError::unusable(dir, error.to_string()))?; + children.sort_by_key(std::fs::DirEntry::file_name); + Ok((true, children)) +} + +fn skip(area: Area, file: String, reason: SkipKind) -> SavesSkip { + SavesSkip { area, file, reason } +} + +/// 名字是不是**裸名字**(判据复用整合包侧那一份,不在这里写第二份规则)。 +/// +/// `read_dir` 给的已经是单段名字,所以只需判它过不过得了路径规则:`\`、`:`、`..` 这类会被 +/// 拒——它们既可能在展示里被误读成路径分隔,也可能在将来接"删除/重命名"时落到别处。 +fn plain_name(raw: &str) -> bool { + crate::addon::pack::path::normalize_file(raw).is_ok() +} + +/// 扩展名是不是认得的图片(大小写不敏感)。 +fn is_image(raw: &str) -> bool { + Path::new(raw) + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| { + let ext = ext.to_ascii_lowercase(); + IMAGE_EXTENSIONS.iter().any(|known| *known == ext) + }) + .unwrap_or(false) +} + +/// 是不是**普通文件**(不跟随符号链接:`icon.png` 指到别处不算这个世界的图标)。 +fn is_regular_file(path: &Path) -> bool { + std::fs::symlink_metadata(path) + .map(|meta| meta.file_type().is_file()) + .unwrap_or(false) +} + +/// mtime → 毫秒(早于 1970 或取不到 ⇒ `None`,不报错)。 +fn modified_ms(meta: &std::fs::Metadata) -> Option { + let since = meta.modified().ok()?.duration_since(UNIX_EPOCH).ok()?; + u64::try_from(since.as_millis()).ok() +} + +/// 跳过条目的排序键(先 `saves/` 再 `screenshots/`)。 +fn area_key(area: Area) -> u8 { + match area { + Area::Saves => 0, + Area::Screenshots => 1, + } +} diff --git a/crates/core/src/game/saves/mod.rs b/crates/core/src/game/saves/mod.rs new file mode 100644 index 0000000..050eab8 --- /dev/null +++ b/crates/core/src/game/saves/mod.rs @@ -0,0 +1,57 @@ +//! 存档 / 截图的**只读**列举与「打开文件夹」(M235 · STOREMVP)。 +//! +//! # 这一版做到哪、不做什么(塔裁决 U3) +//! +//! 用户原话是「存档截图 / 导入」,M230 审计确认三层全缺(core 没有读路径,`saves/` 只在 +//! `isolation.rs` 的切换探测里被计数;presenter 没有 DTO;i18n 没有键;没有页面)。 +//! U3 把本 mission 的边界钉成:**只读列表 + 打开文件夹,不做缩略图墙**。理由有两条: +//! +//! 1. 缩略图要引图片解码依赖,而 `AGENTS.md` §2 只允许 `palette-extract` 之外不引 image 类依赖; +//! 2. 截图/导入的产物形态还没定型,先不进产品。 +//! +//! 所以这里**只读**:只调 `read_dir` / `metadata` / `symlink_metadata`,不建目录、不写文件、 +//! 不改名字、不读文件内容(`icon.png` 只判存在——那是将来缩略图墙的输入,不是这一版的解码对象)。 +//! +//! # 单根语义(D20) +//! +//! 入口是**已经派生好的运行目录**([`crate::game::isolation::derive_game_dir`] 的结果), +//! 本模块只往下取两层:`/saves/` 与 `/screenshots/`。 +//! 不自己去猜游戏根,也不做「隔离侧 + 共享侧合并」——派生是启动、mod 列表、存档列表**同一个** +//! 答案的唯一来源。与 [`crate::addon::mods`] 同一条纪律。 +//! +//! # 目录不存在不是错误 +//! +//! 新实例两个目录都不存在,这**不是**异常(`exists = false` + 空列表)。只有「存在但不可用」 +//! (符号链接 / 不是目录 / 读不了)才是 [`SavesError`];**并且照样不建目录**。 +//! +//! # 收纳口径 +//! +//! | 位置 | 收什么 | 不收什么(逐条登记原因,不静默丢) | +//! |---|---|---| +//! | `saves/` | 子目录 = 一个世界 | 符号链接(不跟随)、非目录(散落文件)、名字过不了路径规则、元数据读不出 | +//! | `screenshots/` | 扩展名是 [`IMAGE_EXTENSIONS`] 的普通文件 | 同上 + 不是图片的文件(`NotAnImage`) | +//! +//! 「不静默丢」是 M224 定下的口径:列表要能和磁盘对得上账,用户看到「跳过 3 项」时得知道 +//! 那 3 项不是我们忘了。 +//! +//! # 模块 +//! +//! | 文件 | 职责 | +//! |---|---| +//! | [`error`] | 「目录存在但不可用」的失败形状(带 OS 原文) | +//! | [`list`] | 列 `/saves` 与 `/screenshots`(纯只读) | +//! +//! 「打开文件夹」不在本模块:平台差异收敛在 [`crate::platform::opener::open_dir`] +//! (`AGENTS.md` §6),本模块一个 `cfg` 都不写。 + +mod error; +mod list; + +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, +}; + +#[cfg(test)] +mod tests; diff --git a/crates/core/src/game/saves/tests.rs b/crates/core/src/game/saves/tests.rs new file mode 100644 index 0000000..fd3fb63 --- /dev/null +++ b/crates/core/src/game/saves/tests.rs @@ -0,0 +1,260 @@ +//! 只读判据的用例:**真的建盘上目录**再读回来(不 mock 文件系统)。 +//! +//! 每条用例都写在「抓得住什么 / 抓不住什么」的口径里(M224 的写法): +//! +//! | 用例 | 抓得住 | 抓不住 | +//! |---|---|---| +//! | [`scan_lists_worlds_and_screenshots_read_only`] | 收纳口径、排序、`icon.png` 只判存在、**调用前后盘上指纹不变** | 大世界的耗时(不测性能) | +//! | [`missing_dirs_are_empty_and_not_created`] | 「不存在 ⇒ 空列表」且**不建目录** | 权限不足导致的 `NotFound`(本机跑不出来) | +//! | [`unusable_dirs_are_errors`] | 目录被占成文件 ⇒ 具名错误 | Windows 上建符号链接要开发者模式,见下一条 | +//! | [`symlinks_are_refused`] | 符号链接不被跟随(目录级与条目级) | 非 unix 平台(`cfg(unix)`;Windows 建链接要提权) | + +use std::path::Path; + +use super::*; +use crate::addon::temp::TempRoot; + +/// 建一个运行目录(`/run`)。 +/// +/// `create_dir_all` 这一句不是冗余:`addon::temp::TempRoot::under` 返回时它内部的 `base` +/// 已经析构、把那棵树删掉了(M235 已作为 finding 上报,`addon/` 不在本 mission 的改动范围)。 +/// 修好之后这一句会退化成 no-op。 +fn run_dir(tag: &str) -> TempRoot { + let root = TempRoot::under(tag, "run"); + std::fs::create_dir_all(&root).expect("建运行目录"); + root +} + +/// 盘上指纹:`(相对路径, 是否目录, 字节数, mtime)` 逐条排序后的列表。 +/// +/// 这就是「调用前后版本树快照不变」的判据本体——只要列举路径里多了一次 `create_dir`、 +/// 一次 `write`、一次 `remove`,或者顺手动了 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; + }; + let mut entries: Vec<_> = read.flatten().collect(); + entries.sort_by_key(std::fs::DirEntry::file_name); + for entry in entries { + 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(); + let mtime = meta + .modified() + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map_or(0, |since| since.as_millis()); + out.push(format!( + "{rel}|dir={}|len={}|mtime={mtime}", + meta.is_dir(), + meta.len() + )); + if meta.is_dir() { + stack.push(path); + } + } + } + out.sort(); + out +} + +/// 收纳口径 + 排序 + `icon.png` + **只读**(M235 的核心判据)。 +#[test] +fn scan_lists_worlds_and_screenshots_read_only() { + let root = run_dir("m235-scan"); + let saves = saves_dir(&root); + std::fs::create_dir_all(saves.join("World A")).expect("建世界 A"); + std::fs::write(saves.join("World A/level.dat"), b"nbt").expect("写 level.dat"); + std::fs::write(saves.join("World A/icon.png"), b"png").expect("写 icon"); + std::fs::create_dir_all(saves.join("world b")).expect("建世界 B"); + // `saves/` 下的散落文件:不是世界,登记原因。 + std::fs::write(saves.join("readme.txt"), b"hi").expect("写散落文件"); + + let shots = screenshots_dir(&root); + std::fs::create_dir_all(&shots).expect("建截图目录"); + std::fs::write(shots.join("2024-05-01_10.00.00.png"), b"aa").expect("写截图 1"); + std::fs::write(shots.join("2024-06-02_11.00.00.PNG"), b"bbb").expect("写截图 2"); + std::fs::write(shots.join("notes.txt"), b"x").expect("写非图片"); + + let before = fingerprint(&root); + let scan = scan(&root).expect("列存档"); + let after = fingerprint(&root); + assert_eq!(before, after, "列举改动了磁盘:{before:?} → {after:?}"); + + assert!(scan.exists); + assert!(scan.screenshots_exists); + assert_eq!(scan.dir, save_path(&root)); + assert_eq!(scan.screenshots_dir, shot_path(&root)); + + // 世界:名字排序大小写不敏感;`icon.png` 只判存在。 + let worlds: Vec<(&str, bool)> = scan + .worlds + .iter() + .map(|world| (world.dir.as_str(), world.icon)) + .collect(); + assert_eq!(worlds, [("World A", true), ("world b", false)]); + assert!( + scan.worlds.iter().all(|world| world.modified_ms.is_some()), + "世界的 mtime 应当取得到:{:?}", + scan.worlds + ); + + // 截图:新的在前(文件名倒序),扩展名大小写不敏感,非图片登记原因。 + let shots_listed: Vec<&str> = scan + .screenshots + .iter() + .map(|shot| shot.file.as_str()) + .collect(); + assert_eq!( + shots_listed, + ["2024-06-02_11.00.00.PNG", "2024-05-01_10.00.00.png"] + ); + assert_eq!(scan.screenshots[0].bytes, 3, "字节数来自 metadata"); + assert_eq!(scan.screenshots[1].bytes, 2); + + let skipped: Vec<(Area, &str, SkipKind)> = scan + .skipped + .iter() + .map(|skip| (skip.area, skip.file.as_str(), skip.reason)) + .collect(); + assert_eq!( + skipped, + [ + (Area::Saves, "readme.txt", SkipKind::NotADirectoryExpected), + (Area::Screenshots, "notes.txt", SkipKind::NotAnImage), + ], + "跳过必须逐条登记原因(不静默丢)" + ); +} + +/// 「目录不存在 ⇒ 空列表」**且不替玩家建目录**。 +#[test] +fn missing_dirs_are_empty_and_not_created() { + let root = run_dir("m235-missing"); + let before = fingerprint(&root); + + let scan = scan(&root).expect("两个目录都不存在不是错误"); + assert!(!scan.exists); + assert!(!scan.screenshots_exists); + assert!(scan.worlds.is_empty()); + assert!(scan.screenshots.is_empty()); + assert!(scan.skipped.is_empty()); + assert!(scan.is_empty()); + + assert_eq!(fingerprint(&root), before, "列举顺手建了目录"); + assert!(!saves_dir(&root).exists(), "`saves/` 被建出来了"); + assert!( + !screenshots_dir(&root).exists(), + "`screenshots/` 被建出来了" + ); + // 路径本身照样是"拼得出来的"(UI 的「打开文件夹」需要它)。 + assert_eq!(scan.dir, saves_dir(&root)); + assert_eq!(scan.screenshots_dir, screenshots_dir(&root)); +} + +/// 「存在但不是目录」⇒ 具名错误(不折叠成空列表:那不是"没存档",是"读不了")。 +#[test] +fn unusable_dirs_are_errors() { + let root = run_dir("m235-unusable"); + std::fs::write(saves_dir(&root), b"not a dir").expect("用文件占住 saves 这个名字"); + + let error = scan(&root).expect_err("saves 是个文件,居然列成功了"); + assert_eq!(error.dir, saves_dir(&root)); + assert!(error.detail.contains("不是目录"), "原因:{}", error.detail); + + // `screenshots/` 那一侧单独判(saves 正常、shot 被占)。 + let other = run_dir("m235-unusable-shots"); + std::fs::create_dir_all(saves_dir(&other)).expect("建 saves"); + std::fs::write(screenshots_dir(&other), b"x").expect("用文件占住 screenshots"); + let error = scan(&other).expect_err("screenshots 是个文件,居然列成功了"); + assert_eq!(error.dir, screenshots_dir(&other)); +} + +/// 符号链接不跟随(目录级 = 错误;条目级 = 跳过并登记原因)。 +#[cfg(unix)] +#[test] +fn symlinks_are_refused() { + use std::os::unix::fs::symlink; + + // 目录级:`saves/` 指到别处 ⇒ 拒(列下去会把实例外的东西算成存档)。 + let root = run_dir("m235-symlink-dir"); + let elsewhere = root.join("elsewhere"); + std::fs::create_dir_all(elsewhere.join("Outside")).expect("建实例外目录"); + symlink(&elsewhere, saves_dir(&root)).expect("建符号链接"); + let error = scan(&root).expect_err("符号链接的 saves 居然列成功了"); + assert!(error.detail.contains("符号链接"), "原因:{}", error.detail); + + // 条目级:世界目录 / 截图文件各一个链接 ⇒ 跳过,不跟随。 + let root = run_dir("m235-symlink-entry"); + let saves = saves_dir(&root); + std::fs::create_dir_all(&saves).expect("建 saves"); + std::fs::create_dir_all(elsewhere.join("Real")).expect("建真实世界"); + symlink(elsewhere.join("Real"), saves.join("Linked")).expect("建世界链接"); + let shots = screenshots_dir(&root); + std::fs::create_dir_all(&shots).expect("建 screenshots"); + std::fs::write(elsewhere.join("shot.png"), b"png").expect("写真实截图"); + symlink(elsewhere.join("shot.png"), shots.join("linked.png")).expect("建截图链接"); + + let scan = scan(&root).expect("列存档"); + assert!(scan.worlds.is_empty(), "跟随了世界符号链接"); + assert!(scan.screenshots.is_empty(), "跟随了截图符号链接"); + let reasons: Vec<(Area, SkipKind)> = scan + .skipped + .iter() + .map(|skip| (skip.area, skip.reason)) + .collect(); + assert_eq!( + reasons, + [ + (Area::Saves, SkipKind::SymbolicLink), + (Area::Screenshots, SkipKind::SymbolicLink), + ] + ); +} + +/// 名字过不了路径规则的条目:跳过并登记(不静默丢)。 +/// +/// `:` 在 unix 上是合法文件名字符,在 Windows 上建不出来——所以这条只在 unix 跑得起来。 +#[cfg(unix)] +#[test] +fn unsafe_names_are_skipped() { + let root = run_dir("m235-unsafe"); + let scenes = saves_dir(&root); + std::fs::create_dir_all(&scenes).expect("建 saves"); + std::fs::create_dir_all(scenes.join("bad:name")).expect("建带冒号的世界"); + std::fs::create_dir_all(scenes.join("good")).expect("建正常世界"); + + let scan = scan(&root).expect("列存档"); + let worlds: Vec<&str> = scan.worlds.iter().map(|world| world.dir.as_str()).collect(); + assert_eq!(worlds, ["good"]); + assert_eq!(scan.skipped.len(), 1); + assert_eq!(scan.skipped[0].file, "bad:name"); + assert_eq!(scan.skipped[0].reason, SkipKind::UnsafeName); +} + +/// 路径拼接口径:两个目录都挂在**运行目录**下面(不是游戏根、不是 `.minecraft`)。 +#[test] +fn paths_hang_off_the_run_dir() { + let run = Path::new("D:/ULL/.ULL/data/instances/better-mc"); + assert_eq!(saves_dir(run).file_name().unwrap(), SAVES_DIR); + assert_eq!(screenshots_dir(run).file_name().unwrap(), SCREENSHOTS_DIR); + assert!(saves_dir(run).starts_with(run)); + assert!(screenshots_dir(run).starts_with(run)); +} + +fn save_path(root: &Path) -> std::path::PathBuf { + root.join(SAVES_DIR) +} + +fn shot_path(root: &Path) -> std::path::PathBuf { + root.join(SCREENSHOTS_DIR) +} diff --git a/crates/core/src/platform/mod.rs b/crates/core/src/platform/mod.rs index 42cf359..c2ec8bd 100644 --- a/crates/core/src/platform/mod.rs +++ b/crates/core/src/platform/mod.rs @@ -27,6 +27,8 @@ //! - [`child`]:子进程的唯一构造器 [`child::quiet`](M213)。Windows 上给子进程带 //! `CREATE_NO_WINDOW`,否则 GUI 父进程 spawn 控制台程序会给子进程新建控制台——桌面上 //! 就是「黑框闪一下」。**别在别处直接构造 `Command`**,`tests/child_quiet.rs` 守门。 +//! - [`opener`]:交出去的两件事——[`opener::open_url`](授权码流)与 [`opener::open_dir`] +//! (实例详情页的「打开文件夹」,M235)。平台分支只在这里出现一次,业务模块一律调它。 //! //! 尚未落地(后续 mission):文件锁、进程工具、真实 GL/Vulkan 能力探测(钩子见 //! [`linux_caps::probe_gl`] 与 [`vk_probe::probe_vk`],FFI 受限,实际探测归 app 层渲染后端选择)。 @@ -40,6 +42,7 @@ pub mod hash; pub mod layout; pub mod linux_caps; pub mod macos_version; +pub mod opener; pub mod os_info; pub mod paths; pub mod receipt; diff --git a/crates/core/src/platform/opener.rs b/crates/core/src/platform/opener.rs new file mode 100644 index 0000000..d7f491c --- /dev/null +++ b/crates/core/src/platform/opener.rs @@ -0,0 +1,220 @@ +//! 用**系统默认程序**打开一个 URL 或一个目录(M235)。 +//! +//! # 为什么收敛到 `core::platform` +//! +//! `DevDocs/AGENTS.md` §6:平台差异判断收敛到 `core::platform`,不在各模块里散写 +//! `cfg(target_os)` 补丁。本文件里的两种"交出去": +//! +//! | 目标 | 程序 | 谁在用 | +//! |---|---|---| +//! | 目录 | Windows `explorer` / macOS `open` / 其它 `xdg-open` | 实例详情页「打开文件夹」(`game::saves`,M235) | +//! | URL(`http`/`https`) | Windows `rundll32 url.dll,FileProtocolHandler` / macOS `open` / 其它 `xdg-open` | 微软授权码流(**当前仍走** `presenter::host::opener`,见下) | +//! +//! `open_url` 放在这里是为了给 URL 通道一个归宿:它现在还在 `presenter::host::opener`,因为 +//! 把那份实现搬过来会同时改到 `crates/core/tests/child_quiet_guard.rs` 的角色登记(M217 把 +//! 那个文件钉在"裸构造 1 处",不在 M235 的改动范围),所以本 mission **只搬目录这一支**, +//! 迁移 URL 那支留给后续(见 M235 的 handoff)。 +//! +//! `app-iced/src/app/dialog/export.rs` 是第三处目录打开器(崩溃对话框的「打开日志文件夹」, +//! M217 特意留裸);它与这里的 `open_dir` 是同一件事,迁移同样留给后续。 +//! +//! # 子进程纪律(M213 / M217) +//! +//! 所有启动都经 [`child::quiet`]:Windows 上带 `CREATE_NO_WINDOW`,否则 GUI 父进程 spawn +//! 控制台程序会给子进程新建控制台(桌面上是"黑框闪一下")。本文件是本 crate 里**唯一**出现 +//! `explorer` / `xdg-open` / `open` 这三个字面量的地方,[`tests::the_launcher_literals_live_here_only`] +//! 扫源码钉住这一条(防"有人又在某个业务模块里塞一个平台分支")。 +//! +//! # 不等待、不判退出码 +//! +//! 打开浏览器/文件管理器是**把意图交出去**:用户可能在那边停留很久,`explorer.exe` 即便成功 +//! 也常返回非 0 退出码。所以这里只 `spawn` 不 `wait`,`Ok(())` 的含义是「拉起来了」。 + +use std::path::Path; + +use super::child::quiet; + +/// 拉起系统默认浏览器打开 `url`(只接受 `http` / `https`)。 +/// +/// # Errors +/// +/// URL 不是 `http`/`https`,或进程拉不起来时返回用户可读文案。 +pub fn open_url(url: &str) -> Result<(), String> { + if !url.starts_with("https://") && !url.starts_with("http://") { + return Err(format!("不是可打开的网址:{url}")); + } + let (program, args) = url_launcher(url); + launch(program, args) +} + +/// 用系统文件管理器打开一个**已存在的目录**。 +/// +/// 先确认它真是一个目录:拿一个不存在的路径去问 `explorer` 会得到一个"我的电脑"窗口或一句 +/// OS 报错,两种都不是用户想要的答案——所以在这里就说清楚。 +/// +/// # Errors +/// +/// 路径不存在 / 不是目录 / 进程拉不起来时返回用户可读文案(含 OS 原文)。 +pub fn open_dir(dir: &Path) -> Result<(), String> { + let meta = std::fs::metadata(dir) + .map_err(|error| format!("找不到目录({}):{error}", dir.display()))?; + if !meta.is_dir() { + return Err(format!("不是目录({})", dir.display())); + } + let (program, args) = dir_launcher(dir); + launch(program, args) +} + +/// 真去拉进程(两个入口共用)。 +fn launch(program: &'static str, args: Vec) -> Result<(), String> { + quiet(program) + .args(args) + .spawn() + .map(|_child| ()) + .map_err(|error| format!("拉起失败({program}):{error}")) +} + +/// 打开 URL 的程序与参数。 +#[cfg(target_os = "windows")] +fn url_launcher(url: &str) -> (&'static str, Vec) { + // `rundll32` 不用 shell,URL 里的 `&` 不会被当成命令分隔符(`cmd /C start` 会)。 + ( + "rundll32", + vec!["url.dll,FileProtocolHandler".into(), url.into()], + ) +} + +#[cfg(target_os = "macos")] +fn url_launcher(url: &str) -> (&'static str, Vec) { + ("open", vec![url.into()]) +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn url_launcher(url: &str) -> (&'static str, Vec) { + ("xdg-open", vec![url.into()]) +} + +/// 打开目录的程序与参数。 +#[cfg(target_os = "windows")] +fn dir_launcher(dir: &Path) -> (&'static str, Vec) { + // `explorer` 吃的是**一个**路径参数;别拼引号(它不是 shell,引号会变成名字的一部分)。 + ("explorer", vec![dir.as_os_str().to_os_string()]) +} + +#[cfg(target_os = "macos")] +fn dir_launcher(dir: &Path) -> (&'static str, Vec) { + ("open", vec![dir.as_os_str().to_os_string()]) +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn dir_launcher(dir: &Path) -> (&'static str, Vec) { + ("xdg-open", vec![dir.as_os_str().to_os_string()]) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::{dir_launcher, open_dir, open_url, url_launcher}; + + /// URL 通道只收 http/https(不在单测里真去拉浏览器,只验命令形态)。 + #[test] + fn rejects_non_http_urls() { + assert!(open_url("javascript:alert(1)").is_err()); + assert!(open_url("").is_err()); + let (program, args) = url_launcher("http://localhost:1234/auth?code=a&state=b"); + assert!(!program.is_empty()); + assert!(args + .iter() + .any(|arg| arg.to_string_lossy().contains("http://localhost:1234/auth"))); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_opens_urls_through_the_file_protocol_handler() { + let (program, args) = url_launcher("https://example.invalid/"); + assert_eq!(program, "rundll32"); + assert_eq!(args[0], "url.dll,FileProtocolHandler"); + assert_eq!(args[1], "https://example.invalid/"); + } + + /// 目录通道:不存在的路径直接拒(**不 spawn**,否则会弹出"我的电脑"或一句 OS 报错)。 + #[test] + fn missing_dirs_are_rejected_before_spawning() { + let gone = PathBuf::from("ull-no-such-dir-m235/definitely-missing"); + let error = open_dir(&gone).expect_err("不存在的目录居然打开了?"); + assert!(error.contains("找不到目录"), "错误文案:{error}"); + + // 存在但不是目录(用一个文件)也要拒。 + let file = std::env::temp_dir().join(format!("ull-opener-m235-{}", std::process::id())); + std::fs::write(&file, b"x").expect("写临时文件"); + let error = open_dir(&file).expect_err("文件居然被当目录打开了?"); + let _ = std::fs::remove_file(&file); + assert!(error.contains("不是目录"), "错误文案:{error}"); + } + + /// 目录通道的命令形态(各平台一支,只验"程序名 + 一个路径参数",不 spawn)。 + #[test] + fn dir_launcher_passes_the_path_as_a_single_argument() { + let (program, args) = dir_launcher(&PathBuf::from("D:/ULL/.ULL/data/instances/x/saves")); + assert!(!program.is_empty()); + assert_eq!(args.len(), 1, "路径必须是**一个**参数:{args:?}"); + assert_eq!( + args[0].to_string_lossy().replace('\\', "/"), + "D:/ULL/.ULL/data/instances/x/saves" + ); + if cfg!(target_os = "windows") { + assert_eq!(program, "explorer"); + } else if cfg!(target_os = "macos") { + assert_eq!(program, "open"); + } else { + assert_eq!(program, "xdg-open"); + } + } + + /// **防退化判据**(`AGENTS.md` §6):`explorer` / `xdg-open` 这两个字面量只许出现在本文件。 + /// + /// 抓得住:有人在 `game::saves` 或别的业务模块里再写一份平台分支(tower 在 M235 明确要求 + /// 把这条写成可执行判据,而不是只写在提交信息里)。 + /// 抓不住:经由变量间接拼出的程序名、以及本 crate 之外的 crate(presenter/app 侧另有 + /// `tests/child_quiet_guard.rs` 管"是否经 `quiet` 起进程",不管"在哪儿起")。 + #[test] + fn the_launcher_literals_live_here_only() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut hits = Vec::new(); + let mut stack = vec![root.clone()]; + 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(); + if path.is_dir() { + stack.push(path); + continue; + } + if path.extension().and_then(|ext| ext.to_str()) != Some("rs") { + continue; + } + if path == root.join("platform").join("opener.rs") { + continue; + } + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + for (index, line) in text.lines().enumerate() { + // 注释里提到这两个名字(文档、handoff)不算退化。 + let code = line.split("//").next().unwrap_or(""); + if code.contains("\"explorer\"") || code.contains("\"xdg-open\"") { + hits.push(format!("{}:{}", path.display(), index + 1)); + } + } + } + } + assert!( + hits.is_empty(), + "平台 opener 必须只在 platform/opener.rs 里:{}", + hits.join(", ") + ); + } +} diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 4816cfe..ef9cab8 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -105,6 +105,26 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("InstancesModsReasonNotFile", "that is not a regular file", "那不是普通文件"), ("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.", "还没有世界或截图——玩一次就会出现在这里。"), + ("InstancesSavesMissingDir", "This instance has no saves/ or screenshots/ directory yet.", "这个实例还没有 saves/ 或 screenshots/ 目录。"), + ("InstancesSavesWorlds", "WORLDS", "世界"), + ("InstancesSavesScreenshots", "SCREENSHOTS", "截图"), + ("InstancesSavesNoWorlds", "No worlds yet.", "还没有世界。"), + ("InstancesSavesNoScreenshots", "No screenshots yet.", "还没有截图。"), + ("InstancesSavesWorldIcon", "has icon", "有图标"), + ("InstancesSavesTimeUnknown", "time unknown", "时间未知"), + ("InstancesSavesSkipped", "{count} entries skipped", "跳过 {count} 项"), + ("InstancesSavesOpenFolder", "Open saves folder", "打开存档文件夹"), + ("InstancesSavesRetry", "Retry", "重试"), + ("InstancesSavesFailed", "Could not read saves/: {reason}", "读取 saves/ 失败:{reason}"), + ("InstancesSavesOpenFailed", "Could not open the folder: {reason}", "打开文件夹失败:{reason}"), + ("InstancesSavesReasonDir", "the directory is not usable", "目录不可用"), + ("InstancesSavesReasonInstance", "the instance is gone", "实例已不存在"), + ("InstancesSavesReasonOpen", "the file manager did not start", "文件管理器没起来"), ]; /// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。 diff --git a/crates/i18n/src/catalog/instances.rs b/crates/i18n/src/catalog/instances.rs index 679b1d9..276eb59 100644 --- a/crates/i18n/src/catalog/instances.rs +++ b/crates/i18n/src/catalog/instances.rs @@ -154,6 +154,30 @@ 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` 同一条口径)。 + 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.", "还没有世界或截图——玩一次就会出现在这里。"), + InstancesSavesMissingDir => ("This instance has no saves/ or screenshots/ directory yet.", "这个实例还没有 saves/ 或 screenshots/ 目录。"), + InstancesSavesWorlds => ("WORLDS", "世界"), + InstancesSavesScreenshots => ("SCREENSHOTS", "截图"), + InstancesSavesNoWorlds => ("No worlds yet.", "还没有世界。"), + InstancesSavesNoScreenshots => ("No screenshots yet.", "还没有截图。"), + InstancesSavesWorldIcon => ("has icon", "有图标"), + InstancesSavesTimeUnknown => ("time unknown", "时间未知"), + InstancesSavesSkipped => ("{count} entries skipped", "跳过 {count} 项"), + InstancesSavesOpenFolder => ("Open saves folder", "打开存档文件夹"), + InstancesSavesRetry => ("Retry", "重试"), + InstancesSavesFailed => ("Could not read saves/: {reason}", "读取 saves/ 失败:{reason}"), + InstancesSavesOpenFailed => ("Could not open the folder: {reason}", "打开文件夹失败:{reason}"), + InstancesSavesReasonDir => ("the directory is not usable", "目录不可用"), + InstancesSavesReasonInstance => ("the instance is gone", "实例已不存在"), + InstancesSavesReasonOpen => ("the file manager did not start", "文件管理器没起来"), } } }; } diff --git a/crates/presenter/src/dto/events.rs b/crates/presenter/src/dto/events.rs index 9ce4922..938ceb8 100644 --- a/crates/presenter/src/dto/events.rs +++ b/crates/presenter/src/dto/events.rs @@ -39,6 +39,9 @@ use crate::dto::{ NoticeView, PackCommand, PackEvent, + SavesCommand, + SavesFailureView, + SavesListView, SessionId, SettingGroupView, // M159: 自更新 —— 一条快照整体替换 @@ -168,6 +171,10 @@ pub enum CoreEvent { Update(Box), /// 本地 Mod 列表 / 启停(M224):`Ok` = 整份快照;`Err` = 具名原因 + 原文(文案由 app 现求)。 Mods(Box>), + /// 实例存档 / 截图的只读列表(M235):`Ok` = 整份快照;`Err` = 具名原因 + 原文。 + /// + /// 与 [`Self::Mods`] 分开:这一族**没有任何写路径**(U3 只做只读列表 + 打开文件夹)。 + Saves(Box>), } /// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路, @@ -299,6 +306,8 @@ pub enum AppCommand { SetUpdateAutoCheck(bool), /// 本地 Mod 列表 / 启停(M224;子枚举与语义见 [`ModsCommand`] 的文档)。 Mods(Box), + /// 实例存档 / 截图的只读列表(M235;子枚举与语义见 [`SavesCommand`] 的文档)。 + Saves(Box), } /// 后端通道:core/agent host 实现它;[`crate::mock::MockBackend`] 验证通道语义。 diff --git a/crates/presenter/src/dto/mod.rs b/crates/presenter/src/dto/mod.rs index 0408ed5..434b9b9 100644 --- a/crates/presenter/src/dto/mod.rs +++ b/crates/presenter/src/dto/mod.rs @@ -32,6 +32,8 @@ mod notices; mod pack; // M224: 本地 Mod 列表与启停(实例详情页的 MODS 区) mod mods; +// M235: 实例存档 / 截图的只读列表(详情页的「存档」区) +mod saves; mod settings; // M159: 自更新(检查结论 / 候选 / 就位状态) mod update; @@ -84,6 +86,11 @@ pub use pack::{ pub use settings::{ find_row, SettingControl, SettingGroupView, SettingRowView, JAVA_REFRESH_ACTION, }; +// M235: 实例存档 / 截图的只读列表 +pub use saves::{ + SavesArea, SavesCommand, SavesFailureKind, SavesFailureView, SavesListView, SavesSkipKind, + SavesSkipView, ScreenshotView, WorldView, +}; // M159: 自更新 pub use update::{ ReadyUpdate, UpdateBanner, UpdateCandidateView, UpdateFailureView, UpdateOutcome, UpdateView, diff --git a/crates/presenter/src/dto/saves.rs b/crates/presenter/src/dto/saves.rs new file mode 100644 index 0000000..99c7a76 --- /dev/null +++ b/crates/presenter/src/dto/saves.rs @@ -0,0 +1,254 @@ +//! 实例**存档 / 截图的只读快照**与「打开文件夹」的 IPC 面(M235 · STOREMVP)。 +//! +//! # 三层里的第二层 +//! +//! 塔裁决 U3 把这一版钉成「只读列表 + 打开文件夹」,不做缩略图墙。三层是: +//! `core::game::saves`(读盘,唯一的真相)→ 本模块(IPC 形状)→ +//! `crates/app-iced/src/pages/instances/saves.rs`(画)。 +//! +//! | 方向 | 类型 | +//! |---|---| +//! | app → host | [`SavesCommand`](列目录 / 打开文件夹) | +//! | host → app | [`SavesListView`](整份快照)或 [`SavesFailureView`](具名原因) | +//! +//! # 为什么另开一族,不并进 `ModListView` +//! +//! 两者形状相近(都是"列一个实例的某个目录"),但语义不同:mod 有启停(会写盘、要看游戏在不在跑), +//! 存档这一版**没有任何写路径**。合并会让 mod 那边的 `pending` / `running` 字段在存档页变成 +//! 永远为假的分支(M224 拒绝给内容页加第五个 `ContentKind` 是同一条理由:别让穷尽 match 为不相干 +//! 的字段服务)。 +//! +//! # 文案纪律(M161) +//! +//! 本模块**不带人读散文**:失败给的是结构([`SavesFailureKind`] + 底层 `detail` 串), +//! 时间戳给的是**毫秒数**(UTC 毫秒,UI 自己格式化)。文案一律由 app 层按 i18n key 现求。 +//! +//! # 时间戳为什么是 `Option` 的毫秒 +//! +//! 取不到(早于 1970 / 文件系统不给)就是 `None`——**不拿 0 或当前时间填**:那会让"时间未知" +//! 看起来像"1970 年"或"刚刚"。格式化交给 UI(`dto::logs` 的时戳同理)。 + +use serde::{Deserialize, Serialize}; + +use crate::dto::InstanceId; + +/// app → host 的存档指令。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum SavesCommand { + /// 列某个实例的 `saves/` 与 `screenshots/`(进详情页时发、点「重试」时再发)。 + List(InstanceId), + /// 用系统文件管理器打开存档目录。 + /// + /// 打开**哪个**目录由 host 决定(`saves/` → `screenshots/` → 运行目录,依次退化), + /// 因为那是"磁盘上到底有什么"的事实,不该让 UI 猜。 + OpenFolder { + /// 实例 id + id: InstanceId, + }, +} + +/// 一个世界目录。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorldView { + /// 磁盘上的目录名(这一版它就是展示名:`LevelName` 在 `level.dat` 里,本版不解析 NBT)。 + pub dir: String, + /// 有没有 `icon.png`(只判存在,不解码——缩略图墙不在 U3 范围内)。 + pub icon: bool, + /// 目录 mtime(UTC 毫秒;`None` = 取不到)。 + pub modified_ms: Option, +} + +/// 一张截图(**只给元数据**,不给像素:解码图片要引依赖,U3 明确不做)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ScreenshotView { + /// 磁盘文件名(含扩展名)。 + pub file: String, + /// 字节数(UI 自己格式化,不在这里拼 `1.2 MiB`)。 + pub bytes: u64, + /// 文件 mtime(UTC 毫秒;`None` = 取不到)。 + pub modified_ms: Option, +} + +/// 被跳过的条目来自哪个目录。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SavesArea { + /// `/saves/` + Saves, + /// `/screenshots/` + Screenshots, +} + +/// 没收进列表的原因(与 `core::game::saves::SkipKind` 一一对应)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SavesSkipKind { + /// 符号链接(不跟随)。 + SymbolicLink, + /// `saves/` 下的非目录。 + NotADirectory, + /// `screenshots/` 下的非普通文件。 + NotAFile, + /// 名字过不了路径规则。 + UnsafeName, + /// 元数据读不出来。 + Unreadable, + /// 不是认得的图片扩展名。 + NotAnImage, +} + +/// 一个没能进列表的条目。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SavesSkipView { + /// 来自哪个目录。 + pub area: SavesArea, + /// 磁盘名字。 + pub file: String, + /// 为什么没收。 + pub reason: SavesSkipKind, +} + +/// 一次列举的完整快照(**整体替换**,不做增量)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SavesListView { + /// 实例 id + pub instance: InstanceId, + /// 被列的 `saves/` 目录(绝对路径)。 + pub dir: String, + /// `saves/` 存不存在(`false` = 这实例还没存过档,不是错误)。 + pub exists: bool, + /// 世界列表(按名字排序)。 + pub worlds: Vec, + /// 被列的 `screenshots/` 目录(绝对路径)。 + pub screenshots_dir: String, + /// `screenshots/` 存不存在。 + pub screenshots_exists: bool, + /// 截图列表(**新的在前**)。 + pub screenshots: Vec, + /// 跳过的条目(UI 只需计数 + 一句"为什么")。 + pub skipped: Vec, +} + +impl SavesListView { + /// 世界数。 + #[must_use] + pub fn world_count(&self) -> usize { + self.worlds.len() + } + + /// 截图数。 + #[must_use] + pub fn screenshot_count(&self) -> usize { + self.screenshots.len() + } + + /// 跳过数。 + #[must_use] + pub fn skipped_count(&self) -> usize { + self.skipped.len() + } + + /// 两个目录都不存在(新实例的常态:还没进过游戏)。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.worlds.is_empty() && self.screenshots.is_empty() + } +} + +/// 一次失败(app 按 key 现求文案)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SavesFailureView { + /// 实例 id(迟到/串台的结果靠它判)。 + pub instance: InstanceId, + /// 失败大类。 + pub kind: SavesFailureKind, + /// 底层原文(OS 错误 / 路径细节);只进括号,不进主句。 + pub detail: String, +} + +/// 失败大类。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SavesFailureKind { + /// 实例不在了(快照里找不到),或隔离档位派生不出运行目录。 + InstanceUnavailable, + /// `saves/` / `screenshots/` 存在但不可用(符号链接 / 不是目录 / 读不了)。 + DirUnusable, + /// 「打开文件夹」失败(目录不存在 / 拉不起文件管理器)。 + OpenFailed, +} + +#[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()); + } +} diff --git a/crates/presenter/src/host/dispatch.rs b/crates/presenter/src/host/dispatch.rs index 328d93f..4003211 100644 --- a/crates/presenter/src/host/dispatch.rs +++ b/crates/presenter/src/host/dispatch.rs @@ -9,7 +9,8 @@ use crate::toast::ToastKind; use super::accounts::{self, LoginJob}; use super::{ - addon, game, install, java, launch, loader_versions, logs, mods, settings, versions, CoreHost, + addon, game, install, java, launch, loader_versions, logs, mods, saves, settings, versions, + CoreHost, }; impl CoreHost { @@ -289,6 +290,11 @@ impl CoreHost { let running = self.in_flight(&id); mods::dispatch(self.env.clone(), self.tx.clone(), *command, running); } + // M235:实例存档 / 截图的只读列表 + 打开文件夹 + // (只读,所以不必问"游戏在不在跑";拉起文件管理器在 core 的 platform opener 里) + AppCommand::Saves(command) => { + saves::dispatch(self.env.clone(), self.tx.clone(), *command); + } // AI 对话:agent 的 GUI host 未落地前用脚本回复(不接真实模型) other @ (AppCommand::AskAgent(_) | AppCommand::StopAgent | AppCommand::SetYolo(_)) => { self.chat.dispatch(other); diff --git a/crates/presenter/src/host/handle.rs b/crates/presenter/src/host/handle.rs index b23c9cd..f16ec1e 100644 --- a/crates/presenter/src/host/handle.rs +++ b/crates/presenter/src/host/handle.rs @@ -233,6 +233,12 @@ impl CoreHost { } self.send(CoreEvent::Mods(Box::new(list))); } + // M235:实例存档 / 截图的只读列表。成功 ⇒ 整份快照;失败 ⇒ 具名原因给 app 求文案。 + // 「打开文件夹」也是这条:拉起文件管理器之后再回一份新鲜快照(用户在那边删了世界, + // 切回来这一拍就跟上)。 + HostTask::Saves(report) => { + self.send(CoreEvent::Saves(Box::new(report.list))); + } HostTask::Toast(toast) => self.events.push_back(AppEvent::Toast(*toast)), } } diff --git a/crates/presenter/src/host/job.rs b/crates/presenter/src/host/job.rs index 1b3500e..c8ffec5 100644 --- a/crates/presenter/src/host/job.rs +++ b/crates/presenter/src/host/job.rs @@ -155,6 +155,8 @@ pub enum HostTask { Update(Box), /// 本地 Mod 列表 / 启停(M224):列表快照或具名失败(见 `host::mods::Report`) Mods(Box), + /// 实例存档 / 截图的只读列表(M235):快照或具名失败(见 `host::saves::Report`) + Saves(Box), } /// 后台线程用的发送端(`Sender` 克隆即可)。 diff --git a/crates/presenter/src/host/mod.rs b/crates/presenter/src/host/mod.rs index 0e5af2f..d28434c 100644 --- a/crates/presenter/src/host/mod.rs +++ b/crates/presenter/src/host/mod.rs @@ -34,6 +34,7 @@ mod logs; mod meter; mod mods; mod opener; +mod saves; mod settings; mod update; mod update_state; diff --git a/crates/presenter/src/host/saves.rs b/crates/presenter/src/host/saves.rs new file mode 100644 index 0000000..f52760b --- /dev/null +++ b/crates/presenter/src/host/saves.rs @@ -0,0 +1,212 @@ +//! 实例存档 / 截图的宿主实现(M235 · STOREMVP)。 +//! +//! ## 三条纪律 +//! +//! | 纪律 | 落点 | +//! |---|---| +//! | 磁盘只在后台线程碰 | [`spawn_list`] / [`spawn_open_folder`] 走 `job::spawn`,UI 只收快照 | +//! | 单根语义(D20) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生、不做双根合并 | +//! | 只读 | 本模块一次写盘都没有;`saves/` 不存在也不替玩家建(core 的 `saves::scan` 同理) | +//! +//! ## 为什么不复用启动路径的 `run_dir()` +//! +//! 与 [`super::mods`] 同一条理由:`host::launch` 的 `run_dir()` 顺带把「这次用哪个档位」写进实例 +//! 设置(`observe_mode`)。**列存档不该因为用户点开详情页就写盘**,所以这里只取派生结果。 +//! +//! ## 「打开文件夹」为什么由 host 决定打开哪个目录 +//! +//! `saves/` 可能还不存在(新实例),而用户点「打开文件夹」想看到的是**能放存档的地方**。 +//! 于是退化成:`saves/` → `screenshots/` → 运行目录,取第一个存在的。这个判断是"磁盘上到底 +//! 有什么"的事实,放在 host 这一侧;UI 只发一条 [`crate::dto::SavesCommand::OpenFolder`]。 +//! +//! 真正拉起文件管理器的是 [`unlucky_core::platform::opener::open_dir`](`AGENTS.md` §6: +//! 平台差异只许收敛在 `core::platform`,本文件一个 `cfg` 都没有,也没有一次裸 spawn)。 +//! +//! ## 失败口径 +//! +//! 一律走 [`crate::dto::SavesFailureView`](结构 + OS 原文),**不拼中文**:文案由 app 层按 +//! i18n key 现求(M161 的纪律)。三类失败见 [`crate::dto::SavesFailureKind`]。 + +use std::path::PathBuf; + +use unlucky_core::game::install::instance::read_instance_config; +use unlucky_core::game::isolation::derive_game_dir; +use unlucky_core::game::saves::{self, SavesError, SavesScan}; +use unlucky_core::platform::opener; + +use crate::dto::{ + locate, InstanceId, InstanceSummary, SavesArea, SavesCommand, SavesFailureKind, + SavesFailureView, SavesListView, SavesSkipKind, SavesSkipView, ScreenshotView, WorldView, +}; + +use super::env::HostEnv; +use super::job::{spawn, JobSender}; + +/// 后台线程回报的一次结果(`HostTask::Saves` 的载荷)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Report { + /// 实例 id(迟到结果靠它判「这条还是不是当前实例的」)。 + pub instance: InstanceId, + /// 列表结果:成功 = 整份快照;失败 = 具名原因。 + pub list: Result, +} + +/// 后台列一个实例的 `saves/` 与 `screenshots/`。 +pub fn spawn_list(env: HostEnv, tx: JobSender, id: InstanceId) { + spawn("saves-list", move || { + let report = Report { + instance: id.clone(), + list: list(&env, &id), + }; + let _ = tx.send(super::HostTask::Saves(Box::new(report))); + }); +} + +/// 后台用系统文件管理器打开存档目录,**并把刷新后的快照一起带回来**。 +/// +/// 带快照回来不是顺手:用户常在文件管理器里删掉旧世界再切回来,页面这一拍就能跟着更新, +/// 不用等下一次进详情页。 +pub fn spawn_open_folder(env: HostEnv, tx: JobSender, id: InstanceId) { + spawn("saves-open", move || { + let report = Report { + instance: id.clone(), + list: open_folder(&env, &id), + }; + let _ = tx.send(super::HostTask::Saves(Box::new(report))); + }); +} + +/// 一条指令 → 后台线程(`CoreHost::dispatch` 的唯一入口)。 +pub fn dispatch(env: HostEnv, tx: JobSender, command: SavesCommand) { + match command { + SavesCommand::List(id) => spawn_list(env, tx, id), + SavesCommand::OpenFolder { id } => spawn_open_folder(env, tx, id), + } +} + +/// 列目录(后台线程里调)。 +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)) +} + +/// 打开存档目录 → 再列一遍(失败只说"打不开",原文进括号)。 +fn open_folder(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))?; + let target = open_target(&scan, &run_dir); + opener::open_dir(&target) + .map_err(|detail| failure(&instance.id, SavesFailureKind::OpenFailed, detail))?; + Ok(view(&instance, &scan)) +} + +/// 打开哪一个目录:`saves/` → `screenshots/` → 运行目录(取第一个存在的)。 +fn open_target(scan: &SavesScan, run_dir: &std::path::Path) -> PathBuf { + if scan.exists { + return scan.dir.clone(); + } + if scan.screenshots_exists { + return scan.screenshots_dir.clone(); + } + run_dir.to_path_buf() +} + +/// 实例 + 它的运行目录(找不到实例 / 派生不出目录都给具名失败)。 +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( + id, + SavesFailureKind::InstanceUnavailable, + format!("实例 {id} 不在快照里(可能刚被删除)"), + )); + }; + let dir = run_dir(env, instance) + .map_err(|detail| failure(&instance.id, SavesFailureKind::InstanceUnavailable, detail))?; + Ok((instance.clone(), dir)) +} + +/// 实例的运行目录(D20 覆盖链,与启动同一份派生;**不写盘**)。 +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}"))?; + let derivation = derive_game_dir( + &env.game, + &instance.version_id, + global, + config.as_ref(), + None, + ) + .map_err(|error| format!("派生运行目录失败:{error}"))?; + Ok(derivation.game_dir) +} + +/// 快照映射:core 的扫描结果 → DTO(路径串在 host 这一侧才成形)。 +fn view(instance: &InstanceSummary, scan: &SavesScan) -> SavesListView { + SavesListView { + instance: instance.id.clone(), + dir: scan.dir.display().to_string(), + exists: scan.exists, + worlds: scan + .worlds + .iter() + .map(|world| WorldView { + dir: world.dir.clone(), + icon: world.icon, + modified_ms: world.modified_ms, + }) + .collect(), + screenshots_dir: scan.screenshots_dir.display().to_string(), + screenshots_exists: scan.screenshots_exists, + screenshots: scan + .screenshots + .iter() + .map(|shot| ScreenshotView { + file: shot.file.clone(), + bytes: shot.bytes, + modified_ms: shot.modified_ms, + }) + .collect(), + skipped: scan + .skipped + .iter() + .map(|skip| SavesSkipView { + area: match skip.area { + saves::Area::Saves => SavesArea::Saves, + saves::Area::Screenshots => SavesArea::Screenshots, + }, + file: skip.file.clone(), + reason: match skip.reason { + saves::SkipKind::SymbolicLink => SavesSkipKind::SymbolicLink, + saves::SkipKind::NotADirectoryExpected => SavesSkipKind::NotADirectory, + saves::SkipKind::NotAFile => SavesSkipKind::NotAFile, + saves::SkipKind::UnsafeName => SavesSkipKind::UnsafeName, + saves::SkipKind::Unreadable => SavesSkipKind::Unreadable, + saves::SkipKind::NotAnImage => SavesSkipKind::NotAnImage, + }, + }) + .collect(), + } +} + +fn failure(instance: &str, kind: SavesFailureKind, detail: impl Into) -> SavesFailureView { + SavesFailureView { + instance: instance.to_string(), + kind, + detail: detail.into(), + } +} + +/// core 的错误 → 「目录不可用」+ 原文(**不翻译**,翻译在 app 层)。 +fn unusable(instance: &str, error: &SavesError) -> SavesFailureView { + failure( + instance, + SavesFailureKind::DirUnusable, + format!("{}:{}", error.dir.display(), error.detail), + ) +} + +#[cfg(test)] +mod tests; diff --git a/crates/presenter/src/host/saves/tests.rs b/crates/presenter/src/host/saves/tests.rs new file mode 100644 index 0000000..2592bb1 --- /dev/null +++ b/crates/presenter/src/host/saves/tests.rs @@ -0,0 +1,199 @@ +//! `host::saves` 的用例:**真盘端到端**(派生运行目录 → core 只读列目录 → 快照回来)。 +//! +//! 端到端那条是这个模块存在的主要理由:只有"真的建一个实例根、真的放几个世界和截图再读回来" +//! 才能验「派生出来的目录就是玩家看到的那个目录」。 +//! +//! 抓不住什么:`OpenFolder` **不在这里真跑**(它会拉起用户的文件管理器);它唯一的判断 +//! (打开哪个目录)由 [`open_target_degrades_from_saves_to_run_dir`] 这条纯函数用例钉住, +//! 真正 spawn 的那一步在 core 的 `platform::opener` 用例里。 + +use super::*; +use crate::dto::{InstanceRuntime, LaunchArgsView, LoaderInfo, LoaderKind}; +use unlucky_core::game::saves::{SavesScan, SavesSkip, SkipKind}; + +const ID: &str = "survival"; +const VERSION: &str = "1.20.4-fabric-0.16.5"; + +/// 一次性临时根(与本 crate 其它 host 测试同款:不引第三方 tempfile)。 +struct TempRoot(PathBuf); + +impl Drop for TempRoot { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// 一个可用的便携根 + 一个 `custom` 档位的实例(运行目录由实例自己指,D20 里唯一这样表达的档位)。 +/// +/// 走 `custom` 是**故意**的:用例不必造 `versions//` 就能验「派生出来的目录就是读的那个」。 +fn fixture(tag: &str) -> (TempRoot, HostEnv, PathBuf) { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("{tag}-{}-{seq}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + let root = TempRoot(path); + std::fs::create_dir_all(&root.0).expect("建临时目录"); + let env = HostEnv::at_root(&root.0); + let run = root.0.join("run"); + std::fs::create_dir_all(&run).expect("建运行目录"); + std::fs::create_dir_all(env.layout.config()).expect("建 config"); + let profile = format!(r#"{{"id":"{ID}","name":"生存","versionId":"{VERSION}"}}"#); + std::fs::write( + env.layout.profiles_config(), + format!(r#"{{"schemaVersion":1,"profiles":[{profile}]}}"#), + ) + .expect("写 profiles"); + let config = format!( + r#"{{"schemaVersion":1,"id":"{ID}","versionId":"{VERSION}","isolation":"custom","gameDir":{}}}"#, + serde_json::to_string(&run).expect("路径转 JSON") + ); + let path = env.layout.instance_config(ID).expect("实例 id"); + std::fs::create_dir_all(path.parent().expect("instances 目录")).expect("建 instances"); + std::fs::write(path, config).expect("写实例设置"); + (root, env, run) +} + +fn instance() -> InstanceSummary { + InstanceSummary { + id: ID.to_string(), + name: "生存".to_string(), + mc_version: "1.20.4".to_string(), + loader: LoaderInfo { + kind: LoaderKind::Fabric, + version: "0.16.5".to_string(), + }, + last_played: None, + runtime: InstanceRuntime::Idle, + icon: None, + isolation: "custom".to_string(), + version_id: VERSION.to_string(), + version_present: true, + launch: LaunchArgsView::default(), + } +} + +/// **端到端**(真盘 + 真派生):世界 / 截图 / 跳过原因都过桥,且快照里的路径就是派生目录。 +/// +/// 抓得住:派生用错目录、`icon.png` 被当成"读到了"、跳过原因被折叠、字节数丢了。 +#[test] +fn list_round_trips_on_a_real_root() { + let (_root, env, run) = fixture("saves-e2e"); + std::fs::create_dir_all(run.join("saves/My World")).expect("建世界"); + std::fs::write(run.join("saves/My World/icon.png"), b"png").expect("写图标"); + std::fs::create_dir_all(run.join("saves/Bare World")).expect("建世界 2"); + std::fs::create_dir_all(run.join("screenshots")).expect("建截图目录"); + std::fs::write(run.join("screenshots/2024-05-01_10.00.00.png"), b"abc").expect("写截图"); + std::fs::write(run.join("screenshots/notes.txt"), b"x").expect("写非图片"); + + let listed = list(&env, ID).expect("列存档"); + assert!(listed.exists && listed.screenshots_exists); + assert_eq!(std::path::PathBuf::from(&listed.dir), run.join("saves")); + assert_eq!( + std::path::PathBuf::from(&listed.screenshots_dir), + run.join("screenshots") + ); + + let worlds: Vec<(&str, bool)> = listed + .worlds + .iter() + .map(|world| (world.dir.as_str(), world.icon)) + .collect(); + assert_eq!(worlds, [("Bare World", false), ("My World", true)]); + assert_eq!(listed.world_count(), 2); + + assert_eq!(listed.screenshot_count(), 1); + assert_eq!(listed.screenshots[0].bytes, 3); + assert_eq!( + listed.skipped_count(), + 1, + "非图片文件要登记原因,不能静默丢" + ); + assert_eq!(listed.skipped[0].reason, SavesSkipKind::NotAnImage); + assert_eq!(listed.skipped[0].area, SavesArea::Screenshots); +} + +/// 「打开文件夹」打开哪个目录:`saves/` → `screenshots/` → 运行目录。 +/// +/// 纯函数,**不 spawn**(真拉起文件管理器那一步在 core 的 opener 用例里)。 +#[test] +fn open_target_degrades_from_saves_to_run_dir() { + let run = PathBuf::from("D:/ULL/.ULL/data/instances/x"); + let mut scan = SavesScan { + dir: run.join("saves"), + exists: true, + worlds: Vec::new(), + screenshots_dir: run.join("screenshots"), + screenshots_exists: true, + screenshots: Vec::new(), + skipped: Vec::new(), + }; + assert_eq!(open_target(&scan, &run), run.join("saves")); + + scan.exists = false; + assert_eq!( + open_target(&scan, &run), + run.join("screenshots"), + "saves 不在时退到 screenshots" + ); + + scan.screenshots_exists = false; + assert_eq!(open_target(&scan, &run), run, "两个都没有时打开运行目录"); +} + +/// 找不到实例 ⇒ 具名失败(不是"空列表":那会让页面以为这实例没存档)。 +#[test] +fn missing_instance_is_a_named_failure() { + let (_root, env, _run) = fixture("saves-unknown"); + let failure = list(&env, "no-such-instance").expect_err("不存在的实例居然列成功了"); + assert_eq!(failure.kind, SavesFailureKind::InstanceUnavailable); + assert_eq!(failure.instance, "no-such-instance"); + assert!(!failure.detail.is_empty(), "原因被吃掉了"); +} + +/// `saves/` 存在但不可用 ⇒ `DirUnusable`,且原文里带着那个路径(用户能自己去看)。 +#[test] +fn unusable_dir_maps_to_dir_unusable() { + let (_root, env, run) = fixture("saves-unusable"); + std::fs::write(run.join("saves"), b"not a dir").expect("用文件占住 saves"); + + let failure = list(&env, ID).expect_err("saves 是个文件,居然列成功了"); + assert_eq!(failure.kind, SavesFailureKind::DirUnusable); + assert!( + failure.detail.contains("saves"), + "原文里没有路径:{}", + failure.detail + ); +} + +/// 跳过原因一一对应(`NotADirectoryExpected` → `NotADirectory` 这条尤其容易抄错)。 +#[test] +fn every_core_skip_reason_keeps_its_own_kind() { + let cases = [ + (SkipKind::SymbolicLink, SavesSkipKind::SymbolicLink), + ( + SkipKind::NotADirectoryExpected, + SavesSkipKind::NotADirectory, + ), + (SkipKind::NotAFile, SavesSkipKind::NotAFile), + (SkipKind::UnsafeName, SavesSkipKind::UnsafeName), + (SkipKind::Unreadable, SavesSkipKind::Unreadable), + (SkipKind::NotAnImage, SavesSkipKind::NotAnImage), + ]; + for (reason, expected) in cases { + let scan = SavesScan { + dir: PathBuf::from("s"), + exists: true, + worlds: Vec::new(), + screenshots_dir: PathBuf::from("sh"), + screenshots_exists: true, + screenshots: Vec::new(), + skipped: vec![SavesSkip { + area: saves::Area::Saves, + file: "x".to_string(), + reason, + }], + }; + assert_eq!(view(&instance(), &scan).skipped[0].reason, expected); + } +} diff --git a/crates/presenter/src/state.rs b/crates/presenter/src/state.rs index 13fbbb1..ab2315b 100644 --- a/crates/presenter/src/state.rs +++ b/crates/presenter/src/state.rs @@ -47,10 +47,13 @@ mod agent; mod live; // M224: 本地 Mod 列表与启停的视图态(状态落地 + 在飞标记) mod mods; +// M235: 实例存档 / 截图的只读快照(状态落地 + 迟到丢弃) +mod saves; mod tiles; pub use live::MAX_LIVE_LINES; pub use mods::ModsState; +pub use saves::SavesState; pub use tiles::{grid_positions, TileCell}; /// 一份加载器版本清单查询的结果(`Ok` = 最新在前;`Err` = 人读原因)。 @@ -141,6 +144,8 @@ pub struct AppState { pub adopt: AdoptState, /// 本地 Mod(M224):当前实例的快照 / 失败 / 在飞启停 pub mods: ModsState, + /// 实例存档 / 截图(M235):当前实例的只读快照 / 失败(可重试) + pub saves: SavesState, } impl Default for AppState { @@ -188,6 +193,7 @@ impl AppState { pack: PackState::default(), adopt: AdoptState::default(), mods: ModsState::default(), + saves: SavesState::default(), } } @@ -316,6 +322,8 @@ impl AppState { } // M224:本地 Mod(落地判据在 `state/mods.rs`,含迟到丢弃与在飞标记) CoreEvent::Mods(result) => self.mods.apply(*result), + // M235:实例存档 / 截图(落地判据在 `state/saves.rs`,含迟到丢弃) + CoreEvent::Saves(result) => self.saves.apply(*result), } } diff --git a/crates/presenter/src/state/saves.rs b/crates/presenter/src/state/saves.rs new file mode 100644 index 0000000..4e43c1b --- /dev/null +++ b/crates/presenter/src/state/saves.rs @@ -0,0 +1,155 @@ +//! 实例存档 / 截图快照的落地(M235 · STOREMVP)。 +//! +//! 从 `state.rs` 拆出(那个文件贴着 400 行硬线),形状与 [`super::mods`] 一致: +//! **另一个 `impl AppState` 块**,路径与可见性都不变,`apply_core` 只有一行投递。 +//! +//! # 为什么快照住在这里而不是页面 +//! +//! 「列表画的永远是磁盘上的真相」是本模块唯一要保证的事,所以: +//! +//! - 快照走 [`crate::dto::CoreEvent::Saves`] 的 `Ok` 一支,**整体替换**(不做增量合并)—— +//! host 每次都是重新扫一遍目录才发的,合并只会两处真相; +//! - 失败走同一个事件的 `Err` 一支,记下一条**具名原因**,页面按 i18n key 现求文案; +//! - **可重试**:失败时不清 `bound`,页面据此画"重试"按钮(再发一条 +//! [`crate::dto::SavesCommand::List`] 就行,没有别的状态要复位)。 +//! +//! # 迟到结果 +//! +//! 用户很可能点开 A 的详情页、还没等后台回来就切到 B。所以 `view.instance` 与 +//! [`SavesState::bound`] 不一致的结果**直接丢掉**(与 [`super::mods`] 同一条纪律)。 + +use crate::dto::{SavesFailureView, SavesListView}; + +use super::AppState; + +/// 存档区的视图态。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct SavesState { + /// 当前详情页的实例 id(`None` = 不在详情页)。 + pub bound: Option, + /// 最近一份快照(`None` = 还没请求过)。 + pub view: Option, + /// 最近一条失败(具名原因;成功一次就清掉)。 + pub failed: Option, +} + +impl SavesState { + /// 切到另一个实例(换实例必须清空:A 的存档不能画在 B 的详情页上)。 + pub(super) fn bind(&mut self, instance: Option<&str>) { + if self.bound.as_deref() == instance { + return; + } + self.bound = instance.map(str::to_string); + self.view = None; + self.failed = None; + } + + /// 一条结果落地(`Ok` = 快照整体替换,`Err` = 记一条具名失败;迟到/串台的丢掉)。 + pub fn apply(&mut self, result: Result) { + match result { + Ok(view) => self.changed(view), + Err(failure) => self.failed(failure), + } + } + + /// 快照落地(整体替换;迟到/串台的丢掉)。 + pub fn changed(&mut self, view: SavesListView) { + if self.bound.as_deref() != Some(view.instance.as_str()) { + return; + } + self.failed = None; + self.view = Some(view); + } + + /// 失败落地(只记**当前绑定实例**的;old 列表保留——用户还能看见旧列表,只是多一条红字)。 + pub fn failed(&mut self, failure: SavesFailureView) { + if self.bound.as_deref() != Some(failure.instance.as_str()) { + return; + } + self.failed = Some(failure); + } + + /// 当前绑定实例的列表是否已经有一份快照。 + #[must_use] + pub fn has_view(&self) -> bool { + self.view.is_some() + } +} + +impl AppState { + /// 进/出详情页时对齐绑定(`refresh_view` 调;与 `bind_mods` 同一处生命周期)。 + pub fn bind_saves(&mut self, instance: Option<&str>) { + self.saves.bind(instance); + } +} + +#[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()); + } +}