基于 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>
461 lines
17 KiB
Markdown
461 lines
17 KiB
Markdown
# 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)。
|