Skip to content

Repository files navigation

MXControl

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.


Status: read this before filing an issue

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.

Written but not verified on hardware

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.


What it does

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.


Install

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.app

Enable Launch at Login from the menu once it's running.

Signing: do not skip this

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.


Permissions

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.


Macros

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

Driving an application's menus

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.

Pressing controls in any application

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.

When Accessibility can't see it

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.

Typing text

{ "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.

Secure text

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.

Two honest caveats

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.

Why macros instead of button remapping

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.


Diagnostics

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
./mxprobe

It prints the transport in use, the negotiated HID++ version, the device's full feature table, and decoded state for battery, wheel, DPI and buttons.


How it works

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 0x11 over Bluetooth LE but both 0x10 and 0x11 over 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 10 regardless 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.

Not implemented

  • Notarized release builds. Build from source.
  • An editor for sequence actions. They work, and survive the editor untouched, but must be written in JSON.

Credits

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.

Disclaimer

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.

License

MIT © 2026 theseriouspdx

About

Menu bar control for Logitech mice over HID++ 2.0. Button-combination macros for any mouse.

Topics

Resources

Stars

9 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages