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

+3
View File
@@ -27,6 +27,8 @@
//! - [`child`]:子进程的唯一构造器 [`child::quiet`](M213)。Windows 上给子进程带
//! `CREATE_NO_WINDOW`,否则 GUI 父进程 spawn 控制台程序会给子进程新建控制台——桌面上
//! 就是「黑框闪一下」。**别在别处直接构造 `Command`**,`tests/child_quiet.rs` 守门。
//! - [`opener`]:交出去的两件事——[`opener::open_url`](授权码流)与 [`opener::open_dir`]
//! (实例详情页的「打开文件夹」,M235)。平台分支只在这里出现一次,业务模块一律调它。
//!
//! 尚未落地(后续 mission):文件锁、进程工具、真实 GL/Vulkan 能力探测(钩子见
//! [`linux_caps::probe_gl`] 与 [`vk_probe::probe_vk`],FFI 受限,实际探测归 app 层渲染后端选择)。
@@ -40,6 +42,7 @@ pub mod hash;
pub mod layout;
pub mod linux_caps;
pub mod macos_version;
pub mod opener;
pub mod os_info;
pub mod paths;
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(", ")
);
}
}