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 inJSDNuVFramework/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.