feat: remap Caps directly through in-process IOKit
This commit is contained in:
parent
14f9469ee2
commit
4c47f2cf08
10 changed files with 802 additions and 479 deletions
170
README.md
170
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 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
|
||||
}
|
||||
|
||||
let current = try readMappings()
|
||||
guard current.contains(ourMapping) else {
|
||||
// Reconciliation is idempotent: the native layer checks every
|
||||
// keyboard and only installs missing mappings it can safely own.
|
||||
try ensureInstalled()
|
||||
return
|
||||
}
|
||||
|
||||
// Re-broadcast the complete mapping table, never an empty replacement.
|
||||
try writeMappings(current)
|
||||
guard try readMappings().contains(ourMapping) else {
|
||||
throw HIDRemapError.verificationFailed
|
||||
func inspect() throws {
|
||||
guard let manager else {
|
||||
throw HIDRemapError.failure(1)
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
keyboardServiceCount = 0
|
||||
mappedServiceCount = 0
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,12 +152,16 @@ final class StatusBarController: NSObject, NSMenuDelegate {
|
|||
now >= nextStartAttempt {
|
||||
nextStartAttempt = now.addingTimeInterval(5)
|
||||
|
||||
if inputController.isTapEnabled {
|
||||
inputController.repairMappingIfNeeded()
|
||||
} else {
|
||||
do {
|
||||
try inputController.start()
|
||||
} catch {
|
||||
inputController.lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if now.timeIntervalSince(lastDeepRefresh) >= 10 {
|
||||
lastDeepRefresh = now
|
||||
|
|
@ -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() {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
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 <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
|
||||
|
|
|
|||
48
Taskfile.yml
48
Taskfile.yml
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; })"
|
||||
))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue