feat: remap Caps directly through in-process IOKit
This commit is contained in:
parent
72d500a350
commit
4a7942cbd9
13 changed files with 858 additions and 479 deletions
170
README.md
170
README.md
|
|
@ -1,133 +1,117 @@
|
||||||
# Capswitch
|
# 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).
|
**Caps + Option** in either order: toggle the actual system Caps Lock state.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## 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
|
git pull origin main
|
||||||
task test
|
task test
|
||||||
task bundle
|
task bundle
|
||||||
open build/Capswitch.app
|
open build/Capswitch.app
|
||||||
~~~
|
~~~
|
||||||
|
|
||||||
After opening Capswitch, grant **Accessibility** from the status-bar menu.
|
The bundle is ad-hoc signed with a stable bundle identifier
|
||||||
If the event tap is refused or no F19 events are received, also grant
|
(dev.local.Capswitch). Keep its installed path stable so macOS can retain
|
||||||
**Input Monitoring** from the menu. Restart Capswitch after granting
|
its privacy grants.
|
||||||
permissions if macOS prompts you to do so.
|
|
||||||
|
|
||||||
### macOS Sonoma: grant Input Monitoring to hidutil itself
|
## Permissions — Capswitch only
|
||||||
|
|
||||||
On macOS 14, an Input Monitoring grant for Capswitch.app may not be
|
At first launch Capswitch requests access via macOS:
|
||||||
sufficient for its child process /usr/bin/hidutil, which is used to set
|
- **Accessibility**: needed for an active event tap that suppresses F19.
|
||||||
the actual global HID mapping. If task doctor reports (null), Caps->F19
|
- **Input Monitoring**, if macOS requires it: needed on some macOS setups
|
||||||
is not installed (regardless of whether the app process is running).
|
for observing keyboard events or interacting with the HID subsystem.
|
||||||
|
|
||||||
Open **System Settings -> Privacy & Security -> Input Monitoring**, click
|
Approve Capswitch.app, *never* /usr/bin/hidutil. macOS requires your own
|
||||||
the + button, then press **Command+Shift+G** in the file picker and enter
|
confirmation in System Settings; applications cannot grant themselves access.
|
||||||
**/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.
|
|
||||||
|
|
||||||
If the mapping remains null, inspect the **Capswitch menu** and capture
|
You can check/access those settings through the Capswitch menu. If granting
|
||||||
the Accessibility / Event tap / HID / Error entries. The application may
|
a permission requires a restart, Quit and reopen Capswitch.
|
||||||
be running without having created its event tap at all; Input Monitoring
|
|
||||||
for hidutil cannot fix missing Accessibility permission.
|
|
||||||
|
|
||||||
If you previously used macOS's built-in Caps-to-input-source switching, open
|
To avoid competing with the stock shortcut, disable
|
||||||
System Settings -> Keyboard -> Text Input -> Edit and uncheck
|
**System Settings -> Keyboard -> Text Input -> Edit ->
|
||||||
"Use Caps Lock to switch to and from [last used Latin input source]".
|
Use Caps Lock to switch to/from ABC**. Leave Caps Lock with its normal
|
||||||
Keep Caps assigned to the normal Caps Lock action in Modifier Keys.
|
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:
|
## Status menu
|
||||||
- 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
|
The menu shows:
|
||||||
physical keyboard is actually sending F19. Use the counters to verify it.
|
- 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.
|
## Native diagnostics
|
||||||
- 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.
|
|
||||||
|
|
||||||
## 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
|
task doctor
|
||||||
~~~
|
~~~
|
||||||
|
|
||||||
This prints the native Caps preference, global UserKeyMapping and the keyboard
|
This executes **Capswitch's own signed executable** in a read-only inspection
|
||||||
service-specific mapping; these can differ even when hidutil exits successfully.
|
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:
|
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
|
||||||
~~~bash
|
blindly clear other programs' mapping tables.
|
||||||
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.
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
~~~bash
|
~~~
|
||||||
|
task verify
|
||||||
task lint
|
task lint
|
||||||
task test
|
task test
|
||||||
task build
|
task build
|
||||||
task bundle
|
task bundle
|
||||||
task clean
|
|
||||||
task verify
|
|
||||||
task doctor
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,160 +1,108 @@
|
||||||
|
import CapswitchHID
|
||||||
import Foundation
|
import Foundation
|
||||||
import CapswitchCore
|
|
||||||
|
|
||||||
enum HIDRemapError: LocalizedError {
|
enum HIDRemapError: LocalizedError {
|
||||||
case conflictingMapping
|
case failure(Int32)
|
||||||
case unreadableMapping
|
|
||||||
case verificationFailed
|
|
||||||
case commandFailed(String)
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .conflictingMapping:
|
case let .failure(code):
|
||||||
return "Caps Lock or F19 is already remapped. Disable the conflicting mapping first."
|
switch code {
|
||||||
case .unreadableMapping:
|
case 1:
|
||||||
return "Cannot safely read the existing HID key mappings; no changes were made."
|
return "Not enough memory to manage keyboard mappings."
|
||||||
case .verificationFailed:
|
case 2:
|
||||||
return "hidutil did not apply Caps → F19. On macOS 14, grant Input Monitoring to /usr/bin/hidutil separately from Capswitch.app, then restart Capswitch."
|
return "Cannot access IOKit keyboard services. Check Capswitch's macOS privacy permissions."
|
||||||
case let .commandFailed(details):
|
case 3:
|
||||||
return "hidutil failed: \(details)"
|
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
|
/// Owns the native IOKit remapping manager inside Capswitch itself.
|
||||||
/// installing and when restoring on quit or Pause.
|
/// No subprocess, hidutil, privileged helper, driver or third-party library.
|
||||||
final class HIDRemappingController {
|
final class HIDRemappingController {
|
||||||
private let markerKey = "CapswitchOwnsCapsF19Mapping"
|
private let manager: UnsafeMutableRawPointer?
|
||||||
private let ourMapping = HIDMappingCodec.capsToF19
|
|
||||||
|
|
||||||
private(set) var isInstalled = false
|
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 {
|
func ensureInstalled() throws {
|
||||||
let current = try readMappings()
|
guard let manager else {
|
||||||
|
throw HIDRemapError.failure(1)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do not overwrite Caps remapped to something else, F19 remapped away,
|
var keyboardCount: UInt32 = 0
|
||||||
// or a different physical key remapped to F19.
|
var mappedCount: UInt32 = 0
|
||||||
if current.contains(where: {
|
let status = CapswitchRemapperApply(manager, &keyboardCount, &mappedCount)
|
||||||
$0.source == HIDMappingCodec.capsUsage
|
|
||||||
|| $0.source == HIDMappingCodec.f19Usage
|
keyboardServiceCount = keyboardCount
|
||||||
|| $0.destination == HIDMappingCodec.f19Usage
|
mappedServiceCount = mappedCount
|
||||||
}) {
|
isInstalled = status == 0 && keyboardCount > 0
|
||||||
throw HIDRemapError.conflictingMapping
|
&& 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 {
|
func reapplyIfOwned() throws {
|
||||||
guard ownsMapping || UserDefaults.standard.bool(forKey: markerKey) else {
|
// Reconciliation is idempotent: the native layer checks every
|
||||||
throw HIDRemapError.conflictingMapping
|
// keyboard and only installs missing mappings it can safely own.
|
||||||
}
|
|
||||||
|
|
||||||
let current = try readMappings()
|
|
||||||
guard current.contains(ourMapping) else {
|
|
||||||
try ensureInstalled()
|
try ensureInstalled()
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-broadcast the complete mapping table, never an empty replacement.
|
func inspect() throws {
|
||||||
try writeMappings(current)
|
guard let manager else {
|
||||||
guard try readMappings().contains(ourMapping) else {
|
throw HIDRemapError.failure(1)
|
||||||
throw HIDRemapError.verificationFailed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isInstalled = true
|
var keyboardCount: UInt32 = 0
|
||||||
|
var mappedCount: UInt32 = 0
|
||||||
|
let status = CapswitchRemapperInspect(manager, &keyboardCount, &mappedCount)
|
||||||
|
keyboardServiceCount = keyboardCount
|
||||||
|
mappedServiceCount = mappedCount
|
||||||
|
|
||||||
|
guard status == 0 else {
|
||||||
|
throw HIDRemapError.failure(status)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func restoreIfOwned() throws {
|
func restoreIfOwned() throws {
|
||||||
guard ownsMapping || UserDefaults.standard.bool(forKey: markerKey) else {
|
guard let manager else {
|
||||||
|
throw HIDRemapError.failure(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = CapswitchRemapperRestore(manager)
|
||||||
|
guard status == 0 else {
|
||||||
|
throw HIDRemapError.failure(status)
|
||||||
|
}
|
||||||
|
|
||||||
isInstalled = false
|
isInstalled = false
|
||||||
return
|
keyboardServiceCount = 0
|
||||||
}
|
mappedServiceCount = 0
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -181,8 +181,12 @@ final class MacOSInputController {
|
||||||
remapper.isInstalled
|
remapper.isInstalled
|
||||||
}
|
}
|
||||||
|
|
||||||
// This key is an undocumented macOS preference; missing means unknown,
|
var mappingStatus: String {
|
||||||
// not "off". The UI never changes the user's global keyboard settings.
|
"\(remapper.mappedServiceCount)/\(remapper.keyboardServiceCount) keyboards"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undocumented macOS preference: only diagnostic, never modified by us.
|
||||||
|
// Missing means unknown, not off.
|
||||||
var nativeCapsSwitchState: String {
|
var nativeCapsSwitchState: String {
|
||||||
let value = UserDefaults.standard.object(forKey: "TISRomanSwitchState")
|
let value = UserDefaults.standard.object(forKey: "TISRomanSwitchState")
|
||||||
?? UserDefaults(suiteName: "com.apple.HIToolbox")?
|
?? UserDefaults(suiteName: "com.apple.HIToolbox")?
|
||||||
|
|
@ -274,7 +278,19 @@ final class MacOSInputController {
|
||||||
onStateChanged?()
|
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 {
|
if let eventTap {
|
||||||
CGEvent.tapEnable(tap: eventTap, enable: false)
|
CGEvent.tapEnable(tap: eventTap, enable: false)
|
||||||
}
|
}
|
||||||
|
|
@ -290,13 +306,8 @@ final class MacOSInputController {
|
||||||
eventTap = nil
|
eventTap = nil
|
||||||
runLoopSource = nil
|
runLoopSource = nil
|
||||||
resetGestureState()
|
resetGestureState()
|
||||||
|
|
||||||
do {
|
|
||||||
try remapper.restoreIfOwned()
|
|
||||||
} catch {
|
|
||||||
lastError = "Cannot restore Caps remap: \(error.localizedDescription)"
|
|
||||||
}
|
|
||||||
onStateChanged?()
|
onStateChanged?()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func setPaused(_ paused: Bool) {
|
func setPaused(_ paused: Bool) {
|
||||||
|
|
@ -304,10 +315,12 @@ final class MacOSInputController {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isPaused = paused
|
|
||||||
if paused {
|
if paused {
|
||||||
stop()
|
if stop() {
|
||||||
|
isPaused = true
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
isPaused = false
|
||||||
do {
|
do {
|
||||||
try start()
|
try start()
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -358,6 +371,21 @@ final class MacOSInputController {
|
||||||
onStateChanged?()
|
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() {
|
func requestInputMonitoringPermission() {
|
||||||
_ = CGRequestListenEventAccess()
|
_ = CGRequestListenEventAccess()
|
||||||
if let url = URL(
|
if let url = URL(
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ final class StatusBarController: NSObject, NSMenuDelegate {
|
||||||
keyEquivalent: ""
|
keyEquivalent: ""
|
||||||
)
|
)
|
||||||
private let reapplyMappingItem = NSMenuItem(
|
private let reapplyMappingItem = NSMenuItem(
|
||||||
title: "Reapply Caps → F19 mapping",
|
title: "Retry Caps → F19 mapping",
|
||||||
action: #selector(reapplyMapping),
|
action: #selector(reapplyMapping),
|
||||||
keyEquivalent: ""
|
keyEquivalent: ""
|
||||||
)
|
)
|
||||||
|
|
@ -87,6 +87,7 @@ final class StatusBarController: NSObject, NSMenuDelegate {
|
||||||
object: nil
|
object: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
|
inputController.requestRequiredPermissions()
|
||||||
refreshController()
|
refreshController()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,12 +152,16 @@ final class StatusBarController: NSObject, NSMenuDelegate {
|
||||||
now >= nextStartAttempt {
|
now >= nextStartAttempt {
|
||||||
nextStartAttempt = now.addingTimeInterval(5)
|
nextStartAttempt = now.addingTimeInterval(5)
|
||||||
|
|
||||||
|
if inputController.isTapEnabled {
|
||||||
|
inputController.repairMappingIfNeeded()
|
||||||
|
} else {
|
||||||
do {
|
do {
|
||||||
try inputController.start()
|
try inputController.start()
|
||||||
} catch {
|
} catch {
|
||||||
inputController.lastError = error.localizedDescription
|
inputController.lastError = error.localizedDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if now.timeIntervalSince(lastDeepRefresh) >= 10 {
|
if now.timeIntervalSince(lastDeepRefresh) >= 10 {
|
||||||
lastDeepRefresh = now
|
lastDeepRefresh = now
|
||||||
|
|
@ -184,10 +189,10 @@ final class StatusBarController: NSObject, NSMenuDelegate {
|
||||||
tapStatusItem.isEnabled = false
|
tapStatusItem.isEnabled = false
|
||||||
|
|
||||||
hidMappingItem.title = inputController.hidMappingConfigured
|
hidMappingItem.title = inputController.hidMappingConfigured
|
||||||
? "HID Caps → F19: configured (verify physical keyboard)"
|
? "Native HID: \(inputController.mappingStatus)"
|
||||||
: "HID Caps → F19: not configured"
|
: "Native HID inactive: \(inputController.mappingStatus)"
|
||||||
hidMappingItem.isEnabled = false
|
hidMappingItem.isEnabled = false
|
||||||
reapplyMappingItem.isEnabled = inputController.isRunning
|
reapplyMappingItem.isEnabled = inputController.isTapEnabled
|
||||||
|
|
||||||
errorItem.isHidden = inputController.lastError == nil
|
errorItem.isHidden = inputController.lastError == nil
|
||||||
errorItem.title = "Error: \(inputController.lastError ?? "")"
|
errorItem.title = "Error: \(inputController.lastError ?? "")"
|
||||||
|
|
@ -255,6 +260,16 @@ final class StatusBarController: NSObject, NSMenuDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func quit() {
|
@objc private func quit() {
|
||||||
|
if inputController.stop() {
|
||||||
NSApp.terminate(nil)
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,27 @@
|
||||||
import AppKit
|
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 application = NSApplication.shared
|
||||||
let delegate = AppDelegate()
|
let delegate = AppDelegate()
|
||||||
|
|
|
||||||
513
Sources/CapswitchHID/CapswitchRemapping.c
Normal file
513
Sources/CapswitchHID/CapswitchRemapping.c
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -4,8 +4,51 @@
|
||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
#include <stdint.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 CapswitchReadCapsLock(bool *enabled);
|
||||||
int32_t CapswitchWriteCapsLock(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
|
#endif
|
||||||
|
|
|
||||||
48
Taskfile.yml
48
Taskfile.yml
|
|
@ -63,10 +63,11 @@ tasks:
|
||||||
- grep -q "keyboardEventKeycode" Sources/CapswitchApp/MacOSInputController.swift
|
- grep -q "keyboardEventKeycode" Sources/CapswitchApp/MacOSInputController.swift
|
||||||
- grep -q "CapsInputRouter.f19VirtualKeyCode" Sources/CapswitchApp/MacOSInputController.swift
|
- grep -q "CapsInputRouter.f19VirtualKeyCode" Sources/CapswitchApp/MacOSInputController.swift
|
||||||
- test -f Sources/CapswitchApp/HIDRemappingController.swift
|
- test -f Sources/CapswitchApp/HIDRemappingController.swift
|
||||||
- test -f Sources/Capswitch/HIDMapping.swift
|
|
||||||
- test -f Sources/CapswitchHID/CapswitchHID.c
|
- 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/CapsInputRouterTests.swift
|
||||||
- test -f Tests/CapswitchCoreTests/HIDMappingCodecTests.swift
|
|
||||||
- grep -q "CGEvent.tapCreate" Sources/CapswitchApp/MacOSInputController.swift
|
- grep -q "CGEvent.tapCreate" Sources/CapswitchApp/MacOSInputController.swift
|
||||||
- grep -q "CGEvent.tapEnable" Sources/CapswitchApp/MacOSInputController.swift
|
- grep -q "CGEvent.tapEnable" Sources/CapswitchApp/MacOSInputController.swift
|
||||||
- grep -q "CGPreflightListenEventAccess" Sources/CapswitchApp/MacOSInputController.swift
|
- grep -q "CGPreflightListenEventAccess" Sources/CapswitchApp/MacOSInputController.swift
|
||||||
|
|
@ -78,49 +79,18 @@ tasks:
|
||||||
- grep -q "switchToNextInputSource" Sources/Capswitch/CapsGestureMachine.swift
|
- grep -q "switchToNextInputSource" Sources/Capswitch/CapsGestureMachine.swift
|
||||||
|
|
||||||
doctor:
|
doctor:
|
||||||
desc: Inspect macOS Caps preferences, remapping and running Capswitch processes
|
desc: Inspect keyboard services through Capswitch's own native IOKit code
|
||||||
cmds:
|
cmds:
|
||||||
- |
|
- |
|
||||||
if [ "$(uname -s)" != "Darwin" ]; then
|
if [ "$(uname -s)" != "Darwin" ]; then
|
||||||
echo "SKIP: HID diagnostics require macOS."
|
echo "SKIP: native HID diagnostics require macOS."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "=== macOS ==="
|
if [ ! -x "{{.APP_DIR}}/Contents/MacOS/{{.APP_NAME}}" ]; then
|
||||||
sw_vers
|
echo "Build the Capswitch app first: task bundle" >&2
|
||||||
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
|
|
||||||
exit 1
|
exit 1
|
||||||
}
|
fi
|
||||||
echo "$mapping"
|
"{{.APP_DIR}}/Contents/MacOS/{{.APP_NAME}}" --doctor
|
||||||
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."
|
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
desc: Remove build outputs
|
desc: Remove build outputs
|
||||||
|
|
|
||||||
|
|
@ -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; })"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -118,3 +118,10 @@ The original Caps Lock event-tap approach was not reliable during fast typing.
|
||||||
The code now relies on a Caps-to-F19 HID mapping, direct F19 keyDown/keyUp,
|
The code now relies on a Caps-to-F19 HID mapping, direct F19 keyDown/keyUp,
|
||||||
a managed remap lifecycle, and a small IOKit bridge for true Caps Lock state.
|
a managed remap lifecycle, and a small IOKit bridge for true Caps Lock state.
|
||||||
The original spec is retained for historical debugging context.
|
The original spec is retained for historical debugging context.
|
||||||
|
|
||||||
|
## In-process HID release
|
||||||
|
|
||||||
|
The latest native design is in
|
||||||
|
docs/superpowers/specs/2026-10-25-in-process-hid-remap.md.
|
||||||
|
Capswitch now uses IOHIDEventSystemClient and IOHIDServiceClientSetProperty
|
||||||
|
inside CapswitchHID, replacing all subprocess-based remapping.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
> Historical design. Superseded by 2026-10-25-in-process-hid-remap.md.
|
||||||
|
> The executable no longer starts hidutil or requests permission for it.
|
||||||
|
|
||||||
# Capswitch HID-Level Caps Lock Remapping
|
# Capswitch HID-Level Caps Lock Remapping
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
|
||||||
46
docs/superpowers/specs/2026-10-25-in-process-hid-remap.md
Normal file
46
docs/superpowers/specs/2026-10-25-in-process-hid-remap.md
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# In-Process Native HID Remapping
|
||||||
|
|
||||||
|
## User contract
|
||||||
|
|
||||||
|
The app installs physical Caps->F19 mappings and requests any required input
|
||||||
|
privileges under Capswitch's own identity. Never request that the user grant
|
||||||
|
Input Monitoring or Accessibility to a system binary or helper.
|
||||||
|
Do not launch /usr/bin/hidutil or other executables.
|
||||||
|
|
||||||
|
## Apple-supported mechanism
|
||||||
|
|
||||||
|
Technical Note TN2450 documents:
|
||||||
|
- IOHIDEventSystemClientCreateSimpleClient
|
||||||
|
- IOHIDEventSystemClientCopyServices
|
||||||
|
- IOHIDServiceClientConformsTo(kHIDPage_GenericDesktop, kHIDUsage_GD_Keyboard)
|
||||||
|
- IOHIDServiceClientCopyProperty/SetProperty with UserKeyMapping.
|
||||||
|
|
||||||
|
The IOKit adapter is compiled inside the existing CapswitchHID SwiftPM C
|
||||||
|
target, linked to IOKit in the Capswitch executable.
|
||||||
|
|
||||||
|
## Safety and lifecycle
|
||||||
|
|
||||||
|
- Set up active Quartz event tap before changing HID keyboard mappings.
|
||||||
|
- Enumerate connected keyboard services, read each current UserKeyMapping,
|
||||||
|
and fail closed if malformed or conflicting with Caps/F19.
|
||||||
|
- Preserve other mappings while appending Caps->F19 only when absent.
|
||||||
|
- Verify the result per service, do not trust a successful setter alone.
|
||||||
|
- Persist owned service registry IDs under Capswitch's own preferences so a
|
||||||
|
crash/restart can restore exactly Capswitch-owned rules.
|
||||||
|
- On Pause/Quit, remove only the exact pair for those owned services.
|
||||||
|
- A disconnected service's mapping is discarded by macOS.
|
||||||
|
- On wake/reconnect, re-enumerate and reconcile.
|
||||||
|
- No privileged helpers, keyboard drivers, or external subprocesses.
|
||||||
|
- Both Accessibility and Input Monitoring requests target Capswitch; the OS
|
||||||
|
remains responsible for user consent.
|
||||||
|
|
||||||
|
## Acceptance tests
|
||||||
|
|
||||||
|
- Build without Xcode IDE using Taskfile and Command Line Tools on macOS 14.8.9.
|
||||||
|
- First launch creates permission prompts where appropriate.
|
||||||
|
- Native --doctor reports keyboard services and applied pair counts.
|
||||||
|
- Short Caps taps between typed letters register as F19 down/up and switch
|
||||||
|
exactly once on release.
|
||||||
|
- Option+Caps toggles real Caps Lock including actual typing state.
|
||||||
|
- Pause/Quit preserves foreign HID remappings.
|
||||||
|
- Keyboard reconnect / sleep recovery works.
|
||||||
Loading…
Reference in a new issue