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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions Apps/Keyty/Sources/Keyty/App/Lifecycle/AppController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
Expand Down Expand Up @@ -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() {
Expand Down
4 changes: 2 additions & 2 deletions Apps/Keyty/Sources/Keyty/App/Shell/StatusItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ final class StatusItemController {
private let statusItem: NSStatusItem

private var statusItemImage: NSImage {
if !self.isAccessibilityGranted {
if !self.hasEventListeningPermission {
return NSImage.statusItemPermissionRequired
}

Expand All @@ -37,7 +37,7 @@ final class StatusItemController {
}
}

var isAccessibilityGranted: Bool = false {
var hasEventListeningPermission: Bool = false {
didSet {
self.updateStatusItemImage()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)?
Expand All @@ -38,8 +40,8 @@ final class PermissionsOnboardingViewModel: ObservableObject {
}
}

func requestAccessibility() {
self.handlePermissionAction(for: .accessibility)
func requestInputCapturePermission() {
self.handlePermissionAction(for: .inputCapture)
}

func continueIfComplete() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,30 @@ 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()
}
}
}

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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
28 changes: 24 additions & 4 deletions Apps/Keyty/Sources/Keyty/Platform/Permissions/Permission.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,8 @@ extension NSWorkspace {
func openAccessibilitySettings() {
self.open(.accessibilitySettings)
}

func openInputMonitoringSettings() {
self.open(.inputMonitoringSettings)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)!
}
Loading
Loading