feat: 初始化 jpackage-maven-plugin 通用原生打包插件
基于 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 <zkh@mirenqinggege.com>
This commit is contained in:
+18
@@ -0,0 +1,18 @@
|
||||
target/
|
||||
*.class
|
||||
*.jar
|
||||
*.log
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.vscode/
|
||||
.settings/
|
||||
.project
|
||||
.classpath
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -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
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>com.mirenqinggege.maven</groupId>
|
||||
<artifactId>jpackage-maven-plugin</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>native-package</id>
|
||||
<goals>
|
||||
<goal>package-app</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
```
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
mvn clean package
|
||||
```
|
||||
|
||||
插件会自动完成:定位 `jpackage` → 建立纯净输入目录 → 探测 `Main-Class` → 清洗版本号 →
|
||||
组装命令行 → 调用 `jpackage` → 生成启动脚本 → 清理临时目录。
|
||||
|
||||
> 如果没有 `Main-Class` 且未配置 `<mainClass>`,插件会打印告警并交由 `jpackage`
|
||||
> 自行从清单读取;此时若构建失败,请显式配置 `<mainClass>`。
|
||||
|
||||
## 输出结构
|
||||
|
||||
默认产物位于 `target/dist/<appName>/`:
|
||||
|
||||
```
|
||||
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`。
|
||||
|
||||
整个 `<appName>` 目录即为绿色免安装版:压缩后拷贝到目标机器解压,直接执行 `run.sh`
|
||||
(或 `run.bat`)即可运行。
|
||||
|
||||
## POM 配置示例
|
||||
|
||||
### 示例一:命令行工具(Linux 产物 + 自定义 JVM 参数)
|
||||
|
||||
```xml
|
||||
<plugin>
|
||||
<groupId>com.mirenqinggege.maven</groupId>
|
||||
<artifactId>jpackage-maven-plugin</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>native-package</id>
|
||||
<goals>
|
||||
<goal>package-app</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<appName>MyTool</appName>
|
||||
<mainClass>com.example.MyTool</mainClass>
|
||||
<vendor>Example Inc.</vendor>
|
||||
<description>我的命令行工具</description>
|
||||
<javaOptions>
|
||||
<javaOption>-Xmx512m</javaOption>
|
||||
<javaOption>-Duser.timezone=Asia/Shanghai</javaOption>
|
||||
</javaOptions>
|
||||
</configuration>
|
||||
</plugin>
|
||||
```
|
||||
|
||||
### 示例二:Windows 桌面应用(带图标、保留控制台)
|
||||
|
||||
```xml
|
||||
<plugin>
|
||||
<groupId>com.mirenqinggege.maven</groupId>
|
||||
<artifactId>jpackage-maven-plugin</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>native-package</id>
|
||||
<goals>
|
||||
<goal>package-app</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<icon>${project.basedir}/src/main/resources/app.ico</icon>
|
||||
<winConsole>true</winConsole>
|
||||
<!-- 不声明 mainClass,由插件从 MANIFEST.MF 自动探测 -->
|
||||
</configuration>
|
||||
</plugin>
|
||||
```
|
||||
|
||||
### 示例三:已打成 fat jar 的项目
|
||||
|
||||
```xml
|
||||
<configuration>
|
||||
<!-- 依赖已在 fat jar 内,无需重复拷贝 -->
|
||||
<includeDependencies>false</includeDependencies>
|
||||
<!-- 自定义 finalName 时同步指定主 JAR 名 -->
|
||||
<mainJar>${project.build.finalName}.jar</mainJar>
|
||||
</configuration>
|
||||
```
|
||||
|
||||
### 示例四:作为独立 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
|
||||
<pluginGroups>
|
||||
<pluginGroup>com.mirenqinggege.maven</pluginGroup>
|
||||
</pluginGroups>
|
||||
```
|
||||
|
||||
### 示例五: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<String>`,追加的自定义 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<String>`,原样追加到 jpackage 命令行的参数 |
|
||||
|
||||
> **输入目录隔离**:插件不会把 `target/` 根目录直接作为 `--input`,而是建立独立的
|
||||
> `jpackage-input/` 目录,只放入主 JAR 与依赖,避免 `classes/`、`maven-status/`、
|
||||
> 测试报告等编译产物被误打包进应用镜像。
|
||||
|
||||
### 启动脚本
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `generateLauncherScript` | `true` | 生成 `run.sh` / `run.bat` |
|
||||
| `generateDesktopEntry` | `true` | Linux 下额外生成 `<appName>.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,最可靠,兼容 `<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 换行) |
|
||||
| 桌面入口 | — | `<appName>.desktop` | — |
|
||||
| 可执行权限 | 不适用 | 自动设为 755 | 自动设为 755 |
|
||||
|
||||
生成的 `run.sh`:
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
# 由 jpackage-maven-plugin 自动生成:切换到脚本所在目录后启动应用
|
||||
set -e
|
||||
APP_HOME=$(cd "$(dirname "$0")" && pwd)
|
||||
exec "$APP_HOME/bin/<appName>" "$@"
|
||||
```
|
||||
|
||||
生成的 `run.bat`:
|
||||
|
||||
```bat
|
||||
@echo off
|
||||
rem 由 jpackage-maven-plugin 自动生成:切换控制台代码页为 UTF-8 后启动应用
|
||||
chcp 65001 > nul
|
||||
setlocal
|
||||
"%~dp0<appName>.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
|
||||
<configuration>
|
||||
<useWine>true</useWine>
|
||||
<windowsJdkHome>/opt/jdk-21.0.9-windows-x64</windowsJdkHome>
|
||||
</configuration>
|
||||
```
|
||||
|
||||
插件会:
|
||||
|
||||
1. 校验 `wine` 与 `<windowsJdkHome>/bin/jpackage.exe` 是否存在,缺失时快速失败并给出提示;
|
||||
2. 通过 `winepath -w` 把宿主路径转换为 Windows 路径(失败时回退到 Wine 默认的 `Z:` 盘映射);
|
||||
3. 以 `wine <jpackage.exe> ...` 形式执行,并设置 `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(此时应设置
|
||||
`<includeDependencies>false</includeDependencies>`)。
|
||||
|
||||
**Q:想在 Windows 上打包但 `mvn` 运行的 JDK 不是 16+?**
|
||||
A:插件的 `jpackageExecutable` 允许指向另一个 JDK:
|
||||
`<jpackageExecutable>C:\jdk-21\bin\jpackage.exe</jpackageExecutable>`。
|
||||
|
||||
**Q:构建速度慢?**
|
||||
A:`jpackage` 需要对内嵌运行时做 jlink 裁剪并拷贝数百个文件,属正常现象。日常开发可用
|
||||
`-Djpackage.skip=true` 跳过。
|
||||
|
||||
**Q:中文输出乱码?**
|
||||
A:确认不要通过 `javaOptions` 覆盖掉 `-Dfile.encoding=UTF-8`;Windows 上请使用插件生成的
|
||||
`run.bat`(其中含 `chcp 65001`)而非直接双击 `bin\<appName>.exe`。
|
||||
|
||||
## 发布到 Maven 中央仓库
|
||||
|
||||
本项目已按 Maven Central 的要求配置完毕:`name` / `description` / `url` / `licenses` /
|
||||
`developers` / `scm` 齐全,`sources` 与 `javadoc` 构件随包附加,GPG 签名在 `release`
|
||||
profile 中启用。
|
||||
|
||||
### 1. 前置准备
|
||||
|
||||
1. **注册命名空间**:登录 <https://central.sonatype.com>,添加并验证命名空间
|
||||
`com.mirenqinggege`(可通过 DNS TXT 记录或代码托管平台账号验证;本项目的 SCM 地址为
|
||||
`git.mirenqinggege.com`,建议用域名方式验证)。
|
||||
2. **生成 Portal Token**:在 Central Portal 的 *Account → Generate User Token* 中生成,
|
||||
然后写入 `~/.m2/settings.xml`:
|
||||
|
||||
```xml
|
||||
<servers>
|
||||
<server>
|
||||
<id>central</id>
|
||||
<username>Portal Token 的用户名</username>
|
||||
<password>Portal Token 的密码</password>
|
||||
</server>
|
||||
</servers>
|
||||
```
|
||||
|
||||
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 的待发布状态,
|
||||
需在 <https://central.sonatype.com/publishing> 手动点击 **Publish**;
|
||||
若希望校验通过即自动发布,把 `pom.xml` 中的 `<autoPublish>` 改为 `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` 的 `<version>`;
|
||||
- `pom.xml` 的 `<scm><tag>`;
|
||||
- `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)。
|
||||
@@ -0,0 +1,97 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
示例项目:一个普通的标准 Java 命令行应用,演示如何使用 jpackage-maven-plugin。
|
||||
|
||||
使用方式:
|
||||
mvn clean package # 打出 target/dist/HelloTool 绿色免安装版
|
||||
mvn clean package -Djpackage.skip=true # 跳过原生打包
|
||||
./target/dist/HelloTool/run.sh 参数1 参数2
|
||||
-->
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>hello-cli</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>hello-cli</name>
|
||||
<description>jpackage-maven-plugin 的最小可用示例</description>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.release>17</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<jpackage.plugin.version>1.0.0</jpackage.plugin.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<!-- 一个真实的三方依赖,用于验证依赖拷贝与运行时类路径 -->
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-lang3</artifactId>
|
||||
<version>3.14.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<!-- 让 JAR 带上 Main-Class,插件即可零配置自动探测 -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>3.4.1</version>
|
||||
<configuration>
|
||||
<archive>
|
||||
<manifest>
|
||||
<mainClass>com.example.hello.HelloApp</mainClass>
|
||||
<addClasspath>true</addClasspath>
|
||||
<classpathPrefix>lib/</classpathPrefix>
|
||||
</manifest>
|
||||
</archive>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<!-- 核心:原生打包插件 -->
|
||||
<plugin>
|
||||
<groupId>com.mirenqinggege.maven</groupId>
|
||||
<artifactId>jpackage-maven-plugin</artifactId>
|
||||
<version>${jpackage.plugin.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>native-package</id>
|
||||
<goals>
|
||||
<goal>package-app</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<!-- 应用名默认取 artifactId,这里演示显式覆盖 -->
|
||||
<appName>HelloTool</appName>
|
||||
|
||||
<!-- mainClass 留空时插件会自动从 MANIFEST.MF 读取,这里演示显式声明 -->
|
||||
<mainClass>com.example.hello.HelloApp</mainClass>
|
||||
|
||||
<!-- 追加的自定义 JVM 参数,排在默认 UTF-8 参数之后 -->
|
||||
<javaOptions>
|
||||
<javaOption>-Xmx256m</javaOption>
|
||||
<javaOption>-Ddemo.mode=example</javaOption>
|
||||
</javaOptions>
|
||||
|
||||
<!-- 应用图标:Windows 用 .ico、Linux 用 .png、macOS 用 .icns -->
|
||||
<icon>${project.basedir}/src/main/resources/app.png</icon>
|
||||
|
||||
<vendor>Example Inc.</vendor>
|
||||
<description>示例命令行工具</description>
|
||||
|
||||
<!-- Windows 目标平台是否保留控制台窗口(其他平台自动忽略) -->
|
||||
<winConsole>true</winConsole>
|
||||
|
||||
<!-- 打印 jpackage 完整日志,便于排查问题 -->
|
||||
<verbose>true</verbose>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.example.hello;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* 示例应用:一个再普通不过的命令行工具。
|
||||
*
|
||||
* <p>刻意不使用任何框架,以证明插件对应用类型没有假设。程序会打印中文、命令行参数、
|
||||
* 编码相关系统属性与三方依赖的处理结果,用于直观验证:</p>
|
||||
* <ul>
|
||||
* <li>内嵌运行时确实生效({@code java.home} 指向应用镜像内的 {@code lib/runtime});</li>
|
||||
* <li>UTF-8 参数确实生效({@code file.encoding} 等均为 UTF-8);</li>
|
||||
* <li>三方依赖确实被正确打包并可加载。</li>
|
||||
* </ul>
|
||||
*/
|
||||
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"}, "-"));
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 82 B |
@@ -0,0 +1,344 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.mirenqinggege.maven</groupId>
|
||||
<artifactId>jpackage-maven-plugin</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>maven-plugin</packaging>
|
||||
|
||||
<name>JPackage Maven Plugin</name>
|
||||
<description>
|
||||
通用原生打包插件:基于 OpenJDK 16+ 官方 jpackage 工具,将任意标准 Java 应用打包为
|
||||
内嵌最小运行时的跨平台自包含应用镜像(app-image,解压即用、目标机器无需安装 Java)。
|
||||
插件不绑定任何应用框架,自动探测 Main-Class、自动清洗版本号、按平台自适应参数并生成
|
||||
一键启动脚本。
|
||||
</description>
|
||||
<inceptionYear>2026</inceptionYear>
|
||||
|
||||
<url>https://git.mirenqinggege.com/zkh/jpackage-maven-plugin</url>
|
||||
|
||||
<licenses>
|
||||
<license>
|
||||
<name>Apache License, Version 2.0</name>
|
||||
<url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>
|
||||
<distribution>repo</distribution>
|
||||
<comments>一个 OSI 认可的开源许可证。</comments>
|
||||
</license>
|
||||
</licenses>
|
||||
|
||||
<developers>
|
||||
<developer>
|
||||
<id>zkh</id>
|
||||
<name>zkh</name>
|
||||
<email>zkh@mirenqinggege.com</email>
|
||||
<roles>
|
||||
<role>Developer</role>
|
||||
<role>Maintainer</role>
|
||||
</roles>
|
||||
<timezone>Asia/Shanghai</timezone>
|
||||
</developer>
|
||||
</developers>
|
||||
|
||||
<scm>
|
||||
<connection>scm:git:ssh://git@git.mirenqinggege.com:2222/zkh/jpackage-maven-plugin.git</connection>
|
||||
<developerConnection>scm:git:ssh://git@git.mirenqinggege.com:2222/zkh/jpackage-maven-plugin.git</developerConnection>
|
||||
<url>https://git.mirenqinggege.com/zkh/jpackage-maven-plugin</url>
|
||||
<tag>jpackage-maven-plugin-1.0.0</tag>
|
||||
</scm>
|
||||
|
||||
<issueManagement>
|
||||
<system>Gitea Issues</system>
|
||||
<url>https://git.mirenqinggege.com/zkh/jpackage-maven-plugin/issues</url>
|
||||
</issueManagement>
|
||||
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
<maven.compiler.release>17</maven.compiler.release>
|
||||
|
||||
<!-- 编译插件所用到的 Maven API 版本(provided,运行时由 Maven 自身提供) -->
|
||||
<maven.version>3.9.9</maven.version>
|
||||
<maven.plugin.tools.version>3.16.0</maven.plugin.tools.version>
|
||||
<maven.compiler.version>3.14.0</maven.compiler.version>
|
||||
<maven.surefire.version>3.5.2</maven.surefire.version>
|
||||
<maven.jar.version>3.4.2</maven.jar.version>
|
||||
<maven.source.version>3.3.1</maven.source.version>
|
||||
<maven.javadoc.version>3.12.0</maven.javadoc.version>
|
||||
<maven.gpg.version>3.2.8</maven.gpg.version>
|
||||
<maven.enforcer.version>3.6.3</maven.enforcer.version>
|
||||
<central.publishing.version>0.12.0</central.publishing.version>
|
||||
|
||||
<junit.version>5.14.4</junit.version>
|
||||
</properties>
|
||||
|
||||
<prerequisites>
|
||||
<maven>3.6.3</maven>
|
||||
</prerequisites>
|
||||
|
||||
<dependencies>
|
||||
<!-- Maven 插件基础 API -->
|
||||
<dependency>
|
||||
<groupId>org.apache.maven</groupId>
|
||||
<artifactId>maven-plugin-api</artifactId>
|
||||
<version>${maven.version}</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- MavenProject / 运行时模型,插件在 Maven 容器内运行时由 Maven 提供 -->
|
||||
<dependency>
|
||||
<groupId>org.apache.maven</groupId>
|
||||
<artifactId>maven-core</artifactId>
|
||||
<version>${maven.version}</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.apache.maven</groupId>
|
||||
<artifactId>maven-model</artifactId>
|
||||
<version>${maven.version}</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Mojo 注解处理器(仅编译期需要) -->
|
||||
<dependency>
|
||||
<groupId>org.apache.maven.plugin-tools</groupId>
|
||||
<artifactId>maven-plugin-annotations</artifactId>
|
||||
<version>${maven.plugin.tools.version}</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- 测试 -->
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<!-- 校验构建环境:Maven 版本、JDK 版本,避免产出无法被消费的构件 -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-enforcer-plugin</artifactId>
|
||||
<version>${maven.enforcer.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>enforce-build-environment</id>
|
||||
<goals>
|
||||
<goal>enforce</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<requireMavenVersion>
|
||||
<version>[3.6.3,)</version>
|
||||
</requireMavenVersion>
|
||||
<requireJavaVersion>
|
||||
<version>[17,)</version>
|
||||
</requireJavaVersion>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>${maven.compiler.version}</version>
|
||||
<configuration>
|
||||
<release>${maven.compiler.release}</release>
|
||||
<encoding>UTF-8</encoding>
|
||||
<!-- 生成方法参数名,便于 Maven 在调试与诊断时输出更可读的信息 -->
|
||||
<parameters>true</parameters>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-plugin-plugin</artifactId>
|
||||
<version>${maven.plugin.tools.version}</version>
|
||||
<configuration>
|
||||
<!-- 插件 goal 前缀:使用 mvn jpackage:package-app 调用 -->
|
||||
<goalPrefix>jpackage</goalPrefix>
|
||||
<!-- 使用注解处理器生成 plugin.xml,避免额外的 XML 描述文件 -->
|
||||
<skipErrorNoDescriptorsFound>false</skipErrorNoDescriptorsFound>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>default-descriptor</id>
|
||||
<phase>process-classes</phase>
|
||||
<goals>
|
||||
<goal>descriptor</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>help-goal</id>
|
||||
<goals>
|
||||
<goal>helpmojo</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>${maven.surefire.version}</version>
|
||||
</plugin>
|
||||
|
||||
<!-- Maven Central 要求:随主构件一同发布 sources jar -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-source-plugin</artifactId>
|
||||
<version>${maven.source.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>attach-sources</id>
|
||||
<goals>
|
||||
<goal>jar-no-fork</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<!-- Maven Central 要求:随主构件一同发布 javadoc jar -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-javadoc-plugin</artifactId>
|
||||
<version>${maven.javadoc.version}</version>
|
||||
<configuration>
|
||||
<encoding>UTF-8</encoding>
|
||||
<docencoding>UTF-8</docencoding>
|
||||
<charset>UTF-8</charset>
|
||||
<locale>zh_CN</locale>
|
||||
<doclint>none</doclint>
|
||||
<quiet>true</quiet>
|
||||
<notimestamp>true</notimestamp>
|
||||
<!-- 不让他人项目的依赖出现在本项目的 javadoc 链接中 -->
|
||||
<detectJavaApiLink>false</detectJavaApiLink>
|
||||
<detectLinks>false</detectLinks>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>attach-javadocs</id>
|
||||
<goals>
|
||||
<goal>jar</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<!-- Maven Central 要求:所有构件必须附带 GPG 签名。
|
||||
默认 profile(default-no-release)会把该执行置为 phase=none,
|
||||
因此日常 mvn install / mvn verify 不会触发签名;
|
||||
发布时使用 mvn clean deploy -P release 激活签名。 -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-gpg-plugin</artifactId>
|
||||
<version>${maven.gpg.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>sign-artifacts</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>sign</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<!--
|
||||
发布到 Maven Central 的官方插件(Sonatype Central Portal)。
|
||||
发布命令:mvn clean deploy -P release
|
||||
|
||||
首次使用前需:
|
||||
1. 在 https://central.sonatype.com 注册命名空间(如 com.mirenqinggege);
|
||||
2. 在 ~/.m2/settings.xml 中配置 server:
|
||||
<server>
|
||||
<id>central</id>
|
||||
<username>Portal Token 的用户名</username>
|
||||
<password>Portal Token 的密码</password>
|
||||
</server>
|
||||
-->
|
||||
<plugin>
|
||||
<groupId>org.sonatype.central</groupId>
|
||||
<artifactId>central-publishing-maven-plugin</artifactId>
|
||||
<version>${central.publishing.version}</version>
|
||||
<extensions>true</extensions>
|
||||
<configuration>
|
||||
<publishingServerId>central</publishingServerId>
|
||||
<!--
|
||||
false:构件上传并校验通过后停留在 Portal 的“待发布”状态,由人工确认后再点 Publish。
|
||||
熟悉流程后可改为 true 实现校验通过即自动发布。
|
||||
-->
|
||||
<autoPublish>false</autoPublish>
|
||||
<!-- 等待 Portal 完成校验,校验不通过会直接以失败退出并给出原因 -->
|
||||
<waitUntil>validated</waitUntil>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<profiles>
|
||||
<!--
|
||||
发布专用 profile:mvn clean deploy -P release
|
||||
激活后启用 GPG 签名(显式指定密钥),日常构建不受影响。
|
||||
-->
|
||||
<profile>
|
||||
<id>release</id>
|
||||
<properties>
|
||||
<!-- 可通过 -Dgpg.keyname=xxx 覆盖 -->
|
||||
<gpg.keyname>A29C55F143AA11321B87D9F402253AEB01AD4266</gpg.keyname>
|
||||
</properties>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-gpg-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>sign-artifacts</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>sign</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<keyname>${gpg.keyname}</keyname>
|
||||
<!-- 关闭 GPG 代理的图形弹窗,便于 CI 中非交互执行 -->
|
||||
<pinentryMode>loopback</pinentryMode>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
|
||||
<!-- 默认构建不签名:把 gpg 的 sign 执行停用 -->
|
||||
<profile>
|
||||
<id>default-no-release</id>
|
||||
<activation>
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-gpg-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>sign-artifacts</id>
|
||||
<phase>none</phase>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
@@ -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},即绿色免安装版)。
|
||||
*
|
||||
* <p>插件刻意不依赖任何应用框架(Spring Boot / JavaFX / Quarkus 等),只使用纯 Java 与
|
||||
* Maven 标准模型,因此适用于命令行工具、桌面 GUI 应用、微服务等各类标准 Java 项目。</p>
|
||||
*
|
||||
* <h2>零配置用法</h2>
|
||||
* <pre>{@code
|
||||
* <plugin>
|
||||
* <groupId>com.mirenqinggege.maven</groupId>
|
||||
* <artifactId>jpackage-maven-plugin</artifactId>
|
||||
* <version>1.0.0</version>
|
||||
* <executions>
|
||||
* <execution>
|
||||
* <goals><goal>package-app</goal></goals>
|
||||
* </execution>
|
||||
* </executions>
|
||||
* </plugin>
|
||||
* }</pre>
|
||||
*
|
||||
* <p>执行 {@code mvn package} 后,产物位于 {@code target/dist/<appName>/},
|
||||
* 目录内即为可直接拷贝到目标机器(无需安装 Java)运行的完整应用。</p>
|
||||
*/
|
||||
@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 <finalName>} 一致。
|
||||
*/
|
||||
@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}。
|
||||
*
|
||||
* <p>插件默认注入的 UTF-8 参数排在用户参数之前,因此同名参数可由用户覆盖。</p>
|
||||
*/
|
||||
@Parameter
|
||||
List<String> javaOptions;
|
||||
|
||||
/**
|
||||
* 是否注入默认的 UTF-8 编码参数({@code file.encoding}、{@code stdout.encoding} 等)。
|
||||
*/
|
||||
@Parameter(property = "jpackage.injectUtf8Options", defaultValue = "true")
|
||||
boolean injectUtf8Options = true;
|
||||
|
||||
/**
|
||||
* Windows 目标平台下是否附加 {@code --win-console} 以保留控制台窗口。
|
||||
*
|
||||
* <p>该参数只在 Windows 目标平台生效;Linux / macOS 平台永远不会传递它,
|
||||
* 否则 jpackage 会直接报错。</p>
|
||||
*/
|
||||
@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}。
|
||||
*
|
||||
* <p>保持默认即为绿色免安装版;如需 {@code msi} / {@code deb} / {@code rpm} / {@code dmg} / {@code pkg},
|
||||
* 需自行确认目标平台已安装对应打包工具。</p>
|
||||
*/
|
||||
@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 的纯净输入目录,插件会先清空再填充。
|
||||
*
|
||||
* <p>刻意与 {@code target} 根目录隔离,避免把 {@code classes/}、{@code maven-status/} 等
|
||||
* 编译污染目录一并打包进应用镜像。</p>
|
||||
*/
|
||||
@Parameter(defaultValue = "${project.build.directory}/jpackage-input")
|
||||
File inputDirectory;
|
||||
|
||||
/**
|
||||
* 是否把运行时依赖一并拷贝到输入目录。默认 {@code true}。
|
||||
*
|
||||
* <p>若项目已通过 shade / assembly 插件打成 fat jar,可将此项设为 {@code false}。</p>
|
||||
*/
|
||||
@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 <appName>.desktop} 桌面入口文件。默认 {@code true}。
|
||||
*/
|
||||
@Parameter(property = "jpackage.generateDesktopEntry", defaultValue = "true")
|
||||
boolean generateDesktopEntry = true;
|
||||
|
||||
/**
|
||||
* 追加到 jpackage 命令行的原始参数,例如 {@code --resource-dir}。
|
||||
*/
|
||||
@Parameter
|
||||
List<String> 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<String> 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 清单中自行读取,"
|
||||
+ "若构建失败请显式配置 <mainClass>。"));
|
||||
|
||||
List<Path> extraJars = prepareInputDirectory(inputDir, resolvedMainJar);
|
||||
getLog().info(" ├─ 输入目录已就绪:" + inputDir + "(主 JAR + " + extraJars.size() + " 个依赖)");
|
||||
|
||||
JpackageOptions options = buildOptions(resolvedAppName, resolvedVersion, resolvedMainJar,
|
||||
resolvedMainClass.orElse(null), inputDir, destDir, targetPlatform);
|
||||
|
||||
List<String> 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<String> 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<Path> located = JdkToolLocator.locate(targetPlatform, jpackageExecutable,
|
||||
javaHomeOverride, javaHomeEnv);
|
||||
|
||||
if (located.isEmpty()) {
|
||||
String hint = jpackageExecutable != null && !jpackageExecutable.isBlank()
|
||||
? "已配置的 <jpackageExecutable> 不可用:" + jpackageExecutable
|
||||
: "已尝试 ${java.home}/bin、JAVA_HOME/bin 与系统 PATH";
|
||||
throw new MojoExecutionException("无法定位 jpackage 可执行文件(" + hint + ")。"
|
||||
+ "请确认 Maven 运行在 JDK 16 及以上版本,或通过 <jpackageExecutable> 显式指定路径。");
|
||||
}
|
||||
|
||||
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),或通过 <mainJar> 指定正确的文件名。");
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析主启动类:用户配置优先,其次读取主 JAR 清单。
|
||||
*
|
||||
* @param mainJarPath 主 JAR 路径
|
||||
* @return 主类名;两者均未提供时返回空
|
||||
*/
|
||||
private Optional<String> 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<Path> prepareInputDirectory(Path inputDir, Path mainJarPath) throws MojoExecutionException {
|
||||
try {
|
||||
FileTreeUtils.cleanDirectory(inputDir);
|
||||
} catch (IOException e) {
|
||||
throw new MojoExecutionException("无法清理输入目录 " + inputDir + ":" + e.getMessage(), e);
|
||||
}
|
||||
|
||||
Set<Path> copied = new LinkedHashSet<>();
|
||||
try {
|
||||
FileTreeUtils.copyFile(mainJarPath, inputDir.resolve(mainJarPath.getFileName()));
|
||||
} catch (IOException e) {
|
||||
throw new MojoExecutionException("拷贝主 JAR 到输入目录失败:" + e.getMessage(), e);
|
||||
}
|
||||
|
||||
List<Path> 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<Path> collectClassPathJars(Path mainJarPath) {
|
||||
List<Path> result = new ArrayList<>();
|
||||
Path baseDir = mainJarPath.getParent();
|
||||
List<String> 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<Path> collectDependencyJars() {
|
||||
List<Path> 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<String> buildCommand(Path jpackage, JpackageOptions options) {
|
||||
List<String> rawArguments = options.toCommandLineArguments();
|
||||
if (!useWine) {
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(jpackage.toString());
|
||||
command.addAll(rawArguments);
|
||||
return command;
|
||||
}
|
||||
|
||||
Function<Path, String> 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<String> convertPathsToWindows(List<String> arguments, Function<Path, String> converter) {
|
||||
List<String> 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<String> command, Path inputDir)
|
||||
throws MojoExecutionException {
|
||||
Deque<String> tail = new ArrayDeque<>();
|
||||
Map<String, String> 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<String, String> wineEnvironment() {
|
||||
if (!useWine) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 权限设置工具。
|
||||
*
|
||||
* <p>jpackage 生成的 {@code bin/<app>} 可执行文件在类 Unix 系统上通常已带有执行位,
|
||||
* 但自行生成的 {@code run.sh} 需要显式赋予 {@code 755}。在 Windows 或不支持 POSIX
|
||||
* 权限的文件系统上,这些操作会被安全地忽略而不会中断构建。</p>
|
||||
*/
|
||||
public final class FilePermissions {
|
||||
|
||||
/** {@code rwxr-xr-x},即八进制 755。 */
|
||||
public static final Set<PosixFilePermission> EXECUTABLE_PERMISSIONS =
|
||||
PosixFilePermissions.fromString("rwxr-xr-x");
|
||||
|
||||
/** {@code rw-r--r--},即八进制 644。 */
|
||||
public static final Set<PosixFilePermission> 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<PosixFilePermission> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Path>() {
|
||||
@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<Path> 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<Path> 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<Path> listSorted(Path directory) throws IOException {
|
||||
if (directory == null || !Files.isDirectory(directory)) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
try (Stream<Path> stream = Files.list(directory)) {
|
||||
return stream.sorted(Comparator.comparing(path -> path.getFileName().toString())).toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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} 中的关键属性。
|
||||
*
|
||||
* <p>用于实现零配置能力:当用户在 POM 中未显式声明 {@code mainClass} 时,
|
||||
* 从主 JAR 的清单文件中自动推断启动类;同时解析 {@code Class-Path} 头,
|
||||
* 以便把外部依赖一并拷贝进 jpackage 的输入目录。</p>
|
||||
*/
|
||||
public final class JarManifestInspector {
|
||||
|
||||
private JarManifestInspector() {
|
||||
// 工具类
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 JAR 中读取 {@code Main-Class}。
|
||||
*
|
||||
* @param jarFile 目标 JAR 文件路径
|
||||
* @return 去空白后的主类全限定名;清单不存在或未声明该属性时返回空
|
||||
* @throws IOException 读取 JAR 失败时抛出
|
||||
*/
|
||||
public static Optional<String> 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} 头,并按空格拆分为条目列表。
|
||||
*
|
||||
* <p>条目通常形如 {@code lib/foo.jar}(相对于主 JAR 所在目录),
|
||||
* 也可能出现 {@code file:/...} 形式的绝对 URL。</p>
|
||||
*
|
||||
* @param jarFile 目标 JAR 文件路径
|
||||
* @return 条目列表;清单不存在或未声明该属性时返回空列表
|
||||
* @throws IOException 读取 JAR 失败时抛出
|
||||
*/
|
||||
public static List<String> findClassPath(Path jarFile) throws IOException {
|
||||
Optional<String> raw = readManifest(jarFile)
|
||||
.map(manifest -> manifest.getMainAttributes().getValue(Attributes.Name.CLASS_PATH));
|
||||
if (raw.isEmpty() || raw.get().trim().isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<String> 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<Manifest> 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<Manifest> 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:");
|
||||
}
|
||||
}
|
||||
@@ -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 可执行文件。
|
||||
*
|
||||
* <p>查找优先级:</p>
|
||||
* <ol>
|
||||
* <li>用户显式配置的 {@code jpackageExecutable};</li>
|
||||
* <li>{@code java.home}/bin/{jpackage[.exe]}(即运行当前 Maven 的 JDK,最可靠);</li>
|
||||
* <li>{@code JAVA_HOME}/bin/{jpackage[.exe]};</li>
|
||||
* <li>系统 {@code PATH} 上的 jpackage。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>注意 {@code java.home} 在 JDK 中指向 JDK 根目录,但若 Maven 运行在 JRE 上,
|
||||
* 该值可能是 {@code <jdk>/jre},因此还会额外检查其父目录。</p>
|
||||
*/
|
||||
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 <jdk>/bin})或 JDK 根目录(如 {@code <jdk>})
|
||||
* @param javaHomeOverride 覆盖 {@code java.home} 的值(供测试注入),可为 {@code null}
|
||||
* @param environment {@code JAVA_HOME} 的值,可为 {@code null}
|
||||
* @return 可执行文件路径;未找到时返回空
|
||||
*/
|
||||
public static Optional<Path> 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<Path> 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<Path> candidateJavaHomes(String javaHomeOverride, String environment) {
|
||||
Set<Path> homes = new LinkedHashSet<>();
|
||||
List<String> 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 布局:<jdk>/jre/bin/java,jpackage 位于 <jdk>/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<Path> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 外部进程调用封装。
|
||||
*
|
||||
* <p>隔离对 {@link ProcessBuilder} 的直接依赖,使 Mojo 的流程逻辑可以在测试中
|
||||
* 通过注入假的执行器进行验证,而无需真的启动 jpackage。</p>
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface JpackageExecutor {
|
||||
|
||||
/**
|
||||
* 同步执行命令,等待其结束。
|
||||
*
|
||||
* @param command 完整命令行(首个元素为可执行文件路径)
|
||||
* @param workingDir 工作目录,{@code null} 表示继承当前进程
|
||||
* @param environment 需要追加/覆盖的环境变量,可为空
|
||||
* @param outputSink 逐行接收合并后的标准输出与标准错误,可为 {@code null}
|
||||
* @return 退出码
|
||||
* @throws IOException 进程无法启动或读取输出失败
|
||||
* @throws InterruptedException 等待期间线程被中断
|
||||
*/
|
||||
int execute(List<String> command,
|
||||
Path workingDir,
|
||||
Map<String, String> environment,
|
||||
Consumer<String> 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<String> command) {
|
||||
List<String> 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<String> noopSink() {
|
||||
return line -> {
|
||||
// 忽略
|
||||
};
|
||||
}
|
||||
|
||||
/** @return 空环境变量表 */
|
||||
static Map<String, String> noEnvironment() {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
package com.mirenqinggege.maven.jpackage.internal;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* jpackage 命令行参数模型与组装器。
|
||||
*
|
||||
* <p>该类不依赖 Maven API,纯粹负责把已解析好的配置翻译成 jpackage 的参数列表,
|
||||
* 因此可以被完整地单元测试。参数顺序不影响 jpackage 行为,但保持稳定顺序
|
||||
* 有利于日志可读性与测试断言。</p>
|
||||
*/
|
||||
public final class JpackageOptions {
|
||||
|
||||
/** 默认注入的 UTF-8 编码参数,用于规避跨平台控制台乱码。 */
|
||||
public static final List<String> 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<String> javaOptions = new ArrayList<>();
|
||||
private final List<String> 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<String> 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<String> arguments) {
|
||||
if (arguments != null) {
|
||||
arguments.forEach(this::addAdditionalArgument);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/** @return 用户自定义 JVM 参数的不可变视图 */
|
||||
public List<String> getJavaOptions() {
|
||||
return Collections.unmodifiableList(javaOptions);
|
||||
}
|
||||
|
||||
/** @return 追加的原始参数的不可变视图 */
|
||||
public List<String> getAdditionalArguments() {
|
||||
return Collections.unmodifiableList(additionalArguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算最终传递给 jpackage 的 JVM 参数:默认 UTF-8 参数在前,用户参数在后。
|
||||
*
|
||||
* <p>用户参数排在后面,因此同名参数(如再次指定 {@code -Dfile.encoding=GBK})
|
||||
* 可以覆盖插件注入的默认值。</p>
|
||||
*
|
||||
* @return 合并后的 JVM 参数列表
|
||||
*/
|
||||
public List<String> resolveJavaOptions() {
|
||||
List<String> 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<String> toCommandLineArguments() {
|
||||
requireNonBlank(name, "应用名称 --name 不能为空");
|
||||
requireNonBlank(appVersion, "应用版本 --app-version 不能为空");
|
||||
requireNonBlank(mainJar, "主 JAR 包 --main-jar 不能为空");
|
||||
requireNonBlank(inputDirectory, "输入目录 --input 不能为空");
|
||||
requireNonBlank(destinationDirectory, "输出目录 --dest 不能为空");
|
||||
|
||||
List<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.mirenqinggege.maven.jpackage.internal;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* 生成解压即用的平台启动脚本。
|
||||
*
|
||||
* <p>jpackage 的 {@code app-image} 会在 {@code bin/} 下生成可执行文件,
|
||||
* 但用户拿到压缩包解压后往往希望在根目录直接双击/执行。因此本类负责:</p>
|
||||
* <ul>
|
||||
* <li>类 Unix:生成 {@code run.sh}(LF 换行、{@code exec} 转发全部参数),并赋予 755 权限;</li>
|
||||
* <li>Windows:生成 CRLF 换行的 {@code run.bat},先 {@code chcp 65001} 切换到 UTF-8 代码页,
|
||||
* 再以 {@code %~dp0} 定位脚本所在目录并转发 {@code %*} 参数。</li>
|
||||
* </ul>
|
||||
*/
|
||||
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 <dest>/<name>})
|
||||
* @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 换行)。
|
||||
*
|
||||
* <p>脚本先切换到自身所在目录,保证在任意工作目录下双击/执行都能正确定位运行时,
|
||||
* 随后用 {@code exec} 替换当前进程,使信号(Ctrl+C、SIGTERM)能直接传递到应用。</p>
|
||||
*
|
||||
* @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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.mirenqinggege.maven.jpackage.internal;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* 目标操作系统平台。
|
||||
*
|
||||
* <p>用于决定 jpackage 的参数组装方式(例如 {@code --win-console} 只允许出现在 Windows 上)、
|
||||
* 启动脚本的生成格式以及应用镜像的目录布局。</p>
|
||||
*/
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.mirenqinggege.maven.jpackage.internal;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 将 Maven 版本号清洗为 jpackage 可接受的版本字符串。
|
||||
*
|
||||
* <p>jpackage 要求 {@code --app-version} 满足形如 {@code 1.0.0} 的纯数字点分格式,
|
||||
* 而 Maven 常见版本号带有 {@code -SNAPSHOT}、{@code .RELEASE}、{@code -beta-1} 等后缀,
|
||||
* 直接传递会导致 jpackage 报 {@code Version [x] is not valid} 而失败。</p>
|
||||
*
|
||||
* <p>清洗规则:</p>
|
||||
* <ol>
|
||||
* <li>从左向右截取第一个非数字且非 {@code .} 的字符之前的内容;</li>
|
||||
* <li>按 {@code .} 拆分,丢弃空片段并去掉每个片段的前导零;</li>
|
||||
* <li>结果为空时回退为 {@link #DEFAULT_VERSION}。</li>
|
||||
* </ol>
|
||||
*/
|
||||
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<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -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 的支持逻辑。
|
||||
*
|
||||
* <p>jpackage 只能在目标平台上生成对应平台的可执行文件,因此要在一台 Linux 构建机上
|
||||
* 产出 Windows 应用,常规做法是在 Wine 中运行 Windows OpenJDK 的 {@code jpackage.exe}。
|
||||
* 本类的职责:</p>
|
||||
* <ol>
|
||||
* <li>确认 {@code wine} 可执行文件存在;</li>
|
||||
* <li>在用户提供的 Windows JDK 目录中定位 {@code jpackage.exe};</li>
|
||||
* <li>把宿主机 POSIX 路径转换为 Wine 可识别的 Windows 路径。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>路径转换优先使用 {@code winepath -w},其不可用时回退到 Wine 默认的
|
||||
* {@code Z:} 盘符映射({@code Z:\} 指向宿主机的 {@code /})。</p>
|
||||
*/
|
||||
public final class WineSupport {
|
||||
|
||||
/** Wine 可执行文件名。 */
|
||||
public static final String WINE_EXECUTABLE = "wine";
|
||||
|
||||
private WineSupport() {
|
||||
// 工具类
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 {@code PATH} 中查找 wine。
|
||||
*
|
||||
* @return wine 可执行文件路径;未安装时返回空
|
||||
*/
|
||||
public static Optional<Path> 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<Path> 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<String> 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:\...} 形式。
|
||||
*
|
||||
* <p>Wine 默认把宿主机的 {@code /} 映射为 {@code Z:} 盘,因此该回退对绝大多数
|
||||
* 场景都是正确的。</p>
|
||||
*
|
||||
* @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<String> buildWineCommand(Path wineExecutable,
|
||||
Path windowsJpackage,
|
||||
List<String> jpackageArguments,
|
||||
java.util.function.Function<Path, String> pathConverter) {
|
||||
List<String> 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<String> validate(String windowsJdkHome) {
|
||||
if (windowsJdkHome == null || windowsJdkHome.isBlank()) {
|
||||
return Optional.of("启用 useWine 时必须配置 <windowsJdkHome>,指向 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();
|
||||
}
|
||||
}
|
||||
@@ -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} 的端到端单元测试。
|
||||
*
|
||||
* <p>测试通过替换 {@link JpackageExecutor} 拦截真实进程调用,从而验证:</p>
|
||||
* <ul>
|
||||
* <li>命令行组装结果(含平台自适应与 UTF-8 参数);</li>
|
||||
* <li>输入目录隔离与依赖拷贝行为;</li>
|
||||
* <li>Main-Class 智能探测与用户配置的优先级;</li>
|
||||
* <li>启动脚本生成、临时目录清理与错误处理。</li>
|
||||
* </ul>
|
||||
*/
|
||||
class PackageAppMojoTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
private PackageAppMojo mojo;
|
||||
private Path targetDir;
|
||||
private Path distDir;
|
||||
private Path inputDir;
|
||||
private Path fakeJpackage;
|
||||
|
||||
/** 记录最近一次执行的命令行。 */
|
||||
private final List<String> 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<String> args, String flag, String value) {
|
||||
return indexOfPair(args, flag, value) >= 0;
|
||||
}
|
||||
|
||||
private static int indexOfPair(List<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<Path> 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")));
|
||||
}
|
||||
}
|
||||
+137
@@ -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<String> 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<String> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Path> 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<Path> 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<Path> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> 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<String> args = baseOptions()
|
||||
.setWinConsole(true)
|
||||
.setTargetPlatform(Platform.MACOS)
|
||||
.toCommandLineArguments();
|
||||
|
||||
assertFalse(args.contains("--win-console"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Windows 平台在开启控制台模式时追加 --win-console")
|
||||
void shouldPassWinConsoleOnWindows() {
|
||||
List<String> args = baseOptions()
|
||||
.setWinConsole(true)
|
||||
.setTargetPlatform(Platform.WINDOWS)
|
||||
.toCommandLineArguments();
|
||||
|
||||
assertTrue(args.contains("--win-console"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Windows 平台关闭控制台模式时不追加 --win-console")
|
||||
void shouldRespectWinConsoleFlag() {
|
||||
List<String> args = baseOptions()
|
||||
.setWinConsole(false)
|
||||
.setTargetPlatform(Platform.WINDOWS)
|
||||
.toCommandLineArguments();
|
||||
|
||||
assertFalse(args.contains("--win-console"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("默认注入全套 UTF-8 参数")
|
||||
void shouldInjectUtf8Options() {
|
||||
List<String> 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<String> 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<String> 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<String> 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<String> args = baseOptions().setMainClass("com.example.Main").toCommandLineArguments();
|
||||
assertTrue(containsPair(args, "--main-class", "com.example.Main"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("附加的元数据与图标参数按需出现")
|
||||
void shouldIncludeOptionalMetadata() {
|
||||
List<String> 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<String> args = baseOptions().toCommandLineArguments();
|
||||
assertFalse(args.contains("--icon"));
|
||||
assertFalse(args.contains("--vendor"));
|
||||
assertFalse(args.contains("--description"));
|
||||
assertFalse(args.contains("--copyright"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("原始参数被原样追加")
|
||||
void shouldAppendAdditionalArguments() {
|
||||
List<String> 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<String> 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<String> args, String flag, String value) {
|
||||
return indexOfPair(args, flag, value) >= 0;
|
||||
}
|
||||
|
||||
private static int indexOfPair(List<String> 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;
|
||||
}
|
||||
}
|
||||
+130
@@ -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/<app> 时顺手补齐可执行权限")
|
||||
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<String> 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)));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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<Path> 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<Path, String> converter = path -> "Z:" + path.toString().replace('/', '\\');
|
||||
List<String> arguments = List.of("--type", "app-image", "--name", "demo");
|
||||
|
||||
List<String> 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<String> 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<String> 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<String> 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<String> 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"), "非路径参数应保持原样");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user