盘上已经铺好的版本(多半是 PCL/HMCL 装的)今天在 ULL 里看不见,用户只能重装一遍。这个 commit 把它们登记成实例,**只读外部、只写自己的配置**:不搬文件、不补清单、不改任何外部 文件的 mtime(versions/ libraries/ assets/ 一个字节都不写)。 core(`game::adopt`,新模块): - `scan` / `inspect`:只读扫描 + 逐项检查,坏 JSON(带行列号)/ 缺 id / id 与目录名不一致 / 缺父 / 成环 / 读不出来 / 越界 / 同名冲突一律**结构化**报错(稳定 `code`),不吞成空列表; `versions/` 不存在才是空结果,读不了就是错误; - 越界判据用 canonicalize 后的前缀比较,同时覆盖**符号链接、Windows 目录联接(junction)与 祖先链接**(junction 不需要管理员权限,是最常见的「把别人版本目录挂进来」);越界条目在 边界检查就停手,一个字节都不去读; - `plan`(零写盘,喂 dry-run 与后续 GUI 预检)/ `adopt`(plan + 只写自己的配置): **缺省运行目录落 ULL 数据区 `<data>/instances/<实例目录名>`(custom 档),绝不沿用全局 `isolated`**——那条缺省的运行目录正是 `versions/<versionId>/`,会在他人的版本目录里长出 mods/saves/config;`shared` / `isolated` 必须显式选,且 `target_notes()` 给出后果话术; - 缺省运行目录已存在且非空 ⇒ 拒绝(不覆盖、不猜「合并进已有实例」);数据区里已有的祖先 若解析到数据根之外(符号链接/联接)⇒ 拒绝,不沿它写盘; - 登记与隔离档一次写清:`install/instance` 拆出 `register` 子模块,新增 `register_with(InstanceSettings)`;写盘顺序改成「先实例设置、后 profiles」(中断只留孤立 设置文件,不会留下按全局档启动的实例),profiles 写失败会撤掉刚写的设置。`register` 签名与行为不变(`register` = `register_with(inherit)`;同版本同名的幂等复用照旧)。 CLI: - `ull instances scan`(只读列出候选与原因)、`ull instances adopt <version-id> [--name N] [--isolation shared|isolated|custom] [--game-dir DIR] [--dry-run]`;`--game-dir` 单独给即 custom,相对路径在解析期就报用法错误; - 人读/`--json`/help 三条出口齐备(`error.detail.problems` 逐条可分流),已有命令未改。 测试(真磁盘夹具,`cargo test -p unlucky-core --test adopt` 22 passed / `cargo test -p unlucky-cli --test adopt_test` 10 passed): - 一份「别的启动器铺好的」`.minecraft`(versions/libraries/assets/mods/saves/config + launcher_profiles.json),扫描与登记前后做「路径集合 + 字节数 + 内容哈希 + mtime」全等断言; - 正常 / 缺清单 / 坏 JSON / 缺父 / 成环 / id 不匹配 / 非法 id / 符号链接越界 / 根内符号链接 放行 / **junction 越界** / 数据区 junction 写盘边界 / 缺省非 isolated / shared 与 isolated 的话术 / 重名 / 重复 / 非空目录 / 空目录复用 / dry-run 零写盘; - CLI 侧验 `launch --dry-run` 的 `--gameDir` 指向选定运行目录(argv 已脱敏,比尾段; `data.game_dir` 全等); - 变异核对(只改实现、跑完即回滚):缺省档改 `VersionDir` ⇒ 红;边界判据摘掉 ⇒ 符号链接与 junction 两条红;写盘边界摘掉 ⇒ 数据区 junction 那条红;缺父当链终点 ⇒ 两条红。 界限:没有启动真实 Minecraft;GUI(presenter/app-iced)接线另派;D20 单游戏根模型未改 (不扫第二个 `.minecraft`)。
277 lines
11 KiB
Rust
277 lines
11 KiB
Rust
//! 登记的**数据形状**:候选、结构化失败、运行目录选择。
|
||
//!
|
||
//! 三种形状刻意分开:
|
||
//! - [`AdoptCandidate`]:盘上的一个 `versions/<id>/` 是不是能登记的版本(含逐项问题);
|
||
//! - [`AdoptProblem`]:一条**结构化**的不合格原因(有稳定 `code`,人读 `message`);
|
||
//! - [`AdoptTarget`]:登记后运行目录从哪来(隔离档由它决定);
|
||
//! - 整次调用的失败形状在 [`super::error::AdoptError`]。
|
||
//!
|
||
//! 为什么不把问题塞进 [`crate::game::GameError`]:`core::game` 的错误是「解析/路径/IO」这一层,
|
||
//! 而这里是「这个版本对我们而言能不能登记」的业务判据(缺父、越界、同名冲突…)。
|
||
//! 混进 `GameError` 会让所有现存 `match` 多出一批只在本模块有意义的变体。
|
||
|
||
use std::fmt;
|
||
use std::path::PathBuf;
|
||
|
||
use super::super::install::instance::Isolation;
|
||
|
||
/// 候选版本的**结构化**不合格原因(每一条都带稳定机读 `code`)。
|
||
///
|
||
/// 判据来源:`DevDocs/artifacts/m218/SURVEY-p4.md` §3 的 copy/引用/拒绝表。任一 `Err` 都意味着
|
||
/// 「登记了也启动不了」或「越界/踩别人的目录」,因此登记前一律拒绝,并把原因原样说给用户。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum AdoptProblem {
|
||
/// `versions/<id>/` 存在但没有同名清单(只剩 natives / 上个启动器的残留)。
|
||
ManifestMissing {
|
||
/// 版本 id(目录名)。
|
||
version_id: String,
|
||
/// 期望的清单路径 `versions/<id>/<id>.json`。
|
||
path: PathBuf,
|
||
},
|
||
/// 版本 id 本身不能当路径用(空 / 含分隔符 / 盘符 / NUL / 结尾点号空格)。
|
||
VersionIdInvalid {
|
||
/// 原始 id。
|
||
version_id: String,
|
||
/// 人读原因。
|
||
reason: &'static str,
|
||
},
|
||
/// 清单文件读不出来(权限 / 被占用 / 不是 UTF-8)。
|
||
ManifestUnreadable {
|
||
/// 清单路径。
|
||
path: PathBuf,
|
||
/// 底层错误文本(含 Win32 错误码或 serde 行列号)。
|
||
message: String,
|
||
},
|
||
/// 清单不是合法 JSON(**带行号列号**,别只说「坏了」)。
|
||
ManifestInvalidJson {
|
||
/// 清单路径。
|
||
path: PathBuf,
|
||
/// serde_json 的错误文本(含 `line L column C`)。
|
||
message: String,
|
||
},
|
||
/// 清单里没有 `id`。
|
||
ManifestMissingId {
|
||
/// 清单路径。
|
||
path: PathBuf,
|
||
},
|
||
/// 清单里的 `id` 与目录名不一致(启动时按清单 id 找 jar,必然找不到)。
|
||
ManifestIdMismatch {
|
||
/// 清单路径。
|
||
path: PathBuf,
|
||
/// 清单里的 id。
|
||
manifest_id: String,
|
||
/// 目录名。
|
||
directory_id: String,
|
||
},
|
||
/// 目录遍历/元数据读不出来(权限、坏链接)。
|
||
DirectoryUnreadable {
|
||
/// 出问题的路径。
|
||
path: PathBuf,
|
||
/// 底层错误文本。
|
||
message: String,
|
||
},
|
||
/// 已有条目解析到游戏根之外(符号链接 / 目录联接 / 祖先链接;不跟随)。
|
||
OutsideGameRoot {
|
||
/// 链接本身。
|
||
path: PathBuf,
|
||
/// 解析出来的真实目标。
|
||
target: PathBuf,
|
||
/// 比较基准(真实化后的游戏根)。
|
||
root: PathBuf,
|
||
},
|
||
/// 是符号链接但解析不出来(断链 / 权限)。
|
||
SymlinkUnresolvable {
|
||
/// 链接本身。
|
||
path: PathBuf,
|
||
/// 底层错误文本。
|
||
message: String,
|
||
},
|
||
/// `inheritsFrom` 指向的父版本在盘上找不到。
|
||
MissingParent {
|
||
/// 缺失的父版本 id。
|
||
parent: String,
|
||
/// 谁在要它。
|
||
required_by: String,
|
||
},
|
||
/// `inheritsFrom` 成环。
|
||
CircularInheritance {
|
||
/// 从根到环的 id 链(首尾同一个 id)。
|
||
chain: Vec<String>,
|
||
},
|
||
/// 继承链在 `core::game::resolve` 那里失败(走查通过、权威解析不通过的兜底)。
|
||
InheritanceInvalid {
|
||
/// `GameError` 的人读文本。
|
||
detail: String,
|
||
},
|
||
}
|
||
|
||
impl AdoptProblem {
|
||
/// 稳定机读码(`--json` 的 `problems[].code`)。
|
||
#[must_use]
|
||
pub const fn code(&self) -> &'static str {
|
||
match self {
|
||
Self::ManifestMissing { .. } => "manifest_missing",
|
||
Self::VersionIdInvalid { .. } => "version_id_invalid",
|
||
Self::ManifestUnreadable { .. } => "manifest_unreadable",
|
||
Self::ManifestInvalidJson { .. } => "manifest_invalid_json",
|
||
Self::ManifestMissingId { .. } => "manifest_missing_id",
|
||
Self::ManifestIdMismatch { .. } => "manifest_id_mismatch",
|
||
Self::DirectoryUnreadable { .. } => "directory_unreadable",
|
||
Self::OutsideGameRoot { .. } => "outside_game_root",
|
||
Self::SymlinkUnresolvable { .. } => "symlink_unresolvable",
|
||
Self::MissingParent { .. } => "missing_parent",
|
||
Self::CircularInheritance { .. } => "circular_inheritance",
|
||
Self::InheritanceInvalid { .. } => "inheritance_invalid",
|
||
}
|
||
}
|
||
}
|
||
|
||
impl fmt::Display for AdoptProblem {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
match self {
|
||
Self::ManifestMissing { version_id, path } => write!(
|
||
f,
|
||
"版本 {version_id} 没有清单文件(期望 {})",
|
||
path.display()
|
||
),
|
||
Self::VersionIdInvalid { version_id, reason } => {
|
||
write!(f, "版本 id 不能当路径用({reason}):{version_id:?}")
|
||
}
|
||
Self::ManifestUnreadable { path, message } => {
|
||
write!(f, "清单读不出来 {}:{message}", path.display())
|
||
}
|
||
Self::ManifestInvalidJson { path, message } => {
|
||
write!(f, "清单不是合法 JSON {}:{message}", path.display())
|
||
}
|
||
Self::ManifestMissingId { path } => {
|
||
write!(f, "清单缺少 id 字段:{}", path.display())
|
||
}
|
||
Self::ManifestIdMismatch {
|
||
path,
|
||
manifest_id,
|
||
directory_id,
|
||
} => write!(
|
||
f,
|
||
"清单 id({manifest_id})与目录名({directory_id})不一致:{}——启动时会按清单 id 找 jar,必然找不到",
|
||
path.display()
|
||
),
|
||
Self::DirectoryUnreadable { path, message } => {
|
||
write!(f, "目录读不出来 {}:{message}", path.display())
|
||
}
|
||
Self::OutsideGameRoot { path, target, root } => write!(
|
||
f,
|
||
"条目解析到游戏根之外,不跟随:{} -> {}(游戏根 {})",
|
||
path.display(),
|
||
target.display(),
|
||
root.display()
|
||
),
|
||
Self::SymlinkUnresolvable { path, message } => {
|
||
write!(f, "符号链接解析失败 {}:{message}", path.display())
|
||
}
|
||
Self::MissingParent { parent, required_by } => {
|
||
write!(f, "版本 {required_by} 继承的父版本 {parent} 不在盘上")
|
||
}
|
||
Self::CircularInheritance { chain } => {
|
||
write!(f, "版本继承成环:{}", chain.join(" -> "))
|
||
}
|
||
Self::InheritanceInvalid { detail } => write!(f, "继承链解析失败:{detail}"),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 已经绑定某个版本 id 的实例档案(只带展示需要的最小信息,不泄露内部结构)。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct RegisteredInstance {
|
||
/// 实例 id。
|
||
pub id: String,
|
||
/// 实例展示名。
|
||
pub name: String,
|
||
}
|
||
|
||
/// 盘上的一个候选版本(扫出来的一个 `versions/<id>/`)。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct AdoptCandidate {
|
||
/// 版本 id(= 目录名,登记后写进 `profiles.json` 的 `version_id`)。
|
||
pub version_id: String,
|
||
/// 版本目录 `versions/<id>/`。
|
||
pub version_dir: PathBuf,
|
||
/// 清单路径 `versions/<id>/<id>.json`。
|
||
pub manifest_path: PathBuf,
|
||
/// 客户端 jar `versions/<id>/<id>.jar` 是否存在(只报事实,不校验内容)。
|
||
pub jar_present: bool,
|
||
/// 清单里声明的 id(读不出来时为 `None`)。
|
||
pub manifest_id: Option<String>,
|
||
/// `inheritsFrom` 链(自身在前、最祖先在后;读不出来时是已走通的那一段)。
|
||
pub inherits: Vec<String>,
|
||
/// 已有实例绑定这个版本(空 = 还没登记过)。
|
||
pub registered: Vec<RegisteredInstance>,
|
||
/// 问题清单(空 = 可登记)。
|
||
pub problems: Vec<AdoptProblem>,
|
||
}
|
||
|
||
impl AdoptCandidate {
|
||
/// 是否可登记(没有任何问题)。
|
||
#[must_use]
|
||
pub fn adoptable(&self) -> bool {
|
||
self.problems.is_empty()
|
||
}
|
||
|
||
/// 已登记过(至少有一个实例绑定这个版本 id)。
|
||
#[must_use]
|
||
pub fn is_registered(&self) -> bool {
|
||
!self.registered.is_empty()
|
||
}
|
||
}
|
||
|
||
/// 登记后实例的**运行目录**从哪来(隔离档由它决定,见 [`AdoptTarget::isolation`])。
|
||
///
|
||
/// 缺省是 [`Self::InstanceDir`]——**不是** `isolated`:理由见模块文档。
|
||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||
pub enum AdoptTarget {
|
||
/// 缺省:ULL 数据区里新建独立运行目录 `<data>/instances/<实例目录名>`(写 `custom` 档)。
|
||
#[default]
|
||
InstanceDir,
|
||
/// 共用当前游戏根(别人的 `.minecraft`)——必须显式选,且必须告知后果。
|
||
Shared,
|
||
/// 版本目录 `versions/<id>/`(`isolated` 档)——会在别人的版本目录里长玩家数据。
|
||
VersionDir,
|
||
/// 用户指定的目录(`custom` 档,必须绝对路径)。
|
||
Custom(PathBuf),
|
||
}
|
||
|
||
impl AdoptTarget {
|
||
/// 稳定机读 id(`--json` 的 `run_dir_source`)。
|
||
#[must_use]
|
||
pub const fn as_str(&self) -> &'static str {
|
||
match self {
|
||
Self::InstanceDir => "instance_dir",
|
||
Self::Shared => "shared",
|
||
Self::VersionDir => "version_dir",
|
||
Self::Custom(_) => "custom",
|
||
}
|
||
}
|
||
|
||
/// 生效隔离档。
|
||
#[must_use]
|
||
pub const fn isolation(&self) -> Isolation {
|
||
match self {
|
||
Self::InstanceDir | Self::Custom(_) => Isolation::Custom,
|
||
Self::Shared => Isolation::Shared,
|
||
Self::VersionDir => Isolation::Isolated,
|
||
}
|
||
}
|
||
|
||
/// 命令行/配置里的词表(`shared` / `isolated` / `custom`,与 [`Isolation`] 同一套字面量)。
|
||
///
|
||
/// `instance_dir` 有意**不接受**输入:它是缺省值,不是一个用户可以手写选中的档位。
|
||
#[must_use]
|
||
pub fn parse(raw: &str, game_dir: Option<PathBuf>) -> Option<Self> {
|
||
match raw.trim() {
|
||
"shared" => Some(Self::Shared),
|
||
"isolated" | "per_version" => Some(Self::VersionDir),
|
||
"custom" => game_dir.map(Self::Custom),
|
||
_ => None,
|
||
}
|
||
}
|
||
}
|