feat: peer version exchange via Hello proto event#421
Closed
jondkinney wants to merge 40 commits intofeschber:mainfrom
Closed
feat: peer version exchange via Hello proto event#421jondkinney wants to merge 40 commits intofeschber:mainfrom
jondkinney wants to merge 40 commits intofeschber:mainfrom
Conversation
This was referenced May 6, 2026
fde5531 to
0598b89
Compare
4 tasks
ca2b561 to
024e02d
Compare
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.
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>
`host_normalized_cursor` and `peer_warp_target` previously did
`nx = cx / host_w`, assuming cursor coordinates fall in
`[0, host_w]`. On macOS specifically (and X11 generally), the global
pointer-coordinate system is anchored at the primary display's
top-left, so a secondary monitor positioned LEFT of (or ABOVE) the
primary gives cursor x ∈ `[-w, 0)` for points on it. The
`clamp(0.0, 1.0)` silently masked this — every point on a
left-attached external normalized to nx = 0 ("left edge"), and the
receiver warped to the wrong column on every crossing from that
display.
Add `Capture::display_origin()` returning `(xmin, ymin)` with a
`(0, 0)` default for backends whose primary IS the origin
(Windows, most X11/Wayland setups), and macOS overrides to walk
`CGDisplay::active_displays()` for the actual leftmost/topmost
corner. `host_normalized_cursor` and `peer_warp_target` now subtract
the origin before normalizing, so off-primary cursor positions
produce correct fractions.
The wall-press auto-release model's `initial_virtual_cursor`
estimate inherits the fix automatically since it routes through
`peer_warp_target`. No protocol change — `CursorPos` payload stays
the same; only the host-side normalization that produces
`(nx, ny)` is corrected.
The dual-event design (MotionAbsolute + CursorPos sent back-to-back
on every Enter) existed to support old receivers that only
recognized MotionAbsolute. Now that we've dropped support for those
old receivers, we can simplify to a single CursorPos warp per Enter.
CursorPos is the better primitive anyway — it carries the host's
cursor as a normalized fraction of the host's own bounds plus the
entry side, so the receiver scales against its *current* live
display geometry. Doesn't suffer the cached-peer-bounds staleness
or first-crossing bootstrap problems MotionAbsolute had.
Changes:
- src/capture.rs: stop computing/sending MotionAbsolute on Begin.
Only emit CursorPos when the backend reported a cursor position.
- src/emulation.rs: remove the MotionAbsolute receive handler.
Tighten CursorPos cross-axis clamp to `dim - 1` so a host edge
(nx == 1.0 or ny == 1.0) doesn't compute one pixel past the
receiver's addressable column/row.
Wire-format byte stability: ProtoEvent::MotionAbsolute stays in
`lan-mouse-proto` so the EventType discriminant byte for CursorPos
doesn't shift. The variant just goes unused; the receive side hits
the catch-all `_ => {}` arm if a peer running an older build sends
one.
The virtual_cursor model — which release-time
host_warp_target_on_release reads to figure out where on the
host screen to put the cursor when capture ends — depends on
peer_bounds being cached at the moment Begin fires. peer_bounds
is populated in response to a Bounds event the peer sends after
receiving our Enter, so on the very first crossing it's
guaranteed to be None: we send Enter, Begin fires, the host
asks initial_virtual_cursor for a seed, peer_bounds is None,
seed returns None, virtual_cursor stays None for the rest of
the session.
The track_wall_press Motion handler then has a
`if let (Some(vc), Some(_)) =` guard that silently drops every
delta when vc is None. So even after Bounds finally arrives,
virtual_cursor is never updated — and at release time the warp
falls back to the original-crossing y. Symptom: cross macOS top
→ arch top → drift down on arch → return; cursor lands at the
top y you crossed at, not the bottom y you drifted to.
Two new fields stitch the bootstrap together:
pending_begin_cursor: Option<(i32, i32)>
The host-coord cursor reported by the backend at Begin time.
Stashed unconditionally so we can replay the seed once
peer_bounds is known.
pending_motion: (f64, f64)
Motion deltas that arrived while virtual_cursor was None.
Without this they'd be silently lost; with it we apply them
cumulatively to the seeded vc when bootstrap completes.
set_peer_bounds gains a retroactive-seeding path: when the new
bounds match the active capture_pos and virtual_cursor is still
None and we have a pending Begin cursor, call
initial_virtual_cursor (which will now succeed because we just
inserted the bounds), add pending_motion, and assign. Both
pending fields cleared in reset_wall_press_state.
Track-press Motion's match changed from a single guard to
explicit arms — `(Some, Some)` accumulates into vc as before;
`(None, _)` accumulates into pending_motion (with a debug log
so we can confirm the path is exercised when investigating).
Diagnostic logging:
[wp-begin] per Begin: pos, host cursor, peer_bounds presence,
the seeded virtual_cursor.
[wp-motion] per deferred Motion: deferred dx/dy and current
peer_bounds for the position. Debug level.
[bootstrap] when retroactive seeding fires: the seeded vc
and the drained pending_motion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`display_bounds` returns the size of the host's display union; `display_origin` returns its top-left corner in pointer-event coordinate space. The release-time warp was building target coordinates as `0..host_w` of the union rectangle without adding the origin back, so on hosts whose primary display isn't anchored at (0, 0) — typically macOS with externals attached above or to the left of the primary, where the global pointer-coord origin sits at the leftmost/topmost monitor — the warp_target landed outside the addressable space. Symptom on those setups would be the cursor reappearing way off-screen, or warp silently failing. Same correction we already applied in `host_normalized_cursor` and `peer_warp_target` (see 30606ca for the rationale on the input side); the release path needs the symmetric inverse on the output. Single-monitor hosts and multi-monitor hosts whose primary IS the union origin (Windows, most X11/Wayland setups, macOS with a single built-in display) are unaffected — `display_origin` returns (0, 0) for them, which is what the previous code implicitly assumed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Temporary diagnostic. Will be cleaned up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The wlroots emulation backend reported display_bounds from wl_output's raw mode dimensions (e.g. 2560x1600) while the capture side reports them from xdg_output's LogicalSize (e.g. 2400x1500 with software scaling). The CursorPos warp computes target = ny * emulation_bounds, so warps landed at proportions shifted relative to where the sender measured the crossing. Bind zxdg_output_manager_v1 in WlrootsEmulation, request an xdg_output companion for each wl_output, and prefer the logical size/position when computing union_bounds. Falls back to wl_output mode/geometry per-output when xdg-output isn't advertised. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the peer takes over (sends Enter+CursorPos), the host was also releasing capture and warping its local cursor based on the last-known peer virtual_cursor. The two warps fired on the same shared cursor and raced — the host's stale warp frequently won, clobbering the peer's authoritative proportional landing and making the cursor appear at whatever position the host *thought* the peer cursor was, regardless of where the user actually crossed. Split the release path: ReleaseForHandover skips the host warp_target so CursorPos is the only warp on remote-takeover. The release-bind chord and backend auto-release still go through the original release_capture path that computes a host warp. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ReleaseNotify wasn't the only source of host warp races. When the peer's local capture begins, it sends ProtoEvent::Leave to every incoming connection (service.rs:357), which the recipient's capture loop handles by calling release_capture — computing a host warp from stale virtual_cursor and racing against the peer's upcoming CursorPos warp on the shared cursor. Route peer-Leave release through release_capture_handover so the proportional CursorPos warp lands without competition. The rare case where the peer released without taking over (no Enter/ CursorPos follows) just leaves our cursor where it was — fine, since nothing else is moving it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Enter handler unconditionally warped the local cursor to the
midpoint of the entry edge, intending to seat virtual_pos=0 at
column 0 before the host's stream of relative motion arrived.
But the host now sends CursorPos right after Enter, which carries
the proportional landing point AND pins the on-axis dimension to
the matching edge — making the midpoint warp redundant.
Worse, the midpoint warp races against fast handovers: when the
user crosses, then crosses back within ~100ms, the local
CGEventTap (or layer-shell equivalent) reads the cursor's
location field at the new crossing while the cursor is still
sitting at the midpoint from the previous Enter — never
advancing to the proportional CursorPos warp that would have
followed. The opposite-direction CursorPos then encodes
ny=0.500 ("middle of source") and the receiver dutifully warps
its cursor to its own middle, producing the persistent
"always lands in the middle" symptom even after suppressing the
host-warp races on both sides.
Trust the host: if it can compute a proportional point (which it
can in every case where Begin.cursor was populated), CursorPos
seats the cursor correctly. If it can't, the cursor stays where
it was — preferable to a forced midpoint that masquerades as a
mid-screen crossing on subsequent re-crosses.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The wall-press fallback previously fired the moment the cursor pressed the host-adjacent edge of the peer for `release_threshold_px` worth of unabsorbed motion — racing the peer's layer-shell `Leave` (the authoritative handover signal) on every normal cross. In practice the network round-trip beats 200px of physical motion easily, so layer-shell won the race and wall-press only visibly fired on the lock screen where the peer has no layer-shell. The right outcome, by accident. Make it explicit. When wall_pressure crosses the threshold, set `wall_press_pending_at` and arm a 150ms timer instead of firing. `release_no_host_warp` (the path peer-Leave already routes through) clears the pending flag via `reset_wall_press_state`, so a healthy handover cancels the deferred AutoRelease before it can fire. The timer itself is polled in `poll_next` so the deadline elapses even when the user pinned the cursor at the wall and stopped moving. Result: - Normal operation: peer Leave arrives in <50ms → wall-press cancelled, no race against the proportional CursorPos warp the handover path uses to position the host's cursor. - Lock screen / dead peer / network down: no Leave arrives → 150ms past threshold → fire AutoRelease as the original fallback intended. Costs +150ms of latency to the genuine fallback case (lock screen), which is imperceptible on top of the 200px of cursor "stickiness" the user already sees while the threshold accumulates. Also retreating into the interior now cancels a pending fire — a brief bump against the wall followed by motion deeper into the guest no longer leaves a primed timer waiting to misfire. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Instant value was stored but never read — only `is_some()` / `is_none()` / `take()`. `tokio::time::Instant::now()` already gives us the deadline base for the timer reset, so the std::time import drops too. No behavior change.
Wayland's compositor revokes input on layer-shell surfaces while the screen is locked, so Linux-as-host gets this behavior for free. macOS and Windows do not — CGEventTap and WH_MOUSE_LL hooks both keep firing under the lock screen — leaving a half-broken state where the mouse can move to the peer but the keyboard can't follow (the lock screen consumes keys before any tap/hook sees them). Match Wayland's behavior on the other two platforms by detecting lock state and gating barrier crossings on it. macOS: - Register CFNotificationCenter distributed-notification observers for `com.apple.screenIsLocked` / `com.apple.screenIsUnlocked` on the same CFRunLoop thread that hosts the event tap. - Add `host_locked: bool` to InputCaptureState; the lock callback flips it via blocking_lock and synthesizes `AutoRelease` upward via the event channel if a capture was already in flight. - Gate the cross-detection branch in event_tap_callback on `!state.host_locked`. The mutex serializes against the callback so events delivered after the lock-state flip see the new value. Windows: - Add `Win32_System_RemoteDesktop` to the `windows` crate features for `WTSRegisterSessionNotification` / `WTSUnRegisterSessionNotification`. - Register the existing message-only window for `NOTIFY_FOR_THIS_SESSION` so it receives `WM_WTSSESSION_CHANGE`. - Add `HOST_LOCKED: Cell<bool>` thread-local; window_proc updates it on `WTS_SESSION_LOCK` / `WTS_SESSION_UNLOCK` and synthesizes `AutoRelease` via the event channel if a capture was active. - Gate the cross-detection in `check_client_activation` on `!HOST_LOCKED.get()`. Linux X11 backend is currently `NotImplemented` so there's nothing to gate; whoever wires up the X11 capture path can add the same check using their preferred lock-state source (D-Bus org.freedesktop.ScreenSaver, xss XScreenSaverQueryInfo, etc). Known limitation: distributed notifications / WM_WTSSESSION_CHANGE fire only on transitions — if the daemon starts while the host is already locked, host_locked stays false until the next lock cycle. Acceptable for now since the daemon normally starts before lock. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous attempt to gate cursor crossings while the host's screen is locked tried `CFNotificationCenterGetDistributedCenter` for `com.apple.screenIsLocked` / `Unlocked`. Empirically, the callback never fires when the daemon is non-Cocoa: the distnoted mach port is attached to the main thread's CFRunLoop regardless of which thread called AddObserver, and lan-mouse's main thread runs the GLib main loop instead of a CFRunLoop, so the port is never serviced. A dedicated worker thread with its own CFRunLoop doesn't help (port still attaches to main). `notify_register_check` against the same names is also a dead end — `loginwindow` doesn't post on notify(3) for these keys (verified with `notifyutil -1`). Replace the entire observer machinery with a direct poll of `CGSessionCopyCurrentDictionary["CGSSessionScreenIsLocked"]` on each `MouseMoved` event in the tap callback. ~10-50us per call (XPC to WindowServer); negligible at typical mouse rates. On the unlocked → locked transition, synthesize an `AutoRelease` so the cursor returns to the host. On Sequoia 15+ the key is absent (not `kCFBooleanFalse`) when unlocked — treat missing-or-nil as unlocked. Verified: with macOS as host and Linux as guest, locking the Mac prevents the cursor from crossing to the Linux peer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously polled CGSession on every MouseMoved tap callback (~1000Hz worst case = 1-5% CPU on the XPC). The only forwarding decision that actually consults the lock state is the cross-detection commit point inside `state.crossed(cg_ev)` returning Some — fire-once-per-cross, not fire-once-per-twitch. Move the `is_screen_locked()` call there and drop the per-event polling, the `host_locked` cached field, and the transition-detection logic. Tradeoff: mid-capture lock (cursor on peer when Mac auto-locks via idle timeout) no longer auto-releases the cursor back to the Mac. The user can release-bind (Ctrl+Shift+Cmd+Alt) to bring the cursor back. Acceptable: cursor stuck on peer while screen locked is mildly annoying, not dangerous; auto-lock-during-capture is rare in practice. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apply rustfmt to the host-lock suppression code (input-capture macOS + Windows event_thread).
The DTLS recv loops in src/listen.rs and src/connect.rs each read one full datagram per call. A failed `try_into::<ProtoEvent>()` means the datagram's leading EventType byte didn't match any known variant — a misalignment is impossible because DTLS is message-framed, not stream-framed. Previously, src/listen.rs would `break` out of the loop on parse failure (tearing down the connection) and src/connect.rs would silently swallow the error with no log. Both are wrong as forward-compat behavior: any future protocol addition (e.g. a new event variant) would force every existing peer to disconnect rather than gracefully ignoring the unknown event. Skip-and-continue on both sides, with a debug-level log so the behavior is observable. Pre-requisite for any future ProtoEvent variant to land without forcing a coordinated upgrade across every peer in a deployment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a one-shot Hello message to the lan-mouse wire protocol so each
peer can display the other end's build commit hash and warn on
version mismatch. Soft-warn only — mismatched versions never refuse
traffic.
Wire change (lan-mouse-proto)
* `ProtoEvent::Hello { commit: [u8; 8] }` carries the 8-byte ASCII
short commit from shadow_rs's `SHORT_COMMIT`. Encoded/decoded
alongside the existing event variants.
* `EventType::Hello` is appended to the enum so existing IDs are
untouched. Old peers receive the event, hit `InvalidEventId`, and
silently skip it via the forward-compat handler in
`connect.rs::receive_loop` — the connection is unaffected.
Daemon
* Connect side sends one Hello immediately after the DTLS handshake
authenticates and before the ping_pong loop starts. Best-effort,
fire-and-forget — `log::debug!` on send error.
* Listen side mirrors the peer's Hello with its own (same shape as
the existing Ping → Pong reply), so the peer's connect-side
receive_loop populates `ClientState::peer_commit` for that
handle.
* The disconnect path clears `peer_commit` so a stale hash isn't
shown after the connection drops.
IPC
* `ClientState::peer_commit: Option<[u8; 8]>`. `None` means the
peer hasn't sent Hello yet — either fresh connection or older
build that predates the event.
GTK
* `ClientObject` exposes `peer-commit` as an `Option<String>`
property; `peer_commit_to_string` converts the wire `[u8; 8]` to
the displayable hex.
* `lan_mouse_gtk::run` now takes the local commit and stashes it in
a `OnceLock` so per-row UI can compare against each peer's hash.
* `ClientRow::refresh_version_status` re-renders the collapsed
subtitle with Pango markup whenever the property changes:
- matched → green "peer version: <hex> · matched"
- mismatch → orange "peer version: <hex> · ours: <hex>"
- unknown → orange "peer version: unknown · ours: <hex>"
* Window invokes `refresh_version_status` from
`update_client_state` after writing the new property, and
`bind` calls it once on row construction so the initial
subtitle isn't blank.
Known limitation: state-change broadcasts from the network side
(set_alive / set_active_addr / set_peer_commit) don't currently
trigger a `FrontendEvent::State` directly; the UI picks up the
latest values on the next user-driven broadcast. Same pre-existing
behavior as the alive/active_addr fields.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
These are user-visible labels in the version-status subtitle, so sentence-case reads better than the lowercase first-pass. "matched" stays lowercase since it's a status descriptor, not a label. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the Hello handler in `ListenTask` echoed our local commit
back but deliberately threw away the peer's, on the assumption that
the outgoing connect-side path (`connect.rs:278-279` →
`set_peer_commit`) would always populate the visible state for any
bidirectionally-configured peer.
That assumption breaks any time the *outgoing* TCP/DTLS direction is
broken even though the inbound direction is fine — happened just now
when the peer Mac's daemon stopped listening on 4242 (DHCP-renewed
IP, daemon crashed, asymmetric NAT, …). Mac was still happily
connecting in the other direction and sending events, including the
initial Hello, but Linux silently displayed "peer version unknown"
because the listen side dropped Mac's commit on the floor.
Add a `PeerHello { addr, commit }` EmulationEvent variant fired from
the listen-side Hello handler. The service maps `addr → ClientHandle`
via `client_manager.get_client(addr)` and calls `set_peer_commit` +
`broadcast_client` exactly like the connect path does. The connect
path remains the primary source for symmetric setups; this is the
defensive fallback so version visibility doesn't depend on outbound
reachability.
Skips silently when no outgoing client is configured for the peer's
addr (incoming-only setup) — there's no UI row to update in that
case anyway.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
024e02d to
9989579
Compare
Contributor
Author
|
Superseded by #431 (same content; the branch was renumbered as part of restructuring the stack from 7 PRs into 9, which auto-closed this cross-fork PR). |
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.
Review-only focused diff (just this PR's commits, vs.
split/01-cursor-and-walls): jondkinney/lan-mouse@split/01-host-lock...split/02-peer-versionSummary
Peer version exchange — each peer surfaces the other's build commit hash in the GUI, with a soft-warn color indicator on mismatch.
ProtoEvent::Hello { commit: [u8; 8] }carries each peer'sshadow_rsSHORT_COMMITonce per session. Sender fires immediately after DTLS auth; listener mirrors the event back so the connect side'sreceive_looppopulatesClientState::peer_commitfor the right handle. Disconnect path clears it.Each outgoing-connection row's collapsed subtitle renders match status with Pango-colored markup: green when commits match, orange when mismatched or when the peer hasn't sent
Hello(older build). Soft-warn only — version mismatch never refuses traffic. The local commit reaches the GTK frontend (separate process) via an explicitlocal_commitparameter onlan_mouse_gtk::run, stashed in aOnceLockso per-row UI can compare against each peer's hash without an IPC round-trip.EventType::Hellois appended to the enum so existing IDs are untouched. Old peers hit the existingInvalidEventIdskip path from3025422and silently ignore the event — backward interop preserved.Listen-side mirror (
1ea7148): the original implementation readpeer_commitonly off the outgoing-connect path, on the assumption that bidirectional setups always have a working outbound connection in both directions. That assumption broke the moment any direction's outbound was down (e.g. peer's TCP listener temporarily not bound) — version display silently said "unknown" while the peer was happily sending events to us inbound. NewEmulationEvent::PeerHello { addr, commit }variant fired from the listen-side Hello handler; service mapsaddr → ClientHandleviaclient_manager.get_client(addr)and stampspeer_commitexactly like the connect path. Version visibility is now independent of outbound reachability.Test plan
peer_commitso the next connect starts freshSplit out from #418, the umbrella PR collecting ~10 independent feature areas. This PR is the peer-version-exchange subset and stacks on top of the cursor-sync/wall-press PR. See #418 for the full picture.
Stack overview
These PRs are split out from #418 and stack in this order:
Each PR's branch builds on the previous one, so until earlier PRs are merged the cumulative diff against
mainincludes all preceding work. Reviewing in order is easiest.