How to bundle Java in a universal MacOS app

Vassal 3.7.2 will have a single universal MacOS bundle, runnable on both Intel and Apple Silicon Macs. It will no longer be necessary to pick (or let the front page of our website guess) the correct one for your architecture. The only one we provide from here onward will work natively on both.

It’s not at all obvious how to build a universal MacOS bundle for a Java application, and there’s little information on the internet about doing it. Here’s what we learned:

There are no universal JDKs out there for MacOS, so you need to get an x86_64 one and an arm64 one and combine them using lipo. Our build process was already fetching the two JDKs we needed; we added a version of lipo which runs on Linux for producing the universal binaries:

You might think the obvious approach is to lipo the two JDKs and then build bundles normally with the universal JDK you’ve produced… and you would be completely wrong, because jlink does not work properly with universal binaries. What it does, at least as of Java 20, is give you binaries stripped back to a single architecture. That is—jlink and lipo do not commute. Instead, you need to produce a minimal JRE using jlink for each of the two architectures and then run lipo on the results of jlink.

This blog post about bundling universal Java apps for MacOS was extremely helpful:

The author noted that executables run from a shell script launcher default to x86_64 if that architecture is present in the executable, even on arm64 machines. To overcome this, he switched his launcher to a C program. We found that we were able to convince executables which are run from a shell script launcher to run with the correct architecture if we detected the CPU architecture and then ran them with arch, which lets you force executables to run with a specified architecture:

# determine the correct CPU architecture
if sysctl machdep.cpu.brand_string | grep -q Intel ; then

# fire it up
exec arch -$ARCH Contents/MacOS/jre/bin/java -classpath Contents/Resources/Java/Vengine.jar  -Xdock:name=VASSAL -Xdock:icon=Contents/Resources/VASSAL.icns VASSAL.launch.ModuleManager "${ARGS[@]}"

Hopefully somebody finds this explanation helpful. Thank you to @marktb1961 and @JoelCFC25 for providing essential testing as we worked through this.