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
@@ -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;
|
||||
Reference in new issue
Block a user