feat(presenter): 多根发现(配置根 + 官方启动器标准位置),实例按自己的根解析存档/模组/启动(M274)
This commit is contained in:
1 parent
dfc0f4bcb3
commit
dc8323531b
20 files changed
+943
-121
No files matched your search
@@ -1,22 +1,34 @@
|
||||
//! 自动发现的实例(M273):盘上扫出来的版本 → 磁贴,以及它们的**运行目录判据**。
|
||||
//! 自动发现的实例(M273 · 多根 M274):盘上扫出来的版本 → 磁贴,以及它们的**运行目录判据**。
|
||||
//!
|
||||
//! 从 [`super::instances`] 里拆出来只为守 400 行硬线(那边装了档案侧的翻译)。本文件的两件事:
|
||||
//!
|
||||
//! 1. **哪些盘上的版本算实例**:`versions/<id>/<id>.json` 能解析、没有被任何档案绑定
|
||||
//! (判据来自 `core::game::adopt::scan`,本文件只做「有没有档案」这一步);
|
||||
//! 1. **哪些盘上的版本算实例**:`versions/<id>/<id>.json` 能解析(判据来自 `core::game::adopt::scan`,
|
||||
//! 本文件只做「哪个根」与「有没有档案」这两步);
|
||||
//! 2. **它们从哪个目录跑**:[`base_mode`]——`versions/<id>/` 里有痕迹(`mods` / `saves` /
|
||||
//! `config` / `options.txt`)就按独立目录,否则按共用目录(游戏根)。
|
||||
//! `config` / `options.txt`)就按独立目录,否则按共用目录(**它自己那个根**)。
|
||||
//!
|
||||
//! 两条都**只读**:本文件不建目录、不写 `profiles.json`、不碰 `versions/` 一个字节。
|
||||
//! # 多根(M274):身份 = (根, 版本)
|
||||
//!
|
||||
//! 两个根里都可能有 `1.20.4`,所以版本 id **不再唯一**。身份的落点只有一个:
|
||||
//! [`external_instance_id`](把根的规范化路径喂进哈希)。于是「同一个根 + 同一个版本目录」跨重启
|
||||
//! 拿到同一个 id,而两个根里的同名版本拿到两个不同 id——列表自然出两条。
|
||||
//!
|
||||
//! 反查(id → 根 + 版本)只能靠**重算**:哈希不可逆,所以 [`locate`] 枚举全部根、每个根扫一遍
|
||||
//! `versions/`,逐个比 id。因此它只在「用户动了一次某个实例」时被调用(存档 / 模组 / 启动 / 改设置),
|
||||
//! 不在 `instances()` 的循环里。
|
||||
//!
|
||||
//! 两条都**只读**:本文件不建目录、不写 `profiles.json`、不碰 `versions/` 一个字节,
|
||||
//! 更不往官方启动器的根里写任何东西。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use unlucky_core::game::isolation::IsolationMode;
|
||||
use unlucky_core::game::ProfileList;
|
||||
use unlucky_core::game::{GameLayout, ProfileList};
|
||||
|
||||
use crate::dto::{InstanceRuntime, InstanceSummary};
|
||||
|
||||
use super::super::env::HostEnv;
|
||||
use super::roots::{self, GameRoot};
|
||||
use super::{describe_version, global_mode, isolation_of};
|
||||
|
||||
/// `versions/<version_id>/` 里这些名字出现任何一个,就说明这个版本本来就被当成
|
||||
@@ -27,8 +39,10 @@ use super::{describe_version, global_mode, isolation_of};
|
||||
const RUN_DIR_TRACES: [&str; 4] = ["mods", "saves", "config", "options.txt"];
|
||||
|
||||
/// 版本目录里有没有「它自己用过」的痕迹(只读)。
|
||||
fn has_own_run_dir(env: &HostEnv, version_id: &str) -> bool {
|
||||
let dir = env.game.version_dir(version_id);
|
||||
///
|
||||
/// **必须传这个实例自己的根**:同一个版本 id 在两个根里的痕迹可以不同(官方根里跑过、ULL 根里没有)。
|
||||
fn has_own_run_dir(game: &GameLayout, version_id: &str) -> bool {
|
||||
let dir = game.version_dir(version_id);
|
||||
RUN_DIR_TRACES.iter().any(|name| dir.join(name).exists())
|
||||
}
|
||||
|
||||
@@ -43,38 +57,42 @@ fn has_own_run_dir(env: &HostEnv, version_id: &str) -> bool {
|
||||
/// `versions/<id>/`。一个从没跑过、也没有 `mods`/`saves` 的外部版本若按 `isolated` 处理,
|
||||
/// 第一次启动就会在**别人的版本目录**里新长出 `mods/` `saves/` `config/`(D20 的
|
||||
/// `AdoptTarget` 缺省值正是为了躲开这件事而选 `InstanceDir`)。反过来,盘上已经有这些痕迹的
|
||||
/// 版本本来就是独立目录实例,按 `shared` 处理会让它突然读到 `.minecraft` 根下别人的存档 ——
|
||||
/// 版本本来就是独立目录实例,按 `shared` 处理会让它突然读到根下别人的存档 ——
|
||||
/// 两种错都真实存在,所以判据只能是**盘上的事实**。
|
||||
///
|
||||
/// **M274**:痕迹按**这个实例自己的根**判(`game` 参数)。换根之后判定不变:官方根里那个
|
||||
/// 带 `mods/` 的版本仍是 `isolated`,ULL 根里那个干净的版本仍是 `shared`。
|
||||
///
|
||||
/// 只读:这里不建目录、不写任何文件(写盘只发生在用户第一次改设置时,见 [`super::ensure_profile`])。
|
||||
pub(in crate::host) fn base_mode(env: &HostEnv, id: &str, version_id: &str) -> IsolationMode {
|
||||
pub(in crate::host) fn base_mode(
|
||||
env: &HostEnv,
|
||||
game: &GameLayout,
|
||||
id: &str,
|
||||
version_id: &str,
|
||||
) -> IsolationMode {
|
||||
if !crate::dto::is_discovered_instance(id) {
|
||||
return global_mode(env);
|
||||
}
|
||||
if has_own_run_dir(env, version_id) {
|
||||
if has_own_run_dir(game, version_id) {
|
||||
IsolationMode::Isolated
|
||||
} else {
|
||||
IsolationMode::Shared
|
||||
}
|
||||
}
|
||||
|
||||
/// 盘上有版本、但没有档案绑定的那些版本 id(字典序,来自 core 的扫描)。
|
||||
/// 一个根下「盘上有版本、可登记」的那些版本 id(字典序,来自 core 的扫描)。
|
||||
///
|
||||
/// 扫描失败(`versions/` 读不出来 / 断链联接)时返回空:列表退回「只有档案」的旧行为。
|
||||
/// 这里**不**把错误往上抛——实例页打不开是比「少列几个外部实例」严重得多的故障。
|
||||
pub(super) fn external_versions(env: &HostEnv, profiles: &ProfileList) -> Vec<String> {
|
||||
let Ok(scan) = unlucky_core::game::adopt::scan(&env.game, &env.layout) else {
|
||||
///
|
||||
/// 档案绑定(`profile.version_id`)**不在这一层过滤**:那只对配置根有意义,由 [`super::instances`] 做。
|
||||
pub(in crate::host) fn external_versions(env: &HostEnv, game: &GameLayout) -> Vec<String> {
|
||||
let Ok(scan) = unlucky_core::game::adopt::scan(game, &env.layout) else {
|
||||
return Vec::new();
|
||||
};
|
||||
scan.candidates
|
||||
.into_iter()
|
||||
.filter(|candidate| candidate.adoptable())
|
||||
.filter(|candidate| {
|
||||
!profiles
|
||||
.profiles
|
||||
.iter()
|
||||
.any(|profile| profile.version_id == candidate.version_id)
|
||||
})
|
||||
.map(|candidate| candidate.version_id)
|
||||
.collect()
|
||||
}
|
||||
@@ -86,34 +104,53 @@ pub(super) fn external_versions(env: &HostEnv, profiles: &ProfileList) -> Vec<St
|
||||
/// 这个白名单校验),而版本目录名可以是中文——用户实测的那个就是 `樱落怡然客户端`。
|
||||
/// 直接沿用版本 id 会让**启动**在写盘前就死在 `LaunchFailureStage::InstanceConfig`。
|
||||
///
|
||||
/// 前缀 + 稳定哈希 ⇒ 同一个版本每次拿到同一个 id(懒创建因此幂等),且是合法实例 id。
|
||||
/// 前缀 + 稳定哈希 ⇒ 同一个 (根, 版本) 每次拿到同一个 id(懒创建因此幂等),且是合法实例 id。
|
||||
///
|
||||
/// **M274(多根发现)**:两个根里都可能有 `1.20.4`,所以这里是**唯一**该把「根」并进身份的
|
||||
/// 地方——到时把 `root` 也喂进哈希(`external_instance_id(root, version_id)`),
|
||||
/// 列表就自然出两条,不必动 [`instances`] 的其余部分。
|
||||
pub(super) fn external_instance_id(version_id: &str) -> String {
|
||||
/// **M274**:`root_key` 也进哈希(`fnv(root || 0 || version)`)——两个根里的 `1.20.4` 因此是两个
|
||||
/// 实例。分隔符用 `0`:路径里不可能有 NUL,所以 `("ab","c")` 与 `("a","bc")` 不会撞。
|
||||
pub(in crate::host) fn external_instance_id(root_key: &str, version_id: &str) -> String {
|
||||
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for byte in version_id.as_bytes() {
|
||||
hash ^= u64::from(*byte);
|
||||
let bytes = root_key
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(std::iter::once(0))
|
||||
.chain(version_id.as_bytes().iter().copied());
|
||||
for byte in bytes {
|
||||
hash ^= u64::from(byte);
|
||||
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
format!("{}{hash:016x}", crate::dto::DISCOVERED_ID_PREFIX)
|
||||
}
|
||||
|
||||
/// 一条外部版本 → 磁贴(显示名 = 版本目录名;**只读**,不落任何文件)。
|
||||
pub(super) fn external_summary(
|
||||
/// 磁贴的显示名:配置根里的版本就是版本目录名;**别的根**里的版本带上根路径。
|
||||
///
|
||||
/// 为什么非带不可:两个根里都可能有一个叫 `A` 的版本,列表上两条同名磁贴,用户无从分辨点哪一条
|
||||
/// (而两条的存档在不同的目录里)。路径是**事实**,直接摆出来;只对配置根保持原样是为了不让
|
||||
/// 绝大多数用户看到一堆长路径。
|
||||
pub(in crate::host) fn display_name(root: &GameRoot, version_id: &str) -> String {
|
||||
if root.configured() {
|
||||
return version_id.to_string();
|
||||
}
|
||||
format!("{version_id} · {}", root.path().display())
|
||||
}
|
||||
|
||||
/// 一条外部版本 → 磁贴(**只读**,不落任何文件)。
|
||||
pub(in crate::host) fn external_summary(
|
||||
env: &HostEnv,
|
||||
root: &GameRoot,
|
||||
version_id: &str,
|
||||
played: &BTreeMap<String, String>,
|
||||
) -> InstanceSummary {
|
||||
let id = external_instance_id(version_id);
|
||||
let (mc_version, loader) = describe_version(env, version_id);
|
||||
let id = external_instance_id(root.key(), version_id);
|
||||
let game = root.game();
|
||||
let (mc_version, loader) = describe_version(game, version_id);
|
||||
InstanceSummary {
|
||||
last_played: played.get(&id).cloned(),
|
||||
isolation: isolation_of(env, &id, version_id),
|
||||
launch: super::super::launch_args::view(env, &id, version_id),
|
||||
version_present: env.game.is_version_installed(version_id),
|
||||
name: version_id.to_string(),
|
||||
isolation: isolation_of(env, game, &id, version_id),
|
||||
launch: super::super::launch_args::view(env, game, &id, version_id),
|
||||
version_present: game.is_version_installed(version_id),
|
||||
name: display_name(root, version_id),
|
||||
version_id: version_id.to_string(),
|
||||
mc_version,
|
||||
loader,
|
||||
@@ -123,16 +160,56 @@ pub(super) fn external_summary(
|
||||
}
|
||||
}
|
||||
|
||||
/// 视图 id → 版本 id 的反查(只认「可登记、且还没有档案绑定」的版本)。
|
||||
/// 视图 id → (所属根, 版本 id):**唯一的反查点**(哈希不可逆,只能重算比对)。
|
||||
///
|
||||
/// 与 [`external_versions`] 同一份判据:合成的视图 id 只在那个集合里有意义,
|
||||
/// 于是「界面上出现过的外部实例」与「能懒创建的外部实例」永远是同一批。
|
||||
pub(super) fn external_version_of(
|
||||
/// 与 [`external_versions`] 同一份判据(可登记 ⇒ 会出现在列表里),但**不**看档案绑定:
|
||||
/// 一个自动发现的实例被懒创建成档案之后,id 仍然是 `ext-…`,而它的根必须还认得出来
|
||||
/// (否则存档 / 模组 / 启动会退回配置根,那正是 M274 要修的错)。
|
||||
///
|
||||
/// 不是 `ext-` 前缀的 id 直接返回 `None`(档案实例属于配置根,不需要反查)。
|
||||
#[must_use]
|
||||
pub(in crate::host) fn locate(env: &HostEnv, id: &str) -> Option<(GameRoot, String)> {
|
||||
if !crate::dto::is_discovered_instance(id) {
|
||||
return None;
|
||||
}
|
||||
for root in roots::all(env) {
|
||||
for version_id in external_versions(env, root.game()) {
|
||||
if external_instance_id(root.key(), &version_id) == id {
|
||||
return Some((root, version_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 实例所属的那个根的布局(读盘路径的唯一入口)。
|
||||
///
|
||||
/// 档案实例、以及「反查不到」的 id 一律退回配置根 `env.game`——**没有第二个答案**:
|
||||
/// 宁可读配置根(旧行为),也不把实例指向一个猜出来的目录。
|
||||
pub(in crate::host) fn game_for(env: &HostEnv, id: &str) -> GameLayout {
|
||||
locate(env, id)
|
||||
.map(|(root, _)| root.game().clone())
|
||||
.unwrap_or_else(|| env.game.clone())
|
||||
}
|
||||
|
||||
/// 视图 id → 版本 id 的反查(只认「可登记、且**还没有档案绑定**」的版本)。
|
||||
///
|
||||
/// 与 `instances()` 同一份判据:合成的视图 id 只在那个集合里有意义,于是「界面上出现过的外部实例」
|
||||
/// 与「能懒创建的外部实例」永远是同一批(有了档案就走档案,不再重复列一条)。
|
||||
#[must_use]
|
||||
pub(in crate::host) fn external_version_of(
|
||||
env: &HostEnv,
|
||||
profiles: &ProfileList,
|
||||
id: &str,
|
||||
) -> Option<String> {
|
||||
external_versions(env, profiles)
|
||||
.into_iter()
|
||||
.find(|version_id| external_instance_id(version_id) == id)
|
||||
) -> Option<(GameRoot, String)> {
|
||||
let (root, version_id) = locate(env, id)?;
|
||||
let bound = profiles
|
||||
.profiles
|
||||
.iter()
|
||||
.any(|profile| profile.version_id == version_id);
|
||||
// 档案绑定只对**配置根**有意义(别处的同名版本是另一个实例,同一个 id 也不会撞)。
|
||||
if root.configured() && bound {
|
||||
return None;
|
||||
}
|
||||
Some((root, version_id))
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! 游戏根的发现与去重(M274):**当前配置的根** + **官方启动器的标准位置**。
|
||||
//!
|
||||
//! 用户原话:「其他第三方启动器甚至能自动读到官方启动器的版本和存档」。官方启动器用的是
|
||||
//! `%APPDATA%\.minecraft` / `~/Library/Application Support/minecraft` / `~/.minecraft`,
|
||||
//! 而便携模式下的 ULL 用的是 `<exe>/.minecraft`——**两个根,不是一回事**。所以列表要能同时读两边。
|
||||
//!
|
||||
//! # 只有一件事不是显然的:什么算「同一个根」
|
||||
//!
|
||||
//! 同一个目录可以有四种写法:大小写不同、`\` 与 `/` 混用、8.3 短名(`PROGRA~1`)、
|
||||
//! 符号链接 / 目录联接(Windows 的 junction)。按字面量比较会让**同一批实例列两遍**,
|
||||
//! 所以判据落在 [`key_of`] 的**规范化真实路径**上:
|
||||
//!
|
||||
//! 1. `canonicalize`(Windows 上是 `GetFinalPathNameByHandle`:短名展开成长名,联接解析到目标);
|
||||
//! 2. `\` → `/`;
|
||||
//! 3. **去掉 `\\?\` 前缀**——Windows 的 `canonicalize` 一律返回 verbatim 形式(`\\?\C:\…`),
|
||||
//! 不去掉的话「同一路径的两种写法」永远不相等(这是个已知坑,用例
|
||||
//! `junction_to_the_same_folder_is_one_root` 钉住);
|
||||
//! 4. Windows / macOS 折成小写(大小写不敏感的文件系统)。
|
||||
//!
|
||||
//! `canonicalize` 失败(根还不存在)时退化成「字面量归一」:`\`→`/`、去尾斜杠、折小写。
|
||||
//! 尚不存在的目录没有短名/链接可解析,这一步够用。
|
||||
//!
|
||||
//! # 只读
|
||||
//!
|
||||
//! 本模块只 `is_dir()` / `canonicalize()`:不建目录、不写一个字节。官方启动器那个根上尤其要紧
|
||||
//! ——那是**别人的**目录。
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use unlucky_core::game::GameLayout;
|
||||
|
||||
use super::super::env::HostEnv;
|
||||
|
||||
/// 一个被发现的游戏根。
|
||||
///
|
||||
/// `configured` 那一格区分「用户在设置里指定的根」与「官方启动器的标准位置」:档案绑定、
|
||||
/// 新建/安装版本都只对前者有意义(我们**从不往官方根写东西**,见 M274 的只读纪律)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(in crate::host) struct GameRoot {
|
||||
key: String,
|
||||
game: GameLayout,
|
||||
configured: bool,
|
||||
}
|
||||
|
||||
impl GameRoot {
|
||||
/// 这个根的布局(`versions/` / `libraries/` / `assets/` 都在它下面)。
|
||||
pub(in crate::host) fn game(&self) -> &GameLayout {
|
||||
&self.game
|
||||
}
|
||||
|
||||
/// 规范化后的比较键(实例 id 的哈希输入之一,见 [`super::discovered::external_instance_id`])。
|
||||
pub(in crate::host) fn key(&self) -> &str {
|
||||
&self.key
|
||||
}
|
||||
|
||||
/// 是不是**当前配置**的那个根(`env.game`)。
|
||||
pub(in crate::host) fn configured(&self) -> bool {
|
||||
self.configured
|
||||
}
|
||||
|
||||
/// 根目录本身(显示用)。
|
||||
pub(in crate::host) fn path(&self) -> &Path {
|
||||
self.game.root()
|
||||
}
|
||||
}
|
||||
|
||||
/// 全部游戏根:**配置根永远第一位**,官方启动器的标准位置(存在、且不是同一个物理目录)第二位。
|
||||
///
|
||||
/// 官方位置不存在 ⇒ 不列。「没装官方启动器」不该在列表上留一个空根——那是「这里本来就没有」,
|
||||
/// 与「有个空目录」是两件事。
|
||||
pub(in crate::host) fn all(env: &HostEnv) -> Vec<GameRoot> {
|
||||
let mut roots = vec![GameRoot {
|
||||
key: key_of(env.game.root()),
|
||||
game: env.game.clone(),
|
||||
configured: true,
|
||||
}];
|
||||
let mut seen: BTreeSet<String> = roots.iter().map(|root| root.key.clone()).collect();
|
||||
for extra in env.standard_game.iter() {
|
||||
if !extra.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let key = key_of(extra);
|
||||
// 同一个物理目录(符号链接 / 目录联接 / 大小写 / 短名)⇒ 只留配置根那一条。
|
||||
if !seen.insert(key.clone()) {
|
||||
continue;
|
||||
}
|
||||
roots.push(GameRoot {
|
||||
key,
|
||||
game: GameLayout::new(extra.clone()),
|
||||
configured: false,
|
||||
});
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
/// 路径 → **比较键**(判据的完整理由见模块文档)。
|
||||
///
|
||||
/// 同一个物理目录的所有写法必须给同一个键;不同的目录必须给不同的键(大小写不敏感的平台除外,
|
||||
/// 那里它们本来就是同一个目录)。
|
||||
pub(in crate::host) fn key_of(path: &Path) -> String {
|
||||
let resolved: PathBuf = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
let text = resolved.to_string_lossy().replace('\\', "/");
|
||||
// verbatim 前缀(`\\?\C:\…` 在上一步已经变成 `//?/C:/…`):不去掉就与普通写法永不相等。
|
||||
let text = text
|
||||
.strip_prefix("//?/")
|
||||
.or_else(|| text.strip_prefix("//./"))
|
||||
.unwrap_or(text.as_str());
|
||||
let text = text.trim_end_matches('/');
|
||||
let text = if text.is_empty() { "/" } else { text };
|
||||
if cfg!(any(windows, target_os = "macos")) {
|
||||
text.to_lowercase()
|
||||
} else {
|
||||
text.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_root(tag: &str) -> PathBuf {
|
||||
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!("{tag}-{}-{seq}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
std::fs::create_dir_all(&path).expect("建临时目录");
|
||||
path
|
||||
}
|
||||
|
||||
/// 斜杠方向 / 尾斜杠 / 大小写不影响比较键(`canonicalize` 之后三者本来就该相等)。
|
||||
#[test]
|
||||
fn key_ignores_separators_case_and_trailing_slash() {
|
||||
let root = temp_root("ull-roots-key");
|
||||
let nested = root.join("Minecraft/.minecraft");
|
||||
std::fs::create_dir_all(&nested).expect("建目录");
|
||||
let plain = key_of(&nested);
|
||||
assert_eq!(plain, key_of(&PathBuf::from(format!("{}/", plain))));
|
||||
let mixed = PathBuf::from(plain.replace('/', "\\"));
|
||||
assert_eq!(plain, key_of(&mixed), "反斜杠写法必须是同一个根");
|
||||
assert!(
|
||||
!plain.starts_with("//?/"),
|
||||
"verbatim 前缀没去掉,两种写法永远不相等:{plain}"
|
||||
);
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// **去重判据**:`root_b` 是指向 `root_a` 的目录联接(junction)⇒ 两个写法是同一个根。
|
||||
///
|
||||
/// 这一条是整个多根发现最容易假绿的地方:只比字面量时它必然「通过」两条,而用户看到同一批
|
||||
/// 实例列了两遍。Windows 上用 `mklink /J`(不需要管理员),其它平台用符号链接。
|
||||
#[test]
|
||||
fn junction_to_the_same_folder_is_one_root() {
|
||||
let root = temp_root("ull-roots-junction");
|
||||
let target = root.join("official/.minecraft");
|
||||
std::fs::create_dir_all(target.join("versions")).expect("建目录");
|
||||
let link = root.join("linked");
|
||||
if !make_dir_link(&link, &target) {
|
||||
// 造不出链接就**不假绿**:这一条判据在没链接的盘上等于没验,如实标出来。
|
||||
eprintln!("跳过:这个环境造不出目录链接({})", link.display());
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
return;
|
||||
}
|
||||
assert_eq!(
|
||||
key_of(&target),
|
||||
key_of(&link),
|
||||
"联接与目标是同一个根:{} vs {}",
|
||||
key_of(&target),
|
||||
key_of(&link)
|
||||
);
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// 造一个目录链接:Windows 用 junction(无管理员权限也能建),Unix 用符号链接。
|
||||
#[cfg(windows)]
|
||||
fn make_dir_link(link: &Path, target: &Path) -> bool {
|
||||
std::process::Command::new("cmd")
|
||||
.args(["/c", "mklink", "/J"])
|
||||
.arg(link)
|
||||
.arg(target)
|
||||
.status()
|
||||
.map(|status| status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn make_dir_link(link: &Path, target: &Path) -> bool {
|
||||
std::os::unix::fs::symlink(target, link).is_ok()
|
||||
}
|
||||
}
|
||||
@@ -190,9 +190,9 @@ fn isolation_write_then_read_round_trips() {
|
||||
let id = profiles.profiles[0].id.as_str().to_string();
|
||||
|
||||
// 有档案的实例:基底档 = 全局档(`isolated`),与 M273 之前一字不差。
|
||||
assert_eq!(isolation_of(&env, &id, "1.20.1"), "isolated");
|
||||
assert_eq!(isolation_of(&env, &env.game, &id, "1.20.1"), "isolated");
|
||||
set_isolation(&env, &id, "shared").expect("切档位");
|
||||
assert_eq!(isolation_of(&env, &id, "1.20.1"), "shared");
|
||||
assert_eq!(isolation_of(&env, &env.game, &id, "1.20.1"), "shared");
|
||||
assert!(set_isolation(&env, &id, "nope").is_err());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
//! M274:**多根发现**(配置根 + 官方启动器的标准位置)的判据用例。
|
||||
//!
|
||||
//! 用户原话:「其他第三方启动器甚至能自动读到官方启动器的版本和存档」。官方启动器用的是
|
||||
//! `%APPDATA%\.minecraft`,便携模式的 ULL 用的是 `<exe>/.minecraft`——两个不同的目录。
|
||||
//!
|
||||
//! **本机没有装官方启动器**(`paths::standard_game_home()` 解析出的路径不存在),所以这一组的
|
||||
//! 每个根都是**临时目录里造出来的夹具**:`<root>/.minecraft/versions/A/A.json`。这不是缺陷,
|
||||
//! 是环境事实;「真的去读 `%APPDATA%`」那一步**未验**(见报告)。
|
||||
//!
|
||||
//! | 用例 | 抓得住什么 | 抓不住什么 |
|
||||
//! |---|---|---|
|
||||
//! | [`the_same_version_in_two_roots_is_two_instances`] | 两个根里的同名版本 = **2 条**磁贴、id 不同、各自指向自己的根、名字分辨得出来 | 真实官方安装的目录形状(`launcher_profiles.json` / 加密的账户文件一概不读) |
|
||||
//! | [`a_junction_to_the_configured_root_is_not_a_second_root`] | 同一路径的第二种写法(junction / 符号链接)**不许**让同一批实例列两遍 | 8.3 短名(用例盘上造不出来;判据靠 `canonicalize` 的长名展开,见 `roots::key_of` 的文档) |
|
||||
//! | [`saves_come_from_the_instance_own_root`] | 每个实例的存档 / 截图列表只来自**它自己那个根**(不许把外部实例的存档当成当前根的) | 游戏进程正在跑时的文件锁 / 半写状态(未处理,见报告) |
|
||||
//! | [`discovery_writes_nothing_into_the_official_root`] | 只读发现:`instances()` + 列存档前后,官方根里一个条目都不许多 | 「官方启动器同时也在写」的并发(TOCTOU 本来就看不到) |
|
||||
//! | [`a_lazily_created_profile_stays_on_its_own_root`] | 懒创建档案(用户第一次改设置)之后 id → 根的反查不退化,且没把配置根的同名版本一并抹掉 | 档案里**没有**根字段这一事实本身(根只从 id 反查,见 `discovered::locate`) |
|
||||
//! | [`the_launch_reads_the_manifest_from_the_instance_root`] | 启动装配的清单 / 运行目录走实例自己的根 | 真的把游戏拉起来(要真 jvm / 账号 / 网络) |
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use unlucky_core::game::GameLayout;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 临时根(每个用例一个目录;断言 panic 时也会删)。
|
||||
mod temp {
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
pub struct Root(pub PathBuf);
|
||||
|
||||
impl Root {
|
||||
pub fn new(tag: &str) -> Self {
|
||||
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);
|
||||
std::fs::create_dir_all(&path).expect("建临时目录");
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Root {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(Path::new(&self.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// 目录下所有条目(排序后比较,用来做「读盘前后」快照)。
|
||||
pub fn entries_under(dir: &Path) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![dir.to_path_buf()];
|
||||
while let Some(current) = stack.pop() {
|
||||
let Ok(read) = std::fs::read_dir(¤t) else {
|
||||
continue;
|
||||
};
|
||||
for entry in read.flatten() {
|
||||
let path = entry.path();
|
||||
if let Ok(relative) = path.strip_prefix(dir) {
|
||||
out.push(relative.to_string_lossy().replace('\\', "/"));
|
||||
}
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// 造两个根:配置根 = `<root>/.minecraft`,官方位置 = `<root>/official/.minecraft`。
|
||||
fn two_roots(tag: &str) -> (temp::Root, HostEnv) {
|
||||
let root = temp::Root::new(tag);
|
||||
let mut env = HostEnv::at_root(&root.0);
|
||||
let official = root.0.join("official").join(".minecraft");
|
||||
std::fs::create_dir_all(&official).expect("官方根目录");
|
||||
env.standard_game = Some(official);
|
||||
(root, env)
|
||||
}
|
||||
|
||||
/// 官方根(=第二个候选根)的**字面**路径。
|
||||
fn official(env: &HostEnv) -> PathBuf {
|
||||
env.standard_game.clone().expect("用例里一定钉了官方根")
|
||||
}
|
||||
|
||||
fn write_version(game: &GameLayout, id: &str) {
|
||||
let path = game.version_json(id);
|
||||
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录");
|
||||
std::fs::write(&path, format!(r#"{{"id":"{id}"}}"#)).expect("写版本 JSON");
|
||||
}
|
||||
|
||||
/// 世界目录(`<根>/saves/<name>/level.dat`)。
|
||||
fn write_world(root: &Path, name: &str) {
|
||||
let dir = root.join("saves").join(name);
|
||||
std::fs::create_dir_all(&dir).expect("世界目录");
|
||||
std::fs::write(dir.join("level.dat"), b"fixture").expect("level.dat");
|
||||
}
|
||||
|
||||
/// 目录链接(Windows 用 junction:不需要管理员;其它平台符号链接)。造不出来返回 `false`。
|
||||
#[cfg(windows)]
|
||||
fn link_dir(link: &Path, target: &Path) -> bool {
|
||||
std::process::Command::new("cmd")
|
||||
.args(["/c", "mklink", "/J"])
|
||||
.arg(link)
|
||||
.arg(target)
|
||||
.status()
|
||||
.map(|status| status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn link_dir(link: &Path, target: &Path) -> bool {
|
||||
std::os::unix::fs::symlink(target, link).is_ok()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 判据①:两个根各一条
|
||||
|
||||
/// 两个临时根各放一个同名实例(`versions/A/A.json`)⇒ 列表出 **2 条**,各自指向自己的根。
|
||||
#[test]
|
||||
fn the_same_version_in_two_roots_is_two_instances() {
|
||||
let (_root, env) = two_roots("ull-host-multiroot-two");
|
||||
write_version(&env.game, "A");
|
||||
write_version(&GameLayout::new(official(&env)), "A");
|
||||
|
||||
let snapshot = instances(&env);
|
||||
assert_eq!(
|
||||
snapshot.len(),
|
||||
2,
|
||||
"两个根里的同名版本必须是 2 条:{snapshot:?}"
|
||||
);
|
||||
let ids: std::collections::BTreeSet<&str> =
|
||||
snapshot.iter().map(|tile| tile.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
ids.len(),
|
||||
2,
|
||||
"两个根的 id 撞了(哈希没喂根?):{snapshot:?}"
|
||||
);
|
||||
|
||||
for tile in &snapshot {
|
||||
assert_eq!(tile.version_id, "A");
|
||||
assert!(
|
||||
unlucky_core::platform::layout::is_valid_instance_id(&tile.id),
|
||||
"合成的实例 id 不合法:{}",
|
||||
tile.id
|
||||
);
|
||||
let game = game_for(&env, &tile.id);
|
||||
assert!(
|
||||
game.root() == env.game.root() || game.root() == official(&env),
|
||||
"实例 {} 的根既不是配置根也不是官方根:{}",
|
||||
tile.id,
|
||||
game.root().display()
|
||||
);
|
||||
}
|
||||
|
||||
// 两条磁贴的**名字**必须分辨得出来(否则用户不知道点哪一条,而两条的存档在不同目录里)。
|
||||
assert_ne!(
|
||||
snapshot[0].name, snapshot[1].name,
|
||||
"两条磁贴同名,用户无从分辨:{snapshot:?}"
|
||||
);
|
||||
assert!(
|
||||
snapshot.iter().any(|tile| tile.name == "A"),
|
||||
"配置根里的那条仍叫版本目录名(绝大多数用户看到的就是它):{snapshot:?}"
|
||||
);
|
||||
let other = snapshot
|
||||
.iter()
|
||||
.find(|tile| tile.name != "A")
|
||||
.expect("另一条存在");
|
||||
assert!(
|
||||
other.name.contains("official"),
|
||||
"另一条的名字得带上它的根(现在是 {})",
|
||||
other.name
|
||||
);
|
||||
|
||||
// 同一个 (根, 版本) 反查稳定:换一次快照,id 不变(懒创建幂等的前提)。
|
||||
let again = instances(&env);
|
||||
assert_eq!(
|
||||
again.iter().map(|tile| tile.id.clone()).collect::<Vec<_>>(),
|
||||
snapshot
|
||||
.iter()
|
||||
.map(|tile| tile.id.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
"同一批盘上内容两次快照给了不同 id(跨重启就会长出第二条档案)"
|
||||
);
|
||||
}
|
||||
|
||||
/// **存在但是空的**官方根(本机的真实形状)⇒ 一条实例都不许多,也不许报错。
|
||||
///
|
||||
/// 这条不是假想:开发机上 `%APPDATA%\.minecraft` **目录是在的**,里面只有一个空的 `cache/`
|
||||
/// (没有 `versions/`、没有 `launcher_profiles.json`)。判据必须把「有个空目录」与「装了官方启动器」
|
||||
/// 分开——否则用户会看到一条来路不明的实例(或者实例页整页报错)。
|
||||
#[test]
|
||||
fn an_empty_official_root_adds_no_instances() {
|
||||
let (_root, env) = two_roots("ull-host-multiroot-empty");
|
||||
write_version(&env.game, "A");
|
||||
std::fs::create_dir_all(official(&env).join("cache")).expect("cache 目录");
|
||||
|
||||
let snapshot = instances(&env);
|
||||
assert_eq!(snapshot.len(), 1, "空的官方根不该长出实例:{snapshot:?}");
|
||||
assert_eq!(snapshot[0].version_id, "A");
|
||||
assert_eq!(
|
||||
game_for(&env, &snapshot[0].id).root(),
|
||||
env.game.root(),
|
||||
"唯一那条属于配置根"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- 判据②:同一路径的两种写法只算一个根
|
||||
|
||||
/// 让两个路径指向同一个目录(junction / 符号链接)⇒ 列表只能出 **1 条**。
|
||||
#[test]
|
||||
fn a_junction_to_the_configured_root_is_not_a_second_root() {
|
||||
let root = temp::Root::new("ull-host-multiroot-dedupe");
|
||||
let mut env = HostEnv::at_root(&root.0);
|
||||
write_version(&env.game, "A");
|
||||
let link = root.0.join("linked");
|
||||
if !link_dir(&link, env.game.root()) {
|
||||
// 造不出链接就**不假绿**:这一条判据在造不出链接的盘上等于没验。
|
||||
eprintln!("跳过:这个环境造不出目录链接({})", link.display());
|
||||
return;
|
||||
}
|
||||
env.standard_game = Some(link);
|
||||
assert_eq!(
|
||||
super::roots::all(&env).len(),
|
||||
1,
|
||||
"联接与配置根是同一个物理目录,不该算两个根"
|
||||
);
|
||||
let snapshot = instances(&env);
|
||||
assert_eq!(
|
||||
snapshot.len(),
|
||||
1,
|
||||
"同一批实例列了两遍(去重没落到真实路径上):{snapshot:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- 判据③:存档按各自的根解析
|
||||
|
||||
/// 两个根各一个同名实例 + 各一个世界 ⇒ 每条实例的存档列表只来自**它自己那个根**。
|
||||
#[test]
|
||||
fn saves_come_from_the_instance_own_root() {
|
||||
let (_root, env) = two_roots("ull-host-multiroot-saves");
|
||||
write_version(&env.game, "A");
|
||||
write_version(&GameLayout::new(official(&env)), "A");
|
||||
write_world(env.game.root(), "configured-world");
|
||||
write_world(&official(&env), "official-world");
|
||||
|
||||
let snapshot = instances(&env);
|
||||
assert_eq!(snapshot.len(), 2, "{snapshot:?}");
|
||||
let mut seen: Vec<PathBuf> = Vec::new();
|
||||
for tile in &snapshot {
|
||||
let dir = super::super::saves::run_dir(&env, tile).expect("派生运行目录");
|
||||
let view = super::super::saves::snapshot(tile, &dir, None).expect("列存档");
|
||||
let worlds: Vec<&str> = view.worlds.iter().map(|world| world.dir.as_str()).collect();
|
||||
if dir == env.game.root() {
|
||||
assert_eq!(
|
||||
worlds,
|
||||
vec!["configured-world"],
|
||||
"配置根那条读串了:{view:?}"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(dir, official(&env), "外部实例的运行目录应当是它自己的根");
|
||||
assert_eq!(worlds, vec!["official-world"], "官方根那条读串了:{view:?}");
|
||||
}
|
||||
seen.push(dir);
|
||||
}
|
||||
assert_ne!(
|
||||
seen[0], seen[1],
|
||||
"两个根的实例派生出同一个运行目录(存档会互相覆盖)"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 判据④:只读发现(官方根零写入)
|
||||
|
||||
/// 浏览(列实例 + 列存档)不许在官方根里留下任何东西,也不许凭空写 `profiles.json`。
|
||||
#[test]
|
||||
fn discovery_writes_nothing_into_the_official_root() {
|
||||
let (_root, env) = two_roots("ull-host-multiroot-readonly");
|
||||
write_version(&env.game, "A");
|
||||
write_version(&GameLayout::new(official(&env)), "A");
|
||||
write_world(&official(&env), "official-world");
|
||||
|
||||
let before = temp::entries_under(&official(&env));
|
||||
let snapshot = instances(&env);
|
||||
for tile in &snapshot {
|
||||
if let Ok(dir) = super::super::saves::run_dir(&env, tile) {
|
||||
let _ = super::super::saves::snapshot(tile, &dir, None);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
before,
|
||||
temp::entries_under(&official(&env)),
|
||||
"读一次实例列表 / 存档就在官方启动器的目录里多出了东西"
|
||||
);
|
||||
let profiles = read_profiles(&env.layout).expect("读档案");
|
||||
assert!(
|
||||
profiles.profiles.is_empty(),
|
||||
"浏览不该懒创建档案:{:?}",
|
||||
profiles.profiles
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------- 判据⑤:懒创建的档案仍认自己那个根(不吞同名)
|
||||
|
||||
/// 用户第一次给官方根里的实例改设置 ⇒ 懒创建档案,但:
|
||||
/// 1. id → 根的反查**不退化**(存档 / 模组 / 启动还读官方根);
|
||||
/// 2. 配置根里那个同名版本**还在**(它是另一个实例,档案不该连它一起吞掉)。
|
||||
#[test]
|
||||
fn a_lazily_created_profile_stays_on_its_own_root() {
|
||||
let (_root, env) = two_roots("ull-host-multiroot-lazy");
|
||||
write_version(&env.game, "A");
|
||||
write_version(&GameLayout::new(official(&env)), "A");
|
||||
// 官方根里那个版本带 `mods/`(「版本隔离」形态)⇒ 基底档是 isolated,换根之后不许变。
|
||||
std::fs::create_dir_all(
|
||||
GameLayout::new(official(&env))
|
||||
.version_dir("A")
|
||||
.join("mods"),
|
||||
)
|
||||
.expect("mods 目录");
|
||||
|
||||
let snapshot = instances(&env);
|
||||
let tile = snapshot
|
||||
.iter()
|
||||
.find(|tile| tile.name.contains("official"))
|
||||
.expect("官方根那条在列表里")
|
||||
.clone();
|
||||
let game = game_for(&env, &tile.id);
|
||||
assert_eq!(game.root(), official(&env), "反查没落到官方根上");
|
||||
assert_eq!(
|
||||
isolation_of(&env, &game, &tile.id, "A"),
|
||||
"isolated",
|
||||
"官方根里 `versions/A/mods/` 是「自带数据」的痕迹 ⇒ 独立目录"
|
||||
);
|
||||
|
||||
set_isolation(&env, &tile.id, "shared").expect("第一次改档位 = 懒创建档案");
|
||||
assert_eq!(
|
||||
game_for(&env, &tile.id).root(),
|
||||
official(&env),
|
||||
"懒创建之后反查退回了配置根(存档会读串)"
|
||||
);
|
||||
|
||||
let after = instances(&env);
|
||||
assert_eq!(
|
||||
after.len(),
|
||||
2,
|
||||
"懒创建之后配置根那个同名版本被档案吞掉了:{after:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
after
|
||||
.iter()
|
||||
.filter(|tile| tile.id == tile_id(&snapshot, "official"))
|
||||
.count(),
|
||||
1,
|
||||
"懒创建之后官方根那条被列了两遍(档案 + 合成视图)"
|
||||
);
|
||||
// 档案里的 version_id 只是版本名,**根只从 id 反查**——这条用例钉住的就是这一点。
|
||||
let profiles = read_profiles(&env.layout).expect("读档案");
|
||||
assert_eq!(profiles.profiles.len(), 1);
|
||||
assert_eq!(profiles.profiles[0].version_id, "A");
|
||||
}
|
||||
|
||||
/// 列表里「名字带 `needle` 的那一条」的 id(懒创建前后的同一个实例)。
|
||||
fn tile_id(snapshot: &[crate::dto::InstanceSummary], needle: &str) -> String {
|
||||
snapshot
|
||||
.iter()
|
||||
.find(|tile| tile.name.contains(needle))
|
||||
.expect("那一条在列表里")
|
||||
.id
|
||||
.clone()
|
||||
}
|
||||
|
||||
// ------------------------------------------------ 判据⑥:启动装配按实例自己的根读清单
|
||||
|
||||
/// 清单**只在官方根里** ⇒ 装配仍要成功,且 `gameDir` 是官方根(共用目录档)。
|
||||
#[test]
|
||||
fn the_launch_reads_the_manifest_from_the_instance_root() {
|
||||
let (_root, env) = two_roots("ull-host-multiroot-launch");
|
||||
std::fs::create_dir_all(env.layout.config()).expect("config 目录");
|
||||
write_version(&GameLayout::new(official(&env)), "A");
|
||||
|
||||
let snapshot = instances(&env);
|
||||
assert_eq!(snapshot.len(), 1, "只有官方根里有版本:{snapshot:?}");
|
||||
let tile = &snapshot[0];
|
||||
let target = super::super::launch::LaunchTarget {
|
||||
id: tile.id.clone(),
|
||||
name: tile.name.clone(),
|
||||
version_id: "A".to_string(),
|
||||
account: None,
|
||||
};
|
||||
let (request, _warnings) =
|
||||
super::super::launch::build_request(&env, &target).expect("清单在官方根里,装配应当成功");
|
||||
assert_eq!(
|
||||
request.dirs.game_dir,
|
||||
official(&env),
|
||||
"共用目录档的运行目录应当是**它自己那个根**(不是 ULL 便携根)"
|
||||
);
|
||||
assert_eq!(request.instance.id, tile.id);
|
||||
}
|
||||
@@ -143,14 +143,14 @@ fn external_instance_writes_nothing_until_first_edit() {
|
||||
|
||||
let id = snapshot[0].id.clone();
|
||||
assert_eq!(
|
||||
isolation_of(&env, &id, "A"),
|
||||
isolation_of(&env, &env.game, &id, "A"),
|
||||
"shared",
|
||||
"`versions/A/` 里没有 mods / saves / config / options.txt ⇒ 基底档是 shared(按共用目录读)"
|
||||
);
|
||||
|
||||
set_isolation(&env, &id, "isolated").expect("第一次改档位 = 懒创建档案");
|
||||
assert_eq!(
|
||||
isolation_of(&env, &id, "A"),
|
||||
isolation_of(&env, &env.game, &id, "A"),
|
||||
"isolated",
|
||||
"实例文件里写下的档位优先于基底档"
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user