Java_logo

Bundle the Java JRE into your macOS application

There are lots of tutorials explaining how to bundle a Java application as a macOS application, but there aren’t really any that explain how to bundle a Java application into an existing Objective-C or Swift Cocoa application.

This article describes one possible means of using Java within such applications, and will work for Swift as well as Objective-C.

Background to this Exercise

Since version 4.0, Balthisar Tidy and Balthisar Tidy for Work have offered HTML document validation with the W3C Nu HTML Checker. Users are given the option to perform document validation with their choice of the official W3C server, a server of their choice, or the local server that’s bundled with Balthisar Tidy.

The local server, inconveniently, is a Java application, which makes it quite tricky to use from a C/Objective-C environment. While C interfaces remain the default for linking to foreign libraries (ask Ruby, Python, PHP, and everyone else), Java’s JNI can be a bit of a pain to implement on macOS. This goes doubly for the Nu HTML Checker, because it’s designed to be a complete application used as a web service, and not a library like HTML Tidy.

The fact that it’s a web service, though, means that we can can communicate with it via HTTP, just like we have to do with the W3C’s server or user-specified server. This means, all we have to do is run it in a process separate from Balthisar Tidy.

Adding the JAR

At first glance, this appears easy:

  • After building the Nu HTML Checker, we have the entire application in a single Java JAR file: vnu.jar.
  • Xcode and macOS already support Java in packages, so the framework target that implements the server (JSDNuVFramework in the case of Balthisar Tidy) can simply put the JAR in JSDNuVFramework/Java/JAR, and this is a standard build phase in Xcode.
  • And, I can run the JAR file by invoking Java, probably with NSTask.

Except this probably won’t work, and won’t even be allowed if you want to offer your application on the Mac App Store.

You Need a JRE

Like both Objective-C and Swift, Java applications require a runtime, called the Java Runtime Environment (JRE). It used to be that you could almost count on this being installed on a macOS system by default, but that’s not been the case in many, many releases.

Sometimes users install Java (which includes the JRE, naturally) on their own in order to support other Java applications. If not, macOS will inform the user to install Java when an attempt to use Java is made, but it’s up to the user to find and install a compatible Java version.

Counting on the system to prompt to install Java isn’t very friendly, and it’s verboten for Mac App Store applications.

Instead, your only alternative is to host the JRE within your application’s bundle (or in the case of JSDNuVFramework, the framework bundle, which is found in the application bundle). The rest of this article will explain how to do that while minimizing the JRE size.

The JRE can Bloat your Application

The entire Java 10 JRE can be over 540 MB! While you can certainly bundle the entire JRE in your application, you’ll probably find that your users will wonder why the download size for your simple date calculator (or whatever) is half a gigabyte.

The entire JRE consists of headers, debugging symbols, documentation, and executable code that your application – in all likelihood – does’t require. Using Java’s own tools, we can find out the true JRE dependencies and build a custom JRE that’s significantly smaller. In the case of vnu.jar, the JRE has been trimmed down to just under 45 MB for Balthisar Tidy’s next release.

Prerequisites

If you’re reading this article for information (rather than for fun), you probably already have a Java JDK installed. These instructions should work for Java 9 and anything newer than Java 10, but I’ve only tested these techniques with Java 10.

Don’t overlook setting your $JAVA_HOME environment variable. It makes it much easier to type long paths in Terminal.app, and it means I won’t have to type long paths in this article that, when you copy and paste in your own terminal, fail to work.

On my system, I've set

export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-10.0.2.jdk/Contents/Home

…in my .bash_profile file.

Determine JRE Dependencies

To determine which parts of the JRE that vnu.jar depend on, use the jdeps tool provided by Java. Note that my terminal’s working directory is the root directory of the Nu HTML Checker project; adjust your paths to your own situation.

jdeps --print-module-deps build/dist/vnu.jar

For the most part, it’s okay to ignore the “split package” warnings. The important data is the last line of output:

java.base,java.desktop,java.management,java.naming,java.security.jgss,java.sql,jdk.scripting.nashorn

These are the Java modules that must constitute our custom JRE.

Build the Custom JRE with jlink

Next, we can build the custom JRE, and make it as small as possible in one long step. Again, my example is working from the repository’s root directory.

$JAVA_HOME/bin/jlink \
	--module-path build/dist \
	--add-modules java.base,java.desktop,java.management,java.naming,java.security.jgss,java.sql,jdk.scripting.nashorn \
	--output build/Home \
	--no-header-files \
	--no-man-pages \
	--strip-debug \
	--compress=2

The result will be a custom JRE with the minimum number of Java modules required to run the vnu.jar JAR. Things such as debug symbols and headers will be omitted, and everything will be packaged into the single folder named “Home”.

