You can use an assembly to create a zip of the jlink output as well as the additional "loose files".
The Assembly Plugin for Maven enables developers to combine project output into a single distributable archive that also contains dependencies, modules, site documentation, and other files.
Using jpackage
(and perhaps JPackageScriptFX) would be an alternative to this approach if you also want a platform specific installer and uninstaller, or James's solution is also simpler if you that fits your requirements. This alternate approach of using jlink+assembly includes more functionality and is presented in case it is useful to you. If the additional complexity and functionality aren't required, then use James's solution instead.
Here is an incomplete example.
The example does a bit more than what you are asking, just discard the bits you don't need.
- It assumes a modular application (though it can use some kinds of automatic modules, such as the postgres driver, if needed).
- It uses jlink to link the application for the target architecture of the build machine.
- It uses an assembly to create a zip distribution of the application.
- The assembly includes "loose files", jlink image, launcher, default config, and non-modular libraries.
- The jlink image includes a Java runtime, JavaFX runtime, all module dependencies for your application, and your application's module.
- The "loose files" are any files that you want visible on the file system such as editable config files, launcher scripts, non-modular libraries to be loaded from the classpath, etc.
- The zip is targeted to just one operating architecture (e.g. Mac or Linux or Windows, but not all of them). The architecture used is the one on which the project is built.
- When creating the zip it copies all files from an image-overlay directory into the zip, adding to and replacing, where needed, files generated by the jlink process.
- One of the overlay files is a custom launcher file which replaces the jlink generated launcher.
- Non-modular libraries are copied by the assembly process into a lib directory and the custom launcher puts them on the classpath.
- This allows you to use jlink to link your modular application, but also include the automatic modules, which jlink doesn't handle, on the classpath, so the app will still run. However, it will only work for service libraries like the postgres driver. If you have a library need that you call the API directly from your code (e.g. the apache HTTP client, which is currently an automatic module), then you will need to use a different packaging solution (such as jpackage) rather than jlink.
- Additional exports are used at the compile and execution stage to break modularity and allow access to private API in the JDK/JavaFX (if needed).
- Profiles are used to allow switchable customized config files based on an environment selector.
- Command line arguments are processed to handle the config setup and profile setup.
- Custom JVM switches are used to enable Java preview functions.
- Default config properties for the application originate from
${project.basedir}/src/main/resources/application.properties
.
- Profile-specific config properties,
application-<profile>.properties
, are also used if provided.
- Once deployed and the zip is expanded, the config properties will end up in
<deployment directory>/conf/application.properties
and can be edited and modified there if needed.
- Profile-specific config properties,
application-<profile>.properties
, are also used if provided.
Usage
Replace myapp
with your app name and build target directory name, and com.example
with your package, preserving case where necessary.
Run mvn:package
to generate the assembly.
The assembly output ends up in:
${project.basedir}/target/myapp-<version>.zip
Run mvn:install
to publish the assembly to a maven repository. The assembly will end up in the same maven repository location as other output artifacts of the build (such as the pom.xml and jar file), and will be classified using the assembly type (zip). For example, the following maven co-ordinates (groupId:artifactId:classifier:version):
com.example:myapp:zip:1.0-SNAPSHOT
For a user to deploy your app, they
- Download the zip file then unzip it to a deployment directory.
- Modify the
<deployment directory>/conf/application.properties
file if needed.
- Execute the launch script
<deployment directory>/bin/myapp
.
If they want to select a specific config profile on startup, they can provide the argument --profile <profile-name>
to the launch script.
${project.basedir}/src/main/assembly/zip.xml
If you don't have non-modular libs, you can omit that section, otherwise, replace the files in that section with your non-modular (automatic module) libs.
<assembly xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.2"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.2 http://maven.apache.org/xsd/assembly-1.1.2.xsd">
<id>zip</id>
<includeBaseDirectory>true</includeBaseDirectory>
<formats>
<format>zip</format>
</formats>
<fileSets>
<fileSet>
<directory>${project.basedir}/image-overlay</directory>
<outputDirectory>/</outputDirectory>
</fileSet>
<fileSet>
<directory>${project.basedir}/target/myapp</directory>
<outputDirectory>/</outputDirectory>
</fileSet>
<fileSet>
<directory>${project.basedir}/src/main/resources</directory>
<includes>
<include>*.properties</include>
</includes>
<outputDirectory>/conf</outputDirectory>
</fileSet>
</fileSets>
<dependencySets>
<dependencySet>
<outputDirectory>non-modular-libs</outputDirectory>
<includes>
<include>org.postgresql:postgresql:jar:*</include>
</includes>
</dependencySet>
</dependencySets>
</assembly>
plugin config in pom.xml
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.10.1</version>
<configuration>
<source>18</source>
<target>18</target>
<compilerArgs>
<arg>--enable-preview</arg>
<arg>--add-exports</arg>
<arg>javafx.web/com.sun.javafx.webkit=com.example</arg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
<groupId>org.openjfx</groupId>
<artifactId>javafx-maven-plugin</artifactId>
<version>0.0.8</version>
<executions>
<execution>
<id>default-cli</id>
<phase>package</phase>
<goals>
<goal>jlink</goal>
</goals>
<configuration>
<mainClass>
com.example/com.example.MyApp
</mainClass>
<launcher>myapp</launcher>
<jlinkImageName>myapp</jlinkImageName>
<noManPages>true</noManPages>
<stripDebug>true</stripDebug>
<noHeaderFiles>true</noHeaderFiles>
<jlinkExecutable>${jlinkExecutable}</jlinkExecutable>
<!--<jlinkVerbose>true</jlinkVerbose>-->
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
<configuration>
<appendAssemblyId>false</appendAssemblyId>
<descriptors>
<descriptor>src/main/assembly/zip.xml</descriptor>
</descriptors>
</configuration>
</execution>
</executions>
</plugin>
Customized launcher image-overlay/bin/myapp
Overwrites the default launcher created by jlink.
Place other "loose files" that you want included in your distribution in the image-overlay directory or appropriate sub-directories.
#!/bin/sh
# disable glob (* wildcard) expansion
set -f
DIR=`dirname $0`
CONFIG_DIR=$DIR/../conf
JLINK_VM_OPTIONS="--enable-preview -cp $DIR/../non-modular-libs/* --add-exports javafx.web/com.sun.javafx.webkit=com.example"
$DIR/java $JLINK_VM_OPTIONS -m com.example/com.example.MyApp --configdir "$CONFIG_DIR" "$@"
Config.java
import javafx.application.Platform;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Objects;
import java.util.Properties;
import java.util.stream.Collectors;
public class Config {
private static final Logger log = LoggerFactory.getLogger(Config.class);
private String configPropertiesResource =
"/application"
+ (configProfile != null
? "-" + configProfile
: "")
+ ".properties";
private static final Properties properties = new Properties();
private static Config instance;
private static String configDir;
private static String configProfile;
public static String getConfigProfile() {
return configProfile;
}
public static Config getInstance() {
if (instance == null) {
instance = new Config();
}
return instance;
}
private Config() {
try {
// use the config override directory if defined, otherwise use the module resource path.
InputStream configInputStream;
if (configDir != null) {
Path configFilePath = Paths.get(configDir, configPropertiesResource);
if (!Files.exists(configFilePath)) {
log.error("Config file does not exist: {}", configFilePath.toAbsolutePath());
System.exit(-1);
}
configInputStream = Files.newInputStream(configFilePath);
log.info("Loading config properties from config file: {}", configFilePath.toAbsolutePath());
} else {
configInputStream = Config.class.getResourceAsStream(configPropertiesResource);
log.info("Loading config properties from config resource: {}", Config.class.getResource(configPropertiesResource));
}
properties.load(configInputStream);
StringWriter stringWriter = new StringWriter();
PrintWriter printWriter = new PrintWriter(stringWriter);
properties.list(printWriter);
String sortedProperties =
stringWriter.toString()
.lines()
.sorted()
.collect(
Collectors.joining(
"\n"
)
);
log.info("Config properties: {}", sortedProperties);
} catch (IOException e) {
log.error("Unable to read configuration", e);
Platform.exit();
}
}
public static void initConfigDir(String newConfigDir) {
configDir = newConfigDir;
}
public static void initConfigProfile(String profileName) {
configProfile = profileName;
}
public boolean isTheSkyBlue() {
return Boolean.parseBoolean(
properties.getProperty(
"sky.blue",
"true"
)
);
}
}
You can then access a config property from anywhere in your app:
boolean skyBlue = Config.instance().isTheSkyBlue();
/src/main/resources/application.properties
sky.blue=true
To create configuration defaults for different environments, define separate properties files such as application-dev.properties
, then when executing the app, pass a profile name, e.g. --profile dev
to select the configuration setup for that environment.
The default naming of the config file application-properties
and the profile selection, follows the same naming conventions used in SpringBoot configuration, so if you wish to convert this method to use SpringBoot at some time in the future, the conversion is simpler. It also means that configuration of your application should be easy to understand for anybody used to configuration a SpringBoot application.
The main
method in your JavaFX application. Place this in your class with extends the JavaFX Application
class.
The custom Config class will load the configuration from the properties files for the appropriate environment profile in the initialized config directory.
Alternatively, you could use SpringBoot to load the configuration as that has lots of support for such things, but that is out of scope for this answer and SpringBoot (currently) is hard to adapt and use with the Java Platform Module System used by JavaFX apps.
public static void main(String[] args) {
processArguments(args);
launch();
}
private static void processArguments(String[] args) {
for (int i = 0; i < args.length; i++) {
switch (args[i]) {
case "--configdir" -> {
ensureValueArgAfter(args, i);
Config.initConfigDir(args[++i]);
log.info("Initialized config directory as: {}", args[i]);
}
case "--profile" -> {
ensureValueArgAfter(args, i);
Config.initConfigProfile(args[++i]);
log.info("Initialized config profile as: {}", args[i]);
}
default -> incorrectUsage();
}
}
}
private static void ensureValueArgAfter(String[] args, int idx) {
if (idx == args.length - 1) {
incorrectUsage();
}
}
private static void incorrectUsage() {
System.err.println(
"""
Usage: java com.example.MyApp --configdir <dirname> --profile <local|dev|qa|prod>
Adjust the command line for the "java" command based on your usage, see the "java" command man page for more info.
"""
);
System.exit(-1);
}
Example unzipped output directory
This example omits display of most of the paths and files added for the JRE by the jlink
process, as most of those files are irrelevant for demonstration purposes. The additional jlink generated files will, however, be included in the resultant zip, so the zip created will have more files than are shown here.
myapp-1.0-SNAPSHOT
├── bin (overlaid app launcher script)
│ └── myapp
├── conf (overlaid app config files)
│ ├── application-dev.properties
│ ├── application-local.properties
│ ├── application-qa.properties
│ ├── application.properties
├── legal (jre legal documents)
├── lib (jre libraries and jlink created modular image)
├── non-modular-libs (overlaid non modular libraries)
│ └── postgresql-42.5.0.jar
└── release (defines jre and base modules in the jlink image)