Take your Java application to the Mac App Store.

Posted by Marco Dinacci on 0 comments

The AppBundler fork is still maintained but no longer by me. Forward your queries to the capable folks at the Infinite Kind. Please also note that this article has been last updated on the 29th October 2012 and I'm no longer actively maintaning it. Thanks.

Moneydance is on sale in the App Store!

In this guide I'll go through all the steps required to port your Java Swing application to OpenJDK with the goal of preparing it for the submission into the App Store. Although the App Store guidelines explicitly forbids applications to rely on deprecated or optionally installed technologies (Apple no longer bundles their JDK port so applications can't rely on the user to have it installed), you can still distribute your Java application on the App Store by embedding the OpenJDK 7 OSX port in a native OSX application.

It may sounds obvious but the procedure that follows can be performed only on OSX, not from Windows, Linux or any other OS.

Follow this steps:
  1. Gather the requirements
  2. Make your application sandbox-proof
  3. Migrate existing data and preferences
  4. Build and sign a package for your application bundle
and be sure to take a look at the known problems.

Requirements

OpenJDK

First of all, you need to acquire the OpenJDK OSX port. There are 3(½) ways:

Don't know which one to choose ?

Getting the source should be the only reasonable option if you're serious about bringing your application to the App Store. However if you want to get started quickly and are not interested in the bleeding edge version, download the Oracle distribution. If you want the latest version of the code but don't want to build it yourself, download one of the Henry Gomez's pre-built images.

Note that the Oracle bundle and the OpenJDK have different licences, the first is released under the Binary Code License agreement , the second under the GPL version 2 with Classpath Exception. Make your choice.

(½) The absolute best setup would be to clone the official repo and then replace the jdk subrepo with our fork which includes more bug fixes for OSX.The situation is a bit different from when I started writing this post. Oracle has integrated all my patches, so unless you require 32bits support the recommended option is to use an OpenJDK binary distribution (and apply the fix for freetype, see below).

AppBundler

Once you have finished downloading the OpenJDK you need to install AppBundler to package your application.

AppBundler is an ant task so just put the downloaded jar in the lib folder in your ant distribution and then follow the instructions to package your application. It still misses a few features like the ability to register your application with a particular file type but it allows to include a Java runtime within the application bundle and building an Info.plist file with most of the required keys.

AppBundler is still missing some basic features like file types association. I recommend instead using our fork.

Note that some keys are compulsory and your application won't be accepted if they're missing so be sure they're present. You must declare at least:

Refer to Apple documentation for the meaning of these keys or just follow this example to be sure they're always included:

<target name="bundle" depends="...">
    <!-- Import the AppBundlerTaks from ant lib directory -->
    <taskdef name="bundleapp" 
           classname="com.oracle.appbundler.AppBundlerTask"/>

    <bundleapp outputdirectory="."
        name="MyApp"
        displayname="MyApp"
        identifier="com.foobarbaz.MyApp"
        shortversion="1.0"
        icon="MyApp.icns"
        mainclassname="MyAppMain"
        copyright="2012 FooBarBaz LLC."
        applicationCategory="public.app-category.entertainment">

        <!-- The directory where your OpenJDK runtime is. -->
        <runtime dir="/path/to/openjdk/Contents/Home"/>

        <!-- The bundleapp task doesn't support classpathref so all 
            the run classpath entries must be stated here too.
        -->
        <classpath file="MyApp.jar"/>
        <classpath file="dependencies.jar"/>
      
        <!-- Workaround since the icon parameter for bundleapp 
             doesn't work. (It's not a bug in AppBundler but 
             in the JavaAppLauncher, see Known Problems).
        -->
        <option value="-Xdock:icon=Contents/Resources/${bundle.icon}"/>

        <!-- OSX specific options, optional -->
        <option value="-Dapple.laf.useScreenMenuBar=true"/>
        <option value="-Dcom.apple.macos.use-file-dialog-packages=true"/>
        <option value="-Dcom.apple.macos.useScreenMenuBar=true"/>
        <option value="-Dapple.awt.application.name=MyApp"/>
        <option value="-Dcom.apple.smallTabs=true"/>

        <option value="-Xmx1024M"/>
    </bundleapp>
</target>   

If you decide to use my fork, you can do much more:

<target name="bundle">
  <taskdef name="bundleapp" 
    classpath="appbundler-1.0ea.jar"
    classname="com.oracle.appbundler.AppBundlerTask"/>

    <!-- Note the usage of classpathref to avoid copy-pasting all 
    your classpath entries from another target. -->
  <bundleapp 
        classpathref="runclasspathref"
        name="MyApp"
        displayname="MyApp"
        identifier="com.foobarbaz.MyApp"
        shortversion="1.0"
        version="build 325"
        icon="MyApp.icns"
        mainclassname="MyAppMain"
        copyright="2012 FooBarBaz LLC."
        applicationCategory="public.app-category.entertainment">

      <runtime dir="${runtime}/Contents/Home"/>

      <!-- Specify which architectures you want to support -->
      <arch name="x86_64"/>
      <arch name="i386"/>

      <!-- Register the application as an editor for PNG and JPG files -->
      <bundledocument extensions="png,jpg"
        icon="${bundle.icon}"
        name="Images"
        role="editor">
      </bundledocument>

      <!-- Register the application as a viewer for PDF files -->
      <bundledocument extensions="pdf"
        icon="${bundle.icon}"
        name="PDF files"
        role="viewer">
      </bundledocument>

      <!-- Register the application with your custom format, 
      bundled as a package -->
      <bundledocument extensions="custom"
        icon="${bundle.icon}"
        name="Custom data"
        role="editor"
        isPackage="true">
      </bundledocument>

      <!-- Workaround since the icon parameter for bundleapp doesn't work -->
      <option value="-Xdock:icon=Contents/Resources/${bundle.icon}"/>

      <!-- OSX specific options, optional -->
      <option value="-Dapple.laf.useScreenMenuBar=true"/>
      <option value="-Dcom.apple.macos.use-file-dialog-packages=true"/>
      <option value="-Dcom.apple.macos.useScreenMenuBar=true"/>
      <option value="-Dcom.apple.mrj.application.apple.menu.about.name=${bundle.name}"/>
      <option value="-Dcom.apple.smallTabs=true"/>

      <option value="-Xmx1024M"/>
  </bundleapp>
</target>

See how I used classpathref to declare an external classpath, arch to declare the supported architectures and bundledocument to register the application with particular file types.

Make your application sandbox proof

Starting from the 1st of June all applications submitted to the App Store must be sandbox ready.

For a comprehensive explanation of what the sandbox is refers to the Apple documentation. In short though, the sandbox is an access control mechanism which restricts interaction of your application with the operating system.

The container is the directory where your application can safely read/write the files required for its correct functioning. It is physically located in ~/Library/Containers/yourapp.bundle.id; I doubt this is likely to change anytime soon so if you have a very simple application you may hardcode this path instead of relying on a native library to return it for you. I say may because I can't possibly know how Apple will react if you hardcode the path of the container instead of obtaining it by using the API.

If your application will require storing user documents in the container or writing temporary files you'll have to acquire the appropriate directories.One route is to write a JNI wrapper and bundle a small native library with your app, another is to use our AppBundler fork which pass the directories to the Java app as environment variables.

If you decide to use our AppBundler, from your Java app you can retrieve the directories using these system properties:

System.getProperty("LibraryDirectory");
System.getProperty("DocumentsDirectory");
System.getProperty("CachesDirectory");
System.getProperty("ApplicationSupportDirectory");
System.getProperty("SandboxEnabled");
System.getProperty("SandboxEnabled");// (the String "true" or "false")

Assuming your app identifier is com.acme.MyApp, the variable values will be:

// If the app is sandboxed:
/Users/user/Library/Containers/com.acme.MyApp/Data/Library
/Users/user/Library/Containers/com.acme.MyApp/Data/Documents
/Users/user/Library/Containers/com.acme.MyApp/Data/Library/Application Support
/Users/user/Library/Containers/com.acme.MyApp/Data/Library/Caches

// if the app is not sandboxed
/Users/user/Library
/Users/user/Documents
/Users/user/Library/Application Support
/Users/user/Library/Caches
false

Otherwise, if you decide to go with the native route, this is how you would create a native library and acquire the directories where you can write and store app-specific data:

UPDATE: javah generate the header files just fine, the problem was due to the fact I was using Apple's JavaVM framework based on JDK 1.6; instead, compile including the headers of the OpenJDK you're using, for example:

-I /Library/Java/JavaVirtualMachines/jdk1.7.0_06.jdk/Contents/Home/include -I /Library/Java/JavaVirtualMachines/jdk1.7.0_06.jdk/Contents/Home/include/darwin  

and include the jni header

#include <JavaVM/jni.h>

Thanks to Scott Kovatch for pointing it out.

Here's a simple Makefile to generate the headers:

#!/bin/sh

DIST = jni_headers
CP = foobaz.jar
CLASS = com.foobaz.yourclass

all:
    mkdir -p $(DIST)
    javah -jni -d $(DIST) -cp $(CP) $(CLASS)

clean:
    rm *.o *.class

Now that you have your C header you can create a function that will return for example the Application Support folder for your application, which is located inside the appplication container:

JNIEXPORT jstring JNICALL 
Java_com_foobar_OSXAdapter_getApplicationSupportFolder
(JNIEnv *env, jobject jthis) {
    jstring path;
    
    @autoreleasepool {
        
        JNF_COCOA_ENTER(env);
        
        NSArray *paths = NSSearchPathForDirectoriesInDomains(
                                NSApplicationSupportDirectory, 
                                NSUserDomainMask, YES);
        NSString *basePath = [paths objectAtIndex:0];
        
        // Convert the NSString to a jstring
        const char *cString = [basePath UTF8String];
        path = (*env)->NewStringUTF(env, cString);
        
        JNF_COCOA_EXIT(env);
    }
    
    return path;
}

Migrate existing application data and preferences

If you're not releasing a new application chances is that you are already saving your application data and preferences somewhere, most likely in ~/Library/Application Support/YourApp.

Once your updated application is executed in the sandbox, that folder will be inaccessible for your program, but luckily Apple thought of a migration mechanism, which happens automatically the first time the user start the application. Here's an example of how you would migrate the preferences in ~/Library/Application Support/YourApp to ~/Library/Container/com.yourcompany.YourApp/Data/Library/Application Support/YourApp.

<?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>Move</key>
   <array>
      <string>${Library}/YourApp</string>
  </array>
</dict>
</plist>

Check the Apple documentation for more complex examples.

Build and sign the application bundle

This is where you need to have a Mac developer account. You could technically sign your bundle with a test key but you won't be able to submit your app nor to install it.

The process is made of three steps:

  1. Write an entitlements file to specify the permissions your application requires.
  2. Sign the bundle.
  3. Package it.

The first step require writing a plist file listing the permissions you need. Head over to the Apple website and check the documentation to find out which keys are available.

I'll show a simple example and I'll later demonstrate how to include it in your bundle:

?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>
    <!-- Activates the sandbox, required. -->
    <key>com.apple.security.app-sandbox</key>
    <true/>
    <!-- Allow your application read/write access to the user downloads folder -->
    <key>com.apple.security.files.downloads.read-write</key>
    <true/>
    <!-- Allow your application read/write access to the file selected by the user. -->
    <key>com.apple.security.files.user-selected.read-write</key>
    <true/>
    <!-- Allow your application to initiate network requests -->
    <key>com.apple.security.network.client</key>
    <true/>
    <!-- Allow your application to listen for network requests -->
    <key>com.apple.security.network.server</key>
    <true/>
    <!-- Allow your application to use the printer -->
    <key>com.apple.security.print</key>
    <true/>
</dict>
</plist>

It's not very difficult to understand what's going on in the above example, especially with the comments, but com.apple.security.files.user-selected.read-write deserves a more detailed explanation. The user selected file corresponds to the selected file or directory in a file dialog (either open or save). Note that on OSX this means that you're obliged to use the FileDialog instead of the JFileChooser, this is because the former uses the native NSOpenDialog and NSSaveDialog which are compatible with the sandbox.

Now let's see how to sign your bundle (that you have generated using AppBundler):

I assume that you have downloaded and installed the two keys required for signing the bundle from the Apple developer website. Of course you need to be registered as a Mac developer.

First, you have to sign the application bundle, which you do by executing the following command from a terminal:

codesign -v -f -s SIGNING_ID_APP --entitlements YourApp.entitlements YourApp.app

SIGNING_ID_APP is the name of your key which is named something like 3rt Party Mac Developer Application: your name here

Second, you need to sign every jar and dylib (the jvm obiously have plenty) contained in the bundle:

find YourApp.app/Contents/ -type f \( -name "*.jar" -or -name "*.dylib" \) -exec codesign --verbose -f -s SIGNING_ID_APP --entitlements YourApp.entitlements {} \;

It doesn't harm to verify that the signing process went well:

echo "Verify all libraries have been signed..."
find YourApp.app/Contents/ -type f \( -name "*.jar" -or -name "*.dylib" \) -exec codesign --verbose --verify {} \;

Finally you can create a .pkg ready for submission:

productbuild --component YourApp.app /Applications --sign SIGNING_ID_INSTALLER YourApp.pkg

SIGNING_ID_APP is the name of your installer key which is named something like 3rt Party Mac Developer Installer: your name here

Congratulations, your app is ready for submission!

Known problems

Plenty unfortunately, however I fixed most of the ones I've encountered and included the fixes in our forks. The easiest way to get them is to clone our jdk and AppBundler stable forks.