Now Construct Our Package

Xcode and macOS will expect your JRE to be installed in your bundle’s standard “PlugIns” folder, and that it follow the standard bundle format itself. If you are planning to code sign your application, then this bundle format is doubly important.

Create this directory structure manually in Finder or your terminal. In the case of JSDNuVFramework, our bundle content looks something like this:

{JSDNuVFramework}/
  Java/
    vnu.jar
  PlugIns/
    Java.runtime/
      Contents/
        Home/
          {jlink-built-contents}
        MacOS/
          libjli.dylib
  Resources/

Notice that the Java.runtime/ directory honors the standard bundle format. This is important if you plan to code-sign your application, in that codesign simply won’t work if the bundle isn’t a macOS bundle.

When we built the JRE with jlink, the example should have produced a directory named “Home”; the Home directory above should be this entire directory. Just drag it over in Finder to keep things simple.

However, let’s address the MacOS directory and its libjli.dylib library. This file is not generated by the JRE, and it’s not part of the JRE. Rather, it’s a bridging library required by macOS to execute the java instance in the bundle. You should copy and paste this from your JDK installation path, which should be $JAVA_HOME/lib/jli/libjli.dylib.

If you wish to pare down your JRE just a little bit further, feel free to delete all of the executables except for java in the Home/bin/ directory. If you plan to sign your executable, do this before signing.

If you’re not code-signing or deploying to the Mac App Store, then you’re done. You can use NSTask to execute the java executable in your bundle using vnu.jar as its argument.

Change the java Info.plist

Yes, binary executables can have an Info.plist file embedded into them, too. Let’s take a look at your custom JRE’s java executable’s Info.plist, adjusting your own paths accordingly:

otool -s __TEXT __info_plist java| xxd -r

The result should be something very similar to this:

<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>CFBundleIdentifier</key>
        <string>net.java.openjdk.cmd</string>
        <key>CFBundleInfoDictionaryVersion</key>
        <string>6.0</string>
        <key>CFBundleShortVersionString</key>
        <string>1.0</string>
        <key>CFBundleVersion</key>
        <string>1.0</string>
</dict>
</plist>

That CFBundleIdentifier with value net.java.openjdk.cmd is going to be a problem for you if you wish to deploy to the Mac App Store, because every bundle in the Mac App Store must have a unique name. Some unknown developer has already apparently submitted an application using this bundle identifier, and so it’s no longer available for use.

You can develop a bash script to use xxd and sed to convert the binary to text, and find and replace the net.java.openjdk.cmd string, and convert the file back to binary, but the simplest thing to do is probably use any binary editor and search for the offending bundle ID, and replace it with a new bundle ID of the exact same length. Please don’t use com.balthisar.java-a or com.balthisar.java-b, because I already have them in use!

Give Entitlements to the java executable

Mac App Store apps must be sandboxed, which means that they only have the entitlements requested by the developer. In Xcode, there’s a simple GUI to set these for your application, but there’s no GUI for embedded executables. Because we have to execute vnu.jar with the JRE’s java executable, it must be given its own entitlements; it will not inherit your application’s entitlements by default.

Although you can set any entitlements you want with the following method, this example will keep things simple and demonstrate how to inherit the containing application’s entitlements.

Create a text file named java.entitlements with the following contents:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
    <dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <key>com.apple.security.inherit</key>
        <true/>
    </dict>
</plist>

And then grant these entitlements to the java executable:

codesign -f -s "Mac Developer: John Smith (XYZ)" --entitlements "java.entitlements" "Home/bin/java"

Of course, use your own developer certificate, and as always, use the paths appropriate to your situation.

Because this is a cryptographic operation, make sure any changes to the Info.plist are already completed prior to this step.

Configure Xcode

Make sure that your application or framework target will copy and sign this PlugIn bundle when you build. Do this by adding a "New Copy Files Phase" to the target. Rename it to something sensible (such as “Copy Java Runtime”), choose PlugIns as the destination, choose the Java.runtime directory, and ensure that code sign on copy is selected.

Sign the Java.runtime bundle

Sometimes code sign on copy will complain that the Java.runtime bundle is not already signed. Although this appears to have gone away with Xcode 10, you may have to manually deep sign the bundle in order to have Xcode re-sign it when building your project:

codesign --deep -f -s "Mac Developer: John Smith (XYZ)" Java.runtime/

As usual, check your own developer ID and paths to the package.

Launch your NSTask

That’s it! Assuming you know how to locate the resources in your bundle, you can run your JAR file using NSTask. To see how Balthisar Tidy does this, including a watchdog to kill the process if Balthisar Tidy dies, check out the source code for JSDNuVFramework on the Github page.

comments powered by Disqus