Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions BetterCapture.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion BetterCapture/BetterCapture.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.movies.read-write</key>
<key>com.apple.security.assets.movies.read-write</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
Expand Down
4 changes: 4 additions & 0 deletions BetterCapture/BetterCaptureApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
4 changes: 2 additions & 2 deletions BetterCapture/Service/NotificationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
135 changes: 135 additions & 0 deletions BetterCapture/Service/PermissionService.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
93 changes: 93 additions & 0 deletions BetterCapture/View/MenuBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down
15 changes: 15 additions & 0 deletions BetterCapture/ViewModel/RecorderViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()

Expand All @@ -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
Expand Down