While making the MonoDevelop and MonoDoc packages for Mac I learned a few things about adapting GTK# apps for Mac, and I'd like to share them so that anyone else who's built a GTK# app on Windows or Linux can provide a nice self-contained Mac app bundle for their Mac users. This first part will cover building an app bundle, and a later post will cover building platform-specific code paths so that your app integrates with the main menu and dock.
If you're not a Mac developer, you might be wondering exactly what an app bundle is. Well, it's simply a directory with a ".app" extension that contains an application and everything it needs. The Mac GUI shell treats this folder as a self-contained application that can be run directly. It never has to be "installed" as such, but can simply kept wherever the user wants, typically in the system's Applications folder, and to "uninstall", it's simply deleted. To look inside an app bundle, use the context menu on the bundle in Finder.
The most important part of the app bundle is the Info.plist manifest, which lives in the Contents subdirectory of your app bundle, and describes the application. A plist ("property list") is an Apple structured data format that can be represented in binary or xml formats. We'll be using the xml format, which is fairly simple, if a little odd. Let's look at a simplified version of the MonoDoc app bundle's manifest:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>CFBundleDevelopmentRegion</key> <string>English</string> <key>CFBundleExecutable</key> <string>monodoc</string> <key>CFBundleIconFile</key> <string>monodoc.icns</string> <key>CFBundleIdentifier</key> <string>com.novell.monodoc</string> <key>CFBundleInfoDictionaryVersion</key> <string>6.0</string> <key>CFBundleName</key> <string>MonoDoc</string> <key>CFBundlePackageType</key> <string>APPL</string> <key>CFBundleShortVersionString</key> <string>2.2</string> <key>CFBundleSignature</key> <string>xmmd</string> <key>CFBundleVersion</key> <string>2.2</string> <key>NSAppleScriptEnabled</key> <string>NO</string> </dict> </plist>
As you can see, it simply contains key/value pairs to set properties of the app bundle. This is just a subset of the keys you can set, but they're the important ones for our purposes. If you need more, see the Apple docs.
The one you need to change are:
Contents/MacOS subdirectory of your bundle that Launch Services will run when opening your app. We'll be using a shell script to run your real executable using Mono.Contents/Resources subdirectory of your bundle.Treat the other keys as boilerplate that must be included. Much of the app bundle layout and properties are designed for apps using the native toolkits; we are using just enough of them to fit in and work correctly.
If you read the descriptions of the keys, you'll see that your app bundle directory should have the following structure:
.app
Contents
Info.plist
Resources
.icnsMacOS
After you've written your Info.plist, made an icon file using Icon Composer (from the Apple Developer Tools), and copied all your app's real file into the bundle's MacOS folder, all we're missing is the launch script. The Apple Launch services can run shell scripts, but doesn't know how to open Mono programs directly, so we use a shell script to start Mono. Let's look at the MonoDoc launch script:
#!/bin/sh # Author: Michael Hutchinson (mhutchinson@novell.com) DIR=$(cd "$(dirname "$0")"; pwd) MONO_FRAMEWORK_PATH=/Library/Frameworks/Mono.framework/Versions/Current export DYLD_FALLBACK_LIBRARY_PATH="$DIR:$MONO_FRAMEWORK_PATH/lib:/lib:/usr/lib" export PATH="$MONO_FRAMEWORK_PATH/bin:$PATH" exec mono "$DIR/browser.exe"
This is very straightforward. It simply gets a full path to the bundle's MacOS directory and puts it in the DIR variable so that can be used later in the script, and sets DYLD_FALLBACK_LIBRARY_PATH to include this directory and the Mono framework directory, so that you can P/Invoke native libraries in your MacOS directory and the Mono framework lib directory. It also sets the PATH environment variable to include the official Mono framework's bin directory, ensuring that the official Mono is used. Using the official Mono is important, because it means you avoid issues specific to users who have MacPorts or some other custom Mono in their PATH, which are likely to be difficult to reproduce or fix. It then executes the app using Mono.
This is a simple script, but it has some deficiencies. We can improve it by taking some code and tricks from the MonoDevelop Mac launch script.
First, we'll detect whether the Mono Framework is installed, and if it's not, show a message using AppleScript telling the user to download it. This prevents your app from dying silently if Mono isn't installed. Put this in your script before the exec call.
#mono version check REQUIRED_MAJOR=2 REQUIRED_MINOR=4 APPNAME="MonoDevelop" VERSION_TITLE="Cannot launch $APPNAME" VERSION_MSG="$APPNAME requires the Mono Framework version $REQUIRED_MAJOR.$REQUIRED_MINOR or later." DOWNLOAD_URL="http://www.go-mono.com/mono-downloads/download.html" MONO_VERSION="$(mono --version | grep 'Mono JIT compiler version ' | cut -f5 -d\ )" MONO_VERSION_MAJOR="$(echo $MONO_VERSION | cut -f1 -d.)" MONO_VERSION_MINOR="$(echo $MONO_VERSION | cut -f2 -d.)" if [ -z "$MONO_VERSION" ] \ || [ $MONO_VERSION_MAJOR -lt $REQUIRED_MAJOR ] \ || [ $MONO_VERSION_MAJOR -eq $REQUIRED_MAJOR -a $MONO_VERSION_MINOR -lt $REQUIRED_MINOR ] then osascript \ -e "set question to display dialog \"$VERSION_MSG\" with title \"$VERSION_TITLE\" buttons {\"Cancel\", \"Download...\"} default button 2" \ -e "if button returned of question is equal to \"Download...\" then open location \"$DOWNLOAD_URL\"" echo "$VERSION_TITLE" echo "$VERSION_MSG" exit 1 fi
We can also take some code to use the "-a" argument to exec to set the process name that will be see by the "ps" commandline tool. Note that this doesn't work on OS 10.4, so we check the OS version and define an exec command in a variable that we can use later.
# Work around a limitation in 'exec' in older versions of macosx OSX_VERSION=$(uname -r | cut -f1 -d.) if [ $OSX_VERSION -lt 9 ]; then # If OSX version is 10.4 MONO_EXEC="exec mono" else MONO_EXEC="exec -a appname mono" fi
Finally, change the exec call from the original version to use our new MONO_EXEC variable, write all console output to a log file in a subdirectory of ~/Library/Logs, and pass the value of the MONO_OPTIONS environment variable to Mono. The MONO_OPTIONS environment variable is useful to enable you to to pass diagnostic options to the Mono runtime, such as "--debug", without altering your script.
EXE_PATH="$DIR\appname.exe" LOG_FILE="$HOME/Library/Logs/$APPNAME/$APPNAME.log" mkdir -p "`dirname \"$LOG_FILE\"`" $MONO_EXEC $MONO_OPTIONS "$EXE_PATH" $* 2>&1 1> "$LOG_FILE"
Let's tidy this all up into a single script, with all the values you need to change for your specific app in one place at the top.
#!/bin/sh #get the bundle's MacOS directory full path DIR=$(cd "$(dirname "$0")"; pwd) #change these values to match your app EXE_PATH="$DIR\appname.exe" PROCESS_NAME=appname APPNAME="AppName" #set up environment MONO_FRAMEWORK_PATH=/Library/Frameworks/Mono.framework/Versions/Current export DYLD_FALLBACK_LIBRARY_PATH="$DIR:$MONO_FRAMEWORK_PATH/lib:/lib:/usr/lib" export PATH="$MONO_FRAMEWORK_PATH/bin:$PATH" #mono version check REQUIRED_MAJOR=2 REQUIRED_MINOR=4 VERSION_TITLE="Cannot launch $APPNAME" VERSION_MSG="$APPNAME requires the Mono Framework version $REQUIRED_MAJOR.$REQUIRED_MINOR or later." DOWNLOAD_URL="http://www.go-mono.com/mono-downloads/download.html" MONO_VERSION="$(mono --version | grep 'Mono JIT compiler version ' | cut -f5 -d\ )" MONO_VERSION_MAJOR="$(echo $MONO_VERSION | cut -f1 -d.)" MONO_VERSION_MINOR="$(echo $MONO_VERSION | cut -f2 -d.)" if [ -z "$MONO_VERSION" ] \ || [ $MONO_VERSION_MAJOR -lt $REQUIRED_MAJOR ] \ || [ $MONO_VERSION_MAJOR -eq $REQUIRED_MAJOR -a $MONO_VERSION_MINOR -lt $REQUIRED_MINOR ] then osascript \ -e "set question to display dialog \"$VERSION_MSG\" with title \"$VERSION_TITLE\" buttons {\"Cancel\", \"Download...\"} default button 2" \ -e "if button returned of question is equal to \"Download...\" then open location \"$DOWNLOAD_URL\"" echo "$VERSION_TITLE" echo "$VERSION_MSG" exit 1 fi #get an exec command that will work on the current OS version OSX_VERSION=$(uname -r | cut -f1 -d.) if [ $OSX_VERSION -lt 9 ]; then # If OSX version is 10.4 MONO_EXEC="exec mono" else MONO_EXEC="exec -a \"$PROCESS_NAME\" mono" fi #create log file directory if it doesn't exist LOG_FILE="$HOME/Library/Logs/$APPNAME/$APPNAME.log" mkdir -p "`dirname \"$LOG_FILE\"`" #run app using mono $MONO_EXEC $MONO_OPTIONS "$EXE_PATH" $* 2>&1 1> "$LOG_FILE"
Now open a terminal, make the script executable, and execute it.
chmod +x AppName.app/Contents/MacOS/scriptname ./AppName.app/Contents/MacOS/scriptname
It's useful to run the script directly like this because you will immediately see the result of any errors in it. Assuming the script worked, your app bundle is complete, and you can double-click on it to run it.
In my follow-up post I cover writing platform-specific code paths to integrate your new bundle better with the platform, including using the Mac main menu, and handling Apple events, which will enable your app to handle files and URLs.
This is part of the Catchup 2010 series of posts.
Comments
Winni
Sun, 2010-01-24 06:40
Permalink
Thank you!
It's great to see that Mono Development on OS X is gaining momentum. You guys are doing excellent work. Thank you for all your efforts!
SandyArmstrong
Sun, 2010-01-24 11:57
Permalink
Console.app
Tip: Even if you start your app by double-clicking the bundle, you can still see complete output by looking in /Utilities/Console.app.
Great post, Michael!
Michael
Mon, 2010-01-25 01:59
Permalink
Excellent point
It's definitely worth knowing about Console.app — it also lets you easily find and view the log that the launch script redirected to ~/Library/Logs/AppName. But I find it a bit noisy for viewing stdout/stderr of apps, which is why I recommended using the terminal.
scott willeke
Tue, 2010-02-02 18:28
Permalink
Why not macpack?
This is a super useful article for understanding how everything works. However, I noticed there is the macpack (http://www.mono-project.com/Guide:Running_Mono_Applications#macpack_.28Mac_OS_X_only.29 ) utility too. Is there any reason not to use macpack?
Admin
Thu, 2010-02-04 15:23
Permalink
More control
macpack doesn't give you as much control — for a start, you can't edit the Info.plist or the startup script, so many of the things I did, like URL/file handlers and the Mono version check, are impossible. Arguably macpack could be improved, but there's not really much to doing the process by hand anyway.
Anonymous
Wed, 2010-11-10 15:14
Permalink
Amazing
Thanks! Great post. I never thought I could develop Mac applications. It's amazing to see the program on the screen
Peter
Tue, 2010-11-23 09:44
Permalink
Putting mono itself in the bundle?
Thanks for the article, it's very useful. However, the resulting app will only work on Mac with mono installed. What if I want to bring everything I need from mono with one package, instead of sending the user doing additional download and install? How it is possible?
Thanks in advance,
Peter
Michael
Thu, 2010-12-02 20:57
Permalink
It's possible
It's possible, but we don't currently have a tool to do it automatically. We're investigating doing one to enable shipping apps on the Mac app store.