Ilija Matoski

    ← Articles

    hidutil said it worked, the key disagreed

    A key swap on the Magic Keyboard read back as applied and still did nothing when launchd ran it, and the fix was a permission I'd given to the wrong binary.

    So on my Magic Keyboard the key on the top left types §, and I want it to type `, which is on the key next to left Shift.

    hidutil ships with macOS and doesn’t need sudo, so we can swap them with the following command.

    hidutil property --matching '{"ProductID":0x29a}' --set '{"UserKeyMapping":[{"HIDKeyboardModifierMappingSrc":0x700000035,"HIDKeyboardModifierMappingDst":0x700000064},{"HIDKeyboardModifierMappingSrc":0x700000064,"HIDKeyboardModifierMappingDst":0x700000035}]}'

    The values are HID usages with 0x700000000 added, and you can get the product ID for your keyboard from hidutil list.

    The catch is that the mapping lives on the keyboard’s service, so it’s gone when the keyboard reconnects, and I needed something to run this on every connect.

    Applied, and doing nothing

    So I wrote a small Swift watcher that runs the same command when the keyboard connects, the exit code was 0, --get showed the mapping, and the key still typed §.

    I assumed it was timing and reapplied it at 1, 3, 6 and 10 seconds after a connect, then I tried a plain LaunchAgent on a 10 second timer, and neither changed anything, while the same command from iTerm2 fixed the key straight away.

    So the only difference left was who runs it, and the log shows hidutil asking macOS for a permission on every run.

    hidutil[18250:582c72] (TCC) TCCAccessRequest() IPC

    The log hides which permission, it turned out to be Input Monitoring, and without it the mapping is stored and has no effect.

    We need to add /usr/bin/hidutil under Privacy & Security, Input Monitoring in System Settings, you can press Cmd+Shift+G in the file picker and paste the path.

    I did that and still got §, so I crossed Input Monitoring off, and only later noticed I’d added hdiutil, the disk image tool, which sits in the same directory with the same letters in a different order.

    With the right one in the list it worked from launchd.

    Only when the keyboard connects

    Now we can stop polling, because launchd can start a job when an IOKit service appears, so we need two LaunchAgents.

    The first one is com.ilijamt.magic-keyboard-remap, it runs the hidutil command with RunAtLoad, and the second is the trigger, which has the following in its plist.

    <key>LaunchEvents</key>
    <dict>
    <key>com.apple.iokit.matching</key>
    <dict>
    <key>com.ilijamt.magic-keyboard-remap.connected</key>
    <dict>
    <key>IOProviderClass</key>
    <string>AppleHIDKeyboardEventDriverV2</string>
    <key>VendorID</key>
    <integer>76</integer>
    <key>ProductID</key>
    <integer>666</integer>
    <key>IOMatchLaunchStream</key>
    <true/>
    </dict>
    </dict>
    </dict>

    The job has to consume the IOKit event or launchd keeps relaunching it, so the trigger is a few lines of Swift.

    import Foundation
    import XPC
    let remapJob = "gui/\(getuid())/com.ilijamt.magic-keyboard-remap"
    xpc_set_event_stream_handler("com.apple.iokit.matching", DispatchQueue.main) { _ in }
    DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
    let p = Process()
    p.executableURL = URL(fileURLWithPath: "/bin/launchctl")
    p.arguments = ["kickstart", "-k", remapJob]
    try? p.run()
    p.waitUntilExit()
    exit(0)
    }
    dispatchMain()

    It restarts the first job instead of running hidutil itself, because the hidutil that launchd starts is the one holding Input Monitoring.

    Build it with the following command and load both plists with launchctl bootstrap, and about a second after the keyboard connects the key on the top left types `.

    swiftc -O trigger.swift -o ~/.local/bin/magic-keyboard-remap-trigger