Search code examples
javaspring-bootjavafxjpackage

How to make a Windows/MacOS/Linux installer for JavaFX & Spring Boot application


I am a beginner for JavaFX. I have a JavaFX & Spring Boot application which runs as a STOMP Websocket client. I did build it as a jar file and it runs well with Java command. My question is how to wrap it in a Windows/Mac/Linux installer (including JRE) so that it can be installed easily on other computers. My pom.xml

<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.1</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.my-group</groupId>
    <artifactId>my-app</artifactId>
    <version>1.0</version>
    <name>my-app</name>

    <properties>
        <java.version>17</java.version>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.apache.httpcomponents.client5</groupId>
            <artifactId>httpclient5</artifactId>
            <version>5.2.1</version>
        </dependency>
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-controls</artifactId>
            <version>19.0.2.1</version>
        </dependency>
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-fxml</artifactId>
            <version>19.0.2.1</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

My main class

package com.mygroup.myapp;

import javafx.application.Application;
import javafx.application.Platform;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;

import java.awt.*;

@SpringBootApplication
public class MyApplication extends Application {

    private ConfigurableApplicationContext applicationContext;
    private Parent rootNode;

    public static void main(String[] args) {
        if (!SystemTray.isSupported()) {
            System.setProperty("java.awt.headless", "false");
        }
        Application.launch(args);
    }

    @Override
    public void init() throws Exception {
        applicationContext = SpringApplication.run(MyApplication.class);
        FXMLLoader fxmlLoader = new FXMLLoader(getClass().getResource("/login.fxml"));
        fxmlLoader.setControllerFactory(applicationContext::getBean);
        rootNode = fxmlLoader.load();
    }

    @Override
    public void start(Stage primaryStage) {
        Platform.setImplicitExit(false);

        primaryStage.setResizable(false);
        primaryStage.setScene(new Scene(rootNode));
        primaryStage.show();
    }

    @Override
    public void stop() {
        applicationContext.close();
        Platform.exit();
    }
}

I have tried this tutorial using JPackage but it does not run after installation.


Solution

  • Background

    This example is only for creating a Windows Installer for a JavaFX SpringBoot application, using Maven and jpackage running on Windows, with the assistance of IntelliJ Idea, though the steps will work for any Java application and can performed without Idea, (given some adaption and modification).

    This is not a guide on how to integrate SpringBoot with JavaFX, but rather how to package and deploy such an application to a Windows machine.

    There are a lot of steps here, the reason for that is:

    1. This guide needs enough detail that somebody with little knowledge could follow it and still have a reasonable chance of succeeding.
    2. Packaging apps to native installers is a large topic and has some complex details.
    3. There are many questions asking how to do this on StackOverflow, and there are indications that many people fail to achieve their goal of creating an easily installed and shared packaged application.
    4. A lot of those who fail seem to find alternate low-quality resources on the web or in youtube channels, which lead them to failure.

    As long as you follow this guide, you should be able to create an installer in a reasonable amount of time and have some chance of troubleshooting the build and installation process when something goes wrong.

    Steps

    Package your App into an Installer

    1. Install Java

    2. Configure your JAVA_HOME with JDK 21+.

    3. Install the JavaFX SDK 21.0.1+.

    4. Install Wix 3.x.

      • Wix 4.x does not work with JDK 21 jpackage.
    5. In Idea 2023.3.2 or later, create a new JavaFX project.

      • Name the project wininstalled.
      • Choose Maven as the build system.
      • Don't select any other options from the new project wizard.
    6. Delete the module-info.java file.

      • SpringBoot 3.2.x works poorly with the Java Platform Module System.
      • You will have a non-modular project, but will reference the JDK and JavaFX modules as modules, not as jars on the classpath. All of your Spring related stuff and other app dependencies will be referenced as jars on the classpath and not as modules.
    7. Upgrade the maven version in the maven wrapper of the generated project.

      • Edit <your project home>\.mvn\wrapper\maven-wrapper.properties

      • Ensure the version listed in the distributionUrl is at least 3.9.6.

        distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip
        
      • This is because the akman jpackage plugin used here requires at least that version of maven to work.

    8. Replace the generated pom.xml file, with the one below.

      • Leave everything in the pom.xml as is.
      • You can adjust it to custom fit your app later, but ensure you get the example HelloWorld app up and running first.
    9. Reimport the Maven project into Idea.

      • Because of the project Maven upgrade, the import may not work immediately, in which case close the project, restart the IDE and open the project again, then resync the Maven project, hopefully it will be synched with the IDE OK after that.
    10. Go to the HelloApplication, and try to run it.

      • The app will fail due to missing JavaFX components.
    11. Edit the run configuration that your execution attempt generated so that you can set the VM arguments for it.

      • Set these values (adjust for additional or removed JavaFX modules as needed):

        --add-modules javafx.controls,javafx.fxml --module-path <your JavaFX SDK path>/lib
        
      • Make sure you set VM arguments NOT program arguments.

      • Do actually click the link and study the image of how the VM arguments are set, so you don't make a mistake here.

    12. Run the application again, it should run in the IDE.

    13. Copy the icon file for your application from icon archive (Choose All Download formats | Download ICO) to

      /src/main/resources/com/example/wininstalled/coffee.ico
      
    14. Go to the Maven panel in the IDE, your project name there should be the name configured in the pom.xml (wininstalled), under Lifecycle, double click clean, then click install.

    15. Your application will be built and packaged.

    What the packaging did

    • Maven will compile, and test your app.
    • Then it will package your code and resources into a jar file.
    • Next it will copy non-modular dependencies to a target/jpackage-input directory.
    • Then it will invoke jpackage using the akman plugin configuration.
    • jpackage will create a jlinked image of the java runtime using a default set of modules and the modules listed in the akman configuration (these include both modules from the JDK and modules from JavaFX) and those will be baked into the image
      • you won't even find the module jmod or jar files anywhere in the image, but all of the code and native libraries for the JRE and JavaFX to run on a Windows Intel platform are there.
      • these modular dependencies will be run from the module path.
    • jpackage will copy the non-modular dependencies from the target/jpackage-input directory and configure the application startup executable that it creates to run with the non-modular jars on the classpath.
    • jpackage will invoke Wix to create an installer for the application.
      • In this example we have chosen EXE, rather than MSI, as the installer type.
    • The installer is the <your project>/target/jpackage/<your project name>.exe file.

    Install the app

    • To run the installer, select the Terminal tab in Idea

      • You can use the command prompt instead if you like or double click the installer exe in explorer.
    • Execute the installer

       .\target\jpackage\jpackage\wininstalled-24.01.1013.1128.exe
      
      • The numbers after the installer name will change for each build, and they match the version number that the jpackage configuration assigned Windows to use for the application update and to display when the user queries version information on the installed application in the OS.
    • The installer will execute and install the application onto your machine.

    • The application is installed under:

       \Users\<your username>\AppData\Local\<your app name>\<your app name>.exe
      
    • A shortcut to the application with the coffee icon you configured will be added to your desktop.

    • The app is also available from the Windows Start menu,

    • The app can be removed via the Windows Add or Remove Programs item in the Windows Control Panel.

    • You can double click your application icon to run your app.

    • You can run the application from the command line by executing the .exe file which has been installed (not the installer exe, but the program exe).

    Updating the App

    • If you run a new build, Maven will clean the existing jpackage build directories and rebuild their contents.
    • Each build is timestamped and generates a unique Window Application version ID for the application. The timestamp will be later than the previous version, so the version will be higher.
    • Timestamp versioning is used because Windows checks the app version when you try to install the app. If the same app at the same version is already installed, then the new install attempt will fail. Applying a new timestamped version for each build is a convenient way of avoiding an update failure due to trying to reinstall the same application version. The timestamps also help to know which version you have installed on a machine and when it was created.
    • When you run the newly created installer, Windows will detect there is already a version of the application installed with a lower version number, and then remove it, and replace it with your new application version.
    • Double-clicking on the same installed icon on the desktop will now run the new version of the application. 

    Troubleshooting

    When things go wrong, which is quite likely . . . for instance, maybe you click the icon of the installed application and nothing happens, and then you think you are stuck, but you are not . . .

    • Ensure you can run the application within the IDE.
    • Ensure that the package target of maven can build a jar.
    • Ensure that you can run that jar from a command line using your installed JDK with the JavaFX SDK lib directory added to the module path.
    • If something goes wrong with Maven, then run Maven in debug mode (it will unfortumately output way too much info, but you might find something useful, like the generated jpackage command that the plugin used to invoke jpackage, or perhaps some error message generated by the wix software).
    • Once you know the command that the plugin is using to invoke jpackage, you can run and test the same command from the command line manually if needed to assist in debugging.
    • Change the package configuration in the pom.xml for the akman jpackage plugin to enable the win console.
    • Use the Add Remove program function of Windows to eliminate any old copies of your app that might have been installed.
    • Make sure the packaging runs and creates an icon on your desktop.
    • In a console, change to the directory where the application has been installed and run the <user dir>/AppData/Local/<your app name>/<your app name>.exe file from the command line (this is the installed app, not the installer app). If it fails with a stack trace, you will now see the stack trace in the console explaining what went wrong.

    References

    Creating installers for non-SpringBoot or non-JavaFX applications

    Most of the steps described here are general for packaging any Java program and not specific for SpringBoot or JavaFX. So you can apply these steps for other application types with some modifications.

    Even though the target here is SpringBoot, most of the information is generic to deploying a Java application on Windows using a Windows Installer and not that specific for SpringBoot. The only real complication with SpringBoot is that it works best with a non-modular application, but other than that there is nothing special about it in respect to creating installers.

    Similarly, for JavaFX it is only supported when used as modules, so that is what is demonstrated here. Note that a non-modular application can still use JDK and JavaFX modules, so having a non-modular application that uses SpringBoot in a non-modular way, but uses a custom modular Java runtime and JavaFX modules is fine.

    Creating a cross-platform build

    The steps described here are for a Windows-only build.

    Steps to create installers for other platforms can be followed and are largely similar to the steps outlined here for Windows. However, they must be run on a machine of the same type as the target platform you are building for, and will create a platform-specific installer for that machine type.

    The installers built by jpackage will differ, not only by OS, but by architecture type (for instance a Mac Intel installer must be created on a Mac Intel machine, and a Mac M-series processor installer must be created on a Mac M-series machine).

    The steps for other platforms will differ slightly (e.g. linux builds will use rpm or deb packaging rather than wix packaging, and mac builds will create Mac-type package formats such as pkg or dmg). There are different requirements for some things like icon types and formats for different platforms, as well as possibly requirements for signing the app for distribution, which may require paid developer certificates. None of those topics are covered here, but there is information on them in the jpackage resource guide from Oracle.

    Example pom.xml

    <?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
        <modelVersion>4.0.0</modelVersion>
    
        <groupId>com.example</groupId>
        <artifactId>wininstalled</artifactId>
        <version>1.0-SNAPSHOT</version>
        <name>wininstalled</name>
        <description>An installable SpringBoot JavaFX application for Windows</description>
    
        <properties>
            <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
            <javafx.version>21.0.1</javafx.version>
            <jpackageInputDirectory>${project.build.directory}/jpackage-input</jpackageInputDirectory>
            <maven.build.timestamp.format>yy.MM.ddHH.mmss</maven.build.timestamp.format>
        </properties>
    
        <dependencies>
            <dependency>
                <groupId>org.openjfx</groupId>
                <artifactId>javafx-controls</artifactId>
                <version>${javafx.version}</version>
            </dependency>
            <dependency>
                <groupId>org.openjfx</groupId>
                <artifactId>javafx-fxml</artifactId>
                <version>${javafx.version}</version>
            </dependency>
    
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-data-jpa</artifactId>
                <version>3.2.1</version>
            </dependency>
    
            <dependency>
                <groupId>com.h2database</groupId>
                <artifactId>h2</artifactId>
                <version>2.2.224</version>
                <scope>runtime</scope>
            </dependency>
        </dependencies>
    
        <build>
            <plugins>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-jar-plugin</artifactId>
                    <version>3.3.0</version>
                    <configuration>
                        <outputDirectory>${jpackageInputDirectory}</outputDirectory>
                    </configuration>
                </plugin>
    
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-dependency-plugin</artifactId>
                    <version>3.6.0</version>
                    <executions>
                        <execution>
                            <phase>package</phase>
                            <goals>
                                <goal>copy-dependencies</goal>
                            </goals>
                            <configuration>
                                <outputDirectory>
                                    ${jpackageInputDirectory}/lib
                                </outputDirectory>
                                <excludeGroupIds>
                                    org.openjfx
                                </excludeGroupIds>
                                <includeScope>
                                    runtime
                                </includeScope>
                            </configuration>
                        </execution>
                    </executions>
                </plugin>
    
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-compiler-plugin</artifactId>
                    <version>3.11.0</version>
                    <configuration>
                        <source>21</source>
                        <target>21</target>
                        <parameters>true</parameters>
                    </configuration>
                </plugin>
    
                <plugin>
                    <artifactId>maven-clean-plugin</artifactId>
                    <version>3.3.1</version>
                    <executions>
                        <execution>
                            <id>auto-clean</id>
                            <phase>verify</phase>
                            <goals>
                                <goal>clean</goal>
                            </goals>
                            <configuration>
                                <excludeDefaultDirectories>true</excludeDefaultDirectories>
                                <filesets>
                                    <fileset>
                                        <directory>${project.build.directory}/jpackage</directory>
                                    </fileset>
                                </filesets>
                            </configuration>
                        </execution>
                    </executions>
                </plugin>
    
                <plugin>
                    <groupId>com.github.akman</groupId>
                    <artifactId>jpackage-maven-plugin</artifactId>
                    <version>0.1.5</version>
                    <executions>
                        <execution>
                            <phase>verify</phase>
                            <goals>
                                <goal>jpackage</goal>
                            </goals>
                            <configuration>
                                <name>wininstalled</name>
                                <appversion>${maven.build.timestamp}</appversion>
                                <copyright>Unrestricted freeware</copyright>
                                <description>JavaFX windows installed app demo.</description>
                                <vendor>Acme Widgets, Inc.</vendor>
                                <icon>${project.basedir}/src/main/resources/com/example/wininstalled/coffee.ico</icon>
                                <modulepath>
                                    <dependencysets>
                                        <dependencyset>
                                            <includeoutput>false</includeoutput>
                                            <excludeautomatic>true</excludeautomatic>
                                            <includes>
                                                <!-- todo would it be better to fetch jmods and use them? -->
                                                <include>glob:**/javafx-*-win.jar</include>
                                            </includes>
                                        </dependencyset>
                                    </dependencysets>
                                </modulepath>
                                <addmodules>
                                    <!-- we add required modules here,
                                         we need to include base ones from the jdk which are not
                                         part of the minimum service set that jpackage uses by default,
                                         for example jdk.crypto.cryptoki is needed for ssl support and
                                         jdk.crypto.ec if you need to support elliptic curve ciphers in ssl
                                         and java.sql if you (or a library you use) uses jdbc, etc.
                                         you would want different ones for another app,
                                         libraries that are not treated as modular should need to be listed,
                                         transitively included modules don`t need to be listed -->
                                    <addmodule>jdk.crypto.cryptoki</addmodule>
                                    <addmodule>jdk.crypto.ec</addmodule>
                                    <addmodule>java.sql</addmodule>
                                    <addmodule>java.naming</addmodule>
                                    <addmodule>java.net.http</addmodule>
                                    <addmodule>java.instrument</addmodule>
                                    <addmodule>javafx.controls</addmodule>
                                    <addmodule>javafx.fxml</addmodule>
                                    <!-- if you want these other javafx modules then
                                         uncomment them and ensure you
                                         also have maven dependencies for them -->
    <!--                                <addmodule>javafx.media</addmodule>-->
    <!--                                <addmodule>javafx.swing</addmodule>-->
    <!--                                <addmodule>javafx.web</addmodule>-->
                                </addmodules>
                                <!-- our app is non-modular, so we wont have a module entry, we set the mainjar and mainclass instead -->
                                <!--                            <module>com.example.wininstalled/HelloApplication</module>-->
                                <input>${jpackageInputDirectory}</input>
                                <mainjar>wininstalled-1.0-SNAPSHOT.jar</mainjar>
                                <mainclass>com.example.wininstalled.HelloApplication</mainclass>
                                <!--                            <javaoptions>-Dfile.encoding=UTF-8</javaoptions>-->
                                <!--                            <installdir>Utilities/Win Installed FX App</installdir>-->
                                <!--                            <licensefile>${project.basedir}/config/jpackage/LICENSE</licensefile>-->
                                <!--                            <resourcedir>${project.basedir}/config/jpackage/resources</resourcedir>-->
                                <!--                            <windirchooser>false</windirchooser>-->
                                <winmenu>true</winmenu>
                                <!--                            <winmenugroup>Utilities/Win Installed FX App</winmenugroup>-->
                                <winperuserinstall>true</winperuserinstall>
                                <winshortcut>true</winshortcut>
                                <!--                            <winupgradeuuid>${project.build.uuid}</winupgradeuuid>-->
    
                                <!-- if something goes wrong (and it will..) enable the winconsole and run the app from the command line
                                     then if the app aborts with an exception you can see it
                                     To run from the command line execute
                                        <your user home>\AppData\Local\<your app>\<your app>.exe
                                     -->
                                <!--                            <winconsole>true</winconsole>-->
                                <type>EXE</type>
                                <verbose>true</verbose>
                                <!-- example for setting jvm options if needed -->
                                <javaoptions>--enable-preview</javaoptions>
                            </configuration>
                        </execution>
                    </executions>
                    <dependencies>
                        <dependency>
                            <groupId>org.ow2.asm</groupId>
                            <artifactId>asm</artifactId>
                            <version>9.5</version>
                        </dependency>
                    </dependencies>
                </plugin>
                <plugin>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-maven-plugin</artifactId>
                    <version>3.2.1</version>
                </plugin>
            </plugins>
        </build>
    </project>
    

    Related