From 643a466e5c431bcd4dfc4876ce08307b8ba2a900 Mon Sep 17 00:00:00 2001 From: casona Date: Sun, 25 Oct 2026 21:36:34 +0300 Subject: [PATCH] fix: expose Caps remap permissions and diagnostics --- README.md | 48 ++++++++- .../CapswitchApp/HIDRemappingController.swift | 25 +++++ .../CapswitchApp/MacOSInputController.swift | 99 ++++++++++++++++++- .../CapswitchApp/StatusBarController.swift | 92 +++++++++++++++-- Taskfile.yml | 26 +++++ 5 files changed, 272 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index ba4ddf0..3311278 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,14 @@ Small native macOS menu-bar app. Physical Caps Lock is HID-remapped to F19, whic - macOS 14 or newer and Apple Command Line Tools (install with xcode-select --install). - Task (Taskfile runner). -- Accessibility permission under **System Settings -> Privacy & Security -> Accessibility**. -- Disable **Keyboard -> Text Input -> Edit -> Use Caps Lock to switch to/from ABC**. +- Grant **Accessibility** under **System Settings -> Privacy & Security** to + let the app suppress F19. On some macOS setups you may also need + **Input Monitoring**. The menu shows both permissions separately and + provides a grant action; active CGEventTap may work with Accessibility alone. +- **Important for users coming from the stock Caps switch:** Disable + **Keyboard -> Text Input -> Edit -> Use Caps Lock to switch to/from ABC** + while using Capswitch. That macOS shortcut must not compete with our own + input-source selector. Capswitch does not change this setting automatically. - Keep the Caps Lock key set to its normal role in **Keyboard -> Keyboard Shortcuts -> Modifier Keys**, not "No Action". - No Xcode project, root helper, Karabiner or third-party driver is needed. @@ -22,7 +28,24 @@ task bundle open build/Capswitch.app ~~~ -Grant Accessibility permission and restart Capswitch if macOS prompts you to do so. +After opening Capswitch, grant **Accessibility** from the status-bar menu. +If the event tap is refused or no F19 events are received, also grant +**Input Monitoring** from the menu. Restart Capswitch after granting +permissions if macOS prompts you to do so. + +If you previously used macOS's built-in Caps-to-input-source switching, open +System Settings -> Keyboard -> Text Input -> Edit and uncheck +"Use Caps Lock to switch to and from [last used Latin input source]". +Keep Caps assigned to the normal Caps Lock action in Modifier Keys. + +Open the Capswitch menu and confirm: +- Accessibility: Granted +- Input Monitoring: Granted (or not granted if the active tap works) +- Event tap: Enabled +- HID Caps -> F19: configured + +These confirm permissions and the global configuration, **not** yet that the +physical keyboard is actually sending F19. Use the counters to verify it. ## Behavior @@ -38,7 +61,23 @@ Open the status-bar menu while testing quick typing. It displays: F19 ↓N ↑N · switches N -No typed text is recorded. If the F19 counts do not change, the HID remap is not producing F19 events. If they change but the switch count does not, inspect input source selection. +No typed text is recorded. If the F19 counts do not change, the HID remap is not producing F19 events +or the event tap is not receiving them. First check both permissions, the +event tap status and the native Caps switch preference. Then use the +"Reapply Caps -> F19 mapping" menu action and repeat the test. + +If F19 counts increase but switches do not, investigate TIS input-source +selection. If the stock Caps input switch still operates while Capswitch +is active, the HID remap may not be applied to your physical keyboard. + +To gather diagnostics without logging any typed text: + +~~~bash +task doctor +~~~ + +This prints the native Caps preference, global UserKeyMapping and the keyboard +service-specific mapping; these can differ even when hidutil exits successfully. Caps+Option through IOKit and its LED behavior still require verification on an actual Mac. @@ -69,6 +108,7 @@ task build task bundle task clean task verify +task doctor ~~~ Linux RSB runs host-independent static checks only. SwiftPM builds, XCTest, IOKit and actual keyboard input behavior must be verified on macOS with the Apple SDK. diff --git a/Sources/CapswitchApp/HIDRemappingController.swift b/Sources/CapswitchApp/HIDRemappingController.swift index bba75ab..82d4d82 100644 --- a/Sources/CapswitchApp/HIDRemappingController.swift +++ b/Sources/CapswitchApp/HIDRemappingController.swift @@ -37,6 +37,11 @@ final class HIDRemappingController { // Adopt a rule left behind after Capswitch crashed. A pre-existing // user-managed Caps -> F19 rule has no marker and is never removed. ownsMapping = ownsMapping || UserDefaults.standard.bool(forKey: markerKey) + if ownsMapping && !isInstalled { + // Reapply once after a crash/relaunch. Global UserKeyMapping + // may be present while a keyboard service still has no mapping. + try writeMappings(current) + } isInstalled = true return } @@ -64,6 +69,26 @@ final class HIDRemappingController { isInstalled = true } + func reapplyIfOwned() throws { + guard ownsMapping || UserDefaults.standard.bool(forKey: markerKey) else { + throw HIDRemapError.conflictingMapping + } + + let current = try readMappings() + guard current.contains(ourMapping) else { + try ensureInstalled() + return + } + + // Re-broadcast the complete mapping table, never an empty replacement. + try writeMappings(current) + guard try readMappings().contains(ourMapping) else { + throw HIDRemapError.verificationFailed + } + + isInstalled = true + } + func restoreIfOwned() throws { guard ownsMapping || UserDefaults.standard.bool(forKey: markerKey) else { isInstalled = false diff --git a/Sources/CapswitchApp/MacOSInputController.swift b/Sources/CapswitchApp/MacOSInputController.swift index 7f1fffd..7af6cd0 100644 --- a/Sources/CapswitchApp/MacOSInputController.swift +++ b/Sources/CapswitchApp/MacOSInputController.swift @@ -8,14 +8,17 @@ import Foundation enum InputControllerError: LocalizedError { case accessibilityRequired + case inputMonitoringRequired case eventTapUnavailable var errorDescription: String? { switch self { case .accessibilityRequired: - return "Accessibility permission is required to intercept Caps Lock." + return "Accessibility permission is required. Open the Capswitch menu to grant it." + case .inputMonitoringRequired: + return "Keyboard tap refused. Grant Input Monitoring from the Capswitch menu." case .eventTapUnavailable: - return "macOS did not allow the global keyboard event tap to start." + return "macOS did not allow the keyboard event tap. Check Accessibility and Input Monitoring permissions." } } } @@ -160,7 +163,36 @@ final class MacOSInputController { var onStateChanged: (() -> Void)? var isRunning: Bool { - eventTap != nil && remapper.isInstalled + isTapEnabled && remapper.isInstalled + } + + var isTapEnabled: Bool { + guard let eventTap else { + return false + } + return CFMachPortIsValid(eventTap) && CGEvent.tapIsEnabled(tap: eventTap) + } + + var inputMonitoringTrusted: Bool { + CGPreflightListenEventAccess() + } + + var hidMappingConfigured: Bool { + remapper.isInstalled + } + + // This key is an undocumented macOS preference; missing means unknown, + // not "off". The UI never changes the user's global keyboard settings. + var nativeCapsSwitchState: String { + let value = UserDefaults.standard.object(forKey: "TISRomanSwitchState") + ?? UserDefaults(suiteName: "com.apple.HIToolbox")? + .object(forKey: "TISRomanSwitchState") + + guard let number = value as? NSNumber else { + return "Unknown (check Keyboard settings)" + } + + return number.intValue == 0 ? "Off" : "On - disable for Capswitch" } var currentInputSourceName: String { @@ -188,6 +220,9 @@ final class MacOSInputController { throw InputControllerError.accessibilityRequired } + // Active .defaultTap filtering is authorized through Accessibility + // on many macOS versions. Try that first; Input Monitoring is an + // additional diagnostic/grant path if tap creation is refused. // Enumerate input sources before entering the event tap callback. inputSources.refresh() @@ -204,7 +239,9 @@ final class MacOSInputController { callback: Self.eventTapCallback, userInfo: refcon ) else { - throw InputControllerError.eventTapUnavailable + throw inputMonitoringTrusted + ? InputControllerError.eventTapUnavailable + : InputControllerError.inputMonitoringRequired } eventTap = tap @@ -284,6 +321,60 @@ final class MacOSInputController { inputSources.refresh() } + // macOS can silently disable taps after a timeout or permission change. + // Detect that before considering the HID mapping healthy. + func repairEventTapIfNeeded() { + guard let eventTap else { + return + } + + guard !isTapEnabled else { + return + } + + if CFMachPortIsValid(eventTap), accessibilityTrusted { + CGEvent.tapEnable(tap: eventTap, enable: true) + } + + if !isTapEnabled { + lastError = "The keyboard event tap is disabled. Recheck macOS permissions." + stop() + } + } + + func reapplyHIDMapping() { + guard !isPaused, eventTap != nil, isTapEnabled else { + lastError = "The keyboard event tap must be active before reapplying Caps → F19." + onStateChanged?() + return + } + + do { + try remapper.reapplyIfOwned() + lastError = nil + } catch { + lastError = error.localizedDescription + } + onStateChanged?() + } + + func requestInputMonitoringPermission() { + _ = CGRequestListenEventAccess() + if let url = URL( + string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent" + ) { + NSWorkspace.shared.open(url) + } + } + + func openKeyboardSettings() { + if let url = URL( + string: "x-apple.systempreferences:com.apple.preference.keyboard" + ) { + NSWorkspace.shared.open(url) + } + } + func repairMappingIfNeeded() { guard !isPaused, eventTap != nil else { return diff --git a/Sources/CapswitchApp/StatusBarController.swift b/Sources/CapswitchApp/StatusBarController.swift index 76d3d01..fc17f5a 100644 --- a/Sources/CapswitchApp/StatusBarController.swift +++ b/Sources/CapswitchApp/StatusBarController.swift @@ -21,11 +21,36 @@ final class StatusBarController: NSObject, NSMenuDelegate { action: nil, keyEquivalent: "" ) + private let tapStatusItem = NSMenuItem( + title: "", + action: nil, + keyEquivalent: "" + ) + private let hidMappingItem = NSMenuItem( + title: "", + action: nil, + keyEquivalent: "" + ) + private let reapplyMappingItem = NSMenuItem( + title: "Reapply Caps → F19 mapping", + action: #selector(reapplyMapping), + keyEquivalent: "" + ) private let permissionItem = NSMenuItem( title: "", action: #selector(openAccessibilitySettings), keyEquivalent: "" ) + private let inputMonitoringItem = NSMenuItem( + title: "", + action: #selector(openInputMonitoringSettings), + keyEquivalent: "" + ) + private let systemSwitchItem = NSMenuItem( + title: "", + action: #selector(openKeyboardSettings), + keyEquivalent: "" + ) private let pauseItem = NSMenuItem( title: "", action: #selector(togglePaused), @@ -77,10 +102,15 @@ final class StatusBarController: NSObject, NSMenuDelegate { menu.delegate = self menu.addItem(currentLayoutItem) menu.addItem(diagnosticsItem) + menu.addItem(tapStatusItem) + menu.addItem(hidMappingItem) menu.addItem(errorItem) + menu.addItem(reapplyMappingItem) menu.addItem(NSMenuItem.separator()) menu.addItem(pauseItem) menu.addItem(permissionItem) + menu.addItem(inputMonitoringItem) + menu.addItem(systemSwitchItem) menu.addItem(NSMenuItem.separator()) let quitItem = NSMenuItem( @@ -90,7 +120,14 @@ final class StatusBarController: NSObject, NSMenuDelegate { ) menu.addItem(quitItem) - for item in [pauseItem, permissionItem, quitItem] { + for item in [ + pauseItem, + permissionItem, + inputMonitoringItem, + systemSwitchItem, + reapplyMappingItem, + quitItem, + ] { item.target = self } @@ -101,10 +138,13 @@ final class StatusBarController: NSObject, NSMenuDelegate { private func refreshController() { let now = Date() - if !inputController.accessibilityTrusted && inputController.isRunning { + if !inputController.accessibilityTrusted, + inputController.isTapEnabled { inputController.stop() } + inputController.repairEventTapIfNeeded() + if inputController.accessibilityTrusted, !inputController.isPaused, !inputController.isRunning, @@ -140,6 +180,15 @@ final class StatusBarController: NSObject, NSMenuDelegate { diagnosticsItem.title = inputController.diagnosticsSummary diagnosticsItem.isEnabled = false + tapStatusItem.title = "Event tap: \(inputController.isTapEnabled ? "Enabled" : "Inactive")" + tapStatusItem.isEnabled = false + + hidMappingItem.title = inputController.hidMappingConfigured + ? "HID Caps → F19: configured (verify physical keyboard)" + : "HID Caps → F19: not configured" + hidMappingItem.isEnabled = false + reapplyMappingItem.isEnabled = inputController.isRunning + errorItem.isHidden = inputController.lastError == nil errorItem.title = "Error: \(inputController.lastError ?? "")" errorItem.toolTip = inputController.lastError @@ -150,15 +199,26 @@ final class StatusBarController: NSObject, NSMenuDelegate { : "Pause switching (restore Caps)" pauseItem.state = inputController.isPaused ? .on : .off - if inputController.accessibilityTrusted { - permissionItem.title = inputController.isRunning - ? "Accessibility: Granted · Caps → F19 active" - : "Accessibility: Granted · switching inactive" - } else { - permissionItem.title = "Grant Accessibility permission…" - } + permissionItem.title = inputController.accessibilityTrusted + ? "Accessibility: Granted" + : "Grant Accessibility permission…" - for item in [currentLayoutItem, permissionItem, pauseItem] { + inputMonitoringItem.title = inputController.inputMonitoringTrusted + ? "Input Monitoring: Granted" + : "Input Monitoring: Not granted (may be needed)…" + + systemSwitchItem.title = + "System Caps input switch: \(inputController.nativeCapsSwitchState)" + systemSwitchItem.toolTip = + "Keyboard → Text Input → Edit → disable 'Use Caps Lock to switch input sources'." + + for item in [ + currentLayoutItem, + permissionItem, + inputMonitoringItem, + systemSwitchItem, + pauseItem, + ] { item.target = self } } @@ -182,6 +242,18 @@ final class StatusBarController: NSObject, NSMenuDelegate { inputController.openAccessibilitySettings() } + @objc private func openInputMonitoringSettings() { + inputController.requestInputMonitoringPermission() + } + + @objc private func openKeyboardSettings() { + inputController.openKeyboardSettings() + } + + @objc private func reapplyMapping() { + inputController.reapplyHIDMapping() + } + @objc private func quit() { NSApp.terminate(nil) } diff --git a/Taskfile.yml b/Taskfile.yml index 16dff39..354eed8 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -69,11 +69,37 @@ tasks: - test -f Tests/CapswitchCoreTests/HIDMappingCodecTests.swift - grep -q "CGEvent.tapCreate" Sources/CapswitchApp/MacOSInputController.swift - grep -q "CGEvent.tapEnable" Sources/CapswitchApp/MacOSInputController.swift + - grep -q "CGPreflightListenEventAccess" Sources/CapswitchApp/MacOSInputController.swift + - grep -q "CGRequestListenEventAccess" Sources/CapswitchApp/MacOSInputController.swift + - grep -q "CGEvent.tapIsEnabled" Sources/CapswitchApp/MacOSInputController.swift - test "$(grep -c "CGEventTapCreate" Sources/CapswitchApp/MacOSInputController.swift || true)" -eq 0 - test "$(grep -c "CGEventTapEnable" Sources/CapswitchApp/MacOSInputController.swift || true)" -eq 0 - grep -q "TISSelectInputSource" Sources/CapswitchApp/MacOSInputController.swift - grep -q "switchToNextInputSource" Sources/Capswitch/CapsGestureMachine.swift + doctor: + desc: Inspect macOS Caps preferences, remapping and running Capswitch processes + cmds: + - | + if [ "$(uname -s)" != "Darwin" ]; then + echo "SKIP: HID diagnostics require macOS." + exit 0 + fi + echo "=== macOS ===" + sw_vers + echo "=== Native Caps input-source setting (1=enabled, 0=disabled) ===" + defaults read -g TISRomanSwitchState 2>/dev/null || true + defaults read com.apple.HIToolbox TISRomanSwitchState 2>/dev/null || true + echo "=== Global HID remappings ===" + hidutil property --get UserKeyMapping + echo "=== HID keyboard services (may vary by keyboard) ===" + hidutil property --matching '{"PrimaryUsagePage":1,"PrimaryUsage":6}' --get UserKeyMapping || true + echo "=== Running Capswitch processes ===" + pgrep -fl Capswitch || true + echo "=== Privacy permissions ===" + echo "Inspect Accessibility and Input Monitoring in the Capswitch status menu." + echo "If Input Monitoring is not granted, choose 'Grant Input Monitoring permission'." + clean: desc: Remove build outputs cmds: