Skip to content

[OLD all-in-one version] - Wall-press auto-release, scroll preferences, GUI singleton, and macOS QoL#418

Open
jondkinney wants to merge 86 commits intofeschber:mainfrom
jondkinney:auto-release-and-scroll-invert
Open

[OLD all-in-one version] - Wall-press auto-release, scroll preferences, GUI singleton, and macOS QoL#418
jondkinney wants to merge 86 commits intofeschber:mainfrom
jondkinney:auto-release-and-scroll-invert

Conversation

@jondkinney
Copy link
Copy Markdown
Contributor

@jondkinney jondkinney commented Apr 30, 2026

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

jondkinney and others added 24 commits April 29, 2026 16:10
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>
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>
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>
Hyprland (and most other wl_pointer consumers — Sway, GNOME Shell
on Wayland, etc.) silently drop a continuous-scroll axis event
that arrives without a preceding `axis_source` call in the same
frame. The handler for `PointerEvent::AxisDiscrete120` was already
calling `axis_source(AxisSource::Wheel)`, which is why mouse-wheel
scrolling worked. The handler for `PointerEvent::Axis` (the
continuous-scroll path used by macOS trackpad gestures) wasn't
calling axis_source at all, so the events were forwarded onto the
wire, received by the wlr-virtual-pointer device, and then dropped
by the compositor.

Symptoms: the Mac trace log shows correct `Axis { time: 0, axis: 0,
value: N.M }` events flowing toward the Linux peer; the Arch peer
log even shows them arriving; but no actual scrolling happens in
any window. Mouse-wheel scrolling works fine in the same session.

Fix: emit `axis_source(AxisSource::Finger)` alongside the
`axis()` call. Finger is the appropriate source for trackpad-
originated continuous scroll, which is the typical case for
Lan Mouse forwarding from a Mac. Also switch from the
upstream-supplied `time: 0` to the local `now` timestamp — some
compositors filter zero-time events as well; the AxisDiscrete120
path was already doing this.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Lan Mouse window previously couldn't scroll its preference
groups when the window height was reduced below the natural
content height — content was simply clipped, with no way to
reach the lower groups. AdwStatusPage doesn't include built-in
scrolling.

Wrap the AdwStatusPage in a GtkScrolledWindow inside the
existing AdwToastOverlay, with vertical scroll on demand and
horizontal scroll disabled (we use AdwClamp for horizontal
sizing). propagate-natural-height keeps the window's preferred
size identical when content fits, so existing layout behavior
on tall windows is unchanged.

Effect: when the user resizes the window shorter than the
natural content height (or has a small display), all preference
groups remain reachable via vertical scroll.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wayland virtual-pointer events bypass libinput entirely on every
mainstream compositor (Hyprland, Sway, GNOME-Shell-Wayland, KDE
Plasma 6, etc.), so the user's compositor-level natural_scroll
setting doesn't affect events forwarded by Lan Mouse — only their
physical input. The result is asymmetric scroll direction: physical
trackpad on the receiver scrolls naturally, Mac trackpad forwarded
through Lan Mouse scrolls inverted.

Add a per-receiver natural-scroll toggle in Lan Mouse itself,
applied pre-injection in the emulation layer. Each peer's
preference is independent; doesn't depend on the sender or any
compositor config.

Surface:
- New `natural_scroll: Option<bool>` on the daemon config
  (default false, persisted to config.toml).
- New FrontendRequest::SetNaturalScroll + FrontendEvent::
  NaturalScroll IPC pair.
- New "Scroll" preferences group in the GTK window with a
  GtkSwitch; signal-blocked when daemon-driven Sync pushes the
  initial value.

Backend implementations: each Emulation backend stores the bool,
applies it as a sign flip on PointerEvent::Axis and
PointerEvent::AxisDiscrete120 values before injection. macOS,
wlroots, libei, x11, windows, and xdg-desktop-portal are all
covered. Trait method `set_natural_scroll(bool)` with a no-op
default; concrete backends override.

Plumbing mirrors the auto-release threshold pattern: Service
dispatches FrontendRequest, persists to config, pushes through
Emulation → EmulationProxy → EmulationTask cache → InputEmulation
backend. EmulationTask caches the value and re-applies it whenever
a backend respawns (e.g. portal session restart) so the user's
setting survives transient backend recreation.

Also: `ui: wrap window content in GtkScrolledWindow` (separate
prerequisite commit) was needed so the new Scroll group remains
reachable when the window height is reduced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Forwarded scroll events go to receivers that may apply their own
natural-vs-classic preference (libinput's natural_scroll, the
receiver-side `natural_scroll` toggle, etc.), so the wire format
needs a single, predictable convention. We pick classic — the
historical baseline that everything else inverts away from.

macOS's Natural Scrolling preference pre-flips POINT_DELTA at the
OS layer; CGEventTap at Session placement sees events after that
flip:

  Natural ON: POINT_DELTA already away from classic → pass through
    (sign = +1) so the wire lands on classic.
  Natural OFF: POINT_DELTA reads as natural at the wl_pointer layer
    → flip once (sign = -1) so the wire lands on classic.

Net result: wire is consistently classic-feel regardless of the
Mac's preference. Receivers re-invert via their own toggle.

Read `com.apple.swipescrolldirection` from CFPreferences (the modern
macOS default is `true` — Natural Scrolling on) to know which
branch to take. CFPreferencesCopyAppValue is cached by the
framework, so reading per-event is cheap; the user can toggle the
preference at runtime and the next event uses the updated sign
without restart.

ui: trim Scroll group description and row subtitle

The Scroll preferences group's description repeated implementation
details ("Mirrors the libinput natural_scroll preference for
forwarded events, which bypass libinput on Wayland.") that don't
help a user understand what the toggle does. Drop the description
entirely; the group title "Scroll" plus the row's own labels are
sufficient.

Also tightens the row subtitle from "invert the direction of
forwarded scroll events to match a natural-scroll setup" to
"invert the direction of forwarded scroll events" — the row title
"Natural scrolling" already conveys the "natural-scroll setup"
context.
The Scroll group's natural-scrolling toggle only affects forwarded
scroll events received from peers — i.e., it's relevant when this
device acts as a receiver of input, not a sender. Placing it
between Auto-release (a sender-side feature) and Connections
(outgoing) made it look like a host-side setting, which it isn't.

Move the group below the Incoming Connections list so its position
in the page matches its semantic context. Add a one-line
description ("Applies to scroll events received from peers above.")
that points at the Incoming Connections list directly above it,
disambiguating without resorting to the libinput / Wayland jargon
the earlier description had.

Net effect: a user with no incoming peers configured sees the
toggle but understands it's for the receiving role; a user with
peers configured sees it right next to the list of those peers
and gets the connection naturally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Drop the slider's bespoke margins so Auto-release matches the inner
  padding of the surrounding preference groups.
- Capitalize the first letter of every visible title, subtitle,
  tooltip, and placeholder across the GTK resources and runtime
  strings.
- On macOS, follow the main window's visibility: Regular activation
  policy when shown (Dock icon present), Accessory when hidden so the
  app reads as a menu-bar-only background helper.
A second `lan-mouse` invocation no longer opens a duplicate window.
The first GUI binds a per-user socket (Unix on Linux/macOS, localhost
TCP on Windows) and listens for show requests. Any later launch
connects, sends a single byte, and exits — and the primary brings its
window forward.

Decoupled from the daemon's IPC socket so the headless
`lan-mouse daemon` use case is unaffected: the GUI lock only exists
while a GUI process is alive, and a future GUI launch can attach
to a previously-headless daemon as before.

Defense in depth: `build_ui` now reuses the existing `gtk::Window`
when `activate` fires a second time in the same process, which can
happen via GApplication's DBus single-instance hand-off on Linux or
`kAEReopenApplication` on macOS.
Compositors that tile the window (Hyprland and similar) honor min-size
hints by clipping the window's bottom rather than shrinking it, leaving
the lower portion of the layout — including the bottom of "Incoming
Connections" — rendered off-screen. Using default-width/default-height
suggests an initial size while letting the compositor resize freely,
so scrolling reaches all content. Also drops propagate-natural-height
on the scrolled window and adds margin-bottom to the inner content for
breathing room.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Capitalize section row titles ("Hostname & Port",
  "Certificate Fingerprint") and group titles ("Auto-Release",
  "Release Threshold", "Scroll Direction") so the case matches
  their headings.
- Rename "Connections" → "Outgoing Connections" to mirror the
  "Incoming Connections" group and remove ambiguity.
- Replace the Auto-Release description with one that names the
  operand (forwarded mouse capture) and the typical use case
  (smooth release between lock screens).
- Rewrite the Scroll Direction description so it's clear forwarded
  scroll events bypass OS-level preferences and the toggle below
  controls direction.
- Bump AdwClamp max-size 600 → 800 so SHA-256 fingerprint values
  fit on one line without wrapping.
…lider

Bumps the inner content GtkBox spacing 12 → 24 so the headings of each
AdwPreferencesGroup (General, Auto-Release, Outgoing Connections, …)
sit visually as distinct sections rather than running together. Also
adds margin-top: 12 on the release-threshold scale so it doesn't crowd
the row above it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… view

The collapsed AdwExpanderRow had a single row whose title/subtitle were
"Hostname" / "Port" with both entry fields side-by-side as suffix — so
the labels stacked vertically while the inputs sat horizontally, making
it unclear which input belonged to which label. Splitting into two
AdwActionRows pairs each label with its own entry. The Hostname row
also gains a subtitle "IP address or DNS hostname" and a placeholder
of "192.168.1.42" so users know a literal IP is acceptable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the AdwExpanderRow bound hostname → title and port → subtitle,
which rendered the address as two stacked lines in the collapsed state.
Replace the two property bindings with notify-handlers on hostname/port
that recompute the title as "hostname:port" (or the existing "no hostname!"
markup when hostname is empty), and clear the subtitle so the row is a
single line. SignalHandlerIds are tracked alongside Bindings so unbind()
can disconnect them when the row is recycled.

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.
The 700px default cut off below Outgoing Connections on a typical
macOS display, sending the user straight to the scrollbar to find
Incoming Connections / Scroll Direction. Bump default-height to
1400 so every preference group fits on first paint on the displays
this is most likely to launch on (≥ 1440 px tall — most modern
external displays and the built-in Liquid Retina XDR panels). On
shorter displays the GtkScrolledWindow still kicks in, so the
larger default doesn't strand small-screen users.

Width unchanged; the AdwClamp's 800px ceiling already governs
content width independently.
GtkWindow's built-in `window.close` action fires `close-request`,
which on macOS is already hooked to set_visible(false). The
window's connect_hide handler flips the activation policy to
Accessory, so the same gesture also drops the Dock icon and the
menu bar — the app becomes a menu-bar-only background helper and
focus returns to whatever the user does next.

Add the accelerator wiring (Cmd+W → window.close) and the matching
File → Close Window menu entry so the shortcut is discoverable.
Wired only on macOS so we don't shadow the native Ctrl+W on Linux
and Windows.
Two layers of belt-and-suspenders for the intermittent macOS quit
freeze:

1. Bound the daemon-child cleanup to 3 seconds. The GUI's
   service.wait() was previously unbounded, so a wedged daemon
   (CGEventTap stuck on a TCC change, blocked syscall, IPC peer
   timeout) would freeze the parent process indefinitely. After
   SIGINT we now poll try_wait() with a 3s deadline and fall
   through to SIGKILL if the child hasn't exited.

2. Process-level force-exit backstop on every quit path. A
   dedicated std::thread sleeps for 5 seconds outside the GTK main
   loop and calls process::exit(0). Normal cleanup completes well
   under 5s and the OS reaps the sleeping thread on exit; a wedged
   GTK loop or stuck cleanup gets force-quit instead of hanging.
   Wired through every macOS quit entry point: Cmd+Q via the File
   menu (and the Lan Mouse > Quit app menu macOS auto-attaches),
   the menu bar status item's Quit, and the Accessibility-revoked
   auto-quit. Idempotent — calling it twice doesn't spawn two
   backstop threads.

Linux/Windows already exit cleanly so we don't add the backstop
there.
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>
@feschber
Copy link
Copy Markdown
Owner

Please take a look at #347 #407, I think we have some overlap here.

As for the first point, I'm a bit skeptic if this works correctly with games / pointer grabbing. I'd also like to post pone this until after 0.11.0 because of the protocol changes.

@jondkinney jondkinney force-pushed the auto-release-and-scroll-invert branch from e5b3ef9 to 5de6d4a Compare April 30, 2026 14:00
jondkinney and others added 4 commits May 2, 2026 00:17
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>
@jondkinney
Copy link
Copy Markdown
Contributor Author

Hey @feschber — I split this PR into 7 smaller, themed PRs to make review more
manageable. Each carries the relevant section from this PR's original write-up.

In suggested merge order:

  1. feat(capture): wall-press auto-release + Bounds protocol foundation #420 — cursor sync + wall-press + host-lock + slider/UI (33 commits, the
    foundation; the rest stack on it)
  2. feat: peer version exchange via Hello proto event #421 — peer version exchange (Hello proto event)
  3. fix: hostname resolution via OS resolver + multi-homed DTLS listener #422 — hostname resolver + multi-homed DTLS listener
  4. feat(discovery): mDNS-SD primary-IP hints for service-order-aware dialing #423 — mDNS-SD service-order discovery
  5. macOS: QoL bundle (LSUIElement, TCC flow, quit-unfreezable, display wake) + UI polish #424 — macOS QoL bundle + UI polish
  6. feat(scroll): receiver natural-scroll toggle + wlroots axis_source + macOS v120 fix #425 — scroll forwarding
  7. feat(gui): cross-platform GUI singleton via dedicated socket #426 — GUI singleton

