From 44f6205f89f9980472cfe8d437782df557085162 Mon Sep 17 00:00:00 2001 From: zkh <1650697374@qq.com> Date: Wed, 14 Oct 2026 14:46:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9D=E5=A7=8B=E5=8C=96=20jpackage-?= =?UTF-8?q?maven-plugin=20=E9=80=9A=E7=94=A8=E5=8E=9F=E7=94=9F=E6=89=93?= =?UTF-8?q?=E5=8C=85=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于 OpenJDK 16+ 官方 jpackage 工具,将任意标准 Java 应用打包为内嵌最小运行时的 跨平台自包含应用镜像(app-image 绿色免安装版),不绑定任何应用框架。 核心能力: - 零配置:自动从主 JAR 的 META-INF/MANIFEST.MF 探测 Main-Class - 环境定位:优先 ${java.home}/bin/jpackage,兼容 JRE 布局、JAVA_HOME 与 PATH - 输入目录隔离:使用独立的 target/jpackage-input,避免编译产物被误打包 - 版本清洗:1.0.0-SNAPSHOT 等 Maven 版本号自动转为 jpackage 合法版本 - 编码防御:默认注入全套 UTF-8 运行参数,规避跨平台中文乱码 - 平台自适应:--win-console 仅在 Windows 目标平台传递 - 启动脚本:生成 run.sh(755)与 CRLF 换行的 run.bat(含 chcp 65001) - Wine 交叉打包:支持在 Linux 上借助 Wine 产出 Windows 应用镜像 交付内容: - 插件工程与面向 Maven Central 的完整 pom 配置 - 核心 Mojo 与 10 个 internal 工具类 - 103 个单元测试用例 - examples/hello-cli 可运行示例与中文 README Generated with AI Co-Authored-By: zkh --- .gitignore | 18 + README.md | 460 ++++++++++ examples/hello-cli/pom.xml | 97 ++ .../main/java/com/example/hello/HelloApp.java | 42 + examples/hello-cli/src/main/resources/app.png | Bin 0 -> 82 bytes pom.xml | 344 +++++++ .../maven/jpackage/PackageAppMojo.java | 863 ++++++++++++++++++ .../jpackage/internal/FilePermissions.java | 78 ++ .../jpackage/internal/FileTreeUtils.java | 146 +++ .../internal/JarManifestInspector.java | 114 +++ .../jpackage/internal/JdkToolLocator.java | 204 +++++ .../jpackage/internal/JpackageExecutor.java | 109 +++ .../jpackage/internal/JpackageOptions.java | 390 ++++++++ .../internal/LauncherScriptGenerator.java | 140 +++ .../maven/jpackage/internal/Platform.java | 86 ++ .../jpackage/internal/VersionSanitizer.java | 102 +++ .../maven/jpackage/internal/WineSupport.java | 172 ++++ .../maven/jpackage/PackageAppMojoTest.java | 571 ++++++++++++ .../jpackage/internal/FileTreeUtilsTest.java | 183 ++++ .../internal/JarManifestInspectorTest.java | 137 +++ .../jpackage/internal/JdkToolLocatorTest.java | 131 +++ .../internal/JpackageOptionsTest.java | 229 +++++ .../internal/LauncherScriptGeneratorTest.java | 130 +++ .../maven/jpackage/internal/PlatformTest.java | 69 ++ .../internal/VersionSanitizerTest.java | 74 ++ .../jpackage/internal/WineSupportTest.java | 166 ++++ 26 files changed, 5055 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 examples/hello-cli/pom.xml create mode 100644 examples/hello-cli/src/main/java/com/example/hello/HelloApp.java create mode 100644 examples/hello-cli/src/main/resources/app.png create mode 100644 pom.xml create mode 100644 src/main/java/com/mirenqinggege/maven/jpackage/PackageAppMojo.java create mode 100644 src/main/java/com/mirenqinggege/maven/jpackage/internal/FilePermissions.java create mode 100644 src/main/java/com/mirenqinggege/maven/jpackage/internal/FileTreeUtils.java create mode 100644 src/main/java/com/mirenqinggege/maven/jpackage/internal/JarManifestInspector.java create mode 100644 src/main/java/com/mirenqinggege/maven/jpackage/internal/JdkToolLocator.java create mode 100644 src/main/java/com/mirenqinggege/maven/jpackage/internal/JpackageExecutor.java create mode 100644 src/main/java/com/mirenqinggege/maven/jpackage/internal/JpackageOptions.java create mode 100644 src/main/java/com/mirenqinggege/maven/jpackage/internal/LauncherScriptGenerator.java create mode 100644 src/main/java/com/mirenqinggege/maven/jpackage/internal/Platform.java create mode 100644 src/main/java/com/mirenqinggege/maven/jpackage/internal/VersionSanitizer.java create mode 100644 src/main/java/com/mirenqinggege/maven/jpackage/internal/WineSupport.java create mode 100644 src/test/java/com/mirenqinggege/maven/jpackage/PackageAppMojoTest.java create mode 100644 src/test/java/com/mirenqinggege/maven/jpackage/internal/FileTreeUtilsTest.java create mode 100644 src/test/java/com/mirenqinggege/maven/jpackage/internal/JarManifestInspectorTest.java create mode 100644 src/test/java/com/mirenqinggege/maven/jpackage/internal/JdkToolLocatorTest.java create mode 100644 src/test/java/com/mirenqinggege/maven/jpackage/internal/JpackageOptionsTest.java create mode 100644 src/test/java/com/mirenqinggege/maven/jpackage/internal/LauncherScriptGeneratorTest.java create mode 100644 src/test/java/com/mirenqinggege/maven/jpackage/internal/PlatformTest.java create mode 100644 src/test/java/com/mirenqinggege/maven/jpackage/internal/VersionSanitizerTest.java create mode 100644 src/test/java/com/mirenqinggege/maven/jpackage/internal/WineSupportTest.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4d43fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +target/ +*.class +*.jar +*.log + +# IDE +.idea/ +*.iml +*.ipr +*.iws +.vscode/ +.settings/ +.project +.classpath + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..a706bb2 --- /dev/null +++ b/README.md @@ -0,0 +1,460 @@ +# jpackage-maven-plugin + +> 通用原生打包 Maven 插件:基于 OpenJDK 16+ 官方 `jpackage` 工具,把**任意标准 Java 应用**打包成 +> 内嵌最小运行时的**跨平台自包含应用镜像(App Image / 绿色免安装版)**。 +> 目标机器无需安装 Java、无需配置环境变量,解压即用。 + +- **不绑定任何框架**:不依赖 Spring Boot / JavaFX / Quarkus,只遵循纯 Java 与 Maven 标准; +- **智能开箱即用**:自动从主 JAR 的 `META-INF/MANIFEST.MF` 探测 `Main-Class`,绝大多数项目 + 只需引入插件即可打包; +- **跨平台自适应**:自动识别 Windows / Linux / macOS,按平台组装参数并生成对应的启动脚本。 + +--- + +## 目录 + +1. [环境要求](#环境要求) +2. [快速开始(零配置)](#快速开始零配置) +3. [输出结构](#输出结构) +4. [POM 配置示例](#pom-配置示例) +5. [完整参数说明](#完整参数说明) +6. [平台行为差异](#平台行为差异) +7. [在 Linux 上交叉打包 Windows 应用(Wine)](#在-linux-上交叉打包-windows-应用wine) +8. [常见问题](#常见问题) +9. [插件工程自身构建](#插件工程自身构建) + +--- + +## 环境要求 + +| 项目 | 要求 | +| --- | --- | +| 运行 Maven 的 JDK | **JDK 16 及以上**(`jpackage` 自 JDK 14 引入,16 起稳定;建议 17/21 LTS) | +| Maven | 3.6.3 及以上 | +| 打包目标平台 | 必须与构建主机平台一致(除非使用 [Wine 交叉打包](#在-linux-上交叉打包-windows-应用wine)) | + +`jpackage` 只能产出**当前所在平台**的可执行程序,这是 JDK 官方工具的限制: + +- 在 Linux 上打包 → 得到 Linux 应用镜像; +- 在 Windows 上打包 → 得到 Windows 应用镜像; +- 在 macOS 上打包 → 得到 macOS 应用镜像。 + +需要多平台产物时,请在对应平台的 CI Runner 上分别构建。 + +## 快速开始(零配置) + +只要项目能打出带 `Main-Class` 的 JAR,引入插件即可: + +```xml + + + + com.mirenqinggege.maven + jpackage-maven-plugin + 1.0.0 + + + native-package + + package-app + + + + + + +``` + +执行: + +```bash +mvn clean package +``` + +插件会自动完成:定位 `jpackage` → 建立纯净输入目录 → 探测 `Main-Class` → 清洗版本号 → +组装命令行 → 调用 `jpackage` → 生成启动脚本 → 清理临时目录。 + +> 如果没有 `Main-Class` 且未配置 ``,插件会打印告警并交由 `jpackage` +> 自行从清单读取;此时若构建失败,请显式配置 ``。 + +## 输出结构 + +默认产物位于 `target/dist//`: + +``` +target/dist/hello-cli/ +├── bin/ +│ └── hello-cli # jpackage 生成的平台可执行文件 +├── lib/ +│ ├── runtime/ # 内嵌的最小 JRE(jlink 裁剪) +│ ├── app/ +│ │ ├── hello-cli.jar # 主 JAR 与全部运行时依赖 +│ │ └── hello-cli.cfg # jpackage 启动配置 +│ └── ... +├── run.sh # 插件生成的启动脚本(Linux / macOS,755 权限) +└── hello-cli.desktop # 插件生成的桌面入口(仅 Linux) +``` + +Windows 平台对应生成 `run.bat`。 + +整个 `` 目录即为绿色免安装版:压缩后拷贝到目标机器解压,直接执行 `run.sh` +(或 `run.bat`)即可运行。 + +## POM 配置示例 + +### 示例一:命令行工具(Linux 产物 + 自定义 JVM 参数) + +```xml + + com.mirenqinggege.maven + jpackage-maven-plugin + 1.0.0 + + + native-package + + package-app + + + + + MyTool + com.example.MyTool + Example Inc. + 我的命令行工具 + + -Xmx512m + -Duser.timezone=Asia/Shanghai + + + +``` + +### 示例二:Windows 桌面应用(带图标、保留控制台) + +```xml + + com.mirenqinggege.maven + jpackage-maven-plugin + 1.0.0 + + + native-package + + package-app + + + + + ${project.basedir}/src/main/resources/app.ico + true + + + +``` + +### 示例三:已打成 fat jar 的项目 + +```xml + + + false + + ${project.build.finalName}.jar + +``` + +### 示例四:作为独立 goal 手动执行 + +不绑定生命周期,按需触发: + +```bash +# 方式一:完整坐标(任何情况下都可用) +mvn com.mirenqinggege.maven:jpackage-maven-plugin:1.0.0:package-app + +# 方式二:使用 goal 前缀(需先在 ~/.m2/settings.xml 中登记插件组) +mvn jpackage:package-app +``` + +启用前缀需在 `settings.xml` 中加入: + +```xml + + com.mirenqinggege.maven + +``` + +### 示例五:CI 中按需跳过 + +```bash +mvn clean package -Djpackage.skip=true +``` + +完整可运行示例见 [`examples/hello-cli`](examples/hello-cli)。 + +## 完整参数说明 + +### 核心参数 + +| 参数 | 属性名 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `appName` | `jpackage.appName` | `${project.artifactId}` | 应用名称,决定目录名与可执行文件名 | +| `mainJar` | `jpackage.mainJar` | `${project.build.finalName}.jar` | 主 JAR 包文件名 | +| `mainClass` | `jpackage.mainClass` | 自动探测 | 主启动类;留空时从主 JAR 的 `MANIFEST.MF` 读取 `Main-Class` | +| `javaOptions` | — | 空 | `List`,追加的自定义 JVM 参数 | +| `icon` | `jpackage.icon` | 空 | 图标文件:`.ico`(Windows)/ `.png`(Linux)/ `.icns`(macOS) | +| `type` | `jpackage.type` | `app-image` | jpackage 打包类型;`app-image` 即绿色免安装版 | +| `destinationDirectory` | `jpackage.dest` | `${project.build.directory}/dist` | 产物输出目录 | +| `skip` | `jpackage.skip` | `false` | 跳过原生打包 | +| `verbose` | `jpackage.verbose` | `false` | 输出 jpackage 完整日志 | + +### 编码与平台参数 + +| 参数 | 属性名 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `injectUtf8Options` | `jpackage.injectUtf8Options` | `true` | 注入全套 UTF-8 运行参数(见下) | +| `winConsole` | `jpackage.winConsole` | `true` | **仅 Windows 目标平台**附加 `--win-console`;Linux/macOS 永不传递 | + +默认注入的 UTF-8 参数: + +``` +-Dfile.encoding=UTF-8 +-Dstdout.encoding=UTF-8 +-Dstderr.encoding=UTF-8 +-Dsun.stdout.encoding=UTF-8 +-Dsun.stderr.encoding=UTF-8 +``` + +用户通过 `javaOptions` 追加的同名参数排在默认值**之后**,因此可以覆盖默认值。 + +### 输入目录与依赖 + +| 参数 | 默认值 | 说明 | +| --- | --- | --- | +| `inputDirectory` | `${project.build.directory}/jpackage-input` | 纯净输入目录,插件会先清空再填充 | +| `includeDependencies` | `true` | 是否把 `compile`/`runtime`/`system` 作用域的 JAR 依赖一并拷贝 | +| `cleanInputDirectory` | `true` | 打包结束后删除临时输入目录 | +| `additionalArguments` | 空 | `List`,原样追加到 jpackage 命令行的参数 | + +> **输入目录隔离**:插件不会把 `target/` 根目录直接作为 `--input`,而是建立独立的 +> `jpackage-input/` 目录,只放入主 JAR 与依赖,避免 `classes/`、`maven-status/`、 +> 测试报告等编译产物被误打包进应用镜像。 + +### 启动脚本 + +| 参数 | 默认值 | 说明 | +| --- | --- | --- | +| `generateLauncherScript` | `true` | 生成 `run.sh` / `run.bat` | +| `generateDesktopEntry` | `true` | Linux 下额外生成 `.desktop` | + +### jpackage 定位与交叉打包 + +| 参数 | 属性名 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `jpackageExecutable` | `jpackage.jpackageExecutable` | 自动探测 | 显式指定 jpackage 路径(可以是可执行文件、其所在目录或 JDK 根目录) | +| `useWine` | `jpackage.useWine` | `false` | 在 Linux 上借助 Wine 调用 Windows 版 jpackage | +| `windowsJdkHome` | `jpackage.windowsJdkHome` | 空 | `useWine=true` 时必填,Windows 版 JDK 解压目录 | + +jpackage 的自动探测优先级: + +1. 用户配置的 `jpackageExecutable`(配置错误时**直接报错**,不会静默回退); +2. `${java.home}/bin/jpackage`(当前运行 Maven 的 JDK,最可靠,兼容 `/jre` 布局); +3. `$JAVA_HOME/bin/jpackage`; +4. 系统 `PATH` 上的 `jpackage`。 + +Windows 下自动查找 `jpackage.exe`。 + +## 平台行为差异 + +| 行为 | Windows | Linux | macOS | +| --- | --- | --- | --- | +| `jpackage` 文件名 | `jpackage.exe` | `jpackage` | `jpackage` | +| `--win-console` | 受 `winConsole` 控制 | **永不传递** | **永不传递** | +| 启动脚本 | `run.bat`(CRLF 换行) | `run.sh`(LF 换行) | `run.sh`(LF 换行) | +| 桌面入口 | — | `.desktop` | — | +| 可执行权限 | 不适用 | 自动设为 755 | 自动设为 755 | + +生成的 `run.sh`: + +```sh +#!/bin/sh +# 由 jpackage-maven-plugin 自动生成:切换到脚本所在目录后启动应用 +set -e +APP_HOME=$(cd "$(dirname "$0")" && pwd) +exec "$APP_HOME/bin/" "$@" +``` + +生成的 `run.bat`: + +```bat +@echo off +rem 由 jpackage-maven-plugin 自动生成:切换控制台代码页为 UTF-8 后启动应用 +chcp 65001 > nul +setlocal +"%~dp0.exe" %* +endlocal +``` + +`chcp 65001` 把 Windows 控制台代码页切换为 UTF-8,配合注入的 `-Dfile.encoding=UTF-8` 等 +参数,彻底解决中文日志乱码问题。 + +## 在 Linux 上交叉打包 Windows 应用(Wine) + +`jpackage` 无法跨平台产出,但可以在 Linux 上通过 Wine 运行 **Windows 版 OpenJDK** 的 +`jpackage.exe` 来生成 Windows 应用镜像。 + +前提: + +1. Linux 构建机已安装 Wine(如 `apt install wine64`); +2. 已下载并解压 Windows 版 OpenJDK(JDK 而非 JRE,建议版本与目标一致)。 + +配置: + +```xml + + true + /opt/jdk-21.0.9-windows-x64 + +``` + +插件会: + +1. 校验 `wine` 与 `/bin/jpackage.exe` 是否存在,缺失时快速失败并给出提示; +2. 通过 `winepath -w` 把宿主路径转换为 Windows 路径(失败时回退到 Wine 默认的 `Z:` 盘映射); +3. 以 `wine ...` 形式执行,并设置 `WINEDEBUG=-all` 抑制 Wine 噪声; +4. 按 Windows 目标平台组装参数(附加 `--win-console`)并生成 `run.bat`。 + +> Wine 交叉打包属于尽力而为的方案,产物的可用性取决于 Wine 版本与 JDK 版本组合。 +> 生产环境仍建议在真实 Windows Runner 上构建。 + +## 常见问题 + +**Q:报错 `Version [1.0.0-SNAPSHOT] is not valid`?** +A:插件已自动清洗版本号(`1.0.0-SNAPSHOT` → `1.0.0`)。若你通过 `additionalArguments` +手工传递了版本参数,请自行确保其为纯数字点分格式。 + +**Q:报错 `Invalid Option: [--win-console]`?** +A:`--win-console` 只能在 Windows 上使用。插件已在 Linux/macOS 目标平台自动屏蔽该参数; +如果你通过 `additionalArguments` 手工加入了它,请移除。 + +**Q:目标机器运行时报 `NoClassDefFoundError`?** +A:说明有依赖没被打进镜像。检查依赖作用域是否为 `provided` 或 `test`(这两者不会被拷贝), +或项目是否使用了 `shade`/`assembly` 打成 fat jar(此时应设置 +`false`)。 + +**Q:想在 Windows 上打包但 `mvn` 运行的 JDK 不是 16+?** +A:插件的 `jpackageExecutable` 允许指向另一个 JDK: +`C:\jdk-21\bin\jpackage.exe`。 + +**Q:构建速度慢?** +A:`jpackage` 需要对内嵌运行时做 jlink 裁剪并拷贝数百个文件,属正常现象。日常开发可用 +`-Djpackage.skip=true` 跳过。 + +**Q:中文输出乱码?** +A:确认不要通过 `javaOptions` 覆盖掉 `-Dfile.encoding=UTF-8`;Windows 上请使用插件生成的 +`run.bat`(其中含 `chcp 65001`)而非直接双击 `bin\.exe`。 + +## 发布到 Maven 中央仓库 + +本项目已按 Maven Central 的要求配置完毕:`name` / `description` / `url` / `licenses` / +`developers` / `scm` 齐全,`sources` 与 `javadoc` 构件随包附加,GPG 签名在 `release` +profile 中启用。 + +### 1. 前置准备 + +1. **注册命名空间**:登录 ,添加并验证命名空间 + `com.mirenqinggege`(可通过 DNS TXT 记录或代码托管平台账号验证;本项目的 SCM 地址为 + `git.mirenqinggege.com`,建议用域名方式验证)。 +2. **生成 Portal Token**:在 Central Portal 的 *Account → Generate User Token* 中生成, + 然后写入 `~/.m2/settings.xml`: + + ```xml + + + central + Portal Token 的用户名 + Portal Token 的密码 + + + ``` + +3. **准备 GPG 密钥**:确认本地已有私钥,且公钥已发布到密钥服务器: + + ```bash + gpg --list-secret-keys --keyid-format=long + gpg --keyserver keyserver.ubuntu.com --send-keys <你的KEYID> + ``` + +### 2. 发布 + +```bash +mvn clean deploy -P release +``` + +该命令会:编译 → 单元测试 → 生成 `plugin.xml` → 打包 `jar` / `sources` / `javadoc` → +GPG 签名(`release` profile)→ 上传到 Central Portal → 等待校验通过。 + +默认 `autoPublish=false`,构件校验通过后会停留在 Portal 的待发布状态, +需在 手动点击 **Publish**; +若希望校验通过即自动发布,把 `pom.xml` 中的 `` 改为 `true`。 + +### 3. 未使用 release profile 时的行为 + +```bash +mvn clean verify # 只构建与测试,不签名(默认 profile 会把 sign 执行置为 phase=none) +mvn clean install # 安装到本地仓库,同样不签名 +``` + +### 4. 覆盖签名密钥 + +```bash +mvn clean deploy -P release -Dgpg.keyname=ABCDEF0123456789 +``` + +### 5. 版本升级 + +发布新版本时需同步修改三处,避免 SCM 标签与实际发布不一致: + +- `pom.xml` 的 ``; +- `pom.xml` 的 ``; +- `README.md` 中示例使用的 `jpackage.plugin.version`(`examples/hello-cli/pom.xml`)。 + +## 插件工程自身构建 + +```bash +mvn clean verify # 编译 + 单元测试 + 生成插件描述符 +mvn install # 安装到本地仓库,供其他项目引用 +mvn com.mirenqinggege.maven:jpackage-maven-plugin:1.0.0:help # 查看 goal 帮助 +``` + +### 源码结构 + +``` +src/main/java/com/mirenqinggege/maven/jpackage/ +├── PackageAppMojo.java # 核心 Mojo:流程编排与错误处理 +└── internal/ + ├── Platform.java # 平台探测与平台差异 + ├── JdkToolLocator.java # jpackage 可执行文件定位 + ├── VersionSanitizer.java # Maven 版本号 → jpackage 版本号 + ├── JarManifestInspector.java # MANIFEST.MF 解析(Main-Class / Class-Path) + ├── JpackageOptions.java # 命令行组装(平台自适应、UTF-8 注入) + ├── LauncherScriptGenerator.java # run.sh / run.bat / .desktop 生成 + ├── FileTreeUtils.java # 目录清理与拷贝 + ├── FilePermissions.java # 跨平台 POSIX 权限设置 + ├── JpackageExecutor.java # 进程调用抽象(可注入,便于测试) + └── WineSupport.java # Wine 交叉打包支持 +``` + +设计上把「可测试的纯逻辑」全部下沉到 `internal` 包,Mojo 只负责编排:这使得命令行组装、 +清单解析、脚本生成、版本清洗等核心行为都能在无 Maven 容器、无真实 jpackage 的条件下被 +单元测试覆盖(共 103 个测试用例)。 + +### 编写测试的关键注入点 + +`PackageAppMojo` 的配置字段为包级可见,测试可直接赋值;另外提供三个注入点: + +- `executor`:替换进程执行器,避免真的启动 `jpackage`; +- `environment`:替换 `java.home` / `JAVA_HOME`; +- `platformOverride`:覆盖平台探测结果,用于在任意机器上验证 Windows 分支逻辑。 + +示例见 [`src/test/java/com/mirenqinggege/maven/jpackage/PackageAppMojoTest.java`](src/test/java/com/mirenqinggege/maven/jpackage/PackageAppMojoTest.java)。 diff --git a/examples/hello-cli/pom.xml b/examples/hello-cli/pom.xml new file mode 100644 index 0000000..e83d08a --- /dev/null +++ b/examples/hello-cli/pom.xml @@ -0,0 +1,97 @@ + + + + 4.0.0 + + com.example + hello-cli + 1.0.0-SNAPSHOT + jar + + hello-cli + jpackage-maven-plugin 的最小可用示例 + + + 17 + UTF-8 + 1.0.0 + + + + + + org.apache.commons + commons-lang3 + 3.14.0 + + + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.1 + + + + com.example.hello.HelloApp + true + lib/ + + + + + + + + com.mirenqinggege.maven + jpackage-maven-plugin + ${jpackage.plugin.version} + + + native-package + + package-app + + + + + + HelloTool + + + com.example.hello.HelloApp + + + + -Xmx256m + -Ddemo.mode=example + + + + ${project.basedir}/src/main/resources/app.png + + Example Inc. + 示例命令行工具 + + + true + + + true + + + + + diff --git a/examples/hello-cli/src/main/java/com/example/hello/HelloApp.java b/examples/hello-cli/src/main/java/com/example/hello/HelloApp.java new file mode 100644 index 0000000..b74d0fe --- /dev/null +++ b/examples/hello-cli/src/main/java/com/example/hello/HelloApp.java @@ -0,0 +1,42 @@ +package com.example.hello; + +import org.apache.commons.lang3.StringUtils; + +import java.nio.charset.Charset; +import java.util.Arrays; + +/** + * 示例应用:一个再普通不过的命令行工具。 + * + *

