hid-keymap 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -2
- package/bin/hid-keymap.js +30 -0
- package/native/hid-keymap +0 -0
- package/package.json +31 -4
- package/src/hid-keymap.swift +477 -0
package/README.md
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
1
|
-
#
|
|
1
|
+
# hid-keymap
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
macOS HID keyboard Event Viewer and key mapper. The npm package includes a universal binary for Apple Silicon and Intel Macs; it has no third-party runtime dependencies.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install --global hid-keymap
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
With no arguments, the command prints help. Start the live Event Viewer with:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
hid-keymap --monitor
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Map a non-keyboard HID usage to a modifier, key, or chord:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
hid-keymap --map 0x01/0x97=right_option
|
|
21
|
+
hid-keymap --map 0x01/0x97=key:escape
|
|
22
|
+
hid-keymap --map 0x01/0x97=left_command+key:space
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Use `hid-keymap --list-targets` to list accepted target names. By default, it listens to all keyboard collections; add `--device VID/PID` to scope a monitor or mapping to one keyboard.
|
|
26
|
+
|
|
27
|
+
## Permissions and behavior
|
|
28
|
+
|
|
29
|
+
Event monitoring needs **Input Monitoring** permission. Mapping also needs **Accessibility** permission to post synthetic key events. macOS may attribute these permissions to the terminal app that launched the command. Set them in **System Settings → Privacy & Security**.
|
|
30
|
+
|
|
31
|
+
The tool observes HID events and does not suppress the keyboard's original events. It can map non-keyboard-page HID state usages, but cannot replace normal keyboard-page (`0x07`) key events. The keyboard's own Fn layer remains active.
|
|
32
|
+
|
|
33
|
+
The Event Viewer prints device names and HID page/usage/value data to the terminal, but does not save or transmit it. Press Ctrl-C to stop and release held synthetic modifiers.
|
|
34
|
+
|
|
35
|
+
## Build
|
|
36
|
+
|
|
37
|
+
The included Swift source is in `src/hid-keymap.swift`. Building from source requires Xcode Command Line Tools on macOS.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { spawnSync } = require('node:child_process');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
if (process.platform !== 'darwin') {
|
|
8
|
+
console.error('hid-keymap currently supports macOS only.');
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (!['arm64', 'x64'].includes(process.arch)) {
|
|
13
|
+
console.error(`hid-keymap does not support macOS architecture: ${process.arch}`);
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const executable = path.resolve(__dirname, '..', 'native', 'hid-keymap');
|
|
18
|
+
const packageRoot = path.resolve(__dirname, '..');
|
|
19
|
+
const relativeExecutable = path.relative(packageRoot, executable);
|
|
20
|
+
if (relativeExecutable.startsWith('..') || path.isAbsolute(relativeExecutable)) {
|
|
21
|
+
console.error('Invalid bundled executable path.');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const result = spawnSync(executable, process.argv.slice(2), { stdio: 'inherit' });
|
|
26
|
+
if (result.error) {
|
|
27
|
+
console.error(`Could not start hid-keymap: ${result.error.message}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
process.exitCode = result.status ?? 1;
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hid-keymap",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "macOS HID keyboard event viewer and key mapper",
|
|
5
|
+
"bin": {
|
|
6
|
+
"hid-keymap": "bin/hid-keymap.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"README.md",
|
|
10
|
+
"bin/",
|
|
11
|
+
"native/",
|
|
12
|
+
"src/"
|
|
13
|
+
],
|
|
14
|
+
"os": [
|
|
15
|
+
"darwin"
|
|
16
|
+
],
|
|
17
|
+
"cpu": [
|
|
18
|
+
"x64",
|
|
19
|
+
"arm64"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"registry": "https://registry.npmjs.org/"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"macOS",
|
|
29
|
+
"HID",
|
|
30
|
+
"keyboard",
|
|
31
|
+
"keymap"
|
|
32
|
+
]
|
|
33
|
+
}
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import ApplicationServices
|
|
2
|
+
import CoreGraphics
|
|
3
|
+
import Foundation
|
|
4
|
+
import IOKit.hid
|
|
5
|
+
import IOKit.hidsystem
|
|
6
|
+
|
|
7
|
+
private let genericDesktopPage = 0x01
|
|
8
|
+
private let keyboardUsage = 0x06
|
|
9
|
+
|
|
10
|
+
private struct HIDCode: Hashable, CustomStringConvertible {
|
|
11
|
+
let page: UInt32
|
|
12
|
+
let usage: UInt32
|
|
13
|
+
|
|
14
|
+
var description: String { String(format: "0x%04x/0x%04x", page, usage) }
|
|
15
|
+
|
|
16
|
+
static func parse(_ text: String) throws -> HIDCode {
|
|
17
|
+
let parts = text.split(separator: "/", omittingEmptySubsequences: false)
|
|
18
|
+
guard parts.count == 2 else { throw CLIError("Expected HID code PAGE/USAGE, such as 0x01/0x97") }
|
|
19
|
+
let page = try parseNumber(String(parts[0]))
|
|
20
|
+
let usage = try parseNumber(String(parts[1]))
|
|
21
|
+
guard page <= UInt32(UInt16.max), usage <= UInt32(UInt16.max) else {
|
|
22
|
+
throw CLIError("HID page and usage must each fit in 16 bits")
|
|
23
|
+
}
|
|
24
|
+
return HIDCode(page: page, usage: usage)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private static func parseNumber(_ raw: String) throws -> UInt32 {
|
|
28
|
+
let value: UInt32?
|
|
29
|
+
if raw.hasPrefix("0x") || raw.hasPrefix("0X") {
|
|
30
|
+
value = UInt32(raw.dropFirst(2), radix: 16)
|
|
31
|
+
} else {
|
|
32
|
+
value = UInt32(raw, radix: 10)
|
|
33
|
+
}
|
|
34
|
+
guard let value else { throw CLIError("Invalid HID number: \(raw)") }
|
|
35
|
+
return value
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private enum ModifierTarget: String, CaseIterable, Hashable {
|
|
40
|
+
case leftControl = "left_control"
|
|
41
|
+
case rightControl = "right_control"
|
|
42
|
+
case leftOption = "left_option"
|
|
43
|
+
case rightOption = "right_option"
|
|
44
|
+
case leftCommand = "left_command"
|
|
45
|
+
case rightCommand = "right_command"
|
|
46
|
+
case leftShift = "left_shift"
|
|
47
|
+
case rightShift = "right_shift"
|
|
48
|
+
|
|
49
|
+
var keyCode: CGKeyCode {
|
|
50
|
+
switch self {
|
|
51
|
+
case .leftControl: return 0x3b
|
|
52
|
+
case .rightControl: return 0x3e
|
|
53
|
+
case .leftOption: return 0x3a
|
|
54
|
+
case .rightOption: return 0x3d
|
|
55
|
+
case .leftCommand: return 0x37
|
|
56
|
+
case .rightCommand: return 0x36
|
|
57
|
+
case .leftShift: return 0x38
|
|
58
|
+
case .rightShift: return 0x3c
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
var flag: CGEventFlags {
|
|
63
|
+
switch self {
|
|
64
|
+
case .leftControl, .rightControl: return .maskControl
|
|
65
|
+
case .leftOption, .rightOption: return .maskAlternate
|
|
66
|
+
case .leftCommand, .rightCommand: return .maskCommand
|
|
67
|
+
case .leftShift, .rightShift: return .maskShift
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private struct KeyTarget: Hashable, CustomStringConvertible {
|
|
73
|
+
let keyCode: CGKeyCode
|
|
74
|
+
let modifiers: Set<ModifierTarget>
|
|
75
|
+
let modifierOnly: ModifierTarget?
|
|
76
|
+
let name: String
|
|
77
|
+
|
|
78
|
+
var description: String { name }
|
|
79
|
+
|
|
80
|
+
static let keyCodes: [String: CGKeyCode] = [
|
|
81
|
+
"a": 0x00, "s": 0x01, "d": 0x02, "f": 0x03, "h": 0x04, "g": 0x05,
|
|
82
|
+
"z": 0x06, "x": 0x07, "c": 0x08, "v": 0x09, "b": 0x0b, "q": 0x0c,
|
|
83
|
+
"w": 0x0d, "e": 0x0e, "r": 0x0f, "y": 0x10, "t": 0x11,
|
|
84
|
+
"1": 0x12, "2": 0x13, "3": 0x14, "4": 0x15, "6": 0x16, "5": 0x17,
|
|
85
|
+
"=": 0x18, "9": 0x19, "7": 0x1a, "-": 0x1b, "8": 0x1c, "0": 0x1d,
|
|
86
|
+
"]": 0x1e, "o": 0x1f, "u": 0x20, "[": 0x21, "i": 0x22, "p": 0x23,
|
|
87
|
+
"return": 0x24, "l": 0x25, "j": 0x26, "'": 0x27, "k": 0x28, ";": 0x29,
|
|
88
|
+
"\\": 0x2a, ",": 0x2b, "/": 0x2c, "n": 0x2d, "m": 0x2e, ".": 0x2f,
|
|
89
|
+
"tab": 0x30, "space": 0x31, "grave": 0x32, "backspace": 0x33, "escape": 0x35,
|
|
90
|
+
"f1": 0x7a, "f2": 0x78, "f3": 0x63, "f4": 0x76, "f5": 0x60, "f6": 0x61,
|
|
91
|
+
"f7": 0x62, "f8": 0x64, "f9": 0x65, "f10": 0x6d, "f11": 0x67, "f12": 0x6f,
|
|
92
|
+
"home": 0x73, "page_up": 0x74, "forward_delete": 0x75, "end": 0x77,
|
|
93
|
+
"page_down": 0x79, "left": 0x7b, "right": 0x7c, "down": 0x7d, "up": 0x7e
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
static func parse(_ raw: String) throws -> KeyTarget {
|
|
97
|
+
if let modifier = ModifierTarget(rawValue: raw) {
|
|
98
|
+
return KeyTarget(keyCode: modifier.keyCode, modifiers: [modifier], modifierOnly: modifier, name: raw)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let parts = raw.split(separator: "+", omittingEmptySubsequences: false).map(String.init)
|
|
102
|
+
guard !parts.isEmpty, !parts.contains(where: \.isEmpty) else {
|
|
103
|
+
throw CLIError("Invalid target '\(raw)'. Use a key name, modifier name, or modifier+key, such as left_command+key:space")
|
|
104
|
+
}
|
|
105
|
+
var modifiers = Set<ModifierTarget>()
|
|
106
|
+
var keyName: String?
|
|
107
|
+
for part in parts {
|
|
108
|
+
if let modifier = ModifierTarget(rawValue: part) {
|
|
109
|
+
modifiers.insert(modifier)
|
|
110
|
+
} else if part.hasPrefix("key:"), keyName == nil {
|
|
111
|
+
keyName = String(part.dropFirst(4))
|
|
112
|
+
} else {
|
|
113
|
+
throw CLIError("Unknown target component '\(part)'. Use --list-targets to see available keys")
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
guard let keyName, let keyCode = keyCodes[keyName] else {
|
|
117
|
+
throw CLIError("Target must include one supported key (key:NAME). Use --list-targets to see names")
|
|
118
|
+
}
|
|
119
|
+
let canonicalModifiers = ModifierTarget.allCases.filter { modifiers.contains($0) }.map(\.rawValue)
|
|
120
|
+
let name = (canonicalModifiers + ["key:\(keyName)"]).joined(separator: "+")
|
|
121
|
+
return KeyTarget(keyCode: keyCode, modifiers: modifiers, modifierOnly: nil, name: name)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private struct HIDMapping {
|
|
126
|
+
let source: HIDCode
|
|
127
|
+
let target: KeyTarget
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private struct DeviceFilter {
|
|
131
|
+
let vendorID: UInt32
|
|
132
|
+
let productID: UInt32
|
|
133
|
+
|
|
134
|
+
static func parse(_ raw: String) throws -> DeviceFilter {
|
|
135
|
+
let components = raw.split(separator: "/", omittingEmptySubsequences: false)
|
|
136
|
+
guard components.count == 2,
|
|
137
|
+
let vendor = parseInteger(String(components[0])),
|
|
138
|
+
let product = parseInteger(String(components[1])) else {
|
|
139
|
+
throw CLIError("Expected device filter VID/PID, such as 0x045e/0x0832")
|
|
140
|
+
}
|
|
141
|
+
return DeviceFilter(vendorID: vendor, productID: product)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private struct CLIOptions {
|
|
146
|
+
let mappings: [HIDMapping]?
|
|
147
|
+
let deviceFilter: DeviceFilter?
|
|
148
|
+
let monitor: Bool
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private struct DeviceInfo {
|
|
152
|
+
let id: String
|
|
153
|
+
let label: String
|
|
154
|
+
let vendorID: UInt32
|
|
155
|
+
let productID: UInt32
|
|
156
|
+
|
|
157
|
+
init(_ device: IOHIDDevice) {
|
|
158
|
+
let name = IOHIDDeviceGetProperty(device, kIOHIDProductKey as CFString) as? String ?? "Keyboard"
|
|
159
|
+
let vendor = (IOHIDDeviceGetProperty(device, kIOHIDVendorIDKey as CFString) as? NSNumber)?.uint32Value ?? 0
|
|
160
|
+
let product = (IOHIDDeviceGetProperty(device, kIOHIDProductIDKey as CFString) as? NSNumber)?.uint32Value ?? 0
|
|
161
|
+
vendorID = vendor
|
|
162
|
+
productID = product
|
|
163
|
+
let location = (IOHIDDeviceGetProperty(device, kIOHIDLocationIDKey as CFString) as? NSNumber)?.uint32Value
|
|
164
|
+
id = location.map { String(format: "%04x:%08x", vendor, $0) } ?? String(format: "%04x:%04x:%@", vendor, product, name)
|
|
165
|
+
label = String(format: "%@ [0x%04x:0x%04x]", name, vendor, product)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
func matches(_ filter: DeviceFilter?) -> Bool {
|
|
169
|
+
guard let filter else { return true }
|
|
170
|
+
return vendorID == filter.vendorID && productID == filter.productID
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private struct ActiveSource: Hashable {
|
|
175
|
+
let deviceID: String
|
|
176
|
+
let code: HIDCode
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private func parseInteger(_ raw: String) -> UInt32? {
|
|
180
|
+
if raw.hasPrefix("0x") || raw.hasPrefix("0X") {
|
|
181
|
+
return UInt32(raw.dropFirst(2), radix: 16)
|
|
182
|
+
}
|
|
183
|
+
return UInt32(raw, radix: 10)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private struct CLIError: Error, CustomStringConvertible {
|
|
187
|
+
let message: String
|
|
188
|
+
init(_ message: String) { self.message = message }
|
|
189
|
+
var description: String { message }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private func parseArguments(_ arguments: [String]) throws -> CLIOptions {
|
|
193
|
+
if arguments.isEmpty {
|
|
194
|
+
printUsage()
|
|
195
|
+
exit(EXIT_SUCCESS)
|
|
196
|
+
}
|
|
197
|
+
if arguments == ["--help"] || arguments == ["-h"] {
|
|
198
|
+
printUsage()
|
|
199
|
+
exit(EXIT_SUCCESS)
|
|
200
|
+
}
|
|
201
|
+
if arguments == ["--list-targets"] {
|
|
202
|
+
printTargets()
|
|
203
|
+
exit(EXIT_SUCCESS)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
var mappings: [HIDMapping] = []
|
|
207
|
+
var seenSources = Set<HIDCode>()
|
|
208
|
+
var deviceFilter: DeviceFilter?
|
|
209
|
+
var monitor = false
|
|
210
|
+
var index = 0
|
|
211
|
+
while index < arguments.count {
|
|
212
|
+
if arguments[index] == "--monitor" {
|
|
213
|
+
guard !monitor else { throw CLIError("Specify --monitor only once") }
|
|
214
|
+
monitor = true
|
|
215
|
+
index += 1
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
if arguments[index] == "--device" {
|
|
219
|
+
index += 1
|
|
220
|
+
guard index < arguments.count else { throw CLIError("--device requires VID/PID") }
|
|
221
|
+
guard deviceFilter == nil else { throw CLIError("Specify --device only once") }
|
|
222
|
+
deviceFilter = try DeviceFilter.parse(arguments[index])
|
|
223
|
+
index += 1
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
226
|
+
guard arguments[index] == "--map" else {
|
|
227
|
+
throw CLIError("Unknown argument: \(arguments[index])")
|
|
228
|
+
}
|
|
229
|
+
index += 1
|
|
230
|
+
guard index < arguments.count else { throw CLIError("--map requires PAGE/USAGE=TARGET") }
|
|
231
|
+
let pair = arguments[index].split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
|
|
232
|
+
guard pair.count == 2 else { throw CLIError("Expected PAGE/USAGE=TARGET after --map") }
|
|
233
|
+
let source = try HIDCode.parse(String(pair[0]))
|
|
234
|
+
guard source.page != 0x07 else {
|
|
235
|
+
throw CLIError("Keyboard-page sources (0x07) are passed through and cannot be safely remapped by this listener")
|
|
236
|
+
}
|
|
237
|
+
let target = try KeyTarget.parse(String(pair[1]))
|
|
238
|
+
guard seenSources.insert(source).inserted else { throw CLIError("Duplicate source HID code: \(source)") }
|
|
239
|
+
mappings.append(HIDMapping(source: source, target: target))
|
|
240
|
+
index += 1
|
|
241
|
+
}
|
|
242
|
+
guard !(monitor && !mappings.isEmpty) else { throw CLIError("--monitor cannot be combined with --map") }
|
|
243
|
+
guard monitor || !mappings.isEmpty else { throw CLIError("Use --monitor to view events, or --map to create a mapping") }
|
|
244
|
+
return CLIOptions(mappings: mappings.isEmpty ? nil : mappings, deviceFilter: deviceFilter, monitor: monitor)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private func printUsage() {
|
|
248
|
+
print("""
|
|
249
|
+
hid-keymap — HID Event Viewer / keyboard mapper
|
|
250
|
+
|
|
251
|
+
Usage:
|
|
252
|
+
hid-keymap --monitor [--device VID/PID]
|
|
253
|
+
Listen and print HID usage/value changes (Event Viewer mode).
|
|
254
|
+
hid-keymap --map PAGE/USAGE=TARGET [--map PAGE/USAGE=TARGET ...] [--device VID/PID]
|
|
255
|
+
Map one or more non-keyboard HID state usages to held keys or modifiers.
|
|
256
|
+
hid-keymap --list-targets
|
|
257
|
+
List supported target key names.
|
|
258
|
+
|
|
259
|
+
Example:
|
|
260
|
+
hid-keymap --monitor
|
|
261
|
+
hid-keymap --map 0x01/0x97=right_option
|
|
262
|
+
hid-keymap --map 0x01/0x97=key:escape
|
|
263
|
+
hid-keymap --map 0x01/0x97=left_command+key:space
|
|
264
|
+
|
|
265
|
+
Targets:
|
|
266
|
+
Modifier alone: right_option
|
|
267
|
+
Key alone: key:escape
|
|
268
|
+
Chord: left_command+key:space (modifiers: left/right_control, option, command, shift)
|
|
269
|
+
|
|
270
|
+
Without --device, the listener includes every connected keyboard.
|
|
271
|
+
Keyboard-page sources (0x07) are rejected because this observer cannot
|
|
272
|
+
suppress their original key events.
|
|
273
|
+
""")
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private func printTargets() {
|
|
277
|
+
print("Modifier targets (use alone to hold the modifier):")
|
|
278
|
+
print(ModifierTarget.allCases.map(\.rawValue).joined(separator: ", "))
|
|
279
|
+
print("\nKey targets (prefix names with key:):")
|
|
280
|
+
print(KeyTarget.keyCodes.keys.sorted().map { "key:\($0)" }.joined(separator: "\n"))
|
|
281
|
+
print("\nExamples: right_option, key:escape, left_command+key:space")
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private func inputValueCallback(context: UnsafeMutableRawPointer?, result: IOReturn, sender: UnsafeMutableRawPointer?, value: IOHIDValue) {
|
|
285
|
+
guard result == kIOReturnSuccess, let context else { return }
|
|
286
|
+
Unmanaged<HIDMapper>.fromOpaque(context).takeUnretainedValue().handle(value)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private func deviceMatchedCallback(context: UnsafeMutableRawPointer?, result: IOReturn, sender: UnsafeMutableRawPointer?, device: IOHIDDevice) {
|
|
290
|
+
guard result == kIOReturnSuccess, let context else { return }
|
|
291
|
+
Unmanaged<HIDMapper>.fromOpaque(context).takeUnretainedValue().setConnected(device, connected: true)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private func deviceRemovedCallback(context: UnsafeMutableRawPointer?, result: IOReturn, sender: UnsafeMutableRawPointer?, device: IOHIDDevice) {
|
|
295
|
+
guard result == kIOReturnSuccess, let context else { return }
|
|
296
|
+
Unmanaged<HIDMapper>.fromOpaque(context).takeUnretainedValue().setConnected(device, connected: false)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private final class HIDMapper {
|
|
300
|
+
private let mappings: [HIDCode: KeyTarget]
|
|
301
|
+
private let deviceFilter: DeviceFilter?
|
|
302
|
+
private let eventViewer: Bool
|
|
303
|
+
private var manager: IOHIDManager?
|
|
304
|
+
private var activeSources = Set<ActiveSource>()
|
|
305
|
+
private var activeTargets = Set<KeyTarget>()
|
|
306
|
+
private var connectedDevices = Set<String>()
|
|
307
|
+
private let formatter = ISO8601DateFormatter()
|
|
308
|
+
|
|
309
|
+
init(options: CLIOptions) {
|
|
310
|
+
eventViewer = options.monitor
|
|
311
|
+
deviceFilter = options.deviceFilter
|
|
312
|
+
mappings = Dictionary(uniqueKeysWithValues: (options.mappings ?? []).map { ($0.source, $0.target) })
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
func start() throws {
|
|
316
|
+
if IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) != kIOHIDAccessTypeGranted {
|
|
317
|
+
_ = IOHIDRequestAccess(kIOHIDRequestTypeListenEvent)
|
|
318
|
+
}
|
|
319
|
+
guard IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) == kIOHIDAccessTypeGranted else {
|
|
320
|
+
throw CLIError("Input Monitoring permission is required. Allow the launching app (usually Terminal) in System Settings → Privacy & Security → Input Monitoring, then run again.")
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if !eventViewer && !AXIsProcessTrusted() {
|
|
324
|
+
let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
|
|
325
|
+
_ = AXIsProcessTrustedWithOptions([promptKey: true] as CFDictionary)
|
|
326
|
+
}
|
|
327
|
+
guard eventViewer || AXIsProcessTrusted() else {
|
|
328
|
+
throw CLIError("Accessibility permission is required to post modifier events. Allow the launching app (usually Terminal) in System Settings → Privacy & Security → Accessibility, then run again.")
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let newManager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
|
|
332
|
+
var criteria: [String: Any] = [
|
|
333
|
+
kIOHIDPrimaryUsagePageKey as String: genericDesktopPage,
|
|
334
|
+
kIOHIDPrimaryUsageKey as String: keyboardUsage
|
|
335
|
+
]
|
|
336
|
+
if let deviceFilter {
|
|
337
|
+
criteria[kIOHIDVendorIDKey as String] = deviceFilter.vendorID
|
|
338
|
+
criteria[kIOHIDProductIDKey as String] = deviceFilter.productID
|
|
339
|
+
}
|
|
340
|
+
IOHIDManagerSetDeviceMatching(newManager, criteria as CFDictionary)
|
|
341
|
+
let context = Unmanaged.passUnretained(self).toOpaque()
|
|
342
|
+
IOHIDManagerRegisterInputValueCallback(newManager, inputValueCallback, context)
|
|
343
|
+
IOHIDManagerRegisterDeviceMatchingCallback(newManager, deviceMatchedCallback, context)
|
|
344
|
+
IOHIDManagerRegisterDeviceRemovalCallback(newManager, deviceRemovedCallback, context)
|
|
345
|
+
IOHIDManagerScheduleWithRunLoop(newManager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue)
|
|
346
|
+
|
|
347
|
+
let result = IOHIDManagerOpen(newManager, IOOptionBits(kIOHIDOptionsTypeNone))
|
|
348
|
+
guard result == kIOReturnSuccess else {
|
|
349
|
+
IOHIDManagerUnscheduleFromRunLoop(newManager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue)
|
|
350
|
+
throw CLIError("Could not open keyboard HID devices (IOKit status \(result))")
|
|
351
|
+
}
|
|
352
|
+
manager = newManager
|
|
353
|
+
|
|
354
|
+
if eventViewer {
|
|
355
|
+
let scope = deviceFilter.map { String(format: "VID/PID 0x%04x/0x%04x", $0.vendorID, $0.productID) } ?? "all keyboards"
|
|
356
|
+
print("Listening for HID changes from \(scope). Press Ctrl-C to stop.")
|
|
357
|
+
print("Timestamp Device Usage page Usage Value")
|
|
358
|
+
} else {
|
|
359
|
+
for (source, target) in mappings.sorted(by: { $0.key.description < $1.key.description }) {
|
|
360
|
+
print("Mapping \(source) → \(target). Press Ctrl-C to stop.")
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
fflush(stdout)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
func handle(_ value: IOHIDValue) {
|
|
367
|
+
let element = IOHIDValueGetElement(value)
|
|
368
|
+
let device = DeviceInfo(IOHIDElementGetDevice(element))
|
|
369
|
+
guard device.matches(deviceFilter) else { return }
|
|
370
|
+
let code = HIDCode(page: IOHIDElementGetUsagePage(element), usage: IOHIDElementGetUsage(element))
|
|
371
|
+
let integerValue = IOHIDValueGetIntegerValue(value)
|
|
372
|
+
|
|
373
|
+
if eventViewer {
|
|
374
|
+
let timestamp = formatter.string(from: Date())
|
|
375
|
+
print(String(format: "%@ %-39@ 0x%04x 0x%04x %lld", timestamp, device.label as NSString, code.page, code.usage, integerValue))
|
|
376
|
+
fflush(stdout)
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
guard mappings[code] != nil else { return }
|
|
381
|
+
let source = ActiveSource(deviceID: device.id, code: code)
|
|
382
|
+
let isDown = integerValue != 0
|
|
383
|
+
let changed: Bool
|
|
384
|
+
if isDown { changed = activeSources.insert(source).inserted }
|
|
385
|
+
else { changed = activeSources.remove(source) != nil }
|
|
386
|
+
guard changed else { return }
|
|
387
|
+
|
|
388
|
+
updateTargets()
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
func setConnected(_ rawDevice: IOHIDDevice, connected: Bool) {
|
|
392
|
+
let device = DeviceInfo(rawDevice)
|
|
393
|
+
guard device.matches(deviceFilter) else { return }
|
|
394
|
+
if connected {
|
|
395
|
+
guard connectedDevices.insert(device.id).inserted else { return }
|
|
396
|
+
if eventViewer { print("[Connected] \(device.label)") }
|
|
397
|
+
} else {
|
|
398
|
+
guard connectedDevices.remove(device.id) != nil else { return }
|
|
399
|
+
if eventViewer { print("[Disconnected] \(device.label)") }
|
|
400
|
+
activeSources = activeSources.filter { $0.deviceID != device.id }
|
|
401
|
+
updateTargets()
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
func run() {
|
|
406
|
+
let interrupt = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
|
|
407
|
+
signal(SIGINT, SIG_IGN)
|
|
408
|
+
interrupt.setEventHandler { [weak self] in
|
|
409
|
+
self?.stop()
|
|
410
|
+
exit(EXIT_SUCCESS)
|
|
411
|
+
}
|
|
412
|
+
interrupt.resume()
|
|
413
|
+
|
|
414
|
+
let terminate = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
|
|
415
|
+
signal(SIGTERM, SIG_IGN)
|
|
416
|
+
terminate.setEventHandler { [weak self] in
|
|
417
|
+
self?.stop()
|
|
418
|
+
exit(EXIT_SUCCESS)
|
|
419
|
+
}
|
|
420
|
+
terminate.resume()
|
|
421
|
+
CFRunLoopRun()
|
|
422
|
+
stop()
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
func stop() {
|
|
426
|
+
releaseAllTargets()
|
|
427
|
+
if let manager {
|
|
428
|
+
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
|
|
429
|
+
IOHIDManagerUnscheduleFromRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue)
|
|
430
|
+
}
|
|
431
|
+
manager = nil
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private func releaseAllTargets() {
|
|
435
|
+
activeSources.removeAll()
|
|
436
|
+
updateTargets()
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private func updateTargets() {
|
|
440
|
+
let newTargets = Set(activeSources.compactMap { mappings[$0.code] })
|
|
441
|
+
let releases = activeTargets.subtracting(newTargets)
|
|
442
|
+
let presses = newTargets.subtracting(activeTargets)
|
|
443
|
+
activeTargets = newTargets
|
|
444
|
+
for target in releases { post(target, down: false, flags: flags(for: activeTargets)) }
|
|
445
|
+
for target in presses { post(target, down: true, flags: flags(for: activeTargets)) }
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private func flags(for targets: Set<KeyTarget>) -> CGEventFlags {
|
|
449
|
+
targets.reduce(into: CGEventFlags()) { flags, target in
|
|
450
|
+
for modifier in target.modifiers { flags.formUnion(modifier.flag) }
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private func post(_ target: KeyTarget, down: Bool, flags: CGEventFlags) {
|
|
455
|
+
guard let event = CGEvent(keyboardEventSource: nil, virtualKey: target.keyCode, keyDown: down) else {
|
|
456
|
+
fputs("Could not create event for \(target)\n", stderr)
|
|
457
|
+
return
|
|
458
|
+
}
|
|
459
|
+
event.flags = target.modifiers.reduce(flags) { $0.union($1.flag) }
|
|
460
|
+
event.post(tap: .cghidEventTap)
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
@main
|
|
465
|
+
enum FnHIDMapMain {
|
|
466
|
+
static func main() {
|
|
467
|
+
do {
|
|
468
|
+
let options = try parseArguments(Array(CommandLine.arguments.dropFirst()))
|
|
469
|
+
let mapper = HIDMapper(options: options)
|
|
470
|
+
try mapper.start()
|
|
471
|
+
mapper.run()
|
|
472
|
+
} catch {
|
|
473
|
+
fputs("hid-keymap: \(error)\nUse --help for usage.\n", stderr)
|
|
474
|
+
exit(EXIT_FAILURE)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|