Unlucky Launcher v0.0.1-alpha
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent), MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
commit
bc41dfcf7d
517 files changed
+92844
No files matched your search
@@ -0,0 +1,344 @@
|
||||
//! Adoptium(Eclipse Temurin)版本查询:`api.adoptium.net` → 一个可下载的 JDK 归档。
|
||||
//!
|
||||
//! 只用 v3 的 `assets/latest/{major}/hotspot` 这一条接口(D13:自动下载只装最新 GA):
|
||||
//!
|
||||
//! ```text
|
||||
//! GET https://api.adoptium.net/v3/assets/latest/21/hotspot?os=windows&arch=x64&image_type=jdk&vendor=eclipse
|
||||
//! → [ { release_name, version{major,semver}, binary{os,architecture,image_type,package{name,link,checksum,size}} } ]
|
||||
//! ```
|
||||
//!
|
||||
//! 两个实测坑(都在 Windows x64 / Linux x64 上确认过):
|
||||
//!
|
||||
//! 1. **`arch` 参数不参与过滤**:`os=windows&arch=x64` 也会把 `aarch64` 的条目一起返回,
|
||||
//! 必须自己按 `binary.architecture` 再筛一遍,否则 ARM 包会被当成 x64 装下来;
|
||||
//! 2. **`package.link` 是 GitHub release 直链**(`github.com/adoptium/temurin21-binaries/releases/download/…`),
|
||||
//! 这正是 [`crate::download::adoptium`] 的镜像展开所认识的形态——所以院校镜像加速
|
||||
//! 不需要本模块拼任何 URL。
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::error::{JavaError, JavaErrorKind};
|
||||
use super::release::version_from_release_name;
|
||||
use super::target::AdoptiumTarget;
|
||||
use super::vendor::JdkVendor;
|
||||
|
||||
/// Adoptium API 根。
|
||||
pub const ADOPTIUM_API_BASE: &str = "https://api.adoptium.net/v3";
|
||||
|
||||
/// 托管 JDK 的默认供应商标识(D13 v1 的目录名 `jdk-<major>-vendor` 沿用它)。
|
||||
pub const JDK_VENDOR: &str = "temurin";
|
||||
|
||||
/// 归档与查询结果的结构定义在 [`super::release`](四家发行版共用);这里重导出以保持
|
||||
/// `api::JdkRelease` / `api::JdkArchive` 这两个老路径可用。
|
||||
pub use super::release::{JdkArchive, JdkRelease};
|
||||
|
||||
/// 拼 `latest/{major}` 的查询地址。
|
||||
#[must_use]
|
||||
pub fn latest_url(major: u32, target: AdoptiumTarget) -> String {
|
||||
format!(
|
||||
"{ADOPTIUM_API_BASE}/assets/latest/{major}/hotspot?os={}&arch={}&image_type=jdk&vendor=eclipse",
|
||||
target.os, target.arch
|
||||
)
|
||||
}
|
||||
|
||||
/// 解析 `assets/latest` 的响应体,挑出与目标平台匹配的那一条。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 响应体不是 JSON、列表为空、没有匹配 `os` + `arch` 的 jdk 条目、sha256 不合法时返回错误。
|
||||
pub fn parse_latest(json: &str, target: AdoptiumTarget) -> Result<JdkRelease, JavaError> {
|
||||
let assets: Vec<ApiAsset> = serde_json::from_str(json).map_err(|error| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!("Adoptium 响应不是预期 JSON:{error}"),
|
||||
)
|
||||
})?;
|
||||
let matched = assets
|
||||
.into_iter()
|
||||
.find(|asset| asset.matches(target))
|
||||
.ok_or_else(|| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!(
|
||||
"Adoptium 没有 {}/{} 的 jdk 产物(响应里 {} 条都不匹配)",
|
||||
target.os, target.arch, "全部"
|
||||
),
|
||||
)
|
||||
.with_hint("可能该大版本只有别的架构;用 `ull java list` 挑已有 JDK")
|
||||
})?;
|
||||
matched.into_release()
|
||||
}
|
||||
|
||||
/// 请求一次「某大版本的最新 GA」。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 网络不可达、HTTP 非 2xx、响应体不认识、没有对应平台产物时返回错误。
|
||||
pub async fn fetch_latest(
|
||||
client: &reqwest::Client,
|
||||
major: u32,
|
||||
target: AdoptiumTarget,
|
||||
) -> Result<JdkRelease, JavaError> {
|
||||
let url = latest_url(major, target);
|
||||
let response = client.get(&url).send().await.map_err(|error| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Network,
|
||||
format!("连不上 Adoptium API:{error}"),
|
||||
)
|
||||
.with_hint("检查网络/代理;已装的 JDK 仍可用 `ull java list` 看到")
|
||||
})?;
|
||||
let status = response.status();
|
||||
if status == reqwest::StatusCode::NOT_FOUND {
|
||||
return Err(JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!("Adoptium 没有 {major} 的 GA 产物(HTTP 404)"),
|
||||
)
|
||||
.with_hint("可自动下载的一般是 8 / 11 / 17 / 21 / 25 这类 GA 大版本"));
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(JavaError::new(
|
||||
JavaErrorKind::Network,
|
||||
format!("Adoptium API 返回 HTTP {status}({url})"),
|
||||
));
|
||||
}
|
||||
let body = response.text().await.map_err(|error| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Network,
|
||||
format!("读 Adoptium 响应失败:{error}"),
|
||||
)
|
||||
})?;
|
||||
parse_latest(&body, target)
|
||||
}
|
||||
|
||||
/// 响应里的 `package` 子对象。
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiPackage {
|
||||
name: String,
|
||||
link: String,
|
||||
#[serde(default)]
|
||||
checksum: Option<String>,
|
||||
#[serde(default)]
|
||||
size: u64,
|
||||
}
|
||||
|
||||
/// 响应里的 `binary` 子对象。
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiBinary {
|
||||
#[serde(default)]
|
||||
os: Option<String>,
|
||||
#[serde(default)]
|
||||
architecture: Option<String>,
|
||||
#[serde(default)]
|
||||
image_type: Option<String>,
|
||||
#[serde(default)]
|
||||
package: Option<ApiPackage>,
|
||||
}
|
||||
|
||||
/// 响应里的 `version` 子对象。
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiVersion {
|
||||
#[serde(default)]
|
||||
major: Option<u32>,
|
||||
#[serde(default)]
|
||||
semver: Option<String>,
|
||||
}
|
||||
|
||||
/// 响应列表里的一条。
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiAsset {
|
||||
#[serde(default)]
|
||||
release_name: String,
|
||||
#[serde(default)]
|
||||
version: Option<ApiVersion>,
|
||||
#[serde(default)]
|
||||
binary: Option<ApiBinary>,
|
||||
}
|
||||
|
||||
impl ApiAsset {
|
||||
/// 是否就是目标平台的 jdk 归档(`arch` 参数不过滤,必须自己判)。
|
||||
fn matches(&self, target: AdoptiumTarget) -> bool {
|
||||
let Some(binary) = &self.binary else {
|
||||
return false;
|
||||
};
|
||||
let same_os = binary.os.as_deref().is_some_and(|os| os == target.os);
|
||||
let same_arch = binary
|
||||
.architecture
|
||||
.as_deref()
|
||||
.is_some_and(|arch| arch == target.arch);
|
||||
let is_jdk = binary.image_type.as_deref() != Some("jre");
|
||||
same_os && same_arch && is_jdk && binary.package.is_some()
|
||||
}
|
||||
|
||||
/// 转成对外结果(校验 sha256 形态)。
|
||||
fn into_release(self) -> Result<JdkRelease, JavaError> {
|
||||
let package = self
|
||||
.binary
|
||||
.and_then(|binary| binary.package)
|
||||
.ok_or_else(|| {
|
||||
JavaError::new(JavaErrorKind::Metadata, "Adoptium 条目缺少 package 子对象")
|
||||
})?;
|
||||
let sha256 = package
|
||||
.checksum
|
||||
.as_deref()
|
||||
.map(|hex| hex.trim().to_ascii_lowercase())
|
||||
.filter(|hex| hex.len() == 64 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()))
|
||||
.ok_or_else(|| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Integrity,
|
||||
format!("Adoptium 没给可用的 sha256({})", package.name),
|
||||
)
|
||||
.with_hint("换一个大版本,或手动装 JDK 后设 JAVA_HOME")
|
||||
})?;
|
||||
let version = self.version;
|
||||
let major = version
|
||||
.as_ref()
|
||||
.and_then(|version| version.major)
|
||||
.or_else(|| major_from_name(&package.name))
|
||||
.ok_or_else(|| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!("Adoptium 条目里读不出大版本号({})", package.name),
|
||||
)
|
||||
})?;
|
||||
let native_version = version_from_release_name(&self.release_name)
|
||||
.or_else(|| version.as_ref().and_then(|version| version.semver.clone()))
|
||||
.unwrap_or_else(|| major.to_string());
|
||||
Ok(JdkRelease {
|
||||
vendor: JdkVendor::Temurin,
|
||||
major,
|
||||
version: native_version,
|
||||
semver: version.and_then(|version| version.semver),
|
||||
release_name: self.release_name,
|
||||
archive: JdkArchive {
|
||||
name: package.name,
|
||||
url: package.link,
|
||||
sha256,
|
||||
size: package.size,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 从产物名兜底读大版本(`OpenJDK21U-jdk_x64_windows_hotspot_…` → `21`)。
|
||||
fn major_from_name(name: &str) -> Option<u32> {
|
||||
let rest = name.split_once("OpenJDK")?.1;
|
||||
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
|
||||
digits.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{latest_url, parse_latest, ApiAsset};
|
||||
use crate::game::java::error::JavaErrorKind;
|
||||
use crate::game::java::target::AdoptiumTarget;
|
||||
|
||||
const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64");
|
||||
|
||||
fn sample() -> String {
|
||||
// 实测:`arch` 参数不过滤,响应里会混进别的架构。
|
||||
r#"[
|
||||
{
|
||||
"release_name": "jdk-21.0.13+9",
|
||||
"version": {"major": 21, "semver": "21.0.13+9.0.LTS"},
|
||||
"binary": {
|
||||
"os": "windows", "architecture": "aarch64", "image_type": "jdk",
|
||||
"package": {
|
||||
"name": "OpenJDK21U-jdk_aarch64_windows_hotspot_21.0.12.1_1.zip",
|
||||
"link": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_aarch64_windows_hotspot_21.0.12.1_1.zip",
|
||||
"checksum": "9dcbb8ebe24aa9ee5a47a7fab2106a7c8988d7acee461260fef107411df91dc5",
|
||||
"size": 179666944
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"release_name": "jdk-21.0.13+9",
|
||||
"version": {"major": 21, "semver": "21.0.13+9.0.LTS"},
|
||||
"binary": {
|
||||
"os": "windows", "architecture": "x64", "image_type": "jdk",
|
||||
"package": {
|
||||
"name": "OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip",
|
||||
"link": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.13%2B9/OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip",
|
||||
"checksum": "a4dbaa05fca4bd89d84584e58c459bd732efebb1722af80ab13c6516832004f9",
|
||||
"size": 190000000
|
||||
}
|
||||
}
|
||||
}
|
||||
]"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_the_matching_arch_and_keeps_the_github_link() {
|
||||
let release = parse_latest(&sample(), WINDOWS).expect("应解析出 x64 条目");
|
||||
assert_eq!(release.major, 21);
|
||||
assert_eq!(
|
||||
release.vendor,
|
||||
crate::game::java::vendor::JdkVendor::Temurin
|
||||
);
|
||||
assert_eq!(release.version, "21.0.13+9", "版本串取自 release_name");
|
||||
assert_eq!(release.release_name, "jdk-21.0.13+9");
|
||||
assert_eq!(release.semver.as_deref(), Some("21.0.13+9.0.LTS"));
|
||||
assert_eq!(
|
||||
release.archive.name,
|
||||
"OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip"
|
||||
);
|
||||
assert_eq!(release.archive.size, 190_000_000);
|
||||
assert!(release.archive.url.contains("temurin21-binaries"));
|
||||
assert_eq!(
|
||||
crate::download::adoptium::parse_adoptium_artifact(&release.archive.url)
|
||||
.map(|artifact| (artifact.major, artifact.arch, artifact.os)),
|
||||
Some(("21".to_string(), "x64".to_string(), "windows".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_arch_is_an_error_not_a_guess() {
|
||||
let error = parse_latest(&sample(), AdoptiumTarget::new("windows", "x86-32"))
|
||||
.expect_err("x86-32 应没有条目");
|
||||
assert_eq!(error.kind(), JavaErrorKind::Metadata);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_checksum_is_rejected() {
|
||||
let body = sample().replace(
|
||||
"a4dbaa05fca4bd89d84584e58c459bd732efebb1722af80ab13c6516832004f9",
|
||||
"deadbeef",
|
||||
);
|
||||
let error = parse_latest(&body, WINDOWS).expect_err("短 sha256 应被拒");
|
||||
assert_eq!(error.kind(), JavaErrorKind::Integrity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_url_carries_os_arch_and_flavor() {
|
||||
let url = latest_url(17, AdoptiumTarget::new("linux", "aarch64"));
|
||||
assert!(url.starts_with("https://api.adoptium.net/v3/assets/latest/17/hotspot?"));
|
||||
assert!(url.contains("os=linux"));
|
||||
assert!(url.contains("arch=aarch64"));
|
||||
assert!(url.contains("image_type=jdk"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_json_body_is_metadata_error() {
|
||||
let error = parse_latest("<html>502</html>", WINDOWS).expect_err("HTML 应被拒");
|
||||
assert_eq!(error.kind(), JavaErrorKind::Metadata);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn major_falls_back_to_the_artifact_name() {
|
||||
let body = r#"[{"release_name":"jdk-17.0.13+11",
|
||||
"binary":{"os":"windows","architecture":"x64","image_type":"jdk",
|
||||
"package":{"name":"OpenJDK17U-jdk_x64_windows_hotspot_17.0.13_11.zip",
|
||||
"link":"https://example.invalid/x.zip",
|
||||
"checksum":"a4dbaa05fca4bd89d84584e58c459bd732efebb1722af80ab13c6516832004f9",
|
||||
"size":1}}}]"#;
|
||||
let release = parse_latest(body, WINDOWS).expect("应解析");
|
||||
assert_eq!(release.major, 17);
|
||||
assert_eq!(release.semver, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_without_binary_never_matches() {
|
||||
let asset: ApiAsset = serde_json::from_str(r#"{"release_name":"x"}"#).expect("最小条目");
|
||||
assert!(!asset.matches(WINDOWS));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
//! 标准安装位置扫描 + 全部发现源的合并去重(`java list` 的「系统发现」半边)。
|
||||
//!
|
||||
//! 只认 `JAVA_HOME`/`PATH` 会漏掉最常见的一类用户:**用 MSI 装的 Temurin / Microsoft OpenJDK
|
||||
//! 不设环境变量**(安装器只登记开始菜单)。HMCL 的 `JavaManager.searchAllJavaInRepository`
|
||||
//! 与 PCL 的 `JavaUtils.SearchFolders` 都扫这些位置,本模块照同一思路:
|
||||
//!
|
||||
//! | 平台 | 扫描位置 |
|
||||
//! | --- | --- |
|
||||
//! | Windows | `%ProgramFiles%\{Java, Eclipse Adoptium, Microsoft, Zulu, Amazon Corretto, BellSoft, Semeru}\*`(含 `ProgramFiles(x86)` / `ProgramW6432` 变体,WOW64 才看得到 32 位安装)、`%APPDATA%\.minecraft\runtime\*`(**官方启动器装的运行时**,跨产品兼容)、`HKLM\SOFTWARE\JavaSoft\JDK`(老 Oracle 写的,新版发行版通常没有→容错跳过) |
|
||||
//! | Linux | `/usr/lib/jvm/*`、`/usr/java/*` |
|
||||
//! | macOS | `/Library/Java/JavaVirtualMachines/*.jdk/Contents/Home` |
|
||||
//!
|
||||
//! 三个纪律:
|
||||
//!
|
||||
//! 1. **目录不存在是常态**(本机实测 `%APPDATA%\.minecraft\runtime` 与 `JavaSoft\JDK` 都没有),
|
||||
//! 一律静默跳过,绝不报错;
|
||||
//! 2. **同一路径多来源命中只列一次**,保留优先级高者(`JAVA_HOME` 指向 Program Files 里的
|
||||
//! JDK 时,用户看到的是 `JAVA_HOME` 而不是 `system`);
|
||||
//! 3. **扫描只在需要时才做**:`java list` 全扫;启动路径上先看托管+环境,不满足才扫盘
|
||||
//! (别为了一次启动白跑 N 次 `java -version`)。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use super::extract::java_binary_name;
|
||||
use super::probe::{self, discover_managed_roots, system_candidates, JavaOrigin, JavaRuntime};
|
||||
|
||||
/// 全部发现源(托管 → `JAVA_HOME` → `PATH` → 标准安装位置),已按路径去重。
|
||||
#[must_use]
|
||||
pub fn candidates(managed_root: &Path) -> Vec<JavaRuntime> {
|
||||
candidates_in_roots(std::slice::from_ref(&managed_root.to_path_buf()))
|
||||
}
|
||||
|
||||
/// 同上,但托管区可以是**多个安装根**(Portable 数据根 + Standard/Custom 落点,D23)。
|
||||
#[must_use]
|
||||
pub fn candidates_in_roots(managed_roots: &[PathBuf]) -> Vec<JavaRuntime> {
|
||||
let mut all = discover_managed_roots(managed_roots);
|
||||
all.extend(system_candidates());
|
||||
all.extend(standard_candidates());
|
||||
dedupe(all)
|
||||
}
|
||||
|
||||
/// 标准安装位置扫出来的运行时(逐个探测)。
|
||||
#[must_use]
|
||||
pub fn standard_candidates() -> Vec<JavaRuntime> {
|
||||
scan_java_binaries()
|
||||
.into_iter()
|
||||
.map(|binary| probe::probe(&binary, JavaOrigin::System))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 扫描标准安装位置,返回 `bin/java[.exe]` 路径表(稳定排序,未探测)。
|
||||
#[must_use]
|
||||
pub fn scan_java_binaries() -> Vec<PathBuf> {
|
||||
let mut found: Vec<PathBuf> = Vec::new();
|
||||
for root in standard_roots() {
|
||||
let binary = root.join("bin").join(java_binary_name());
|
||||
if binary.is_file() {
|
||||
found.push(binary);
|
||||
}
|
||||
}
|
||||
for home in registry_homes() {
|
||||
let binary = home.join("bin").join(java_binary_name());
|
||||
if binary.is_file() {
|
||||
found.push(binary);
|
||||
}
|
||||
}
|
||||
found.sort();
|
||||
found.dedup();
|
||||
found
|
||||
}
|
||||
|
||||
/// 候选 JDK 根目录(`bin/java` 的父目录的父目录)。
|
||||
#[must_use]
|
||||
pub fn standard_roots() -> Vec<PathBuf> {
|
||||
let mut roots = Vec::new();
|
||||
if cfg!(windows) {
|
||||
for variable in ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"] {
|
||||
let Some(base) = std::env::var_os(variable) else {
|
||||
continue;
|
||||
};
|
||||
let base = PathBuf::from(base);
|
||||
for vendor in WINDOWS_VENDOR_DIRS {
|
||||
roots.extend(child_dirs(&base.join(vendor)));
|
||||
}
|
||||
}
|
||||
if let Some(appdata) = std::env::var_os("APPDATA") {
|
||||
roots.extend(child_dirs(
|
||||
&PathBuf::from(appdata).join(".minecraft").join("runtime"),
|
||||
));
|
||||
}
|
||||
} else if cfg!(target_os = "macos") {
|
||||
for entry in child_dirs(Path::new("/Library/Java/JavaVirtualMachines")) {
|
||||
// `*.jdk/Contents/Home`(`java_home` 语义)。
|
||||
let home = entry.join("Contents").join("Home");
|
||||
roots.push(if home.is_dir() { home } else { entry });
|
||||
}
|
||||
} else {
|
||||
for base in ["/usr/lib/jvm", "/usr/java"] {
|
||||
roots.extend(child_dirs(Path::new(base)));
|
||||
}
|
||||
}
|
||||
roots.sort();
|
||||
roots.dedup();
|
||||
roots
|
||||
}
|
||||
|
||||
/// Windows 各发行版的安装父目录(每种下面按子目录名当版本)。
|
||||
const WINDOWS_VENDOR_DIRS: &[&str] = &[
|
||||
"Java",
|
||||
"Eclipse Adoptium",
|
||||
"Microsoft",
|
||||
"Zulu",
|
||||
"Amazon Corretto",
|
||||
"BellSoft",
|
||||
"Semeru",
|
||||
];
|
||||
|
||||
/// 列一个目录的直接子目录(不存在/无权限一律空)。
|
||||
fn child_dirs(dir: &Path) -> Vec<PathBuf> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
|
||||
.map(|entry| entry.path())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 注册表里登记的 `JavaHome`(只查老 Oracle 的 `JavaSoft\JDK`;查不到返回空)。
|
||||
///
|
||||
/// 不走额外依赖:`reg.exe` 在 Win10+ 恒定存在,输出格式也稳定
|
||||
/// (` JavaHome REG_SZ C:\Program Files\Java\jdk-17`)。
|
||||
#[must_use]
|
||||
pub fn registry_homes() -> Vec<PathBuf> {
|
||||
if !cfg!(windows) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut homes = Vec::new();
|
||||
for view in ["64", "32"] {
|
||||
let Ok(output) = Command::new("reg")
|
||||
.args(["query", "HKLM\\SOFTWARE\\JavaSoft\\JDK", "/s", "/reg:"])
|
||||
.arg(view)
|
||||
.output()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !output.status.success() {
|
||||
continue;
|
||||
}
|
||||
homes.extend(parse_java_home(&String::from_utf8_lossy(&output.stdout)));
|
||||
}
|
||||
homes.sort();
|
||||
homes.dedup();
|
||||
homes
|
||||
}
|
||||
|
||||
/// 从 `reg query /s` 的输出里抠出 `JavaHome` 的值(纯函数,便于用真实输出做单测)。
|
||||
#[must_use]
|
||||
pub fn parse_java_home(output: &str) -> Vec<PathBuf> {
|
||||
output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let rest = line.trim().strip_prefix("JavaHome")?;
|
||||
let (_, value) = rest.split_once("REG_SZ")?;
|
||||
let home = value.trim();
|
||||
(!home.is_empty()).then(|| PathBuf::from(home))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 按路径去重,保留**先出现**的那个(调用方按优先级排好序)。
|
||||
#[must_use]
|
||||
pub fn dedupe(runtimes: Vec<JavaRuntime>) -> Vec<JavaRuntime> {
|
||||
let mut seen: Vec<String> = Vec::with_capacity(runtimes.len());
|
||||
let mut kept = Vec::with_capacity(runtimes.len());
|
||||
for runtime in runtimes {
|
||||
let key = path_key(&runtime.binary);
|
||||
if seen.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
seen.push(key);
|
||||
kept.push(runtime);
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
/// 去重键:去掉 Windows 的 `\\?\` 前缀、统一分隔符、大小写不敏感(Windows)。
|
||||
#[must_use]
|
||||
pub fn path_key(path: &Path) -> String {
|
||||
let text = path.display().to_string();
|
||||
let text = text.strip_prefix(r"\\?\").unwrap_or(&text).to_string();
|
||||
if cfg!(windows) {
|
||||
text.replace('/', "\\").to_ascii_lowercase()
|
||||
} else {
|
||||
text
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{dedupe, parse_java_home, path_key, standard_roots};
|
||||
use crate::game::java::probe::{JavaOrigin, JavaRuntime};
|
||||
|
||||
fn runtime(path: &str, origin: JavaOrigin) -> JavaRuntime {
|
||||
JavaRuntime {
|
||||
binary: std::path::PathBuf::from(path),
|
||||
origin,
|
||||
version: None,
|
||||
major: Some(21),
|
||||
bits: 64,
|
||||
vendor: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedupe_keeps_the_higher_priority_source() {
|
||||
let list = vec![
|
||||
runtime(
|
||||
"C:/Program Files/Java/jdk-21/bin/java.exe",
|
||||
JavaOrigin::JavaHome,
|
||||
),
|
||||
runtime(
|
||||
"C:\\Program Files\\Java\\jdk-21\\bin\\java.exe",
|
||||
JavaOrigin::System,
|
||||
),
|
||||
runtime(
|
||||
"C:/Program Files/Zulu/zulu-17/bin/java.exe",
|
||||
JavaOrigin::System,
|
||||
),
|
||||
];
|
||||
let kept = dedupe(list);
|
||||
assert_eq!(kept.len(), 2);
|
||||
assert_eq!(kept[0].origin, JavaOrigin::JavaHome, "重复路径保留高优先级");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_key_normalises_separators() {
|
||||
let left = path_key(std::path::Path::new("C:/a/b/java.exe"));
|
||||
let right = path_key(std::path::Path::new("C:\\a\\b\\java.exe"));
|
||||
assert_eq!(left, right);
|
||||
assert!(!path_key(std::path::Path::new(r"\\?\C:\a\java.exe")).starts_with(r"\\"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_real_reg_query_output() {
|
||||
// 实测形态(`reg query HKLM\SOFTWARE\JavaSoft\JDK /s`)。
|
||||
let output = "\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\JavaSoft\\JDK\r\n CurrentVersion REG_SZ 1.8\r\n\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\JavaSoft\\JDK\\1.8\r\n JavaHome REG_SZ C:\\Program Files\\Java\\jdk1.8.0_402\r\n";
|
||||
let homes = parse_java_home(output);
|
||||
assert_eq!(
|
||||
homes,
|
||||
vec![std::path::PathBuf::from(
|
||||
r"C:\Program Files\Java\jdk1.8.0_402"
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_registry_key_is_not_an_error() {
|
||||
assert!(parse_java_home("错误: 系统找不到指定的注册表项或值。\r\n").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_roots_never_panic_on_missing_directories() {
|
||||
// 本机大概率缺若干目录;这里只要求「能跑、不 panic」。
|
||||
let roots = standard_roots();
|
||||
assert!(roots.iter().all(|root| !root.as_os_str().is_empty()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Java 管理模块的错误形状:稳定分类 + 人读说明 + 下一步提示。
|
||||
//!
|
||||
//! 与 `core::game::install::InstallError` 同一个套路(分类进 `--json` 的 `error.code`,
|
||||
//! 说明给人看,提示告诉用户下一步做什么),只是没有复用那个类型——安装层的错误语义
|
||||
//! 绑在版本本体上,JDK 下载的失败面(API 不可达 / 无对应产物 / 解压后跑不起来)不一样。
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::download::{DownloadError, DownloadErrorKind};
|
||||
|
||||
/// 失败大类(CLI 映射成稳定错误码,脚本可分流)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum JavaErrorKind {
|
||||
/// 网络侧:Adoptium API 不可达,或所有候选镜像源都失败。
|
||||
Network,
|
||||
/// 元数据不认识:API 返回体缺字段、空列表、要的大版本没有 GA 产物。
|
||||
Metadata,
|
||||
/// 摘要校验不符(镜像给了错内容)或 API 没给可用的 sha256。
|
||||
Integrity,
|
||||
/// 本地 IO:建目录 / 解压 / 落盘。
|
||||
Io,
|
||||
/// 当前平台没有对应的 Temurin 产物(架构不认识等)。
|
||||
Unsupported,
|
||||
/// 装完探测不到可用 java(解压成功但跑不起来 / 版本不符)。
|
||||
Probe,
|
||||
}
|
||||
|
||||
impl JavaErrorKind {
|
||||
/// 稳定字符串 id(进 `--json` 的 `error.code`)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Network => "java_network",
|
||||
Self::Metadata => "java_metadata",
|
||||
Self::Integrity => "java_integrity",
|
||||
Self::Io => "java_io",
|
||||
Self::Unsupported => "java_unsupported",
|
||||
Self::Probe => "java_probe",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Java 管理期错误。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct JavaError {
|
||||
kind: JavaErrorKind,
|
||||
message: String,
|
||||
hint: Option<String>,
|
||||
}
|
||||
|
||||
impl JavaError {
|
||||
/// 构造。
|
||||
#[must_use]
|
||||
pub fn new(kind: JavaErrorKind, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
hint: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 补一句「下一步怎么办」。
|
||||
#[must_use]
|
||||
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
|
||||
self.hint = Some(hint.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// 分类。
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> JavaErrorKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
/// 稳定错误码。
|
||||
#[must_use]
|
||||
pub const fn code(&self) -> &'static str {
|
||||
self.kind.as_str()
|
||||
}
|
||||
|
||||
/// 人读说明。
|
||||
#[must_use]
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
|
||||
/// 下一步提示。
|
||||
#[must_use]
|
||||
pub fn hint(&self) -> Option<&str> {
|
||||
self.hint.as_deref()
|
||||
}
|
||||
|
||||
/// 在说明前面加一段上下文(保留分类与提示)。
|
||||
#[must_use]
|
||||
pub fn with_context(mut self, context: impl fmt::Display) -> Self {
|
||||
self.message = format!("{context}:{}", self.message);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for JavaError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for JavaError {}
|
||||
|
||||
impl From<DownloadError> for JavaError {
|
||||
fn from(error: DownloadError) -> Self {
|
||||
let kind = match error.kind {
|
||||
DownloadErrorKind::Integrity => JavaErrorKind::Integrity,
|
||||
DownloadErrorKind::Io => JavaErrorKind::Io,
|
||||
DownloadErrorKind::InvalidRequest => JavaErrorKind::Metadata,
|
||||
DownloadErrorKind::NotFound
|
||||
| DownloadErrorKind::ServerError
|
||||
| DownloadErrorKind::Timeout
|
||||
| DownloadErrorKind::Network
|
||||
| DownloadErrorKind::RangeUnsupported
|
||||
| DownloadErrorKind::AllSourcesFailed => JavaErrorKind::Network,
|
||||
};
|
||||
let mut message = error.message.clone();
|
||||
if let Some(source) = &error.source {
|
||||
message.push_str(&format!("(源 {source})"));
|
||||
}
|
||||
// 逐源失败摘要:没有这一段,「全部失败」在现场只能靠复现去猜(本轮真机验收就踩到了)。
|
||||
if !error.suppressed.is_empty() {
|
||||
message.push_str(&format!(";逐源明细:{}", error.suppressed.join(" / ")));
|
||||
}
|
||||
Self::new(kind, message)
|
||||
}
|
||||
}
|
||||
|
||||
/// IO 错误包装(带上「在做什么 / 哪个路径」,日志里才追得动)。
|
||||
#[must_use]
|
||||
pub fn io_error(op: &'static str, path: impl fmt::Display, source: &std::io::Error) -> JavaError {
|
||||
JavaError::new(JavaErrorKind::Io, format!("{op} 失败({path}):{source}"))
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
//! 归档解压:zip(Windows)/ tar.gz(Linux、macOS 的 Temurin 只发这种)+ 幂等戳记。
|
||||
//!
|
||||
//! # 为什么不用「解压到最终目录」了事
|
||||
//!
|
||||
//! Temurin 的归档里所有条目都在一个顶层目录下(`jdk-21.0.13+9/bin/java`),
|
||||
//! 而托管布局要的是 `java-managed/jdk-21-temurin/bin/java`。裸解压会多出一层,
|
||||
//! 于是这里先在**暂存目录**里解完,再把那个唯一的顶层目录(或暂存目录本身)
|
||||
//! 提升成最终目录——这样终态恒定,`bin/java` 的位置不会因为厂商换打包方式而漂移。
|
||||
//!
|
||||
//! # 幂等
|
||||
//!
|
||||
//! 戳记 `.ull-jdk.json` 记下「这份目录是用哪个 sha256 的归档解出来的 + 文件数」。
|
||||
//! 重复 `ull java install 21` 时 sha256 相同就**一个字节都不动**(返回 `skipped`);
|
||||
//! 官方发了新版(sha256 变)才重解。戳记缺失/损坏一律当「没装过」。
|
||||
//!
|
||||
//! # 安全
|
||||
//!
|
||||
//! zip 走 `enclosed_name()`(拒绝 `..` 与绝对路径),tar 走 `unpack_in()`
|
||||
//! (同样拒绝越界),两者都不会把文件写到目标目录之外(zip-slip)。
|
||||
//! tar 里的符号链接条目跳过(Windows 建不了;Temurin 的 JDK 归档不依赖它们)。
|
||||
|
||||
use std::fs::{self, File};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use flate2::read::GzDecoder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zip::ZipArchive;
|
||||
|
||||
use super::error::{io_error, JavaError, JavaErrorKind};
|
||||
|
||||
/// 戳记文件名(放在托管 JDK 根目录里)。
|
||||
pub const STAMP_FILE: &str = ".ull-jdk.json";
|
||||
|
||||
/// 戳记 schema 版本(改字段语义才升)。
|
||||
const STAMP_VERSION: u8 = 1;
|
||||
|
||||
/// 暂存目录前缀(解压在它里面进行,成功后提升成最终目录)。
|
||||
const STAGING_PREFIX: &str = ".staging-";
|
||||
|
||||
/// 托管 JDK 的戳记。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct JdkStamp {
|
||||
/// schema 版本。
|
||||
pub v: u8,
|
||||
/// 归档文件名。
|
||||
pub archive: String,
|
||||
/// 归档的 SHA-256(幂等判据)。
|
||||
pub sha256: String,
|
||||
/// 大版本号。
|
||||
pub major: u32,
|
||||
/// 解出来的文件数。
|
||||
pub files: usize,
|
||||
}
|
||||
|
||||
/// 一次解压的结果。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ExtractReport {
|
||||
/// 本次落盘的文件数(`skipped` 时为 0)。
|
||||
pub files: usize,
|
||||
/// 是否因为戳记命中而整体跳过。
|
||||
pub skipped: bool,
|
||||
}
|
||||
|
||||
impl ExtractReport {
|
||||
/// 是否真的写盘了。
|
||||
#[must_use]
|
||||
pub const fn wrote(self) -> bool {
|
||||
!self.skipped
|
||||
}
|
||||
}
|
||||
|
||||
/// 托管 JDK 的 `java` 可执行文件路径。
|
||||
#[must_use]
|
||||
pub fn java_binary(dir: &Path) -> PathBuf {
|
||||
dir.join("bin").join(java_binary_name())
|
||||
}
|
||||
|
||||
/// 平台上的 java 可执行文件名。
|
||||
#[must_use]
|
||||
pub const fn java_binary_name() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"java.exe"
|
||||
} else {
|
||||
"java"
|
||||
}
|
||||
}
|
||||
|
||||
/// 读戳记(不存在 / 损坏 / 版本不符都当作「没有」)。
|
||||
#[must_use]
|
||||
pub fn read_stamp(dir: &Path) -> Option<JdkStamp> {
|
||||
let text = fs::read_to_string(dir.join(STAMP_FILE)).ok()?;
|
||||
let stamp: JdkStamp = serde_json::from_str(&text).ok()?;
|
||||
(stamp.v == STAMP_VERSION).then_some(stamp)
|
||||
}
|
||||
|
||||
/// 这个目录里是不是已经躺着一份用**同一个归档**解出来的 JDK。
|
||||
#[must_use]
|
||||
pub fn is_ready(dir: &Path, sha256: &str) -> bool {
|
||||
let Some(stamp) = read_stamp(dir) else {
|
||||
return false;
|
||||
};
|
||||
stamp.sha256 == sha256.to_ascii_lowercase() && java_binary(dir).is_file()
|
||||
}
|
||||
|
||||
/// 解压一个 JDK 归档到 `dest`(幂等)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 归档打不开/格式不认识、解压写盘失败、`dest` 已存在却不由我们管理
|
||||
/// (没有戳记)时返回错误。
|
||||
pub fn extract_archive(
|
||||
archive: &Path,
|
||||
sha256: &str,
|
||||
major: u32,
|
||||
dest: &Path,
|
||||
) -> Result<ExtractReport, JavaError> {
|
||||
if is_ready(dest, sha256) {
|
||||
return Ok(ExtractReport {
|
||||
files: 0,
|
||||
skipped: true,
|
||||
});
|
||||
}
|
||||
let parent = dest.parent().ok_or_else(|| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Io,
|
||||
format!("托管目录没有父目录:{}", dest.display()),
|
||||
)
|
||||
})?;
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| io_error("创建托管目录", parent.display(), &error))?;
|
||||
|
||||
let folder = dest
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "jdk".to_string());
|
||||
let staging = parent.join(format!("{STAGING_PREFIX}{folder}"));
|
||||
if staging.exists() {
|
||||
fs::remove_dir_all(&staging)
|
||||
.map_err(|error| io_error("清理暂存目录", staging.display(), &error))?;
|
||||
}
|
||||
fs::create_dir_all(&staging)
|
||||
.map_err(|error| io_error("创建暂存目录", staging.display(), &error))?;
|
||||
|
||||
let extracted = if is_zip(archive) {
|
||||
extract_zip(archive, &staging)
|
||||
} else {
|
||||
extract_tar_gz(archive, &staging)
|
||||
};
|
||||
let files = match extracted {
|
||||
Ok(files) => files,
|
||||
Err(error) => {
|
||||
let _ = fs::remove_dir_all(&staging);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
let root = single_top_level_dir(&staging).unwrap_or_else(|| staging.clone());
|
||||
if dest.exists() {
|
||||
// 只回收「我们自己装的」目录:有戳记,或空目录。其余一律不碰。
|
||||
let ours = read_stamp(dest).is_some() || is_empty_dir(dest);
|
||||
if !ours {
|
||||
let _ = fs::remove_dir_all(&staging);
|
||||
return Err(JavaError::new(
|
||||
JavaErrorKind::Io,
|
||||
format!("目标目录已存在且不是托管 JDK:{}", dest.display()),
|
||||
)
|
||||
.with_hint("换个目录(`--data-dir`),或先手动清掉它"));
|
||||
}
|
||||
fs::remove_dir_all(dest)
|
||||
.map_err(|error| io_error("清空旧托管目录", dest.display(), &error))?;
|
||||
}
|
||||
fs::rename(&root, dest).map_err(|error| {
|
||||
io_error(
|
||||
"提升暂存目录",
|
||||
format!("{} → {}", root.display(), dest.display()),
|
||||
&error,
|
||||
)
|
||||
})?;
|
||||
if root != staging {
|
||||
let _ = fs::remove_dir_all(&staging);
|
||||
}
|
||||
if !java_binary(dest).is_file() {
|
||||
return Err(JavaError::new(
|
||||
JavaErrorKind::Probe,
|
||||
format!("解压完成但找不到 bin/{}", java_binary_name()),
|
||||
)
|
||||
.with_hint("归档形态与平台不匹配(例如把 Linux 包当成 Windows 包装)"));
|
||||
}
|
||||
|
||||
let stamp = JdkStamp {
|
||||
v: STAMP_VERSION,
|
||||
archive: archive
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_default(),
|
||||
sha256: sha256.to_ascii_lowercase(),
|
||||
major,
|
||||
files,
|
||||
};
|
||||
let text = serde_json::to_string_pretty(&stamp).map_err(|error| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!("序列化 JDK 戳记失败:{error}"),
|
||||
)
|
||||
})?;
|
||||
let stamp_path = dest.join(STAMP_FILE);
|
||||
fs::write(&stamp_path, text)
|
||||
.map_err(|error| io_error("写 JDK 戳记", stamp_path.display(), &error))?;
|
||||
Ok(ExtractReport {
|
||||
files,
|
||||
skipped: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// 归档是不是 zip(魔数优先,读不到才退回扩展名)。
|
||||
///
|
||||
/// 不按扩展名一刀切:镜像把 `.zip` 存成 `.zip.tmp` 之类时魔数仍然可靠,
|
||||
/// 反过来把 tar.gz 命名成 `.zip` 也不会被错解。
|
||||
#[must_use]
|
||||
pub fn is_zip(archive: &Path) -> bool {
|
||||
let mut head = [0u8; 4];
|
||||
let magic = File::open(archive)
|
||||
.and_then(|mut file| io::Read::read_exact(&mut file, &mut head).map(|()| head));
|
||||
if let Ok(head) = magic {
|
||||
// `PK\x03\x04` 普通条目 / `PK\x05\x06` 空归档。
|
||||
return head == *b"PK\x03\x04" || head == *b"PK\x05\x06";
|
||||
}
|
||||
archive
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().to_ascii_lowercase())
|
||||
.is_some_and(|name| name.ends_with(".zip"))
|
||||
}
|
||||
|
||||
/// zip:逐条目落盘,`enclosed_name` 挡路径穿越。
|
||||
fn extract_zip(archive: &Path, dir: &Path) -> Result<usize, JavaError> {
|
||||
let file = File::open(archive)
|
||||
.map_err(|error| io_error("打开 JDK 归档", archive.display(), &error))?;
|
||||
let mut zip = ZipArchive::new(file).map_err(|error| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Io,
|
||||
format!("JDK 归档不是合法 zip({}):{error}", archive.display()),
|
||||
)
|
||||
})?;
|
||||
let mut files = 0;
|
||||
for index in 0..zip.len() {
|
||||
let mut entry = zip.by_index(index).map_err(|error| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Io,
|
||||
format!("读 zip 条目失败({}):{error}", archive.display()),
|
||||
)
|
||||
})?;
|
||||
if entry.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(relative) = entry.enclosed_name() else {
|
||||
continue;
|
||||
};
|
||||
let dest = dir.join(&relative);
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| io_error("创建解压子目录", parent.display(), &error))?;
|
||||
}
|
||||
let mut out = File::create(&dest)
|
||||
.map_err(|error| io_error("创建解压文件", dest.display(), &error))?;
|
||||
io::copy(&mut entry, &mut out)
|
||||
.map_err(|error| io_error("写解压文件", dest.display(), &error))?;
|
||||
files += 1;
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// tar.gz:`unpack_in` 挡路径穿越,符号链接条目跳过。
|
||||
fn extract_tar_gz(archive: &Path, dir: &Path) -> Result<usize, JavaError> {
|
||||
let file = File::open(archive)
|
||||
.map_err(|error| io_error("打开 JDK 归档", archive.display(), &error))?;
|
||||
let mut tar = tar::Archive::new(GzDecoder::new(file));
|
||||
let entries = tar.entries().map_err(|error| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Io,
|
||||
format!("JDK 归档不是合法 tar.gz({}):{error}", archive.display()),
|
||||
)
|
||||
})?;
|
||||
let mut files = 0;
|
||||
for entry in entries {
|
||||
let mut entry = entry.map_err(|error| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Io,
|
||||
format!("读 tar 条目失败({}):{error}", archive.display()),
|
||||
)
|
||||
})?;
|
||||
if !entry.header().entry_type().is_file() {
|
||||
continue;
|
||||
}
|
||||
let unpacked = entry
|
||||
.unpack_in(dir)
|
||||
.map_err(|error| io_error("解压 tar 条目", dir.display(), &error))?;
|
||||
if unpacked {
|
||||
files += 1;
|
||||
}
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// 暂存目录里是不是只有一个顶层目录(Temurin 的 `jdk-21.0.13+9/`)。
|
||||
fn single_top_level_dir(dir: &Path) -> Option<PathBuf> {
|
||||
let mut entries = fs::read_dir(dir).ok()?;
|
||||
let first = entries.next()?.ok()?;
|
||||
if entries.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
let path = first.path();
|
||||
path.is_dir().then_some(path)
|
||||
}
|
||||
|
||||
fn is_empty_dir(dir: &Path) -> bool {
|
||||
fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none())
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
//! 托管 JDK 安装编排:发行版回退链 → 下载引擎 → 解压 → `java -version` 验证。
|
||||
//!
|
||||
//! ```text
|
||||
//! vendor::order(用户优先级, 地区) D23 的尝试顺序
|
||||
//! → source::fetch(vendor, major) 查到该家最新 GA(Temurin/Oracle/MS/Zulu)
|
||||
//! → location::JavaInstallRoot::jdk_dir 按落点模式算目录
|
||||
//! → [戳记 sha256 相同 && bin/java 在] 复用 幂等的第一层
|
||||
//! → engine.download(JavaRuntime 请求) 镜像/健康度/退避/续传/边下边算全在引擎
|
||||
//! → extract::extract_archive zip / tar.gz + 幂等戳记
|
||||
//! → probe::probe(bin/java -version) 幂等的第二层,也是「装完真能用」的证据
|
||||
//! ```
|
||||
//!
|
||||
//! **回退的粒度是发行版**(D23):该家没有这个大版本、镜像全 4xx、sha256 取不到、下载校验失败,
|
||||
//! 都记一条降级原因然后换下一家;只有全部失败才整体报错,错误里带上每一跳的原因。
|
||||
//!
|
||||
//! 这一步**不拼任何镜像 URL**(Oracle 的哈希段不可推导,见 `oracle` 模块):Temurin 的
|
||||
//! GitHub 直链交给 `MirrorTable::expand` 展开成院校镜像,Oracle/MS/Zulu 的链接由各自模块
|
||||
//! 从目录页/模板/API 拿到最终形态,引擎只负责「把这个 URL 下下来并校验」。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::error::{io_error, JavaError, JavaErrorKind};
|
||||
use super::extract;
|
||||
use super::location::{JavaInstallRoot, LocationMode};
|
||||
use super::probe::{self, JavaOrigin, JavaRuntime};
|
||||
use super::release::JdkRelease;
|
||||
use super::source::{self, ChainLog};
|
||||
use super::target::AdoptiumTarget;
|
||||
use super::vendor::{self, JdkVendor};
|
||||
use crate::download::{DownloadEngine, DownloadRequest, ResourceKind};
|
||||
|
||||
/// 归档缓存目录名(在安装根下;托管目录扫描跳过它)。
|
||||
pub use super::location::CACHE_DIR;
|
||||
|
||||
/// 一次安装请求。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InstallRequest {
|
||||
/// 目标大版本。
|
||||
pub major: u32,
|
||||
/// 钉死某一家发行版(`--vendor`);`None` = 按回退链自己选。
|
||||
pub vendor: Option<JdkVendor>,
|
||||
/// 用户优先级(`java.vendorPriority`,空 = 按地区默认序)。
|
||||
pub priority: Vec<JdkVendor>,
|
||||
}
|
||||
|
||||
impl InstallRequest {
|
||||
/// 只要大版本(走回退链,优先级用地区默认)。
|
||||
#[must_use]
|
||||
pub const fn new(major: u32) -> Self {
|
||||
Self {
|
||||
major,
|
||||
vendor: None,
|
||||
priority: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 钉死发行版。
|
||||
#[must_use]
|
||||
pub const fn with_vendor(major: u32, vendor: JdkVendor) -> Self {
|
||||
Self {
|
||||
major,
|
||||
vendor: Some(vendor),
|
||||
priority: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 带上用户优先级(只影响没有 `--vendor` 的情形)。
|
||||
#[must_use]
|
||||
pub fn with_priority(mut self, priority: Vec<JdkVendor>) -> Self {
|
||||
self.priority = priority;
|
||||
self
|
||||
}
|
||||
|
||||
/// 实际尝试顺序(`--vendor` 钉死时链里只放它)。
|
||||
#[must_use]
|
||||
pub fn order(&self, domestic: bool) -> Vec<JdkVendor> {
|
||||
match self.vendor {
|
||||
Some(vendor) => vec![vendor],
|
||||
None => vendor::order(&self.priority, domestic),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 会真正进入回退链的发行版(顺序 + 能力过滤)。
|
||||
///
|
||||
/// 与 [`install`] 的循环同源,但**纯**:不联网、不落盘,给 `--dry-run` 与离线测试用。
|
||||
/// 过滤条件只有一条——该家发不发这个 GA 大版本(MS 没有 8、Oracle 从 9 起)。
|
||||
/// `--vendor` 钉死时**不过滤**:链里就它一个,由 `install` 明确报「不支持 Java X」,
|
||||
/// 而不是悄悄把用户点名的发行版换成别的。
|
||||
#[must_use]
|
||||
pub fn chain_order(request: &InstallRequest, domestic: bool) -> Vec<JdkVendor> {
|
||||
if request.vendor.is_some() {
|
||||
return request.order(domestic);
|
||||
}
|
||||
request
|
||||
.order(domestic)
|
||||
.into_iter()
|
||||
.filter(|vendor| vendor.supports(request.major))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 一次成功安装的结果。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InstalledJdk {
|
||||
/// 大版本号。
|
||||
pub major: u32,
|
||||
/// 实际装上的发行版。
|
||||
pub vendor: JdkVendor,
|
||||
/// 版本查询结果(含归档名与 sha256)。
|
||||
pub release: JdkRelease,
|
||||
/// 托管目录(Portable:`java-managed/jdk-21-temurin`;Standard:`Java/oracle/21.0.2`)。
|
||||
pub root: PathBuf,
|
||||
/// 落点模式(报告里要说清「装到哪」)。
|
||||
pub mode: LocationMode,
|
||||
/// 归档路径(缓存)。
|
||||
pub archive: PathBuf,
|
||||
/// 探测到的运行时。
|
||||
pub runtime: JavaRuntime,
|
||||
/// 实际下载的源标识(`None` = 复用已装,没走网络)。
|
||||
pub downloaded: Option<String>,
|
||||
/// 本次下载字节数(复用已装时为 0)。
|
||||
pub bytes: u64,
|
||||
/// 本次解压的文件数(复用已装时可能为 0)。
|
||||
pub files: usize,
|
||||
/// 本次是否真的解压了。
|
||||
pub extracted: bool,
|
||||
/// 回退链记录(命中与降级原因)。
|
||||
pub chain: ChainLog,
|
||||
}
|
||||
|
||||
impl InstalledJdk {
|
||||
/// 是否完全复用了已装的那一份。
|
||||
#[must_use]
|
||||
pub fn reused(&self) -> bool {
|
||||
self.downloaded.is_none()
|
||||
}
|
||||
|
||||
/// `bin/java[.exe]` 路径。
|
||||
#[must_use]
|
||||
pub fn binary(&self) -> PathBuf {
|
||||
extract::java_binary(&self.root)
|
||||
}
|
||||
|
||||
/// 实际落点(人读一行)。
|
||||
#[must_use]
|
||||
pub fn location_line(&self) -> String {
|
||||
format!("{} · {}", self.mode.as_str(), self.root.display())
|
||||
}
|
||||
}
|
||||
|
||||
/// 托管目录路径(Portable 布局 `java-managed/jdk-<major>-<vendor>`)。
|
||||
#[must_use]
|
||||
pub fn managed_root_dir(managed_root: &Path, major: u32, vendor: &str) -> PathBuf {
|
||||
managed_root.join(probe::managed_dir_name(major, vendor))
|
||||
}
|
||||
|
||||
/// 归档缓存路径(`<root>/.cache/<归档名>`)。
|
||||
#[must_use]
|
||||
pub fn archive_path(root: &Path, release: &JdkRelease) -> PathBuf {
|
||||
root.join(CACHE_DIR).join(&release.archive.name)
|
||||
}
|
||||
|
||||
/// 归档下载请求(`ResourceKind::JavaRuntime` + sha256 + 期望大小)。
|
||||
#[must_use]
|
||||
pub fn archive_request(release: &JdkRelease, dest: PathBuf) -> DownloadRequest {
|
||||
let request =
|
||||
DownloadRequest::new(ResourceKind::JavaRuntime, release.archive.url.clone(), dest)
|
||||
.with_sha256(release.archive.sha256.clone());
|
||||
if release.archive.size > 0 {
|
||||
request.with_size(release.archive.size)
|
||||
} else {
|
||||
request
|
||||
}
|
||||
}
|
||||
|
||||
/// 按优先级链装一个托管 JDK(幂等:同一份归档装过就直接复用)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 全部发行版都失败(没有该大版本 / 平台无产物 / 网络 / 校验 / 解压 / 装完跑不起来)时
|
||||
/// 返回错误,`message()` 里带着每一跳的降级原因。
|
||||
pub async fn install(
|
||||
engine: &DownloadEngine,
|
||||
location: &JavaInstallRoot,
|
||||
request: InstallRequest,
|
||||
) -> Result<InstalledJdk, JavaError> {
|
||||
let target = AdoptiumTarget::host()?;
|
||||
let domestic = engine.region().is_domestic();
|
||||
let priority = request.order(domestic);
|
||||
let mut chain = ChainLog::new();
|
||||
let mut reasons: Vec<String> = Vec::new();
|
||||
// 清掉上次中断留下的 .staging-* 与空壳(只碰我们自己的形状,见 location 模块)。
|
||||
let _ = super::location::cleanup_orphans(&location.root);
|
||||
|
||||
for candidate in &priority {
|
||||
let vendor = *candidate;
|
||||
if !vendor.supports(request.major) {
|
||||
let reason = format!(
|
||||
"不支持 Java {}({} 只发 {})",
|
||||
request.major,
|
||||
vendor.id(),
|
||||
majors_text(vendor)
|
||||
);
|
||||
chain.skip(vendor, reason.clone());
|
||||
reasons.push(format!("{}:{reason}", vendor.id()));
|
||||
continue;
|
||||
}
|
||||
let release = match source::fetch(engine, vendor, request.major, target).await {
|
||||
Ok(release) => release,
|
||||
Err(error) => {
|
||||
chain.failed(vendor, error.message().to_string());
|
||||
reasons.push(format!("{}:{}", vendor.id(), error.message()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match install_one(engine, location, &release).await {
|
||||
Ok(prepared) => {
|
||||
chain.hit(vendor, format!("命中 {}", release.summary()));
|
||||
return Ok(InstalledJdk {
|
||||
major: release.major,
|
||||
vendor,
|
||||
release,
|
||||
root: prepared.root,
|
||||
mode: location.mode,
|
||||
archive: prepared.archive,
|
||||
runtime: prepared.runtime,
|
||||
downloaded: prepared.downloaded,
|
||||
bytes: prepared.bytes,
|
||||
files: prepared.files,
|
||||
extracted: prepared.extracted,
|
||||
chain,
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
chain.failed(vendor, error.message().to_string());
|
||||
reasons.push(format!("{}:{}", vendor.id(), error.message()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!(
|
||||
"没有装成 Java {}(试过 {}):{}",
|
||||
request.major,
|
||||
priority.len(),
|
||||
reasons.join(";")
|
||||
),
|
||||
)
|
||||
.with_hint("`ull java vendors` 看各发行版的可用性;或手动装 JDK 后设 JAVA_HOME"))
|
||||
}
|
||||
|
||||
/// 某一家的中间结果(把七元组元组换成具名结构,读起来才不费劲)。
|
||||
struct Prepared {
|
||||
root: PathBuf,
|
||||
archive: PathBuf,
|
||||
runtime: JavaRuntime,
|
||||
downloaded: Option<String>,
|
||||
bytes: u64,
|
||||
files: usize,
|
||||
extracted: bool,
|
||||
}
|
||||
|
||||
/// 装某一家的那一份(下载 → 解压 → 探测)。
|
||||
async fn install_one(
|
||||
engine: &DownloadEngine,
|
||||
location: &JavaInstallRoot,
|
||||
release: &JdkRelease,
|
||||
) -> Result<Prepared, JavaError> {
|
||||
let root = location.jdk_dir(release.major, release.vendor, &release.version);
|
||||
let binary = extract::java_binary(&root);
|
||||
let archive = archive_path(&location.root, release);
|
||||
|
||||
// 幂等第一层:戳记里的 sha256 与本次一致,且 bin/java 在 → 一个字节都不动。
|
||||
if extract::is_ready(&root, release.sha256()) {
|
||||
let runtime = probe::probe(&binary, JavaOrigin::Managed);
|
||||
if runtime.major == Some(release.major) {
|
||||
let mut runtime = runtime;
|
||||
runtime.vendor = Some(release.vendor.id().to_string());
|
||||
return Ok(Prepared {
|
||||
root,
|
||||
archive,
|
||||
runtime,
|
||||
downloaded: None,
|
||||
bytes: 0,
|
||||
files: 0,
|
||||
extracted: false,
|
||||
});
|
||||
}
|
||||
note(&format!(
|
||||
"托管目录 {} 里的 java 跑不起来,重装一份",
|
||||
root.display()
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(parent) = archive.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|error| io_error("创建归档缓存目录", parent.display(), &error))?;
|
||||
}
|
||||
let outcome = engine
|
||||
.download(archive_request(release, archive.clone()))
|
||||
.await?;
|
||||
let report = extract::extract_archive(&archive, release.sha256(), release.major, &root)?;
|
||||
let mut runtime = probe::probe(&binary, JavaOrigin::Managed);
|
||||
runtime.vendor = Some(release.vendor.id().to_string());
|
||||
if runtime.major != Some(release.major) {
|
||||
return Err(JavaError::new(
|
||||
JavaErrorKind::Probe,
|
||||
format!(
|
||||
"装好的 JDK 探测结果是 Java {},与请求的 {} 不符({})",
|
||||
runtime
|
||||
.major
|
||||
.map_or_else(|| "未知".to_string(), |major| major.to_string()),
|
||||
release.major,
|
||||
binary.display()
|
||||
),
|
||||
)
|
||||
.with_hint("删掉该托管目录后重跑 `ull java install`;或手动设 JAVA_HOME"));
|
||||
}
|
||||
Ok(Prepared {
|
||||
root,
|
||||
archive,
|
||||
runtime,
|
||||
downloaded: Some(outcome.source),
|
||||
bytes: outcome.bytes,
|
||||
files: report.files,
|
||||
extracted: report.wrote(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 只做规划不落盘:返回将要安装的版本、目标目录与「是否已经装好」。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 与 [`install`] 的查询段相同(平台不支持 / 网络失败 / 所有发行版都没有该大版本)。
|
||||
pub async fn plan(
|
||||
engine: &DownloadEngine,
|
||||
location: &JavaInstallRoot,
|
||||
major: u32,
|
||||
) -> Result<(JdkRelease, PathBuf, bool), JavaError> {
|
||||
let target = AdoptiumTarget::host()?;
|
||||
let domestic = engine.region().is_domestic();
|
||||
let mut reasons: Vec<String> = Vec::new();
|
||||
for vendor in vendor::order(&[], domestic) {
|
||||
if !vendor.supports(major) {
|
||||
reasons.push(format!("{}:不支持 Java {major}", vendor.id()));
|
||||
continue;
|
||||
}
|
||||
match source::fetch(engine, vendor, major, target).await {
|
||||
Ok(release) => {
|
||||
let root = location.jdk_dir(release.major, release.vendor, &release.version);
|
||||
let ready = extract::is_ready(&root, release.sha256());
|
||||
return Ok((release, root, ready));
|
||||
}
|
||||
Err(error) => reasons.push(format!("{}:{}", vendor.id(), error.message())),
|
||||
}
|
||||
}
|
||||
Err(JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!("没有可用的 Java {major}({} )", reasons.join(";")),
|
||||
))
|
||||
}
|
||||
|
||||
fn majors_text(vendor: JdkVendor) -> String {
|
||||
vendor
|
||||
.hint_majors()
|
||||
.iter()
|
||||
.map(u32::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
}
|
||||
|
||||
fn note(message: &str) {
|
||||
if cfg!(debug_assertions) {
|
||||
eprintln!("[ull::game::java] {message}");
|
||||
} else {
|
||||
let _ = message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//! 托管 JDK 的落点模式(D23 三模式,与 D6 数据目录三模式对齐)。
|
||||
//!
|
||||
//! | 模式 | 落点 | 说明 |
|
||||
//! | --- | --- | --- |
|
||||
//! | Portable | `<数据根>/.ULL/data/java-managed/jdk-<major>-<vendor>/` | v1 既有布局,M33/M35 的托管根解析语义不变 |
|
||||
//! | Standard | Windows `%APPDATA%\Java\<vendor>\<version>\`;macOS `~/Library/Application Support/Java/<vendor>/<version>`;Linux `$XDG_DATA_HOME/java/<vendor>/<version>`(回落 `~/.local/share/java/…`) | 用户原话「安装到 %APPDATA%\Java\发行方\Java版本\」 |
|
||||
//! | Custom | 设置键 `java.installRoot` 指定的目录,同样按 `<vendor>/<version>` 分层 | 覆盖前两种模式 |
|
||||
//!
|
||||
//! 三条纪律:
|
||||
//!
|
||||
//! 1. **不猜平台目录**:`%APPDATA%` / `XDG_DATA_HOME` / `HOME` 全走 `platform::paths::EnvSource`,
|
||||
//! 测试能注入假环境确定性地覆盖各 OS 落点;
|
||||
//! 2. **Portable 布局永不改**:`.ULL/data/java-managed/jdk-<major>-<vendor>` 是 M33 定下的,
|
||||
//! 改成 `<vendor>/<version>` 会让既有托管 JDK 变成孤儿——迁移语义靠「两种形状都扫得到」兜住;
|
||||
//! 3. **清理只碰自己的形状**:`cleanup_orphans` 只删 `.staging-*` 半成品与空目录,
|
||||
//! 没有戳记的非空目录一律不动(用户可能自己往里塞了 JDK)。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::platform::data_dir::DataMode;
|
||||
use crate::platform::layout::LauncherLayout;
|
||||
use crate::platform::paths::{home_dir, EnvSource, Platform};
|
||||
|
||||
use super::error::{JavaError, JavaErrorKind};
|
||||
use super::vendor::JdkVendor;
|
||||
|
||||
/// Standard 模式下 JDK 的父目录名(Windows 是 `%APPDATA%\Java`)。
|
||||
pub const JAVA_DIR_NAME: &str = "Java";
|
||||
|
||||
/// 归档缓存目录名(在安装根下;托管目录扫描跳过它)。
|
||||
pub const CACHE_DIR: &str = ".cache";
|
||||
|
||||
/// 解压暂存目录前缀(中断后残留的就是孤儿)。
|
||||
pub const STAGING_PREFIX: &str = ".staging-";
|
||||
|
||||
/// 落地模式。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LocationMode {
|
||||
/// 数据根内(v1 布局)。
|
||||
Portable,
|
||||
/// OS 标准应用数据目录。
|
||||
Standard,
|
||||
/// `java.installRoot` 指定。
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl LocationMode {
|
||||
/// 稳定名(JSON / 日志)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Portable => "portable",
|
||||
Self::Standard => "standard",
|
||||
Self::Custom => "custom",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从配置字符串解析。
|
||||
#[must_use]
|
||||
pub fn parse(text: &str) -> Option<Self> {
|
||||
match text {
|
||||
"portable" => Some(Self::Portable),
|
||||
"standard" => Some(Self::Standard),
|
||||
"custom" => Some(Self::Custom),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析好的安装根 + 它是怎么来的。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct JavaInstallRoot {
|
||||
/// 落地模式。
|
||||
pub mode: LocationMode,
|
||||
/// 安装根目录(各 JDK 目录在它下面)。
|
||||
pub root: PathBuf,
|
||||
}
|
||||
|
||||
impl JavaInstallRoot {
|
||||
/// Portable:`<数据根>/.ULL/data/java-managed`。
|
||||
#[must_use]
|
||||
pub fn portable(launcher: &LauncherLayout) -> Self {
|
||||
Self {
|
||||
mode: LocationMode::Portable,
|
||||
root: launcher.java_managed(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard:OS 标准应用数据目录 + `Java`。
|
||||
///
|
||||
/// 平台约定与 `platform::paths::standard_launcher_home` 同源:
|
||||
/// - Windows:`%APPDATA%\Java`(回落 `%USERPROFILE%\AppData\Roaming\Java`)
|
||||
/// - macOS:`~/Library/Application Support/Java`
|
||||
/// - Linux / 其它:`$XDG_DATA_HOME/java`(回落 `~/.local/share/java`)
|
||||
#[must_use]
|
||||
pub fn standard(platform: Platform, env: &dyn EnvSource) -> Option<Self> {
|
||||
standard_java_root(platform, env).map(|root| Self {
|
||||
mode: LocationMode::Standard,
|
||||
root,
|
||||
})
|
||||
}
|
||||
|
||||
/// Custom:`java.installRoot` 指定的目录(相对路径按数据根解析)。
|
||||
#[must_use]
|
||||
pub fn custom(root: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
mode: LocationMode::Custom,
|
||||
root: root.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 按设置解析出真正的落点。
|
||||
///
|
||||
/// 顺序:`java.installRoot`(设了就用,模式 Custom)→ Standard(数据目录 Standard 模式)
|
||||
/// → Portable(数据根内,含 `--data-dir` 的 Custom 数据模式)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 数据目录是 Standard 但拿不到系统标准目录时返回 [`JavaErrorKind::Unsupported`]
|
||||
/// (不静默落到别处——否则用户会在 `%APPDATA%\Java` 里找不到刚装的 JDK)。
|
||||
pub fn resolve(
|
||||
data_mode: DataMode,
|
||||
launcher: &LauncherLayout,
|
||||
override_root: Option<&Path>,
|
||||
platform: Platform,
|
||||
env: &dyn EnvSource,
|
||||
) -> Result<Self, JavaError> {
|
||||
if let Some(root) = override_root.filter(|root| !root.as_os_str().is_empty()) {
|
||||
let root = if root.is_absolute() {
|
||||
root.to_path_buf()
|
||||
} else {
|
||||
launcher.home().join(root)
|
||||
};
|
||||
return Ok(Self::custom(root));
|
||||
}
|
||||
if data_mode == DataMode::Standard {
|
||||
return Self::standard(platform, env).ok_or_else(|| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Unsupported,
|
||||
"Standard 模式下拿不到系统标准目录(%APPDATA% / HOME 都读不到)",
|
||||
)
|
||||
.with_hint(
|
||||
"设置 `java.installRoot` 指定一个目录,或把 `core.data_mode` 改回 portable",
|
||||
)
|
||||
});
|
||||
}
|
||||
Ok(Self::portable(launcher))
|
||||
}
|
||||
|
||||
/// 某个 JDK 的目录。
|
||||
///
|
||||
/// Standard / Custom 用 `<vendor>/<version>`(D23 用户原话的 `<发行方>\<Java版本>\`),
|
||||
/// Portable 保持 v1 的 `jdk-<major>-<vendor>`(改形状会让既有托管 JDK 变孤儿)。
|
||||
#[must_use]
|
||||
pub fn jdk_dir(&self, major: u32, vendor: JdkVendor, version: &str) -> PathBuf {
|
||||
match self.mode {
|
||||
LocationMode::Standard | LocationMode::Custom => {
|
||||
self.root.join(vendor.id()).join(sanitize_version(version))
|
||||
}
|
||||
LocationMode::Portable => self
|
||||
.root
|
||||
.join(super::probe::managed_dir_name(major, vendor.id())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 归档缓存目录(`<root>/.cache`)。
|
||||
#[must_use]
|
||||
pub fn cache_dir(&self) -> PathBuf {
|
||||
self.root.join(CACHE_DIR)
|
||||
}
|
||||
|
||||
/// 人读描述(`Standard(%APPDATA%\Java)` 这种)。
|
||||
#[must_use]
|
||||
pub fn describe(&self) -> String {
|
||||
format!("{}({})", self.mode.as_str(), self.root.display())
|
||||
}
|
||||
}
|
||||
|
||||
/// OS 标准 JDK 根(纯函数,测试注入环境)。
|
||||
///
|
||||
/// 注意 `HOME` 不用 `platform::paths::home_dir`:那个函数按**编译目标**分叉(Windows 上读
|
||||
/// `USERPROFILE`),会让「在 Windows 上断言 macOS/Linux 落点」这种测试无法成立。
|
||||
/// 这里按传入的 `platform` 参数走,语义与 `paths::standard_launcher_home` 的落点一致。
|
||||
#[must_use]
|
||||
pub fn standard_java_root(platform: Platform, env: &dyn EnvSource) -> Option<PathBuf> {
|
||||
match platform {
|
||||
Platform::Windows => roaming_app_data(env).map(|dir| dir.join(JAVA_DIR_NAME)),
|
||||
Platform::MacOs => {
|
||||
unix_home(env).map(|home| home.join("Library/Application Support").join(JAVA_DIR_NAME))
|
||||
}
|
||||
Platform::Linux | Platform::Other => env
|
||||
.var("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.filter(|dir| dir.is_absolute())
|
||||
.or_else(|| unix_home(env).map(|home| home.join(".local/share")))
|
||||
.map(|dir| dir.join("java")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Unix 家目录(`HOME`,缺了才退回 `platform::paths::home_dir`)。
|
||||
fn unix_home(env: &dyn EnvSource) -> Option<PathBuf> {
|
||||
env.var("HOME").map(PathBuf::from).or_else(|| home_dir(env))
|
||||
}
|
||||
|
||||
/// `%APPDATA%`,回落 `%USERPROFILE%\AppData\Roaming`。
|
||||
fn roaming_app_data(env: &dyn EnvSource) -> Option<PathBuf> {
|
||||
env.var("APPDATA").map(PathBuf::from).or_else(|| {
|
||||
env.var("USERPROFILE")
|
||||
.map(|profile| PathBuf::from(profile).join("AppData/Roaming"))
|
||||
})
|
||||
}
|
||||
|
||||
/// 版本号 → 目录名(只保留文件系统安全的字符)。
|
||||
///
|
||||
/// Temurin `21.0.13+9`、MS `21.0.13.0.2`、Zulu `21.0.13+3` 都原样可作目录名;
|
||||
/// 遇到 `~` / `/` 这类才替换(Zulu 的 `21.0.13+3-x86_64` 之类不会出现,但别赌)。
|
||||
#[must_use]
|
||||
pub fn sanitize_version(version: &str) -> String {
|
||||
let cleaned: String = version
|
||||
.trim()
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_' | '+') {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if cleaned.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
cleaned
|
||||
}
|
||||
}
|
||||
|
||||
/// `java list` 要扫的全部安装根(Portable 数据根在前,去重)。
|
||||
#[must_use]
|
||||
pub fn scan_roots(
|
||||
data_mode: DataMode,
|
||||
launcher: &LauncherLayout,
|
||||
override_root: Option<&Path>,
|
||||
platform: Platform,
|
||||
env: &dyn EnvSource,
|
||||
) -> Vec<PathBuf> {
|
||||
let mut roots = vec![launcher.java_managed()];
|
||||
if let Ok(resolved) =
|
||||
JavaInstallRoot::resolve(data_mode, launcher, override_root, platform, env)
|
||||
{
|
||||
if !roots.contains(&resolved.root) {
|
||||
roots.push(resolved.root);
|
||||
}
|
||||
}
|
||||
// 数据目录是 Standard 时,便携目录里的老装法仍然可能还在(用户从便携切过来的)。
|
||||
if let Some(standard) = standard_java_root(platform, env) {
|
||||
if !roots.contains(&standard) {
|
||||
roots.push(standard);
|
||||
}
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
/// 清理结果。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct CleanupReport {
|
||||
/// 删掉的路径(暂存目录、空壳目录)。
|
||||
pub removed: Vec<PathBuf>,
|
||||
/// 保留下来的条目数(都是我们没资格动的)。
|
||||
pub kept: usize,
|
||||
}
|
||||
|
||||
impl CleanupReport {
|
||||
/// 是否什么都没删。
|
||||
#[must_use]
|
||||
pub fn is_noop(&self) -> bool {
|
||||
self.removed.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 清掉安装根下的半成品与空壳。
|
||||
///
|
||||
/// 只碰两种东西:①`.staging-*`(解压中断的残留,永远可以删)②空目录(含空 vendor 目录)。
|
||||
/// 其余一律保留并计入 `kept`——**宁可不删,也不误删用户手工放进来的 JDK**。
|
||||
#[must_use]
|
||||
pub fn cleanup_orphans(root: &Path) -> CleanupReport {
|
||||
let mut report = CleanupReport::default();
|
||||
let Ok(entries) = std::fs::read_dir(root) else {
|
||||
return report;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if !path.is_dir() || name == CACHE_DIR {
|
||||
report.kept += 1;
|
||||
continue;
|
||||
}
|
||||
if name.starts_with(STAGING_PREFIX) {
|
||||
if std::fs::remove_dir_all(&path).is_ok() {
|
||||
report.removed.push(path);
|
||||
} else {
|
||||
report.kept += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if is_empty_dir(&path) {
|
||||
if std::fs::remove_dir(&path).is_ok() {
|
||||
report.removed.push(path);
|
||||
} else {
|
||||
report.kept += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// 两层的 Standard/Custom 布局:`<vendor>/<version>` 里的空目录也要收。
|
||||
if JdkVendor::parse(&name).is_some() {
|
||||
for child in std::fs::read_dir(&path).into_iter().flatten().flatten() {
|
||||
let child_path = child.path();
|
||||
if child_path.is_dir()
|
||||
&& is_empty_dir(&child_path)
|
||||
&& std::fs::remove_dir(&child_path).is_ok()
|
||||
{
|
||||
report.removed.push(child_path);
|
||||
}
|
||||
}
|
||||
if is_empty_dir(&path) && std::fs::remove_dir(&path).is_ok() {
|
||||
report.removed.push(path);
|
||||
}
|
||||
}
|
||||
report.kept += 1;
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
fn is_empty_dir(dir: &Path) -> bool {
|
||||
std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none())
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
//! MC 版本 ↔ Java 版本映射(平移 HMCL 约束体系)+ 不符检查。
|
||||
//!
|
||||
//! 依据 `DevDocs/analyse/02-launch-pipeline.md` §5.4(HMCL `GameJavaVersion.getMinimumJavaVersion`)
|
||||
//! 与 §3.1 的强制约束清单(`JavaVersionConstraint` 的 `isMandatory` 项)。两条权威叠加:
|
||||
//!
|
||||
//! | MC 版本 | 最低 | 最高 | 出处 |
|
||||
//! | --- | --- | --- | --- |
|
||||
//! | ≥ 26.1 | 25 | — | `java-runtime-epsilon` |
|
||||
//! | ≥ 1.20.5 | 21 | — | `java-runtime-delta` |
|
||||
//! | ≥ 1.18 | 17 | — | `java-runtime-beta` |
|
||||
//! | ≥ 1.17 | 16 | — | `java-runtime-alpha` |
|
||||
//! | ≥ 1.13 | 8 | — | `jre-legacy` |
|
||||
//! | < 1.13 | 8 | **8** | `LAUNCH_WRAPPER`:LaunchWrapper 的 `URLClassLoader` 只能吃 Java 8 |
|
||||
//!
|
||||
//! **版本 JSON 优先**:装了 1.17 的玩家看的是 Mojang 自己写的 `javaVersion.majorVersion`
|
||||
//! (1.17 就是 16),静态表只在清单没写时兜底——这也是 HMCL 的 `GAME_JSON` 强制项语义。
|
||||
//!
|
||||
//! 上限(`maximum`)只对老版本存在:1.12.2 用 Java 17 会直接
|
||||
//! `UnsupportedClassVersionError`-级别的失败(启动器侧是 LaunchWrapper 假设),
|
||||
//! 所以「1.12.2 + Java 21」要被判成不符,而不是「19 以上都能跑」。
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use super::super::inherit::resolve;
|
||||
use super::super::layout::GameLayout;
|
||||
use super::super::manifest::{JavaVersion, VersionJson};
|
||||
|
||||
/// 要求来源(`--json` 的 `requirement_source`)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RequirementSource {
|
||||
/// 版本 JSON 的 `javaVersion`(含继承链解析)。
|
||||
Manifest,
|
||||
/// 静态表(HMCL 约束体系的平移)。
|
||||
Table,
|
||||
}
|
||||
|
||||
impl RequirementSource {
|
||||
/// 稳定字符串 id。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Manifest => "manifest",
|
||||
Self::Table => "table",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个版本对 Java 的要求(闭区间)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct JavaRequirement {
|
||||
/// 最低主版本号(`21`)。
|
||||
pub minimum: u32,
|
||||
/// 最高主版本号(`Some(8)` = 只能用 8 及以下)。
|
||||
pub maximum: Option<u32>,
|
||||
/// 来源。
|
||||
pub source: RequirementSource,
|
||||
}
|
||||
|
||||
impl JavaRequirement {
|
||||
/// 构造。
|
||||
#[must_use]
|
||||
pub const fn new(minimum: u32, maximum: Option<u32>, source: RequirementSource) -> Self {
|
||||
Self {
|
||||
minimum,
|
||||
maximum,
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
/// 静态表来源的构造。
|
||||
#[must_use]
|
||||
pub const fn table(minimum: u32, maximum: Option<u32>) -> Self {
|
||||
Self::new(minimum, maximum, RequirementSource::Table)
|
||||
}
|
||||
|
||||
/// 某主版本号是否满足要求。
|
||||
#[must_use]
|
||||
pub fn satisfied_by(&self, major: u32) -> bool {
|
||||
major >= self.minimum && self.maximum.is_none_or(|maximum| major <= maximum)
|
||||
}
|
||||
|
||||
/// 人读描述(`Java 21+` / `Java 8` / `Java 8–16`)。
|
||||
#[must_use]
|
||||
pub fn describe(&self) -> String {
|
||||
match self.maximum {
|
||||
Some(maximum) if maximum == self.minimum => format!("Java {}", self.minimum),
|
||||
Some(maximum) => format!("Java {}–{}", self.minimum, maximum),
|
||||
None => format!("Java {}+", self.minimum),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 不符的两种形态(D13:只提示,不阻断)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum JavaMismatch {
|
||||
/// Java 太低。
|
||||
TooOld {
|
||||
/// 需要的最低主版本。
|
||||
required: u32,
|
||||
/// 实际主版本。
|
||||
actual: u32,
|
||||
},
|
||||
/// Java 太高(老版本只能用 8)。
|
||||
TooNew {
|
||||
/// 允许的最高主版本。
|
||||
maximum: u32,
|
||||
/// 实际主版本。
|
||||
actual: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl JavaMismatch {
|
||||
/// 稳定字符串 id(`--json` 的 `mismatch.code`)。
|
||||
#[must_use]
|
||||
pub const fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::TooOld { .. } => "java_too_old",
|
||||
Self::TooNew { .. } => "java_too_new",
|
||||
}
|
||||
}
|
||||
|
||||
/// 人读说明(GUI 弹窗 P1 后直接复用这句)。
|
||||
#[must_use]
|
||||
pub fn message(self) -> String {
|
||||
match self {
|
||||
Self::TooOld { required, actual } => format!(
|
||||
"该版本需要 Java {required} 及以上,当前用的是 Java {actual}(大概率起不来)"
|
||||
),
|
||||
Self::TooNew { maximum, actual } => format!(
|
||||
"该版本只能用 Java {maximum} 及以下,当前用的是 Java {actual}(老加载器吃不了新 class 文件)"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 判需求与实际是否相符(`None` = 相符)。
|
||||
#[must_use]
|
||||
pub fn check(actual: u32, required: &JavaRequirement) -> Option<JavaMismatch> {
|
||||
if actual < required.minimum {
|
||||
return Some(JavaMismatch::TooOld {
|
||||
required: required.minimum,
|
||||
actual,
|
||||
});
|
||||
}
|
||||
if let Some(maximum) = required.maximum {
|
||||
if actual > maximum {
|
||||
return Some(JavaMismatch::TooNew { maximum, actual });
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 静态表:MC 版本 → Java 要求。
|
||||
///
|
||||
/// 无法解析的版本(周快照 `20w14a`、魔改 id)只给下限 `8`、不设上限——
|
||||
/// 宁可漏判一次,也不要把「Java 21 + 1.20 快照」误锁成「必须降到 8」。
|
||||
#[must_use]
|
||||
pub fn requirement_for(mc_version: &str) -> JavaRequirement {
|
||||
let parts = version_parts(mc_version);
|
||||
if parts.is_empty() {
|
||||
return JavaRequirement::table(8, None);
|
||||
}
|
||||
if at_least(&parts, &[26, 1]) {
|
||||
return JavaRequirement::table(25, None);
|
||||
}
|
||||
if at_least(&parts, &[1, 20, 5]) {
|
||||
return JavaRequirement::table(21, None);
|
||||
}
|
||||
if at_least(&parts, &[1, 18]) {
|
||||
return JavaRequirement::table(17, None);
|
||||
}
|
||||
if at_least(&parts, &[1, 17]) {
|
||||
return JavaRequirement::table(16, None);
|
||||
}
|
||||
if at_least(&parts, &[1, 13]) {
|
||||
return JavaRequirement::table(8, None);
|
||||
}
|
||||
JavaRequirement::table(8, Some(8))
|
||||
}
|
||||
|
||||
/// 静态表的最低位(`recommended_major("1.20.5") == 21`)。
|
||||
#[must_use]
|
||||
pub fn recommended_major(mc_version: &str) -> u32 {
|
||||
requirement_for(mc_version).minimum
|
||||
}
|
||||
|
||||
/// 版本 JSON 的 `javaVersion` → 要求(字段缺失或为 0 时返回 `None`)。
|
||||
///
|
||||
/// Mojang 这个字段给的是**最低**要求(`majorVersion: 21` 表示 21 起跳),
|
||||
/// 不声明上限——所以这里 `maximum` 恒为 `None`;上限由 [`requirement_for`] 补
|
||||
/// (见 [`requirement_for_version`] 的合成说明)。
|
||||
#[must_use]
|
||||
pub fn requirement_from_manifest(java: &JavaVersion) -> Option<JavaRequirement> {
|
||||
let major = java.major_version.filter(|major| *major > 0)?;
|
||||
Some(JavaRequirement::new(
|
||||
major,
|
||||
None,
|
||||
RequirementSource::Manifest,
|
||||
))
|
||||
}
|
||||
|
||||
/// 已安装版本的 Java 要求:解析继承链 → 版本 JSON 的 `javaVersion` 优先 → 静态表兜底。
|
||||
///
|
||||
/// **下限与上限来源不同**:Mojang 的 `javaVersion` 只声明「最低要多少」,
|
||||
/// 而「1.12.2 及更早吃不了 Java 9+」这条知识来自 HMCL 的 `LAUNCH_WRAPPER` 约束,
|
||||
/// 不在清单里。所以合成时下限取清单、上限取静态表——否则「1.12.2 + Java 21」
|
||||
/// 会被判成完全合法(真机上必崩)。
|
||||
///
|
||||
/// 版本 JSON 读不到(未安装 / 损坏)时直接走静态表,不报错——这是「启动前提示」的路径,
|
||||
/// 不该因为清单缺件就拦住用户(真正拦人的是 `snapshot::prepare`)。
|
||||
#[must_use]
|
||||
pub fn requirement_for_version(layout: &GameLayout, version_id: &str) -> JavaRequirement {
|
||||
let table = requirement_for(version_id);
|
||||
let read = |id: &str| -> Option<VersionJson> {
|
||||
let text = std::fs::read_to_string(layout.version_json(id)).ok()?;
|
||||
VersionJson::from_json_str(&text).ok()
|
||||
};
|
||||
let Some(manifest) = read(version_id) else {
|
||||
return table;
|
||||
};
|
||||
let resolved = resolve(&read, &manifest).unwrap_or(manifest);
|
||||
let Some(declared) = resolved
|
||||
.java_version
|
||||
.as_ref()
|
||||
.and_then(requirement_from_manifest)
|
||||
else {
|
||||
// 清单没写 javaVersion:按 id 的静态表(继承来的 id 可能是魔改名)。
|
||||
return match resolved.id.as_deref() {
|
||||
Some(id) => requirement_for(id),
|
||||
None => table,
|
||||
};
|
||||
};
|
||||
JavaRequirement {
|
||||
minimum: declared.minimum,
|
||||
maximum: table.maximum,
|
||||
source: RequirementSource::Manifest,
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本串 → 数字分量(`1.20.5-pre1` → `[1, 20, 5]`;周快照 → 空)。
|
||||
fn version_parts(raw: &str) -> Vec<u32> {
|
||||
let trimmed = raw.trim();
|
||||
if is_weekly_snapshot(trimmed) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut parts = Vec::new();
|
||||
for piece in trimmed.split('.') {
|
||||
let digits: String = piece.chars().take_while(char::is_ascii_digit).collect();
|
||||
match digits.parse::<u32>() {
|
||||
Ok(value) => parts.push(value),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
/// `20w14a` / `26w14a` 形态(与 `launch::game_version` 同一套判据)。
|
||||
fn is_weekly_snapshot(raw: &str) -> bool {
|
||||
let bytes = raw.as_bytes();
|
||||
bytes.len() == 6
|
||||
&& bytes[..2].iter().all(u8::is_ascii_digit)
|
||||
&& bytes[2].eq_ignore_ascii_case(&b'w')
|
||||
&& bytes[3..5].iter().all(u8::is_ascii_digit)
|
||||
&& bytes[5].is_ascii_alphabetic()
|
||||
}
|
||||
|
||||
/// `parts >= floor`(缺位按 0:`1.20` < `1.20.5`)。
|
||||
fn at_least(parts: &[u32], floor: &[u32]) -> bool {
|
||||
let len = parts.len().max(floor.len());
|
||||
for index in 0..len {
|
||||
let left = parts.get(index).copied().unwrap_or(0);
|
||||
let right = floor.get(index).copied().unwrap_or(0);
|
||||
match left.cmp(&right) {
|
||||
Ordering::Greater => return true,
|
||||
Ordering::Less => return false,
|
||||
Ordering::Equal => {}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{check, recommended_major, requirement_for, JavaMismatch, JavaRequirement};
|
||||
|
||||
#[test]
|
||||
fn table_covers_the_hmcl_boundaries() {
|
||||
let cases: &[(&str, u32, Option<u32>)] = &[
|
||||
("1.6.4", 8, Some(8)),
|
||||
("1.12.2", 8, Some(8)),
|
||||
("1.13", 8, None),
|
||||
("1.15.2", 8, None),
|
||||
("1.16.5", 8, None),
|
||||
("1.17", 16, None),
|
||||
("1.17.1", 16, None),
|
||||
("1.18", 17, None),
|
||||
("1.19.4", 17, None),
|
||||
("1.20.1", 17, None),
|
||||
("1.20.4", 17, None),
|
||||
("1.20.5", 21, None),
|
||||
("1.21.4", 21, None),
|
||||
("26.1", 25, None),
|
||||
("26.3", 25, None),
|
||||
("26.3-snapshot-1", 25, None),
|
||||
];
|
||||
for (version, minimum, maximum) in cases {
|
||||
let requirement = requirement_for(version);
|
||||
assert_eq!(
|
||||
(requirement.minimum, requirement.maximum),
|
||||
(*minimum, *maximum),
|
||||
"{version}"
|
||||
);
|
||||
assert_eq!(recommended_major(version), *minimum, "{version}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekly_snapshots_get_no_upper_bound() {
|
||||
// 20w14a 的前导数字是 20,绝不能被当成「26.x」或误锁上限。
|
||||
let requirement = requirement_for("20w14a");
|
||||
assert_eq!(requirement.minimum, 8);
|
||||
assert_eq!(requirement.maximum, None);
|
||||
assert!(requirement.satisfied_by(21));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_release_suffixes_do_not_change_the_floor() {
|
||||
assert_eq!(requirement_for("1.20.5-pre1").minimum, 21);
|
||||
assert_eq!(requirement_for("1.20.4-rc1").minimum, 17);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_reports_both_directions() {
|
||||
let modern = JavaRequirement::table(21, None);
|
||||
assert_eq!(check(21, &modern), None);
|
||||
assert_eq!(check(25, &modern), None);
|
||||
assert_eq!(
|
||||
check(17, &modern),
|
||||
Some(JavaMismatch::TooOld {
|
||||
required: 21,
|
||||
actual: 17
|
||||
})
|
||||
);
|
||||
let legacy = JavaRequirement::table(8, Some(8));
|
||||
assert_eq!(check(8, &legacy), None);
|
||||
assert_eq!(
|
||||
check(21, &legacy),
|
||||
Some(JavaMismatch::TooNew {
|
||||
maximum: 8,
|
||||
actual: 21
|
||||
})
|
||||
);
|
||||
assert_eq!(legacy.describe(), "Java 8");
|
||||
assert_eq!(modern.describe(), "Java 21+");
|
||||
assert_eq!(JavaRequirement::table(8, Some(16)).describe(), "Java 8–16");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatch_messages_name_both_sides() {
|
||||
let message = JavaMismatch::TooOld {
|
||||
required: 21,
|
||||
actual: 8,
|
||||
}
|
||||
.message();
|
||||
assert!(message.contains("Java 21"));
|
||||
assert!(message.contains("Java 8"));
|
||||
assert_eq!(
|
||||
JavaMismatch::TooNew {
|
||||
maximum: 8,
|
||||
actual: 21
|
||||
}
|
||||
.code(),
|
||||
"java_too_new"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Microsoft OpenJDK:`aka.ms` 模板下载 + 成对 `.sha256sum.txt` 校验。
|
||||
//!
|
||||
//! 分发形态与 Temurin / Oracle 都不同(research/06 §3.2):
|
||||
//!
|
||||
//! ```text
|
||||
//! 下载 https://aka.ms/download-jdk/microsoft-jdk-{version}-{os}-{arch}.zip
|
||||
//! → 301 → https://download.visualstudio.microsoft.com/download/pr/{guid}/{hash}/{同名}
|
||||
//! 校验 https://aka.ms/download-jdk/<同一文件名>.sha256sum.txt # "<hash> <filename>"
|
||||
//! ```
|
||||
//!
|
||||
//! 三条硬事实(都影响实现):
|
||||
//!
|
||||
//! 1. **版本是四段/五段**(`21.0.13.0.2` = JDK 21.0.13+2;11 线是 `11.0.32.1`),
|
||||
//! 不能拿 Temurin 的三段号直接拼——所以这里有自己的 [`parse_version`] 与内置快照表;
|
||||
//! 2. **没有 8**(微软已停发,官方页面让去下 Temurin 8),`supports()` 也这么判;
|
||||
//! 3. **GitHub 侧没有任何资产**(`microsoft/openjdk` 的 release `assets` 全空),
|
||||
//! 所以 gh-proxy 对这条链毫无用处——`domestic_mirrors` 实测为空数组。
|
||||
|
||||
use super::error::{JavaError, JavaErrorKind};
|
||||
use super::net;
|
||||
use super::release::{parse_sha256_text, JdkArchive, JdkRelease};
|
||||
use super::target::AdoptiumTarget;
|
||||
use super::vendor::JdkVendor;
|
||||
use crate::download::MsOpenJdkTable;
|
||||
|
||||
/// 内置版本快照(`learn.microsoft.com/java/openjdk/download` 2026-10-30 的表,research/06 §1.3)。
|
||||
///
|
||||
/// 官方只有人读 HTML 表、没有 JSON API,逐版本探测又要 `aka.ms` 301(0.3–0.8 s/次),
|
||||
/// 所以按 research/06 §3.3 的建议 A 内置快照;`size` 与实测字节数一致,用于长度对拍。
|
||||
pub const SNAPSHOT: &[(u32, &str, u64)] = &[
|
||||
(25, "25.0.5.0.2", 220_869_659),
|
||||
(21, "21.0.13.0.2", 201_450_315),
|
||||
(17, "17.0.21.0.2", 187_236_595),
|
||||
(11, "11.0.32.1", 191_250_397),
|
||||
];
|
||||
|
||||
/// 四段/五段版本号(`21.0.13.0.2` / `11.0.32.1`)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MsVersion {
|
||||
/// 大版本(`21`)。
|
||||
pub major: u32,
|
||||
/// 各数字段(长度 3..=5)。
|
||||
pub segments: Vec<u32>,
|
||||
}
|
||||
|
||||
impl MsVersion {
|
||||
/// 原样的版本串。
|
||||
#[must_use]
|
||||
pub fn text(&self) -> String {
|
||||
self.segments
|
||||
.iter()
|
||||
.map(u32::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(".")
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 MS 的版本串;段数必须 3..=5(MS 的语义:`major.update.patch[.build[.rev]]`)。
|
||||
#[must_use]
|
||||
pub fn parse_version(text: &str) -> Option<MsVersion> {
|
||||
let mut segments = Vec::new();
|
||||
for segment in text.trim().split('.') {
|
||||
segments.push(segment.parse::<u32>().ok()?);
|
||||
}
|
||||
if segments.len() < 3 || segments.len() > 5 {
|
||||
return None;
|
||||
}
|
||||
Some(MsVersion {
|
||||
major: segments[0],
|
||||
segments,
|
||||
})
|
||||
}
|
||||
|
||||
/// MS 的 os 段(`windows` / `macos` / `linux`)。
|
||||
#[must_use]
|
||||
pub fn ms_os(target: AdoptiumTarget) -> &'static str {
|
||||
match target.os {
|
||||
"mac" => "macos",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// MS 的 arch 段(`x64` / `aarch64`)。
|
||||
#[must_use]
|
||||
pub fn ms_arch(target: AdoptiumTarget) -> &'static str {
|
||||
target.arch
|
||||
}
|
||||
|
||||
/// 归档文件名(模板:`microsoft-jdk-{version}-{os}-{arch}.zip`,全平台都是 zip)。
|
||||
#[must_use]
|
||||
pub fn archive_name(version: &str, target: AdoptiumTarget) -> String {
|
||||
format!(
|
||||
"microsoft-jdk-{version}-{}-{}.zip",
|
||||
ms_os(target),
|
||||
ms_arch(target)
|
||||
)
|
||||
}
|
||||
|
||||
/// 把模板里的 `{version}` / `{os}` / `{arch}` 换掉。
|
||||
#[must_use]
|
||||
pub fn expand_template(template: &str, version: &str, target: AdoptiumTarget) -> String {
|
||||
template
|
||||
.replace("{version}", version)
|
||||
.replace("{os}", ms_os(target))
|
||||
.replace("{arch}", ms_arch(target))
|
||||
}
|
||||
|
||||
/// 快照里该大版本的版本串(没有 → `None`)。
|
||||
#[must_use]
|
||||
pub fn snapshot_version(major: u32) -> Option<&'static str> {
|
||||
SNAPSHOT
|
||||
.iter()
|
||||
.find(|(known, _, _)| *known == major)
|
||||
.map(|(_, version, _)| *version)
|
||||
}
|
||||
|
||||
/// 快照里该大版本的字节数。
|
||||
#[must_use]
|
||||
pub fn snapshot_size(major: u32) -> Option<u64> {
|
||||
SNAPSHOT
|
||||
.iter()
|
||||
.find(|(known, _, _)| *known == major)
|
||||
.map(|(_, _, size)| *size)
|
||||
}
|
||||
|
||||
/// 查一次「某大版本的最新 MS OpenJDK」。
|
||||
///
|
||||
/// 两跳:内置快照挑版本 → 取 `.sha256sum.txt` 拿哈希。校验文件取不到就整家失败
|
||||
/// (宁可降级到 Zulu,也不下一个没法验证的 200 MB 归档)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 表里没配模板、该大版本微软不发(含 8)、校验文件取不到或格式不对时返回错误。
|
||||
pub async fn fetch_latest(
|
||||
client: &reqwest::Client,
|
||||
table: &MsOpenJdkTable,
|
||||
major: u32,
|
||||
target: AdoptiumTarget,
|
||||
) -> Result<JdkRelease, JavaError> {
|
||||
let version = snapshot_version(major).ok_or_else(|| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!(
|
||||
"Microsoft OpenJDK 没有 {major} 的 GA(只发 {})",
|
||||
majors_text()
|
||||
),
|
||||
)
|
||||
.with_hint("8 用 temurin;其它大版本用 oracle / zulu")
|
||||
})?;
|
||||
let filename = archive_name(version, target);
|
||||
let sha_url = expand_template(&table.sha256_template, version, target);
|
||||
let sha_text = net::get_text(client, &sha_url, "Microsoft OpenJDK 校验文件").await?;
|
||||
let sha256 = parse_sha256_text(&sha_text).ok_or_else(|| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Integrity,
|
||||
format!("{filename} 的 .sha256sum.txt 里没有 64 位哈希"),
|
||||
)
|
||||
})?;
|
||||
Ok(JdkRelease {
|
||||
vendor: JdkVendor::Microsoft,
|
||||
major,
|
||||
version: version.to_string(),
|
||||
semver: None,
|
||||
release_name: version.to_string(),
|
||||
archive: JdkArchive {
|
||||
name: filename.clone(),
|
||||
url: expand_template(&table.template, version, target),
|
||||
sha256,
|
||||
size: snapshot_size(major).unwrap_or(0),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn majors_text() -> String {
|
||||
SNAPSHOT
|
||||
.iter()
|
||||
.map(|(major, _, _)| major.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
archive_name, expand_template, ms_os, parse_version, snapshot_size, snapshot_version,
|
||||
SNAPSHOT,
|
||||
};
|
||||
use crate::game::java::target::AdoptiumTarget;
|
||||
|
||||
const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64");
|
||||
|
||||
#[test]
|
||||
fn snapshot_covers_the_four_majors_microsoft_ships() {
|
||||
assert_eq!(snapshot_version(21), Some("21.0.13.0.2"));
|
||||
assert_eq!(snapshot_version(11), Some("11.0.32.1"));
|
||||
assert_eq!(snapshot_version(8), None, "微软已停发 8");
|
||||
assert_eq!(snapshot_size(17), Some(187_236_595));
|
||||
assert_eq!(
|
||||
SNAPSHOT
|
||||
.iter()
|
||||
.map(|(major, _, _)| *major)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![25, 21, 17, 11]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_and_five_segment_versions_have_their_own_semantics() {
|
||||
// 21.0.13.0.2 = JDK 21.0.13+2(五段);11 线只有四段。
|
||||
let ms = parse_version("21.0.13.0.2").expect("五段应解析");
|
||||
assert_eq!(ms.major, 21);
|
||||
assert_eq!(ms.segments.len(), 5);
|
||||
assert_eq!(ms.text(), "21.0.13.0.2");
|
||||
let eleven = parse_version("11.0.32.1").expect("四段应解析");
|
||||
assert_eq!(eleven.major, 11);
|
||||
assert_eq!(eleven.segments.len(), 4);
|
||||
// Temurin 的三段号形态在这里同样成立,但语义由快照表定义,不共用 Temurin 的解析。
|
||||
assert_eq!(
|
||||
parse_version("17.0.21").map(|version| version.major),
|
||||
Some(17)
|
||||
);
|
||||
assert_eq!(parse_version("21"), None);
|
||||
assert_eq!(parse_version("21.0"), None);
|
||||
assert_eq!(parse_version("21.0.13.0.2.1"), None);
|
||||
assert_eq!(parse_version("21.0.x.2"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_names_and_templates_expand_per_platform() {
|
||||
assert_eq!(
|
||||
archive_name("21.0.13.0.2", WINDOWS),
|
||||
"microsoft-jdk-21.0.13.0.2-windows-x64.zip"
|
||||
);
|
||||
let template = "https://aka.ms/download-jdk/microsoft-jdk-{version}-{os}-{arch}.zip";
|
||||
assert_eq!(
|
||||
expand_template(template, "17.0.21.0.2", WINDOWS),
|
||||
"https://aka.ms/download-jdk/microsoft-jdk-17.0.21.0.2-windows-x64.zip"
|
||||
);
|
||||
assert_eq!(
|
||||
expand_template(
|
||||
"https://aka.ms/download-jdk/microsoft-jdk-{version}-{os}-{arch}.zip.sha256sum.txt",
|
||||
"21.0.13.0.2",
|
||||
AdoptiumTarget::new("linux", "aarch64")
|
||||
),
|
||||
"https://aka.ms/download-jdk/microsoft-jdk-21.0.13.0.2-linux-aarch64.zip.sha256sum.txt"
|
||||
);
|
||||
assert_eq!(ms_os(AdoptiumTarget::new("mac", "x64")), "macos");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_table_uses_the_official_templates() {
|
||||
let table = crate::download::MirrorTable::builtin();
|
||||
let microsoft = table.microsoft_openjdk().expect("内置表应带 MS 分发");
|
||||
assert!(microsoft
|
||||
.template
|
||||
.starts_with("https://aka.ms/download-jdk/"));
|
||||
assert!(microsoft.sha256_template.ends_with(".sha256sum.txt"));
|
||||
assert!(microsoft.domestic_mirrors.is_empty(), "MS 无国内镜像");
|
||||
assert_eq!(microsoft.majors, vec![25, 21, 17, 11]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Java 运行时管理(D13 v1 → D23 v2):托管 JDK 的查询 / 下载 / 解压 / 探测,以及 MC↔Java 映射。
|
||||
//!
|
||||
//! ```text
|
||||
//! target 宿主平台 → Adoptium 的 os/arch(Win=zip、Linux/mac=tar.gz)
|
||||
//! vendor 四家发行版与用户可调的优先级链(国内 Temurin→Oracle→MS / 国外 Temurin→MS→Oracle)
|
||||
//! api Temurin:api.adoptium.net 查询最新 GA → (github 直链, sha256, size)
|
||||
//! oracle Oracle OpenJDK:镜像目录页解析(hash 段不可推导,禁止拼 URL)+ .sha256
|
||||
//! microsoft MS OpenJDK:aka.ms 模板 + 内置四段版本快照 + .sha256sum.txt
|
||||
//! zulu Azul Zulu:两跳 JSON API(列表 → 详情)+ CDN 直链(第四备选)
|
||||
//! source 发行版分派 + 回退链记录(命中与降级原因进事件流)
|
||||
//! location 落点三模式(Portable / Standard / Custom)+ 孤儿目录清理
|
||||
//! release 一次版本查询的统一产物(vendor 原生版本串 + 归档 + sha256 + 大小)
|
||||
//! net 版本查询用的极小 GET 文本工具(下载不走这里)
|
||||
//! install 编排:回退链 → 下载引擎(镜像/健康度/退避)→ 解压 → 探测验证
|
||||
//! extract zip / tar.gz 解压 + 顶层目录剥离 + 幂等戳记 + zip-slip 防护
|
||||
//! probe java -version 探测、托管目录扫描(两种布局都认)
|
||||
//! discover 标准安装位置扫描(Program Files 系 / 官方启动器 runtime / 注册表 / /usr/lib/jvm)+ 去重
|
||||
//! select 启动选 Java 的**纯**优先链(满足要求优先,再按来源)
|
||||
//! mapping MC↔Java 静态表(HMCL `GameJavaVersion`)+ 版本 JSON 优先 + 不符检查
|
||||
//! ```
|
||||
//!
|
||||
//! # 三条边界
|
||||
//!
|
||||
//! 1. **不碰网络细节**:包下载一律交 `core::download` 的引擎,本模块不写重试循环
|
||||
//! (`MirrorTable::expand` 负责 Temurin 的镜像展开;Oracle 的目录解析在 `oracle`);
|
||||
//! 2. **不弹窗**:不符检查只产出数据([`JavaMismatch`]),提示交给 CLI 的告警行与 GUI;
|
||||
//! 3. **不改启动策略**:「用哪个 Java 启动」是启动期的派生结果,不落盘;落盘的是**装到哪**
|
||||
//! ([`JavaInstallRoot`],由 `java.installRoot` + 数据目录模式决定)。
|
||||
//!
|
||||
//! # 托管布局(D23 后两种并存)
|
||||
//!
|
||||
//! ```text
|
||||
//! Portable:data/java-managed/
|
||||
//! ├── .cache/OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip 归档缓存(可删)
|
||||
//! └── jdk-21-temurin/ 一个 major+vendor 一份(永不改)
|
||||
//! ├── .ull-jdk.json 戳记(幂等判据)
|
||||
//! └── bin/java[.exe]
|
||||
//!
|
||||
//! Standard / Custom:%APPDATA%\Java\<vendor>\<version>\ (mac/Linux 见 location 模块)
|
||||
//! ```
|
||||
|
||||
pub mod api;
|
||||
pub mod discover;
|
||||
pub mod error;
|
||||
pub mod extract;
|
||||
pub mod install;
|
||||
pub mod location;
|
||||
pub mod mapping;
|
||||
pub mod microsoft;
|
||||
pub mod net;
|
||||
pub mod oracle;
|
||||
pub mod probe;
|
||||
pub mod release;
|
||||
pub mod select;
|
||||
pub mod source;
|
||||
pub mod target;
|
||||
pub mod vendor;
|
||||
pub mod zulu;
|
||||
|
||||
pub use self::api::{
|
||||
latest_url, parse_latest, JdkArchive, JdkRelease, ADOPTIUM_API_BASE, JDK_VENDOR,
|
||||
};
|
||||
pub use self::discover::{
|
||||
candidates, candidates_in_roots, scan_java_binaries, standard_candidates, standard_roots,
|
||||
};
|
||||
pub use self::error::{JavaError, JavaErrorKind};
|
||||
pub use self::extract::{
|
||||
extract_archive, is_ready, java_binary, java_binary_name, read_stamp, ExtractReport, JdkStamp,
|
||||
STAMP_FILE,
|
||||
};
|
||||
pub use self::install::{
|
||||
archive_path, archive_request, chain_order, install, managed_root_dir, plan, InstallRequest,
|
||||
InstalledJdk,
|
||||
};
|
||||
pub use self::location::{
|
||||
cleanup_orphans, sanitize_version, scan_roots, CleanupReport, JavaInstallRoot, LocationMode,
|
||||
};
|
||||
pub use self::mapping::{
|
||||
check, recommended_major, requirement_for, requirement_for_version, JavaMismatch,
|
||||
JavaRequirement, RequirementSource,
|
||||
};
|
||||
pub use self::probe::{
|
||||
discover_managed, discover_managed_roots, managed_dir_name, parse_managed_dir, probe,
|
||||
resolve_on_path, system_candidates, JavaOrigin, JavaRuntime, DEFAULT_MAJOR,
|
||||
};
|
||||
pub use self::release::parse_sha256_text;
|
||||
pub use self::select::{pick, resolve, JavaResolution};
|
||||
pub use self::source::{ChainLog, VendorAttempt};
|
||||
pub use self::target::AdoptiumTarget;
|
||||
pub use self::vendor::{order as vendor_order, JdkVendor, VENDOR_IDS};
|
||||
@@ -0,0 +1,60 @@
|
||||
//! 版本查询用的极小 HTTP 取文本工具(四家发行版的「一锤子 GET」共用)。
|
||||
//!
|
||||
//! 只有一件事:GET → 状态分类 → 整段文本。**下载不走这里**(那归 `core::download` 引擎),
|
||||
//! 所以没有重试、没有镜像展开、没有进度事件——查清单失败就直接把错误抛给发行版回退链,
|
||||
//! 让它换下一家(这是 D23「vendor 级失败自动降下一 vendor」的最短路径)。
|
||||
|
||||
use super::error::{JavaError, JavaErrorKind};
|
||||
|
||||
/// 取一段文本(`what` 是给用户看的名词,例如「华为云 openjdk 目录」)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 连不上 / 超时 → [`JavaErrorKind::Network`];404 或 410 → [`JavaErrorKind::Metadata`]
|
||||
/// (「这个东西上游没有」比「网络坏了」更准确,回退链据此换 vendor 而不是重试同一条)。
|
||||
pub async fn get_text(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
what: &str,
|
||||
) -> Result<String, JavaError> {
|
||||
let response = client.get(url).send().await.map_err(|error| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Network,
|
||||
format!("取 {what} 失败:{error}({url})"),
|
||||
)
|
||||
})?;
|
||||
let status = response.status();
|
||||
if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::GONE {
|
||||
return Err(JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!("{what} 不存在(HTTP {status}):{url}"),
|
||||
));
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(JavaError::new(
|
||||
JavaErrorKind::Network,
|
||||
format!("{what} 返回 HTTP {status}:{url}"),
|
||||
));
|
||||
}
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let body = response.text().await.map_err(|error| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Network,
|
||||
format!("读 {what} 响应失败:{error}"),
|
||||
)
|
||||
})?;
|
||||
// 门户兜底页会把「路径不存在」伪装成 200 + HTML;版本清单页面本身可能也是 HTML,
|
||||
// 所以这里不做内容判定,只看调用方能不能解析出来(解析失败会给出更准的原因)。
|
||||
if body.trim().is_empty() {
|
||||
return Err(JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!("{what} 返回空响应(Content-Type: {content_type}):{url}"),
|
||||
));
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
//! Oracle OpenJDK(`download.java.net` GA):**枚举与下载都走国内镜像目录页**。
|
||||
//!
|
||||
//! 为什么不能像 Temurin 那样「先拿官方链接、再反解镜像路径」:Oracle 的直链里有一整段
|
||||
//! 构建哈希(`/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip`),
|
||||
//! 那段**不可推导**(research/06 §4.1 实测)。于是本模块的纪律是:
|
||||
//!
|
||||
//! > 候选 URL 的 `{ver}` 段与文件名段都必须来自对镜像目录页的解析(`parse_links`),
|
||||
//! > 代码里没有任何一处把哈希段拼出来。
|
||||
//!
|
||||
//! 目录形状(华为云 / 南大与官方完全同构,§4.2):
|
||||
//!
|
||||
//! ```text
|
||||
//! {base}/ → Apache 列表页:21/ 21.0.1/ 21.0.2/ 27.0.1.0.3/ …
|
||||
//! {base}/{ver}/ → 文件列表:openjdk-21.0.2_windows-x64_bin.zip …
|
||||
//! {base}/{ver}/{filename} → 归档本体(大小与官方逐字节一致)
|
||||
//! {base}/{ver}/{filename}.sha256 → 64 位哈希纯文本(成对提供)
|
||||
//! ```
|
||||
//!
|
||||
//! 三处实测坑(都写进了实现):
|
||||
//!
|
||||
//! 1. **华为云对任意不存在的路径都回 200 + 12 434 B 门户 HTML**(假阳性)——所以本模块只信
|
||||
//! 「目录页里真的出现了这个文件名」,不信状态码;下载侧另有 `download::http::html_mirror_reason`
|
||||
//! 兜一层(Content-Type 非 HTML + Content-Length 对拍)。
|
||||
//! 2. **只发「首个 GA + ≤2 个 update」**:21 线最高只到 21.0.2,别当补丁源用。
|
||||
//! 3. **目录页给的大小是四舍五入的**(`192 MB`)→ 不可当 `expected_size`,本模块一律填 0,
|
||||
//! 校验交给成对的 `.sha256`。
|
||||
|
||||
use super::error::{JavaError, JavaErrorKind};
|
||||
use super::net;
|
||||
use super::release::{parse_sha256_text, JdkArchive, JdkRelease};
|
||||
use super::target::AdoptiumTarget;
|
||||
use super::vendor::JdkVendor;
|
||||
use crate::download::MirrorTable;
|
||||
|
||||
/// Oracle 的 os 段(Adoptium 的 `mac` 在 Oracle 里叫 `macos`)。
|
||||
#[must_use]
|
||||
pub fn oracle_os(target: AdoptiumTarget) -> &'static str {
|
||||
match target.os {
|
||||
"mac" => "macos",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Oracle 的 arch 段(`x86-32` → `x86`)。
|
||||
#[must_use]
|
||||
pub fn oracle_arch(target: AdoptiumTarget) -> &'static str {
|
||||
match target.arch {
|
||||
"x86-32" => "x86",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// 目录页 → 本级条目名列表(去掉尾部 `/`、跳过 `../` 与绝对链接,去重保序)。
|
||||
#[must_use]
|
||||
pub fn parse_links(html: &str) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut rest = html;
|
||||
while let Some(start) = rest.find("href=\"") {
|
||||
let after = &rest[start + "href=\"".len()..];
|
||||
let Some(end) = after.find('"') else {
|
||||
break;
|
||||
};
|
||||
let value = after[..end].trim();
|
||||
rest = &after[end..];
|
||||
if value.is_empty()
|
||||
|| value.starts_with("http")
|
||||
|| value.starts_with("//")
|
||||
|| value.starts_with('?')
|
||||
|| value.contains('#')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let name = value.trim_end_matches('/');
|
||||
let Some(name) = name.rsplit('/').next() else {
|
||||
continue;
|
||||
};
|
||||
if name.is_empty() || name == "." || name == ".." {
|
||||
continue;
|
||||
}
|
||||
if !out.iter().any(|known| known == name) {
|
||||
out.push(name.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 版本串 → 可比较的数字段(`21.0.2` → `[21, 0, 2]`,`27.0.1.0.3` → `[27,0,1,0,3]`)。
|
||||
#[must_use]
|
||||
pub fn version_key(version: &str) -> Option<Vec<u64>> {
|
||||
let mut parts = Vec::new();
|
||||
for segment in version.split('.') {
|
||||
parts.push(segment.parse::<u64>().ok()?);
|
||||
}
|
||||
(!parts.is_empty()).then_some(parts)
|
||||
}
|
||||
|
||||
/// 目录页里属于 `major` 的版本串,按版本**降序**(最新在前)。
|
||||
#[must_use]
|
||||
pub fn versions_for(html: &str, major: u32) -> Vec<String> {
|
||||
let mut found: Vec<(Vec<u64>, String)> = parse_links(html)
|
||||
.into_iter()
|
||||
.filter_map(|name| {
|
||||
let key = version_key(&name)?;
|
||||
(key.first() == Some(&u64::from(major))).then_some((key, name))
|
||||
})
|
||||
.collect();
|
||||
found.sort_by(|left, right| right.0.cmp(&left.0));
|
||||
found.into_iter().map(|(_, name)| name).collect()
|
||||
}
|
||||
|
||||
/// 取 `major` 线的最新 GA 版本串。
|
||||
#[must_use]
|
||||
pub fn pick_version(html: &str, major: u32) -> Option<String> {
|
||||
versions_for(html, major).into_iter().next()
|
||||
}
|
||||
|
||||
/// 期望文件名(Oracle 的命名是**模板**,哈希只在目录层级上,不在文件名里)。
|
||||
#[must_use]
|
||||
pub fn archive_name(version: &str, target: AdoptiumTarget) -> String {
|
||||
format!(
|
||||
"openjdk-{version}_{}-{}_bin.{}",
|
||||
oracle_os(target),
|
||||
oracle_arch(target),
|
||||
target.archive_ext()
|
||||
)
|
||||
}
|
||||
|
||||
/// `{base}/{ver}/{filename}`。
|
||||
#[must_use]
|
||||
pub fn archive_url(base: &str, version: &str, filename: &str) -> String {
|
||||
format!("{}/{version}/{filename}", base.trim_end_matches('/'))
|
||||
}
|
||||
|
||||
/// 归档旁的 `.sha256` 地址。
|
||||
#[must_use]
|
||||
pub fn checksum_url(base: &str, version: &str, filename: &str) -> String {
|
||||
format!("{}.sha256", archive_url(base, version, filename))
|
||||
}
|
||||
|
||||
/// 目录页里是否有这个文件(**唯一**的「存在」判据:不信状态码)。
|
||||
#[must_use]
|
||||
pub fn find_archive(html: &str, filename: &str) -> bool {
|
||||
parse_links(html).iter().any(|name| name == filename)
|
||||
}
|
||||
|
||||
/// 查一次「某大版本的最新 Oracle OpenJDK GA」。
|
||||
///
|
||||
/// 按 `mirrors.json` 里 `oracle_openjdk` 的启用顺序逐个镜像试(国内:华为云 → 南大),
|
||||
/// 每个镜像三跳:目录页 → 版本目录页 → `.sha256`。任何一跳失败就记下原因换下一个镜像。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 所有镜像都失败(没有该大版本 / 目录页不可解析 / 缺 sha256)时返回错误,
|
||||
/// 由回退链决定降级到下一家发行版。
|
||||
pub async fn fetch_latest(
|
||||
client: &reqwest::Client,
|
||||
table: &MirrorTable,
|
||||
major: u32,
|
||||
target: AdoptiumTarget,
|
||||
) -> Result<JdkRelease, JavaError> {
|
||||
let mirrors: Vec<_> = table.oracle_mirrors().collect();
|
||||
if mirrors.is_empty() {
|
||||
return Err(JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
"镜像清单里没有启用的 oracle_openjdk 源",
|
||||
)
|
||||
.with_hint("`mirrors.json` 的 `oracle_openjdk` 表需要至少一个 enabled 条目"));
|
||||
}
|
||||
let mut reasons: Vec<String> = Vec::new();
|
||||
for mirror in mirrors {
|
||||
match fetch_from(client, mirror.base(), major, target).await {
|
||||
Ok(release) => return Ok(release),
|
||||
Err(error) => reasons.push(format!("{}:{}", mirror.name, error.message())),
|
||||
}
|
||||
}
|
||||
Err(JavaError::new(
|
||||
JavaErrorKind::Network,
|
||||
format!(
|
||||
"Oracle OpenJDK {major} 在所有镜像上都取不到:{}",
|
||||
reasons.join(";")
|
||||
),
|
||||
)
|
||||
.with_hint("Oracle 的 21 线只到 21.0.2(只发首个 GA + ≤2 update);换 17/25 或改用 temurin"))
|
||||
}
|
||||
|
||||
/// 单个镜像的完整三跳查询。
|
||||
async fn fetch_from(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
major: u32,
|
||||
target: AdoptiumTarget,
|
||||
) -> Result<JdkRelease, JavaError> {
|
||||
let index = net::get_text(
|
||||
client,
|
||||
&format!("{}/", base.trim_end_matches('/')),
|
||||
"Oracle 镜像根目录",
|
||||
)
|
||||
.await?;
|
||||
let version = pick_version(&index, major).ok_or_else(|| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!(
|
||||
"镜像目录里没有 {major} 线的 Oracle OpenJDK(目录页 {} 项)",
|
||||
parse_links(&index).len()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let filename = archive_name(&version, target);
|
||||
let listing = net::get_text(
|
||||
client,
|
||||
&format!("{}/{version}/", base.trim_end_matches('/')),
|
||||
"Oracle 版本目录",
|
||||
)
|
||||
.await?;
|
||||
if !find_archive(&listing, &filename) {
|
||||
return Err(JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!("{version} 目录里没有 {filename}(该平台没有产物,或目录页不可解析)"),
|
||||
));
|
||||
}
|
||||
let sha_text = net::get_text(
|
||||
client,
|
||||
&checksum_url(base, &version, &filename),
|
||||
"Oracle 归档 sha256",
|
||||
)
|
||||
.await?;
|
||||
let sha256 = parse_sha256_text(&sha_text).ok_or_else(|| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Integrity,
|
||||
format!("{filename} 的 .sha256 不是 64 位十六进制"),
|
||||
)
|
||||
})?;
|
||||
Ok(JdkRelease {
|
||||
vendor: JdkVendor::Oracle,
|
||||
major,
|
||||
version: version.clone(),
|
||||
semver: None,
|
||||
release_name: version.clone(),
|
||||
archive: JdkArchive {
|
||||
name: filename.clone(),
|
||||
url: archive_url(base, &version, &filename),
|
||||
sha256,
|
||||
// 目录页给的是四舍五入的大小(192 MB),不能当 expected_size。
|
||||
size: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
archive_name, archive_url, checksum_url, find_archive, oracle_os, parse_links,
|
||||
pick_version, version_key, versions_for,
|
||||
};
|
||||
use crate::game::java::target::AdoptiumTarget;
|
||||
|
||||
/// 华为云 `/openjdk/` 的真实目录页片段(2026-10-30 抓取,`<pre>` 列表)。
|
||||
const INDEX: &str = r#"<html><body><pre>Name Last modified Size</pre><hr><pre>
|
||||
<a href="10/" onclick="handleLinkUrl(this)">10/</a> 22-Aug-2021 15:18 -
|
||||
<a href="17.0.2/" onclick="handleLinkUrl(this)">17.0.2/</a> 22-Aug-2021 15:19 -
|
||||
<a href="21/" onclick="handleLinkUrl(this)">21/</a> 22-Aug-2021 15:19 -
|
||||
<a href="21.0.1/" onclick="handleLinkUrl(this)">21.0.1/</a> 22-Aug-2021 15:19 -
|
||||
<a href="21.0.2/" onclick="handleLinkUrl(this)">21.0.2/</a> 06-Jan-2024 15:26 -
|
||||
<a href="27.0.1.0.3/" onclick="handleLinkUrl(this)">27.0.1.0.3/</a> 06-Jan-2026 15:26 -
|
||||
<a href="../" onclick="handleLinkUrl(this)">../</a>
|
||||
</pre></body></html>"#;
|
||||
|
||||
/// 华为云 `/openjdk/21.0.2/` 的真实文件列表片段。
|
||||
const LISTING: &str = r#"<html><body><pre><a href="../">../</a>
|
||||
<a href="openjdk-21.0.2_linux-x64_bin.tar.gz" onclick="handleLinkUrl(this)">openjdk-21.0.2_linux-x64_bin.tar.gz</a> 194.18 MB
|
||||
<a href="openjdk-21.0.2_windows-x64_bin.zip" onclick="handleLinkUrl(this)">openjdk-21.0.2_windows-x64_bin.zip</a> 192 MB
|
||||
</pre></body></html>"#;
|
||||
|
||||
const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64");
|
||||
|
||||
#[test]
|
||||
fn directory_links_are_scraped_without_any_hash_segment() {
|
||||
let links = parse_links(INDEX);
|
||||
assert!(links.contains(&"21.0.2".to_string()));
|
||||
assert!(!links.iter().any(|name| name == ".."));
|
||||
// Oracle 直链里的构建哈希(f2283984656d49d69e91c558476027ac)在镜像目录里根本不存在:
|
||||
// 目录层级只有 `{ver}/`,所以实现里没有任何拼哈希的位置。
|
||||
assert!(!links.iter().any(|name| name.len() == 32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newest_version_of_a_line_wins_and_ordering_is_numeric() {
|
||||
assert_eq!(
|
||||
versions_for(INDEX, 21),
|
||||
vec!["21.0.2".to_string(), "21.0.1".to_string(), "21".to_string()]
|
||||
);
|
||||
assert_eq!(pick_version(INDEX, 21).as_deref(), Some("21.0.2"));
|
||||
// 27.0.1.0.3 是四段+,比 27.0.1 新(字符串比较会得出相反结论)。
|
||||
assert_eq!(version_key("27.0.1.0.3"), Some(vec![27, 0, 1, 0, 3]));
|
||||
assert_eq!(version_key("27.0.1"), Some(vec![27, 0, 1]));
|
||||
assert!(version_key("27.0.1") < version_key("27.0.1.0.3"));
|
||||
assert_eq!(pick_version(INDEX, 8), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filename_template_matches_the_archive_on_every_platform() {
|
||||
assert_eq!(
|
||||
archive_name("21.0.2", WINDOWS),
|
||||
"openjdk-21.0.2_windows-x64_bin.zip"
|
||||
);
|
||||
assert_eq!(
|
||||
archive_name("21.0.2", AdoptiumTarget::new("linux", "aarch64")),
|
||||
"openjdk-21.0.2_linux-aarch64_bin.tar.gz"
|
||||
);
|
||||
assert_eq!(
|
||||
archive_name("17.0.2", AdoptiumTarget::new("mac", "x64")),
|
||||
"openjdk-17.0.2_macos-x64_bin.tar.gz"
|
||||
);
|
||||
assert_eq!(oracle_os(AdoptiumTarget::new("mac", "x64")), "macos");
|
||||
assert_eq!(oracle_os(WINDOWS), "windows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_is_only_published_when_it_really_appears_in_the_listing() {
|
||||
let filename = archive_name("21.0.2", WINDOWS);
|
||||
assert!(find_archive(LISTING, &filename));
|
||||
// 华为云的门户兜底页(任意路径 200 + HTML)里没有这个链接 → 必须判为不存在。
|
||||
assert!(!find_archive(
|
||||
"<html><body>mirrors 门户</body></html>",
|
||||
&filename
|
||||
));
|
||||
assert_eq!(
|
||||
archive_url(
|
||||
"https://mirrors.huaweicloud.com/openjdk/",
|
||||
"21.0.2",
|
||||
&filename
|
||||
),
|
||||
format!("https://mirrors.huaweicloud.com/openjdk/21.0.2/{filename}")
|
||||
);
|
||||
assert_eq!(
|
||||
checksum_url(
|
||||
"https://mirrors.huaweicloud.com/openjdk",
|
||||
"21.0.2",
|
||||
&filename
|
||||
),
|
||||
format!("https://mirrors.huaweicloud.com/openjdk/21.0.2/{filename}.sha256")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
//! Java 运行时探测:跑一次 `java -version`、扫托管目录、列系统候选。
|
||||
//!
|
||||
//! 探测动作只有两种:读托管目录(`.ULL/data/java-managed/jdk-<major>-<vendor>/`)
|
||||
//! 与跑 `java -version`(输出在 stderr,主版本号从引号里的版本号解析,
|
||||
//! `1.8.0_402` 这种老形态换算成 8)。
|
||||
//!
|
||||
//! 「选哪个 Java 启动」不在这里,而在 [`super::select`]——那边是**纯函数**,
|
||||
//! 候选运行时由调用方注入,测试因此不需要真的装三个 JDK。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use super::extract::{java_binary_name, read_stamp};
|
||||
use super::vendor::JdkVendor;
|
||||
|
||||
/// 缺省 Java 主版本(探测不到任何东西时的保守值,D13:JDK 17 基准)。
|
||||
pub const DEFAULT_MAJOR: u32 = 17;
|
||||
|
||||
/// 托管目录名前缀。
|
||||
pub const MANAGED_PREFIX: &str = "jdk-";
|
||||
|
||||
/// 一个 Java 运行时的来源。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum JavaOrigin {
|
||||
/// 实例覆盖(`--java` 参数或实例档案的 `javaPath`)。
|
||||
Instance,
|
||||
/// 托管目录(`ull java install` 装的)。
|
||||
Managed,
|
||||
/// `JAVA_HOME`。
|
||||
JavaHome,
|
||||
/// `PATH` 上的 `java`。
|
||||
Path,
|
||||
/// 标准安装位置扫出来的(MSI 装的 Temurin / 官方启动器的 `runtime/` / 注册表等)。
|
||||
System,
|
||||
}
|
||||
|
||||
impl JavaOrigin {
|
||||
/// 稳定字符串 id(`--json` 的 `source` 字段)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Instance => "instance",
|
||||
Self::Managed => "managed",
|
||||
Self::JavaHome => "JAVA_HOME",
|
||||
Self::Path => "PATH",
|
||||
Self::System => "system",
|
||||
}
|
||||
}
|
||||
|
||||
/// 展示类别(`--json` 的 `source_category`):GUI 用它分组,不必区分 `JAVA_HOME`/`PATH`。
|
||||
#[must_use]
|
||||
pub const fn category(self) -> &'static str {
|
||||
match self {
|
||||
Self::Instance => "instance",
|
||||
Self::Managed => "managed",
|
||||
Self::JavaHome | Self::Path => "env",
|
||||
Self::System => "system",
|
||||
}
|
||||
}
|
||||
|
||||
/// 同档内的优先序(小者优先)。
|
||||
///
|
||||
/// 「环境变量」先于「扫盘」:用户显式设过的 `JAVA_HOME`/`PATH` 是意图表达,
|
||||
/// 盘上扫出来的只是候选(HMCL/PCL 也是这个顺序)。
|
||||
#[must_use]
|
||||
pub const fn rank(self) -> u8 {
|
||||
match self {
|
||||
Self::Instance => 0,
|
||||
Self::Managed => 1,
|
||||
Self::JavaHome => 2,
|
||||
Self::Path => 3,
|
||||
Self::System => 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个探测到的 Java 运行时。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct JavaRuntime {
|
||||
/// 可执行文件。
|
||||
pub binary: PathBuf,
|
||||
/// 来源。
|
||||
pub origin: JavaOrigin,
|
||||
/// `java -version` 的首行(含 `version` 字样)。
|
||||
pub version: Option<String>,
|
||||
/// 主版本号(`1.8.0_402` → 8)。
|
||||
pub major: Option<u32>,
|
||||
/// 位数(32 / 64;探测不到时按宿主)。
|
||||
pub bits: u32,
|
||||
/// 托管目录里的供应商(`temurin`);其它来源为 `None`。
|
||||
pub vendor: Option<String>,
|
||||
}
|
||||
|
||||
impl JavaRuntime {
|
||||
/// 探测不出来时给个「版本未知」的占位。
|
||||
#[must_use]
|
||||
pub fn unknown(binary: impl Into<PathBuf>, origin: JavaOrigin) -> Self {
|
||||
Self {
|
||||
binary: binary.into(),
|
||||
origin,
|
||||
version: None,
|
||||
major: None,
|
||||
bits: host_bits(),
|
||||
vendor: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 主版本号(未知时退回 [`DEFAULT_MAJOR`])。
|
||||
#[must_use]
|
||||
pub fn major_or_default(&self) -> u32 {
|
||||
self.major.unwrap_or(DEFAULT_MAJOR)
|
||||
}
|
||||
}
|
||||
|
||||
/// 跑一次 `java -version` 填满运行时信息(探测失败不报错,只留空字段)。
|
||||
///
|
||||
/// 失败不报错的理由:`ull java list` 要能列出「探测不到的项」而不是整体失败,
|
||||
/// 而启动路径上探测失败会自然退化为系统默认 Java(见 [`super::select::resolve`])。
|
||||
#[must_use]
|
||||
pub fn probe(binary: &Path, origin: JavaOrigin) -> JavaRuntime {
|
||||
let mut runtime = JavaRuntime::unknown(binary.to_path_buf(), origin);
|
||||
let Ok(output) = Command::new(binary).arg("-version").output() else {
|
||||
return runtime;
|
||||
};
|
||||
let mut text = String::from_utf8_lossy(&output.stderr).into_owned();
|
||||
text.push_str(&String::from_utf8_lossy(&output.stdout));
|
||||
if let Some((line, major)) = parse_version_output(&text) {
|
||||
runtime.major = Some(major);
|
||||
runtime.version = Some(line);
|
||||
runtime.bits = bits_of(&text);
|
||||
}
|
||||
runtime
|
||||
}
|
||||
|
||||
/// 扫描一个安装根里的托管 JDK,逐个探测。
|
||||
///
|
||||
/// **两种布局都要认**(D23 之后同时存在):
|
||||
///
|
||||
/// ```text
|
||||
/// <root>/jdk-<major>-<vendor>/bin/java Portable(v1 布局,M33 定下,永不改)
|
||||
/// <root>/<vendor>/<version>/bin/java Standard / Custom(D23 的 <发行方>/<版本>)
|
||||
/// ```
|
||||
///
|
||||
/// 只认带戳记的目录:托管区是我们自己写的地方,但没有戳记的子目录可能是用户塞进来的
|
||||
/// 别的东西,不去猜它。`major` 一律以 `java -version` 为准(戳记只用于幂等判定)。
|
||||
#[must_use]
|
||||
pub fn discover_managed(managed_root: &Path) -> Vec<JavaRuntime> {
|
||||
let mut found: Vec<JavaRuntime> = Vec::new();
|
||||
let Ok(entries) = std::fs::read_dir(managed_root) else {
|
||||
return found;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if !path.is_dir() || name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
if let Some(runtime) = probe_managed_dir(&path, parse_managed_dir(&name).map(|(_, v)| v)) {
|
||||
found.push(runtime);
|
||||
continue;
|
||||
}
|
||||
// 两层布局:`<vendor>/<version>`——vendor 名取自父目录名。
|
||||
if JdkVendor::parse(&name).is_some() {
|
||||
let Ok(children) = std::fs::read_dir(&path) else {
|
||||
continue;
|
||||
};
|
||||
for child in children.flatten() {
|
||||
let child_path = child.path();
|
||||
if let Some(runtime) = probe_managed_dir(&child_path, Some(&name)) {
|
||||
found.push(runtime);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort_runtimes(&mut found);
|
||||
found
|
||||
}
|
||||
|
||||
/// 扫多个安装根(Portable 数据根 + Standard/Custom 落点),按 `bin` 路径去重。
|
||||
#[must_use]
|
||||
pub fn discover_managed_roots(roots: &[PathBuf]) -> Vec<JavaRuntime> {
|
||||
let mut found: Vec<JavaRuntime> = Vec::new();
|
||||
for root in roots {
|
||||
for runtime in discover_managed(root) {
|
||||
if !found.iter().any(|known| known.binary == runtime.binary) {
|
||||
found.push(runtime);
|
||||
}
|
||||
}
|
||||
}
|
||||
sort_runtimes(&mut found);
|
||||
found
|
||||
}
|
||||
|
||||
/// 探测一个候选托管目录(有戳记 + `bin/java` 才算数)。
|
||||
fn probe_managed_dir(path: &Path, vendor: Option<&str>) -> Option<JavaRuntime> {
|
||||
if !path.is_dir() || read_stamp(path).is_none() {
|
||||
return None;
|
||||
}
|
||||
let binary = path.join("bin").join(java_binary_name());
|
||||
if !binary.is_file() {
|
||||
return None;
|
||||
}
|
||||
let mut runtime = probe(&binary, JavaOrigin::Managed);
|
||||
runtime.vendor = vendor.map(str::to_string);
|
||||
Some(runtime)
|
||||
}
|
||||
|
||||
/// 大版本升序,输出稳定(同 major 按路径)。
|
||||
fn sort_runtimes(found: &mut [JavaRuntime]) {
|
||||
found.sort_by(|left, right| {
|
||||
left.major
|
||||
.cmp(&right.major)
|
||||
.then_with(|| left.binary.cmp(&right.binary))
|
||||
});
|
||||
}
|
||||
|
||||
/// 系统来源的候选:`JAVA_HOME` 与 `PATH` 上的 `java`(都探测一次)。
|
||||
///
|
||||
/// `PATH` 上的那一项会先**解析成绝对路径**再探测:否则它与扫盘结果(绝对路径)看起来是
|
||||
/// 两个不同的文件,`java list` 会把同一个 JDK 列两次。
|
||||
#[must_use]
|
||||
pub fn system_candidates() -> Vec<JavaRuntime> {
|
||||
let mut found = Vec::new();
|
||||
if let Some(home) = std::env::var_os("JAVA_HOME") {
|
||||
let binary = PathBuf::from(home).join("bin").join(java_binary_name());
|
||||
if binary.is_file() {
|
||||
found.push(probe(&binary, JavaOrigin::JavaHome));
|
||||
}
|
||||
}
|
||||
let name = java_binary_name();
|
||||
let binary = resolve_on_path(name).unwrap_or_else(|| PathBuf::from(name));
|
||||
found.push(probe(&binary, JavaOrigin::Path));
|
||||
found
|
||||
}
|
||||
|
||||
/// 在 `PATH` 里找某个可执行文件的绝对路径(找不到返回 `None`)。
|
||||
#[must_use]
|
||||
pub fn resolve_on_path(name: &str) -> Option<PathBuf> {
|
||||
let path = std::env::var_os("PATH")?;
|
||||
std::env::split_paths(&path)
|
||||
.map(|dir| dir.join(name))
|
||||
.find(|candidate| candidate.is_file())
|
||||
}
|
||||
|
||||
/// 托管目录名(`jdk-21-temurin`)。
|
||||
#[must_use]
|
||||
pub fn managed_dir_name(major: u32, vendor: &str) -> String {
|
||||
format!("{MANAGED_PREFIX}{major}-{vendor}")
|
||||
}
|
||||
|
||||
/// 反解托管目录名 →(大版本,供应商)。
|
||||
#[must_use]
|
||||
pub fn parse_managed_dir(name: &str) -> Option<(u32, &str)> {
|
||||
let rest = name.strip_prefix(MANAGED_PREFIX)?;
|
||||
let (major, vendor) = rest.split_once('-')?;
|
||||
let major: u32 = major.parse().ok()?;
|
||||
(!vendor.is_empty()).then_some((major, vendor))
|
||||
}
|
||||
|
||||
/// 从 `java -version` 输出里取(含 `version` 的首行,主版本号)。
|
||||
#[must_use]
|
||||
pub fn parse_version_output(text: &str) -> Option<(String, u32)> {
|
||||
let line = text
|
||||
.lines()
|
||||
.find(|line| line.contains("version"))
|
||||
.or_else(|| text.lines().find(|line| !line.trim().is_empty()))?
|
||||
.trim();
|
||||
let version = quoted_version(line).or_else(|| bare_version(line))?;
|
||||
let major = major_of(&version)?;
|
||||
Some((line.to_string(), major))
|
||||
}
|
||||
|
||||
/// 引号里的版本号(`openjdk version "21.0.13" 2026-01-20 LTS`)。
|
||||
fn quoted_version(line: &str) -> Option<String> {
|
||||
let start = line.find('"')? + 1;
|
||||
let rest = &line[start..];
|
||||
let end = rest.find('"')?;
|
||||
Some(rest[..end].to_string())
|
||||
}
|
||||
|
||||
/// 无引号形态(`openjdk 25.0.3 2026-04-21`)。
|
||||
///
|
||||
/// 「含 `version` 的那一段之后逐个试」优先——避开 `Java(TM) SE Runtime` 这类噪声;
|
||||
/// 没有 `version` 字样时从头试(部分精简发行版就这么打印)。
|
||||
fn bare_version(line: &str) -> Option<String> {
|
||||
let tokens: Vec<&str> = line.split_whitespace().collect();
|
||||
let start = tokens
|
||||
.iter()
|
||||
.position(|token| token.contains("version"))
|
||||
.map_or(0, |position| position + 1);
|
||||
tokens[start..].iter().find_map(|token| {
|
||||
let candidate = token.trim_matches(['"', ',', '(', ')']);
|
||||
(candidate.contains('.') && major_of(candidate).is_some()).then(|| candidate.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// 版本号 → 主版本(`1.8.0_402` → 8,`21.0.13` → 21)。
|
||||
#[must_use]
|
||||
pub fn major_of(version: &str) -> Option<u32> {
|
||||
let mut parts = version.split(['.', '_', '-']);
|
||||
let first = parts.next()?.parse::<u32>().ok()?;
|
||||
if first == 1 {
|
||||
parts.next()?.parse::<u32>().ok()
|
||||
} else {
|
||||
Some(first)
|
||||
}
|
||||
}
|
||||
|
||||
/// `java -version` 输出里的位数(`64-Bit` / `32-Bit`;读不到按宿主)。
|
||||
#[must_use]
|
||||
pub fn bits_of(text: &str) -> u32 {
|
||||
let lowered = text.to_ascii_lowercase();
|
||||
if lowered.contains("64-bit") {
|
||||
64
|
||||
} else if lowered.contains("32-bit") {
|
||||
32
|
||||
} else {
|
||||
host_bits()
|
||||
}
|
||||
}
|
||||
|
||||
/// 宿主指针宽度(32 / 64)。
|
||||
#[must_use]
|
||||
pub const fn host_bits() -> u32 {
|
||||
if cfg!(target_pointer_width = "64") {
|
||||
64
|
||||
} else {
|
||||
32
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{bits_of, major_of, managed_dir_name, parse_managed_dir, parse_version_output};
|
||||
|
||||
#[test]
|
||||
fn parses_modern_and_legacy_version_lines() {
|
||||
let modern =
|
||||
parse_version_output("openjdk version \"21.0.13\" 2026-01-20 LTS\nOpenJDK 64-Bit");
|
||||
assert_eq!(modern.map(|(_, major)| major), Some(21));
|
||||
let legacy = parse_version_output("java version \"1.8.0_402\"\nJava(TM) SE Runtime");
|
||||
assert_eq!(legacy.map(|(_, major)| major), Some(8));
|
||||
let bare = parse_version_output("openjdk 25.0.3 2026-04-21\nOpenJDK 64-Bit Server VM");
|
||||
assert_eq!(bare.map(|(_, major)| major), Some(25));
|
||||
assert!(parse_version_output("no java here").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_bit_width() {
|
||||
assert_eq!(bits_of("OpenJDK 64-Bit Server VM (build 25.0.3)"), 64);
|
||||
assert_eq!(bits_of("Java HotSpot(TM) Client VM (32-Bit)"), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn major_of_handles_legacy_scheme() {
|
||||
assert_eq!(major_of("1.8.0_402"), Some(8));
|
||||
assert_eq!(major_of("17.0.13+11"), Some(17));
|
||||
assert_eq!(major_of("not-a-version"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_dir_names_round_trip() {
|
||||
assert_eq!(parse_managed_dir("jdk-21-temurin"), Some((21, "temurin")));
|
||||
assert_eq!(parse_managed_dir("jdk-8-temurin"), Some((8, "temurin")));
|
||||
assert_eq!(parse_managed_dir(".staging-jdk-21-temurin"), None);
|
||||
assert_eq!(parse_managed_dir("java-21-temurin"), None);
|
||||
assert_eq!(parse_managed_dir("jdk-x-temurin"), None);
|
||||
assert_eq!(managed_dir_name(25, "temurin"), "jdk-25-temurin");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! 一次版本查询的统一产物:一个可下载的 JDK 归档(四家发行版共用形态)。
|
||||
//!
|
||||
//! 各家的**版本号语义不同**(research/06 §5.3 第 3 条),所以这里只存「vendor 原生的版本串」
|
||||
//! 而不是硬拆成 semver 四段:
|
||||
//!
|
||||
//! | 发行版 | 版本串 | 出处 |
|
||||
//! | --- | --- | --- |
|
||||
//! | Temurin | `21.0.13+9`(三段+构建号) | Adoptium `release_name` |
|
||||
//! | Oracle OpenJDK | `21.0.2`(三段,只到 GA + ≤2 update) | 镜像目录名 |
|
||||
//! | Microsoft OpenJDK | `21.0.13.0.2`(四段/五段),11 线是 `11.0.32.1` | `aka.ms` 文件名 |
|
||||
//! | Azul Zulu | `21.0.13+3`(三段+构建号,文件名里还有 `21.0.13.0.3` 另一种写法) | Zulu API |
|
||||
//!
|
||||
//! 目录名、戳记、`--json` 都直接用这个串(`location::sanitize_version` 只做字符白名单)。
|
||||
|
||||
use super::vendor::JdkVendor;
|
||||
|
||||
/// 一个可下载的 JDK 归档。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct JdkArchive {
|
||||
/// 产物文件名(`OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip`)。
|
||||
pub name: String,
|
||||
/// 官方下载链接(镜像展开的输入;Oracle / MS / Zulu 已经是最终链接)。
|
||||
pub url: String,
|
||||
/// SHA-256(小写十六进制)。
|
||||
pub sha256: String,
|
||||
/// 字节数(`0` = 上游没给可信大小,引擎跳过长度对拍只验哈希)。
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
/// 一次版本查询的结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct JdkRelease {
|
||||
/// 发行版。
|
||||
pub vendor: JdkVendor,
|
||||
/// 大版本号(`21`)。
|
||||
pub major: u32,
|
||||
/// vendor 原生的完整版本串(目录名与展示都用它)。
|
||||
pub version: String,
|
||||
/// Temurin 系才有的 semver(其它家为 `None`)。
|
||||
pub semver: Option<String>,
|
||||
/// release 名(Temurin `jdk-21.0.13+9`;其它家用版本串)。
|
||||
pub release_name: String,
|
||||
/// 归档。
|
||||
pub archive: JdkArchive,
|
||||
}
|
||||
|
||||
impl JdkRelease {
|
||||
/// 是否就是这个发行版 + 大版本的产物。
|
||||
#[must_use]
|
||||
pub fn matches(&self, vendor: JdkVendor, major: u32) -> bool {
|
||||
self.vendor == vendor && self.major == major
|
||||
}
|
||||
|
||||
/// sha256(幂等判据用)。
|
||||
#[must_use]
|
||||
pub fn sha256(&self) -> &str {
|
||||
&self.archive.sha256
|
||||
}
|
||||
|
||||
/// 人读一行(`Temurin 21.0.13+9 · OpenJDK21U-…zip`)。
|
||||
#[must_use]
|
||||
pub fn summary(&self) -> String {
|
||||
format!(
|
||||
"{} {} · {}",
|
||||
self.vendor.display(),
|
||||
self.version,
|
||||
self.archive.name
|
||||
)
|
||||
}
|
||||
|
||||
/// `--json` 的归档块(四家共用一个形状)。
|
||||
#[must_use]
|
||||
pub fn archive_json(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"vendor": self.vendor.id(),
|
||||
"version": self.version,
|
||||
"name": self.archive.name,
|
||||
"url": self.archive.url,
|
||||
"sha256": self.archive.sha256,
|
||||
"size": self.archive.size,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 `release_name`(Temurin 的 `jdk-21.0.13+9`)取版本串。
|
||||
#[must_use]
|
||||
pub fn version_from_release_name(release_name: &str) -> Option<String> {
|
||||
let trimmed = release_name.trim();
|
||||
let stripped = trimmed.strip_prefix("jdk-").unwrap_or(trimmed);
|
||||
(!stripped.is_empty()).then(|| stripped.to_string())
|
||||
}
|
||||
|
||||
/// 解析 sha256 文本(`.sha256` / `.sha256sum.txt` / 裸哈希都吃),校验 64 位十六进制。
|
||||
#[must_use]
|
||||
pub fn parse_sha256_text(text: &str) -> Option<String> {
|
||||
for token in text.split_whitespace() {
|
||||
let lowered = token.trim().to_ascii_lowercase();
|
||||
let candidate = lowered.strip_prefix('*').unwrap_or(lowered.as_str());
|
||||
if candidate.len() == 64 && candidate.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return Some(candidate.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_sha256_text, version_from_release_name, JdkArchive, JdkRelease};
|
||||
use crate::game::java::vendor::JdkVendor;
|
||||
|
||||
fn release() -> JdkRelease {
|
||||
JdkRelease {
|
||||
vendor: JdkVendor::Oracle,
|
||||
major: 21,
|
||||
version: "21.0.2".to_string(),
|
||||
semver: None,
|
||||
release_name: "21.0.2".to_string(),
|
||||
archive: JdkArchive {
|
||||
name: "openjdk-21.0.2_windows-x64_bin.zip".to_string(),
|
||||
url: "https://mirrors.huaweicloud.com/openjdk/21.0.2/openjdk-21.0.2_windows-x64_bin.zip"
|
||||
.to_string(),
|
||||
sha256: "b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"
|
||||
.to_string(),
|
||||
size: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_carries_vendor_and_native_version() {
|
||||
let item = release();
|
||||
assert!(item.matches(JdkVendor::Oracle, 21));
|
||||
assert!(!item.matches(JdkVendor::Microsoft, 21));
|
||||
assert!(item.summary().contains("Oracle OpenJDK 21.0.2"));
|
||||
assert_eq!(item.archive_json()["vendor"], "oracle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_texts_from_every_vendor_parse_the_same_way() {
|
||||
// Huawei 的 .sha256:只有 64 位哈希,没有文件名。
|
||||
assert_eq!(
|
||||
parse_sha256_text("b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664\n")
|
||||
.as_deref(),
|
||||
Some("b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664")
|
||||
);
|
||||
// MS 的 .sha256sum.txt:`<hash> <filename>`,并且带大写与 CRLF。
|
||||
assert_eq!(
|
||||
parse_sha256_text(
|
||||
"6ECFA864AE0A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D4E5F60718293A4 microsoft-jdk-21.0.13-windows-x64.zip\r\n"
|
||||
)
|
||||
.as_deref(),
|
||||
Some("6ecfa864ae0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4")
|
||||
);
|
||||
assert_eq!(parse_sha256_text("<html>404</html>"), None);
|
||||
assert_eq!(parse_sha256_text("deadbeef"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_falls_back_to_the_release_name() {
|
||||
assert_eq!(
|
||||
version_from_release_name("jdk-21.0.13+9").as_deref(),
|
||||
Some("21.0.13+9")
|
||||
);
|
||||
assert_eq!(
|
||||
version_from_release_name("21.0.13+9").as_deref(),
|
||||
Some("21.0.13+9")
|
||||
);
|
||||
assert_eq!(version_from_release_name(""), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! 「启动选哪个 Java」的**纯**优先链(D13)。
|
||||
//!
|
||||
//! 优先级:实例覆盖(`--java` / 档案 `javaPath`)> 托管目录里**满足要求**的 JDK >
|
||||
//! `JAVA_HOME` > `PATH`。实例覆盖由调用方单独判(用户明确指定了就不参与打分),
|
||||
//! 这里只处理「一堆已探测的候选 + 一个要求」的挑选。
|
||||
//!
|
||||
//! 挑选规则(比「机械按来源排序」更贴近用户预期):
|
||||
//!
|
||||
//! 1. **满足要求的候选优先**——1.12.2 只能用 Java 8,托管的 21 再新也不能选;
|
||||
//! 2. 同档内按来源:托管 > `JAVA_HOME` > `PATH`(D13:托管目录是启动器的地盘,
|
||||
//! 用户 PATH 上的 java 可能是另一个 JDK 的残留);
|
||||
//! 3. 全都不满足时仍给一个(按来源优先取最好那个),并报不符——**提示而不是拒绝**。
|
||||
//!
|
||||
//! 抽成纯函数是为了可测:测试注入「托管 21 + PATH 8」这类组合,不必真装 JDK。
|
||||
|
||||
use super::mapping::{check, JavaMismatch, JavaRequirement};
|
||||
use super::probe::{JavaRuntime, DEFAULT_MAJOR};
|
||||
|
||||
/// 一次启动的 Java 选择结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct JavaResolution {
|
||||
/// 选中的运行时(一个候选都没有时 `None`)。
|
||||
pub runtime: Option<JavaRuntime>,
|
||||
/// 目标版本对 Java 的要求。
|
||||
pub required: JavaRequirement,
|
||||
/// 不符说明(`None` = 满足要求,或压根没探测到 Java)。
|
||||
pub mismatch: Option<JavaMismatch>,
|
||||
}
|
||||
|
||||
impl JavaResolution {
|
||||
/// 生效的主版本号(没有运行时按缺省值)。
|
||||
#[must_use]
|
||||
pub fn major(&self) -> u32 {
|
||||
self.runtime
|
||||
.as_ref()
|
||||
.map_or(DEFAULT_MAJOR, JavaRuntime::major_or_default)
|
||||
}
|
||||
|
||||
/// 是否满足要求(没有运行时视为「不知道」,返回 `true`)。
|
||||
#[must_use]
|
||||
pub fn is_satisfied(&self) -> bool {
|
||||
self.mismatch.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// 从候选里挑一个:满足要求的优先,同档内按来源优先序。
|
||||
#[must_use]
|
||||
pub fn pick<'a>(
|
||||
candidates: &'a [JavaRuntime],
|
||||
required: &JavaRequirement,
|
||||
) -> Option<&'a JavaRuntime> {
|
||||
candidates.iter().min_by_key(|runtime| {
|
||||
let ok = runtime
|
||||
.major
|
||||
.is_some_and(|major| required.satisfied_by(major));
|
||||
// 先排「不满足」(`ok=false` 的 key 大),再排来源优先序。
|
||||
(!ok, runtime.origin.rank())
|
||||
})
|
||||
}
|
||||
|
||||
/// 挑 + 判不符。
|
||||
#[must_use]
|
||||
pub fn resolve(candidates: &[JavaRuntime], required: &JavaRequirement) -> JavaResolution {
|
||||
let runtime = pick(candidates, required).cloned();
|
||||
let mismatch = runtime
|
||||
.as_ref()
|
||||
.and_then(|runtime| runtime.major)
|
||||
.and_then(|major| check(major, required));
|
||||
JavaResolution {
|
||||
runtime,
|
||||
required: required.clone(),
|
||||
mismatch,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::resolve;
|
||||
use crate::game::java::mapping::{JavaRequirement, RequirementSource};
|
||||
use crate::game::java::probe::{JavaOrigin, JavaRuntime, DEFAULT_MAJOR};
|
||||
|
||||
fn runtime(major: u32, origin: JavaOrigin) -> JavaRuntime {
|
||||
JavaRuntime {
|
||||
binary: std::path::PathBuf::from(format!("java{major}")),
|
||||
origin,
|
||||
version: Some(format!("openjdk version \"{major}.0.1\"")),
|
||||
major: Some(major),
|
||||
bits: 64,
|
||||
vendor: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn at_least(minimum: u32) -> JavaRequirement {
|
||||
JavaRequirement::new(minimum, None, RequirementSource::Table)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn satisfying_managed_runtime_beats_mismatching_system_java() {
|
||||
let candidates = [
|
||||
runtime(8, JavaOrigin::Path),
|
||||
runtime(21, JavaOrigin::Managed),
|
||||
];
|
||||
let picked = resolve(&candidates, &at_least(17));
|
||||
assert_eq!(picked.major(), 21);
|
||||
assert!(picked.is_satisfied());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn satisfying_system_java_beats_mismatching_managed() {
|
||||
// 1.12.2 只能 Java 8:托管的 21 不满足,PATH 上的 8 满足 → 选 8。
|
||||
let required = JavaRequirement::new(8, Some(8), RequirementSource::Table);
|
||||
let candidates = [
|
||||
runtime(21, JavaOrigin::Managed),
|
||||
runtime(8, JavaOrigin::Path),
|
||||
];
|
||||
let picked = resolve(&candidates, &required);
|
||||
assert_eq!(picked.major(), 8);
|
||||
assert!(picked.is_satisfied());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_wins_when_both_satisfy() {
|
||||
let candidates = [
|
||||
runtime(21, JavaOrigin::Path),
|
||||
runtime(17, JavaOrigin::JavaHome),
|
||||
runtime(17, JavaOrigin::Managed),
|
||||
];
|
||||
assert_eq!(resolve(&candidates, &at_least(17)).major(), 17);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_home_beats_path_when_both_satisfy() {
|
||||
let candidates = [
|
||||
runtime(21, JavaOrigin::Path),
|
||||
runtime(21, JavaOrigin::JavaHome),
|
||||
];
|
||||
let picked = resolve(&candidates, &at_least(21));
|
||||
assert_eq!(
|
||||
picked.runtime.map(|runtime| runtime.origin),
|
||||
Some(JavaOrigin::JavaHome)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_candidate_keeps_the_default_and_reports_no_mismatch() {
|
||||
let picked = resolve(&[], &at_least(21));
|
||||
assert_eq!(picked.major(), DEFAULT_MAJOR);
|
||||
assert!(picked.runtime.is_none());
|
||||
assert!(picked.is_satisfied());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatch_is_reported_when_nothing_fits() {
|
||||
let candidates = [runtime(8, JavaOrigin::Path)];
|
||||
let picked = resolve(&candidates, &at_least(21));
|
||||
assert_eq!(picked.major(), 8);
|
||||
let mismatch = picked.mismatch.expect("应报不符");
|
||||
assert_eq!(mismatch.code(), "java_too_old");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//! 版本查询的发行版分派 + 回退链记录(D23)。
|
||||
//!
|
||||
//! 两件事:
|
||||
//!
|
||||
//! 1. [`fetch`]:把一次「某发行版某大版本的最新 GA」查询分派给对应实现——
|
||||
//! Temurin 走 `api.adoptium.net`(v1 既有),Oracle / MS / Zulu 各走自己的源表;
|
||||
//! 2. [`ChainLog`]:按用户优先级依次尝试时,**每一跳的结果与降级原因**都记下来。
|
||||
//! 这份记录直接进 `--json` 与事件流,是「为什么最后装的是 MS 而不是 Temurin」的唯一解释。
|
||||
//!
|
||||
//! 回退的粒度是**发行版级**(D23 原话):版本不存在、镜像全 4xx、校验失败都算这一家失败,
|
||||
//! 换下一家;同一家内部的镜像换源(TUNA→官方、华为云→南大)仍然归 `core::download` 引擎管。
|
||||
|
||||
use super::api;
|
||||
use super::error::JavaError;
|
||||
use super::microsoft;
|
||||
use super::oracle;
|
||||
use super::release::JdkRelease;
|
||||
use super::target::AdoptiumTarget;
|
||||
use super::vendor::JdkVendor;
|
||||
use super::zulu;
|
||||
use crate::download::DownloadEngine;
|
||||
|
||||
/// 查一次「某发行版某大版本的最新 GA」。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 平台无产物、清单不可达、该家没有这个大版本、校验缺失时返回错误——调用方据此降级。
|
||||
pub async fn fetch(
|
||||
engine: &DownloadEngine,
|
||||
vendor: JdkVendor,
|
||||
major: u32,
|
||||
target: AdoptiumTarget,
|
||||
) -> Result<JdkRelease, JavaError> {
|
||||
let client = engine.client();
|
||||
let table = engine.mirrors();
|
||||
match vendor {
|
||||
JdkVendor::Temurin => api::fetch_latest(client, major, target).await,
|
||||
JdkVendor::Oracle => oracle::fetch_latest(client, table, major, target).await,
|
||||
JdkVendor::Microsoft => {
|
||||
let table = table.microsoft_openjdk().ok_or_else(|| {
|
||||
JavaError::new(
|
||||
super::error::JavaErrorKind::Metadata,
|
||||
"镜像清单里没有 microsoft_openjdk 表",
|
||||
)
|
||||
.with_hint("升级 mirrors.json 到 v3,或改用 --vendor temurin")
|
||||
})?;
|
||||
microsoft::fetch_latest(client, table, major, target).await
|
||||
}
|
||||
JdkVendor::Zulu => {
|
||||
let table = table.zulu().ok_or_else(|| {
|
||||
JavaError::new(
|
||||
super::error::JavaErrorKind::Metadata,
|
||||
"镜像清单里没有 zulu 表",
|
||||
)
|
||||
.with_hint("升级 mirrors.json 到 v3,或改用 --vendor temurin")
|
||||
})?;
|
||||
zulu::fetch_latest(client, table, major, target).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一跳的结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VendorAttempt {
|
||||
/// 尝试的发行版。
|
||||
pub vendor: JdkVendor,
|
||||
/// 是否命中(成功拿到可下载的归档)。
|
||||
pub ok: bool,
|
||||
/// 命中/降级的一句话原因(`"命中"` / `"没有 8 的 GA(只发 11/17/21/25)"`)。
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl VendorAttempt {
|
||||
/// `--json` 形状。
|
||||
#[must_use]
|
||||
pub fn to_json(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"vendor": self.vendor.id(),
|
||||
"ok": self.ok,
|
||||
"detail": self.detail,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条回退链的执行记录。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ChainLog {
|
||||
attempts: Vec<VendorAttempt>,
|
||||
}
|
||||
|
||||
impl ChainLog {
|
||||
/// 空记录。
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 记一次「跳过」(该家不支持这个大版本,根本没查网络)。
|
||||
pub fn skip(&mut self, vendor: JdkVendor, detail: impl Into<String>) {
|
||||
self.attempts.push(VendorAttempt {
|
||||
vendor,
|
||||
ok: false,
|
||||
detail: detail.into(),
|
||||
});
|
||||
}
|
||||
|
||||
/// 记一次失败(查到了但拿不到、或下载/校验失败)。
|
||||
pub fn failed(&mut self, vendor: JdkVendor, detail: impl Into<String>) {
|
||||
self.attempts.push(VendorAttempt {
|
||||
vendor,
|
||||
ok: false,
|
||||
detail: detail.into(),
|
||||
});
|
||||
}
|
||||
|
||||
/// 记一次命中。
|
||||
pub fn hit(&mut self, vendor: JdkVendor, detail: impl Into<String>) {
|
||||
self.attempts.push(VendorAttempt {
|
||||
vendor,
|
||||
ok: true,
|
||||
detail: detail.into(),
|
||||
});
|
||||
}
|
||||
|
||||
/// 是否已经命中(命中后不再往下试)。
|
||||
#[must_use]
|
||||
pub fn hit_vendor(&self) -> Option<JdkVendor> {
|
||||
self.attempts
|
||||
.iter()
|
||||
.find(|attempt| attempt.ok)
|
||||
.map(|attempt| attempt.vendor)
|
||||
}
|
||||
|
||||
/// 全部记录。
|
||||
#[must_use]
|
||||
pub fn attempts(&self) -> &[VendorAttempt] {
|
||||
&self.attempts
|
||||
}
|
||||
|
||||
/// 降级说明(「已跳过」的那些;给事件流与人读告警用)。
|
||||
#[must_use]
|
||||
pub fn degradations(&self) -> Vec<String> {
|
||||
self.attempts
|
||||
.iter()
|
||||
.filter(|attempt| !attempt.ok)
|
||||
.map(|attempt| format!("{}:{}", attempt.vendor.id(), attempt.detail))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 实际尝试了几家(不含跳过)。
|
||||
#[must_use]
|
||||
pub fn tried(&self) -> usize {
|
||||
self.attempts.len()
|
||||
}
|
||||
|
||||
/// `--json` 形状。
|
||||
#[must_use]
|
||||
pub fn to_json(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"hit": self.hit_vendor().map(JdkVendor::id),
|
||||
"attempts": self.attempts.iter().map(VendorAttempt::to_json).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ChainLog;
|
||||
use crate::game::java::vendor::JdkVendor;
|
||||
|
||||
#[test]
|
||||
fn chain_skips_unsupported_majors_and_records_the_reason() {
|
||||
let mut log = ChainLog::new();
|
||||
log.skip(JdkVendor::Oracle, "不支持 8(Oracle archive 从 9 起)");
|
||||
log.skip(JdkVendor::Microsoft, "没有 8 的 GA(只发 11/17/21/25)");
|
||||
log.hit(JdkVendor::Temurin, "命中 8.0.504+1");
|
||||
assert_eq!(log.hit_vendor(), Some(JdkVendor::Temurin));
|
||||
let degradations = log.degradations();
|
||||
assert_eq!(degradations.len(), 2);
|
||||
assert!(degradations[1].contains("microsoft"));
|
||||
assert_eq!(log.attempts().len(), 3);
|
||||
let json = log.to_json();
|
||||
assert_eq!(json["hit"], "temurin");
|
||||
assert_eq!(json["attempts"][0]["vendor"], "oracle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_reasons_are_kept_for_the_event_stream() {
|
||||
let mut log = ChainLog::new();
|
||||
log.failed(JdkVendor::Temurin, "所有镜像都 4xx");
|
||||
log.hit(JdkVendor::Oracle, "命中 21.0.2");
|
||||
assert_eq!(
|
||||
log.degradations(),
|
||||
vec!["temurin:所有镜像都 4xx".to_string()],
|
||||
"降级原因必须原样可读(用户要能看懂为什么换了发行版)"
|
||||
);
|
||||
assert_eq!(log.tried(), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! 宿主平台 → Adoptium 请求参数,以及归档形态(Win = zip,Linux/mac = tar.gz)。
|
||||
//!
|
||||
//! Adoptium 的 `os` / `arch` 取值与 Rust 的 `target_os` / `target_arch` 不同形
|
||||
//! (`windows`/`mac`/`linux` × `x64`/`aarch64`/`x86-32`……),这里做唯一一次翻译。
|
||||
//! 平台差异仍然按项目纪律收敛在 `core::platform` 的语义里,本文件只做**命名映射**,
|
||||
//! 不写任何能力探测。
|
||||
|
||||
use super::error::{JavaError, JavaErrorKind};
|
||||
|
||||
/// 一次 Adoptium 请求的目标。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AdoptiumTarget {
|
||||
/// Adoptium 的 `os` 段(`windows` / `linux` / `mac`)。
|
||||
pub os: &'static str,
|
||||
/// Adoptium 的 `arch` 段(`x64` / `aarch64` / `x86-32` …)。
|
||||
pub arch: &'static str,
|
||||
}
|
||||
|
||||
impl AdoptiumTarget {
|
||||
/// 宿主平台(不认识的架构直接拒绝,不猜一个错的产物下来)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 当前 OS / 架构没有 Temurin 产物时返回 [`JavaErrorKind::Unsupported`]。
|
||||
pub fn host() -> Result<Self, JavaError> {
|
||||
let os = if cfg!(windows) {
|
||||
"windows"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"mac"
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"linux"
|
||||
} else {
|
||||
return Err(JavaError::new(
|
||||
JavaErrorKind::Unsupported,
|
||||
format!("当前系统({})没有 Temurin 产物", std::env::consts::OS),
|
||||
)
|
||||
.with_hint("手动指定 JAVA_HOME,或用 `ull launch --java <path>` 挑已有 JDK"));
|
||||
};
|
||||
let arch = arch_name(std::env::consts::ARCH).ok_or_else(|| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Unsupported,
|
||||
format!("当前架构({})没有 Temurin 产物", std::env::consts::ARCH),
|
||||
)
|
||||
.with_hint("手动指定 JAVA_HOME,或用 `ull launch --java <path>` 挑已有 JDK")
|
||||
})?;
|
||||
Ok(Self { os, arch })
|
||||
}
|
||||
|
||||
/// 构造(测试注入用)。
|
||||
#[must_use]
|
||||
pub const fn new(os: &'static str, arch: &'static str) -> Self {
|
||||
Self { os, arch }
|
||||
}
|
||||
|
||||
/// 该平台的产物是不是 zip(Windows 是;Linux/mac 的 Temurin 只发 `.tar.gz`)。
|
||||
#[must_use]
|
||||
pub fn is_zip(self) -> bool {
|
||||
self.os == "windows"
|
||||
}
|
||||
|
||||
/// 归档扩展名(拼「期望文件名」与给出「不支持的形态」提示时用)。
|
||||
#[must_use]
|
||||
pub fn archive_ext(self) -> &'static str {
|
||||
if self.is_zip() {
|
||||
"zip"
|
||||
} else {
|
||||
"tar.gz"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rust 架构名 → Adoptium 架构名(不认识返回 `None`)。
|
||||
#[must_use]
|
||||
pub fn arch_name(arch: &str) -> Option<&'static str> {
|
||||
match arch {
|
||||
"x86_64" => Some("x64"),
|
||||
"aarch64" => Some("aarch64"),
|
||||
"x86" | "i686" => Some("x86-32"),
|
||||
"arm" => Some("arm"),
|
||||
"ppc64le" => Some("ppc64le"),
|
||||
"s390x" => Some("s390x"),
|
||||
"riscv64" => Some("riscv64"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
//! JDK 发行版(vendor)抽象与用户可调优先级链(D23)。
|
||||
//!
|
||||
//! 四家发行版的**默认顺序按地区分区**(用户定案 + research/06 §5.1 实测修正):
|
||||
//!
|
||||
//! ```text
|
||||
//! 国内:Temurin(TUNA) → Oracle OpenJDK(华为云→南大) → MS OpenJDK(境外 CDN) → Zulu
|
||||
//! 国外:Temurin → MS OpenJDK → Oracle OpenJDK → Zulu
|
||||
//! ```
|
||||
//!
|
||||
//! 国内把 Oracle 提到 MS 之前的依据是实测差 28×(Oracle 国内 11.5 MB/s vs MS 410 KB/s);
|
||||
//! 用户原话是「Temurin → MS → Oracle」,但同一句话里给了「优先级可调」,所以这只是默认值,
|
||||
//! 设置键 `java.vendorPriority` 可以整体覆盖(D23 定稿)。
|
||||
//!
|
||||
//! 这里的表只描述**能力与顺序**,不碰网络:怎么查、怎么下在 `oracle` / `microsoft` /
|
||||
//! `zulu` / `api`(Temurin)四个模块里。
|
||||
|
||||
/// 设置键 `java.vendorPriority` 的允许值(顺序即默认序的覆盖来源)。
|
||||
pub const VENDOR_IDS: &[&str] = &["temurin", "oracle", "microsoft", "zulu"];
|
||||
|
||||
/// 一家 JDK 发行版。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum JdkVendor {
|
||||
/// Eclipse Temurin(Adoptium),v1 唯一实现。
|
||||
Temurin,
|
||||
/// Oracle OpenJDK(`download.java.net` GA,只发首个 GA + ≤2 个 update)。
|
||||
Oracle,
|
||||
/// Microsoft OpenJDK(`aka.ms` 模板 + VS CDN,无 8)。
|
||||
Microsoft,
|
||||
/// Azul Zulu(第四备选,API 元数据 + CDN 直链)。
|
||||
Zulu,
|
||||
}
|
||||
|
||||
impl JdkVendor {
|
||||
/// 全部发行版(枚举顺序,不是优先级顺序)。
|
||||
pub const ALL: [Self; 4] = [Self::Temurin, Self::Oracle, Self::Microsoft, Self::Zulu];
|
||||
|
||||
/// 稳定 id(目录名段、`--vendor` 取值、设置值、JSON 字段)。
|
||||
#[must_use]
|
||||
pub const fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Temurin => "temurin",
|
||||
Self::Oracle => "oracle",
|
||||
Self::Microsoft => "microsoft",
|
||||
Self::Zulu => "zulu",
|
||||
}
|
||||
}
|
||||
|
||||
/// 展示名(人读输出 + `java vendors`)。
|
||||
#[must_use]
|
||||
pub const fn display(self) -> &'static str {
|
||||
match self {
|
||||
Self::Temurin => "Eclipse Temurin",
|
||||
Self::Oracle => "Oracle OpenJDK",
|
||||
Self::Microsoft => "Microsoft OpenJDK",
|
||||
Self::Zulu => "Azul Zulu",
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 `--vendor` / 设置值(容忍常见别名,大小写不敏感)。
|
||||
#[must_use]
|
||||
pub fn parse(text: &str) -> Option<Self> {
|
||||
match text.trim().to_ascii_lowercase().as_str() {
|
||||
"temurin" | "adoptium" | "eclipse" | "eclipse-temurin" => Some(Self::Temurin),
|
||||
"oracle" | "oracle_openjdk" | "openjdk" | "java.net" => Some(Self::Oracle),
|
||||
"microsoft" | "ms" | "msopenjdk" | "ms_openjdk" | "microsoft-openjdk" => {
|
||||
Some(Self::Microsoft)
|
||||
}
|
||||
"zulu" | "azul" => Some(Self::Zulu),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 该发行版能不能装这个大版本。
|
||||
///
|
||||
/// Temurin / Zulu 覆盖 8 起的全部 GA;Oracle 的 archive 从 9 起(8 由 Temurin 负责);
|
||||
/// MS 只发 11/17/21/25(**已停发 8**,官方页面直接让去下 Temurin 8)。
|
||||
#[must_use]
|
||||
pub const fn supports(self, major: u32) -> bool {
|
||||
match self {
|
||||
Self::Temurin | Self::Zulu => major >= 8,
|
||||
Self::Oracle => major >= 9,
|
||||
Self::Microsoft => matches!(major, 11 | 17 | 21 | 25),
|
||||
}
|
||||
}
|
||||
|
||||
/// 已知在发的 GA 大版本(`java vendors` 展示与设置页提示用;不是硬上限)。
|
||||
#[must_use]
|
||||
pub const fn hint_majors(self) -> &'static [u32] {
|
||||
match self {
|
||||
Self::Temurin => &[8, 11, 17, 21, 25],
|
||||
Self::Oracle => &[17, 21, 25],
|
||||
Self::Microsoft => &[11, 17, 21, 25],
|
||||
Self::Zulu => &[8, 11, 17, 21, 25],
|
||||
}
|
||||
}
|
||||
|
||||
/// 国内默认序(用户定案 + 实测修正)。
|
||||
#[must_use]
|
||||
pub const fn domestic_default() -> [Self; 4] {
|
||||
[Self::Temurin, Self::Oracle, Self::Microsoft, Self::Zulu]
|
||||
}
|
||||
|
||||
/// 国外默认序(用户原话顺序)。
|
||||
#[must_use]
|
||||
pub const fn global_default() -> [Self; 4] {
|
||||
[Self::Temurin, Self::Microsoft, Self::Oracle, Self::Zulu]
|
||||
}
|
||||
|
||||
/// 按地区取默认序。
|
||||
#[must_use]
|
||||
pub const fn default_order(domestic: bool) -> [Self; 4] {
|
||||
if domestic {
|
||||
Self::domestic_default()
|
||||
} else {
|
||||
Self::global_default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户优先级(设置值)→ 实际尝试顺序。
|
||||
///
|
||||
/// 语义刻意做成「**补集追加**」而不是「必须写全」:用户写了 `["oracle"]` 就只把 oracle 提上来,
|
||||
/// 其余按地区默认序跟在后面。理由——写全四家是负担,漏写更不该等于「其余不试」。
|
||||
#[must_use]
|
||||
pub fn order(priority: &[JdkVendor], domestic: bool) -> Vec<JdkVendor> {
|
||||
let mut out: Vec<JdkVendor> = Vec::with_capacity(JdkVendor::ALL.len());
|
||||
for vendor in priority {
|
||||
if !out.contains(vendor) {
|
||||
out.push(*vendor);
|
||||
}
|
||||
}
|
||||
for vendor in JdkVendor::default_order(domestic) {
|
||||
if !out.contains(&vendor) {
|
||||
out.push(vendor);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 解析设置值里的字符串数组(未知项忽略;顺序保留)。
|
||||
#[must_use]
|
||||
pub fn parse_priority(items: &[String]) -> Vec<JdkVendor> {
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
if let Some(vendor) = JdkVendor::parse(item) {
|
||||
if !out.contains(&vendor) {
|
||||
out.push(vendor);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 尝试顺序 → 稳定 id 列表(JSON / 人读输出)。
|
||||
#[must_use]
|
||||
pub fn ids(vendors: &[JdkVendor]) -> Vec<&'static str> {
|
||||
vendors.iter().map(|vendor| vendor.id()).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ids, order, parse_priority, JdkVendor};
|
||||
|
||||
#[test]
|
||||
fn domestic_and_global_defaults_follow_d23() {
|
||||
assert_eq!(
|
||||
ids(&order(&[], true)),
|
||||
vec!["temurin", "oracle", "microsoft", "zulu"],
|
||||
"国内:Oracle 在 MS 之前(实测差 28×)"
|
||||
);
|
||||
assert_eq!(
|
||||
ids(&order(&[], false)),
|
||||
vec!["temurin", "microsoft", "oracle", "zulu"],
|
||||
"国外:维持用户原话 Temurin→MS→Oracle"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_priority_leads_and_the_rest_follows_as_a_complement() {
|
||||
let priority = parse_priority(&["microsoft".to_string()]);
|
||||
assert_eq!(
|
||||
ids(&order(&priority, true)),
|
||||
vec!["microsoft", "temurin", "oracle", "zulu"],
|
||||
"只写一家也要把其余按默认序补齐"
|
||||
);
|
||||
let full = parse_priority(&["zulu".to_string(), "oracle".to_string(), "zulu".to_string()]);
|
||||
assert_eq!(
|
||||
ids(&order(&full, true)),
|
||||
vec!["zulu", "oracle", "temurin", "microsoft"],
|
||||
"重复项去重,未列出的追加在后面"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_setting_values_are_ignored() {
|
||||
let parsed = parse_priority(&["graalvm".to_string(), "AZUL".to_string()]);
|
||||
assert_eq!(parsed, vec![JdkVendor::Zulu]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vendor_ids_majors_and_aliases() {
|
||||
assert_eq!(JdkVendor::parse("Adoptium"), Some(JdkVendor::Temurin));
|
||||
assert_eq!(JdkVendor::parse("ms"), Some(JdkVendor::Microsoft));
|
||||
assert_eq!(JdkVendor::parse("graalvm"), None);
|
||||
// MS 没有 8:这是「回退链要跳过某家」的真实判据。
|
||||
assert!(!JdkVendor::Microsoft.supports(8));
|
||||
assert!(JdkVendor::Microsoft.supports(21));
|
||||
assert!(JdkVendor::Oracle.supports(9));
|
||||
assert!(!JdkVendor::Oracle.supports(8), "8 交给 Temurin");
|
||||
assert!(JdkVendor::Temurin.supports(8));
|
||||
for vendor in JdkVendor::ALL {
|
||||
assert!(
|
||||
vendor.hint_majors().contains(&21),
|
||||
"{} 应发 21",
|
||||
vendor.id()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Azul Zulu:**第四备选**(D23)。API 元数据 + CDN 直链,实现刻意简化。
|
||||
//!
|
||||
//! 为什么排最后:国内没有任何 Zulu 镜像(三家云 + 四家校园源全无 `zulu`/`azul` 目录),
|
||||
//! 只能吃 `cdn.azul.com` 的 3.3 MB/s(211 MB ≈ 64 s);而前三家在国内都有更好的路。
|
||||
//! 留这一家的价值是「三家全挂时的最后一条活路」,以及 Temurin/Oracle/MS 都缺某版本时的兜底。
|
||||
//!
|
||||
//! 两跳(research/06 §4.3):
|
||||
//!
|
||||
//! ```text
|
||||
//! GET {api}?java_version=21&os=windows&arch=x64&java_package_type=jdk&archive_type=zip&latest=true
|
||||
//! → [{ package_uuid, name, java_version, download_url }] # 注意:不含 sha256
|
||||
//! GET {detail({uuid} 占位)} → { sha256_hash, size(不可信,差 133 B) }
|
||||
//! ```
|
||||
//!
|
||||
//! 三条实测约束:①`latest=true` 的响应**没有** `sha256_hash`,必须再取一次详情;
|
||||
//! ②`size` 字段与真实字节数差 133 B → 一律不信,只填 0;③文件名必须照抄 API 返回值
|
||||
//! (`zulu21.54.109-ca-jdk21.0.13.0.3-win_x64.zip` 里混着三段与四段两种写法,拼不出来)。
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::error::{JavaError, JavaErrorKind};
|
||||
use super::net;
|
||||
use super::release::{JdkArchive, JdkRelease};
|
||||
use super::target::AdoptiumTarget;
|
||||
use super::vendor::JdkVendor;
|
||||
use crate::download::ZuluTable;
|
||||
|
||||
/// Zulu 的 os 段(`windows` / `macos` / `linux`)。
|
||||
#[must_use]
|
||||
pub fn zulu_os(target: AdoptiumTarget) -> &'static str {
|
||||
match target.os {
|
||||
"mac" => "macos",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// 包列表查询地址(`latest=true` 只返回最新一个)。
|
||||
#[must_use]
|
||||
pub fn api_url(table: &ZuluTable, major: u32, target: AdoptiumTarget) -> String {
|
||||
format!(
|
||||
"{}?java_version={major}&os={}&arch={}&java_package_type=jdk&archive_type=zip&latest=true",
|
||||
table.api,
|
||||
zulu_os(target),
|
||||
target.arch
|
||||
)
|
||||
}
|
||||
|
||||
/// 包详情地址(`{uuid}` 占位替换)。
|
||||
#[must_use]
|
||||
pub fn detail_url(table: &ZuluTable, uuid: &str) -> String {
|
||||
table.detail.replace("{uuid}", uuid)
|
||||
}
|
||||
|
||||
/// 列表响应里我们关心的字段。
|
||||
///
|
||||
/// ⚠️ 真机形态(2026-10-30 实测):`latest=true` **不止返回一条**,而且 `java_version` 是
|
||||
/// **数字数组**(`[21,0,13,0,3]`)而不是字符串;同一批里还混着 `-crac-`(CRaC)与
|
||||
/// `-fx-`(JavaFX)变体。所以这里按「字段宽进 + 只挑纯 JDK 包」处理,见 [`parse_packages`]。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct ZuluPackage {
|
||||
/// 包 id(取详情用)。
|
||||
pub package_uuid: String,
|
||||
/// 归档文件名(照抄,不拼)。
|
||||
pub name: String,
|
||||
/// JDK 版本(`[21,0,13,0,3]`,Azul 用数组给)。
|
||||
#[serde(default)]
|
||||
pub java_version: Vec<u32>,
|
||||
/// CDN 直链。
|
||||
pub download_url: String,
|
||||
}
|
||||
|
||||
impl ZuluPackage {
|
||||
/// 版本串(`[21,0,13,0,3]` → `21.0.13.0.3`;空数组时退回文件名)。
|
||||
#[must_use]
|
||||
pub fn version_text(&self) -> String {
|
||||
if self.java_version.is_empty() {
|
||||
return self.name.clone();
|
||||
}
|
||||
self.java_version
|
||||
.iter()
|
||||
.map(u32::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(".")
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否是「纯 JDK」包(排除 CRaC / JavaFX / JRE 变体)。
|
||||
#[must_use]
|
||||
pub fn is_plain_jdk(name: &str) -> bool {
|
||||
name.contains("-jdk")
|
||||
&& !name.contains("-jre")
|
||||
&& !name.contains("crac")
|
||||
&& !name.contains("-fx")
|
||||
}
|
||||
|
||||
/// 列表响应 → 纯 JDK 包(`latest=true` 会给多条,必须自己挑)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 响应不是 JSON、列表为空时返回错误。
|
||||
pub fn parse_packages(json: &str) -> Result<ZuluPackage, JavaError> {
|
||||
let packages: Vec<ZuluPackage> = serde_json::from_str(json).map_err(|error| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!("Zulu 列表响应不是预期 JSON:{error}"),
|
||||
)
|
||||
})?;
|
||||
let picked = packages
|
||||
.iter()
|
||||
.find(|package| is_plain_jdk(&package.name))
|
||||
.or_else(|| packages.first())
|
||||
.ok_or_else(|| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
"Zulu 没有该版本/平台的 JDK 包(列表为空)",
|
||||
)
|
||||
})?;
|
||||
Ok(picked.clone())
|
||||
}
|
||||
|
||||
/// 详情响应里的 `sha256_hash`(只信这一个字段,`size` 不可对拍)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 响应不是 JSON 或缺可用哈希时返回错误。
|
||||
pub fn parse_detail_sha256(json: &str) -> Result<String, JavaError> {
|
||||
#[derive(Deserialize)]
|
||||
struct Detail {
|
||||
#[serde(default)]
|
||||
sha256_hash: Option<String>,
|
||||
}
|
||||
let detail: Detail = serde_json::from_str(json).map_err(|error| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
format!("Zulu 详情响应不是预期 JSON:{error}"),
|
||||
)
|
||||
})?;
|
||||
detail
|
||||
.sha256_hash
|
||||
.map(|hex| hex.trim().to_ascii_lowercase())
|
||||
.filter(|hex| hex.len() == 64 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()))
|
||||
.ok_or_else(|| {
|
||||
JavaError::new(
|
||||
JavaErrorKind::Integrity,
|
||||
"Zulu 详情里没有可用的 sha256_hash",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// 查一次「某大版本的最新 Zulu JDK」。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 表里没配 Zulu、API 不可达/无包、详情缺哈希时返回错误(回退链到此为止,报总失败)。
|
||||
pub async fn fetch_latest(
|
||||
client: &reqwest::Client,
|
||||
table: &ZuluTable,
|
||||
major: u32,
|
||||
target: AdoptiumTarget,
|
||||
) -> Result<JdkRelease, JavaError> {
|
||||
let list = net::get_text(client, &api_url(table, major, target), "Zulu 包列表").await?;
|
||||
let package = parse_packages(&list)?;
|
||||
let detail = net::get_text(
|
||||
client,
|
||||
&detail_url(table, &package.package_uuid),
|
||||
"Zulu 包详情",
|
||||
)
|
||||
.await?;
|
||||
let sha256 = parse_detail_sha256(&detail)?;
|
||||
// Zulu 的版本串用 `java_version` 数组拼(真机是数组不是字符串);文件名照抄,
|
||||
// 因为文件名里 `jdk21.0.13.0.3` 这类四段/五段写法拼不出来。
|
||||
Ok(JdkRelease {
|
||||
vendor: JdkVendor::Zulu,
|
||||
major,
|
||||
version: package.version_text(),
|
||||
semver: None,
|
||||
release_name: package.name.clone(),
|
||||
archive: JdkArchive {
|
||||
name: package.name,
|
||||
url: package.download_url,
|
||||
sha256,
|
||||
// size 字段不可信(实测差 133 B)→ 填 0,交给 sha256 校验。
|
||||
size: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{api_url, detail_url, is_plain_jdk, parse_detail_sha256, parse_packages, zulu_os};
|
||||
use crate::download::MirrorTable;
|
||||
use crate::game::java::target::AdoptiumTarget;
|
||||
|
||||
const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64");
|
||||
|
||||
fn table() -> crate::download::ZuluTable {
|
||||
MirrorTable::builtin()
|
||||
.zulu()
|
||||
.expect("内置表应带 Zulu")
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urls_carry_every_filter_the_api_needs() {
|
||||
let table = table();
|
||||
let url = api_url(&table, 21, WINDOWS);
|
||||
assert!(url.starts_with("https://api.azul.com/metadata/v1/zulu/packages/?"));
|
||||
for needle in [
|
||||
"java_version=21",
|
||||
"os=windows",
|
||||
"arch=x64",
|
||||
"java_package_type=jdk",
|
||||
"archive_type=zip",
|
||||
"latest=true",
|
||||
] {
|
||||
assert!(url.contains(needle), "{needle} 应在 {url}");
|
||||
}
|
||||
assert_eq!(
|
||||
detail_url(&table, "abc-123"),
|
||||
"https://api.azul.com/metadata/v1/zulu/packages/abc-123"
|
||||
);
|
||||
assert_eq!(zulu_os(AdoptiumTarget::new("mac", "aarch64")), "macos");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_listing_gives_the_uuid_and_the_exact_filename() {
|
||||
// 真机响应(2026-10-30):`latest=true` 返回多条,`java_version` 是数组,
|
||||
// 并且混着 `-crac-` / `-fx-` 变体——必须挑出纯 JDK 那条。
|
||||
let json = r#"[
|
||||
{"package_uuid":"4ee30e3a-b26a-40d3-9d04-2182eae4075e",
|
||||
"name":"zulu21.54.109-ca-jdk21.0.13.0.3-win_x64.zip",
|
||||
"java_version":[21,0,13,0,3],
|
||||
"download_url":"https://cdn.azul.com/zulu/bin/zulu21.54.109-ca-jdk21.0.13.0.3-win_x64.zip"},
|
||||
{"package_uuid":"2781fb21-9401-40d3-a88e-cf8f36541e3c",
|
||||
"name":"zulu21.52.203-ca-crac-jdk21.0.12.1-win_x64.zip",
|
||||
"java_version":[21,0,12,1],
|
||||
"download_url":"https://cdn.azul.com/zulu/bin/zulu21.52.203-ca-crac-jdk21.0.12.1-win_x64.zip"},
|
||||
{"package_uuid":"49851aa2-4fd2-4e54-a903-323989cdf711",
|
||||
"name":"zulu21.52.203-ca-fx-jdk21.0.12.1-win_x64.zip",
|
||||
"java_version":[21,0,12,1],
|
||||
"download_url":"https://cdn.azul.com/zulu/bin/zulu21.52.203-ca-fx-jdk21.0.12.1-win_x64.zip"}
|
||||
]"#;
|
||||
let package = parse_packages(json).expect("应解析");
|
||||
assert_eq!(package.package_uuid, "4ee30e3a-b26a-40d3-9d04-2182eae4075e");
|
||||
assert_eq!(
|
||||
package.name, "zulu21.54.109-ca-jdk21.0.13.0.3-win_x64.zip",
|
||||
"文件名必须照抄 API(三段/四段混写,拼不出来)"
|
||||
);
|
||||
assert_eq!(package.version_text(), "21.0.13.0.3");
|
||||
assert!(is_plain_jdk(&package.name));
|
||||
assert!(!is_plain_jdk(
|
||||
"zulu21.52.203-ca-crac-jdk21.0.12.1-win_x64.zip"
|
||||
));
|
||||
assert!(!is_plain_jdk(
|
||||
"zulu21.52.203-ca-fx-jdk21.0.12.1-win_x64.zip"
|
||||
));
|
||||
assert!(
|
||||
parse_packages("[]").is_err(),
|
||||
"空列表要报错而不是造一个空包"
|
||||
);
|
||||
assert!(parse_packages("<html>502</html>").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_gives_a_trustworthy_sha256_only() {
|
||||
let json = r#"{"package_uuid":"x","size":211151000,
|
||||
"sha256_hash":"6B0E2B0F4C1D2A3E4F5061728394A5B6C7D8E9F0A1B2C3D4E5F60718293A4B5C"}"#;
|
||||
assert_eq!(
|
||||
parse_detail_sha256(json).expect("大写哈希应被接受并转小写"),
|
||||
"6b0e2b0f4c1d2a3e4f5061728394a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c"
|
||||
);
|
||||
assert!(parse_detail_sha256(r#"{"size":1}"#).is_err());
|
||||
assert!(parse_detail_sha256("not json").is_err());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user