Per-application control of Logitech mice on macOS — without G Hub or Options+.
Whisker is a single Swift binary and a single TOML config file. It remaps extra mouse buttons per application and sets on-device features (DPI, SmartShift scroll ratchet) over the Logitech HID++ 2.0 protocol.
Logitech splits its configuration software into two resident apps (G Hub for gaming mice, Options+ for office mice). Both want broad permissions and neither is pleasant to run. If you own one mouse from each line, you need both. Whisker replaces the subset that matters:
- Different button behavior per frontmost app (CAD orbit on a thumb button in Fusion 360, tab switching in your terminal).
- Per-mouse mappings — the same button on two mice can do different things.
- Per-app DPI (G305) and scroll ratchet threshold or freewheel (MX Master 2S).
git clone https://github.com/jottr/whisker
cd whiskerNeeds macOS 13 or later, Swift 6 (Xcode command line tools) and just. Everything below runs from the clone; Running it at login puts it on your PATH.
Per-app button remapping and per-app DPI / scroll-wheel mode both work. Buttons are taken over by HID++ diversion where the mouse supports it (the MX Master 2S does) and by an event tap where it does not (the G305 has no 0x1b04) — the tap tells the mice apart by the event's own sender field, so a binding for one mouse never fires from the other.
just check # build + selftest
just probe # read-only: devices, features, buttons, DPI, onboard mode
just probe --divert-test # volatile: divert one button per mouse, then restore
just run # the daemonGenerate a config for the mice you actually own:
just setup # writes ~/.config/whisker/whisker.toml
just setup "MX Master 3S" "G502" # for mice that aren't plugged in
just setup --stdout # print it instead, to pipe or inspectsetup asks each attached mouse what it supports and writes a config with the
right aliases, its real DPI and wheel settings, and one commented-out line per
bindable button — so nothing is guessed from a list of known models, and a
mouse whisker has never seen describes itself correctly. Everything is
commented out, so a fresh file changes nothing until you edit it.
whisker.example.toml is the syntax reference.
Got a new mouse? Run just setup again: with a config already in place it
finds the mice missing from it, shows what each supports, and asks before
adding it:
$ whisker setup
found MX Master 3S — buttons: back, forward, gesture, middle, dpi 1000, wheel ratcheting
adopt it as [mouse.mxmaster]? [y/N] y
adopted MX Master 3S into ~/.config/whisker/whisker.toml
It appends and never rewrites, so your comments and ordering survive — the one
rule the config tooling holds to. A mouse already covered by a match line is
skipped, aliases never collide with ones you're using, and with no terminal to
ask (a pipe, a script) it prints the sections instead of editing anything.
--yes adds without asking, --force starts the file over, and naming a
model (whisker setup "G502") works for a mouse that isn't plugged in.
whisker run is a foreground daemon: it holds the buttons and device state
until you stop it with ctrl-c, and puts everything back when you do.
just watch # foreground, logging every press and profile switch
just logs # follow the installed agent's log
whisker run -vv # as above, plus raw HID++ reportsLevels are error, warn, info (default), debug, trace, set with
--log <level>, -v (debug), -vv (trace), or WHISKER_LOG in the
environment. WHISKER_LOG=debug just install bakes the level into the
LaunchAgent. Logs go to stderr with timestamps; command output — a probe
report, generated TOML — stays on stdout, so redirecting one never swallows
the other.
21:39:01.268 info Wireless Mouse MX Master 2S → mx2s (device 1): diverted back, forward
21:39:04.912 debug frontmost (event): com.autodesk.fusion360
21:39:04.913 debug mx2s back → bound in com.autodesk.fusion360
whisker check parses the config, prints anything wrong with it and exits
non-zero — a broken file otherwise only says so in the log, where the daemon
keeps running the last config that parsed.
whisker run reloads the moment you save — the kernel tells it, through
kqueue, so there is no polling loop and no restart. Symlinked configs work, so
keeping whisker.toml in a dotfiles repo is fine: the watch follows the link
to the file your edits actually land on. just reload forces one if you want
to be certain, or after replacing the file wholesale.
Bind a button in one app and it keeps its ordinary behavior everywhere else.
[mouse.mx2s]
match = "MX Master 2S" # substring of the name `whisker probe` prints
[defaults.mx2s]
smartshift = "on" # ratcheting wheel; "off" frees it to spin
[app."com.apple.Safari".mx2s]
smartshift = "off"
back = { key = "[", mods = ["cmd"] }Button names come from the mouse itself. whisker probe lists every control
it reports, and anything it marks divertable can be bound — by name where
whisker knows one (back, forward, middle, gesture), or by id otherwise
(cid-00d7). A mouse with no HID++ diversion goes through the event tap, where
macOS offers only numbers: middle, back, forward, or button7 and up.
So an unfamiliar mouse needs no template and no code change — only left and right click are refused, since a mouse whose primary buttons stop working is one you cannot use to fix the config.
Anything you don't set keeps whatever the mouse had when whisker started, and that is what it gets back when whisker exits. Device writes wait until no diverted button is held, so a setting never changes under a drag.
Posting synthetic keys and clicks, and the event tap, need an Accessibility grant for whatever runs whisker; it says so on start if the grant is missing. Reading and writing the mouse itself needs no permission at all, and Input Monitoring is never required.
Binding a button to { button = "middle" } also makes movement while it is
held arrive as a middle-button drag, so apps that orbit or pan on middle-drag
(Fusion 360 and friends) work.
A mouse must be awake — a sleeping one answers "unreachable", so click a button first. If whisker is SIGKILLed, diverted buttons stay dead until the mouse is powered off and on; every other exit path restores them.
just cert # once, so the Accessibility grant survives rebuilds — see below
just install # build, sign, copy to ~/.local/bin, load the LaunchAgent
just logs # follow ~/Library/Logs/whisker.log
just restart # after changing the binary or the grant
just uninstall # stop and remove; leaves your config aloneinstall runs whisker setup for you if you have no config yet, and generates
the agent from
launchagent.plist.in. launchd restarts whisker if it
dies, no faster than every 10 seconds — which is also what happens when no
receiver is plugged in, since whisker exits rather than idling.
On first start whisker asks for Accessibility, which also puts it in System
Settings > Privacy & Security > Accessibility with its switch off. Turn the
switch on and just restart. macOS asks once per signature: if you missed the
dialog, add ~/.local/bin/whisker there yourself with + (⌘⇧G to type the
path, since .local is hidden).
Accessibility is granted to a code signature. The default ad-hoc signature changes on every build, so macOS treats each install as a new app and you grant it again. To avoid that, make a self-signed code-signing certificate once:
just cert # asks for your login password, to trust the certificate
just installinstall signs with that certificate whenever it exists, and rebuilds keep the
grant from then on. WHISKER_SIGN_IDENTITY=<name> overrides which one it uses.
See docs/plans/2026-08-14-whisker-design.md for the architecture, safety rules, and the phased implementation plan.
Currently targeted hardware:
- Logitech G305 (Lightspeed receiver)
- Logitech MX Master 2S (Unifying receiver)
Other HID++ 2.0 mice will likely work for the generic parts; feature
support is discovered at runtime (whisker probe).
- No kernel or system extensions. Userland only: IOHIDManager for HID++ report I/O, one CGEventTap for button remapping.
- Idle means idle. Nothing wakes on a timer: the kernel reports config changes, macOS reports app switches, and the mice report their own buttons. A whisker with nothing to do costs about 0.01s of CPU per minute.
- No persistent writes to the mouse. Only volatile settings are touched; a power cycle restores stock behavior.
- Whisker dies safe: on exit it restores default button routing and ratchet state. Only a SIGKILL can skip that — and then powering the mouse off and on restores it, because everything whisker sets is volatile.
- The config file is the interface. Any future GUI just edits the TOML.
Copyright (C) 2026 jottr. GPL-3.0-or-later — whisker comes with no warranty, and anything you build on it stays free too.