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`)。
This commit is contained in:
1 parent
0577ee87a3
commit
9f5c001387
25 files changed
+3460
-73
No files matched your search
@@ -0,0 +1,54 @@
|
||||
//! 登记「已经铺在盘上的版本」(从其他启动器的 `.minecraft` 只读接入,M221)。
|
||||
//!
|
||||
//! 依据:`DevDocs/artifacts/m218/SURVEY-p4.md`(④ 从其他启动器导入数据)与
|
||||
//! `DevDocs/00-债务台账.md` §21 的第 ④ 条;模型口径仍按 D20(实例隔离只改**运行目录**,
|
||||
//! `versions/` `assets/` `libraries/` 恒共享,见 [`crate::game::isolation::SHARED_LAYERS`])。
|
||||
//!
|
||||
//! # 一句话纪律
|
||||
//!
|
||||
//! **登记只读外部、只写自己**:本模块从不往游戏根写一个字节(不搬文件、不补清单、不改 mtime),
|
||||
//! 唯一的写盘是 `profiles.json` + `config/instances/<id>.json`(+ 缺省运行目录的准备,若选了它)。
|
||||
//! 因此「导入前后对 `versions/` `libraries/` 做全量哈希必须完全不变」是可以用测试钉死的事实。
|
||||
//!
|
||||
//! # 为什么要有它(现状的洞)
|
||||
//!
|
||||
//! [`GameLayout::installed_versions`] 早就会扫 `versions/`,但**没有任何代码把扫到的东西变成实例**。
|
||||
//! 于是「别人的 `.minecraft` 已经装好了 1.20.1」这件事在 ULL 里看不见,用户只能重装一遍
|
||||
//! (下载全部资源)。这个模块把那批版本变成实例档案,内容一个字节不搬。
|
||||
//!
|
||||
//! # 缺省隔离档**不能**沿用全局 `isolated`(本模块最要紧的一条)
|
||||
//!
|
||||
//! 全局 `instances.mode` 缺省是 `isolated`,而 `isolated` 的运行目录正是 `versions/<versionId>/`
|
||||
//! ([`crate::game::isolation::derive_game_dir`])。若登记沿用这条缺省,第一次启动就会在
|
||||
//! **别人的版本目录**里长出 `mods/` `saves/` `config/` `logs/` —— 不覆盖任何文件,但与
|
||||
//! 「多启动器共存」的意图相反。所以 [`AdoptTarget`] 的缺省值是
|
||||
//! [`AdoptTarget::InstanceDir`](落 ULL 数据区 `<data>/instances/<实例目录名>`),
|
||||
//! 想共用/写进对方目录必须**显式**选,并且 [`target_notes`] 会把后果说清楚。
|
||||
//!
|
||||
//! # 分层
|
||||
//!
|
||||
//! - `candidate`:候选与逐项**结构化失败**(坏 JSON / 缺父 / 环 / 不可读 / 越界 / 符号链接);
|
||||
//! - `error`:整次调用失败([`AdoptError`],带稳定错误码);
|
||||
//! - `inspect`:只读扫描与逐项检查([`scan`] / [`inspect`]);
|
||||
//! - `paths`:路径判据(版本 id 能不能当路径、符号链接有没有跑出游戏根);
|
||||
//! - `plan`:把候选 + 用户选择算成一次登记的落点([`plan`]),真正落盘是 [`adopt`]。
|
||||
//!
|
||||
//! # 不做什么
|
||||
//!
|
||||
//! - **不读账户凭据**:`launcher_profiles.json` / `launcher_accounts.json` / HMCL 的
|
||||
//! `accounts.json` 一个都不碰(`AGENTS.md` §6 同族纪律);
|
||||
//! - **不做多根**(D20 未改):扫的永远是**当前**游戏根(`core.game_home`),
|
||||
//! 「同时管多个 `.minecraft`」是另一个更贵的模型改动,见 SURVEY-p4 §4.2;
|
||||
//! - **不下载、不校验完整性**:版本 JSON 里 `libraries[].downloads.artifact.url` 指向 maven
|
||||
//! 中心是正常形态,登记不因此拒绝,但也不假装校验过(人读输出里如实写成「未校验」)。
|
||||
|
||||
pub mod candidate;
|
||||
mod error;
|
||||
mod inspect;
|
||||
mod paths;
|
||||
mod plan;
|
||||
|
||||
pub use candidate::{AdoptCandidate, AdoptProblem, AdoptTarget, RegisteredInstance};
|
||||
pub use error::AdoptError;
|
||||
pub use inspect::{inspect, scan, AdoptScan};
|
||||
pub use plan::{adopt, plan, target_notes, AdoptOutcome, AdoptPlan, AdoptRequest};
|
||||
@@ -0,0 +1,276 @@
|
||||
//! 登记的**数据形状**:候选、结构化失败、运行目录选择。
|
||||
//!
|
||||
//! 三种形状刻意分开:
|
||||
//! - [`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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//! 一次登记的失败形状(CLI 据此给稳定错误码 + 结构化 details)。
|
||||
//!
|
||||
//! 与 [`AdoptProblem`] 的分工:`AdoptProblem` 是「这个版本为什么不能登记」(可以多条、逐条列出),
|
||||
//! [`AdoptError`] 是「这次调用为什么没成功」(一条,喂给 `--json` 的 `error.code`)。
|
||||
//! 候选不合格是后者的一个变体,但问题清单原样带出来——不许把原因折叠成一句「导入失败」。
|
||||
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::candidate::AdoptProblem;
|
||||
use crate::game::error::GameError;
|
||||
|
||||
/// 一次登记失败(CLI 据此给稳定错误码 + 结构化 details)。
|
||||
#[derive(Debug)]
|
||||
pub enum AdoptError {
|
||||
/// `core::game` 的解析/路径/IO 失败。
|
||||
Game(GameError),
|
||||
/// 版本 id 在盘上不存在(没有 `versions/<id>/`)。
|
||||
UnknownVersion {
|
||||
/// 请求的版本 id。
|
||||
version_id: String,
|
||||
/// 期望的目录。
|
||||
version_dir: PathBuf,
|
||||
},
|
||||
/// 候选不合格(`problems` 就是全部原因,一条都不吞)。
|
||||
NotAdoptable {
|
||||
/// 版本 id。
|
||||
version_id: String,
|
||||
/// 全部原因。
|
||||
problems: Vec<AdoptProblem>,
|
||||
},
|
||||
/// 展示名已被别的实例占用(**显式报错**,不静默复用一条别的版本)。
|
||||
DuplicateName {
|
||||
/// 冲突的展示名。
|
||||
name: String,
|
||||
/// 占用它的实例 id。
|
||||
instance_id: String,
|
||||
},
|
||||
/// 这个版本已经以同名实例登记过了(重复执行 = 无副作用失败)。
|
||||
AlreadyRegistered {
|
||||
/// 版本 id。
|
||||
version_id: String,
|
||||
/// 已存在的实例名。
|
||||
name: String,
|
||||
/// 已存在的实例 id。
|
||||
instance_id: String,
|
||||
},
|
||||
/// 缺省运行目录已存在且非空(不猜「合并进已有实例」,也不覆盖)。
|
||||
TargetNotEmpty {
|
||||
/// 那个目录。
|
||||
path: PathBuf,
|
||||
},
|
||||
/// 运行目录选择本身不成立(`custom` 缺目录 / 相对路径)。
|
||||
TargetInvalid {
|
||||
/// 人读原因。
|
||||
detail: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl AdoptError {
|
||||
/// 稳定机读码(`--json` 的 `error.code`)。
|
||||
#[must_use]
|
||||
pub const fn code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Game(_) => "adopt_io",
|
||||
Self::UnknownVersion { .. } => "adopt_version_not_found",
|
||||
Self::NotAdoptable { .. } => "adopt_not_adoptable",
|
||||
Self::DuplicateName { .. } => "adopt_duplicate_name",
|
||||
Self::AlreadyRegistered { .. } => "adopt_already_registered",
|
||||
Self::TargetNotEmpty { .. } => "adopt_target_not_empty",
|
||||
Self::TargetInvalid { .. } => "adopt_target_invalid",
|
||||
}
|
||||
}
|
||||
|
||||
/// 候选不合格时的问题清单(其它错误为空)。
|
||||
#[must_use]
|
||||
pub fn problems(&self) -> &[AdoptProblem] {
|
||||
match self {
|
||||
Self::NotAdoptable { problems, .. } => problems,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AdoptError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Game(error) => write!(f, "{error}"),
|
||||
Self::UnknownVersion {
|
||||
version_id,
|
||||
version_dir,
|
||||
} => write!(
|
||||
f,
|
||||
"版本 {version_id} 不在盘上(没有 {})",
|
||||
version_dir.display()
|
||||
),
|
||||
Self::NotAdoptable {
|
||||
version_id,
|
||||
problems,
|
||||
} => {
|
||||
write!(f, "版本 {version_id} 不能登记:")?;
|
||||
for (index, problem) in problems.iter().enumerate() {
|
||||
if index > 0 {
|
||||
f.write_str(";")?;
|
||||
}
|
||||
write!(f, "{problem}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Self::DuplicateName { name, instance_id } => write!(
|
||||
f,
|
||||
"已有同名实例 `{name}`(id {instance_id}):导入时请用 --name 换一个展示名"
|
||||
),
|
||||
Self::AlreadyRegistered {
|
||||
version_id,
|
||||
name,
|
||||
instance_id,
|
||||
} => write!(
|
||||
f,
|
||||
"版本 {version_id} 已经登记过(实例 `{name}`,id {instance_id}):没有改动任何文件"
|
||||
),
|
||||
Self::TargetNotEmpty { path } => {
|
||||
write!(f, "运行目录已存在且非空,不覆盖:{}", path.display())
|
||||
}
|
||||
Self::TargetInvalid { detail } => write!(f, "运行目录选择不成立:{detail}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AdoptError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Game(error) => Some(error),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GameError> for AdoptError {
|
||||
fn from(error: GameError) -> Self {
|
||||
Self::Game(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
//! 只读扫描与逐项检查:`versions/` 下哪些版本能登记、不能登记的原因是什么。
|
||||
//!
|
||||
//! # 三条不许偷懒的判据
|
||||
//!
|
||||
//! 1. **坏东西不能当空列表**:`versions/` 读不了是错误([`AdoptError::Game`]),一个版本的清单坏了是
|
||||
//! 一条 [`AdoptProblem`];两条都不会变成「这里什么都没有」。
|
||||
//! 2. **继承链要走到头**:缺父、成环、父清单坏了、父的清单 id 与目录名不一致,逐条报清楚是哪个文件。
|
||||
//! 3. **不跟随越界符号链接**:`versions/<id>` 或清单是符号链接时,解析出的真实路径必须仍在游戏根内
|
||||
//! (与导出侧「符号链接不跟随」同一政策)。
|
||||
//!
|
||||
//! 全程**只读**:不建目录、不写文件、不改 mtime。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::super::error::GameError;
|
||||
use super::super::inherit;
|
||||
use super::super::install::instance::read_profiles;
|
||||
use super::super::layout::GameLayout;
|
||||
use super::super::manifest::VersionJson;
|
||||
use super::super::profiles::ProfileList;
|
||||
use super::candidate::{AdoptCandidate, AdoptProblem, RegisteredInstance};
|
||||
use super::error::AdoptError;
|
||||
use super::paths::{ensure_inside, root_of, validate_version_id};
|
||||
use crate::platform::layout::LauncherLayout;
|
||||
|
||||
/// 一次扫描的结果(`versions/` 里的全部条目,逐项带问题)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AdoptScan {
|
||||
/// 被扫的目录(`<游戏根>/versions`)。
|
||||
pub versions_dir: PathBuf,
|
||||
/// 候选版本(按版本 id 字典序)。
|
||||
pub candidates: Vec<AdoptCandidate>,
|
||||
}
|
||||
|
||||
/// 扫当前游戏根的 `versions/`,逐个检查。
|
||||
///
|
||||
/// `versions/` **不存在** ⇒ 空结果(新装的数据根,没有可登记的东西,不是错误);
|
||||
/// `versions/` 存在但读不了 ⇒ [`AdoptError::Game`](绝不降级成空列表)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 读目录 / 读 `profiles.json` 失败;条目元数据读不出来(权限)时也直接失败——那属于
|
||||
/// 「环境坏了」而不是「这个版本不能登记」。
|
||||
pub fn scan(game: &GameLayout, launcher: &LauncherLayout) -> Result<AdoptScan, AdoptError> {
|
||||
let profiles = read_profiles(launcher)?;
|
||||
let versions_dir = game.versions_dir();
|
||||
let root = root_of(game);
|
||||
let entries = match std::fs::read_dir(&versions_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(AdoptScan {
|
||||
versions_dir,
|
||||
candidates: Vec::new(),
|
||||
})
|
||||
}
|
||||
Err(error) => return Err(GameError::io(&versions_dir, error).into()),
|
||||
};
|
||||
let mut candidates: Vec<AdoptCandidate> = Vec::new();
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|error| GameError::io(&versions_dir, error))?;
|
||||
let path = entry.path();
|
||||
let Some(version_id) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
// 非 UTF-8 目录名不能当版本 id(进不了 profiles.json),如实记一条问题而不是丢掉。
|
||||
candidates.push(unreadable_id_candidate(&path, &versions_dir));
|
||||
continue;
|
||||
};
|
||||
match std::fs::metadata(&path) {
|
||||
Ok(meta) if meta.is_dir() => {}
|
||||
Ok(_) => continue, // `versions/` 里的普通文件不是版本目录
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
// 断链符号链接:元数据跟不过去,但链接本身在。
|
||||
if std::fs::symlink_metadata(&path).is_ok() {
|
||||
candidates.push(broken_link_candidate(version_id, &path));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(GameError::io(&path, error).into()),
|
||||
}
|
||||
candidates.push(check(game, &root, version_id, &profiles));
|
||||
}
|
||||
candidates.sort_by(|a, b| a.version_id.cmp(&b.version_id));
|
||||
Ok(AdoptScan {
|
||||
versions_dir,
|
||||
candidates,
|
||||
})
|
||||
}
|
||||
|
||||
/// 检查单个版本。
|
||||
///
|
||||
/// 与 [`scan`] 用同一套判据,但**先**校验 id 能不能当路径用(非法 id 连存在性都不该去探,
|
||||
/// 否则 `versions/../../外面的/1.20.1` 这种形状会先撞上「目录真的存在」再报出别的结论)。
|
||||
/// 候选不合格一律 [`AdoptError::NotAdoptable`] 且把问题清单原样带出,只有环境级失败才 `Err`。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`AdoptError::NotAdoptable`]:id 非法,或候选有问题(坏清单 / 缺父 / 环 / 越界…);
|
||||
/// - [`AdoptError::UnknownVersion`]:id 合法但 `versions/<id>/` 不存在;
|
||||
/// - [`AdoptError::Game`]:读 `profiles.json` 失败。
|
||||
pub fn inspect(
|
||||
game: &GameLayout,
|
||||
launcher: &LauncherLayout,
|
||||
version_id: &str,
|
||||
) -> Result<AdoptCandidate, AdoptError> {
|
||||
if let Err(problem) = validate_version_id(version_id) {
|
||||
return Err(AdoptError::NotAdoptable {
|
||||
version_id: version_id.to_owned(),
|
||||
problems: vec![problem],
|
||||
});
|
||||
}
|
||||
if !game.version_dir(version_id).is_dir() {
|
||||
return Err(AdoptError::UnknownVersion {
|
||||
version_id: version_id.to_owned(),
|
||||
version_dir: game.version_dir(version_id),
|
||||
});
|
||||
}
|
||||
let profiles = read_profiles(launcher)?;
|
||||
let candidate = check(game, &root_of(game), version_id, &profiles);
|
||||
if candidate.adoptable() {
|
||||
Ok(candidate)
|
||||
} else {
|
||||
Err(AdoptError::NotAdoptable {
|
||||
version_id: candidate.version_id.clone(),
|
||||
problems: candidate.problems.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 逐项检查一个版本目录。
|
||||
fn check(
|
||||
game: &GameLayout,
|
||||
root: &Path,
|
||||
version_id: &str,
|
||||
profiles: &ProfileList,
|
||||
) -> AdoptCandidate {
|
||||
let version_dir = game.version_dir(version_id);
|
||||
let manifest_path = game.version_json(version_id);
|
||||
let registered = registered_for(profiles, version_id);
|
||||
let mut candidate = AdoptCandidate {
|
||||
version_id: version_id.to_owned(),
|
||||
version_dir: version_dir.clone(),
|
||||
manifest_path: manifest_path.clone(),
|
||||
jar_present: false,
|
||||
manifest_id: None,
|
||||
inherits: Vec::new(),
|
||||
registered,
|
||||
problems: Vec::new(),
|
||||
};
|
||||
|
||||
// ① id 能不能当路径用:不能的话后面的 join 全不可信,直接结束。
|
||||
if let Err(problem) = validate_version_id(version_id) {
|
||||
candidate.problems.push(problem);
|
||||
return candidate;
|
||||
}
|
||||
// ② 版本目录解析到根外(符号链接 / 目录联接 / 祖先链接)⇒ 立刻停手:
|
||||
// **一个字节都不去读**那个目录(读它就等于读别人的启动器数据)。
|
||||
if let Some(problem) = ensure_inside(&version_dir, root) {
|
||||
candidate.problems.push(problem);
|
||||
return candidate;
|
||||
}
|
||||
// ③ 清单必须在(`installed_versions` 的判据同源)。
|
||||
if !manifest_path.is_file() {
|
||||
candidate.problems.push(AdoptProblem::ManifestMissing {
|
||||
version_id: version_id.to_owned(),
|
||||
path: manifest_path,
|
||||
});
|
||||
return candidate;
|
||||
}
|
||||
if let Some(problem) = ensure_inside(&manifest_path, root) {
|
||||
candidate.problems.push(problem);
|
||||
return candidate;
|
||||
}
|
||||
candidate.jar_present = game.version_jar(version_id).is_file();
|
||||
|
||||
// ④ 清单读得出来、id 对得上。
|
||||
let manifest = match read_manifest(&manifest_path) {
|
||||
Ok(manifest) => manifest,
|
||||
Err(problem) => {
|
||||
candidate.problems.push(problem);
|
||||
return candidate;
|
||||
}
|
||||
};
|
||||
let manifest_id = manifest.id.clone();
|
||||
candidate.manifest_id = manifest_id.clone();
|
||||
match manifest_id.as_deref() {
|
||||
None => {
|
||||
candidate.problems.push(AdoptProblem::ManifestMissingId {
|
||||
path: manifest_path.clone(),
|
||||
});
|
||||
return candidate;
|
||||
}
|
||||
Some(id) if id != version_id => {
|
||||
candidate.problems.push(AdoptProblem::ManifestIdMismatch {
|
||||
path: manifest_path.clone(),
|
||||
manifest_id: id.to_owned(),
|
||||
directory_id: version_id.to_owned(),
|
||||
});
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
|
||||
// ⑤ 继承链走到底(缺父 / 环 / 父清单坏 / 父 id 不匹配都在这条路上报)。
|
||||
match walk_chain(game, root, &manifest_path, manifest) {
|
||||
Ok(chain) => {
|
||||
candidate.inherits = chain;
|
||||
// 走查通过后再让 `resolve` 做一次权威判定:两处判据不许漂。
|
||||
if let Some(problem) = resolve_verdict(game, &candidate.manifest_path) {
|
||||
candidate.problems.push(problem);
|
||||
}
|
||||
}
|
||||
Err(problem) => candidate.problems.push(problem),
|
||||
}
|
||||
candidate
|
||||
}
|
||||
|
||||
/// 走 `inheritsFrom` 链,返回 [自身, 父, 祖父, …];任一层不合格就返回那条问题。
|
||||
fn walk_chain(
|
||||
game: &GameLayout,
|
||||
root: &Path,
|
||||
manifest_path: &Path,
|
||||
manifest: VersionJson,
|
||||
) -> Result<Vec<String>, AdoptProblem> {
|
||||
let Some(self_id) = manifest.id.clone() else {
|
||||
return Err(AdoptProblem::ManifestMissingId {
|
||||
path: manifest_path.to_path_buf(),
|
||||
});
|
||||
};
|
||||
let mut chain = vec![self_id];
|
||||
let mut current = manifest;
|
||||
while let Some(parent) = current.inherits_from.clone() {
|
||||
if chain.iter().any(|id| id == &parent) {
|
||||
let mut loop_chain = chain.clone();
|
||||
loop_chain.push(parent);
|
||||
return Err(AdoptProblem::CircularInheritance { chain: loop_chain });
|
||||
}
|
||||
validate_version_id(&parent)?;
|
||||
let required_by = chain.last().cloned().unwrap_or_default();
|
||||
let parent_dir = game.version_dir(&parent);
|
||||
let parent_path = game.version_json(&parent);
|
||||
// 父版本同样先过边界(越界就不读),再判存在。
|
||||
if let Some(problem) = ensure_inside(&parent_dir, root) {
|
||||
return Err(problem);
|
||||
}
|
||||
if !parent_path.is_file() {
|
||||
return Err(AdoptProblem::MissingParent {
|
||||
parent,
|
||||
required_by,
|
||||
});
|
||||
}
|
||||
if let Some(problem) = ensure_inside(&parent_path, root) {
|
||||
return Err(problem);
|
||||
}
|
||||
let parent_manifest = read_manifest(&parent_path)?;
|
||||
match parent_manifest.id.as_deref() {
|
||||
None => return Err(AdoptProblem::ManifestMissingId { path: parent_path }),
|
||||
Some(id) if id != parent => {
|
||||
return Err(AdoptProblem::ManifestIdMismatch {
|
||||
path: parent_path,
|
||||
manifest_id: id.to_owned(),
|
||||
directory_id: parent,
|
||||
})
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
chain.push(parent);
|
||||
current = parent_manifest;
|
||||
}
|
||||
Ok(chain)
|
||||
}
|
||||
|
||||
/// 用 `core::game::resolve` 判一次继承链(权威结论;失败就如实报,不猜)。
|
||||
fn resolve_verdict(game: &GameLayout, manifest_path: &Path) -> Option<AdoptProblem> {
|
||||
let manifest = read_manifest(manifest_path).ok()?;
|
||||
let source = |id: &str| -> Option<VersionJson> {
|
||||
let bytes = std::fs::read(game.version_json(id)).ok()?;
|
||||
VersionJson::from_json_slice(&bytes).ok()
|
||||
};
|
||||
match inherit::resolve(&source, &manifest) {
|
||||
Ok(_) => None,
|
||||
Err(error) => Some(AdoptProblem::InheritanceInvalid {
|
||||
detail: error.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 读一份清单(错误带路径与 serde 的行列号)。
|
||||
fn read_manifest(path: &Path) -> Result<VersionJson, AdoptProblem> {
|
||||
let bytes = std::fs::read(path).map_err(|error| AdoptProblem::ManifestUnreadable {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
VersionJson::from_json_slice(&bytes).map_err(|error| match error {
|
||||
GameError::Json { source, .. } => AdoptProblem::ManifestInvalidJson {
|
||||
path: path.to_path_buf(),
|
||||
message: source.to_string(),
|
||||
},
|
||||
other => AdoptProblem::ManifestInvalidJson {
|
||||
path: path.to_path_buf(),
|
||||
message: other.to_string(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// 绑定这个版本 id 的全部实例档案。
|
||||
fn registered_for(profiles: &ProfileList, version_id: &str) -> Vec<RegisteredInstance> {
|
||||
profiles
|
||||
.profiles
|
||||
.iter()
|
||||
.filter(|profile| profile.version_id == version_id)
|
||||
.map(|profile| RegisteredInstance {
|
||||
id: profile.id.as_str().to_owned(),
|
||||
name: profile.name.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 非 UTF-8 目录名:当成一条不可登记的候选(丢掉它就等于「静默少一行」)。
|
||||
fn unreadable_id_candidate(path: &Path, versions_dir: &Path) -> AdoptCandidate {
|
||||
let shown = path
|
||||
.strip_prefix(versions_dir)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
AdoptCandidate {
|
||||
version_id: shown,
|
||||
version_dir: path.to_path_buf(),
|
||||
manifest_path: path.to_path_buf(),
|
||||
jar_present: false,
|
||||
manifest_id: None,
|
||||
inherits: Vec::new(),
|
||||
registered: Vec::new(),
|
||||
problems: vec![AdoptProblem::DirectoryUnreadable {
|
||||
path: path.to_path_buf(),
|
||||
message: "目录名不是合法 UTF-8,不能作为版本 id".to_owned(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
/// 断链符号链接:`versions/` 里有这个条目,但指不到任何东西。
|
||||
fn broken_link_candidate(version_id: &str, path: &Path) -> AdoptCandidate {
|
||||
AdoptCandidate {
|
||||
version_id: version_id.to_owned(),
|
||||
version_dir: path.to_path_buf(),
|
||||
manifest_path: path.to_path_buf(),
|
||||
jar_present: false,
|
||||
manifest_id: None,
|
||||
inherits: Vec::new(),
|
||||
registered: Vec::new(),
|
||||
problems: vec![AdoptProblem::SymlinkUnresolvable {
|
||||
path: path.to_path_buf(),
|
||||
message: "符号链接的目标不存在(断链)".to_owned(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! 路径判据:版本 id 能不能当路径用、已有条目是不是跑出了游戏根(符号链接 / 目录联接 / 祖先链接)。
|
||||
//!
|
||||
//! 两条判据都与「登记只读」直接相关:id 不可信 ⇒ `versions/<id>/` 可能落到别人的目录上;
|
||||
//! 越界 ⇒ 我们以为在游戏根里的东西其实在外面(读它就等于读别的启动器的数据)。
|
||||
//! 判据写成纯函数,好在这一层单独变异。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::super::layout::GameLayout;
|
||||
use super::candidate::AdoptProblem;
|
||||
|
||||
/// 游戏根的「真实」形式(符号链接越界判据的比较基准)。
|
||||
///
|
||||
/// 拿不到(根还不存在 / 无权限)时退回原始路径:那种情况下不会有已存在的符号链接能通过元数据检查,
|
||||
/// 越界判据仍能给出「不在根内」的结论,而不是静默放行。
|
||||
pub(super) fn root_of(game: &GameLayout) -> PathBuf {
|
||||
game.root()
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| game.root().to_path_buf())
|
||||
}
|
||||
|
||||
/// 边界判据:**已存在**的路径,其真实(解析符号链接/目录联接之后)位置必须在游戏根内。
|
||||
///
|
||||
/// 为什么不是 `is_symlink` 一刀切:
|
||||
/// - `is_symlink` 看不到 Windows 的**目录联接(junction)**和大部分重解析点——而 junction 不需要
|
||||
/// 管理员权限就能建,正是「把别人的版本目录挂进来」最容易的形态;
|
||||
/// - 祖先链接同样要算:`versions/` 本身是 junction 时,每个 `versions/<id>` 都解析到根外,
|
||||
/// 单看 `<id>` 这一层还是普通目录。
|
||||
///
|
||||
/// 所以这里一律 `canonicalize` 后与**真实游戏根**做前缀比较:根内的链接照旧放行(合法的省盘做法),
|
||||
/// 根外的一律给出 [`AdoptProblem::OutsideGameRoot`]。路径不存在 ⇒ `None`(存在性由调用方的
|
||||
/// 「清单缺失」判据负责,别把「还没装」说成「越界」)。
|
||||
///
|
||||
/// **已知边界**:判据是「检查那一刻」的快照,检查与读取之间若有人把目录换成链接(TOCTOU),
|
||||
/// 本函数看不到——本模块不是并发/对抗入口,不做跨进程锁。
|
||||
pub(super) fn ensure_inside(path: &Path, root: &Path) -> Option<AdoptProblem> {
|
||||
if std::fs::symlink_metadata(path).is_err() {
|
||||
return None;
|
||||
}
|
||||
match path.canonicalize() {
|
||||
Ok(target) if target.starts_with(root) => None,
|
||||
Ok(target) => Some(AdoptProblem::OutsideGameRoot {
|
||||
path: path.to_path_buf(),
|
||||
target,
|
||||
root: root.to_path_buf(),
|
||||
}),
|
||||
Err(error) => Some(AdoptProblem::SymlinkUnresolvable {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本 id 的路径安全判据(空 / 分隔符 / 盘符 / NUL / 结尾点号空格都拒绝)。
|
||||
///
|
||||
/// 结尾的点号与空格必须拒绝:Win32 会在建目录时把它们**静默剥掉**,于是
|
||||
/// `versions/1.20.1.` 与 `versions/1.20.1` 会落到同一个目录上——两个「不同 id」指向一份文件。
|
||||
pub(super) fn validate_version_id(version_id: &str) -> Result<(), AdoptProblem> {
|
||||
let invalid = |reason: &'static str| {
|
||||
Err(AdoptProblem::VersionIdInvalid {
|
||||
version_id: version_id.to_owned(),
|
||||
reason,
|
||||
})
|
||||
};
|
||||
if version_id.trim().is_empty() {
|
||||
return invalid("为空");
|
||||
}
|
||||
if version_id.contains('/') || version_id.contains('\\') {
|
||||
return invalid("含路径分隔符");
|
||||
}
|
||||
if version_id.contains(':') {
|
||||
return invalid("含冒号(盘符 / NTFS 数据流)");
|
||||
}
|
||||
if version_id.contains('\0') || version_id.chars().any(|ch| ch.is_control()) {
|
||||
return invalid("含控制字符");
|
||||
}
|
||||
if version_id == "." || version_id == ".." {
|
||||
return invalid("是相对路径标记");
|
||||
}
|
||||
if version_id.ends_with('.') || version_id.ends_with(' ') {
|
||||
return invalid("以点号或空格结尾(Win32 会剥掉,落到别的目录上)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
//! 落点计算与真正的登记:`plan`(纯算 + 零写盘)与 `adopt`(plan + 只写自己的两份配置)。
|
||||
//!
|
||||
//! # 写盘面(全部在 ULL 数据根下,游戏根一个字节都不动)
|
||||
//!
|
||||
//! 1. `config/instances/<id>.json`(实例设置:隔离档 + 运行目录);
|
||||
//! 2. `config/profiles.json`(档案列表);
|
||||
//! 3. 选了缺省运行目录时,`<data>/instances/<实例目录名>` 这个空目录(已存在且非空 ⇒ 拒绝;
|
||||
//! 路径上的已有祖先若解析到**数据根之外**(符号链接/目录联接)⇒ 拒绝,不沿它写盘)。
|
||||
//!
|
||||
//! `versions/` `libraries/` `assets/` **一个字节都不写**:登记只填 `version_id`,内容靠 D20 的
|
||||
//! 「恒共享层」被启动/下载链路复用([`crate::game::isolation::SHARED_LAYERS`])。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::super::install::instance::{read_profiles, register_with, InstanceSettings, Isolation};
|
||||
use super::super::install::pack_dir;
|
||||
use super::super::layout::GameLayout;
|
||||
use super::candidate::{AdoptCandidate, AdoptTarget};
|
||||
use super::error::AdoptError;
|
||||
use super::inspect::inspect;
|
||||
use crate::platform::layout::LauncherLayout;
|
||||
|
||||
/// 一次登记请求(CLI / presenter 的输入形状)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AdoptRequest {
|
||||
/// 要接进来的版本 id(= `versions/<id>/`)。
|
||||
pub version_id: String,
|
||||
/// 展示名;缺省 = 版本 id。
|
||||
pub name: Option<String>,
|
||||
/// 运行目录选择(缺省 [`AdoptTarget::InstanceDir`],**不是** `isolated`)。
|
||||
pub target: AdoptTarget,
|
||||
/// 档案的创建时间(RFC-3339;core 不碰时钟格式,由调用方给)。
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
impl AdoptRequest {
|
||||
/// 最小请求(缺省名 + 缺省运行目录)。
|
||||
#[must_use]
|
||||
pub fn new(version_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
version_id: version_id.into(),
|
||||
name: None,
|
||||
target: AdoptTarget::default(),
|
||||
created_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次登记的**计划**(不写任何东西;`--dry-run` / GUI 预检就吃这个)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AdoptPlan {
|
||||
/// 检查过的候选(含问题清单,正常时为空)。
|
||||
pub candidate: AdoptCandidate,
|
||||
/// 将写进档案的展示名。
|
||||
pub name: String,
|
||||
/// 运行目录选择。
|
||||
pub target: AdoptTarget,
|
||||
/// 生效隔离档。
|
||||
pub isolation: Isolation,
|
||||
/// 生效运行目录(= `--gameDir`)。
|
||||
pub run_dir: PathBuf,
|
||||
/// 人读提示(共享/版本目录的后果、已有同版本实例…)。
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// 一次登记的结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AdoptOutcome {
|
||||
/// 实例 id(稳定 UUID)。
|
||||
pub instance_id: String,
|
||||
/// 展示名。
|
||||
pub name: String,
|
||||
/// 绑定的版本 id。
|
||||
pub version_id: String,
|
||||
/// 实例设置文件路径。
|
||||
pub config_path: PathBuf,
|
||||
/// 生效隔离档。
|
||||
pub isolation: Isolation,
|
||||
/// 运行目录选择。
|
||||
pub target: AdoptTarget,
|
||||
/// 生效运行目录。
|
||||
pub run_dir: PathBuf,
|
||||
/// 是否新建了档案(`false` 只可能是复用同名同版本档案,本命令会先拒绝那种情况)。
|
||||
pub created: bool,
|
||||
/// 是否新建了缺省运行目录(`false` = 目录已存在但为空、直接复用)。
|
||||
pub dir_created: bool,
|
||||
/// 人读提示(与 [`AdoptPlan::notes`] 同一份)。
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// 计算一次登记:检查候选 + 解析名字 + 查重名 + 算运行目录。**零写盘**。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`AdoptError::UnknownVersion`]:版本不在盘上;
|
||||
/// - [`AdoptError::NotAdoptable`]:候选有问题(清单坏/缺父/环/越界…,原因逐条给出;由
|
||||
/// [`inspect`] 判出来);
|
||||
/// - [`AdoptError::DuplicateName`] / [`AdoptError::AlreadyRegistered`]:名字冲突 / 重复登记;
|
||||
/// - [`AdoptError::TargetNotEmpty`] / [`AdoptError::TargetInvalid`]:运行目录不可用;
|
||||
/// - [`AdoptError::Game`]:`profiles.json` 读不了。
|
||||
pub fn plan(
|
||||
game: &GameLayout,
|
||||
launcher: &LauncherLayout,
|
||||
request: &AdoptRequest,
|
||||
) -> Result<AdoptPlan, AdoptError> {
|
||||
// `inspect` 只在候选**可登记**时返回 `Ok`,不合格一律是 `NotAdoptable`(问题清单已带出)。
|
||||
let candidate = inspect(game, launcher, &request.version_id)?;
|
||||
let name = resolve_name(request.name.as_deref(), &candidate.version_id);
|
||||
let profiles = read_profiles(launcher)?;
|
||||
let mut notes = Vec::new();
|
||||
// 重名与重复登记:**显式报错**,不静默复用(复用会把用户「导入这个」的意图改成「改一个别的实例」)。
|
||||
if let Some(existing) = profiles
|
||||
.profiles
|
||||
.iter()
|
||||
.find(|profile| profile.name == name)
|
||||
{
|
||||
if existing.version_id == candidate.version_id {
|
||||
return Err(AdoptError::AlreadyRegistered {
|
||||
version_id: candidate.version_id.clone(),
|
||||
name,
|
||||
instance_id: existing.id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
return Err(AdoptError::DuplicateName {
|
||||
name,
|
||||
instance_id: existing.id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
for profile in &candidate.registered {
|
||||
notes.push(format!(
|
||||
"这个版本已经有实例了:`{}`(id {})——再登记会得到第二个实例,两个实例共用同一份版本文件",
|
||||
profile.name, profile.id
|
||||
));
|
||||
}
|
||||
|
||||
let run_dir = run_dir_of(
|
||||
game,
|
||||
launcher,
|
||||
&candidate.version_id,
|
||||
&name,
|
||||
&request.target,
|
||||
)?;
|
||||
notes.extend(target_notes(&request.target, &run_dir));
|
||||
|
||||
Ok(AdoptPlan {
|
||||
candidate,
|
||||
name,
|
||||
target: request.target.clone(),
|
||||
isolation: request.target.isolation(),
|
||||
run_dir,
|
||||
notes,
|
||||
})
|
||||
}
|
||||
|
||||
/// 按计划登记:先准备缺省运行目录(若选了它),再写实例设置与档案。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 同 [`plan`],外加建目录/写配置失败([`AdoptError::Game`])。
|
||||
pub fn adopt(
|
||||
game: &GameLayout,
|
||||
launcher: &LauncherLayout,
|
||||
request: &AdoptRequest,
|
||||
) -> Result<AdoptOutcome, AdoptError> {
|
||||
let plan = plan(game, launcher, request)?;
|
||||
let mut dir_created = false;
|
||||
// 目录先建:建不出来就一个配置都不写(失败的残留是一个空目录,可复用)。
|
||||
if plan.target == AdoptTarget::InstanceDir && !plan.run_dir.is_dir() {
|
||||
std::fs::create_dir_all(&plan.run_dir)
|
||||
.map_err(|error| AdoptError::Game(crate::game::GameError::io(&plan.run_dir, error)))?;
|
||||
dir_created = true;
|
||||
// 建完再核一次:`create_dir_all` 会沿已存在的目录联接走,越过边界就不算成功。
|
||||
ensure_inside_data(launcher, &plan.run_dir)?;
|
||||
}
|
||||
let settings = InstanceSettings {
|
||||
isolation: Some(plan.isolation),
|
||||
game_dir: match &plan.target {
|
||||
// `custom` 档必须有运行目录:缺省目录与用户目录都要落进 `gameDir`。
|
||||
AdoptTarget::InstanceDir | AdoptTarget::Custom(_) => Some(plan.run_dir.clone()),
|
||||
AdoptTarget::Shared | AdoptTarget::VersionDir => None,
|
||||
},
|
||||
};
|
||||
let registration = register_with(
|
||||
launcher,
|
||||
&plan.name,
|
||||
&plan.candidate.version_id,
|
||||
request.created_at.as_deref(),
|
||||
&settings,
|
||||
)?;
|
||||
Ok(AdoptOutcome {
|
||||
instance_id: registration.profile.id.as_str().to_owned(),
|
||||
name: registration.profile.name.clone(),
|
||||
version_id: registration.profile.version_id.clone(),
|
||||
config_path: registration.config_path,
|
||||
isolation: plan.isolation,
|
||||
target: plan.target,
|
||||
run_dir: plan.run_dir,
|
||||
created: registration.created,
|
||||
dir_created,
|
||||
notes: plan.notes,
|
||||
})
|
||||
}
|
||||
|
||||
/// 运行目录的派生(缺省落点复用整合包导入的 `<data>/instances/<实例目录名>`)。
|
||||
fn run_dir_of(
|
||||
game: &GameLayout,
|
||||
launcher: &LauncherLayout,
|
||||
version_id: &str,
|
||||
name: &str,
|
||||
target: &AdoptTarget,
|
||||
) -> Result<PathBuf, AdoptError> {
|
||||
match target {
|
||||
AdoptTarget::InstanceDir => {
|
||||
let dir = pack_dir::instance_dir(launcher, name);
|
||||
if directory_occupied(&dir) {
|
||||
return Err(AdoptError::TargetNotEmpty { path: dir });
|
||||
}
|
||||
// 数据区里**已有**的祖先不许是跑出数据根的链接/联接(junction 不需要管理员权限就能建)。
|
||||
ensure_inside_data(launcher, &dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
AdoptTarget::Shared => Ok(game.root().to_path_buf()),
|
||||
AdoptTarget::VersionDir => Ok(game.version_dir(version_id)),
|
||||
AdoptTarget::Custom(dir) => {
|
||||
if !dir.is_absolute() {
|
||||
return Err(AdoptError::TargetInvalid {
|
||||
detail: format!("--game-dir 需要绝对路径,收到 `{}`", dir.display()),
|
||||
});
|
||||
}
|
||||
if dir.as_os_str().is_empty() {
|
||||
return Err(AdoptError::TargetInvalid {
|
||||
detail: "运行目录为空".to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(dir.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 运行目录选择的后果说明(人读;CLI 与 GUI 共用同一份话术)。
|
||||
///
|
||||
/// `shared` / `version_dir` 必须**明确告知**:它们会把玩家数据写进别人的 `.minecraft`
|
||||
/// (D20 的 `isolated` 档运行目录就是 `versions/<id>/`)。
|
||||
#[must_use]
|
||||
pub fn target_notes(target: &AdoptTarget, run_dir: &Path) -> Vec<String> {
|
||||
match target {
|
||||
AdoptTarget::InstanceDir => vec![format!(
|
||||
"运行目录 = {}(ULL 数据区新建,与别人的 .minecraft 隔离;versions/libraries/assets 仍按 D20 恒共享,不复制)",
|
||||
run_dir.display()
|
||||
)],
|
||||
AdoptTarget::Shared => vec![format!(
|
||||
"运行目录 = 游戏根 {}(共享档):mods/saves/config 会落在别人的 .minecraft 里,与其它启动器共用;\
|
||||
要隔离请用缺省(ULL 数据区新目录)或 --isolation custom --game-dir <dir>",
|
||||
run_dir.display()
|
||||
)],
|
||||
AdoptTarget::VersionDir => vec![format!(
|
||||
"运行目录 = 版本目录 {}(版本隔离档):第一次启动会在这里新增 mods/saves/config/logs,\
|
||||
而这个目录是别的启动器的版本目录(不覆盖已有文件);要隔离请用缺省或 --isolation custom",
|
||||
run_dir.display()
|
||||
)],
|
||||
AdoptTarget::Custom(_) => vec![format!(
|
||||
"运行目录 = {}(用户指定;不新建、不改动其中已有内容)",
|
||||
run_dir.display()
|
||||
)],
|
||||
}
|
||||
}
|
||||
|
||||
/// 展示名:去空格,空串退回版本 id(与 `pack import` 的「名字不给就用包名」同一口径)。
|
||||
fn resolve_name(name: Option<&str>, version_id: &str) -> String {
|
||||
name.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| version_id.to_owned())
|
||||
}
|
||||
|
||||
/// 缺省运行目录必须**真的**落在启动器数据根之下(挡住「数据区里预先放了指向外面的目录联接」)。
|
||||
///
|
||||
/// 边界是 `<data>`(`<root>/.ULL/data`)而不是 `<data>/instances`:后者本身可能就是链接,
|
||||
/// 拿它当基准等于把「越界」当成「正常」。`<data>` 自己是链接是用户自己的迁移手法(D6 的数据目录
|
||||
/// 迁移就是整棵树搬走),那种情况允许——判据是「最终落点仍在**数据根解析后的位置**之内」。
|
||||
///
|
||||
/// **已知边界**:这是检查那一刻的判据;检查与 `create_dir_all` 之间被人换掉组件(TOCTOU)
|
||||
/// 本函数看不到——本命令不是并发/对抗入口。
|
||||
fn ensure_inside_data(launcher: &LauncherLayout, dir: &Path) -> Result<(), AdoptError> {
|
||||
let data = launcher.data();
|
||||
let Some(data_real) = canonical_existing(&data) else {
|
||||
// 连数据根都还不存在 ⇒ 不可能已经有指向外面的链接。
|
||||
return Ok(());
|
||||
};
|
||||
let Some(dir_real) = canonical_existing(dir) else {
|
||||
return Err(AdoptError::TargetInvalid {
|
||||
detail: format!("解析运行目录的真实位置失败:{}", dir.display()),
|
||||
});
|
||||
};
|
||||
if !dir_real.starts_with(&data_real) {
|
||||
return Err(AdoptError::TargetInvalid {
|
||||
detail: format!(
|
||||
"运行目录 {} 解析到 {},落在数据根 {} 之外:数据区里有指向外部的符号链接/目录联接,拒绝沿它写盘",
|
||||
dir.display(),
|
||||
dir_real.display(),
|
||||
data_real.display()
|
||||
),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 最近的**已存在**祖先的真实路径(目标本身可能还不存在)。
|
||||
fn canonical_existing(path: &Path) -> Option<PathBuf> {
|
||||
let mut probe = path.to_path_buf();
|
||||
loop {
|
||||
if std::fs::symlink_metadata(&probe).is_ok() {
|
||||
return probe.canonicalize().ok();
|
||||
}
|
||||
if !probe.pop() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 「这个目录已经有人在用」= 存在且非空(空目录是上次失败的残留,可复用)。
|
||||
fn directory_occupied(path: &Path) -> bool {
|
||||
std::fs::read_dir(path).is_ok_and(|mut entries| entries.next().is_some())
|
||||
}
|
||||
@@ -16,7 +16,8 @@
|
||||
//! # 幂等
|
||||
//!
|
||||
//! 同一版本重复 `install`:按 `version_id` + 展示名复用已有档案,返回 `created = false`,
|
||||
//! 不产生第二个实例、不改已有实例的 id(id 一旦写进档案就是稳定的)。
|
||||
//! 不产生第二个实例、不改已有实例的 id(id 一旦写进档案就是稳定的)。登记本身(含
|
||||
//! [`InstanceSettings`] 与 `register_with`)在同目录的 `register` 子模块里。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
@@ -26,10 +27,14 @@ use serde::{Deserialize, Serialize};
|
||||
use super::super::error::GameError;
|
||||
use super::super::isolation::{IsolationMode, ModeChange, ModeChangeWarning};
|
||||
use super::super::launch_overrides::LaunchOverrides;
|
||||
use super::super::profiles::{InstanceProfile, ProfileList};
|
||||
use super::super::profiles::ProfileList;
|
||||
use crate::platform::atomic::write_atomic;
|
||||
use crate::platform::layout::LauncherLayout;
|
||||
|
||||
mod register;
|
||||
|
||||
pub use register::{register, register_with, InstanceSettings, Registration};
|
||||
|
||||
/// 每实例设置文件的 schema 版本。
|
||||
///
|
||||
/// 与 `setting::instance_keys::instance_version` 的 minor 对应:D20 加了 `lastModeChange`,
|
||||
@@ -215,71 +220,6 @@ impl InstanceConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次登记的结果。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Registration {
|
||||
/// 档案(新建或复用)。
|
||||
pub profile: InstanceProfile,
|
||||
/// 每实例设置文件路径。
|
||||
pub config_path: PathBuf,
|
||||
/// 是否新建(`false` = 已有同版本同名的实例,直接复用)。
|
||||
pub created: bool,
|
||||
}
|
||||
|
||||
/// 登记一个实例(幂等)。
|
||||
///
|
||||
/// 行为:
|
||||
/// 1. 已有"同 `version_id` + 同展示名"的档案 → 复用(`created = false`),只补写缺失的设置文件;
|
||||
/// 2. 否则新建档案(稳定 UUID)→ 写 `profiles.json` → 写 `config/instances/<id>.json`。
|
||||
///
|
||||
/// `created_at` 由调用方给(core::game 不碰时钟格式;CLI 用 `launch::time` 的 RFC3339)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 读/写 `profiles.json`、实例 id 不合法、原子写失败。
|
||||
pub fn register(
|
||||
layout: &LauncherLayout,
|
||||
name: &str,
|
||||
version_id: &str,
|
||||
created_at: Option<&str>,
|
||||
) -> Result<Registration, GameError> {
|
||||
let mut profiles = read_profiles(layout)?;
|
||||
let existing = profiles
|
||||
.profiles
|
||||
.iter()
|
||||
.find(|profile| profile.version_id == version_id && profile.name == name)
|
||||
.cloned();
|
||||
|
||||
if let Some(profile) = existing {
|
||||
let config_path = instance_config_path(layout, profile.id.as_str())?;
|
||||
// 老档案可能还没有设置文件(早期 CLI 只写 profiles.json),这里补齐。
|
||||
if !config_path.is_file() {
|
||||
write_instance_config(
|
||||
layout,
|
||||
&InstanceConfig::new(profile.id.as_str(), version_id),
|
||||
)?;
|
||||
}
|
||||
return Ok(Registration {
|
||||
profile,
|
||||
config_path,
|
||||
created: false,
|
||||
});
|
||||
}
|
||||
|
||||
let mut profile = InstanceProfile::new(name, version_id);
|
||||
profile.created_at = created_at.map(str::to_string);
|
||||
let id = profile.id.as_str().to_string();
|
||||
profiles.upsert(profile.clone());
|
||||
write_profiles(layout, &profiles)?;
|
||||
|
||||
let config_path = write_instance_config(layout, &InstanceConfig::new(id, version_id))?;
|
||||
Ok(Registration {
|
||||
profile,
|
||||
config_path,
|
||||
created: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// 读实例清单(文件缺失 = 空列表,不算错误)。
|
||||
///
|
||||
/// # Errors
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! 实例登记:把「用户点了一下导入/安装」变成 `profiles.json` + `config/instances/<id>.json`。
|
||||
//!
|
||||
//! 从 `install/instance.rs` 搬出来只为守 400 行硬线(M221 的 `register_with` 让那边到 430 行);
|
||||
//! 搬动是纯挪,唯一新增的是 [`InstanceSettings`] 与 [`register_with`](登记时一并定隔离档/运行目录)。
|
||||
//!
|
||||
//! # 幂等
|
||||
//!
|
||||
//! 同一 `version_id` + 同一展示名重复登记:复用已有档案(`created = false`),不产生第二个实例、
|
||||
//! 不改已有实例的 id(id 一旦写进档案就是稳定的)。
|
||||
//!
|
||||
//! # 写盘顺序(M221)
|
||||
//!
|
||||
//! **先写实例设置、后写档案**。中断时留下的是「没有档案的孤立设置文件」——不可见、下次登记同一 id
|
||||
//! 会覆盖;反过来(先档案后设置)会留下一个按全局档启动的实例,而全局缺省是 `isolated`,
|
||||
//! 那会把 `mods/saves/config` 造进 `versions/<versionId>/`(登记别人的版本时那正是别人的目录)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::game::error::GameError;
|
||||
use crate::game::profiles::InstanceProfile;
|
||||
use crate::platform::layout::LauncherLayout;
|
||||
|
||||
use super::{
|
||||
instance_config_path, read_profiles, write_instance_config, write_profiles, InstanceConfig,
|
||||
Isolation,
|
||||
};
|
||||
|
||||
/// 一次登记的结果。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Registration {
|
||||
/// 档案(新建或复用)。
|
||||
pub profile: InstanceProfile,
|
||||
/// 每实例设置文件路径。
|
||||
pub config_path: PathBuf,
|
||||
/// 是否新建(`false` = 已有同版本同名的实例,直接复用)。
|
||||
pub created: bool,
|
||||
}
|
||||
|
||||
/// 登记时一并落进 `config/instances/<id>.json` 的初始设置。
|
||||
///
|
||||
/// 为什么要有它:登记与「运行目录怎么定」原本是两件事,于是调用方只能先 `register` 再补写设置
|
||||
/// ——中间那段时间实例是 `inherit`(跟随全局 `isolated`)。把选择前移到登记这一步,就没有那个窗口。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct InstanceSettings {
|
||||
/// 隔离档;`None` = 跟随全局(不落盘)。
|
||||
pub isolation: Option<Isolation>,
|
||||
/// 自定义运行目录(仅 `custom` 档需要)。
|
||||
pub game_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl InstanceSettings {
|
||||
/// 跟随全局(= 未引入 [`InstanceSettings`] 前的历史行为,不落任何设置键)。
|
||||
#[must_use]
|
||||
pub fn inherit() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 是否什么都没选。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.isolation.is_none() && self.game_dir.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// 登记一个实例(幂等,跟随全局隔离档)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 读/写 `profiles.json`、实例 id 不合法、原子写失败。
|
||||
pub fn register(
|
||||
layout: &LauncherLayout,
|
||||
name: &str,
|
||||
version_id: &str,
|
||||
created_at: Option<&str>,
|
||||
) -> Result<Registration, GameError> {
|
||||
register_with(
|
||||
layout,
|
||||
name,
|
||||
version_id,
|
||||
created_at,
|
||||
&InstanceSettings::inherit(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 同 [`register`],但把隔离档与运行目录**一并**写进新建实例的设置文件。
|
||||
///
|
||||
/// 复用已有档案(同版本 + 同名)时 `settings` 只在「设置文件缺失、需要补建」时生效——
|
||||
/// 已有设置文件绝不改写(那是用户的显式选择,登记不该覆盖它)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 同 [`register`]。
|
||||
pub fn register_with(
|
||||
layout: &LauncherLayout,
|
||||
name: &str,
|
||||
version_id: &str,
|
||||
created_at: Option<&str>,
|
||||
settings: &InstanceSettings,
|
||||
) -> Result<Registration, GameError> {
|
||||
let mut profiles = read_profiles(layout)?;
|
||||
let existing = profiles
|
||||
.profiles
|
||||
.iter()
|
||||
.find(|profile| profile.version_id == version_id && profile.name == name)
|
||||
.cloned();
|
||||
|
||||
if let Some(profile) = existing {
|
||||
let config_path = instance_config_path(layout, profile.id.as_str())?;
|
||||
// 老档案可能还没有设置文件(早期 CLI 只写 profiles.json),这里补齐。
|
||||
if !config_path.is_file() {
|
||||
let mut config = InstanceConfig::new(profile.id.as_str(), version_id);
|
||||
apply_settings(&mut config, settings);
|
||||
write_instance_config(layout, &config)?;
|
||||
}
|
||||
return Ok(Registration {
|
||||
profile,
|
||||
config_path,
|
||||
created: false,
|
||||
});
|
||||
}
|
||||
|
||||
let mut profile = InstanceProfile::new(name, version_id);
|
||||
profile.created_at = created_at.map(str::to_string);
|
||||
let id = profile.id.as_str().to_string();
|
||||
let mut config = InstanceConfig::new(id, version_id);
|
||||
apply_settings(&mut config, settings);
|
||||
let config_path = write_instance_config(layout, &config)?;
|
||||
|
||||
profiles.upsert(profile.clone());
|
||||
if let Err(error) = write_profiles(layout, &profiles) {
|
||||
// 档案没落盘 ⇒ 撤掉刚写的设置文件,不留半登记状态。
|
||||
let _ = std::fs::remove_file(&config_path);
|
||||
return Err(error);
|
||||
}
|
||||
Ok(Registration {
|
||||
profile,
|
||||
config_path,
|
||||
created: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// 把登记时的初始设置贴到实例设置上(只动这两个键,其余保持稀疏缺省)。
|
||||
fn apply_settings(config: &mut InstanceConfig, settings: &InstanceSettings) {
|
||||
if let Some(isolation) = settings.isolation {
|
||||
config.isolation = Some(isolation);
|
||||
}
|
||||
if settings.game_dir.is_some() {
|
||||
config.game_dir = settings.game_dir.clone();
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,14 @@
|
||||
//! Java 管理(M33,[`java`]):托管 JDK 的查询/下载/解压/探测(`.ULL/data/java-managed/`)
|
||||
//! 与 MC↔Java 映射表。同样只借 `core::download` 的引擎做下载,自己不拼镜像 URL。
|
||||
//!
|
||||
//! 已铺在盘上的版本接入(M221,[`adopt`]):扫描/检查 `versions/` 里**别人装的**版本 → 只登记
|
||||
//! (`profiles.json` + 实例设置),不搬文件、不写游戏根。缺省运行目录落 ULL 数据区,
|
||||
//! 共用/写进对方的 `.minecraft` 必须显式选择(见 [`adopt::AdoptTarget`])。
|
||||
//!
|
||||
//! 模块分工按"数据形状 / 纯计算 / 状态"切开,任何模块都不碰网络与 UI:
|
||||
//! 下载归 `core::download`、进程归 `core::launch`、配置落盘归 `core::setting`。
|
||||
|
||||
pub mod adopt;
|
||||
pub mod arguments;
|
||||
pub mod capability;
|
||||
pub mod error;
|
||||
@@ -44,6 +49,10 @@ pub mod snapshot;
|
||||
pub mod unlisted;
|
||||
pub mod version;
|
||||
|
||||
pub use adopt::{
|
||||
AdoptCandidate, AdoptError, AdoptOutcome, AdoptPlan, AdoptProblem, AdoptRequest, AdoptScan,
|
||||
AdoptTarget, RegisteredInstance,
|
||||
};
|
||||
pub use arguments::{rules_allow, Arguments, Environment, ExpandedArguments, Rule, RuleAction};
|
||||
pub use capability::{
|
||||
is_vulkan_library, library_names_have_vulkan, VersionCapabilities, VULKAN_LIBRARY_ARTIFACT,
|
||||
|
||||
Reference in new issue
Block a user