A note on the merge dance: #420 and #423#426 are stacked off main cumulatively,
so as you merge each one the next PR's diff naturally collapses to only its own
commits. No action needed from you between merges — just go in order.

#421 and #422 sit directly on main (rather than on the cumulative chain), so
they'll need a rebase before merging once #420 lands. Ping me on the PR when
you're ready and I'll force-push the rebase; takes a minute. After that, merging
proceeds normally.

I'll leave this PR (#418) open as the umbrella reference — full architecture
write-up and test plan are here. Happy to close it once the splits are merged,
or whenever you'd like.

jondkinney and others added 6 commits May 5, 2026 22:27
…re bypass

Previously each call into LanMouseConnection::send spawned a fresh
connect_to_handle, even when the prior attempt had failed because the
peer was unreachable. With ips=[] in the client config and the peer
offline, this produced dozens of attempts per second — every mouse
event near the boundary triggered another DNS lookup and another round
of "client (N) connecting ... (ips: [], preferred: None)" log spam.
Hours of that may have contributed to mDNS state corruption observed
when the peer eventually returned to the LAN.

Gate the spawn at the call site:

  - Per-handle RetryState tracks next_attempt_at + a doubling backoff
    capped at 30s.
  - signature_of(ips, primary_hint) hashes the candidate set; when the
    signature changes between attempts (mDNS browse populates a primary,
    DNS resolves new IPs) the gate is bypassed and the next send tries
    immediately.
  - Successful connect drops the retry entry; failure (or empty
    candidate set) records a new backoff floor.

