diff --git a/Apps/Keyty/Sources/Keyty/App/Lifecycle/AppController.swift b/Apps/Keyty/Sources/Keyty/App/Lifecycle/AppController.swift index 0f30a4d0..ae0beb72 100644 --- a/Apps/Keyty/Sources/Keyty/App/Lifecycle/AppController.swift +++ b/Apps/Keyty/Sources/Keyty/App/Lifecycle/AppController.swift @@ -38,11 +38,10 @@ final class AppController: NSObject { self.dependencies.captureController.onCapturingChanged = { [weak self] isCapturing in self?.statusItemController.isCapturing = isCapturing } - self.statusItemController.isAccessibilityGranted = - self.dependencies.permissionsService.status(for: .accessibility) == .granted + self.statusItemController.hasEventListeningPermission = self.dependencies.permissionsService.canCaptureInputEvents self.permissionsObservationToken = self.dependencies.permissionsService.observeChanges { [weak self] in Task { @MainActor [weak self] in - self?.updateAccessibilityStatus() + self?.updatePermissionStatus() } } } @@ -96,9 +95,8 @@ extension AppController: NSApplicationDelegate { // MARK: - Settings Presentation private extension AppController { - func updateAccessibilityStatus() { - self.statusItemController.isAccessibilityGranted = - self.dependencies.permissionsService.status(for: .accessibility) == .granted + func updatePermissionStatus() { + self.statusItemController.hasEventListeningPermission = self.dependencies.permissionsService.canCaptureInputEvents } func checkForUpdatesAtLaunchIfNeeded() { diff --git a/Apps/Keyty/Sources/Keyty/App/Shell/StatusItemController.swift b/Apps/Keyty/Sources/Keyty/App/Shell/StatusItemController.swift index 7cc33287..b979c030 100644 --- a/Apps/Keyty/Sources/Keyty/App/Shell/StatusItemController.swift +++ b/Apps/Keyty/Sources/Keyty/App/Shell/StatusItemController.swift @@ -14,7 +14,7 @@ final class StatusItemController { private let statusItem: NSStatusItem private var statusItemImage: NSImage { - if !self.isAccessibilityGranted { + if !self.hasEventListeningPermission { return NSImage.statusItemPermissionRequired } @@ -37,7 +37,7 @@ final class StatusItemController { } } - var isAccessibilityGranted: Bool = false { + var hasEventListeningPermission: Bool = false { didSet { self.updateStatusItemImage() } diff --git a/Apps/Keyty/Sources/Keyty/Features/PermissionsOnboarding/PermissionsOnboardingView.swift b/Apps/Keyty/Sources/Keyty/Features/PermissionsOnboarding/PermissionsOnboardingView.swift index 11fcf165..b4777976 100644 --- a/Apps/Keyty/Sources/Keyty/Features/PermissionsOnboarding/PermissionsOnboardingView.swift +++ b/Apps/Keyty/Sources/Keyty/Features/PermissionsOnboarding/PermissionsOnboardingView.swift @@ -48,7 +48,7 @@ struct PermissionsOnboardingView: View { .font(.system(size: 20, weight: .semibold)) .foregroundColor(Color.Theme.Text.primary) - Text(L10n.PermissionsOnboarding.subtitle) + Text(self.subtitle) .font(.system(size: 13, weight: .regular)) .foregroundColor(Color.Theme.Text.secondary) .multilineTextAlignment(.center) @@ -61,19 +61,56 @@ struct PermissionsOnboardingView: View { private var permissionsCard: some View { VStack(spacing: Spacing.none) { self.permissionRow( - iconName: "accessibility", + iconName: self.permissionIconName, iconBackgroundColor: Color.Theme.Accent.controlAccent, - title: L10n.PermissionsOnboarding.Accessibility.title, - detail: L10n.PermissionsOnboarding.Accessibility.detail, - status: self.viewModel.accessibilityStatus, - buttonTitle: L10n.PermissionsOnboarding.Accessibility.grantButton, - action: self.viewModel.requestAccessibility + title: self.permissionTitle, + detail: self.permissionDetail, + status: self.viewModel.inputCaptureStatus, + buttonTitle: self.permissionGrantButton, + action: self.viewModel.requestInputCapturePermission ) } .padding(.horizontal, Spacing.grid(4)) .background(self.cardBackground) } + private var subtitle: String { + switch self.viewModel.inputCapturePermission { + case .accessibility: + L10n.PermissionsOnboarding.Accessibility.subtitle + case .inputMonitoring: + L10n.PermissionsOnboarding.InputMonitoring.subtitle + } + } + + private var permissionIconName: String { + switch self.viewModel.inputCapturePermission { + case .accessibility: "accessibility" + case .inputMonitoring: "keyboard" + } + } + + private var permissionTitle: String { + switch self.viewModel.inputCapturePermission { + case .accessibility: L10n.PermissionsOnboarding.Accessibility.title + case .inputMonitoring: L10n.PermissionsOnboarding.InputMonitoring.title + } + } + + private var permissionDetail: String { + switch self.viewModel.inputCapturePermission { + case .accessibility: L10n.PermissionsOnboarding.Accessibility.detail + case .inputMonitoring: L10n.PermissionsOnboarding.InputMonitoring.detail + } + } + + private var permissionGrantButton: String { + switch self.viewModel.inputCapturePermission { + case .accessibility: L10n.PermissionsOnboarding.Accessibility.grantButton + case .inputMonitoring: L10n.PermissionsOnboarding.InputMonitoring.grantButton + } + } + private var privacyNote: some View { HStack(spacing: Spacing.none) { Text(L10n.PermissionsOnboarding.privacy) diff --git a/Apps/Keyty/Sources/Keyty/Features/PermissionsOnboarding/PermissionsOnboardingViewModel.swift b/Apps/Keyty/Sources/Keyty/Features/PermissionsOnboarding/PermissionsOnboardingViewModel.swift index a2afb6cf..1fab3e93 100644 --- a/Apps/Keyty/Sources/Keyty/Features/PermissionsOnboarding/PermissionsOnboardingViewModel.swift +++ b/Apps/Keyty/Sources/Keyty/Features/PermissionsOnboarding/PermissionsOnboardingViewModel.swift @@ -11,12 +11,14 @@ import SwiftUI @MainActor final class PermissionsOnboardingViewModel: ObservableObject { - var accessibilityStatus: Permission.Status { - self.permissionsService.status(for: .accessibility) + var inputCapturePermission: Permission { .inputCapture } + + var inputCaptureStatus: Permission.Status { + self.permissionsService.status(for: .inputCapture) } var isComplete: Bool { - self.accessibilityStatus == .granted + self.permissionsService.canCaptureInputEvents } var onCompletion: (() -> Void)? @@ -38,8 +40,8 @@ final class PermissionsOnboardingViewModel: ObservableObject { } } - func requestAccessibility() { - self.handlePermissionAction(for: .accessibility) + func requestInputCapturePermission() { + self.handlePermissionAction(for: .inputCapture) } func continueIfComplete() { diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Permissions/PermissionsSettingsPane.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Permissions/PermissionsSettingsPane.swift index abb67796..0644707a 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Permissions/PermissionsSettingsPane.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Permissions/PermissionsSettingsPane.swift @@ -19,17 +19,35 @@ struct PermissionsSettingsPane: View { SettingsStack { SettingsSectionView( title: L10n.Settings.Pane.permissions, - subtitle: L10n.Permissions.sectionSubtitle + subtitle: self.subtitle ) { permissionRow( - title: L10n.Permissions.accessibilityLabel, - status: model.accessibilityStatus, - action: model.requestAccessibility + title: self.permissionTitle, + status: model.inputCaptureStatus, + action: model.requestInputCapturePermission ) } } } + private var permissionTitle: String { + switch self.model.inputCapturePermission { + case .accessibility: + L10n.Permissions.accessibilityLabel + case .inputMonitoring: + L10n.Permissions.inputMonitoringLabel + } + } + + private var subtitle: String { + switch self.model.inputCapturePermission { + case .accessibility: + L10n.Permissions.accessibilitySectionSubtitle + case .inputMonitoring: + L10n.Permissions.inputMonitoringSectionSubtitle + } + } + private func permissionRow(title: String, status: Permission.Status, action: @escaping () -> Void) -> some View { HStack(alignment: .center, spacing: Spacing.md) { VStack(alignment: .leading, spacing: Spacing.none) { diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Permissions/PermissionsSettingsPaneViewModel.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Permissions/PermissionsSettingsPaneViewModel.swift index d07a5fea..65773ee2 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Permissions/PermissionsSettingsPaneViewModel.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Permissions/PermissionsSettingsPaneViewModel.swift @@ -10,14 +10,16 @@ import SwiftUI @MainActor final class PermissionsSettingsPaneViewModel: ObservableObject { - @Published private(set) var accessibilityStatus: Permission.Status + @Published private(set) var inputCaptureStatus: Permission.Status + + var inputCapturePermission: Permission { .inputCapture } private let permissionsService: any PermissionsService private var observationToken: PermissionObservationToken? init(permissionsService: any PermissionsService) { self.permissionsService = permissionsService - self.accessibilityStatus = self.permissionsService.status(for: .accessibility) + self.inputCaptureStatus = self.permissionsService.status(for: .inputCapture) self.observationToken = self.permissionsService.observeChanges { [weak self] in Task { @MainActor [weak self] in self?.refresh() @@ -25,13 +27,13 @@ final class PermissionsSettingsPaneViewModel: ObservableObject { } } - func requestAccessibility() { - self.handleAction(for: .accessibility) + func requestInputCapturePermission() { + self.handleAction(for: .inputCapture) self.refresh() } func refresh() { - self.accessibilityStatus = self.permissionsService.status(for: .accessibility) + self.inputCaptureStatus = self.permissionsService.status(for: .inputCapture) } private func handleAction(for permission: Permission) { diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift index 6a68600b..1dd06c05 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift @@ -72,9 +72,7 @@ final class SettingsWindowController: NSWindowController { } private func updatePermissionsBadge(permissionsService: any PermissionsService) { - let missingPermissionCount = Permission.allCases.filter { - permissionsService.status(for: $0) == .notGranted - }.count + let missingPermissionCount = permissionsService.status(for: .inputCapture) == .notGranted ? 1 : 0 self.sidebarViewModel.setBadgeCount(missingPermissionCount, for: .permissions) } diff --git a/Apps/Keyty/Sources/Keyty/Platform/Capture/CaptureController/CaptureController.swift b/Apps/Keyty/Sources/Keyty/Platform/Capture/CaptureController/CaptureController.swift index 1c18403f..02f686c4 100644 --- a/Apps/Keyty/Sources/Keyty/Platform/Capture/CaptureController/CaptureController.swift +++ b/Apps/Keyty/Sources/Keyty/Platform/Capture/CaptureController/CaptureController.swift @@ -86,14 +86,12 @@ private extension CaptureController { self.startObservingPermissionChanges() - let hasAccessibility = self.permissionsService.status(for: .accessibility) == .granted - - guard hasAccessibility else { + guard self.permissionsService.canCaptureInputEvents else { self.stopCapture() self.state = self.state == .capturing ? .blockedByPermission : .waitingForPermission // Only prompt on an explicit user action; launching must stay silent. if case .userEnabledCapture = trigger { - self.permissionsService.request(.accessibility) + self.permissionsService.request(.inputCapture) } return } diff --git a/Apps/Keyty/Sources/Keyty/Platform/Capture/EventTap/EventTap+Error.swift b/Apps/Keyty/Sources/Keyty/Platform/Capture/EventTap/EventTap+Error.swift index f6c60320..0ba1875a 100644 --- a/Apps/Keyty/Sources/Keyty/Platform/Capture/EventTap/EventTap+Error.swift +++ b/Apps/Keyty/Sources/Keyty/Platform/Capture/EventTap/EventTap+Error.swift @@ -10,7 +10,7 @@ import Cocoa extension EventTap { enum Error: LocalizedError, Equatable { - /// The system refused to create the tap's mach port, which is how a missing Accessibility grant surfaces. + /// The system refused to create the tap's mach port, which is how a missing event-listening grant surfaces. case portCreationFailed /// The mach port could not be attached to a run loop. diff --git a/Apps/Keyty/Sources/Keyty/Platform/Capture/EventTap/EventTap.swift b/Apps/Keyty/Sources/Keyty/Platform/Capture/EventTap/EventTap.swift index c32093e5..8bf43a56 100644 --- a/Apps/Keyty/Sources/Keyty/Platform/Capture/EventTap/EventTap.swift +++ b/Apps/Keyty/Sources/Keyty/Platform/Capture/EventTap/EventTap.swift @@ -61,8 +61,8 @@ extension EventTap { // MARK: - Tap Resources private extension EventTap { - /// Every event type the app visualizes. Creation fails as a whole when the - /// Accessibility grant is missing, so a `nil` port is a truthful capability signal. + /// Every event type the app visualizes. Creation fails as a whole when no + /// event-listening permission has been granted, so a `nil` port is a truthful capability signal. static let eventsOfInterest: CGEventMask = [ .keyDown, .keyUp, .systemDefined, .flagsChanged, .leftMouseDown, .leftMouseUp, .rightMouseDown, .rightMouseUp, diff --git a/Apps/Keyty/Sources/Keyty/Platform/Permissions/Permission.swift b/Apps/Keyty/Sources/Keyty/Platform/Permissions/Permission.swift index 98d370a5..763b019d 100644 --- a/Apps/Keyty/Sources/Keyty/Platform/Permissions/Permission.swift +++ b/Apps/Keyty/Sources/Keyty/Platform/Permissions/Permission.swift @@ -11,15 +11,35 @@ import ApplicationServices enum Permission: CaseIterable, Hashable { case accessibility + case inputMonitoring + + static var inputCapture: Self { + #if APP_STORE + .inputMonitoring + #else + .accessibility + #endif + } func isGranted() -> Bool { - AXIsProcessTrusted() + switch self { + case .accessibility: + AXIsProcessTrusted() + case .inputMonitoring: + CGPreflightListenEventAccess() + } } func requestSystemPermission() { - let key = kAXTrustedCheckOptionPrompt.takeRetainedValue() as String - AXIsProcessTrustedWithOptions([key: true] as CFDictionary) - NSWorkspace.shared.openAccessibilitySettings() + switch self { + case .accessibility: + let key = kAXTrustedCheckOptionPrompt.takeRetainedValue() as String + AXIsProcessTrustedWithOptions([key: true] as CFDictionary) + NSWorkspace.shared.openAccessibilitySettings() + case .inputMonitoring: + CGRequestListenEventAccess() + NSWorkspace.shared.openInputMonitoringSettings() + } } } diff --git a/Apps/Keyty/Sources/Keyty/Platform/Permissions/PermissionsService.swift b/Apps/Keyty/Sources/Keyty/Platform/Permissions/PermissionsService.swift index f28f366c..34dba6f9 100644 --- a/Apps/Keyty/Sources/Keyty/Platform/Permissions/PermissionsService.swift +++ b/Apps/Keyty/Sources/Keyty/Platform/Permissions/PermissionsService.swift @@ -14,6 +14,12 @@ protocol PermissionsService: AnyObject { func observeChanges(handler: @escaping () -> Void) -> PermissionObservationToken } +extension PermissionsService { + var canCaptureInputEvents: Bool { + self.status(for: .inputCapture) == .granted + } +} + /// Holds an observation; cancels it automatically on deinit. final class PermissionObservationToken { private let cancel: () -> Void diff --git a/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings index 50976895..cb4df3fe 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings @@ -239,21 +239,29 @@ /* Permissions settings pane */ "permissions.accessibility_label" = "Accessibility"; -"permissions.section_subtitle" = "Keyty needs Accessibility permission to observe and display your input events."; +"permissions.input_monitoring_label" = "Input Monitoring"; +"permissions.section_subtitle" = "Keyty can use Input Monitoring or Accessibility permission to observe and display your input events."; +"permissions.accessibility_section_subtitle" = "Keyty needs Accessibility permission to observe and display your input events."; +"permissions.input_monitoring_section_subtitle" = "Keyty needs Input Monitoring permission to observe and display your input events."; "permissions.status.granted" = "Granted"; "permissions.status.not_granted" = "Not Granted"; "permissions.grant_access_button" = "Grant…"; /* Permissions onboarding */ "permissions_onboarding.title" = "Set Up Keyty"; -"permissions_onboarding.subtitle" = "To enable keyboard and mouse visualization,\ngrant macOS Accessibility permission."; +"permissions_onboarding.subtitle" = "To enable keyboard and mouse visualization,\ngrant Input Monitoring or Accessibility permission."; +"permissions_onboarding.input_monitoring.title" = "Input Monitoring"; +"permissions_onboarding.input_monitoring.subtitle" = "To enable keyboard and mouse visualization,\ngrant macOS Input Monitoring permission."; +"permissions_onboarding.input_monitoring.detail" = "Preferred permission for displaying keyboard and mouse input."; +"permissions_onboarding.input_monitoring.grant_button" = "Grant…"; "permissions_onboarding.accessibility.title" = "Accessibility"; +"permissions_onboarding.accessibility.subtitle" = "To enable keyboard and mouse visualization,\ngrant macOS Accessibility permission."; "permissions_onboarding.accessibility.detail" = "Required to display keyboard and mouse input."; "permissions_onboarding.accessibility.grant_button" = "Grant…"; "permissions_onboarding.privacy" = "All input is processed locally on your Mac. Nothing is uploaded or stored."; "permissions_onboarding.learn_more" = "Learn more"; "permissions_onboarding.continue" = "Continue"; -"permissions_onboarding.continue_hint" = "Continue once Accessibility is granted."; +"permissions_onboarding.continue_hint" = "Continue once either permission is granted."; /* Update settings pane */ "update.check_at_startup" = "Check for updates at startup"; diff --git a/Apps/Keyty/Sources/Keyty/Support/Extensions/AppKit/NSWorkspace+AccessibilitySettings.swift b/Apps/Keyty/Sources/Keyty/Support/Extensions/AppKit/NSWorkspace+AccessibilitySettings.swift index 85301d2d..d14b1e36 100644 --- a/Apps/Keyty/Sources/Keyty/Support/Extensions/AppKit/NSWorkspace+AccessibilitySettings.swift +++ b/Apps/Keyty/Sources/Keyty/Support/Extensions/AppKit/NSWorkspace+AccessibilitySettings.swift @@ -12,4 +12,8 @@ extension NSWorkspace { func openAccessibilitySettings() { self.open(.accessibilitySettings) } + + func openInputMonitoringSettings() { + self.open(.inputMonitoringSettings) + } } diff --git a/Apps/Keyty/Sources/Keyty/Support/Extensions/Foundation/URL+AccessibilitySettings.swift b/Apps/Keyty/Sources/Keyty/Support/Extensions/Foundation/URL+AccessibilitySettings.swift index 518e42a0..1b052f3a 100644 --- a/Apps/Keyty/Sources/Keyty/Support/Extensions/Foundation/URL+AccessibilitySettings.swift +++ b/Apps/Keyty/Sources/Keyty/Support/Extensions/Foundation/URL+AccessibilitySettings.swift @@ -12,4 +12,8 @@ extension URL { static let accessibilitySettings = URL( string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" )! + + static let inputMonitoringSettings = URL( + string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent" + )! } diff --git a/Apps/Keyty/Tests/KeytyTests/Features/PermissionsOnboarding/PermissionsOnboardingViewModelTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/PermissionsOnboarding/PermissionsOnboardingViewModelTests.swift index 3767b23a..9e14bf02 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/PermissionsOnboarding/PermissionsOnboardingViewModelTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/PermissionsOnboarding/PermissionsOnboardingViewModelTests.swift @@ -11,45 +11,54 @@ import XCTest @MainActor final class PermissionsOnboardingViewModelTests: XCTestCase { - func testIsCompleteRequiresAccessibilityPermission() async { + func testIsCompleteWhenInputCapturePermissionIsGranted() async { let service = TestPermissionsService(statuses: [ - .accessibility: .granted, + .inputCapture: .granted, ]) let model = PermissionsOnboardingViewModel(permissionsService: service) XCTAssertTrue(model.isComplete) - service.statuses[.accessibility] = .notGranted + service.statuses[.inputCapture] = .notGranted service.notifyObservers() await Task.yield() XCTAssertFalse(model.isComplete) } - func testRequestAccessibilityForwardsAccessibilityRequest() { + func testIsNotCompleteWhenInactivePermissionIsGranted() { + let service = TestPermissionsService(statuses: [ + Self.inactiveInputCapturePermission: .granted, + ]) + let model = PermissionsOnboardingViewModel(permissionsService: service) + + XCTAssertFalse(model.isComplete) + } + + func testRequestInputCapturePermissionForwardsActivePermissionRequest() { let service = TestPermissionsService() let model = PermissionsOnboardingViewModel(permissionsService: service) - model.requestAccessibility() + model.requestInputCapturePermission() - XCTAssertEqual(service.requestedPermissions, [.accessibility]) + XCTAssertEqual(service.requestedPermissions, [.inputCapture]) } - func testCompletionDoesNotRunWhenAccessibilityBecomesGranted() async { + func testCompletionDoesNotRunWhenInputCapturePermissionBecomesGranted() async { let service = TestPermissionsService() let model = PermissionsOnboardingViewModel(permissionsService: service) var completionCount = 0 model.onCompletion = { completionCount += 1 } - service.statuses[.accessibility] = .granted + service.statuses[.inputCapture] = .granted service.notifyObservers() await Task.yield() XCTAssertEqual(completionCount, 0) } - func testContinueRunsCompletionWhenAccessibilityIsGranted() { + func testContinueRunsCompletionWhenInputCapturePermissionIsGranted() { let service = TestPermissionsService(statuses: [ - .accessibility: .granted, + .inputCapture: .granted, ]) let model = PermissionsOnboardingViewModel(permissionsService: service) var completionCount = 0 @@ -60,7 +69,7 @@ final class PermissionsOnboardingViewModelTests: XCTestCase { XCTAssertEqual(completionCount, 1) } - func testContinueDoesNotRunCompletionWhenAccessibilityIsNotGranted() { + func testContinueDoesNotRunCompletionWhenInputCapturePermissionIsNotGranted() { let service = TestPermissionsService() let model = PermissionsOnboardingViewModel(permissionsService: service) var completionCount = 0 @@ -70,6 +79,10 @@ final class PermissionsOnboardingViewModelTests: XCTestCase { XCTAssertEqual(completionCount, 0) } + + private static var inactiveInputCapturePermission: Permission { + Permission.inputCapture == .inputMonitoring ? .accessibility : .inputMonitoring + } } private final class TestPermissionsService: PermissionsService { diff --git a/Apps/Keyty/Tests/KeytyTests/Platform/Capture/CaptureControllerTests.swift b/Apps/Keyty/Tests/KeytyTests/Platform/Capture/CaptureControllerTests.swift index a9335104..82148e93 100644 --- a/Apps/Keyty/Tests/KeytyTests/Platform/Capture/CaptureControllerTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Platform/Capture/CaptureControllerTests.swift @@ -56,6 +56,16 @@ final class CaptureControllerTests: XCTestCase { XCTAssertTrue(self.controller.isCapturing) } + func testStartInstallsTapWhenOnlyInputCapturePermissionIsGranted() { + self.permissionsService.currentStatus = .notGranted + self.permissionsService.statuses[.inputCapture] = .granted + + self.controller.start() + + XCTAssertEqual(self.eventTap.installCount, 1) + XCTAssertTrue(self.controller.isCapturing) + } + func testStartDoesNotInstallTapWhenPermissionIsNotGranted() { self.permissionsService.currentStatus = .notGranted @@ -73,6 +83,15 @@ final class CaptureControllerTests: XCTestCase { XCTAssertEqual(self.permissionsService.requestedPermissions, []) } + func testExplicitCaptureEnableRequestsInputCapturePermission() { + self.permissionsService.currentStatus = .notGranted + self.controller.stopCapturing() + + self.controller.toggleCapturing() + + XCTAssertEqual(self.permissionsService.requestedPermissions, [.inputCapture]) + } + func testCapturingStartsOncePermissionIsGrantedLater() { self.permissionsService.currentStatus = .notGranted self.controller.start() @@ -231,11 +250,12 @@ private final class TestEventTap: EventTapping { private final class TestPermissionsService: PermissionsService { var currentStatus: Permission.Status = .granted + var statuses: [Permission: Permission.Status] = [:] private(set) var requestedPermissions: [Permission] = [] private var observers: [UUID: () -> Void] = [:] func status(for permission: Permission) -> Permission.Status { - self.currentStatus + self.statuses[permission] ?? self.currentStatus } func request(_ permission: Permission) { diff --git a/Apps/Keyty/Tests/KeytyTests/Platform/Permissions/SystemPermissionsServiceTests.swift b/Apps/Keyty/Tests/KeytyTests/Platform/Permissions/SystemPermissionsServiceTests.swift index 4d52cc34..8ec0744b 100644 --- a/Apps/Keyty/Tests/KeytyTests/Platform/Permissions/SystemPermissionsServiceTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Platform/Permissions/SystemPermissionsServiceTests.swift @@ -24,6 +24,24 @@ final class SystemPermissionsServiceTests: XCTestCase { XCTAssertEqual(service.status(for: .accessibility), .granted) } + func testInputMonitoringStatusIsGrantedWhenSystemGrantsPermission() { + let provider = TestPermissionsProvider(grantedPermissions: [.inputMonitoring]) + let service = SystemPermissionsService(provider: provider) + + XCTAssertEqual(service.status(for: .inputMonitoring), .granted) + } + + func testCanCaptureInputEventsWhenActivePermissionIsGranted() { + let activeProvider = TestPermissionsProvider(grantedPermissions: [.inputCapture]) + let inactivePermission: Permission = Permission.inputCapture == .inputMonitoring ? .accessibility : .inputMonitoring + let inactiveProvider = TestPermissionsProvider(grantedPermissions: [inactivePermission]) + let deniedProvider = TestPermissionsProvider() + + XCTAssertTrue(SystemPermissionsService(provider: activeProvider).canCaptureInputEvents) + XCTAssertFalse(SystemPermissionsService(provider: inactiveProvider).canCaptureInputEvents) + XCTAssertFalse(SystemPermissionsService(provider: deniedProvider).canCaptureInputEvents) + } + func testStatusFollowsSystemStateWithoutRememberingPastRequests() { let provider = TestPermissionsProvider() let service = SystemPermissionsService(provider: provider) @@ -50,6 +68,18 @@ final class SystemPermissionsServiceTests: XCTestCase { XCTAssertEqual(provider.requestedPermissions, [.accessibility]) } + func testInputMonitoringRequestIsForwardedOnlyWhenPermissionIsNotGranted() { + let provider = TestPermissionsProvider(grantedPermissions: [.inputMonitoring]) + let service = SystemPermissionsService(provider: provider) + + service.request(.inputMonitoring) + XCTAssertEqual(provider.requestedPermissions, []) + + provider.grantedPermissions = [] + service.request(.inputMonitoring) + XCTAssertEqual(provider.requestedPermissions, [.inputMonitoring]) + } + func testObserverIsNotifiedWhenSystemStatusChanges() { let provider = TestPermissionsProvider() let service = SystemPermissionsService(provider: provider) diff --git a/Docs/BUILD.md b/Docs/BUILD.md index 24d66865..6f37d993 100644 --- a/Docs/BUILD.md +++ b/Docs/BUILD.md @@ -139,10 +139,10 @@ xcodebuild test \ ## Code Signing -Keyty observes input through macOS Accessibility APIs, so local builds should use +Keyty observes input through macOS Accessibility APIs in non-App-Store builds, so local builds should use a stable Apple Development signature. Avoid adding `CODE_SIGNING_ALLOWED=NO` to local builds: ad-hoc code signatures can change between builds, and macOS may -silently drop the app's Accessibility grant. +silently drop the app's privacy grant. The checked-in project settings use the maintainer team `NEVA4MAZBL`. To build and run locally with your own signing identity, sign in to Xcode with your Apple @@ -150,5 +150,5 @@ ID and change the team in **Signing & Capabilities** after generating the project. Unsigned builds are only for non-interactive CI jobs that do not need a stable -Accessibility permission grant. The CI-only unsigned flags live in +privacy permission grant. The CI-only unsigned flags live in [`.github/workflows/build.yml`](../.github/workflows/build.yml). diff --git a/Docs/DEVELOPING.md b/Docs/DEVELOPING.md index 5a9e1094..d2e66632 100644 --- a/Docs/DEVELOPING.md +++ b/Docs/DEVELOPING.md @@ -44,7 +44,7 @@ Run the test suite from Xcode with `Cmd+U`, or use the `xcodebuild test` command ## Permissions for Local Development -Keyty needs macOS `Accessibility` permission to capture and visualize input events, including when running a debug build from Xcode. +Keyty needs macOS `Accessibility` permission to capture and visualize input events in non-App-Store builds, including when running a debug build from Xcode. App Store builds use `Input Monitoring` instead. If a local build is not receiving events correctly, the most common cause is stale or missing macOS permission entries for the running app bundle. @@ -66,7 +66,7 @@ The main project lives under `Apps/Keyty`. ## Common Issues - No keyboard or mouse events appear: - Check macOS permissions first. Old app references in **Accessibility** are a common cause. + Check macOS permissions first. Old app references in **Accessibility** (or **Input Monitoring** for the App Store build) are a common cause. - Swift packages do not resolve: Reopen the project in Xcode and let Swift Package Manager refresh dependencies. - Xcode project changes are missing: diff --git a/Docs/PERMISSIONS.md b/Docs/PERMISSIONS.md index d0d22d0a..419b844a 100644 --- a/Docs/PERMISSIONS.md +++ b/Docs/PERMISSIONS.md @@ -4,9 +4,10 @@ Keyty needs macOS privacy permission to capture and display keyboard and mouse a ## Required Permissions -Keyty uses the following macOS permission: +Keyty uses a target-specific macOS permission: -- `Accessibility`: allows Keyty to observe input events needed for keyboard and mouse visualization +- App Store builds use `Input Monitoring` to passively observe the input events needed for keyboard and mouse visualization. +- Non-App-Store builds use `Accessibility`. ## Granting Permissions @@ -14,7 +15,9 @@ To grant permissions on current macOS versions: 1. Open **System Settings**. 2. Go to **Privacy & Security**. -3. Open **Accessibility** and enable `Keyty`. +3. Open the permission required by your Keyty build and enable `Keyty`: + - App Store: **Input Monitoring** + - Non-App-Store: **Accessibility** 4. Restart Keyty if macOS asks you to do so. If `Keyty` is not listed yet, launch the app once and return to these settings after macOS has registered it. @@ -31,9 +34,9 @@ If Keyty is running but no keyboard or mouse activity appears: 1. Quit Keyty. 2. Open **System Settings > Privacy & Security**. -3. Remove any existing `Keyty` entries from **Accessibility** if they refer to an older app build or moved app bundle. +3. Check the permission required by your build for existing `Keyty` entries that refer to an older app build or moved app bundle. 4. Launch Keyty again. -5. Re-enable `Keyty` in **Accessibility**. +5. Re-enable `Keyty` in that permission pane. 6. Restart the app if macOS requires it. Common causes: @@ -51,11 +54,15 @@ If the app is still listed in System Settings but macOS does not deliver input e 2. Run: ```bash + # App Store build + tccutil reset ListenEvent app.keyty.Keyty.AppStore + + # Non-App-Store build tccutil reset Accessibility app.keyty.Keyty ``` 3. Launch Keyty. -4. Re-enable `Keyty` in **Accessibility**. +4. Re-enable `Keyty` in the corresponding permission pane. 5. Restart Keyty if macOS asks you to do so. These commands reset only Keyty's entries for the relevant privacy services. Avoid broader resets such as `tccutil reset All` unless you intentionally want macOS to forget privacy decisions for other apps too. diff --git a/Docs/PRIVACY.md b/Docs/PRIVACY.md index b293a4c9..fad22167 100644 --- a/Docs/PRIVACY.md +++ b/Docs/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy -Keyty visualizes keyboard and mouse activity on screen. Because that requires observing input events, Keyty asks macOS for Accessibility permission. +Keyty visualizes keyboard and mouse activity on screen. Because that requires observing input events, App Store builds ask macOS for Input Monitoring permission and non-App-Store builds ask for Accessibility permission. ## Input Events @@ -42,6 +42,6 @@ Automatic update checks and anonymous system profile sharing can be controlled f ## Permissions -Accessibility permission is required so Keyty can observe input events and render them in the overlay. +The permission required to observe input events and render the overlay depends on the build: Input Monitoring for App Store builds, or Accessibility for non-App-Store builds. For setup and troubleshooting details, see [PERMISSIONS.md](PERMISSIONS.md).