feat: per-pair scroll & mouse-sensitivity controls + scroll/version fixes#435
Open
jondkinney wants to merge 93 commits into
Open
feat: per-pair scroll & mouse-sensitivity controls + scroll/version fixes#435jondkinney wants to merge 93 commits into
jondkinney wants to merge 93 commits into
Conversation
Adds a host-side fallback that releases capture when the user
sweeps the cursor against the host-adjacent edge of the guest
and keeps pushing past a configurable threshold. Solves the
"two locked screens" case where the peer's capture backend
can't fire CaptureBegin (and therefore can't send Leave back),
leaving the host stuck capturing indefinitely until the
release-bind chord is pressed.
Algorithm lives in InputCapture::poll_next so every backend
(macOS, libei, layer-shell, x11, windows, dummy) gets it for
free — they only need to emit standard motion events through
the existing Stream interface, which they already do. The
wrapper tracks:
virtual_pos: signed position along the entry axis, clamped at
0 from below. No upper clamp — the wrapper can't know the
guest's far-edge extent without protocol-level cooperation,
and any proxy is wrong for some user's setup.
wall_pressure: motion that overshoots the host-adjacent edge
and would have driven virtual_pos negative. Fires
CaptureEvent::AutoRelease when the threshold is reached;
the capture loop then runs the same teardown path as the
release-bind chord.
State resets on Begin (entry to capture), AutoRelease (we
self-released), and external release (chord, peer Leave,
connection error, EnterOnly fallback).
Surface:
- New FrontendRequest::SetReleaseThreshold + FrontendEvent::
ReleaseThreshold IPC pair.
- New release_threshold_px field on the daemon config (0 = off,
serialized to config.toml).
- New AdwPreferencesGroup with a 0–500px slider in the GTK
window. Default 0 (disabled) so existing users see no
behavior change until they opt in.
- New CaptureEvent::AutoRelease variant + handling in
src/capture.rs's handle_capture_event (short-circuit to
release_capture, which already synthesizes key-ups and sends
Leave to the peer).
Known limitation: the wrapper has no way to know where the
guest's cursor actually is (the guest doesn't tell us). On
re-entry into a peer mid-session, virtual_pos resets to 0 but
the guest's cursor may still be in the middle of its screen
from the prior session, causing the threshold to fire from
the wrong reference point. A protocol-level Bounds event +
cursor-warp on Enter is needed for full correctness.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a new ProtoEvent variant carrying the receiving device's display geometry (in pixels). Sent by the emulation side right after acknowledging an Enter so the capturing peer can model the guest cursor's position along the entry axis. Wire format: 1-byte EventType discriminator (Bounds = 11) followed by big-endian u32 width and big-endian u32 height — 9 bytes total, well under MAX_EVENT_SIZE (21). This commit only adds the protocol wiring. Senders and the host-side cache come in subsequent commits. Old peers that don't recognize EventType=11 will skip the datagram per the forward-compat fix in the previous commit, so deployment is incremental: the emulation side can start sending Bounds without breaking older capturing peers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add `display_bounds(pos)` and `warp_cursor(pos)` to the
InputEmulation trait and implement them across every backend:
- macOS: CGDisplay APIs for bounds, CGWarpMouseCursorPosition for warp
- x11: RandR for bounds, XWarpPointer for warp
- wlroots: wl_output extents + virtual_pointer.motion_absolute
- libei: region walking + ei_pointer.emit_motion_absolute
- Windows: GetSystemMetrics + SetCursorPos
- xdg_desktop_portal: no-op fallback (the protocol exposes neither
bounds nor a warp primitive)
These are the prerequisites for the protocol-based wall-press
auto-release: emulation hosts now have a common API to report their
display extents to peers and to warp the cursor on Enter so the
host's modeled virtual_pos = 0 matches the guest's actual cursor.
Wire the new emulation-side capabilities into the daemon's
listener task. When a peer's Enter arrives:
1. Reply Ack (existing behavior).
2. Reply Bounds(width, height) using the cached display
geometry from the active emulation backend.
3. Warp the local cursor to the entry edge of the displayed
position (0 for Left, width-1 for Right, etc., centered
along the orthogonal axis).
The warp is the structural fix for the "cursor jumps back to
where it was" symptom: previously, on re-entry into a peer
mid-session, the cursor stayed wherever the prior capture
session left it, breaking the host's wall-press model
(virtual_pos=0 in the host's mind didn't match the guest's
actual cursor column). With the warp, the host's model is
synchronized with the guest's reality on every Enter.
EmulationProxy gains:
- Cached display_bounds (Rc<Cell<Option<(u32, u32)>>>),
refreshed each time the underlying InputEmulation is
(re)created. Read by the listener task.
- warp_cursor(x, y) fire-and-forget. Drops if emulation
isn't currently active (no live backend to receive it).
ProxyRequest::Warp(x, y) carries the request to EmulationTask,
which dispatches to InputEmulation::warp_cursor.
If the active backend doesn't implement display_bounds — every
non-macOS backend right now — the listener skips the Bounds
reply and the warp call. The capturing peer falls back to its
existing "no upper clamp / virtual_pos = 0 on Begin" heuristic,
which is degraded but functional. Adding display_bounds /
warp_cursor to other backends unlocks correct behavior
incrementally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
InputCapture now keeps a per-position HashMap of peer display
geometry, populated when ProtoEvent::Bounds arrives from the
peer (handled in src/capture.rs's recv arm). track_wall_press
uses the cached entry-axis extent as the upper clamp for
virtual_pos:
self.virtual_pos = proposed.clamp(0.0, peer_extent);
Eliminates the runaway-virtual_pos bug from the heuristic
fallback: when the user obliviously over-pushes their physical
mouse past the guest's actual far edge, the modeled position
clamps at the real width instead of climbing fictionally to
infinity. Now the user's "walk back" cost is bounded by the
guest's actual screen width.
When the peer hasn't sent Bounds yet (older peer running
without the protocol extension, or in the brief pre-Ack
window of a fresh connection), peer_extent returns INFINITY
and the model degrades to the prior heuristic.
Cache lifecycle:
- Insert on ProtoEvent::Bounds.
- Drop on CaptureRequest::Destroy(handle) so re-adding the
same peer later starts fresh.
Combined with the previous commit (emulation warps cursor on
Enter), the host's virtual_pos = 0 at Begin now matches the
guest's actual cursor at column 0 (or width-1, etc.) on every
re-entry. The "cursor was in the middle, 200px back fires
release prematurely" bug is fixed structurally rather than
papered over.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The label "Auto-Release" reads as a global app preference; the description's "forwarded mouse capture" was ambiguous about which machine does the forwarding. Rename the group to "Outgoing Auto-Release" so the scope mirrors the surrounding "Outgoing Connections" / "Incoming Connections" groups, and lead the description with "When this machine is capturing input for a peer …" so a user scanning the window can tell at a glance that this setting only matters when the local machine is the host.
GtkScale's default behavior treats a vertical scroll event as +/- increment, which means the threshold creeps any time the user is scrolling the window and the cursor passes over the slider — easy to do given the slider sits in the middle of the preferences pane. Add an EventControllerScroll to the slider in CAPTURE phase that returns Propagation::Stop unconditionally. The scale's own scroll controller never sees the event, so the value doesn't change. Trade-off: scrolling doesn't pass through to the parent GtkScrolledWindow while the cursor is on the slider — the wheel becomes inert there. Acceptable: prior behavior was actively destructive (silent state corruption); this is just "no scroll in this small region." If users start complaining about the gap, the next step is to forward dy to the ancestor scrolled window's vadjustment manually before returning Stop. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Old text described the mechanism ("releases capture automatically once
the cursor pushes past the host-adjacent edge") without explaining
when the user would actually need it. With the new peer-Leave deadline
gate (34605a7), wall-press only fires when the peer can't deliver a
Leave — i.e. when the peer's screen is locked or its capture backend
is otherwise suppressed. New text leads with that framing and trims
two sentences to two.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Capture-phase scroll handler used to return Propagation::Stop to suppress GtkScale's default scroll-to-adjust behavior, but Stop also killed propagation to the parent — so the main window wouldn't scroll when the cursor was over the slider. Frustrating because the slider sits in the middle of the preferences pane and "I just want to scroll past this" is the common interaction. Same capture-phase handler now walks up to the ancestor ScrolledWindow and bumps its vadjustment by `dy * step_increment` (or 40px when step_increment is unset). Mimics what native scroll passthrough would have done — slider value stays fixed, parent scrolls smoothly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Windows clippy flags `loop { let Some(...) = get_msg() else { break } }`
as while-let-loop. Rewrite to `while let Some(msg) = get_msg() { … }`.
The inner `break` for `RequestType::Exit` still breaks the surrounding
while-let, so semantics are unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the Proxy trait import needed by the wlroots backend's `output.id()` call (introduced when the emulation side started binding wl_output for display_bounds), and applies cargo fmt for this split's own files.
This was referenced May 6, 2026
`test windows-latest`'s only failure was the post-job cache-save step in `Swatinem/rust-cache@v2` (job step 27); `cargo test` itself (step 12) succeeded with all tests passing. CI rerun on upstream requires admin rights, so an empty commit is the only way to retrigger from this side.
Before: when crossing machines, the guest's cursor jumped to the
midpoint of the entry edge — a ~100 px Y-jump on typical
displays — because the guest snapped to a hardcoded
(0, h/2) / (w/2, 0) point on Enter. Visually discontinuous and
hard to follow when the user is mid-task.
After: the host's capture backend snapshots the screen-space cursor
position at the instant of the edge crossing (CGEvent.location()
on macOS — the only backend that can report this today; others
emit None and the guest falls back to the prior midpoint warp).
The capture loop scales those host coords against the cached peer
geometry and sends them as a new ProtoEvent::MotionAbsolute right
after Enter. The guest handles MotionAbsolute by warping the
cursor to (x, y), overriding the entry-edge midpoint so the user
sees visual continuity across the boundary.
Layered choices:
- New ProtoEvent::MotionAbsolute { x, y } primitive rather than
bolting an offset onto Enter — gives a reusable
position-setting building block for future features (snap to
point on app launch, multi-monitor handoff, follow-host-cursor
modes) without inventing more event variants.
- Pixel coordinates in the receiver's screen space, not normalized
floats — host already caches peer bounds (Bounds proto event)
for the wall-press upper clamp, so it can do the scaling and
the guest just calls warp_cursor directly. Guest's
warp_cursor primitive already takes pixels.
- Backwards compatibility: peers running the previous protocol
don't recognize MotionAbsolute and skip it via the forward-
compat decode-tolerance fix from earlier in this branch. Old
hosts paired with new guests fall through to the entry-edge
midpoint (current behavior); new hosts paired with old guests
ignore MotionAbsolute and the cursor stays at the edge midpoint
too — neither pair regresses.
Capture backend coverage in this commit: macOS only (the
CGEventTap callback has cg_ev.location() at the moment of edge
crossing). Other backends (libei, x11, layer_shell, windows,
dummy) emit Begin { cursor: None } and don't send MotionAbsolute,
so the guest falls back to the midpoint warp on Enter. Adding
cursor-position reporting to those backends is a per-backend
follow-up.
InputCapture trait grew display_bounds() (default impl returns
None; macOS implements via CGDisplay::active_displays) and a
peer_warp_target(pos, cursor) helper that combines the host's
own bounds, the cached peer bounds, and the cursor position into
a target point on the peer's screen. peer_warp_target returns
None when either bounds is unavailable, in which case the capture
loop just doesn't emit MotionAbsolute.
The cross-axis cursor preservation introduced in 6c1bd88 was macOS-only; the layer-shell capture backend (Wayland/Hyprland and similar wlroots compositors) emitted Begin { cursor: None }, so transitions where Linux was the host fell back to the entry-edge midpoint warp on the guest — the same 300–400 px Y-jump the macOS path was fixed to avoid. Read surface_x / surface_y from wl_pointer::Enter and translate to compositor screen-space using the layer-surface's anchor edge: surfaces here are 1 px on the on-axis dimension and span the cross-axis, so the surface-local cross-axis coord is the screen offset directly. To support multi-output setups, store the output's compositor position+size on the Window when it's created, and add a display_bounds() override that returns the union rectangle of all active outputs (mirrors the macOS impl so MotionAbsolute scaling stays consistent). Effect: Linux→peer transitions where Linux is the source now preserve cross-axis cursor position the same way macOS→peer transitions already do. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Counterpart to 6c1bd88's Enter-time cross-axis preservation. When the host releases capture (release-bind chord, auto-release threshold, peer destroyed), the visible cursor reappears at whatever point capture started — typically the entry-edge midpoint or wherever the guest chose to warp to. The user perceives this as a 100–400 px Y-jump even though Mac→Linux→Mac round-trip "should" feel continuous, because nothing in the release path tells the host where the guest's cursor visually was at the moment of release. Track a virtual_cursor (f64, f64) in the wrapper that mirrors the guest's screen-space cursor: seeded on Begin from the peer_warp_target / entry-edge midpoint (whatever the guest will actually do on Enter), accumulated against every Motion event we forward, clamped to peer bounds. On release, project it back to host screen-space with host_warp_target_on_release — symmetric inverse of peer_warp_target — and pass that as a new Option<(i32, i32)> parameter on the Capture::release trait method. macOS threads the target through ProducerEvent::Release and warps before show_cursor() so the visible cursor reappears at the matching host point. Other backends ignore the parameter (they don't hide/manage the system cursor on the way out). This is a no-op when peer_bounds or display_bounds is unavailable — fallback is the previous behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Self-sufficient counterpart to MotionAbsolute. Carries the host's cursor as a normalized fraction (0..1) of the host's own screen plus the entry side from the receiver's frame. The receiver scales nx/ny against its own display bounds and pins the on-axis dimension to the matching edge. The point: MotionAbsolute requires the host to know the peer's geometry (cached via a prior `Bounds` event), which doesn't exist on the very first crossing — `Bounds` is only sent in response to `Enter`, so the host can't include MotionAbsolute on the same crossing that asks for the bounds it needs. CursorPos sidesteps the round-trip dependency entirely; the receiver does the scaling locally with its own bounds. Wire format adds f32 codec impl alongside existing u8/u32/i32/f64. Old peers don't know the new EventType tag and skip the event via the proto forward-compat decode-tolerance path; they continue to warp to the entry-edge midpoint as before. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion to peer_warp_target for the bounds-free CursorPos path. Normalizes the host's screen-space cursor against the host's own display bounds — no peer geometry consulted, so a return value of Some is independent of whether the peer has sent Bounds yet. The capture loop will emit this fraction as ProtoEvent::CursorPos right after Enter so the guest can warp on the very first crossing instead of falling through to the entry-edge midpoint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After Enter, also send a ProtoEvent::CursorPos carrying the host's cursor as a normalized fraction of the host's own screen and the entry side. Both events are emitted; the receiver tolerates either and the second warp wins. Why both: MotionAbsolute is more precise when peer_bounds is already cached (uses peer pixel coords directly), but it can't fire on the first crossing because the cache is populated by Bounds — which only arrives in response to Enter. CursorPos has no such dependency: it's self-contained, so the first crossing warps correctly. Emitting both keeps backwards-compat with old guests that only know MotionAbsolute (they get correct warps from the second crossing onward, same as before) while letting new guests warp on every crossing including the first. Old guests skip the unknown CursorPos tag via the proto forward- compat decode-tolerance path. Order on the wire is MotionAbsolute then CursorPos so a new guest that handles both ends up with the CursorPos warp — accurate against the peer's *current* display bounds, robust to mid-session display reconfiguration on the receiver. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Receiver-side counterpart to the capture change: scale the host-normalized fraction by our own display bounds, pin the on-axis dimension to the entry edge matching the side the host is on, and warp the local cursor. This is what makes the very first arch->macOS crossing seamless. The previous code path required arch (host) to have cached macOS (peer) bounds before sending MotionAbsolute, but those bounds arrive in response to Enter — too late for the same crossing. CursorPos lets the receiver do the scaling itself, so no prior round-trip is needed. Falls through silently when display_bounds is unavailable on the receiver (rare; occurs for backends without a geometry-query trait impl). In that case the entry-edge midpoint warp from the preceding Enter remains the user-visible result, same as before this change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both modals previously hardcoded 560 px which clipped on narrow
parent windows — particularly when the user had tiled the main
window into a narrow split under Hyprland and similar tilers, in
which case the popup ran past the parent's right edge or got
clipped by the compositor.
Wire popup_w = clamp(parent_w − 40, 280, 460):
* Lower the XML default-width to 460 (the new cap) and
width-request to 280 (the new floor) on both popups.
* In open_fingerprint_dialog and request_authorization, read
the parent's allocated width and call set_default_width()
with the clamped value before present(). When the parent's
width isn't yet known (very first launch, hidden window),
fall through to the XML default.
The 40 px gap is enough that the popup visibly nests inside the
parent rather than feeling like a same-sized overlay, but doesn't
crowd the modal's own content. Uses the XML width-request as the
hard minimum so the runtime clamp can't accidentally shrink the
popup below a usable size when the parent is itself very narrow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Several small fixes that compound into the popup actually feeling
like it has 12 px of space at the top and bottom rather than the
~50 px that the previous scaffolding produced:
* resizable=False on the AdwWindow so the modal sizes to its
natural content height instead of inheriting a larger
default-height that left empty space below the buttons.
* Drop the explicit height-request and default-height now that
the modal is autosizing — they were both forcing a 320–380 px
minimum that was usually larger than content.
* Add the `flat` style class to the AdwHeaderBar to remove the
libadwaita-default shadow/separator below it, which read as
extra padding under the title.
* valign=start on the AdwClamp so the inner box pins to the
top of the content area on compositors that don't fully honor
resizable=False (Hyprland's mod+drag, etc.). Without this the
AdwClamp was vertically centering its child and putting empty
space both above and below.
* margin-top: 12 on the Description field group so there's a
visual divider between the explanatory body labels and the
field section. SHA-256 group keeps the standard 18 px parent
spacing — both groups read as siblings within the same
section rather than the start of a new one.
Applied symmetrically to fingerprint_window.ui and the
restructured authorization_window.ui.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire an EventControllerKey on each modal that swallows Escape and calls close(). Catches Escape regardless of which child has focus (entry, button, or window itself) so the user doesn't have to fish for the X button or click outside. Uses GTK 4.0+ EventControllerKey with Propagation::Stop on the match path so the key event doesn't propagate further; non-Escape keys continue with Propagation::Proceed and reach their normal handlers (typing into the entries, button accelerators, etc). AuthorizationWindow gains a constructed() override since it didn't previously need one — the controller has to be added on each instance, not the class. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CGEventPost-synthesized events are processed correctly by macOS (focused apps see them) but DO NOT trigger display wake — the kernel power-manager only treats real USB/Bluetooth HID interrupts as wake-worthy. Symptom: when the Mac is the guest and its display has gone to sleep, input forwarded from the host arrives but the screen stays black until the user physically taps the Mac's keyboard. Fix: call `IOPMAssertionDeclareUserActivity(reason, kIOPMUserActiveLocal, &id)` from IOKit at the top of `consume()`. This is Apple's documented "treat this as real user input for power-management purposes" signal — it wakes the display and resets the idle timer. The system coalesces calls within a 5-second window (returns the same IOPMAssertionID), so calling on every event is essentially free; we stash the most recent ID in a `Cell` and pass it back as in/out so the system can do the right thing. Verified: with macOS as guest and the Mac's display asleep, a single mouse click forwarded from the Linux host now wakes the display. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes the accidentally-tracked `.claude/scheduled_tasks.lock` introduced one commit ago and prevents future agent-runtime state from being committed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CGDisplayRegisterReconfigurationCallback covers monitor plug/unplug and resolution changes during normal operation, but in clamshell- disconnect → lid-open transitions the callback either doesn't fire (no actual reconfigure event reaches us during sleep) or fires too early to be useful, leaving lan-mouse's cached `self.bounds` matching whatever the display layout was when the lid closed. Symptom: after opening the lid on a Mac that had been clamshelled to an external monitor and then disconnected, the cursor behaves as if constrained to the now-absent external display's resolution. Subscribe to the IOKit power-management notifications via `IORegisterForSystemPower` and attach the resulting CFRunLoopSource to the same run loop that owns the event tap. On `kIOMessageSystemHasPoweredOn` (post-wake), post the same `ProducerEvent::DisplayReconfigured` the existing handler consumes, so `update_bounds()` runs against the live display set. Sleep-pending messages get acked with `IOAllowPowerChange` so we don't stall the kernel's 30-second client-wait timeout. Cleanup paths added: deregister + destroy notification port + drop the boxed refcon when the run loop exits.
Pure formatting follow-ups from a code-review pass: - input-emulation/macos.rs: move CoreFoundation imports up next to their core_graphics/core-foundation siblings, and let rustfmt collapse the IOPMAssertionDeclareUserActivity unsafe-block call onto a single line. - lan-mouse-gtk/client_row.rs: rustfmt collapse of the port-title notify_local closure. No behavior change.
The Lan Mouse window previously couldn't scroll its preference groups when the window height was reduced below the natural content height — content was simply clipped, with no way to reach the lower groups. AdwStatusPage doesn't include built-in scrolling. Wrap the AdwStatusPage in a GtkScrolledWindow inside the existing AdwToastOverlay, with vertical scroll on demand and horizontal scroll disabled (we use AdwClamp for horizontal sizing). propagate-natural-height keeps the window's preferred size identical when content fits, so existing layout behavior on tall windows is unchanged. Effect: when the user resizes the window shorter than the natural content height (or has a small display), all preference groups remain reachable via vertical scroll. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hyprland (and most other wl_pointer consumers — Sway, GNOME Shell
on Wayland, etc.) silently drop a continuous-scroll axis event
that arrives without a preceding `axis_source` call in the same
frame. The handler for `PointerEvent::AxisDiscrete120` was already
calling `axis_source(AxisSource::Wheel)`, which is why mouse-wheel
scrolling worked. The handler for `PointerEvent::Axis` (the
continuous-scroll path used by macOS trackpad gestures) wasn't
calling axis_source at all, so the events were forwarded onto the
wire, received by the wlr-virtual-pointer device, and then dropped
by the compositor.
Symptoms: the Mac trace log shows correct `Axis { time: 0, axis: 0,
value: N.M }` events flowing toward the Linux peer; the Arch peer
log even shows them arriving; but no actual scrolling happens in
any window. Mouse-wheel scrolling works fine in the same session.
Fix: emit `axis_source(AxisSource::Finger)` alongside the
`axis()` call. Finger is the appropriate source for trackpad-
originated continuous scroll, which is the typical case for
Lan Mouse forwarding from a Mac. Also switch from the
upstream-supplied `time: 0` to the local `now` timestamp — some
compositors filter zero-time events as well; the AxisDiscrete120
path was already doing this.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wayland virtual-pointer events bypass libinput entirely on every mainstream compositor (Hyprland, Sway, GNOME-Shell-Wayland, KDE Plasma 6, etc.), so the user's compositor-level natural_scroll setting doesn't affect events forwarded by Lan Mouse — only their physical input. The result is asymmetric scroll direction: physical trackpad on the receiver scrolls naturally, Mac trackpad forwarded through Lan Mouse scrolls inverted. Add a per-receiver natural-scroll toggle in Lan Mouse itself, applied pre-injection in the emulation layer. Each peer's preference is independent; doesn't depend on the sender or any compositor config. Surface: - New `natural_scroll: Option<bool>` on the daemon config (default false, persisted to config.toml). - New FrontendRequest::SetNaturalScroll + FrontendEvent:: NaturalScroll IPC pair. - New "Scroll" preferences group in the GTK window with a GtkSwitch; signal-blocked when daemon-driven Sync pushes the initial value. Backend implementations: each Emulation backend stores the bool, applies it as a sign flip on PointerEvent::Axis and PointerEvent::AxisDiscrete120 values before injection. macOS, wlroots, libei, x11, windows, and xdg-desktop-portal are all covered. Trait method `set_natural_scroll(bool)` with a no-op default; concrete backends override. Plumbing mirrors the auto-release threshold pattern: Service dispatches FrontendRequest, persists to config, pushes through Emulation → EmulationProxy → EmulationTask cache → InputEmulation backend. EmulationTask caches the value and re-applies it whenever a backend respawns (e.g. portal session restart) so the user's setting survives transient backend recreation. Also: `ui: wrap window content in GtkScrolledWindow` (separate prerequisite commit) was needed so the new Scroll group remains reachable when the window height is reduced. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Forwarded scroll events go to receivers that may apply their own
natural-vs-classic preference (libinput's natural_scroll, the
receiver-side `natural_scroll` toggle, etc.), so the wire format
needs a single, predictable convention. We pick classic — the
historical baseline that everything else inverts away from.
macOS's Natural Scrolling preference pre-flips POINT_DELTA at the
OS layer; CGEventTap at Session placement sees events after that
flip:
Natural ON: POINT_DELTA already away from classic → pass through
(sign = +1) so the wire lands on classic.
Natural OFF: POINT_DELTA reads as natural at the wl_pointer layer
→ flip once (sign = -1) so the wire lands on classic.
Net result: wire is consistently classic-feel regardless of the
Mac's preference. Receivers re-invert via their own toggle.
Read `com.apple.swipescrolldirection` from CFPreferences (the modern
macOS default is `true` — Natural Scrolling on) to know which
branch to take. CFPreferencesCopyAppValue is cached by the
framework, so reading per-event is cheap; the user can toggle the
preference at runtime and the next event uses the updated sign
without restart.
ui: trim Scroll group description and row subtitle
The Scroll preferences group's description repeated implementation
details ("Mirrors the libinput natural_scroll preference for
forwarded events, which bypass libinput on Wayland.") that don't
help a user understand what the toggle does. Drop the description
entirely; the group title "Scroll" plus the row's own labels are
sufficient.
Also tightens the row subtitle from "invert the direction of
forwarded scroll events to match a natural-scroll setup" to
"invert the direction of forwarded scroll events" — the row title
"Natural scrolling" already conveys the "natural-scroll setup"
context.
The Scroll group's natural-scrolling toggle only affects forwarded
scroll events received from peers — i.e., it's relevant when this
device acts as a receiver of input, not a sender. Placing it
between Auto-release (a sender-side feature) and Connections
(outgoing) made it look like a host-side setting, which it isn't.
Move the group below the Incoming Connections list so its position
in the page matches its semantic context. Add a one-line
description ("Applies to scroll events received from peers above.")
that points at the Incoming Connections list directly above it,
disambiguating without resorting to the libinput / Wayland jargon
the earlier description had.
Net effect: a user with no incoming peers configured sees the
toggle but understands it's for the receiving role; a user with
peers configured sees it right next to the list of those peers
and gets the connection naturally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously LINES_PER_STEP=3 meant one macOS DELTA line was sent over the wire as 40 v120 units (1/3 of a full tick). Receivers using the wl_pointer discrete count saw `value / 120 = 0` for a single notch, so apps that key off discrete steps (Slack via XWayland, Alacritty) required 3+ notches before any scroll registered. Apps that consume the continuous f64 component (ghostty's smooth-scroll path) felt fine because they got 5.0px regardless. macOS already amplifies SCROLL_WHEEL_EVENT_DELTA based on wheel velocity — slow notch reports DELTA=1, fast flick reports DELTA=10+ per event — so we don't need our own multiplier. Map one macOS line to one full v120 tick (120 units). After the fix, a slow notch arrives as wire_value=120, discrete=1, and registers everywhere. Flicks become correspondingly more aggressive, matching the feel of the MagSpeed flywheel on the Mac itself. Diagnostic logs (`[SCROLL-DEBUG capture]` in macos.rs, `[SCROLL-DEBUG emit ...]` in wlroots.rs) are bundled here for one e2e cycle so the Mac-side raw DELTA/POINT_DELTA values can be inspected after rebuild. A follow-up commit reverts them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the [SCROLL-DEBUG ...] log statements added in 19d36bf and restores the wlroots `axis_discrete` continuous-value divisor from the experimental `value / 4` back to `value / 8`. The wev capture during diagnosis confirmed Hyprland forwards `axis_value120 = 120` per click intact, so terminals consume the v120 path and ignore the continuous fallback — the divisor doesn't affect terminal feel and `/ 8` (15 px per tick) sits closer to libinput's ~10 px convention for the rare app that does use the continuous value. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…gerprints
Replaces the bare `description` value in `authorized_fingerprints`
with `IncomingPeerConfig { description, natural_scroll,
mouse_sensitivity }`. Each row in the receive-side authorization
registry now carries its own scroll-direction and motion-sensitivity
preferences, keyed by TLS certificate fingerprint — the only stable
cross-session identity for an inbound peer.
Backwards-compat: legacy configs that store a bare string per
fingerprint deserialize via a custom `Deserialize` impl on
`IncomingPeerConfig` that accepts either shape (untagged enum) and
fills missing fields with defaults (`natural_scroll: false`,
`mouse_sensitivity: 1.0`). No migration tooling needed; existing
config.toml files keep working.
Wires the schema through:
- lan-mouse-ipc: new `IncomingPeerConfig` type;
`AuthorizedUpdated(HashMap<String, IncomingPeerConfig>)`;
`SetIncomingPeerNaturalScroll(fingerprint, bool)` and
`SetIncomingPeerSensitivity(fingerprint, f64)` requests.
- src/config.rs + src/listen.rs + src/service.rs: type updates
through the `Arc<RwLock<HashMap<...>>>` shared with the DTLS
listener, plus per-fingerprint mutators in Service.
- lan-mouse-gtk: `KeyObject` carries the new properties; window
re-emits them on `AuthorizedUpdated`.
Receive-side application of the new settings (looking up by
fingerprint at handle creation, pushing to InputEmulation) and the
expandable per-row GTK controls land in follow-up commits to keep
the diff focused. The deprecated global `SetNaturalScroll` /
`NaturalScroll` IPC variants stay in place until the global UI
toggle is removed in the cleanup commit.
`FrontendRequest` loses its `Eq`/`PartialEq` derives because `f64`
isn't `Eq`. No callers were comparing requests for equality.
Sensitivity-multiplier algorithm pattern in InputEmulation::consume
(applied in a follow-up commit) is borrowed from feschber#347 by Raidon
Chrome / NeoTheFox, where the global form was originally proposed.
Co-Authored-By: Raidon Chrome <soniczerops@gmail.com>
…gerprint Centralizes scroll-direction inversion and motion-sensitivity scaling into a single transform site in `InputEmulation::consume`, replacing the per-backend `set_natural_scroll(bool)` plumbing introduced earlier in this PR. The platform emulation backends (macos, wlroots, libei, x11, windows, xdg_desktop_portal) go back to platform- mechanics-only — no scroll-sign awareness, no shadow per-backend state, no duplicated transform code. The active settings are stored per-`EmulationHandle` on `InputEmulation` itself and applied by mutating the event before it reaches the backend's `consume()`. The match-arm pattern (multiplier on `Motion`, sign-flip on `Axis` / `AxisDiscrete120`) follows the shape used in feschber#347 by Raidon Chrome, ported to per-handle. Plumbing on the receive side: - `lan-mouse-ipc` exposes `IncomingPeerConfig` (commit 1). - `src/emulation.rs::ListenTask` maintains a small cache of `addr → fingerprint` (populated from `ListenEvent::Accept`) plus the latest `incoming_peers` snapshot pushed by Service. - On Accept, ListenTask resolves the right `ReceivePostProcessing` for the addr and forwards it via `ProxyRequest::SetPostProcessing` so EmulationTask can attach it to the handle the moment one is assigned. - On `EmulationRequest::SetIncomingPeers` (Service push triggered by user UI changes / authorize / remove / config reload), ListenTask re-resolves every known addr and re-publishes to keep currently-active sessions in sync. - `EmulationTask` caches `addr → ReceivePostProcessing` so a backend respawn (CGEventTap timeout, portal session restart) re-applies the right values to all known handles. `input-emulation` stays decoupled from `lan-mouse-ipc`; the conversion from `IncomingPeerConfig` → `ReceivePostProcessing` happens at the boundary in `src/emulation.rs` so the lower crate doesn't grow IPC dependencies. The deprecated global `FrontendRequest::SetNaturalScroll` / `FrontendEvent::NaturalScroll` IPC variants stay round-tripping through Service (persist + echo only, no emulation effect) so the existing GUI toggle continues to render its state until the cleanup commit removes the global UI surface. Co-Authored-By: Raidon Chrome <soniczerops@gmail.com>
…Connections Each authorized peer's row in the Incoming Connections list is now an `AdwExpanderRow` instead of a flat `AdwActionRow`. Expanding a row reveals two new controls scoped to that peer: - Natural Scrolling toggle (`GtkSwitch`). - Mouse Sensitivity (`GtkSpinButton`, range 0.1–5.0, step 0.1, default 1.0). `AdwSpinRow` would be cleaner but requires libadwaita `v1_4`; this codebase pins `v1_1`, so the row is composed manually as `AdwActionRow` + `GtkSpinButton`. Wiring follows the existing per-client signal pattern: the row emits `request-natural-scroll-change(bool)` and `request-sensitivity-change(f64)`, the parent window resolves the peer's fingerprint via the row index and dispatches `FrontendRequest::SetIncomingPeerNaturalScroll` / `SetIncomingPeerSensitivity`. Daemon-driven `AuthorizedUpdated` events repopulate `KeyObject`'s new properties, which `KeyRow::bind` syncs into the widgets with signal-blocked initial state to avoid ping-pong. The global Scroll preferences group at the bottom of the window becomes vestigial here and is removed in the cleanup commit.
The global Scroll preferences group, the `FrontendRequest::SetNaturalScroll` / `FrontendEvent::NaturalScroll` IPC pair, the `Config::natural_scroll` accessors, and the matching window-level `set_natural_scroll` / `request_natural_scroll` were left in place during the per-pair refactor so the existing GUI toggle kept rendering its state mid-series. They have no functional effect now that scroll-direction is keyed per-incoming-peer in `authorized_fingerprints`, so this commit drops them outright. Net effect: the bottom of the window loses the redundant Scroll group; users tune scroll direction per-peer by expanding the matching row in Incoming Connections.
…ttings Inner `AdwActionRow`s default to activatable, so a click anywhere on the row body bubbles up to AdwExpanderRow and collapses it — even when the user is reaching for the GtkSwitch or GtkSpinButton inside. Mark the two settings rows `activatable="false"`. The actual widgets (Switch, SpinButton) still receive their own clicks because they're focusable on their own; the row-level click target is what's suppressed.
The original wording ("Sign-invert scroll deltas forwarded from
this peer before injection", "Linear multiplier applied to motion
deltas...") was implementation-flavored. Use the same plain phrasing
that the old global Scroll group had for natural-scrolling, and a
matching tone for sensitivity.
Five tightly-coupled UX changes on the per-incoming-peer row: 1. **Delete button moves into the expansion**, mirroring ClientRow's destructive-action layout. The new "Revoke Authorization" row at the bottom of the expanded body uses the same red-trash treatment as the outgoing-client delete row, so accidental clicks while scanning the list can't revoke a peer. 2. **Compact summary in the title-row suffix.** A small dim `Natural · 1.5×` label appears next to the title only when one or both settings differ from default. A freshly-authorized peer's row stays uncluttered; users tuning a peer can see at a glance what they've changed without expanding the row. 3. **Subtitle now reflects connection identity**, not the noisy fingerprint. New `last_addr` and `last_hostname` fields on `IncomingPeerConfig` are updated on `EmulationEvent::Connected`: `last_addr` from the connecting `SocketAddr`, `last_hostname` from a reverse-lookup against the existing mDNS `PrimaryCache` (`hostname → primary_ip`). Both persist to disk so the row stays identifiable across daemon restarts and while peers are offline. Subtitle renders as `mac-mini (192.168.1.42)`, `192.168.1.42`, `mac-mini`, or `(not yet connected)` depending on what's known. 4. **Fingerprint moves to a dedicated row inside the expansion** with a copy-to-clipboard button. The hash is selectable text in the row's subtitle, so users can still grab it manually if they prefer. 5. **In-place diff in `Window::set_authorized_keys`**, replacing the previous remove-all + rebuild loop. Without this, every `AuthorizedUpdated` round-trip (which now fires on every per-peer setting change too) would collapse every expanded row in the list. KeyRow listens to property-notify on the bound KeyObject so widget state tracks the in-place mutations without ping-ponging back as fresh user requests. `KeyObject::new` now takes `(fingerprint, IncomingPeerConfig)` instead of four positional fields — fewer call sites get a noisy update when fields are added.
`ProxyRequest::Remove(addr)` was dropping the cached `post_processing[addr]` entry alongside the EmulationHandle. That's wrong — `Remove` fires on every `ProtoEvent::Leave` (cursor crossing back to the peer's screen) and on the 1-second heartbeat-timeout sweep, neither of which means the DTLS session is gone for good. The same SocketAddr keeps delivering Input events on the next cross-in, but the freshly-minted handle would start with default post-processing because the cache had been wiped. The user's per-pair scroll/sensitivity values appeared to take effect only on the first cross of a session, then silently revert. Repro: cross Mac → Linux (settings applied), cross back to Mac, cross to Linux again (settings reverted to passthrough), nudge any setting via the GUI to re-push it. Fix: keep `post_processing` populated across `Remove`. The new- handle path on first Input from the addr already looks the cache up and re-applies, so settings now follow the SocketAddr instead of the ephemeral handle. A genuine DTLS disconnect followed by a reconnect arrives with a new ephemeral source port, so a stale entry doesn't shadow a fresh one.
The wall-press auto-release model accumulates "wall pressure" by
projecting captured motion deltas onto the entry-axis edge. With
the receive-side `mouse_sensitivity` multiplier from the per-peer
post-processing, the receiver's actual cursor moves at
`raw_delta * sensitivity` while the host's model still sees the
raw delta — for sensitivity < 1.0 the model overruns reality and
fires AutoRelease before the receiver hits the wall, looking to the
user like the cursor crosses back early.
Repro: configure a peer's Mouse Sensitivity below 1.0, push the
cursor toward the host edge — the cross-back happens with the
guest cursor still well inside its screen.
Fix: communicate the receiver's per-pair sensitivity to the
capturing peer via a new `ProtoEvent::ReceiverSensitivity` and
scale the wall-press accumulator by it.
- **lan-mouse-proto**: new `ReceiverSensitivity { mouse_sensitivity }`
variant + `EventType` + encode/decode (single trailing f64).
Forward-compatible: old peers that don't recognize the variant
silently skip per the existing `EventType::try_from` handling.
- **emulation (receiver side)**: send the variant immediately
after `Ack` and `Bounds` on every `Enter`, looking the value up
from the cached `IncomingPeerConfig.mouse_sensitivity`. Also
push it live to every active peer when `SetIncomingPeers` lands
(user-driven slider change), so the host's model picks up the
change immediately instead of waiting for the next cross.
- **capture (sender side)**: cache per-position `peer_sensitivity`
alongside `peer_bounds`; same lifecycle (cleared on capture
destroy). The wall-press accumulator multiplies the entry-axis
delta by it before adding to `virtual_pos`. Sub-1.0 values now
legitimately let the host's model lag the receiver, matching
reality. Default 1.0 when never received, matching prior
behavior for old peers.
The address-lookup `get_client` matched only against `s.ips` (union of `fix_ips` and DNS-resolved IPs). When the mDNS-primary dialer connected to a peer at an IP that wasn't in DNS — say, DNS resolved `peer.local` to `192.168.1.29` but mDNS advertised `192.168.1.88` and the dialer preferred the latter — the listen-side counterpart of the same connection arrived from `192.168.1.88`, which `s.ips` doesn't include, so the lookup returned `None` and the matching `EmulationEvent::PeerHello` was silently dropped. Visible symptom: the peer-version display in the GUI never updated for peers reached via the mDNS primary path. Pre-existing since the mDNS-primary feature; not caused by the recent per-pair scroll/sensitivity work. Fix: also match against `s.active_addr.ip()`. The successful connect path already records the operational addr via `set_active_addr(handle, addr)`, so the lookup picks up whichever IP the peer is actually using right now in addition to the configured/DNS-known set.
The listen-side `PeerHello` path is racy: when the peer dials in before our own outbound dial completes, `get_client(addr)` finds no client (no `s.ips` entry, no `active_addr` yet) and the peer's commit is silently dropped. The connect-side `receive_loop` later receives the peer's `Hello` echo and writes it to `client_manager.peer_commit`, but historically that was a fire-and-forget mutation with no GUI broadcast — so the version-status row stayed at "unknown" indefinitely. Plumb the connect-side commit through to Service so it can call `broadcast_client` on receipt: - `connect.rs`: forward `Hello` over the existing `tx` channel alongside the existing `client_manager.set_peer_commit` call. - `capture.rs`: handle the forwarded `Hello`, emit a new `ICaptureEvent::PeerCommitUpdated(handle)` so the event bubbles up to Service through the existing capture-event channel. - `service.rs`: handle `PeerCommitUpdated` by calling `broadcast_client(handle)`. `client_manager` already has the fresh commit thanks to `connect.rs`'s direct write, so the broadcast picks up the right value. The listen-side path stays as-is — it's still useful for the asymmetric case where outbound is broken but inbound works (the case 1ea7148 was added for). The two paths are complementary now: listen-side fires when `get_client(addr)` matches; connect-side fires whenever Linux successfully dials out and receives the echo, regardless of `s.ips` / `active_addr` race timing. Repro: peer's hostname resolves via DNS to a stale IP while mDNS advertises the correct one. The dialer prefers mDNS, the actual DTLS connection lands at the mDNS IP, but `s.ips` only contains the stale DNS IP. Before this commit, the GUI showed "Peer version: unknown" indefinitely; after, it populates as soon as the outbound dial echoes back the peer's `Hello`.
48f7477 to
b9a4711
Compare
This was referenced May 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Per-incoming-peer receive-side controls for scroll inversion and motion sensitivity, surfaced as expandable rows in the Incoming Connections list. Plus the original scroll-correctness fixes that started this PR, plus a handful of version-exchange and identity-tracking fixes that fell out of stress-testing.
Review-only focused diff (just this PR's commits, vs.
split/07-macos-qol): jondkinney/lan-mouse@split/07-macos-qol...split/08-scrollPer-pair receive-side post-processing
IncomingPeerConfig(keyed by TLS certificate fingerprint), persisted toconfig.toml, and applied receive-side in a centralized transform insideInputEmulation::consume. Backends (macos, wlroots, libei, x11, windows, xdg_desktop_portal) go back to platform-mechanics-only — no per-backendset_natural_scrollshadow state.PrimaryCache. Renders asmac-mini (192.168.1.42),192.168.1.42,mac-mini, or(not yet connected)depending on what's known. Persisted onIncomingPeerConfigso it survives daemon restarts.ClientRowlays out destructive actions). Revoke Authorization moves to a red-button row at the bottom.Window::set_authorized_keysso toggling a per-row setting doesn't collapse expansion state on everyAuthorizedUpdatedround-trip. KeyRow listens to property-notify on the bound KeyObject so per-row widget state tracks in-place mutations without ping-ponging back as fresh user requests.EmulationTask'spost_processingcache outlivesProxyRequest::Remove(which fires on everyLeaveand on the heartbeat sweep), so per-pair settings follow the SocketAddr instead of the ephemeral handle that's minted fresh on each cross.ProtoEvent::ReceiverSensitivity { mouse_sensitivity: f64 }— receiver tells sender what multiplier it applies, so the host's wall-press auto-release model can scale its delta accumulator to match. Without this, sub-1.0 sensitivity made the host's model overrun reality and fire AutoRelease before the receiver hit the wall. Sent on Ack-of-Enter and on every slider change for active peers, so changes apply immediately. Forward-compatible: old peers silently skip the unknown variant.Relationship to #347 (mouse-config) and #339
#347 by @NeoTheFox / Raidon Chrome was the first PR to address mouse sensitivity and scroll inversion in lan-mouse, originally discussed in #339. This PR shares the same goals and reuses the central algorithmic insight from #347, but ships a different architectural shape that we believe is the better long-term fit for the codebase. Direct credit and a
Co-Authored-By: Raidon Chrome <soniczerops@gmail.com>trailer is on the receive-side transform commit.What we adopted from #347
consume()match-arm transform pattern: multiplier onPointerEvent::Motion, sign-flip onAxisandAxisDiscrete120. We use the same arms; the only difference is per-handle lookup instead of a single struct field.What's different in this PR
config.tomlonly[input_post_processing]tableauthorized_fingerprints(forward-compat: legacy bare-string entries auto-migrate to defaults via a customDeserialize)ProtoEvent::ReceiverSensitivity— receiver tells sender its multiplier, sender's wall-press accumulator scales to matchupdate_config)SetIncomingPeerssnapshot down the receive pipeline AND pushReceiverSensitivityover the wire to active peers)Why per-pair is the right shape
The receive-side multiplier converts wire-format deltas (whatever the sender's capture layer emits) into the receiver's screen-space motion. Different sender capture backends emit deltas at different scales — a Mac trackpad's small floating-point Cocoa values, a Windows high-DPI mouse's RAWMOUSE counts, and Wayland evdev all arrive at the receiver with meaningfully different magnitudes. A single global multiplier on the receiver is fine when only one peer is sending, but the moment a user has two senders running different OSes (the canonical multi-machine workflow), one of them will feel wrong while the other feels right.
The same applies to natural-scroll: in a multi-sender setup, one sender's trackpad may already be doing natural-direction (Mac), while another is classic-direction (Linux/Windows) — the receiver wants to invert one and not the other. A global toggle can't express that.
Per-pair is also strictly more general: setting all pairs' values to the same number recovers the global behavior, while no global setting can reproduce the mixed-direction cases above.
Why the new GUI surface
Lan Mouse's existing UI already groups configuration by peer (the Outgoing Connections list has expandable rows per client). Putting the new controls in the symmetric Incoming Connections list keeps the mental model consistent: "these are the senders allowed to control me, and here's how I want their input scaled before it lands on my screen." Co-locating the fingerprint, hostname, and per-pair settings in the same expanded body means everything about a given peer-pair lives in one place. CLI live-update is a tasteful addition from #347 that we don't currently mirror; if it's wanted we can add it post-merge as a small follow-up.
Recommended path
If the maintainer agrees with the per-pair direction and the wall-press wire-protocol fix, this PR would supersede #347 — close #347, land this. If there's disagreement on the global-vs-per-pair scope, we can split this PR's per-pair work behind the existing global toggle as the default and introduce per-pair as an opt-in extension; happy to do that work if it's the preferred path.
Scroll correctness fixes (original scope of this PR)
axis_source— setAxisSource::Finger/Wheelalongside continuous scroll events. Several compositors silently drop axis events that arrive without a source, which used to manifest as Mac-trackpad scroll dropping after the first frame.Version-exchange & identity fixes
get_clientmatchesactive_addr.ip()in addition tos.ips— fixes peer-version display going "Peer version: unknown" indefinitely when the peer's actual operational IP differs from DNS-resolved values. Common with mDNS-primary peers where DNS lags or returns stale records.Helloecho — historically a fire-and-forget mutation. Now the connect-side bubbles up viaICaptureEvent::PeerCommitUpdated(handle)so Service canbroadcast_client. Closes a race where the listen-sidePeerHelloarrived before our outbound dial populatedactive_addrand the listen-side path'sget_client(addr)returned None, leaving the GUI stuck at "unknown" until next daemon restart.Wire-protocol additions
ProtoEvent::ReceiverSensitivity { mouse_sensitivity: f64 }— encoded as event-type tag + trailingf64(big-endian, like the rest of the protocol's f64s). Old peers that don't recognize the variant silently skip it via the existing forward-compat handling in the receive loop.Schema additions (forward-compatible)
IncomingPeerConfig { description, natural_scroll, mouse_sensitivity, last_addr, last_hostname }replaces the bare-string description inauthorized_fingerprints. CustomDeserializeimpl accepts both shapes (untagged enum) — legacy configs that store a bare string per fingerprint auto-migrate to defaults (natural_scroll: false, mouse_sensitivity: 1.0, last_addr: None, last_hostname: None) on first read. No migration tooling needed.FrontendRequestloses itsEq/PartialEqderives becausef64isn'tEq. No callers were comparing requests for equality.Test plan
peer at <pos> reports sensitivity Xdebug logs on both ends(IP)after first connectRelated PRs
This PR is part of an effort to split #418 into focused, independently-reviewable pieces.
The split stack (each builds on the previous; review in order):
Plus:
fix(capture): throttle WaitingForAck Enter re-sends to one per 50ms — small standalone fix offmain, independent of the stack