From abe0662bf93775b536dd0a9f29af9dae2cb92bca Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sun, 13 Sep 2026 19:47:52 +0200 Subject: [PATCH 01/12] feat(permissions): add input monitoring settings links --- .../Extensions/AppKit/NSWorkspace+AccessibilitySettings.swift | 4 ++++ .../Extensions/Foundation/URL+AccessibilitySettings.swift | 4 ++++ 2 files changed, 8 insertions(+) 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 85301d2..d14b1e3 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 518e42a..1b052f3 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" + )! } From 0293669b3936f8a3612d775a1ae2a4a3f9d6b334 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sun, 13 Sep 2026 19:52:49 +0200 Subject: [PATCH 02/12] feat(permissions): support input monitoring access --- .../Platform/Permissions/Permission.swift | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Platform/Permissions/Permission.swift b/Apps/Keyty/Sources/Keyty/Platform/Permissions/Permission.swift index 98d370a..5daad68 100644 --- a/Apps/Keyty/Sources/Keyty/Platform/Permissions/Permission.swift +++ b/Apps/Keyty/Sources/Keyty/Platform/Permissions/Permission.swift @@ -11,15 +11,27 @@ import ApplicationServices enum Permission: CaseIterable, Hashable { case accessibility + case inputMonitoring 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() + } } } From 6e252f5f1f3a17ebb6db234a2a66abd75ee7ece4 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sun, 13 Sep 2026 19:54:46 +0200 Subject: [PATCH 03/12] chore(capture): clarify event-listening permission errors --- .../Keyty/Platform/Capture/EventTap/EventTap+Error.swift | 2 +- .../Sources/Keyty/Platform/Capture/EventTap/EventTap.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 f6c6032..0ba1875 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 c32093e..8bf43a5 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, From 5c47d8013fcff46e11e28def2b1f75c94cdd3017 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sun, 13 Sep 2026 20:14:57 +0200 Subject: [PATCH 04/12] feat(permissions): centralize input capture access --- .../Keyty/App/Lifecycle/AppController.swift | 10 +++---- .../App/Shell/StatusItemController.swift | 4 +-- .../CaptureController/CaptureController.swift | 6 ++-- .../Permissions/PermissionsService.swift | 7 +++++ .../SystemPermissionsServiceTests.swift | 29 +++++++++++++++++++ 5 files changed, 44 insertions(+), 12 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/App/Lifecycle/AppController.swift b/Apps/Keyty/Sources/Keyty/App/Lifecycle/AppController.swift index 0f30a4d..ae0beb7 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 7cc3328..b979c03 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/Platform/Capture/CaptureController/CaptureController.swift b/Apps/Keyty/Sources/Keyty/Platform/Capture/CaptureController/CaptureController.swift index 1c18403..816caad 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(.inputMonitoring) } return } diff --git a/Apps/Keyty/Sources/Keyty/Platform/Permissions/PermissionsService.swift b/Apps/Keyty/Sources/Keyty/Platform/Permissions/PermissionsService.swift index f28f366..376a8f2 100644 --- a/Apps/Keyty/Sources/Keyty/Platform/Permissions/PermissionsService.swift +++ b/Apps/Keyty/Sources/Keyty/Platform/Permissions/PermissionsService.swift @@ -14,6 +14,13 @@ protocol PermissionsService: AnyObject { func observeChanges(handler: @escaping () -> Void) -> PermissionObservationToken } +extension PermissionsService { + var canCaptureInputEvents: Bool { + self.status(for: .inputMonitoring) == .granted || + self.status(for: .accessibility) == .granted + } +} + /// Holds an observation; cancels it automatically on deinit. final class PermissionObservationToken { private let cancel: () -> Void diff --git a/Apps/Keyty/Tests/KeytyTests/Platform/Permissions/SystemPermissionsServiceTests.swift b/Apps/Keyty/Tests/KeytyTests/Platform/Permissions/SystemPermissionsServiceTests.swift index 4d52cc3..377b951 100644 --- a/Apps/Keyty/Tests/KeytyTests/Platform/Permissions/SystemPermissionsServiceTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Platform/Permissions/SystemPermissionsServiceTests.swift @@ -24,6 +24,23 @@ 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 testCanCaptureInputEventsWhenEitherPermissionIsGranted() { + let inputMonitoringProvider = TestPermissionsProvider(grantedPermissions: [.inputMonitoring]) + let accessibilityProvider = TestPermissionsProvider(grantedPermissions: [.accessibility]) + let deniedProvider = TestPermissionsProvider() + + XCTAssertTrue(SystemPermissionsService(provider: inputMonitoringProvider).canCaptureInputEvents) + XCTAssertTrue(SystemPermissionsService(provider: accessibilityProvider).canCaptureInputEvents) + XCTAssertFalse(SystemPermissionsService(provider: deniedProvider).canCaptureInputEvents) + } + func testStatusFollowsSystemStateWithoutRememberingPastRequests() { let provider = TestPermissionsProvider() let service = SystemPermissionsService(provider: provider) @@ -50,6 +67,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) From e5f0285a25e61818b4d19364ddf5ac7d745cee12 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sun, 13 Sep 2026 23:09:14 +0200 Subject: [PATCH 05/12] feat(permissions): add input monitoring permission UI --- .../PermissionsOnboardingView.swift | 9 +++++ .../PermissionsOnboardingViewModel.swift | 10 ++++- .../Permissions/PermissionsSettingsPane.swift | 6 +++ .../PermissionsSettingsPaneViewModel.swift | 8 ++++ .../Keyty/Presentation/Tokens/Size.swift | 2 +- .../Resources/en.lproj/Localizable.strings | 10 +++-- .../PermissionsOnboardingViewModelTests.swift | 39 ++++++++++++++++++- 7 files changed, 78 insertions(+), 6 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Features/PermissionsOnboarding/PermissionsOnboardingView.swift b/Apps/Keyty/Sources/Keyty/Features/PermissionsOnboarding/PermissionsOnboardingView.swift index 11fcf16..9ce1632 100644 --- a/Apps/Keyty/Sources/Keyty/Features/PermissionsOnboarding/PermissionsOnboardingView.swift +++ b/Apps/Keyty/Sources/Keyty/Features/PermissionsOnboarding/PermissionsOnboardingView.swift @@ -60,6 +60,15 @@ struct PermissionsOnboardingView: View { private var permissionsCard: some View { VStack(spacing: Spacing.none) { + self.permissionRow( + iconName: "keyboard", + iconBackgroundColor: Color.Theme.Accent.controlAccent, + title: L10n.PermissionsOnboarding.InputMonitoring.title, + detail: L10n.PermissionsOnboarding.InputMonitoring.detail, + status: self.viewModel.inputMonitoringStatus, + buttonTitle: L10n.PermissionsOnboarding.InputMonitoring.grantButton, + action: self.viewModel.requestInputMonitoring + ) self.permissionRow( iconName: "accessibility", iconBackgroundColor: Color.Theme.Accent.controlAccent, diff --git a/Apps/Keyty/Sources/Keyty/Features/PermissionsOnboarding/PermissionsOnboardingViewModel.swift b/Apps/Keyty/Sources/Keyty/Features/PermissionsOnboarding/PermissionsOnboardingViewModel.swift index a2afb6c..eaa7242 100644 --- a/Apps/Keyty/Sources/Keyty/Features/PermissionsOnboarding/PermissionsOnboardingViewModel.swift +++ b/Apps/Keyty/Sources/Keyty/Features/PermissionsOnboarding/PermissionsOnboardingViewModel.swift @@ -15,8 +15,12 @@ final class PermissionsOnboardingViewModel: ObservableObject { self.permissionsService.status(for: .accessibility) } + var inputMonitoringStatus: Permission.Status { + self.permissionsService.status(for: .inputMonitoring) + } + var isComplete: Bool { - self.accessibilityStatus == .granted + self.inputMonitoringStatus == .granted || self.accessibilityStatus == .granted } var onCompletion: (() -> Void)? @@ -42,6 +46,10 @@ final class PermissionsOnboardingViewModel: ObservableObject { self.handlePermissionAction(for: .accessibility) } + func requestInputMonitoring() { + self.handlePermissionAction(for: .inputMonitoring) + } + func continueIfComplete() { guard self.isComplete else { return } self.onCompletion?() diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Permissions/PermissionsSettingsPane.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Permissions/PermissionsSettingsPane.swift index abb6779..482a787 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Permissions/PermissionsSettingsPane.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Permissions/PermissionsSettingsPane.swift @@ -21,6 +21,12 @@ struct PermissionsSettingsPane: View { title: L10n.Settings.Pane.permissions, subtitle: L10n.Permissions.sectionSubtitle ) { + permissionRow( + title: L10n.Permissions.inputMonitoringLabel, + status: model.inputMonitoringStatus, + action: model.requestInputMonitoring + ) + Divider() permissionRow( title: L10n.Permissions.accessibilityLabel, status: model.accessibilityStatus, diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Permissions/PermissionsSettingsPaneViewModel.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Permissions/PermissionsSettingsPaneViewModel.swift index d07a5fe..44ff2da 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Permissions/PermissionsSettingsPaneViewModel.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Permissions/PermissionsSettingsPaneViewModel.swift @@ -11,6 +11,7 @@ import SwiftUI @MainActor final class PermissionsSettingsPaneViewModel: ObservableObject { @Published private(set) var accessibilityStatus: Permission.Status + @Published private(set) var inputMonitoringStatus: Permission.Status private let permissionsService: any PermissionsService private var observationToken: PermissionObservationToken? @@ -18,6 +19,7 @@ final class PermissionsSettingsPaneViewModel: ObservableObject { init(permissionsService: any PermissionsService) { self.permissionsService = permissionsService self.accessibilityStatus = self.permissionsService.status(for: .accessibility) + self.inputMonitoringStatus = self.permissionsService.status(for: .inputMonitoring) self.observationToken = self.permissionsService.observeChanges { [weak self] in Task { @MainActor [weak self] in self?.refresh() @@ -30,8 +32,14 @@ final class PermissionsSettingsPaneViewModel: ObservableObject { self.refresh() } + func requestInputMonitoring() { + self.handleAction(for: .inputMonitoring) + self.refresh() + } + func refresh() { self.accessibilityStatus = self.permissionsService.status(for: .accessibility) + self.inputMonitoringStatus = self.permissionsService.status(for: .inputMonitoring) } private func handleAction(for permission: Permission) { diff --git a/Apps/Keyty/Sources/Keyty/Presentation/Tokens/Size.swift b/Apps/Keyty/Sources/Keyty/Presentation/Tokens/Size.swift index 9c913c5..2d93f7b 100644 --- a/Apps/Keyty/Sources/Keyty/Presentation/Tokens/Size.swift +++ b/Apps/Keyty/Sources/Keyty/Presentation/Tokens/Size.swift @@ -13,7 +13,7 @@ enum Size { enum Window { static let about = CGSize(width: Spacing.grid(155), height: Spacing.grid(91)) - static let permissionsOnboarding = CGSize(width: Spacing.grid(165), height: Spacing.grid(96)) + static let permissionsOnboarding = CGSize(width: Spacing.grid(165), height: Spacing.grid(108)) static let settings = CGSize(width: Spacing.grid(180) * 1.1, height: Spacing.grid(110) * 1.1) } diff --git a/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings index 5097689..189304a 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings @@ -239,21 +239,25 @@ /* 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.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.detail" = "Preferred permission for displaying keyboard and mouse input."; +"permissions_onboarding.input_monitoring.grant_button" = "Grant…"; "permissions_onboarding.accessibility.title" = "Accessibility"; "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/Tests/KeytyTests/Features/PermissionsOnboarding/PermissionsOnboardingViewModelTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/PermissionsOnboarding/PermissionsOnboardingViewModelTests.swift index 3767b23..8197bcb 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/PermissionsOnboarding/PermissionsOnboardingViewModelTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/PermissionsOnboarding/PermissionsOnboardingViewModelTests.swift @@ -11,7 +11,7 @@ import XCTest @MainActor final class PermissionsOnboardingViewModelTests: XCTestCase { - func testIsCompleteRequiresAccessibilityPermission() async { + func testIsCompleteWhenAccessibilityPermissionIsGranted() async { let service = TestPermissionsService(statuses: [ .accessibility: .granted, ]) @@ -26,6 +26,21 @@ final class PermissionsOnboardingViewModelTests: XCTestCase { XCTAssertFalse(model.isComplete) } + func testIsCompleteWhenInputMonitoringPermissionIsGranted() async { + let service = TestPermissionsService(statuses: [ + .inputMonitoring: .granted, + ]) + let model = PermissionsOnboardingViewModel(permissionsService: service) + + XCTAssertTrue(model.isComplete) + + service.statuses[.inputMonitoring] = .notGranted + service.notifyObservers() + await Task.yield() + + XCTAssertFalse(model.isComplete) + } + func testRequestAccessibilityForwardsAccessibilityRequest() { let service = TestPermissionsService() let model = PermissionsOnboardingViewModel(permissionsService: service) @@ -35,6 +50,15 @@ final class PermissionsOnboardingViewModelTests: XCTestCase { XCTAssertEqual(service.requestedPermissions, [.accessibility]) } + func testRequestInputMonitoringForwardsInputMonitoringRequest() { + let service = TestPermissionsService() + let model = PermissionsOnboardingViewModel(permissionsService: service) + + model.requestInputMonitoring() + + XCTAssertEqual(service.requestedPermissions, [.inputMonitoring]) + } + func testCompletionDoesNotRunWhenAccessibilityBecomesGranted() async { let service = TestPermissionsService() let model = PermissionsOnboardingViewModel(permissionsService: service) @@ -60,6 +84,19 @@ final class PermissionsOnboardingViewModelTests: XCTestCase { XCTAssertEqual(completionCount, 1) } + func testContinueRunsCompletionWhenInputMonitoringIsGranted() { + let service = TestPermissionsService(statuses: [ + .inputMonitoring: .granted, + ]) + let model = PermissionsOnboardingViewModel(permissionsService: service) + var completionCount = 0 + model.onCompletion = { completionCount += 1 } + + model.continueIfComplete() + + XCTAssertEqual(completionCount, 1) + } + func testContinueDoesNotRunCompletionWhenAccessibilityIsNotGranted() { let service = TestPermissionsService() let model = PermissionsOnboardingViewModel(permissionsService: service) From 8c2c3bd1368d55f6e0b67336119d4ea9a6ac3542 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sun, 13 Sep 2026 23:09:24 +0200 Subject: [PATCH 06/12] test(capture): cover input monitoring access --- .../Capture/CaptureControllerTests.swift | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/Apps/Keyty/Tests/KeytyTests/Platform/Capture/CaptureControllerTests.swift b/Apps/Keyty/Tests/KeytyTests/Platform/Capture/CaptureControllerTests.swift index a933510..867ba64 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 testStartInstallsTapWhenOnlyInputMonitoringIsGranted() { + self.permissionsService.currentStatus = .notGranted + self.permissionsService.statuses[.inputMonitoring] = .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 testExplicitCaptureEnableRequestsInputMonitoring() { + self.permissionsService.currentStatus = .notGranted + self.controller.stopCapturing() + + self.controller.toggleCapturing() + + XCTAssertEqual(self.permissionsService.requestedPermissions, [.inputMonitoring]) + } + 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) { From e487ef1b93154f9d1b422ee2351603cdd027bbfc Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sun, 13 Sep 2026 23:09:33 +0200 Subject: [PATCH 07/12] docs(permissions): document input monitoring access --- Docs/BUILD.md | 6 +++--- Docs/DEVELOPING.md | 4 ++-- Docs/PERMISSIONS.md | 17 ++++++++++------- Docs/PRIVACY.md | 4 ++-- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/Docs/BUILD.md b/Docs/BUILD.md index 24d6686..48641c1 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 Input Monitoring APIs (with Accessibility as a fallback), 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 5a9e109..1e8ad85 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 `Input Monitoring` permission to capture and visualize input events, including when running a debug build from Xcode. `Accessibility` remains available as a fallback for existing installs. 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 **Input Monitoring** or **Accessibility** 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 d0d22d0..6e90eb5 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 supports the following macOS permissions: -- `Accessibility`: allows Keyty to observe input events needed for keyboard and mouse visualization +- `Input Monitoring` (preferred): allows Keyty to passively observe the input events needed for keyboard and mouse visualization. +- `Accessibility` (fallback): remains supported for existing installs and grants. ## Granting Permissions @@ -14,9 +15,11 @@ To grant permissions on current macOS versions: 1. Open **System Settings**. 2. Go to **Privacy & Security**. -3. Open **Accessibility** and enable `Keyty`. +3. Open **Input Monitoring** and enable `Keyty`. 4. Restart Keyty if macOS asks you to do so. +Accessibility remains available as a fallback in Keyty's **Permissions** pane, but only one of these permissions is required. + If `Keyty` is not listed yet, launch the app once and return to these settings after macOS has registered it. ## Permissions in App @@ -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 **Input Monitoring** and **Accessibility** 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 **Input Monitoring**. 6. Restart the app if macOS requires it. Common causes: @@ -51,11 +54,11 @@ If the app is still listed in System Settings but macOS does not deliver input e 2. Run: ```bash - tccutil reset Accessibility app.keyty.Keyty + tccutil reset ListenEvent app.keyty.Keyty ``` 3. Launch Keyty. -4. Re-enable `Keyty` in **Accessibility**. +4. Re-enable `Keyty` in **Input Monitoring**. 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 b293a4c..1cbca21 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, Keyty asks macOS for Input Monitoring permission. Accessibility remains available as a fallback for existing installs. ## 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. +Input Monitoring permission is preferred so Keyty can observe input events and render them in the overlay. Accessibility remains a supported fallback; only one permission is required. For setup and troubleshooting details, see [PERMISSIONS.md](PERMISSIONS.md). From 8d40f0d55dba6ef56096df93500ca4ff202f6c34 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Mon, 14 Sep 2026 12:11:03 +0200 Subject: [PATCH 08/12] feat(permissions): select access by distribution target --- .../Capture/CaptureController/CaptureController.swift | 2 +- .../Sources/Keyty/Platform/Permissions/Permission.swift | 8 ++++++++ .../Keyty/Platform/Permissions/PermissionsService.swift | 3 +-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Platform/Capture/CaptureController/CaptureController.swift b/Apps/Keyty/Sources/Keyty/Platform/Capture/CaptureController/CaptureController.swift index 816caad..02f686c 100644 --- a/Apps/Keyty/Sources/Keyty/Platform/Capture/CaptureController/CaptureController.swift +++ b/Apps/Keyty/Sources/Keyty/Platform/Capture/CaptureController/CaptureController.swift @@ -91,7 +91,7 @@ private extension CaptureController { 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(.inputMonitoring) + self.permissionsService.request(.inputCapture) } return } diff --git a/Apps/Keyty/Sources/Keyty/Platform/Permissions/Permission.swift b/Apps/Keyty/Sources/Keyty/Platform/Permissions/Permission.swift index 5daad68..763b019 100644 --- a/Apps/Keyty/Sources/Keyty/Platform/Permissions/Permission.swift +++ b/Apps/Keyty/Sources/Keyty/Platform/Permissions/Permission.swift @@ -13,6 +13,14 @@ enum Permission: CaseIterable, Hashable { case accessibility case inputMonitoring + static var inputCapture: Self { + #if APP_STORE + .inputMonitoring + #else + .accessibility + #endif + } + func isGranted() -> Bool { switch self { case .accessibility: diff --git a/Apps/Keyty/Sources/Keyty/Platform/Permissions/PermissionsService.swift b/Apps/Keyty/Sources/Keyty/Platform/Permissions/PermissionsService.swift index 376a8f2..34dba6f 100644 --- a/Apps/Keyty/Sources/Keyty/Platform/Permissions/PermissionsService.swift +++ b/Apps/Keyty/Sources/Keyty/Platform/Permissions/PermissionsService.swift @@ -16,8 +16,7 @@ protocol PermissionsService: AnyObject { extension PermissionsService { var canCaptureInputEvents: Bool { - self.status(for: .inputMonitoring) == .granted || - self.status(for: .accessibility) == .granted + self.status(for: .inputCapture) == .granted } } From a231b5fb2fbc10fa15c2ad42d87d1e0a5b189dfa Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Mon, 14 Sep 2026 12:11:10 +0200 Subject: [PATCH 09/12] feat(permissions): tailor permission screens by target --- .../PermissionsOnboardingView.swift | 60 ++++++++++++++----- .../PermissionsOnboardingViewModel.swift | 18 ++---- .../Permissions/PermissionsSettingsPane.swift | 32 ++++++---- .../PermissionsSettingsPaneViewModel.swift | 20 +++---- .../Keyty/Presentation/Tokens/Size.swift | 2 +- .../Resources/en.lproj/Localizable.strings | 4 ++ 6 files changed, 84 insertions(+), 52 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Features/PermissionsOnboarding/PermissionsOnboardingView.swift b/Apps/Keyty/Sources/Keyty/Features/PermissionsOnboarding/PermissionsOnboardingView.swift index 9ce1632..b477797 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,28 +61,56 @@ struct PermissionsOnboardingView: View { private var permissionsCard: some View { VStack(spacing: Spacing.none) { self.permissionRow( - iconName: "keyboard", + iconName: self.permissionIconName, iconBackgroundColor: Color.Theme.Accent.controlAccent, - title: L10n.PermissionsOnboarding.InputMonitoring.title, - detail: L10n.PermissionsOnboarding.InputMonitoring.detail, - status: self.viewModel.inputMonitoringStatus, - buttonTitle: L10n.PermissionsOnboarding.InputMonitoring.grantButton, - action: self.viewModel.requestInputMonitoring - ) - self.permissionRow( - iconName: "accessibility", - 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 eaa7242..1fab3e9 100644 --- a/Apps/Keyty/Sources/Keyty/Features/PermissionsOnboarding/PermissionsOnboardingViewModel.swift +++ b/Apps/Keyty/Sources/Keyty/Features/PermissionsOnboarding/PermissionsOnboardingViewModel.swift @@ -11,16 +11,14 @@ import SwiftUI @MainActor final class PermissionsOnboardingViewModel: ObservableObject { - var accessibilityStatus: Permission.Status { - self.permissionsService.status(for: .accessibility) - } + var inputCapturePermission: Permission { .inputCapture } - var inputMonitoringStatus: Permission.Status { - self.permissionsService.status(for: .inputMonitoring) + var inputCaptureStatus: Permission.Status { + self.permissionsService.status(for: .inputCapture) } var isComplete: Bool { - self.inputMonitoringStatus == .granted || self.accessibilityStatus == .granted + self.permissionsService.canCaptureInputEvents } var onCompletion: (() -> Void)? @@ -42,12 +40,8 @@ final class PermissionsOnboardingViewModel: ObservableObject { } } - func requestAccessibility() { - self.handlePermissionAction(for: .accessibility) - } - - func requestInputMonitoring() { - self.handlePermissionAction(for: .inputMonitoring) + 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 482a787..0644707 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Permissions/PermissionsSettingsPane.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Permissions/PermissionsSettingsPane.swift @@ -19,23 +19,35 @@ struct PermissionsSettingsPane: View { SettingsStack { SettingsSectionView( title: L10n.Settings.Pane.permissions, - subtitle: L10n.Permissions.sectionSubtitle + subtitle: self.subtitle ) { permissionRow( - title: L10n.Permissions.inputMonitoringLabel, - status: model.inputMonitoringStatus, - action: model.requestInputMonitoring - ) - Divider() - 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 44ff2da..65773ee 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Permissions/PermissionsSettingsPaneViewModel.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Permissions/PermissionsSettingsPaneViewModel.swift @@ -10,16 +10,16 @@ import SwiftUI @MainActor final class PermissionsSettingsPaneViewModel: ObservableObject { - @Published private(set) var accessibilityStatus: Permission.Status - @Published private(set) var inputMonitoringStatus: 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.inputMonitoringStatus = self.permissionsService.status(for: .inputMonitoring) + self.inputCaptureStatus = self.permissionsService.status(for: .inputCapture) self.observationToken = self.permissionsService.observeChanges { [weak self] in Task { @MainActor [weak self] in self?.refresh() @@ -27,19 +27,13 @@ final class PermissionsSettingsPaneViewModel: ObservableObject { } } - func requestAccessibility() { - self.handleAction(for: .accessibility) - self.refresh() - } - - func requestInputMonitoring() { - self.handleAction(for: .inputMonitoring) + func requestInputCapturePermission() { + self.handleAction(for: .inputCapture) self.refresh() } func refresh() { - self.accessibilityStatus = self.permissionsService.status(for: .accessibility) - self.inputMonitoringStatus = self.permissionsService.status(for: .inputMonitoring) + self.inputCaptureStatus = self.permissionsService.status(for: .inputCapture) } private func handleAction(for permission: Permission) { diff --git a/Apps/Keyty/Sources/Keyty/Presentation/Tokens/Size.swift b/Apps/Keyty/Sources/Keyty/Presentation/Tokens/Size.swift index 2d93f7b..9c913c5 100644 --- a/Apps/Keyty/Sources/Keyty/Presentation/Tokens/Size.swift +++ b/Apps/Keyty/Sources/Keyty/Presentation/Tokens/Size.swift @@ -13,7 +13,7 @@ enum Size { enum Window { static let about = CGSize(width: Spacing.grid(155), height: Spacing.grid(91)) - static let permissionsOnboarding = CGSize(width: Spacing.grid(165), height: Spacing.grid(108)) + static let permissionsOnboarding = CGSize(width: Spacing.grid(165), height: Spacing.grid(96)) static let settings = CGSize(width: Spacing.grid(180) * 1.1, height: Spacing.grid(110) * 1.1) } diff --git a/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings index 189304a..cb4df3f 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings @@ -241,6 +241,8 @@ "permissions.accessibility_label" = "Accessibility"; "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…"; @@ -249,9 +251,11 @@ "permissions_onboarding.title" = "Set Up Keyty"; "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."; From aed4d16aba0aa8c6dca7481c6e595411f3a0eda1 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Mon, 14 Sep 2026 12:11:15 +0200 Subject: [PATCH 10/12] test(permissions): cover target-specific input access --- .../PermissionsOnboardingViewModelTests.swift | 58 ++++++------------- .../Capture/CaptureControllerTests.swift | 8 +-- .../SystemPermissionsServiceTests.swift | 11 ++-- 3 files changed, 27 insertions(+), 50 deletions(-) diff --git a/Apps/Keyty/Tests/KeytyTests/Features/PermissionsOnboarding/PermissionsOnboardingViewModelTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/PermissionsOnboarding/PermissionsOnboardingViewModelTests.swift index 8197bcb..9e14bf0 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/PermissionsOnboarding/PermissionsOnboardingViewModelTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/PermissionsOnboarding/PermissionsOnboardingViewModelTests.swift @@ -11,82 +11,54 @@ import XCTest @MainActor final class PermissionsOnboardingViewModelTests: XCTestCase { - func testIsCompleteWhenAccessibilityPermissionIsGranted() 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 testIsCompleteWhenInputMonitoringPermissionIsGranted() async { + func testIsNotCompleteWhenInactivePermissionIsGranted() { let service = TestPermissionsService(statuses: [ - .inputMonitoring: .granted, + Self.inactiveInputCapturePermission: .granted, ]) let model = PermissionsOnboardingViewModel(permissionsService: service) - XCTAssertTrue(model.isComplete) - - service.statuses[.inputMonitoring] = .notGranted - service.notifyObservers() - await Task.yield() - XCTAssertFalse(model.isComplete) } - func testRequestAccessibilityForwardsAccessibilityRequest() { - let service = TestPermissionsService() - let model = PermissionsOnboardingViewModel(permissionsService: service) - - model.requestAccessibility() - - XCTAssertEqual(service.requestedPermissions, [.accessibility]) - } - - func testRequestInputMonitoringForwardsInputMonitoringRequest() { + func testRequestInputCapturePermissionForwardsActivePermissionRequest() { let service = TestPermissionsService() let model = PermissionsOnboardingViewModel(permissionsService: service) - model.requestInputMonitoring() + model.requestInputCapturePermission() - XCTAssertEqual(service.requestedPermissions, [.inputMonitoring]) + 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() { - let service = TestPermissionsService(statuses: [ - .accessibility: .granted, - ]) - let model = PermissionsOnboardingViewModel(permissionsService: service) - var completionCount = 0 - model.onCompletion = { completionCount += 1 } - - model.continueIfComplete() - - XCTAssertEqual(completionCount, 1) - } - - func testContinueRunsCompletionWhenInputMonitoringIsGranted() { + func testContinueRunsCompletionWhenInputCapturePermissionIsGranted() { let service = TestPermissionsService(statuses: [ - .inputMonitoring: .granted, + .inputCapture: .granted, ]) let model = PermissionsOnboardingViewModel(permissionsService: service) var completionCount = 0 @@ -97,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 @@ -107,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 867ba64..82148e9 100644 --- a/Apps/Keyty/Tests/KeytyTests/Platform/Capture/CaptureControllerTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Platform/Capture/CaptureControllerTests.swift @@ -56,9 +56,9 @@ final class CaptureControllerTests: XCTestCase { XCTAssertTrue(self.controller.isCapturing) } - func testStartInstallsTapWhenOnlyInputMonitoringIsGranted() { + func testStartInstallsTapWhenOnlyInputCapturePermissionIsGranted() { self.permissionsService.currentStatus = .notGranted - self.permissionsService.statuses[.inputMonitoring] = .granted + self.permissionsService.statuses[.inputCapture] = .granted self.controller.start() @@ -83,13 +83,13 @@ final class CaptureControllerTests: XCTestCase { XCTAssertEqual(self.permissionsService.requestedPermissions, []) } - func testExplicitCaptureEnableRequestsInputMonitoring() { + func testExplicitCaptureEnableRequestsInputCapturePermission() { self.permissionsService.currentStatus = .notGranted self.controller.stopCapturing() self.controller.toggleCapturing() - XCTAssertEqual(self.permissionsService.requestedPermissions, [.inputMonitoring]) + XCTAssertEqual(self.permissionsService.requestedPermissions, [.inputCapture]) } func testCapturingStartsOncePermissionIsGrantedLater() { diff --git a/Apps/Keyty/Tests/KeytyTests/Platform/Permissions/SystemPermissionsServiceTests.swift b/Apps/Keyty/Tests/KeytyTests/Platform/Permissions/SystemPermissionsServiceTests.swift index 377b951..8ec0744 100644 --- a/Apps/Keyty/Tests/KeytyTests/Platform/Permissions/SystemPermissionsServiceTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Platform/Permissions/SystemPermissionsServiceTests.swift @@ -31,13 +31,14 @@ final class SystemPermissionsServiceTests: XCTestCase { XCTAssertEqual(service.status(for: .inputMonitoring), .granted) } - func testCanCaptureInputEventsWhenEitherPermissionIsGranted() { - let inputMonitoringProvider = TestPermissionsProvider(grantedPermissions: [.inputMonitoring]) - let accessibilityProvider = TestPermissionsProvider(grantedPermissions: [.accessibility]) + 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: inputMonitoringProvider).canCaptureInputEvents) - XCTAssertTrue(SystemPermissionsService(provider: accessibilityProvider).canCaptureInputEvents) + XCTAssertTrue(SystemPermissionsService(provider: activeProvider).canCaptureInputEvents) + XCTAssertFalse(SystemPermissionsService(provider: inactiveProvider).canCaptureInputEvents) XCTAssertFalse(SystemPermissionsService(provider: deniedProvider).canCaptureInputEvents) } From 192cddbec21073f5e32145705f75ed268e329607 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Mon, 14 Sep 2026 12:11:49 +0200 Subject: [PATCH 11/12] docs(permissions): describe target-specific access --- Docs/BUILD.md | 2 +- Docs/DEVELOPING.md | 4 ++-- Docs/PERMISSIONS.md | 24 ++++++++++++++---------- Docs/PRIVACY.md | 4 ++-- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/Docs/BUILD.md b/Docs/BUILD.md index 48641c1..6f37d99 100644 --- a/Docs/BUILD.md +++ b/Docs/BUILD.md @@ -139,7 +139,7 @@ xcodebuild test \ ## Code Signing -Keyty observes input through macOS Input Monitoring APIs (with Accessibility as a fallback), 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 privacy grant. diff --git a/Docs/DEVELOPING.md b/Docs/DEVELOPING.md index 1e8ad85..d2e6663 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 `Input Monitoring` permission to capture and visualize input events, including when running a debug build from Xcode. `Accessibility` remains available as a fallback for existing installs. +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 **Input Monitoring** or **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 6e90eb5..419b844 100644 --- a/Docs/PERMISSIONS.md +++ b/Docs/PERMISSIONS.md @@ -4,10 +4,10 @@ Keyty needs macOS privacy permission to capture and display keyboard and mouse a ## Required Permissions -Keyty supports the following macOS permissions: +Keyty uses a target-specific macOS permission: -- `Input Monitoring` (preferred): allows Keyty to passively observe the input events needed for keyboard and mouse visualization. -- `Accessibility` (fallback): remains supported for existing installs and grants. +- 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 @@ -15,11 +15,11 @@ To grant permissions on current macOS versions: 1. Open **System Settings**. 2. Go to **Privacy & Security**. -3. Open **Input Monitoring** 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. -Accessibility remains available as a fallback in Keyty's **Permissions** pane, but only one of these permissions is required. - If `Keyty` is not listed yet, launch the app once and return to these settings after macOS has registered it. ## Permissions in App @@ -34,9 +34,9 @@ If Keyty is running but no keyboard or mouse activity appears: 1. Quit Keyty. 2. Open **System Settings > Privacy & Security**. -3. Check **Input Monitoring** and **Accessibility** for existing `Keyty` entries that 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 **Input Monitoring**. +5. Re-enable `Keyty` in that permission pane. 6. Restart the app if macOS requires it. Common causes: @@ -54,11 +54,15 @@ If the app is still listed in System Settings but macOS does not deliver input e 2. Run: ```bash - tccutil reset ListenEvent app.keyty.Keyty + # 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 **Input Monitoring**. +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 1cbca21..fad2216 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 Input Monitoring permission. Accessibility remains available as a fallback for existing installs. +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 -Input Monitoring permission is preferred so Keyty can observe input events and render them in the overlay. Accessibility remains a supported fallback; only one permission is required. +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). From 093c6b8dd77ce022fa3848b358b048752ee5e846 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Mon, 14 Sep 2026 12:23:54 +0200 Subject: [PATCH 12/12] fix(settings): count only required permission in badge --- .../Keyty/Features/Settings/SettingsWindowController.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift index 6a68600..1dd06c0 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) }