A small macOS menu bar app that controls Logitech mice directly, in place of Logi Options+.
The entire app is one ~120 KB binary and a menu, running as an ordinary userspace process. It talks to the mouse over HID++ 2.0, the same protocol Logitech's own software uses, through plain IOKit. Where Options+ installs a suite with background services, an updater and an account, installing this is copying it, and everything it knows stays on your Mac.
It also does something Options+ doesn't: macros that fire on button combinations. Hold one mouse button, click another, run anything, shell commands included. Options+ macros trigger on a single button; combinations work with any mouse, from any vendor.
This has been developed and tested against exactly one configuration:
| Tested | |
|---|---|
| Device | MX Master 3S |
| Transport | Bluetooth LE |
| macOS | 26 (Darwin 25.1), Apple Silicon |
Everything else (USB cable, Bolt/Unifying receiver, other Logitech models, Intel Macs, older macOS) is written for but not verified. The code detects device capabilities at runtime rather than assuming them, so other hardware should degrade gracefully to whatever it supports. But "should" is doing real work in that sentence.
If it misbehaves on your device, choose Copy Diagnostics in the menu (see Diagnostics) and paste the report into an issue. It contains everything needed to fix it.
Called out explicitly so nobody is surprised:
- Feature
0x2202(Extended Adjustable DPI), used by newer sensors. Function numbers come from community documentation and could not be tested here. The write path is gated on a successful read of the device's own advertised DPI range, and refuses any value the device hasn't itself reported, so if the numbers are wrong the DPI menu simply doesn't appear rather than writing something bad to your sensor. - USB and Bolt/Unifying transports, including the receiver device-index sweep.
- Report rate (
0x8060), which is absent over Bluetooth and so never exercised here.
All of these degrade to "the control doesn't show up" rather than misbehaving. Reports welcome.
Logitech devices (feature-detected: you only see what your mouse actually supports):
- Battery level and charging state
- Scroll wheel: Ratchet / SmartShift / Freespin, plus SmartShift threshold
- Invert scroll, hi-res scroll, invert thumb wheel
- DPI, snapped to your sensor's real supported range and step
- Report rate (USB and Bolt only; the feature is absent over Bluetooth)
- Host switching for multi-host mice
- Settings reapplied automatically on reconnect and after system wake
Any mouse:
- Button macros → keyboard shortcut, launch app/file/URL, shell command, or system action
If MXControl isn't running, your mouse still works completely normally. You lose the customizations, never the hardware. That property is deliberate and load-bearing in the design; see Why macros instead of button remapping.
Requires the Xcode command line tools (xcode-select --install), and that is the entire
toolchain: the build is plain swiftc driven by one script, and every dependency is a system
framework.
git clone https://github.com/theseriouspdx/mxcontrol.git
cd mxcontrol
./scripts/gen-signing-cert.sh # once, see Signing below
./build.sh
open MXControl.appEnable Launch at Login from the menu once it's running.
macOS ties Input Monitoring and Accessibility grants to an app's designated requirement. With an ad-hoc signature that requirement is a cdhash, a hash of the compiled binary, so every rebuild looks like a brand new app and silently revokes your permissions. The Settings toggle keeps reading "on" while pointing at a binary that no longer exists. This is maddening and it will happen to you.
./scripts/gen-signing-cert.sh generates a self-signed certificate, which makes the requirement
identifier + certificate root instead. Grant permissions once and they hold across every
future rebuild. Back up signing/mxctl.p12; generating a different certificate means
re-granting everything.
build.sh falls back to ad-hoc signing with a loud warning if no identity is found.
| Permission | Needed for | Asked when |
|---|---|---|
| Input Monitoring | Opening the HID device at all | Always |
| Accessibility | The macro event tap | Only once you configure a macro |
A fresh install with no macros never installs an event tap and never asks for Accessibility.
If the menu shows "Grant Input Monitoring…", click it, add MXControl.app, then choose
Refresh; it takes effect immediately.
Hold one or more buttons, then click another. You don't have to configure anything first: just perform a combination and MXControl offers to bind it:
back + left: Assign an action to this combination?
[Never ask] [Not now] [Assign…]
This works because detecting a combination and acting on one are separate problems. Noticing that two buttons overlapped needs no interference at all, so unassigned combinations behave completely normally and cost nothing. Only an assigned macro withholds anything.
Accidental presses are filtered by requiring ~80ms of genuine overlap, prompting on release rather than on press, a per-combination Never ask list, and a Suggest New Macros toggle in the menu.
Edit Bindings… opens the editor: a drawn mouse showing which buttons the selected macro uses and which one you're physically pressing right now, plus one row per macro. Press Record… and perform a combination rather than assembling it from popups; that is also how three-button macros get expressed. Save & Apply validates every row and reports the first problem instead of silently discarding it.
The editor is a front end onto a plain JSON file, which stays fully hand-editable:
~/Library/Application Support/MXControl/bindings.json
{
"chords": [
{
"buttons": ["forward", "left"],
"action": { "type": "system", "name": "missionControl" }
},
{
"buttons": ["back", "forward", "left"],
"action": { "type": "shell", "command": "open -a Terminal", "label": "Terminal" }
}
]
}Every button but the last is held; the last one triggers. Older files using {"hold": …, "press": …}
are still read and upgraded on save.
Buttons: left, right, middle, back, forward, extra1, extra2
Actions:
| type | fields |
|---|---|
keyboard |
key plus optional modifiers: cmd, shift, opt, ctrl, fn |
text |
text to type, plus optional pressReturn |
menu |
menuPath: menu titles top-down; optional app (defaults to frontmost) |
ui |
element: a control's visible name; optional role, matchIndex, app |
click |
x, y plus relativeTo: window (default) or screen. Last resort |
sequence |
steps: an ordered list of {text}, {key, modifiers} and {delayMs}. JSON only |
launch |
path: app, file, or https:// URL |
shell |
command, run through /bin/sh -c |
system |
name: missionControl, appExpose, showDesktop, spaceLeft, spaceRight, launchpad, lockScreen, playPause, next, previous, volumeUp, volumeDown, mute |
For a command with no keyboard shortcut (changing an Illustrator workspace, say), bind a
menu action:
{
"buttons": ["forward", "left"],
"action": {
"type": "menu",
"app": "Adobe Illustrator",
"menuPath": ["Window", "Workspace", "Essentials"]
}
}This presses the item by name through the Accessibility API. It does not move your pointer, and it keeps working when the window moves, the display resolution changes, or the menu gains items, none of which is true of replaying recorded pointer coordinates. Verified against three-level paths; the whole menu tree is readable without opening anything.
Two caveats worth knowing. Titles must match exactly, and some toggle: Finder shows
Hide Sidebar or Show Sidebar depending on state, so a macro bound to one will not find the
other. And menu contents can depend on context: an item present with a document open may
vanish without one. Prefer a keyboard shortcut when the command has one.
menu only reaches the menu bar. For anything else (a toolbar button, a palette tab, a
checkbox) use ui, which searches the application's whole Accessibility tree and presses the
control by name:
{ "type": "ui", "element": "Essentials", "app": "Adobe Illustrator" }Add "role": "AXButton" to disambiguate, or "matchIndex": 1 when a name appears more than once.
The search is breadth-first, so the shallowest match wins: usually the control itself rather
than a label nested inside it. Your pointer never moves.
In the editor, choose ui, click Pick…, and point at the control. After a three-second
countdown MXControl reads whatever is under the cursor and fills in its name, role and owning
app, which beats guessing what a control calls itself.
Some applications draw panels that expose nothing to Accessibility; Adobe's are notorious.
That is what click is for, and it is a genuine last resort:
{ "type": "click", "x": 240, "y": 96, "relativeTo": "window", "app": "Adobe Illustrator" }Coordinates are measured from the window's top-left corner, not the screen's, so moving the window does not break the binding. Resizing it still will, as will rearranging panels or changing which panels are open. The pointer is moved, clicked, and put back where it was.
Prefer menu over ui, and ui over click. Each step down that list is more fragile.
{ "type": "text", "text": "Best regards,\nAlex", "pressReturn": true }Sent as unicode rather than simulated key codes, so any character works on any keyboard layout.
Plain text is stored in bindings.json as-is: fine for signatures and boilerplate, wrong for
anything sensitive, since that file is readable by any process running as you and goes into
Time Machine.
A text action has three protection modes:
| Mode | Stored | Prompt | Use for |
|---|---|---|---|
| Plain text | in bindings.json |
none | text you would not mind anyone reading |
| 🔒 Keychain | macOS Keychain | none | anything you would rather not leave in a readable file |
| 🔒 Touch ID / Password | macOS Keychain | on use, reusable 5 min | when you want a deliberate confirmation |
The middle one is the answer for most people. The config file then holds only a reference:
{ "type": "text", "secretRef": "chord-4F2A…", "pressReturn": true }Even with no prompt, that is a real improvement over plain text: the value is encrypted at rest, the Keychain ACL is enforced by the OS against this app's code signature so other applications cannot read it silently, and it is excluded from plaintext Time Machine backups.
Encrypting bindings.json itself would not have achieved any of that. The app must decrypt
unattended, so the key would have to sit on disk beside the ciphertext and anything running as
you could read both. That is obfuscation, not encryption, which is why secrets live in the
Keychain instead.
The third mode is labelled Touch ID only on Macs that have it. Elsewhere the same setting prompts for the login password, and the menu says so rather than promising biometrics that do not exist.
Entering a secret uses a secure text field, which makes macOS turn on Secure Input Mode while you type it. That blocks event taps, and HID-level tools such as Karabiner-Elements, from seeing the one time the value is actually typed by hand.
One limit worth stating plainly: whichever mode you choose, the value is emitted as synthetic key events when the macro fires, and anything holding Input Monitoring can observe them, exactly as it could observe you typing it yourself. Storing it reduces total exposure, since it is typed by hand once instead of every time.
What is worth protecting is your call. The app cannot know whether a string is a signature, an API key, a licence or a passphrase, so it offers the choice rather than guessing.
Held thumb and wheel buttons have their own click deferred until release, because until then the app cannot know whether a macro is coming. There is deliberately no timeout that delivers the press mid-hold; take as long as you like deciding on the second button.
Left and right are never withheld. They pass straight through, so dragging and context menus behave exactly as they always did. When a macro fires while one of them is held, Escape is sent to dismiss the context menu or cancel the drag.
HID++ can "divert" a button: the firmware stops emitting the normal event and sends it to software instead. That is how Options+ remaps buttons, and it has a nasty failure mode: if the controlling app dies while buttons are diverted, those buttons are dead, not merely un-customized, until something restores them.
An event tap inverts that. If MXControl crashes, the tap dies with it and every button is
instantly native again. It also works on Left and Right click, which are not divertable at all
(they report HID++ flags 0x01, with no divertable bit), and it works with non-Logitech mice.
The fastest way: choose Copy Diagnostics in the menu. It puts a full report on the clipboard: app and macOS version, permission status, engine state, the device's transport, HID++ version, complete feature table and decoded settings, and the shape of your macros with their contents redacted (buttons and action types only; commands, text and secrets stay on your machine). Paste it into any issue; it answers nearly every question about unfamiliar hardware.
When the app itself will not run, mxprobe is the standalone fallback: a read-only HID++
dumper that never writes to the device.
swiftc -O -o mxprobe mxprobe.swift
./mxprobeIt prints the transport in use, the negotiated HID++ version, the device's full feature table, and decoded state for battery, wheel, DPI and buttons.
| File | Role |
|---|---|
HIDPP.swift |
Transport. Report descriptor parsing, request/response matching, dedicated run-loop thread |
MXDevice.swift |
Typed feature operations: battery, wheel, DPI, hi-res, thumb wheel, hosts, report rate |
MXController.swift |
Device lifecycle, reconnect/wake reapply, preference storage |
Bindings.swift |
Macro model and JSON config |
Actions.swift |
Action execution |
ChordEngine.swift |
CGEventTap, macro detection, suppression and replay |
main.swift |
Menu bar UI |
If you're extending it, read HACKING.md first; it holds the engine safety rules and the list of paths that lack real-world mileage. A few hardware things worth knowing:
- Which HID++ reports exist is read from the report descriptor at runtime. The same mouse
declares only report
0x11over Bluetooth LE but both0x10and0x11over USB. Hardcoding either one breaks the other transport. - Replies are zero-padded to the full report length, so response length tells you nothing about which fields are populated. Discriminate on feature ID.
- Read-back is not behavior. On the MX Master 3S, the SmartShift threshold always reads back
as
10regardless of what was written, yet the written value demonstrably changes how the wheel feels. Don't use a register read to conclude a tactile setting has no effect, and don't remove user-facing options on that basis.
- Notarized release builds. Build from source.
- An editor for
sequenceactions. They work, and survive the editor untouched, but must be written in JSON.
The HID++ 2.0 protocol is undocumented by Logitech. Public understanding of it comes from years of community reverse engineering, principally Solaar and libratbag. No code was taken from either project, but the feature IDs and function numbers this app relies on are knowledge those projects established, and it would not exist without them.
Not affiliated with, endorsed by, or connected to Logitech. "Logitech", "MX Master", "MagSpeed", "Options+", "Unifying" and "Bolt" are trademarks of Logitech International S.A., used here only to describe compatibility.
This software talks directly to your mouse's firmware. It only calls documented HID++ feature functions and never touches firmware update, device reset, or manufacturing features, but it is provided without warranty. See LICENSE.
MIT © 2026 theseriouspdx