[PR #418] Wall-press auto-release, scroll preferences, GUI singleton, and macOS QoL #412

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

📋 Pull Request Information

Original PR: https://github.com/feschber/lan-mouse/pull/418
Author: @jondkinney
Created: 4/30/2026
Status: 🔄 Open

Base: mainHead: auto-release-and-scroll-invert


📝 Commits (10+)

  • 86151f5 feat: cross-platform wall-press auto-release fallback
  • 3025422 fix(proto): tolerate undecodable peer datagrams instead of disconnecting
  • fbce4a5 proto: add Bounds(width, height) event variant
  • b6b91c0 feat(emulation): display_bounds + warp_cursor for all backends
  • 2d21456 feat(emulation): send Bounds + warp cursor on Enter
  • 588c6a7 feat(capture): cache peer bounds and use as wall-press upper clamp
  • a26ae8b fix(emulation/wlroots): set axis_source for continuous scroll events
  • d0de131 ui: wrap window content in GtkScrolledWindow
  • 9ed25b0 feat: per-receiver natural-scroll preference for forwarded events
  • dc09e88 fix(capture/macos): emit classic-direction scroll deltas on the wire

📊 Changes

50 files changed (+4748 additions, -597 deletions)

View changed files

📝 .gitignore (+1 -0)
📝 Cargo.lock (+494 -204)
📝 Cargo.toml (+5 -1)
📝 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 (+574 -11)
📝 input-capture/src/libei.rs (+5 -2)
📝 input-capture/src/macos.rs (+257 -20)
📝 input-capture/src/windows.rs (+1 -1)
📝 input-capture/src/windows/event_thread.rs (+66 -12)
📝 input-capture/src/x11.rs (+1 -1)
📝 input-emulation/src/lib.rs (+49 -0)
📝 input-emulation/src/libei.rs (+49 -2)
📝 input-emulation/src/macos.rs (+110 -1)
📝 input-emulation/src/windows.rs (+41 -4)
📝 input-emulation/src/wlroots.rs (+219 -5)
📝 input-emulation/src/x11.rs (+38 -1)
📝 input-emulation/src/xdg_desktop_portal.rs (+13 -1)
📝 lan-mouse-gtk/resources/authorization_window.ui (+78 -82)

...and 30 more files

📄 Description

Summary

Ten independent feature areas, each useful on its own:

  1. Host-lock cross-suppression — when the host's screen is locked, the cursor refuses to cross to the peer. Standardizes behavior across all platforms (Wayland already enforced this; macOS and Windows didn't) so a half-broken state where the mouse moves to the peer but the keyboard goes to the host's lock screen can no longer be reached. Wayland gets it free from the compositor; macOS polls CGSessionCopyCurrentDictionary at cross-decision time; Windows registers for WM_WTSSESSION_CHANGE. The complementary direction — cursor already on the peer when the host locks — is covered by §2's wall-press auto-release: host-side input hooks keep running under the lock screen, so pushing against the host-adjacent edge of the peer ejects the cursor back to the host without any extra plumbing.
  2. Wall-press auto-release — capture self-releases when the cursor is held against the host-adjacent edge of the guest, gated by a peer-Leave deadline so it stays a fallback rather than racing the layer-shell handover. New Bounds proto event so the host knows the guest's extent and can clamp / warp accurately. Doubles as the user-facing escape hatch for §1's locked-host-while-on-peer case.
  3. Scroll handling — receiver-side natural-scroll preference, plus a wlroots compositor fix for silently-dropped trackpad events, plus a macOS capture-side wire-convention fix so the receiver toggle has a fixed reference to invert from, plus a v120-multiplier fix so a single notch lands as one full wheel tick.
  4. Cross-platform GUI singletonlan-mouse launched twice no longer opens a second window. Decoupled from the daemon socket; headless lan-mouse daemon workflow unchanged.
  5. macOS QoL — Cmd+W collapses to menu bar, default height fits modern displays, Auto-Release group label scoped to "outgoing", quit unfreezable (3s daemon SIGKILL + 5s process force-exit backstop), display wakes on incoming input.
  6. Cross-machine cursor position sync — when control transfers, the destination cursor lands at the visually-corresponding point. Survives mismatched resolutions, fractional/software scaling, fast back-and-forth handovers, and the bootstrap problem on the very first crossing.
  7. Peer version exchangeHello proto event lets each peer surface the other's build commit hash in the GUI, with a soft-warn color indicator on mismatch. Listen-side mirror so version visibility doesn't depend on outbound reachability.
  8. Hostname resolution via the OS resolverJKMBP-M4-Max.local and similar Bonjour names now Just Work because getaddrinfo walks /etc/nsswitch.conf / mDNS / /etc/hosts instead of pure-DNS-only.
  9. Multi-homed DTLS listener — one listener per local IPv4 instead of a single 0.0.0.0 bind, so reply packets always source from the IP the peer dialed. if-watch supervisor with periodic reconciliation handles interface plug/unplug and admin-disable.
  10. mDNS-SD service-order discovery — each instance advertises a _lan-mouse._udp.local. Bonjour service whose TXT record names the OS-preferred-interface IP. Dialer biases toward that IP via a 200ms happy-eyeballs head-start, so multi-homed peers connect via the right interface (Mac service order / Linux default route) without a manual ips=[…] workaround.

History is pre-cleaned: each commit single-purpose. Diagnostic markers at log::info! ([wp-begin], [release-warp], [bootstrap], [cursor-pos], mdns: …, wall-press …) are intentional — set LAN_MOUSE_LOG_LEVEL=warn to silence.


1. 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 section standardizes that 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 section's 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 §2's 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: §2's 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 §2's 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 §2's wall-press already covers it.

2. 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 §1's 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 §1's locked-host-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.

3. Scroll handling

macOS capture (dc09e88): Wire convention canonicalized to classic mouse-wheel direction regardless of the user's macOS Natural Scrolling preference (read via CFPreferencesCopyAppValue). Without a fixed wire convention, the receiver-side toggle has no stable reference. Opposite of feschber/lan-mouse#415, which negated unconditionally and traded the bug for the opposite-preference user.

wlroots emulation (a26ae8b): Continuous-scroll events (trackpad) were silently dropped by Hyprland/Sway/GNOME-Shell because the axis() event lacked a companion axis_source in the same wl_pointer.frame. Spec calls it a "hint"; in practice it's load-bearing for continuous scroll. One extra emit per axis frame fixes it.

Receiver-side Natural scrolling toggle (9ed25b0): New GUI preference, off by default — wire is classic, receiver re-inverts. Mirrors libinput's natural_scroll, applied to forwarded events specifically (which on Wayland bypass libinput).

macOS line→tick mapping (19d36bf): Capture used to map one macOS scroll-line to 40 v120 units (1/3 of a wheel tick), so receivers using the discrete count (Slack via XWayland, terminals reading axis_value120) needed 3+ notches before any scroll registered. macOS already amplifies SCROLL_WHEEL_EVENT_DELTA by velocity, so one line should map directly to one full v120 tick. Slow-notch and fast-flick behavior is now symmetric across native-Wayland and XWayland clients.

4. Cross-platform GUI singleton (60041ae)

Dedicated lan-mouse-gui.sock (Unix) / 127.0.0.1:5253 (Windows), separate from the daemon socket. First GUI binds; later launches connect, send a byte, and exit. Primary forwards into the GTK main loop and calls window.present().

Decoupled from the daemon socket on purpose: lan-mouse daemon headless workflow is unchanged. Stale-socket recovery if the primary crashed without cleanup. Defense-in-depth via app.windows().first() for the in-process activation path. Unit test covers acquire → signal → re-acquire.

5. macOS QoL

  • Cmd+W (afe9456) wires to GtkWindow's window.close action, which on macOS hides the window and flips the activation policy to Accessory — effectively collapses to the menu bar. Linux/Windows unchanged.
  • Default height 700→1400 (8a86b9d) fits every preference group on first paint on ≥1440px-tall displays. Tiling Wayland WMs ignore default-height, so no effect there.
  • "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 (§2) makes that its actual role.
  • Quit unfreezable (39752ee): 3s try_wait() poll on the daemon child with SIGKILL fallback, plus a process-level std::thread 5s force-exit backstop scheduled outside the GTK main loop (so a wedged loop can't prevent it). Worst-case quit latency 5s; normal completes in <1s.
  • Display wake on input (56f828f): emulation backend pokes IOPMAssertionDeclareUserActivity whenever a forwarded event arrives, so a peer-driven keystroke or click wakes the macOS display from idle-sleep. Without this, lan-mouse would keep pumping events to a blanked screen.
  • 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).

6. 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.

7. Peer version exchange (29da4d8)

ProtoEvent::Hello { commit: [u8; 8] } carries each peer's shadow_rs SHORT_COMMIT once per session. Sender fires immediately after DTLS auth; listener mirrors the event back so the connect side's receive_loop populates ClientState::peer_commit for 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 explicit local_commit parameter on lan_mouse_gtk::run, stashed in a OnceLock so per-row UI can compare against each peer's hash without an IPC round-trip.

EventType::Hello is appended to the enum so existing IDs are untouched. Old peers hit the existing InvalidEventId skip path from 3025422 and silently ignore the event — backward interop preserved.

Listen-side mirror (1ea7148): the original implementation read peer_commit only 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. New EmulationEvent::PeerHello { addr, commit } variant fired from the listen-side Hello handler; service maps addr → ClientHandle via client_manager.get_client(addr) and stamps peer_commit exactly like the connect path. Version visibility is now independent of outbound reachability.

8. Hostname resolution via the OS resolver (9ce3847)

hickory_resolver::TokioResolver only consults /etc/resolv.conf and queries upstream DNS servers — which means it can't see /etc/hosts, mDNS/Avahi/Bonjour, NetBIOS, or anything else in the system's full name-resolution stack. On a typical home LAN there's no DNS server that knows about peer machine names, so users had to fall back to typing IP addresses, which broke the moment they moved their setup to a different network.

Swap to tokio::net::lookup_host, which calls getaddrinfo. That walks /etc/nsswitch.conf on Linux (picking up Avahi-resolved .local names, /etc/hosts, and DNS), uses Bonjour for .local on macOS, and the full Windows resolver on Windows. A Bonjour hostname like JKMBP-M4-Max.local now resolves on every modern network without explicit configuration; the user can carry their two machines between LANs and the connection still finds them. Drop the hickory-resolver dependency entirely; lookup failures surface as io::Error, already covered by ServiceError::Io.

9. Multi-homed DTLS listener (2c7ce2e / 4c80ed0)

When a host has two interfaces on the same subnet (macOS Wi-Fi en0 + USB-C dock en7 both on 192.168.1.0/24), a single 0.0.0.0:port DTLS listener silently breaks for peers that dial the non-routed IP: the kernel sources its reply from the routing table's preferred interface, so the reply's src-IP doesn't match the 4-tuple the peer expects, and webrtc-dtls drops the packet.

Replace the single 0.0.0.0 bind with one Listener per local IPv4 address (loopback + link-local skipped), each socket bound to a specific IP so the kernel uses that IP as source — symmetric replies guaranteed regardless of the routing table. An if-watch supervisor task adds/drops listener slots dynamically on interface up/down; plugging a dock or toggling Wi-Fi no longer requires a lan-mouse restart.

The supervisor also runs a 30-second reconciliation tick that diffs the live getifaddrs set against the listeners HashMap. if-watch on macOS uses Network.framework, which doesn't reliably fire IfEvent::Down when an interface is administratively disabled (e.g. user toggles Wi-Fi off in System Settings); the polling backup catches whatever the event stream misses, both adds and drops.

Falls back to a single 0.0.0.0 bind only if interface enumeration or every per-IP bind fails — preserves single-NIC behavior and ensures we never silently fail to listen.

Removes the previous user-facing workaround of forcing ips = ["192.168.1.88"] on the peer.

10. mDNS-SD service-order discovery (5ae6fee)

Even with a multi-homed listener (§9), the dialer still has to choose which of the peer's IPs to dial first — and plain hostname resolution returns every interface's IP without ranking. connect_any's parallel race picks whichever DTLS handshake completes first, which is RTT-roughly-correct but not always what the user wanted. The classic symptom: Wi-Fi wins the race even when the user has Ethernet ranked higher in macOS's service order, leading to a stuttery session over Wi-Fi while a healthy wired path sits idle.

Each lan-mouse instance now publishes a _lan-mouse._udp.local. Bonjour service whose TXT record carries primary=<ipv4>, where <ipv4> is the IP of the interface that owns the default route — which on macOS reflects service order, on Linux the lowest-metric default route, on Windows whatever GetBestRoute2 selects. The dialer continuously browses the same service type and caches peer_hostname → primary_ipv4 in a Rc<RefCell<HashMap>> shared with LanMouseConnection.

connect_any extended with happy-eyeballs head-start: if a preferred address is known, dial it alone for 200ms before joining the rest of the candidate list to the race. A healthy preferred path virtually always wins; a broken one only delays connect by 200ms before fallbacks kick in. (Cf. RFC 8305 IPv6→IPv4 fallback delay.)

Subsystem gated by a new mdns_discovery config flag (default true) and a corresponding GUI switch under a new "Network Discovery" preferences group. Toggling off unregisters the service, aborts the browse task, and shuts the daemon, but preserves the primary_cache so already-known hints stay queryable until overwritten — useful on networks where mDNS multicast (224.0.0.251) is firewalled. A 30-second discovery_refresh_tick re-publishes the TXT record so it stays accurate when the OS-preferred interface changes (e.g. user toggles Wi-Fi off and Ethernet takes over).

New deps: mdns-sd (cross-platform mDNS responder, doesn't piggyback on system Avahi/Bonjour), netdev (default-route lookup), hostname (local hostname for the service instance name).

Falls back gracefully when ServiceDaemon::new fails (multicast group locked / no perms), no interface owns the default route, or the peer isn't announcing (old version or discovery disabled there) — the dialer just sees preferred = None and the existing connect_any race runs unchanged.


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. Trackpad and mouse-wheel scrolling forward correctly with both natural and classic preference combinations, including single-notch behavior in Slack (XWayland) and ghostty (native Wayland). macOS Cmd+W / Cmd+Q / GUI singleton verified. Peer version exchange shows green-matched on same-commit pairs and orange-unknown when one side runs a pre-Hello build, and remains correct in one-direction-down scenarios (listen-side mirror). 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). mDNS-SD primary-IP hint verified on a multi-homed Mac (Wi-Fi + Ethernet on same subnet): Linux dialer consistently selects the Ethernet path even when Wi-Fi wins a raw RTT race.

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 (1 new test in lan-mouse-ipc).


🔄 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/418 **Author:** [@jondkinney](https://github.com/jondkinney) **Created:** 4/30/2026 **Status:** 🔄 Open **Base:** `main` ← **Head:** `auto-release-and-scroll-invert` --- ### 📝 Commits (10+) - [`86151f5`](https://github.com/feschber/lan-mouse/commit/86151f53ef583d7e9e74d916f1d83807d1e0d226) feat: cross-platform wall-press auto-release fallback - [`3025422`](https://github.com/feschber/lan-mouse/commit/30254224b8433b4deb4665304e6a40081868b006) fix(proto): tolerate undecodable peer datagrams instead of disconnecting - [`fbce4a5`](https://github.com/feschber/lan-mouse/commit/fbce4a59c083750cbfbe49aa016f5f1ed8e580d2) proto: add Bounds(width, height) event variant - [`b6b91c0`](https://github.com/feschber/lan-mouse/commit/b6b91c0f769c75a4c5bc1f7fa69f5e27c6303ae5) feat(emulation): display_bounds + warp_cursor for all backends - [`2d21456`](https://github.com/feschber/lan-mouse/commit/2d21456aececcbbbdb61b98010ef1fcc4f5f0d55) feat(emulation): send Bounds + warp cursor on Enter - [`588c6a7`](https://github.com/feschber/lan-mouse/commit/588c6a74d9ec762fe934ffaef8db7032e414d562) feat(capture): cache peer bounds and use as wall-press upper clamp - [`a26ae8b`](https://github.com/feschber/lan-mouse/commit/a26ae8b1f191000fefb236fcafb1f6490a8be809) fix(emulation/wlroots): set axis_source for continuous scroll events - [`d0de131`](https://github.com/feschber/lan-mouse/commit/d0de13168219cceccd691656778532a6c3aa65cd) ui: wrap window content in GtkScrolledWindow - [`9ed25b0`](https://github.com/feschber/lan-mouse/commit/9ed25b0c7ac9f9bb72659749099ced32a386bd58) feat: per-receiver natural-scroll preference for forwarded events - [`dc09e88`](https://github.com/feschber/lan-mouse/commit/dc09e88a6e8a703a3b6954c7e5897661dc235a2e) fix(capture/macos): emit classic-direction scroll deltas on the wire ### 📊 Changes **50 files changed** (+4748 additions, -597 deletions) <details> <summary>View changed files</summary> 📝 `.gitignore` (+1 -0) 📝 `Cargo.lock` (+494 -204) 📝 `Cargo.toml` (+5 -1) 📝 `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` (+574 -11) 📝 `input-capture/src/libei.rs` (+5 -2) 📝 `input-capture/src/macos.rs` (+257 -20) 📝 `input-capture/src/windows.rs` (+1 -1) 📝 `input-capture/src/windows/event_thread.rs` (+66 -12) 📝 `input-capture/src/x11.rs` (+1 -1) 📝 `input-emulation/src/lib.rs` (+49 -0) 📝 `input-emulation/src/libei.rs` (+49 -2) 📝 `input-emulation/src/macos.rs` (+110 -1) 📝 `input-emulation/src/windows.rs` (+41 -4) 📝 `input-emulation/src/wlroots.rs` (+219 -5) 📝 `input-emulation/src/x11.rs` (+38 -1) 📝 `input-emulation/src/xdg_desktop_portal.rs` (+13 -1) 📝 `lan-mouse-gtk/resources/authorization_window.ui` (+78 -82) _...and 30 more files_ </details> ### 📄 Description ## Summary Ten independent feature areas, each useful on its own: 1. **Host-lock cross-suppression** — when the host's screen is locked, the cursor refuses to cross to the peer. Standardizes behavior across all platforms (Wayland already enforced this; macOS and Windows didn't) so a half-broken state where the mouse moves to the peer but the keyboard goes to the host's lock screen can no longer be reached. Wayland gets it free from the compositor; macOS polls `CGSessionCopyCurrentDictionary` at cross-decision time; Windows registers for `WM_WTSSESSION_CHANGE`. The complementary direction — *cursor already on the peer* when the host locks — is covered by §2's wall-press auto-release: host-side input hooks keep running under the lock screen, so pushing against the host-adjacent edge of the peer ejects the cursor back to the host without any extra plumbing. 2. **Wall-press auto-release** — capture self-releases when the cursor is held against the host-adjacent edge of the guest, gated by a peer-Leave deadline so it stays a fallback rather than racing the layer-shell handover. New `Bounds` proto event so the host knows the guest's extent and can clamp / warp accurately. Doubles as the user-facing escape hatch for §1's locked-host-while-on-peer case. 3. **Scroll handling** — receiver-side natural-scroll preference, plus a wlroots compositor fix for silently-dropped trackpad events, plus a macOS capture-side wire-convention fix so the receiver toggle has a fixed reference to invert from, plus a v120-multiplier fix so a single notch lands as one full wheel tick. 4. **Cross-platform GUI singleton** — `lan-mouse` launched twice no longer opens a second window. Decoupled from the daemon socket; headless `lan-mouse daemon` workflow unchanged. 5. **macOS QoL** — Cmd+W collapses to menu bar, default height fits modern displays, Auto-Release group label scoped to "outgoing", quit unfreezable (3s daemon SIGKILL + 5s process force-exit backstop), display wakes on incoming input. 6. **Cross-machine cursor position sync** — when control transfers, the destination cursor lands at the visually-corresponding point. Survives mismatched resolutions, fractional/software scaling, fast back-and-forth handovers, and the bootstrap problem on the very first crossing. 7. **Peer version exchange** — `Hello` proto event lets each peer surface the other's build commit hash in the GUI, with a soft-warn color indicator on mismatch. Listen-side mirror so version visibility doesn't depend on outbound reachability. 8. **Hostname resolution via the OS resolver** — `JKMBP-M4-Max.local` and similar Bonjour names now Just Work because `getaddrinfo` walks `/etc/nsswitch.conf` / mDNS / `/etc/hosts` instead of pure-DNS-only. 9. **Multi-homed DTLS listener** — one listener per local IPv4 instead of a single `0.0.0.0` bind, so reply packets always source from the IP the peer dialed. `if-watch` supervisor with periodic reconciliation handles interface plug/unplug and admin-disable. 10. **mDNS-SD service-order discovery** — each instance advertises a `_lan-mouse._udp.local.` Bonjour service whose TXT record names the OS-preferred-interface IP. Dialer biases toward that IP via a 200ms happy-eyeballs head-start, so multi-homed peers connect via the right interface (Mac service order / Linux default route) without a manual `ips=[…]` workaround. History is pre-cleaned: each commit single-purpose. Diagnostic markers at `log::info!` (`[wp-begin]`, `[release-warp]`, `[bootstrap]`, `[cursor-pos]`, `mdns: …`, `wall-press …`) are intentional — set `LAN_MOUSE_LOG_LEVEL=warn` to silence. --- ## 1. 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 section standardizes that 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 section's 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 §2's 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: §2's 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 §2's 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 §2's wall-press already covers it. ## 2. 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 §1's 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 §1's locked-host-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. ## 3. Scroll handling **macOS capture (`dc09e88`):** Wire convention canonicalized to classic mouse-wheel direction regardless of the user's macOS Natural Scrolling preference (read via `CFPreferencesCopyAppValue`). Without a fixed wire convention, the receiver-side toggle has no stable reference. Opposite of `feschber/lan-mouse#415`, which negated unconditionally and traded the bug for the opposite-preference user. **wlroots emulation (`a26ae8b`):** Continuous-scroll events (trackpad) were silently dropped by Hyprland/Sway/GNOME-Shell because the `axis()` event lacked a companion `axis_source` in the same `wl_pointer.frame`. Spec calls it a "hint"; in practice it's load-bearing for continuous scroll. One extra emit per axis frame fixes it. **Receiver-side `Natural scrolling` toggle (`9ed25b0`):** New GUI preference, off by default — wire is classic, receiver re-inverts. Mirrors libinput's `natural_scroll`, applied to forwarded events specifically (which on Wayland bypass libinput). **macOS line→tick mapping (`19d36bf`):** Capture used to map one macOS scroll-line to 40 v120 units (1/3 of a wheel tick), so receivers using the discrete count (Slack via XWayland, terminals reading `axis_value120`) needed 3+ notches before any scroll registered. macOS already amplifies `SCROLL_WHEEL_EVENT_DELTA` by velocity, so one line should map directly to one full v120 tick. Slow-notch and fast-flick behavior is now symmetric across native-Wayland and XWayland clients. ## 4. Cross-platform GUI singleton (`60041ae`) Dedicated `lan-mouse-gui.sock` (Unix) / `127.0.0.1:5253` (Windows), separate from the daemon socket. First GUI binds; later launches connect, send a byte, and exit. Primary forwards into the GTK main loop and calls `window.present()`. Decoupled from the daemon socket on purpose: `lan-mouse daemon` headless workflow is unchanged. Stale-socket recovery if the primary crashed without cleanup. Defense-in-depth via `app.windows().first()` for the in-process activation path. Unit test covers acquire → signal → re-acquire. ## 5. macOS QoL - **Cmd+W (`afe9456`)** wires to GtkWindow's `window.close` action, which on macOS hides the window and flips the activation policy to `Accessory` — effectively collapses to the menu bar. Linux/Windows unchanged. - **Default height 700→1400 (`8a86b9d`)** fits every preference group on first paint on ≥1440px-tall displays. Tiling Wayland WMs ignore `default-height`, so no effect there. - **"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 (§2) makes that its actual role. - **Quit unfreezable (`39752ee`)**: 3s `try_wait()` poll on the daemon child with SIGKILL fallback, plus a process-level `std::thread` 5s force-exit backstop scheduled *outside* the GTK main loop (so a wedged loop can't prevent it). Worst-case quit latency 5s; normal completes in <1s. - **Display wake on input (`56f828f`)**: emulation backend pokes `IOPMAssertionDeclareUserActivity` whenever a forwarded event arrives, so a peer-driven keystroke or click wakes the macOS display from idle-sleep. Without this, lan-mouse would keep pumping events to a blanked screen. - **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). ## 6. 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. ## 7. Peer version exchange (`29da4d8`) `ProtoEvent::Hello { commit: [u8; 8] }` carries each peer's `shadow_rs` `SHORT_COMMIT` once per session. Sender fires immediately after DTLS auth; listener mirrors the event back so the connect side's `receive_loop` populates `ClientState::peer_commit` for 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 explicit `local_commit` parameter on `lan_mouse_gtk::run`, stashed in a `OnceLock` so per-row UI can compare against each peer's hash without an IPC round-trip. `EventType::Hello` is appended to the enum so existing IDs are untouched. Old peers hit the existing `InvalidEventId` skip path from `3025422` and silently ignore the event — backward interop preserved. **Listen-side mirror (`1ea7148`):** the original implementation read `peer_commit` only 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. New `EmulationEvent::PeerHello { addr, commit }` variant fired from the listen-side Hello handler; service maps `addr → ClientHandle` via `client_manager.get_client(addr)` and stamps `peer_commit` exactly like the connect path. Version visibility is now independent of outbound reachability. ## 8. Hostname resolution via the OS resolver (`9ce3847`) `hickory_resolver::TokioResolver` only consults `/etc/resolv.conf` and queries upstream DNS servers — which means it can't see `/etc/hosts`, mDNS/Avahi/Bonjour, NetBIOS, or anything else in the system's full name-resolution stack. On a typical home LAN there's no DNS server that knows about peer machine names, so users had to fall back to typing IP addresses, which broke the moment they moved their setup to a different network. Swap to `tokio::net::lookup_host`, which calls `getaddrinfo`. That walks `/etc/nsswitch.conf` on Linux (picking up Avahi-resolved `.local` names, `/etc/hosts`, and DNS), uses Bonjour for `.local` on macOS, and the full Windows resolver on Windows. A Bonjour hostname like `JKMBP-M4-Max.local` now resolves on every modern network without explicit configuration; the user can carry their two machines between LANs and the connection still finds them. Drop the `hickory-resolver` dependency entirely; lookup failures surface as `io::Error`, already covered by `ServiceError::Io`. ## 9. Multi-homed DTLS listener (`2c7ce2e` / `4c80ed0`) When a host has two interfaces on the same subnet (macOS Wi-Fi `en0` + USB-C dock `en7` both on `192.168.1.0/24`), a single `0.0.0.0:port` DTLS listener silently breaks for peers that dial the non-routed IP: the kernel sources its reply from the routing table's preferred interface, so the reply's src-IP doesn't match the 4-tuple the peer expects, and `webrtc-dtls` drops the packet. Replace the single `0.0.0.0` bind with one `Listener` per local IPv4 address (loopback + link-local skipped), each socket bound to a specific IP so the kernel uses *that* IP as source — symmetric replies guaranteed regardless of the routing table. An `if-watch` supervisor task adds/drops listener slots dynamically on interface up/down; plugging a dock or toggling Wi-Fi no longer requires a lan-mouse restart. The supervisor also runs a **30-second reconciliation tick** that diffs the live `getifaddrs` set against the listeners HashMap. `if-watch` on macOS uses Network.framework, which doesn't reliably fire `IfEvent::Down` when an interface is administratively disabled (e.g. user toggles Wi-Fi off in System Settings); the polling backup catches whatever the event stream misses, both adds and drops. Falls back to a single `0.0.0.0` bind only if interface enumeration or every per-IP bind fails — preserves single-NIC behavior and ensures we never silently fail to listen. Removes the previous user-facing workaround of forcing `ips = ["192.168.1.88"]` on the peer. ## 10. mDNS-SD service-order discovery (`5ae6fee`) Even with a multi-homed listener (§9), the **dialer** still has to choose which of the peer's IPs to dial first — and plain hostname resolution returns every interface's IP without ranking. `connect_any`'s parallel race picks whichever DTLS handshake completes first, which is RTT-roughly-correct but not always what the user wanted. The classic symptom: Wi-Fi wins the race even when the user has Ethernet ranked higher in macOS's service order, leading to a stuttery session over Wi-Fi while a healthy wired path sits idle. Each lan-mouse instance now publishes a `_lan-mouse._udp.local.` Bonjour service whose TXT record carries `primary=<ipv4>`, where `<ipv4>` is the IP of the interface that owns the default route — which on macOS reflects service order, on Linux the lowest-metric default route, on Windows whatever `GetBestRoute2` selects. The dialer continuously browses the same service type and caches `peer_hostname → primary_ipv4` in a `Rc<RefCell<HashMap>>` shared with `LanMouseConnection`. `connect_any` extended with **happy-eyeballs head-start**: if a preferred address is known, dial it alone for 200ms before joining the rest of the candidate list to the race. A healthy preferred path virtually always wins; a broken one only delays connect by 200ms before fallbacks kick in. (Cf. RFC 8305 IPv6→IPv4 fallback delay.) Subsystem gated by a new `mdns_discovery` config flag (default true) and a corresponding GUI switch under a new "Network Discovery" preferences group. Toggling off unregisters the service, aborts the browse task, and shuts the daemon, but preserves the `primary_cache` so already-known hints stay queryable until overwritten — useful on networks where mDNS multicast (`224.0.0.251`) is firewalled. A 30-second `discovery_refresh_tick` re-publishes the TXT record so it stays accurate when the OS-preferred interface changes (e.g. user toggles Wi-Fi off and Ethernet takes over). New deps: `mdns-sd` (cross-platform mDNS responder, doesn't piggyback on system Avahi/Bonjour), `netdev` (default-route lookup), `hostname` (local hostname for the service instance name). Falls back gracefully when `ServiceDaemon::new` fails (multicast group locked / no perms), no interface owns the default route, or the peer isn't announcing (old version or discovery disabled there) — the dialer just sees `preferred = None` and the existing `connect_any` race runs unchanged. --- ## 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. Trackpad and mouse-wheel scrolling forward correctly with both natural and classic preference combinations, including single-notch behavior in Slack (XWayland) and ghostty (native Wayland). macOS Cmd+W / Cmd+Q / GUI singleton verified. Peer version exchange shows green-matched on same-commit pairs and orange-unknown when one side runs a pre-Hello build, and remains correct in one-direction-down scenarios (listen-side mirror). 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). mDNS-SD primary-IP hint verified on a multi-homed Mac (Wi-Fi + Ethernet on same subnet): Linux dialer consistently selects the Ethernet path even when Wi-Fi wins a raw RTT race. 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 (1 new test in `lan-mouse-ipc`). --- <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:33 -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#412
No description provided.