Search code examples
javaspring-bootjavafx

create an Exe combining Spring and OpenJFX


I am trying to create a Java desktop application that can be distributed on Windows using a combination of SpringBoot and OpenJFX. However, I can create an executable Jar, but I am having trouble launching it as an exe using exewrap or launch4j. When I run the exe created by exewrap, I also get an error that Start-Class is not registered in MANIFEST.MF. However, Start-Class is registered in MANIFEST.MF. There are many QAs with similar problems and I have tried all kinds of information but cannot solve the problem. Please help me with your wisdom.

When creating an exe in exewrap or Launch4j, after checking dependencies in jdeps, create a lightweight JRE from OpenJFX and load it from the same directory level.

The build environment is Windows 11 and uses intellij.

I also attach the pom.xml I made.

Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.4.2
Build-Jdk-Spec: 21
Main-Class: org.springframework.boot.loader.launch.JarLauncher
Start-Class: myPakage.myApp.MyAppApplication
Spring-Boot-Version: 3.4.1
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx
Spring-Boot-Layers-Index: BOOT-INF/layers.idx
<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>myPakage</groupId>
    <artifactId>myApp</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>myApp</name>
    
    <packaging>jar</packaging>
    
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.4.1</version>
    </parent>
    
    <properties>
        <start-class>myPakage.myApp.MyAppApplication</start-class>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <java.version>21</java.version>
        <jfx.version>21.0.1</jfx.version>
        <javacpp.version>1.5.10</javacpp.version>
        <javacpp.windows-x86_64>windows-x86_64</javacpp.windows-x86_64>
        <poi.version>5.3.0</poi.version>
        <jackson.version>2.16.1</jackson.version>
        <slf4j.version>2.0.16</slf4j.version>
        <log4j.version>2.23.1</log4j.version>
    </properties>
    
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</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.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
    
        <dependency>
            <groupId>org.hibernate.orm</groupId>
            <artifactId>hibernate-community-dialects</artifactId>
            <version>6.6.1.Final</version>
        </dependency>
    
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    
        <!-- OpenJFX -->
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-controls</artifactId>
            <version>${jfx.version}</version>
        </dependency>
    
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-fxml</artifactId>
            <version>${jfx.version}</version>
        </dependency>
    
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-swing</artifactId>
            <version>${jfx.version}</version>
        </dependency>
    
        <dependency>
            <groupId>org.controlsfx</groupId>
            <artifactId>controlsfx</artifactId>
            <version>11.2.1</version>
        </dependency>
    
        <!-- logger -->
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
            <version>${slf4j.version}</version>
        </dependency>
    
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-reload4j</artifactId>
            <version>${slf4j.version}</version>
            <scope>test</scope>
        </dependency>
    
        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-core</artifactId>
            <version>${log4j.version}</version>
        </dependency>
    
        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-api</artifactId>
            <version>${log4j.version}</version>
        </dependency>
    
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
    
    </dependencies>
    
    <build>
        <plugins>
    
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-dependency-plugin</artifactId>
                <executions>
                    <execution>
                        <id>copy-dependencies</id>
                        <phase>prepare-package</phase>
                        <goals>
                            <goal>copy-dependencies</goal>
                        </goals>
                        <configuration>
                            <outputDirectory>
                                ${project.build.directory}/libs
                            </outputDirectory>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
    
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.13.0</version>
                <configuration>
                    <encoding>UTF-8</encoding>
                    <source>21</source>
                    <target>21</target>
                </configuration>
            </plugin>
    
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <executions>
                    <execution>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <mainClass>${start-class}</mainClass>
                </configuration>
            </plugin>
    
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-assembly-plugin</artifactId>
                <version>3.7.1</version>
                <executions>
                    <execution>
                        <id>make-assembly</id>
                        <phase>package</phase>
                        <goals>
                            <goal>single</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <descriptorRefs>
                        <descriptorRef>jar-with-dependencies</descriptorRef>
                    </descriptorRefs>
                    <archive>
                        <manifest>
                            <mainClass>${start-class}</mainClass>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>
    
        </plugins>
    </build>

Solution

  • This gets a little tricky because Spring is non-modular and JavaFX should be run with the JavaFX libraries on the module path, not the classpath.

    Here's how I made this work. This may not be optimal. The basic idea is to create a non-modular application, use Maven to copy the dependencies (except the JavaFX modules) to lib folder and biuld a jar file with the application. Then use jlink to create a JDK with the JAvaFX modules included, and finally use jpackage to build a native package using that JDK, the dependencies, and the jar file.

    I'm using IntelliJ (version 2024.3.2.1) and this includes some IntelliJ-specific steps which can probably easily be adapted for other IDEs. I'm also using JDK 23.

    You will need to download the JavaFX modules from here. Make sure you get the modules for your target platform and download the jmods, not the SDK. I downloaded the zip file to ~/javafx-mods and unzipped it, creating ~/javafx-mods/javafx-jmods-23.0.2 inside of which there are seven .jmod files.

    First I created a new project in IntelliJ. I chose the "JavaFX" generator, "Java" for language, and "Maven" for build tool. It uses (by default) JDK 23.

    I deleted the module.java file, to make the project non-modular, and then edited the pom.xml file. These edits added the Spring dependencies, a plugin to copy the dependencies to the lib folder, and made sure I had the correct source and target JDK versions. Here is the finished pom.xml (note you can't copy this directly, you'll at least need to edit the groupId, artifactId, name, etc.):

    <?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>org.jamesd.examples</groupId>
        <artifactId>springfx23</artifactId>
        <version>1.0-SNAPSHOT</version>
        <name>springfx23</name>
    
        <properties>
            <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
            <junit.version>5.10.2</junit.version>
        </properties>
    
        <parent>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-parent</artifactId>
            <version>3.4.2</version>
        </parent>
    
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter</artifactId>
            </dependency>
            <dependency>
                <groupId>org.openjfx</groupId>
                <artifactId>javafx-controls</artifactId>
                <version>23.0.2</version>
            </dependency>
            <dependency>
                <groupId>org.openjfx</groupId>
                <artifactId>javafx-fxml</artifactId>
                <version>23.0.2</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>
        </dependencies>
    
        <build>
            <plugins>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-compiler-plugin</artifactId>
                    <version>3.13.0</version>
                    <configuration>
                        <source>23</source>
                        <target>23</target>
                    </configuration>
                </plugin>
    
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-dependency-plugin</artifactId>
                    <version>3.8.1</version>
                    <executions>
                        <execution>
                            <id>copy-dependencies</id>
                            <phase>package</phase>
                            <goals>
                                <goal>copy-dependencies</goal>
                            </goals>
                            <configuration>
                                <excludeGroupIds>org.openjfx</excludeGroupIds>
                                <outputDirectory>${project.build.directory}/lib</outputDirectory>
                                <overWriteReleases>false</overWriteReleases>
                                <overWriteSnapshots>false</overWriteSnapshots>
                                <overWriteIfNewer>true</overWriteIfNewer>
                            </configuration>
                        </execution>
                    </executions>
                </plugin>
            </plugins>
        </build>
    </project>
    

    I then modified the demo application to use spring. There are several discussions on this site about the best ways to do this (it's a bit complicated because you have two different frameworks both with their own application startup mechanisms). I won't repeat those discussions here, but my strategy is to use Spring Boot to start up the application and then Platform.startup(...) to bypass the standard JavaFX Application startup process and start the JavaFX runtime manually.

    Here is a Spring bean that provides a message:

    package org.jamesd.examples.springfx23;
    
    public class MessageProvider {
        public String getMessage() {
            return "Hello from JavaFX with Spring";
        }
    }
    

    The controller class modified to use Spring bean injection:

    package org.jamesd.examples.springfx23;
    
    import javafx.fxml.FXML;
    import javafx.scene.control.Label;
    import org.springframework.beans.factory.annotation.Autowired;
    
    public class HelloController {
    
        @Autowired
        private MessageProvider messageProvider;
        @FXML
        private Label welcomeText;
    
        @FXML
        protected void onHelloButtonClick() {
            welcomeText.setText(messageProvider.getMessage());
        }
    }
    

    The spring configuration class:

    package org.jamesd.examples.springfx23;
    
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    
    @Configuration
    public class Config {
        @Bean
        public HelloController helloController() {
            return new HelloController();
        }
    
        @Bean
        public MessageProvider messageProvider() {
            return new MessageProvider();
        }
    }
    

    The FXML (still called 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="org.jamesd.examples.springfx23.HelloController">
        <padding>
            <Insets bottom="20.0" left="20.0" right="20.0" top="20.0"/>
        </padding>
    
        <Label fx:id="welcomeText"/>
        <Button text="Hello!" onAction="#onHelloButtonClick"/>
    </VBox>
    

    and finally the HelloApplication class, modified to use Spring:

    package org.jamesd.examples.springfx23;
    
    import javafx.application.Platform;
    import javafx.fxml.FXMLLoader;
    import javafx.scene.Scene;
    import javafx.stage.Stage;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.ApplicationArguments;
    import org.springframework.boot.ApplicationRunner;
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    import org.springframework.context.ApplicationContext;
    
    import java.io.IOException;
    
    @SpringBootApplication
    public class HelloApplication implements ApplicationRunner {
    
        @Autowired
        private ApplicationContext appContext;
    
        public void run(ApplicationArguments args) {
            Platform.startup(this::runUI);
        }
    
        public void runUI() {
            try {
                Stage stage = new Stage();
                FXMLLoader fxmlLoader = new FXMLLoader(HelloApplication.class.getResource("hello-view.fxml"));
                fxmlLoader.setControllerFactory(appContext::getBean);
                Scene scene = new Scene(fxmlLoader.load(), 320, 240);
                stage.setTitle("Hello!");
                stage.setScene(scene);
                stage.show();
            } catch (IOException exc) {
                exc.printStackTrace();
            }
        }
    
        public static void main(String[] args) {
            SpringApplication.run(HelloApplication.class, args);
        }
    }
    

    Next I ran the Maven build (from IntelliJ, View -> Tool Windows -> Maven, and then press the green run button in that view "Run Maven Build"). This will create the jar file and the lib folder inside the target directory.

    The next step is to build the JDK with JavaFX included. From the terminal (I am on a mac, it should be essentially the same for windows) I ran

    jlink --add-modules javafx.base,javafx.graphics,javafx.controls,javafx.fxml --module-path javafx-mods/javafx-jmods-23.0.2 --output JavaWithFX23
    

    (here javafx-jmods is the directory to which I downloaded the openjfx jmod archive, and javafx-jmods-23.0.2 was created when I unzipped it). This creates a new JDK inside JavaWithFX23 which includes the javafx.base, javafx.controls and javafx.fxml modules. (Note here javafx.base is redundant, the others require it transitively anyway, so you can omit it. You might need to adjust the list of modules in the jlink command, e.g. if you also need javafx.media etc. Just make them match what's in your pom.xml.)

    Finally, run jpackage to create your native installation bundle. My project folder is ~/Documents/Java/FX Projects/springfx23 so the command is

    jpackage --runtime-image JavaWithFX23/ --name "Hello Spring World" --dest HelloSpring --main-jar ~/Documents/Java/FX\ Projects/springfx23/target/springfx23-1.0-SNAPSHOT.jar --main-class org.jamesd.examples.springfx23.HelloApplication --input ~/Documents/Java/FX\ Projects/springfx23/target/lib
    

    This creates a .dmg (since I'm on a Mac) in the HelloSpring folder which can install the application.

    See the documentation for jlink and jpackage for more on using those tools.

    As I said earlier, this may be non-optimal, and it makes the build process a little too hands-on, but it works. Others may have more streamlined approaches and/or Maven plugins that can automate this process.

    It is possible to combine the jlink and jpackage steps into a single step using

    jpackage --add-modules javafx.controls,javafx.fxml --module-path javafx-mods/javafx-jmods-23.0.2 --name "Hello Spring World" --dest HelloSpring --main-jar ~/Documents/Java/FX\ Projects/springfx23/target/springfx23-1.0-SNAPSHOT.jar --main-class org.jamesd.examples.springfx23.HelloApplication --input ~/Documents/Java/FX\ Projects/springfx23/target/lib
    

    but I usually find it helpful to have the custom runtime available to debug anything that goes wrong. Also consider using --type app-image as a jpackage command to create an on-disk runnable version, which again can be useful for debugging.