Skip to content

feat: per-pair clipboard sync with app-source suppression#438

Open
jondkinney wants to merge 118 commits into
feschber:mainfrom
jondkinney:feat/clipboard-per-pair
Open

feat: per-pair clipboard sync with app-source suppression#438
jondkinney wants to merge 118 commits into
feschber:mainfrom
jondkinney:feat/clipboard-per-pair

Conversation

@jondkinney
Copy link
Copy Markdown
Contributor

@jondkinney jondkinney commented May 8, 2026

Summary

Optional, per-pair clipboard text sync with cross-platform app-source suppression. Built on top of #327 (Daniel Nakov, attributed via Co-Authored-By on Phase 1). Replaces the global enable_clipboard boolean with two per-pair gates aligned to the per-pair scroll/sensitivity work in #435:

  • ClientConfig.clipboard_send — outgoing gate; default false
  • IncomingPeerConfig.clipboard_receive — incoming gate; default false

Both must be true for clipboard text to flow in a given direction. Plus a per-OS suppression list for password managers and other sensitive apps.

Note

Diff is currently inflated because this branch carries the entire #420#436 stack in its history. Once those land in main, the diff here shrinks to just the clipboard work. Review the commits view for the focused per-commit narrative; the files-changed view is best read after the prerequisites merge.

For a focused view of just the clipboard work added on top of #435, see split/08-scroll...feat/clipboard-per-pair — that compare URL bypasses the stack and shows only the 15 commits / ~5 K LOC introduced by this PR.

Highlights

  • Per-pair gating mirrors the per-pair scroll/sensitivity architecture from feat: per-pair scroll & mouse-sensitivity controls + scroll/version fixes #435
  • Wire format: ProtoEvent::Clipboard { from_fingerprint, content } carrying the originator's TLS fingerprint for N-peer rebroadcast loop prevention. Variable-length frames cap at 4 KiB; oversize is logged and dropped
  • N-peer loop prevention: Service tracks recent_forwarded keyed on (originator_fp, content_hash) with 1 s TTL
  • Cross-platform suppression:
    • Linux/Wayland: Hyprland (hyprctl activewindow -j) and Sway (swaymsg -t get_tree)
    • Linux/X11: _NET_ACTIVE_WINDOW + WM_CLASS via x11rb
    • Linux suppression-picker also walks .desktop files (XDG dirs, including Flatpak exports), resolves icons via the freedesktop hicolor theme (PNG and SVG via gdk-pixbuf + librsvg), and matches Chrome --app=URL PWAs back to their source .desktop via Exec= URL host (e.g. chrome-discord.com__channels_@me-DefaultDiscord.desktop)
    • Windows: GetForegroundWindow → process basename via QueryFullProcessImageNameW
    • macOS: objc2-app-kit against NSWorkspace, with an osascript fallback for plain Cocoa apps the daemon can't see directly. Also honors org.nspasteboard.ConcealedType per the nspasteboard.org convention so 1Password etc. are auto-suppressed
  • Focus-race fix: clipboard poll's last_content now advances on every state-changing decision, including suppressed paths, so an alt-tab between polls can't leak the suppressed content. Pinned by PollDecision::classify unit tests
  • README: new "Clipboard Sync" section documents per-pair gates, the per-OS clipboard_suppress_apps shape, ConcealedType auto-skip, and the 4 KiB / text-only / UTF-8 limits

Test plan

  • Per-pair toggle ON/OFF, both directions; persistence across daemon restart
  • Three-peer fan-out with no echo
  • Loop prevention for repeated copies of the same content within 1 s
  • Linux/Wayland (Hyprland) + macOS app suppression verified live
  • macOS concealed-pasteboard auto-suppression verified with 1Password
  • Linux .desktop picker shows real names + icons, including Chrome --app=URL PWAs
  • >4 KiB clipboard payload dropped at sender with debug log; receiver unchanged
  • Pre-clipboard peers silently ignore the new event types — no disconnect

Tests

47 unit tests, all green. cargo clippy --workspace --all-targets -- -D warnings clean.

  • 3 in lan-mouse-proto — clipboard frame round-trip, oversize rejection, truncated decode
  • 10 in lan-mouse-ipcAppIdent matches/serde/labels, IncomingPeerConfig legacy compat, ClientConfig defaults
  • 3 in lan-mouse (service) — clipboard_hash determinism, recent_forwarded TTL eviction
  • 31 in input-capture — 9 PollDecision (focus-race regression pin), 12 desktop_entries (parser + Chrome-PWA matcher), 3 frontmost_app (smoke + Wayland detection), 7 from existing modules

Rollout

Defaults stay off — existing pairs see no behavior change on upgrade. Per-OS sections in clipboard_suppress_apps so a single config.toml synced between machines doesn't bleed Mac bundle IDs into Linux session classes and vice versa.

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
  10. this PR — per-pair clipboard sync + app-source suppression (depends on the per-pair architecture from feat: per-pair scroll & mouse-sensitivity controls + scroll/version fixes #435)

Plus:

jondkinney and others added 30 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.
`test windows-latest`'s only failure was the post-job cache-save
step in `Swatinem/rust-cache@v2` (job step 27); `cargo test`
itself (step 12) succeeded with all tests passing. CI rerun on
upstream requires admin rights, so an empty commit is the only
way to retrigger from this side.
Before: when crossing machines, the guest's cursor jumped to the
midpoint of the entry edge — a ~100 px Y-jump on typical
displays — because the guest snapped to a hardcoded
(0, h/2) / (w/2, 0) point on Enter. Visually discontinuous and
hard to follow when the user is mid-task.

After: the host's capture backend snapshots the screen-space cursor
position at the instant of the edge crossing (CGEvent.location()
on macOS — the only backend that can report this today; others
emit None and the guest falls back to the prior midpoint warp).
The capture loop scales those host coords against the cached peer
geometry and sends them as a new ProtoEvent::MotionAbsolute right
after Enter. The guest handles MotionAbsolute by warping the
cursor to (x, y), overriding the entry-edge midpoint so the user
sees visual continuity across the boundary.

Layered choices:

- New ProtoEvent::MotionAbsolute { x, y } primitive rather than
  bolting an offset onto Enter — gives a reusable
  position-setting building block for future features (snap to
  point on app launch, multi-monitor handoff, follow-host-cursor
  modes) without inventing more event variants.
- Pixel coordinates in the receiver's screen space, not normalized
  floats — host already caches peer bounds (Bounds proto event)
  for the wall-press upper clamp, so it can do the scaling and
  the guest just calls warp_cursor directly. Guest's
  warp_cursor primitive already takes pixels.
- Backwards compatibility: peers running the previous protocol
  don't recognize MotionAbsolute and skip it via the forward-
  compat decode-tolerance fix from earlier in this branch. Old
  hosts paired with new guests fall through to the entry-edge
  midpoint (current behavior); new hosts paired with old guests
  ignore MotionAbsolute and the cursor stays at the edge midpoint
  too — neither pair regresses.

Capture backend coverage in this commit: macOS only (the
CGEventTap callback has cg_ev.location() at the moment of edge
crossing). Other backends (libei, x11, layer_shell, windows,
dummy) emit Begin { cursor: None } and don't send MotionAbsolute,
so the guest falls back to the midpoint warp on Enter. Adding
cursor-position reporting to those backends is a per-backend
follow-up.

InputCapture trait grew display_bounds() (default impl returns
None; macOS implements via CGDisplay::active_displays) and a
peer_warp_target(pos, cursor) helper that combines the host's
own bounds, the cached peer bounds, and the cursor position into
a target point on the peer's screen. peer_warp_target returns
None when either bounds is unavailable, in which case the capture
loop just doesn't emit MotionAbsolute.
The cross-axis cursor preservation introduced in 6c1bd88 was macOS-only;
the layer-shell capture backend (Wayland/Hyprland and similar wlroots
compositors) emitted Begin { cursor: None }, so transitions where Linux
was the host fell back to the entry-edge midpoint warp on the guest —
the same 300–400 px Y-jump the macOS path was fixed to avoid.

Read surface_x / surface_y from wl_pointer::Enter and translate to
compositor screen-space using the layer-surface's anchor edge: surfaces
here are 1 px on the on-axis dimension and span the cross-axis, so the
surface-local cross-axis coord is the screen offset directly. To support
multi-output setups, store the output's compositor position+size on the
Window when it's created, and add a display_bounds() override that
returns the union rectangle of all active outputs (mirrors the macOS
impl so MotionAbsolute scaling stays consistent).

Effect: Linux→peer transitions where Linux is the source now preserve
cross-axis cursor position the same way macOS→peer transitions already
do.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Counterpart to 6c1bd88's Enter-time cross-axis preservation. When the
host releases capture (release-bind chord, auto-release threshold, peer
destroyed), the visible cursor reappears at whatever point capture
started — typically the entry-edge midpoint or wherever the guest
chose to warp to. The user perceives this as a 100–400 px Y-jump even
though Mac→Linux→Mac round-trip "should" feel continuous, because
nothing in the release path tells the host where the guest's cursor
visually was at the moment of release.

Track a virtual_cursor (f64, f64) in the wrapper that mirrors the
guest's screen-space cursor: seeded on Begin from the
peer_warp_target / entry-edge midpoint (whatever the guest will
actually do on Enter), accumulated against every Motion event we
forward, clamped to peer bounds. On release, project it back to host
screen-space with host_warp_target_on_release — symmetric inverse of
peer_warp_target — and pass that as a new Option<(i32, i32)>
parameter on the Capture::release trait method. macOS threads the
target through ProducerEvent::Release and warps before show_cursor()
so the visible cursor reappears at the matching host point. Other
backends ignore the parameter (they don't hide/manage the system
cursor on the way out).

This is a no-op when peer_bounds or display_bounds is unavailable —
fallback is the previous behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Self-sufficient counterpart to MotionAbsolute. Carries the host's
cursor as a normalized fraction (0..1) of the host's own screen
plus the entry side from the receiver's frame. The receiver
scales nx/ny against its own display bounds and pins the on-axis
dimension to the matching edge.

The point: MotionAbsolute requires the host to know the peer's
geometry (cached via a prior `Bounds` event), which doesn't exist
on the very first crossing — `Bounds` is only sent in response to
`Enter`, so the host can't include MotionAbsolute on the same
crossing that asks for the bounds it needs. CursorPos sidesteps
the round-trip dependency entirely; the receiver does the
scaling locally with its own bounds.

Wire format adds f32 codec impl alongside existing u8/u32/i32/f64.
Old peers don't know the new EventType tag and skip the event via
the proto forward-compat decode-tolerance path; they continue to
warp to the entry-edge midpoint as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion to peer_warp_target for the bounds-free CursorPos path.
Normalizes the host's screen-space cursor against the host's own
display bounds — no peer geometry consulted, so a return value
of Some is independent of whether the peer has sent Bounds yet.

The capture loop will emit this fraction as ProtoEvent::CursorPos
right after Enter so the guest can warp on the very first
crossing instead of falling through to the entry-edge midpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After Enter, also send a ProtoEvent::CursorPos carrying the host's
cursor as a normalized fraction of the host's own screen and the
entry side. Both events are emitted; the receiver tolerates either
and the second warp wins.

Why both: MotionAbsolute is more precise when peer_bounds is
already cached (uses peer pixel coords directly), but it can't
fire on the first crossing because the cache is populated by
Bounds — which only arrives in response to Enter. CursorPos has
no such dependency: it's self-contained, so the first crossing
warps correctly. Emitting both keeps backwards-compat with old
guests that only know MotionAbsolute (they get correct warps from
the second crossing onward, same as before) while letting new
guests warp on every crossing including the first.

Old guests skip the unknown CursorPos tag via the proto forward-
compat decode-tolerance path. Order on the wire is MotionAbsolute
then CursorPos so a new guest that handles both ends up with the
CursorPos warp — accurate against the peer's *current* display
bounds, robust to mid-session display reconfiguration on the
receiver.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Receiver-side counterpart to the capture change: scale the
host-normalized fraction by our own display bounds, pin the
on-axis dimension to the entry edge matching the side the host
is on, and warp the local cursor.

This is what makes the very first arch->macOS crossing seamless.
The previous code path required arch (host) to have cached macOS
(peer) bounds before sending MotionAbsolute, but those bounds
arrive in response to Enter — too late for the same crossing.
CursorPos lets the receiver do the scaling itself, so no prior
round-trip is needed.

Falls through silently when display_bounds is unavailable on the
receiver (rare; occurs for backends without a geometry-query
trait impl). In that case the entry-edge midpoint warp from the
preceding Enter remains the user-visible result, same as before
this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`host_normalized_cursor` and `peer_warp_target` previously did
`nx = cx / host_w`, assuming cursor coordinates fall in
`[0, host_w]`. On macOS specifically (and X11 generally), the global
pointer-coordinate system is anchored at the primary display's
top-left, so a secondary monitor positioned LEFT of (or ABOVE) the
primary gives cursor x ∈ `[-w, 0)` for points on it. The
`clamp(0.0, 1.0)` silently masked this — every point on a
left-attached external normalized to nx = 0 ("left edge"), and the
receiver warped to the wrong column on every crossing from that
display.

Add `Capture::display_origin()` returning `(xmin, ymin)` with a
`(0, 0)` default for backends whose primary IS the origin
(Windows, most X11/Wayland setups), and macOS overrides to walk
`CGDisplay::active_displays()` for the actual leftmost/topmost
corner. `host_normalized_cursor` and `peer_warp_target` now subtract
the origin before normalizing, so off-primary cursor positions
produce correct fractions.

The wall-press auto-release model's `initial_virtual_cursor`
estimate inherits the fix automatically since it routes through
`peer_warp_target`. No protocol change — `CursorPos` payload stays
the same; only the host-side normalization that produces
`(nx, ny)` is corrected.
The dual-event design (MotionAbsolute + CursorPos sent back-to-back
on every Enter) existed to support old receivers that only
recognized MotionAbsolute. Now that we've dropped support for those
old receivers, we can simplify to a single CursorPos warp per Enter.

CursorPos is the better primitive anyway — it carries the host's
cursor as a normalized fraction of the host's own bounds plus the
entry side, so the receiver scales against its *current* live
display geometry. Doesn't suffer the cached-peer-bounds staleness
or first-crossing bootstrap problems MotionAbsolute had.

Changes:

- src/capture.rs: stop computing/sending MotionAbsolute on Begin.
  Only emit CursorPos when the backend reported a cursor position.
- src/emulation.rs: remove the MotionAbsolute receive handler.
  Tighten CursorPos cross-axis clamp to `dim - 1` so a host edge
  (nx == 1.0 or ny == 1.0) doesn't compute one pixel past the
  receiver's addressable column/row.

Wire-format byte stability: ProtoEvent::MotionAbsolute stays in
`lan-mouse-proto` so the EventType discriminant byte for CursorPos
doesn't shift. The variant just goes unused; the receive side hits
the catch-all `_ => {}` arm if a peer running an older build sends
one.
The virtual_cursor model — which release-time
host_warp_target_on_release reads to figure out where on the
host screen to put the cursor when capture ends — depends on
peer_bounds being cached at the moment Begin fires. peer_bounds
is populated in response to a Bounds event the peer sends after
receiving our Enter, so on the very first crossing it's
guaranteed to be None: we send Enter, Begin fires, the host
asks initial_virtual_cursor for a seed, peer_bounds is None,
seed returns None, virtual_cursor stays None for the rest of
the session.

The track_wall_press Motion handler then has a
`if let (Some(vc), Some(_)) =` guard that silently drops every
delta when vc is None. So even after Bounds finally arrives,
virtual_cursor is never updated — and at release time the warp
falls back to the original-crossing y. Symptom: cross macOS top
→ arch top → drift down on arch → return; cursor lands at the
top y you crossed at, not the bottom y you drifted to.

Two new fields stitch the bootstrap together:

  pending_begin_cursor: Option<(i32, i32)>
    The host-coord cursor reported by the backend at Begin time.
    Stashed unconditionally so we can replay the seed once
    peer_bounds is known.

  pending_motion: (f64, f64)
    Motion deltas that arrived while virtual_cursor was None.
    Without this they'd be silently lost; with it we apply them
    cumulatively to the seeded vc when bootstrap completes.

set_peer_bounds gains a retroactive-seeding path: when the new
bounds match the active capture_pos and virtual_cursor is still
None and we have a pending Begin cursor, call
initial_virtual_cursor (which will now succeed because we just
inserted the bounds), add pending_motion, and assign. Both
pending fields cleared in reset_wall_press_state.

Track-press Motion's match changed from a single guard to
explicit arms — `(Some, Some)` accumulates into vc as before;
`(None, _)` accumulates into pending_motion (with a debug log
so we can confirm the path is exercised when investigating).

Diagnostic logging:
  [wp-begin]  per Begin: pos, host cursor, peer_bounds presence,
              the seeded virtual_cursor.
  [wp-motion] per deferred Motion: deferred dx/dy and current
              peer_bounds for the position. Debug level.
  [bootstrap] when retroactive seeding fires: the seeded vc
              and the drained pending_motion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`display_bounds` returns the size of the host's display union;
`display_origin` returns its top-left corner in pointer-event
coordinate space. The release-time warp was building target
coordinates as `0..host_w` of the union rectangle without adding
the origin back, so on hosts whose primary display isn't
anchored at (0, 0) — typically macOS with externals attached
above or to the left of the primary, where the global
pointer-coord origin sits at the leftmost/topmost monitor — the
warp_target landed outside the addressable space. Symptom on
those setups would be the cursor reappearing way off-screen,
or warp silently failing.

Same correction we already applied in `host_normalized_cursor`
and `peer_warp_target` (see 30606ca for the rationale on the
input side); the release path needs the symmetric inverse on
the output.

Single-monitor hosts and multi-monitor hosts whose primary IS
the union origin (Windows, most X11/Wayland setups, macOS with
a single built-in display) are unaffected — `display_origin`
returns (0, 0) for them, which is what the previous code
implicitly assumed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Temporary diagnostic. Will be cleaned up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The wlroots emulation backend reported display_bounds from
wl_output's raw mode dimensions (e.g. 2560x1600) while the
capture side reports them from xdg_output's LogicalSize (e.g.
2400x1500 with software scaling). The CursorPos warp computes
target = ny * emulation_bounds, so warps landed at proportions
shifted relative to where the sender measured the crossing.

Bind zxdg_output_manager_v1 in WlrootsEmulation, request an
xdg_output companion for each wl_output, and prefer the logical
size/position when computing union_bounds. Falls back to wl_output
mode/geometry per-output when xdg-output isn't advertised.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the peer takes over (sends Enter+CursorPos), the host was
also releasing capture and warping its local cursor based on the
last-known peer virtual_cursor. The two warps fired on the same
shared cursor and raced — the host's stale warp frequently won,
clobbering the peer's authoritative proportional landing and
making the cursor appear at whatever position the host *thought*
the peer cursor was, regardless of where the user actually
crossed.

Split the release path: ReleaseForHandover skips the host
warp_target so CursorPos is the only warp on remote-takeover.
The release-bind chord and backend auto-release still go through
the original release_capture path that computes a host warp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ReleaseNotify wasn't the only source of host warp races. When the
peer's local capture begins, it sends ProtoEvent::Leave to every
incoming connection (service.rs:357), which the recipient's
capture loop handles by calling release_capture — computing a
host warp from stale virtual_cursor and racing against the peer's
upcoming CursorPos warp on the shared cursor.

Route peer-Leave release through release_capture_handover so the
proportional CursorPos warp lands without competition. The rare
case where the peer released without taking over (no Enter/
CursorPos follows) just leaves our cursor where it was — fine,
since nothing else is moving it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Enter handler unconditionally warped the local cursor to the
midpoint of the entry edge, intending to seat virtual_pos=0 at
column 0 before the host's stream of relative motion arrived.
But the host now sends CursorPos right after Enter, which carries
the proportional landing point AND pins the on-axis dimension to
the matching edge — making the midpoint warp redundant.

