import SwiftUI import AVFoundation import Cocoa import KeyboardShortcuts class PermissionManager: ObservableObject { @Published var audioPermissionStatus = AVCaptureDevice.authorizationStatus(for: .audio) @Published var isAccessibilityEnabled = false @Published var isScreenRecordingEnabled = false @Published var isKeyboardShortcutSet = false init() { // Start observing system events that might indicate permission changes setupNotificationObservers() // Initial permission checks checkAllPermissions() } deinit { NotificationCenter.default.removeObserver(self) } private func setupNotificationObservers() { // Only observe when app becomes active, as this is a likely time for permissions to have changed NotificationCenter.default.addObserver( self, selector: #selector(applicationDidBecomeActive), name: NSApplication.didBecomeActiveNotification, object: nil ) } @objc private func applicationDidBecomeActive() { checkAllPermissions() } func checkAllPermissions() { checkAccessibilityPermissions() checkScreenRecordingPermission() checkAudioPermissionStatus() checkKeyboardShortcut() } func checkAccessibilityPermissions() { let options: NSDictionary = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: false] let accessibilityEnabled = AXIsProcessTrustedWithOptions(options) DispatchQueue.main.async { self.isAccessibilityEnabled = accessibilityEnabled } } func checkScreenRecordingPermission() { DispatchQueue.main.async { self.isScreenRecordingEnabled = CGPreflightScreenCaptureAccess() } } func requestScreenRecordingPermission() { CGRequestScreenCaptureAccess() } func checkAudioPermissionStatus() { DispatchQueue.main.async { self.audioPermissionStatus = AVCaptureDevice.authorizationStatus(for: .audio) } } func requestAudioPermission() { AVCaptureDevice.requestAccess(for: .audio) { granted in DispatchQueue.main.async { self.audioPermissionStatus = granted ? .authorized : .denied } } } func checkKeyboardShortcut() { DispatchQueue.main.async { self.isKeyboardShortcutSet = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) != nil } } } struct PermissionCard: View { let icon: String let title: String let description: String let isGranted: Bool let buttonTitle: String let buttonAction: () -> Void let checkPermission: () -> Void var infoTipTitle: String? var infoTipMessage: String? var infoTipLink: String? @State private var isRefreshing = false var body: some View { VStack(alignment: .leading, spacing: 16) { HStack(spacing: 16) { // Icon with background ZStack { Circle() .fill(isGranted ? Color.green.opacity(0.15) : Color.orange.opacity(0.15)) .frame(width: 44, height: 44) Image(systemName: isGranted ? "\(icon).fill" : icon) .font(.system(size: 20, weight: .semibold)) .foregroundColor(isGranted ? .green : .orange) .symbolRenderingMode(.hierarchical) } VStack(alignment: .leading, spacing: 4) { HStack { Text(title) .font(.headline) if let infoTipTitle = infoTipTitle, let infoTipMessage = infoTipMessage { InfoTip( title: infoTipTitle, message: infoTipMessage, learnMoreURL: infoTipLink ?? "" ) } } Text(description) .font(.subheadline) .foregroundColor(.secondary) } Spacer() // Status indicator with refresh HStack(spacing: 12) { Button(action: { withAnimation(.easeInOut(duration: 0.5)) { isRefreshing = true } checkPermission() // Reset the animation after a delay DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { isRefreshing = false } }) { Image(systemName: "arrow.clockwise") .font(.system(size: 14, weight: .medium)) .foregroundColor(.secondary) .rotationEffect(.degrees(isRefreshing ? 360 : 0)) } .buttonStyle(.plain) .contentShape(Rectangle()) if isGranted { Image(systemName: "checkmark.seal.fill") .font(.system(size: 20)) .foregroundColor(.green) .symbolRenderingMode(.hierarchical) } else { Image(systemName: "xmark.seal.fill") .font(.system(size: 20)) .foregroundColor(.orange) .symbolRenderingMode(.hierarchical) } } } if !isGranted { Button(action: buttonAction) { HStack { Text(buttonTitle) Spacer() Image(systemName: "arrow.right") } .font(.headline) .foregroundColor(.white) .padding() .frame(maxWidth: .infinity) .background( LinearGradient( colors: [Color.accentColor, Color.accentColor.opacity(0.8)], startPoint: .leading, endPoint: .trailing ) ) .cornerRadius(10) } .buttonStyle(.plain) } } .padding() .background(CardBackground(isSelected: false)) .cornerRadius(16) .shadow(color: Color.black.opacity(0.05), radius: 5, y: 2) } } struct PermissionsView: View { @EnvironmentObject private var hotkeyManager: HotkeyManager @StateObject private var permissionManager = PermissionManager() var body: some View { ScrollView { VStack(spacing: 32) { // Header VStack(spacing: 24) { Image(systemName: "shield.lefthalf.filled") .font(.system(size: 40)) .foregroundStyle(.blue) .padding(20) .background(Circle() .fill(Color(.windowBackgroundColor).opacity(0.9)) .shadow(color: .black.opacity(0.1), radius: 10, y: 5)) VStack(spacing: 8) { Text("App Permissions") .font(.system(size: 28, weight: .bold)) Text("VoiceInk requires the following permissions to function properly") .font(.system(size: 15)) .foregroundStyle(.secondary) } } .padding(.vertical, 40) .frame(maxWidth: .infinity) // Permission Cards VStack(spacing: 16) { // Keyboard Shortcut Permission PermissionCard( icon: "keyboard", title: "Keyboard Shortcut", description: "Set up a keyboard shortcut to use VoiceInk anywhere", isGranted: hotkeyManager.selectedHotkey1 != .none, buttonTitle: "Configure Shortcut", buttonAction: { NotificationCenter.default.post( name: .navigateToDestination, object: nil, userInfo: ["destination": "Settings"] ) }, checkPermission: { permissionManager.checkKeyboardShortcut() } ) // Audio Permission PermissionCard( icon: "mic", title: "Microphone Access", description: "Allow VoiceInk to record your voice for transcription", isGranted: permissionManager.audioPermissionStatus == .authorized, buttonTitle: permissionManager.audioPermissionStatus == .notDetermined ? "Request Permission" : "Open System Settings", buttonAction: { if permissionManager.audioPermissionStatus == .notDetermined { permissionManager.requestAudioPermission() } else { if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone") { NSWorkspace.shared.open(url) } } }, checkPermission: { permissionManager.checkAudioPermissionStatus() } ) // Accessibility Permission PermissionCard( icon: "hand.raised", title: "Accessibility Access", description: "Allow VoiceInk to paste transcribed text directly at your cursor position", isGranted: permissionManager.isAccessibilityEnabled, buttonTitle: "Open System Settings", buttonAction: { if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { NSWorkspace.shared.open(url) } }, checkPermission: { permissionManager.checkAccessibilityPermissions() }, infoTipTitle: "Accessibility Access", infoTipMessage: "VoiceInk uses Accessibility permissions to paste the transcribed text directly into other applications at your cursor's position. This allows for a seamless dictation experience across your Mac." ) // Screen Recording Permission PermissionCard( icon: "rectangle.on.rectangle", title: "Screen Recording Access", description: "Allow VoiceInk to understand context from your screen for transcript Enhancement", isGranted: permissionManager.isScreenRecordingEnabled, buttonTitle: "Request Permission", buttonAction: { permissionManager.requestScreenRecordingPermission() // After requesting, open system preferences as fallback if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") { NSWorkspace.shared.open(url) } }, checkPermission: { permissionManager.checkScreenRecordingPermission() }, infoTipTitle: "Screen Recording Access", infoTipMessage: "VoiceInk captures on-screen text to understand the context of your voice input, which significantly improves transcription accuracy. Your privacy is important: this data is processed locally and is not stored.", infoTipLink: "https://tryvoiceink.com/docs/contextual-awareness" ) } } .padding(24) } .background(Color(NSColor.controlBackgroundColor)) .onAppear { permissionManager.checkAllPermissions() } } } #Preview { PermissionsView() }