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());
}
}
+7 -1
View File
@@ -9,7 +9,8 @@ use crate::toast::ToastKind;
use super::accounts::{self, LoginJob};
use super::{
addon, game, install, java, launch, loader_versions, logs, mods, settings, versions, CoreHost,
addon, game, install, java, launch, loader_versions, logs, mods, saves, settings, versions,
CoreHost,
};
impl CoreHost {
@@ -289,6 +290,11 @@ impl CoreHost {
let running = self.in_flight(&id);
mods::dispatch(self.env.clone(), self.tx.clone(), *command, running);
}
// M235:实例存档 / 截图的只读列表 + 打开文件夹
// (只读,所以不必问"游戏在不在跑";拉起文件管理器在 core 的 platform opener 里)
AppCommand::Saves(command) => {
saves::dispatch(self.env.clone(), self.tx.clone(), *command);
}
// AI 对话:agent 的 GUI host 未落地前用脚本回复(不接真实模型)
other @ (AppCommand::AskAgent(_) | AppCommand::StopAgent | AppCommand::SetYolo(_)) => {
self.chat.dispatch(other);
+6
View File
@@ -233,6 +233,12 @@ impl CoreHost {
}
self.send(CoreEvent::Mods(Box::new(list)));
}
// M235:实例存档 / 截图的只读列表。成功 ⇒ 整份快照;失败 ⇒ 具名原因给 app 求文案。
// 「打开文件夹」也是这条:拉起文件管理器之后再回一份新鲜快照(用户在那边删了世界,
// 切回来这一拍就跟上)。
HostTask::Saves(report) => {
self.send(CoreEvent::Saves(Box::new(report.list)));
}
HostTask::Toast(toast) => self.events.push_back(AppEvent::Toast(*toast)),
}
}
+2
View File
@@ -155,6 +155,8 @@ pub enum HostTask {
Update(Box<crate::host::update::Report>),
/// 本地 Mod 列表 / 启停(M224):列表快照或具名失败(见 `host::mods::Report`)
Mods(Box<super::mods::Report>),
/// 实例存档 / 截图的只读列表(M235):快照或具名失败(见 `host::saves::Report`)
Saves(Box<super::saves::Report>),
}
/// 后台线程用的发送端(`Sender` 克隆即可)。
+1
View File
@@ -34,6 +34,7 @@ mod logs;
mod meter;
mod mods;
mod opener;
mod saves;
mod settings;
mod update;
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;
// M224: 本地 Mod 列表与启停的视图态(状态落地 + 在飞标记)
mod mods;
// M235: 实例存档 / 截图的只读快照(状态落地 + 迟到丢弃)
mod saves;
mod tiles;
pub use live::MAX_LIVE_LINES;
pub use mods::ModsState;
pub use saves::SavesState;
pub use tiles::{grid_positions, TileCell};
/// 一份加载器版本清单查询的结果(`Ok` = 最新在前;`Err` = 人读原因)。
@@ -141,6 +144,8 @@ pub struct AppState {
pub adopt: AdoptState,
/// 本地 Mod(M224):当前实例的快照 / 失败 / 在飞启停
pub mods: ModsState,
/// 实例存档 / 截图(M235):当前实例的只读快照 / 失败(可重试)
pub saves: SavesState,
}
impl Default for AppState {
@@ -188,6 +193,7 @@ impl AppState {
pack: PackState::default(),
adopt: AdoptState::default(),
mods: ModsState::default(),
saves: SavesState::default(),
}
}
@@ -316,6 +322,8 @@ impl AppState {
}
// M224:本地 Mod(落地判据在 `state/mods.rs`,含迟到丢弃与在飞标记)
CoreEvent::Mods(result) => self.mods.apply(*result),
// M235:实例存档 / 截图(落地判据在 `state/saves.rs`,含迟到丢弃)
CoreEvent::Saves(result) => self.saves.apply(*result),
}
}
+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());
}
}