Netbeans Platform: Mac OSX App Bundle and Embedded JRE with Maven

One problem every Java Desktop developer has to deal with is that Java apps just don't act like native applications. Whether it's the JRE dependency, the swing look and feel, or just bad memories about slow performance in old versions of Java. On Mac OSX there is an additional hurdle where users expect their applications to be a .app bundle inside a DMG image with a shortcut to /Application allowing them to drag/drop the installation. The built in Netbeans Platform installer is nice, but it's a far cry from the native behavior.

With that in mind, I'm going to describe how you can really step up your Netbeans Platform Maven build to include an OSX deployment with a bundled JRE all packaged up as an APP inside a DMG image. What a mouth full.

All of the source code for my implementation in Universal Gcode Sender can be found on GitHub in this commit. The techniques used in this post were heavily inspired (and in many cases copy/pasted from) the Gephi project, which has a very nice packaging and deployment strategy.

Overview

1. Define properties - we'll expose some custom properties in our maven file in order to make it easier to understand and maintain.
2. Create and modify resource files.
3. Resource Filtering - some extra steps need to be taken to apply our properties to configuration files, so we'll have a filtering step utilizing the maven-resources-plugin.
4. Build the artifact - using the maven-antrun-plugin we'll build the Mac OSX bundle.
5. How to use it - What commands need to be run and from where.
6. Next steps - integration with CI, app signing, etc.

Define Properties

We start things off simple by defining a few properties. Here I've prefixed my app's properties with "ugs." to avoid any possible naming collisions and put them in my root POM.xml:
<ugs.app.title>Universal Gcode Platform ${project.version}</ugs.app.title>
  
<!--==== Mac OS X bundle settings ====-->
<ugs.appbundle.name>Universal Gcode Platform</ugs.appbundle.name>
<ugs.bundle.jre.url>http://download.oracle.com/otn-pub/java/jdk/8u131-b11/d54c1d3a095b4ff2b6607d096fa80163</ugs.bundle.jre.url>
<ugs.bundle.jre.version>jre-8u131-macosx-x64</ugs.bundle.jre.version>

<!-- Mac OS X signing identity - must match with a verified Apple developer certificate in the keychain -->
<ugs.codesign.identity>Developer ID Application</ugs.codesign.identity>

<!-- You probably already have this property -->
<brandingToken>ugsplatform</brandingToken>

Create and Modify Resource Files

OSX applications use an information property list file named "Info.plist" to configure the application, we will need to create such a file using the above properties. There are a number of additional configurations which can be made in this file like associating file extensions with your app, but they won't be covered here. You can read more about that in the documentation.

Create this file in your application module at applicaiton/src/main/resource/Info.plist:
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<plist version="1.0">
    <dict>

        <key>CFBundleName</key>
        <string>${ugs.appbundle.name}</string>
    
        <key>CFBundleVersion</key>
        <string>${project.version}</string>
    
        <key>CFBundleExecutable</key>
        <string>${brandingToken}</string>
    
        <key>CFBundlePackageType</key>
        <string>APPL</string>
    
        <key>CFBundleShortVersionString</key>
        <string>${project.version}</string>
    
        <key>CFBundleSignature</key>
        <string>????</string>
    
        <key>CFBundleInfoDictionaryVersion</key>
        <string>6.0</string>
    
        <key>CFBundleIdentifier</key>
        <string>${project.groupId}</string>
    
        <key>CFBundleIconFile</key>
        <string>${brandingToken}.icns</string>
    
        <key>NSHighResolutionCapable</key> 
        <true/>
    </dict>
</plist>

For convenience, you should also rename your launcher.conf file to your brandingToken and put it in the same directory as the Info.plist, for me this file is named ugsplatform.conf. The important part in this file is that it contains the commented out jdkhome property, it will be used later for bundling the JRE. Additional options can be configured with the "default_options" parameter:
# \${HOME} will be replaced by user home directory according to platform
default_userdir="\${HOME}/.\${APPNAME}/${project.version}/dev"
default_mac_userdir="\${HOME}/Library/Application Support/\${APPNAME}/${project.version}/dev"

# options used by the launcher by default, can be overridden by explicit
# command line switches
default_options="--branding ${brandingToken} -J-Xms64m -J-Xverify:none -J-Dsun.java2d.noddraw=true -J-Dsun.awt.noerasebackground=true -J-Dnetbeans.indexing.noFileRefresh=true"
# for development purposes you may wish to append: -J-Dnetbeans.logger.console=true -J-ea

# default location of JDK/JRE, can be overridden by using --jdkhome <dir> switch
#jdkhome="/path/to/jdk"

# clusters' paths separated by path.separator (semicolon on Windows, colon on Unices)
#extra_clusters=

Finally, we also need to create a .icns icon file, it should have the same name as your brandingToken and be located in the application module at application/src/main/app-resources/

Resource Filtering

Now that we have our resource files, we need to substitute the properties. This is done using the maven-resources-plugin. There is nothing especially fancy here, we are setting filtering to true and copying the filtered resources into the target directory:

<!-- Copy ressources ${brandingToken}.conf and Info.plist with filtering --><plugin>
    <artifactId>maven-resources-plugin</artifactId>
    <executions>
        <execution>
            <id>generate-app-conf-file</id>
            <phase>generate-resources</phase>
            <goals>
                <goal>copy-resources</goal>
            </goals>
            <configuration>
                <outputDirectory>${basedir}/target/</outputDirectory>
                <resources>
                    <resource>
                        <directory>src/main/resources</directory>
                        <includes>
                            <include>${brandingToken}.conf</include>
                            <include>Info.plist</include>
                        </includes>
                        <filtering>true</filtering>
                    </resource>
                </resources>
                <escapeString>\</escapeString>
            </configuration>
        </execution>
    </executions>
</plugin>

Build the Artifact

This is the big one, we download the JRE, create the package structure, sign the application and package it all up into a DMG image. By utilizing the maven-antrun-plugin we can do this in a procedural way.

Because we don't want to create the DMG image for every build, this is created as a profile which can be enabled independently of other features. I'll briefly describe the sections commented below to explain what is happening, but for further detail you'll need to carefully read the code.

Clean

In case the build fails, we need to delete any files which may have been left behind from a previous build. This will prevent accidentally releasing stale files.

Create folders

A special file structure needs to be created in order for the .app to be used, we start setting up that file structure by creating the folders.

Copy application

One important piece of the .app file structure is that the executable is put in a specific location. First we're copying the executable into the build directory so that we can do further modifications to it in a moment.

Copy logo and configuration files

Two more important files, copy the ${brandingToken}.icns and Info.plist files to their specified location.

Modify application script and copy to final location

We now move the executable into its final location in the .app file, which happens to be Contents/MacOS/. For OSX, the netbeans platform uses a shell script. We need to make a slight modification to this script because the relative location of the resources is different with this new file structure. To that end the replace command to update the working directory back to the bin directory so that everything works as it ought to.

Bundling the JRE

To bundle the JRE we first download it and untar the jre directory into .app/Contents/PlugIns/, some additional manipulation is needed to make sure everything works smoothly. Like resetting a quarantine bit and updating libjli.dylib

We also need to set the jdkhome variable in our .conf file, this is done with another substitution looking for #jdkhome="/path/to/jdk" and replacing it with the relative path to our bundled JRE.

Codesign JRE and App

This piece is something I haven't actually gotten to work yet. Presumably if you have your codesign identity configured properly on the build machine it should work as expected.

Create the DMG

Finally we need our DMG file complete with a symling to the /Application directory. You can take a look at the hdiutil and genisoimage commands which build the image on a OSX and Linux machines respectively.


<profile>
  <id>create-dmg</id>
  <properties>
      <skipCreateDmg>false</skipCreateDmg>
  </properties>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-antrun-plugin</artifactId>
        <executions>
          <!-- Create the Mac OS X application bundle and dmg file -->
          <execution>
              <id>create-app-bundle</id>
              <phase>package</phase>
              <goals>
                  <goal>run</goal>
              </goals>
              <configuration>
                  <skip>${skipCreateDmg}</skip>
                  <target>
                      <!-- Clean -->
                      <delete includeEmptyDirs="true" failonerror="false" removeNotFollowedSymlinks="true">
                          <fileset dir="${project.build.directory}/${ugs.appbundle.name}.app" followsymlinks="false"/>
                          <fileset dir="${project.build.directory}/${ugs.app.title}" followsymlinks="false"/>
                          <fileset dir="${project.build.directory}/${project.artifactId}-${project.version}.dmg" followsymlinks="false"/>
                      </delete>
                      <delete file="${project.build.directory}/${project.artifactId}-${project.version}.dmg" failonerror="false"/>

                      <!-- Create folders -->
                      <mkdir dir="${project.build.directory}/${ugs.appbundle.name}.app"/>
                      <mkdir dir="${project.build.directory}/${ugs.appbundle.name}.app/Contents/MacOS"/>

                      <!-- Copy application -->
                      <copy todir="${project.build.directory}/${ugs.appbundle.name}.app/Contents/Resources/${brandingToken}">
                          <fileset dir="${project.build.directory}/${brandingToken}"/>
                      </copy>

                      <!-- Copy logo and configuration files -->
                      <copy tofile="${project.build.directory}/${ugs.appbundle.name}.app/Contents/Resources/${brandingToken}.icns" file="src/main/app-resources/${brandingToken}.ic
ns" />
                      <copy tofile="${project.build.directory}/${ugs.appbundle.name}.app/Contents/Info.plist" file="${project.build.directory}/Info.plist"/>

                      <!-- Modify application script and copy to final location -->
                      <move file="${project.build.directory}/${ugs.appbundle.name}.app/Contents/Resources/${brandingToken}/bin/${brandingToken}" todir="${project.build.directory}/
${ugs.appbundle.name}.app/Contents/MacOS"/>
                      <replace file="${project.build.directory}/${ugs.appbundle.name}.app/Contents/MacOS/${brandingToken}" token="`dirname &quot;$PRG&quot;`" value="`dirname &quot
;$PRG&quot;`&quot;/../Resources/${brandingToken}/bin&quot;"/>
                      <chmod file="${project.build.directory}/${ugs.appbundle.name}.app/Contents/MacOS/${brandingToken}" perm="ugo+rx"/>

                      <!-- Download and untar JRE -->
                      <exec dir="${project.build.directory}" executable="curl">
                          <arg line="-L"/>
                          <arg line="-C"/>
                          <arg line="-"/>
                          <arg line="-b"/>
                          <arg line="&quot;oraclelicense=accept-securebackup-cookie&quot;"/>
                          <arg line="-O"/>
                          <arg line="${ugs.bundle.jre.url}/${ugs.bundle.jre.version}.tar.gz"/>
                      </exec>

                      <!-- Unzip archive -->
                      <mkdir dir="${project.build.directory}/${ugs.appbundle.name}.app/Contents/PlugIns"/>
                      <exec dir="${project.build.directory}" executable="tar">
                          <arg line="-zxf"/>
                          <arg line="${ugs.bundle.jre.version}.tar.gz"/>
                          <arg line="-C &quot;${ugs.appbundle.name}.app/Contents/PlugIns&quot;"/>
                      </exec>

                      <!-- Remove quarantine bit set recursively on JRE -->
                      <exec dir="${project.build.directory}" os="Mac OS X" executable="xattr">
                          <arg line="-rd"/>
                          <arg line="com.apple.quarantine"/>
                          <arg line="&quot;${ugs.appbundle.name}.app/Contents/PlugIns&quot;"/>
                      </exec>

                      <!-- Get the JRE folder name -->
                      <path id="jre_name">
                          <dirset dir="${project.build.directory}/${ugs.appbundle.name}.app/Contents/PlugIns" includes="jre*" />
                      </path>
                      <property name="bundle.jre.path" refid="jre_name" />
                      <basename property="bundle.jre.name" file="${bundle.jre.path}"/>

                      <!-- Configure relative JRE path into ugsplatform.conf -->
                      <replace file="${project.build.directory}/${ugs.appbundle.name}.app/Contents/Resources/${brandingToken}/etc/${brandingToken}.conf" token="#jdkhome=&quot;/pat
h/to/jdk&quot;" value="jdkhome=&quot;../../PlugIns/${bundle.jre.name}/Contents/Home&quot;"/>
                      <chmod file="${project.build.directory}/${ugs.appbundle.name}.app/Contents/PlugIns/**" perm="+x" type="both"/>

                      <!-- Fix JRE by replacing libjli.dylib symlink with real file -->
                      <delete file="${project.build.directory}/${ugs.appbundle.name}.app/Contents/PlugIns/${bundle.jre.name}/Contents/MacOS/libjli.dylib"/>
                      <copy file="${project.build.directory}/${ugs.appbundle.name}.app/Contents/PlugIns/${bundle.jre.name}/Contents/Home/lib/jli/libjli.dylib" todir="${project.bui
ld.directory}/${ugs.appbundle.name}.app/Contents/PlugIns/${bundle.jre.name}/Contents/MacOS"/>

                      <!-- Codesign JRE -->
                      <exec dir="${project.build.directory}" os="Mac OS X" executable="codesign">
                          <arg value="-fs"/>
                          <arg value="${ugs.codesign.identity}"/>
                          <arg value="-v"/>
                          <arg value="${ugs.appbundle.name}.app/Contents/PlugIns/${bundle.jre.name}/"/>
                      </exec>

                      <!-- Codesign app -->
                      <exec dir="${project.build.directory}" os="Mac OS X" executable="codesign">
                          <arg value="-fs"/>
                          <arg value="${ugs.codesign.identity}"/>
                          <arg value="-v"/>
                          <arg value="${ugs.appbundle.name}.app"/>
                      </exec>

                      <!-- Create application folder and add Applications dynamic link -->
                      <mkdir dir="${project.build.directory}/${ugs.app.title}"/>
                      <move file="${project.build.directory}/${ugs.appbundle.name}.app" todir="${project.build.directory}/${ugs.app.title}/" />
                      <symlink link="${project.build.directory}/${ugs.app.title}/Applications" resource="/Applications" failonerror="false" />

                      <!-- Create DMG (Mac OS X) -->
                      <exec dir="${project.build.directory}" os="Mac OS X" executable="hdiutil">
                          <arg value="create"/>
                          <arg value="-noanyowners"/>
                          <arg value="-imagekey"/>
                          <arg value="zlib-level=9"/>
                          <arg value="-srcfolder"/>
                          <arg value="${project.build.directory}/${ugs.app.title}"/>
                          <arg value="${project.artifactId}-${project.version}.dmg"/>
                      </exec>

                      <!-- Create DMG (Linux), only for testing -->
                      <exec dir="${project.build.directory}" os="Linux" executable="genisoimage">
                          <arg value="-V"/>
                          <arg value="${ugs.appbundle.name}"/>
                          <arg value="-U"/>
                          <arg value="-D"/>
                          <arg value="-l"/>
                          <arg value="-allow-multidot"/>
                          <arg value="-max-iso9660-filenames"/>
                          <arg value="-relaxed-filenames"/>
                          <arg value="-no-iso-translate"/>
                          <arg value="-r"/>
                          <arg value="-o"/>
                          <arg value="${project.artifactId}-${project.version}.dmg"/>
                          <arg value="-root"/>
                          <arg value="${project.build.directory}/${ugs.app.title}"/>
                          <arg value="${project.build.directory}/${ugs.app.title}"/>
                      </exec>
                  </target>
              </configuration>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
</profile>

How to use it

After all that, using it is a simple matter of running "mvn package -P create-dmg". The resulting file will look something like ugs-platform-app-2.0-SNAPSHOT.dmg and be located in your application/target directory.

Next Steps 

The next steps for me are to create a windows installer with a bundled JRE, and setup TravisCI (which has OSX machines available) to build my artifacts.

Comments

Popular posts from this blog

Decommissioning the cloud: A self-hosted future

Using a JFileChooser to browse AWS S3

Taking Stuff Apart: Realistic Modulette-8