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:
1 parent
3789cb7e61
commit
f217d83680
36 files changed
+2702
-17
No files matched your search
@@ -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`] 验证通道语义。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user