diff --git a/Cargo.lock b/Cargo.lock index 8728054..7f65aba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5003,6 +5003,7 @@ dependencies = [ "image", "log", "unlucky-cli", + "unlucky-core", "unlucky-design", "unlucky-i18n", "unlucky-presenter", diff --git a/README.md b/README.md index c447a4f..b1d1f3b 100644 --- a/README.md +++ b/README.md @@ -194,8 +194,9 @@ This list is the reason to believe the rest. None of it is hidden behind marketi report lists every conflict so nothing is overwritten silently. - **Install progress is stage-level**, not byte-level, on the content-library install path. - **No native file chooser.** Import takes drag-and-drop or a pasted path. -- **Android/Termux is experimental and not promised**: no released asset targets it, and the GUI is - unverified there. +- **Android/Termux is experimental and not promised**: no released asset targets it. A GUI build is + possible from source via the glibc toolchain (`packaging/docs/termux-glibc-build.md`, branch notes: + [`README.termux.md`](README.termux.md)); it was verified on-device twice: once on CPU software rendering, once with GPU acceleration via a self-built Mesa/turnip stack (see the evidence in README.termux.md). ## Download and run diff --git a/README.termux.md b/README.termux.md new file mode 100644 index 0000000..14d54ed --- /dev/null +++ b/README.termux.md @@ -0,0 +1,59 @@ +# Unlucky Launcher · Termux 适配(`termux-glibc` 分支) + +这条分支把启动器带到 **Termux(Android)**:用 glibc 工具链编出 `aarch64-unknown-linux-gnu` +二进制,在 **termux-x11** 里跑 GUI;同时补上 Termux 平台识别、系统 JDK 安装与中文字体。 + +> 给主线的改动清单、兼容性说明与合并指南见 +> [`TERMUX-ADAPTATION.md`](TERMUX-ADAPTATION.md)。 + +## 已验证 + +| 项 | 状态 | 证据 | +| --- | --- | --- | +| 源码构建(GUI + CLI) | ✅ | Rust 1.99.1 gnu + clang/lld + glibc 运行时;GPU 构建(wgpu)默认带 CPU 兜底 | +| 窗口显示 | ✅ | termux-x11 `:0`,横屏 1657×1067,首帧 ~60 ms(GPU) | +| 中文 | ✅ | 启动时注册 `/system/fonts/NotoSansCJK-Regular.ttc` | +| 微软登录 id | ✅ | 构建期注入,日志 `client_id=configured source=build` | +| 横屏适配 | ✅ | 默认窗口按 X 屏幕等比自适应(`source=termux-screen`) | +| Java 发现 | ✅ | `JAVA_HOME`/PATH + `$PREFIX/lib/jvm` 扫描 | +| Java 自动安装 | ✅ | `ull java install 17` 由内置镜像装完 openjdk-17(约 20 s) | +| GPU(wgpu) | ✅ | 自编 Mesa 26.2.6 + KGSL;wgpu 选中 Adreno 840(Vulkan/turnip) | +| Minecraft 运行 | ✅ | 真机 1.20.4 跑通:glibc JRE + arm64 natives + softpipe GL,主菜单在 X11 渲染 | + +## 快速开始 + +完整配方(工具链、patchelf、linker、门禁)见 +[`packaging/docs/termux-glibc-build.md`](packaging/docs/termux-glibc-build.md)。最短路径: + +```sh +termux-x11 :0 -ac & +DISPLAY=:0 ./unlucky-launcher-iced # 默认 auto:有驱动走 GPU,没有则自动降级 CPU +``` + +- GPU 驱动不随 Termux 源提供,需要按构建文档 §5 自己编一次 Mesa 26 + ICD; + 不装也能跑,只是 `auto` 会落到 tiny-skia(CPU)。 +- 想强制某一侧:`--renderer=gpu` / `--software-renderer`。 +- 窗口尺寸默认按屏幕自适应;`ULL_WINDOW_SIZE=1520x900` 可覆盖。 +- Java:`ull java install 17|21|25` 在 Termux 上默认用系统包(内置镜像 + 隔离 apt 状态)。 +- **数据根**:Portable 模式按**可执行文件所在目录**生成 `.ULL` / `.minecraft`; + 换目录启动会看到另一套实例(`--data-dir` 目前接受并忽略)。 + +## 已知边界 + +- 仅单机(OnePlus 15 / Android 17)验证,试验级、不在发布矩阵内。 +- **GPU**:已打通,但驱动是自编的(Mesa 26.2.6 + KGSL),装到 + `$PREFIX/glibc/{lib,share/vulkan/icd.d}` 后应用会自动启用;Termux 官方源里的 + glibc Mesa 太旧(24.2.6 不认识 Adreno 840)。驱动缺失时自动降级 CPU,不会崩。 +- **MC**:Android 原生 natives 走不通(LWJGL 没有官方 Android/bionic 产物,且 GLFW 的 + 窗口模型与 termux-x11 的 X11 不兼容)。有效路线是 glibc JVM,已真机跑通 1.20.4: + Temurin 21 JRE(patchelf 后直接执行)+ Linux arm64 natives(启动器自动改写/镜像兜底) + + softpipe GL。非致命项:narrator 的 flite 缺失、LWJGL JNI 版本告警; + softpipe 是软件渲染,帧率有限,zink(硬件 GL)仍卡 DRI3。 +- Termux 上三条测试失败是环境差异(`/proc/*/children`、长路径脱敏、mtime flaky), + PC 的 Linux CI 不受影响。 + +## 后续 + +1. zink 的 DRI3 问题(硬件 GL;当前 softpipe 可跑但慢)——这是 MC 帧率的主要瓶颈 +2. 剪贴板/IME 在 termux-x11 下的行为验证 +3. narrator/flite 与音频设备(当前 missing 但不阻断) diff --git a/README.zh-CN.md b/README.zh-CN.md index 179462f..62ca718 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -158,7 +158,7 @@ unlucky-launcher --version # CLI,版本 + 系统摘要 文件是被静默覆盖的。 - **内容库的安装进度是阶段级**,不是字节级。 - **没有原生文件选择框。** 导入靠拖拽或粘贴路径。 -- **Android/Termux 是试验级、不承诺**:发布矩阵里没有面向它的资产,那一侧的 GUI 也未验证。 +- **Android/Termux 是试验级、不承诺**:发布矩阵里没有面向它的资产。GUI 可以经 glibc 工具链源码自建(见 `packaging/docs/termux-glibc-build.md`,分支说明见 [`README.termux.md`](README.termux.md)),已真机验证两条路径各一次:CPU 软渲染,以及自编 Mesa/turnip 栈的 GPU 加速(证据见 `README.termux.md`)。 ## 下载与运行 diff --git a/TERMUX-ADAPTATION.md b/TERMUX-ADAPTATION.md new file mode 100644 index 0000000..441c59a --- /dev/null +++ b/TERMUX-ADAPTATION.md @@ -0,0 +1,144 @@ +# Termux 适配:方案、改动清单与合并指南 + +> 分支:`termux-glibc`(基于 `origin/dev`,15 个提交,分叉点 `87ab878`,合并前 HEAD `e987e02`) +> 目的:让主线快速了解这组代码改了什么、为什么、怎么验证、如何合并。 + +## TL;DR + +- **构建**:为 Termux 编出 `aarch64-unknown-linux-gnu`(glibc)二进制,在 termux-x11 里跑 GUI。 + Android 原生 target 走不通(winit 的 android 后端需要 NativeActivity),所以不用它。 +- **已跑通**:GUI(CPU 与 GPU 两条渲染路径)、中文、横屏自适应、JDK 发现与系统包安装、 + MC 1.20.4(glibc JRE + arm64 natives + softpipe GL,真机进游戏)。 +- **未收口**:MC 启动参数支持(由主线后续补)、MC 硬件 GL(zink 卡 DRI3)、剪贴板/IME。 +- **对主线的影响**:所有行为都由 **运行时 Termux 识别 / `cfg(target_os = "linux")` / + 文件存在** 三选一 gate 住,Windows / macOS / 普通 Linux 的默认路径不变。 + +## 方案 + +### 1. 平台识别 + +`crates/core/src/platform/termux.rs`:用 `TERMUX_VERSION` 或默认 `PREFIX` +(`/data/data/com.termux/files/usr`)识别 Termux,推导 `$PREFIX` 与 `$PREFIX/lib/jvm`。 +不把路径写死到调用方,也不改 `OsFamily`(Android 上仍按 Linux 家族处理)。 + +### 2. JDK:发现 + 安装 + +- **发现**:`game/java/discover.rs` 的 Unix 扫描根追加 `$PREFIX/lib/jvm`, + 环境可注入(`standard_roots_in`)。补上「Termux 装了 openjdk 但没设 JAVA_HOME/PATH」的盲点。 +- **安装**:新增 `game/java/termux.rs`。Termux 的 openjdk 是 `.deb`(bionic), + 与 Temurin 的 zip/tar.gz 不是同一形态,所以**委托包管理器**: + - 内置 SAU / TUNA / USTC / 官方四个 termux-main 镜像,按顺序 `apt-get update && install`; + - lists/cache 隔离在 `$PREFIX/tmp/ull-java-termux/`,不污染用户 apt 配置;装完清理; + - 装完从 `$PREFIX/lib/jvm` 定位,幂等(已装则不走网络)。 +- **vendor 接线**:`JdkVendor::Termux`(17/21/25)。Termux 上缺省安装先试系统包, + 失败再走归档链;`--vendor termux` 只走系统包。设置白名单与契约测试同步。 + +### 3. 字体 + +`crates/app-iced/src/fonts.rs`:Termux 上把 `/system/fonts/NotoSansCJK-Regular.ttc` +注册进 cosmic-text(fontdb 不扫 Android 字体目录),中文不再显示方框。 + +### 4. 窗口(横屏优先) + +`app/boot/termux_window.rs`:Termux 上默认窗口按当前 X 屏幕等比自适应 +(日志 `size_source=termux-screen`),横屏铺满、竖屏不溢出; +`ULL_WINDOW_SIZE` 覆盖与 1180×760 验收基准不变。 + +### 5. GPU(启动器) + +- 驱动是自编的 **Mesa 26.2.6 + KGSL**,装在 `$PREFIX/glibc/{lib,share/vulkan/icd.d}`; + 构建/安装步骤见 `packaging/docs/termux-glibc-build.md` §5。 +- `app/boot/termux_gpu.rs`:glibc 构建 + Termux + ICD 文件存在时,在探测前设 + `VK_ICD_FILENAMES` / `VK_DRIVER_FILES`;用户显式设置不覆盖。 +- 实测 wgpu 选中 Adreno 840(turnip Mesa 26.2.6);无驱动自动降级 tiny-skia。 + +### 6. Minecraft 运行链 + +- **natives**:1.19+ 的清单把 natives 作为独立库条目(`org.lwjgl:...:natives-linux`), + Linux/aarch64 上改写成 `natives-linux-arm64`(`Library::linux_arm64_native_name`, + install 与 launch 共用);Mojang 的 `libraries.minecraft.net` 对 arm64 一律 404, + 下载兜底到阿里云 Maven(`install/libraries.rs`)。 +- **JVM**:用 glibc 的 Temurin JRE(patchelf 后直接执行),bionic 的 Termux JVM + 加载不了 glibc natives。 +- **GL**:glibc Mesa 的 zink 在 termux-x11 下卡 `DRI3 not available`, + 子进程默认带 `MESA_LOADER_DRIVER_OVERRIDE=swrast` + `LIBGL_ALWAYS_SOFTWARE=1` + (`launch/defaults.rs`,用户自定义环境变量可覆盖)。 + +## 改动清单 + +| 文件 | 改动 | 平台隔离 | +| --- | --- | --- | +| `core/platform/termux.rs`(新) | Termux 识别、`$PREFIX`/JVM 根 | 纯函数,按环境判断 | +| `core/platform/mod.rs` | 导出新模块 | — | +| `core/game/java/discover.rs` | `$PREFIX/lib/jvm` 扫描 | Unix + Termux 判断 | +| `core/game/java/termux.rs`(新) | 镜像/隔离 apt 安装、`installed_home` | 仅 Termux 可执行 | +| `core/game/java/vendor.rs` / `source.rs` / `install.rs` | `JdkVendor::Termux` 与安装分派 | Termux 上才启用 | +| `core/setting/keys.rs` + 契约测试 | 白名单加 `termux` | 跨平台可选,非 Termux 明确报错 | +| `cli/commands/java_vendors.rs`、`cli/cli/help.rs`、`presenter/host/java.rs` | 展示与帮助文案 | 展示层 | +| `core/game/library.rs` | `linux_arm64_native_name` / 分类器改写 | 仅 linux+arm64 | +| `core/game/install/libraries.rs` | 现代 natives 条目改写 + Maven 兜底 | 仅 linux+arm64 | +| `core/launch/defaults.rs` | Termux softpipe 环境默认值 | `cfg(linux)` + Termux | +| `presenter/host/launch.rs`、`cli/commands/snapshot.rs` | classpath 用改写后的 arm64 坐标 | 仅 linux+arm64 | +| `app-iced/fonts.rs` | Android CJK 字体注册 | 仅 Termux | +| `app-iced/boot/termux_window.rs`(新) | 横屏自适应 | 仅 Termux | +| `app-iced/boot/termux_gpu.rs`(新) | 自动启用自编 ICD | 仅 glibc+Termux+文件存在 | +| `app-iced/Cargo.toml` | 新增 `unlucky-core` 依赖 | 复用平台识别 | +| `README.termux.md`(新)、`packaging/docs/termux-glibc-build.md`(新) | 分支说明与构建配方 | 文档 | +| `README.md` / `README.zh-CN.md` / 支持矩阵两处 | Termux 行同步 | 文档 | + +## 行为变化与兼容性 + +- 新增 `JdkVendor::Termux` 会改变 `VENDOR_IDS` / `JDK_VENDORS` 的允许值集合 + (契约测试已同步)。非 Termux 上显式选它会在安装时明确报「只在 Termux 里可用」, + 默认回退序**不含** termux,PC 行为不变。 +- Linux/aarch64 的 LWJGL3 natives 会被改写;x86_64、Windows、macOS 不受影响。 +- `app-iced` 直接依赖 `unlucky-core`(此前通过 cli/presenter 间接依赖)。 +- 所有 Termux 专属路径都需要运行时识别或文件存在才会生效;普通 Linux 桌面不会触发。 + +## 合并指南 + +1. ~~`git fetch origin && git rebase origin/dev`~~ → **实际按用户令走 `--no-ff` 合并** + (M156):`git merge --no-ff origin/termux-glibc`,15 个原提交一字未改地留在历史里, + 按平台识别 → 字体 → 文档 → Java → GPU → natives → MC 的分组可整组 revert。 + 与 dev 前进的 12 个提交**零文本冲突**(交集只有 `Cargo.lock` / 两个 README / + `crates/app-iced/Cargo.toml`)。 +2. PR base:`dev`。CI 门禁:`fmt` / `clippy --workspace --all-targets -D warnings` / + `test --workspace --all-targets` / `cargo deny`(本机没装 deny,PC 上补跑)。 +3. 本机已知、**不影响 PC CI** 的三条失败: + - `unlucky-core --test launch`:`/proc//task//children`(Android 内核没有) + - 同测试:长路径被脱敏成 `` + - `presenter settings_bridge`:mtime 热重载偶发(flaky,单跑 3 次 2 过 1 挂) +4. **不要提交**:`dist/`、`.gnu/`、任何 `ULL_MSA_CLIENT_ID` 值(仓库 Secret)。 +5. 合并后建议真机再跑一次:`--software-renderer` 截图 + `ull java install 17` + MC 启动。 +6. **PC 门禁发现的两处、已在 M156 就地修掉**(都只在 Windows/PC 侧才暴露, + 所以在 Termux 上跑门禁看不出来): + - `game/java/discover.rs` 的两个 `#[cfg(unix)]` 测试,导入没跟着 gate + → Windows 上 `-D warnings` 判「未使用导入」。已把两个 `use` 同样 gate 成 `#[cfg(unix)]`; + 代价是这两个用例在 Windows 上不编译(由 Linux CI 覆盖)。 + - 本分支把 `game/java/install.rs`(456 行)与 `game/install/libraries.rs`(409 行) + 顶过了 400 行红线(`guards.yml` 的 `file-size`)。已按步骤拆出 + `install/termux_install.rs`、`install/one.rs`、`install/libraries/arm64_natives.rs`, + 行为不变、用例原样留在原处。 + +## 验证矩阵(本机) + +| 项 | 结果 | +| --- | --- | +| GUI 截图(横屏 1657×1067) | ✅ 中文正常,`client_id=configured source=build` | +| GPU 渲染 | ✅ wgpu 选中 Adreno 840(turnip Mesa 26.2.6);降级路径也验证过 | +| `ull java install 17` | ✅ SAU 镜像约 20 s,装完清理,`java list` 可见 | +| MC 1.20.4 | ✅ 真机安装(740 MB)→ 主菜单 → 建世界 → 进游戏 | +| fmt / clippy / 核心测试 | ✅ 475 条核心测试通过,clippy 零告警 | + +## 后续路线 + +1. **MC 启动参数**(主线功能,另行排期):当前 `ull launch` 不接受自定义 JVM/游戏参数。 +2. **MC 硬件 GL**:修 glibc GLX 的 DRI3(让 zink 生效),或改用 VulkanMod。 +3. 剪贴板 / IME 在 termux-x11 下的行为验证。 +4. narrator/flite 与音频设备(当前缺失但不阻断)。 +5. GPU 驱动分发:目前每次都要按文档自编 Mesa;可考虑脚本化/预编译产物。 + +## 回滚策略 + +全部改动按提交分组(platform → fonts → docs → java → gpu → natives → mc), +可按组 revert;Termux 专属逻辑都有运行时/文件存在 gate,回滚不会波及 PC 路径。 diff --git a/crates/app-iced/Cargo.toml b/crates/app-iced/Cargo.toml index e3cf139..ecba70b 100644 --- a/crates/app-iced/Cargo.toml +++ b/crates/app-iced/Cargo.toml @@ -24,6 +24,9 @@ path = "src/lib.rs" # 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。 unlucky-design = { path = "../design", version = "0.2.2" } +# 平台识别(Termux / $PREFIX 路径)唯一真源:字体注册这类平台适配不再各写一份环境判断。 +unlucky-core = { path = "../core", version = "0.2.2" } + # M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**—— # 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。 # 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。 diff --git a/crates/app-iced/src/app/boot.rs b/crates/app-iced/src/app/boot.rs index 182e421..38161ed 100644 --- a/crates/app-iced/src/app/boot.rs +++ b/crates/app-iced/src/app/boot.rs @@ -30,6 +30,8 @@ use super::{MIN_SIZE, WINDOW_SIZE}; mod diagnostics; mod fallback; +mod termux_gpu; +mod termux_window; use fallback::{on_failure, respawn, OnFailure}; @@ -39,6 +41,8 @@ pub(crate) fn run(options: Options) -> iced::Result { let boot_started = Instant::now(); // ① 探测(判据取在哪、日志在 `report_boot` 里统一打) + // Termux + glibc 构建:装了我们自编的 turnip ICD 就先生效,auto 也能选到 GPU。 + termux_gpu::enable(); let boot = boot_probe(&options); let decision = boot.decision; diagnostics::report_boot(&options, &boot.probe, &decision); @@ -131,10 +135,20 @@ fn launch( // M98:`ULL_WINDOW_SIZE=1520x900` 覆盖尺寸(矩阵的「最小 / 宽屏」两档靠它,且必须走**真外壳** // ——`pages_demo` example 的标题栏是留白带,出不了「三键位置」那张图)。 - let (size, size_source) = acceptance::window_size( + // Termux:没有环境覆盖时,默认尺寸按 X 屏幕等比自适应(横屏优先), + // 失败就退回 1180×760 的验收尺寸。 + let (default_size, default_source) = termux_window::default_size( (WINDOW_SIZE.width, WINDOW_SIZE.height), (MIN_SIZE.width, MIN_SIZE.height), - ); + ) + .unwrap_or(((WINDOW_SIZE.width, WINDOW_SIZE.height), "default")); + let (size, size_source) = + acceptance::window_size(default_size, (MIN_SIZE.width, MIN_SIZE.height)); + let size_source = if size_source == "default" { + default_source + } else { + size_source + }; let settings = diagnostics::window_settings(size); // 两行日志与开窗用的是**同一个** settings(见模块文档纪律 2) diagnostics::report_window(&settings, options.scale_factor, size_source); diff --git a/crates/app-iced/src/app/boot/termux_gpu.rs b/crates/app-iced/src/app/boot/termux_gpu.rs new file mode 100644 index 0000000..ee14b8f --- /dev/null +++ b/crates/app-iced/src/app/boot/termux_gpu.rs @@ -0,0 +1,58 @@ +//! Termux 上自动启用随附的 Vulkan ICD(glibc 侧 turnip,`ull_freedreno.json`)。 +//! +//! 背景:Termux 软件源里的 glibc Mesa(24.2.6)不认识 Adreno 840,硬件 GPU 需要用 +//! `packaging/docs/termux-glibc-build.md` §5 里自编的 Mesa 26(KGSL)。驱动装在 +//! `$PREFIX/glibc/share/vulkan/icd.d/ull_freedreno.json` 时,这里在探测之前把它设进 +//! `VK_ICD_FILENAMES` / `VK_DRIVER_FILES`,让 `auto` 也能选到 GPU。 +//! +//! 只在 **glibc 构建**(`target_os = "linux"`)上生效:Termux 原生(bionic)构建的 +//! `target_os = "android"`,glibc 驱动对它没有意义。用户显式设了这两个变量时不覆盖。 + +use std::path::{Path, PathBuf}; + +use unlucky_core::platform::paths::ProcessEnv; +use unlucky_core::platform::termux; + +/// 随附 ICD 的相对路径(相对 `$PREFIX`)。 +const ICD_REL: &str = "glibc/share/vulkan/icd.d/ull_freedreno.json"; + +/// 在 GPU 探测之前调用:装了就启用,没装就什么都不做。 +pub(super) fn enable() { + if !cfg!(target_os = "linux") { + return; + } + if std::env::var_os("VK_ICD_FILENAMES").is_some() + || std::env::var_os("VK_DRIVER_FILES").is_some() + { + return; + } + let Some(prefix) = termux::prefix(&ProcessEnv) else { + return; + }; + let icd = icd_path(&prefix); + if icd.is_file() { + std::env::set_var("VK_ICD_FILENAMES", &icd); + std::env::set_var("VK_DRIVER_FILES", &icd); + } +} + +/// `$PREFIX` 下随附 ICD 的路径(纯函数,测试用)。 +fn icd_path(prefix: &Path) -> PathBuf { + prefix.join(ICD_REL) +} + +#[cfg(test)] +mod tests { + use super::icd_path; + + #[test] + fn icd_path_is_under_the_glibc_prefix() { + let path = icd_path(std::path::Path::new("/data/data/com.termux/files/usr")); + assert_eq!( + path, + std::path::PathBuf::from( + "/data/data/com.termux/files/usr/glibc/share/vulkan/icd.d/ull_freedreno.json" + ) + ); + } +} diff --git a/crates/app-iced/src/app/boot/termux_window.rs b/crates/app-iced/src/app/boot/termux_window.rs new file mode 100644 index 0000000..b75ba4f --- /dev/null +++ b/crates/app-iced/src/app/boot/termux_window.rs @@ -0,0 +1,115 @@ +//! Termux / termux-x11 下的窗口尺寸自适应(横屏优先)。 +//! +//! 验收尺寸(1180×760)是 D30 的硬要求,不能改;但手机显示器可能横屏远大于它 +//! (例如 2066×1272)或竖屏略小。这里只在**运行时**把「默认尺寸」换成按当前 X +//! 屏幕算出的最大等比矩形;`ULL_WINDOW_SIZE` 覆盖仍然优先(见 `acceptance::window_size`)。 +//! +//! 只在 Termux + 有 `DISPLAY` 时探测;`xdpyinfo` 缺失/解析失败一律回默认,不影响其它平台。 + +use std::process::Command; + +use unlucky_core::platform::paths::ProcessEnv; +use unlucky_core::platform::termux; + +/// 屏幕四周留白(像素)。 +const MARGIN: f32 = 32.0; +/// 最大放大倍数:大屏上也不把窗口撑成巨屏。 +const MAX_SCALE: f32 = 2.0; +/// `xdpyinfo` 里尺寸那一行的前缀。 +const DIMENSIONS_PREFIX: &str = "dimensions:"; + +/// Termux 上的默认窗口尺寸:按 X 屏幕等比放大/缩小,失败返回 `None`(用调用方默认)。 +/// +/// `default` / `min` 由调用方传入([`super::WINDOW_SIZE`] / [`super::MIN_SIZE`]), +/// 这里不复制常量。 +#[must_use] +pub(super) fn default_size( + default: (f32, f32), + min: (f32, f32), +) -> Option<((f32, f32), &'static str)> { + if !termux::is_termux(&ProcessEnv) || std::env::var_os("DISPLAY").is_none() { + return None; + } + let screen = x_screen_size()?; + Some((fit_screen(default, min, screen), "termux-screen")) +} + +/// 在 `screen` 里放一个保持 `default` 宽高比的最大矩形(保留留白,不突破 `min`)。 +#[must_use] +pub(super) fn fit_screen(default: (f32, f32), min: (f32, f32), screen: (f32, f32)) -> (f32, f32) { + if screen.0 <= 0.0 || screen.1 <= 0.0 || default.0 <= 0.0 || default.1 <= 0.0 { + return default; + } + let available = ( + (screen.0 - MARGIN * 2.0).max(1.0), + (screen.1 - MARGIN * 2.0).max(1.0), + ); + let scale = (available.0 / default.0) + .min(available.1 / default.1) + .min(MAX_SCALE); + let width = (default.0 * scale).max(min.0).min(screen.0); + let height = (default.1 * scale).max(min.1).min(screen.1); + (width, height) +} + +/// 当前 X 屏幕尺寸(`xdpyinfo` 的 `dimensions: WxH pixels`)。 +fn x_screen_size() -> Option<(f32, f32)> { + let output = Command::new("xdpyinfo").output().ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8_lossy(&output.stdout); + let line = text + .lines() + .find(|line| line.trim_start().starts_with(DIMENSIONS_PREFIX))?; + let value = line.split_whitespace().nth(1)?; + let (width, height) = value.split_once('x')?; + Some((width.parse().ok()?, height.parse().ok()?)) +} + +#[cfg(test)] +mod tests { + use super::fit_screen; + + const DEFAULT: (f32, f32) = (1180.0, 760.0); + const MIN: (f32, f32) = (880.0, 560.0); + + fn close(actual: (f32, f32), expected: (f32, f32)) { + assert!( + (actual.0 - expected.0).abs() < 0.5 && (actual.1 - expected.1).abs() < 0.5, + "expected {expected:?}, got {actual:?}" + ); + } + + #[test] + fn landscape_screen_scales_up_keeping_aspect() { + // 2066×1272:留白后 2002×1208,高度是瓶颈 → 1875.6×1208。 + close( + fit_screen(DEFAULT, MIN, (2066.0, 1272.0)), + (1875.58, 1208.0), + ); + } + + #[test] + fn portrait_screen_still_fits_width() { + // 1272×2066:宽度是瓶颈 → 1208×778。 + close(fit_screen(DEFAULT, MIN, (1272.0, 2066.0)), (1208.0, 778.03)); + } + + #[test] + fn huge_screen_is_capped_at_two_times() { + close(fit_screen(DEFAULT, MIN, (4000.0, 3000.0)), (2360.0, 1520.0)); + } + + #[test] + fn tiny_screen_never_goes_below_min_or_overflow() { + let (width, height) = fit_screen(DEFAULT, MIN, (800.0, 600.0)); + assert_eq!(width, 800.0, "宽度受屏幕限制"); + assert_eq!(height, 560.0, "高度不小于最小尺寸"); + } + + #[test] + fn invalid_screen_falls_back_to_default() { + assert_eq!(fit_screen(DEFAULT, MIN, (0.0, 0.0)), DEFAULT); + } +} diff --git a/crates/app-iced/src/fonts.rs b/crates/app-iced/src/fonts.rs index 74c5b5e..8690f05 100644 --- a/crates/app-iced/src/fonts.rs +++ b/crates/app-iced/src/fonts.rs @@ -139,8 +139,43 @@ pub fn resolve() -> Resolved { resolve_with(None) } +/// Termux 上把 Android 的系统 CJK 字体注册进 cosmic-text 的字体库。 +/// +/// 这套 glibc 构建跑在 Termux 里(`target_os = "linux"`),fontdb 只扫 Linux 的常规字体目录, +/// Android 的 `/system/fonts` 不在其中——不注册的话中文实例名与界面文案全是方框。 +/// 第一次调用加载一次;找不到任何候选字体时静默放过(由既有回退链兜底)。 +fn register_termux_fonts() { + use std::sync::OnceLock; + + use unlucky_core::platform::paths::ProcessEnv; + use unlucky_core::platform::termux; + + static REGISTERED: OnceLock<()> = OnceLock::new(); + REGISTERED.get_or_init(|| { + if !termux::is_termux(&ProcessEnv) { + return; + } + const CANDIDATES: &[&str] = &[ + "/system/fonts/NotoSansCJK-Regular.ttc", + "/system/fonts/NotoSansCJKSC-Regular.otf", + "/system/fonts/DroidSansFallback.ttf", + ]; + let Ok(mut font_system) = iced::advanced::graphics::text::font_system().write() else { + return; + }; + let db = font_system.raw().db_mut(); + for path in CANDIDATES { + if db.load_font_file(path).is_ok() { + return; + } + } + }); +} + /// 同 [`resolve`],但把配置里 `ui.language` 的显式值喂给中文策略(D28 的第一级)。 pub fn resolve_with(setting: Option<&str>) -> Resolved { + register_termux_fonts(); + let platform = Platform::current(); let faces = system_faces(); diff --git a/crates/cli/src/cli/help.rs b/crates/cli/src/cli/help.rs index 9a1ce60..eaadf7f 100644 --- a/crates/cli/src/cli/help.rs +++ b/crates/cli/src/cli/help.rs @@ -34,8 +34,8 @@ pub const fn general() -> &'static str { config set 写配置(走 setting schema 校验) java list 列出可用 Java(含每个 JDK 的实际落点) java install [--vendor X] 安装托管 JDK(缺省按地区回退链选发行版; - --vendor temurin|oracle|microsoft|zulu 钉死一家) - java vendors 列出四家发行版的镜像/支持版本/当前优先级 + --vendor temurin|oracle|microsoft|zulu|termux 钉死一家) + java vendors 列出各发行版的镜像/支持版本/当前优先级 agent serve [--stdio] [--port N] 起对外 MCP server(默认 127.0.0.1 的 Streamable HTTP; [--allow-write] --stdio 给 llama.cpp 内置 client 用;默认只读) agent config [--port N] 打印 Claude Code / Cursor 的配置样例 @@ -157,7 +157,7 @@ authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CL %prog% java list 探测 JAVA_HOME / PATH / 托管目录 / 实例覆盖里的 Java 并打印每个 JDK 的实际落点(managed 项带目录) %prog% java install [--vendor X] 安装托管 JDK(D13/D23):回退链查版本 → 下载 → 解压 → 验证 -%prog% java vendors 列出四家发行版的镜像/支持版本/当前优先级顺序 +%prog% java vendors 列出各发行版的镜像/支持版本/当前优先级顺序 落点(D23 三模式,随 core.data_mode 与设置键 java.installRoot 走): portable(默认) <数据根>/.ULL/data/java-managed/jdk--/ @@ -168,7 +168,9 @@ authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CL 发行版回退链(D23):国内 temurin(TUNA) → oracle(华为云→南大) → microsoft(aka.ms) → zulu;国外 temurin → microsoft → oracle → zulu。设置 java.vendorPriority 可改顺序 (只写几家也行,其余按默认序补齐);--vendor 钉死一家、不自动降级。 -每家失败的原因(版本不存在 / 镜像全 4xx / 校验失败)进 --json 的 chain 块与人读的「降级:」行。", +Termux 上缺省先用系统包(pkg install openjdk-,支持 17/21/25),失败再走归档链; +--vendor termux 只走系统包。每家失败的原因(版本不存在 / 镜像全 4xx / 校验失败)进 +--json 的 chain 块与人读的「降级:」行。", ), "agent" => Some( "\ diff --git a/crates/cli/src/commands/java_vendors.rs b/crates/cli/src/commands/java_vendors.rs index 96cfb22..82bbec9 100644 --- a/crates/cli/src/commands/java_vendors.rs +++ b/crates/cli/src/commands/java_vendors.rs @@ -8,7 +8,7 @@ use serde_json::{json, Value}; use unlucky_core::download::MirrorTable; -use unlucky_core::game::java::{vendor, JdkVendor}; +use unlucky_core::game::java::{termux, vendor, JdkVendor}; use super::java::{region_profile, JavaSettings}; use crate::context::Ctx; @@ -23,7 +23,11 @@ use crate::output::Report; pub fn vendors(ctx: &Ctx) -> Result { let settings = JavaSettings::load(ctx); let domestic = region_profile(ctx).is_domestic(); - let order = vendor::order(&settings.priority, domestic); + let mut order = vendor::order(&settings.priority, domestic); + // Termux 上系统包是一条真实可用的发行版:不进默认回退序,但展示时要让人看见。 + if termux::available() && !order.contains(&JdkVendor::Termux) { + order.insert(0, JdkVendor::Termux); + } let table = MirrorTable::builtin(); let rows: Vec = order .iter() @@ -114,6 +118,10 @@ fn vendor_json(table: &MirrorTable, item: JdkVendor, index: usize) -> Value { .zulu() .map(|zulu| vec![format!("{}({})", zulu.api, zulu.measured_speed)]) .unwrap_or_default(), + JdkVendor::Termux => termux::MIRRORS + .iter() + .map(|mirror| format!("{mirror}(系统包:pkg install openjdk-)")) + .collect(), }; json!({ "rank": index + 1, diff --git a/crates/cli/src/commands/snapshot.rs b/crates/cli/src/commands/snapshot.rs index b37fd9a..efc01a8 100644 --- a/crates/cli/src/commands/snapshot.rs +++ b/crates/cli/src/commands/snapshot.rs @@ -75,18 +75,38 @@ pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result { let mut libraries: Vec = Vec::new(); for library in resolved.libraries.as_deref().unwrap_or_default() { - if !library.applies(&environment) || library.native_classifier(&environment).is_some() { + if !library.applies(&environment) { continue; } - let Ok(coordinate) = library.coordinate() else { + // 1.19+ 的 natives 是独立库条目(name 带分类器),必须进 classpath; + // ≤1.18 的 `natives` 表条目归 natives::collect,不进 classpath。 + // Linux arm64:独立条目改写成 natives-linux-arm64(路径与展示名一起改)。 + let arm64 = library.linux_arm64_native_name(&environment); + if arm64.is_none() && library.native_classifier(&environment).is_some() { continue; + } + let (coordinate, name) = match arm64 { + Some(rewritten) => { + let Ok(coordinate) = + unlucky_core::game::library::MavenCoordinate::parse(&rewritten) + else { + continue; + }; + (coordinate, rewritten) + } + None => { + let Ok(coordinate) = library.coordinate() else { + continue; + }; + (coordinate, library.name.clone()) + } }; let path = if library.is_local() { game.version_library(&version_id, &coordinate) } else { game.library(&coordinate) }; - libraries.push(LibraryInput::new(path, library.name.clone())); + libraries.push(LibraryInput::new(path, name)); } // natives 分类器构件(≤1.18.2 才有):收集口径归 core,CLI 只搬运。 diff --git a/crates/core/src/game/install/libraries.rs b/crates/core/src/game/install/libraries.rs index 6638c38..ea92fce 100644 --- a/crates/core/src/game/install/libraries.rs +++ b/crates/core/src/game/install/libraries.rs @@ -18,6 +18,10 @@ use super::super::library::Library; use super::plan::{FileRole, PlanFile}; use crate::download::ResourceKind; +use arm64_natives::{arm64_native_url, linux_arm64_native_entry, LINUX_ARM64_CLASSIFIER}; + +mod arm64_natives; + /// 没写 `url` 的库兜底用的 Maven 基址(Mojang 官方库仓)。 pub const DEFAULT_MAVEN_BASE: &str = "https://libraries.minecraft.net/"; @@ -39,6 +43,20 @@ pub fn plan( if !library.applies(environment) { continue; } + // 现代清单把 natives 作为独立库条目(`org.lwjgl:lwjgl:3.3.2:natives-linux`), + // 不是 `natives` 表。Linux arm64 上改写坐标并兜底到 Maven 镜像。 + if let Some(rewritten) = linux_arm64_native_entry(library, environment) { + files.push(PlanFile { + role: FileRole::Native, + name: rewritten.name, + url: rewritten.url, + sha1: None, + size: None, + dest: layout.libraries_dir().join(&rewritten.relative), + resource: ResourceKind::Library, + }); + continue; + } if library.coordinate().is_err() { warnings.push(format!("库坐标不合法,已跳过:{}", library.name)); continue; @@ -79,31 +97,42 @@ pub fn plan( let Some(classifier) = library.native_classifier(environment) else { continue; }; - let Some(artifact) = library.native_artifact(&classifier) else { - warnings.push(format!( - "{} 的 natives 分类器 {classifier} 没有下载信息,已跳过", - library.name - )); - continue; - }; let Ok(native_relative) = library.native_relative_path(&classifier) else { warnings.push(format!("{} 的 natives 路径推导失败,已跳过", library.name)); continue; }; - if !artifact.has_url() { - warnings.push(format!( - "{} 的 natives 构件 {classifier} 没有下载地址,已跳过", - library.name - )); - continue; - } + let dest = layout.libraries_dir().join(&native_relative); + // Linux arm64:Mojang 清单没有 arm64 分类器,兜底到 Maven 镜像(见常量注释)。 + let arm64_fallback = classifier == LINUX_ARM64_CLASSIFIER; + let (url, sha1, size) = match library.native_artifact(&classifier) { + Some(artifact) if artifact.has_url() => ( + artifact.url.clone().unwrap_or_default(), + artifact.sha1.clone(), + artifact.size, + ), + _ if arm64_fallback => (arm64_native_url(&native_relative), None, None), + Some(_) => { + warnings.push(format!( + "{} 的 natives 构件 {classifier} 没有下载地址,已跳过", + library.name + )); + continue; + } + None => { + warnings.push(format!( + "{} 的 natives 分类器 {classifier} 没有下载信息,已跳过", + library.name + )); + continue; + } + }; files.push(PlanFile { role: FileRole::Native, name: format!("{}:{classifier}", library.name), - url: artifact.url.clone().unwrap_or_default(), - sha1: artifact.sha1.clone(), - size: artifact.size, - dest: layout.libraries_dir().join(native_relative), + url, + sha1, + size, + dest, resource: ResourceKind::Library, }); } @@ -255,4 +284,81 @@ mod tests { .dest .ends_with("versions/inst/libraries/net/minecraftforge/forge/1.20.1-47.2.0.jar")); } + + #[test] + fn linux_arm64_lwjgl3_natives_fall_back_to_maven_mirror() { + // Mojang 的清单只有 `natives-linux`(x86_64)且没有 arm64 分类器下载信息; + // Linux arm64 要改写成 `natives-linux-arm64` 并兜底到 Maven 镜像。 + let library = library_with_artifact( + r#"{"name":"org.lwjgl:lwjgl:3.3.2","natives":{"linux":"natives-linux"}}"#, + ); + let env = Environment { + os: "linux".to_string(), + os_version: String::new(), + arch: "arm64".to_string(), + ..environment() + }; + let layout = GameLayout::new("/g/.minecraft"); + let (files, warnings) = plan(&layout, "1.20.4", &[library], &env); + assert!(warnings.is_empty(), "不该有告警:{warnings:?}"); + assert_eq!(files.len(), 1); + assert_eq!(files[0].name, "org.lwjgl:lwjgl:3.3.2:natives-linux-arm64"); + assert_eq!( + files[0].url, + "https://maven.aliyun.com/repository/public/org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-linux-arm64.jar" + ); + assert!( + files[0].sha1.is_none(), + "镜像兜底没有 sha1,只能靠大小/存在性" + ); + } + + #[test] + fn modern_lwjgl_native_entry_is_rewritten_to_arm64_maven() { + // 1.20.x 的清单把 natives 作为独立条目,分类器写在 name 里。 + let library = library_with_artifact( + r#"{"name":"org.lwjgl:lwjgl:3.3.2:natives-linux", + "rules":[{"action":"allow","os":{"name":"linux"}}], + "downloads":{"artifact":{"path":"org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-linux.jar","url":"https://libraries.minecraft.net/x.jar","sha1":"aa"}}}"#, + ); + let env = Environment { + os: "linux".to_string(), + os_version: String::new(), + arch: "arm64".to_string(), + ..environment() + }; + let layout = GameLayout::new("/g/.minecraft"); + let (files, warnings) = plan(&layout, "1.20.4", &[library], &env); + assert!(warnings.is_empty(), "不该有告警:{warnings:?}"); + assert_eq!(files.len(), 1); + assert_eq!(files[0].role.as_str(), "native"); + assert_eq!(files[0].name, "org.lwjgl:lwjgl:3.3.2:natives-linux-arm64"); + assert_eq!( + files[0].url, + "https://maven.aliyun.com/repository/public/org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-linux-arm64.jar" + ); + assert!(files[0] + .dest + .ends_with("libraries/org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-linux-arm64.jar")); + } + + #[test] + fn linux_x86_64_keeps_the_manifest_classifier() { + let library = library_with_artifact( + r#"{"name":"org.lwjgl:lwjgl:3.3.2","natives":{"linux":"natives-linux"}}"#, + ); + let env = Environment { + os: "linux".to_string(), + os_version: String::new(), + arch: "x86_64".to_string(), + ..environment() + }; + let layout = GameLayout::new("/g/.minecraft"); + let (files, warnings) = plan(&layout, "1.20.4", &[library], &env); + assert!(files.is_empty(), "x86_64 没有清单下载信息,不该凭空拼 URL"); + assert!( + warnings.iter().any(|w| w.contains("natives-linux")), + "{warnings:?}" + ); + } } diff --git a/crates/core/src/game/install/libraries/arm64_natives.rs b/crates/core/src/game/install/libraries/arm64_natives.rs new file mode 100644 index 0000000..fc0c7f9 --- /dev/null +++ b/crates/core/src/game/install/libraries/arm64_natives.rs @@ -0,0 +1,72 @@ +//! Linux arm64 的 natives 改写与下载兜底。 +//! +//! 两件事实拼出来的特例: +//! 1. Mojang 清单里 LWJGL3 的 natives 只写 `natives-linux`(x86_64 的 `.so`); +//! 直接用它,在 aarch64 JVM 里加载必失败。 +//! 2. 同一个 Maven 坐标确实有 `natives-linux-arm64` 分类器,但 `libraries.minecraft.net` +//! 对它一律 404(实测 3.3.2 的 lwjgl/glfw/opengl/stb/tinyfd/openal),Maven Central 与 +//! 阿里云镜像有。 +//! +//! 所以 Linux arm64 上:分类器改写(`Library::native_classifier` / +//! `Library::linux_arm64_native_name`)+ 地址兜底到镜像。**只在 linux + arm64 生效**, +//! x86_64 / Windows / macOS 的地址一个字节不变。用例仍在 `libraries.rs` 的 tests 里 +//! (它们从 `plan()` 的出口断言,不直接调这里的私有步骤)。 +//! +//! 从 `libraries.rs` 拆出来是为了守住 400 行红线(`guards.yml` 的 `file-size`)。 + +use std::path::{Path, PathBuf}; + +use crate::game::arguments::Environment; +use crate::game::library::Library; + +/// Linux arm64 的 natives 兜底仓库:Mojang 的 `libraries.minecraft.net` 对 +/// `natives-linux-arm64` 一律 404(实测 3.3.2 的 lwjgl/glfw/opengl/stb/tinyfd/openal), +/// 同一坐标在 Maven Central / 阿里云镜像有。 +pub const LINUX_ARM64_NATIVES_BASE: &str = "https://maven.aliyun.com/repository/public/"; + +/// Linux arm64 的 natives 分类器(见 `Library::native_classifier`)。 +pub const LINUX_ARM64_CLASSIFIER: &str = "natives-linux-arm64"; + +/// 现代清单里 LWJGL3 natives 条目的改写结果。 +pub(super) struct Arm64Native { + pub(super) name: String, + pub(super) relative: PathBuf, + pub(super) url: String, +} + +/// Linux arm64:把 `org.lwjgl:::natives-linux` 改写成 arm64。 +pub(super) fn linux_arm64_native_entry( + library: &Library, + env: &Environment, +) -> Option { + let name = library.linux_arm64_native_name(env)?; + let base = name.strip_suffix(":natives-linux-arm64")?; + let mut parts = base.split(':'); + let group = parts.next()?; + let artifact = parts.next()?; + let version = parts.next()?; + if parts.next().is_some() { + return None; + } + let relative = PathBuf::from(format!( + "{}/{artifact}/{version}/{artifact}-{version}-natives-linux-arm64.jar", + group.replace('.', "/") + )); + let url = format!( + "{LINUX_ARM64_NATIVES_BASE}{}", + relative.to_string_lossy().replace('\\', "/") + ); + Some(Arm64Native { + name, + relative, + url, + }) +} + +/// arm64 natives 的兜底 URL(按相对路径拼镜像基址)。 +pub(super) fn arm64_native_url(relative: &Path) -> String { + format!( + "{LINUX_ARM64_NATIVES_BASE}{}", + relative.to_string_lossy().replace('\\', "/") + ) +} diff --git a/crates/core/src/game/java/discover.rs b/crates/core/src/game/java/discover.rs index b44a3e5..bfd9d01 100644 --- a/crates/core/src/game/java/discover.rs +++ b/crates/core/src/game/java/discover.rs @@ -8,6 +8,7 @@ //! | --- | --- | //! | 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/*` | +//! | Termux | `$PREFIX/lib/jvm/*`(`java-17-openjdk` 这类;`$PREFIX` 由 [`crate::platform::termux`] 识别) | //! | macOS | `/Library/Java/JavaVirtualMachines/*.jdk/Contents/Home` | //! //! 三个纪律: @@ -22,6 +23,9 @@ use std::path::{Path, PathBuf}; use std::process::Command; +use crate::platform::paths::{EnvSource, ProcessEnv}; +use crate::platform::termux; + use super::extract::java_binary_name; use super::probe::{self, discover_managed_roots, system_candidates, JavaOrigin, JavaRuntime}; @@ -73,6 +77,12 @@ pub fn scan_java_binaries() -> Vec { /// 候选 JDK 根目录(`bin/java` 的父目录的父目录)。 #[must_use] pub fn standard_roots() -> Vec { + standard_roots_in(&ProcessEnv) +} + +/// 同 [`standard_roots`],但环境变量可注入(测试 / 便携化封装用)。 +#[must_use] +pub fn standard_roots_in(env: &dyn EnvSource) -> Vec { let mut roots = Vec::new(); if cfg!(windows) { for variable in ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"] { @@ -99,6 +109,10 @@ pub fn standard_roots() -> Vec { for base in ["/usr/lib/jvm", "/usr/java"] { roots.extend(child_dirs(Path::new(base))); } + // Termux 的 JDK 落在 `$PREFIX/lib/jvm`(如 `java-17-openjdk`),不在 `/usr` 下。 + if let Some(root) = termux::jvm_root(env) { + roots.extend(child_dirs(&root)); + } } roots.sort(); roots.dedup(); @@ -213,6 +227,12 @@ pub fn path_key(path: &Path) -> String { mod tests { use super::{dedupe, parse_java_home, path_key, standard_roots}; use crate::game::java::probe::{JavaOrigin, JavaRuntime}; + // 这两个只有 Termux 扫描的两个测试用,而那两个测试是 `cfg(unix)`——导入必须同样 gate, + // 否则 Windows 上不编译测试、导入就成了未使用(`-D warnings` 直接判错)。 + #[cfg(unix)] + use super::standard_roots_in; + #[cfg(unix)] + use crate::platform::paths::MapEnv; fn runtime(path: &str, origin: JavaOrigin) -> JavaRuntime { JavaRuntime { @@ -323,4 +343,41 @@ mod tests { let roots = standard_roots(); assert!(roots.iter().all(|root| !root.as_os_str().is_empty())); } + + #[cfg(unix)] + #[test] + fn termux_jvm_root_is_scanned() { + let base = std::env::temp_dir().join(format!("ull-termux-jvm-{}", std::process::id())); + let jdk = base.join("lib").join("jvm").join("java-17-openjdk"); + std::fs::create_dir_all(jdk.join("bin")).unwrap(); + std::fs::write(jdk.join("bin").join("java"), b"").unwrap(); + + let env = MapEnv::new() + .with("TERMUX_VERSION", "0.118.3") + .with("PREFIX", base.to_str().unwrap()); + let roots = standard_roots_in(&env); + assert!( + roots.contains(&jdk), + "Termux 的 $PREFIX/lib/jvm 必须进扫描表:{roots:?}" + ); + + std::fs::remove_dir_all(&base).ok(); + } + + #[cfg(unix)] + #[test] + fn non_termux_env_does_not_scan_prefix() { + let base = std::env::temp_dir().join(format!("ull-not-termux-{}", std::process::id())); + let jdk = base.join("lib").join("jvm").join("java-17-openjdk"); + std::fs::create_dir_all(&jdk).unwrap(); + + let env = MapEnv::new().with("PREFIX", base.to_str().unwrap()); + let roots = standard_roots_in(&env); + assert!( + !roots.contains(&jdk), + "非 Termux 不该扫 $PREFIX/lib/jvm:{roots:?}" + ); + + std::fs::remove_dir_all(&base).ok(); + } } diff --git a/crates/core/src/game/java/install.rs b/crates/core/src/game/java/install.rs index 654dbc6..6377579 100644 --- a/crates/core/src/game/java/install.rs +++ b/crates/core/src/game/java/install.rs @@ -1,6 +1,7 @@ //! 托管 JDK 安装编排:发行版回退链 → 下载引擎 → 解压 → `java -version` 验证。 //! //! ```text +//! [Termux] termux_install::attempt 系统包(.deb)——与下面这条链不共用任何一步 //! vendor::order(用户优先级, 地区) D23 的尝试顺序 //! → source::fetch(vendor, major) 查到该家最新 GA(Temurin/Oracle/MS/Zulu) //! → location::JavaInstallRoot::jdk_dir 按落点模式算目录 @@ -13,22 +14,35 @@ //! **回退的粒度是发行版**(D23):该家没有这个大版本、镜像全 4xx、sha256 取不到、下载校验失败, //! 都记一条降级原因然后换下一家;只有全部失败才整体报错,错误里带上每一跳的原因。 //! +//! **平台参数(`AdoptiumTarget::host()`)失败不提前退出**:Termux/Android 这类没有归档产物的 +//! 宿主上,早退只会在错误里留一句「不支持该平台」,看不出是哪几家、各卡在哪;现在它降级成 +//! 每家一条原因,与其它失败同形。 +//! +//! 这一步**不拼任何镜像 URL**(Oracle 的哈希段不可推导,见 `oracle` 模块):Temurin 的 +//! GitHub 直链交给 `MirrorTable::expand` 展开成院校镜像,Oracle/MS/Zulu 的链接由各自模块 +//! 从目录页/模板/API 拿到最终形态,引擎只负责「把这个 URL 下下来并校验」。 +//! //! 这一步**不拼任何镜像 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::error::{JavaError, JavaErrorKind}; use super::extract; use super::location::{JavaInstallRoot, LocationMode}; -use super::probe::{self, JavaOrigin, JavaRuntime}; +use super::probe::{self, 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}; +use one::install_one; + +mod one; +mod termux_install; + /// 归档缓存目录名(在安装根下;托管目录扫描跳过它)。 pub use super::location::CACHE_DIR; @@ -184,14 +198,31 @@ pub async fn install( location: &JavaInstallRoot, request: InstallRequest, ) -> Result { - let target = AdoptiumTarget::host()?; let domestic = engine.region().is_domestic(); let priority = request.order(domestic); - let mut chain = ChainLog::new(); let mut reasons: Vec = Vec::new(); // 清掉上次中断留下的 .staging-* 与空壳(只碰我们自己的形状,见 location 模块)。 let _ = super::location::cleanup_orphans(&location.root); + // Termux 那一条路(系统包)单独成模块:它与归档链不共用任何一步,见 `termux_install`。 + // 它把 chain 还回来(没走成时),归档链接着往上记。 + let mut chain = match termux_install::attempt( + request.vendor, + request.major, + location, + ChainLog::new(), + &mut reasons, + ) + .await? + { + termux_install::Outcome::Done(installed) => return Ok(*installed), + termux_install::Outcome::Skipped(chain) => chain, + }; + + // 归档发行版需要 Adoptium 的平台参数;Termux/Android 上这里会 Unsupported, + // 不提前 `?` 退出——让每一家都记一条降级原因,错误里能看见全貌。 + let target = AdoptiumTarget::host(); + for candidate in &priority { let vendor = *candidate; if !vendor.supports(request.major) { @@ -205,6 +236,15 @@ pub async fn install( reasons.push(format!("{}:{reason}", vendor.id())); continue; } + let target = match &target { + Ok(target) => *target, + Err(error) => { + let message = error.message().to_string(); + chain.failed(vendor, message.clone()); + reasons.push(format!("{}:{message}", vendor.id())); + continue; + } + }; let release = match source::fetch(engine, vendor, request.major, target).await { Ok(release) => release, Err(error) => { @@ -250,84 +290,6 @@ pub async fn install( .with_hint("`ull java vendors` 看各发行版的可用性;或手动装 JDK 后设 JAVA_HOME")) } -/// 某一家的中间结果(把七元组元组换成具名结构,读起来才不费劲)。 -struct Prepared { - root: PathBuf, - archive: PathBuf, - runtime: JavaRuntime, - downloaded: Option, - bytes: u64, - files: usize, - extracted: bool, -} - -/// 装某一家的那一份(下载 → 解压 → 探测)。 -async fn install_one( - engine: &DownloadEngine, - location: &JavaInstallRoot, - release: &JdkRelease, -) -> Result { - 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 @@ -369,11 +331,3 @@ fn majors_text(vendor: JdkVendor) -> String { .collect::>() .join("/") } - -fn note(message: &str) { - if cfg!(debug_assertions) { - eprintln!("[ull::game::java] {message}"); - } else { - let _ = message; - } -} diff --git a/crates/core/src/game/java/install/one.rs b/crates/core/src/game/java/install/one.rs new file mode 100644 index 0000000..e213e22 --- /dev/null +++ b/crates/core/src/game/java/install/one.rs @@ -0,0 +1,100 @@ +//! 回退链里的「一家」怎么装:下载 → 解压 → 探测,外加幂等复用。 +//! +//! 从 `install.rs` 拆出来是为了守住 400 行红线(`guards.yml` 的 `file-size`): +//! `install.rs` 只保留「按优先级链挑一家 / 记降级原因」的编排,这里放单家的机械步骤。 + +use std::path::PathBuf; + +use super::super::error::{io_error, JavaError, JavaErrorKind}; +use super::super::extract; +use super::super::location::JavaInstallRoot; +use super::super::probe::{self, JavaOrigin, JavaRuntime}; +use super::super::release::JdkRelease; +use super::{archive_path, archive_request}; +use crate::download::DownloadEngine; + +/// 某一家的中间结果(把七元组元组换成具名结构,读起来才不费劲)。 +pub(super) struct Prepared { + pub(super) root: PathBuf, + pub(super) archive: PathBuf, + pub(super) runtime: JavaRuntime, + pub(super) downloaded: Option, + pub(super) bytes: u64, + pub(super) files: usize, + pub(super) extracted: bool, +} + +/// 装某一家的那一份(下载 → 解压 → 探测)。 +pub(super) async fn install_one( + engine: &DownloadEngine, + location: &JavaInstallRoot, + release: &JdkRelease, +) -> Result { + 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(), + }) +} + +fn note(message: &str) { + if cfg!(debug_assertions) { + eprintln!("[ull::game::java] {message}"); + } else { + let _ = message; + } +} diff --git a/crates/core/src/game/java/install/termux_install.rs b/crates/core/src/game/java/install/termux_install.rs new file mode 100644 index 0000000..62d8ef6 --- /dev/null +++ b/crates/core/src/game/java/install/termux_install.rs @@ -0,0 +1,105 @@ +//! `install` 的 Termux 分支:JDK 由系统包管理器装进 `$PREFIX`,不是可下载的归档。 +//! +//! 单独成模块有两个理由:`.deb` 这条路与归档链**没有共用步骤**(不查版本元数据、不下载、 +//! 不解压、不校验 sha256),留在 `install()` 里只能是一段嵌在中间的 if;而 `install.rs` +//! 贴着 400 行红线(拆分前 456 行,见 `guards.yml` 的 `file-size`)。 + +use super::super::error::{JavaError, JavaErrorKind}; +use super::super::extract; +use super::super::location::JavaInstallRoot; +use super::super::probe::{self, JavaOrigin}; +use super::super::release::{JdkArchive, JdkRelease}; +use super::super::source::ChainLog; +use super::super::vendor::JdkVendor; +use super::InstalledJdk; + +/// Termux 分支的结果。 +pub(super) enum Outcome { + /// 系统包装成了,`install` 直接返回它(装箱只为让枚举别撑到 392 字节, + /// 这条路一次安装才走一次)。 + Done(Box), + /// 不该走这条分支,或者走了但失败(原因已进 `chain` 与 `reasons`)——把 chain 还给调用方, + /// 由它继续跑归档回退链。 + Skipped(ChainLog), +} + +/// 尝试走 Termux 系统包。 +/// +/// 语义(D23「`--vendor` 钉死一家、不自动降级」): +/// - `pinned = Some(Termux)`:只走这条,失败直接冒泡; +/// - `pinned = None`:Termux 上先试系统包,失败记一条降级原因后继续归档链; +/// - 其它平台:`Ok(Skipped(chain))`,一个字节都不动。 +/// +/// # Errors +/// +/// 只有「钉死 termux」且失败时才返回错误;其余失败一律降级成 `reasons` 里的一行。 +pub(super) async fn attempt( + pinned: Option, + major: u32, + location: &JavaInstallRoot, + chain: ChainLog, + reasons: &mut Vec, +) -> Result { + let wanted = match pinned { + Some(JdkVendor::Termux) => true, + Some(_) => false, + None => super::super::termux::available(), + }; + if !wanted { + return Ok(Outcome::Skipped(chain)); + } + if !super::super::termux::available() { + return Err( + JavaError::new(JavaErrorKind::Unsupported, "termux 源只在 Termux 里可用") + .with_hint("普通 Linux/Windows 用 --vendor temurin|oracle|microsoft|zulu"), + ); + } + let mut chain = chain; + match super::super::termux::install(major).await { + Ok((home, reused)) => { + let mut runtime = probe::probe(&extract::java_binary(&home), JavaOrigin::Managed); + runtime.vendor = Some(JdkVendor::Termux.id().to_string()); + let version = runtime.version.clone().unwrap_or_else(|| major.to_string()); + let release = JdkRelease { + vendor: JdkVendor::Termux, + major, + version, + semver: None, + release_name: format!("Termux openjdk-{major}"), + archive: JdkArchive { + name: format!("openjdk-{major}.deb"), + url: String::new(), + sha256: String::new(), + size: 0, + }, + }; + chain.hit(JdkVendor::Termux, format!("命中 {}", release.summary())); + Ok(Outcome::Done(Box::new(InstalledJdk { + major: release.major, + vendor: JdkVendor::Termux, + release, + root: home, + mode: location.mode, + archive: std::path::PathBuf::new(), + runtime, + downloaded: if reused { + None + } else { + Some("termux".to_string()) + }, + bytes: 0, + files: 0, + extracted: false, + chain, + }))) + } + Err(error) => { + if pinned == Some(JdkVendor::Termux) { + return Err(error); + } + chain.failed(JdkVendor::Termux, error.message().to_string()); + reasons.push(format!("termux:{}", error.message())); + Ok(Outcome::Skipped(chain)) + } + } +} diff --git a/crates/core/src/game/java/mod.rs b/crates/core/src/game/java/mod.rs index 45e0110..dd2aad7 100644 --- a/crates/core/src/game/java/mod.rs +++ b/crates/core/src/game/java/mod.rs @@ -54,6 +54,7 @@ pub mod release; pub mod select; pub mod source; pub mod target; +pub mod termux; pub mod vendor; pub mod zulu; diff --git a/crates/core/src/game/java/source.rs b/crates/core/src/game/java/source.rs index 112691f..a463a84 100644 --- a/crates/core/src/game/java/source.rs +++ b/crates/core/src/game/java/source.rs @@ -56,6 +56,11 @@ pub async fn fetch( })?; zulu::fetch_latest(client, table, major, target).await } + JdkVendor::Termux => Err(JavaError::new( + super::error::JavaErrorKind::Unsupported, + "Termux 的 JDK 由系统包管理器安装,不走归档下载链", + ) + .with_hint("在 Termux 里用 `ull java install `(默认走 termux 源)")), } } diff --git a/crates/core/src/game/java/termux.rs b/crates/core/src/game/java/termux.rs new file mode 100644 index 0000000..fa471c8 --- /dev/null +++ b/crates/core/src/game/java/termux.rs @@ -0,0 +1,257 @@ +//! Termux 系统 JDK(`openjdk-` 包)的安装与定位。 +//! +//! 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--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 { + 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 { + 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 = 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` 时返回错误。 +/// +/// 返回 `(JDK 根, 是否复用了已装)`:包管理器刚装完与「本来就有」对调用方是两种结果 +/// (一个要报安装,一个要报「未走网络」)。 +pub async fn install(major: u32) -> Result<(PathBuf, bool), 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, true)); + } + + 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}"), + ) + })? + .map(|home| (home, false)) +} + +/// 逐镜像尝试 `apt-get`(阻塞,跑在 `spawn_blocking` 里)。 +fn run_install( + prefix: &Path, + workspace: &Path, + package: &str, + major: u32, +) -> Result { + let mut failures: Vec = Vec::new(); + for mirror in MIRRORS { + match apt_install(prefix, workspace, mirror, package) { + Ok(()) => { + if let Some(home) = installed_home(prefix, major) { + // 装完把隔离的 lists/cache/archives 清掉(几十到上百 MB,别留在 $PREFIX/tmp)。 + let _ = std::fs::remove_dir_all(workspace); + 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-`")) +} + +/// 对单个镜像跑 `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(); + } +} diff --git a/crates/core/src/game/java/vendor.rs b/crates/core/src/game/java/vendor.rs index 76dd270..31c9a4f 100644 --- a/crates/core/src/game/java/vendor.rs +++ b/crates/core/src/game/java/vendor.rs @@ -15,7 +15,10 @@ //! `zulu` / `api`(Temurin)四个模块里。 /// 设置键 `java.vendorPriority` 的允许值(顺序即默认序的覆盖来源)。 -pub const VENDOR_IDS: &[&str] = &["temurin", "oracle", "microsoft", "zulu"]; +/// +/// `termux` 只在 Termux 宿主上可用(系统包管理器安装),默认序里不出现; +/// 显式写进优先级或 `--vendor termux` 才走它。 +pub const VENDOR_IDS: &[&str] = &["temurin", "oracle", "microsoft", "zulu", "termux"]; /// 一家 JDK 发行版。 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -28,11 +31,19 @@ pub enum JdkVendor { Microsoft, /// Azul Zulu(第四备选,API 元数据 + CDN 直链)。 Zulu, + /// Termux 的系统 openjdk(`pkg install openjdk-`;只在 Termux 里可用)。 + Termux, } impl JdkVendor { /// 全部发行版(枚举顺序,不是优先级顺序)。 - pub const ALL: [Self; 4] = [Self::Temurin, Self::Oracle, Self::Microsoft, Self::Zulu]; + pub const ALL: [Self; 5] = [ + Self::Temurin, + Self::Oracle, + Self::Microsoft, + Self::Zulu, + Self::Termux, + ]; /// 稳定 id(目录名段、`--vendor` 取值、设置值、JSON 字段)。 #[must_use] @@ -42,6 +53,7 @@ impl JdkVendor { Self::Oracle => "oracle", Self::Microsoft => "microsoft", Self::Zulu => "zulu", + Self::Termux => "termux", } } @@ -53,6 +65,7 @@ impl JdkVendor { Self::Oracle => "Oracle OpenJDK", Self::Microsoft => "Microsoft OpenJDK", Self::Zulu => "Azul Zulu", + Self::Termux => "Termux OpenJDK", } } @@ -66,6 +79,7 @@ impl JdkVendor { Some(Self::Microsoft) } "zulu" | "azul" => Some(Self::Zulu), + "termux" | "termux-openjdk" | "pkg" => Some(Self::Termux), _ => None, } } @@ -80,6 +94,7 @@ impl JdkVendor { Self::Temurin | Self::Zulu => major >= 8, Self::Oracle => major >= 9, Self::Microsoft => matches!(major, 11 | 17 | 21 | 25), + Self::Termux => matches!(major, 17 | 21 | 25), } } @@ -91,6 +106,7 @@ impl JdkVendor { Self::Oracle => &[17, 21, 25], Self::Microsoft => &[11, 17, 21, 25], Self::Zulu => &[8, 11, 17, 21, 25], + Self::Termux => &[17, 21, 25], } } diff --git a/crates/core/src/game/library.rs b/crates/core/src/game/library.rs index 230c4b9..b6e128d 100644 --- a/crates/core/src/game/library.rs +++ b/crates/core/src/game/library.rs @@ -172,6 +172,11 @@ impl Library { } /// natives 分类器名:按 OS 查 `natives` 表并展开 `${arch}`(64 位为 `64`)。 + /// + /// **Linux arm64 特例**:Mojang 的 LWJGL3 条目只写 `natives-linux`(x86_64), + /// 但同一个 Maven 坐标有 `natives-linux-arm64`。原样用 `natives-linux` 会下到 x86_64 + /// 的 `.so`,在 aarch64 JVM 里加载失败;这里改成 arm64 分类器(下载地址的兜底见 + /// `install::libraries`)。 pub fn native_classifier(&self, env: &Environment) -> Option { let raw = self.natives.as_ref()?.get(&env.os)?; let arch = if matches!(env.arch.as_str(), "x86_64" | "arm64") { @@ -179,7 +184,33 @@ impl Library { } else { "" }; - Some(raw.replace("${arch}", arch)) + let classifier = raw.replace("${arch}", arch); + if env.os == "linux" + && env.arch == "arm64" + && classifier == "natives-linux" + && self.is_lwjgl3() + { + return Some("natives-linux-arm64".to_owned()); + } + Some(classifier) + } + + /// 是否是 LWJGL 3(`org.lwjgl:`;LWJGL 2 的 group 是 `org.lwjgl.lwjgl`)。 + fn is_lwjgl3(&self) -> bool { + self.name.starts_with("org.lwjgl:") + } + + /// 现代清单(1.19+)把 natives 作为独立库条目:Linux arm64 上把 + /// `org.lwjgl:::natives-linux` 改写成 `...:natives-linux-arm64`。 + /// + /// 返回 None 表示不适用(非 Linux/arm64、非 LWJGL3、或不是 natives 条目)。 + #[must_use] + pub fn linux_arm64_native_name(&self, env: &Environment) -> Option { + if env.os != "linux" || env.arch != "arm64" || !self.is_lwjgl3() { + return None; + } + let base = self.name.strip_suffix(":natives-linux")?; + Some(format!("{base}:natives-linux-arm64")) } /// natives 构件下载信息(按分类器查 `downloads.classifiers`)。 diff --git a/crates/core/src/launch/defaults.rs b/crates/core/src/launch/defaults.rs index ffcc157..b60f4cd 100644 --- a/crates/core/src/launch/defaults.rs +++ b/crates/core/src/launch/defaults.rs @@ -174,6 +174,13 @@ pub fn environment(req: &LaunchRequest, os: OsFamily) -> Vec<(String, String)> { set_env(&mut env, "MESA_LOADER_DRIVER_OVERRIDE", "llvmpipe"); set_env(&mut env, "GALLIUM_DRIVER", "llvmpipe"); } + // Termux + glibc 构建:termux-x11 下 glibc Mesa 的 zink 卡 DRI3,softpipe 是当前 + // 可用路径。放在软件渲染块之后、用户自定义之前(用户显式设置仍可覆盖)。 + if cfg!(target_os = "linux") + && crate::platform::termux::is_termux(&crate::platform::paths::ProcessEnv) + { + apply_termux_gl_defaults(&mut env); + } if os == OsFamily::MacOs { if let Some(icd) = &req.renderer.vulkan_icd { set_env(&mut env, "VK_ICD_FILENAMES", &icd.display().to_string()); @@ -201,3 +208,43 @@ fn set_env(env: &mut Vec<(String, String)>, key: &str, value: &str) { env.push((key.to_string(), value.to_string())); } } + +/// Termux(glibc 构建)上的默认 GL 环境:softpipe。 +/// +/// glibc Mesa 26 的 zink 在 termux-x11 下报 `DRI3 not available`,softpipe 是当前 +/// 可用的软件路径;硬件路径等 DRI3 问题解决后再切。 +fn apply_termux_gl_defaults(env: &mut Vec<(String, String)>) { + set_env(env, "MESA_LOADER_DRIVER_OVERRIDE", "swrast"); + set_env(env, "LIBGL_ALWAYS_SOFTWARE", "1"); +} + +#[cfg(test)] +mod tests { + use super::apply_termux_gl_defaults; + + #[test] + fn termux_gl_defaults_force_softpipe() { + let mut env = Vec::new(); + apply_termux_gl_defaults(&mut env); + assert!(env.contains(&( + "MESA_LOADER_DRIVER_OVERRIDE".to_string(), + "swrast".to_string() + ))); + assert!(env.contains(&("LIBGL_ALWAYS_SOFTWARE".to_string(), "1".to_string()))); + } + + #[test] + fn termux_gl_defaults_overwrite_earlier_llvmpipe_choice() { + let mut env = vec![( + "MESA_LOADER_DRIVER_OVERRIDE".to_string(), + "llvmpipe".to_string(), + )]; + apply_termux_gl_defaults(&mut env); + assert_eq!( + env.iter() + .find(|(key, _)| key == "MESA_LOADER_DRIVER_OVERRIDE") + .map(|(_, value)| value.as_str()), + Some("swrast") + ); + } +} diff --git a/crates/core/src/platform/mod.rs b/crates/core/src/platform/mod.rs index 5691acb..964d0a4 100644 --- a/crates/core/src/platform/mod.rs +++ b/crates/core/src/platform/mod.rs @@ -40,6 +40,7 @@ pub mod os_info; pub mod paths; pub mod receipt; pub mod redirect; +pub mod termux; pub mod theme_probe; pub mod vk_probe; pub mod wallpaper; diff --git a/crates/core/src/platform/termux.rs b/crates/core/src/platform/termux.rs new file mode 100644 index 0000000..71095ce --- /dev/null +++ b/crates/core/src/platform/termux.rs @@ -0,0 +1,82 @@ +//! Termux(Android 上的用户空间)宿主识别。 +//! +//! Termux 的软件前缀不在 `/usr`,而是 `$PREFIX`(典型值 +//! `/data/data/com.termux/files/usr`);JDK 由 `pkg install openjdk-17` 落到 +//! `$PREFIX/lib/jvm/java-17-openjdk`。这里只做**识别与路径推导**,不碰包管理, +//! 也不把 `$PREFIX` 当成编译期常量写死。 + +use std::path::PathBuf; + +use super::paths::EnvSource; + +/// Termux 的默认前缀(`PREFIX` 缺失时的兜底)。 +pub const DEFAULT_PREFIX: &str = "/data/data/com.termux/files/usr"; + +/// 当前环境是否运行在 Termux 里。 +/// +/// 判据取 Termux 自己导出的两个变量:`TERMUX_VERSION`(版本)或 `PREFIX` +/// 指向默认前缀。两者都没有时按普通 Linux 处理——不猜。 +#[must_use] +pub fn is_termux(env: &dyn EnvSource) -> bool { + env.var("TERMUX_VERSION").is_some() + || env + .var("PREFIX") + .is_some_and(|prefix| prefix.trim_end_matches('/') == DEFAULT_PREFIX) +} + +/// Termux 的软件前缀(仅当识别为 Termux 时)。 +#[must_use] +pub fn prefix(env: &dyn EnvSource) -> Option { + if !is_termux(env) { + return None; + } + Some( + env.var("PREFIX") + .map_or_else(|| PathBuf::from(DEFAULT_PREFIX), PathBuf::from), + ) +} + +/// Termux 的 JDK 安装根(`$PREFIX/lib/jvm`)。 +#[must_use] +pub fn jvm_root(env: &dyn EnvSource) -> Option { + prefix(env).map(|prefix| prefix.join("lib").join("jvm")) +} + +#[cfg(test)] +mod tests { + use super::{is_termux, jvm_root, prefix, DEFAULT_PREFIX}; + use crate::platform::paths::MapEnv; + + #[test] + fn plain_linux_is_not_termux() { + let env = MapEnv::new().with("HOME", "/home/u"); + assert!(!is_termux(&env)); + assert_eq!(prefix(&env), None); + assert_eq!(jvm_root(&env), None); + } + + #[test] + fn default_prefix_is_recognised() { + let env = MapEnv::new().with("PREFIX", DEFAULT_PREFIX); + assert!(is_termux(&env)); + assert_eq!( + jvm_root(&env).unwrap(), + std::path::PathBuf::from(DEFAULT_PREFIX).join("lib/jvm") + ); + } + + #[test] + fn version_variable_alone_is_enough() { + let env = MapEnv::new() + .with("TERMUX_VERSION", "0.118.3") + .with("PREFIX", "/data/data/com.termux/files/usr"); + assert!(is_termux(&env)); + } + + #[test] + fn other_prefix_is_not_termux() { + // `PREFIX` 被搬到别的目录不在识别范围内——只有版本变量或默认前缀算数。 + let env = MapEnv::new().with("PREFIX", "/opt/termux"); + assert!(!is_termux(&env)); + } +} diff --git a/crates/core/src/setting/keys.rs b/crates/core/src/setting/keys.rs index 5e31902..d04063d 100644 --- a/crates/core/src/setting/keys.rs +++ b/crates/core/src/setting/keys.rs @@ -39,7 +39,7 @@ pub const JAVA_INSTALL_ROOT_UNSET: &str = ""; /// /// 与 `crate::game::java::vendor::VENDOR_IDS` 必须是同一份集合——setting 层不依赖 game 层 /// (分层纪律),一致性由 `tests/setting/java_install_keys.rs` 的契约测试固定。 -pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu"]; +pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu", "termux"]; /// launcher.json 当前 schema 版本。 /// diff --git a/crates/core/tests/game/java_vendor_chain.rs b/crates/core/tests/game/java_vendor_chain.rs index 0647e22..db97db5 100644 --- a/crates/core/tests/game/java_vendor_chain.rs +++ b/crates/core/tests/game/java_vendor_chain.rs @@ -175,7 +175,10 @@ fn sha256_texts_are_parsed_the_same_way_for_every_upstream() { #[test] fn vendor_ids_are_the_setting_whitelist() { - assert_eq!(VENDOR_IDS, &["temurin", "oracle", "microsoft", "zulu"]); + assert_eq!( + VENDOR_IDS, + &["temurin", "oracle", "microsoft", "zulu", "termux"] + ); for id in VENDOR_IDS { assert_eq!(JdkVendor::parse(id).map(|vendor| vendor.id()), Some(*id)); } diff --git a/crates/presenter/src/host/java.rs b/crates/presenter/src/host/java.rs index 71229bb..691bd81 100644 --- a/crates/presenter/src/host/java.rs +++ b/crates/presenter/src/host/java.rs @@ -80,6 +80,10 @@ fn sources_of(table: &unlucky_core::download::MirrorTable, vendor: JdkVendor) -> .zulu() .map(|zulu| vec![format!("{}({})", zulu.api, zulu.measured_speed)]) .unwrap_or_default(), + JdkVendor::Termux => unlucky_core::game::java::termux::MIRRORS + .iter() + .map(|mirror| format!("{mirror}(系统包:pkg install openjdk-)")) + .collect(), } } diff --git a/crates/presenter/src/host/launch.rs b/crates/presenter/src/host/launch.rs index dc94f4f..8d65527 100644 --- a/crates/presenter/src/host/launch.rs +++ b/crates/presenter/src/host/launch.rs @@ -134,18 +134,38 @@ pub fn build_request( let mut libraries: Vec = Vec::new(); for library in resolved.libraries.as_deref().unwrap_or_default() { - if !library.applies(&environment) || library.native_classifier(&environment).is_some() { + if !library.applies(&environment) { continue; } - let Ok(coordinate) = library.coordinate() else { + // 1.19+ 的 natives 是独立库条目(name 带分类器),必须进 classpath; + // ≤1.18 的 `natives` 表条目归 natives::collect,不进 classpath。 + // Linux arm64:独立条目改写成 natives-linux-arm64(路径与展示名一起改)。 + let arm64 = library.linux_arm64_native_name(&environment); + if arm64.is_none() && library.native_classifier(&environment).is_some() { continue; + } + let (coordinate, name) = match arm64 { + Some(rewritten) => { + let Ok(coordinate) = + unlucky_core::game::library::MavenCoordinate::parse(&rewritten) + else { + continue; + }; + (coordinate, rewritten) + } + None => { + let Ok(coordinate) = library.coordinate() else { + continue; + }; + (coordinate, library.name.clone()) + } }; let path = if library.is_local() { env.game.version_library(&version_id, &coordinate) } else { env.game.library(&coordinate) }; - libraries.push(LibraryInput::new(path, library.name.clone())); + libraries.push(LibraryInput::new(path, name)); } let natives = unlucky_core::launch::natives::collect( &env.game, diff --git a/packaging/docs/compliance-support-boundaries.md b/packaging/docs/compliance-support-boundaries.md index 87535e3..4214261 100644 --- a/packaging/docs/compliance-support-boundaries.md +++ b/packaging/docs/compliance-support-boundaries.md @@ -28,7 +28,7 @@ | Linux arm64 X11 | ❌ 不构建 | 一等(未真机验证) | glibc 2.35+(`ubuntu-22.04-arm` 原生构建,**与 x64 同地板**)、xkbcommon、fontconfig、libGL、xcb | | Linux arm64 Wayland | ❌ 不构建 | 支持 | 上述 + wayland-client | | Windows 8.1/8/7 | — | 不支持 | 基线已上调(D21) | -| Android Termux + X11 | — | 试验级(不承诺) | 仅 CLI(源码自建),GUI 未验证,不在本发布矩阵内 | +| Android Termux + X11 | — | 试验级(不承诺) | CLI 与 GUI 均需源码自建;GUI 已在单机真机验证两条路径各一次(CPU 软渲染 + 自编 Mesa/turnip GPU 加速,配方见 `packaging/docs/termux-glibc-build.md`、证据见 `README.termux.md`),不在本发布矩阵内 | `packaging/release-notes.md` 的 "Support matrix" 表与上表逐行对应(英文版同样带 published / not published 一列,Termux 行标注 experimental)——改一边必须同步另一边,否则标题里的"一致"就是假话。 diff --git a/packaging/docs/termux-glibc-build.md b/packaging/docs/termux-glibc-build.md new file mode 100644 index 0000000..5860174 --- /dev/null +++ b/packaging/docs/termux-glibc-build.md @@ -0,0 +1,279 @@ +# Termux + termux-x11 上的 glibc 构建(试验级) + +本页记录**在 Termux 里构建 glibc 版 GUI,并在 termux-x11 里跑起来**的可复现步骤。 +2026-11-06 在一台 OnePlus 15(Android 17、Termux、KirinSU/root 环境)上实测通过: +`--software-renderer --screenshot` 出 1180×760 PNG,中文正常,`msa client_id=configured source=build`。 + +**定位**:试验级、不承诺。发布矩阵里没有面向 Termux 的资产;本文只描述源码自建路径。 +GUI 只在上述设备 + CPU 渲染(tiny-skia)上验证过,GPU 路径见 §5(尚未在 glibc 侧验证)。 + +## 1. 为什么不能直接 `cargo build` + +Termux 的 `rustc` host 是 `aarch64-linux-android`(`target_os = "android"`): + +- `crates/app-iced/Cargo.toml` 的 `x11` / `wayland` feature 只挂在 `cfg(target_os = "linux")` 下; +- iced 的窗口层 winit 0.30 在 android target 上走 Android(NativeActivity)后端, + 会拉 `android-activity` 并要求 `game-activity`/`native-activity` feature,普通进程编不过也跑不了。 + +所以要在 Termux 里产出一个 **`aarch64-unknown-linux-gnu`(glibc)** 二进制,靠 Termux 的 +[glibc 运行时](https://packages.termux.dev/apt/termux-glibc/) 原生执行。 + +## 2. 工具链(一次性) + +以下路径用变量表示,实际可放在任意本地目录(示例放在仓库内的 `.gnu/`,已 gitignore): + +```sh +PREFIX="${PREFIX:-/data/data/com.termux/files/usr}" +G="$PWD/.gnu" +``` + +### 2.1 glibc 运行时 + +确认 `$PREFIX/glibc/lib/ld-linux-aarch64.so.1` 存在(`pkg install glibc-runner` 或装过 glibc 仓库 +的包即可)。**不需要 `gcc-glibc`**:链接用 Termux 自带的 clang + lld,glibc 的 CRT/头文件由 +`glibc` 包提供。 + +### 2.2 Rust gnu 工具链 + +下载 Rust 1.99.1(或与 `rust-version` 兼容的 stable)的 `rustc` / `rust-std` / `cargo`, +以及跑门禁需要的 `clippy` / `rustfmt` 五个 `aarch64-unknown-linux-gnu` 组件。官方源在国内很慢, +用 USTC 镜像: + +```sh +mkdir -p "$TMPDIR/ull-gnu" && cd "$TMPDIR/ull-gnu" +for c in rustc rust-std cargo clippy rustfmt; do + curl -fLO "https://mirrors.ustc.edu.cn/rust-static/dist/$c-1.99.1-aarch64-unknown-linux-gnu.tar.xz" +done +``` + +解包后按组件布局合并到 `$G/toolchain`(`rustc/bin`、`cargo/bin` → `toolchain/bin`, +两者的 `lib/` → `toolchain/lib`,`rust-std-*/lib/rustlib/aarch64-unknown-linux-gnu` → +`toolchain/lib/rustlib/`)。 + +### 2.3 改 interpreter / rpath + +工具链里的 `rustc`、`cargo`、`rustdoc` 是 glibc ELF,默认 interpreter 是 `/lib/ld-linux-*.so.1`, +在 Termux 上不存在: + +```sh +LD="$PREFIX/glibc/lib/ld-linux-aarch64.so.1" +for f in rustc cargo rustdoc clippy-driver cargo-clippy rustfmt cargo-fmt; do + patchelf --set-interpreter "$LD" --set-rpath "$PREFIX/glibc/lib:$G/toolchain/lib" \ + "$G/toolchain/bin/$f" +done +``` + +> **坑(实测)**:`cargo clippy` 会在 PATH 里找 `cargo-clippy`。如果只装了 Termux 的 Rust, +> 找到的是 android 版 `cargo-clippy`,`aws-lc-sys` 等构建脚本会被当成 +> `--target=aarch64-linux-android` 编译并失败。把 gnu 工具链的 `bin` 放在 PATH 最前面即可; +> `rustc`/`rustfmt` 同理。 + +### 2.4 gcc 运行时片段 + +`clang -fuse-ld=lld` 仍然需要 `crtbeginS.o` / `crtendS.o` / `libgcc.a`。从 Debian 的 +`libgcc-14-dev`(arm64)取即可(只解包,不安装): + +```sh +curl -fLO https://deb.debian.org/debian/pool/main/g/gcc-14/libgcc-14-dev_14.2.0-19_arm64.deb +dpkg-deb -x libgcc-14-dev_14.2.0-19_arm64.deb "$G/gnu-support" +# crt/libgcc 在 $G/gnu-support/usr/lib/gcc/aarch64-linux-gnu/14/ +``` + +### 2.5 linker 驱动 + +写一个 `$G/bin/cc-glibc`(`chmod +x`): + +```sh +#!/data/data/com.termux/files/usr/bin/sh +unset LD_PRELOAD +P=$PREFIX +S="<仓库>/.gnu/gnu-support/usr/lib/gcc/aarch64-linux-gnu/14" +C="clang --target=aarch64-unknown-linux-gnu --sysroot=/ -isystem $P/glibc/include \ + -B$P/glibc/lib -B$S -L$S -L$P/glibc/lib -fuse-ld=lld" +for a in "$@"; do + case "$a" in -c|-E|-S) exec $C "$@" ;; esac +done +exec $C -Wl,--dynamic-linker=$P/glibc/lib/ld-linux-aarch64.so.1 -Wl,-rpath,$P/glibc/lib "$@" +``` + +要点:`--sysroot=/` 而不是 `--sysroot=$PREFIX/glibc`——glibc 的 `libc.so` 是含绝对路径的 +linker script,带 sysroot 会被二次拼接;`-B $P/glibc/lib` 让 clang 找到 `Scrt1.o`/`crti.o`/`crtn.o`。 + +## 3. 构建 + +```sh +unset LD_PRELOAD # 必须:Termux 的 libtermux-exec 会被 glibc loader 当成坏 ELF +G="$PWD/.gnu" +export PATH="$G/toolchain/bin:$PATH" +export CARGO_TARGET_DIR="$G/target" +export RUSTFLAGS="-C linker=$G/bin/cc-glibc" +export CC="$G/bin/cc-glibc" CXX="$G/bin/cc-glibc" AR="$PREFIX/glibc/bin/ar" +export CARGO_HTTP_CAINFO="$PREFIX/etc/tls/cert.pem" # glibc 版 cargo 不认 Android 的 CA 路径 +export ULL_MSA_CLIENT_ID= # 可选;烘微软登录 id,值不进 git + +# crates 走 USTC 稀疏索引(官方源在部分网络下只有 ~20 KB/s) +cargo --config 'source.crates-io.replace-with="ustc"' \ + --config 'source.ustc.registry="sparse+https://mirrors.ustc.edu.cn/crates.io-index/"' \ + build --release -p unlucky-app-iced --no-default-features +``` + +`--no-default-features` 关掉 `gpu`,只编 tiny-skia(CPU)。产物:`.gnu/target/release/unlucky-launcher-iced`。 + +门禁(与 CI 同一组命令,环境同上): + +```sh +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace --all-targets --no-fail-fast +``` + +Termux 上有三条**环境差异**失败(不是回归):`launch` 测试读 +`/proc//task//children`(Android 内核没有)、natives 事件的长路径被脱敏成 +``、`settings_bridge` 的 mtime 热重载偶发(flaky)。PC 的 Linux CI 不受影响。 + +## 4. 运行(termux-x11) + +```sh +# 一次性:glibc 版 X 运行库(cf 源慢时可把 sources.list 里的 packages-cf 换成 packages.termux.dev) +pkg install libx11-glibc libxkbcommon-glibc fontconfig-glibc \ + libxcursor-glibc libxrandr-glibc libxi-glibc libxext-glibc \ + libxcb-glibc libwayland-glibc + +termux-x11 :0 -ac & # 配合已安装的 termux-x11 App 使用 +DISPLAY=:0 .gnu/target/release/unlucky-launcher-iced --software-renderer +``` + +无头取证(CI 同款通道): + +```sh +DISPLAY=:0 .gnu/target/release/unlucky-launcher-iced \ + --software-renderer --screenshot /tmp/ull-termux.png --data-dir /tmp/ull-termux-data +``` + +- 中文:GUI 在 Termux 上会把 `/system/fonts/NotoSansCJK-Regular.ttc` 注册进 cosmic-text + (fontdb 不扫 Android 的字体目录),无需额外环境变量。 +- 启动日志里 `[auth] msa client_id=configured source=build` 表示构建期 id 已烘入。 +- **横屏优先**:Termux 上默认窗口尺寸会按当前 X 屏幕等比自适应(日志 + `[window] ... source=termux-screen`),横屏铺满、竖屏也不溢出;验收尺寸 1180×760 仍是 + 默认基准,`ULL_WINDOW_SIZE=1520x900` 这类覆盖仍然优先。 + +### 4.1 Termux 上的 Java + +`ull java install ` 在 Termux 上**默认先用系统包**(`openjdk-17/21/25`): + +- 内置 SAU / TUNA / USTC / 官方四个 termux-main 镜像,按顺序尝试; +- `apt-get` 的 lists/cache 隔离在 `$PREFIX/tmp/ull-java-termux/`,不改用户的 apt 配置; + 装完自动清理该目录(一次安装约 100 MB 临时占用); +- 装完从 `$PREFIX/lib/jvm/java--openjdk` 发现,`ull java list` 直接可见; +- `--vendor termux` 只走系统包;`temurin|oracle|microsoft|zulu` 仍走归档下载链。 +- 已实测:`ull java install 17` 从 SAU 镜像装完 `openjdk-17`(含 `openjdk-17-x`), + 约 20 秒;`ull java list` 随后列出 `[system]` 的 17。 + +### 4.2 跑 MC 需要的运行时(已手动验证) + +bionic 的 Termux JVM 加载不了 MC 的 glibc natives,所以 MC 要用 **glibc JVM**: + +```sh +# Temurin 21 JRE(linux-aarch64),用 glibc loader 直接跑 +curl -L -o jre21.tar.gz \ + https://api.adoptium.net/v3/binary/latest/21/ga/linux/aarch64/jre/hotspot/normal/eclipse +tar -xzf jre21.tar.gz -C "$G/jre" +JRE="$G/jre/jdk-21.0.13+9-jre" +$PREFIX/glibc/lib/ld-linux-aarch64.so.1 \ + --library-path "$JRE/lib:$JRE/lib/server:$PREFIX/glibc/lib" "$JRE/bin/java" -version +``` + +- AWT/X11:`DISPLAY=:0` 下 Swing 窗口正常(`headless=false`、`swing-ok`)。 +- GL:需要 §5 的 Mesa 26 GL 构建(`-Dgallium-drivers=zink,softpipe -Dglx=dri`)。 + `zink` 目前卡在 `DRI3 not available`;**softpipe 可用**: + `MESA_LOADER_DRIVER_OVERRIDE=swrast LIBGL_ALWAYS_SOFTWARE=1`。 +- 端到端探针:LWJGL 3.3.3 arm64 natives + GLFW + GLX → `GL_VERSION=3.3 (Compatibility + Profile) Mesa 26.2.6`、`GL_RENDERER=softpipe`、`gl-ok`。 + +> **启动器已接线**(本分支): +> - Linux/aarch64 上 LWJGL3 的 natives 自动改写成 `natives-linux-arm64` +> (Mojang 的 `libraries.minecraft.net` 对 arm64 一律 404,下载兜底到阿里云 Maven 镜像); +> - Termux glibc 构建启动 MC 时,子进程默认带 `MESA_LOADER_DRIVER_OVERRIDE=swrast` +> 与 `LIBGL_ALWAYS_SOFTWARE=1`(用户自定义环境变量仍可覆盖)。 +> +> **还差一步**:把实例的 Java 指到 glibc JRE。Temurin 解包后 `bin/java` 默认的 +> interpreter 在 Android 上不存在,先 patchelf 一次即可直接执行: +> +> ```sh +> patchelf --set-interpreter "$PREFIX/glibc/lib/ld-linux-aarch64.so.1" \ +> --set-rpath "$PREFIX/glibc/lib:$JRE/lib:$JRE/lib/server" "$JRE/bin/java" +> "$JRE/bin/java" -version +> ``` +> +> 之后在实例设置里把 Java 指向 `$JRE/bin/java`(或 `--java`)即可。 +> +> **真机验证(1.20.4)**:`ull install 1.20.4 --name mc-test`(740 MB)成功后, +> `ull launch mc-test --java "$JRE/bin/java" --account Tester`, +> MC 主菜单在 termux-x11 中渲染(窗口 854×480;日志在实例目录 +> `versions/1.20.4/logs/latest.log`)。非致命项:narrator 的 flite 缺失、 +> LWJGL JNI 版本告警、oshi 的 cpuVendor NPE(都未阻断启动)。 + +## 5. GPU(已打通:自编 Mesa 26.2.6 + KGSL) + +termux-x11 的 X 客户端可以走硬件;glibc 侧的问题在**驱动**,不在 loader: + +1. `vulkan-icd-loader-glibc` + ICD JSON 本身正常。 +2. Termux 软件源的 glibc Mesa 24.2.6 不认识 Adreno 840 + (`chip_id = 44050A31 is unsupported`)。 +3. Arch 的 `vulkan-freedreno 26.2.6` 能加载,但它是 Linux/DRM 构建,缺 KGSL + (`/dev/dri` 对 `untrusted_app` 被 SELinux 拒绝)。 + +**解法:给 glibc 自编一份带 KGSL 的 Mesa 26.2.6。** 本机实测通过: + +```sh +# 1) 源码 + 依赖 +# mesa-26.2.6.tar.xz(archive.mesa3d.org) +# Debian libstdc++-14-dev 的头($G/gnu-support/usr/include/c++/14) +# meson / ninja(pip 装 meson) +# 2) 把源码里 *.py 的 shebang 从 #!/usr/bin/env python3 改成 Termux python3 +# (Android 没有 /usr/bin/env) +# 3) meson setup(cc-glibc 包装器:-c 不带链接参数、C++ 加 -stdlib=libstdc++、 +# 共享库链接加 -lstdc++;详见 §3) +meson setup build3 --native-file="$G/mesa-native.ini" --wrap-mode=nofallback \ + -Dbuildtype=release -Dstrip=true -Dplatforms=x11 \ + -Dvulkan-drivers=freedreno -Dfreedreno-kmds=kgsl \ + -Dgallium-drivers=zink,softpipe -Dglx=dri -Dopengl=true -Degl=enabled \ + -Dgles1=disabled -Dgles2=enabled -Dllvm=disabled -Dgbm=disabled \ + -Dvideo-codecs= -Dzstd=enabled -Dbuild-tests=false +ninja -C build3 +``` + +`--wrap-mode=nofallback` 是为了跳过 libarchive 子项目(它只被 freedreno 的 decode 工具需要, +且它自带的 `id_t` 兜底宏会和 glibc 头冲突)。 + +安装与启用: + +```sh +cp build3/src/freedreno/vulkan/libvulkan_freedreno.so \ + "$PREFIX/glibc/lib/libvulkan_freedreno_ull.so" +cat > "$PREFIX/glibc/share/vulkan/icd.d/ull_freedreno.json" < ICED_BACKEND=wgpu +[gpu] Selected: AdapterInfo { name: "Adreno (TM) 840", driver: "turnip Mesa driver", + driver_info: "Mesa 26.2.6", backend: Vulkan } +[shot] 1657x1067 renderer=wgpu,首帧 61 ms +``` + +没装驱动/没有 GPU 时仍会打印具名错误并自动降级 tiny-skia(同一进程不能重建事件循环, +降级走 respawn),所以 GPU 变体在纯 CPU 环境也能跑。 + +## 6. 已知边界 + +- 没有面向 Termux 的发布资产,也不在 CI 矩阵里;每次都要源码自建。 +- 只在 CPU 渲染 + 单台设备(Adreno 840 / Android 17)上验证过。 +- **装好 JDK ≠ 能跑 MC**:MC 的 LWJGL natives 是 glibc `.so`,bionic 的 Termux JVM 加载不了; + 这条与「启动器 GUI 能显示」是两件事,未在本文覆盖。 +- `ULL_MSA_CLIENT_ID` 属于仓库 Secret,构建时从环境/Secret 注入,**不写进任何 git 文件**。 diff --git a/packaging/release-notes.md b/packaging/release-notes.md index 3a3949b..26263af 100644 --- a/packaging/release-notes.md +++ b/packaging/release-notes.md @@ -59,7 +59,7 @@ Published = shipped as a download of **this** release, listed in `SHA256SUMS.txt | Linux arm64, X11 | not built (unverified) | first class (unverified) | glibc 2.35+, `libxkbcommon`, `fontconfig`, `libGL`, `libxcb` | | Linux arm64, Wayland | not built (unverified) | supported (unverified) | the above plus `wayland-client` | | Windows 8.1/8/7 | not built | unsupported | the baseline is Windows 10 | -| Android — Termux + X11 | not built (no CI slot) | experimental, not promised | CLI only and only when built from source; GUI unverified | +| Android — Termux + X11 | not built (no CI slot) | experimental, not promised | CLI and GUI from source only; GUI verified on-device twice: CPU software rendering and self-built Mesa/turnip GPU acceleration (see `packaging/docs/termux-glibc-build.md` and `README.termux.md`) | `windows-x64` is the only published slot this release; Windows ARM64 is kept out until its build is green (see Downloads above). Both arm64 rows are formal matrix slots, not the experimental Termux path, and