hid-keymap 0.0.0-stage → 0.2.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,39 @@
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
+ Mapping mode stays running as a menu bar utility. Open its keyboard icon to see the configured source-to-target mappings, current status, and controls to stop, restart, or quit. The `--monitor` Event Viewer remains in the terminal and prints raw HID usage/value events.
26
+
27
+ 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.
28
+
29
+ ## Permissions and behavior
30
+
31
+ 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**.
32
+
33
+ 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.
34
+
35
+ 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.
36
+
37
+ ## Build
38
+
39
+ 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.2.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,608 @@
1
+ import ApplicationServices
2
+ import AppKit
3
+ import CoreGraphics
4
+ import Foundation
5
+ import IOKit.hid
6
+ import IOKit.hidsystem
7
+
8
+ private let genericDesktopPage = 0x01
9
+ private let keyboardUsage = 0x06
10
+
11
+ private struct HIDCode: Hashable, CustomStringConvertible {
12
+ let page: UInt32
13
+ let usage: UInt32
14
+
15
+ var description: String { String(format: "0x%04x/0x%04x", page, usage) }
16
+
17
+ static func parse(_ text: String) throws -> HIDCode {
18
+ let parts = text.split(separator: "/", omittingEmptySubsequences: false)
19
+ guard parts.count == 2 else { throw CLIError("Expected HID code PAGE/USAGE, such as 0x01/0x97") }
20
+ let page = try parseNumber(String(parts[0]))
21
+ let usage = try parseNumber(String(parts[1]))
22
+ guard page <= UInt32(UInt16.max), usage <= UInt32(UInt16.max) else {
23
+ throw CLIError("HID page and usage must each fit in 16 bits")
24
+ }
25
+ return HIDCode(page: page, usage: usage)
26
+ }
27
+
28
+ private static func parseNumber(_ raw: String) throws -> UInt32 {
29
+ let value: UInt32?
30
+ if raw.hasPrefix("0x") || raw.hasPrefix("0X") {
31
+ value = UInt32(raw.dropFirst(2), radix: 16)
32
+ } else {
33
+ value = UInt32(raw, radix: 10)
34
+ }
35
+ guard let value else { throw CLIError("Invalid HID number: \(raw)") }
36
+ return value
37
+ }
38
+ }
39
+
40
+ private enum ModifierTarget: String, CaseIterable, Hashable {
41
+ case leftControl = "left_control"
42
+ case rightControl = "right_control"
43
+ case leftOption = "left_option"
44
+ case rightOption = "right_option"
45
+ case leftCommand = "left_command"
46
+ case rightCommand = "right_command"
47
+ case leftShift = "left_shift"
48
+ case rightShift = "right_shift"
49
+
50
+ var keyCode: CGKeyCode {
51
+ switch self {
52
+ case .leftControl: return 0x3b
53
+ case .rightControl: return 0x3e
54
+ case .leftOption: return 0x3a
55
+ case .rightOption: return 0x3d
56
+ case .leftCommand: return 0x37
57
+ case .rightCommand: return 0x36
58
+ case .leftShift: return 0x38
59
+ case .rightShift: return 0x3c
60
+ }
61
+ }
62
+
63
+ var flag: CGEventFlags {
64
+ switch self {
65
+ case .leftControl, .rightControl: return .maskControl
66
+ case .leftOption, .rightOption: return .maskAlternate
67
+ case .leftCommand, .rightCommand: return .maskCommand
68
+ case .leftShift, .rightShift: return .maskShift
69
+ }
70
+ }
71
+ }
72
+
73
+ private struct KeyTarget: Hashable, CustomStringConvertible {
74
+ let keyCode: CGKeyCode
75
+ let modifiers: Set<ModifierTarget>
76
+ let modifierOnly: ModifierTarget?
77
+ let name: String
78
+
79
+ var description: String { name }
80
+
81
+ static let keyCodes: [String: CGKeyCode] = [
82
+ "a": 0x00, "s": 0x01, "d": 0x02, "f": 0x03, "h": 0x04, "g": 0x05,
83
+ "z": 0x06, "x": 0x07, "c": 0x08, "v": 0x09, "b": 0x0b, "q": 0x0c,
84
+ "w": 0x0d, "e": 0x0e, "r": 0x0f, "y": 0x10, "t": 0x11,
85
+ "1": 0x12, "2": 0x13, "3": 0x14, "4": 0x15, "6": 0x16, "5": 0x17,
86
+ "=": 0x18, "9": 0x19, "7": 0x1a, "-": 0x1b, "8": 0x1c, "0": 0x1d,
87
+ "]": 0x1e, "o": 0x1f, "u": 0x20, "[": 0x21, "i": 0x22, "p": 0x23,
88
+ "return": 0x24, "l": 0x25, "j": 0x26, "'": 0x27, "k": 0x28, ";": 0x29,
89
+ "\\": 0x2a, ",": 0x2b, "/": 0x2c, "n": 0x2d, "m": 0x2e, ".": 0x2f,
90
+ "tab": 0x30, "space": 0x31, "grave": 0x32, "backspace": 0x33, "escape": 0x35,
91
+ "f1": 0x7a, "f2": 0x78, "f3": 0x63, "f4": 0x76, "f5": 0x60, "f6": 0x61,
92
+ "f7": 0x62, "f8": 0x64, "f9": 0x65, "f10": 0x6d, "f11": 0x67, "f12": 0x6f,
93
+ "home": 0x73, "page_up": 0x74, "forward_delete": 0x75, "end": 0x77,
94
+ "page_down": 0x79, "left": 0x7b, "right": 0x7c, "down": 0x7d, "up": 0x7e
95
+ ]
96
+
97
+ static func parse(_ raw: String) throws -> KeyTarget {
98
+ if let modifier = ModifierTarget(rawValue: raw) {
99
+ return KeyTarget(keyCode: modifier.keyCode, modifiers: [modifier], modifierOnly: modifier, name: raw)
100
+ }
101
+
102
+ let parts = raw.split(separator: "+", omittingEmptySubsequences: false).map(String.init)
103
+ guard !parts.isEmpty, !parts.contains(where: \.isEmpty) else {
104
+ throw CLIError("Invalid target '\(raw)'. Use a key name, modifier name, or modifier+key, such as left_command+key:space")
105
+ }
106
+ var modifiers = Set<ModifierTarget>()
107
+ var keyName: String?
108
+ for part in parts {
109
+ if let modifier = ModifierTarget(rawValue: part) {
110
+ modifiers.insert(modifier)
111
+ } else if part.hasPrefix("key:"), keyName == nil {
112
+ keyName = String(part.dropFirst(4))
113
+ } else {
114
+ throw CLIError("Unknown target component '\(part)'. Use --list-targets to see available keys")
115
+ }
116
+ }
117
+ guard let keyName, let keyCode = keyCodes[keyName] else {
118
+ throw CLIError("Target must include one supported key (key:NAME). Use --list-targets to see names")
119
+ }
120
+ let canonicalModifiers = ModifierTarget.allCases.filter { modifiers.contains($0) }.map(\.rawValue)
121
+ let name = (canonicalModifiers + ["key:\(keyName)"]).joined(separator: "+")
122
+ return KeyTarget(keyCode: keyCode, modifiers: modifiers, modifierOnly: nil, name: name)
123
+ }
124
+ }
125
+
126
+ private struct HIDMapping {
127
+ let source: HIDCode
128
+ let target: KeyTarget
129
+ }
130
+
131
+ private struct DeviceFilter {
132
+ let vendorID: UInt32
133
+ let productID: UInt32
134
+
135
+ static func parse(_ raw: String) throws -> DeviceFilter {
136
+ let components = raw.split(separator: "/", omittingEmptySubsequences: false)
137
+ guard components.count == 2,
138
+ let vendor = parseInteger(String(components[0])),
139
+ let product = parseInteger(String(components[1])) else {
140
+ throw CLIError("Expected device filter VID/PID, such as 0x045e/0x0832")
141
+ }
142
+ return DeviceFilter(vendorID: vendor, productID: product)
143
+ }
144
+ }
145
+
146
+ private struct CLIOptions {
147
+ let mappings: [HIDMapping]?
148
+ let deviceFilter: DeviceFilter?
149
+ let monitor: Bool
150
+ }
151
+
152
+ private struct DeviceInfo {
153
+ let id: String
154
+ let label: String
155
+ let vendorID: UInt32
156
+ let productID: UInt32
157
+
158
+ init(_ device: IOHIDDevice) {
159
+ let name = IOHIDDeviceGetProperty(device, kIOHIDProductKey as CFString) as? String ?? "Keyboard"
160
+ let vendor = (IOHIDDeviceGetProperty(device, kIOHIDVendorIDKey as CFString) as? NSNumber)?.uint32Value ?? 0
161
+ let product = (IOHIDDeviceGetProperty(device, kIOHIDProductIDKey as CFString) as? NSNumber)?.uint32Value ?? 0
162
+ vendorID = vendor
163
+ productID = product
164
+ let location = (IOHIDDeviceGetProperty(device, kIOHIDLocationIDKey as CFString) as? NSNumber)?.uint32Value
165
+ id = location.map { String(format: "%04x:%08x", vendor, $0) } ?? String(format: "%04x:%04x:%@", vendor, product, name)
166
+ label = String(format: "%@ [0x%04x:0x%04x]", name, vendor, product)
167
+ }
168
+
169
+ func matches(_ filter: DeviceFilter?) -> Bool {
170
+ guard let filter else { return true }
171
+ return vendorID == filter.vendorID && productID == filter.productID
172
+ }
173
+ }
174
+
175
+ private struct ActiveSource: Hashable {
176
+ let deviceID: String
177
+ let code: HIDCode
178
+ }
179
+
180
+ private func parseInteger(_ raw: String) -> UInt32? {
181
+ if raw.hasPrefix("0x") || raw.hasPrefix("0X") {
182
+ return UInt32(raw.dropFirst(2), radix: 16)
183
+ }
184
+ return UInt32(raw, radix: 10)
185
+ }
186
+
187
+ private struct CLIError: Error, CustomStringConvertible {
188
+ let message: String
189
+ init(_ message: String) { self.message = message }
190
+ var description: String { message }
191
+ }
192
+
193
+ private func parseArguments(_ arguments: [String]) throws -> CLIOptions {
194
+ if arguments.isEmpty {
195
+ printUsage()
196
+ exit(EXIT_SUCCESS)
197
+ }
198
+ if arguments == ["--help"] || arguments == ["-h"] {
199
+ printUsage()
200
+ exit(EXIT_SUCCESS)
201
+ }
202
+ if arguments == ["--list-targets"] {
203
+ printTargets()
204
+ exit(EXIT_SUCCESS)
205
+ }
206
+
207
+ var mappings: [HIDMapping] = []
208
+ var seenSources = Set<HIDCode>()
209
+ var deviceFilter: DeviceFilter?
210
+ var monitor = false
211
+ var index = 0
212
+ while index < arguments.count {
213
+ if arguments[index] == "--monitor" {
214
+ guard !monitor else { throw CLIError("Specify --monitor only once") }
215
+ monitor = true
216
+ index += 1
217
+ continue
218
+ }
219
+ if arguments[index] == "--device" {
220
+ index += 1
221
+ guard index < arguments.count else { throw CLIError("--device requires VID/PID") }
222
+ guard deviceFilter == nil else { throw CLIError("Specify --device only once") }
223
+ deviceFilter = try DeviceFilter.parse(arguments[index])
224
+ index += 1
225
+ continue
226
+ }
227
+ guard arguments[index] == "--map" else {
228
+ throw CLIError("Unknown argument: \(arguments[index])")
229
+ }
230
+ index += 1
231
+ guard index < arguments.count else { throw CLIError("--map requires PAGE/USAGE=TARGET") }
232
+ let pair = arguments[index].split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
233
+ guard pair.count == 2 else { throw CLIError("Expected PAGE/USAGE=TARGET after --map") }
234
+ let source = try HIDCode.parse(String(pair[0]))
235
+ guard source.page != 0x07 else {
236
+ throw CLIError("Keyboard-page sources (0x07) are passed through and cannot be safely remapped by this listener")
237
+ }
238
+ let target = try KeyTarget.parse(String(pair[1]))
239
+ guard seenSources.insert(source).inserted else { throw CLIError("Duplicate source HID code: \(source)") }
240
+ mappings.append(HIDMapping(source: source, target: target))
241
+ index += 1
242
+ }
243
+ guard !(monitor && !mappings.isEmpty) else { throw CLIError("--monitor cannot be combined with --map") }
244
+ guard monitor || !mappings.isEmpty else { throw CLIError("Use --monitor to view events, or --map to create a mapping") }
245
+ return CLIOptions(mappings: mappings.isEmpty ? nil : mappings, deviceFilter: deviceFilter, monitor: monitor)
246
+ }
247
+
248
+ private func printUsage() {
249
+ print("""
250
+ hid-keymap — HID Event Viewer / keyboard mapper
251
+
252
+ Usage:
253
+ hid-keymap --monitor [--device VID/PID]
254
+ Listen and print HID usage/value changes (Event Viewer mode).
255
+ hid-keymap --map PAGE/USAGE=TARGET [--map PAGE/USAGE=TARGET ...] [--device VID/PID]
256
+ Map one or more non-keyboard HID state usages to held keys or modifiers.
257
+ hid-keymap --list-targets
258
+ List supported target key names.
259
+
260
+ Example:
261
+ hid-keymap --monitor
262
+ hid-keymap --map 0x01/0x97=right_option
263
+ hid-keymap --map 0x01/0x97=key:escape
264
+ hid-keymap --map 0x01/0x97=left_command+key:space
265
+
266
+ Targets:
267
+ Modifier alone: right_option
268
+ Key alone: key:escape
269
+ Chord: left_command+key:space (modifiers: left/right_control, option, command, shift)
270
+
271
+ Without --device, the listener includes every connected keyboard.
272
+ Keyboard-page sources (0x07) are rejected because this observer cannot
273
+ suppress their original key events.
274
+ """)
275
+ }
276
+
277
+ private func printTargets() {
278
+ print("Modifier targets (use alone to hold the modifier):")
279
+ print(ModifierTarget.allCases.map(\.rawValue).joined(separator: ", "))
280
+ print("\nKey targets (prefix names with key:):")
281
+ print(KeyTarget.keyCodes.keys.sorted().map { "key:\($0)" }.joined(separator: "\n"))
282
+ print("\nExamples: right_option, key:escape, left_command+key:space")
283
+ }
284
+
285
+ private func inputValueCallback(context: UnsafeMutableRawPointer?, result: IOReturn, sender: UnsafeMutableRawPointer?, value: IOHIDValue) {
286
+ guard result == kIOReturnSuccess, let context else { return }
287
+ Unmanaged<HIDMapper>.fromOpaque(context).takeUnretainedValue().handle(value)
288
+ }
289
+
290
+ private func deviceMatchedCallback(context: UnsafeMutableRawPointer?, result: IOReturn, sender: UnsafeMutableRawPointer?, device: IOHIDDevice) {
291
+ guard result == kIOReturnSuccess, let context else { return }
292
+ Unmanaged<HIDMapper>.fromOpaque(context).takeUnretainedValue().setConnected(device, connected: true)
293
+ }
294
+
295
+ private func deviceRemovedCallback(context: UnsafeMutableRawPointer?, result: IOReturn, sender: UnsafeMutableRawPointer?, device: IOHIDDevice) {
296
+ guard result == kIOReturnSuccess, let context else { return }
297
+ Unmanaged<HIDMapper>.fromOpaque(context).takeUnretainedValue().setConnected(device, connected: false)
298
+ }
299
+
300
+ private final class HIDMapper {
301
+ private let mappings: [HIDCode: KeyTarget]
302
+ private let deviceFilter: DeviceFilter?
303
+ private let eventViewer: Bool
304
+ private var manager: IOHIDManager?
305
+ private var activeSources = Set<ActiveSource>()
306
+ private var activeTargets = Set<KeyTarget>()
307
+ private var connectedDevices = Set<String>()
308
+ private let formatter = ISO8601DateFormatter()
309
+ var onStatusChange: ((String) -> Void)?
310
+ private(set) var status = "Not started"
311
+
312
+ init(options: CLIOptions) {
313
+ eventViewer = options.monitor
314
+ deviceFilter = options.deviceFilter
315
+ mappings = Dictionary(uniqueKeysWithValues: (options.mappings ?? []).map { ($0.source, $0.target) })
316
+ }
317
+
318
+ func start() throws {
319
+ if IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) != kIOHIDAccessTypeGranted {
320
+ _ = IOHIDRequestAccess(kIOHIDRequestTypeListenEvent)
321
+ }
322
+ guard IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) == kIOHIDAccessTypeGranted else {
323
+ throw CLIError("Input Monitoring permission is required. Allow the launching app (usually Terminal) in System Settings → Privacy & Security → Input Monitoring, then run again.")
324
+ }
325
+
326
+ if !eventViewer && !AXIsProcessTrusted() {
327
+ let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
328
+ _ = AXIsProcessTrustedWithOptions([promptKey: true] as CFDictionary)
329
+ }
330
+ guard eventViewer || AXIsProcessTrusted() else {
331
+ 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.")
332
+ }
333
+
334
+ let newManager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
335
+ var criteria: [String: Any] = [
336
+ kIOHIDPrimaryUsagePageKey as String: genericDesktopPage,
337
+ kIOHIDPrimaryUsageKey as String: keyboardUsage
338
+ ]
339
+ if let deviceFilter {
340
+ criteria[kIOHIDVendorIDKey as String] = deviceFilter.vendorID
341
+ criteria[kIOHIDProductIDKey as String] = deviceFilter.productID
342
+ }
343
+ IOHIDManagerSetDeviceMatching(newManager, criteria as CFDictionary)
344
+ let context = Unmanaged.passUnretained(self).toOpaque()
345
+ IOHIDManagerRegisterInputValueCallback(newManager, inputValueCallback, context)
346
+ IOHIDManagerRegisterDeviceMatchingCallback(newManager, deviceMatchedCallback, context)
347
+ IOHIDManagerRegisterDeviceRemovalCallback(newManager, deviceRemovedCallback, context)
348
+ IOHIDManagerScheduleWithRunLoop(newManager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue)
349
+
350
+ let result = IOHIDManagerOpen(newManager, IOOptionBits(kIOHIDOptionsTypeNone))
351
+ guard result == kIOReturnSuccess else {
352
+ IOHIDManagerUnscheduleFromRunLoop(newManager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue)
353
+ throw CLIError("Could not open keyboard HID devices (IOKit status \(result))")
354
+ }
355
+ manager = newManager
356
+
357
+ if eventViewer {
358
+ let scope = deviceFilter.map { String(format: "VID/PID 0x%04x/0x%04x", $0.vendorID, $0.productID) } ?? "all keyboards"
359
+ print("Listening for HID changes from \(scope). Press Ctrl-C to stop.")
360
+ print("Timestamp Device Usage page Usage Value")
361
+ } else {
362
+ setStatus("Ready · \(mappings.count) mapping\(mappings.count == 1 ? "" : "s")")
363
+ }
364
+ fflush(stdout)
365
+ }
366
+
367
+ func handle(_ value: IOHIDValue) {
368
+ let element = IOHIDValueGetElement(value)
369
+ let device = DeviceInfo(IOHIDElementGetDevice(element))
370
+ guard device.matches(deviceFilter) else { return }
371
+ let code = HIDCode(page: IOHIDElementGetUsagePage(element), usage: IOHIDElementGetUsage(element))
372
+ let integerValue = IOHIDValueGetIntegerValue(value)
373
+
374
+ if eventViewer {
375
+ let timestamp = formatter.string(from: Date())
376
+ print(String(format: "%@ %-39@ 0x%04x 0x%04x %lld", timestamp, device.label as NSString, code.page, code.usage, integerValue))
377
+ fflush(stdout)
378
+ return
379
+ }
380
+
381
+ guard mappings[code] != nil else { return }
382
+ let source = ActiveSource(deviceID: device.id, code: code)
383
+ let isDown = integerValue != 0
384
+ let changed: Bool
385
+ if isDown { changed = activeSources.insert(source).inserted }
386
+ else { changed = activeSources.remove(source) != nil }
387
+ guard changed else { return }
388
+
389
+ updateTargets()
390
+ if !eventViewer, let target = mappings[code] {
391
+ setStatus(isDown ? "Pressed · \(target)" : "Ready · \(mappings.count) mapping\(mappings.count == 1 ? "" : "s")")
392
+ }
393
+ }
394
+
395
+ func setConnected(_ rawDevice: IOHIDDevice, connected: Bool) {
396
+ let device = DeviceInfo(rawDevice)
397
+ guard device.matches(deviceFilter) else { return }
398
+ if connected {
399
+ guard connectedDevices.insert(device.id).inserted else { return }
400
+ if eventViewer { print("[Connected] \(device.label)") }
401
+ } else {
402
+ guard connectedDevices.remove(device.id) != nil else { return }
403
+ if eventViewer { print("[Disconnected] \(device.label)") }
404
+ activeSources = activeSources.filter { $0.deviceID != device.id }
405
+ updateTargets()
406
+ if !eventViewer { setStatus("Keyboard disconnected · waiting for input") }
407
+ }
408
+ }
409
+
410
+ func run() {
411
+ let interrupt = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
412
+ signal(SIGINT, SIG_IGN)
413
+ interrupt.setEventHandler { [weak self] in
414
+ self?.stop()
415
+ exit(EXIT_SUCCESS)
416
+ }
417
+ interrupt.resume()
418
+
419
+ let terminate = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
420
+ signal(SIGTERM, SIG_IGN)
421
+ terminate.setEventHandler { [weak self] in
422
+ self?.stop()
423
+ exit(EXIT_SUCCESS)
424
+ }
425
+ terminate.resume()
426
+ CFRunLoopRun()
427
+ stop()
428
+ }
429
+
430
+ func stop() {
431
+ releaseAllTargets()
432
+ if let manager {
433
+ IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
434
+ IOHIDManagerUnscheduleFromRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue)
435
+ }
436
+ manager = nil
437
+ if !eventViewer { setStatus("Stopped") }
438
+ }
439
+
440
+ private func releaseAllTargets() {
441
+ activeSources.removeAll()
442
+ updateTargets()
443
+ }
444
+
445
+ private func updateTargets() {
446
+ let newTargets = Set(activeSources.compactMap { mappings[$0.code] })
447
+ let releases = activeTargets.subtracting(newTargets)
448
+ let presses = newTargets.subtracting(activeTargets)
449
+ activeTargets = newTargets
450
+ for target in releases { post(target, down: false, flags: flags(for: activeTargets)) }
451
+ for target in presses { post(target, down: true, flags: flags(for: activeTargets)) }
452
+ }
453
+
454
+ private func flags(for targets: Set<KeyTarget>) -> CGEventFlags {
455
+ targets.reduce(into: CGEventFlags()) { flags, target in
456
+ for modifier in target.modifiers { flags.formUnion(modifier.flag) }
457
+ }
458
+ }
459
+
460
+ private func post(_ target: KeyTarget, down: Bool, flags: CGEventFlags) {
461
+ guard let event = CGEvent(keyboardEventSource: nil, virtualKey: target.keyCode, keyDown: down) else {
462
+ fputs("Could not create event for \(target)\n", stderr)
463
+ return
464
+ }
465
+ event.flags = target.modifiers.reduce(flags) { $0.union($1.flag) }
466
+ event.post(tap: .cghidEventTap)
467
+ }
468
+
469
+ func reportError(_ error: Error) {
470
+ setStatus(String(describing: error))
471
+ }
472
+
473
+ private func setStatus(_ newStatus: String) {
474
+ status = newStatus
475
+ onStatusChange?(newStatus)
476
+ }
477
+ }
478
+
479
+ private final class MappingMenuApp: NSObject, NSApplicationDelegate {
480
+ private let mapper: HIDMapper
481
+ private let mappings: [HIDMapping]
482
+ private let deviceFilter: DeviceFilter?
483
+ private var statusItem: NSStatusItem?
484
+ private var statusMenuItem: NSMenuItem?
485
+ private var interruptSource: DispatchSourceSignal?
486
+ private var terminateSource: DispatchSourceSignal?
487
+
488
+ init(options: CLIOptions) {
489
+ mapper = HIDMapper(options: options)
490
+ mappings = options.mappings ?? []
491
+ deviceFilter = options.deviceFilter
492
+ }
493
+
494
+ func applicationDidFinishLaunching(_ notification: Notification) {
495
+ NSApp.setActivationPolicy(.accessory)
496
+ buildMenu()
497
+ mapper.onStatusChange = { [weak self] status in
498
+ self?.statusMenuItem?.title = status
499
+ self?.statusItem?.button?.toolTip = "hid-keymap · \(status)"
500
+ }
501
+ installSignalHandlers()
502
+ startMapping()
503
+ }
504
+
505
+ func applicationWillTerminate(_ notification: Notification) {
506
+ mapper.stop()
507
+ }
508
+
509
+ @objc private func stopMapping() {
510
+ mapper.stop()
511
+ }
512
+
513
+ @objc private func restartMapping() {
514
+ mapper.stop()
515
+ startMapping()
516
+ }
517
+
518
+ @objc private func quit() {
519
+ mapper.stop()
520
+ NSApp.terminate(nil)
521
+ }
522
+
523
+ private func startMapping() {
524
+ do {
525
+ try mapper.start()
526
+ } catch {
527
+ mapper.reportError(error)
528
+ }
529
+ }
530
+
531
+ private func buildMenu() {
532
+ let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
533
+ item.button?.title = "⌨"
534
+ item.button?.toolTip = "hid-keymap"
535
+
536
+ let menu = NSMenu()
537
+ let status = NSMenuItem(title: mapper.status, action: nil, keyEquivalent: "")
538
+ status.isEnabled = false
539
+ menu.addItem(status)
540
+ statusMenuItem = status
541
+ menu.addItem(.separator())
542
+
543
+ for mapping in mappings.sorted(by: { $0.source.description < $1.source.description }) {
544
+ let line = NSMenuItem(title: "\(mapping.source) → \(mapping.target)", action: nil, keyEquivalent: "")
545
+ line.isEnabled = false
546
+ menu.addItem(line)
547
+ }
548
+ if let deviceFilter {
549
+ let scope = NSMenuItem(title: String(format: "Device · 0x%04x/0x%04x", deviceFilter.vendorID, deviceFilter.productID), action: nil, keyEquivalent: "")
550
+ scope.isEnabled = false
551
+ menu.addItem(scope)
552
+ } else {
553
+ let scope = NSMenuItem(title: "Device · all keyboards", action: nil, keyEquivalent: "")
554
+ scope.isEnabled = false
555
+ menu.addItem(scope)
556
+ }
557
+ menu.addItem(.separator())
558
+
559
+ let stopItem = NSMenuItem(title: "Stop Mapping", action: #selector(stopMapping), keyEquivalent: "")
560
+ stopItem.target = self
561
+ menu.addItem(stopItem)
562
+ let restartItem = NSMenuItem(title: "Restart Mapping", action: #selector(restartMapping), keyEquivalent: "")
563
+ restartItem.target = self
564
+ menu.addItem(restartItem)
565
+ menu.addItem(.separator())
566
+ let quitItem = NSMenuItem(title: "Quit hid-keymap", action: #selector(quit), keyEquivalent: "q")
567
+ quitItem.target = self
568
+ menu.addItem(quitItem)
569
+ item.menu = menu
570
+ statusItem = item
571
+ }
572
+
573
+ private func installSignalHandlers() {
574
+ signal(SIGINT, SIG_IGN)
575
+ let interrupt = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
576
+ interrupt.setEventHandler { [weak self] in self?.quit() }
577
+ interrupt.resume()
578
+ interruptSource = interrupt
579
+
580
+ signal(SIGTERM, SIG_IGN)
581
+ let terminate = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
582
+ terminate.setEventHandler { [weak self] in self?.quit() }
583
+ terminate.resume()
584
+ terminateSource = terminate
585
+ }
586
+ }
587
+
588
+ @main
589
+ enum FnHIDMapMain {
590
+ static func main() {
591
+ do {
592
+ let options = try parseArguments(Array(CommandLine.arguments.dropFirst()))
593
+ if options.monitor {
594
+ let mapper = HIDMapper(options: options)
595
+ try mapper.start()
596
+ mapper.run()
597
+ } else {
598
+ let app = NSApplication.shared
599
+ let delegate = MappingMenuApp(options: options)
600
+ app.delegate = delegate
601
+ app.run()
602
+ }
603
+ } catch {
604
+ fputs("hid-keymap: \(error)\nUse --help for usage.\n", stderr)
605
+ exit(EXIT_FAILURE)
606
+ }
607
+ }
608
+ }