Saturday, September 19, 2020

An AppImage for the NumericalChameleon, problems and solutions

Preface

What is an AppImage?

AppImage is a format for distributing portable software on Linux without needing superuser permissions to install the application. See also https://appimage.org/

What is the NumericalChameleon?

The NumericalChameleon is a free, open source unit converter running on the desktop on Windows, macOS, and Linux. It supports more than 6000 units in more than 90 categories. It is entirely written in Java. See also http://numericalchameleon.net

The Goal

On GNU/Linux the NumericalChameleon already supports several ways for a deployment: .deb, .rpm, self extracting file and a bzip2 compressed tarball. I wanted to support .AppImage as well, but I run into problems. That article should help other developers to bypass those issues that I have been facing with.

Problems and Solutions

While I was working on the .AppImage for the NumericalChameleon two major problems arise. To say it in advance, without code modification the AppImage did not work out of the box. Fortunately the code changes can be minimized.

Problem 1: Restart of the application didn't work

There are situations where the user has to restart the app. For example, if the user selects a different language for the GUI or if the user selects an option in order to let render the frame decoration by Java rather then by the operating system. The NumericalChameleon also offers the user to force a restart explicitly by selecting the menu item called "Restart" from the program menu. Example:

If a restart is being triggered, the current implementation determines the Java class that is being called when the user double clicks on the (portable or non-portable) jarball. It passes that class (along with some program args) to the startJarApplication method. The following is a short excerpt from the source of the NumericalChameleon 3.0.0:

                Class clazz = isPortable() ? net.numericalchameleon.launchers.MainGUIPortable.class : net.numericalchameleon.launchers.MainGUI.class;
                ProcessHelper.startJarApplication(JVMoptions, clazz, args);

See also https://github.com/jonelo/n16n-desktop

The startJarApplication method basically finds both the java executable binary and the .jar file that were used to start the app. It builds a new process using ProcessBuilder and starts it so that a new process with a new PID gets created. The parent process simply exists and the new process will survive.

That approach worked perfectly fine on all supported operating systems Windows, Linux, and macOS. However it failed on Linux with the .AppImage, because it is the AppImage itself that starts its contained application. The AppImage does that by mounting its payload in read only mode and calling an AppRun script that launches the JVM which launches the .jar file. So knowning the path to the java executable isn't helpful in this case, because it will change each time the AppImage starts.

Solution to Problem 1: Check whether we are running from an .AppImage?

The solution to the problem 1 is to determine whether the app is running from an .AppImage. And if it does, we have to restart the AppImage binary rather than the .jar file. To to that we can check, whether we are on Linux, because .AppImages are Linux only and we can check whether the system proberty called java.home starts with "/tmp/.mount", because that is the mount point that AppImage uses to mount its payload. If we are runnig from an AppImage, we also need to know the path to the .AppImage file itself. This can be done by passing the $APPIMAGE environement variable as a system property to the application launcher called AppRun:

# -- snip --
# start the application in the expected working folder ../openjdk/bin/java -Dapplication.appimage="$APPIMAGE" -jar nc.jar "$@"

 And in Java land, we need to add some code to achieve that:


        if (System.getProperty("os.name").equalsIgnoreCase("Linux")
         && System.getProperty("java.home").startsWith("/tmp/.mount_")) {
        ...
        String appimage = System.getProperty("application.appimage");

Since we now know the entire path to the AppImage, it is easy to restart the AppImage using a standard ProcessBuilder.


Problem 2: Files are not writable

The NumericalChameleon comes with a lot of files, many of them can be modified by the user. For example, if the user would like to load historic or current exchange rates, the user can do that by selecting the appropriate function in the app. However, since the AppImage mounts everything in ready-only mode, persistance is disabled and the app data cannot be updated by the user. The user has to wait for an updated AppImage. Well, since I am not going to upload an updated AppImage every day, those volatile data should be continued to be updated by the user on demand. The NC stores all changable data in the folder called data.

Solution to Problem 2: let's use a symlink to /tmp

Let's see whether we can find a solution for problem 2 that doesn't require any code chages in Java land. Since all files in the AppImage are being mounted ready-only and cannot be changed, the app image needs to hardcode a path that is available on all Linux platforms and it also must be accessible by the user. The home folder is not an option here, because the user's home is unknown at the time when the AppImage is being built. The solution is to use the /tmp path, because /tmp has the sticky bit set by default and it is writable by the user. So we bake a symlink called data that points to /tmp/.NumericalChameleon/data into the AppImage. The preciding dot is to mark that folder hidden on Linux. Here is an excerpt from my build script:

# -- snip --
# prepare a pointer to /tmp which is both known and user writable
mv "${n16nDir}/data" "${n16nDir}/data.ori"
cd "${n16nDir}"
ln -s /tmp/.NumericalChameleon/data data
cd -
"./${appImageTool}" --no-appstream "${appdir}" "${outdir}/${outfile}"

If the application starts, we simply need to check whether there is an up to date data folder and if it isn't, we simply copy the actual data folder from the mounted .AppImage mount point to /tmp/.NumericalChameleon/data:

# -- snip --
if [[ $refresh -eq 1 ]]; then
    mkdir -p /tmp/.NumericalChameleon
    printf "copying data files to %s\n" "$dataFolder"
    cp -r ../data.ori/. "$dataFolder"
    printf "%s\n" "$currentVersion" > "$versionFile"
fi

# start the application in the expected working folder

../openjdk/bin/java -Dapplication.appimage="$APPIMAGE" -jar nc.jar "$@"


And now we also can update the exchange rates on the fly:

./NumericalChameleon-x86_64.AppImage --filter ebc.europa.eu --continue

Mission completed.

The next release of the NumericalChameleon will also support the AppImage :-)