A native macOS menu bar app to control Razer mice: battery, DPI, polling rate, RGB lighting, brightness, and software button remapping. Razer's Synapse does not support macOS, so this app talks to the mouse directly over USB HID. Four models have been verified on real hardware: the Razer Cobra HyperSpeed (the development mouse), the Razer Atheris, the Razer Basilisk V3 X HyperSpeed and the Razer Viper Ultimate. By design it detects and tries to control any Razer mouse in the same protocol family; the registry carries twelve product ids, and README's table says which of them anyone has actually run the probes against.
This document explains how the app is built and how every feature works, so a future Claude session or a human can pick it up cold. See also:
BRIEF.md, the original research/planning brief and device facts.../CHANGELOG.md, feature list / history.../README.md, quick status + build commands.
The mouse speaks Razer's proprietary HID protocol. We did not reverse-engineer it from scratch, we ported the protocol from OpenRazer's Linux driver (the Cobra Pro command set, which the Cobra HyperSpeed reuses). All command bytes were read from the OpenRazer C source and reimplemented in Swift over Apple's IOKit HID Manager. No kernel extension is required: Razer mice respond to standard USB HID feature reports that any HID-capable userspace process can send.
Razer mouse <-- HID feature reports (90-byte razer_report) --> HIDDevice (IOKit)
|
RazerCommands (builds the command bytes)
|
MouseController (poll loop, battery, state)
|
SwiftUI popover / menu bar (NSStatusItem)
The app is a SwiftPM executable (not an Xcode project). With no arguments it launches the menu bar app; with a subcommand it runs a CLI diagnostic (see §9).
Requires macOS 14+ (Apple Silicon), Xcode 16 / Swift 6.1.
# Develop (inherits your Terminal's permission grants, easiest loop):
swift run MacRazer
# Build a standalone .app:
./Scripts/setup-signing.sh # one-time: stable self-signed identity (see §7)
./Scripts/build-app.sh # to "MacRazer.app"
open "MacRazer.app"Permissions (see §7): the app needs Input Monitoring (to send/receive HID reports) and, for button remapping, Accessibility (for the event tap). Both are requested in-app.
Every command is a fixed 90-byte structure sent as a HID feature report, report id 0.
Ported in RazerReport.swift:
| Offset | Field | Notes |
|---|---|---|
| 0 | status | response: 0x02 success, 0x01 busy, 0x03 failure, 0x04 timeout |
| 1 | transaction_id | 0x1f for all Cobra Pro/HyperSpeed commands |
| 2-3 | remaining_packets | big-endian, usually 0 |
| 4 | protocol_type | 0 |
| 5 | data_size | size of the arguments used |
| 6 | command_class | |
| 7 | command_id | direction bit: get = 0x80|id |
| 8-87 | arguments[80] | |
| 88 | crc | XOR of bytes 2..87 |
| 89 | reserved | 0 |
HIDDevice.send() sends the request (SetReport), waits, then reads the response (GetReport). The wait is
31 ms (RAZER_NEW_MOUSE_RECEIVER_WAIT_US in OpenRazer). Too short a wait returns status
0x01 (BUSY) with empty arguments. On BUSY we re-read a few times.
Built in RazerCommands.swift. VID 0x1532.
| Operation | class / id | data_size | Notes |
|---|---|---|---|
| Get battery | 0x07 / 0x80 |
0x02 |
response args[1] is 0-255; * 100 / 255 gives the %. |
| Get charging | 0x07 / 0x84 |
0x02 |
response args[1] != 0 = charging. |
| Get / Set DPI | 0x04 / 0x85 / 0x05 |
0x07 |
VARSTORE + big-endian x/y; clamp 100-45000 (arbitrary DPI). |
| Get / Set poll | 0x00 / 0x85 / 0x05 |
0x01 |
1000->0x01, 500->0x02, 125->0x08 (basic set only). |
| Lighting effect | 0x0F / 0x02 |
varies | extended-matrix on ZERO_LED (0x00); effect ids: none 0x00, static 0x01, spectrum 0x03, wave 0x04. |
| Get / Set brightness | 0x0F / 0x84 / 0x04 |
0x03 |
value 0-255. LED group is per model (RazerDevices.brightnessLed), never ZERO_LED — see quirk below. |
- Battery works over the 2.4 GHz dongle. The OpenRazer PR author thought it didn't; the real fix was the 31 ms wait + targeting the correct interface.
- Brightness lives on a different LED group than effects, and which one is per model.
Colors/effects are driven on ZERO_LED for every model so far, but brightness is not: the
Cobra family answers only on
LOGO_LED(0x04), while the Basilisk V3 X HyperSpeed — whose only lit zone is the scroll wheel — answers only onSCROLL_LED(0x01). Every other group returns status0x03(failure), so a wrong id is not a silent no-op: the brightness slider simply stops working. The id is a registry field (RazerDevices.brightnessLed, defaultLOGO_LED);swift run MacRazer brightnesssweeps all four groups to find it on a new model. Both verified live. - Lighting is one group. The "4 zones" in marketing aren't independently addressable; everything is driven together via ZERO_LED.
- Transient garbage on reconnect. Right after a USB reconnect the battery can read 0x00 (0%) or 0xFF (100%) before settling. The controller distrusts these (see §5.1).
- Two product IDs:
0x00DA(wired) and0x00DB(wireless dongle).
matchingDevices(vendorId:)enumerates all HID interfaces for a vendor without opening the manager (opening the manager grabs the keyboard/mouse interfaces and yieldskIOReturnNotOpenon SetReport, a bug we hit and fixed).open(vendorId:)picks the control interface by score: it must carry a 90-byte feature report (MaxFeatureReportSize >= 90); we prefer a vendor usage page and the Mouse usage (0x01/0x02) so we don't grab a connected Razer keyboard. Exposes the device'sproductIDandproductName(from the USB product string).send()/sendWithRetry()implement the request/response with the 31 ms wait, BUSY re-reads, and retry/backoff for the finicky wireless link.
IOKit service notifications (IOServiceAddMatchingNotification, matched + terminated)
for instant USB plug/unplug detection. It does not open the device, so it can't
interfere with the control-interface open. Fires onAppear / onRemove callbacks on the
main queue. (Polling, §5, is the fallback for the wireless-sleep case where the dongle stays
plugged in.)
Over Bluetooth the mouse is a plain HID pointer (feature report size 1, no control
interface). Razer's control protocol lives in a separate vendor GATT service
(52401523-F97C-7F90-0E7F-6C6F4E36DB1C), reverse-engineered by @ungrav in #32 and confirmed
on the Cobra HyperSpeed (Bluetooth PID 0x00DC, vendor 0x068E).
- Framing. A request is a header
[id, payloadLength, 0, 0, class, command, arg, arg]written to…1524, then the payload in 20-byte writes. The reply arrives as notifications on…1525: a header[id, length, 0, 0, 0, 0, 0, status](status02ok,03failure,05not supported) followed bylengthpayload bytes. The mouse also sends an unsolicited01 … 03frame when notifications are enabled, which the assembler skips. - Commands (verified on the Cobra HyperSpeed): battery
05 81 00 01(0-255, same scale as USB), serial01 83 00 00(the same serial as over the dongle), DPI stages get/set0B 84 01 00/0B 04 01 00, brightness get/set10 85 01 <led>/10 05 01 <led>(LOGO04on the Cobra, like USB), static colour10 04 00 00. - Stage table.
[activeID, count]then per stage[id, x_lo, x_hi, y_lo, y_hi, 0, 0], ids from 1, little-endian. Replies drop the last reserved byte. There is no direct "set DPI", so selecting a DPI rewrites the table with a new active stage, then reads it back. BLEProtocoltranslatesRazerReportrequests and replies, soMouseControllerand theRazerCommandsparsers are shared with USB. Commands without a BLE equivalent (polling rate, effects other than static) thrownotSupported, and the popover hides them.- Link choice (
MouseController.openTransport): a cable wins. An idle dongle loses to a supported mouse on Bluetooth, because the mouse is on one wireless link at a time and the dongle would only time out. Detection runs on IOHID (HIDDevice.bluetoothRazerMouse), so CoreBluetooth and its permission prompt only appear for someone who has such a mouse.
The orchestrator and single source of UI truth (an ObservableObject). All blocking HID IO
runs on a serial io queue; @Published state is updated on the main queue via publish.
Published state: connected, batteryPercent, charging, dpi, pollRate, brightness,
timeEstimate, statusText, deviceName, deviceSupported, deviceHasBattery,
isRefreshing, showPercentInMenuBar.
- Adaptive self-rescheduling poll: every 15 s when connected, 4 s when offline / not yet ready, so disconnects show within ~15 s and reconnects within ~4 s, all without a manual refresh.
- Instant plug/unplug via
HIDMonitortoforceCheck()(USB removal is definitive, so it marks offline immediately, bypassing the debounce). - 2-failure debounce: a single transient wireless timeout won't flap the UI to "offline".
- Reconnect sanity checks (fixes the 0%/100% jumps): a raw
0reading is treated as "not ready" (keep last value, re-poll fast); and the first post-reconnect reading is rejected if it jumps >20% from the last trusted value (transient garbage) until confirmed. - Battery-less mice: if
deviceHasBatteryis false (registry), a successful DPI read is the alive-check instead of a battery read, and the battery UI is hidden.
On a connection-state transition (after the first baseline poll), plays a system sound:
Pop on connect, Submarine on disconnect (NSSound, names are constants).
setDPI, setPollRate, setBrightness, setStatic/Spectrum/Wave/LightingOff, each
dispatches a command on the io queue and re-publishes the new value optimistically. While
the popover is open, setPopoverVisible(true) polls DPI/poll every 2 s so on-mouse changes
(e.g. the DPI-cycle button) reflect live.
- Logs
(timestamp, %)samples to~/Library/Application Support/MacRazer/battery-history.json. - Computes a discharge rate by linear least-squares fit over samples (needs >=5 samples,
=30 min span, >=3% measured drop before it's trusted, otherwise the coarse reading is noise and the estimate drifts).
- Persists a learned discharge rate (
learnedDischargeRatein UserDefaults), blended (EMA) across sessions and charge cycles, so after a restart/recharge the estimate is available immediately instead of re-deriving for ~3 hours. - The estimate is always labelled "(est.)".
Maps PID to { name, fullySupported, hasBattery }. The connected device's name comes from
its own USB product string (works for any Razer mouse); the registry only adds the
"controls verified" flag (Cobra family) and the battery flag. Unknown mice show their name +
"limited support". This is the extension point for universal support (see §8).
NSStatusItemwith a custom-drawn outlined mouse icon (vector, drawn inMenuBarIcon.swift; a template image that adapts to light/dark and dims to 60% opacity when disconnected) + the battery % text (hideable via a setting; hidden entirely for battery-less mice).- Left-click opens the popover; right-click / control-click shows an
NSMenu(status line, Open Controls, Refresh Now, Configure Buttons..., Input Monitoring Settings, Quit). Items that need a connected mouse are disabled when offline. - The popover is dark-appearance forced (
.darkAqua) so the Razer green pops, pre-warmed at launch so it opens instantly, andanimates = false.
A fixed 320*620 navigable container with three pages and a push/pop slide animation; each
page is a ScrollView (so long content scrolls without resizing the popover):
- Main page, Control-Center-style frosted cards, each section its own tile:
- Header card: Razer logo (bright green) in a tinted tile + detected device name + connection state + status dot. Shows "No mouse connected" when none.
- Battery card: a custom proportional battery gauge (fill tracks the exact %, colored green >=40% / orange <40% / red <15%), the big % number, a state-colored level bar, the time estimate, and a spinner-animated refresh button. Dims when offline.
- DPI card: green slider (100-26000) + preset chips (400/800/1600/3200/6400) + a persisted custom chip (drag the slider to any value to save it; green-outlined).
- Polling card: segmented 125/500/1000 Hz.
- Lighting card: brightness slider (☀︎) + effect segmented (Static/Spectrum/Wave/Off)
- colour swatches (true red...pink) + a rainbow "custom" well that opens the colour wheel.
- Configure Buttons card-button to buttons page.
- Settings card: a switch toggle for "Show battery % in menu bar".
- Live mouse-config sections grey out + disable when disconnected (battery stays readable; refresh stays active).
- Colour page,
ColorPickerPage.swift: an inline hue/saturation colour wheel + brightness slider + live preview, applied to the mouse live (throttled). Replaces the old systemNSColorPanel. - Buttons page,
RemapView.swiftembedded (see §6.3).
Software remapping via a CGEvent tap (the onboard-remap protocol isn't in OpenRazer and
Razer's EULA forbids reverse-engineering it, see CHANGELOG / chat). The tap watches
otherMouseDown/Up; for a mapped button it suppresses the original event and posts the
mapped action (events the app itself posts are tagged and skipped to avoid loops). Only the
side buttons (Back/Forward = buttons 4/5) emit OS-level events and are remappable; DPI/profile
buttons are handled onboard and never reach macOS.
- Actions: passthrough, keystroke (preset shortcuts or a custom recorder that captures any combo), mouse (middle/double click), media (play/next/prev/volume/mute).
- Mappings persist to UserDefaults. Needs Accessibility permission (banner + Open Settings / Re-check in the UI).
- Available both inline (popover buttons page) and as a standalone window
(
RemapWindowController.swift, opened from the right-click menu).
| Permission | Why | How |
|---|---|---|
| Input Monitoring | Razer mice enumerate as keyboard/mouse HID; macOS gates opening them. | Requested at launch via IOHIDRequestAccess(kIOHIDRequestTypeListenEvent); in-app banner + settings link. |
| Accessibility | The CGEvent tap for button remapping. |
AXIsProcessTrustedWithOptions; banner + Open-Settings/Re-check in the remap view. |
Signing matters for permissions. TCC binds a grant to the app's code identity. The build is ad-hoc signed by default, so every rebuild changes the identity and breaks the grant (the toggle looks on but doesn't apply). Fixes:
Scripts/setup-signing.shcreates a stable self-signed identity ("Razer Cobra Self-Signed");build-app.shauto-uses it. Grant persists across rebuilds.- Or just develop with
swift run MacRazer, which inherits the Terminal's grants. tccutil reset ListenEvent com.macrazer.menubarclears a stale Input Monitoring grant.
For distribution to other users: every user still grants Input Monitoring once (unavoidable macOS security). Self-signed = works but Gatekeeper warns ("unidentified developer", one-time right-click->Open). A clean install needs Developer ID + notarization (paid Apple account).
Detection + name display already work for any Razer mouse (read-only, via the USB product string). To make the controls verified for another model:
- Add its PID/name to
RazerDevices.knownwithfullySupported: trueand the righthasBattery/hasLighting/maxDPI. If its brightness answers on a group other thanLOGO_LED, setbrightnessLedtoo. - Confirm its command dialect matches the Cobra Pro set (transaction id, command variants,
max DPI, poll rates, LED layout, brightness LED). Port any per-device specifics from
OpenRazer's
razermouse_driver.cswitch statements /daemon/.../mouse.pyMETHODS. - Test each control on hardware (use the CLI diagnostics, §9).
If a model uses a different dialect, generalize RazerCommands / add a per-device command
table keyed by PID.
Run from a terminal (uses the Terminal's permission grant). These were how each feature was verified against hardware:
swift run MacRazer info # list HID interfaces (find the control one)
swift run MacRazer battery # read battery %
swift run MacRazer dpi [x] [y] # read / set DPI
swift run MacRazer poll [125|500|1000]
swift run MacRazer rgb static ff0000 # or: spectrum | wave | off
swift run MacRazer brightness [0-100] # sweeps LOGO/SCROLL/ZERO/BACKLIGHT LEDs
swift run MacRazer icon out.png # render the menu bar icon
swift run MacRazer render-ui [offline|color|update|updated|whatsnew|…] out.png # popover (dev)
swift run MacRazer render-ui whatsnew installed out.png # the notes without an update to install
swift run MacRazer render-about [notes] out.png
swift run MacRazer render-remap out.png(The render-* commands use SwiftUI ImageRenderer, which can't rasterize native controls —
buttons and sliders show as placeholders. It can't rasterize a ScrollView either, so
render-ui whatsnew, whose page is a scroll view, goes through an NSHostingView instead;
that path draws the content but needs an explicit size, since there is no window to supply one.)
| File | Responsibility |
|---|---|
main.swift |
Entry point: no args to menu bar app; subcommands to CLI diagnostics. |
AppDelegate.swift |
NSStatusItem, popover, right-click menu, HIDMonitor wiring, permission request. |
MouseController.swift |
Orchestrator: poll loop, connection logic, writes, battery, published state. |
HIDDevice.swift |
IOKit HID open/enumerate + request/response send. |
HIDMonitor.swift |
IOKit service notifications for plug/unplug. |
RazerTransport.swift |
Transport protocol shared by USB and Bluetooth, plus the shared retry ladder. |
BLEProtocol.swift |
Bluetooth LE framing and RazerReport translation. |
BluetoothDevice.swift |
CoreBluetooth connection to Razer's vendor GATT service. |
RazerReport.swift |
90-byte razer_report struct + CRC. |
RazerCommands.swift |
Command-byte builders (battery/DPI/poll/RGB/brightness) + Razer constants. |
RazerDevices.swift |
PID to {name, supported, hasBattery} registry. |
BatteryHistory.swift |
Sample log + learned discharge rate + time estimate. |
PopoverView.swift |
Main popover UI + page navigation. |
ColorPickerPage.swift |
Inline hue/sat colour wheel page + shared BackButton. |
ButtonRemapper.swift |
CGEvent tap, action model, presets, persistence, Accessibility. |
RemapView.swift |
Button-config UI (inline + window), key recorder. |
RemapWindowController.swift |
Standalone window host for the remap UI. |
MenuBarIcon.swift |
Vector-drawn menu bar mouse icon (+ triskelion). |
RazerLogo.swift |
Embedded official Razer logo (vector PDF, base64) for the header. |
External: reference/openrazer/ (cloned driver source, the protocol reference, gitignored)
and reference/openrazer-pr-2583.diff (the Cobra HyperSpeed PR).