Skip to content

feat: per-device capture — every online device gets its own session, bindings, and settings - #419

Open
tagawa0525 wants to merge 19 commits into
AprilNEA:masterfrom
tagawa0525:feature/per-device-capture
Open

feat: per-device capture — every online device gets its own session, bindings, and settings#419
tagawa0525 wants to merge 19 commits into
AprilNEA:masterfrom
tagawa0525:feature/per-device-capture

Conversation

@tagawa0525

Copy link
Copy Markdown

Problem

The runtime manages only the GUI-selected device. With more than one mouse connected this produces two real defects:

  1. The non-selected device's settings are dead. Its thumbwheel rebinds, sensitivity, and button captures silently stop applying the moment you switch the carousel to another device.
  2. The Linux evdev hook applies the selected device's profile to every mouse it grabs — including other vendors' mice and virtual pointers (e.g. keyd's). Side-button remaps configured for an MX Master land on whatever other pointing device you plug in.

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 one DeviceCapturePlan per online device — route, per-device binding maps, divert set, effective thumbwheel sensitivity — on every config / app / inventory change, including pure online-flag flips.
  • Plan-driven sessions (openlogi-hid): run_capture_session takes a CaptureSpec. Rebindable standard buttons (middle/back/forward, 0x0052/0x0053/0x0056) divert over 0x1b04 and dispatch as ButtonPressed. 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.
  • Multi-session watcher: sessions live in a map keyed by config key; inputs arrive tagged with their device and dispatch against that device's own plan (per-device thumbwheel accumulators included). The capture-vs-pairing lease is shared across sessions via an Arc and frees itself when the last session exits — pairing semantics unchanged.
  • Per-device settings: DeviceConfig.thumbwheel_sensitivity overrides 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). DpiCycles replaces 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 to preset[0].
  • Per-device manage toggle: DeviceConfig.enabled (default true). 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.
  • Linux hook: grabs only Logitech (VID 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):

  • Both devices hold concurrent capture sessions; side buttons, gesture swipes/click, DPI button, thumbwheel rotation and (3S) single tap all arrive correctly attributed with no OS hook involved, and every diverted control restores on teardown.
  • Independent per-device behavior confirmed live: different thumbwheel directions and sensitivities on each mouse simultaneously, regardless of carousel selection.
  • cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace all 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

Copilot AI review requested due to automatic review settings July 18, 2026 14:56

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@greptile-apps

greptile-apps Bot commented Jul 18, 2026

Copy link
Copy Markdown

Greptile Summary

This PR refactors the runtime from a single-device capture model to a fully per-device one: the orchestrator now builds one DeviceCapturePlan per online device, a multi-session watcher runs one HID++ capture session per device (diffing on plan changes), and DPI cycle state lives in a per-device map (DpiCycles) keyed by config key. It also adds a per-device enabled toggle and a per-device thumbwheel-sensitivity override.

  • Per-device sessions (watchers/gesture.rs): Sessions are keyed by config key; a stopped session stays tracked (with its stop sender taken) until done_rx confirms full teardown, preventing the new session's arm_controls from racing the old session's restore writes.
  • DpiCycles + DpiCycleState preservation: rebuild_dpi_cycles retains each device's live index and capabilities when presets are unchanged, and is now called in both the full rebuild() path and the inventory fast path (!changed online-flag flip) via publish_device_runtime.
  • Linux evdev hook: Filtered to Logitech VID 0x046d, mirroring macOS vendor awareness and avoiding grabbing unrelated pointing devices.

Confidence Score: 5/5

Safe to merge. The multi-session teardown design correctly prevents control-divert races on plan changes, and publish_device_runtime is now called in every orchestrator code path so capture plans and DPI cycles stay in sync for all device lifecycle events.

The overall architecture is sound: per-device CaptureSpec equality gates session restarts, the channel-slot clear guards against evicting a sibling session's channel, and the Logitech-only hook filter fixes the cross-vendor remap defect. The one finding — capture_plans.read() staying alive during dispatch_action for gesture/button events — is a minor read-lock extension, not a correctness bug.

Files Needing Attention: crates/openlogi-agent-core/src/watchers/gesture.rs — the dispatch function's lock-hold pattern is the only area worth a second look.

Important Files Changed

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
Loading

Reviews (9): Last reviewed commit: "fix(hid): only clear the capture-channel..." | Re-trigger Greptile

Comment thread crates/openlogi-agent-core/src/orchestrator.rs
Comment thread crates/openlogi-agent-core/src/hook_runtime.rs Outdated
tagawa0525 added a commit to tagawa0525/OpenLogi that referenced this pull request Jul 18, 2026
…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
@tagawa0525

Copy link
Copy Markdown
Author

Both review findings addressed in 170cfbe:

  • DPI cycles on the inventory fast path: the same-set/fresh-online-flags branch now goes through a single publish_device_runtime() that rewrites the capture plans and the per-device DPI-cycle map together, so a waking device gets both its session and its DPI slot — and the two views can't diverge again by construction.
  • ToggleSmartShift write lock: DpiCycles gained a read-only target_for() (with a unit test covering the explicit-key and selection-fallback paths) and the toggle now takes only a read lock.

tagawa0525 added a commit to tagawa0525/OpenLogi that referenced this pull request Jul 18, 2026
…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
tagawa0525 added a commit to tagawa0525/OpenLogi that referenced this pull request Jul 19, 2026
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.
@tagawa0525

Copy link
Copy Markdown
Author

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 run_capture_session returns, restore writes included — and a tracked key is never re-armed. This covers both plan changes and the post-pairing recovery path, no matter how many ticks the teardown takes.

@tagawa0525

Copy link
Copy Markdown
Author

Fixed in b00d213: set_current_app now routes through publish_device_runtime(), so the capture plans (binding maps and divert sets, both per-app effective) are republished whenever the foreground app changes. Covered by app_switch_republishes_capture_plans (RED in 2985353). DPI cycle state is preserved across the republish since presets are not app-scoped.

tagawa0525 added a commit to tagawa0525/OpenLogi that referenced this pull request Jul 19, 2026
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.
tagawa0525 added a commit to tagawa0525/OpenLogi that referenced this pull request Jul 19, 2026
…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.
@tagawa0525

Copy link
Copy Markdown
Author

On the two P2 notes from the latest review pass:

  • Sensitivity override not clearable to None: fixed in 8065e4c. Committing the per-device slider at the app-wide default now clears the override (the slider is the device's only sensitivity control, so landing on the default is the "no override" gesture), and the no-op guard compares the stored override rather than the effective value so an existing pinned-at-default override actually clears.
  • Forwarding-task drain ordering: keeping as is. Dispatch deliberately resolves every input against the plans in force at dispatch time (same event-time semantics as the OS hook on a config edit), so a buffered input from a stopping session is interpreted exactly as if it had arrived a moment later — reordering done behind the forwarding drain wouldn't change which plan those one or two events hit, and no hardware write is involved.

@tagawa0525

Copy link
Copy Markdown
Author

Fixed in b57b279: the teardown clear is now guarded by an Arc::ptr_eq identity check, so a stopping session only removes its own published channel and never evicts a sibling session's.

tagawa0525 added a commit to tagawa0525/OpenLogi that referenced this pull request Jul 19, 2026
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.
tagawa0525 added a commit to tagawa0525/OpenLogi that referenced this pull request Jul 19, 2026
## 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).
tagawa0525 added a commit to tagawa0525/OpenLogi that referenced this pull request Jul 19, 2026
## 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).
tagawa0525 added a commit to tagawa0525/OpenLogi that referenced this pull request Jul 19, 2026
## 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).
tagawa0525 added a commit to tagawa0525/OpenLogi that referenced this pull request Jul 19, 2026
## 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).
tagawa0525 added a commit to tagawa0525/OpenLogi that referenced this pull request Jul 19, 2026
## 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).
tagawa0525 added a commit to tagawa0525/OpenLogi that referenced this pull request Jul 19, 2026
## 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).
tagawa0525 added a commit to tagawa0525/OpenLogi that referenced this pull request Jul 19, 2026
## 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).
tagawa0525 added a commit to tagawa0525/OpenLogi that referenced this pull request Jul 25, 2026
## 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.
tagawa0525 added a commit to tagawa0525/OpenLogi that referenced this pull request Jul 25, 2026
…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
tagawa0525 added a commit to tagawa0525/OpenLogi that referenced this pull request Jul 25, 2026
…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
tagawa0525 added a commit to tagawa0525/OpenLogi that referenced this pull request Jul 25, 2026
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.
tagawa0525 added a commit to tagawa0525/OpenLogi that referenced this pull request Jul 25, 2026
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.
tagawa0525 added a commit to tagawa0525/OpenLogi that referenced this pull request Jul 25, 2026
…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.
tagawa0525 added a commit to tagawa0525/OpenLogi that referenced this pull request Jul 25, 2026
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.
tagawa0525 added a commit to tagawa0525/OpenLogi that referenced this pull request Jul 25, 2026
## 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).
tagawa0525 added a commit to tagawa0525/OpenLogi that referenced this pull request Jul 25, 2026
## 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).
tagawa0525 added a commit to tagawa0525/OpenLogi that referenced this pull request Jul 25, 2026
## 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).
tagawa0525 and others added 19 commits July 25, 2026 10:51
…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.
@tagawa0525
tagawa0525 force-pushed the feature/per-device-capture branch from b57b279 to 9cbf4a5 Compare July 25, 2026 01:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants