feat(saves): 实例存档 / 截图的只读列表与打开文件夹(M235 STOREMVP)

用户诉求是 PCL 差距四项里的「存档截图 / 导入」,M230 审计确认三层全缺。塔裁决 U3
把这一版钉成「只读列表 + 打开文件夹」,不做缩略图墙(缩略图要引图片解码依赖,而
AGENTS §2 只允许 palette-extract 引 image 类依赖;截图/导入产物形态也没定型)。

core
- `game::saves`(新):只读列 `<run_dir>/saves`(世界目录)与 `<run_dir>/screenshots`
  (图片文件)。只调 symlink_metadata / read_dir / metadata,不建目录、不写文件、不读
  文件内容(`icon.png` 只判存在)。目录不存在 ⇒ 空列表不是错误;存在但不可用(符号
  链接 / 不是目录 / 读不了)⇒ 具名错误。条目级异常逐条登记原因,不静默丢。
- `platform::opener`(新):`open_dir` 是**目录**打开器的唯一收敛点(explorer / open /
  xdg-open),内部复用 `child::quiet`,cfg 分支只此一处。塔明确要求按 AGENTS §6 放
  platform 而不是业务模块,并要一条可执行判据防退化
  (`the_launcher_literals_live_here_only` 扫 `crates/core/src`:除本文件外不许出现
  `"explorer"` / `"xdg-open"` 字面量)。

presenter
- `dto::saves`:SavesCommand(List / OpenFolder)、SavesListView、SavesFailureView
  (结构 + OS 原文,不拼中文)、`SavesListView` 的计数助手。
- `host::saves`:走 `job::spawn`,UI 只收快照;运行目录仍是
  `isolation::derive_game_dir` 的同一个答案(与启动 / mod 列表同源,**不写盘**)。
  「打开文件夹」打开哪个目录由 host 定:`saves/` → `screenshots/` → 运行目录。
  失败分三类(InstanceUnavailable / DirUnusable / OpenFailed),可重试。
- `state::saves`:快照整体替换、迟到结果丢弃、失败保留绑定(重试才有意义)。
- 接线:CoreEvent::Saves / AppCommand::Saves / HostTask::Saves。

app-iced
- `pages::instances::saves`:详情页 SAVES 卡片(MODS 之后、启动参数之前;**不套第二层
  滚动条**,整列那一条是唯一入口)。世界行带 UTC 时间与「有图标」,截图行带大小与时间;
  第一行固定画「这一版只读」(U3 边界让用户看得见)。读盘失败画一条红字 + 「重试」;
  打开文件夹失败**不画**重试(那个动作的重试就是再点一次按钮)。
- 接线:Message::{ReloadSaves, OpenSavesFolder}、`sync_saves`(与 `sync_mods` 同一判据)、
  Model::saves、`saves_shot` 出图夹具(ULL_SAVES_FIXTURE=1)。

i18n
- `InstancesSaves*` 20 条中英成对,catalog 与金表在**同一条锚点**后追加、逐字节一致。

证据
- core 只读判据:`game::saves::tests` 在真盘建世界/截图,调用前后各拍一次
  (相对路径, 是否目录, 字节数, mtime)指纹并逐条比对;另有用例钉「目录缺失不建目录」。
- presenter:`host::saves::tests` 真盘端到端(custom 档位派生运行目录 → 快照)。
- GUI:`--software-renderer` 1180×760 浅/深各一张 + 1180×1280 两张「整块卡片可见」
  (1180×760 下 SAVES 卡片位于折叠线附近,只能看到卡头;第二对用 ULL_WINDOW_SIZE
  把窗口拉高,口径与 M98 的矩阵一致),落 `DevDocs/artifacts/M235/`。

没做的(U3 边界内,见 handoff):缩略图墙、导入、删除/重命名、`level.dat` 的
LevelName 解析(世界名暂时就是目录名)。
This commit is contained in:
AskaEth committed 2026-11-11 06:00:27 +08:00
1 parent 3789cb7e61
commit f217d83680
36 files changed
+2702 -17

No files matched your search

