Skip to content

feat(gui): cross-platform GUI singleton via dedicated socket#436

Open
jondkinney wants to merge 99 commits intofeschber:mainfrom
jondkinney:split/09-singleton
Open

feat(gui): cross-platform GUI singleton via dedicated socket#436
jondkinney wants to merge 99 commits intofeschber:mainfrom
jondkinney:split/09-singleton

Conversation

@jondkinney
Copy link
Copy Markdown
Contributor

@jondkinney jondkinney commented May 6, 2026

Cross-platform GUI singleton: a second lan-mouse invocation signals the running one to show itself instead of opening a new window.

Review-only focused diff (just this PR's commits, vs. split/08-scroll): jondkinney/lan-mouse@split/08-scroll...split/09-singleton

Summary

  • Only one Lan Mouse window per user session; second lan-mouse invocation signals the running one to show itself
  • Decoupled from the daemon socket so a headless lan-mouse daemon doesn't block a later GUI launch
  • GUI quits when the daemon's IPC connection drops
  • Skip the transient daemon-child spawn when an external daemon is already running, with a fallback respawn if it dies between probe and connect

Relationship to #407

#407 also targets the duplicate-window problem. This PR keeps the parts of #407 that are clearly good (probe before spawning a daemon child; respawn fallback if the daemon dies during GUI startup) and replaces the singleton mechanism with one that holds up under more adverse conditions:

Property #407 this PR
Singleton mechanism Daemon's IPC socket + GTK app.windows().first().present() Dedicated lan-mouse-gui.sock (Unix) / 127.0.0.1:5253 (Windows), bound by the primary GUI
Cross-platform GUI dedup GTK GApplication via D-Bus session bus Same OS-socket mechanism on Linux, macOS, Windows
Race window Probe-then-spawn (TOCTOU between is_service_running and start_service) bind() is kernel-atomic — only one process can hold the lock
Coupling to daemon state Singleton signal is the daemon socket GUI lock is its own socket; daemon lifecycle is independent
Stale lock recovery N/A (probe sees no listener and respawns) Explicit: stale socket file is detected and removed before bind
Tests None Unit test for acquire → signal → re-acquire

Why each of those matters

Cross-platform. GTK's GApplication single-instance hand-off is wired via the D-Bus session bus. That's available on most Linux desktops and absent by default on Windows; on macOS it depends on whether a session bus is running. With #407, a second lan-mouse launch on Windows simply opens another window because GTK has nowhere to forward activate to. With this PR, the same OS-socket bind mechanism is used identically on all three platforms — the singleton guarantee doesn't depend on GTK or D-Bus.

Atomic vs TOCTOU. #407's is_service_running() is a probe followed by a non-atomic spawn. Two simultaneous launches can both probe "no daemon," both start_service(), and you end up with two GUI processes both connected to whichever daemon won the bind race — the original duplicate-window problem in a slightly different shape, with no GTK dedup as backstop on Windows. bind() returns EADDRINUSE for the loser deterministically, so the worst case here is "one signaled, one waited."

Decoupled from daemon state. Because #407 reuses the daemon socket as the singleton signal, the lifecycle is conflated: a daemon restart looks like a brief gap during which a second GUI could legitimately launch (correctly, in their model — they'd connect to the new daemon). This PR's lock is GUI-scoped, so daemon crashes/restarts don't affect singleton guarantees, and a long-running headless daemon doesn't impede a later GUI launch.

Stale lock recovery. If a GUI dies without dropping its lock cleanly (SIGKILL, OOM), the socket file persists. acquire_or_signal() detects this — socket_path.exists() but connect() fails — and removes the file before binding. #407 doesn't have this concern because it never owns a separate file.

Tests. gui_lock.rs has a unit test exercising acquire → signal-from-another-thread → drop → re-acquire. It runs in CI on Linux and macOS.

What this PR borrows from #407

  • is_service_running() probe in lan-mouse-ipc to skip spawning a transient daemon child when one's already up (added in 0b0fad9)
  • try_connect() + revival fallback in lan-mouse-gtk::build_ui to handle the daemon-died-between-probe-and-connect case (added in 53ebea1). Gated on a flag from main.rs so the normal startup path (we just spawned the daemon) keeps the retrying connect() and avoids spuriously spawning a second daemon during the bind window.

Test plan

  • Run lan-mouse twice; second invocation brings the existing window to front
  • Kill the daemon; GUI exits
  • Acquire/signal/re-acquire unit test passes on Linux and macOS
  • Start lan-mouse daemon standalone, then launch lan-mouse GUI; GUI attaches without spawning a child, daemon survives GUI exit
  • Kill the daemon mid-GUI-startup; GUI respawns it and connects

Related PRs

This PR is part of an effort to split #418 into focused, independently-reviewable pieces.

The split stack (each builds on the previous; review in order):

  1. feat(capture): wall-press auto-release + Bounds protocol foundation #420 — wall-press auto-release + Bounds protocol foundation
  2. feat(cursor-sync): CursorPos protocol + cursor warps without prior Bounds #429 — CursorPos protocol + cursor warps without prior Bounds
  3. feat(capture): suppress cross-machine crossings while host screen is locked #430 — host-lock crossing suppression
  4. feat: peer version exchange via Hello proto event #431 — peer version exchange
  5. fix: hostname resolution via OS resolver + multi-homed DTLS listener #432 — multi-homed DTLS listener + OS-resolver DNS
  6. feat(discovery): mDNS-SD primary-IP hints for service-order-aware dialing #433 — mDNS-SD primary-IP hints
  7. macOS: QoL bundle (LSUIElement, TCC flow, quit-unfreezable, display wake) + UI polish #434 — macOS QoL bundle + UI polish
  8. feat: per-pair scroll & mouse-sensitivity controls + scroll/version fixes #435 — per-pair scroll/sensitivity + scroll & version-exchange fixes
  9. feat(gui): cross-platform GUI singleton via dedicated socket #436 — cross-platform GUI singleton

Plus:

jondkinney and others added 11 commits May 6, 2026 16:01
Adds a host-side fallback that releases capture when the user
sweeps the cursor against the host-adjacent edge of the guest
and keeps pushing past a configurable threshold. Solves the
"two locked screens" case where the peer's capture backend
can't fire CaptureBegin (and therefore can't send Leave back),
leaving the host stuck capturing indefinitely until the
release-bind chord is pressed.

Algorithm lives in InputCapture::poll_next so every backend
(macOS, libei, layer-shell, x11, windows, dummy) gets it for
free — they only need to emit standard motion events through
the existing Stream interface, which they already do. The
wrapper tracks:

  virtual_pos: signed position along the entry axis, clamped at
    0 from below. No upper clamp — the wrapper can't know the
    guest's far-edge extent without protocol-level cooperation,
    and any proxy is wrong for some user's setup.
  wall_pressure: motion that overshoots the host-adjacent edge
    and would have driven virtual_pos negative. Fires
    CaptureEvent::AutoRelease when the threshold is reached;
    the capture loop then runs the same teardown path as the
    release-bind chord.

State resets on Begin (entry to capture), AutoRelease (we
self-released), and external release (chord, peer Leave,
connection error, EnterOnly fallback).

Surface:
- New FrontendRequest::SetReleaseThreshold + FrontendEvent::
  ReleaseThreshold IPC pair.
- New release_threshold_px field on the daemon config (0 = off,
  serialized to config.toml).
- New AdwPreferencesGroup with a 0–500px slider in the GTK
  window. Default 0 (disabled) so existing users see no
  behavior change until they opt in.
- New CaptureEvent::AutoRelease variant + handling in
  src/capture.rs's handle_capture_event (short-circuit to
  release_capture, which already synthesizes key-ups and sends
  Leave to the peer).

Known limitation: the wrapper has no way to know where the
guest's cursor actually is (the guest doesn't tell us). On
re-entry into a peer mid-session, virtual_pos resets to 0 but
the guest's cursor may still be in the middle of its screen
from the prior session, causing the threshold to fire from
the wrong reference point. A protocol-level Bounds event +
cursor-warp on Enter is needed for full correctness.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a new ProtoEvent variant carrying the receiving device's
display geometry (in pixels). Sent by the emulation side
right after acknowledging an Enter so the capturing peer can
model the guest cursor's position along the entry axis.

Wire format: 1-byte EventType discriminator (Bounds = 11)
followed by big-endian u32 width and big-endian u32 height
— 9 bytes total, well under MAX_EVENT_SIZE (21).

This commit only adds the protocol wiring. Senders and the
host-side cache come in subsequent commits. Old peers that
don't recognize EventType=11 will skip the datagram per the
forward-compat fix in the previous commit, so deployment is
incremental: the emulation side can start sending Bounds
without breaking older capturing peers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add `display_bounds(pos)` and `warp_cursor(pos)` to the
InputEmulation trait and implement them across every backend:

  - macOS: CGDisplay APIs for bounds, CGWarpMouseCursorPosition for warp
  - x11: RandR for bounds, XWarpPointer for warp
  - wlroots: wl_output extents + virtual_pointer.motion_absolute
  - libei: region walking + ei_pointer.emit_motion_absolute
  - Windows: GetSystemMetrics + SetCursorPos
  - xdg_desktop_portal: no-op fallback (the protocol exposes neither
    bounds nor a warp primitive)

These are the prerequisites for the protocol-based wall-press
auto-release: emulation hosts now have a common API to report their
display extents to peers and to warp the cursor on Enter so the
host's modeled virtual_pos = 0 matches the guest's actual cursor.
Wire the new emulation-side capabilities into the daemon's
listener task. When a peer's Enter arrives:

  1. Reply Ack (existing behavior).
  2. Reply Bounds(width, height) using the cached display
     geometry from the active emulation backend.
  3. Warp the local cursor to the entry edge of the displayed
     position (0 for Left, width-1 for Right, etc., centered
     along the orthogonal axis).

The warp is the structural fix for the "cursor jumps back to
where it was" symptom: previously, on re-entry into a peer
mid-session, the cursor stayed wherever the prior capture
session left it, breaking the host's wall-press model
(virtual_pos=0 in the host's mind didn't match the guest's
actual cursor column). With the warp, the host's model is
synchronized with the guest's reality on every Enter.

EmulationProxy gains:
  - Cached display_bounds (Rc<Cell<Option<(u32, u32)>>>),
    refreshed each time the underlying InputEmulation is
    (re)created. Read by the listener task.
  - warp_cursor(x, y) fire-and-forget. Drops if emulation
    isn't currently active (no live backend to receive it).

ProxyRequest::Warp(x, y) carries the request to EmulationTask,
which dispatches to InputEmulation::warp_cursor.

If the active backend doesn't implement display_bounds — every
non-macOS backend right now — the listener skips the Bounds
reply and the warp call. The capturing peer falls back to its
existing "no upper clamp / virtual_pos = 0 on Begin" heuristic,
which is degraded but functional. Adding display_bounds /
warp_cursor to other backends unlocks correct behavior
incrementally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
InputCapture now keeps a per-position HashMap of peer display
geometry, populated when ProtoEvent::Bounds arrives from the
peer (handled in src/capture.rs's recv arm). track_wall_press
uses the cached entry-axis extent as the upper clamp for
virtual_pos:

  self.virtual_pos = proposed.clamp(0.0, peer_extent);

Eliminates the runaway-virtual_pos bug from the heuristic
fallback: when the user obliviously over-pushes their physical
mouse past the guest's actual far edge, the modeled position
clamps at the real width instead of climbing fictionally to
infinity. Now the user's "walk back" cost is bounded by the
guest's actual screen width.

When the peer hasn't sent Bounds yet (older peer running
without the protocol extension, or in the brief pre-Ack
window of a fresh connection), peer_extent returns INFINITY
and the model degrades to the prior heuristic.

Cache lifecycle:
  - Insert on ProtoEvent::Bounds.
  - Drop on CaptureRequest::Destroy(handle) so re-adding the
    same peer later starts fresh.

Combined with the previous commit (emulation warps cursor on
Enter), the host's virtual_pos = 0 at Begin now matches the
guest's actual cursor at column 0 (or width-1, etc.) on every
re-entry. The "cursor was in the middle, 200px back fires
release prematurely" bug is fixed structurally rather than
papered over.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The label "Auto-Release" reads as a global app preference; the
description's "forwarded mouse capture" was ambiguous about which
machine does the forwarding. Rename the group to
"Outgoing Auto-Release" so the scope mirrors the surrounding
"Outgoing Connections" / "Incoming Connections" groups, and lead
the description with "When this machine is capturing input for a
peer …" so a user scanning the window can tell at a glance that
this setting only matters when the local machine is the host.
GtkScale's default behavior treats a vertical scroll event as
+/- increment, which means the threshold creeps any time the
user is scrolling the window and the cursor passes over the
slider — easy to do given the slider sits in the middle of the
preferences pane.

Add an EventControllerScroll to the slider in CAPTURE phase that
returns Propagation::Stop unconditionally. The scale's own scroll
controller never sees the event, so the value doesn't change.

Trade-off: scrolling doesn't pass through to the parent
GtkScrolledWindow while the cursor is on the slider — the wheel
becomes inert there. Acceptable: prior behavior was actively
destructive (silent state corruption); this is just "no scroll
in this small region." If users start complaining about the gap,
the next step is to forward dy to the ancestor scrolled window's
vadjustment manually before returning Stop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Old text described the mechanism ("releases capture automatically once
the cursor pushes past the host-adjacent edge") without explaining
when the user would actually need it. With the new peer-Leave deadline
gate (34605a7), wall-press only fires when the peer can't deliver a
Leave — i.e. when the peer's screen is locked or its capture backend
is otherwise suppressed. New text leads with that framing and trims
two sentences to two.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Capture-phase scroll handler used to return Propagation::Stop to
suppress GtkScale's default scroll-to-adjust behavior, but Stop also
killed propagation to the parent — so the main window wouldn't
scroll when the cursor was over the slider. Frustrating because the
slider sits in the middle of the preferences pane and "I just want
to scroll past this" is the common interaction.

Same capture-phase handler now walks up to the ancestor
ScrolledWindow and bumps its vadjustment by `dy * step_increment`
(or 40px when step_increment is unset). Mimics what native scroll
passthrough would have done — slider value stays fixed, parent
scrolls smoothly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Windows clippy flags `loop { let Some(...) = get_msg() else { break } }`
as while-let-loop. Rewrite to `while let Some(msg) = get_msg() { … }`.
The inner `break` for `RequestType::Exit` still breaks the surrounding
while-let, so semantics are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the Proxy trait import needed by the wlroots backend's
`output.id()` call (introduced when the emulation side started
binding wl_output for display_bounds), and applies cargo fmt for
this split's own files.
jondkinney added a commit to jondkinney/lan-mouse that referenced this pull request May 7, 2026
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.
jondkinney added a commit to jondkinney/lan-mouse that referenced this pull request May 7, 2026
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.
jondkinney added a commit to jondkinney/lan-mouse that referenced this pull request May 7, 2026
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.
@jondkinney jondkinney force-pushed the split/09-singleton branch from 350e297 to 71e8a35 Compare May 7, 2026 03:41
jondkinney added a commit to jondkinney/lan-mouse that referenced this pull request May 7, 2026
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.
@jondkinney jondkinney force-pushed the split/09-singleton branch from 71e8a35 to 578b3bb Compare May 7, 2026 03:50
jondkinney added a commit to jondkinney/lan-mouse that referenced this pull request May 7, 2026
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.
@jondkinney jondkinney force-pushed the split/09-singleton branch 2 times, most recently from fa38fe2 to 386f2e7 Compare May 7, 2026 04:27
jondkinney added a commit to jondkinney/lan-mouse that referenced this pull request May 7, 2026
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.
jondkinney and others added 27 commits May 7, 2026 00:50
Removes the accidentally-tracked `.claude/scheduled_tasks.lock`
introduced one commit ago and prevents future agent-runtime state
from being committed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CGDisplayRegisterReconfigurationCallback covers monitor plug/unplug
and resolution changes during normal operation, but in clamshell-
disconnect → lid-open transitions the callback either doesn't fire
(no actual reconfigure event reaches us during sleep) or fires too
early to be useful, leaving lan-mouse's cached `self.bounds` matching
whatever the display layout was when the lid closed. Symptom: after
opening the lid on a Mac that had been clamshelled to an external
monitor and then disconnected, the cursor behaves as if constrained
to the now-absent external display's resolution.

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

Cleanup paths added: deregister + destroy notification port + drop
the boxed refcon when the run loop exits.
Pure formatting follow-ups from a code-review pass:
- input-emulation/macos.rs: move CoreFoundation imports up next to
  their core_graphics/core-foundation siblings, and let rustfmt
  collapse the IOPMAssertionDeclareUserActivity unsafe-block call
  onto a single line.
- lan-mouse-gtk/client_row.rs: rustfmt collapse of the port-title
  notify_local closure.

No behavior change.
The Lan Mouse window previously couldn't scroll its preference
groups when the window height was reduced below the natural
content height — content was simply clipped, with no way to
reach the lower groups. AdwStatusPage doesn't include built-in
scrolling.

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hyprland (and most other wl_pointer consumers — Sway, GNOME Shell
on Wayland, etc.) silently drop a continuous-scroll axis event
that arrives without a preceding `axis_source` call in the same
frame. The handler for `PointerEvent::AxisDiscrete120` was already
calling `axis_source(AxisSource::Wheel)`, which is why mouse-wheel
scrolling worked. The handler for `PointerEvent::Axis` (the
continuous-scroll path used by macOS trackpad gestures) wasn't
calling axis_source at all, so the events were forwarded onto the
wire, received by the wlr-virtual-pointer device, and then dropped
by the compositor.

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

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

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

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

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

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

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

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

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

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

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

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

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

ui: trim Scroll group description and row subtitle

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously LINES_PER_STEP=3 meant one macOS DELTA line was sent over
the wire as 40 v120 units (1/3 of a full tick). Receivers using the
wl_pointer discrete count saw `value / 120 = 0` for a single notch,
so apps that key off discrete steps (Slack via XWayland, Alacritty)
required 3+ notches before any scroll registered. Apps that consume
the continuous f64 component (ghostty's smooth-scroll path) felt
fine because they got 5.0px regardless.

macOS already amplifies SCROLL_WHEEL_EVENT_DELTA based on wheel
velocity — slow notch reports DELTA=1, fast flick reports DELTA=10+
per event — so we don't need our own multiplier. Map one macOS line
to one full v120 tick (120 units). After the fix, a slow notch
arrives as wire_value=120, discrete=1, and registers everywhere.
Flicks become correspondingly more aggressive, matching the feel of
the MagSpeed flywheel on the Mac itself.

Diagnostic logs (`[SCROLL-DEBUG capture]` in macos.rs, `[SCROLL-DEBUG
emit ...]` in wlroots.rs) are bundled here for one e2e cycle so the
Mac-side raw DELTA/POINT_DELTA values can be inspected after rebuild.
A follow-up commit reverts them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the [SCROLL-DEBUG ...] log statements added in 19d36bf and
restores the wlroots `axis_discrete` continuous-value divisor from
the experimental `value / 4` back to `value / 8`. The wev capture
during diagnosis confirmed Hyprland forwards `axis_value120 = 120`
per click intact, so terminals consume the v120 path and ignore the
continuous fallback — the divisor doesn't affect terminal feel and
`/ 8` (15 px per tick) sits closer to libinput's ~10 px convention
for the rare app that does use the continuous value.

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

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

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

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

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

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

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

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

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

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

Plumbing on the receive side:

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

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

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

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

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

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

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

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

Net effect: the bottom of the window loses the redundant Scroll
group; users tune scroll direction per-peer by expanding the
matching row in Incoming Connections.
…ttings

Inner `AdwActionRow`s default to activatable, so a click anywhere on
the row body bubbles up to AdwExpanderRow and collapses it — even
when the user is reaching for the GtkSwitch or GtkSpinButton inside.
Mark the two settings rows `activatable="false"`. The actual widgets
(Switch, SpinButton) still receive their own clicks because they're
focusable on their own; the row-level click target is what's
suppressed.
The original wording ("Sign-invert scroll deltas forwarded from
this peer before injection", "Linear multiplier applied to motion
deltas...") was implementation-flavored. Use the same plain phrasing
that the old global Scroll group had for natural-scrolling, and a
matching tone for sensitivity.
Five tightly-coupled UX changes on the per-incoming-peer row:

1. **Delete button moves into the expansion**, mirroring ClientRow's
   destructive-action layout. The new "Revoke Authorization" row at
   the bottom of the expanded body uses the same red-trash treatment
   as the outgoing-client delete row, so accidental clicks while
   scanning the list can't revoke a peer.

2. **Compact summary in the title-row suffix.** A small dim
   `Natural · 1.5×` label appears next to the title only when one
   or both settings differ from default. A freshly-authorized peer's
   row stays uncluttered; users tuning a peer can see at a glance
   what they've changed without expanding the row.

3. **Subtitle now reflects connection identity**, not the noisy
   fingerprint. New `last_addr` and `last_hostname` fields on
   `IncomingPeerConfig` are updated on `EmulationEvent::Connected`:
   `last_addr` from the connecting `SocketAddr`, `last_hostname`
   from a reverse-lookup against the existing mDNS `PrimaryCache`
   (`hostname → primary_ip`). Both persist to disk so the row stays
   identifiable across daemon restarts and while peers are offline.
   Subtitle renders as `mac-mini (192.168.1.42)`, `192.168.1.42`,
   `mac-mini`, or `(not yet connected)` depending on what's known.

4. **Fingerprint moves to a dedicated row inside the expansion**
   with a copy-to-clipboard button. The hash is selectable text in
   the row's subtitle, so users can still grab it manually if they
   prefer.

5. **In-place diff in `Window::set_authorized_keys`**, replacing
   the previous remove-all + rebuild loop. Without this, every
   `AuthorizedUpdated` round-trip (which now fires on every
   per-peer setting change too) would collapse every expanded row
   in the list. KeyRow listens to property-notify on the bound
   KeyObject so widget state tracks the in-place mutations
   without ping-ponging back as fresh user requests.

`KeyObject::new` now takes `(fingerprint, IncomingPeerConfig)`
instead of four positional fields — fewer call sites get a noisy
update when fields are added.
`ProxyRequest::Remove(addr)` was dropping the cached
`post_processing[addr]` entry alongside the EmulationHandle. That's
wrong — `Remove` fires on every `ProtoEvent::Leave` (cursor crossing
back to the peer's screen) and on the 1-second heartbeat-timeout
sweep, neither of which means the DTLS session is gone for good.
The same SocketAddr keeps delivering Input events on the next
cross-in, but the freshly-minted handle would start with default
post-processing because the cache had been wiped. The user's
per-pair scroll/sensitivity values appeared to take effect only on
the first cross of a session, then silently revert.

Repro: cross Mac → Linux (settings applied), cross back to Mac,
cross to Linux again (settings reverted to passthrough), nudge any
setting via the GUI to re-push it.

Fix: keep `post_processing` populated across `Remove`. The new-
handle path on first Input from the addr already looks the cache up
and re-applies, so settings now follow the SocketAddr instead of
the ephemeral handle. A genuine DTLS disconnect followed by a
reconnect arrives with a new ephemeral source port, so a stale
entry doesn't shadow a fresh one.
The wall-press auto-release model accumulates "wall pressure" by
projecting captured motion deltas onto the entry-axis edge. With
the receive-side `mouse_sensitivity` multiplier from the per-peer
post-processing, the receiver's actual cursor moves at
`raw_delta * sensitivity` while the host's model still sees the
raw delta — for sensitivity < 1.0 the model overruns reality and
fires AutoRelease before the receiver hits the wall, looking to the
user like the cursor crosses back early.

Repro: configure a peer's Mouse Sensitivity below 1.0, push the
cursor toward the host edge — the cross-back happens with the
guest cursor still well inside its screen.

Fix: communicate the receiver's per-pair sensitivity to the
capturing peer via a new `ProtoEvent::ReceiverSensitivity` and
scale the wall-press accumulator by it.

- **lan-mouse-proto**: new `ReceiverSensitivity { mouse_sensitivity }`
  variant + `EventType` + encode/decode (single trailing f64).
  Forward-compatible: old peers that don't recognize the variant
  silently skip per the existing `EventType::try_from` handling.
- **emulation (receiver side)**: send the variant immediately
  after `Ack` and `Bounds` on every `Enter`, looking the value up
  from the cached `IncomingPeerConfig.mouse_sensitivity`. Also
  push it live to every active peer when `SetIncomingPeers` lands
  (user-driven slider change), so the host's model picks up the
  change immediately instead of waiting for the next cross.
- **capture (sender side)**: cache per-position `peer_sensitivity`
  alongside `peer_bounds`; same lifecycle (cleared on capture
  destroy). The wall-press accumulator multiplies the entry-axis
  delta by it before adding to `virtual_pos`. Sub-1.0 values now
  legitimately let the host's model lag the receiver, matching
  reality. Default 1.0 when never received, matching prior
  behavior for old peers.
The address-lookup `get_client` matched only against `s.ips`
(union of `fix_ips` and DNS-resolved IPs). When the mDNS-primary
dialer connected to a peer at an IP that wasn't in DNS — say,
DNS resolved `peer.local` to `192.168.1.29` but mDNS advertised
`192.168.1.88` and the dialer preferred the latter — the
listen-side counterpart of the same connection arrived from
`192.168.1.88`, which `s.ips` doesn't include, so the lookup
returned `None` and the matching `EmulationEvent::PeerHello`
was silently dropped. Visible symptom: the peer-version display
in the GUI never updated for peers reached via the mDNS
primary path.

Pre-existing since the mDNS-primary feature; not caused by the
recent per-pair scroll/sensitivity work.

Fix: also match against `s.active_addr.ip()`. The successful
connect path already records the operational addr via
`set_active_addr(handle, addr)`, so the lookup picks up
whichever IP the peer is actually using right now in addition
to the configured/DNS-known set.
The listen-side `PeerHello` path is racy: when the peer dials in
before our own outbound dial completes, `get_client(addr)` finds
no client (no `s.ips` entry, no `active_addr` yet) and the peer's
commit is silently dropped. The connect-side `receive_loop`
later receives the peer's `Hello` echo and writes it to
`client_manager.peer_commit`, but historically that was a
fire-and-forget mutation with no GUI broadcast — so the
version-status row stayed at "unknown" indefinitely.

Plumb the connect-side commit through to Service so it can call
`broadcast_client` on receipt:

- `connect.rs`: forward `Hello` over the existing `tx` channel
  alongside the existing `client_manager.set_peer_commit` call.
- `capture.rs`: handle the forwarded `Hello`, emit a new
  `ICaptureEvent::PeerCommitUpdated(handle)` so the event bubbles
  up to Service through the existing capture-event channel.
- `service.rs`: handle `PeerCommitUpdated` by calling
  `broadcast_client(handle)`. `client_manager` already has the
  fresh commit thanks to `connect.rs`'s direct write, so the
  broadcast picks up the right value.

The listen-side path stays as-is — it's still useful for the
asymmetric case where outbound is broken but inbound works (the
case 1ea7148 was added for). The two paths are complementary now:
listen-side fires when `get_client(addr)` matches; connect-side
fires whenever Linux successfully dials out and receives the echo,
regardless of `s.ips` / `active_addr` race timing.

Repro: peer's hostname resolves via DNS to a stale IP while mDNS
advertises the correct one. The dialer prefers mDNS, the actual
DTLS connection lands at the mDNS IP, but `s.ips` only contains
the stale DNS IP. Before this commit, the GUI showed
"Peer version: unknown" indefinitely; after, it populates as soon
as the outbound dial echoes back the peer's `Hello`.
Reordering commits onto a fresh upstream/main base exposes that
the cargo fmt commit from the original integrated branch was doing
double duty — fmt for code from THIS split plus fmt for code that
exists in later splits. We now apply fmt only to this split's own
code, on top of which a small Proxy-trait import is needed because
`wayland_client::Proxy` provides the `output.id()` method used in
the wlroots backend; without that trait in scope, `id` resolves
only to the (private) struct field.
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.
When the daemon child exits unexpectedly — most often because its
CGEventTap died after macOS Accessibility was revoked, see
input-capture/src/macos.rs::ProducerEvent::EventTapDisabled — the
IPC connection it was holding open closes. The GUI's frontend-event
loop now detects this via receiver.recv() returning Err and routes
through request_quit_with_backstop instead of staying alive with a
dead daemon.

Routing through the backstop (rather than a raw process::exit(1))
gives the macOS quit path its 5-second force-exit safety net, so a
wedged GTK main loop during shutdown still terminates cleanly. On
non-macOS platforms it falls through to a plain app.quit().

Closes the second leg of the AX-revoke detection chain: tap dies →
daemon exits → IPC drops → GUI quits → all kernel taps released.
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.
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.

1 participant