
Axiom Macos
- 621 installs
- 1.1k repo stars
- Updated August 3, 2026
- charleswiltgen/axiom
axiom-macos is an agent skill that guides developers building macOS apps through windows, menus, sandboxing, notarization, AppKit bridging, and macOS-specific SwiftUI patterns.
About
axiom-macos is an MIT-licensed agent skill from charleswiltgen/axiom required for any macOS-specific development. It maps symptoms to focused references: WindowGroup and MenuBarExtra window management, menu bars with commands and keyboard shortcuts, sandbox entitlements and distribution, AppKit bridging, and SwiftUI differences such as NavigationSplitView, Table, and Inspector layouts. Use it when a SwiftUI or AppKit Mac app needs correct window chrome, menu command wiring, sandbox compliance, or notarization steps that differ from iOS patterns. The skill ships linked topic guides including windows.md, menus-and-commands.md, and swiftui-differences.md so agents route to the right macOS platform detail quickly.
- Mandatory use for ANY macOS-specific development per skill HARD guidance
- Quick-reference routes to windows, menus, sandbox, direct distribution, ScreenCaptureKit
- Covers WindowGroup, MenuBarExtra, security-scoped bookmarks, Developer ID and notarization
- AppKit interop via NSViewRepresentable and NSHostingController patterns
- Cross-suite pointers to axiom-swiftui and axiom-payments for shared UI and Apple Pay on Mac
Axiom Macos by the numbers
- 621 all-time installs (skills.sh)
- Ranked #276 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-macosAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 621 |
|---|---|
| repo stars | ★ 1.1k |
| Security audit | 3 / 3 scanners passed |
| Last updated | August 3, 2026 |
| Repository | charleswiltgen/axiom ↗ |
How do you build macOS apps with SwiftUI and AppKit?
Install this whenever you or your agent build a macOS app—windows, menus, sandbox, notarization, AppKit bridging, and macOS-specific SwiftUI.
Who is it for?
Swift developers shipping native macOS apps who need window management, menus, sandboxing, or AppKit bridging beyond iOS SwiftUI defaults.
Skip if: iOS-only or cross-platform web apps with no macOS-native window, menu, or sandbox requirements.
When should I use this skill?
A developer builds a Mac app involving WindowGroup, menu commands, sandbox entitlements, notarization, or macOS SwiftUI layout differences.
What you get
Window and menu implementations, sandbox entitlements, AppKit bridge code, and macOS SwiftUI layout patterns
- Window and menu code
- Sandbox entitlement plist
- AppKit bridge implementations
By the numbers
- Links dedicated reference guides including windows.md, menus-and-commands.md, and swiftui-differences.md
Files
macOS Development
You MUST use this skill for ANY macOS-specific development including windows, menus, sandboxing, distribution, AppKit bridging, and macOS SwiftUI differences.
Quick Reference
| Symptom / Task | Reference |
|---|---|
| Window management (WindowGroup, Window, MenuBarExtra, DocumentGroup) | See skills/windows.md |
| Menu bar, commands, keyboard shortcuts | See skills/menus-and-commands.md |
| Table, Inspector, NavigationSplitView, focus | See skills/swiftui-differences.md |
| App Sandbox, file access, security-scoped bookmarks | See skills/sandbox-and-file-access.md |
| Developer ID, notarization, Sparkle auto-updates | See skills/direct-distribution.md |
| NSViewRepresentable, NSHostingController, AppKit bridging, @Observable in AppKit, NSHostingMenu, SwiftUI scenes from AppKit | See skills/appkit-interop.md |
Modernizing AppKit: mouseDown replacement, control events, status-item sessions, state restoration, concentric corners, touch (OS27) | See skills/appkit-modernization.md |
| Screen recording, sharing, or capture (ScreenCaptureKit) | See skills/screencapturekit.md |
| SCStream / SCContentFilter / screenshots / file recording API | See skills/screencapturekit-ref.md |
| Apple Pay on Mac / Catalyst | See axiom-payments/skills/apple-pay.md (Catalyst section) |
Cross-Suite Routes
These topics overlap with macOS development but live in separate suites:
SwiftUI (shared iOS/macOS)
- View state, data flow, @Observable → See axiom-swiftui
- Navigation (NavigationStack basics) → See axiom-swiftui (skills/nav.md)
- Layout (ViewThatFits, AnyLayout) → See axiom-swiftui (skills/layout.md)
- Animations → See axiom-swiftui (skills/animation-ref.md)
Data & persistence
- SwiftData, Core Data, GRDB → See axiom-data
- CloudKit sync → See axiom-data
Concurrency
- Swift 6 concurrency, actors, Sendable → See axiom-concurrency
Other
- Accessibility (VoiceOver, Dynamic Type) → See axiom-accessibility
- Networking (URLSession, Network.framework) → See axiom-networking
- Security (Keychain, passkeys, encryption) → See axiom-security
Conflict Resolution
axiom-macos vs axiom-swiftui: When working on a macOS SwiftUI app: 1. Use axiom-macos for macOS-only concerns: windows, menus, commands, sandboxing, distribution, Table, Inspector, AppKit bridging 2. Use axiom-swiftui for cross-platform SwiftUI: navigation, layout, state management, animations 3. Both may apply: A macOS app using NavigationSplitView with Table needs axiom-macos for Table specifics and axiom-swiftui for NavigationSplitView basics
axiom-macos vs axiom-security: For sandbox and code signing: 1. Use axiom-macos for macOS App Sandbox, security-scoped bookmarks, file access entitlements, Developer ID signing 2. Use axiom-security for Keychain storage, encryption, passkeys, certificate management
Decision Tree
digraph macos {
start [label="macOS development task" shape=ellipse];
what [label="What area?" shape=diamond];
start -> what;
what -> "skills/windows.md" [label="windows/scenes"];
what -> "skills/menus-and-commands.md" [label="menus/commands/shortcuts"];
what -> "skills/swiftui-differences.md" [label="Table/Inspector/focus/macOS SwiftUI"];
what -> "skills/sandbox-and-file-access.md" [label="sandbox/file access"];
what -> "skills/direct-distribution.md" [label="distribution/notarization/updates"];
what -> "skills/appkit-interop.md" [label="AppKit bridging"];
what -> "skills/appkit-modernization.md" [label="modernize AppKit\n(input/restoration/27 look)"];
what -> "skills/screencapturekit.md" [label="screen capture/\nrecording/sharing"];
what -> "axiom-swiftui" [label="cross-platform SwiftUI"];
what -> "axiom-security" [label="Keychain/encryption"];
}iOS screen capture? ScreenCaptureKit is macOS-only. For iOS screen recording, use ReplayKit — see axiom-media.
Resources
WWDC: 2021-10062, 2022-10061, 2022-10075, 2023-10148, 2024-10149, 2026-272, 2026-289
Docs: /security/app-sandbox, /swiftui/windowgroup, /swiftui/table
Skills: axiom-swiftui, axiom-security, axiom-concurrency, axiom-uikit
macOS AppKit Interoperability
When to Use This Skill
Use when:
- Embedding an AppKit view or view controller inside SwiftUI (NSViewRepresentable, NSViewControllerRepresentable)
- Hosting SwiftUI views inside an AppKit app (NSHostingController, NSHostingView)
- Updating NSViews automatically from @Observable models (observation tracking — no SwiftUI required)
- Adding an existing NSGestureRecognizer to a SwiftUI view (NSGestureRecognizerRepresentable)
- Building main-menu items in SwiftUI (NSHostingMenu) or adding SwiftUI scenes — MenuBarExtra, Settings — to an AppKit app delegate (NSHostingSceneRepresentation)
- Menu bar commands, copy/paste, or keyboard shortcuts fail across the SwiftUI/AppKit boundary
- NSToolbar needs capabilities beyond SwiftUI's
.toolbarmodifier - File panels need options that
.fileImporterdoesn't expose - Drag and drop must cross the SwiftUI/AppKit boundary
- Responder chain or focus behavior breaks when mixing frameworks
Related Skills
- Use
axiom-uikitfor UIKit-SwiftUI bridging (same representable pattern, different types) - Use
axiom-swiftuiskills for pure SwiftUI navigation, layout, and architecture
---
Red Flags -- Anti-Patterns to Prevent
If you're doing ANY of these, STOP and use the patterns in this skill:
1. Fighting the responder chain instead of joining it
// WRONG -- manually forwarding selectors
override func keyDown(with event: NSEvent) {
swiftUIView.handleKeyDown(event) // bypasses responder chain
}Why this fails SwiftUI views participate in the AppKit responder chain automatically. When an NSHostingView is in focus, selectors travel through SwiftUI's onCommand, copyable, pasteDestination modifiers. Manually forwarding events duplicates or breaks the chain.
2. Using NSOpenPanel when fileImporter suffices
// WRONG -- unnecessary AppKit drop-down for basic file picking
let panel = NSOpenPanel()
panel.allowedContentTypes = [.png]
panel.begin { response in ... }Why this fails SwiftUI's .fileImporter(isPresented:allowedContentTypes:) handles single/multi-file picking with content types and works correctly with sandbox entitlements. Drop to NSOpenPanel only when you need canChooseDirectories, accessory views, or canDownloadUbiquitousContents.
3. Creating a new NSHostingView on every cell reuse
// WRONG -- destroys and rebuilds SwiftUI hierarchy each scroll
func collectionView(_ cv: NSCollectionView, ...) -> NSCollectionViewItem {
let item = cv.makeItem(...)
let hosting = NSHostingView(rootView: CellView(data: data))
item.view.addSubview(hosting) // new view every time
return item
}Why this fails Each NSHostingView creates a full SwiftUI view hierarchy. Rebuilding on every cell reuse causes jank during scrolling. Instead, create the hosting view once and update its rootView property.
4. Modifying frame/bounds on a hosted AppKit view
// WRONG -- conflicts with SwiftUI layout
func updateNSView(_ nsView: MyView, context: Context) {
nsView.frame = CGRect(x: 0, y: 0, width: 300, height: 200)
}Why this fails SwiftUI fully controls the layout of representable views through its own constraint system. Setting frame or bounds directly produces undefined behavior. Use SwiftUI's .frame() modifier on the representable instead.
5. Forgetting to update the coordinator in updateNSView
// WRONG -- coordinator holds stale bindings
func updateNSView(_ nsView: MyView, context: Context) {
nsView.text = text
// forgot: context.coordinator.parent = self
}Why this fails The coordinator is created once and persists for the view's lifetime. If it holds a reference to the representable (for accessing bindings), that reference must be refreshed in every updateNSView call. Stale bindings cause writes to go nowhere.
---
Direction Decision
The first question: which framework is the host, and which is the guest?
Mixing SwiftUI and AppKit?
|
+-- SwiftUI is the host, need an AppKit view inside it?
| |
| +-- Single NSView (text editor, custom control, map)
| | -> NSViewRepresentable
| |
| +-- NSViewController with lifecycle (document editor, media player)
| -> NSViewControllerRepresentable
|
+-- AppKit is the host, need SwiftUI inside it?
|
+-- Need a view controller (split view item, sheet, popover, tab)?
| -> NSHostingController
|
+-- Need a raw view (collection cell, sidebar, subview)?
-> NSHostingViewStart with SwiftUI. Only drop to AppKit when SwiftUI lacks the capability. Common reasons to cross the boundary:
| Need | SwiftUI equivalent | When AppKit is required |
|---|---|---|
| File picking | .fileImporter | Directory selection, accessory views, iCloud conflict resolution |
| Toolbar | .toolbar modifier | Item validation, custom views in toolbar, overflow behavior |
| Drag destination | .onDrop, Transferable | NSDraggingDestination for legacy pasteboard types |
| Text editing | TextEditor | NSTextView for rich text, custom input, TextKit 2 |
| Menu bar | CommandMenu, CommandGroup | Dynamic menus, NSHostingMenu, validateMenuItem |
| Responder commands | onCommand, copyable | Custom selectors not in SwiftUI's command set |
---
Automatic Observation in AppKit (@Observable, No SwiftUI Required)
AppKit observes @Observable properties accessed inside certain methods and redraws automatically — the first modernization step, before hosting any SwiftUI (WWDC 2026-272). No more manual needsDisplay = true fan-out when one model property affects several views:
@Observable @MainActor
final class ColorModel {
var hue: Double = 0.6
var saturation: Double = 1.0
var brightness: Double = 1.0
}
class HueSliderCell: NSSliderCell {
var model: ColorModel!
override func drawKnob(_ knobRect: NSRect) {
// AppKit tracks every @Observable property accessed here and
// redraws this view when any of them change
let color = NSColor(hue: model.hue, saturation: model.saturation,
brightness: model.brightness, alpha: 1)
// ... draw with color
}
}Observation-tracking methods: anything called as part of NSView.draw(_:) (incl. NSCell draw methods like drawKnob/drawBar), plus updateConstraints(), layout(), updateLayer(), and the NSViewController equivalents. UIKit's list is larger (extends to UIButton, UICollectionViewCell, …) — see axiom-uikit.
Availability: on by default for apps built against the 2026 SDKs and later. Back-deploy to macOS 15 with the NSObservationTrackingEnabled Info.plist key (iOS 18: UIObservationTrackingEnabled).
Adopting @Observable first also makes later SwiftUI adoption seamless — the same model drives NSView drawing and SwiftUI bodies.
---
SwiftUI to AppKit (NSViewRepresentable)
Use when SwiftUI needs to host an AppKit view. This is the most common bridging direction.
Lifecycle
1. makeCoordinator() -- created once, lives as long as the view 2. makeNSView(context:) -- create the AppKit view, assign coordinator as delegate 3. updateNSView(_:context:) -- called on every SwiftUI state change; keep updates minimal 4. dismantleNSView(_:coordinator:) -- optional cleanup
Canonical Example: Wrapping an AppKit Editor
struct ScriptEditorRepresentable: NSViewRepresentable {
@Binding var sourceCode: String
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
func makeNSView(context: Context) -> ScriptEditorView {
let editor = ScriptEditorView(frame: .zero)
editor.delegate = context.coordinator
return editor
}
func updateNSView(_ editor: ScriptEditorView, context: Context) {
// Guard against redundant updates
if editor.sourceCode != sourceCode {
editor.sourceCode = sourceCode
}
editor.isEditable = context.environment.isEnabled
// Keep coordinator's reference fresh
context.coordinator.parent = self
}
class Coordinator: NSObject, ScriptEditorViewDelegate {
var parent: ScriptEditorRepresentable
init(parent: ScriptEditorRepresentable) {
self.parent = parent
}
func sourceCodeDidChange(in view: ScriptEditorView) {
parent.sourceCode = view.sourceCode
}
}
}Key Rules
- Guard updates:
updateNSViewis called frequently. Compare before setting properties -- this is not just a perf nicety: blindly reassigning a text/value property (e.g.textView.string = text) on every update resets the insertion point and selection, so the caret jumps to the top while the user types. - Wrapping `NSTextView`: a bare
NSTextViewhas no enclosing scroll view, so it won't scroll or resize correctly. Create it with theNSTextView.scrollableTextView()factory (returns the configuredNSScrollView) and return that frommakeNSView; reach the text view viascrollView.documentView as? NSTextView. - Coordinator is the bridge: It conforms to AppKit delegates and writes back to SwiftUI bindings.
- Refresh the coordinator: Always update
context.coordinator.parent = self(or equivalent) inupdateNSViewso bindings stay current. - Environment propagation: Read
context.environmentvalues (likeisEnabled) inupdateNSViewand apply them to the AppKit view. - Never set frame/bounds: SwiftUI owns layout. Use
.frame()on the SwiftUI side.
---
AppKit Gestures in SwiftUI (NSGestureRecognizerRepresentable, macOS 26+)
Bring an existing NSGestureRecognizer subclass to a SwiftUI view instead of rewriting it as a SwiftUI Gesture (WWDC 2026-272):
struct ForceClickReset: NSGestureRecognizerRepresentable {
var model: ColorModel
func makeNSGestureRecognizer(context: Context) -> ForceClickGestureRecognizer {
ForceClickGestureRecognizer() // your existing recognizer subclass
}
func handleNSGestureRecognizerAction(_ recognizer: ForceClickGestureRecognizer,
context: Context) {
withAnimation {
model.saturation = 1
model.brightness = 1
}
}
}
// Attach like any SwiftUI gesture — composes with existing SwiftUI gestures
HSBColorPicker(model: model)
.gesture(ForceClickReset(model: model))The protocol is macOS-only at macOS 26; the UIKit counterpart UIGestureRecognizerRepresentable is covered in axiom-uikit.
---
AppKit to SwiftUI (NSHostingController / NSHostingView)
Use when an AppKit app needs to embed SwiftUI content.
NSHostingController -- for view controller contexts
Use with NSSplitViewController, sheets, popovers, modal windows, and tab view controllers.
// Sidebar in a split view
let sidebar = NSHostingController(rootView: SidebarView(model: selectionModel))
let item = NSSplitViewItem(viewController: sidebar)
splitViewController.addSplitViewItem(item)
// Sheet
viewController.presentAsSheet(NSHostingController(rootView: SheetContent()))
// Popover
viewController.present(
NSHostingController(rootView: PopoverContent()),
asPopoverRelativeTo: rect, of: view,
preferredEdge: .maxY, behavior: .transient
)
// Modal window
let controller = NSHostingController(rootView: ModalView())
controller.title = "Settings"
viewController.presentAsModalWindow(controller)Sizing: NSHostingController creates Auto Layout constraints from the SwiftUI view's ideal, minimum, and maximum sizes. Customize with:
controller.sizingOptions = [.minSize, .intrinsicContentSize, .maxSize]Disable constraints you don't need for performance or when surrounding AppKit views already handle layout.
NSHostingView -- for raw view contexts
Use in collection view cells, table view cells, and any place that needs an NSView rather than a view controller.
class ShortcutItemView: NSCollectionViewItem {
private var hostingView: NSHostingView<ShortcutView>?
func displayShortcut(_ shortcut: Shortcut) {
let view = ShortcutView(shortcut: shortcut)
if let hostingView {
hostingView.rootView = view // reuse existing hierarchy
} else {
let newHosting = NSHostingView(rootView: view)
self.view.addSubview(newHosting)
setupConstraints(for: newHosting)
hostingView = newHosting
}
}
}Critical for performance: Create the NSHostingView once, then set rootView on reuse. SwiftUI diffs the view hierarchy internally and only updates what changed.
Shared State Between AppKit and SwiftUI
Use an @Observable model that both sides can access:
@Observable @MainActor
class SelectionModel {
var selectedItem: SidebarItem = .allShortcuts
}
// AppKit side: read the property inside an observation-tracking method
// (draw/layout/updateLayer/updateConstraints — see "Automatic Observation in
// AppKit" above) and AppKit re-invokes it on change. Outside those methods,
// use withObservationTracking(_:onChange:).
// SwiftUI side: bind directly
struct SidebarView: View {
@Bindable var model: SelectionModel
var body: some View {
List(selection: $model.selectedItem) { ... }
}
}(@Observable models have no Combine $property publishers — that's @Published/ObservableObject. Observation tracking replaces the .sink dance.)
---
Responder Chain and Focus
SwiftUI views hosted in AppKit participate in the same responder chain. This is the key mental model: they don't live in separate worlds.
How it works
When an NSHostingView has focus, it becomes the first responder. Selectors from menu items travel through the responder chain just like they would for any AppKit view. SwiftUI intercepts them via modifiers.
SwiftUI command modifiers
struct EditorView: View {
var body: some View {
ScrollView { ... }
.focusable()
.copyable([selectedItem]) // @autoclosure -- pass the array, NOT a trailing closure
.cuttable { [selectedItem] } // action returns items to cut; remove them from your model too
.pasteDestination(for: String.self) { strings in // label is `for:`, not `payloadType:`
paste(strings)
}
.onMoveCommand { direction in moveSelection(direction) }
.onExitCommand { cancelOperation() }
.onCommand(#selector(NSResponder.selectAll(_:))) {
selectAllItems()
}
.onCommand(#selector(moveActionUp(_:))) {
moveSelected(.up)
}
}
}Standard selectors handled by SwiftUI
| Modifier | Selector |
|---|---|
.copyable | copy: |
.cuttable | cut: |
.pasteDestination | paste: |
.onCommand(#selector(...)) | Any custom or standard selector |
.onMoveCommand | Arrow keys |
.onExitCommand | Escape |
Focus and Full Keyboard Navigation
- Use
.focusable()to make non-interactive SwiftUI views participate in keyboard navigation - Test with System Settings > Keyboard > Full Keyboard Navigation both on and off
- Use
@FocusStateand.focused()for programmatic focus control - Some controls are only focusable when Full Keyboard Navigation is enabled
---
SwiftUI in the Main Menu (NSHostingMenu, macOS 14.4+)
Build a menu's content as a SwiftUI View (Buttons with .keyboardShortcut, Dividers, palette-style Pickers) and attach it to the AppKit main menu:
let colorMenu = NSHostingMenu(rootView: ColorMenu(model: colorModel))
colorMenu.title = "Color" // NSHostingMenu IS an NSMenu — configure as usual
let colorMenuItem = NSMenuItem()
colorMenuItem.submenu = colorMenu
mainMenu.addItem(colorMenuItem)The rootView's Button actions, withAnimation updates, and Picker(selection: Bindable(model).hue) bindings all work as in any SwiftUI view. See skills/menus-and-commands.md for pure-SwiftUI menu construction.
---
SwiftUI Scenes from AppKit (NSHostingSceneRepresentation, macOS 26+)
Add complete SwiftUI scenes — MenuBarExtra, Settings — to an existing NSApplicationDelegate app without rewriting its lifecycle:
@MainActor
class AppDelegate: NSObject, NSApplicationDelegate {
let model = AppModel()
var openSettingsAction: (() -> Void)?
func applicationWillFinishLaunching(_ notification: Notification) {
let scenes = NSHostingSceneRepresentation {
LightMenuBarExtra(appModel: model) // MenuBarExtra scene
LightSettings(appModel: model) // Settings scene
}
NSApplication.shared.addSceneRepresentation(scenes)
// The representation exposes SwiftUI environment actions:
openSettingsAction = { scenes.environment.openSettings() }
}
@IBAction func openSettings(_ sender: Any?) {
openSettingsAction?() // open the SwiftUI Settings window from an AppKit menu item
}
}Pair a MenuBarExtra(isInserted:) binding with a Settings Toggle so people can remove and re-add the menu bar item. A SwiftUI MenuBarExtra also handles the keyboard-navigation/session bookkeeping that raw NSStatusItem custom windows need (skills/appkit-modernization.md, expanded interface sessions).
---
NSToolbar Integration
SwiftUI's .toolbar modifier covers most toolbar needs. Drop to NSToolbar when you need:
- Item validation (
validateToolbarItemfor enabling/disabling based on state) - User customization (
allowsUserCustomizationwith persistent layout) - Centered item groups (
centeredItemIdentifiers) - Custom item views beyond what SwiftUI toolbar content supports
- Overflow behavior control
Bridging approach
Use NSToolbar on the window and populate items with NSHostingView-wrapped SwiftUI views for the best of both worlds:
func toolbar(_ toolbar: NSToolbar,
itemForIdentifier identifier: NSToolbarItem.Identifier,
willBeInsertedIntoToolbar: Bool) -> NSToolbarItem? {
let item = NSToolbarItem(itemIdentifier: identifier)
item.view = NSHostingView(rootView: MyToolbarButton())
return item
}---
NSOpenPanel vs fileImporter
Use .fileImporter when
- Picking files by content type (UTType)
- Single or multiple file selection
- Standard file picker UX is sufficient
- Working within sandbox (
.fileImporterhandles entitlements automatically)
.fileImporter(isPresented: $showPicker,
allowedContentTypes: [.png, .jpeg],
allowsMultipleSelection: true) { result in
switch result {
case .success(let urls): handleFiles(urls)
case .failure(let error): handleError(error)
}
}Use NSOpenPanel when
- Selecting directories (
canChooseDirectories) - Adding accessory views to the panel
- Handling iCloud conflicts (
canResolveUbiquitousConflicts) - Downloading ubiquitous content (
canDownloadUbiquitousContents) - Needing panel delegate callbacks for filtering beyond UTType
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
panel.begin { response in
guard response == .OK, let url = panel.url else { return }
handleDirectory(url)
}---
Drag and Drop
SwiftUI-native (preferred)
Use Transferable conformance with .draggable() and .dropDestination() when both sides are SwiftUI or when working with standard types.
Bridging to AppKit drag destinations
When an NSView wrapped via NSViewRepresentable needs to accept drops, implement NSDraggingDestination on the AppKit view (or its coordinator) as usual. The representable pattern naturally supports this since you own the AppKit view creation.
When SwiftUI content needs to accept drops from AppKit views using legacy pasteboard types that Transferable doesn't cover, use .onDrop(of:delegate:) with NSItemProvider to access the raw pasteboard data.
---
Common Mistakes
| Mistake | Symptom | Fix |
|---|---|---|
| New NSHostingView per cell reuse | Scroll jank, high memory | Create once, update rootView |
| Setting frame/bounds in updateNSView | Layout corruption | Use SwiftUI .frame() modifier |
| Stale coordinator bindings | Writes to SwiftUI state ignored | Update coordinator reference in updateNSView |
Wrapping a bare NSTextView | No scrolling; caret jumps to top while typing | Build via NSTextView.scrollableTextView(); guard the string assignment |
| Redundant property sets in updateNSView | Unnecessary AppKit view reloads | Compare before setting |
| Using NSOpenPanel for basic file picks | Unnecessary complexity, sandbox issues | Use .fileImporter first |
| Manual event forwarding | Duplicated or broken input | Let the responder chain work |
Missing .focusable() on command receivers | onCommand modifiers silently ignored | Add .focusable() to the view |
| Forgetting Full Keyboard Navigation testing | Controls unreachable for keyboard users | Test with setting on and off |
Resources
WWDC: 2022-10075, 2026-272
Docs: /swiftui/nsviewrepresentable, /swiftui/nsviewcontrollerrepresentable, /swiftui/nshostingcontroller, /swiftui/nshostingview, /swiftui/nshostingmenu, /swiftui/nsgesturerecognizerrepresentable, /swiftui/nshostingscenerepresentation, /appkit/nstoolbar, /appkit/nsopenpanel, /appkit/updating-views-automatically-with-observation-tracking
Skills: skills/appkit-modernization.md, axiom-uikit, axiom-swiftui
AppKit Modernization
Modernizing an existing AppKit app: replacing mouseDown tracking with modern input APIs, keyboard navigation, graceful termination and state restoration, and the macOS 27 look-and-feel (concentric corners, interactive glass, touch input). Based on WWDC 2026-289 plus the macOS 27 SDK delta.
When to Use This Skill
Use when:
- An AppKit app overrides
mouseDownfor selection, context menus, drag-and-drop, or text selection - Reacting to control interactions (buttons, sliders) without subclassing — control events
- Full Keyboard Navigation skips views, or the key view loop is stale
- A status item shows custom windows or transient UI (expanded interface sessions)
- The app blocks quit, or loses window state across relaunch (state restoration)
- Adopting the macOS 27 look: concentric corners, interactive Liquid Glass
- Handling touch input on the Mac (touch scrolling, pull-to-refresh)
Related Skills
skills/appkit-interop.md— hosting SwiftUI in AppKit (NSHostingView/Menu/SceneRepresentation, observation tracking, gesture representables)axiom-uikit (skills/uikit-modernization.md)— the UIKit sibling (scene lifecycle, 27 tab/nav APIs)axiom-uikit (skills/textkit-ref.md)— TextKit 2 surfaces (NSTextViewportRenderingSurface is documented there, cross-platform)
Red Flags
- ❌ Overriding
mouseDownto track selection (observeselected/ use delegate callbacks instead) - ❌ Implementing custom mouse-tracking loops for behaviors gesture recognizers already provide
- ❌ Toggling a status-item window manually from target/action (breaks keyboard navigation — use expanded interface sessions)
- ❌ Leaving
preventsApplicationTerminationWhenModalat its default for sheets that don't need intervention (blocks overnight-update restarts) - ❌ Encoding document/database data in
encodeRestorableState(restore the UI, not the model) - ❌ Hand-rolled corner radii on views near container corners (use
cornerConfigurationconcentricity)
Modern Input (Replace mouseDown Overrides)
Gesture recognizers are the common event-handling language across AppKit, SwiftUI, and Mac Catalyst. Three solutions interface well with them — reach for the dedicated API before a mouseDown override:
| You override mouseDown for | Use instead |
|---|---|
| Tracking selection | Observe selected on NSCollectionViewItem/NSTableRowView, or NSTableViewDelegate/NSOutlineViewDelegate callbacks |
| Context menus | NSView.defaultMenu (class-wide), NSResponder.menu (per responder), or menu(for:) (per event) |
| Drag-and-drop from containers | Modern dragging delegate: tableView(_:pasteboardWriterForRow:) — return an NSPasteboardItem; equivalents on NSCollectionView, NSOutlineView, NSBrowser |
| Text selection outside NSTextView | NSTextSelectionManager OS27 — attach to a view + set a text-selection data source for bidirectional selection, drag-and-drop with text, toggling |
| Custom interactions | Standard NSGestureRecognizers, or your own subclass |
Control Events (UIKit-Style, Now in AppKit)
React to tracking-state changes on standard controls without subclassing. addTarget(_:action:for:) / removeTarget(_:action:for:) are available from macOS 11 (and per WWDC 2026-289, most tracking events carry behavior dating back to OS X 10.11); the semantic cases are new in the 27 SDK:
let button = NSButton()
button.addTarget(self, action: #selector(trackingEndedOutsideHandler),
for: .trackingEndedOutside)NSControl.Events case | Availability |
|---|---|
down/up/tracking cases (.trackingBegan, .trackingEndedInside, .trackingEndedOutside, …) | macOS 11 (with addTarget) |
.trackingRepeated (click count > 1) | OS27 |
.valueChanged (sliders, etc.) | OS27 |
.primaryActionTriggered (semantic action for buttons) | OS27 |
.menuActionTriggered (menu gesture fired, before the menu presents) | OS27 |
.applicationReserved (range for app-defined events) | OS27 |
Hit-Testing Gotcha
Gesture recognizers operate on a view and its subviews, so an overlapping sibling view silently swallows clicks. If a control doesn't respond: resize the sibling so it doesn't overlap, or — if it's a deliberate overlay — let events fall through:
override func hitTest(_ point: NSPoint) -> NSView? {
return nil // fall through to content underneath
}Keyboard Navigation
- Full Keyboard Navigation (System Settings > Keyboard) moves focus with Tab/Shift-Tab through the key view loop. Set
window.autorecalculatesKeyViewLoop = trueto recalculate it automatically as views come and go — otherwise you own loop maintenance. - Status items that trigger actions: give the status item's
buttona target and action — Return fires it during keyboard navigation.
Status Item Expanded Interface Sessions OS27
Status items that show custom windows ("expanded interface") must tell AppKit when that UI is active so keyboard focus and menu tracking behave. Don't toggle the window from a plain target/action:
// 1. Set the delegate when the item is created
lightStatusItem.expandedInterfaceDelegate = self
// 2. Show/hide the window in the delegate callbacks
// (@MainActor on the conformance: the protocol isn't actor-annotated, so a
// main-actor delegate needs it to satisfy the requirements in Swift 6 mode)
extension LightAppDelegate: @MainActor NSStatusItemExpandedInterfaceDelegate {
func statusItem(_ statusItem: NSStatusItem,
didBegin session: NSStatusItemExpandedInterfaceSession) {
// Show the window
}
func statusItemDidEndExpandedInterfaceSession(_ statusItem: NSStatusItem,
animated: Bool) {
// Order the window out
}
}
// 3. Dismiss programmatically (e.g. after an action) — the session may also be
// cancelled for you when focus moves elsewhere
lightStatusItem.expandedInterfaceSession?.cancel()Items that assign an NSMenu to their button don't get these callouts — menu tracking is automatic. SwiftUI's MenuBarExtra does this work for you: see skills/appkit-interop.md (SwiftUI Scenes from AppKit).
Graceful Termination and State Restoration
A modern Mac app quits without pushback (overnight software updates need to reboot) and relaunches as if it never quit.
Termination: NSWindow.preventsApplicationTerminationWhenModal defaults to true — a presented sheet blocks quit. Set it to false for every modal/sheet that doesn't strictly require intervention.
Restoration (NSWindowRestoration, three steps):
// 1. Opt in — identifier, autosave name (skip for document windows), restorable, class
window.identifier = NSUserInterfaceItemIdentifier(WindowIdentifiers.mainWindow)
window.setFrameAutosaveName(WindowIdentifiers.mainWindow)
window.isRestorable = true // AppKit also restores minimized/frontmost/full-screen
window.restorationClass = WindowRestorationHandler.self
// 2. Encode UI state (any NSResponder can override this)
override func encodeRestorableState(with coder: NSCoder) {
super.encodeRestorableState(with: coder) // always call super
coder.encode(selectedProduct?.identifier.uuid, forKey: RestorationKeys.productIdentifier)
}
// Encoding only happens for invalidated objects — signal UI changes:
invalidateRestorableState()
// 3. On relaunch: recreate windows, then decode
class WindowRestorationHandler: NSObject, NSWindowRestoration {
static func restoreWindow(withIdentifier identifier: NSUserInterfaceItemIdentifier,
state: NSCoder,
completionHandler: @escaping (NSWindow?, Error?) -> Void) {
// Recreate the window controller for this identifier…
// ALWAYS call completionHandler — AppKit waits on every restorable window;
// on failure call it with the error
}
}
override func restoreState(with coder: NSCoder) {
super.restoreState(with: coder)
// Decode keys, hand values to view controllers
}Encode UI state, not data: restoration reconstructs the UI (selection, frontmost window), never re-serializes documents or databases. Sample code: "Restoring your app's state with AppKit".
macOS 27 Look and Feel OS27
System-wide on macOS 27, no rebuild — apps that adopted Liquid Glass on macOS 26 pick these up just by running on 27: the automatic NSScrollEdgeEffectStyle resolves to a hard edge under free-floating text (e.g. window titles), sidebars extend to the window edges with semibold selection text, content flows behind them, and bordered toolbar items over the sidebar adopt Liquid Glass.
New API in the 27 SDK — interactive glass and concentric corners:
Interactive glass: NSGlassEffectView.effectIsInteractive = true makes glass subtly bounce when clicked. Use it on controls, buttons, or glass containers of interactive controls only — a little goes a long way.
Concentric corners: content near a container's corner should follow the container's curve.
class LocalWeatherView: NSView {
// cornerConfiguration is a readonly property — override the getter
override var cornerConfiguration: NSViewCornerConfiguration? {
let radius: NSViewCornerRadius = .containerConcentric(8) // 8pt minimum, always rounded
return .uniformCorners(radius: radius) // same radii on all 4 corners
}
}| API | Notes |
|---|---|
NSViewCornerRadius | .containerConcentric / .containerConcentric(_ minimum:) (always-rounded floor) / .fixed(_:) |
NSViewCornerConfiguration factories | .uniformCorners(radius:), .corners(radius:), per-corner .corners(topLeftRadius:…), .capsule, .capsule(maximumRadius:), .uniformEdges(topRadius:bottomRadius:) |
NSView.effectiveCornerRadii / viewDidChangeEffectiveCornerRadii() / invalidateCornerConfiguration() | Read back resolved radii; react to changes; force re-resolution |
Semantic roles (also new in the 27 SDK): NSSegmentedControl.role (.automatic / .tabs / .valueSelection) and NSToolbarItemGroup.role declare what a segmented control or toolbar item group means, so the system can style it appropriately.
Touch and Gesture Additions OS27 (SDK)
SDK additions in the 27 AppKit headers (not covered in WWDC 2026-289):
| API | What it is |
|---|---|
NSScreen.touchCapabilities | Whether the current screen reports touch input |
NSScrollView.isTouchScrollingEnabled + minimumNumberOfTouchesForScrolling / maximumNumberOfTouchesForScrolling | Touch-driven scrolling with finger-count thresholds |
NSScrollView.refreshController (NSRefreshController) | Pull-to-refresh for scroll views on the Mac |
NSScrollView.scrollGestureForRelationships | The scroll gesture, exposed for gesture-relationship setup |
NSView.beginDraggingSession(items:gesture:source:) | Start a dragging session from a gesture recognizer |
NSView.exclusiveGestureBehavior | Exclusivity policy between a view's gestures and others |
NSEvent.isTouchSwipeNavigationEnabled | Class property — the user's touch swipe-navigation preference |
NSGestureRecognizer.isCancellableByScrollGesture | Whether a scroll gesture can cancel this recognizer |
NSPanGestureRecognizer.minimumNumberOfTouches / maximumNumberOfTouches | Finger-count thresholds for pans |
WKWebView (WebKit) hosts the same NSRefreshController through its own refreshController property on macOS 27 — first-class pull-to-refresh for web content, macOS-only (on iOS you wire it up yourself by attaching a UIRefreshControl to WKWebView.scrollView).
Checklist
- ☑ No
mouseDownoverrides where a dedicated API exists (selection, menus, dragging, text selection) - ☑ Control interactions via control events or gesture recognizers, not tracking loops
- ☑
hitTestfall-through (or resized siblings) for overlay views that block clicks - ☑
autorecalculatesKeyViewLoopenabled, or the key view loop maintained manually - ☑ Status-item custom UI tracked with expanded interface sessions
- ☑
preventsApplicationTerminationWhenModal = falseon every sheet that doesn't need intervention - ☑ Windows restorable: identifier +
isRestorable+restorationClass;restoreWindowalways calls its completion handler - ☑
invalidateRestorableState()called on UI changes that should persist - ☑ Corner-adjacent views adopt
cornerConfigurationconcentricity
Resources
WWDC: 2026-289
Docs: /appkit/nscontrol/events, /appkit/nstextselectionmanager, /appkit/nsstatusitem, /appkit/nswindowrestoration, /appkit/nsviewcornerconfiguration
Skills: skills/appkit-interop.md, skills/windows.md, skills/menus-and-commands.md, axiom-uikit (skills/uikit-modernization.md)
macOS Direct Distribution
When to Use This Skill
Use when:
- Distributing a macOS app outside the Mac App Store via Developer ID
- Setting up code signing for direct distribution (not App Store)
- Notarizing software with
notarytool - Troubleshooting Gatekeeper blocks, notarization failures, or code signing errors
- Adding auto-updates to a directly distributed app (Sparkle)
- Packaging apps as DMG, zip, or installer package
- Migrating from deprecated
altooltonotarytool
Related Skills
- Use
skills/sandbox-and-file-access.mdfor App Sandbox entitlements, file access, container architecture - Use
axiom-securityfor Keychain, encryption, passkeys, certificate management - Use
axiom-shippingfor App Store submission, rejections, privacy manifests
Red Flags — Anti-Patterns to Prevent
If you are doing ANY of these, STOP and follow the guidance in this skill.
Using altool for notarization
# ❌ REJECTED — altool is no longer accepted as of November 1, 2023
xcrun altool --notarize-app --file MyApp.zip ...Why this fails: Apple's notary service no longer accepts submissions from altool. You must use notarytool (Xcode 13+) or the Notary REST API. See TN3147.
Signing in the wrong order
# ❌ WRONG — signing the app before its embedded frameworks
codesign -s "Developer ID Application" MyApp.app
codesign -s "Developer ID Application" MyApp.app/Contents/Frameworks/Sparkle.frameworkWhy this fails: Signing the outer bundle first, then signing an inner framework, invalidates the outer signature. macOS validates nested signatures as part of the parent. Always sign inside-out.
Using --deep for code signing
# ❌ WRONG — applies identical options to all nested code
codesign --deep -s "Developer ID Application" -o runtime MyApp.appWhy this fails: --deep applies the same entitlements, options, and identity to every nested component. Different components often need different entitlements (e.g., XPC services vs main app) or no entitlements at all (frameworks). Quinn "The Eskimo!" calls this "--deep Considered Harmful."
Skipping Hardened Runtime
# ❌ WRONG — notarization will reject this
codesign -s "Developer ID Application" --timestamp MyApp.appWhy this fails: Hardened Runtime (-o runtime) is mandatory for notarization. Without it, Apple's notary service rejects the submission.
Not stapling the notarization ticket
Why this fails: Without stapling, Gatekeeper must contact Apple's servers to verify notarization. If the user is offline, Gatekeeper blocks the app. Stapling embeds the ticket directly in the distribution file.
Leaving get-task-allow in distribution builds
Why this fails: The com.apple.security.get-task-allow entitlement allows debugger attachment. Notarization rejects code with this entitlement because an attacker could inject code at runtime. Remove it from distribution entitlements.
---
Distribution Checklist
This is the end-to-end workflow from signed code to delivered product. Complete every step in order.
Phase 1: Prepare
- [ ] Verify Developer ID identity is available:
security find-identity -p codesigning -v
# Look for "Developer ID Application: <Name> (<TeamID>)"- [ ] Create distribution entitlements file (remove
com.apple.security.get-task-allow, setcom.apple.developer.apsto "production" if using push) - [ ] Verify entitlements file is ASCII XML with LF line endings, no BOM:
plutil -convert xml1 MyApp.entitlements- [ ] Enable Hardened Runtime in Xcode (Signing & Capabilities) or plan to pass
-o runtimetocodesign - [ ] Add only the Hardened Runtime exceptions your app actually needs
Phase 2: Sign (Inside-Out)
- [ ] Sign embedded frameworks first:
codesign -f -s "Developer ID Application: <Name> (<TeamID>)" \
--timestamp -o runtime \
MyApp.app/Contents/Frameworks/SomeFramework.framework- [ ] Sign helpers, XPC services, and extensions:
codesign -f -s "Developer ID Application: <Name> (<TeamID>)" \
--timestamp -o runtime \
MyApp.app/Contents/XPCServices/Helper.xpc- [ ] Sign the main app last, with entitlements:
codesign -f -s "Developer ID Application: <Name> (<TeamID>)" \
--timestamp -o runtime \
--entitlements MyApp.entitlements \
MyApp.app- [ ] Verify the signature:
codesign -v -vvv --strict --deep MyApp.appPhase 3: Package
- [ ] Choose a container format (see Packaging section below)
- [ ] For DMG: create, copy app, sign the DMG:
hdiutil create -volname "MyApp" -srcfolder MyApp.app -ov -format UDZO MyApp.dmg
codesign -s "Developer ID Application: <Name> (<TeamID>)" --timestamp MyApp.dmg- [ ] For zip: use
ditto(preserves symlinks and resource forks):
ditto -c -k --sequesterRsrc --keepParent MyApp.app MyApp.zip- [ ] For pkg: build and sign:
productbuild --component MyApp.app /Applications MyApp-unsigned.pkg
productsign --sign "Developer ID Installer: <Name> (<TeamID>)" \
MyApp-unsigned.pkg MyApp.pkgPhase 4: Notarize
- [ ] Store credentials in Keychain (one-time setup):
xcrun notarytool store-credentials "AC_PASSWORD" \
--apple-id you@example.com \
--team-id YOURTEAMID- [ ] Submit and wait:
xcrun notarytool submit MyApp.dmg \
--keychain-profile "AC_PASSWORD" \
--wait- [ ] Check the log even on success (warnings matter):
xcrun notarytool log <submission-id> \
--keychain-profile "AC_PASSWORD"Phase 5: Staple and Deliver
- [ ] Staple the notarization ticket:
xcrun stapler staple MyApp.dmg- [ ] Verify stapling:
xcrun stapler validate MyApp.dmg- [ ] Test on a clean Mac or VM (see Troubleshooting section)
- [ ] Upload to your distribution server
---
Code Signing
Signing Order
Sign inside-out. The outer signature includes hashes of inner signatures, so signing inner components after the outer signature invalidates it.
| Order | Component | Example |
|---|---|---|
| 1 | Dylibs | Contents/Frameworks/*.dylib |
| 2 | Frameworks | Contents/Frameworks/*.framework |
| 3 | XPC services | Contents/XPCServices/*.xpc |
| 4 | Helpers/tools | Contents/MacOS/helper-tool |
| 5 | App extensions | Contents/PlugIns/*.appex |
| 6 | Main app | MyApp.app |
Essential codesign Flags
| Flag | Purpose | When Required |
|---|---|---|
-s "Developer ID Application: ..." | Signing identity | Always |
-f | Force re-sign | When re-signing previously signed code |
--timestamp | Secure timestamp from Apple | Developer ID (notarization requires it) |
-o runtime | Enable Hardened Runtime | Developer ID (notarization requires it) |
--entitlements path | Apply entitlements | Main executable only, never libraries |
-i com.example.tool | Set identifier | Non-bundled executables only |
Never Use
- `--deep` — Applies identical options to all nested code. Different components need different entitlements.
- `sudo codesign` — codesign depends on user account information. Running as root breaks identity lookup.
Verify a Signature
# Full verification with strict checks
codesign -v -vvv --strict --deep MyApp.app
# Display signing details
codesign -d -vvv MyApp.app
# Check designated requirement
codesign --display -r - MyApp.app---
Hardened Runtime
Hardened Runtime protects against code injection, DLL hijacking, and memory tampering. It works alongside System Integrity Protection. Required for notarization.
Most apps work without any exceptions. Add exceptions only when your app genuinely needs them.
Runtime Exceptions
| Exception | Entitlement | Use Case |
|---|---|---|
| JIT compilation | com.apple.security.cs.allow-jit | JavaScript engines, regex engines |
| Unsigned executable memory | com.apple.security.cs.allow-unsigned-executable-memory | Legacy code, avoid if possible |
| DYLD environment variables | com.apple.security.cs.allow-dyld-environment-variables | Plugin hosts, debugging tools |
| Disable library validation | com.apple.security.cs.disable-library-validation | Loading third-party frameworks/plugins |
| Disable executable memory protection | com.apple.security.cs.disable-executable-page-protection | Extremely rare, removes core protections |
| Debugging tool | com.apple.security.cs.debugger | Instruments-like tools |
Resource Access Entitlements
| Resource | Entitlement |
|---|---|
| Camera | com.apple.security.device.camera |
| Microphone | com.apple.security.device.audio-input |
| Location | com.apple.security.personal-information.location |
| Contacts | com.apple.security.personal-information.addressbook |
| Calendar | com.apple.security.personal-information.calendars |
| Photos | com.apple.security.personal-information.photos-library |
| Apple Events | com.apple.security.automation.apple-events |
Even with entitlements, the user still sees a permission prompt at runtime.
---
Notarization
notarytool Commands
Store credentials once, reference everywhere:
# Store with app-specific password
xcrun notarytool store-credentials "AC_PASSWORD" \
--apple-id you@example.com \
--team-id YOURTEAMID
# Store with App Store Connect API key
xcrun notarytool store-credentials "AC_APIKEY" \
--issuer ISSUER_UUID \
--key-id API_KEY_ID \
--key /path/to/AuthKey_XXXX.p8Submit, check, and retrieve logs:
# Submit and wait (blocks until complete)
xcrun notarytool submit MyApp.dmg --keychain-profile "AC_PASSWORD" --wait
# Submit without waiting (returns submission ID)
xcrun notarytool submit MyApp.dmg --keychain-profile "AC_PASSWORD"
# Check status of a submission
xcrun notarytool info <submission-id> --keychain-profile "AC_PASSWORD"
# Retrieve the notary log (always check, even on success)
xcrun notarytool log <submission-id> --keychain-profile "AC_PASSWORD"
# View submission history
xcrun notarytool history --keychain-profile "AC_PASSWORD"Accepted Upload Formats
| Format | Staple-able | Notes |
|---|---|---|
.dmg (UDIF) | Yes | Must be signed DMG |
.pkg (flat) | Yes | Must be signed installer package |
.zip | No | Cannot staple; staple contents before zipping |
Common Notarization Failures
| Error | Cause | Fix |
|---|---|---|
| "The signature does not include a secure timestamp" | Missing --timestamp | Re-sign with --timestamp flag |
| "The executable does not have the hardened runtime enabled" | Missing -o runtime | Re-sign with -o runtime flag |
| "The signature of the binary is invalid" | Signed in wrong order or modified after signing | Re-sign inside-out, don't modify after |
| "The binary uses an SDK older than the 10.9 SDK" | Linked against ancient SDK | Rebuild with macOS 10.9+ SDK |
| "The executable requests the com.apple.security.get-task-allow entitlement" | Debug entitlement in distribution build | Remove from distribution entitlements file |
Stapling
# Staple to DMG or pkg
xcrun stapler staple MyApp.dmg
# Staple to app bundle (before zipping)
xcrun stapler staple MyApp.app
# Validate stapling
xcrun stapler validate MyApp.dmgFor zip distribution: staple the .app first, then create the zip with ditto.
Stapler Troubleshooting
If stapling fails with caching errors:
sudo killall -9 trustd
sudo rm /Library/Keychains/crls/valid.sqlite3If stapling reports error 65, see Quinn's "Resolving Error 65 When Stapling" forum post.
---
Packaging
DMG (Recommended for User-Facing Distribution)
Best for: drag-to-install experience, can include /Applications symlink, supports custom backgrounds, staple-able.
# Create compressed DMG
hdiutil create -volname "MyApp" -srcfolder MyApp.app -ov -format UDZO MyApp.dmg
# Sign the DMG itself
codesign -s "Developer ID Application: <Name> (<TeamID>)" --timestamp MyApp.dmgZip (Simplest, No Stapling)
Best for: Sparkle updates, automated distribution, CI artifacts.
# MUST use ditto to preserve symlinks and resource forks
ditto -c -k --sequesterRsrc --keepParent MyApp.app MyApp.zipZip files cannot be stapled. Staple the .app before creating the zip. Gatekeeper still verifies notarization online for non-stapled archives.
Installer Package (System-Level Installation)
Best for: installing daemons, launch agents, privileged helpers, multi-component products.
# Build the package
productbuild --component MyApp.app /Applications MyApp-unsigned.pkg
# Sign with Developer ID Installer identity (not Application)
productsign --sign "Developer ID Installer: <Name> (<TeamID>)" \
MyApp-unsigned.pkg MyApp.pkg---
Auto-Updates with Sparkle
Sparkle is the standard auto-update framework for directly distributed macOS apps. MIT-licensed, supports EdDSA signatures, sandboxing, and silent background updates.
Setup
1. Add Sparkle via SPM:
- File > Add Packages >
https://github.com/sparkle-project/Sparkle
2. Generate EdDSA keys (once per project):
# Tools location varies by install method
# SPM: ../artifacts/sparkle/Sparkle/bin/
./bin/generate_keysCopy the public key to Info.plist as SUPublicEDKey. The private key is stored in your Keychain.
3. Add SUFeedURL to Info.plist:
<key>SUFeedURL</key>
<string>https://example.com/appcast.xml</string>4. For sandboxed apps, add to Info.plist:
<key>SUEnableInstallerLauncherService</key>
<true/>And add the XPC temporary exception to your .entitlements:
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spks</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spki</string>
</array>SwiftUI Integration
import Sparkle
final class CheckForUpdatesViewModel: ObservableObject {
@Published var canCheckForUpdates = false
let updater: SPUUpdater
init() {
let controller = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: nil,
userDriverDelegate: nil
)
self.updater = controller.updater
updater.publisher(for: \.canCheckForUpdates)
.assign(to: &$canCheckForUpdates)
}
func checkForUpdates() {
updater.checkForUpdates()
}
}
struct CheckForUpdatesView: View {
@ObservedObject var viewModel: CheckForUpdatesViewModel
var body: some View {
Button("Check for Updates...") {
viewModel.checkForUpdates()
}
.disabled(!viewModel.canCheckForUpdates)
}
}Add to your App:
@main
struct MyApp: App {
@StateObject private var updaterViewModel = CheckForUpdatesViewModel()
var body: some Scene {
WindowGroup { ContentView() }
.commands {
CommandGroup(after: .appInfo) {
CheckForUpdatesView(viewModel: updaterViewModel)
}
}
}
}Publishing an Update
1. Archive and export with Developer ID distribution 2. Create the archive:
ditto -c -k --sequesterRsrc --keepParent MyApp.app MyApp.zip3. Generate the appcast:
./bin/generate_appcast /path/to/updates_folder/4. Upload the archive, any delta files, and appcast.xml to your server
Key Info.plist Settings
| Key | Default | Purpose |
|---|---|---|
SUFeedURL | (required) | Appcast URL |
SUPublicEDKey | (required) | EdDSA public key for signature verification |
SUEnableAutomaticChecks | Prompt user | Set YES to skip the permission prompt |
SUAutomaticallyUpdate | NO | Silent background updates |
SUScheduledCheckInterval | 86400 (1 day) | Minimum: 3600 (1 hour) |
Signing Sparkle in Manual Builds
If you re-sign Sparkle manually (not using Xcode's Archive/Export), sign inside-out:
codesign -f -s "$IDENTITY" -o runtime \
Sparkle.framework/Versions/B/XPCServices/Installer.xpc
codesign -f -s "$IDENTITY" -o runtime --preserve-metadata=entitlements \
Sparkle.framework/Versions/B/XPCServices/Downloader.xpc
codesign -f -s "$IDENTITY" -o runtime \
Sparkle.framework/Versions/B/Autoupdate
codesign -f -s "$IDENTITY" -o runtime \
Sparkle.framework/Versions/B/Updater.app
codesign -f -s "$IDENTITY" -o runtime \
Sparkle.frameworkDo NOT use --deep on Sparkle. Its XPC services have different signing requirements.
---
Troubleshooting
Gatekeeper Blocks
Test on a fresh Mac or VM. To isolate Gatekeeper from other issues:
# Download without quarantine attribute
curl -O https://example.com/MyApp.dmg
# Or remove quarantine from existing file
xattr -d com.apple.quarantine MyApp.dmgIf the app still fails without quarantine, the problem is NOT Gatekeeper — it is a code signing or runtime issue.
On macOS 14+, use syspolicy_check:
syspolicy_check distribution MyApp.appDangling Load Command Paths
BY FAR the most common Gatekeeper failure. A Mach-O binary references a library path that does not exist at runtime.
Diagnose with:
otool -L MyApp.app/Contents/MacOS/MyApp
# Look for absolute paths like /usr/local/lib/... or build-directory pathsFix by using install_name_tool or @rpath-relative paths.
Unicode Normalization in Zip Archives
The Finder's Archive Utility can convert precomposed Unicode filenames to decomposed form, breaking code signatures. Stick to ASCII when naming files in your bundle. Use ditto instead of Finder compression.
cdhash Matching for Notarization Issues
When Gatekeeper logs show ticket not available: 2/2/<cdhash>:
1. Get your app's cdhash:
codesign -d -vvv MyApp.app
# Look for "CDHash=" line2. Compare with the cdhash in the notary log 3. If they differ, you are testing a different binary than you notarized
System Log Diagnostics
# Stream trusted execution logs
log stream --predicate "sender == 'AppleMobileFileIntegrity' or \
sender == 'AppleSystemPolicy' or process == 'amfid' or \
process == 'taskgated-helper' or process == 'syspolicyd'"Search keywords: gk, xprotect, syspolicy, amfi, cmd
Testing a Notarized Product
Always test from a clean download, not from your build directory. The build directory does not have the quarantine attribute that triggers Gatekeeper.
1. Upload to a web server or share via AirDrop 2. Download on a test Mac 3. Verify the quarantine attribute exists:
xattr -l MyApp.dmg
# Should show com.apple.quarantine4. Open normally (double-click in Finder)
---
Resources
WWDC: 2018-702, 2019-703, 2021-10261, 2022-10109, 2023-10266
Docs: /security/notarizing-macos-software-before-distribution, /xcode/creating-distribution-signed-code-for-the-mac, /xcode/packaging-mac-software-for-distribution, /security/hardened-runtime, /technotes/tn3147-migrating-to-the-latest-notarization-tool
Forum Posts (Quinn "The Eskimo!"): Resolving Trusted Execution Problems, Resolving Gatekeeper Problems, The Care and Feeding of Developer ID, --deep Considered Harmful, Testing a Notarised Product, The Pros and Cons of Stapling, Resolving Error 65 When Stapling
Skills: sandbox, axiom-security, axiom-shipping
macOS Menus and Commands
When to Use This Skill
Use when:
- Adding custom menus or menu items to a macOS app's menu bar
- Implementing keyboard shortcuts for menu commands
- Building context menus for macOS views
- Connecting menu commands to the focused window via focusedSceneValue
- Extending or replacing system-provided menu bar commands
- Debugging menu items that appear disabled or don't affect the right window
Related Skills
- Use
skills/windows.mdfor WindowGroup, Window, UtilityWindow, and multi-window management - Use
skills/appkit-interop.mdto build menu content in SwiftUI and attach it to an AppKit main menu (NSHostingMenu) - Use
skills/appkit-modernization.mdfor status items with custom windows (expanded interface sessions) - Use axiom-swiftui (skills/toolbars.md) for toolbar customization and toolbar items (cross-platform
.toolbar, ToolbarItem, ToolbarSpacer)
---
Red Flags — Anti-Patterns to Prevent
If you're doing ANY of these, STOP and use the patterns in this skill:
1. Putting commands directly in a view instead of the scene
// WRONG — commands belong on the scene, not inside a view
struct ContentView: View {
var body: some View {
Text("Hello")
.commands { // This modifier doesn't exist on View
CommandMenu("Tools") { ... }
}
}
}Why this fails: The .commands modifier is a scene-level modifier. It goes on WindowGroup or Window in your App body, not on views. Attempting this produces a compiler error.
2. Reading @State directly from command menus
// WRONG — commands can't access a specific window's @State
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
EditorView()
}
.commands {
CommandMenu("Editor") {
// How do you access the focused window's document?
Button("Save") { document.save() } // No access to document
}
}
}
}Why this fails: There's one menu bar but potentially many windows. Commands have no direct reference to any window's state. You must use @FocusedValue or @FocusedBinding to bridge data from the focused window to the menu bar. This is the single most common mistake iOS developers make when building for macOS.
3. Forgetting to publish focusedSceneValue from the view
// WRONG — @FocusedValue is nil because nothing publishes the value
struct EditorCommands: Commands {
@FocusedValue(\.document) var document // Always nil
var body: some Commands {
CommandMenu("Editor") {
Button("Save") { document?.save() }
.disabled(document == nil) // Always disabled
}
}
}Why this fails: @FocusedValue only receives values if a view in the focused scene publishes them via .focusedSceneValue. Without the publishing side, every @FocusedValue reads nil and every menu item stays disabled.
4. Using focusedValue when you mean focusedSceneValue
// WRONG for most cases — focusedValue tracks individual focus, not the window
TableView()
.focusedValue(\.selection, selectedItems) // Only works when table has focusWhy this fails: .focusedValue publishes only when the specific view has keyboard focus. Click a toolbar button or sidebar, and the value becomes nil. Use .focusedSceneValue to publish values that represent the entire window's state regardless of which view has focus within it.
---
Menu Architecture
Mac menu commands are NOT just buttons. They're a routing system that connects the single, shared menu bar to whichever window is currently focused.
The Command Flow
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Menu Bar │────>│ Commands struct │────>│ @FocusedValue │
│ (one, shared)│ │ (reads focused │ │ (bridges to │
│ │ │ values) │ │ active window) │
└─────────────┘ └──────────────────┘ └─────────────────┘
│
.focusedSceneValue
│
┌───────▼─────────┐
│ Focused Window │
│ (publishes its │
│ state) │
└─────────────────┘Key insight from WWDC 2021-10062: "We have multiple windows, but only ever one menu bar. I don't want to put carrots in my flower bed, so how can the menu know which garden to send the action to?" The answer is focusedSceneValue — it tells the system to expose values for a given key path when the entire scene is in focus.
Where Commands Are Declared
Commands are added via the .commands modifier on scene types in the App body:
@main
struct GardenApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.commands {
SidebarCommands() // System-provided
PlantCommands() // Your custom commands
}
}
}---
Command Patterns
CommandMenu — New Top-Level Menu
Creates a new menu in the menu bar, positioned between the View and Window menus:
struct PlantCommands: Commands {
@FocusedBinding(\.garden) var garden
@FocusedValue(\.selectedPlants) var selectedPlants
var body: some Commands {
CommandMenu("Plants") {
Button("Water Selected") {
guard let plants = selectedPlants else { return }
garden?.water(plants)
}
.keyboardShortcut("w", modifiers: [.command, .shift])
.disabled(selectedPlants?.isEmpty ?? true)
Divider()
Button("Add Plant") {
garden?.addPlant()
}
.keyboardShortcut("n", modifiers: [.command, .option])
}
}
}Per the HIG, custom menus appear between View and Window. Use short, one-word titles.
CommandGroup — Extend or Replace Existing Menus
Add items to system menus using CommandGroupPlacement:
struct FileCommands: Commands {
@FocusedBinding(\.garden) var garden
var body: some Commands {
// Add before the standard "New" group in the File menu
CommandGroup(before: .newItem) {
Button("New Plant") {
garden?.addPlant()
}
.keyboardShortcut("n", modifiers: [.command])
}
// Replace the undo/redo group (if your app doesn't support undo)
CommandGroup(replacing: .undoRedo) {
EmptyView()
}
}
}Standard Placement Locations
| Placement | Menu | Use For |
|---|---|---|
.newItem | File | Creating new items |
.saveItem | File | Save-related actions |
.importExport | File | Import/export actions |
.printItem | File | Print actions |
.pasteboard | Edit | Clipboard operations |
.undoRedo | Edit | Undo/redo operations |
.textEditing | Edit | Text manipulation |
.sidebar | View | Sidebar visibility |
.toolbar | View | Toolbar commands |
.windowList | Window | Window management |
.help | Help | Help content |
.appSettings | App | Settings/preferences |
System-Provided Command Groups
SwiftUI includes pre-built command groups that wire up standard functionality:
.commands {
SidebarCommands() // Toggle sidebar from View menu
InspectorCommands() // Show/Hide Inspector in View menu
ToolbarCommands() // Toolbar customization
TextEditingCommands() // Standard text editing
TextFormattingCommands() // Bold, italic, underline
}Keyboard Shortcuts
Button("Refresh") { refresh() }
.keyboardShortcut("r", modifiers: [.command])
Button("Delete") { delete() }
.keyboardShortcut(.delete, modifiers: [.command])
Button("Select All") { selectAll() }
.keyboardShortcut("a", modifiers: [.command])Follow the HIG: support standard keyboard shortcuts for standard actions (Cmd+C, Cmd+V, Cmd+S, etc.). Only create custom shortcuts when necessary.
Removing Default Commands
// Remove default commands from a specific scene
WindowGroup(id: "detail", for: Item.ID.self) { $itemID in
DetailView(itemID: $itemID)
}
.commandsRemoved() // No "New Window" in File menu for this groupAppKit Menu-Item Image Visibility OS27
For AppKit-built menus: NSMenuItem.preferredImageVisibility (NSMenuItem.ImageVisibility: .automatic default / .visible / .hidden) declares whether an item's image should show. With .automatic, AppKit decides from the system configuration — and it may still override .visible.
---
Context Menus
macOS context menus appear on secondary click (Control-click or right-click). They provide quick access to actions relevant to the clicked item.
struct ArticleRow: View {
@Environment(\.openWindow) var openWindow
let article: Article
var body: some View {
ArticleContent(article: article)
.contextMenu {
Button("Open in New Window") {
openWindow(value: article.id)
}
Button("Duplicate") {
duplicateArticle(article)
}
Divider()
Button("Delete", role: .destructive) {
deleteArticle(article)
}
}
}
}Per the HIG: context menus should contain a small number of frequently used actions directly related to the item. Apply context menus consistently across all instances of the same item type.
---
Focus-Based Command Routing
This is the mechanism that connects the shared menu bar to the correct window. It has two sides: publishing (from the view) and reading (from the commands).
Step 1 — Define Focused Value Keys
Use the @Entry macro (iOS 17+ / macOS 14+) for value types:
extension FocusedValues {
@Entry var document: Document?
@Entry var selectedItems: Set<Item.ID>?
}For binding access (read-write), use @Entry with a Binding:
extension FocusedValues {
@Entry var garden: Binding<Garden>?
}Step 2 — Publish from the View
Use .focusedSceneValue to publish state from within the window:
struct GardenDetail: View {
@Binding var garden: Garden
@State private var selection: Set<Plant.ID> = []
var body: some View {
Table(garden.plants, selection: $selection) { ... }
.focusedSceneValue(\.garden, $garden)
.focusedSceneValue(\.selectedItems, selection)
}
}focusedSceneValue exposes these values whenever any part of this scene's window has focus — not just the table.
Step 3 — Read from Commands
struct GardenCommands: Commands {
@FocusedBinding(\.garden) var garden // Read-write binding
@FocusedValue(\.selectedItems) var selection // Read-only value
var body: some Commands {
CommandMenu("Garden") {
Button("Water Selected") {
guard let plants = selection else { return }
garden?.water(plants)
}
.disabled(selection?.isEmpty ?? true)
}
}
}focusedValue vs focusedSceneValue
| Modifier | Scope | Use When |
|---|---|---|
.focusedSceneValue | Entire window/scene | The value represents window-level state (document, selection) |
.focusedValue | Individual focused view | The value is only meaningful when a specific view has keyboard focus |
Default to `focusedSceneValue`. Use focusedValue only for fine-grained focus tracking like text field state.
---
Common Mistakes
| Mistake | Symptom | Fix |
|---|---|---|
.commands on a View | Compiler error | Move to scene level (WindowGroup/Window) |
No focusedSceneValue published | Menu items always disabled | Add .focusedSceneValue to the publishing view |
Using focusedValue instead of focusedSceneValue | Menu items disable when clicking toolbar/sidebar | Switch to focusedSceneValue |
@FocusedValue without @Entry definition | Compiler error or nil values | Define the key in extension FocusedValues |
| Menu title too long | Truncated in menu bar, looks cluttered | Use short, one-word titles per HIG |
| Hiding unavailable menu items | Users can't discover what's possible | Disable items instead of hiding them per HIG |
| Custom keyboard shortcut conflicts with system | System shortcut overridden silently | Check standard shortcuts before assigning custom ones |
| Commands not appearing | Forgot to add Commands struct to .commands {} | Add all command types in the scene modifier |
---
Resources
WWDC: 2021-10062
Docs: /swiftui/commandmenu, /swiftui/commandgroup, /swiftui/commandgroupplacement, /swiftui/focusedvalues, /swiftui/building-and-customizing-the-menu-bar-with-swiftui, /appkit/nsmenuitem
HIG: The Menu Bar, Menus, Context Menus
Skills: skills/windows.md, skills/appkit-interop.md, skills/appkit-modernization.md
macOS App Sandbox and File Access
When to Use This Skill
Use when:
- Building a new macOS app that will ship on the Mac App Store
- App works in debug but fails in release, TestFlight, or production
- Getting "Operation not permitted" or "sandbox violation" errors
- Implementing file open/save/import workflows on macOS
- Persisting access to user-selected files across app launches
- Preparing a macOS app for App Store review or notarization
- Deciding which sandbox entitlements to request
Related Skills
- Use the distribution skill in this suite for code signing, notarization, and packaging
- Use
axiom-securityfor Keychain, encryption, passkeys, and certificate management - Use
axiom-integrationfor App Groups and inter-process file sharing
Red Flags — Anti-Patterns to Prevent
If you're doing ANY of these, STOP and use the patterns in this skill:
❌ CRITICAL — Never Do These
1. Never testing in a sandboxed environment
// You built and ran from Xcode, it worked, you shipped.
// Then users report "can't open files" or "settings lost."Why this fails: Xcode debug builds do NOT enable the sandbox by default. Your app has full disk access during development. Every file operation that works in debug can silently fail in release. This is the #1 cause of "works on my machine" bugs in macOS development.
2. Hardcoding file paths
// ❌ WRONG — Path differs per user, breaks in sandbox
let configPath = "/Users/charles/Library/Application Support/MyApp/config.json"Why this fails: Sandboxed apps get a container at ~/Library/Containers/<bundle-id>/. Hardcoded paths point outside the container and will be denied. Use FileManager APIs to resolve paths.
3. Forgetting to call stopAccessingSecurityScopedResource()
// ❌ WRONG — Kernel resource leak
let url = try URL(resolvingBookmarkData: data, options: .withSecurityScope,
bookmarkDataIsStale: &isStale)
url.startAccessingSecurityScopedResource()
let contents = try Data(contentsOf: url)
// ... never calls stopAccessingSecurityScopedResource()Why this fails: Apple's documentation warns explicitly: "Failing to properly relinquish access leaks kernel resources, and sufficient kernel resource leaks can prevent your app from accessing file-system locations until relaunching." Every unbalanced startAccessing call leaks a kernel resource. Enough leaks and ALL file access stops working until the user force-quits your app.
4. Storing bookmark data in UserDefaults
// ❌ WRONG — UserDefaults has size limits and sync issues
UserDefaults.standard.set(bookmarkData, forKey: "lastFile")Why this fails: Bookmark data can be several KB. UserDefaults is not designed for binary blobs, has size limits, and syncs unpredictably. Store bookmarks in a dedicated file in your app's container.
5. Assuming fileImporter URLs are permanently accessible
// ❌ WRONG — URL access expires when scope ends
.fileImporter(isPresented: $showImporter, allowedContentTypes: [.pdf]) { result in
self.savedURL = try? result.get().first // Saving URL for later
}
// Later, in a different view lifecycle...
let data = try Data(contentsOf: savedURL!) // May fail — access revokedWhy this fails: URLs from fileImporter are security-scoped. Access is temporary. To use the file later or after relaunch, you must create a security-scoped bookmark immediately in the completion handler.
---
The Sandbox Model
What the Sandbox Does
The App Sandbox is a kernel-level access control system. It restricts your app to:
- Its own container directory (
~/Library/Containers/<bundle-id>/) - Files the user explicitly grants access to (via open/save panels, drag-and-drop)
- Resources declared via entitlements (network, hardware, standard folders)
Everything else is denied at the kernel level. No amount of error handling in your code can work around a sandbox denial — the operation simply fails.
Why Debug Builds Bypass It
Xcode's default debug configuration does NOT sandbox your app. This means:
FileManagercalls succeed on any path- Network connections work without entitlements
- Hardware access works without permission prompts
This is convenient for development but dangerous for shipping. You must test in the sandbox before release.
How to Test in the Sandbox
Method 1: Verify sandbox is active (Activity Monitor)
1. Build and run your app from Xcode 2. Open Activity Monitor 3. View > Columns > Sandbox 4. Check that your app shows "Yes" in the Sandbox column
If it shows "No", your app is not sandboxed in debug. To enable it:
Method 2: Enable sandbox in debug
1. Select your target in Xcode 2. Signing & Capabilities tab 3. Add "App Sandbox" capability if not present 4. The com.apple.security.app-sandbox entitlement is added to your entitlements file
Method 3: Test the release build
# Archive and export for testing
xcodebuild archive -scheme MyApp -archivePath MyApp.xcarchive
xcodebuild -exportArchive -archivePath MyApp.xcarchive \
-exportOptionsPlist ExportOptions.plist -exportPath ./build
# Run the exported app — it will be sandboxedMethod 4: TestFlight
TestFlight builds are always sandboxed. This is your best pre-release validation.
---
File Access Patterns
Decision Tree
Need to access a file?
├─ File is inside your app's container?
│ └─ Just read/write it — no special handling needed
├─ User picks a file interactively?
│ ├─ SwiftUI app?
│ │ └─ Use .fileImporter / .fileExporter
│ └─ AppKit app?
│ └─ Use NSOpenPanel / NSSavePanel
├─ Need to access the same file next launch?
│ └─ Create a security-scoped bookmark (see next section)
├─ Need access to Downloads/Pictures/Music/Movies?
│ └─ Add the specific folder entitlement
├─ Need to share files between your apps?
│ └─ Use App Group container
└─ Need access to arbitrary files?
└─ User must grant Full Disk Access in System Settings
(you cannot request this programmatically)SwiftUI File Import
struct ContentView: View {
@State private var showImporter = false
var body: some View {
Button("Open File") { showImporter = true }
.fileImporter(
isPresented: $showImporter,
allowedContentTypes: [.plainText, .pdf],
allowsMultipleSelection: false
) { result in
switch result {
case .success(let urls):
guard let url = urls.first else { return }
// Access is already started for URLs from fileImporter
defer { url.stopAccessingSecurityScopedResource() }
// Read the file
let data = try? Data(contentsOf: url)
// If you need this file later, bookmark it NOW
saveBookmark(for: url)
case .failure(let error):
// Handle — do not swallow silently
logger.error("File import failed: \(error.localizedDescription)")
}
}
}
}AppKit Open Panel
let panel = NSOpenPanel()
panel.allowedContentTypes = [.plainText]
panel.allowsMultipleSelection = false
panel.begin { response in
guard response == .OK, let url = panel.url else { return }
// Access is already started for URLs from NSOpenPanel
defer { url.stopAccessingSecurityScopedResource() }
let data = try? Data(contentsOf: url)
// Bookmark if needed for future access
saveBookmark(for: url)
}Key Rule
URLs from open panels, save panels, fileImporter, and Dock drag-and-drop all come with security-scoped access already started. You MUST call stopAccessingSecurityScopedResource() when done. If you need the file again later, create a bookmark before stopping access.
---
Security-Scoped Bookmarks
This is the pattern developers get wrong most often. Follow every step.
Entitlement prerequisite. Creating .withSecurityScope bookmarks requires the bookmark entitlement: com.apple.security.files.bookmarks.app-scope for app-scoped bookmarks, com.apple.security.files.bookmarks.document-scope for document-relative ones. If bookmarkData(options: .withSecurityScope) throws, or a resolved bookmark's startAccessingSecurityScopedResource() always returns false despite valid data, a missing bookmark entitlement is the usual cause — check it before debugging the code. (Recent macOS is sometimes lenient and resolves bookmarks without it, but Apple still documents it as required — declare it so you're not depending on undocumented behavior.)
Step 1: Create the Bookmark
Create bookmark data immediately when you have access to the file — typically in the fileImporter completion or NSOpenPanel callback.
func saveBookmark(for url: URL) {
do {
let bookmarkData = try url.bookmarkData(
options: .withSecurityScope,
includingResourceValuesForKeys: nil,
relativeTo: nil
)
// Store in a file, NOT UserDefaults
try bookmarkData.write(to: bookmarkStorageURL)
} catch {
logger.error("Failed to create bookmark for \(url.path): \(error)")
}
}For read-only access on future launches, add .securityScopeAllowOnlyReadAccess:
let bookmarkData = try url.bookmarkData(
options: [.withSecurityScope, .securityScopeAllowOnlyReadAccess],
includingResourceValuesForKeys: nil,
relativeTo: nil
)Step 2: Store the Bookmark
Store bookmark data in a file inside your app's container. A property list or JSON file mapping identifiers to bookmark data works well.
private var bookmarkStorageURL: URL {
FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
.appendingPathComponent("Bookmarks.plist")
}Step 3: Resolve the Bookmark
On next launch, resolve the stored bookmark back to a URL.
func resolveBookmark() -> URL? {
guard let bookmarkData = try? Data(contentsOf: bookmarkStorageURL) else {
return nil
}
var isStale = false
do {
let url = try URL(
resolvingBookmarkData: bookmarkData,
options: .withSecurityScope,
relativeTo: nil,
bookmarkDataIsStale: &isStale
)
// CRITICAL: Refresh stale bookmarks immediately
if isStale {
logger.info("Bookmark is stale, recreating for \(url.path)")
saveBookmark(for: url)
}
return url
} catch {
logger.error("Failed to resolve bookmark: \(error)")
return nil
}
}Step 4: Access the File
func readBookmarkedFile() -> Data? {
guard let url = resolveBookmark() else { return nil }
// MUST call start — resolved bookmarks do NOT auto-start access
guard url.startAccessingSecurityScopedResource() else {
logger.error("Failed to start security-scoped access for \(url.path)")
return nil
}
// MUST call stop — use defer to guarantee it
defer { url.stopAccessingSecurityScopedResource() }
return try? Data(contentsOf: url)
}The Critical Difference
| Source | Access auto-started? | Must call start? | Must call stop? |
|---|---|---|---|
| NSOpenPanel / NSSavePanel | Yes | No | Yes |
| fileImporter / fileExporter | Yes | No | Yes |
| Dock drag-and-drop | Yes | No | Yes |
| Resolved security-scoped bookmark | No | Yes | Yes |
Every source requires stopAccessingSecurityScopedResource(). Resolved bookmarks additionally require startAccessingSecurityScopedResource().
Document-Relative Bookmarks
For project files that reference other files (like an IDE referencing source files), use document-relative bookmarks:
let bookmarkData = try sourceFileURL.bookmarkData(
options: .withSecurityScope,
includingResourceValuesForKeys: nil,
relativeTo: projectDocumentURL // The parent document
)Any process with access to the parent document can resolve these bookmarks.
---
Entitlements
Core Sandbox Entitlement
com.apple.security.app-sandbox — Required for Mac App Store. Enables the sandbox. Without other entitlements, your app can only access its own container.
File Access Entitlements
| Entitlement | Grants |
|---|---|
com.apple.security.files.user-selected.read-only | Read files the user picks via open panel |
com.apple.security.files.user-selected.read-write | Read/write files the user picks |
com.apple.security.files.user-selected.executable | Write executables to user-selected locations |
com.apple.security.files.bookmarks.app-scope | Create + resolve app-scoped security-scoped bookmarks (persist access across launches) |
com.apple.security.files.bookmarks.document-scope | Create + resolve document-scoped bookmarks (a parent document referencing child files) |
com.apple.security.files.downloads.read-only | Read the Downloads folder |
com.apple.security.files.downloads.read-write | Read/write the Downloads folder |
com.apple.security.files.pictures.read-only | Read the Pictures folder |
com.apple.security.files.pictures.read-write | Read/write the Pictures folder |
com.apple.security.files.music.read-only | Read the Music folder |
com.apple.security.files.music.read-write | Read/write the Music folder |
com.apple.security.files.movies.read-only | Read the Movies folder |
com.apple.security.files.movies.read-write | Read/write the Movies folder |
com.apple.security.files.all | Access all files (rarely approved for App Store) |
Network Entitlements
| Entitlement | Grants |
|---|---|
com.apple.security.network.client | Outgoing network connections |
com.apple.security.network.server | Incoming network connections |
Other Common Entitlements
| Entitlement | Grants |
|---|---|
com.apple.security.device.camera | Camera access |
com.apple.security.device.audio-input | Microphone access |
com.apple.security.device.usb | USB device access |
com.apple.security.print | Printing |
com.apple.security.personal-information.addressbook | Contacts |
com.apple.security.personal-information.calendars | Calendar |
com.apple.security.personal-information.location | Location services |
com.apple.security.application-groups | Shared container between apps |
Temporary Exception Entitlements
Temporary exceptions (com.apple.security.temporary-exception.*) grant broader access than standard entitlements. They exist for migrating legacy apps to the sandbox.
What App Review expects: Temporary exceptions require justification. Apple may reject apps that use them without a clear migration path toward removing them. If you're building a new app, avoid temporary exceptions entirely. Use standard entitlements and user-interaction-based file access instead.
Principle of Least Privilege
Request only what you need. An app requesting files.all when it only opens user-selected documents will face App Review scrutiny. Start with the minimum (files.user-selected.read-write) and add entitlements only when a feature requires them.
---
Diagnosing Sandbox Violations
Step 1: Check Console.app
Open Console.app and apply these filters:
- Subsystem:
com.apple.sandbox.reporting - Category:
violation - Type: Error
This shows detailed violation reports including the process name, the denied operation, and a stack trace.
Step 2: Check Xcode Debug Output
When running from Xcode with sandbox enabled, watch the Debug area for messages like:
Sandbox is preventing this process from reading networkd settings fileStep 3: Use Quinn's Diagnostic Approach
Quinn "The Eskimo!" from Apple DTS recommends this flow for macOS access issues:
1. Confirm the problem is actually the sandbox — Other access controls (POSIX permissions, TCC/Privacy, Full Disk Access) can also deny operations. A sandbox violation always appears in Console with com.apple.sandbox.reporting.
2. Check for missing entitlements — Compare your entitlements file against what the operation requires.
3. Check for stale bookmarks — If accessing a previously bookmarked file, the bookmark may be stale (file moved/renamed). Check the bookmarkDataIsStale flag.
4. Check the code signing identity — macOS 14+ associates sandbox containers with code signatures. Different signatures (debug vs release, different teams) trigger permission prompts or denials.
Common Violation Messages
| Message Pattern | Likely Cause | Fix |
|---|---|---|
deny(1) file-read-data | Reading file outside sandbox | Use open panel or add entitlement |
deny(1) file-write-data | Writing file outside sandbox | Use save panel or add entitlement |
deny(1) network-outbound | Outgoing network without entitlement | Add network.client entitlement |
deny(1) network-inbound | Listening without entitlement | Add network.server entitlement |
deny(1) mach-lookup | IPC with system service | May need temporary exception or redesign |
Enable Verbose Logging
For deeper investigation:
log stream --predicate "subsystem == 'com.apple.sandbox.reporting'" --level debug---
Common Mistakes
| Mistake | Symptom | Fix |
|---|---|---|
| Not testing in sandbox | Works in debug, crashes in release | Enable sandbox in debug OR test release/TestFlight builds |
Not calling stopAccessingSecurityScopedResource() | File access stops working after many open/close cycles | Always use defer to balance every start with stop |
| Storing bookmarks in UserDefaults | Bookmark data lost or corrupted | Store in dedicated file in app container |
Not checking bookmarkDataIsStale | Access fails after file is moved/renamed | Check flag and recreate bookmark when stale |
Calling startAccessing on panel URLs | Wasted call (harmless but confusing) | Panel URLs have access auto-started; just call stop |
Not calling startAccessing on resolved bookmarks | Access denied despite valid bookmark | Resolved bookmarks require explicit startAccessing |
Hardcoding ~/Library/... paths | Path denied in sandbox | Use FileManager.urls(for:in:) or container paths |
Requesting files.all unnecessarily | App Review rejection | Use files.user-selected.read-write + bookmarks |
Forgetting network.client entitlement | All network requests fail silently | Add outgoing connection entitlement |
| Different code signing in debug vs release | Container permission prompt on launch | Use consistent team ID; expect prompts during development |
---
Resources
WWDC: 2022-10096, 2023-10053, 2024-10123
Docs: /security/app-sandbox, /security/accessing-files-from-the-macos-app-sandbox, /security/discovering-and-diagnosing-app-sandbox-violations, /xcode/configuring-the-macos-app-sandbox
Forum Posts: App Sandbox Resources (Quinn/DTS), Resolving Trusted Execution Problems, The Case for Sandboxing a Directly Distributed App
Skills: axiom-security, distribution (this suite)
ScreenCaptureKit — API Reference
Comprehensive API reference for ScreenCaptureKit: content enumeration, filtering, configuration, streaming, the system picker, screenshots, and file recording. For the discipline (pipeline, threading, consent, gotchas), see skills/screencapturekit.md.
Key Terminology
- SCShareableContent — Snapshot of capturable displays, windows, and apps.
- SCContentFilter — What to capture (a window, or a display with inclusions/exclusions).
- SCStreamConfiguration — How to capture (resolution, fps, audio, cursor, color, HDR).
- SCStream — The live capture session; emits
CMSampleBuffers to outputs. - SCStreamOutput — Protocol receiving sample buffers on a queue you supply.
- SCContentSharingPicker — System selection UI that hands back an
SCContentFilter(macOS 14+). - SCScreenshotManager — One-shot frame capture (macOS 14+).
- SCRecordingOutput — Records a stream straight to a file (macOS 15+).
---
Part 1: Enumerating content (SCShareableContent)
// Async (preferred)
let content = try await SCShareableContent.current
let content2 = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
content.displays // [SCDisplay]
content.windows // [SCWindow]
content.applications // [SCRunningApplication]- SCDisplay:
displayID,width,height,frame. - SCWindow:
windowID,frame,title,isOnScreen,isActive,owningApplication(SCRunningApplication?),windowLayer. - SCRunningApplication:
bundleIdentifier,applicationName,processID.
---
Part 2: Content filters (SCContentFilter)
// Display-independent: follow one window across displays
SCContentFilter(desktopIndependentWindow: window)
// Display-dependent: whole display, excluding apps/windows
SCContentFilter(display: display, excludingApplications: [myApp], exceptingWindows: [])
// Display-dependent: only specific windows
SCContentFilter(display: display, including: [window1, window2])
filter.contentRect // CGRect of captured content
filter.pointPixelScale // Float backing scale
filter.streamType // SCStreamTypeAudio is filtered only at the application level, never per-window.
---
Part 3: Stream configuration (SCStreamConfiguration)
let config = SCStreamConfiguration()
// Video
config.width = 3840
config.height = 2160
config.minimumFrameInterval = CMTime(value: 1, timescale: 60) // fps cap
config.pixelFormat = kCVPixelFormatType_32BGRA
config.colorSpaceName = CGColorSpace.sRGB
config.showsCursor = true
config.scalesToFit = true
config.queueDepth = 5 // in-flight frame buffers (memory vs. smoothness)
config.capturesShadowsOnly = false
// Audio (macOS 13+)
config.capturesAudio = true
config.sampleRate = 48_000
config.channelCount = 2
config.excludesCurrentProcessAudio = true
// macOS 15+
config.captureMicrophone = true
config.microphoneCaptureDeviceID = nil
config.captureDynamicRange = .hdrLocalDisplay // .sdr | .hdrLocalDisplay | .hdrCanonicalDisplay
config.showMouseClicks = true---
Part 4: The stream (SCStream)
let stream = SCStream(filter: filter, configuration: config, delegate: streamDelegate)
try stream.addStreamOutput(output, type: .screen, sampleHandlerQueue: videoQueue)
try stream.addStreamOutput(output, type: .audio, sampleHandlerQueue: audioQueue)
try stream.addStreamOutput(output, type: .microphone, sampleHandlerQueue: micQueue) // macOS 15+
try await stream.startCapture()
try await stream.stopCapture()
// Hot updates — no restart
try await stream.updateConfiguration(newConfig)
try await stream.updateContentFilter(newFilter)SCStreamOutput
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer,
of type: SCStreamOutputType) { /* .screen | .audio | .microphone */ }SCStreamDelegate
func stream(_ stream: SCStream, didStopWithError error: Error)
func outputEffectDidStart(for stream: SCStream) // Presenter Overlay began (macOS 14+)
func outputEffectDidStop(for stream: SCStream)Frame attachments (SCStreamFrameInfo)
let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false)
as? [[SCStreamFrameInfo: Any]]
// keys: .status, .displayTime, .scaleFactor, .contentRect, .dirtyRects, .contentScale
// status value is an SCFrameStatus: .complete, .idle, .blank, .suspended, .started, .stoppedUse only .complete frames; .idle carries no new IOSurface.
---
Part 5: System picker (SCContentSharingPicker, macOS 14+)
let picker = SCContentSharingPicker.shared
picker.add(observer) // SCContentSharingPickerObserver
picker.isActive = true
picker.maximumStreamCount = 1
var config = SCContentSharingPickerConfiguration()
config.allowedPickerModes = [.singleWindow, .multipleWindows, .singleApplication,
.multipleApplications, .singleDisplay]
config.excludedWindowIDs = []
config.excludedBundleIDs = []
config.allowsChangingSelectedContent = true
picker.defaultConfiguration = config // applies to all
picker.setConfiguration(config, for: stream) // per-stream override
picker.present() // also present(for:) / present(using:) / present(for:using:)Observer callbacks
func contentSharingPicker(_ picker: SCContentSharingPicker,
didUpdateWith filter: SCContentFilter, for stream: SCStream?)
func contentSharingPicker(_ picker: SCContentSharingPicker, didCancelFor stream: SCStream?)
func contentSharingPickerStartDidFailWithError(_ error: Error)---
Part 6: Screenshots (SCScreenshotManager, macOS 14+)
// CGImage
let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config)
// CMSampleBuffer (more pixel formats)
let buffer = try await SCScreenshotManager.captureSampleBuffer(contentFilter: filter, configuration: config)Class methods — no instance needed. Reuses the same SCContentFilter / SCStreamConfiguration as streaming.
---
Part 7: File recording (SCRecordingOutput, macOS 15+)
let recConfig = SCRecordingOutputConfiguration()
recConfig.outputURL = url
recConfig.outputFileType = .mov // AVFileType
recConfig.videoCodecType = .h264 // AVVideoCodecType
let recording = SCRecordingOutput(configuration: recConfig, delegate: recDelegate)
try stream.addRecordingOutput(recording)
// recording.recordedDuration, recording.recordedFileSize
try stream.removeRecordingOutput(recording)SCRecordingOutputDelegate
func recordingOutputDidStartRecording(_ recordingOutput: SCRecordingOutput)
func recordingOutput(_ recordingOutput: SCRecordingOutput, didFailWithError error: Error)
func recordingOutputDidFinishRecording(_ recordingOutput: SCRecordingOutput)---
Part 8: Permissions and migration
- Screen Recording TCC is mandatory;
SCShareableContentis empty until granted. - Persistent Content Capture entitlement for login-item/background capturers (VNC, remote desktop).
- Presenter Overlay is automatic for any ScreenCaptureKit + camera app; observe
outputEffectDidStart.
| Deprecated | Replacement |
|---|---|
CGDisplayStream | SCStream |
CGWindowListCreateImage | SCScreenshotManager.captureImage(contentFilter:configuration:) |
AVCaptureScreenInput (superseded, not deprecated) | SCStream |
---
Resources
WWDC: 2022-10156, 2022-10155, 2023-10136, 2024-10088
Docs: /screencapturekit, /screencapturekit/scshareablecontent, /screencapturekit/sccontentfilter, /screencapturekit/scstreamconfiguration, /screencapturekit/scstream, /screencapturekit/scstreamoutput, /screencapturekit/sccontentsharingpicker, /screencapturekit/scscreenshotmanager, /screencapturekit/screcordingoutput
Skills: skills/screencapturekit.md, skills/sandbox-and-file-access.md, axiom-media (ReplayKit, CMSampleBuffer), axiom-concurrency (serial queues, async)
ScreenCaptureKit — Screen Recording & Sharing
ScreenCaptureKit is the modern, GPU-accelerated, privacy-gated framework for capturing macOS screen content — displays, windows, applications, and their audio — as a live stream, a one-shot screenshot, or a recorded file. It replaces the deprecated CGDisplayStream and CGWindowListCreateImage, and supersedes AVCaptureScreenInput.
Core mental model
Capture is a four-stage pipeline:
1. Enumerate — SCShareableContent lists the displays, windows, and apps you can capture. 2. Filter — SCContentFilter narrows that to exactly what to capture (one window, a whole display minus your own app, etc.). 3. Configure — SCStreamConfiguration sets resolution, frame rate, audio, cursor, color, HDR. 4. Stream — SCStream (filter + configuration + delegate) delivers CMSampleBuffers to an SCStreamOutput on a queue you provide.
Filters and configurations can be swapped on the fly without tearing down the stream. For consent, prefer the system SCContentSharingPicker (macOS 14+) over building your own selection UI.
When to Use This Skill
- Building screen sharing, recording, or streaming (conferencing, OBS-style capture, demos)
- Capturing a specific window or display, with or without audio
- Taking a high-quality programmatic screenshot
- Recording screen content straight to a file (macOS 15+)
- Migrating off
CGDisplayStream/CGWindowListCreateImage/AVCaptureScreenInput
This is macOS only. For iOS screen capture, use ReplayKit (RPScreenRecorder / broadcast extensions) — see axiom-media. For the full type/property surface, see skills/screencapturekit-ref.md. For sandbox/entitlement details, see skills/sandbox-and-file-access.md.
System Requirements
| API | Availability |
|---|---|
SCStream, SCShareableContent, SCContentFilter, SCStreamConfiguration, SCStreamOutput | macOS |
SCContentSharingPicker, SCScreenshotManager, Presenter Overlay (outputEffectDidStart) | macOS |
SCRecordingOutput, microphone capture (captureMicrophone), HDR (captureDynamicRange) | macOS 15.0+ |
| Mac Catalyst | 18.2+ |
| iOS / iPadOS | Not available — use ReplayKit |
Screen capture requires the user's Screen Recording permission (TCC). Without it, SCShareableContent returns no shareable content. A background/login-item capturer (VNC, remote desktop) additionally needs the Persistent Content Capture entitlement.
Critical Gotchas
| Gotcha | Why it bites | Fix |
|---|---|---|
| Building for iOS | ScreenCaptureKit is macOS-only | Use ReplayKit on iOS |
| No frames ever arrive | Screen Recording permission not granted | SCShareableContent is empty without TCC consent — request it and handle the empty case |
| UI hitches / dropped frames | Heavy work on the sample-handler queue | Pass a dedicated serial DispatchQueue; copy what you need and return fast |
| Memory balloons or the stream stalls | Holding IOSurface-backed buffers past queueDepth | Process and release each CMSampleBuffer promptly; tune queueDepth |
| Reinventing the consent UI | Misses system integration (Video menu bar, Presenter Overlay) | Use SCContentSharingPicker (macOS 14+) |
| Treating idle frames as new content | .idle frame status means no new IOSurface | Read SCStreamFrameInfo.status; skip .idle |
| "Hall of mirrors" recursion | Capturing your own window inside a display filter | Exclude your app in the SCContentFilter |
Part 1 — The capture pipeline
import ScreenCaptureKit
// 1. Enumerate
let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
guard let display = content.displays.first else { return }
// 2. Filter — capture the display, excluding our own app (avoid the hall of mirrors)
let myApp = content.applications.first { $0.bundleIdentifier == Bundle.main.bundleIdentifier }
let filter = SCContentFilter(display: display,
excludingApplications: myApp.map { [$0] } ?? [],
exceptingWindows: [])
// 3. Configure
let config = SCStreamConfiguration()
config.width = 1920
config.height = 1080
config.minimumFrameInterval = CMTime(value: 1, timescale: 60) // 60 fps
config.showsCursor = true
config.capturesAudio = true
config.queueDepth = 5 // buffered frames
// 4. Stream
let stream = SCStream(filter: filter, configuration: config, delegate: self) // self: SCStreamDelegate
try stream.addStreamOutput(self, type: .screen,
sampleHandlerQueue: DispatchQueue(label: "capture.video"))
try await stream.startCapture()SCShareableContent exposes .displays (SCDisplay), .windows (SCWindow), and .applications (SCRunningApplication), all read-only metadata. Audio can only be filtered at the application level, not per-window.
Part 2 — Get consent right (macOS 14+)
Don't build your own picker. SCContentSharingPicker gives you the system selection UI, the Video menu-bar item, Presenter Overlay, and per-stream re-picking for free. It hands you an SCContentFilter via an observer callback.
let picker = SCContentSharingPicker.shared
picker.add(self) // self: SCContentSharingPickerObserver
picker.isActive = true // register so the system includes your app
var pickerConfig = SCContentSharingPickerConfiguration()
pickerConfig.allowedPickerModes = [.singleWindow, .singleApplication, .singleDisplay]
picker.defaultConfiguration = pickerConfig
picker.present()
// Observer callback — you receive a ready-made filter
func contentSharingPicker(_ picker: SCContentSharingPicker,
didUpdateWith filter: SCContentFilter,
for stream: SCStream?) {
// create a new stream with `filter`, or `stream?.updateContentFilter(filter)`
}Also implement the cancel and fail callbacks so your stream state stays correct.
Part 3 — The output callback and threading
Samples arrive on the serial queue you provided. Check the frame status before using a video frame.
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer,
of type: SCStreamOutputType) {
switch type {
case .screen:
guard let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer,
createIfNecessary: false) as? [[SCStreamFrameInfo: Any]],
let raw = attachments.first?[.status] as? Int,
let status = SCFrameStatus(rawValue: raw),
status == .complete else { return } // skip .idle / .blank / .suspended
// sampleBuffer is IOSurface-backed — use it now, don't retain it
case .audio:
// PCM audio CMSampleBuffer
default:
break
}
}Keep this callback fast. Long work here back-pressures the capture pipeline and drops frames. Never retain video buffers beyond queueDepth — they hold IOSurfaces from a fixed pool.
Part 4 — Hot updates
Change what or how you capture without restarting the stream:
try await stream.updateConfiguration(newConfig) // resolution, fps, audio toggle
try await stream.updateContentFilter(newFilter) // switch window/display/exclusionsThis is the whole point of the framework's design — adjust quality on the fly (e.g. drop resolution and raise fps when motion increases) instead of stopping and recreating the stream.
Part 5 — Screenshots (macOS 14+)
For a single frame, skip the stream entirely. SCScreenshotManager reuses the same filter + configuration.
let image = try await SCScreenshotManager.captureImage(contentFilter: filter,
configuration: config) // CGImage
// or captureSampleBuffer(contentFilter:configuration:) for a CMSampleBuffer with more pixel formatsThis replaces CGWindowListCreateImage — the window-image options it had now live on SCStreamConfiguration, and "windows above ID" enumeration lives on SCShareableContent.
Part 6 — Recording to a file (macOS 15+)
SCRecordingOutput records the stream straight to a movie file — no manual AVAssetWriter plumbing.
let recordingConfig = SCRecordingOutputConfiguration()
recordingConfig.outputURL = outputURL
recordingConfig.outputFileType = .mov
let recording = SCRecordingOutput(configuration: recordingConfig, delegate: self) // SCRecordingOutputDelegate
try stream.addRecordingOutput(recording)
try await stream.startCapture()
// recording.recordedDuration / recording.recordedFileSize while runningPart 7 — Migration
| Deprecated API | Replacement |
|---|---|
CGDisplayStream | SCStream with a display SCContentFilter |
CGWindowListCreateImage | SCScreenshotManager.captureImage(contentFilter:configuration:) |
AVCaptureScreenInput (superseded, not deprecated) | SCStream |
Manual AVAssetWriter for screen recording | SCRecordingOutput (macOS 15+) |
Common Mistakes
- Shipping screen capture on iOS — that's ReplayKit, not ScreenCaptureKit.
- Not handling the empty
SCShareableContentcase when Screen Recording permission is denied. - Doing real work (encoding, disk I/O, UI updates) directly on the sample-handler queue.
- Retaining IOSurface-backed video buffers — exhausts the pool and stalls capture.
- Ignoring
SCStreamFrameInfo.statusand processing.idleframes as if they were new. - Capturing your own app's window inside a display filter (hall of mirrors) instead of excluding it.
- Hand-rolling a selection UI instead of
SCContentSharingPicker.
Resources
WWDC: 2022-10156, 2022-10155, 2023-10136, 2024-10088
Docs: /screencapturekit, /screencapturekit/scstream, /screencapturekit/scshareablecontent, /screencapturekit/sccontentfilter, /screencapturekit/scstreamconfiguration, /screencapturekit/sccontentsharingpicker, /screencapturekit/scscreenshotmanager, /screencapturekit/screcordingoutput
Skills: skills/screencapturekit-ref.md, skills/sandbox-and-file-access.md (TCC, entitlements), axiom-media (ReplayKit for iOS, CMSampleBuffer handling), axiom-concurrency (async sequences, serial queues)
macOS Settings Scene
When to Use This Skill
Use when:
- Adding the standard macOS Preferences window (the one ⌘, opens) to a SwiftUI app
- Organizing preferences into tabbed panes
- Sizing the Settings window correctly per macOS HIG
- Using
SettingsLinkto open Settings programmatically from the app - Persisting preferences with
@AppStorage/UserDefaults - Sharing one codebase across iOS and macOS where macOS needs a Settings scene
- Implementing the iOS counterpart: opening the system Settings app for the current app's prefs
Related Skills
- Use
skills/windows.mdfor WindowGroup, Window, MenuBarExtra, and other Scene types - Use
skills/menus-and-commands.mdfor the Settings menu item placement, keyboard shortcuts - Use
skills/sandbox-and-file-access.mdfor where preference defaults are stored under sandbox - Use axiom-swiftui (skills/architecture.md) for
@AppStorageand overall state architecture
Red Flags — Anti-Patterns to Prevent
| Symptom | Cause | Fix |
|---|---|---|
| Settings menu item missing from app menu | No Settings { } scene declared | Add a Settings scene to the App body (macOS only) |
| iOS build fails: "'Settings' is unavailable" | Settings { } declared without #if os(macOS) | Wrap in #if os(macOS) for cross-platform apps |
| Settings window opens at tiny size | No .frame() constraint | Set .frame(width: 450, minHeight: 200) (or per-tab if sizes differ) |
| Layout cramped against window edge | Missing .scenePadding() | Add .scenePadding() to the root SettingsView |
| Settings window resizable when it shouldn't be | macOS auto-allows resize when content is flexible | Set fixed .frame(width:height:) per tab (no min/max) |
| Tab icons missing | Forgot systemImage: on Tab | Add SF Symbols to every tab — required by macOS HIG |
| Tabs jump in size when switched | Each tab has different intrinsic content size | Set the same .frame() on every tab body, OR let tabs resize via .frame(idealWidth:idealHeight:) |
SettingsLink in iOS code | Used SettingsLink thinking it opens the iOS system Settings app | macOS-only — opens the app's own Settings scene. Use UIApplication.openSettingsURLString for iOS system Settings |
| Settings UI shows no values | @AppStorage keys differ from where the rest of the app reads them | Centralize keys in a PreferenceKey enum; never hardcode strings twice |
---
The Settings Scene
Settings is a macOS-only Scene type (macOS 11+) that declares the standard Preferences window. SwiftUI wires up the Settings menu item and ⌘, keyboard shortcut automatically — you supply the content view.
Minimal declaration
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
#if os(macOS)
Settings {
SettingsView()
}
#endif
}
}Why `#if os(macOS)` Settings does not exist on iOS, iPadOS, watchOS, or tvOS. Without the conditional, cross-platform apps fail to compile on non-Mac targets.
---
Pattern 1: Single-Pane Settings
For apps with few preferences (a handful of toggles, no logical grouping needed).
struct SettingsView: View {
@AppStorage("showPreviews") private var showPreviews = true
@AppStorage("autoSaveInterval") private var autoSaveInterval = 30.0
var body: some View {
Form {
Toggle("Show previews on hover", isOn: $showPreviews)
Stepper(
"Auto-save every \(Int(autoSaveInterval)) seconds",
value: $autoSaveInterval,
in: 10...300,
step: 10
)
}
.formStyle(.grouped)
.scenePadding()
.frame(width: 450, height: 180)
}
}Sizing rationale Fixed width: 450, height: 180. macOS HIG: preferences windows are typically not user-resizable. A fixed frame produces a window that opens consistently and cannot be dragged to weird sizes. Choose the size that fits the content snugly.
---
Pattern 2: Tabbed Settings (General + Advanced)
The standard pattern for any Settings UI with > ~5 preferences. Each tab is a focused subset.
struct SettingsView: View {
var body: some View {
TabView {
GeneralSettings()
.tabItem { Label("General", systemImage: "gear") }
AppearanceSettings()
.tabItem { Label("Appearance", systemImage: "paintbrush") }
AdvancedSettings()
.tabItem { Label("Advanced", systemImage: "slider.horizontal.3") }
}
.scenePadding()
.frame(width: 450, height: 280)
}
}
struct GeneralSettings: View {
@AppStorage("openAtLogin") private var openAtLogin = false
@AppStorage("showInDock") private var showInDock = true
var body: some View {
Form {
Toggle("Open at login", isOn: $openAtLogin)
Toggle("Show in Dock", isOn: $showInDock)
}
.formStyle(.grouped)
}
}Tab icon convention Every tab must have a systemImage: (SF Symbol). macOS HIG mandates icons on preference tabs — text-only tabs look unfinished.
Frame placement The frame goes on the TabView, not on each tab body. This locks the window to one size across all tabs. If different tabs need different sizes, see Pattern 4.
---
Pattern 3: macOS-Only Settings in a Cross-Platform App
A typical iOS+macOS app: most code is shared, Settings exists only on macOS.
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
#if os(macOS)
Settings {
SettingsView()
}
#endif
}
}
#if os(macOS)
struct SettingsView: View {
var body: some View {
TabView { ... }
.scenePadding()
.frame(width: 450, height: 280)
}
}
#endifWhy wrap the View definition too If SettingsView references macOS-only types (Settings, SettingsLink, certain modifiers), the file must be conditionally compiled. Otherwise the iOS build fails on the non-conditional view declaration.
iOS users still need preferences — they go in the app's main UI (a sheet, a navigation route, a Settings tab in TabView), not in a Settings { } scene. See Pattern 6 for opening the iOS system Settings app.
---
Pattern 4: Per-Tab Sizing
When tabs have legitimately different content sizes, attach the frame to each tab body and let the window resize.
TabView {
GeneralSettings()
.frame(width: 450, height: 200)
.tabItem { Label("General", systemImage: "gear") }
LongPreferenceList()
.frame(width: 450, height: 500)
.tabItem { Label("Library", systemImage: "books.vertical") }
}
.scenePadding()Trade-off Per-tab sizing means the window animates between sizes when the user switches tabs. Smooth on modern Macs but visible. If you want stable sizing, pick the largest needed size and pad shorter tabs.
---
Pattern 5: SettingsLink (macOS)
SettingsLink opens the app's own Settings scene from anywhere in the UI — useful for "Open Settings" buttons in onboarding, error states, or inline help.
struct WelcomeView: View {
var body: some View {
VStack {
Text("Welcome to MyApp")
Text("Configure your preferences to get started.")
SettingsLink {
Label("Open Settings", systemImage: "gear")
}
.buttonStyle(.borderedProminent)
}
}
}Default label SettingsLink() with no closure produces a system-styled "Settings…" button.
What it does NOT do SettingsLink does not exist on iOS and does not open the iOS system Settings app. It is purely a shortcut to the app's own Settings { } scene (macOS only).
Programmatic alternative @Environment(\.openSettings) gives an openSettings() action you can call from a button handler — use it when you need a plain button rather than SettingsLink's link semantics.
---
Pattern 6: iOS Adjacency — Opening System Settings
Different concept, often confused with SettingsLink. On iOS/iPadOS, your app cannot define a Settings scene, but you can deep-link to the system Settings app showing your app's bundled preferences.
import UIKit
struct PermissionDeniedView: View {
@Environment(\.openURL) private var openURL
var body: some View {
Button("Open Settings") {
if let url = URL(string: UIApplication.openSettingsURLString) {
openURL(url)
}
}
}
}Where this opens The iOS Settings app, scrolled to the section for the current app. Useful for permission denial flows ("camera access denied → user must enable in Settings").
Two different macOS targets — do not conflate them. "Open settings" means different things, and SettingsLink only covers one of them:
| Goal | iOS | macOS |
|---|---|---|
| Open the app's own preferences | (no Settings scene — show in-app UI) | SettingsLink / openSettings |
| Re-enable a denied system permission (camera, mic, location) | UIApplication.openSettingsURLString → System Settings | x-apple.systempreferences: deep link → System Settings |
SettingsLink opens the app's own Settings window — it is not the macOS equivalent of iOS's openSettingsURLString. For a permission-denial flow on macOS, open System Settings directly:
// macOS: deep-link System Settings to the Camera privacy pane.
// Swap the anchor for the permission: Privacy_Camera, Privacy_Microphone,
// Privacy_LocationServices, etc.
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera") {
NSWorkspace.shared.open(url)
}Cross-platform unification A single OpenSettingsButton is only correct when "settings" means the app's own preferences:
struct OpenSettingsButton: View {
var body: some View {
#if os(macOS)
SettingsLink { Label("Settings…", systemImage: "gear") } // app's prefs only
#else
OpenSystemSettingsButton() // system Settings
#endif
}
}For a permission-denial CTA, branch to the System Settings deep link on both platforms instead.
---
Pattern 7: Persistence with @AppStorage
The standard SwiftUI way to persist preferences. Backed by UserDefaults under the hood.
// Centralize keys to avoid string drift
enum PreferenceKey {
static let showPreviews = "showPreviews"
static let autoSaveInterval = "autoSaveInterval"
static let theme = "theme"
}
struct GeneralSettings: View {
@AppStorage(PreferenceKey.showPreviews) private var showPreviews = true
@AppStorage(PreferenceKey.autoSaveInterval) private var autoSaveInterval = 30.0
var body: some View {
Form {
Toggle("Show previews", isOn: $showPreviews)
Stepper("Auto-save: \(Int(autoSaveInterval))s", value: $autoSaveInterval, in: 10...300, step: 10)
}
}
}
// Other parts of the app read the same key
struct DocumentView: View {
@AppStorage(PreferenceKey.showPreviews) private var showPreviews = true
// ...
}Why centralize keys @AppStorage("showPreviews") in two files with one typo'd as "showPreview" produces a silent split: the Settings UI updates one key, the rest of the app reads the other. Keys live in one enum.
Shared groups (App Group container) If preferences need to sync across an app + extension (Widget, Share Extension), use @AppStorage(_:store:) with a shared UserDefaults(suiteName:). See skills/sandbox-and-file-access.md for the App Group container setup.
---
Anti-Patterns (DO NOT DO THIS)
❌ Using Settings without #if os(macOS) in a cross-platform app
// Compiles on macOS, fails on iOS with "'Settings' is unavailable"
var body: some Scene {
WindowGroup { ContentView() }
Settings { SettingsView() }
}Fix Wrap in #if os(macOS) ... #endif.
❌ Confusing SettingsLink with iOS system Settings
// On iOS this won't compile; on Mac Catalyst it opens the Mac-style Settings, not iOS Settings
SettingsLink { Label("Settings", systemImage: "gear") }Fix SettingsLink is macOS-only and opens the app's own Settings { } scene. For the iOS system Settings app, use UIApplication.openSettingsURLString (Pattern 6).
❌ Resizable Settings window for static content
// No frame → window resizes freely, looks weird
SettingsView()Fix Add .frame(width:height:) for fixed sizing, or .frame(width: 450, minHeight: 200) for vertical-only resize on long preference lists.
❌ Tab labels without icons
// macOS HIG violation; tabs render text-only and look unfinished
.tabItem { Text("General") }Fix .tabItem { Label("General", systemImage: "gear") }. Pick SF Symbols that match the tab's category.
❌ String-keyed @AppStorage scattered across files
// SettingsView.swift
@AppStorage("show_previews") private var showPreviews = true
// DocumentView.swift
@AppStorage("showPreviews") private var showPreviews = true // Typo — different key!Fix Define keys in a central enum (Pattern 7).
❌ Settings as a side door for app logic
// SettingsView containing business logic, network calls, side effects
struct SettingsView: View {
@State var users: [User] = []
var body: some View {
Form {
Button("Refresh users") {
Task { users = try await fetchUsers() }
}
}
}
}Fix SettingsView is for displaying and toggling preferences. Business logic belongs in the app's main flow. If a setting triggers behavior, the behavior listens to the @AppStorage change elsewhere.
---
Common Mistakes — Quick Reference
| Symptom | Most Likely Cause |
|---|---|
'Settings' is unavailable on iOS | Missing #if os(macOS) |
| Settings menu item not in app menu | No Settings { } scene declared (or hidden by another scene's commands) |
| Window opens too small | No .frame() on SettingsView root |
| Window resizes weirdly | Frame uses min / max instead of fixed values |
| Tab content cramped | Missing .scenePadding() |
| Tabs render without icons | Used Text instead of Label for .tabItem |
| ⌘, doesn't open Settings | App is sandboxed AND another window is keyWindow with conflicting shortcut |
@AppStorage doesn't sync to other view | Different key strings (typo) — use central enum |
SettingsLink causes iOS build failure | SettingsLink is macOS-only; needs #if os(macOS) |
| Settings opens but is empty | View body has if/switch returning EmptyView for current state |
---
Code Review Checklist
Before merging Settings code:
- [ ]
Settings { }is wrapped in#if os(macOS)(cross-platform apps) - [ ]
SettingsViewitself is wrapped in#if os(macOS)if it references macOS-only APIs - [ ] Root view has
.scenePadding()AND a.frame()constraint - [ ] All
Tablabels useLabel("Title", systemImage: "...")— never text-only - [ ]
@AppStoragekeys come from a central enum, not inline string literals - [ ]
SettingsLinkis wrapped in#if os(macOS)(it does not exist on iOS) - [ ] iOS code that opens system Settings uses
UIApplication.openSettingsURLStringvia@Environment(\.openURL)— notSettingsLink - [ ] No business logic / network calls / heavy state in SettingsView
- [ ] Keys persisted via
@AppStorageuse App GroupUserDefaults(suiteName:)if shared with extensions
---
Resources
WWDC: 2020-10119, 2022-10059, 2023-10148
Docs: /swiftui/settings, /swiftui/settingslink, /swiftui/scene, /uikit/uiapplication/opensettingsurlstring, /swiftui/appstorage
Skills: axiom-macos (skills/windows.md), axiom-macos (skills/menus-and-commands.md), axiom-macos (skills/sandbox-and-file-access.md), axiom-swiftui (skills/architecture.md)
Related skills
How it compares
Use axiom-macos instead of generic SwiftUI skills when the target is macOS windows, menus, sandbox, or AppKit—not shared iOS UI patterns.
FAQ
What macOS topics does axiom-macos cover?
axiom-macos covers macOS window management, menu bars and commands, sandboxing, distribution and notarization, AppKit bridging, and SwiftUI platform differences including NavigationSplitView, Table, and Inspector layouts.
Is axiom-macos required for Mac development in Axiom?
axiom-macos is marked mandatory for any macOS-specific work in the Axiom skill set—windows, menus, sandbox, distribution, AppKit bridging, and macOS SwiftUI patterns should route through this skill.
Is Axiom Macos safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.