Contents

jaywcjlove/permissionflow

简体中文繁體中文InstallationLocalizationPublic APISystem Settings URL Scheme

Features

  • Real-time permission status display: Buttons automatically show whether permissions are granted with visual feedback (green checkmark for granted, blue arrow for not granted)
  • Opens the target System Settings privacy pane automatically
  • Animates the floating panel from the click position to the System Settings window
  • Follows the System Settings window while it moves
  • Shows the current app as a native drag source
  • Keeps only one active floating panel at a time
  • Closes the floating panel automatically when System Settings closes
  • Supports adaptive floating panel height based on content
  • Intelligent permission detection: Uses official Apple APIs for accurate permission status checking without triggering system prompts

Requirements

  • macOS 13+
  • Swift 6 package toolchain
  • SwiftUI + AppKit host application

Installation

Add the package to your app:

dependencies: [
    .package(url: "https://github.com/jaywcjlove/PermissionFlow.git", from: "1.0.0")
]

The package URL and installation entry stay the same as before. What changed is the product layout: permission status detection for some panes is now split into optional extensions instead of being linked by default.

This package now exposes these library products:

  • PermissionFlow: floating authorization guidance for supported privacy panes on macOS
  • SystemSettingsKit: reusable deeplink API for arbitrary System Settings pages
  • PermissionFlowStatusStore: injectable SwiftUI environment status store for reading permission state from any view
  • PermissionFlowExtendedStatus: one-stop optional status detection for .bluetooth, .camera, .inputMonitoring, .mediaAppleMusic, and .screenRecording
  • PermissionFlowBluetoothStatus: optional status detection for .bluetooth
  • PermissionFlowCameraStatus: optional status detection for .camera
  • PermissionFlowMediaStatus: optional status detection for .mediaAppleMusic
  • PermissionFlowInputMonitoringStatus: optional status detection for .inputMonitoring
  • PermissionFlowScreenRecordingStatus: optional status detection for .screenRecording

Then add the product you need to your target:

.target(
    name: "YourApp",
    dependencies: [
        .product(name: "PermissionFlow", package: "PermissionFlow"),
        .product(name: "SystemSettingsKit", package: "PermissionFlow")
    ]
)

If you want status detection for .bluetooth, .camera, .inputMonitoring, .mediaAppleMusic, and .screenRecording, add the optional extension product as well:

.target(
    name: "YourApp",
    dependencies: [
        .product(name: "PermissionFlow", package: "PermissionFlow"),
        .product(name: "PermissionFlowExtendedStatus", package: "PermissionFlow")
    ]
)

You can also depend on only the specific extension products you need:

.product(name: "PermissionFlowBluetoothStatus", package: "PermissionFlow")
.product(name: "PermissionFlowCameraStatus", package: "PermissionFlow")
.product(name: "PermissionFlowMediaStatus", package: "PermissionFlow")
.product(name: "PermissionFlowInputMonitoringStatus", package: "PermissionFlow")
.product(name: "PermissionFlowScreenRecordingStatus", package: "PermissionFlow")

Why this split matters:

  • Apps that only use PermissionFlow keep the original core integration and do not need to link optional status-detection modules by default.
  • This reduces unnecessary compile-time and link-time dependencies such as CoreBluetooth, AVFoundation (camera status), MusicKit, and Carbon when those permission states are not needed.
  • In practice, this usually keeps the final app product cleaner and can reduce the amount of optional code that ends up linked into your binary.

Platform support:

  • PermissionFlow: macOS 13+
  • SystemSettingsKit: macOS 13+, iOS 16+

SystemSettingsKit is intentionally partial on iOS. The macOS deeplink-based pane and anchor APIs remain macOS-only, while iOS only exposes destinations that are publicly supported by UIKit, such as the current app's Settings page.

Supported Permission Panes

PermissionFlow covers these privacy panes. Most use the floating drag-and-drop authorization workflow; .camera, .microphone, .calendars, and .reminders use the system permission prompt and only open System Settings (no floating drag panel).

  • .accessibility: Opens Privacy & Security > Accessibility. ✅ Status Detection Supported
  • .fullDiskAccess: Opens Privacy & Security > Full Disk Access. ✅ Status Detection Supported
  • .inputMonitoring: Opens Privacy & Security > Input Monitoring. ✅ Status Detection Supported
  • .screenRecording: Opens Privacy & Security > Screen Recording. ✅ Status Detection Supported
  • .camera: Requests camera authorization and opens Privacy & Security > Camera when settings access is needed. ✅ Supports status detection (no floating panel; via PermissionFlowCameraStatus)
  • .microphone: Requests microphone authorization and opens Privacy & Security > Microphone when settings access is needed. ✅ Status Detection Supported (no floating panel)
  • .calendars: Requests calendar authorization and opens Privacy & Security > Calendars. ✅ Status Detection Supported (no floating panel; host Info.plist required)
  • .reminders: Requests reminders authorization and opens Privacy & Security > Reminders. ✅ Status Detection Supported (no floating panel; host Info.plist required)
  • .bluetooth: Opens Privacy & Security > Bluetooth. ✅ Supports status detection
  • .mediaAppleMusic: Opens Privacy & Security > Media & Apple Music. ✅ Supports status detection
  • .appManagement: Opens Privacy & Security > App Management. ⚠️ Status detection not available
  • .developerTools: Opens Privacy & Security > Developer Tools. ⚠️ Status detection not available

Permission Status Display: For supported permissions, PermissionFlowButton automatically displays the current authorization status:

  • Granted: Green checkmark icon with "Granted" text
  • ➡️ Not Granted: Blue arrow icon with "Grant" text
  • Built into PermissionFlow: .accessibility, .fullDiskAccess, .microphone, .calendars, .reminders
  • Available through optional status extensions: .bluetooth, .camera, .inputMonitoring, .mediaAppleMusic, .screenRecording
  • 🔄 Checking: Clock icon with "Checking..." text
  • Unknown: Blue arrow icon with "Open" text (for unsupported detection)

For every other System Settings page or privacy subsection, use SystemSettingsKit.

Info.plist Privacy Descriptions

Permissions that trigger Apple's system privacy prompt must include the matching usage description in the host app's Info.plist. If the host macOS app uses App Sandbox, also enable the matching entitlement in Signing & Capabilities > App Sandbox.

Microphone

Use this when requesting .microphone or calling Apple's microphone authorization APIs.

<key>NSMicrophoneUsageDescription</key>
<string>This app needs microphone access for audio recording.</string>

For sandboxed macOS apps, turn on Audio Input, or add:

<key>com.apple.security.device.audio-input</key>
<true/>

Calendars

Use this when requesting .calendars or calling EventKit calendar authorization APIs. After the host declares the usage description and the app successfully requests access once, the app appears in Privacy & Security > Calendars. This pane does not support drag-to-list authorization—PermissionFlow only opens the settings page.

<!-- Required on older macOS / as a compatibility key -->
<key>NSCalendarsUsageDescription</key>
<string>This app needs calendar access to manage events.</string>

<!-- Required for full calendar access on newer macOS (macOS 14+) -->
<key>NSCalendarsFullAccessUsageDescription</key>
<string>This app needs full calendar access to read and manage events.</string>

If the host enables App Sandbox, also grant Calendars access:

  • Xcode: Signing & Capabilities > App Sandbox > App Data > Calendars
  • Or entitlement:
<key>com.apple.security.personal-information.calendars</key>
<true/>

Without this sandbox entitlement, requestFullAccessToEvents will not register the app with TCC and it will not appear in the Calendars list.

Status is read with EventKit. For a full manual UI example that does not use PermissionFlowButton, see Manual Calendars authorization.

let provider = CalendarPermissionStatusProvider()
let state = provider.authorizationState() // .granted when EKAuthorizationStatus.fullAccess
// Equivalent check:
// EKEventStore.authorizationStatus(for: .event) == .fullAccess

Reminders

Same pattern as Calendars: system prompt + open System Settings (no floating drag panel). Status uses EventKit with EKEntityType.reminder.

<key>NSRemindersUsageDescription</key>
<string>This app needs reminders access to manage tasks.</string>

<!-- Required for full reminders access on newer macOS (macOS 14+) -->
<key>NSRemindersFullAccessUsageDescription</key>
<string>This app needs full reminders access to read and manage tasks.</string>

If App Sandbox is enabled, grant EventKit personal-data access (Calendars sandbox entitlement is typically required for EventKit on macOS):

<key>com.apple.security.personal-information.calendars</key>
<true/>

For a full manual UI example, see Manual Reminders authorization.

let provider = RemindersPermissionStatusProvider()
let state = provider.authorizationState() // .granted when EKAuthorizationStatus.fullAccess
// Equivalent check:
// EKEventStore.authorizationStatus(for: .reminder) == .fullAccess

Camera

Use this when requesting .camera or calling Apple's camera authorization APIs.

<key>NSCameraUsageDescription</key>
<string>This app needs camera access for video capture.</string>

For sandboxed macOS apps, turn on Camera, or add:

<key>com.apple.security.device.camera</key>
<true/>

Status is read with AVFoundation (AVCaptureDevice.authorizationStatus(for: .video)) via the optional PermissionFlowCameraStatus product. This pane does not support drag-to-list authorization—PermissionFlow only opens the settings page after the system prompt when needed.

import PermissionFlowCameraStatus

// One-time registration (e.g. in App.init)
PermissionFlowCameraStatus.register()

let provider = CameraPermissionStatusProvider()
let state = provider.authorizationState()
provider.requestAuthorization { state in
    // ...
}

Or register all optional providers at once with PermissionFlowExtendedStatus.register().

Apple Events

Use this when your app sends Apple Events, such as automating or controlling another app.

<key>NSAppleEventsUsageDescription</key>
<string>This app needs to control other apps for authorization guidance.</string>

For sandboxed macOS apps, turn on Apple Events, or add:

<key>com.apple.security.automation.apple-events</key>
<true/>

Quick Start

SwiftUI button

import PermissionFlow
import SwiftUI

struct ContentView: View {
    var body: some View {
        PermissionFlowButton(
            title: "Grant Accessibility",
            pane: .accessibility,
            suggestedAppURLs: [Bundle.main.bundleURL]
        )
    }
}

Enable optional status detection

To enable status detection for .bluetooth, .inputMonitoring, .mediaAppleMusic, and .screenRecording, add the optional extension products and register them once at app startup:

import PermissionFlowExtendedStatus
import SwiftUI

@main
struct MyApp: App {
    init() {
        PermissionFlowExtendedStatus.register()
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Inject a status store at app startup

If you want to read permission state from any SwiftUI view, add the PermissionFlowStatusStore product:

.product(name: "PermissionFlow", package: "PermissionFlow"),
.product(name: "PermissionFlowStatusStore", package: "PermissionFlow")

Then create and inject the store at the app entry point:

import PermissionFlow
import PermissionFlowStatusStore
import SwiftUI

@main
struct MyApp: App {
    @StateObject private var permissionStatusStore = PermissionFlowStatusStore()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(permissionStatusStore)
        }
    }
}

Read it from any child view:

import PermissionFlow
import PermissionFlowStatusStore
import SwiftUI

struct PermissionBadge: View {
    @EnvironmentObject private var permissionStatusStore: PermissionFlowStatusStore

    var body: some View {
        Text(title(for: permissionStatusStore.state(for: .accessibility)))
            .onAppear {
                permissionStatusStore.refresh(.accessibility)
            }
    }

    private func title(for state: PermissionAuthorizationState) -> String {
        switch state {
        case .granted:
            "Granted"
        case .notGranted:
            "Not Granted"
        case .unknown:
            "Unknown"
        case .checking:
            "Checking"
        }
    }
}

PermissionFlowStatusStore tracks PermissionFlowPane.allCases by default and refreshes automatically when the app becomes active again. You can also track only selected panes:

@StateObject private var permissionStatusStore = PermissionFlowStatusStore(
    panes: [.accessibility, .fullDiskAccess, .screenRecording]
)

PermissionFlowStatusStore does not decide whether a pane is detectable by itself; it reads the providers currently registered in PermissionStatusRegistry. Current support is:

| Pane | Detectable by default | Requires extra registration | Not reliably detectable | | --- | --- | --- | --- | | .accessibility | ✅ | | | | .fullDiskAccess | ✅ | | | | .microphone | ✅ | | | | .calendars | ✅ | | | | .reminders | ✅ | | | | .bluetooth | | ✅ | | | .camera | | ✅ | | | .inputMonitoring | | ✅ | | | .mediaAppleMusic | | ✅ | | | .screenRecording | | ✅ | | | .appManagement | | | ✅ | | .developerTools | | | ✅ |

For panes that are not reliably detectable, state(for:) usually returns .unknown.

Note: PermissionFlowStatusStore is only the state container. Optional panes such as .inputMonitoring, .screenRecording, .bluetooth, .camera, and .mediaAppleMusic still need their status providers registered first. In other words, register() and PermissionFlowStatusStore are two separate steps:

import PermissionFlowInputMonitoringStatus
import PermissionFlowStatusStore
import SwiftUI

@main
struct MyApp: App {
    @StateObject private var permissionStatusStore: PermissionFlowStatusStore

    init() {
        PermissionFlowInputMonitoringStatus.register()
        _permissionStatusStore = StateObject(
            wrappedValue: PermissionFlowStatusStore()
        )
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(permissionStatusStore)
        }
    }
}

To enable all optional status providers at once, register PermissionFlowExtendedStatus:

import PermissionFlowExtendedStatus
import PermissionFlowStatusStore
import SwiftUI

@main
struct MyApp: App {
    @StateObject private var permissionStatusStore: PermissionFlowStatusStore

    init() {
        PermissionFlowExtendedStatus.register()
        _permissionStatusStore = StateObject(
            wrappedValue: PermissionFlowStatusStore()
        )
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(permissionStatusStore)
        }
    }
}

Manual status display

import AppKit
import PermissionFlow
import SwiftUI

struct ManualPermissionButton: View {
    @StateObject private var controller = PermissionFlow.makeController()
    @State private var authorizationState: PermissionAuthorizationState = .checking

    let didBecomeActive = NotificationCenter.default.publisher(
        for: NSApplication.didBecomeActiveNotification
    )

    var body: some View {
        Button {
            controller.authorize(
                pane: .accessibility,
                suggestedAppURLs: [Bundle.main.bundleURL],
                sourceFrameInScreen: clickSourceFrameInScreen()
            )
        } label: {
            Label {
                Text(title(for: authorizationState))
            } icon: {
                let icon = PermissionFlowButtonState
                    .make(from: authorizationState).systemImage
                Image(systemName: icon)
            }
        }
        .onAppear(perform: refreshStatus)
        .onReceive(didBecomeActive) { _ in
            refreshStatus()
        }
    }

    private func refreshStatus() {
        let provider = PermissionStatusRegistry.provider(for: .accessibility)
        authorizationState = provider.authorizationState()
    }

    private func title(for state: PermissionAuthorizationState) -> String {
        switch state {
        case .granted:
            "Granted"
        case .notGranted:
            "Grant"
        case .unknown:
            "Open"
        case .checking:
            "Checking..."
        }
    }

    private func clickSourceFrameInScreen() -> CGRect {
        let mouse = NSEvent.mouseLocation
        return CGRect(x: mouse.x - 16, y: mouse.y - 16, width: 32, height: 32)
    }
}

Manual Calendars authorization

.calendars does not use the floating drag panel. You can handle it fully without PermissionFlowButton: request access with EventKit (via CalendarPermissionStatusProvider), then open the Calendars settings page.

Host setup first (see Calendars):

  1. NSCalendarsUsageDescription + NSCalendarsFullAccessUsageDescription in Info.plist
  2. App Sandbox → Calendars entitlement when sandbox is enabled
import AppKit
import PermissionFlow
import SystemSettingsKit
import SwiftUI

struct ManualCalendarsPermissionView: View {
    @State private var authorizationState: PermissionAuthorizationState = .checking

    private let didBecomeActive = NotificationCenter.default.publisher(
        for: NSApplication.didBecomeActiveNotification
    )

    var body: some View {
        Button {
            requestCalendarAccess()
        } label: {
            let buttonState = PermissionFlowButtonState.make(from: authorizationState)
            Label(title(for: authorizationState), systemImage: buttonState.systemImage)
                .foregroundStyle(buttonState.isGranted ? .green : .primary)
        }
        .onAppear(perform: refreshStatus)
        .onReceive(didBecomeActive) { _ in
            refreshStatus()
        }
    }

    private func refreshStatus() {
        // Built-in registry entry for .calendars
        authorizationState = PermissionStatusRegistry
            .provider(for: .calendars)
            .authorizationState()

        // Or call the provider directly:
        // authorizationState = CalendarPermissionStatusProvider().authorizationState()
    }

    private func requestCalendarAccess() {
        authorizationState = .checking

        CalendarPermissionStatusProvider().requestAuthorization { state in
            Task { @MainActor in
                authorizationState = state

                // No floating panel — only open System Settings.
                SystemSettings.open(.privacy(anchor: .privacyCalendars))

                // Equivalent:
                // PermissionFlow.makeController().authorize(pane: .calendars)
            }
        }
    }

    private func title(for state: PermissionAuthorizationState) -> String {
        switch state {
        case .granted:
            "Granted"
        case .notGranted:
            "Request Calendars"
        case .unknown:
            "Open Calendars Settings"
        case .checking:
            "Checking..."
        }
    }
}

Minimal non-UI version:

import PermissionFlow
import SystemSettingsKit

func openCalendarsPermission() {
    CalendarPermissionStatusProvider().requestAuthorization { _ in
        // After the system prompt (if needed), open settings so the user can
        // change Full Access / toggle the app.
        DispatchQueue.main.async {
            SystemSettings.open(.privacy(anchor: .privacyCalendars))
        }
    }
}

func isCalendarsGranted() -> Bool {
    CalendarPermissionStatusProvider().hasFullAccess()
    // Same as:
    // PermissionStatusRegistry.provider(for: .calendars).authorizationState() == .granted
}

Manual Reminders authorization

Same flow as Calendars, with RemindersPermissionStatusProvider and the Reminders settings pane. Host setup first (see Reminders).

import AppKit
import PermissionFlow
import SystemSettingsKit
import SwiftUI

struct ManualRemindersPermissionView: View {
    @State private var authorizationState: PermissionAuthorizationState = .checking

    private let didBecomeActive = NotificationCenter.default.publisher(
        for: NSApplication.didBecomeActiveNotification
    )

    var body: some View {
        Button {
            requestRemindersAccess()
        } label: {
            let buttonState = PermissionFlowButtonState.make(from: authorizationState)
            Label(title(for: authorizationState), systemImage: buttonState.systemImage)
                .foregroundStyle(buttonState.isGranted ? .green : .primary)
        }
        .onAppear(perform: refreshStatus)
        .onReceive(didBecomeActive) { _ in
            refreshStatus()
        }
    }

    private func refreshStatus() {
        authorizationState = PermissionStatusRegistry
            .provider(for: .reminders)
            .authorizationState()

        // Or:
        // authorizationState = RemindersPermissionStatusProvider().authorizationState()
    }

    private func requestRemindersAccess() {
        authorizationState = .checking

        RemindersPermissionStatusProvider().requestAuthorization { state in
            Task { @MainActor in
                authorizationState = state
                SystemSettings.open(.privacy(anchor: .privacyReminders))
                // Equivalent:
                // PermissionFlow.makeController().authorize(pane: .reminders)
            }
        }
    }

    private func title(for state: PermissionAuthorizationState) -> String {
        switch state {
        case .granted:
            "Granted"
        case .notGranted:
            "Request Reminders"
        case .unknown:
            "Open Reminders Settings"
        case .checking:
            "Checking..."
        }
    }
}

Minimal non-UI version:

import PermissionFlow
import SystemSettingsKit

func openRemindersPermission() {
    RemindersPermissionStatusProvider().requestAuthorization { _ in
        DispatchQueue.main.async {
            SystemSettings.open(.privacy(anchor: .privacyReminders))
        }
    }
}

func isRemindersGranted() -> Bool {
    RemindersPermissionStatusProvider().hasFullAccess()
}

Manual controller usage

Use PermissionFlowController when you want to control the flow yourself:

import PermissionFlow
import SwiftUI

@MainActor
final class PermissionViewModel: ObservableObject {
    private let controller = PermissionFlow.makeController()

    func requestFullDiskAccess() {
        controller.authorize(
            pane: .fullDiskAccess,
            suggestedAppURLs: [Bundle.main.bundleURL]
        )
    }
}

Localization

PermissionFlow UI copy (button titles, floating panel title, drag card label) is loaded through a resilient package resource lookup. It does not call SwiftPM’s Bundle.module at runtime, so missing or relocated resource bundles in signed/installed apps degrade to English defaults instead of asserting.

SwiftUI environment locale (recommended)

PermissionFlowButton reads @Environment(\.locale) for its default title and passes the same identifier into the floating panel when the button is pressed:

import PermissionFlow
import SwiftUI

struct ContentView: View {
    @State private var languageCode = "zh-Hans"

    var body: some View {
        VStack {
            PermissionFlowButton(pane: .accessibility)
            PermissionFlowButton(pane: .fullDiskAccess)
        }
        .environment(\.locale, .init(identifier: languageCode))
    }
}

Prefer full identifiers such as zh-Hans, zh-Hant, or ja. Short codes like zh may only partially match available .lproj folders.

Manual controller / configuration

Floating panels created outside PermissionFlowButton do not inherit the SwiftUI environment automatically. Set the locale explicitly:

// At controller creation
let controller = PermissionFlow.makeController(
    configuration: .init(
        requiredAppURLs: [Bundle.main.bundleURL],
        localeIdentifier: "ja"
    )
)

// Or later
controller.setLocaleIdentifier("ja")
Built-in languages

Package strings ship under Sources/PermissionFlow/Resources/*.lproj for:

en, zh-Hans, zh-Hant, ja, ko, fr, de, es, pt, ru, ar

Keep the launch animation

If you use PermissionFlowButton, the package captures the click position for you and the floating panel will animate from the button click to the System Settings window automatically.

If you call PermissionFlowController.authorize(...) manually, pass the click source frame yourself. Otherwise the panel will still appear, but it will skip the launch animation and jump directly to the target position.

import AppKit
import PermissionFlow

@MainActor
final class PermissionViewModel: ObservableObject {
    private let controller = PermissionFlow.makeController()

    func requestAccessibility() {
        let mouseLocation = NSEvent.mouseLocation
        let sourceFrame = CGRect(
            x: mouseLocation.x - 16,
            y: mouseLocation.y - 16,
            width: 32,
            height: 32
        )

        controller.authorize(
            pane: .accessibility,
            suggestedAppURLs: [Bundle.main.bundleURL],
            sourceFrameInScreen: sourceFrame
        )
    }
}

Public API

PermissionFlowButton

Convenience SwiftUI button for launching a permission flow.

PermissionFlowButton(
    title: "Open Screen Recording",
    pane: .screenRecording,
    suggestedAppURLs: [Bundle.main.bundleURL],
    configuration: .init()
)

PermissionFlowButton(
    pane: .screenRecording,
    suggestedAppURLs: [Bundle.main.bundleURL],
    configuration: .init()
) { state in
    // Prefer `defaultTitle` (or your own copy) so custom labels do not
    // depend on `Bundle.module` / a missing package resource bundle.
    Label(state.defaultTitle, systemImage: state.systemImage)
        .foregroundStyle(state.isGranted ? .green : .primary)
}

Default titles follow the SwiftUI environment locale when you omit a custom title / label. See Localization.

PermissionFlow.makeController

Creates a reusable controller:

let controller = PermissionFlow.makeController(
    configuration: .init(
        requiredAppURLs: [Bundle.main.bundleURL],
        promptForAccessibilityTrust: false,
        localeIdentifier: "zh-Hans"
    )
)

PermissionFlowController

Main entry points:

  • authorize(pane:suggestedAppURLs:sourceFrameInScreen:)
  • setLocaleIdentifier(_:) — updates floating-panel localization
  • showPanel()
  • closePanel()
  • resetDroppedApps()
  • registerDroppedApp(_:)

PermissionFlowResources

Safe access to the package resource bundle for host apps that need PermissionFlow’s localized strings or other packaged assets.

Do not use SwiftPM’s Bundle.module from host UI or runtime code: when the installed .app layout does not match compile-time assumptions, Bundle.module can trap (EXC_BREAKPOINT / assertion failure). PermissionFlowResources searches common packaged locations and never asserts.

import PermissionFlow

// Preferred: optional package bundle + English (or your) default
if let bundle = PermissionFlowResources.packageBundle {
    let title = bundle.localizedString(
        forKey: "permission_flow.button.grant",
        value: "Grant",
        table: nil
    )
}

// Non-optional convenience: package bundle, or Bundle.main if lookup fails
let bundle = PermissionFlowResources.bundle
let title = bundle.localizedString(
    forKey: "permission_flow.button.grant",
    value: "Grant",
    table: nil
)
public enum PermissionFlowResources {
    public static let resourceBundleName = "PermissionFlow_PermissionFlow"
    public static var packageBundle: Bundle? { get } // nil when not found
    public static var bundle: Bundle { get }         // packageBundle ?? .main
}

Package UI (PermissionFlowButton, floating panel, drag card) already uses this resilient path internally.

SystemSettings.open

Open any System Settings page directly from a pane identifier and optional anchor:

import SystemSettingsKit

SystemSettings.open(
    paneIdentifier: "com.apple.Wallpaper-Settings.extension"
)

SystemSettings.open(
    paneIdentifier: "com.apple.settings.PrivacySecurity.extension",
    anchor: "Privacy_Advertising"
)

You can also use SystemSettingsDestination:

import SystemSettingsKit

SystemSettings.open(.wallpaper)
SystemSettings.open(.privacy(anchor: .privacyAllFiles))
SystemSettings.open(.displays(anchor: .resolutionSection))

System Settings URL Scheme

SystemSettingsKit exposes a lightweight API for opening arbitrary System Settings panes using the x-apple.systempreferences: URL scheme.

The behavior and examples are based on the identifiers and deeplink notes collected in SystemSettings-URLs-macOS.

URL format

x-apple.systempreferences:<pane-identifier>
x-apple.systempreferences:<pane-identifier>?<anchor>

Examples:

x-apple.systempreferences:com.apple.Wallpaper-Settings.extension
x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Advertising
x-apple.systempreferences:com.apple.Wallpaper-Settings.extension?ScreenSaver

Package type

public struct SystemSettingsDestination {
    public let paneIdentifier: String
    public let anchor: String?
    public var url: URL { get }
}

Convenience destinations

The package includes a few common helpers:

  • .wallpaper
  • .displays
  • .displays(anchor:)
  • .accessibility
  • .accessibility(anchor:)
  • .bluetooth
  • .loginItems
  • .loginItems(anchor:)
  • .loginItems(extensionPointIdentifier:)
  • .wifi
  • .wifi(anchor:)
  • .vpn
  • .vpn(anchor:)
  • .privacy(anchor:)

Privacy anchors

For Privacy & Security subsections, use:

SystemSettings.open(.privacy(anchor: .privacyAllFiles))
SystemSettings.open(.privacy(anchor: .privacyAdvertising))
SystemSettings.open(.privacy(anchor: .privacyAccessibility))
SystemSettings.open(.privacy(anchor: .security))

The existing PermissionFlowPane type continues to handle the privacy pages used by the authorization workflow.

  • .appManagement: Opens Privacy & Security > App Management.
  • .accessibility: Opens Privacy & Security > Accessibility.
  • .bluetooth: Opens Privacy & Security > Bluetooth.
  • .developerTools: Opens Privacy & Security > Developer Tools.
  • .fullDiskAccess: Opens Privacy & Security > Full Disk Access.
  • .inputMonitoring: Opens Privacy & Security > Input Monitoring.
  • .mediaAppleMusic: Opens Privacy & Security > Media & Apple Music.
  • .camera: Requests camera authorization and opens Privacy & Security > Camera when settings access is needed (status via PermissionFlowCameraStatus).
  • .microphone: Requests microphone authorization and opens Privacy & Security > Microphone when settings access is needed.
  • .calendars: Requests calendar authorization and opens Privacy & Security > Calendars (no floating drag panel).
  • .reminders: Requests reminders authorization and opens Privacy & Security > Reminders (no floating drag panel).
  • .screenRecording: Opens Privacy & Security > Screen Recording.

Available typed privacy anchors and their destinations:

  • .advanced: Privacy & Security > Advanced
  • .fileVault: Privacy & Security > FileVault
  • .locationAccessReport: Privacy & Security > Location Access Report
  • .lockdownMode: Privacy & Security > Lockdown Mode
  • .privacyAccessibility: Privacy & Security > Accessibility
  • .privacyAdvertising: Privacy & Security > Advertising
  • .privacyAllFiles: Privacy & Security > Full Disk Access
  • .privacyAnalytics: Privacy & Security > Analytics & Improvements
  • .privacyAppBundles: Privacy & Security > App Management
  • .privacyAudioCapture: Privacy & Security > Audio Capture
  • .privacyAutomation: Privacy & Security > Automation
  • .privacyBluetooth: Privacy & Security > Bluetooth
  • .privacyCalendars: Privacy & Security > Calendars
  • .privacyCamera: Privacy & Security > Camera
  • .privacyContacts: Privacy & Security > Contacts
  • .privacyDevTools: Privacy & Security > Developer Tools
  • .privacyFilesAndFolders: Privacy & Security > Files & Folders
  • .privacyFocus: Privacy & Security > Focus
  • .privacyHomeKit: Privacy & Security > Home
  • .privacyListenEvent: Privacy & Security > Input Monitoring
  • .privacyLocationServices: Privacy & Security > Location Services
  • .privacyMedia: Privacy & Security > Media & Apple Music
  • .privacyMicrophone: Privacy & Security > Microphone
  • .privacyMotion: Privacy & Security > Motion & Fitness
  • .privacyNudityDetection: Privacy & Security > Sensitive Content Warning
  • .privacyPasskeyAccess: Privacy & Security > Passkey Access
  • .privacyPhotos: Privacy & Security > Photos
  • .privacyReminders: Privacy & Security > Reminders
  • .privacyRemoteDesktop: Privacy & Security > Remote Desktop
  • .privacyScreenCapture: Privacy & Security > Screen Recording
  • .privacySpeechRecognition: Privacy & Security > Speech Recognition
  • .privacySystemServices: Privacy & Security > System Services
  • .security: Privacy & Security > Security
  • .securityImprovements: Privacy & Security > Security Improvements

Displays anchors

Displays now has a typed helper instead of raw string anchors:

SystemSettings.open(.displays)
SystemSettings.open(.displays(anchor: .arrangementSection))
SystemSettings.open(.displays(anchor: .resolutionSection))
SystemSettings.open(.displays(anchor: .nightShiftSection))

Available display anchors and their destinations:

  • .advancedSection: Displays > Advanced
  • .ambienceSection: Displays > Ambience
  • .arrangementSection: Displays > Arrangement
  • .characteristicSection: Displays > Display Characteristics
  • .displaysSection: Displays > Displays
  • .miscellaneousSection: Displays > Miscellaneous
  • .nightShiftSection: Displays > Night Shift
  • .profileSection: Displays > Color Profile
  • .resolutionSection: Displays > Resolution
  • .sidecarSection: Displays > Sidecar

Login Items anchors

Login Items supports typed subsection anchors:

SystemSettings.open(.loginItems)
SystemSettings.open(.loginItems(anchor: .extensionItems))
SystemSettings.open(.loginItems(extensionPointIdentifier: .quickLookPreview))
SystemSettings.open(.loginItems(extensionPointIdentifier: .shareServices))

Available login item anchors and extension point helpers:

  • .extensionItems: Login Items > Extension Items
  • .shareServices: extensionPointIdentifier=com.apple.share-services
  • .actions: extensionPointIdentifier=com.apple.ui-services
  • .photoEditing: extensionPointIdentifier=com.apple.photo-editing
  • .spotlightImporter: extensionPointIdentifier=com.apple.spotlight.import
  • .quickLookPreview: Login Items & Extensions > Extensions > Quick Look, using extensionPointIdentifier=com.apple.quicklook.preview
  • .fileProvider: extensionPointIdentifier=com.apple.fileprovider-nonui
  • .finderQuickActions: extensionPointIdentifier=com.apple.finder-quick-actions
  • .touchBarQuickActions: extensionPointIdentifier=com.apple.touchbar-quick-actions
  • .legacyDockTiles: extensionPointIdentifier=com.apple.extensionkit.legacy-plugins.docktiles
  • .legacySpotlightImporter: extensionPointIdentifier=com.apple.extensionkit.legacy-plugins.spotlight-importer

Wi-Fi anchors

Wi-Fi supports typed subsection anchors:

SystemSettings.open(.wifi)
SystemSettings.open(.wifi(anchor: .generalMain))
SystemSettings.open(.wifi(anchor: .generalJoin))
SystemSettings.open(.wifi(anchor: .generalDetails))
SystemSettings.open(.wifi(anchor: .advanced))

Available Wi-Fi anchors and their destinations:

  • .advanced: Wi-Fi > Advanced
  • .generalDetails: Wi-Fi > Details
  • .generalJoin: Wi-Fi > Join
  • .generalMain: Wi-Fi > Main

VPN anchors

VPN supports typed subsection anchors:

SystemSettings.open(.vpn)
SystemSettings.open(.vpn(anchor: .vpn))
SystemSettings.open(.vpn(anchor: .vpnOnDemand))

Available VPN anchors and their destinations:

  • .vpn: VPN > VPN
  • .vpnOnDemand: VPN > VPN on Demand

Accessibility anchors

Accessibility has a typed helper for common sections and a raw string fallback for detailed control-level anchors:

SystemSettings.open(.accessibility)
SystemSettings.open(.accessibility(anchor: .display))
SystemSettings.open(.accessibility(anchor: .voiceOver))
SystemSettings.open(.accessibility(anchor: "AX_ZOOM_MAX_FACTOR"))

Available common accessibility anchors:

  • .display: Accessibility > Display
  • .text: Accessibility > Text
  • .pointer: Accessibility > Pointer
  • .mouseAndTrackpad: Accessibility > Mouse & Trackpad
  • .headphones: Accessibility > Headphones
  • .voiceOver: Accessibility > VoiceOver
  • .zoom: Accessibility > Zoom
  • .displayFilters: Accessibility > Display Filters
  • .backgroundSounds: Accessibility > Background Sounds
  • .spokenContent: Accessibility > Spoken Content
  • .captions: Accessibility > Captions
  • .audio: Accessibility > Audio
  • .descriptions: Accessibility > Audio Descriptions
  • .keyboard: Accessibility > Keyboard
  • .fullKeyboardAccess: Accessibility > Full Keyboard Access
  • .stickyKeys: Accessibility > Sticky Keys
  • .slowKeys: Accessibility > Slow Keys
  • .virtualKeyboard: Accessibility > Accessibility Keyboard
  • .voiceControl: Accessibility > Voice Control
  • .switchControl: Accessibility > Switch Control
  • .alternateMouseButtons: Accessibility > Alternate Mouse Buttons
  • .headMouse: Accessibility > Head Pointer
  • .mouseKeys: Accessibility > Mouse Keys
  • .hoverText: Accessibility > Hover Text
  • .hoverTyping: Accessibility > Hover Typing
  • .liveSpeech: Accessibility > Live Speech
  • .personalVoice: Accessibility > Personal Voice
  • .siri: Accessibility > Siri
  • .shortcut: Accessibility > Accessibility Shortcut

Configuration

let configuration = PermissionFlowConfiguration(
    requiredAppURLs: [Bundle.main.bundleURL],
    promptForAccessibilityTrust: false,
    localeIdentifier: "zh-Hans"
)

Notes

  • requiredAppURLs preloads apps into the panel
  • promptForAccessibilityTrust controls whether AX trust is actively prompted
  • localeIdentifier seeds floating-panel localization when you use the controller without PermissionFlowButton / .environment(\.locale, …)

How It Works

  1. Your app requests a permission pane.
  2. PermissionFlow opens the matching System Settings page.
  3. If that pane supports drag-based authorization, a floating panel appears.
  4. The panel animates from the click location to the System Settings window.
  5. The panel tracks the System Settings window position (AX attributes are type-checked with CFGetTypeID / AXValueGetTypeID before conversion; unexpected types skip the frame instead of crashing).
  6. The user drags the current .app bundle into the permission list.

Example

The repository includes an Example macOS app that demonstrates all supported permission flows.

Notes and Limitations

  • The floating helper is only shown for panes that support app-list style authorization.
  • Permission status detection: Uses official Apple APIs (CGPreflightListenEventAccess, CGPreflightScreenCaptureAccess, AXIsProcessTrusted) for accurate status checking without triggering system prompts.
  • Status refresh: Permission status is automatically refreshed when the app becomes active and when buttons appear on screen.
  • Localization: Prefer .environment(\.locale, …) with PermissionFlowButton, or localeIdentifier / setLocaleIdentifier(_:) with a manual controller. Host code should use PermissionFlowResources.packageBundle (or bundle) and always pass a default string value—never Bundle.module—to avoid crashes in packaged apps.
  • System Settings behavior is controlled by macOS and may vary slightly by OS version.
  • AX-based window tracking is used when available. Window Server frame lookup is used as fallback and bootstrap. AX read results are validated before conversion so unexpected attribute types return nil / skip the current frame.
  • The package does not bypass macOS security. It only guides the user through the system UI.

License

Licensed under the MIT License.

Package Metadata

Repository: jaywcjlove/permissionflow

Default branch: main

README: README.md