macos app minus xcode, pt 2: app bundles

what is an app, really?

In the previous post, we managed to build a macOS app binary using only Swift! But... it's not quite an app in the way we think of apps. It doesn't have an icon, for starters, and we needed the AppDelegate and NSApp.setActivationPolicy(.regular) to have it show up in the dock. What we made is what macOS calls a "Mach-O executable", i.e. a plain single file executable.

On macOS the usual kind of app is known as an App Bundle, and when I started looking into it, I was surprised to learn that that's just a folder. A folder with a name that ends with.app and a specific structure to be sure, but a folder nonetheless. macOS understands this and will try to launch that folder as an app.

In fact, you can look inside of any app bundle by going to the Applications folder in Finder, right clicking on an app, and selecting "Show Package Contents"!

the structure of an App Bundle

All we need for a minimal app bundle is this:

HelloWorld.app (dir)
    └── Contents (dir)
        ├── MacOS (dir)
        │   └── hello_world (binary from before)
        └── Info.plist		

I prepared the folder structure using mkdir -p HelloWorld.app/Contents/MacOS which creates all the folders in one go, and then copied my hello_world binary to Contents/MacOS/.

cp -R $(swift build --show-bin-path)/hello_world \
	HelloWorld.app/Contents/MacOS/hello_world

swift build --show-bin-path saves me the trouble of hunting down where the binary got compiled to.

Note: for clarity, I changed the executableTarget in Package.swift from Hello World to hello_world for this post, but you can set executableTarget to anything.

manifesting my app

The one special file we'll need is the Info.plist manifest. This contains a bunch of metadata for the app, and it's just a specially formatted XML file, like this:

<?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>CFBundleExecutable</key>
  <string>hello_world</string>
  <key>CFBundleName</key>
  <string>Hello World</string>
</dict>
</plist>

Here CFBundleExecutable is the executableTarget from Package.swift, the binary that Swift compiles.

It's kind of annoying to create and edit this directly, and macOS comes with nice utility for generating these from JSON, plutil. So what I did instead was to first create an Info.json file:

{
  "CFBundleName": "Hello World",
  "CFBundleExecutable": "hello_world"
}

And then run the following to generate the Info.plist:

plutil -convert xml1 \
	-o HelloWorld.app/Contents/Info.plist \
	Info.json

One thing that took a while to figure out was that the Info.plist location differs for macOS vs iOS apps - for iOS apps, Info.plist sits at the top level of the bundle folder, but for macOS apps it goes in Contents/.

app icons

This ended up being a bit of a rabbit hole because there are several ways to add an app icon. Ultimately I found that the easiest way is to simply use CFBundleIconFile, which allows you to use a regular PNG file as your icon. From the docs:

CFBundleIconFile identifies the file containing the icon for the bundle. The filename you specify does not need to include the extension, although it may. The system looks for the icon file in the main resources directory of the bundle.

The main resources directory is $BUNDLE_DIR/Contents/Resources/, so I copied my AppIcon.png into that folder and then updated my Info.json file with CFBundleIconFile

{
  "CFBundleName": "Hello World",
  "CFBundleExecutable": "hello_world",
  "CFBundleIconFile": "AppIcon"
}

One cool thing I found was that Xcode now comes with Icon Composer, and it makes it super easy to give your app icon a standard looking frame. Just make sure to export for the "macOS pre-Tahoe" platform if you're still holding out on upgrading like me. That will give you a correctly sized 1024x1024 png.

wrapping it all up

So my app now looks like this:

HelloWorld.app
    └── Contents
        ├── MacOS
        │   └── hello_world
        ├── Resources
        │   └── AppIcon.png
        └── Info.plist

Not only does it now have an icon, but we can remove the calls to NSApp.setActivationPolicy and NSApp.activate because macOS understands it to be a regular app now. We can move HelloWorld.app into Applications/ and we open it easily from spotlight or mission control, and we still haven't needed to use Xcode!