From ba083b6dd35533e3c89b533bfee32070f307d16c Mon Sep 17 00:00:00 2001
From: Joshua Sattler <34030048+jsattler@users.noreply.github.com>
Date: Sat, 7 Feb 2026 22:06:59 +0100
Subject: [PATCH] chore: improve permission handling
---
BetterCapture.xcodeproj/project.pbxproj | 20 +--
BetterCapture/BetterCapture.entitlements | 2 +-
BetterCapture/BetterCaptureApp.swift | 4 +
.../Service/NotificationService.swift | 4 +-
BetterCapture/Service/PermissionService.swift | 135 ++++++++++++++++++
BetterCapture/View/MenuBarView.swift | 93 ++++++++++++
.../ViewModel/RecorderViewModel.swift | 15 ++
7 files changed, 260 insertions(+), 13 deletions(-)
create mode 100644 BetterCapture/Service/PermissionService.swift
diff --git a/BetterCapture.xcodeproj/project.pbxproj b/BetterCapture.xcodeproj/project.pbxproj
index fdb0c6c..b634e77 100644
--- a/BetterCapture.xcodeproj/project.pbxproj
+++ b/BetterCapture.xcodeproj/project.pbxproj
@@ -29,6 +29,16 @@
6C0990C62F2BE0C200D48100 /* BetterCaptureUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BetterCaptureUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
+/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
+ 6C0990B12F2BE0C100D48101 /* Exceptions for "BetterCapture" folder in "BetterCapture" target */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Info.plist,
+ );
+ target = 6C0990AE2F2BE0C100D48100 /* BetterCapture */;
+ };
+/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
+
/* Begin PBXFileSystemSynchronizedRootGroup section */
6C0990B12F2BE0C100D48100 /* BetterCapture */ = {
isa = PBXFileSystemSynchronizedRootGroup;
@@ -50,16 +60,6 @@
};
/* End PBXFileSystemSynchronizedRootGroup section */
-/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
- 6C0990B12F2BE0C100D48101 /* Exceptions for "BetterCapture" folder in "BetterCapture" target */ = {
- isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
- membershipExceptions = (
- Info.plist,
- );
- target = 6C0990AE2F2BE0C100D48100 /* BetterCapture */;
- };
-/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
-
/* Begin PBXFrameworksBuildPhase section */
6C0990AC2F2BE0C100D48100 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
diff --git a/BetterCapture/BetterCapture.entitlements b/BetterCapture/BetterCapture.entitlements
index d0ed538..4803372 100644
--- a/BetterCapture/BetterCapture.entitlements
+++ b/BetterCapture/BetterCapture.entitlements
@@ -8,7 +8,7 @@
com.apple.security.files.user-selected.read-write
- com.apple.security.files.movies.read-write
+ com.apple.security.assets.movies.read-write
com.apple.security.device.audio-input
diff --git a/BetterCapture/BetterCaptureApp.swift b/BetterCapture/BetterCaptureApp.swift
index 45204c0..0bbc2ad 100644
--- a/BetterCapture/BetterCaptureApp.swift
+++ b/BetterCapture/BetterCaptureApp.swift
@@ -16,6 +16,10 @@ struct BetterCaptureApp: App {
// Using .window style to support custom toggle switches
MenuBarExtra {
MenuBarView(viewModel: viewModel)
+ .task {
+ // Request permissions on first app launch
+ await viewModel.requestPermissionsOnLaunch()
+ }
} label: {
MenuBarLabel(viewModel: viewModel)
}
diff --git a/BetterCapture/Service/NotificationService.swift b/BetterCapture/Service/NotificationService.swift
index 2d30f22..e1d54f5 100644
--- a/BetterCapture/Service/NotificationService.swift
+++ b/BetterCapture/Service/NotificationService.swift
@@ -209,9 +209,9 @@ extension NotificationService: UNUserNotificationCenterDelegate {
switch response.actionIdentifier {
case NotificationIdentifier.actionShowInFinder,
- UNNotificationDefaultActionIdentifier where categoryIdentifier == NotificationIdentifier.categoryRecordingSaved:
+ UNNotificationDefaultActionIdentifier where await categoryIdentifier == NotificationIdentifier.categoryRecordingSaved:
// User tapped the notification or the "Show in Finder" action
- if let folderPath = userInfo[UserInfoKey.folderURL] as? String {
+ if let folderPath = await userInfo[UserInfoKey.folderURL] as? String {
await MainActor.run {
openFolderInFinder(path: folderPath)
}
diff --git a/BetterCapture/Service/PermissionService.swift b/BetterCapture/Service/PermissionService.swift
new file mode 100644
index 0000000..eb24898
--- /dev/null
+++ b/BetterCapture/Service/PermissionService.swift
@@ -0,0 +1,135 @@
+//
+// PermissionService.swift
+// BetterCapture
+//
+// Created by Joshua Sattler on 07.02.26.
+//
+
+import Foundation
+import ScreenCaptureKit
+import AVFoundation
+import OSLog
+import CoreGraphics
+import AppKit
+
+/// Service responsible for checking and requesting system permissions
+@MainActor
+@Observable
+final class PermissionService {
+
+ // MARK: - Permission States
+
+ enum PermissionState {
+ case unknown
+ case granted
+ case denied
+ }
+
+ private(set) var screenRecordingState: PermissionState = .unknown
+ private(set) var microphoneState: PermissionState = .unknown
+
+ var allPermissionsGranted: Bool {
+ screenRecordingState == .granted && microphoneState == .granted
+ }
+
+ var hasAnyPermissionDenied: Bool {
+ screenRecordingState == .denied || microphoneState == .denied
+ }
+
+ private let logger = Logger(
+ subsystem: Bundle.main.bundleIdentifier ?? "BetterCapture",
+ category: "PermissionService"
+ )
+
+ // MARK: - Initialization
+
+ init() {
+ updatePermissionStates()
+ }
+
+ // MARK: - Permission Checking
+
+ /// Updates all permission states
+ func updatePermissionStates() {
+ screenRecordingState = checkScreenRecordingPermission()
+ microphoneState = checkMicrophonePermission()
+
+ logger.info("Permission states - Screen: \(String(describing: self.screenRecordingState)), Microphone: \(String(describing: self.microphoneState))")
+ }
+
+ private func checkScreenRecordingPermission() -> PermissionState {
+ CGPreflightScreenCaptureAccess() ? .granted : .denied
+ }
+
+ private func checkMicrophonePermission() -> PermissionState {
+ switch AVCaptureDevice.authorizationStatus(for: .audio) {
+ case .authorized:
+ return .granted
+ case .notDetermined:
+ return .unknown
+ case .denied, .restricted:
+ return .denied
+ @unknown default:
+ return .unknown
+ }
+ }
+
+ // MARK: - Permission Requests
+
+ /// Requests required permissions on app launch
+ /// - Parameter includeMicrophone: Whether to also request microphone permission
+ func requestPermissions(includeMicrophone: Bool) async {
+ logger.info("Requesting permissions (includeMicrophone: \(includeMicrophone))...")
+
+ // Request screen recording permission first (synchronous)
+ requestScreenRecordingPermission()
+
+ // Request microphone permission only if needed (asynchronous)
+ if includeMicrophone {
+ await requestMicrophonePermission()
+ }
+
+ // Update states after requests
+ updatePermissionStates()
+ }
+
+ /// Requests screen recording permission
+ /// - Note: This will open System Settings if permission was previously denied
+ func requestScreenRecordingPermission() {
+ let wasGranted = CGRequestScreenCaptureAccess()
+ screenRecordingState = wasGranted ? .granted : .denied
+ logger.info("Screen recording permission request result: \(wasGranted)")
+ }
+
+ /// Requests microphone permission
+ func requestMicrophonePermission() async {
+ let status = AVCaptureDevice.authorizationStatus(for: .audio)
+
+ switch status {
+ case .authorized:
+ microphoneState = .granted
+ case .notDetermined:
+ let granted = await AVCaptureDevice.requestAccess(for: .audio)
+ microphoneState = granted ? .granted : .denied
+ logger.info("Microphone permission request result: \(granted)")
+ case .denied, .restricted:
+ microphoneState = .denied
+ @unknown default:
+ microphoneState = .unknown
+ }
+ }
+
+ /// Opens System Settings to the Screen Recording preferences pane
+ func openScreenRecordingSettings() {
+ if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") {
+ NSWorkspace.shared.open(url)
+ }
+ }
+
+ /// Opens System Settings to the Microphone preferences pane
+ func openMicrophoneSettings() {
+ if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone") {
+ NSWorkspace.shared.open(url)
+ }
+ }
+}
diff --git a/BetterCapture/View/MenuBarView.swift b/BetterCapture/View/MenuBarView.swift
index 419748c..fc83838 100644
--- a/BetterCapture/View/MenuBarView.swift
+++ b/BetterCapture/View/MenuBarView.swift
@@ -29,6 +29,16 @@ struct MenuBarView: View {
private var idleContent: some View {
VStack(spacing: 0) {
+ // Permission status banner (if required permissions are missing)
+ if viewModel.permissionService.screenRecordingState != .granted ||
+ (viewModel.settings.captureMicrophone && viewModel.permissionService.microphoneState != .granted) {
+ PermissionStatusBanner(
+ permissionService: viewModel.permissionService,
+ showMicrophonePermission: viewModel.settings.captureMicrophone
+ )
+ MenuBarDivider()
+ }
+
// Start Recording Button
MenuBarActionButton(
title: "Start Recording",
@@ -262,6 +272,89 @@ struct ContentSharingPickerButton: View {
}
}
+// MARK: - Permission Status Banner
+
+/// A banner showing missing permissions with buttons to open System Settings
+struct PermissionStatusBanner: View {
+ let permissionService: PermissionService
+ let showMicrophonePermission: Bool
+
+ var body: some View {
+ VStack(spacing: 4) {
+ HStack(spacing: 8) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.orange)
+ Text("Permissions Required")
+ .font(.system(size: 13, weight: .semibold))
+ Spacer()
+ }
+ .padding(.horizontal, 12)
+ .padding(.top, 8)
+
+ if permissionService.screenRecordingState != .granted {
+ PermissionRow(
+ title: "Screen Recording",
+ isGranted: false
+ ) {
+ permissionService.openScreenRecordingSettings()
+ }
+ }
+
+ if showMicrophonePermission && permissionService.microphoneState != .granted {
+ PermissionRow(
+ title: "Microphone",
+ isGranted: false
+ ) {
+ permissionService.openMicrophoneSettings()
+ }
+ }
+ }
+ .padding(.bottom, 8)
+ }
+}
+
+/// A single permission row with status and action button
+struct PermissionRow: View {
+ let title: String
+ let isGranted: Bool
+ let action: () -> Void
+ @State private var isHovered = false
+
+ var body: some View {
+ Button(action: action) {
+ HStack(spacing: 8) {
+ Image(systemName: isGranted ? "checkmark.circle.fill" : "xmark.circle.fill")
+ .foregroundStyle(isGranted ? .green : .red)
+ .font(.system(size: 12))
+
+ Text(title)
+ .font(.system(size: 12))
+ .foregroundStyle(.primary)
+
+ Spacer()
+
+ if !isGranted {
+ Text("Open Settings")
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 4)
+ .contentShape(.rect)
+ }
+ .buttonStyle(.plain)
+ .background(
+ RoundedRectangle(cornerRadius: 4)
+ .fill(isHovered ? .gray.opacity(0.1) : .clear)
+ .padding(.horizontal, 4)
+ )
+ .onHover { hovering in
+ isHovered = hovering
+ }
+ }
+}
+
// MARK: - Preview
#Preview {
diff --git a/BetterCapture/ViewModel/RecorderViewModel.swift b/BetterCapture/ViewModel/RecorderViewModel.swift
index 71dd51e..5e51b60 100644
--- a/BetterCapture/ViewModel/RecorderViewModel.swift
+++ b/BetterCapture/ViewModel/RecorderViewModel.swift
@@ -60,6 +60,7 @@ final class RecorderViewModel {
let audioDeviceService: AudioDeviceService
let previewService: PreviewService
let notificationService: NotificationService
+ let permissionService: PermissionService
private let captureEngine: CaptureEngine
private let assetWriter: AssetWriter
@@ -78,6 +79,7 @@ final class RecorderViewModel {
self.audioDeviceService = AudioDeviceService()
self.previewService = PreviewService()
self.notificationService = NotificationService()
+ self.permissionService = PermissionService()
self.captureEngine = CaptureEngine()
self.assetWriter = AssetWriter()
@@ -86,6 +88,19 @@ final class RecorderViewModel {
previewService.delegate = self
}
+ // MARK: - Permission Methods
+
+ /// Requests required permissions on app launch
+ /// Only requests microphone permission if microphone capture is enabled
+ func requestPermissionsOnLaunch() async {
+ await permissionService.requestPermissions(includeMicrophone: settings.captureMicrophone)
+ }
+
+ /// Refreshes the current permission states
+ func refreshPermissions() {
+ permissionService.updatePermissionStates()
+ }
+
// MARK: - Public Methods
/// Presents the system content sharing picker