Skip to content

helsky-labs/InputSwitch

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

7 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

InputSwitch

A macOS menu bar app that switches a shared monitor, Logitech peripherals, and a Bluetooth speaker between two Macs in one click. Software KVM, no hardware required.

What It Does

InputSwitch sits in your menu bar and gives you two buttons:

  • Switch to MacBook: monitor to HDMI, Logitech devices to channel 1, Edifier M60 connects on the MacBook
  • Switch to Mac Mini: monitor to DisplayPort, Logitech devices to channel 2, Edifier M60 connects on the Mac Mini

Three switching mechanisms, three protocols:

  • Monitor: DDC commands via m1ddc.
  • Logitech peripherals: HID++ 2.0 over Bluetooth, setCurrentHost directly through IOKit.
  • Edifier M60 speakers: Bluetooth connect / disconnect via blueutil, with cross-machine coordination through a tiny SSH-written trigger file.

The app runs on both machines simultaneously. Each instance handles HID++ locally (the device's active Bluetooth channel is always on the machine you're currently using), routes DDC commands to whichever Mac has the physical display cable, and runs blueutil locally on whichever Mac is the destination of the speaker switch.

Requirements

  • macOS 11.0+
  • m1ddc installed at /opt/homebrew/bin/m1ddc
  • blueutil installed at /opt/homebrew/bin/blueutil on both Macs (only needed if you use the speaker switching path)
  • Logitech peripherals with HID++ 2.0 and multi-host support (MX Master 3S, MX Keys Mini, etc.)
  • An Edifier M60 (or any Bluetooth speaker paired on both Macs) — optional
  • An Apple code signing certificate (see Signing)
  • Bidirectional passwordless SSH between machines (for DDC routing and the speaker trigger file)

Quick Start

1. Clone and configure

git clone https://github.com/helsky-labs/InputSwitch.git
cd InputSwitch

Before building, update build.sh with your own signing certificate (see Signing) and edit the configuration values in Sources/main.swift to match your setup (see Configuration).

2. Build

./build.sh

This compiles the Swift source, signs the bundle, installs it to /Applications, and launches it.

Build without installing:

NO_INSTALL=1 ./build.sh

3. Grant permissions

The app needs two TCC permissions: Input Monitoring (for IOKit HID, used by Logitech HID++) and Bluetooth (for blueutil, used by speaker switching). On each Mac:

  1. Open System Settings > Privacy & Security > Input Monitoring, click +, add /Applications/InputSwitch.app, enable it.
  2. Open System Settings > Privacy & Security > Bluetooth, click +, add /Applications/InputSwitch.app, enable it.

One-time step per Mac. Both permissions survive rebuilds because the app is signed with a stable certificate identity, not ad-hoc.

If you only use the monitor and Logitech paths and don't have a Bluetooth speaker, you can skip the Bluetooth permission. The speaker step will fail silently; the monitor and Logitech switch still works.

4. Add to Login Items (optional)

  1. Open System Settings > General > Login Items
  2. Add /Applications/InputSwitch.app

5. Deploy to second Mac

scp -r InputSwitch.app you@other-mac.local:/Applications/
ssh you@other-mac.local "open /Applications/InputSwitch.app"

Grant Input Monitoring on the second machine too (same one-time step).

Configuration

The app is a single Swift file. All configuration lives in Sources/main.swift.

Monitor Input Codes

In the Actions section near the bottom of AppDelegate:

@objc private func switchToMacBook() {
    switchAll(monitorInput: 17, logiChannel: 0x00)  // HDMI
}
@objc private func switchToMacMini() {
    switchAll(monitorInput: 15, logiChannel: 0x01)  // DisplayPort
}

Find your monitor's input codes:

m1ddc get input       # shows the current input code
m1ddc display list    # lists all connected displays

Common DDC input codes: HDMI 1 = 17, HDMI 2 = 18, DisplayPort 1 = 15, DisplayPort 2 = 16, USB-C = 16. These vary by monitor; check your model's documentation or just try values.

Logitech Channel Mapping

Channels are zero-indexed: 0x00 = host 1, 0x01 = host 2, 0x02 = host 3. Match these to whichever channel you've paired on your Logitech devices.

Speaker (Bluetooth) Configuration

The speaker MAC and blueutil path live near the top of Sources/main.swift:

private let kSpeakerMAC      = "c8-24-78-1a-ef-de"  // EDIFIER M60 (blueutil format)
private let kBlueutilPath    = "/opt/homebrew/bin/blueutil"
private let kTriggerFilePath = "\(NSHomeDirectory())/Library/Caches/com.helsky.inputswitch.trigger"

blueutil accepts MAC addresses with dashes or colons. To find yours:

blueutil --paired

The hostnames used to SSH-trigger the other Mac live in otherMacSSHTarget:

private var otherMacSSHTarget: String {
    isOnMacMini
        ? "helrabelo@macbook-pro-max-m4.local"
        : "helrabelo@hels-Mac-mini.local"
}

Update both branches to match your setup.

Machine Detection

The app detects which Mac it's running on by hostname:

private var isOnMacMini: Bool {
    let host = ProcessInfo.processInfo.hostName.lowercased()
    let name = (Host.current().localizedName ?? "").lowercased()
    return host.hasPrefix("hels-mac-mini") || name.hasPrefix("hels-mac-mini")
}

Change the prefix strings to match your setup. This controls whether DDC runs locally or over SSH.

DDC Routing

The Mac with the physical display cable (DisplayPort/USB-C) runs m1ddc locally. The other Mac sends DDC commands over SSH:

private func runDDC(code: Int) -> Bool {
    if isOnMacMini {
        return exec("/opt/homebrew/bin/m1ddc", args: ["set", "input", "\(code)"])
    }
    return exec("/usr/bin/ssh", args: [
        "-o", "StrictHostKeyChecking=no",
        "-o", "ConnectTimeout=5",
        "helrabelo@hels-Mac-mini.local",
        "/opt/homebrew/bin/m1ddc set input \(code)"
    ])
}

Update the SSH user and hostname to match your machines.

SSH Setup

DDC routing only needs one direction (from the Mac without the display cable to the Mac that has it). The speaker trigger needs both directions, because either Mac may need to nudge the other one to grab the speaker. On each Mac:

ssh-keygen -t ed25519          # skip if you already have a key
ssh-copy-id you@other-mac.local

Verify both directions:

# From MacBook:
ssh you@mac-mini.local "m1ddc get input"

# From Mac mini:
ssh you@macbook.local "echo OK"

Both should return cleanly with no password prompt.

Signing

InputSwitch uses IOKit HID, which requires Input Monitoring permission. macOS (TCC) identifies apps by their code signing identity.

Ad-hoc signing (codesign --sign -) will break on every rebuild. Each compilation produces a new code hash. TCC silently invalidates the permission even though the toggle in System Settings still shows "on". The symptom: IOHIDDeviceOpen returns 0xE00002E2 (kIOReturnNoInterrupt).

You need a certificate-based signature so TCC can anchor the grant to a stable identity.

# Find available certificates:
security find-identity -v -p codesigning

# Update the codesign line in build.sh:
codesign --force --sign "Apple Development: YOUR NAME (TEAM_ID)" "$APP"

An Apple Development certificate (free with any Apple Developer account) works for personal use. A Developer ID certificate (requires the paid Apple Developer Program at $99/year) works for distribution to others.

How It Works

HID++ 2.0

Logitech's multi-host devices support channel switching via the HID++ 2.0 protocol. InputSwitch:

  1. Opens an IOHIDManager filtered to Logitech's vendor-specific HID++ interface (usage page 0xFF43, usage 0x0202)
  2. For each matching device, queries the IRoot feature table for CHANGE_HOST (feature ID 0x1814)
  3. Sends a setCurrentHost command with the target channel
  4. Repeats the command 3x with 150ms spacing for reliability

The feature index query uses the HID++ 2.0 IRoot mechanism: send a getFeature request for feature 0x1814, read the response to get the feature's index on this specific device. If the query times out (device is slow or unresponsive), it falls back to 0x0a, which is the confirmed index on the MX Master 3S.

DDC (Display Data Channel)

Monitor input switching uses DDC/CI commands via m1ddc, which works on Apple Silicon Macs with DisplayPort or USB-C display connections. HDMI generally does not support DDC on Macs. If your display cable is HDMI, you need to route DDC through a second Mac that has a DisplayPort/USB-C connection to the same monitor.

Bluetooth Speaker (Edifier M60)

blueutil reads Bluetooth state through private CoreBluetooth APIs that return wrong values when invoked from a non-GUI session. Run blueutil --power over SSH on a Mac whose Bluetooth system_profiler reports as On, and you'll get 0. Run --connect and you'll get Power is required to be on for connect command. The "fix" you'll find online is launchctl asuser, which needs sudo and isn't usable from passwordless SSH.

InputSwitch sidesteps this by keeping all blueutil calls local to whichever Mac's app is running them. The app is launched into the user's GUI session via Login Items, so blueutil inherits the right context and works correctly. To trigger the other Mac to do its half of the switch, the source Mac SSH-writes a one-line command into a trigger file that the other Mac's app watches with DispatchSource:

~/Library/Caches/com.helsky.inputswitch.trigger

The receiving app reads the line (connect <mac> or disconnect <mac>), runs blueutil locally, and clears the file. The trigger file is created on app launch, watched in-place (no rename), and the watcher uses an inode-stable in-place truncate so writes don't break the descriptor.

The switch is asymmetric:

  • "Switch to MacBook" clicked from MacBook: connect speaker locally. Done.
  • "Switch to MacBook" clicked from Mac mini: disconnect speaker locally on the Mini. Wait 500 ms (the M60 doesn't always handle back-to-back disconnect/connect cleanly). SSH the MacBook's trigger file with connect <mac>. MacBook's app picks it up and connects.
  • The mirror two cases for "Switch to Mac Mini" work the same way in reverse.

This is also why bidirectional SSH is required: either Mac may end up as the source.

Bluetooth permission

blueutil needs the parent app (InputSwitch) to hold the Bluetooth TCC permission. The NSBluetoothAlwaysUsageDescription key in Info.plist declares the intent; you grant the permission once per Mac via System Settings (see Grant permissions). Without it, blueutil aborts with:

Error: Received abort signal, it may be due to absence of access to Bluetooth API

Project Structure

InputSwitch/
  Sources/
    main.swift            # Single file: HID++, DDC, menu bar UI
  Resources/
    AppIcon.icns          # App icon
    StatusIcon.png        # Menu bar icon (1x)
    StatusIcon@2x.png     # Menu bar icon (2x)
  build.sh                # Compile, sign, install, launch

Troubleshooting

Peripherals stop switching after a rebuild. TCC permission was invalidated. This means build.sh is using ad-hoc signing (--sign -) instead of a certificate. See Signing. After fixing the signing, remove InputSwitch from Input Monitoring and re-add it once.

Monitor does not switch. Test m1ddc standalone: m1ddc set input 15. If that works locally but not from the app, check SSH: ssh you@other-mac.local "m1ddc set input 15". If SSH hangs, your key-based auth is not set up (see SSH Setup).

No Logitech devices detected. Make sure the device is actively connected via Bluetooth on this machine (not USB, and not connected to a different host channel). The HID++ interface only appears when the device has a live Bluetooth session.

Devices switch to the wrong host. Check your channel mapping. Open Logitech Options+ and verify which channel number is paired to which Mac. Then match logiChannel: 0x00 for channel 1, 0x01 for channel 2, 0x02 for channel 3.

Menu bar icon missing. The fallback SF Symbol ("display") renders if StatusIcon.png is not found in the bundle. Rebuild with ./build.sh to re-copy resources.

Speaker doesn't connect on the destination Mac. Three things to check, in order:

  1. Bluetooth TCC permission is granted to InputSwitch.app on the destination Mac (System Settings > Privacy & Security > Bluetooth).
  2. Bidirectional passwordless SSH is set up. Run ssh <other-mac> "echo OK" from each Mac.
  3. The speaker is paired on both Macs and currently in Bluetooth input mode (not USB-C, not AUX).

If everything looks right and it still fails, temporarily uncomment the debug logging in handleSpeakerTrigger (or add a print to the exec path) and tail ~/Library/Caches/com.helsky.inputswitch.log on the destination Mac while clicking switch.

Speaker connects but audio still plays on the wrong Mac. Bluetooth connection ≠ system audio output. macOS doesn't auto-route audio to a freshly connected device. Either set the Edifier as the default output on each Mac once (System Settings > Sound > Output), or use a tool like SoundSource to script the output device alongside the BT switch.

License

MIT. See LICENSE.

About

macOS menu bar app to switch monitor input and Logitech device channels in one click

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors