Understanding file associations in JVM apps
Tags: java, desktop, kotlin
Published: 2024-01-07 12:55:28

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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) 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.

```java
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]);
    }
}
```

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.

```bash
jpackage --type msi --win-menu --input input --dest output --main-jar hello.jar --main-class Hello
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ta1orxzgxtiytnr2ff4q.png)

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

![The Hello app](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gliud9e3ou6qnjy7h6qo.png)

To learn more about the various ways to configure `jpackage`, kindly take a look at its [documentation](https://docs.oracle.com/en/java/javase/17/docs/specs/man/jpackage.html).

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*:

```properties
mime-type=text/plain
extension=hello
description=Hello
```

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`. 

```bash
jpackage --file-associations hello.properties --type msi --win-menu --input input --dest output --main-jar hello.jar --main-class Hello
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/pu4hiaea7ac22l3s61uc.png)


![Screenshot of the Hello app](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/acq913k8gcs8fjcc7fa8.png)

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*.

```java
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;
    }
}
```

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
```

This is how *Hello2* looks like:

![Hello2 running on macOS](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8pjf3i2v8ijazraq7dn2.png)

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](https://github.com/JetBrains/compose-multiplatform/issues/773) 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"
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/o0y2nf407sgosub67aqp.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/w7f09hc1y6m2tz1cxlan.png)

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.