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 +2 -0
- package/native/hid-keymap +0 -0
- package/package.json +1 -1
- package/src/hid-keymap.swift +209 -9
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
package/src/hid-keymap.swift
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
469
|
-
let
|
|
470
|
-
|
|
471
|
-
|
|
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)
|