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

Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
AskaEth committed 2026-10-31 21:54:10 +08:00
commit bc41dfcf7d
517 files changed
+92844

No files matched your search

+331
View File
@@ -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>,
}
+83
View File
@@ -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"}
]
}
+124
View File
@@ -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,
}
}
}
+198
View File
@@ -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,
}
}
+190
View File
@@ -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"]);
}
}
+361
View File
@@ -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(),
}
}
+351
View File
@@ -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)))
}
+258
View File
@@ -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"));
}
}
+186
View File
@@ -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;
+342
View File
@@ -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
}
+72
View File
@@ -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
}
}
+353
View File
@@ -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(&registration.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;
+285
View File
@@ -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();
}
+345
View File
@@ -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)
}
+344
View File
@@ -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));
}
}
+270
View File
@@ -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()));
}
}
+138
View File
@@ -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}"))
}
+318
View File
@@ -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())
}
+379
View File
@@ -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;
}
}
+335
View File
@@ -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())
}
+376
View File
@@ -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"
);
}
}
+261
View File
@@ -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]);
}
}
+90
View File
@@ -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};
+60
View File
@@ -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)
}
+344
View File
@@ -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")
);
}
}
+370
View File
@@ -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");
}
}
+170
View File
@@ -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);
}
}
+160
View File
@@ -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");
}
}
+199
View File
@@ -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);
}
}
+85
View File
@@ -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,
}
}
+219
View File
@@ -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()
);
}
}
}
+275
View File
@@ -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());
}
}
+165
View File
@@ -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)
}
}
+213
View File
@@ -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())
}
}
+265
View File
@@ -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()
}
}
+64
View File
@@ -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,
};
+244
View File
@@ -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))
}
}
+174
View File
@@ -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()
}
}
+54
View File
@@ -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())
}
+238
View File
@@ -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()
}
}