feat: remap Caps directly through in-process IOKit

This commit is contained in:
casona 2026-10-25 22:30:44 +03:00
parent 14f9469ee2
commit 4c47f2cf08
10 changed files with 802 additions and 479 deletions

170
README.md
View file

@ -1,133 +1,117 @@
# Capswitch
Small native macOS menu-bar app. Physical Caps Lock is HID-remapped to F19, which Capswitch intercepts to select the next input source **on release**, including during continuous fast typing. Caps+Option retains the system Caps Lock function.
Native macOS menu-bar keyboard layout switcher, targeting macOS 14+.
## Requirements
**Caps**: switch to the next enabled input source **on release**, without
macOS's stock Caps delay or input-source HUD.
- macOS 14 or newer and Apple Command Line Tools (install with xcode-select --install).
- Task (Taskfile runner).
- 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.
**Caps + Option** in either order: toggle the actual system Caps Lock state.
## Build and test on your Mac
## How it works
**Quit the currently running Capswitch from its menu first.** Opening an already running app may activate an old process even if a new bundle was created. The Taskfile refuses to replace the app bundle while a Capswitch process is running.
Capswitch uses **IOKit APIs inside its own process** to remap physical HID
Caps Lock (usage 0x39) to F19 (usage 0x6E). Its active CoreGraphics event tap
intercepts F19 down/up and suppresses them, switching layouts on F19 release.
A small C bridge built into Capswitch contains the IOKit calls required by
Swift. There is **no subprocess**, no hidutil dependency, no external helper,
no driver, and no requirement to give permissions to a system executable.
~~~bash
Apple documents the direct APIs used for remapping in Technical Note TN2450:
https://developer.apple.com/library/archive/technotes/tn2450/
## Build on your Mac
Install Command Line Tools and the Task runner. Full Xcode is not needed.
**Quit any running Capswitch from the menu before rebuilding.** An already
running process won't be replaced merely by opening a new .app file.
~~~
git pull origin main
task test
task bundle
open build/Capswitch.app
~~~
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.
The bundle is ad-hoc signed with a stable bundle identifier
(dev.local.Capswitch). Keep its installed path stable so macOS can retain
its privacy grants.
### macOS Sonoma: grant Input Monitoring to hidutil itself
## Permissions - Capswitch only
On macOS 14, an Input Monitoring grant for Capswitch.app may not be
sufficient for its child process /usr/bin/hidutil, which is used to set
the actual global HID mapping. If task doctor reports (null), Caps->F19
is not installed (regardless of whether the app process is running).
At first launch Capswitch requests access via macOS:
- **Accessibility**: needed for an active event tap that suppresses F19.
- **Input Monitoring**, if macOS requires it: needed on some macOS setups
for observing keyboard events or interacting with the HID subsystem.
Open **System Settings -> Privacy & Security -> Input Monitoring**, click
the + button, then press **Command+Shift+G** in the file picker and enter
**/usr/bin/hidutil**. Select hidutil and enable its Input Monitoring
toggle. This is separate from Capswitch.app. Quit and reopen Capswitch,
then run task doctor again. See also the Sonoma notes for
mvp/mac-remap-keys.
Approve Capswitch.app, *never* /usr/bin/hidutil. macOS requires your own
confirmation in System Settings; applications cannot grant themselves access.
If the mapping remains null, inspect the **Capswitch menu** and capture
the Accessibility / Event tap / HID / Error entries. The application may
be running without having created its event tap at all; Input Monitoring
for hidutil cannot fix missing Accessibility permission.
You can check/access those settings through the Capswitch menu. If granting
a permission requires a restart, Quit and reopen Capswitch.
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.
To avoid competing with the stock shortcut, disable
**System Settings -> Keyboard -> Text Input -> Edit ->
Use Caps Lock to switch to/from ABC**. Leave Caps Lock with its normal
Caps Lock modifier role (not "No Action") in Keyboard Shortcuts -> Modifier Keys.
Capswitch never secretly changes these system preferences.
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
## Status menu
These confirm permissions and the global configuration, **not** yet that the
physical keyboard is actually sending F19. Use the counters to verify it.
The menu shows:
- Current layout
- Native HID keyboard mapping count (e.g. 2/2 keyboards)
- Whether the keyboard event tap is enabled
- Accessibility and Input Monitoring grants
- F19 down/up and input-source switch counters (no typed text is recorded)
- Errors and a manual "Retry Caps -> F19 mapping" action
- Pause (restores Caps) and Quit (restores Caps)
## Behavior
When testing fast typing, repeatedly tap Caps while typing letters. If F19
counts increase but the switch counter doesn't, check the enabled TIS sources.
If the F19 counters remain zero, check the event tap, privacy grants, and HID
keyboard service count in the menu.
- Plain physical Caps emits F19 keyDown/keyUp. Capswitch swallows both events and on F19 keyUp selects the next enabled keyboard input source.
- Caps+Option in either order toggles the actual system Caps Lock state through IOKit after Option has been released.
- Pause from the menu disables the event tap and restores the Caps key to its original purpose.
- Quit normally restores only Capswitch's Caps->F19 mapping, leaving unrelated mappings alone.
- On wake or keyboard reconnection, Capswitch checks whether its HID mapping is still present.
## Native diagnostics
## Diagnostics
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
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.
This executes **Capswitch's own signed executable** in a read-only inspection
mode and reports the number of HID keyboard services and how many have
Caps->F19 configured. It does not launch hidutil and does not change keyboard
configuration. The command requires an existing app bundle.
Caps+Option through IOKit and its LED behavior still require verification on an actual Mac.
## HID safety
## HID remapping safety
Capswitch reads **each keyboard service's existing mappings**, refuses
conflicting mappings involving Caps or F19, and preserves unrelated mappings.
It remembers which services it modified, storing their registry IDs in the
app's own preferences for crash recovery.
HID key mapping is global. Capswitch first reads existing mappings, refuses collisions involving Caps or F19, and preserves unrelated remaps. It removes only its own rule on normal Quit/Pause. A small ownership marker allows a later launch to restore a rule that Capswitch left behind after a crash.
During Pause or normal Quit it removes only the exact Caps->F19 rule from
those services. Newly connected keyboards are handled when the app refreshes
its HID state. If Capswitch is forcibly killed, its mapping may remain until
the next successful Capswitch launch/quit, keyboard service removal, or reboot.
If the IOKit service rejects a mapping, Capswitch shows an error rather than
silently claiming success.
If the app is forcibly terminated, Caps may remain mapped to F19 until the app is relaunched and Quit normally, or until reboot. To inspect mappings:
~~~bash
hidutil property --get UserKeyMapping
~~~
**Do not clear all UserKeyMapping rules if you use other remappers.** Only if the sole entry is the one Capswitch inserted may you reset mappings manually:
~~~bash
hidutil property --set '{"UserKeyMapping":[]}'
~~~
hidutil remapping normally resets after restart or after the last keyboard service disappears.
If you need to recover, use the menu's Pause/Quit action after restarting
Capswitch. In an emergency reboot macOS to clear HID remappings. Do not
blindly clear other programs' mapping tables.
## Development
~~~bash
~~~
task verify
task lint
task test
task build
task bundle
task clean
task verify
task doctor
task clean
~~~
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.
The Linux RSB host can run static checks but has no Apple AppKit/IOKit SDK.
The final Swift build and real keyboard tests must be run on macOS.

View file

@ -1,143 +0,0 @@
import Foundation
public struct HIDKeyMapping: Equatable, Hashable {
public let source: UInt64
public let destination: UInt64
public init(source: UInt64, destination: UInt64) {
self.source = source
self.destination = destination
}
}
public enum HIDMappingCodecError: Error, Equatable {
case unrecognizedOutput
case invalidEntry
}
// Parse OpenStep-style hidutil --get output. Unknown entries are rejected
// rather than risking deletion of someone else's keyboard remappings.
public enum HIDMappingCodec {
public static let capsUsage: UInt64 = 0x700000039
public static let f19Usage: UInt64 = 0x70000006E
public static let capsToF19 = HIDKeyMapping(
source: capsUsage,
destination: f19Usage
)
public static func parse(_ output: String) throws -> [HIDKeyMapping] {
var value = output.trimmingCharacters(in: .whitespacesAndNewlines)
if value.hasPrefix("UserKeyMapping:") {
value = String(value.dropFirst("UserKeyMapping:".count))
.trimmingCharacters(in: .whitespacesAndNewlines)
}
guard value.first == "(", value.last == ")" else {
throw HIDMappingCodecError.unrecognizedOutput
}
let inner = String(value.dropFirst().dropLast())
.trimmingCharacters(in: .whitespacesAndNewlines)
if inner.isEmpty || inner == "null" {
return []
}
let entryPattern = try NSRegularExpression(pattern: #"\{([^{}]*)\}"#)
let matches = entryPattern.matches(
in: inner,
range: NSRange(inner.startIndex..<inner.endIndex, in: inner)
)
guard !matches.isEmpty else {
throw HIDMappingCodecError.unrecognizedOutput
}
let remainder = NSMutableString(string: inner)
var mappings: [HIDKeyMapping] = []
for match in matches.reversed() {
guard let bodyRange = Range(match.range(at: 1), in: inner) else {
throw HIDMappingCodecError.invalidEntry
}
mappings.insert(try parseEntry(String(inner[bodyRange])), at: 0)
remainder.deleteCharacters(in: match.range)
}
let separators = (remainder as String).replacingOccurrences(
of: #"[\s,]"#,
with: "",
options: .regularExpression
)
guard separators.isEmpty else {
throw HIDMappingCodecError.unrecognizedOutput
}
return mappings
}
public static func setArgument(_ mappings: [HIDKeyMapping]) throws -> String {
let entries: [[String: NSNumber]] = mappings.map {
[
"HIDKeyboardModifierMappingSrc": NSNumber(value: $0.source),
"HIDKeyboardModifierMappingDst": NSNumber(value: $0.destination),
]
}
let data = try JSONSerialization.data(
withJSONObject: ["UserKeyMapping": entries],
options: [.sortedKeys]
)
return String(decoding: data, as: UTF8.self)
}
private static func parseEntry(_ body: String) throws -> HIDKeyMapping {
let fieldPattern = try NSRegularExpression(
pattern: #"([A-Za-z][A-Za-z0-9_]*)\s*=\s*([^;]+);"#
)
let matches = fieldPattern.matches(
in: body,
range: NSRange(body.startIndex..<body.endIndex, in: body)
)
guard matches.count == 2 else {
throw HIDMappingCodecError.invalidEntry
}
let remainder = NSMutableString(string: body)
var fields: [String: UInt64] = [:]
for match in matches.reversed() {
guard let nameRange = Range(match.range(at: 1), in: body),
let valueRange = Range(match.range(at: 2), in: body) else {
throw HIDMappingCodecError.invalidEntry
}
let name = String(body[nameRange])
let literal = body[valueRange]
.trimmingCharacters(in: .whitespacesAndNewlines)
let number: UInt64?
if literal.lowercased().hasPrefix("0x") {
number = UInt64(literal.dropFirst(2), radix: 16)
} else {
number = UInt64(literal)
}
guard let number, fields[name] == nil else {
throw HIDMappingCodecError.invalidEntry
}
fields[name] = number
remainder.deleteCharacters(in: match.range)
}
guard (remainder as String)
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
fields.count == 2,
let source = fields["HIDKeyboardModifierMappingSrc"],
let destination = fields["HIDKeyboardModifierMappingDst"] else {
throw HIDMappingCodecError.invalidEntry
}
return HIDKeyMapping(source: source, destination: destination)
}
}

View file

@ -1,160 +1,108 @@
import CapswitchHID
import Foundation
import CapswitchCore
enum HIDRemapError: LocalizedError {
case conflictingMapping
case unreadableMapping
case verificationFailed
case commandFailed(String)
case failure(Int32)
var errorDescription: String? {
switch self {
case .conflictingMapping:
return "Caps Lock or F19 is already remapped. Disable the conflicting mapping first."
case .unreadableMapping:
return "Cannot safely read the existing HID key mappings; no changes were made."
case .verificationFailed:
return "hidutil did not apply Caps → F19. On macOS 14, grant Input Monitoring to /usr/bin/hidutil separately from Capswitch.app, then restart Capswitch."
case let .commandFailed(details):
return "hidutil failed: \(details)"
case let .failure(code):
switch code {
case 1:
return "Not enough memory to manage keyboard mappings."
case 2:
return "Cannot access IOKit keyboard services. Check Capswitch's macOS privacy permissions."
case 3:
return "No HID keyboard services found. Connect a keyboard and retry."
case 4:
return "An existing keyboard mapping has an unsupported format. No unrelated mappings were changed."
case 5:
return "Caps Lock or F19 already has a conflicting mapping. Capswitch will not overwrite it."
case 6:
return "macOS rejected the native Caps → F19 mapping for a keyboard."
case 7:
return "A keyboard did not confirm the Caps → F19 mapping."
case 8:
return "Could not identify a keyboard service safely."
default:
return "Native IOKit keyboard remapping failed (code \(code))."
}
}
}
}
/// Owns only the Caps -> F19 rule. Other mappings are preserved both when
/// installing and when restoring on quit or Pause.
/// Owns the native IOKit remapping manager inside Capswitch itself.
/// No subprocess, hidutil, privileged helper, driver or third-party library.
final class HIDRemappingController {
private let markerKey = "CapswitchOwnsCapsF19Mapping"
private let ourMapping = HIDMappingCodec.capsToF19
private let manager: UnsafeMutableRawPointer?
private(set) var isInstalled = false
private var ownsMapping = false
private(set) var keyboardServiceCount: UInt32 = 0
private(set) var mappedServiceCount: UInt32 = 0
init() {
manager = CapswitchRemapperCreate()
}
deinit {
if let manager {
CapswitchRemapperDestroy(manager)
}
}
func ensureInstalled() throws {
let current = try readMappings()
if current.contains(ourMapping) {
// 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
guard let manager else {
throw HIDRemapError.failure(1)
}
// Do not overwrite Caps remapped to something else, F19 remapped away,
// or a different physical key remapped to F19.
if current.contains(where: {
$0.source == HIDMappingCodec.capsUsage
|| $0.source == HIDMappingCodec.f19Usage
|| $0.destination == HIDMappingCodec.f19Usage
}) {
throw HIDRemapError.conflictingMapping
var keyboardCount: UInt32 = 0
var mappedCount: UInt32 = 0
let status = CapswitchRemapperApply(manager, &keyboardCount, &mappedCount)
keyboardServiceCount = keyboardCount
mappedServiceCount = mappedCount
isInstalled = status == 0 && keyboardCount > 0
&& mappedCount == keyboardCount
guard status == 0, isInstalled else {
throw HIDRemapError.failure(status == 0 ? 7 : status)
}
// The marker is written before modifying global HID state so the next
// launch can clean up an interrupted installation after a crash.
UserDefaults.standard.set(true, forKey: markerKey)
try writeMappings(current + [ourMapping])
guard try readMappings().contains(ourMapping) else {
throw HIDRemapError.verificationFailed
}
ownsMapping = true
isInstalled = true
}
func reapplyIfOwned() throws {
guard ownsMapping || UserDefaults.standard.bool(forKey: markerKey) else {
throw HIDRemapError.conflictingMapping
// Reconciliation is idempotent: the native layer checks every
// keyboard and only installs missing mappings it can safely own.
try ensureInstalled()
}
func inspect() throws {
guard let manager else {
throw HIDRemapError.failure(1)
}
let current = try readMappings()
guard current.contains(ourMapping) else {
try ensureInstalled()
return
}
var keyboardCount: UInt32 = 0
var mappedCount: UInt32 = 0
let status = CapswitchRemapperInspect(manager, &keyboardCount, &mappedCount)
keyboardServiceCount = keyboardCount
mappedServiceCount = mappedCount
// Re-broadcast the complete mapping table, never an empty replacement.
try writeMappings(current)
guard try readMappings().contains(ourMapping) else {
throw HIDRemapError.verificationFailed
guard status == 0 else {
throw HIDRemapError.failure(status)
}
isInstalled = true
}
func restoreIfOwned() throws {
guard ownsMapping || UserDefaults.standard.bool(forKey: markerKey) else {
isInstalled = false
return
guard let manager else {
throw HIDRemapError.failure(1)
}
let current = try readMappings()
if current.contains(ourMapping) {
// Remove only our exact pair, preserving any changes made by
// another remapper while Capswitch was running.
try writeMappings(current.filter { $0 != ourMapping })
if try readMappings().contains(ourMapping) {
throw HIDRemapError.verificationFailed
}
let status = CapswitchRemapperRestore(manager)
guard status == 0 else {
throw HIDRemapError.failure(status)
}
ownsMapping = false
isInstalled = false
UserDefaults.standard.removeObject(forKey: markerKey)
}
private func readMappings() throws -> [HIDKeyMapping] {
let output = try runHidutil(["property", "--get", "UserKeyMapping"])
do {
return try HIDMappingCodec.parse(output)
} catch {
throw HIDRemapError.unreadableMapping
}
}
private func writeMappings(_ mappings: [HIDKeyMapping]) throws {
let argument = try HIDMappingCodec.setArgument(mappings)
_ = try runHidutil(["property", "--set", argument])
}
private func runHidutil(_ arguments: [String]) throws -> String {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/hidutil")
process.arguments = arguments
let output = Pipe()
let errors = Pipe()
process.standardOutput = output
process.standardError = errors
do {
try process.run()
} catch {
throw HIDRemapError.commandFailed(error.localizedDescription)
}
process.waitUntilExit()
let data = output.fileHandleForReading.readDataToEndOfFile()
let errorData = errors.fileHandleForReading.readDataToEndOfFile()
let message = String(decoding: errorData, as: UTF8.self)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard process.terminationStatus == 0 else {
throw HIDRemapError.commandFailed(
message.isEmpty ? "exit code \(process.terminationStatus)" : message
)
}
return String(decoding: data, as: UTF8.self)
keyboardServiceCount = 0
mappedServiceCount = 0
}
}

View file

@ -181,8 +181,12 @@ final class MacOSInputController {
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 mappingStatus: String {
"\(remapper.mappedServiceCount)/\(remapper.keyboardServiceCount) keyboards"
}
// Undocumented macOS preference: only diagnostic, never modified by us.
// Missing means unknown, not off.
var nativeCapsSwitchState: String {
let value = UserDefaults.standard.object(forKey: "TISRomanSwitchState")
?? UserDefaults(suiteName: "com.apple.HIToolbox")?
@ -274,7 +278,19 @@ final class MacOSInputController {
onStateChanged?()
}
func stop() {
@discardableResult
func stop() -> Bool {
// Restore the actual Caps key before disabling the F19 interceptor.
// If restoration fails, refuse a normal Pause/Quit so F19 cannot be
// left unhandled by an otherwise healthy running Capswitch.
do {
try remapper.restoreIfOwned()
} catch {
lastError = "Cannot restore Caps remap: \(error.localizedDescription)"
onStateChanged?()
return false
}
if let eventTap {
CGEvent.tapEnable(tap: eventTap, enable: false)
}
@ -290,13 +306,8 @@ final class MacOSInputController {
eventTap = nil
runLoopSource = nil
resetGestureState()
do {
try remapper.restoreIfOwned()
} catch {
lastError = "Cannot restore Caps remap: \(error.localizedDescription)"
}
onStateChanged?()
return true
}
func setPaused(_ paused: Bool) {
@ -304,10 +315,12 @@ final class MacOSInputController {
return
}
isPaused = paused
if paused {
stop()
if stop() {
isPaused = true
}
} else {
isPaused = false
do {
try start()
} catch {
@ -358,6 +371,21 @@ final class MacOSInputController {
onStateChanged?()
}
func requestRequiredPermissions() {
if !accessibilityTrusted {
let options = [
kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String:
true as CFBoolean
] as CFDictionary
_ = AXIsProcessTrustedWithOptions(options)
}
if !inputMonitoringTrusted {
// Request Input Monitoring for our own application identity.
_ = CGRequestListenEventAccess()
}
}
func requestInputMonitoringPermission() {
_ = CGRequestListenEventAccess()
if let url = URL(

View file

@ -32,7 +32,7 @@ final class StatusBarController: NSObject, NSMenuDelegate {
keyEquivalent: ""
)
private let reapplyMappingItem = NSMenuItem(
title: "Reapply Caps → F19 mapping",
title: "Retry Caps → F19 mapping",
action: #selector(reapplyMapping),
keyEquivalent: ""
)
@ -87,6 +87,7 @@ final class StatusBarController: NSObject, NSMenuDelegate {
object: nil
)
inputController.requestRequiredPermissions()
refreshController()
}
@ -151,10 +152,14 @@ final class StatusBarController: NSObject, NSMenuDelegate {
now >= nextStartAttempt {
nextStartAttempt = now.addingTimeInterval(5)
do {
try inputController.start()
} catch {
inputController.lastError = error.localizedDescription
if inputController.isTapEnabled {
inputController.repairMappingIfNeeded()
} else {
do {
try inputController.start()
} catch {
inputController.lastError = error.localizedDescription
}
}
}
@ -184,10 +189,10 @@ final class StatusBarController: NSObject, NSMenuDelegate {
tapStatusItem.isEnabled = false
hidMappingItem.title = inputController.hidMappingConfigured
? "HID Caps → F19: configured (verify physical keyboard)"
: "HID Caps → F19: not configured"
? "Native HID: \(inputController.mappingStatus)"
: "Native HID inactive: \(inputController.mappingStatus)"
hidMappingItem.isEnabled = false
reapplyMappingItem.isEnabled = inputController.isRunning
reapplyMappingItem.isEnabled = inputController.isTapEnabled
errorItem.isHidden = inputController.lastError == nil
errorItem.title = "Error: \(inputController.lastError ?? "")"
@ -255,6 +260,16 @@ final class StatusBarController: NSObject, NSMenuDelegate {
}
@objc private func quit() {
NSApp.terminate(nil)
if inputController.stop() {
NSApp.terminate(nil)
} else {
update()
let alert = NSAlert()
alert.messageText = "Caps Lock could not be restored"
alert.informativeText = inputController.lastError
?? "The native HID service rejected the restoration."
alert.addButton(withTitle: "Keep Capswitch Running")
alert.runModal()
}
}
}

View file

@ -1,4 +1,27 @@
import AppKit
import Darwin
import Foundation
if CommandLine.arguments.contains("--doctor") {
// Read-only inspection uses the same native IOKit code as the GUI.
// This command never changes mappings or starts an event tap.
let remapper = HIDRemappingController()
do {
try remapper.inspect()
print("Capswitch - native HID diagnostic")
print("Keyboard services: \(remapper.keyboardServiceCount)")
print("Caps → F19 mappings: \(remapper.mappedServiceCount)")
if remapper.mappedServiceCount == remapper.keyboardServiceCount {
print("Result: all detected keyboard services map Caps to F19")
} else {
print("Result: Caps → F19 is missing on at least one keyboard")
}
exit(0)
} catch {
fputs("Capswitch HID check: \(error.localizedDescription)\n", stderr)
exit(1)
}
}
let application = NSApplication.shared
let delegate = AppDelegate()

View file

@ -0,0 +1,513 @@
#include "CapswitchHID.h"
#include <CoreFoundation/CoreFoundation.h>
#include <IOKit/hid/IOHIDUsageTables.h>
#include <IOKit/hidsystem/IOHIDEventSystemClient.h>
#include <IOKit/hidsystem/IOHIDServiceClient.h>
#include <stdlib.h>
#define CAPSWITCH_CAPS_USAGE UINT64_C(0x700000039)
#define CAPSWITCH_F19_USAGE UINT64_C(0x70000006E)
// These exact property names are documented in Apple Technical Note TN2450.
#define SOURCE_KEY CFSTR("HIDKeyboardModifierMappingSrc")
#define DEST_KEY CFSTR("HIDKeyboardModifierMappingDst")
#define MAPPING_KEY CFSTR("UserKeyMapping")
#define OWNED_SERVICES_KEY CFSTR("CapswitchOwnedKeyboardServiceIDs")
typedef struct {
CFMutableSetRef ownedServices;
} CapswitchRemapper;
static void PersistOwnedServices(CapswitchRemapper *manager) {
CFIndex count = CFSetGetCount(manager->ownedServices);
const void **ids = count > 0 ? calloc((size_t)count, sizeof(void *)) : NULL;
if (count > 0 && ids == NULL) {
return;
}
if (count > 0) {
CFSetGetValues(manager->ownedServices, ids);
}
CFArrayRef array = CFArrayCreate(
kCFAllocatorDefault, ids, count, &kCFTypeArrayCallBacks
);
free(ids);
if (array != NULL) {
CFPreferencesSetAppValue(
OWNED_SERVICES_KEY, array, kCFPreferencesCurrentApplication
);
CFPreferencesAppSynchronize(kCFPreferencesCurrentApplication);
CFRelease(array);
}
}
CapswitchRemapperRef CapswitchRemapperCreate(void) {
CapswitchRemapper *manager = calloc(1, sizeof(*manager));
if (manager == NULL) {
return NULL;
}
manager->ownedServices = CFSetCreateMutable(
kCFAllocatorDefault, 0, &kCFTypeSetCallBacks
);
if (manager->ownedServices == NULL) {
free(manager);
return NULL;
}
CFTypeRef saved = CFPreferencesCopyAppValue(
OWNED_SERVICES_KEY, kCFPreferencesCurrentApplication
);
if (saved != NULL && CFGetTypeID(saved) == CFArrayGetTypeID()) {
CFArrayRef ids = (CFArrayRef)saved;
for (CFIndex i = 0; i < CFArrayGetCount(ids); i++) {
CFTypeRef id = CFArrayGetValueAtIndex(ids, i);
if (CFGetTypeID(id) == CFNumberGetTypeID()) {
CFSetAddValue(manager->ownedServices, id);
}
}
}
if (saved != NULL) {
CFRelease(saved);
}
return manager;
}
void CapswitchRemapperDestroy(CapswitchRemapperRef reference) {
CapswitchRemapper *manager = (CapswitchRemapper *)reference;
if (manager == NULL) {
return;
}
if (manager->ownedServices != NULL) {
CFRelease(manager->ownedServices);
}
free(manager);
}
static bool KeyboardService(IOHIDServiceClientRef service) {
return IOHIDServiceClientConformsTo(
service, kHIDPage_GenericDesktop, kHIDUsage_GD_Keyboard
) != 0;
}
static CFNumberRef ServiceID(IOHIDServiceClientRef service) {
CFTypeRef id = IOHIDServiceClientGetRegistryID(service);
if (id == NULL || CFGetTypeID(id) != CFNumberGetTypeID()) {
return NULL;
}
return (CFNumberRef)id; // Get: borrowed, not released by the caller.
}
static CFArrayRef CopyMappings(IOHIDServiceClientRef service) {
CFTypeRef property = IOHIDServiceClientCopyProperty(service, MAPPING_KEY);
if (property == NULL) {
// A missing UserKeyMapping property means there are no active pairs.
return CFArrayCreate(kCFAllocatorDefault, NULL, 0, &kCFTypeArrayCallBacks);
}
if (CFGetTypeID(property) != CFArrayGetTypeID()) {
CFRelease(property);
return NULL;
}
return (CFArrayRef)property;
}
static bool ReadPair(
CFTypeRef object, uint64_t *source, uint64_t *destination
) {
if (CFGetTypeID(object) != CFDictionaryGetTypeID()) {
return false;
}
CFDictionaryRef mapping = (CFDictionaryRef)object;
CFTypeRef src = CFDictionaryGetValue(mapping, SOURCE_KEY);
CFTypeRef dst = CFDictionaryGetValue(mapping, DEST_KEY);
if (src == NULL || dst == NULL
|| CFGetTypeID(src) != CFNumberGetTypeID()
|| CFGetTypeID(dst) != CFNumberGetTypeID()) {
return false;
}
int64_t signedSource = 0;
int64_t signedDestination = 0;
if (!CFNumberGetValue((CFNumberRef)src, kCFNumberSInt64Type, &signedSource)
|| !CFNumberGetValue((CFNumberRef)dst, kCFNumberSInt64Type, &signedDestination)
|| signedSource < 0 || signedDestination < 0) {
return false;
}
*source = (uint64_t)signedSource;
*destination = (uint64_t)signedDestination;
return true;
}
// -1: malformed; 0: not mapped; 1: Caps->F19 already mapped; 2: conflict.
static int MappingState(CFArrayRef mappings) {
bool found = false;
for (CFIndex i = 0; i < CFArrayGetCount(mappings); i++) {
uint64_t source = 0;
uint64_t destination = 0;
if (!ReadPair(CFArrayGetValueAtIndex(mappings, i), &source, &destination)) {
return -1;
}
if (source == CAPSWITCH_CAPS_USAGE
&& destination == CAPSWITCH_F19_USAGE) {
found = true;
continue;
}
if (source == CAPSWITCH_CAPS_USAGE
|| source == CAPSWITCH_F19_USAGE
|| destination == CAPSWITCH_CAPS_USAGE
|| destination == CAPSWITCH_F19_USAGE) {
return 2;
}
}
return found ? 1 : 0;
}
static CFDictionaryRef CreateOurMapping(void) {
int64_t source = (int64_t)CAPSWITCH_CAPS_USAGE;
int64_t destination = (int64_t)CAPSWITCH_F19_USAGE;
CFNumberRef sourceValue = CFNumberCreate(
kCFAllocatorDefault, kCFNumberSInt64Type, &source
);
CFNumberRef destinationValue = CFNumberCreate(
kCFAllocatorDefault, kCFNumberSInt64Type, &destination
);
if (sourceValue == NULL || destinationValue == NULL) {
if (sourceValue != NULL) CFRelease(sourceValue);
if (destinationValue != NULL) CFRelease(destinationValue);
return NULL;
}
const void *keys[] = { SOURCE_KEY, DEST_KEY };
const void *values[] = { sourceValue, destinationValue };
CFDictionaryRef mapping = CFDictionaryCreate(
kCFAllocatorDefault, keys, values, 2,
&kCFTypeDictionaryKeyCallBacks,
&kCFTypeDictionaryValueCallBacks
);
CFRelease(sourceValue);
CFRelease(destinationValue);
return mapping;
}
static int32_t InspectService(
IOHIDServiceClientRef service, bool *mapped
) {
CFArrayRef current = CopyMappings(service);
if (current == NULL) {
return CapswitchRemapInvalidExistingMappings;
}
int state = MappingState(current);
CFRelease(current);
if (state < 0) {
return CapswitchRemapInvalidExistingMappings;
}
if (state == 2) {
return CapswitchRemapConflict;
}
*mapped = state == 1;
return CapswitchRemapOK;
}
// Keeps a separate record of ownership for each HID keyboard service so
// removing our remap will never remove another program's mapping.
static int32_t InstallService(
CapswitchRemapper *manager, IOHIDServiceClientRef service
) {
CFNumberRef id = ServiceID(service);
if (id == NULL) {
return CapswitchRemapMissingRegistryID;
}
CFArrayRef current = CopyMappings(service);
if (current == NULL) {
return CapswitchRemapInvalidExistingMappings;
}
int state = MappingState(current);
if (state < 0) {
CFRelease(current);
return CapswitchRemapInvalidExistingMappings;
}
if (state == 2) {
CFRelease(current);
return CapswitchRemapConflict;
}
if (state == 1) {
// Existing Caps->F19 mapping may belong to somebody else.
// Only the per-service ownership record authorizes restoration.
CFRelease(current);
return CapswitchRemapOK;
}
CFDictionaryRef ourMapping = CreateOurMapping();
CFMutableArrayRef desired = CFArrayCreateMutableCopy(
kCFAllocatorDefault, 0, current
);
CFRelease(current);
if (ourMapping == NULL || desired == NULL) {
if (ourMapping != NULL) CFRelease(ourMapping);
if (desired != NULL) CFRelease(desired);
return CapswitchRemapOutOfMemory;
}
CFArrayAppendValue(desired, ourMapping);
CFRelease(ourMapping);
// Persist ownership before writing so a crash immediately after writing
// does not leave behind a remap the next launch cannot recognize.
CFSetAddValue(manager->ownedServices, id);
PersistOwnedServices(manager);
bool set = IOHIDServiceClientSetProperty(service, MAPPING_KEY, desired);
CFRelease(desired);
if (!set) {
// Keep the ownership record: a partially accepted write can be safely
// removed by Restore after observing the exact pair on this service.
return CapswitchRemapSetFailed;
}
bool mapped = false;
int32_t result = InspectService(service, &mapped);
if (result != CapswitchRemapOK) {
return result;
}
return mapped ? CapswitchRemapOK : CapswitchRemapVerificationFailed;
}
static int32_t WithKeyboardServices(
CapswitchRemapper *manager,
bool install,
uint32_t *total,
uint32_t *mapped
) {
if (total != NULL) *total = 0;
if (mapped != NULL) *mapped = 0;
if (manager == NULL) return CapswitchRemapOutOfMemory;
IOHIDEventSystemClientRef system =
IOHIDEventSystemClientCreateSimpleClient(kCFAllocatorDefault);
if (system == NULL) {
return CapswitchRemapClientUnavailable;
}
CFArrayRef services = IOHIDEventSystemClientCopyServices(system);
if (services == NULL) {
CFRelease(system);
return CapswitchRemapClientUnavailable;
}
uint32_t keyboardCount = 0;
uint32_t mappedCount = 0;
int32_t result = CapswitchRemapOK;
for (CFIndex i = 0; i < CFArrayGetCount(services); i++) {
IOHIDServiceClientRef service = (IOHIDServiceClientRef)
CFArrayGetValueAtIndex(services, i);
if (!KeyboardService(service)) {
continue;
}
keyboardCount++;
if (install) {
result = InstallService(manager, service);
} else {
bool isMapped = false;
result = InspectService(service, &isMapped);
}
if (result != CapswitchRemapOK) {
break;
}
bool isMapped = false;
result = InspectService(service, &isMapped);
if (result != CapswitchRemapOK) {
break;
}
if (isMapped) mappedCount++;
}
CFRelease(services);
CFRelease(system);
if (total != NULL) *total = keyboardCount;
if (mapped != NULL) *mapped = mappedCount;
if (result != CapswitchRemapOK) {
return result;
}
return keyboardCount > 0 ? CapswitchRemapOK : CapswitchRemapNoKeyboards;
}
int32_t CapswitchRemapperApply(
CapswitchRemapperRef reference,
uint32_t *keyboardCount,
uint32_t *mappedCount
) {
return WithKeyboardServices(
(CapswitchRemapper *)reference, true, keyboardCount, mappedCount
);
}
int32_t CapswitchRemapperInspect(
CapswitchRemapperRef reference,
uint32_t *keyboardCount,
uint32_t *mappedCount
) {
return WithKeyboardServices(
(CapswitchRemapper *)reference, false, keyboardCount, mappedCount
);
}
int32_t CapswitchRemapperRestore(CapswitchRemapperRef reference) {
CapswitchRemapper *manager = (CapswitchRemapper *)reference;
if (manager == NULL) return CapswitchRemapOutOfMemory;
IOHIDEventSystemClientRef system =
IOHIDEventSystemClientCreateSimpleClient(kCFAllocatorDefault);
if (system == NULL) return CapswitchRemapClientUnavailable;
CFArrayRef services = IOHIDEventSystemClientCopyServices(system);
if (services == NULL) {
CFRelease(system);
return CapswitchRemapClientUnavailable;
}
int32_t result = CapswitchRemapOK;
CFMutableSetRef connected = CFSetCreateMutable(
kCFAllocatorDefault, 0, &kCFTypeSetCallBacks
);
if (connected == NULL) {
CFRelease(services);
CFRelease(system);
return CapswitchRemapOutOfMemory;
}
for (CFIndex i = 0; i < CFArrayGetCount(services); i++) {
IOHIDServiceClientRef service = (IOHIDServiceClientRef)
CFArrayGetValueAtIndex(services, i);
if (!KeyboardService(service)) continue;
CFNumberRef id = ServiceID(service);
if (id == NULL) {
result = CapswitchRemapMissingRegistryID;
break;
}
CFSetAddValue(connected, id);
if (!CFSetContainsValue(manager->ownedServices, id)) continue;
CFArrayRef current = CopyMappings(service);
if (current == NULL) {
result = CapswitchRemapInvalidExistingMappings;
break;
}
CFMutableArrayRef desired = CFArrayCreateMutable(
kCFAllocatorDefault, 0, &kCFTypeArrayCallBacks
);
if (desired == NULL) {
CFRelease(current);
result = CapswitchRemapOutOfMemory;
break;
}
bool found = false;
for (CFIndex j = 0; j < CFArrayGetCount(current); j++) {
CFTypeRef entry = CFArrayGetValueAtIndex(current, j);
uint64_t source = 0;
uint64_t destination = 0;
if (!ReadPair(entry, &source, &destination)) {
result = CapswitchRemapInvalidExistingMappings;
break;
}
if (source == CAPSWITCH_CAPS_USAGE
&& destination == CAPSWITCH_F19_USAGE) {
found = true;
} else {
CFArrayAppendValue(desired, entry);
}
}
CFRelease(current);
if (result == CapswitchRemapOK && found) {
if (!IOHIDServiceClientSetProperty(service, MAPPING_KEY, desired)) {
result = CapswitchRemapSetFailed;
} else {
// Verify only that our exact rule disappeared. A third party
// may have added another rule during our lifetime; it must
// not prevent us from restoring (or erasing that rule).
CFArrayRef verified = CopyMappings(service);
if (verified == NULL) {
result = CapswitchRemapInvalidExistingMappings;
} else {
for (CFIndex k = 0; k < CFArrayGetCount(verified); k++) {
uint64_t source = 0;
uint64_t destination = 0;
CFTypeRef entry = CFArrayGetValueAtIndex(verified, k);
if (!ReadPair(entry, &source, &destination)) {
result = CapswitchRemapInvalidExistingMappings;
break;
}
if (source == CAPSWITCH_CAPS_USAGE
&& destination == CAPSWITCH_F19_USAGE) {
result = CapswitchRemapVerificationFailed;
break;
}
}
CFRelease(verified);
}
}
}
CFRelease(desired);
if (result != CapswitchRemapOK) break;
CFSetRemoveValue(manager->ownedServices, id);
PersistOwnedServices(manager);
}
// IDs for disconnected services can be discarded; macOS discards their
// key mappings when the keyboard service is removed.
if (result == CapswitchRemapOK) {
CFIndex count = CFSetGetCount(manager->ownedServices);
const void **ids = count > 0 ? calloc((size_t)count, sizeof(void *)) : NULL;
if (count > 0 && ids == NULL) {
result = CapswitchRemapOutOfMemory;
} else {
if (count > 0) CFSetGetValues(manager->ownedServices, ids);
for (CFIndex i = 0; i < count; i++) {
if (!CFSetContainsValue(connected, ids[i])) {
CFSetRemoveValue(manager->ownedServices, ids[i]);
}
}
free(ids);
PersistOwnedServices(manager);
}
}
CFRelease(connected);
CFRelease(services);
CFRelease(system);
return result;
}

View file

@ -4,8 +4,51 @@
#include <stdbool.h>
#include <stdint.h>
// Returns KERN_SUCCESS (0) or an IOKit/Mach error code.
// Caps Lock lock-state APIs return KERN_SUCCESS (0) or a Mach/IOKit code.
int32_t CapswitchReadCapsLock(bool *enabled);
int32_t CapswitchWriteCapsLock(bool enabled);
// Remaps HID usage 0x700000039 (physical Caps Lock) to 0x70000006E (F19).
// Uses IOHIDEventSystemClient directly in the current process. Does not
// launch hidutil or require root, a privileged helper or a virtual driver.
//
// The opaque manager remembers the registry IDs of keyboard services modified
// by Capswitch; other service mappings are preserved on apply and restoration.
// IDs are persisted in the application's CFPreferences for crash recovery.
typedef void *CapswitchRemapperRef;
typedef enum {
CapswitchRemapOK = 0,
CapswitchRemapOutOfMemory = 1,
CapswitchRemapClientUnavailable = 2,
CapswitchRemapNoKeyboards = 3,
CapswitchRemapInvalidExistingMappings = 4,
CapswitchRemapConflict = 5,
CapswitchRemapSetFailed = 6,
CapswitchRemapVerificationFailed = 7,
CapswitchRemapMissingRegistryID = 8
} CapswitchRemapResult;
CapswitchRemapperRef CapswitchRemapperCreate(void);
void CapswitchRemapperDestroy(CapswitchRemapperRef manager);
// A return of CapswitchRemapOK means every connected keyboard service has the
// Caps->F19 pair; keyboardCount must also be greater than zero.
int32_t CapswitchRemapperApply(
CapswitchRemapperRef manager,
uint32_t *keyboardCount,
uint32_t *mappedCount
);
// Remove our exact Caps->F19 pair from services that Capswitch itself changed.
// Re-read each service before altering it to preserve other remappers' changes.
int32_t CapswitchRemapperRestore(CapswitchRemapperRef manager);
// Read-only diagnostics. No HID mappings are changed.
int32_t CapswitchRemapperInspect(
CapswitchRemapperRef manager,
uint32_t *keyboardCount,
uint32_t *mappedCount
);
#endif

View file

@ -63,10 +63,11 @@ tasks:
- grep -q "keyboardEventKeycode" Sources/CapswitchApp/MacOSInputController.swift
- grep -q "CapsInputRouter.f19VirtualKeyCode" Sources/CapswitchApp/MacOSInputController.swift
- test -f Sources/CapswitchApp/HIDRemappingController.swift
- test -f Sources/Capswitch/HIDMapping.swift
- test -f Sources/CapswitchHID/CapswitchHID.c
- test -f Sources/CapswitchHID/CapswitchRemapping.c
- grep -q "IOHIDServiceClientSetProperty" Sources/CapswitchHID/CapswitchRemapping.c
- '! grep -R -E "Process\(|/usr/bin/hidutil" Sources'
- test -f Tests/CapswitchCoreTests/CapsInputRouterTests.swift
- 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
@ -78,49 +79,18 @@ tasks:
- grep -q "switchToNextInputSource" Sources/Capswitch/CapsGestureMachine.swift
doctor:
desc: Inspect macOS Caps preferences, remapping and running Capswitch processes
desc: Inspect keyboard services through Capswitch's own native IOKit code
cmds:
- |
if [ "$(uname -s)" != "Darwin" ]; then
echo "SKIP: HID diagnostics require macOS."
echo "SKIP: native 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 ==="
mapping="$(hidutil property --get UserKeyMapping 2>&1)" || {
echo "$mapping"
echo "ERROR: hidutil could not read UserKeyMapping." >&2
if [ ! -x "{{.APP_DIR}}/Contents/MacOS/{{.APP_NAME}}" ]; then
echo "Build the Capswitch app first: task bundle" >&2
exit 1
}
echo "$mapping"
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 "=== Diagnosis ==="
case "$mapping" in
*"(null)"* | *"()"*)
echo "NO HID REMAP ACTIVE: physical Caps cannot arrive as F19."
echo "Capswitch can be running without a working event tap or remap."
echo "Check Capswitch menu: Accessibility, Event tap, HID and Error."
echo "On Sonoma, /usr/bin/hidutil may need its OWN Input Monitoring approval."
echo "Grant it via Privacy & Security -> Input Monitoring -> + -> Cmd+Shift+G -> /usr/bin/hidutil."
echo "That is separate from permission for Capswitch.app."
;;
*"HIDKeyboardModifierMappingSrc"*)
echo "Mapping entries exist. Verify Caps -> F19 in the output above."
;;
*)
echo "Unrecognized mapping output. Check the raw values above."
;;
esac
echo "=== Privacy permissions ==="
echo "The terminal cannot verify the running app's TCC status."
echo "Inspect Accessibility / Input Monitoring / Event tap / Error in its menu."
fi
"{{.APP_DIR}}/Contents/MacOS/{{.APP_NAME}}" --doctor
clean:
desc: Remove build outputs

View file

@ -1,58 +0,0 @@
import Foundation
import XCTest
@testable import CapswitchCore
final class HIDMappingCodecTests: XCTestCase {
func testMissingMappingsReadAsEmpty() throws {
XCTAssertEqual(try HIDMappingCodec.parse("(null)\n"), [])
XCTAssertEqual(try HIDMappingCodec.parse("UserKeyMapping:(\n)\n"), [])
}
func testReadsDecimalMappingsAndPreservesTheirOrder() throws {
let output = """
UserKeyMapping:(
{
HIDKeyboardModifierMappingDst = 30064771125;
HIDKeyboardModifierMappingSrc = 30064771172;
},
{
HIDKeyboardModifierMappingSrc = 30064771129;
HIDKeyboardModifierMappingDst = 30064771182;
}
)
"""
let mappings = try HIDMappingCodec.parse(output)
XCTAssertEqual(mappings, [
HIDKeyMapping(source: 30064771172, destination: 30064771125),
.init(source: 30064771129, destination: 30064771182),
])
XCTAssertEqual(mappings[1], HIDMappingCodec.capsToF19)
}
func testEncodeMappingPreservesOtherEntries() throws {
let original = [HIDKeyMapping(source: 30064771302, destination: 30064771300)]
let installed = original + [HIDMappingCodec.capsToF19]
let remaining = installed.filter { $0 != HIDMappingCodec.capsToF19 }
XCTAssertEqual(remaining, original)
let payload = try HIDMappingCodec.setArgument(installed)
let dictionary = try XCTUnwrap(
JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any]
)
let entries = try XCTUnwrap(dictionary["UserKeyMapping"] as? [[String: NSNumber]])
XCTAssertEqual(entries.count, 2)
XCTAssertEqual(
entries[1]["HIDKeyboardModifierMappingDst"]?.uint64Value,
HIDMappingCodec.f19Usage
)
}
func testUnknownFieldsOrGarbageAreRejected() {
XCTAssertThrowsError(try HIDMappingCodec.parse("hidutil failed"))
XCTAssertThrowsError(try HIDMappingCodec.parse("({ Foo = 123; })"))
XCTAssertThrowsError(try HIDMappingCodec.parse(
"({ HIDKeyboardModifierMappingSrc = 1; HIDKeyboardModifierMappingDst = 2; Mystery = 3; })"
))
}
}