feat: per-device capture — every online device gets its own session, bindings, and settings - #419
feat: per-device capture — every online device gets its own session, bindings, and settings#419tagawa0525 wants to merge 19 commits into
Conversation
Greptile SummaryThis PR refactors the runtime from a single-device capture model to a fully per-device one: the orchestrator now builds one
Confidence Score: 5/5Safe to merge. The multi-session teardown design correctly prevents control-divert races on plan changes, and The overall architecture is sound: per-device Files Needing Attention:
|
| Filename | Overview |
|---|---|
| crates/openlogi-agent-core/src/capture_plan.rs | New file: builds DeviceCapturePlan per device — divert set, binding maps, thumbwheel flags — with clear unit tests covering the gesture-button divert edge cases. |
| crates/openlogi-agent-core/src/watchers/gesture.rs | Multi-session manager: stopped sessions stay in the map until done_rx confirms teardown, preventing arm/restore races. dispatch holds capture_plans.read() during dispatch_action for Gesture/ButtonPressed because the action borrows from the plan — adding .cloned() would let NLL release the lock sooner (same as the Scroll arm already does). |
| crates/openlogi-agent-core/src/orchestrator.rs | Replaces single-device DPI+sensitivity state with per-device DpiCycles; publish_device_runtime is now called in every code path (full rebuild, inventory fast-path, app switch), ensuring capture plans and DPI cycles stay in sync. |
| crates/openlogi-agent-core/src/dpi.rs | New DpiCycles struct wraps per-device DpiCycleState map; state_for/target_for correctly fall back to the selected device for the OS-hook path (None key). |
| crates/openlogi-hid/src/gesture.rs | Adds CaptureSpec and DIVERTABLE_STANDARD_BUTTONS; correctly guards the channel-slot clear on teardown with Arc::ptr_eq to avoid evicting a sibling session's channel; separates plain-diverted gesture button from swipe accumulator path. |
| crates/openlogi-core/src/config/device.rs | Adds enabled (default true, skip-serialize-if-true) and thumbwheel_sensitivity: Option<i32> to DeviceConfig with backward-compatible serde defaults. |
| crates/openlogi-gui/src/state.rs | Adds set_device_enabled and set_device_thumbwheel_sensitivity; the sensitivity setter clears the per-device override when the user slides to the app-wide default, keeping config.toml clean. |
| crates/openlogi-hook/src/linux.rs | Adds Logitech VID filter to find_mouse_devices, preventing the hook from grabbing unrelated vendors' pointing devices. |
| crates/openlogi-gui/src/components/smartshift_panel.rs | Adds per-device thumbwheel sensitivity slider; re-seats the slider from committed config only when not mid-drag, following the same safe pattern as the existing SmartShift threshold slider. |
Sequence Diagram
sequenceDiagram
participant Orch as Orchestrator
participant Plans as SharedCapturePlans
participant Mgr as gesture::manage (async)
participant Sess as run_capture_session (task)
participant Fwd as forward task
participant DPI as DpiCycles
Orch->>Plans: write capture_plans (publish_device_runtime)
Orch->>DPI: write dpi_cycle (rebuild_dpi_cycles)
Note over Mgr: ticker fires
Mgr->>Plans: read → build want map
Mgr->>Sess: spawn_session(key, route, spec, epoch, lease)
Sess->>Fwd: spawn forward task (key-tags inputs)
Note over Sess: arm_controls → divert CIDs
Sess-->>Fwd: CapturedInput via session_tx
Fwd-->>Mgr: (key, input) via tx
Note over Mgr: rx.recv() branch
Mgr->>Plans: read → find plan by key
Mgr->>DPI: write (CycleDpiPresets / SetDpiPreset)
Note over Mgr: plan changes — ticker fires
Mgr->>Sess: stop.send() — stop sender taken
Note over Sess: restore diverted CIDs, drop session_tx
Fwd-->>Mgr: None (session_tx dropped) — forward task exits
Sess-->>Mgr: done.send((key, epoch))
Note over Mgr: done_rx: sessions.remove(key)
Note over Mgr: next tick: new session started
Reviews (9): Last reviewed commit: "fix(hid): only clear the capture-channel..." | Re-trigger Greptile
…ift toggle Review follow-up (AprilNEA#419): - refresh_inventory's same-set fast path republished capture plans but not the DPI-cycle map, so a device waking from sleep got its capture session yet silently dropped DPI-cycle / SmartShift-toggle actions until the next full rebuild. Fold both runtime views into one publish_device_runtime() so the pair can't diverge again. - ToggleSmartShift only needs the target route; give DpiCycles a read-only target_for() and drop the unnecessary write lock. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N76prZoUHMrHZajXq4jET1
|
Both review findings addressed in 170cfbe:
|
…decessor Review follow-up (AprilNEA#419): the single-session manager deliberately left a one-tick gap between stopping a session and starting its successor, so the teardown's divert-restore writes could not interleave with the new session's arm writes on the same device (a restore landing after the arm leaves the control un-diverted while the session believes it owns it). The multi-session rewrite lost that gap. Track the keys stopped in the current tick and defer their restart to the next one, per device. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N76prZoUHMrHZajXq4jET1
Review follow-up (AprilNEA#419): the tick-local stopping guard deferred the replacement session by exactly one tick, so a stop whose divert-restore writes outlast TARGET_POLL could still interleave with the successor's arm writes on the same device. Keep a deliberately stopped session in the map — stop sender taken — until its task's completion report arrives, and never arm a tracked key. The report is sent after run_capture_session returns, restore included, so the successor can no longer overlap the teardown regardless of how long it takes; the same holds for the post-pairing recovery path, which stops every session the same way.
|
The remaining cross-tick window flagged in the re-review summary is closed in e277c5b: a deliberately stopped session now stays tracked (stop sender taken) until its task's completion report arrives — which is sent only after |
|
Fixed in b00d213: |
Review follow-up (AprilNEA#419): set_current_app only rewrote the OS-hook maps, but the capture plans bake the foreground app into their binding maps and divert sets at build time, so HID++ dispatch kept serving the previous app's overlay — a diverted button fired its stale binding and a button due to fall back to native stayed diverted. Route the app switch through publish_device_runtime, the same single publish point the inventory paths use, so the plans (and the watcher sessions diffing them) always reflect the app in front. DPI cycles ride along unchanged: presets are not app-scoped, so the rebuild preserves every live index.
…de default Review follow-up (AprilNEA#419): the per-device slider always stored Some(value), so a device that once touched the slider was pinned forever — even at the default value — and silently stopped following later Settings → General changes. The slider is the device's only sensitivity control, so committing the app-wide default now clears the override (back to following the default) and anything else pins it. The no-op guard compares the stored override rather than the effective value, so dragging an existing override onto the default actually clears it.
|
On the two P2 notes from the latest review pass:
|
|
Fixed in b57b279: the teardown clear is now guarded by an |
Review follow-up (AprilNEA#419): the shared write-reuse slot is a single last-writer-wins cell, so with one session per device a stopping session's unconditional clear could evict the channel a sibling session published after it — silently demoting that device's DPI/SmartShift writes to the fresh-open slow path. Guard the teardown clear with an Arc identity check so a session only removes its own channel.
## Why Capture previously followed only the GUI's selected device: bindings on any other online device did nothing, and a selection switch tore down and re-armed the single session. ## What Every online device gets its own capture session, driven by per-device capture plans (config + inventory) that carry the device's own binding maps; inputs dispatch against the plan of the session they arrived on. Includes the per-device manage toggle, per-device thumbwheel sensitivity / DPI cycle state, the plain divert of a single-bound gesture button, session-teardown draining, plan republish on app switch, the capture-channel slot ownership fix, and the Linux evdev/devenv fixes made along the way. ## Impact openlogi-agent-core watchers, openlogi-hid capture, GUI gallery ring. Upstream PR AprilNEA#419 (in review; branch merged at its current tip).
## Why Leaving a device detail view required clicking the explicit back button; the mouse's back button and Alt+Left did nothing, and the gallery carousel's arrows/dots duplicated scroll affordances. ## What Navigate back with the mouse's back button and Alt+Left across the GUI, and drop the gallery carousel arrows and dots. ## Impact openlogi-gui only; no PR upstream yet (next in line after AprilNEA#419).
## Why The MX Master 4 Haptic Sense Panel (CID 0x01a0) delivers the gesture role on that model, but capture only ever armed the dedicated gesture button CID, so thumb-pad gestures and single bindings did nothing (issue AprilNEA#313). ## What Generalize capture to a per-device gesture-source CID list, add the panel to it (raw-XY divert as gesture owner, plain divert for a single binding), and discard the panel's first raw-XY sample after contact — a real-hardware probe showed it is an absolute position jump, not a delta. ## Impact openlogi-hid capture, openlogi-agent-core capture plans. Fixes AprilNEA#313 once PR'd upstream (stacked on AprilNEA#419; not hardware-verified yet).
## Why Capture previously followed only the GUI's selected device: bindings on any other online device did nothing, and a selection switch tore down and re-armed the single session. ## What Every online device gets its own capture session, driven by per-device capture plans (config + inventory) that carry the device's own binding maps; inputs dispatch against the plan of the session they arrived on. Includes the per-device manage toggle, per-device thumbwheel sensitivity / DPI cycle state, the plain divert of a single-bound gesture button, session-teardown draining, plan republish on app switch, the capture-channel slot ownership fix, and the Linux evdev/devenv fixes made along the way. The AprilNEA#415 thumbwheel divert fix is stacked underneath this branch and arrives with it. ## Impact openlogi-agent-core watchers, openlogi-hid capture, GUI gallery ring. Upstream PRs AprilNEA#415 + AprilNEA#419 (in review; merged at the current tip).
## Why Leaving a device detail view required clicking the explicit back button; the mouse's back button and Alt+Left did nothing, and the gallery carousel's arrows/dots duplicated scroll affordances. ## What Navigate back with the mouse's back button and Alt+Left across the GUI, and drop the gallery carousel arrows and dots. ## Impact openlogi-gui only; no PR upstream yet (next in line after AprilNEA#419).
## Why The MX Master 4 Haptic Sense Panel (CID 0x01a0) delivers the gesture role on that model, but capture only ever armed the dedicated gesture button CID, so thumb-pad gestures and single bindings did nothing (issue AprilNEA#313). ## What Generalize capture to a per-device gesture-source CID list, add the panel to it (raw-XY divert as gesture owner, plain divert for a single binding), and discard the panel's first raw-XY sample after contact — a real-hardware probe showed it is an absolute position jump, not a delta. ## Impact openlogi-hid capture, openlogi-agent-core capture plans. Fixes AprilNEA#313 once PR'd upstream (stacked on AprilNEA#419; not hardware-verified yet).
## Why The MX Master 4 Haptic Sense Panel (CID 0x01a0) is a physical control distinct from the dedicated gesture button, but OpenLogi neither captured it nor showed it anywhere in the GUI: gestures bound to the thumb pad did nothing (issue AprilNEA#313) and there was no place to assign it. ## What Model the panel as ButtonId::HapticPanel end to end: capture arms the gesture owner's own source CID with raw-XY (dedicated button or panel) and discards the panel's contact-jump first sample; a single-bound non-owner source is plain-diverted delivering its own ButtonId; the mouse model maps the ASSIGNMENT_NAME_SHOW_RADIAL_MENU marker to a panel hotspot, offers the panel as a gesture-owner choice, and labels it in every locale. ## Impact openlogi-core (append-only ButtonId), openlogi-hid capture, openlogi-agent-core capture plans, openlogi-gui + all locales. Fixes AprilNEA#313 once PR'd upstream (stacked on AprilNEA#419; not hardware-verified yet).
## Why The devenv shell was broken off macOS: create-dmg was provided unconditionally but only exists for darwin, and the Linux GUI/hook builds lacked their system libraries (X11/Wayland, evdev, udev), so the workspace could not even compile inside the shell on Linux. ## What Make create-dmg darwin-only and provide the Linux GUI/hook system libraries in devenv.nix. ## Impact devenv.nix only — dev shell, no shipped code. These commits also ride in upstream PR AprilNEA#419; carried here as their own branch so the environment fix sits directly on origin/master and every later branch builds on a working shell.
…ift toggle Review follow-up (AprilNEA#419): - refresh_inventory's same-set fast path republished capture plans but not the DPI-cycle map, so a device waking from sleep got its capture session yet silently dropped DPI-cycle / SmartShift-toggle actions until the next full rebuild. Fold both runtime views into one publish_device_runtime() so the pair can't diverge again. - ToggleSmartShift only needs the target route; give DpiCycles a read-only target_for() and drop the unnecessary write lock. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N76prZoUHMrHZajXq4jET1
…decessor Review follow-up (AprilNEA#419): the single-session manager deliberately left a one-tick gap between stopping a session and starting its successor, so the teardown's divert-restore writes could not interleave with the new session's arm writes on the same device (a restore landing after the arm leaves the control un-diverted while the session believes it owns it). The multi-session rewrite lost that gap. Track the keys stopped in the current tick and defer their restart to the next one, per device. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N76prZoUHMrHZajXq4jET1
Review follow-up (AprilNEA#419): the tick-local stopping guard deferred the replacement session by exactly one tick, so a stop whose divert-restore writes outlast TARGET_POLL could still interleave with the successor's arm writes on the same device. Keep a deliberately stopped session in the map — stop sender taken — until its task's completion report arrives, and never arm a tracked key. The report is sent after run_capture_session returns, restore included, so the successor can no longer overlap the teardown regardless of how long it takes; the same holds for the post-pairing recovery path, which stops every session the same way.
Review follow-up (AprilNEA#419): set_current_app only rewrote the OS-hook maps, but the capture plans bake the foreground app into their binding maps and divert sets at build time, so HID++ dispatch kept serving the previous app's overlay — a diverted button fired its stale binding and a button due to fall back to native stayed diverted. Route the app switch through publish_device_runtime, the same single publish point the inventory paths use, so the plans (and the watcher sessions diffing them) always reflect the app in front. DPI cycles ride along unchanged: presets are not app-scoped, so the rebuild preserves every live index.
…de default Review follow-up (AprilNEA#419): the per-device slider always stored Some(value), so a device that once touched the slider was pinned forever — even at the default value — and silently stopped following later Settings → General changes. The slider is the device's only sensitivity control, so committing the app-wide default now clears the override (back to following the default) and anything else pins it. The no-op guard compares the stored override rather than the effective value, so dragging an existing override onto the default actually clears it.
Review follow-up (AprilNEA#419): the shared write-reuse slot is a single last-writer-wins cell, so with one session per device a stopping session's unconditional clear could evict the channel a sibling session published after it — silently demoting that device's DPI/SmartShift writes to the fresh-open slow path. Guard the teardown clear with an Arc identity check so a session only removes its own channel.
## Why Capture previously followed only the GUI's selected device: bindings on any other online device did nothing, and a selection switch tore down and re-armed the single session. ## What Every online device gets its own capture session, driven by per-device capture plans (config + inventory) that carry the device's own binding maps; inputs dispatch against the plan of the session they arrived on. Includes the per-device manage toggle, per-device thumbwheel sensitivity / DPI cycle state, the plain divert of a single-bound gesture button, session-teardown draining, plan republish on app switch, the capture-channel slot ownership fix, and the Linux evdev fix made along the way. The AprilNEA#415 thumbwheel divert fix is stacked underneath this branch and arrives with it; the devenv fixes ride in their own branch merged just before this one. ## Impact openlogi-agent-core watchers, openlogi-hid capture, GUI gallery ring. Upstream PRs AprilNEA#415 + AprilNEA#419 are still open; this merges a copy of the branch rebased onto the current origin/master — the PR branches themselves are left untouched (and therefore unmerged here).
## Why Leaving a device detail view required clicking the explicit back button; the mouse's back button and Alt+Left did nothing, and the gallery carousel's arrows/dots duplicated scroll affordances. ## What Navigate back with the mouse's back button and Alt+Left across the GUI, and drop the gallery carousel arrows and dots. ## Impact openlogi-gui only; no PR upstream yet (next in line after AprilNEA#419).
## Why The MX Master 4 Haptic Sense Panel (CID 0x01a0) is a physical control distinct from the dedicated gesture button, but OpenLogi neither captured it nor showed it anywhere in the GUI: gestures bound to the thumb pad did nothing (issue AprilNEA#313) and there was no place to assign it. ## What Model the panel as ButtonId::HapticPanel end to end: capture arms the gesture owner's own source CID with raw-XY (dedicated button or panel) and discards the panel's contact-jump first sample; a single-bound non-owner source is plain-diverted delivering its own ButtonId; the mouse model maps the ASSIGNMENT_NAME_SHOW_RADIAL_MENU marker to a panel hotspot, offers the panel as a gesture-owner choice, and labels it in every locale. ## Impact openlogi-core (append-only ButtonId), openlogi-hid capture, openlogi-agent-core capture plans, openlogi-gui + all locales. Fixes AprilNEA#313 once PR'd upstream (stacked on AprilNEA#419; not hardware-verified yet).
…apability arm_controls refused to divert the thumb wheel unless getThumbwheelInfo reported the single-tap capability. Rotation rebinds and the sensitivity multiplier need the diverted event stream too, and devices like the MX Master 4 report no single-tap bit — leaving ThumbwheelScrollUp/Down bindings silently inert. Divert whenever capture is requested; a missing single tap now only means a bound click can never fire. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N76prZoUHMrHZajXq4jET1
Review follow-up: the module-level doc and the CapturedInput::Scroll doc still described diversion as click-bound-only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N76prZoUHMrHZajXq4jET1
find_mouse_devices grabbed every BTN_LEFT evdev device exclusively and routed it through the virtual mouse, applying the managed device's remaps to unrelated vendors' mice (and virtual pointers like keyd's). Filter by Logitech's vendor id, mirroring the macOS hook's vendor awareness. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N76prZoUHMrHZajXq4jET1
Replace the single selected-device capture session with one session per online device, dispatching each input against the binding maps of the device it arrived on: - openlogi-hid: run_capture_session takes a CaptureSpec; rebindable standard buttons (middle/back/forward, 0x0052/0x0053/0x0056) divert over 0x1b04 when the spec asks, emit ButtonPressed on rising edges, and restore on teardown. A button at its default binding is never diverted, so native behavior needs no re-synthesis. - agent-core: new capture_plan module — the orchestrator rebuilds one DeviceCapturePlan (route, per-device bindings, gesture map, divert set) for every online device on config/app/inventory changes, including pure online-flag flips. The OS-hook gesture owner's button is excluded from diversion so hold+swipe detection keeps its events. - watcher: manages sessions in a HashMap keyed by config_key, tags inputs with their device, keeps per-device thumbwheel accumulators, and shares the capture-vs-pairing lease across sessions via an Arc (freed when the last session exits, as before). Verified on hardware: MX Master 4 and MX Master 3S (two Bolt receivers) hold concurrent sessions and their bindings apply independently of the GUI carousel selection. Known limitation: CycleDpiPresets still targets the selected device's DPI cycle state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N76prZoUHMrHZajXq4jET1
Finish moving per-device runtime state out of the app-wide globals now that capture runs one session per device: - config: DeviceConfig.thumbwheel_sensitivity overrides the app-wide default (which remains the fallback and the Settings → General slider's meaning); Config::thumbwheel_sensitivity(key) resolves it. - agent: each DeviceCapturePlan carries its device's resolved sensitivity for arming and scroll scaling; the SharedRuntime AtomicI32 mirror is gone. DpiCycles replaces the single DpiCycleState — one cycle state per online device plus the GUI selection; HID++ capture dispatch cycles the device an event arrived on, while the OS hook (which cannot attribute events) keeps targeting the selection. A config reload no longer snaps an unchanged device's cycle index back to preset[0]. - GUI: the SmartShift panel gains a per-device Thumb Wheel Sensitivity slider (reusing the existing localized label), editing the selected device's override. Verified on hardware: MX Master 4 at the app default and MX Master 3S with an override scroll at visibly different speeds simultaneously. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N76prZoUHMrHZajXq4jET1
Add DeviceConfig.enabled (default true, serialized only when off). A disabled device is left fully native: no capture session (so no HID++ diversion at all, including the previously unconditional DPI/ModeShift divert), no volatile-settings re-apply on reconnect, no DPI-cycle entry, and empty OS-hook maps when it is the selection. DeviceConfig's derived Default would have created disabled entries from `.or_default()` callers, so it gets a manual impl with enabled: true. The GUI exposes the toggle on the Device tab's configuration card, and the Home gallery ring now shows managed state — accent blue when managed, red when disabled — instead of marking the selection: capture runs per device, so selection only decides what the detail screen shows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N76prZoUHMrHZajXq4jET1
…ift toggle Review follow-up (AprilNEA#419): - refresh_inventory's same-set fast path republished capture plans but not the DPI-cycle map, so a device waking from sleep got its capture session yet silently dropped DPI-cycle / SmartShift-toggle actions until the next full rebuild. Fold both runtime views into one publish_device_runtime() so the pair can't diverge again. - ToggleSmartShift only needs the target route; give DpiCycles a read-only target_for() and drop the unnecessary write lock. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N76prZoUHMrHZajXq4jET1
…decessor Review follow-up (AprilNEA#419): the single-session manager deliberately left a one-tick gap between stopping a session and starting its successor, so the teardown's divert-restore writes could not interleave with the new session's arm writes on the same device (a restore landing after the arm leaves the control un-diverted while the session believes it owns it). The multi-session rewrite lost that gap. Track the keys stopped in the current tick and defer their restart to the next one, per device. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N76prZoUHMrHZajXq4jET1
With gestures off, a single action bound to the dedicated gesture button (CID 0x00c3) has no delivery path: the button never reaches the OS hook, and the HID++ capture session only diverts it with raw-XY while it owns the gesture role. The new tests pin the intended contract — plain-divert the button when its binding leaves the default and it is not the gesture owner — and are ignore-marked until the implementation lands.
create-dmg is a macOS-only package (meta.platforms = darwin), so listing it unconditionally made nixpkgs refuse to evaluate the devenv shell on Linux, breaking direnv entirely. It is only used by xtask's macOS DMG packaging, which always runs on macOS.
The Linux build links a handful of system libraries the dev shell did not carry, so workspace-wide clippy/test only succeeded when the ambient user environment happened to expose them: fontconfig via pkg-config (yeslogic-fontconfig-sys, pulled in by GPUI's text system), libxcb (x11rb in openlogi-hook and GPUI's X11 backend), and libxkbcommon (GPUI keyboard handling). Add them to the Linux package set so the shell is self-contained.
… binding With gestures off (or owned by an OS-hook button), a single action bound to the dedicated gesture button never fired: CID 0x00c3 is invisible to the OS hook, and the capture session only diverted it (with raw-XY) while it owned the gesture role — so the binding had no delivery path at all. This is how an MX Master 4 with gesture_owner = "Off" and GestureButton = "CycleDpiPresets" silently ignored every press. Divert the gesture button as a plain 0x1b04 button (no raw-XY) whenever its binding leaves the default and it does not own the gesture role; its press then dispatches through the existing per-device ButtonPressed path. The swipe accumulator is now gated on the raw-XY divert being armed, so a plain-diverted press cannot also emit a spurious gesture click, and arm_controls guards against a plain write ever stripping an armed raw-XY divert. The RED tests from the previous commit are un-ignored.
Behavior-preserving: replace the boolean should_rearm with a DoneAction enum so the settle-vs-rearm-vs-ignore decision for a session-completion report is one testable function. Groundwork for tracking deliberately stopped sessions until their teardown drains.
The tick-local stopping guard (2cf292c) only defers a replacement by one tick: a session stopped in tick T can still be executing its divert restore when tick T+1 arms the successor, interleaving restore and arm writes on the same device. The window exists for plan changes and for post-pairing recovery alike. Pin the intended contract — a deliberately stopped session stays tracked until its task reports completion, and that report settles the key without an unexpected-exit warning — ignore- marked until the implementation lands.
Review follow-up (AprilNEA#419): the tick-local stopping guard deferred the replacement session by exactly one tick, so a stop whose divert-restore writes outlast TARGET_POLL could still interleave with the successor's arm writes on the same device. Keep a deliberately stopped session in the map — stop sender taken — until its task's completion report arrives, and never arm a tracked key. The report is sent after run_capture_session returns, restore included, so the successor can no longer overlap the teardown regardless of how long it takes; the same holds for the post-pairing recovery path, which stops every session the same way.
set_current_app refreshes the OS-hook maps but not the shared capture plans, whose binding maps and divert sets are baked per-app at build time and read by HID++ dispatch at event time. After an app switch, every diverted button therefore keeps firing the previous app's actions, and a button that should fall back to native in the new app stays diverted. Pin the intended contract — a foreground-app change republishes the plans with the new app's overlay — ignore-marked until the implementation lands.
Review follow-up (AprilNEA#419): set_current_app only rewrote the OS-hook maps, but the capture plans bake the foreground app into their binding maps and divert sets at build time, so HID++ dispatch kept serving the previous app's overlay — a diverted button fired its stale binding and a button due to fall back to native stayed diverted. Route the app switch through publish_device_runtime, the same single publish point the inventory paths use, so the plans (and the watcher sessions diffing them) always reflect the app in front. DPI cycles ride along unchanged: presets are not app-scoped, so the rebuild preserves every live index.
…de default Review follow-up (AprilNEA#419): the per-device slider always stored Some(value), so a device that once touched the slider was pinned forever — even at the default value — and silently stopped following later Settings → General changes. The slider is the device's only sensitivity control, so committing the app-wide default now clears the override (back to following the default) and anything else pins it. The no-op guard compares the stored override rather than the effective value, so dragging an existing override onto the default actually clears it.
Review follow-up (AprilNEA#419): the shared write-reuse slot is a single last-writer-wins cell, so with one session per device a stopping session's unconditional clear could evict the channel a sibling session published after it — silently demoting that device's DPI/SmartShift writes to the fresh-open slow path. Guard the teardown clear with an Arc identity check so a session only removes its own channel.
b57b279 to
9cbf4a5
Compare
Problem
The runtime manages only the GUI-selected device. With more than one mouse connected this produces two real defects:
Design
Capture moves to the HID++ layer per device, where device identity is structural (one channel per device) instead of inferred:
capture_plan(agent-core, new): the orchestrator rebuilds oneDeviceCapturePlanper online device — route, per-device binding maps, divert set, effective thumbwheel sensitivity — on every config / app / inventory change, including pure online-flag flips.openlogi-hid):run_capture_sessiontakes aCaptureSpec. Rebindable standard buttons (middle/back/forward,0x0052/0x0053/0x0056) divert over0x1b04and dispatch asButtonPressed. A button at its default binding is never diverted, so native behavior needs no re-synthesis; the OS-hook gesture owner's button is likewise excluded so hold+swipe keeps its events.Arcand frees itself when the last session exits — pairing semantics unchanged.DeviceConfig.thumbwheel_sensitivityoverrides the app-wide default (which remains the fallback and keeps its Settings → General slider); the SmartShift panel gains a per-device sensitivity slider (existing localized label, no new i18n keys).DpiCyclesreplaces the single cycle state: HID++ dispatch cycles the device an event arrived on, the OS hook (which cannot attribute events) keeps targeting the selection, and a config reload no longer snaps an unchanged device's cycle index back topreset[0].DeviceConfig.enabled(defaulttrue). A disabled device is left fully native — no capture session, no volatile re-apply, no DPI-cycle entry, empty hook maps. The Home gallery ring now shows managed state (accent blue = managed, red = disabled) instead of marking the selection, since capture no longer depends on it.046d) mice, mirroring the macOS hook's vendor awareness.Verification
On real hardware (MX Master 4 + MX Master 3S on two Bolt receivers, Linux/Wayland):
cargo fmt --check,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspaceall pass.HID++-layer changes are platform-neutral; runtime testing was on Linux (the OS-hook change is Linux-only).
Notes
🤖 Generated with Claude Code
https://claude.ai/code/session_01N76prZoUHMrHZajXq4jET1