Worse, the midpoint warp races against fast handovers: when the
user crosses, then crosses back within ~100ms, the local
CGEventTap (or layer-shell equivalent) reads the cursor's
location field at the new crossing while the cursor is still
sitting at the midpoint from the previous Enter — never
advancing to the proportional CursorPos warp that would have
followed. The opposite-direction CursorPos then encodes
ny=0.500 ("middle of source") and the receiver dutifully warps
its cursor to its own middle, producing the persistent
"always lands in the middle" symptom even after suppressing the
host-warp races on both sides.

Trust the host: if it can compute a proportional point (which it
can in every case where Begin.cursor was populated), CursorPos
seats the cursor correctly. If it can't, the cursor stays where
it was — preferable to a forced midpoint that masquerades as a
mid-screen crossing on subsequent re-crosses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jondkinney and others added 30 commits May 7, 2026 00:50
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`.
Phase 1 of the per-pair clipboard sync feature: lift the primitives
from feschber#327 verbatim and wrap them in a wire format
that pre-bakes the originator fingerprint needed for N-peer loop
prevention later in the rollout.

- arboard dependency on input-capture and input-emulation
- input-capture::ClipboardMonitor (500ms poll, 200ms debounce)
- input-emulation::ClipboardEmulation (blocking-task wrapper)
- input_event::ClipboardEvent + Event::Clipboard variant; Event drops
  Copy so the new String payload compiles
- lan-mouse-proto::ProtoEvent::Clipboard { from_fingerprint, content }
  encoded via variable-length encode_clipboard_event /
  decode_clipboard_event helpers (fixed-buffer codec panics for
  this variant). MAX_CLIPBOARD_SIZE caps total wire payload at 4 KiB
- InputEmulation intercepts Event::Clipboard in consume() and routes
  it to the cross-platform ClipboardEmulation sink, so per-backend
  emulations stay platform-mechanics-only
- Round-trip + over-size + truncated-decode unit tests for the new
  codec

No service wiring yet — ClipboardMonitor isn't instantiated and no
peer can transmit a ProtoEvent::Clipboard. Behavior change: zero.
Phase 2 wires capture, IPC, and per-pair Service routing.

Co-Authored-By: dnakov <3777433+dnakov@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 wires up clipboard sync end-to-end, gated per-pair:

- ClientConfig.clipboard_send (serde-default false): per-pair gate
  on the broadcast side
- IncomingPeerConfig.clipboard_receive (legacy-friendly Deserialize,
  default false): per-pair gate on the receive side
- Two new FrontendRequest variants: SetClientClipboardSend,
  SetIncomingPeerClipboardReceive — handled in Service, persisted via
  the existing config-write path
- Service spawns the cross-platform ClipboardMonitor at startup,
  drains it via a new tokio::select! arm, and on each local clipboard
  change fans out ProtoEvent::Clipboard{from_fingerprint=self_fp,
  content} to every active client whose clipboard_send is true
- emulation::ListenTask gates inbound ProtoEvent::Clipboard frames by
  the receiving peer's clipboard_receive, injects locally through
  emulation_proxy.consume (which short-circuits to ClipboardEmulation
  ::set), and surfaces a new EmulationEvent::ClipboardReceived
  upward so Service can refresh ClipboardMonitor.last_content (loop
  prevention against the local 500ms poll) and re-fan to other peers
- N-peer rebroadcast loop prevention: Service tracks
  recent_forwarded: HashMap<(originator_fp, content_hash), Instant>
  with a 1s TTL. Both the local-capture and the forwarding paths
  insert; the forwarding path skips the originator by IP and
  short-circuits when the (origin, hash) entry is fresh
- LanMouseConnection.sender_clone(): cheap send-only handle that
  shares all dialer state with the original; lets Service emit
  clipboard frames without routing through the capture session loop
- Wire format: connect.rs and listen.rs now read into a buffer sized
  for MAX_CLIPBOARD_SIZE and dispatch by event-type tag, routing
  clipboard frames through decode_clipboard_event and everything
  else through the existing fixed-buffer try_into path

The two-peer happy path: copy text on a peer with clipboard_send=true
to another peer with clipboard_receive=true and the text appears in
the receiver's clipboard. With both gates default-false this is opt-
in per pair; existing pairs see no behavior change on upgrade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new AdwSwitchRow toggles mirroring the scroll/sensitivity
pattern from PR feschber#435:

- ClientRow gets "Share Clipboard With This Peer" — drives
  FrontendRequest::SetClientClipboardSend on toggle, reads
  client.clipboard_send on update_client_config so the GUI stays in
  sync with the daemon's persisted state.
- KeyRow (Incoming Connections) gets "Accept Clipboard From This
  Peer" — drives SetIncomingPeerClipboardReceive on toggle, picks
  up server-driven changes via property-notify so the in-place
  diff in set_authorized_keys flips the switch without re-creating
  rows. The collapsed-row settings_summary now includes a
  "Clipboard" token when receive is on, alongside Natural / N×.

ClientObject and KeyObject each gain a matching GObject property
(clipboard-send / clipboard-receive). The bindings + signal
block/unblock dance follows the existing pattern so server-
originated values don't ricochet back as fresh user requests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4: cross-platform frontmost-app detection + a user-maintained
suppression list that ClipboardMonitor consults before broadcasting
a change. macOS is stubbed for a Mac-side build pass — see
CLIPBOARD_PLAN.md "macOS TODOs".

- New shared type `lan_mouse_ipc::AppIdent` with platform-tagged
  variants (MacBundle / WindowsExe / LinuxX11 / LinuxWayland).
  Case-insensitive equality within a variant; cross-variant
  comparisons always false so a Mac entry doesn't suppress a
  Windows peer.
- New `input-capture/src/frontmost_app.rs`:
  - Linux: Hyprland via `hyprctl activewindow -j`, Sway via
    `swaymsg -t get_tree`, X11 via x11rb (_NET_ACTIVE_WINDOW +
    WM_CLASS). Wayland vs X11 dispatch off `WAYLAND_DISPLAY`.
  - Windows: GetForegroundWindow → GetWindowThreadProcessId →
    OpenProcess + QueryFullProcessImageNameW; basename, lowercased.
    list_running_apps walks visible top-level windows + dedups by
    process basename.
  - macOS: stubs returning None / empty with module-level docs
    pointing to the objc2-app-kit work needed.
- ClipboardMonitor::with_suppression(SuppressionList) checks the
  list on every change; on a hit it drops both the emit AND the
  last_content update, so a later non-suppressed copy of the same
  text still flows.
- Service owns the canonical Arc<Mutex<HashSet<AppIdent>>> and
  routes Add/Remove/List requests; SuppressedAppsUpdated and
  RunningApps events flow back to the GUI (Phase 5 wires the
  modal). Persisted as `clipboard_suppress_apps` in `config.toml`.
- input-capture gains `lan-mouse-ipc` + `serde_json` + `x11rb` deps
  (the first for the shared AppIdent type, the latter two for the
  Linux backend implementations).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 5: minimum-viable management UI for the clipboard
suppression list.

- New "Clipboard Privacy" preferences group at the bottom of the
  main window with a "Manage" button. Subtitle reflects the
  current count ("0 apps" / "1 app" / "N apps") and updates in
  place as the daemon pushes SuppressedAppsUpdated.
- New ClipboardPrivacyWindow modal (single window, no nested sub-
  modal): boxed list of current entries with per-row trash buttons
  + an inline "Add an App" group with a kind dropdown
  (mac_bundle / windows_exe / linux_x11 / linux_wayland) and a
  free-form value entry. Add / Remove emit GObject signals that
  Window catches and routes to AddSuppressedApp /
  RemoveSuppressedApp requests.
- Daemon-driven SuppressedAppsUpdated events flow into the modal
  via Window::set_suppressed_apps so the list stays in sync even
  when the modal is closed.

Deferred (planned in CLIPBOARD_PLAN.md and tracked in source
comments):
- "From running apps" tab — for now the daemon's
  ListRunningApps reply is reserved but unused. The manual entry
  path is enough to manage the list in this first cut.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 6: 16 new unit tests across the affected crates plus a
manual test plan that drops cleanly into the PR description.

- lan-mouse-ipc: AppIdent matches() (case-insensitive within
  variant, always-false cross-variant), serde round-trip for
  every variant, kind-tag stability check (mac_bundle /
  windows_exe / linux_x11 / linux_wayland), label() platform
  rendering, IncomingPeerConfig legacy bare-string + legacy-Full-
  without-clipboard_receive deserialization paths,
  ClientConfig.clipboard_send default-false on omitted field.
- lan-mouse: clipboard_hash determinism + distinct-input
  separation, recent_forwarded TTL eviction contract.
- input-capture: frontmost_app() / list_running_apps() smoke
  tests (must not panic in a headless / sandboxed environment),
  Wayland-detection helper exposed at module scope and exercised
  from the test suite to pin the WAYLAND_DISPLAY-precedence rule.

CLIPBOARD_TEST_PLAN.md walks through 13 manual checkpoints
covering: per-pair gates default to false, two- and three-peer
fan-out, toggle persistence, suppression-list manual entry,
suppression actually suppresses on Linux/Windows (with a
follow-up checklist for macOS once the objc2 work lands), and
forward-compat with older peers that don't know the new event
type.

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

`Event::Clipboard(ClipboardEvent::Text(String))` (vendored in
5f74233) made `Event` non-Copy, but the macOS event-tap callback
in input-capture/src/macos.rs still copied via `*e` when fanning
collected `res_events` into the channel. The macOS build broke at
compile time on this branch — the rest of the workspace happened
to dodge it because no other call site copied an Event after the
clipboard variant landed.

Switch the iteration to `into_iter()` and move each `CaptureEvent`
through `blocking_send`. The `res_events` Vec is freshly built on
every callback invocation, so the move is fine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… sections, running-app picker

Closes the macOS gap left by 9269ce6 ("Phase 4: Linux + Windows
only") and rebuilds the suppression-list UX around what macOS
actually exposes vs hides to a non-Cocoa LSUIElement child.

Concealed-pasteboard auto-suppression (`input-capture`)
- Wire `objc2` + `objc2-app-kit` (NSWorkspace / NSPasteboard /
  NSImage / NSBitmapImageRep / NSRunningApplication) and
  `objc2-foundation` (NSString / NSData / NSDictionary / NSURL).
- `clipboard.rs::is_concealed_clipboard` checks the general
  pasteboard's `types` array for `org.nspasteboard.ConcealedType`
  — the nspasteboard.org convention password managers use to opt
  out of clipboard-manager capture. Honored before the user list
  so 1Password etc. just work without a manual entry.
- `frontmost_app::macos::frontmost_app` now resolves via
  `NSWorkspace.frontmostApplication.bundleIdentifier`, replacing
  the Phase-4 stub. Doc updates in CLIPBOARD_PLAN.md mark the
  macOS TODOs done.

Per-OS data model (`lan-mouse-ipc`, `src/config.rs`, `src/service.rs`)
- `ClipboardSuppression { macos, windows, linux_wayland, linux_x11:
  Vec<String> }` replaces the flat `Vec<AppIdent>`. Each host
  reads/writes only its own slot via `host()` / `host_mut()`; the
  other slots round-trip untouched, so a config synced across
  machines (dotfiles, Syncthing) keeps each machine's list intact.
- `HostKind::current()` picks `MacBundle` / `WindowsExe` /
  `LinuxWayland` / `LinuxX11` (Wayland-vs-X11 decided at runtime
  via `WAYLAND_DISPLAY`). `make_ident(value)` wraps a host string
  in the matching `AppIdent` variant for the runtime suppression
  check.
- `FrontendRequest::AddSuppressedApp(String)` /
  `RemoveSuppressedApp(String)` and
  `FrontendEvent::SuppressedAppsUpdated(Vec<String>)` now carry
  plain identifier strings; the kind is implicit from the host
  OS. Service rebuilds the runtime `HashSet<AppIdent>` shared
  with `ClipboardMonitor` whenever the host slot changes.

Running-app picker with icons (`lan-mouse-gtk` ↔ `input-capture`)
- New `RunningApp { display_name, identifier, icon_png:
  Option<Vec<u8>> }` IPC type. `FrontendEvent::RunningApps(Vec<
  RunningApp>)` carries the picker payload.
- `frontmost_app::macos::list_running_apps` shells out to
  `osascript` → System Events for `every process where background
  only is false`. Three direct AppKit APIs (NSWorkspace
  .runningApplications, NSRunningApplication
  .runningApplicationWithProcessIdentifier, CGWindowListCopyWindow
  Info) all silently scope to the caller's loginwindow / Aqua
  session and return only ~3 entries from a non-Cocoa GTK process
  — System Events is itself fully session-attached so it returns
  the real list. Apple Events permission is already declared via
  `NSAppleEventsUsageDescription` (we use it for input emulation).
- Icons via `NSWorkspace.iconForFile:` (path-based, session-
  independent), encoded to PNG by picking the closest-but-no-
  smaller-than-64 px rep. Per-bundle-id icon cache amortizes the
  5-second auto-refresh.
- New `frontmost_app::lookup_app_metadata(identifier)` resolves a
  bundle ID to display-name + icon via Launch Services
  (`URLForApplicationWithBundleIdentifier`) so the suppressed-
  apps list renders 1Password's name + icon even when 1Password
  isn't currently running.
- `lan-mouse-gtk` gains a direct `input-capture` dep (default
  features off) and bumps `gtk4` to `v4_6` for `Texture::from_
  bytes`. Picker enumeration runs in the GUI process — the daemon
  child can't see other apps (same Aqua-session restriction).

GTK rewrite (`clipboard_privacy_window`, `window`)
- `AdwComboRow` with a custom `SignalListItemFactory` renders
  Image + Label per row at a fixed 320 px min-width so the
  popover doesn't shrink horizontally as the user types into
  search. `RunningAppObject` GObject carries display_name +
  identifier + decoded `gdk::Texture`.
- Already-suppressed apps are filtered out of the picker so the
  user can't add a duplicate; selection is preserved across
  refreshes if the picked app is still present.
- The suppressed-apps list (above the picker) renders the same
  `Image + display_name` treatment instead of raw bundle IDs;
  metadata is mirrored from the running-apps cache and lazily
  filled via `lookup_app_metadata` for not-currently-running
  entries. Trash button uses `error` style (red) to match the
  authorize-key UI in `key_row.ui`.
- `Window::open_clipboard_privacy_window` calls
  `frontmost_app::list_running_apps()` directly on first open,
  then via a 5-second `glib::timeout_add_local` while the modal
  is visible. Refresh is skipped while the picker's popover is
  open so a search-in-progress isn't disrupted.
- Removing a suppressed entry now re-applies the picker filter
  against the cached running-apps snapshot immediately, so the
  removed app reappears as a candidate without waiting for the
  next 5 s tick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GTK's application-level accelerators (set via
`app.set_accels_for_action`) only deliver to GtkApplicationWindow
children. Our modals (clipboard_privacy_window,
authorization_window, fingerprint_window) are plain AdwWindow, so
without an explicit per-modal key controller, Cmd+W (macOS) /
Ctrl+W (Linux/Windows) falls through to the focused
ApplicationWindow — i.e. the main window — and closes that
instead of the modal. Exactly the wrong UX.

- New `lan-mouse-gtk/src/modal_keys.rs` exposes
  `wire_close_shortcuts(window)` that attaches an
  `EventControllerKey` matching `Escape` and `<Cmd|Ctrl>+W` and
  calls `window.close()`. Bubble phase ensures a child widget that
  handles the key first — open AdwComboRow popover, focused
  search entry, etc. — consumes it and our handler doesn't fire.
  That's intentional: pressing Esc with the picker open dismisses
  the picker, not the whole modal.
- All three modal `imp::constructed()` blocks call this helper
  instead of hand-rolling the same Esc handler. Net diff is
  smaller per modal because the inline `EventControllerKey` +
  `connect_key_pressed` block disappears.
- `clipboard_privacy_window.ui` gains `hide-on-close="True"` so
  the macOS traffic-light close (red X) dismisses the window
  without destroying our cached `RefCell<Option<Window>>` — the
  default GtkWindow close-request destroys the widget, which then
  collides with the long-lived cached reference and leaves the
  user with a modal that visually doesn't close.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "Manage" button next to Suppressed Apps in the main window
and the "Add to Suppression List" button in the privacy modal
were styled with `pill` + (`flat` | `suggested-action`), which
gives them rounded ends and noticeably more vertical padding
than the surrounding rows. Removing `pill` keeps each button's
intent (flat / suggested) but matches the standard rectangular
treatment used elsewhere in the app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 7: same picker quality on Linux as macOS — display names
and icons for the running-apps list and for previously-added
entries.

- New `input-capture/src/desktop_entries` module:
  - Walks XDG_DATA_DIRS-defined `applications` directories
    (system, user, Flatpak system & user) parsing
    `[Desktop Entry]` for `Name`, `Icon`, `StartupWMClass`.
    Filters Type≠Application, Hidden=true, NoDisplay=true.
  - Indexes the resulting map by lowercased filename stem AND
    by lowercased StartupWMClass so e.g. Hyprland's `1Password`
    class lines up with `1password.desktop` (StartupWMClass=
    `1Password`).
  - `icon_bytes_for_name()` resolves freedesktop icon names via
    /usr/share/icons/hicolor/{128x128,256x256,64x64,…}/apps/
    PNG → scalable/apps/ SVG → /usr/share/pixmaps fallback.
    Absolute paths in `Icon=` (PWA shortcuts) bypass the search.
- Linux backend in frontmost_app.rs:
  - `list_running_apps()` enriches each Hyprland/Sway/X11
    runtime identifier with its .desktop metadata; falls
    through to raw-string display when no .desktop entry
    matches. Re-sorts by enriched display name.
  - `lookup_app_metadata()` resolves a stored identifier back
    to display name + icon for the GUI's saved-entries list,
    so a not-currently-running entry still renders nicely.
- 9 new unit tests for the .desktop parser (Type/Hidden/
  NoDisplay/Link filtering, comments, blank lines, locale
  section bleed) + a `#[ignore]`-gated `discover_apps_dump`
  utility for manual local verification.

The wire protocol is unchanged: PNG and SVG bytes both flow
through `RunningApp::icon_png` because `gdk::Texture::from_bytes`
on the GTK side handles both via gdk-pixbuf + librsvg.

Verified locally: 82 .desktop entries discovered, including
1password → "1Password" with the `1password` hicolor icon
correctly resolved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The omarchy `omarchy-launch-webapp <url>` flow (and any other
`--app=URL` Chrome shortcut) reports a Hyprland class like
`chrome-discord.com__channels_@me-Default`. The shipping
`Discord.desktop` has Name + Icon + Exec=URL but no
StartupWMClass, so the direct .desktop index missed it and the
picker fell through to the raw class + generic gear icon.

- `desktop_entries::discover_apps()` now returns an
  `AppDirectory` with TWO indices:
  - `by_identifier` — filename stem + StartupWMClass (existing
    behavior).
  - `by_webapp_host` — every host parsed from `https?://…` tokens
    in `Exec=` lines.
- `AppDirectory::lookup()` tries direct first, then parses the
  identifier as a Chrome `--app=` class
  (`chrome-<host>__<path>-<Profile>` with `-default` /
  `-profile_N` fallback for path-less URLs) and probes the host
  index. Misses fall through to the existing raw-string display.
- 9 new unit tests pin Exec URL extraction (quoted args, port +
  query strip, multi-URL lines), Chrome PWA host parsing (path
  form, path-less form, alt profiles, rejection of extension
  IDs / unknown suffixes), and the AppDirectory lookup chain.

Verified locally on the omarchy-style Discord shortcut: the
runtime class `chrome-discord.com__channels_@me-Default` now
resolves to `Discord` with the Discord icon via
`web discord.com → Discord`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ClipboardMonitor previously left `last_content` at the prior
emitted value when a change was suppressed (either by the
app-source list or by the macOS concealed-pasteboard check). The
500ms poll loop kept seeing the SAME suppressed content as
"changed" on every tick and re-ran the suppression check. Any
focus shift between polls (typical user flow: copy from
1Password, alt-tab to terminal/chat) put a non-suppressed app in
the frontmost slot at exactly the wrong moment, the suppression
list miss returned `None`, and the password got broadcast.

Reproduced live on Linux/Wayland: with `1password` in the
suppression list, copying from 1Password and switching to
Ghostty within ~5 seconds reliably leaked the password to the
peer at the next poll. Debug logs showed the leak fire on the
single tick where `hyprctl activewindow -j` reported the new
frontmost.

Fix: always advance `last_content` / `last_change` immediately
after the change-detection event, regardless of which branch
(suppressed-by-app, concealed, or emit) actually fires. The
suppressed value is now "consumed" — we wait for the next
real clipboard change before deciding again.

The original "blind to suppressed value" rationale (preserving
non-secret syncs that happen after a secret) was buggy under
its own logic too: the sequence `bar → foo (suppressed) → bar`
left `last_content` at the original `bar`, so the second `bar`
copy looked unchanged and didn't emit either. Updating
unconditionally fixes both cases — the user copying the same
non-secret value again after a suppressed copy now triggers a
proper change-detection event.

After the fix, the same Linux/Wayland test produces ONE
suppression check + decision per copy event with no leak,
including when focus shifts immediately afterward.

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

The Messages bug had two layered causes:

1. `NSWorkspace.frontmostApplication` from the daemon (a fork+exec
   child of an LSUIElement parent) is silently scoped to the
   caller's loginwindow / Aqua session and returns `nil` for plain
   Cocoa apps the daemon doesn't share a Mach connection with.
   1Password happens to be visible (likely via its accessibility
   integration); Messages, Notes, and most Apple system apps aren't.
   `frontmost_app::frontmost_app()` was therefore returning `None`,
   `is_suppressed()` short-circuited to `None`, and the broadcast
   went out regardless of the user's suppression list.

2. The 500 ms poll cadence left a wide race window: even when
   `frontmost_app()` did resolve, the user could Cmd+Tab between
   copy and the next poll fire and the suppression check would see
   the wrong app.

Fix:

- `frontmost_app::macos::frontmost_app` now shells out to `osascript`
  → System Events (`get bundle identifier of first application
  process whose frontmost is true`). System Events is itself fully
  Aqua-attached, so it returns the real frontmost regardless of our
  process's session quirks. Apple Events permission is already
  declared via `NSAppleEventsUsageDescription` (we use it for input
  emulation), so no additional grant prompts. ~50–150 ms per call,
  but only fires on actual clipboard changes.

- `ClipboardMonitor` polling switches to a changeCount-first
  pattern on macOS: every 100 ms tick we read
  `NSPasteboard.changeCount` (single Objective-C call, ~µs) and
  short-circuit when it hasn't advanced. Only when it has do we
  pay the cost of `arboard::Clipboard::get_text` + frontmost
  lookup + suppression check.

  Net effect: 5× tighter race window (100 ms vs 500 ms — well
  below human Cmd+Tab speed), with LOWER aggregate CPU than today
  because 99% of ticks now exit at the integer compare. Other
  platforms keep 500 ms + always-read because they have no cheap
  precheck and the existing cadence works fine.

Verified end-to-end: with Messages in the suppression list, copy
from Messages now logs:

    clipboard suppression check: list=[...] active=Some(MacBundle("com.apple.MobileSMS"))
    clipboard change suppressed (frontmost app `com.apple.MobileSMS (macOS bundle)`)

and no broadcast goes out. Same for any other plain Cocoa app the
user adds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refactor ClipboardMonitor's poll loop to call a pure
`PollDecision::classify` function for the change-detection +
suppression-gate decision, and gate `last_content` advancement
on `PollDecision::advances_state()` instead of an inline copy of
the rule. Both pieces are pure functions with no I/O so the
focus-race invariant — "advance last_content on every state-
changing decision, including Suppressed" — is now expressible
as `assert!(d.advances_state())` in unit tests.

9 new tests in `input-capture/src/clipboard.rs::tests`:

- Unchanged when text matches last_content.
- Debounced when 200ms window hasn't elapsed.
- Emit on first change and on a normal cleared-suppression
  change.
- Suppressed for both concealed-pasteboard and app-list paths.
- The regression-pin: `suppressed_decision_advances_state` —
  if this fails, the live leak we caught (1Password password
  broadcast on Ghostty alt-tab after copy) is back.
- Companion: Unchanged + Debounced must NOT advance state
  (otherwise peer-driven syncs echo).
- `content_might_emit` short-circuit pre-flight check used by
  the poll loop to skip the expensive frontmost-app /
  concealed-pasteboard probes when the content didn't change.

Also drop the design + test plan markdown files
(CLIPBOARD_PLAN.md, CLIPBOARD_TEST_PLAN.md) — they were working
docs, won't ship in the PR, and the design is fully captured in
the commit history + module docs at this point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mark "Clipboard support" done in the roadmap and add a "Clipboard
Sync" section before the roadmap covering: per-pair gates,
text-only / 4 KiB / UTF-8 limits, the per-OS
`clipboard_suppress_apps` config shape (so a single config.toml
can be shared across machines), the macOS automatic
`org.nspasteboard.ConcealedType` honor, and N-peer loop
prevention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI on feschber#438 surfaced three categories of issues
the local dev workflow had been letting through:

- macOS clippy `-D warnings` flagged `unnecessary_unsafe` on the
  `URLForApplicationWithBundleIdentifier` call (now safe in
  objc2-app-kit 0.3.2), `dead_code` for `is_wayland_for_test` on
  macOS (only called from the Linux backend), and
  `unnecessary_sort_by` for the `frontmost_app::list_running_apps`
  display-name sort.
- All-platform clippy `-D warnings` flagged 10 `uninlined_format_args`
  hits across `input-capture/src/clipboard.rs` and
  `input-emulation/src/clipboard.rs` — lifted as auto-fixes by
  `cargo clippy --fix`.
- `cargo fmt --check` flagged style drift in 5 files (line-length
  collapses + closure→sort-by-key rewrite).

Net effect: `cargo fmt --check` clean and
`cargo clippy --workspace --all-targets -- -D warnings` clean on
macOS / Linux / (presumably) Windows. All 47 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LPARAM moved from windows::core to windows::Win32::Foundation in
windows v0.61.2; nested extern fn called process_basename via
super:: which resolved one module too high.
`&content[..40]` panics when byte 40 splits a multi-byte UTF-8
sequence, crashing the daemon every time a clipboard broadcast is
logged. Use char-based truncation so the Display impl can never
panic on user payloads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings Linux GUI parity with the macOS menu-bar item:

- StatusNotifierItem registered via ksni (works with waybar /
  Plasma / AGS without extra system deps).
- Left-click toggles the window; menu exposes "Open Lan Mouse" and
  "Quit Lan Mouse".
- close-request now hides the window instead of destroying it on
  Linux (X button, GTK window.close, WM-level close all funnel
  through the same handler).
- Super+W bound to window.close so the close path is keyboard-
  reachable on Linux too.
- ApplicationHoldGuard kept for the lifetime of the process so the
  tray survives the last visible window closing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Its only entry was "Close window" — the X button and Super+W cover
that path, so the empty header-bar menu button was just visual noise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add INFO-level logs around tray Activate / SecondaryActivate / the
TogglePresent handler so we can verify which click event the host
emits and what the resulting visibility transition is. Also map
SecondaryActivate (middle-click) to the same toggle so the icon is
responsive on hosts that use middle-click as primary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-ups to the initial tray work:

1. waybar fires `Activate` twice per physical click in some
   configurations, causing the toggle to cancel itself (the window
   appeared to flash). Drop any Activate / SecondaryActivate that
   arrives within 300 ms of the previous one.

2. Render the bundled SVG into ARGB32 pixmaps at multiple sizes
   (16/22/32/48/64), each rendered at 1.3× the target then
   centre-cropped — the trim removes the SVG's natural padding so
   the glyph fills the host's tray slot instead of leaving the
   ~10% margin theme icons typically reserve. Effect: lan-mouse's
   icon visibly fills more of the slot than neighbouring icons
   without changing the host's `icon-size`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The desktop icon (de.feschber.LanMouse.svg) carries a lot of detail
that becomes unreadable at 12-22 px tray sizes — the surrounding
canvas overwhelmed the small mouse silhouette inside. Replace it
for the tray with a dedicated lan-mouse-tray.svg: simple light-grey
mouse silhouette with a dark scroll-wheel anchor, tight viewBox.

Pixmap rendering also switched from a fixed 1.3× centre crop to a
content-bbox scan that crops to the actual non-transparent extent
of whatever SVG we render, so the glyph fills the slot regardless
of the source's authored padding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ksni transitively depends on libdbus-sys, whose build script needs
dbus-1.pc. Add libdbus-1-dev to the apt step in rust.yml and dbus
to the Linux buildInputs in nix/default.nix and flake.nix devshell.
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