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
@@ -28,6 +28,10 @@
|
||||
//! (`profiles.json` + 实例设置),不搬文件、不写游戏根。缺省运行目录落 ULL 数据区,
|
||||
//! 共用/写进对方的 `.minecraft` 必须显式选择(见 [`adopt::AdoptTarget`])。
|
||||
//!
|
||||
//! 存档与截图(M235,[`saves`]):**只读**列 `<run_dir>/saves`(世界目录)与
|
||||
//! `<run_dir>/screenshots`(图片),不建目录、不写盘;「打开文件夹」走
|
||||
//! [`crate::platform::opener::open_dir`](平台差异不在这里)。缩略图墙与导入不在这一版(塔裁决 U3)。
|
||||
//!
|
||||
//! 模块分工按"数据形状 / 纯计算 / 状态"切开,任何模块都不碰网络与 UI:
|
||||
//! 下载归 `core::download`、进程归 `core::launch`、配置落盘归 `core::setting`。
|
||||
|
||||
@@ -45,6 +49,7 @@ pub mod library;
|
||||
pub mod loader;
|
||||
pub mod manifest;
|
||||
pub mod profiles;
|
||||
pub mod saves;
|
||||
pub mod snapshot;
|
||||
pub mod unlisted;
|
||||
pub mod version;
|
||||
@@ -75,6 +80,10 @@ pub use library::{Library, MavenCoordinate};
|
||||
pub use loader::{InstanceLoader, LoaderKind};
|
||||
pub use manifest::{AssetIndexRef, DownloadInfo, Downloads, JavaVersion, Logging, VersionJson};
|
||||
pub use profiles::{InstanceProfile, ProfileId, ProfileList, PROFILE_SCHEMA_VERSION};
|
||||
pub use saves::{
|
||||
Area, SavesError, SavesScan, SavesSkip, ScreenshotEntry, SkipKind, WorldEntry,
|
||||
IMAGE_EXTENSIONS, SAVES_DIR, SCREENSHOTS_DIR, WORLD_ICON,
|
||||
};
|
||||
pub use snapshot::{Draft, Snapshot, Store};
|
||||
pub use version::{
|
||||
LatestVersions, ReleaseType, RemoteVersion, VersionCategory, VersionList,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
//! 「目录存在但不可用」的失败形状(带 OS 原文)。
|
||||
//!
|
||||
//! 只有**一层**:`<run_dir>/saves` 或 `<run_dir>/screenshots` 本身不可用。项目级的问题
|
||||
//! (某个世界目录读不出元数据、某个文件不是图片)不是错误,逐条进 `skipped`——列表宁可短,
|
||||
//! 也不能因为一个坏文件整份读不出来。
|
||||
//!
|
||||
//! 为什么不折叠成 `io::Error`:调用方要分得清「这个实例没有存档目录」与「有但读不了」,
|
||||
//! 前者是空列表加一句说明,后者是带重试按钮的红字。`dir` 字段是这两者的唯一区分依据。
|
||||
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 一次列举失败。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SavesError {
|
||||
/// 出问题的那个目录(`<run_dir>/saves` 或 `<run_dir>/screenshots`)。
|
||||
pub dir: PathBuf,
|
||||
/// 人读原因(OS 原文或"是符号链接"这类判据)。
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl SavesError {
|
||||
/// 建一条(内部用;`dir` 一律是**被列的那个目录**,不是它的父目录)。
|
||||
pub(super) fn unusable(dir: &std::path::Path, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
dir: dir.to_path_buf(),
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SavesError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "目录不可用({}):{}", self.dir.display(), self.detail)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SavesError {}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! 列一个运行目录下的 `saves/`(世界)与 `screenshots/`(图片),**纯只读**。
|
||||
//!
|
||||
//! 只碰三种系统调用:`symlink_metadata`(判目录形态)、`read_dir`(取子项)、`metadata`
|
||||
//! (取大小/时间)。没有 `create_dir`、没有 `write`、没有 `rename`、没有 `remove`——`saves/`
|
||||
//! 不存在时也**不替玩家建**(建出来的空目录会让「这实例还没玩过」这件事再也看不出来)。
|
||||
//!
|
||||
//! 回看「调用前后版本树不变」的判据见 `tests::scan_does_not_touch_the_disk`:
|
||||
//! 它在调用前后各拍一次 `(相对路径, 是否目录, 字节数, mtime)` 的指纹并逐条比对。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use super::error::SavesError;
|
||||
|
||||
/// `saves` 目录名(游戏与社区一致的口径)。
|
||||
pub const SAVES_DIR: &str = "saves";
|
||||
|
||||
/// `screenshots` 目录名(原版游戏把 F2 截图放这里)。
|
||||
pub const SCREENSHOTS_DIR: &str = "screenshots";
|
||||
|
||||
/// 世界图标文件名(**只判存在**,不解码:U3 不做缩略图墙)。
|
||||
pub const WORLD_ICON: &str = "icon.png";
|
||||
|
||||
/// 认作截图的扩展名(大小写不敏感)。
|
||||
///
|
||||
/// 原版游戏只写 `.png`;其余几个是玩家手动塞进去/其它 mod 的常见形态。**不收** `.gif` /
|
||||
/// 视频——它们是"别的东西",与"我们忘了列"要区分开(见 [`SkipKind::NotAnImage`])。
|
||||
pub const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "webp"];
|
||||
|
||||
/// 某个运行目录下的 `saves/`(纯路径拼接,不建、不检查)。
|
||||
#[must_use]
|
||||
pub fn saves_dir(run_dir: &Path) -> PathBuf {
|
||||
run_dir.join(SAVES_DIR)
|
||||
}
|
||||
|
||||
/// 某个运行目录下的 `screenshots/`(纯路径拼接,不建、不检查)。
|
||||
#[must_use]
|
||||
pub fn screenshots_dir(run_dir: &Path) -> PathBuf {
|
||||
run_dir.join(SCREENSHOTS_DIR)
|
||||
}
|
||||
|
||||
/// `saves/` 下的一个世界目录。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorldEntry {
|
||||
/// 磁盘上的目录名(原样)。**这一版它就是展示名**:`LevelName` 在 `level.dat` 里,
|
||||
/// 解析 NBT 不在 U3 范围内(做缩略图墙时一起做)。
|
||||
pub dir: String,
|
||||
/// 有没有 `icon.png`(只判存在,不读内容)。
|
||||
pub icon: bool,
|
||||
/// 目录 mtime(毫秒 since UNIX_EPOCH;取不到为 `None`)。
|
||||
pub modified_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// `screenshots/` 下的一张图。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ScreenshotEntry {
|
||||
/// 磁盘文件名(原样,含扩展名)。
|
||||
pub file: String,
|
||||
/// 字节数(UI 自己格式化,不在这里拼 `1.2 MiB`)。
|
||||
pub bytes: u64,
|
||||
/// 文件 mtime(毫秒 since UNIX_EPOCH;取不到为 `None`)。
|
||||
pub modified_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// 被跳过的条目来自哪个目录。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Area {
|
||||
/// `<run_dir>/saves/`
|
||||
Saves,
|
||||
/// `<run_dir>/screenshots/`
|
||||
Screenshots,
|
||||
}
|
||||
|
||||
/// 没收进列表的原因。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SkipKind {
|
||||
/// 符号链接(不跟随:跟随会把实例外的东西算成存档)。
|
||||
SymbolicLink,
|
||||
/// `saves/` 下的非目录(散落文件),或 `screenshots/` 下的目录。
|
||||
NotADirectoryExpected,
|
||||
/// `screenshots/` 下的非普通文件(设备 / 管道 / 目录)。
|
||||
NotAFile,
|
||||
/// 名字过不了路径规则(含 `\`、`:`、`..` 之类;判据复用整合包侧那一份)。
|
||||
UnsafeName,
|
||||
/// 元数据读不出来。
|
||||
Unreadable,
|
||||
/// `screenshots/` 下的普通文件,但扩展名不是 [`IMAGE_EXTENSIONS`]。
|
||||
NotAnImage,
|
||||
}
|
||||
|
||||
/// 一个没能进列表的条目。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SavesSkip {
|
||||
/// 来自哪个目录。
|
||||
pub area: Area,
|
||||
/// 磁盘名字(原样;读不出来时给能拿到的那个串)。
|
||||
pub file: String,
|
||||
/// 为什么没收。
|
||||
pub reason: SkipKind,
|
||||
}
|
||||
|
||||
/// 一次列举的结果(两个目录一次读回,UI 只收这一份快照)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SavesScan {
|
||||
/// 被列的 `saves/` 目录。
|
||||
pub dir: PathBuf,
|
||||
/// `saves/` 存不存在(`false` = 这实例还没存过档,不是错误)。
|
||||
pub exists: bool,
|
||||
/// 世界列表(按名字排序,大小写不敏感)。
|
||||
pub worlds: Vec<WorldEntry>,
|
||||
/// 被列的 `screenshots/` 目录。
|
||||
pub screenshots_dir: PathBuf,
|
||||
/// `screenshots/` 存不存在。
|
||||
pub screenshots_exists: bool,
|
||||
/// 截图列表(**新的在前**:文件名就是时间戳,倒序即时间倒序)。
|
||||
pub screenshots: Vec<ScreenshotEntry>,
|
||||
/// 跳过的条目与原因(两个目录并在一份里,靠 [`SavesSkip::area`] 区分)。
|
||||
pub skipped: Vec<SavesSkip>,
|
||||
}
|
||||
|
||||
impl SavesScan {
|
||||
/// `saves/` 与 `screenshots/` 一个都不存在(新实例的常态)。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.worlds.is_empty() && self.screenshots.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 列一个运行目录下的 `saves/` 与 `screenshots/`。
|
||||
///
|
||||
/// 目录不存在 → `exists = false` 的空结果(新实例不是错误),**且不建那个目录**。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 目录存在但不可用(符号链接 / 不是目录 / 读不了)时给 [`SavesError`]。
|
||||
pub fn scan(run_dir: &Path) -> Result<SavesScan, SavesError> {
|
||||
let dir = saves_dir(run_dir);
|
||||
let (exists, children) = read_children(&dir)?;
|
||||
let mut worlds = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
for child in children {
|
||||
let raw = child.file_name().to_string_lossy().to_string();
|
||||
let Ok(file_type) = child.file_type() else {
|
||||
skipped.push(skip(Area::Saves, raw, SkipKind::Unreadable));
|
||||
continue;
|
||||
};
|
||||
if file_type.is_symlink() {
|
||||
skipped.push(skip(Area::Saves, raw, SkipKind::SymbolicLink));
|
||||
continue;
|
||||
}
|
||||
if !file_type.is_dir() {
|
||||
skipped.push(skip(Area::Saves, raw, SkipKind::NotADirectoryExpected));
|
||||
continue;
|
||||
}
|
||||
if !plain_name(&raw) {
|
||||
skipped.push(skip(Area::Saves, raw, SkipKind::UnsafeName));
|
||||
continue;
|
||||
}
|
||||
let icon = is_regular_file(&dir.join(&raw).join(WORLD_ICON));
|
||||
let modified_ms = child.metadata().ok().and_then(|meta| modified_ms(&meta));
|
||||
worlds.push(WorldEntry {
|
||||
dir: raw,
|
||||
icon,
|
||||
modified_ms,
|
||||
});
|
||||
}
|
||||
// 名字排序(大小写不敏感;同名时按磁盘名字典序兜底,保证两帧之间不抖)。
|
||||
worlds.sort_by(|left, right| {
|
||||
left.dir
|
||||
.to_lowercase()
|
||||
.cmp(&right.dir.to_lowercase())
|
||||
.then_with(|| left.dir.cmp(&right.dir))
|
||||
});
|
||||
|
||||
let shots_dir = screenshots_dir(run_dir);
|
||||
let (shots_exists, shots) = read_children(&shots_dir)?;
|
||||
let mut screenshots = Vec::new();
|
||||
for child in shots {
|
||||
let raw = child.file_name().to_string_lossy().to_string();
|
||||
let Ok(file_type) = child.file_type() else {
|
||||
skipped.push(skip(Area::Screenshots, raw, SkipKind::Unreadable));
|
||||
continue;
|
||||
};
|
||||
if file_type.is_symlink() {
|
||||
skipped.push(skip(Area::Screenshots, raw, SkipKind::SymbolicLink));
|
||||
continue;
|
||||
}
|
||||
if !file_type.is_file() {
|
||||
skipped.push(skip(Area::Screenshots, raw, SkipKind::NotAFile));
|
||||
continue;
|
||||
}
|
||||
if !plain_name(&raw) {
|
||||
skipped.push(skip(Area::Screenshots, raw, SkipKind::UnsafeName));
|
||||
continue;
|
||||
}
|
||||
if !is_image(&raw) {
|
||||
skipped.push(skip(Area::Screenshots, raw, SkipKind::NotAnImage));
|
||||
continue;
|
||||
}
|
||||
let meta = child.metadata().ok();
|
||||
screenshots.push(ScreenshotEntry {
|
||||
bytes: meta.as_ref().map_or(0, std::fs::Metadata::len),
|
||||
modified_ms: meta.as_ref().and_then(modified_ms),
|
||||
file: raw,
|
||||
});
|
||||
}
|
||||
// 新的在前:截图文件名就是 `2024-01-02_12.00.00.png`,字典序倒排就是时间倒排。
|
||||
screenshots.sort_by(|left, right| right.file.cmp(&left.file));
|
||||
skipped.sort_by(|left, right| {
|
||||
area_key(left.area)
|
||||
.cmp(&area_key(right.area))
|
||||
.then_with(|| left.file.cmp(&right.file))
|
||||
});
|
||||
|
||||
Ok(SavesScan {
|
||||
dir,
|
||||
exists,
|
||||
worlds,
|
||||
screenshots_dir: shots_dir,
|
||||
screenshots_exists: shots_exists,
|
||||
screenshots,
|
||||
skipped,
|
||||
})
|
||||
}
|
||||
|
||||
/// 列一个目录的子项(**不存在 ⇒ `(false, 空)`**;不可用 ⇒ 具名错误;符号链接不跟随)。
|
||||
fn read_children(dir: &Path) -> Result<(bool, Vec<std::fs::DirEntry>), SavesError> {
|
||||
let meta = match std::fs::symlink_metadata(dir) {
|
||||
Ok(meta) => meta,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok((false, Vec::new()));
|
||||
}
|
||||
Err(error) => return Err(SavesError::unusable(dir, error.to_string())),
|
||||
};
|
||||
if meta.file_type().is_symlink() {
|
||||
return Err(SavesError::unusable(
|
||||
dir,
|
||||
"是符号链接(不跟随:会把实例目录之外的内容算成存档)",
|
||||
));
|
||||
}
|
||||
if !meta.is_dir() {
|
||||
return Err(SavesError::unusable(dir, "不是目录"));
|
||||
}
|
||||
let read =
|
||||
std::fs::read_dir(dir).map_err(|error| SavesError::unusable(dir, error.to_string()))?;
|
||||
let mut children: Vec<_> = read
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| SavesError::unusable(dir, error.to_string()))?;
|
||||
children.sort_by_key(std::fs::DirEntry::file_name);
|
||||
Ok((true, children))
|
||||
}
|
||||
|
||||
fn skip(area: Area, file: String, reason: SkipKind) -> SavesSkip {
|
||||
SavesSkip { area, file, reason }
|
||||
}
|
||||
|
||||
/// 名字是不是**裸名字**(判据复用整合包侧那一份,不在这里写第二份规则)。
|
||||
///
|
||||
/// `read_dir` 给的已经是单段名字,所以只需判它过不过得了路径规则:`\`、`:`、`..` 这类会被
|
||||
/// 拒——它们既可能在展示里被误读成路径分隔,也可能在将来接"删除/重命名"时落到别处。
|
||||
fn plain_name(raw: &str) -> bool {
|
||||
crate::addon::pack::path::normalize_file(raw).is_ok()
|
||||
}
|
||||
|
||||
/// 扩展名是不是认得的图片(大小写不敏感)。
|
||||
fn is_image(raw: &str) -> bool {
|
||||
Path::new(raw)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| {
|
||||
let ext = ext.to_ascii_lowercase();
|
||||
IMAGE_EXTENSIONS.iter().any(|known| *known == ext)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 是不是**普通文件**(不跟随符号链接:`icon.png` 指到别处不算这个世界的图标)。
|
||||
fn is_regular_file(path: &Path) -> bool {
|
||||
std::fs::symlink_metadata(path)
|
||||
.map(|meta| meta.file_type().is_file())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// mtime → 毫秒(早于 1970 或取不到 ⇒ `None`,不报错)。
|
||||
fn modified_ms(meta: &std::fs::Metadata) -> Option<u64> {
|
||||
let since = meta.modified().ok()?.duration_since(UNIX_EPOCH).ok()?;
|
||||
u64::try_from(since.as_millis()).ok()
|
||||
}
|
||||
|
||||
/// 跳过条目的排序键(先 `saves/` 再 `screenshots/`)。
|
||||
fn area_key(area: Area) -> u8 {
|
||||
match area {
|
||||
Area::Saves => 0,
|
||||
Area::Screenshots => 1,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! 存档 / 截图的**只读**列举与「打开文件夹」(M235 · STOREMVP)。
|
||||
//!
|
||||
//! # 这一版做到哪、不做什么(塔裁决 U3)
|
||||
//!
|
||||
//! 用户原话是「存档截图 / 导入」,M230 审计确认三层全缺(core 没有读路径,`saves/` 只在
|
||||
//! `isolation.rs` 的切换探测里被计数;presenter 没有 DTO;i18n 没有键;没有页面)。
|
||||
//! U3 把本 mission 的边界钉成:**只读列表 + 打开文件夹,不做缩略图墙**。理由有两条:
|
||||
//!
|
||||
//! 1. 缩略图要引图片解码依赖,而 `AGENTS.md` §2 只允许 `palette-extract` 之外不引 image 类依赖;
|
||||
//! 2. 截图/导入的产物形态还没定型,先不进产品。
|
||||
//!
|
||||
//! 所以这里**只读**:只调 `read_dir` / `metadata` / `symlink_metadata`,不建目录、不写文件、
|
||||
//! 不改名字、不读文件内容(`icon.png` 只判存在——那是将来缩略图墙的输入,不是这一版的解码对象)。
|
||||
//!
|
||||
//! # 单根语义(D20)
|
||||
//!
|
||||
//! 入口是**已经派生好的运行目录**([`crate::game::isolation::derive_game_dir`] 的结果),
|
||||
//! 本模块只往下取两层:`<run_dir>/saves/` 与 `<run_dir>/screenshots/`。
|
||||
//! 不自己去猜游戏根,也不做「隔离侧 + 共享侧合并」——派生是启动、mod 列表、存档列表**同一个**
|
||||
//! 答案的唯一来源。与 [`crate::addon::mods`] 同一条纪律。
|
||||
//!
|
||||
//! # 目录不存在不是错误
|
||||
//!
|
||||
//! 新实例两个目录都不存在,这**不是**异常(`exists = false` + 空列表)。只有「存在但不可用」
|
||||
//! (符号链接 / 不是目录 / 读不了)才是 [`SavesError`];**并且照样不建目录**。
|
||||
//!
|
||||
//! # 收纳口径
|
||||
//!
|
||||
//! | 位置 | 收什么 | 不收什么(逐条登记原因,不静默丢) |
|
||||
//! |---|---|---|
|
||||
//! | `saves/` | 子目录 = 一个世界 | 符号链接(不跟随)、非目录(散落文件)、名字过不了路径规则、元数据读不出 |
|
||||
//! | `screenshots/` | 扩展名是 [`IMAGE_EXTENSIONS`] 的普通文件 | 同上 + 不是图片的文件(`NotAnImage`) |
|
||||
//!
|
||||
//! 「不静默丢」是 M224 定下的口径:列表要能和磁盘对得上账,用户看到「跳过 3 项」时得知道
|
||||
//! 那 3 项不是我们忘了。
|
||||
//!
|
||||
//! # 模块
|
||||
//!
|
||||
//! | 文件 | 职责 |
|
||||
//! |---|---|
|
||||
//! | [`error`] | 「目录存在但不可用」的失败形状(带 OS 原文) |
|
||||
//! | [`list`] | 列 `<run_dir>/saves` 与 `<run_dir>/screenshots`(纯只读) |
|
||||
//!
|
||||
//! 「打开文件夹」不在本模块:平台差异收敛在 [`crate::platform::opener::open_dir`]
|
||||
//! (`AGENTS.md` §6),本模块一个 `cfg` 都不写。
|
||||
|
||||
mod error;
|
||||
mod list;
|
||||
|
||||
pub use error::SavesError;
|
||||
pub use list::{
|
||||
saves_dir, scan, screenshots_dir, Area, SavesScan, SavesSkip, ScreenshotEntry, SkipKind,
|
||||
WorldEntry, IMAGE_EXTENSIONS, SAVES_DIR, SCREENSHOTS_DIR, WORLD_ICON,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,260 @@
|
||||
//! 只读判据的用例:**真的建盘上目录**再读回来(不 mock 文件系统)。
|
||||
//!
|
||||
//! 每条用例都写在「抓得住什么 / 抓不住什么」的口径里(M224 的写法):
|
||||
//!
|
||||
//! | 用例 | 抓得住 | 抓不住 |
|
||||
//! |---|---|---|
|
||||
//! | [`scan_lists_worlds_and_screenshots_read_only`] | 收纳口径、排序、`icon.png` 只判存在、**调用前后盘上指纹不变** | 大世界的耗时(不测性能) |
|
||||
//! | [`missing_dirs_are_empty_and_not_created`] | 「不存在 ⇒ 空列表」且**不建目录** | 权限不足导致的 `NotFound`(本机跑不出来) |
|
||||
//! | [`unusable_dirs_are_errors`] | 目录被占成文件 ⇒ 具名错误 | Windows 上建符号链接要开发者模式,见下一条 |
|
||||
//! | [`symlinks_are_refused`] | 符号链接不被跟随(目录级与条目级) | 非 unix 平台(`cfg(unix)`;Windows 建链接要提权) |
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::*;
|
||||
use crate::addon::temp::TempRoot;
|
||||
|
||||
/// 建一个运行目录(`<tmp>/run`)。
|
||||
///
|
||||
/// `create_dir_all` 这一句不是冗余:`addon::temp::TempRoot::under` 返回时它内部的 `base`
|
||||
/// 已经析构、把那棵树删掉了(M235 已作为 finding 上报,`addon/` 不在本 mission 的改动范围)。
|
||||
/// 修好之后这一句会退化成 no-op。
|
||||
fn run_dir(tag: &str) -> TempRoot {
|
||||
let root = TempRoot::under(tag, "run");
|
||||
std::fs::create_dir_all(&root).expect("建运行目录");
|
||||
root
|
||||
}
|
||||
|
||||
/// 盘上指纹:`(相对路径, 是否目录, 字节数, mtime)` 逐条排序后的列表。
|
||||
///
|
||||
/// 这就是「调用前后版本树快照不变」的判据本体——只要列举路径里多了一次 `create_dir`、
|
||||
/// 一次 `write`、一次 `remove`,或者顺手动了 mtime,这个列表就会不同。
|
||||
fn fingerprint(root: &Path) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![root.to_path_buf()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(read) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
let mut entries: Vec<_> = read.flatten().collect();
|
||||
entries.sort_by_key(std::fs::DirEntry::file_name);
|
||||
for entry in entries {
|
||||
let path = entry.path();
|
||||
let Ok(meta) = std::fs::symlink_metadata(&path) else {
|
||||
continue;
|
||||
};
|
||||
let rel = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(&path)
|
||||
.display()
|
||||
.to_string();
|
||||
let mtime = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map_or(0, |since| since.as_millis());
|
||||
out.push(format!(
|
||||
"{rel}|dir={}|len={}|mtime={mtime}",
|
||||
meta.is_dir(),
|
||||
meta.len()
|
||||
));
|
||||
if meta.is_dir() {
|
||||
stack.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// 收纳口径 + 排序 + `icon.png` + **只读**(M235 的核心判据)。
|
||||
#[test]
|
||||
fn scan_lists_worlds_and_screenshots_read_only() {
|
||||
let root = run_dir("m235-scan");
|
||||
let saves = saves_dir(&root);
|
||||
std::fs::create_dir_all(saves.join("World A")).expect("建世界 A");
|
||||
std::fs::write(saves.join("World A/level.dat"), b"nbt").expect("写 level.dat");
|
||||
std::fs::write(saves.join("World A/icon.png"), b"png").expect("写 icon");
|
||||
std::fs::create_dir_all(saves.join("world b")).expect("建世界 B");
|
||||
// `saves/` 下的散落文件:不是世界,登记原因。
|
||||
std::fs::write(saves.join("readme.txt"), b"hi").expect("写散落文件");
|
||||
|
||||
let shots = screenshots_dir(&root);
|
||||
std::fs::create_dir_all(&shots).expect("建截图目录");
|
||||
std::fs::write(shots.join("2024-05-01_10.00.00.png"), b"aa").expect("写截图 1");
|
||||
std::fs::write(shots.join("2024-06-02_11.00.00.PNG"), b"bbb").expect("写截图 2");
|
||||
std::fs::write(shots.join("notes.txt"), b"x").expect("写非图片");
|
||||
|
||||
let before = fingerprint(&root);
|
||||
let scan = scan(&root).expect("列存档");
|
||||
let after = fingerprint(&root);
|
||||
assert_eq!(before, after, "列举改动了磁盘:{before:?} → {after:?}");
|
||||
|
||||
assert!(scan.exists);
|
||||
assert!(scan.screenshots_exists);
|
||||
assert_eq!(scan.dir, save_path(&root));
|
||||
assert_eq!(scan.screenshots_dir, shot_path(&root));
|
||||
|
||||
// 世界:名字排序大小写不敏感;`icon.png` 只判存在。
|
||||
let worlds: Vec<(&str, bool)> = scan
|
||||
.worlds
|
||||
.iter()
|
||||
.map(|world| (world.dir.as_str(), world.icon))
|
||||
.collect();
|
||||
assert_eq!(worlds, [("World A", true), ("world b", false)]);
|
||||
assert!(
|
||||
scan.worlds.iter().all(|world| world.modified_ms.is_some()),
|
||||
"世界的 mtime 应当取得到:{:?}",
|
||||
scan.worlds
|
||||
);
|
||||
|
||||
// 截图:新的在前(文件名倒序),扩展名大小写不敏感,非图片登记原因。
|
||||
let shots_listed: Vec<&str> = scan
|
||||
.screenshots
|
||||
.iter()
|
||||
.map(|shot| shot.file.as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
shots_listed,
|
||||
["2024-06-02_11.00.00.PNG", "2024-05-01_10.00.00.png"]
|
||||
);
|
||||
assert_eq!(scan.screenshots[0].bytes, 3, "字节数来自 metadata");
|
||||
assert_eq!(scan.screenshots[1].bytes, 2);
|
||||
|
||||
let skipped: Vec<(Area, &str, SkipKind)> = scan
|
||||
.skipped
|
||||
.iter()
|
||||
.map(|skip| (skip.area, skip.file.as_str(), skip.reason))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
skipped,
|
||||
[
|
||||
(Area::Saves, "readme.txt", SkipKind::NotADirectoryExpected),
|
||||
(Area::Screenshots, "notes.txt", SkipKind::NotAnImage),
|
||||
],
|
||||
"跳过必须逐条登记原因(不静默丢)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 「目录不存在 ⇒ 空列表」**且不替玩家建目录**。
|
||||
#[test]
|
||||
fn missing_dirs_are_empty_and_not_created() {
|
||||
let root = run_dir("m235-missing");
|
||||
let before = fingerprint(&root);
|
||||
|
||||
let scan = scan(&root).expect("两个目录都不存在不是错误");
|
||||
assert!(!scan.exists);
|
||||
assert!(!scan.screenshots_exists);
|
||||
assert!(scan.worlds.is_empty());
|
||||
assert!(scan.screenshots.is_empty());
|
||||
assert!(scan.skipped.is_empty());
|
||||
assert!(scan.is_empty());
|
||||
|
||||
assert_eq!(fingerprint(&root), before, "列举顺手建了目录");
|
||||
assert!(!saves_dir(&root).exists(), "`saves/` 被建出来了");
|
||||
assert!(
|
||||
!screenshots_dir(&root).exists(),
|
||||
"`screenshots/` 被建出来了"
|
||||
);
|
||||
// 路径本身照样是"拼得出来的"(UI 的「打开文件夹」需要它)。
|
||||
assert_eq!(scan.dir, saves_dir(&root));
|
||||
assert_eq!(scan.screenshots_dir, screenshots_dir(&root));
|
||||
}
|
||||
|
||||
/// 「存在但不是目录」⇒ 具名错误(不折叠成空列表:那不是"没存档",是"读不了")。
|
||||
#[test]
|
||||
fn unusable_dirs_are_errors() {
|
||||
let root = run_dir("m235-unusable");
|
||||
std::fs::write(saves_dir(&root), b"not a dir").expect("用文件占住 saves 这个名字");
|
||||
|
||||
let error = scan(&root).expect_err("saves 是个文件,居然列成功了");
|
||||
assert_eq!(error.dir, saves_dir(&root));
|
||||
assert!(error.detail.contains("不是目录"), "原因:{}", error.detail);
|
||||
|
||||
// `screenshots/` 那一侧单独判(saves 正常、shot 被占)。
|
||||
let other = run_dir("m235-unusable-shots");
|
||||
std::fs::create_dir_all(saves_dir(&other)).expect("建 saves");
|
||||
std::fs::write(screenshots_dir(&other), b"x").expect("用文件占住 screenshots");
|
||||
let error = scan(&other).expect_err("screenshots 是个文件,居然列成功了");
|
||||
assert_eq!(error.dir, screenshots_dir(&other));
|
||||
}
|
||||
|
||||
/// 符号链接不跟随(目录级 = 错误;条目级 = 跳过并登记原因)。
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlinks_are_refused() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
// 目录级:`saves/` 指到别处 ⇒ 拒(列下去会把实例外的东西算成存档)。
|
||||
let root = run_dir("m235-symlink-dir");
|
||||
let elsewhere = root.join("elsewhere");
|
||||
std::fs::create_dir_all(elsewhere.join("Outside")).expect("建实例外目录");
|
||||
symlink(&elsewhere, saves_dir(&root)).expect("建符号链接");
|
||||
let error = scan(&root).expect_err("符号链接的 saves 居然列成功了");
|
||||
assert!(error.detail.contains("符号链接"), "原因:{}", error.detail);
|
||||
|
||||
// 条目级:世界目录 / 截图文件各一个链接 ⇒ 跳过,不跟随。
|
||||
let root = run_dir("m235-symlink-entry");
|
||||
let saves = saves_dir(&root);
|
||||
std::fs::create_dir_all(&saves).expect("建 saves");
|
||||
std::fs::create_dir_all(elsewhere.join("Real")).expect("建真实世界");
|
||||
symlink(elsewhere.join("Real"), saves.join("Linked")).expect("建世界链接");
|
||||
let shots = screenshots_dir(&root);
|
||||
std::fs::create_dir_all(&shots).expect("建 screenshots");
|
||||
std::fs::write(elsewhere.join("shot.png"), b"png").expect("写真实截图");
|
||||
symlink(elsewhere.join("shot.png"), shots.join("linked.png")).expect("建截图链接");
|
||||
|
||||
let scan = scan(&root).expect("列存档");
|
||||
assert!(scan.worlds.is_empty(), "跟随了世界符号链接");
|
||||
assert!(scan.screenshots.is_empty(), "跟随了截图符号链接");
|
||||
let reasons: Vec<(Area, SkipKind)> = scan
|
||||
.skipped
|
||||
.iter()
|
||||
.map(|skip| (skip.area, skip.reason))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
reasons,
|
||||
[
|
||||
(Area::Saves, SkipKind::SymbolicLink),
|
||||
(Area::Screenshots, SkipKind::SymbolicLink),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// 名字过不了路径规则的条目:跳过并登记(不静默丢)。
|
||||
///
|
||||
/// `:` 在 unix 上是合法文件名字符,在 Windows 上建不出来——所以这条只在 unix 跑得起来。
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unsafe_names_are_skipped() {
|
||||
let root = run_dir("m235-unsafe");
|
||||
let scenes = saves_dir(&root);
|
||||
std::fs::create_dir_all(&scenes).expect("建 saves");
|
||||
std::fs::create_dir_all(scenes.join("bad:name")).expect("建带冒号的世界");
|
||||
std::fs::create_dir_all(scenes.join("good")).expect("建正常世界");
|
||||
|
||||
let scan = scan(&root).expect("列存档");
|
||||
let worlds: Vec<&str> = scan.worlds.iter().map(|world| world.dir.as_str()).collect();
|
||||
assert_eq!(worlds, ["good"]);
|
||||
assert_eq!(scan.skipped.len(), 1);
|
||||
assert_eq!(scan.skipped[0].file, "bad:name");
|
||||
assert_eq!(scan.skipped[0].reason, SkipKind::UnsafeName);
|
||||
}
|
||||
|
||||
/// 路径拼接口径:两个目录都挂在**运行目录**下面(不是游戏根、不是 `.minecraft`)。
|
||||
#[test]
|
||||
fn paths_hang_off_the_run_dir() {
|
||||
let run = Path::new("D:/ULL/.ULL/data/instances/better-mc");
|
||||
assert_eq!(saves_dir(run).file_name().unwrap(), SAVES_DIR);
|
||||
assert_eq!(screenshots_dir(run).file_name().unwrap(), SCREENSHOTS_DIR);
|
||||
assert!(saves_dir(run).starts_with(run));
|
||||
assert!(screenshots_dir(run).starts_with(run));
|
||||
}
|
||||
|
||||
fn save_path(root: &Path) -> std::path::PathBuf {
|
||||
root.join(SAVES_DIR)
|
||||
}
|
||||
|
||||
fn shot_path(root: &Path) -> std::path::PathBuf {
|
||||
root.join(SCREENSHOTS_DIR)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user