Files
Unlucky-Launcher/crates/core/src/game/adopt/candidate.rs
T
AskaEth 9f5c001387 feat(game): 从其他启动器只读登记已有版本(ull instances scan|adopt)+ 登记可一并定隔离档(M221)
盘上已经铺好的版本(多半是 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`)。
2026-11-09 22:21:37 +08:00

277 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 登记的**数据形状**:候选、结构化失败、运行目录选择。
//!
//! 三种形状刻意分开:
//! - [`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,
}
}
}