macOS: Windows, Menus, Commands, Settings and Menu Bar Extras
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
A Mac app lives in a different environment from its iPad sibling. People keep it open for hours next to a dozen
other apps, drive it with a keyboard and a precise pointer, expect every command to be in the menu bar, arrange its
windows across several displays, and expect it to come back exactly as they left it after a restart. This page
turns FieldNotes' Mac build into that kind of app, targeting the baseline of this section: iOS, iPadOS, macOS,
watchOS and visionOS 27, Xcode 27 and Swift 6.4, with a deployment minimum of 26. macOS 27 is Golden Gate;
macOS 26, still in FieldNotes' supported range, is Tahoe. APIs that exist only on macOS 27 are labelled and guarded
with if #available.
FieldNotes' Mac app is the native SwiftUI build of the same multiplatform target described on
One Codebase for iOS, iPadOS, macOS, watchOS and visionOS. The scene
types themselves were introduced on App Structure and Life Cycle
(SwiftUI, UIKit, AppKit), and the menu bar commands and per-note windows were built for iPad on
iPadOS: Windows, Multitasking, Pointer and Apple Pencil. This page reuses
FieldNotesCommands, the selectedNote focused value and NoteWindow from there unchanged, and adds what only the
Mac has: utility windows, window placement and levels, the Settings window, the menu bar extra, AppKit
interoperability and the App Sandbox.
The FieldNotes Mac App at a Glance
On the Mac, FieldNotes declares seven scenes. Two are window groups that can have many windows, two are single windows, one is the Settings window and one lives in the system menu bar. The commands attached to the main window group build the menu bar for all of them.
Figure: The FieldNotes Mac scene graph
store = AppServices.shared.store"] App --> Main["WindowGroup (main)
ContentView: notebooks, notes, detail"] App --> NoteWG["WindowGroup id note, for Note.ID
NoteWindow"] App --> Browser["Window id tag-browser
TagBrowser"] App --> Info["UtilityWindow id note-info
NoteInfoPanel"] App --> Inspector["UtilityWindow id tag-inspector
TagInspector"] App --> Settings["Settings
SettingsView: General and Sync tabs"] App --> Extra["MenuBarExtra FieldNotes
QuickCaptureMenu, menu or window style"] Main -->|".commands"| Cmds["FieldNotesCommands, SidebarCommands,
InspectorCommands, Help"] Cmds -.->|"reads focused value selectedNote"| NoteWG Cmds -.->|"reads focused value selectedNote"| Main Info -.->|"follows the focused main window"| Main Inspector -.->|"follows the focused main window"| NoteWG Extra -->|"openWindow(id: note, value:)"| NoteWG
Every scene that shows views injects the one store, so a pin toggled in a note window is visible in the main window, the utility windows and the menu bar extra at once:
// FieldNotes/FieldNotesApp.swift (excerpt: the macOS scenes; the other platforms are on the pages linked above)
import SwiftUI
import FieldNotesModel
import FieldNotesServices
import FieldNotesUI
@main
struct FieldNotesApp: App {
@State private var store = AppServices.shared.store // the single owner, see Background Execution
#if os(macOS)
@NSApplicationDelegateAdaptor private var appDelegate: FieldNotesMacDelegate
@AppStorage("menuBarExtraChoice", store: .fieldNotesShared)
private var menuBarExtraChoice = MenuBarExtraChoice.window
#endif
var body: some Scene {
WindowGroup {
ContentView()
.environment(store)
}
.commands {
FieldNotesCommands(store: store) // from the iPadOS page, unchanged
SidebarCommands()
InspectorCommands()
}
.defaultAppStorage(.fieldNotesShared) // as on Preferences, Files and Document-Based Apps
#if os(macOS)
.defaultSize(width: 1100, height: 720)
#endif
WindowGroup(id: "note", for: Note.ID.self) { $noteID in
NoteWindow(noteID: noteID)
.environment(store)
}
.defaultAppStorage(.fieldNotesShared)
.defaultSize(width: 640, height: 720)
.windowResizability(.contentMinSize)
#if os(macOS)
Window("Tag Browser", id: "tag-browser") {
TagBrowser()
.environment(store)
}
.defaultAppStorage(.fieldNotesShared)
.windowResizability(.contentSize)
UtilityWindow("Note Info", id: "note-info") {
NoteInfoPanel()
}
.defaultAppStorage(.fieldNotesShared)
UtilityWindow("Tag Inspector", id: "tag-inspector") {
TagInspector()
.environment(store)
}
.defaultAppStorage(.fieldNotesShared)
.defaultWindowPlacement { _, context in
// Top-trailing corner of the screen the window opens on.
let visible = context.defaultDisplay.visibleRect
return WindowPlacement(CGPoint(x: visible.maxX - 300, y: visible.minY + 40))
}
.restorationBehavior(.disabled)
Settings {
SettingsView()
.environment(store)
}
.defaultAppStorage(.fieldNotesShared) // GeneralSettings writes the suite the App reads
MenuBarExtra("FieldNotes", systemImage: "note.text", isInserted: isShown(.menu)) {
QuickCaptureMenu()
.environment(store)
}
.defaultAppStorage(.fieldNotesShared)
.menuBarExtraStyle(.menu)
MenuBarExtra("FieldNotes", systemImage: "note.text", isInserted: isShown(.window)) {
VStack(alignment: .leading) {
QuickCaptureBar()
Divider()
QuickCaptureMenu()
}
.frame(width: 300)
.padding(.vertical, 8)
.environment(store)
}
.defaultAppStorage(.fieldNotesShared)
.menuBarExtraStyle(.window)
#endif
}
#if os(macOS)
/// At most one of the two menu bar extras is inserted; removing it from the menu bar hides both.
private func isShown(_ choice: MenuBarExtraChoice) -> Binding<Bool> {
Binding(get: { menuBarExtraChoice == choice },
set: { if !$0 { menuBarExtraChoice = .hidden } })
}
#endif
}
See Apple Developer Documentation — Windows, Apple Developer Documentation -- defaultAppStorage(_:) and Apple Developer Documentation — Creating a macOS app.
Every scene keeps the .defaultAppStorage(.fieldNotesShared) it has on
Preferences, Files and Document-Based Apps, so an
@AppStorage without a store: argument anywhere in the app reads and writes the App Group suite. The App itself
sits above every scene and inherits nothing, so its own menuBarExtraChoice names the suite explicitly; that is the
same key GeneralSettings writes from the Settings scene, and the menu bar extra follows the picker at once.
The sections below take the scenes one at a time. FieldNotesMacDelegate, which keeps the app running after its
last window closes and adds a Dock menu, is on
App Structure and Life Cycle (SwiftUI, UIKit, AppKit). ContentView
and NoteWindow publish the focused values the menu bar reads, as described on the iPadOS page.
Window Scenes: WindowGroup, Window and UtilityWindow
The three window scene types differ in how many windows they create and how those windows behave:
| Scene | Windows | FieldNotes uses it for |
|---|---|---|
|
Any number, each with independent state. File > New Window (or FieldNotes' own commands) creates more. A
data-presenting group ( |
The main library window, and |
|
Exactly one. Opening it again brings the existing window forward. It appears in the Window menu under its title. If it is the app’s only scene, closing it quits the app. |
Tag Browser, the library-wide list of tags. |
|
One floating panel that stays above main windows, hides when the app is inactive, and is listed in the View menu with a show/hide command. Its content reads focused values from the main window in front. |
Note Info and Tag Inspector, which describe whatever note is frontmost. |
TagInspector shows every tag in the library with its note count, and highlights the tags of the note in the
frontmost main or note window. It is read-only; editing tags happens in the note itself:
// FieldNotes/Mac/TagInspector.swift
#if os(macOS)
import SwiftUI
import FieldNotesModel
import FieldNotesServices
import FieldNotesUI
struct TagInspector: View {
@Environment(NoteStore.self) private var store
@FocusedValue(\.selectedNote) private var selectedNote // declared in FieldNotesUI, set by NoteDetail
/// Every tag in the library, with how many notes use it, most used first.
private var tagCounts: [(tag: String, count: Int)] {
Dictionary(grouping: store.notes.flatMap(\.tags), by: { $0 })
.map { (tag: $0.key, count: $0.value.count) }
.sorted { $0.count != $1.count ? $0.count > $1.count : $0.tag < $1.tag }
}
var body: some View {
List(tagCounts, id: \.tag) { entry in
HStack {
TagChip(entry.tag, isSelected: selectedNote?.tags.contains(entry.tag) == true)
Spacer()
Text(entry.count, format: .number)
.foregroundStyle(.secondary)
.monospacedDigit()
}
}
.navigationSubtitle(selectedNote?.title ?? String(localized: "No note selected"))
.frame(minWidth: 240, minHeight: 300)
}
}
#endif
The List uses the tag string as its identity, which is stable because tags are keys of the dictionary. The utility
window works because NoteDetail publishes its note with focusedSceneValue(\.selectedNote, note): a utility
window never becomes the focused scene itself, so it keeps seeing the value of the main window behind it.
openWindow and dismissWindow
Windows with an id are opened by the openWindow action and closed by dismissWindow. Called with no arguments,
dismissWindow() closes the window the view is in; with an id it closes that scene’s window. NoteWindow closes
itself after the person deletes its note, and a toolbar button in the main window shows the tag inspector:
// FieldNotes/Windows/NoteWindow+Delete.swift (excerpt: NoteWindow's toolbar gains a Delete button)
@Environment(\.dismissWindow) private var dismissWindow
Button("Delete Note", systemImage: "trash", role: .destructive) {
store.delete(note)
dismissWindow() // this window has nothing left to show
}
// FieldNotes/Mac/TagInspectorToolbarButton.swift
struct TagInspectorToolbarButton: View {
@Environment(\.openWindow) private var openWindow
var body: some View {
Button("Tag Inspector", systemImage: "tag.circle") {
openWindow(id: "tag-inspector")
}
}
}
Sizing, Placement and Styles
A Mac window has a size, a position, a title bar and toolbar style, and a level. SwiftUI sets all four with scene modifiers, so the choice is declared once and applies to every window of the scene.
Size and resizability
defaultSize(width:height:) is the size of a new window; after that the person’s size wins and is restored.
windowResizability(_:) decides how the window’s size relates to its content: .automatic (the default),
.contentSize (the window is exactly as large as the content allows, between its minimum and maximum frames) and
.contentMinSize (the content sets only the minimum). The Tag Browser uses .contentSize above, so its frame
modifiers fully control it:
// FieldNotes/Mac/TagBrowser.swift (excerpt)
var body: some View {
TagBrowserList()
.frame(minWidth: 320, idealWidth: 380, maxWidth: 520,
minHeight: 360, maxHeight: .infinity) // width fixed within a range, height free
}
Placement and zoom
Since macOS 15, defaultWindowPlacement(_:) computes where a new window appears. The closure receives a
WindowLayoutRoot proxy, whose sizeThatFits(_:) asks the content for its size, and a WindowPlacementContext
with the display the window will open on. The Tag Inspector above opens in the top-trailing corner of the visible
area of that display, clear of the menu bar and the Dock. windowIdealPlacement(_:) answers a different question:
where the window goes when the person Option-clicks the green button to zoom it. FieldNotes' note windows zoom to
a readable column rather than the full screen:
// FieldNotes/FieldNotesApp.swift (excerpt: the note window group on macOS)
WindowGroup(id: "note", for: Note.ID.self) { $noteID in
NoteWindow(noteID: noteID)
.environment(store)
}
.defaultAppStorage(.fieldNotesShared) // as in the full scene list above
#if os(macOS)
.windowIdealPlacement { root, context in
let display = context.defaultDisplay.visibleRect
let ideal = root.sizeThatFits(.unspecified)
let size = CGSize(width: min(max(ideal.width, 720), display.width), // a comfortable line length
height: display.height)
return WindowPlacement(CGPoint(x: display.midX - size.width / 2, y: display.minY), size: size)
}
#endif
Title bar, toolbar and level
Window style covers the frame and title bar; toolbar style covers the unified toolbar; a few view modifiers remove parts of it. The Note Info panel gets a compact toolbar, the Tag Browser floats above other windows without a title bar, and a note window in focus mode hides its title so the text reaches the top edge:
// FieldNotes/FieldNotesApp.swift (excerpt: styles on the Mac scenes)
UtilityWindow("Note Info", id: "note-info") {
NoteInfoPanel()
}
.defaultAppStorage(.fieldNotesShared)
.windowToolbarStyle(.unifiedCompact)
Window("Tag Browser", id: "tag-browser") {
TagBrowser()
.environment(store)
}
.defaultAppStorage(.fieldNotesShared)
.windowLevel(.floating) // stays above other apps' windows while FieldNotes is active
.windowStyle(.hiddenTitleBar) // no title text or title bar backing
// Inside NoteWindow, when focus mode is on:
NoteDetail(note: note)
.toolbar(removing: .title)
.toolbarBackgroundVisibility(.hidden, for: .windowToolbar)
.gesture(WindowDragGesture()) // the window stays draggable by its content
See Apple Developer Documentation -- windowStyle(_:), Apple Developer Documentation -- windowToolbarStyle(_:), Apple Developer Documentation -- windowLevel(_:) and Apple Developer Documentation — WindowDragGesture.
Removing the title and the toolbar background is a visual change only: VoiceOver still reads the title, and the
Window menu still lists it, which is why every FieldNotes window keeps a navigationTitle. Without a toolbar
background the window has no obvious place to drag it, so WindowDragGesture makes the content itself a drag
handle. Use .floating sparingly: it suits a small reference panel, not a document window.
Restoration and launch behavior
macOS restores windows at launch when the person has Close windows when quitting an app turned off in System
Settings, which is the default. SwiftUI follows that setting, restoring each window’s frame, its presented value
(the Note.ID of a note window) and its @SceneStorage. Two scene modifiers adjust it per scene:
-
restorationBehavior(.disabled)opts a scene’s windows out of restoration. The Tag Inspector uses it above, because it describes the current selection and reopening it at launch, before any note is selected, is noise. -
defaultLaunchBehavior(_:)decides whether a scene presents a window at launch when nothing was restored:.presentedopens it,.suppressedkeeps it closed.
// FieldNotes/FieldNotesApp.swift (excerpt): the tag browser never opens by itself at launch,
// but it is restored if it was open when the person quit.
Window("Tag Browser", id: "tag-browser") {
TagBrowser()
.environment(store)
}
.defaultAppStorage(.fieldNotesShared)
.defaultLaunchBehavior(.suppressed)
See Apple Developer Documentation -- restorationBehavior(_:) and Apple Developer Documentation -- defaultLaunchBehavior(_:).
What is worth restoring inside a window is UI state, such as the selected notebook in @SceneStorage, never the
data itself; @SceneStorage is covered on the life-cycle page.
Menus and Commands
The Mac menu bar is the app’s primary command surface: every action a person can take should be reachable there,
with a keyboard shortcut for the frequent ones. FieldNotes gets most of its menu bar from FieldNotesCommands,
defined on iPadOS: Windows, Multitasking, Pointer and Apple Pencil and attached to the
main window group above. It replaces the File > New group with New Note (⌘N) and New Window for Note (⌥⌘N),
removes Print so that ⌘P can pin, and adds a Notes menu with Pin Note and Find Notes… (⌘F), all driven by
@FocusedValue(\.selectedNote) and two focused bindings from ContentView.
The Mac adds a few commands of its own, in a second Commands type that only the macOS build includes: a Window
menu item for each utility window with a shortcut, Export Notebook… in the File menu, and the help link from
Human Interface Guidelines in Practice:
// FieldNotes/Mac/MacOnlyCommands.swift
#if os(macOS)
import SwiftUI
import FieldNotesModel
import FieldNotesServices
struct MacOnlyCommands: Commands {
let store: NoteStore
@Environment(\.openWindow) private var openWindow
@FocusedValue(\.selectedNote) private var selectedNote
/// The notebook of the frontmost note, if it has one.
private var notebook: Notebook? {
store.notebooks.first { $0.id == selectedNote?.notebookID }
}
var body: some Commands {
CommandGroup(after: .importExport) {
Button("Export Notebook…") {
guard let notebook else { return }
do {
try exportNotebook(notebook, from: store) // shown under App Sandbox below
} catch {
NSAlert(error: error).runModal() // never fail silently
}
}
.keyboardShortcut("e", modifiers: [.command, .shift])
.disabled(notebook == nil)
}
CommandGroup(before: .windowList) {
Button("Tag Browser") { openWindow(id: "tag-browser") }
.keyboardShortcut("t", modifiers: [.command, .option])
Button("Tag Inspector") { openWindow(id: "tag-inspector") }
.keyboardShortcut("i", modifiers: [.command, .option])
Divider()
}
CommandGroup(replacing: .help) {
Link("FieldNotes Help", destination: URL(string: "https://example.com/fieldnotes/help")!)
}
}
}
#endif
See Apple Developer Documentation — CommandGroup, Apple Developer Documentation — CommandMenu and Apple Developer Documentation — Building and customizing the menu bar with SwiftUI.
The scene’s commands closure then lists both: FieldNotesCommands(store: store) everywhere and
MacOnlyCommands(store: store) inside #if os(macOS). A few rules keep a Mac menu bar trustworthy:
-
Disable, don’t hide. A command that can’t run right now stays in its menu, greyed out, so people learn where it lives. Every FieldNotes command uses
.disabled(_:)with its focused value. -
Scene-wide or view-focused.
focusedSceneValue(whatNoteDetailuses) keeps a command enabled whatever has keyboard focus in the window;focusedValue(_:_:)only while focus is inside the modified view, which suits commands like Delete Row in a table. -
Standard placements first.
CommandGroupPlacementnames every standard group (.newItem,.saveItem,.importExport,.printItem,.pasteboard,.textEditing,.sidebar,.toolbar,.windowList,.helpand more). Putting Export next to Import in the File menu, rather than in a custom menu, is what Mac users expect. -
Symbols are hidden by default in 27. As on iPadOS 27, menu item symbol images are not shown in macOS 27 menus and context menus unless a
Labeluses.titleAndIcon; see the iPadOS page for the example.
See Apple Developer Documentation -- focusedValue(_:_:) and Human Interface Guidelines — The menu bar.
The AppKit machinery beneath commands (an NSMenu whose items send actions up the responder chain) is on
UIKit and AppKit Essentials, and Mixing Them with SwiftUI.
Settings and SettingsLink
The Settings scene gives the app the standard FieldNotes > Settings… menu item and the ⌘, shortcut, and
opens one Settings window. Mac settings windows are traditionally organized in panes with a toolbar of tabs. A
TabView inside the Settings scene produces exactly that, so SettingsView gains tabs on macOS while the iOS
sheet keeps one grouped form. The sections that used to be inline in its Form
(Controls, Forms, Text, Images and SF Symbols) move
into two views, GeneralSettings and SyncSettings:
// Packages/FieldNotesUI/Sources/FieldNotesUI/SettingsView.swift (updated: tabs on macOS)
import SwiftUI
public struct SettingsView: View {
public init() {}
public var body: some View {
#if os(macOS)
TabView {
Tab("General", systemImage: "gearshape") {
Form { GeneralSettings() }.formStyle(.grouped)
}
Tab("Sync", systemImage: "arrow.triangle.2.circlepath.icloud") {
Form { SyncSettings() }.formStyle(.grouped)
}
}
.frame(width: 520, height: 460)
#else
Form {
SyncSettings()
GeneralSettings()
}
.formStyle(.grouped)
.navigationTitle("Settings")
#endif
}
}
GeneralSettings holds the Notes, Capture and About sections from the original form, unchanged, and on the Mac
one more: the menu bar extra choice. SyncSettings holds the iCloud toggle and, on the Mac, the export folder
described under The App Sandbox and ExportFolderBookmark:
// Packages/FieldNotesUI/Sources/FieldNotesUI/GeneralSettings.swift
import SwiftUI
import FieldNotesModel
import FieldNotesServices
/// Where the Mac app shows its menu bar extra, stored in the App Group suite.
public enum MenuBarExtraChoice: String, CaseIterable, Identifiable, Sendable {
case hidden, menu, window
public var id: Self { self }
}
public struct GeneralSettings: View {
@Environment(NoteStore.self) private var store
@AppStorage("pinnedFirst") private var pinnedFirst = true
@AppStorage("noteSortOrder") private var sortOrder = NoteSortOrder.newestFirst
#if os(macOS)
@AppStorage("menuBarExtraChoice") private var menuBarExtraChoice = MenuBarExtraChoice.window
#endif
public init() {}
public var body: some View {
Section("Notes") {
Picker("Sort By", selection: $sortOrder) {
ForEach(NoteSortOrder.allCases) { Text($0.title).tag($0) }
}
Toggle("Show Pinned Notes First", isOn: $pinnedFirst)
// Default Notebook picker as before
}
#if os(macOS)
Section("Menu Bar") {
Picker("Quick Capture", selection: $menuBarExtraChoice) {
Text("Hidden").tag(MenuBarExtraChoice.hidden)
Text("Menu").tag(MenuBarExtraChoice.menu)
Text("Panel").tag(MenuBarExtraChoice.window)
}
}
#endif
// Capture and About sections as before
}
}
// Packages/FieldNotesUI/Sources/FieldNotesUI/SyncSettings.swift
import SwiftUI
public struct SyncSettings: View {
@AppStorage("syncWithICloud") private var syncWithICloud = true
public init() {}
public var body: some View {
Section("Sync") {
Toggle("Sync with iCloud", isOn: $syncWithICloud)
}
#if os(macOS)
Section("Export") {
ExportFolderRow() // also in FieldNotesUI, shown under App Sandbox below
}
#endif
}
}
The @AppStorage properties don’t name a store because the Settings scene’s content (and, on iOS, the main
window group that presents the settings sheet) inherits .defaultAppStorage(.fieldNotesShared), as set up on
Preferences, Files and Document-Based Apps and kept on every
scene in FieldNotesApp above; the App names the suite explicitly with store: .fieldNotesShared because it sits
above every scene. Both sides therefore read and write the same App Group suite. The ForEach over NoteSortOrder.allCases uses the enum’s own
stable identity.
To open Settings from inside the app, for example from the menu bar extra or from an "iCloud is off" banner, use
SettingsLink, a button that opens (or brings forward) the Settings window. Its default label is the system’s
localized Settings…. The openSettings environment action does the same from code:
// FieldNotes/Mac/SyncOffBanner.swift
#if os(macOS)
import SwiftUI
struct SyncOffBanner: View {
@Environment(\.openSettings) private var openSettings
var body: some View {
HStack {
Label("iCloud sync is off", systemImage: "icloud.slash")
Spacer()
SettingsLink {
Text("Open Settings…")
}
Button("Turn On in Settings") {
openSettings() // the same as SettingsLink, for use inside another action
}
}
.padding(8)
}
}
#endif
See Apple Developer Documentation — SettingsLink and Apple Developer Documentation — OpenSettingsAction.
SettingsLink and openSettings are the SwiftUI routes to the Settings window. On iPad the same Settings menu item opens
FieldNotes' page in the Settings app instead.
Menu Bar Extras
A MenuBarExtra puts an item in the right-hand side of the system menu bar, available even when the app has no
windows open. It has two styles:
-
.menu(the default) shows a pull-down menu. The content is interpreted as menu items:Button,Toggle,Picker,Menufor submenus,DividerandSection. Layout containers and text fields have no meaning in a menu. -
.windowshows a small popover-like window with arbitrary SwiftUI content, which suits a text field.
FieldNotes lets people choose between them, or hide the item, with the Menu Bar picker in General settings. The
app declares both scenes and inserts at most one, through the isInserted: bindings shown in FieldNotesApp. If
the person Command-drags the item out of the menu bar, the system sets the binding to false and the choice becomes
Hidden. QuickCaptureMenu is written only with menu-compatible views, so it works in both styles:
// FieldNotes/Mac/QuickCaptureMenu.swift
#if os(macOS)
import SwiftUI
import FieldNotesModel
import FieldNotesServices
/// The menu bar extra's content: menu items in .menu style, the lower part of the panel in .window style.
struct QuickCaptureMenu: View {
@Environment(NoteStore.self) private var store
@Environment(\.openWindow) private var openWindow
private var pinned: [Note] {
store.notes.filter(\.isPinned).sorted { $0.createdAt > $1.createdAt }
}
var body: some View {
Button("New Note from Clipboard") {
let text = NSPasteboard.general.string(forType: .string) ?? ""
let note = Note(title: String(text.prefix(60)), body: text)
store.add(note)
openWindow(id: "note", value: note.id)
NSApplication.shared.activate()
}
.disabled(NSPasteboard.general.string(forType: .string) == nil)
Section("Pinned") {
ForEach(pinned.prefix(5)) { note in
Button(note.title) {
openWindow(id: "note", value: note.id)
NSApplication.shared.activate()
}
}
}
Divider()
SettingsLink()
Button("Quit FieldNotes") { NSApplication.shared.terminate(nil) }
.keyboardShortcut("q")
}
}
#endif
See Apple Developer Documentation — MenuBarExtra and Apple Developer Documentation -- menuBarExtraStyle(_:).
In .window style, FieldNotesApp wraps the same content in a VStack below QuickCaptureBar, the capsule text
field from Controls, Forms, Text, Images and SF Symbols,
so a thought can be captured without opening any window. ForEach(pinned.prefix(5)) identifies rows by Note.ID.
Calling NSApplication.shared.activate() after opening a window brings FieldNotes forward; without it the new window
can open behind the app the person is using. Note(title:body:) goes through store.add(_:), so the new note is
written to SwiftData like any other.
An app whose only scene is a MenuBarExtra is a menu bar utility: it usually sets LSUIElement in Info.plist
to hide its Dock icon, and it quits when its item is removed. FieldNotes is a regular app with an optional extra, so
it keeps its Dock icon and, through FieldNotesMacDelegate, keeps running when its last window closes.
AppKit in macOS 27
SwiftUI builds FieldNotes' Mac interface, but AppKit is still underneath it, and many Mac apps, including
FieldNotes' predecessor FieldNotesMacLegacy from the life-cycle page, are AppKit apps. WWDC26’s Modernize your
AppKit app session collects what changed for them in macOS 27: modern event handling with gesture recognizers and
control events instead of mouseDown tracking loops, better keyboard navigation (including for status items),
graceful termination and state restoration, and Liquid Glass and corner-concentricity updates. Two changes matter
most for code that shares a model with SwiftUI.
Observation tracking in AppKit
AppKit tracks @Observable models automatically: any observable property read inside updateConstraints(),
layout(), draw(_:), or a view controller’s viewWillLayout() is recorded, and AppKit reruns the method when
that property changes. It became available in macOS 15, where it had to be enabled with the
NSObservationTrackingEnabled Info.plist key; since macOS 26 it is on by default, so FieldNotes' deployment
minimum gets it with no configuration. NoteStore is @Observable, so an AppKit view that reads it updates itself:
// FieldNotesMacLegacy/NoteCountBadgeView.swift
import AppKit
import FieldNotesServices
/// A Dock-tile-style badge showing the number of pinned notes. No notifications, no KVO, no manual invalidation.
final class NoteCountBadgeView: NSView {
private let store: NoteStore
init(store: NoteStore) {
self.store = store
super.init(frame: NSRect(x: 0, y: 0, width: 44, height: 24))
}
required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
override func draw(_ dirtyRect: NSRect) {
// Reading store.notes here registers a dependency; a pin anywhere in the app redraws the badge.
let count = store.notes.filter(\.isPinned).count
NSColor.controlAccentColor.setFill()
NSBezierPath(roundedRect: bounds, xRadius: bounds.height / 2, yRadius: bounds.height / 2).fill()
let text = count.formatted() as NSString
let attributes: [NSAttributedString.Key: Any] = [
.font: NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .semibold),
.foregroundColor: NSColor.white,
]
let size = text.size(withAttributes: attributes)
text.draw(at: NSPoint(x: bounds.midX - size.width / 2, y: bounds.midY - size.height / 2),
withAttributes: attributes)
}
}
See Apple Developer Documentation — Updating views automatically with observation tracking in AppKit and Apple Developer Documentation — NSObservationTrackingEnabled.
NSView subclasses are main-actor isolated, like NoteStore, so the read in draw(_:) needs no await. The same
rule applies as in SwiftUI: only properties actually read during the tracked method are dependencies, so read what
you draw and nothing more.
Control events
UIKit has always had control events: a target and action registered for .valueChanged or .touchUpInside.
AppKit controls traditionally sent one action, and anything finer meant subclassing and overriding mouseDown. The
WWDC26 session introduces NSControl.Events and addTarget(_:action:for:): tracking events (.trackingBegan,
.trackingEndedInside, .trackingEndedOutside, .trackingCancelled and more) and semantic events
(.valueChanged, .primaryActionTriggered, .menuActionTriggered), registered on standard controls without
subclassing. FieldNotesMacLegacy’s map zoom slider previews while dragging and commits once, when the drag ends:
// FieldNotesMacLegacy/MapZoomController.swift
import AppKit
final class MapZoomController: NSViewController {
private let slider = NSSlider(value: 1, minValue: 0.5, maxValue: 4, target: nil, action: nil)
override func viewDidLoad() {
super.viewDidLoad()
slider.isContinuous = true
slider.addTarget(self, action: #selector(previewZoom(_:)), for: .valueChanged)
slider.addTarget(self, action: #selector(commitZoom(_:)), for: [.trackingEndedInside, .trackingEndedOutside])
view.addSubview(slider)
}
@objc private func previewZoom(_ sender: NSSlider) {
// cheap: scale the current map snapshot
}
@objc private func commitZoom(_ sender: NSSlider) {
// expensive: request new map tiles for the final zoom level
}
}
See Apple Developer Documentation — NSControl.Events and Apple Developer Documentation -- addTarget(_:action:for:).
The documentation lists NSControl.Events and addTarget(_:action:for:) as available from macOS 11, and the
session explains that most of the events work on releases long before 27, so the code above needs no availability
check at FieldNotes' deployment minimum of 26. The session also covers text selection in custom views, keyboard
navigation of status items and NSWindow.preventsApplicationTerminationWhenModal, which lets a non-essential sheet
stop blocking Quit:
// FieldNotesMacLegacy/ExportSheet.swift (excerpt)
exportSheetWindow.preventsApplicationTerminationWhenModal = false // quitting just cancels the export
mainWindow.beginSheet(exportSheetWindow)
Hosting SwiftUI inside AppKit and AppKit inside SwiftUI (NSHostingController, NSHostingView,
NSViewRepresentable) is covered on
UIKit and AppKit Essentials, and Mixing Them with SwiftUI.
The App Sandbox and ExportFolderBookmark
A Mac App Store app must be sandboxed, and FieldNotes is sandboxed everywhere. The sandbox confines the app to its
container in ~/Library/Containers, and every other resource (network, location, user files) requires an
entitlement. The Mac entitlements file, with com.apple.security.files.user-selected.read-write, is on
Security and Privacy. That entitlement lets the app read and write what the
person picks in an open or save panel, during that session.
FieldNotes exports notebooks as .fieldnotes files to a folder the person chooses once, in Sync settings. The
access granted by the panel ends when the app quits, so to keep writing to that folder on later launches the app
stores a security-scoped bookmark: opaque data, created while it has access, that it can resolve later to
regain access. ExportFolderBookmark wraps the whole protocol:
// Packages/FieldNotesKit/Sources/FieldNotesServices/Mac/ExportFolderBookmark.swift
#if os(macOS)
import Foundation
/// The export folder the person chose, remembered across launches as a security-scoped bookmark
/// in `UserDefaults.fieldNotesShared`.
public enum ExportFolderBookmark {
public enum AccessError: Error {
case noFolderChosen
case accessDenied(URL)
}
private static let key = "exportFolderBookmark"
/// Call with a URL from fileImporter or NSOpenPanel, while the panel's access is still active.
public static func remember(_ folder: URL) throws {
let data = try folder.bookmarkData(options: .withSecurityScope,
includingResourceValuesForKeys: nil, relativeTo: nil)
UserDefaults.fieldNotesShared.set(data, forKey: key)
}
public static var hasFolder: Bool {
UserDefaults.fieldNotesShared.data(forKey: key) != nil
}
/// Resolves the bookmark, opens the sandbox for the folder, runs `body`, and closes it again.
public static func withAccess<T>(_ body: (URL) throws -> T) throws -> T {
guard let data = UserDefaults.fieldNotesShared.data(forKey: key) else {
throw AccessError.noFolderChosen
}
var isStale = false
let folder = try URL(resolvingBookmarkData: data, options: .withSecurityScope,
relativeTo: nil, bookmarkDataIsStale: &isStale)
guard folder.startAccessingSecurityScopedResource() else {
throw AccessError.accessDenied(folder)
}
defer { folder.stopAccessingSecurityScopedResource() }
if isStale {
try remember(folder) // the folder moved or was renamed: refresh the stored bookmark
}
return try body(folder)
}
}
#endif
See Apple Developer Documentation -- bookmarkData(options:includingResourceValuesForKeys:relativeTo:), Apple Developer Documentation -- init(resolvingBookmarkData:options:relativeTo:bookmarkDataIsStale:) and Apple Developer Documentation — Accessing files from the macOS App Sandbox.
The order of operations is what Apple’s sandbox article prescribes: resolve with .withSecurityScope, start
access, recreate the bookmark if it is stale, use the files, stop access. Every start is balanced by a stop
through defer, because the kernel keeps a limited number of open sandbox extensions per process. The bookmark is
only resolvable by the app that created it, so storing it in the App Group suite doesn’t give the widget access to
the folder; it just keeps FieldNotes' preferences in one place.
The settings row chooses the folder with fileImporter, and the export writes through withAccess. Errors reach
the person as an alert instead of disappearing. The row lives in the FieldNotesUI package next to SyncSettings,
which shows it: a package can’t see types declared in the app target, but FieldNotesUI already depends on
FieldNotesServices, where ExportFolderBookmark is declared. The export function, called only by the app’s
MacOnlyCommands, stays in the app target:
// Packages/FieldNotesUI/Sources/FieldNotesUI/Mac/ExportFolderRow.swift
#if os(macOS)
import SwiftUI
import UniformTypeIdentifiers
import FieldNotesServices
public struct ExportFolderRow: View {
@State private var isChoosing = false
@State private var hasFolder = ExportFolderBookmark.hasFolder
@State private var errorMessage: String?
public init() {}
public var body: some View {
LabeledContent("Export Folder") {
Button(hasFolder ? "Change…" : "Choose…") { isChoosing = true }
}
.fileImporter(isPresented: $isChoosing, allowedContentTypes: [.folder]) { result in
do {
try ExportFolderBookmark.remember(try result.get())
hasFolder = true
} catch {
errorMessage = error.localizedDescription
}
}
.alert("Couldn't Use That Folder", isPresented: Binding(
get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
Button("OK") {}
} message: {
Text(errorMessage ?? "")
}
}
}
#endif
// FieldNotes/Mac/ExportNotebook.swift (app target: the export itself, called from the Export Notebook command)
#if os(macOS)
import Foundation
import FieldNotesModel
import FieldNotesServices
@MainActor
func exportNotebook(_ notebook: Notebook, from store: NoteStore) throws {
let notes = store.notes.filter { $0.notebookID == notebook.id }
let envelope = NoteFileFormat.Envelope(notebookName: notebook.name, notes: notes)
let data = try JSONEncoder().encode(envelope)
try ExportFolderBookmark.withAccess { folder in
try data.write(to: folder.appending(path: "\(notebook.name).fieldnotes"), options: .atomic)
}
}
#endif
See Apple Developer Documentation -- fileImporter(isPresented:allowedContentTypes:onCompletion:) and Apple Developer Documentation — startAccessingSecurityScopedResource().
The URL that fileImporter returns is already security-scoped for this session (the system started access as if
the app had called startAccessingSecurityScopedResource()), which is why remember(_:) can create the bookmark
directly. NoteFileFormat.Envelope is the .fieldnotes format from
Preferences, Files and Document-Based Apps. The App Sandbox
capability and the user-selected-files entitlement need a signing team; FieldNotes' entitlements are assembled on
Membership, Signing, Capabilities and Info.plist.
The sandbox is required only for the Mac App Store, but FieldNotes keeps it in every build, including a Developer ID build distributed from its website. That build loses a few capabilities regardless of the sandbox: Apple’s Supported capabilities (macOS) reference lists Sign in with Apple, In-App Purchase, Game Center and WeatherKit as available to App Store apps only, not to Developer ID apps, so FieldNotes' Developer ID build hides Sign in with Apple and its StoreKit upgrade. Signing, notarizing and shipping that build is on Distributing Mac Apps with Developer ID and Notarization.
Apple Silicon Only, and the End of Rosetta
The Mac hardware baseline changes with this release, and the dates matter for FieldNotes' deployment minimum:
| Release | What it means for a Mac app |
|---|---|
macOS 26 Tahoe (2025) |
The last macOS release for Intel-based Macs. FieldNotes supports it, so its Release build stays a universal
binary ( |
macOS 27 Golden Gate (2026) |
Runs only on Macs with Apple silicon. Rosetta is still included as a general-purpose translator for Intel apps, for the last time. Settings > General lists installed Intel apps that won’t run on macOS 28, and Get Info marks them. |
macOS 28 (expected 2027) |
Rosetta remains only as a subset for older, unmaintained games that depend on Intel frameworks; other Intel-only apps stop running. |
See Apple Developer Documentation — About the Rosetta translation environment and Apple Developer Documentation — macOS 27 Golden Gate Release Notes.
Xcode’s Standard Architectures setting builds both slices for Release, so a SwiftUI app like FieldNotes is
universal without extra work. Check the shipped binary, and any third-party binary framework inside it, before
dropping x86_64:
# Which architectures does the archived Mac app contain?
lipo -archs "FieldNotes.app/Contents/MacOS/FieldNotes"
# arm64 x86_64
# Any Intel-only framework left in the bundle would stop working on macOS 28.
find FieldNotes.app/Contents/Frameworks -type f -perm +111 -exec lipo -archs {} \; 2>/dev/null
When FieldNotes raises its minimum to macOS 27, the x86_64 slice can go, since no Intel Mac can install that
release.
Native SwiftUI, Mac Catalyst, Designed for iPad or AppKit?
There are four ways to put an app on the Mac. One Codebase for iOS, iPadOS, macOS, watchOS and visionOS compares the three that start from an iPad app (native, Mac Catalyst, Designed for iPad) in a table; this flowchart adds AppKit and turns the comparison into a decision:
Figure: Choosing how to build the Mac app
e.g. a complex text or canvas engine?"} NewUI -->|No| Native["Native SwiftUI, Mac destination
(FieldNotes)"] NewUI -->|Yes| NativeMix["Native SwiftUI plus AppKit
through NSViewRepresentable"] Existing -->|Mac AppKit app| AppKit["Stay on AppKit, modernize for macOS 27;
add SwiftUI views with NSHostingView"] Existing -->|iPad app| Tech{"Built with SwiftUI or UIKit?"} Tech -->|SwiftUI| Native Tech -->|UIKit| Invest{"Willing to adapt layouts, menus
and windows for the Mac?"} Invest -->|Yes| Catalyst["Mac Catalyst
(Optimize Interface for Mac)"] Invest -->|No| DFI["Designed for iPad
(unmodified iPad binary, Apple silicon only)"] DFI -. later .-> Catalyst Catalyst -. rewrite screens over time .-> Native
In code, the choice shows up as the conditions a shared file is compiled or run under. FieldNotes is native, so its
Mac-specific code uses #if os(macOS). A Catalyst build is os(iOS) with targetEnvironment(macCatalyst), and a
Designed-for-iPad build is the iPad binary itself, detectable only at run time:
// Packages/FieldNotesUI/Sources/FieldNotesUI/PlatformDescription.swift
import Foundation
/// How this build of FieldNotes is running on a Mac, if at all (shown in the About section).
func macRuntimeDescription() -> String? {
#if os(macOS)
return "Native Mac app" // FieldNotes' Mac destination: AppKit and SwiftUI
#elseif targetEnvironment(macCatalyst)
return "Mac Catalyst" // UIKit compiled for the Mac
#elseif os(iOS)
return ProcessInfo.processInfo.isiOSAppOnMac ? "iPad app on Mac" : nil // Designed for iPad
#else
return nil
#endif
}
See Apple Developer Documentation — Mac Catalyst and Apple Developer Documentation — Running your iOS apps in macOS.
For FieldNotes the answer was never in doubt: a SwiftUI app gets the best Mac result from the native destination,
and everything on this page (utility windows, window levels, Settings, MenuBarExtra, the sandboxed bookmark)
is native macOS API that a Designed-for-iPad build can’t use at all. Mac Catalyst remains the right path for a large
UIKit iPad app, and AppKit remains the right foundation for Mac apps that already have years of AppKit code. Both
UIKit-on-Mac routes require Apple silicon on macOS 27 and later, like everything else on the Mac. Distributing the
native build outside the Mac App Store is on
Distributing Mac Apps with Developer ID and Notarization.
Availability and Requirements
Platform availability
| API or feature | iOS | iPadOS | macOS | watchOS | visionOS |
|---|---|---|---|---|---|
|
Yes (one window) |
Yes |
Yes |
Yes (one window, no |
Yes |
|
Not available |
Not available |
Yes (13) |
Not available |
New in 26 |
|
Not available |
Not available |
Yes (15) |
Not available |
Not available |
|
Not available |
Not available |
Yes (15) |
Not available |
Yes (2) |
|
Not available |
Not available |
Yes (15) |
Not available |
Not available |
|
Yes (18) / Not available |
Yes (18) / Not available |
Yes (15) |
Yes (11) / Not available |
Yes (2) / New in 26 |
|
Yes (shortcuts only) |
Yes (menu bar in 26) |
Yes |
Not available |
Yes |
|
Not available |
Not available (Settings app instead) |
Yes ( |
Not available |
Not available |
|
Not available |
Not available |
Yes (13) |
Not available |
Not available |
AppKit Observation tracking |
Not applicable |
Not applicable |
Yes (15, opt-in; on by default in 26) |
Not applicable |
Not applicable |
|
Not applicable |
Not applicable |
Yes (listed from 11, documented in the 27 SDK) |
Not applicable |
Not applicable |
App Sandbox, security-scoped bookmarks |
Always sandboxed |
Always sandboxed |
Entitlement |
Always sandboxed |
Always sandboxed |
The App Sandbox capability and its file-access entitlements need a signing team in Xcode; Mac App Store distribution needs a paid Apple Developer Program membership. Everything else on this page runs on any Apple silicon Mac with macOS 26 or later, and the macOS 27 behaviors need macOS 27.
References
-
Apple Developer Documentation — Customizing window styles and state-restoration behavior in macOS
-
Apple Developer Documentation — Building and customizing the menu bar with SwiftUI
-
Apple Developer Documentation — Updating views automatically with observation tracking in AppKit
-
Apple Developer Documentation — NSObservationTrackingEnabled
-
Apple Developer Documentation — preventsApplicationTerminationWhenModal
-
Apple Developer Documentation — Configuring the macOS App Sandbox
-
Apple Developer Documentation — Accessing files from the macOS App Sandbox
-
Apple Developer Documentation -- bookmarkData(options:includingResourceValuesForKeys:relativeTo:)
-
Apple Developer Documentation -- init(resolvingBookmarkData:options:relativeTo:bookmarkDataIsStale:)
-
Apple Developer Documentation — startAccessingSecurityScopedResource()
-
Apple Developer Documentation -- fileImporter(isPresented:allowedContentTypes:onCompletion:)
-
Apple Developer Account Help — Supported capabilities (macOS)
-
Apple Developer Documentation — About the Rosetta translation environment
-
Apple Developer Documentation — macOS 27 Golden Gate Release Notes
-
Apple Developer Documentation — Building a universal macOS binary
-
Apple Developer Documentation — Running your iOS apps in macOS