macOS-specific development patterns, platform APIs, and decision frameworks. Use when developing Mac apps, macOS applications, Cocoa/AppKit code, or making SwiftUI vs AppKit decisions. Covers NSWindow management, NSDocument architecture, sandboxing, code signing, notarization, and macOS UI patterns. Applies to Mac Catalyst considerations.
<skill_scope skill="macos-programmer"> Related skills:
swift-programmer — Swift language fundamentals and Swift 6 concurrencysoftware-engineer — General software engineering principles and system architecturetest-driven-development — Testing philosophy and practicesThis skill covers macOS-specific development patterns, platform APIs, and decision frameworks. It applies when developing Mac apps, working with Cocoa/AppKit code, or making SwiftUI vs AppKit decisions. </skill_scope>
<core_philosophy>
Hybrid by default. Production Mac apps commonly combine SwiftUI and AppKit: SwiftUI for most view content, AppKit where the platform's window, text, and event systems are exposed only there. Where the line falls varies by app; the case studies in <swiftui_vs_appkit_decision> show teams landing at different points, and each macOS release moves some formerly AppKit-only capability into SwiftUI (see <recent_changes>). Treat AppKit fallback as expected work rather than a failure of SwiftUI, and re-check the boundary when the deployment target moves.
Platform Identity: macOS is not iOS with a bigger screen. Multiple windows, menu bars, keyboard navigation, document-based architecture, and precise window management are first-class citizens. Respect macOS conventions; don't port iOS patterns blindly. </core_philosophy>
<swiftui_vs_appkit_decision> The Ground Truth from Production Apps:
SwiftUI maturity differs between iOS and macOS. Two 2023 accounts from Mac apps in development show the shape of the gap. Ghostty (then in private beta) rewrote its SwiftUI app and window lifecycle management in AppKit (+802/-239 lines) when non-native fullscreen, which requires subclassing NSWindow, proved impossible in pure SwiftUI; its views stayed SwiftUI.[^ghostty-devlog] Multi.app moved to SwiftUI but still needed "some access to NSEvents, text input, and tweaking the first responder that just aren't possible with pure SwiftUI," and wrote that SwiftUI bugs on older macOS left them "approaching the cusp of dropping support entirely" for those versions.[^multi-swiftui] Both are dated; re-read the boundary against the current release before applying them.
Use SwiftUI When:
NSViewRepresentable where a control or behavior isn't available in SwiftUIUse AppKit When:
List falling behind NSTableView; measure on the current OS, since a macOS 26 change to NavigationLink "improves performance of many NavigationLinks in lazy containers like List"[^macos26-notes]The Hybrid Shape (Common in Production):
Ghostty's arrangement after its rewrite: AppKit owns the app and window lifecycle and SwiftUI supplies the views.[^ghostty-devlog] The bridging mechanisms are NSHostingController (SwiftUI inside AppKit) and NSViewRepresentable (AppKit inside SwiftUI).
<swiftui_limitations> SwiftUI Limitations on macOS (as of macOS 26; re-check each release):
NSEvent, first-responder manipulation, and some text-input behavior is AppKit-only[^multi-swiftui]List performance with large data sets can lag NSTableView; the gap is workload-dependent and narrowed in macOS 26, so measure rather than assumeCapabilities that have moved into SwiftUI recently, such as styled text editing with AttributedString and Find Bar control in TextEditor on macOS 26, are listed in <recent_changes>.
</swiftui_limitations>
Decision Pattern:
Production Mac App
├── AppKit: NSApplication, NSWindow, NSWindowController, NSDocument
├── SwiftUI: View content where appropriate
└── Bridge: NSHostingController, NSViewRepresentable
</swiftui_vs_appkit_decision>
<platform_differences>
Coordinate Systems:
NSView) origin: bottom-left, Y increases upwardisFlipped to return true for iOS-style coordinatesLayer Backing:
wantsLayer = trueWindows vs Views:
Text System:
NSTextView included, in macOS 13;[^wwdc22-10090] touching textView.layoutManager switches that view to TextKit 1 compatibility mode ("if you explicitly call the layoutManager property on a text view or text container, the framework reverts to a compatibility mode")[^textkit-compat]NSLayoutManager during a migration), select it when creating the view rather than by triggering the fallback later, which discards the TextKit 2 layout; macOS 26 continues to extend TextKit 2 (e.g., includesTextListMarkers)[^macos26-notes]Background Colors:
drawsBackground propertybackgroundColor like iOSMouse vs Touch:
updateTrackingAreas()); SwiftUI views use .onHoverNSEvent provides precise cursor position and modifier keys
</platform_differences><window_management>
NSWindow Lifecycle (10.13 SDK Change):
isReleasedWhenClosed defaults to true for NSWindow (false for NSPanel) and "is ignored for windows owned by window controllers";[^released-when-closed] for a window your own code owns and references, set it to false, or closing the window over-releases it under ARC.
AppKit's release notes state the current rule: "If your application is linked on macOS 10.13 SDK or later, NSWindows that are ordered-in will be strongly referenced by AppKit, until they are explicitly ordered-out or closed."[^appkit-rn-window] The condition is the SDK the app is linked against, not its deployment target; an app built against an older SDK keeps the old behavior even when running on newer macOS.
Window Style and Collection Behavior:
Style masks combine but have limitations:
canBecomeKey or canBecomeMain is true" (subclass and override)[^stylemask-borderless]fullSizeContentView "opts in to layer-backing"[^stylemask-fullsize]Collection behavior controls Spaces/Exposé/fullscreen:
.canJoinAllSpaces: Visible on all spaces (like menu bar).moveToActiveSpace: "When the window becomes active, move it to the active space instead of switching spaces"[^collection-behavior].fullScreenPrimary: Can be fullscreen window.fullScreenAuxiliary: Shown with fullscreen window.stationary: Unaffected by Exposé, visible on desktopPattern for an overlay that appears on every space:
window.collectionBehavior = [.canJoinAllSpaces, .stationary]
Collection behavior governs Spaces and Exposé membership, not stacking order or fullscreen coexistence; set the window level separately and test with a fullscreen app and Stage Manager before relying on it.
Multi-Window Document Architecture:
NSDocumentController (singleton)
↓ manages
NSDocument instances (one per document)
↓ manages
NSWindowController instances (one per window)
Modern Document Best Practice:
override class var autosavesInPlace: Bool { true }
Enables autosave in place and the system's version browsing and storage. Asynchronous saving is a separate opt-in (canAsynchronouslyWrite(to:ofType:for:)), and the document must still unblock user interaction itself.
</window_management>
<responder_chain>
The Complete Action Message Responder Chain:
nextResponder in that chain, then the key window itselfNSDocument (if different from the delegate)NSApplication tries to respondNSApplication.delegateNSDocumentController (which does not inherit from NSResponder)[^event-architecture]Critical Insight: App delegate is NOT part of nextResponder chain—you can never reach it through iteration. It's used as a fallback when current key window's responder chain returns nil.
NSViewController Integration (macOS 10.10+):
Before 10.10, an NSViewController was not in the responder chain by default; code patched nextResponder by hand. From 10.10, AppKit inserts the view controller into the chain immediately after its view: "The view's nextResponder is then set to be the viewController, and viewController's nextResponder is set to be the previously saved nextResponder."[^appkit-rn-1010]
Menu Validation Performance:
NSMenu updates EVERY menu item on EVERY user event (mouse move, keypress). This is a performance killer for large menus.
How it works:
validateMenuItem: or validateUserInterfaceItem:, call it and use return valueOptimization:
menu.autoenablesItems = falsemenuItem.isEnablednil target routes the action and the validation query through the responder chain; set an explicit target only when you want to bypass that lookup
</responder_chain><swiftui_appkit_integration>
NSHostingController (Essential Bridge Pattern):
// Embedding SwiftUI in AppKit
let swiftUIView = MySwiftUIView()
let hostingController = NSHostingController(rootView: swiftUIView)
// macOS 13+ sizing control
hostingController.sizingOptions = [.intrinsicContentSize]
NSViewRepresentable:
updateNSView runs whenever SwiftUI updates this represented view, so guard assignments whose setter has side effects: assigning NSTextView.string resets the selection (observed on macOS 26; it does not post textDidChange). Propagate edits back to the binding through a Coordinator, or the bridge is one-way.
struct TextViewRepresentable: NSViewRepresentable {
@Binding var text: String
func makeCoordinator() -> Coordinator { Coordinator(text: $text) }
func makeNSView(context: Context) -> NSTextView {
let view = NSTextView()
view.delegate = context.coordinator
return view
}
func updateNSView(_ nsView: NSTextView, context: Context) {
context.coordinator.text = $text // keep the coordinator on the current binding
if nsView.string != text { // guard: assigning resets the selection
nsView.string = text
}
}
final class Coordinator: NSObject, NSTextViewDelegate {
var text: Binding<String>
init(text: Binding<String>) { self.text = text }
func textDidChange(_ notification: Notification) {
guard let view = notification.object as? NSTextView else { return }
text.wrappedValue = view.string // edits flow back to SwiftUI
}
}
}
State Management with @MainActor @Observable (macOS 14+):
Gotcha, toolchain-dependent: built with Xcode 26 or earlier, "A State property always instantiates its default value when SwiftUI instantiates the view," so Apple's guidance is to "avoid side effects and performance-intensive work when initializing the default value";[^swiftui-state] an @Observable model declared as @State in a frequently re-instantiated view is allocated on each instantiation. Built with Xcode 27 (beta 6 as of September 2026), @State is a macro and "objects held in state are only ever initialized one time, when the view is first created," which removes the cost but also rejects some initializer patterns that used to compile; read TN3211 before migrating.[^tn3211] For SwiftUI view state, keep the observable type on the main actor; see swift-programmer for the general @MainActor @Observable rule.
Solution for app-wide state: declare the main-actor observable model in the App struct, which SwiftUI instantiates once. Apple's alternative for a view-local model is to create it in a .task modifier, "which is called only once when the view first appears";[^swiftui-state] that is once per appearance of a given identity: it runs again if the view disappears and reappears, or if its identity changes, so guard the creation if it must happen once per state lifetime.
@MainActor
@Observable
class AppModel {
// App state and actions.
}
@main
struct MyApp: App {
@State private var appModel = AppModel() // Declare here
var body: some Scene {
WindowGroup {
ContentView().environment(appModel)
}
}
}
Multi-Window Management:
// WindowGroup - Multiple instances
WindowGroup { ContentView() }
// Window - Single unique instance
Window("Stats", id: "stats") { StatsView() }
// UtilityWindow (macOS 15+) - Floating palette
UtilityWindow("Palette", id: "palette") { PaletteView() }
.keyboardShortcut("u")
Menu Bar & Commands:
.commands {
CommandMenu("Custom") {
Button("Action") {}
.keyboardShortcut("x", modifiers: [.command, .shift])
}
}
// Focus values for multi-window menus
@FocusedValue(\.messageState) var messageState
</swiftui_appkit_integration>
Sandboxing Strategy:
Access Methods:
~/Library/Containers/{bundle-id}Security-Scoped Bookmarks (Critical Pattern):
// Save bookmark
let bookmarkData = try url.bookmarkData(options: .withSecurityScope)
// Restore and use
var isStale = false
let url = try URL(resolvingBookmarkData: bookmarkData,
options: .withSecurityScope,
bookmarkDataIsStale: &isStale)
guard url.startAccessingSecurityScopedResource() else {
throw CocoaError(.fileReadNoPermission)
}
defer { url.stopAccessingSecurityScopedResource() }
if isStale {
// Re-create and re-save the bookmark while access is active; creating one needs access to the file.
let fresh = try url.bookmarkData(options: .withSecurityScope)
save(fresh)
}
// Access file
Rules:
startAccessingSecurityScopedResource() on the resolved URL, not the originalNSOpenPanel/NSSavePanel URLs; the system starts access on those for youEntitlements to Know:
com.apple.security.app-sandbox: Enable App Sandboxcom.apple.security.files.user-selected.read-write: User-selected filescom.apple.security.files.bookmarks.app-scope: App-scoped bookmarkscom.apple.security.network.client: Outgoing network connectionscom.apple.security.network.server: Incoming network connections
</sandboxing>
<code_signing>
Process (checked against Apple's notarization documentation, September 2026):
--deep:codesign --force --options runtime --timestamp \
--entitlements App.entitlements \
--sign "Developer ID Application: Your Name (TEAMID)" \
App.app
codesign --verify --deep --strict --verbose=2 App.app # --deep is for verification
ditto -c -k --keepParent App.app App.zip
notarytool store-credentials:xcrun notarytool submit App.zip --keychain-profile "notarytool-password" --wait
stapler against each item that you added to the archive. Then create a new ZIP file containing the stapled items for distribution."[^notarization-workflow]xcrun stapler staple App.app
xcrun stapler validate App.app
ditto -c -k --keepParent App.app App.zip # the distributed archive must contain the stapled app
Rules:
--deep (man codesign marks it "DEPRECATED for signing as of macOS 13.0"), because it applies the outer entitlements and flags to nested code. --deep remains the right flag for --verify.notarytool replaced altool, which Apple stopped accepting on November 1, 2023; the @keychain: password syntax was altool's, and notarytool uses --keychain-profile or a literal --password.Common Failures:
--options runtime<architecture_patterns>
For general architecture philosophy, see the software-engineer skill. This section covers macOS-specific architectural considerations.
Primary Patterns (Choose One):
What it is:
When to use:
Reality check:
What it is:
When to use:
Reality check:
Pattern:
@MainActor
@Observable
class User {
var name: String = ""
var email: String = ""
func save() {
// Business logic here
}
}
struct ProfileView: View {
@State private var user = User()
var body: some View {
Form {
TextField("Name", text: $user.name)
Button("Save") { user.save() }
}
}
}
What it is:
When to use:
Reality check:
What it is:
When to use:
Reality check:
NavigationStack with a NavigationPath held in an observable model), with navigation policy still yours to writeSearch for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer