[PR #420] feat: cross-machine cursor sync + wall-press auto-release + host-lock cross-suppression #411

Open
opened 2026-05-05 22:18:32 -06:00 by gitea-mirror · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/feschber/lan-mouse/pull/420
Author: @jondkinney
Created: 5/6/2026
Status: 🔄 Open

Base: mainHead: split/01-cursor-and-walls


📝 Commits (10+)

  • 31298f2 feat: cross-platform wall-press auto-release fallback
  • bffbef0 proto: add Bounds(width, height) event variant
  • 2219f45 feat(emulation): display_bounds + warp_cursor for all backends
  • 5de5016 feat(emulation): send Bounds + warp cursor on Enter
  • 68fab55 feat(capture): cache peer bounds and use as wall-press upper clamp
  • 944e758 ui: wrap window content in GtkScrolledWindow
  • 56f7730 ui: rename Auto-Release group to scope it to outgoing capture
  • cd63f5d fix: preserve cross-axis cursor position across machine transitions
  • 2571b47 fix(capture/layer_shell): report screen-space cursor position on Enter
  • cbe2ec1 fix(capture): warp host cursor to guest position on release

📊 Changes

25 files changed (+1753 additions, -86 deletions)

View changed files

📝 input-capture/Cargo.toml (+1 -0)
📝 input-capture/src/dummy.rs (+2 -2)
📝 input-capture/src/layer_shell.rs (+67 -17)
📝 input-capture/src/lib.rs (+579 -11)
📝 input-capture/src/libei.rs (+5 -2)
📝 input-capture/src/macos.rs (+152 -13)
📝 input-capture/src/windows.rs (+1 -1)
📝 input-capture/src/windows/event_thread.rs (+64 -12)
📝 input-capture/src/x11.rs (+1 -1)
📝 input-emulation/src/lib.rs (+36 -0)
📝 input-emulation/src/libei.rs (+41 -2)
📝 input-emulation/src/macos.rs (+34 -1)
📝 input-emulation/src/windows.rs (+24 -1)
📝 input-emulation/src/wlroots.rs (+190 -1)
📝 input-emulation/src/x11.rs (+27 -0)
📝 lan-mouse-gtk/resources/window.ui (+66 -5)
📝 lan-mouse-gtk/src/lib.rs (+3 -0)
📝 lan-mouse-gtk/src/window.rs (+26 -0)
📝 lan-mouse-gtk/src/window/imp.rs (+77 -1)
📝 lan-mouse-ipc/src/lib.rs (+5 -0)

...and 5 more files

📄 Description

Summary

Three intertwined improvements to cross-machine cursor handling, plus the GUI control they expose. They share peer_bounds storage and display_bounds/warp_cursor infrastructure on the InputCapture/InputEmulation traits — splitting them produces non-compiling intermediate states, so they're bundled here.

Cross-machine cursor position sync

The host computes its cursor's position as a normalized fraction (nx, ny) ∈ [0, 1] against its own bounds and sends ProtoEvent::CursorPos { pos, nx, ny } right after Enter. The receiver scales against its own live bounds and pins the on-axis dimension to the entry edge. Self-sufficient — works on the very first crossing, no Bounds round-trip needed.

One architectural change to flag for review: the release path is now split. release_capture (release-bind chord, backend auto-release) computes a host-side cursor warp from the cached virtual_cursor. release_capture_handover (used when the peer takes over via ReleaseNotify or ProtoEvent::Leave) skips the host warp so the peer's authoritative CursorPos is the only signal seating our shared cursor.

Wall-press auto-release

User pushes cursor past the host-adjacent edge of the guest and keeps pushing for N more pixels → capture releases. Threshold tunable in GUI; 0 disables.

The triggering scenario is two locked screens. When the peer's screen is locked, its lock screen owns the input pipeline before lan-mouse's capture barrier can fire — so the peer can't detect the user crossing back and never sends Leave to the host. The host stays in capture mode indefinitely: every keystroke goes to the (probably-locked) guest, and there's no way to unlock the host without first recovering the cursor via the release-bind chord (Ctrl+Shift+Meta+Alt). Wall-press auto-release breaks this deadlock by treating sustained motion past the host-adjacent edge as an explicit "I want out" signal — without protocol cooperation from the peer, which is the part that's broken.

Same mechanism doubles as the host-lock escape hatch. When the host's screen is locked while the cursor is already on the peer, the host's input hook keeps running under the lock screen, so wall-press still accumulates and still fires. Without it, a user whose host locks mid-capture would have to fall back to the release-bind chord or wait for the peer to fire a Leave. With it, "lock the Mac with cursor on Linux, push left, cursor returns" Just Works on every platform that has wall-press configured.

The naive virtual-cursor accumulator runs away if the user holds against the wall, so the protocol-based fix adds ProtoEvent::Bounds(width, height) (sent by emulation right after Enter), caches it per-position, and clamps virtual_pos to the peer's actual extent. Emulation also warps the cursor on Enter so virtual_pos = 0 lines up with the guest's actual cursor.

display_bounds and warp_cursor are added as trait methods on InputEmulation and implemented for every backend except xdg_desktop_portal (no protocol support — falls back to heuristic-only path).

Peer-Leave deadline gate (34605a7): wall-press used to fire the moment the threshold was crossed, which raced the peer-side layer-shell handover and only "worked correctly" because the network round-trip beat 200px of physical motion. With the gate, wall-press defers AutoRelease for ~150ms after threshold and cancels if a peer Leave arrives in that window. Result: in normal operation the layer-shell handover always wins (no spurious wall-press fires); in true fallback scenarios (peer's layer-shell suppressed, or host-lock-stuck-on-peer case) the deadline elapses and wall-press fires as designed.

Bundled forward-compat fix (3025422): we now log-and-skip undecodable peer datagrams instead of disconnecting. Necessary because peers running pre-Bounds builds can interop with this branch.

Host-lock cross-suppression

When the host's screen is locked, prevent the cursor from leaving for the peer — the host's lock screen consumes keyboard events before any capture hook sees them, so a mouse-only-on-peer state is broken-by-design. This standardizes prevention across all platforms; previously Wayland enforced it (because the compositor revokes input on lock) but macOS and Windows didn't, so users on those hosts could silently end up in the half-broken state.

Two cases worth distinguishing:

  • "Host is unlocked, cursor on host, user moves toward the edge while host locks" — refuse to commit the cross. This is the primary job. All three platforms now do it.
  • "Cursor is already on the peer when the host locks" — eject the cursor back to the host. This is where wall-press auto-release earns its keep: the host-side CGEventTap / WH_MOUSE_LL hook keeps running under the lock screen, so the user's motion still accumulates in the host's virtual_cursor model, and pushing against the host-adjacent edge of the peer fires the wall-press release as if everything were normal. Verified end-to-end on macOS host ↔ Linux peer: lock the Mac while cursor is on Linux, push left, cursor returns. Same mechanism works on Windows host (and on Wayland host where the compositor handles it natively). Release-bind chord and peer-side Leave are the alternative recovery paths.

Wayland (free) — the compositor revokes input on layer-shell surfaces when the screen locks, so wl_pointer.Enter stops firing AND the in-flight wl_pointer.Leave arrives, naturally tearing down whichever direction was in play. No code needed.

macOS (8cd86ba / 9d6f427 / 76f3544) — initial attempt was CFNotificationCenterAddObserver for com.apple.screenIsLocked/Unlocked, but those callbacks never fired in practice because lan-mouse's main thread runs the GLib event loop, not a CFRunLoop, and the distnoted mach port attaches to the main thread regardless of which thread called AddObserver. Replaced with a direct CGSessionCopyCurrentDictionary["CGSSessionScreenIsLocked"] poll evaluated at the moment a barrier crossing is about to commit — ~10–50 µs of XPC to WindowServer, paid only on cross attempts (a few per minute), zero per-event overhead during in-flight capture. Mid-capture lock isn't separately handled and doesn't need to be: wall-press is the user-facing recovery path and it works under the lock screen because the CGEventTap keeps running.

Windows (8cd86ba)WTSRegisterSessionNotification on the existing message-only window; window_proc flips a HOST_LOCKED thread-local on WTS_SESSION_LOCK / WTS_SESSION_UNLOCK and gates check_client_activation. Convenience extra: also synthesizes CaptureEvent::AutoRelease if a capture is already in flight when the lock fires — same hand-off into the wall-press release plumbing, so the cursor returns to the host the instant the lock screen comes up rather than waiting for the user to wall-press out. Possible because the WTS notification arrives unconditionally; on macOS the equivalent would require polling on every motion event during capture, and wall-press already covers it.

UI controls (slider + group label)

  • Threshold slider scroll passthrough (3743edd): cursor over the release-threshold slider no longer eats main-window scroll events. Capture-phase handler suppresses GtkScale's own scroll-to-adjust handler (so the slider value stays put) AND forwards the scroll to the ancestor ScrolledWindow (so the page scrolls naturally).
  • Window content wrapped in GtkScrolledWindow so the preferences pane scrolls cleanly when the window is shorter than the natural content height.
  • "Outgoing Auto-Release" rename (8b0a169) scopes the group label to what it actually controls. Description retightened (66df7e5) to frame it as a peer-locked-screen fallback rather than a positive-action feature, since the deadline gate makes that its actual role.

Test plan

Verified locally: macOS host (2056×1329 logical) ↔ Hyprland guest (2400×1500 logical) over both wired and wifi LAN. Crossings at top / middle / bottom of source land proportionally on destination, both directions, including fast back-and-forth within the same second. Wall-press auto-release deadline gate behaves correctly: never fires on healthy crosses, fires after the deadline elapses on locked-peer scenarios. Host-lock suppression verified on macOS (Cmd+Ctrl+Q lock; cursor refuses to cross until unlock; with cursor already on Linux, push-left wall-press cleanly returns the cursor to the locked Mac).

Open items (not blocking — environment access):

  • X11, libei, Windows emulation backends for cursor sync + wall-press
  • Sway-on-wlroots (verified on Hyprland)
  • Forward-compat: peer running pre-Bounds build; back-compat: this build with non-Bounds peer
  • Windows host-lock suppression end-to-end (code path mirrors macOS but unverified on a real Windows host)

Build hygiene: cargo fmt --all clean, cargo clippy --workspace --all-targets --all-features -- -D warnings clean, cargo test --workspace passes.


Split out from #418, the umbrella PR collecting ~10 independent feature areas. This PR contains the cursor-positioning, wall-press auto-release, host-lock cross-suppression, and slider/UI subset. The other split PRs will stack on top of this one — see #418 for the full picture.

Stack overview

These PRs are split out from #418 and stack in this order:

  1. #420 — cursor sync + wall-press + host-lock + slider/UI
  2. #421 — peer version exchange
  3. #422 — hostname resolver + multi-homed DTLS listener
  4. #423 — mDNS-SD service-order discovery
  5. #424 — macOS QoL + UI polish
  6. #425 — scroll forwarding
  7. #426 — GUI singleton

Each PR's branch builds on the previous one, so until earlier PRs are merged the cumulative diff against main includes all preceding work. Reviewing in order is easiest.


🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/feschber/lan-mouse/pull/420 **Author:** [@jondkinney](https://github.com/jondkinney) **Created:** 5/6/2026 **Status:** 🔄 Open **Base:** `main` ← **Head:** `split/01-cursor-and-walls` --- ### 📝 Commits (10+) - [`31298f2`](https://github.com/feschber/lan-mouse/commit/31298f29577e06d4fda36188f5721cd93123e17a) feat: cross-platform wall-press auto-release fallback - [`bffbef0`](https://github.com/feschber/lan-mouse/commit/bffbef0875257c24227e6599e64b4e7a0030e1b2) proto: add Bounds(width, height) event variant - [`2219f45`](https://github.com/feschber/lan-mouse/commit/2219f45a1c37ac9f324728470ea7900eb804c2f6) feat(emulation): display_bounds + warp_cursor for all backends - [`5de5016`](https://github.com/feschber/lan-mouse/commit/5de501637d1e6a5e486c9a3799d8c069ae0d5184) feat(emulation): send Bounds + warp cursor on Enter - [`68fab55`](https://github.com/feschber/lan-mouse/commit/68fab558b340a224e075b842f6182b2c7d6646a3) feat(capture): cache peer bounds and use as wall-press upper clamp - [`944e758`](https://github.com/feschber/lan-mouse/commit/944e7582b783252e07c895b16c5440253b8ecbfe) ui: wrap window content in GtkScrolledWindow - [`56f7730`](https://github.com/feschber/lan-mouse/commit/56f7730164171cbb3dfb0176ca2eae7e314460c6) ui: rename Auto-Release group to scope it to outgoing capture - [`cd63f5d`](https://github.com/feschber/lan-mouse/commit/cd63f5d42875bd27f4724eb9fe3ce787e5725c6c) fix: preserve cross-axis cursor position across machine transitions - [`2571b47`](https://github.com/feschber/lan-mouse/commit/2571b47074da9f68e56501eefbb8ea959b67f5a7) fix(capture/layer_shell): report screen-space cursor position on Enter - [`cbe2ec1`](https://github.com/feschber/lan-mouse/commit/cbe2ec13030fa565f1e562ecf5e09cc5d2a8c637) fix(capture): warp host cursor to guest position on release ### 📊 Changes **25 files changed** (+1753 additions, -86 deletions) <details> <summary>View changed files</summary> 📝 `input-capture/Cargo.toml` (+1 -0) 📝 `input-capture/src/dummy.rs` (+2 -2) 📝 `input-capture/src/layer_shell.rs` (+67 -17) 📝 `input-capture/src/lib.rs` (+579 -11) 📝 `input-capture/src/libei.rs` (+5 -2) 📝 `input-capture/src/macos.rs` (+152 -13) 📝 `input-capture/src/windows.rs` (+1 -1) 📝 `input-capture/src/windows/event_thread.rs` (+64 -12) 📝 `input-capture/src/x11.rs` (+1 -1) 📝 `input-emulation/src/lib.rs` (+36 -0) 📝 `input-emulation/src/libei.rs` (+41 -2) 📝 `input-emulation/src/macos.rs` (+34 -1) 📝 `input-emulation/src/windows.rs` (+24 -1) 📝 `input-emulation/src/wlroots.rs` (+190 -1) 📝 `input-emulation/src/x11.rs` (+27 -0) 📝 `lan-mouse-gtk/resources/window.ui` (+66 -5) 📝 `lan-mouse-gtk/src/lib.rs` (+3 -0) 📝 `lan-mouse-gtk/src/window.rs` (+26 -0) 📝 `lan-mouse-gtk/src/window/imp.rs` (+77 -1) 📝 `lan-mouse-ipc/src/lib.rs` (+5 -0) _...and 5 more files_ </details> ### 📄 Description ## Summary Three intertwined improvements to cross-machine cursor handling, plus the GUI control they expose. They share `peer_bounds` storage and `display_bounds`/`warp_cursor` infrastructure on the `InputCapture`/`InputEmulation` traits — splitting them produces non-compiling intermediate states, so they're bundled here. ## Cross-machine cursor position sync The host computes its cursor's position as a normalized fraction `(nx, ny) ∈ [0, 1]` against its own bounds and sends `ProtoEvent::CursorPos { pos, nx, ny }` right after Enter. The receiver scales against its own live bounds and pins the on-axis dimension to the entry edge. Self-sufficient — works on the very first crossing, no `Bounds` round-trip needed. **One architectural change to flag for review:** the release path is now split. `release_capture` (release-bind chord, backend auto-release) computes a host-side cursor warp from the cached `virtual_cursor`. `release_capture_handover` (used when the peer takes over via `ReleaseNotify` or `ProtoEvent::Leave`) skips the host warp so the peer's authoritative `CursorPos` is the only signal seating our shared cursor. ## Wall-press auto-release User pushes cursor past the host-adjacent edge of the guest and keeps pushing for N more pixels → capture releases. Threshold tunable in GUI; 0 disables. **The triggering scenario is two locked screens.** When the peer's screen is locked, its lock screen owns the input pipeline before lan-mouse's capture barrier can fire — so the peer can't detect the user crossing back and never sends `Leave` to the host. The host stays in capture mode indefinitely: every keystroke goes to the (probably-locked) guest, and there's no way to unlock the host without first recovering the cursor via the release-bind chord (Ctrl+Shift+Meta+Alt). Wall-press auto-release breaks this deadlock by treating sustained motion past the host-adjacent edge as an explicit "I want out" signal — without protocol cooperation from the peer, which is the part that's broken. **Same mechanism doubles as the host-lock escape hatch.** When the host's screen is locked while the cursor is already on the peer, the host's input hook keeps running under the lock screen, so wall-press still accumulates and still fires. Without it, a user whose host locks mid-capture would have to fall back to the release-bind chord or wait for the peer to fire a `Leave`. With it, "lock the Mac with cursor on Linux, push left, cursor returns" Just Works on every platform that has wall-press configured. The naive virtual-cursor accumulator runs away if the user holds against the wall, so the protocol-based fix adds `ProtoEvent::Bounds(width, height)` (sent by emulation right after Enter), caches it per-position, and clamps `virtual_pos` to the peer's actual extent. Emulation also warps the cursor on Enter so `virtual_pos = 0` lines up with the guest's actual cursor. `display_bounds` and `warp_cursor` are added as trait methods on `InputEmulation` and implemented for every backend except `xdg_desktop_portal` (no protocol support — falls back to heuristic-only path). **Peer-Leave deadline gate (`34605a7`):** wall-press used to fire the moment the threshold was crossed, which raced the peer-side layer-shell handover and only "worked correctly" because the network round-trip beat 200px of physical motion. With the gate, wall-press defers AutoRelease for ~150ms after threshold and cancels if a peer `Leave` arrives in that window. Result: in normal operation the layer-shell handover always wins (no spurious wall-press fires); in true fallback scenarios (peer's layer-shell suppressed, or host-lock-stuck-on-peer case) the deadline elapses and wall-press fires as designed. Bundled forward-compat fix (`3025422`): we now log-and-skip undecodable peer datagrams instead of disconnecting. Necessary because peers running pre-`Bounds` builds can interop with this branch. ## Host-lock cross-suppression When the host's screen is locked, prevent the cursor from leaving for the peer — the host's lock screen consumes keyboard events before any capture hook sees them, so a mouse-only-on-peer state is broken-by-design. This standardizes prevention across all platforms; previously Wayland enforced it (because the compositor revokes input on lock) but macOS and Windows didn't, so users on those hosts could silently end up in the half-broken state. Two cases worth distinguishing: - **"Host is unlocked, cursor on host, user moves toward the edge while host locks"** — refuse to commit the cross. This is the primary job. All three platforms now do it. - **"Cursor is already on the peer when the host locks"** — eject the cursor back to the host. This is where wall-press auto-release earns its keep: the host-side CGEventTap / WH_MOUSE_LL hook keeps running under the lock screen, so the user's motion still accumulates in the host's `virtual_cursor` model, and pushing against the host-adjacent edge of the peer fires the wall-press release as if everything were normal. Verified end-to-end on macOS host ↔ Linux peer: lock the Mac while cursor is on Linux, push left, cursor returns. Same mechanism works on Windows host (and on Wayland host where the compositor handles it natively). Release-bind chord and peer-side `Leave` are the alternative recovery paths. **Wayland (`free`)** — the compositor revokes input on layer-shell surfaces when the screen locks, so `wl_pointer.Enter` stops firing AND the in-flight `wl_pointer.Leave` arrives, naturally tearing down whichever direction was in play. No code needed. **macOS (`8cd86ba` / `9d6f427` / `76f3544`)** — initial attempt was `CFNotificationCenterAddObserver` for `com.apple.screenIsLocked`/`Unlocked`, but those callbacks never fired in practice because lan-mouse's main thread runs the GLib event loop, not a CFRunLoop, and the distnoted mach port attaches to the main thread regardless of which thread called `AddObserver`. Replaced with a direct `CGSessionCopyCurrentDictionary["CGSSessionScreenIsLocked"]` poll evaluated at the moment a barrier crossing is about to commit — ~10–50 µs of XPC to WindowServer, paid only on cross attempts (a few per minute), zero per-event overhead during in-flight capture. Mid-capture lock isn't separately handled and doesn't need to be: wall-press is the user-facing recovery path and it works under the lock screen because the CGEventTap keeps running. **Windows (`8cd86ba`)** — `WTSRegisterSessionNotification` on the existing message-only window; `window_proc` flips a `HOST_LOCKED` thread-local on `WTS_SESSION_LOCK` / `WTS_SESSION_UNLOCK` and gates `check_client_activation`. Convenience extra: also synthesizes `CaptureEvent::AutoRelease` if a capture is already in flight when the lock fires — same hand-off into the wall-press release plumbing, so the cursor returns to the host the instant the lock screen comes up rather than waiting for the user to wall-press out. Possible because the WTS notification arrives unconditionally; on macOS the equivalent would require polling on every motion event during capture, and wall-press already covers it. ## UI controls (slider + group label) - **Threshold slider scroll passthrough (`3743edd`):** cursor over the release-threshold slider no longer eats main-window scroll events. Capture-phase handler suppresses `GtkScale`'s own scroll-to-adjust handler (so the slider value stays put) AND forwards the scroll to the ancestor `ScrolledWindow` (so the page scrolls naturally). - **Window content wrapped in `GtkScrolledWindow`** so the preferences pane scrolls cleanly when the window is shorter than the natural content height. - **"Outgoing Auto-Release" rename (`8b0a169`)** scopes the group label to what it actually controls. Description retightened (`66df7e5`) to frame it as a peer-locked-screen fallback rather than a positive-action feature, since the deadline gate makes that its actual role. ## Test plan Verified locally: macOS host (2056×1329 logical) ↔ Hyprland guest (2400×1500 logical) over both wired and wifi LAN. Crossings at top / middle / bottom of source land proportionally on destination, both directions, including fast back-and-forth within the same second. Wall-press auto-release deadline gate behaves correctly: never fires on healthy crosses, fires after the deadline elapses on locked-peer scenarios. Host-lock suppression verified on macOS (`Cmd+Ctrl+Q` lock; cursor refuses to cross until unlock; with cursor already on Linux, push-left wall-press cleanly returns the cursor to the locked Mac). Open items (not blocking — environment access): - [ ] X11, libei, Windows emulation backends for cursor sync + wall-press - [ ] Sway-on-wlroots (verified on Hyprland) - [ ] Forward-compat: peer running pre-Bounds build; back-compat: this build with non-Bounds peer - [ ] Windows host-lock suppression end-to-end (code path mirrors macOS but unverified on a real Windows host) Build hygiene: `cargo fmt --all` clean, `cargo clippy --workspace --all-targets --all-features -- -D warnings` clean, `cargo test --workspace` passes. --- Split out from #418, the umbrella PR collecting ~10 independent feature areas. This PR contains the cursor-positioning, wall-press auto-release, host-lock cross-suppression, and slider/UI subset. The other split PRs will stack on top of this one — see #418 for the full picture. ## Stack overview These PRs are split out from #418 and stack in this order: 1. #420 — cursor sync + wall-press + host-lock + slider/UI 2. #421 — peer version exchange 3. #422 — hostname resolver + multi-homed DTLS listener 4. #423 — mDNS-SD service-order discovery 5. #424 — macOS QoL + UI polish 6. #425 — scroll forwarding 7. #426 — GUI singleton Each PR's branch builds on the previous one, so until earlier PRs are merged the cumulative diff against `main` includes all preceding work. Reviewing in order is easiest. --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
gitea-mirror added the
pull-request
label 2026-05-05 22:18:32 -06:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: github-starred/lan-mouse#411
No description provided.