feat(clipboard): app-source suppression infra (Linux + Windows)

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>
This commit is contained in:
Jon Kinney 2026-05-07 11:22:50 -05:00
parent ecf46fb850
commit 9269ce6f01
10 changed files with 1109 additions and 13 deletions

406
CLIPBOARD_PLAN.md Normal file
View file

@ -0,0 +1,406 @@
# Per-pair clipboard sync + app-suppression — implementation plan
Self-contained working plan so a fresh context can pick this up and execute. All
design decisions below are user-signed-off; open questions section at the bottom
is intentionally empty.
## Context
PR #327 (Daniel Nakov) added bidirectional clipboard sharing across all
connected peers, gated by a single global `enable_clipboard` boolean in
`config.toml`. We want the same feature, but:
1. **Per-pair gating** instead of global — `clipboard_send` on each
outgoing `ClientConfig`, `clipboard_receive` on each incoming
`IncomingPeerConfig`. Sync only happens when both ends have their
respective bit on. Aligns with the per-pair architecture we already
shipped for scroll & sensitivity in PR #435 (`IncomingPeerConfig`,
`AdwExpanderRow` per peer).
2. **App-source suppression** — user-maintained list of apps whose
clipboard contents must never be sent to peers. Specifically password
managers (1Password, Bitwarden, LastPass, KeePassXC, etc.). Plus
automatic suppression of macOS clipboard items marked with
`org.nspasteboard.ConcealedType` UTI.
## Decisions (signed off)
1. **Defaults:** both `clipboard_send` and `clipboard_receive` default to
**`false`** (opt-in). Clipboard contents are a meaningfully different
trust scope than mouse/keyboard; authorization-for-input shouldn't
imply authorization-for-clipboard.
2. **macOS concealed-type auto-suppression:** **include in Phase 4**.
Catches password managers without the user maintaining a list. Layer
the user-maintained list as a supplement.
3. **Linux compositor coverage:** **Hyprland + Sway + X11 only** for
v1. KDE / GNOME require DBus calls / Shell extensions and can be a
follow-up. Users on those compositors won't have suppression
detection (we surface that as a known limitation in the README of
the new PR).
4. **App-suppression modal UX:** **running-apps tab + manual-entry tab
only**. No "recently active during copies" LRU for v1.
## What's reusable from #327
Cherry-pickable as-is, with `Co-Authored-By: Daniel Nakov <…>` trailer:
- `arboard` crate dependency (cross-platform clipboard primitive).
- `input-capture/src/clipboard.rs` (`ClipboardMonitor`: 500 ms poll,
200 ms debounce, `update_last_content` to prevent reading our own
writes as external changes).
- `input-emulation/src/clipboard.rs` (`ClipboardEmulation::set()`,
blocking-task wrapper).
- `Event::Clipboard(ClipboardEvent::Text(String))` enum variant.
- Per-backend emulation wiring (libei, macos, windows, wlroots, xdp) —
small additions consuming `ClipboardEvent`.
What we **rewrite**:
- The global `enable_clipboard` flag → per-pair bits.
- The capture-side broadcast logic → per-peer fan-out by `clipboard_send`.
- The listen-side accept logic → per-peer gate by `clipboard_receive`.
- `ProtoEvent::Clipboard` gets an originator-fingerprint field for
N-peer loop prevention (#327's 200 ms debounce only solves N=2).
## Branch strategy
- **Base:** branch off `split/08-scroll` because we depend on the
`IncomingPeerConfig` schema introduced there.
- **Branch name:** `feat/clipboard-per-pair`.
- **PR target:** `main`. Note in the description: "depends on #435
landing first; until then the diff includes #435's commits as
context. Will rebase onto `main` once #435 merges."
- After #435 merges to upstream `main`, rebase
`feat/clipboard-per-pair` onto `main` so the PR's diff shrinks to
just the clipboard work.
- Alternative if #435 is already merged when we start: branch off
`main` directly.
## Phase 1 — lift the reusable bits from #327
Single commit. Behavior change: zero (primitives only, no service wiring).
**Commit message:** `feat(clipboard): vendor primitives + protocol from #327`
with `Co-Authored-By: Daniel Nakov <NN@…>` (resolve email via
`git -C /home/jon/Code/lan-mouse log --pretty='%an <%ae>' …` against
the source commit on `feschber/lan-mouse#327`).
**Files:**
- `Cargo.toml` (workspace) and `input-capture/Cargo.toml` /
`input-emulation/Cargo.toml` — add `arboard` dep at the version
used in #327.
- **NEW** `input-capture/src/clipboard.rs` — lift verbatim.
- **NEW** `input-emulation/src/clipboard.rs` — lift verbatim.
- `input-event/src/lib.rs` — add `ClipboardEvent::Text(String)` and
the `Event::Clipboard(ClipboardEvent)` variant.
- `lan-mouse-proto/src/lib.rs` — add `ProtoEvent::Clipboard {
from_fingerprint: String, content: String }`. (Note the
pre-baked `from_fingerprint` field — not in #327. See Phase 2 loop
prevention.) `EventType::Clipboard` + encode/decode (length-prefixed
strings; mind `MAX_EVENT_SIZE` so total fits — content cap stays at
~4 KB minus fingerprint overhead).
- Per-backend trait method addition on `Emulation`:
`async fn set_clipboard(&self, text: String) -> Result<…>` with a
default no-op. Concrete impls in macos.rs / wlroots.rs /
libei.rs / windows.rs / xdg_desktop_portal.rs delegate to
`ClipboardEmulation::set()`.
- `Cargo.lock` churn accepted once.
**Build check:** `cargo build --release -p lan-mouse` should be clean.
No new behavior triggers.
## Phase 2 — per-pair config + IPC + Service routing
The architectural commit. Behavior change: clipboard sync starts
working, gated per-pair.
**Files:**
- `lan-mouse-ipc/src/lib.rs`:
- `IncomingPeerConfig`: add `clipboard_receive: bool` (default
`false`). Update the custom `Deserialize` to default-false on
legacy entries.
- `ClientConfig`: add `clipboard_send: bool` (default `false`).
Update its serde derives.
- New requests: `FrontendRequest::SetClientClipboardSend(ClientHandle, bool)`,
`FrontendRequest::SetIncomingPeerClipboardReceive(String, bool)`.
- `FrontendEvent`: rely on existing `State` + `AuthorizedUpdated`
broadcasts to push values back to GUI; no new event variant.
- `src/config.rs`: legacy-friendly load (already handled via
`IncomingPeerConfig`'s untagged-enum `Deserialize`). Add migration
for `ClientConfig` so old `clients = […]` entries default the new
field to false.
- `src/service.rs`:
- Handlers for the two new requests; mutate `client_manager` /
`authorized_keys`, broadcast updated state, save config.
- **Capture-side broadcast site:** when `ClipboardMonitor` emits
a `Clipboard` event, fan out via `LanMouseConnection`, but only
to peers where the `ClientConfig.clipboard_send` is true. Stamp
the originator fingerprint (this device's
`public_key_fingerprint`) onto the `ProtoEvent::Clipboard`
before send.
- **Listen-side accept site:** when `ProtoEvent::Clipboard`
arrives, look up the peer's `IncomingPeerConfig` by fingerprint
via existing `addr_to_fingerprint` cache. Drop with debug log
if `clipboard_receive` is false. Otherwise:
1. Inject locally via `ClipboardEmulation::set`.
2. Loop-prevention check (see below).
3. Forward onward to other peers whose `clipboard_send` is true,
skipping the originator and any peer fingerprint we've already
forwarded the same content to within the last 1 s.
- `src/capture.rs` — wire up `ClipboardMonitor` and route its events
through Service. Spawn the monitor at Service start.
- `src/connect.rs` / `src/listen.rs` — pass through the new ProtoEvent.
**Loop prevention (N-peer):**
- `Service` keeps a small in-memory map
`recent_forwarded: HashMap<(String /*from_fp*/, u64 /*content_hash*/), Instant>`
with a 1 s eviction sweep.
- Before forwarding, check the (origin, hash) tuple. If recently
forwarded, skip. If not, record and proceed.
- The originator fingerprint guarantees A → B → C still terminates
cleanly even if C is also subscribed to A directly.
**Build + smoke test:** with two peers, both with their respective
toggles on, copy on A and verify clipboard on B updates. Toggle B's
`clipboard_receive` off and verify content stops landing.
## Phase 3 — per-pair GTK toggles
Two new switch rows mirroring what we shipped in #435.
**Files:**
- `lan-mouse-gtk/resources/client_row.ui` — new
`AdwSwitchRow` titled "Share clipboard with this peer", subtitle
"Allow this peer to receive copies you make on this device.".
`activatable=false` to not collapse the expander on click.
- `lan-mouse-gtk/src/client_row/imp.rs` — template_child + signal
wiring; emits `request-clipboard-send-change(bool)`.
- `lan-mouse-gtk/src/client_row.rs` — refresh helper, property-notify
on `ClientObject` for `clipboard-send`.
- `lan-mouse-gtk/src/client_object*.rs` — new property
`clipboard-send` (bool).
- `lan-mouse-gtk/resources/key_row.ui` — new
`AdwSwitchRow` titled "Accept clipboard from this peer".
- `lan-mouse-gtk/src/key_row/imp.rs` — template_child + signal,
emits `request-clipboard-receive-change(bool)`.
- `lan-mouse-gtk/src/key_row.rs` — refresh helper, property-notify
on `KeyObject`.
- `lan-mouse-gtk/src/key_object*.rs` — new property
`clipboard-receive` (bool).
- `lan-mouse-gtk/src/window.rs` — new signal closures dispatch to
`FrontendRequest::SetClientClipboardSend` /
`SetIncomingPeerClipboardReceive`.
- Optionally extend `format_summary_parts` in `key_row.rs` to
surface clipboard state in the collapsed-row summary.
## Phase 4 — app-suppression infrastructure
The biggest chunk of new code. Cross-platform "what's the frontmost
app" abstraction + concealed-type detection on macOS + the suppression
list itself.
**New crate or new module?** Lives in `input-capture` since it's
read by `ClipboardMonitor`. New file:
`input-capture/src/frontmost_app.rs`.
**Files:**
- **NEW** `input-capture/src/frontmost_app.rs`:
```rust
pub enum AppIdent {
MacBundle(String), // e.g. "com.1password.1password7"
WindowsExe(String), // e.g. "1Password.exe" (basename, lowercased)
LinuxX11(String), // WM_CLASS instance/name
LinuxWayland(String), // xdg-toplevel app_id
}
pub fn frontmost_app() -> Option<AppIdent>;
pub fn list_running_apps() -> Vec<AppIdent>; // for the "From running apps" tab
```
Per-platform impl via `cfg`:
- **macOS:** `objc2` to call
`NSWorkspace.frontmostApplication.bundleIdentifier`. List via
`NSWorkspace.runningApplications`.
- **Windows:** `GetForegroundWindow()`
`GetWindowThreadProcessId()``OpenProcess` +
`QueryFullProcessImageNameW` → basename. List via
`EnumProcesses` + `QueryFullProcessImageNameW`.
- **Linux/Wayland:** shell out to `hyprctl activewindow -j` and
`swaymsg -t get_tree`, parse JSON `app_id`. Fallback `None`
on KDE / GNOME / unknown compositors. List via the same IPC
queries iterating the tree.
- **Linux/X11:** `xcb` for `_NET_ACTIVE_WINDOW`
`WM_CLASS`. List via `_NET_CLIENT_LIST`.
- `input-capture/src/clipboard.rs` (modify the lifted file):
- Take an `Arc<Mutex<SuppressionList>>` constructor arg.
- In the change-detection loop, before emitting, call
`frontmost_app()` and check membership.
- On macOS, also call a new `is_concealed_clipboard()` helper
that checks `NSPasteboard.types` for `org.nspasteboard.ConcealedType`.
(Layer this on top of `arboard`'s text read; one extra
`objc2` call.)
- Suppressed path: log at debug, **do NOT** call
`update_last_content`. (Reasoning: if user pastes a password
then copies a non-secret then copies the same password again,
we still want to sync the non-secret correctly. Forgetting
"we saw this content" lets later copies through.)
- `lan-mouse-ipc/src/lib.rs`:
- New `pub struct ClipboardSuppression { pub apps: Vec<AppIdent> }`
on `Config` (or just `Vec<AppIdent>` directly).
- New requests: `FrontendRequest::AddSuppressedApp(AppIdent)`,
`FrontendRequest::RemoveSuppressedApp(AppIdent)`,
`FrontendRequest::ListRunningApps`.
- Event: `FrontendEvent::SuppressedAppsUpdated(Vec<AppIdent>)`,
`FrontendEvent::RunningApps(Vec<AppIdent>)`.
- `src/config.rs`: persist `clipboard_suppress_apps` at the top
level of `config.toml`.
- `src/service.rs`: handlers for the new requests. Push the
`Arc<Mutex<SuppressionList>>` into the spawned `ClipboardMonitor`
at startup; refresh on `AddSuppressedApp` / `RemoveSuppressedApp`.
## Phase 5 — app-suppression GTK surface
UI for managing the suppression list. Modal dialog mirrors
`AuthorizationWindow`.
**Files:**
- `lan-mouse-gtk/resources/window.ui` — new "Clipboard Privacy"
preferences group near the bottom (after "Network Discovery").
Single `AdwActionRow` with title "Suppressed apps", subtitle
showing count ("0 apps" / "1 app" / "N apps"), suffix button
"Manage" that opens the modal.
- **NEW** `lan-mouse-gtk/resources/clipboard_privacy_window.ui`:
modal `AdwWindow` with:
- Title "Apps that won't share their clipboard"
- Header bar with close button
- `AdwPreferencesGroup` listing current entries; each entry is
a row with the app identifier and a delete button
- `AdwPreferencesGroup` with "Add app" `AdwActionRow` whose
suffix button opens a sub-dialog
- Empty-state placeholder when list is empty
- **NEW** `lan-mouse-gtk/resources/add_suppressed_app_window.ui`:
sub-modal with two-tab `AdwViewStack`:
- **From running apps**`GtkListView` populated from
`FrontendEvent::RunningApps`. Refreshed when the dialog opens.
- **Manual entry**`GtkEntry` for free-form text + dropdown
for platform/identifier-type (defaults to current platform's
natural identifier). Validation hint: "On macOS use the bundle
ID (e.g. `com.1password.1password7`); on Windows use the
executable name (e.g. `1Password.exe`); …"
- **NEW** `lan-mouse-gtk/src/clipboard_privacy_window.rs` and
`add_suppressed_app_window.rs` — wrapper widgets following the
`AuthorizationWindow` pattern.
- `lan-mouse-gtk/src/lib.rs` — handle
`FrontendEvent::SuppressedAppsUpdated` and
`FrontendEvent::RunningApps`; route to the privacy window.
- `lan-mouse-gtk/src/window.rs` — wire the "Manage" suffix button
to open `ClipboardPrivacyWindow`.
## Phase 6 — test + ship
**Unit tests** (in respective crates):
- `AppIdent` parsing/serialization round-trips.
- `frontmost_app()` smoke tests on each supported platform (best-
effort; some require a windowed environment).
- `recent_forwarded` LRU-style map: insert, eviction at TTL.
**Manual test plan** (in the PR description):
- 1Password on macOS: copy a password while clipboard sync is on
with a peer; verify password does NOT propagate (concealed-type
auto-detect).
- 1Password on Linux/Wayland (Hyprland or Sway): same; verify
app-id-based suppression catches it.
- 1Password on Windows: same; verify exe-name suppression catches it.
- Add a non-password-manager app to the suppression list; copy from
it; verify suppression.
- Remove the same app from the list; copy again; verify propagation
resumes.
- Per-pair toggle off (either send or receive): verify no
propagation.
- 3-peer fan-out: A → B → C, verify C receives once and doesn't
echo back to A.
- Restart daemon; verify suppression list and per-pair toggles
persist via `config.toml`.
**CI:** all platforms green before tag.
## Open questions / decisions still to make
(All sign-offs done as of plan creation. Section is intentionally empty;
re-add here if anything comes up during implementation.)
## macOS TODOs (deferred to a follow-up build pass on a Mac)
Phase 4 landed Linux (Hyprland + Sway + X11) and Windows
implementations of `frontmost_app::frontmost_app()` /
`list_running_apps()`. macOS is currently a stub returning
`None` / `Vec::new()`. To finish:
1. **Frontmost app**:
`NSWorkspace.frontmostApplication.bundleIdentifier`. Either pull
in `objc2` + `objc2-app-kit` (clean Rust bindings, ~10 LOC)
or shell out to:
```sh
osascript -e 'tell application "System Events" to get bundle identifier of first application process whose frontmost is true'
```
The shell-out has ~50ms latency — fine for the 500ms clipboard
poll. The bindings approach is preferred if we expect concealed-
type detection too (next bullet).
2. **Running apps**:
`NSWorkspace.runningApplications` map → bundle IDs.
3. **Concealed-type auto-suppression**:
`NSPasteboard.generalPasteboard.types` checking for
`org.nspasteboard.ConcealedType` UTI. Layer this on top of the
user-maintained suppression list; when present, drop the change
without consulting the list (and without
`update_last_content`). Same Objective-C bridge as #1, so worth
doing in the same patch.
The bundle of changes lives in `input-capture/src/frontmost_app.rs`
under `#[cfg(target_os = "macos")]`. The user-maintained list +
manual entry already work cross-platform, so macOS users can still
exercise the feature today by typing bundle IDs into the manual
entry tab — the auto-detect is the missing piece.
## Reference links
- PR #327 (Daniel Nakov): https://github.com/feschber/lan-mouse/pull/327
- PR #435 (per-pair scroll/sensitivity, our reference architecture):
https://github.com/feschber/lan-mouse/pull/435
- Existing per-pair implementation in this codebase:
- Schema: `lan-mouse-ipc/src/lib.rs::IncomingPeerConfig`
- Receive-side gate pattern: `src/emulation.rs::ListenTask::post_processing_for_addr`
- GTK row pattern: `lan-mouse-gtk/src/key_row.rs`,
`lan-mouse-gtk/src/client_row.rs`
- Existing modal pattern: `lan-mouse-gtk/src/authorization_window.rs`
## Execution checkpoints (suggested human-review boundaries)
1. After Phase 1 commit lands: confirm primitives compile clean and
`cargo test` passes.
2. After Phase 2: smoke-test 2-peer clipboard sync end-to-end before
layering UI.
3. After Phase 3: confirm GTK toggles round-trip and that
`config.toml` reflects the user's choices.
4. After Phase 4: confirm `frontmost_app()` works on at least one
supported platform; add unit tests for the others.
5. After Phase 5: live-test the modal flow on macOS (password
manager auto-suppression catches 1Password without manual config).
6. Before opening the PR: run the full manual test plan from Phase 6.

3
Cargo.lock generated
View file

@ -1845,11 +1845,13 @@ dependencies = [
"futures-core",
"input-event",
"keycode",
"lan-mouse-ipc",
"libc",
"log",
"memmap",
"once_cell",
"reis",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"tokio",
@ -1859,6 +1861,7 @@ dependencies = [
"wayland-protocols-wlr",
"windows 0.61.3",
"x11",
"x11rb",
]
[[package]]

View file

@ -29,6 +29,8 @@ once_cell = "1.19.0"
async-trait = "0.1.81"
tokio-util = "0.7.11"
arboard = { version = "3.4", features = ["wayland-data-control"] }
lan-mouse-ipc = { path = "../lan-mouse-ipc", version = "0.2.0" }
serde_json = "1.0"
[target.'cfg(all(unix, not(target_os="macos")))'.dependencies]
@ -47,6 +49,11 @@ ashpd = { version = "0.13.9", default-features = false, features = [
"tokio",
], optional = true }
reis = { version = "0.5.0", features = ["tokio"], optional = true }
# Used unconditionally on Linux for frontmost-app detection in
# `frontmost_app::linux_x11`. Already pulled in transitively via
# arboard's wayland-data-control feature, so listing it here just
# pins it as a direct dep.
x11rb = "0.13"
[target.'cfg(target_os="macos")'.dependencies]
core-graphics = { version = "0.25.0", features = ["highsierra"] }

View file

@ -1,13 +1,23 @@
use arboard::Clipboard;
use input_event::{ClipboardEvent, Event};
use lan_mouse_ipc::AppIdent;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::sync::mpsc::{self, Receiver, Sender};
use tokio::task::spawn_blocking;
use tokio::time::interval;
use crate::frontmost_app;
use crate::{CaptureError, CaptureEvent};
/// Shared, mutable suppression list. Service owns the canonical
/// `Arc<Mutex<HashSet<AppIdent>>>` and clones the handle into each
/// freshly-spawned [`ClipboardMonitor`]; mutations from
/// `Add/RemoveSuppressedApp` requests take effect immediately on
/// the next clipboard poll.
pub type SuppressionList = Arc<Mutex<HashSet<AppIdent>>>;
/// Clipboard monitor that watches for clipboard changes
pub struct ClipboardMonitor {
event_rx: Receiver<CaptureEvent>,
@ -18,7 +28,22 @@ pub struct ClipboardMonitor {
}
impl ClipboardMonitor {
/// Construct without app-source suppression. Equivalent to
/// `with_suppression(Default::default())` — provided as a
/// convenience for callers that don't care about suppression
/// (CLI smoke tests, future per-platform unit tests).
pub fn new() -> Result<Self, CaptureError> {
Self::with_suppression(SuppressionList::default())
}
/// Construct a monitor that consults `suppression` on every
/// detected clipboard change and skips both the emit AND the
/// `last_content` update when [`frontmost_app::frontmost_app()`]
/// reports an app whose [`AppIdent`] is in the list. Skipping
/// the `last_content` update is intentional: it keeps the
/// monitor "blind" to the suppressed content so a later non-
/// suppressed copy of the same string still emits normally.
pub fn with_suppression(suppression: SuppressionList) -> Result<Self, CaptureError> {
let (event_tx, event_rx) = mpsc::channel(16);
let last_content: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let last_change: Arc<Mutex<Option<Instant>>> = Arc::new(Mutex::new(None));
@ -28,6 +53,7 @@ impl ClipboardMonitor {
let last_change_clone = last_change.clone();
let enabled_clone = enabled.clone();
let event_tx_clone = event_tx.clone();
let suppression_clone = suppression.clone();
// Spawn monitoring task
tokio::spawn(async move {
@ -50,6 +76,7 @@ impl ClipboardMonitor {
let last_content_clone2 = last_content_clone.clone();
let last_change_clone2 = last_change_clone.clone();
let event_tx_clone2 = event_tx_clone.clone();
let suppression_clone2 = suppression_clone.clone();
let _ = spawn_blocking(move || {
// Create clipboard instance
@ -92,15 +119,41 @@ impl ClipboardMonitor {
};
if should_emit {
log::info!("Clipboard changed, length: {} bytes", current_text.len());
*last_content = Some(current_text.clone());
*last_change = Some(Instant::now());
// App-source suppression. Frontmost-app
// lookup happens here (not on every
// poll) so we only pay the cost when
// the clipboard actually changed.
let suppressed = is_suppressed(&suppression_clone2);
if let Some(app) = suppressed {
log::debug!(
"clipboard change suppressed (frontmost app `{}`)",
app.label()
);
// Intentionally NOT updating
// last_content / last_change. If
// the user later copies a non-
// suppressed value followed by the
// same suppressed text, we still
// want the non-suppressed copy to
// emit and the suppressed re-copy
// to be re-evaluated (and re-
// suppressed) — keeping
// last_content "blind" to the
// suppressed value preserves that.
} else {
log::info!(
"Clipboard changed, length: {} bytes",
current_text.len()
);
*last_content = Some(current_text.clone());
*last_change = Some(Instant::now());
// Send event
let event = CaptureEvent::Input(Event::Clipboard(
ClipboardEvent::Text(current_text),
));
let _ = event_tx_clone2.blocking_send(event);
// Send event
let event = CaptureEvent::Input(Event::Clipboard(
ClipboardEvent::Text(current_text),
));
let _ = event_tx_clone2.blocking_send(event);
}
} else {
log::trace!("Clipboard changed but debounced (too recent)");
}
@ -147,3 +200,20 @@ impl ClipboardMonitor {
*last_change = Some(Instant::now());
}
}
/// If [`frontmost_app::frontmost_app()`] reports an app whose ident
/// is in the suppression list, return that ident. Otherwise return
/// `None`. Snapshotting the lock guard short keeps us from holding
/// the mutex across the platform call (which on Linux can shell
/// out to hyprctl/swaymsg).
fn is_suppressed(list: &SuppressionList) -> Option<AppIdent> {
let snapshot: Vec<AppIdent> = {
let guard = list.lock().ok()?;
if guard.is_empty() {
return None;
}
guard.iter().cloned().collect()
};
let active = frontmost_app::frontmost_app()?;
snapshot.into_iter().find(|s| s.matches(&active))
}

View file

@ -0,0 +1,448 @@
//! Cross-platform "what's the frontmost app right now?" lookup.
//!
//! Used by [`crate::clipboard::ClipboardMonitor`] to consult a
//! user-maintained suppression list before broadcasting a clipboard
//! change: when the active app at the moment of capture matches an
//! entry in the list (e.g. `1Password.app`), the change is dropped
//! locally rather than going on the wire.
//!
//! Each platform returns a `Some(AppIdent)` whose variant matches
//! the OS — see [`AppIdent`] in `lan-mouse-ipc`. None means we
//! couldn't determine the active app (no compositor support, no
//! permissions, transient race, …); the caller treats that as "not
//! suppressed."
//!
//! # macOS
//!
//! `frontmost_app()` and `list_running_apps()` are currently stubs
//! that return `None` / `Vec::new()`. A proper implementation needs
//! to call into AppKit:
//!
//! - `NSWorkspace.frontmostApplication.bundleIdentifier` →
//! `Some(AppIdent::MacBundle(...))`
//! - `NSWorkspace.runningApplications` → list
//!
//! Either pull in `objc2` + `objc2-app-kit` and call the bindings
//! directly, or shell out to `osascript` (`tell application
//! "System Events" to get bundle identifier of first application
//! process whose frontmost is true`). The shell-out path avoids new
//! deps but is comparatively slow (~50ms) — fine for clipboard's
//! 500ms poll cadence. Until either lands, manual entries in the
//! suppression list are the way to suppress on macOS.
//!
//! # Concealed-type detection (macOS only, also TODO)
//!
//! macOS password managers stamp `org.nspasteboard.ConcealedType`
//! on the pasteboard so apps can voluntarily skip syncing
//! passwords. Reading that requires
//! `NSPasteboard.generalPasteboard.types`, which lives behind the
//! same Objective-C bridge as the bundle-id lookup above. Implement
//! both at the same time.
use lan_mouse_ipc::AppIdent;
pub use lan_mouse_ipc::AppIdent as AppIdentRe;
/// Best-effort lookup of the application whose window is currently
/// frontmost. Returns `None` when the platform doesn't support the
/// query (or when the lookup transiently fails — caller should
/// treat that as "not suppressed", not as "suppressed").
pub fn frontmost_app() -> Option<AppIdent> {
backend::frontmost_app()
}
/// Best-effort enumeration of currently-running apps suitable for
/// the suppression-list "From running apps" UI tab. Empty when the
/// platform doesn't implement enumeration yet — the manual-entry
/// tab still works, so the feature remains usable.
pub fn list_running_apps() -> Vec<AppIdent> {
backend::list_running_apps()
}
#[cfg(target_os = "macos")]
mod backend {
use super::AppIdent;
pub fn frontmost_app() -> Option<AppIdent> {
// TODO(macOS): NSWorkspace.frontmostApplication.bundleIdentifier
// via objc2-app-kit. See module-level docs.
None
}
pub fn list_running_apps() -> Vec<AppIdent> {
// TODO(macOS): NSWorkspace.runningApplications via
// objc2-app-kit. See module-level docs.
Vec::new()
}
}
#[cfg(windows)]
mod backend {
use super::AppIdent;
use windows::Win32::Foundation::{CloseHandle, FALSE, HWND};
use windows::Win32::System::Threading::{
OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION,
QueryFullProcessImageNameW,
};
use windows::Win32::UI::WindowsAndMessaging::{
EnumWindows, GetForegroundWindow, GetWindowThreadProcessId, IsWindowVisible,
};
use windows::core::{BOOL, LPARAM, PWSTR};
fn process_basename(pid: u32) -> Option<String> {
if pid == 0 {
return None;
}
unsafe {
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).ok()?;
let mut buf = [0u16; 1024];
let mut len = buf.len() as u32;
let result = QueryFullProcessImageNameW(
handle,
PROCESS_NAME_WIN32,
PWSTR(buf.as_mut_ptr()),
&mut len,
);
let _ = CloseHandle(handle);
result.ok()?;
let path = String::from_utf16_lossy(&buf[..len as usize]);
std::path::Path::new(&path)
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_lowercase())
}
}
pub fn frontmost_app() -> Option<AppIdent> {
unsafe {
let hwnd = GetForegroundWindow();
if hwnd == HWND::default() {
return None;
}
let mut pid: u32 = 0;
GetWindowThreadProcessId(hwnd, Some(&mut pid));
process_basename(pid).map(AppIdent::WindowsExe)
}
}
pub fn list_running_apps() -> Vec<AppIdent> {
// Walk every visible top-level window, dedup by process
// basename. Closures captured via LPARAM pointer to a Vec.
let mut basenames: Vec<String> = Vec::new();
unsafe extern "system" fn enum_proc(hwnd: HWND, lparam: LPARAM) -> BOOL {
unsafe {
if IsWindowVisible(hwnd) == FALSE {
return BOOL(1); // continue
}
let mut pid: u32 = 0;
GetWindowThreadProcessId(hwnd, Some(&mut pid));
let Some(name) = super::process_basename(pid) else {
return BOOL(1);
};
let v: &mut Vec<String> = &mut *(lparam.0 as *mut Vec<String>);
if !v.iter().any(|n| n == &name) {
v.push(name);
}
BOOL(1)
}
}
unsafe {
let _ = EnumWindows(
Some(enum_proc),
LPARAM(&mut basenames as *mut _ as isize),
);
}
basenames
.into_iter()
.map(AppIdent::WindowsExe)
.collect()
}
}
#[cfg(all(unix, not(target_os = "macos")))]
mod backend {
use super::AppIdent;
use std::process::Command;
/// Detect compositor flavor via env vars. Wayland sessions set
/// `WAYLAND_DISPLAY`; X11 sessions don't. `XDG_SESSION_TYPE` is
/// the modern signal but isn't always set on tiling WMs (Sway,
/// Hyprland) so we treat presence of `WAYLAND_DISPLAY` as
/// authoritative for Wayland.
fn is_wayland() -> bool {
std::env::var_os("WAYLAND_DISPLAY")
.map(|v| !v.is_empty())
.unwrap_or(false)
}
pub fn frontmost_app() -> Option<AppIdent> {
if is_wayland() {
hyprland_active()
.or_else(sway_active)
.map(|s| AppIdent::LinuxWayland(s.to_lowercase()))
} else {
x11_active().map(|s| AppIdent::LinuxX11(s.to_lowercase()))
}
}
pub fn list_running_apps() -> Vec<AppIdent> {
if is_wayland() {
// Hyprland's `clients -j` returns every mapped client;
// sway's `get_tree` returns the whole tree. Either way
// we extract `class` / `app_id`, dedup, and sort for
// stable display in the GUI.
let mut idents: Vec<String> = hyprland_clients()
.into_iter()
.chain(sway_clients())
.collect();
idents.sort();
idents.dedup();
idents
.into_iter()
.map(|s| AppIdent::LinuxWayland(s.to_lowercase()))
.collect()
} else {
let mut classes = x11_client_list();
classes.sort();
classes.dedup();
classes
.into_iter()
.map(|s| AppIdent::LinuxX11(s.to_lowercase()))
.collect()
}
}
fn run_capture(cmd: &str, args: &[&str]) -> Option<String> {
let out = Command::new(cmd).args(args).output().ok()?;
if !out.status.success() {
return None;
}
String::from_utf8(out.stdout).ok()
}
fn hyprland_active() -> Option<String> {
let json = run_capture("hyprctl", &["activewindow", "-j"])?;
let parsed: serde_json::Value = serde_json::from_str(&json).ok()?;
// Hyprland reports the X11 WM_CLASS-equivalent as `class`.
// `initialClass` is the value the toplevel registered with;
// prefer it when present so a renamed window doesn't slip
// suppression by changing its title.
let class = parsed
.get("initialClass")
.and_then(|v| v.as_str())
.or_else(|| parsed.get("class").and_then(|v| v.as_str()))?;
let class = class.trim();
if class.is_empty() {
return None;
}
Some(class.to_owned())
}
fn hyprland_clients() -> Vec<String> {
let Some(json) = run_capture("hyprctl", &["clients", "-j"]) else {
return Vec::new();
};
let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&json) else {
return Vec::new();
};
parsed
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|c| {
c.get("initialClass")
.and_then(|v| v.as_str())
.or_else(|| c.get("class").and_then(|v| v.as_str()))
})
.map(str::to_owned)
.collect()
})
.unwrap_or_default()
}
fn sway_active() -> Option<String> {
let json = run_capture("swaymsg", &["-t", "get_tree"])?;
let tree: serde_json::Value = serde_json::from_str(&json).ok()?;
find_focused_app_id(&tree)
}
fn sway_clients() -> Vec<String> {
let Some(json) = run_capture("swaymsg", &["-t", "get_tree"]) else {
return Vec::new();
};
let Ok(tree) = serde_json::from_str::<serde_json::Value>(&json) else {
return Vec::new();
};
let mut acc = Vec::new();
collect_app_ids(&tree, &mut acc);
acc
}
/// Walk the sway/i3 tree depth-first looking for the node with
/// `focused == true` and a non-empty `app_id` (Wayland clients)
/// or `window_properties.class` (XWayland fallback).
fn find_focused_app_id(node: &serde_json::Value) -> Option<String> {
if node.get("focused").and_then(|v| v.as_bool()) == Some(true) {
if let Some(s) = node.get("app_id").and_then(|v| v.as_str()) {
if !s.is_empty() {
return Some(s.to_owned());
}
}
if let Some(s) = node
.get("window_properties")
.and_then(|wp| wp.get("class"))
.and_then(|v| v.as_str())
{
if !s.is_empty() {
return Some(s.to_owned());
}
}
}
for key in ["nodes", "floating_nodes"] {
if let Some(arr) = node.get(key).and_then(|v| v.as_array()) {
for child in arr {
if let Some(found) = find_focused_app_id(child) {
return Some(found);
}
}
}
}
None
}
fn collect_app_ids(node: &serde_json::Value, acc: &mut Vec<String>) {
if let Some(s) = node.get("app_id").and_then(|v| v.as_str()) {
if !s.is_empty() {
acc.push(s.to_owned());
}
}
if let Some(s) = node
.get("window_properties")
.and_then(|wp| wp.get("class"))
.and_then(|v| v.as_str())
{
if !s.is_empty() {
acc.push(s.to_owned());
}
}
for key in ["nodes", "floating_nodes"] {
if let Some(arr) = node.get(key).and_then(|v| v.as_array()) {
for child in arr {
collect_app_ids(child, acc);
}
}
}
}
fn x11_active() -> Option<String> {
use x11rb::connection::Connection;
use x11rb::protocol::xproto::{AtomEnum, ConnectionExt};
let (conn, screen_num) = x11rb::connect(None).ok()?;
let root = conn.setup().roots[screen_num].root;
let net_active = conn
.intern_atom(false, b"_NET_ACTIVE_WINDOW")
.ok()?
.reply()
.ok()?
.atom;
let prop = conn
.get_property(false, root, net_active, AtomEnum::WINDOW, 0, 1)
.ok()?
.reply()
.ok()?;
let window_id = prop.value32()?.next()?;
if window_id == 0 {
return None;
}
let class_prop = conn
.get_property(
false,
window_id,
AtomEnum::WM_CLASS,
AtomEnum::STRING,
0,
1024,
)
.ok()?
.reply()
.ok()?;
// WM_CLASS is two NUL-separated strings: instance, class.
// Prefer the second (class) since it tends to be the more
// stable identifier.
let raw = class_prop.value;
let mut parts = raw.split(|&b| b == 0).filter(|s| !s.is_empty());
let _instance = parts.next();
let class = parts.next();
let bytes = class.or_else(|| {
// Single-string fallback (some toolkits put the same
// value in both fields without a separator).
raw.split(|&b| b == 0).find(|s| !s.is_empty())
})?;
let s = String::from_utf8_lossy(bytes).into_owned();
if s.is_empty() {
return None;
}
Some(s)
}
fn x11_client_list() -> Vec<String> {
use x11rb::connection::Connection;
use x11rb::protocol::xproto::{AtomEnum, ConnectionExt};
let Ok((conn, screen_num)) = x11rb::connect(None) else {
return Vec::new();
};
let root = conn.setup().roots[screen_num].root;
let Ok(reply) = conn.intern_atom(false, b"_NET_CLIENT_LIST") else {
return Vec::new();
};
let Ok(net_client_list) = reply.reply() else {
return Vec::new();
};
let net_client_list = net_client_list.atom;
let Ok(prop_req) = conn.get_property(
false,
root,
net_client_list,
AtomEnum::WINDOW,
0,
u32::MAX,
) else {
return Vec::new();
};
let Ok(prop) = prop_req.reply() else {
return Vec::new();
};
let Some(values) = prop.value32() else {
return Vec::new();
};
let mut out = Vec::new();
for window_id in values {
if window_id == 0 {
continue;
}
let Ok(req) = conn.get_property(
false,
window_id,
AtomEnum::WM_CLASS,
AtomEnum::STRING,
0,
1024,
) else {
continue;
};
let Ok(class_prop) = req.reply() else {
continue;
};
let raw = class_prop.value;
let mut parts = raw.split(|&b| b == 0).filter(|s| !s.is_empty());
let _instance = parts.next();
let class = parts.next();
if let Some(bytes) = class.or_else(|| raw.split(|&b| b == 0).find(|s| !s.is_empty())) {
out.push(String::from_utf8_lossy(bytes).into_owned());
}
}
out
}
}

View file

@ -19,6 +19,7 @@ pub use error::{CaptureCreationError, CaptureError, InputCaptureError};
pub mod clipboard;
pub mod error;
pub mod frontmost_app;
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
mod libei;

View file

@ -360,6 +360,17 @@ fn build_ui(app: &Application) {
FrontendEvent::MdnsDiscovery(enabled) => {
window.set_mdns_discovery(enabled);
}
FrontendEvent::SuppressedAppsUpdated(_apps) => {
// Phase 5 will route this to the
// ClipboardPrivacyWindow once the modal is
// built. For now the daemon's persisted
// state is authoritative — the GUI just
// doesn't visualize the list yet.
}
FrontendEvent::RunningApps(_apps) => {
// Same — Phase 5 wires this into the
// "From running apps" tab.
}
}
}
}

View file

@ -58,6 +58,53 @@ pub enum IpcError {
pub const DEFAULT_PORT: u16 = 4242;
/// Cross-platform identifier for an application whose clipboard
/// should not be shared with peers. The variant captures the
/// platform-specific string the OS surfaces for "this is app X":
/// macOS bundle ID, Windows executable basename, X11 `WM_CLASS`,
/// Wayland `xdg-toplevel.app_id`. Comparison is case-insensitive
/// within the same variant; cross-variant comparisons are always
/// `false` so a `LinuxX11("Chromium")` entry does not unexpectedly
/// suppress a Mac peer's `MacBundle("org.chromium.Chromium")`.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum AppIdent {
/// macOS bundle identifier, e.g. `com.1password.1password7`.
MacBundle(String),
/// Windows executable basename (lowercased), e.g.
/// `1password.exe`.
WindowsExe(String),
/// X11 `WM_CLASS` instance/name (lowercased), e.g. `firefox`.
LinuxX11(String),
/// Wayland `xdg-toplevel.app_id` (lowercased), e.g.
/// `org.mozilla.firefox`.
LinuxWayland(String),
}
impl AppIdent {
/// Case-insensitive equality within the same variant.
pub fn matches(&self, other: &AppIdent) -> bool {
match (self, other) {
(AppIdent::MacBundle(a), AppIdent::MacBundle(b))
| (AppIdent::WindowsExe(a), AppIdent::WindowsExe(b))
| (AppIdent::LinuxX11(a), AppIdent::LinuxX11(b))
| (AppIdent::LinuxWayland(a), AppIdent::LinuxWayland(b)) => a.eq_ignore_ascii_case(b),
_ => false,
}
}
/// Human-readable rendering: `value (kind)` so the GUI can show
/// `1password.exe (Windows)` rather than the raw enum.
pub fn label(&self) -> String {
match self {
AppIdent::MacBundle(v) => format!("{v} (macOS bundle)"),
AppIdent::WindowsExe(v) => format!("{v} (Windows)"),
AppIdent::LinuxX11(v) => format!("{v} (X11)"),
AppIdent::LinuxWayland(v) => format!("{v} (Wayland)"),
}
}
}
#[derive(Debug, Default, Eq, Hash, PartialEq, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Position {
@ -350,6 +397,14 @@ pub enum FrontendEvent {
/// peers can bias their connection attempts toward the right
/// interface on multi-homed hosts.
MdnsDiscovery(bool),
/// Snapshot of the clipboard-suppression list. Pushed on Sync
/// and after every Add/Remove so the GUI never has to query.
SuppressedAppsUpdated(Vec<AppIdent>),
/// Reply to [`FrontendRequest::ListRunningApps`]: best-effort
/// list of apps currently running on this device. Empty on
/// platforms where enumeration isn't implemented yet (e.g.
/// macOS without an objc2 bridge).
RunningApps(Vec<AppIdent>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -407,6 +462,18 @@ pub enum FrontendRequest {
/// is applied to this device's clipboard. Per-pair receive-side
/// gate, keyed on the peer's TLS certificate fingerprint.
SetIncomingPeerClipboardReceive(String, bool),
/// Add an application to the clipboard suppression list — its
/// clipboard contents will never be broadcast to any peer.
/// Idempotent; adding an already-present entry is a no-op.
AddSuppressedApp(AppIdent),
/// Remove an application from the clipboard suppression list.
/// Idempotent.
RemoveSuppressedApp(AppIdent),
/// Ask the daemon to enumerate currently-running apps so the
/// "From running apps" tab in the suppression-list modal can be
/// populated. The daemon replies with a
/// [`FrontendEvent::RunningApps`].
ListRunningApps,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Serialize, Deserialize)]

View file

@ -16,7 +16,7 @@ use toml;
use toml_edit::{self, DocumentMut};
use lan_mouse_cli::CliArgs;
use lan_mouse_ipc::{DEFAULT_PORT, IncomingPeerConfig, Position};
use lan_mouse_ipc::{AppIdent, DEFAULT_PORT, IncomingPeerConfig, Position};
use input_event::scancode::{
self,
@ -80,6 +80,14 @@ struct ConfigToml {
cert_path: Option<PathBuf>,
clients: Option<Vec<TomlClient>>,
authorized_fingerprints: Option<HashMap<String, IncomingPeerConfig>>,
/// Apps whose clipboard contents must never be broadcast to
/// peers (password managers, sensitive editors, etc.). The
/// daemon consults this list on every clipboard change via
/// [`input_capture::frontmost_app::frontmost_app`]. `None` /
/// empty means no suppression — the user-maintained list is
/// purely additive on top of platform-specific automatic
/// detection (e.g. macOS `org.nspasteboard.ConcealedType`).
clipboard_suppress_apps: Option<Vec<AppIdent>>,
}
#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
@ -586,6 +594,27 @@ impl Config {
.authorized_fingerprints = Some(fingerprints);
}
/// Persisted clipboard-suppression list.
pub fn clipboard_suppressed_apps(&self) -> Vec<AppIdent> {
self.config_toml
.as_ref()
.and_then(|c| c.clipboard_suppress_apps.clone())
.unwrap_or_default()
}
/// Replace the persisted clipboard-suppression list. `None` is
/// written when the list is empty so an upgrade from a config
/// without the field doesn't gain a new key on every save.
pub fn set_clipboard_suppressed_apps(&mut self, apps: Vec<AppIdent>) {
if self.config_toml.is_none() {
self.config_toml = Some(Default::default());
}
self.config_toml
.as_mut()
.expect("config")
.clipboard_suppress_apps = if apps.is_empty() { None } else { Some(apps) };
}
pub fn read_from_disk(&mut self) -> Result<bool, io::Error> {
log::info!("reading config from {:?}", &self.config_path);

View file

@ -10,11 +10,12 @@ use crate::{
listen::{LanMouseListener, ListenerCreationError},
};
use futures::StreamExt;
use input_capture::clipboard::ClipboardMonitor;
use input_capture::clipboard::{ClipboardMonitor, SuppressionList};
use input_capture::frontmost_app;
use input_event::{ClipboardEvent, Event as InputEvent};
use lan_mouse_ipc::{
AsyncFrontendListener, ClientHandle, FrontendEvent, FrontendRequest, IncomingPeerConfig,
IpcError, IpcListenerCreationError, Position, Status,
AppIdent, AsyncFrontendListener, ClientHandle, FrontendEvent, FrontendRequest,
IncomingPeerConfig, IpcError, IpcListenerCreationError, Position, Status,
};
use lan_mouse_proto::ProtoEvent;
use log;
@ -101,6 +102,10 @@ pub struct Service {
/// the duplicate. Pruned lazily — entries older than
/// `RECENT_FORWARD_TTL` are dropped on each clipboard event.
recent_forwarded: HashMap<(String, u64), Instant>,
/// Shared with [`ClipboardMonitor`]; mutations to the inner
/// `HashSet` take effect on the next clipboard poll without
/// rebuilding the monitor.
clipboard_suppression: SuppressionList,
}
const RECENT_FORWARD_TTL: Duration = Duration::from_secs(1);
@ -169,7 +174,13 @@ impl Service {
// permanent error here. We log and proceed without clipboard
// sync rather than tying daemon startup to clipboard
// availability.
let clipboard_monitor = match ClipboardMonitor::new() {
let clipboard_suppression: SuppressionList = {
let initial: std::collections::HashSet<AppIdent> =
config.clipboard_suppressed_apps().into_iter().collect();
Arc::new(std::sync::Mutex::new(initial))
};
let clipboard_monitor = match ClipboardMonitor::with_suppression(clipboard_suppression.clone())
{
Ok(m) => Some(m),
Err(e) => {
log::warn!("clipboard monitor unavailable: {e}; clipboard sync disabled");
@ -198,6 +209,7 @@ impl Service {
conn: conn_for_service,
clipboard_monitor,
recent_forwarded: HashMap::new(),
clipboard_suppression,
};
Ok(service)
}
@ -337,9 +349,43 @@ impl Service {
self.set_incoming_peer_clipboard_receive(fp, enabled);
self.save_config();
}
FrontendRequest::AddSuppressedApp(app) => {
self.add_suppressed_app(app);
self.save_config();
}
FrontendRequest::RemoveSuppressedApp(app) => {
self.remove_suppressed_app(app);
self.save_config();
}
FrontendRequest::ListRunningApps => {
let apps = frontmost_app::list_running_apps();
self.notify_frontend(FrontendEvent::RunningApps(apps));
}
}
}
fn add_suppressed_app(&mut self, app: AppIdent) {
let mut guard = self.clipboard_suppression.lock().expect("lock");
guard.insert(app);
let snapshot: Vec<AppIdent> = guard.iter().cloned().collect();
drop(guard);
self.config.set_clipboard_suppressed_apps(snapshot.clone());
self.notify_frontend(FrontendEvent::SuppressedAppsUpdated(snapshot));
}
fn remove_suppressed_app(&mut self, app: AppIdent) {
let mut guard = self.clipboard_suppression.lock().expect("lock");
// HashSet::remove takes &T; AppIdent's Hash + Eq impls
// ensure case-sensitive removal targets the same entry the
// GUI added. (Suppression matches case-insensitively at
// poll time but identity in the set is exact.)
guard.remove(&app);
let snapshot: Vec<AppIdent> = guard.iter().cloned().collect();
drop(guard);
self.config.set_clipboard_suppressed_apps(snapshot.clone());
self.notify_frontend(FrontendEvent::SuppressedAppsUpdated(snapshot));
}
/// Refresh `last_addr` / `last_hostname` for the authorized-peer
/// entry matching `fingerprint` whenever a DTLS connect lands.
/// Hostname comes from a reverse-lookup against the mDNS
@ -738,6 +784,14 @@ impl Service {
self.notify_frontend(FrontendEvent::MdnsDiscovery(self.config.mdns_discovery()));
let keys = self.authorized_keys.read().expect("lock").clone();
self.notify_frontend(FrontendEvent::AuthorizedUpdated(keys));
let apps: Vec<AppIdent> = self
.clipboard_suppression
.lock()
.expect("lock")
.iter()
.cloned()
.collect();
self.notify_frontend(FrontendEvent::SuppressedAppsUpdated(apps));
}
const ENTER_HANDLE_BEGIN: u64 = u64::MAX / 2 + 1;