PermissionPilot

0.2.0

Drop-in SwiftUI onboarding + permissions flow for macOS apps — Accessibility, Screen Recording, Full Disk Access, Input Monitoring. Zero dependencies.
arpitagarwal1301/PermissionPilot

What's New

PermissionPilot 0.2.0

2026-07-02T13:31:29Z

The first release hardened by a real sandboxed consumer — integration-tested
inside a sandboxed menu-bar app, which surfaced three real bugs, all fixed here.

Fixed

  • Sandbox-safe relaunch. quitAndReopen() spawned a detached /bin/sh helper, which the App Sandbox forbids — sandboxed apps quit and never relaunched. Sandboxed apps now relaunch via LaunchServices (NSWorkspace.openApplication, createsNewApplicationInstance), terminating only once the new instance is underway; on failure they stay running. Non-sandboxed behavior is unchanged. Sandboxed apps marked LSMultipleInstancesProhibited (no relaunch path exists) stay running — the grant applies on the next manual restart.
  • Reliable sandbox detection. The APP_SANDBOX_CONTAINER_ID env var is not reliably present in sandboxed processes; detection now reads the com.apple.security.app-sandbox entitlement from the process's own code signature (SecTaskCopyValueForEntitlement), with a container-home fallback. Every relaunch path logs via os.log (subsystem PermissionPilot, category relaunch) so field failures are diagnosable with log show.
  • "Open the … list" always opens the pane. The manual-add walkthrough's step-1 button called request() — for Accessibility that's the one-shot AX prompt, so every later click was a silent no-op. It now deep-links to the pane, and the Accessibility request path also falls back to opening the pane when the prompt is spent (mirroring Screen Recording).
  • Wizard stays visible after a quit-and-reopen handoff (orderFrontRegardless() — cooperative activation on macOS 14+ can deny activation right after the old instance exits).

Added

  • OnboardingPresenter.front() — deminiaturize + re-front an already-presented wizard.
  • PermissionManager.relaunchAvailable — false when no relaunch path exists (sandboxed + LSMultipleInstancesProhibited); the relaunch banner hides its "Quit & Reopen" button instead of rendering a dead control.
  • OnboardingConfiguration.showsWelcomeStep / showsDoneStep (omit wizard screens) and colorScheme (pin light/dark).
  • Localization — every user-facing string is translatable (English base; add a <lang>.lproj per target).
  • Downloadable demo installers (.pkg recommended, .dmg alternative), README visuals, CI, CHANGELOG/CONTRIBUTING.
  • Regression tests for the relaunch decisions (33 tests total).

Install

.package(url: "https://github.com/arpitagarwal1301/PermissionPilot.git", from: "0.2.0")

Full details in the CHANGELOG.

PermissionPilot — drop-in SwiftUI onboarding + permissions for macOS apps

PermissionPilot

CI Platform: macOS 12+ Swift 5.9+ SPM compatible License: MIT

Drop-in SwiftUI onboarding + permissions flow for non–App Store macOS apps. Detects, prompts, deep-links, and onboards across 16 macOS permissionszero dependencies, Apple frameworks only.

Locally-distributed Mac apps (DMG / Homebrew) can't grant TCC permissions programmatically — users flip toggles in System Settings, and every app re-invents detection, prompting, deep-linking, and onboarding UI. PermissionPilot is that layer, done once:

  • 🧭 First-run wizard (welcome → permissions → done) with live re-check + auto-advance.
  • ✅ Drop-in rows / checklist / grid that flip to ✓ and report "N of M enabled."
  • 🔗 Deep-links to the exact Privacy pane; handles the macOS signing / relaunch / Sequoia gotchas.
  • 🧩 Composable — engine only, components only, or the whole flow. No SDK branding: your app's name, icon, and accent.

PermissionPilot onboarding — light PermissionPilot onboarding — dark

The first-run wizard — welcome → grant → done — in light and dark.

Requirements

  • macOS 12+, Swift 5.9+ / Xcode 15+
  • Built for non-sandboxed apps. The engine + detection work under the App Sandbox too, but several permissions (Accessibility, Input Monitoring, Full Disk Access, Automation) don't — see Sandbox compatibility below.
Sandbox compatibility

PermissionPilot wraps only public Apple APIs, so its engine and detection run fine under the App Sandbox. Whether a given permission is usable while sandboxed is an Apple/entitlements matter, not a PermissionPilot one — it splits in three:

  • Usable sandboxed — add the matching App Sandbox entitlement + usage string: Camera, Microphone, Photos, Contacts, Calendars, Reminders, Location, Speech Recognition, Bluetooth, Notifications. Screen Recording works too (via ScreenCaptureKit), and the System Settings deep-links go through NSWorkspace.open, which the sandbox permits.
  • Not usable sandboxed (Apple's position, not a library limit): AccessibilityAXIsProcessTrusted() still returns a value, but AXUIElement calls fail .cannotComplete; Input Monitoring — global event taps are restricted; Full Disk Access — the container blocks file access regardless, and the detection heuristic can't read its probe files (reports .unknown); Automation — needs scripting-target entitlements.

[!WARNING] A status call returning a value is not proof the capability works. If you're sandboxed, verify the real capability under your entitlements — an actual AXUIElement call, a live CGEventTap, a returned capture frame — not just the preflight check.

quitAndReopen() is sandbox-aware: sandboxed apps can't spawn the detached shell helper, so there the relaunch goes through LaunchServices (NSWorkspace.openApplication, createsNewApplicationInstance) instead — except for apps marked LSMultipleInstancesProhibited, which stay running and pick up the grant on the next manual restart.

Install

Swift Package Manager:

.package(url: "https://github.com/arpitagarwal1301/PermissionPilot.git", from: "0.2.0")

Depend on PermissionPilot for the full wizard (it re-exports the other two). For finer-grained use: PermissionPilotUI (components) or PermissionPilotCore (engine, no UI).

Quick start

import PermissionPilot

let permissions = PermissionManager(
    required: [.accessibility, .screenRecording],
    optional: [.fullDiskAccess, .camera]
)

var config = OnboardingConfiguration(appName: "YourApp")
config.appIcon = Image("AppIcon")                  // optional; neutral placeholder otherwise
config.reasons = [.accessibility: "So YourApp can resize windows with your hotkey."]

if !PermissionPilot.hasCompletedOnboarding {
    PermissionPilot.presentOnboarding(manager: permissions, configuration: config)
}

presentOnboarding opens a real, titled NSWindow (no faked chrome). Prefer to embed it? Use OnboardingView in your own window/sheet. PermissionManager is an @MainActor ObservableObject that auto re-checks when the user returns from System Settings, so SwiftUI views update on their own.

Tailor the flow via OnboardingConfiguration:

config.showsWelcomeStep = false   // skip the intro (you have your own onboarding)
config.showsDoneStep    = false   // finish straight back to your flow on Continue
config.colorScheme      = .dark   // pin light/dark; nil (default) follows the system
config.tint             = .indigo // accent; icon and per-permission reasons too

Tip

Already have your own onboarding and just want the live permission UI? Skip the wizard entirely and drop PermissionsView(manager:) / PermissionChecklist(manager:) straight into your views.

Components & engine only
// Components — drop into your own onboarding UI:
PermissionChecklist(manager: permissions)                       // card + counter + live rows
PermissionsView(manager: permissions)                           // adds a List ⇄ Grid toggle
JustInTimePermissionButton(manager: permissions, permission: .camera)

// Engine — no UI:
permissions.refresh()                                           // re-detect now
permissions.request(.screenRecording)                           // prompt or deep-link
permissions.status(for: .screenRecording)                       // .granted / .denied / …
permissions.allRequiredGranted

Theme with OnboardingConfiguration(appName:tint:) or .permissionPilotTint(_:) on any subtree. Colors are system-semantic (adapt to dark mode / contrast / accent); green means granted only, and status is never conveyed by color alone (VoiceOver reads it out).

⚠️ The two things that will bite you

1 — Info.plist usage strings. A prompt-based permission crashes the app on first request without its usage-description key (PermissionPilot asserts and fails gracefully instead of crashing, but the permission won't work until you add it). Add the keys you use: NSCameraUsageDescription, NSMicrophoneUsageDescription, NSContactsUsageDescription, NSPhotoLibraryUsageDescription, NSLocationWhenInUseUsageDescription, NSSpeechRecognitionUsageDescription, NSBluetoothAlwaysUsageDescription. Calendars/Reminders need both the macOS 14+ …FullAccessUsageDescription and the 12–13 legacy key. (Notifications, Accessibility, Screen Recording, Input Monitoring need none.)

2 — Code-signing & TCC persistence. macOS ties every grant to your code signature + bundle ID. Ad-hoc / unsigned / changing signatures look like a "new app" on each rebuild and drop all grants. Sign with a stable identity (Apple Development in dev; Developer ID + notarization for distribution) and keep the bundle ID constant. Recover a stuck state with tccutil reset <Service> <bundle-id>. On MDM-managed Macs, corporate PPPC policy can silently suppress prompts for non-notarized apps. PermissionPilot surfaces these states clearly but cannot fix signing — that's your build setup.

All 16 macOS permissions in the List ⇄ Grid board

All 16 permissions — List ⇄ Grid, live status. Adapts to your theme.

Per-permission reference — detection · prompt · Settings anchor · Info.plist key
Permission Detection In-app prompt? Settings anchor Info.plist key
Accessibility AXIsProcessTrusted system prompt → Settings Privacy_Accessibility
Screen Recording CGPreflightScreenCaptureAccess CGRequestScreenCaptureAccess Privacy_ScreenCapture
Input Monitoring IOHIDCheckAccess(.listenEvent) IOHIDRequestAccess Privacy_ListenEvent
Camera AVCaptureDevice.authorizationStatus(.video) requestAccess(.video) Privacy_Camera NSCameraUsageDescription
Microphone AVCaptureDevice.authorizationStatus(.audio) requestAccess(.audio) Privacy_Microphone NSMicrophoneUsageDescription
Location CLLocationManager.authorizationStatus requestWhenInUseAuthorization Privacy_LocationServices NSLocationWhenInUseUsageDescription
Contacts CNContactStore.authorizationStatus(.contacts) requestAccess(.contacts) Privacy_Contacts NSContactsUsageDescription
Calendars EKEventStore.authorizationStatus(.event) requestFullAccessToEvents¹ Privacy_Calendars **NSCalendarsFullAccessUsageDescription**¹
Reminders EKEventStore.authorizationStatus(.reminder) requestFullAccessToReminders¹ Privacy_Reminders **NSRemindersFullAccessUsageDescription**¹
Photos PHPhotoLibrary.authorizationStatus(.readWrite) requestAuthorization(.readWrite) Privacy_Photos NSPhotoLibraryUsageDescription
Speech Recognition SFSpeechRecognizer.authorizationStatus requestAuthorization Privacy_SpeechRecognition NSSpeechRecognitionUsageDescription
Bluetooth CBManager.authorization instantiate CBCentralManager Privacy_Bluetooth NSBluetoothAlwaysUsageDescription
Notifications getNotificationSettings (async) requestAuthorization
Full Disk Access heuristic read of a TCC-protected path none — deep-link only Privacy_AllFiles
Automation none — per-target Apple Events none — deep-link only Privacy_Automation
Local Network none — no status API (macOS 15+) none — deep-link only Privacy_LocalNetwork

¹ macOS 14+ uses requestFullAccessTo… + the …FullAccessUsageDescription keys; 12–13 use requestAccess(to:) + legacy NSCalendarsUsageDescription / NSRemindersUsageDescription. PermissionPilot picks the right API/key per OS.

Notes: Notifications status is async-only (served from a cache, auto-refreshed). Input Monitoring and pre-Sequoia Screen Recording only apply after a quit & reopen — the wizard surfaces a Quit & Reopen button (manager.quitAndReopen()). Automation / Local Network / Full Disk Access are deep-link-only (no honest in-app prompt), so their rows always open System Settings.

Try the demo

The full wizard plus a live status window showing all 16 permissions (List ⇄ Grid) — no build required, good for evaluating the flow before you integrate. Both downloads are universal (Apple Silicon + Intel). Neither is notarized (this repo ships no paid Developer ID), so each needs a one-time, GUI-only approval.

Recommended — the installer (.pkg):

  1. Download PermissionPilot-Demo.pkg from the latest release.
  2. Open it. If macOS calls it "unidentified," right-click the .pkg → Open (or System Settings → Privacy & Security → Open Anyway) — once.
  3. Click through the installer. PermissionPilot Demo lands in Applications and opens normally afterward — pkg-installed files aren't quarantined, so there's no per-launch prompt.

Alternative — the disk image (.dmg):

  1. Download PermissionPilot-Demo.dmg, open it, and drag PermissionPilot Demo into Applications.
  2. macOS blocks it on first launch — unsigned downloads are just quarantined. Clear it once: right-click the app → Open → Open, or in Terminal:
    xattr -dr com.apple.quarantine "/Applications/PermissionPilot Demo.app"

Note

For your own app, sign with a stable identity (Developer ID + notarization for distribution) — see the code-signing note above. The demo is ad-hoc signed only because this repo ships no paid Developer ID.

Build it yourself

Example/build-demo-app.sh --open

Builds, signs (with your local identity), and launches the demo as its own .app. Don't test permissions with swift run PermissionPilotDemo: an unbundled, unsigned binary is attributed to the responsible parent process (your terminal), so System Settings shows the wrong app and toggles never stick.

License & credits

MIT. No third-party code — detection, deep-links, the wizard, and drag-to-authorize are original. The Full Disk Access check uses the TCC-protected-file probe technique, as also used by the MIT-licensed FullDiskAccess.


Built by Arpit Agarwal · zero dependencies, Apple frameworks only.

Description

  • Swift Tools 5.9.0
View More Packages from this Author

Dependencies

  • None
Last updated: Mon Jul 13 2026 10:32:53 GMT-0900 (Hawaii-Aleutian Daylight Time)