Airlock is a standalone Swift package for building first-class onboarding flows for macOS apps.
It gives you:
- A polished two-column onboarding container for SwiftUI
- A declarative step API for custom onboarding content
- A
FlightCheckAPI for reusable preflight checks - Reusable permission, feature, and status UI components
- Configurable built-in checks for permissions, setup, welcome screens, and license activation
Airlock is designed to be used outside this workspace. The package itself is product-agnostic; app-specific branding belongs in the host app's configuration.
- macOS 14.0+
- Swift 5.9+
- Xcode 15.0+
dependencies: [
.package(url: "https://github.com/alexmodrono/Airlock.git", from: "1.0.0")
]Then add the product you need:
.target(
name: "YourApp",
dependencies: [
"Airlock"
]
)Airlock: umbrella import for most appsAirlockCore: state, models, step APIsAirlockUI: container views and reusable UI componentsAirlockChecks: configurable built-in checks
Airlock/
├── Sources/
│ ├── AirlockCore
│ ├── AirlockUI
│ ├── AirlockChecks
│ └── Airlock
├── Tests/
└── AirlockDemo/
Use the declarative API when your app already has custom onboarding views.
import SwiftUI
import Airlock
@main
struct MyApp: App {
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
@StateObject private var navigator = AirlockNavigator(
appName: "MyApp",
appIconName: "AppIcon"
) {
AirlockStep(
id: "welcome",
title: "Welcome",
icon: "hand.wave.fill",
subtitle: "Get started with MyApp"
) {
WelcomeStep()
}
AirlockStep(
id: "permissions",
title: "Permissions",
icon: "lock.shield.fill",
subtitle: "Grant the required access"
) {
PermissionsStep()
}
AirlockStep(
id: "ready",
title: "Ready",
icon: "checkmark.circle.fill",
subtitle: "Finish onboarding"
) {
ReadyStep()
}
}
var body: some Scene {
WindowGroup {
if hasCompletedOnboarding && !navigator.isActive {
ContentView()
} else {
AirlockFlowView(
navigator: navigator,
configuration: AirlockConfiguration(
showIntro: true,
introDuration: 2.5,
playIntroSound: true,
allowSkipIntro: true
)
)
.onChange(of: navigator.isActive) { _, isActive in
if !isActive {
hasCompletedOnboarding = true
}
}
}
}
.windowStyle(.hiddenTitleBar)
}
}
struct WelcomeStep: View {
var body: some View {
VStack {
Text("Welcome to MyApp")
}
.airlockEnableContinueAfter(seconds: 2)
}
}
struct PermissionsStep: View {
@Environment(\.airlockNavigator) private var navigator
@StateObject private var checker = PermissionChecker(
permissions: [.accessibility, .screenRecording]
)
var body: some View {
PermissionsGroupView(
permissions: [.accessibility, .screenRecording],
permissionStates: checker.permissionStates
)
.onAppear {
checker.startMonitoring(interval: 1.0)
navigator?.setContinueEnabled(checker.allGranted)
}
.onDisappear {
checker.stopMonitoring()
}
.onChange(of: checker.permissionStates) { _, _ in
navigator?.setContinueEnabled(checker.allGranted)
}
}
}
struct ReadyStep: View {
@Environment(\.airlockNavigator) private var navigator
var body: some View {
Text("You're all set.")
.onAppear {
navigator?.setContinueEnabled(true)
}
}
}Use the FlightCheck API when you want Airlock to drive validation and state for a reusable list of checks.
import SwiftUI
import Airlock
@main
struct MyApp: App {
@StateObject private var manager = AirlockManager(
appName: "MyApp",
erasedChecks: [
AnyFlightCheck(WelcomeCheck(
appName: "MyApp",
subtitle: "Let's get you ready."
)),
AnyFlightCheck(PermissionsCheck(
permissions: [.accessibility, .screenRecording],
title: "Permissions",
description: "Grant the required system permissions."
)),
AnyFlightCheck(SetupCheck(
taskNames: [
"Preparing workspace...",
"Loading configuration...",
"Finishing setup..."
]
))
]
)
var body: some Scene {
WindowGroup {
AirlockView(manager: manager)
}
.windowStyle(.hiddenTitleBar)
}
}WelcomeCheck is already configurable through its initializer.
let check = WelcomeCheck(
appName: "MyApp",
subtitle: "A fast setup with clear guidance.",
features: [
.init(icon: "sparkles", title: "Feature One", description: "Short explanation", color: .blue),
.init(icon: "lock.shield", title: "Private", description: "Runs locally", color: .green)
],
autoAdvanceAfter: 3.0
)AccessibilityCheck is generic by default and can be branded by the host app.
let check = AccessibilityCheck(
configuration: .default(
appName: "Flow",
purpose: "track Finder windows and show contextual overlays",
privacyNote: "Flow only observes the UI state it needs for overlays."
)
)FullDiskAccessCheck supports branded copy and optional folder highlights.
let check = FullDiskAccessCheck(
configuration: .default(
appName: "Orbit",
purpose: "watch folders and organize files in protected locations",
folderHighlights: [
.init(icon: "arrow.down.doc", name: "Downloads"),
.init(icon: "doc.text", name: "Documents"),
.init(icon: "display", name: "Desktop")
],
foldersTitle: "Orbit will work with:"
)
)PermissionsCheck can validate standard permissions automatically and lets you override both detection and requesting when macOS cannot expose a reliable generic flow.
let check = PermissionsCheck(
permissions: [.automation, .files],
title: "Permissions",
description: "Review the permissions used by MyApp."
) { permission in
switch permission {
case .automation:
return hasAutomationAccessToFinder ? .granted : .notGranted
case .files:
return hasDownloadsFolderAccess ? .granted : .notGranted
default:
return permission.authorizationState
}
} requestHandler: { permission in
switch permission {
case .automation:
return await requestAutomationAccessToFinder()
case .files:
return await requestDownloadsFolderAccess()
default:
return await permission.requestAccess()
}
}LicenseActivationCheck is fully generic. Provide your own validation closure or build one on top of an HTTP endpoint.
let check = LicenseActivationCheck(
storageKey: "com.example.myapp.license",
configuration: .init(
detailTitle: "Activate MyApp",
detailDescription: "Enter your license key to unlock the full version.",
purchasePrompt: "Need a license?",
purchaseURL: URL(string: "https://example.com/purchase")
)
) { licenseKey, machineID in
let requestBody = try JSONEncoder().encode([
"license_key": licenseKey,
"machine_id": machineID
])
let validator = LicenseActivationCheck.jsonEndpointValidator(
endpoint: URL(string: "https://example.com/v1/licenses/validate")!,
requestBody: { _, _ in requestBody }
) { data, response in
if response.statusCode == 200 {
return .init(isValid: true)
}
return .init(isValid: false, failureMessage: "License validation failed.")
}
return try await validator(licenseKey, machineID)
}SetupCheck is a reusable progress-oriented check for async setup work.
let check = SetupCheck(
tasks: [
.init(name: "Preparing cache") {
try await Task.sleep(for: .seconds(0.5))
},
.init(name: "Syncing defaults") {
try await Task.sleep(for: .seconds(0.5))
}
],
title: "Setup",
description: "Prepare the app for first launch."
)Airlock distinguishes between permissions it can verify and request generically and permissions that require app-specific behavior.
accessibility,fullDiskAccess,screenRecording,camera,microphone,contacts,calendars,reminders,photos, andlocationServicesuse best-effort automatic checks.accessibility,screenRecording,camera,microphone,contacts,calendars,reminders,photos, andlocationServicescan also show the system prompt directly from Airlock when macOS allows it.fullDiskAccessalways requires a trip to System Settings.automationandfilesreturn.requiresManualVerification(...)by default because those permissions are target-specific or folder-specific on macOS.- For app-specific permissions, pass both a custom
stateProviderand a customrequestHandlertoPermissionCheckerorPermissionsCheck.
let checker = PermissionChecker(
permissions: [.automation, .files]
) { permission in
switch permission {
case .automation:
return hasAutomationAccessToFinder ? .granted : .notGranted
case .files:
return hasDownloadsFolderAccess ? .granted : .notGranted
default:
return permission.authorizationState
}
} requestHandler: { permission in
switch permission {
case .automation:
return await requestAutomationAccessToFinder()
case .files:
return await requestDownloadsFolderAccess()
default:
return await permission.requestAccess()
}
}Airlock UI components are public and can be used outside the main onboarding containers.
FeatureCard,FeatureGrid,FeatureRowPermissionsGroupView,PermissionRowView,PermissionCheckerAirlockStepHeader,AirlockInfoCard,AirlockInlineContinueButtonInfoBanner,ProgressBarView,InstructionStep
AirlockDemo/ contains a standalone sample app that demonstrates:
- A declarative onboarding flow
- Permission monitoring
- App-specific setup content
- A completion screen outside the onboarding container
Open AirlockDemo/AirlockDemo.xcodeproj to run it.
Run the package checks from the repository root:
swift build
swift testAirlock uses semantic version tags for Swift Package Manager releases.
- Merge the release-ready changes into
main. - Update version references in the README if needed.
- Create and push a release tag:
git tag 1.0.0
git push origin main
git push origin 1.0.0See RELEASING.md for the full GitHub publishing checklist.
- The license-validation URLs in the examples are placeholders. Replace them with your own API endpoints.
AirlockDemo/is included as a local sample app and is not part of the Swift package products.
Airlock is released under the MIT License. See LICENSE.