Here's a list of most of the problems I've encountered and their solution, where available:

When I say that an issue has been fixed it means it's in the official Oracle JDK8 or JDK7u repo.

Launch failure (LSOpenURLsWithRole() failed with error...)

UPDATE: this has been fixed.

Mailing list Discussion.

This is not a bug in the AppBundler but it is related to this bug which should be fixed soon.

libfreetype absolute reference causes applications inside a sandbox to crash

Mailing list discussion

All Swing/AWT apps won't even open because they rely on the freetype lib installed on the user system but the sandbox will immediately block your application the access to it and your application will crash.

The proper solution would be to fix the OpenJDK makefiles otherwise a quick workaround is to use two nifty tools called otool and install_name_tools:

First, find out where libfontmanager.dylib,located in the lib directory of your VM, expects to find freetype.

Open a shell and type:

    $ otool -L libfontmanager.dylib
    

Take note of the libfreetype path, likely /usr/X11/lib/libfreetype.6.dylib unless you have built freetype yourself or installed it somewhere else (maybe using MacPorts or a similar tool). Now type:

    $ sudo install_name_tool -change /usr/X11/lib/libfreetype.6.dylib @rpath/libfreetype.dylib libfontmanager.dylib
    

Type again otool -L libfontmanager.dylib and you should see the change. We have changed the path to where libfontmanager look for libfreetype to a relative one. So now you have to copy the system or your custom-build freetype library inside the JVM in the same location as libfontmanager.dylib.
Relaunch your application, you should at least be able to see it now :)

segmentedTextured buttons not working

Mailing list Discussion.

I didn't find a solution for this, but it seems that increasing the margin around the button make the problem go away. I couldn't afford to have too much empty space though so I just replaced the decorations with an image.

OpenJDK Universal build not available

Patch by Henry Gomez.

AppBundler universal binary

There's no point to have a 32/64 OpenJDK if your JavaAppLauncher only supports a 64bit architecture. Use this AppBundler instead.

Dock Icon defaults to Generic Java Application

UPDATE: I believe this has been fixed too but I haven't found the reference yet.

Dock Icon defaults to Generic Java Application

The (temporary) solution here is to specify both CFBundleIconFile and -Xdock:icon=Contents/Resources/your-icon in your Info.plist file. This could be done easily with AppBundler, I've written an example above.

apple.awt.fileDialogForDirectories property not working.

UPDATE: this patch has been accepted, it's already in OpenJDK 8 and will be soon in OpenJDK 7u

Bug report, discussion and a standalone patch.

JFileChooser broken in the sandbox.

Bug report. Discussion. There is no workaround for this, except to switch to a FileDialog .The bug has been incorrectly closed as duplicate of another issue with Runtime.exec().

Crash when opening an application from a registered file

UPDATE: my patch has been integrated into OpenJDK 8 and backported to OpenJDK 7u.

Bug report and Discussion.

If a user double click on a file to open your application the application will crash before even showing its main window.

awt.brush.metalLook is not observed.

UPDATE: this has been fixed, see webrev.

Planned to be fixed in jdk7u6.

Sandbox Violation on Runtime Exec

Bug description and ML discussion. Because of the sandbox an application can't create a child process; there are a few workarounds but they all involve writing native code. The real solution is to use posix_spawn.

CA certificates not included in OpenJDK

If your application uses SSL, you may want to look at this discussion (workaround included).

Private API usage

UPDATE: fixed, this will soon be in JDK7u (hopefully 7u8/10). I updated my repo with the (corrected) patch I proposed.

Our first app submission failed because some awt files use some functions defined in some Apple private frameworks. I replaced the functions I've found with public API and added a patch to the bug report.

Bug report and ml discussion.

ActionListener called twice for JMenuItem using ScreenMenuBar

UPDATE: I fixed this in my repo and submitted a patch to Oracle, it will be included in JDK7u8/10.

This causes the ActionListener to be called twice when executing an Action that is bound to a JMenuItem if the property apple.laf.useScreenMenuBar is set to true.

Bug report and patch discussion.

Main menu shortcuts not displayed when using ScreenMenuBar

UPDATE: this has been fixed in JDK8 so soon will be avalaible only in JDK7.

Bug report and discussion.

I fixed this bug in my fork but unfortunately the fix now causes bug #7160951 to manifest again. However I will revert my change once the JDK8 fix will be backported to JDK7u


It took me ages to make this post and I try as much as possible to keep it updated, if there are any errors or if you have any questions, leave a comment here or on Twitter or send me an e-mail.

Marco