+2
View File
@@ -71,6 +71,8 @@ mod boot_probe;
mod construct; mod construct;
mod mods_shot; mod mods_shot;
mod refresh; mod refresh;
// M235: 出图取证用的 SAVES 夹具(`ULL_SAVES_FIXTURE=1`)
mod saves_shot;
mod theme; mod theme;
/// iced 侧的 presenter 接线层(M119):物理目录 `src/presenter/`,逻辑名字在 `crate::app::state` 下。 /// iced 侧的 presenter 接线层(M119):物理目录 `src/presenter/`,逻辑名字在 `crate::app::state` 下。
+35 -2
View File
@@ -4,9 +4,9 @@
//! 由 [`App::view_dirty`](super::App) 门控(M90 r2/P2-1 起):只有真的改了状态的事情才置位, //! 由 [`App::view_dirty`](super::App) 门控(M90 r2/P2-1 起):只有真的改了状态的事情才置位,
//! 30 Hz 空转不重建(重建要把 N 个实例的名字/版本串克隆一遍)。 //! 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 unlucky_presenter::toast::ToastKind;
use crate::app::view; use crate::app::view;
@@ -64,6 +64,8 @@ impl App {
// **必须在建模型之前**:它会把快照写进 presenter 状态,而下一行 `model()` 就读那份状态 // **必须在建模型之前**:它会把快照写进 presenter 状态,而下一行 `model()` 就读那份状态
// (放在 `model()` 之后等于这一帧画的是旧快照——M224 出图实测:注入的夹具要等下一帧)。 // (放在 `model()` 之后等于这一帧画的是旧快照——M224 出图实测:注入的夹具要等下一帧)。
self.sync_mods(); self.sync_mods();
// M235:详情页 ⇄ SAVES 区的绑定与取数(换实例就重新列一次存档)。同样是**建模型之前**。
self.sync_saves();
// M224:启停失败的提示语(presenter 只发结构,文案在这里按 i18n 现求)。 // M224:启停失败的提示语(presenter 只发结构,文案在这里按 i18n 现求)。
self.note_mods_failure(); self.note_mods_failure();
@@ -155,6 +157,37 @@ impl App {
.dispatch(AppCommand::Mods(Box::new(ModsCommand::List(id)))); .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。 /// 启停失败的提示语:每条**新**失败弹一次 Toast。
/// ///
/// 判据是 `(实例, 文件, 大类)` 三元组:同一个文件连点两次、每次都失败在同一个大类上时, /// 判据是 `(实例, 文件, 大类)` 三元组:同一个文件连点两次、每次都失败在同一个大类上时,
@@ -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<u64>| WorldView {
dir: dir.to_string(),
icon,
modified_ms,
};
let shot = |file: &str, bytes: u64, modified_ms: Option<u64>| 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(),
}
}
+17 -1
View File
@@ -11,7 +11,7 @@
//! 要等回程事件,写盘失败时旧快照回来必须留住用户敲的值(M157 r3 复审 P2-2)。 //! 要等回程事件,写盘失败时旧快照回来必须留住用户敲的值(M157 r3 复审 P2-2)。
use iced::Task; use iced::Task;
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch, ModsCommand}; use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch, ModsCommand, SavesCommand};
use crate::pages::InstanceMessage; use crate::pages::InstanceMessage;
@@ -40,6 +40,22 @@ impl App {
}))); })));
return Task::none(); 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(), _ if !self.launch_form.bound_to(&instance) => return Task::none(),
InstanceMessage::Jvm(text) => { InstanceMessage::Jvm(text) => {
+1
View File
@@ -257,6 +257,7 @@ pub(super) fn model(
logs: logs_model(state).with_status(lang), logs: logs_model(state).with_status(lang),
// M224:详情页 MODS 区的列表 / 启停(presenter 的视图态 → 展示字段) // M224:详情页 MODS 区的列表 / 启停(presenter 的视图态 → 展示字段)
mods: instances::mods_of(state, catalog), mods: instances::mods_of(state, catalog),
saves: instances::saves_of(state, catalog),
// M159:更新快照 → 页面视图(设置页「更新」区 + 顶部提示条都读它) // M159:更新快照 → 页面视图(设置页「更新」区 + 顶部提示条都读它)
update: update_of(&state.update), update: update_of(&state.update),
// M201:设置组快照(`SettingsChanged` 的回流结果)→ 页面视图。设置页的下载组从这里按 // M201:设置组快照(`SettingsChanged` 的回流结果)→ 页面视图。设置页的下载组从这里按
+9 -1
View File
@@ -25,7 +25,7 @@ use unlucky_presenter::dto::{
}; };
use unlucky_presenter::state::AppState; 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`])。 /// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。
pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String { 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 { pub(super) fn mods_of(state: &AppState, catalog: Catalog) -> Mods {
crate::pages::mods_from_state(&state.mods, catalog) 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)
}
+33 -6
View File
@@ -51,6 +51,8 @@ mod detail;
mod launch_args; mod launch_args;
// M224: 详情页的 MODS 区(本地 mod 列表 + 启停开关) // M224: 详情页的 MODS 区(本地 mod 列表 + 启停开关)
mod mods; mod mods;
// M235: 详情页的 SAVES 区(存档 / 截图的只读列表 + 打开文件夹)
mod saves;
mod state; mod state;
mod text; mod text;
@@ -61,6 +63,17 @@ mod tests;
pub(crate) use data::{DEMO, NOTE, SELECTED}; pub(crate) use data::{DEMO, NOTE, SELECTED};
pub(crate) use demo::{demo, demo_form}; pub(crate) use demo::{demo, demo_form};
pub use mods::{from_state as mods_from_state, Mods}; 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`])。 /// 启停失败/读目录失败的一句话(app 层弹 Toast 用;取词入口仍是 [`Texts`])。
pub fn mods_failure_line( pub fn mods_failure_line(
@@ -142,16 +155,30 @@ pub fn view<'a>(
metrics, metrics,
list_column(ui, block, forced, texts), list_column(ui, block, forced, texts),
match &block.detail { match &block.detail {
Some(detail) => { Some(detail) => detail::detail_column(
detail::detail_column(ui, metrics, detail, form, &model.mods, forced, texts) ui,
} metrics,
detail,
form,
&model.mods,
&model.saves,
forced,
texts,
),
None => detail::detail_empty(ui, texts), None => detail::detail_empty(ui, texts),
}, },
), ),
_ => match &block.detail { _ => match &block.detail {
Some(detail) => { Some(detail) => detail::narrow_detail(
detail::narrow_detail(ui, metrics, detail, form, &model.mods, forced, texts) ui,
} metrics,
detail,
form,
&model.mods,
&model.saves,
forced,
texts,
),
None => frame::body(metrics, detail::detail_empty(ui, texts)), None => frame::body(metrics, detail::detail_empty(ui, texts)),
}, },
}; };
@@ -35,6 +35,7 @@ pub(super) fn detail_column<'a>(
detail: &'a Detail, detail: &'a Detail,
form: &'a LaunchForm, form: &'a LaunchForm,
mods: &'a super::Mods, mods: &'a super::Mods,
saves: &'a super::Saves,
forced: Option<Interaction>, forced: Option<Interaction>,
texts: Texts, texts: Texts,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
@@ -51,6 +52,9 @@ pub(super) fn detail_column<'a>(
// 排在它后面意味着「本地 mod 列表」永远在折叠线以下(M224 出图实测:1180×1000 完全看不见)。 // 排在它后面意味着「本地 mod 列表」永远在折叠线以下(M224 出图实测:1180×1000 完全看不见)。
// 顺序因此是:身份(页头 + 元信息)→ 内容(MODS)→ 选项(启动参数)→ 动作。 // 顺序因此是:身份(页头 + 元信息)→ 内容(MODS)→ 选项(启动参数)→ 动作。
body = body.push(super::mods::section(ui, mods, forced, texts)); 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 内,卡片式)。 // M157:启动参数区在正文段之后、动作行之前(详情列 424px 内,卡片式)。
body = body.push(super::launch_args::section(ui, &detail.launch, form, texts)); 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, detail: &'a Detail,
form: &'a LaunchForm, form: &'a LaunchForm,
mods: &'a super::Mods, mods: &'a super::Mods,
saves: &'a super::Saves,
forced: Option<Interaction>, forced: Option<Interaction>,
texts: Texts, texts: Texts,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
@@ -93,6 +98,7 @@ pub(super) fn narrow_detail<'a>(
body = push_text(ui, metrics, body, detail); body = push_text(ui, metrics, body, detail);
// 顺序与宽窗那一支一致:内容(MODS)在选项(启动参数)之前,理由见 `detail_column`。 // 顺序与宽窗那一支一致:内容(MODS)在选项(启动参数)之前,理由见 `detail_column`。
body = body.push(super::mods::section(ui, mods, forced, texts)); 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(super::launch_args::section(ui, &detail.launch, form, texts));
body = body.push(frame::text_role( body = body.push(frame::text_role(
@@ -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<String>,
/// 正在读盘(进详情页首帧:请求已发出、快照还没回来)。
pub loading: bool,
/// 读盘/打开失败的人读原因(app 层按 i18n 拼好)。
pub error: Option<String>,
/// 这一条失败能不能靠「再列一次」解决(打开文件夹失败不算)。
pub retryable: bool,
/// 手里有没有一份**快照**(`false` = 只拿到了失败,"还没有世界"那几句不许画)。
pub has_view: bool,
/// 两个目录都不存在(新实例的常态:还没进过游戏)。
pub missing: bool,
/// 世界行。
pub worlds: Vec<Row>,
/// 截图行(新的在前,顺序由 core 给)。
pub screenshots: Vec<Row>,
/// 计数行(`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<u64>, texts: Texts) -> String {
let stamp = stamp(modified_ms, texts);
if icon {
format!("{stamp} · {}", texts.saves_world_icon())
} else {
stamp
}
}
/// 截图那一行的副文案:`1.2 MiB · 2024-05-01 10:00 UTC`。
fn shot_detail(bytes: u64, modified_ms: Option<u64>, texts: Texts) -> String {
format!("{} · {}", human_bytes(bytes), stamp(modified_ms, texts))
}
/// mtime(UTC 毫秒)→ `2024-05-01 10:00 UTC`;取不到就说"时间未知"(不拿 0 或当前时间糊)。
///
/// 换算走 `core::launch::time` 的 RFC3339 格式化,再按 `app/view/instances.rs::last_played`
/// 同一刀切开——**UTC 是 core 的口径,不做本地时区换算**(那属时间格式 mission)。
fn stamp(modified_ms: Option<u64>, texts: Texts) -> String {
let Some(text) = modified_ms.and_then(stamp_utc) else {
return texts.saves_time_unknown().to_string();
};
text
}
/// 毫秒 → `YYYY-MM-DD HH:MM UTC`(`None` = 早于 1970 / 溢出,不猜)。
fn stamp_utc(ms: u64) -> Option<String> {
let at = std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_millis(ms))?;
let text = unlucky_core::launch::time::format_rfc3339_millis(at);
// `2024-05-01T10:00:00.000Z` → `2024-05-01 10:00 UTC`(切两段,不解析、不重排)。
// 模板与 `app/view/instances.rs::last_played` **一字不差**:同一句话只能有一份写法,
// 页面源码的字面量白名单里也只有这一条(`instances/tests/i18n.rs` 的 `ALLOWED_LITERALS`)。
let date = text.get(0..10)?;
let time = text.get(11..16)?;
Some(format!("{date} {time} UTC"))
}
/// 画 SAVES 区(实例页唯一入口,`detail.rs` 调它)。
pub(super) fn section<'a>(
ui: &'a Ui,
saves: &'a Saves,
forced: Option<Interaction>,
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<Interaction>,
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),
)
}
@@ -163,6 +163,13 @@ pub enum Message {
/// 期望的启用状态 /// 期望的启用状态
enabled: bool, enabled: bool,
}, },
/// 重新列一次存档(M235):读盘失败时的「重试」,也用于用户手动刷新。
///
/// 只发一条 `SavesCommand::List`——没有别的状态要复位(失败不清绑定,见 presenter 的
/// `SavesState::failed`)。
ReloadSaves,
/// 打开存档文件夹(M235):拉起哪个目录由 host 决定(`saves/` → `screenshots/` → 运行目录)。
OpenSavesFolder,
} }
#[cfg(test)] #[cfg(test)]
@@ -7,6 +7,9 @@ mod i18n;
/// M157 的门限判据(三态 chips 在版本不够时谁可点)。 /// M157 的门限判据(三态 chips 在版本不够时谁可点)。
mod launch_args; mod launch_args;
/// M235 的存档区模型判据(快照 → 行 / 计数 / 失败线 / UTC 时间)。
mod saves;
use super::super::layout::source; use super::super::layout::source;
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass}; use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass};
@@ -29,14 +29,24 @@ use super::super::Texts;
/// - `Java`:运行时品牌名(`JAVA` 那格的值域里也全是 `Temurin 21` 这类品牌串),翻成「爪哇」是错的; /// - `Java`:运行时品牌名(`JAVA` 那格的值域里也全是 `Temurin 21` 这类品牌串),翻成「爪哇」是错的;
/// - `pid`:进程号的技术标识。用户拿它去任务管理器里搜进程,翻成「进程」就丢了这条可操作性。 /// - `pid`:进程号的技术标识。用户拿它去任务管理器里搜进程,翻成「进程」就丢了这条可操作性。
const ALLOWED: &[&str] = &[ const ALLOWED: &[&str] = &[
"Java", "pid", "JVM", "OpenGL", "Vulkan", "MiB", "Java",
"pid",
"JVM",
"OpenGL",
"Vulkan",
"MiB",
// M224:`mods` 是**目录名**(文件系统事实)。中文界面上写成「模组目录」会让用户找不到那个 // M224:`mods` 是**目录名**(文件系统事实)。中文界面上写成「模组目录」会让用户找不到那个
// 文件夹——`mods/` 就是他要打开的那个东西,所以两套语言都原样写它。 // 文件夹——`mods/` 就是他要打开的那个东西,所以两套语言都原样写它。
"mods", "mods",
// M235:`saves` / `screenshots` 同样是**目录名**(文件系统事实,玩家要照着去找)。
// 「正在读取 saves/…」「还没有 saves/ 或 screenshots/ 目录」里的这两个词不许翻——
// 翻成「存档目录」用户就对不上那两层文件夹了。
"saves",
"screenshots",
]; ];
/// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。 /// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。
const PAGE_SOURCES: [(&str, &str); 7] = [ const PAGE_SOURCES: [(&str, &str); 8] = [
("instances.rs", include_str!("../../instances.rs")), ("instances.rs", include_str!("../../instances.rs")),
("instances/detail.rs", include_str!("../detail.rs")), ("instances/detail.rs", include_str!("../detail.rs")),
("instances/demo.rs", include_str!("../demo.rs")), ("instances/demo.rs", include_str!("../demo.rs")),
@@ -51,6 +61,8 @@ const PAGE_SOURCES: [(&str, &str); 7] = [
), ),
// M224:MODS 区(本地 mod 列表 + 启停开关) // M224:MODS 区(本地 mod 列表 + 启停开关)
("instances/mods.rs", include_str!("../mods.rs")), ("instances/mods.rs", include_str!("../mods.rs")),
// M235:SAVES 区(存档 / 截图的只读列表 + 打开文件夹)
("instances/saves.rs", include_str!("../saves.rs")),
]; ];
/// 页面五件套里**允许出现的字符串字面量**(凡是带 ASCII 单词的都必须在这张表里)。 /// 页面五件套里**允许出现的字符串字面量**(凡是带 ASCII 单词的都必须在这张表里)。
@@ -68,7 +80,7 @@ const PAGE_SOURCES: [(&str, &str); 7] = [
/// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 | /// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 |
/// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) | /// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) |
/// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) | /// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) |
const ALLOWED_LITERALS: [&str; 28] = [ const ALLOWED_LITERALS: [&str; 30] = [
"{date} {time} UTC", "{date} {time} UTC",
"isolated", "isolated",
"shared", "shared",
@@ -110,6 +122,9 @@ const ALLOWED_LITERALS: [&str; 28] = [
"disabled", "disabled",
"count", "count",
"file", "file",
// M235:存档区计数那条的**占位符名**(`{worlds}` / `{shots}`),同样不是文案。
"worlds",
"shots",
]; ];
/// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。 /// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。
@@ -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)
);
}
+77 -2
View File
@@ -26,7 +26,7 @@
//! `Row.status`(「阶段 · 百分比」是一整句),与元信息卡同一层——页面只画模型给的字符串。 //! `Row.status`(「阶段 · 百分比」是一整句),与元信息卡同一层——页面只画模型给的字符串。
use unlucky_i18n::{Catalog, Key}; use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::ModFailureKind; use unlucky_presenter::dto::{ModFailureKind, SavesFailureKind};
/// 实例页 + 详情列的文案(一个 `Catalog` 的薄包装,`Copy`)。 /// 实例页 + 详情列的文案(一个 `Catalog` 的薄包装,`Copy`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[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 { fn text(self, key: Key) -> &'static str {
self.0.text(key) self.0.text(key)
} }
/// 这一页的全部 key(守门测试的取样口:**从 `Key::ALL` 过滤出来**,不是手抄一份清单)。 /// 这一页的全部 key(守门测试的取样口:**从 `Key::ALL` 过滤出来**,不是手抄一份清单)。
/// ///
/// 域判据是前缀 `Instances`——本族的 key 名是契约(`catalog/instances.rs` 里一条一条写着), /// 域判据是前缀 `Instances`——本族的 key 名是契约(`catalog/instances.rs` 里一条一条写着),
+4 -1
View File
@@ -128,7 +128,10 @@ pub(crate) mod settings;
mod tests; mod tests;
pub use download::{cancelled_toast, Category, Download, InstallDraft, Loader}; pub use download::{cancelled_toast, Category, Download, InstallDraft, Loader};
pub(crate) use instances::demo_form as demo_launch_form; pub(crate) use instances::demo_form as demo_launch_form;
pub use instances::{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 layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome}; pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome};
pub use message::{Action, Message, UpdateAction}; pub use message::{Action, Message, UpdateAction};
+6
View File
@@ -145,6 +145,11 @@ pub struct Model {
/// 谁填:`app/view/instances.rs` 的 `mods_of`(presenter 的 `AppState::mods` → 展示字段)。 /// 谁填:`app/view/instances.rs` 的 `mods_of`(presenter 的 `AppState::mods` → 展示字段)。
/// 空态(不在详情页 / 还没请求)是 `Mods::default()`,页面不画这一区。 /// 空态(不在详情页 / 还没请求)是 `Mods::default()`,页面不画这一区。
pub mods: super::instances::Mods, 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` 时页面里的控件按该态画。 /// 取证用的强制交互态(`--force-state`):`Some` 时页面里的控件按该态画。
/// ///
/// M90 r2(P3-1)之前这个开关只作用到 `shell` 回退路径,产品页是**静默失效**的; /// M90 r2(P3-1)之前这个开关只作用到 `shell` 回退路径,产品页是**静默失效**的;
@@ -175,6 +180,7 @@ impl Model {
// 见 `pages/settings/download.rs` 的「没有快照就画演示行」)。 // 见 `pages/settings/download.rs` 的「没有快照就画演示行」)。
settings: Vec::new(), settings: Vec::new(),
mods: super::instances::Mods::default(), mods: super::instances::Mods::default(),
saves: super::instances::Saves::default(),
lang: Lang::En, lang: Lang::En,
forced: None, forced: None,
} }
+9
View File
@@ -28,6 +28,10 @@
//! (`profiles.json` + 实例设置),不搬文件、不写游戏根。缺省运行目录落 ULL 数据区, //! (`profiles.json` + 实例设置),不搬文件、不写游戏根。缺省运行目录落 ULL 数据区,
//! 共用/写进对方的 `.minecraft` 必须显式选择(见 [`adopt::AdoptTarget`])。 //! 共用/写进对方的 `.minecraft` 必须显式选择(见 [`adopt::AdoptTarget`])。
//! //!
//! 存档与截图(M235,[`saves`]):**只读**列 `<run_dir>/saves`(世界目录)与
//! `<run_dir>/screenshots`(图片),不建目录、不写盘;「打开文件夹」走
//! [`crate::platform::opener::open_dir`](平台差异不在这里)。缩略图墙与导入不在这一版(塔裁决 U3)。
//!
//! 模块分工按"数据形状 / 纯计算 / 状态"切开,任何模块都不碰网络与 UI: //! 模块分工按"数据形状 / 纯计算 / 状态"切开,任何模块都不碰网络与 UI:
//! 下载归 `core::download`、进程归 `core::launch`、配置落盘归 `core::setting`。 //! 下载归 `core::download`、进程归 `core::launch`、配置落盘归 `core::setting`。
@@ -45,6 +49,7 @@ pub mod library;
pub mod loader; pub mod loader;
pub mod manifest; pub mod manifest;
pub mod profiles; pub mod profiles;
pub mod saves;
pub mod snapshot; pub mod snapshot;
pub mod unlisted; pub mod unlisted;
pub mod version; pub mod version;
@@ -75,6 +80,10 @@ pub use library::{Library, MavenCoordinate};
pub use loader::{InstanceLoader, LoaderKind}; pub use loader::{InstanceLoader, LoaderKind};
pub use manifest::{AssetIndexRef, DownloadInfo, Downloads, JavaVersion, Logging, VersionJson}; pub use manifest::{AssetIndexRef, DownloadInfo, Downloads, JavaVersion, Logging, VersionJson};
pub use profiles::{InstanceProfile, ProfileId, ProfileList, PROFILE_SCHEMA_VERSION}; 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 snapshot::{Draft, Snapshot, Store};
pub use version::{ pub use version::{
LatestVersions, ReleaseType, RemoteVersion, VersionCategory, VersionList, LatestVersions, ReleaseType, RemoteVersion, VersionCategory, VersionList,
+38
View File
@@ -0,0 +1,38 @@
//! 「目录存在但不可用」的失败形状(带 OS 原文)。
//!
//! 只有**一层**:`<run_dir>/saves` 或 `<run_dir>/screenshots` 本身不可用。项目级的问题
//! (某个世界目录读不出元数据、某个文件不是图片)不是错误,逐条进 `skipped`——列表宁可短,
//! 也不能因为一个坏文件整份读不出来。
//!
//! 为什么不折叠成 `io::Error`:调用方要分得清「这个实例没有存档目录」与「有但读不了」,
//! 前者是空列表加一句说明,后者是带重试按钮的红字。`dir` 字段是这两者的唯一区分依据。
use std::fmt;
use std::path::PathBuf;
/// 一次列举失败。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SavesError {
/// 出问题的那个目录(`<run_dir>/saves` 或 `<run_dir>/screenshots`)。
pub dir: PathBuf,
/// 人读原因(OS 原文或"是符号链接"这类判据)。
pub detail: String,
}
impl SavesError {
/// 建一条(内部用;`dir` 一律是**被列的那个目录**,不是它的父目录)。
pub(super) fn unusable(dir: &std::path::Path, detail: impl Into<String>) -> 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 {}
+296
View File
@@ -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<u64>,
}
/// `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<u64>,
}
/// 被跳过的条目来自哪个目录。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Area {
/// `<run_dir>/saves/`
Saves,
/// `<run_dir>/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<WorldEntry>,
/// 被列的 `screenshots/` 目录。
pub screenshots_dir: PathBuf,
/// `screenshots/` 存不存在。
pub screenshots_exists: bool,
/// 截图列表(**新的在前**:文件名就是时间戳,倒序即时间倒序)。
pub screenshots: Vec<ScreenshotEntry>,
/// 跳过的条目与原因(两个目录并在一份里,靠 [`SavesSkip::area`] 区分)。
pub skipped: Vec<SavesSkip>,
}
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<SavesScan, SavesError> {
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<std::fs::DirEntry>), 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::<Result<Vec<_>, _>>()
.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<u64> {
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,
}
}
+57
View File
@@ -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`] 的结果),
//! 本模块只往下取两层:`<run_dir>/saves/` 与 `<run_dir>/screenshots/`。
//! 不自己去猜游戏根,也不做「隔离侧 + 共享侧合并」——派生是启动、mod 列表、存档列表**同一个**
//! 答案的唯一来源。与 [`crate::addon::mods`] 同一条纪律。
//!
//! # 目录不存在不是错误
//!
//! 新实例两个目录都不存在,这**不是**异常(`exists = false` + 空列表)。只有「存在但不可用」
//! (符号链接 / 不是目录 / 读不了)才是 [`SavesError`];**并且照样不建目录**。
//!
//! # 收纳口径
//!
//! | 位置 | 收什么 | 不收什么(逐条登记原因,不静默丢) |
//! |---|---|---|
//! | `saves/` | 子目录 = 一个世界 | 符号链接(不跟随)、非目录(散落文件)、名字过不了路径规则、元数据读不出 |
//! | `screenshots/` | 扩展名是 [`IMAGE_EXTENSIONS`] 的普通文件 | 同上 + 不是图片的文件(`NotAnImage`) |
//!
//! 「不静默丢」是 M224 定下的口径:列表要能和磁盘对得上账,用户看到「跳过 3 项」时得知道
//! 那 3 项不是我们忘了。
//!
//! # 模块
//!
//! | 文件 | 职责 |
//! |---|---|
//! | [`error`] | 「目录存在但不可用」的失败形状(带 OS 原文) |
//! | [`list`] | 列 `<run_dir>/saves` 与 `<run_dir>/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;
+260
View File
@@ -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;
/// 建一个运行目录(`<tmp>/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<String> {
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(read) = std::fs::read_dir(&dir) else {
continue;
};
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)
}
+3
View File
@@ -27,6 +27,8 @@
//! - [`child`]:子进程的唯一构造器 [`child::quiet`](M213)。Windows 上给子进程带 //! - [`child`]:子进程的唯一构造器 [`child::quiet`](M213)。Windows 上给子进程带
//! `CREATE_NO_WINDOW`,否则 GUI 父进程 spawn 控制台程序会给子进程新建控制台——桌面上 //! `CREATE_NO_WINDOW`,否则 GUI 父进程 spawn 控制台程序会给子进程新建控制台——桌面上
//! 就是「黑框闪一下」。**别在别处直接构造 `Command`**,`tests/child_quiet.rs` 守门。 //! 就是「黑框闪一下」。**别在别处直接构造 `Command`**,`tests/child_quiet.rs` 守门。
//! - [`opener`]:交出去的两件事——[`opener::open_url`](授权码流)与 [`opener::open_dir`]
//! (实例详情页的「打开文件夹」,M235)。平台分支只在这里出现一次,业务模块一律调它。
//! //!
//! 尚未落地(后续 mission):文件锁、进程工具、真实 GL/Vulkan 能力探测(钩子见 //! 尚未落地(后续 mission):文件锁、进程工具、真实 GL/Vulkan 能力探测(钩子见
//! [`linux_caps::probe_gl`] 与 [`vk_probe::probe_vk`],FFI 受限,实际探测归 app 层渲染后端选择)。 //! [`linux_caps::probe_gl`] 与 [`vk_probe::probe_vk`],FFI 受限,实际探测归 app 层渲染后端选择)。
@@ -40,6 +42,7 @@ pub mod hash;
pub mod layout; pub mod layout;
pub mod linux_caps; pub mod linux_caps;
pub mod macos_version; pub mod macos_version;
pub mod opener;
pub mod os_info; pub mod os_info;
pub mod paths; pub mod paths;
pub mod receipt; pub mod receipt;
+220
View File
@@ -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<std::ffi::OsString>) -> 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<std::ffi::OsString>) {
// `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<std::ffi::OsString>) {
("open", vec![url.into()])
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
fn url_launcher(url: &str) -> (&'static str, Vec<std::ffi::OsString>) {
("xdg-open", vec![url.into()])
}
/// 打开目录的程序与参数。
#[cfg(target_os = "windows")]
fn dir_launcher(dir: &Path) -> (&'static str, Vec<std::ffi::OsString>) {
// `explorer` 吃的是**一个**路径参数;别拼引号(它不是 shell,引号会变成名字的一部分)。
("explorer", vec![dir.as_os_str().to_os_string()])
}
#[cfg(target_os = "macos")]
fn dir_launcher(dir: &Path) -> (&'static str, Vec<std::ffi::OsString>) {
("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<std::ffi::OsString>) {
("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(", ")
);
}
}
+20
View File
@@ -105,6 +105,26 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("InstancesModsReasonNotFile", "that is not a regular file", "那不是普通文件"), ("InstancesModsReasonNotFile", "that is not a regular file", "那不是普通文件"),
("InstancesModsReasonCollision", "a file with that name already exists", "同名文件已存在"), ("InstancesModsReasonCollision", "a file with that name already exists", "同名文件已存在"),
("InstancesModsReasonRename", "the rename failed", "改名失败"), ("InstancesModsReasonRename", "the rename failed", "改名失败"),
("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`)。 /// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。
+24
View File
@@ -154,6 +154,30 @@ macro_rules! instances_entries {
InstancesModsReasonNotFile => ("that is not a regular file", "那不是普通文件"), InstancesModsReasonNotFile => ("that is not a regular file", "那不是普通文件"),
InstancesModsReasonCollision => ("a file with that name already exists", "同名文件已存在"), InstancesModsReasonCollision => ("a file with that name already exists", "同名文件已存在"),
InstancesModsReasonRename => ("the rename failed", "改名失败"), InstancesModsReasonRename => ("the rename failed", "改名失败"),
// ── 存档区(M235 · STOREMVP):**只读**列 `saves/` 与 `screenshots/` + 打开文件夹。
// 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", "文件管理器没起来"),
} } } }
}; };
} }
+9
View File
@@ -39,6 +39,9 @@ use crate::dto::{
NoticeView, NoticeView,
PackCommand, PackCommand,
PackEvent, PackEvent,
SavesCommand,
SavesFailureView,
SavesListView,
SessionId, SessionId,
SettingGroupView, SettingGroupView,
// M159: 自更新 —— 一条快照整体替换 // M159: 自更新 —— 一条快照整体替换
@@ -168,6 +171,10 @@ pub enum CoreEvent {
Update(Box<UpdateView>), Update(Box<UpdateView>),
/// 本地 Mod 列表 / 启停(M224):`Ok` = 整份快照;`Err` = 具名原因 + 原文(文案由 app 现求)。 /// 本地 Mod 列表 / 启停(M224):`Ok` = 整份快照;`Err` = 具名原因 + 原文(文案由 app 现求)。
Mods(Box<Result<ModListView, ModFailureView>>), Mods(Box<Result<ModListView, ModFailureView>>),
/// 实例存档 / 截图的只读列表(M235):`Ok` = 整份快照;`Err` = 具名原因 + 原文。
///
/// 与 [`Self::Mods`] 分开:这一族**没有任何写路径**(U3 只做只读列表 + 打开文件夹)。
Saves(Box<Result<SavesListView, SavesFailureView>>),
} }
/// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路, /// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路,
@@ -299,6 +306,8 @@ pub enum AppCommand {
SetUpdateAutoCheck(bool), SetUpdateAutoCheck(bool),
/// 本地 Mod 列表 / 启停(M224;子枚举与语义见 [`ModsCommand`] 的文档)。 /// 本地 Mod 列表 / 启停(M224;子枚举与语义见 [`ModsCommand`] 的文档)。
Mods(Box<ModsCommand>), Mods(Box<ModsCommand>),
/// 实例存档 / 截图的只读列表(M235;子枚举与语义见 [`SavesCommand`] 的文档)。
Saves(Box<SavesCommand>),
} }
/// 后端通道:core/agent host 实现它;[`crate::mock::MockBackend`] 验证通道语义。 /// 后端通道:core/agent host 实现它;[`crate::mock::MockBackend`] 验证通道语义。
+7
View File
@@ -32,6 +32,8 @@ mod notices;
mod pack; mod pack;
// M224: 本地 Mod 列表与启停(实例详情页的 MODS 区) // M224: 本地 Mod 列表与启停(实例详情页的 MODS 区)
mod mods; mod mods;
// M235: 实例存档 / 截图的只读列表(详情页的「存档」区)
mod saves;
mod settings; mod settings;
// M159: 自更新(检查结论 / 候选 / 就位状态) // M159: 自更新(检查结论 / 候选 / 就位状态)
mod update; mod update;
@@ -84,6 +86,11 @@ pub use pack::{
pub use settings::{ pub use settings::{
find_row, SettingControl, SettingGroupView, SettingRowView, JAVA_REFRESH_ACTION, find_row, SettingControl, SettingGroupView, SettingRowView, JAVA_REFRESH_ACTION,
}; };
// M235: 实例存档 / 截图的只读列表
pub use saves::{
SavesArea, SavesCommand, SavesFailureKind, SavesFailureView, SavesListView, SavesSkipKind,
SavesSkipView, ScreenshotView, WorldView,
};
// M159: 自更新 // M159: 自更新
pub use update::{ pub use update::{
ReadyUpdate, UpdateBanner, UpdateCandidateView, UpdateFailureView, UpdateOutcome, UpdateView, ReadyUpdate, UpdateBanner, UpdateCandidateView, UpdateFailureView, UpdateOutcome, UpdateView,
+254
View File
@@ -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<u64>` 的毫秒
//!
//! 取不到(早于 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<u64>,
}
/// 一张截图(**只给元数据**,不给像素:解码图片要引依赖,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<u64>,
}
/// 被跳过的条目来自哪个目录。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavesArea {
/// `<run_dir>/saves/`
Saves,
/// `<run_dir>/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<WorldView>,
/// 被列的 `screenshots/` 目录(绝对路径)。
pub screenshots_dir: String,
/// `screenshots/` 存不存在。
pub screenshots_exists: bool,
/// 截图列表(**新的在前**)。
pub screenshots: Vec<ScreenshotView>,
/// 跳过的条目(UI 只需计数 + 一句"为什么")。
pub skipped: Vec<SavesSkipView>,
}
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::<SavesListView>(&text).expect("反序列化"),
view
);
}
/// 两条指令都要能过 IPC(含「打开文件夹」——它的载荷只有实例 id)。
#[test]
fn commands_round_trip() {
for command in [
SavesCommand::List("inst".to_string()),
SavesCommand::OpenFolder {
id: "inst".to_string(),
},
] {
let text = serde_json::to_string(&command).expect("序列化");
assert_eq!(
serde_json::from_str::<SavesCommand>(&text).expect("反序列化"),
command
);
}
}
/// 两个目录都不存在 ⇒ 空(`is_empty` 只看内容,不看 `exists`:一个目录有空世界也算非空)。
#[test]
fn empty_means_no_worlds_and_no_screenshots() {
let mut view = view();
view.worlds.clear();
view.screenshots.clear();
assert!(view.is_empty(), "没有世界也没有截图就该算空");
view.screenshots.push(ScreenshotView {
file: "a.png".to_string(),
bytes: 0,
modified_ms: None,
});
assert!(!view.is_empty());
}
}
+7 -1
View File
@@ -9,7 +9,8 @@ use crate::toast::ToastKind;
use super::accounts::{self, LoginJob}; use super::accounts::{self, LoginJob};
use super::{ 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 { impl CoreHost {
@@ -289,6 +290,11 @@ impl CoreHost {
let running = self.in_flight(&id); let running = self.in_flight(&id);
mods::dispatch(self.env.clone(), self.tx.clone(), *command, running); mods::dispatch(self.env.clone(), self.tx.clone(), *command, running);
} }
// M235:实例存档 / 截图的只读列表 + 打开文件夹
// (只读,所以不必问"游戏在不在跑";拉起文件管理器在 core 的 platform opener 里)
AppCommand::Saves(command) => {
saves::dispatch(self.env.clone(), self.tx.clone(), *command);
}
// AI 对话:agent 的 GUI host 未落地前用脚本回复(不接真实模型) // AI 对话:agent 的 GUI host 未落地前用脚本回复(不接真实模型)
other @ (AppCommand::AskAgent(_) | AppCommand::StopAgent | AppCommand::SetYolo(_)) => { other @ (AppCommand::AskAgent(_) | AppCommand::StopAgent | AppCommand::SetYolo(_)) => {
self.chat.dispatch(other); self.chat.dispatch(other);
+6
View File
@@ -233,6 +233,12 @@ impl CoreHost {
} }
self.send(CoreEvent::Mods(Box::new(list))); 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)), HostTask::Toast(toast) => self.events.push_back(AppEvent::Toast(*toast)),
} }
} }
+2
View File
@@ -155,6 +155,8 @@ pub enum HostTask {
Update(Box<crate::host::update::Report>), Update(Box<crate::host::update::Report>),
/// 本地 Mod 列表 / 启停(M224):列表快照或具名失败(见 `host::mods::Report`) /// 本地 Mod 列表 / 启停(M224):列表快照或具名失败(见 `host::mods::Report`)
Mods(Box<super::mods::Report>), Mods(Box<super::mods::Report>),
/// 实例存档 / 截图的只读列表(M235):快照或具名失败(见 `host::saves::Report`)
Saves(Box<super::saves::Report>),
} }
/// 后台线程用的发送端(`Sender` 克隆即可)。 /// 后台线程用的发送端(`Sender` 克隆即可)。
+1
View File
@@ -34,6 +34,7 @@ mod logs;
mod meter; mod meter;
mod mods; mod mods;
mod opener; mod opener;
mod saves;
mod settings; mod settings;
mod update; mod update;
mod update_state; mod update_state;
+212
View File
@@ -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<SavesListView, SavesFailureView>,
}
/// 后台列一个实例的 `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<SavesListView, SavesFailureView> {
let (instance, run_dir) = target(env, id)?;
let scan = saves::scan(&run_dir).map_err(|error| unusable(&instance.id, &error))?;
Ok(view(&instance, &scan))
}
/// 打开存档目录 → 再列一遍(失败只说"打不开",原文进括号)。
fn open_folder(env: &HostEnv, id: &str) -> Result<SavesListView, SavesFailureView> {
let (instance, run_dir) = target(env, id)?;
let scan = saves::scan(&run_dir).map_err(|error| unusable(&instance.id, &error))?;
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<PathBuf, String> {
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<String>) -> 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;
+199
View File
@@ -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/<id>/` 就能验「派生出来的目录就是读的那个」。
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);
}
}
+8
View File
@@ -47,10 +47,13 @@ mod agent;
mod live; mod live;
// M224: 本地 Mod 列表与启停的视图态(状态落地 + 在飞标记) // M224: 本地 Mod 列表与启停的视图态(状态落地 + 在飞标记)
mod mods; mod mods;
// M235: 实例存档 / 截图的只读快照(状态落地 + 迟到丢弃)
mod saves;
mod tiles; mod tiles;
pub use live::MAX_LIVE_LINES; pub use live::MAX_LIVE_LINES;
pub use mods::ModsState; pub use mods::ModsState;
pub use saves::SavesState;
pub use tiles::{grid_positions, TileCell}; pub use tiles::{grid_positions, TileCell};
/// 一份加载器版本清单查询的结果(`Ok` = 最新在前;`Err` = 人读原因)。 /// 一份加载器版本清单查询的结果(`Ok` = 最新在前;`Err` = 人读原因)。
@@ -141,6 +144,8 @@ pub struct AppState {
pub adopt: AdoptState, pub adopt: AdoptState,
/// 本地 Mod(M224):当前实例的快照 / 失败 / 在飞启停 /// 本地 Mod(M224):当前实例的快照 / 失败 / 在飞启停
pub mods: ModsState, pub mods: ModsState,
/// 实例存档 / 截图(M235):当前实例的只读快照 / 失败(可重试)
pub saves: SavesState,
} }
impl Default for AppState { impl Default for AppState {
@@ -188,6 +193,7 @@ impl AppState {
pack: PackState::default(), pack: PackState::default(),
adopt: AdoptState::default(), adopt: AdoptState::default(),
mods: ModsState::default(), mods: ModsState::default(),
saves: SavesState::default(),
} }
} }
@@ -316,6 +322,8 @@ impl AppState {
} }
// M224:本地 Mod(落地判据在 `state/mods.rs`,含迟到丢弃与在飞标记) // M224:本地 Mod(落地判据在 `state/mods.rs`,含迟到丢弃与在飞标记)
CoreEvent::Mods(result) => self.mods.apply(*result), CoreEvent::Mods(result) => self.mods.apply(*result),
// M235:实例存档 / 截图(落地判据在 `state/saves.rs`,含迟到丢弃)
CoreEvent::Saves(result) => self.saves.apply(*result),
} }
} }
+155
View File
@@ -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<String>,
/// 最近一份快照(`None` = 还没请求过)。
pub view: Option<SavesListView>,
/// 最近一条失败(具名原因;成功一次就清掉)。
pub failed: Option<SavesFailureView>,
}
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<SavesListView, SavesFailureView>) {
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());
}
}