Skip to content

feat(macOS): Native MX Master 3S Gesture Button Support + AppleScript Space-Switch Bypass + Drag Debouncing #2

@NotTanJune

Description

@NotTanJune

Background

I downloaded OpenLogi hoping to remap the thumb-rest gesture button on my Logitech MX Master 3S on macOS. Unfortunately, the gesture button was completely undetected, clicking it produced no response in the app, no button events, nothing. It appeared to be completely dead at the OS level.

Rather than giving up, I decided to reverse-engineer what was actually happening using Gemini 3.5 Flash (Google's AI assistant). Over a long debugging session, we:

  1. Wrote a raw Quartz Event Tap event sniffer in Rust to log every mouse and keyboard event at the OS level
  2. Tested the button with and without Logitech Options+ installed
  3. Discovered that without any Logitech drivers, the gesture button natively registers as mouse Button 5
  4. Worked through several layers of macOS sandbox restrictions to get space-switching working reliably

Sharing all findings here in case they are useful to integrate into OpenLogi.


1. 🖱️ Native Gesture Button 5 Hook (No HID++ Diversion Required)

Discovery: When Logitech proprietary drivers are fully uninstalled on macOS, the physical thumb-rest gesture button on MX Master 3S natively reports to the OS as standard mouse Button 5 via CGEventType::OtherMouseDown/Up, rather than being completely silent or requiring raw HID++ 2.0 vendor diversion packets.

This means a lightweight Quartz Event Tap can intercept it directly — no REPROG_CONTROLS_V4 feature negotiation or USB HID++ vendor page access needed:

CGEventType::OtherMouseDown => {
    let button = event.get_integer_value_field(EventField::MOUSE_EVENT_BUTTON_NUMBER);
    if button == 5 {
        state.active_button = Some(button);
        state.start_x = event.location().x;
        state.has_gestured = false;
        return None; // Swallow the raw click
    }
}

Note: This was discovered by running a raw event sniffer with Accessibility permissions and physically pressing the gesture button with no drivers installed. The button showed up cleanly as OtherMouseDown with button index 5.


2. 🔑 AppleScript System Events Space-Switching Sandbox Bypass

Problem: On macOS Ventura/Sonoma/Sequoia, posting simulated Control + Arrow key events via CGEvent::new_keyboard_event to trigger Mission Control space transitions silently fails in two ways:

  • The macOS Window Server blocks them to prevent programmatic system UI automation.
  • The keystrokes then leak directly into the active foreground window, printing raw terminal escape sequences like ;5D and ;5C in the shell prompt.

This was the single hardest bug to diagnose, the gesture was being detected and the key was being fired, but macOS was silently swallowing it at the global hotkey layer while simultaneously routing it to the active window.

Solution: Route the keystrokes through the macOS built-in, fully trusted AppleScript System Events agent:

fn switch_space_left() {
    let _ = std::process::Command::new("osascript")
        .arg("-e")
        .arg("tell application \"System Events\" to key code 123 using control down")
        .spawn(); // Non-blocking: hook thread stays at full speed
}

fn switch_space_right() {
    let _ = std::process::Command::new("osascript")
        .arg("-e")
        .arg("tell application \"System Events\" to key code 124 using control down")
        .spawn();
}

Why it works: System Events is a trusted macOS system process. When it simulates the shortcut, the OS honours it immediately and transitions spaces. .spawn() runs it asynchronously so the mouse hook never blocks.


3. 🛡️ One-Shot Drag Gesture Debouncing (Eliminates Screen Jitter)

Problem: During a fast swipe, the mouse easily travels past the pixel threshold multiple times within a single button hold. This queues multiple osascript processes firing in rapid succession, causing a jarring "double-bounce" or screen jitter at the end of the space transition animation.

Solution: A has_gestured one-shot lock that freezes further threshold checks for the entire duration of the current button hold:

if action == MouseAction::SwitchSpaces {
    if state.has_gestured {
        return None; // Lock engaged — swallow all further movement events
    }
    let dx = loc.x - state.start_x;
    if dx > config.threshold {
        switch_space_right();
        state.has_gestured = true; // Engage one-shot lock immediately
    } else if dx < -config.threshold {
        switch_space_left();
        state.has_gestured = true;
    }
    return None;
}

Releasing the physical button resets has_gestured = false, cleanly preparing for the next independent swipe.


4. 🔒 Terminal-Context Accessibility Wrapper (Bypasses launchd TCC Invalidation)

Problem: macOS TCC silently invalidates Accessibility trust for any binary that is recompiled (code signature changes). Binaries launched directly by launchd custom plists are also frequently blocked — even if System Settings shows the toggle as "on".

Solution: Auto-start the daemon from ~/.zshrc, inheriting the Terminal's stable, pre-approved Accessibility credentials:

# Auto-start daemon under Terminal's trusted Accessibility context
if ! pgrep -x "your-daemon-binary" > /dev/null; then
    nohup /path/to/daemon --daemon > ~/.daemon.log 2>&1 &
fi

This completely bypasses the launchd TCC cache invalidation problem with zero configuration.


Environment

  • Mouse: Logitech MX Master 3S
  • macOS: Sequoia 15.x
  • Connection: Bluetooth
  • Logitech Options+: Fully uninstalled
  • Language: Rust (Quartz Event Tap via core-graphics crate)
  • Reverse-engineered with: Gemini 3.5 Flash

Happy to discuss further or contribute a PR if any of this is useful to the project!

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: guiGraphical user interfacearea: hidppHID++ protocol and device feature supportneeds: decisionNeeds a maintainer/product decisionplatform: macosmacOS-specific issuetype: featureNew feature request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions