DEV Community

Cover image for Understanding file associations in JVM apps
Thomas Künneth
Thomas Künneth

Posted on

Understanding file associations in JVM apps

One of the many advantages of graphical user interfaces, which started to appear in the early 1980s, is the focus on documents: when a document is opened, the system launches the application that is able to handle it, and passes the document to it. For this mechanism to work, there needs to be a specific relation between documents and apps that can handle them. To put it another way: the system must know which apps feel responsible for certain document types.

It turns out that many operating systems decided to determine the document type by looking at the filename; to be more precisely, the file extension (.doc, .c, .img). Not to much surprise, this easy solution reaches its limits when different document types want to have the same file extension. Consider .img: in the 80s it seemed obvious that the type must be a graphics image. But how about a firmware image? Here, .img fits equally well. Later, other ways to associate document types with apps have been introduced. For example, we can use the MIME type to describe a document type. I will return to both approaches soon. But first, let's do a sharp turn and look at Java.

A simple Hello world app

You may be thinking But Java on the Desktop is pretty much dead, isn't it?. While it is true that we haven't seen many killer apps for the Desktop written in Java in recent years, I very much hope to see a renaissance. In using Compose Multiplatform by JetBrains, we can create stunning user interfaces with Kotlin and Jetpack Compose. On the Desktop, such apps run inside the JVM. This means that the level of integration is defined by the capabilities of the Java Runtime. Fortunately, there are a lot of things we can utilize. One of them is jpackage. This commandline tool can create native images and native installers. Let me show you how this works. To focus on the mechanisms, let's follow a bare metal approach. No project wizard, no build system. Just the code and the commandline.

import javax.swing.*;

public class Hello {

    public static void main(String [] args) {
        if (args.length == 0)
            JOptionPane.showMessageDialog(null, "Hello, world!");
        else
            JOptionPane.showMessageDialog(null, args[0]);
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Let's compile the code: javac Hello.java. This creates a file called Hello.class.
  2. Next, we will put this inside a .jar file: jar cf input\hello.jar. This file is created in the input subdirectory.

Now comes the interesting part. We create a native installer on Windows.

jpackage --type msi --win-menu --input input --dest output --main-jar hello.jar --main-class Hello
Enter fullscreen mode Exit fullscreen mode

More complex projects will have longer commandlines consisting of way more options. That's why I stick to a simple scenario. It is easier to grasp the important things. --win-menu tells the installer to show the app inside the Start menu. --type msi chooses the native Windows installer file format. The file (Hello-1.0.msi) will be saved in the output directory (--dest output). You can install the app by opening the installation archive. Once you have done that, a nice icon appears in the Start menu.

Windows Start menu with the Hello app

If you run the app, you will be greeted with a friendly message.

The Hello app

To learn more about the various ways to configure jpackage, kindly take a look at its documentation.

So far, I showed you how to create a native installer for Windows. Certainly, this works for Linux and macOS, too. The jpackage documentation mentions what to pass to --type. Next, let's talk about how to launch the app when the user opens some file type.

Imagine, I invented the revolutionary .hello file format. Here's how to associate it with the ingenious Hello app. The first step is to define a properties file, hello.properties:

mime-type=text/plain
extension=hello
description=Hello
Enter fullscreen mode Exit fullscreen mode

Such properties files can also contain an icon reference. If none is specified, the app icon will be used. To establish the file association, we need to pass the properties file to jpackage.

jpackage --file-associations hello.properties --type msi --win-menu --input input --dest output --main-jar hello.jar --main-class Hello
Enter fullscreen mode Exit fullscreen mode

Once the installation file has been created and opened, .hello files will show the Duke logo, and opening them will launch the Hello app.

A file info dialog showing a .hello file

Screenshot of the Hello app

Now, isn't that cool? There is one problem, though. When you open another .hello file while the app is running, either a new app instance is launched (Windows and Linux), or the app is just brought to the front (macOS). To receive file open requests on the Mac, we need to register an OpenFilesHandler. Here's an alternative version of the app, called Hello2.

import javax.swing.*;
import java.awt.*;
import java.awt.desktop.*;

public class Hello2 {

    private static int numWindows = 0;

    public static void main(String[] args) {
        if (Desktop.isDesktopSupported()) {
            Desktop desktop = Desktop.getDesktop();
            if (desktop.isSupported(Desktop.Action.APP_OPEN_FILE)) {
                desktop.setOpenFileHandler(new OpenFilesHandler() {
                    @Override
                    public void openFiles(OpenFilesEvent e) {
                        e.getFiles().forEach(file ->
                            openWindow(file.getAbsolutePath()));
                    }
                });
            }
        }
        if (args.length == 0) {
            openWindow("Hello, world!");
        } else {
            for (String arg : args) {
                openWindow(arg);
            }
        }
    }

    private static void openWindow(String text) {
        JFrame f = new JFrame();
        f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        f.setBounds(100 + numWindows * 20, 
                    100 + numWindows * 20,
                    200,
                    100);
        JLabel l = new JLabel(text);
        l.setHorizontalAlignment(SwingConstants.CENTER);
        JPanel cp = new JPanel(new BorderLayout());
        cp.add(l, BorderLayout.CENTER);
        f.setContentPane(cp);
        f.setVisible(true);
        numWindows += 1;
    }
}
Enter fullscreen mode Exit fullscreen mode

The code inside openWindow() is basic AWT and Swing stuff. It just opens a window that displays some text. The interesting things happen inside main(). Before we can register an OpenFilesHandler, we need to make sure that the relevant mechanisms are available (Desktop.isDesktopSupported() and isSupported(Desktop.Action.APP_OPEN_FILE)).

Here's how to create a Hello2 app image for the Mac:

jpackage --file-associations hello.properties --type app-image --input input --dest output --main-jar hello2.jar --main-class Hello2
Enter fullscreen mode Exit fullscreen mode

This is how Hello2 looks like:

Hello2 running on macOS

On macOS, the file associations are store inside the app's info.plist. Once the Finder has digested this information, the app can be launched by opening .hello files. If it is already running, an additional window is opened.

Turning to Kotlin

Kindly recall that, at the beginning of this article, I expressed my hopes that there will be new Desktop applications based on Compose Multiplatform by JetBrains. Compose for Desktop, which is the relevant part for this scenario, uses the JVM. Consequently, everything I have explained to you so far, fully applies. Let's recap:

  • File associations are stored in .properties files
  • The files are passed to the jpackage tool using the --file-associations option

To add file associations to a Compose for Desktop app, the above bullet points must be applied. Simply put, Compose Multiplatform (and Compose for Desktop) is based on a sophisticated project structure, countless libraries, a set of Gradle build files, and some Gradle plugins. To build an app, tasks like packageDmg or packageMsi are executed. Invoking them triggers a plethora of subtasks, for example, compiling the source files, and creating so-called runtime images. Runtime images contain both the Java runtime and the app code and follow a well-defined directory structure. Here comes the important thing: at some point, some plugin will invoke jpackage.

So, to add file associations, we need to be able to pass both the .properties file and the --file-associations option. At the time of writing this article, the org.jetbrains.compose plugin doesn't provide such a mechanism. There is an open issue on GitHub that describes a possible workaround, which, unfortunately seems to not work on macOS.

Let me show you another workaround, or, hack, if you will. When a packaging task, for example, packageMsi, is executed, we obviously get the native installer. In addition, there is a file called createRuntimeImage.args inside build\compose\tmp. For one of my projects, it looks like this:

--input
"Z:\\souffleur\\server\\build\\compose\\tmp\\packageMsi\\libs"
--runtime-image
"Z:\\souffleur\\server\\build\\compose\\tmp\\main\\runtime"
--resource-dir
"Z:\\souffleur\\server\\build\\compose\\tmp\\resources"
--java-options
"'-Dcompose.application.resources.dir=$APPDIR\\resources'"
--main-jar
"server-jvm-1.1.0-8138455775c74f7d614163c557567d.jar"
--main-class
"eu.thomaskuenneth.souffleur.ComposeMainKt"
--icon
"Z:\\souffleur\\server\\artwork\\Souffleur.ico"
--java-options
"'-Dcompose.application.configure.swing.globals=true'"
--java-options
"'-Dskiko.library.path=$APPDIR'"
--resource-dir
"Z:\\souffleur\\server\\build\\compose\\tmp\\resources"
--win-dir-chooser
--win-menu
--win-menu-group
"Thomas Kuenneth"
--type
"msi"
--dest
"Z:\\souffleur\\server\\build\\compose\\binaries\\main\\msi"
--name
"Souffleur"
--description
"A cross platform remote control for presentations"
--copyright
"2019 - 2023 Thomas Kuenneth. All rights reserved."
--app-version
"1.1.0"
--vendor
"Thomas Kuenneth"
Enter fullscreen mode Exit fullscreen mode

Doesn't this look like a complete jpackage arguments list? The only problem is that none of the referenced .jar files are there. There is hope, though. Because there is another file called libs-mapping.txt inside build\compose\tmp\packageMsi.

Visual Studio Code showing the libs-mapping.txt file

It, unfortunately, is not nicely formatted at all. But it looks like it consists of lines of source file - destination file pairs. So, with either scripting support or quite a bit of manual work, we could transform the file to become a shell script that would copy all mentioned files. Luckily, this is not necessary. If you run another build task, runDistributable, you will get an app image in build\compose\binaries\main\app.

App image of Souffleur

The base directory is named like your app, in my example, Souffleur. Inside you will spot another directory, app. It contains all files from libs-mapping.txt. So you basically copy all .jar, .dat, .dll and .sha256 files. Once you have done this, you should be able to pass all above mentioned arguments to jpackage and recreate the native installer. Important: replace `$APPDIR with app. If this works for your projects as well, the last step you need to do is apply the --file-associations option.

Summary

I am not yet convinced that my hack will work in all situations. Ideally, JetBrains adds support for file associations to the Gradle plugin soon. What are your thoughts about file associations? Have you already used them in your JVM apps? Are you planning to do so? Please share your thoughts in the comments.

Top comments (0)