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 CHANGED
@@ -1,3 +1,37 @@
1
- # Temporary Holding Version
1
+ # hid-keymap
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }