mirror of
https://github.com/feschber/lan-mouse.git
synced 2026-05-26 06:12:16 -06:00
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:
parent
ecf46fb850
commit
9269ce6f01
10 changed files with 1109 additions and 13 deletions
406
CLIPBOARD_PLAN.md
Normal file
406
CLIPBOARD_PLAN.md
Normal 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
3
Cargo.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
448
input-capture/src/frontmost_app.rs
Normal file
448
input-capture/src/frontmost_app.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue