Per-pair clipboard sync with app-source suppression#2
Open
jondkinney wants to merge 25 commits into
Open
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Optional, per-pair clipboard text sync with cross-platform app-source suppression. Built on top of feschber/lan-mouse#327 (Daniel Nakov, attributed via
Co-Authored-Byon Phase 1). Replaces the globalenable_clipboardboolean with two per-pair gates aligned to the per-pair scroll/sensitivity work insplit/08-scroll:ClientConfig.clipboard_send— outgoing gate; defaultfalseIncomingPeerConfig.clipboard_receive— incoming gate; defaultfalseBoth 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.
Diff vs prior PR (#1 — macOS menubar app):
macos-menubar-app...feat/clipboard-per-pairHighlights
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 droppedServicetracksrecent_forwardedkeyed on(originator_fp, content_hash)with 1 s TTLhyprctl activewindow -j) and Sway (swaymsg -t get_tree)_NET_ACTIVE_WINDOW+WM_CLASSviax11rb.desktopfiles (XDG dirs, including Flatpak exports), resolves icons via the freedesktop hicolor theme (PNG and SVG via gdk-pixbuf + librsvg), and matches Chrome--app=URLPWAs back to their source.desktopviaExec=URL host (e.g.chrome-discord.com__channels_@me-Default→Discord.desktop)GetForegroundWindow→ process basename viaQueryFullProcessImageNameWobjc2-app-kitagainstNSWorkspace, with anosascriptfallback for plain Cocoa apps the daemon can't see directly. Also honorsorg.nspasteboard.ConcealedTypeper the nspasteboard.com convention so 1Password etc. are auto-suppressedlast_contentnow advances on every state-changing decision, including suppressed paths, so an alt-tab between polls can't leak the suppressed content. Pinned byPollDecision::classifyunit testsTest plan
.desktoppicker shows real names + icons, including Chrome--app=URLPWAs>4 KiBclipboard payload dropped at sender with debug log; receiver unchangedTests
47 unit tests, all green.
cargo clippy --workspace --all-targets -- -D warningsclean.lan-mouse-proto— clipboard frame round-trip, oversize rejection, truncated decodelan-mouse-ipc—AppIdentmatches/serde/labels,IncomingPeerConfiglegacy compat,ClientConfigdefaultslan-mouse(service) —clipboard_hashdeterminism,recent_forwardedTTL evictioninput-capture— 9PollDecision(focus-race regression pin), 12desktop_entries(parser + Chrome-PWA matcher), 3frontmost_app(smoke + Wayland detection), 7 from existing modulesRollout
Defaults stay off — existing pairs see no behavior change on upgrade. Per-OS sections in
clipboard_suppress_appsso a singleconfig.tomlsynced between machines doesn't bleed Mac bundle IDs into Linux session classes and vice versa.🤖 Generated with Claude Code