Net effect: no retry storm during outages, and a peer reappearing via
mDNS reconnects on the very next mouse event without waiting on the
backoff to expire.
The dialer's `primary_hints` lookup keys on the configured `hostname`
("JKMBP-M4-Max.local"), but the cache was being populated with the
SRV target hostname returned by `ServiceInfo::get_hostname()`. macOS
will sometimes appear in mDNS-SD with a suffixed system hostname
("JKMBP-M4-Max-2.local") for the SRV record while the service-instance
label keeps the user-visible identifier ("JKMBP-M4-Max.local") — those
two names are advertised together but mdns-sd resolves only one
SRV target into the event, so the cache key drifted to a name the
config never references and `preferred` came back None.

Switch the cache key to the service-instance label, parsed off the
fullname's `.<SERVICE_TYPE>` suffix. The label is what users put in
their config (the announcer derives it from the same `local_hostname()`
on registration) and it's stable across SRV-target variations.

Log line now shows both fields so future hostname/target mismatches
are visible without a packet capture:
  mdns: peer instance=jkmbp-m4-max.local (target=jkmbp-m4-max-2.local) ...
Discovery now caches by service-instance label, but the announcer's
choice of label is platform-dependent: macOS's `hostname::get()`
returns the FQDN (`Foo.local`) while Linux's returns the short name
(`omarchy`). Without normalization this works asymmetrically — a
config of `omarchy.local` for a Linux peer wouldn't match the cached
`omarchy` key.

Add `normalize_mdns_name` (lower-case, drop trailing `.`, drop
`.local` suffix) and apply it on both insert (start_browse) and
lookup (`peer_primary_ip`, `should_attempt`, `connect_to_handle`).
The `.local` domain is implied for everything mDNS-SD touches, so
collapsing it on both sides is lossless and matches how `dns-sd`
and Bonjour APIs treat instance labels in their wire form.
CGDisplayRegisterReconfigurationCallback covers monitor plug/unplug
and resolution changes during normal operation, but in clamshell-
disconnect → lid-open transitions the callback either doesn't fire
(no actual reconfigure event reaches us during sleep) or fires too
early to be useful, leaving lan-mouse's cached `self.bounds` matching
whatever the display layout was when the lid closed. Symptom: after
opening the lid on a Mac that had been clamshelled to an external
monitor and then disconnected, the cursor behaves as if constrained
to the now-absent external display's resolution.

Subscribe to the IOKit power-management notifications via
`IORegisterForSystemPower` and attach the resulting CFRunLoopSource
to the same run loop that owns the event tap. On
`kIOMessageSystemHasPoweredOn` (post-wake), post the same
`ProducerEvent::DisplayReconfigured` the existing handler consumes,
so `update_bounds()` runs against the live display set. Sleep-pending
messages get acked with `IOAllowPowerChange` so we don't stall the
kernel's 30-second client-wait timeout.

Cleanup paths added: deregister + destroy notification port + drop
the boxed refcon when the run loop exits.
- src/connect.rs: insert blank line in `should_attempt` doc-comment
  before the "Otherwise returns false" continuation. Clippy's
  `doc_lazy_continuation` (Rust 1.94+) treats text immediately after
  a list item without a blank line or indent as continuing the last
  bullet, which it isn't.
- src/discovery.rs: cargo fmt collapsed a wrapped `let instance = …`
  line onto one line at column-fit.

No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`if !listeners.contains_key(&ip) { ... insert(ip, ...) }` plus
`#[allow(clippy::map_entry)]` works but is uglier than just using
the `Entry::Vacant` slot up front. The Vacant arm handles both the
existence check and the subsequent insert in a single hash lookup —
which is the exact rewrite clippy was suggesting, just expressed
without forcing `or_insert_with` (which doesn't fit because
`try_bind_listener` is async + fallible).

Brings combined branch in line with the equivalent fix on the
split stack so both express the same behavior in the same shape.
jondkinney and others added 8 commits May 6, 2026 19:54
Originally every `CaptureEvent::Input` arriving while the cross was
in `State::WaitingForAck` got converted into a repeat `Enter`
packet. The intent was reliability — if the first `Enter` was
dropped, the user's next motion would re-trigger it. In practice
on a LAN the round-trip-to-Ack is sub-millisecond and the user's
mouse fires at 1000 Hz, so each cross sent 2–10 `Enter` packets
inside the gap. Each one made the receiver run its 'Enter'
handler again — re-running `ReleaseNotify` and (with the
CursorPos work) re-warping the cursor on its entry edge. The
user sees that cluster of warps as a visible stutter and reads
it as "the cross got rejected."

Throttle re-sends to at most one per 50 ms: the first `Enter`
always fires immediately, subsequent motion events inside that
window are dropped, and a stalled handshake recovers on the
next motion event after 50 ms. 50 ms is short enough to be well
inside human reaction time and long enough that a healthy LAN
never re-sends at all.

No protocol change. The Ack path additionally clears the
re-send timer so subsequent crosses start fresh.
The GUI startup path always spawned a `lan-mouse daemon` child
process and immediately killed it on exit. If a daemon was already
running — e.g. because the user had launched `lan-mouse daemon`
standalone — the spawned child died early with `AlreadyRunning`,
producing a noisy "service already running!" line and a brief
phantom process in the tree.

Probe the IPC socket via `lan-mouse-ipc::is_service_running` before
spawning. If something's already listening, attach to it and skip
the child entirely; the existing SIGINT/wait/SIGKILL teardown is
gated on a `Some(child)` so an externally managed daemon outlives
the GUI as expected.

The probe is a one-shot connect; a stale socket file with no
listener returns false and the daemon's own bind path cleans the
file up.

Inspired by the approach in feschber#407, layered on top of feschber#436's
dedicated GuiLock so the singleton guarantee remains atomic and
cross-platform — this just removes the transient-child noise on
the headless-daemon-then-GUI path.
The is_service_running() probe answers "is a daemon listening
right now"; the GUI's IPC connect happens after GTK initialization
finishes, milliseconds-to-seconds later. A daemon that crashed
during that window would leave the GUI hanging in connect()'s
retry loop forever.

Two changes close the gap:

- lan-mouse-ipc: add `try_connect()`, a one-shot non-retrying
  connect that exposes the underlying I/O error so callers can
  branch on "no daemon" vs "real failure".
- lan-mouse-gtk: when main.rs has flagged the daemon as
  externally managed (we skipped spawning a child), `build_ui`
  probes with try_connect first; on failure it spawns a fresh
  daemon (fire-and-forget — matches the externally-managed
  daemon semantics) and falls back to the retrying connect()
  to bind against the new instance.

The else branch (we *did* spawn a child) keeps the existing
retrying connect() so the normal startup window where the daemon
hasn't bound yet still works as before.
…gerprints

Replaces the bare `description` value in `authorized_fingerprints`
with `IncomingPeerConfig { description, natural_scroll,
mouse_sensitivity }`. Each row in the receive-side authorization
registry now carries its own scroll-direction and motion-sensitivity
preferences, keyed by TLS certificate fingerprint — the only stable
cross-session identity for an inbound peer.

Backwards-compat: legacy configs that store a bare string per
fingerprint deserialize via a custom `Deserialize` impl on
`IncomingPeerConfig` that accepts either shape (untagged enum) and
fills missing fields with defaults (`natural_scroll: false`,
`mouse_sensitivity: 1.0`). No migration tooling needed; existing
config.toml files keep working.

Wires the schema through:
- lan-mouse-ipc: new `IncomingPeerConfig` type;
  `AuthorizedUpdated(HashMap<String, IncomingPeerConfig>)`;
  `SetIncomingPeerNaturalScroll(fingerprint, bool)` and
  `SetIncomingPeerSensitivity(fingerprint, f64)` requests.
- src/config.rs + src/listen.rs + src/service.rs: type updates
  through the `Arc<RwLock<HashMap<...>>>` shared with the DTLS
  listener, plus per-fingerprint mutators in Service.
- lan-mouse-gtk: `KeyObject` carries the new properties; window
  re-emits them on `AuthorizedUpdated`.

Receive-side application of the new settings (looking up by
fingerprint at handle creation, pushing to InputEmulation) and the
expandable per-row GTK controls land in follow-up commits to keep
the diff focused. The deprecated global `SetNaturalScroll` /
`NaturalScroll` IPC variants stay in place until the global UI
toggle is removed in the cleanup commit.

`FrontendRequest` loses its `Eq`/`PartialEq` derives because `f64`
isn't `Eq`. No callers were comparing requests for equality.

Sensitivity-multiplier algorithm pattern in InputEmulation::consume
(applied in a follow-up commit) is borrowed from feschber#347 by Raidon
Chrome / NeoTheFox, where the global form was originally proposed.

Co-Authored-By: Raidon Chrome <soniczerops@gmail.com>
…gerprint

Centralizes scroll-direction inversion and motion-sensitivity scaling
into a single transform site in `InputEmulation::consume`, replacing
the per-backend `set_natural_scroll(bool)` plumbing introduced
earlier in this PR. The platform emulation backends (macos, wlroots,
libei, x11, windows, xdg_desktop_portal) go back to platform-
mechanics-only — no scroll-sign awareness, no shadow per-backend
state, no duplicated transform code.

The active settings are stored per-`EmulationHandle` on
`InputEmulation` itself and applied by mutating the event before it
reaches the backend's `consume()`. The match-arm pattern (multiplier
on `Motion`, sign-flip on `Axis` / `AxisDiscrete120`) follows the
shape used in feschber#347 by Raidon Chrome, ported to per-handle.

Plumbing on the receive side:

- `lan-mouse-ipc` exposes `IncomingPeerConfig` (commit 1).
- `src/emulation.rs::ListenTask` maintains a small cache of
  `addr → fingerprint` (populated from `ListenEvent::Accept`) plus
  the latest `incoming_peers` snapshot pushed by Service.
- On Accept, ListenTask resolves the right `ReceivePostProcessing`
  for the addr and forwards it via `ProxyRequest::SetPostProcessing`
  so EmulationTask can attach it to the handle the moment one is
  assigned.
- On `EmulationRequest::SetIncomingPeers` (Service push triggered
  by user UI changes / authorize / remove / config reload),
  ListenTask re-resolves every known addr and re-publishes to keep
  currently-active sessions in sync.
- `EmulationTask` caches `addr → ReceivePostProcessing` so a
  backend respawn (CGEventTap timeout, portal session restart)
  re-applies the right values to all known handles.

`input-emulation` stays decoupled from `lan-mouse-ipc`; the
conversion from `IncomingPeerConfig` → `ReceivePostProcessing`
happens at the boundary in `src/emulation.rs` so the lower crate
doesn't grow IPC dependencies.

The deprecated global `FrontendRequest::SetNaturalScroll` /
`FrontendEvent::NaturalScroll` IPC variants stay round-tripping
through Service (persist + echo only, no emulation effect) so the
existing GUI toggle continues to render its state until the cleanup
commit removes the global UI surface.

Co-Authored-By: Raidon Chrome <soniczerops@gmail.com>
…Connections

Each authorized peer's row in the Incoming Connections list is now
an `AdwExpanderRow` instead of a flat `AdwActionRow`. Expanding a
row reveals two new controls scoped to that peer:

- Natural Scrolling toggle (`GtkSwitch`).
- Mouse Sensitivity (`GtkSpinButton`, range 0.1–5.0, step 0.1,
  default 1.0). `AdwSpinRow` would be cleaner but requires
  libadwaita `v1_4`; this codebase pins `v1_1`, so the row is
  composed manually as `AdwActionRow` + `GtkSpinButton`.

Wiring follows the existing per-client signal pattern: the row
emits `request-natural-scroll-change(bool)` and
`request-sensitivity-change(f64)`, the parent window resolves the
peer's fingerprint via the row index and dispatches
`FrontendRequest::SetIncomingPeerNaturalScroll` /
`SetIncomingPeerSensitivity`. Daemon-driven `AuthorizedUpdated`
events repopulate `KeyObject`'s new properties, which `KeyRow::bind`
syncs into the widgets with signal-blocked initial state to avoid
ping-pong.

The global Scroll preferences group at the bottom of the window
becomes vestigial here and is removed in the cleanup commit.
The global Scroll preferences group, the
`FrontendRequest::SetNaturalScroll` / `FrontendEvent::NaturalScroll`
IPC pair, the `Config::natural_scroll` accessors, and the
matching window-level `set_natural_scroll` / `request_natural_scroll`
were left in place during the per-pair refactor so the existing GUI
toggle kept rendering its state mid-series. They have no functional
effect now that scroll-direction is keyed per-incoming-peer in
`authorized_fingerprints`, so this commit drops them outright.

Net effect: the bottom of the window loses the redundant Scroll
group; users tune scroll direction per-peer by expanding the
matching row in Incoming Connections.
@jondkinney jondkinney changed the title Wall-press auto-release, scroll preferences, GUI singleton, and macOS QoL [OLD all-in-one version] - Wall-press auto-release, scroll preferences, GUI singleton, and macOS QoL May 7, 2026
…ttings

Inner `AdwActionRow`s default to activatable, so a click anywhere on
the row body bubbles up to AdwExpanderRow and collapses it — even
when the user is reaching for the GtkSwitch or GtkSpinButton inside.
Mark the two settings rows `activatable="false"`. The actual widgets
(Switch, SpinButton) still receive their own clicks because they're
focusable on their own; the row-level click target is what's
suppressed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants