fix: expose Caps remap permissions and diagnostics

This commit is contained in:
casona 2026-10-25 21:36:34 +03:00
parent 394ff78431
commit 643a466e5c
5 changed files with 272 additions and 18 deletions

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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)
}

View file

@ -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: