Building InputSwitch: A Software KVM for Two Macs, In a Weekend
The Friction Was Immediate
I bought a Mac mini to act as my always-on Claude/AI box. The plan was simple. Park it on my desk, leave it on 24/7, ssh into it from anywhere, run long Claude Code jobs without burning my MacBook's battery. A second brain that happens to also be a CPU.
The plan worked. The setup did not.
I have one monitor. One MX Master mouse. One MX Keys Mini keyboard. Two Macs that both want to use all of them. To switch from my MacBook to the Mac mini I had to do three things, in order:
- Reach behind the monitor and click the input button until it cycled to DisplayPort.
- Tap the Easy-Switch key on the MX Keys to channel 2.
- Click the channel button on the bottom of the MX Master to channel 2.
Three actions. Maybe twelve seconds. Reaching behind a monitor in 2026 feels like a war crime.
By hour two of using both Macs, I was already doing the math. I switch between them maybe twenty times a day. Twelve seconds times twenty is four minutes a day, two hours a month, a full day a year of reaching, tapping, and clicking. Plus the cognitive friction every single time.
That is when the spark hit. What if this was one click?
What InputSwitch Does
InputSwitch is a macOS menu bar app. It runs on both Macs simultaneously. It exposes two buttons: "Switch to MacBook" and "Switch to Mac mini." Click either, and three things happen at once:
- The monitor input switches (DDC over USB-C, via m1ddc).
- Both Logitech devices switch host channels (HID++ 2.0 over Bluetooth, talking directly to IOKit).
- The Edifier M60 speakers connect on the destination Mac (Bluetooth, via blueutil).
Three protocols, one menu, one click. Around 400 lines of Swift in a single main.swift.
It is not a clever app. It is a small, ugly, useful one. Exactly the kind of thing you build for yourself and never tell anyone about, except I am telling you now because the journey to get here was the actually interesting part.
Wall One: HID++ Is a Documented-But-Not-Documented Protocol
The first thing I needed to figure out was how to tell a Logitech MX Master to switch hosts from code.
Logitech's multi-host devices speak a protocol called HID++ 2.0. It is a vendor-specific extension on top of standard USB HID. Logi Options+ uses it to talk to your peripherals. The protocol is documented (Logitech publishes specs on GitHub), but the specs assume you already know which feature index corresponds to "switch host" on your specific device, and that index is different per model.
The feature you want is 0x1814 (CHANGE_HOST). To find its index on a given device, you send an IRoot getFeature request and wait for the response. The response tells you "on this device, CHANGE_HOST lives at index 0x0a" (or whatever). Then you send a setCurrentHost to that index with the channel number.
Sounds simple. The implementation took me through three IOKit gotchas:
- The HID input report callback is a
@convention(c)C function pointer. It cannot capture Swift state. You have to pass an opaque pointer through andUnmanagedyour way back to a class instance. Forget to deregister the callback before releasing the wrapper and you get a use-after-free that crashes a couple seconds later, when the device sends an unrelated report and the now-dangling closure runs. - If you send the
setCurrentHostcommand once, it works maybe 80% of the time. Some devices need 2 or 3 retries with a settling time between them. I send it 3x with 150ms spacing, then wait 400ms before closing. - Logi Options+ aggressively grabs the device every few hundred milliseconds. If your
setCurrentHostlands during one of its grabs, it gets dropped. Solution: open the device withkIOHIDOptionsTypeSeizeDevicefor exclusive access, run the switch, release.
Each of those was a wall. Each one I would have hit, googled fruitlessly, and probably given up on if I were doing this alone in a regular evening. With Claude Code as a research and prototyping partner, each one took 20 to 40 minutes to find and fix.
Wall Two: TCC Hates You
This one almost killed the project.
Talking to HID devices on macOS requires the Input Monitoring permission. You add the app once in System Settings, toggle it on, done.
Except every rebuild broke it. I would compile, sign with codesign --sign - (ad-hoc), test, and the app would silently fail with IOHIDDeviceOpen returning 0xE00002E2. The toggle in System Settings still showed "on." The permission was, by all visible signals, granted. But it was not.
It took me a while to understand the failure mode. macOS TCC anchors permissions to a code signature's identity. Ad-hoc signing produces a new CDHash on every compile, which TCC interprets as "different app, no permission." It does not invalidate the toggle visibly, because that would imply the system tracks per-build state. So you keep toggling on and off, removing and re-adding the app, getting nowhere.
The fix is to sign with a real certificate. An Apple Development cert (free with any Apple Developer account) gives TCC a stable identity anchored to the cert chain. The CDHash changes per build, but the team identifier and signing chain do not, and that is what TCC actually checks.
I added one line to build.sh:
codesign --force --sign "Apple Development: ..." "$APP"
The Input Monitoring grant has survived every rebuild since.
Wall Three: HID++ Has To Run On the Active Mac
My first architecture was clean. I would run InputSwitch only on the Mac mini. The MacBook would ssh into the mini and trigger everything from there. One source of truth.
It did not work. HID++ commands targeting a Logitech device only land if you send them from the Mac the device is currently bonded to. If I am on the MacBook and I send setCurrentHost(2) from the Mac mini, the mouse never sees it because the mouse's active Bluetooth channel is on the MacBook.
The fix was to run InputSwitch on both Macs and have each instance handle HID++ locally. The DDC command (which talks to the monitor through the cable on the Mac mini) routes via ssh from the MacBook. The HID++ command always runs locally. Two machines, one app, asymmetric routing per protocol.
This is a small architectural pivot but it is the kind of thing you only learn by hitting the wall. No amount of reading the HID++ spec would have told me that I was holding it wrong.
The Mouse-Mid-Switch Caveat
There is one quirk I have not been able to fully solve. If I move the mouse during the switch, the HID++ command sometimes does not register. I think it is a race between Logi Options+ reclaiming the device and our setCurrentHost arriving. The seize-device fix cut the failure rate dramatically, but did not zero it out.
The workaround is "do not touch the mouse for half a second after clicking switch." Which sounds dumb. But muscle memory adapts in a day, and now I do not even think about it.
This is the kind of imperfection I have learned to ship. Indie product. One user. The user knows. Move on.
Then I Bought Speakers
Six months in, the setup was working beautifully for monitor + keyboard + mouse. Then I bought an Edifier M60. Two-channel Bluetooth speakers, beautifully built, surprisingly good for the price.
And immediately I hit the same friction in audio. Speakers paired on the MacBook played MacBook audio. To play Mac mini audio, I had to disconnect on MacBook and connect on Mac mini, manually, through the menu bar Bluetooth submenu. New permutation of the old problem.
So I extended InputSwitch to handle speakers. blueutil is the obvious tool. It is a small CLI that wraps Apple's IOBluetooth API:
blueutil --connect c8-24-78-1a-ef-de
blueutil --disconnect c8-24-78-1a-ef-de
The plan was: when you click "Switch to Mac mini," the MacBook (where the speaker is currently connected) runs blueutil --disconnect, then ssh into the mini and runs blueutil --connect there. Same shape as the DDC routing. Twenty minutes of work.
It was not twenty minutes of work.
Wall Four: blueutil Lies Over SSH
I ran ssh mini "blueutil --connect c8-24-78-1a-ef-de" and got back:
Power is required to be on for connect command
Failed to connect "c8-24-78-1a-ef-de"
But Bluetooth was on. system_profiler SPBluetoothDataType on the Mac mini reported State: On. The Logitech devices were happily connected over Bluetooth, on that exact same machine. Bluetooth was clearly on. blueutil thought it was off.
This is a rabbit hole I do not recommend descending. The short version: blueutil reads Bluetooth state through private CoreBluetooth APIs that return wrong values when called from a non-GUI session. SSH sessions do not have a GUI session. blueutil over SSH always thinks Bluetooth is off, regardless of reality.
The fix you find online is launchctl asuser, which requires sudo. I have passwordless ssh between the Macs, not passwordless sudo. Dead end.
The fix that actually works: never run blueutil over ssh. Each Mac runs InputSwitch in its user GUI session (it launches via Login Items). blueutil spawned as a child of that app inherits the GUI session and works correctly. So you keep all blueutil calls local to whichever Mac is going to do the action.
But then how does the MacBook tell the Mac mini "you should connect to the speaker now"?
A Trigger File Watched By DispatchSource
The answer turned out to be the most fun part of the whole project.
Each app watches a per-user file:
~/Library/Caches/com.helsky.inputswitch.trigger
When the source Mac wants to tell the destination Mac to do something, it ssh-writes a one-line command into that file:
ssh other-mac "printf '%s' 'connect c8-24-78-1a-ef-de' > ~/Library/Caches/com.helsky.inputswitch.trigger"
The destination's app has a DispatchSource.makeFileSystemObjectSource watching that file with [.write, .extend]. When the file changes, the source fires. The handler reads the line, parses it (connect <mac> or disconnect <mac>), spawns blueutil locally with the right argument, and clears the file in place (in-place truncate, so the watcher's file descriptor stays valid).
It is small. About 60 lines. It does one thing. The trigger file is the world's smallest IPC channel.
I love this kind of solution. No new networking. No new ports. No service to manage. The trust boundary is whatever ssh already trusts. The protocol is one line of text. If anything breaks, you can cat the trigger file and know exactly what was last written.
Wall Five: Bluetooth TCC, Same Trick, Different Permission
Of course, the moment I shipped that, the speaker switch failed silently on the Mac mini. blueutil aborted with:
Error: Received abort signal, it may be due to absence of access to Bluetooth API
Same TCC pattern as Input Monitoring. Bluetooth access is its own permission. The parent app (InputSwitch) needs it; the spawned blueutil inherits the parent's TCC context.
The fix is in two places. Info.plist declares the intent:
<key>NSBluetoothAlwaysUsageDescription</key>
<string>InputSwitch routes the Edifier M60 between Macs by issuing
Bluetooth connect and disconnect commands.</string>
And the user goes once to System Settings > Privacy & Security > Bluetooth and adds InputSwitch.app. Same one-time grant flow as Input Monitoring. After that, blueutil works correctly when spawned by the app.
This was the last wall. The whole speaker switching now works end to end. I click "Switch to Mac mini" from my MacBook. The monitor flips. The keyboard and mouse flip. Half a second later the Edifier disconnects on the MacBook. Half a second after that, it connects on the Mac mini. Audio follows me.
That is not nothing.
Still Open: The Magic Trackpad
There is one peripheral I cannot yet automate. The Apple Magic Trackpad.
Apple peripherals do not speak HID++. They have no documented multi-host protocol. A Magic Trackpad is paired to one Mac at a time. You can re-pair it, but there is no programmatic "switch to host 2" command.
The honest answer for trackpad-across-Macs is Apple's own Universal Control. It works. It is built in. It is free. The cursor flows from one Mac to the other when you push past the screen edge. I have it on right now.
But it is not the same thing as a one-click switch. With Universal Control, both Macs are technically active and the trackpad is "on" the Mac it last entered. There is no clean moment of "everything is now on this Mac." For 95% of use cases this does not matter. For the other 5% (recording a screenshare, presenting, anything that wants the trackpad explicitly bound to one machine), it does.
The hard wall for full automation: there is no public API on macOS for telling the system "disconnect this Bluetooth device and have that other machine reconnect." The closest path is reverse-engineering the Apple BLE pairing protocol, which is a lot of work for one peripheral. blueutil cannot disconnect/reconnect Apple HID devices in a useful way because Magic peripherals re-pair on a different timing than third-party speakers.
I will come back to this. Probably as a separate exploration. Probably involving sniffing Bluetooth HCI traffic between Magic Trackpad and macOS to see what the pairing handshake actually looks like. Not this weekend.
What This Would Have Cost Me Without AI
Here is the part I want to be honest about.
This project would have taken me weeks if I were doing it alone. Maybe months. Probably I would have given up.
Each wall I described above represents a research task that, on its own, is hard and time-consuming:
- Reading Logitech's HID++ 2.0 spec, finding the right feature index, writing IOKit Swift bindings: easily a day.
- Diagnosing the TCC ad-hoc signing issue from a
0xE00002E2error code: probably two evenings of searching forums. - Diagnosing blueutil's GUI-session quirk: I would have stared at "Power is required to be on for connect command" while
system_profilersaid the opposite for hours. - Designing the trigger-file IPC: an afternoon.
- The Bluetooth TCC permission, which only surfaces when you actually run from a non-Terminal context: probably another evening.
I am a senior frontend developer. I have shipped native iOS apps. I have not written Swift IOKit code before this project. I had read about HID++ exactly zero times before two weeks ago. I had never used DispatchSource.makeFileSystemObjectSource.
Each of these gaps would have required hours of orientation. Reading Apple's docs, which assume you already know IOKit. Reading Logitech's protocol notes, which assume you already know HID. Sifting through forum posts that mostly say "this used to work in Catalina but no more."
With Claude Code as a partner, I did not have to orient. I described the goal. We searched, we hypothesized, we tried, we hit the wall, we backed up, we tried something else. The walls were still walls. I still spent real hours on them. But the time between hitting a wall and understanding it compressed by an order of magnitude.
I am not saying AI did the work. The architecture choices, the willingness to keep going, the "hmm, what if I just write a one-line file and watch it" leap, those are mine. But I am saying I would not have shipped this without it. The friction of orienting on a new problem area used to be the thing that killed my side projects. It is no longer the thing that kills them.
The Gain Is Not the Switch
The clicks I save are not the point. Twelve seconds reaching behind a monitor is annoying, but it is not life-altering.
The gain is that I now run two Macs as one workspace. I can send Claude Code a multi-hour task, switch to the MacBook, and forget the Mac mini exists. When the task is done I switch back. No friction. No "ugh, do I really want to deal with the input switching again." The Mac mini is finally what I bought it to be: an always-on second brain that disappears when I am not using it.
That, plus the embarrassing satisfaction of clicking a menu bar button and watching three things happen at once, is what makes a small ugly app worth building.
If you have two Macs and one set of peripherals, you should probably build something like this for yourself. The repo is at helsky-labs/InputSwitch. The code is short enough to read in one sitting. The walls I described are documented in the README so you do not have to find them yourself.
Go reach behind your monitor one less time today.