swift-macos
Covers macOS app development with Swift 6.3, SwiftUI, SwiftData, Swift Concurrency, Foundation Models, Swift Testing, ScreenCaptureKit, and app distribution. Use when building native Mac apps - windows, scenes, navigation, menus and toolbars, SwiftData models and queries, modern
Install
npx skills add https://github.com/tenequm/skills/tree/main/skills/swift-macos
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install tenequm-skills@llmmart
git clone https://github.com/tenequm/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole tenequm/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
macOS App Development - Swift 6.3
Build native macOS apps with Swift 6.3 (latest: 6.3.3, bundled in Xcode 26.6, Jun 2026), SwiftUI, SwiftData, and macOS 26 Tahoe (26.6.2 current). Target macOS 14+ for SwiftData/@Observable, macOS 15+ for latest SwiftUI, macOS 26 for Liquid Glass and Foundation Models. Note Xcode 26.6 still bundles the macOS 26.5 SDK, so 26.6-only API is not yet buildable. For the WWDC 2026 beta stack (macOS 27, Xcode 27, Swift 6.4, shipping fall 2026), see references/fall-2026-releases.md.
Quick Start
import SwiftUI
import SwiftData
@Model
final class Project {
var name: String
var createdAt: Date
// Named ProjectTask, not Task - `Task` would shadow `Swift.Task`
@Relationship(deleteRule: .cascade) var tasks: [ProjectTask] = []
init(name: String) {
self.name = name
self.createdAt = .now
}
}
@Model
final class ProjectTask {
var title: String
var isComplete: Bool
var project: Project?
init(title: String) {
self.title = title
self.isComplete = false
}
}
@main
struct MyApp: App {
var body: some Scene {
WindowGroup("Projects") {
ContentView()
}
.modelContainer(for: [Project.self, ProjectTask.self])
.defaultSize(width: 900, height: 600)
#if os(macOS)
Settings { SettingsView() }
MenuBarExtra("Status", systemImage: "circle.fill") {
MenuBarView()
}
.menuBarExtraStyle(.window)
#endif
}
}
struct ContentView: View {
@Query(sort: \Project.createdAt, order: .reverse)
private var projects: [Project]
@Environment(\.modelContext) private var context
@State private var selected: Project?
var body: some View {
NavigationSplitView {
List(projects, selection: $selected) { project in
NavigationLink(value: project) {
Text(project.name)
}
}
.navigationSplitViewColumnWidth(min: 200, ideal: 250)
} detail: {
if let selected {
DetailView(project: selected)
} else {
ContentUnavailableView("Select a Project",
systemImage: "sidebar.left")
}
}
}
}
Scenes & Windows
| Scene | Purpose |
|---|---|
WindowGroup |
Resizable windows (multiple instances) |
Window |
Single-instance utility window |
Settings |
Preferences (Cmd+,) |
MenuBarExtra |
Menu bar with .menu or .window style |
DocumentGroup |
Document-based apps |
Open windows: @Environment(\.openWindow) var openWindow; openWindow(id: "about")
For complete scene lifecycle, see references/app-lifecycle.md.
Menus & Commands
.commands {
CommandGroup(replacing: .newItem) {
Button("New Project") { /* ... */ }
.keyboardShortcut("n", modifiers: .command)
}
CommandMenu("Tools") {
Button("Run Analysis") { /* ... */ }
.keyboardShortcut("r", modifiers: [.command, .shift])
}
}
Table (macOS-native)
Table(items, selection: $selectedIDs, sortOrder: $sortOrder) {
TableColumn("Name", value: \.name)
TableColumn("Date") { Text($0.date, format: .dateTime) }
.width(min: 100, ideal: 150)
}
.contextMenu(forSelectionType: Item.ID.self) { ids in
Button("Delete", role: .destructive) { delete(ids) }
}
For forms, popovers, sheets, inspector, and macOS modifiers, see references/swiftui-macos.md.
@Observable
@Observable
final class AppState {
var projects: [Project] = []
var isLoading = false
func load() async throws {
isLoading = true
defer { isLoading = false }
projects = try await ProjectService.fetchAll()
}
}
// Use: @State var state = AppState() (owner)
// Pass: .environment(state) (inject)
// Read: @Environment(AppState.self) var state (child)
SwiftData
@Query & #Predicate
@Query(filter: #Predicate<Project> { !$0.isArchived }, sort: \Project.name)
private var active: [Project]
// Dynamic predicate
func search(_ term: String) -> Predicate<Project> {
#Predicate { $0.name.localizedStandardContains(term) }
}
// FetchDescriptor (outside views)
var desc = FetchDescriptor<Project>(predicate: #Predicate { $0.isArchived })
desc.fetchLimit = 50
let results = try context.fetch(desc)
let count = try context.fetchCount(desc)
Relationships
@Model final class Author {
var name: String
@Relationship(deleteRule: .cascade, inverse: \Book.author)
var books: [Book] = []
init(name: String) { self.name = name }
}
@Model final class Book {
var title: String
var author: Author?
@Relationship var tags: [Tag] = [] // many-to-many
init(title: String) { self.title = title }
}
Delete rules: .cascade, .nullify (default), .deny, .noAction.
Every @Model class needs an explicit initializer - the macro does not synthesize one, and omitting it fails with @Model requires an initializer be provided for '<Type>'.
Schema Migration
enum SchemaV1: VersionedSchema { /* ... */ }
enum SchemaV2: VersionedSchema { /* ... */ }
enum MigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] }
static var stages: [MigrationStage] {
[.lightweight(fromVersion: SchemaV1.self, toVersion: SchemaV2.self)]
}
}
// Apply: .modelContainer(for: Model.self, migrationPlan: MigrationPlan.self)
CloudKit Sync
Enable iCloud capability, then .modelContainer(for: Model.self) auto-syncs. Constraints: all properties need defaults/optional, no unique constraints, optional relationships.
For model attributes, background contexts, batch ops, undo/redo, and testing, see SwiftData references below.
Concurrency (Swift 6.2+)
Default MainActor Isolation
Opt entire module into main actor - all code runs on main actor by default:
// Package.swift
.executableTarget(name: "MyApp", swiftSettings: [
.defaultIsolation(MainActor.self),
])
Or Xcode: Build Settings > Swift Compiler > Default Isolation > MainActor.
@concurrent
Mark functions for background execution:
@concurrent
func processFile(_ url: URL) async throws -> Data {
let data = try Data(contentsOf: url)
return try compress(data) // runs off main actor
}
// After await, automatically back on main actor
let result = try await processFile(fileURL)
Use for CPU-intensive work, I/O, anything not touching UI.
Actors
actor DocumentStore {
private var docs: [UUID: Document] = [:]
func add(_ doc: Document) { docs[doc.id] = doc }
func get(_ id: UUID) -> Document? { docs[id] }
nonisolated let name: String
}
// Requires await: let doc = await store.get(id)
Structured Concurrency
// Parallel with async let
func loadDashboard() async throws -> Dashboard {
async let profile = fetchProfile()
async let stats = fetchStats()
return try await Dashboard(profile: profile, stats: stats)
}
// Dynamic with TaskGroup
func processImages(_ urls: [URL]) async throws -> [NSImage] {
try await withThrowingTaskGroup(of: (Int, NSImage).self) { group in
for (i, url) in urls.enumerated() {
group.addTask { (i, try await loadImage(url)) }
}
var results = [(Int, NSImage)]()
for try await r in group { results.append(r) }
return results.sorted { $0.0 < $1.0 }.map(\.1)
}
}
Sendable
struct Point: Sendable { var x, y: Double } // value types: implicit
final class Config: Sendable { let apiURL: URL } // final + immutable
actor SharedState { var count = 0 } // mutable: use actors
// Enable strict mode: .swiftLanguageMode(.v6) in Package.swift
AsyncSequence & Observations
// Stream @Observable changes (macOS 26+ / iOS 26+, SE-0475)
// Observations uses a closure init, not Observations(of:).
let progresses = Observations { manager.progress }
for await p in progresses { print(p) }
// Typed NotificationCenter (macOS 26+)
struct DocSaved: NotificationCenter.MainActorMessage {
typealias Subject = Document
static var name: Notification.Name { .init("DocSaved") }
let id: UUID
}
NotificationCenter.default.post(DocSaved(id: document.id), subject: document)
let token = NotificationCenter.default.addObserver(of: document, for: DocSaved.self) { msg in
refresh(msg.id)
}
For concurrency deep dives, see concurrency references below.
Foundation Models (macOS 26+)
On-device ~3B LLM. Free, offline, private:
import FoundationModels
let session = LanguageModelSession()
let response = try await session.respond(to: "Summarize: \(text)")
print(response.content) // respond() returns Response<Content>, not Content
// Structured output
@Generable struct Summary { var title: String; var points: [String] }
let result = try await session.respond(to: prompt, generating: Summary.self)
let summary: Summary = result.content
For tool calling, streaming, and sessions, see references/foundation-models.md.
Testing
import Testing
@Suite("Project Tests")
struct ProjectTests {
@Test("creates with defaults")
func create() {
let p = Project(name: "Test")
#expect(p.name == "Test")
}
@Test("formats sizes", arguments: [(1024, "1 KB"), (0, "0 KB")])
func format(bytes: Int, expected: String) {
#expect(formatSize(bytes) == expected)
}
}
// SwiftData testing
let container = try ModelContainer(
for: Project.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
)
let ctx = ModelContext(container)
ctx.insert(Project(name: "Test"))
try ctx.save()
For exit tests, attachments, UI testing, see references/testing.md.
Distribution
| Method | Sandbox | Notarization | Review |
|---|---|---|---|
| App Store | Required | Automatic | Yes |
| Developer ID | Recommended | Required | No |
| Ad-Hoc | No | No | Local only |
xcodebuild archive -scheme MyApp -archivePath MyApp.xcarchive
xcodebuild -exportArchive -archivePath MyApp.xcarchive \
-exportPath ./export -exportOptionsPlist ExportOptions.plist
xcrun notarytool submit ./export/MyApp.dmg \
--apple-id you@example.com --team-id TEAM_ID \
--password @keychain:AC_PASSWORD --wait
xcrun stapler staple ./export/MyApp.dmg
For complete distribution guide, see references/distribution.md.
SPM
// swift-tools-version: 6.3
let package = Package(
name: "MyApp",
platforms: [.macOS(.v14)],
targets: [
.executableTarget(name: "MyApp", swiftSettings: [
.swiftLanguageMode(.v6),
.defaultIsolation(MainActor.self),
]),
.testTarget(name: "MyAppTests", dependencies: ["MyApp"]),
]
)
For build plugins, macros, and Swift Build, see references/spm-build.md.
Liquid Glass (macOS 26)
Apps rebuilt with Xcode 26 SDK get automatic Liquid Glass styling. Use .glassEffect() for custom glass surfaces, GlassEffectContainer for custom hierarchies. Opt out (Xcode 26 only): UIDesignRequiresCompatibility = YES in Info.plist keeps the legacy visual style - a temporary migration aid. Apps rebuilt with Xcode 27 (beta) can no longer opt out; the key is ignored and Liquid Glass is mandatory (see references/fall-2026-releases.md).
ScreenCaptureKit
Capture screen content, app audio, and microphone (macOS 12.3+):
import ScreenCaptureKit
let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
guard let display = content.displays.first else { return }
// Filter: specific apps only
let filter = SCContentFilter(display: display, including: [targetApp], exceptingWindows: [])
// Configure
let config = SCStreamConfiguration()
config.capturesAudio = true
config.sampleRate = 48000
config.channelCount = 2
config.excludesCurrentProcessAudio = true
// Audio-only: throttle the video pipeline (it always runs). minimumFrameInterval is a
// MINIMUM gap between frames - 1/Int32.max is ~0s, i.e. native refresh rate (60+ fps
// of discarded frames and a full-screen recomposite each). Use 1 fps.
config.width = 2; config.height = 2
config.minimumFrameInterval = CMTime(value: 1, timescale: 1)
let stream = SCStream(filter: filter, configuration: config, delegate: self)
try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: nil)
try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: audioQueue)
try await stream.startCapture()
macOS 15+: SCRecordingOutput for simplified file recording, config.captureMicrophone for mic capture. macOS 14+: SCContentSharingPicker for system picker UI, SCScreenshotManager for single-frame capture.
For complete API reference, audio writing (AVAssetWriter/AVAudioFile), permissions, and examples, see references/screen-capture-audio.md.
AppKit Interop
struct WebViewWrapper: NSViewRepresentable {
let url: URL
func makeNSView(context: Context) -> WKWebView { WKWebView() }
func updateNSView(_ v: WKWebView, context: Context) {
v.load(URLRequest(url: url))
}
}
For hosting SwiftUI in AppKit and advanced bridging, see references/appkit-interop.md.
Architecture
| Pattern | Best For | Complexity |
|---|---|---|
| SwiftUI + @Observable | Small-medium, solo | Low |
| MVVM + @Observable | Medium, teams | Medium |
| TCA | Large, strict testing | High |
See references/architecture.md for all patterns with examples.
References
| File | When to read |
|---|---|
references/fall-2026-releases.md |
WWDC 2026 beta stack: macOS 27, Xcode 27, Swift 6.4, Foundation Models next-gen, Core AI, Spatial Preview, mandatory Liquid Glass |
| SwiftUI & macOS | |
references/app-lifecycle.md |
Window management, scenes, DocumentGroup, MenuBarExtra gotchas, async termination, LSUIElement issues |
references/swiftui-macos.md |
Sidebar, Inspector, Table, forms, popovers, sheets, search |
references/appkit-interop.md |
NSViewRepresentable, hosting controllers, NSHostingSceneRepresentation, sizing/scene-bridging options, AppKit Liquid Glass (NSGlassEffectView), pasteboard privacy, NSPanel/floating HUD |
references/screen-capture-audio.md |
ScreenCaptureKit, SCStream gotchas, SCStream teardown hazards, AVAudioEngine dual pipeline, AVAssetWriter crash safety, non-interleaved stereo trap, TCC gotchas, CDHash degraded-state after reinstall, SpeechAnalyzer transcription |
references/core-audio-tap.md |
CATap for per-process audio: tap-only aggregate (HFP-safe), drift compensation, rate-change anti-pattern, interleaved-stereo frame-count trap, IO proc isolation |
references/system-integration.md |
Keyboard shortcuts, drag & drop, file access, App Intents (entities, Spotlight, snippets), widgets & Control Center, process monitoring, AXUIElement, CoreAudio per-process APIs, login items, XPC, LSUIElement, os.Logger & signposts, privacy usage descriptions |
references/foundation-models.md |
On-device AI: guided generation, tool calling, streaming |
references/architecture.md |
MVVM, TCA, dependency injection, project structure |
references/testing.md |
Swift Testing, exit tests, attachments, UI testing, XCTest migration |
references/distribution.md |
App Store, Developer ID, notarization gotchas, nested bundle signing, xcodebuild signing traps (silent ad-hoc builds), sandboxing, universal binaries |
references/spm-build.md |
Package.swift, Swift Build, plugins, macros, plugin/macro validation gates, Metal toolchain download, manual .app bundle assembly, mixed ObjC targets, CLT testing |
| Concurrency | |
references/approachable-concurrency.md |
Default MainActor isolation, @concurrent, nonisolated async, runtime pitfalls |
references/actors-isolation.md |
Actor model, global actors, custom executors, reentrancy |
references/structured-concurrency.md |
Task, TaskGroup, async let, cancellation, priority, named tasks |
references/sendable-safety.md |
Sendable protocol, data race safety, @unchecked Sendable + serial queue, @preconcurrency import |
references/async-patterns.md |
AsyncSequence, AsyncStream, Observations, continuations, Clock |
references/migration-guide.md |
GCD to async/await, Combine to AsyncSequence, Swift 6 migration |
| SwiftData | |
references/models-schema.md |
@Model, @Attribute options, Codable, transformable, external storage |
references/relationships-predicates.md |
Advanced relationships, inverse rules, compound predicates |
references/container-context.md |
ModelContainer, ModelContext, background contexts, undo/redo, batch ops |
references/cloudkit-sync.md |
CloudKit setup, conflict resolution, sharing, debugging sync |
references/migrations.md |
VersionedSchema, lightweight/custom migration, Core Data migration |
Files (skills)
-
references
-
actors-isolation.md 17.1 KB
# Actors & Isolation ## Table of Contents - Actor Basics - Global Actors - @MainActor - Custom Global Actors - Nonisolated - Actor Reentrancy - Custom Executors (DispatchSerialQueue pattern) - `assumeIsolated` Recipes - Reentrancy & Ordering Hazards - `isolated deinit` (SE-0371) ## Actor Basics Actors protect mutable state from data races via serialized access: ```swift actor BankAccount { let id: UUID private(set) var balance: Decimal init(id: UUID, initialBalance: Decimal) { self.id = id self.balance = initialBalance } func deposit(_ amount: Decimal) { precondition(amount > 0) balance += amount } func withdraw(_ amount: Decimal) throws { guard balance >= amount else { throw BankError.insufficientFunds } balance -= amount } // Cross-actor operations func transfer(amount: Decimal, to other: BankAccount) async throws { try withdraw(amount) await other.deposit(amount) } } // All access from outside requires await let account = BankAccount(id: UUID(), initialBalance: 1000) await account.deposit(500) let balance = await account.balance ``` ### Actor properties - Actor-isolated properties/methods require `await` from outside - `let` properties are `nonisolated` by default (immutable = safe) - Actors are reference types (like classes) - Actors implicitly conform to `Sendable` ## Global Actors Annotate types/functions to isolate them to a shared actor: ```swift @MainActor class ViewModel { var items: [Item] = [] // protected by MainActor func refresh() async throws { let data = try await api.fetch() // suspends, but resumes on MainActor items = data } } ``` ### @MainActor specifics ```swift // On a function @MainActor func updateUI() { // Guaranteed to run on main thread } // On a property @MainActor var currentTitle: String = "" // On a closure let callback: @MainActor () -> Void = { // Runs on MainActor } // Opt out within MainActor type @MainActor class ViewModel { nonisolated var description: String { "ViewModel" // No actor isolation needed } @concurrent func heavyComputation() async -> Data { // Runs off MainActor } } ``` ## Custom Global Actors ```swift @globalActor actor DatabaseActor { static let shared = DatabaseActor() } @DatabaseActor class DatabaseManager { private var connection: Connection? func query(_ sql: String) throws -> [Row] { guard let conn = connection else { throw DBError.notConnected } return try conn.execute(sql) } } // Usage @DatabaseActor func fetchUsers() throws -> [User] { let rows = try DatabaseManager.shared.query("SELECT * FROM users") return rows.map(User.init) } ``` ## Nonisolated Opt specific members out of actor isolation: ```swift actor Cache { let name: String // implicitly nonisolated (let) nonisolated var debugDescription: String { "Cache(\(name))" // OK - only accesses nonisolated data } nonisolated func hash(into hasher: inout Hasher) { hasher.combine(name) } private var store: [String: Data] = [] func get(_ key: String) -> Data? { store[key] } } ``` ### nonisolated(unsafe) Escape hatch for when you know something is safe but compiler disagrees: ```swift // Use sparingly - bypasses safety checks nonisolated(unsafe) var legacyCallback: (() -> Void)? ``` ## Actor Reentrancy Actors don't prevent reentrancy - state can change across await points: ```swift actor ImageLoader { private var cache: [URL: NSImage] = [:] func load(_ url: URL) async throws -> NSImage { // Check cache if let cached = cache[url] { return cached } // DANGER: Another call to load() can execute here during await let image = try await downloadImage(url) // State may have changed! Check again. if let cached = cache[url] { return cached // Another task already loaded it } cache[url] = image return image } } ``` **Rule**: Never assume state is unchanged after an `await` inside an actor. ## Custom Executors Most apps don't need custom executors. The default executor (cooperative thread pool for actors, main thread for `@MainActor`) works well. The one pattern worth knowing is using a `DispatchSerialQueue` as an actor's serial executor - the replacement for `class X: @unchecked Sendable` + `nonisolated(unsafe) var` bookkeeping that Apple's own AVCam sample uses for audio/video capture. ### `DispatchSerialQueue` as an actor's serial executor Before - a class managing its own serial queue with roughly 28 `nonisolated(unsafe)` declarations and `@unchecked Sendable`: ```swift final class AudioRecorder: NSObject, @unchecked Sendable, SCStreamOutput { private let audioQueue = DispatchQueue(label: "com.app.audio") nonisolated(unsafe) private var writer: AVAssetWriter? nonisolated(unsafe) private var audioInput: AVAssetWriterInput? nonisolated(unsafe) private var sessionStarted = false // ... and 20+ more nonisolated(unsafe) vars } ``` After - an actor whose serial executor *is* the `DispatchSerialQueue`. No `@unchecked Sendable`, no `nonisolated(unsafe)` bookkeeping. Every `func` on the actor is serialized on the audio queue. `SCStreamOutput` inherits `NSObjectProtocol`, so actors can't conform directly. The idiomatic workaround is a small NSObject adapter that forwards the callback into the actor via `assumeIsolated` (safe because the callback runs on `audioQueue`, which IS the actor's executor): ```swift actor AudioRecorder { // The backing queue. Declared DispatchSerialQueue (not DispatchQueue) so that // asUnownedSerialExecutor() is available. private let audioQueue = DispatchSerialQueue(label: "com.app.audio") // Tell the runtime: run this actor's isolated code on audioQueue, not the // cooperative pool. nonisolated var unownedExecutor: UnownedSerialExecutor { audioQueue.asUnownedSerialExecutor() } // State. Ordinary actor-isolated properties, no nonisolated(unsafe). private var writer: AVAssetWriter? private var audioInput: AVAssetWriterInput? private var sessionStarted = false func start(url: URL) throws { /* ... */ } func stop() async { /* ... */ } // SCStream adapter. Pass audioQueue as the sampleHandlerQueue so the callback // runs on the actor's executor and assumeIsolated is safe. final class StreamOutput: NSObject, SCStreamOutput { unowned let recorder: AudioRecorder init(_ recorder: AudioRecorder) { self.recorder = recorder } func stream(_ stream: SCStream, didOutputSampleBuffer sb: CMSampleBuffer, of type: SCStreamOutputType) { recorder.assumeIsolated { iso in iso.handleSampleBuffer(sb, type: type) } } } nonisolated func makeStreamOutput() -> SCStreamOutput { StreamOutput(self) } private func handleSampleBuffer(_ sb: CMSampleBuffer, type: SCStreamOutputType) { // Actor-isolated, runs on audioQueue. Touches writer / audioInput directly. } } ``` **Why not just use an actor with the default executor?** Real-time audio callbacks (CoreAudio IO procs, SCStream sample handlers) must deliver on a specific dispatch queue to meet timing. The default cooperative pool cannot guarantee that. Custom executor ties the actor's isolation to the queue the callbacks already run on, so you get data-race safety *and* preserved timing, with zero bridging code. **IO procs stay `nonisolated`.** CoreAudio's real-time IO proc must not allocate, must not call `Task {}`, must not yield to `AsyncStream`. Keep the IO-proc closure outside the actor (or as a `nonisolated` method returning an IO block), `memcpy` into a staging buffer, dispatch to `audioQueue`, and `assumeIsolated` on the other side. ## `assumeIsolated` Recipes `assumeIsolated` lets a `nonisolated` function synchronously access actor state *if* it can prove it's already running on the actor's executor. With a custom `DispatchSerialQueue` executor, "already on the queue" = "already isolated to the actor." Three correct recipes, one wrong one. ### Recipe 1: CoreAudio listener registered on the actor's queue ```swift extension AudioRecorder { nonisolated func installRateListener(on deviceID: AudioObjectID) { let listener: AudioObjectPropertyListenerBlock = { [weak self] _, _ in // Block runs on audioQueue because we pass it below. self?.assumeIsolated { iso in iso.handleRateChange() // actor-isolated, zero await } } var addr = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyNominalSampleRate, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) AudioObjectAddPropertyListenerBlock(deviceID, &addr, audioQueue, listener) } } ``` ### Recipe 2: Block dispatched to the actor's queue from outside ```swift someBackgroundWork { result in self.audioQueue.async { self.assumeIsolated { iso in iso.writeFrames(result) } } } ``` ### Recipe 3: Real-time IO proc - do NOT assumeIsolated on the RT thread ```swift nonisolated func makeIOProc() -> AudioDeviceIOBlock { return { [weak self] _, inputData, _, _, _ in guard let self else { return } // RT thread. No allocation, no Task, no AsyncStream, no assumeIsolated. let frames = stageIntoRingBuffer(inputData) // Hop to audioQueue for everything else. self.audioQueue.async { self.assumeIsolated { iso in iso.writeFrames(frames) } } } } ``` ## Reentrancy & Ordering Hazards ### Don't re-dispatch when the block already runs on the actor's executor When a CoreAudio property listener is registered with the actor's `audioQueue`, the block fires *on that queue* - so the block is already isolated to the actor. Wrapping its body in an extra `audioQueue.async { assumeIsolated { ... } }` is not just redundant; it creates an **ordering bug**. Consider: `AudioObjectAddPropertyListenerBlock(deviceID, &rateAddr, audioQueue, listener)`. An `AudioObjectPropertyListenerBlock` rate-change fires on `audioQueue`. The queue already has IO-proc buffers ahead of it. If the listener does: ```swift // WRONG: re-dispatches rate change behind already-queued IO-proc work. let listener: AudioObjectPropertyListenerBlock = { [weak self] _, _ in self?.audioQueue.async { self?.assumeIsolated { iso in iso.handleRateChange() } } } ``` ...then `handleRateChange` runs *after* the IO-proc buffers queued before it. Those buffers read the stale `tapFormat` for one cycle, producing pitch/length corruption. Correct pattern: call `assumeIsolated` directly, no inner `async`: ```swift let listener: AudioObjectPropertyListenerBlock = { [weak self] _, _ in self?.assumeIsolated { iso in iso.handleRateChange() } } ``` ### State can change across every `await` Standard actor reentrancy rule - restated because it bites concrete patterns: ```swift actor ImageLoader { private var cache: [URL: NSImage] = [:] func load(_ url: URL) async throws -> NSImage { if let cached = cache[url] { return cached } let image = try await downloadImage(url) // Another load() may have populated cache during the await. if let cached = cache[url] { return cached } cache[url] = image return image } } ``` ### Don't assign the reference before awaiting a suspension that can race `stop()` ```swift // FRAGILE: assignment happens after the await. A concurrent stop() during // startCapture's suspension calls stopCapture() on a nil stream; cleanup // silently no-ops, and the SCStream that eventually starts is orphaned. func start() async throws { let s = SCStream(...) try await s.startCapture() // <-- suspension self.stream = s } // ROBUST: assignment first, so stop() during the suspension drives cleanup. func start() async throws { let s = SCStream(...) self.stream = s try await s.startCapture() } ``` SCStream tolerates `stopCapture()` on an unstarted stream; not all APIs do. Test the "stop during start" path explicitly. ## `isolated deinit` (SE-0371, Swift 6.2+) `deinit` is `nonisolated` by default — it cannot touch actor-isolated state. SE-0371 (shipped in Swift 6.2) lets actors and global-actor-isolated classes mark `deinit` as `isolated`; the runtime hops to the relevant executor (including a custom `unownedExecutor` like `DispatchSerialQueue`) before running the body, so cleanup can access isolated properties directly. No `nonisolated(unsafe)` mirrors needed for observer tokens, listener IDs, or `beginActivity` handles. Caveat: task-local values set outside are **not** visible inside an isolated deinit — SE-0371 clears them on entry. Escaping `self` from an isolated deinit still traps. Source: https://github.com/swiftlang/swift-evolution/blob/main/proposals/0371-isolated-synchronous-deinit.md ```swift actor AudioRecorder { private let audioQueue = DispatchSerialQueue(label: "audio") nonisolated var unownedExecutor: UnownedSerialExecutor { audioQueue.asUnownedSerialExecutor() } // Cached at start() time so deinit can tear them down. private var listenerIDs: Set<AudioObjectID> = [] private let rateListener: AudioObjectPropertyListenerBlock private var observerTokens: [NSObjectProtocol] = [] private var activityToken: NSObjectProtocol? init() { self.rateListener = { _, _ in /* handle rate change */ } } private func rateAddr() -> AudioObjectPropertyAddress { AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyNominalSampleRate, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) } isolated deinit { // Runs on audioQueue, can touch actor-isolated state directly. var addr = rateAddr() for id in listenerIDs { AudioObjectRemovePropertyListenerBlock(id, &addr, audioQueue, rateListener) } observerTokens.forEach { NotificationCenter.default.removeObserver($0) } if let t = activityToken { ProcessInfo.processInfo.endActivity(t) } } } ``` The "before" equivalent required `nonisolated(unsafe) var` copies of every piece of cleanup state, kept in sync with the actor-isolated originals. ## Reentrancy: a `Bool` flag does not make async start/stop safe The classic shape - an actor with `start()` and `stop()` guarded by a `stopped` flag - is unsound the moment `start()` contains an `await`. Actors are reentrant: `stop()` runs *during* the suspension, flips the flag, and the resumed `start()` walks past it and finishes building a live object that no caller holds a reference to. Observed failure: a `start()` suspended for ~12 s inside a slow `SCShareableContent` query. `stop()` arrived mid-suspension, set `stopped = true`, and returned. `start()` resumed, checked nothing further, and built a running recorder. It stayed alive for hours dropping every buffer at a `guard !stopped` in the output callback, never finalized the writer, and left a zero-byte file plus a UI stuck in "recording". ```swift // WRONG - one flag read, checked before the await that matters actor Recorder { private var stopped = false func start() async throws { guard !stopped else { return } let content = try await SCShareableContent.current // stop() lands here stream = try buildStream(content) // built anyway try await stream?.startCapture() } } ``` Two rules fix it: **Re-check cancellation after every await, not once at the top.** ```swift actor Recorder { private enum State { case idle, starting, running, stopping } private var state: State = .idle private var stream: SCStream? func start() async throws { guard state == .idle else { throw RecorderError.alreadyRunning } state = .starting let content = try await SCShareableContent.current guard state == .starting else { // re-check try await cleanupPartialStart() throw RecorderError.cancelled } let built = try buildStream(content) guard state == .starting else { // re-check again try? await built.stopCapture() throw RecorderError.cancelled } stream = built try await built.startCapture() state = .running } } ``` **Make cleanup idempotent by taking ownership before awaiting.** Copy the resource to a local and nil the actor field *before* any suspension, so an interleaved `stop()` cannot double-free or act on a half-torn-down object: ```swift func stop() async { guard let stream else { state = .idle; return } self.stream = nil // take ownership BEFORE the await state = .stopping try? await stream.stopCapture() await finalizeWriter() state = .idle } ``` Distinct error cases (`.cancelled` vs `.alreadyRunning`) are worth the extra enum - they let tests assert the exact interleaving outcome instead of just "it threw". Delete partial artifacts on a failed start; a zero-byte output file is worse than no file. -
app-lifecycle.md 16.2 KB
# App Lifecycle & Scenes ## Table of Contents - App Protocol & Entry Point - WindowGroup - Window (Single Instance) - Settings Scene - MenuBarExtra - MenuBarExtra Gotchas - DocumentGroup - Window Management - Scene Phases - Async Termination Cleanup - LSUIElement Operational Issues ## App Protocol & Entry Point ```swift @main struct MyApp: App { // App-level state @State private var appState = AppState() // App delegate for system events @NSApplicationDelegateAdaptor private var delegate: AppDelegate var body: some Scene { WindowGroup("Projects", id: "projects") { ContentView() .environment(appState) } .defaultSize(width: 1000, height: 700) .defaultPosition(.center) .keyboardShortcut("1", modifiers: .command) Window("Activity", id: "activity") { ActivityView() } .defaultSize(width: 400, height: 600) .windowResizability(.contentMinSize) Settings { SettingsView() .environment(appState) } MenuBarExtra("MyApp", systemImage: "app.fill") { MenuBarContentView() } .menuBarExtraStyle(.window) } } ``` ## WindowGroup Creates standard resizable windows. Multiple instances allowed by default: ```swift // Basic WindowGroup { ContentView() } // With identifier for programmatic opening WindowGroup("Editor", id: "editor") { EditorView() } // With data binding - open window for specific item WindowGroup("Detail", id: "detail", for: Item.ID.self) { $itemID in if let itemID { DetailView(itemID: itemID) } } // Open programmatically @Environment(\.openWindow) private var openWindow Button("Open Editor") { openWindow(id: "editor") } Button("Show Detail") { openWindow(value: selectedItem.id) } ``` Window modifiers: ```swift WindowGroup { ContentView() } .defaultSize(width: 800, height: 600) .defaultSize(CGSize(width: 800, height: 600)) .defaultPosition(.center) // .leading, .trailing, .topLeading, etc. .windowResizability(.automatic) // .contentSize, .contentMinSize .windowStyle(.automatic) // .hiddenTitleBar, .titleBar .windowToolbarStyle(.unified) // .unifiedCompact, .expanded, .automatic .keyboardShortcut("n", modifiers: .command) ``` ## Window (Single Instance) For utility/auxiliary windows that should have only one instance: ```swift Window("Inspector", id: "inspector") { InspectorView() } .defaultSize(width: 300, height: 500) .windowResizability(.contentSize) .commandsRemoved() // Remove default window commands ``` Dismiss from within: ```swift @Environment(\.dismissWindow) private var dismissWindow Button("Close") { dismissWindow(id: "inspector") } ``` ## Settings Scene Preferences window accessible via Cmd+,: ```swift Settings { TabView { GeneralSettingsView() .tabItem { Label("General", systemImage: "gear") } AppearanceSettingsView() .tabItem { Label("Appearance", systemImage: "paintpalette") } AdvancedSettingsView() .tabItem { Label("Advanced", systemImage: "wrench") } } .frame(width: 450) } ``` Use `@AppStorage` for UserDefaults-backed preferences: ```swift struct GeneralSettingsView: View { @AppStorage("autoSave") private var autoSave = true @AppStorage("fontSize") private var fontSize = 14.0 @AppStorage("theme") private var theme = "system" var body: some View { Form { Toggle("Auto-save documents", isOn: $autoSave) Slider(value: $fontSize, in: 10...24, step: 1) { Text("Font Size: \(Int(fontSize))pt") } Picker("Theme", selection: $theme) { Text("System").tag("system") Text("Light").tag("light") Text("Dark").tag("dark") } } .formStyle(.grouped) .padding() } } ``` ## MenuBarExtra Two styles - menu or window: ```swift // Menu style (dropdown menu) MenuBarExtra("Status", systemImage: "circle.fill") { Button("Show Dashboard") { openWindow(id: "dashboard") } Divider() Toggle("Monitoring", isOn: $isMonitoring) Divider() Button("Quit") { NSApplication.shared.terminate(nil) } } .menuBarExtraStyle(.menu) // Window style (popover window) MenuBarExtra("Status", systemImage: "circle.fill") { VStack { Text("System Status") .font(.headline) StatusDashboard() } .frame(width: 300, height: 400) } .menuBarExtraStyle(.window) ``` Dynamic icon: ```swift MenuBarExtra { MenuBarContent() } label: { Image(systemName: isConnected ? "wifi" : "wifi.slash") if showBadge { Text("\(unreadCount)") } } ``` ## MenuBarExtra Gotchas ### .menu style strips SwiftUI font modifiers With `.menuBarExtraStyle(.menu)`, content renders to native `NSMenu` items via `NSStatusBarButton`. All SwiftUI font modifiers (`.monospacedDigit()`, `.font(.system(.body, design: .monospaced))`) are silently ignored. Timer displays like "0:05" jump width as digits change. Fix options: 1. **Fixed-width frame**: `.frame(width: 38)` on the timer text 2. **ImageRenderer trick** (used by AeroSpace app): render `Text` with proper font into a `CGImage`, display as `Image`. Font modifiers are baked into the rendered image, bypassing the bridge. ### TimelineView doesn't work in .menu style `TimelineView(.periodic(from: .now, by: 1))` doesn't tick - `.menu` style renders to static `NSMenu` items. Use a `Timer` firing every second that updates an `@Observable` property instead: ```swift @Observable class AppState { var formattedElapsed = "0:00" private var timer: Timer? func startTimer() { timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in self?.formattedElapsed = self?.computeElapsed() ?? "0:00" } } } ``` ### onAppear never fires until menu is opened With `.menuBarExtraStyle(.menu)`, the content view's `onAppear` only fires when the user clicks the menu bar icon. Never place initialization code (monitoring start, permission requests, delegate setup) in `onAppear`. Use `applicationDidFinishLaunching` instead. ### @NSApplicationDelegateAdaptor reference timing `(NSApplication.shared.delegate as? AppDelegate)?.monitor = monitor` in `App.init()` may silently fail. Use the adaptor property directly: ```swift @main struct MyApp: App { @NSApplicationDelegateAdaptor private var delegate: AppDelegate init() { delegate.monitor = monitor // Works - uses adaptor property directly } } ``` ## DocumentGroup For document-based apps: ```swift @main struct TextEditorApp: App { var body: some Scene { DocumentGroup(newDocument: TextDocument()) { file in TextEditorView(document: file.$document) } } } // Document type struct TextDocument: FileDocument { static var readableContentTypes: [UTType] { [.plainText] } var text: String init(text: String = "") { self.text = text } init(configuration: ReadConfiguration) throws { guard let data = configuration.file.regularFileContents, let text = String(data: data, encoding: .utf8) else { throw CocoaError(.fileReadCorruptFile) } self.text = text } func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { let data = text.data(using: .utf8)! return FileWrapper(regularFileWithContents: data) } } ``` For `ReferenceFileDocument` (class-based, supports undo): ```swift class RichDocument: ReferenceFileDocument { static var readableContentTypes: [UTType] { [.rtf] } @Published var content: AttributedString required init(configuration: ReadConfiguration) throws { /* ... */ } func snapshot(contentType: UTType) throws -> Data { /* ... */ } func fileWrapper(snapshot: Data, configuration: WriteConfiguration) throws -> FileWrapper { FileWrapper(regularFileWithContents: snapshot) } } ``` ## Window Management ### Restore behavior (macOS 15+) ```swift WindowGroup { ContentView() } .restorationBehavior(.enabled) // .disabled, .enabled ``` ### Window level ```swift .windowLevel(.floating) // Keep window above others ``` ### Presented window style `presentedWindowStyle` is a `View` modifier that styles windows this view presents. The complete set of `WindowStyle` values in the macOS 26.5 SDK is `.automatic`, `.titleBar`, `.hiddenTitleBar`, and `.plain` - there is no `.fullScreen` member. ```swift .presentedWindowStyle(.hiddenTitleBar) // .automatic, .titleBar, .plain ``` Full-screen is not a `WindowStyle`. Drive it from AppKit (`NSWindow.toggleFullScreen(_:)`) or let the user use the standard green-button behavior. ## Scene Phases React to app lifecycle: ```swift @Environment(\.scenePhase) private var scenePhase var body: some View { ContentView() .onChange(of: scenePhase) { oldPhase, newPhase in switch newPhase { case .active: // App is active and visible refreshData() case .inactive: // App is visible but not interactive break case .background: // App is in background saveState() @unknown default: break } } } ``` ## NSApplicationDelegateAdaptor Bridge to AppKit delegate for system events: ```swift class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { // Register URL scheme handlers, set up global state } func applicationWillTerminate(_ notification: Notification) { // Cleanup } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false // Keep menu bar app alive } func application(_ application: NSApplication, open urls: [URL]) { // Handle URL scheme } } ``` ## Async Termination Cleanup For apps that need async cleanup before quitting (finalizing file writers, stopping streams): ```swift class AppDelegate: NSObject, NSApplicationDelegate { var monitor: AudioMonitor? private var hasReplied = false func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { hasReplied = false // Cleanup task Task { @MainActor in await monitor?.stopAndSave() guard !hasReplied else { return } hasReplied = true NSApplication.shared.reply(toApplicationShouldTerminate: true) } // Timeout task - prevents hanging forever Task { @MainActor in try? await Task.sleep(for: .seconds(8)) guard !hasReplied else { return } hasReplied = true NSApplication.shared.reply(toApplicationShouldTerminate: true) } return .terminateLater } } ``` The `hasReplied` flag prevents double-reply (undefined behavior). Both Tasks are MainActor (serial), so the flag check is race-free. Use `movieFragmentInterval` on AVAssetWriter to bound data loss to ~10s even if timeout fires. ### Prevent idle sleep during recording ```swift var activity: NSObjectProtocol? activity = ProcessInfo.processInfo.beginActivity( .userInitiated, reason: "Recording audio" ) // ... later: if let a = activity { ProcessInfo.processInfo.endActivity(a) } ``` ## LSUIElement Operational Issues ### Windows open behind other apps LSUIElement apps don't auto-activate. When opening windows from the menu: ```swift Button("Show Settings") { openWindow(id: "settings") NSApplication.shared.activate(ignoringOtherApps: true) } ``` ### Cmd+, doesn't work LSUIElement apps have no app menu bar, so `CommandGroup(replacing: .appSettings)` has nowhere to attach. Add `.keyboardShortcut(",", modifiers: .command)` to a button in the menu dropdown (only works when menu is open). ### Crash detection When an LSUIElement menu bar app crashes, the icon silently disappears - no crash dialog. Use a watchdog helper binary that monitors the main process via `kqueue`/`kevent(EVFILT_PROC, NOTE_EXIT)` and shows an alert on crash signals (SIGTRAP, SIGABRT, SIGSEGV). ### Onboarding for LSUIElement apps Host onboarding in a manually-created `NSWindow` (not a SwiftUI `Window` scene), since the window must appear before any user interaction: ```swift func applicationDidFinishLaunching(_ notification: Notification) { guard !UserDefaults.standard.bool(forKey: "hasCompletedOnboarding") else { return } // Skip onboarding for existing users who already have permissions if CGPreflightScreenCaptureAccess() { UserDefaults.standard.set(true, forKey: "hasCompletedOnboarding") return } let window = NSWindow(contentRect: .init(x: 0, y: 0, width: 500, height: 400), styleMask: [.titled, .closable], backing: .buffered, defer: false) window.isReleasedWhenClosed = false // Prevent dangling reference crash window.contentView = NSHostingView(rootView: OnboardingView(onComplete: { ... })) window.center() window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) } ``` Use `onComplete` callback instead of `@Environment(\.dismiss)` - dismiss has no backing in a manually-created NSWindow. Set `isReleasedWhenClosed = false` and nil the window reference before calling `close()` if you also use `NSWindowDelegate.windowWillClose`. ### Restarting the app Don't use `NSWorkspace.OpenConfiguration(createsNewApplicationInstance: true)` - it spawns a duplicate before the first exits. Use `Process` + terminate: ```swift func restartApp() { let path = Bundle.main.bundlePath Process.launchedProcess(launchPath: "/usr/bin/open", arguments: [path]) NSApplication.shared.terminate(nil) } ``` ## Crash: high-frequency MenuBarExtra label updates A per-second timer driving the `MenuBarExtra` label (an elapsed-time readout, a live meter) can crash intermittently with `EXC_BREAKPOINT` inside `-[NSWindow _postWindowNeedsUpdateConstraints]`. The `NSHostingView` backing the status item is pushed into an AppKit constraint-update exception by the repeated re-layout. It compiles, usually runs, and crashes in the field. Keep the SwiftUI label static and push frequent text through AppKit directly: ```swift // Static label - SwiftUI never re-lays-out on every tick MenuBarExtra("Recorder", systemImage: "record.circle") { MenuBarView() } // Update the status item's title from AppKit instead statusItem.button?.attributedTitle = NSAttributedString( string: elapsed, attributes: [.font: NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular)] ) ``` Monospaced digits stop the width from oscillating each tick, which is what drives the constraint churn. If you must render SwiftUI, pre-render with `ImageRenderer` and assign the resulting image, and lower the update frequency - a wall-clock readout rarely needs more than 1 Hz, and a meter can be throttled well below its sample rate. ## Crash visibility for menu-bar-only apps When an `LSUIElement` app crashes, macOS suppresses the "quit unexpectedly" dialog. The icon simply disappears and the user keeps believing the app is running - the worst failure mode for anything doing background recording or monitoring. An in-app `NSSetUncaughtExceptionHandler` only logs, and a crash notice shown at next launch lands in a dropdown nobody opens. Bundle a small helper that watches the main app's PID and reports unexpected exits: ```c int fd = kqueue(); struct kevent change; EV_SET(&change, targetPID, EVFILT_PROC, EV_ADD | EV_ENABLE, NOTE_EXIT, 0, NULL); kevent(fd, &change, 1, NULL, 0, NULL); struct kevent event; kevent(fd, NULL, 0, &event, 1, NULL); // blocks until the process exits // Inspect exit status, then show a dialog if it was not a clean quit ``` Choose the hosting model deliberately: | Model | Pros | Cons | |---|---|---| | Spawned by the app at launch | No system prompt, dies with the parent, nothing in System Settings | Cannot catch very early startup crashes | | `SMAppService` login item in `Contents/Library/LoginItems/` | Survives reboot, catches startup crashes | Triggers a one-time "Background Items Added" notification, and the user can silently disable it in System Settings | The spawned helper is the lighter default. Reach for the registered login item only if crashes during startup are the ones you actually need to see. -
appkit-interop.md 12.5 KB
# AppKit Interop ## Table of Contents - NSViewRepresentable - NSViewControllerRepresentable - Hosting SwiftUI in AppKit - Common Bridges - NSWindow Access - NSPanel / Floating HUD ## NSViewRepresentable Wrap an AppKit view for use in SwiftUI: ```swift struct ColorWellView: NSViewRepresentable { @Binding var color: Color func makeNSView(context: Context) -> NSColorWell { let well = NSColorWell() well.target = context.coordinator well.action = #selector(Coordinator.colorChanged(_:)) return well } func updateNSView(_ well: NSColorWell, context: Context) { well.color = NSColor(color) } func makeCoordinator() -> Coordinator { Coordinator(color: $color) } class Coordinator: NSObject { var color: Binding<Color> init(color: Binding<Color>) { self.color = color } @objc func colorChanged(_ sender: NSColorWell) { color.wrappedValue = Color(nsColor: sender.color) } } } ``` ### Lifecycle methods ```swift func makeNSView(context: Context) -> NSView // Create (called once) func updateNSView(_ view: NSView, context: Context) // Update (called on state changes) static func dismantleNSView(_ view: NSView, coordinator: Coordinator) // Cleanup // sizeThatFits for intrinsic sizing func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSView, context: Context) -> CGSize? { nsView.intrinsicContentSize == .zero ? nil : nsView.intrinsicContentSize } ``` ### Coordinator for delegates ```swift struct TextViewWrapper: NSViewRepresentable { @Binding var text: String func makeNSView(context: Context) -> NSScrollView { let scrollView = NSTextView.scrollableTextView() let textView = scrollView.documentView as! NSTextView textView.delegate = context.coordinator textView.isEditable = true textView.font = .monospacedSystemFont(ofSize: 13, weight: .regular) return scrollView } func updateNSView(_ scrollView: NSScrollView, context: Context) { let textView = scrollView.documentView as! NSTextView if textView.string != text { textView.string = text } } func makeCoordinator() -> Coordinator { Coordinator(text: $text) } class Coordinator: NSObject, NSTextViewDelegate { var text: Binding<String> init(text: Binding<String>) { self.text = text } func textDidChange(_ notification: Notification) { guard let textView = notification.object as? NSTextView else { return } text.wrappedValue = textView.string } } } ``` ## NSViewControllerRepresentable Wrap entire AppKit view controllers: ```swift struct PDFViewWrapper: NSViewControllerRepresentable { let url: URL func makeNSViewController(context: Context) -> PDFViewController { PDFViewController(url: url) } func updateNSViewController(_ controller: PDFViewController, context: Context) { controller.loadPDF(from: url) } } ``` ## Hosting SwiftUI in AppKit Embed SwiftUI views inside AppKit apps: ```swift // In an NSViewController class MainViewController: NSViewController { override func viewDidLoad() { super.viewDidLoad() let swiftUIView = ContentView() let hostingView = NSHostingView(rootView: swiftUIView) hostingView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(hostingView) NSLayoutConstraint.activate([ hostingView.topAnchor.constraint(equalTo: view.topAnchor), hostingView.bottomAnchor.constraint(equalTo: view.bottomAnchor), hostingView.leadingAnchor.constraint(equalTo: view.leadingAnchor), hostingView.trailingAnchor.constraint(equalTo: view.trailingAnchor), ]) } } // As a window let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 600, height: 400), styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false ) window.contentView = NSHostingView(rootView: ContentView()) window.makeKeyAndOrderFront(nil) ``` ### Sizing and window bridging options A bare `NSHostingView` has **no intrinsic content size** and does not surface its content's title or toolbar to the enclosing window - both are opt-in: ```swift let hosting = NSHostingView(rootView: ContentView()) hosting.sizingOptions = [.intrinsicContentSize] // macOS 13+ hosting.sceneBridgingOptions = [.title, .toolbars] // macOS 14+ ``` `NSHostingSizingOptions` controls how the hosting view reflects its content's size into Auto Layout constraints; `NSHostingSceneBridgingOptions` controls which window aspects the SwiftUI content is allowed to manage. If a hosted view "has no size" under Auto Layout, `sizingOptions` is almost always the missing piece. ### Hosting whole SwiftUI scenes (macOS 26+) Hand-rolling an `NSWindow` for a Settings screen, as above, is the old way. macOS 26 adds `NSHostingSceneRepresentation` - an AppKit type that hosts and presents SwiftUI **scenes** - registered via `NSApplication.addSceneRepresentation(_:)`. An AppKit-lifecycle app can present a SwiftUI `Settings` or `Window` scene with its real scene semantics (Cmd+, handling, restoration, single-instance behaviour) instead of reimplementing them around a manually created window. Prefer it over the manual `NSWindow` recipe when your deployment target allows. ### Gesture recognizers `NSGestureRecognizerRepresentable` (macOS 26+) is the third `Representable` protocol alongside `NSViewRepresentable` and `NSViewControllerRepresentable`. It wraps an `NSGestureRecognizer` for use in a SwiftUI hierarchy - the way to reuse an existing AppKit recognizer, or to get recognizer behaviour SwiftUI's built-in gestures do not expose. ## Liquid Glass in AppKit (macOS 26+) The SwiftUI half of Liquid Glass is `.glassEffect()` / `GlassEffectContainer`. AppKit has its own: | Type | Purpose | |---|---| | `NSGlassEffectView` | "A view that embeds its content view in a dynamic glass effect." | | `NSGlassEffectContainerView` | "A view that efficiently merges descendant glass effect views together when they are within a specified proximity to each other." | | `NSBackgroundExtensionView` | "A view that extends content to fill its own bounds" - lay it out past the safe area to run content under the titlebar, sidebar, or inspector. | The single most useful migration escape hatch is on `NSView` itself: ```swift view.prefersCompactControlSizeMetrics = true ``` > When this property is `YES`, any `NSControl`s in the view or its descendants will be sized with compact metrics compatible with macOS 15.0 and earlier. Defaults to `NO`. That is the fix when Liquid Glass inflates an existing dense AppKit layout - a toolbar or inspector that was tuned for pre-26 metrics and now overflows. All four symbols are in the macOS 26.5 SDK (`AppKit.framework/Headers/NSGlassEffectView.h`, `NSBackgroundExtensionView.h`, `NSView.h`). ## Common Bridges ### NSPasteboard (Clipboard) ```swift // Copy NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) // Paste if let string = NSPasteboard.general.string(forType: .string) { // use string } ``` **Reading the general pasteboard is becoming user-visible.** macOS 15.4 added `NSPasteboard.accessBehavior`, and Apple is rolling out an alert shown when an app programmatically reads the general pasteboard - the `string(forType:)` call above is exactly what triggers it: > Prepare your app for an upcoming feature in macOS that alerts a person using a device when your app programmatically reads the general pasteboard. ... New `detect` methods in NSPasteboard and NSPasteboardItem make it possible for an app to examine the kinds of data on the pasteboard without actually reading them and showing the alert. If you only need to know *whether* the pasteboard holds something you can handle - to enable a Paste menu item, say - use `detectedValues(for:)` / `detectedPatterns(for:)` rather than reading. Read the contents only in response to an explicit user paste action, which is what the alert is designed to permit. Test the behaviour ahead of the rollout: ```bash defaults write <your_app_bundle_id> EnablePasteboardPrivacyDeveloperPreview -bool yes ``` Apps using the standard responder-chain paste (`NSTextView`, `Cmd+V` through first responder) are unaffected; this hits polling and clipboard-manager patterns. ### NSWorkspace ```swift // Open file in default app NSWorkspace.shared.open(fileURL) // Open URL in browser NSWorkspace.shared.open(URL(string: "https://example.com")!) // Reveal in Finder NSWorkspace.shared.activateFileViewerSelecting([fileURL]) // Get running applications let apps = NSWorkspace.shared.runningApplications ``` ### NSSavePanel / NSOpenPanel ```swift func selectFile() async -> URL? { let panel = NSOpenPanel() panel.allowedContentTypes = [.json, .plainText] panel.allowsMultipleSelection = false panel.canChooseDirectories = false let result = await panel.begin() return result == .OK ? panel.url : nil } func saveFile() async -> URL? { let panel = NSSavePanel() panel.allowedContentTypes = [.json] panel.nameFieldStringValue = "export.json" let result = await panel.begin() return result == .OK ? panel.url : nil } ``` ## NSWindow Access Access the underlying NSWindow from SwiftUI: ```swift struct WindowAccessor: NSViewRepresentable { let callback: (NSWindow) -> Void func makeNSView(context: Context) -> NSView { let view = NSView() DispatchQueue.main.async { if let window = view.window { callback(window) } } return view } func updateNSView(_ nsView: NSView, context: Context) {} } // Usage ContentView() .background(WindowAccessor { window in window.titlebarAppearsTransparent = true window.isOpaque = false window.backgroundColor = .clear }) ``` ## NSPanel / Floating HUD For toast notifications, recording indicators, or floating widgets that must appear above all apps (including full-screen): ```swift class HUDPanel: NSPanel { init(content: some View) { super.init( contentRect: .zero, styleMask: [.nonactivatingPanel, .fullSizeContentView], backing: .buffered, defer: true ) level = .floating isOpaque = false backgroundColor = .clear hidesOnDeactivate = false // CRITICAL for menu bar apps isReleasedWhenClosed = false // Prevent dangling reference collectionBehavior = [ .canJoinAllSpaces, // Visible on all Spaces/desktops .fullScreenAuxiliary, // Visible over full-screen apps .transient, // Don't appear in Mission Control ] let hostingView = NSHostingView(rootView: content) hostingView.frame.size = hostingView.fittingSize contentView = hostingView setContentSize(hostingView.fittingSize) } func show(on screen: NSScreen? = NSScreen.main) { guard let screen else { return } let size = contentView?.fittingSize ?? .zero // Use visibleFrame (not frame) to avoid menu bar overlap let origin = NSPoint( x: screen.visibleFrame.maxX - size.width - 16, y: screen.visibleFrame.maxY - size.height - 16 ) setFrameOrigin(origin) orderFrontRegardless() // Auto-dismiss after delay DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in self?.close() } } } ``` Key gotchas: - **`hidesOnDeactivate = false`**: Required for menu bar apps - the HUD must stay visible when the app isn't frontmost. - **Use `screen.visibleFrame`** not `screen.frame` - `frame` includes the menu bar area. - **`.fixedSize()`** on the SwiftUI content - without it, `fittingSize` compresses the content and text truncates. - **`isReleasedWhenClosed = false`**: Prevents a dangling reference crash if you hold a strong reference to the panel. ### HUD actions bridging to SwiftUI NSPanel can't access `@Environment(\.openWindow)`. Use NotificationCenter to bridge: ```swift // In HUD - post notification on click: static let showMainWindowNotification = Notification.Name("ShowMainWindow") NSNotificationCenter.default.post(name: Self.showMainWindowNotification, object: nil) // In SwiftUI MenuBarExtra content - receive and act: .onReceive(NotificationCenter.default.publisher(for: HUDPanel.showMainWindowNotification)) { _ in openWindow(id: "main") NSApp.activate(ignoringOtherApps: true) } ``` -
approachable-concurrency.md 13.1 KB
# Approachable Concurrency (Swift 6.2) ## Table of Contents - Vision & Philosophy - Default MainActor Isolation - @concurrent Attribute - Nonisolated Async Changes - Enabling in Xcode - Migration Strategy - Runtime Pitfalls with Default Isolation ## Vision & Philosophy Swift 6.2 addresses feedback that Swift Concurrency was too difficult to adopt. The key insight: most app code doesn't need concurrency. The new model follows progressive disclosure: 1. **Phase 1** - Write sequential code. Default isolation keeps everything on main actor. 2. **Phase 2** - Add `async/await` for suspension without introducing parallelism. 3. **Phase 3** - Opt into parallelism with `@concurrent` when you need performance. ## Default MainActor Isolation ### Enabling Package.swift: ```swift .executableTarget( name: "MyApp", swiftSettings: [ .defaultIsolation(MainActor.self), ] ) ``` Xcode: Build Settings > Swift Compiler - Upcoming Features > Default Isolation > MainActor Default isolation is a **target-level** compiler setting - there is no documented per-file `defaultIsolation(nil)` directive. Opt out per-declaration instead: ```swift // At the declaration level nonisolated class NetworkClient { /* not MainActor-isolated even inside a MainActor-default target */ } @concurrent func heavyWork() async -> Data { /* explicitly runs off the caller actor */ } ``` ### What changes With `-default-isolation MainActor`: ```swift // Before: needed explicit annotations @MainActor class ViewModel { @MainActor var items: [Item] = [] @MainActor func refresh() async { /* ... */ } } // After: everything is implicitly @MainActor class ViewModel { var items: [Item] = [] // protected by MainActor func refresh() async { items = try await fetchItems() // suspends, resumes on main } } ``` ### What's NOT affected - Types in libraries imported without default isolation - Protocol conformances to protocols defined outside the module - `@concurrent` functions (explicit opt-out) - Types explicitly marked `nonisolated` ### Interaction with existing code ```swift // This "just works" now struct ContentView: View { @State private var items: [Item] = [] var body: some View { List(items) { item in Text(item.name) } .task { // No actor-isolation errors - everything on MainActor items = try await loadItems() } } } ``` ## @concurrent Attribute Explicitly request execution on the concurrent thread pool: ```swift // Heavy computation - run off main actor @concurrent func compressImage(_ data: Data, quality: Double) async throws -> Data { // Runs on concurrent pool, keeping main actor free let source = CGImageSourceCreateWithData(data as CFData, nil)! // ... expensive processing return compressedData } // I/O-bound work @concurrent func loadFileContents(_ url: URL) async throws -> String { try String(contentsOf: url, encoding: .utf8) } // Call from MainActor context func handleImport() async throws { // Automatically dispatched to concurrent pool let data = try await loadFileContents(fileURL) // Back on MainActor after await self.content = data } ``` ### When to use @concurrent - CPU-intensive work (image processing, data parsing, compression) - Large file I/O - Expensive computations (sorting large datasets, cryptography) - Any work that would cause UI jank if run on the main actor ### When NOT to use @concurrent - Simple property access or state updates - UI-related operations - Short synchronous operations - Functions that primarily call other async functions (just let them suspend) ## Nonisolated Async Changes In Swift 6.2, `nonisolated async` functions behave differently: ```swift // Swift 6.1: nonisolated async always ran on global pool // Swift 6.2: nonisolated async runs in caller's context class DataProcessor { nonisolated func process() async -> Data { // Swift 6.1: This ran on the global concurrent pool // Swift 6.2: This runs wherever the caller is (e.g., MainActor) return computeData() } } // To get the old behavior (run on concurrent pool), use @concurrent: class DataProcessor { @concurrent func process() async -> Data { return computeData() // Explicitly on concurrent pool } } ``` This change (SE-0461) is gated on the **`NonisolatedNonsendingByDefault` upcoming feature flag** — it is not on by default in Swift 6.2, not even when default isolation is set. Enable it explicitly in Package.swift: ```swift swiftSettings: [ .defaultIsolation(MainActor.self), .enableUpcomingFeature("NonisolatedNonsendingByDefault"), ] ``` Without the flag, `nonisolated async` still runs on the global concurrent pool. Source: https://github.com/swiftlang/swift-evolution/blob/main/proposals/0461-async-function-isolation.md ## Migration Strategy ### From Swift 5 / no concurrency 1. Enable `-default-isolation MainActor` on your target 2. Build and fix errors (mostly around library boundaries) 3. Mark expensive functions with `@concurrent` 4. Test thoroughly - behavior change is mostly in async function execution context ### From Swift 6.0/6.1 with explicit @MainActor 1. Enable default isolation 2. Remove redundant `@MainActor` annotations 3. Replace `nonisolated` background work with `@concurrent` 4. Remove unnecessary `Sendable` annotations (compiler infers more) ### Gradual adoption Split modules at the SPM level — give the converted target `.defaultIsolation(MainActor.self)` and leave the legacy target alone. Within the converted target, use `nonisolated` / `@concurrent` to opt individual declarations out (see the "Default MainActor Isolation" section above for why there's no per-file directive). ## Runtime Pitfalls with Default Isolation These issues compile cleanly but crash at runtime. The compiler does not always catch them. ### Closure isolation inheritance (most dangerous) Closures defined inside MainActor-isolated methods inherit MainActor isolation. When passed to APIs that call them on background threads, Swift 6 runtime checks the executor and crashes with `dispatch_assert_queue_fail` / SIGTRAP. Affected APIs include `AVAudioEngine.installTap`, `NotificationCenter.addObserver` with `queue: nil`, `DispatchSource.setEventHandler`, and `NSSetUncaughtExceptionHandler`. ```swift // CRASHES at runtime - closure inherits MainActor isolation but runs on audio thread: func startCapture() { engine.inputNode.installTap(onBus: 0, bufferSize: 1024, format: fmt) { buffer, time in self.process(buffer) // dispatch_assert_queue_fail } } // FIX - extract to @Sendable typed variable to break isolation inheritance: func startCapture() { let handler: @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void = { [weak self] buffer, time in self?.process(buffer) // nonisolated, safe on any thread } engine.inputNode.installTap(onBus: 0, bufferSize: 1024, format: fmt, block: handler) } ``` Same fix needed for NotificationCenter observers: ```swift // CRASHES - observer closure inherits MainActor, but CoreAudio fires on I/O thread: NotificationCenter.default.addObserver( forName: .AVAudioEngineConfigurationChange, object: engine, queue: nil ) { [weak self] _ in self?.handleConfigChange() } // FIX - either use queue: .main or extract to @Sendable: NotificationCenter.default.addObserver( forName: .AVAudioEngineConfigurationChange, object: engine, queue: .main ) { [weak self] _ in self?.handleConfigChange() } ``` ### deinit is nonisolated by default; SE-0371 offers an opt-in By default, `deinit` is `nonisolated` and cannot access actor-isolated properties or call MainActor-isolated methods. For classes that can't adopt `isolated deinit` (see `actors-isolation.md`), properties needed for cleanup must be `nonisolated(unsafe)`: ```swift @Observable class ResourceManager: @unchecked Sendable { // Pair @ObservationIgnored with nonisolated(unsafe) for internal state @ObservationIgnored nonisolated(unsafe) private var listenerID: AudioObjectID? deinit { // Can access nonisolated(unsafe) properties directly if let id = listenerID { AudioObjectRemovePropertyListener(id, &address, callback, nil) } } } ``` In Swift 6.2+, actors (and global-actor-isolated classes) can opt into **`isolated deinit`** (SE-0371) so cleanup runs on the actor's executor and can touch isolated state directly. Task-local values are cleared on entry to an isolated deinit. See `actors-isolation.md` for the full pattern. Source: https://github.com/swiftlang/swift-evolution/blob/main/proposals/0371-isolated-synchronous-deinit.md ### Types and enums need explicit nonisolated for cross-isolation use With default isolation, all types are MainActor. Value types and enums used in background callbacks or `Task.detached` need explicit opt-out: ```swift // Without this, using LevelSource in a nonisolated SCStreamOutput callback errors: nonisolated enum LevelSource: Sendable { case mic, system, both } // Codable structs used across isolation boundaries: nonisolated struct TranscriptSegment: Codable, Sendable { let timestamp: Double let text: String } ``` ### Non-Sendable Apple framework types Several Apple types lack Sendable conformance. Use `@preconcurrency import` to suppress warnings: ```swift @preconcurrency import AVFoundation // Suppresses AVAudioPCMBuffer, AVAssetWriter Sendable warnings // KeyPath is not Sendable - Table sort with KeyPathComparator breaks: // Instead of: @State var sortOrder = [KeyPathComparator(\Item.date)] // Pre-sort data in the source and avoid KeyPathComparator entirely. // AVAssetWriter can't cross TaskGroup boundary (sending parameter). // Use a simple Task instead of TaskGroup for timeout patterns. ``` ### Methods called from nonisolated contexts Functions called from background callbacks must be explicitly `nonisolated`: ```swift class AudioProcessor: @unchecked Sendable { // Called from audioQueue (background) - must be explicit nonisolated nonisolated private func handleConfigChange(engine: AVAudioEngine) { // Only access nonisolated(unsafe) state or dispatch to queue } // Log utilities must also be nonisolated to work from any thread nonisolated static func log(_ message: String) { ... } } ``` ### Callbacks set after init create data races Mutable closure properties set after initialization are read from background callbacks: ```swift // BAD - data race between MainActor set and background read: class Recorder { var onError: ((Error) -> Void)? } // GOOD - immutable, passed at init: class Recorder { nonisolated let onError: (@Sendable (Error) -> Void)? init(onError: (@Sendable (Error) -> Void)? = nil) { self.onError = onError } } ``` ## `nonisolated(nonsending)` - the spelling behind the behavior The section above describes how a `nonisolated async` function runs on the caller's actor rather than hopping to the global executor. The attribute that expresses this explicitly is `nonisolated(nonsending)` (SE-0461), and you will meet it in Xcode diagnostics and in Apple's own signatures long before you write it yourself: ```swift // From FoundationModels - note the annotation on Apple's API nonisolated(nonsending) public func respond( to prompt: Prompt, options: GenerationOptions ) async throws -> LanguageModelSession.Response<String> ``` Three spellings, three behaviors: | Declaration | Runs on | |---|---| | `nonisolated func f() async` | caller's isolation (under `NonisolatedNonsendingByDefault`) | | `nonisolated(nonsending) func f() async` | caller's isolation, stated explicitly | | `@concurrent func f() async` | the global concurrent executor, always | Write `nonisolated(nonsending)` when you want the caller-inherits behavior to survive regardless of whether the module has the upcoming feature enabled - it is the portable way to say "do not hop". ## Isolated conformances (SE-0470) A recurring wall under default `MainActor` isolation: a `@MainActor` type needs to conform to a `nonisolated` protocol from a framework - `SCStreamOutput`, `SCStreamDelegate`, `NSObjectProtocol`-derived delegates. The conformance requirements are not main-actor-isolated, so the compiler rejects the isolated implementations. Swift 6.2 lets the conformance itself carry isolation: ```swift @MainActor final class CaptureController: NSObject, @MainActor SCStreamOutput { var frameCount = 0 // main-actor state, safe to touch below func stream(_ stream: SCStream, didOutputSampleBuffer buffer: CMSampleBuffer, of type: SCStreamOutputType) { frameCount += 1 } } ``` The compiler then guarantees the conformance is only used from that actor - if something tries to use `CaptureController` as an `SCStreamOutput` from another isolation domain, that is the error, rather than every method body erroring. Caveat worth knowing before reaching for it: an isolated conformance cannot be used where the protocol is required to be `Sendable`-usable across domains. When a framework hands your delegate to a background queue, the right answer is still a `nonisolated` conformance whose body hops explicitly - isolated conformances solve the "this genuinely only ever runs on main" case, not the "I want to silence the error" case. -
architecture.md 8.3 KB
# Architecture Patterns for macOS Apps ## Table of Contents - SwiftUI + @Observable (Recommended Default) - MVVM with @Observable - The Composable Architecture (TCA) - Dependency Injection - Project Structure - Decision Guide ## SwiftUI + @Observable (Simple Apps) Best for small-medium apps, solo developers, or prototypes. Direct @Observable objects owned by views: ```swift @Observable final class ProjectManager { var projects: [Project] = [] var selectedProject: Project? var searchText = "" private let store: ProjectStore var filteredProjects: [Project] { searchText.isEmpty ? projects : projects.filter { $0.name.localizedStandardContains(searchText) } } init(store: ProjectStore = .shared) { self.store = store } func load() async throws { projects = try await store.fetchAll() } func create(name: String) async throws { let project = try await store.create(name: name) projects.append(project) } func delete(_ project: Project) async throws { try await store.delete(project) projects.removeAll { $0.id == project.id } } } struct ProjectsView: View { @State private var manager = ProjectManager() var body: some View { NavigationSplitView { List(manager.filteredProjects, selection: $manager.selectedProject) { project in Text(project.name) } .searchable(text: $manager.searchText) .task { try? await manager.load() } } detail: { if let project = manager.selectedProject { ProjectDetailView(project: project) } } } } ``` ### Environment sharing ```swift // Root ContentView() .environment(manager) // Child views @Environment(ProjectManager.self) private var manager ``` ## MVVM with @Observable (Medium Apps) Separate ViewModel per view for clearer responsibilities: ```swift @Observable final class ProjectListViewModel { var projects: [Project] = [] var searchText = "" var isLoading = false var error: Error? private let service: ProjectServiceProtocol init(service: ProjectServiceProtocol = ProjectService()) { self.service = service } var filteredProjects: [Project] { guard !searchText.isEmpty else { return projects } return projects.filter { $0.name.localizedStandardContains(searchText) } } func load() async { isLoading = true defer { isLoading = false } do { projects = try await service.fetchAll() } catch { self.error = error } } func delete(_ project: Project) async { do { try await service.delete(project.id) projects.removeAll { $0.id == project.id } } catch { self.error = error } } } struct ProjectListView: View { @State private var viewModel: ProjectListViewModel init(service: ProjectServiceProtocol = ProjectService()) { _viewModel = State(initialValue: ProjectListViewModel(service: service)) } var body: some View { List(viewModel.filteredProjects) { project in ProjectRow(project: project) .swipeActions { Button("Delete", role: .destructive) { Task { await viewModel.delete(project) } } } } .searchable(text: $viewModel.searchText) .overlay { if viewModel.isLoading { ProgressView() } } .task { await viewModel.load() } } } ``` ## The Composable Architecture (TCA) For large apps requiring strict testability, modular composition, and predictable state management. Uses `pointfreeco/swift-composable-architecture`: ```swift import ComposableArchitecture @Reducer struct ProjectListFeature { @ObservableState struct State: Equatable { var projects: IdentifiedArrayOf<Project> = [] var searchText = "" var isLoading = false @Presents var destination: Destination.State? } enum Action: BindableAction { case binding(BindingAction<State>) case onAppear case projectsLoaded([Project]) case deleteProject(Project.ID) case destination(PresentationAction<Destination.Action>) } @Dependency(\.projectClient) var projectClient var body: some ReducerOf<Self> { BindingReducer() Reduce { state, action in switch action { case .onAppear: state.isLoading = true return .run { send in let projects = try await projectClient.fetchAll() await send(.projectsLoaded(projects)) } case let .projectsLoaded(projects): state.isLoading = false state.projects = IdentifiedArray(uniqueElements: projects) return .none case let .deleteProject(id): state.projects.remove(id: id) return .run { _ in try await projectClient.delete(id) } case .binding, .destination: return .none } } } @Reducer enum Destination { case detail(ProjectDetailFeature) } } ``` ### Testing TCA ```swift @Test func loadProjects() async { let store = TestStore(initialState: ProjectListFeature.State()) { ProjectListFeature() } withDependencies: { $0.projectClient.fetchAll = { [Project(name: "Test")] } } await store.send(.onAppear) { $0.isLoading = true } await store.receive(\.projectsLoaded) { $0.isLoading = false $0.projects = [Project(name: "Test")] } } ``` ## Dependency Injection ### Protocol-based (for MVVM) ```swift protocol ProjectServiceProtocol: Sendable { func fetchAll() async throws -> [Project] func create(name: String) async throws -> Project func delete(_ id: Project.ID) async throws } struct ProjectService: ProjectServiceProtocol { /* real impl */ } struct MockProjectService: ProjectServiceProtocol { /* test impl */ } ``` ### TCA Dependencies ```swift struct ProjectClient: Sendable { var fetchAll: @Sendable () async throws -> [Project] var create: @Sendable (String) async throws -> Project var delete: @Sendable (Project.ID) async throws -> Void } extension ProjectClient: DependencyKey { static let liveValue = ProjectClient( fetchAll: { try await APIClient.shared.get("/projects") }, create: { try await APIClient.shared.post("/projects", body: ["name": $0]) }, delete: { try await APIClient.shared.delete("/projects/\($0)") } ) static let testValue = ProjectClient( fetchAll: { [] }, create: { Project(name: $0) }, delete: { _ in } ) } ``` ## Project Structure ### Small-medium app ``` MyApp/ ├── MyAppApp.swift # @main, scenes ├── Models/ # Data models ├── Views/ # SwiftUI views ├── ViewModels/ # @Observable view models (MVVM only) ├── Services/ # API clients, persistence └── Utilities/ # Extensions, helpers ``` ### Large app (modular) ``` MyApp/ ├── App/ # Entry point, scenes, commands ├── Features/ │ ├── Projects/ │ │ ├── ProjectListView.swift │ │ ├── ProjectDetailView.swift │ │ └── ProjectListViewModel.swift │ ├── Settings/ │ └── Dashboard/ ├── Core/ │ ├── Models/ │ ├── Services/ │ └── Networking/ ├── Shared/ │ ├── Components/ # Reusable views │ └── Extensions/ └── Resources/ ``` ## Decision Guide | Factor | @Observable | MVVM | TCA | |--------|-------------|------|-----| | Team size | 1-2 | 2-5 | 5+ | | Testability | Basic | Good | Excellent | | Boilerplate | Minimal | Low | Medium | | Learning curve | Low | Low | High | | State predictability | Good | Good | Excellent | | Navigation handling | SwiftUI native | SwiftUI native | Tree-based | | Dependencies | Protocol injection | Protocol injection | Built-in DI | **Rule of thumb**: Start with @Observable. Graduate to MVVM when you need testable view models. Use TCA when you need strict state management and modular composition. -
async-patterns.md 8 KB
# Async Patterns ## Table of Contents - AsyncSequence - AsyncStream - Observations (Swift 6.2) - Continuations - Clock & Duration - Debouncing & Throttling - Error Handling ## AsyncSequence Protocol for asynchronous iteration: ```swift // Consuming for await line in fileURL.lines { process(line) } // With transformations let validItems = items .compactMap { try? parse($0) } .filter { $0.isActive } for try await item in validItems { display(item) } // First element let first = await stream.first(where: { $0.isImportant }) // Collect into array let all = try await stream.reduce(into: []) { $0.append($1) } ``` ### Built-in AsyncSequences - `URL.lines` - Lines from a file - `URLSession.bytes(from:)` - Bytes from network - `NotificationCenter.notifications(named:)` - Notifications - `FileHandle.bytes` - Bytes from file handle ## AsyncStream Create custom async sequences: ```swift // Yield-based func priceUpdates(for symbol: String) -> AsyncStream<Price> { AsyncStream { continuation in let connection = WebSocket(url: priceURL(for: symbol)) connection.onMessage = { data in if let price = try? JSONDecoder().decode(Price.self, from: data) { continuation.yield(price) } } connection.onClose = { continuation.finish() } continuation.onTermination = { _ in connection.close() } connection.connect() } } // Usage for await price in priceUpdates(for: "AAPL") { updateChart(price) } ``` ### Throwing variant ```swift func monitorSystem() -> AsyncThrowingStream<SystemEvent, Error> { AsyncThrowingStream { continuation in let monitor = SystemMonitor() monitor.onEvent = { event in continuation.yield(event) } monitor.onError = { error in continuation.finish(throwing: error) } monitor.onComplete = { continuation.finish() } continuation.onTermination = { _ in monitor.stop() } monitor.start() } } ``` ### Buffering policy ```swift AsyncStream(bufferingPolicy: .bufferingNewest(10)) { continuation in // Only keeps latest 10 values if consumer is slow } // Options: // .unbounded - No limit (default) // .bufferingOldest(N) - Keep first N, drop new // .bufferingNewest(N) - Keep latest N, drop old ``` ## Observations (Swift 6.2) Stream transactional state changes from `@Observable` types: ```swift import Observation @Observable class DownloadState { var bytesReceived: Int = 0 var totalBytes: Int = 0 var isComplete = false var progress: Double { totalBytes > 0 ? Double(bytesReceived) / Double(totalBytes) : 0 } } // Stream as AsyncSequence. `Observations` uses a closure initializer; // there is NO `Observations(of:)` factory and NO variadic-keypath tracking // form. SE-0475 rejected both. Apple platforms: macOS 26+ / iOS 26+. let state = DownloadState() let progressStream = Observations { state.progress } for await value in progressStream { progressBar.value = value if state.isComplete { break } } ``` Key behavior: - Groups synchronous changes into transactions - Transaction ends at next `await` that suspends - Avoids redundant updates (if you change 3 properties synchronously, one update fires) - Works with any `@Observable` type ### Observing multiple properties ```swift // Project the values you want into a tuple inside the closure. let progress = Observations { (state.bytesReceived, state.totalBytes) } for await (bytes, total) in progress { updateProgress(bytes: bytes, total: total) } ``` Source: https://github.com/swiftlang/swift-evolution/blob/main/proposals/0475-observed.md. Docs: https://developer.apple.com/documentation/observation/observations ### Advanced Observation Tracking (SE-0506, Swift 6.4) SE-0506 moved to **Implemented (Swift 6.4)** on 2026-07-28 - not in the pinned 6.3.3 toolchain, but it changes what the right answer will be. It adds two top-level interfaces: - `withObservationTracking(options:)` - the existing function plus an options parameter, so you can control tracking behaviour instead of getting exactly one fire-once callback. - `withContinuousObservationTracking` - a callback-based form with behaviour similar to `Observations`, for the common case where you want to keep observing rather than re-arming the tracker by hand. Until 6.4 ships, `Observations { }` remains the way to stream changes, and plain `withObservationTracking` remains fire-once - if you are re-registering it inside its own `onChange` closure today, that is the pattern SE-0506 replaces. Source: https://github.com/swiftlang/swift-evolution/blob/main/proposals/0506-advanced-observation-tracking.md ## Continuations Bridge callback-based APIs to async/await: ```swift // Checked continuation (with runtime checks for misuse) func fetchLocation() async throws -> CLLocation { try await withCheckedThrowingContinuation { continuation in locationManager.requestLocation { result in switch result { case .success(let location): continuation.resume(returning: location) case .failure(let error): continuation.resume(throwing: error) } // WARNING: Must resume exactly once. Double-resume crashes. } } } // Unsafe continuation (no runtime checks, slightly faster) func readSensor() async -> SensorData { await withUnsafeContinuation { continuation in sensor.read { data in continuation.resume(returning: data) } } } ``` **Rules:** - Must resume exactly once - `withCheckedContinuation` catches double-resume at runtime - `withUnsafeContinuation` for performance-critical paths ## Clock & Duration ```swift // Sleep try await Task.sleep(for: .seconds(2)) try await Task.sleep(for: .milliseconds(500)) try await Task.sleep(until: .now + .seconds(5), clock: .continuous) // Measure let clock = ContinuousClock() let elapsed = try await clock.measure { try await performWork() } print("Took \(elapsed)") // e.g., "1.234 seconds" // Timeout pattern func withTimeout<T>( _ duration: Duration, operation: @Sendable () async throws -> T ) async throws -> T { try await withThrowingTaskGroup(of: T.self) { group in group.addTask { try await operation() } group.addTask { try await Task.sleep(for: duration) throw TimeoutError() } let result = try await group.next()! group.cancelAll() return result } } ``` ## Debouncing & Throttling ```swift // Debounce search input actor SearchDebouncer { private var currentTask: Task<Void, Never>? func debounce(delay: Duration = .milliseconds(300), action: @Sendable @escaping () async -> Void) { currentTask?.cancel() currentTask = Task { try? await Task.sleep(for: delay) guard !Task.isCancelled else { return } await action() } } } // Usage in SwiftUI struct SearchView: View { @State private var query = "" @State private var results: [Item] = [] private let debouncer = SearchDebouncer() var body: some View { TextField("Search", text: $query) .onChange(of: query) { _, newValue in Task { await debouncer.debounce { let items = try? await search(newValue) await MainActor.run { results = items ?? [] } } } } } } ``` ## Error Handling ```swift // Typed throws (Swift 6.0+) func parse(_ input: String) throws(ParseError) -> AST { guard !input.isEmpty else { throw .emptyInput } // ... } // Catch specific typed errors do throws(ParseError) { let ast = try parse(input) } catch .emptyInput { showEmptyMessage() } catch .invalidSyntax(let line) { highlightError(at: line) } // In async context func fetchAndParse() async throws(AppError) -> Model { let data = try await fetch() return try parse(data) } ``` -
cloudkit-sync.md 4.9 KB
# CloudKit Sync ## Table of Contents - Setup - Configuration Options - Model Constraints - Conflict Resolution - Debugging Sync - Sharing ## Setup ### 1. Enable capabilities in Xcode - Signing & Capabilities > + Capability > iCloud - Check "CloudKit" - Select or create a container (e.g., `iCloud.com.yourapp`) - Also add "Background Modes" > "Remote notifications" for push-based sync ### 2. Configure container ```swift // Default: auto-syncs all models to private CloudKit database @main struct MyApp: App { var body: some Scene { WindowGroup { ContentView() } .modelContainer(for: [Project.self, Task.self]) // CloudKit sync happens automatically if iCloud capability is enabled } } ``` ### 3. Explicit configuration ```swift let config = ModelConfiguration( cloudKitDatabase: .automatic // Uses default container ) // Or specify database type let privateConfig = ModelConfiguration( cloudKitDatabase: .private("iCloud.com.yourapp") ) // Disable sync for specific config let localConfig = ModelConfiguration( cloudKitDatabase: .none ) // Mix synced and local storage let syncedConfig = ModelConfiguration( "Synced", schema: Schema([Project.self]), cloudKitDatabase: .automatic ) let localConfig = ModelConfiguration( "Local", schema: Schema([CacheItem.self]), cloudKitDatabase: .none ) let container = try ModelContainer( for: Project.self, CacheItem.self, configurations: syncedConfig, localConfig ) ``` ## Model Constraints for CloudKit CloudKit imposes stricter requirements on models: ```swift @Model final class SyncableProject { // All properties must have default values or be optional var name: String = "" var description: String? var createdAt: Date = .now var isArchived: Bool = false // Relationships must be optional @Relationship(deleteRule: .nullify) var tasks: [SyncableTask]? // Optional array // NO unique constraints (not supported with CloudKit) // @Attribute(.unique) var slug: String // WRONG init(name: String) { self.name = name } } ``` ### Rules - All properties must have defaults or be optional - No `@Attribute(.unique)` constraints - Relationships must be optional - Delete rule `.deny` not recommended (can fail silently) - Avoid very large `Data` properties (CloudKit has size limits) - Model class name becomes the CloudKit record type ## Conflict Resolution CloudKit uses last-writer-wins by default, and SwiftData handles most merge work transparently. There is **no public SwiftData API** that surfaces conflicts as `NSMergeConflict` objects on `ModelContext.didSave` — that's a Core Data pattern that does not apply here. If you need programmatic conflict inspection and resolution policies today, you must drop down to `NSPersistentCloudKitContainer` + Core Data. For SwiftData + CloudKit apps, design models to minimize conflicts (see Best Practices below) rather than relying on post-hoc conflict handlers. ### Best practices - Use server timestamps for ordering (`createdAt`, `updatedAt`) - Design models to minimize conflicts (separate frequently-changed properties) - Expect eventual consistency (sync is not instant) - Test with multiple devices/simulators ## Debugging Sync ### Enable CloudKit logging ```bash # In Xcode scheme, add launch argument: -com.apple.CoreData.CloudKitDebug 1 # Verbose logging: -com.apple.CoreData.CloudKitDebug 3 ``` ### CloudKit Dashboard 1. Go to https://icloud.developer.apple.com 2. Select your container 3. View records, zones, subscriptions 4. Check for errors in the "Logs" section ### Common issues **Sync not starting:** - Verify iCloud is signed in (System Settings > Apple Account) - Check CloudKit container identifier matches capability - Ensure network connectivity - Check Console.app for CloudKit errors **Data not appearing on other devices:** - Sync is eventually consistent (can take seconds to minutes) - Verify both devices use the same iCloud account - Check that the model schema matches on both versions - Look for CKError codes in logs **Schema mismatch:** - After adding new properties, deploy schema to CloudKit Dashboard - Development environment: auto-deployed - Production: must manually deploy via Dashboard ## Sharing (CloudKit Sharing) Share records with other iCloud users: ```swift // Note: Direct SwiftData sharing APIs are limited. // For advanced sharing, bridge to Core Data's NSPersistentCloudKitContainer. // Basic approach: Share via a unique code/link @Model final class SharedProject { var name: String var shareCode: String? // Generate unique code for sharing var ownerID: String // iCloud user record ID init(name: String, ownerID: String) { self.name = name self.ownerID = ownerID } } ``` For full CloudKit sharing with CKShare records, consider using Core Data's `NSPersistentCloudKitContainer` alongside SwiftData, or build a custom sharing layer using CloudKit framework directly. -
container-context.md 10.2 KB
# ModelContainer & ModelContext ## Table of Contents - ModelContainer Configuration - ModelContext Operations - Background Contexts - Batch Operations - Undo/Redo - Previews & Testing ## ModelContainer Configuration ### Basic setup ```swift // Default (SQLite, app's default location) .modelContainer(for: [Project.self, Task.self]) // With configuration let config = ModelConfiguration( "MyDB", schema: Schema([Project.self, Task.self]), url: .applicationSupportDirectory.appending(path: "myapp.store"), allowsSave: true ) let container = try ModelContainer( for: Project.self, Task.self, configurations: config ) ``` ### Multiple stores ```swift // User data in one store, reference data in another let userData = ModelConfiguration( "UserData", schema: Schema([Project.self, Task.self]) ) let referenceData = ModelConfiguration( "ReferenceData", schema: Schema([Template.self, Category.self]), url: bundledDBURL, allowsSave: false ) let container = try ModelContainer( for: Project.self, Task.self, Template.self, Category.self, configurations: userData, referenceData ) ``` ### In-memory (previews/testing) ```swift let config = ModelConfiguration(isStoredInMemoryOnly: true) let container = try ModelContainer( for: Project.self, configurations: config ) ``` ### Injecting container ```swift // Scene level WindowGroup { ContentView() } .modelContainer(container) // Or in a view ContentView() .modelContainer(for: Project.self) ``` ## ModelContext Operations ### CRUD ```swift @Environment(\.modelContext) private var context // Create let project = Project(name: "New Project") context.insert(project) // Read let descriptor = FetchDescriptor<Project>( predicate: #Predicate { !$0.isArchived }, sortBy: [SortDescriptor(\.name)] ) let projects = try context.fetch(descriptor) // Update (just modify properties - auto-tracked) project.name = "Updated Name" project.updatedAt = .now // Delete context.delete(project) // Save explicitly try context.save() // Check for unsaved changes if context.hasChanges { try context.save() } ``` ### Auto-save By default, ModelContext auto-saves on the next run loop iteration. To control: ```swift // Disable auto-save context.autosaveEnabled = false // Manual save try context.save() // Rollback unsaved changes context.rollback() ``` ### Fetch count ```swift let count = try context.fetchCount( FetchDescriptor<Project>(predicate: #Predicate { $0.isArchived }) ) ``` ### Fetch with pagination ```swift var descriptor = FetchDescriptor<Project>(sortBy: [SortDescriptor(\.name)]) descriptor.fetchLimit = 20 descriptor.fetchOffset = page * 20 let page = try context.fetch(descriptor) ``` ## Background Contexts Perform heavy operations off the main thread: ```swift // Create background context from container let container = // your ModelContainer let backgroundContext = ModelContext(container) // Use in a detached task @concurrent func importData(_ data: [ImportRow]) async throws { let context = ModelContext(container) context.autosaveEnabled = false for row in data { let item = Item(from: row) context.insert(item) } try context.save() } ``` ### ModelActor (actor-isolated context) ```swift @ModelActor actor DataImporter { // modelContext and modelContainer are auto-provided func importItems(_ items: [ImportData]) throws { for item in items { let model = Project(name: item.name) modelContext.insert(model) } try modelContext.save() } func countProjects() throws -> Int { try modelContext.fetchCount(FetchDescriptor<Project>()) } } // Usage let importer = DataImporter(modelContainer: container) try await importer.importItems(data) ``` ## Batch Operations ### Batch delete ```swift // Delete all archived projects try context.delete( model: Project.self, where: #Predicate { $0.isArchived } ) ``` ### Enumerate for bulk processing `FetchDescriptor` has **no** `fetchBatchSize` property - batching is a parameter on `enumerate` itself (default 5000): ```swift let descriptor = FetchDescriptor<Project>() try context.enumerate(descriptor, batchSize: 100) { project in project.lastSyncedAt = .now } try context.save() ``` Full signature: `enumerate(_:batchSize:allowEscapingMutations:block:)`. Set `allowEscapingMutations: true` only when the block intentionally mutates objects outside the enumerated set - otherwise SwiftData traps on escaping mutations, which is the behavior you want as a safety net. ## Undo/Redo ```swift // Enable undo manager let container = try ModelContainer(for: Project.self) container.mainContext.undoManager = UndoManager() // In SwiftUI @Environment(\.undoManager) private var undoManager // Undo/redo undoManager?.undo() undoManager?.redo() // Check availability undoManager?.canUndo undoManager?.canRedo ``` Toolbar integration: ```swift .toolbar { ToolbarItem { Button(action: { undoManager?.undo() }) { Label("Undo", systemImage: "arrow.uturn.backward") } .disabled(!(undoManager?.canUndo ?? false)) } ToolbarItem { Button(action: { undoManager?.redo() }) { Label("Redo", systemImage: "arrow.uturn.forward") } .disabled(!(undoManager?.canRedo ?? false)) } } ``` ## Previews & Testing ### Preview container ```swift struct ProjectListView_Previews: PreviewProvider { static var previews: some View { let config = ModelConfiguration(isStoredInMemoryOnly: true) let container = try! ModelContainer(for: Project.self, configurations: config) // Seed data let context = container.mainContext let project = Project(name: "Sample Project") context.insert(project) return ProjectListView() .modelContainer(container) } } // Or with @Previewable macro #Preview { @Previewable @State var container = { let config = ModelConfiguration(isStoredInMemoryOnly: true) let container = try! ModelContainer(for: Project.self, configurations: config) let project = Project(name: "Preview Project") container.mainContext.insert(project) return container }() ProjectListView() .modelContainer(container) } ``` ### Testing ```swift @Suite("Project Repository") struct ProjectRepositoryTests { let container: ModelContainer init() throws { container = try ModelContainer( for: Project.self, Task.self, configurations: ModelConfiguration(isStoredInMemoryOnly: true) ) } @Test func fetchActiveProjects() throws { let context = ModelContext(container) context.insert(Project(name: "Active")) let archived = Project(name: "Old") archived.isArchived = true context.insert(archived) try context.save() let descriptor = FetchDescriptor<Project>( predicate: #Predicate { !$0.isArchived } ) let active = try context.fetch(descriptor) #expect(active.count == 1) #expect(active.first?.name == "Active") } } ``` ## History tracking SwiftData records a transaction history you can replay - the basis for "what changed since I last looked" across processes, app relaunches, or CloudKit pulls. This is a whole API surface distinct from `@Query` observation. ```swift import SwiftData // Fetch every transaction newer than a stored token func changes(since token: (any HistoryToken)?, context: ModelContext) throws -> [DefaultHistoryTransaction] { var descriptor = HistoryDescriptor<DefaultHistoryTransaction>() if let token { descriptor.predicate = #Predicate { $0.token > token } } return try context.fetchHistory(descriptor) } for transaction in try changes(since: lastToken, context: context) { for change in transaction.changes { switch change { case .insert(let inserted): handleInsert(inserted.changedPersistentIdentifier) case .update(let updated): handleUpdate(updated.changedPersistentIdentifier) case .delete(let deleted): handleDelete(deleted.tombstone) } } lastToken = transaction.token // persist this } ``` Key types: `HistoryDescriptor`, `HistoryToken` (Comparable + Codable - persist it between launches), `HistoryTransaction`, and the change types `HistoryInsert` / `HistoryUpdate` / `HistoryDelete` with their `Default*` concrete variants. A delete leaves a **tombstone** rather than the object - the row is gone, so only the values you marked `@Attribute(.preserveValueOnDeletion)` survive in the tombstone. If you need to know *which* record was deleted (to mirror the delete to a server, say), mark that identifier with `.preserveValueOnDeletion` up front or the tombstone will not carry it. Prune consumed history so the store does not grow without bound: ```swift try context.deleteHistory(HistoryDescriptor<DefaultHistoryTransaction>( predicate: #Predicate { $0.token < cutoffToken } )) ``` ## Custom data stores `DataStore` lets you back SwiftData with something other than its default SQLite store - a JSON file, a remote service, an in-memory fixture - while keeping `@Model`, `@Query`, and `ModelContext` unchanged. ```swift final class JSONStore: DataStore { typealias Configuration = JSONStoreConfiguration typealias Snapshot = DefaultSnapshot var identifier: String var schema: Schema var configuration: JSONStoreConfiguration func fetch<T>(_ request: DataStoreFetchRequest<T>) throws -> DataStoreFetchResult<T, DefaultSnapshot> where T: PersistentModel { /* read + decode */ } func save(_ request: DataStoreSaveChangesRequest<DefaultSnapshot>) throws -> DataStoreSaveChangesResult<DefaultSnapshot> { /* encode + write */ } } // Wire it up let container = try ModelContainer( for: Project.self, configurations: JSONStoreConfiguration(name: "Local", schema: schema, fileURL: url) ) ``` Adopt `DataStoreBatching` to support batched fetches, and `HistoryProviding` if the store should serve the history API above. Related: `DataStoreError`, `DataStoreSnapshotCodingKey`. This is the supported route for a read-only or remote-backed store. It is a substantial amount of code - reach for it only when the default store genuinely cannot work, not to avoid a migration. -
core-audio-tap.md 18.5 KB
# CoreAudio Process Tap (CATap) `CATapDescription` + `AudioHardwareCreateProcessTap` + aggregate device (macOS 14.2+) captures a specific process's audio output directly from CoreAudio, without ScreenCaptureKit. Lower latency and a separate TCC permission from Screen Recording. Used by RecordKit, Chromium's `CatapAudioInputStream`, AudioCap, audiotee, and others. > **Production reality check before you migrate.** An internal call-recorder shipped CATap and reverted to display-wide SCStream after three distinct silent-recording bugs in five days. The root cause is **structural to CATap**: the aggregate-device IO proc's time base is tied to its subdevice clocks — Apple's `AudioHardwareAggregateDevice` reference states it "synchronizes the clocks of its subdevices and subtaps when running IO" ([docs](https://developer.apple.com/documentation/coreaudio/audiohardwareaggregatedevice)) — so when the default output clock is idle, rate-pinned (Bluetooth HFP), or stalled, the tap has audio to deliver but no ticks to deliver it on. A buffer-arrival watchdog restart hits the same idle clock and exhausts its restart budget. These failure modes are not Apple-documented as such, but the **Chromium audio team has independently shipped listeners for all three**: alive-state + default-output-change listeners ([crbug 436110597](https://chromium.googlesource.com/chromium/src/+/a4545b03c738f0a84468a7f70066c85f80d6b23d)), sample-rate-change error propagation ([crbug 441729516](https://chromium.googlesource.com/chromium/src/+/e17d528fa243625faeebf4ec1ff500b628f1fd74)), and device-change stream-restart ([crbug 442993607](https://chromium.googlesource.com/chromium/src/+/0cf5a46b9db9820785be8ddaf6ecd62830775501%5E%21), which notes "*CatapAudioInputStream will ignore the change and continue to capture the original default device*"). Display-wide SCStream's clock comes from the OS-composited mix, decoupled from any specific hardware output device, and has a multi-week production track record on the same workload with zero silent-recording reports. Read "When NOT to use CATap" below before choosing CATap for long-running recordings. ## Table of Contents - When NOT to Use CATap - When to Use CATap vs SCStream - Tap-Only Aggregate Pattern (HFP-safe) - Clock Fragility: What the Tap-Only Pattern Does NOT Fix - The Rate-Change Listener Anti-Pattern - Interleaved-Stereo Frame-Count Trap - IO Proc Isolation and `assumeIsolated` - TCC / Permissions for CATap ## When NOT to Use CATap Specifically these scenarios have produced silent recordings in production: 1. **Bluetooth HFP path** (AirPods on a call, Mac as the audio device). Aggregate pins to 24 kHz; unless your aggregate is tap-only (see below) the IO proc never fires. Tap-only aggregate fixes *this* specific symptom but not the broader class. 2. **Idle default output device.** IO proc stops emitting during long silences (observed ~18 s post-call tail drops). 3. **In-page audio routing** (Chrome Meet / Zoom web picker sending call audio to a non-default output while the default is idle). IO proc on the default device never ticks; the hour-long recording ends up mic-only. 4. **Long-duration unattended recordings** where any of the above can happen and a silent file is worse than a failed start. For these workloads, display-wide `SCStream` is more robust even with the cosmetic overhead of a still-running video pipeline (see `screen-capture-audio.md`). macOS 26.1+ also grants audio capture with the Screen Recording TCC grant, neutralizing CATap's permission-UX advantage. ## When to Use CATap vs SCStream | Need | CATap | Display-wide SCStream | |------|-------|-----------------------| | Per-process audio isolation | Yes (target PID) | No (OS-composited mix, exclude by app via filter) | | Survives idle default-output clock | **No** (structural) | Yes | | Survives Bluetooth HFP on default output | Only with tap-only aggregate | Yes | | Survives in-page audio re-routing | **No** (structural) | Yes | | Cosmetic Console.app noise | None | `stream output NOT found. Dropping frame` (cosmetic) | | Hidden video-pipeline CPU cost | None | Minor (mitigate with 2x2, `timescale: .max`) | | TCC permission on macOS 26.1+ | Screen Recording (unified) | Screen Recording | | TCC permission on macOS 14/15 | Separate "System Audio Recording" | Screen Recording | | Lower capture latency | Yes (~few ms) | No (SCStream sample-handler buffering ~20-50 ms) | **Short take**: if you need sub-20 ms capture latency for real-time AEC or live analysis, CATap is the only option and you accept the clock-fragility - pair it with a buffer-arrival watchdog and validate against HFP / idle-output / in-page-routing scenarios before shipping. If you're writing to disk for later playback/transcription (call recording, lecture capture, meeting archival), the latency argument doesn't apply and display-wide SCStream is the safer default. ## Tap-Only Aggregate Pattern (HFP-safe) The canonical recipe: build an aggregate device that contains **only the tap** (no physical output subdevice). When the output device changes - headphones plug in, AirPods enter HFP at 24 kHz - the tap stays at its own sample rate and keeps delivering frames. **Why**: including the output device as a subdevice (`kAudioAggregateDeviceMainSubDeviceKey` + `kAudioAggregateDeviceSubDeviceListKey`) locks the aggregate to the output's rate. When AirPods switch to 24 kHz mono HFP, the 48 kHz tap and the aggregate disagree, IO proc stops firing, and `AudioObjectSetPropertyData` will report `noErr` while silently doing nothing. ```swift import CoreAudio import AudioToolbox func makeAggregate(tapID: AudioObjectID, name: String, uid: String) throws -> AudioObjectID { let description: [String: Any] = [ kAudioAggregateDeviceNameKey: name, kAudioAggregateDeviceUIDKey: uid, kAudioAggregateDeviceIsPrivateKey: 1, kAudioAggregateDeviceIsStackedKey: 0, // Tap-only. DO NOT include kAudioAggregateDeviceMainSubDeviceKey // or kAudioAggregateDeviceSubDeviceListKey - they lock the aggregate // to the output device's rate and break under HFP. kAudioAggregateDeviceTapListKey: [[ kAudioSubTapUIDKey: tapUID(for: tapID), // Must be true: compensates for tap-vs-device clock drift. kAudioSubTapDriftCompensationKey: true, ]], ] var aggregateID: AudioObjectID = 0 let status = AudioHardwareCreateAggregateDevice( description as CFDictionary, &aggregateID ) guard status == noErr else { throw CATapError.create(status) } return aggregateID } ``` Reference: [graphaelli/audiotap](https://github.com/graphaelli/audiotap), RecordKit v0.78.0. ### Don't omit `kAudioSubTapDriftCompensationKey: true` Without drift compensation, CoreAudio resamples on every IO cycle to reconcile the tap's clock with the aggregate's, producing **periodic artifacts in all system audio** — audible as crackling during normal playback (music, calls) whenever the tap is running, and as pitch drift in long recordings. Confirmed in production by [Omi PR #6489](https://github.com/BasedHardware/omi/pull/6489): "*setting `kAudioSubTapDriftCompensationKey` … tells CoreAudio to reconcile clocks at the sub-tap level … the workaround is no longer needed.*" Keep it on. ## Clock Fragility: What the Tap-Only Pattern Does NOT Fix The tap-only aggregate fixes the HFP rate-pinning symptom (IO proc never fires when AirPods go to 24 kHz and the aggregate was locked to 48 kHz). It does **not** fix the underlying architectural issue: > Apple's [`AudioHardwareAggregateDevice`](https://developer.apple.com/documentation/coreaudio/audiohardwareaggregatedevice) "synchronizes the clocks of its subdevices and subtaps when running IO." When the system default output device's hardware clock is idle (no app is actively playing audio), rate-pinned (HFP transport flip), or stalled (DeviceIsAlive flapping, USB hub hiccup), the aggregate's IO proc receives no ticks and no buffer-delivery callbacks. The tap has samples to deliver; the scheduler has no timing source to hand them over on. Apple does not document this specific failure mode, but **Chromium has shipped listeners for all three transitions** (alive, default-output-change, sample-rate-change): [crbug 436110597](https://chromium.googlesource.com/chromium/src/+/a4545b03c738f0a84468a7f70066c85f80d6b23d), [441729516](https://chromium.googlesource.com/chromium/src/+/e17d528fa243625faeebf4ec1ff500b628f1fd74), [442993607](https://chromium.googlesource.com/chromium/src/+/0cf5a46b9db9820785be8ddaf6ecd62830775501%5E%21). Observed production failures (internal recorder + Chromium-mirrored classes): - Post-call tail drops of ~18 s when nothing plays after the call ends. - Full-hour mic-only recordings when a web app routes call audio to a non-default output while the default output stays silent. Chromium confirms the class verbatim: "*CatapAudioInputStream will ignore the change and continue to capture the original default device*" ([commit 0cf5a46b](https://chromium.googlesource.com/chromium/src/+/0cf5a46b9db9820785be8ddaf6ecd62830775501%5E%21)). Chrome's `kMacCatapCaptureAllDevices` feature flag exists specifically to work around this by capturing all outputs rather than just the default. - Watchdog restart + reinstall hits the same idle clock, burns the restart budget (e.g. 3 restarts / 30 s), and converts a recoverable stall into a terminated recording. If you ship CATap for long-running recordings, mandatory: 1. **Buffer-arrival watchdog** on the IO-proc delivery path with a trip threshold appropriate for your domain (2 s is reasonable for calls; longer for music). Treat buffer absence as the failure mode. 2. **Layered signal sources** — Chromium's pattern: listen on `kAudioDevicePropertyDeviceIsAlive` (tap), `kAudioHardwarePropertyDefaultOutputDevice` (system), and `kAudioDevicePropertyNominalSampleRate` (aggregate). Still run the watchdog as the universal safety net — the listeners can miss same-format / same-rate transitions. 3. **Validation scenarios** before shipping: AirPods HFP on a call, idle default output for >30 s, in-page audio router on Chrome Meet / Zoom web, USB mic unplug-replug, virtual audio device (BlackHole / Loopback) crashing mid-recording. 4. **Fallback path** — decide in advance what happens when the watchdog trips: restart the aggregate (may re-trip immediately), switch to SCStream, or fail loudly. Silently losing audio is the worst outcome. ## The Rate-Change Listener Anti-Pattern Tempting: subscribe to `kAudioDevicePropertyNominalSampleRate` on the tap and re-configure when the rate changes. Don't. Calling `AudioObjectSetPropertyData` from inside the listener re-fires the listener, producing an infinite notification storm. Observed in practice: 49 iterations before CoreAudio throttled, with no rate ever converging. ```swift // DO NOT DO THIS let listener: AudioObjectPropertyListenerBlock = { _, _ in // This call re-fires the listener. AudioObjectSetPropertyData(tapID, &addr, 0, nil, size, &newRate) } AudioObjectAddPropertyListenerBlock(tapID, &rateAddr, queue, listener) ``` Two safe alternatives: 1. **Linear resampling inside the IO proc** - each buffer is already annotated with its source format; resample to the writer's fixed target rate (e.g. 48 kHz) on the hot path. Same technique used for mic input. 2. **Tap-only aggregate with drift compensation** (above) - the compensation key handles small drift automatically, so the rate-change handler is unnecessary. ## Interleaved-Stereo Frame-Count Trap CATap delivers 2-channel audio as **interleaved** (`interleaved: true`, `mFormatFlags & kAudioFormatFlagIsNonInterleaved == 0`). When wrapping into a `CMSampleBuffer` via a helper like `asSampleBuffer(sampleCount:)`, passing `pcmBuffer.frameLength` as the sample count produces a track that plays back at **half the expected duration** - `CMSampleTimingInfo` misinterprets interleaved pairs as two samples. ```swift // WRONG for interleaved stereo: resulting track is 50% of real duration let sb = pcmBuffer.asSampleBuffer(sampleCount: Int(pcmBuffer.frameLength)) // RIGHT: for interleaved stereo, frame count is bytes / bytesPerFrame / channels let bytesPerFrame = Int(pcmBuffer.format.streamDescription.pointee.mBytesPerFrame) let frameCount = pcmBuffer.byteLength / bytesPerFrame // frameCount is now the true sample (time-domain) count. ``` Rule of thumb: if the source format is interleaved, compute frame count from the byte length of the block buffer divided by `mBytesPerFrame`, not from `frameLength`. ## CFString Property Values Follow the Create Rule Reading a `CFString`-typed property (e.g. `kAudioDevicePropertyDeviceUID`, `kAudioObjectPropertyName`) with `AudioObjectGetPropertyData` returns an object with a **+1 retain count** - despite "Get" in the function name, the value follows the Create Rule and the caller owns it. Apple's `AudioHardware.h` states "*The caller is responsible for releasing the returned CFObject*". In Swift, take it with `takeRetainedValue()`, not `takeUnretainedValue()`; the latter leaks nothing but under-retains, so the string can be freed out from under you. ```swift var cfName: Unmanaged<CFString>? var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size) let status = AudioObjectGetPropertyData(deviceID, &addr, 0, nil, &size, &cfName) let name = cfName?.takeRetainedValue() as String? // Create Rule: retained ``` ## IO Proc Isolation and `assumeIsolated` The IO proc closure returned from `AudioHardwareCreateProcessTapWithAggregateDevice` / `makeIOProcBlock` runs on CoreAudio's **real-time thread**. It must stay `nonisolated` - do not make it actor-isolated or `@MainActor`. Allocation, locks, `Task {}`, and `AsyncStream.yield()` are all unsafe there. Pattern: IO proc `memcpy`s into a pre-allocated staging buffer, then dispatches (via `audioQueue.async`) for off-RT processing. The dispatched block can then synchronously `assumeIsolated` back into the actor to touch actor state. ```swift actor AudioRecorder { let audioQueue = DispatchSerialQueue(label: "audio") nonisolated var unownedExecutor: UnownedSerialExecutor { audioQueue.asUnownedSerialExecutor() } // IO proc: nonisolated, no allocation, no Task, no AsyncStream. nonisolated func makeIOProc() -> AudioDeviceIOBlock { return { [weak self] _, inputData, _, _, _ in guard let self else { return } // Copy out. No actor state access here. let frames = stageBuffer(from: inputData) // Hand off to audio queue - assumeIsolated on the other side. audioQueue.async { self.assumeIsolated { iso in iso.writeFrames(frames) } } } } } ``` See `actors-isolation.md` for the full `assumeIsolated` recipe set, including why `audioQueue.async { assumeIsolated { ... } }` is correct but nesting it inside a CoreAudio listener that already dispatches on the same queue causes an ordering bug. ## TCC / Permissions for CATap The Privacy & Security pane has been labeled **"Screen & System Audio Recording"** since macOS 14 Sonoma. On macOS 26 it gains a "System Audio Recording Only" subsection and the Screen Recording grant implicitly covers system-audio capture. Under the hood, `kTCCServiceAudioCapture` is a distinct TCC service from `kTCCServiceScreenCapture` — preflighting one does not authoritatively answer for the other, and the two can drift (particularly on macOS 14/15 where they are separately toggleable). **There is no public `CGPreflightScreenCaptureAccess` equivalent for CATap.** Apple does not expose a documented live "is System Audio Recording authorized?" API. Common fallback patterns: 1. Attempt to create the tap + aggregate. Failure (`kAudioHardwareIllegalOperationError` or `kAudioHardwareBadObjectError` when the TCC prompt is declined) strongly correlates with lack of permission; branch to "request access" UX. 2. Mirror the `CGPreflightScreenCaptureAccess` check as a **proxy only** — the two services share a UI pane on macOS 14+, so Screen Recording authorization is a decent hint, but do not treat it as authoritative for CATap specifically. 3. `insidegui/AudioCap` uses the private SPI `TCCAccessPreflight(kTCCServiceAudioCapture, nil)`. Not App-Store-safe but useful for Developer ID builds. 4. Never rely on a `UserDefaults`-cached "granted" flag — the user can revoke permission in System Settings between launches, and reading stale state leads to silent recordings (buffers flow, RMS stays at -inf). ```swift // Preferred pattern: live-check on every permission-sensitive UI refresh. // macOS 26: Screen Recording TCC covers system audio too. func audioCapturePermissionGranted() -> Bool { CGPreflightScreenCaptureAccess() } ``` For the "granted but silent" failure mode after a force-recursive replace (rm‑rf + cp‑R) reinstall with the same Developer ID, see `distribution.md` - TCC is keyed by CDHash; a same-CDHash reinstall can leave the entry in a degraded state where it reads authorized but delivers no audio. Fix: toggle off/on in System Settings. ## Another app's Voice Processing reshapes your microphone tap Voice Processing (VPIO) is a property of the shared input path, not of your process alone. When a communications app enables it during a call, your own raw mic tap can be handed a layout you did not ask for - more than two channels - and naive stereo handling produces a nearly inaudible track. ```swift let format = inputNode.inputFormat(forBus: 0) if format.channelCount > 2 { // Take channel 0 rather than downmixing an unknown layout mono = extractChannel(0, from: buffer) mono = applyMakeupGain(mono, db: calibratedGainDB) } else { mono = downmixToMono(buffer) // branch on isInterleaved - see screen-capture-audio.md } logRMS(mono) // per-session RMS makes a wrong gain diagnosable on other hardware ``` Log per-session RMS. A makeup gain calibrated on one machine is a guess on another, and RMS in the log is the difference between "the user reports it is quiet" and knowing by how much. ### Enabling VPIO in your own engine ducks all other system audio Beyond the incompatibility already documented above, turning on `setVoiceProcessingEnabled(true)` makes macOS classify your process as a VoIP app and duck every other audio source by roughly 20 dB system-wide. If you are concurrently capturing system audio, you have just attenuated your own recording's content by 20 dB. Treat "enable VPIO for AEC/AGC" as an experiment that must be measured, not a default: - Measure the ducking effect on a concurrent system-audio capture before shipping it. - Keep a raw-mode fallback: enabling voice processing can fail and leave the input node in a corrupted state, so the code path that runs without it must stay working. -
distribution.md 21.8 KB
# macOS App Distribution ## Table of Contents - Distribution Methods Overview - Code Signing - App Store Distribution - Developer ID Distribution - Notarization - Notarization Gotchas - Sandboxing - Hardened Runtime - Universal Binaries - Sparkle (Auto-Update) ## Distribution Methods | Method | Audience | Signing | Notarization | Sandbox | Review | |--------|----------|---------|-------------|---------|--------| | Mac App Store | Public | Apple Distribution | Automatic | Required | Yes | | Developer ID | Public (outside store) | Developer ID | Required | Recommended | No | | Ad-Hoc | Internal/testing | None or Dev | Not required | No | No | | TestFlight | Testers | Apple Distribution | Automatic | Required | Beta review | ## Code Signing ### Certificates - **Apple Development** - For running on local devices during development - **Apple Distribution** - For App Store and TestFlight - **Developer ID Application** - For distribution outside the App Store - **Developer ID Installer** - For signed `.pkg` installers ### Automatic signing (recommended) In Xcode: Signing & Capabilities > check "Automatically manage signing". Select your team. ### Manual signing ```bash # List identities security find-identity -v -p codesigning # Sign app codesign --force --options runtime \ --sign "Developer ID Application: Your Name (TEAM_ID)" \ --timestamp \ MyApp.app # Verify codesign --verify --deep --strict MyApp.app spctl --assess --type execute MyApp.app ``` ### Dev-loop gotcha: `SIGKILL (Code Signature Invalid)` If a running signed app is killed at idle right after a rebuild (e.g. a `make run` loop that replaces the `.app` under the running process), the crash is `SIGKILL` with `EXC_CRASH (Code Signature Invalid)` - not a bug in your code. macOS validates memory-mapped code pages against the on-disk signature lazily; overwriting the bundle invalidates the pages the kernel later faults in, so it terminates the process. Quit the old instance before replacing the bundle, or launch from a copied path. ## Building an existing `.xcodeproj` from the command line Signing an `.xcodeproj` via `xcodebuild` fails in ways that still report `BUILD SUCCEEDED`. These are the traps that cost the most time. **`BUILD SUCCEEDED` does not mean "signed as you asked."** When the pbxproj declares an SDK-conditional identity - `CODE_SIGN_IDENTITY[sdk=macosx*] = "Apple Development"` - a command-line override can be *accepted, logged, and then ignored*. The log prints `note: Using codesigning identity override: <hash>` and the build still emits an ad-hoc-signed bundle. An `.xcconfig` setting `CODE_SIGN_IDENTITY = -` outranks both the pbxproj conditional and the command line. Two defences: ```bash # 1. Refuse to silently fall back to ad-hoc xcodebuild -scheme MyApp CODE_SIGNING_REQUIRED=YES ... # 2. Never trust the build log - verify the artifact codesign -dv --verbose=4 build/MyApp.app 2>&1 | grep -E 'Authority|Signature|flags' ``` `Signature=adhoc` in that output means unsigned for every practical purpose: no Developer ID, no notarization, no stable TCC identity. **Bracketed build settings cannot be passed on the command line at all.** `xcodebuild` splits each `SETTING=VALUE` argument on the *first* `=`, so `CODE_SIGN_IDENTITY[sdk=macosx*]=X` parses as the setting name `CODE_SIGN_IDENTITY[sdk` with the value `macosx*]=X`. To change a conditional identity, edit the pbxproj or supply an `.xcconfig` - there is no CLI form. **Match the identity by name, not by prefix.** Scripts that auto-detect a certificate with `security find-identity | grep "Apple Development: "` can never match a `Developer ID Application` certificate - different certificate class, and no environment variable or rename fixes it. Match on the exact keychain name you intend to use, or build ad-hoc and re-sign the bundle inside-out (frameworks -> dylibs -> XPC services -> app). **Build from the resolved real path.** Building through a symlinked source directory corrupts Xcode's build database: you get `Stale file ... outside of the allowed root paths` and a module-emit failure, often with **no `error:` line anywhere in the log**. Delete the derived-data directory and rebuild from the fully resolved path (`cd "$(realpath .)"`). **Signature-class changes cost one round of TCC re-prompts.** Switching a bundle between ad-hoc and Developer ID changes its code identity, so the system asks for screen-recording and microphone consent once more. That is expected. Also expected: `spctl -a -vv` reporting `rejected / Unnotarized Developer ID` for a locally signed build - Gatekeeper acts on the quarantine attribute, which `xattr -cr MyApp.app` clears. **Moving a signed `.app` preserves its TCC grants and preferences.** TCC keys off bundle identifier plus signature, not path, so relocating a bundle does not reset permissions. The safe replace idiom is quit the app, `ditto` the new bundle into place, then `xattr -cr` it. ## App Store Distribution ### Requirements - Active Apple Developer Program membership ($99/year) - App Store Connect listing with metadata, screenshots - **Since April 28, 2026**: uploads to App Store Connect must be built with **Xcode 26 and the iOS 26 / iPadOS 26 / tvOS 26 / visionOS 26 / watchOS 26 SDK**. Pure macOS apps are **not** in Apple's list as of 2026-04-24; Mac Catalyst and Designed-for-iPad builds inherit the iOS 26 SDK requirement. Source: https://developer.apple.com/news/?id=ueeok6yw and https://developer.apple.com/news/upcoming-requirements - Sandbox entitlement required - App Review compliance - Audit `PrivacyInfo.xcprivacy` against the current required-reason APIs list (https://developer.apple.com/documentation/bundleresources/describing-use-of-required-reason-api) — Apple updates it periodically ### Workflow 1. Archive: Product > Archive in Xcode 2. Validate: Window > Organizer > Validate App 3. Upload: Distribute App > App Store Connect 4. Submit for review in App Store Connect ### ExportOptions.plist (for CI) ```xml <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>method</key> <string>app-store-connect</string> <key>teamID</key> <string>YOUR_TEAM_ID</string> <key>destination</key> <string>upload</string> <key>signingStyle</key> <string>automatic</string> </dict> </plist> ``` ### CI build & upload ```bash # Build archive xcodebuild archive \ -project MyApp.xcodeproj \ -scheme MyApp \ -archivePath build/MyApp.xcarchive \ -destination "generic/platform=macOS" # Export and upload xcodebuild -exportArchive \ -archivePath build/MyApp.xcarchive \ -exportPath build/export \ -exportOptionsPlist ExportOptions.plist # Or upload directly. Use notarytool - NOT `altool --upload-app`: # the notary service stopped accepting altool uploads on 2023-11-01, # and the flag is absent from Xcode 26.6's altool --help. xcrun notarytool submit build/export/MyApp.pkg \ --key AuthKey_KEYID.p8 \ --key-id KEY_ID \ --issuer ISSUER_ID \ --wait ``` ### Notarize-then-export in one archive flow `xcodebuild` can export an already-notarized archive directly, which avoids re-signing between notarization and distribution: ```bash xcodebuild -exportNotarizedApp \ -archivePath build/MyApp.xcarchive \ -exportPath build/notarized ``` ## Developer ID Distribution For distributing outside the Mac App Store: ### Build and sign ```bash xcodebuild archive \ -scheme MyApp \ -archivePath MyApp.xcarchive xcodebuild -exportArchive \ -archivePath MyApp.xcarchive \ -exportPath ./export \ -exportOptionsPlist DevIDExport.plist ``` DevIDExport.plist: ```xml <dict> <key>method</key> <string>developer-id</string> <key>teamID</key> <string>YOUR_TEAM_ID</string> <key>signingStyle</key> <string>automatic</string> </dict> ``` ### Create DMG ```bash # Create DMG with hdiutil hdiutil create -volname "MyApp" -srcfolder ./export/MyApp.app \ -ov -format UDZO MyApp.dmg # Or use create-dmg for pretty DMGs # brew install create-dmg create-dmg \ --volname "MyApp" \ --window-pos 200 120 \ --window-size 600 400 \ --icon-size 100 \ --icon "MyApp.app" 175 120 \ --hide-extension "MyApp.app" \ --app-drop-link 425 120 \ MyApp.dmg ./export/MyApp.app ``` ## Notarization Required for all Developer ID-signed apps (since macOS 10.15): ```bash # Submit for notarization xcrun notarytool submit MyApp.dmg \ --apple-id your@email.com \ --team-id TEAM_ID \ --password @keychain:AC_PASSWORD \ --wait # Check status xcrun notarytool info SUBMISSION_ID \ --apple-id your@email.com \ --team-id TEAM_ID \ --password @keychain:AC_PASSWORD # View log on failure xcrun notarytool log SUBMISSION_ID \ --apple-id your@email.com \ --team-id TEAM_ID \ --password @keychain:AC_PASSWORD # Staple ticket to app/DMG xcrun stapler staple MyApp.dmg # Verify xcrun stapler validate MyApp.dmg spctl --assess --type open --context context:primary-signature MyApp.dmg ``` ### Store credentials in keychain ```bash xcrun notarytool store-credentials "AC_PASSWORD" \ --apple-id your@email.com \ --team-id TEAM_ID \ --password "app-specific-password" # Then use: xcrun notarytool submit MyApp.dmg \ --keychain-profile "AC_PASSWORD" --wait ``` ### Using API keys (recommended for CI) ```bash xcrun notarytool submit MyApp.dmg \ --key AuthKey_KEYID.p8 \ --key-id KEY_ID \ --issuer ISSUER_UUID \ --wait ``` ## Notarization Gotchas ### notarytool 403 "A required agreement is missing or has expired" A 403 from `xcrun notarytool submit` is almost never a code-signing problem - it's an Apple agreement that needs acceptance. Two flavors, both easy to miss: **(a) Apple Developer Program License Agreement.** Must be accepted by the **Account Holder** (not team admin, not developer). Log in at `developer.apple.com/account` with the Account Holder's Apple ID; a banner prompts acceptance. **(b) Digital Services Act (DSA) banner on App Store Connect.** Even when distributing via Developer ID + GitHub Releases (nothing on the App Store), an unanswered DSA compliance banner at `appstoreconnect.apple.com` can hold the account in incomplete state and cause notarytool to 403. Click "Business → Agreements, Tax, and Banking" and complete any outstanding prompts. For non-App-Store distribution, "I'm not a trader under the DSA or I don't plan to distribute in the EU" is the appropriate answer. **Propagation lag**: after accepting, notarytool may still 403 for several minutes. Don't retry in a tight loop - check both `developer.apple.com/account` and the "Agreements, Tax, and Banking" page for any remaining unsigned items, then wait 5-10 minutes before resubmitting. ### TCC is keyed by code-signature CDHash - reinstalls can degrade without error TCC grants are keyed by the app's CDHash + bundle ID + Developer ID. When a release-channel app is replaced via a force-recursive delete of `/Applications/App.app` followed by `cp -R ./export/App.app /Applications/` (common in `make install` / CI workflows), TCC may remember the grant **but deliver degraded content** - permission reads as authorized, capture APIs start without error, buffers flow at zero amplitude. Direct-reading `~/Library/Application Support/com.apple.TCC/TCC.db` is blocked by SIP, so there is no programmatic recovery. The user-visible remediation: System Settings → Privacy & Security → toggle the permission off, then on again. To prevent the issue: - Prefer in-place overwrite (let the OS handle inode swap) over force-recursive replace (rm‑rf + cp‑R). - `killall App` before replacing the bundle - otherwise the still-running process keeps executing from its unlinked inode and `open -a` activates the stale instance instead of launching the new one. - For dev scripts, consider `tccutil reset ScreenCapture $BUNDLE_ID` after install so the user gets a fresh prompt rather than a degraded cached grant. ### Nested framework bundles must be deep-signed Frameworks like Sparkle contain nested bundles (`Updater.app`, `Installer.xpc`, `Downloader.xpc`). Notarization rejects if these aren't individually signed with Developer ID + secure timestamp. Sign from inside out: ```bash # Sign all nested bundles inside Sparkle find "MyApp.app/Contents/Frameworks/Sparkle.framework" \ -type d \( -name "*.app" -o -name "*.xpc" \) | while read f; do codesign --force --options runtime --sign "$SIGN_ID" --timestamp "$f" done # Then the framework itself codesign --force --options runtime --sign "$SIGN_ID" --timestamp \ "MyApp.app/Contents/Frameworks/Sparkle.framework" # Then the main app codesign --force --options runtime --sign "$SIGN_ID" --timestamp \ --entitlements entitlements.plist "MyApp.app" # Verify before submitting codesign --verify --deep --strict MyApp.app ``` ### Missing intermediate certificate After importing a Developer ID Application certificate, `security find-identity -v -p codesigning` may show no valid identity. Apple's G2 intermediate certificate must be downloaded and installed separately into the keychain. ### First-time Developer ID accounts may stall First notarization submissions from a new Developer ID can sit "In Progress" for 72+ hours. If signing were wrong, Apple rejects within minutes as `Invalid`. "In Progress" for days means the submission passed validation and is in Apple's review queue. File a Technical Support Incident (TSI) if this happens. ### Gatekeeper bypass for non-notarized builds Developer ID-signed but non-notarized apps show Gatekeeper warnings. **The Control-click (right-click) > Open override was removed in macOS Sequoia and is still gone in Tahoe 26** - do not tell users to use it. The only supported path now: try to open the app once (so the block is registered), then System Settings > Privacy & Security > scroll down > **Open Anyway** > confirm. Apple's [current documentation](https://support.apple.com/en-us/102445) describes this flow exclusively. The app is then saved as an exception and opens by double-click thereafter. ### Sparkle EdDSA signing Sparkle uses its own EdDSA key pair (separate from Apple code signing) to verify update integrity. Sign DMGs with Sparkle's `sign_update` tool: ```bash .build/artifacts/sparkle/Sparkle/bin/sign_update MyApp-1.0.0.dmg ``` The appcast.xml contains the EdDSA signature and is generated per-release. ## Sandboxing Required for Mac App Store. Configured via entitlements: ```xml <!-- MyApp.entitlements --> <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>com.apple.security.app-sandbox</key> <true/> <key>com.apple.security.files.user-selected.read-write</key> <true/> <key>com.apple.security.network.client</key> <true/> <key>com.apple.security.network.server</key> <false/> </dict> </plist> ``` Common sandbox entitlements: - `files.user-selected.read-write` - Access user-selected files - `files.downloads.read-write` - Access Downloads folder - `network.client` - Outbound network connections - `network.server` - Incoming connections (server) - `device.camera` - Camera access - `device.microphone` - Microphone access (sandbox side) - `personal-information.calendars` - Calendar access - `personal-information.contacts` - Contacts access ## Hardened Runtime Required for notarization. Enable in Xcode: Signing & Capabilities > + Capability > Hardened Runtime. Resource access entitlements (only enable if needed): - `com.apple.security.device.audio-input` - Microphone and audio input via Core Audio - `com.apple.security.device.camera` - Camera access Runtime exception entitlements (weaken security - use sparingly): - `cs.disable-library-validation` - Load third-party frameworks/plugins with different Team ID - `cs.allow-unsigned-executable-memory` - Allow unsigned executable memory - `cs.allow-jit` - Allow JIT compilation **For capture apps**: microphone access needs BOTH sandbox (`device.microphone`) AND hardened runtime (`device.audio-input`) entitlements if sandboxed. Screen recording needs NO entitlement - it's governed by TCC runtime permission only. ### Capture app entitlements Non-sandboxed (Developer ID): ```xml <dict> <key>com.apple.security.device.audio-input</key> <true/> </dict> ``` Sandboxed (App Store): ```xml <dict> <key>com.apple.security.app-sandbox</key> <true/> <key>com.apple.security.device.microphone</key> <true/> <key>com.apple.security.device.audio-input</key> <true/> <key>com.apple.security.files.user-selected.read-write</key> <true/> </dict> ``` Info.plist (required for microphone): ```xml <key>NSMicrophoneUsageDescription</key> <string>Record audio alongside screen capture.</string> ``` ### Persistent Content Capture `com.apple.developer.persistent-content-capture` bypasses macOS 15+ recurring screen recording prompts. Intended for VNC/remote desktop apps. Requires Apple approval and provisioning profile - request through Apple Developer entitlement request form. ## Universal Binaries Support both Apple Silicon and Intel: ```bash # Build universal xcodebuild -scheme MyApp \ -destination "generic/platform=macOS" \ ARCHS="arm64 x86_64" \ ONLY_ACTIVE_ARCH=NO # Verify architectures lipo -info MyApp.app/Contents/MacOS/MyApp # Output: Architectures in the fat file: arm64 x86_64 # Create universal from two builds lipo -create MyApp-arm64 MyApp-x86_64 -output MyApp-universal ``` In Xcode: Build Settings > Architectures > Standard Architectures (Apple Silicon, Intel) ## Sparkle (Auto-Update) For Developer ID apps, use Sparkle for auto-updates: ```swift // Package.swift dependency .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.0.0") // In App import Sparkle @main struct MyApp: App { private let updaterController = SPUStandardUpdaterController( startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil ) var body: some Scene { WindowGroup { ContentView() } .commands { CommandGroup(after: .appInfo) { CheckForUpdatesView(updater: updaterController.updater) } } } } ``` ## Notarization rejections: check these first `com.apple.security.get-task-allow` is the single most common cause. It is the debug entitlement Xcode injects into Debug builds; if it survives into the archive you submit, the notary service rejects the upload. Verify before submitting: ```bash codesign -d --entitlements - --xml build/export/MyApp.app | plutil -p - ``` If `get-task-allow` appears with value `true`, you archived a Debug configuration or a custom entitlements file carries it. Strip it from the Release entitlements. Other frequent blockers: - Hardened Runtime not enabled on every executable in the bundle (not just the main one). - A nested binary signed with a different Team ID, or left unsigned. - **Plug-in entitlement inheritance**: "Shared libraries, frameworks, and in-process plug-ins inherit the entitlements of their host executable." The host app must declare every entitlement its plug-ins need - a plug-in cannot add its own. Reference: [Resolving common notarization issues](https://developer.apple.com/documentation/security/resolving-common-notarization-issues). ## Enhanced Security capability (Xcode 26+) Xcode 26 added an **Enhanced Security** capability enabling additional runtime and compile-time protections - pointer authentication, hardened heap, memory tagging - via the `com.apple.security.hardened-process.*` entitlement family. The entitlement schema changed again in Xcode 26.4: `-version` became `-version-string` and `-platform-restrictions` became `-platform-restrictions-string`. If you hand-maintain an entitlements plist rather than using the capability UI, re-check the key spellings against the [Security entitlements reference](https://developer.apple.com/documentation/bundleresources/security-entitlements) when moving between Xcode versions. ## Installer packages For apps that need an installer rather than a drag-to-Applications DMG: ```bash pkgbuild --root build/export/MyApp.app \ --identifier com.example.myapp \ --version 1.0.0 \ --install-location /Applications/MyApp.app \ build/MyApp-component.pkg productbuild --distribution Distribution.xml \ --package-path build \ build/MyApp.pkg productsign --sign "Developer ID Installer: Your Name (TEAMID)" \ build/MyApp.pkg build/MyApp-signed.pkg ``` Note the identity is **Developer ID Installer**, a different certificate from the **Developer ID Application** identity used for the app itself. Useful `notarytool` subcommands beyond `submit`: ```bash xcrun notarytool history --key ... --key-id ... --issuer ... xcrun notarytool log <submission-id> --key ... --key-id ... --issuer ... xcrun notarytool info <submission-id> -f json --key ... --key-id ... --issuer ... ``` `--s3-acceleration` speeds up large uploads. `stapler validate <path>` verifies a stapled ticket. ## Homebrew cask as a second install channel A signed, notarized Mac app can ship through a Homebrew tap alongside the DMG. Practical constraints, in the order they usually bite: - **Pick a globally unique cask token.** Tap CI (`brew test-bot --only-tap-syntax`) fails if your token collides with one in homebrew-core, and renaming later orphans every existing install. - **If you must rename, ship `cask_renames.json`** in the tap so `brew update` migrates existing installs. `tap_migrations.json` is for cross-tap moves and will not do this. - **Set `auto_updates true`** when the app self-updates via Sparkle, so Homebrew does not fight the in-app updater. Add a `livecheck` block so version bumps are detectable. - **`depends_on macos:` cannot express a point release** (e.g. 26.1). Enforce a precise minimum in the app at launch and treat the cask constraint as approximate. - **Reinstalling over an app already in `/Applications` fails** without `--force`. - Run `brew style` and `brew audit --cask` before pushing; generated casks commonly trip on a redundant `version` line. -
fall-2026-releases.md 8.5 KB
# Fall 2026 Releases (WWDC 2026) Announced at WWDC 2026 (June 8, 2026), shipping fall 2026. As of 2026-08-20, Xcode 27 is at **beta 5** and macOS 27 at **beta 6**. Build against the shipping stack (Swift 6.3.3 / Xcode 26.6 / macOS 26.6.2 Tahoe) unless you specifically target these betas. Sources: [Apple Newsroom, 2026-06-08](https://www.apple.com/newsroom/2026/06/apple-aids-app-development-with-new-intelligence-frameworks-and-advanced-tools/); [WWDC26 "What's new in Swift"](https://developer.apple.com/videos/play/wwdc2026/262); [Xcode 27 release notes](https://developer.apple.com/documentation/xcode-release-notes/xcode-27-release-notes); [macOS 27 release notes](https://developer.apple.com/documentation/macos-release-notes/macos-27-release-notes). ## OS and toolchain - **macOS 27 Golden Gate** (beta 6, build 26A5416b, 2026-08-17) - next macOS; Apple-silicon only. macOS 26.6.2 Tahoe is the current shipping release until fall. - **Xcode 27** (beta 5, build 27A5237l, 2026-08-10) - agentic coding with Anthropic/Google/OpenAI models plus MCP plug-ins and the Agent Client Protocol; Apple-silicon only, ~30% smaller. Requires macOS Tahoe 26.4 or later. Gemini also landed in stable Xcode 26.6. Beta 5 previews an MCP server that runs without an open Xcode workspace and can grant code-signed agents long-lived permission over a directory tree; enable it with `sudo xcrun mcp-server enable`. - **Swift 6.4** (beta; shipping toolchain is still 6.3.3) - ships inside the Xcode 27 beta ("Xcode 27 beta 5 includes Swift 6.4 and SDKs for iOS 27, iPadOS 27, tvOS 27, watchOS 27, macOS 27, and visionOS 27"), and the `release/6.4.x` branch is cut with daily snapshots on swift.org. Features: targeted warning suppression (SE-0522), `~Sendable` to explicitly mark a type non-Sendable (SE-0518), Task Cancellation Shields (SE-0504), `Continuation` (SE-0528), `async` calls in `defer` bodies (SE-0493), borrow/mutate accessors (SE-0507), a memberwise initializer that excludes private initialized properties (SE-0502), and Advanced Observation Tracking (SE-0506). Two version-gate corrections worth carrying: - `weak let` (SE-0481) is **not** a 6.4 feature - it shipped in Swift 6.3 and is already available in the pinned toolchain. - `anyAppleOS` is **not** gated on 6.4 either - it compiles on 6.3.3 behind `-enable-experimental-feature AnyAppleOSAvailability`. Without the flag the compiler names it for you: `error: any Apple OS requires '-enable-experimental-feature AnyAppleOSAvailability'`. ## Xcode 27 build-breakers These bite when you first open a project in Xcode 27, before any new API is adopted. Check them ahead of the beta. - **The ld64 linker is gone.** "The ld64 linker has been removed and the `-ld_classic` option is no longer supported. (165165518)" Any project still carrying `-ld_classic` in `OTHER_LDFLAGS` - a flag Apple itself prescribed as a workaround in Xcode 15 - fails to link. Remove it. - **Clang module names must be globally unique per dependency scan.** The optimized Swift dependency scanner now requires that "every Clang module reachable from a single Swift dependency-scan action must have a unique module name. If two module maps visible to the same scan declare a Clang module with the same name, the scan may report an error." The common cause is vendored third-party sources shipping a `module.modulemap` that redeclares an SDK module. - **Universal binaries are no longer the default at deployment target 27.0+.** "The `ARCHS_STANDARD` build setting will no longer include x86_64 when `MACOSX_DEPLOYMENT_TARGET` or `DRIVERKIT_DEPLOYMENT_TARGET` >= 27.0." Add x86_64 to `ARCHS` explicitly if you still ship Intel. Xcode 27 itself installs only on Apple silicon. - **SE-0508 source break.** "A computed property with both an `init` accessor and an array/dictionary literal initial value will no longer compile if the getter is declared before the `init` accessor." Workaround: declare the `init` accessor first. - **libc++ floor raised.** The minimum supported C++ deployment target on macOS moves to 11.0, and `multimap`/`multiset::find` no longer necessarily returns an iterator to the first equal element. ## Document apps: concurrency and new protocols macOS 27 adds `ReadableDocument` / `WritableDocument`, plus a combined `Document` protocol for the common read-and-write case. As of beta 6 the older protocols are formally deprecated - not merely superseded: > You can now use the `Document` protocol for representing documents in `DocumentGroup`. This protocol combines `ReadableDocument` and `WritableDocument` for common read-and-write cases. Use `Document` instead of `ReferenceFileDocument` and `FileDocument`, which are now deprecated. New `DocumentGroup` initializers adopting them expose an `Observable` `URLDocumentConfiguration`, let you disable document creation for editing-only apps, and present custom UI before any document is opened. The isolation contract changed in a way that silently defeats off-main I/O if you carry old annotations forward: > The `read(from:progress:)` and `write(content:to:previous:progress:)` requirements of `DocumentReader` and `DocumentWriter` are declared with `@concurrent` instead of `nonisolated`. With approachable-concurrency defaults that infer `MainActor` isolation, an unannotated `nonisolated` async method runs on the main actor, defeating the intent of off-main reading and writing. Conforming types that previously used `nonisolated` should switch to `@concurrent` to match. `makeDocument:` and `makeReadableDocument:` closures passed to `DocumentGroup` initializers are now `@MainActor`-isolated. ## Frameworks - **Foundation Models next-gen** - the single native Swift API now accepts image input and adds server models running on Private Cloud Compute via `PrivateCloudComputeLanguageModel`, which Apple frames as a one-line swap for `SystemLanguageModel` and which requires the `com.apple.developer.private-cloud-compute` entitlement. `DynamicProfile` (with `LanguageModelSession.Profile` and `DynamicInstructions`) swaps model/tools/instructions mid-session. A new `LanguageModel` protocol makes third-party models (Claude, Gemini) pluggable behind the same API. `ContextOptions` arrives here too - despite the name it configures what appears in the prompt, not a trimming policy. `LanguageModelSession.GenerationError` is **deprecated** as of 27.0 in favour of `LanguageModelError` / `SystemLanguageModel.Error` / `LanguageModelSession.Error`, with `exceededContextWindowSize` renamed `contextSizeExceeded`; Apple states you must update to Xcode 27 to catch the new types before submitting. It remains correct under the macOS 26.5 SDK you build against today. - **Core AI** - a brand-new framework, distinct from Foundation Models, for loading and running full-scale LLMs on device, optimized for the Neural Engine and unified memory. Public surface includes `AIModel`, `AIModelAsset`, `AIModelCache`, `InferenceFunction`, `NDArray`, `ComputeUnitKind`, and `SpecializationOptions`, plus a background-inference entitlement `com.apple.developer.background-tasks.continued-processing.inference`. No `updates/coreai` change log exists yet (404), so the framework index is the only source. - **SwiftData** - a new data-store observation surface: `ResultsObserver` delivers real-time updates for models matching fetch criteria, and `HistoryObserver` observes remote model changes. `@Query(...sectionBy:)` returns `SectionedResults`/`ResultsSection` for grouped queries, and `Schema.Attribute.Option.codable` lets `Codable` types - including ones you do not control - be stored directly in a model. - **SwiftUI** - reorderable list/grid containers, faster layout, and lazier `@State` initialization (back-deployed). In apps built with the macOS 27 SDK, `List` accepts drops in two cases that previously did not work: drags with compatible transfer representations into reorderable content even without `.reorderableItem`, and `.dropDestination(...)` declared on a list item. SwiftUI also now hides menu item symbol images in most contexts by default - use `.labelStyle(.titleAndIcon)` to opt a menu item back in. New **Spatial Preview** framework streams 3D content from a Mac to Apple Vision Pro. - **Previews** - code inside `#Preview` now explicitly runs on the main actor, so it can call main-actor-isolated APIs without concurrency warnings or runtime check failures. ## Liquid Glass becomes mandatory Apps rebuilt with the Xcode 27 SDK can no longer opt out of Liquid Glass - the `UIDesignRequiresCompatibility` key is ignored. Under Xcode 26 the key still works as a temporary migration aid. A new system transparency slider lets users tune the effect. -
foundation-models.md 22.2 KB
# Foundation Models Framework On-device AI using Apple's ~3B parameter LLM. Available on macOS 26+ with Apple Silicon. Free inference, offline, private. All symbol references cite the shipped SDK swiftinterface at `FoundationModels.framework/.../arm64e-apple-macos.swiftinterface` (lines indicated inline) and/or Apple's developer documentation. ## Table of Contents - Setup & Basic Usage - Instructions - Guided Generation (Structured Output) - Streaming - Tool Calling - Sessions & Context - Generation Options - Error Handling - Built-in Use Cases - Custom Adapters - Language Support & Limits - Availability & Limitations ## Setup ```swift import FoundationModels let model = SystemLanguageModel.default switch model.availability { case .available: break case .unavailable(.deviceNotEligible): // Intel Mac, non-A17/M-series, etc. return case .unavailable(.appleIntelligenceNotEnabled): // User must enable in Settings > Apple Intelligence & Siri. return case .unavailable(.modelNotReady): // Model is still downloading / preparing. return } ``` The four-case `Availability` enum (`.available` plus three `UnavailableReason`s) is defined at swiftinterface lines 555-572. `SystemLanguageModel.default` is at line 576. There is also a `final public var isAvailable: Bool` on the model (line 518) for a quick boolean check. Requires: macOS 26+ / iOS 26+ / iPadOS 26+ / visionOS 26+, Apple Silicon, Apple Intelligence enabled. ## Basic Usage ```swift let session = LanguageModelSession() let response = try await session.respond(to: "Summarize: \(text)") print(response.content) // Swift.String print(response.transcriptEntries) // ArraySlice<Transcript.Entry> ``` The return type is `LanguageModelSession.Response<String>`, not `String` directly (swiftinterface lines 347-354). `Response<Content>` exposes `content`, `rawContent: GeneratedContent`, and `transcriptEntries`. ## Instructions Set the system prompt at session creation. Instructions are fixed for the lifetime of the session. ```swift let session = LanguageModelSession( instructions: """ You are a concise writing assistant. Focus on grammar and clarity. Never add commentary; return only the rewritten text. """ ) let rewritten = try await session.respond(to: "Review: \(userText)").content ``` Correct initializer: `convenience init(model: SystemLanguageModel = .default, tools: [any Tool] = [], instructions: String? = nil)` (swiftinterface line 339). Overloads also accept `Instructions` directly or an `@InstructionsBuilder` closure (lines 340-341). ## Guided Generation Generate Swift types directly - the framework constrains decoding to your schema, no JSON parsing. ```swift @Generable struct RecipeSuggestion { @Guide(description: "Name of the recipe") var name: String @Guide(description: "Ingredients with quantities") var ingredients: [String] @Guide(description: "Step-by-step instructions") var steps: [String] @Guide(description: "Estimated cooking time in minutes", .range(5...180)) var cookingTime: Int } let session = LanguageModelSession() let result: LanguageModelSession.Response<RecipeSuggestion> = try await session.respond( to: "Suggest a quick pasta recipe", generating: RecipeSuggestion.self ) let recipe = result.content print(recipe.name, recipe.cookingTime) ``` `respond(to:generating:...)` returns `Response<Content>` where `Content: Generable` (swiftinterface lines 378-382). The `@Generable` macro is at line 16; `@Guide` overloads at lines 24/28/32. `GenerationGuide` supports `.range(_:)`, `.minimum(_:)`, `.maximum(_:)` for numerics; `.anyOf(_:)`, `.constant(_:)`, `.pattern(_:)` for `String`; `.count(_:)`, `.minimumCount(_:)`, `.maximumCount(_:)`, `.element(_:)` for arrays (lines 254-299). Built-in `Generable` conformances: `Bool`, `String`, `Int`, `Float`, `Double`, `Decimal`, `Array<Element: Generable>`, `Optional`, plus your own `@Generable` structs and enums (lines 92-195). ### Enum-constrained output ```swift @Generable enum Sentiment: String, CaseIterable { case positive, negative, neutral } @Generable struct SentimentResult { var sentiment: Sentiment @Guide(description: "0.0 - 1.0 confidence", .range(0.0...1.0)) var confidence: Double } let analysis = try await session.respond( to: "Analyze sentiment: '\(review)'", generating: SentimentResult.self ).content ``` ## Streaming Stream partial results for responsive UI. Snapshots contain `Content.PartiallyGenerated` which fills in progressively (swiftinterface lines 463-468). ```swift // Text streaming - snapshots are String snapshots for try await snapshot in session.streamResponse(to: "Write a short story.") { view.text = snapshot.content // current complete text so far } // Structured streaming — given a @Generable target type: @Generable struct TripPlan { @Guide(description: "Destination city") var destination: String @Guide(description: "Day-by-day itinerary") var days: [String] @Guide(description: "Estimated budget in USD", .range(0...10_000)) var budgetUSD: Int } // Pick one: iterate for live updates, OR collect once for the final value. // The stream is single-pass; iterating consumes it. let stream = session.streamResponse( to: "Plan a weekend trip", generating: TripPlan.self ) for try await snapshot in stream { // snapshot.content: TripPlan.PartiallyGenerated (optionals fill in over time) render(snapshot.content) } // Or, without iterating, collect the final value: let finalResponse = try await session.streamResponse( to: "Plan a weekend trip", generating: TripPlan.self ).collect() // Response<TripPlan> ``` `ResponseStream.collect()` is at swiftinterface line 491. The stream conforms to `AsyncSequence` (line 473). ## Tool Calling `Tool` is a protocol, not a macro. There is no `@Toolbox` or `@Tool` annotation in the framework. Each tool is a `Sendable` type with an associated `Arguments: Generable` and `Output: PromptRepresentable` (swiftinterface lines 1186-1196). ```swift struct GetWeather: Tool { let name = "getWeather" let description = "Get current weather for a city." @Generable struct Arguments { @Guide(description: "City name, e.g. 'San Francisco'") var city: String } func call(arguments: Arguments) async throws -> String { let w = try await WeatherService.current(for: arguments.city) return "\(w.tempF)F, \(w.conditions)" } } struct GetForecast: Tool { let name = "getForecast" let description = "Get an N-day forecast for a city." @Generable struct Arguments { var city: String @Guide(description: "Number of days", .range(1...10)) var days: Int } func call(arguments: Arguments) async throws -> String { let f = try await WeatherService.forecast(for: arguments.city, days: arguments.days) return f.map { "\($0.date): \($0.conditions)" }.joined(separator: "\n") } } let session = LanguageModelSession( tools: [GetWeather(), GetForecast()], instructions: "Use the weather tools when asked about weather." ) let answer = try await session.respond( to: "Should I bring an umbrella to SF tomorrow?" ).content ``` Notes from the framework: - `Arguments` must be `Generable`. `String`, `Int`, `Double`, `Float`, `Decimal`, and `Bool` are *explicitly unavailable* as `Arguments` (swiftinterface lines 1219-1268) - use an `@Generable` struct that wraps them. - `Output` must be `PromptRepresentable`. `String` and `Array<PromptRepresentable>` already conform (lines 1131, 1139). - `call(arguments:)` runs on the `@concurrent` executor; make it `async throws`. - TN3193 recommends a maximum of 3-5 tools per session to keep schemas small in the context window. ## Sessions & Context Sessions are stateful and thread-safe (`final public class` marked `@unchecked Sendable`, swiftinterface lines 321-397). Re-use one across turns; the transcript grows automatically. ```swift let session = LanguageModelSession(instructions: "You are a code reviewer.") session.prewarm() // Optional: start loading the model in the background. let r1 = try await session.respond(to: "Review: \(code)") let r2 = try await session.respond(to: "How would you refactor it?") // session.transcript contains all instructions/prompts/responses so far. // Check whether a response is in flight if !session.isResponding { // safe to start another respond(...) } ``` There is no `session.reset()` method. To start fresh, create a new session. To preserve partial state across the reset, rebuild a `Transcript` from the old session's entries: ```swift // Start a new session that carries the first and last turns only. func condensed(from old: LanguageModelSession) -> LanguageModelSession { let kept = [old.transcript.first, old.transcript.last].compactMap { $0 } let transcript = Transcript(entries: kept) let session = LanguageModelSession(transcript: transcript) session.prewarm() return session } ``` `prewarm(promptPrefix:)` is at swiftinterface line 343. The `LanguageModelSession(transcript:)` initializer is at line 342. This pattern is the one Apple recommends for recovering from context-window errors (TN3193). ## Generation Options ```swift let options = GenerationOptions( sampling: .greedy, // or .random(top: 40), .random(probabilityThreshold: 0.9) temperature: 0.2, // lower = more deterministic maximumResponseTokens: 512 // cap response size ) let r = try await session.respond(to: prompt, options: options).content ``` `GenerationOptions` is a `struct` with `sampling: SamplingMode?`, `temperature: Double?`, `maximumResponseTokens: Int?` (swiftinterface lines 1311-1328). `SamplingMode` offers `.greedy`, `.random(top:seed:)`, `.random(probabilityThreshold:seed:)` (lines 1315-1322). TN3193 warns: use `maximumResponseTokens` only as a safety cap against runaway generations; hard truncation can produce malformed partial output. To shorten responses, ask in the prompt ("In 3 sentences...") or use `.maximumCount(_:)` on `Generable` arrays. ## Error Handling `LanguageModelSession.GenerationError` is an enum with these cases (swiftinterface lines 405-443): ```swift do { let response = try await session.respond(to: prompt, options: options) return response.content } catch LanguageModelSession.GenerationError.guardrailViolation(let ctx) { // Apple safety guardrail tripped. Don't blindly retry - reword or decline. log("guardrail: \(ctx.debugDescription)") return fallback } catch LanguageModelSession.GenerationError.exceededContextWindowSize(let ctx) { // Context window full (4096 tokens). Start a new session with a condensed transcript. log("context full: \(ctx.debugDescription)") return try await condensed(from: session).respond(to: prompt).content } catch LanguageModelSession.GenerationError.unsupportedLanguageOrLocale(let ctx) { return "Language not supported: \(ctx.debugDescription)" } catch LanguageModelSession.GenerationError.assetsUnavailable(let ctx) { // Model / adapter assets not downloaded yet. return "Model unavailable: \(ctx.debugDescription)" } catch LanguageModelSession.GenerationError.rateLimited { return "Too many requests - back off." } catch LanguageModelSession.GenerationError.concurrentRequests { return "Only one request per session at a time." } catch LanguageModelSession.GenerationError.refusal(let refusal, _) { // Model refused. `refusal.explanation` is an async call to fetch the reason. return try await refusal.explanation.content } catch LanguageModelSession.GenerationError.decodingFailure(let ctx) { // Guided generation produced content that failed to decode into your type. throw NSError(domain: "decoding", code: 0, userInfo: [NSLocalizedDescriptionKey: ctx.debugDescription]) } catch LanguageModelSession.GenerationError.unsupportedGuide(let ctx) { // A @Guide constraint isn't supported (e.g. unsupported regex feature). throw NSError(domain: "guide", code: 0, userInfo: [NSLocalizedDescriptionKey: ctx.debugDescription]) } ``` Every case carries a `GenerationError.Context` with a `debugDescription`. The `.refusal` case additionally carries a `Refusal` whose `explanation` / `explanationStream` let you ask the model why it refused (swiftinterface lines 416-433). Tool errors surface as a separate `LanguageModelSession.ToolCallError` that wraps your tool and the underlying error (lines 447-454). ### `GenerationError` is deprecated in macOS 27 - with a submission deadline Apple deprecates `LanguageModelSession.GenerationError` in the macOS 27 SDK in favour of `LanguageModelError`, `SystemLanguageModel.Error`, and `LanguageModelSession.Error`, and renames `exceededContextWindowSize` to `contextSizeExceeded`. The deprecation note is unusually strict: > Use `LanguageModelError`, `SystemLanguageModel.Error`, or `LanguageModelSession.Error` instead. Apps built with Xcode 26 will continue to catch this error until you rebuild with Xcode 27. **You must update to Xcode 27 to catch the new error types before submitting your app.** The `catch` ladder above is correct for the Xcode 26 / macOS 26.5 SDK this skill targets, but plan the rename before moving to Xcode 27 - a stale `catch LanguageModelSession.GenerationError.exceededContextWindowSize` silently stops matching once rebuilt. Source: <https://developer.apple.com/documentation/foundationmodels/languagemodelsession/generationerror> ## Built-in Use Cases `SystemLanguageModel.UseCase` is a struct (not an enum). Only two values ship on macOS 26 (swiftinterface lines 524-528): - `.general` - the default model. - `.contentTagging` - optimized for generating tags/topics/classifications from text. ```swift // Content tagging model - better than .general for tag/topic extraction. let taggingModel = SystemLanguageModel(useCase: .contentTagging) let session = LanguageModelSession(model: taggingModel) @Generable struct Tags { @Guide(description: "3-7 topical tags", .maximumCount(7)) var tags: [String] } let tags = try await session.respond( to: "Tag this article: \(text)", generating: Tags.self ).content.tags ``` `SystemLanguageModel(useCase:guardrails:)` is at swiftinterface line 582. Guardrails are either `.default` or `.permissiveContentTransformations` (lines 543-547); use the permissive option only for transformation tasks on user-provided content (summarize, translate, rewrite). ## Custom Adapters A custom LoRA adapter lets you specialize the base model's behavior. Training happens out-of-band with Apple's Python toolkit; the deployed artifact is an `.fmadapter` package. Key rules (from Apple's "Loading and using a custom adapter" doc): - Adapter files are 160 MB or larger - **do not bundle them in your app**. Download on demand via Background Assets or a Managed Asset Pack. - Each adapter is locked to a specific base model version. You must retrain for every new base model release. - Shipping adapters requires the `com.apple.developer.foundation-model-adapter` entitlement. Local testing does not. - Adapters run only on physical devices (not Simulator). ### Local testing (file URL) ```swift let url = URL(filePath: "/absolute/path/to/my.fmadapter") let adapter = try SystemLanguageModel.Adapter(fileURL: url) let model = SystemLanguageModel(adapter: adapter) let session = LanguageModelSession(model: model) let response = try await session.respond(to: "...") ``` `Adapter(fileURL:)` is at swiftinterface line 663; `SystemLanguageModel(adapter:)` is at line 586. ### Shipping (Background Assets) ```swift // Clear out any adapters from older base-model versions. try SystemLanguageModel.Adapter.removeObsoleteAdapters() // Load by base name. Triggers a Background Assets download if needed. let adapter = try SystemLanguageModel.Adapter(name: "myAdapter") // Optional: compile the draft model once (if your adapter has one) for faster inference. try await adapter.compile() let model = SystemLanguageModel(adapter: adapter) let session = LanguageModelSession(model: model) ``` `Adapter(name:)`, `removeObsoleteAdapters()`, `compile()`, `compatibleAdapterIdentifiers(name:)`, and `isCompatible(_:)` are all on `SystemLanguageModel.Adapter` (swiftinterface lines 664-673). The asset-downloader extension's `shouldDownload(_:)` should delegate adapter-compatibility checks to `SystemLanguageModel.Adapter.isCompatible(assetPack)`. See <https://developer.apple.com/apple-intelligence/foundation-models-adapter> for the Python toolkit. (Apple removed the "Loading and using a custom adapter" article - that URL now redirects to the framework root. The `SystemLanguageModel.Adapter` symbols above are still present in the SDK and still compile.) ## Language Support & Limits ```swift let model = SystemLanguageModel.default model.supportedLanguages // Set<Locale.Language> model.supportsLocale(.current) // Bool model.contextSize // Int (4096 on macOS 26) ``` `supportedLanguages`, `supportsLocale(_:)`, and `contextSize` are at swiftinterface lines 587-590 and 635-644. **Context window**: 4096 tokens per `LanguageModelSession`, covering instructions, prompts, tool definitions, tool outputs, `Generable` schemas, and all responses (TN3193). When you exceed it, the framework throws `.exceededContextWindowSize` - create a fresh session with a condensed transcript (see "Sessions & Context" above). **Token counting** (macOS 26.4+): `SystemLanguageModel` exposes `tokenCount(for:)` overloads for `Prompt`, `Instructions`, `[Tool]`, `GenerationSchema`, and transcript-entry collections (swiftinterface lines 600-624). Use these plus the Foundation Models Instruments template (Xcode > Instruments > Foundation Models) to profile consumption. ## Availability & Limitations - **Hardware**: Apple Silicon only (A17 Pro / M1 and later where Apple Intelligence is supported). - **OS**: macOS 26+, iOS 26+, iPadOS 26+, visionOS 26+. Unavailable on tvOS and watchOS (swiftinterface `@available ... tvOS, unavailable; watchOS, unavailable`). - **Model size**: ~3B parameters on-device. - **Strengths**: summarization, content generation, structured extraction, classification, tagging, light tool use. - **Limitations**: not a general-knowledge chatbot, 4096-token context window, no image generation, one in-flight request per session (`.concurrentRequests`). - **Privacy**: all inference runs on device. No data sent to Apple. - **Cost**: free; no API keys, no quotas beyond hardware throughput. Primary references: - <https://developer.apple.com/documentation/foundationmodels> - <https://developer.apple.com/documentation/foundationmodels/generating-content-and-performing-tasks-with-foundation-models> - <https://developer.apple.com/documentation/technotes/tn3193-managing-the-on-device-foundation-model-s-context-window> ## `@PromptBuilder` - composing prompts structurally Every `respond`/`streamResponse` method has an overload taking a `@PromptBuilder` closure instead of a string. Use it when the prompt is assembled conditionally - it keeps the structure readable and avoids string-concatenation bugs: ```swift let response = try await session.respond(generating: Summary.self) { "Summarize the following meeting transcript." if let focus = userFocus { "Focus specifically on: \(focus)" } for segment in transcript.segments { "[\(segment.speaker)]: \(segment.text)" } } ``` `@InstructionsBuilder` does the same for `Instructions` at session construction: ```swift let session = LanguageModelSession { "You are a concise meeting summarizer." if compactMode { "Keep every bullet under 12 words." } } ``` ## Runtime schemas with `DynamicGenerationSchema` `@Generable` requires the shape at compile time. When the structure is only known at runtime - user-defined extraction fields, a schema fetched from a server - build it dynamically: ```swift let schema = DynamicGenerationSchema( name: "ExtractedRecord", properties: userFields.map { field in DynamicGenerationSchema.Property( name: field.name, schema: DynamicGenerationSchema(type: String.self), isOptional: !field.isRequired ) } ) let generationSchema = try GenerationSchema(root: schema, dependencies: []) let response = try await session.respond(to: prompt, schema: generationSchema) let content: GeneratedContent = response.content let value = try content.value(String.self, forProperty: "title") ``` The result is `GeneratedContent` rather than a typed struct - read properties by name with `value(_:forProperty:)`. Prefer `@Generable` whenever the shape is static; this path trades compile-time safety for flexibility. ## Managing the context window The context window is 4096 tokens and there is **no** API in the macOS 26 SDK that trims or evicts transcript entries for you. The recovery path is manual: catch the context error and start a fresh session seeded with a condensed summary of the old transcript (see "Sessions & Context" above). Budget proactively instead of reacting. `SystemLanguageModel.tokenCount(for:)` measures a `Prompt`, `Instructions`, `[Tool]`, `GenerationSchema`, or a collection of transcript entries before you send it, and `contextSize` reports the maximum the model supports: ```swift let model = SystemLanguageModel.default let used = try await model.tokenCount(for: prompt) if used > model.contextSize / 2 { /* condense before sending */ } ``` In Xcode, the `#Playground` macro shows an estimate of how much of the 4096-token window a given call consumes, which is the fastest way to find the instruction block or tool schema that is eating the budget. > **`ContextOptions` is macOS 27 beta, not macOS 26.** It does not exist in the macOS 26.5 SDK (`error: cannot find 'ContextOptions' in scope`), and despite the name it is not a trimming or retention policy - Apple defines it as "Options that configure details that should appear in the prompt." Do not reach for it on the shipping stack. Source: <https://developer.apple.com/documentation/foundationmodels/contextoptions> ## Server-backed models (macOS 27 beta) `PrivateCloudComputeLanguageModel` is a variant of the system model that runs on Private Cloud Compute for capabilities the ~3B on-device model cannot handle, while keeping Apple's privacy guarantees. Apple describes it as a one-line swap for `SystemLanguageModel`; it requires the `com.apple.developer.private-cloud-compute` entitlement. macOS 27 beta only. Source: <https://developer.apple.com/documentation/foundationmodels/privatecloudcomputelanguagemodel> -
migration-guide.md 7.5 KB
# Concurrency Migration Guide ## Table of Contents - Migration Paths - From No Concurrency to Swift 6.2 - From GCD to async/await - From Combine to AsyncSequence - From ObservableObject to @Observable - Common Errors & Fixes - Checklist ## Migration Paths ### Path A: New project (recommended) 1. Swift 6 language mode + default MainActor isolation from day one 2. Use `@concurrent` for explicit background work 3. Use `@Observable` for state, `@Query` for SwiftData ### Path B: Existing project, incremental 1. Start with Swift 5 mode + `StrictConcurrency` upcoming feature (warnings only) 2. Fix warnings one module at a time 3. Enable Swift 6 mode per-target as they become clean 4. Add default isolation last ## From GCD to async/await ### Dispatch queues ```swift // Before DispatchQueue.global().async { let data = loadData() DispatchQueue.main.async { self.items = parse(data) } } // After func loadAndParse() async { let data = await loadData() items = parse(data) // Already on MainActor with default isolation } ``` ### DispatchGroup ```swift // Before let group = DispatchGroup() var results: [Data] = [] for url in urls { group.enter() fetch(url) { data in results.append(data) group.leave() } } group.notify(queue: .main) { process(results) } // After func fetchAll(_ urls: [URL]) async throws -> [Data] { try await withThrowingTaskGroup(of: Data.self) { group in for url in urls { group.addTask { try await fetch(url) } } return try await group.reduce(into: []) { $0.append($1) } } } ``` ### Serial queue (mutual exclusion) ```swift // Before let queue = DispatchQueue(label: "com.app.serial") queue.sync { sharedState.update() } // After actor SharedState { func update() { /* ... */ } } await sharedState.update() ``` ### Timer ```swift // Before Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in refresh() } // After func startPolling() async { while !Task.isCancelled { try? await Task.sleep(for: .seconds(5)) await refresh() } } ``` ## From Combine to AsyncSequence ### Publisher to AsyncSequence ```swift // Before (Combine) cancellable = publisher .map { $0.name } .filter { !$0.isEmpty } .sink { name in self.displayName = name } // After for await value in stream.map(\.name).filter({ !$0.isEmpty }) { displayName = value } ``` ### @Published to @Observable ```swift // Before class ViewModel: ObservableObject { @Published var items: [Item] = [] @Published var isLoading = false @Published var error: Error? private var cancellables = Set<AnyCancellable>() func load() { isLoading = true service.fetchItems() .receive(on: DispatchQueue.main) .sink( receiveCompletion: { [weak self] completion in self?.isLoading = false if case .failure(let error) = completion { self?.error = error } }, receiveValue: { [weak self] items in self?.items = items } ) .store(in: &cancellables) } } // After @Observable final class ViewModel { var items: [Item] = [] var isLoading = false var error: Error? func load() async { isLoading = true defer { isLoading = false } do { items = try await service.fetchItems() } catch { self.error = error } } } ``` ### View changes ```swift // Before struct ContentView: View { @StateObject private var viewModel = ViewModel() var body: some View { /* ... */ } } // After struct ContentView: View { @State private var viewModel = ViewModel() var body: some View { /* ... */ } } ``` Note: `@StateObject` -> `@State`, `@ObservedObject` -> remove or use `@Bindable` ## Common Errors & Fixes ### "Sending value of non-Sendable type" ```swift // Error class MyClass { var data: [String] = [] } Task { let c = MyClass() } // MyClass not Sendable // Fix 1: Make it Sendable final class MyClass: Sendable { let data: [String] } // Fix 2: Use actor actor MyActor { var data: [String] = [] } // Fix 3: Use struct struct MyData: Sendable { var data: [String] = [] } ``` ### "Cannot access property from non-isolated context" ```swift // Error @MainActor class VM { var x = 0 } func compute(vm: VM) { print(vm.x) } // non-isolated can't access // Fix 1: Make function MainActor @MainActor func compute(vm: VM) { print(vm.x) } // Fix 2: Make it async func compute(vm: VM) async { print(await vm.x) } // Fix 3: Use default isolation (Swift 6.2) // With -default-isolation MainActor, both are on MainActor ``` ### "Global variable must be isolated or Sendable" ```swift // Error var sharedConfig = Config() // Fix 1: Make it a let with Sendable type let sharedConfig = Config(/* immutable */) // Config must be Sendable // Fix 2: MainActor isolate @MainActor var sharedConfig = Config() // Fix 3: nonisolated(unsafe) for thread-safe globals nonisolated(unsafe) let logger = Logger() ``` ## Checklist 1. [ ] Enable strict concurrency checking as warnings first 2. [ ] Make value types (structs/enums) conform to Sendable where needed 3. [ ] Convert mutable classes to actors or @Observable 4. [ ] Replace DispatchQueue.main with @MainActor 5. [ ] Replace GCD with structured concurrency (TaskGroup, async let) 6. [ ] Replace Combine publishers with AsyncSequence where possible 7. [ ] Replace @Published with @Observable properties 8. [ ] Replace completion handlers with async/await 9. [ ] Enable Swift 6 language mode 10. [ ] Enable default MainActor isolation (Swift 6.2) 11. [ ] Add @concurrent to functions needing background execution 12. [ ] Remove now-redundant @MainActor annotations ## Let the compiler do the migration: `swift package migrate` SwiftPM ships a migration command that mechanically rewrites source for a given upcoming feature, instead of chasing diagnostics by hand: ```bash swift package migrate --to-feature ExistentialAny swift package migrate --to-feature InternalImportsByDefault swift package migrate --target MyApp --to-feature NonisolatedNonsendingByDefault ``` It applies the fix-its across the target and updates `Package.swift` to enable the feature. Run it on a clean tree, one feature at a time, and review the diff - it is a mechanical rewrite, not a design review. Full flag list: `swift package migrate --help`. ## Upcoming-feature flags worth knowing individually Enabling `.swiftLanguageMode(.v6)` in one step is often too big a jump for an existing codebase. These dials let you land Swift 6 semantics incrementally, each as `.enableUpcomingFeature("<name>")`: | Flag | What it turns on | |---|---| | `StrictConcurrency` | Full data-race checking under language mode 5 | | `InferSendableFromCaptures` | Closures infer `Sendable` from what they capture - removes many spurious annotations | | `GlobalActorIsolatedTypesUsability` | Makes global-actor-isolated types usable in more generic contexts | | `NonisolatedNonsendingByDefault` | `nonisolated async` inherits the caller's isolation | | `DisableOutwardActorInference` | Stops a global-actor-isolated property from silently isolating its containing type | | `ExistentialAny` | Requires `any` on existential types | | `InternalImportsByDefault` | Imports are `internal` unless marked `public` | `DisableOutwardActorInference` is the one that most often explains a surprise: a single `@MainActor var` can otherwise pull an entire type onto the main actor without you writing that anywhere. -
migrations.md 6.8 KB
# Schema Migrations ## Table of Contents - Versioned Schemas - Lightweight Migration - Custom Migration - SchemaMigrationPlan - Core Data Migration - Best Practices ## Versioned Schemas Define each version of your schema: ```swift // Version 1: Original enum AppSchemaV1: VersionedSchema { static var versionIdentifier = Schema.Version(1, 0, 0) static var models: [any PersistentModel.Type] { [Project.self] } @Model final class Project { var name: String var isActive: Bool init(name: String) { self.name = name self.isActive = true } } } // Version 2: Added fields enum AppSchemaV2: VersionedSchema { static var versionIdentifier = Schema.Version(2, 0, 0) static var models: [any PersistentModel.Type] { [Project.self] } @Model final class Project { var name: String var isActive: Bool var createdAt: Date // New field var priority: Int // New field init(name: String) { self.name = name self.isActive = true self.createdAt = .now self.priority = 0 } } } // Version 3: Renamed + added relationship enum AppSchemaV3: VersionedSchema { static var versionIdentifier = Schema.Version(3, 0, 0) static var models: [any PersistentModel.Type] { [Project.self, Task.self] } @Model final class Project { var title: String // Renamed from 'name' var isActive: Bool var createdAt: Date var priority: Int @Relationship(deleteRule: .cascade) var tasks: [Task] = [] // New relationship init(title: String) { self.title = title self.isActive = true self.createdAt = .now self.priority = 0 } } @Model final class Task { var title: String var isComplete: Bool var project: Project? init(title: String) { self.title = title self.isComplete = false } } } ``` ## Lightweight Migration For additive changes (new properties with defaults, new models): ```swift enum AppMigrationPlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { [AppSchemaV1.self, AppSchemaV2.self] } static var stages: [MigrationStage] { [migrateV1toV2] } static let migrateV1toV2 = MigrationStage.lightweight( fromVersion: AppSchemaV1.self, toVersion: AppSchemaV2.self ) } ``` Lightweight migration handles: - Adding new properties (with default values) - Adding new model types - Removing properties - Renaming properties (with `@Attribute(originalName:)`) ### Property rename ```swift // In V2, rename 'name' to 'title' @Model final class Project { @Attribute(originalName: "name") var title: String // ... } ``` ## Custom Migration For complex transformations: ```swift enum AppMigrationPlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { [AppSchemaV1.self, AppSchemaV2.self, AppSchemaV3.self] } static var stages: [MigrationStage] { [migrateV1toV2, migrateV2toV3] } static let migrateV1toV2 = MigrationStage.lightweight( fromVersion: AppSchemaV1.self, toVersion: AppSchemaV2.self ) static let migrateV2toV3 = MigrationStage.custom( fromVersion: AppSchemaV2.self, toVersion: AppSchemaV3.self ) { context in // Custom migration logic let projects = try context.fetch(FetchDescriptor<AppSchemaV2.Project>()) for project in projects { // Transform data during migration // e.g., capitalize all names project.name = project.name.capitalized } try context.save() } } ``` ## SchemaMigrationPlan Register with container: ```swift @main struct MyApp: App { var body: some Scene { WindowGroup { ContentView() } .modelContainer( for: AppSchemaV3.Project.self, migrationPlan: AppMigrationPlan.self ) } } ``` ### Multiple migration chains ```swift // Each stage migrates sequentially: V1 -> V2 -> V3 // User on V1 will run both stages // User on V2 will run only V2 -> V3 static var stages: [MigrationStage] { [migrateV1toV2, migrateV2toV3] } ``` ## Migrating from Core Data ### Coexistence approach (recommended) Run both Core Data and SwiftData side by side, migrating data at runtime: ```swift @main struct MyApp: App { let swiftDataContainer: ModelContainer let coreDataStack: NSPersistentContainer init() { // Set up SwiftData swiftDataContainer = try! ModelContainer(for: Project.self) // Set up legacy Core Data coreDataStack = NSPersistentContainer(name: "LegacyModel") coreDataStack.loadPersistentStores { _, _ in } // Migrate on first launch if !UserDefaults.standard.bool(forKey: "migrated_to_swiftdata") { migrateFromCoreData() UserDefaults.standard.set(true, forKey: "migrated_to_swiftdata") } } func migrateFromCoreData() { let coreDataContext = coreDataStack.viewContext let swiftDataContext = swiftDataContainer.mainContext // Fetch from Core Data let request = NSFetchRequest<NSManagedObject>(entityName: "CDProject") let cdProjects = try! coreDataContext.fetch(request) // Insert into SwiftData for cdProject in cdProjects { let project = Project( name: cdProject.value(forKey: "name") as! String ) swiftDataContext.insert(project) } try! swiftDataContext.save() } } ``` ### Direct replacement If your Core Data model is simple, SwiftData can often read the existing SQLite store: ```swift // Point SwiftData to the existing Core Data store let storeURL = NSPersistentContainer.defaultDirectoryURL() .appendingPathComponent("MyApp.sqlite") let config = ModelConfiguration(url: storeURL) let container = try ModelContainer( for: Project.self, configurations: config ) ``` Requirements: - SwiftData model class name must match Core Data entity name - Property names must match attribute names - Types must be compatible ## Best Practices 1. **Always version your schema** from the start, even if you only have V1 2. **Use lightweight migration** whenever possible (simpler, safer) 3. **Test migrations** with real data before shipping 4. **Keep migration stages small** - one logical change per stage 5. **Back up user data** before running migrations in production 6. **Use `@Attribute(originalName:)`** for renames instead of delete+add 7. **New properties must have defaults** for lightweight migration to work 8. **Never delete a VersionedSchema** that users might still be running -
models-schema.md 9.4 KB
# Models & Schema ## Table of Contents - @Model Basics - Property Attributes - Codable Properties - Transient Properties - External Storage - Transformable - Unique Constraints - Custom Hashable/Identifiable ## @Model Basics `@Model` is a macro that transforms a class into a SwiftData persistent model: ```swift @Model final class Note { var title: String var content: String var createdAt: Date var updatedAt: Date var isPinned: Bool var color: NoteColor init(title: String, content: String = "") { self.title = title self.content = content self.createdAt = .now self.updatedAt = .now self.isPinned = false self.color = .default } } ``` What `@Model` synthesizes: - `PersistentModel` conformance (persistence) - `Observable` conformance (SwiftUI reactivity) - `Hashable` and `Identifiable` (based on persistent ID) - Schema definition for the persistence store ### Rules - Must be a `class` (not struct) - Prefer `final` for clarity - Must have at least one stored property - All stored properties must be persistable types - Needs a designated initializer ### Supported property types - Primitives: `String`, `Int`, `Double`, `Float`, `Bool`, `Date`, `Data`, `URL`, `UUID` - Optional versions of all above - Enums with `Codable` raw values - `Codable` structs/enums - Collections: `[T]` where T is any supported type - Relationships: Other `@Model` types ## Property Attributes ### @Attribute ```swift @Model final class Document { // Unique constraint - no two documents with same slug @Attribute(.unique) var slug: String // Spotlight indexing @Attribute(.spotlight) var title: String // External storage for large data @Attribute(.externalStorage) var thumbnail: Data? // Custom original name (for migration) @Attribute(originalName: "old_name") var newName: String // Preserve value on deletion @Attribute(.preserveValueOnDeletion) var archiveID: UUID // Per-field encryption at rest in CloudKit (NOT local-store encryption). // Apple DTS confirms: `.allowsCloudEncryption` only has effect on CloudKit- // synced stores. Use platform Data Protection (`FileProtectionType`) for // local at-rest encryption. @Attribute(.allowsCloudEncryption) var sensitiveData: Data? var content: String init(slug: String, title: String, content: String) { self.slug = slug self.title = title self.newName = title self.archiveID = UUID() self.content = content } } ``` ### @Transient Properties not persisted to store: ```swift @Model final class Task { var title: String var isComplete: Bool // Not saved to database @Transient var isSelected = false @Transient var cachedPreview: NSImage? init(title: String) { self.title = title self.isComplete = false } } ``` **Important**: `@Transient` properties must have default values since they won't be loaded from the store. ## Codable Properties Store complex types as Codable: ```swift struct Address: Codable { var street: String var city: String var zip: String var country: String } struct Preferences: Codable { var theme: String var fontSize: Int var notifications: Bool } @Model final class UserProfile { var name: String var address: Address // Stored as encoded data var preferences: Preferences init(name: String, address: Address, preferences: Preferences) { self.name = name self.address = address self.preferences = preferences } } ``` ### Enum properties ```swift enum Priority: String, Codable, CaseIterable { case low, medium, high, critical } enum TaskStatus: Int, Codable { case todo = 0 case inProgress = 1 case done = 2 case archived = 3 } @Model final class Task { var title: String var priority: Priority var status: TaskStatus init(title: String, priority: Priority = .medium) { self.title = title self.priority = priority self.status = .todo } } ``` ## External Storage For large binary data (images, files): ```swift @Model final class Photo { var name: String var dateTaken: Date // Stored outside the main SQLite file @Attribute(.externalStorage) var imageData: Data? @Attribute(.externalStorage) var thumbnailData: Data? init(name: String, imageData: Data?) { self.name = name self.dateTaken = .now self.imageData = imageData } } ``` External storage stores the data as a file on disk rather than inline in SQLite, improving query performance when you don't need the large data. ## Unique Constraints ```swift @Model final class Tag { @Attribute(.unique) var name: String var color: String init(name: String, color: String = "blue") { self.name = name self.color = color } } // Inserting a Tag with a duplicate name will upsert (update existing) let tag = Tag(name: "swift") // Creates new let tag2 = Tag(name: "swift") // Updates existing context.insert(tag2) // No duplicate ``` **Note**: Unique constraints are not supported with CloudKit sync. ## Identifiable & Hashable `@Model` types automatically conform to `Identifiable` using the persistent model ID: ```swift // Automatic - no need to define 'id' @Model final class Item { var name: String init(name: String) { self.name = name } } // item.id is PersistentIdentifier (auto-generated) // item.persistentModelID is the same value // In SwiftUI Lists ForEach(items) { item in // Identifiable via @Model Text(item.name) } ``` If you need a custom ID: ```swift @Model final class Item { @Attribute(.unique) var customID: UUID var name: String init(name: String) { self.customID = UUID() self.name = name } } ``` ## Compound uniqueness: `#Unique` `@Attribute(.unique)` constrains a single property. For multi-property (compound) uniqueness, use the freestanding `#Unique` macro inside the model body: ```swift @Model final class Enrollment { #Unique<Enrollment>([\.studentID, \.courseID]) // the pair must be unique var studentID: UUID var courseID: UUID var enrolledAt: Date init(studentID: UUID, courseID: UUID) { self.studentID = studentID self.courseID = courseID self.enrolledAt = .now } } ``` Multiple constraint sets are allowed: `#Unique<T>([\.a, \.b], [\.c])`. Same CloudKit caveat as `.unique` - unique constraints are unsupported when syncing, so a CloudKit-backed model cannot declare them. ## Indexes: `#Index` Declares single or compound indexes so predicates and sorts can use them instead of scanning: ```swift @Model final class LogEntry { #Index<LogEntry>([\.timestamp], [\.level, \.timestamp]) var timestamp: Date var level: String var message: String init(level: String, message: String) { self.level = level self.message = message self.timestamp = .now } } ``` Index the properties you actually filter and sort on. Compound index column order matters - `[\.level, \.timestamp]` serves "filter by level, sort by timestamp" but not the reverse. ## Model inheritance `@Model` classes can subclass other `@Model` classes (**macOS 26.0+**). Fetches on the parent type return subclass instances by default: ```swift @available(macOS 26, *) @Model class Media { var title: String init(title: String) { self.title = title } } @available(macOS 26, *) @Model final class Movie: Media { var runtime: Int = 0 init(title: String, runtime: Int) { self.runtime = runtime super.init(title: title) } } @available(macOS 26, *) @Model final class Podcast: Media { var episodeCount: Int = 0 init(title: String, episodeCount: Int) { self.episodeCount = episodeCount super.init(title: title) } } // Returns Movies and Podcasts too let all = try context.fetch(FetchDescriptor<Media>()) ``` The `@available` annotation is not optional: a `PersistentModel` subclass without one fails to compile with `A PersistentModel Subclass is required to have platform availability specified`, and annotating it below 26.0 fails with `A PersistentModel Subclass requires macOS 26.0 or greater`. Subclasses need explicit initializers like every other `@Model`. Control this on deletes with `includeSubclasses` (defaults to `true`): ```swift try context.delete(model: Media.self, where: #Predicate { $0.title.isEmpty }, includeSubclasses: false) // only exact Media rows ``` Inheritance costs query performance and complicates migration - prefer a shared protocol or an enum discriminator column unless you genuinely need polymorphic fetches. ## `.ephemeral` The complete `Schema.Attribute.Option` set in the macOS 26.5 SDK is exactly: `.unique`, `.transformable(by:)`, `.externalStorage`, `.allowsCloudEncryption`, `.preserveValueOnDeletion`, `.ephemeral`, `.spotlight`. There is no `.encrypt` option - CloudKit field encryption is `.allowsCloudEncryption` (covered above). `.ephemeral` is the one not documented elsewhere in this file: the property participates in the model but is never persisted. Use it for transient state - a computed progress value, a cached thumbnail - that should not survive a relaunch. ```swift @Model final class Download { var url: URL @Attribute(.ephemeral) var bytesReceived: Int = 0 // reset on every launch init(url: URL) { self.url = url } } ``` -
relationships-predicates.md 6.9 KB
# Relationships & Predicates ## Table of Contents - Relationship Types - Delete Rules - Inverse Relationships - Advanced #Predicate - Compound Predicates - Relationship Queries ## Relationship Types ### One-to-many ```swift @Model final class Folder { var name: String @Relationship(deleteRule: .cascade, inverse: \Document.folder) var documents: [Document] = [] init(name: String) { self.name = name } } @Model final class Document { var title: String var folder: Folder? init(title: String, folder: Folder? = nil) { self.title = title self.folder = folder } } // Usage let folder = Folder(name: "Projects") let doc = Document(title: "Proposal", folder: folder) context.insert(folder) // doc is automatically associated via the relationship ``` ### Many-to-many ```swift @Model final class Student { var name: String @Relationship(inverse: \Course.students) var courses: [Course] = [] init(name: String) { self.name = name } } @Model final class Course { var title: String var students: [Student] = [] init(title: String) { self.title = title } } // Associate let student = Student(name: "Alice") let course = Course(title: "Swift 101") student.courses.append(course) // course.students now automatically contains student ``` ### One-to-one ```swift @Model final class User { var name: String @Relationship(deleteRule: .cascade, inverse: \Profile.user) var profile: Profile? init(name: String) { self.name = name } } @Model final class Profile { var bio: String var avatarURL: URL? var user: User? init(bio: String) { self.bio = bio } } ``` ## Delete Rules | Rule | Behavior | |------|----------| | `.nullify` (default) | Set related objects' reference to nil | | `.cascade` | Delete related objects | | `.deny` | Prevent deletion if related objects exist | | `.noAction` | Do nothing (can leave orphans) | ```swift @Relationship(deleteRule: .cascade) // Delete children when parent deleted @Relationship(deleteRule: .nullify) // Set to nil, keep children @Relationship(deleteRule: .deny) // Block deletion if children exist ``` ### Cascade example ```swift @Model final class Project { var name: String @Relationship(deleteRule: .cascade) var tasks: [ProjectTask] = [] init(name: String) { self.name = name } } // Deleting project automatically deletes all its tasks context.delete(project) // tasks are cascade-deleted ``` ## Inverse Relationships Always specify inverse for bidirectional relationships: ```swift // Explicit inverse @Relationship(inverse: \Document.folder) var documents: [Document] = [] // SwiftData can infer inverse when unambiguous: // If Document has only one Folder? property, inverse is auto-detected. // Prefer explicit inverse for clarity. ``` ## Advanced #Predicate ### String operations ```swift // Contains (case-insensitive) #Predicate<Document> { doc in doc.title.localizedStandardContains(searchText) } // Starts with #Predicate<Document> { doc in doc.title.starts(with: "Draft") } // Not empty #Predicate<Document> { doc in !doc.content.isEmpty } ``` ### Date comparisons ```swift // Created today let startOfDay = Calendar.current.startOfDay(for: .now) #Predicate<Document> { doc in doc.createdAt >= startOfDay } // Within last week let weekAgo = Calendar.current.date(byAdding: .day, value: -7, to: .now)! #Predicate<Document> { doc in doc.createdAt >= weekAgo } // Between dates #Predicate<Document> { doc in doc.createdAt >= startDate && doc.createdAt <= endDate } ``` ### Optional handling ```swift // Has value #Predicate<Task> { task in task.dueDate != nil } // Optional comparison (use force unwrap inside predicate) #Predicate<Task> { task in task.dueDate != nil && task.dueDate! < Date.now } ``` ### Compound predicates ```swift // AND #Predicate<Task> { task in !task.isComplete && task.priority == .high } // OR #Predicate<Task> { task in task.priority == .high || task.priority == .critical } // NOT #Predicate<Task> { task in !(task.status == .archived) } ``` ### Collection predicates ```swift // Contains element #Predicate<Project> { project in project.tags.contains("urgent") } // Any match #Predicate<Project> { project in project.tasks.contains(where: { !$0.isComplete }) } // Count #Predicate<Project> { project in project.tasks.count > 5 } // All match #Predicate<Folder> { folder in folder.documents.allSatisfy { $0.isReviewed } } ``` ## Dynamic Predicates Build predicates based on runtime conditions: ```swift func buildPredicate( searchText: String, showCompleted: Bool, priority: Priority? ) -> Predicate<Task> { #Predicate<Task> { task in (searchText.isEmpty || task.title.localizedStandardContains(searchText)) && (showCompleted || !task.isComplete) && (priority == nil || task.priority == priority) } } // Use in @Query (via init) struct TaskListView: View { @Query private var tasks: [Task] init(searchText: String, showCompleted: Bool, priority: Priority?) { let predicate = buildPredicate( searchText: searchText, showCompleted: showCompleted, priority: priority ) _tasks = Query(filter: predicate, sort: \.createdAt, order: .reverse) } } ``` ## Shaping the fetch `FetchDescriptor` carries more than `predicate` / `sortBy` / `fetchLimit`. These three change how much work a fetch does: ```swift var descriptor = FetchDescriptor<Book>( predicate: #Predicate { $0.year > 2000 }, sortBy: [SortDescriptor(\.title)] ) // Fetch only these properties; others fault in on demand. descriptor.propertiesToFetch = [\.title, \.year] // Warm these relationships in the same round trip - avoids the N+1 // pattern where iterating results triggers a fetch per book. descriptor.relationshipKeyPathsForPrefetching = [\.author] // Exclude unsaved in-context changes (default true). descriptor.includePendingChanges = false let books = try context.fetch(descriptor) ``` `relationshipKeyPathsForPrefetching` is the highest-leverage one: a list view that shows `book.author.name` for 500 rows issues 500 extra fetches without it. Also available: `fetchOffset` for paging alongside `fetchLimit`, and `context.fetchIdentifiers(_:)` when you only need `PersistentIdentifier`s (much cheaper than materializing models - useful for diffing or cross-context handoff). ## Relationship cardinality `@Relationship` takes more than `deleteRule` and `inverse`: ```swift @Relationship( deleteRule: .cascade, minimumModelCount: 1, // enforce at least one maximumModelCount: 10, // and at most ten inverse: \Chapter.book ) var chapters: [Chapter] = [] ``` `minimumModelCount` / `maximumModelCount` express cardinality constraints in the schema itself. `hashModifier` (on both `@Relationship` and `@Attribute`) forces a property to be treated as changed for migration purposes without renaming it. -
screen-capture-audio.md 63.1 KB
# Screen Capture & Audio Recording For per-process system audio without ScreenCaptureKit (call recording, background taps), see `core-audio-tap.md` - CATap is the lower-overhead alternative and has its own HFP / aggregate-device pitfalls. ## Table of Contents - SCShareableContent - Content Discovery - SCContentFilter - Filtering - SCStream & SCStreamConfiguration - SCStreamOutput - Receiving Samples - SCStream Production Gotchas - SCStream Teardown Gotchas - SCRecordingOutput (macOS 15+) - SCContentSharingPicker (macOS 14+) - SCScreenshotManager (macOS 14+) - Audio-Only Capture Pattern - AVAudioEngine for Mic (Dual Pipeline) - AVAssetWriter for Audio - AVAssetWriter Crash Safety - AVAudioFile for Audio - CMSampleBuffer to AVAudioPCMBuffer - AVAudioPCMBuffer to CMSampleBuffer (for AVAssetWriter) - Audio Format Settings - Permissions - TCC Operational Gotchas - Complete Examples ## SCShareableContent - Content Discovery ```swift import ScreenCaptureKit // Enumerate available content (macOS 12.3+) let content = try await SCShareableContent.excludingDesktopWindows( false, onScreenWindowsOnly: true ) content.displays // [SCDisplay] content.windows // [SCWindow] content.applications // [SCRunningApplication] // Async property (macOS 14+) let content = try await SCShareableContent.current ``` Key types: ```swift // SCDisplay display.displayID // CGDirectDisplayID display.width // Int display.height // Int // SCRunningApplication app.bundleIdentifier // String app.applicationName // String app.processID // pid_t // SCWindow window.windowID // CGWindowID window.title // String? window.isOnScreen // Bool window.owningApplication // SCRunningApplication? ``` **Note**: `SCShareableContent` calls trigger the screen recording permission prompt if not yet granted. First call after granting permission requires app restart. ## SCContentFilter - Filtering ```swift // Single window (follows across displays) let filter = SCContentFilter(desktopIndependentWindow: window) // Specific apps on a display let filter = SCContentFilter( display: display, including: [app1, app2], exceptingWindows: [] ) // Full display, exclude own app let excludedApps = content.applications.filter { Bundle.main.bundleIdentifier == $0.bundleIdentifier } let filter = SCContentFilter( display: display, excludingApplications: excludedApps, exceptingWindows: [] ) // Specific windows on a display let filter = SCContentFilter(display: display, including: [window1, window2]) // Display minus specific windows let filter = SCContentFilter(display: display, excludingWindows: [windowToExclude]) ``` | Use Case | Initializer | |----------|------------| | Single window (follows across displays) | `init(desktopIndependentWindow:)` | | Entire display minus own app | `init(display:excludingApplications:exceptingWindows:)` | | Specific apps only | `init(display:including:exceptingWindows:)` | | Specific windows | `init(display:including:)` | Audio filtering works at the **application level** - the filter determines which apps' audio is captured. ## SCStream & SCStreamConfiguration ### Configuration ```swift let config = SCStreamConfiguration() // Video config.width = 1920 config.height = 1080 config.minimumFrameInterval = CMTime(value: 1, timescale: 60) // 60 fps config.pixelFormat = kCVPixelFormatType_32BGRA config.queueDepth = 5 // max frames in queue (default 3, max 8) config.showsCursor = true config.scalesToFit = true // Audio (macOS 12.3+) config.capturesAudio = true config.sampleRate = 48000 // up to 48kHz config.channelCount = 2 // stereo config.excludesCurrentProcessAudio = true // Microphone (macOS 15+) config.captureMicrophone = true config.microphoneCaptureDeviceID = AVCaptureDevice.default(for: .audio)?.uniqueID // HDR (macOS 15+) config.captureDynamicRange = .hdrCanonicalDisplay // Resolution (macOS 14+) config.captureResolution = .best ``` Configuration presets (macOS 15+): ```swift let config = SCStreamConfiguration(preset: .captureHDRStreamCanonicalDisplay) let config = SCStreamConfiguration(preset: .captureHDRScreenshotLocalDisplay) ``` ### Stream lifecycle ```swift // Create let stream = SCStream(filter: filter, configuration: config, delegate: self) // Add outputs try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: videoQueue) try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: audioQueue) try stream.addStreamOutput(self, type: .microphone, sampleHandlerQueue: micQueue) // macOS 15+ // Start/stop try await stream.startCapture() try await stream.stopCapture() // Update without restart try await stream.updateConfiguration(newConfig) try await stream.updateContentFilter(newFilter) ``` ### SCStreamDelegate ```swift extension CaptureManager: SCStreamDelegate { func stream(_ stream: SCStream, didStopWithError error: Error) { // Stream stopped unexpectedly } } ``` ## SCStreamOutput - Receiving Samples ```swift class CaptureEngine: NSObject, SCStreamOutput { func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { guard sampleBuffer.isValid else { return } switch type { case .screen: handleVideo(sampleBuffer) case .audio: handleAudio(sampleBuffer) case .microphone: // macOS 15+ handleMicrophone(sampleBuffer) @unknown default: break } } } ``` ### Processing video buffers ```swift private func handleVideo(_ sampleBuffer: CMSampleBuffer) { guard let attachments = CMSampleBufferGetSampleAttachmentsArray( sampleBuffer, createIfNecessary: false ) as? [[SCStreamFrameInfo: Any]], let status = attachments.first?[.status] as? Int, SCFrameStatus(rawValue: status) == .complete, let pixelBuffer = sampleBuffer.imageBuffer else { return } let surface = CVPixelBufferGetIOSurface(pixelBuffer)?.takeUnretainedValue() let ciImage = CIImage(cvPixelBuffer: pixelBuffer) } ``` ### Processing audio buffers ```swift private func handleAudio(_ sampleBuffer: CMSampleBuffer) { try? sampleBuffer.withAudioBufferList { audioBufferList, blockBuffer in guard let desc = sampleBuffer.formatDescription?.audioStreamBasicDescription, let format = AVAudioFormat( standardFormatWithSampleRate: desc.mSampleRate, channels: desc.mChannelsPerFrame ), let pcmBuffer = AVAudioPCMBuffer( pcmFormat: format, bufferListNoCopy: audioBufferList.unsafePointer ) else { return } // Use pcmBuffer for processing, level metering, or writing } } ``` ## SCStream Production Gotchas ### SCStream is NOT reusable after error After `didStopWithError`, the XPC connection to `replayd` is invalidated. Calling `startCapture()` again throws `attemptToStartStreamState`. You must destroy the stream and create a new one: ```swift func stream(_ stream: SCStream, didStopWithError error: Error) { // DO NOT try stream.startCapture() - it will throw self.stream = nil // Release the dead stream Task { try await restartWithNewStream() } // Create fresh SCStream } ``` ### SCRecordingOutput stops on updateConfiguration() `SCRecordingOutput` stops recording when `SCStreamConfiguration` is updated on a running stream. This is documented in the Apple header. If your app needs mid-stream config changes (device following, resolution changes), use manual `SCStreamOutput` + `AVAssetWriter` instead. ### VPIO and SCStream are fundamentally incompatible `AVAudioEngine.setVoiceProcessingEnabled(true)` creates a hidden VPIO aggregate device that hooks into the system audio output path for its AEC reference signal. This silences SCStream's system audio capture. Do not use VPIO and SCStream together. Use post-processing AEC or an independent mic pipeline instead. ### SCStream .microphone output type is unreliable for dual-track The `.microphone` SCStreamOutputType (macOS 15+) can produce duration mismatches and data corruption when written as a second AVAssetWriterInput alongside system audio. Use an independent AVAudioEngine pipeline for mic capture instead (see "AVAudioEngine for Mic" section). ### Multiple SCStreams can run simultaneously Two `SCStream` instances (e.g., one display-wide, one per-app) can run from the same process. Each is an independent XPC connection to the ScreenCaptureKit daemon. Both start without error and deliver audio buffers concurrently. ### Virtual audio processors cause duplication in display-wide capture Virtual audio devices (Krisp, SoundSource) process audio with latency (~50ms). Display-wide capture picks up both the original app output and the virtual device's delayed copy, causing audible echo. Fix: use per-app `SCContentFilter(display:including:[specificApp])` to exclude virtual audio processors. ### Chrome/Electron helper bundle ID resolution CoreAudio and ScreenCaptureKit report Chrome's renderer subprocess as the audio client (`com.google.Chrome.helper.renderer`). Strip `.helper*` suffix to resolve to the parent app for `SCContentFilter` lookup: ```swift func resolveParentBundleID(_ bundleID: String) -> String { if let range = bundleID.range(of: ".helper", options: .literal) { return String(bundleID[..<range.lowerBound]) } return bundleID } // "com.google.Chrome.helper.renderer" -> "com.google.Chrome" ``` ### Never mutate SCStream's CMSampleBuffer in-place SCStream sample buffers are framework-managed and potentially shared. Writing into them via `CMBlockBufferGetDataPointer` is undefined behavior. Use dual-track recording (separate AVAssetWriterInputs) instead of mixing into existing buffers. ### SCStream error codes and recovery Full mapping from `SCError.h` in the macOS 26 SDK. Source (local path on any machine with Xcode 26 installed): `/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/System/Library/Frameworks/ScreenCaptureKit.framework/Versions/A/Headers/SCError.h`. API docs: https://developer.apple.com/documentation/screencapturekit/scstreamerror/code | Code | Enum case | Since | Meaning | |------|-----------|-------|---------| | -3801 | `userDeclined` | 12.3 | User did not authorize capture (TCC) | | -3802 | `failedToStart` | 12.3 | Stream failed to start (generic) | | -3803 | `missingEntitlements` | 12.3 | Missing required entitlements | | -3804 | `failedApplicationConnectionInvalid` | 12.3 | Recording connection became invalid | | -3805 | `failedApplicationConnectionInterrupted` | 12.3 | Recording connection was interrupted | | -3806 | `failedNoMatchingApplicationContext` | 12.3 | Context id does not match application | | -3807 | `attemptToStartStreamState` | 12.3 | Start attempted on a stream already running | | -3808 | `attemptToStopStreamState` | 12.3 | Stop attempted on a stream already stopped | | -3809 | `attemptToUpdateFilterState` | 12.3 | Update-filter attempted on stopped stream | | -3810 | `attemptToConfigState` | 12.3 | Update-config attempted on stopped stream | | -3811 | `internalError` | 12.3 | Video/audio capture failure | | -3812 | `invalidParameter` | 12.3 | Invalid parameter | | -3813 | `noWindowList` | 12.3 | No window list available | | -3814 | `noDisplayList` | 12.3 | No display list available | | -3815 | `noCaptureSource` | 12.3 | No display or window list to capture | | -3816 | `removingStream` | 12.3 | Failed to remove stream | | -3817 | `userStopped` | 12.3 | User stopped via system UI | | -3818 | `failedToStartAudioCapture` | 13.0 | Audio capture failed to start | | -3819 | `failedToStopAudioCapture` | 13.0 | Audio capture failed to stop | | -3820 | `failedToStartMicrophoneCapture` | 15.0 | Microphone capture failed to start | | -3821 | `systemStoppedStream` | 15.0 | System stopped the stream (sleep/wake, policy) | Recovery rules of thumb: `-3801` / `-3803` → permission or entitlement problem, stop and prompt user. `-3817` → user intent, save and stop. `-3818` / `-3820` → restart without the offending track. `-3821` → wait ~1 s and restart with a new stream. Rate-limit restarts (e.g. max 3 in 30 s) to prevent infinite loops. Remember SCStream is not reusable after error — destroy and recreate. Keep `mappedStartError` and the stop-time error handler in sync - in practice they drift (stop-time maps -3801/-3802/-3821, start-time only maps -3801), producing misleading user-facing error messages. ## SCStream Teardown Gotchas Teardown is the phase where SCStream apps lose files, hang, and leak state. Three patterns worth internalizing. ### `stopCapture()` can hang; wrap it in a timeout Under WindowServer stalls or TCC-revoked mid-stream state, `try await stream.stopCapture()` can block for 10+ seconds. `applicationShouldTerminate:` gives the app ~8 seconds total before force-kill, so an unguarded `stopCapture` call blows the budget and the writer never finalizes (partial file, or nothing). Wrap it in a bounded `withTimeout`: ```swift func stopSafely() async { _ = await withTimeout(seconds: 3) { try? await self.stream?.stopCapture() } self.audioInput?.markAsFinished() _ = await withTimeout(seconds: 3) { await self.writer?.finishWriting() } } func withTimeout<T: Sendable>(seconds: TimeInterval, _ op: @escaping @Sendable () async -> T) async -> T? { await withTaskGroup(of: T?.self) { group in group.addTask { await op() } group.addTask { try? await Task.sleep(for: .seconds(seconds)) return nil } let result = await group.next() ?? nil group.cancelAll() return result } } ``` ### Assign the stream reference *before* awaiting `startCapture()` ```swift // FRAGILE: if TCC was revoked between preflight and start, catch cannot cleanly stop. func start() async throws { let s = SCStream(filter: filter, configuration: config, delegate: self) try s.addStreamOutput(self, type: .audio, sampleHandlerQueue: audioQueue) do { try await s.startCapture() self.stream = s // assigned after success only } catch { // 's' is out of scope or half-initialized; hard to drive cleanup. throw error } } // BETTER: assign first, so the catch can drive teardown uniformly. func start() async throws { let s = SCStream(filter: filter, configuration: config, delegate: self) try s.addStreamOutput(self, type: .audio, sampleHandlerQueue: audioQueue) self.stream = s do { try await s.startCapture() } catch { await self.stopSafely() // cleans up self.stream uniformly throw error } } ``` **Trade-off**: assigning before `startCapture` exposes a short window where `self.stream` is non-nil but not yet capturing. A concurrent `stop()` during that window must be tolerant of `stopCapture()` on an unstarted stream (SCStream handles this, but the undocumented error path is worth a test). ### Prefer `SCShareableContent.current` over `excludingDesktopWindows(false, false)` in restart paths `SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false)` is the slow variant - it enumerates off-screen windows, virtual desktops, and minimized windows. Auto-restart paths (up to 3 restarts in 30 s after `.systemStopped`) that re-query this on every attempt produce visible UI stalls. For audio streams you typically don't need the window list at all; cache the display handle at first start and reuse it. When you do need a fresh enumeration, prefer the narrower `SCShareableContent.current` (macOS 14+) or `excludingDesktopWindows(true, onScreenWindowsOnly: true)`. ```swift // Keep this cached across restarts: private var cachedDisplay: SCDisplay? func refreshDisplayIfNeeded() async throws -> SCDisplay { if let d = cachedDisplay { return d } let content = try await SCShareableContent.current // fast guard let d = content.displays.first else { throw CaptureError.noDisplay } cachedDisplay = d return d } ``` ## SCRecordingOutput (macOS 15+) Simplified file recording without manual AVAssetWriter buffer handling. ```swift // Configure let recordingConfig = SCRecordingOutputConfiguration() recordingConfig.outputURL = fileURL // file URL, not folder recordingConfig.outputFileType = .mov // .mov or .mp4 recordingConfig.videoCodecType = .hevc // .hevc or .h264 // Create let recordingOutput = SCRecordingOutput( configuration: recordingConfig, delegate: self ) // Add to stream BEFORE startCapture for guaranteed first-frame capture try stream.addRecordingOutput(recordingOutput) try await stream.startCapture() // Monitor recordingOutput.recordedDuration // CMTime recordingOutput.recordedFileSize // Int64 // Stop recording only (keep streaming) try stream.removeRecordingOutput(recordingOutput) // Or stop everything try await stream.stopCapture() ``` ### SCRecordingOutputDelegate ```swift extension CaptureManager: SCRecordingOutputDelegate { func recordingOutputDidStartRecording(_ output: SCRecordingOutput) { } func recordingOutput(_ output: SCRecordingOutput, didFailWithError error: Error) { } func recordingOutputDidFinishRecording(_ output: SCRecordingOutput) { // File is ready at outputURL } } ``` **Caveats**: Only ONE recording output per stream. Handles video recording; audio-only recording still uses AVAudioFile/AVAssetWriter. Updating `SCStreamConfiguration` on a running stream stops the recording. ## SCContentSharingPicker (macOS 14+) System-provided picker UI for selecting content. Apple's preferred approach in macOS 15+. ```swift let picker = SCContentSharingPicker.shared picker.add(self) // SCContentSharingPickerObserver picker.isActive = true // Present picker.present() picker.present(using: .window) // specific style picker.present(for: existingStream) // for existing stream // Configure let pickerConfig = SCContentSharingPickerConfiguration() pickerConfig.allowedPickerModes = [.singleWindow, .multipleWindows, .singleApplication] pickerConfig.excludedBundleIDs = ["com.example.excluded"] picker.defaultConfiguration = pickerConfig ``` ### Observer ```swift extension CaptureManager: SCContentSharingPickerObserver { func contentSharingPicker(_ picker: SCContentSharingPicker, didUpdateWith filter: SCContentFilter, for stream: SCStream?) { if let stream { try? await stream.updateContentFilter(filter) } else { // Create new stream with filter } } func contentSharingPicker(_ picker: SCContentSharingPicker, didCancel forStream: SCStream?) { } func contentSharingPickerStartDidFailWithError(_ error: Error) { } } ``` ## SCScreenshotManager (macOS 14+) Single-frame capture without a persistent stream. ```swift let image: CGImage = try await SCScreenshotManager.captureImage( contentFilter: filter, configuration: config ) let buffer: CMSampleBuffer = try await SCScreenshotManager.captureSampleBuffer( contentFilter: filter, configuration: config ) // HDR (macOS 15+) let hdrConfig = SCStreamConfiguration(preset: .captureHDRScreenshotLocalDisplay) let hdrImage = try await SCScreenshotManager.captureImage( contentFilter: filter, configuration: hdrConfig ) ``` ## Audio-Only Capture Pattern **ScreenCaptureKit has no documented audio-only mode, but `.audio`-only streams work in practice.** You can create an `SCStream` with `capturesAudio = true` and attach only `addStreamOutput(_:type:.audio,...)` - no `.screen` output. Audio buffers flow. Validated across macOS 14/15/26 in multiple shipping apps (Aperture, Blackbox across v0.3/v0.4/v0.6/v0.8, etc.). Two caveats, and the second one is not cosmetic: 1. The framework logs `stream output NOT found. Dropping frame` for every video frame when no `.screen` output is attached, because the **video pipeline still runs** internally. Attach a `.screen` output that ignores its buffers (on its own queue, not the audio queue) and the log line goes away. 2. That still-running video pipeline costs real CPU: every frame is a WindowServer recomposite of the whole display. At the display's refresh rate on a 5K monitor this is ~15-20% of a core across the app, `replayd`, and WindowServer for the entire recording. Throttle it with `minimumFrameInterval` (below). **`minimumFrameInterval` trap.** The property is the *minimum time between frames*, so a larger value means fewer frames. `CMTime(value: 1, timescale: CMTimeScale.max)` - which reads like "infinite interval" - is ~0.5 ns, the smallest positive interval expressible, and the SDK documents `kCMTimeZero` as "capture at display's native refresh rate". It requests the *maximum* frame rate. One shipping recorder ran with that line from v0.8.0 to v0.9.1 before a user measured the 60 fps recomposite (blackbox#17). `CMTime(value: 1, timescale: 1)` is 1 fps; same shape as the `1/60` used for 60 fps elsewhere in this file. **Do not reach for CATap as the "zero-overhead" replacement without reading `core-audio-tap.md` first.** Production experience (one call-recorder shipped CATap in v0.7.0 and reverted to display-wide SCStream in v0.8.0 five days later) shows CATap has structural clock-fragility: its IO proc is driven by the hardware output clock, so when that clock is idle, pinned by Bluetooth HFP, or stalled, buffers stop flowing silently. Display-wide SCStream's clock comes from the OS-composited mix and is decoupled from hardware output, making it more robust for long-duration recording even with the cosmetic video-pipeline overhead. CATap is the right tool when you specifically need sub-20 ms capture latency (real-time AEC, live analysis); for disk-bound recording workloads (calls, meetings, lectures), SCStream wins on reliability. If you stay with SCStream audio-only, minimize the hidden video work: ```swift let config = SCStreamConfiguration() config.capturesAudio = true config.sampleRate = 48000 config.channelCount = 2 config.excludesCurrentProcessAudio = true // Minimal video: 2x2 at 1 fps. NOT 1/Int32.max - that is ~0s = native refresh rate. config.width = 2 config.height = 2 config.minimumFrameInterval = CMTime(value: 1, timescale: 1) // Subscribe .screen even for audio-only (ignore its buffers), or SCStream logs a // dropped frame per frame. Keep it off the audio queue so video can never delay audio. try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: videoQueue) try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: audioQueue) ``` **App-specific audio**: use `SCContentFilter(display:including:exceptingWindows:)` with specific apps to capture only their audio output. ## AVAudioEngine for Mic (Dual Pipeline) For dual-track recording (system audio + mic), use SCStream for system audio and AVAudioEngine for mic independently. This avoids SCStream `.microphone` output reliability issues and VPIO incompatibility. ```swift class DualTrackRecorder { private var engine = AVAudioEngine() private let audioQueue = DispatchQueue(label: "AudioCapture") func startMicCapture() throws { let inputNode = engine.inputNode let format = inputNode.inputFormat(forBus: 0) // CRITICAL: Extract closure to @Sendable to avoid MainActor isolation inheritance let tapHandler: @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void = { [weak self] buffer, time in self?.audioQueue.async { self?.handleMicBuffer(buffer, time: time) } } inputNode.installTap(onBus: 0, bufferSize: 1024, format: format, block: tapHandler) engine.prepare() try engine.start() } } ``` ### AVAudioEngine device following When audio hardware changes (headphone plug/unplug, Bluetooth connect), handle `AVAudioEngineConfigurationChange`: ```swift // Use queue: .main to avoid isolation inheritance crash NotificationCenter.default.addObserver( forName: .AVAudioEngineConfigurationChange, object: engine, queue: .main ) { [weak self] _ in self?.handleConfigChange() } func handleConfigChange() { // Use the NEW device's native format, not the stored old one let newFormat = engine.inputNode.inputFormat(forBus: 0) guard newFormat.sampleRate > 0, newFormat.channelCount > 0 else { return } engine.inputNode.removeTap(onBus: 0) // Reinstall tap with new format... (see ObjC wrapper section below) } ``` Debounce config changes (200-500ms) - virtual devices like Krisp fire rapid sequences. ### installTap throws ObjC NSException, not Swift Error `AVAudioEngine.installTap(onBus:)` throws an ObjC `NSException` when the format is incompatible. Swift `do/catch` does NOT catch NSExceptions. A generic `ObjCTryBlock` wrapper also fails because the Swift compiler eliminates the ObjC trampoline for `NS_NOESCAPE` blocks in release builds. Fix: purpose-built ObjC wrapper methods where the entire throw-to-catch chain is pure ObjC: ```objc // ObjCExceptionCatcher.h #import <AVFAudio/AVFAudio.h> BOOL ObjCInstallTap(AVAudioNode *node, uint32_t bus, uint32_t bufferSize, AVAudioFormat * _Nullable format, void (^block)(AVAudioPCMBuffer *, AVAudioTime *), NSError **outError); BOOL ObjCStartEngine(AVAudioEngine *engine, NSError **outError); ``` ```objc // ObjCExceptionCatcher.m BOOL ObjCInstallTap(AVAudioNode *node, uint32_t bus, uint32_t bufferSize, AVAudioFormat *format, void (^block)(AVAudioPCMBuffer *, AVAudioTime *), NSError **outError) { @try { [node installTapOnBus:bus bufferSize:bufferSize format:format block:block]; return YES; } @catch (NSException *e) { if (outError) *outError = [NSError errorWithDomain:@"ObjCException" code:-1 userInfo:@{NSLocalizedDescriptionKey: e.reason ?: e.name}]; return NO; } } ``` In SPM, create a separate target for the ObjC code: ```swift .target( name: "ObjCExceptionCatcher", path: "Sources/ObjCExceptionCatcher", publicHeadersPath: "include", linkerSettings: [.linkedFramework("AVFAudio")] ) ``` ### VPIO aggregate device reports bogus channel counts `setVoiceProcessingEnabled(true)` reports combined input+output channels (e.g., 9 = mic + speaker). Pass `nil` as format to `installTap` to let VPIO negotiate internally: ```swift inputNode.installTap(onBus: 0, bufferSize: 1024, format: nil, block: handler) ``` ## AVAssetWriter for Audio **Preferred for ScreenCaptureKit** - accepts CMSampleBuffer directly (no conversion), supports compressed output. ```swift let writer = try AVAssetWriter(url: outputURL, fileType: .m4a) let audioSettings: [String: Any] = [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 48000.0, AVNumberOfChannelsKey: 2, AVEncoderBitRateKey: 128_000 ] let audioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings) audioInput.expectsMediaDataInRealTime = true writer.add(audioInput) writer.startWriting() // In SCStreamOutput callback: func handleAudio(_ sampleBuffer: CMSampleBuffer) { if !sessionStarted { writer.startSession(atSourceTime: sampleBuffer.presentationTimeStamp) sessionStarted = true } if audioInput.isReadyForMoreMediaData { audioInput.append(sampleBuffer) } } // Stop audioInput.markAsFinished() await writer.finishWriting() ``` File types: `.m4a` (AAC/ALAC), `.mov` (PCM/AAC/ALAC), `.mp4` (AAC), `.wav` (PCM), `.caf` (any). Pass `nil` for `outputSettings` to write without re-encoding (pass-through). ## AVAssetWriter Crash Safety ### movieFragmentInterval for crash recovery Writes fragment headers periodically, making partial files recoverable after crashes or force-kills. Works with `.m4a` container (empirically verified - not just `.mov`): ```swift writer.movieFragmentInterval = CMTime(seconds: 10, preferredTimescale: 600) ``` A force-killed recording produces a valid file with audio up to the last fragment boundary (~10s max data loss). ### expectsMediaDataInRealTime is critical Always set `expectsMediaDataInRealTime = true` on inputs, even for post-processing pipelines. Without it, the writer applies internal backpressure that can deadlock synchronous polling loops: ```swift audioInput.expectsMediaDataInRealTime = true // Prevents backpressure deadlock ``` ### Guard writer status in polling loops After `input.append()` fails, the writer enters `.failed` state and `isReadyForMoreMediaData` returns `false` forever. Without a status guard, polling loops hang infinitely: ```swift // BAD - hangs forever if writer fails: while !input.isReadyForMoreMediaData { usleep(10_000) } // GOOD - break on writer failure: while !input.isReadyForMoreMediaData { guard writer.status == .writing else { break } usleep(10_000) } ``` ### Session start timing for multi-track recording With dual-track (system audio + mic), start the session on the first system audio sample. Gate mic buffer appending on a `sessionStarted` flag. Mic samples arriving before session start must be dropped: ```swift func handleSystemAudio(_ sampleBuffer: CMSampleBuffer) { if !sessionStarted { writer.startSession(atSourceTime: sampleBuffer.presentationTimeStamp) sessionStarted = true } systemInput.append(sampleBuffer) } func handleMic(_ sampleBuffer: CMSampleBuffer) { guard sessionStarted else { return } // Drop pre-session mic samples micInput.append(sampleBuffer) } ``` ### Channel count mismatch causes silent audio Output settings must match actual input format. AVAudioEngine delivers mono (1ch) mic audio. Configuring the writer input for stereo (2ch) causes silent output with 100% append success rate: ```swift // System audio: 2ch stereo let systemSettings: [String: Any] = [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVNumberOfChannelsKey: 2, AVEncoderBitRateKey: 128_000, ... ] // Mic audio: 1ch mono (matches AVAudioEngine input) let micSettings: [String: Any] = [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVNumberOfChannelsKey: 1, AVEncoderBitRateKey: 64_000, ... ] ``` ### CMBlockBuffer memory trap Never use `CMBlockBufferCreateWithMemoryBlock` with `flags: 0` and `memoryBlock: nil` - this defers memory allocation and `CMBlockBufferReplaceDataBytes` writes to uninitialized memory. Use `kCMBlockBufferAssureMemoryNowFlag` or the `CMSampleBufferSetDataBufferFromAudioBufferList` pattern (see next section). ### AVAssetWriterInput format is immutable after the first append The internal AAC encoder configures itself from the **first** appended sample buffer's format description. Appending a buffer with a different format later causes `append()` to fail silently (returns `false` and the writer eventually enters `.failed` state, losing **both** tracks in a dual-track output). This matters concretely when the mic device changes mid-recording: - AVAudioEngine rebuilds its tap on `AVAudioEngineConfigurationChange` with whatever the new device's native format is (different sample rate, channel count). - If you let that new format flow into the writer input directly, the writer goes to `.failed`. The fix: **resample/downmix into a fixed target format** (e.g. mono 48 kHz Float32) before appending, and reinstall the tap with whatever source format the device dictates. One production-hardened path: mic tap → `resampleToMono48k()` (linear interpolation, any source rate → 48 kHz mono) → `asSampleBuffer()` → writer input. System track (SCStream) bypasses resampling since SCStream always delivers at the configured `sampleRate`/`channelCount` regardless of hardware. ### AVAssetWriter collapses PTS gaps; fill with explicit silence No AVAssetWriter property makes it preserve gaps between buffers. If buffer A ends at t=10.0 s and buffer B arrives with PTS=12.5 s, the AAC encoder writes them back-to-back and the track ends up 2.5 s shorter than it should be. In a dual-track recording this manifests as one track being seconds shorter than the other, growing over the course of the recording. Detection / fill pattern (run on the same queue as `append`, per input): ```swift actor TrackState { var nextExpectedPTS: CMTime = .invalid let input: AVAssetWriterInput let silenceFormat: CMFormatDescription // clean LPCM, built once func append(_ sample: CMSampleBuffer) { let incoming = CMSampleBufferGetPresentationTimeStamp(sample) if nextExpectedPTS.isValid, CMTimeCompare(incoming, nextExpectedPTS) > 0, CMTimeGetSeconds(incoming - nextExpectedPTS) > 0.005 { fillGap(from: nextExpectedPTS, to: incoming) } if input.isReadyForMoreMediaData { input.append(sample) } let dur = CMSampleBufferGetDuration(sample) nextExpectedPTS = incoming + dur } private func fillGap(from start: CMTime, to end: CMTime) { // Write SMALL chunks (~1024 samples each). One big silent buffer spanning // the whole gap causes kCMSampleBufferError_ArrayTooSmall (-12737) or // crashes the AAC encoder. Bail out on any failure - never risk the // real buffer to chase gap accuracy. var cursor = start while CMTimeCompare(cursor, end) < 0, input.isReadyForMoreMediaData { let silent = makeSilentSampleBuffer(at: cursor, frames: 1024, format: silenceFormat) if !input.append(silent) { break } cursor = cursor + CMTime(value: 1024, timescale: 48_000) } } } ``` Three constraints the pattern *must* respect: 1. **Write silence in small chunks** matching normal buffer cadence (~1024 samples at 48 kHz). One large silent buffer covering the full gap fails with `kCMSampleBufferError_ArrayTooSmall` or crashes the AAC encoder. 2. **Build a clean LPCM `CMFormatDescription` from scratch** for the silence (Float32, packed, interleaved, no channel-layout extensions). Do NOT reuse the format description from pipeline buffers - they may carry channel layouts or non-interleaved flags that don't match a flat zero-filled block buffer, and `input.append()` will reject or the writer will fail. 3. **Never block the real buffer on gap-fill success.** If silence `append` fails or `isReadyForMoreMediaData` goes false mid-fill, break out and still try the real buffer. Accept partial desync over data loss. Log the partial fill for diagnostics. Gap detection threshold >5 ms (240 samples at 48 kHz) avoids false positives from normal PTS jitter. ## AVAudioFile for Audio Simpler but PCM-only, requires CMSampleBuffer-to-AVAudioPCMBuffer conversion. ```swift let settings: [String: Any] = [ AVFormatIDKey: kAudioFormatLinearPCM, AVSampleRateKey: 48000.0, AVNumberOfChannelsKey: 2, AVLinearPCMBitDepthKey: 32, AVLinearPCMIsFloatKey: true, AVLinearPCMIsBigEndianKey: false, AVLinearPCMIsNonInterleaved: false ] let audioFile = try AVAudioFile( forWriting: url, settings: settings, commonFormat: .pcmFormatFloat32, interleaved: false // AVAudioFile requires non-interleaved ) // In callback, after converting CMSampleBuffer to AVAudioPCMBuffer: try audioFile.write(from: pcmBuffer) // Close by setting to nil audioFile = nil ``` **`settings`** = file format on disk. **`commonFormat`** = processing format of buffers passed to `write(from:)`. ## CMSampleBuffer to AVAudioPCMBuffer **If audio is silent after the conversion, check this first.** SCStream on macOS 26 delivers Float32 stereo as **non-interleaved** (two separate buffers in the `AudioBufferList`). Code written for interleaved layout that `memcpy`s the `CMBlockBuffer` bytes into `floatChannelData[0]` produces a valid-looking PCM buffer that decodes as silence / garbage. This has silently killed multi-minute recordings in production. The safe one-liner: let CoreMedia copy into the `AudioBufferList` and handle both layouts: ```swift extension AVAudioPCMBuffer { static func from(_ sampleBuffer: CMSampleBuffer) -> AVAudioPCMBuffer? { guard let formatDescription = CMSampleBufferGetFormatDescription(sampleBuffer) else { return nil } let numSamples = CMSampleBufferGetNumSamples(sampleBuffer) let avFormat = AVAudioFormat(cmAudioFormatDescription: formatDescription) guard let pcmBuffer = AVAudioPCMBuffer( pcmFormat: avFormat, frameCapacity: AVAudioFrameCount(numSamples) ) else { return nil } pcmBuffer.frameLength = AVAudioFrameCount(numSamples) // Handles both interleaved and non-interleaved source layouts. CMSampleBufferCopyPCMDataIntoAudioBufferList( sampleBuffer, at: 0, frameCount: Int32(numSamples), into: pcmBuffer.mutableAudioBufferList ) return pcmBuffer } } ``` Better yet for AVAssetWriter destinations: **skip the conversion entirely** and append the `CMSampleBuffer` directly to a stereo AAC `AVAssetWriterInput` (see "AVAssetWriter for Audio"). Passthrough is both simpler and avoids every class of PCM-layout bug. Modern Swift alternative using `copyPCMData(fromRange:into:)`: ```swift try sampleBuffer.copyPCMData( fromRange: 0..<CMSampleBufferGetNumSamples(sampleBuffer), into: pcmBuffer.mutableAudioBufferList ) ``` ## AVAudioPCMBuffer to CMSampleBuffer (for AVAssetWriter) When writing AVAudioEngine tap output to AVAssetWriter, convert `AVAudioPCMBuffer` to `CMSampleBuffer`. Let CoreMedia manage block buffer memory: ```swift func makeSampleBuffer(from pcmBuffer: AVAudioPCMBuffer, time: AVAudioTime) -> CMSampleBuffer? { let format = pcmBuffer.format let frameCount = pcmBuffer.frameLength guard let formatDesc = format.formatDescription else { return nil } var sampleBuffer: CMSampleBuffer? var timing = CMSampleTimingInfo( duration: CMTime(value: 1, timescale: Int32(format.sampleRate)), presentationTimeStamp: CMTime( seconds: AVAudioTime.seconds(forHostTime: time.hostTime), preferredTimescale: 600 ), decodeTimeStamp: .invalid ) // Create empty sample buffer guard CMSampleBufferCreate( allocator: kCFAllocatorDefault, dataBuffer: nil, dataReady: false, makeDataReadyCallback: nil, refcon: nil, formatDescription: formatDesc, sampleCount: CMItemCount(frameCount), sampleTimingEntryCount: 1, sampleTimingArray: &timing, sampleSizeEntryCount: 0, sampleSizeArray: nil, sampleBufferOut: &sampleBuffer ) == noErr, let sb = sampleBuffer else { return nil } // Attach audio data (CoreMedia manages block buffer memory) guard CMSampleBufferSetDataBufferFromAudioBufferList( sb, blockBufferAllocator: kCFAllocatorDefault, blockBufferMemoryAllocator: kCFAllocatorDefault, flags: 0, bufferList: pcmBuffer.audioBufferList ) == noErr else { return nil } return sb } ``` ## Audio Format Settings | Constant | Value | Container | |----------|-------|-----------| | `kAudioFormatLinearPCM` | Uncompressed PCM | WAV, CAF, AIFF | | `kAudioFormatMPEG4AAC` | AAC (lossy) | M4A, MP4 | | `kAudioFormatAppleLossless` | ALAC (lossless) | M4A, CAF | | `kAudioFormatFLAC` | FLAC lossless | FLAC, CAF | Common settings: ```swift // WAV (16-bit PCM) [AVFormatIDKey: kAudioFormatLinearPCM, AVSampleRateKey: 48000.0, AVNumberOfChannelsKey: 2, AVLinearPCMBitDepthKey: 16, AVLinearPCMIsFloatKey: false, AVLinearPCMIsBigEndianKey: false] // M4A (AAC) [AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 48000.0, AVNumberOfChannelsKey: 2, AVEncoderBitRateKey: 128_000] // M4A (Apple Lossless) [AVFormatIDKey: kAudioFormatAppleLossless, AVSampleRateKey: 48000.0, AVNumberOfChannelsKey: 2, AVEncoderBitDepthHintKey: 16] ``` **AVAssetWriter note**: For `kAudioFormatLinearPCM` output, all `AVLinearPCM*` keys are required. For `kAudioFormatMPEG4AAC`, `AVEncoderBitRateKey` is required (`AVEncoderBitRatePerChannelKey` is NOT supported). ## Permissions ### Screen recording (TCC) Screen capture has NO dedicated entitlement. It is governed entirely by macOS TCC runtime permission. ```swift // Check (macOS 11+, does NOT trigger prompt) let hasAccess = CGPreflightScreenCaptureAccess() // Request (triggers system prompt once) CGRequestScreenCaptureAccess() // Or: calling SCShareableContent also triggers the prompt let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) ``` After granting permission, app restart is typically required. ### macOS 15+ recurring prompts macOS 15 Sequoia shows monthly re-authorization prompts. Options: - Use `SCContentSharingPicker` (Apple's preferred approach) - Request `com.apple.developer.persistent-content-capture` entitlement from Apple (VNC/remote desktop apps) - MDM: `forceBypassScreenCaptureAlert` key (enterprise only) ### Audio capture permissions | Audio Type | Permission | Entitlement | |-----------|-----------|-------------| | System/app audio via ScreenCaptureKit | Screen Recording (TCC) | None | | Microphone via AVFoundation | Microphone (TCC) | `com.apple.security.device.audio-input` (Hardened Runtime) | | Microphone via ScreenCaptureKit (macOS 15+) | Both Screen Recording + Microphone | `com.apple.security.device.audio-input` + `NSMicrophoneUsageDescription` | Microphone permission check: ```swift switch AVCaptureDevice.authorizationStatus(for: .audio) { case .authorized: proceed() case .notDetermined: let granted = await AVCaptureDevice.requestAccess(for: .audio) case .denied, .restricted: // Direct to System Settings NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")!) } ``` ### Entitlements for capture apps Non-sandboxed (Developer ID): ```xml <dict> <key>com.apple.security.device.audio-input</key> <true/> </dict> ``` Sandboxed (App Store): ```xml <dict> <key>com.apple.security.app-sandbox</key> <true/> <key>com.apple.security.device.microphone</key> <true/> <key>com.apple.security.device.audio-input</key> <true/> <key>com.apple.security.files.user-selected.read-write</key> <true/> <key>com.apple.security.network.client</key> <true/> </dict> ``` Info.plist (required for microphone): ```xml <key>NSMicrophoneUsageDescription</key> <string>Record audio alongside screen capture.</string> ``` ### macOS 26 TCC pane layout The pane is labeled **"Screen & System Audio Recording"** on macOS 14 Sonoma and later (it was renamed from "Screen Recording" in Sonoma; macOS 15.x and 26.x inherit the same label). On macOS 26 there is a separate "System Audio Recording Only" subsection below the main list. Granting Screen Recording on 26 implicitly grants system-audio capture; on 14/15 the `kTCCServiceAudioCapture` service is distinct from `kTCCServiceScreenCapture` — preflighting one does not answer for the other. The canonical deep-link prefix is `com.apple.settings.PrivacySecurity.extension` on macOS 26; the legacy `com.apple.preference.security` prefix still opens *something*, but on 26 it lands on the top Privacy pane rather than the subpane. Also: the `Privacy_AudioCapture` anchor lands on an inactive pane when the capturing app uses SCStream - use `Privacy_ScreenCapture` for anything SCStream-based. ### Open settings programmatically To avoid 5-site string duplication (and the Audio-vs-Screen URL drift that duplication invites), centralize the URLs in a small enum: ```swift enum SystemPreferenceURL: String { case screenCapture = "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ScreenCapture" case microphone = "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Microphone" case notifications = "x-apple.systempreferences:com.apple.preference.notifications" case accessibility = "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Accessibility" case loginItems = "x-apple.systempreferences:com.apple.LoginItems-Settings.extension" var url: URL { URL(string: rawValue)! } func open() { NSWorkspace.shared.open(url) } } // Usage: SystemPreferenceURL.screenCapture.open() ``` ### Reset permissions (development) ```bash tccutil reset ScreenCapture com.yourcompany.yourapp tccutil reset Microphone com.yourcompany.yourapp ``` ## TCC Operational Gotchas ### `CGPreflightScreenCaptureAccess` vs `CGRequestScreenCaptureAccess` Two superficially similar APIs with very different UX: | Call | Triggers dialog | Triggers "app will relaunch" | Use when | |------|-----------------|------------------------------|----------| | `CGPreflightScreenCaptureAccess()` | No | No | Every permission-sensitive UI refresh. Cheap. | | `CGRequestScreenCaptureAccess()` | Yes (once) | Yes | Exactly once during onboarding, at the step the user sees. | Call the preflight on every recording start and on `didBecomeActive` rather than caching a `UserDefaults` flag - the user can revoke permission between launches, and a stale cached flag leads to silent recordings with no error surface. Call `CGRequestScreenCaptureAccess()` at the onboarding *step* (the "Grant Screen Recording" screen), not at "Complete Setup". Users who click "Open System Settings" before hitting "Complete Setup" otherwise never trigger the in-app request path, and your onboarding behaves asymmetrically with mic / notifications (which fire per step). ### Reinstalling via force-recursive replace (rm‑rf + cp‑R) can leave TCC in a degraded state TCC is keyed by code-signature CDHash. When you replace `/Applications/Blackbox.app` via a force-recursive delete of `/Applications/App.app` followed by `cp -R ./export/App.app /Applications/`, TCC *remembers* the grant (same Developer ID, same CDHash) but content delivery can be broken - permission reads as authorized, `SCStream` starts without error, buffers flow at zero amplitude (RMS stays at `-inf`). There is no programmatic recovery. Direct-reading `~/Library/Application Support/com.apple.TCC/TCC.db` is blocked by SIP. The remediation the user must take: 1. System Settings → Privacy & Security → Screen & System Audio Recording 2. Toggle the app **off**, then **on** again Design your installer / update path accordingly - prefer an in-place overwrite (let the OS handle the inode swap) or run a `tccutil reset ScreenCapture $BUNDLE_ID` from a signed installer, rather than a force-recursive replace (rm‑rf + cp). For `make install` dev scripts, always `killall App` before replacing the bundle, otherwise the still-running process keeps executing from its unlinked inode and `open -a` brings the stale instance to front instead of launching the new one. ### Ad-hoc signing resets TCC on every rebuild Ad-hoc signing (`codesign --sign -`) generates a different CDHash each build. TCC identifies ad-hoc apps by CDHash, so permissions reset on every rebuild. Fix: use a self-signed development certificate - TCC then uses the designated requirement (cert + bundle ID), and permissions persist across rebuilds. ```bash # Create self-signed dev cert (one-time) openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes \ -subj "/CN=My Development" security import cert.pem -k ~/Library/Keychains/login.keychain-db security import key.pem -k ~/Library/Keychains/login.keychain-db ``` ### Terminal attribution Running a compiled binary from the terminal attributes Screen Recording permission to the terminal app (e.g., WezTerm, Terminal.app), not the actual app. Always test via `.app` bundle (`open MyApp.app`), not direct binary execution. ### Bare binaries cannot get Screen Recording Standalone binaries without a `.app` bundle and `CFBundleIdentifier` in Info.plist cannot reliably get Screen Recording permission on modern macOS. TCC expects a proper bundle. Wrap test binaries in a minimal `.app` with Info.plist. ### Two separate TCC entries for microphone `SCStreamConfiguration.captureMicrophone = true` and `AVCaptureDevice.requestAccess(for: .audio)` create separate TCC entries under different services (`kTCCServiceScreenCapture` vs `kTCCServiceMicrophone`). Both must be granted. System Settings Microphone pane shows both. ### CGRequestScreenCaptureAccess timing Do NOT call `CGRequestScreenCaptureAccess()` in `App.init()` - macOS attributes the permission to the parent process (terminal). Call it in `applicationDidFinishLaunching` when the app bundle is fully registered. ### Permission status vs action For `.notDetermined`: trigger `AVCaptureDevice.requestAccess()` (shows system dialog). For `.denied`: redirect to System Settings (user must toggle manually). Don't open Settings for `.notDetermined` - the system dialog is a better UX. ### Refresh permission state when app reactivates Permission state checked in `onAppear` becomes stale after user switches to Settings and back: ```swift .onAppear { refreshPermissions() } .onReceive(NotificationCenter.default.publisher( for: NSApplication.didBecomeActiveNotification )) { _ in refreshPermissions() } ``` ### Screen Recording permission requires restart After granting Screen Recording in System Settings, macOS shows "Quit & Reopen". This is unavoidable system behavior. Design onboarding so Screen Recording is the last permission step, framing the restart as "setup complete." ## Complete Examples ### Audio-only recorder for specific apps ```swift import ScreenCaptureKit import AVFoundation class AppAudioRecorder: NSObject, SCStreamOutput, SCStreamDelegate { private var stream: SCStream? private var writer: AVAssetWriter? private var audioInput: AVAssetWriterInput? private var sessionStarted = false private let audioQueue = DispatchQueue(label: "AudioCapture") func startRecording(appBundleID: String, to url: URL) async throws { let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) guard let display = content.displays.first, let app = content.applications.first(where: { $0.bundleIdentifier == appBundleID }) else { throw CaptureError.appNotFound } let filter = SCContentFilter(display: display, including: [app], exceptingWindows: []) let config = SCStreamConfiguration() config.capturesAudio = true config.sampleRate = 48000 config.channelCount = 2 config.excludesCurrentProcessAudio = true config.width = 2 config.height = 2 config.minimumFrameInterval = CMTime(value: 1, timescale: 1) // 1 fps, not 1/Int32.max writer = try AVAssetWriter(url: url, fileType: .m4a) audioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 48000.0, AVNumberOfChannelsKey: 2, AVEncoderBitRateKey: 128_000 ]) audioInput!.expectsMediaDataInRealTime = true writer!.add(audioInput!) writer!.startWriting() stream = SCStream(filter: filter, configuration: config, delegate: self) try stream!.addStreamOutput(self, type: .screen, sampleHandlerQueue: nil) try stream!.addStreamOutput(self, type: .audio, sampleHandlerQueue: audioQueue) try await stream!.startCapture() } func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { guard type == .audio, sampleBuffer.isValid, let input = audioInput, input.isReadyForMoreMediaData else { return } if !sessionStarted { writer?.startSession(atSourceTime: sampleBuffer.presentationTimeStamp) sessionStarted = true } input.append(sampleBuffer) } func stopRecording() async { try? await stream?.stopCapture() stream = nil audioInput?.markAsFinished() await writer?.finishWriting() writer = nil sessionStarted = false } func stream(_ stream: SCStream, didStopWithError error: Error) { Task { await stopRecording() } } enum CaptureError: Error { case appNotFound } } ``` ### Full display recording with SCRecordingOutput (macOS 15+) ```swift import ScreenCaptureKit import AVFoundation @available(macOS 15.0, *) class DisplayRecorder: NSObject, SCStreamDelegate, SCRecordingOutputDelegate { private var stream: SCStream? private var recordingOutput: SCRecordingOutput? func startRecording(to url: URL) async throws { let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) guard let display = content.displays.first else { return } let excludeSelf = content.applications.filter { $0.bundleIdentifier == Bundle.main.bundleIdentifier } let filter = SCContentFilter( display: display, excludingApplications: excludeSelf, exceptingWindows: [] ) let config = SCStreamConfiguration() config.width = display.width * 2 config.height = display.height * 2 config.minimumFrameInterval = CMTime(value: 1, timescale: 60) config.capturesAudio = true config.sampleRate = 48000 config.channelCount = 2 config.excludesCurrentProcessAudio = true let recordingConfig = SCRecordingOutputConfiguration() recordingConfig.outputURL = url recordingConfig.outputFileType = .mov recordingConfig.videoCodecType = .hevc stream = SCStream(filter: filter, configuration: config, delegate: self) recordingOutput = SCRecordingOutput(configuration: recordingConfig, delegate: self) try stream!.addRecordingOutput(recordingOutput!) try await stream!.startCapture() } func stopRecording() async throws { try await stream?.stopCapture() stream = nil recordingOutput = nil } func recordingOutputDidStartRecording(_ output: SCRecordingOutput) { } func recordingOutputDidFinishRecording(_ output: SCRecordingOutput) { } func recordingOutput(_ output: SCRecordingOutput, didFailWithError error: Error) { } func stream(_ stream: SCStream, didStopWithError error: Error) { } } ``` ## API Availability | API | Minimum macOS | |-----|--------------| | SCShareableContent, SCContentFilter, SCStream, SCStreamOutput | 12.3 | | SCStreamConfiguration.capturesAudio | 12.3 | | SCContentSharingPicker, SCScreenshotManager | 14.0 | | SCRecordingOutput, SCStreamOutputType.microphone | 15.0 | | HDR capture, configuration presets | 15.0 | ## `synchronizationClock`: aligning SCStream with other capture sources `SCStream.synchronizationClock` exposes the `CMClock` that SCStream timestamps its buffers against. When you mix SCStream output with a second pipeline - an `AVAudioEngine` mic tap, a `CATap`, an `AVCaptureSession` - both sides must be expressed in the same time base or the muxed result drifts even though each track is individually correct. ```swift if let clock = stream.synchronizationClock { session.synchronizationClock = clock // AVCaptureSession // or convert a host-time stamp into the stream's base let streamTime = CMSyncConvertTime(hostTime, from: CMClockGetHostTimeClock(), to: clock) } ``` Do this instead of subtracting a captured "start host time" from both sides - that approximation accumulates error over long recordings and is a common cause of audio slowly sliding against video. ## Screenshots: `SCScreenshotConfiguration` (macOS 26+) `SCScreenshotManager.captureImage(contentFilter:configuration:)` takes an `SCStreamConfiguration`, which means single-frame capture inherits stream semantics it does not need. macOS 26 adds a dedicated path: ```swift let config = SCScreenshotConfiguration() config.width = 3840 config.height = 2160 config.dynamicRange = .high // HDR without the stream preset dance config.includesCursor = false let output = try await SCScreenshotManager.captureScreenshot( contentFilter: filter, configuration: config ) let image = output.cgImage ``` Prefer this over `SCStreamConfiguration(preset: .captureHDRScreenshotLocalDisplay)` on macOS 26+; the preset route remains correct for 14/15 back-deployment. ## Presenter Overlay delegate callbacks When the user turns on Presenter Overlay (the camera-overlay feature in video calls), the system reshapes your capture. `SCStreamDelegate` gets told, and most implementations silently ignore it: ```swift func stream(_ stream: SCStream, outputVideoEffectDidStartFor screen: SCStream) { // Overlay active - your frames now include the camera composite } func stream(_ stream: SCStream, outputVideoEffectDidStopFor screen: SCStream) { } ``` Set `config.presenterOverlayPrivacyAlertSetting` to control whether the system shows its privacy alert. If your app records for later playback rather than live presentation, handling these callbacks lets you warn the user that an overlay is being baked into the recording. ## `SCStreamConfiguration` properties worth knowing Beyond `width`/`height`/`capturesAudio`/`minimumFrameInterval`: | Property | Use | |---|---| | `sourceRect` | Capture a sub-region of the display without post-cropping | | `destinationRect` | Place captured content within the output surface | | `preservesAspectRatio` | Letterbox instead of stretching when the rects disagree | | `showMouseClicks` | Visualize clicks - useful for tutorial/demo recording | | `includeChildWindows` | Include or exclude child windows of a captured window | | `capturesShadowsOnly` | Window shadows without the window | | `colorMatrix` | Override the YCbCr matrix for the output pixel buffers | | `ignoreGlobalClipDisplay` / `ignoreGlobalClipSingleWindow` | Bypass global clipping behavior | ## Trap: a mono downmix that assumes interleaved layout A `resampleToMono` helper that indexes `floatChannelData[0]` as `[L, R, L, R, ...]` is only correct for an **interleaved** buffer. `AVAudioPCMBuffer` is frequently **planar (non-interleaved)**, where `floatChannelData[0]` is the entire left channel and the right channel lives at `floatChannelData[1]`. Feed a planar buffer to interleaved-indexing code and you average two adjacent *left* samples and never read the right channel at all. The output is audibly wrong but not silent - it passes a smoke test and ships. ```swift func downmixToMono(_ buffer: AVAudioPCMBuffer) -> [Float] { guard let data = buffer.floatChannelData else { return [] } let frames = Int(buffer.frameLength) let channels = Int(buffer.format.channelCount) guard channels > 1 else { return Array(UnsafeBufferPointer(start: data[0], count: frames)) } var mono = [Float](repeating: 0, count: frames) if buffer.format.isInterleaved { let p = data[0] for i in 0..<frames { mono[i] = (p[i * channels] + p[i * channels + 1]) * 0.5 } } else { let left = data[0], right = data[1] for i in 0..<frames { mono[i] = (left[i] + right[i]) * 0.5 } } return mono } ``` Always branch on `buffer.format.isInterleaved`. Test with hostile stereo - opposite-polarity channels sum to silence under a correct downmix and to a full-amplitude signal under the broken one, which makes the bug unmissable. ## Trap: a stale TCC row from a superseded signing identity After switching signing identity - ad-hoc or self-signed during development, then Developer ID for re -
sendable-safety.md 12.9 KB
# Sendable & Data Race Safety ## Table of Contents - Sendable Protocol - Implicit Sendable - Making Types Sendable - @unchecked Sendable - @unchecked Sendable + Serial Queue Pattern - @Sendable Closures - `[#SendingRisksDataRace]` Shim for Apple Non-Sendable Types - @preconcurrency import - Swift 6 Language Mode - Common Patterns ## Sendable Protocol `Sendable` marks types safe to share across concurrency domains (actors, tasks): ```swift // Value types with Sendable stored properties are implicitly Sendable struct Point: Sendable { var x: Double var y: Double } // Enums with Sendable associated values. // Note: `any Error` is NOT Sendable by default, so an enum carrying it cannot // claim Sendable unless the error type is concrete and Sendable. enum NetworkResult: Sendable { case success(Data) case failure(NetworkError) // concrete, Sendable error type } // If you need to carry `any Error`, drop Sendable conformance or route through // a Sendable wrapper. ``` ## Implicit Sendable These are automatically Sendable without explicit conformance: - Primitive types (`Int`, `String`, `Bool`, `Double`, etc.) - Structs where all stored properties are Sendable - Enums where all associated values are Sendable - Tuples of Sendable types - Metatypes (`Int.Type`) - Actors (isolated state) ## Making Types Sendable ### Final immutable classes ```swift final class AppConfig: Sendable { let apiURL: URL let timeout: TimeInterval let maxRetries: Int init(apiURL: URL, timeout: TimeInterval, maxRetries: Int) { self.apiURL = apiURL self.timeout = timeout self.maxRetries = maxRetries } } ``` Requirements for class Sendable: - Must be `final` - All stored properties must be `let` (immutable) - All stored properties must be `Sendable` ### Sendable through actors ```swift // Instead of making a mutable class Sendable, use an actor actor UserSession { private var token: String? private var refreshTask: Task<String, Error>? func getToken() async throws -> String { if let token { return token } if let task = refreshTask { return try await task.value } let task = Task { try await refreshToken() } refreshTask = task let newToken = try await task.value token = newToken refreshTask = nil return newToken } } ``` ## @unchecked Sendable Escape hatch when you ensure thread safety yourself: ```swift // Thread-safe via internal locking final class ThreadSafeCache<Key: Hashable & Sendable, Value: Sendable>: @unchecked Sendable { private let lock = NSLock() private var storage: [Key: Value] = [:] func get(_ key: Key) -> Value? { lock.withLock { storage[key] } } func set(_ key: Key, value: Value) { lock.withLock { storage[key] = value } } } ``` **Use sparingly.** Prefer actors or restructuring to avoid `@unchecked Sendable`. Common legitimate uses: - Wrapping C/Objective-C types with internal synchronization - Types using `os_unfair_lock` or `NSLock` internally - Bridging legacy code during migration ## @unchecked Sendable + Serial Queue Pattern For classes that manage their own thread safety via a serial dispatch queue (common in audio/video recording), use `@unchecked Sendable` with `nonisolated(unsafe)` properties: ```swift class AudioRecorder: NSObject, @unchecked Sendable, SCStreamOutput { private let audioQueue = DispatchQueue(label: "com.app.audio") // State accessed from background callbacks - nonisolated(unsafe) + serial queue nonisolated(unsafe) private var writer: AVAssetWriter? nonisolated(unsafe) private var systemInput: AVAssetWriterInput? nonisolated(unsafe) private var micInput: AVAssetWriterInput? nonisolated(unsafe) private var sessionStarted = false nonisolated(unsafe) private var stopped = false // SCStreamOutput callback - runs on audioQueue (background) nonisolated func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { // All nonisolated(unsafe) state accessed exclusively on audioQueue guard !stopped, let input = systemInput, input.isReadyForMoreMediaData else { return } input.append(sampleBuffer) } // Callbacks passed at init, not set after - avoids data races nonisolated let onError: (@Sendable (Error) -> Void)? init(onError: (@Sendable (Error) -> Void)? = nil) { self.onError = onError } } ``` With `defaultIsolation(MainActor.self)`, pair `@ObservationIgnored` with `nonisolated(unsafe)` for internal bookkeeping properties in `@Observable` classes: ```swift @Observable class ResourceManager: @unchecked Sendable { // UI-visible state (MainActor-isolated, observed by SwiftUI) var isRecording = false // Internal state (not for UI, accessed on background queue) @ObservationIgnored nonisolated(unsafe) private var writer: AVAssetWriter? @ObservationIgnored nonisolated(unsafe) private var listenerIDs: Set<AudioObjectID> = [] } ``` ## @Sendable Closures Functions passed across concurrency boundaries must be `@Sendable`: ```swift // @Sendable closures cannot capture mutable state func performInBackground(_ work: @Sendable () async -> Void) { Task.detached { await work() } } // OK - captures immutable value let name = "test" performInBackground { print(name) } // Error - captures mutable variable var count = 0 performInBackground { count += 1 // Compiler error: mutation of captured var in @Sendable closure } ``` ## `[#SendingRisksDataRace]` Shim for Apple Non-Sendable Types When an `SCStreamOutput` method, `AVAudioEngine` tap, or `AudioObjectPropertyListenerBlock` hands a `CMSampleBuffer` / `AVAudioPCMBuffer` / `AudioBufferList*` into actor-isolated code, Swift 6 flags the parameter with the `SendingRisksDataRace` diagnostic group (canonical identifier used with `-Werror` / `-Wwarning`; the compiler prints it as `[#SendingRisksDataRace]` after each error line — see https://docs.swift.org/compiler/documentation/diagnostics/sending-risks-data-race). The type isn't `Sendable` and can't cross isolation boundaries by the usual rules. **If the callback already runs on the target executor** (e.g. SCStream's `sampleHandlerQueue` is your actor's `audioQueue`, or the `AudioObjectPropertyListenerBlock` was registered on that queue), the "data race" doesn't exist - the callback and the actor are literally the same isolation domain. The idiomatic workaround is a one-line rebind: ```swift nonisolated func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { // Rebinding breaks the sending check. Safe because this callback runs on // audioQueue, which IS this actor's executor. nonisolated(unsafe) let buffer = sampleBuffer self.assumeIsolated { iso in iso.handleSampleBuffer(buffer, type: type) } } ``` Reflexively adding an extra `audioQueue.async { ... }` to "fix" the warning is worse than the shim - it adds a redundant queue hop *and* can introduce the ordering bug described in `actors-isolation.md` ("Don't re-dispatch when the block already runs on the actor's executor"). **When NOT to use this shim**: if the callback fires on a queue that is *not* your actor's executor, the data race is real - you need a proper hop (`actor.method(buffer)` or `queue.async { assumeIsolated { ... } }`). The shim lies only when the callback is already running in the actor's isolation domain. ## @preconcurrency import Apple framework types like `AVAudioPCMBuffer`, `AVAssetWriter`, `CMSampleBuffer`, and `AVAudioFormat` lack `Sendable` conformance. Use `@preconcurrency import` to suppress warnings while Apple updates their frameworks: ```swift @preconcurrency import AVFoundation // Covers AVAudioPCMBuffer, AVAssetWriter, etc. @preconcurrency import CoreMedia // Covers CMSampleBuffer, CMTime, etc. @preconcurrency import AudioToolbox // Covers C block types used by CoreAudio listeners ``` This treats the imported types as implicitly `Sendable` (matching pre-concurrency behavior). When Apple adds proper annotations, remove `@preconcurrency` to get full checking. ### For deinit accessing non-Sendable C block types `deinit` that needs to remove a CoreAudio listener block (`AudioObjectPropertyListenerBlock`) or invoke AudioToolbox cleanup C APIs can fail to compile against a plain `import AudioToolbox` because the block typealias isn't `Sendable`. `@preconcurrency import AudioToolbox` unblocks this case without resorting to `@unchecked Sendable` on the whole enclosing type. `KeyPath<Root, Value>` is not unconditionally `Sendable` in Swift 6 (SE-0418): keypath-literal captures are inferred Sendable only when the captures themselves are Sendable, and a stored `KeyPath` property needs an explicit `& Sendable` constraint. This bites `KeyPathComparator`-based `Table` sort on Swift 6.2 / Xcode 26.0-26.2 (tracked as swiftlang/swift #75852 and #84983). Most cases are resolved in Swift 6.3 / Xcode 26.3+, but verify against the toolchain you target. Workaround on affected versions: pre-sort data in the source and avoid `KeyPathComparator` entirely. ## Swift 6 Language Mode Enable strict data race safety: ```swift // Package.swift .target( name: "MyApp", swiftSettings: [.swiftLanguageMode(.v6)] ) ``` What Swift 6 mode enforces: - All `Sendable` violations are errors (not warnings) - Global variables must be isolated or Sendable - Closures passed across isolation boundaries must be `@Sendable` - Protocol conformances must respect isolation ### Gradual migration ```swift // Start with strict concurrency checking as warnings .target( name: "MyApp", swiftSettings: [ .swiftLanguageMode(.v5), .enableUpcomingFeature("StrictConcurrency"), ] ) ``` Then fix warnings before enabling `.v6`. ## Common Patterns ### Global state ```swift // BAD: Mutable global var globalCache: [String: Data] = [:] // Error in Swift 6 // GOOD: Actor-isolated actor GlobalCache { static let shared = GlobalCache() private var storage: [String: Data] = [:] func get(_ key: String) -> Data? { storage[key] } func set(_ key: String, data: Data) { storage[key] = data } } // GOOD: nonisolated(unsafe) for truly thread-safe globals nonisolated(unsafe) let logger = Logger(subsystem: "com.app", category: "main") ``` ### Delegate patterns ```swift // Protocol must be MainActor-isolated or Sendable @MainActor protocol DocumentDelegate: AnyObject { func documentDidSave(_ document: Document) func documentDidFail(_ document: Document, error: Error) } ``` ### Migrating ObservableObject ```swift // Old (pre-Swift 5.9) class ViewModel: ObservableObject { @Published var items: [Item] = [] } // New @Observable @MainActor final class ViewModel { var items: [Item] = [] } // With default isolation (Swift 6.2), @MainActor is implicit @Observable final class ViewModel { var items: [Item] = [] } ``` ## The `Synchronization` module: `Mutex` and `Atomic` Actors are the default answer for shared mutable state, but they force `async` at every call site. In a realtime audio callback or a C-API completion handler you cannot `await` - which is exactly where `@unchecked Sendable` plus a manual lock usually gets reached for. The `Synchronization` module gives you a `Sendable`-correct alternative with no `@unchecked` escape hatch. ```swift import Synchronization final class LevelMeter: Sendable { private let peak = Mutex<Float>(0) // Callable from a realtime audio thread - no await, no actor hop func record(_ sample: Float) { peak.withLock { $0 = max($0, abs(sample)) } } func drain() -> Float { peak.withLock { value in defer { value = 0 } return value } } } ``` `Mutex<Value>` is `Sendable` when `Value` is, so the enclosing type conforms to `Sendable` without `@unchecked`. The compiler enforces that the value is only reachable inside `withLock`. For single values where a lock is overkill: ```swift import Synchronization let frameCount = Atomic<Int>(0) frameCount.add(1, ordering: .relaxed) let total = frameCount.load(ordering: .acquiring) let isRunning = Atomic<Bool>(false) // Compare-and-exchange for idempotent start/stop let (exchanged, _) = isRunning.compareExchange( expected: false, desired: true, ordering: .sequentiallyConsistent ) guard exchanged else { return } // someone else already started ``` Rules that matter: - **Never `await` inside `withLock`.** The closure is non-async by design; holding a lock across a suspension point is how you deadlock. - Keep the critical section tiny - copy out, compute outside. - Prefer `.relaxed` only for statistics counters. For anything establishing a happens-before relationship with other memory, use `.acquiring`/`.releasing` or `.sequentiallyConsistent`. - This is the tool for realtime and C-callback contexts. For ordinary app state, an actor is still the better default. -
spm-build.md 15.6 KB
# Swift Package Manager & Build ## Table of Contents - Package.swift Basics - Platform & Version Config - Dependencies - Targets & Products - Build Plugins - Swift Build (Open Source) - Macros - Resources - Conditional Compilation - Manual .app Bundle Assembly - Mixed ObjC Targets - Swift Testing with CLT (No Xcode) - Multiple Executable Targets ## Package.swift Basics ```swift // swift-tools-version: 6.2 import PackageDescription let package = Package( name: "MyMacApp", platforms: [ .macOS(.v14), // minimum deployment target ], products: [ .executable(name: "MyMacApp", targets: ["MyMacApp"]), .library(name: "MyAppCore", targets: ["MyAppCore"]), ], dependencies: [ .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.17.0"), .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.7.0"), ], targets: [ .executableTarget( name: "MyMacApp", dependencies: [ "MyAppCore", .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "Sparkle", package: "Sparkle"), ], swiftSettings: [ .swiftLanguageMode(.v6), .defaultIsolation(MainActor.self), ] ), .target( name: "MyAppCore", dependencies: [], swiftSettings: [.swiftLanguageMode(.v6)] ), .testTarget( name: "MyMacAppTests", dependencies: ["MyAppCore"] ), ] ) ``` ## Swift Settings ```swift .executableTarget( name: "MyApp", swiftSettings: [ // Swift 6 language mode (strict concurrency) .swiftLanguageMode(.v6), // Default MainActor isolation (Swift 6.2) .defaultIsolation(MainActor.self), // Enable upcoming features individually .enableUpcomingFeature("ExistentialAny"), .enableUpcomingFeature("InternalImportsByDefault"), // Strict memory safety - first-class setting since Swift 6.2 (SE-0458). // The old .enableExperimentalFeature("StrictMemorySafety") spelling still // builds, but prefer the real API. .strictMemorySafety(), // Warning control (Swift 6.2) .treatAllWarnings(as: .error), .treatWarning("DeprecatedDeclaration", as: .warning), ] ) ``` ## Dependencies ### Version requirements ```swift .package(url: "https://github.com/org/repo", from: "1.0.0"), // >= 1.0.0, < 2.0.0 .package(url: "https://github.com/org/repo", exact: "1.2.3"), // exactly 1.2.3 .package(url: "https://github.com/org/repo", "1.0.0"..<"2.0.0"), // range .package(url: "https://github.com/org/repo", branch: "main"), // branch (dev only) .package(url: "https://github.com/org/repo", revision: "abc123"), // specific commit .package(path: "../LocalPackage"), // local path ``` ### Conditional dependencies ```swift .target( name: "MyApp", dependencies: [ .target(name: "MyCore"), .product(name: "ArgumentParser", package: "swift-argument-parser", condition: .when(platforms: [.macOS, .linux])), ] ) ``` ## Resources Bundle resources with targets: ```swift .target( name: "MyApp", resources: [ .process("Resources/"), // Optimize for platform .copy("Data/config.json"), // Copy as-is ] ) ``` Access in code: ```swift let url = Bundle.module.url(forResource: "config", withExtension: "json")! let image = NSImage(resource: .appIcon) // Xcode asset catalogs ``` ## Build Plugins ### Build tool plugin ```swift // Plugins/CodeGenPlugin/plugin.swift import PackagePlugin @main struct CodeGenPlugin: BuildToolPlugin { func createBuildCommands(context: PluginContext, target: Target) throws -> [Command] { let inputFile = context.package.directory.appending("schema.json") let outputFile = context.pluginWorkDirectory.appending("Generated.swift") return [ .buildCommand( displayName: "Generate code from schema", executable: try context.tool(named: "codegen").url, arguments: [inputFile.string, outputFile.string], inputFiles: [inputFile], outputFiles: [outputFile] ) ] } } ``` ### Command plugin ```swift @main struct FormatPlugin: CommandPlugin { func performCommand(context: PluginContext, arguments: [String]) throws { let swiftformat = try context.tool(named: "swift-format") let process = Process() process.executableURL = swiftformat.url process.arguments = ["--recursive", context.package.directory.string] try process.run() process.waitUntilExit() } } // Run: swift package format ``` ## Swift Build Apple open-sourced Xcode's build engine as Swift Build (Feb 2025). Aims to unify the build experience between Xcode and SPM: - Same build rules for both Xcode projects and Swift packages - Supports libraries, executables, and GUI applications - Build graph optimizations for Swift/C parallel compilation - Future: Replace SPM's simple build engine with Swift Build Current status: **shipping**, not "integration ongoing". SwiftPM 6.3 exposes it as a preview behind a flag; SwiftPM 6.4 makes it the default build system. ```bash # Opt in on the 6.3 toolchain swift build --build-system swiftbuild # Swift Build supports SwiftPM-native universal binaries swift build --build-system swiftbuild --arch arm64 --arch x86_64 ``` The `--arch` flags are a Swift Build capability - the default 6.3 native build system builds a single architecture, which is why manual `.app` assembly elsewhere in this file hardcodes `.build/arm64-apple-macosx/release/`. Sources: [SwiftPM 6.3 notes](https://docs.swift.org/swiftpm/documentation/packagemanagerdocs/6.3), [Swift Build preview](https://docs.swift.org/swiftpm/documentation/packagemanagerdocs/swiftbuildpreview). ## Package Traits (SE-0450, Swift 6.1+) Conditional compilation flags for packages - let consumers opt into optional feature sets without separate products: ```swift let package = Package( name: "MyLib", traits: [ .default(enabledTraits: ["Networking"]), "Networking", .trait(name: "Experimental", enabledTraits: ["Networking"]), ], targets: [ .target(name: "MyLib", swiftSettings: [ .define("NETWORKING", .when(traits: ["Networking"])), ]), ] ) // Consumer side .package(url: "https://github.com/org/mylib", from: "1.0.0", traits: ["Experimental"]), ``` Inspect with `swift package show-traits`. **Traits must be strictly additive - enabling a trait must not remove API.** Breaking that rule makes dependency resolution unsound, since two consumers can enable different trait sets on the same package build. ## Other target kinds Beyond `.target` / `.executableTarget` / `.testTarget`: ```swift .binaryTarget(name: "Vendor", path: "Vendor.xcframework"), // prebuilt .xcframework .binaryTarget(name: "Remote", url: "https://.../lib.zip", checksum: "..."), .systemLibrary(name: "CZlib", pkgConfig: "zlib"), // wrap a system C library ``` Plugin sandbox permissions must be declared explicitly - a command plugin that writes outside the package or hits the network is denied by default: ```swift .plugin(name: "Generate", capability: .command( intent: .custom(verb: "generate", description: "Generate sources"), permissions: [ .writeToPackageDirectory(reason: "Writes generated sources"), .allowNetworkConnections(scope: .all(ports: [443]), reason: "Fetches schema"), ] )) ``` ## Build fails before compiling your code: unwritable `$HOME` caches In sandboxed, CI, or agent environments, `swift build` and `swift test` can die during **manifest compilation** - before touching your sources - because SwiftPM's cache and the clang module cache live under the home directory and are not writable. The error names SwiftPM internals, so it reads like a broken package when the package is fine. ```bash swift build \ --cache-path "$SCRATCH/spm-cache" \ --scratch-path "$SCRATCH/build" \ -Xswiftc -module-cache-path -Xswiftc "$SCRATCH/module-cache" ``` Redirect the caches to a writable scratch directory and re-run before concluding anything about the package itself. ## Consuming packages from `xcodebuild`: two gates that block non-interactive builds **Plugin and macro validation.** A project whose dependencies carry package plugins or macros stops on a trust prompt that has no answer in a headless build - it surfaces as a stall or failure on `Validate plug-in '<name>' in package '<pkg>'`. Both gates have opt-outs, and both exist for a reason: they are the trust boundary for arbitrary code running at build time, so only skip them for dependencies you have vetted. ```bash xcodebuild -scheme MyApp \ -skipPackagePluginValidation \ -skipMacroValidation ``` **The Metal toolchain is no longer bundled.** Xcode 26 unbundled it, so any target compiling `.metal` shaders - directly, or through a dependency that does - fails with `cannot execute tool 'metal'` on a fresh install. It is a one-time ~688 MB download: ```bash xcodebuild -downloadComponent MetalToolchain ``` `xcodebuild -help` lists `MetalToolchain` as the only supported component value. ## Macros ### Using macros ```swift // Add macro package // Pin to the swift-syntax release matching your toolchain: 6.3 -> 603.x .package(url: "https://github.com/swiftlang/swift-syntax", from: "603.0.2"), .target( name: "MyApp", dependencies: [ .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), ] ) ``` ### Swift 6.2 macro performance Pre-built swift-syntax is now supported, eliminating the need to build swift-syntax from source on every clean build. Significantly faster CI builds. ## Swiftly (Toolchain Manager) Official Swift toolchain manager for macOS: ```bash # Install swiftly curl -L https://swift.org/install | bash # Install latest stable swiftly install latest # Install specific version swiftly install 6.3.3 # Switch versions swiftly use 6.3.3 # List installed swiftly list ``` ## Conditional Compilation ```swift #if os(macOS) import AppKit #elseif os(iOS) import UIKit #endif #if canImport(FoundationModels) import FoundationModels // Use on-device AI #endif #if swift(>=6.2) // Use Swift 6.2 features #endif #if DEBUG // Debug-only code #endif #if targetEnvironment(simulator) // Simulator-specific code #endif ``` ## Manual .app Bundle Assembly SPM (`swift build`) produces a bare executable, not a `.app` bundle. macOS requires a proper `.app` for Info.plist keys (LSUIElement, NSMicrophoneUsageDescription), TCC permissions, and framework loading. Assemble manually via Makefile: ```makefile APP_BUNDLE = build/MyApp.app BINARY = .build/arm64-apple-macosx/release/MyApp bundle: build @mkdir -p "$(APP_BUNDLE)/Contents/MacOS" @mkdir -p "$(APP_BUNDLE)/Contents/Frameworks" @mkdir -p "$(APP_BUNDLE)/Contents/Resources" # Copy binary @cp "$(BINARY)" "$(APP_BUNDLE)/Contents/MacOS/MyApp" # CRITICAL: Add rpath for frameworks (SPM default doesn't include it) @install_name_tool -add_rpath @executable_path/../Frameworks \ "$(APP_BUNDLE)/Contents/MacOS/MyApp" # Copy Info.plist and icon @cp Info.plist "$(APP_BUNDLE)/Contents/" @cp Assets/AppIcon.icns "$(APP_BUNDLE)/Contents/Resources/" # Copy dynamic frameworks (e.g., Sparkle) @cp -R .build/artifacts/sparkle/Sparkle/Sparkle.framework \ "$(APP_BUNDLE)/Contents/Frameworks/" # CRITICAL: Copy SPM resource bundles (CoreML models, assets, etc.) @for bundle in .build/arm64-apple-macosx/release/*.bundle; do \ [ -d "$$bundle" ] && cp -R "$$bundle" "$(APP_BUNDLE)/Contents/Resources/"; \ done # Sign from inside out: nested bundles first, then main app @codesign --force --options runtime --sign "$(SIGN_ID)" --timestamp \ "$(APP_BUNDLE)/Contents/Frameworks/Sparkle.framework" @codesign --force --options runtime --sign "$(SIGN_ID)" --timestamp \ --identifier com.example.MyApp --entitlements entitlements.plist "$(APP_BUNDLE)" ``` Key gotchas: - **`install_name_tool -add_rpath`** is required or dynamic frameworks crash with `dyld: Library not loaded: @rpath/...` - **SPM resource bundles** (`.bundle` directories from packages with `resources:`) are NOT automatically included. Missing them causes silent crashes on non-dev machines where `Bundle.module` resolves to nil. - **Sign from inside out**: nested `.xpc` and `.app` bundles within frameworks must be signed before the framework, which must be signed before the top-level app. - **Stale artifacts** can have read-only permissions from previous builds. Use `chmod -R u+w` or clean before copying. ### make run race condition `open` won't relaunch an already-running LSUIElement app. Kill and wait before rebuilding: ```makefile run: build @killall MyApp 2>/dev/null; while killall -0 MyApp 2>/dev/null; do sleep 0.1; done @$(MAKE) bundle @open "$(APP_BUNDLE)" ``` Rebuilding while the old process is running causes `SIGKILL (Code Signature Invalid)` - macOS detects memory-mapped code pages don't match the new binary's signature. ## Mixed ObjC Targets SPM doesn't allow mixing Swift and ObjC in a single target. Create a separate target: ```swift targets: [ .executableTarget( name: "MyApp", dependencies: ["ObjCExceptionCatcher"], exclude: ["ObjCExceptionCatcher"], // Exclude from main target's path scan swiftSettings: [.swiftLanguageMode(.v6), .defaultIsolation(MainActor.self)] ), .target( name: "ObjCExceptionCatcher", path: "Sources/ObjCExceptionCatcher", publicHeadersPath: "include", linkerSettings: [.linkedFramework("AVFAudio")] ), ] ``` Directory layout: ``` Sources/ MyApp/ main.swift ObjCExceptionCatcher/ include/ObjCExceptionCatcher.h ObjCExceptionCatcher.m ``` ## Swift Testing with CLT (No Xcode) On machines using CommandLineTools (not Xcode), `import Testing` fails. The framework exists but SPM doesn't search the CLT path. Add three `unsafeFlags`: ```swift .testTarget( name: "MyAppTests", dependencies: ["MyApp"], swiftSettings: [ .swiftLanguageMode(.v6), // Compiler: find Testing module .unsafeFlags(["-F", "/Library/Developer/CommandLineTools/Library/Developer/Frameworks"]), ], linkerSettings: [ // Linker: resolve Testing framework .unsafeFlags(["-F", "/Library/Developer/CommandLineTools/Library/Developer/Frameworks"]), // Runtime: load Testing framework .unsafeFlags(["-Xlinker", "-rpath", "-Xlinker", "/Library/Developer/CommandLineTools/Library/Developer/Frameworks"]), ] ) ``` ## Multiple Executable Targets When adding helper binaries (e.g., watchdog, CLI tool) to the same package: ```swift targets: [ .executableTarget( name: "MyApp", exclude: ["Watchdog", "ObjCExceptionCatcher"], // Exclude sibling directories swiftSettings: [.swiftLanguageMode(.v6), .defaultIsolation(MainActor.self)] ), .executableTarget( name: "MyWatchdog", path: "Sources/Watchdog", swiftSettings: [ .swiftLanguageMode(.v6), .treatAllWarnings(as: .error), // Helper doesn't need defaultIsolation - it's a simple process monitor ] ), ] ``` The Makefile copies helper binaries into `Contents/MacOS/` and signs them before the main app: ```makefile @cp .build/arm64-apple-macosx/release/MyWatchdog "$(APP_BUNDLE)/Contents/MacOS/" @codesign --force --sign "$(SIGN_ID)" "$(APP_BUNDLE)/Contents/MacOS/MyWatchdog" # Then sign main app last @codesign --force --sign "$(SIGN_ID)" --entitlements entitlements.plist "$(APP_BUNDLE)" ``` -
structured-concurrency.md 8.5 KB
# Structured Concurrency ## Table of Contents - Task - async let - TaskGroup - Cancellation - Priority - Task-Local Values - Named Tasks - Unstructured Tasks ## Task ```swift // Create a new top-level task Task { try await refreshData() } // With priority Task(priority: .userInitiated) { try await importFile() } // Detached task (no inherited context) Task.detached(priority: .background) { try await cleanupCache() } ``` ### Task vs Task.detached | | Task | Task.detached | |---|------|---------------| | Inherits actor | Yes | No | | Inherits priority | Yes | No | | Inherits task-locals | Yes | No | | Use when | Most cases | Independent background work | ## async let Concurrent bindings - start multiple async operations in parallel: ```swift func loadDashboard() async throws -> Dashboard { async let user = fetchUser() async let projects = fetchProjects() async let notifications = fetchNotifications() async let stats = fetchStats() // All four requests run concurrently // Results collected when accessed return try await Dashboard( user: user, projects: projects, notifications: notifications, stats: stats ) } ``` Child tasks are automatically cancelled if the parent scope exits early. But that cancellation alone does **not** implement a timeout — to enforce one you must *race* the work against a sleep using a task group. `async let _ = Task.sleep(...)` is a common trap: the sleep runs concurrently but nothing awaits or races it, so `try await data` still waits forever if the fetch hangs. ```swift struct TimeoutError: Error {} /// Race `operation` against a sleep; first to finish wins, the loser is cancelled. /// Stdlib equivalent `withDeadline` is SE-0526, accepted with modifications 2026-07-29. func withTimeout<T: Sendable>( seconds: Double, operation: sending @escaping () async throws -> T ) async throws -> T { try await withThrowingTaskGroup(of: T.self) { group in group.addTask { try await operation() } group.addTask { try await Task.sleep(for: .seconds(seconds)) throw TimeoutError() } let result = try await group.next()! group.cancelAll() // cancels the loser return result } } // Usage func loadWithTimeout() async throws -> Data { try await withTimeout(seconds: 10) { try await fetchLargeDataset() } } ``` `withThrowingTaskGroup` + sleep is the canonical pattern until [SE-0526 `withDeadline`](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0526-deadline.md) ships - it was **accepted with modifications on 2026-07-29** and is not in the 6.3.3 toolchain, so keep the helper for now. SE-0526 expresses the limit as a composable absolute clock instant rather than a duration, cancelling the operation if it has not completed in time. Do not reach for `swift-async-algorithms` - the timeout feature was explicitly pulled from that package in favor of the stdlib path. **Caveat: the helper is cooperative.** Swift's concurrency runtime cannot forcibly stop a task that isn't observing cancellation. `group.cancelAll()` only flips the cancellation flag; if `operation` is a tight synchronous loop, a blocking C bridge, or any path without `try Task.checkCancellation()` / cancellation-aware suspension points, the TaskGroup scope still waits for it and the call hangs past `seconds`. Use this pattern for cancellation-cooperative work (`URLSession`, `Task.sleep`, most modern async APIs). For CPU-bound or C-bridged work, sprinkle `try Task.checkCancellation()` into the work at suspension-friendly granularity, or dispatch the work to a queue/thread that you can kill independently. ## TaskGroup Dynamic number of concurrent tasks: ```swift // Throwing task group func fetchAllPages() async throws -> [Page] { try await withThrowingTaskGroup(of: Page.self) { group in for id in pageIDs { group.addTask { try await fetchPage(id) } } var pages: [Page] = [] for try await page in group { pages.append(page) } return pages } } // With ordered results func processFiles(_ urls: [URL]) async throws -> [Result] { try await withThrowingTaskGroup(of: (Int, Result).self) { group in for (index, url) in urls.enumerated() { group.addTask { let result = try await process(url) return (index, result) } } var results = [(Int, Result)]() for try await pair in group { results.append(pair) } return results.sorted(by: { $0.0 < $1.0 }).map(\.1) } } // Limiting concurrency func downloadImages(_ urls: [URL], maxConcurrent: Int = 4) async throws -> [NSImage] { try await withThrowingTaskGroup(of: (Int, NSImage).self) { group in var results = [(Int, NSImage)]() var nextIndex = 0 // Start initial batch for i in 0..<min(maxConcurrent, urls.count) { let url = urls[i] group.addTask { (i, try await downloadImage(url)) } nextIndex = i + 1 } // As each completes, start next for try await result in group { results.append(result) if nextIndex < urls.count { let url = urls[nextIndex] let idx = nextIndex group.addTask { (idx, try await downloadImage(url)) } nextIndex += 1 } } return results.sorted(by: { $0.0 < $1.0 }).map(\.1) } } ``` ## Cancellation ```swift // Check cancellation func processItems(_ items: [Item]) async throws -> [Result] { var results: [Result] = [] for item in items { // Check before expensive operation try Task.checkCancellation() let result = try await process(item) results.append(result) } return results } // Non-throwing cancellation check if Task.isCancelled { return partialResults // Return what we have } // Cancel a task let task = Task { try await longRunningOperation() } // Later: task.cancel() // withTaskCancellationHandler func download(_ url: URL) async throws -> Data { let session = URLSession.shared return try await withTaskCancellationHandler { try await session.data(from: url).0 } onCancel: { // Clean up resources session.invalidateAndCancel() } } ``` ## Priority ```swift // Task priorities Task(priority: .userInitiated) { } // User is waiting Task(priority: .medium) { } // Default Task(priority: .utility) { } // Long-running, user aware Task(priority: .background) { } // User not waiting Task(priority: .low) { } // Lowest // TaskGroup with priority await withTaskGroup(of: Void.self) { group in group.addTask(priority: .high) { await urgentWork() } group.addTask(priority: .low) { await backgroundWork() } } // Current task priority let priority = Task.currentPriority ``` ## Task-Local Values Thread-safe context propagation: ```swift enum RequestContext { @TaskLocal static var requestID: String = "unknown" @TaskLocal static var userID: String? } func handleRequest() async { await RequestContext.$requestID.withValue(UUID().uuidString) { await RequestContext.$userID.withValue("user-123") { // All child tasks inherit these values await processRequest() } } } func processRequest() async { print(RequestContext.requestID) // The inherited value print(RequestContext.userID) // "user-123" } ``` ## Named Tasks (Swift 6.2) Assign human-readable names for debugging: ```swift Task(name: "Refresh dashboard data") { try await dashboard.refresh() } Task(name: "Export \(document.name)") { try await exporter.export(document) } // Visible in: // - LLDB: `swift task list` // - Instruments: Swift Concurrency instrument // - Xcode: Debug navigator > Task column ``` ## Unstructured Tasks When you need tasks that outlive their creation scope: ```swift class DocumentController { private var saveTask: Task<Void, Error>? func autoSave() { // Cancel previous save saveTask?.cancel() // Start new save with debounce saveTask = Task { try await Task.sleep(for: .seconds(2)) try await save() } } deinit { saveTask?.cancel() } } ``` **Prefer structured concurrency** (async let, TaskGroup) over unstructured Task when possible - it provides automatic cancellation and clearer lifetime management. -
swiftui-macos.md 13.3 KB
# macOS-Specific SwiftUI ## Table of Contents - Sidebar & Inspectors - Table View - Forms & Controls - Popovers & Sheets - Search - Split Views & Layout - Liquid Glass (macOS 26) - macOS Modifiers - Platform Conditionals ## Sidebar ```swift struct SidebarView: View { @Binding var selection: SidebarItem? var body: some View { List(selection: $selection) { Section("Favorites") { ForEach(favorites) { item in Label(item.name, systemImage: item.icon) .tag(item) .badge(item.count) } } Section("Collections") { ForEach(collections) { collection in Label(collection.name, systemImage: "folder") .tag(SidebarItem.collection(collection.id)) } } } .listStyle(.sidebar) .frame(minWidth: 200) .toolbar { ToolbarItem { Button(action: addCollection) { Label("New Collection", systemImage: "folder.badge.plus") } } } } } ``` ## Inspector ```swift struct ContentView: View { @State private var showInspector = false @State private var selectedItem: Item? var body: some View { MainContentView(selection: $selectedItem) .inspector(isPresented: $showInspector) { if let item = selectedItem { InspectorView(item: item) .inspectorColumnWidth(min: 200, ideal: 300, max: 400) } } .toolbar { ToolbarItem { Button { showInspector.toggle() } label: { Label("Inspector", systemImage: "sidebar.trailing") } } } } } ``` ## Table View Full-featured macOS table with sorting, selection, and context menus: ```swift struct FileListView: View { @State private var files: [FileItem] = [] @State private var selectedIDs: Set<FileItem.ID> = [] @State private var sortOrder = [KeyPathComparator(\FileItem.name)] var body: some View { Table(files, selection: $selectedIDs, sortOrder: $sortOrder) { TableColumn("Name", value: \.name) { file in Label(file.name, systemImage: file.icon) } .width(min: 150, ideal: 250) TableColumn("Size", value: \.size) { file in Text(file.size, format: .byteCount(style: .file)) } .width(80) TableColumn("Modified", value: \.modifiedDate) { file in Text(file.modifiedDate, format: .dateTime.month().day().hour().minute()) } .width(min: 100, ideal: 150) TableColumn("Kind", value: \.kind) .width(100) } .onChange(of: sortOrder) { _, newOrder in files.sort(using: newOrder) } .contextMenu(forSelectionType: FileItem.ID.self) { ids in Button("Open") { openFiles(ids) } Button("Reveal in Finder") { revealInFinder(ids) } Divider() Button("Delete", role: .destructive) { deleteFiles(ids) } } primaryAction: { ids in openFiles(ids) } } } ``` ## Forms & Controls macOS-optimized form layout: ```swift Form { Section("General") { TextField("Name", text: $name) TextField("Description", text: $description, axis: .vertical) .lineLimit(3...6) Picker("Category", selection: $category) { ForEach(Category.allCases) { cat in Text(cat.rawValue).tag(cat) } } .pickerStyle(.menu) // .radioGroup, .segmented, .inline DatePicker("Due Date", selection: $dueDate, displayedComponents: [.date]) } Section("Options") { Toggle("Enable notifications", isOn: $notificationsEnabled) .toggleStyle(.checkbox) // macOS default Stepper("Priority: \(priority)", value: $priority, in: 1...5) Slider(value: $opacity, in: 0...1) { Text("Opacity") } } } .formStyle(.grouped) // macOS grouped form style ``` ## Popovers ```swift Button("Info") { showPopover = true } .popover(isPresented: $showPopover, arrowEdge: .bottom) { VStack(alignment: .leading, spacing: 8) { Text("Details").font(.headline) Text("Additional information here") Link("Learn More", destination: helpURL) } .padding() .frame(width: 250) } ``` ## Sheets (macOS) ```swift .sheet(isPresented: $showingNewProject) { NewProjectSheet() .frame(minWidth: 400, minHeight: 300) } struct NewProjectSheet: View { @Environment(\.dismiss) private var dismiss var body: some View { VStack { // Content HStack { Button("Cancel", role: .cancel) { dismiss() } .keyboardShortcut(.cancelAction) Spacer() Button("Create") { create(); dismiss() } .keyboardShortcut(.defaultAction) .buttonStyle(.borderedProminent) } } .padding() } } ``` ## Search ```swift struct SearchableListView: View { @State private var searchText = "" @State private var searchScope: SearchScope = .all var body: some View { List(filteredItems) { item in ItemRow(item: item) } .searchable(text: $searchText, placement: .toolbar, prompt: "Search items") .searchScopes($searchScope) { Text("All").tag(SearchScope.all) Text("Active").tag(SearchScope.active) Text("Archived").tag(SearchScope.archived) } .searchSuggestions { ForEach(suggestions) { suggestion in Text(suggestion.text) .searchCompletion(suggestion.text) } } } } ``` ## Split Views ### HSplitView / VSplitView (AppKit-backed) ```swift HSplitView { LeftPanel() .frame(minWidth: 200, maxWidth: 400) RightPanel() .frame(minWidth: 300) } ``` ### NavigationSplitView Visibility ```swift @State private var columnVisibility: NavigationSplitViewVisibility = .all NavigationSplitView(columnVisibility: $columnVisibility) { SidebarView() } detail: { DetailView() } .navigationSplitViewStyle(.balanced) // or .prominentDetail ``` ## Liquid Glass (macOS 26) Apps rebuilt with the Xcode 26 SDK automatically adopt Liquid Glass styling. For custom surfaces and animated shape changes, use the dedicated APIs rather than rolling material backgrounds by hand. ### Basic glass surfaces ```swift // On any view: Rectangle() .glassEffect() // .regular by default Rectangle().glassEffect(.clear) // less opaque Rectangle().glassEffect(.regular.tint(.blue).interactive()) // On buttons: Button("Primary") { /* ... */ } .buttonStyle(.glassProminent) Button("Secondary") { /* ... */ } .buttonStyle(.glass) ``` ### Morphing transitions between shapes `GlassEffectContainer` + `.glassEffectID(_:in:)` with a `@Namespace` produces a continuous glass morph when shapes swap rather than a cross-fade: ```swift struct MorphingCard: View { @Namespace private var glassNamespace @State private var expanded = false var body: some View { GlassEffectContainer(spacing: 8) { if expanded { RoundedRectangle(cornerRadius: 24) .glassEffect() .glassEffectID("card", in: glassNamespace) .frame(width: 400, height: 280) } else { Circle() .glassEffect() .glassEffectID("card", in: glassNamespace) .frame(width: 80, height: 80) } } .onTapGesture { withAnimation(.smooth) { expanded.toggle() } } } } ``` ### Glass toolbars Use `ToolbarSpacer` to group glass items so they render as distinct glass surfaces rather than one merged shape. Full signature is `init(_ sizing: SpacerSizing = .flexible, placement: ToolbarItemPlacement = .automatic)` — both parameters default, so the short form works: ```swift .toolbar { ToolbarItem { Button("New") { /* ... */ }.buttonStyle(.glass) } ToolbarSpacer(.fixed) // equivalent to: ToolbarSpacer(.fixed, placement: .automatic) ToolbarItem { Button("Share") { /* ... */ }.buttonStyle(.glass) } } ``` Available macOS 26 / iOS 26+. Docs: https://developer.apple.com/documentation/swiftui/toolbarspacer ### Opting out Set `UIDesignRequiresCompatibility = YES` (Boolean) in Info.plist to keep the legacy visual style app-wide. It's a temporary migration aid — Apple expects to remove it in a future release; there is no per-view or per-window opt-out. Docs: https://developer.apple.com/documentation/bundleresources/information-property-list/uidesignrequirescompatibility ## macOS Modifiers ```swift // Window background .containerBackground(.ultraThinMaterial, for: .window) // Hover effect .onHover { isHovered in self.isHovered = isHovered } // Pointer style (macOS 15+) .pointerStyle(.link) // also: .grabIdle, .zoomIn, .horizontalText, etc. // Pre-macOS-15: there is no built-in SwiftUI cursor modifier; wrap an // NSViewRepresentable that overrides resetCursorRects. Docs: // https://developer.apple.com/documentation/swiftui/view/pointerstyle(_:) // Visual effect (vibrancy) .background(.ultraThinMaterial) // Focus state (keyboard navigation) @FocusState private var isFocused: Bool TextField("Name", text: $name) .focused($isFocused) // Help tag (tooltip) .help("Click to save your changes") // File importer/exporter .fileImporter(isPresented: $importing, allowedContentTypes: [.json]) { result in // handle result } .fileExporter(isPresented: $exporting, document: doc, contentType: .json) { result in // handle result } ``` ## Platform Conditionals ```swift // Prefer #if over runtime checks #if os(macOS) .frame(minWidth: 800, minHeight: 600) .toolbar { MacToolbar() } #elseif os(iOS) .toolbar { IOSToolbar() } #endif // Or use view modifiers .macOS { view in view.frame(minWidth: 800) } // Multiplatform extension helper extension View { @ViewBuilder func macOS<Content: View>(@ViewBuilder modify: (Self) -> Content) -> some View { #if os(macOS) modify(self) #else self #endif } } ``` ## `@Entry` - custom environment keys in one line The `EnvironmentKey` + `EnvironmentValues` extension boilerplate is obsolete. `@Entry` generates both: ```swift extension EnvironmentValues { @Entry var captureSession: CaptureSession? = nil @Entry var isCompactLayout: Bool = false } // Unchanged at the use site .environment(\.captureSession, session) @Environment(\.captureSession) private var session ``` Works for `FocusedValues`, `ContainerValues`, and `Transaction` too. ## SwiftUI `WebView` (macOS 26+) WebKit ships a native SwiftUI view - `NSViewRepresentable` around `WKWebView` is no longer the default answer on macOS 26: ```swift import WebKit import SwiftUI struct DocsView: View { @State private var page = WebPage() var body: some View { WebView(page) .onAppear { page.load(URLRequest(url: docsURL)) } .navigationTitle(page.title) } } ``` `WebPage` is `@Observable` - `page.title`, `page.url`, `page.isLoading`, and `page.estimatedProgress` drive SwiftUI directly with no KVO bridging. Call `page.callJavaScript(_:)` for evaluation. Keep the `NSViewRepresentable` wrapper only for back-deployment below macOS 26 or when you need `WKWebView` API the SwiftUI type does not expose. ## Rich text editing (macOS 26+) `TextEditor` binds to `AttributedString`, making formatted text a first-class SwiftUI feature: ```swift struct NotesEditor: View { @State private var text = AttributedString("") @State private var selection = AttributedTextSelection() var body: some View { TextEditor(text: $text, selection: $selection) .attributedTextFormattingDefinition(NoteFormatting()) } } ``` `AttributedTextFormattingDefinition` constrains which attributes users may apply - use it rather than post-validating, so unsupported formatting never enters the document. The standard Format menu commands work against the selection automatically. ## Window and scene APIs beyond `Window`/`WindowGroup` | API | Use | |---|---| | `UtilityWindow` | Panel-style scene (inspector, tool palette) without dropping to `NSPanel` | | `SettingsLink` | A button that opens Settings - correct across macOS versions, unlike hand-rolled selectors | | `defaultLaunchBehavior(.presented / .suppressed)` | Whether a scene opens at launch - the supported way to start windowless | | `WindowManagerRole` | Declares a scene's role to the window manager | | `windowResizeAnchor` | **View** modifier (macOS 26+), not a Scene one: `windowResizeAnchor(_ anchor: UnitPoint?)` - the anchor point that stays fixed when the window resizes | | `WindowDragGesture` | Drag the window from arbitrary content, not just the title bar | `defaultLaunchBehavior(.suppressed)` is the clean replacement for the `LSUIElement`-plus-close-the-window dance when an app should start with no visible window but is not permanently an accessory. -
system-integration.md 28.7 KB
# System Integration ## Table of Contents - Keyboard Shortcuts - Drag & Drop - File System Access - UserDefaults & AppStorage - App Intents - Widgets & Control Center - Notifications - Process Observation - Accessibility API (AXUIElement) - CoreAudio Per-Process APIs - Login Items (SMAppService) - XPC (XPCSession / XPCListener) - LSUIElement & Background Apps - Idle Sleep Prevention - Logging & Diagnostics - Privacy usage descriptions ## Keyboard Shortcuts ### In commands ```swift .commands { CommandMenu("Edit") { Button("Find...") { showFind = true } .keyboardShortcut("f", modifiers: .command) Button("Replace...") { showReplace = true } .keyboardShortcut("h", modifiers: [.command, .option]) } } ``` ### On buttons ```swift Button("Save") { save() } .keyboardShortcut("s", modifiers: .command) Button("Delete") { delete() } .keyboardShortcut(.delete, modifiers: .command) Button("Cancel") { cancel() } .keyboardShortcut(.cancelAction) // Esc Button("OK") { confirm() } .keyboardShortcut(.defaultAction) // Return ``` ### Global key handlers ```swift .onKeyPress(.return) { submitForm() return .handled } .onKeyPress(characters: .alphanumerics) { press in handleTyping(press.characters) return .handled } .onKeyPress(phases: .down) { press in if press.key == .space { startPreview() return .handled } return .ignored } ``` ## Drag & Drop ### Draggable ```swift struct ItemCard: View { let item: Item var body: some View { VStack { /* content */ } .draggable(item) // Item must conform to Transferable } } // Transferable conformance extension Item: Transferable { static var transferRepresentation: some TransferRepresentation { CodableRepresentation(for: Item.self, contentType: .json) ProxyRepresentation(exporting: \.name) // fallback to string } } ``` ### Drop target ```swift .dropDestination(for: Item.self) { items, location in // Handle dropped items collection.append(contentsOf: items) return true } // File drops .dropDestination(for: URL.self) { urls, location in importFiles(urls) return true } ``` ### Drag preview ```swift .draggable(item) { // Custom preview Label(item.name, systemImage: "doc") .padding(8) .background(.regularMaterial) .clipShape(RoundedRectangle(cornerRadius: 8)) } ``` ### Spring-loaded destination ```swift .springLoadedDestination(for: Item.self) { items in // Auto-open folder/collection when hovering with drag navigateToCollection(items) } ``` ## File System Access ### Security-scoped bookmarks (for sandboxed apps) ```swift func saveBookmark(for url: URL) throws { let bookmarkData = try url.bookmarkData( options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil ) UserDefaults.standard.set(bookmarkData, forKey: "savedBookmark") } func resolveBookmark() throws -> URL { let data = UserDefaults.standard.data(forKey: "savedBookmark")! var isStale = false let url = try URL( resolvingBookmarkData: data, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale ) guard url.startAccessingSecurityScopedResource() else { throw FileError.accessDenied } // Remember to call url.stopAccessingSecurityScopedResource() when done return url } ``` ### FileManager ```swift // App support directory let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! let appDir = appSupport.appendingPathComponent(Bundle.main.bundleIdentifier!) // Create directory try FileManager.default.createDirectory(at: appDir, withIntermediateDirectories: true) // Temporary directory let tempDir = FileManager.default.temporaryDirectory ``` ## App Intents Expose actions to Shortcuts and Siri: ```swift import AppIntents struct CreateProjectIntent: AppIntent { static var title: LocalizedStringResource = "Create Project" static var description: IntentDescription = "Creates a new project" @Parameter(title: "Name") var name: String @Parameter(title: "Template", default: .blank) var template: ProjectTemplate func perform() async throws -> some IntentResult & ReturnsValue<String> { let project = try await ProjectService.create(name: name, template: template) return .result(value: project.id.uuidString) } } // Register with app struct MyAppShortcuts: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { AppShortcut( intent: CreateProjectIntent(), phrases: ["Create a project in \(.applicationName)"], shortTitle: "Create Project", systemImageName: "folder.badge.plus" ) } } ``` ### Beyond primitives: entities An `AppIntent` whose parameters are only `String`/`Int` can never reference your app's actual data. `AppEntity` is the type system that fixes that - "an interface for making a custom type or app-specific concept discoverable by Apple Intelligence and experiences like Siri or the Shortcuts app" (macOS 13+). The three pieces: | Type | Role | |---|---| | `AppEntity` | A model object addressable from Shortcuts/Siri - has an `id`, a `displayRepresentation`, and a query | | `EntityQuery` | How the system finds entities: by id, by string match, or by suggestion | | `AppEnum` | A fixed set of choices surfaced as a picker (what `ProjectTemplate` above should be) | ### Spotlight indexing Conform an entity to `IndexedEntity` to put your app's records in Spotlight: > Make app entities available in Spotlight that conform to `IndexedEntity` and use the `@ComputedProperty(indexingKey:)` or `@Property(indexingKey:)` Swift macros for attributes you want to add to the Spotlight index. ### Interactive snippets (macOS 26) `SnippetIntent` renders an interactive SwiftUI snippet as the intent's result rather than a plain value. One trap Apple calls out explicitly: > If an app intent conforms to `SnippetIntent` and only returns a snippet ... it's nondiscoverable by the Shortcuts app and in Spotlight. To make such an intent discoverable, explicitly set `isDiscoverable` to `true`. ### Foreground continuation: use `supportedModes` `ForegroundContinuableIntent` is **deprecated at macOS 26.0**. Apple's replacement note: "Please include `.foreground(.dynamic)` in the `supportedModes` of your app intent instead." Declare `supportedModes` (`IntentModes`) on the intent rather than conforming to the old protocol. ### macOS-only onscreen-content bridges The `UI*` data sources in Apple's App Intents documentation are iOS-only. The Mac equivalents are `NSTableViewAppIntentsDataSource` and `NSCollectionViewAppIntentsDataSource` (macOS 15.4+) - "the methods that an object adopts to make items in a table view or outline view discoverable by Apple Intelligence and Siri." Adopt them so Siri can act on the row a user is looking at. ## Widgets & Control Center WidgetKit is macOS 11+ and covers desktop and Notification Center widgets. Two macOS-specific points the MenuBarExtra crowd usually wants: **Control Center controls reached macOS in 26.0.** Previously watchOS/iOS only: > Create controls that use `ControlWidgetButton` to execute an action and `ControlWidgetToggle` to toggle some state in your app in watchOS **and macOS**. The building blocks are `ControlWidget` (a SwiftUI widget kind), `ControlWidgetButton`, `ControlWidgetToggle`, and `ControlConfigurationIntent` for a configurable control. A control is often a better fit than a `MenuBarExtra` for a single toggle - it costs no menu-bar real estate and the system handles placement. **Liquid Glass rendering.** Use `WidgetAccentedRenderingMode` to control how widget images are treated under the macOS 26 rendering modes; `WidgetPushHandler` drives push-based timeline reloads. ## Notifications (System) ### User Notifications ```swift import UserNotifications func requestPermission() async throws -> Bool { try await UNUserNotificationCenter.current() .requestAuthorization(options: [.alert, .sound, .badge]) } func scheduleNotification(title: String, body: String) { let content = UNMutableNotificationContent() content.title = title content.body = body content.sound = .default let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false) let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger) UNUserNotificationCenter.current().add(request) } ``` ## UserDefaults & AppStorage ```swift // @AppStorage in views @AppStorage("selectedTheme") private var theme = "system" // UserDefaults directly extension UserDefaults { var lastSyncDate: Date? { get { object(forKey: "lastSyncDate") as? Date } set { set(newValue, forKey: "lastSyncDate") } } } // App group (for sharing between app and extensions) let sharedDefaults = UserDefaults(suiteName: "group.com.myapp") @AppStorage("shared_key", store: UserDefaults(suiteName: "group.com.myapp")) private var sharedValue = "" ``` ## Process Observation ### Currently running apps ```swift import AppKit let runningApps = NSWorkspace.shared.runningApplications let isTargetRunning = runningApps.contains { $0.bundleIdentifier == "com.example.target" } // NSRunningApplication properties app.localizedName // String? app.bundleIdentifier // String? app.processIdentifier // pid_t (Int32) app.isActive // Bool (frontmost) app.isTerminated // Bool app.launchDate // Date? app.icon // NSImage? app.activationPolicy // .regular, .accessory, .prohibited ``` ### Notification-based observation Use `NSWorkspace.shared.notificationCenter` (NOT `NotificationCenter.default`): ```swift // App launched NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didLaunchApplicationNotification, object: nil, queue: .main ) { notification in if let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication { print("Launched: \(app.localizedName ?? "?") (\(app.bundleIdentifier ?? "?")") } } // App terminated NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didTerminateApplicationNotification, object: nil, queue: .main ) { notification in if let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication { print("Terminated: \(app.localizedName ?? "?")") } } ``` All workspace notifications: | Notification | Fires when | |-------------|------------| | `didLaunchApplicationNotification` | App starts (not background/LSUIElement apps) | | `didTerminateApplicationNotification` | App terminates (not background/LSUIElement apps) | | `didActivateApplicationNotification` | App becomes frontmost | | `didDeactivateApplicationNotification` | App loses frontmost | | `didHideApplicationNotification` | App hidden | | `didUnhideApplicationNotification` | App unhidden | ### KVO for ALL apps (including background/LSUIElement) `didLaunchApplicationNotification` does NOT fire for background or LSUIElement apps. Use KVO instead: ```swift class AppMonitor: NSObject { private var observation: NSKeyValueObservation? func startObserving() { observation = NSWorkspace.shared.observe( \.runningApplications, options: [.new, .old] ) { workspace, change in let oldPIDs = Set(change.oldValue?.map(\.processIdentifier) ?? []) let newPIDs = Set(change.newValue?.map(\.processIdentifier) ?? []) let launched = newPIDs.subtracting(oldPIDs) for app in workspace.runningApplications where launched.contains(app.processIdentifier) { print("Launched: \(app.localizedName ?? "?")") } } } func stopObserving() { observation?.invalidate() observation = nil } } ``` ### Background monitoring pattern Combine notifications + KVO + optional safety-net poll: ```swift @Observable class ProcessMonitor { var watchedBundleIDs: Set<String> = [] private(set) var activeWatchedApps: [NSRunningApplication] = [] private var tokens: [NSObjectProtocol] = [] private var kvoObservation: NSKeyValueObservation? func startMonitoring() { refreshActiveApps() // Notifications for regular apps tokens.append(NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didLaunchApplicationNotification, object: nil, queue: .main ) { [weak self] _ in self?.refreshActiveApps() }) tokens.append(NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didTerminateApplicationNotification, object: nil, queue: .main ) { [weak self] _ in self?.refreshActiveApps() }) // KVO for background/LSUIElement apps kvoObservation = NSWorkspace.shared.observe(\.runningApplications, options: [.new]) { [weak self] _, _ in DispatchQueue.main.async { self?.refreshActiveApps() } } } func stopMonitoring() { tokens.forEach { NSWorkspace.shared.notificationCenter.removeObserver($0) } tokens.removeAll() kvoObservation?.invalidate() } private func refreshActiveApps() { activeWatchedApps = NSWorkspace.shared.runningApplications.filter { guard let id = $0.bundleIdentifier else { return false } return watchedBundleIDs.contains(id) } } } ``` ## Accessibility API (AXUIElement) `NSWorkspace` and KVO tell you *which* apps are running. `AXUIElement` - "a structure used to refer to an accessibility object" - is how a Mac utility reads and drives *another app's* UI: window titles, focused element, text selection, button presses. It is the foundation of window managers, launchers, text-expansion tools, and automation utilities. Practical constraints that decide whether a feature is even viable: - Requires the **Accessibility** TCC permission (System Settings > Privacy & Security > Accessibility), granted per app by the user. Check with `AXIsProcessTrusted()`; prompt with `AXIsProcessTrustedWithOptions` passing `kAXTrustedCheckOptionPrompt`. - Like screen recording, the grant keys off the app's code signature, so re-signing forces a re-grant (see the TCC notes in `distribution.md`). - It is a C API (`ApplicationServices`) with `AXUIElementCopyAttributeValue` returning `CFTypeRef` - the same Create-Rule bridging care as the CoreAudio CFString trap in `core-audio-tap.md` applies. - **Not available under the App Sandbox.** An accessibility-driven feature is Developer ID-only. Docs: <https://developer.apple.com/documentation/applicationservices/axuielement> ## CoreAudio Per-Process APIs macOS 14.2+ provides per-process audio state APIs for detecting which apps are using audio I/O. Useful for call detection, audio monitoring, or building audio routing tools. ```swift import CoreAudio /// Find all processes with active audio input AND output (e.g., call apps) func findActiveCallingProcesses() -> [(pid: pid_t, bundleID: String)] { var addr = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyProcessObjectList, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) var size: UInt32 = 0 guard AudioObjectGetPropertyDataSize( AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size ) == noErr else { return [] } let count = Int(size) / MemoryLayout<AudioObjectID>.size var objectIDs = [AudioObjectID](repeating: 0, count: count) guard AudioObjectGetPropertyData( AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &objectIDs ) == noErr else { return [] } let myPID = ProcessInfo.processInfo.processIdentifier var results: [(pid_t, String)] = [] for objID in objectIDs { // Get PID var pidAddr = AudioObjectPropertyAddress( mSelector: kAudioProcessPropertyPID, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) var pid: pid_t = 0 var pidSize = UInt32(MemoryLayout<pid_t>.size) guard AudioObjectGetPropertyData(objID, &pidAddr, 0, nil, &pidSize, &pid) == noErr, pid != myPID else { continue } // Check IsRunningInput AND IsRunningOutput (dual check filters dictation/Siri) var inputAddr = AudioObjectPropertyAddress( mSelector: kAudioProcessPropertyIsRunningInput, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) var isInput: UInt32 = 0 var boolSize = UInt32(MemoryLayout<UInt32>.size) guard AudioObjectGetPropertyData(objID, &inputAddr, 0, nil, &boolSize, &isInput) == noErr, isInput != 0 else { continue } var outputAddr = inputAddr outputAddr.mSelector = kAudioProcessPropertyIsRunningOutput var isOutput: UInt32 = 0 guard AudioObjectGetPropertyData(objID, &outputAddr, 0, nil, &boolSize, &isOutput) == noErr, isOutput != 0 else { continue } // Get bundle ID if let bundleID = getBundleID(for: objID) { results.append((pid, bundleID)) } } return results } ``` Key gotchas: - **Filter out own PID** and ScreenCaptureKit helper PIDs (`com.apple.screencapturekit*`, `com.apple.replayd`). - **Input+Output dual check** filters out dictation, Siri, voice memos (input only). Real call apps (Zoom, Meet, Teams) have both active. - **`AudioBufferList` is a variable-length C struct**. `UnsafeMutablePointer<AudioBufferList>.allocate(capacity: 1)` only reserves space for one buffer. Multi-channel devices cause heap overflow. Allocate exact `bufSize` bytes from `GetPropertyDataSize`. - **Chrome reports helper subprocess bundle IDs** (e.g., `com.google.Chrome.helper.renderer`). Strip `.helper*` suffix to resolve to the parent app. - **Polling (3s interval) is simpler than listeners**. CoreAudio property listeners require `Unmanaged.passUnretained(self)` pointer dance, complex deinit cleanup, and are unreliable with some browser audio pipelines. For call detection, 0-3s delay is negligible. - **`CoreAudio.RemovePropertyListenerBlock`** has a known Swift bug where block-copied closures get different addresses, causing removal to fail. Use the C function pointer variant instead. ## Login Items (SMAppService) macOS 13+ API for registering login items, agents, and daemons. ### Register main app as login item ```swift import ServiceManagement // Register (launches on subsequent logins) try SMAppService.mainApp.register() // Unregister try SMAppService.mainApp.unregister() // Check status switch SMAppService.mainApp.status { case .notRegistered: print("Not registered") case .enabled: print("Enabled") case .requiresApproval: SMAppService.openSystemSettingsLoginItems() case .notFound: print("Service not found") @unknown default: break } ``` ### SwiftUI Settings toggle Never persist login-item state locally - always read from `SMAppService.mainApp.status`: ```swift import ServiceManagement import SwiftUI struct LaunchAtLoginToggle: View { @State private var launchAtLogin = false @Environment(\.appearsActive) var appearsActive var body: some View { Toggle("Launch at login", isOn: $launchAtLogin) .onChange(of: launchAtLogin) { _, newValue in if newValue { try? SMAppService.mainApp.register() } else { try? SMAppService.mainApp.unregister() } } .onAppear { launchAtLogin = (SMAppService.mainApp.status == .enabled) } .onChange(of: appearsActive) { _, active in guard active else { return } // Re-sync: user may have toggled in System Settings launchAtLogin = (SMAppService.mainApp.status == .enabled) } } } ``` ### Service types ```swift // Main app login item SMAppService.mainApp // Helper app (in Contents/Library/LoginItems/) SMAppService.loginItem(identifier: "com.example.helper") // LaunchAgent (in Contents/Library/LaunchAgents/) SMAppService.agent(plistName: "com.example.agent.plist") // LaunchDaemon (in Contents/Library/LaunchDaemons/ - requires admin approval) SMAppService.daemon(plistName: "com.example.daemon.plist") ``` | Type | Runs as | Starts | Can show UI | Approval | |------|---------|--------|-------------|----------| | mainApp | Current user | Next login | Yes | Background item notification | | loginItem | Current user | Immediately + login | Yes | Background item notification | | agent | Current user | Immediately + login | If not LSBackgroundOnly | Background item notification | | daemon | root | After admin approval | No | Admin authentication | ### Bundle structure for agents/daemons ``` MyApp.app/Contents/ Library/ LoginItems/MyHelper.app/ # loginItem bundles LaunchAgents/com.example.agent.plist LaunchDaemons/com.example.daemon.plist Resources/MyHelper # helper executable ``` Agent plist uses `BundleProgram` (path relative to app bundle): ```xml <dict> <key>Label</key> <string>com.example.agent</string> <key>BundleProgram</key> <string>Contents/Resources/MyHelper</string> <key>RunAtLoad</key> <true/> </dict> ``` ## XPC (XPCSession / XPCListener) `SMAppService` registers a LaunchAgent or LaunchDaemon; it does not give you a way to **talk** to it. That is XPC. Since macOS 14 there is a Swift-native API - `XPCSession` ("a type that sends messages to a server process") on the app side, `XPCListener` ("a type that performs tasks for clients across process boundaries") in the helper - so a privileged helper no longer requires the Objective-C `NSXPCConnection` dance. ```swift import XPC // Client: talk to the registered helper let session = try XPCSession(machService: "com.example.MyHelper") let reply = try session.sendSync(MyRequest(command: .status)) // Helper: serve let listener = try XPCListener(service: "com.example.MyHelper") { request in request.accept { (message: MyRequest) -> MyResponse in handle(message) } } ``` Messages are `Codable`. The Mach service name must match the `MachServices` key in the helper's launchd plist, and the two binaries must share a team identifier for the connection to be accepted - verify the peer's code signing requirement rather than trusting the connection. Docs: <https://developer.apple.com/documentation/xpc/xpcsession> ## LSUIElement & Background Apps ### LSUIElement (menu-bar-only apps) Set in Info.plist (`Application is agent (UIElement)`): ```xml <key>LSUIElement</key> <true/> ``` App does NOT appear in Dock or Cmd+Tab. CAN still show UI (windows, menus, popovers). **This is what menu-bar-only apps use.** ### LSBackgroundOnly (faceless helpers) ```xml <key>LSBackgroundOnly</key> <true/> ``` App runs ONLY in background. Cannot show any UI. | Key | Dock Icon | Can Show UI | Use Case | |-----|-----------|-------------|----------| | Neither | Yes | Yes | Normal app | | `LSUIElement` | No | Yes | Menu bar apps | | `LSBackgroundOnly` | No | No | Faceless helpers | ### Menu-bar-only app pattern ```swift @main struct MyMenuBarApp: App { var body: some Scene { MenuBarExtra("Status", systemImage: "star") { VStack { ContentView() Divider() Button("Quit") { NSApp.terminate(nil) } } .frame(width: 300, height: 200) } .menuBarExtraStyle(.window) } } ``` With `LSUIElement = true`: no Dock icon, no Cmd+Tab entry, no Force Quit listing. **Always include a Quit button** since users can't right-click the Dock icon. ### Caveat `NSWorkspace.didLaunchApplicationNotification` does NOT fire for LSUIElement or background apps. Use KVO on `runningApplications` to detect them (see Process Observation section). ## Idle Sleep Prevention Prevent macOS from sleeping during long operations (recording, encoding, uploads): ```swift // Start activity (prevents idle sleep) let activity = ProcessInfo.processInfo.beginActivity( .userInitiated, reason: "Recording audio" ) // ... long-running operation ... // End activity (allow sleep again) ProcessInfo.processInfo.endActivity(activity) ``` Use `.userInitiated` for operations the user started. The system will not idle-sleep while the activity is active, but the user can still manually put the machine to sleep. ## Trap: "is the mic in use?" latches on with a system-wide property `kAudioDevicePropertyDeviceIsRunningSomewhere` on the default input device looks like the way to auto-trigger recording when another app starts using the microphone. It is not: the property is system-wide and includes **your** process. Once your app opens the mic in response to the trigger, the property stays `true` after the other app stops, so the trigger never releases and auto-stop never fires. Use the per-process CoreAudio APIs (macOS 14.2+) and exclude yourself: ```swift import CoreAudio func otherProcessIsUsingInput() -> Bool { var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyProcessObjectList, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) var size: UInt32 = 0 guard AudioObjectGetPropertyDataSize( AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size) == noErr else { return false } var processes = [AudioObjectID](repeating: 0, count: Int(size) / MemoryLayout<AudioObjectID>.size) guard AudioObjectGetPropertyData( AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &processes) == noErr else { return false } let myPID = ProcessInfo.processInfo.processIdentifier for process in processes { guard pid(for: process) != myPID else { continue } if isRunningInput(process) { return true } // kAudioProcessPropertyIsRunningInput } return false } ``` Two caveats before designing around this: - **App Sandbox**: these low-level per-process APIs are not reliably available under sandboxing, which makes a mic-activity auto-trigger a Mac App Store blocker. Plan the feature as Developer ID-only, or provide a manual path. - Poll on a timer or install a property listener on the process list; there is no single "someone started using the mic" notification. ## Logging & Diagnostics `print` does not survive shipping. The unified logging system is the standard instrument for a Mac app: `os.Logger` is "an object for writing interpolated string messages to the unified logging system" (macOS 11+), and it is what `log stream` / `log show` and Console.app read. ```swift import os private let log = Logger(subsystem: "com.example.MyApp", category: "capture") log.debug("frame \(index, privacy: .public) queued") log.error("stream stopped: \(error.localizedDescription, privacy: .public)") ``` Points that matter in practice: - **Interpolated values default to `.private`** and render as `<private>` when read back from another process. Mark non-sensitive diagnostic values `.public` explicitly or your shipped logs are useless. Do the opposite for anything user-derived. - **Levels have different persistence.** `.debug` is memory-only and discarded aggressively; `.info` persists only when collected; `.notice` (the default), `.error`, and `.fault` go to the on-disk store. Ship at `.notice` and above for anything you want to see in a user's sysdiagnose. - **Read logs for a menu-bar-only app** - which has no console output anywhere - with `log stream --predicate 'subsystem == "com.example.MyApp"' --level debug`. For performance work, `OSSignposter` brackets intervals that show up as regions in Instruments: ```swift let signposter = OSSignposter(subsystem: "com.example.MyApp", category: "render") let state = signposter.beginInterval("compose") defer { signposter.endInterval("compose", state) } ``` Docs: <https://developer.apple.com/documentation/os/logger> ## Privacy usage descriptions The capture-heavy parts of this skill cover screen recording and microphone TCC in depth. One easily missed sibling: - **`NSLocalNetworkUsageDescription`** - "a message that tells people why the app is requesting access to the local network" (macOS 11+). Required for **any** Bonjour/mDNS discovery or local-subnet traffic. Without it the app is denied local network access, and the failure looks like a networking bug: discovery returns nothing, connections to `.local` names time out. Apps that stream to a local device, discover a companion app, or run a local server for a helper all need it, alongside `NSBonjourServices` listing the service types you browse. Docs: <https://developer.apple.com/documentation/bundleresources/information-property-list/nslocalnetworkusagedescription> -
testing.md 20.9 KB
# Testing macOS Apps ## Table of Contents - Swift Testing Framework - Test Suites & Organization - Expectations & Requirements - Parameterized Tests - Exit Tests - Attachments - Async Testing - Parallelism Pitfalls for Hardware / Bundle Tests - Traits - Swift Testing recent features (6.2 and 6.3) - UI Testing - XCTest Migration ## Swift Testing Framework Swift Testing (bundled with Swift 6.0+, Xcode 16+) replaces XCTest for new tests: ```swift import Testing @Test("user can create account") func createAccount() throws { let account = try Account(name: "Test", email: "test@example.com") #expect(account.name == "Test") #expect(account.isActive) } ``` ### Key differences from XCTest | Feature | XCTest | Swift Testing | |---------|--------|---------------| | Test declaration | `func testX()` | `@Test func x()` | | Assertions | `XCTAssertEqual` | `#expect(a == b)` | | Required values | `XCTUnwrap` | `try #require(value)` | | Test suites | `class: XCTestCase` | `@Suite struct` | | Parallelism | Sequential | Parallel by default | | Parameterized | Manual loops | `@Test(arguments:)` | | Traits | None | Tags, conditions, time limits | ## Test Suites ```swift @Suite("Document Manager") struct DocumentManagerTests { // Shared setup let manager: DocumentManager let tempDir: URL init() throws { tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) manager = DocumentManager(directory: tempDir) } // Cleanup (deinit not available for structs, use helper) @Test func createDocument() throws { let doc = try manager.create(name: "test.txt") #expect(doc.exists) } @Test func listDocuments() async throws { try manager.create(name: "a.txt") try manager.create(name: "b.txt") let docs = try await manager.listAll() #expect(docs.count == 2) } } ``` ### Nested suites ```swift @Suite("API Client") struct APIClientTests { @Suite("Authentication") struct AuthTests { @Test func validToken() { /* ... */ } @Test func expiredToken() { /* ... */ } } @Suite("Requests") struct RequestTests { @Test func getRequest() { /* ... */ } @Test func postRequest() { /* ... */ } } } ``` ## Expectations & Requirements ```swift // Basic expectation #expect(result == 42) #expect(name.isEmpty == false) #expect(items.count > 0) // String contains #expect(message.contains("success")) // Optional handling - #require unwraps or fails test let user = try #require(response.user) #expect(user.name == "Alice") // Throws #expect(throws: ValidationError.self) { try validate(invalidInput) } // Specific error #expect { try parse("") } throws: { error in guard let parseError = error as? ParseError else { return false } return parseError.code == .emptyInput } // No throw #expect(throws: Never.self) { try safeOperation() } ``` ## Parameterized Tests Test multiple inputs without duplication: ```swift @Test("validates email", arguments: [ ("user@example.com", true), ("invalid", false), ("@missing.com", false), ("user@.com", false), ("a@b.co", true), ]) func validateEmail(email: String, isValid: Bool) { #expect(Email.isValid(email) == isValid) } // With zip @Test(arguments: zip( ["admin", "user", "guest"], [Permission.all, Permission.read, Permission.none] )) func rolePermissions(role: String, expected: Permission) throws { let user = try User(role: role) #expect(user.permissions == expected) } // From collection enum FileFormat: CaseIterable { case json, xml, csv } @Test("exports in all formats", arguments: FileFormat.allCases) func export(format: FileFormat) throws { let data = try exporter.export(items, as: format) #expect(!data.isEmpty) } ``` ## Exit Tests (Swift 6.2) Verify code terminates under specific conditions: ```swift @Test func preconditionFailsForNegativeIndex() async { await #expect(processExitsWith: .failure) { let array = [1, 2, 3] _ = array[-1] // Should trigger precondition failure } } @Test func fatalErrorOnInvalidState() async { await #expect(processExitsWith: .failure) { StateMachine.transition(from: .completed, to: .idle) } } ``` Exit tests run in a separate process - safe for testing fatal paths. ## Attachments (Swift 6.2) Include diagnostic data in test results: ```swift @Test func renderChart() throws { let chart = try ChartRenderer.render(data: sampleData) // Attach PNG bytes for debugging let imageData = try chart.pngData() Attachment.record(imageData, named: "chart.png") #expect(chart.width == 800) #expect(chart.height == 600) } @Test func apiResponse() async throws { let response = try await api.fetchUsers() // Attach raw JSON for diagnosis Attachment.record(response.rawData, named: "response.json") #expect(response.users.count > 0) } ``` Attachments appear in Xcode test reports and can be written to disk. ## Async Testing ```swift @Test func fetchData() async throws { let service = DataService() let items = try await service.fetchAll() #expect(!items.isEmpty) } // With timeout trait @Test(.timeLimit(.minutes(1))) func longRunningOperation() async throws { let result = try await processor.processLargeFile(url) #expect(result.isComplete) } // Testing async sequences @Test func streamEvents() async throws { let stream = EventSource.events() var count = 0 for await event in stream.prefix(5) { #expect(event.isValid) count += 1 } #expect(count == 5) } ``` ## Parallelism Pitfalls for Hardware / Bundle Tests Swift Testing runs tests **in parallel by default**. For tests that launch the same `.app` bundle, drive CoreAudio, or touch shared hardware state (microphone, Screen Recording TCC, a running process), this causes non-obvious races: - Two tests launch the same bundle, fight for the CATap, one ends up with silent buffers. - Two hardware tests both request microphone access, the second one's `AVCaptureDevice.requestAccess(...)` returns spuriously `false`. - Two UI-driving tests try to `open -a MyApp.app` concurrently and `NSWorkspace.runningApplications` reports inconsistent state. ### Serialize with `.serialized` Apply the `.serialized` trait on the suite (or individual tests) that share a resource: ```swift @Suite("Hardware Smoke", .serialized) struct HardwareSmokeTests { @Test func recordsFaceTime() async throws { /* launches bundle, records */ } @Test func recordsSystemAudio() async throws { /* launches bundle, records */ } @Test func handlesDeviceSwitch() async throws { /* launches bundle, switches device */ } } ``` Tests outside the suite still run in parallel with suites that don't share the resource. ### Kill by bundle identifier, not by path Test harnesses that tear down a stale app instance between tests commonly use: ```swift // WRONG: only kills instances of THIS copy of the bundle. // A stale /Applications/MyApp.app running from a previous install stays alive // and keeps holding CoreAudio / SCStream resources. for app in NSWorkspace.shared.runningApplications where app.bundleURL == testBundleURL { app.terminate() } ``` For LSUIElement / menu-bar apps especially, a stale `/Applications` copy from a previous `make install` is invisible (no Dock icon, no Cmd+Tab, no Force Quit listing) but contends for the CATap / microphone / Screen Recording TCC. Kill by bundle ID instead: ```swift for app in NSWorkspace.shared.runningApplications where app.bundleIdentifier == "com.example.MyApp" { app.terminate() } // Or via AppKit's async termination for a cleaner shutdown before yielding. ``` ### Polling helpers must be tolerant of transient errors Bundle-based tests often poll a JSON / IPC state file the app writes. Under full parallel suite load the app can briefly miss its poll interval; if the helper rethrows the inner read error, the outer deadline doesn't get a chance to govern: ```swift // FRAGILE: any transient read failure aborts the wait, even though the outer // deadline hasn't been reached. func waitUntil<T>(_ timeout: Duration = .seconds(15), _ condition: () async throws -> T?) async throws -> T { let deadline = Date().addingTimeInterval(timeout.seconds) while Date() < deadline { if let v = try await condition() { return v } // rethrows transient errors try await Task.sleep(for: .milliseconds(200)) } throw WaitError.timedOut } // ROBUST: outer deadline governs; inner errors are treated as "not yet". func waitUntil<T>(_ timeout: Duration = .seconds(15), _ condition: () async throws -> T?) async throws -> T { let deadline = Date().addingTimeInterval(timeout.seconds) while Date() < deadline { if let v = try? await condition() { return v } try await Task.sleep(for: .milliseconds(200)) } throw WaitError.timedOut } ``` ### Gate hardware tests behind an env var Tests that need live TCC permissions (screen recording, microphone) or specific hardware (AirPods, a running FaceTime call) shouldn't run on developer machines by default: ```swift @Test(.enabled(if: ProcessInfo.processInfo.environment["RUN_HARDWARE_SMOKE"] == "1")) func recordsAgainstLiveSystem() async throws { /* ... */ } ``` A `Makefile` or `justfile` target that sets the env var is the canonical wrapper (`make smoke-test`). `swift test` without the var shows these as "skipped", which is the desired default. ### Pipe buffering and `make` swallow test output Running `swift test` through `make` / `rtk` / any command that pipes through a second process can truncate Swift Testing output at ~120 lines and lose the exit code. When you need the full output (e.g. to see which test crashed and where), invoke `swift test --parallel` directly. ## Traits ```swift // Tags for filtering extension Tag { @Tag static var networking: Self @Tag static var database: Self @Tag static var slow: Self } @Test(.tags(.networking)) func apiCall() async throws { /* ... */ } // Conditional execution @Test(.enabled(if: ProcessInfo.processInfo.environment["CI"] != nil)) func ciOnlyTest() { /* ... */ } // Disabled with reason @Test(.disabled("Waiting for server fix")) func brokenTest() { /* ... */ } // Bug reference @Test(.bug("https://github.com/org/repo/issues/123")) func regressionTest() { /* ... */ } // Time limit - minutes only. `.seconds(_:)` is @available(*, unavailable): // "Time limit must be specified in minutes" @Test(.timeLimit(.minutes(1))) func quickTest() async throws { /* ... */ } // Serial execution (see Parallelism Pitfalls above) @Suite(.serialized) struct HardwareTests { /* ... */ } ``` ## Swift Testing recent features (6.2 and 6.3) A handful of workflow wins scattered across Swift Testing 6.2 (Xcode 26) and 6.3 (Xcode 26.4). Version gate noted per feature — they did not all ship at the same time. ### Warning-severity issues (non-failing) — Swift 6.3 ```swift @Test func parsesPayload() throws { let parsed = try parser.parse(payload) #expect(parsed.id != nil) if parsed.deprecatedField != nil { // Does NOT fail the test, but surfaces in reports. Issue.record("payload still uses deprecatedField", severity: .warning) } } ``` Use for soft expectations (deprecations, perf regressions short of a hard bar, drift warnings). `Issue.Severity` is annotated `@Available(Swift, introduced: 6.3)` / `@Available(Xcode, introduced: 26.4)` in [Issue.swift](https://github.com/swiftlang/swift-testing/blob/main/Sources/Testing/Issues/Issue.swift) - the ST-0013 design landed earlier, but the shipping gate is 6.3, not 6.2. ### Exit tests: `processExitsWith:` — Swift 6.2 The `#expect(exitsWith:)` spelling was renamed to `#expect(processExitsWith:)` in swift-testing 6.2: ```swift @Test func preconditionFailsForNegativeIndex() async { await #expect(processExitsWith: .failure) { let array = [1, 2, 3] _ = array[-1] } } ``` ### `Attachment.record(...)` — Swift 6.2 (rename), Swift 6.3 (AppKit/CoreImage/UIKit images) The static `Attachment.record(_:named:...)` replaces the old instance `.attach()` method (rename shipped in [PR #1032](https://github.com/swiftlang/swift-testing/pull/1032), Swift 6.2). **All** image attachment support - `CGImage` included, not just the `NSImage`/`CIImage`/`UIImage` overlays - is gated at Swift 6.3 / Xcode 26.4: every initializer in [Attachment+AttachableAsImage.swift](https://github.com/swiftlang/swift-testing/blob/main/Sources/Testing/Attachments/Images/Attachment%2BAttachableAsImage.swift) carries `@Available(Swift, introduced: 6.3)` (ST-0014). ```swift // Swift 6.2+ — bytes / CGImage Attachment.record(imageData, named: "chart.png") Attachment.record(cgImage, named: "chart", as: .png) // Swift 6.3+ — NSImage / UIImage / CIImage overlays @Test func rendersChart() throws { let image: NSImage = try ChartRenderer.render(data) Attachment.record(image, named: "chart", as: .png) #expect(image.size == CGSize(width: 800, height: 600)) } ``` Xcode's test report displays attached images inline; useful for golden-image tests and visual regressions. ### Cooperative mid-test cancellation — Swift 6.3 `Test.cancel(_:)` has signature `throws -> Never`: it always throws, so the call never returns normally and callers must use `try`. The comment argument is positional (no `reason:` label). ```swift @Test func longRunningCheck() async throws { for _ in 0..<1_000_000 { if shouldStop() { try Test.cancel("condition met early") } try await doOneStep() } } ``` Cleaner than `throw XCTSkip("...")` — the test reports cancelled, not failed or skipped. (ST-0016, [swift-testing 6.3](https://github.com/swiftlang/swift-testing/releases/tag/swift-6.3-RELEASE) / Xcode 26.4.) The 6.2 release does not correctly handle task cancellation in all conditions, per the proposal note — require 6.3. ### `SourceLocation.filePath` — Swift 6.3 Non-underscored file-path access on `SourceLocation` for custom reporters: ```swift let loc = #_sourceLocation print(loc.filePath) // String ``` (ST-0020, PR #1538.) ### ST-0021 XCTest / Swift Testing interop — Implemented in Swift 6.4 Proposal status is now **Implemented (Swift 6.4)**, and `SWIFT_TESTING_XCTEST_INTEROP_MODE` is a documented environment variable accepting `none`, `limited`, `complete`, or `strict` — it controls how XCTest assertion failures recorded during a Swift Testing test are handled. Practical gate is unchanged for this skill's pinned toolchain: on Swift 6.3 only the fallback-event-handler plumbing is present (PRs #1369, #1503, #1543), so don't rely on interop-mode semantics until you are on a 6.4 toolchain. Sources: [ST-0021](https://github.com/swiftlang/swift-evolution/blob/main/proposals/testing/0021-targeted-interoperability-swift-testing-and-xctest.md), [EnvironmentVariables.md](https://github.com/swiftlang/swift-testing/blob/main/Documentation/EnvironmentVariables.md) ### In the pipeline (not yet in any toolchain) Nothing below is usable on 6.3.3; track them if you maintain a test suite that will move to 6.4+. - **ST-0026 task-local test trait** - **Accepted with revisions** on 2026-08-14. A trait that sets task-local values for the duration of a test, which is today's workaround-by-hand for injecting per-test configuration through `@TaskLocal`. ([proposal](https://github.com/swiftlang/swift-evolution/blob/main/proposals/testing/0026-task-local-test-trait.md)) - **ST-0027 source-location macro** and **ST-0028 revised attachment `Encodable` interfaces** - both entered active review in August 2026; ST-0028 in particular would reshape the attachment API documented above. - The event stream gained an `ABI.Context` API producing human-readable output for CI tooling and dashboards (merged, unreleased). The newest tagged swift-testing release is still `swift-6.3.2-RELEASE` (2026-05-13). ## UI Testing (XCTest-based) UI testing still uses XCTest (Swift Testing doesn't support UI tests yet): ```swift import XCTest final class ProjectUITests: XCTestCase { let app = XCUIApplication() override func setUpWithError() throws { continueAfterFailure = false app.launch() } func testCreateProject() throws { app.buttons["New Project"].click() let nameField = app.textFields["projectName"] nameField.click() nameField.typeText("My Project") app.buttons["Create"].click() XCTAssertTrue(app.staticTexts["My Project"].exists) } } ``` ## XCTest Migration Migrate incrementally - both frameworks coexist in the same target: ```swift // Old (XCTest) class OldTests: XCTestCase { func testAdd() { XCTAssertEqual(add(2, 3), 5) } } // New (Swift Testing) @Test func add() { #expect(add(2, 3) == 5) } ``` Migration checklist: 1. `XCTestCase` class -> `@Suite` struct 2. `func testX()` -> `@Test func x()` 3. `XCTAssertEqual(a, b)` -> `#expect(a == b)` 4. `XCTAssertTrue(x)` -> `#expect(x)` 5. `XCTAssertNil(x)` -> `#expect(x == nil)` 6. `XCTAssertThrowsError` -> `#expect(throws:)` 7. `XCTUnwrap(x)` -> `try #require(x)` 8. `setUp/tearDown` -> `init/deinit` or test-local setup 9. `measure { }` -> Use Instruments (no direct equivalent yet) ## Confirmations: testing callbacks and delegates `#expect` cannot express "this callback fired exactly N times". `confirmation` can, and it is the right tool for the delegate-driven APIs common in macOS apps - `SCStreamOutput`, `AVAudioEngine` taps, `NotificationCenter` observers: ```swift @Test func streamDeliversBuffers() async throws { await confirmation("audio buffers delivered", expectedCount: 3) { delivered in let output = StubStreamOutput { _ in delivered() } try await runCapture(feeding: output, frames: 3) } } ``` The suite fails if the count does not match when the closure returns. Ranges work for "at least once, unbounded" cases: ```swift await confirmation(expectedCount: 1...) { fired in monitor.onChange = { _ in fired() } try await monitor.pump() } ``` Use `expectedCount: 0` to assert a callback never fires - stronger than asserting on final state, because it catches a spurious extra invocation. ## `withKnownIssue` instead of disabling A test that documents a real bug should keep running. `.disabled()` hides it; `withKnownIssue` records the failure without failing the suite, and **fails loudly if the issue stops reproducing** - so the test tells you when the bug is fixed: ```swift @Test func handlesMalformedHeader() throws { withKnownIssue("parser drops the extension bit - FB12345678") { let parsed = try Parser.parse(malformedFixture) #expect(parsed.extensionBit == true) } } // Intermittent failures: don't fail when it happens to pass withKnownIssue(isIntermittent: true) { try flakyPath() } ``` ## `swift test` command-line surface Beyond `--parallel`: ```bash swift test --list-tests # enumerate without running swift test --filter ProjectTests # regex over suite/test names swift test --disable-xctest # run only Swift Testing swift test --enable-xctest # run only XCTest swift test --attachments-path ./test-output # write Attachment.record(...) payloads here ``` `--attachments-path` is what makes attachments useful in CI - without it, recorded attachments have nowhere to land. ### Trap: `swift test` cannot run tests under Command Line Tools alone With only the Command Line Tools installed (no full Xcode), tests **compile and link but never execute** - there is no `xctest` runner binary. Both XCTest and Swift Testing suites report nothing, which reads as "all tests passed" in a CI log. A placeholder test can sit "green" for weeks without ever having run. ```bash xcode-select -p # /Library/Developer/CommandLineTools == the problem sudo xcode-select -s /Applications/Xcode.app/Contents/Developer ``` Standardize on `swift test --disable-xctest` in your Justfile/Makefile so Swift Testing runs without needing the XCTest runner at all, and assert on a known-nonzero test count in CI rather than trusting a clean exit code. ## Trap: menu-bar-only apps are invisible to UI automation An `LSUIElement` app does not appear in the accessibility registry the way a regular app does, so harnesses that enumerate running or installed applications through Launch Services or the accessibility APIs report it as absent regardless of its actual state. XCUITest-style automation of the menu bar UI is a dead end. Drive the real bundle through a narrow, explicitly-flagged test surface instead, and assert on the artifacts it produces: ```swift // In the app, gated so it cannot ship enabled by accident if CommandLine.arguments.contains("--ui-test-mode") { TestControlSurface.install() // exposes start/stop hooks only } ``` Launch the `.app` with that argument, exercise the hooks, and verify the output files. Keep the surface minimal - the goal is testability, not restructuring the app around the test harness.
-
-
CHANGELOG.md 12 KB
# Changelog All notable changes to this skill will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/2.0.0/), and this skill adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ## [0.8.3] - 2026-09-09 ### Changed - Description condensed to fit the repo's 250-character limit. ## [0.8.2] - 2026-08-21 ### Changed - Declared ClawHub browse categories (`development`) and topics in `metadata`, so the release pipeline publishes them instead of leaving the skill in the `other` category. ### Removed - `skill-card.md`. The ClawHub CLI strips a root `skill-card.md` from every publish and the registry generates its own card, so the authored file never reached ClawHub. ## [0.8.1] - 2026-08-20 ### Fixed - ScreenCaptureKit audio-only capture recommended `CMTime(value: 1, timescale: CMTimeScale.max)` for `minimumFrameInterval`. That reads like "infinite interval" but is ~0.5 ns - the smallest positive interval - so it requests the display's native refresh rate and burns ~15-20% of a core on WindowServer recomposites for the whole recording. Replaced with `CMTime(value: 1, timescale: 1)` (1 fps) in SKILL.md and screen-capture-audio.md, with an explicit note on the trap. - screen-capture-audio.md called the `stream output NOT found. Dropping frame` log "cosmetic" and left the video pipeline unaddressed. The fix is to attach a `.screen` output that discards its buffers on its own queue (never the audio queue) - documented in the snippet. ## [0.8.0] - 2026-08-20 ### Fixed - SKILL.md Relationships snippet and relationships-predicates.md Cascade example did not compile: `@Model` requires an explicit initializer, which neither had. Verified against the macOS 26.5 SDK. - models-schema.md model-inheritance snippet failed twice - `@Model` subclasses require explicit platform availability and an initializer - and the section carried no version gate at all. Subclassing is macOS 26.0+. - `.presentedWindowStyle(.automatic) // .fullScreen` in app-lifecycle.md: `WindowStyle` has no `.fullScreen` member. The complete SDK set is `.automatic`, `.titleBar`, `.hiddenTitleBar`, `.plain`. - `windowResizeAnchor` was documented as a Scene API taking an edge; it is a `View` modifier taking `UnitPoint?`. - `restorationBehavior` was labelled "(macOS 26)"; it is macOS 15.0. - `ContextOptions` is not in the macOS 26.5 SDK - it is macOS 27 beta - and was described as a trimming/retention policy; it configures prompt content. Section rewritten around `tokenCount(for:)`/`contextSize`, which do exist. - Three stale documentation links: the Foundation Models custom-adapter article was removed by Apple, and the privacy-manifest and `SCStreamErrorCode` URLs now redirect. - SE-0526 `withDeadline` status was "in review April 2026"; it was accepted with modifications on 2026-07-29. - `anyAppleOS` was listed as a Swift 6.4 feature; it compiles on 6.3.3 behind `-enable-experimental-feature AnyAppleOSAvailability`. - The App Store SDK-minimum requirement is now past tense ("Since April 28, 2026"). ### Changed - Current shipping macOS is 26.6.2 (was 26.6); Xcode 27 pinned to beta 5 (27A5237l) and macOS 27 to beta 6 (26A5416b), with Swift 6.4 noted as shipping inside the Xcode 27 beta. - macOS 27 beta 6 deprecates both `FileDocument` and `ReferenceFileDocument` in favor of a combined `Document` protocol; the skill had quoted the superseded beta-4 note saying `ReferenceFileDocument` remained available. - Foundation Models `GenerationError` is deprecated in macOS 27 with a hard submission deadline, and `exceededContextWindowSize` is renamed `contextSizeExceeded`. ### Added - AppKit Liquid Glass interop: `NSGlassEffectView`, `NSGlassEffectContainerView`, `NSBackgroundExtensionView`, and `NSView.prefersCompactControlSizeMetrics` as the escape hatch when Liquid Glass inflates a dense AppKit layout. - SwiftUI/AppKit bridging: `NSHostingSceneRepresentation` for hosting whole scenes, `NSHostingSizingOptions`, `NSHostingSceneBridgingOptions`, and `NSGestureRecognizerRepresentable`. - Pasteboard privacy (macOS 15.4): `NSPasteboard.accessBehavior` and the `detect` methods that inspect the pasteboard without triggering the new read alert - the existing clipboard recipe was exactly the triggering pattern. - App Intents beyond the single snippet: `AppEntity`/`EntityQuery`/`AppEnum`, `IndexedEntity` with `@ComputedProperty(indexingKey:)`, `SnippetIntent` and its `isDiscoverable` trap, `supportedModes` replacing the deprecated `ForegroundContinuableIntent`, and the macOS-only `NSTableViewAppIntentsDataSource`. - WidgetKit on macOS and Control Center controls (`ControlWidget`, `ControlWidgetButton`, `ControlWidgetToggle`, `ControlConfigurationIntent`), which reached macOS in 26.0. - Logging and diagnostics: `os.Logger`, the `.private`-by-default interpolation trap, log-level persistence, reading logs from a menu-bar-only app, and `OSSignposter`. - `XPCSession`/`XPCListener` - the missing half of the existing `SMAppService` login-item coverage. - `AXUIElement` accessibility API with its TCC, signature, and sandbox constraints. - `NSLocalNetworkUsageDescription`, whose absence presents as a networking bug rather than a permission failure. - `SpeechAnalyzer`/`SpeechTranscriber` as the on-device transcription stage of the existing capture pipeline. - ScreenCaptureKit gaps: `streamDidBecomeActive`/`streamDidBecomeInactive` (macOS 15.2), `SCStreamConfiguration.Preset`, `SCScreenshotOutput`, and the macOS 27 beta `SCStream.isCapturing`, `SCClipBufferingOutput`, `SCRecordingEditor`. - xcodebuild-on-.xcodeproj field notes: builds that report `BUILD SUCCEEDED` while emitting an ad-hoc-signed bundle, `codesign -dv` as the post-build gate, `CODE_SIGNING_REQUIRED=YES`, bracketed build settings being unpassable on the command line, certificate-class prefix matching, symlinked-path build-database corruption, and TCC behavior across signature changes and bundle moves. - SPM consumer-side gates: `-skipPackagePluginValidation`/`-skipMacroValidation` for non-interactive builds, and `-downloadComponent MetalToolchain` after Xcode 26 unbundled the Metal toolchain. - SE-0506 Advanced Observation Tracking (`withObservationTracking(options:)`, `withContinuousObservationTracking`), Implemented in Swift 6.4. - Swift Testing pipeline: ST-0026 task-local test trait (accepted with revisions), ST-0027 and ST-0028 in active review, and the event-stream `ABI.Context` API. - macOS 27 beta surface: SwiftData `ResultsObserver`/`HistoryObserver`/`@Query(sectionBy:)`/`Schema.Attribute.Option.codable`, Foundation Models `PrivateCloudComputeLanguageModel` with its entitlement, the `#Playground` macro, and Core AI's public API names. Verified against: swift@6.3.3, xcode@26.6, macos@26.6.2 ## [0.7.1] - 2026-08-07 ### Changed - Trimmed the frontmatter description to what-plus-when; dropped the trailing 15-item trigger-keyword list. ## [0.7.0] - 2026-07-28 ### Fixed - Foundation Models quick start assigned `Response<Content>` to the bare content type and did not compile; now reads `.content`. Verified with the compiler against the macOS 26.5 SDK. - `.timeLimit(.seconds(30))` in testing.md is `@available(*, unavailable)` ("Time limit must be specified in minutes"); switched to `.minutes(1)`. - `FetchDescriptor.fetchBatchSize` does not exist and the snippet mutated a `let`; replaced with `enumerate(_:batchSize:)`. - Quick Start `@Model final class Task` shadowed `Swift.Task` in a file that then uses `Task {}` and `TaskGroup`; renamed to `ProjectTask`, matching relationships-predicates.md. ### Changed - Current shipping macOS is 26.6 (was 26.5); noted that Xcode 26.6 still bundles the macOS 26.5 SDK. - `weak let` corrected from a Swift 6.4 beta feature to SE-0481, shipped in Swift 6.3; 6.4 feature list rewritten against swift-evolution data. - Xcode 27 and macOS 27 pinned to beta 4 status. - Swift Testing version gates corrected: issue severity and all image attachments (including `CGImage`) are Swift 6.3 / Xcode 26.4, not 6.2. ST-0021 is now Implemented (Swift 6.4). - SPM guidance modernized: `.strictMemorySafety()` over the experimental spelling, Swift Build as a 6.3 preview and 6.4 default, swift-syntax 603.0.2, swiftly 6.3.3. - Dropped `altool --upload-app` (notary service stopped accepting it in 2023) in favor of notarytool. - Gatekeeper Control-click bypass replaced with the Open Anyway flow - the Control-click override was removed in macOS Sequoia and is still absent in Tahoe. - Corrected the `Schema.Attribute.Option` list; there is no `.encrypt` option. ### Added - Xcode 27 build-breakers: ld64 and `-ld_classic` removal, unique Clang module-name requirement, `ARCHS_STANDARD` dropping x86_64 at deployment target 27.0+, the SE-0508 source break, and the `DocumentReader`/`DocumentWriter` `@concurrent` isolation change alongside the new `ReadableDocument`/`WritableDocument` protocols. - SwiftData: `#Unique` and `#Index` macros, model inheritance with `includeSubclasses`, `.ephemeral`, history tracking (`HistoryDescriptor`/`HistoryToken`/tombstones), custom `DataStore`, fetch shaping (`propertiesToFetch`, `relationshipKeyPathsForPrefetching`), relationship cardinality. - Swift Testing: `confirmation` for callback- and delegate-driven code, `withKnownIssue`, the `swift test` CLI surface, and the trap where Command Line Tools alone silently run no tests. - Concurrency: the `Synchronization` module (`Mutex`, `Atomic`) for realtime and C-callback contexts, the `nonisolated(nonsending)` spelling, isolated conformances (SE-0470), `swift package migrate --to-feature`, and the individual upcoming-feature flags. - ScreenCaptureKit: `synchronizationClock`, `SCScreenshotConfiguration`, Presenter Overlay delegate callbacks, and the omitted `SCStreamConfiguration` properties. - SwiftUI: `@Entry`, native `WebView`/`WebPage`, rich-text `TextEditor`, and the `UtilityWindow`/`SettingsLink`/`defaultLaunchBehavior` scene surface. - Distribution: `get-task-allow` as the top notarization blocker, plug-in entitlement inheritance, the Enhanced Security capability, `-exportNotarizedApp`, installer packaging, and Homebrew cask as a second channel. - SPM: package traits, `--build-system swiftbuild`, `.binaryTarget`/`.systemLibrary`, plugin permissions, and the unwritable-`$HOME`-cache build failure. - Foundation Models: `@PromptBuilder`/`@InstructionsBuilder`, `DynamicGenerationSchema`, `ContextOptions`. - Field-hardening notes: actor reentrancy across awaits, the interleaved-vs-planar downmix trap, stale TCC rows after a signing-identity change, mic taps dying on output-device switches, third-party Voice Processing reshaping your input, mic-activity latching, MenuBarExtra label-update crashes, and crash visibility for menu-bar-only apps. Verified against: swift@6.3.3, xcode@26.6, macos@26.6 ## [0.6.3] - 2026-07-22 ### Added - skill-card.md release record following NVIDIA's skill-card format ### Changed - metadata.openclaw audited against the official ClawHub spec ## [0.6.2] - 2026-07-10 ### Changed - CHANGELOG preamble pinned to Keep a Changelog 2.0.0 (format unchanged; KaC 2.0.0 keeps existing changelogs valid). ## [0.6.1] - 2026-07-01 ### Changed - Moved the "Fall 2026 Releases (WWDC 2026)" section out of SKILL.md into `references/fall-2026-releases.md` (progressive disclosure), keeping SKILL.md under the 500-line policy limit. Content unchanged, expanded slightly with sources. ## [0.6.0] - 2026-07-01 ### Changed - Current toolchain updated to Swift 6.3.3 / Xcode 26.6 (macOS 26.5 Tahoe SDK); dropped the stale "Swift 6.2.4, Feb 2026 latest" framing. - Liquid Glass section notes the `UIDesignRequiresCompatibility` opt-out is removed for apps built with Xcode 27. ### Added - Forward-looking "Fall 2026 Releases (WWDC 2026, beta)" section: macOS 27 Golden Gate, Xcode 27, Swift 6.4, Foundation Models next-gen (image input, server models, Dynamic Profiles, pluggable models), Core AI, Spatial Preview. - CoreAudio CFString Create-Rule trap (`takeRetainedValue`) in core-audio-tap.md. - `SIGKILL (Code Signature Invalid)` dev-loop gotcha in distribution.md. - CHANGELOG and upstream tracking established. Verified against: swift@6.3.3, xcode@26.6 -
LICENSE.txt 8.9 KB
Apache License Version 2.0, January 2004 https://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS -
SKILL.md 17.3 KB
--- name: swift-macos description: macOS apps with Swift 6.3, SwiftUI, SwiftData, Swift Concurrency, Foundation Models, Swift Testing, and ScreenCaptureKit. Use when building native Mac apps - windows, menus, SwiftData, on-device AI, capture, AppKit bridges, notarization. metadata: version: "0.8.3" categories: "development" topics: "swift, swiftui, macos, swiftdata, screencapturekit" upstream: "swift@6.3.3, xcode@26.6, macos@26.6.2" openclaw: homepage: https://github.com/tenequm/skills/tree/main/skills/swift-macos emoji: "🍎" os: - macos --- # macOS App Development - Swift 6.3 Build native macOS apps with Swift 6.3 (latest: 6.3.3, bundled in Xcode 26.6, Jun 2026), SwiftUI, SwiftData, and macOS 26 Tahoe (26.6.2 current). Target macOS 14+ for SwiftData/@Observable, macOS 15+ for latest SwiftUI, macOS 26 for Liquid Glass and Foundation Models. Note Xcode 26.6 still bundles the macOS **26.5** SDK, so 26.6-only API is not yet buildable. For the WWDC 2026 beta stack (macOS 27, Xcode 27, Swift 6.4, shipping fall 2026), see `references/fall-2026-releases.md`. ## Quick Start ```swift import SwiftUI import SwiftData @Model final class Project { var name: String var createdAt: Date // Named ProjectTask, not Task - `Task` would shadow `Swift.Task` @Relationship(deleteRule: .cascade) var tasks: [ProjectTask] = [] init(name: String) { self.name = name self.createdAt = .now } } @Model final class ProjectTask { var title: String var isComplete: Bool var project: Project? init(title: String) { self.title = title self.isComplete = false } } @main struct MyApp: App { var body: some Scene { WindowGroup("Projects") { ContentView() } .modelContainer(for: [Project.self, ProjectTask.self]) .defaultSize(width: 900, height: 600) #if os(macOS) Settings { SettingsView() } MenuBarExtra("Status", systemImage: "circle.fill") { MenuBarView() } .menuBarExtraStyle(.window) #endif } } struct ContentView: View { @Query(sort: \Project.createdAt, order: .reverse) private var projects: [Project] @Environment(\.modelContext) private var context @State private var selected: Project? var body: some View { NavigationSplitView { List(projects, selection: $selected) { project in NavigationLink(value: project) { Text(project.name) } } .navigationSplitViewColumnWidth(min: 200, ideal: 250) } detail: { if let selected { DetailView(project: selected) } else { ContentUnavailableView("Select a Project", systemImage: "sidebar.left") } } } } ``` ## Scenes & Windows | Scene | Purpose | |-------|---------| | `WindowGroup` | Resizable windows (multiple instances) | | `Window` | Single-instance utility window | | `Settings` | Preferences (Cmd+,) | | `MenuBarExtra` | Menu bar with `.menu` or `.window` style | | `DocumentGroup` | Document-based apps | Open windows: `@Environment(\.openWindow) var openWindow; openWindow(id: "about")` For complete scene lifecycle, see `references/app-lifecycle.md`. ## Menus & Commands ```swift .commands { CommandGroup(replacing: .newItem) { Button("New Project") { /* ... */ } .keyboardShortcut("n", modifiers: .command) } CommandMenu("Tools") { Button("Run Analysis") { /* ... */ } .keyboardShortcut("r", modifiers: [.command, .shift]) } } ``` ## Table (macOS-native) ```swift Table(items, selection: $selectedIDs, sortOrder: $sortOrder) { TableColumn("Name", value: \.name) TableColumn("Date") { Text($0.date, format: .dateTime) } .width(min: 100, ideal: 150) } .contextMenu(forSelectionType: Item.ID.self) { ids in Button("Delete", role: .destructive) { delete(ids) } } ``` For forms, popovers, sheets, inspector, and macOS modifiers, see `references/swiftui-macos.md`. ## @Observable ```swift @Observable final class AppState { var projects: [Project] = [] var isLoading = false func load() async throws { isLoading = true defer { isLoading = false } projects = try await ProjectService.fetchAll() } } // Use: @State var state = AppState() (owner) // Pass: .environment(state) (inject) // Read: @Environment(AppState.self) var state (child) ``` ## SwiftData ### @Query & #Predicate ```swift @Query(filter: #Predicate<Project> { !$0.isArchived }, sort: \Project.name) private var active: [Project] // Dynamic predicate func search(_ term: String) -> Predicate<Project> { #Predicate { $0.name.localizedStandardContains(term) } } // FetchDescriptor (outside views) var desc = FetchDescriptor<Project>(predicate: #Predicate { $0.isArchived }) desc.fetchLimit = 50 let results = try context.fetch(desc) let count = try context.fetchCount(desc) ``` ### Relationships ```swift @Model final class Author { var name: String @Relationship(deleteRule: .cascade, inverse: \Book.author) var books: [Book] = [] init(name: String) { self.name = name } } @Model final class Book { var title: String var author: Author? @Relationship var tags: [Tag] = [] // many-to-many init(title: String) { self.title = title } } ``` Delete rules: `.cascade`, `.nullify` (default), `.deny`, `.noAction`. Every `@Model` class needs an explicit initializer - the macro does not synthesize one, and omitting it fails with `@Model requires an initializer be provided for '<Type>'`. ### Schema Migration ```swift enum SchemaV1: VersionedSchema { /* ... */ } enum SchemaV2: VersionedSchema { /* ... */ } enum MigrationPlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] } static var stages: [MigrationStage] { [.lightweight(fromVersion: SchemaV1.self, toVersion: SchemaV2.self)] } } // Apply: .modelContainer(for: Model.self, migrationPlan: MigrationPlan.self) ``` ### CloudKit Sync Enable iCloud capability, then `.modelContainer(for: Model.self)` auto-syncs. Constraints: all properties need defaults/optional, no unique constraints, optional relationships. For model attributes, background contexts, batch ops, undo/redo, and testing, see SwiftData references below. ## Concurrency (Swift 6.2+) ### Default MainActor Isolation Opt entire module into main actor - all code runs on main actor by default: ```swift // Package.swift .executableTarget(name: "MyApp", swiftSettings: [ .defaultIsolation(MainActor.self), ]) ``` Or Xcode: Build Settings > Swift Compiler > Default Isolation > MainActor. ### @concurrent Mark functions for background execution: ```swift @concurrent func processFile(_ url: URL) async throws -> Data { let data = try Data(contentsOf: url) return try compress(data) // runs off main actor } // After await, automatically back on main actor let result = try await processFile(fileURL) ``` Use for CPU-intensive work, I/O, anything not touching UI. ### Actors ```swift actor DocumentStore { private var docs: [UUID: Document] = [:] func add(_ doc: Document) { docs[doc.id] = doc } func get(_ id: UUID) -> Document? { docs[id] } nonisolated let name: String } // Requires await: let doc = await store.get(id) ``` ### Structured Concurrency ```swift // Parallel with async let func loadDashboard() async throws -> Dashboard { async let profile = fetchProfile() async let stats = fetchStats() return try await Dashboard(profile: profile, stats: stats) } // Dynamic with TaskGroup func processImages(_ urls: [URL]) async throws -> [NSImage] { try await withThrowingTaskGroup(of: (Int, NSImage).self) { group in for (i, url) in urls.enumerated() { group.addTask { (i, try await loadImage(url)) } } var results = [(Int, NSImage)]() for try await r in group { results.append(r) } return results.sorted { $0.0 < $1.0 }.map(\.1) } } ``` ### Sendable ```swift struct Point: Sendable { var x, y: Double } // value types: implicit final class Config: Sendable { let apiURL: URL } // final + immutable actor SharedState { var count = 0 } // mutable: use actors // Enable strict mode: .swiftLanguageMode(.v6) in Package.swift ``` ### AsyncSequence & Observations ```swift // Stream @Observable changes (macOS 26+ / iOS 26+, SE-0475) // Observations uses a closure init, not Observations(of:). let progresses = Observations { manager.progress } for await p in progresses { print(p) } // Typed NotificationCenter (macOS 26+) struct DocSaved: NotificationCenter.MainActorMessage { typealias Subject = Document static var name: Notification.Name { .init("DocSaved") } let id: UUID } NotificationCenter.default.post(DocSaved(id: document.id), subject: document) let token = NotificationCenter.default.addObserver(of: document, for: DocSaved.self) { msg in refresh(msg.id) } ``` For concurrency deep dives, see concurrency references below. ## Foundation Models (macOS 26+) On-device ~3B LLM. Free, offline, private: ```swift import FoundationModels let session = LanguageModelSession() let response = try await session.respond(to: "Summarize: \(text)") print(response.content) // respond() returns Response<Content>, not Content // Structured output @Generable struct Summary { var title: String; var points: [String] } let result = try await session.respond(to: prompt, generating: Summary.self) let summary: Summary = result.content ``` For tool calling, streaming, and sessions, see `references/foundation-models.md`. ## Testing ```swift import Testing @Suite("Project Tests") struct ProjectTests { @Test("creates with defaults") func create() { let p = Project(name: "Test") #expect(p.name == "Test") } @Test("formats sizes", arguments: [(1024, "1 KB"), (0, "0 KB")]) func format(bytes: Int, expected: String) { #expect(formatSize(bytes) == expected) } } // SwiftData testing let container = try ModelContainer( for: Project.self, configurations: ModelConfiguration(isStoredInMemoryOnly: true) ) let ctx = ModelContext(container) ctx.insert(Project(name: "Test")) try ctx.save() ``` For exit tests, attachments, UI testing, see `references/testing.md`. ## Distribution | Method | Sandbox | Notarization | Review | |--------|---------|--------------|--------| | App Store | Required | Automatic | Yes | | Developer ID | Recommended | Required | No | | Ad-Hoc | No | No | Local only | ```bash xcodebuild archive -scheme MyApp -archivePath MyApp.xcarchive xcodebuild -exportArchive -archivePath MyApp.xcarchive \ -exportPath ./export -exportOptionsPlist ExportOptions.plist xcrun notarytool submit ./export/MyApp.dmg \ --apple-id you@example.com --team-id TEAM_ID \ --password @keychain:AC_PASSWORD --wait xcrun stapler staple ./export/MyApp.dmg ``` For complete distribution guide, see `references/distribution.md`. ## SPM ```swift // swift-tools-version: 6.3 let package = Package( name: "MyApp", platforms: [.macOS(.v14)], targets: [ .executableTarget(name: "MyApp", swiftSettings: [ .swiftLanguageMode(.v6), .defaultIsolation(MainActor.self), ]), .testTarget(name: "MyAppTests", dependencies: ["MyApp"]), ] ) ``` For build plugins, macros, and Swift Build, see `references/spm-build.md`. ## Liquid Glass (macOS 26) Apps rebuilt with Xcode 26 SDK get automatic Liquid Glass styling. Use `.glassEffect()` for custom glass surfaces, `GlassEffectContainer` for custom hierarchies. Opt out (Xcode 26 only): `UIDesignRequiresCompatibility = YES` in Info.plist keeps the legacy visual style - a temporary migration aid. Apps rebuilt with Xcode 27 (beta) can no longer opt out; the key is ignored and Liquid Glass is mandatory (see `references/fall-2026-releases.md`). ## ScreenCaptureKit Capture screen content, app audio, and microphone (macOS 12.3+): ```swift import ScreenCaptureKit let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) guard let display = content.displays.first else { return } // Filter: specific apps only let filter = SCContentFilter(display: display, including: [targetApp], exceptingWindows: []) // Configure let config = SCStreamConfiguration() config.capturesAudio = true config.sampleRate = 48000 config.channelCount = 2 config.excludesCurrentProcessAudio = true // Audio-only: throttle the video pipeline (it always runs). minimumFrameInterval is a // MINIMUM gap between frames - 1/Int32.max is ~0s, i.e. native refresh rate (60+ fps // of discarded frames and a full-screen recomposite each). Use 1 fps. config.width = 2; config.height = 2 config.minimumFrameInterval = CMTime(value: 1, timescale: 1) let stream = SCStream(filter: filter, configuration: config, delegate: self) try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: nil) try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: audioQueue) try await stream.startCapture() ``` macOS 15+: `SCRecordingOutput` for simplified file recording, `config.captureMicrophone` for mic capture. macOS 14+: `SCContentSharingPicker` for system picker UI, `SCScreenshotManager` for single-frame capture. For complete API reference, audio writing (AVAssetWriter/AVAudioFile), permissions, and examples, see `references/screen-capture-audio.md`. ## AppKit Interop ```swift struct WebViewWrapper: NSViewRepresentable { let url: URL func makeNSView(context: Context) -> WKWebView { WKWebView() } func updateNSView(_ v: WKWebView, context: Context) { v.load(URLRequest(url: url)) } } ``` For hosting SwiftUI in AppKit and advanced bridging, see `references/appkit-interop.md`. ## Architecture | Pattern | Best For | Complexity | |---------|----------|------------| | SwiftUI + @Observable | Small-medium, solo | Low | | MVVM + @Observable | Medium, teams | Medium | | TCA | Large, strict testing | High | See `references/architecture.md` for all patterns with examples. ## References | File | When to read | |------|-------------| | `references/fall-2026-releases.md` | WWDC 2026 beta stack: macOS 27, Xcode 27, Swift 6.4, Foundation Models next-gen, Core AI, Spatial Preview, mandatory Liquid Glass | | **SwiftUI & macOS** | | | `references/app-lifecycle.md` | Window management, scenes, DocumentGroup, MenuBarExtra gotchas, async termination, LSUIElement issues | | `references/swiftui-macos.md` | Sidebar, Inspector, Table, forms, popovers, sheets, search | | `references/appkit-interop.md` | NSViewRepresentable, hosting controllers, NSHostingSceneRepresentation, sizing/scene-bridging options, AppKit Liquid Glass (NSGlassEffectView), pasteboard privacy, NSPanel/floating HUD | | `references/screen-capture-audio.md` | ScreenCaptureKit, SCStream gotchas, SCStream teardown hazards, AVAudioEngine dual pipeline, AVAssetWriter crash safety, non-interleaved stereo trap, TCC gotchas, CDHash degraded-state after reinstall, SpeechAnalyzer transcription | | `references/core-audio-tap.md` | CATap for per-process audio: tap-only aggregate (HFP-safe), drift compensation, rate-change anti-pattern, interleaved-stereo frame-count trap, IO proc isolation | | `references/system-integration.md` | Keyboard shortcuts, drag & drop, file access, App Intents (entities, Spotlight, snippets), widgets & Control Center, process monitoring, AXUIElement, CoreAudio per-process APIs, login items, XPC, LSUIElement, os.Logger & signposts, privacy usage descriptions | | `references/foundation-models.md` | On-device AI: guided generation, tool calling, streaming | | `references/architecture.md` | MVVM, TCA, dependency injection, project structure | | `references/testing.md` | Swift Testing, exit tests, attachments, UI testing, XCTest migration | | `references/distribution.md` | App Store, Developer ID, notarization gotchas, nested bundle signing, xcodebuild signing traps (silent ad-hoc builds), sandboxing, universal binaries | | `references/spm-build.md` | Package.swift, Swift Build, plugins, macros, plugin/macro validation gates, Metal toolchain download, manual .app bundle assembly, mixed ObjC targets, CLT testing | | **Concurrency** | | | `references/approachable-concurrency.md` | Default MainActor isolation, @concurrent, nonisolated async, runtime pitfalls | | `references/actors-isolation.md` | Actor model, global actors, custom executors, reentrancy | | `references/structured-concurrency.md` | Task, TaskGroup, async let, cancellation, priority, named tasks | | `references/sendable-safety.md` | Sendable protocol, data race safety, @unchecked Sendable + serial queue, @preconcurrency import | | `references/async-patterns.md` | AsyncSequence, AsyncStream, Observations, continuations, Clock | | `references/migration-guide.md` | GCD to async/await, Combine to AsyncSequence, Swift 6 migration | | **SwiftData** | | | `references/models-schema.md` | @Model, @Attribute options, Codable, transformable, external storage | | `references/relationships-predicates.md` | Advanced relationships, inverse rules, compound predicates | | `references/container-context.md` | ModelContainer, ModelContext, background contexts, undo/redo, batch ops | | `references/cloudkit-sync.md` | CloudKit setup, conflict resolution, sharing, debugging sync | | `references/migrations.md` | VersionedSchema, lightweight/custom migration, Core Data migration |
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.