Files
Unlucky-Launcher/crates/core/src/game/java/termux.rs
T
AskaEth 48d1ee6616 feat(java): Termux 上自动安装系统 openjdk(内置镜像 + 隔离 apt 状态)
- 新增 game::java::termux:内置 SAU/TUNA/USTC/官方四个 termux-main 镜像,
  按顺序 apt-get update+install;lists/cache 隔离在 $PREFIX/tmp/ull-java-termux,
  不改用户 apt 配置;装完从 $PREFIX/lib/jvm 定位,幂等(已装则不走网络)
- JdkVendor 增加 termux(17/21/25),--vendor termux 只走系统包;
  Termux 上缺省安装先试系统包,失败再走归档链并带出每一跳原因
- standard_roots 已能扫 $PREFIX/lib/jvm(上一条提交),装完即被 java list 发现
- CLI/presenter 的 vendor 展示与帮助文案同步;设置白名单与契约测试同步
- 文档补 Termux 上的 Java 行为
2026-11-07 09:33:19 +08:00

252 lines
9.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Termux 系统 JDK(`openjdk-<major>` 包)的安装与定位。
//!
//! Termux 的 openjdk 是 `.deb`(bionic),由 apt/dpkg 装进 `$PREFIX`,与 Temurin 的
//! zip/tar.gz 归档不是同一形态,所以**不走** `source::fetch` 的归档链。这里委托包管理器:
//!
//! 1. 用内置镜像生成一份**隔离**的 sources.list;
//! 2. `apt-get update && install` 的 lists/cache 都指向临时目录,不污染用户的 apt 状态;
//! 3. 装完在 `$PREFIX/lib/jvm` 下找 `java-<major>-openjdk`,用 `bin/java` 是否存在做判据。
//!
//! 镜像顺序是实测可用的国内源;全部失败才报错,错误里带每一跳的原因。
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::platform::paths::ProcessEnv;
use crate::platform::termux as host;
use super::error::{JavaError, JavaErrorKind};
/// 内置镜像(termux-main 仓库根)。顺序即尝试顺序。
pub const MIRRORS: &[&str] = &[
"https://mirrors.sau.edu.cn/termux/apt/termux-main",
"https://mirrors.tuna.tsinghua.edu.cn/termux/apt/termux-main",
"https://mirrors.ustc.edu.cn/termux/apt/termux-main",
"https://packages.termux.dev/apt/termux-main",
];
/// 当前宿主是不是 Termux。
#[must_use]
pub fn available() -> bool {
host::is_termux(&ProcessEnv)
}
/// 大版本 → Termux 包名(主源只有 17 / 21 / 25)。
#[must_use]
pub fn package_for(major: u32) -> Option<String> {
matches!(major, 17 | 21 | 25).then(|| format!("openjdk-{major}"))
}
/// `$PREFIX/lib/jvm` 下与 `major` 对应的 JDK 根(`bin/java` 必须存在)。
#[must_use]
pub fn installed_home(prefix: &Path, major: u32) -> Option<PathBuf> {
let root = prefix.join("lib").join("jvm");
let entries = std::fs::read_dir(root).ok()?;
let dashed = format!("java-{major}-openjdk");
let plain = format!("openjdk-{major}");
let mut found: Vec<PathBuf> = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_dir() && path.join("bin").join("java").is_file())
.filter(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name == dashed || name == plain)
})
.collect();
found.sort();
found.into_iter().next()
}
/// 一条 `deb` 行(测试与安装共用同一份拼法)。
#[must_use]
pub fn sources_line(mirror: &str) -> String {
format!("deb {} stable main\n", mirror.trim_end_matches('/'))
}
/// 装一个 Termux JDK,返回它的根目录。
///
/// # Errors
///
/// 当前不在 Termux、主源没有该大版本、所有镜像都失败、装完仍找不到 `bin/java` 时返回错误。
pub async fn install(major: u32) -> Result<PathBuf, JavaError> {
let Some(package) = package_for(major) else {
return Err(JavaError::new(
JavaErrorKind::Unsupported,
format!("Termux 主源没有 Java {major}(只有 17 / 21 / 25)"),
)
.with_hint("`pkg search openjdk` 看可用包;其它大版本请用归档发行版"));
};
let Some(prefix) = host::prefix(&ProcessEnv) else {
return Err(
JavaError::new(JavaErrorKind::Unsupported, "termux 源只在 Termux 里可用")
.with_hint("普通 Linux/Windows 用 Temurin / Oracle / MS / Zulu"),
);
};
// 幂等:已经装了同一大版本就不碰网络。
if let Some(home) = installed_home(&prefix, major) {
return Ok(home);
}
let workspace = workspace_dir(&prefix, major);
std::fs::create_dir_all(&workspace).map_err(|error| {
JavaError::new(
JavaErrorKind::Io,
format!(
"建 Termux 安装临时目录失败({}):{error}",
workspace.display()
),
)
})?;
let package_for_task = package.clone();
tokio::task::spawn_blocking(move || run_install(&prefix, &workspace, &package_for_task, major))
.await
.map_err(|error| {
JavaError::new(
JavaErrorKind::Io,
format!("Termux 安装任务没能完成:{error}"),
)
})?
}
/// 逐镜像尝试 `apt-get`(阻塞,跑在 `spawn_blocking` 里)。
fn run_install(
prefix: &Path,
workspace: &Path,
package: &str,
major: u32,
) -> Result<PathBuf, JavaError> {
let mut failures: Vec<String> = Vec::new();
for mirror in MIRRORS {
match apt_install(prefix, workspace, mirror, package) {
Ok(()) => {
if let Some(home) = installed_home(prefix, major) {
return Ok(home);
}
failures.push(format!("{mirror}:装完找不到 bin/java"));
}
Err(error) => failures.push(format!("{mirror}:{error}")),
}
}
Err(JavaError::new(
JavaErrorKind::Network,
format!(
"Termux 包安装失败(试过 {} 个镜像):{}",
MIRRORS.len(),
failures.join(";")
),
)
.with_hint("确认 apt 可用、磁盘空间足够,或手动 `pkg install openjdk-<major>`"))
}
/// 对单个镜像跑 `apt-get update` + `install`(lists/cache 隔离在 workspace 内)。
fn apt_install(prefix: &Path, workspace: &Path, mirror: &str, package: &str) -> Result<(), String> {
let sources = workspace.join("sources.list");
std::fs::write(&sources, sources_line(mirror))
.map_err(|error| format!("写 sources.list 失败:{error}"))?;
let lists = workspace.join("lists");
let cache = workspace.join("cache");
let archives = workspace.join("archives");
for dir in [&lists, &cache, &archives] {
std::fs::create_dir_all(dir).map_err(|error| format!("建临时目录失败:{error}"))?;
}
let options = [
format!("Dir::Etc::sourcelist={}", sources.display()),
"Dir::Etc::sourceparts=-".to_string(),
format!("Dir::State::lists={}", lists.display()),
format!("Dir::Cache={}", cache.display()),
format!("Dir::Cache::archives={}", archives.display()),
"Acquire::ForceIPv4=true".to_string(),
"Acquire::Retries=2".to_string(),
"Acquire::http::Timeout=30".to_string(),
"Acquire::https::Timeout=30".to_string(),
];
run_apt(prefix, &options, &["update"])?;
run_apt(
prefix,
&options,
&["install", "-y", "--no-install-suggests", package],
)
}
/// 执行一次 `apt-get`(`-o` 选项在前,子命令在后)。
fn run_apt(prefix: &Path, options: &[String], args: &[&str]) -> Result<(), String> {
let apt = prefix.join("bin").join("apt-get");
let mut command = Command::new(&apt);
command.env("DEBIAN_FRONTEND", "noninteractive");
for option in options {
command.arg("-o").arg(option);
}
command.args(args);
let output = command
.output()
.map_err(|error| format!("执行 {} 失败:{error}", apt.display()))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let last = stderr
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.unwrap_or("(无 stderr 输出)");
Err(format!(
"apt-get {} 退出 {}:{last}",
args.first().copied().unwrap_or("?"),
output.status.code().unwrap_or(-1)
))
}
/// 临时工作区(按大版本区分,避免两次安装互相踩 lists)。
fn workspace_dir(prefix: &Path, major: u32) -> PathBuf {
prefix
.join("tmp")
.join("ull-java-termux")
.join(format!("jdk-{major}"))
}
#[cfg(test)]
mod tests {
use super::{installed_home, package_for, sources_line, MIRRORS};
#[test]
fn only_the_majors_that_termux_ships_are_installable() {
assert_eq!(package_for(17).as_deref(), Some("openjdk-17"));
assert_eq!(package_for(21).as_deref(), Some("openjdk-21"));
assert_eq!(package_for(25).as_deref(), Some("openjdk-25"));
assert_eq!(package_for(8), None);
assert_eq!(package_for(11), None);
}
#[test]
fn sources_line_has_one_deb_and_keeps_the_mirror_root() {
let line = sources_line("https://mirrors.tuna.tsinghua.edu.cn/termux/apt/termux-main/");
assert_eq!(
line,
"deb https://mirrors.tuna.tsinghua.edu.cn/termux/apt/termux-main stable main\n"
);
assert!(MIRRORS.iter().all(|mirror| mirror.starts_with("https://")));
}
#[test]
fn installed_home_requires_bin_java_and_matching_major() {
let base = std::env::temp_dir().join(format!("ull-termux-home-{}", std::process::id()));
let good = base.join("lib").join("jvm").join("java-21-openjdk");
let empty = base.join("lib").join("jvm").join("java-17-openjdk");
std::fs::create_dir_all(good.join("bin")).unwrap();
std::fs::write(good.join("bin").join("java"), b"").unwrap();
std::fs::create_dir_all(&empty).unwrap();
assert_eq!(installed_home(&base, 21), Some(good));
assert_eq!(installed_home(&base, 17), None, "没有 bin/java 的目录不算");
assert_eq!(installed_home(&base, 25), None);
std::fs::remove_dir_all(&base).ok();
}
}