Search code examples
javamavenjavafxexecutable-jarmaven-assembly-plugin

JavaFX - How to compile uber jar with maven and create a installer?


I need to compile a JavaFX 20 application into an executable Jar file that includes all the dependencies listed in the pom.xml (including javaFX itself and all the other JAR files). My project is based on Maven. I tried many different maven plugins but none of them were able to make the final jar executable by double clicking, although I was able to run in in the command line with the java -jar command. The intention is to distribute this app with next/next/finish installers on Linux, Windows and MacOS. The end user profile is a lab researcher with low IT knowledge (I work for a NPO that helps protecting the Amazon forest). Is there an objective way to do this?

I already tried many maven plugins with different goals (resources, dependencies, shade, compiler, etc) but no success at all.


Solution

  • Self-Contained Application

    Your desire to create an installer lends itself to creating an entirely self-contained application. That is an application that bundles not only your code and its third-party dependencies, but also the Java Run-Time Environment itself. Your end user won't need to install Java separately.

    GraalVM Native Image

    One option for creating a self-contained application is GraalVM's native image. Note that this will also ahead-of-time compile your Java code to native code.

    I have very little experience with GraalVM, so I won't give an example, but here are some links that may help:

    jpackage

    A relatively accessible tool for creating a self-contained applications and installers is jpackage. It comes with the JDK since version 16. See the Packaging Tool User Guide for more information. It would also help to understand jlink, as jpackage will either use jlink "behind the scenes" to generate a custom run-time image, or you'll use jlink explicitly and then provide jpackage with the custom run-time image.

    One significant downside to using jpackage, however, is that it can only create application images and installers for the operating system it is running on. If you want a Windows application, then you'll have to build your project on Windows, with the same being true for Linux and macOS. And as jewelsea explains in a comment, there are also platform-specific issues you'll have to resolve:

    Creating installers for multiple platforms is tricky and each platform has its own quirks you need to work with. For example, Windows requires increasing versions for updates, OS X requires (paid I believe) developer signing certs. So be prepared to spend some time and (probably) a bit of money to create the installers. Though there are existing guides, such as those I referenced, there will probably still be some platform and app-specific quirks you will need to work through on your own with limited outside help.

    If you're going to create a self-contained application, then I would suggest you forgo creating a fat/uber/shadowed JAR file. A self-contained application already contains all dependencies, so a shadowed JAR file is unlikely to provide any benefits. Plus, if you're going to be using jpackage to create the installer, you might as well design your entire deployment around jpackage from the start.

    Using Maven profiles can solve a lot of the platform-specific configuration issues. While any remaining platform-specific configuration, as well as building the application on multiple platforms and publishing it automatically, can likely be accomplished with some kind of CI/CD pipeline. Though remember to never commit any secrets to your VCS (e.g., Git), which by extension means do not put any secrets in the POM file(s).

    Example

    Here is a POM that is configured to create an msi installer file when the windows profile is activated. Note there is only configurations for Windows in this example. The org.panteleyev:jpackage-maven-plugin plugin is used to execute jpackage.

    This POM is designed for a non-modular application. As such, it's configured so that the JavaFX modules are added to the custom run-time image, while the project code and any other dependencies are configured to be placed on the class-path via --input and --main-jar. By having JavaFX in the custom run-time image, it will implicitly be on the module-path.

    Disclaimer: I'm more familiar with Gradle than Maven, so there may be ways to simplify the following POM.

    <?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.example</groupId>
      <artifactId>app</artifactId>
      <version>1.0</version>
    
      <name>app</name>
    
      <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
        <javafxVersion>21.0.1</javafxVersion>
        <libsDir>${project.build.directory}/artifacts/libs</libsDir>
        <javafxModsDir>${project.build.directory}/artifacts/javafx</javafxModsDir>
      </properties>
    
      <dependencies>
        <dependency>
          <groupId>org.openjfx</groupId>
          <artifactId>javafx-controls</artifactId>
          <version>${javafxVersion}</version>
        </dependency>
      </dependencies>
    
      <build>
        <pluginManagement>
          <!-- Lock in versions. -->
          <plugins>
            <plugin>
              <artifactId>maven-clean-plugin</artifactId>
              <version>3.3.2</version>
            </plugin>
            <plugin>
              <artifactId>maven-resources-plugin</artifactId>
              <version>3.3.1</version>
            </plugin>
            <plugin>
              <artifactId>maven-compiler-plugin</artifactId>
              <version>3.11.0</version>
            </plugin>
            <plugin>
              <artifactId>maven-jar-plugin</artifactId>
              <version>3.3.0</version>
            </plugin>
            <plugin>
              <artifactId>maven-dependency-plugin</artifactId>
              <version>3.6.1</version>
            </plugin>
            <plugin>
              <groupId>org.panteleyev</groupId>
              <artifactId>jpackage-maven-plugin</artifactId>
              <version>1.6.0</version>
            </plugin>
          </plugins>
        </pluginManagement>
    
        <plugins>
          <!-- 
            Configure JAR plugin to set Main-Class entry in the JAR's manifest. Also, put the JAR
            file in the same directory as the non-JavaFX dependencies to make using jpackage easier.
          -->
          <plugin>
            <artifactId>maven-jar-plugin</artifactId>
            <configuration>
              <outputDirectory>${libsDir}</outputDirectory>
              <archive>
                <manifest>
                  <mainClass>com.example.app.Main</mainClass>
                </manifest>
              </archive>
            </configuration>
          </plugin>
    
          <!-- Copy dependencies into project-local directories to make using jpackage easier. -->
          <plugin>
            <artifactId>maven-dependency-plugin</artifactId>
            <executions>
              <!-- 
                Put JavaFX JARs in their own directory so they are not included in the input directory
                and can easily be placed on the module-path of jpackage.
              -->
              <execution>
                <id>copy-javafx-deps</id>
                <phase>package</phase>
                <goals>
                  <goal>copy-dependencies</goal>
                </goals>
                <configuration>
                  <outputDirectory>${javafxModsDir}</outputDirectory>
                  <includeGroupIds>org.openjfx</includeGroupIds>
                </configuration>
              </execution>
              <!-- 
                Put all non-JavaFX JARs in a separate directory to use with the input argument
                of jpackage. The project JAR will be placed here as well.
              -->
              <execution>
                <id>copy-nonjavafx-deps</id>
                <phase>package</phase>
                <goals>
                  <goal>copy-dependencies</goal>
                </goals>
                <configuration>
                  <outputDirectory>${libsDir}</outputDirectory>
                  <excludeGroupIds>org.openjfx</excludeGroupIds>
                </configuration>
              </execution>
            </executions>
          </plugin>
    
          <!-- Common jpackage configurations. -->
          <plugin>
            <groupId>org.panteleyev</groupId>
            <artifactId>jpackage-maven-plugin</artifactId>
            <configuration>
              <modulePaths>
                <modulePath>${javafxModsDir}</modulePath>
              </modulePaths>
              <!-- 
                Must explicitly add modules since project code is not modular, meaning there's
                no module-info.java file with requires directives.
    
                Include the jdk.localedata module to ensure internationalization works. You can filter
                which locales are included via jlink options.
              -->
              <addModules>javafx.controls,jdk.localedata</addModules>
              <input>${libsDir}</input>
              <mainJar>${project.name}-${project.version}.jar</mainJar>
              <temp>${project.build.directory}/jpackage/temp</temp>
            </configuration>
          </plugin>
        </plugins>
    
      </build>
    
      <profiles>
    
        <profile>
          <id>windows</id>
          <build>
            <plugins>
              <!-- Windows-specific jpackage configurations. -->
              <plugin>
                <groupId>org.panteleyev</groupId>
                <artifactId>jpackage-maven-plugin</artifactId>
                <executions>
                  <execution>
                    <id>windows-msi</id>
                    <phase>package</phase>
                    <goals>
                      <goal>jpackage</goal>
                    </goals>
                    <configuration>
                      <type>MSI</type>
                      <destination>${project.build.directory}/jpackage/windows-msi</destination>
                      <winPerUserInstall>true</winPerUserInstall>
                      <winDirChooser>true</winDirChooser>
                      <winUpgradeUuid>7c59c875-1ad3-4042-9c9f-fed5fc3f8ab9</winUpgradeUuid>
                    </configuration>
                  </execution>
                </executions>
              </plugin>
            </plugins>
          </build>
        </profile>
    
      </profiles>
    
    </project>
    

    And here's an example com.example.app.Main class to go along with the POM:

    package com.example.app;
    
    import javafx.application.Application;
    import javafx.scene.Scene;
    import javafx.scene.control.ListView;
    import javafx.stage.Stage;
    
    public class Main extends Application {
    
        @Override
        public void start(Stage primaryStage) {
            // List view will display the modules included in the custom run-time image.
            var listView = new ListView<String>();
            ModuleLayer.boot()
                .modules()
                .stream()
                .map(Module::getName)
                .sorted()
                .forEach(listView.getItems()::add);
            primaryStage.setTitle("JavaFX " + System.getProperty("javafx.version") + " Application");
            primaryStage.setScene(new Scene(listView, 600, 400));
            primaryStage.show();        
        }
    }
    

    To create the msi file, execute:

    mvn -P windows package
    

    And you should get a target/jpackage/windows-msi/app-1.0.msi installer file.

    Note you must execute that on Windows, and you must have WiX Toolset 3.x.x installed (version 4.x.x doesn't seem to work with jpackage, or at least I couldn't get it to work). If you don't want to install WiX 3.x.x just for running the example, then change the type to <type>APP_IMAGE</type>. You won't get an installer file, but you'll end up with an application image that you can run as is.


    Executable JAR

    You can create an executable fat/uber/shadowed JAR file with JavaFX via Maven using the Maven Shade Plugin. Note this will inherently put everything on the class-path. Any code that relies explicitly on modules may break with this setup, though such code is rare. Additionally, JavaFX does not technically support being loaded from the class-path. Doing so will not break anything as of JavaFX 21, as far as I know, but any issues caused by this configuration are unlikely to be fixed by the JavaFX developers.

    See David Weber's answer for an example using the Maven Shade Plugin.

    If you want to go with a shadowed executable JAR file as your deployment strategy, then I suggest you forgo creating an installer. Just have your end user install the appropriate version of Java (you can send them the installer for that), and then they can just double-click the JAR file to make it run. Make sure your end user enables associating *.jar files with Java if the Java installer gives that option.

    If you want a cross-platform executable JAR file, you'll have to declare a JavaFX dependency for each platform manually. JavaFX relies on platform-specific native code, and the Maven artifacts embed that native code in the JAR file, but only for a particular platform. For instance, javafx-graphics-21.0.1-win.jar contains native code for Windows, but not Linux or macOS.

    Note that when JavaFX is on the class-path, as is the case here, then your main class cannot be a subclass of javafx.application.Application. You must have a separate "launcher class" as the main class. Something like:

    package sample;
    
    import javafx.application.Application;
    
    public class Launcher {
    
        public static void main(String[] args) {
            Application.launch(YourAppClass.class, args);
        }
    }