Open-source button remapping for Logitech mice on macOS, without Logi Options+.
Upright is a small native SwiftUI menu-bar app. It remaps Logitech mice over HID++: the thumb buttons, the top (DPI) button, the wheel click and any other button a mouse lets software take over. It also adds pointer-speed, scrolling and battery features. It talks to the mouse directly over the publicly documented HID++ 2.0 protocol, the same approach used by Solaar, LogiOps and libratbag. It doesn't use a kernel extension or a background daemon, and it doesn't include any Logitech software.
Upright works with any Logitech HID++ 2.0 mouse that exposes programmable buttons
(REPROG_CONTROLS_V4), and with several mice at once, over any mix of USB cable, Bluetooth
and Unifying, Bolt or Nano receivers. It was built and tested on the MX Vertical, which is the
only model verified on real hardware. Every other model is built from published device data and
tested against Upright's mouse simulator only. See Supported devices.
Both pictures are rendered by Demo Mode with simulated mice (an MX Vertical, an MX Master 3S and a Lift, with made-up names and batteries). They show the real interface, not real hardware.
- Remap buttons. Back, Forward, the Top (DPI) button, the Wheel Click and any other
divertable button a mouse reports. Actions:
- built-in system actions: Mission Control, App Exposé, Show Desktop, switching Spaces, Back/Forward, tabs, media keys, Spotlight, screenshots, Lock Screen, Copy/Paste/Undo/Redo, middle click and mouse buttons 4/5
- any recorded keyboard shortcut
- Open App, Open URL and Run Shortcut (a Shortcuts.app shortcut, by name)
- Pointer speed: cycle through your presets, or jump to a fixed DPI (mice with
ADJUSTABLE_DPI,0x2201, only) - Easy-Switch: move the mouse to another paired computer (mice that report the Easy-Switch feature)
- Do Nothing, or Default to leave the button to the mouse
- Several mice at once. Every connected mouse is listed in the menu-bar panel and in Settings → Mice, with its connection, state and battery. See Using several mice.
- Per-model mappings. Each mouse model has its own button setup and its own per-app profiles. Choose which model you are editing with the Configure picker in Settings → Buttons. You can set up a catalog model before you own it, copy a setup from another model, or use one setup for all your mice.
- Gestures. Set a button to Gestures to get five actions: a click, plus hold and move up, down, left or right. The mouse reports raw movement while the button is held, so the cursor doesn't move. If a button can't report raw movement, Upright falls back to watching the cursor, which does move during the gesture (the Buttons pane says so).
- Per-app profiles. Keep a global table and add apps (by bundle ID) that override single buttons while they are frontmost. A button without an override uses the global action, and an override can also hand a button back to the mouse in that app.
- Per-mouse settings. Give a mouse a nickname, switch Remap this mouse off to hand all its buttons back, and keep its pointer speed. Upright remembers these for each physical mouse and lists mice it has seen before under Not Connected.
- Pointer speed (DPI). Set the speed with a slider, keep a list of presets, and pick a
speed to apply whenever a mouse reconnects. Speeds snap to what the mouse supports. Mice without
0x2201don't get pointer-speed controls. - Device info. Firmware, serial number, battery, connection type, the Easy-Switch channel (with host names when the mouse stores them), the HID++ feature table and the control list, plus a Copy Device Report button for bug reports and new-model requests.
- Battery. Reads the level when a mouse connects and about every 10 minutes. Mice that report only a coarse level (critical, low, good, full) show an approximate value such as "~50%". Low-battery notifications at 15% and 5% per mouse (on by default; macOS asks for notification permission the first time), and an optional percentage next to the menu-bar icon (with several mice it shows the lowest).
- Scrolling. Reverse the mouse wheel independently of the trackpad, change the speed (0.5x to 3x), and try the experimental smooth scrolling. All off by default. These settings apply to every mouse wheel; see Scrolling.
- Export and import of all mappings and profiles, for every model, as a JSON file.
- First-run onboarding for new installs: connect, allow Accessibility, choose a setup.
- Robust reconnection. Button diversion is reapplied after a mouse sleeps or is switched off and on, after the Mac wakes, and when a receiver or cable is replugged. Each mouse is retried on its own with backoff, so a sleeping mouse doesn't hold up another one.
- Leaves nothing behind. Buttons are only temporarily diverted. When Upright quits it hands them back, and power-cycling a mouse always restores factory behavior. Nothing is written to a mouse's memory.
- Demo Mode.
--demoruns the real interface against simulated mice, and--export-snapshotsrenders it to PNG files. See Demo Mode. - Feels native. A menu-bar panel, a System Settings-style window, SF Symbols, light and
dark mode. Open at Login uses
SMAppService. - Explains permissions. It shows what it needs and why. Until Accessibility is granted, mappings that need it are not applied and the buttons keep their normal behavior.
A fresh install remaps nothing: every button keeps its native behavior (for the MX Vertical, Back and Forward are mouse buttons 4 and 5, and the top button cycles DPI). Pick actions in Settings → Buttons, or accept the suggested setup for your model during onboarding.
If you used an earlier version, your saved mappings are migrated into the MX Vertical's setup and kept. Anything Upright can't read is backed up in its preferences instead of being thrown away. See the changelog for the details of the migration and of going back to an older build.
Only the MX Vertical has been tested on real hardware. Every other model below is built from published device data (Solaar and libratbag dumps) and has been tested only against Upright's mouse simulator. "Published data" means exactly that: it is expected to work, and unverified on hardware. If you try one, please report what you see (see Report your mouse).
The table below is derived from the model catalog,
DeviceCatalog.swift. The same data
with every product ID is in docs/DEVICES.md.
| Model | Connections in the catalog | Remappable controls in the catalog | Status |
|---|---|---|---|
| MX Vertical | USB cable, Bluetooth, Unifying receiver | Back, Forward, Top (DPI) button, Wheel click | Verified on hardware |
| MX Master | Bluetooth, Unifying / Nano receiver | Back, Forward, Gesture button, SmartShift button, Wheel click | Published data (untested on hardware) |
| MX Master 2S | Bluetooth, Unifying / Nano receiver | Back, Forward, Gesture button, SmartShift button, Wheel click | Published data (untested on hardware) |
| MX Master 3 | Bluetooth, Unifying / Nano receiver | Back, Forward, Gesture button, SmartShift button, Wheel click | Published data (untested on hardware) |
| MX Master 3 for Business | Bluetooth, Bolt receiver | Back, Forward, Gesture button, SmartShift button, Wheel click | Published data (untested on hardware) |
| MX Master 3S | Bluetooth, Bolt receiver | Back, Forward, Gesture button, SmartShift button, Wheel click | Published data (untested on hardware) |
| MX Master 4 | Bluetooth | Back, Forward, Gesture button, Haptic Sense button, SmartShift button, Wheel click | Published data (untested on hardware) |
| MX Anywhere 2 | Bluetooth, Unifying / Nano receiver | Back, Forward, Wheel click, Tilt left, Tilt right | Published data (untested on hardware) |
| MX Anywhere 2S | Bluetooth, Unifying / Nano receiver | Back, Forward, Wheel click, Tilt left, Tilt right | Published data (untested on hardware) |
| MX Anywhere 3 | Bluetooth, Unifying / Nano receiver | Back, Forward, SmartShift button, Wheel click | Published data (untested on hardware) |
| MX Anywhere 3 for Business | Bluetooth, Bolt receiver | Back, Forward, SmartShift button, Wheel click | Published data (untested on hardware) |
| MX Ergo | Bluetooth, Unifying / Nano receiver | Back, Forward, Precision button (DPI), Wheel click, Tilt left, Tilt right | Published data (untested on hardware) |
| Lift | Bluetooth, Bolt receiver | Back, Forward, Top (DPI) button, Wheel click | Published data (untested on hardware) |
| Lift for Business | Bluetooth, Bolt receiver | Back, Forward, Top (DPI) button, Wheel click | Published data (untested on hardware) |
| M720 Triathlon | Bluetooth, Unifying / Nano receiver | Back, Forward, Gesture button (multi-platform), Wheel click, Tilt left, Tilt right | Published data (untested on hardware) |
| M585 / M590 | Bluetooth, Unifying / Nano receiver | Back, Forward, Wheel click, Tilt left, Tilt right | Published data (untested on hardware) |
| Signature M650 L | Bluetooth, Bolt receiver | Back, Forward, Wheel click | Published data (untested on hardware) |
| Signature M550 | Bluetooth, Bolt receiver | Wheel click | Published data (untested on hardware) |
| ERGO M575 | Bluetooth, Unifying / Nano receiver | Back, Forward, Wheel click | Published data (untested on hardware) |
| Marathon M705 | Unifying / Nano receiver | Back, Forward, Wheel click, Tilt left, Tilt right | Published data (untested on hardware) |
| Pebble M350 | Bluetooth, Unifying / Nano receiver | Wheel click | Published data (untested on hardware) |
| M510 (v2) | Unifying / Nano receiver | Back, Forward, Wheel click, Tilt left, Tilt right | Published data (untested on hardware) |
| M185 / M235 / M310 | Unifying / Nano receiver | Wheel click | Published data (untested on hardware) |
| Status | Meaning |
|---|---|
| Verified on hardware | Tested on a real mouse. Only the MX Vertical. |
| Published data (untested on hardware) | In the catalog, built from published dumps. Simulator-tested only. |
| Untested | Not in the catalog. Any other Logitech mouse that answers HID++ 2.0 and exposes programmable buttons is served too, but nothing is known about it. |
The catalog supplies names, control lists and suggested setups. Whether a mouse is served is
decided by what it answers (HID++ 2.0, a pointer, and 0x1B04 with at least one control Upright can
divert), not by its name or product ID. A mouse that doesn't qualify shows up under
Settings → Mice → Other Logitech devices with the reason, and keeps working as usual.
Keyboards are never opened. G-series and PRO gaming mice keep their buttons in onboard profiles
and are not expected to work.
| Receiver | Status |
|---|---|
Unifying (046D:C52B, C532) |
Supported. A mouse is found in any of the 6 slots. The MX Vertical is verified on hardware (1.0) behind one; other mice are unverified. |
Bolt (046D:C548) |
Implemented from Solaar's documentation and simulator-tested only. Not verified on a real Bolt receiver. The MX Vertical can't pair with Bolt. |
| Nano (12 product IDs, see docs/DEVICES.md) | Opened and read with the Unifying pairing layout. Unverified on hardware. |
| Lightspeed (G and PRO mice) | Never opened. The mice behind Lightspeed receivers in the research data have no programmable-button feature (0x1B04), the C53A receiver also carries a PowerPlay chip, and Logitech G HUB owns them. Upright leaves them completely alone. |
27 MHz (pre-Unifying, C517) |
Never opened. Those devices speak HID++ 1.0 only. |
| Connection | Status |
|---|---|
| USB cable | Supported. The MX Vertical (046D:C08A) is verified on hardware (1.0); other models are unverified. |
| Bluetooth | Best effort. Depends on macOS exposing the mouse's HID++ collection, and may need Input Monitoring. The MX Vertical (046D:B020) is the model it was built for; other models are unverified. |
| Receivers | See above. |
If you own a model other than the MX Vertical, you can help move it from "published data" to
"verified": use Settings → Mice → Copy Device Report while the mouse is connected, then quit
Upright and run make probe ARGS=--all (no button is diverted; Upright and the probe must not run
at the same time), then open an issue on GitHub with both
(see .github/ISSUE_TEMPLATE).
You can connect any number of supported mice at the same time, over any mix of cable, Bluetooth and receivers, including several mice on one receiver. Upright opens each one separately.
- Each mouse is its own row in the menu-bar panel and in Settings → Mice, with its own state, battery, pointer speed, nickname and Remap this mouse switch. A mouse that is asleep or isn't answering doesn't hold up the others.
- Button mappings belong to the model, not to the unit. Two MX Masters share one setup; an MX Master and an MX Vertical each have their own. When two or more units of a model are connected, the Buttons pane says "Applies to every ".
- The same mouse over two links (for example a mouse that is on its receiver and also on a cable) is recognized by its unit ID and listed once: one connection is used and the other is kept as a standby, so an action is never sent twice. This relies on the mouse reporting the same unit ID on both links, which is not yet verified on hardware.
- The menu-bar panel's button summary, pointer speed and Easy-Switch sections act on one mouse at a time and show its name in the section header: the mouse that last sent a button press, else the one selected in Settings → Mice, else the first connected.
- Verification. Multi-mouse behavior is covered by the simulator and by end-to-end tests with several simulated mice. Upright 1.0 was tested on hardware with a single MX Vertical; this multi-mouse build has not yet been run on real hardware. Two real mice at once is unverified.
- macOS 13 Ventura or later (Apple silicon or Intel)
- Xcode 15.3 or later to build (CI builds with Xcode 16.2)
- A Logitech mouse that speaks HID++ 2.0 with programmable buttons. The MX Vertical is the one verified on hardware; see Supported devices.
- Open
Upright.xcodeproj. - Select the Upright scheme and press ⌘R.
The project is set to Sign to Run Locally (ad-hoc signing), so you don't need an Apple Developer account or team. A mouse icon appears in the menu bar. On first launch the welcome window opens.
make test # UprightKit unit tests (also run on Linux)
make app # Release build, universal, ad-hoc signed with the hardened runtime
# → build/Build/Products/Release/Upright.app
make verify # codesign --verify on the built app, and print its flags
make dmg # …and package it as dist/Upright.dmg
make open # build and launch
make probe # talk to connected Logitech mice from the terminal (see below)
make snapshots # render the UI with simulated mice into media/snapshots/ (see Demo Mode)
make clean # remove build/, dist/ and UprightKit/.buildmake app wraps this command:
xcodebuild -project Upright.xcodeproj -scheme Upright -configuration Release \
-derivedDataPath build CODE_SIGN_IDENTITY="-" CODE_SIGNING_REQUIRED=NO \
ENABLE_HARDENED_RUNTIME=YES OTHER_CODE_SIGN_FLAGS="--options=runtime" \
ARCHS="arm64 x86_64" ONLY_ACTIVE_ARCH=NO buildThe app uses the hardened runtime and is not sandboxed (see Permissions).
.github/workflows/build.yml has three jobs:
- core-tests runs the UprightKit tests on Ubuntu 24.04 with Swift 6.0.
- app runs on
macos-15with Xcode 16.2. It runs the tests again on macOS (which also covers the IOKit layer), builds the app withmake app, checks the signature withmake verifyand packagesUpright-<version>.dmg(orUpright-<commit>.dmgoutside a release). The DMG is uploaded as a workflow artifact. - release runs only for tags that start with
v. It attaches the DMG to a GitHub release with generated notes.
For a tag such as v1.1.0, the app job fails unless the tag matches MARKETING_VERSION in
project.pbxproj.
By default the DMG is unsigned (ad-hoc). If the repository defines the secrets
MACOS_CERTIFICATE_P12 and MACOS_CERTIFICATE_PASSWORD, the job also signs the app and DMG
with a Developer ID certificate. If APPLE_ID, APPLE_TEAM_ID and APPLE_APP_PASSWORD are
set as well, it notarizes and staples the DMG. Without those secrets, none of this runs.
An ad-hoc DMG isn't notarized, so Gatekeeper blocks the first launch. Either right-click
Upright.app → Open → Open, or run:
xattr -dr com.apple.quarantine /Applications/Upright.app| Permission | Needed for | When |
|---|---|---|
| Accessibility | Posting the keyboard shortcuts, media keys and clicks your buttons are mapped to, and changing scrolling (it needs an event tap) | Whenever a mapping sends input or a scroll setting is on. Mission Control actions, Open App/URL, Run Shortcut, pointer speed, Easy-Switch and "Do Nothing" work without it. |
| Input Monitoring | Opening a mouse's HID++ interface | Only if macOS hides a mouse from Upright, which can happen over Bluetooth. Upright doesn't ask for it at launch. The Permissions pane and the menu-bar panel say so when it happens. |
-
Upright never reads keystrokes. It only opens Logitech HID interfaces: the vendor usage pages
0xFF00and0xFF43(receivers, cables and Bluetooth LE) and the mouse interface of Logitech mice, where Bluetooth mice carry HID++. Keyboards, Lightspeed receivers and other devices that Upright can't use are not opened, and it only posts the actions you assign. It never configures a mouse that doesn't answer as a supported pointer. -
Until Accessibility is granted, buttons with keyboard, media or click actions are not diverted, so they keep working as normal mouse buttons. The menu-bar panel and the Buttons pane show an Allow… button that opens the right System Settings page. The UI updates by itself once you flip the switch.
-
Rebuilds and updates. macOS ties the Accessibility grant to the app's code signature. An ad-hoc build gets a new signature every time you rebuild, and an updated or moved copy can look the same. System Settings may then show Upright switched on, while macOS refuses the new copy. Upright detects this (it was trusted before and isn't now) and offers a Reset Permission button in Settings → Permissions and in the welcome window. It removes the old entry with
tccutiland asks again. To do it by hand:tccutil reset Accessibility io.github.upright.Upright tccutil reset ListenEvent io.github.upright.Upright
-
The app is not sandboxed, because posting synthetic input and opening HID devices requires that. It doesn't phone home or collect anything. Run Shortcut starts
/usr/bin/shortcuts, and Open App/URL go through macOS.
Settings → Scrolling applies to mouse wheels only. Trackpads and Magic Mouse send continuous scroll events, and Upright never touches those.
- Reverse vertical and horizontal scrolling separately from the trackpad's Natural Scrolling.
- Speed multiplies the distance of each wheel step (0.5x to 3x).
- Smooth scrolling (experimental) turns each wheel step into a short eased glide.
- Only while a Logitech mouse is connected (on by default). macOS doesn't tell an event tap which mouse a scroll came from, so these settings are global to every mouse wheel, with no per-mouse or per-model setting. With this option on, Upright rewrites wheel events only while at least one supported Logitech mouse is connected, but it can't tell that mouse from another one: while one is connected, a third-party mouse wheel is changed too. With the option off, every mouse wheel is always changed.
This needs Accessibility. Without it, scrolling works as usual and the pane says why.
The core of Upright is unit-tested against simulated mice (see below). These steps verify the real hardware path, and were written for the MX Vertical, the one model verified on hardware. They work for any mouse; a model other than the MX Vertical is unverified, so a report of what you see is valuable. If something fails, the output from step 1 and the log from step 6 make a great bug report.
-
Probe from the terminal (no app needed).
make probe # or: swift run --package-path UprightKit upright-probe make probe ARGS=--all # also read interfaces Upright doesn't use (read-only) make probe ARGS=--divert # also divert every remappable button of every mouse and print events
You should see each receiver or cable interface, then, for every mouse, a verdict such as
✓ MX Vertical: Upright supports this devicewith each line prefixed by the mouse's label (for example[MX Master 3S · Bolt slot 2]), followed by the battery, firmware, serial number, pointer speed, Easy-Switch channels, the HID++ feature table and the control list. Remappable controls are marked with*, and controls that can report raw movement (needed for hold-and-move gestures) carry araw XYflag. With--divert, pressing a thumb button prints● Back Button (0x0053) pressed/releasedinstead of navigating, and Ctrl-C gives the buttons back. If nothing is found, click a mouse button to wake it. If macOS blocks access, allow your terminal app under Privacy & Security → Input Monitoring. -
Launch the app (
make openor ⌘R in Xcode). The menu-bar panel should show a row for each mouse with its connection (for example Unifying · Connected) and its battery level. -
Grant Accessibility when asked. The orange callout disappears.
-
Defaults. On a fresh install, nothing is remapped. In Safari or Finder the thumb buttons still go back and forward as mouse buttons 4 and 5, and the top button still cycles DPI.
-
Remap. Open Settings → Buttons, choose your mouse in Configure, set Top Button to Keyboard Shortcut… and press e.g. ⇧⌘5. Pressing the top button should now open the screenshot toolbar. Set a button to Default to give it back to the mouse. For the top button, that brings back DPI cycling.
-
Sleep and reconnects. Watch Settings → Mice → Activity while you:
- switch the mouse off and on (for a receiver, expect Slot n link lost, then link restored and Reapplied configuration),
- put the Mac to sleep and wake it (expect Mac woke up; reapplying button configuration),
- unplug and replug the receiver or cable.
After each one, remapped buttons should still work. Turn on Raw HID++ reports to see every packet.
-
Quit Upright. The buttons should immediately behave like stock mouse buttons again.
Something not working? See Troubleshooting. With several mice, repeat steps 4 to 7 for each one, and check that pressing a button on one mouse never triggers the other's mapping.
Demo Mode runs the real interface against simulated mice, so you can look around (or make screenshots) without a mouse, a receiver or any permission:
make app
build/Build/Products/Release/Upright.app/Contents/MacOS/Upright --demoIt simulates an MX Vertical on a Unifying receiver, an MX Master 3S on a Bolt receiver and a Lift
over Bluetooth. Every name, serial number and battery level is made up. It never opens IOKit,
installs event taps, asks for permissions or posts input, and it keeps its own preferences
(io.github.upright.Upright.demo, cleared at each start), so it never touches your real settings
and can run next to a normal Upright. Because the mice are simulated, this shows the interface, not
how any real model behaves.
--export-snapshots <dir> (which implies --demo) renders the interface offscreen into
<dir>, light and dark, then quits: one PNG per state plus a manifest.json with each image's
size and the rectangles of its named elements. make snapshots does this into media/snapshots/
(override the folder with SNAPSHOTS=<dir>). The pictures at the top of this page were made this
way.
UprightKit contains every protocol, mapping, scrolling and battery decision and runs on any
platform with Swift 5.9+:
swift test --package-path UprightKitThe tests cover HID++ framing and response/error matching, control-table and event decoding,
mapping persistence and migration (schema v1, v2 and v3), profiles, gestures, scroll transforms,
battery alerts, the device catalog, interface classification and the configuration planner.
They also drive the hub (HIDPPLink) and its per-mouse EndpointControllers end to end against
UprightKit/Sources/UprightKit/Simulation, a set of software mice and receivers that answer
HID++ the way published device dumps say real ones do (an MX Vertical, MX Master 3S, Lift, MX
Master 3, M720 Triathlon, MX Ergo, M185 family, a G502 Lightspeed, an MX Keys keyboard and more),
either directly or behind a Unifying or Bolt receiver slot. Those scenarios include receiver
slot discovery, several mice on one receiver and on several links, diversion, button events,
sleeping and waking, a mouse that shows up later, the 0x1D4B reconnect event, Mac wake,
transport removal mid-press, retry with backoff, restore on quit and a randomized
fleet test. Passing these tests says the logic is right for the simulated device; it doesn't
make a model verified on hardware.
Upright says "Not connected" or can't find the mouse.
Plug in the receiver or the USB cable, or pair over Bluetooth, then click a mouse button to
wake the mouse. Open Settings → Mice → Activity to see what Upright found. Use Reconnect
on the mouse's page, or Reconnect All Mice in the menu-bar panel, to make it look again.
Remember that the MX Vertical can't use a Bolt receiver, and that Lightspeed and 27 MHz
receivers are never opened. Run make probe ARGS=--all (see above): if the probe can't find the
mouse either, the problem is below Upright.
My mouse isn't in the list, or is under "Other Logitech devices".
Upright serves a mouse only if it answers HID++ 2.0 as a pointer and offers programmable
buttons (0x1B04). The reason is shown next to the device (for example "No programmable
buttons" or "Older HID++ 1.0 device"). Models in the catalog are
unverified on hardware apart from the MX Vertical, so a catalogued
mouse can still turn out not to work: use Copy Device Report and open an issue.
Two mice do the wrong thing, or one of them is ignored. Mappings belong to the model, so two mice of one model share a setup (see Using several mice). Check that the Configure picker in Settings → Buttons is on the model you mean, and that Remap this mouse is on in Settings → Mice. If the same mouse is connected two ways, Upright uses one connection and shows the other as "also on …".
Bluetooth: the mouse is paired but Upright never sees HID++. macOS has to expose the mouse's HID++ collection to apps, and it sometimes doesn't. Check Privacy & Security → Input Monitoring: if the Permissions pane shows Input Monitoring as needed, allow Upright there and choose Reconnect. If the probe also fails over Bluetooth, use the Unifying receiver or the USB cable instead.
Accessibility looks switched on, but buttons don't remap.
This is usually a stale grant after a rebuild, an update or moving the app. Open
Settings → Permissions and click Reset Permission, then allow Upright again. If the button
isn't there, run the tccutil reset Accessibility io.github.upright.Upright command above,
then relaunch Upright and allow it again. Also check that Remap buttons is on, and that
the frontmost app doesn't have a profile that overrides the button.
A Mission Control, App Exposé or Show Desktop action does nothing.
Upright starts these through macOS (open -a "Mission Control"). Try that command in Terminal.
If it works there, check Upright's Activity log. If not, assign a keyboard shortcut instead:
turn on the shortcut in System Settings → Keyboard → Keyboard Shortcuts → Mission Control and
record the same shortcut in Upright.
Open at Login doesn't stick, or Upright isn't there after logging in.
Upright must live in /Applications first. A copy run from build/, Downloads or a mounted
DMG breaks when it moves or is deleted. Move it to Applications, quit, launch it from there,
then turn Open at login on again. If macOS asks for approval, use Open Login Items… in the
General pane.
Scrolling settings have no effect. Scrolling needs Accessibility. The Scrolling pane says "Accessibility access needed" when it is missing, and "Paused until a Logitech mouse connects" when Only while a Logitech mouse is connected is on and no mouse is connected. These settings apply to every mouse wheel. Trackpad and Magic Mouse scrolling is never changed. If smooth scrolling feels wrong, turn it off; it is experimental.
Gestures move the cursor.
Your mouse didn't offer raw movement for that button (the probe doesn't mark it raw XY), so
Upright watches the cursor instead. Gestures still work, but the pointer moves while the
button is held. The Buttons pane shows a note when this applies. In this fallback Upright
watches the cursor, which isn't tied to any one mouse, so it can't attribute the movement to a
particular mouse.
Another app is fighting over the buttons. Logi Options+, Logitech Options, Solaar and LogiOps also divert buttons. Only one can win. Quit them (and remove them from your login items) while using Upright.
Collect diagnostics. Run make probe ARGS=--all and copy its output. Then open Settings →
Mice, use Copy Device Report for the mouse, and open its Activity log (turn on Raw HID++
reports if asked, reproduce the problem, and use Copy). Include them in a bug report (see
.github/ISSUE_TEMPLATE), together with the model, how many mice are connected and how.
┌──────────────────────────── Upright.app (SwiftUI, AppKit) ─────────────────────────────┐
│ MenuBarView · OnboardingView │
│ SettingsView (Buttons / Scrolling / Mice / General / Permissions / About) │
│ AppModel ── per-model mappings + per-app profiles (MappingStore), per-mouse │
│ preferences, remap switches, configuration planning, routing │
│ AppSettings ── scroll, battery and pointer-speed preferences │
│ DeviceManager ── opens interfaces from a LinkSource (IOKit, or simulated in Demo Mode),│
│ sleep/wake observers, one record per mouse, status and activity log │
│ SystemActionPerformer ── CGEvent keys, media keys, clicks, apps, URLs, Shortcuts │
│ FrontmostAppMonitor · ScrollController (event tap) · BatteryNotifier │
│ PermissionsMonitor · LoginItemController (SMAppService) · DemoMode · SnapshotExporter │
└────────────────────────────────────────────┬───────────────────────────────────────────┘
│ compiled into the app from
┌───────────────────────────── UprightKit (Swift package) ───────────────────────────────┐
│ Platform/ HIDDeviceMonitor, IOKitHIDTransport (IOHIDManager, macOS only) │
│ Device/ DeviceCatalog, ControlCatalog ── models, receivers, control names │
│ InterfaceClassifier ── which HID interfaces to open, and as what │
│ HIDPPLink ── one per opened interface: receiver slots or a direct mouse │
│ EndpointController ── keeps ONE mouse configured (probe, retry, reconnect) │
│ DeviceSession ── features, controls, diversion, events, device info │
│ DeviceRegistry ── every mouse, its role, same-unit dedupe │
│ ConfigurationPlanner ── what each mouse should be doing │
│ DeviceIdentity (ModelKey, DeviceID), DevicePreferences, ReceiverDirectory │
│ HIDPP/ HIDPPMessage, HIDPPClient (request/response matching, busy retry), │
│ HIDPPDevice (root, feature set, name, battery), ReprogControls (0x1B04), │
│ DeviceFeatures (firmware, DPI, Easy-Switch) │
│ Mapping/ ButtonAction, SystemAction, KeyCombo, ButtonMappings, MappingProfiles, │
│ MappingLibrary (schema v3, per model), MappingStore, GestureSet, │
│ GestureRecognizer, RemapEngine (events carry their source mouse) │
│ Scrolling/ ScrollSettings, ScrollTransform, SmoothScrollAnimator │
│ Battery/ BatteryAlertPolicy │
│ Simulation/ SimulatedReceiver, SimulatedDirect, DeviceProfiles (software mice) │
└────────────────────────────────────────────────────────────────────────────────────────┘
How a button press gets remapped:
HIDDeviceMonitormatches Logitech interfaces on usage page0xFF00or0xFF43, plus Logitech mice's own mouse interface (needed over Bluetooth).InterfaceClassifierdecides what each one is: a receiver (Unifying, Bolt or Nano, with its pairing layout), a directly connected mouse (cable or Bluetooth), or something never opened (a keyboard, a Lightspeed or 27 MHz receiver, an unknown device).DeviceManagerruns oneHIDPPLinkper opened interface. For a receiver, the link enables wireless notifications (HID++ 1.0 register0x00), reads the pairing list (register0xB5, in the Unifying or Bolt layout) when it can, asks for announcements or pings slots withRoot.getProtocolVersionwhen it can't, and starts oneEndpointControllerper paired device that isn't a keyboard or another non-pointer. A direct connection is one endpoint at device index0xFF.- Each endpoint identifies its device (name and kind, and the model and unit IDs from
DEVICE_FW_VERSION,0x0003), resolves feature indices, and reads theREPROG_CONTROLS_V4control table. A device with no HID++ 2.0, no pointer kind or no configurable control ends in a terminal "unsupported" state and is never written to.DeviceCatalogturns the IDs into aModelKey, which is the same on every connection. ConfigurationPlannerdecides what each endpoint should do from the model's effective mappings for the frontmost app, the remap switches, the Accessibility state and the mouse's own pointer speed.AppModelre-evaluates it when any of those change. The endpoint callssetCidReporting(cid, divert)for every button the plan needs.- The mouse now sends
divertedButtonsEventreports (the list of CIDs currently held) instead of acting on those buttons.ButtonStateTrackerturns them into press and release events that carry the mouse they came from. RemapEnginelooks up the action in that mouse's model and asksSystemActionPerformerto post it, or hands a pointer-speed or Easy-Switch action back toDeviceManager. Keys and clicks follow the physical button (down on press, up on release). One-shot actions fire on press. A button mapped to gestures also reports raw movement, whichGestureRecognizerturns into a direction (or a tap, if the button is released first). Held buttons and gesture state are kept per mouse, so two mice never share them.
Staying configured. Temporary diversion is lost when a mouse power-cycles, so each endpoint reapplies it (and the kept pointer speed) when:
- the receiver reports a link-established notification (
0x41) for the mouse's slot, - the mouse sends a
WIRELESS_DEVICE_STATUS(0x1D4B) reconnection event, - macOS posts a wake, screens-wake or session-active notification,
- IOKit re-matches a replugged receiver or cable,
- a mouse that was asleep starts sending reports again.
Signals that arrive close together are coalesced into one pass. If a mouse stops answering, its endpoint keeps the session, shows Asleep and retries with exponential backoff (1 s, doubling to 30 s), independently of the other mice. A receiver slot whose mouse is away for good (off, or switched to another computer) backs off to about 5 minutes once the receiver sends link notifications, which announce the mouse when it returns. Controller work runs on the main actor through a serial operation queue per endpoint, so probes and reconfigurations of one mouse never interleave. Requests to one HID interface go through a gate that allows one in flight (with a lane for interactive changes), because it is unverified whether receivers tolerate more.
Why the app compiles UprightKit's sources directly. The Xcode project references the files
in UprightKit/Sources/UprightKit instead of linking the package. That keeps the project
buildable on any Xcode 15.3+ without package resolution. Meanwhile swift test exercises exactly
the same code, on Linux too. New Kit files must therefore be added to project.pbxproj; see
CONTRIBUTING.md.
See docs/HIDPP.md for the exact messages Upright sends.
- Mouse support is by capability, and verified for one model. Upright serves Logitech HID++ 2.0 mice with programmable buttons. Only the MX Vertical is verified on real hardware; every other model, every other receiver type than Unifying and anything involving two physical mice is verified in the simulator only. See Supported devices and docs/DEVICES.md.
- Mappings are per model, not per physical mouse. Two mice of one model share a button setup and per-app profiles. Pointer speed, nickname and the remap switch are per mouse.
- Scroll settings are global. macOS scroll events carry no source, so reverse, speed and smooth scrolling apply to every mouse wheel, not just to a Logitech one. See Scrolling.
- Cursor-fallback gestures. A gesture button that can't report raw movement is handled by watching the cursor. The pointer moves during the gesture, and the movement can't be tied to one mouse.
- Pointer speed only through
ADJUSTABLE_DPI(0x2201). A mouse without that feature has no pointer-speed controls in Upright, and pointer-speed actions are hidden for it. Upright doesn't use other speed features. - No pairing, no firmware updates. Pointer speed is the only mouse setting Upright changes besides button diversion. Many mice forget a speed set this way when they reconnect, so Upright re-applies the speed it keeps each time the mouse connects. Setting a DPI button to Default keeps its built-in DPI cycling.
- Scroll features act on macOS scroll events. Upright doesn't change the wheel's own hardware settings (SmartShift, ratchet or hi-res mode).
- Not served: Lightspeed (G and PRO) and 27 MHz receivers, keyboards and other non-mouse devices, and HID++ 1.0 devices. They are never opened or written to.
- No kernel extension, no driver, no App Store build.
- Bluetooth depends on macOS exposing a mouse's vendor HID collection. If the probe can't reach the mouse over Bluetooth, use a receiver or cable.
- Another program that diverts the same buttons (Logi Options+, Solaar, LogiOps) can fight with Upright. Upright doesn't detect them.
Upright was written from public information only. No Logitech software, driver or binary was disassembled, decompiled or otherwise reverse-engineered, and none is included.
- Logitech's HID++ 2.0 documentation drafts (published by Logitech and mirrored by the
projects below) for the report format, the root and feature-set features, and
REPROG_CONTROLS_V4. - Solaar (GPL-2.0) for its descriptions of receiver registers and notifications, the device product IDs, the firmware and DPI feature layouts, and the CID names in its special-keys table.
- LogiOps (GPL-3.0) for its documentation of
diverting MX Vertical buttons (including the top button's CID
0xFD) throughREPROG_CONTROLS_V4. - libratbag (MIT) for its HID++ 1.0/2.0 documentation and device data.
- Linux
hid-logitech-hidppandhid-logitech-dj(GPL-2.0) for report IDs and receiver behavior. - logitech-mx-vertical-mapper and similar open projects for showing the approach works on macOS.
No code was copied from the GPL projects. They were used as protocol references only.
See CONTRIBUTING.md and the changelog.
MIT. Logitech, MX, Unifying, Bolt and Logi Options+ are trademarks of Logitech. Upright is an independent project and is not affiliated with or endorsed by Logitech.

