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