This document contains the technical details a developer needs to navigate Mouser. The user-facing tour lives in README.md; this guide covers how the codebase is wired together, how the platform-specific hooks behave, and how to build / debug locally.
- Development setup
- Architecture
- Entry point:
main_qml.py - How it works
- UI overview
- Project structure
- CLI flags & debug overrides
- Build internals
- Desktop shortcut (Windows)
- Debugging tips
Install the project dependencies before running the app or test suite. Do not rely on the system Python unless it already has the requirements installed.
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txtOn macOS, requirements.txt installs PyObjC (objc, Quartz, and AppKit bindings), which Mouser needs for CGEventTap, app detection, and key simulation.
Run the test suite from the activated environment:
python -m unittest discover -s testsmain_qml.py is the primary launch script for Mouser, bringing together the core processing logic (Engine) and the graphical user interface (QML Backend). It replaces an older tkinter-based interface.
- Environment Setup: Defines absolute paths to handle both dev environments and frozen PyInstaller executables (
.appbundles on macOS,_internalon Windows). - App Initialization: Creates the
QApplicationand configures the Qt Material theme. - Engine Bootstrapping: Initializes the core HID (Human Interface Device) engine and the UI backend.
- QML Loading: Registers context properties and image providers, then loads
Main.qml. - System Integration: Sets up the OS system tray / menu-bar icon, checks macOS accessibility permissions, syncs login-item state, and binds system-wide dark/light mode states.
main(): The main entry point. Orchestrates the startup sequence, initializes theEngineandBackend, loads the QML files, exposes Python objects to QML, creates the system tray, and starts the Qt event loop (app.exec()).UiState(QObject): A bridge class that tracks the OS's system appearance (Dark vs. Light mode) and exposes it to the QML frontend via Qt Properties and Signals._check_accessibility(): A macOS-specific function that checks (and prompts) the user for Accessibility Permissions. This is crucial for intercepting or simulating mouse/keyboard events on Mac.core/accessibility.py: Centralizes the native macOS trust check used by both startup and backend-exposed state.core/startup.py: Owns login startup integration on both Windows and macOS, including the per-user macOS LaunchAgent used by the Start at login UI toggle.AppIconProvider&SystemIconProvider: Subclasses ofQQuickImageProvider. QML uses these to request images dynamically (e.g., rendering SVGs cleanly at various DPIs or reading native file icons viaQFileIconProvider)._app_icon(),_tray_icon(), &_render_svg_pixmap(): Utility functions that construct high-resolution (QIcon/QPixmap) icons for the taskbar and the system tray, handling platform differences.
- Configuration Flow: Command-line args are parsed (
_parse_cli_args) to configure hardware specifics like--hid-backendand startup behavior such as--start-hidden. - Setup Flow: The
Engine()(core logic) andBackend()(QML interface) are instantiated. - QML Binding: Instances of the
BackendandUiStateare injected directly into the QML engine's root context. This allows the QML JavaScript/UI layer to read application state and invoke methods on the Python objects. - Execution Flow:
qml_engine.load(...)parses and rendersMain.qml.- A deferral (
QTimer.singleShot(0, ...)) is queued to start theEngineasynchronously. - If
--start-hiddenis present, the window is kept hidden and Mouser starts as a tray / menu-bar app first. - Execution hands over to
app.exec(), blocking the main thread to run the Qt UI event loop. engine.stop()gracefully shuts down background threads when the Qt event loop terminates.
- PyInstaller Pathing (
getattr(sys, "frozen", ...)): Handles the different execution environments. Running viapython main_qml.pyuses local paths, but running a compiled PyInstaller build uses paths nested in the macOS.app/Contents/Resourcesor Windows_internalfolders. - Deferred Engine Start: The core
engine.start()is wrapped inQTimer.singleShot(0, ...). This ensures the graphical window renders and appears BEFORE the potentially blocking process of binding to HID devices occurs. - Hardcoded PySide6 Plugin Paths:
QML2_IMPORT_PATHandQT_PLUGIN_PATHare manually set viaos.environto work around PyInstaller/PySide6 edge cases where the QML engine fails to locate basic QML modules when bundled. - LaunchAgent Wiring: macOS autostart is implemented as a per-user LaunchAgent that launches either the frozen app executable or the current interpreter plus
main_qml.py, so the same UI toggle works in packaged and source-based workflows. - Centralized Accessibility Check: The backend and startup path share the same native trust check from
core/accessibility.py, avoiding drift between the permission banner and the live settings state. - macOS System Tray Contrast: The system tray icon provides two different SVGs (black and white) marked as
NormalandSelected. This macOS-specific trick ensures the menu bar icon automatically inverts color appropriately when the user selects it or toggles dark/light mode. - macOS Debugging (
SIGUSR1): A custom signal handlersignal.signal(signal.SIGUSR1, _dump_threads)is registered, providing developers a hidden way to dump all thread stack traces directly to the terminal viakill -SIGUSR1 <pid>. This is highly useful for debugging cross-thread freezing bugs without a debugger attached. - Startup Benchmarks: Explicit timing logic (
_t0,_t1, ...,_t8) is used to profile startup times. Because importing heavy UI frameworks like Qt in Python can be slow, this enforces performance budgets.
graph LR
Mouse["Logitech Mouse / HID++ Device"]
Hook["Mouse Hook"]
Engine["Engine (Orchestrator)"]
Simulator["Key Simulator (SendInput / CGEvent / uinput)"]
Backend["Backend (QObject)"]
UI["QML UI (PySide6)"]
Detector["App Detector"]
Mouse --> Hook
Hook --> Engine
Engine -- "block/pass" --> Hook
Engine --> Simulator
Engine <--> Backend
Backend <--> UI
Detector --> Backend
The arrows match the runtime call graph: the OS-level mouse hook feeds events into the Engine, which decides whether to suppress and rewrite them (firing Key Simulator) or pass them through. Connection state and device identity flow back through Backend and into QML so the UI stays in sync.
Mouser exposes a single MouseHook façade in core/mouse_hook.py and dispatches to a per-platform implementation:
- Windows —
core/mouse_hook_windows.py:SetWindowsHookExWwithWH_MOUSE_LLon a dedicated background thread, plus Raw Input for extra mouse data. - macOS —
core/mouse_hook_macos.py:CGEventTapfor interception and Quartz events for key simulation. The callback is wrapped with@_autoreleasedto recycle Foundation objects every event (closing a ~1.4 GB leak that appeared under load) and the tap auto re-enables itself when the system disables it on timeout. - Linux —
core/mouse_hook_linux.py:evdevto grab the physical mouse anduinputto forward pass-through events through a virtual device. - Stub —
core/mouse_hook_stub.py: inert hook for unsupported platforms / smoke tests.
The shared base + types live in core/mouse_hook_base.py, core/mouse_hook_contract.py, and core/mouse_hook_types.py.
All paths feed the same internal event model and intercept:
WM_XBUTTONDOWN/UP— side buttons (back / forward)WM_MBUTTONDOWN/UP— middle clickWM_MOUSEHWHEEL— horizontal scrollWM_MOUSEWHEEL— vertical scroll (for inversion)
Intercepted events are either blocked (hook returns 1) and replaced with an action, or passed through to the foreground application. Synthetic events Mouser injects itself are tagged so the hook ignores them on the way back in (Windows uses an event marker; macOS uses kCGEventSourceUserData).
core/logi_device_catalog.pyholds Mouser's curated per-device Logitech specs, image assets, and hotspot coordinates for dedicated control surfaces.core/logi_devices.pyresolves known product IDs and model aliases into aConnectedDeviceInforecord with display name, DPI range, preferred gesture CIDs, supported buttons, and default UI layout key.core/device_layouts.pystores built-in family layouts plus catalog layouts, layout notes, and whether a layout is interactive or only a generic fallback._FAMILY_FALLBACKSmaps per-model keys to family layout keys until a dedicated overlay exists.ui/backend.pycombines auto-detected device info with any persisted per-device layout override and exposes the effective layout to QML.
Logitech gesture / thumb buttons do not always appear as standard mouse events. Mouser uses a layered detector inside core/hid_gesture.py:
- HID++ 2.0 (primary) — opens the Logitech HID collection, discovers
REPROG_CONTROLS_V4(feature0x1B04), ranks gesture CID candidates from the device registry plus control-capability heuristics, and diverts the best candidate. When supported, RawXY movement data is also enabled. - Raw Input (Windows fallback) — registers for raw mouse input and detects extra button bits beyond the standard 5.
- Gesture tap / swipe dispatch — a clean press/release emits
gesture_click; once movement crosses the configured threshold, Mouser emits directional swipe actions instead.
The same module owns the SmartShift integration. It prefers the enhanced feature 0x2111 (FEAT_SMART_SHIFT_ENHANCED) when available and falls back to 0x2110, exposing both an enable flag and a sensitivity threshold; pending settings are re-applied on every reconnect (including wake-from-sleep).
core/app_detector.py polls the foreground window every 300ms.
- Windows:
GetForegroundWindow→GetWindowThreadProcessId→ process name. UWP apps are resolved viaApplicationFrameHost.exeto the actual child process. - macOS:
NSWorkspace.frontmostApplication. - Linux:
xdotool(X11) andkdotool(KDE Wayland). Other Wayland compositors fall back to the default profile.
core/engine.py is the orchestrator. On app change, it performs a lightweight profile switch — clears and re-wires hook callbacks without tearing down the hook thread or HID++ connection. This avoids the latency and instability of a full hook restart. The engine also forwards connected-device identity to the backend so QML can render the right model name and layout state, and routes mouse-injection actions (mouse_left_click, mouse_right_click, …) through inject_mouse_down / inject_mouse_up.
Mouser handles mouse power-off / on cycles automatically:
- HID++ layer —
HidGestureListenerdetects device disconnection (read errors) and enters a reconnect loop, retrying every 2–5 seconds until the device returns. Pending SmartShift / scroll-mode settings are replayed on reconnect. - Hook layer —
MouseHooklistens forWM_DEVICECHANGE(Windows) and platform equivalents elsewhere, reinstalling the low-level hook when devices are added or removed. - UI layer — connection state and device identity flow from HID++ → MouseHook → Engine → Backend (cross-thread safe via Qt signals) → QML, updating the status badge, device name, and active layout in real time.
All settings live in config.json under the platform config dir (%APPDATA%\Mouser, ~/Library/Application Support/Mouser, ~/.config/Mouser). The schema supports:
- Multiple named profiles with per-profile button mappings, including gesture tap + swipe actions
- Per-profile app associations (list of
.exe/ bundle / process names) - Global settings: DPI, scroll inversion, macOS trackpad filtering, gesture tuning, appearance, debug flags, Smart Shift mode + sensitivity, language, and startup preferences (
start_at_login,start_minimized) - Per-device layout override selections for unsupported devices
- Automatic migration from older config versions (current version
9)
Logs are written via core/log_setup.py to a 5 × 5 MB rotating file in ~/Library/Logs/Mouser, %APPDATA%\Mouser\logs, or $XDG_STATE_HOME/Mouser/logs. The setup is idempotent and safe to call multiple times — main_qml.py invokes it before any Qt or core import so startup output is captured from the very first line.
Two pages accessible from a slim sidebar in ui/qml/Main.qml:
- Left panel — list of profiles. The "Default (All Apps)" profile is always present. Per-app profiles show the app icon and name. Selecting a profile binds it as the active editing target.
- Right panel — device-aware mouse view. MX Master and MX Anywhere family devices get clickable hotspot dots on the image; unsupported layouts fall back to a generic device card with an experimental "try another supported map" picker.
- Add profile — combo box at the bottom lists known apps (Chrome, Edge, VS Code, VLC, etc.). Click
+to create a per-app profile.
- DPI slider — 200 to the device max with quick presets (400, 800, 1000, 1600, 2400, 4000, 6000, 8000). Reads the current DPI from the device on startup.
- Scroll inversion — independent toggles for vertical and horizontal scroll direction.
- Ignore trackpad (macOS) — keep trackpad and Magic Mouse continuous scroll out of Mouser mappings. Disable only if you intentionally want Mouser to handle them.
- Smart Shift — toggle ratchet ↔ free-spin (HID++
0x2111) plus a sensitivity threshold; status syncs every 15 s and on every reconnect. - Startup controls — Start at login (Windows + macOS) and Start minimized (all platforms).
The window itself is resizable: default 1060 × 700 with a 920 × 620 minimum (ApplicationWindow in ui/qml/Main.qml). Inner pages use Layout.fillWidth / Layout.fillHeight, so panels reflow as the window grows.
mouser/
├── main_qml.py # Application entry point (PySide6 + QML)
├── Mouser.bat # Quick-launch batch file
├── Mouser.spec / Mouser-mac.spec / Mouser-linux.spec # PyInstaller specs
├── build.bat # Windows build (installs deps, verifies hidapi, packages)
├── build_macos_app.sh # macOS bundle build + icon/signing flow
├── packaging/linux/ # 69-mouser-logitech.rules + install-linux-permissions.sh
├── .github/workflows/
│ ├── ci.yml # CI checks (compile, tests, QML lint)
│ └── release.yml # Automated release builds (Windows / macOS arm64+intel / Linux)
├── README.md / README_CN.md / readme_mac_osx.md / CONTRIBUTING_DEVICES.md / DEVELOPMENT.md
├── requirements.txt
│
├── core/ # Backend logic
│ ├── accessibility.py # macOS Accessibility trust checks
│ ├── app_catalog.py # Known apps + per-profile metadata
│ ├── app_detector.py # Foreground app polling
│ ├── config.py # Config manager (JSON load/save/migrate)
│ ├── device_layouts.py # Device-family layout registry for QML overlays
│ ├── engine.py # Core engine — wires hook ↔ simulator ↔ config
│ ├── hid_gesture.py # HID++ 2.0 gesture button + SmartShift (0x2110/0x2111)
│ ├── key_simulator.py # Platform-specific action simulator
│ ├── linux_permissions.py # hidraw / event / uinput permission report
│ ├── log_setup.py # Rotating file log + stdout redirection
│ ├── logi_device_catalog.py # Curated Logitech specs, assets, and hotspots
│ ├── logi_devices.py # Known Logitech device catalog + connected-device metadata
│ ├── mouse_hook.py # Platform dispatcher façade
│ ├── mouse_hook_base.py # Shared base class
│ ├── mouse_hook_contract.py # Hook protocol / type stubs
│ ├── mouse_hook_types.py # Event enums
│ ├── mouse_hook_windows.py # WH_MOUSE_LL + Raw Input
│ ├── mouse_hook_macos.py # CGEventTap + Quartz
│ ├── mouse_hook_linux.py # evdev + uinput
│ ├── mouse_hook_stub.py # Inert hook (unsupported platforms / tests)
│ ├── startup.py # Login startup (Windows registry + macOS LaunchAgent)
│ └── version.py # APP_VERSION / commit / build mode
│
├── ui/ # UI layer
│ ├── backend.py # QML ↔ Python bridge (QObject)
│ ├── locale_manager.py # en / zh_CN / zh_TW translations + button/action labels
│ └── qml/
│ ├── Main.qml # App shell (sidebar + page stack + tray toast)
│ ├── MousePage.qml # Merged mouse diagram + profile manager
│ ├── ScrollPage.qml # DPI slider + scroll/SmartShift toggles
│ ├── KeyCaptureDialog.qml # Custom shortcut recorder
│ ├── HotspotDot.qml # Interactive button overlay on mouse image
│ ├── ActionChip.qml # Selectable action pill
│ ├── AppIcon.qml # File-icon helper for known apps
│ └── Theme.js # Shared colors and constants
│
├── tests/ # unittest suite (logi_devices, hid_gesture, engine, hooks, …)
└── images/ # Logos, app icons, mouse diagrams, screenshots
Parsed in main_qml.py (_parse_cli_args):
| Flag | Behavior |
|---|---|
--start-hidden |
Boot directly into the tray / menu bar; combined with the start_minimized config preference. |
--hid-backend=<auto|hidapi|iokit> |
Force a specific HID transport. macOS defaults to iokit; other platforms default to auto. Use only for debugging. |
Example:
python main_qml.py --hid-backend=hidapi
python main_qml.py --start-hiddenbuild.bat # standard packaged build (installs deps, verifies hidapi, runs PyInstaller)
build.bat --clean # nuke build/ and dist/ before rebuilding
# Manual path
pip install -r requirements.txt pyinstaller
pyinstaller Mouser.spec --noconfirmbuild.bat fails early if hidapi is not importable, which prevents shipping a build that cannot detect Logitech devices. Output: dist\Mouser\ — zip the folder for distribution.
pip install -r requirements.txt pyinstaller
./build_macos_app.shThe script reuses images/AppIcon.icns when present, otherwise generates one from images/logo_icon.png, then runs PyInstaller with Mouser-mac.spec. Output: dist/Mouser.app. The bundle runs as LSUIElement.
Signing is driven by MOUSER_SIGN_IDENTITY:
- Unset: the bundle is ad-hoc signed (
codesign --sign -). The bundle's code identity can change on rebuild, so macOS may ask for Accessibility permission again. Fine for one-off builds. - Set to a codesigning identity SHA-1 (list with
security find-identity -v -p codesigning): the script signs nested.dylib/.so/.frameworkfiles depth-first with--options runtime, then signs the outer bundle with the hardened-runtime exceptions atbuild_resources/Mouser.entitlements(allow-jit,allow-unsigned-executable-memory,disable-library-validation), then runscodesign --verify --deep --strict --verbose=2and aborts the build if verification fails. This local developer signing path can reduce macOS Accessibility permission churn across repeated builds when the source, resolved Python interpreter, dependency versions, architecture, signing identity, entitlements, and timestamp policy stay the same.
The script picks the Python interpreter in this order: MOUSER_PYTHON env override → active $VIRTUAL_ENV/bin/python3 or bin/python → ./.venv/bin/python3 or bin/python → python3 or python on PATH. It fails fast with an explicit error if the selected interpreter is missing PyInstaller, so a half-set-up environment can't silently produce a different bundle layout. pyenv, uv, Conda, asdf, Poetry, and similar tools are supported through the active virtualenv, normal PATH, or MOUSER_PYTHON; the script does not call those tools directly. pyenv users should initialize shims in the shell so python3 resolves through pyenv, or set MOUSER_PYTHON explicitly.
PYTHONHASHSEED=0 is pinned for the PyInstaller invocation so set iteration during the analysis stage produces byte-identical base_library.zip output across rebuilds (otherwise the outer cdhash drifts even with a stable signing identity).
The MOUSER_SIGN_IDENTITY path is not a notarized release-signing workflow. Public macOS release zips remain ad-hoc signed until a separate Developer ID Application signing, secure timestamp, notarization, stapling, and Gatekeeper assessment workflow exists.
- Build on the architecture you want to ship.
arm64Python → Apple Silicon,x86_64Python → Intel. - Set
PYINSTALLER_TARGET_ARCH=arm64|x86_64|universal2to override (when your Python supports the target). - Release CI publishes both
Mouser-macOS.zipandMouser-macOS-intel.zip.
sudo apt-get install libhidapi-dev
pip install pyinstaller
pyinstaller Mouser-linux.spec --noconfirmOutput: dist/Mouser/. The release pipeline additionally bundles the Linux permission helper files and hicolor app-icon ladder, runs ldd on the resulting binary to flag missing libraries, and performs an offscreen smoke test (QT_QPA_PLATFORM=offscreen).
Create a Mouser.lnk shortcut that launches via pythonw.exe if you want to run from source without a console window:
$s = (New-Object -ComObject WScript.Shell).CreateShortcut("$([Environment]::GetFolderPath('Desktop'))\Mouser.lnk")
$s.TargetPath = "C:\path\to\mouser\.venv\Scripts\pythonw.exe"
$s.Arguments = "main_qml.py"
$s.WorkingDirectory = "C:\path\to\mouser"
$s.IconLocation = "C:\path\to\mouser\images\logo.ico, 0"
$s.Save()- Thread dump:
kill -USR1 $(pgrep -f main_qml.py)triggers_dump_threadsand prints all stack traces to the terminal — useful for cross-thread freezes without an attached debugger. - Startup timing:
_t0–_t8markers inmain_qml.pylog per-phase startup costs (env setup, PySide6 imports, core imports). Watch for regressions when adding heavy imports. - HID transport override:
--hid-backend=iokit|hidapi|autolets you isolate transport-specific bugs (e.g. Bolt receivers, BLE quirks). - Logs:
~/Library/Logs/Mouser/mouser.log,%APPDATA%\Mouser\logs\mouser.log, or$XDG_STATE_HOME/Mouser/logs/mouser.log. Stdout is redirected through the rotating file handler; stderr is preserved so logging-handler errors don't recurse. - Linux permissions:
core/linux_permissions.pyemits aLinuxPermissionReportdescribing which/dev/hidraw*,/dev/input/event*, and/dev/uinputnodes are blocked. Mouser surfaces this via the UI banner and the log.