Understanding a few concepts of macOS applications by building an agent-based (menu bar) app Sep 3 2019

With advances in the frameworks and tools we use to develop software, creating a new app seems like magic. We just click a few buttons, and everything is created for us. I enjoy magic, but I think that sometimes we end up being Framework “users” without any real understanding of what is happening. In this post, I’ll explain a few concepts of how macOS apps work, so hopefully, we understand the ecosystem better.

We won’t use Xcode, so grab your favourite text editor and let’s get started. There is a lot to cover, and we’ll go fast. If you have any questions, you can send me a message in Reddit /u/rcderik, twitter @rderik or email.

You can download the code from the GitHub repository.

Agent-based applications

You might be familiar with agent-based applications, but I’ll explain what they are so we start with the same base. Agent-based applications are applications that provide services but not an extensive GUI(Graphical User Interface), the interface they provide usually is an icon on the main toolbar with a contextual menu. A famous example is the Dropbox app, it just stands in your toolbar where you can ask for synchronization, status and change configuration.

Our app will be simple, it will only keep track of events. It will show a counter and the option to increase and decrease the count. It is simple, so we don’t get distracted by its workings, and we can focus on learning how macOS apps work.

We are going to use the Swift Package Manager to generate the basic app, and we go from there.

Creating an executable using the Swift Package Manager

We are going to name our app Squirrel (If you’ve read my blog, you know about my trouble with naming).

1
2
3
$ mkdir ~/Desktop/Squirrel
$ cd ~/Desktop/Squirrel
$ swift package init --type executable

This creates the structure of a Swift executable, we can run it and see the “Hello, world!” message.

1
2
3
4
$ swift run
# it'll build our executable and run it.
#[2/2] Linking ./.build/x86_64-apple-macosx/debug/Squirrel
#Hello, world!

Let’s see what was created for us.

Swift package structure

When we ran the init command, it built a file structure that looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
.
├── Package.swift                   # specifies our package targets, dependencies and metadata 
├── README.md                       # Just a README where you can explain what your package is about
├── Sources                         # All of our source code goes here
│   └── Squirrel
│       └── main.swift              # Our entry point, the main code file
└── Tests                           # Tests structure that we'll ignore for now.
    ├── LinuxMain.swift
    └── SquirrelTests
        ├── SquirrelTests.swift
        └── XCTestManifests.swift

4 directories, 6 files

As you can see, our entry point is our main.swift. If you open it, you’ll see the print statement for “Hello, world!”.

When we compile the code, it generates all the output into the .build directory. What we are interested in is the executable that will be located in:

1
./.build/x86_64-apple-macosx/debug/Squirrel

We can use the swift run command or just run it directly:

1
2
$ ./.build/x86_64-apple-macosx/debug/Squirrel
# Hello, world!

Ok, that should be enough so you can start using the SPM (Swift Package Manager) to create executables. We will now move to creating our macOS application.

Basic macOS App

We are going to create an agent-based application, they are handy and don’t require a complex GUI. The workflow is simple, it should display a counter and a contextual menu to increase or decrease that count.

We are used to launching Xcode and create a new project, but here we’ll just work from the command line. We are going as simple as we can.

What is the bare minimum?

The convenience of using Xcode has made us a little bit lazy, we don’t have to think about the bootstrapping process that our app goes through. We just know that somehow if we hit play, the app will run. We don’t know where everything starts. In our case, we already have that! we already have our main.swift file. We were able to start a program from there, even if it was a simple one.

Now we need to start the life cycle of our app, so we know that it’ll involve the AppDelegate.

Lifecycle of a macOS app

In macOS as in iOS, the life cycle of our apps is handled by the AppDelegate. The AppDelegate is in the one notified when the app finished launching, or when it’s going to the background, etcetera. So we are sure we’ll need an AppDelagete.

So let’s create our AppDelegate. Create the file AppDelegate.swift inside your Sources/Squirrel/ folder. And let’s add the following content:

1
2
3
4
5
6
7
8
import Cocoa

class AppDelegate: NSObject, NSApplicationDelegate {

  func applicationDidFinishLaunching(_ aNotification: Notification) {
    print("Welcome to Squirrel App!")
  }
}

That should be simple enough, but how we run it?

If you check any app you’ve created with Xcode, you’ll notice that the AppDelegate includes an interesting annotation, @NSApplicationMain. That should give us a hint.

What is the @NSApplicationMain annotation?

The @NSApplicationMain gets replaced with the boilerplate required to start the app and create the RunLoop for our app. A RunLoop is a cycle that “endlessly” runs (until the app is stopped that is) handling inputs and is where the actions of our application take place. For example, if our app were a simple REPL(Run Eval Print Loop), we would need to have a loop that prompts the user for input (maybe displaying a question) and then waits for the user’s answer and then executes the action and goes back to prompting the user for more information. That simple loop can be a while or any other loop we know of. It is similar for our more macOS applications, but more complex so it can respond to user clicks, taps, changes in temperature, changes in the filesystem or any other input our application takes, but the idea is still the same.

Now that we know that our app needs a RunLoop, we are going to start our own RunLoop by calling the NSApplicationMain function.

Edit the main.swift file and replace its content with the following:

1
2
3
4
5
6
7
import Foundation
import Cocoa

let delegate = AppDelegate()
NSApplication.shared.delegate = delegate

_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)

That should be enough to run our app. let’s see if it works!

1
$ swift run

Oh, we got an error:

1
Squirrel[9045:2534023] No Info.plist file in application bundle or no NSPrincipalClass in the Info.plist file, exiting

Remember that Info.plist file Xcode generates for you? Ok, we’ll need to create one for our app.

Application Property list (Plist)

Let’s create a directory for our support files.

1
$ mkdir SupportFiles

And Inside we can create our Info.plist with the following Content:

1
2
3
4
5
6
7
8
<?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>NSPrincipalClass</key>
    <string>NSApplication</string>
</dict>
</plist>

Now we can copy that to our .build directory.

1
$ cp SupportFiles/Info.plist .build/x86_64-apple-macosx/debug/

And now we can run our app.

1
2
$ swift run
# Welcome to Squirrel App!

Our welcome message will be displayed, and because we are still on the RunLoop, our app will stay active in the foreground. To stop it we can press [Ctrl-c](Control plus c).

Congratulations, our app is working. Now its time we set the icon in the menu and the rest of the application logic.

Showing our app in the menu bar

Our app is simple, so we are going to do all our work on AppDelegate. We are going to create three functions, increaseCounter, decreaseCounter and quit. And we are going to link those to items in our menu. Your AppDelegate.swift should look like the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import Cocoa

class AppDelegate: NSObject, NSApplicationDelegate {

  var statusBarItem: NSStatusItem!

  func applicationDidFinishLaunching(_ aNotification: Notification) {
    let statusBar = NSStatusBar.system
    statusBarItem = statusBar.statusItem(
      withLength: NSStatusItem.variableLength)
    statusBarItem.button?.title = "🌰"

    let statusBarMenu = NSMenu(title: "Counter Bar Menu")
    statusBarItem.menu = statusBarMenu

    statusBarMenu.addItem(
      withTitle: "Increase",
      action: #selector(AppDelegate.increaseCount),
      keyEquivalent: "")

    statusBarMenu.addItem(
      withTitle: "Decrease",
      action: #selector(AppDelegate.decreaseCount),
      keyEquivalent: "")
    statusBarMenu.addItem(
      withTitle: "Quit",
      action: #selector(AppDelegate.quit),
      keyEquivalent: "")
  }

  @objc func increaseCount() {
    print("Increasing")
  }


  @objc func decreaseCount() {
    print("Decreasing")
  }

  @objc func quit() {
    NSApplication.shared.terminate(self)
  }
}

Now we can run (which will build too) our application and see it working.

1
$ swift run

Now check the status bar. If you can’t see our icon, maybe it’s because some of your current menu items are covering it. Switch to Finder, and you’ll see a nut icon on your toolbar if you click it you’ll be able to interact with our Application.

Nice, but it is not counting. Let’s fix that. We’ll add a counter variable to keep track of the count and then we’ll update the button title to display the count. The code is not complicated, so here is the whole AppDelegate with the changes.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import Cocoa

class AppDelegate: NSObject, NSApplicationDelegate {

  var statusBarItem: NSStatusItem!
  var counter: Int = 0

  func applicationDidFinishLaunching(_ aNotification: Notification) {
    let statusBar = NSStatusBar.system
    statusBarItem = statusBar.statusItem(
      withLength: NSStatusItem.variableLength)
    statusBarItem.button?.title = "🌰 \(counter)"

    let statusBarMenu = NSMenu(title: "Counter Bar Menu")
    statusBarItem.menu = statusBarMenu

    statusBarMenu.addItem(
      withTitle: "Increase",
      action: #selector(AppDelegate.increaseCount),
      keyEquivalent: "")

    statusBarMenu.addItem(
      withTitle: "Decrease",
      action: #selector(AppDelegate.decreaseCount),
      keyEquivalent: "")
    statusBarMenu.addItem(
      withTitle: "Quit",
      action: #selector(AppDelegate.quit),
      keyEquivalent: "")
  }

  @objc func increaseCount() {
    counter += 1
    statusBarItem.button?.title = "🌰 \(counter)"
  }


  @objc func decreaseCount() {
    counter -= 1
    statusBarItem.button?.title = "🌰 \(counter)"
  }

  @objc func quit() {
    NSApplication.shared.terminate(self)
  }
}

Run it:

1
$ swift run

And now you should see the count next to our icon. That’s cool! But that’s not a macOS application. We won’t tell our users: “hey, you need to install developer tools run swift run wait for it to build, and you’ll see it.”

Let’s create an App with our build manually. Before we create the app, we are going to have a look at a concept in macOS that is key to how applications and many other elements of the system work.

macOS Bundle files

When you see an application, a plugin, even an Xcode project, using the Finder you see it as a single element. In reality, all those elements are directories with specific structures. The Finder knows to display those elements as a single Icon. The Finder uses the name of the directory to identify the type of package. For example, if the package has a .app extension the Finder will assume its an Application. The packages are called bundles. Many services in macOS (and iOS) know how to interact with these bundles, but sometimes they need additional information to run them successfully. The most common way to store and access the bundle’s information is through PList(Property List) files.

The bundle schema is a very clever one, it keeps everything contained and on its own space.

Let’s have a look at application bundles in macOS.

Application bundles

In macOS, if you cd (change directory) to any of the applications installed in your /Applications folder you’ll be able to see the structure. For example, let’s look at the Chess.app file structure:

1
2
3
4
5
6
7
8
Chess.app
└── Contents
    ├── Info.plist
    ├── MacOS
    ├── PkgInfo
    ├── Resources
    ├── _CodeSignature
    └── version.plist

As you can see, there are many files and resources inside the App. We want the simplest structure. Let’s find out what would be the minimum to create our app.

The bare minimum

The minimum required will be this file structure:

1
2
3
4
Squirrel.app
└── Contents
    ├── Info.plist
    └── MacOS

Our executable will be inside Contents/MacOS, and that’s it. We already have the Info.plist that we created before. Let’s create the app manually with our latest build.

1
2
3
4
5
6
# First, let's create the containing directory
$ mkdir -p Squirrel.app/Contents/MacOS
# Now let's coppy our plist file
$ cp SupportFiles/Info.plist Squirrel.app/Contents/
# Copy our executable to the MacOS folder
$ cp .build/x86_64-apple-macosx/debug/Squirrel Squirrel.app/Contents/MacOS/

Now we can run our application and see if everything is working. Let’s first run it using the Finder.

1
2
# you can open the current directory in Finder by using the open command
$ open .

You’ll see our app Squirrel there, you can click it, and it’ll work :). You can skip opening the Finder and use the open command to open our app directly.

1
$ open Squirrel.app

And it’ll run our app correctly :).

But if you switch tasks or check the Dock you’ll see the Squirrel‘s generic icon there, we don’t want that so let’s fix that.

Agent

We planned to create an Agent. Agents don’t show as Icons in the Dock or in the task switcher. Fixing this is simple, we only need to tell the launch system that our application is an Agent and we do that by setting the property in our Info.plist. You can see how convenient having our application’s information in Plist files is.

Add these two lines to your SupportFiles/Info.plist

1
2
    <key>LSUIElement</key>
    <true/>

Our full Info.plist should look like this:

1
2
3
4
5
6
7
8
9
10
<?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>NSPrincipalClass</key>
    <string>NSApplication</string>
    <key>LSUIElement</key>
    <true/>
</dict>
</plist>

Copy it again to our Squirrel.app/Contents/.

1
$ cp SupportFiles/Info.plist Squirrel.app/Contents/

And rerun your app. Now, your app will function like a real Agent and not show any Icon on the task switcher or Dock.

Good, but manually creating the application is error-prone and repetitive. We should automate the process of building the App. You can create a script in any of the languages you want to automate these steps. I’ll show you a basic Makefile to automate the process.

Makefile

create a Makefile in your project’s directory, with the following content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
SUPPORTFILES=./SupportFiles/
PLATFORM=x86_64-apple-macosx
BUILD_DIRECTORY = ./.build/${PLATFORM}/debug
APP_DIRECTORY=./Squirrel.app
CFBUNDLEEXECUTABLE=Squirrel

install: build copySupportFiles

build:
    swift build

copySupportFiles:
    mkdir -p ${APP_DIRECTORY}/Contents/MacOS/
    cp ${SUPPORTFILES}/Info.plist ${APP_DIRECTORY}/Contents
    cp ${BUILD_DIRECTORY}/${CFBUNDLEEXECUTABLE} ${APP_DIRECTORY}/Contents/MacOS/

run: build
    open -a ${APP_DIRECTORY}

clean:
    rm -rf .build
    rm -rf ${APP_DIRECTORY}

.PHONY: run build copySupportFiles clean

Now you can run:

1
2
3
4
# clean anything we created
$ make clean
# Build and create our application
$ make install

Congratulations we created an Agent app!

Final Thoughts

We’ve learned a lot:

I hope you find this useful. There is a lot more to building a production-ready app, especially if you need to notarize it. But a lot of things should be more evident now.

Understanding how things work is a good path to creative solutions and innovation. If our knowledge is superficial and we don’t really understand how things work, it’ll be hard to know what is possible or not.

Ok, that’s it for today, as always feedback is welcome. Let me know if you have any questions, tips or anything interesting you’ve found.

Related topics/notes of interest


** There is no comment system yet, but you can send me a message on twitter @rderik or send me an email: derik[at]rderik[dot]com.