Skip to content

fix(hook,hid): revive gesture mode on Middle/Back/Forward - #918

Open
ngtanthanh-qc wants to merge 2 commits into
AprilNEA:masterfrom
ngtanthanh-qc:fix/release-unowned-hidpp-diversions
Open

fix(hook,hid): revive gesture mode on Middle/Back/Forward#918
ngtanthanh-qc wants to merge 2 commits into
AprilNEA:masterfrom
ngtanthanh-qc:fix/release-unowned-hidpp-diversions

Conversation

@ngtanthanh-qc

@ngtanthanh-qc ngtanthanh-qc commented Aug 24, 2026

Copy link
Copy Markdown

Summary

Middle-button gesture mode is dead on my MX Anywhere 3S: no direction fires, the plain click stops working too, and it survives restarts and reboots. Chasing it turned up two independent bugs, both on the path between the button and the hook. Either one alone is enough to kill gestures on Middle/Back/Forward; this branch fixes both, one commit each.

With both applied, hold-and-swipe works on this hardware — confirmed end to end, see Testing.

1. A stale 0x1b04 diversion is never released (#917)

arm_controls_into only writes the CIDs the current CaptureSpec wants diverted. A diversion left behind by a session that never tore down (agent killed, crash, force quit) is therefore never cleared, and since diversion is volatile device state with no owner on the wire, nothing else clears it. The control then emits no OS event (it is diverted) and no HID++ event (nothing armed it).

A gesture-mode Middle/Back/Forward button lands exactly there. plan_for_device deliberately keeps it out of divert_buttons — "diverting it would starve the hook of events" — which correctly stops renewing the diversion but never clears the one already set. So "bind Middle Click to a single action, then later switch it to gesture mode" leaves the button diverted forever.

The code already knows this state exists; arm_reprog_control logs control was already diverted before arming. It just only looks at CIDs it is arming, so a control the current config wants native is never read and never handed back.

2. Unattributed auxiliary buttons fail closed (#920)

handle_button gives up before it reads the binding when button_source_may_remap says no, and that fails closed when macOS attribution is missing. On this mouse the middle button's CGEvent carries no IOHIDEvent at allCGEventCopyIOHIDEvent returns null — so device arrives as None, every time.

Nothing the closed rule protects can be the source there: no trackpad emits button 2/3/4, and handle_button has already narrowed to the OS-hook buttons before the check runs. So it buys nothing on those three while costing every binding on them, silently.

Changes

crates/openlogi-devicesession/gesture.rs

  • After diverting what the spec wants, release_unowned_diversions reads back every OpenLogi-managed control the device exposes as divertable that this session does not own, and undiverts the ones still diverted. Ownership comes from what actually armed (gesture_cids + dpi_cids + button_cids), so the guard against undoing a diversion set moments earlier is exact.
  • unowned_divertable_cids / managed_cids split the ownership rule out as pure functions, unit-testable without a device on the far end of a channel. Only GESTURE_SOURCE_BUTTONS, DPI_MODE_SHIFT_CIDS and DIVERTABLE_STANDARD_BUTTONS are consulted — another application's diversion is left exactly as found, and a control the firmware will not divert is skipped.
  • Reuses the existing undivert_change, so the semantics match the teardown path: clear diverted/raw_xy, re-assert remap, leave every other bit untouched.
  • Costs one getCidReporting per unowned managed control the device exposes — a handful, once per arm — and only writes when one is actually diverted.
  • Side effect worth naming: any ungraceful agent exit becomes self-healing on the next arm, instead of leaving a button dead until the device sleeps or reconnects.
  • 4 tests on the release rule.

crates/openlogi-agent-corehook_runtime.rs

  • button_source_may_remap takes the ButtonId and, when attribution is absent on macOS, keeps Middle/Back/Forward remappable while the primary buttons still fail closed. Linux/Windows are unchanged (they already treat unknown sources as remappable).
  • 3 tests covering the unattributed auxiliary arm, the unattributed primary arm, and that an attributed source is still judged on identity alone.

Testing

Commands run, from the final tree:

export RUSTFLAGS="-D warnings"
cargo fmt --all -- --check                                          # clean
cargo clippy -p openlogi-device -p openlogi-hid -p openlogi-agent \
             -p openlogi-agent-core -p openlogi-cli -p openlogi \
             --all-targets -- -D warnings                           # clean
cargo test   -p openlogi-device -p openlogi-hid -p openlogi-agent \
             -p openlogi-agent-core -p openlogi-cli -p openlogi     # all green
RUSTDOCFLAGS="-D warnings" cargo doc -p openlogi-device \
             -p openlogi-agent-core --no-deps --document-private-items   # clean

Affected-package tier. cargo tree --workspace --target all --invert gives openlogi-device → openlogi-hid → {openlogi-agent, openlogi-agent-core, openlogi-cli → openlogi} and openlogi-agent-core → openlogi-agent. No GUI crate depends on either change.

Not run: the full-workspace tier and cargo xtask ci. This machine has Command Line Tools only, not full Xcode, so the GPUI crates do not build here. No cross-lint either — aarch64-apple-darwin is the only installed target, so clippy-windows and the Linux jobs are not run, not green. The diff touches no wire type, no locale, and no #[cfg(target_os = …)] block; the one platform-conditional expression is a cfg!() in button_source_may_remap, whose Linux/Windows arm is unchanged, and the one platform-gated test is #[cfg(target_os = "macos")] while the other two hold on every target.

Hardware verification — MX Anywhere 3S over a Logi Bolt receiver, macOS 26.5 Apple Silicon:

  1. Reproduced on 0.7.10 and master @ 013ab99: middle button completely inert in gesture mode.

  2. Instrumented master showed the config, plan and hook maps all correct (gkeys=[MiddleClick]) while the event never reached the binding.

  3. Bug 1 confirmed: a tail-append CGEventTap of my own saw nothing from button 2 while seeing every LeftClick. With the first commit the agent logs, on first arm:

    INFO openlogi_device::session::gesture: releasing a diversion this session does not own cid=82
    

    cid=82 is 0x0052, Middle Click — and button-2 events start reaching macOS again (470 of them in the next test).

  4. Bug 2 then surfaced underneath, with probes on handle_button:

    id=LeftClick   device=Some(EventDevice { vendor_id: Some(1133), … }) may_remap=true
    id=MiddleClick device=None                                          may_remap=false
    

    110 MiddleClick events, every one unattributed.

  5. With both commits: hold-and-swipe fires the bound direction, and a tap without a swipe still sends a plain middle click. Verified against this layout:

    [devices."receiver:…:slot:1".bindings.MiddleClick]
    Up    = "MissionControl"
    Down  = "CaptureRegion"
    Left  = "PrevTab"
    Right = "ShowActionsRing"
    Click = "MiddleClick"

    All five behave as configured, including the Actions Ring opening on a rightward swipe.

Worth a maintainer's pass on a device with a dedicated gesture button or an MX Master 4 haptic panel, to confirm nothing that should stay diverted gets released by the first commit.

Fixes #917
Fixes #920

Arming a capture session only ever writes the CIDs it wants diverted. A
diversion left behind by a session that never tore down — the agent was
killed, or another Logitech app set it — is therefore never cleared, and
because diversion is volatile device state with no owner on the wire,
nothing else clears it either. The control then emits no OS event (it is
diverted) and no HID++ event (nothing armed it): dead until the device
sleeps or reconnects.

A Middle/Back/Forward button in gesture mode hits exactly this. Its swipes
are detected by the OS hook, so plan_for_device deliberately keeps a
gesture-mode button out of divert_buttons — diverting it would starve the
hook of the very press it must see. Moving a button that had been diverted
for a single action into gesture mode therefore has to clear that diversion,
not merely stop renewing it. Until now it did not: the button stayed
diverted, the hook never saw it, and gestures silently never fired.

Reconcile at arm time instead. Once the spec's controls are diverted, read
back every OpenLogi-managed control this device exposes as divertable that
the session does not own, and undivert the ones still diverted. Only the
managed tables are consulted, so another application's diversion is left
exactly as found, and a control the firmware will not divert is skipped.
@greptile-apps

greptile-apps Bot commented Aug 24, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds arm-time reconciliation for stale HID++ control diversions and allows unattributed auxiliary mouse buttons through the macOS remapping path.

  • Computes session-owned controls and undiverts other managed controls that remain diverted.
  • Adds unit coverage for managed, owned, foreign, and non-divertable CID classification.
  • Adds source-attribution coverage for auxiliary and primary mouse buttons.

Confidence Score: 3/5

The PR is not yet safe to merge because reconciliation can disrupt another application's active diversion and can leave a stale diversion permanently unrecovered after a transient HID++ failure.

The current reconciliation path treats local non-ownership as stale state even though the protocol state does not identify an owner, and it suppresses reconciliation failures while allowing the long-lived capture session to report successful startup.

Files Needing Attention: crates/openlogi-device/src/session/gesture.rs

Important Files Changed

Filename Overview
crates/openlogi-device/src/session/gesture.rs Adds stale-diversion reconciliation, but active foreign diversions remain indistinguishable from stale ones and reconciliation failures remain unretried.
crates/openlogi-device/src/session/gesture/tests.rs Adds focused classification tests for managed, owned, foreign, and non-divertable controls.
crates/openlogi-agent-core/src/hook_runtime.rs Permits unattributed auxiliary mouse buttons to use remapping while retaining identity checks for attributed events and conservative handling of primary buttons.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Start capture session] --> B[Enumerate divertable controls]
    B --> C[Arm controls required by current plan]
    C --> D[Build owned CID set]
    D --> E[Inspect unowned managed CIDs]
    E --> F{CID still diverted?}
    F -->|No| G[Leave unchanged]
    F -->|Yes| H[Write undiverted reporting state]
    G --> I[Run capture session]
    H --> I
Loading

Reviews (2): Last reviewed commit: "fix(hook): keep unattributed Middle/Back..." | Re-trigger Greptile

Comment thread crates/openlogi-device/src/session/gesture.rs
Comment on lines +632 to +638
Err(error) => {
debug!(
cid,
?error,
"could not read reporting while releasing diversions"
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Failed reconciliation is never retried

When a transient HID++ error occurs while reading or clearing a stale diversion, the error is suppressed and the session reports healthy without retrying reconciliation, causing the affected control to remain dead until an unrelated re-arm, reconnect, or device sleep.

Fix in Codex Fix in Claude Code

macOS usually attributes a CGEvent to an IOKit sender, and the hook leans on
that to decide whether a button event may be remapped. When the attribution
is missing it fails closed, on the grounds that an unattributed event is more
likely a trackpad or a system source than a Logi mouse.

That is right for the primary buttons and wrong for the auxiliary ones. Some
mice deliver Middle/Back/Forward through a HID collection whose CGEvent
carries no IOHIDEvent at all — CGEventCopyIOHIDEvent returns null, so there is
no sender to resolve and `device` arrives as `None`. Observed on an
MX Anywhere 3S over a Bolt receiver, where `LeftClick` resolves to
`vendor_id: Some(0x046d)` while every `MiddleClick` on the same mouse arrives
unattributed.

Failing closed there loses every binding on those buttons, silently: the
events still reach the tap, `handle_button` still runs, and it returns
PassThrough before reading the binding. Gesture mode is the worst of it — the
hook never claims the press, so hold-and-swipe never fires and the button
looks dead even though the config, the plan and the hook maps are all correct.

Nothing the closed rule protects can be the source: no trackpad emits button
2/3/4, and `handle_button` has already narrowed to the OS-hook buttons before
this check runs. Give those three the benefit of the doubt when attribution is
absent, exactly as Linux and Windows already do, and keep the primary buttons
failing closed.
@ngtanthanh-qc ngtanthanh-qc changed the title fix(hid): release stale HID++ diversions a capture session does not own fix(hook,hid): revive gesture mode on Middle/Back/Forward Aug 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant