IronExcavater
IronExcavater

Reputation: 13

JavaFX Application Built with Maven and jpackage No Longer Works

I am building a JavaFX application and trying to package it as a standalone .exe using Maven with jpackage. Initially, I avoided using jlink because I had dependencies relying on automatic modules. The application was working fine at this stage, and I successfully built a functional .exe.

However, after making some modifications to the project (I don’t recall exactly what I changed, as I wasn’t concerned about it breaking at the time), the resulting .exe stopped working. When I tried to run it, the installer opens for a split second and crashes without giving me meaningful error messages. It stays open on my computer and requires force quit from task manager.

After struggling to fix this for a day, I decided to try a different approach using jlink, despite having automatic modules. I used Moditect to add module-info.java files to problematic libraries and create custom jmods. Unfortunately, even with jlink, the .exe still doesn’t work. However, the resulting disk image from jlink seems to work correctly as when adding a launcher to jlink, and opening the .bat, the javafx application opens and works as intended. Because of this, I don't know what the source of the issue is.

Questions:

You can find the project structure and rest of the project here. Below are relevant files:

module ironbyte.gradetracker {
    requires javafx.controls;
    requires javafx.fxml;
    requires com.google.gson;
    requires java.desktop;
    requires org.apache.poi.poi;
    requires org.apache.poi.ooxml;

    exports ironbyte.gradetracker;
    opens ironbyte.gradetracker to javafx.fxml;
    exports ironbyte.gradetracker.controller;
    opens ironbyte.gradetracker.controller to javafx.fxml;
    exports ironbyte.gradetracker.view;
    opens ironbyte.gradetracker.view to javafx.fxml;
    exports ironbyte.gradetracker.view.data;

    exports ironbyte.gradetracker.model;
    opens ironbyte.gradetracker.model to com.google.gson;
    exports ironbyte.gradetracker.model.action;
    opens ironbyte.gradetracker.model.action to com.google.gson;
    exports ironbyte.gradetracker.model.data;
    opens ironbyte.gradetracker.model.data to com.google.gson;
}

Barebones pom.xml (When running jpackage --input target --main-jar GradeTracker-1.0.jar multiple times, the resulting installer .exe exhibits the same broken behaviour).

<?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>IronByte</groupId>
    <artifactId>GradeTracker</artifactId>
    <version>1.0</version>
    <name>GradeTracker</name>
    <description>An intuitive GPA tracking tool that monitors and calculates academic performance</description>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.appClass>ironbyte.gradetracker.Application</project.appClass>
        <project.launchClass>ironbyte.gradetracker.Launcher</project.launchClass>
        <junit.version>5.10.0</junit.version>
        <javafx.version>23</javafx.version>
        <javafx.home>C:\Program Files\JavaFX\javafx-sdk-23</javafx.home>
    </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.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>${junit.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-engine</artifactId>
            <version>${junit.version}</version>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>com.google.code.gson</groupId>
            <artifactId>gson</artifactId>
            <version>2.11.0</version>
        </dependency>

        <dependency>
            <groupId>org.apache.poi</groupId>
            <artifactId>poi</artifactId>
            <version>5.3.0</version>
        </dependency>
        <dependency>
            <groupId>org.apache.poi</groupId>
            <artifactId>poi-ooxml</artifactId>
            <version>5.3.0</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.11.0</version>
                <configuration>
                    <source>22</source>
                    <target>22</target>
                </configuration>
            </plugin>

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>2.4</version>
                <configuration>
                    <archive>
                        <manifest>
                            <addClasspath>true</addClasspath>
                            <mainClass>${project.appClass}</mainClass>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

Upvotes: 0

Views: 202

Answers (1)

jewelsea
jewelsea

Reputation: 159536

This process worked for me (on a Mac, I don't have a Windows PC around at the moment to try it on).

If running on Windows adjust the files and steps to be more similar to the ones in the above answer, which generates a Windows installer.

The example creates a non-modular JavaFX application that uses JavaFX modules and POI, built with jpackage to create a native installer for OS X x64 14.6.1, using JavaFX 23.0.1 and Java (OpenJDK) 23.0.1. Built with the help of Maven and (optionally) Idea.

pom.xml

Edit the file to put your JDK location in the toolhome element. On OS X you can find this location using /usr/libexec/java_home -V from the command line.

<?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>macinstalled</artifactId>
    <version>1.0-SNAPSHOT</version>
    <name>macinstalled</name>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <javafx.version>23.0.1</javafx.version>
        <jpackageInputDirectory>${project.build.directory}/jpackage-input</jpackageInputDirectory>
        <!-- for mac we use a different format from windows as mac doesn't accept this multi-doted format for a version -->
<!--        <maven.build.timestamp.format>yy.MM.ddHH.mmss</maven.build.timestamp.format>-->
        <maven.build.timestamp.format>yyMMddHHmmss</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>com.google.code.gson</groupId>
            <artifactId>gson</artifactId>
            <version>2.11.0</version>
        </dependency>

        <dependency>
            <groupId>org.apache.poi</groupId>
            <artifactId>poi</artifactId>
            <version>5.3.0</version>
        </dependency>
        <dependency>
            <groupId>org.apache.poi</groupId>
            <artifactId>poi-ooxml</artifactId>
            <version>5.3.0</version>
        </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>23</source>
                    <target>23</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>macinstalled</name>
                            <appversion>${maven.build.timestamp}</appversion>
                            <copyright>Unrestricted freeware</copyright>
                            <description>JavaFX mac installed app demo.</description>
                            <vendor>Acme Widgets, Inc.</vendor>
                            <icon>${project.basedir}/src/main/resources/com/example/macinstalled/coffee-cup.icns</icon>
                            <modulepath>
                                <dependencysets>
                                    <dependencyset>
                                        <includeoutput>false</includeoutput>
                                        <excludeautomatic>true</excludeautomatic>
                                        <includes>
                                            <!-- todo would it be better to fetch jmods and use them? -->
                                            <!-- NOTE: this uses JavaFX MAC x64 artifacts,
                                                       change mac.jar to something else
                                                       if creating an installer for a different system type -->
                                            <include>glob:**/javafx-*-mac.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.examplemacinstalled/HelloApplication</module>-->
                            <input>${jpackageInputDirectory}</input>
                            <mainjar>macinstalled-1.0-SNAPSHOT.jar</mainjar>
                            <mainclass>com.example.macinstalled.PoiApplication</mainclass>
                            <!--                            <javaoptions>-Dfile.encoding=UTF-8</javaoptions>-->
                            <!--                            <installdir>Utilities/Mac 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>-->
                            <!-- DMG is for MAC, use a different type to package for a different platform -->
                            <type>DMG</type>
                            <verbose>true</verbose>
                            <!-- example for setting jvm options if needed -->
                            <javaoptions>--enable-preview</javaoptions>
                            <!-- VERY IMPORTANT -> change is to the absolute directory of your openjdk 23.0.1 install! -->
                            <toolhome>CHANGEME</toolhome>
                        </configuration>
                    </execution>
                </executions>
                <dependencies>
                    <dependency>
                        <groupId>org.ow2.asm</groupId>
                        <artifactId>asm</artifactId>
                        <version>9.5</version>
                    </dependency>
                </dependencies>
            </plugin>

        </plugins>
    </build>
</project>

Under src/main/resources/com/example/macinstalled

hello-view.fxml

<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.layout.VBox?>

<?import javafx.scene.control.Button?>
<VBox alignment="CENTER" spacing="20.0" xmlns:fx="http://javafx.com/fxml"
      fx:controller="com.example.macinstalled.PoiController">
    <padding>
        <Insets bottom="20.0" left="20.0" right="20.0" top="20.0"/>
    </padding>

    <Label fx:id="welcomeText"/>
    <Button text="Hello! See the countries!" onAction="#onHelloButtonClick"/>
</VBox>

coffee-cup.icns

From flaticon, then processed through cloud convert.

.icns is a Mac-only format. For Windows you would use a .ico file.


Java code under src/main/java/com/example/macinstalled.

POI code from digital ocean (all bugs are belong to them ;-)

PoiApplication.java

package com.example.macinstalled;

import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.stage.Stage;

import java.io.IOException;

public class PoiApplication extends Application {

    @Override
    public void start(Stage stage) throws IOException {
        FXMLLoader fxmlLoader = new FXMLLoader(PoiApplication.class.getResource("hello-view.fxml"));

        Scene scene = new Scene(fxmlLoader.load(), 320, 240);
        stage.setTitle("Hello!");
        stage.setScene(scene);
        stage.show();
    }

    public static void main(String[] args) {
        launch();
    }
}

PoiController.java

package com.example.macinstalled;

import javafx.collections.FXCollections;
import javafx.fxml.FXML;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.control.ListView;
import javafx.stage.Modality;
import javafx.stage.Stage;

import java.io.File;
import java.util.List;

public class PoiController {
    @FXML
    private Label welcomeText;

    @FXML
    protected void onHelloButtonClick() {
        try {
            welcomeText.setText("Welcome to the POI Application!");

            List<Country> countries = List.of(
                    new Country("Tuvalu", "tv"),
                    new Country("Nauru", "nr")
            );

            File tempFile = File.createTempFile("countries-", ".xlsx");
            tempFile.deleteOnExit();

            PoiIO.writeCountryListToFile(
                    tempFile.getAbsolutePath(),
                    countries
            );

            countries = PoiIO.readExcelData(
                    tempFile.getAbsolutePath()
            );

            Stage stage = new Stage();
            stage.setScene(
                    new Scene(
                            new ListView<>(
                                    FXCollections.observableList(
                                            countries
                                    )
                            ),
                            100, 50
                    )
            );
            stage.initOwner(welcomeText.getScene().getWindow());
            stage.initModality(Modality.APPLICATION_MODAL);
            stage.showAndWait();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

Country.java

package com.example.macinstalled;

public class Country {

    private String name;
    private String shortCode;
    
    public Country(String n, String c){
        this.name=n;
        this.shortCode=c;
    }
    
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
    public String getShortCode() {
        return shortCode;
    }
    public void setShortCode(String shortCode) {
        this.shortCode = shortCode;
    }
    
    @Override
    public String toString(){
        return name + "::" + shortCode;
    }
    
}

PoiIO.java

package com.example.macinstalled;

import org.apache.poi.hssf.usermodel.HSSFWorkbook;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class PoiIO {

    public static void writeCountryListToFile(String fileName, List<Country> countryList) throws Exception{
        Workbook workbook = null;

        if(fileName.endsWith("xlsx")){
            workbook = new XSSFWorkbook();
        }else if(fileName.endsWith("xls")){
            workbook = new HSSFWorkbook();
        }else{
            throw new Exception("invalid file name, should be xls or xlsx");
        }

        Sheet sheet = workbook.createSheet("Countries");

        Iterator<Country> iterator = countryList.iterator();

        int rowIndex = 0;
        while(iterator.hasNext()){
            Country country = iterator.next();
            Row row = sheet.createRow(rowIndex++);
            Cell cell0 = row.createCell(0);
            cell0.setCellValue(country.getName());
            Cell cell1 = row.createCell(1);
            cell1.setCellValue(country.getShortCode());
        }

        //lets write the excel data to file now
        FileOutputStream fos = new FileOutputStream(fileName);
        workbook.write(fos);
        fos.close();
        System.out.println(fileName + " written successfully");
    }

    public static List<Country> readExcelData(String fileName) {
        List<Country> countriesList = new ArrayList<Country>();

        try {
            //Create the input stream from the xlsx/xls file
            FileInputStream fis = new FileInputStream(fileName);

            //Create Workbook instance for xlsx/xls file input stream
            Workbook workbook = null;
            if(fileName.toLowerCase().endsWith("xlsx")){
                workbook = new XSSFWorkbook(fis);
            }else if(fileName.toLowerCase().endsWith("xls")){
                workbook = new HSSFWorkbook(fis);
            }

            //Get the number of sheets in the xlsx file
            int numberOfSheets = workbook.getNumberOfSheets();

            //loop through each of the sheets
            for(int i=0; i < numberOfSheets; i++){

                //Get the nth sheet from the workbook
                Sheet sheet = workbook.getSheetAt(i);

                //every sheet has rows, iterate over them
                Iterator<Row> rowIterator = sheet.iterator();
                while (rowIterator.hasNext())
                {
                    String name = "";
                    String shortCode = "";

                    //Get the row object
                    Row row = rowIterator.next();

                    //Every row has columns, get the column iterator and iterate over them
                    Iterator<Cell> cellIterator = row.cellIterator();

                    while (cellIterator.hasNext())
                    {
                        //Get the Cell object
                        Cell cell = cellIterator.next();

                        //check the cell type and process accordingly
                        switch(cell.getCellType()){
                            case CellType.STRING:
                                if(shortCode.equalsIgnoreCase("")){
                                    shortCode = cell.getStringCellValue().trim();
                                }else if(name.equalsIgnoreCase("")){
                                    //2nd column
                                    name = cell.getStringCellValue().trim();
                                }else{
                                    //random data, leave it
                                    System.out.println("Random data::"+cell.getStringCellValue());
                                }
                                break;
                            case CellType.NUMERIC:
                                System.out.println("Random data::"+cell.getNumericCellValue());
                        }
                    } //end of cell iterator
                    Country c = new Country(name, shortCode);
                    countriesList.add(c);
                } //end of rows iterator


            } //end of sheets for loop

            //close file input stream
            fis.close();

        } catch (IOException e) {
            e.printStackTrace();
        }

        return countriesList;
    }
}

To use, follow the instructions from the prior post on this subject mentioned at the top of this answer, adjusting slightly for Mac OS.

On Mac, the jpackage DMG generation step takes a few minutes, so patience is required for that step.

Once installed, launch the app as you would any other native installed app.

Press the hello button in the app to have POI write some countries to an excel spreadsheet, then read the spreadsheet back in and display the resulting countries in a JavaFX ListView.

screenshot

App in Mac Launchpad:

launchpad

App installer:

installer

FAQ

In addition to this FAQ, the linked answer (at the start of this answer) has further trouble-shooting steps.

Is there a more reliable way to handle automatic modules than using Moditect?

IMO the moditect approach is problematic. I wouldn't recommend it.

If the software wasn't built to be modular then moditect will try to make it work as module. It don't think it can guarantee to make it work as a module (most things will work, but some things may not).

Instead I'd recommend following the jpackage user guide by oracle. It provides instructions on packaging non-modular apps (including apps that rely on automatic modules).

Have I overcomplicated the build process and need to start from scratch?

Try packaging a basic hello world app, then, if that works, add in your non-modular poi dependencies and make sure that works, then if that works add in the rest of your code and make sure that works.

Creating native installers for JavaFX is inherently complex at the moment, unfortunately.

Are there best practices for handling modular dependencies in a JavaFX project built with Maven?

You could see how JPackageScriptFX handles this.

How can I debug what is causing the .exe to fail? Are there tools or logs that can help me pinpoint the issue?

As noted by Slaw in comments

invoke jpackage with --win-console and then execute your application from the terminal. Hopefully whatever is going wrong is logged.

Also, note if you don't want to go through the installation process just to test if your application will execute, then pass --type app-image to jpackage.

I want to additional customize installer customizations.

Additional customization is detailed in the jpackage man page, the Oracle jpackage user guide, and the documentation for the native packaging tool that jpackage relies on (e.g. for Java 23 on Windows, Wix 3.x is used).

What does the akman jpackage plugin do?

The ackman jpackage plugin used in this system is just a convenient way to integrate the jpackage tool with a Maven platform. The plugin parameters are very similar or the same as the jpackage tool parameters. All the plugin implementation does is take those parameters given in the maven pom.xml and pass it to a shell process used to execute jpackage.

Suppose verbose debugging is switched on for Maven. In that case, the plugin will output the actual jpackage command which is used and that can be executed or tweaked manually by copying the command from the debug output and manually executing the command in a terminal after a build has been completed.

Upvotes: 2

Related Questions