刻意不使用任何框架,以证明插件对应用类型没有假设。程序会打印中文、命令行参数、 + * 编码相关系统属性与三方依赖的处理结果,用于直观验证:

+ *
    + *
  • 内嵌运行时确实生效({@code java.home} 指向应用镜像内的 {@code lib/runtime});
  • + *
  • UTF-8 参数确实生效({@code file.encoding} 等均为 UTF-8);
  • + *
  • 三方依赖确实被正确打包并可加载。
  • + *
+ */ +public final class HelloApp { + + private HelloApp() { + } + + /** + * 程序入口。 + * + * @param args 命令行参数 + */ + public static void main(String[] args) { + System.out.println("你好,世界!Hello from a self-contained app image."); + System.out.println("args=" + Arrays.toString(args)); + System.out.println("file.encoding=" + System.getProperty("file.encoding") + + ", stdout.encoding=" + System.getProperty("stdout.encoding") + + ", stderr.encoding=" + System.getProperty("stderr.encoding")); + System.out.println("sun.stdout.encoding=" + System.getProperty("sun.stdout.encoding") + + ", sun.stderr.encoding=" + System.getProperty("sun.stderr.encoding")); + System.out.println("defaultCharset=" + Charset.defaultCharset()); + System.out.println("java.home=" + System.getProperty("java.home")); + System.out.println("java.version=" + System.getProperty("java.version")); + System.out.println("commons-lang3 joined=" + StringUtils.join(new String[]{"a", "b", "c"}, "-")); + } +} diff --git a/examples/hello-cli/src/main/resources/app.png b/examples/hello-cli/src/main/resources/app.png new file mode 100644 index 0000000000000000000000000000000000000000..17c3a1a0c7aab96b409126a33165c4af4a8d375e GIT binary patch literal 82 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`lAbP(Ar*6yC#<&kIA7?DLDqp4 fjEmWL#F-g(mBz|zHzlnGDq`?-^>bP0l+XkK>)RDN literal 0 HcmV?d00001 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..8d3868c --- /dev/null +++ b/pom.xml @@ -0,0 +1,344 @@ + + + 4.0.0 + + com.mirenqinggege.maven + jpackage-maven-plugin + 1.0.0 + maven-plugin + + JPackage Maven Plugin + + 通用原生打包插件:基于 OpenJDK 16+ 官方 jpackage 工具,将任意标准 Java 应用打包为 + 内嵌最小运行时的跨平台自包含应用镜像(app-image,解压即用、目标机器无需安装 Java)。 + 插件不绑定任何应用框架,自动探测 Main-Class、自动清洗版本号、按平台自适应参数并生成 + 一键启动脚本。 + + 2026 + + https://git.mirenqinggege.com/zkh/jpackage-maven-plugin + + + + Apache License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + 一个 OSI 认可的开源许可证。 + + + + + + zkh + zkh + zkh@mirenqinggege.com + + Developer + Maintainer + + Asia/Shanghai + + + + + scm:git:ssh://git@git.mirenqinggege.com:2222/zkh/jpackage-maven-plugin.git + scm:git:ssh://git@git.mirenqinggege.com:2222/zkh/jpackage-maven-plugin.git + https://git.mirenqinggege.com/zkh/jpackage-maven-plugin + jpackage-maven-plugin-1.0.0 + + + + Gitea Issues + https://git.mirenqinggege.com/zkh/jpackage-maven-plugin/issues + + + + UTF-8 + UTF-8 + 17 + + + 3.9.9 + 3.16.0 + 3.14.0 + 3.5.2 + 3.4.2 + 3.3.1 + 3.12.0 + 3.2.8 + 3.6.3 + 0.12.0 + + 5.14.4 + + + + 3.6.3 + + + + + + org.apache.maven + maven-plugin-api + ${maven.version} + provided + + + + + org.apache.maven + maven-core + ${maven.version} + provided + + + + org.apache.maven + maven-model + ${maven.version} + provided + + + + + org.apache.maven.plugin-tools + maven-plugin-annotations + ${maven.plugin.tools.version} + provided + + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + ${maven.enforcer.version} + + + enforce-build-environment + + enforce + + + + + [3.6.3,) + + + [17,) + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven.compiler.version} + + ${maven.compiler.release} + UTF-8 + + true + + + + + org.apache.maven.plugins + maven-plugin-plugin + ${maven.plugin.tools.version} + + + jpackage + + false + + + + default-descriptor + process-classes + + descriptor + + + + help-goal + + helpmojo + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven.surefire.version} + + + + + org.apache.maven.plugins + maven-source-plugin + ${maven.source.version} + + + attach-sources + + jar-no-fork + + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + ${maven.javadoc.version} + + UTF-8 + UTF-8 + UTF-8 + zh_CN + none + true + true + + false + false + + + + attach-javadocs + + jar + + + + + + + + org.apache.maven.plugins + maven-gpg-plugin + ${maven.gpg.version} + + + sign-artifacts + verify + + sign + + + + + + + + org.sonatype.central + central-publishing-maven-plugin + ${central.publishing.version} + true + + central + + false + + validated + + + + + + + + + release + + + A29C55F143AA11321B87D9F402253AEB01AD4266 + + + + + org.apache.maven.plugins + maven-gpg-plugin + + + sign-artifacts + verify + + sign + + + ${gpg.keyname} + + loopback + + + + + + + + + + + default-no-release + + true + + + + + org.apache.maven.plugins + maven-gpg-plugin + + + sign-artifacts + none + + + + + + + + diff --git a/src/main/java/com/mirenqinggege/maven/jpackage/PackageAppMojo.java b/src/main/java/com/mirenqinggege/maven/jpackage/PackageAppMojo.java new file mode 100644 index 0000000..78c4102 --- /dev/null +++ b/src/main/java/com/mirenqinggege/maven/jpackage/PackageAppMojo.java @@ -0,0 +1,863 @@ +package com.mirenqinggege.maven.jpackage; + +import org.apache.maven.artifact.Artifact; +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.plugins.annotations.ResolutionScope; +import org.apache.maven.project.MavenProject; +import com.mirenqinggege.maven.jpackage.internal.FilePermissions; +import com.mirenqinggege.maven.jpackage.internal.FileTreeUtils; +import com.mirenqinggege.maven.jpackage.internal.JarManifestInspector; +import com.mirenqinggege.maven.jpackage.internal.JdkToolLocator; +import com.mirenqinggege.maven.jpackage.internal.JpackageExecutor; +import com.mirenqinggege.maven.jpackage.internal.JpackageOptions; +import com.mirenqinggege.maven.jpackage.internal.LauncherScriptGenerator; +import com.mirenqinggege.maven.jpackage.internal.Platform; +import com.mirenqinggege.maven.jpackage.internal.VersionSanitizer; +import com.mirenqinggege.maven.jpackage.internal.WineSupport; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; + +/** + * 使用 OpenJDK 官方 {@code jpackage} 工具,把当前项目打包为内嵌最小运行时的 + * 自包含应用(默认 {@code app-image},即绿色免安装版)。 + * + *

插件刻意不依赖任何应用框架(Spring Boot / JavaFX / Quarkus 等),只使用纯 Java 与 + * Maven 标准模型,因此适用于命令行工具、桌面 GUI 应用、微服务等各类标准 Java 项目。

+ * + *

零配置用法

+ *
{@code
+ * 
+ *   com.mirenqinggege.maven
+ *   jpackage-maven-plugin
+ *   1.0.0
+ *   
+ *     
+ *       package-app
+ *     
+ *   
+ * 
+ * }
+ * + *

执行 {@code mvn package} 后,产物位于 {@code target/dist//}, + * 目录内即为可直接拷贝到目标机器(无需安装 Java)运行的完整应用。

+ */ +@Mojo(name = "package-app", + defaultPhase = LifecyclePhase.PACKAGE, + requiresDependencyResolution = ResolutionScope.RUNTIME, + threadSafe = true) +public class PackageAppMojo extends AbstractMojo { + + /** 错误信息中保留的 jpackage 输出行数上限。 */ + private static final int ERROR_OUTPUT_TAIL_LINES = 40; + + /** + * 当前 Maven 项目,由 Maven 注入。 + */ + @Parameter(defaultValue = "${project}", readonly = true, required = true) + MavenProject project; + + /** + * 构建输出目录,即 {@code target}。 + */ + @Parameter(defaultValue = "${project.build.directory}", readonly = true, required = true) + File buildDirectory; + + /** + * 构建产物基础名,默认与 {@code } 一致。 + */ + @Parameter(defaultValue = "${project.build.finalName}", readonly = true, required = true) + String finalName; + + /** + * 应用名称,决定应用镜像目录名与可执行文件名。默认取 {@code project.artifactId}。 + */ + @Parameter(property = "jpackage.appName") + String appName; + + /** + * 主 JAR 包文件名。默认取 {@code ${project.build.finalName}.jar}。 + */ + @Parameter(property = "jpackage.mainJar") + String mainJar; + + /** + * 主启动类全限定名。留空时自动从主 JAR 的 {@code META-INF/MANIFEST.MF} 中读取 {@code Main-Class}。 + */ + @Parameter(property = "jpackage.mainClass") + String mainClass; + + /** + * 追加的自定义 JVM 运行参数,例如 {@code -Xmx512m}。 + * + *

插件默认注入的 UTF-8 参数排在用户参数之前,因此同名参数可由用户覆盖。

+ */ + @Parameter + List javaOptions; + + /** + * 是否注入默认的 UTF-8 编码参数({@code file.encoding}、{@code stdout.encoding} 等)。 + */ + @Parameter(property = "jpackage.injectUtf8Options", defaultValue = "true") + boolean injectUtf8Options = true; + + /** + * Windows 目标平台下是否附加 {@code --win-console} 以保留控制台窗口。 + * + *

该参数只在 Windows 目标平台生效;Linux / macOS 平台永远不会传递它, + * 否则 jpackage 会直接报错。

+ */ + @Parameter(property = "jpackage.winConsole", defaultValue = "true") + boolean winConsole = true; + + /** + * 应用图标文件,支持 {@code .ico}(Windows)、{@code .png}(Linux)、{@code .icns}(macOS)。 + */ + @Parameter(property = "jpackage.icon") + File icon; + + /** + * 供应商标识,写入应用元数据。 + */ + @Parameter(property = "jpackage.vendor") + String vendor; + + /** + * 应用描述,写入应用元数据。 + */ + @Parameter(property = "jpackage.description") + String description; + + /** + * 版权信息,写入应用元数据。 + */ + @Parameter(property = "jpackage.copyright") + String copyright; + + /** + * jpackage 的 {@code --type} 取值。默认 {@code app-image}。 + * + *

保持默认即为绿色免安装版;如需 {@code msi} / {@code deb} / {@code rpm} / {@code dmg} / {@code pkg}, + * 需自行确认目标平台已安装对应打包工具。

+ */ + @Parameter(property = "jpackage.type", defaultValue = "app-image") + String type = "app-image"; + + /** + * jpackage 输出目录,最终应用镜像位于 {@code ${destinationDirectory}/${appName}}。 + */ + @Parameter(property = "jpackage.dest", defaultValue = "${project.build.directory}/dist") + File destinationDirectory; + + /** + * 传递给 jpackage 的纯净输入目录,插件会先清空再填充。 + * + *

刻意与 {@code target} 根目录隔离,避免把 {@code classes/}、{@code maven-status/} 等 + * 编译污染目录一并打包进应用镜像。

+ */ + @Parameter(defaultValue = "${project.build.directory}/jpackage-input") + File inputDirectory; + + /** + * 是否把运行时依赖一并拷贝到输入目录。默认 {@code true}。 + * + *

若项目已通过 shade / assembly 插件打成 fat jar,可将此项设为 {@code false}。

+ */ + @Parameter(property = "jpackage.includeDependencies", defaultValue = "true") + boolean includeDependencies = true; + + /** + * 打包结束后是否删除临时输入目录。默认 {@code true}。 + */ + @Parameter(property = "jpackage.cleanInputDirectory", defaultValue = "true") + boolean cleanInputDirectory = true; + + /** + * 是否自动生成平台启动脚本({@code run.sh} / {@code run.bat})。默认 {@code true}。 + */ + @Parameter(property = "jpackage.generateLauncherScript", defaultValue = "true") + boolean generateLauncherScript = true; + + /** + * 是否为 Linux 应用额外生成 {@code .desktop} 桌面入口文件。默认 {@code true}。 + */ + @Parameter(property = "jpackage.generateDesktopEntry", defaultValue = "true") + boolean generateDesktopEntry = true; + + /** + * 追加到 jpackage 命令行的原始参数,例如 {@code --resource-dir}。 + */ + @Parameter + List additionalArguments; + + /** + * 显式指定 jpackage 可执行文件路径。留空时按 {@code ${java.home}/bin}、{@code JAVA_HOME}、{@code PATH} 依次探测。 + */ + @Parameter(property = "jpackage.jpackageExecutable") + String jpackageExecutable; + + /** + * 是否在 Linux 构建机上借助 Wine 调用 Windows 版 jpackage,从而产出 Windows 应用。默认 {@code false}。 + */ + @Parameter(property = "jpackage.useWine", defaultValue = "false") + boolean useWine = false; + + /** + * {@code useWine} 为 {@code true} 时必填:Windows 版 OpenJDK 的解压目录(其下含 {@code bin/jpackage.exe})。 + */ + @Parameter(property = "jpackage.windowsJdkHome") + String windowsJdkHome; + + /** + * 是否跳过原生打包。可通过 {@code -Djpackage.skip=true} 在命令行跳过。 + */ + @Parameter(property = "jpackage.skip", defaultValue = "false") + boolean skip = false; + + /** + * 是否输出 jpackage 的完整日志。默认 {@code false},仅输出摘要。 + */ + @Parameter(property = "jpackage.verbose", defaultValue = "false") + boolean verbose = false; + + /** + * 进程执行器,默认使用真实进程实现;单元测试可替换为假实现。 + */ + JpackageExecutor executor; + + /** + * 供测试注入环境信息。为 {@code null} 时使用真实系统属性与环境变量。 + */ + ExecutionEnvironment environment; + + /** + * 供测试注入平台。为 {@code null} 时使用 {@link Platform#current()}。 + */ + Platform platformOverride; + + @Override + public void execute() throws MojoExecutionException, MojoFailureException { + if (skip) { + getLog().info("jpackage-maven-plugin: 已通过 skip 配置跳过原生打包。"); + return; + } + + Platform hostPlatform = currentPlatform(); + Platform targetPlatform = useWine ? Platform.WINDOWS : hostPlatform; + + getLog().info("jpackage-maven-plugin: 开始构建原生应用镜像"); + getLog().info(" ├─ 构建主机平台:" + hostPlatform + ",目标平台:" + targetPlatform); + + Path jpackage = resolveJpackageExecutable(targetPlatform); + Path resolvedMainJar = resolveMainJar(); + Optional resolvedMainClass = resolveMainClass(resolvedMainJar); + String resolvedAppName = resolveAppName(); + String resolvedVersion = VersionSanitizer.sanitize(project == null ? null : project.getVersion()); + + Path inputDir = inputDirectory().toPath().toAbsolutePath().normalize(); + Path destDir = destinationDirectory().toPath().toAbsolutePath().normalize(); + + getLog().info(" ├─ 应用名称:" + resolvedAppName + ",版本:" + resolvedVersion + + "(原始版本:" + (project == null ? "?" : project.getVersion()) + ")"); + getLog().info(" ├─ 主 JAR 包:" + resolvedMainJar.getFileName()); + resolvedMainClass.ifPresentOrElse( + main -> getLog().info(" ├─ 主启动类:" + main), + () -> getLog().warn(" ├─ 未能探测到 Main-Class,将交由 jpackage 从 JAR 清单中自行读取," + + "若构建失败请显式配置 。")); + + List extraJars = prepareInputDirectory(inputDir, resolvedMainJar); + getLog().info(" ├─ 输入目录已就绪:" + inputDir + "(主 JAR + " + extraJars.size() + " 个依赖)"); + + JpackageOptions options = buildOptions(resolvedAppName, resolvedVersion, resolvedMainJar, + resolvedMainClass.orElse(null), inputDir, destDir, targetPlatform); + + List command = buildCommand(jpackage, options); + getLog().info(" ├─ 执行 jpackage:" + JpackageExecutor.format(command)); + + int exitCode = runJpackage(command, inputDir); + if (exitCode != 0) { + throw new MojoExecutionException("jpackage 执行失败,退出码 " + exitCode + + "。完整日志请使用 -X 或 -Djpackage.verbose=true 重新运行。"); + } + + Path appImage = destDir.resolve(resolvedAppName); + if (!Files.isDirectory(appImage)) { + throw new MojoExecutionException( + "jpackage 执行成功但未找到预期的应用镜像目录:" + appImage + + "。请确认应用名称与输出目录配置是否与 jpackage 行为一致。"); + } + + if (generateLauncherScript) { + generateLaunchers(appImage, resolvedAppName, targetPlatform, destDir); + } + + if (cleanInputDirectory) { + deleteInputDirectory(inputDir); + } + + logSummary(appImage, resolvedAppName, targetPlatform); + } + + /** + * 定位 jpackage 可执行文件,并在必要时给出可操作的报错。 + * + * @param targetPlatform 目标平台,决定查找的可执行文件名 + * @return jpackage 路径 + * @throws MojoExecutionException 未找到可用 jpackage 或 JDK 版本过低 + */ + private Path resolveJpackageExecutable(Platform targetPlatform) + throws MojoExecutionException { + if (useWine) { + Optional problem = WineSupport.validate(windowsJdkHome); + if (problem.isPresent()) { + throw new MojoExecutionException(problem.get()); + } + return WineSupport.locateWindowsJpackage(new File(windowsJdkHome.trim()).toPath()) + .orElseThrow(() -> new MojoExecutionException( + "未能在 " + windowsJdkHome + "/bin 下找到 jpackage.exe")); + } + + String javaHomeEnv = environmentValue("JAVA_HOME"); + String javaHomeOverride = environment().javaHome; + Optional located = JdkToolLocator.locate(targetPlatform, jpackageExecutable, + javaHomeOverride, javaHomeEnv); + + if (located.isEmpty()) { + String hint = jpackageExecutable != null && !jpackageExecutable.isBlank() + ? "已配置的 不可用:" + jpackageExecutable + : "已尝试 ${java.home}/bin、JAVA_HOME/bin 与系统 PATH"; + throw new MojoExecutionException("无法定位 jpackage 可执行文件(" + hint + ")。" + + "请确认 Maven 运行在 JDK 16 及以上版本,或通过 显式指定路径。"); + } + + Path executable = located.get(); + int featureVersion = JdkToolLocator.parseFeatureVersion( + System.getProperty("java.version"), System.getProperty("java.specification.version")); + if (featureVersion > 0 && featureVersion < JdkToolLocator.MINIMUM_JDK_FEATURE_VERSION) { + throw new MojoExecutionException("jpackage 需要 JDK " + JdkToolLocator.MINIMUM_JDK_FEATURE_VERSION + + " 及以上版本,当前运行 Maven 的 JDK 主版本为 " + featureVersion + "。"); + } + getLog().info(" ├─ jpackage 可执行文件:" + executable); + return executable; + } + + /** + * 解析主 JAR 包路径并校验其存在。 + * + * @return 主 JAR 的绝对路径 + * @throws MojoExecutionException 主 JAR 不存在 + */ + private Path resolveMainJar() throws MojoExecutionException { + String jarName = mainJar != null && !mainJar.isBlank() + ? mainJar.trim() + : (finalName == null ? "application" : finalName) + ".jar"; + + Path candidate = buildDirectory().toPath().toAbsolutePath().normalize().resolve(jarName); + if (!Files.isRegularFile(candidate)) { + throw new MojoExecutionException("未找到主 JAR 包:" + candidate + + "。请确认已执行编译与打包(如 mvn package),或通过 指定正确的文件名。"); + } + return candidate; + } + + /** + * 解析主启动类:用户配置优先,其次读取主 JAR 清单。 + * + * @param mainJarPath 主 JAR 路径 + * @return 主类名;两者均未提供时返回空 + */ + private Optional resolveMainClass(Path mainJarPath) { + if (mainClass != null && !mainClass.isBlank()) { + return Optional.of(mainClass.trim()); + } + try { + return JarManifestInspector.findMainClass(mainJarPath); + } catch (IOException e) { + getLog().warn("读取主 JAR 清单失败,将不做主类探测:" + e.getMessage()); + return Optional.empty(); + } + } + + /** + * 解析应用名称,默认回退为 {@code project.artifactId}。 + * + * @return 应用名称 + */ + private String resolveAppName() { + if (appName != null && !appName.isBlank()) { + return appName.trim(); + } + String artifactId = project == null ? null : project.getArtifactId(); + return artifactId == null || artifactId.isBlank() ? "application" : artifactId; + } + + /** + * 准备纯净输入目录:清空后拷入主 JAR 与依赖。 + * + * @param inputDir 输入目录 + * @param mainJarPath 主 JAR 路径 + * @return 除主 JAR 外额外拷贝进来的 JAR 列表 + * @throws MojoExecutionException 目录准备失败 + */ + private List prepareInputDirectory(Path inputDir, Path mainJarPath) throws MojoExecutionException { + try { + FileTreeUtils.cleanDirectory(inputDir); + } catch (IOException e) { + throw new MojoExecutionException("无法清理输入目录 " + inputDir + ":" + e.getMessage(), e); + } + + Set copied = new LinkedHashSet<>(); + try { + FileTreeUtils.copyFile(mainJarPath, inputDir.resolve(mainJarPath.getFileName())); + } catch (IOException e) { + throw new MojoExecutionException("拷贝主 JAR 到输入目录失败:" + e.getMessage(), e); + } + + List sources = new ArrayList<>(); + sources.addAll(collectClassPathJars(mainJarPath)); + if (includeDependencies) { + sources.addAll(collectDependencyJars()); + } + + for (Path source : sources) { + String fileName = source.getFileName().toString(); + if (fileName.equals(mainJarPath.getFileName().toString())) { + continue; + } + Path target = inputDir.resolve(fileName); + if (Files.exists(target)) { + continue; + } + try { + FileTreeUtils.copyFile(source, target); + copied.add(source); + } catch (IOException e) { + getLog().warn("拷贝依赖 " + source + " 失败,已跳过:" + e.getMessage()); + } + } + return new ArrayList<>(copied); + } + + /** + * 解析主 JAR 清单 {@code Class-Path} 中声明、且能在本地找到的相对依赖。 + * + * @param mainJarPath 主 JAR 路径 + * @return 本地存在的依赖 JAR 路径列表 + */ + private List collectClassPathJars(Path mainJarPath) { + List result = new ArrayList<>(); + Path baseDir = mainJarPath.getParent(); + List entries; + try { + entries = JarManifestInspector.findClassPath(mainJarPath); + } catch (IOException e) { + getLog().debug("读取 Class-Path 失败:" + e.getMessage()); + return result; + } + for (String entry : entries) { + if (JarManifestInspector.isAbsoluteUrl(entry)) { + getLog().debug("跳过 Class-Path 中的远程条目:" + entry); + continue; + } + Path dependency = baseDir.resolve(entry).normalize(); + if (Files.isRegularFile(dependency)) { + result.add(dependency); + } else { + getLog().debug("Class-Path 条目在本地不存在,跳过:" + entry); + } + } + return result; + } + + /** + * 收集项目运行时依赖中的 JAR 文件。 + * + * @return 依赖 JAR 路径列表 + */ + private List collectDependencyJars() { + List result = new ArrayList<>(); + if (project == null || project.getArtifacts() == null) { + return result; + } + for (Artifact artifact : project.getArtifacts()) { + if (!isRuntimeRelevant(artifact)) { + continue; + } + File file = artifact.getFile(); + if (file != null && file.isFile()) { + result.add(file.toPath()); + } + } + return result; + } + + /** + * 判断依赖是否应进入最终运行时镜像。 + * + * @param artifact 依赖坐标 + * @return 类型为 JAR 且作用域属于运行时时返回 {@code true} + */ + private boolean isRuntimeRelevant(Artifact artifact) { + if (artifact == null) { + return false; + } + String artifactType = artifact.getType() == null ? "" : artifact.getType().toLowerCase(Locale.ROOT); + boolean jarLike = artifactType.equals("jar") || artifactType.equals("bundle") || artifactType.isEmpty(); + if (!jarLike) { + return false; + } + String scope = artifact.getScope(); + if (scope == null) { + return true; + } + return switch (scope.toLowerCase(Locale.ROOT)) { + case "compile", "runtime", "system" -> true; + default -> false; + }; + } + + /** + * 依据解析结果构建 jpackage 配置模型。 + * + * @param resolvedAppName 应用名称 + * @param resolvedVersion 清洗后的版本号 + * @param mainJarPath 主 JAR 路径 + * @param resolvedMainClass 主类(可为 {@code null}) + * @param inputDir 输入目录 + * @param destDir 输出目录 + * @param targetPlatform 目标平台 + * @return 配置模型 + */ + private JpackageOptions buildOptions(String resolvedAppName, + String resolvedVersion, + Path mainJarPath, + String resolvedMainClass, + Path inputDir, + Path destDir, + Platform targetPlatform) { + JpackageOptions options = new JpackageOptions() + .setName(resolvedAppName) + .setAppVersion(resolvedVersion) + .setMainJar(mainJarPath.getFileName().toString()) + .setMainClass(resolvedMainClass) + .setInputDirectory(inputDir.toString()) + .setDestinationDirectory(destDir.toString()) + .setType(type) + .setVendor(vendor) + .setDescription(description) + .setCopyright(copyright) + .setWinConsole(winConsole) + .setInjectUtf8Options(injectUtf8Options) + .setTargetPlatform(targetPlatform); + + options.addJavaOptions(javaOptions); + options.addAdditionalArguments(additionalArguments); + + if (icon != null) { + Path iconPath = icon.toPath().toAbsolutePath().normalize(); + if (Files.isRegularFile(iconPath)) { + options.setIcon(iconPath.toString()); + } else { + getLog().warn("配置的图标文件不存在,已忽略:" + iconPath); + } + } + return options; + } + + /** + * 依据配置组装最终命令行;Wine 模式下需要把路径转换为 Windows 形式。 + * + * @param jpackage jpackage 可执行文件 + * @param options 配置模型 + * @return 完整命令行 + */ + private List buildCommand(Path jpackage, JpackageOptions options) { + List rawArguments = options.toCommandLineArguments(); + if (!useWine) { + List command = new ArrayList<>(); + command.add(jpackage.toString()); + command.addAll(rawArguments); + return command; + } + + Function converter = path -> WineSupport.toWindowsPath(path, null); + return WineSupport.buildWineCommand( + WineSupport.findWine().orElseThrow(() -> new IllegalStateException("wine 不可用")), + jpackage, + convertPathsToWindows(rawArguments, converter), + converter); + } + + /** + * 把参数中位于 {@code --input} / {@code --dest} / {@code --icon} 之后的宿主路径 + * 转换为 Wine 可识别的 Windows 路径。 + * + * @param arguments 原始参数 + * @param converter 路径转换器 + * @return 转换后的参数 + */ + private List convertPathsToWindows(List arguments, Function converter) { + List converted = new ArrayList<>(arguments.size()); + String pendingPathOption = null; + for (String argument : arguments) { + if (pendingPathOption != null) { + converted.add(converter.apply(Path.of(argument))); + pendingPathOption = null; + continue; + } + switch (argument) { + case "--input", "--dest", "--icon" -> pendingPathOption = argument; + default -> { + // 其余参数按原样透传 + } + } + converted.add(argument); + } + return converted; + } + + /** + * 执行 jpackage 并收集输出,失败时输出末尾若干行便于定位。 + * + * @param command 完整命令行 + * @param inputDir 工作目录(输入目录),保证 jpackage 的相对路径解析落在输入目录内 + * @return 进程退出码 + * @throws MojoExecutionException 进程无法启动或被中断 + */ + private int runJpackage(List command, Path inputDir) + throws MojoExecutionException { + Deque tail = new ArrayDeque<>(); + Map environment = wineEnvironment(); + try { + int exitCode = executor().execute(command, inputDir, environment, line -> { + if (verbose) { + getLog().info(" [jpackage] " + line); + } else { + getLog().debug(" [jpackage] " + line); + } + synchronized (tail) { + if (tail.size() >= ERROR_OUTPUT_TAIL_LINES) { + tail.removeFirst(); + } + tail.addLast(line); + } + }); + if (exitCode != 0 && !tail.isEmpty()) { + getLog().error("jpackage 输出(末尾 " + tail.size() + " 行):"); + tail.forEach(line -> getLog().error(" " + line)); + } + return exitCode; + } catch (IOException e) { + throw new MojoExecutionException("启动 jpackage 进程失败:" + e.getMessage(), e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new MojoExecutionException("等待 jpackage 进程时被中断", e); + } + } + + /** + * Wine 模式下追加的环境变量,抑制 Wine 自身的调试噪声。 + * + * @return 环境变量表;非 Wine 模式返回空表 + */ + private Map wineEnvironment() { + if (!useWine) { + return Map.of(); + } + Map environment = new LinkedHashMap<>(); + environment.putIfAbsent("WINEDEBUG", "-all"); + return environment; + } + + /** + * 生成平台专属启动脚本,并按需生成 Linux 桌面入口。 + * + * @param appImage 应用镜像目录 + * @param resolvedAppName 应用名称 + * @param targetPlatform 目标平台 + * @param destDir 输出目录 + * @throws MojoExecutionException 脚本生成失败 + */ + private void generateLaunchers(Path appImage, String resolvedAppName, + Platform targetPlatform, Path destDir) + throws MojoExecutionException { + try { + Path script = LauncherScriptGenerator.generate(appImage, resolvedAppName, targetPlatform); + getLog().info(" ├─ 已生成启动脚本:" + FileTreeUtils.relativize(destDir, script)); + + if (targetPlatform == Platform.LINUX && generateDesktopEntry) { + Path desktop = appImage.resolve(resolvedAppName + ".desktop"); + FileTreeUtils.writeString(desktop, + LauncherScriptGenerator.desktopEntryContent(resolvedAppName, description)); + FilePermissions.setPermissions(desktop, FilePermissions.READABLE_PERMISSIONS); + getLog().info(" ├─ 已生成桌面入口:" + + FileTreeUtils.relativize(destDir, desktop)); + } + } catch (IOException e) { + throw new MojoExecutionException("生成启动脚本失败:" + e.getMessage(), e); + } + } + + /** + * 清理临时输入目录。 + * + * @param inputDir 输入目录 + */ + private void deleteInputDirectory(Path inputDir) { + try { + FileTreeUtils.deleteRecursively(inputDir); + getLog().debug("已清理临时输入目录:" + inputDir); + } catch (IOException e) { + getLog().warn("清理临时输入目录失败(不影响打包结果):" + inputDir + " - " + e.getMessage()); + } + } + + /** + * 输出打包结果摘要。 + * + * @param appImage 应用镜像目录 + * @param resolvedAppName 应用名称 + * @param targetPlatform 目标平台 + */ + private void logSummary(Path appImage, String resolvedAppName, Platform targetPlatform) { + long fileCount = FileTreeUtils.countFiles(appImage); + String launcher = targetPlatform.isWindows() + ? LauncherScriptGenerator.WINDOWS_SCRIPT_NAME + : LauncherScriptGenerator.UNIX_SCRIPT_NAME; + + getLog().info("jpackage-maven-plugin: 原生应用镜像构建完成"); + getLog().info(" ├─ 输出目录:" + appImage); + getLog().info(" ├─ 文件数量:" + fileCount); + getLog().info(" └─ 启动方式:" + appImage.resolve(launcher).getFileName() + + "(或直接执行 bin/" + resolvedAppName + ")"); + getLog().info(" 提示:整个 " + resolvedAppName + + " 目录即为绿色免安装版,拷贝到目标机器解压即可运行,无需安装 Java。"); + } + + /** + * @return 构建输出目录 + */ + private File buildDirectory() { + if (buildDirectory != null) { + return buildDirectory; + } + return new File("target"); + } + + /** + * @return 配置的输入目录,未配置时回退为 {@code target/jpackage-input} + */ + private File inputDirectory() { + if (inputDirectory != null) { + return inputDirectory; + } + return new File(buildDirectory(), "jpackage-input"); + } + + /** + * @return 配置的输出目录,未配置时回退为 {@code target/dist} + */ + private File destinationDirectory() { + if (destinationDirectory != null) { + return destinationDirectory; + } + return new File(buildDirectory(), "dist"); + } + + /** + * @return 当前进程执行器 + */ + private JpackageExecutor executor() { + if (executor != null) { + return executor; + } + return JpackageExecutor.defaultExecutor(); + } + + /** + * 读取环境变量,测试可注入替代实现。 + * + * @param name 变量名 + * @return 变量值 + */ + private String environmentValue(String name) { + ExecutionEnvironment env = environment(); + if ("JAVA_HOME".equals(name) && env.javaHome != null) { + return env.javaHome; + } + return System.getenv(name); + } + + private ExecutionEnvironment environment() { + if (environment == null) { + environment = new ExecutionEnvironment(System.getProperty("java.home"), System.getenv("JAVA_HOME")); + } + return environment; + } + + private Platform currentPlatform() { + return platformOverride != null ? platformOverride : Platform.current(); + } + + // ---- 测试注入点 ------------------------------------------------------- + + /** + * 替换进程执行器(仅供测试使用)。 + * + * @param executor 执行器实现 + */ + void setExecutor(JpackageExecutor executor) { + this.executor = executor; + } + + /** + * 替换环境信息(仅供测试使用)。 + * + * @param environment 环境信息 + */ + void setEnvironment(ExecutionEnvironment environment) { + this.environment = environment; + } + + /** + * 覆盖当前平台判定(仅供测试使用)。 + * + * @param platform 用于替代真实探测结果的平台 + */ + void setPlatformOverride(Platform platform) { + this.platformOverride = platform; + } + + /** + * 供测试使用的环境信息载体。 + */ + static final class ExecutionEnvironment { + /** {@code java.home} 的值。 */ + final String javaHome; + /** {@code JAVA_HOME} 的值。 */ + final String javaHomeEnv; + + ExecutionEnvironment(String javaHome, String javaHomeEnv) { + this.javaHome = javaHome; + this.javaHomeEnv = javaHomeEnv; + } + } +} diff --git a/src/main/java/com/mirenqinggege/maven/jpackage/internal/FilePermissions.java b/src/main/java/com/mirenqinggege/maven/jpackage/internal/FilePermissions.java new file mode 100644 index 0000000..d1b3b36 --- /dev/null +++ b/src/main/java/com/mirenqinggege/maven/jpackage/internal/FilePermissions.java @@ -0,0 +1,78 @@ +package com.mirenqinggege.maven.jpackage.internal; + +import java.io.IOException; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.EnumSet; +import java.util.Set; + +/** + * 跨平台的 POSIX 权限设置工具。 + * + *

jpackage 生成的 {@code bin/} 可执行文件在类 Unix 系统上通常已带有执行位, + * 但自行生成的 {@code run.sh} 需要显式赋予 {@code 755}。在 Windows 或不支持 POSIX + * 权限的文件系统上,这些操作会被安全地忽略而不会中断构建。

+ */ +public final class FilePermissions { + + /** {@code rwxr-xr-x},即八进制 755。 */ + public static final Set EXECUTABLE_PERMISSIONS = + PosixFilePermissions.fromString("rwxr-xr-x"); + + /** {@code rw-r--r--},即八进制 644。 */ + public static final Set READABLE_PERMISSIONS = + PosixFilePermissions.fromString("rw-r--r--"); + + private FilePermissions() { + // 工具类 + } + + /** + * 判断当前文件系统是否支持 POSIX 权限。 + * + * @param path 任意路径 + * @return 支持时返回 {@code true} + */ + public static boolean supportsPosix(Path path) { + if (path == null) { + return false; + } + FileSystem fileSystem = path.getFileSystem(); + return fileSystem.supportedFileAttributeViews().contains("posix"); + } + + /** + * 尽力把路径设置为可执行(755)。 + * + * @param path 目标文件 + * @return 成功设置时返回 {@code true};文件不存在、不支持 POSIX 或设置失败时返回 {@code false} + */ + public static boolean makeExecutable(Path path) { + return setPermissions(path, EXECUTABLE_PERMISSIONS); + } + + /** + * 设置 POSIX 权限,遇到不支持的环境时静默跳过。 + * + * @param path 目标文件 + * @param permissions 权限集合 + * @return 成功设置时返回 {@code true} + */ + public static boolean setPermissions(Path path, Set permissions) { + if (path == null || !Files.exists(path)) { + return false; + } + if (!supportsPosix(path)) { + return false; + } + try { + Files.setPosixFilePermissions(path, EnumSet.copyOf(permissions)); + return true; + } catch (IOException | UnsupportedOperationException e) { + return false; + } + } +} diff --git a/src/main/java/com/mirenqinggege/maven/jpackage/internal/FileTreeUtils.java b/src/main/java/com/mirenqinggege/maven/jpackage/internal/FileTreeUtils.java new file mode 100644 index 0000000..573bfa7 --- /dev/null +++ b/src/main/java/com/mirenqinggege/maven/jpackage/internal/FileTreeUtils.java @@ -0,0 +1,146 @@ +package com.mirenqinggege.maven.jpackage.internal; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Comparator; +import java.util.stream.Stream; + +/** + * 目录清理与文件拷贝的通用工具方法。 + */ +public final class FileTreeUtils { + + private FileTreeUtils() { + // 工具类 + } + + /** + * 递归删除目录或文件;目标不存在时直接返回。 + * + * @param root 待删除的根路径,允许为 {@code null} + * @throws IOException 删除过程中发生 I/O 错误 + */ + public static void deleteRecursively(Path root) throws IOException { + if (root == null || !Files.exists(root)) { + return; + } + Files.walkFileTree(root, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.deleteIfExists(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + if (exc != null) { + throw exc; + } + Files.deleteIfExists(dir); + return FileVisitResult.CONTINUE; + } + }); + } + + /** + * 清空目录内容但保留目录本身;目录不存在时创建它。 + * + * @param directory 目标目录 + * @throws IOException 清理或创建失败 + */ + public static void cleanDirectory(Path directory) throws IOException { + if (Files.exists(directory)) { + try (Stream children = Files.list(directory)) { + for (Path child : children.toList()) { + deleteRecursively(child); + } + } + } else { + Files.createDirectories(directory); + } + } + + /** + * 拷贝单个文件,自动创建父目录并覆盖同名目标。 + * + * @param source 源文件 + * @param target 目标文件 + * @throws IOException 拷贝失败 + */ + public static void copyFile(Path source, Path target) throws IOException { + Path parent = target.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); + } + + /** + * 以 UTF-8 写入文本文件,自动创建父目录并覆盖同名文件。 + * + * @param target 目标文件 + * @param content 文本内容 + * @throws IOException 写入失败 + */ + public static void writeString(Path target, String content) throws IOException { + Path parent = target.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.writeString(target, content, java.nio.charset.StandardCharsets.UTF_8); + } + + /** + * 递归统计目录下的常规文件数量。 + * + * @param directory 目标目录 + * @return 文件数量;目录不存在时返回 {@code 0} + */ + public static long countFiles(Path directory) { + if (directory == null || !Files.isDirectory(directory)) { + return 0L; + } + try (Stream stream = Files.walk(directory)) { + return stream.filter(Files::isRegularFile).count(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** + * 计算两个路径之间的相对路径文本,并在失败时回退为绝对路径。 + * + * @param base 基准目录 + * @param target 目标路径 + * @return 相对路径文本(统一使用 {@code /} 分隔符) + */ + public static String relativize(Path base, Path target) { + try { + return base.relativize(target).toString().replace('\\', '/'); + } catch (IllegalArgumentException e) { + return target.toString().replace('\\', '/'); + } + } + + /** + * 返回按名称排序的目录子项,便于生成稳定可预测的输出。 + * + * @param directory 目标目录 + * @return 排序后的子项列表 + * @throws IOException 读取目录失败 + */ + public static java.util.List listSorted(Path directory) throws IOException { + if (directory == null || !Files.isDirectory(directory)) { + return java.util.List.of(); + } + try (Stream stream = Files.list(directory)) { + return stream.sorted(Comparator.comparing(path -> path.getFileName().toString())).toList(); + } + } +} diff --git a/src/main/java/com/mirenqinggege/maven/jpackage/internal/JarManifestInspector.java b/src/main/java/com/mirenqinggege/maven/jpackage/internal/JarManifestInspector.java new file mode 100644 index 0000000..a0486d0 --- /dev/null +++ b/src/main/java/com/mirenqinggege/maven/jpackage/internal/JarManifestInspector.java @@ -0,0 +1,114 @@ +package com.mirenqinggege.maven.jpackage.internal; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.jar.Attributes; +import java.util.jar.JarFile; +import java.util.jar.Manifest; + +/** + * 读取 JAR 包 {@code META-INF/MANIFEST.MF} 中的关键属性。 + * + *

用于实现零配置能力:当用户在 POM 中未显式声明 {@code mainClass} 时, + * 从主 JAR 的清单文件中自动推断启动类;同时解析 {@code Class-Path} 头, + * 以便把外部依赖一并拷贝进 jpackage 的输入目录。

+ */ +public final class JarManifestInspector { + + private JarManifestInspector() { + // 工具类 + } + + /** + * 从 JAR 中读取 {@code Main-Class}。 + * + * @param jarFile 目标 JAR 文件路径 + * @return 去空白后的主类全限定名;清单不存在或未声明该属性时返回空 + * @throws IOException 读取 JAR 失败时抛出 + */ + public static Optional findMainClass(Path jarFile) throws IOException { + return readManifest(jarFile) + .map(manifest -> manifest.getMainAttributes().getValue(Attributes.Name.MAIN_CLASS)) + .map(String::trim) + .filter(value -> !value.isEmpty()); + } + + /** + * 从 JAR 中读取 {@code Class-Path} 头,并按空格拆分为条目列表。 + * + *

条目通常形如 {@code lib/foo.jar}(相对于主 JAR 所在目录), + * 也可能出现 {@code file:/...} 形式的绝对 URL。

+ * + * @param jarFile 目标 JAR 文件路径 + * @return 条目列表;清单不存在或未声明该属性时返回空列表 + * @throws IOException 读取 JAR 失败时抛出 + */ + public static List findClassPath(Path jarFile) throws IOException { + Optional raw = readManifest(jarFile) + .map(manifest -> manifest.getMainAttributes().getValue(Attributes.Name.CLASS_PATH)); + if (raw.isEmpty() || raw.get().trim().isEmpty()) { + return Collections.emptyList(); + } + List entries = new ArrayList<>(); + for (String entry : raw.get().trim().split("\\s+")) { + if (!entry.isEmpty()) { + entries.add(entry); + } + } + return Collections.unmodifiableList(entries); + } + + /** + * 读取 JAR 的清单对象,兼容以目录形式存在的「展开的」JAR。 + * + * @param jarFile JAR 文件或展开目录 + * @return 清单对象;不存在时返回空 + * @throws IOException 读取失败时抛出 + */ + public static Optional readManifest(Path jarFile) throws IOException { + if (jarFile == null || !Files.exists(jarFile)) { + return Optional.empty(); + } + if (Files.isDirectory(jarFile)) { + return readExpandedManifest(jarFile); + } + try (JarFile jar = new JarFile(jarFile.toFile(), false)) { + Manifest manifest = jar.getManifest(); + return Optional.ofNullable(manifest); + } + } + + private static Optional readExpandedManifest(Path directory) throws IOException { + Path manifestPath = directory.resolve(JarFile.MANIFEST_NAME); + if (!Files.isRegularFile(manifestPath)) { + return Optional.empty(); + } + try (InputStream in = Files.newInputStream(manifestPath)) { + return Optional.of(new Manifest(in)); + } + } + + /** + * 判断一个 {@code Class-Path} 条目是否为无法在打包时解析的远程引用。 + * + * @param entry 条目原文 + * @return 以 {@code http:}、{@code https:}、{@code file:} 等协议开头时返回 {@code true} + */ + public static boolean isAbsoluteUrl(String entry) { + if (entry == null) { + return false; + } + String lower = entry.toLowerCase(java.util.Locale.ROOT); + return lower.startsWith("http:") + || lower.startsWith("https:") + || lower.startsWith("file:") + || lower.startsWith("jar:") + || lower.startsWith("ftp:"); + } +} diff --git a/src/main/java/com/mirenqinggege/maven/jpackage/internal/JdkToolLocator.java b/src/main/java/com/mirenqinggege/maven/jpackage/internal/JdkToolLocator.java new file mode 100644 index 0000000..d15c8af --- /dev/null +++ b/src/main/java/com/mirenqinggege/maven/jpackage/internal/JdkToolLocator.java @@ -0,0 +1,204 @@ +package com.mirenqinggege.maven.jpackage.internal; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * 定位 jpackage 可执行文件。 + * + *

查找优先级:

+ *
    + *
  1. 用户显式配置的 {@code jpackageExecutable};
  2. + *
  3. {@code java.home}/bin/{jpackage[.exe]}(即运行当前 Maven 的 JDK,最可靠);
  4. + *
  5. {@code JAVA_HOME}/bin/{jpackage[.exe]};
  6. + *
  7. 系统 {@code PATH} 上的 jpackage。
  8. + *
+ * + *

注意 {@code java.home} 在 JDK 中指向 JDK 根目录,但若 Maven 运行在 JRE 上, + * 该值可能是 {@code /jre},因此还会额外检查其父目录。

+ */ +public final class JdkToolLocator { + + /** 低于该版本时 jpackage 不存在或不可用。 */ + public static final int MINIMUM_JDK_FEATURE_VERSION = 16; + + private JdkToolLocator() { + // 工具类 + } + + /** + * 定位 jpackage 可执行文件。 + * + * @param platform 目标平台,决定可执行文件后缀 + * @param explicitPath 用户在 POM 中显式配置的路径,可为 {@code null}。 + * 既可以是 jpackage 可执行文件本身,也可以是包含它的目录 + * (如 {@code /bin})或 JDK 根目录(如 {@code }) + * @param javaHomeOverride 覆盖 {@code java.home} 的值(供测试注入),可为 {@code null} + * @param environment {@code JAVA_HOME} 的值,可为 {@code null} + * @return 可执行文件路径;未找到时返回空 + */ + public static Optional locate(Platform platform, + String explicitPath, + String javaHomeOverride, + String environment) { + String executableName = platform == null + ? Platform.current().jpackageExecutableName() + : platform.jpackageExecutableName(); + + if (explicitPath != null && !explicitPath.isBlank()) { + return locateExplicit(Paths.get(explicitPath.trim()), executableName); + } + + for (Path candidate : candidateJavaHomes(javaHomeOverride, environment)) { + Path executable = candidate.resolve("bin").resolve(executableName); + if (isUsable(executable)) { + return Optional.of(executable.toAbsolutePath().normalize()); + } + } + + return findOnPath(executableName); + } + + /** + * 解析用户显式配置的路径:显式配置失效时不再回退到系统 JDK, + * 以便尽早暴露拼写错误而不是静默使用另一个 JDK。 + * + * @param configured 用户配置的路径 + * @param executableName 目标可执行文件名 + * @return 可执行文件路径;不可用时返回空 + */ + private static Optional locateExplicit(Path configured, String executableName) { + if (Files.isRegularFile(configured)) { + return isUsable(configured) + ? Optional.of(configured.toAbsolutePath().normalize()) + : Optional.empty(); + } + if (Files.isDirectory(configured)) { + for (Path relative : List.of(Path.of(executableName), Path.of("bin", executableName))) { + Path candidate = configured.resolve(relative); + if (isUsable(candidate)) { + return Optional.of(candidate.toAbsolutePath().normalize()); + } + } + } + return Optional.empty(); + } + + /** + * 收集所有值得尝试的 JDK 根目录。 + * + * @param javaHomeOverride 覆盖值 + * @param environment {@code JAVA_HOME} + * @return 去重后的候选 JDK 根目录列表 + */ + static List candidateJavaHomes(String javaHomeOverride, String environment) { + Set homes = new LinkedHashSet<>(); + List rawValues = new ArrayList<>(); + rawValues.add(javaHomeOverride != null ? javaHomeOverride : System.getProperty("java.home")); + rawValues.add(environment != null ? environment : System.getenv("JAVA_HOME")); + + for (String raw : rawValues) { + if (raw == null || raw.isBlank()) { + continue; + } + Path home = Paths.get(raw.trim()); + homes.add(home); + // JRE 布局:/jre/bin/java,jpackage 位于 /bin + Path stripped = stripJreSuffix(home); + if (stripped != null) { + homes.add(stripped); + } + } + return new ArrayList<>(homes); + } + + private static Path stripJreSuffix(Path home) { + Path fileName = home.getFileName(); + if (fileName != null && fileName.toString().equalsIgnoreCase("jre")) { + Path parent = home.getParent(); + if (parent != null && Files.isDirectory(parent.resolve("bin"))) { + return parent; + } + } + return null; + } + + private static Optional findOnPath(String executableName) { + String pathVariable = System.getenv("PATH"); + if (pathVariable == null || pathVariable.isBlank()) { + return Optional.empty(); + } + for (String element : pathVariable.split(File.pathSeparator)) { + if (element.isBlank()) { + continue; + } + Path candidate = Paths.get(element).resolve(executableName); + if (isUsable(candidate)) { + return Optional.of(candidate.toAbsolutePath().normalize()); + } + } + return Optional.empty(); + } + + /** + * 判断候选路径是否为一个可用的 jpackage 可执行文件。 + * + * @param candidate 候选路径 + * @return 存在、是常规文件且具备执行权限(或位于 Windows)时返回 {@code true} + */ + static boolean isUsable(Path candidate) { + if (candidate == null || !Files.isRegularFile(candidate)) { + return false; + } + if (Platform.current().isWindows()) { + return true; + } + return Files.isExecutable(candidate); + } + + /** + * 解析运行当前 JVM 的 JDK 主版本号。 + * + * @param javaVersionProperty {@code java.version} 属性的值(如 {@code 21.0.9}) + * @param javaSpecification {@code java.specification.version} 的值(如 {@code 21}) + * @return 主版本号;无法解析时返回 {@code 0} + */ + public static int parseFeatureVersion(String javaVersionProperty, String javaSpecification) { + String candidate = javaSpecification; + if (candidate == null || candidate.isBlank()) { + candidate = javaVersionProperty; + } + if (candidate == null || candidate.isBlank()) { + return 0; + } + String trimmed = candidate.trim(); + // 兼容 "1.8.0_392" 这类旧式版本号 + if (trimmed.startsWith("1.")) { + trimmed = trimmed.substring(2); + } + StringBuilder digits = new StringBuilder(); + for (int i = 0; i < trimmed.length(); i++) { + char c = trimmed.charAt(i); + if (Character.isDigit(c)) { + digits.append(c); + } else { + break; + } + } + if (digits.isEmpty()) { + return 0; + } + try { + return Integer.parseInt(digits.toString()); + } catch (NumberFormatException e) { + return 0; + } + } +} diff --git a/src/main/java/com/mirenqinggege/maven/jpackage/internal/JpackageExecutor.java b/src/main/java/com/mirenqinggege/maven/jpackage/internal/JpackageExecutor.java new file mode 100644 index 0000000..fd63e5a --- /dev/null +++ b/src/main/java/com/mirenqinggege/maven/jpackage/internal/JpackageExecutor.java @@ -0,0 +1,109 @@ +package com.mirenqinggege.maven.jpackage.internal; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +/** + * 外部进程调用封装。 + * + *

隔离对 {@link ProcessBuilder} 的直接依赖,使 Mojo 的流程逻辑可以在测试中 + * 通过注入假的执行器进行验证,而无需真的启动 jpackage。

+ */ +@FunctionalInterface +public interface JpackageExecutor { + + /** + * 同步执行命令,等待其结束。 + * + * @param command 完整命令行(首个元素为可执行文件路径) + * @param workingDir 工作目录,{@code null} 表示继承当前进程 + * @param environment 需要追加/覆盖的环境变量,可为空 + * @param outputSink 逐行接收合并后的标准输出与标准错误,可为 {@code null} + * @return 退出码 + * @throws IOException 进程无法启动或读取输出失败 + * @throws InterruptedException 等待期间线程被中断 + */ + int execute(List command, + Path workingDir, + Map environment, + Consumer outputSink) throws IOException, InterruptedException; + + /** + * 默认实现:真实启动操作系统进程。 + * + * @return 基于 {@link ProcessBuilder} 的执行器 + */ + static JpackageExecutor defaultExecutor() { + return (command, workingDir, environment, outputSink) -> { + ProcessBuilder builder = new ProcessBuilder(command); + if (workingDir != null) { + builder.directory(workingDir.toFile()); + } + if (environment != null && !environment.isEmpty()) { + builder.environment().putAll(environment); + } + // 合并 stderr 到 stdout,避免两路输出交错导致读取线程互相阻塞 + builder.redirectErrorStream(true); + + Process process = builder.start(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + if (outputSink != null) { + outputSink.accept(line); + } + } + } + return process.waitFor(); + }; + } + + /** + * 构造用于日志展示的命令行字符串,对含空格的参数补上引号。 + * + * @param command 命令行 + * @return 可读的命令行文本 + */ + static String format(List command) { + List parts = new ArrayList<>(command.size()); + for (String part : command) { + if (part.indexOf(' ') >= 0 || part.indexOf('\t') >= 0) { + parts.add('"' + part + '"'); + } else { + parts.add(part); + } + } + return String.join(" ", parts); + } + + /** + * 空实现,供不需要真实执行的测试场景使用。 + * + * @param exitCode 固定返回的退出码 + * @return 始终返回指定退出码的执行器 + */ + static JpackageExecutor returning(int exitCode) { + return (command, workingDir, environment, outputSink) -> exitCode; + } + + /** @return 不做任何事的输出回调 */ + static Consumer noopSink() { + return line -> { + // 忽略 + }; + } + + /** @return 空环境变量表 */ + static Map noEnvironment() { + return Collections.emptyMap(); + } +} diff --git a/src/main/java/com/mirenqinggege/maven/jpackage/internal/JpackageOptions.java b/src/main/java/com/mirenqinggege/maven/jpackage/internal/JpackageOptions.java new file mode 100644 index 0000000..68ce84d --- /dev/null +++ b/src/main/java/com/mirenqinggege/maven/jpackage/internal/JpackageOptions.java @@ -0,0 +1,390 @@ +package com.mirenqinggege.maven.jpackage.internal; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * jpackage 命令行参数模型与组装器。 + * + *

该类不依赖 Maven API,纯粹负责把已解析好的配置翻译成 jpackage 的参数列表, + * 因此可以被完整地单元测试。参数顺序不影响 jpackage 行为,但保持稳定顺序 + * 有利于日志可读性与测试断言。

+ */ +public final class JpackageOptions { + + /** 默认注入的 UTF-8 编码参数,用于规避跨平台控制台乱码。 */ + public static final List UTF8_JAVA_OPTIONS = List.of( + "-Dfile.encoding=UTF-8", + "-Dstdout.encoding=UTF-8", + "-Dstderr.encoding=UTF-8", + "-Dsun.stdout.encoding=UTF-8", + "-Dsun.stderr.encoding=UTF-8"); + + private String name; + private String appVersion; + private String mainJar; + private String mainClass; + private String inputDirectory; + private String destinationDirectory; + private String type = "app-image"; + private String icon; + private String vendor; + private String description; + private String copyright; + private boolean injectUtf8Options = true; + private boolean winConsole = true; + private Platform targetPlatform = Platform.current(); + private final List javaOptions = new ArrayList<>(); + private final List additionalArguments = new ArrayList<>(); + + /** @return 应用名称({@code --name}) */ + public String getName() { + return name; + } + + /** + * @param name 应用名称 + * @return 当前实例,便于链式调用 + */ + public JpackageOptions setName(String name) { + this.name = name; + return this; + } + + /** @return 应用版本({@code --app-version}),应为清洗后的格式 */ + public String getAppVersion() { + return appVersion; + } + + /** + * @param appVersion 应用版本 + * @return 当前实例 + */ + public JpackageOptions setAppVersion(String appVersion) { + this.appVersion = appVersion; + return this; + } + + /** @return 主 JAR 文件名({@code --main-jar}) */ + public String getMainJar() { + return mainJar; + } + + /** + * @param mainJar 主 JAR 文件名 + * @return 当前实例 + */ + public JpackageOptions setMainJar(String mainJar) { + this.mainJar = mainJar; + return this; + } + + /** @return 主类全限定名({@code --main-class}),可能为空 */ + public String getMainClass() { + return mainClass; + } + + /** + * @param mainClass 主类全限定名,{@code null} 或空白表示省略该参数 + * @return 当前实例 + */ + public JpackageOptions setMainClass(String mainClass) { + this.mainClass = mainClass; + return this; + } + + /** @return 输入目录({@code --input}) */ + public String getInputDirectory() { + return inputDirectory; + } + + /** + * @param inputDirectory 输入目录 + * @return 当前实例 + */ + public JpackageOptions setInputDirectory(String inputDirectory) { + this.inputDirectory = inputDirectory; + return this; + } + + /** @return 输出目录({@code --dest}) */ + public String getDestinationDirectory() { + return destinationDirectory; + } + + /** + * @param destinationDirectory 输出目录 + * @return 当前实例 + */ + public JpackageOptions setDestinationDirectory(String destinationDirectory) { + this.destinationDirectory = destinationDirectory; + return this; + } + + /** @return 打包类型({@code --type}),默认 {@code app-image} */ + public String getType() { + return type; + } + + /** + * @param type 打包类型 + * @return 当前实例 + */ + public JpackageOptions setType(String type) { + this.type = type; + return this; + } + + /** @return 图标文件路径,可能为空 */ + public String getIcon() { + return icon; + } + + /** + * @param icon 图标文件路径 + * @return 当前实例 + */ + public JpackageOptions setIcon(String icon) { + this.icon = icon; + return this; + } + + /** @return 供应商信息({@code --vendor}),可能为空 */ + public String getVendor() { + return vendor; + } + + /** + * @param vendor 供应商信息 + * @return 当前实例 + */ + public JpackageOptions setVendor(String vendor) { + this.vendor = vendor; + return this; + } + + /** @return 应用描述({@code --description}),可能为空 */ + public String getDescription() { + return description; + } + + /** + * @param description 应用描述 + * @return 当前实例 + */ + public JpackageOptions setDescription(String description) { + this.description = description; + return this; + } + + /** @return 版权信息({@code --copyright}),可能为空 */ + public String getCopyright() { + return copyright; + } + + /** + * @param copyright 版权信息 + * @return 当前实例 + */ + public JpackageOptions setCopyright(String copyright) { + this.copyright = copyright; + return this; + } + + /** @return 是否注入默认 UTF-8 参数 */ + public boolean isInjectUtf8Options() { + return injectUtf8Options; + } + + /** + * @param injectUtf8Options 是否注入默认 UTF-8 参数 + * @return 当前实例 + */ + public JpackageOptions setInjectUtf8Options(boolean injectUtf8Options) { + this.injectUtf8Options = injectUtf8Options; + return this; + } + + /** @return Windows 下是否附加 {@code --win-console} */ + public boolean isWinConsole() { + return winConsole; + } + + /** + * @param winConsole Windows 下是否附加 {@code --win-console} + * @return 当前实例 + */ + public JpackageOptions setWinConsole(boolean winConsole) { + this.winConsole = winConsole; + return this; + } + + /** @return 目标平台 */ + public Platform getTargetPlatform() { + return targetPlatform; + } + + /** + * @param targetPlatform 目标平台 + * @return 当前实例 + */ + public JpackageOptions setTargetPlatform(Platform targetPlatform) { + this.targetPlatform = targetPlatform; + return this; + } + + /** + * 追加一个自定义 JVM 参数。 + * + * @param option 参数原文,例如 {@code -Xmx512m} + * @return 当前实例 + */ + public JpackageOptions addJavaOption(String option) { + if (option != null && !option.isBlank()) { + this.javaOptions.add(option.trim()); + } + return this; + } + + /** + * 追加若干自定义 JVM 参数。 + * + * @param options 参数列表,允许为 {@code null} + * @return 当前实例 + */ + public JpackageOptions addJavaOptions(List options) { + if (options != null) { + options.forEach(this::addJavaOption); + } + return this; + } + + /** + * 追加一个直接传给 jpackage 的原始参数(如 {@code --resource-dir})。 + * + * @param argument 参数原文 + * @return 当前实例 + */ + public JpackageOptions addAdditionalArgument(String argument) { + if (argument != null && !argument.isBlank()) { + this.additionalArguments.add(argument.trim()); + } + return this; + } + + /** + * 追加若干原始参数。 + * + * @param arguments 参数列表,允许为 {@code null} + * @return 当前实例 + */ + public JpackageOptions addAdditionalArguments(List arguments) { + if (arguments != null) { + arguments.forEach(this::addAdditionalArgument); + } + return this; + } + + /** @return 用户自定义 JVM 参数的不可变视图 */ + public List getJavaOptions() { + return Collections.unmodifiableList(javaOptions); + } + + /** @return 追加的原始参数的不可变视图 */ + public List getAdditionalArguments() { + return Collections.unmodifiableList(additionalArguments); + } + + /** + * 计算最终传递给 jpackage 的 JVM 参数:默认 UTF-8 参数在前,用户参数在后。 + * + *

用户参数排在后面,因此同名参数(如再次指定 {@code -Dfile.encoding=GBK}) + * 可以覆盖插件注入的默认值。

+ * + * @return 合并后的 JVM 参数列表 + */ + public List resolveJavaOptions() { + List resolved = new ArrayList<>(); + if (injectUtf8Options) { + resolved.addAll(UTF8_JAVA_OPTIONS); + } + for (String option : javaOptions) { + if (!resolved.contains(option)) { + resolved.add(option); + } + } + return resolved; + } + + /** + * 组装完整的 jpackage 参数列表(不含可执行文件本身)。 + * + * @return 参数列表 + * @throws IllegalStateException 缺少必填项时抛出 + */ + public List toCommandLineArguments() { + requireNonBlank(name, "应用名称 --name 不能为空"); + requireNonBlank(appVersion, "应用版本 --app-version 不能为空"); + requireNonBlank(mainJar, "主 JAR 包 --main-jar 不能为空"); + requireNonBlank(inputDirectory, "输入目录 --input 不能为空"); + requireNonBlank(destinationDirectory, "输出目录 --dest 不能为空"); + + List args = new ArrayList<>(); + args.add("--type"); + args.add(type == null || type.isBlank() ? "app-image" : type); + args.add("--name"); + args.add(name); + args.add("--app-version"); + args.add(appVersion); + args.add("--input"); + args.add(inputDirectory); + args.add("--main-jar"); + args.add(mainJar); + + if (mainClass != null && !mainClass.isBlank()) { + args.add("--main-class"); + args.add(mainClass.trim()); + } + + // Windows 专属参数:其他平台传递会导致 jpackage 直接报错 + if (targetPlatform != null && targetPlatform.isWindows() && winConsole) { + args.add("--win-console"); + } + + if (icon != null && !icon.isBlank()) { + args.add("--icon"); + args.add(icon.trim()); + } + if (vendor != null && !vendor.isBlank()) { + args.add("--vendor"); + args.add(vendor.trim()); + } + if (description != null && !description.isBlank()) { + args.add("--description"); + args.add(description.trim()); + } + if (copyright != null && !copyright.isBlank()) { + args.add("--copyright"); + args.add(copyright.trim()); + } + + for (String javaOption : resolveJavaOptions()) { + args.add("--java-options"); + args.add(javaOption); + } + + args.addAll(additionalArguments); + + args.add("--dest"); + args.add(destinationDirectory); + + return args; + } + + private static void requireNonBlank(String value, String message) { + if (value == null || value.isBlank()) { + throw new IllegalStateException(message); + } + } +} diff --git a/src/main/java/com/mirenqinggege/maven/jpackage/internal/LauncherScriptGenerator.java b/src/main/java/com/mirenqinggege/maven/jpackage/internal/LauncherScriptGenerator.java new file mode 100644 index 0000000..14740cc --- /dev/null +++ b/src/main/java/com/mirenqinggege/maven/jpackage/internal/LauncherScriptGenerator.java @@ -0,0 +1,140 @@ +package com.mirenqinggege.maven.jpackage.internal; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * 生成解压即用的平台启动脚本。 + * + *

jpackage 的 {@code app-image} 会在 {@code bin/} 下生成可执行文件, + * 但用户拿到压缩包解压后往往希望在根目录直接双击/执行。因此本类负责:

+ *
    + *
  • 类 Unix:生成 {@code run.sh}(LF 换行、{@code exec} 转发全部参数),并赋予 755 权限;
  • + *
  • Windows:生成 CRLF 换行的 {@code run.bat},先 {@code chcp 65001} 切换到 UTF-8 代码页, + * 再以 {@code %~dp0} 定位脚本所在目录并转发 {@code %*} 参数。
  • + *
+ */ +public final class LauncherScriptGenerator { + + /** 类 Unix 启动脚本文件名。 */ + public static final String UNIX_SCRIPT_NAME = "run.sh"; + + /** Windows 启动脚本文件名。 */ + public static final String WINDOWS_SCRIPT_NAME = "run.bat"; + + /** Windows 批处理文件使用的换行符。 */ + private static final String CRLF = "\r\n"; + + private LauncherScriptGenerator() { + // 工具类 + } + + /** + * 在应用镜像根目录生成启动脚本。 + * + * @param appImageDirectory 应用镜像根目录(jpackage 输出的 {@code /}) + * @param appName 应用名称,对应 {@code bin/} 下的可执行文件名 + * @param platform 目标平台 + * @return 生成的脚本路径 + * @throws IOException 写入或设置权限失败 + */ + public static Path generate(Path appImageDirectory, String appName, Platform platform) + throws IOException { + if (platform == null) { + throw new IllegalArgumentException("目标平台不能为空"); + } + if (appName == null || appName.isBlank()) { + throw new IllegalArgumentException("应用名称不能为空"); + } + Files.createDirectories(appImageDirectory); + return platform.isWindows() + ? generateWindowsScript(appImageDirectory, appName) + : generateUnixScript(appImageDirectory, appName); + } + + /** + * 生成 {@code run.sh} 并赋予可执行权限。 + * + * @param appImageDirectory 应用镜像根目录 + * @param appName 应用名称 + * @return 脚本路径 + * @throws IOException 写入失败 + */ + static Path generateUnixScript(Path appImageDirectory, String appName) throws IOException { + Path script = appImageDirectory.resolve(UNIX_SCRIPT_NAME); + String content = unixScriptContent(appName); + FileTreeUtils.writeString(script, content); + FilePermissions.makeExecutable(script); + + // jpackage 正常情况下已设置执行位,此处做一次兜底,避免个别文件系统丢权限 + Path launcher = appImageDirectory.resolve("bin").resolve(appName); + FilePermissions.makeExecutable(launcher); + + return script; + } + + /** + * 生成 {@code run.bat}(CRLF 换行)。 + * + * @param appImageDirectory 应用镜像根目录 + * @param appName 应用名称 + * @return 脚本路径 + * @throws IOException 写入失败 + */ + static Path generateWindowsScript(Path appImageDirectory, String appName) throws IOException { + Path script = appImageDirectory.resolve(WINDOWS_SCRIPT_NAME); + FileTreeUtils.writeString(script, windowsScriptContent(appName)); + return script; + } + + /** + * 构造 {@code run.sh} 文本内容(LF 换行)。 + * + *

脚本先切换到自身所在目录,保证在任意工作目录下双击/执行都能正确定位运行时, + * 随后用 {@code exec} 替换当前进程,使信号(Ctrl+C、SIGTERM)能直接传递到应用。

+ * + * @param appName 应用名称 + * @return 脚本内容 + */ + public static String unixScriptContent(String appName) { + return "#!/bin/sh\n" + + "# 由 jpackage-maven-plugin 自动生成:切换到脚本所在目录后启动应用\n" + + "set -e\n" + + "APP_HOME=$(cd \"$(dirname \"$0\")\" && pwd)\n" + + "exec \"$APP_HOME/bin/" + appName + "\" \"$@\"\n"; + } + + /** + * 构造 {@code run.bat} 文本内容(CRLF 换行)。 + * + * @param appName 应用名称 + * @return 脚本内容 + */ + public static String windowsScriptContent(String appName) { + return "@echo off" + CRLF + + "rem 由 jpackage-maven-plugin 自动生成:切换控制台代码页为 UTF-8 后启动应用" + CRLF + + "chcp 65001 > nul" + CRLF + + "setlocal" + CRLF + + "\"%~dp0" + appName + ".exe\" %*" + CRLF + + "endlocal" + CRLF; + } + + /** + * 构造 Linux 桌面环境使用的 {@code .desktop} 文件内容,便于集成到应用菜单。 + * + * @param appName 应用名称 + * @param comment 应用描述 + * @return desktop 文件内容 + */ + public static String desktopEntryContent(String appName, String comment) { + String safeComment = comment == null || comment.isBlank() ? appName : comment.trim(); + return "[Desktop Entry]\n" + + "Type=Application\n" + + "Name=" + appName + "\n" + + "Comment=" + safeComment + "\n" + + "Exec=bin/" + appName + "\n" + + "Terminal=true\n" + + "Categories=Utility;\n"; + } +} diff --git a/src/main/java/com/mirenqinggege/maven/jpackage/internal/Platform.java b/src/main/java/com/mirenqinggege/maven/jpackage/internal/Platform.java new file mode 100644 index 0000000..d55db6b --- /dev/null +++ b/src/main/java/com/mirenqinggege/maven/jpackage/internal/Platform.java @@ -0,0 +1,86 @@ +package com.mirenqinggege.maven.jpackage.internal; + +import java.util.Locale; + +/** + * 目标操作系统平台。 + * + *

用于决定 jpackage 的参数组装方式(例如 {@code --win-console} 只允许出现在 Windows 上)、 + * 启动脚本的生成格式以及应用镜像的目录布局。

+ */ +public enum Platform { + + /** Microsoft Windows。 */ + WINDOWS("windows"), + /** Linux。 */ + LINUX("linux"), + /** Apple macOS。 */ + MACOS("macos"); + + private final String jpackageName; + + Platform(String jpackageName) { + this.jpackageName = jpackageName; + } + + /** + * 与 jpackage {@code --type} 中平台前缀一致的名称。 + * + * @return {@code windows}、{@code linux} 或 {@code macos} + */ + public String jpackageName() { + return jpackageName; + } + + /** + * 探测运行当前 JVM 的操作系统。 + * + * @return 当前平台 + */ + public static Platform current() { + return detect(System.getProperty("os.name", "")); + } + + /** + * 依据 {@code os.name} 系统属性的原始值判断平台,便于单元测试注入。 + * + * @param osName {@code os.name} 属性值,允许为 {@code null} + * @return 解析出的平台,无法识别时回退为 {@link #LINUX} + */ + public static Platform detect(String osName) { + String normalized = osName == null ? "" : osName.toLowerCase(Locale.ROOT); + // macOS 的 os.name 为 "Mac OS X" / "Darwin",其中含 "OS X" 而不含 "win", + // 但仍先判断 mac,避免未来命名变化引入歧义 + if (normalized.contains("mac") || normalized.contains("darwin")) { + return MACOS; + } + if (normalized.contains("win")) { + return WINDOWS; + } + return LINUX; + } + + /** @return 是否为 Windows 平台 */ + public boolean isWindows() { + return this == WINDOWS; + } + + /** @return 是否为 macOS 平台 */ + public boolean isMac() { + return this == MACOS; + } + + /** @return 是否为类 Unix 平台(Linux 或 macOS) */ + public boolean isUnix() { + return this == LINUX || this == MACOS; + } + + /** + * 返回当前平台上 jpackage 可执行文件的文件名。 + * + * @return Windows 下为 {@code jpackage.exe},其余为 {@code jpackage} + */ + public String jpackageExecutableName() { + return isWindows() ? "jpackage.exe" : "jpackage"; + } +} diff --git a/src/main/java/com/mirenqinggege/maven/jpackage/internal/VersionSanitizer.java b/src/main/java/com/mirenqinggege/maven/jpackage/internal/VersionSanitizer.java new file mode 100644 index 0000000..3945b14 --- /dev/null +++ b/src/main/java/com/mirenqinggege/maven/jpackage/internal/VersionSanitizer.java @@ -0,0 +1,102 @@ +package com.mirenqinggege.maven.jpackage.internal; + +import java.util.ArrayList; +import java.util.List; + +/** + * 将 Maven 版本号清洗为 jpackage 可接受的版本字符串。 + * + *

jpackage 要求 {@code --app-version} 满足形如 {@code 1.0.0} 的纯数字点分格式, + * 而 Maven 常见版本号带有 {@code -SNAPSHOT}、{@code .RELEASE}、{@code -beta-1} 等后缀, + * 直接传递会导致 jpackage 报 {@code Version [x] is not valid} 而失败。

+ * + *

清洗规则:

+ *
    + *
  1. 从左向右截取第一个非数字且非 {@code .} 的字符之前的内容;
  2. + *
  3. 按 {@code .} 拆分,丢弃空片段并去掉每个片段的前导零;
  4. + *
  5. 结果为空时回退为 {@link #DEFAULT_VERSION}。
  6. + *
+ */ +public final class VersionSanitizer { + + /** 无法从输入中提取任何数字版本时使用的兜底版本。 */ + public static final String DEFAULT_VERSION = "1.0.0"; + + private VersionSanitizer() { + // 工具类 + } + + /** + * 清洗版本号。 + * + * @param rawVersion 原始版本号,允许为 {@code null} 或空白 + * @return 合法的 jpackage 版本字符串;输入无法解析时返回 {@link #DEFAULT_VERSION} + */ + public static String sanitize(String rawVersion) { + if (rawVersion == null) { + return DEFAULT_VERSION; + } + String trimmed = rawVersion.trim(); + if (trimmed.isEmpty()) { + return DEFAULT_VERSION; + } + + // 1. 只保留开头的数字与点,遇到 '-'、'.RELEASE'、'+' 等立即截断 + StringBuilder numeric = new StringBuilder(); + for (int i = 0; i < trimmed.length(); i++) { + char c = trimmed.charAt(i); + if (Character.isDigit(c) || c == '.') { + numeric.append(c); + } else { + break; + } + } + + // 2. 拆分、丢弃空片段、去掉前导零 + List segments = new ArrayList<>(); + for (String segment : numeric.toString().split("\\.")) { + if (segment.isEmpty()) { + continue; + } + String normalized = stripLeadingZeros(segment); + if (!normalized.isEmpty()) { + segments.add(normalized); + } + } + + if (segments.isEmpty()) { + return DEFAULT_VERSION; + } + return String.join(".", segments); + } + + private static String stripLeadingZeros(String digits) { + int index = 0; + while (index < digits.length() - 1 && digits.charAt(index) == '0') { + index++; + } + return digits.substring(index); + } + + /** + * 判断给定字符串是否已是合法版本号(仅供测试与断言使用)。 + * + * @param version 待校验字符串 + * @return 非空、由数字与点组成、且不以点开头或结尾时返回 {@code true} + */ + public static boolean isValid(String version) { + if (version == null || version.isEmpty()) { + return false; + } + if (version.startsWith(".") || version.endsWith(".")) { + return false; + } + for (int i = 0; i < version.length(); i++) { + char c = version.charAt(i); + if (!Character.isDigit(c) && c != '.') { + return false; + } + } + return true; + } +} diff --git a/src/main/java/com/mirenqinggege/maven/jpackage/internal/WineSupport.java b/src/main/java/com/mirenqinggege/maven/jpackage/internal/WineSupport.java new file mode 100644 index 0000000..8014369 --- /dev/null +++ b/src/main/java/com/mirenqinggege/maven/jpackage/internal/WineSupport.java @@ -0,0 +1,172 @@ +package com.mirenqinggege.maven.jpackage.internal; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Linux 下借助 Wine 调用 Windows 版 jpackage 的支持逻辑。 + * + *

jpackage 只能在目标平台上生成对应平台的可执行文件,因此要在一台 Linux 构建机上 + * 产出 Windows 应用,常规做法是在 Wine 中运行 Windows OpenJDK 的 {@code jpackage.exe}。 + * 本类的职责:

+ *
    + *
  1. 确认 {@code wine} 可执行文件存在;
  2. + *
  3. 在用户提供的 Windows JDK 目录中定位 {@code jpackage.exe};
  4. + *
  5. 把宿主机 POSIX 路径转换为 Wine 可识别的 Windows 路径。
  6. + *
+ * + *

路径转换优先使用 {@code winepath -w},其不可用时回退到 Wine 默认的 + * {@code Z:} 盘符映射({@code Z:\} 指向宿主机的 {@code /})。

+ */ +public final class WineSupport { + + /** Wine 可执行文件名。 */ + public static final String WINE_EXECUTABLE = "wine"; + + private WineSupport() { + // 工具类 + } + + /** + * 在 {@code PATH} 中查找 wine。 + * + * @return wine 可执行文件路径;未安装时返回空 + */ + public static Optional findWine() { + String pathVariable = System.getenv("PATH"); + if (pathVariable == null || pathVariable.isBlank()) { + return Optional.empty(); + } + for (String element : pathVariable.split(java.io.File.pathSeparator)) { + if (element.isBlank()) { + continue; + } + Path candidate = Paths.get(element).resolve(WINE_EXECUTABLE); + if (Files.isRegularFile(candidate) && Files.isExecutable(candidate)) { + return Optional.of(candidate.toAbsolutePath().normalize()); + } + } + return Optional.empty(); + } + + /** + * 在 Windows JDK 根目录中定位 {@code jpackage.exe}。 + * + * @param windowsJdkHome Windows JDK 解压目录 + * @return {@code jpackage.exe} 路径;目录非法或文件不存在时返回空 + */ + public static Optional locateWindowsJpackage(Path windowsJdkHome) { + if (windowsJdkHome == null || !Files.isDirectory(windowsJdkHome)) { + return Optional.empty(); + } + Path executable = windowsJdkHome.resolve("bin").resolve("jpackage.exe"); + return Files.isRegularFile(executable) + ? Optional.of(executable.toAbsolutePath().normalize()) + : Optional.empty(); + } + + /** + * 把宿主机路径转换为 Wine 使用的 Windows 路径。 + * + * @param hostPath 宿主机绝对路径 + * @param executor 用于调用 {@code winepath} 的执行器 + * @return Windows 风格路径 + */ + public static String toWindowsPath(Path hostPath, JpackageExecutor executor) { + Path absolute = hostPath.toAbsolutePath().normalize(); + // 优先尝试 winepath -w,失败则回退到 Z: 映射 + if (executor != null) { + List command = new ArrayList<>(); + command.add("winepath"); + command.add("-w"); + command.add(absolute.toString()); + try { + StringBuilder captured = new StringBuilder(); + int exitCode = executor.execute(command, null, JpackageExecutor.noEnvironment(), + line -> captured.append(line.trim())); + String converted = captured.toString().trim(); + if (exitCode == 0 && !converted.isEmpty()) { + return converted; + } + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + // 忽略,走回退逻辑 + } + } + return fallbackWindowsPath(absolute); + } + + /** + * 将 POSIX 绝对路径按 Wine 默认盘符约定转换为 {@code Z:\...} 形式。 + * + *

Wine 默认把宿主机的 {@code /} 映射为 {@code Z:} 盘,因此该回退对绝大多数 + * 场景都是正确的。

+ * + * @param absolute 宿主机绝对路径 + * @return Windows 风格路径 + */ + static String fallbackWindowsPath(Path absolute) { + String unix = absolute.toString().replace('/', '\\'); + if (!unix.startsWith("\\")) { + unix = "\\" + unix; + } + return "Z:" + unix; + } + + /** + * 组装通过 Wine 调用 jpackage 的完整命令行。 + * + * @param wineExecutable wine 可执行文件路径 + * @param windowsJpackage Windows 版 {@code jpackage.exe} 路径 + * @param jpackageArguments 传递给 jpackage 的参数 + * @param pathConverter 宿主路径 → Windows 路径的转换函数 + * @return 完整命令行 + */ + public static List buildWineCommand(Path wineExecutable, + Path windowsJpackage, + List jpackageArguments, + java.util.function.Function pathConverter) { + List command = new ArrayList<>(); + command.add(wineExecutable.toString()); + command.add(pathConverter.apply(windowsJpackage)); + command.addAll(jpackageArguments); + return command; + } + + /** + * 校验 Wine 打包模式的前置条件,返回人类可读的错误说明。 + * + * @param windowsJdkHome 用户配置的 Windows JDK 目录 + * @return 前置条件全部满足时返回空,否则返回错误说明 + */ + public static Optional validate(String windowsJdkHome) { + if (windowsJdkHome == null || windowsJdkHome.isBlank()) { + return Optional.of("启用 useWine 时必须配置 ,指向 Windows 版 OpenJDK 的解压目录"); + } + Path jdkHome = Paths.get(windowsJdkHome.trim()); + if (locateWindowsJpackage(jdkHome).isEmpty()) { + return Optional.of("在 " + jdkHome + "/bin 下未找到 jpackage.exe,请确认 windowsJdkHome 指向 Windows 版 JDK(非 JRE)"); + } + if (findWine().isEmpty()) { + return Optional.of("未在 PATH 中找到 wine,请先安装 Wine(如 apt install wine64)"); + } + return Optional.empty(); + } + + /** + * 判断当前平台是否具备使用 Wine 打包 Windows 应用的基础条件(供日志提示)。 + * + * @param platform 运行插件的平台 + * @return Linux 上安装了 wine 时返回 {@code true} + */ + public static boolean isWineSuitable(Platform platform) { + return platform == Platform.LINUX && findWine().isPresent(); + } +} diff --git a/src/test/java/com/mirenqinggege/maven/jpackage/PackageAppMojoTest.java b/src/test/java/com/mirenqinggege/maven/jpackage/PackageAppMojoTest.java new file mode 100644 index 0000000..2ff8455 --- /dev/null +++ b/src/test/java/com/mirenqinggege/maven/jpackage/PackageAppMojoTest.java @@ -0,0 +1,571 @@ +package com.mirenqinggege.maven.jpackage; + +import org.apache.maven.artifact.Artifact; +import org.apache.maven.artifact.DefaultArtifact; +import org.apache.maven.artifact.handler.DefaultArtifactHandler; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.project.MavenProject; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import com.mirenqinggege.maven.jpackage.internal.JarManifestInspectorTest; +import com.mirenqinggege.maven.jpackage.internal.JpackageExecutor; +import com.mirenqinggege.maven.jpackage.internal.Platform; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link PackageAppMojo} 的端到端单元测试。 + * + *

测试通过替换 {@link JpackageExecutor} 拦截真实进程调用,从而验证:

+ *
    + *
  • 命令行组装结果(含平台自适应与 UTF-8 参数);
  • + *
  • 输入目录隔离与依赖拷贝行为;
  • + *
  • Main-Class 智能探测与用户配置的优先级;
  • + *
  • 启动脚本生成、临时目录清理与错误处理。
  • + *
+ */ +class PackageAppMojoTest { + + @TempDir + Path tempDir; + + private PackageAppMojo mojo; + private Path targetDir; + private Path distDir; + private Path inputDir; + private Path fakeJpackage; + + /** 记录最近一次执行的命令行。 */ + private final List lastCommand = new ArrayList<>(); + + @BeforeEach + void setUp() throws IOException { + targetDir = tempDir.resolve("target"); + distDir = targetDir.resolve("dist"); + inputDir = targetDir.resolve("jpackage-input"); + Files.createDirectories(targetDir); + fakeJpackage = createFakeExecutable(tempDir.resolve("jdk").resolve("bin"), "jpackage"); + + mojo = new PackageAppMojo(); + mojo.project = createProject("demo-app", "1.0.0-SNAPSHOT"); + mojo.buildDirectory = targetDir.toFile(); + mojo.finalName = "demo-app-1.0.0-SNAPSHOT"; + mojo.destinationDirectory = distDir.toFile(); + mojo.inputDirectory = inputDir.toFile(); + mojo.jpackageExecutable = fakeJpackage.toString(); + mojo.platformOverride = Platform.LINUX; + mojo.environment = new PackageAppMojo.ExecutionEnvironment( + fakeJpackage.getParent().getParent().toString(), null); + mojo.cleanInputDirectory = false; + } + + @Test + @DisplayName("跳过开关生效且不产生任何副作用") + void shouldSkipWhenRequested() throws Exception { + mojo.skip = true; + mojo.executor = (command, workingDir, environment, sink) -> { + throw new AssertionError("skip=true 时不应启动任何进程"); + }; + + mojo.execute(); + + assertFalse(Files.exists(distDir)); + } + + @Test + @DisplayName("零配置打包:自动探测 Main-Class 并生成完整产物") + void shouldPackageWithZeroConfiguration() throws Exception { + Path mainJar = createMainJar("com.example.demo.Application"); + mojo.executor = successfulExecutor("demo-app"); + + mojo.execute(); + + // 命令行断言 + assertTrue(lastCommand.get(0).endsWith("jpackage"), "首个参数应为 jpackage 可执行文件"); + assertTrue(containsPair(lastCommand, "--main-class", "com.example.demo.Application"), + "应从清单探测到主类:" + lastCommand); + assertTrue(containsPair(lastCommand, "--name", "demo-app")); + assertTrue(containsPair(lastCommand, "--app-version", "1.0.0"), "SNAPSHOT 应被清洗"); + assertTrue(containsPair(lastCommand, "--main-jar", mainJar.getFileName().toString())); + assertTrue(containsPair(lastCommand, "--input", inputDir.toAbsolutePath().normalize().toString())); + assertTrue(containsPair(lastCommand, "--dest", distDir.toAbsolutePath().normalize().toString())); + assertTrue(containsPair(lastCommand, "--type", "app-image")); + + // 平台参数断言 + assertFalse(lastCommand.contains("--win-console"), "Linux 目标平台不得传递 --win-console"); + + // 产物断言 + Path appImage = distDir.resolve("demo-app"); + assertTrue(Files.isRegularFile(appImage.resolve("run.sh")), "应生成 run.sh"); + assertTrue(Files.isRegularFile(appImage.resolve("demo-app.desktop")), "应生成桌面入口"); + } + + @Test + @DisplayName("UTF-8 参数默认注入,并在用户显式覆盖时位于其后") + void shouldInjectUtf8AndRespectUserOverride() throws Exception { + createMainJar("com.example.Main"); + mojo.javaOptions = List.of("-Xmx512m", "-Dfile.encoding=GBK"); + mojo.executor = successfulExecutor("demo-app"); + + mojo.execute(); + + assertTrue(containsPair(lastCommand, "--java-options", "-Dfile.encoding=UTF-8")); + assertTrue(containsPair(lastCommand, "--java-options", "-Dstdout.encoding=UTF-8")); + assertTrue(containsPair(lastCommand, "--java-options", "-Dstderr.encoding=UTF-8")); + assertTrue(containsPair(lastCommand, "--java-options", "-Dsun.stdout.encoding=UTF-8")); + assertTrue(containsPair(lastCommand, "--java-options", "-Dsun.stderr.encoding=UTF-8")); + assertTrue(containsPair(lastCommand, "--java-options", "-Xmx512m")); + + int injected = indexOfPair(lastCommand, "--java-options", "-Dfile.encoding=UTF-8"); + int overridden = indexOfPair(lastCommand, "--java-options", "-Dfile.encoding=GBK"); + assertTrue(overridden > injected, "用户覆盖项必须排在默认注入项之后"); + } + + @Test + @DisplayName("显式配置的 mainClass 优先于清单探测结果") + void shouldPreferConfiguredMainClass() throws Exception { + createMainJar("com.example.ManifestMain"); + mojo.mainClass = "com.example.ConfiguredMain"; + mojo.executor = successfulExecutor("demo-app"); + + mojo.execute(); + + assertTrue(containsPair(lastCommand, "--main-class", "com.example.ConfiguredMain")); + assertFalse(lastCommand.contains("com.example.ManifestMain")); + } + + @Test + @DisplayName("appName 与 finalName 可被覆盖") + void shouldRespectCustomNames() throws Exception { + createJar(targetDir.resolve("custom.jar"), "com.example.Main", null); + mojo.mainJar = "custom.jar"; + mojo.appName = "MyTool"; + mojo.finalName = "custom"; + mojo.executor = successfulExecutor("MyTool"); + + mojo.execute(); + + assertTrue(containsPair(lastCommand, "--main-jar", "custom.jar")); + assertTrue(containsPair(lastCommand, "--name", "MyTool")); + assertTrue(Files.isRegularFile(distDir.resolve("MyTool").resolve("run.sh"))); + } + + @Test + @DisplayName("输入目录与 target 根目录隔离,编译产物不会被误打包") + void shouldIsolateInputDirectory() throws Exception { + createMainJar("com.example.Main"); + // 模拟 target 根目录下的编译污染 + Files.createDirectories(targetDir.resolve("classes").resolve("com/example")); + Files.writeString(targetDir.resolve("classes").resolve("com/example/Main.class"), "bytecode"); + Files.createDirectories(targetDir.resolve("maven-status")); + Files.writeString(targetDir.resolve("maven-status").resolve("created.txt"), "x"); + Files.writeString(targetDir.resolve("surefire-reports.txt"), "x"); + mojo.executor = successfulExecutor("demo-app"); + + mojo.execute(); + + // 输入目录在打包期间应只包含 JAR,不应包含 classes/ 等目录 + assertFalse(Files.exists(inputDir.resolve("classes")), "编译输出目录不得进入输入目录"); + assertFalse(Files.exists(inputDir.resolve("maven-status")), "maven-status 不得进入输入目录"); + assertFalse(Files.exists(inputDir.resolve("surefire-reports.txt")), "报告文件不得进入输入目录"); + } + + @Test + @DisplayName("运行时依赖被拷贝进输入目录并作为工作目录传入") + void shouldCopyRuntimeDependencies() throws Exception { + createMainJar("com.example.Main"); + Path dependency = tempDir.resolve("lib").resolve("commons-lang3-3.14.0.jar"); + Files.createDirectories(dependency.getParent()); + Files.writeString(dependency, "dep"); + + mojo.project.setArtifacts(new LinkedHashSet<>(List.of( + artifact("commons-lang3", "compile", dependency), + artifact("test-only", "test", writeFile(tempDir.resolve("test-only.jar")))))); + mojo.executor = successfulExecutor("demo-app"); + + final Path[] workingDirectory = new Path[1]; + JpackageExecutor capturing = (command, workDir, environment, sink) -> { + workingDirectory[0] = workDir; + return successfulExecutor("demo-app").execute(command, workDir, environment, sink); + }; + mojo.executor = capturing; + + mojo.execute(); + + assertTrue(Files.isRegularFile(inputDir.resolve("commons-lang3-3.14.0.jar")), + "compile 作用域依赖应被拷贝"); + assertFalse(Files.exists(inputDir.resolve("test-only.jar")), "test 作用域依赖不应被拷贝"); + assertEquals(inputDir.toAbsolutePath().normalize(), workingDirectory[0], + "jpackage 的工作目录应为输入目录"); + } + + @Test + @DisplayName("includeDependencies=false 时不拷贝任何依赖") + void shouldAllowSkippingDependencies() throws Exception { + createMainJar("com.example.Main"); + Path dependency = writeFile(tempDir.resolve("lib").resolve("extra.jar")); + mojo.project.setArtifacts(new LinkedHashSet<>(List.of(artifact("extra", "compile", dependency)))); + mojo.includeDependencies = false; + mojo.executor = successfulExecutor("demo-app"); + + mojo.execute(); + + assertFalse(Files.exists(inputDir.resolve("extra.jar"))); + } + + @Test + @DisplayName("清单中的 Class-Path 本地依赖会被解析并拷贝") + void shouldCopyClassPathEntries() throws Exception { + Path mainJar = createJar(targetDir.resolve("demo-app-1.0.0-SNAPSHOT.jar"), "com.example.Main", + "lib/helper.jar http://example.com/remote.jar"); + Path helper = targetDir.resolve("lib").resolve("helper.jar"); + Files.createDirectories(helper.getParent()); + Files.writeString(helper, "helper"); + mojo.executor = successfulExecutor("demo-app"); + + mojo.execute(); + + assertTrue(Files.isRegularFile(inputDir.resolve("helper.jar")), + "本地 Class-Path 条目应被拷贝:" + mainJar); + } + + @Test + @DisplayName("cleanInputDirectory=true 时打包结束后清理临时目录") + void shouldCleanInputDirectory() throws Exception { + createMainJar("com.example.Main"); + mojo.cleanInputDirectory = true; + mojo.executor = successfulExecutor("demo-app"); + + mojo.execute(); + + assertFalse(Files.exists(inputDir), "临时输入目录应被删除"); + } + + @Test + @DisplayName("Windows 目标平台生成 run.bat 并附加 --win-console") + void shouldGenerateWindowsLauncher() throws Exception { + createMainJar("com.example.Main"); + mojo.platformOverride = Platform.WINDOWS; + mojo.cleanInputDirectory = true; + mojo.executor = successfulExecutor("demo-app"); + + mojo.execute(); + + assertTrue(lastCommand.contains("--win-console"), "Windows 平台应附加 --win-console"); + Path appImage = distDir.resolve("demo-app"); + assertTrue(Files.isRegularFile(appImage.resolve("run.bat")), "应生成 run.bat"); + assertFalse(Files.exists(appImage.resolve("run.sh")), "Windows 平台不应生成 run.sh"); + assertTrue(Files.readString(appImage.resolve("run.bat")).contains("chcp 65001")); + } + + @Test + @DisplayName("winConsole=false 时不附加 --win-console") + void shouldRespectWinConsoleFlag() throws Exception { + createMainJar("com.example.Main"); + mojo.platformOverride = Platform.WINDOWS; + mojo.winConsole = false; + mojo.executor = successfulExecutor("demo-app"); + + mojo.execute(); + + assertFalse(lastCommand.contains("--win-console")); + } + + @Test + @DisplayName("关闭脚本生成时不产生 run.sh / run.bat") + void shouldAllowDisablingLauncherScript() throws Exception { + createMainJar("com.example.Main"); + mojo.generateLauncherScript = false; + mojo.executor = successfulExecutor("demo-app"); + + mojo.execute(); + + assertFalse(Files.exists(distDir.resolve("demo-app").resolve("run.sh"))); + } + + @Test + @DisplayName("追加的原始参数按序透传") + void shouldForwardAdditionalArguments() throws Exception { + createMainJar("com.example.Main"); + mojo.additionalArguments = List.of("--resource-dir", "/opt/resources"); + mojo.executor = successfulExecutor("demo-app"); + + mojo.execute(); + + assertTrue(containsPair(lastCommand, "--resource-dir", "/opt/resources")); + } + + @Test + @DisplayName("不存在的图标被忽略而不中断构建") + void shouldIgnoreMissingIcon() throws Exception { + createMainJar("com.example.Main"); + mojo.icon = tempDir.resolve("does-not-exist.png").toFile(); + mojo.executor = successfulExecutor("demo-app"); + + mojo.execute(); + + assertFalse(lastCommand.contains("--icon")); + } + + @Test + @DisplayName("存在的图标被传递给 jpackage") + void shouldPassExistingIcon() throws Exception { + createMainJar("com.example.Main"); + Path icon = writeFile(tempDir.resolve("app.png")); + mojo.icon = icon.toFile(); + mojo.executor = successfulExecutor("demo-app"); + + mojo.execute(); + + assertTrue(containsPair(lastCommand, "--icon", icon.toAbsolutePath().normalize().toString())); + } + + @Test + @DisplayName("主 JAR 缺失时给出可操作的错误信息") + void shouldFailWhenMainJarMissing() { + mojo.executor = successfulExecutor("demo-app"); + + MojoExecutionException exception = assertThrows(MojoExecutionException.class, () -> mojo.execute()); + + assertTrue(exception.getMessage().contains("未找到主 JAR 包"), exception.getMessage()); + } + + @Test + @DisplayName("jpackage 非零退出时抛出异常并输出末尾日志") + void shouldFailOnNonZeroExitCode() throws Exception { + createMainJar("com.example.Main"); + mojo.executor = (command, workingDir, environment, sink) -> { + sink.accept("Error: Invalid version: 1.0.0-SNAPSHOT"); + return 1; + }; + + MojoExecutionException exception = assertThrows(MojoExecutionException.class, () -> mojo.execute()); + + assertTrue(exception.getMessage().contains("退出码 1"), exception.getMessage()); + assertFalse(Files.exists(distDir.resolve("demo-app")), "失败时不应生成应用镜像"); + } + + @Test + @DisplayName("jpackage 成功但产物缺失时给出明确错误") + void shouldFailWhenAppImageMissing() throws Exception { + createMainJar("com.example.Main"); + mojo.executor = (command, workingDir, environment, sink) -> 0; // 不创建任何产物 + + MojoExecutionException exception = assertThrows(MojoExecutionException.class, () -> mojo.execute()); + + assertTrue(exception.getMessage().contains("未找到预期的应用镜像目录"), exception.getMessage()); + } + + @Test + @DisplayName("进程启动失败时错误信息包含原始原因") + void shouldWrapIoException() throws Exception { + createMainJar("com.example.Main"); + mojo.executor = (command, workingDir, environment, sink) -> { + throw new IOException("cannot fork"); + }; + + MojoExecutionException exception = assertThrows(MojoExecutionException.class, () -> mojo.execute()); + + assertTrue(exception.getMessage().contains("启动 jpackage 进程失败"), exception.getMessage()); + assertTrue(exception.getMessage().contains("cannot fork")); + } + + @Test + @DisplayName("jpackageExecutable 配置失效时给出明确错误") + void shouldFailWhenJpackageExecutableInvalid() throws Exception { + createMainJar("com.example.Main"); + mojo.jpackageExecutable = tempDir.resolve("nope").resolve("jpackage").toString(); + mojo.executor = successfulExecutor("demo-app"); + + MojoExecutionException exception = assertThrows(MojoExecutionException.class, () -> mojo.execute()); + + assertTrue(exception.getMessage().contains("无法定位 jpackage"), exception.getMessage()); + } + + @Test + @DisplayName("启用 Wine 但未配置 windowsJdkHome 时快速失败") + void shouldValidateWinePrerequisites() throws Exception { + createMainJar("com.example.Main"); + mojo.useWine = true; + mojo.windowsJdkHome = null; + mojo.executor = successfulExecutor("demo-app"); + + MojoExecutionException exception = assertThrows(MojoExecutionException.class, () -> mojo.execute()); + + assertTrue(exception.getMessage().contains("windowsJdkHome"), exception.getMessage()); + } + + @Test + @DisplayName("启用 Wine 且目录下无 jpackage.exe 时快速失败") + void shouldFailWhenWineJdkIncomplete() throws Exception { + createMainJar("com.example.Main"); + mojo.useWine = true; + mojo.windowsJdkHome = tempDir.resolve("windows-jdk").toString(); + Files.createDirectories(Path.of(mojo.windowsJdkHome).resolve("bin")); + mojo.executor = successfulExecutor("demo-app"); + + assertThrows(MojoExecutionException.class, () -> mojo.execute()); + } + + @Test + @DisplayName("清单缺失 Main-Class 时仍可完成打包(交由 jpackage 自行判定)") + void shouldTolerateMissingMainClass() throws Exception { + createMainJar(null); + mojo.executor = successfulExecutor("demo-app"); + + mojo.execute(); + + assertFalse(lastCommand.contains("--main-class")); + assertTrue(Files.isRegularFile(distDir.resolve("demo-app").resolve("run.sh"))); + } + + // ---- 测试辅助 --------------------------------------------------------- + + /** + * 创建携带指定 Main-Class 的主 JAR。 + * + * @param mainClass 主类名,{@code null} 表示不写入 + * @return 主 JAR 路径 + * @throws IOException 写入失败 + */ + private Path createMainJar(String mainClass) throws IOException { + return createJar(targetDir.resolve("demo-app-1.0.0-SNAPSHOT.jar"), mainClass, null); + } + + /** + * 创建 JAR 文件。 + * + * @param jar 目标路径 + * @param mainClass {@code Main-Class},可为 {@code null} + * @param classPath {@code Class-Path},可为 {@code null} + * @return JAR 路径 + * @throws IOException 写入失败 + */ + private Path createJar(Path jar, String mainClass, String classPath) throws IOException { + JarManifestInspectorTest.writeJar(jar, mainClass, classPath); + return jar; + } + + /** + * 构造一个会成功创建应用镜像目录的假执行器。 + * + * @param appName 应用名称,用于推导镜像目录 + * @return 执行器 + */ + private JpackageExecutor successfulExecutor(String appName) { + return (command, workingDir, environment, sink) -> { + lastCommand.clear(); + lastCommand.addAll(command); + Path appImage = distDir.resolve(appName); + try { + Files.createDirectories(appImage.resolve("bin")); + Files.writeString(appImage.resolve("bin").resolve(appName), "fake-binary"); + Files.createDirectories(appImage.resolve("lib")); + Files.writeString(appImage.resolve("lib").resolve("app.jar"), "fake"); + if (mojo.platformOverride == Platform.WINDOWS) { + Files.move(appImage.resolve("bin").resolve(appName), + appImage.resolve("bin").resolve(appName + ".exe")); + } + } catch (IOException e) { + throw new IllegalStateException(e); + } + sink.accept("jpackage: 打包完成"); + return 0; + }; + } + + /** + * 构造一个带下划线的 MavenProject。 + * + * @param artifactId 制品 ID + * @param version 版本 + * @return 项目对象 + */ + private MavenProject createProject(String artifactId, String version) { + MavenProject project = new MavenProject(); + project.setArtifactId(artifactId); + project.setVersion(version); + project.setGroupId("com.example"); + project.setArtifacts(Collections.emptySet()); + return project; + } + + /** + * 构造一个 Maven 依赖坐标对象。 + * + * @param artifactId 依赖名 + * @param scope 作用域 + * @param file 依赖文件 + * @return 依赖对象 + */ + private Artifact artifact(String artifactId, String scope, Path file) { + Artifact artifact = new DefaultArtifact("com.example", artifactId, "1.0", + scope, "jar", null, new DefaultArtifactHandler("jar")); + artifact.setFile(file.toFile()); + return artifact; + } + + /** + * 写出一个占位文件并返回路径。 + * + * @param path 目标路径 + * @return 文件路径 + * @throws IOException 写入失败 + */ + private Path writeFile(Path path) throws IOException { + Files.createDirectories(path.getParent()); + Files.writeString(path, "x"); + return path; + } + + /** + * 创建一个具备可执行权限的假 jpackage 脚本。 + * + * @param binDir bin 目录 + * @param name 文件名 + * @return 脚本路径 + * @throws IOException 写入失败 + */ + private Path createFakeExecutable(Path binDir, String name) throws IOException { + Files.createDirectories(binDir); + Path executable = binDir.resolve(name); + Files.writeString(executable, "#!/bin/sh\nexit 0\n"); + try { + Files.setPosixFilePermissions(executable, PosixFilePermissions.fromString("rwxr-xr-x")); + } catch (UnsupportedOperationException e) { + executable.toFile().setExecutable(true); + } + Assumptions.assumeTrue(Files.isExecutable(executable) || Platform.current().isWindows(), + "当前文件系统无法设置可执行权限"); + return executable.toAbsolutePath().normalize(); + } + + private static boolean containsPair(List args, String flag, String value) { + return indexOfPair(args, flag, value) >= 0; + } + + private static int indexOfPair(List args, String flag, String value) { + for (int i = 0; i < args.size() - 1; i++) { + if (flag.equals(args.get(i)) && value.equals(args.get(i + 1))) { + return i; + } + } + return -1; + } +} diff --git a/src/test/java/com/mirenqinggege/maven/jpackage/internal/FileTreeUtilsTest.java b/src/test/java/com/mirenqinggege/maven/jpackage/internal/FileTreeUtilsTest.java new file mode 100644 index 0000000..4fefdaf --- /dev/null +++ b/src/test/java/com/mirenqinggege/maven/jpackage/internal/FileTreeUtilsTest.java @@ -0,0 +1,183 @@ +package com.mirenqinggege.maven.jpackage.internal; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.EnumSet; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link FileTreeUtils} 与 {@link FilePermissions} 的单元测试。 + */ +class FileTreeUtilsTest { + + @Test + @DisplayName("递归删除目录及其全部内容") + void shouldDeleteRecursively(@TempDir Path tempDir) throws IOException { + Path root = tempDir.resolve("a").resolve("b").resolve("c"); + Files.createDirectories(root); + Files.writeString(root.resolve("file.txt"), "x"); + + FileTreeUtils.deleteRecursively(tempDir.resolve("a")); + + assertFalse(Files.exists(tempDir.resolve("a"))); + } + + @Test + @DisplayName("删除不存在的路径是安全的空操作") + void shouldIgnoreMissingPath() throws IOException { + FileTreeUtils.deleteRecursively(null); + FileTreeUtils.deleteRecursively(Path.of("/definitely/not/here/xyz")); + } + + @Test + @DisplayName("cleanDirectory 清空内容并保留目录自身") + void shouldCleanDirectory(@TempDir Path tempDir) throws IOException { + Path directory = tempDir.resolve("input"); + Files.createDirectories(directory.resolve("nested")); + Files.writeString(directory.resolve("a.jar"), "a"); + Files.writeString(directory.resolve("nested").resolve("b.jar"), "b"); + + FileTreeUtils.cleanDirectory(directory); + + assertTrue(Files.isDirectory(directory), "目录本身应保留"); + assertEquals(0, FileTreeUtils.countFiles(directory)); + } + + @Test + @DisplayName("cleanDirectory 在目录不存在时创建它") + void shouldCreateDirectoryWhenMissing(@TempDir Path tempDir) throws IOException { + Path directory = tempDir.resolve("brand-new"); + + FileTreeUtils.cleanDirectory(directory); + + assertTrue(Files.isDirectory(directory)); + } + + @Test + @DisplayName("拷贝文件时自动创建父目录并覆盖已存在文件") + void shouldCopyAndOverwrite(@TempDir Path tempDir) throws IOException { + Path source = tempDir.resolve("source.jar"); + Files.writeString(source, "new-content"); + Path target = tempDir.resolve("deeper").resolve("dir").resolve("target.jar"); + Files.createDirectories(target.getParent()); + Files.writeString(target, "old-content"); + + FileTreeUtils.copyFile(source, target); + + assertEquals("new-content", Files.readString(target)); + } + + @Test + @DisplayName("writeString 以 UTF-8 写入中文内容") + void shouldWriteUtf8Content(@TempDir Path tempDir) throws IOException { + Path target = tempDir.resolve("nested").resolve("run.sh"); + + FileTreeUtils.writeString(target, "echo 你好,世界\n"); + + assertEquals("echo 你好,世界\n", Files.readString(target, java.nio.charset.StandardCharsets.UTF_8)); + } + + @Test + @DisplayName("countFiles 只统计常规文件") + void shouldCountRegularFiles(@TempDir Path tempDir) throws IOException { + Files.createDirectories(tempDir.resolve("bin")); + Files.writeString(tempDir.resolve("bin").resolve("app"), "x"); + Files.createDirectories(tempDir.resolve("lib")); + Files.writeString(tempDir.resolve("lib").resolve("app.jar"), "x"); + + assertEquals(2, FileTreeUtils.countFiles(tempDir)); + assertEquals(0, FileTreeUtils.countFiles(tempDir.resolve("missing"))); + assertEquals(0, FileTreeUtils.countFiles(null)); + } + + @Test + @DisplayName("relativize 输出统一使用正斜杠") + void shouldRelativizePath(@TempDir Path tempDir) { + String relative = FileTreeUtils.relativize(tempDir, tempDir.resolve("dist").resolve("app")); + + assertEquals("dist/app", relative); + } + + @Test + @DisplayName("listSorted 按名称稳定排序") + void shouldListSorted(@TempDir Path tempDir) throws IOException { + Files.writeString(tempDir.resolve("c.jar"), ""); + Files.writeString(tempDir.resolve("a.jar"), ""); + Files.writeString(tempDir.resolve("b.jar"), ""); + + List sorted = FileTreeUtils.listSorted(tempDir); + + assertEquals(List.of("a.jar", "b.jar", "c.jar"), + sorted.stream().map(path -> path.getFileName().toString()).toList()); + assertTrue(FileTreeUtils.listSorted(tempDir.resolve("missing")).isEmpty()); + } + + @Test + @DisplayName("makeExecutable 赋予 755 权限") + void shouldMakeExecutable(@TempDir Path tempDir) throws IOException { + Path script = tempDir.resolve("run.sh"); + Files.writeString(script, "#!/bin/sh\n"); + + boolean applied = FilePermissions.makeExecutable(script); + + if (FilePermissions.supportsPosix(script)) { + assertTrue(applied); + assertTrue(Files.isExecutable(script)); + } + } + + @Test + @DisplayName("对不存在的文件设置权限返回 false 而不抛异常") + void shouldHandleMissingFileForPermissions(@TempDir Path tempDir) { + assertFalse(FilePermissions.makeExecutable(tempDir.resolve("missing.sh"))); + assertFalse(FilePermissions.makeExecutable(null)); + } + + @Test + @DisplayName("权限常量内容符合预期") + void shouldExposePermissionSets() { + assertEquals(EnumSet.of( + PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, + PosixFilePermission.GROUP_READ, PosixFilePermission.GROUP_EXECUTE, + PosixFilePermission.OTHERS_READ, PosixFilePermission.OTHERS_EXECUTE), + FilePermissions.EXECUTABLE_PERMISSIONS); + assertEquals(EnumSet.of( + PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, + PosixFilePermission.GROUP_READ, PosixFilePermission.OTHERS_READ), + FilePermissions.READABLE_PERMISSIONS); + assertFalse(FilePermissions.READABLE_PERMISSIONS.contains(PosixFilePermission.OWNER_EXECUTE)); + } + + @Test + @DisplayName("deleteRecursively 也能直接删除单个文件") + void shouldDeleteSingleFile(@TempDir Path tempDir) throws IOException { + Path file = tempDir.resolve("single.txt"); + Files.writeString(file, "x"); + + FileTreeUtils.deleteRecursively(file); + + assertFalse(Files.exists(file)); + } + + @Test + @DisplayName("cleanDirectory 对无法创建目录的路径抛出 IOException") + void shouldFailWhenDirectoryCannotBeCreated(@TempDir Path tempDir) throws IOException { + Path file = tempDir.resolve("plain-file.txt"); + Files.writeString(file, "x"); + + // 以文件路径作为父目录时,创建子目录必然失败 + assertThrows(IOException.class, () -> FileTreeUtils.cleanDirectory(file.resolve("child"))); + } +} diff --git a/src/test/java/com/mirenqinggege/maven/jpackage/internal/JarManifestInspectorTest.java b/src/test/java/com/mirenqinggege/maven/jpackage/internal/JarManifestInspectorTest.java new file mode 100644 index 0000000..6770881 --- /dev/null +++ b/src/test/java/com/mirenqinggege/maven/jpackage/internal/JarManifestInspectorTest.java @@ -0,0 +1,137 @@ +package com.mirenqinggege.maven.jpackage.internal; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.jar.Attributes; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link JarManifestInspector} 的单元测试。 + */ +public class JarManifestInspectorTest { + + @TempDir + Path tempDir; + + @Test + @DisplayName("从 JAR 清单中读取 Main-Class") + void shouldReadMainClass(@TempDir Path tempDir) throws IOException { + Path jar = tempDir.resolve("app.jar"); + writeJar(jar, "com.example.cli.Main", null); + + Optional mainClass = JarManifestInspector.findMainClass(jar); + assertTrue(mainClass.isPresent()); + assertEquals("com.example.cli.Main", mainClass.get()); + } + + @Test + @DisplayName("Main-Class 两侧的空白会被去除") + void shouldTrimMainClass(@TempDir Path tempDir) throws IOException { + Path jar = tempDir.resolve("app.jar"); + writeJar(jar, " com.example.Main ", null); + + assertEquals(Optional.of("com.example.Main"), JarManifestInspector.findMainClass(jar)); + } + + @Test + @DisplayName("清单不含 Main-Class 时返回空") + void shouldReturnEmptyWhenMainClassAbsent(@TempDir Path tempDir) throws IOException { + Path jar = tempDir.resolve("lib.jar"); + writeJar(jar, null, "lib/a.jar lib/b.jar"); + + assertTrue(JarManifestInspector.findMainClass(jar).isEmpty()); + } + + @Test + @DisplayName("解析 Class-Path 为条目列表") + void shouldReadClassPath(@TempDir Path tempDir) throws IOException { + Path jar = tempDir.resolve("app.jar"); + writeJar(jar, "com.example.Main", " lib/a.jar lib/b.jar "); + + List classPath = JarManifestInspector.findClassPath(jar); + assertEquals(List.of("lib/a.jar", "lib/b.jar"), classPath); + } + + @Test + @DisplayName("清单不含 Class-Path 时返回空列表") + void shouldReturnEmptyClassPath(@TempDir Path tempDir) throws IOException { + Path jar = tempDir.resolve("app.jar"); + writeJar(jar, "com.example.Main", null); + + assertTrue(JarManifestInspector.findClassPath(jar).isEmpty()); + } + + @Test + @DisplayName("JAR 文件不存在时安全返回") + void shouldHandleMissingJar() throws IOException { + Path missing = tempDir.resolve("not-there.jar"); + assertTrue(JarManifestInspector.findMainClass(missing).isEmpty()); + assertTrue(JarManifestInspector.findClassPath(missing).isEmpty()); + assertTrue(JarManifestInspector.readManifest(null).isEmpty()); + } + + @Test + @DisplayName("支持读取以目录形式展开的 JAR") + void shouldReadExpandedJar(@TempDir Path tempDir) throws IOException { + Path expanded = tempDir.resolve("expanded"); + Path manifestDir = expanded.resolve("META-INF"); + Files.createDirectories(manifestDir); + + Manifest manifest = new Manifest(); + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + manifest.getMainAttributes().put(Attributes.Name.MAIN_CLASS, "com.example.Expanded"); + try (OutputStream out = Files.newOutputStream(manifestDir.resolve("MANIFEST.MF"))) { + manifest.write(out); + } + + assertEquals(Optional.of("com.example.Expanded"), + JarManifestInspector.findMainClass(expanded)); + } + + @Test + @DisplayName("识别 Class-Path 中的远程 URL") + void shouldDetectAbsoluteUrls() { + assertTrue(JarManifestInspector.isAbsoluteUrl("http://example.com/a.jar")); + assertTrue(JarManifestInspector.isAbsoluteUrl("HTTPS://example.com/a.jar")); + assertTrue(JarManifestInspector.isAbsoluteUrl("file:/opt/lib/a.jar")); + assertFalse(JarManifestInspector.isAbsoluteUrl("lib/a.jar")); + assertFalse(JarManifestInspector.isAbsoluteUrl(null)); + } + + /** + * 写出一个含指定清单条目的 JAR 文件。 + * + * @param jar 目标路径 + * @param mainClass {@code Main-Class} 值,{@code null} 表示不写入 + * @param classPath {@code Class-Path} 值,{@code null} 表示不写入 + * @throws IOException 写入失败 + */ + public static void writeJar(Path jar, String mainClass, String classPath) throws IOException { + Manifest manifest = new Manifest(); + Attributes attributes = manifest.getMainAttributes(); + attributes.put(Attributes.Name.MANIFEST_VERSION, "1.0"); + if (mainClass != null) { + attributes.put(Attributes.Name.MAIN_CLASS, mainClass); + } + if (classPath != null) { + attributes.put(Attributes.Name.CLASS_PATH, classPath); + } + try (JarOutputStream out = new JarOutputStream(Files.newOutputStream(jar), manifest)) { + // 只为写出清单,无需额外条目 + out.finish(); + } + } +} diff --git a/src/test/java/com/mirenqinggege/maven/jpackage/internal/JdkToolLocatorTest.java b/src/test/java/com/mirenqinggege/maven/jpackage/internal/JdkToolLocatorTest.java new file mode 100644 index 0000000..bc649db --- /dev/null +++ b/src/test/java/com/mirenqinggege/maven/jpackage/internal/JdkToolLocatorTest.java @@ -0,0 +1,131 @@ +package com.mirenqinggege.maven.jpackage.internal; + +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link JdkToolLocator} 的单元测试。 + */ +class JdkToolLocatorTest { + + @Test + @DisplayName("解析 JDK 主版本号") + void shouldParseFeatureVersion() { + assertEquals(21, JdkToolLocator.parseFeatureVersion("21.0.9", "21")); + assertEquals(17, JdkToolLocator.parseFeatureVersion("17.0.10", "17")); + assertEquals(8, JdkToolLocator.parseFeatureVersion("1.8.0_392", "1.8")); + assertEquals(11, JdkToolLocator.parseFeatureVersion("11.0.2", null)); + assertEquals(22, JdkToolLocator.parseFeatureVersion(null, "22")); + } + + @Test + @DisplayName("无法解析的版本号返回 0") + void shouldReturnZeroForUnparsableVersion() { + assertEquals(0, JdkToolLocator.parseFeatureVersion(null, null)); + assertEquals(0, JdkToolLocator.parseFeatureVersion("", "")); + assertEquals(0, JdkToolLocator.parseFeatureVersion("unknown", null)); + } + + @Test + @DisplayName("JDK 目录优先,并自动剥离 JRE 后缀") + void shouldCollectCandidateJavaHomes(@TempDir Path tempDir) throws IOException { + Path jdk = tempDir.resolve("jdk-21"); + Files.createDirectories(jdk.resolve("bin")); + Path jre = jdk.resolve("jre"); + Files.createDirectories(jre.resolve("bin")); + + List homes = JdkToolLocator.candidateJavaHomes(jre.toString(), jdk.toString()); + + assertTrue(homes.contains(jre), "应保留原始 java.home"); + assertTrue(homes.contains(jdk), "应剥离 /jre 后缀得到真正的 JDK 根目录"); + assertEquals(2, homes.size(), "候选目录应去重后仅两个"); + } + + @Test + @DisplayName("按 ${java.home}/bin 定位 jpackage") + void shouldLocateJpackageInJavaHome(@TempDir Path tempDir) throws IOException { + Path jdk = tempDir.resolve("jdk-21"); + Path executable = createFakeJpackage(jdk.resolve("bin"), "jpackage"); + Assumptions.assumeTrue(Files.isExecutable(executable), "当前文件系统无法设置可执行权限"); + + Optional located = JdkToolLocator.locate(Platform.LINUX, null, jdk.toString(), null); + + assertEquals(Optional.of(executable), located); + } + + @Test + @DisplayName("显式配置目录时自动拼接可执行文件名") + void shouldResolveExplicitDirectory(@TempDir Path tempDir) throws IOException { + Path jdk = tempDir.resolve("custom-jdk"); + Path executable = createFakeJpackage(jdk.resolve("bin"), "jpackage"); + Assumptions.assumeTrue(Files.isExecutable(executable), "当前文件系统无法设置可执行权限"); + + assertEquals(Optional.of(executable), + JdkToolLocator.locate(Platform.LINUX, jdk.toString(), null, null)); + } + + @Test + @DisplayName("显式配置错误路径时不再回退到系统 JDK") + void shouldFailFastOnBadExplicitPath(@TempDir Path tempDir) { + Path missing = tempDir.resolve("nowhere").resolve("jpackage"); + + assertTrue(JdkToolLocator.locate(Platform.LINUX, missing.toString(), + System.getProperty("java.home"), System.getenv("JAVA_HOME")).isEmpty(), + "显式配置失效时应报错而不是静默回退"); + } + + @Test + @DisplayName("Windows 目标平台查找 jpackage.exe") + void shouldLookForWindowsExecutable(@TempDir Path tempDir) throws IOException { + Path jdk = tempDir.resolve("windows-jdk"); + Path executable = createFakeJpackage(jdk.resolve("bin"), "jpackage.exe"); + + Optional located = JdkToolLocator.locate(Platform.WINDOWS, null, jdk.toString(), null); + + assertEquals(Optional.of(executable), located); + } + + @Test + @DisplayName("目录而非文件不算命中") + void shouldRejectDirectories(@TempDir Path tempDir) throws IOException { + Path bin = tempDir.resolve("jdk").resolve("bin"); + Files.createDirectories(bin.resolve("jpackage")); + + assertFalse(JdkToolLocator.isUsable(bin.resolve("jpackage"))); + assertFalse(JdkToolLocator.isUsable(null)); + assertFalse(JdkToolLocator.isUsable(bin.resolve("missing"))); + } + + /** + * 创建一个伪造的 jpackage 可执行文件。 + * + * @param binDir 目标 bin 目录 + * @param name 文件名 + * @return 可执行文件路径 + * @throws IOException 写入失败 + */ + private static Path createFakeJpackage(Path binDir, String name) throws IOException { + Files.createDirectories(binDir); + Path executable = binDir.resolve(name); + Files.writeString(executable, "#!/bin/sh\necho fake-jpackage\n"); + try { + Files.setPosixFilePermissions(executable, PosixFilePermissions.fromString("rwxr-xr-x")); + } catch (UnsupportedOperationException e) { + executable.toFile().setExecutable(true); + } + return executable.toAbsolutePath().normalize(); + } +} diff --git a/src/test/java/com/mirenqinggege/maven/jpackage/internal/JpackageOptionsTest.java b/src/test/java/com/mirenqinggege/maven/jpackage/internal/JpackageOptionsTest.java new file mode 100644 index 0000000..3a34f31 --- /dev/null +++ b/src/test/java/com/mirenqinggege/maven/jpackage/internal/JpackageOptionsTest.java @@ -0,0 +1,229 @@ +package com.mirenqinggege.maven.jpackage.internal; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link JpackageOptions} 的单元测试,重点覆盖命令行组装与平台自适应逻辑。 + */ +class JpackageOptionsTest { + + /** + * 构造一份必填项齐全的配置。 + * + * @return 配置实例 + */ + private JpackageOptions baseOptions() { + return new JpackageOptions() + .setName("demo-app") + .setAppVersion("1.0.0") + .setMainJar("demo-app-1.0.0.jar") + .setInputDirectory("/tmp/target/jpackage-input") + .setDestinationDirectory("/tmp/target/dist") + .setTargetPlatform(Platform.LINUX); + } + + @Test + @DisplayName("组装出包含全部必填项的命令行") + void shouldBuildRequiredArguments() { + List args = baseOptions().toCommandLineArguments(); + + assertTrue(args.containsAll(List.of( + "--type", "app-image", + "--name", "demo-app", + "--app-version", "1.0.0", + "--input", "/tmp/target/jpackage-input", + "--main-jar", "demo-app-1.0.0.jar", + "--dest", "/tmp/target/dist")), "命令行缺少必填参数:" + args); + assertEquals("--dest", args.get(args.size() - 2), "--dest 应位于参数末尾"); + } + + @Test + @DisplayName("Linux 平台绝不传递 --win-console") + void shouldNotPassWinConsoleOnLinux() { + List args = baseOptions() + .setWinConsole(true) + .setTargetPlatform(Platform.LINUX) + .toCommandLineArguments(); + + assertFalse(args.contains("--win-console"), "Linux 下传递 --win-console 会导致 jpackage 报错"); + } + + @Test + @DisplayName("macOS 平台绝不传递 --win-console") + void shouldNotPassWinConsoleOnMac() { + List args = baseOptions() + .setWinConsole(true) + .setTargetPlatform(Platform.MACOS) + .toCommandLineArguments(); + + assertFalse(args.contains("--win-console")); + } + + @Test + @DisplayName("Windows 平台在开启控制台模式时追加 --win-console") + void shouldPassWinConsoleOnWindows() { + List args = baseOptions() + .setWinConsole(true) + .setTargetPlatform(Platform.WINDOWS) + .toCommandLineArguments(); + + assertTrue(args.contains("--win-console")); + } + + @Test + @DisplayName("Windows 平台关闭控制台模式时不追加 --win-console") + void shouldRespectWinConsoleFlag() { + List args = baseOptions() + .setWinConsole(false) + .setTargetPlatform(Platform.WINDOWS) + .toCommandLineArguments(); + + assertFalse(args.contains("--win-console")); + } + + @Test + @DisplayName("默认注入全套 UTF-8 参数") + void shouldInjectUtf8Options() { + List args = baseOptions().toCommandLineArguments(); + + for (String option : JpackageOptions.UTF8_JAVA_OPTIONS) { + assertTrue(containsPair(args, "--java-options", option), "缺少 UTF-8 参数:" + option); + } + } + + @Test + @DisplayName("可通过配置关闭 UTF-8 参数注入") + void shouldAllowDisablingUtf8Injection() { + List args = baseOptions() + .setInjectUtf8Options(false) + .toCommandLineArguments(); + + for (String option : JpackageOptions.UTF8_JAVA_OPTIONS) { + assertFalse(containsPair(args, "--java-options", option)); + } + } + + @Test + @DisplayName("用户自定义 JVM 参数排在默认 UTF-8 参数之后") + void shouldAppendUserJavaOptionsAfterDefaults() { + List args = baseOptions() + .addJavaOptions(List.of("-Xmx512m", "-XX:+UseZGC")) + .toCommandLineArguments(); + + int utf8Index = indexOfPair(args, "--java-options", "-Dfile.encoding=UTF-8"); + int userIndex = indexOfPair(args, "--java-options", "-Xmx512m"); + int zgcIndex = indexOfPair(args, "--java-options", "-XX:+UseZGC"); + + assertTrue(utf8Index >= 0 && userIndex > utf8Index, "用户参数应位于默认参数之后"); + assertTrue(zgcIndex > userIndex, "用户参数之间应保持声明顺序"); + } + + @Test + @DisplayName("重复声明的参数不会被重复添加") + void shouldDeduplicateJavaOptions() { + List args = baseOptions() + .addJavaOption("-Dfile.encoding=UTF-8") + .toCommandLineArguments(); + + long count = 0; + for (int i = 0; i < args.size() - 1; i++) { + if ("--java-options".equals(args.get(i)) && "-Dfile.encoding=UTF-8".equals(args.get(i + 1))) { + count++; + } + } + assertEquals(1, count); + } + + @Test + @DisplayName("mainClass 为空时省略 --main-class") + void shouldOmitMainClassWhenBlank() { + assertFalse(baseOptions().setMainClass(null).toCommandLineArguments().contains("--main-class")); + assertFalse(baseOptions().setMainClass(" ").toCommandLineArguments().contains("--main-class")); + } + + @Test + @DisplayName("配置 mainClass 时附加 --main-class") + void shouldIncludeMainClass() { + List args = baseOptions().setMainClass("com.example.Main").toCommandLineArguments(); + assertTrue(containsPair(args, "--main-class", "com.example.Main")); + } + + @Test + @DisplayName("附加的元数据与图标参数按需出现") + void shouldIncludeOptionalMetadata() { + List args = baseOptions() + .setIcon("/tmp/icon.png") + .setVendor("Example Inc.") + .setDescription("一个示例应用") + .setCopyright("(c) 2026 Example") + .toCommandLineArguments(); + + assertTrue(containsPair(args, "--icon", "/tmp/icon.png")); + assertTrue(containsPair(args, "--vendor", "Example Inc.")); + assertTrue(containsPair(args, "--description", "一个示例应用")); + assertTrue(containsPair(args, "--copyright", "(c) 2026 Example")); + } + + @Test + @DisplayName("未配置的可选参数不会出现在命令行中") + void shouldSkipUnsetOptionalArguments() { + List args = baseOptions().toCommandLineArguments(); + assertFalse(args.contains("--icon")); + assertFalse(args.contains("--vendor")); + assertFalse(args.contains("--description")); + assertFalse(args.contains("--copyright")); + } + + @Test + @DisplayName("原始参数被原样追加") + void shouldAppendAdditionalArguments() { + List args = baseOptions() + .addAdditionalArguments(List.of("--resource-dir", "/tmp/res")) + .toCommandLineArguments(); + + assertTrue(containsPair(args, "--resource-dir", "/tmp/res")); + } + + @Test + @DisplayName("缺少必填项时抛出明确异常") + void shouldRejectIncompleteConfiguration() { + assertThrows(IllegalStateException.class, + () -> new JpackageOptions().setAppVersion("1.0").toCommandLineArguments()); + assertThrows(IllegalStateException.class, + () -> baseOptions().setMainJar(null).toCommandLineArguments()); + assertThrows(IllegalStateException.class, + () -> baseOptions().setInputDirectory(" ").toCommandLineArguments()); + assertThrows(IllegalStateException.class, + () -> baseOptions().setDestinationDirectory(null).toCommandLineArguments()); + } + + @Test + @DisplayName("resolveJavaOptions 合并默认值与用户值") + void shouldResolveJavaOptions() { + List resolved = baseOptions().addJavaOption("-Xmx512m").resolveJavaOptions(); + + assertEquals(JpackageOptions.UTF8_JAVA_OPTIONS.size() + 1, resolved.size()); + assertEquals("-Xmx512m", resolved.get(resolved.size() - 1)); + } + + private static boolean containsPair(List args, String flag, String value) { + return indexOfPair(args, flag, value) >= 0; + } + + private static int indexOfPair(List args, String flag, String value) { + for (int i = 0; i < args.size() - 1; i++) { + if (flag.equals(args.get(i)) && value.equals(args.get(i + 1))) { + return i; + } + } + return -1; + } +} diff --git a/src/test/java/com/mirenqinggege/maven/jpackage/internal/LauncherScriptGeneratorTest.java b/src/test/java/com/mirenqinggege/maven/jpackage/internal/LauncherScriptGeneratorTest.java new file mode 100644 index 0000000..0196b3b --- /dev/null +++ b/src/test/java/com/mirenqinggege/maven/jpackage/internal/LauncherScriptGeneratorTest.java @@ -0,0 +1,130 @@ +package com.mirenqinggege.maven.jpackage.internal; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link LauncherScriptGenerator} 的单元测试。 + */ +class LauncherScriptGeneratorTest { + + @Test + @DisplayName("run.sh 使用 LF 换行、切换目录并 exec 启动") + void shouldGenerateUnixScript() { + String content = LauncherScriptGenerator.unixScriptContent("demo-app"); + + assertTrue(content.startsWith("#!/bin/sh\n"), "缺少 shebang"); + assertFalse(content.contains("\r"), "run.sh 不应包含 CRLF"); + assertTrue(content.contains("APP_HOME=$(cd \"$(dirname \"$0\")\" && pwd)"), "缺少目录定位逻辑"); + assertTrue(content.contains("exec \"$APP_HOME/bin/demo-app\" \"$@\""), "缺少 exec 启动逻辑"); + } + + @Test + @DisplayName("run.bat 使用 CRLF 换行并切换到 UTF-8 代码页") + void shouldGenerateWindowsScript() { + String content = LauncherScriptGenerator.windowsScriptContent("demo-app"); + + assertTrue(content.contains("chcp 65001 > nul"), "缺少 UTF-8 代码页切换"); + assertTrue(content.contains("\"%~dp0demo-app.exe\" %*"), "缺少相对路径启动逻辑"); + assertTrue(content.contains("\r\n"), "run.bat 必须使用 CRLF 换行"); + assertFalse(content.replace("\r\n", "").contains("\n"), "run.bat 中不应出现孤立 LF"); + } + + @Test + @DisplayName("在类 Unix 平台生成可执行的 run.sh") + void shouldWriteExecutableUnixScript(@org.junit.jupiter.api.io.TempDir Path tempDir) throws IOException { + Path appImage = tempDir.resolve("dist").resolve("demo-app"); + + Path script = LauncherScriptGenerator.generate(appImage, "demo-app", Platform.LINUX); + + assertEquals(appImage.resolve("run.sh"), script); + assertTrue(Files.isRegularFile(script)); + if (FilePermissions.supportsPosix(script)) { + assertTrue(Files.isExecutable(script), "run.sh 应具备可执行权限"); + } + } + + @Test + @DisplayName("在 Windows 平台生成 run.bat") + void shouldWriteWindowsScript(@org.junit.jupiter.api.io.TempDir Path tempDir) throws IOException { + Path appImage = tempDir.resolve("dist").resolve("demo-app"); + + Path script = LauncherScriptGenerator.generate(appImage, "demo-app", Platform.WINDOWS); + + assertEquals(appImage.resolve("run.bat"), script); + assertTrue(Files.isRegularFile(script)); + assertTrue(Files.readString(script).contains("chcp 65001")); + } + + @Test + @DisplayName("已有 bin/ 时顺手补齐可执行权限") + void shouldMakeLauncherExecutable(@org.junit.jupiter.api.io.TempDir Path tempDir) throws IOException { + Path appImage = tempDir.resolve("dist").resolve("demo-app"); + Path launcher = appImage.resolve("bin").resolve("demo-app"); + Files.createDirectories(launcher.getParent()); + Files.writeString(launcher, "binary-placeholder"); + + LauncherScriptGenerator.generate(appImage, "demo-app", Platform.LINUX); + + if (FilePermissions.supportsPosix(launcher)) { + assertTrue(Files.isExecutable(launcher)); + } + } + + @Test + @DisplayName("桌面入口文件包含应用名与执行路径") + void shouldGenerateDesktopEntry() { + String content = LauncherScriptGenerator.desktopEntryContent("demo-app", "示例应用"); + + assertTrue(content.startsWith("[Desktop Entry]\n")); + assertTrue(content.contains("Name=demo-app")); + assertTrue(content.contains("Comment=示例应用")); + assertTrue(content.contains("Exec=bin/demo-app")); + } + + @Test + @DisplayName("描述为空时桌面入口回退为应用名") + void shouldFallbackDesktopComment() { + assertTrue(LauncherScriptGenerator.desktopEntryContent("demo-app", null) + .contains("Comment=demo-app")); + assertTrue(LauncherScriptGenerator.desktopEntryContent("demo-app", " ") + .contains("Comment=demo-app")); + } + + @Test + @DisplayName("非法入参被拒绝") + void shouldRejectInvalidInput(@org.junit.jupiter.api.io.TempDir Path tempDir) { + assertThrows(IllegalArgumentException.class, + () -> LauncherScriptGenerator.generate(tempDir, "demo", null)); + assertThrows(IllegalArgumentException.class, + () -> LauncherScriptGenerator.generate(tempDir, " ", Platform.LINUX)); + } + + @Test + @DisplayName("脚本名常量与生成结果一致") + void shouldExposeScriptNames() { + assertEquals("run.sh", LauncherScriptGenerator.UNIX_SCRIPT_NAME); + assertEquals("run.bat", LauncherScriptGenerator.WINDOWS_SCRIPT_NAME); + } + + @Test + @DisplayName("参数转发保持原样") + void shouldForwardArguments() { + List expectedFragments = List.of("\"$@\"", "%*"); + String unix = LauncherScriptGenerator.unixScriptContent("demo"); + String windows = LauncherScriptGenerator.windowsScriptContent("demo"); + + assertTrue(unix.contains(expectedFragments.get(0))); + assertTrue(windows.contains(expectedFragments.get(1))); + } +} diff --git a/src/test/java/com/mirenqinggege/maven/jpackage/internal/PlatformTest.java b/src/test/java/com/mirenqinggege/maven/jpackage/internal/PlatformTest.java new file mode 100644 index 0000000..d0ffc4f --- /dev/null +++ b/src/test/java/com/mirenqinggege/maven/jpackage/internal/PlatformTest.java @@ -0,0 +1,69 @@ +package com.mirenqinggege.maven.jpackage.internal; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link Platform} 的单元测试。 + */ +class PlatformTest { + + @Test + @DisplayName("识别 Windows 的多种 os.name 写法") + void shouldDetectWindows() { + assertEquals(Platform.WINDOWS, Platform.detect("Windows 10")); + assertEquals(Platform.WINDOWS, Platform.detect("windows 11")); + assertEquals(Platform.WINDOWS, Platform.detect("Windows Server 2022")); + } + + @Test + @DisplayName("识别 macOS 的多种 os.name 写法") + void shouldDetectMacOs() { + assertEquals(Platform.MACOS, Platform.detect("Mac OS X")); + assertEquals(Platform.MACOS, Platform.detect("macOS 14.5")); + assertEquals(Platform.MACOS, Platform.detect("Darwin")); + } + + @Test + @DisplayName("其余情况一律按 Linux 处理") + void shouldFallbackToLinux() { + assertEquals(Platform.LINUX, Platform.detect("Linux")); + assertEquals(Platform.LINUX, Platform.detect("FreeBSD")); + assertEquals(Platform.LINUX, Platform.detect(null)); + assertEquals(Platform.LINUX, Platform.detect("")); + } + + @Test + @DisplayName("按平台给出正确的 jpackage 可执行文件名") + void shouldProvideExecutableName() { + assertEquals("jpackage.exe", Platform.WINDOWS.jpackageExecutableName()); + assertEquals("jpackage", Platform.LINUX.jpackageExecutableName()); + assertEquals("jpackage", Platform.MACOS.jpackageExecutableName()); + } + + @Test + @DisplayName("平台分类谓词互斥且完备") + void shouldClassifyPlatforms() { + assertTrue(Platform.WINDOWS.isWindows()); + assertFalse(Platform.WINDOWS.isUnix()); + assertFalse(Platform.WINDOWS.isMac()); + + assertTrue(Platform.LINUX.isUnix()); + assertFalse(Platform.LINUX.isWindows()); + + assertTrue(Platform.MACOS.isUnix()); + assertTrue(Platform.MACOS.isMac()); + } + + @Test + @DisplayName("jpackageName 与 jpackage 平台前缀一致") + void shouldExposeJpackageName() { + assertEquals("windows", Platform.WINDOWS.jpackageName()); + assertEquals("linux", Platform.LINUX.jpackageName()); + assertEquals("macos", Platform.MACOS.jpackageName()); + } +} diff --git a/src/test/java/com/mirenqinggege/maven/jpackage/internal/VersionSanitizerTest.java b/src/test/java/com/mirenqinggege/maven/jpackage/internal/VersionSanitizerTest.java new file mode 100644 index 0000000..d780a2c --- /dev/null +++ b/src/test/java/com/mirenqinggege/maven/jpackage/internal/VersionSanitizerTest.java @@ -0,0 +1,74 @@ +package com.mirenqinggege.maven.jpackage.internal; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link VersionSanitizer} 的单元测试。 + */ +class VersionSanitizerTest { + + @Test + @DisplayName("去除 Maven 常见的版本后缀") + void shouldStripMavenSuffixes() { + assertEquals("1.0.0", VersionSanitizer.sanitize("1.0.0-SNAPSHOT")); + assertEquals("1.0.0", VersionSanitizer.sanitize("1.0.0.RELEASE")); + assertEquals("3.0.0", VersionSanitizer.sanitize("3.0.0-beta-1")); + assertEquals("1.0", VersionSanitizer.sanitize("1.0-0")); + assertEquals("2.1.5", VersionSanitizer.sanitize("2.1.5+local")); + } + + @Test + @DisplayName("保留已经合法的版本号") + void shouldKeepValidVersion() { + assertEquals("1.0.0", VersionSanitizer.sanitize("1.0.0")); + assertEquals("1.0", VersionSanitizer.sanitize("1.0")); + assertEquals("7", VersionSanitizer.sanitize("7")); + assertEquals("20240101", VersionSanitizer.sanitize("20240101")); + } + + @Test + @DisplayName("修复多余的点与前导零") + void shouldNormalizeSeparatorsAndLeadingZeros() { + assertEquals("1.2", VersionSanitizer.sanitize("1..2")); + assertEquals("1.2.3", VersionSanitizer.sanitize("01.02.03")); + assertEquals("1.2", VersionSanitizer.sanitize(".1.2.")); + assertEquals("1.0", VersionSanitizer.sanitize("1.0.")); + } + + @Test + @DisplayName("无法解析时回退到默认版本") + void shouldFallbackToDefault() { + assertEquals(VersionSanitizer.DEFAULT_VERSION, VersionSanitizer.sanitize(null)); + assertEquals(VersionSanitizer.DEFAULT_VERSION, VersionSanitizer.sanitize("")); + assertEquals(VersionSanitizer.DEFAULT_VERSION, VersionSanitizer.sanitize(" ")); + assertEquals(VersionSanitizer.DEFAULT_VERSION, VersionSanitizer.sanitize("v1.2.3")); + assertEquals(VersionSanitizer.DEFAULT_VERSION, VersionSanitizer.sanitize("latest")); + } + + @Test + @DisplayName("清洗结果始终通过合法性校验") + void shouldAlwaysProduceValidVersion() { + String[] inputs = {"1.0.0-SNAPSHOT", "1..2", "v1", "", null, "0.0.1", "...", "1.0.0-rc1"}; + for (String input : inputs) { + String sanitized = VersionSanitizer.sanitize(input); + assertTrue(VersionSanitizer.isValid(sanitized), + "清洗结果应合法:" + input + " -> " + sanitized); + } + } + + @Test + @DisplayName("isValid 能识别非法版本号") + void shouldRejectInvalidVersion() { + assertFalse(VersionSanitizer.isValid(null)); + assertFalse(VersionSanitizer.isValid("")); + assertFalse(VersionSanitizer.isValid("1.0.0-SNAPSHOT")); + assertFalse(VersionSanitizer.isValid(".1")); + assertFalse(VersionSanitizer.isValid("1.")); + assertTrue(VersionSanitizer.isValid("1.0.0")); + } +} diff --git a/src/test/java/com/mirenqinggege/maven/jpackage/internal/WineSupportTest.java b/src/test/java/com/mirenqinggege/maven/jpackage/internal/WineSupportTest.java new file mode 100644 index 0000000..d16c952 --- /dev/null +++ b/src/test/java/com/mirenqinggege/maven/jpackage/internal/WineSupportTest.java @@ -0,0 +1,166 @@ +package com.mirenqinggege.maven.jpackage.internal; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link WineSupport} 的单元测试。 + */ +class WineSupportTest { + + @Test + @DisplayName("能在 Windows JDK 目录中定位 jpackage.exe") + void shouldLocateWindowsJpackage(@TempDir Path tempDir) throws IOException { + Path jdkHome = tempDir.resolve("jdk-21-windows"); + Files.createDirectories(jdkHome.resolve("bin")); + Path executable = jdkHome.resolve("bin").resolve("jpackage.exe"); + Files.writeString(executable, "fake"); + + Optional located = WineSupport.locateWindowsJpackage(jdkHome); + + assertEquals(Optional.of(executable.toAbsolutePath().normalize()), located); + } + + @Test + @DisplayName("目录非法或缺少 jpackage.exe 时返回空") + void shouldReturnEmptyForInvalidJdk(@TempDir Path tempDir) throws IOException { + assertTrue(WineSupport.locateWindowsJpackage(null).isEmpty()); + assertTrue(WineSupport.locateWindowsJpackage(tempDir.resolve("missing")).isEmpty()); + + Path emptyJdk = tempDir.resolve("empty-jdk"); + Files.createDirectories(emptyJdk.resolve("bin")); + assertTrue(WineSupport.locateWindowsJpackage(emptyJdk).isEmpty()); + } + + @Test + @DisplayName("优先采用 winepath -w 的转换结果") + void shouldPreferWinepathOutput() { + JpackageExecutor fake = (command, workingDir, environment, sink) -> { + assertEquals("winepath", command.get(0)); + assertEquals("-w", command.get(1)); + sink.accept("C:\\converted\\path"); + return 0; + }; + + String converted = WineSupport.toWindowsPath(Path.of("/tmp/example"), fake); + + assertEquals("C:\\converted\\path", converted); + } + + @Test + @DisplayName("winepath 不可用时回退到 Z: 盘符映射") + void shouldFallbackToDriveZ() { + JpackageExecutor failing = (command, workingDir, environment, sink) -> 1; + + String converted = WineSupport.toWindowsPath(Path.of("/tmp/example"), failing); + + assertTrue(converted.startsWith("Z:\\"), "应回退为 Z: 盘符路径,实际为 " + converted); + assertTrue(converted.endsWith("tmp\\example"), "应保留原始层级,实际为 " + converted); + } + + @Test + @DisplayName("执行器为空时直接回退,不抛异常") + void shouldFallbackWithoutExecutor() { + String converted = WineSupport.toWindowsPath(Path.of("/opt/app"), null); + assertTrue(converted.startsWith("Z:\\")); + } + + @Test + @DisplayName("winepath 进程启动失败时回退") + void shouldFallbackWhenProcessFails() { + JpackageExecutor throwing = (command, workingDir, environment, sink) -> { + throw new IOException("winepath not found"); + }; + + assertTrue(WineSupport.toWindowsPath(Path.of("/opt/app"), throwing).startsWith("Z:\\")); + } + + @Test + @DisplayName("组装 wine 调用命令行:wine + exe + 参数") + void shouldBuildWineCommand() { + Function converter = path -> "Z:" + path.toString().replace('/', '\\'); + List arguments = List.of("--type", "app-image", "--name", "demo"); + + List command = WineSupport.buildWineCommand( + Path.of("/usr/bin/wine"), + Path.of("/opt/jdk/bin/jpackage.exe"), + arguments, + converter); + + assertEquals("/usr/bin/wine", command.get(0)); + assertEquals("Z:\\opt\\jdk\\bin\\jpackage.exe", command.get(1)); + assertEquals(List.of("--type", "app-image", "--name", "demo"), + command.subList(2, command.size())); + } + + @Test + @DisplayName("缺少 windowsJdkHome 时给出明确提示") + void shouldRequireWindowsJdkHome() { + Optional problem = WineSupport.validate(null); + assertTrue(problem.isPresent()); + assertTrue(problem.get().contains("windowsJdkHome")); + + assertTrue(WineSupport.validate(" ").isPresent()); + } + + @Test + @DisplayName("windowsJdkHome 缺少 jpackage.exe 时给出明确提示") + void shouldReportMissingJpackageExe(@TempDir Path tempDir) { + Optional problem = WineSupport.validate(tempDir.toString()); + assertTrue(problem.isPresent()); + assertTrue(problem.get().contains("jpackage.exe")); + } + + @Test + @DisplayName("仅 Linux 平台适合使用 Wine 打包") + void shouldCheckSuitability() { + assertFalse(WineSupport.isWineSuitable(Platform.WINDOWS)); + assertFalse(WineSupport.isWineSuitable(Platform.MACOS)); + // Linux 上是否真的具备该条件取决于宿主机是否安装 wine,此处只断言不抛异常 + WineSupport.isWineSuitable(Platform.LINUX); + } + + @Test + @DisplayName("转换整条命令中的路径参数") + void shouldConvertPathArguments() throws Exception { + List raw = new ArrayList<>(List.of( + "--type", "app-image", + "--input", "/tmp/target/jpackage-input", + "--dest", "/tmp/target/dist", + "--main-jar", "demo.jar", + "--icon", "/tmp/icon.ico")); + + // 复用 Mojo 中的转换约定:仅 --input / --dest / --icon 的取值是宿主机路径 + List converted = new ArrayList<>(); + String pending = null; + for (String argument : raw) { + if (pending != null) { + converted.add("Z:" + argument.replace('/', '\\')); + pending = null; + continue; + } + if (argument.equals("--input") || argument.equals("--dest") || argument.equals("--icon")) { + pending = argument; + } + converted.add(argument); + } + + assertTrue(converted.contains("Z:\\tmp\\target\\jpackage-input")); + assertTrue(converted.contains("Z:\\tmp\\target\\dist")); + assertTrue(converted.contains("Z:\\tmp\\icon.ico")); + assertTrue(converted.contains("demo.jar"), "非路径参数应保持原样"); + } +}