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

+9
View File
@@ -39,6 +39,9 @@ use crate::dto::{
NoticeView,
PackCommand,
PackEvent,
SavesCommand,
SavesFailureView,
SavesListView,
SessionId,
SettingGroupView,
// M159: 自更新 —— 一条快照整体替换
@@ -168,6 +171,10 @@ pub enum CoreEvent {
Update(Box<UpdateView>),
/// 本地 Mod 列表 / 启停(M224):`Ok` = 整份快照;`Err` = 具名原因 + 原文(文案由 app 现求)。
Mods(Box<Result<ModListView, ModFailureView>>),
/// 实例存档 / 截图的只读列表(M235):`Ok` = 整份快照;`Err` = 具名原因 + 原文。
///
/// 与 [`Self::Mods`] 分开:这一族**没有任何写路径**(U3 只做只读列表 + 打开文件夹)。
Saves(Box<Result<SavesListView, SavesFailureView>>),
}
/// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路,
@@ -299,6 +306,8 @@ pub enum AppCommand {
SetUpdateAutoCheck(bool),
/// 本地 Mod 列表 / 启停(M224;子枚举与语义见 [`ModsCommand`] 的文档)。
Mods(Box<ModsCommand>),
/// 实例存档 / 截图的只读列表(M235;子枚举与语义见 [`SavesCommand`] 的文档)。
Saves(Box<SavesCommand>),
}
/// 后端通道:core/agent host 实现它;[`crate::mock::MockBackend`] 验证通道语义。
+7
View File
@@ -32,6 +32,8 @@ mod notices;
mod pack;
// M224: 本地 Mod 列表与启停(实例详情页的 MODS 区)
mod mods;
// M235: 实例存档 / 截图的只读列表(详情页的「存档」区)
mod saves;
mod settings;
// M159: 自更新(检查结论 / 候选 / 就位状态)
mod update;
@@ -84,6 +86,11 @@ pub use pack::{
pub use settings::{
find_row, SettingControl, SettingGroupView, SettingRowView, JAVA_REFRESH_ACTION,
};
// M235: 实例存档 / 截图的只读列表
pub use saves::{
SavesArea, SavesCommand, SavesFailureKind, SavesFailureView, SavesListView, SavesSkipKind,
SavesSkipView, ScreenshotView, WorldView,
};
// M159: 自更新
pub use update::{
ReadyUpdate, UpdateBanner, UpdateCandidateView, UpdateFailureView, UpdateOutcome, UpdateView,
+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());
}
}