hid-keymap 0.1.0 → 0.2.1

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
@@ -22,6 +22,8 @@ hid-keymap --map 0x01/0x97=key:escape
22
22
  hid-keymap --map 0x01/0x97=left_command+key:space
23
23
  ```
24
24
 
25
+ Mapping mode launches a separate menu bar utility and returns to the shell. 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
+
25
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.
26
28
 
27
29
  ## Permissions and behavior
package/native/hid-keymap CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hid-keymap",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "macOS HID keyboard event viewer and key mapper",
5
5
  "bin": {
6
6
  "hid-keymap": "bin/hid-keymap.js"
@@ -1,11 +1,14 @@
1
1
  import ApplicationServices
2
+ import AppKit
2
3
  import CoreGraphics
4
+ import Darwin
3
5
  import Foundation
4
6
  import IOKit.hid
5
7
  import IOKit.hidsystem
6
8
 
7
9
  private let genericDesktopPage = 0x01
8
10
  private let keyboardUsage = 0x06
11
+ private let appVersion = "0.2.1"
9
12
 
10
13
  private struct HIDCode: Hashable, CustomStringConvertible {
11
14
  let page: UInt32
@@ -183,6 +186,51 @@ private func parseInteger(_ raw: String) -> UInt32? {
183
186
  return UInt32(raw, radix: 10)
184
187
  }
185
188
 
189
+ private func launchDetachedMapping(_ arguments: [String]) throws {
190
+ let executablePath = CommandLine.arguments[0]
191
+ let childArguments = [executablePath, "--_background_mapping"] + arguments
192
+ var cArguments = childArguments.map { strdup($0) }
193
+ guard cArguments.allSatisfy({ $0 != nil }) else {
194
+ cArguments.forEach { if let argument = $0 { free(argument) } }
195
+ throw CLIError("Could not prepare background process arguments")
196
+ }
197
+ cArguments.append(nil)
198
+ defer { cArguments.forEach { if let argument = $0 { free(argument) } } }
199
+
200
+ var fileActions: posix_spawn_file_actions_t?
201
+ var spawnAttributes: posix_spawnattr_t?
202
+ guard posix_spawn_file_actions_init(&fileActions) == 0 else {
203
+ throw CLIError("Could not initialize background process file actions")
204
+ }
205
+ defer { posix_spawn_file_actions_destroy(&fileActions) }
206
+ guard posix_spawnattr_init(&spawnAttributes) == 0 else {
207
+ throw CLIError("Could not initialize background process attributes")
208
+ }
209
+ defer { posix_spawnattr_destroy(&spawnAttributes) }
210
+
211
+ let redirectInput = posix_spawn_file_actions_addopen(&fileActions, STDIN_FILENO, "/dev/null", O_RDONLY, 0)
212
+ let redirectOutput = posix_spawn_file_actions_addopen(&fileActions, STDOUT_FILENO, "/dev/null", O_WRONLY, 0)
213
+ let redirectError = posix_spawn_file_actions_addopen(&fileActions, STDERR_FILENO, "/dev/null", O_WRONLY, 0)
214
+ guard redirectInput == 0, redirectOutput == 0, redirectError == 0 else {
215
+ throw CLIError("Could not detach background process from the terminal")
216
+ }
217
+ guard posix_spawnattr_setflags(&spawnAttributes, Int16(POSIX_SPAWN_SETSID)) == 0 else {
218
+ throw CLIError("Could not create a detached session for the mapping process")
219
+ }
220
+
221
+ var childPID: pid_t = 0
222
+ let result = executablePath.withCString { path in
223
+ cArguments.withUnsafeBufferPointer { buffer in
224
+ posix_spawn(&childPID, path, &fileActions, &spawnAttributes, buffer.baseAddress, environ)
225
+ }
226
+ }
227
+ guard result == 0 else {
228
+ throw CLIError("Could not start the menu bar mapping process (system error \(result))")
229
+ }
230
+ print("Started hid-keymap mapping process (pid \(childPID)). Open the keyboard icon in the menu bar.")
231
+ fflush(stdout)
232
+ }
233
+
186
234
  private struct CLIError: Error, CustomStringConvertible {
187
235
  let message: String
188
236
  init(_ message: String) { self.message = message }
@@ -198,6 +246,10 @@ private func parseArguments(_ arguments: [String]) throws -> CLIOptions {
198
246
  printUsage()
199
247
  exit(EXIT_SUCCESS)
200
248
  }
249
+ if arguments == ["--version"] || arguments == ["-V"] {
250
+ print("hid-keymap \(appVersion)")
251
+ exit(EXIT_SUCCESS)
252
+ }
201
253
  if arguments == ["--list-targets"] {
202
254
  printTargets()
203
255
  exit(EXIT_SUCCESS)
@@ -246,7 +298,7 @@ private func parseArguments(_ arguments: [String]) throws -> CLIOptions {
246
298
 
247
299
  private func printUsage() {
248
300
  print("""
249
- hid-keymap — HID Event Viewer / keyboard mapper
301
+ hid-keymap \(appVersion) — HID Event Viewer / keyboard mapper
250
302
 
251
303
  Usage:
252
304
  hid-keymap --monitor [--device VID/PID]
@@ -255,6 +307,8 @@ private func printUsage() {
255
307
  Map one or more non-keyboard HID state usages to held keys or modifiers.
256
308
  hid-keymap --list-targets
257
309
  List supported target key names.
310
+ hid-keymap --version
311
+ Print the installed version.
258
312
 
259
313
  Example:
260
314
  hid-keymap --monitor
@@ -305,6 +359,8 @@ private final class HIDMapper {
305
359
  private var activeTargets = Set<KeyTarget>()
306
360
  private var connectedDevices = Set<String>()
307
361
  private let formatter = ISO8601DateFormatter()
362
+ var onStatusChange: ((String) -> Void)?
363
+ private(set) var status = "Not started"
308
364
 
309
365
  init(options: CLIOptions) {
310
366
  eventViewer = options.monitor
@@ -356,9 +412,7 @@ private final class HIDMapper {
356
412
  print("Listening for HID changes from \(scope). Press Ctrl-C to stop.")
357
413
  print("Timestamp Device Usage page Usage Value")
358
414
  } 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
- }
415
+ setStatus("Ready · \(mappings.count) mapping\(mappings.count == 1 ? "" : "s")")
362
416
  }
363
417
  fflush(stdout)
364
418
  }
@@ -386,6 +440,9 @@ private final class HIDMapper {
386
440
  guard changed else { return }
387
441
 
388
442
  updateTargets()
443
+ if !eventViewer, let target = mappings[code] {
444
+ setStatus(isDown ? "Pressed · \(target)" : "Ready · \(mappings.count) mapping\(mappings.count == 1 ? "" : "s")")
445
+ }
389
446
  }
390
447
 
391
448
  func setConnected(_ rawDevice: IOHIDDevice, connected: Bool) {
@@ -399,6 +456,7 @@ private final class HIDMapper {
399
456
  if eventViewer { print("[Disconnected] \(device.label)") }
400
457
  activeSources = activeSources.filter { $0.deviceID != device.id }
401
458
  updateTargets()
459
+ if !eventViewer { setStatus("Keyboard disconnected · waiting for input") }
402
460
  }
403
461
  }
404
462
 
@@ -429,6 +487,7 @@ private final class HIDMapper {
429
487
  IOHIDManagerUnscheduleFromRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue)
430
488
  }
431
489
  manager = nil
490
+ if !eventViewer { setStatus("Stopped") }
432
491
  }
433
492
 
434
493
  private func releaseAllTargets() {
@@ -456,19 +515,160 @@ private final class HIDMapper {
456
515
  fputs("Could not create event for \(target)\n", stderr)
457
516
  return
458
517
  }
459
- event.flags = target.modifiers.reduce(flags) { $0.union($1.flag) }
518
+ // A modifier's key-up event must report that the released modifier is
519
+ // no longer down. Chord key-up events still carry their chord flags;
520
+ // modifier-only releases rely on the remaining active targets' flags.
521
+ event.flags = down || target.modifierOnly == nil
522
+ ? target.modifiers.reduce(flags) { $0.union($1.flag) }
523
+ : flags
460
524
  event.post(tap: .cghidEventTap)
461
525
  }
526
+
527
+ func reportError(_ error: Error) {
528
+ setStatus(String(describing: error))
529
+ }
530
+
531
+ private func setStatus(_ newStatus: String) {
532
+ status = newStatus
533
+ onStatusChange?(newStatus)
534
+ }
535
+ }
536
+
537
+ private final class MappingMenuApp: NSObject, NSApplicationDelegate {
538
+ private let mapper: HIDMapper
539
+ private let mappings: [HIDMapping]
540
+ private let deviceFilter: DeviceFilter?
541
+ private var statusItem: NSStatusItem?
542
+ private var statusMenuItem: NSMenuItem?
543
+ private var interruptSource: DispatchSourceSignal?
544
+ private var terminateSource: DispatchSourceSignal?
545
+
546
+ init(options: CLIOptions) {
547
+ mapper = HIDMapper(options: options)
548
+ mappings = options.mappings ?? []
549
+ deviceFilter = options.deviceFilter
550
+ }
551
+
552
+ func applicationDidFinishLaunching(_ notification: Notification) {
553
+ NSApp.setActivationPolicy(.accessory)
554
+ buildMenu()
555
+ mapper.onStatusChange = { [weak self] status in
556
+ self?.statusMenuItem?.title = status
557
+ self?.statusItem?.button?.toolTip = "hid-keymap · \(status)"
558
+ }
559
+ installSignalHandlers()
560
+ startMapping()
561
+ }
562
+
563
+ func applicationWillTerminate(_ notification: Notification) {
564
+ mapper.stop()
565
+ }
566
+
567
+ @objc private func stopMapping() {
568
+ mapper.stop()
569
+ }
570
+
571
+ @objc private func restartMapping() {
572
+ mapper.stop()
573
+ startMapping()
574
+ }
575
+
576
+ @objc private func quit() {
577
+ mapper.stop()
578
+ NSApp.terminate(nil)
579
+ }
580
+
581
+ private func startMapping() {
582
+ do {
583
+ try mapper.start()
584
+ } catch {
585
+ mapper.reportError(error)
586
+ }
587
+ }
588
+
589
+ private func buildMenu() {
590
+ let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
591
+ if let image = NSImage(systemSymbolName: "keyboard", accessibilityDescription: "hid-keymap")?
592
+ .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 18, weight: .medium)) {
593
+ image.isTemplate = true
594
+ item.button?.image = image
595
+ } else {
596
+ item.button?.title = "⌨"
597
+ }
598
+ item.button?.toolTip = "hid-keymap"
599
+
600
+ let menu = NSMenu()
601
+ let status = NSMenuItem(title: mapper.status, action: nil, keyEquivalent: "")
602
+ status.isEnabled = false
603
+ menu.addItem(status)
604
+ statusMenuItem = status
605
+ menu.addItem(.separator())
606
+
607
+ for mapping in mappings.sorted(by: { $0.source.description < $1.source.description }) {
608
+ let line = NSMenuItem(title: "\(mapping.source) → \(mapping.target)", action: nil, keyEquivalent: "")
609
+ line.isEnabled = false
610
+ menu.addItem(line)
611
+ }
612
+ if let deviceFilter {
613
+ let scope = NSMenuItem(title: String(format: "Device · 0x%04x/0x%04x", deviceFilter.vendorID, deviceFilter.productID), action: nil, keyEquivalent: "")
614
+ scope.isEnabled = false
615
+ menu.addItem(scope)
616
+ } else {
617
+ let scope = NSMenuItem(title: "Device · all keyboards", action: nil, keyEquivalent: "")
618
+ scope.isEnabled = false
619
+ menu.addItem(scope)
620
+ }
621
+ menu.addItem(.separator())
622
+
623
+ let stopItem = NSMenuItem(title: "Stop Mapping", action: #selector(stopMapping), keyEquivalent: "")
624
+ stopItem.target = self
625
+ menu.addItem(stopItem)
626
+ let restartItem = NSMenuItem(title: "Restart Mapping", action: #selector(restartMapping), keyEquivalent: "")
627
+ restartItem.target = self
628
+ menu.addItem(restartItem)
629
+ menu.addItem(.separator())
630
+ let quitItem = NSMenuItem(title: "Quit hid-keymap", action: #selector(quit), keyEquivalent: "q")
631
+ quitItem.target = self
632
+ menu.addItem(quitItem)
633
+ item.menu = menu
634
+ statusItem = item
635
+ }
636
+
637
+ private func installSignalHandlers() {
638
+ signal(SIGINT, SIG_IGN)
639
+ let interrupt = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
640
+ interrupt.setEventHandler { [weak self] in self?.quit() }
641
+ interrupt.resume()
642
+ interruptSource = interrupt
643
+
644
+ signal(SIGTERM, SIG_IGN)
645
+ let terminate = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
646
+ terminate.setEventHandler { [weak self] in self?.quit() }
647
+ terminate.resume()
648
+ terminateSource = terminate
649
+ }
462
650
  }
463
651
 
464
652
  @main
465
653
  enum FnHIDMapMain {
466
654
  static func main() {
467
655
  do {
468
- let options = try parseArguments(Array(CommandLine.arguments.dropFirst()))
469
- let mapper = HIDMapper(options: options)
470
- try mapper.start()
471
- mapper.run()
656
+ let arguments = Array(CommandLine.arguments.dropFirst())
657
+ let isBackgroundMapping = arguments.first == "--_background_mapping"
658
+ let userArguments = isBackgroundMapping ? Array(arguments.dropFirst()) : arguments
659
+ let options = try parseArguments(userArguments)
660
+ if options.monitor {
661
+ let mapper = HIDMapper(options: options)
662
+ try mapper.start()
663
+ mapper.run()
664
+ } else if !isBackgroundMapping {
665
+ try launchDetachedMapping(userArguments)
666
+ } else {
667
+ let app = NSApplication.shared
668
+ let delegate = MappingMenuApp(options: options)
669
+ app.delegate = delegate
670
+ app.run()
671
+ }
472
672
  } catch {
473
673
  fputs("hid-keymap: \(error)\nUse --help for usage.\n", stderr)
474
674
  exit(EXIT_FAILURE)