15 Commits
Author SHA1 Message Date
AskaEth e987e02755 docs(termux): 根目录新增适配方案与合并指南
TERMUX-ADAPTATION.md:方案(平台识别/JDK/字体/横屏/GPU/MC)、逐文件改动表、
兼容性说明(Termux gate、x86_64 不变)、合并步骤与 CI 预期、验证矩阵、
后续路线与按组回滚策略。README.termux 顶部链过去。
2026-11-07 09:05:37 +08:00
AskaEth 38d302428c docs(termux): README 校正——GPU 默认、横屏截图尺寸、数据根说明
- 构建描述从 CPU-only 改为 GPU 构建(wgpu + CPU 兜底)
- 窗口证据改为横屏 1657x1067、首帧 ~60 ms
- 快速开始不再强制 --software-renderer:默认 auto,驱动缺失自动降级
- 补数据根说明(Portable 按 exe 目录,--data-dir 暂忽略)
2026-11-07 09:04:58 +08:00
AskaEth 7d8b3b99e6 docs(termux): MC 1.20.4 真机跑通结论与剩余项
- README:MC 标 ✅(glibc JRE + arm64 natives + softpipe);后续收敛为
  zink/DRI3、剪贴板/IME、narrator/音频
- 构建文档 §4.2 补真机启动命令、窗口尺寸与日志位置、非致命项
2026-11-07 08:54:12 +08:00
AskaEth 5bd566f92f feat(termux): launch 侧同样改写 arm64 natives;真机跑通 MC 1.20.4
- Library::linux_arm64_native_name 收敛改写规则,install 与 launch 共用
- presenter/CLI 的 classpath 组装:1.19+ 的独立 natives 条目按 arm64 坐标
  找文件并进 classpath;≤1.18 的 natives 表条目仍归 natives::collect
- 真机验证:ull install 1.20.4(740MB)→ glibc Temurin 21 + arm64 natives +
  softpipe GL,MC 主菜单在 termux-x11 中渲染(窗口 854x480)
- 已知非致命:narrator flite 缺失、LWJGL JNI 版本告警、oshi cpuVendor NPE
2026-11-07 08:53:24 +08:00
AskaEth aa963345bd fix(termux): 现代清单的 LWJGL natives 条目也改写为 arm64
1.20.x 把 natives 作为独立库条目(org.lwjgl:lwjgl:3.3.2:natives-linux),
不走 natives 表。Linux/aarch64 上按坐标改写成 natives-linux-arm64,
URL 兜底到阿里云 Maven(Mojang 对 arm64 一律 404)。
真机 1.20.4 的 dry-run:7 个 natives 全部变成 arm64;x86_64 不变。
2026-11-07 08:45:16 +08:00
AskaEth 147280d12a feat(termux): MC 启动接线——Linux arm64 natives + softpipe 子进程环境
- Library::native_classifier:Linux/aarch64 上把 LWJGL3 的 natives-linux
  改写成 natives-linux-arm64(Mojang 清单只有 x86_64)
- install::libraries:arm64 分类器在清单里没有下载信息时,按 Maven 相对路径
  兜底到阿里云镜像(实测 Mojang libraries 404、Aliyun 200);
  单测覆盖 arm64 兜底与 x86_64 不变
- launch::defaults:Termux glibc 构建启动 MC 时默认带
  MESA_LOADER_DRIVER_OVERRIDE=swrast / LIBGL_ALWAYS_SOFTWARE=1(用户可覆盖)
- 文档:JRE patchelf 一次性步骤与接线现状;README 同步
- 验证:patched Temurin java 直接执行;LWJGL arm64+GLFW 探针 gl-ok
2026-11-07 08:39:25 +08:00
AskaEth 29e49dd7a4 docs(termux): 补 MC 运行时结论(glibc JRE + LWJGL arm64 + softpipe GL)
- §4.2:Temurin 21 JRE 在 glibc 下跑通;AWT/X11 正常;
  LWJGL 3.3.3 arm64 + GLFW + GLX/softpipe 端到端探针通过(GL 3.3)
- zink 仍卡 DRI3;softpipe 可用(软件 GL)
- README 同步:MC 标 🟡(栈已验证,启动器接线待做),后续改为接线 + DRI3
2026-11-07 08:32:33 +08:00
AskaEth 64fe489a04 feat(termux): glibc 构建自动启用自编 Mesa 26 turnip ICD(GPU 打通)
- app/boot/termux_gpu:glibc 构建 + Termux 且随附 ICD 存在时,在探测前设
  VK_ICD_FILENAMES/VK_DRIVER_FILES;用户显式设置不覆盖
- 文档:Mesa 26.2.6 + KGSL 的完整构建/安装/验证步骤(含 shebang、
  --wrap-mode=nofallback、C++/链接参数三处坑)
- 实测:wgpu 选中 Adreno 840(turnip Mesa 26.2.6),截图首帧 61 ms
- README.termux:GPU 标 ✅,MC 标 🟡(glibc JRE+AWT 已验证,GLX 待 GL 构建)
2026-11-07 08:28:36 +08:00
AskaEth aaba339499 docs(termux): GPU 结论落地——glibc 侧被驱动版本挡住
实测结论:
- glibc Vulkan loader + 自写 ICD 能加载
- Termux glibc Mesa 24.2.6 不认识 Adreno 840(chip 0x44050A31 unsupported)
- Arch vulkan-freedreno 26.2.6 能加载但 Linux/DRM 构建找不到设备(缺 KGSL,
  /dev/dri 对 untrusted_app 被 SELinux 拒绝)
- GPU 变体会打印具名错误并自动降级 tiny-skia

README.termux 同步:GPU 标 ⛔、MC 走 glibc JVM 的结论与后续路线
2026-11-07 07:57:24 +08:00
AskaEth 7cbb354972 feat(termux): 横屏自适应窗口、安装后清理 apt 工作区、补分支 README
- app/boot/termux_window:Termux 上默认窗口按 X 屏幕等比自适应
  (横屏铺满、竖屏不溢出,最大 2×;日志 source=termux-screen),
  ULL_WINDOW_SIZE 覆盖与 1180×760 验收基准不变;5 条单测
- termux::install 成功后清理 $PREFIX/tmp/ull-java-termux(一次安装约 100 MB)
- 构建文档补 clippy/rustfmt 组件、cargo-clippy 目标被带成 android 的坑、
  门禁命令、横屏行为与 openjdk-17 实测
- 新增 README.termux.md(分支定位/已验证矩阵/快速开始/边界/路线图),
  两份主 README 的 Termux 条目链过去
2026-11-07 07:50:33 +08:00
AskaEth 38f4682dba fix(java): Termux 幂等安装报「未走网络」,vendors 在 Termux 上展示 termux 行
- termux::install 返回 (home, reused),复用时 downloaded=None,
  CLI 不再显示「0 字节 / 0 个文件」
- java vendors 在 Termux 上把 termux 插到顺序最前(仅展示,默认回退序不变)
2026-11-06 23:12:10 +08:00
AskaEth c9efdf0fd1 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-06 22:54:53 +08:00
AskaEth 16c61ca8b7 docs(packaging): 记录 Termux glibc 构建与 termux-x11 运行配方
新增 packaging/docs/termux-glibc-build.md:glibc 工具链(Rust gnu 组件 +
patchelf + clang/lld + Debian libgcc crt)、构建环境(LD_PRELOAD/CA/USTC 镜像)、
termux-x11 运行与截图取证、GPU 现状与已知边界。
支持矩阵四处同步:Termux 行从「GUI 未验证」改为「源码自建、CPU 渲染单机验证过」,
仍标注实验级、不在发布矩阵内。
2026-11-06 22:32:09 +08:00
AskaEth 76ddc9b3e9 fix(fonts): Termux 上注册 Android 系统 CJK 字体,消除中文方框
glibc 构建跑在 Termux 里时 target_os 仍是 linux,fontdb 只扫 Linux 常规
字体目录,/system/fonts 不在其中,中文实例名与界面文案全渲染成方框。
在字体求值入口按运行时识别 Termux,把 NotoSansCJK 等候选注册进
cosmic-text 的字体库(OnceLock 只加载一次,找不到就静默放过)。
app-iced 直接依赖 unlucky-core 复用平台识别,不再本地复制环境判断。
2026-11-06 22:31:11 +08:00
AskaEth 898e052c2d feat(platform): 识别 Termux 宿主并把 $PREFIX/lib/jvm 纳入 JDK 扫描
- 新增 platform::termux:用 TERMUX_VERSION / 默认 PREFIX 识别 Termux,
  推导 $PREFIX 与 $PREFIX/lib/jvm,不把路径写死进调用方
- java::discover 的 standard_roots 在 Unix 分支追加 Termux 的 JVM 根,
  环境可注入(standard_roots_in),补上「Termux 装了 openjdk 但没设
  JAVA_HOME/PATH 就发现不了」的盲点
- 单测覆盖识别、负例与扫描结果
2026-11-06 22:31:03 +08:00
31 changed files with 1511 additions and 37 deletions

No files matched your search

Generated
+1
View File
@@ -5003,6 +5003,7 @@ dependencies = [
"image", "image",
"log", "log",
"unlucky-cli", "unlucky-cli",
"unlucky-core",
"unlucky-design", "unlucky-design",
"unlucky-i18n", "unlucky-i18n",
"unlucky-presenter", "unlucky-presenter",
+3 -2
View File
@@ -193,8 +193,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. report lists every conflict so nothing is overwritten silently.
- **Install progress is stage-level**, not byte-level, on the content-library install path. - **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. - **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 - **Android/Termux is experimental and not promised**: no released asset targets it. A GUI build is
unverified there. possible from source via the glibc toolchain (`packaging/docs/termux-glibc-build.md`, branch notes:
[`README.termux.md`](README.termux.md)); it was verified once on CPU rendering only.
## Download and run ## Download and run
+59
View File
@@ -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 但不阻断)
+1 -1
View File
@@ -157,7 +157,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 渲染下验证过一次。
## 下载与运行 ## 下载与运行
+132
View File
@@ -0,0 +1,132 @@
# Termux 适配:方案、改动清单与合并指南
> 分支:`termux-glibc`(基于 `origin/dev`,14 个提交,HEAD `38d3024`)
> 目的:让主线快速了解这组代码改了什么、为什么、怎么验证、如何合并。
## 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`(分支基于 `7d8b3b9` 之后的 dev;
dev 若前进,先 rebase 再推)。
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/<pid>/task/<pid>/children`(Android 内核没有)
- 同测试:长路径被脱敏成 `<opaque>`
- `presenter settings_bridge`:mtime 热重载偶发(flaky,单跑 3 次 2 过 1 挂)
4. **不要提交**:`dist/`、`.gnu/`、任何 `ULL_MSA_CLIENT_ID` 值(仓库 Secret)。
5. 合并后建议真机再跑一次:`--software-renderer` 截图 + `ull java install 17` + MC 启动。
## 验证矩阵(本机)
| 项 | 结果 |
| --- | --- |
| 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 路径。
+3
View File
@@ -24,6 +24,9 @@ path = "src/lib.rs"
# 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。 # 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。
unlucky-design = { path = "../design", version = "0.2.1" } unlucky-design = { path = "../design", version = "0.2.1" }
# 平台识别(Termux / $PREFIX 路径)唯一真源:字体注册这类平台适配不再各写一份环境判断。
unlucky-core = { path = "../core", version = "0.2.1" }
# M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**—— # M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**——
# 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。 # 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。
# 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。 # 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。
+16 -2
View File
@@ -30,6 +30,8 @@ use super::{MIN_SIZE, WINDOW_SIZE};
mod diagnostics; mod diagnostics;
mod fallback; mod fallback;
mod termux_gpu;
mod termux_window;
use fallback::{on_failure, respawn, OnFailure}; use fallback::{on_failure, respawn, OnFailure};
@@ -39,6 +41,8 @@ pub(crate) fn run(options: Options) -> iced::Result {
let boot_started = Instant::now(); let boot_started = Instant::now();
// ① 探测(判据取在哪、日志在 `report_boot` 里统一打) // ① 探测(判据取在哪、日志在 `report_boot` 里统一打)
// Termux + glibc 构建:装了我们自编的 turnip ICD 就先生效,auto 也能选到 GPU。
termux_gpu::enable();
let boot = boot_probe(&options); let boot = boot_probe(&options);
let decision = boot.decision; let decision = boot.decision;
diagnostics::report_boot(&options, &boot.probe, &decision); diagnostics::report_boot(&options, &boot.probe, &decision);
@@ -131,10 +135,20 @@ fn launch(
// M98:`ULL_WINDOW_SIZE=1520x900` 覆盖尺寸(矩阵的「最小 / 宽屏」两档靠它,且必须走**真外壳** // M98:`ULL_WINDOW_SIZE=1520x900` 覆盖尺寸(矩阵的「最小 / 宽屏」两档靠它,且必须走**真外壳**
// ——`pages_demo` example 的标题栏是留白带,出不了「三键位置」那张图)。 // ——`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), (WINDOW_SIZE.width, WINDOW_SIZE.height),
(MIN_SIZE.width, MIN_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); let settings = diagnostics::window_settings(size);
// 两行日志与开窗用的是**同一个** settings(见模块文档纪律 2) // 两行日志与开窗用的是**同一个** settings(见模块文档纪律 2)
diagnostics::report_window(&settings, options.scale_factor, size_source); diagnostics::report_window(&settings, options.scale_factor, size_source);
@@ -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"
)
);
}
}
@@ -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);
}
}
+35
View File
@@ -139,8 +139,43 @@ pub fn resolve() -> Resolved {
resolve_with(None) 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 的第一级)。 /// 同 [`resolve`],但把配置里 `ui.language` 的显式值喂给中文策略(D28 的第一级)。
pub fn resolve_with(setting: Option<&str>) -> Resolved { pub fn resolve_with(setting: Option<&str>) -> Resolved {
register_termux_fonts();
let platform = Platform::current(); let platform = Platform::current();
let faces = system_faces(); let faces = system_faces();
+6 -4
View File
@@ -34,8 +34,8 @@ pub const fn general() -> &'static str {
config set <key> <value> 写配置(走 setting schema 校验) config set <key> <value> 写配置(走 setting schema 校验)
java list 列出可用 Java(含每个 JDK 的实际落点) java list 列出可用 Java(含每个 JDK 的实际落点)
java install <major> [--vendor X] 安装托管 JDK(缺省按地区回退链选发行版; java install <major> [--vendor X] 安装托管 JDK(缺省按地区回退链选发行版;
--vendor temurin|oracle|microsoft|zulu 钉死一家) --vendor temurin|oracle|microsoft|zulu|termux 钉死一家)
java vendors 列出四家发行版的镜像/支持版本/当前优先级 java vendors 列出各发行版的镜像/支持版本/当前优先级
agent serve [--stdio] [--port N] 起对外 MCP server(默认 127.0.0.1 的 Streamable HTTP; agent serve [--stdio] [--port N] 起对外 MCP server(默认 127.0.0.1 的 Streamable HTTP;
[--allow-write] --stdio 给 llama.cpp 内置 client 用;默认只读) [--allow-write] --stdio 给 llama.cpp 内置 client 用;默认只读)
agent config [--port N] 打印 Claude Code / Cursor 的配置样例 agent config [--port N] 打印 Claude Code / Cursor 的配置样例
@@ -157,7 +157,7 @@ authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CL
%prog% java list 探测 JAVA_HOME / PATH / 托管目录 / 实例覆盖里的 Java %prog% java list 探测 JAVA_HOME / PATH / 托管目录 / 实例覆盖里的 Java
并打印每个 JDK 的实际落点(managed 项带目录) 并打印每个 JDK 的实际落点(managed 项带目录)
%prog% java install <major> [--vendor X] 安装托管 JDK(D13/D23):回退链查版本 → 下载 → 解压 → 验证 %prog% java install <major> [--vendor X] 安装托管 JDK(D13/D23):回退链查版本 → 下载 → 解压 → 验证
%prog% java vendors 列出四家发行版的镜像/支持版本/当前优先级顺序 %prog% java vendors 列出各发行版的镜像/支持版本/当前优先级顺序
落点(D23 三模式,随 core.data_mode 与设置键 java.installRoot 走): 落点(D23 三模式,随 core.data_mode 与设置键 java.installRoot 走):
portable(默认) <数据根>/.ULL/data/java-managed/jdk-<major>-<vendor>/ portable(默认) <数据根>/.ULL/data/java-managed/jdk-<major>-<vendor>/
@@ -168,7 +168,9 @@ authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CL
发行版回退链(D23):国内 temurin(TUNA) → oracle(华为云→南大) → microsoft(aka.ms) 发行版回退链(D23):国内 temurin(TUNA) → oracle(华为云→南大) → microsoft(aka.ms)
→ zulu;国外 temurin → microsoft → oracle → zulu。设置 java.vendorPriority 可改顺序 → zulu;国外 temurin → microsoft → oracle → zulu。设置 java.vendorPriority 可改顺序
(只写几家也行,其余按默认序补齐);--vendor 钉死一家、不自动降级。 (只写几家也行,其余按默认序补齐);--vendor 钉死一家、不自动降级。
每家失败的原因(版本不存在 / 镜像全 4xx / 校验失败)进 --json 的 chain 块与人读的「降级:」行。", Termux 上缺省先用系统包(pkg install openjdk-<major>,支持 17/21/25),失败再走归档链;
--vendor termux 只走系统包。每家失败的原因(版本不存在 / 镜像全 4xx / 校验失败)进
--json 的 chain 块与人读的「降级:」行。",
), ),
"agent" => Some( "agent" => Some(
"\ "\
+10 -2
View File
@@ -8,7 +8,7 @@
use serde_json::{json, Value}; use serde_json::{json, Value};
use unlucky_core::download::MirrorTable; 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 super::java::{region_profile, JavaSettings};
use crate::context::Ctx; use crate::context::Ctx;
@@ -23,7 +23,11 @@ use crate::output::Report;
pub fn vendors(ctx: &Ctx) -> Result<Report, CliError> { pub fn vendors(ctx: &Ctx) -> Result<Report, CliError> {
let settings = JavaSettings::load(ctx); let settings = JavaSettings::load(ctx);
let domestic = region_profile(ctx).is_domestic(); 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 table = MirrorTable::builtin();
let rows: Vec<Value> = order let rows: Vec<Value> = order
.iter() .iter()
@@ -114,6 +118,10 @@ fn vendor_json(table: &MirrorTable, item: JdkVendor, index: usize) -> Value {
.zulu() .zulu()
.map(|zulu| vec![format!("{}({})", zulu.api, zulu.measured_speed)]) .map(|zulu| vec![format!("{}({})", zulu.api, zulu.measured_speed)])
.unwrap_or_default(), .unwrap_or_default(),
JdkVendor::Termux => termux::MIRRORS
.iter()
.map(|mirror| format!("{mirror}(系统包:pkg install openjdk-<major>)"))
.collect(),
}; };
json!({ json!({
"rank": index + 1, "rank": index + 1,
+22 -2
View File
@@ -75,18 +75,38 @@ pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result<Plan, CliError> {
let mut libraries: Vec<LibraryInput> = Vec::new(); let mut libraries: Vec<LibraryInput> = Vec::new();
for library in resolved.libraries.as_deref().unwrap_or_default() { 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; continue;
} }
// 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 { let Ok(coordinate) = library.coordinate() else {
continue; continue;
}; };
(coordinate, library.name.clone())
}
};
let path = if library.is_local() { let path = if library.is_local() {
game.version_library(&version_id, &coordinate) game.version_library(&version_id, &coordinate)
} else { } else {
game.library(&coordinate) game.library(&coordinate)
}; };
libraries.push(LibraryInput::new(path, library.name.clone())); libraries.push(LibraryInput::new(path, name));
} }
// natives 分类器构件(≤1.18.2 才有):收集口径归 core,CLI 只搬运。 // natives 分类器构件(≤1.18.2 才有):收集口径归 core,CLI 只搬运。
+163 -12
View File
@@ -39,6 +39,20 @@ pub fn plan(
if !library.applies(environment) { if !library.applies(environment) {
continue; 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() { if library.coordinate().is_err() {
warnings.push(format!("库坐标不合法,已跳过:{}", library.name)); warnings.push(format!("库坐标不合法,已跳过:{}", library.name));
continue; continue;
@@ -79,31 +93,42 @@ pub fn plan(
let Some(classifier) = library.native_classifier(environment) else { let Some(classifier) = library.native_classifier(environment) else {
continue; 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 { let Ok(native_relative) = library.native_relative_path(&classifier) else {
warnings.push(format!("{} 的 natives 路径推导失败,已跳过", library.name)); warnings.push(format!("{} 的 natives 路径推导失败,已跳过", library.name));
continue; continue;
}; };
if !artifact.has_url() { 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!( warnings.push(format!(
"{} 的 natives 构件 {classifier} 没有下载地址,已跳过", "{} 的 natives 构件 {classifier} 没有下载地址,已跳过",
library.name library.name
)); ));
continue; continue;
} }
None => {
warnings.push(format!(
"{} 的 natives 分类器 {classifier} 没有下载信息,已跳过",
library.name
));
continue;
}
};
files.push(PlanFile { files.push(PlanFile {
role: FileRole::Native, role: FileRole::Native,
name: format!("{}:{classifier}", library.name), name: format!("{}:{classifier}", library.name),
url: artifact.url.clone().unwrap_or_default(), url,
sha1: artifact.sha1.clone(), sha1,
size: artifact.size, size,
dest: layout.libraries_dir().join(native_relative), dest,
resource: ResourceKind::Library, resource: ResourceKind::Library,
}); });
} }
@@ -125,6 +150,39 @@ fn library_dest(
} }
} }
/// 现代清单里 LWJGL3 natives 条目的改写结果。
struct Arm64Native {
name: String,
relative: PathBuf,
url: String,
}
/// Linux arm64:把 `org.lwjgl:<module>:<version>:natives-linux` 改写成 arm64。
fn linux_arm64_native_entry(library: &Library, env: &Environment) -> Option<Arm64Native> {
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,
})
}
/// 按库声明(或默认)的 Maven 基址拼下载地址。 /// 按库声明(或默认)的 Maven 基址拼下载地址。
fn maven_url(library: &Library, relative: &Path) -> String { fn maven_url(library: &Library, relative: &Path) -> String {
let base = library let base = library
@@ -141,6 +199,22 @@ fn maven_url(library: &Library, relative: &Path) -> String {
} }
} }
/// 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";
/// arm64 natives 的兜底 URL(按相对路径拼镜像基址)。
fn arm64_native_url(relative: &Path) -> String {
format!(
"{LINUX_ARM64_NATIVES_BASE}{}",
relative.to_string_lossy().replace('\\', "/")
)
}
/// 供上层判错用:库展开不产生硬失败,缺地址一律降级成告警。 /// 供上层判错用:库展开不产生硬失败,缺地址一律降级成告警。
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@@ -255,4 +329,81 @@ mod tests {
.dest .dest
.ends_with("versions/inst/libraries/net/minecraftforge/forge/1.20.1-47.2.0.jar")); .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:?}"
);
}
} }
+53 -1
View File
@@ -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 写的,新版发行版通常没有→容错跳过) | //! | 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/*` | //! | Linux | `/usr/lib/jvm/*`、`/usr/java/*` |
//! | Termux | `$PREFIX/lib/jvm/*`(`java-17-openjdk` 这类;`$PREFIX` 由 [`crate::platform::termux`] 识别) |
//! | macOS | `/Library/Java/JavaVirtualMachines/*.jdk/Contents/Home` | //! | macOS | `/Library/Java/JavaVirtualMachines/*.jdk/Contents/Home` |
//! //!
//! 三个纪律: //! 三个纪律:
@@ -22,6 +23,9 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use crate::platform::paths::{EnvSource, ProcessEnv};
use crate::platform::termux;
use super::extract::java_binary_name; use super::extract::java_binary_name;
use super::probe::{self, discover_managed_roots, system_candidates, JavaOrigin, JavaRuntime}; use super::probe::{self, discover_managed_roots, system_candidates, JavaOrigin, JavaRuntime};
@@ -73,6 +77,12 @@ pub fn scan_java_binaries() -> Vec<PathBuf> {
/// 候选 JDK 根目录(`bin/java` 的父目录的父目录)。 /// 候选 JDK 根目录(`bin/java` 的父目录的父目录)。
#[must_use] #[must_use]
pub fn standard_roots() -> Vec<PathBuf> { pub fn standard_roots() -> Vec<PathBuf> {
standard_roots_in(&ProcessEnv)
}
/// 同 [`standard_roots`],但环境变量可注入(测试 / 便携化封装用)。
#[must_use]
pub fn standard_roots_in(env: &dyn EnvSource) -> Vec<PathBuf> {
let mut roots = Vec::new(); let mut roots = Vec::new();
if cfg!(windows) { if cfg!(windows) {
for variable in ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"] { for variable in ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"] {
@@ -99,6 +109,10 @@ pub fn standard_roots() -> Vec<PathBuf> {
for base in ["/usr/lib/jvm", "/usr/java"] { for base in ["/usr/lib/jvm", "/usr/java"] {
roots.extend(child_dirs(Path::new(base))); 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.sort();
roots.dedup(); roots.dedup();
@@ -211,8 +225,9 @@ pub fn path_key(path: &Path) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{dedupe, parse_java_home, path_key, standard_roots}; use super::{dedupe, parse_java_home, path_key, standard_roots, standard_roots_in};
use crate::game::java::probe::{JavaOrigin, JavaRuntime}; use crate::game::java::probe::{JavaOrigin, JavaRuntime};
use crate::platform::paths::MapEnv;
fn runtime(path: &str, origin: JavaOrigin) -> JavaRuntime { fn runtime(path: &str, origin: JavaOrigin) -> JavaRuntime {
JavaRuntime { JavaRuntime {
@@ -323,4 +338,41 @@ mod tests {
let roots = standard_roots(); let roots = standard_roots();
assert!(roots.iter().all(|root| !root.as_os_str().is_empty())); 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();
}
} }
+79 -2
View File
@@ -23,7 +23,7 @@ use super::error::{io_error, JavaError, JavaErrorKind};
use super::extract; use super::extract;
use super::location::{JavaInstallRoot, LocationMode}; use super::location::{JavaInstallRoot, LocationMode};
use super::probe::{self, JavaOrigin, JavaRuntime}; use super::probe::{self, JavaOrigin, JavaRuntime};
use super::release::JdkRelease; use super::release::{JdkArchive, JdkRelease};
use super::source::{self, ChainLog}; use super::source::{self, ChainLog};
use super::target::AdoptiumTarget; use super::target::AdoptiumTarget;
use super::vendor::{self, JdkVendor}; use super::vendor::{self, JdkVendor};
@@ -184,7 +184,6 @@ pub async fn install(
location: &JavaInstallRoot, location: &JavaInstallRoot,
request: InstallRequest, request: InstallRequest,
) -> Result<InstalledJdk, JavaError> { ) -> Result<InstalledJdk, JavaError> {
let target = AdoptiumTarget::host()?;
let domestic = engine.region().is_domestic(); let domestic = engine.region().is_domestic();
let priority = request.order(domestic); let priority = request.order(domestic);
let mut chain = ChainLog::new(); let mut chain = ChainLog::new();
@@ -192,6 +191,75 @@ pub async fn install(
// 清掉上次中断留下的 .staging-* 与空壳(只碰我们自己的形状,见 location 模块)。 // 清掉上次中断留下的 .staging-* 与空壳(只碰我们自己的形状,见 location 模块)。
let _ = super::location::cleanup_orphans(&location.root); let _ = super::location::cleanup_orphans(&location.root);
// Termux:JDK 由系统包管理器装进 `$PREFIX`,不是可下载的归档。
// 默认(没钉发行版)时优先走它;`--vendor termux` 时只走它。
let want_termux = match request.vendor {
Some(JdkVendor::Termux) => true,
Some(_) => false,
None => super::termux::available(),
};
if want_termux {
if !super::termux::available() {
return Err(
JavaError::new(JavaErrorKind::Unsupported, "termux 源只在 Termux 里可用")
.with_hint("普通 Linux/Windows 用 --vendor temurin|oracle|microsoft|zulu"),
);
}
match super::termux::install(request.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(|| request.major.to_string());
let release = JdkRelease {
vendor: JdkVendor::Termux,
major: request.major,
version,
semver: None,
release_name: format!("Termux openjdk-{}", request.major),
archive: JdkArchive {
name: format!("openjdk-{}.deb", request.major),
url: String::new(),
sha256: String::new(),
size: 0,
},
};
chain.hit(JdkVendor::Termux, format!("命中 {}", release.summary()));
return Ok(InstalledJdk {
major: release.major,
vendor: JdkVendor::Termux,
release,
root: home,
mode: location.mode,
archive: PathBuf::new(),
runtime,
downloaded: if reused {
None
} else {
Some("termux".to_string())
},
bytes: 0,
files: 0,
extracted: false,
chain,
});
}
Err(error) => {
if request.vendor == Some(JdkVendor::Termux) {
return Err(error);
}
chain.failed(JdkVendor::Termux, error.message().to_string());
reasons.push(format!("termux:{}", error.message()));
}
}
}
// 归档发行版需要 Adoptium 的平台参数;Termux/Android 上这里会 Unsupported,
// 不提前 `?` 退出——让每一家都记一条降级原因,错误里能看见全貌。
let target = AdoptiumTarget::host();
for candidate in &priority { for candidate in &priority {
let vendor = *candidate; let vendor = *candidate;
if !vendor.supports(request.major) { if !vendor.supports(request.major) {
@@ -205,6 +273,15 @@ pub async fn install(
reasons.push(format!("{}:{reason}", vendor.id())); reasons.push(format!("{}:{reason}", vendor.id()));
continue; 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 { let release = match source::fetch(engine, vendor, request.major, target).await {
Ok(release) => release, Ok(release) => release,
Err(error) => { Err(error) => {
+1
View File
@@ -54,6 +54,7 @@ pub mod release;
pub mod select; pub mod select;
pub mod source; pub mod source;
pub mod target; pub mod target;
pub mod termux;
pub mod vendor; pub mod vendor;
pub mod zulu; pub mod zulu;
+5
View File
@@ -56,6 +56,11 @@ pub async fn fetch(
})?; })?;
zulu::fetch_latest(client, table, major, target).await 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 <major>`(默认走 termux 源)")),
} }
} }
+257
View File
@@ -0,0 +1,257 @@
//! 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` 时返回错误。
///
/// 返回 `(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<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) {
// 装完把隔离的 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-<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();
}
}
+18 -2
View File
@@ -15,7 +15,10 @@
//! `zulu` / `api`(Temurin)四个模块里。 //! `zulu` / `api`(Temurin)四个模块里。
/// 设置键 `java.vendorPriority` 的允许值(顺序即默认序的覆盖来源)。 /// 设置键 `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 发行版。 /// 一家 JDK 发行版。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
@@ -28,11 +31,19 @@ pub enum JdkVendor {
Microsoft, Microsoft,
/// Azul Zulu(第四备选,API 元数据 + CDN 直链)。 /// Azul Zulu(第四备选,API 元数据 + CDN 直链)。
Zulu, Zulu,
/// Termux 的系统 openjdk(`pkg install openjdk-<major>`;只在 Termux 里可用)。
Termux,
} }
impl JdkVendor { 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 字段)。 /// 稳定 id(目录名段、`--vendor` 取值、设置值、JSON 字段)。
#[must_use] #[must_use]
@@ -42,6 +53,7 @@ impl JdkVendor {
Self::Oracle => "oracle", Self::Oracle => "oracle",
Self::Microsoft => "microsoft", Self::Microsoft => "microsoft",
Self::Zulu => "zulu", Self::Zulu => "zulu",
Self::Termux => "termux",
} }
} }
@@ -53,6 +65,7 @@ impl JdkVendor {
Self::Oracle => "Oracle OpenJDK", Self::Oracle => "Oracle OpenJDK",
Self::Microsoft => "Microsoft OpenJDK", Self::Microsoft => "Microsoft OpenJDK",
Self::Zulu => "Azul Zulu", Self::Zulu => "Azul Zulu",
Self::Termux => "Termux OpenJDK",
} }
} }
@@ -66,6 +79,7 @@ impl JdkVendor {
Some(Self::Microsoft) Some(Self::Microsoft)
} }
"zulu" | "azul" => Some(Self::Zulu), "zulu" | "azul" => Some(Self::Zulu),
"termux" | "termux-openjdk" | "pkg" => Some(Self::Termux),
_ => None, _ => None,
} }
} }
@@ -80,6 +94,7 @@ impl JdkVendor {
Self::Temurin | Self::Zulu => major >= 8, Self::Temurin | Self::Zulu => major >= 8,
Self::Oracle => major >= 9, Self::Oracle => major >= 9,
Self::Microsoft => matches!(major, 11 | 17 | 21 | 25), 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::Oracle => &[17, 21, 25],
Self::Microsoft => &[11, 17, 21, 25], Self::Microsoft => &[11, 17, 21, 25],
Self::Zulu => &[8, 11, 17, 21, 25], Self::Zulu => &[8, 11, 17, 21, 25],
Self::Termux => &[17, 21, 25],
} }
} }
+32 -1
View File
@@ -172,6 +172,11 @@ impl Library {
} }
/// natives 分类器名:按 OS 查 `natives` 表并展开 `${arch}`(64 位为 `64`)。 /// 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<String> { pub fn native_classifier(&self, env: &Environment) -> Option<String> {
let raw = self.natives.as_ref()?.get(&env.os)?; let raw = self.natives.as_ref()?.get(&env.os)?;
let arch = if matches!(env.arch.as_str(), "x86_64" | "arm64") { let arch = if matches!(env.arch.as_str(), "x86_64" | "arm64") {
@@ -179,7 +184,33 @@ impl Library {
} else { } 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:<module>`;LWJGL 2 的 group 是 `org.lwjgl.lwjgl`)。
fn is_lwjgl3(&self) -> bool {
self.name.starts_with("org.lwjgl:")
}
/// 现代清单(1.19+)把 natives 作为独立库条目:Linux arm64 上把
/// `org.lwjgl:<module>:<ver>:natives-linux` 改写成 `...:natives-linux-arm64`。
///
/// 返回 None 表示不适用(非 Linux/arm64、非 LWJGL3、或不是 natives 条目)。
#[must_use]
pub fn linux_arm64_native_name(&self, env: &Environment) -> Option<String> {
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`)。 /// natives 构件下载信息(按分类器查 `downloads.classifiers`)。
+47
View File
@@ -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, "MESA_LOADER_DRIVER_OVERRIDE", "llvmpipe");
set_env(&mut env, "GALLIUM_DRIVER", "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 os == OsFamily::MacOs {
if let Some(icd) = &req.renderer.vulkan_icd { if let Some(icd) = &req.renderer.vulkan_icd {
set_env(&mut env, "VK_ICD_FILENAMES", &icd.display().to_string()); 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())); 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")
);
}
}
+1
View File
@@ -40,6 +40,7 @@ pub mod os_info;
pub mod paths; pub mod paths;
pub mod receipt; pub mod receipt;
pub mod redirect; pub mod redirect;
pub mod termux;
pub mod theme_probe; pub mod theme_probe;
pub mod vk_probe; pub mod vk_probe;
pub mod wallpaper; pub mod wallpaper;
+82
View File
@@ -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<PathBuf> {
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<PathBuf> {
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));
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ pub const JAVA_INSTALL_ROOT_UNSET: &str = "";
/// ///
/// 与 `crate::game::java::vendor::VENDOR_IDS` 必须是同一份集合——setting 层不依赖 game 层 /// 与 `crate::game::java::vendor::VENDOR_IDS` 必须是同一份集合——setting 层不依赖 game 层
/// (分层纪律),一致性由 `tests/setting/java_install_keys.rs` 的契约测试固定。 /// (分层纪律),一致性由 `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 版本。 /// launcher.json 当前 schema 版本。
/// ///
+4 -1
View File
@@ -175,7 +175,10 @@ fn sha256_texts_are_parsed_the_same_way_for_every_upstream() {
#[test] #[test]
fn vendor_ids_are_the_setting_whitelist() { 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 { for id in VENDOR_IDS {
assert_eq!(JdkVendor::parse(id).map(|vendor| vendor.id()), Some(*id)); assert_eq!(JdkVendor::parse(id).map(|vendor| vendor.id()), Some(*id));
} }
+4
View File
@@ -80,6 +80,10 @@ fn sources_of(table: &unlucky_core::download::MirrorTable, vendor: JdkVendor) ->
.zulu() .zulu()
.map(|zulu| vec![format!("{}({})", zulu.api, zulu.measured_speed)]) .map(|zulu| vec![format!("{}({})", zulu.api, zulu.measured_speed)])
.unwrap_or_default(), .unwrap_or_default(),
JdkVendor::Termux => unlucky_core::game::java::termux::MIRRORS
.iter()
.map(|mirror| format!("{mirror}(系统包:pkg install openjdk-<major>)"))
.collect(),
} }
} }
+22 -2
View File
@@ -134,18 +134,38 @@ pub fn build_request(
let mut libraries: Vec<LibraryInput> = Vec::new(); let mut libraries: Vec<LibraryInput> = Vec::new();
for library in resolved.libraries.as_deref().unwrap_or_default() { 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; continue;
} }
// 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 { let Ok(coordinate) = library.coordinate() else {
continue; continue;
}; };
(coordinate, library.name.clone())
}
};
let path = if library.is_local() { let path = if library.is_local() {
env.game.version_library(&version_id, &coordinate) env.game.version_library(&version_id, &coordinate)
} else { } else {
env.game.library(&coordinate) 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( let natives = unlucky_core::launch::natives::collect(
&env.game, &env.game,
@@ -28,7 +28,7 @@
| Linux arm64 X11 | ❌ 不构建 | 一等(未真机验证) | glibc 2.35+(`ubuntu-22.04-arm` 原生构建,**与 x64 同地板**)、xkbcommon、fontconfig、libGL、xcb | | Linux arm64 X11 | ❌ 不构建 | 一等(未真机验证) | glibc 2.35+(`ubuntu-22.04-arm` 原生构建,**与 x64 同地板**)、xkbcommon、fontconfig、libGL、xcb |
| Linux arm64 Wayland | ❌ 不构建 | 支持 | 上述 + wayland-client | | Linux arm64 Wayland | ❌ 不构建 | 支持 | 上述 + wayland-client |
| Windows 8.1/8/7 | — | 不支持 | 基线已上调(D21) | | Windows 8.1/8/7 | — | 不支持 | 基线已上调(D21) |
| Android Termux + X11 | — | 试验级(不承诺) | 仅 CLI(源码自建),GUI 未验证,不在本发布矩阵内 | | Android Termux + X11 | — | 试验级(不承诺) | CLI 与 GUI 均需源码自建;GUI 已在单机 CPU 渲染下验证(`packaging/docs/termux-glibc-build.md`),不在本发布矩阵内 |
`packaging/release-notes.md` 的 "Support matrix" 表与上表逐行对应(英文版同样带 published / not `packaging/release-notes.md` 的 "Support matrix" 表与上表逐行对应(英文版同样带 published / not
published 一列,Termux 行标注 experimental)——改一边必须同步另一边,否则标题里的"一致"就是假话。 published 一列,Termux 行标注 experimental)——改一边必须同步另一边,否则标题里的"一致"就是假话。
+279
View File
@@ -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> # 可选;烘微软登录 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/<pid>/task/<pid>/children`(Android 内核没有)、natives 事件的长路径被脱敏成
`<opaque>`、`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 <major>` 在 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-<major>-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" <<EOF
{"ICD":{"api_version":"1.4.335","library_path":"$PREFIX/glibc/lib/libvulkan_freedreno_ull.so"},"file_format_version":"1.0.0"}
EOF
```
应用启动时(glibc 构建 + Termux + 该 JSON 存在)会自动把它设进
`VK_ICD_FILENAMES` / `VK_DRIVER_FILES`(`app/boot/termux_gpu.rs`);用户显式设了这两个
变量时不覆盖。实测日志:
```text
[probe] adapters=1 [Adreno (TM) 840 [IntegratedGpu]] hardware=true :: request=auto -> 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 文件**。
+1 -1
View File
@@ -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, 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` | | 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 | | 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 once on CPU rendering (see `packaging/docs/termux-glibc-build.md`) |
`windows-x64` is the only published slot this release; Windows ARM64 is kept out until its build is green `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 (see Downloads above). Both arm64 rows are formal matrix slots, not the experimental Termux path, and