Skip to content
C.W.K.
Stream
Lesson 02 of 06 · published

Where a Mac App Begins, and How It Ends

~16 min · appkit-swiftui, lifecycle, nsapplication, open-urls, termination

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"Reveal worked every time the app was already running. On a cold launch it silently did nothing."

Two Ways In

A SwiftUI app starts at a @main struct … : App; when it needs AppKit callbacks it attaches a delegate with @NSApplicationDelegateAdaptor. An AppKit-owned app starts at a @main type whose static func main() creates NSApplication.shared, installs a delegate, sets the activation policy and calls run(). Several family Mac apps start the second way, because it makes launch order explicit.

The activation policy decides how the app presents itself: .regular (Dock icon, menu bar, can be frontmost), .accessory (no Dock icon, can still show windows — the menu-bar utility shape), or .prohibited. Setting LSUIElement in Info.plist gives an app accessory behaviour from the first instant, before any code runs.

Launch Is a Race You Did Not Schedule

A file workbench accepts "reveal this file" requests from other programs through application(_:open:). It worked perfectly when the app was running, and did nothing on a cold launch. Two independent races were involved, and fixing only one left the bug:

  1. The open event can be delivered before applicationDidFinishLaunching has built the window controller, so an optional-chained call on a controller that does not exist yet is a no-op.
  2. Any asynchronous session restore that completes afterwards overwrites the revealed location with the restored one.

The fix parks every incoming URL and drains the queue only when both conditions hold: the controller exists and the restore has finished — and the restore must signal completion on its empty and error paths too, or a request waits forever. Test cold and warm launches separately; a warm pass proves nothing about launch ordering.

Ending Without Losing the Session

A terminal with native window tabs saved its tab layout whenever a window closed. On quit, applicationShouldTerminate runs while every window is still open; then the windows close one by one, each close saving a snapshot with one fewer window, and the last save wrote an empty session. The fix takes the snapshot in applicationShouldTerminate, sets a terminating flag, and turns later per-window saves into no-ops.

One more termination fact matters for fleet tooling: a document app must be quit politely (an Apple Event, or the Quit menu) rather than killed. applicationShouldTerminate — where unsaved documents are handled — never runs on SIGTERM.

Code

An AppKit-owned app: explicit main, parked open requests, one terminate snapshot·swift
import AppKit
import SwiftUI

@main
@MainActor
final class SparkAppDelegate: NSObject, NSApplicationDelegate {
    private var windowController: NSWindowController?
    private var pendingURLs: [URL] = []
    private var restoreFinished = false
    private var terminating = false

    static func main() {
        let app = NSApplication.shared
        let delegate = SparkAppDelegate()
        app.delegate = delegate
        app.setActivationPolicy(.regular)
        app.run()
    }

    func applicationDidFinishLaunching(_ notification: Notification) {
        let hosting = NSHostingController(rootView: Text("Settings live in SwiftUI"))
        let window = NSWindow(contentViewController: hosting)
        windowController = NSWindowController(window: window)
        windowController?.showWindow(nil)
        Task { await restoreSession() }
    }

    func application(_ application: NSApplication, open urls: [URL]) {
        pendingURLs.append(contentsOf: urls)   // may arrive before the controller or the restore
        drainPendingURLs()
    }

    private func restoreSession() async {
        // load the last workspace; reach this line on success, empty result AND error
        restoreFinished = true
        drainPendingURLs()
    }

    private func drainPendingURLs() {
        guard windowController != nil, restoreFinished else { return }
        let urls = pendingURLs
        pendingURLs.removeAll()
        for url in urls { windowController?.window?.title = url.lastPathComponent }
    }

    func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
        saveSnapshot()                 // every window is still open here
        terminating = true             // later per-window saves become no-ops
        return .terminateNow
    }

    func windowWillCloseSavingState() {
        guard !terminating else { return }
        saveSnapshot()
    }

    private func saveSnapshot() { /* write the session */ }
}

External links

Exercise

Give Spark's Mac app a URL scheme or document type and implement application(_:open:) with the parking queue from the code block. Add a deliberate one-second delay to restoreSession. Quit the app completely and open a URL from Terminal with open 'spark://reveal?item=42'; confirm the request is revealed after the restore. Then remove the parking and watch the cold-launch case fail while the warm case still works.
Hint
Registering a URL scheme needs CFBundleURLTypes in the bundle's Info.plist, so this exercise needs the packaged .app from Track 6 — if you are not there yet, simulate the race by calling the handler from main() before run().

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.