Unlucky Launcher v0.0.1-alpha
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent), MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
commit
bc41dfcf7d
517 files changed
+92844
No files matched your search
@@ -0,0 +1,331 @@
|
||||
//! 版本 JSON 的 `arguments` 模型:规则(rules)求值与参数展开。
|
||||
//!
|
||||
//! 依据:`DevDocs/analyse/03-version-download.md` §4.4(`arguments` 的合并语义)、
|
||||
//! §4.6(`minecraftArguments` 单串形式与新版 `arguments` 并存)。
|
||||
//!
|
||||
//! 规则求值语义照抄 Mojang/HMCL(`CompatibilityRule.appliesToCurrentEnvironment`):
|
||||
//! 规则列表为空 → 生效;否则**从 DISALLOW 开始**逐条覆盖,最后是 ALLOW 才算生效。
|
||||
//! 这是"白名单式"语义,与"最后一条匹配者胜"不同,抄错会漏掉/多带启动参数。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::platform::os_info::OsInfo;
|
||||
|
||||
/// 规则动作。
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RuleAction {
|
||||
/// 允许(命中后该参数/库生效)。
|
||||
#[default]
|
||||
Allow,
|
||||
/// 禁止(命中后该参数/库失效)。
|
||||
Disallow,
|
||||
}
|
||||
|
||||
/// 一条规则。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Rule {
|
||||
/// 动作;缺省按 `allow` 处理(Mojang 老写法里 rules 只写 os 的情况)。
|
||||
#[serde(default)]
|
||||
pub action: RuleAction,
|
||||
/// 操作系统限制。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub os: Option<OsRestriction>,
|
||||
/// 特性开关限制(`has_custom_resolution`、`is_demo_user`…)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub features: Option<BTreeMap<String, bool>>,
|
||||
}
|
||||
|
||||
/// 规则的 OS 限制。`version`/`arch` 在官方 JSON 里是**正则**,见 [`matches_simple_pattern`]。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OsRestriction {
|
||||
/// `windows` / `osx` / `linux`;也接受模组包里的 `win-x86` 这种合并写法。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// OS 版本模式。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
/// 架构模式(`x86`、`x86_64`、`arm64`)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub arch: Option<String>,
|
||||
}
|
||||
|
||||
/// 求值时的运行环境(OS / 架构 / 特性开关)。
|
||||
///
|
||||
/// 现由 [`Environment::host`] 从 `core::platform::os_info` 的探测快照构造(同 crate 依赖),
|
||||
/// 因此 `os_version` 是真实系统版本而不是空串;单测可以注入假快照做到不碰真机。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Environment {
|
||||
/// Mojang 口径的 OS 名:`windows` / `osx` / `linux`。
|
||||
pub os: String,
|
||||
/// OS 版本字符串(Windows 上是 `10.0.19045` 之类;探测失败时为空串)。
|
||||
pub os_version: String,
|
||||
/// Mojang 口径的架构:`x86` / `x86_64` / `arm64`。
|
||||
pub arch: String,
|
||||
/// 特性开关,缺省全部视为 `false`。
|
||||
pub features: BTreeMap<String, bool>,
|
||||
}
|
||||
|
||||
impl Default for Environment {
|
||||
fn default() -> Self {
|
||||
Self::host()
|
||||
}
|
||||
}
|
||||
|
||||
impl Environment {
|
||||
/// 当前主机环境。
|
||||
///
|
||||
/// `os` / `arch` / `os_version` 全部取自 [`crate::platform::os_info::current`]
|
||||
/// (进程内缓存,探测只做一次):`os_version` 不再是空串,因此带 `os.version` 限制的
|
||||
/// 规则(官方 JSON 里用正则写系统版本)能按真实值求值。
|
||||
pub fn host() -> Self {
|
||||
Self::from_os_info(crate::platform::os_info::current())
|
||||
}
|
||||
|
||||
/// 从一份系统信息快照构造环境(单测注入假快照,不碰真机;也让 app/CLI 能复用已探测结果)。
|
||||
pub fn from_os_info(info: &OsInfo) -> Self {
|
||||
Self {
|
||||
os: info.family.manifest_name().to_owned(),
|
||||
os_version: info.os_version().to_owned(),
|
||||
arch: info.arch.clone(),
|
||||
features: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 打开/关闭一个特性开关(构建器风格,便于 launch 侧串起来)。
|
||||
pub fn with_feature(mut self, name: impl Into<String>, value: bool) -> Self {
|
||||
self.features.insert(name.into(), value);
|
||||
self
|
||||
}
|
||||
|
||||
/// 读一个特性开关;未声明视为 `false`。
|
||||
pub fn feature(&self, name: &str) -> bool {
|
||||
self.features.get(name).copied().unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
impl OsRestriction {
|
||||
/// 该限制是否匹配当前环境。
|
||||
pub fn allows(&self, env: &Environment) -> bool {
|
||||
if let Some(name) = &self.name {
|
||||
// 模组包常见 `{ "name": "win-x86" }`:OS 名与架构合写。
|
||||
let (os_name, arch) = match name.split_once('-') {
|
||||
Some((os_part, arch_part)) if is_known_os(os_part) && is_known_arch(arch_part) => {
|
||||
(os_part, Some(arch_part))
|
||||
}
|
||||
_ => (name.as_str(), None),
|
||||
};
|
||||
if is_known_os(os_name) && os_name != env.os {
|
||||
return false;
|
||||
}
|
||||
if let Some(arch) = arch {
|
||||
if arch != env.arch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(version) = &self.version {
|
||||
if !matches_simple_pattern(version, &env.os_version) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(arch) = &self.arch {
|
||||
if !matches_simple_pattern(arch, &env.arch) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn is_known_os(name: &str) -> bool {
|
||||
matches!(name, "windows" | "osx" | "linux")
|
||||
}
|
||||
|
||||
fn is_known_arch(name: &str) -> bool {
|
||||
matches!(name, "x86" | "x86_64" | "arm64")
|
||||
}
|
||||
|
||||
/// 极简正则匹配(官方 JSON 里 `os.version`/`os.arch` 用的是正则)。
|
||||
///
|
||||
/// 只覆盖实际出现过的形态:`^字面量`(前缀)、`字面量`(全等)、`字面量$`、
|
||||
/// 以及里面的 `\.`/`.*` 展开。**遇到其它元字符一律放宽为匹配**——
|
||||
/// 宁可多带一个参数(例如 Windows 专用 `-Dos.name`),也不要因为不会解析正则而漏掉参数。
|
||||
/// 阶段 B 若真有 JSON 用到复杂模式,再引入 `regex` 依赖替换本函数。
|
||||
pub fn matches_simple_pattern(pattern: &str, actual: &str) -> bool {
|
||||
let anchored_start = pattern.starts_with('^');
|
||||
let body = pattern.strip_prefix('^').unwrap_or(pattern);
|
||||
let anchored_end = body.ends_with('$');
|
||||
let body = body.strip_suffix('$').unwrap_or(body);
|
||||
|
||||
let mut literal = String::with_capacity(body.len());
|
||||
let mut chars = body.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
// `.*` 视作通配:丢掉两个字符即可(前后锚点仍然生效)。
|
||||
'.' if chars.as_str().starts_with('*') => {
|
||||
chars.next();
|
||||
}
|
||||
// `\.` 展开成字面点号。
|
||||
'\\' => match chars.next() {
|
||||
Some('.') => literal.push('.'),
|
||||
_ => return true,
|
||||
},
|
||||
'.' => literal.push('.'),
|
||||
'|' | '[' | ']' | '(' | ')' | '+' | '?' | '{' | '}' | '^' | '$' => return true,
|
||||
other => literal.push(other),
|
||||
}
|
||||
}
|
||||
|
||||
match (anchored_start, anchored_end) {
|
||||
(true, true) => actual == literal,
|
||||
(true, false) => actual.starts_with(&literal),
|
||||
(false, true) => actual.ends_with(&literal),
|
||||
(false, false) => actual == literal || actual.starts_with(&literal),
|
||||
}
|
||||
}
|
||||
|
||||
impl Rule {
|
||||
/// 该规则在当前环境下的动作;不匹配环境时返回 `None`。
|
||||
pub fn action_in(&self, env: &Environment) -> Option<RuleAction> {
|
||||
if let Some(os) = &self.os {
|
||||
if !os.allows(env) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if let Some(features) = &self.features {
|
||||
for (name, expected) in features {
|
||||
if env.feature(name) != *expected {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(self.action)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一组规则的求值(Mojang 白名单语义)。
|
||||
pub fn rules_allow(rules: &[Rule], env: &Environment) -> bool {
|
||||
if rules.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let mut action = RuleAction::Disallow;
|
||||
for rule in rules {
|
||||
if let Some(this) = rule.action_in(env) {
|
||||
action = this;
|
||||
}
|
||||
}
|
||||
action == RuleAction::Allow
|
||||
}
|
||||
|
||||
/// `arguments` 里的一项:纯字符串参数,或带规则的条件参数。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Argument {
|
||||
/// 无条件参数。
|
||||
Plain(String),
|
||||
/// 带规则的条件参数。
|
||||
Conditional(ConditionalArgument),
|
||||
}
|
||||
|
||||
/// 带规则的条件参数。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConditionalArgument {
|
||||
/// 规则列表(白名单语义,见 [`rules_allow`])。
|
||||
#[serde(default)]
|
||||
pub rules: Vec<Rule>,
|
||||
/// 命中后的参数值(单个或多个)。
|
||||
pub value: ArgumentValue,
|
||||
}
|
||||
|
||||
/// 参数值:单个字符串或多个字符串。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ArgumentValue {
|
||||
/// 单个参数。
|
||||
One(String),
|
||||
/// 多个参数。
|
||||
Many(Vec<String>),
|
||||
}
|
||||
|
||||
impl ArgumentValue {
|
||||
/// 逐个参数借用(`One` 给一个,`Many` 给若干个)。
|
||||
pub fn strings(&self) -> Vec<&str> {
|
||||
match self {
|
||||
Self::One(value) => vec![value.as_str()],
|
||||
Self::Many(values) => values.iter().map(String::as_str).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Argument {
|
||||
/// 在当前环境下展开为具体参数(占位符替换归 launch 负责)。
|
||||
pub fn expand_into(&self, env: &Environment, out: &mut Vec<String>) {
|
||||
match self {
|
||||
Self::Plain(value) => out.push(value.clone()),
|
||||
Self::Conditional(cond) => {
|
||||
if rules_allow(&cond.rules, env) {
|
||||
match &cond.value {
|
||||
ArgumentValue::One(value) => out.push(value.clone()),
|
||||
ArgumentValue::Many(values) => out.extend(values.iter().cloned()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本 JSON 的 `arguments` 对象。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Arguments {
|
||||
/// 游戏参数(`mainClass` 之后)。
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub game: Vec<Argument>,
|
||||
/// JVM 参数(`-Xmx` 之类之外的那部分)。
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub jvm: Vec<Argument>,
|
||||
}
|
||||
|
||||
impl Arguments {
|
||||
/// 合并两层参数:**父在前、子在后**(analyse/03 §4.4)。
|
||||
pub fn merge(parent: Option<&Arguments>, child: Option<&Arguments>) -> Option<Arguments> {
|
||||
match (parent, child) {
|
||||
(None, None) => None,
|
||||
(Some(parent), None) => Some(parent.clone()),
|
||||
(None, Some(child)) => Some(child.clone()),
|
||||
(Some(parent), Some(child)) => Some(Arguments {
|
||||
game: parent
|
||||
.game
|
||||
.iter()
|
||||
.chain(child.game.iter())
|
||||
.cloned()
|
||||
.collect(),
|
||||
jvm: parent.jvm.iter().chain(child.jvm.iter()).cloned().collect(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 按环境展开成扁平参数表(占位符 `--username ${auth_player_name}` 不在这里替换)。
|
||||
pub fn expand(&self, env: &Environment) -> ExpandedArguments {
|
||||
let mut game = Vec::new();
|
||||
let mut jvm = Vec::new();
|
||||
for argument in &self.game {
|
||||
argument.expand_into(env, &mut game);
|
||||
}
|
||||
for argument in &self.jvm {
|
||||
argument.expand_into(env, &mut jvm);
|
||||
}
|
||||
ExpandedArguments { game, jvm }
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Arguments::expand`] 的结果。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ExpandedArguments {
|
||||
/// 展开后的游戏参数。
|
||||
pub game: Vec<String>,
|
||||
/// 展开后的 JVM 参数。
|
||||
pub jvm: Vec<String>,
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! 版本能力位:从版本 JSON 的**库表**推断「这个版本带不带某个渲染后端」。
|
||||
//!
|
||||
//! 依据 `DevDocs/PlanDoc/research/05-mc-vulkan.md`(M26)§2.2 与 §4.2:
|
||||
//! Vulkan 的三件套(`lwjgl-vulkan` / `lwjgl-shaderc` / `lwjgl-spvc` / `lwjgl-vma`)从
|
||||
//! **`26.2-snapshot-1`(2026-04-07)** 起进入官方库列表,26.1 全线(含 `26w14a` 这类周快照)没有。
|
||||
//!
|
||||
//! # 为什么用库表而不是版本号
|
||||
//!
|
||||
//! 硬编码 `is_at_least("26.2-snapshot-2")` 对官方主线是对的,但周快照(`26w14a`)、
|
||||
//! 继承版本、第三方改版都拿不到可靠的比较结果(`GameVersion::is_known() == false` 时
|
||||
//! 门限恒假)。库表是版本自己声明的**事实**,比版本号串稳。
|
||||
//!
|
||||
//! # ⚠️ 「库存在」≠「参数可用」
|
||||
//!
|
||||
//! `26.2-snapshot-1` 已经有 `lwjgl-vulkan`,但 `--graphicsBackend` 要到
|
||||
//! **`26.2-snapshot-2`(2026-04-09)** 才进 `Main.class`,两者差两天(实测结论)。
|
||||
//! 因此能力位只适合做:
|
||||
//! - **必要条件的快速否定**(没有库 → 一定没有 Vulkan 后端);
|
||||
//! - UI 可用性提示(渲染设置里出不出「图形 API」三选一);
|
||||
//! - **无法排序的版本**(周快照)的兜底判据。
|
||||
//!
|
||||
//! 可解析版本的下发门限仍以 `26.2-snapshot-2` 为准,见
|
||||
//! [`crate::launch::game_args::graphics_backend_allowed`]。
|
||||
|
||||
use super::manifest::VersionJson;
|
||||
|
||||
/// Vulkan 后端的 Maven 坐标(`group:artifact`)。
|
||||
pub const VULKAN_LIBRARY_GROUP: &str = "org.lwjgl";
|
||||
/// Vulkan 后端的 Maven 坐标(artifact)。
|
||||
pub const VULKAN_LIBRARY_ARTIFACT: &str = "lwjgl-vulkan";
|
||||
|
||||
/// 一条库名是否是 LWJGL 的 Vulkan 绑定。
|
||||
///
|
||||
/// 吃 `group:artifact:version[:classifier][@ext]` 全形态;只比对前两段,因此
|
||||
/// `org.lwjgl:lwjgl-vulkan:3.4.3`、`org.lwjgl:lwjgl-vulkan:3.4.3:natives-macos`
|
||||
/// 与 `org.lwjgl:lwjgl-vulkan:3.4.3@jar` 都算命中。
|
||||
#[must_use]
|
||||
pub fn is_vulkan_library(name: &str) -> bool {
|
||||
let mut parts = name.split(':');
|
||||
let (Some(group), Some(artifact)) = (parts.next(), parts.next()) else {
|
||||
return false;
|
||||
};
|
||||
// classifier / `@ext` 可能粘在 artifact 上(`lwjgl-vulkan@jar`)。
|
||||
let artifact = artifact.split('@').next().unwrap_or(artifact);
|
||||
group.trim() == VULKAN_LIBRARY_GROUP && artifact.trim() == VULKAN_LIBRARY_ARTIFACT
|
||||
}
|
||||
|
||||
/// 从一份版本 JSON 推出的能力位(目前只有 Vulkan 一项,后续可加)。
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct VersionCapabilities {
|
||||
/// `libraries` 里存在 `org.lwjgl:lwjgl-vulkan`。
|
||||
///
|
||||
/// **注意语义**:这是「版本带 Vulkan 后端实现」的**必要条件**,不是
|
||||
/// 「`--graphicsBackend` 可下发」的充分条件(见模块文档)。
|
||||
pub has_vulkan_backend: bool,
|
||||
}
|
||||
|
||||
impl VersionCapabilities {
|
||||
/// 从版本 JSON 解析能力位(只读 `libraries[].name`,不看下载信息与规则)。
|
||||
#[must_use]
|
||||
pub fn from_json(json: &VersionJson) -> Self {
|
||||
Self {
|
||||
has_vulkan_backend: json
|
||||
.libraries
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.any(|library| is_vulkan_library(&library.name)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否带 Vulkan 后端。
|
||||
#[must_use]
|
||||
pub const fn has_vulkan_backend(&self) -> bool {
|
||||
self.has_vulkan_backend
|
||||
}
|
||||
}
|
||||
|
||||
/// 快捷方式:库名列表里是否有 Vulkan 绑定(launch 侧拿到的库名表也能直接用)。
|
||||
#[must_use]
|
||||
pub fn library_names_have_vulkan<'a>(names: impl IntoIterator<Item = &'a str>) -> bool {
|
||||
names.into_iter().any(is_vulkan_library)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
{
|
||||
"versions": [
|
||||
{"id": "1.21.11_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/327be7759157b04495c591dbb721875e341877af/1.21.11_unobfuscated.json", "time": "2025-12-09T12:43:15+00:00", "releaseTime": "2025-12-09T12:43:15+00:00"},
|
||||
{"id": "1.21.11-rc3_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/ce3f7ac6d0e9d23ea4e5f0354b91ff15039d9931/1.21.11-rc3_unobfuscated.json", "time": "2025-12-08T13:59:34+00:00", "releaseTime": "2025-12-08T13:59:34+00:00"},
|
||||
{"id": "1.21.11-rc2_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/9282a3fb154d2a425086c62c11827281308bf93b/1.21.11-rc2_unobfuscated.json", "time": "2025-12-05T11:57:45+00:00", "releaseTime": "2025-12-05T11:57:45+00:00"},
|
||||
{"id": "1.21.11-rc1_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/5d3ee0ef1f0251cf7e073354ca9e085a884a643d/1.21.11-rc1_unobfuscated.json", "time": "2025-12-04T15:56:55+00:00", "releaseTime": "2025-12-04T15:56:55+00:00"},
|
||||
{"id": "1.21.11-pre5_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/1028441ca6d288bbf2103e773196bf524f7260fd/1.21.11-pre5_unobfuscated.json", "time": "2025-12-03T13:34:06+00:00", "releaseTime": "2025-12-03T13:34:06+00:00"},
|
||||
{"id": "1.21.11-pre4_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/410ce37a2506adcfd54ef7d89168cfbe89cac4cb/1.21.11-pre4_unobfuscated.json", "time": "2025-12-01T13:40:12+00:00", "releaseTime": "2025-12-01T13:40:12+00:00"},
|
||||
{"id": "1.21.11-pre3_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/579bf3428f72b5ea04883d202e4831bfdcb2aa8d/1.21.11-pre3_unobfuscated.json", "time": "2025-11-25T14:14:30+00:00", "releaseTime": "2025-11-25T14:14:30+00:00"},
|
||||
{"id": "1.21.11-pre2_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/2955ce0af0512fdfe53ff0740b017344acf6f397/1.21.11-pre2_unobfuscated.json", "time": "2025-11-21T12:07:21+00:00", "releaseTime": "2025-11-21T12:07:21+00:00"},
|
||||
{"id": "1.21.11-pre1_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/9c267f8dda2728bae55201a753cdd07b584709f1/1.21.11-pre1_unobfuscated.json", "time": "2025-11-19T08:30:46+00:00", "releaseTime": "2025-11-19T08:30:46+00:00"},
|
||||
{"id": "25w46a_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/314ade2afeada364047798e163ef8e82427c69e1/25w46a_unobfuscated.json", "time": "2025-11-11T13:20:54+00:00", "releaseTime": "2025-11-11T13:20:54+00:00"},
|
||||
{"id": "25w45a_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/7a3c149f148b6aa5ac3af48c4f701adea7e5b615/25w45a_unobfuscated.json", "time": "2025-11-04T14:07:08+00:00", "releaseTime": "2025-11-04T14:07:08+00:00"},
|
||||
{"id": "1.19_deep_dark_experimental_snapshot-1", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_19_deep_dark_experimental_snapshot-1/1_19_deep_dark_experimental_snapshot-1.json", "time": "2022-02-17T13:55:59+00:00", "releaseTime": "2022-02-17T13:55:59+00:00"},
|
||||
{"id": "1.18_experimental-snapshot-7", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_18_experimental-snapshot-7/1_18_experimental-snapshot-7.json", "time": "2021-09-08T12:33:23+00:00", "releaseTime": "2021-09-08T12:33:23+00:00"},
|
||||
{"id": "1.18_experimental-snapshot-6", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_18_experimental-snapshot-6/1_18_experimental-snapshot-6.json", "time": "2021-09-01T12:53:14+00:00", "releaseTime": "2021-09-01T12:53:14+00:00"},
|
||||
{"id": "1.18_experimental-snapshot-5", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_18_experimental-snapshot-5/1_18_experimental-snapshot-5.json", "time": "2021-08-25T14:41:57+00:00", "releaseTime": "2021-08-25T14:41:57+00:00"},
|
||||
{"id": "1.18_experimental-snapshot-4", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_18_experimental-snapshot-4/1_18_experimental-snapshot-4.json", "time": "2021-08-17T16:10:25+00:00", "releaseTime": "2021-08-17T16:10:25+00:00"},
|
||||
{"id": "1.18_experimental-snapshot-3", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_18_experimental-snapshot-3/1_18_experimental-snapshot-3.json", "time": "2021-08-10T12:42:45+00:00", "releaseTime": "2021-08-10T12:42:45+00:00"},
|
||||
{"id": "1.18_experimental-snapshot-2", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_18_experimental-snapshot-2/1_18_experimental-snapshot-2.json", "time": "2021-07-20T13:35:08+00:00", "releaseTime": "2021-07-20T13:35:08+00:00"},
|
||||
{"id": "1.18_experimental-snapshot-1", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_18_experimental-snapshot-1/1_18_experimental-snapshot-1.json", "time": "2021-07-13T12:54:19+00:00", "releaseTime": "2021-07-13T12:54:19+00:00"},
|
||||
{"id": "1.16_combat-6", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-6/1_16_combat-6.json", "time": "2020-08-26T06:24:28+00:00", "releaseTime": "2020-08-26T06:24:28+00:00"},
|
||||
{"id": "1.16_combat-5", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-5/1_16_combat-5.json", "time": "2020-08-21T09:23:13+00:00", "releaseTime": "2020-08-21T09:23:13+00:00"},
|
||||
{"id": "1.16_combat-4", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-4/1_16_combat-4.json", "time": "2020-08-19T11:14:58+00:00", "releaseTime": "2020-08-19T11:14:58+00:00"},
|
||||
{"id": "1.16_combat-3", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-3/1_16_combat-3.json", "time": "2020-08-14T09:02:15+00:00", "releaseTime": "2020-08-14T09:02:15+00:00"},
|
||||
{"id": "1.16_combat-2", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-2/1_16_combat-2.json", "time": "2020-08-13T11:56:30+00:00", "releaseTime": "2020-08-13T11:56:30+00:00"},
|
||||
{"id": "1.16_combat-1", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-1/1_16_combat-1.json", "time": "2020-08-12T14:07:25+00:00", "releaseTime": "2020-08-12T14:07:25+00:00"},
|
||||
{"id": "1.16_combat-0", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-0/1_16_combat-0.json", "time": "2020-08-07T10:44:47+00:00", "releaseTime": "2020-08-07T10:44:47+00:00"},
|
||||
{"id": "1.15_combat-6", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_15_combat-6/1_15_combat-6.json", "time": "2020-01-15T09:46:35+00:00", "releaseTime": "2020-01-15T09:46:35+00:00"},
|
||||
{"id": "1.15_combat-1", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_15_combat-1/1_15_combat-1.json", "time": "2019-11-29T15:41:39+00:00", "releaseTime": "2019-11-29T15:41:39+00:00"},
|
||||
{"id": "1.14_combat-3", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_14_combat-3/1_14_combat-3.json", "time": "2019-10-31T14:31:38+00:00", "releaseTime": "2019-10-31T14:31:38+00:00"},
|
||||
{"id": "1.14_combat-0", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_14_combat-0/1_14_combat-0.json", "time": "2019-08-13T07:33:42+00:00", "releaseTime": "2019-08-13T07:33:42+00:00"},
|
||||
{"id": "1.14_combat-212796", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_14_combat-212796/1_14_combat-212796.json", "time": "2019-06-20T13:23:44+00:00", "releaseTime": "2019-06-20T13:23:44+00:00"},
|
||||
{"id": "2.0_blue", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/2point0_blue/2point0_blue.json", "time": "2013-08-06T06:00:02-05:00", "releaseTime": "2013-03-20T05:00:02-05:00"},
|
||||
{"id": "2.0_red", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/2point0_red/2point0_red.json", "time": "2013-08-06T06:00:01-05:00", "releaseTime": "2013-03-20T05:00:01-05:00"},
|
||||
{"id": "2.0_purple", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/2point0_purple/2point0_purple.json", "time": "2013-08-06T06:00:00-05:00", "releaseTime": "2013-03-20T05:00:00-05:00"},
|
||||
{"id": "13w12~", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w12-/13w12-.json", "time": "2013-03-19T00:00:00+00:00", "releaseTime": "2013-03-19T00:00:00+00:00"},
|
||||
{"id": "13w10b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w10b/13w10b.json", "time": "2013-03-06T00:00:00+00:00", "releaseTime": "2013-03-06T00:00:00+00:00"},
|
||||
{"id": "13w10a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w10a/13w10a.json", "time": "2013-03-04T00:00:00+00:00", "releaseTime": "2013-03-04T00:00:00+00:00"},
|
||||
{"id": "13w09c", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w09c/13w09c.json", "time": "2013-03-01T00:00:00+00:00", "releaseTime": "2013-03-01T00:00:00+00:00"},
|
||||
{"id": "13w09b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w09b/13w09b.json", "time": "2013-02-27T00:00:00+00:00", "releaseTime": "2013-02-27T00:00:00+00:00"},
|
||||
{"id": "13w09a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w09a/13w09a.json", "time": "2013-02-26T00:00:00+00:00", "releaseTime": "2013-02-26T00:00:00+00:00"},
|
||||
{"id": "13w07a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w07a/13w07a.json", "time": "2013-02-14T00:00:00+00:00", "releaseTime": "2013-02-14T00:00:00+00:00"},
|
||||
{"id": "13w11a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w11a/13w11a.json", "time": "2013-02-14T00:00:00+00:00", "releaseTime": "2013-02-14T00:00:00+00:00"},
|
||||
{"id": "13w06a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w06a/13w06a.json", "time": "2013-02-07T00:00:00+00:00", "releaseTime": "2013-02-07T00:00:00+00:00"},
|
||||
{"id": "13w05b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w05b/13w05b.json", "time": "2013-02-01T00:00:00+00:00", "releaseTime": "2013-02-01T00:00:00+00:00"},
|
||||
{"id": "13w05a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w05a/13w05a.json", "time": "2013-01-31T00:00:00+00:00", "releaseTime": "2013-01-31T00:00:00+00:00"},
|
||||
{"id": "13w04a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w04a/13w04a.json", "time": "2013-01-24T00:00:00+00:00", "releaseTime": "2013-01-24T00:00:00+00:00"},
|
||||
{"id": "13w03a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w03a/13w03a.json", "time": "2013-01-17T00:00:00+00:00", "releaseTime": "2013-01-17T00:00:00+00:00"},
|
||||
{"id": "13w02b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w02b/13w02b.json", "time": "2013-01-11T00:00:00+00:00", "releaseTime": "2013-01-11T00:00:00+00:00"},
|
||||
{"id": "13w02a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w02a/13w02a.json", "time": "2013-01-10T00:00:00+00:00", "releaseTime": "2013-01-10T00:00:00+00:00"},
|
||||
{"id": "13w01b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w01b/13w01b.json", "time": "2013-01-04T00:00:00+00:00", "releaseTime": "2013-01-04T00:00:00+00:00"},
|
||||
{"id": "13w01a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w01a/13w01a.json", "time": "2013-01-03T00:00:00+00:00", "releaseTime": "2013-01-03T00:00:00+00:00"},
|
||||
{"id": "12w50b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w50b/12w50b.json", "time": "2012-12-14T00:00:00+00:00", "releaseTime": "2012-12-14T00:00:00+00:00"},
|
||||
{"id": "12w50a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w50a/12w50a.json", "time": "2012-12-13T00:00:00+00:00", "releaseTime": "2012-12-13T00:00:00+00:00"},
|
||||
{"id": "12w49a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w49a/12w49a.json", "time": "2012-12-07T00:00:00+00:00", "releaseTime": "2012-12-07T00:00:00+00:00"},
|
||||
{"id": "12w42b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w42b/12w42b.json", "time": "2012-10-18T00:00:00+00:00", "releaseTime": "2012-10-18T00:00:00+00:00"},
|
||||
{"id": "12w42a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w42a/12w42a.json", "time": "2012-10-17T00:00:00+00:00", "releaseTime": "2012-10-17T00:00:00+00:00"},
|
||||
{"id": "12w41b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w41b/12w41b.json", "time": "2012-10-12T00:00:00+00:00", "releaseTime": "2012-10-12T00:00:00+00:00"},
|
||||
{"id": "12w41a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w41a/12w41a.json", "time": "2012-10-11T00:00:00+00:00", "releaseTime": "2012-10-11T00:00:00+00:00"},
|
||||
{"id": "12w40b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w40b/12w40b.json", "time": "2012-10-05T00:00:00+00:00", "releaseTime": "2012-10-05T00:00:00+00:00"},
|
||||
{"id": "12w40a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w40a/12w40a.json", "time": "2012-10-04T00:00:00+00:00", "releaseTime": "2012-10-04T00:00:00+00:00"},
|
||||
{"id": "12w39b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w39b/12w39b.json", "time": "2012-09-28T00:00:00+00:00", "releaseTime": "2012-09-28T00:00:00+00:00"},
|
||||
{"id": "12w39a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w39a/12w39a.json", "time": "2012-09-27T00:00:00+00:00", "releaseTime": "2012-09-27T00:00:00+00:00"},
|
||||
{"id": "12w38b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w38b/12w38b.json", "time": "2012-09-21T00:00:00+00:00", "releaseTime": "2012-09-21T00:00:00+00:00"},
|
||||
{"id": "12w38a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w38a/12w38a.json", "time": "2012-09-20T00:00:00+00:00", "releaseTime": "2012-09-20T00:00:00+00:00"},
|
||||
{"id": "12w37a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w37a/12w37a.json", "time": "2012-09-13T00:00:00+00:00", "releaseTime": "2012-09-13T00:00:00+00:00"},
|
||||
{"id": "12w36a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w36a/12w36a.json", "time": "2012-09-06T00:00:00+00:00", "releaseTime": "2012-09-06T00:00:00+00:00"},
|
||||
{"id": "12w34b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w34b/12w34b.json", "time": "2012-08-24T00:00:00+00:00", "releaseTime": "2012-08-24T00:00:00+00:00"},
|
||||
{"id": "12w34a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w34a/12w34a.json", "time": "2012-08-23T00:00:00+00:00", "releaseTime": "2012-08-23T00:00:00+00:00"},
|
||||
{"id": "12w32a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w32a/12w32a.json", "time": "2012-08-09T00:00:00+00:00", "releaseTime": "2012-08-09T00:00:00+00:00"},
|
||||
{"id": "12w30d", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w30d/12w30d.json", "time": "2012-07-25T00:00:00+00:00", "releaseTime": "2012-07-25T00:00:00+00:00"},
|
||||
{"id": "12w30e", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w30e/12w30e.json", "time": "2012-07-25T00:00:00+00:00", "releaseTime": "2012-07-25T00:00:00+00:00"},
|
||||
{"id": "12w30c", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w30c/12w30c.json", "time": "2012-07-24T00:00:00+00:00", "releaseTime": "2012-07-24T00:00:00+00:00"},
|
||||
{"id": "12w30a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w30a/12w30a.json", "time": "2012-07-23T00:00:00+00:00", "releaseTime": "2012-07-23T00:00:00+00:00"},
|
||||
{"id": "12w30b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w30b/12w30b.json", "time": "2012-07-23T00:00:00+00:00", "releaseTime": "2012-07-23T00:00:00+00:00"},
|
||||
{"id": "12w27a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w27a/12w27a.json", "time": "2012-07-05T00:00:00+00:00", "releaseTime": "2012-07-05T00:00:00+00:00"},
|
||||
{"id": "12w26a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w26a/12w26a.json", "time": "2012-06-27T00:00:00+00:00", "releaseTime": "2012-06-27T00:00:00+00:00"},
|
||||
{"id": "12w25a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w25a/12w25a.json", "time": "2012-06-21T00:00:00+00:00", "releaseTime": "2012-06-21T00:00:00+00:00"},
|
||||
{"id": "12w24a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w24a/12w24a.json", "time": "2012-06-14T00:00:00+00:00", "releaseTime": "2012-06-14T00:00:00+00:00"},
|
||||
{"id": "12w23a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w23a/12w23a.json", "time": "2012-06-07T00:00:00+00:00", "releaseTime": "2012-06-07T00:00:00+00:00"},
|
||||
{"id": "12w23b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w23b/12w23b.json", "time": "2012-06-07T00:00:00+00:00", "releaseTime": "2012-06-07T00:00:00+00:00"},
|
||||
{"id": "12w22a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w22a/12w22a.json", "time": "2012-05-31T00:00:00+00:00", "releaseTime": "2012-05-31T00:00:00+00:00"},
|
||||
{"id": "12w21b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w21b/12w21b.json", "time": "2012-05-25T00:00:00+00:00", "releaseTime": "2012-05-25T00:00:00+00:00"},
|
||||
{"id": "12w21a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w21a/12w21a.json", "time": "2012-05-24T00:00:00+00:00", "releaseTime": "2012-05-24T00:00:00+00:00"},
|
||||
{"id": "12w19a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w19a/12w19a.json", "time": "2012-05-10T00:00:00+00:00", "releaseTime": "2012-05-10T00:00:00+00:00"},
|
||||
{"id": "12w18a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w18a/12w18a.json", "time": "2012-05-03T00:00:00+00:00", "releaseTime": "2012-05-03T00:00:00+00:00"},
|
||||
{"id": "12w17a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w17a/12w17a.json", "time": "2012-04-26T00:00:00+00:00", "releaseTime": "2012-04-26T00:00:00+00:00"},
|
||||
{"id": "12w16a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w16a/12w16a.json", "time": "2012-04-19T00:00:00+00:00", "releaseTime": "2012-04-19T00:00:00+00:00"},
|
||||
{"id": "1.2", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_2/1_2.json", "time": "2012-02-29T00:00:00+00:00", "releaseTime": "2012-02-29T00:00:00+00:00"},
|
||||
{"id": "12w08a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w08a/12w08a.json", "time": "2012-02-23T00:00:00+00:00", "releaseTime": "2012-02-23T00:00:00+00:00"},
|
||||
{"id": "12w07a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w07a/12w07a.json", "time": "2012-02-15T00:00:00+00:00", "releaseTime": "2012-02-15T00:00:00+00:00"},
|
||||
{"id": "12w07b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w07b/12w07b.json", "time": "2012-02-15T00:00:00+00:00", "releaseTime": "2012-02-15T00:00:00+00:00"},
|
||||
{"id": "12w06a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w06a/12w06a.json", "time": "2012-02-09T00:00:00+00:00", "releaseTime": "2012-02-09T00:00:00+00:00"},
|
||||
{"id": "12w05b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w05b/12w05b.json", "time": "2012-02-03T00:00:00+00:00", "releaseTime": "2012-02-03T00:00:00+00:00"},
|
||||
{"id": "12w05a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w05a/12w05a.json", "time": "2012-02-02T00:00:00+00:00", "releaseTime": "2012-02-02T00:00:00+00:00"},
|
||||
{"id": "12w04a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w04a/12w04a.json", "time": "2012-01-26T00:00:00+00:00", "releaseTime": "2012-01-26T00:00:00+00:00"},
|
||||
{"id": "12w03a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w03a/12w03a.json", "time": "2012-01-19T00:00:00+00:00", "releaseTime": "2012-01-19T00:00:00+00:00"},
|
||||
{"id": "12w01a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w01a/12w01a.json", "time": "2012-01-05T00:00:00+00:00", "releaseTime": "2012-01-05T00:00:00+00:00"},
|
||||
{"id": "11w50a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/11w50a/11w50a.json", "time": "2011-12-15T00:00:00+00:00", "releaseTime": "2011-12-15T00:00:00+00:00"},
|
||||
{"id": "11w49a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/11w49a/11w49a.json", "time": "2011-12-08T00:00:00+00:00", "releaseTime": "2011-12-08T00:00:00+00:00"},
|
||||
{"id": "11w48a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/11w48a/11w48a.json", "time": "2011-12-01T00:00:00+00:00", "releaseTime": "2011-12-01T00:00:00+00:00"},
|
||||
{"id": "11w47a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/11w47a/11w47a.json", "time": "2011-11-24T00:00:00+00:00", "releaseTime": "2011-11-24T00:00:00+00:00"},
|
||||
{"id": "1.0.0-rc2-3", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1.0.0-rc2-3/1.0.0-rc2-3.json", "time": "2013-08-06T07:33:30-07:00", "releaseTime": "2011-11-14T15:00:00-07:00"},
|
||||
{"id": "1.0.0-rc2-2", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1.0.0-rc2-2/1.0.0-rc2-2.json", "time": "2019-04-21T21:04:24+00:00", "releaseTime": "2011-11-14T00:00:00+00:00"},
|
||||
{"id": "1.0.0-rc2-1", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1.0.0-rc2-1/1.0.0-rc2-1.json", "time": "2019-11-21T22:37:45+00:00", "releaseTime": "2011-11-13T17:33:00+00:00"},
|
||||
{"id": "1.0.0-rc1", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1.0.0-rc1/1.0.0-rc1.json", "time": "2013-08-06T07:33:30-07:00", "releaseTime": "2011-11-13T15:00:00-07:00"},
|
||||
{"id": "b1.9-pre6", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1_9-pre6/b1_9-pre6.json", "time": "2011-11-11T00:00:00+00:00", "releaseTime": "2011-11-11T00:00:00+00:00"},
|
||||
{"id": "b1.9-pre5", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1_9-pre5/b1_9-pre5.json", "time": "2011-10-27T00:00:00+00:00", "releaseTime": "2011-10-27T00:00:00+00:00"},
|
||||
{"id": "b1.9-pre4", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1_9-pre4/b1_9-pre4.json", "time": "2011-10-13T00:00:00+00:00", "releaseTime": "2011-10-13T00:00:00+00:00"},
|
||||
{"id": "b1.9-pre3", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1_9-pre3/b1_9-pre3.json", "time": "2011-10-06T00:00:00+00:00", "releaseTime": "2011-10-06T00:00:00+00:00"},
|
||||
{"id": "b1.9-pre2", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1_9-pre2/b1_9-pre2.json", "time": "2011-09-29T00:00:00+00:00", "releaseTime": "2011-09-29T00:00:00+00:00"},
|
||||
{"id": "b1.9-pre1", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1_9-pre1/b1_9-pre1.json", "time": "2011-09-22T00:00:00+00:00", "releaseTime": "2011-09-22T00:00:00+00:00"},
|
||||
{"id": "b1.8-pre2", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1_8-pre2/b1_8-pre2.json", "time": "2011-09-13T00:00:00+00:00", "releaseTime": "2011-09-13T00:00:00+00:00"},
|
||||
{"id": "b1.8-pre1-2", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1_8-pre1/b1_8-pre1.json", "time": "2011-09-09T00:00:00+00:00", "releaseTime": "2011-09-09T00:00:00+00:00"},
|
||||
{"id": "b1.8-pre1-1", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1.8-pre1-1/b1.8-pre1-1.json", "time": "2011-09-09T00:00:00+00:00", "releaseTime": "2011-09-09T00:00:00+00:00"},
|
||||
{"id": "b1.6-tb3", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1.6-tb3/b1.6-tb3.json", "time": "2013-08-06T07:33:30-07:00", "releaseTime": "2011-05-24T15:00:00-07:00"},
|
||||
{"id": "b1.1-1", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1.1-1/b1.1-1.json", "time": "2016-02-02T15:37:47+00:00", "releaseTime": "2010-12-21T22:00:00+00:00"},
|
||||
{"id": "b1.1-2", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1.1-2/b1.1-2.json", "time": "2016-02-02T15:37:47+00:00", "releaseTime": "2010-12-21T22:00:00+00:00"},
|
||||
{"id": "a1.1.1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.1.1/a1.1.1.json", "time": "2010-09-18T19:29:56+02:00", "releaseTime": "2010-09-18T19:29:56+02:00"},
|
||||
{"id": "a1.1.0-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.1.0-2/a1.1.0-2.json", "time": "2016-02-02T15:37:47+00:00", "releaseTime": "2010-09-12T22:00:00+00:00"},
|
||||
{"id": "a1.1.0-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.1.0-1/a1.1.0-1.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-09-10T00:00:00+00:00"},
|
||||
{"id": "a1.0.17_03", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.17_03/a1.0.17_03.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-08-23T00:00:00+00:00"},
|
||||
{"id": "a1.0.16_02", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.16_02/a1.0.16_02.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-08-13T00:00:00+00:00"},
|
||||
{"id": "a1.0.16_01", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.16_01/a1.0.16_01.json", "time": "2013-08-06T04:00:00-07:00", "releaseTime": "2010-08-12T15:00:00-07:00"},
|
||||
{"id": "a1.0.14-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.14-1/a1.0.14-1.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-30T00:00:00+00:00"},
|
||||
{"id": "a1.0.14-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.14-2/a1.0.14-2.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-30T00:00:00+00:00"},
|
||||
{"id": "a1.0.13_01-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.13_01-1/a1.0.13_01-1.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-29T00:00:00+00:00"},
|
||||
{"id": "a1.0.13_01-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.13_01-2/a1.0.13_01-2.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-29T00:00:00+00:00"},
|
||||
{"id": "a1.0.13", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.13/a1.0.13.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-28T00:00:00+00:00"},
|
||||
{"id": "a1.0.12", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.12/a1.0.12.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-26T00:00:00+00:00"},
|
||||
{"id": "a1.0.10", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.10/a1.0.10.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-22T00:00:00+00:00"},
|
||||
{"id": "a1.0.9", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.9/a1.0.9.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-21T00:00:00+00:00"},
|
||||
{"id": "a1.0.7", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.7/a1.0.7.json", "time": "2013-08-06T04:00:00-07:00", "releaseTime": "2010-07-19T15:00:00-07:00"},
|
||||
{"id": "a1.0.8_01", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.8_01/a1.0.8_01.json", "time": "2013-08-06T04:00:00-07:00", "releaseTime": "2010-07-19T15:00:00-07:00"},
|
||||
{"id": "a1.0.6_01", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.6_01/a1.0.6_01.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-17T00:00:00+00:00"},
|
||||
{"id": "a1.0.6_03", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.6_03/a1.0.6_03.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-17T00:00:00+00:00"},
|
||||
{"id": "a1.0.6", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.6/a1.0.6.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-16T00:00:00+00:00"},
|
||||
{"id": "a1.0.5", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.5/a1.0.5.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-13T00:00:00+00:00"},
|
||||
{"id": "a1.0.3", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.3/a1.0.3.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-07T00:00:00+00:00"},
|
||||
{"id": "a1.0.2_01", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.2_01/a1.0.2_01.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-06T00:00:00+00:00"},
|
||||
{"id": "a1.0.2_02", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.2_02/a1.0.2_02.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-06T00:00:00+00:00"},
|
||||
{"id": "a1.0.1_01", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.1_01/a1.0.1_01.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-03T22:00:00+00:00"},
|
||||
{"id": "inf-20100630-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100630-1/inf-20100630-1.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-30T00:00:00-00:00"},
|
||||
{"id": "inf-20100630-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100630-2/inf-20100630-2.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-30T00:00:00-00:00"},
|
||||
{"id": "inf-20100629", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100629/inf-20100629.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-29T00:00:00-00:00"},
|
||||
{"id": "inf-20100627", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100627/inf-20100627.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-27T15:00:00-07:00"},
|
||||
{"id": "inf-20100625-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100625-1/inf-20100625-1.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-25T00:00:00-00:00"},
|
||||
{"id": "inf-20100625-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100625-2/inf-20100625-2.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-25T00:00:00-00:00"},
|
||||
{"id": "inf-20100624", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100624/inf-20100624.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-24T00:00:00-00:00"},
|
||||
{"id": "inf-20100617-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100617-2/inf-20100617-2.json", "time": "2010-06-17T00:00:00-00:00", "releaseTime": "2010-06-17T00:00:00-00:00"},
|
||||
{"id": "inf-20100617-3", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100617-3/inf-20100617-3.json", "time": "2010-06-17T00:00:00-00:00", "releaseTime": "2010-06-17T00:00:00-00:00"},
|
||||
{"id": "inf-20100615", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100615/inf-20100615.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-15T00:00:00-00:00"},
|
||||
{"id": "inf-20100611", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100611/inf-20100611.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-11T00:00:00-00:00"},
|
||||
{"id": "inf-20100608", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100608/inf-20100608.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-08T15:00:00-07:00"},
|
||||
{"id": "inf-20100607", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100607/inf-20100607.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-07T00:00:00-00:00"},
|
||||
{"id": "inf-20100420", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100420/inf-20100420.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-04-20T15:00:00-07:00"},
|
||||
{"id": "inf-20100415", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100415/inf-20100415.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-04-15T15:00:00-07:00"},
|
||||
{"id": "inf-20100414", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100414/inf-20100414.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-04-14T00:00:00-00:00"},
|
||||
{"id": "inf-20100413", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100413/inf-20100413.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-04-13T00:00:00-00:00"},
|
||||
{"id": "inf-20100330", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100330/inf-20100330.json", "time": "2010-03-30T00:00:00-00:00", "releaseTime": "2010-03-30T00:00:00-00:00"},
|
||||
{"id": "inf-20100327", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100327/inf-20100327.json", "time": "2010-03-27T00:00:00-00:00", "releaseTime": "2010-03-27T00:00:00-00:00"},
|
||||
{"id": "inf-20100325", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100325/inf-20100325.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-03-25T00:00:00-00:00"},
|
||||
{"id": "inf-20100321", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100321/inf-20100321.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-03-21T00:00:00-00:00"},
|
||||
{"id": "inf-20100320", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100320/inf-20100320.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-03-20T00:00:00-00:00"},
|
||||
{"id": "inf-20100316", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100316/inf-20100316.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-03-16T00:00:00-00:00"},
|
||||
{"id": "inf-20100313", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100313/inf-20100313.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-03-13T00:00:00-00:00"},
|
||||
{"id": "in-20100223", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100223/in-20100223.json", "time": "2013-08-06T07:33:30-07:00", "releaseTime": "2010-02-23T15:00:00-07:00"},
|
||||
{"id": "in-20100219", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100219/in-20100219.json", "time": "2017-10-24T00:00:00+00:00", "releaseTime": "2010-02-19T00:00:00+00:00"},
|
||||
{"id": "in-20100218", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100218/in-20100218.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-02-18T00:00:00+00:00"},
|
||||
{"id": "in-20100214-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100214-2/in-20100214-2.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-02-14T00:00:01+00:00"},
|
||||
{"id": "in-20100214-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100214-1/in-20100214-1.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-02-14T00:00:00+00:00"},
|
||||
{"id": "in-20100212-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100212-2/in-20100212-2.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-02-12T00:00:01+00:00"},
|
||||
{"id": "in-20100212-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100212-1/in-20100212-1.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-02-12T00:00:00+00:00"},
|
||||
{"id": "in-20100207-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100207-2/in-20100207-2.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-02-07T00:00:01+00:00"},
|
||||
{"id": "in-20100207-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100207-1/in-20100207-1.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-02-07T00:00:00+00:00"},
|
||||
{"id": "in-20100206-2103", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100206-2103/in-20100206-2103.json", "time": "2020-10-14T18:51:02+00:00", "releaseTime": "2010-02-06T21:03:00+00:00"},
|
||||
{"id": "in-20100203", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100203/in-20100203.json", "time": "2017-09-20T19:21:48+02:00", "releaseTime": "2010-02-03T00:30:00+02:00"},
|
||||
{"id": "in-20100201-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100201-2/in-20100201-2.json", "time": "2017-09-20T19:21:48+02:00", "releaseTime": "2010-02-01T00:00:00+00:00"},
|
||||
{"id": "in-20100201-3", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100201-3/in-20100201-3.json", "time": "2017-09-20T19:21:48+02:00", "releaseTime": "2010-02-01T00:00:00+00:00"},
|
||||
{"id": "in-20100130", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100130/in-20100130.json", "time": "2013-08-06T07:33:30-07:00", "releaseTime": "2010-01-30T00:00:00-00:00"},
|
||||
{"id": "in-20100129", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100129/in-20100129.json", "time": "2010-01-29T00:00:00-00:00", "releaseTime": "2010-01-29T00:00:00-00:00"},
|
||||
{"id": "in-20100128-2304", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100128-2304/in-20100128-2304.json", "time": "2010-01-28T23:04:00-00:00", "releaseTime": "2010-01-28T23:04:00-00:00"},
|
||||
{"id": "in-20100125-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100125-2/in-20100125-2.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-01-25T15:00:01-07:00"},
|
||||
{"id": "in-20100125-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100125-1/in-20100125-1.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-01-25T15:00:00-07:00"},
|
||||
{"id": "in-20100111-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100111-1/in-20100111-1.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-01-11T15:00:00-07:00"},
|
||||
{"id": "in-20100105", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100105/in-20100105.json", "time": "2013-08-06T07:33:30-07:00", "releaseTime": "2010-01-05T00:00:00-00:00"},
|
||||
{"id": "in-20091231-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20091231-2/in-20091231-2.json", "time": "2021-06-26T22:48:05+00:00", "releaseTime": "2009-12-31T23:55:00+00:00"},
|
||||
{"id": "in-20091223-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20091223-2/in-20091223-2.json", "time": "2009-12-23T00:00:00-00:00", "releaseTime": "2009-12-23T00:00:00-00:00"},
|
||||
{"id": "c0.30-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.30-2/c0.30-2.json", "time": "2013-08-06T17:33:30+03:00", "releaseTime": "2009-11-10T00:00:01+02:00"},
|
||||
{"id": "c0.30-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.30-1/c0.30-1.json", "time": "2013-08-06T17:33:30+03:00", "releaseTime": "2009-11-10T00:00:00+02:00"},
|
||||
{"id": "c0.29_02", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.29_02/c0.29_02.json", "time": "2017-09-20T03:05:00+01:00", "releaseTime": "2009-10-30T00:00:00+00:00"},
|
||||
{"id": "c0.29_01", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.29_01/c0.29_01.json", "time": "2021-06-26T12:16:50+00:00", "releaseTime": "2009-10-29T00:00:00+00:00"},
|
||||
{"id": "c0.28_01", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.28_01/c0.28_01.json", "time": "2017-09-20T03:19:48+01:00", "releaseTime": "2009-10-27T00:00:00+00:00"},
|
||||
{"id": "c0.27_st", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.27_st/c0.27_st.json", "time": "2017-09-17T16:33:30+02:00", "releaseTime": "2009-10-24T24:00:00+00:00"},
|
||||
{"id": "c0.25_05_st", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.25_05_st/c0.25_05_st.json", "time": "2009-09-03T00:00:00+00:00", "releaseTime": "2009-09-03T00:00:00+00:00"},
|
||||
{"id": "c0.24_st_03", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.24_st_03/c0.24_st_03.json", "time": "2009-09-01T00:00:00+00:00", "releaseTime": "2009-09-01T00:00:00+00:00"},
|
||||
{"id": "c0.0.22a_05", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.22a_05/c0.0.22a_05.json", "time": "2009-06-30T00:00:00+00:00", "releaseTime": "2009-06-30T00:00:00+00:00"},
|
||||
{"id": "c0.0.21a", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.21a/c0.0.21a.json", "time": "2017-09-20T03:12:34+01:00", "releaseTime": "2009-06-22T23:11:11+01:00"},
|
||||
{"id": "c0.0.20a_01", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.20a_01/c0.0.20a_01.json", "time": "2020-10-14T23:51:10+00:00", "releaseTime": "2009-06-20T23:10:00+00:00"},
|
||||
{"id": "c0.0.19a_06-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.19a_06-2/c0.0.19a_06-2.json", "time": "2020-10-17T19:47:58-00:00", "releaseTime": "2009-06-20T01:37:00-00:00"},
|
||||
{"id": "c0.0.18a_02", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.18a_02/c0.0.18a_02.json", "time": "2020-10-15T18:45:15-00:00", "releaseTime": "2009-06-14T10:53:00-00:00"},
|
||||
{"id": "c0.0.17a", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.17a/c0.0.17a.json", "time": "2021-04-17T21:49:29-00:00", "releaseTime": "2009-06-10T20:14:00-00:00"},
|
||||
{"id": "c0.0.16a_02", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.16a_02/c0.0.16a_02.json", "time": "2019-10-20T21:42:41+00:00", "releaseTime": "2009-06-08T12:47:00+00:00"},
|
||||
{"id": "c0.0.23a_01", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.23a_01/c0.0.23a_01.json", "time": "2013-08-06T15:33:30+01:00", "releaseTime": "2009-05-28T23:11:11+01:00"},
|
||||
{"id": "c0.0.14a_08", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.14a_08/c0.0.14a_08.json", "time": "2019-10-20T21:41:28+00:00", "releaseTime": "2009-05-28T22:37:00+00:00"},
|
||||
{"id": "c0.0.12a_03", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.12a_03/c0.0.12a_03.json", "time": "2021-04-26T19:18:00-00:00", "releaseTime": "2009-05-20T00:00:00-00:00"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! `core::game` 的错误类型。
|
||||
//!
|
||||
//! 采用手写 `Display`/`Error` 而不用 `thiserror`:core 的依赖面越窄越好(D3 模块纪律),
|
||||
//! 且这里的变体数量固定、不需要派生宏的收益。
|
||||
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// `core::game` 的失败集合。
|
||||
#[derive(Debug)]
|
||||
pub enum GameError {
|
||||
/// JSON 解析/序列化失败(版本清单、版本 JSON、profiles)。
|
||||
Json {
|
||||
/// 出错的文档种类,用于给用户看上下文(如 `version_manifest_v2`)。
|
||||
context: String,
|
||||
/// 底层 serde_json 错误。
|
||||
source: serde_json::Error,
|
||||
},
|
||||
/// 清单或版本 JSON 缺少必需的 `id`。
|
||||
MissingVersionId,
|
||||
/// `inheritsFrom` 指向的父版本在本仓库中不存在。
|
||||
MissingParent {
|
||||
/// 缺失的父版本 id。
|
||||
parent: String,
|
||||
/// 发起继承的子版本 id。
|
||||
required_by: String,
|
||||
},
|
||||
/// `inheritsFrom` 成环。
|
||||
CircularInheritance {
|
||||
/// 从根到环的 id 链(首尾为同一个 id)。
|
||||
chain: Vec<String>,
|
||||
},
|
||||
/// Maven 坐标(`group:artifact:version[:classifier][@ext]`)不合法。
|
||||
InvalidCoordinate {
|
||||
/// 原始字符串。
|
||||
raw: String,
|
||||
/// 不合法的原因。
|
||||
reason: &'static str,
|
||||
},
|
||||
/// 资源对象哈希不合法(长度不足以做两级分片)。
|
||||
InvalidAssetHash {
|
||||
/// 原始哈希。
|
||||
hash: String,
|
||||
},
|
||||
/// 文件系统错误。
|
||||
Io {
|
||||
/// 出错的路径。
|
||||
path: PathBuf,
|
||||
/// 底层 IO 错误。
|
||||
source: std::io::Error,
|
||||
},
|
||||
/// 已有草稿持有写权,同一仓库同时只允许一个写会话。
|
||||
DraftBusy,
|
||||
/// 实例隔离档位是 `custom`,但没有可用的自定义目录(不猜默认值)。
|
||||
CustomIsolationWithoutDir {
|
||||
/// 实例 id(可能为空:调用方没给实例上下文)。
|
||||
instance: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for GameError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Json { context, source } => write!(f, "解析 {context} 失败: {source}"),
|
||||
Self::MissingVersionId => write!(f, "版本 JSON 缺少 id"),
|
||||
Self::MissingParent {
|
||||
parent,
|
||||
required_by,
|
||||
} => write!(
|
||||
f,
|
||||
"版本 {required_by} 继承的父版本 {parent} 不存在(缺失前置版本)"
|
||||
),
|
||||
Self::CircularInheritance { chain } => {
|
||||
write!(f, "版本继承成环: {}", chain.join(" -> "))
|
||||
}
|
||||
Self::InvalidCoordinate { raw, reason } => {
|
||||
write!(f, "Maven 坐标不合法({reason}): {raw}")
|
||||
}
|
||||
Self::InvalidAssetHash { hash } => {
|
||||
write!(f, "资源哈希不合法(短于两个字符): {hash}")
|
||||
}
|
||||
Self::Io { path, source } => write!(f, "IO 失败 {}: {source}", path.display()),
|
||||
Self::DraftBusy => write!(f, "已有草稿持有写权(单写者约束)"),
|
||||
Self::CustomIsolationWithoutDir { instance } => {
|
||||
if instance.is_empty() {
|
||||
write!(f, "隔离档位为 custom,但没有指定自定义运行目录")
|
||||
} else {
|
||||
write!(
|
||||
f,
|
||||
"实例 {instance} 的隔离档位为 custom,但没有指定自定义运行目录"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for GameError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Json { source, .. } => Some(source),
|
||||
Self::Io { source, .. } => Some(source),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GameError {
|
||||
/// 包装 serde_json 错误并带上文档上下文。
|
||||
pub fn json(context: impl Into<String>, source: serde_json::Error) -> Self {
|
||||
Self::Json {
|
||||
context: context.into(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
/// 包装 IO 错误并带上路径。
|
||||
pub fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
|
||||
Self::Io {
|
||||
path: path.into(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//! `inheritsFrom` 继承解析、逐字段合并语义、patch 优先级排序。
|
||||
//!
|
||||
//! 依据:`DevDocs/analyse/03-version-download.md` §4.3(`resolve()`)、§4.4(merge 语义表)、
|
||||
//! §4.5(patch 生命周期)。
|
||||
//!
|
||||
//! 与 HMCL 的三处**有意差异**(都在 §4.3 的对照里):
|
||||
//! 1. **成环即报错**([`GameError::CircularInheritance`])而不是"断链后降级启动":
|
||||
//! 静默降级会让用户拿到"能启动但缺库"的实例,宁可明确告知清单坏了。
|
||||
//! 2. **缺失父版本报错**([`GameError::MissingParent`]),与 HMCL 的
|
||||
//! `NoSuchGameInstanceException` 一致。
|
||||
//! 3. 不实现 `toPatch()` 的"把已解析父层固化进 patches"优化——P0 实例数量小,
|
||||
//! 每次启动重算继承链(毫秒级);等有性能证据再加缓存。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::arguments::Arguments;
|
||||
use super::error::GameError;
|
||||
use super::manifest::VersionJson;
|
||||
|
||||
/// 父版本来源(仓库快照或测试里的内存表)。
|
||||
pub trait VersionSource {
|
||||
/// 按 id 取一份版本 JSON;不存在返回 `None`。
|
||||
///
|
||||
/// 实现方应自行处理 IO 错误——把失败转成 `None` 会让"父版本缺失"和"读盘失败"
|
||||
/// 混为一谈,因此读盘失败的实现应当在调用 [`resolve`] 前就把它暴露给用户。
|
||||
fn load(&self, id: &str) -> Option<VersionJson>;
|
||||
}
|
||||
|
||||
impl VersionSource for BTreeMap<String, VersionJson> {
|
||||
fn load(&self, id: &str) -> Option<VersionJson> {
|
||||
self.get(id).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
impl<F> VersionSource for F
|
||||
where
|
||||
F: Fn(&str) -> Option<VersionJson>,
|
||||
{
|
||||
fn load(&self, id: &str) -> Option<VersionJson> {
|
||||
self(id)
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析继承链,返回可交给 launch/download 的扁平清单。
|
||||
///
|
||||
/// 返回的清单保证:`inherits_from == None`、`patches == None`、`jar` 已回退为实例 id、
|
||||
/// `root == Some(true)`(见 [`VersionJson::is_resolved`])。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - 清单缺 `id` → [`GameError::MissingVersionId`]
|
||||
/// - 父版本缺失 → [`GameError::MissingParent`]
|
||||
/// - 继承成环 → [`GameError::CircularInheritance`]
|
||||
pub fn resolve<S: VersionSource>(
|
||||
source: &S,
|
||||
manifest: &VersionJson,
|
||||
) -> Result<VersionJson, GameError> {
|
||||
let root_id = manifest.require_id()?.to_owned();
|
||||
|
||||
// 1. 自根向祖先收集层(迭代而非递归:深层整合包的链可以很长)。
|
||||
let mut chain = vec![root_id.clone()];
|
||||
let mut layers = vec![manifest.clone()];
|
||||
while let Some(parent_id) = layers.last().and_then(|l| l.inherits_from.clone()) {
|
||||
if chain.iter().any(|id| id == &parent_id) {
|
||||
chain.push(parent_id);
|
||||
return Err(GameError::CircularInheritance { chain });
|
||||
}
|
||||
let required_by = layers
|
||||
.last()
|
||||
.and_then(|l| l.id.clone())
|
||||
.unwrap_or_else(|| root_id.clone());
|
||||
let parent = source
|
||||
.load(&parent_id)
|
||||
.ok_or_else(|| GameError::MissingParent {
|
||||
parent: parent_id.clone(),
|
||||
required_by,
|
||||
})?;
|
||||
chain.push(parent_id);
|
||||
layers.push(parent);
|
||||
}
|
||||
|
||||
// 2. 自最祖先向根折叠:每一层先与父层合并,再合并它自己的 patches。
|
||||
let mut folded: Option<VersionJson> = None;
|
||||
for layer in layers.into_iter().rev() {
|
||||
let merged = match &folded {
|
||||
Some(parent) => merge(&layer, parent),
|
||||
None => merge(&layer, &VersionJson::default()),
|
||||
};
|
||||
folded = Some(apply_patches(
|
||||
merged,
|
||||
&layer.patches.clone().unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
|
||||
// 3. 收尾:清继承标记、jar 兜底、补 id。
|
||||
let mut resolved = folded.unwrap_or_default();
|
||||
resolved.inherits_from = None;
|
||||
resolved.patches = None;
|
||||
resolved.priority = None;
|
||||
resolved.root = Some(true);
|
||||
if resolved.id.is_none() {
|
||||
resolved.id = Some(root_id.clone());
|
||||
}
|
||||
if resolved.jar.is_none() {
|
||||
resolved.jar = Some(root_id);
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
/// 按 `priority` 升序排好的 patch 列表(`None` 视作最小值,稳定排序保持插入顺序)。
|
||||
pub fn sorted_patches(patches: &[VersionJson]) -> Vec<&VersionJson> {
|
||||
let mut sorted: Vec<&VersionJson> = patches.iter().collect();
|
||||
sorted.sort_by_key(|p| p.priority.unwrap_or(i32::MIN));
|
||||
sorted
|
||||
}
|
||||
|
||||
/// 把 patch 层按优先级依次叠加到基底清单上(先原版、后 loader:`0 < 30000`)。
|
||||
pub fn apply_patches(base: VersionJson, patches: &[VersionJson]) -> VersionJson {
|
||||
let mut out = base;
|
||||
for patch in sorted_patches(patches) {
|
||||
out = merge(patch, &out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 合并两层清单:**子(或后合并的层)覆盖父**,语义逐字段照 analyse/03 §4.4。
|
||||
///
|
||||
/// 非直觉的两点,测试里各有一条守门:
|
||||
/// - `downloads`/`logging` **整体覆盖**:子层只写 `server` 会让父层的 `client` 消失;
|
||||
/// - `compliance_level` **只取子**:父层的值被丢弃(缺则是 `None`)。
|
||||
///
|
||||
/// 这两点**与 analyse/03 §8.3 第 15 条建议相左**(该条主张逐字段/逐 key 合并)。
|
||||
/// 保留现状的理由是生态兼容:现实 modpack/loader 的版本 JSON 按 HMCL/原版语义编写,
|
||||
/// 逐 key 合并会让"子层想整份换掉 downloads"的场景语义漂移。经 tower 裁决 2026-10-29 维持。
|
||||
pub fn merge(child: &VersionJson, parent: &VersionJson) -> VersionJson {
|
||||
let mut extra = parent.extra.clone();
|
||||
extra.extend(child.extra.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
|
||||
VersionJson {
|
||||
id: child.id.clone().or_else(|| parent.id.clone()),
|
||||
name: child.name.clone().or_else(|| parent.name.clone()),
|
||||
// 解析后不再带继承。
|
||||
inherits_from: None,
|
||||
jar: child.jar.clone().or_else(|| parent.jar.clone()),
|
||||
main_class: child
|
||||
.main_class
|
||||
.clone()
|
||||
.or_else(|| parent.main_class.clone()),
|
||||
minecraft_arguments: child
|
||||
.minecraft_arguments
|
||||
.clone()
|
||||
.or_else(|| parent.minecraft_arguments.clone()),
|
||||
arguments: Arguments::merge(parent.arguments.as_ref(), child.arguments.as_ref()),
|
||||
assets: child.assets.clone().or_else(|| parent.assets.clone()),
|
||||
asset_index: child
|
||||
.asset_index
|
||||
.clone()
|
||||
.or_else(|| parent.asset_index.clone()),
|
||||
compliance_level: child.compliance_level,
|
||||
java_version: child
|
||||
.java_version
|
||||
.clone()
|
||||
.or_else(|| parent.java_version.clone()),
|
||||
libraries: match (&parent.libraries, &child.libraries) {
|
||||
(None, None) => None,
|
||||
(Some(parent), None) => Some(parent.clone()),
|
||||
(None, Some(child)) => Some(child.clone()),
|
||||
// 父在前、子在后追加(重复项由使用方按需去重)。
|
||||
(Some(parent), Some(child)) => {
|
||||
Some(parent.iter().chain(child.iter()).cloned().collect())
|
||||
}
|
||||
},
|
||||
// 整体覆盖,不逐 key 合并。
|
||||
downloads: child.downloads.clone().or_else(|| parent.downloads.clone()),
|
||||
logging: child.logging.clone().or_else(|| parent.logging.clone()),
|
||||
version_type: child.version_type.or(parent.version_type),
|
||||
time: child.time.clone().or_else(|| parent.time.clone()),
|
||||
release_time: child
|
||||
.release_time
|
||||
.clone()
|
||||
.or_else(|| parent.release_time.clone()),
|
||||
minimum_launcher_version: match (
|
||||
parent.minimum_launcher_version,
|
||||
child.minimum_launcher_version,
|
||||
) {
|
||||
(Some(a), Some(b)) => Some(a.max(b)),
|
||||
(a, None) => a,
|
||||
(None, b) => b,
|
||||
},
|
||||
// 合并结果一律是"根清单"。
|
||||
root: Some(true),
|
||||
hidden: child.hidden,
|
||||
// patch 生命周期由 resolve/apply_patches 管,合并结果不带 patches。
|
||||
patches: None,
|
||||
priority: None,
|
||||
extra,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! 资源索引(`assets/indexes/<id>.json`)的模型与逐对象展开。
|
||||
//!
|
||||
//! 依据:`DevDocs/analyse/03-version-download.md` §6.5(索引下载)、§6.6(逐对象下载)。
|
||||
//!
|
||||
//! 对象是**内容寻址**的:路径 `assets/objects/<hash[0..2]>/<hash>`、URL
|
||||
//! `https://resources.download.minecraft.net/<hash[0..2]>/<hash>`,校验值就是哈希本身。
|
||||
//! 因此索引里不需要 `sha1` 字段——文件名即摘要,这也是"两个实例共用同一资源"零成本的原因。
|
||||
//!
|
||||
//! 本模块只做"索引 → 待下载对象"的纯计算,不碰网络;落盘路径一律问
|
||||
//! [`GameLayout`],不在本地拼字符串。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::super::error::GameError;
|
||||
use super::super::layout::GameLayout;
|
||||
use super::plan::{FileRole, PlanFile};
|
||||
use crate::download::ResourceKind;
|
||||
|
||||
/// 资源对象的官方源基址。
|
||||
pub const ASSET_OBJECT_BASE: &str = "https://resources.download.minecraft.net";
|
||||
|
||||
/// 一个资源对象。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AssetObject {
|
||||
/// 内容 SHA-1(同时是文件名)。
|
||||
#[serde(default)]
|
||||
pub hash: String,
|
||||
/// 字节数。
|
||||
#[serde(default)]
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
impl AssetObject {
|
||||
/// 相对 `assets/objects/` 的路径 `xx/<hash>`。
|
||||
#[must_use]
|
||||
pub fn location(&self) -> String {
|
||||
let shard = self.hash.get(0..2).unwrap_or_default();
|
||||
format!("{shard}/{}", self.hash)
|
||||
}
|
||||
|
||||
/// 官方下载地址(镜像由下载引擎按前缀规则展开)。
|
||||
#[must_use]
|
||||
pub fn url(&self) -> String {
|
||||
format!("{ASSET_OBJECT_BASE}/{}", self.location())
|
||||
}
|
||||
|
||||
/// 哈希是否够做两级分片(短于两个字符说明索引坏了)。
|
||||
#[must_use]
|
||||
pub fn is_well_formed(&self) -> bool {
|
||||
self.hash.len() >= 2 && self.hash.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
}
|
||||
}
|
||||
|
||||
/// 一份资源索引。
|
||||
///
|
||||
/// `virtual` / `map_to_resources` 只影响**启动期**的目录映射(`assets/virtual/<id>`、
|
||||
/// `resources/`),不影响下载,这里保真存下但不消费。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AssetIndex {
|
||||
/// 对象表:键是资源名(`minecraft/sounds/...ogg`),值是内容描述。
|
||||
#[serde(default)]
|
||||
pub objects: BTreeMap<String, AssetObject>,
|
||||
/// 旧版:是否要在启动期铺到 `assets/virtual/<id>/`。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub r#virtual: Option<bool>,
|
||||
/// 旧版:是否要映射到存档目录旁的 `resources/`。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub map_to_resources: Option<bool>,
|
||||
}
|
||||
|
||||
impl AssetIndex {
|
||||
/// 解析索引 JSON。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// JSON 非法(例如镜像返回了 HTML 错误页)时返回 [`GameError::Json`]。
|
||||
pub fn from_json_slice(bytes: &[u8]) -> Result<Self, GameError> {
|
||||
serde_json::from_slice(bytes).map_err(|e| GameError::json("asset_index", e))
|
||||
}
|
||||
|
||||
/// 对象数量。
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.objects.len()
|
||||
}
|
||||
|
||||
/// 是否没有对象。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.objects.is_empty()
|
||||
}
|
||||
|
||||
/// 全部对象的总字节数(进度预算与 `--dry-run` 总大小用)。
|
||||
#[must_use]
|
||||
pub fn total_bytes(&self) -> u64 {
|
||||
self.objects.values().map(|object| object.size).sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// 把索引展开成待下载文件清单(按资源名排序,保证计划可复现)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 某个对象哈希不合法(短于两字符 / 非十六进制)时返回 [`GameError::InvalidAssetHash`]。
|
||||
pub fn plan(layout: &GameLayout, index: &AssetIndex) -> Result<Vec<PlanFile>, GameError> {
|
||||
let mut files = Vec::with_capacity(index.objects.len());
|
||||
for (name, object) in &index.objects {
|
||||
if !object.is_well_formed() {
|
||||
return Err(GameError::InvalidAssetHash {
|
||||
hash: object.hash.clone(),
|
||||
});
|
||||
}
|
||||
files.push(PlanFile {
|
||||
role: FileRole::Asset,
|
||||
name: name.clone(),
|
||||
url: object.url(),
|
||||
sha1: Some(object.hash.clone()),
|
||||
size: Some(object.size),
|
||||
dest: layout.asset_object(&object.hash)?,
|
||||
resource: ResourceKind::Asset,
|
||||
});
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{plan, AssetIndex, AssetObject};
|
||||
use crate::game::layout::GameLayout;
|
||||
|
||||
const HASH: &str = "0f9d6b1c0d5f4e3b2a1908070605040302010f0e";
|
||||
|
||||
#[test]
|
||||
fn location_and_url_use_two_char_shard() {
|
||||
let object = AssetObject {
|
||||
hash: HASH.to_string(),
|
||||
size: 7,
|
||||
};
|
||||
assert_eq!(object.location(), format!("0f/{HASH}"));
|
||||
assert_eq!(
|
||||
object.url(),
|
||||
format!("https://resources.download.minecraft.net/0f/{HASH}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_index_and_plans_objects() {
|
||||
let json = format!(
|
||||
r#"{{"objects":{{"minecraft/lang/zh_cn.json":{{"hash":"{HASH}","size":42}}}}}}"#
|
||||
);
|
||||
let index = AssetIndex::from_json_slice(json.as_bytes()).expect("索引应可解析");
|
||||
assert_eq!(index.len(), 1);
|
||||
assert_eq!(index.total_bytes(), 42);
|
||||
|
||||
let layout = GameLayout::new("/games/.minecraft");
|
||||
let files = plan(&layout, &index).expect("展开应成功");
|
||||
assert_eq!(files.len(), 1);
|
||||
let file = &files[0];
|
||||
assert_eq!(file.sha1.as_deref(), Some(HASH));
|
||||
assert_eq!(file.size, Some(42));
|
||||
assert!(file.dest.ends_with(format!("objects/0f/{HASH}")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_short_hash() {
|
||||
let mut index = AssetIndex::default();
|
||||
index.objects.insert(
|
||||
"a".to_string(),
|
||||
AssetObject {
|
||||
hash: "a".to_string(),
|
||||
size: 1,
|
||||
},
|
||||
);
|
||||
let layout = GameLayout::new("/games/.minecraft");
|
||||
assert!(plan(&layout, &index).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_asset_keys_as_names() {
|
||||
let json = format!(
|
||||
r#"{{"objects":{{"icons/icon_16x16.png":{{"hash":"{HASH}","size":1}},"lang/en_us.json":{{"hash":"{HASH}","size":2}}}}}}"#
|
||||
);
|
||||
let index = AssetIndex::from_json_slice(json.as_bytes()).expect("索引应可解析");
|
||||
let files = plan(&GameLayout::new("/g"), &index).expect("展开应成功");
|
||||
let names: Vec<&str> = files.iter().map(|f| f.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["icons/icon_16x16.png", "lang/en_us.json"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
//! 落盘执行:跳过判定 → 并发下载(引擎负责镜像/退避/续传/校验)→ 原子写版本 JSON。
|
||||
//!
|
||||
//! 依据:`DevDocs/analyse/03-version-download.md` §6.9(完整性修复路径的"存在即信"分级)、
|
||||
//! `DevDocs/PlanDoc/05-download-and-mirrors.md` §2(按资源类型分层)、§5(静态展开并行度)。
|
||||
//!
|
||||
//! # 跳过判定(幂等的地基)
|
||||
//!
|
||||
//! 判据分三级,与 analyse/03 §6.9 的思路一致:
|
||||
//! - 有 sha1 → **重算摘要比对**(唯一可信判据;代价是读一遍盘);
|
||||
//! - 无 sha1 有大小 → 只比大小(清单没给摘要时退一步);
|
||||
//! - 两者都没有 → 存在即信(HMCL 同类做法,留给后续"一键修复"补校验)。
|
||||
//!
|
||||
//! 跳过不写盘、不进网络,因此"重复 install 同版本"第二次几乎零成本。
|
||||
//!
|
||||
//! # 失败策略
|
||||
//!
|
||||
//! **不整体回滚**:已下好的共享层文件(libraries/assets)留在原地复用,失败信息逐文件结构化
|
||||
//! 返回,由调用方决定是重试、还是只提示"缺哪几个文件"。这与 analyse/03 §6.3 的"宁可给出
|
||||
//! 可修复的骨架"一致,但**版本清单与实例登记只在全量成功后才写**,不会出现半实例。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_util::stream::{self, StreamExt};
|
||||
|
||||
use super::plan::{FileRole, PlanFile};
|
||||
use super::InstallPlan;
|
||||
use crate::download::verify;
|
||||
use crate::download::{DownloadEngine, DownloadError, DownloadErrorKind, HashAlgo};
|
||||
use crate::platform::atomic::write_atomic;
|
||||
|
||||
/// 单个文件的结局。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FileStatus {
|
||||
/// 本次真的下载(或从缓存直取)并落盘。
|
||||
Downloaded,
|
||||
/// 已存在且校验通过,跳过。
|
||||
Skipped,
|
||||
/// 失败(原因在 [`FileOutcome::failure`])。
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl FileStatus {
|
||||
/// 稳定字符串 id。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Downloaded => "downloaded",
|
||||
Self::Skipped => "skipped",
|
||||
Self::Failed => "failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次失败的分类信息(调用方据此给 `install_network` / `install_integrity` 等稳定码)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FileFailure {
|
||||
/// 下载错误分类。
|
||||
pub kind: DownloadErrorKind,
|
||||
/// 人读说明。
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 一个文件的执行结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FileOutcome {
|
||||
/// 角色。
|
||||
pub role: FileRole,
|
||||
/// 人读标识。
|
||||
pub name: String,
|
||||
/// 落盘路径。
|
||||
pub dest: std::path::PathBuf,
|
||||
/// 结局。
|
||||
pub status: FileStatus,
|
||||
/// 实际字节数(跳过时为盘上大小)。
|
||||
pub bytes: u64,
|
||||
/// 成功的源标识(`bmclapi` / `official` / `cache` / `metadata`)。
|
||||
pub source: Option<String>,
|
||||
/// 失败详情。
|
||||
pub failure: Option<FileFailure>,
|
||||
}
|
||||
|
||||
impl FileOutcome {
|
||||
/// 是否失败。
|
||||
#[must_use]
|
||||
pub fn failed(&self) -> bool {
|
||||
self.status == FileStatus::Failed
|
||||
}
|
||||
}
|
||||
|
||||
/// 进度回调:每完成一个文件调用一次(可跨线程,由执行器持有 `Arc`)。
|
||||
///
|
||||
/// CLI 用它往 stderr 打实时进度;GUI 侧直接消费 `ProgressEvent` 事件流即可,不必用这个。
|
||||
pub type ProgressSink = Arc<dyn Fn(&FileOutcome) + Send + Sync>;
|
||||
|
||||
/// 一次安装的执行结果。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InstallReport {
|
||||
/// 逐文件结果(顺序不保证,按角色/大小汇总即可)。
|
||||
pub files: Vec<FileOutcome>,
|
||||
/// 版本清单落盘路径;`None` = 有文件失败,故意没写(见 [`execute`] 的说明)。
|
||||
pub version_json: Option<PathBuf>,
|
||||
/// 计划阶段带来的告警(缺件降级说明)。
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl InstallReport {
|
||||
/// 真正下载的文件数。
|
||||
#[must_use]
|
||||
pub fn downloaded(&self) -> usize {
|
||||
self.files
|
||||
.iter()
|
||||
.filter(|file| file.status == FileStatus::Downloaded)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// 跳过(已存在且校验通过)的文件数。
|
||||
#[must_use]
|
||||
pub fn skipped(&self) -> usize {
|
||||
self.files
|
||||
.iter()
|
||||
.filter(|file| file.status == FileStatus::Skipped)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// 失败的文件数。
|
||||
#[must_use]
|
||||
pub fn failed(&self) -> usize {
|
||||
self.files.iter().filter(|file| file.failed()).count()
|
||||
}
|
||||
|
||||
/// 本次写入的字节数(只算真正下载的)。
|
||||
#[must_use]
|
||||
pub fn bytes(&self) -> u64 {
|
||||
self.files
|
||||
.iter()
|
||||
.filter(|file| file.status == FileStatus::Downloaded)
|
||||
.map(|file| file.bytes)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// 是否全部就绪(无失败)。
|
||||
#[must_use]
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.failed() == 0
|
||||
}
|
||||
|
||||
/// 失败文件列表(最多取前 `limit` 条,给 CLI 拼提示用)。
|
||||
#[must_use]
|
||||
pub fn failures(&self, limit: usize) -> Vec<&FileOutcome> {
|
||||
self.files
|
||||
.iter()
|
||||
.filter(|file| file.failed())
|
||||
.take(limit)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 失败里最"严重"的大类:有校验不符就先报它,其余按网络/IO 归。
|
||||
#[must_use]
|
||||
pub fn failure_kind(&self) -> Option<DownloadErrorKind> {
|
||||
let mut fallback = None;
|
||||
for file in self.files.iter().filter(|file| file.failed()) {
|
||||
let Some(failure) = &file.failure else {
|
||||
continue;
|
||||
};
|
||||
if failure.kind == DownloadErrorKind::Integrity {
|
||||
return Some(DownloadErrorKind::Integrity);
|
||||
}
|
||||
fallback.get_or_insert(failure.kind);
|
||||
}
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行安装计划。
|
||||
///
|
||||
/// 具体步骤:
|
||||
/// 1. 规划期已取到的元数据(资源索引,见 [`InstallPlan::prefetch`])先原子落盘,零网络;
|
||||
/// 2. 其余文件并发下载(`concurrency` 上限,引擎内部另有信号量),每个文件带进度回调;
|
||||
/// 3. **全量就绪**才写 `versions/<id>/<id>.json`:这份清单是"已安装"的判据
|
||||
/// ([`GameLayout::is_version_installed`]),半成品写进去会把缺件的版本报成已安装。
|
||||
/// 共享层里已下好的文件留着复用,重跑 `ull install` 会补齐剩下的。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 版本 JSON 落盘失败时返回 IO 错误;文件下载失败**不**返回错误,而是进
|
||||
/// [`InstallReport`](调用方按 [`InstallReport::is_complete`] 决策)。
|
||||
pub async fn execute(
|
||||
engine: &DownloadEngine,
|
||||
plan: &InstallPlan,
|
||||
concurrency: usize,
|
||||
sink: Option<ProgressSink>,
|
||||
) -> Result<InstallReport, super::InstallError> {
|
||||
let mut outcomes: Vec<FileOutcome> = Vec::with_capacity(plan.files.len());
|
||||
let mut prefetched: Vec<&PlanFile> = Vec::new();
|
||||
|
||||
for payload in &plan.prefetched {
|
||||
let Some(file) = plan.files.iter().find(|file| file.dest == payload.dest) else {
|
||||
continue;
|
||||
};
|
||||
// 盘上已是同一份内容就不再重写(规划期为了枚举对象必须联网取索引,
|
||||
// 但那不等于"需要重新落盘"——重装的"下载 0 个"要名副其实)。
|
||||
if let Some(bytes) = current_bytes(file) {
|
||||
let outcome = FileOutcome {
|
||||
role: file.role,
|
||||
name: file.name.clone(),
|
||||
dest: file.dest.clone(),
|
||||
status: FileStatus::Skipped,
|
||||
bytes,
|
||||
source: None,
|
||||
failure: None,
|
||||
};
|
||||
if let Some(sink) = &sink {
|
||||
sink(&outcome);
|
||||
}
|
||||
outcomes.push(outcome);
|
||||
prefetched.push(file);
|
||||
continue;
|
||||
}
|
||||
let written = write_atomic(&payload.dest, &payload.bytes).map_err(|error| {
|
||||
super::InstallError::new(super::InstallErrorKind::Io, error.to_string())
|
||||
});
|
||||
let outcome = match written {
|
||||
Ok(()) => FileOutcome {
|
||||
role: file.role,
|
||||
name: file.name.clone(),
|
||||
dest: file.dest.clone(),
|
||||
status: FileStatus::Downloaded,
|
||||
bytes: payload.bytes.len() as u64,
|
||||
source: Some("metadata".to_string()),
|
||||
failure: None,
|
||||
},
|
||||
Err(error) => FileOutcome {
|
||||
role: file.role,
|
||||
name: file.name.clone(),
|
||||
dest: file.dest.clone(),
|
||||
status: FileStatus::Failed,
|
||||
bytes: 0,
|
||||
source: None,
|
||||
failure: Some(FileFailure {
|
||||
kind: DownloadErrorKind::Io,
|
||||
message: error.to_string(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
if let Some(sink) = &sink {
|
||||
sink(&outcome);
|
||||
}
|
||||
outcomes.push(outcome);
|
||||
prefetched.push(file);
|
||||
}
|
||||
|
||||
let pending: Vec<&PlanFile> = plan
|
||||
.files
|
||||
.iter()
|
||||
.filter(|file| !prefetched.iter().any(|done| done.dest == file.dest))
|
||||
.collect();
|
||||
|
||||
let downloaded: Vec<FileOutcome> = stream::iter(pending)
|
||||
.map(|file| {
|
||||
let sink = sink.clone();
|
||||
async move {
|
||||
let outcome = install_one(engine, file).await;
|
||||
if let Some(sink) = &sink {
|
||||
sink(&outcome);
|
||||
}
|
||||
outcome
|
||||
}
|
||||
})
|
||||
.buffer_unordered(concurrency.max(1))
|
||||
.collect()
|
||||
.await;
|
||||
outcomes.extend(downloaded);
|
||||
|
||||
// 版本清单只在**全量就绪**时落盘:`is_version_installed()` 以清单存在为判据,
|
||||
// 半成品写进去会让 `instances list` 把"缺 jar 的版本"报成已安装。
|
||||
let complete = outcomes.iter().all(|outcome| !outcome.failed());
|
||||
let version_json = if complete {
|
||||
write_version_json(&plan.version_json_path, plan)?;
|
||||
Some(plan.version_json_path.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(InstallReport {
|
||||
files: outcomes,
|
||||
version_json,
|
||||
warnings: plan.warnings.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 单文件:先判"已就绪",否则走引擎下载。
|
||||
async fn install_one(engine: &DownloadEngine, file: &PlanFile) -> FileOutcome {
|
||||
if let Some(bytes) = current_bytes(file) {
|
||||
return FileOutcome {
|
||||
role: file.role,
|
||||
name: file.name.clone(),
|
||||
dest: file.dest.clone(),
|
||||
status: FileStatus::Skipped,
|
||||
bytes,
|
||||
source: None,
|
||||
failure: None,
|
||||
};
|
||||
}
|
||||
match engine.download(file.request()).await {
|
||||
Ok(outcome) => FileOutcome {
|
||||
role: file.role,
|
||||
name: file.name.clone(),
|
||||
dest: file.dest.clone(),
|
||||
status: FileStatus::Downloaded,
|
||||
bytes: outcome.bytes,
|
||||
source: Some(outcome.source),
|
||||
failure: None,
|
||||
},
|
||||
Err(error) => FileOutcome {
|
||||
role: file.role,
|
||||
name: file.name.clone(),
|
||||
dest: file.dest.clone(),
|
||||
status: FileStatus::Failed,
|
||||
bytes: 0,
|
||||
source: None,
|
||||
failure: Some(describe(&error)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 文件已就绪则返回盘上大小(跳过判定,见模块文档的三级分级)。
|
||||
#[must_use]
|
||||
pub fn current_bytes(file: &PlanFile) -> Option<u64> {
|
||||
if !file.dest.is_file() {
|
||||
return None;
|
||||
}
|
||||
let size = std::fs::metadata(&file.dest).ok()?.len();
|
||||
match file.sha1.as_deref().filter(|hex| !hex.is_empty()) {
|
||||
Some(expected) => {
|
||||
let actual = verify::digest_file(&file.dest, HashAlgo::Sha1).ok()?;
|
||||
verify::matches(expected, &actual).then_some(size)
|
||||
}
|
||||
None => match file.size {
|
||||
Some(expected) => (size == expected).then_some(size),
|
||||
// 没有摘要也没有大小:存在即信(后续"一键修复"再补校验)。
|
||||
None => Some(size),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 写解析后的版本清单(原子写)。
|
||||
fn write_version_json(path: &Path, plan: &InstallPlan) -> Result<(), super::InstallError> {
|
||||
let text = plan.version_json.to_json_string().map_err(|error| {
|
||||
super::InstallError::new(super::InstallErrorKind::Metadata, error.to_string())
|
||||
})?;
|
||||
write_atomic(path, text.as_bytes())
|
||||
.map_err(|error| super::InstallError::new(super::InstallErrorKind::Io, error.to_string()))
|
||||
}
|
||||
|
||||
fn describe(error: &DownloadError) -> FileFailure {
|
||||
FileFailure {
|
||||
kind: error.kind,
|
||||
message: error.message.clone(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
//! 实例登记:`config/profiles.json` + `config/instances/<id>.json`(稀疏、幂等)。
|
||||
//!
|
||||
//! 依据:`DevDocs/PlanDoc/04-data-and-config.md` §4(D20 实例模型)、§2(`.ULL/config/`)、
|
||||
//! `DevDocs/PlanDoc/research/04-launcher-configs.md` §5(实例键草案)。
|
||||
//!
|
||||
//! # 为什么是两份文件
|
||||
//!
|
||||
//! - `profiles.json` = **档案列表**(UI 顺序、展示名、绑定版本),是"有哪些实例"的真相源;
|
||||
//! - `instances/<id>.json` = **每实例设置**,稀疏:只写用户显式改过的键。
|
||||
//!
|
||||
//! §5 的关键结论:`isolation` 默认 `inherit` 且**不落盘**——这样改全局模式对存量实例立即生效,
|
||||
//! 直接避开 PCL2"默认值被固化成实例值"的缺陷。因此 [`InstanceConfig::new`] 的
|
||||
//! `isolation` 是 `None`(序列化时整个键消失);档位的**派生与切换检测**在
|
||||
//! [`crate::game::isolation`](M30 落地),本模块只管结构与落盘。
|
||||
//!
|
||||
//! # 幂等
|
||||
//!
|
||||
//! 同一版本重复 `install`:按 `version_id` + 展示名复用已有档案,返回 `created = false`,
|
||||
//! 不产生第二个实例、不改已有实例的 id(id 一旦写进档案就是稳定的)。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::super::error::GameError;
|
||||
use super::super::isolation::{IsolationMode, ModeChange, ModeChangeWarning};
|
||||
use super::super::profiles::{InstanceProfile, ProfileList};
|
||||
use crate::platform::atomic::write_atomic;
|
||||
use crate::platform::layout::LauncherLayout;
|
||||
|
||||
/// 每实例设置文件的 schema 版本。
|
||||
///
|
||||
/// 与 `setting::instance_keys::instance_version` 的 minor 对应:D20 加了 `lastModeChange`,
|
||||
/// 老文件缺这个键即「没切换过」,不需要迁移步骤。
|
||||
pub const INSTANCE_CONFIG_VERSION: u32 = 1;
|
||||
|
||||
/// 版本隔离档位(`isolation`)。
|
||||
///
|
||||
/// 缺省 `inherit` **不是**这个枚举的一个值,而是"键不存在"([`InstanceConfig::isolation`]
|
||||
/// 为 `None`)——这样稀疏序列化天然表达"跟随全局"。
|
||||
///
|
||||
/// 派生规则见 [`crate::game::isolation::derive_game_dir`]:`isolated` → `versions/<versionId>/`、
|
||||
/// `shared` → 游戏根 `.minecraft/`、`custom` → 用户指定的 `gameDir`。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Isolation {
|
||||
/// 独立游戏目录(派生 `versions/<versionId>/`)。
|
||||
#[serde(alias = "per_version")]
|
||||
Isolated,
|
||||
/// 与全局共用 `.minecraft`(HMCL 传统形态)。
|
||||
Shared,
|
||||
/// 用户指定的目录(此时 `gameDir` 必须有值)。
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl Isolation {
|
||||
/// 稳定字符串 id。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Isolated => "isolated",
|
||||
Self::Shared => "shared",
|
||||
Self::Custom => "custom",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从字符串解析(未知值返回 `None`,由调用方决定是报错还是忽略)。
|
||||
///
|
||||
/// `per_version` 是 M12 键草案里的旧字面量,读兼容映射到 `isolated`(语义相同)。
|
||||
#[must_use]
|
||||
pub fn parse(raw: &str) -> Option<Self> {
|
||||
match raw.trim() {
|
||||
"isolated" | "per_version" => Some(Self::Isolated),
|
||||
"shared" => Some(Self::Shared),
|
||||
"custom" => Some(Self::Custom),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 实例可覆盖的设置键(enum 白名单)。
|
||||
///
|
||||
/// research/04 §5 的教训:**不要用字符串集合**,拼错会静默不生效;这里用枚举,
|
||||
/// 后续 M28 接语义时编译器会逼着把每个键处理干净。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum OverrideKey {
|
||||
/// 最大堆内存。
|
||||
MaxMemory,
|
||||
/// Java 运行时选择。
|
||||
JavaType,
|
||||
/// 工作目录覆盖。
|
||||
RunningDirectory,
|
||||
}
|
||||
|
||||
/// 每实例设置(`config/instances/<id>.json`)。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceConfig {
|
||||
/// schema 版本(迁移链入口)。
|
||||
#[serde(default = "default_version")]
|
||||
pub schema_version: u32,
|
||||
/// 实例 id(稳定 UUID,与文件名一致)。
|
||||
pub id: String,
|
||||
/// 绑定的版本 id(`versions/<versionId>/`)。
|
||||
pub version_id: String,
|
||||
/// 隔离档位;`None` = `inherit`(跟随全局,不落盘)。
|
||||
///
|
||||
/// 显式写 `"inherit"` 也照读(归一成 `None`)——见
|
||||
/// [`deserialize_isolation`]:schema 白名单允许用户/AI 写这个字面量,
|
||||
/// 读路径必须闭环,否则「显式写回默认值」的实例文件会让启动直接报 unknown variant。
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
deserialize_with = "deserialize_isolation"
|
||||
)]
|
||||
pub isolation: Option<Isolation>,
|
||||
/// 自定义游戏目录(仅 `isolation = custom` 时有值)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub game_dir: Option<PathBuf>,
|
||||
/// 该实例覆盖了哪些设置键。
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub overrides: Vec<OverrideKey>,
|
||||
/// 上次隔离档位切换的记录(`lastModeChange`;没切过就不落盘)。
|
||||
///
|
||||
/// 由 [`crate::game::isolation::observe_mode`] 维护,只记事实(含双侧探测),**不迁移数据**。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_mode_change: Option<ModeChange>,
|
||||
/// 未识别字段原样保留(向前兼容)。
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
fn default_version() -> u32 {
|
||||
INSTANCE_CONFIG_VERSION
|
||||
}
|
||||
|
||||
/// `isolation` 的反序列化:显式 `"inherit"` 归一成「键不存在」(`None`)。
|
||||
///
|
||||
/// 为什么需要:`setting::instance_keys` 的白名单里有 `inherit`(读/显示要用),
|
||||
/// 用户或 AI 把档位写回默认值时就会落一个 `"isolation": "inherit"`;从枚举角度它
|
||||
/// 是**没有变体**的(`inherit` 的真相是稀疏 —— 键缺失),所以在这里折叠成 `None`,
|
||||
/// 顺带把 `per_version` 旧字面量也接住(`Isolation::parse` 负责映射)。
|
||||
fn deserialize_isolation<'de, D>(deserializer: D) -> Result<Option<Isolation>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
match Option::<String>::deserialize(deserializer)? {
|
||||
None => Ok(None),
|
||||
Some(text) if text.trim() == "inherit" => Ok(None),
|
||||
Some(text) => Isolation::parse(&text).map(Some).ok_or_else(|| {
|
||||
serde::de::Error::unknown_variant(&text, &["inherit", "isolated", "shared", "custom"])
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
impl InstanceConfig {
|
||||
/// 新建一份最小设置:`isolation` 留空(= inherit,不落盘)。
|
||||
#[must_use]
|
||||
pub fn new(id: impl Into<String>, version_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
schema_version: INSTANCE_CONFIG_VERSION,
|
||||
id: id.into(),
|
||||
version_id: version_id.into(),
|
||||
isolation: None,
|
||||
game_dir: None,
|
||||
overrides: Vec::new(),
|
||||
last_mode_change: None,
|
||||
extra: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否跟随全局隔离模式(`isolation` 键缺失)。
|
||||
#[must_use]
|
||||
pub fn inherits_isolation(&self) -> bool {
|
||||
self.isolation.is_none()
|
||||
}
|
||||
|
||||
/// 生效隔离档位(覆盖链第二层落到全局第一层,`inherit` 在这里被解析掉)。
|
||||
#[must_use]
|
||||
pub fn effective_isolation(&self, global: IsolationMode) -> Isolation {
|
||||
self.isolation.unwrap_or_else(|| global.as_isolation())
|
||||
}
|
||||
|
||||
/// 取走待展示的切换提示(取走后 `warned` 置真,调用方负责落盘)。
|
||||
pub fn take_mode_change_warning(&mut self) -> Option<ModeChangeWarning> {
|
||||
self.last_mode_change.as_mut()?.take_warning()
|
||||
}
|
||||
|
||||
/// 解析。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// JSON 非法时返回 [`GameError::Json`]。
|
||||
pub fn from_json_str(json: &str) -> Result<Self, GameError> {
|
||||
serde_json::from_str(json).map_err(|e| GameError::json("instance_config", e))
|
||||
}
|
||||
|
||||
/// 序列化(格式化,便于用户手改与 diff)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 序列化失败时返回 [`GameError::Json`]。
|
||||
pub fn to_json_string(&self) -> Result<String, GameError> {
|
||||
serde_json::to_string_pretty(self).map_err(|e| GameError::json("instance_config", e))
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次登记的结果。
|
||||
#[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
|
||||
///
|
||||
/// 读盘失败、非 UTF-8 或 JSON 非法。
|
||||
pub fn read_profiles(layout: &LauncherLayout) -> Result<ProfileList, GameError> {
|
||||
let path = layout.profiles_config();
|
||||
let bytes = match std::fs::read(&path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(ProfileList::default());
|
||||
}
|
||||
Err(error) => return Err(GameError::io(&path, error)),
|
||||
};
|
||||
let Ok(text) = String::from_utf8(bytes) else {
|
||||
return Err(GameError::io(
|
||||
&path,
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, "profiles.json 不是 UTF-8"),
|
||||
));
|
||||
};
|
||||
ProfileList::from_json_str(&text)
|
||||
}
|
||||
|
||||
/// 写实例清单(原子写)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 序列化失败或原子写失败。
|
||||
pub fn write_profiles(layout: &LauncherLayout, profiles: &ProfileList) -> Result<(), GameError> {
|
||||
let text = profiles.to_json_string()?;
|
||||
write_atomic(&layout.profiles_config(), text.as_bytes())
|
||||
.map_err(|error| GameError::io(layout.profiles_config(), std::io::Error::other(error)))
|
||||
}
|
||||
|
||||
/// 写每实例设置(原子写)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 实例 id 不合法、序列化失败或原子写失败。
|
||||
pub fn write_instance_config(
|
||||
layout: &LauncherLayout,
|
||||
config: &InstanceConfig,
|
||||
) -> Result<PathBuf, GameError> {
|
||||
let path = instance_config_path(layout, &config.id)?;
|
||||
let text = config.to_json_string()?;
|
||||
write_atomic(&path, text.as_bytes())
|
||||
.map_err(|error| GameError::io(&path, std::io::Error::other(error)))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// 读每实例设置;缺失返回 `None`。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 实例 id 不合法、读盘失败或 JSON 非法。
|
||||
pub fn read_instance_config(
|
||||
layout: &LauncherLayout,
|
||||
id: &str,
|
||||
) -> Result<Option<InstanceConfig>, GameError> {
|
||||
let path = instance_config_path(layout, id)?;
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(text) => InstanceConfig::from_json_str(&text).map(Some),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(GameError::io(&path, error)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `config/instances/<id>.json`(实例 id 白名单校验在布局层,挡掉 `../` 穿越)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 实例 id 不合法时返回 [`GameError::Io`](`PlatformError` 没有对应变体)。
|
||||
fn instance_config_path(layout: &LauncherLayout, id: &str) -> Result<PathBuf, GameError> {
|
||||
layout
|
||||
.instance_config(id)
|
||||
.map_err(|error| GameError::io(layout.config(), std::io::Error::other(error)))
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! 依赖库展开:主构件 + natives 分类器构件 → 待下载文件。
|
||||
//!
|
||||
//! 依据:`DevDocs/analyse/03-version-download.md` §6.4(libraries 含 natives 规则)、
|
||||
//! §6.8(目录布局速查)。
|
||||
//!
|
||||
//! 三条容易踩的规则:
|
||||
//! 1. **主构件与 natives 构件都要下**:老版本(lwjgl 2.x)一条库里同时有
|
||||
//! `downloads.artifact` 与 `natives` 表,HMCL 也是两者都取;
|
||||
//! 2. **路径优先用 JSON 给的 `path`**(Mojang 权威值),缺了才按 Maven 坐标推导;
|
||||
//! 3. **`hint=local` 的库落到实例私有目录** `versions/<id>/libraries/`,其余一律进
|
||||
//! 恒共享的 `libraries/`(D20:任何模式都不按实例复制)。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::super::arguments::Environment;
|
||||
use super::super::layout::GameLayout;
|
||||
use super::super::library::Library;
|
||||
use super::plan::{FileRole, PlanFile};
|
||||
use crate::download::ResourceKind;
|
||||
|
||||
/// 没写 `url` 的库兜底用的 Maven 基址(Mojang 官方库仓)。
|
||||
pub const DEFAULT_MAVEN_BASE: &str = "https://libraries.minecraft.net/";
|
||||
|
||||
/// 展开一层(已合并)清单里的库列表。
|
||||
///
|
||||
/// 返回(文件清单,人读告警)。告警不致命:缺下载地址的库会被跳过并告知,
|
||||
/// 让用户知道"缺哪个文件"而不是拿到一个静默不完整的实例。
|
||||
#[must_use]
|
||||
pub fn plan(
|
||||
layout: &GameLayout,
|
||||
version_id: &str,
|
||||
libraries: &[Library],
|
||||
environment: &Environment,
|
||||
) -> (Vec<PlanFile>, Vec<String>) {
|
||||
let mut files = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
for library in libraries {
|
||||
if !library.applies(environment) {
|
||||
continue;
|
||||
}
|
||||
if library.coordinate().is_err() {
|
||||
warnings.push(format!("库坐标不合法,已跳过:{}", library.name));
|
||||
continue;
|
||||
}
|
||||
let Ok(relative) = library.relative_path() else {
|
||||
warnings.push(format!("库路径推导失败,已跳过:{}", library.name));
|
||||
continue;
|
||||
};
|
||||
let dest = library_dest(layout, version_id, library, &relative);
|
||||
|
||||
match library.artifact() {
|
||||
Some(artifact) if artifact.has_url() => files.push(PlanFile {
|
||||
role: FileRole::Library,
|
||||
name: library.name.clone(),
|
||||
url: artifact.url.clone().unwrap_or_default(),
|
||||
sha1: artifact.sha1.clone(),
|
||||
size: artifact.size,
|
||||
dest,
|
||||
resource: ResourceKind::Library,
|
||||
}),
|
||||
// natives-only 库(1.6 时代的 `lwjgl-platform` / `jinput-platform`)**没有主构件**,
|
||||
// 只要 `downloads.classifiers`。这里若按坐标硬拼 URL 会拼出一个不存在的 jar
|
||||
// (实测:官方与 BMCLAPI 双双 404)。判据是 `natives` 在场。
|
||||
None if library.natives.is_some() => {}
|
||||
// 魔改/现代 loader 的库常见"只给 name 不给 downloads",按坐标拼仓库地址。
|
||||
_ => files.push(PlanFile {
|
||||
role: FileRole::Library,
|
||||
name: library.name.clone(),
|
||||
url: maven_url(library, &relative),
|
||||
sha1: None,
|
||||
size: None,
|
||||
dest,
|
||||
resource: ResourceKind::Library,
|
||||
}),
|
||||
}
|
||||
|
||||
// natives 分类器构件(存在才下;解压发生在启动期,不归安装管)。
|
||||
let Some(classifier) = library.native_classifier(environment) else {
|
||||
continue;
|
||||
};
|
||||
let Some(artifact) = library.native_artifact(&classifier) else {
|
||||
warnings.push(format!(
|
||||
"{} 的 natives 分类器 {classifier} 没有下载信息,已跳过",
|
||||
library.name
|
||||
));
|
||||
continue;
|
||||
};
|
||||
let Ok(native_relative) = library.native_relative_path(&classifier) else {
|
||||
warnings.push(format!("{} 的 natives 路径推导失败,已跳过", library.name));
|
||||
continue;
|
||||
};
|
||||
if !artifact.has_url() {
|
||||
warnings.push(format!(
|
||||
"{} 的 natives 构件 {classifier} 没有下载地址,已跳过",
|
||||
library.name
|
||||
));
|
||||
continue;
|
||||
}
|
||||
files.push(PlanFile {
|
||||
role: FileRole::Native,
|
||||
name: format!("{}:{classifier}", library.name),
|
||||
url: artifact.url.clone().unwrap_or_default(),
|
||||
sha1: artifact.sha1.clone(),
|
||||
size: artifact.size,
|
||||
dest: layout.libraries_dir().join(native_relative),
|
||||
resource: ResourceKind::Library,
|
||||
});
|
||||
}
|
||||
|
||||
(files, warnings)
|
||||
}
|
||||
|
||||
/// 库文件落点:实例私有(`hint=local`)或恒共享 `libraries/`。
|
||||
fn library_dest(
|
||||
layout: &GameLayout,
|
||||
version_id: &str,
|
||||
library: &Library,
|
||||
relative: &Path,
|
||||
) -> PathBuf {
|
||||
if library.is_local() {
|
||||
layout.version_libraries_dir(version_id).join(relative)
|
||||
} else {
|
||||
layout.libraries_dir().join(relative)
|
||||
}
|
||||
}
|
||||
|
||||
/// 按库声明(或默认)的 Maven 基址拼下载地址。
|
||||
fn maven_url(library: &Library, relative: &Path) -> String {
|
||||
let base = library
|
||||
.url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|base| !base.is_empty())
|
||||
.unwrap_or(DEFAULT_MAVEN_BASE);
|
||||
let path = relative.to_string_lossy().replace('\\', "/");
|
||||
if base.ends_with('/') {
|
||||
format!("{base}{path}")
|
||||
} else {
|
||||
format!("{base}/{path}")
|
||||
}
|
||||
}
|
||||
|
||||
/// 供上层判错用:库展开不产生硬失败,缺地址一律降级成告警。
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::plan;
|
||||
use crate::game::arguments::Environment;
|
||||
use crate::game::layout::GameLayout;
|
||||
use crate::game::library::Library;
|
||||
|
||||
fn environment() -> Environment {
|
||||
Environment {
|
||||
os: "windows".to_string(),
|
||||
os_version: String::new(),
|
||||
arch: "x86_64".to_string(),
|
||||
..Environment::host()
|
||||
}
|
||||
}
|
||||
|
||||
fn library_with_artifact(json: &str) -> Library {
|
||||
serde_json::from_str(json).expect("库 JSON 应可解析")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plans_artifact_at_maven_path() {
|
||||
let library = library_with_artifact(
|
||||
r#"{"name":"org.ow2.asm:asm:9.6","downloads":{"artifact":{"path":"org/ow2/asm/asm/9.6/asm-9.6.jar","sha1":"aa","size":122,"url":"https://libraries.minecraft.net/org/ow2/asm/asm/9.6/asm-9.6.jar"}}}"#,
|
||||
);
|
||||
let layout = GameLayout::new("/g/.minecraft");
|
||||
let (files, warnings) = plan(&layout, "1.20.1", &[library], &environment());
|
||||
assert!(warnings.is_empty());
|
||||
assert_eq!(files.len(), 1);
|
||||
assert_eq!(files[0].sha1.as_deref(), Some("aa"));
|
||||
assert_eq!(files[0].size, Some(122));
|
||||
assert!(files[0]
|
||||
.dest
|
||||
.ends_with("libraries/org/ow2/asm/asm/9.6/asm-9.6.jar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn natives_only_library_plans_no_artifact() {
|
||||
// 1.6 时代的 lwjgl-platform / jinput-platform:有 natives 表、只有 classifiers,
|
||||
// **没有 downloads.artifact**。按坐标硬拼主构件会拼出不存在的 jar(实测双源 404)。
|
||||
let library = library_with_artifact(
|
||||
r#"{"name":"org.lwjgl.lwjgl:lwjgl-platform:2.9.0",
|
||||
"natives":{"windows":"natives-windows","linux":"natives-linux","osx":"natives-osx"},
|
||||
"downloads":{"classifiers":{"natives-windows":{"path":"org/lwjgl/lwjgl/lwjgl-platform/2.9.0/lwjgl-platform-2.9.0-natives-windows.jar","sha1":"3f11","size":609967,"url":"https://libraries.minecraft.net/natives-windows.jar"}}}}"#,
|
||||
);
|
||||
let layout = GameLayout::new("/g/.minecraft");
|
||||
let (files, warnings) = plan(&layout, "1.6.4", &[library], &environment());
|
||||
assert!(warnings.is_empty(), "不该有告警:{warnings:?}");
|
||||
assert_eq!(files.len(), 1, "只应有 natives 构件:{files:?}");
|
||||
assert_eq!(files[0].role.as_str(), "native");
|
||||
assert_eq!(files[0].sha1.as_deref(), Some("3f11"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plans_both_artifact_and_native_jar() {
|
||||
let library = library_with_artifact(
|
||||
r#"{"name":"org.lwjgl.lwjgl:lwjgl-platform:2.9.4","natives":{"windows":"natives-windows"},
|
||||
"downloads":{"artifact":{"path":"org/lwjgl/lwjgl/lwjgl-platform/2.9.4/lwjgl-platform-2.9.4.jar","sha1":"bb","size":1,"url":"https://libraries.minecraft.net/a.jar"},
|
||||
"classifiers":{"natives-windows":{"path":"org/lwjgl/lwjgl/lwjgl-platform/2.9.4/lwjgl-platform-2.9.4-natives-windows.jar","sha1":"cc","size":2,"url":"https://libraries.minecraft.net/b.jar"}}}}"#,
|
||||
);
|
||||
let layout = GameLayout::new("/g/.minecraft");
|
||||
let (files, _) = plan(&layout, "1.7.10", &[library], &environment());
|
||||
assert_eq!(files.len(), 2);
|
||||
let native = files
|
||||
.iter()
|
||||
.find(|f| f.role.as_str() == "native")
|
||||
.expect("应有 natives 构件");
|
||||
assert_eq!(native.sha1.as_deref(), Some("cc"));
|
||||
assert!(native
|
||||
.dest
|
||||
.ends_with("lwjgl-platform-2.9.4-natives-windows.jar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derives_url_for_libraries_without_downloads() {
|
||||
let library = library_with_artifact(
|
||||
r#"{"name":"net.minecraftforge:forge:1.20.1-47.2.0:universal","url":"https://maven.minecraftforge.net/"}"#,
|
||||
);
|
||||
let layout = GameLayout::new("/g/.minecraft");
|
||||
let (files, warnings) = plan(&layout, "1.20.1-forge", &[library], &environment());
|
||||
assert!(warnings.is_empty());
|
||||
assert_eq!(files.len(), 1);
|
||||
assert_eq!(
|
||||
files[0].url,
|
||||
"https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-universal.jar"
|
||||
);
|
||||
assert!(files[0].sha1.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_libraries_filtered_by_rules() {
|
||||
let library = library_with_artifact(
|
||||
r#"{"name":"org.lwjgl:lwjgl:3.3.2","rules":[{"action":"allow","os":{"name":"linux"}}],
|
||||
"downloads":{"artifact":{"url":"https://libraries.minecraft.net/x.jar","sha1":"dd"}}}"#,
|
||||
);
|
||||
let layout = GameLayout::new("/g/.minecraft");
|
||||
let (files, _) = plan(&layout, "1.20.1", &[library], &environment());
|
||||
assert!(files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_hint_libraries_land_in_instance_dir() {
|
||||
let library = library_with_artifact(
|
||||
r#"{"name":"net.minecraftforge:forge:1.20.1-47.2.0","hint":"local",
|
||||
"downloads":{"artifact":{"path":"net/minecraftforge/forge/1.20.1-47.2.0.jar","url":"https://libraries.minecraft.net/y.jar"}}}"#,
|
||||
);
|
||||
let layout = GameLayout::new("/g/.minecraft");
|
||||
let (files, _) = plan(&layout, "inst", &[library], &environment());
|
||||
assert_eq!(files.len(), 1);
|
||||
assert!(files[0]
|
||||
.dest
|
||||
.ends_with("versions/inst/libraries/net/minecraftforge/forge/1.20.1-47.2.0.jar"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//! 安装层:版本链 → 下载计划 → 分区落盘 → 实例登记(P2 核心闭环「装一个版本并启动它」)。
|
||||
//!
|
||||
//! 设计输入:`DevDocs/analyse/03-version-download.md` §6(逐类资源拆解)、
|
||||
//! `DevDocs/PlanDoc/04-data-and-config.md` §4(D20 实例模型)、§2(`.ULL/` 四分区)、
|
||||
//! `DevDocs/PlanDoc/05-download-and-mirrors.md` §2(分层下载策略)、
|
||||
//! `DevDocs/PlanDoc/research/04-launcher-configs.md` §5(实例键草案)。
|
||||
//!
|
||||
//! # 分工(每层只做一件事,谁都不越界)
|
||||
//!
|
||||
//! ```text
|
||||
//! source 网络:版本清单(带 TTL 缓存)→ 版本 JSON 链 → 资源索引 (异步)
|
||||
//! plan 纯计算:清单 + 索引 → InstallPlan(文件清单 / 总大小 / 角色) (同步、可 dry-run)
|
||||
//! execute 落盘:逐文件跳过判定 + 下载引擎 + 原子写版本 JSON (异步)
|
||||
//! instance 登记:profiles.json + config/instances/<id>.json(稀疏、幂等)(同步)
|
||||
//! ```
|
||||
//!
|
||||
//! # 三条硬约定
|
||||
//!
|
||||
//! 1. **恒共享层**(D20):`versions/` `assets/` `libraries/` 一律进游戏根,任何模式都不按实例复制;
|
||||
//! 2. **幂等**:重复装同一版本不重复下载(按 sha1 跳过)、不重复建实例(按 `versionId` 复用档案);
|
||||
//! 3. **如实告知**:清单缺件(旧版无 `downloads.client` / 无 `assetIndex`)只降级成计划告警,
|
||||
//! 不静默假装成功,也不编造 URL。
|
||||
//!
|
||||
//! # 同步宿主怎么用
|
||||
//!
|
||||
//! `crates/cli` 不依赖 tokio:整段「规划 + 下载」包在一次
|
||||
//! [`crate::download::blocking::block_on`] 里,见 `ull install` 的实现。
|
||||
|
||||
pub mod assets;
|
||||
pub mod execute;
|
||||
pub mod instance;
|
||||
pub mod libraries;
|
||||
pub mod plan;
|
||||
pub mod plan_view;
|
||||
pub mod source;
|
||||
|
||||
use std::fmt;
|
||||
|
||||
pub use assets::{AssetIndex, AssetObject, ASSET_OBJECT_BASE};
|
||||
pub use execute::{execute, FileFailure, FileOutcome, FileStatus, InstallReport, ProgressSink};
|
||||
pub use instance::{register, InstanceConfig, Registration};
|
||||
pub use plan::{FileRole, InstallPlan, PlanFile, PlanInput};
|
||||
pub use source::{ManifestFreshness, RemoteSource, MANIFEST_TTL_MS};
|
||||
|
||||
use crate::download::{DownloadError, DownloadErrorKind};
|
||||
use crate::game::error::GameError;
|
||||
|
||||
/// 安装失败的大类(CLI 据此给稳定错误码,脚本可分流)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum InstallErrorKind {
|
||||
/// 版本 id 在官方清单与本地仓库里都不存在。
|
||||
VersionNotFound,
|
||||
/// 网络侧失败(所有候选源都失败 / 超时 / 连接错误)。
|
||||
Network,
|
||||
/// 内容校验不符(镜像给了错内容)。
|
||||
Integrity,
|
||||
/// 本地 IO(写盘失败、目录权限)。
|
||||
Io,
|
||||
/// 元数据解析失败(清单/版本 JSON/资源索引坏了)。
|
||||
Metadata,
|
||||
}
|
||||
|
||||
impl InstallErrorKind {
|
||||
/// 稳定字符串 id(进 `--json` 的 `error.code`)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::VersionNotFound => "install_version_not_found",
|
||||
Self::Network => "install_network",
|
||||
Self::Integrity => "install_integrity",
|
||||
Self::Io => "install_io",
|
||||
Self::Metadata => "install_metadata",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 安装期错误:大类 + 人读说明 + 可选提示。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InstallError {
|
||||
kind: InstallErrorKind,
|
||||
message: String,
|
||||
hint: Option<String>,
|
||||
}
|
||||
|
||||
impl InstallError {
|
||||
/// 构造。
|
||||
#[must_use]
|
||||
pub fn new(kind: InstallErrorKind, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
hint: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本不存在。
|
||||
#[must_use]
|
||||
pub fn version_not_found(id: &str) -> Self {
|
||||
Self::new(
|
||||
InstallErrorKind::VersionNotFound,
|
||||
format!("版本 {id} 不在版本清单里,也不在本地 versions/ 下"),
|
||||
)
|
||||
.with_hint("用 `ull --json install <version>` 前先确认版本 id(如 26.3 / 1.21.4);快照版本 id 形如 24w14a")
|
||||
}
|
||||
|
||||
/// 补一句提示。
|
||||
#[must_use]
|
||||
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
|
||||
self.hint = Some(hint.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// 大类。
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> InstallErrorKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
/// 人读说明。
|
||||
#[must_use]
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
|
||||
/// 下一步提示。
|
||||
#[must_use]
|
||||
pub fn hint(&self) -> Option<&str> {
|
||||
self.hint.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for InstallError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for InstallError {}
|
||||
|
||||
impl From<DownloadError> for InstallError {
|
||||
fn from(error: DownloadError) -> Self {
|
||||
let kind = match error.kind {
|
||||
DownloadErrorKind::Integrity => InstallErrorKind::Integrity,
|
||||
DownloadErrorKind::Io => InstallErrorKind::Io,
|
||||
DownloadErrorKind::InvalidRequest => InstallErrorKind::Metadata,
|
||||
DownloadErrorKind::NotFound
|
||||
| DownloadErrorKind::ServerError
|
||||
| DownloadErrorKind::Timeout
|
||||
| DownloadErrorKind::Network
|
||||
| DownloadErrorKind::RangeUnsupported
|
||||
| DownloadErrorKind::AllSourcesFailed => InstallErrorKind::Network,
|
||||
};
|
||||
let mut message = error.message.clone();
|
||||
if let Some(source) = &error.source {
|
||||
message.push_str(&format!("(源 {source})"));
|
||||
}
|
||||
Self::new(kind, message)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GameError> for InstallError {
|
||||
fn from(error: GameError) -> Self {
|
||||
let kind = match &error {
|
||||
GameError::Io { .. } => InstallErrorKind::Io,
|
||||
GameError::Json { .. } | GameError::InvalidAssetHash { .. } => {
|
||||
InstallErrorKind::Metadata
|
||||
}
|
||||
GameError::MissingVersionId
|
||||
| GameError::MissingParent { .. }
|
||||
| GameError::CircularInheritance { .. }
|
||||
| GameError::InvalidCoordinate { .. }
|
||||
| GameError::CustomIsolationWithoutDir { .. }
|
||||
| GameError::DraftBusy => InstallErrorKind::Metadata,
|
||||
};
|
||||
Self::new(kind, error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::platform::error::PlatformError> for InstallError {
|
||||
fn from(error: crate::platform::error::PlatformError) -> Self {
|
||||
Self::new(InstallErrorKind::Io, error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,342 @@
|
||||
//! 安装计划:把「已解析的版本清单 + 资源索引」翻译成一份可下载文件清单。
|
||||
//!
|
||||
//! 依据:`DevDocs/analyse/03-version-download.md` §6(安装期资源清单逐类拆解)、
|
||||
//! `DevDocs/PlanDoc/05-download-and-mirrors.md`(镜像由下载引擎展开)。
|
||||
//!
|
||||
//! 本模块是**纯计算**:输入已解析清单([`VersionJson`])与已取到的资源索引,输出
|
||||
//! [`InstallPlan`];不发请求、不写文件。网络在 [`super::source`]、落盘在 [`super::execute`]。
|
||||
//! 这样 `--dry-run`、测试、以及 GUI 的"安装前确认页"共用同一份计划真相。
|
||||
//!
|
||||
//! 恒共享层(D20):`versions/`、`assets/`、`libraries/` 一律进游戏根,不按实例复制;
|
||||
//! 只有 `hint=local` 的库落实例私有目录(见 [`super::libraries`])。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::super::arguments::Environment;
|
||||
use super::super::error::GameError;
|
||||
use super::super::layout::GameLayout;
|
||||
use super::super::manifest::{Logging, VersionJson};
|
||||
use super::assets::{self, AssetIndex};
|
||||
use super::libraries;
|
||||
use crate::download::{DownloadRequest, ResourceKind};
|
||||
|
||||
/// 一个待落盘文件的角色(进度分组、`--dry-run` 汇总、错误定位都用它)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FileRole {
|
||||
/// 客户端 jar `versions/<jarId>/<jarId>.jar`。
|
||||
ClientJar,
|
||||
/// 共享库 `libraries/...`。
|
||||
Library,
|
||||
/// natives 分类器构件(也进 `libraries/`,启动期解压)。
|
||||
Native,
|
||||
/// 资源索引 `assets/indexes/<id>.json`。
|
||||
AssetIndex,
|
||||
/// 资源对象 `assets/objects/<hash[0..2]>/<hash>`。
|
||||
Asset,
|
||||
/// 日志配置 `assets/log_configs/<id>`。
|
||||
LogConfig,
|
||||
}
|
||||
|
||||
impl FileRole {
|
||||
/// 稳定字符串 id(JSON 输出与日志用,**不要改名**)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::ClientJar => "client_jar",
|
||||
Self::Library => "library",
|
||||
Self::Native => "native",
|
||||
Self::AssetIndex => "asset_index",
|
||||
Self::Asset => "asset",
|
||||
Self::LogConfig => "log_config",
|
||||
}
|
||||
}
|
||||
|
||||
/// 人读名称(汇总行用)。
|
||||
#[must_use]
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::ClientJar => "客户端 jar",
|
||||
Self::Library => "依赖库",
|
||||
Self::Native => "natives",
|
||||
Self::AssetIndex => "资源索引",
|
||||
Self::Asset => "资源对象",
|
||||
Self::LogConfig => "日志配置",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 清单里的一个待下载文件。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PlanFile {
|
||||
/// 角色。
|
||||
pub role: FileRole,
|
||||
/// 人读标识(库坐标 / 资源名 / 索引 id / jar id)。
|
||||
pub name: String,
|
||||
/// 官方逻辑 URL(镜像展开由下载引擎负责)。
|
||||
pub url: String,
|
||||
/// 期望 SHA-1(`None` = 清单没给,只能靠大小/存在性兜底)。
|
||||
pub sha1: Option<String>,
|
||||
/// 期望字节数。
|
||||
pub size: Option<u64>,
|
||||
/// 最终落盘路径。
|
||||
pub dest: PathBuf,
|
||||
/// 资源类型(健康度统计与分块准入的第二维度)。
|
||||
pub resource: ResourceKind,
|
||||
}
|
||||
|
||||
impl PlanFile {
|
||||
/// 转成下载引擎的请求(校验值/大小原样透传)。
|
||||
#[must_use]
|
||||
pub fn request(&self) -> DownloadRequest {
|
||||
let mut request = DownloadRequest::new(self.resource, self.url.clone(), self.dest.clone());
|
||||
request.integrity = self
|
||||
.sha1
|
||||
.clone()
|
||||
.filter(|hex| !hex.is_empty())
|
||||
.map(crate::download::Integrity::sha1);
|
||||
request.expected_size = self.size;
|
||||
request
|
||||
}
|
||||
|
||||
/// `--dry-run` 的单行描述。
|
||||
#[must_use]
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({
|
||||
"role": self.role.as_str(),
|
||||
"name": self.name,
|
||||
"url": self.url,
|
||||
"sha1": self.sha1,
|
||||
"size": self.size,
|
||||
"dest": self.dest.display().to_string(),
|
||||
"resource": self.resource.as_str(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 规划阶段已经取到内容、但因 `--dry-run` / 时序原因还没落盘的元数据文件。
|
||||
///
|
||||
/// 典型场景:资源索引——不先取到它就无法枚举对象,所以规划期已经下载在内存里;
|
||||
/// 执行期直接原子写盘,不再重复走一次网络。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Prefetched {
|
||||
/// 目标路径(必须与计划里的某个 [`PlanFile::dest`] 一致)。
|
||||
pub dest: PathBuf,
|
||||
/// 内容字节。
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// 一次安装的完整计划。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InstallPlan {
|
||||
/// 目标版本 id(`versions/<id>/`)。
|
||||
pub version_id: String,
|
||||
/// 实际提供 jar 的版本 id(继承链上可能是父版本)。
|
||||
pub jar_id: String,
|
||||
/// 游戏根目录(恒共享层的根)。
|
||||
pub game_root: PathBuf,
|
||||
/// 版本清单落盘路径 `versions/<id>/<id>.json`。
|
||||
pub version_json_path: PathBuf,
|
||||
/// 待下载文件(按 dest 去重、按角色排序)。
|
||||
pub files: Vec<PlanFile>,
|
||||
/// 已解析的扁平清单(落盘时写进 `versions/<id>/<id>.json`)。
|
||||
pub version_json: VersionJson,
|
||||
/// 规划期已取到内容的文件(见 [`Prefetched`])。
|
||||
pub prefetched: Vec<Prefetched>,
|
||||
/// 人读告警(缺哪类资源一律如实告知,不静默降级)。
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// [`InstallPlan::build`] 的输入。
|
||||
pub struct PlanInput<'a> {
|
||||
/// 游戏目录布局。
|
||||
pub layout: &'a GameLayout,
|
||||
/// 目标版本 id。
|
||||
pub version_id: &'a str,
|
||||
/// 已 resolve 的扁平清单。
|
||||
pub resolved: &'a VersionJson,
|
||||
/// 已取到并解析的资源索引(缺 = 老版本没有 `assetIndex`)。
|
||||
pub asset_index: Option<&'a AssetIndex>,
|
||||
/// 规则求值环境(OS / 架构 / 特性开关)。
|
||||
pub environment: &'a Environment,
|
||||
}
|
||||
|
||||
impl InstallPlan {
|
||||
/// 组计划。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 资源对象的哈希不合法(索引坏了)时返回 [`GameError::InvalidAssetHash`]。
|
||||
pub fn build(input: PlanInput<'_>) -> Result<Self, GameError> {
|
||||
let layout = input.layout;
|
||||
let version_id = input.version_id;
|
||||
let resolved = input.resolved;
|
||||
let mut files: Vec<PlanFile> = Vec::new();
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
|
||||
let jar_id = resolved
|
||||
.jar
|
||||
.clone()
|
||||
.unwrap_or_else(|| version_id.to_string());
|
||||
|
||||
// ① 客户端 jar:老版本(pre-1.6)没有 `downloads.client`,如实告知而不是编 URL。
|
||||
match resolved.client_jar() {
|
||||
Some(info) if info.has_url() => files.push(PlanFile {
|
||||
role: FileRole::ClientJar,
|
||||
name: jar_id.clone(),
|
||||
url: info.url.clone().unwrap_or_default(),
|
||||
sha1: info.sha1.clone(),
|
||||
size: info.size,
|
||||
dest: layout.version_jar(&jar_id),
|
||||
resource: ResourceKind::ClientJar,
|
||||
}),
|
||||
_ => warnings.push(format!(
|
||||
"版本 {version_id} 的清单没有 downloads.client:客户端 jar 无法自动获取(pre-1.6 旧清单待补兜底)"
|
||||
)),
|
||||
}
|
||||
|
||||
// ② 依赖库 + natives。
|
||||
let (library_files, library_warnings) = libraries::plan(
|
||||
layout,
|
||||
version_id,
|
||||
resolved.libraries.as_deref().unwrap_or_default(),
|
||||
input.environment,
|
||||
);
|
||||
files.extend(library_files);
|
||||
warnings.extend(library_warnings);
|
||||
|
||||
// ③ 资源索引 + 逐对象。
|
||||
match resolved.asset_index.as_ref() {
|
||||
Some(reference) if reference.url.is_some() => files.push(PlanFile {
|
||||
role: FileRole::AssetIndex,
|
||||
name: reference.id.clone(),
|
||||
url: reference.url.clone().unwrap_or_default(),
|
||||
sha1: reference.sha1.clone(),
|
||||
size: reference.size,
|
||||
dest: layout.asset_index(&reference.id),
|
||||
resource: ResourceKind::AssetIndex,
|
||||
}),
|
||||
Some(reference) => warnings.push(format!(
|
||||
"资源索引 {} 没有下载地址,跳过(对象无法枚举)",
|
||||
reference.id
|
||||
)),
|
||||
None => warnings.push(format!(
|
||||
"版本 {version_id} 的清单没有 assetIndex:资源对象无法枚举(旧版 sha1 反查表待补)"
|
||||
)),
|
||||
}
|
||||
if let Some(index) = input.asset_index {
|
||||
files.extend(assets::plan(layout, index)?);
|
||||
}
|
||||
|
||||
// ④ 日志配置(官方 manifest 带的 appender 配置;有就下,没有就用启动器的内置模板)。
|
||||
if let Some(logging) = resolved.logging.as_ref().and_then(Logging::client) {
|
||||
if logging.file.has_url() {
|
||||
let file_id = logging
|
||||
.file
|
||||
.id
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{version_id}-client"));
|
||||
files.push(PlanFile {
|
||||
role: FileRole::LogConfig,
|
||||
name: file_id.clone(),
|
||||
url: logging.file.url.clone().unwrap_or_default(),
|
||||
sha1: logging.file.sha1.clone(),
|
||||
size: logging.file.size,
|
||||
dest: layout.log_config(&file_id),
|
||||
resource: ResourceKind::Other,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let files = dedupe(files);
|
||||
|
||||
Ok(Self {
|
||||
version_id: version_id.to_string(),
|
||||
jar_id,
|
||||
game_root: layout.root().to_path_buf(),
|
||||
version_json_path: layout.version_json(version_id),
|
||||
files,
|
||||
version_json: resolved.clone(),
|
||||
prefetched: Vec::new(),
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// 挂一份"规划期已取到内容"的文件(内容必须与计划里的对应文件同一落点)。
|
||||
pub fn prefetch(&mut self, dest: PathBuf, bytes: Vec<u8>) {
|
||||
self.prefetched.push(Prefetched { dest, bytes });
|
||||
}
|
||||
|
||||
/// 客户端 jar 的待下载项(`None` = 旧版清单没给下载信息)。
|
||||
#[must_use]
|
||||
pub fn client_jar(&self) -> Option<&PlanFile> {
|
||||
self.files
|
||||
.iter()
|
||||
.find(|file| file.role == FileRole::ClientJar)
|
||||
}
|
||||
|
||||
/// 这份计划能否真的装出一个可启动的版本(判据:有客户端 jar)。
|
||||
///
|
||||
/// 旧版(pre-1.6)清单没有 `downloads.client`,计划里就没有 jar——此时"装成功"
|
||||
/// 等于骗人,调用方应当拒绝执行而不是登记一个起不来的实例。
|
||||
#[must_use]
|
||||
pub fn is_installable(&self) -> bool {
|
||||
self.client_jar().is_some()
|
||||
}
|
||||
|
||||
/// 文件数。
|
||||
#[must_use]
|
||||
pub fn file_count(&self) -> usize {
|
||||
self.files.len()
|
||||
}
|
||||
|
||||
/// 总字节数(已知大小的合计;`unknown_size` 给出"有几个文件大小未知")。
|
||||
#[must_use]
|
||||
pub fn total_bytes(&self) -> (u64, usize) {
|
||||
let mut total = 0_u64;
|
||||
let mut unknown = 0_usize;
|
||||
for file in &self.files {
|
||||
match file.size {
|
||||
Some(size) => total += size,
|
||||
None => unknown += 1,
|
||||
}
|
||||
}
|
||||
(total, unknown)
|
||||
}
|
||||
|
||||
/// 按角色统计(角色 → (文件数, 字节数))。
|
||||
#[must_use]
|
||||
pub fn by_role(&self) -> BTreeMap<FileRole, (usize, u64)> {
|
||||
let mut out: BTreeMap<FileRole, (usize, u64)> = BTreeMap::new();
|
||||
for file in &self.files {
|
||||
let entry = out.entry(file.role).or_insert((0, 0));
|
||||
entry.0 += 1;
|
||||
entry.1 += file.size.unwrap_or(0);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// 按落盘路径去重:同一目标只留一条,优先保留带 sha1 的那条(后出现的更可信时覆盖)。
|
||||
fn dedupe(files: Vec<PlanFile>) -> Vec<PlanFile> {
|
||||
let mut by_dest: BTreeMap<PathBuf, PlanFile> = BTreeMap::new();
|
||||
for file in files {
|
||||
match by_dest.get(&file.dest) {
|
||||
Some(existing) if existing.sha1.is_some() || file.sha1.is_none() => {}
|
||||
_ => {
|
||||
by_dest.insert(file.dest.clone(), file);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out: Vec<PlanFile> = by_dest.into_values().collect();
|
||||
out.sort_by(|left, right| {
|
||||
left.role
|
||||
.cmp(&right.role)
|
||||
.then_with(|| left.dest.cmp(&right.dest))
|
||||
});
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! 计划的 JSON 视图与源选择样本(`--dry-run` 的输出形状)。
|
||||
//!
|
||||
//! 单独一份是为了让 [`InstallPlan`](super::plan::InstallPlan) 的"计算"与"呈现"分开:
|
||||
//! 形状变了只动这里,计划算法不受影响;也让 `plan.rs` 不至于越过 400 行硬约束。
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::plan::{FileRole, InstallPlan, PlanFile};
|
||||
use crate::download::DownloadEngine;
|
||||
|
||||
impl InstallPlan {
|
||||
/// 计划摘要(`--dry-run` 与日志共用;不含逐文件明细)。
|
||||
#[must_use]
|
||||
pub fn summary_json(&self) -> Value {
|
||||
let (total, unknown) = self.total_bytes();
|
||||
let by_role: BTreeMap<String, Value> = self
|
||||
.by_role()
|
||||
.into_iter()
|
||||
.map(|(role, (count, bytes))| {
|
||||
(
|
||||
role.as_str().to_string(),
|
||||
json!({"files": count, "bytes": bytes, "label": role.label()}),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"version_id": self.version_id,
|
||||
"jar_id": self.jar_id,
|
||||
"game_root": self.game_root.display().to_string(),
|
||||
"file_count": self.file_count(),
|
||||
"total_bytes": total,
|
||||
"unknown_size_files": unknown,
|
||||
"by_role": by_role,
|
||||
"warnings": self.warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// 逐文件明细(`--dry-run` 的"列清单"用它;真实安装不输出,避免上千行噪声)。
|
||||
#[must_use]
|
||||
pub fn files_json(&self) -> Vec<Value> {
|
||||
self.files.iter().map(PlanFile::to_json).collect()
|
||||
}
|
||||
|
||||
/// 每个角色取第一个文件,[`DownloadEngine::plan`] 给出候选源顺序("源选择")。
|
||||
///
|
||||
/// 只取每类的**首个**文件做样本:3000 个资源对象的候选源是一样的,
|
||||
/// 逐文件列源选择既没用也没法读。
|
||||
#[must_use]
|
||||
pub fn source_samples(&self, engine: &DownloadEngine, now_ms: u64) -> Vec<Value> {
|
||||
let mut seen: BTreeSet<FileRole> = BTreeSet::new();
|
||||
let mut samples = Vec::new();
|
||||
for file in &self.files {
|
||||
if !seen.insert(file.role) {
|
||||
continue;
|
||||
}
|
||||
let candidates: Vec<Value> = engine
|
||||
.plan(&file.request(), now_ms)
|
||||
.into_iter()
|
||||
.map(|candidate| json!({"source": candidate.source, "url": candidate.url}))
|
||||
.collect();
|
||||
samples.push(json!({
|
||||
"role": file.role.as_str(),
|
||||
"label": file.role.label(),
|
||||
"sample": file.name,
|
||||
"candidates": candidates,
|
||||
}));
|
||||
}
|
||||
samples
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
//! 远端元数据:版本清单(带 TTL 缓存与过期回退)、版本 JSON 链、资源索引。
|
||||
//!
|
||||
//! 依据:`DevDocs/analyse/03-version-download.md` §2.6(清单缓存)、§4.3(继承解析)、
|
||||
//! §6.2(版本 JSON)、§6.5(资源索引);`DevDocs/PlanDoc/05-download-and-mirrors.md` §2
|
||||
//! (清单类资源"短 TTL + 失败立切源")。
|
||||
//!
|
||||
//! 三条与 HMCL 有意的差异(都是 analyse/03 §8 点名的上游坑):
|
||||
//! 1. **清单与版本 JSON 一律校验 sha1**(v2 端点自带),镜像篡改/截断在下载阶段就暴露;
|
||||
//! 2. **清单缓存过期自动刷新**,且刷新失败时**回退到过期缓存**并标注
|
||||
//! [`ManifestFreshness::StaleFallback`]——离线机器上也能装已缓存的清单;
|
||||
//! 3. 版本 JSON 链**远端优先、本地兜底**:装已存在的 loader 实例时不会因为"官方清单里没有
|
||||
//! 这个魔改 id"就直接失败。
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::super::error::GameError;
|
||||
use super::super::inherit;
|
||||
use super::super::layout::GameLayout;
|
||||
use super::super::manifest::{AssetIndexRef, VersionJson};
|
||||
use super::super::unlisted;
|
||||
use super::super::version::{RemoteVersion, VersionList, VERSION_MANIFEST_V2_URL};
|
||||
use super::assets::AssetIndex;
|
||||
use super::InstallError;
|
||||
use super::InstallErrorKind;
|
||||
use crate::download::{DownloadEngine, DownloadRequest, ResourceKind};
|
||||
use crate::platform::atomic::write_atomic;
|
||||
|
||||
/// 版本清单缓存有效期(12 小时)。过期后自动重新拉取;拉取失败退回旧缓存。
|
||||
pub const MANIFEST_TTL_MS: u64 = 12 * 60 * 60 * 1000;
|
||||
|
||||
/// 清单缓存文件名(落在 `.ULL/cache/meta/`)。
|
||||
pub const MANIFEST_CACHE_FILE: &str = "version_manifest_v2.json";
|
||||
|
||||
/// 继承链最大层数(防御恶意/损坏清单造成的无限展开;`resolve` 另有成环检测)。
|
||||
const MAX_CHAIN_DEPTH: usize = 16;
|
||||
|
||||
/// 清单内容的来源(可观测性:诊断页/事件流要能说明"这次用的是缓存还是新拉的")。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ManifestFreshness {
|
||||
/// 本次真实拉取。
|
||||
Fetched,
|
||||
/// 命中未过期的本地缓存。
|
||||
Cached,
|
||||
/// 拉取失败,退回过期缓存(离线可用)。
|
||||
StaleFallback,
|
||||
}
|
||||
|
||||
impl ManifestFreshness {
|
||||
/// 稳定字符串 id。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Fetched => "fetched",
|
||||
Self::Cached => "cached",
|
||||
Self::StaleFallback => "stale_fallback",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 落盘的缓存形态(带拉取时刻,TTL 判定用)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ManifestCache {
|
||||
fetched_at_ms: u64,
|
||||
list: VersionList,
|
||||
}
|
||||
|
||||
/// 远端元数据来源。
|
||||
pub struct RemoteSource<'a> {
|
||||
engine: &'a DownloadEngine,
|
||||
game: &'a GameLayout,
|
||||
meta_cache: &'a Path,
|
||||
manifest_url: String,
|
||||
now_ms: u64,
|
||||
}
|
||||
|
||||
impl<'a> RemoteSource<'a> {
|
||||
/// 绑定引擎、游戏布局与缓存目录。
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
engine: &'a DownloadEngine,
|
||||
game: &'a GameLayout,
|
||||
meta_cache: &'a Path,
|
||||
now_ms: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
engine,
|
||||
game,
|
||||
meta_cache,
|
||||
manifest_url: VERSION_MANIFEST_V2_URL.to_string(),
|
||||
now_ms,
|
||||
}
|
||||
}
|
||||
|
||||
/// 覆盖清单地址(内网专有清单/测试用)。
|
||||
#[must_use]
|
||||
pub fn with_manifest_url(mut self, url: impl Into<String>) -> Self {
|
||||
self.manifest_url = url.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// 取版本清单(带 TTL 缓存;`refresh = true` 强制重新拉取)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 无可用缓存且网络失败、或清单 JSON/校验失败时返回 [`InstallError`]。
|
||||
pub async fn manifest(
|
||||
&self,
|
||||
refresh: bool,
|
||||
) -> Result<(VersionList, ManifestFreshness), InstallError> {
|
||||
let path = self.meta_cache.join(MANIFEST_CACHE_FILE);
|
||||
let cached = read_cache(&path);
|
||||
if !refresh {
|
||||
if let Some(cache) = &cached {
|
||||
if self.now_ms.saturating_sub(cache.fetched_at_ms) < MANIFEST_TTL_MS {
|
||||
return Ok((cache.list.clone(), ManifestFreshness::Cached));
|
||||
}
|
||||
}
|
||||
}
|
||||
match self.fetch_manifest(&path).await {
|
||||
Ok(list) => Ok((list, ManifestFreshness::Fetched)),
|
||||
Err(error) => match cached {
|
||||
Some(cache) => Ok((cache.list, ManifestFreshness::StaleFallback)),
|
||||
None => Err(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析目标版本的扁平清单(远端取版本 JSON,递归拉父层,再走
|
||||
/// [`inherit::resolve`])。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 版本不存在([`InstallError::version_not_found`])、网络失败、继承链断裂/成环、
|
||||
/// JSON 非法。
|
||||
pub async fn resolve(&self, version_id: &str) -> Result<VersionJson, InstallError> {
|
||||
let (manifest, _) = self.manifest(false).await?;
|
||||
let root = self.load_one(version_id, &manifest).await?;
|
||||
|
||||
let mut table: BTreeMap<String, VersionJson> = BTreeMap::new();
|
||||
table.insert(version_id.to_string(), root.clone());
|
||||
let mut visited: BTreeSet<String> = BTreeSet::new();
|
||||
visited.insert(version_id.to_string());
|
||||
let mut pending = vec![root.clone()];
|
||||
let mut depth = 0_usize;
|
||||
|
||||
while let Some(layer) = pending.pop() {
|
||||
let Some(parent_id) = layer.inherits_from.clone() else {
|
||||
continue;
|
||||
};
|
||||
depth += 1;
|
||||
if depth > MAX_CHAIN_DEPTH {
|
||||
return Err(InstallError::new(
|
||||
InstallErrorKind::Metadata,
|
||||
format!("版本 {version_id} 的继承链超过 {MAX_CHAIN_DEPTH} 层,疑似清单损坏"),
|
||||
));
|
||||
}
|
||||
// 环不在这里报:交给 resolve 给出完整 id 链(信息更全)。
|
||||
if !visited.insert(parent_id.clone()) {
|
||||
continue;
|
||||
}
|
||||
let parent = self.load_one(&parent_id, &manifest).await?;
|
||||
pending.push(parent.clone());
|
||||
table.insert(parent_id, parent);
|
||||
}
|
||||
|
||||
inherit::resolve(&table, &root)
|
||||
.map_err(|error| InstallError::new(InstallErrorKind::Metadata, error.to_string()))
|
||||
}
|
||||
|
||||
/// 取资源索引,返回(索引模型,原始字节)。
|
||||
///
|
||||
/// 原始字节一并返回,是因为**规划必须先有索引内容**才能枚举对象;这份字节随后由
|
||||
/// [`super::execute`] 原子落到 `assets/indexes/<id>.json`,不重复下载。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 索引没有下载地址、网络失败、sha1 不符或 JSON 非法。
|
||||
pub async fn asset_index(
|
||||
&self,
|
||||
reference: &AssetIndexRef,
|
||||
) -> Result<(AssetIndex, Vec<u8>), InstallError> {
|
||||
let url = reference.url.clone().ok_or_else(|| {
|
||||
InstallError::new(
|
||||
InstallErrorKind::Metadata,
|
||||
format!("资源索引 {} 没有下载地址", reference.id),
|
||||
)
|
||||
})?;
|
||||
let bytes = self
|
||||
.fetch_bytes(ResourceKind::AssetIndex, &url, reference.sha1.as_deref())
|
||||
.await?;
|
||||
let index = AssetIndex::from_json_slice(&bytes)?;
|
||||
Ok((index, bytes))
|
||||
}
|
||||
|
||||
/// 拉清单并落缓存(成功才写,失败不动旧缓存)。
|
||||
async fn fetch_manifest(&self, cache_path: &Path) -> Result<VersionList, InstallError> {
|
||||
let request = DownloadRequest::new(
|
||||
ResourceKind::VersionManifest,
|
||||
self.manifest_url.clone(),
|
||||
cache_path,
|
||||
);
|
||||
let bytes = self.engine.download_bytes(request).await?;
|
||||
let mut list =
|
||||
VersionList::from_json_str(std::str::from_utf8(&bytes).map_err(|_| {
|
||||
InstallError::new(InstallErrorKind::Metadata, "版本清单不是 UTF-8")
|
||||
})?)
|
||||
.map_err(|error| InstallError::new(InstallErrorKind::Metadata, error.to_string()))?;
|
||||
// 官方清单没有的远古版本/魔改条目由内嵌补充清单兜底(analyse/03 §2.3)。
|
||||
unlisted::merge_into(&mut list);
|
||||
let cache = ManifestCache {
|
||||
fetched_at_ms: self.now_ms,
|
||||
list: list.clone(),
|
||||
};
|
||||
let json = serde_json::to_vec(&cache).map_err(|error| {
|
||||
InstallError::new(
|
||||
InstallErrorKind::Metadata,
|
||||
format!("序列化清单缓存失败:{error}"),
|
||||
)
|
||||
})?;
|
||||
write_atomic(cache_path, &json)?;
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// 取一份版本 JSON:远端优先(带 v2 的 sha1 校验),本地 `versions/<id>/<id>.json` 兜底。
|
||||
async fn load_one(
|
||||
&self,
|
||||
id: &str,
|
||||
manifest: &VersionList,
|
||||
) -> Result<VersionJson, InstallError> {
|
||||
let remote = match manifest.find(id) {
|
||||
Some(entry) => Some(self.fetch_version_json(entry).await),
|
||||
None => None,
|
||||
};
|
||||
match remote {
|
||||
Some(Ok(version)) => Ok(version),
|
||||
Some(Err(remote_error)) => {
|
||||
let local = self.read_local(id)?;
|
||||
match local {
|
||||
Some(version) => Ok(version),
|
||||
None => Err(remote_error),
|
||||
}
|
||||
}
|
||||
None => match self.read_local(id)? {
|
||||
Some(version) => Ok(version),
|
||||
None => Err(InstallError::version_not_found(id)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_version_json(&self, entry: &RemoteVersion) -> Result<VersionJson, InstallError> {
|
||||
let bytes = self
|
||||
.fetch_bytes(
|
||||
ResourceKind::VersionManifest,
|
||||
&entry.url,
|
||||
entry.sha1.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
VersionJson::from_json_slice(&bytes).map_err(InstallError::from)
|
||||
}
|
||||
|
||||
async fn fetch_bytes(
|
||||
&self,
|
||||
kind: ResourceKind,
|
||||
url: &str,
|
||||
sha1: Option<&str>,
|
||||
) -> Result<Vec<u8>, InstallError> {
|
||||
let mut request = DownloadRequest::new(kind, url, self.meta_cache.join("download.bin"));
|
||||
if let Some(hex) = sha1.filter(|hex| !hex.is_empty()) {
|
||||
request = request.with_sha1(hex);
|
||||
}
|
||||
Ok(self.engine.download_bytes(request).await?)
|
||||
}
|
||||
|
||||
/// 读本地版本 JSON(`versions/<id>/<id>.json`);不存在返回 `Ok(None)`。
|
||||
fn read_local(&self, id: &str) -> Result<Option<VersionJson>, InstallError> {
|
||||
let path: PathBuf = self.game.version_json(id);
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => VersionJson::from_json_slice(&bytes)
|
||||
.map(Some)
|
||||
.map_err(|error| InstallError::new(InstallErrorKind::Metadata, error.to_string())),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(InstallError::new(
|
||||
InstallErrorKind::Io,
|
||||
format!("读 {} 失败:{error}", path.display()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 读清单缓存(文件缺失/损坏都当没有,不报错——缓存坏了就重新拉)。
|
||||
fn read_cache(path: &Path) -> Option<ManifestCache> {
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
|
||||
/// 当前毫秒时间戳(缓存 TTL 判定用;与下载引擎同一个时钟来源)。
|
||||
pub use crate::download::cache::now_ms;
|
||||
|
||||
/// 把 [`GameError`] 转成"缺元数据"错误(供上层统一处理)。
|
||||
#[must_use]
|
||||
pub fn metadata_error(error: GameError) -> InstallError {
|
||||
InstallError::new(InstallErrorKind::Metadata, error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{metadata_error, now_ms, read_cache, ManifestCache, MANIFEST_TTL_MS};
|
||||
use crate::game::error::GameError;
|
||||
use crate::game::version::VersionList;
|
||||
|
||||
#[test]
|
||||
fn ttl_constant_is_half_a_day() {
|
||||
assert_eq!(MANIFEST_TTL_MS, 43_200_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn now_ms_is_a_plausible_wall_clock() {
|
||||
// 2020-01-01 之后、2100 年之前:只用来挡住"单位错成秒"这类退化。
|
||||
let now = now_ms();
|
||||
assert!(now > 1_577_836_800_000, "now_ms 太小:{now}");
|
||||
assert!(now < 4_102_444_800_000, "now_ms 太大:{now}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_roundtrip_and_corruption_tolerance() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-manifest-cache-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("建临时目录");
|
||||
let path = dir.join("version_manifest_v2.json");
|
||||
|
||||
let cache = ManifestCache {
|
||||
fetched_at_ms: 123,
|
||||
list: VersionList::default(),
|
||||
};
|
||||
std::fs::write(&path, serde_json::to_vec(&cache).expect("序列化")).expect("写");
|
||||
let read = read_cache(&path).expect("应读回");
|
||||
assert_eq!(read.fetched_at_ms, 123);
|
||||
|
||||
std::fs::write(&path, b"not json").expect("写坏内容");
|
||||
assert!(read_cache(&path).is_none());
|
||||
|
||||
assert!(read_cache(&dir.join("missing.json")).is_none());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_error_becomes_metadata_error() {
|
||||
let error = metadata_error(GameError::MissingVersionId);
|
||||
assert_eq!(error.kind().as_str(), "install_metadata");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
//! 落盘执行:跳过判定(三级)、幂等重装、预取元数据落盘、失败分类聚合。
|
||||
//!
|
||||
//! 全部用例都**已把文件放好**或**只做纯函数判定**,因此不发网络请求。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::download::verify;
|
||||
use crate::download::ResourceKind;
|
||||
use crate::download::{
|
||||
ContentCache, DownloadEngine, DownloadErrorKind, EngineConfig, HashAlgo, HealthRegistry,
|
||||
MirrorTable, RegionProfile,
|
||||
};
|
||||
use crate::game::arguments::Environment;
|
||||
use crate::game::install::assets::{AssetIndex, AssetObject};
|
||||
use crate::game::install::execute::{current_bytes, execute, FileOutcome, FileStatus};
|
||||
use crate::game::install::plan::{FileRole, InstallPlan, PlanFile, PlanInput};
|
||||
use crate::game::layout::GameLayout;
|
||||
use crate::game::manifest::VersionJson;
|
||||
|
||||
fn temp_root(tag: &str) -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!(
|
||||
"ull-install-exec-{}-{tag}-{unique}",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
|
||||
fn engine(root: &Path) -> DownloadEngine {
|
||||
DownloadEngine::new(
|
||||
EngineConfig::default(),
|
||||
Arc::new(MirrorTable::builtin()),
|
||||
Arc::new(RegionProfile::direct(0)),
|
||||
Arc::new(HealthRegistry::in_memory()),
|
||||
Arc::new(ContentCache::open(root.join("cache"))),
|
||||
)
|
||||
}
|
||||
|
||||
fn sha1_of(bytes: &[u8]) -> String {
|
||||
verify::digest_bytes(HashAlgo::Sha1, bytes)
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, bytes: &[u8]) {
|
||||
std::fs::create_dir_all(path.parent().expect("有父目录")).expect("建目录");
|
||||
std::fs::write(path, bytes).expect("写文件");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_bytes_matches_by_sha1_then_size_then_existence() {
|
||||
let root = temp_root("current");
|
||||
let payload = b"client jar payload";
|
||||
let dest: PathBuf = root.join("versions/1.20.1/1.20.1.jar");
|
||||
write_file(&dest, payload);
|
||||
|
||||
let with_sha1 = PlanFile {
|
||||
role: FileRole::ClientJar,
|
||||
name: "1.20.1".to_string(),
|
||||
url: "https://example.invalid/x.jar".to_string(),
|
||||
sha1: Some(sha1_of(payload)),
|
||||
size: Some(payload.len() as u64),
|
||||
dest: dest.clone(),
|
||||
resource: ResourceKind::ClientJar,
|
||||
};
|
||||
assert_eq!(current_bytes(&with_sha1), Some(payload.len() as u64));
|
||||
|
||||
let wrong_sha1 = PlanFile {
|
||||
sha1: Some("0".repeat(40)),
|
||||
..with_sha1.clone()
|
||||
};
|
||||
assert_eq!(current_bytes(&wrong_sha1), None, "sha1 不符必须重下");
|
||||
|
||||
let size_only = PlanFile {
|
||||
sha1: None,
|
||||
size: Some(payload.len() as u64),
|
||||
..with_sha1.clone()
|
||||
};
|
||||
assert_eq!(current_bytes(&size_only), Some(payload.len() as u64));
|
||||
|
||||
let size_mismatch = PlanFile {
|
||||
sha1: None,
|
||||
size: Some(1),
|
||||
..with_sha1.clone()
|
||||
};
|
||||
assert_eq!(current_bytes(&size_mismatch), None);
|
||||
|
||||
let bare = PlanFile {
|
||||
sha1: None,
|
||||
size: None,
|
||||
..with_sha1.clone()
|
||||
};
|
||||
assert_eq!(current_bytes(&bare), Some(payload.len() as u64), "存在即信");
|
||||
|
||||
let missing = PlanFile {
|
||||
dest: root.join("nope.jar"),
|
||||
..with_sha1
|
||||
};
|
||||
assert_eq!(current_bytes(&missing), None);
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// 一份"客户端 jar 已存在"的清单 + 计划,用于幂等与跳过用例。
|
||||
fn plan_with_existing_jar(root: &Path) -> (InstallPlan, Vec<u8>) {
|
||||
let payload = b"already installed client jar".to_vec();
|
||||
let layout = GameLayout::new(root);
|
||||
let sha1 = sha1_of(&payload);
|
||||
write_file(&layout.version_jar("1.20.1"), &payload);
|
||||
|
||||
let resolved = VersionJson::from_json_str(&format!(
|
||||
r#"{{"id": "1.20.1", "mainClass": "net.minecraft.client.main.Main",
|
||||
"downloads": {{"client": {{"sha1": "{sha1}", "size": {}, "url": "https://piston-data.mojang.com/v1/objects/{sha1}/client.jar"}}}}}}"#,
|
||||
payload.len()
|
||||
))
|
||||
.expect("清单应可解析");
|
||||
|
||||
let plan = InstallPlan::build(PlanInput {
|
||||
layout: &layout,
|
||||
version_id: "1.20.1",
|
||||
resolved: &resolved,
|
||||
asset_index: None,
|
||||
environment: &Environment::host(),
|
||||
})
|
||||
.expect("组计划应成功");
|
||||
(plan, payload)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skips_existing_files_and_writes_version_json() {
|
||||
let root = temp_root("skip");
|
||||
let (plan, payload) = plan_with_existing_jar(&root);
|
||||
let engine = engine(&root);
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let sink_calls = Arc::clone(&calls);
|
||||
|
||||
let report = execute(
|
||||
&engine,
|
||||
&plan,
|
||||
4,
|
||||
Some(Arc::new(move |_outcome: &FileOutcome| {
|
||||
sink_calls.fetch_add(1, Ordering::Relaxed);
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.expect("执行应成功");
|
||||
|
||||
assert!(report.is_complete());
|
||||
assert_eq!(report.downloaded(), 0, "已存在且校验通过的文件不该重下");
|
||||
assert_eq!(report.skipped(), 1);
|
||||
assert_eq!(report.bytes(), 0);
|
||||
assert_eq!(calls.load(Ordering::Relaxed), 1, "每个文件回调一次");
|
||||
|
||||
// 版本清单必须落盘且能被重新解析。
|
||||
let version_json = report.version_json.clone().expect("全量成功应写版本清单");
|
||||
let text = std::fs::read_to_string(&version_json).expect("版本清单应已写盘");
|
||||
let written = VersionJson::from_json_str(&text).expect("写出的清单应可解析");
|
||||
assert_eq!(written.id.as_deref(), Some("1.20.1"));
|
||||
assert_eq!(
|
||||
written.main_class.as_deref(),
|
||||
Some("net.minecraft.client.main.Main")
|
||||
);
|
||||
|
||||
// 盘上内容没被改动(跳过 = 不动文件)。
|
||||
assert_eq!(
|
||||
std::fs::read(GameLayout::new(&root).version_jar("1.20.1")).expect("读 jar"),
|
||||
payload
|
||||
);
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reinstalling_twice_is_idempotent() {
|
||||
let root = temp_root("idempotent");
|
||||
let (plan, _) = plan_with_existing_jar(&root);
|
||||
let engine = engine(&root);
|
||||
let first = execute(&engine, &plan, 2, None).await.expect("第一次");
|
||||
let second = execute(&engine, &plan, 2, None).await.expect("第二次");
|
||||
|
||||
assert!(first.is_complete() && second.is_complete());
|
||||
assert_eq!(second.downloaded(), 0);
|
||||
assert_eq!(second.skipped(), second.files.len());
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prefetched_metadata_is_written_without_network() {
|
||||
let root = temp_root("prefetch");
|
||||
let layout = GameLayout::new(&root);
|
||||
let hash = "0f9d6b1c0d5f4e3b2a1908070605040302010f0e";
|
||||
// 规划期"已经下载到内存"的资源索引字节(sha1 必须与清单声明一致,否则跳过判定不成立)。
|
||||
let index_bytes = br#"{"objects":{}}"#.to_vec();
|
||||
let index_sha1 = sha1_of(&index_bytes);
|
||||
let resolved = VersionJson::from_json_str(&format!(
|
||||
r#"{{"id": "1.20.1", "assetIndex": {{"id": "17", "sha1": "{index_sha1}", "size": {}, "url": "https://piston-meta.mojang.com/v1/packages/{index_sha1}/17.json"}}}}"#,
|
||||
index_bytes.len()
|
||||
))
|
||||
.expect("清单应可解析");
|
||||
let mut index = AssetIndex::default();
|
||||
index.objects.insert(
|
||||
"minecraft/lang/zh_cn.json".to_string(),
|
||||
AssetObject {
|
||||
hash: hash.to_string(),
|
||||
size: 3,
|
||||
},
|
||||
);
|
||||
|
||||
let mut plan = InstallPlan::build(PlanInput {
|
||||
layout: &layout,
|
||||
version_id: "1.20.1",
|
||||
resolved: &resolved,
|
||||
asset_index: Some(&index),
|
||||
environment: &Environment::host(),
|
||||
})
|
||||
.expect("组计划应成功");
|
||||
|
||||
let index_dest = layout.asset_index("17");
|
||||
plan.prefetch(index_dest.clone(), index_bytes.clone());
|
||||
|
||||
// 只跑索引这一个文件:把资源对象从计划里摘掉,避免真发网络请求。
|
||||
plan.files.retain(|file| file.role != FileRole::Asset);
|
||||
let engine = engine(&root);
|
||||
let report = execute(&engine, &plan, 2, None).await.expect("执行应成功");
|
||||
|
||||
assert!(report.is_complete(), "不该有失败:{:?}", report.failures(3));
|
||||
assert_eq!(report.downloaded(), 1);
|
||||
assert_eq!(
|
||||
std::fs::read(&index_dest).expect("索引应已写盘"),
|
||||
index_bytes
|
||||
);
|
||||
let outcome = report
|
||||
.files
|
||||
.iter()
|
||||
.find(|file| file.dest == index_dest)
|
||||
.expect("应有索引结果");
|
||||
assert_eq!(outcome.source.as_deref(), Some("metadata"));
|
||||
|
||||
// 重跑:索引已在盘上且 sha1 一致 → 跳过,不重写、不算"下载"。
|
||||
let again = execute(&engine, &plan, 2, None).await.expect("第二次");
|
||||
assert_eq!(again.downloaded(), 0, "重装不该再写索引");
|
||||
assert_eq!(again.skipped(), 1);
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_kind_prefers_integrity() {
|
||||
let failed = |kind: DownloadErrorKind| FileOutcome {
|
||||
role: FileRole::Asset,
|
||||
name: "x".to_string(),
|
||||
dest: PathBuf::from("/x"),
|
||||
status: FileStatus::Failed,
|
||||
bytes: 0,
|
||||
source: None,
|
||||
failure: Some(crate::game::install::FileFailure {
|
||||
kind,
|
||||
message: "boom".to_string(),
|
||||
}),
|
||||
};
|
||||
let report = crate::game::install::InstallReport {
|
||||
files: vec![
|
||||
failed(DownloadErrorKind::AllSourcesFailed),
|
||||
failed(DownloadErrorKind::Integrity),
|
||||
],
|
||||
version_json: None,
|
||||
warnings: Vec::new(),
|
||||
};
|
||||
assert_eq!(report.failed(), 2);
|
||||
assert!(!report.is_complete());
|
||||
assert_eq!(report.failure_kind(), Some(DownloadErrorKind::Integrity));
|
||||
assert_eq!(report.failures(1).len(), 1);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! 实例登记:幂等(同版本同名复用)、`isolation: inherit` 不落盘、两份文件各就各位。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::game::install::instance::{
|
||||
read_instance_config, read_profiles, register, InstanceConfig, Isolation,
|
||||
};
|
||||
use crate::platform::layout::LauncherLayout;
|
||||
|
||||
fn temp_layout(tag: &str) -> (LauncherLayout, PathBuf) {
|
||||
let unique = format!(
|
||||
"{}-{}-{tag}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
);
|
||||
let root = std::env::temp_dir().join(format!("ull-install-instance-{unique}"));
|
||||
let layout = LauncherLayout::new(&root);
|
||||
layout.ensure().expect("建四分区");
|
||||
(layout, root)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registers_profile_and_sparse_instance_config() {
|
||||
let (layout, root) = temp_layout("new");
|
||||
let registration = register(
|
||||
&layout,
|
||||
"1.20.1",
|
||||
"1.20.1",
|
||||
Some("2026-10-30T00:00:00.000Z"),
|
||||
)
|
||||
.expect("登记应成功");
|
||||
assert!(registration.created);
|
||||
assert!(
|
||||
registration.profile.id.is_uuid_shaped(),
|
||||
"实例 id 必须是稳定 UUID 形状"
|
||||
);
|
||||
assert!(registration.config_path.is_file());
|
||||
|
||||
let profiles = read_profiles(&layout).expect("读实例清单");
|
||||
assert_eq!(profiles.len(), 1);
|
||||
assert_eq!(profiles.profiles[0].version_id, "1.20.1");
|
||||
assert_eq!(
|
||||
profiles.profiles[0].created_at.as_deref(),
|
||||
Some("2026-10-30T00:00:00.000Z")
|
||||
);
|
||||
|
||||
// 每实例设置:id / versionId / schemaVersion 在,isolation 缺省不落盘。
|
||||
let text = std::fs::read_to_string(®istration.config_path).expect("读设置文件");
|
||||
assert!(
|
||||
text.contains("\"versionId\": \"1.20.1\""),
|
||||
"键名用 camelCase:{text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("isolation"),
|
||||
"inherit 是默认值且不落盘:{text}"
|
||||
);
|
||||
assert!(!text.contains("gameDir"), "非 custom 不写 gameDir:{text}");
|
||||
|
||||
let config = read_instance_config(&layout, registration.profile.id.as_str())
|
||||
.expect("读设置")
|
||||
.expect("设置文件应存在");
|
||||
assert!(config.inherits_isolation());
|
||||
assert_eq!(config.id, registration.profile.id.as_str());
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reinstall_same_version_reuses_instance() {
|
||||
let (layout, root) = temp_layout("idempotent");
|
||||
let first = register(&layout, "1.20.1", "1.20.1", None).expect("第一次登记");
|
||||
let second = register(&layout, "1.20.1", "1.20.1", None).expect("第二次登记");
|
||||
|
||||
assert!(!second.created, "同版本同名应复用");
|
||||
assert_eq!(second.profile.id.as_str(), first.profile.id.as_str());
|
||||
assert_eq!(read_profiles(&layout).expect("读清单").len(), 1);
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_names_create_distinct_instances_of_same_version() {
|
||||
let (layout, root) = temp_layout("names");
|
||||
let a = register(&layout, "生存", "1.20.1", None).expect("A");
|
||||
let b = register(&layout, "创造", "1.20.1", None).expect("B");
|
||||
|
||||
assert!(a.created && b.created);
|
||||
assert_ne!(a.profile.id.as_str(), b.profile.id.as_str());
|
||||
assert_eq!(read_profiles(&layout).expect("读清单").len(), 2);
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_config_roundtrip_keeps_explicit_isolation() {
|
||||
let mut config = InstanceConfig::new("11111111-2222-4333-8444-555555555555", "1.20.1");
|
||||
config.isolation = Some(Isolation::Isolated);
|
||||
let text = config.to_json_string().expect("序列化");
|
||||
assert!(text.contains("\"isolation\": \"isolated\""));
|
||||
let back = InstanceConfig::from_json_str(&text).expect("反序列化");
|
||||
assert_eq!(back.isolation, Some(Isolation::Isolated));
|
||||
assert!(!back.inherits_isolation());
|
||||
assert_eq!(Isolation::parse("custom"), Some(Isolation::Custom));
|
||||
assert_eq!(Isolation::parse("inherit"), None, "inherit 不是显式值");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registers_legacy_profile_without_config_file() {
|
||||
let (layout, root) = temp_layout("legacy");
|
||||
// 早期 CLI 只写 profiles.json(没有 instances/<id>.json)。
|
||||
let profile = crate::game::profiles::InstanceProfile::new("老实例", "1.20.1");
|
||||
let id = profile.id.as_str().to_string();
|
||||
let mut profiles = crate::game::profiles::ProfileList::default();
|
||||
profiles.upsert(profile);
|
||||
super::super::instance::write_profiles(&layout, &profiles).expect("写清单");
|
||||
|
||||
let registration = register(&layout, "老实例", "1.20.1", None).expect("补登记");
|
||||
assert!(!registration.created, "已有档案应复用");
|
||||
assert_eq!(registration.profile.id.as_str(), id);
|
||||
assert!(registration.config_path.is_file(), "缺失的设置文件应补齐");
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! `game::install` 的单元测试:计划正确性 / 跳过与幂等 / 实例登记。
|
||||
//!
|
||||
//! 刻意**不打真实网络**:计划是纯计算,跳过判定只看盘,实例登记只看文件系统。
|
||||
//! 需要"引擎"的用例只要求它"不联网也能跑"(全部文件已就位 → 全跳过)。
|
||||
|
||||
mod execute;
|
||||
mod instance;
|
||||
mod plan;
|
||||
@@ -0,0 +1,285 @@
|
||||
//! 计划正确性:多级继承折叠后的文件清单、URL/sha1/落点逐项、JSON 形状。
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use crate::game::arguments::Environment;
|
||||
use crate::game::inherit::resolve;
|
||||
use crate::game::install::assets::{AssetIndex, AssetObject};
|
||||
use crate::game::install::plan::{FileRole, InstallPlan, PlanInput};
|
||||
use crate::game::layout::GameLayout;
|
||||
use crate::game::manifest::VersionJson;
|
||||
|
||||
fn temp_root(tag: &str) -> std::path::PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!(
|
||||
"ull-install-plan-{}-{tag}-{unique}",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
|
||||
/// 三层继承:base(客户端 jar + 资源索引 + 库)← mid(库)← top(库)。
|
||||
fn resolved_three_levels() -> VersionJson {
|
||||
let base = VersionJson::from_json_str(
|
||||
r#"{
|
||||
"id": "base",
|
||||
"mainClass": "net.minecraft.client.main.Main",
|
||||
"downloads": {"client": {"sha1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 100, "url": "https://piston-data.mojang.com/v1/objects/aa/client.jar"}},
|
||||
"assetIndex": {"id": "17", "sha1": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "size": 200, "totalSize": 300, "url": "https://piston-meta.mojang.com/v1/packages/bb/17.json"},
|
||||
"assets": "17",
|
||||
"libraries": [
|
||||
{"name": "org.ow2.asm:asm:9.6", "downloads": {"artifact": {"path": "org/ow2/asm/asm/9.6/asm-9.6.jar", "sha1": "c1", "size": 11, "url": "https://libraries.minecraft.net/org/ow2/asm/asm/9.6/asm-9.6.jar"}}},
|
||||
{"name": "org.lwjgl:lwjgl:3.3.2", "rules": [{"action": "allow", "os": {"name": "linux"}}], "downloads": {"artifact": {"url": "https://libraries.minecraft.net/lwjgl-linux-only.jar", "sha1": "c2", "size": 5}}}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.expect("base 清单应可解析");
|
||||
let mid = VersionJson::from_json_str(
|
||||
r#"{"id": "mid", "inheritsFrom": "base", "libraries": [
|
||||
{"name": "net.minecraftforge:forge:1.20.1-47.2.0", "downloads": {"artifact": {"path": "net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0.jar", "sha1": "c3", "size": 22, "url": "https://maven.minecraftforge.net/forge.jar"}}}
|
||||
]}"#,
|
||||
)
|
||||
.expect("mid 清单应可解析");
|
||||
let top = VersionJson::from_json_str(
|
||||
r#"{"id": "top", "inheritsFrom": "mid", "libraries": [
|
||||
{"name": "top.patch:core:1.0", "downloads": {"artifact": {"path": "top/patch/core/1.0/core-1.0.jar", "sha1": "c4", "size": 33, "url": "https://example.invalid/core-1.0.jar"}}}
|
||||
], "logging": {"client": {"argument": "-Dlog4j.configurationFile=${path}", "file": {"id": "client-1.12.xml", "sha1": "dd", "size": 44, "url": "https://piston-data.mojang.com/v1/objects/dd/client-1.12.xml"}}}}"#,
|
||||
)
|
||||
.expect("top 清单应可解析");
|
||||
|
||||
let mut table = std::collections::BTreeMap::new();
|
||||
table.insert("base".to_string(), base);
|
||||
table.insert("mid".to_string(), mid);
|
||||
table.insert("top".to_string(), top.clone());
|
||||
resolve(&table, &top).expect("三层继承应解析成功")
|
||||
}
|
||||
|
||||
fn asset_index() -> AssetIndex {
|
||||
let mut index = AssetIndex::default();
|
||||
index.objects.insert(
|
||||
"minecraft/lang/zh_cn.json".to_string(),
|
||||
AssetObject {
|
||||
hash: "0f9d6b1c0d5f4e3b2a1908070605040302010f0e".to_string(),
|
||||
size: 55,
|
||||
},
|
||||
);
|
||||
index
|
||||
}
|
||||
|
||||
fn build(layout: &GameLayout, resolved: &VersionJson, index: Option<&AssetIndex>) -> InstallPlan {
|
||||
InstallPlan::build(PlanInput {
|
||||
layout,
|
||||
version_id: "top",
|
||||
resolved,
|
||||
asset_index: index,
|
||||
environment: &Environment::host(),
|
||||
})
|
||||
.expect("组计划应成功")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folds_inheritance_chain_into_one_plan() {
|
||||
let root = temp_root("chain");
|
||||
let layout = GameLayout::new(&root);
|
||||
let resolved = resolved_three_levels();
|
||||
// 三层库都保留(父在前、子在后,去重后按角色/路径排序)。
|
||||
assert_eq!(resolved.libraries.as_ref().map(Vec::len), Some(4));
|
||||
|
||||
let index = asset_index();
|
||||
let plan = build(&layout, &resolved, Some(&index));
|
||||
|
||||
// 客户端 jar 1 + 库 4(linux 规则那条在 Windows 上被过滤)+ 索引 1 + 资源 1 + 日志配置 1。
|
||||
let host_linux = Environment::host().os == "linux";
|
||||
let expected_libraries = if host_linux { 4 } else { 3 };
|
||||
assert_eq!(
|
||||
plan.by_role()
|
||||
.get(&FileRole::ClientJar)
|
||||
.map(|entry| entry.0),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(
|
||||
plan.by_role().get(&FileRole::Library).map(|entry| entry.0),
|
||||
Some(expected_libraries)
|
||||
);
|
||||
assert_eq!(
|
||||
plan.by_role()
|
||||
.get(&FileRole::AssetIndex)
|
||||
.map(|entry| entry.0),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(
|
||||
plan.by_role().get(&FileRole::Asset).map(|entry| entry.0),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(
|
||||
plan.by_role()
|
||||
.get(&FileRole::LogConfig)
|
||||
.map(|entry| entry.0),
|
||||
Some(1)
|
||||
);
|
||||
assert!(plan.warnings.is_empty(), "不应有告警:{:?}", plan.warnings);
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_jar_lands_in_version_dir_with_manifest_hash() {
|
||||
let root = temp_root("jar");
|
||||
let layout = GameLayout::new(&root);
|
||||
let resolved = resolved_three_levels();
|
||||
let plan = build(&layout, &resolved, None);
|
||||
|
||||
let jar = plan
|
||||
.files
|
||||
.iter()
|
||||
.find(|file| file.role == FileRole::ClientJar)
|
||||
.expect("应有客户端 jar");
|
||||
assert_eq!(plan.jar_id, "top");
|
||||
assert!(jar.dest.ends_with("versions/top/top.jar"));
|
||||
assert_eq!(
|
||||
jar.sha1.as_deref(),
|
||||
Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||
);
|
||||
assert_eq!(jar.size, Some(100));
|
||||
assert_eq!(
|
||||
jar.url,
|
||||
"https://piston-data.mojang.com/v1/objects/aa/client.jar"
|
||||
);
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_object_paths_and_hashes_are_exact() {
|
||||
let root = temp_root("assets");
|
||||
let layout = GameLayout::new(&root);
|
||||
let resolved = resolved_three_levels();
|
||||
let index = asset_index();
|
||||
let plan = build(&layout, &resolved, Some(&index));
|
||||
|
||||
let object = plan
|
||||
.files
|
||||
.iter()
|
||||
.find(|file| file.role == FileRole::Asset)
|
||||
.expect("应有资源对象");
|
||||
assert_eq!(
|
||||
object.url,
|
||||
"https://resources.download.minecraft.net/0f/0f9d6b1c0d5f4e3b2a1908070605040302010f0e"
|
||||
);
|
||||
assert_eq!(
|
||||
object.sha1.as_deref(),
|
||||
Some("0f9d6b1c0d5f4e3b2a1908070605040302010f0e")
|
||||
);
|
||||
assert_eq!(object.size, Some(55));
|
||||
assert!(object
|
||||
.dest
|
||||
.ends_with("assets/objects/0f/0f9d6b1c0d5f4e3b2a1908070605040302010f0e"));
|
||||
|
||||
let index_file = plan
|
||||
.files
|
||||
.iter()
|
||||
.find(|file| file.role == FileRole::AssetIndex)
|
||||
.expect("应有资源索引");
|
||||
assert!(index_file.dest.ends_with("assets/indexes/17.json"));
|
||||
assert_eq!(
|
||||
index_file.sha1.as_deref(),
|
||||
Some("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
|
||||
);
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shares_libraries_across_versions_and_sums_totals() {
|
||||
let root = temp_root("totals");
|
||||
let layout = GameLayout::new(&root);
|
||||
let resolved = resolved_three_levels();
|
||||
let index = asset_index();
|
||||
let plan = build(&layout, &resolved, Some(&index));
|
||||
|
||||
let (total, unknown) = plan.total_bytes();
|
||||
// jar 100 + 索引 200 + 资源 55 + 日志 44 + 各库大小(host 上生效的那几条)。
|
||||
assert_eq!(unknown, 0);
|
||||
assert!(total >= 399, "总大小应至少含 jar+索引+资源+日志:{total}");
|
||||
|
||||
// 库落恒共享层(不是实例私有)。
|
||||
for file in plan
|
||||
.files
|
||||
.iter()
|
||||
.filter(|file| file.role == FileRole::Library)
|
||||
{
|
||||
assert!(file.dest.starts_with(root.join("libraries")));
|
||||
}
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warns_instead_of_inventing_urls_for_legacy_manifests() {
|
||||
let root = temp_root("legacy");
|
||||
let layout = GameLayout::new(&root);
|
||||
let legacy = VersionJson::from_json_str(r#"{"id": "c0.30_01c", "assets": "legacy"}"#)
|
||||
.expect("旧清单应可解析");
|
||||
let plan = build(&layout, &legacy, None);
|
||||
|
||||
assert!(plan.files.is_empty(), "旧清单没有可下文件");
|
||||
assert_eq!(
|
||||
plan.warnings.len(),
|
||||
2,
|
||||
"应分别告警缺 jar 与缺索引:{:?}",
|
||||
plan.warnings
|
||||
);
|
||||
assert!(plan.warnings.iter().any(|w| w.contains("downloads.client")));
|
||||
assert!(plan.warnings.iter().any(|w| w.contains("assetIndex")));
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_shape_is_stable_for_dry_run_consumers() {
|
||||
let root = temp_root("json");
|
||||
let layout = GameLayout::new(&root);
|
||||
let resolved = resolved_three_levels();
|
||||
let index = asset_index();
|
||||
let plan = build(&layout, &resolved, Some(&index));
|
||||
|
||||
let summary = plan.summary_json();
|
||||
assert_eq!(summary["version_id"], "top");
|
||||
assert_eq!(summary["jar_id"], "top");
|
||||
assert_eq!(summary["file_count"], plan.file_count());
|
||||
assert!(summary["total_bytes"].as_u64().unwrap_or(0) > 0);
|
||||
assert!(summary["by_role"]["client_jar"]["files"].as_u64() == Some(1));
|
||||
assert!(summary["warnings"].is_array());
|
||||
|
||||
let files = plan.files_json();
|
||||
assert_eq!(files.len(), plan.file_count());
|
||||
for row in &files {
|
||||
assert!(row["role"].as_str().is_some());
|
||||
assert!(row["url"].as_str().is_some());
|
||||
assert!(row["dest"].as_str().is_some());
|
||||
assert!(row["resource"].as_str().is_some());
|
||||
}
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicates_same_destination_and_prefers_hashed_copy() {
|
||||
let root = temp_root("dedupe");
|
||||
let layout = GameLayout::new(&root);
|
||||
let resolved = VersionJson::from_json_str(
|
||||
r#"{"id": "1.20.1", "libraries": [
|
||||
{"name": "a.b:c:1", "downloads": {"artifact": {"path": "a/b/c/1/c-1.jar", "url": "https://libraries.minecraft.net/c-1.jar"}}},
|
||||
{"name": "a.b:c:1", "downloads": {"artifact": {"path": "a/b/c/1/c-1.jar", "sha1": "ff", "size": 9, "url": "https://libraries.minecraft.net/c-1.jar"}}}
|
||||
]}"#,
|
||||
)
|
||||
.expect("清单应可解析");
|
||||
let plan = build(&layout, &resolved, None);
|
||||
|
||||
let libraries: Vec<_> = plan
|
||||
.files
|
||||
.iter()
|
||||
.filter(|file| file.role == FileRole::Library)
|
||||
.collect();
|
||||
assert_eq!(libraries.len(), 1, "同一落点只应留一条");
|
||||
assert_eq!(
|
||||
libraries[0].sha1.as_deref(),
|
||||
Some("ff"),
|
||||
"应保留带 sha1 的那条"
|
||||
);
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
//! 实例隔离档位的**派生**与**切换检测**(D20 定案)。
|
||||
//!
|
||||
//! 依据:`DevDocs/PlanDoc/research/04-launcher-configs.md` §5.1-§5.2(键草案 + 切换检测)、
|
||||
//! `DevDocs/analyse/07-settings-packaging-update.md` §2.4(HMCL 用 `runningDirectory` 覆盖表达隔离)、
|
||||
//! `DevDocs/PlanDoc/04-data-and-config.md` §4(实例模型)。
|
||||
//!
|
||||
//! # D20 的语义(用户定案,不可改)
|
||||
//!
|
||||
//! - **默认隔离**:全局 `instances.mode` 默认 `isolated`,用户可手动切 `shared`;
|
||||
//! - **恒共享层不可配**:`versions/` `assets/` `libraries/` 永远在游戏根,隔离只改**运行目录**
|
||||
//! (= `--gameDir` 的取值 + 进程 CWD),见 [`SHARED_LAYERS`];
|
||||
//! - **切换不迁移 + 双侧内容提示**:切档只改路径语义,不动任何数据;两侧都有内容时提示
|
||||
//! ([`ModeChange::take_warning`]),避免「存档消失」错觉。
|
||||
//!
|
||||
//! # 覆盖链(三层,左优先)
|
||||
//!
|
||||
//! ```text
|
||||
//! 临时启动参数(--game-dir 之类) > 实例 isolation > 全局 instances.mode
|
||||
//! ```
|
||||
//!
|
||||
//! `isolation` 的 `inherit` 不是第四个档位,而是「键不存在」→ 落到全局([`IsolationMode`]);
|
||||
//! 这样改全局对存量实例立即生效,不需要迁移(research/04 §5.3 的 PCL2 反面教材)。
|
||||
//!
|
||||
//! 本模块只做**纯路径计算 + 只读探测**:不建目录、不搬文件、不写配置。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::error::GameError;
|
||||
use super::install::instance::{InstanceConfig, Isolation};
|
||||
use super::layout::GameLayout;
|
||||
|
||||
/// 恒共享层(任何隔离档位都不按实例复制;natives 例外,按实例解包)。
|
||||
pub const SHARED_LAYERS: &[&str] = &["versions", "assets", "libraries"];
|
||||
|
||||
/// 切换检测探测的三类内容目录(两家启动器一致:只关心玩家数据)。
|
||||
pub const PROBED_CONTENT_DIRS: &[&str] = &["mods", "saves", "config"];
|
||||
|
||||
/// 全局默认隔离模式(`launcher.json` 的 `instances.mode`)。
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum IsolationMode {
|
||||
/// 每实例运行目录 = `versions/<versionId>/`(D20 默认)。
|
||||
#[default]
|
||||
Isolated,
|
||||
/// 所有实例共用 `.minecraft` 根(HMCL 传统单目录形态)。
|
||||
Shared,
|
||||
}
|
||||
|
||||
impl IsolationMode {
|
||||
/// 稳定字符串 id(与 `setting::keys::INSTANCE_MODES` 同字面量)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Isolated => "isolated",
|
||||
Self::Shared => "shared",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从字符串解析(未知值返回 `None`,由调用方决定报错还是取默认)。
|
||||
#[must_use]
|
||||
pub fn parse(raw: &str) -> Option<Self> {
|
||||
match raw.trim() {
|
||||
"isolated" => Some(Self::Isolated),
|
||||
"shared" => Some(Self::Shared),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 折算成实例档位(全局模式只有两档,`inherit`/`custom` 不存在)。
|
||||
#[must_use]
|
||||
pub const fn as_isolation(self) -> Isolation {
|
||||
match self {
|
||||
Self::Isolated => Isolation::Isolated,
|
||||
Self::Shared => Isolation::Shared,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 派生结论的来源(诊断/日志用,`mode` 变了要能解释为什么)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum IsolationSource {
|
||||
/// 临时启动参数覆盖(最高优先)。
|
||||
Temporary,
|
||||
/// 实例文件里的 `isolation`。
|
||||
Instance,
|
||||
/// 全局 `instances.mode`(实例 `inherit` 或没有实例文件)。
|
||||
Global,
|
||||
}
|
||||
|
||||
impl IsolationSource {
|
||||
/// 稳定名。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Temporary => "temporary",
|
||||
Self::Instance => "instance",
|
||||
Self::Global => "global",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次派生(覆盖链求值)的结论。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Derivation {
|
||||
/// 生效档位(`inherit` 已被解析掉)。
|
||||
pub isolation: Isolation,
|
||||
/// 生效运行目录(喂 `--gameDir` 与进程 CWD)。
|
||||
pub game_dir: PathBuf,
|
||||
/// 档位来自哪一层。
|
||||
pub source: IsolationSource,
|
||||
}
|
||||
|
||||
/// 按覆盖链派生运行目录(纯计算,不碰盘)。
|
||||
///
|
||||
/// - `temporary`:临时启动参数给的目录(空路径视为未给);
|
||||
/// - `instance`:实例设置;`isolation` 为 `None`(= `inherit`)或没有实例时落到 `global_mode`;
|
||||
/// - `custom` 却没有 `gameDir` → [`GameError::CustomIsolationWithoutDir`](不猜默认目录)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `custom` 档位缺 `gameDir` 时返回 [`GameError::CustomIsolationWithoutDir`]。
|
||||
pub fn derive_game_dir(
|
||||
layout: &GameLayout,
|
||||
version_id: &str,
|
||||
global_mode: IsolationMode,
|
||||
instance: Option<&InstanceConfig>,
|
||||
temporary: Option<&Path>,
|
||||
) -> Result<Derivation, GameError> {
|
||||
let (isolation, source) = match instance.and_then(|config| config.isolation) {
|
||||
Some(isolation) => (isolation, IsolationSource::Instance),
|
||||
None => (global_mode.as_isolation(), IsolationSource::Global),
|
||||
};
|
||||
if let Some(path) = temporary.filter(|path| !path.as_os_str().is_empty()) {
|
||||
return Ok(Derivation {
|
||||
isolation,
|
||||
game_dir: path.to_path_buf(),
|
||||
source: IsolationSource::Temporary,
|
||||
});
|
||||
}
|
||||
let game_dir = match isolation {
|
||||
Isolation::Isolated => layout.version_dir(version_id),
|
||||
Isolation::Shared => layout.root().to_path_buf(),
|
||||
Isolation::Custom => match instance.and_then(|config| config.game_dir.clone()) {
|
||||
Some(path) if !path.as_os_str().is_empty() => path,
|
||||
_ => {
|
||||
return Err(GameError::CustomIsolationWithoutDir {
|
||||
instance: instance.map_or_else(String::new, |config| config.id.clone()),
|
||||
})
|
||||
}
|
||||
},
|
||||
};
|
||||
Ok(Derivation {
|
||||
isolation,
|
||||
game_dir,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
/// 一侧目录的内容探测结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SideContent {
|
||||
/// 探测的目录(隔离侧 = `versions/<versionId>/`,共享侧 = `.minecraft/`)。
|
||||
pub path: PathBuf,
|
||||
/// `mods` `saves` `config` 三类内容的条目数合计。
|
||||
pub entries: u32,
|
||||
}
|
||||
|
||||
impl SideContent {
|
||||
/// 是否有玩家数据(条目数 > 0)。
|
||||
#[must_use]
|
||||
pub const fn occupied(&self) -> bool {
|
||||
self.entries > 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换检测的双侧快照(落进 `lastModeChange.probe`)。
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModeChangeProbe {
|
||||
/// 隔离侧条目数。
|
||||
pub isolated_side: u32,
|
||||
/// 共享侧条目数。
|
||||
pub shared_side: u32,
|
||||
}
|
||||
|
||||
impl ModeChangeProbe {
|
||||
/// 两侧都有内容(切换后玩家会以为「存档消失」的条件)。
|
||||
#[must_use]
|
||||
pub const fn both_occupied(&self) -> bool {
|
||||
self.isolated_side > 0 && self.shared_side > 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次隔离档位切换的记录(实例文件的 `lastModeChange`,启动器写入)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModeChange {
|
||||
/// 切换时间(RFC-3339,由调用方给——core::game 不碰时钟格式)。
|
||||
pub at: String,
|
||||
/// 切换前生效档位。
|
||||
pub from: Isolation,
|
||||
/// 切换后生效档位。
|
||||
pub to: Isolation,
|
||||
/// 切换当时的双侧内容探测(只探测,不迁移)。
|
||||
#[serde(default)]
|
||||
pub probe: ModeChangeProbe,
|
||||
/// 提示是否已展示过(保证「切换后首次启动提示一次」而不是每次启动都刷)。
|
||||
#[serde(default)]
|
||||
pub warned: bool,
|
||||
}
|
||||
|
||||
impl ModeChange {
|
||||
/// 是否是一次**真的**切换(`from != to`;首启建基线时两者相同)。
|
||||
#[must_use]
|
||||
pub fn is_real_change(&self) -> bool {
|
||||
self.from != self.to
|
||||
}
|
||||
|
||||
/// 取走待展示的提示:真切换 + 两侧都有内容 + 还没提示过。
|
||||
///
|
||||
/// 取走后把 `warned` 置真(调用方负责落盘),因此同一次切换只提示一次。
|
||||
pub fn take_warning(&mut self) -> Option<ModeChangeWarning> {
|
||||
if self.warned || !self.is_real_change() || !self.probe.both_occupied() {
|
||||
return None;
|
||||
}
|
||||
self.warned = true;
|
||||
Some(ModeChangeWarning {
|
||||
from: self.from,
|
||||
to: self.to,
|
||||
probe: self.probe,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 「切换后两侧都有内容」的提示(D20:**只提示不迁移**)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ModeChangeWarning {
|
||||
/// 切换前档位。
|
||||
pub from: Isolation,
|
||||
/// 切换后档位。
|
||||
pub to: Isolation,
|
||||
/// 双侧探测结果。
|
||||
pub probe: ModeChangeProbe,
|
||||
}
|
||||
|
||||
impl ModeChangeWarning {
|
||||
/// 一句给人看的文案(含两侧条目数与「不自动搬迁」的说明)。
|
||||
#[must_use]
|
||||
pub fn message(&self) -> String {
|
||||
format!(
|
||||
"隔离档位已从 {} 切到 {}:隔离侧 {} 条、共享侧 {} 条,两侧都有内容。\
|
||||
ULL 不会自动搬数据——旧内容仍在原来那一侧,需要的话请手动把 mods/saves/config 挪到新目录。",
|
||||
self.from.as_str(),
|
||||
self.to.as_str(),
|
||||
self.probe.isolated_side,
|
||||
self.probe.shared_side,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测一侧目录的内容条目数(`mods` `saves` `config` 的直接子项合计)。
|
||||
///
|
||||
/// 目录不存在按 0 计(新实例、纯新的隔离目录都是常态,不是错误)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 目录存在但**读不了**(权限/被占用)时返回 [`GameError::Io`]——这种情况不能当成「空」,
|
||||
/// 否则切换检测会给出错误的「那侧没内容」结论。
|
||||
pub fn probe_side(path: &Path) -> Result<SideContent, GameError> {
|
||||
let mut entries: u32 = 0;
|
||||
for name in PROBED_CONTENT_DIRS {
|
||||
let dir = path.join(name);
|
||||
let reader = match std::fs::read_dir(&dir) {
|
||||
Ok(reader) => reader,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(GameError::io(&dir, error)),
|
||||
};
|
||||
for entry in reader {
|
||||
entry.map_err(|error| GameError::io(&dir, error))?;
|
||||
entries = entries.saturating_add(1);
|
||||
}
|
||||
}
|
||||
Ok(SideContent {
|
||||
path: path.to_path_buf(),
|
||||
entries,
|
||||
})
|
||||
}
|
||||
|
||||
/// 同时探测隔离侧(`versions/<versionId>/`)与共享侧(游戏根)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 任一侧读不了时返回 [`GameError::Io`]。
|
||||
pub fn probe_both_sides(
|
||||
layout: &GameLayout,
|
||||
version_id: &str,
|
||||
) -> Result<(SideContent, SideContent), GameError> {
|
||||
Ok((
|
||||
probe_side(&layout.version_dir(version_id))?,
|
||||
probe_side(layout.root())?,
|
||||
))
|
||||
}
|
||||
|
||||
/// 启动时刷新切换记录(幂等):生效档位与记录里的 `to` 不同才记一次。
|
||||
///
|
||||
/// 返回 `true` 表示 `config` 被改动(调用方负责落盘——本模块不写文件)。
|
||||
///
|
||||
/// 首次观察(没有记录)会建一条基线:`from == to`,`warned = true`——这样老实例
|
||||
/// 升级到 D20 后不会平白弹一次「两侧都有内容」。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 探测读盘失败时返回 [`GameError::Io`]。
|
||||
pub fn observe_mode(
|
||||
config: &mut InstanceConfig,
|
||||
layout: &GameLayout,
|
||||
at: &str,
|
||||
effective: Isolation,
|
||||
) -> Result<bool, GameError> {
|
||||
if config
|
||||
.last_mode_change
|
||||
.as_ref()
|
||||
.is_some_and(|change| change.to == effective)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
let from = config
|
||||
.last_mode_change
|
||||
.as_ref()
|
||||
.map_or(effective, |change| change.to);
|
||||
let (isolated_side, shared_side) = probe_both_sides(layout, &config.version_id)?;
|
||||
config.last_mode_change = Some(ModeChange {
|
||||
at: at.to_string(),
|
||||
from,
|
||||
to: effective,
|
||||
probe: ModeChangeProbe {
|
||||
isolated_side: isolated_side.entries,
|
||||
shared_side: shared_side.entries,
|
||||
},
|
||||
warned: from == effective,
|
||||
});
|
||||
Ok(true)
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
//! Adoptium(Eclipse Temurin)版本查询:`api.adoptium.net` → 一个可下载的 JDK 归档。
|
||||
//!
|
||||
//! 只用 v3 的 `assets/latest/{major}/hotspot` 这一条接口(D13:自动下载只装最新 GA):
|
||||
//!
|
||||
//! ```text
|
||||
//! GET https://api.adoptium.net/v3/assets/latest/21/hotspot?os=windows&arch=x64&image_type=jdk&vendor=eclipse
|
||||
//! → [ { release_name, version{major,semver}, binary{os,architecture,image_type,package{name,link,checksum,size}} } ]
|
||||
//! ```
|
||||
//!
|
||||
//! 两个实测坑(都在 Windows x64 / Linux x64 上确认过):
|
||||
//!
|
||||
//! 1. **`arch` 参数不参与过滤**:`os=windows&arch=x64` 也会把 `aarch64` 的条目一起返回,
|
||||
//! 必须自己按 `binary.architecture` 再筛一遍,否则 ARM 包会被当成 x64 装下来;
|
||||
//! 2. **`package.link` 是 GitHub release 直链**(`github.com/adoptium/temurin21-binaries/releases/download/…`),
|
||||
//! 这正是 [`crate::download::adoptium`] 的镜像展开所认识的形态——所以院校镜像加速
|
||||
//! 不需要本模块拼任何 URL。
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::error::{JavaError, JavaErrorKind};
|
||||
use super::release::version_from_release_name;
|
||||
use super::target::AdoptiumTarget;
|
||||
use super::vendor::JdkVendor;
|
||||
|
||||
/// Adoptium API 根。
|
||||
pub const ADOPTIUM_API_BASE: &str = "https://api.adoptium.net/v3";
|
||||
|
||||
/// 托管 JDK 的默认供应商标识(D13 v1 的目录名 `jdk-<major>-vendor` 沿用它)。
|
||||
pub const JDK_VENDOR: &str = "temurin";
|
||||
|
||||
/// 归档与查询结果的结构定义在 [`super::release`](四家发行版共用);这里重导出以保持
|
||||
/// `api::JdkRelease` / `api::JdkArchive` 这两个老路径可用。
|
||||
pub use super::release::{JdkArchive, JdkRelease};
|
||||
|
||||
/// 拼 `latest/{major}` 的查询地址。
|
||||
#[must_use]
|
||||
pub fn latest_url(major: u32, target: AdoptiumTarget) -> String {
|
||||
format!(
|
||||
"{ADOPTIUM_API_BASE}/assets/latest/{major}/hotspot?os={}&arch={}&image_type=jdk&vendor=eclipse",
|
||||
target.os, target.arch
|
||||
)
|
||||
}
|
||||
|
||||
/// 解析 `assets/latest` 的响应体,挑出与目标平台匹配的那一条。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 响应体不是 JSON、列表为空、没有匹配 `os` + `arch` 的 jdk 条目、sha256 不合法时返回错误。
|
||||
pub fn parse_latest(json: &str, target: AdoptiumTarget) -> Result<JdkRelease, JavaError> {
|
||||
let assets: Vec<ApiAsset> = serde_json::from_str(json).map_err(|error| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!("Adoptium 响应不是预期 JSON:{error}"),
|
||||
)
|
||||
})?;
|
||||
let matched = assets
|
||||
.into_iter()
|
||||
.find(|asset| asset.matches(target))
|
||||
.ok_or_else(|| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!(
|
||||
"Adoptium 没有 {}/{} 的 jdk 产物(响应里 {} 条都不匹配)",
|
||||
target.os, target.arch, "全部"
|
||||
),
|
||||
)
|
||||
.with_hint("可能该大版本只有别的架构;用 `ull java list` 挑已有 JDK")
|
||||
})?;
|
||||
matched.into_release()
|
||||
}
|
||||
|
||||
/// 请求一次「某大版本的最新 GA」。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 网络不可达、HTTP 非 2xx、响应体不认识、没有对应平台产物时返回错误。
|
||||
pub async fn fetch_latest(
|
||||
client: &reqwest::Client,
|
||||
major: u32,
|
||||
target: AdoptiumTarget,
|
||||
) -> Result<JdkRelease, JavaError> {
|
||||
let url = latest_url(major, target);
|
||||
let response = client.get(&url).send().await.map_err(|error| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Network,
|
||||
format!("连不上 Adoptium API:{error}"),
|
||||
)
|
||||
.with_hint("检查网络/代理;已装的 JDK 仍可用 `ull java list` 看到")
|
||||
})?;
|
||||
let status = response.status();
|
||||
if status == reqwest::StatusCode::NOT_FOUND {
|
||||
return Err(JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!("Adoptium 没有 {major} 的 GA 产物(HTTP 404)"),
|
||||
)
|
||||
.with_hint("可自动下载的一般是 8 / 11 / 17 / 21 / 25 这类 GA 大版本"));
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(JavaError::new(
|
||||
JavaErrorKind::Network,
|
||||
format!("Adoptium API 返回 HTTP {status}({url})"),
|
||||
));
|
||||
}
|
||||
let body = response.text().await.map_err(|error| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Network,
|
||||
format!("读 Adoptium 响应失败:{error}"),
|
||||
)
|
||||
})?;
|
||||
parse_latest(&body, target)
|
||||
}
|
||||
|
||||
/// 响应里的 `package` 子对象。
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiPackage {
|
||||
name: String,
|
||||
link: String,
|
||||
#[serde(default)]
|
||||
checksum: Option<String>,
|
||||
#[serde(default)]
|
||||
size: u64,
|
||||
}
|
||||
|
||||
/// 响应里的 `binary` 子对象。
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiBinary {
|
||||
#[serde(default)]
|
||||
os: Option<String>,
|
||||
#[serde(default)]
|
||||
architecture: Option<String>,
|
||||
#[serde(default)]
|
||||
image_type: Option<String>,
|
||||
#[serde(default)]
|
||||
package: Option<ApiPackage>,
|
||||
}
|
||||
|
||||
/// 响应里的 `version` 子对象。
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiVersion {
|
||||
#[serde(default)]
|
||||
major: Option<u32>,
|
||||
#[serde(default)]
|
||||
semver: Option<String>,
|
||||
}
|
||||
|
||||
/// 响应列表里的一条。
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiAsset {
|
||||
#[serde(default)]
|
||||
release_name: String,
|
||||
#[serde(default)]
|
||||
version: Option<ApiVersion>,
|
||||
#[serde(default)]
|
||||
binary: Option<ApiBinary>,
|
||||
}
|
||||
|
||||
impl ApiAsset {
|
||||
/// 是否就是目标平台的 jdk 归档(`arch` 参数不过滤,必须自己判)。
|
||||
fn matches(&self, target: AdoptiumTarget) -> bool {
|
||||
let Some(binary) = &self.binary else {
|
||||
return false;
|
||||
};
|
||||
let same_os = binary.os.as_deref().is_some_and(|os| os == target.os);
|
||||
let same_arch = binary
|
||||
.architecture
|
||||
.as_deref()
|
||||
.is_some_and(|arch| arch == target.arch);
|
||||
let is_jdk = binary.image_type.as_deref() != Some("jre");
|
||||
same_os && same_arch && is_jdk && binary.package.is_some()
|
||||
}
|
||||
|
||||
/// 转成对外结果(校验 sha256 形态)。
|
||||
fn into_release(self) -> Result<JdkRelease, JavaError> {
|
||||
let package = self
|
||||
.binary
|
||||
.and_then(|binary| binary.package)
|
||||
.ok_or_else(|| {
|
||||
JavaError::new(JavaErrorKind::Metadata, "Adoptium 条目缺少 package 子对象")
|
||||
})?;
|
||||
let sha256 = package
|
||||
.checksum
|
||||
.as_deref()
|
||||
.map(|hex| hex.trim().to_ascii_lowercase())
|
||||
.filter(|hex| hex.len() == 64 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()))
|
||||
.ok_or_else(|| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Integrity,
|
||||
format!("Adoptium 没给可用的 sha256({})", package.name),
|
||||
)
|
||||
.with_hint("换一个大版本,或手动装 JDK 后设 JAVA_HOME")
|
||||
})?;
|
||||
let version = self.version;
|
||||
let major = version
|
||||
.as_ref()
|
||||
.and_then(|version| version.major)
|
||||
.or_else(|| major_from_name(&package.name))
|
||||
.ok_or_else(|| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!("Adoptium 条目里读不出大版本号({})", package.name),
|
||||
)
|
||||
})?;
|
||||
let native_version = version_from_release_name(&self.release_name)
|
||||
.or_else(|| version.as_ref().and_then(|version| version.semver.clone()))
|
||||
.unwrap_or_else(|| major.to_string());
|
||||
Ok(JdkRelease {
|
||||
vendor: JdkVendor::Temurin,
|
||||
major,
|
||||
version: native_version,
|
||||
semver: version.and_then(|version| version.semver),
|
||||
release_name: self.release_name,
|
||||
archive: JdkArchive {
|
||||
name: package.name,
|
||||
url: package.link,
|
||||
sha256,
|
||||
size: package.size,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 从产物名兜底读大版本(`OpenJDK21U-jdk_x64_windows_hotspot_…` → `21`)。
|
||||
fn major_from_name(name: &str) -> Option<u32> {
|
||||
let rest = name.split_once("OpenJDK")?.1;
|
||||
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
|
||||
digits.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{latest_url, parse_latest, ApiAsset};
|
||||
use crate::game::java::error::JavaErrorKind;
|
||||
use crate::game::java::target::AdoptiumTarget;
|
||||
|
||||
const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64");
|
||||
|
||||
fn sample() -> String {
|
||||
// 实测:`arch` 参数不过滤,响应里会混进别的架构。
|
||||
r#"[
|
||||
{
|
||||
"release_name": "jdk-21.0.13+9",
|
||||
"version": {"major": 21, "semver": "21.0.13+9.0.LTS"},
|
||||
"binary": {
|
||||
"os": "windows", "architecture": "aarch64", "image_type": "jdk",
|
||||
"package": {
|
||||
"name": "OpenJDK21U-jdk_aarch64_windows_hotspot_21.0.12.1_1.zip",
|
||||
"link": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_aarch64_windows_hotspot_21.0.12.1_1.zip",
|
||||
"checksum": "9dcbb8ebe24aa9ee5a47a7fab2106a7c8988d7acee461260fef107411df91dc5",
|
||||
"size": 179666944
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"release_name": "jdk-21.0.13+9",
|
||||
"version": {"major": 21, "semver": "21.0.13+9.0.LTS"},
|
||||
"binary": {
|
||||
"os": "windows", "architecture": "x64", "image_type": "jdk",
|
||||
"package": {
|
||||
"name": "OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip",
|
||||
"link": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.13%2B9/OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip",
|
||||
"checksum": "a4dbaa05fca4bd89d84584e58c459bd732efebb1722af80ab13c6516832004f9",
|
||||
"size": 190000000
|
||||
}
|
||||
}
|
||||
}
|
||||
]"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_the_matching_arch_and_keeps_the_github_link() {
|
||||
let release = parse_latest(&sample(), WINDOWS).expect("应解析出 x64 条目");
|
||||
assert_eq!(release.major, 21);
|
||||
assert_eq!(
|
||||
release.vendor,
|
||||
crate::game::java::vendor::JdkVendor::Temurin
|
||||
);
|
||||
assert_eq!(release.version, "21.0.13+9", "版本串取自 release_name");
|
||||
assert_eq!(release.release_name, "jdk-21.0.13+9");
|
||||
assert_eq!(release.semver.as_deref(), Some("21.0.13+9.0.LTS"));
|
||||
assert_eq!(
|
||||
release.archive.name,
|
||||
"OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip"
|
||||
);
|
||||
assert_eq!(release.archive.size, 190_000_000);
|
||||
assert!(release.archive.url.contains("temurin21-binaries"));
|
||||
assert_eq!(
|
||||
crate::download::adoptium::parse_adoptium_artifact(&release.archive.url)
|
||||
.map(|artifact| (artifact.major, artifact.arch, artifact.os)),
|
||||
Some(("21".to_string(), "x64".to_string(), "windows".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_arch_is_an_error_not_a_guess() {
|
||||
let error = parse_latest(&sample(), AdoptiumTarget::new("windows", "x86-32"))
|
||||
.expect_err("x86-32 应没有条目");
|
||||
assert_eq!(error.kind(), JavaErrorKind::Metadata);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_checksum_is_rejected() {
|
||||
let body = sample().replace(
|
||||
"a4dbaa05fca4bd89d84584e58c459bd732efebb1722af80ab13c6516832004f9",
|
||||
"deadbeef",
|
||||
);
|
||||
let error = parse_latest(&body, WINDOWS).expect_err("短 sha256 应被拒");
|
||||
assert_eq!(error.kind(), JavaErrorKind::Integrity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_url_carries_os_arch_and_flavor() {
|
||||
let url = latest_url(17, AdoptiumTarget::new("linux", "aarch64"));
|
||||
assert!(url.starts_with("https://api.adoptium.net/v3/assets/latest/17/hotspot?"));
|
||||
assert!(url.contains("os=linux"));
|
||||
assert!(url.contains("arch=aarch64"));
|
||||
assert!(url.contains("image_type=jdk"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_json_body_is_metadata_error() {
|
||||
let error = parse_latest("<html>502</html>", WINDOWS).expect_err("HTML 应被拒");
|
||||
assert_eq!(error.kind(), JavaErrorKind::Metadata);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn major_falls_back_to_the_artifact_name() {
|
||||
let body = r#"[{"release_name":"jdk-17.0.13+11",
|
||||
"binary":{"os":"windows","architecture":"x64","image_type":"jdk",
|
||||
"package":{"name":"OpenJDK17U-jdk_x64_windows_hotspot_17.0.13_11.zip",
|
||||
"link":"https://example.invalid/x.zip",
|
||||
"checksum":"a4dbaa05fca4bd89d84584e58c459bd732efebb1722af80ab13c6516832004f9",
|
||||
"size":1}}}]"#;
|
||||
let release = parse_latest(body, WINDOWS).expect("应解析");
|
||||
assert_eq!(release.major, 17);
|
||||
assert_eq!(release.semver, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_without_binary_never_matches() {
|
||||
let asset: ApiAsset = serde_json::from_str(r#"{"release_name":"x"}"#).expect("最小条目");
|
||||
assert!(!asset.matches(WINDOWS));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
//! 标准安装位置扫描 + 全部发现源的合并去重(`java list` 的「系统发现」半边)。
|
||||
//!
|
||||
//! 只认 `JAVA_HOME`/`PATH` 会漏掉最常见的一类用户:**用 MSI 装的 Temurin / Microsoft OpenJDK
|
||||
//! 不设环境变量**(安装器只登记开始菜单)。HMCL 的 `JavaManager.searchAllJavaInRepository`
|
||||
//! 与 PCL 的 `JavaUtils.SearchFolders` 都扫这些位置,本模块照同一思路:
|
||||
//!
|
||||
//! | 平台 | 扫描位置 |
|
||||
//! | --- | --- |
|
||||
//! | Windows | `%ProgramFiles%\{Java, Eclipse Adoptium, Microsoft, Zulu, Amazon Corretto, BellSoft, Semeru}\*`(含 `ProgramFiles(x86)` / `ProgramW6432` 变体,WOW64 才看得到 32 位安装)、`%APPDATA%\.minecraft\runtime\*`(**官方启动器装的运行时**,跨产品兼容)、`HKLM\SOFTWARE\JavaSoft\JDK`(老 Oracle 写的,新版发行版通常没有→容错跳过) |
|
||||
//! | Linux | `/usr/lib/jvm/*`、`/usr/java/*` |
|
||||
//! | macOS | `/Library/Java/JavaVirtualMachines/*.jdk/Contents/Home` |
|
||||
//!
|
||||
//! 三个纪律:
|
||||
//!
|
||||
//! 1. **目录不存在是常态**(本机实测 `%APPDATA%\.minecraft\runtime` 与 `JavaSoft\JDK` 都没有),
|
||||
//! 一律静默跳过,绝不报错;
|
||||
//! 2. **同一路径多来源命中只列一次**,保留优先级高者(`JAVA_HOME` 指向 Program Files 里的
|
||||
//! JDK 时,用户看到的是 `JAVA_HOME` 而不是 `system`);
|
||||
//! 3. **扫描只在需要时才做**:`java list` 全扫;启动路径上先看托管+环境,不满足才扫盘
|
||||
//! (别为了一次启动白跑 N 次 `java -version`)。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use super::extract::java_binary_name;
|
||||
use super::probe::{self, discover_managed_roots, system_candidates, JavaOrigin, JavaRuntime};
|
||||
|
||||
/// 全部发现源(托管 → `JAVA_HOME` → `PATH` → 标准安装位置),已按路径去重。
|
||||
#[must_use]
|
||||
pub fn candidates(managed_root: &Path) -> Vec<JavaRuntime> {
|
||||
candidates_in_roots(std::slice::from_ref(&managed_root.to_path_buf()))
|
||||
}
|
||||
|
||||
/// 同上,但托管区可以是**多个安装根**(Portable 数据根 + Standard/Custom 落点,D23)。
|
||||
#[must_use]
|
||||
pub fn candidates_in_roots(managed_roots: &[PathBuf]) -> Vec<JavaRuntime> {
|
||||
let mut all = discover_managed_roots(managed_roots);
|
||||
all.extend(system_candidates());
|
||||
all.extend(standard_candidates());
|
||||
dedupe(all)
|
||||
}
|
||||
|
||||
/// 标准安装位置扫出来的运行时(逐个探测)。
|
||||
#[must_use]
|
||||
pub fn standard_candidates() -> Vec<JavaRuntime> {
|
||||
scan_java_binaries()
|
||||
.into_iter()
|
||||
.map(|binary| probe::probe(&binary, JavaOrigin::System))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 扫描标准安装位置,返回 `bin/java[.exe]` 路径表(稳定排序,未探测)。
|
||||
#[must_use]
|
||||
pub fn scan_java_binaries() -> Vec<PathBuf> {
|
||||
let mut found: Vec<PathBuf> = Vec::new();
|
||||
for root in standard_roots() {
|
||||
let binary = root.join("bin").join(java_binary_name());
|
||||
if binary.is_file() {
|
||||
found.push(binary);
|
||||
}
|
||||
}
|
||||
for home in registry_homes() {
|
||||
let binary = home.join("bin").join(java_binary_name());
|
||||
if binary.is_file() {
|
||||
found.push(binary);
|
||||
}
|
||||
}
|
||||
found.sort();
|
||||
found.dedup();
|
||||
found
|
||||
}
|
||||
|
||||
/// 候选 JDK 根目录(`bin/java` 的父目录的父目录)。
|
||||
#[must_use]
|
||||
pub fn standard_roots() -> Vec<PathBuf> {
|
||||
let mut roots = Vec::new();
|
||||
if cfg!(windows) {
|
||||
for variable in ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"] {
|
||||
let Some(base) = std::env::var_os(variable) else {
|
||||
continue;
|
||||
};
|
||||
let base = PathBuf::from(base);
|
||||
for vendor in WINDOWS_VENDOR_DIRS {
|
||||
roots.extend(child_dirs(&base.join(vendor)));
|
||||
}
|
||||
}
|
||||
if let Some(appdata) = std::env::var_os("APPDATA") {
|
||||
roots.extend(child_dirs(
|
||||
&PathBuf::from(appdata).join(".minecraft").join("runtime"),
|
||||
));
|
||||
}
|
||||
} else if cfg!(target_os = "macos") {
|
||||
for entry in child_dirs(Path::new("/Library/Java/JavaVirtualMachines")) {
|
||||
// `*.jdk/Contents/Home`(`java_home` 语义)。
|
||||
let home = entry.join("Contents").join("Home");
|
||||
roots.push(if home.is_dir() { home } else { entry });
|
||||
}
|
||||
} else {
|
||||
for base in ["/usr/lib/jvm", "/usr/java"] {
|
||||
roots.extend(child_dirs(Path::new(base)));
|
||||
}
|
||||
}
|
||||
roots.sort();
|
||||
roots.dedup();
|
||||
roots
|
||||
}
|
||||
|
||||
/// Windows 各发行版的安装父目录(每种下面按子目录名当版本)。
|
||||
const WINDOWS_VENDOR_DIRS: &[&str] = &[
|
||||
"Java",
|
||||
"Eclipse Adoptium",
|
||||
"Microsoft",
|
||||
"Zulu",
|
||||
"Amazon Corretto",
|
||||
"BellSoft",
|
||||
"Semeru",
|
||||
];
|
||||
|
||||
/// 列一个目录的直接子目录(不存在/无权限一律空)。
|
||||
fn child_dirs(dir: &Path) -> Vec<PathBuf> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
|
||||
.map(|entry| entry.path())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 注册表里登记的 `JavaHome`(只查老 Oracle 的 `JavaSoft\JDK`;查不到返回空)。
|
||||
///
|
||||
/// 不走额外依赖:`reg.exe` 在 Win10+ 恒定存在,输出格式也稳定
|
||||
/// (` JavaHome REG_SZ C:\Program Files\Java\jdk-17`)。
|
||||
#[must_use]
|
||||
pub fn registry_homes() -> Vec<PathBuf> {
|
||||
if !cfg!(windows) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut homes = Vec::new();
|
||||
for view in ["64", "32"] {
|
||||
let Ok(output) = Command::new("reg")
|
||||
.args(["query", "HKLM\\SOFTWARE\\JavaSoft\\JDK", "/s", "/reg:"])
|
||||
.arg(view)
|
||||
.output()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !output.status.success() {
|
||||
continue;
|
||||
}
|
||||
homes.extend(parse_java_home(&String::from_utf8_lossy(&output.stdout)));
|
||||
}
|
||||
homes.sort();
|
||||
homes.dedup();
|
||||
homes
|
||||
}
|
||||
|
||||
/// 从 `reg query /s` 的输出里抠出 `JavaHome` 的值(纯函数,便于用真实输出做单测)。
|
||||
#[must_use]
|
||||
pub fn parse_java_home(output: &str) -> Vec<PathBuf> {
|
||||
output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let rest = line.trim().strip_prefix("JavaHome")?;
|
||||
let (_, value) = rest.split_once("REG_SZ")?;
|
||||
let home = value.trim();
|
||||
(!home.is_empty()).then(|| PathBuf::from(home))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 按路径去重,保留**先出现**的那个(调用方按优先级排好序)。
|
||||
#[must_use]
|
||||
pub fn dedupe(runtimes: Vec<JavaRuntime>) -> Vec<JavaRuntime> {
|
||||
let mut seen: Vec<String> = Vec::with_capacity(runtimes.len());
|
||||
let mut kept = Vec::with_capacity(runtimes.len());
|
||||
for runtime in runtimes {
|
||||
let key = path_key(&runtime.binary);
|
||||
if seen.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
seen.push(key);
|
||||
kept.push(runtime);
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
/// 去重键:去掉 Windows 的 `\\?\` 前缀、统一分隔符、大小写不敏感(Windows)。
|
||||
#[must_use]
|
||||
pub fn path_key(path: &Path) -> String {
|
||||
let text = path.display().to_string();
|
||||
let text = text.strip_prefix(r"\\?\").unwrap_or(&text).to_string();
|
||||
if cfg!(windows) {
|
||||
text.replace('/', "\\").to_ascii_lowercase()
|
||||
} else {
|
||||
text
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{dedupe, parse_java_home, path_key, standard_roots};
|
||||
use crate::game::java::probe::{JavaOrigin, JavaRuntime};
|
||||
|
||||
fn runtime(path: &str, origin: JavaOrigin) -> JavaRuntime {
|
||||
JavaRuntime {
|
||||
binary: std::path::PathBuf::from(path),
|
||||
origin,
|
||||
version: None,
|
||||
major: Some(21),
|
||||
bits: 64,
|
||||
vendor: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedupe_keeps_the_higher_priority_source() {
|
||||
let list = vec![
|
||||
runtime(
|
||||
"C:/Program Files/Java/jdk-21/bin/java.exe",
|
||||
JavaOrigin::JavaHome,
|
||||
),
|
||||
runtime(
|
||||
"C:\\Program Files\\Java\\jdk-21\\bin\\java.exe",
|
||||
JavaOrigin::System,
|
||||
),
|
||||
runtime(
|
||||
"C:/Program Files/Zulu/zulu-17/bin/java.exe",
|
||||
JavaOrigin::System,
|
||||
),
|
||||
];
|
||||
let kept = dedupe(list);
|
||||
assert_eq!(kept.len(), 2);
|
||||
assert_eq!(kept[0].origin, JavaOrigin::JavaHome, "重复路径保留高优先级");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_key_normalises_separators() {
|
||||
let left = path_key(std::path::Path::new("C:/a/b/java.exe"));
|
||||
let right = path_key(std::path::Path::new("C:\\a\\b\\java.exe"));
|
||||
assert_eq!(left, right);
|
||||
assert!(!path_key(std::path::Path::new(r"\\?\C:\a\java.exe")).starts_with(r"\\"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_real_reg_query_output() {
|
||||
// 实测形态(`reg query HKLM\SOFTWARE\JavaSoft\JDK /s`)。
|
||||
let output = "\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\JavaSoft\\JDK\r\n CurrentVersion REG_SZ 1.8\r\n\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\JavaSoft\\JDK\\1.8\r\n JavaHome REG_SZ C:\\Program Files\\Java\\jdk1.8.0_402\r\n";
|
||||
let homes = parse_java_home(output);
|
||||
assert_eq!(
|
||||
homes,
|
||||
vec![std::path::PathBuf::from(
|
||||
r"C:\Program Files\Java\jdk1.8.0_402"
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_registry_key_is_not_an_error() {
|
||||
assert!(parse_java_home("错误: 系统找不到指定的注册表项或值。\r\n").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_roots_never_panic_on_missing_directories() {
|
||||
// 本机大概率缺若干目录;这里只要求「能跑、不 panic」。
|
||||
let roots = standard_roots();
|
||||
assert!(roots.iter().all(|root| !root.as_os_str().is_empty()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Java 管理模块的错误形状:稳定分类 + 人读说明 + 下一步提示。
|
||||
//!
|
||||
//! 与 `core::game::install::InstallError` 同一个套路(分类进 `--json` 的 `error.code`,
|
||||
//! 说明给人看,提示告诉用户下一步做什么),只是没有复用那个类型——安装层的错误语义
|
||||
//! 绑在版本本体上,JDK 下载的失败面(API 不可达 / 无对应产物 / 解压后跑不起来)不一样。
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::download::{DownloadError, DownloadErrorKind};
|
||||
|
||||
/// 失败大类(CLI 映射成稳定错误码,脚本可分流)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum JavaErrorKind {
|
||||
/// 网络侧:Adoptium API 不可达,或所有候选镜像源都失败。
|
||||
Network,
|
||||
/// 元数据不认识:API 返回体缺字段、空列表、要的大版本没有 GA 产物。
|
||||
Metadata,
|
||||
/// 摘要校验不符(镜像给了错内容)或 API 没给可用的 sha256。
|
||||
Integrity,
|
||||
/// 本地 IO:建目录 / 解压 / 落盘。
|
||||
Io,
|
||||
/// 当前平台没有对应的 Temurin 产物(架构不认识等)。
|
||||
Unsupported,
|
||||
/// 装完探测不到可用 java(解压成功但跑不起来 / 版本不符)。
|
||||
Probe,
|
||||
}
|
||||
|
||||
impl JavaErrorKind {
|
||||
/// 稳定字符串 id(进 `--json` 的 `error.code`)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Network => "java_network",
|
||||
Self::Metadata => "java_metadata",
|
||||
Self::Integrity => "java_integrity",
|
||||
Self::Io => "java_io",
|
||||
Self::Unsupported => "java_unsupported",
|
||||
Self::Probe => "java_probe",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Java 管理期错误。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct JavaError {
|
||||
kind: JavaErrorKind,
|
||||
message: String,
|
||||
hint: Option<String>,
|
||||
}
|
||||
|
||||
impl JavaError {
|
||||
/// 构造。
|
||||
#[must_use]
|
||||
pub fn new(kind: JavaErrorKind, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
hint: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 补一句「下一步怎么办」。
|
||||
#[must_use]
|
||||
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
|
||||
self.hint = Some(hint.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// 分类。
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> JavaErrorKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
/// 稳定错误码。
|
||||
#[must_use]
|
||||
pub const fn code(&self) -> &'static str {
|
||||
self.kind.as_str()
|
||||
}
|
||||
|
||||
/// 人读说明。
|
||||
#[must_use]
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
|
||||
/// 下一步提示。
|
||||
#[must_use]
|
||||
pub fn hint(&self) -> Option<&str> {
|
||||
self.hint.as_deref()
|
||||
}
|
||||
|
||||
/// 在说明前面加一段上下文(保留分类与提示)。
|
||||
#[must_use]
|
||||
pub fn with_context(mut self, context: impl fmt::Display) -> Self {
|
||||
self.message = format!("{context}:{}", self.message);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for JavaError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for JavaError {}
|
||||
|
||||
impl From<DownloadError> for JavaError {
|
||||
fn from(error: DownloadError) -> Self {
|
||||
let kind = match error.kind {
|
||||
DownloadErrorKind::Integrity => JavaErrorKind::Integrity,
|
||||
DownloadErrorKind::Io => JavaErrorKind::Io,
|
||||
DownloadErrorKind::InvalidRequest => JavaErrorKind::Metadata,
|
||||
DownloadErrorKind::NotFound
|
||||
| DownloadErrorKind::ServerError
|
||||
| DownloadErrorKind::Timeout
|
||||
| DownloadErrorKind::Network
|
||||
| DownloadErrorKind::RangeUnsupported
|
||||
| DownloadErrorKind::AllSourcesFailed => JavaErrorKind::Network,
|
||||
};
|
||||
let mut message = error.message.clone();
|
||||
if let Some(source) = &error.source {
|
||||
message.push_str(&format!("(源 {source})"));
|
||||
}
|
||||
// 逐源失败摘要:没有这一段,「全部失败」在现场只能靠复现去猜(本轮真机验收就踩到了)。
|
||||
if !error.suppressed.is_empty() {
|
||||
message.push_str(&format!(";逐源明细:{}", error.suppressed.join(" / ")));
|
||||
}
|
||||
Self::new(kind, message)
|
||||
}
|
||||
}
|
||||
|
||||
/// IO 错误包装(带上「在做什么 / 哪个路径」,日志里才追得动)。
|
||||
#[must_use]
|
||||
pub fn io_error(op: &'static str, path: impl fmt::Display, source: &std::io::Error) -> JavaError {
|
||||
JavaError::new(JavaErrorKind::Io, format!("{op} 失败({path}):{source}"))
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
//! 归档解压:zip(Windows)/ tar.gz(Linux、macOS 的 Temurin 只发这种)+ 幂等戳记。
|
||||
//!
|
||||
//! # 为什么不用「解压到最终目录」了事
|
||||
//!
|
||||
//! Temurin 的归档里所有条目都在一个顶层目录下(`jdk-21.0.13+9/bin/java`),
|
||||
//! 而托管布局要的是 `java-managed/jdk-21-temurin/bin/java`。裸解压会多出一层,
|
||||
//! 于是这里先在**暂存目录**里解完,再把那个唯一的顶层目录(或暂存目录本身)
|
||||
//! 提升成最终目录——这样终态恒定,`bin/java` 的位置不会因为厂商换打包方式而漂移。
|
||||
//!
|
||||
//! # 幂等
|
||||
//!
|
||||
//! 戳记 `.ull-jdk.json` 记下「这份目录是用哪个 sha256 的归档解出来的 + 文件数」。
|
||||
//! 重复 `ull java install 21` 时 sha256 相同就**一个字节都不动**(返回 `skipped`);
|
||||
//! 官方发了新版(sha256 变)才重解。戳记缺失/损坏一律当「没装过」。
|
||||
//!
|
||||
//! # 安全
|
||||
//!
|
||||
//! zip 走 `enclosed_name()`(拒绝 `..` 与绝对路径),tar 走 `unpack_in()`
|
||||
//! (同样拒绝越界),两者都不会把文件写到目标目录之外(zip-slip)。
|
||||
//! tar 里的符号链接条目跳过(Windows 建不了;Temurin 的 JDK 归档不依赖它们)。
|
||||
|
||||
use std::fs::{self, File};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use flate2::read::GzDecoder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zip::ZipArchive;
|
||||
|
||||
use super::error::{io_error, JavaError, JavaErrorKind};
|
||||
|
||||
/// 戳记文件名(放在托管 JDK 根目录里)。
|
||||
pub const STAMP_FILE: &str = ".ull-jdk.json";
|
||||
|
||||
/// 戳记 schema 版本(改字段语义才升)。
|
||||
const STAMP_VERSION: u8 = 1;
|
||||
|
||||
/// 暂存目录前缀(解压在它里面进行,成功后提升成最终目录)。
|
||||
const STAGING_PREFIX: &str = ".staging-";
|
||||
|
||||
/// 托管 JDK 的戳记。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct JdkStamp {
|
||||
/// schema 版本。
|
||||
pub v: u8,
|
||||
/// 归档文件名。
|
||||
pub archive: String,
|
||||
/// 归档的 SHA-256(幂等判据)。
|
||||
pub sha256: String,
|
||||
/// 大版本号。
|
||||
pub major: u32,
|
||||
/// 解出来的文件数。
|
||||
pub files: usize,
|
||||
}
|
||||
|
||||
/// 一次解压的结果。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ExtractReport {
|
||||
/// 本次落盘的文件数(`skipped` 时为 0)。
|
||||
pub files: usize,
|
||||
/// 是否因为戳记命中而整体跳过。
|
||||
pub skipped: bool,
|
||||
}
|
||||
|
||||
impl ExtractReport {
|
||||
/// 是否真的写盘了。
|
||||
#[must_use]
|
||||
pub const fn wrote(self) -> bool {
|
||||
!self.skipped
|
||||
}
|
||||
}
|
||||
|
||||
/// 托管 JDK 的 `java` 可执行文件路径。
|
||||
#[must_use]
|
||||
pub fn java_binary(dir: &Path) -> PathBuf {
|
||||
dir.join("bin").join(java_binary_name())
|
||||
}
|
||||
|
||||
/// 平台上的 java 可执行文件名。
|
||||
#[must_use]
|
||||
pub const fn java_binary_name() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"java.exe"
|
||||
} else {
|
||||
"java"
|
||||
}
|
||||
}
|
||||
|
||||
/// 读戳记(不存在 / 损坏 / 版本不符都当作「没有」)。
|
||||
#[must_use]
|
||||
pub fn read_stamp(dir: &Path) -> Option<JdkStamp> {
|
||||
let text = fs::read_to_string(dir.join(STAMP_FILE)).ok()?;
|
||||
let stamp: JdkStamp = serde_json::from_str(&text).ok()?;
|
||||
(stamp.v == STAMP_VERSION).then_some(stamp)
|
||||
}
|
||||
|
||||
/// 这个目录里是不是已经躺着一份用**同一个归档**解出来的 JDK。
|
||||
#[must_use]
|
||||
pub fn is_ready(dir: &Path, sha256: &str) -> bool {
|
||||
let Some(stamp) = read_stamp(dir) else {
|
||||
return false;
|
||||
};
|
||||
stamp.sha256 == sha256.to_ascii_lowercase() && java_binary(dir).is_file()
|
||||
}
|
||||
|
||||
/// 解压一个 JDK 归档到 `dest`(幂等)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 归档打不开/格式不认识、解压写盘失败、`dest` 已存在却不由我们管理
|
||||
/// (没有戳记)时返回错误。
|
||||
pub fn extract_archive(
|
||||
archive: &Path,
|
||||
sha256: &str,
|
||||
major: u32,
|
||||
dest: &Path,
|
||||
) -> Result<ExtractReport, JavaError> {
|
||||
if is_ready(dest, sha256) {
|
||||
return Ok(ExtractReport {
|
||||
files: 0,
|
||||
skipped: true,
|
||||
});
|
||||
}
|
||||
let parent = dest.parent().ok_or_else(|| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Io,
|
||||
format!("托管目录没有父目录:{}", dest.display()),
|
||||
)
|
||||
})?;
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| io_error("创建托管目录", parent.display(), &error))?;
|
||||
|
||||
let folder = dest
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "jdk".to_string());
|
||||
let staging = parent.join(format!("{STAGING_PREFIX}{folder}"));
|
||||
if staging.exists() {
|
||||
fs::remove_dir_all(&staging)
|
||||
.map_err(|error| io_error("清理暂存目录", staging.display(), &error))?;
|
||||
}
|
||||
fs::create_dir_all(&staging)
|
||||
.map_err(|error| io_error("创建暂存目录", staging.display(), &error))?;
|
||||
|
||||
let extracted = if is_zip(archive) {
|
||||
extract_zip(archive, &staging)
|
||||
} else {
|
||||
extract_tar_gz(archive, &staging)
|
||||
};
|
||||
let files = match extracted {
|
||||
Ok(files) => files,
|
||||
Err(error) => {
|
||||
let _ = fs::remove_dir_all(&staging);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
let root = single_top_level_dir(&staging).unwrap_or_else(|| staging.clone());
|
||||
if dest.exists() {
|
||||
// 只回收「我们自己装的」目录:有戳记,或空目录。其余一律不碰。
|
||||
let ours = read_stamp(dest).is_some() || is_empty_dir(dest);
|
||||
if !ours {
|
||||
let _ = fs::remove_dir_all(&staging);
|
||||
return Err(JavaError::new(
|
||||
JavaErrorKind::Io,
|
||||
format!("目标目录已存在且不是托管 JDK:{}", dest.display()),
|
||||
)
|
||||
.with_hint("换个目录(`--data-dir`),或先手动清掉它"));
|
||||
}
|
||||
fs::remove_dir_all(dest)
|
||||
.map_err(|error| io_error("清空旧托管目录", dest.display(), &error))?;
|
||||
}
|
||||
fs::rename(&root, dest).map_err(|error| {
|
||||
io_error(
|
||||
"提升暂存目录",
|
||||
format!("{} → {}", root.display(), dest.display()),
|
||||
&error,
|
||||
)
|
||||
})?;
|
||||
if root != staging {
|
||||
let _ = fs::remove_dir_all(&staging);
|
||||
}
|
||||
if !java_binary(dest).is_file() {
|
||||
return Err(JavaError::new(
|
||||
JavaErrorKind::Probe,
|
||||
format!("解压完成但找不到 bin/{}", java_binary_name()),
|
||||
)
|
||||
.with_hint("归档形态与平台不匹配(例如把 Linux 包当成 Windows 包装)"));
|
||||
}
|
||||
|
||||
let stamp = JdkStamp {
|
||||
v: STAMP_VERSION,
|
||||
archive: archive
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_default(),
|
||||
sha256: sha256.to_ascii_lowercase(),
|
||||
major,
|
||||
files,
|
||||
};
|
||||
let text = serde_json::to_string_pretty(&stamp).map_err(|error| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!("序列化 JDK 戳记失败:{error}"),
|
||||
)
|
||||
})?;
|
||||
let stamp_path = dest.join(STAMP_FILE);
|
||||
fs::write(&stamp_path, text)
|
||||
.map_err(|error| io_error("写 JDK 戳记", stamp_path.display(), &error))?;
|
||||
Ok(ExtractReport {
|
||||
files,
|
||||
skipped: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// 归档是不是 zip(魔数优先,读不到才退回扩展名)。
|
||||
///
|
||||
/// 不按扩展名一刀切:镜像把 `.zip` 存成 `.zip.tmp` 之类时魔数仍然可靠,
|
||||
/// 反过来把 tar.gz 命名成 `.zip` 也不会被错解。
|
||||
#[must_use]
|
||||
pub fn is_zip(archive: &Path) -> bool {
|
||||
let mut head = [0u8; 4];
|
||||
let magic = File::open(archive)
|
||||
.and_then(|mut file| io::Read::read_exact(&mut file, &mut head).map(|()| head));
|
||||
if let Ok(head) = magic {
|
||||
// `PK\x03\x04` 普通条目 / `PK\x05\x06` 空归档。
|
||||
return head == *b"PK\x03\x04" || head == *b"PK\x05\x06";
|
||||
}
|
||||
archive
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().to_ascii_lowercase())
|
||||
.is_some_and(|name| name.ends_with(".zip"))
|
||||
}
|
||||
|
||||
/// zip:逐条目落盘,`enclosed_name` 挡路径穿越。
|
||||
fn extract_zip(archive: &Path, dir: &Path) -> Result<usize, JavaError> {
|
||||
let file = File::open(archive)
|
||||
.map_err(|error| io_error("打开 JDK 归档", archive.display(), &error))?;
|
||||
let mut zip = ZipArchive::new(file).map_err(|error| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Io,
|
||||
format!("JDK 归档不是合法 zip({}):{error}", archive.display()),
|
||||
)
|
||||
})?;
|
||||
let mut files = 0;
|
||||
for index in 0..zip.len() {
|
||||
let mut entry = zip.by_index(index).map_err(|error| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Io,
|
||||
format!("读 zip 条目失败({}):{error}", archive.display()),
|
||||
)
|
||||
})?;
|
||||
if entry.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(relative) = entry.enclosed_name() else {
|
||||
continue;
|
||||
};
|
||||
let dest = dir.join(&relative);
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| io_error("创建解压子目录", parent.display(), &error))?;
|
||||
}
|
||||
let mut out = File::create(&dest)
|
||||
.map_err(|error| io_error("创建解压文件", dest.display(), &error))?;
|
||||
io::copy(&mut entry, &mut out)
|
||||
.map_err(|error| io_error("写解压文件", dest.display(), &error))?;
|
||||
files += 1;
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// tar.gz:`unpack_in` 挡路径穿越,符号链接条目跳过。
|
||||
fn extract_tar_gz(archive: &Path, dir: &Path) -> Result<usize, JavaError> {
|
||||
let file = File::open(archive)
|
||||
.map_err(|error| io_error("打开 JDK 归档", archive.display(), &error))?;
|
||||
let mut tar = tar::Archive::new(GzDecoder::new(file));
|
||||
let entries = tar.entries().map_err(|error| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Io,
|
||||
format!("JDK 归档不是合法 tar.gz({}):{error}", archive.display()),
|
||||
)
|
||||
})?;
|
||||
let mut files = 0;
|
||||
for entry in entries {
|
||||
let mut entry = entry.map_err(|error| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Io,
|
||||
format!("读 tar 条目失败({}):{error}", archive.display()),
|
||||
)
|
||||
})?;
|
||||
if !entry.header().entry_type().is_file() {
|
||||
continue;
|
||||
}
|
||||
let unpacked = entry
|
||||
.unpack_in(dir)
|
||||
.map_err(|error| io_error("解压 tar 条目", dir.display(), &error))?;
|
||||
if unpacked {
|
||||
files += 1;
|
||||
}
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// 暂存目录里是不是只有一个顶层目录(Temurin 的 `jdk-21.0.13+9/`)。
|
||||
fn single_top_level_dir(dir: &Path) -> Option<PathBuf> {
|
||||
let mut entries = fs::read_dir(dir).ok()?;
|
||||
let first = entries.next()?.ok()?;
|
||||
if entries.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
let path = first.path();
|
||||
path.is_dir().then_some(path)
|
||||
}
|
||||
|
||||
fn is_empty_dir(dir: &Path) -> bool {
|
||||
fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none())
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
//! 托管 JDK 安装编排:发行版回退链 → 下载引擎 → 解压 → `java -version` 验证。
|
||||
//!
|
||||
//! ```text
|
||||
//! vendor::order(用户优先级, 地区) D23 的尝试顺序
|
||||
//! → source::fetch(vendor, major) 查到该家最新 GA(Temurin/Oracle/MS/Zulu)
|
||||
//! → location::JavaInstallRoot::jdk_dir 按落点模式算目录
|
||||
//! → [戳记 sha256 相同 && bin/java 在] 复用 幂等的第一层
|
||||
//! → engine.download(JavaRuntime 请求) 镜像/健康度/退避/续传/边下边算全在引擎
|
||||
//! → extract::extract_archive zip / tar.gz + 幂等戳记
|
||||
//! → probe::probe(bin/java -version) 幂等的第二层,也是「装完真能用」的证据
|
||||
//! ```
|
||||
//!
|
||||
//! **回退的粒度是发行版**(D23):该家没有这个大版本、镜像全 4xx、sha256 取不到、下载校验失败,
|
||||
//! 都记一条降级原因然后换下一家;只有全部失败才整体报错,错误里带上每一跳的原因。
|
||||
//!
|
||||
//! 这一步**不拼任何镜像 URL**(Oracle 的哈希段不可推导,见 `oracle` 模块):Temurin 的
|
||||
//! GitHub 直链交给 `MirrorTable::expand` 展开成院校镜像,Oracle/MS/Zulu 的链接由各自模块
|
||||
//! 从目录页/模板/API 拿到最终形态,引擎只负责「把这个 URL 下下来并校验」。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::error::{io_error, JavaError, JavaErrorKind};
|
||||
use super::extract;
|
||||
use super::location::{JavaInstallRoot, LocationMode};
|
||||
use super::probe::{self, JavaOrigin, JavaRuntime};
|
||||
use super::release::JdkRelease;
|
||||
use super::source::{self, ChainLog};
|
||||
use super::target::AdoptiumTarget;
|
||||
use super::vendor::{self, JdkVendor};
|
||||
use crate::download::{DownloadEngine, DownloadRequest, ResourceKind};
|
||||
|
||||
/// 归档缓存目录名(在安装根下;托管目录扫描跳过它)。
|
||||
pub use super::location::CACHE_DIR;
|
||||
|
||||
/// 一次安装请求。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InstallRequest {
|
||||
/// 目标大版本。
|
||||
pub major: u32,
|
||||
/// 钉死某一家发行版(`--vendor`);`None` = 按回退链自己选。
|
||||
pub vendor: Option<JdkVendor>,
|
||||
/// 用户优先级(`java.vendorPriority`,空 = 按地区默认序)。
|
||||
pub priority: Vec<JdkVendor>,
|
||||
}
|
||||
|
||||
impl InstallRequest {
|
||||
/// 只要大版本(走回退链,优先级用地区默认)。
|
||||
#[must_use]
|
||||
pub const fn new(major: u32) -> Self {
|
||||
Self {
|
||||
major,
|
||||
vendor: None,
|
||||
priority: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 钉死发行版。
|
||||
#[must_use]
|
||||
pub const fn with_vendor(major: u32, vendor: JdkVendor) -> Self {
|
||||
Self {
|
||||
major,
|
||||
vendor: Some(vendor),
|
||||
priority: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 带上用户优先级(只影响没有 `--vendor` 的情形)。
|
||||
#[must_use]
|
||||
pub fn with_priority(mut self, priority: Vec<JdkVendor>) -> Self {
|
||||
self.priority = priority;
|
||||
self
|
||||
}
|
||||
|
||||
/// 实际尝试顺序(`--vendor` 钉死时链里只放它)。
|
||||
#[must_use]
|
||||
pub fn order(&self, domestic: bool) -> Vec<JdkVendor> {
|
||||
match self.vendor {
|
||||
Some(vendor) => vec![vendor],
|
||||
None => vendor::order(&self.priority, domestic),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 会真正进入回退链的发行版(顺序 + 能力过滤)。
|
||||
///
|
||||
/// 与 [`install`] 的循环同源,但**纯**:不联网、不落盘,给 `--dry-run` 与离线测试用。
|
||||
/// 过滤条件只有一条——该家发不发这个 GA 大版本(MS 没有 8、Oracle 从 9 起)。
|
||||
/// `--vendor` 钉死时**不过滤**:链里就它一个,由 `install` 明确报「不支持 Java X」,
|
||||
/// 而不是悄悄把用户点名的发行版换成别的。
|
||||
#[must_use]
|
||||
pub fn chain_order(request: &InstallRequest, domestic: bool) -> Vec<JdkVendor> {
|
||||
if request.vendor.is_some() {
|
||||
return request.order(domestic);
|
||||
}
|
||||
request
|
||||
.order(domestic)
|
||||
.into_iter()
|
||||
.filter(|vendor| vendor.supports(request.major))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 一次成功安装的结果。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InstalledJdk {
|
||||
/// 大版本号。
|
||||
pub major: u32,
|
||||
/// 实际装上的发行版。
|
||||
pub vendor: JdkVendor,
|
||||
/// 版本查询结果(含归档名与 sha256)。
|
||||
pub release: JdkRelease,
|
||||
/// 托管目录(Portable:`java-managed/jdk-21-temurin`;Standard:`Java/oracle/21.0.2`)。
|
||||
pub root: PathBuf,
|
||||
/// 落点模式(报告里要说清「装到哪」)。
|
||||
pub mode: LocationMode,
|
||||
/// 归档路径(缓存)。
|
||||
pub archive: PathBuf,
|
||||
/// 探测到的运行时。
|
||||
pub runtime: JavaRuntime,
|
||||
/// 实际下载的源标识(`None` = 复用已装,没走网络)。
|
||||
pub downloaded: Option<String>,
|
||||
/// 本次下载字节数(复用已装时为 0)。
|
||||
pub bytes: u64,
|
||||
/// 本次解压的文件数(复用已装时可能为 0)。
|
||||
pub files: usize,
|
||||
/// 本次是否真的解压了。
|
||||
pub extracted: bool,
|
||||
/// 回退链记录(命中与降级原因)。
|
||||
pub chain: ChainLog,
|
||||
}
|
||||
|
||||
impl InstalledJdk {
|
||||
/// 是否完全复用了已装的那一份。
|
||||
#[must_use]
|
||||
pub fn reused(&self) -> bool {
|
||||
self.downloaded.is_none()
|
||||
}
|
||||
|
||||
/// `bin/java[.exe]` 路径。
|
||||
#[must_use]
|
||||
pub fn binary(&self) -> PathBuf {
|
||||
extract::java_binary(&self.root)
|
||||
}
|
||||
|
||||
/// 实际落点(人读一行)。
|
||||
#[must_use]
|
||||
pub fn location_line(&self) -> String {
|
||||
format!("{} · {}", self.mode.as_str(), self.root.display())
|
||||
}
|
||||
}
|
||||
|
||||
/// 托管目录路径(Portable 布局 `java-managed/jdk-<major>-<vendor>`)。
|
||||
#[must_use]
|
||||
pub fn managed_root_dir(managed_root: &Path, major: u32, vendor: &str) -> PathBuf {
|
||||
managed_root.join(probe::managed_dir_name(major, vendor))
|
||||
}
|
||||
|
||||
/// 归档缓存路径(`<root>/.cache/<归档名>`)。
|
||||
#[must_use]
|
||||
pub fn archive_path(root: &Path, release: &JdkRelease) -> PathBuf {
|
||||
root.join(CACHE_DIR).join(&release.archive.name)
|
||||
}
|
||||
|
||||
/// 归档下载请求(`ResourceKind::JavaRuntime` + sha256 + 期望大小)。
|
||||
#[must_use]
|
||||
pub fn archive_request(release: &JdkRelease, dest: PathBuf) -> DownloadRequest {
|
||||
let request =
|
||||
DownloadRequest::new(ResourceKind::JavaRuntime, release.archive.url.clone(), dest)
|
||||
.with_sha256(release.archive.sha256.clone());
|
||||
if release.archive.size > 0 {
|
||||
request.with_size(release.archive.size)
|
||||
} else {
|
||||
request
|
||||
}
|
||||
}
|
||||
|
||||
/// 按优先级链装一个托管 JDK(幂等:同一份归档装过就直接复用)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 全部发行版都失败(没有该大版本 / 平台无产物 / 网络 / 校验 / 解压 / 装完跑不起来)时
|
||||
/// 返回错误,`message()` 里带着每一跳的降级原因。
|
||||
pub async fn install(
|
||||
engine: &DownloadEngine,
|
||||
location: &JavaInstallRoot,
|
||||
request: InstallRequest,
|
||||
) -> Result<InstalledJdk, JavaError> {
|
||||
let target = AdoptiumTarget::host()?;
|
||||
let domestic = engine.region().is_domestic();
|
||||
let priority = request.order(domestic);
|
||||
let mut chain = ChainLog::new();
|
||||
let mut reasons: Vec<String> = Vec::new();
|
||||
// 清掉上次中断留下的 .staging-* 与空壳(只碰我们自己的形状,见 location 模块)。
|
||||
let _ = super::location::cleanup_orphans(&location.root);
|
||||
|
||||
for candidate in &priority {
|
||||
let vendor = *candidate;
|
||||
if !vendor.supports(request.major) {
|
||||
let reason = format!(
|
||||
"不支持 Java {}({} 只发 {})",
|
||||
request.major,
|
||||
vendor.id(),
|
||||
majors_text(vendor)
|
||||
);
|
||||
chain.skip(vendor, reason.clone());
|
||||
reasons.push(format!("{}:{reason}", vendor.id()));
|
||||
continue;
|
||||
}
|
||||
let release = match source::fetch(engine, vendor, request.major, target).await {
|
||||
Ok(release) => release,
|
||||
Err(error) => {
|
||||
chain.failed(vendor, error.message().to_string());
|
||||
reasons.push(format!("{}:{}", vendor.id(), error.message()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match install_one(engine, location, &release).await {
|
||||
Ok(prepared) => {
|
||||
chain.hit(vendor, format!("命中 {}", release.summary()));
|
||||
return Ok(InstalledJdk {
|
||||
major: release.major,
|
||||
vendor,
|
||||
release,
|
||||
root: prepared.root,
|
||||
mode: location.mode,
|
||||
archive: prepared.archive,
|
||||
runtime: prepared.runtime,
|
||||
downloaded: prepared.downloaded,
|
||||
bytes: prepared.bytes,
|
||||
files: prepared.files,
|
||||
extracted: prepared.extracted,
|
||||
chain,
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
chain.failed(vendor, error.message().to_string());
|
||||
reasons.push(format!("{}:{}", vendor.id(), error.message()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!(
|
||||
"没有装成 Java {}(试过 {}):{}",
|
||||
request.major,
|
||||
priority.len(),
|
||||
reasons.join(";")
|
||||
),
|
||||
)
|
||||
.with_hint("`ull java vendors` 看各发行版的可用性;或手动装 JDK 后设 JAVA_HOME"))
|
||||
}
|
||||
|
||||
/// 某一家的中间结果(把七元组元组换成具名结构,读起来才不费劲)。
|
||||
struct Prepared {
|
||||
root: PathBuf,
|
||||
archive: PathBuf,
|
||||
runtime: JavaRuntime,
|
||||
downloaded: Option<String>,
|
||||
bytes: u64,
|
||||
files: usize,
|
||||
extracted: bool,
|
||||
}
|
||||
|
||||
/// 装某一家的那一份(下载 → 解压 → 探测)。
|
||||
async fn install_one(
|
||||
engine: &DownloadEngine,
|
||||
location: &JavaInstallRoot,
|
||||
release: &JdkRelease,
|
||||
) -> Result<Prepared, JavaError> {
|
||||
let root = location.jdk_dir(release.major, release.vendor, &release.version);
|
||||
let binary = extract::java_binary(&root);
|
||||
let archive = archive_path(&location.root, release);
|
||||
|
||||
// 幂等第一层:戳记里的 sha256 与本次一致,且 bin/java 在 → 一个字节都不动。
|
||||
if extract::is_ready(&root, release.sha256()) {
|
||||
let runtime = probe::probe(&binary, JavaOrigin::Managed);
|
||||
if runtime.major == Some(release.major) {
|
||||
let mut runtime = runtime;
|
||||
runtime.vendor = Some(release.vendor.id().to_string());
|
||||
return Ok(Prepared {
|
||||
root,
|
||||
archive,
|
||||
runtime,
|
||||
downloaded: None,
|
||||
bytes: 0,
|
||||
files: 0,
|
||||
extracted: false,
|
||||
});
|
||||
}
|
||||
note(&format!(
|
||||
"托管目录 {} 里的 java 跑不起来,重装一份",
|
||||
root.display()
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(parent) = archive.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|error| io_error("创建归档缓存目录", parent.display(), &error))?;
|
||||
}
|
||||
let outcome = engine
|
||||
.download(archive_request(release, archive.clone()))
|
||||
.await?;
|
||||
let report = extract::extract_archive(&archive, release.sha256(), release.major, &root)?;
|
||||
let mut runtime = probe::probe(&binary, JavaOrigin::Managed);
|
||||
runtime.vendor = Some(release.vendor.id().to_string());
|
||||
if runtime.major != Some(release.major) {
|
||||
return Err(JavaError::new(
|
||||
JavaErrorKind::Probe,
|
||||
format!(
|
||||
"装好的 JDK 探测结果是 Java {},与请求的 {} 不符({})",
|
||||
runtime
|
||||
.major
|
||||
.map_or_else(|| "未知".to_string(), |major| major.to_string()),
|
||||
release.major,
|
||||
binary.display()
|
||||
),
|
||||
)
|
||||
.with_hint("删掉该托管目录后重跑 `ull java install`;或手动设 JAVA_HOME"));
|
||||
}
|
||||
Ok(Prepared {
|
||||
root,
|
||||
archive,
|
||||
runtime,
|
||||
downloaded: Some(outcome.source),
|
||||
bytes: outcome.bytes,
|
||||
files: report.files,
|
||||
extracted: report.wrote(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 只做规划不落盘:返回将要安装的版本、目标目录与「是否已经装好」。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 与 [`install`] 的查询段相同(平台不支持 / 网络失败 / 所有发行版都没有该大版本)。
|
||||
pub async fn plan(
|
||||
engine: &DownloadEngine,
|
||||
location: &JavaInstallRoot,
|
||||
major: u32,
|
||||
) -> Result<(JdkRelease, PathBuf, bool), JavaError> {
|
||||
let target = AdoptiumTarget::host()?;
|
||||
let domestic = engine.region().is_domestic();
|
||||
let mut reasons: Vec<String> = Vec::new();
|
||||
for vendor in vendor::order(&[], domestic) {
|
||||
if !vendor.supports(major) {
|
||||
reasons.push(format!("{}:不支持 Java {major}", vendor.id()));
|
||||
continue;
|
||||
}
|
||||
match source::fetch(engine, vendor, major, target).await {
|
||||
Ok(release) => {
|
||||
let root = location.jdk_dir(release.major, release.vendor, &release.version);
|
||||
let ready = extract::is_ready(&root, release.sha256());
|
||||
return Ok((release, root, ready));
|
||||
}
|
||||
Err(error) => reasons.push(format!("{}:{}", vendor.id(), error.message())),
|
||||
}
|
||||
}
|
||||
Err(JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!("没有可用的 Java {major}({} )", reasons.join(";")),
|
||||
))
|
||||
}
|
||||
|
||||
fn majors_text(vendor: JdkVendor) -> String {
|
||||
vendor
|
||||
.hint_majors()
|
||||
.iter()
|
||||
.map(u32::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
}
|
||||
|
||||
fn note(message: &str) {
|
||||
if cfg!(debug_assertions) {
|
||||
eprintln!("[ull::game::java] {message}");
|
||||
} else {
|
||||
let _ = message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//! 托管 JDK 的落点模式(D23 三模式,与 D6 数据目录三模式对齐)。
|
||||
//!
|
||||
//! | 模式 | 落点 | 说明 |
|
||||
//! | --- | --- | --- |
|
||||
//! | Portable | `<数据根>/.ULL/data/java-managed/jdk-<major>-<vendor>/` | v1 既有布局,M33/M35 的托管根解析语义不变 |
|
||||
//! | Standard | Windows `%APPDATA%\Java\<vendor>\<version>\`;macOS `~/Library/Application Support/Java/<vendor>/<version>`;Linux `$XDG_DATA_HOME/java/<vendor>/<version>`(回落 `~/.local/share/java/…`) | 用户原话「安装到 %APPDATA%\Java\发行方\Java版本\」 |
|
||||
//! | Custom | 设置键 `java.installRoot` 指定的目录,同样按 `<vendor>/<version>` 分层 | 覆盖前两种模式 |
|
||||
//!
|
||||
//! 三条纪律:
|
||||
//!
|
||||
//! 1. **不猜平台目录**:`%APPDATA%` / `XDG_DATA_HOME` / `HOME` 全走 `platform::paths::EnvSource`,
|
||||
//! 测试能注入假环境确定性地覆盖各 OS 落点;
|
||||
//! 2. **Portable 布局永不改**:`.ULL/data/java-managed/jdk-<major>-<vendor>` 是 M33 定下的,
|
||||
//! 改成 `<vendor>/<version>` 会让既有托管 JDK 变成孤儿——迁移语义靠「两种形状都扫得到」兜住;
|
||||
//! 3. **清理只碰自己的形状**:`cleanup_orphans` 只删 `.staging-*` 半成品与空目录,
|
||||
//! 没有戳记的非空目录一律不动(用户可能自己往里塞了 JDK)。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::platform::data_dir::DataMode;
|
||||
use crate::platform::layout::LauncherLayout;
|
||||
use crate::platform::paths::{home_dir, EnvSource, Platform};
|
||||
|
||||
use super::error::{JavaError, JavaErrorKind};
|
||||
use super::vendor::JdkVendor;
|
||||
|
||||
/// Standard 模式下 JDK 的父目录名(Windows 是 `%APPDATA%\Java`)。
|
||||
pub const JAVA_DIR_NAME: &str = "Java";
|
||||
|
||||
/// 归档缓存目录名(在安装根下;托管目录扫描跳过它)。
|
||||
pub const CACHE_DIR: &str = ".cache";
|
||||
|
||||
/// 解压暂存目录前缀(中断后残留的就是孤儿)。
|
||||
pub const STAGING_PREFIX: &str = ".staging-";
|
||||
|
||||
/// 落地模式。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LocationMode {
|
||||
/// 数据根内(v1 布局)。
|
||||
Portable,
|
||||
/// OS 标准应用数据目录。
|
||||
Standard,
|
||||
/// `java.installRoot` 指定。
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl LocationMode {
|
||||
/// 稳定名(JSON / 日志)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Portable => "portable",
|
||||
Self::Standard => "standard",
|
||||
Self::Custom => "custom",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从配置字符串解析。
|
||||
#[must_use]
|
||||
pub fn parse(text: &str) -> Option<Self> {
|
||||
match text {
|
||||
"portable" => Some(Self::Portable),
|
||||
"standard" => Some(Self::Standard),
|
||||
"custom" => Some(Self::Custom),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析好的安装根 + 它是怎么来的。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct JavaInstallRoot {
|
||||
/// 落地模式。
|
||||
pub mode: LocationMode,
|
||||
/// 安装根目录(各 JDK 目录在它下面)。
|
||||
pub root: PathBuf,
|
||||
}
|
||||
|
||||
impl JavaInstallRoot {
|
||||
/// Portable:`<数据根>/.ULL/data/java-managed`。
|
||||
#[must_use]
|
||||
pub fn portable(launcher: &LauncherLayout) -> Self {
|
||||
Self {
|
||||
mode: LocationMode::Portable,
|
||||
root: launcher.java_managed(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard:OS 标准应用数据目录 + `Java`。
|
||||
///
|
||||
/// 平台约定与 `platform::paths::standard_launcher_home` 同源:
|
||||
/// - Windows:`%APPDATA%\Java`(回落 `%USERPROFILE%\AppData\Roaming\Java`)
|
||||
/// - macOS:`~/Library/Application Support/Java`
|
||||
/// - Linux / 其它:`$XDG_DATA_HOME/java`(回落 `~/.local/share/java`)
|
||||
#[must_use]
|
||||
pub fn standard(platform: Platform, env: &dyn EnvSource) -> Option<Self> {
|
||||
standard_java_root(platform, env).map(|root| Self {
|
||||
mode: LocationMode::Standard,
|
||||
root,
|
||||
})
|
||||
}
|
||||
|
||||
/// Custom:`java.installRoot` 指定的目录(相对路径按数据根解析)。
|
||||
#[must_use]
|
||||
pub fn custom(root: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
mode: LocationMode::Custom,
|
||||
root: root.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 按设置解析出真正的落点。
|
||||
///
|
||||
/// 顺序:`java.installRoot`(设了就用,模式 Custom)→ Standard(数据目录 Standard 模式)
|
||||
/// → Portable(数据根内,含 `--data-dir` 的 Custom 数据模式)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 数据目录是 Standard 但拿不到系统标准目录时返回 [`JavaErrorKind::Unsupported`]
|
||||
/// (不静默落到别处——否则用户会在 `%APPDATA%\Java` 里找不到刚装的 JDK)。
|
||||
pub fn resolve(
|
||||
data_mode: DataMode,
|
||||
launcher: &LauncherLayout,
|
||||
override_root: Option<&Path>,
|
||||
platform: Platform,
|
||||
env: &dyn EnvSource,
|
||||
) -> Result<Self, JavaError> {
|
||||
if let Some(root) = override_root.filter(|root| !root.as_os_str().is_empty()) {
|
||||
let root = if root.is_absolute() {
|
||||
root.to_path_buf()
|
||||
} else {
|
||||
launcher.home().join(root)
|
||||
};
|
||||
return Ok(Self::custom(root));
|
||||
}
|
||||
if data_mode == DataMode::Standard {
|
||||
return Self::standard(platform, env).ok_or_else(|| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Unsupported,
|
||||
"Standard 模式下拿不到系统标准目录(%APPDATA% / HOME 都读不到)",
|
||||
)
|
||||
.with_hint(
|
||||
"设置 `java.installRoot` 指定一个目录,或把 `core.data_mode` 改回 portable",
|
||||
)
|
||||
});
|
||||
}
|
||||
Ok(Self::portable(launcher))
|
||||
}
|
||||
|
||||
/// 某个 JDK 的目录。
|
||||
///
|
||||
/// Standard / Custom 用 `<vendor>/<version>`(D23 用户原话的 `<发行方>\<Java版本>\`),
|
||||
/// Portable 保持 v1 的 `jdk-<major>-<vendor>`(改形状会让既有托管 JDK 变孤儿)。
|
||||
#[must_use]
|
||||
pub fn jdk_dir(&self, major: u32, vendor: JdkVendor, version: &str) -> PathBuf {
|
||||
match self.mode {
|
||||
LocationMode::Standard | LocationMode::Custom => {
|
||||
self.root.join(vendor.id()).join(sanitize_version(version))
|
||||
}
|
||||
LocationMode::Portable => self
|
||||
.root
|
||||
.join(super::probe::managed_dir_name(major, vendor.id())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 归档缓存目录(`<root>/.cache`)。
|
||||
#[must_use]
|
||||
pub fn cache_dir(&self) -> PathBuf {
|
||||
self.root.join(CACHE_DIR)
|
||||
}
|
||||
|
||||
/// 人读描述(`Standard(%APPDATA%\Java)` 这种)。
|
||||
#[must_use]
|
||||
pub fn describe(&self) -> String {
|
||||
format!("{}({})", self.mode.as_str(), self.root.display())
|
||||
}
|
||||
}
|
||||
|
||||
/// OS 标准 JDK 根(纯函数,测试注入环境)。
|
||||
///
|
||||
/// 注意 `HOME` 不用 `platform::paths::home_dir`:那个函数按**编译目标**分叉(Windows 上读
|
||||
/// `USERPROFILE`),会让「在 Windows 上断言 macOS/Linux 落点」这种测试无法成立。
|
||||
/// 这里按传入的 `platform` 参数走,语义与 `paths::standard_launcher_home` 的落点一致。
|
||||
#[must_use]
|
||||
pub fn standard_java_root(platform: Platform, env: &dyn EnvSource) -> Option<PathBuf> {
|
||||
match platform {
|
||||
Platform::Windows => roaming_app_data(env).map(|dir| dir.join(JAVA_DIR_NAME)),
|
||||
Platform::MacOs => {
|
||||
unix_home(env).map(|home| home.join("Library/Application Support").join(JAVA_DIR_NAME))
|
||||
}
|
||||
Platform::Linux | Platform::Other => env
|
||||
.var("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.filter(|dir| dir.is_absolute())
|
||||
.or_else(|| unix_home(env).map(|home| home.join(".local/share")))
|
||||
.map(|dir| dir.join("java")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Unix 家目录(`HOME`,缺了才退回 `platform::paths::home_dir`)。
|
||||
fn unix_home(env: &dyn EnvSource) -> Option<PathBuf> {
|
||||
env.var("HOME").map(PathBuf::from).or_else(|| home_dir(env))
|
||||
}
|
||||
|
||||
/// `%APPDATA%`,回落 `%USERPROFILE%\AppData\Roaming`。
|
||||
fn roaming_app_data(env: &dyn EnvSource) -> Option<PathBuf> {
|
||||
env.var("APPDATA").map(PathBuf::from).or_else(|| {
|
||||
env.var("USERPROFILE")
|
||||
.map(|profile| PathBuf::from(profile).join("AppData/Roaming"))
|
||||
})
|
||||
}
|
||||
|
||||
/// 版本号 → 目录名(只保留文件系统安全的字符)。
|
||||
///
|
||||
/// Temurin `21.0.13+9`、MS `21.0.13.0.2`、Zulu `21.0.13+3` 都原样可作目录名;
|
||||
/// 遇到 `~` / `/` 这类才替换(Zulu 的 `21.0.13+3-x86_64` 之类不会出现,但别赌)。
|
||||
#[must_use]
|
||||
pub fn sanitize_version(version: &str) -> String {
|
||||
let cleaned: String = version
|
||||
.trim()
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_' | '+') {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if cleaned.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
cleaned
|
||||
}
|
||||
}
|
||||
|
||||
/// `java list` 要扫的全部安装根(Portable 数据根在前,去重)。
|
||||
#[must_use]
|
||||
pub fn scan_roots(
|
||||
data_mode: DataMode,
|
||||
launcher: &LauncherLayout,
|
||||
override_root: Option<&Path>,
|
||||
platform: Platform,
|
||||
env: &dyn EnvSource,
|
||||
) -> Vec<PathBuf> {
|
||||
let mut roots = vec![launcher.java_managed()];
|
||||
if let Ok(resolved) =
|
||||
JavaInstallRoot::resolve(data_mode, launcher, override_root, platform, env)
|
||||
{
|
||||
if !roots.contains(&resolved.root) {
|
||||
roots.push(resolved.root);
|
||||
}
|
||||
}
|
||||
// 数据目录是 Standard 时,便携目录里的老装法仍然可能还在(用户从便携切过来的)。
|
||||
if let Some(standard) = standard_java_root(platform, env) {
|
||||
if !roots.contains(&standard) {
|
||||
roots.push(standard);
|
||||
}
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
/// 清理结果。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct CleanupReport {
|
||||
/// 删掉的路径(暂存目录、空壳目录)。
|
||||
pub removed: Vec<PathBuf>,
|
||||
/// 保留下来的条目数(都是我们没资格动的)。
|
||||
pub kept: usize,
|
||||
}
|
||||
|
||||
impl CleanupReport {
|
||||
/// 是否什么都没删。
|
||||
#[must_use]
|
||||
pub fn is_noop(&self) -> bool {
|
||||
self.removed.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 清掉安装根下的半成品与空壳。
|
||||
///
|
||||
/// 只碰两种东西:①`.staging-*`(解压中断的残留,永远可以删)②空目录(含空 vendor 目录)。
|
||||
/// 其余一律保留并计入 `kept`——**宁可不删,也不误删用户手工放进来的 JDK**。
|
||||
#[must_use]
|
||||
pub fn cleanup_orphans(root: &Path) -> CleanupReport {
|
||||
let mut report = CleanupReport::default();
|
||||
let Ok(entries) = std::fs::read_dir(root) else {
|
||||
return report;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if !path.is_dir() || name == CACHE_DIR {
|
||||
report.kept += 1;
|
||||
continue;
|
||||
}
|
||||
if name.starts_with(STAGING_PREFIX) {
|
||||
if std::fs::remove_dir_all(&path).is_ok() {
|
||||
report.removed.push(path);
|
||||
} else {
|
||||
report.kept += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if is_empty_dir(&path) {
|
||||
if std::fs::remove_dir(&path).is_ok() {
|
||||
report.removed.push(path);
|
||||
} else {
|
||||
report.kept += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// 两层的 Standard/Custom 布局:`<vendor>/<version>` 里的空目录也要收。
|
||||
if JdkVendor::parse(&name).is_some() {
|
||||
for child in std::fs::read_dir(&path).into_iter().flatten().flatten() {
|
||||
let child_path = child.path();
|
||||
if child_path.is_dir()
|
||||
&& is_empty_dir(&child_path)
|
||||
&& std::fs::remove_dir(&child_path).is_ok()
|
||||
{
|
||||
report.removed.push(child_path);
|
||||
}
|
||||
}
|
||||
if is_empty_dir(&path) && std::fs::remove_dir(&path).is_ok() {
|
||||
report.removed.push(path);
|
||||
}
|
||||
}
|
||||
report.kept += 1;
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
fn is_empty_dir(dir: &Path) -> bool {
|
||||
std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none())
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
//! MC 版本 ↔ Java 版本映射(平移 HMCL 约束体系)+ 不符检查。
|
||||
//!
|
||||
//! 依据 `DevDocs/analyse/02-launch-pipeline.md` §5.4(HMCL `GameJavaVersion.getMinimumJavaVersion`)
|
||||
//! 与 §3.1 的强制约束清单(`JavaVersionConstraint` 的 `isMandatory` 项)。两条权威叠加:
|
||||
//!
|
||||
//! | MC 版本 | 最低 | 最高 | 出处 |
|
||||
//! | --- | --- | --- | --- |
|
||||
//! | ≥ 26.1 | 25 | — | `java-runtime-epsilon` |
|
||||
//! | ≥ 1.20.5 | 21 | — | `java-runtime-delta` |
|
||||
//! | ≥ 1.18 | 17 | — | `java-runtime-beta` |
|
||||
//! | ≥ 1.17 | 16 | — | `java-runtime-alpha` |
|
||||
//! | ≥ 1.13 | 8 | — | `jre-legacy` |
|
||||
//! | < 1.13 | 8 | **8** | `LAUNCH_WRAPPER`:LaunchWrapper 的 `URLClassLoader` 只能吃 Java 8 |
|
||||
//!
|
||||
//! **版本 JSON 优先**:装了 1.17 的玩家看的是 Mojang 自己写的 `javaVersion.majorVersion`
|
||||
//! (1.17 就是 16),静态表只在清单没写时兜底——这也是 HMCL 的 `GAME_JSON` 强制项语义。
|
||||
//!
|
||||
//! 上限(`maximum`)只对老版本存在:1.12.2 用 Java 17 会直接
|
||||
//! `UnsupportedClassVersionError`-级别的失败(启动器侧是 LaunchWrapper 假设),
|
||||
//! 所以「1.12.2 + Java 21」要被判成不符,而不是「19 以上都能跑」。
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use super::super::inherit::resolve;
|
||||
use super::super::layout::GameLayout;
|
||||
use super::super::manifest::{JavaVersion, VersionJson};
|
||||
|
||||
/// 要求来源(`--json` 的 `requirement_source`)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RequirementSource {
|
||||
/// 版本 JSON 的 `javaVersion`(含继承链解析)。
|
||||
Manifest,
|
||||
/// 静态表(HMCL 约束体系的平移)。
|
||||
Table,
|
||||
}
|
||||
|
||||
impl RequirementSource {
|
||||
/// 稳定字符串 id。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Manifest => "manifest",
|
||||
Self::Table => "table",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个版本对 Java 的要求(闭区间)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct JavaRequirement {
|
||||
/// 最低主版本号(`21`)。
|
||||
pub minimum: u32,
|
||||
/// 最高主版本号(`Some(8)` = 只能用 8 及以下)。
|
||||
pub maximum: Option<u32>,
|
||||
/// 来源。
|
||||
pub source: RequirementSource,
|
||||
}
|
||||
|
||||
impl JavaRequirement {
|
||||
/// 构造。
|
||||
#[must_use]
|
||||
pub const fn new(minimum: u32, maximum: Option<u32>, source: RequirementSource) -> Self {
|
||||
Self {
|
||||
minimum,
|
||||
maximum,
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
/// 静态表来源的构造。
|
||||
#[must_use]
|
||||
pub const fn table(minimum: u32, maximum: Option<u32>) -> Self {
|
||||
Self::new(minimum, maximum, RequirementSource::Table)
|
||||
}
|
||||
|
||||
/// 某主版本号是否满足要求。
|
||||
#[must_use]
|
||||
pub fn satisfied_by(&self, major: u32) -> bool {
|
||||
major >= self.minimum && self.maximum.is_none_or(|maximum| major <= maximum)
|
||||
}
|
||||
|
||||
/// 人读描述(`Java 21+` / `Java 8` / `Java 8–16`)。
|
||||
#[must_use]
|
||||
pub fn describe(&self) -> String {
|
||||
match self.maximum {
|
||||
Some(maximum) if maximum == self.minimum => format!("Java {}", self.minimum),
|
||||
Some(maximum) => format!("Java {}–{}", self.minimum, maximum),
|
||||
None => format!("Java {}+", self.minimum),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 不符的两种形态(D13:只提示,不阻断)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum JavaMismatch {
|
||||
/// Java 太低。
|
||||
TooOld {
|
||||
/// 需要的最低主版本。
|
||||
required: u32,
|
||||
/// 实际主版本。
|
||||
actual: u32,
|
||||
},
|
||||
/// Java 太高(老版本只能用 8)。
|
||||
TooNew {
|
||||
/// 允许的最高主版本。
|
||||
maximum: u32,
|
||||
/// 实际主版本。
|
||||
actual: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl JavaMismatch {
|
||||
/// 稳定字符串 id(`--json` 的 `mismatch.code`)。
|
||||
#[must_use]
|
||||
pub const fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::TooOld { .. } => "java_too_old",
|
||||
Self::TooNew { .. } => "java_too_new",
|
||||
}
|
||||
}
|
||||
|
||||
/// 人读说明(GUI 弹窗 P1 后直接复用这句)。
|
||||
#[must_use]
|
||||
pub fn message(self) -> String {
|
||||
match self {
|
||||
Self::TooOld { required, actual } => format!(
|
||||
"该版本需要 Java {required} 及以上,当前用的是 Java {actual}(大概率起不来)"
|
||||
),
|
||||
Self::TooNew { maximum, actual } => format!(
|
||||
"该版本只能用 Java {maximum} 及以下,当前用的是 Java {actual}(老加载器吃不了新 class 文件)"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 判需求与实际是否相符(`None` = 相符)。
|
||||
#[must_use]
|
||||
pub fn check(actual: u32, required: &JavaRequirement) -> Option<JavaMismatch> {
|
||||
if actual < required.minimum {
|
||||
return Some(JavaMismatch::TooOld {
|
||||
required: required.minimum,
|
||||
actual,
|
||||
});
|
||||
}
|
||||
if let Some(maximum) = required.maximum {
|
||||
if actual > maximum {
|
||||
return Some(JavaMismatch::TooNew { maximum, actual });
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 静态表:MC 版本 → Java 要求。
|
||||
///
|
||||
/// 无法解析的版本(周快照 `20w14a`、魔改 id)只给下限 `8`、不设上限——
|
||||
/// 宁可漏判一次,也不要把「Java 21 + 1.20 快照」误锁成「必须降到 8」。
|
||||
#[must_use]
|
||||
pub fn requirement_for(mc_version: &str) -> JavaRequirement {
|
||||
let parts = version_parts(mc_version);
|
||||
if parts.is_empty() {
|
||||
return JavaRequirement::table(8, None);
|
||||
}
|
||||
if at_least(&parts, &[26, 1]) {
|
||||
return JavaRequirement::table(25, None);
|
||||
}
|
||||
if at_least(&parts, &[1, 20, 5]) {
|
||||
return JavaRequirement::table(21, None);
|
||||
}
|
||||
if at_least(&parts, &[1, 18]) {
|
||||
return JavaRequirement::table(17, None);
|
||||
}
|
||||
if at_least(&parts, &[1, 17]) {
|
||||
return JavaRequirement::table(16, None);
|
||||
}
|
||||
if at_least(&parts, &[1, 13]) {
|
||||
return JavaRequirement::table(8, None);
|
||||
}
|
||||
JavaRequirement::table(8, Some(8))
|
||||
}
|
||||
|
||||
/// 静态表的最低位(`recommended_major("1.20.5") == 21`)。
|
||||
#[must_use]
|
||||
pub fn recommended_major(mc_version: &str) -> u32 {
|
||||
requirement_for(mc_version).minimum
|
||||
}
|
||||
|
||||
/// 版本 JSON 的 `javaVersion` → 要求(字段缺失或为 0 时返回 `None`)。
|
||||
///
|
||||
/// Mojang 这个字段给的是**最低**要求(`majorVersion: 21` 表示 21 起跳),
|
||||
/// 不声明上限——所以这里 `maximum` 恒为 `None`;上限由 [`requirement_for`] 补
|
||||
/// (见 [`requirement_for_version`] 的合成说明)。
|
||||
#[must_use]
|
||||
pub fn requirement_from_manifest(java: &JavaVersion) -> Option<JavaRequirement> {
|
||||
let major = java.major_version.filter(|major| *major > 0)?;
|
||||
Some(JavaRequirement::new(
|
||||
major,
|
||||
None,
|
||||
RequirementSource::Manifest,
|
||||
))
|
||||
}
|
||||
|
||||
/// 已安装版本的 Java 要求:解析继承链 → 版本 JSON 的 `javaVersion` 优先 → 静态表兜底。
|
||||
///
|
||||
/// **下限与上限来源不同**:Mojang 的 `javaVersion` 只声明「最低要多少」,
|
||||
/// 而「1.12.2 及更早吃不了 Java 9+」这条知识来自 HMCL 的 `LAUNCH_WRAPPER` 约束,
|
||||
/// 不在清单里。所以合成时下限取清单、上限取静态表——否则「1.12.2 + Java 21」
|
||||
/// 会被判成完全合法(真机上必崩)。
|
||||
///
|
||||
/// 版本 JSON 读不到(未安装 / 损坏)时直接走静态表,不报错——这是「启动前提示」的路径,
|
||||
/// 不该因为清单缺件就拦住用户(真正拦人的是 `snapshot::prepare`)。
|
||||
#[must_use]
|
||||
pub fn requirement_for_version(layout: &GameLayout, version_id: &str) -> JavaRequirement {
|
||||
let table = requirement_for(version_id);
|
||||
let read = |id: &str| -> Option<VersionJson> {
|
||||
let text = std::fs::read_to_string(layout.version_json(id)).ok()?;
|
||||
VersionJson::from_json_str(&text).ok()
|
||||
};
|
||||
let Some(manifest) = read(version_id) else {
|
||||
return table;
|
||||
};
|
||||
let resolved = resolve(&read, &manifest).unwrap_or(manifest);
|
||||
let Some(declared) = resolved
|
||||
.java_version
|
||||
.as_ref()
|
||||
.and_then(requirement_from_manifest)
|
||||
else {
|
||||
// 清单没写 javaVersion:按 id 的静态表(继承来的 id 可能是魔改名)。
|
||||
return match resolved.id.as_deref() {
|
||||
Some(id) => requirement_for(id),
|
||||
None => table,
|
||||
};
|
||||
};
|
||||
JavaRequirement {
|
||||
minimum: declared.minimum,
|
||||
maximum: table.maximum,
|
||||
source: RequirementSource::Manifest,
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本串 → 数字分量(`1.20.5-pre1` → `[1, 20, 5]`;周快照 → 空)。
|
||||
fn version_parts(raw: &str) -> Vec<u32> {
|
||||
let trimmed = raw.trim();
|
||||
if is_weekly_snapshot(trimmed) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut parts = Vec::new();
|
||||
for piece in trimmed.split('.') {
|
||||
let digits: String = piece.chars().take_while(char::is_ascii_digit).collect();
|
||||
match digits.parse::<u32>() {
|
||||
Ok(value) => parts.push(value),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
/// `20w14a` / `26w14a` 形态(与 `launch::game_version` 同一套判据)。
|
||||
fn is_weekly_snapshot(raw: &str) -> bool {
|
||||
let bytes = raw.as_bytes();
|
||||
bytes.len() == 6
|
||||
&& bytes[..2].iter().all(u8::is_ascii_digit)
|
||||
&& bytes[2].eq_ignore_ascii_case(&b'w')
|
||||
&& bytes[3..5].iter().all(u8::is_ascii_digit)
|
||||
&& bytes[5].is_ascii_alphabetic()
|
||||
}
|
||||
|
||||
/// `parts >= floor`(缺位按 0:`1.20` < `1.20.5`)。
|
||||
fn at_least(parts: &[u32], floor: &[u32]) -> bool {
|
||||
let len = parts.len().max(floor.len());
|
||||
for index in 0..len {
|
||||
let left = parts.get(index).copied().unwrap_or(0);
|
||||
let right = floor.get(index).copied().unwrap_or(0);
|
||||
match left.cmp(&right) {
|
||||
Ordering::Greater => return true,
|
||||
Ordering::Less => return false,
|
||||
Ordering::Equal => {}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{check, recommended_major, requirement_for, JavaMismatch, JavaRequirement};
|
||||
|
||||
#[test]
|
||||
fn table_covers_the_hmcl_boundaries() {
|
||||
let cases: &[(&str, u32, Option<u32>)] = &[
|
||||
("1.6.4", 8, Some(8)),
|
||||
("1.12.2", 8, Some(8)),
|
||||
("1.13", 8, None),
|
||||
("1.15.2", 8, None),
|
||||
("1.16.5", 8, None),
|
||||
("1.17", 16, None),
|
||||
("1.17.1", 16, None),
|
||||
("1.18", 17, None),
|
||||
("1.19.4", 17, None),
|
||||
("1.20.1", 17, None),
|
||||
("1.20.4", 17, None),
|
||||
("1.20.5", 21, None),
|
||||
("1.21.4", 21, None),
|
||||
("26.1", 25, None),
|
||||
("26.3", 25, None),
|
||||
("26.3-snapshot-1", 25, None),
|
||||
];
|
||||
for (version, minimum, maximum) in cases {
|
||||
let requirement = requirement_for(version);
|
||||
assert_eq!(
|
||||
(requirement.minimum, requirement.maximum),
|
||||
(*minimum, *maximum),
|
||||
"{version}"
|
||||
);
|
||||
assert_eq!(recommended_major(version), *minimum, "{version}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekly_snapshots_get_no_upper_bound() {
|
||||
// 20w14a 的前导数字是 20,绝不能被当成「26.x」或误锁上限。
|
||||
let requirement = requirement_for("20w14a");
|
||||
assert_eq!(requirement.minimum, 8);
|
||||
assert_eq!(requirement.maximum, None);
|
||||
assert!(requirement.satisfied_by(21));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_release_suffixes_do_not_change_the_floor() {
|
||||
assert_eq!(requirement_for("1.20.5-pre1").minimum, 21);
|
||||
assert_eq!(requirement_for("1.20.4-rc1").minimum, 17);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_reports_both_directions() {
|
||||
let modern = JavaRequirement::table(21, None);
|
||||
assert_eq!(check(21, &modern), None);
|
||||
assert_eq!(check(25, &modern), None);
|
||||
assert_eq!(
|
||||
check(17, &modern),
|
||||
Some(JavaMismatch::TooOld {
|
||||
required: 21,
|
||||
actual: 17
|
||||
})
|
||||
);
|
||||
let legacy = JavaRequirement::table(8, Some(8));
|
||||
assert_eq!(check(8, &legacy), None);
|
||||
assert_eq!(
|
||||
check(21, &legacy),
|
||||
Some(JavaMismatch::TooNew {
|
||||
maximum: 8,
|
||||
actual: 21
|
||||
})
|
||||
);
|
||||
assert_eq!(legacy.describe(), "Java 8");
|
||||
assert_eq!(modern.describe(), "Java 21+");
|
||||
assert_eq!(JavaRequirement::table(8, Some(16)).describe(), "Java 8–16");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatch_messages_name_both_sides() {
|
||||
let message = JavaMismatch::TooOld {
|
||||
required: 21,
|
||||
actual: 8,
|
||||
}
|
||||
.message();
|
||||
assert!(message.contains("Java 21"));
|
||||
assert!(message.contains("Java 8"));
|
||||
assert_eq!(
|
||||
JavaMismatch::TooNew {
|
||||
maximum: 8,
|
||||
actual: 21
|
||||
}
|
||||
.code(),
|
||||
"java_too_new"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Microsoft OpenJDK:`aka.ms` 模板下载 + 成对 `.sha256sum.txt` 校验。
|
||||
//!
|
||||
//! 分发形态与 Temurin / Oracle 都不同(research/06 §3.2):
|
||||
//!
|
||||
//! ```text
|
||||
//! 下载 https://aka.ms/download-jdk/microsoft-jdk-{version}-{os}-{arch}.zip
|
||||
//! → 301 → https://download.visualstudio.microsoft.com/download/pr/{guid}/{hash}/{同名}
|
||||
//! 校验 https://aka.ms/download-jdk/<同一文件名>.sha256sum.txt # "<hash> <filename>"
|
||||
//! ```
|
||||
//!
|
||||
//! 三条硬事实(都影响实现):
|
||||
//!
|
||||
//! 1. **版本是四段/五段**(`21.0.13.0.2` = JDK 21.0.13+2;11 线是 `11.0.32.1`),
|
||||
//! 不能拿 Temurin 的三段号直接拼——所以这里有自己的 [`parse_version`] 与内置快照表;
|
||||
//! 2. **没有 8**(微软已停发,官方页面让去下 Temurin 8),`supports()` 也这么判;
|
||||
//! 3. **GitHub 侧没有任何资产**(`microsoft/openjdk` 的 release `assets` 全空),
|
||||
//! 所以 gh-proxy 对这条链毫无用处——`domestic_mirrors` 实测为空数组。
|
||||
|
||||
use super::error::{JavaError, JavaErrorKind};
|
||||
use super::net;
|
||||
use super::release::{parse_sha256_text, JdkArchive, JdkRelease};
|
||||
use super::target::AdoptiumTarget;
|
||||
use super::vendor::JdkVendor;
|
||||
use crate::download::MsOpenJdkTable;
|
||||
|
||||
/// 内置版本快照(`learn.microsoft.com/java/openjdk/download` 2026-10-30 的表,research/06 §1.3)。
|
||||
///
|
||||
/// 官方只有人读 HTML 表、没有 JSON API,逐版本探测又要 `aka.ms` 301(0.3–0.8 s/次),
|
||||
/// 所以按 research/06 §3.3 的建议 A 内置快照;`size` 与实测字节数一致,用于长度对拍。
|
||||
pub const SNAPSHOT: &[(u32, &str, u64)] = &[
|
||||
(25, "25.0.5.0.2", 220_869_659),
|
||||
(21, "21.0.13.0.2", 201_450_315),
|
||||
(17, "17.0.21.0.2", 187_236_595),
|
||||
(11, "11.0.32.1", 191_250_397),
|
||||
];
|
||||
|
||||
/// 四段/五段版本号(`21.0.13.0.2` / `11.0.32.1`)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MsVersion {
|
||||
/// 大版本(`21`)。
|
||||
pub major: u32,
|
||||
/// 各数字段(长度 3..=5)。
|
||||
pub segments: Vec<u32>,
|
||||
}
|
||||
|
||||
impl MsVersion {
|
||||
/// 原样的版本串。
|
||||
#[must_use]
|
||||
pub fn text(&self) -> String {
|
||||
self.segments
|
||||
.iter()
|
||||
.map(u32::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(".")
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 MS 的版本串;段数必须 3..=5(MS 的语义:`major.update.patch[.build[.rev]]`)。
|
||||
#[must_use]
|
||||
pub fn parse_version(text: &str) -> Option<MsVersion> {
|
||||
let mut segments = Vec::new();
|
||||
for segment in text.trim().split('.') {
|
||||
segments.push(segment.parse::<u32>().ok()?);
|
||||
}
|
||||
if segments.len() < 3 || segments.len() > 5 {
|
||||
return None;
|
||||
}
|
||||
Some(MsVersion {
|
||||
major: segments[0],
|
||||
segments,
|
||||
})
|
||||
}
|
||||
|
||||
/// MS 的 os 段(`windows` / `macos` / `linux`)。
|
||||
#[must_use]
|
||||
pub fn ms_os(target: AdoptiumTarget) -> &'static str {
|
||||
match target.os {
|
||||
"mac" => "macos",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// MS 的 arch 段(`x64` / `aarch64`)。
|
||||
#[must_use]
|
||||
pub fn ms_arch(target: AdoptiumTarget) -> &'static str {
|
||||
target.arch
|
||||
}
|
||||
|
||||
/// 归档文件名(模板:`microsoft-jdk-{version}-{os}-{arch}.zip`,全平台都是 zip)。
|
||||
#[must_use]
|
||||
pub fn archive_name(version: &str, target: AdoptiumTarget) -> String {
|
||||
format!(
|
||||
"microsoft-jdk-{version}-{}-{}.zip",
|
||||
ms_os(target),
|
||||
ms_arch(target)
|
||||
)
|
||||
}
|
||||
|
||||
/// 把模板里的 `{version}` / `{os}` / `{arch}` 换掉。
|
||||
#[must_use]
|
||||
pub fn expand_template(template: &str, version: &str, target: AdoptiumTarget) -> String {
|
||||
template
|
||||
.replace("{version}", version)
|
||||
.replace("{os}", ms_os(target))
|
||||
.replace("{arch}", ms_arch(target))
|
||||
}
|
||||
|
||||
/// 快照里该大版本的版本串(没有 → `None`)。
|
||||
#[must_use]
|
||||
pub fn snapshot_version(major: u32) -> Option<&'static str> {
|
||||
SNAPSHOT
|
||||
.iter()
|
||||
.find(|(known, _, _)| *known == major)
|
||||
.map(|(_, version, _)| *version)
|
||||
}
|
||||
|
||||
/// 快照里该大版本的字节数。
|
||||
#[must_use]
|
||||
pub fn snapshot_size(major: u32) -> Option<u64> {
|
||||
SNAPSHOT
|
||||
.iter()
|
||||
.find(|(known, _, _)| *known == major)
|
||||
.map(|(_, _, size)| *size)
|
||||
}
|
||||
|
||||
/// 查一次「某大版本的最新 MS OpenJDK」。
|
||||
///
|
||||
/// 两跳:内置快照挑版本 → 取 `.sha256sum.txt` 拿哈希。校验文件取不到就整家失败
|
||||
/// (宁可降级到 Zulu,也不下一个没法验证的 200 MB 归档)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 表里没配模板、该大版本微软不发(含 8)、校验文件取不到或格式不对时返回错误。
|
||||
pub async fn fetch_latest(
|
||||
client: &reqwest::Client,
|
||||
table: &MsOpenJdkTable,
|
||||
major: u32,
|
||||
target: AdoptiumTarget,
|
||||
) -> Result<JdkRelease, JavaError> {
|
||||
let version = snapshot_version(major).ok_or_else(|| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!(
|
||||
"Microsoft OpenJDK 没有 {major} 的 GA(只发 {})",
|
||||
majors_text()
|
||||
),
|
||||
)
|
||||
.with_hint("8 用 temurin;其它大版本用 oracle / zulu")
|
||||
})?;
|
||||
let filename = archive_name(version, target);
|
||||
let sha_url = expand_template(&table.sha256_template, version, target);
|
||||
let sha_text = net::get_text(client, &sha_url, "Microsoft OpenJDK 校验文件").await?;
|
||||
let sha256 = parse_sha256_text(&sha_text).ok_or_else(|| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Integrity,
|
||||
format!("{filename} 的 .sha256sum.txt 里没有 64 位哈希"),
|
||||
)
|
||||
})?;
|
||||
Ok(JdkRelease {
|
||||
vendor: JdkVendor::Microsoft,
|
||||
major,
|
||||
version: version.to_string(),
|
||||
semver: None,
|
||||
release_name: version.to_string(),
|
||||
archive: JdkArchive {
|
||||
name: filename.clone(),
|
||||
url: expand_template(&table.template, version, target),
|
||||
sha256,
|
||||
size: snapshot_size(major).unwrap_or(0),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn majors_text() -> String {
|
||||
SNAPSHOT
|
||||
.iter()
|
||||
.map(|(major, _, _)| major.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
archive_name, expand_template, ms_os, parse_version, snapshot_size, snapshot_version,
|
||||
SNAPSHOT,
|
||||
};
|
||||
use crate::game::java::target::AdoptiumTarget;
|
||||
|
||||
const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64");
|
||||
|
||||
#[test]
|
||||
fn snapshot_covers_the_four_majors_microsoft_ships() {
|
||||
assert_eq!(snapshot_version(21), Some("21.0.13.0.2"));
|
||||
assert_eq!(snapshot_version(11), Some("11.0.32.1"));
|
||||
assert_eq!(snapshot_version(8), None, "微软已停发 8");
|
||||
assert_eq!(snapshot_size(17), Some(187_236_595));
|
||||
assert_eq!(
|
||||
SNAPSHOT
|
||||
.iter()
|
||||
.map(|(major, _, _)| *major)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![25, 21, 17, 11]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_and_five_segment_versions_have_their_own_semantics() {
|
||||
// 21.0.13.0.2 = JDK 21.0.13+2(五段);11 线只有四段。
|
||||
let ms = parse_version("21.0.13.0.2").expect("五段应解析");
|
||||
assert_eq!(ms.major, 21);
|
||||
assert_eq!(ms.segments.len(), 5);
|
||||
assert_eq!(ms.text(), "21.0.13.0.2");
|
||||
let eleven = parse_version("11.0.32.1").expect("四段应解析");
|
||||
assert_eq!(eleven.major, 11);
|
||||
assert_eq!(eleven.segments.len(), 4);
|
||||
// Temurin 的三段号形态在这里同样成立,但语义由快照表定义,不共用 Temurin 的解析。
|
||||
assert_eq!(
|
||||
parse_version("17.0.21").map(|version| version.major),
|
||||
Some(17)
|
||||
);
|
||||
assert_eq!(parse_version("21"), None);
|
||||
assert_eq!(parse_version("21.0"), None);
|
||||
assert_eq!(parse_version("21.0.13.0.2.1"), None);
|
||||
assert_eq!(parse_version("21.0.x.2"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_names_and_templates_expand_per_platform() {
|
||||
assert_eq!(
|
||||
archive_name("21.0.13.0.2", WINDOWS),
|
||||
"microsoft-jdk-21.0.13.0.2-windows-x64.zip"
|
||||
);
|
||||
let template = "https://aka.ms/download-jdk/microsoft-jdk-{version}-{os}-{arch}.zip";
|
||||
assert_eq!(
|
||||
expand_template(template, "17.0.21.0.2", WINDOWS),
|
||||
"https://aka.ms/download-jdk/microsoft-jdk-17.0.21.0.2-windows-x64.zip"
|
||||
);
|
||||
assert_eq!(
|
||||
expand_template(
|
||||
"https://aka.ms/download-jdk/microsoft-jdk-{version}-{os}-{arch}.zip.sha256sum.txt",
|
||||
"21.0.13.0.2",
|
||||
AdoptiumTarget::new("linux", "aarch64")
|
||||
),
|
||||
"https://aka.ms/download-jdk/microsoft-jdk-21.0.13.0.2-linux-aarch64.zip.sha256sum.txt"
|
||||
);
|
||||
assert_eq!(ms_os(AdoptiumTarget::new("mac", "x64")), "macos");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_table_uses_the_official_templates() {
|
||||
let table = crate::download::MirrorTable::builtin();
|
||||
let microsoft = table.microsoft_openjdk().expect("内置表应带 MS 分发");
|
||||
assert!(microsoft
|
||||
.template
|
||||
.starts_with("https://aka.ms/download-jdk/"));
|
||||
assert!(microsoft.sha256_template.ends_with(".sha256sum.txt"));
|
||||
assert!(microsoft.domestic_mirrors.is_empty(), "MS 无国内镜像");
|
||||
assert_eq!(microsoft.majors, vec![25, 21, 17, 11]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Java 运行时管理(D13 v1 → D23 v2):托管 JDK 的查询 / 下载 / 解压 / 探测,以及 MC↔Java 映射。
|
||||
//!
|
||||
//! ```text
|
||||
//! target 宿主平台 → Adoptium 的 os/arch(Win=zip、Linux/mac=tar.gz)
|
||||
//! vendor 四家发行版与用户可调的优先级链(国内 Temurin→Oracle→MS / 国外 Temurin→MS→Oracle)
|
||||
//! api Temurin:api.adoptium.net 查询最新 GA → (github 直链, sha256, size)
|
||||
//! oracle Oracle OpenJDK:镜像目录页解析(hash 段不可推导,禁止拼 URL)+ .sha256
|
||||
//! microsoft MS OpenJDK:aka.ms 模板 + 内置四段版本快照 + .sha256sum.txt
|
||||
//! zulu Azul Zulu:两跳 JSON API(列表 → 详情)+ CDN 直链(第四备选)
|
||||
//! source 发行版分派 + 回退链记录(命中与降级原因进事件流)
|
||||
//! location 落点三模式(Portable / Standard / Custom)+ 孤儿目录清理
|
||||
//! release 一次版本查询的统一产物(vendor 原生版本串 + 归档 + sha256 + 大小)
|
||||
//! net 版本查询用的极小 GET 文本工具(下载不走这里)
|
||||
//! install 编排:回退链 → 下载引擎(镜像/健康度/退避)→ 解压 → 探测验证
|
||||
//! extract zip / tar.gz 解压 + 顶层目录剥离 + 幂等戳记 + zip-slip 防护
|
||||
//! probe java -version 探测、托管目录扫描(两种布局都认)
|
||||
//! discover 标准安装位置扫描(Program Files 系 / 官方启动器 runtime / 注册表 / /usr/lib/jvm)+ 去重
|
||||
//! select 启动选 Java 的**纯**优先链(满足要求优先,再按来源)
|
||||
//! mapping MC↔Java 静态表(HMCL `GameJavaVersion`)+ 版本 JSON 优先 + 不符检查
|
||||
//! ```
|
||||
//!
|
||||
//! # 三条边界
|
||||
//!
|
||||
//! 1. **不碰网络细节**:包下载一律交 `core::download` 的引擎,本模块不写重试循环
|
||||
//! (`MirrorTable::expand` 负责 Temurin 的镜像展开;Oracle 的目录解析在 `oracle`);
|
||||
//! 2. **不弹窗**:不符检查只产出数据([`JavaMismatch`]),提示交给 CLI 的告警行与 GUI;
|
||||
//! 3. **不改启动策略**:「用哪个 Java 启动」是启动期的派生结果,不落盘;落盘的是**装到哪**
|
||||
//! ([`JavaInstallRoot`],由 `java.installRoot` + 数据目录模式决定)。
|
||||
//!
|
||||
//! # 托管布局(D23 后两种并存)
|
||||
//!
|
||||
//! ```text
|
||||
//! Portable:data/java-managed/
|
||||
//! ├── .cache/OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip 归档缓存(可删)
|
||||
//! └── jdk-21-temurin/ 一个 major+vendor 一份(永不改)
|
||||
//! ├── .ull-jdk.json 戳记(幂等判据)
|
||||
//! └── bin/java[.exe]
|
||||
//!
|
||||
//! Standard / Custom:%APPDATA%\Java\<vendor>\<version>\ (mac/Linux 见 location 模块)
|
||||
//! ```
|
||||
|
||||
pub mod api;
|
||||
pub mod discover;
|
||||
pub mod error;
|
||||
pub mod extract;
|
||||
pub mod install;
|
||||
pub mod location;
|
||||
pub mod mapping;
|
||||
pub mod microsoft;
|
||||
pub mod net;
|
||||
pub mod oracle;
|
||||
pub mod probe;
|
||||
pub mod release;
|
||||
pub mod select;
|
||||
pub mod source;
|
||||
pub mod target;
|
||||
pub mod vendor;
|
||||
pub mod zulu;
|
||||
|
||||
pub use self::api::{
|
||||
latest_url, parse_latest, JdkArchive, JdkRelease, ADOPTIUM_API_BASE, JDK_VENDOR,
|
||||
};
|
||||
pub use self::discover::{
|
||||
candidates, candidates_in_roots, scan_java_binaries, standard_candidates, standard_roots,
|
||||
};
|
||||
pub use self::error::{JavaError, JavaErrorKind};
|
||||
pub use self::extract::{
|
||||
extract_archive, is_ready, java_binary, java_binary_name, read_stamp, ExtractReport, JdkStamp,
|
||||
STAMP_FILE,
|
||||
};
|
||||
pub use self::install::{
|
||||
archive_path, archive_request, chain_order, install, managed_root_dir, plan, InstallRequest,
|
||||
InstalledJdk,
|
||||
};
|
||||
pub use self::location::{
|
||||
cleanup_orphans, sanitize_version, scan_roots, CleanupReport, JavaInstallRoot, LocationMode,
|
||||
};
|
||||
pub use self::mapping::{
|
||||
check, recommended_major, requirement_for, requirement_for_version, JavaMismatch,
|
||||
JavaRequirement, RequirementSource,
|
||||
};
|
||||
pub use self::probe::{
|
||||
discover_managed, discover_managed_roots, managed_dir_name, parse_managed_dir, probe,
|
||||
resolve_on_path, system_candidates, JavaOrigin, JavaRuntime, DEFAULT_MAJOR,
|
||||
};
|
||||
pub use self::release::parse_sha256_text;
|
||||
pub use self::select::{pick, resolve, JavaResolution};
|
||||
pub use self::source::{ChainLog, VendorAttempt};
|
||||
pub use self::target::AdoptiumTarget;
|
||||
pub use self::vendor::{order as vendor_order, JdkVendor, VENDOR_IDS};
|
||||
@@ -0,0 +1,60 @@
|
||||
//! 版本查询用的极小 HTTP 取文本工具(四家发行版的「一锤子 GET」共用)。
|
||||
//!
|
||||
//! 只有一件事:GET → 状态分类 → 整段文本。**下载不走这里**(那归 `core::download` 引擎),
|
||||
//! 所以没有重试、没有镜像展开、没有进度事件——查清单失败就直接把错误抛给发行版回退链,
|
||||
//! 让它换下一家(这是 D23「vendor 级失败自动降下一 vendor」的最短路径)。
|
||||
|
||||
use super::error::{JavaError, JavaErrorKind};
|
||||
|
||||
/// 取一段文本(`what` 是给用户看的名词,例如「华为云 openjdk 目录」)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 连不上 / 超时 → [`JavaErrorKind::Network`];404 或 410 → [`JavaErrorKind::Metadata`]
|
||||
/// (「这个东西上游没有」比「网络坏了」更准确,回退链据此换 vendor 而不是重试同一条)。
|
||||
pub async fn get_text(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
what: &str,
|
||||
) -> Result<String, JavaError> {
|
||||
let response = client.get(url).send().await.map_err(|error| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Network,
|
||||
format!("取 {what} 失败:{error}({url})"),
|
||||
)
|
||||
})?;
|
||||
let status = response.status();
|
||||
if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::GONE {
|
||||
return Err(JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!("{what} 不存在(HTTP {status}):{url}"),
|
||||
));
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(JavaError::new(
|
||||
JavaErrorKind::Network,
|
||||
format!("{what} 返回 HTTP {status}:{url}"),
|
||||
));
|
||||
}
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let body = response.text().await.map_err(|error| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Network,
|
||||
format!("读 {what} 响应失败:{error}"),
|
||||
)
|
||||
})?;
|
||||
// 门户兜底页会把「路径不存在」伪装成 200 + HTML;版本清单页面本身可能也是 HTML,
|
||||
// 所以这里不做内容判定,只看调用方能不能解析出来(解析失败会给出更准的原因)。
|
||||
if body.trim().is_empty() {
|
||||
return Err(JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!("{what} 返回空响应(Content-Type: {content_type}):{url}"),
|
||||
));
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
//! Oracle OpenJDK(`download.java.net` GA):**枚举与下载都走国内镜像目录页**。
|
||||
//!
|
||||
//! 为什么不能像 Temurin 那样「先拿官方链接、再反解镜像路径」:Oracle 的直链里有一整段
|
||||
//! 构建哈希(`/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip`),
|
||||
//! 那段**不可推导**(research/06 §4.1 实测)。于是本模块的纪律是:
|
||||
//!
|
||||
//! > 候选 URL 的 `{ver}` 段与文件名段都必须来自对镜像目录页的解析(`parse_links`),
|
||||
//! > 代码里没有任何一处把哈希段拼出来。
|
||||
//!
|
||||
//! 目录形状(华为云 / 南大与官方完全同构,§4.2):
|
||||
//!
|
||||
//! ```text
|
||||
//! {base}/ → Apache 列表页:21/ 21.0.1/ 21.0.2/ 27.0.1.0.3/ …
|
||||
//! {base}/{ver}/ → 文件列表:openjdk-21.0.2_windows-x64_bin.zip …
|
||||
//! {base}/{ver}/{filename} → 归档本体(大小与官方逐字节一致)
|
||||
//! {base}/{ver}/{filename}.sha256 → 64 位哈希纯文本(成对提供)
|
||||
//! ```
|
||||
//!
|
||||
//! 三处实测坑(都写进了实现):
|
||||
//!
|
||||
//! 1. **华为云对任意不存在的路径都回 200 + 12 434 B 门户 HTML**(假阳性)——所以本模块只信
|
||||
//! 「目录页里真的出现了这个文件名」,不信状态码;下载侧另有 `download::http::html_mirror_reason`
|
||||
//! 兜一层(Content-Type 非 HTML + Content-Length 对拍)。
|
||||
//! 2. **只发「首个 GA + ≤2 个 update」**:21 线最高只到 21.0.2,别当补丁源用。
|
||||
//! 3. **目录页给的大小是四舍五入的**(`192 MB`)→ 不可当 `expected_size`,本模块一律填 0,
|
||||
//! 校验交给成对的 `.sha256`。
|
||||
|
||||
use super::error::{JavaError, JavaErrorKind};
|
||||
use super::net;
|
||||
use super::release::{parse_sha256_text, JdkArchive, JdkRelease};
|
||||
use super::target::AdoptiumTarget;
|
||||
use super::vendor::JdkVendor;
|
||||
use crate::download::MirrorTable;
|
||||
|
||||
/// Oracle 的 os 段(Adoptium 的 `mac` 在 Oracle 里叫 `macos`)。
|
||||
#[must_use]
|
||||
pub fn oracle_os(target: AdoptiumTarget) -> &'static str {
|
||||
match target.os {
|
||||
"mac" => "macos",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Oracle 的 arch 段(`x86-32` → `x86`)。
|
||||
#[must_use]
|
||||
pub fn oracle_arch(target: AdoptiumTarget) -> &'static str {
|
||||
match target.arch {
|
||||
"x86-32" => "x86",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// 目录页 → 本级条目名列表(去掉尾部 `/`、跳过 `../` 与绝对链接,去重保序)。
|
||||
#[must_use]
|
||||
pub fn parse_links(html: &str) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut rest = html;
|
||||
while let Some(start) = rest.find("href=\"") {
|
||||
let after = &rest[start + "href=\"".len()..];
|
||||
let Some(end) = after.find('"') else {
|
||||
break;
|
||||
};
|
||||
let value = after[..end].trim();
|
||||
rest = &after[end..];
|
||||
if value.is_empty()
|
||||
|| value.starts_with("http")
|
||||
|| value.starts_with("//")
|
||||
|| value.starts_with('?')
|
||||
|| value.contains('#')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let name = value.trim_end_matches('/');
|
||||
let Some(name) = name.rsplit('/').next() else {
|
||||
continue;
|
||||
};
|
||||
if name.is_empty() || name == "." || name == ".." {
|
||||
continue;
|
||||
}
|
||||
if !out.iter().any(|known| known == name) {
|
||||
out.push(name.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 版本串 → 可比较的数字段(`21.0.2` → `[21, 0, 2]`,`27.0.1.0.3` → `[27,0,1,0,3]`)。
|
||||
#[must_use]
|
||||
pub fn version_key(version: &str) -> Option<Vec<u64>> {
|
||||
let mut parts = Vec::new();
|
||||
for segment in version.split('.') {
|
||||
parts.push(segment.parse::<u64>().ok()?);
|
||||
}
|
||||
(!parts.is_empty()).then_some(parts)
|
||||
}
|
||||
|
||||
/// 目录页里属于 `major` 的版本串,按版本**降序**(最新在前)。
|
||||
#[must_use]
|
||||
pub fn versions_for(html: &str, major: u32) -> Vec<String> {
|
||||
let mut found: Vec<(Vec<u64>, String)> = parse_links(html)
|
||||
.into_iter()
|
||||
.filter_map(|name| {
|
||||
let key = version_key(&name)?;
|
||||
(key.first() == Some(&u64::from(major))).then_some((key, name))
|
||||
})
|
||||
.collect();
|
||||
found.sort_by(|left, right| right.0.cmp(&left.0));
|
||||
found.into_iter().map(|(_, name)| name).collect()
|
||||
}
|
||||
|
||||
/// 取 `major` 线的最新 GA 版本串。
|
||||
#[must_use]
|
||||
pub fn pick_version(html: &str, major: u32) -> Option<String> {
|
||||
versions_for(html, major).into_iter().next()
|
||||
}
|
||||
|
||||
/// 期望文件名(Oracle 的命名是**模板**,哈希只在目录层级上,不在文件名里)。
|
||||
#[must_use]
|
||||
pub fn archive_name(version: &str, target: AdoptiumTarget) -> String {
|
||||
format!(
|
||||
"openjdk-{version}_{}-{}_bin.{}",
|
||||
oracle_os(target),
|
||||
oracle_arch(target),
|
||||
target.archive_ext()
|
||||
)
|
||||
}
|
||||
|
||||
/// `{base}/{ver}/{filename}`。
|
||||
#[must_use]
|
||||
pub fn archive_url(base: &str, version: &str, filename: &str) -> String {
|
||||
format!("{}/{version}/{filename}", base.trim_end_matches('/'))
|
||||
}
|
||||
|
||||
/// 归档旁的 `.sha256` 地址。
|
||||
#[must_use]
|
||||
pub fn checksum_url(base: &str, version: &str, filename: &str) -> String {
|
||||
format!("{}.sha256", archive_url(base, version, filename))
|
||||
}
|
||||
|
||||
/// 目录页里是否有这个文件(**唯一**的「存在」判据:不信状态码)。
|
||||
#[must_use]
|
||||
pub fn find_archive(html: &str, filename: &str) -> bool {
|
||||
parse_links(html).iter().any(|name| name == filename)
|
||||
}
|
||||
|
||||
/// 查一次「某大版本的最新 Oracle OpenJDK GA」。
|
||||
///
|
||||
/// 按 `mirrors.json` 里 `oracle_openjdk` 的启用顺序逐个镜像试(国内:华为云 → 南大),
|
||||
/// 每个镜像三跳:目录页 → 版本目录页 → `.sha256`。任何一跳失败就记下原因换下一个镜像。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 所有镜像都失败(没有该大版本 / 目录页不可解析 / 缺 sha256)时返回错误,
|
||||
/// 由回退链决定降级到下一家发行版。
|
||||
pub async fn fetch_latest(
|
||||
client: &reqwest::Client,
|
||||
table: &MirrorTable,
|
||||
major: u32,
|
||||
target: AdoptiumTarget,
|
||||
) -> Result<JdkRelease, JavaError> {
|
||||
let mirrors: Vec<_> = table.oracle_mirrors().collect();
|
||||
if mirrors.is_empty() {
|
||||
return Err(JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
"镜像清单里没有启用的 oracle_openjdk 源",
|
||||
)
|
||||
.with_hint("`mirrors.json` 的 `oracle_openjdk` 表需要至少一个 enabled 条目"));
|
||||
}
|
||||
let mut reasons: Vec<String> = Vec::new();
|
||||
for mirror in mirrors {
|
||||
match fetch_from(client, mirror.base(), major, target).await {
|
||||
Ok(release) => return Ok(release),
|
||||
Err(error) => reasons.push(format!("{}:{}", mirror.name, error.message())),
|
||||
}
|
||||
}
|
||||
Err(JavaError::new(
|
||||
JavaErrorKind::Network,
|
||||
format!(
|
||||
"Oracle OpenJDK {major} 在所有镜像上都取不到:{}",
|
||||
reasons.join(";")
|
||||
),
|
||||
)
|
||||
.with_hint("Oracle 的 21 线只到 21.0.2(只发首个 GA + ≤2 update);换 17/25 或改用 temurin"))
|
||||
}
|
||||
|
||||
/// 单个镜像的完整三跳查询。
|
||||
async fn fetch_from(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
major: u32,
|
||||
target: AdoptiumTarget,
|
||||
) -> Result<JdkRelease, JavaError> {
|
||||
let index = net::get_text(
|
||||
client,
|
||||
&format!("{}/", base.trim_end_matches('/')),
|
||||
"Oracle 镜像根目录",
|
||||
)
|
||||
.await?;
|
||||
let version = pick_version(&index, major).ok_or_else(|| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!(
|
||||
"镜像目录里没有 {major} 线的 Oracle OpenJDK(目录页 {} 项)",
|
||||
parse_links(&index).len()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let filename = archive_name(&version, target);
|
||||
let listing = net::get_text(
|
||||
client,
|
||||
&format!("{}/{version}/", base.trim_end_matches('/')),
|
||||
"Oracle 版本目录",
|
||||
)
|
||||
.await?;
|
||||
if !find_archive(&listing, &filename) {
|
||||
return Err(JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!("{version} 目录里没有 {filename}(该平台没有产物,或目录页不可解析)"),
|
||||
));
|
||||
}
|
||||
let sha_text = net::get_text(
|
||||
client,
|
||||
&checksum_url(base, &version, &filename),
|
||||
"Oracle 归档 sha256",
|
||||
)
|
||||
.await?;
|
||||
let sha256 = parse_sha256_text(&sha_text).ok_or_else(|| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Integrity,
|
||||
format!("{filename} 的 .sha256 不是 64 位十六进制"),
|
||||
)
|
||||
})?;
|
||||
Ok(JdkRelease {
|
||||
vendor: JdkVendor::Oracle,
|
||||
major,
|
||||
version: version.clone(),
|
||||
semver: None,
|
||||
release_name: version.clone(),
|
||||
archive: JdkArchive {
|
||||
name: filename.clone(),
|
||||
url: archive_url(base, &version, &filename),
|
||||
sha256,
|
||||
// 目录页给的是四舍五入的大小(192 MB),不能当 expected_size。
|
||||
size: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
archive_name, archive_url, checksum_url, find_archive, oracle_os, parse_links,
|
||||
pick_version, version_key, versions_for,
|
||||
};
|
||||
use crate::game::java::target::AdoptiumTarget;
|
||||
|
||||
/// 华为云 `/openjdk/` 的真实目录页片段(2026-10-30 抓取,`<pre>` 列表)。
|
||||
const INDEX: &str = r#"<html><body><pre>Name Last modified Size</pre><hr><pre>
|
||||
<a href="10/" onclick="handleLinkUrl(this)">10/</a> 22-Aug-2021 15:18 -
|
||||
<a href="17.0.2/" onclick="handleLinkUrl(this)">17.0.2/</a> 22-Aug-2021 15:19 -
|
||||
<a href="21/" onclick="handleLinkUrl(this)">21/</a> 22-Aug-2021 15:19 -
|
||||
<a href="21.0.1/" onclick="handleLinkUrl(this)">21.0.1/</a> 22-Aug-2021 15:19 -
|
||||
<a href="21.0.2/" onclick="handleLinkUrl(this)">21.0.2/</a> 06-Jan-2024 15:26 -
|
||||
<a href="27.0.1.0.3/" onclick="handleLinkUrl(this)">27.0.1.0.3/</a> 06-Jan-2026 15:26 -
|
||||
<a href="../" onclick="handleLinkUrl(this)">../</a>
|
||||
</pre></body></html>"#;
|
||||
|
||||
/// 华为云 `/openjdk/21.0.2/` 的真实文件列表片段。
|
||||
const LISTING: &str = r#"<html><body><pre><a href="../">../</a>
|
||||
<a href="openjdk-21.0.2_linux-x64_bin.tar.gz" onclick="handleLinkUrl(this)">openjdk-21.0.2_linux-x64_bin.tar.gz</a> 194.18 MB
|
||||
<a href="openjdk-21.0.2_windows-x64_bin.zip" onclick="handleLinkUrl(this)">openjdk-21.0.2_windows-x64_bin.zip</a> 192 MB
|
||||
</pre></body></html>"#;
|
||||
|
||||
const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64");
|
||||
|
||||
#[test]
|
||||
fn directory_links_are_scraped_without_any_hash_segment() {
|
||||
let links = parse_links(INDEX);
|
||||
assert!(links.contains(&"21.0.2".to_string()));
|
||||
assert!(!links.iter().any(|name| name == ".."));
|
||||
// Oracle 直链里的构建哈希(f2283984656d49d69e91c558476027ac)在镜像目录里根本不存在:
|
||||
// 目录层级只有 `{ver}/`,所以实现里没有任何拼哈希的位置。
|
||||
assert!(!links.iter().any(|name| name.len() == 32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newest_version_of_a_line_wins_and_ordering_is_numeric() {
|
||||
assert_eq!(
|
||||
versions_for(INDEX, 21),
|
||||
vec!["21.0.2".to_string(), "21.0.1".to_string(), "21".to_string()]
|
||||
);
|
||||
assert_eq!(pick_version(INDEX, 21).as_deref(), Some("21.0.2"));
|
||||
// 27.0.1.0.3 是四段+,比 27.0.1 新(字符串比较会得出相反结论)。
|
||||
assert_eq!(version_key("27.0.1.0.3"), Some(vec![27, 0, 1, 0, 3]));
|
||||
assert_eq!(version_key("27.0.1"), Some(vec![27, 0, 1]));
|
||||
assert!(version_key("27.0.1") < version_key("27.0.1.0.3"));
|
||||
assert_eq!(pick_version(INDEX, 8), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filename_template_matches_the_archive_on_every_platform() {
|
||||
assert_eq!(
|
||||
archive_name("21.0.2", WINDOWS),
|
||||
"openjdk-21.0.2_windows-x64_bin.zip"
|
||||
);
|
||||
assert_eq!(
|
||||
archive_name("21.0.2", AdoptiumTarget::new("linux", "aarch64")),
|
||||
"openjdk-21.0.2_linux-aarch64_bin.tar.gz"
|
||||
);
|
||||
assert_eq!(
|
||||
archive_name("17.0.2", AdoptiumTarget::new("mac", "x64")),
|
||||
"openjdk-17.0.2_macos-x64_bin.tar.gz"
|
||||
);
|
||||
assert_eq!(oracle_os(AdoptiumTarget::new("mac", "x64")), "macos");
|
||||
assert_eq!(oracle_os(WINDOWS), "windows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_is_only_published_when_it_really_appears_in_the_listing() {
|
||||
let filename = archive_name("21.0.2", WINDOWS);
|
||||
assert!(find_archive(LISTING, &filename));
|
||||
// 华为云的门户兜底页(任意路径 200 + HTML)里没有这个链接 → 必须判为不存在。
|
||||
assert!(!find_archive(
|
||||
"<html><body>mirrors 门户</body></html>",
|
||||
&filename
|
||||
));
|
||||
assert_eq!(
|
||||
archive_url(
|
||||
"https://mirrors.huaweicloud.com/openjdk/",
|
||||
"21.0.2",
|
||||
&filename
|
||||
),
|
||||
format!("https://mirrors.huaweicloud.com/openjdk/21.0.2/{filename}")
|
||||
);
|
||||
assert_eq!(
|
||||
checksum_url(
|
||||
"https://mirrors.huaweicloud.com/openjdk",
|
||||
"21.0.2",
|
||||
&filename
|
||||
),
|
||||
format!("https://mirrors.huaweicloud.com/openjdk/21.0.2/{filename}.sha256")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
//! Java 运行时探测:跑一次 `java -version`、扫托管目录、列系统候选。
|
||||
//!
|
||||
//! 探测动作只有两种:读托管目录(`.ULL/data/java-managed/jdk-<major>-<vendor>/`)
|
||||
//! 与跑 `java -version`(输出在 stderr,主版本号从引号里的版本号解析,
|
||||
//! `1.8.0_402` 这种老形态换算成 8)。
|
||||
//!
|
||||
//! 「选哪个 Java 启动」不在这里,而在 [`super::select`]——那边是**纯函数**,
|
||||
//! 候选运行时由调用方注入,测试因此不需要真的装三个 JDK。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use super::extract::{java_binary_name, read_stamp};
|
||||
use super::vendor::JdkVendor;
|
||||
|
||||
/// 缺省 Java 主版本(探测不到任何东西时的保守值,D13:JDK 17 基准)。
|
||||
pub const DEFAULT_MAJOR: u32 = 17;
|
||||
|
||||
/// 托管目录名前缀。
|
||||
pub const MANAGED_PREFIX: &str = "jdk-";
|
||||
|
||||
/// 一个 Java 运行时的来源。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum JavaOrigin {
|
||||
/// 实例覆盖(`--java` 参数或实例档案的 `javaPath`)。
|
||||
Instance,
|
||||
/// 托管目录(`ull java install` 装的)。
|
||||
Managed,
|
||||
/// `JAVA_HOME`。
|
||||
JavaHome,
|
||||
/// `PATH` 上的 `java`。
|
||||
Path,
|
||||
/// 标准安装位置扫出来的(MSI 装的 Temurin / 官方启动器的 `runtime/` / 注册表等)。
|
||||
System,
|
||||
}
|
||||
|
||||
impl JavaOrigin {
|
||||
/// 稳定字符串 id(`--json` 的 `source` 字段)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Instance => "instance",
|
||||
Self::Managed => "managed",
|
||||
Self::JavaHome => "JAVA_HOME",
|
||||
Self::Path => "PATH",
|
||||
Self::System => "system",
|
||||
}
|
||||
}
|
||||
|
||||
/// 展示类别(`--json` 的 `source_category`):GUI 用它分组,不必区分 `JAVA_HOME`/`PATH`。
|
||||
#[must_use]
|
||||
pub const fn category(self) -> &'static str {
|
||||
match self {
|
||||
Self::Instance => "instance",
|
||||
Self::Managed => "managed",
|
||||
Self::JavaHome | Self::Path => "env",
|
||||
Self::System => "system",
|
||||
}
|
||||
}
|
||||
|
||||
/// 同档内的优先序(小者优先)。
|
||||
///
|
||||
/// 「环境变量」先于「扫盘」:用户显式设过的 `JAVA_HOME`/`PATH` 是意图表达,
|
||||
/// 盘上扫出来的只是候选(HMCL/PCL 也是这个顺序)。
|
||||
#[must_use]
|
||||
pub const fn rank(self) -> u8 {
|
||||
match self {
|
||||
Self::Instance => 0,
|
||||
Self::Managed => 1,
|
||||
Self::JavaHome => 2,
|
||||
Self::Path => 3,
|
||||
Self::System => 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个探测到的 Java 运行时。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct JavaRuntime {
|
||||
/// 可执行文件。
|
||||
pub binary: PathBuf,
|
||||
/// 来源。
|
||||
pub origin: JavaOrigin,
|
||||
/// `java -version` 的首行(含 `version` 字样)。
|
||||
pub version: Option<String>,
|
||||
/// 主版本号(`1.8.0_402` → 8)。
|
||||
pub major: Option<u32>,
|
||||
/// 位数(32 / 64;探测不到时按宿主)。
|
||||
pub bits: u32,
|
||||
/// 托管目录里的供应商(`temurin`);其它来源为 `None`。
|
||||
pub vendor: Option<String>,
|
||||
}
|
||||
|
||||
impl JavaRuntime {
|
||||
/// 探测不出来时给个「版本未知」的占位。
|
||||
#[must_use]
|
||||
pub fn unknown(binary: impl Into<PathBuf>, origin: JavaOrigin) -> Self {
|
||||
Self {
|
||||
binary: binary.into(),
|
||||
origin,
|
||||
version: None,
|
||||
major: None,
|
||||
bits: host_bits(),
|
||||
vendor: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 主版本号(未知时退回 [`DEFAULT_MAJOR`])。
|
||||
#[must_use]
|
||||
pub fn major_or_default(&self) -> u32 {
|
||||
self.major.unwrap_or(DEFAULT_MAJOR)
|
||||
}
|
||||
}
|
||||
|
||||
/// 跑一次 `java -version` 填满运行时信息(探测失败不报错,只留空字段)。
|
||||
///
|
||||
/// 失败不报错的理由:`ull java list` 要能列出「探测不到的项」而不是整体失败,
|
||||
/// 而启动路径上探测失败会自然退化为系统默认 Java(见 [`super::select::resolve`])。
|
||||
#[must_use]
|
||||
pub fn probe(binary: &Path, origin: JavaOrigin) -> JavaRuntime {
|
||||
let mut runtime = JavaRuntime::unknown(binary.to_path_buf(), origin);
|
||||
let Ok(output) = Command::new(binary).arg("-version").output() else {
|
||||
return runtime;
|
||||
};
|
||||
let mut text = String::from_utf8_lossy(&output.stderr).into_owned();
|
||||
text.push_str(&String::from_utf8_lossy(&output.stdout));
|
||||
if let Some((line, major)) = parse_version_output(&text) {
|
||||
runtime.major = Some(major);
|
||||
runtime.version = Some(line);
|
||||
runtime.bits = bits_of(&text);
|
||||
}
|
||||
runtime
|
||||
}
|
||||
|
||||
/// 扫描一个安装根里的托管 JDK,逐个探测。
|
||||
///
|
||||
/// **两种布局都要认**(D23 之后同时存在):
|
||||
///
|
||||
/// ```text
|
||||
/// <root>/jdk-<major>-<vendor>/bin/java Portable(v1 布局,M33 定下,永不改)
|
||||
/// <root>/<vendor>/<version>/bin/java Standard / Custom(D23 的 <发行方>/<版本>)
|
||||
/// ```
|
||||
///
|
||||
/// 只认带戳记的目录:托管区是我们自己写的地方,但没有戳记的子目录可能是用户塞进来的
|
||||
/// 别的东西,不去猜它。`major` 一律以 `java -version` 为准(戳记只用于幂等判定)。
|
||||
#[must_use]
|
||||
pub fn discover_managed(managed_root: &Path) -> Vec<JavaRuntime> {
|
||||
let mut found: Vec<JavaRuntime> = Vec::new();
|
||||
let Ok(entries) = std::fs::read_dir(managed_root) else {
|
||||
return found;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if !path.is_dir() || name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
if let Some(runtime) = probe_managed_dir(&path, parse_managed_dir(&name).map(|(_, v)| v)) {
|
||||
found.push(runtime);
|
||||
continue;
|
||||
}
|
||||
// 两层布局:`<vendor>/<version>`——vendor 名取自父目录名。
|
||||
if JdkVendor::parse(&name).is_some() {
|
||||
let Ok(children) = std::fs::read_dir(&path) else {
|
||||
continue;
|
||||
};
|
||||
for child in children.flatten() {
|
||||
let child_path = child.path();
|
||||
if let Some(runtime) = probe_managed_dir(&child_path, Some(&name)) {
|
||||
found.push(runtime);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort_runtimes(&mut found);
|
||||
found
|
||||
}
|
||||
|
||||
/// 扫多个安装根(Portable 数据根 + Standard/Custom 落点),按 `bin` 路径去重。
|
||||
#[must_use]
|
||||
pub fn discover_managed_roots(roots: &[PathBuf]) -> Vec<JavaRuntime> {
|
||||
let mut found: Vec<JavaRuntime> = Vec::new();
|
||||
for root in roots {
|
||||
for runtime in discover_managed(root) {
|
||||
if !found.iter().any(|known| known.binary == runtime.binary) {
|
||||
found.push(runtime);
|
||||
}
|
||||
}
|
||||
}
|
||||
sort_runtimes(&mut found);
|
||||
found
|
||||
}
|
||||
|
||||
/// 探测一个候选托管目录(有戳记 + `bin/java` 才算数)。
|
||||
fn probe_managed_dir(path: &Path, vendor: Option<&str>) -> Option<JavaRuntime> {
|
||||
if !path.is_dir() || read_stamp(path).is_none() {
|
||||
return None;
|
||||
}
|
||||
let binary = path.join("bin").join(java_binary_name());
|
||||
if !binary.is_file() {
|
||||
return None;
|
||||
}
|
||||
let mut runtime = probe(&binary, JavaOrigin::Managed);
|
||||
runtime.vendor = vendor.map(str::to_string);
|
||||
Some(runtime)
|
||||
}
|
||||
|
||||
/// 大版本升序,输出稳定(同 major 按路径)。
|
||||
fn sort_runtimes(found: &mut [JavaRuntime]) {
|
||||
found.sort_by(|left, right| {
|
||||
left.major
|
||||
.cmp(&right.major)
|
||||
.then_with(|| left.binary.cmp(&right.binary))
|
||||
});
|
||||
}
|
||||
|
||||
/// 系统来源的候选:`JAVA_HOME` 与 `PATH` 上的 `java`(都探测一次)。
|
||||
///
|
||||
/// `PATH` 上的那一项会先**解析成绝对路径**再探测:否则它与扫盘结果(绝对路径)看起来是
|
||||
/// 两个不同的文件,`java list` 会把同一个 JDK 列两次。
|
||||
#[must_use]
|
||||
pub fn system_candidates() -> Vec<JavaRuntime> {
|
||||
let mut found = Vec::new();
|
||||
if let Some(home) = std::env::var_os("JAVA_HOME") {
|
||||
let binary = PathBuf::from(home).join("bin").join(java_binary_name());
|
||||
if binary.is_file() {
|
||||
found.push(probe(&binary, JavaOrigin::JavaHome));
|
||||
}
|
||||
}
|
||||
let name = java_binary_name();
|
||||
let binary = resolve_on_path(name).unwrap_or_else(|| PathBuf::from(name));
|
||||
found.push(probe(&binary, JavaOrigin::Path));
|
||||
found
|
||||
}
|
||||
|
||||
/// 在 `PATH` 里找某个可执行文件的绝对路径(找不到返回 `None`)。
|
||||
#[must_use]
|
||||
pub fn resolve_on_path(name: &str) -> Option<PathBuf> {
|
||||
let path = std::env::var_os("PATH")?;
|
||||
std::env::split_paths(&path)
|
||||
.map(|dir| dir.join(name))
|
||||
.find(|candidate| candidate.is_file())
|
||||
}
|
||||
|
||||
/// 托管目录名(`jdk-21-temurin`)。
|
||||
#[must_use]
|
||||
pub fn managed_dir_name(major: u32, vendor: &str) -> String {
|
||||
format!("{MANAGED_PREFIX}{major}-{vendor}")
|
||||
}
|
||||
|
||||
/// 反解托管目录名 →(大版本,供应商)。
|
||||
#[must_use]
|
||||
pub fn parse_managed_dir(name: &str) -> Option<(u32, &str)> {
|
||||
let rest = name.strip_prefix(MANAGED_PREFIX)?;
|
||||
let (major, vendor) = rest.split_once('-')?;
|
||||
let major: u32 = major.parse().ok()?;
|
||||
(!vendor.is_empty()).then_some((major, vendor))
|
||||
}
|
||||
|
||||
/// 从 `java -version` 输出里取(含 `version` 的首行,主版本号)。
|
||||
#[must_use]
|
||||
pub fn parse_version_output(text: &str) -> Option<(String, u32)> {
|
||||
let line = text
|
||||
.lines()
|
||||
.find(|line| line.contains("version"))
|
||||
.or_else(|| text.lines().find(|line| !line.trim().is_empty()))?
|
||||
.trim();
|
||||
let version = quoted_version(line).or_else(|| bare_version(line))?;
|
||||
let major = major_of(&version)?;
|
||||
Some((line.to_string(), major))
|
||||
}
|
||||
|
||||
/// 引号里的版本号(`openjdk version "21.0.13" 2026-01-20 LTS`)。
|
||||
fn quoted_version(line: &str) -> Option<String> {
|
||||
let start = line.find('"')? + 1;
|
||||
let rest = &line[start..];
|
||||
let end = rest.find('"')?;
|
||||
Some(rest[..end].to_string())
|
||||
}
|
||||
|
||||
/// 无引号形态(`openjdk 25.0.3 2026-04-21`)。
|
||||
///
|
||||
/// 「含 `version` 的那一段之后逐个试」优先——避开 `Java(TM) SE Runtime` 这类噪声;
|
||||
/// 没有 `version` 字样时从头试(部分精简发行版就这么打印)。
|
||||
fn bare_version(line: &str) -> Option<String> {
|
||||
let tokens: Vec<&str> = line.split_whitespace().collect();
|
||||
let start = tokens
|
||||
.iter()
|
||||
.position(|token| token.contains("version"))
|
||||
.map_or(0, |position| position + 1);
|
||||
tokens[start..].iter().find_map(|token| {
|
||||
let candidate = token.trim_matches(['"', ',', '(', ')']);
|
||||
(candidate.contains('.') && major_of(candidate).is_some()).then(|| candidate.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// 版本号 → 主版本(`1.8.0_402` → 8,`21.0.13` → 21)。
|
||||
#[must_use]
|
||||
pub fn major_of(version: &str) -> Option<u32> {
|
||||
let mut parts = version.split(['.', '_', '-']);
|
||||
let first = parts.next()?.parse::<u32>().ok()?;
|
||||
if first == 1 {
|
||||
parts.next()?.parse::<u32>().ok()
|
||||
} else {
|
||||
Some(first)
|
||||
}
|
||||
}
|
||||
|
||||
/// `java -version` 输出里的位数(`64-Bit` / `32-Bit`;读不到按宿主)。
|
||||
#[must_use]
|
||||
pub fn bits_of(text: &str) -> u32 {
|
||||
let lowered = text.to_ascii_lowercase();
|
||||
if lowered.contains("64-bit") {
|
||||
64
|
||||
} else if lowered.contains("32-bit") {
|
||||
32
|
||||
} else {
|
||||
host_bits()
|
||||
}
|
||||
}
|
||||
|
||||
/// 宿主指针宽度(32 / 64)。
|
||||
#[must_use]
|
||||
pub const fn host_bits() -> u32 {
|
||||
if cfg!(target_pointer_width = "64") {
|
||||
64
|
||||
} else {
|
||||
32
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{bits_of, major_of, managed_dir_name, parse_managed_dir, parse_version_output};
|
||||
|
||||
#[test]
|
||||
fn parses_modern_and_legacy_version_lines() {
|
||||
let modern =
|
||||
parse_version_output("openjdk version \"21.0.13\" 2026-01-20 LTS\nOpenJDK 64-Bit");
|
||||
assert_eq!(modern.map(|(_, major)| major), Some(21));
|
||||
let legacy = parse_version_output("java version \"1.8.0_402\"\nJava(TM) SE Runtime");
|
||||
assert_eq!(legacy.map(|(_, major)| major), Some(8));
|
||||
let bare = parse_version_output("openjdk 25.0.3 2026-04-21\nOpenJDK 64-Bit Server VM");
|
||||
assert_eq!(bare.map(|(_, major)| major), Some(25));
|
||||
assert!(parse_version_output("no java here").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_bit_width() {
|
||||
assert_eq!(bits_of("OpenJDK 64-Bit Server VM (build 25.0.3)"), 64);
|
||||
assert_eq!(bits_of("Java HotSpot(TM) Client VM (32-Bit)"), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn major_of_handles_legacy_scheme() {
|
||||
assert_eq!(major_of("1.8.0_402"), Some(8));
|
||||
assert_eq!(major_of("17.0.13+11"), Some(17));
|
||||
assert_eq!(major_of("not-a-version"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_dir_names_round_trip() {
|
||||
assert_eq!(parse_managed_dir("jdk-21-temurin"), Some((21, "temurin")));
|
||||
assert_eq!(parse_managed_dir("jdk-8-temurin"), Some((8, "temurin")));
|
||||
assert_eq!(parse_managed_dir(".staging-jdk-21-temurin"), None);
|
||||
assert_eq!(parse_managed_dir("java-21-temurin"), None);
|
||||
assert_eq!(parse_managed_dir("jdk-x-temurin"), None);
|
||||
assert_eq!(managed_dir_name(25, "temurin"), "jdk-25-temurin");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! 一次版本查询的统一产物:一个可下载的 JDK 归档(四家发行版共用形态)。
|
||||
//!
|
||||
//! 各家的**版本号语义不同**(research/06 §5.3 第 3 条),所以这里只存「vendor 原生的版本串」
|
||||
//! 而不是硬拆成 semver 四段:
|
||||
//!
|
||||
//! | 发行版 | 版本串 | 出处 |
|
||||
//! | --- | --- | --- |
|
||||
//! | Temurin | `21.0.13+9`(三段+构建号) | Adoptium `release_name` |
|
||||
//! | Oracle OpenJDK | `21.0.2`(三段,只到 GA + ≤2 update) | 镜像目录名 |
|
||||
//! | Microsoft OpenJDK | `21.0.13.0.2`(四段/五段),11 线是 `11.0.32.1` | `aka.ms` 文件名 |
|
||||
//! | Azul Zulu | `21.0.13+3`(三段+构建号,文件名里还有 `21.0.13.0.3` 另一种写法) | Zulu API |
|
||||
//!
|
||||
//! 目录名、戳记、`--json` 都直接用这个串(`location::sanitize_version` 只做字符白名单)。
|
||||
|
||||
use super::vendor::JdkVendor;
|
||||
|
||||
/// 一个可下载的 JDK 归档。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct JdkArchive {
|
||||
/// 产物文件名(`OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip`)。
|
||||
pub name: String,
|
||||
/// 官方下载链接(镜像展开的输入;Oracle / MS / Zulu 已经是最终链接)。
|
||||
pub url: String,
|
||||
/// SHA-256(小写十六进制)。
|
||||
pub sha256: String,
|
||||
/// 字节数(`0` = 上游没给可信大小,引擎跳过长度对拍只验哈希)。
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
/// 一次版本查询的结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct JdkRelease {
|
||||
/// 发行版。
|
||||
pub vendor: JdkVendor,
|
||||
/// 大版本号(`21`)。
|
||||
pub major: u32,
|
||||
/// vendor 原生的完整版本串(目录名与展示都用它)。
|
||||
pub version: String,
|
||||
/// Temurin 系才有的 semver(其它家为 `None`)。
|
||||
pub semver: Option<String>,
|
||||
/// release 名(Temurin `jdk-21.0.13+9`;其它家用版本串)。
|
||||
pub release_name: String,
|
||||
/// 归档。
|
||||
pub archive: JdkArchive,
|
||||
}
|
||||
|
||||
impl JdkRelease {
|
||||
/// 是否就是这个发行版 + 大版本的产物。
|
||||
#[must_use]
|
||||
pub fn matches(&self, vendor: JdkVendor, major: u32) -> bool {
|
||||
self.vendor == vendor && self.major == major
|
||||
}
|
||||
|
||||
/// sha256(幂等判据用)。
|
||||
#[must_use]
|
||||
pub fn sha256(&self) -> &str {
|
||||
&self.archive.sha256
|
||||
}
|
||||
|
||||
/// 人读一行(`Temurin 21.0.13+9 · OpenJDK21U-…zip`)。
|
||||
#[must_use]
|
||||
pub fn summary(&self) -> String {
|
||||
format!(
|
||||
"{} {} · {}",
|
||||
self.vendor.display(),
|
||||
self.version,
|
||||
self.archive.name
|
||||
)
|
||||
}
|
||||
|
||||
/// `--json` 的归档块(四家共用一个形状)。
|
||||
#[must_use]
|
||||
pub fn archive_json(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"vendor": self.vendor.id(),
|
||||
"version": self.version,
|
||||
"name": self.archive.name,
|
||||
"url": self.archive.url,
|
||||
"sha256": self.archive.sha256,
|
||||
"size": self.archive.size,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 `release_name`(Temurin 的 `jdk-21.0.13+9`)取版本串。
|
||||
#[must_use]
|
||||
pub fn version_from_release_name(release_name: &str) -> Option<String> {
|
||||
let trimmed = release_name.trim();
|
||||
let stripped = trimmed.strip_prefix("jdk-").unwrap_or(trimmed);
|
||||
(!stripped.is_empty()).then(|| stripped.to_string())
|
||||
}
|
||||
|
||||
/// 解析 sha256 文本(`.sha256` / `.sha256sum.txt` / 裸哈希都吃),校验 64 位十六进制。
|
||||
#[must_use]
|
||||
pub fn parse_sha256_text(text: &str) -> Option<String> {
|
||||
for token in text.split_whitespace() {
|
||||
let lowered = token.trim().to_ascii_lowercase();
|
||||
let candidate = lowered.strip_prefix('*').unwrap_or(lowered.as_str());
|
||||
if candidate.len() == 64 && candidate.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return Some(candidate.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_sha256_text, version_from_release_name, JdkArchive, JdkRelease};
|
||||
use crate::game::java::vendor::JdkVendor;
|
||||
|
||||
fn release() -> JdkRelease {
|
||||
JdkRelease {
|
||||
vendor: JdkVendor::Oracle,
|
||||
major: 21,
|
||||
version: "21.0.2".to_string(),
|
||||
semver: None,
|
||||
release_name: "21.0.2".to_string(),
|
||||
archive: JdkArchive {
|
||||
name: "openjdk-21.0.2_windows-x64_bin.zip".to_string(),
|
||||
url: "https://mirrors.huaweicloud.com/openjdk/21.0.2/openjdk-21.0.2_windows-x64_bin.zip"
|
||||
.to_string(),
|
||||
sha256: "b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"
|
||||
.to_string(),
|
||||
size: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_carries_vendor_and_native_version() {
|
||||
let item = release();
|
||||
assert!(item.matches(JdkVendor::Oracle, 21));
|
||||
assert!(!item.matches(JdkVendor::Microsoft, 21));
|
||||
assert!(item.summary().contains("Oracle OpenJDK 21.0.2"));
|
||||
assert_eq!(item.archive_json()["vendor"], "oracle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_texts_from_every_vendor_parse_the_same_way() {
|
||||
// Huawei 的 .sha256:只有 64 位哈希,没有文件名。
|
||||
assert_eq!(
|
||||
parse_sha256_text("b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664\n")
|
||||
.as_deref(),
|
||||
Some("b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664")
|
||||
);
|
||||
// MS 的 .sha256sum.txt:`<hash> <filename>`,并且带大写与 CRLF。
|
||||
assert_eq!(
|
||||
parse_sha256_text(
|
||||
"6ECFA864AE0A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D4E5F60718293A4 microsoft-jdk-21.0.13-windows-x64.zip\r\n"
|
||||
)
|
||||
.as_deref(),
|
||||
Some("6ecfa864ae0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4")
|
||||
);
|
||||
assert_eq!(parse_sha256_text("<html>404</html>"), None);
|
||||
assert_eq!(parse_sha256_text("deadbeef"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_falls_back_to_the_release_name() {
|
||||
assert_eq!(
|
||||
version_from_release_name("jdk-21.0.13+9").as_deref(),
|
||||
Some("21.0.13+9")
|
||||
);
|
||||
assert_eq!(
|
||||
version_from_release_name("21.0.13+9").as_deref(),
|
||||
Some("21.0.13+9")
|
||||
);
|
||||
assert_eq!(version_from_release_name(""), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! 「启动选哪个 Java」的**纯**优先链(D13)。
|
||||
//!
|
||||
//! 优先级:实例覆盖(`--java` / 档案 `javaPath`)> 托管目录里**满足要求**的 JDK >
|
||||
//! `JAVA_HOME` > `PATH`。实例覆盖由调用方单独判(用户明确指定了就不参与打分),
|
||||
//! 这里只处理「一堆已探测的候选 + 一个要求」的挑选。
|
||||
//!
|
||||
//! 挑选规则(比「机械按来源排序」更贴近用户预期):
|
||||
//!
|
||||
//! 1. **满足要求的候选优先**——1.12.2 只能用 Java 8,托管的 21 再新也不能选;
|
||||
//! 2. 同档内按来源:托管 > `JAVA_HOME` > `PATH`(D13:托管目录是启动器的地盘,
|
||||
//! 用户 PATH 上的 java 可能是另一个 JDK 的残留);
|
||||
//! 3. 全都不满足时仍给一个(按来源优先取最好那个),并报不符——**提示而不是拒绝**。
|
||||
//!
|
||||
//! 抽成纯函数是为了可测:测试注入「托管 21 + PATH 8」这类组合,不必真装 JDK。
|
||||
|
||||
use super::mapping::{check, JavaMismatch, JavaRequirement};
|
||||
use super::probe::{JavaRuntime, DEFAULT_MAJOR};
|
||||
|
||||
/// 一次启动的 Java 选择结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct JavaResolution {
|
||||
/// 选中的运行时(一个候选都没有时 `None`)。
|
||||
pub runtime: Option<JavaRuntime>,
|
||||
/// 目标版本对 Java 的要求。
|
||||
pub required: JavaRequirement,
|
||||
/// 不符说明(`None` = 满足要求,或压根没探测到 Java)。
|
||||
pub mismatch: Option<JavaMismatch>,
|
||||
}
|
||||
|
||||
impl JavaResolution {
|
||||
/// 生效的主版本号(没有运行时按缺省值)。
|
||||
#[must_use]
|
||||
pub fn major(&self) -> u32 {
|
||||
self.runtime
|
||||
.as_ref()
|
||||
.map_or(DEFAULT_MAJOR, JavaRuntime::major_or_default)
|
||||
}
|
||||
|
||||
/// 是否满足要求(没有运行时视为「不知道」,返回 `true`)。
|
||||
#[must_use]
|
||||
pub fn is_satisfied(&self) -> bool {
|
||||
self.mismatch.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// 从候选里挑一个:满足要求的优先,同档内按来源优先序。
|
||||
#[must_use]
|
||||
pub fn pick<'a>(
|
||||
candidates: &'a [JavaRuntime],
|
||||
required: &JavaRequirement,
|
||||
) -> Option<&'a JavaRuntime> {
|
||||
candidates.iter().min_by_key(|runtime| {
|
||||
let ok = runtime
|
||||
.major
|
||||
.is_some_and(|major| required.satisfied_by(major));
|
||||
// 先排「不满足」(`ok=false` 的 key 大),再排来源优先序。
|
||||
(!ok, runtime.origin.rank())
|
||||
})
|
||||
}
|
||||
|
||||
/// 挑 + 判不符。
|
||||
#[must_use]
|
||||
pub fn resolve(candidates: &[JavaRuntime], required: &JavaRequirement) -> JavaResolution {
|
||||
let runtime = pick(candidates, required).cloned();
|
||||
let mismatch = runtime
|
||||
.as_ref()
|
||||
.and_then(|runtime| runtime.major)
|
||||
.and_then(|major| check(major, required));
|
||||
JavaResolution {
|
||||
runtime,
|
||||
required: required.clone(),
|
||||
mismatch,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::resolve;
|
||||
use crate::game::java::mapping::{JavaRequirement, RequirementSource};
|
||||
use crate::game::java::probe::{JavaOrigin, JavaRuntime, DEFAULT_MAJOR};
|
||||
|
||||
fn runtime(major: u32, origin: JavaOrigin) -> JavaRuntime {
|
||||
JavaRuntime {
|
||||
binary: std::path::PathBuf::from(format!("java{major}")),
|
||||
origin,
|
||||
version: Some(format!("openjdk version \"{major}.0.1\"")),
|
||||
major: Some(major),
|
||||
bits: 64,
|
||||
vendor: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn at_least(minimum: u32) -> JavaRequirement {
|
||||
JavaRequirement::new(minimum, None, RequirementSource::Table)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn satisfying_managed_runtime_beats_mismatching_system_java() {
|
||||
let candidates = [
|
||||
runtime(8, JavaOrigin::Path),
|
||||
runtime(21, JavaOrigin::Managed),
|
||||
];
|
||||
let picked = resolve(&candidates, &at_least(17));
|
||||
assert_eq!(picked.major(), 21);
|
||||
assert!(picked.is_satisfied());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn satisfying_system_java_beats_mismatching_managed() {
|
||||
// 1.12.2 只能 Java 8:托管的 21 不满足,PATH 上的 8 满足 → 选 8。
|
||||
let required = JavaRequirement::new(8, Some(8), RequirementSource::Table);
|
||||
let candidates = [
|
||||
runtime(21, JavaOrigin::Managed),
|
||||
runtime(8, JavaOrigin::Path),
|
||||
];
|
||||
let picked = resolve(&candidates, &required);
|
||||
assert_eq!(picked.major(), 8);
|
||||
assert!(picked.is_satisfied());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_wins_when_both_satisfy() {
|
||||
let candidates = [
|
||||
runtime(21, JavaOrigin::Path),
|
||||
runtime(17, JavaOrigin::JavaHome),
|
||||
runtime(17, JavaOrigin::Managed),
|
||||
];
|
||||
assert_eq!(resolve(&candidates, &at_least(17)).major(), 17);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_home_beats_path_when_both_satisfy() {
|
||||
let candidates = [
|
||||
runtime(21, JavaOrigin::Path),
|
||||
runtime(21, JavaOrigin::JavaHome),
|
||||
];
|
||||
let picked = resolve(&candidates, &at_least(21));
|
||||
assert_eq!(
|
||||
picked.runtime.map(|runtime| runtime.origin),
|
||||
Some(JavaOrigin::JavaHome)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_candidate_keeps_the_default_and_reports_no_mismatch() {
|
||||
let picked = resolve(&[], &at_least(21));
|
||||
assert_eq!(picked.major(), DEFAULT_MAJOR);
|
||||
assert!(picked.runtime.is_none());
|
||||
assert!(picked.is_satisfied());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatch_is_reported_when_nothing_fits() {
|
||||
let candidates = [runtime(8, JavaOrigin::Path)];
|
||||
let picked = resolve(&candidates, &at_least(21));
|
||||
assert_eq!(picked.major(), 8);
|
||||
let mismatch = picked.mismatch.expect("应报不符");
|
||||
assert_eq!(mismatch.code(), "java_too_old");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//! 版本查询的发行版分派 + 回退链记录(D23)。
|
||||
//!
|
||||
//! 两件事:
|
||||
//!
|
||||
//! 1. [`fetch`]:把一次「某发行版某大版本的最新 GA」查询分派给对应实现——
|
||||
//! Temurin 走 `api.adoptium.net`(v1 既有),Oracle / MS / Zulu 各走自己的源表;
|
||||
//! 2. [`ChainLog`]:按用户优先级依次尝试时,**每一跳的结果与降级原因**都记下来。
|
||||
//! 这份记录直接进 `--json` 与事件流,是「为什么最后装的是 MS 而不是 Temurin」的唯一解释。
|
||||
//!
|
||||
//! 回退的粒度是**发行版级**(D23 原话):版本不存在、镜像全 4xx、校验失败都算这一家失败,
|
||||
//! 换下一家;同一家内部的镜像换源(TUNA→官方、华为云→南大)仍然归 `core::download` 引擎管。
|
||||
|
||||
use super::api;
|
||||
use super::error::JavaError;
|
||||
use super::microsoft;
|
||||
use super::oracle;
|
||||
use super::release::JdkRelease;
|
||||
use super::target::AdoptiumTarget;
|
||||
use super::vendor::JdkVendor;
|
||||
use super::zulu;
|
||||
use crate::download::DownloadEngine;
|
||||
|
||||
/// 查一次「某发行版某大版本的最新 GA」。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 平台无产物、清单不可达、该家没有这个大版本、校验缺失时返回错误——调用方据此降级。
|
||||
pub async fn fetch(
|
||||
engine: &DownloadEngine,
|
||||
vendor: JdkVendor,
|
||||
major: u32,
|
||||
target: AdoptiumTarget,
|
||||
) -> Result<JdkRelease, JavaError> {
|
||||
let client = engine.client();
|
||||
let table = engine.mirrors();
|
||||
match vendor {
|
||||
JdkVendor::Temurin => api::fetch_latest(client, major, target).await,
|
||||
JdkVendor::Oracle => oracle::fetch_latest(client, table, major, target).await,
|
||||
JdkVendor::Microsoft => {
|
||||
let table = table.microsoft_openjdk().ok_or_else(|| {
|
||||
JavaError::new(
|
||||
super::error::JavaErrorKind::Metadata,
|
||||
"镜像清单里没有 microsoft_openjdk 表",
|
||||
)
|
||||
.with_hint("升级 mirrors.json 到 v3,或改用 --vendor temurin")
|
||||
})?;
|
||||
microsoft::fetch_latest(client, table, major, target).await
|
||||
}
|
||||
JdkVendor::Zulu => {
|
||||
let table = table.zulu().ok_or_else(|| {
|
||||
JavaError::new(
|
||||
super::error::JavaErrorKind::Metadata,
|
||||
"镜像清单里没有 zulu 表",
|
||||
)
|
||||
.with_hint("升级 mirrors.json 到 v3,或改用 --vendor temurin")
|
||||
})?;
|
||||
zulu::fetch_latest(client, table, major, target).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一跳的结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VendorAttempt {
|
||||
/// 尝试的发行版。
|
||||
pub vendor: JdkVendor,
|
||||
/// 是否命中(成功拿到可下载的归档)。
|
||||
pub ok: bool,
|
||||
/// 命中/降级的一句话原因(`"命中"` / `"没有 8 的 GA(只发 11/17/21/25)"`)。
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl VendorAttempt {
|
||||
/// `--json` 形状。
|
||||
#[must_use]
|
||||
pub fn to_json(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"vendor": self.vendor.id(),
|
||||
"ok": self.ok,
|
||||
"detail": self.detail,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条回退链的执行记录。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ChainLog {
|
||||
attempts: Vec<VendorAttempt>,
|
||||
}
|
||||
|
||||
impl ChainLog {
|
||||
/// 空记录。
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 记一次「跳过」(该家不支持这个大版本,根本没查网络)。
|
||||
pub fn skip(&mut self, vendor: JdkVendor, detail: impl Into<String>) {
|
||||
self.attempts.push(VendorAttempt {
|
||||
vendor,
|
||||
ok: false,
|
||||
detail: detail.into(),
|
||||
});
|
||||
}
|
||||
|
||||
/// 记一次失败(查到了但拿不到、或下载/校验失败)。
|
||||
pub fn failed(&mut self, vendor: JdkVendor, detail: impl Into<String>) {
|
||||
self.attempts.push(VendorAttempt {
|
||||
vendor,
|
||||
ok: false,
|
||||
detail: detail.into(),
|
||||
});
|
||||
}
|
||||
|
||||
/// 记一次命中。
|
||||
pub fn hit(&mut self, vendor: JdkVendor, detail: impl Into<String>) {
|
||||
self.attempts.push(VendorAttempt {
|
||||
vendor,
|
||||
ok: true,
|
||||
detail: detail.into(),
|
||||
});
|
||||
}
|
||||
|
||||
/// 是否已经命中(命中后不再往下试)。
|
||||
#[must_use]
|
||||
pub fn hit_vendor(&self) -> Option<JdkVendor> {
|
||||
self.attempts
|
||||
.iter()
|
||||
.find(|attempt| attempt.ok)
|
||||
.map(|attempt| attempt.vendor)
|
||||
}
|
||||
|
||||
/// 全部记录。
|
||||
#[must_use]
|
||||
pub fn attempts(&self) -> &[VendorAttempt] {
|
||||
&self.attempts
|
||||
}
|
||||
|
||||
/// 降级说明(「已跳过」的那些;给事件流与人读告警用)。
|
||||
#[must_use]
|
||||
pub fn degradations(&self) -> Vec<String> {
|
||||
self.attempts
|
||||
.iter()
|
||||
.filter(|attempt| !attempt.ok)
|
||||
.map(|attempt| format!("{}:{}", attempt.vendor.id(), attempt.detail))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 实际尝试了几家(不含跳过)。
|
||||
#[must_use]
|
||||
pub fn tried(&self) -> usize {
|
||||
self.attempts.len()
|
||||
}
|
||||
|
||||
/// `--json` 形状。
|
||||
#[must_use]
|
||||
pub fn to_json(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"hit": self.hit_vendor().map(JdkVendor::id),
|
||||
"attempts": self.attempts.iter().map(VendorAttempt::to_json).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ChainLog;
|
||||
use crate::game::java::vendor::JdkVendor;
|
||||
|
||||
#[test]
|
||||
fn chain_skips_unsupported_majors_and_records_the_reason() {
|
||||
let mut log = ChainLog::new();
|
||||
log.skip(JdkVendor::Oracle, "不支持 8(Oracle archive 从 9 起)");
|
||||
log.skip(JdkVendor::Microsoft, "没有 8 的 GA(只发 11/17/21/25)");
|
||||
log.hit(JdkVendor::Temurin, "命中 8.0.504+1");
|
||||
assert_eq!(log.hit_vendor(), Some(JdkVendor::Temurin));
|
||||
let degradations = log.degradations();
|
||||
assert_eq!(degradations.len(), 2);
|
||||
assert!(degradations[1].contains("microsoft"));
|
||||
assert_eq!(log.attempts().len(), 3);
|
||||
let json = log.to_json();
|
||||
assert_eq!(json["hit"], "temurin");
|
||||
assert_eq!(json["attempts"][0]["vendor"], "oracle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_reasons_are_kept_for_the_event_stream() {
|
||||
let mut log = ChainLog::new();
|
||||
log.failed(JdkVendor::Temurin, "所有镜像都 4xx");
|
||||
log.hit(JdkVendor::Oracle, "命中 21.0.2");
|
||||
assert_eq!(
|
||||
log.degradations(),
|
||||
vec!["temurin:所有镜像都 4xx".to_string()],
|
||||
"降级原因必须原样可读(用户要能看懂为什么换了发行版)"
|
||||
);
|
||||
assert_eq!(log.tried(), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! 宿主平台 → Adoptium 请求参数,以及归档形态(Win = zip,Linux/mac = tar.gz)。
|
||||
//!
|
||||
//! Adoptium 的 `os` / `arch` 取值与 Rust 的 `target_os` / `target_arch` 不同形
|
||||
//! (`windows`/`mac`/`linux` × `x64`/`aarch64`/`x86-32`……),这里做唯一一次翻译。
|
||||
//! 平台差异仍然按项目纪律收敛在 `core::platform` 的语义里,本文件只做**命名映射**,
|
||||
//! 不写任何能力探测。
|
||||
|
||||
use super::error::{JavaError, JavaErrorKind};
|
||||
|
||||
/// 一次 Adoptium 请求的目标。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AdoptiumTarget {
|
||||
/// Adoptium 的 `os` 段(`windows` / `linux` / `mac`)。
|
||||
pub os: &'static str,
|
||||
/// Adoptium 的 `arch` 段(`x64` / `aarch64` / `x86-32` …)。
|
||||
pub arch: &'static str,
|
||||
}
|
||||
|
||||
impl AdoptiumTarget {
|
||||
/// 宿主平台(不认识的架构直接拒绝,不猜一个错的产物下来)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 当前 OS / 架构没有 Temurin 产物时返回 [`JavaErrorKind::Unsupported`]。
|
||||
pub fn host() -> Result<Self, JavaError> {
|
||||
let os = if cfg!(windows) {
|
||||
"windows"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"mac"
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"linux"
|
||||
} else {
|
||||
return Err(JavaError::new(
|
||||
JavaErrorKind::Unsupported,
|
||||
format!("当前系统({})没有 Temurin 产物", std::env::consts::OS),
|
||||
)
|
||||
.with_hint("手动指定 JAVA_HOME,或用 `ull launch --java <path>` 挑已有 JDK"));
|
||||
};
|
||||
let arch = arch_name(std::env::consts::ARCH).ok_or_else(|| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Unsupported,
|
||||
format!("当前架构({})没有 Temurin 产物", std::env::consts::ARCH),
|
||||
)
|
||||
.with_hint("手动指定 JAVA_HOME,或用 `ull launch --java <path>` 挑已有 JDK")
|
||||
})?;
|
||||
Ok(Self { os, arch })
|
||||
}
|
||||
|
||||
/// 构造(测试注入用)。
|
||||
#[must_use]
|
||||
pub const fn new(os: &'static str, arch: &'static str) -> Self {
|
||||
Self { os, arch }
|
||||
}
|
||||
|
||||
/// 该平台的产物是不是 zip(Windows 是;Linux/mac 的 Temurin 只发 `.tar.gz`)。
|
||||
#[must_use]
|
||||
pub fn is_zip(self) -> bool {
|
||||
self.os == "windows"
|
||||
}
|
||||
|
||||
/// 归档扩展名(拼「期望文件名」与给出「不支持的形态」提示时用)。
|
||||
#[must_use]
|
||||
pub fn archive_ext(self) -> &'static str {
|
||||
if self.is_zip() {
|
||||
"zip"
|
||||
} else {
|
||||
"tar.gz"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rust 架构名 → Adoptium 架构名(不认识返回 `None`)。
|
||||
#[must_use]
|
||||
pub fn arch_name(arch: &str) -> Option<&'static str> {
|
||||
match arch {
|
||||
"x86_64" => Some("x64"),
|
||||
"aarch64" => Some("aarch64"),
|
||||
"x86" | "i686" => Some("x86-32"),
|
||||
"arm" => Some("arm"),
|
||||
"ppc64le" => Some("ppc64le"),
|
||||
"s390x" => Some("s390x"),
|
||||
"riscv64" => Some("riscv64"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
//! JDK 发行版(vendor)抽象与用户可调优先级链(D23)。
|
||||
//!
|
||||
//! 四家发行版的**默认顺序按地区分区**(用户定案 + research/06 §5.1 实测修正):
|
||||
//!
|
||||
//! ```text
|
||||
//! 国内:Temurin(TUNA) → Oracle OpenJDK(华为云→南大) → MS OpenJDK(境外 CDN) → Zulu
|
||||
//! 国外:Temurin → MS OpenJDK → Oracle OpenJDK → Zulu
|
||||
//! ```
|
||||
//!
|
||||
//! 国内把 Oracle 提到 MS 之前的依据是实测差 28×(Oracle 国内 11.5 MB/s vs MS 410 KB/s);
|
||||
//! 用户原话是「Temurin → MS → Oracle」,但同一句话里给了「优先级可调」,所以这只是默认值,
|
||||
//! 设置键 `java.vendorPriority` 可以整体覆盖(D23 定稿)。
|
||||
//!
|
||||
//! 这里的表只描述**能力与顺序**,不碰网络:怎么查、怎么下在 `oracle` / `microsoft` /
|
||||
//! `zulu` / `api`(Temurin)四个模块里。
|
||||
|
||||
/// 设置键 `java.vendorPriority` 的允许值(顺序即默认序的覆盖来源)。
|
||||
pub const VENDOR_IDS: &[&str] = &["temurin", "oracle", "microsoft", "zulu"];
|
||||
|
||||
/// 一家 JDK 发行版。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum JdkVendor {
|
||||
/// Eclipse Temurin(Adoptium),v1 唯一实现。
|
||||
Temurin,
|
||||
/// Oracle OpenJDK(`download.java.net` GA,只发首个 GA + ≤2 个 update)。
|
||||
Oracle,
|
||||
/// Microsoft OpenJDK(`aka.ms` 模板 + VS CDN,无 8)。
|
||||
Microsoft,
|
||||
/// Azul Zulu(第四备选,API 元数据 + CDN 直链)。
|
||||
Zulu,
|
||||
}
|
||||
|
||||
impl JdkVendor {
|
||||
/// 全部发行版(枚举顺序,不是优先级顺序)。
|
||||
pub const ALL: [Self; 4] = [Self::Temurin, Self::Oracle, Self::Microsoft, Self::Zulu];
|
||||
|
||||
/// 稳定 id(目录名段、`--vendor` 取值、设置值、JSON 字段)。
|
||||
#[must_use]
|
||||
pub const fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Temurin => "temurin",
|
||||
Self::Oracle => "oracle",
|
||||
Self::Microsoft => "microsoft",
|
||||
Self::Zulu => "zulu",
|
||||
}
|
||||
}
|
||||
|
||||
/// 展示名(人读输出 + `java vendors`)。
|
||||
#[must_use]
|
||||
pub const fn display(self) -> &'static str {
|
||||
match self {
|
||||
Self::Temurin => "Eclipse Temurin",
|
||||
Self::Oracle => "Oracle OpenJDK",
|
||||
Self::Microsoft => "Microsoft OpenJDK",
|
||||
Self::Zulu => "Azul Zulu",
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 `--vendor` / 设置值(容忍常见别名,大小写不敏感)。
|
||||
#[must_use]
|
||||
pub fn parse(text: &str) -> Option<Self> {
|
||||
match text.trim().to_ascii_lowercase().as_str() {
|
||||
"temurin" | "adoptium" | "eclipse" | "eclipse-temurin" => Some(Self::Temurin),
|
||||
"oracle" | "oracle_openjdk" | "openjdk" | "java.net" => Some(Self::Oracle),
|
||||
"microsoft" | "ms" | "msopenjdk" | "ms_openjdk" | "microsoft-openjdk" => {
|
||||
Some(Self::Microsoft)
|
||||
}
|
||||
"zulu" | "azul" => Some(Self::Zulu),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 该发行版能不能装这个大版本。
|
||||
///
|
||||
/// Temurin / Zulu 覆盖 8 起的全部 GA;Oracle 的 archive 从 9 起(8 由 Temurin 负责);
|
||||
/// MS 只发 11/17/21/25(**已停发 8**,官方页面直接让去下 Temurin 8)。
|
||||
#[must_use]
|
||||
pub const fn supports(self, major: u32) -> bool {
|
||||
match self {
|
||||
Self::Temurin | Self::Zulu => major >= 8,
|
||||
Self::Oracle => major >= 9,
|
||||
Self::Microsoft => matches!(major, 11 | 17 | 21 | 25),
|
||||
}
|
||||
}
|
||||
|
||||
/// 已知在发的 GA 大版本(`java vendors` 展示与设置页提示用;不是硬上限)。
|
||||
#[must_use]
|
||||
pub const fn hint_majors(self) -> &'static [u32] {
|
||||
match self {
|
||||
Self::Temurin => &[8, 11, 17, 21, 25],
|
||||
Self::Oracle => &[17, 21, 25],
|
||||
Self::Microsoft => &[11, 17, 21, 25],
|
||||
Self::Zulu => &[8, 11, 17, 21, 25],
|
||||
}
|
||||
}
|
||||
|
||||
/// 国内默认序(用户定案 + 实测修正)。
|
||||
#[must_use]
|
||||
pub const fn domestic_default() -> [Self; 4] {
|
||||
[Self::Temurin, Self::Oracle, Self::Microsoft, Self::Zulu]
|
||||
}
|
||||
|
||||
/// 国外默认序(用户原话顺序)。
|
||||
#[must_use]
|
||||
pub const fn global_default() -> [Self; 4] {
|
||||
[Self::Temurin, Self::Microsoft, Self::Oracle, Self::Zulu]
|
||||
}
|
||||
|
||||
/// 按地区取默认序。
|
||||
#[must_use]
|
||||
pub const fn default_order(domestic: bool) -> [Self; 4] {
|
||||
if domestic {
|
||||
Self::domestic_default()
|
||||
} else {
|
||||
Self::global_default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户优先级(设置值)→ 实际尝试顺序。
|
||||
///
|
||||
/// 语义刻意做成「**补集追加**」而不是「必须写全」:用户写了 `["oracle"]` 就只把 oracle 提上来,
|
||||
/// 其余按地区默认序跟在后面。理由——写全四家是负担,漏写更不该等于「其余不试」。
|
||||
#[must_use]
|
||||
pub fn order(priority: &[JdkVendor], domestic: bool) -> Vec<JdkVendor> {
|
||||
let mut out: Vec<JdkVendor> = Vec::with_capacity(JdkVendor::ALL.len());
|
||||
for vendor in priority {
|
||||
if !out.contains(vendor) {
|
||||
out.push(*vendor);
|
||||
}
|
||||
}
|
||||
for vendor in JdkVendor::default_order(domestic) {
|
||||
if !out.contains(&vendor) {
|
||||
out.push(vendor);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 解析设置值里的字符串数组(未知项忽略;顺序保留)。
|
||||
#[must_use]
|
||||
pub fn parse_priority(items: &[String]) -> Vec<JdkVendor> {
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
if let Some(vendor) = JdkVendor::parse(item) {
|
||||
if !out.contains(&vendor) {
|
||||
out.push(vendor);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 尝试顺序 → 稳定 id 列表(JSON / 人读输出)。
|
||||
#[must_use]
|
||||
pub fn ids(vendors: &[JdkVendor]) -> Vec<&'static str> {
|
||||
vendors.iter().map(|vendor| vendor.id()).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ids, order, parse_priority, JdkVendor};
|
||||
|
||||
#[test]
|
||||
fn domestic_and_global_defaults_follow_d23() {
|
||||
assert_eq!(
|
||||
ids(&order(&[], true)),
|
||||
vec!["temurin", "oracle", "microsoft", "zulu"],
|
||||
"国内:Oracle 在 MS 之前(实测差 28×)"
|
||||
);
|
||||
assert_eq!(
|
||||
ids(&order(&[], false)),
|
||||
vec!["temurin", "microsoft", "oracle", "zulu"],
|
||||
"国外:维持用户原话 Temurin→MS→Oracle"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_priority_leads_and_the_rest_follows_as_a_complement() {
|
||||
let priority = parse_priority(&["microsoft".to_string()]);
|
||||
assert_eq!(
|
||||
ids(&order(&priority, true)),
|
||||
vec!["microsoft", "temurin", "oracle", "zulu"],
|
||||
"只写一家也要把其余按默认序补齐"
|
||||
);
|
||||
let full = parse_priority(&["zulu".to_string(), "oracle".to_string(), "zulu".to_string()]);
|
||||
assert_eq!(
|
||||
ids(&order(&full, true)),
|
||||
vec!["zulu", "oracle", "temurin", "microsoft"],
|
||||
"重复项去重,未列出的追加在后面"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_setting_values_are_ignored() {
|
||||
let parsed = parse_priority(&["graalvm".to_string(), "AZUL".to_string()]);
|
||||
assert_eq!(parsed, vec![JdkVendor::Zulu]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vendor_ids_majors_and_aliases() {
|
||||
assert_eq!(JdkVendor::parse("Adoptium"), Some(JdkVendor::Temurin));
|
||||
assert_eq!(JdkVendor::parse("ms"), Some(JdkVendor::Microsoft));
|
||||
assert_eq!(JdkVendor::parse("graalvm"), None);
|
||||
// MS 没有 8:这是「回退链要跳过某家」的真实判据。
|
||||
assert!(!JdkVendor::Microsoft.supports(8));
|
||||
assert!(JdkVendor::Microsoft.supports(21));
|
||||
assert!(JdkVendor::Oracle.supports(9));
|
||||
assert!(!JdkVendor::Oracle.supports(8), "8 交给 Temurin");
|
||||
assert!(JdkVendor::Temurin.supports(8));
|
||||
for vendor in JdkVendor::ALL {
|
||||
assert!(
|
||||
vendor.hint_majors().contains(&21),
|
||||
"{} 应发 21",
|
||||
vendor.id()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Azul Zulu:**第四备选**(D23)。API 元数据 + CDN 直链,实现刻意简化。
|
||||
//!
|
||||
//! 为什么排最后:国内没有任何 Zulu 镜像(三家云 + 四家校园源全无 `zulu`/`azul` 目录),
|
||||
//! 只能吃 `cdn.azul.com` 的 3.3 MB/s(211 MB ≈ 64 s);而前三家在国内都有更好的路。
|
||||
//! 留这一家的价值是「三家全挂时的最后一条活路」,以及 Temurin/Oracle/MS 都缺某版本时的兜底。
|
||||
//!
|
||||
//! 两跳(research/06 §4.3):
|
||||
//!
|
||||
//! ```text
|
||||
//! GET {api}?java_version=21&os=windows&arch=x64&java_package_type=jdk&archive_type=zip&latest=true
|
||||
//! → [{ package_uuid, name, java_version, download_url }] # 注意:不含 sha256
|
||||
//! GET {detail({uuid} 占位)} → { sha256_hash, size(不可信,差 133 B) }
|
||||
//! ```
|
||||
//!
|
||||
//! 三条实测约束:①`latest=true` 的响应**没有** `sha256_hash`,必须再取一次详情;
|
||||
//! ②`size` 字段与真实字节数差 133 B → 一律不信,只填 0;③文件名必须照抄 API 返回值
|
||||
//! (`zulu21.54.109-ca-jdk21.0.13.0.3-win_x64.zip` 里混着三段与四段两种写法,拼不出来)。
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::error::{JavaError, JavaErrorKind};
|
||||
use super::net;
|
||||
use super::release::{JdkArchive, JdkRelease};
|
||||
use super::target::AdoptiumTarget;
|
||||
use super::vendor::JdkVendor;
|
||||
use crate::download::ZuluTable;
|
||||
|
||||
/// Zulu 的 os 段(`windows` / `macos` / `linux`)。
|
||||
#[must_use]
|
||||
pub fn zulu_os(target: AdoptiumTarget) -> &'static str {
|
||||
match target.os {
|
||||
"mac" => "macos",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// 包列表查询地址(`latest=true` 只返回最新一个)。
|
||||
#[must_use]
|
||||
pub fn api_url(table: &ZuluTable, major: u32, target: AdoptiumTarget) -> String {
|
||||
format!(
|
||||
"{}?java_version={major}&os={}&arch={}&java_package_type=jdk&archive_type=zip&latest=true",
|
||||
table.api,
|
||||
zulu_os(target),
|
||||
target.arch
|
||||
)
|
||||
}
|
||||
|
||||
/// 包详情地址(`{uuid}` 占位替换)。
|
||||
#[must_use]
|
||||
pub fn detail_url(table: &ZuluTable, uuid: &str) -> String {
|
||||
table.detail.replace("{uuid}", uuid)
|
||||
}
|
||||
|
||||
/// 列表响应里我们关心的字段。
|
||||
///
|
||||
/// ⚠️ 真机形态(2026-10-30 实测):`latest=true` **不止返回一条**,而且 `java_version` 是
|
||||
/// **数字数组**(`[21,0,13,0,3]`)而不是字符串;同一批里还混着 `-crac-`(CRaC)与
|
||||
/// `-fx-`(JavaFX)变体。所以这里按「字段宽进 + 只挑纯 JDK 包」处理,见 [`parse_packages`]。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct ZuluPackage {
|
||||
/// 包 id(取详情用)。
|
||||
pub package_uuid: String,
|
||||
/// 归档文件名(照抄,不拼)。
|
||||
pub name: String,
|
||||
/// JDK 版本(`[21,0,13,0,3]`,Azul 用数组给)。
|
||||
#[serde(default)]
|
||||
pub java_version: Vec<u32>,
|
||||
/// CDN 直链。
|
||||
pub download_url: String,
|
||||
}
|
||||
|
||||
impl ZuluPackage {
|
||||
/// 版本串(`[21,0,13,0,3]` → `21.0.13.0.3`;空数组时退回文件名)。
|
||||
#[must_use]
|
||||
pub fn version_text(&self) -> String {
|
||||
if self.java_version.is_empty() {
|
||||
return self.name.clone();
|
||||
}
|
||||
self.java_version
|
||||
.iter()
|
||||
.map(u32::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(".")
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否是「纯 JDK」包(排除 CRaC / JavaFX / JRE 变体)。
|
||||
#[must_use]
|
||||
pub fn is_plain_jdk(name: &str) -> bool {
|
||||
name.contains("-jdk")
|
||||
&& !name.contains("-jre")
|
||||
&& !name.contains("crac")
|
||||
&& !name.contains("-fx")
|
||||
}
|
||||
|
||||
/// 列表响应 → 纯 JDK 包(`latest=true` 会给多条,必须自己挑)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 响应不是 JSON、列表为空时返回错误。
|
||||
pub fn parse_packages(json: &str) -> Result<ZuluPackage, JavaError> {
|
||||
let packages: Vec<ZuluPackage> = serde_json::from_str(json).map_err(|error| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!("Zulu 列表响应不是预期 JSON:{error}"),
|
||||
)
|
||||
})?;
|
||||
let picked = packages
|
||||
.iter()
|
||||
.find(|package| is_plain_jdk(&package.name))
|
||||
.or_else(|| packages.first())
|
||||
.ok_or_else(|| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
"Zulu 没有该版本/平台的 JDK 包(列表为空)",
|
||||
)
|
||||
})?;
|
||||
Ok(picked.clone())
|
||||
}
|
||||
|
||||
/// 详情响应里的 `sha256_hash`(只信这一个字段,`size` 不可对拍)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 响应不是 JSON 或缺可用哈希时返回错误。
|
||||
pub fn parse_detail_sha256(json: &str) -> Result<String, JavaError> {
|
||||
#[derive(Deserialize)]
|
||||
struct Detail {
|
||||
#[serde(default)]
|
||||
sha256_hash: Option<String>,
|
||||
}
|
||||
let detail: Detail = serde_json::from_str(json).map_err(|error| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!("Zulu 详情响应不是预期 JSON:{error}"),
|
||||
)
|
||||
})?;
|
||||
detail
|
||||
.sha256_hash
|
||||
.map(|hex| hex.trim().to_ascii_lowercase())
|
||||
.filter(|hex| hex.len() == 64 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()))
|
||||
.ok_or_else(|| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Integrity,
|
||||
"Zulu 详情里没有可用的 sha256_hash",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// 查一次「某大版本的最新 Zulu JDK」。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 表里没配 Zulu、API 不可达/无包、详情缺哈希时返回错误(回退链到此为止,报总失败)。
|
||||
pub async fn fetch_latest(
|
||||
client: &reqwest::Client,
|
||||
table: &ZuluTable,
|
||||
major: u32,
|
||||
target: AdoptiumTarget,
|
||||
) -> Result<JdkRelease, JavaError> {
|
||||
let list = net::get_text(client, &api_url(table, major, target), "Zulu 包列表").await?;
|
||||
let package = parse_packages(&list)?;
|
||||
let detail = net::get_text(
|
||||
client,
|
||||
&detail_url(table, &package.package_uuid),
|
||||
"Zulu 包详情",
|
||||
)
|
||||
.await?;
|
||||
let sha256 = parse_detail_sha256(&detail)?;
|
||||
// Zulu 的版本串用 `java_version` 数组拼(真机是数组不是字符串);文件名照抄,
|
||||
// 因为文件名里 `jdk21.0.13.0.3` 这类四段/五段写法拼不出来。
|
||||
Ok(JdkRelease {
|
||||
vendor: JdkVendor::Zulu,
|
||||
major,
|
||||
version: package.version_text(),
|
||||
semver: None,
|
||||
release_name: package.name.clone(),
|
||||
archive: JdkArchive {
|
||||
name: package.name,
|
||||
url: package.download_url,
|
||||
sha256,
|
||||
// size 字段不可信(实测差 133 B)→ 填 0,交给 sha256 校验。
|
||||
size: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{api_url, detail_url, is_plain_jdk, parse_detail_sha256, parse_packages, zulu_os};
|
||||
use crate::download::MirrorTable;
|
||||
use crate::game::java::target::AdoptiumTarget;
|
||||
|
||||
const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64");
|
||||
|
||||
fn table() -> crate::download::ZuluTable {
|
||||
MirrorTable::builtin()
|
||||
.zulu()
|
||||
.expect("内置表应带 Zulu")
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urls_carry_every_filter_the_api_needs() {
|
||||
let table = table();
|
||||
let url = api_url(&table, 21, WINDOWS);
|
||||
assert!(url.starts_with("https://api.azul.com/metadata/v1/zulu/packages/?"));
|
||||
for needle in [
|
||||
"java_version=21",
|
||||
"os=windows",
|
||||
"arch=x64",
|
||||
"java_package_type=jdk",
|
||||
"archive_type=zip",
|
||||
"latest=true",
|
||||
] {
|
||||
assert!(url.contains(needle), "{needle} 应在 {url}");
|
||||
}
|
||||
assert_eq!(
|
||||
detail_url(&table, "abc-123"),
|
||||
"https://api.azul.com/metadata/v1/zulu/packages/abc-123"
|
||||
);
|
||||
assert_eq!(zulu_os(AdoptiumTarget::new("mac", "aarch64")), "macos");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_listing_gives_the_uuid_and_the_exact_filename() {
|
||||
// 真机响应(2026-10-30):`latest=true` 返回多条,`java_version` 是数组,
|
||||
// 并且混着 `-crac-` / `-fx-` 变体——必须挑出纯 JDK 那条。
|
||||
let json = r#"[
|
||||
{"package_uuid":"4ee30e3a-b26a-40d3-9d04-2182eae4075e",
|
||||
"name":"zulu21.54.109-ca-jdk21.0.13.0.3-win_x64.zip",
|
||||
"java_version":[21,0,13,0,3],
|
||||
"download_url":"https://cdn.azul.com/zulu/bin/zulu21.54.109-ca-jdk21.0.13.0.3-win_x64.zip"},
|
||||
{"package_uuid":"2781fb21-9401-40d3-a88e-cf8f36541e3c",
|
||||
"name":"zulu21.52.203-ca-crac-jdk21.0.12.1-win_x64.zip",
|
||||
"java_version":[21,0,12,1],
|
||||
"download_url":"https://cdn.azul.com/zulu/bin/zulu21.52.203-ca-crac-jdk21.0.12.1-win_x64.zip"},
|
||||
{"package_uuid":"49851aa2-4fd2-4e54-a903-323989cdf711",
|
||||
"name":"zulu21.52.203-ca-fx-jdk21.0.12.1-win_x64.zip",
|
||||
"java_version":[21,0,12,1],
|
||||
"download_url":"https://cdn.azul.com/zulu/bin/zulu21.52.203-ca-fx-jdk21.0.12.1-win_x64.zip"}
|
||||
]"#;
|
||||
let package = parse_packages(json).expect("应解析");
|
||||
assert_eq!(package.package_uuid, "4ee30e3a-b26a-40d3-9d04-2182eae4075e");
|
||||
assert_eq!(
|
||||
package.name, "zulu21.54.109-ca-jdk21.0.13.0.3-win_x64.zip",
|
||||
"文件名必须照抄 API(三段/四段混写,拼不出来)"
|
||||
);
|
||||
assert_eq!(package.version_text(), "21.0.13.0.3");
|
||||
assert!(is_plain_jdk(&package.name));
|
||||
assert!(!is_plain_jdk(
|
||||
"zulu21.52.203-ca-crac-jdk21.0.12.1-win_x64.zip"
|
||||
));
|
||||
assert!(!is_plain_jdk(
|
||||
"zulu21.52.203-ca-fx-jdk21.0.12.1-win_x64.zip"
|
||||
));
|
||||
assert!(
|
||||
parse_packages("[]").is_err(),
|
||||
"空列表要报错而不是造一个空包"
|
||||
);
|
||||
assert!(parse_packages("<html>502</html>").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_gives_a_trustworthy_sha256_only() {
|
||||
let json = r#"{"package_uuid":"x","size":211151000,
|
||||
"sha256_hash":"6B0E2B0F4C1D2A3E4F5061728394A5B6C7D8E9F0A1B2C3D4E5F60718293A4B5C"}"#;
|
||||
assert_eq!(
|
||||
parse_detail_sha256(json).expect("大写哈希应被接受并转小写"),
|
||||
"6b0e2b0f4c1d2a3e4f5061728394a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c"
|
||||
);
|
||||
assert!(parse_detail_sha256(r#"{"size":1}"#).is_err());
|
||||
assert!(parse_detail_sha256("not json").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! `.minecraft` 实例目录布局:`versions/` `libraries/` `assets/` + ULL 扩展 `.ull/`。
|
||||
//!
|
||||
//! 依据:`DevDocs/analyse/03-version-download.md` §6.8(目录布局速查)、
|
||||
//! `DevDocs/PlanDoc/04-data-and-config.md` §2(`.minecraft/` 遵循 Mojang 标准 +
|
||||
//! ULL 扩展 `.ull/`)。
|
||||
//!
|
||||
//! 布局是**纯路径计算**:不建目录、不写文件。所有路径规则集中在这里,
|
||||
//! 这样将来要支持 MultiMC 家族布局(`instances/<id>/minecraft/`)只需再加一个实现
|
||||
//! (analyse/01 §3.3 的 `GameRepositoryLayout` 抽象意义)。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::error::GameError;
|
||||
use super::library::MavenCoordinate;
|
||||
|
||||
/// 一个游戏根目录(通常就是 `.minecraft/`)的布局规则。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GameLayout {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl GameLayout {
|
||||
/// 以给定根目录构造布局。
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
|
||||
/// 根目录。
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// `versions/`。
|
||||
pub fn versions_dir(&self) -> PathBuf {
|
||||
self.root.join("versions")
|
||||
}
|
||||
|
||||
/// `libraries/`。
|
||||
pub fn libraries_dir(&self) -> PathBuf {
|
||||
self.root.join("libraries")
|
||||
}
|
||||
|
||||
/// `assets/`。
|
||||
pub fn assets_dir(&self) -> PathBuf {
|
||||
self.root.join("assets")
|
||||
}
|
||||
|
||||
/// `assets/indexes/`。
|
||||
pub fn indexes_dir(&self) -> PathBuf {
|
||||
self.assets_dir().join("indexes")
|
||||
}
|
||||
|
||||
/// `assets/objects/`(两级分片,避开 NTFS 单目录塌陷)。
|
||||
pub fn objects_dir(&self) -> PathBuf {
|
||||
self.assets_dir().join("objects")
|
||||
}
|
||||
|
||||
/// `assets/log_configs/`(日志配置缓存)。
|
||||
pub fn log_configs_dir(&self) -> PathBuf {
|
||||
self.assets_dir().join("log_configs")
|
||||
}
|
||||
|
||||
/// 实例根目录 `versions/<id>/`。
|
||||
pub fn version_dir(&self, id: &str) -> PathBuf {
|
||||
self.versions_dir().join(id)
|
||||
}
|
||||
|
||||
/// 实例清单 `versions/<id>/<id>.json`。
|
||||
pub fn version_json(&self, id: &str) -> PathBuf {
|
||||
self.version_dir(id).join(format!("{id}.json"))
|
||||
}
|
||||
|
||||
/// 实例 jar `versions/<id>/<id>.jar`。
|
||||
pub fn version_jar(&self, id: &str) -> PathBuf {
|
||||
self.version_dir(id).join(format!("{id}.jar"))
|
||||
}
|
||||
|
||||
/// natives 解压目录 `versions/<id>/natives/`(ULL 约定,HMCL 用 `natives/<ver>`)。
|
||||
pub fn natives_dir(&self, id: &str) -> PathBuf {
|
||||
self.version_dir(id).join("natives")
|
||||
}
|
||||
|
||||
/// 实例私有状态目录 `versions/<id>/.ull/`(上次会话日志索引、安装收据等)。
|
||||
pub fn version_state_dir(&self, id: &str) -> PathBuf {
|
||||
self.version_dir(id).join(".ull")
|
||||
}
|
||||
|
||||
/// 实例私有库目录 `versions/<id>/libraries/`(HMCL `hint=local` 的落点)。
|
||||
pub fn version_libraries_dir(&self, id: &str) -> PathBuf {
|
||||
self.version_dir(id).join("libraries")
|
||||
}
|
||||
|
||||
/// 启动器全局私有目录 `<root>/.ull/`。
|
||||
pub fn ull_root(&self) -> PathBuf {
|
||||
self.root.join(".ull")
|
||||
}
|
||||
|
||||
/// 资源索引 `assets/indexes/<assetsId>.json`。
|
||||
pub fn asset_index(&self, assets_id: &str) -> PathBuf {
|
||||
self.indexes_dir().join(format!("{assets_id}.json"))
|
||||
}
|
||||
|
||||
/// 资源对象 `assets/objects/<hash[0..2]>/<hash>`。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 哈希短于两个字符时返回 [`GameError::InvalidAssetHash`]。
|
||||
pub fn asset_object(&self, hash: &str) -> Result<PathBuf, GameError> {
|
||||
let shard = hash.get(0..2).ok_or_else(|| GameError::InvalidAssetHash {
|
||||
hash: hash.to_owned(),
|
||||
})?;
|
||||
Ok(self.objects_dir().join(shard).join(hash))
|
||||
}
|
||||
|
||||
/// 公共库 `libraries/<group path>/<artifact>-<version>[-<classifier>].<ext>`。
|
||||
pub fn library(&self, coordinate: &MavenCoordinate) -> PathBuf {
|
||||
self.libraries_dir().join(coordinate.relative_path())
|
||||
}
|
||||
|
||||
/// 实例私有库 `versions/<id>/libraries/...`。
|
||||
pub fn version_library(&self, version_id: &str, coordinate: &MavenCoordinate) -> PathBuf {
|
||||
self.version_libraries_dir(version_id)
|
||||
.join(coordinate.relative_path())
|
||||
}
|
||||
|
||||
/// 日志配置文件 `assets/log_configs/<id>`。
|
||||
pub fn log_config(&self, file_id: &str) -> PathBuf {
|
||||
self.log_configs_dir().join(file_id)
|
||||
}
|
||||
|
||||
/// 该版本是否已安装(判据:清单文件存在)。
|
||||
pub fn is_version_installed(&self, id: &str) -> bool {
|
||||
self.version_json(id).is_file()
|
||||
}
|
||||
|
||||
/// 列出已安装的版本 id(有 `versions/<id>/<id>.json` 的目录),按字典序排列。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 读 `versions/` 失败时返回 [`GameError::Io`];目录不存在视为空列表。
|
||||
pub fn installed_versions(&self) -> Result<Vec<String>, GameError> {
|
||||
let dir = self.versions_dir();
|
||||
let entries = match std::fs::read_dir(&dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(e) => return Err(GameError::io(&dir, e)),
|
||||
};
|
||||
let mut ids = Vec::new();
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| GameError::io(&dir, e))?;
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if path.join(format!("{name}.json")).is_file() {
|
||||
ids.push(name.to_owned());
|
||||
}
|
||||
}
|
||||
ids.sort();
|
||||
Ok(ids)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//! 依赖库模型:Maven 坐标、natives 选择、库文件相对路径。
|
||||
//!
|
||||
//! 依据:`DevDocs/analyse/03-version-download.md` §6.4(libraries 含 natives 规则)、
|
||||
//! §6.8(目录布局速查)。
|
||||
//!
|
||||
//! 这里只做"数据 → 路径/候选"的纯计算,**不碰网络与文件系统**:
|
||||
//! 真正的下载归 `core::download`,放置归 [`super::layout`]。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::arguments::{rules_allow, Environment, Rule};
|
||||
use super::error::GameError;
|
||||
use super::manifest::DownloadInfo;
|
||||
|
||||
/// 一条依赖库。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Library {
|
||||
/// Maven 坐标 `group:artifact:version[:classifier][@ext]`。
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
/// 下载信息;老 JSON/魔改 JSON 可能没有。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub downloads: Option<LibraryDownloads>,
|
||||
/// natives 选择表:`{ "windows": "natives-windows-${arch}" }`。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub natives: Option<BTreeMap<String, String>>,
|
||||
/// 生效规则(空 = 生效)。
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub rules: Vec<Rule>,
|
||||
/// natives 解压规则。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub extract: Option<Extract>,
|
||||
/// Maven 仓库基地址覆盖。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
/// HMCL 私有:`local` 表示库放在实例私有的 `versions/<id>/libraries/` 下。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub hint: Option<String>,
|
||||
/// 未识别字段原样保留。
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 一条依赖库的下载表。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LibraryDownloads {
|
||||
/// 主构件。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub artifact: Option<DownloadInfo>,
|
||||
/// 分类器构件(natives 走这里)。
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub classifiers: BTreeMap<String, DownloadInfo>,
|
||||
}
|
||||
|
||||
/// natives 解压规则:命中 `exclude` 的条目不解压。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Extract {
|
||||
/// 排除的路径模式(`META-INF/`)。
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub exclude: Vec<String>,
|
||||
}
|
||||
|
||||
/// Maven 坐标(`group:artifact:version[:classifier][@ext]`)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MavenCoordinate {
|
||||
/// group,如 `org.lwjgl`。
|
||||
pub group: String,
|
||||
/// artifact,如 `lwjgl`。
|
||||
pub artifact: String,
|
||||
/// version,如 `3.3.2`。
|
||||
pub version: String,
|
||||
/// classifier(natives 常用),如 `natives-windows`。
|
||||
pub classifier: Option<String>,
|
||||
/// 扩展名,缺省 `jar`。
|
||||
pub extension: String,
|
||||
}
|
||||
|
||||
impl MavenCoordinate {
|
||||
/// 解析坐标字符串。
|
||||
pub fn parse(raw: &str) -> Result<Self, GameError> {
|
||||
let invalid = |reason: &'static str| GameError::InvalidCoordinate {
|
||||
raw: raw.to_owned(),
|
||||
reason,
|
||||
};
|
||||
let (body, extension) = match raw.split_once('@') {
|
||||
Some((body, ext)) if !ext.is_empty() => (body, ext.to_owned()),
|
||||
Some(_) => return Err(invalid("扩展名为空")),
|
||||
None => (raw, String::from("jar")),
|
||||
};
|
||||
let parts: Vec<&str> = body.split(':').collect();
|
||||
if parts.len() < 3 {
|
||||
return Err(invalid("至少需要 group:artifact:version 三段"));
|
||||
}
|
||||
if parts.iter().any(|p| p.is_empty()) {
|
||||
return Err(invalid("存在空字段"));
|
||||
}
|
||||
match parts.len() {
|
||||
3 => Ok(Self {
|
||||
group: parts[0].to_owned(),
|
||||
artifact: parts[1].to_owned(),
|
||||
version: parts[2].to_owned(),
|
||||
classifier: None,
|
||||
extension,
|
||||
}),
|
||||
4 => Ok(Self {
|
||||
group: parts[0].to_owned(),
|
||||
artifact: parts[1].to_owned(),
|
||||
version: parts[2].to_owned(),
|
||||
classifier: Some(parts[3].to_owned()),
|
||||
extension,
|
||||
}),
|
||||
_ => Err(invalid("字段多于四段")),
|
||||
}
|
||||
}
|
||||
|
||||
/// group 转目录形式(`org.lwjgl` → `org/lwjgl`)。
|
||||
pub fn group_path(&self) -> String {
|
||||
self.group.replace('.', "/")
|
||||
}
|
||||
|
||||
/// 文件名:`<artifact>-<version>[-<classifier>].<ext>`。
|
||||
pub fn file_name(&self) -> String {
|
||||
match &self.classifier {
|
||||
Some(classifier) => format!(
|
||||
"{}-{}-{}.{}",
|
||||
self.artifact, self.version, classifier, self.extension
|
||||
),
|
||||
None => format!("{}-{}.{}", self.artifact, self.version, self.extension),
|
||||
}
|
||||
}
|
||||
|
||||
/// 相对 `libraries/` 的路径:
|
||||
/// `<group path>/<artifact>/<version>/<artifact>-<version>[-<classifier>].<ext>`。
|
||||
pub fn relative_path(&self) -> PathBuf {
|
||||
PathBuf::from(self.group_path())
|
||||
.join(&self.artifact)
|
||||
.join(&self.version)
|
||||
.join(self.file_name())
|
||||
}
|
||||
|
||||
/// 换一个 classifier 的副本(natives 用)。
|
||||
pub fn with_classifier(&self, classifier: impl Into<String>) -> Self {
|
||||
Self {
|
||||
classifier: Some(classifier.into()),
|
||||
..self.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Library {
|
||||
/// 解析本库的 Maven 坐标。
|
||||
pub fn coordinate(&self) -> Result<MavenCoordinate, GameError> {
|
||||
MavenCoordinate::parse(&self.name)
|
||||
}
|
||||
|
||||
/// 该库在当前环境下是否生效。
|
||||
pub fn applies(&self, env: &Environment) -> bool {
|
||||
rules_allow(&self.rules, env)
|
||||
}
|
||||
|
||||
/// 是否是实例私有库(HMCL `hint=local`)。
|
||||
pub fn is_local(&self) -> bool {
|
||||
self.hint.as_deref() == Some("local")
|
||||
}
|
||||
|
||||
/// 主构件下载信息。
|
||||
pub fn artifact(&self) -> Option<&DownloadInfo> {
|
||||
self.downloads.as_ref()?.artifact.as_ref()
|
||||
}
|
||||
|
||||
/// natives 分类器名:按 OS 查 `natives` 表并展开 `${arch}`(64 位为 `64`)。
|
||||
pub fn native_classifier(&self, env: &Environment) -> Option<String> {
|
||||
let raw = self.natives.as_ref()?.get(&env.os)?;
|
||||
let arch = if matches!(env.arch.as_str(), "x86_64" | "arm64") {
|
||||
"64"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
Some(raw.replace("${arch}", arch))
|
||||
}
|
||||
|
||||
/// natives 构件下载信息(按分类器查 `downloads.classifiers`)。
|
||||
pub fn native_artifact(&self, classifier: &str) -> Option<&DownloadInfo> {
|
||||
self.downloads.as_ref()?.classifiers.get(classifier)
|
||||
}
|
||||
|
||||
/// 主构件相对 `libraries/` 的路径。
|
||||
///
|
||||
/// 优先用 JSON 里给出的 `artifact.path`(Mojang 官方的权威值),否则按坐标推导。
|
||||
pub fn relative_path(&self) -> Result<PathBuf, GameError> {
|
||||
if let Some(path) = self.artifact().and_then(|a| a.path.as_deref()) {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
Ok(self.coordinate()?.relative_path())
|
||||
}
|
||||
|
||||
/// natives 构件相对 `libraries/` 的路径。
|
||||
pub fn native_relative_path(&self, classifier: &str) -> Result<PathBuf, GameError> {
|
||||
if let Some(path) = self
|
||||
.native_artifact(classifier)
|
||||
.and_then(|a| a.path.as_deref())
|
||||
{
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
Ok(self
|
||||
.coordinate()?
|
||||
.with_classifier(classifier)
|
||||
.relative_path())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! 版本 JSON(`versions/<id>/<id>.json`)的数据模型。
|
||||
//!
|
||||
//! 依据:`DevDocs/analyse/03-version-download.md` §4.1(Manifest + Patch 两层模型)、
|
||||
//! §4.2(宽松解析 + `rawJson` 保真)、§4.6(旧版 JSON 的四个特殊分支)。
|
||||
//!
|
||||
//! 一个类型吃两种形态:磁盘上的实例清单(`root`/`hidden`/`patches`)与可合并的补丁层
|
||||
//! (多一个 `priority`)。合并语义不在这里,而在 [`super::inherit`]——模型只管形状。
|
||||
//!
|
||||
//! **保真**:未知字段全部落进 `extra`(`#[serde(flatten)]`),回写时原样带出。
|
||||
//! 第三方启动器的私有字段(`MMC-*`、`tlauncherVersion`…)因此不会丢。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::arguments::Arguments;
|
||||
use super::error::GameError;
|
||||
use super::library::Library;
|
||||
use super::version::ReleaseType;
|
||||
|
||||
/// 单个文件的下载信息(`downloads.client`、库 artifact、资源索引、日志配置都用它)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DownloadInfo {
|
||||
/// 内容 id(资源索引/日志配置用;例如日志配置的 `client-1.12.xml`)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
/// SHA-1 校验值。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sha1: Option<String>,
|
||||
/// 字节数。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<u64>,
|
||||
/// 下载地址(老版本的版本 JSON 可能完全没有)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
/// 库文件相对 `libraries/` 的路径(Mojang 在 artifact 里给出)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
impl DownloadInfo {
|
||||
/// URL 是否可用。
|
||||
pub fn has_url(&self) -> bool {
|
||||
self.url.as_deref().is_some_and(|u| !u.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本 JSON 的 `downloads` 对象:`client` / `server` / `client_mappings` / `server_mappings`。
|
||||
///
|
||||
/// 用"字符串键 + 映射"而不是枚举,是因为魔改 JSON 里会出现未知键
|
||||
/// (analyse/03 §4.2:HMCL 对未知键 `catch` 后忽略),枚举键会让整份 JSON 解析失败。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Downloads(pub BTreeMap<String, DownloadInfo>);
|
||||
|
||||
impl Downloads {
|
||||
/// 客户端 jar 的下载信息。
|
||||
pub fn client(&self) -> Option<&DownloadInfo> {
|
||||
self.0.get("client")
|
||||
}
|
||||
|
||||
/// 服务端 jar 的下载信息。
|
||||
pub fn server(&self) -> Option<&DownloadInfo> {
|
||||
self.0.get("server")
|
||||
}
|
||||
|
||||
/// 按原始键取。
|
||||
pub fn get(&self, key: &str) -> Option<&DownloadInfo> {
|
||||
self.0.get(key)
|
||||
}
|
||||
|
||||
/// 条目数。
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
/// 是否为空。
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 日志配置(`logging.client`)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LoggingInfo {
|
||||
/// JVM 参数模板,含 `${path}` 占位符。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub argument: Option<String>,
|
||||
/// 配置文件本体。
|
||||
#[serde(default)]
|
||||
pub file: DownloadInfo,
|
||||
/// `log4j2-xml` 之类。
|
||||
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
|
||||
pub kind: Option<String>,
|
||||
}
|
||||
|
||||
/// 版本 JSON 的 `logging` 对象(键通常是 `client`)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Logging(pub BTreeMap<String, LoggingInfo>);
|
||||
|
||||
impl Logging {
|
||||
/// 客户端日志配置。
|
||||
pub fn client(&self) -> Option<&LoggingInfo> {
|
||||
self.0.get("client")
|
||||
}
|
||||
}
|
||||
|
||||
/// `javaVersion`:组件名 + 主版本号(D13 的 MC↔Java 映射用)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JavaVersion {
|
||||
/// 官方 Java 运行时组件名,例如 `java-runtime-gamma`。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub component: Option<String>,
|
||||
/// 主版本号,例如 `17`。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub major_version: Option<u32>,
|
||||
}
|
||||
|
||||
/// `assetIndex`:资源索引的引用。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AssetIndexRef {
|
||||
/// 索引 id(`1.20`、`legacy`…),对应 `assets/indexes/<id>.json`。
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
/// 索引文件 SHA-1。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sha1: Option<String>,
|
||||
/// 索引文件字节数。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<u64>,
|
||||
/// 全部对象的总字节数(`totalSize`,进度估算用)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub total_size: Option<u64>,
|
||||
/// 索引下载地址。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
/// 一份版本 JSON(磁盘上的 `<id>.json`,或一个可合并的补丁层)。
|
||||
///
|
||||
/// 字段几乎全部可空——这是"容忍残缺清单"的前提(analyse/03 §4.1:HMCL 22 个字段全可空)。
|
||||
/// 解析后的不变量(由 [`super::inherit::resolve`] 保证):`inherits_from == None`、
|
||||
/// `jar` 已回退成实例 id、`patches` 已清空、`root == Some(true)`。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct VersionJson {
|
||||
/// 版本 id。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
/// 展示名(部分整合包写入)。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// 继承的父版本 id。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub inherits_from: Option<String>,
|
||||
/// 启动用的 jar 所在版本 id;`None` 表示用本实例的 jar。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub jar: Option<String>,
|
||||
/// 主类。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub main_class: Option<String>,
|
||||
/// 旧版单串游戏参数(pre-1.13),与 `arguments` 并存。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub minecraft_arguments: Option<String>,
|
||||
/// 新版结构化参数。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub arguments: Option<Arguments>,
|
||||
/// 资源版本名(`1.20`、`legacy`…);缺失按 `legacy` 处理(analyse/03 §4.6)。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub assets: Option<String>,
|
||||
/// 资源索引引用。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub asset_index: Option<AssetIndexRef>,
|
||||
/// 兼容级别。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub compliance_level: Option<u32>,
|
||||
/// 需要的 Java 版本。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub java_version: Option<JavaVersion>,
|
||||
/// 依赖库列表。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub libraries: Option<Vec<Library>>,
|
||||
/// 下载信息。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub downloads: Option<Downloads>,
|
||||
/// 日志配置。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub logging: Option<Logging>,
|
||||
/// 版本分类。
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
pub version_type: Option<ReleaseType>,
|
||||
/// 最后修改时间。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub time: Option<String>,
|
||||
/// 发布时间。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub release_time: Option<String>,
|
||||
/// 启动器最低版本要求。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub minimum_launcher_version: Option<u32>,
|
||||
/// 是否为根清单(HMCL 私有字段)。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub root: Option<bool>,
|
||||
/// 是否在 UI 中隐藏(HMCL 私有字段)。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hidden: Option<bool>,
|
||||
/// 补丁层,按 `priority` 升序合并(HMCL 私有字段)。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub patches: Option<Vec<VersionJson>>,
|
||||
/// 补丁优先级;`None` 视作最小值(最先合并,analyse/03 §4.1)。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub priority: Option<i32>,
|
||||
/// 未识别的字段,原样保留(`rawJson` 保真)。
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl VersionJson {
|
||||
/// 解析一份版本 JSON。
|
||||
pub fn from_json_str(json: &str) -> Result<Self, GameError> {
|
||||
serde_json::from_str(json).map_err(|e| GameError::json("version_json", e))
|
||||
}
|
||||
|
||||
/// 从字节解析(直接吃 HTTP 响应体)。
|
||||
pub fn from_json_slice(bytes: &[u8]) -> Result<Self, GameError> {
|
||||
serde_json::from_slice(bytes).map_err(|e| GameError::json("version_json", e))
|
||||
}
|
||||
|
||||
/// 序列化回 JSON。
|
||||
pub fn to_json_string(&self) -> Result<String, GameError> {
|
||||
serde_json::to_string(self).map_err(|e| GameError::json("version_json", e))
|
||||
}
|
||||
|
||||
/// 取 `id`,缺失则报错。
|
||||
pub fn require_id(&self) -> Result<&str, GameError> {
|
||||
self.id.as_deref().ok_or(GameError::MissingVersionId)
|
||||
}
|
||||
|
||||
/// 客户端 jar 的下载信息;老版本 JSON 没有 `downloads.client`(analyse/03 §4.6),
|
||||
/// 兜底 URL 由 download/launch 侧按镜像表拼,不在模型里硬编码 BMCLAPI。
|
||||
pub fn client_jar(&self) -> Option<&DownloadInfo> {
|
||||
self.downloads.as_ref().and_then(Downloads::client)
|
||||
}
|
||||
|
||||
/// 资源版本名,缺失时按 `legacy`(analyse/03 §4.6 第 3 条)。
|
||||
pub fn assets_id(&self) -> &str {
|
||||
self.assets.as_deref().unwrap_or("legacy")
|
||||
}
|
||||
|
||||
/// 是否已是被解析过的扁平清单。
|
||||
pub fn is_resolved(&self) -> bool {
|
||||
self.inherits_from.is_none() && self.patches.is_none() && self.jar.is_some()
|
||||
}
|
||||
|
||||
/// 版本是否带 Vulkan 后端(`libraries` 含 `org.lwjgl:lwjgl-vulkan`)。
|
||||
///
|
||||
/// 只是**必要条件**:`26.2-snapshot-1` 有库但还没有 `--graphicsBackend`
|
||||
/// (差两天,实测),下发门限见 [`crate::game::capability`] 的模块文档。
|
||||
pub fn has_vulkan_backend(&self) -> bool {
|
||||
super::capability::VersionCapabilities::from_json(self).has_vulkan_backend()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! 版本与实例仓库:不可变快照 + 单写者草稿、版本 JSON 继承解析、实例目录布局。
|
||||
//!
|
||||
//! 设计定稿:`DevDocs/PlanDoc/04-data-and-config.md` §4(实例模型)、
|
||||
//! `DevDocs/PlanDoc/01-tech-stack-and-architecture.md` §3(Snapshot/Draft 纪律),
|
||||
//! 源证据 `DevDocs/analyse/01-architecture-overview.md` §3.3。
|
||||
//!
|
||||
//! 与其它模块的契约:快照(Snapshot)类型与继承解析结果要与 launch 对齐
|
||||
//! (`DevDocs/SubAgentsTalk/README.md` 的对齐点;详细约定见 `SubAgentsTalk/game-接口约定.md`)。
|
||||
//!
|
||||
//! 公共面(M14):
|
||||
//! - 版本清单:[`VersionList`] / [`RemoteVersion`] / [`ReleaseType`] / [`VersionCategory`],
|
||||
//! 外加内嵌补充清单 [`unlisted`];
|
||||
//! - 版本 JSON 与继承:[`VersionJson`]、[`resolve`]、[`merge`]、[`apply_patches`];
|
||||
//! - 参数与库:[`Arguments`] / [`Environment`] / [`Rule`]、[`Library`] / [`MavenCoordinate`];
|
||||
//! - 能力位:[`capability`](版本 JSON 库表 → `has_vulkan_backend`);
|
||||
//! - 实例:目录布局 [`GameLayout`]、档案 [`ProfileList`] / [`InstanceProfile`] / [`ProfileId`];
|
||||
//! - 发布:快照仓库 [`Store`] / [`Snapshot`] / [`Draft`]。
|
||||
//!
|
||||
//! 安装层(M27,[`install`]):版本链 → 下载计划 → 分区落盘 → 实例登记。
|
||||
//! 它把上面这些"模型"串成可执行的流程,但**自己不碰 UI、不拼镜像 URL**:
|
||||
//! 下载全部交给 `core::download` 的引擎(镜像/健康度/续传/校验都在那边)。
|
||||
//!
|
||||
//! Java 管理(M33,[`java`]):托管 JDK 的查询/下载/解压/探测(`.ULL/data/java-managed/`)
|
||||
//! 与 MC↔Java 映射表。同样只借 `core::download` 的引擎做下载,自己不拼镜像 URL。
|
||||
//!
|
||||
//! 模块分工按"数据形状 / 纯计算 / 状态"切开,任何模块都不碰网络与 UI:
|
||||
//! 下载归 `core::download`、进程归 `core::launch`、配置落盘归 `core::setting`。
|
||||
|
||||
pub mod arguments;
|
||||
pub mod capability;
|
||||
pub mod error;
|
||||
pub mod inherit;
|
||||
pub mod install;
|
||||
pub mod isolation;
|
||||
pub mod java;
|
||||
pub mod layout;
|
||||
pub mod library;
|
||||
pub mod manifest;
|
||||
pub mod profiles;
|
||||
pub mod snapshot;
|
||||
pub mod unlisted;
|
||||
pub mod version;
|
||||
|
||||
pub use arguments::{rules_allow, Arguments, Environment, ExpandedArguments, Rule, RuleAction};
|
||||
pub use capability::{
|
||||
is_vulkan_library, library_names_have_vulkan, VersionCapabilities, VULKAN_LIBRARY_ARTIFACT,
|
||||
VULKAN_LIBRARY_GROUP,
|
||||
};
|
||||
pub use error::GameError;
|
||||
pub use inherit::{apply_patches, merge, resolve, sorted_patches, VersionSource};
|
||||
pub use isolation::{
|
||||
derive_game_dir, observe_mode, probe_both_sides, probe_side, Derivation, IsolationMode,
|
||||
IsolationSource, ModeChange, ModeChangeProbe, ModeChangeWarning, SideContent,
|
||||
PROBED_CONTENT_DIRS, SHARED_LAYERS,
|
||||
};
|
||||
pub use layout::GameLayout;
|
||||
pub use library::{Library, MavenCoordinate};
|
||||
pub use manifest::{AssetIndexRef, DownloadInfo, Downloads, JavaVersion, Logging, VersionJson};
|
||||
pub use profiles::{InstanceProfile, ProfileId, ProfileList, PROFILE_SCHEMA_VERSION};
|
||||
pub use snapshot::{Draft, Snapshot, Store};
|
||||
pub use version::{
|
||||
LatestVersions, ReleaseType, RemoteVersion, VersionCategory, VersionList,
|
||||
VERSION_MANIFEST_V2_URL,
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
//! 实例清单(`profiles.json`)模型:稳定 UUID + 实例档案。
|
||||
//!
|
||||
//! 依据:`DevDocs/PlanDoc/04-data-and-config.md` §4(实例模型预告)、§2(`.ULL/config/profiles.json`)。
|
||||
//!
|
||||
//! 两条硬约束(来自 analyse/01 §7.6 的教训):
|
||||
//! 1. **实例 id 用稳定 UUID,不用路径**——路径会变(用户挪目录、迁移数据目录),
|
||||
//! 而 id 一旦写进档案就不能变;
|
||||
//! 2. 档案只存**绑定关系**(版本 id、独立目录、Java 覆盖),不存派生的绝对路径结论;
|
||||
//! 全局/实例/临时三层设置的合并归 `core::setting`,这里不重复。
|
||||
//!
|
||||
//! 落盘归 `core::setting` 的原子写 + schema 迁移(M12),本模块只提供模型与不变量。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// `profiles.json` 的 schema 版本(迁移链由 `core::setting` 驱动)。
|
||||
pub const PROFILE_SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// 实例 id:UUID 形状的稳定标识。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ProfileId(String);
|
||||
|
||||
impl ProfileId {
|
||||
/// 生成一个新 id(v4 形状)。
|
||||
///
|
||||
/// **不是密码学随机**:由纳秒时间戳 + 进程 id + 进程内计数器经 SplitMix64 混合而来,
|
||||
/// 足以保证单机唯一;删除实例后不复用即可。真要密码学随机,阶段 B 引 `uuid` crate。
|
||||
pub fn generate() -> Self {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_nanos() as u64);
|
||||
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let mut seed = nanos ^ u64::from(std::process::id()).rotate_left(17) ^ seq.rotate_left(41);
|
||||
|
||||
let mut bytes = [0u8; 16];
|
||||
for chunk in bytes.chunks_mut(8) {
|
||||
seed = split_mix64(seed);
|
||||
let source = seed.to_le_bytes();
|
||||
chunk.copy_from_slice(&source[..chunk.len()]);
|
||||
}
|
||||
// RFC 4122 v4 形状:版本位 + 变体位。
|
||||
bytes[6] = (bytes[6] & 0x0F) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3F) | 0x80;
|
||||
|
||||
let mut hex = String::with_capacity(36);
|
||||
for (index, byte) in bytes.iter().enumerate() {
|
||||
if matches!(index, 4 | 6 | 8 | 10) {
|
||||
hex.push('-');
|
||||
}
|
||||
hex.push_str(&format!("{byte:02x}"));
|
||||
}
|
||||
Self(hex)
|
||||
}
|
||||
|
||||
/// 从字符串构造(不校验,用于读回历史档案;校验用 [`ProfileId::is_uuid_shaped`])。
|
||||
pub fn new(raw: impl Into<String>) -> Self {
|
||||
Self(raw.into())
|
||||
}
|
||||
|
||||
/// 字符串视图。
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// 是否是 UUID 形状(`8-4-4-4-12` 十六进制)。
|
||||
pub fn is_uuid_shaped(&self) -> bool {
|
||||
let bytes = self.0.as_bytes();
|
||||
if bytes.len() != 36 {
|
||||
return false;
|
||||
}
|
||||
bytes.iter().enumerate().all(|(index, byte)| match index {
|
||||
8 | 13 | 18 | 23 => *byte == b'-',
|
||||
_ => byte.is_ascii_hexdigit(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn split_mix64(mut state: u64) -> u64 {
|
||||
state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = state;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
impl fmt::Display for ProfileId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ProfileId {
|
||||
type Err = std::convert::Infallible;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Ok(Self(s.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ProfileId {
|
||||
fn from(value: String) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个实例档案。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct InstanceProfile {
|
||||
/// 稳定 id。
|
||||
pub id: ProfileId,
|
||||
/// 展示名。
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
/// 绑定的版本 id(`versions/<id>/`)。
|
||||
#[serde(default)]
|
||||
pub version_id: String,
|
||||
/// 独立游戏目录;`None` 表示使用全局 `.minecraft`。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub game_dir: Option<PathBuf>,
|
||||
/// 工作目录覆盖(HMCL 的"版本隔离")。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub running_dir: Option<PathBuf>,
|
||||
/// Java 路径覆盖。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub java_path: Option<PathBuf>,
|
||||
/// 创建时间(RFC-3339 字符串,由调用方填写)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created_at: Option<String>,
|
||||
/// 最近启动时间。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_played_at: Option<String>,
|
||||
/// 未识别字段原样保留(向前兼容)。
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl InstanceProfile {
|
||||
/// 建一个最小档案(新实例默认值)。
|
||||
pub fn new(name: impl Into<String>, version_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: ProfileId::generate(),
|
||||
name: name.into(),
|
||||
version_id: version_id.into(),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `profiles.json` 的内容。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ProfileList {
|
||||
/// schema 版本。
|
||||
#[serde(default = "default_schema_version")]
|
||||
pub schema_version: u32,
|
||||
/// 档案列表(顺序即 UI 顺序)。
|
||||
#[serde(default)]
|
||||
pub profiles: Vec<InstanceProfile>,
|
||||
}
|
||||
|
||||
fn default_schema_version() -> u32 {
|
||||
PROFILE_SCHEMA_VERSION
|
||||
}
|
||||
|
||||
impl Default for ProfileList {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
schema_version: PROFILE_SCHEMA_VERSION,
|
||||
profiles: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProfileList {
|
||||
/// 按 id 查。
|
||||
pub fn get(&self, id: &ProfileId) -> Option<&InstanceProfile> {
|
||||
self.profiles.iter().find(|p| &p.id == id)
|
||||
}
|
||||
|
||||
/// 按 id 改。
|
||||
pub fn get_mut(&mut self, id: &ProfileId) -> Option<&mut InstanceProfile> {
|
||||
self.profiles.iter_mut().find(|p| &p.id == id)
|
||||
}
|
||||
|
||||
/// 插入或替换同 id 档案,返回被替换的旧档案。
|
||||
pub fn upsert(&mut self, profile: InstanceProfile) -> Option<InstanceProfile> {
|
||||
match self.profiles.iter().position(|p| p.id == profile.id) {
|
||||
Some(index) => Some(std::mem::replace(&mut self.profiles[index], profile)),
|
||||
None => {
|
||||
self.profiles.push(profile);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除档案,返回被删的档案。
|
||||
pub fn remove(&mut self, id: &ProfileId) -> Option<InstanceProfile> {
|
||||
let index = self.profiles.iter().position(|p| &p.id == id)?;
|
||||
Some(self.profiles.remove(index))
|
||||
}
|
||||
|
||||
/// 绑定到某版本的档案(同一版本可以有多个实例:隔离目录不同)。
|
||||
pub fn for_version(&self, version_id: &str) -> Vec<&InstanceProfile> {
|
||||
self.profiles
|
||||
.iter()
|
||||
.filter(|p| p.version_id == version_id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 档案数。
|
||||
pub fn len(&self) -> usize {
|
||||
self.profiles.len()
|
||||
}
|
||||
|
||||
/// 是否没有档案。
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.profiles.is_empty()
|
||||
}
|
||||
|
||||
/// 解析 `profiles.json`。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// JSON 非法时返回 [`super::error::GameError::Json`]。
|
||||
pub fn from_json_str(json: &str) -> Result<Self, super::error::GameError> {
|
||||
serde_json::from_str(json).map_err(|e| super::error::GameError::json("profiles", e))
|
||||
}
|
||||
|
||||
/// 序列化(格式化输出,便于用户手改与 diff)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 序列化失败时返回 [`super::error::GameError::Json`]。
|
||||
pub fn to_json_string(&self) -> Result<String, super::error::GameError> {
|
||||
serde_json::to_string_pretty(self).map_err(|e| super::error::GameError::json("profiles", e))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! 不可变快照 + 单写者草稿(Snapshot / Draft)。
|
||||
//!
|
||||
//! 依据:`DevDocs/analyse/01-architecture-overview.md` §3.3(HMCL 最值得抄的一处设计)、
|
||||
//! `DevDocs/PlanDoc/01-tech-stack-and-architecture.md` 公理 2。
|
||||
//!
|
||||
//! 收益:**读端永远看到一整份自洽的数据**,不会出现"遍历到一半列表被改"
|
||||
//! (HMCL 老式 `synchronized` 实现的典型 bug)。写端同一时刻只允许一个草稿,
|
||||
//! 发布 = 指针交换,读端持有的旧快照继续有效直到自己放手。
|
||||
//!
|
||||
//! **实现说明**:读路径持一把共享锁,只为把 `Arc` 克隆出来(纳秒级),随后所有遍历都在锁外;
|
||||
//! 真正的无锁读需要 `arc-swap` 这类依赖,P0 不引入(core 依赖面越窄越好)。
|
||||
//! 与 HMCL 的另一处差异:**发布不需要 UI 线程桥接**——发布是纯指针交换,
|
||||
//! 订阅通知属于 app 层(`crates/app`),core 不做线程模型假设。
|
||||
|
||||
use std::fmt;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use super::error::GameError;
|
||||
|
||||
/// 一份不可变快照:克隆只是 `Arc` 计数,内容只读。
|
||||
pub struct Snapshot<T> {
|
||||
data: Arc<T>,
|
||||
}
|
||||
|
||||
impl<T> Snapshot<T> {
|
||||
/// 用给定内容构造快照。
|
||||
pub fn new(data: T) -> Self {
|
||||
Self {
|
||||
data: Arc::new(data),
|
||||
}
|
||||
}
|
||||
|
||||
/// 读内容。
|
||||
pub fn get(&self) -> &T {
|
||||
&self.data
|
||||
}
|
||||
|
||||
/// 共享同一份内容的强引用。
|
||||
pub fn shared(&self) -> Arc<T> {
|
||||
Arc::clone(&self.data)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Clone for Snapshot<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
data: Arc::clone(&self.data),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: fmt::Debug> fmt::Debug for Snapshot<T> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_tuple("Snapshot").field(&self.data).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: PartialEq> PartialEq for Snapshot<T> {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.data == other.data
|
||||
}
|
||||
}
|
||||
|
||||
/// 写权凭证:唯一的写权持有者,`drop` 即释放(发布与放弃都走它)。
|
||||
struct WriteToken<'a> {
|
||||
store: &'a AtomicBool,
|
||||
}
|
||||
|
||||
impl<'a> WriteToken<'a> {
|
||||
fn acquire(flag: &'a AtomicBool) -> Result<Self, GameError> {
|
||||
if flag
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_err()
|
||||
{
|
||||
return Err(GameError::DraftBusy);
|
||||
}
|
||||
Ok(Self { store: flag })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WriteToken<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.store.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
/// 快照仓库:持有当前快照,串行化写入。
|
||||
pub struct Store<T> {
|
||||
current: RwLock<Arc<T>>,
|
||||
writing: AtomicBool,
|
||||
}
|
||||
|
||||
impl<T: Clone> Store<T> {
|
||||
/// 以初始内容建立仓库。
|
||||
pub fn new(initial: T) -> Self {
|
||||
Self {
|
||||
current: RwLock::new(Arc::new(initial)),
|
||||
writing: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// 取当前快照(读路径:一次共享锁 + `Arc` 克隆,锁内不做别的)。
|
||||
pub fn snapshot(&self) -> Snapshot<T> {
|
||||
let guard = self.current.read().unwrap_or_else(|e| e.into_inner());
|
||||
Snapshot {
|
||||
data: Arc::clone(&guard),
|
||||
}
|
||||
}
|
||||
|
||||
/// 打开草稿(写会话)。同一时刻只允许一个,违反单写者约束返回
|
||||
/// [`GameError::DraftBusy`]。
|
||||
pub fn open_draft(&self) -> Result<Draft<'_, T>, GameError> {
|
||||
let token = WriteToken::acquire(&self.writing)?;
|
||||
let working = self.snapshot().get().clone();
|
||||
Ok(Draft {
|
||||
store: self,
|
||||
token,
|
||||
working,
|
||||
})
|
||||
}
|
||||
|
||||
/// 整表替换(加载完成后一次性发布,仍受单写者约束)。
|
||||
pub fn replace(&self, value: T) -> Result<Snapshot<T>, GameError> {
|
||||
let mut draft = self.open_draft()?;
|
||||
*draft.edit() = value;
|
||||
Ok(draft.commit())
|
||||
}
|
||||
}
|
||||
|
||||
/// 草稿:持有写权的可变副本。
|
||||
///
|
||||
/// 未 [`commit`](Draft::commit) 就 `drop` 会**放弃改动并释放写权**(不会污染快照)。
|
||||
pub struct Draft<'a, T> {
|
||||
store: &'a Store<T>,
|
||||
token: WriteToken<'a>,
|
||||
working: T,
|
||||
}
|
||||
|
||||
impl<T> Draft<'_, T> {
|
||||
/// 读草稿内容。
|
||||
pub fn view(&self) -> &T {
|
||||
&self.working
|
||||
}
|
||||
|
||||
/// 改草稿内容。
|
||||
pub fn edit(&mut self) -> &mut T {
|
||||
&mut self.working
|
||||
}
|
||||
|
||||
/// 发布:把草稿内容换成仓库当前快照,并释放写权。
|
||||
pub fn commit(self) -> Snapshot<T> {
|
||||
let Draft {
|
||||
store,
|
||||
token,
|
||||
working,
|
||||
} = self;
|
||||
let snapshot = Snapshot::new(working);
|
||||
{
|
||||
let mut guard = store.current.write().unwrap_or_else(|e| e.into_inner());
|
||||
*guard = snapshot.shared();
|
||||
}
|
||||
drop(token);
|
||||
snapshot
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: fmt::Debug> fmt::Debug for Draft<'_, T> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Draft")
|
||||
.field("working", &self.working)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! 内嵌补充清单 `unlisted-versions.json`(205 条,编进二进制)。
|
||||
//!
|
||||
//! 官方清单本来就有 `old_beta`/`old_alpha`,但**不完整**:大量 alpha/classic 构建、
|
||||
//! `pending` 实验快照、`_unobfuscated` 版本从未进官方清单。
|
||||
//! HMCL 的做法是把社区维护的补充清单编进发行包,换取"零网络依赖也能列出旧版",
|
||||
//! 依据见 `DevDocs/analyse/03-version-download.md` §2.3。
|
||||
//!
|
||||
//! 数据来源:HMCL `HMCLCore/src/main/resources/assets/game/unlisted-versions.json`
|
||||
//! (实测 205 条:`old_alpha 88` / `old_beta 16` / `snapshot 70` / `pending 20` /
|
||||
//! `unobfuscated 11`),原样拷进 `game/data/` 由 `include_str!` 内嵌。
|
||||
//!
|
||||
//! **与 HMCL 的差异**:同 id 冲突时我们让**官方条目胜出**。理由是官方 v2 清单带
|
||||
//! `sha1`/`size` 且更新鲜;HMCL 因先插 unlisted 进 `TreeSet` 而让社区条目抢了同 id,
|
||||
//! 会把官方 URL 换成社区镜像 URL(analyse/03 §2.5)。补充清单只用来补官方缺口。
|
||||
//! 后续计划(analyse/03 §2.3 建议):把该清单改为可远程更新的数据 + ETag 缓存,
|
||||
//! 内嵌版本降级为离线回退。
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use super::version::{RemoteVersion, VersionList};
|
||||
|
||||
/// 内嵌清单的原始 JSON(`crates/core/src/game/data/unlisted-versions.json`)。
|
||||
const EMBEDDED_UNLISTED_JSON: &str = include_str!("data/unlisted-versions.json");
|
||||
|
||||
/// 内嵌清单预期的条目数,用于数据完整性自检与文档口径一致。
|
||||
pub const EMBEDDED_VERSION_COUNT: usize = 205;
|
||||
|
||||
static UNLISTED: OnceLock<VersionList> = OnceLock::new();
|
||||
|
||||
/// 内嵌补充清单(进程内只解析一次)。
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// 内嵌数据是编译期常量,可解析性由 `tests/game/unlisted.rs` 守门;
|
||||
/// 若构建产物被破坏导致解析失败则 panic——这是构建错误,不是运行时输入错误。
|
||||
pub fn versions() -> &'static VersionList {
|
||||
UNLISTED.get_or_init(|| {
|
||||
VersionList::from_json_str(EMBEDDED_UNLISTED_JSON)
|
||||
.expect("内嵌 unlisted-versions.json 必须可解析")
|
||||
})
|
||||
}
|
||||
|
||||
/// 内嵌清单的条目(借用)。
|
||||
pub fn entries() -> &'static [RemoteVersion] {
|
||||
&versions().versions
|
||||
}
|
||||
|
||||
/// 把内嵌补充清单合并进给定清单,返回新增条目数。
|
||||
///
|
||||
/// 同 id 时**保留已有条目**(官方胜出,理由见模块注释);新条目按数据文件原顺序追加,
|
||||
/// 调用方如需展示顺序再调用 [`VersionList::sort_newest_first`]。
|
||||
pub fn merge_into(target: &mut VersionList) -> usize {
|
||||
target.merge_missing(entries().iter().cloned())
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
//! 版本清单(`version_manifest_v2.json`)模型、分类与合并。
|
||||
//!
|
||||
//! 设计依据:`DevDocs/analyse/03-version-download.md` §2(清单处理)、§2.4(三层分类)、
|
||||
//! §2.5(排序与相等)、§2.2(v2 的 `sha1`/`size` 校验字段)。
|
||||
//!
|
||||
//! 与 HMCL 的差异(有意为之):
|
||||
//! - **直接用 v2 端点**并保留 `sha1`/`size`,让"清单被镜像篡改/截断"在下载阶段就暴露;
|
||||
//! - 分类枚举保留 `pending`/`unobfuscated`/`modified`,不再压进 `OLD` 桶后又让过滤器不一致;
|
||||
//! - 未知 `type` 值不丢弃整条记录,落到 [`ReleaseType::Unknown`](对应 HMCL 的 null)。
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
use super::error::GameError;
|
||||
|
||||
/// Mojang 官方清单 v2 端点(带 `sha1`/`size`)。
|
||||
pub const VERSION_MANIFEST_V2_URL: &str =
|
||||
"https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
|
||||
|
||||
/// 版本 JSON 里 `type` 字段的取值,见 analyse/03 §2.4。
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
pub enum ReleaseType {
|
||||
/// 正式版。
|
||||
Release,
|
||||
/// 快照(含愚人节、`pending` 之外的实验构建)。
|
||||
Snapshot,
|
||||
/// 远古 beta。
|
||||
OldBeta,
|
||||
/// 远古 alpha(含 classic、`rd-`/`inf-` 系列归此)。
|
||||
OldAlpha,
|
||||
/// `pending`:已推送 JSON 但未正式发布的构建。
|
||||
Pending,
|
||||
/// `_unobfuscated` 形式的反混淆构建(官方 CDN 有、普通清单没有)。
|
||||
Unobfuscated,
|
||||
/// 第三方/魔改清单自定义分类。
|
||||
Modified,
|
||||
/// 未知取值——保留记录但无法分类(HMCL 里是 `null`)。
|
||||
#[default]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl ReleaseType {
|
||||
/// 从 JSON 原始字符串解析(未知值落到 `Unknown`,不报错)。
|
||||
pub fn from_json_str(raw: &str) -> Self {
|
||||
match raw {
|
||||
"release" => Self::Release,
|
||||
"snapshot" => Self::Snapshot,
|
||||
"old_beta" => Self::OldBeta,
|
||||
"old_alpha" => Self::OldAlpha,
|
||||
"pending" => Self::Pending,
|
||||
"unobfuscated" => Self::Unobfuscated,
|
||||
"modified" => Self::Modified,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// 回到 JSON 原始字符串。
|
||||
pub fn as_json_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Release => "release",
|
||||
Self::Snapshot => "snapshot",
|
||||
Self::OldBeta => "old_beta",
|
||||
Self::OldAlpha => "old_alpha",
|
||||
Self::Pending => "pending",
|
||||
Self::Unobfuscated => "unobfuscated",
|
||||
Self::Modified => "modified",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// UI 侧的归类(analyse/03 §2.4 的五态,去掉 `uncategorized` 之外的分歧)。
|
||||
pub fn category(self) -> VersionCategory {
|
||||
match self {
|
||||
Self::Release => VersionCategory::Release,
|
||||
Self::Snapshot => VersionCategory::Snapshot,
|
||||
Self::OldBeta | Self::OldAlpha | Self::Modified => VersionCategory::Old,
|
||||
Self::Pending => VersionCategory::Pending,
|
||||
Self::Unobfuscated => VersionCategory::Unobfuscated,
|
||||
Self::Unknown => VersionCategory::Uncategorized,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ReleaseType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_json_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for ReleaseType {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(self.as_json_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ReleaseType {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let raw = String::deserialize(deserializer)?;
|
||||
Ok(Self::from_json_str(&raw))
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本列表的 UI 归类(对应 HMCL `ComponentRemoteVersion.Type`,见 analyse/03 §2.4)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum VersionCategory {
|
||||
/// 正式版。
|
||||
Release,
|
||||
/// 快照。
|
||||
Snapshot,
|
||||
/// 远古版(old_beta / old_alpha / modified)。
|
||||
Old,
|
||||
/// 未正式发布的 pending 构建。
|
||||
Pending,
|
||||
/// 反混淆构建。
|
||||
Unobfuscated,
|
||||
/// 无法归类。
|
||||
Uncategorized,
|
||||
}
|
||||
|
||||
/// 清单里的 `latest` 对象。Mojang 用它标注"当前最新正式版/快照",与条目列表无关。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LatestVersions {
|
||||
/// 最新正式版 id。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub release: Option<String>,
|
||||
/// 最新快照 id。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub snapshot: Option<String>,
|
||||
}
|
||||
|
||||
/// 版本清单里的一条记录。
|
||||
///
|
||||
/// `sha1`/`size` 只在 v2 端点出现(v1 与内嵌 unlisted 清单没有),因此都是可选的。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RemoteVersion {
|
||||
/// 版本 id(`1.20.1`、`12w01a`、`a1.1.1`…)。
|
||||
pub id: String,
|
||||
/// 分类。
|
||||
#[serde(rename = "type", default)]
|
||||
pub version_type: ReleaseType,
|
||||
/// 该版本 JSON 的下载地址。
|
||||
pub url: String,
|
||||
/// 该版本 JSON 的最后修改时间(ISO-8601 字符串)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub time: Option<String>,
|
||||
/// 该版本的发布时刻(ISO-8601 字符串,排序键)。
|
||||
#[serde(
|
||||
rename = "releaseTime",
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub release_time: Option<String>,
|
||||
/// 版本 JSON 的 SHA-1(v2 才有)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sha1: Option<String>,
|
||||
/// 版本 JSON 的字节数(v2 才有)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<u64>,
|
||||
}
|
||||
|
||||
impl RemoteVersion {
|
||||
/// 该条目是否带 v2 的校验信息。
|
||||
pub fn is_verifiable(&self) -> bool {
|
||||
self.sha1.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// 一份版本清单(官方 `version_manifest_v2.json` 或内嵌 unlisted 清单)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct VersionList {
|
||||
/// `latest` 对象;unlisted 清单没有。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub latest: Option<LatestVersions>,
|
||||
/// 条目列表。
|
||||
#[serde(default)]
|
||||
pub versions: Vec<RemoteVersion>,
|
||||
}
|
||||
|
||||
impl VersionList {
|
||||
/// 解析清单 JSON。
|
||||
pub fn from_json_str(json: &str) -> Result<Self, GameError> {
|
||||
serde_json::from_str(json).map_err(|e| GameError::json("version_manifest", e))
|
||||
}
|
||||
|
||||
/// 序列化回 JSON(便于缓存落盘/测试对拍)。
|
||||
pub fn to_json_string(&self) -> Result<String, GameError> {
|
||||
serde_json::to_string(self).map_err(|e| GameError::json("version_manifest", e))
|
||||
}
|
||||
|
||||
/// 按 id 查条目。
|
||||
pub fn find(&self, id: &str) -> Option<&RemoteVersion> {
|
||||
self.versions.iter().find(|v| v.id == id)
|
||||
}
|
||||
|
||||
/// 已存在的版本 id 数。
|
||||
pub fn len(&self) -> usize {
|
||||
self.versions.len()
|
||||
}
|
||||
|
||||
/// 是否为空清单。
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.versions.is_empty()
|
||||
}
|
||||
|
||||
/// 合并补充清单:**同 id 时先插入者胜**(analyse/03 §2.5 的 `TreeSet` 语义)。
|
||||
///
|
||||
/// 调用方应先合并 unlisted 再合并官方清单,这样社区清单里那些"官方没有/官方 URL 更差"
|
||||
/// 的条目会被保留。返回新插入的条目数。
|
||||
pub fn merge_missing(&mut self, extra: impl IntoIterator<Item = RemoteVersion>) -> usize {
|
||||
let mut added = 0;
|
||||
for item in extra {
|
||||
if self.find(&item.id).is_some() {
|
||||
continue;
|
||||
}
|
||||
self.versions.push(item);
|
||||
added += 1;
|
||||
}
|
||||
added
|
||||
}
|
||||
|
||||
/// 按发布时间**倒序**排列;同时间再按 id 倒序(analyse/03 §2.5)。
|
||||
pub fn sort_newest_first(&mut self) {
|
||||
self.versions.sort_by(|a, b| {
|
||||
let at = a.release_time.as_deref().unwrap_or_default();
|
||||
let bt = b.release_time.as_deref().unwrap_or_default();
|
||||
bt.cmp(at).then_with(|| b.id.cmp(&a.id))
|
||||
});
|
||||
}
|
||||
|
||||
/// 按 UI 归类过滤(保持当前顺序)。
|
||||
pub fn filter_category(&self, category: VersionCategory) -> Vec<&RemoteVersion> {
|
||||
self.versions
|
||||
.iter()
|
||||
.filter(|v| v.version_type.category() == category)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user