When writing desktop applications in Java, we want them to look and feel native. A good application blends in. It provides an experience already familiar to the user.

Swing GUI: metal vs native

Swing GUI look and feel: Metal vs. Native

On the desktop, the user journey doesn’t start within an application itself. It starts with an installer. This is where the Java world used to fall behind. But not anymore.

Starting from Java 16, JDK comes with jpackage. This tool packages an application into a bundle with a built-in JRE and wraps it into a native installer and executable.

In this article, I’ll demonstrate how to use jpackage with a JxBrowser application. I will create a simple JxBrowser application, wrap it into a native installer and executable program. I’ll give you code snippets that you can copy straight into your project.

Configuring Gradle

My goal here is to create a simple Pomodoro tracker, install it, and launch it as a native OS application. It will use Gradle for the build configuration and Swing for the user interface.

Let’s start from scratch by creating an empty Gradle project:

$ gradle init --dsl kotlin --type basic --project-name jxbrowser-installer

Open build.gradle.kts and apply the Java plug-in:

plugins {
    java
}
 
group = "com.teamdev.examples"
version = "1.0"

java {
    sourceCompatibility = JavaVersion.VERSION_16
    targetCompatibility = JavaVersion.VERSION_16
}

repositories {
    mavenCentral()
}

Our application requires two JxBrowser dependencies. One contains the core API with Chromium binaries. And another one implements the Swing toolkit support.

Let’s use the JxBrowser Gradle Plug-in to add the necessary dependencies.

plugins {
    
    id("com.teamdev.jxbrowser") version "1.0.1"
}

jxbrowser {
    version = "7.38.1"
}

dependencies {
    implementation(jxbrowser.swing)
    implementation(jxbrowser.currentPlatform)
}

In the code snippet above, I use jxbrowser.currentPlatform that detects the current platform and cherry-picks only necessary Chromium binaries. If you’re building, let’s say, a Windows-only application, you can specify the Chromium binaries for Windows explicitly:

dependencies {
    implementation(jxbrowser.swing)
    implementation(jxbrowser.win64)
}

Application code

Our application is very simple. It opens a JFrame, adds the BrowserView component that loads and displays a web page with the Pomodoro Tracker.

package com.teamdev.examples;

import com.teamdev.jxbrowser.engine.Engine;
import com.teamdev.jxbrowser.view.swing.BrowserView;

import javax.swing.*;
import java.awt.*;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;

import static com.teamdev.jxbrowser.engine.RenderingMode.HARDWARE_ACCELERATED;
import static javax.swing.SwingConstants.CENTER;
import static javax.swing.WindowConstants.DISPOSE_ON_CLOSE;

/**
 * A Pomodoro tracker.
 *
 * This app displays a window with the integration browser component that loads and displays
 * the Pomodoro Tracker web application.
 */
public final class PomodoroTracker {

    public static final String URL = "https://teamdev.com/jxbrowser/docs/tutorials/jpackage/pomodoro.html";

    public static void main(String[] args) {
        var splash = showSplashScreen();
        showBrowser();
        splash.dispose();
    }

    private static void showBrowser() {
        var engine = Engine.newInstance(HARDWARE_ACCELERATED);
        var browser = engine.newBrowser();
        var frame = new JFrame("Pomodoro Tracker");
        frame.addWindowListener(new WindowAdapter() {
            @Override
            public void windowClosing(WindowEvent e) {
                engine.close();
            }
        });
        var view = BrowserView.newInstance(browser);
        frame.add(view, BorderLayout.CENTER);
        frame.setDefaultCloseOperation(DISPOSE_ON_CLOSE);
        frame.setSize(1280, 900);
        frame.setLocationRelativeTo(null);
        frame.setVisible(true);
        browser.navigation().loadUrl(URL);
    }

    private static JWindow showSplashScreen() {
        var splash = new JWindow();
        splash.getContentPane().add(new JLabel("Loading...", CENTER));
        splash.setBounds(500, 150, 300, 200);
        splash.setVisible(true);
        return splash;
    }
}
Get a powerful and reliable browser control for Java.
Get started now

Packaging the application

Desktop applications written in Java need to carry all necessary libraries they require at runtime. The usual approach is to merge libraries together with an application into a big Uber JAR. It requires an extra bit of configuration and is not suitable for modular projects.

Now there’s a simpler option: gather a bunch of JAR files in one folder and let jpackage handle the rest.

First, let’s configure building the main application JAR:

val jarDirectory = file("$buildDir/jars")
tasks {
    jar {
        manifest {
            attributes["Main-Class"] = "com.teamdev.examples.PomodoroTracker"
        }
        archiveFileName.set("main.jar")
        destinationDirectory.set(jarDirectory)
    }
}

Then let’s create a task to gather JAR files of the dependencies:

val jarDirectory = file("$buildDir/jars")
tasks {
    
    register<Copy>("gatherDependencies") {
        from(configurations.runtimeClasspath).into(jarDirectory)
    }
}

These two tasks are enough to launch the application from the command line:

$ ./gradlew jar gatherDependencies
$ java -cp "build/jars/*" com.teamdev.examples.PomodoroTracker

Now everything is ready to configure jpackage.

This is a command-line tool. However, I prefer to keep everything in Gradle scripts. They are easier to read and maintain than a heap of .sh and .bat files.

I recommend the org.panteleyev.jpackage plug-in. This plug-in wraps the command-line API of jpackage into Gradle DSL. Here’s how to apply it:

plugins {
    ...
    id("org.panteleyev.jpackageplugin") version "1.3.1"
}

And here’s how I configured it to generate the installers:

val jarDirectory = file("$buildDir/jars")
tasks {
    
    jpackage {
        // Gather all JAR files before packaging.
        dependsOn("jar", "gatherDependencies")

        appName = "Pomodoro Tracker"
        appVersion = "${project.version}"

        // The directory with JARs.
        input = jarDirectory.absolutePath

        // The name of the main, launchable JAR.
        mainJar = "main.jar"

        // The list of necessary modules to include into a bundled JRE. 
        // The "java.logging" is required by JxBrowser.
        addModules = listOf("java.base", "java.desktop", "java.logging")

        // The path to the JRE modules.
        modulePaths = listOf("${System.getProperty("java.home")}/jmods")

        // The directory where to put the installers.
        destination = "$buildDir/dist"

        linux {
            type = org.panteleyev.jpackage.ImageType.DEB
            linuxPackageName = "pomodoro"
        }

        windows {
            type = org.panteleyev.jpackage.ImageType.MSI
            winDirChooser = true
            winMenu = true
        }

        mac {
            type = org.panteleyev.jpackage.ImageType.DMG
        }
    }
}

Once everything is configured, the only thing left to do is to call the jpackage task. Once the task is complete, we’ll find the installers in build/dist directory:

$ ./gradlew jpackage

For Windows, you’re going to need https://wixtoolset.org/releases/

Installer in action


Source code

You can find the source code of the application in the GitHub repository.