// hidmon.swift - raw HID input-report monitor (IOHIDManager layer, below the event system) // Usage: ./hidmon [product-name-substring] // No arg -> matches keyboard (GD/0x06), mouse (GD/0x02), and consumer-control (0x0C/0x01) usage pairs. // With arg -> additionally drops devices whose Product string does not contain the substring (case-insensitive). // Output: one line per input report: ISO timestamp | product | reportID | raw bytes hex. // Requires the Input Monitoring TCC permission for the hosting terminal app. import Foundation import IOKit.hid let nameFilter = CommandLine.arguments.count > 1 ? CommandLine.arguments[1].lowercased() : nil let manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) func usagePair(_ page: Int, _ usage: Int) -> CFDictionary { [kIOHIDDeviceUsagePageKey: page, kIOHIDDeviceUsageKey: usage] as CFDictionary } // Keyboard, Mouse, Consumer Control - covers every collection the dongle's composite map exposes. IOHIDManagerSetDeviceMatchingMultiple(manager, [ usagePair(0x01, 0x06), // Generic Desktop / Keyboard usagePair(0x01, 0x02), // Generic Desktop / Mouse usagePair(0x0C, 0x01), // Consumer / Consumer Control ] as CFArray) let tsFmt = ISO8601DateFormatter() tsFmt.formatOptions = [.withInternetDateTime, .withFractionalSeconds] func product(_ device: IOHIDDevice) -> String { (IOHIDDeviceGetProperty(device, kIOHIDProductKey as CFString) as? String) ?? "?" } var reportBuffers: [IOHIDDevice: UnsafeMutablePointer] = [:] let matchCallback: IOHIDDeviceCallback = { _, _, _, device in let prod = product(device) if let f = nameFilter, !prod.lowercased().contains(f) { return } let maxLen = (IOHIDDeviceGetProperty(device, kIOHIDMaxInputReportSizeKey as CFString) as? Int) ?? 64 let buf = UnsafeMutablePointer.allocate(capacity: max(maxLen, 8)) reportBuffers[device] = buf FileHandle.standardError.write("MATCHED: \(prod) (maxInputReport=\(maxLen))\n".data(using: .utf8)!) IOHIDDeviceRegisterInputReportCallback(device, buf, max(maxLen, 8), { _, _, sender, _, reportID, report, reportLength in guard let sender = sender else { return } let dev = Unmanaged.fromOpaque(sender).takeUnretainedValue() let hex = (0 ..< reportLength).map { String(format: "%02x", report[$0]) }.joined(separator: " ") let fmt = ISO8601DateFormatter() fmt.formatOptions = [.withInternetDateTime, .withFractionalSeconds] print("\(fmt.string(from: Date())) | \((IOHIDDeviceGetProperty(dev, kIOHIDProductKey as CFString) as? String) ?? "?") | reportID=\(reportID) | \(hex)") fflush(stdout) }, Unmanaged.passUnretained(device).toOpaque()) } IOHIDManagerRegisterDeviceMatchingCallback(manager, matchCallback, nil) IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) let openResult = IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) if openResult != kIOReturnSuccess { let hexErr = String(format: "0x%08x", openResult) FileHandle.standardError.write(""" IOHIDManagerOpen failed (\(hexErr)). If \(hexErr) == 0xe00002e2 (kIOReturnNotPermitted): grant Input Monitoring to your terminal app: System Settings > Privacy & Security > Input Monitoring > enable your terminal, then fully quit and relaunch it. """.data(using: .utf8)!) exit(1) } FileHandle.standardError.write("hidmon: open OK, waiting for input reports (Ctrl-C to stop)...\n".data(using: .utf8)!) CFRunLoopRun()