From 5f74233f5780a74264fd6e420272eb13149c598c Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Thu, 7 May 2026 10:46:26 -0500 Subject: [PATCH 01/25] feat(clipboard): vendor primitives + protocol from #327 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the per-pair clipboard sync feature: lift the primitives from feschber/lan-mouse#327 verbatim and wrap them in a wire format that pre-bakes the originator fingerprint needed for N-peer loop prevention later in the rollout. - arboard dependency on input-capture and input-emulation - input-capture::ClipboardMonitor (500ms poll, 200ms debounce) - input-emulation::ClipboardEmulation (blocking-task wrapper) - input_event::ClipboardEvent + Event::Clipboard variant; Event drops Copy so the new String payload compiles - lan-mouse-proto::ProtoEvent::Clipboard { from_fingerprint, content } encoded via variable-length encode_clipboard_event / decode_clipboard_event helpers (fixed-buffer codec panics for this variant). MAX_CLIPBOARD_SIZE caps total wire payload at 4 KiB - InputEmulation intercepts Event::Clipboard in consume() and routes it to the cross-platform ClipboardEmulation sink, so per-backend emulations stay platform-mechanics-only - Round-trip + over-size + truncated-decode unit tests for the new codec No service wiring yet — ClipboardMonitor isn't instantiated and no peer can transmit a ProtoEvent::Clipboard. Behavior change: zero. Phase 2 wires capture, IPC, and per-pair Service routing. Co-Authored-By: dnakov <3777433+dnakov@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 343 +++++++++++++++++++++- input-capture/Cargo.toml | 1 + input-capture/src/clipboard.rs | 149 ++++++++++ input-capture/src/lib.rs | 11 +- input-emulation/Cargo.toml | 1 + input-emulation/src/clipboard.rs | 105 +++++++ input-emulation/src/lib.rs | 31 +- input-emulation/src/libei.rs | 4 + input-emulation/src/macos.rs | 5 + input-emulation/src/windows.rs | 4 + input-emulation/src/wlroots.rs | 9 +- input-emulation/src/xdg_desktop_portal.rs | 7 +- input-event/src/lib.rs | 26 +- input-event/src/libei.rs | 2 +- lan-mouse-proto/src/lib.rs | 217 +++++++++++++- src/capture.rs | 4 +- src/connect.rs | 3 +- 17 files changed, 899 insertions(+), 23 deletions(-) create mode 100644 input-capture/src/clipboard.rs create mode 100644 input-emulation/src/clipboard.rs diff --git a/Cargo.lock b/Cargo.lock index d3a6a96db..c86b64a73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aead" version = "0.5.2" @@ -111,6 +117,27 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -147,7 +174,7 @@ dependencies = [ "asn1-rs-derive", "asn1-rs-impl", "displaydoc", - "nom", + "nom 7.1.3", "num-traits", "rusticata-macros", "thiserror 1.0.69", @@ -319,12 +346,24 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -492,6 +531,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -592,12 +640,27 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -681,7 +744,7 @@ checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" dependencies = [ "asn1-rs", "displaydoc", - "nom", + "nom 7.1.3", "num-bigint", "num-traits", "rusticata-macros", @@ -849,6 +912,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "event-listener" version = "5.4.1" @@ -876,6 +945,21 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "ff" version = "0.13.1" @@ -908,6 +992,22 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flume" version = "0.11.1" @@ -1145,6 +1245,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1415,6 +1525,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1653,6 +1774,20 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", +] + [[package]] name = "indexmap" version = "2.13.1" @@ -1699,6 +1834,7 @@ dependencies = [ name = "input-capture" version = "0.3.0" dependencies = [ + "arboard", "ashpd", "async-trait", "bitflags 2.11.0", @@ -1729,6 +1865,7 @@ dependencies = [ name = "input-emulation" version = "0.3.0" dependencies = [ + "arboard", "ashpd", "async-trait", "bitflags 2.11.0", @@ -2146,6 +2283,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.0" @@ -2158,6 +2305,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "netdev" version = "0.43.0" @@ -2277,6 +2434,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "notify" version = "8.2.0" @@ -2378,6 +2544,18 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -2391,6 +2569,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + [[package]] name = "objc2-core-wlan" version = "0.3.2" @@ -2424,6 +2615,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "objc2-security" version = "0.3.2" @@ -2496,6 +2698,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "p256" version = "0.13.2" @@ -2604,6 +2816,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2645,6 +2868,19 @@ dependencies = [ "time", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -2747,6 +2983,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.39.2" @@ -2926,7 +3174,7 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -3169,6 +3417,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" @@ -3354,6 +3608,20 @@ dependencies = [ "syn", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.47" @@ -3573,6 +3841,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + [[package]] name = "typenum" version = "1.19.0" @@ -3952,6 +4231,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -4453,6 +4738,24 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.4", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.3" @@ -4469,6 +4772,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "x25519-dalek" version = "2.0.1" @@ -4491,7 +4811,7 @@ dependencies = [ "data-encoding", "der-parser", "lazy_static", - "nom", + "nom 7.1.3", "oid-registry", "rusticata-macros", "thiserror 1.0.69", @@ -4686,6 +5006,21 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.10.0" diff --git a/input-capture/Cargo.toml b/input-capture/Cargo.toml index bcdeb3246..eeb70ef72 100644 --- a/input-capture/Cargo.toml +++ b/input-capture/Cargo.toml @@ -28,6 +28,7 @@ tokio = { version = "1.32.0", features = [ once_cell = "1.19.0" async-trait = "0.1.81" tokio-util = "0.7.11" +arboard = { version = "3.4", features = ["wayland-data-control"] } [target.'cfg(all(unix, not(target_os="macos")))'.dependencies] diff --git a/input-capture/src/clipboard.rs b/input-capture/src/clipboard.rs new file mode 100644 index 000000000..d9cc3ce55 --- /dev/null +++ b/input-capture/src/clipboard.rs @@ -0,0 +1,149 @@ +use arboard::Clipboard; +use input_event::{ClipboardEvent, Event}; +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::{CaptureError, CaptureEvent}; + +/// Clipboard monitor that watches for clipboard changes +pub struct ClipboardMonitor { + event_rx: Receiver, + _event_tx: Sender, + last_content: Arc>>, + last_change: Arc>>, + enabled: Arc>, +} + +impl ClipboardMonitor { + pub fn new() -> Result { + let (event_tx, event_rx) = mpsc::channel(16); + let last_content: Arc>> = Arc::new(Mutex::new(None)); + let last_change: Arc>> = Arc::new(Mutex::new(None)); + let enabled = Arc::new(Mutex::new(true)); + + let last_content_clone = last_content.clone(); + let last_change_clone = last_change.clone(); + let enabled_clone = enabled.clone(); + let event_tx_clone = event_tx.clone(); + + // Spawn monitoring task + tokio::spawn(async move { + let mut check_interval = interval(Duration::from_millis(500)); + + loop { + check_interval.tick().await; + + // Check if enabled + let is_enabled = { + let enabled = enabled_clone.lock().unwrap(); + *enabled + }; + + if !is_enabled { + continue; + } + + // Read clipboard in blocking task + 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 _ = spawn_blocking(move || { + // Create clipboard instance + let mut clipboard = match Clipboard::new() { + Ok(c) => c, + Err(e) => { + log::debug!("Failed to create clipboard: {}", e); + return; + } + }; + + // Get current clipboard text + let current_text = match clipboard.get_text() { + Ok(text) => { + log::trace!("Clipboard text read: {} bytes", text.len()); + text + } + Err(e) => { + // Clipboard might be empty or contain non-text data + log::trace!("Failed to get clipboard text: {}", e); + return; + } + }; + + // Check if content changed + let mut last_content = last_content_clone2.lock().unwrap(); + let mut last_change = last_change_clone2.lock().unwrap(); + + let content_changed = match last_content.as_ref() { + None => true, + Some(last) => last != ¤t_text, + }; + + if content_changed { + // Debounce: ignore changes within 200ms of last change + // This prevents infinite loops when both sides update clipboard + let should_emit = match *last_change { + None => true, + Some(instant) => instant.elapsed() > Duration::from_millis(200), + }; + + if should_emit { + 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); + } else { + log::trace!("Clipboard changed but debounced (too recent)"); + } + } + }) + .await; + } + }); + + Ok(Self { + event_rx, + _event_tx: event_tx, + last_content, + last_change, + enabled, + }) + } + + /// Receive the next clipboard event + pub async fn recv(&mut self) -> Option { + self.event_rx.recv().await + } + + /// Enable clipboard monitoring + pub fn enable(&self) { + let mut enabled = self.enabled.lock().unwrap(); + *enabled = true; + log::info!("Clipboard monitoring enabled"); + } + + /// Disable clipboard monitoring + pub fn disable(&self) { + let mut enabled = self.enabled.lock().unwrap(); + *enabled = false; + log::info!("Clipboard monitoring disabled"); + } + + /// Update the last known clipboard content (called when we set the clipboard) + /// This prevents detecting our own clipboard changes as external changes + pub fn update_last_content(&self, content: String) { + let mut last_content = self.last_content.lock().unwrap(); + let mut last_change = self.last_change.lock().unwrap(); + *last_content = Some(content); + *last_change = Some(Instant::now()); + } +} diff --git a/input-capture/src/lib.rs b/input-capture/src/lib.rs index 6cc2efed9..8c11abd3d 100644 --- a/input-capture/src/lib.rs +++ b/input-capture/src/lib.rs @@ -17,6 +17,7 @@ use input_event::{Event, KeyboardEvent, PointerEvent, scancode}; pub use error::{CaptureCreationError, CaptureError, InputCaptureError}; +pub mod clipboard; pub mod error; #[cfg(all(unix, feature = "libei", not(target_os = "macos")))] @@ -39,7 +40,7 @@ mod dummy; pub type CaptureHandle = u64; -#[derive(Copy, Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub enum CaptureEvent { /// Capture on this handle is now active. `cursor`, when present, /// is the host's screen-space cursor position (in pixels) at the @@ -822,8 +823,10 @@ impl Stream for InputCapture { }; // handle key presses - if let CaptureEvent::Input(Event::Keyboard(KeyboardEvent::Key { key, state, .. })) = event { - self.update_pressed_keys(key, state); + if let CaptureEvent::Input(Event::Keyboard(KeyboardEvent::Key { key, state, .. })) = + &event + { + self.update_pressed_keys(*key, *state); } // wall-press auto-release tracking. Runs against every event @@ -850,7 +853,7 @@ impl Stream for InputCapture { swap(&mut self.position_map, &mut position_map); { for &id in position_map.get(&pos).expect("position") { - self.pending.push_back((id, event)); + self.pending.push_back((id, event.clone())); } } swap(&mut self.position_map, &mut position_map); diff --git a/input-emulation/Cargo.toml b/input-emulation/Cargo.toml index 38391223d..cfee168cc 100644 --- a/input-emulation/Cargo.toml +++ b/input-emulation/Cargo.toml @@ -24,6 +24,7 @@ tokio = { version = "1.32.0", features = [ "time" ] } once_cell = "1.19.0" +arboard = { version = "3.4", features = ["wayland-data-control"] } [target.'cfg(all(unix, not(target_os="macos")))'.dependencies] bitflags = "2.6.0" diff --git a/input-emulation/src/clipboard.rs b/input-emulation/src/clipboard.rs new file mode 100644 index 000000000..72dd3f6f4 --- /dev/null +++ b/input-emulation/src/clipboard.rs @@ -0,0 +1,105 @@ +use arboard::Clipboard; +use input_event::ClipboardEvent; +use std::sync::{Arc, Mutex}; +use thiserror::Error; +use tokio::task::spawn_blocking; + +#[derive(Debug, Error)] +pub enum ClipboardError { + #[error("Failed to access clipboard: {0}")] + Access(String), + #[error("Failed to set clipboard: {0}")] + Set(String), +} + +/// Clipboard emulation that sets clipboard content +#[derive(Clone)] +pub struct ClipboardEmulation { + // Use Arc> to share clipboard across threads + clipboard: Arc>>, +} + +impl ClipboardEmulation { + pub fn new() -> Result { + // Try to create initial clipboard instance + let clipboard = match Clipboard::new() { + Ok(c) => Some(c), + Err(e) => { + log::warn!("Failed to create clipboard instance: {}", e); + None + } + }; + + Ok(Self { + clipboard: Arc::new(Mutex::new(clipboard)), + }) + } + + /// Set clipboard content from a clipboard event + pub async fn set(&self, event: ClipboardEvent) -> Result<(), ClipboardError> { + match event { + ClipboardEvent::Text(text) => { + let clipboard_arc = self.clipboard.clone(); + + spawn_blocking(move || { + let mut clipboard_guard = clipboard_arc.lock().unwrap(); + + // Try to get or create clipboard + let clipboard = match clipboard_guard.as_mut() { + Some(c) => c, + None => { + // Try to create a new clipboard instance + match Clipboard::new() { + Ok(c) => { + *clipboard_guard = Some(c); + clipboard_guard.as_mut().unwrap() + } + Err(e) => { + return Err(ClipboardError::Access(format!("{}", e))); + } + } + } + }; + + // Set clipboard text + clipboard + .set_text(text.clone()) + .map_err(|e| ClipboardError::Set(format!("{}", e)))?; + + log::debug!("Clipboard set, length: {} bytes", text.len()); + Ok(()) + }) + .await + .map_err(|e| ClipboardError::Access(format!("Task join error: {}", e)))? + } + } + } + + /// Get current clipboard content (for testing/verification) + pub async fn get(&self) -> Result { + let clipboard_arc = self.clipboard.clone(); + + spawn_blocking(move || { + let mut clipboard_guard = clipboard_arc.lock().unwrap(); + + let clipboard = match clipboard_guard.as_mut() { + Some(c) => c, + None => match Clipboard::new() { + Ok(c) => { + *clipboard_guard = Some(c); + clipboard_guard.as_mut().unwrap() + } + Err(e) => { + return Err(ClipboardError::Access(format!("{}", e))); + } + }, + }; + + clipboard + .get_text() + .map_err(|e| ClipboardError::Access(format!("{}", e))) + }) + .await + .map_err(|e| ClipboardError::Access(format!("Task join error: {}", e)))? + } +} diff --git a/input-emulation/src/lib.rs b/input-emulation/src/lib.rs index 4e2dc6fe2..07ac93a0e 100644 --- a/input-emulation/src/lib.rs +++ b/input-emulation/src/lib.rs @@ -4,7 +4,9 @@ use std::{ fmt::Display, }; -use input_event::{Event, KeyboardEvent, PointerEvent}; +use input_event::{ClipboardEvent, Event, KeyboardEvent, PointerEvent}; + +use crate::clipboard::ClipboardEmulation; pub use self::error::{EmulationCreationError, EmulationError, InputEmulationError}; @@ -26,6 +28,7 @@ mod libei; #[cfg(target_os = "macos")] mod macos; +pub mod clipboard; /// fallback input emulation (logs events) mod dummy; mod error; @@ -101,6 +104,11 @@ pub struct InputEmulation { /// upper layer before each event is consumed; missing entries /// resolve to `ReceivePostProcessing::default()` (passthrough). post_processing: HashMap, + /// Cross-platform clipboard sink. Populated lazily on first + /// access; backends don't need to know clipboards exist. + /// `None` when the platform clipboard couldn't be opened (e.g. + /// headless CI, Wayland session without compositor support). + clipboard: Option, } impl InputEmulation { @@ -120,11 +128,19 @@ impl InputEmulation { Backend::MacOs => Box::new(macos::MacOSEmulation::new()?), Backend::Dummy => Box::new(dummy::DummyEmulation::new()), }; + let clipboard = match ClipboardEmulation::new() { + Ok(c) => Some(c), + Err(e) => { + log::warn!("clipboard emulation unavailable: {e}"); + None + } + }; Ok(Self { emulation, handles: HashSet::new(), pressed_keys: HashMap::new(), post_processing: HashMap::new(), + clipboard, }) } @@ -170,6 +186,19 @@ impl InputEmulation { event: Event, handle: EmulationHandle, ) -> Result<(), EmulationError> { + // Clipboard events route through the cross-platform + // `ClipboardEmulation` sink, not the per-backend pointer / + // keyboard pipeline. Per-backend `consume` impls treat + // `Event::Clipboard` as a no-op, so handling it here keeps + // the dispatch in one place. + if let Event::Clipboard(ClipboardEvent::Text(text)) = &event { + if let Some(clipboard) = self.clipboard.as_ref() { + if let Err(e) = clipboard.set(ClipboardEvent::Text(text.clone())).await { + log::warn!("failed to set clipboard: {e}"); + } + } + return Ok(()); + } // Apply per-handle receive-side post-processing in a single // place rather than per-backend. Backends stay // platform-mechanics-only and are spared duplicate sign-flip diff --git a/input-emulation/src/libei.rs b/input-emulation/src/libei.rs index d17f2eab0..751e3d48a 100644 --- a/input-emulation/src/libei.rs +++ b/input-emulation/src/libei.rs @@ -248,6 +248,10 @@ impl Emulation for LibeiEmulation { } KeyboardEvent::Modifiers { .. } => {} }, + Event::Clipboard(_) => { + // Clipboard injection is handled by the cross- + // platform `ClipboardEmulation` sink, not libei. + } } self.context .flush() diff --git a/input-emulation/src/macos.rs b/input-emulation/src/macos.rs index 873be7947..ca18052b4 100644 --- a/input-emulation/src/macos.rs +++ b/input-emulation/src/macos.rs @@ -545,6 +545,11 @@ impl Emulation for MacOSEmulation { modifier_event(self.event_source.clone(), self.modifier_state.get()); } }, + Event::Clipboard(_) => { + // Clipboard injection is handled by the cross- + // platform `ClipboardEmulation` sink, not the macOS + // emulation backend. + } } // FIXME Ok(()) diff --git a/input-emulation/src/windows.rs b/input-emulation/src/windows.rs index 18ca7782c..482399446 100644 --- a/input-emulation/src/windows.rs +++ b/input-emulation/src/windows.rs @@ -72,6 +72,10 @@ impl Emulation for WindowsEmulation { } KeyboardEvent::Modifiers { .. } => {} }, + Event::Clipboard(_) => { + // Clipboard injection is handled by the cross- + // platform `ClipboardEmulation` sink. + } } // FIXME Ok(()) diff --git a/input-emulation/src/wlroots.rs b/input-emulation/src/wlroots.rs index a0c039148..7cca09e2f 100644 --- a/input-emulation/src/wlroots.rs +++ b/input-emulation/src/wlroots.rs @@ -249,13 +249,14 @@ impl Emulation for WlrootsEmulation { _ => {} } } + let event_debug = format!("{event:?}"); virtual_input .consume_event(event) - .unwrap_or_else(|_| panic!("failed to convert event: {event:?}")); + .unwrap_or_else(|_| panic!("failed to convert event: {event_debug}")); match self.queue.flush() { Err(WaylandError::Io(e)) if e.kind() == io::ErrorKind::WouldBlock => { self.last_flush_failed = true; - log::warn!("can't keep up, discarding event: ({handle}) - {event:?}"); + log::warn!("can't keep up, discarding event: ({handle}) - {event_debug}"); } Err(WaylandError::Protocol(e)) => panic!("wayland protocol violation: {e}"), Ok(()) => self.last_flush_failed = false, @@ -390,6 +391,10 @@ impl VirtualInput { .modifiers(mods_depressed, mods_latched, mods_locked, group); } }, + Event::Clipboard(_) => { + // Clipboard injection is handled by the cross- + // platform `ClipboardEmulation` sink, not wlroots. + } } Ok(()) } diff --git a/input-emulation/src/xdg_desktop_portal.rs b/input-emulation/src/xdg_desktop_portal.rs index 7082c0a49..bb78d8593 100644 --- a/input-emulation/src/xdg_desktop_portal.rs +++ b/input-emulation/src/xdg_desktop_portal.rs @@ -12,7 +12,7 @@ use async_trait::async_trait; use futures::FutureExt; use input_event::{ - Event::{Keyboard, Pointer}, + Event::{self, Keyboard, Pointer}, KeyboardEvent, PointerEvent, }; @@ -147,6 +147,11 @@ impl Emulation for DesktopPortalEmulation { } } } + Event::Clipboard(_) => { + // Clipboard injection is handled by the cross- + // platform `ClipboardEmulation` sink, not the + // desktop portal backend. + } } Ok(()) } diff --git a/input-event/src/lib.rs b/input-event/src/lib.rs index 1d8c9ffb1..640d69182 100644 --- a/input-event/src/lib.rs +++ b/input-event/src/lib.rs @@ -38,12 +38,20 @@ pub enum KeyboardEvent { }, } -#[derive(PartialEq, Debug, Clone, Copy)] +#[derive(Debug, PartialEq, Clone)] +pub enum ClipboardEvent { + /// text content from clipboard + Text(String), +} + +#[derive(PartialEq, Debug, Clone)] pub enum Event { /// pointer event (motion / button / axis) Pointer(PointerEvent), /// keyboard events (key / modifiers) Keyboard(KeyboardEvent), + /// clipboard events (cross-peer clipboard sync) + Clipboard(ClipboardEvent), } impl Display for PointerEvent { @@ -109,11 +117,27 @@ impl Display for KeyboardEvent { } } +impl Display for ClipboardEvent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ClipboardEvent::Text(text) => { + let preview = if text.len() > 50 { + format!("{}...", &text[..50]) + } else { + text.clone() + }; + write!(f, "clipboard(text: {preview})") + } + } + } +} + impl Display for Event { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Event::Pointer(p) => write!(f, "{p}"), Event::Keyboard(k) => write!(f, "{k}"), + Event::Clipboard(c) => write!(f, "{c}"), } } } diff --git a/input-event/src/libei.rs b/input-event/src/libei.rs index f79048987..3c85a7fd1 100644 --- a/input-event/src/libei.rs +++ b/input-event/src/libei.rs @@ -46,7 +46,7 @@ impl Iterator for EventIterator { let res = if self.pos >= self.events.len() { None } else { - self.events[self.pos] + self.events[self.pos].clone() }; self.pos += 1; res diff --git a/lan-mouse-proto/src/lib.rs b/lan-mouse-proto/src/lib.rs index 0bcc1fba5..fe5b8bc3d 100644 --- a/lan-mouse-proto/src/lib.rs +++ b/lan-mouse-proto/src/lib.rs @@ -1,4 +1,4 @@ -use input_event::{Event as InputEvent, KeyboardEvent, PointerEvent}; +use input_event::{ClipboardEvent, Event as InputEvent, KeyboardEvent, PointerEvent}; use num_enum::{IntoPrimitive, TryFromPrimitive, TryFromPrimitiveError}; use paste::paste; use std::{ @@ -7,11 +7,18 @@ use std::{ }; use thiserror::Error; -/// defines the maximum size an encoded event can take up -/// this is currently the pointer motion event -/// type: u8, time: u32, dx: f64, dy: f64 +/// defines the maximum size a fixed-buffer encoded event can take up. +/// All non-clipboard events fit in this size; clipboard events use the +/// variable-length [`encode_clipboard_event`] / [`decode_clipboard_event`] +/// helpers because their payload (originator fingerprint + content) +/// vastly exceeds the fixed buffer's capacity. pub const MAX_EVENT_SIZE: usize = size_of::() + size_of::() + 2 * size_of::(); +/// Maximum total clipboard payload size on the wire (originator +/// fingerprint + content + length prefixes). 4 KiB is conservative +/// against typical UDP MTU. +pub const MAX_CLIPBOARD_SIZE: usize = 4 * 1024; + /// error type for protocol violations #[derive(Debug, Error)] pub enum ProtocolError { @@ -21,6 +28,15 @@ pub enum ProtocolError { /// position type does not exist #[error("invalid event id: `{0}`")] InvalidPosition(#[from] TryFromPrimitiveError), + /// clipboard payload exceeds [`MAX_CLIPBOARD_SIZE`] + #[error("clipboard payload too large: {0} bytes")] + ClipboardTooLarge(usize), + /// clipboard text is not valid UTF-8 + #[error("invalid UTF-8 in clipboard payload")] + InvalidUtf8(#[from] std::string::FromUtf8Error), + /// not enough bytes left in the buffer + #[error("buffer too small for clipboard payload")] + BufferTooSmall, } /// Position of a client @@ -46,7 +62,7 @@ impl Display for Position { } /// main lan-mouse protocol event type -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub enum ProtoEvent { /// notify a client that the cursor entered its region at the given position /// [`ProtoEvent::Ack`] with the same serial is used for synchronization between devices @@ -108,6 +124,19 @@ pub enum ProtoEvent { /// peers that don't recognize the event type silently skip it /// per the existing forward-compat handling. ReceiverSensitivity { mouse_sensitivity: f64 }, + /// Clipboard text content propagated from the originating peer. + /// `from_fingerprint` is the TLS fingerprint of the peer that + /// originally read the clipboard (not necessarily the sender — + /// intermediate peers preserve the originator field when they + /// fan-out to other peers). The receiver uses it to short-circuit + /// the N-peer forwarding loop along with a recent-content cache. + /// `content` is the clipboard text. Encoded with the variable- + /// length [`encode_clipboard_event`] / [`decode_clipboard_event`] + /// helpers; the fixed-buffer codec panics on this variant. + Clipboard { + from_fingerprint: String, + content: String, + }, } impl Display for ProtoEvent { @@ -137,11 +166,27 @@ impl Display for ProtoEvent { let s = std::str::from_utf8(commit).unwrap_or("????????"); write!(f, "Hello({s})") } + ProtoEvent::Clipboard { + from_fingerprint, + content, + } => { + let preview = if content.len() > 40 { + format!("{}…", &content[..40]) + } else { + content.clone() + }; + write!( + f, + "Clipboard(from={}…, {}b: {preview})", + &from_fingerprint[..from_fingerprint.len().min(8)], + content.len(), + ) + } } } } -#[derive(TryFromPrimitive, IntoPrimitive)] +#[derive(TryFromPrimitive, IntoPrimitive, Debug)] #[repr(u8)] pub enum EventType { PointerMotion, @@ -160,6 +205,10 @@ pub enum EventType { CursorPos, Hello, ReceiverSensitivity, + /// Variable-length clipboard frame; not decodable through the + /// fixed-size [`MAX_EVENT_SIZE`] buffer path. See + /// [`decode_clipboard_event`]. + Clipboard, } impl ProtoEvent { @@ -176,6 +225,9 @@ impl ProtoEvent { KeyboardEvent::Key { .. } => EventType::KeyboardKey, KeyboardEvent::Modifiers { .. } => EventType::KeyboardModifiers, }, + InputEvent::Clipboard(c) => match c { + ClipboardEvent::Text(_) => EventType::Clipboard, + }, }, ProtoEvent::Ping => EventType::Ping, ProtoEvent::Pong(_) => EventType::Pong, @@ -187,6 +239,7 @@ impl ProtoEvent { ProtoEvent::CursorPos { .. } => EventType::CursorPos, ProtoEvent::Hello { .. } => EventType::Hello, ProtoEvent::ReceiverSensitivity { .. } => EventType::ReceiverSensitivity, + ProtoEvent::Clipboard { .. } => EventType::Clipboard, } } } @@ -264,6 +317,10 @@ impl TryFrom<[u8; MAX_EVENT_SIZE]> for ProtoEvent { EventType::ReceiverSensitivity => Ok(Self::ReceiverSensitivity { mouse_sensitivity: decode_f64(&mut buf)?, }), + // Clipboard frames are variable-length and never arrive + // through the fixed-size buffer path; the connect/listen + // layer routes them through `decode_clipboard_event`. + EventType::Clipboard => Err(ProtocolError::BufferTooSmall), } } } @@ -322,6 +379,12 @@ impl From for ([u8; MAX_EVENT_SIZE], usize) { encode_u32(buf, len, group); } }, + InputEvent::Clipboard(_) => { + panic!( + "ProtoEvent::Input(Clipboard) cannot use the fixed-buffer \ + encoder; route via encode_clipboard_event" + ); + } }, ProtoEvent::Ping => {} ProtoEvent::Pong(alive) => encode_u8(buf, len, alive as u8), @@ -349,6 +412,12 @@ impl From for ([u8; MAX_EVENT_SIZE], usize) { ProtoEvent::ReceiverSensitivity { mouse_sensitivity } => { encode_f64(buf, len, mouse_sensitivity); } + ProtoEvent::Clipboard { .. } => { + panic!( + "ProtoEvent::Clipboard cannot use the fixed-buffer encoder; \ + route via encode_clipboard_event" + ); + } } } (buf, len) @@ -393,3 +462,139 @@ encode_impl!(u32); encode_impl!(i32); encode_impl!(f32); encode_impl!(f64); + +/// Wire format for clipboard frames: +/// `[event_type: u8][fp_len: u32 BE][fp: utf8][text_len: u32 BE][text: utf8]` +/// +/// Returns the encoded bytes ready for transmission. The total +/// length is bounded by [`MAX_CLIPBOARD_SIZE`]. +pub fn encode_clipboard_event(event: &ProtoEvent) -> Result, ProtocolError> { + let (from_fingerprint, content) = match event { + ProtoEvent::Clipboard { + from_fingerprint, + content, + } => (from_fingerprint.as_str(), content.as_str()), + ProtoEvent::Input(InputEvent::Clipboard(ClipboardEvent::Text(content))) => { + // Convenience: capture-side callers carry only the text; + // the originator fingerprint is empty until the service + // layer stamps it in. Phase 2 wires the stamp. + ("", content.as_str()) + } + _ => panic!("encode_clipboard_event called on non-clipboard event"), + }; + let fp_bytes = from_fingerprint.as_bytes(); + let text_bytes = content.as_bytes(); + let total = 1 + 4 + fp_bytes.len() + 4 + text_bytes.len(); + if total > MAX_CLIPBOARD_SIZE { + return Err(ProtocolError::ClipboardTooLarge(total)); + } + let mut buf = Vec::with_capacity(total); + buf.push(EventType::Clipboard as u8); + buf.extend_from_slice(&(fp_bytes.len() as u32).to_be_bytes()); + buf.extend_from_slice(fp_bytes); + buf.extend_from_slice(&(text_bytes.len() as u32).to_be_bytes()); + buf.extend_from_slice(text_bytes); + Ok(buf) +} + +/// Decode a clipboard frame produced by [`encode_clipboard_event`]. +pub fn decode_clipboard_event(buf: &[u8]) -> Result { + if buf.len() > MAX_CLIPBOARD_SIZE { + return Err(ProtocolError::ClipboardTooLarge(buf.len())); + } + if buf.is_empty() { + return Err(ProtocolError::BufferTooSmall); + } + let tag = buf[0]; + let event_type = EventType::try_from(tag)?; + if !matches!(event_type, EventType::Clipboard) { + // Wrong-type tag in the clipboard channel — treat as a buffer + // mismatch rather than silently producing some other variant. + return Err(ProtocolError::BufferTooSmall); + } + let mut cursor = 1usize; + if buf.len() < cursor + 4 { + return Err(ProtocolError::BufferTooSmall); + } + let fp_len = u32::from_be_bytes([ + buf[cursor], + buf[cursor + 1], + buf[cursor + 2], + buf[cursor + 3], + ]) as usize; + cursor += 4; + if buf.len() < cursor + fp_len + 4 { + return Err(ProtocolError::BufferTooSmall); + } + let from_fingerprint = String::from_utf8(buf[cursor..cursor + fp_len].to_vec())?; + cursor += fp_len; + let text_len = u32::from_be_bytes([ + buf[cursor], + buf[cursor + 1], + buf[cursor + 2], + buf[cursor + 3], + ]) as usize; + cursor += 4; + if buf.len() < cursor + text_len { + return Err(ProtocolError::BufferTooSmall); + } + let content = String::from_utf8(buf[cursor..cursor + text_len].to_vec())?; + Ok(ProtoEvent::Clipboard { + from_fingerprint, + content, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clipboard_round_trip() { + let event = ProtoEvent::Clipboard { + from_fingerprint: "abcd1234".into(), + content: "hello, world".into(), + }; + let bytes = encode_clipboard_event(&event).expect("encode"); + let decoded = decode_clipboard_event(&bytes).expect("decode"); + match decoded { + ProtoEvent::Clipboard { + from_fingerprint, + content, + } => { + assert_eq!(from_fingerprint, "abcd1234"); + assert_eq!(content, "hello, world"); + } + other => panic!("expected Clipboard, got {other}"), + } + } + + #[test] + fn clipboard_too_large_rejected() { + let event = ProtoEvent::Clipboard { + from_fingerprint: "fp".into(), + content: "x".repeat(MAX_CLIPBOARD_SIZE), + }; + assert!(matches!( + encode_clipboard_event(&event), + Err(ProtocolError::ClipboardTooLarge(_)) + )); + } + + #[test] + fn clipboard_decode_truncated() { + // Encode then truncate the trailing content bytes; decoder + // must surface BufferTooSmall instead of returning a bogus + // string with random capture from the underlying memory. + let event = ProtoEvent::Clipboard { + from_fingerprint: "fp".into(), + content: "some text".into(), + }; + let bytes = encode_clipboard_event(&event).expect("encode"); + let truncated = &bytes[..bytes.len() - 1]; + assert!(matches!( + decode_clipboard_event(truncated), + Err(ProtocolError::BufferTooSmall) + )); + } +} diff --git a/src/capture.rs b/src/capture.rs index 7840ba4c2..959d83e6f 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -472,12 +472,12 @@ impl CaptureTask { None }; - let proto_event = match event { + let proto_event = match &event { CaptureEvent::Begin { .. } => ProtoEvent::Enter(opposite_pos), CaptureEvent::Input(e) => match self.state { // connection not acknowledged, repeat `Enter` event State::WaitingForAck => ProtoEvent::Enter(opposite_pos), - State::Sending => ProtoEvent::Input(e), + State::Sending => ProtoEvent::Input(e.clone()), }, CaptureEvent::AutoRelease => unreachable!("handled in early return above"), }; diff --git a/src/connect.rs b/src/connect.rs index 68066666a..aa3cb7fe2 100644 --- a/src/connect.rs +++ b/src/connect.rs @@ -228,6 +228,7 @@ impl LanMouseConnection { event: ProtoEvent, handle: ClientHandle, ) -> Result<(), LanMouseConnectionError> { + let event_display = format!("{event}"); let (buf, len): ([u8; MAX_EVENT_SIZE], usize) = event.into(); let buf = &buf[..len]; if let Some(addr) = self.client_manager.active_addr(handle) { @@ -246,7 +247,7 @@ impl LanMouseConnection { disconnect(&self.client_manager, handle, addr, &self.conns).await; } } - log::trace!("{event} >->->->->- {addr}"); + log::trace!("{event_display} >->->->->- {addr}"); return Ok(()); } } From ecb109708131dbbe7823ad685c93fe398f72795e Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Thu, 7 May 2026 11:00:21 -0500 Subject: [PATCH 02/25] feat(clipboard): per-pair config + IPC + Service routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 wires up clipboard sync end-to-end, gated per-pair: - ClientConfig.clipboard_send (serde-default false): per-pair gate on the broadcast side - IncomingPeerConfig.clipboard_receive (legacy-friendly Deserialize, default false): per-pair gate on the receive side - Two new FrontendRequest variants: SetClientClipboardSend, SetIncomingPeerClipboardReceive — handled in Service, persisted via the existing config-write path - Service spawns the cross-platform ClipboardMonitor at startup, drains it via a new tokio::select! arm, and on each local clipboard change fans out ProtoEvent::Clipboard{from_fingerprint=self_fp, content} to every active client whose clipboard_send is true - emulation::ListenTask gates inbound ProtoEvent::Clipboard frames by the receiving peer's clipboard_receive, injects locally through emulation_proxy.consume (which short-circuits to ClipboardEmulation ::set), and surfaces a new EmulationEvent::ClipboardReceived upward so Service can refresh ClipboardMonitor.last_content (loop prevention against the local 500ms poll) and re-fan to other peers - N-peer rebroadcast loop prevention: Service tracks recent_forwarded: HashMap<(originator_fp, content_hash), Instant> with a 1s TTL. Both the local-capture and the forwarding paths insert; the forwarding path skips the originator by IP and short-circuits when the (origin, hash) entry is fresh - LanMouseConnection.sender_clone(): cheap send-only handle that shares all dialer state with the original; lets Service emit clipboard frames without routing through the capture session loop - Wire format: connect.rs and listen.rs now read into a buffer sized for MAX_CLIPBOARD_SIZE and dispatch by event-type tag, routing clipboard frames through decode_clipboard_event and everything else through the existing fixed-buffer try_into path The two-peer happy path: copy text on a peer with clipboard_send=true to another peer with clipboard_receive=true and the text appears in the receiver's clipboard. With both gates default-false this is opt- in per pair; existing pairs see no behavior change on upgrade. Co-Authored-By: Claude Opus 4.7 (1M context) --- lan-mouse-ipc/src/lib.rs | 28 +++++ src/client.rs | 27 +++++ src/config.rs | 16 +++ src/connect.rs | 129 +++++++++++++++++------ src/emulation.rs | 52 ++++++++- src/listen.rs | 38 +++++-- src/service.rs | 222 ++++++++++++++++++++++++++++++++++++++- 7 files changed, 471 insertions(+), 41 deletions(-) diff --git a/lan-mouse-ipc/src/lib.rs b/lan-mouse-ipc/src/lib.rs index 80a646808..ab326459c 100644 --- a/lan-mouse-ipc/src/lib.rs +++ b/lan-mouse-ipc/src/lib.rs @@ -140,6 +140,14 @@ pub struct ClientConfig { pub pos: Position, /// enter hook pub cmd: Option, + /// Whether changes to this device's clipboard should be + /// propagated to this peer. Per-pair gate on the *send* side; + /// the receiving peer's `IncomingPeerConfig::clipboard_receive` + /// is the matching gate on the receive side. Both must be true + /// for clipboard text to flow. Defaults to `false` — clipboard + /// is a meaningfully different trust scope than mouse/keyboard. + #[serde(default)] + pub clipboard_send: bool, } impl Default for ClientConfig { @@ -150,6 +158,7 @@ impl Default for ClientConfig { fix_ips: Default::default(), pos: Default::default(), cmd: None, + clipboard_send: false, } } } @@ -191,6 +200,13 @@ pub struct IncomingPeerConfig { /// on either side or the peer's mDNS record didn't match the /// IP it connected from. pub last_hostname: Option, + /// Whether clipboard text propagated by this peer should be + /// applied to the local clipboard. Per-pair gate on the + /// *receive* side; the sending peer's + /// `ClientConfig::clipboard_send` is the matching gate on the + /// send side. Both must be true for clipboard text to flow. + /// Defaults to `false`. + pub clipboard_receive: bool, } impl Default for IncomingPeerConfig { @@ -201,6 +217,7 @@ impl Default for IncomingPeerConfig { mouse_sensitivity: 1.0, last_addr: None, last_hostname: None, + clipboard_receive: false, } } } @@ -223,6 +240,8 @@ impl<'de> Deserialize<'de> for IncomingPeerConfig { last_addr: Option, #[serde(default)] last_hostname: Option, + #[serde(default)] + clipboard_receive: bool, }, } fn default_sensitivity() -> f64 { @@ -239,12 +258,14 @@ impl<'de> Deserialize<'de> for IncomingPeerConfig { mouse_sensitivity, last_addr, last_hostname, + clipboard_receive, } => Self { description, natural_scroll, mouse_sensitivity, last_addr, last_hostname, + clipboard_receive, }, }) } @@ -379,6 +400,13 @@ pub enum FrontendRequest { SetIncomingPeerSensitivity(String, f64), /// turn mDNS-SD discovery on or off SetMdnsDiscovery(bool), + /// Toggle whether clipboard changes on this device propagate to + /// the given outgoing client. Per-pair send-side gate. + SetClientClipboardSend(ClientHandle, bool), + /// Toggle whether clipboard text from the given authorized peer + /// is applied to this device's clipboard. Per-pair receive-side + /// gate, keyed on the peer's TLS certificate fingerprint. + SetIncomingPeerClipboardReceive(String, bool), } #[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Serialize, Deserialize)] diff --git a/src/client.rs b/src/client.rs index 5a87301b9..dc8176b6e 100644 --- a/src/client.rs +++ b/src/client.rs @@ -33,6 +33,7 @@ impl ClientManager { port: config_client.port, pos: config_client.pos, cmd: config_client.enter_hook, + clipboard_send: config_client.clipboard_send, }; let state = ClientState { active: config_client.active, @@ -330,4 +331,30 @@ impl ClientManager { .get(handle as usize) .map(|(_, s)| s.ips.clone()) } + + /// Update the per-pair clipboard-send gate for the given client. + /// Returns `true` when the value changed (so callers can avoid + /// no-op config writes / frontend broadcasts). + pub(crate) fn set_clipboard_send(&self, handle: ClientHandle, enabled: bool) -> bool { + match self.clients.borrow_mut().get_mut(handle as usize) { + Some((c, _)) if c.clipboard_send != enabled => { + c.clipboard_send = enabled; + true + } + _ => false, + } + } + + /// Snapshot of every client whose `clipboard_send` is true and + /// whose state is `active`. Used by Service to fan-out a local + /// clipboard change without holding the manager's borrow across + /// async sends. + pub(crate) fn clipboard_send_targets(&self) -> Vec { + self.clients + .borrow() + .iter() + .filter(|(_, (c, s))| c.clipboard_send && s.active) + .map(|(k, _)| k as ClientHandle) + .collect() + } } diff --git a/src/config.rs b/src/config.rs index 7489679b4..844cb1868 100644 --- a/src/config.rs +++ b/src/config.rs @@ -91,6 +91,10 @@ struct TomlClient { position: Option, activate_on_startup: Option, enter_hook: Option, + /// Per-pair clipboard send gate. Absent in legacy configs; + /// defaults to `false` so existing pairs don't start + /// transmitting clipboard text on upgrade. + clipboard_send: Option, } impl ConfigToml { @@ -294,6 +298,7 @@ pub struct ConfigClient { pub pos: Position, pub active: bool, pub enter_hook: Option, + pub clipboard_send: bool, } impl From for ConfigClient { @@ -304,6 +309,7 @@ impl From for ConfigClient { let ips = HashSet::from_iter(toml.ips.into_iter().flatten()); let port = toml.port.unwrap_or(DEFAULT_PORT); let pos = toml.position.unwrap_or_default(); + let clipboard_send = toml.clipboard_send.unwrap_or(false); Self { ips, hostname, @@ -311,6 +317,7 @@ impl From for ConfigClient { pos, active, enter_hook, + clipboard_send, } } } @@ -330,6 +337,14 @@ impl From for TomlClient { let position = Some(client.pos); let activate_on_startup = if client.active { Some(true) } else { None }; let enter_hook = client.enter_hook; + // Persist `clipboard_send = true` only when the user has + // opted in. Default-false stays implicit so configs upgraded + // from older builds don't gain a new field on every save. + let clipboard_send = if client.clipboard_send { + Some(true) + } else { + None + }; Self { hostname, host_name, @@ -338,6 +353,7 @@ impl From for TomlClient { position, activate_on_startup, enter_hook, + clipboard_send, } } } diff --git a/src/connect.rs b/src/connect.rs index aa3cb7fe2..f696a4db2 100644 --- a/src/connect.rs +++ b/src/connect.rs @@ -2,7 +2,9 @@ use crate::client::ClientManager; use crate::config::local_commit; use crate::discovery::{PrimaryCache, normalize_mdns_name}; use lan_mouse_ipc::{ClientHandle, DEFAULT_PORT}; -use lan_mouse_proto::{MAX_EVENT_SIZE, ProtoEvent}; +use lan_mouse_proto::{ + MAX_CLIPBOARD_SIZE, MAX_EVENT_SIZE, ProtoEvent, decode_clipboard_event, encode_clipboard_event, +}; use local_channel::mpsc::{Receiver, Sender, channel}; use std::{ cell::RefCell, @@ -223,14 +225,55 @@ impl LanMouseConnection { self.recv_rx.recv().await.expect("channel closed") } + /// Cheap send-only handle that shares all the dialer state with + /// `self`. The clone's `recv_rx` is a dead stub — only the + /// original [`LanMouseConnection`] (held by Capture) drains the + /// live receiver. Used by Service to fan clipboard frames out + /// without routing through the capture session loop. + pub(crate) fn sender_clone(&self) -> Self { + let (_, dead_rx) = channel(); + Self { + cert: self.cert.clone(), + client_manager: self.client_manager.clone(), + conns: self.conns.clone(), + connecting: self.connecting.clone(), + recv_rx: dead_rx, + recv_tx: self.recv_tx.clone(), + ping_response: self.ping_response.clone(), + primary_hints: self.primary_hints.clone(), + retry_state: self.retry_state.clone(), + } + } + pub(crate) async fn send( &self, event: ProtoEvent, handle: ClientHandle, ) -> Result<(), LanMouseConnectionError> { let event_display = format!("{event}"); - let (buf, len): ([u8; MAX_EVENT_SIZE], usize) = event.into(); - let buf = &buf[..len]; + // Clipboard frames are variable-length and can't ride the + // fixed-size codec; route them through the dedicated helper. + // For all other events the existing 21-byte path is faster. + let bytes_owned: Option> = match &event { + ProtoEvent::Clipboard { .. } => match encode_clipboard_event(&event) { + Ok(v) => Some(v), + Err(e) => { + log::warn!("dropping oversize clipboard event for client {handle}: {e}"); + return Ok(()); + } + }, + _ => None, + }; + let bytes_fixed: ([u8; MAX_EVENT_SIZE], usize) = if bytes_owned.is_some() { + ([0u8; MAX_EVENT_SIZE], 0) + } else { + event.into() + }; + let buf: &[u8] = if let Some(v) = bytes_owned.as_deref() { + v + } else { + &bytes_fixed.0[..bytes_fixed.1] + }; if let Some(addr) = self.client_manager.active_addr(handle) { let conn = { let conns = self.conns.lock().await; @@ -427,43 +470,69 @@ async fn receive_loop( tx: Sender<(ClientHandle, ProtoEvent)>, ping_response: Rc>>, ) { - let mut buf = [0u8; MAX_EVENT_SIZE]; - while conn.recv(&mut buf).await.is_ok() { - match buf.try_into() { - Ok(event) => { - log::trace!("{addr} <==<==<== {event}"); - match event { - ProtoEvent::Pong(b) => { - client_manager.set_active_addr(handle, Some(addr)); - client_manager.set_alive(handle, b); - ping_response.borrow_mut().insert(addr); - } - ProtoEvent::Hello { commit } => { - client_manager.set_peer_commit(handle, Some(commit)); - // Forward to capture.rs so Service can - // broadcast — without this the GUI's - // version-status indicator only updates when - // the listen-side `PeerHello` happens to - // match `get_client(addr)`, which fails when - // Mac dials in before Linux's outbound dial - // has populated `active_addr`. - tx.send((handle, ProtoEvent::Hello { commit })) - .expect("channel closed"); - } - event => tx.send((handle, event)).expect("channel closed"), - } - } + // Buffer sized for the largest legal clipboard frame so a single + // DTLS recv never gets truncated. Non-clipboard events use only + // the first MAX_EVENT_SIZE bytes; the rest of the buffer is + // unused for those datagrams. + let mut buf = [0u8; MAX_CLIPBOARD_SIZE]; + while let Ok(n) = conn.recv(&mut buf).await { + if n == 0 { + continue; + } + let datagram = &buf[..n]; + let event = match decode_proto_datagram(datagram) { + Some(event) => event, // Skip undecodable datagrams without dropping the // connection. Each DTLS recv is one framed message, so // skipping is safe and keeps us forward-compatible with // peers that send event types we don't yet know about. - Err(e) => log::debug!("ignoring undecodable event from {addr}: {e}"), + None => { + log::debug!("ignoring undecodable {n}-byte event from {addr}"); + continue; + } + }; + log::trace!("{addr} <==<==<== {event}"); + match event { + ProtoEvent::Pong(b) => { + client_manager.set_active_addr(handle, Some(addr)); + client_manager.set_alive(handle, b); + ping_response.borrow_mut().insert(addr); + } + ProtoEvent::Hello { commit } => { + client_manager.set_peer_commit(handle, Some(commit)); + // Forward to capture.rs so Service can + // broadcast — without this the GUI's + // version-status indicator only updates when + // the listen-side `PeerHello` happens to + // match `get_client(addr)`, which fails when + // Mac dials in before Linux's outbound dial + // has populated `active_addr`. + tx.send((handle, ProtoEvent::Hello { commit })) + .expect("channel closed"); + } + event => tx.send((handle, event)).expect("channel closed"), } } log::warn!("recv error"); disconnect(&client_manager, handle, addr, &conns).await; } +/// Classify the first byte of a DTLS datagram and dispatch through +/// either the variable-length clipboard codec or the fixed-buffer +/// `try_into` path. Returns `None` on any decode failure (bad tag, +/// truncated payload, oversize frame). +fn decode_proto_datagram(bytes: &[u8]) -> Option { + use lan_mouse_proto::EventType; + let tag = *bytes.first()?; + if tag == EventType::Clipboard as u8 { + return decode_clipboard_event(bytes).ok(); + } + let mut fixed = [0u8; MAX_EVENT_SIZE]; + let copy_len = bytes.len().min(MAX_EVENT_SIZE); + fixed[..copy_len].copy_from_slice(&bytes[..copy_len]); + fixed.try_into().ok() +} + async fn disconnect( client_manager: &ClientManager, handle: ClientHandle, diff --git a/src/emulation.rs b/src/emulation.rs index 30250a153..9e9277470 100644 --- a/src/emulation.rs +++ b/src/emulation.rs @@ -4,7 +4,7 @@ use futures::StreamExt; use input_emulation::{ EmulationHandle, InputEmulation, InputEmulationError, ReceivePostProcessing, }; -use input_event::Event; +use input_event::{ClipboardEvent, Event}; use lan_mouse_ipc::IncomingPeerConfig; use lan_mouse_proto::{Position, ProtoEvent}; use local_channel::mpsc::{Receiver, Sender, channel}; @@ -74,6 +74,22 @@ pub(crate) enum EmulationEvent { addr: SocketAddr, commit: [u8; 8], }, + /// Authorized peer at `addr` delivered a clipboard frame whose + /// receive-side gate evaluated true. The local clipboard has + /// already been updated by [`ListenTask`]; Service consumes + /// this event to refresh the `ClipboardMonitor`'s + /// last-known-content (so the next poll doesn't re-emit it as a + /// fresh local change) and to fan the payload out to other + /// authorized peers whose `clipboard_send` is true. + /// `from_fingerprint` is the *originator*'s certificate + /// fingerprint stamped on the wire — distinct from the + /// fingerprint of the peer at `addr` when the message has + /// been forwarded through an intermediate hop. + ClipboardReceived { + addr: SocketAddr, + from_fingerprint: String, + content: String, + }, } enum EmulationRequest { @@ -242,6 +258,40 @@ impl ListenTask { self.listener.reply(addr, ProtoEvent::Ack(0)).await; } ProtoEvent::Input(event) => self.emulation_proxy.consume(event, addr), + ProtoEvent::Clipboard { from_fingerprint, content } => { + let receive_ok = self.addr_to_fingerprint + .get(&addr) + .and_then(|fp| self.incoming_peers.get(fp)) + .map(|peer| peer.clipboard_receive) + .unwrap_or(false); + if !receive_ok { + log::debug!( + "dropping clipboard frame from {addr}: clipboard_receive disabled or unauthorized peer" + ); + } else { + // Inject locally via the same + // pipeline that handles input + // events. InputEmulation::consume + // short-circuits Clipboard events + // to its ClipboardEmulation sink. + self.emulation_proxy.consume( + Event::Clipboard(ClipboardEvent::Text(content.clone())), + addr, + ); + // Hand off to Service so it can + // (a) suppress an immediate self- + // emit from the local + // ClipboardMonitor poll, and (b) + // forward to other peers honoring + // the (originator, content) + // recent-forwarded gate. + self.event_tx.send(EmulationEvent::ClipboardReceived { + addr, + from_fingerprint, + content, + }).expect("channel closed"); + } + } ProtoEvent::Ping => self.listener.reply(addr, ProtoEvent::Pong(self.emulation_proxy.emulation_active.get())).await, // Peer's version handshake. Echo our own // commit back so the peer's connect-side diff --git a/src/listen.rs b/src/listen.rs index 6ffa6545e..b04353d5b 100644 --- a/src/listen.rs +++ b/src/listen.rs @@ -1,6 +1,8 @@ use futures::{Stream, StreamExt}; use lan_mouse_ipc::IncomingPeerConfig; -use lan_mouse_proto::{MAX_EVENT_SIZE, ProtoEvent}; +use lan_mouse_proto::{ + MAX_CLIPBOARD_SIZE, MAX_EVENT_SIZE, ProtoEvent, decode_clipboard_event, +}; use local_channel::mpsc::{Receiver, Sender, channel}; use rustls::pki_types::CertificateDer; use std::{ @@ -536,14 +538,20 @@ async fn read_loop( conn: ArcConn, dtls_tx: Sender, ) -> Result<(), Error> { - let mut b = [0u8; MAX_EVENT_SIZE]; + // Buffer sized for the largest legal clipboard frame; mouse / + // keyboard datagrams use only the first MAX_EVENT_SIZE bytes. + let mut b = [0u8; MAX_CLIPBOARD_SIZE]; - while conn.recv(&mut b).await.is_ok() { - match b.try_into() { - Ok(event) => dtls_tx + while let Ok(n) = conn.recv(&mut b).await { + if n == 0 { + continue; + } + let datagram = &b[..n]; + match decode_listen_datagram(datagram) { + Some(event) => dtls_tx .send(ListenEvent::Msg { event, addr }) .expect("channel closed"), - Err(e) => { + None => { // Skip the malformed/unknown datagram and keep // listening. Each DTLS recv returns one full // datagram, so a parse error here can't desync a @@ -553,7 +561,7 @@ async fn read_loop( // version can introduce additional event types // and old peers will simply ignore them rather // than dropping the connection. - log::debug!("ignoring undecodable event from {addr}: {e}"); + log::debug!("ignoring undecodable {n}-byte event from {addr}"); } } } @@ -566,3 +574,19 @@ async fn read_loop( conns.remove(index); Ok(()) } + +/// Classify a DTLS datagram by its first-byte event-type tag and +/// route through the variable-length clipboard codec or the fixed- +/// buffer `try_into` path. Mirrors `decode_proto_datagram` on the +/// connect side so both directions accept the same wire formats. +fn decode_listen_datagram(bytes: &[u8]) -> Option { + use lan_mouse_proto::EventType; + let tag = *bytes.first()?; + if tag == EventType::Clipboard as u8 { + return decode_clipboard_event(bytes).ok(); + } + let mut fixed = [0u8; MAX_EVENT_SIZE]; + let copy_len = bytes.len().min(MAX_EVENT_SIZE); + fixed[..copy_len].copy_from_slice(&bytes[..copy_len]); + fixed.try_into().ok() +} diff --git a/src/service.rs b/src/service.rs index 0de370f21..543a4b75c 100644 --- a/src/service.rs +++ b/src/service.rs @@ -10,17 +10,21 @@ use crate::{ listen::{LanMouseListener, ListenerCreationError}, }; use futures::StreamExt; +use input_capture::clipboard::ClipboardMonitor; +use input_event::{ClipboardEvent, Event as InputEvent}; use lan_mouse_ipc::{ AsyncFrontendListener, ClientHandle, FrontendEvent, FrontendRequest, IncomingPeerConfig, IpcError, IpcListenerCreationError, Position, Status, }; +use lan_mouse_proto::ProtoEvent; use log; use std::{ collections::{HashMap, HashSet, VecDeque}, + hash::{DefaultHasher, Hash, Hasher}, io, net::{IpAddr, SocketAddr}, sync::{Arc, RwLock}, - time::Duration, + time::{Duration, Instant}, }; use thiserror::Error; use tokio::{process::Command, signal, sync::Notify}; @@ -79,6 +83,32 @@ pub struct Service { /// shared `PrimaryCache` (read by `LanMouseConnection`) from /// peer announcements. discovery: Discovery, + /// Outgoing connection handle to fan clipboard frames out from + /// the capture / forwarding paths. Same handle Capture owns; + /// cloned in `Service::new` so Service can call `send` directly + /// without routing through the capture session loop. + conn: LanMouseConnection, + /// Cross-platform clipboard poller. `None` when the platform + /// clipboard couldn't be opened (headless CI, Wayland session + /// without compositor support). Service drains it in the main + /// loop and fans the resulting events out to peers whose + /// `clipboard_send` is true. + clipboard_monitor: Option, + /// Recent forwards keyed on `(originator_fingerprint, hash)`. + /// Used to break N-peer rebroadcast cycles: when this device + /// receives a forwarded clipboard frame and would re-fan to + /// other peers, the entry under (origin, content_hash) blocks + /// the duplicate. Pruned lazily — entries older than + /// `RECENT_FORWARD_TTL` are dropped on each clipboard event. + recent_forwarded: HashMap<(String, u64), Instant>, +} + +const RECENT_FORWARD_TTL: Duration = Duration::from_secs(1); + +fn clipboard_hash(content: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + content.hash(&mut hasher); + hasher.finish() } #[derive(Debug)] @@ -115,6 +145,7 @@ impl Service { // input capture + emulation let capture_backend = config.capture_backend().map(|b| b.into()); + let conn_for_service = conn.sender_clone(); let capture = Capture::new( capture_backend, conn, @@ -133,6 +164,18 @@ impl Service { let port = config.port(); let discovery = Discovery::new(port, config.mdns_discovery(), primary_cache.clone()); + // ClipboardMonitor is best-effort: a headless CI environment + // or a Wayland session without compositor support yields a + // permanent error here. We log and proceed without clipboard + // sync rather than tying daemon startup to clipboard + // availability. + let clipboard_monitor = match ClipboardMonitor::new() { + Ok(m) => Some(m), + Err(e) => { + log::warn!("clipboard monitor unavailable: {e}; clipboard sync disabled"); + None + } + }; let service = Self { config, capture, @@ -152,6 +195,9 @@ impl Service { incoming_conns: Default::default(), next_trigger_handle: 0, discovery, + conn: conn_for_service, + clipboard_monitor, + recent_forwarded: HashMap::new(), }; Ok(service) } @@ -186,11 +232,14 @@ impl Service { tokio::select! { request = self.frontend_listener.next() => self.handle_frontend_request(request), _ = self.frontend_event_pending.notified() => self.handle_frontend_pending().await, - event = self.emulation.event() => self.handle_emulation_event(event), + event = self.emulation.event() => self.handle_emulation_event(event).await, event = self.capture.event() => self.handle_capture_event(event), event = self.resolver.event() => self.handle_resolver_event(event), _ = self.config.changed() => self.handle_config_change(), _ = discovery_refresh_tick.tick() => self.discovery.refresh(), + event = recv_clipboard(&mut self.clipboard_monitor) => { + self.handle_local_clipboard_event(event).await; + } r = signal::ctrl_c() => break r.expect("failed to wait for CTRL+C"), } } @@ -278,6 +327,16 @@ impl Service { self.notify_frontend(FrontendEvent::MdnsDiscovery(enabled)); self.save_config(); } + FrontendRequest::SetClientClipboardSend(handle, enabled) => { + if self.client_manager.set_clipboard_send(handle, enabled) { + self.broadcast_client(handle); + self.save_config(); + } + } + FrontendRequest::SetIncomingPeerClipboardReceive(fp, enabled) => { + self.set_incoming_peer_clipboard_receive(fp, enabled); + self.save_config(); + } } } @@ -352,6 +411,22 @@ impl Service { self.notify_frontend(FrontendEvent::AuthorizedUpdated(keys)); } + fn set_incoming_peer_clipboard_receive(&mut self, fingerprint: String, clipboard_receive: bool) { + if let Some(peer) = self + .authorized_keys + .write() + .expect("lock") + .get_mut(&fingerprint) + { + peer.clipboard_receive = clipboard_receive; + } + let keys = self.authorized_keys.read().expect("lock").clone(); + // Emulation needs to know so the receive-side gate matches + // the new value immediately, not after a config-change cycle. + self.emulation.set_incoming_peers(keys.clone()); + self.notify_frontend(FrontendEvent::AuthorizedUpdated(keys)); + } + fn save_config(&mut self) { let clients = self.client_manager.clients(); let clients = clients @@ -363,6 +438,7 @@ impl Service { pos: c.pos, active: s.active, enter_hook: c.cmd, + clipboard_send: c.clipboard_send, }) .collect(); self.config.set_clients(clients); @@ -407,7 +483,7 @@ impl Service { } } - fn handle_emulation_event(&mut self, event: EmulationEvent) { + async fn handle_emulation_event(&mut self, event: EmulationEvent) { match event { EmulationEvent::ConnectionAttempt { fingerprint } => { self.notify_frontend(FrontendEvent::ConnectionAttempt { fingerprint }); @@ -467,9 +543,135 @@ impl Service { self.broadcast_client(handle); } } + EmulationEvent::ClipboardReceived { + addr, + from_fingerprint, + content, + } => { + self.handle_clipboard_received(addr, from_fingerprint, content) + .await; + } } } + /// Local clipboard change picked up by the polling + /// [`ClipboardMonitor`]. Stamp the originator fingerprint on the + /// wire frame and fan out to every active outgoing client whose + /// `clipboard_send` is true. Records `(self_fp, hash)` in + /// `recent_forwarded` so a later forwarded copy of the same + /// content (re-arriving via another peer in an N-peer ring) + /// won't be redundantly re-broadcast. + async fn handle_local_clipboard_event( + &mut self, + event: Option, + ) { + let Some(event) = event else { + return; + }; + let input_capture::CaptureEvent::Input(InputEvent::Clipboard(ClipboardEvent::Text( + content, + ))) = event + else { + return; + }; + let targets = self.client_manager.clipboard_send_targets(); + if targets.is_empty() { + log::trace!( + "clipboard captured locally ({} bytes) but no peer has clipboard_send=true; skipping fan-out", + content.len() + ); + return; + } + let from_fingerprint = self.public_key_fingerprint.clone(); + let hash = clipboard_hash(&content); + self.prune_recent_forwarded(); + self.recent_forwarded + .insert((from_fingerprint.clone(), hash), Instant::now()); + log::info!( + "broadcasting local clipboard ({} bytes) to {} peer(s)", + content.len(), + targets.len() + ); + for handle in targets { + let event = ProtoEvent::Clipboard { + from_fingerprint: from_fingerprint.clone(), + content: content.clone(), + }; + if let Err(e) = self.conn.send(event, handle).await { + log::debug!("clipboard send to client {handle} failed: {e}"); + } + } + } + + /// Forwarded clipboard frame just landed via the listen side + /// (the local clipboard has already been updated by + /// `emulation::ListenTask`). Refresh the + /// [`ClipboardMonitor`]'s last-known content so the next 500ms + /// poll doesn't see this as a fresh local change and bounce it + /// back, then forward to other peers honoring the recent- + /// forwarded gate. + async fn handle_clipboard_received( + &mut self, + from_addr: SocketAddr, + from_fingerprint: String, + content: String, + ) { + if let Some(monitor) = self.clipboard_monitor.as_ref() { + monitor.update_last_content(content.clone()); + } + let hash = clipboard_hash(&content); + self.prune_recent_forwarded(); + let key = (from_fingerprint.clone(), hash); + if self.recent_forwarded.contains_key(&key) { + log::debug!( + "skipping clipboard re-fan-out: already forwarded ({}, {} bytes) within {}ms", + &from_fingerprint[..from_fingerprint.len().min(8)], + content.len(), + RECENT_FORWARD_TTL.as_millis() + ); + return; + } + let targets = self.client_manager.clipboard_send_targets(); + let forward_targets: Vec = targets + .into_iter() + .filter(|h| { + // Skip the client we just received from. Identified + // by IP rather than full SocketAddr so a peer's + // ephemeral source port (which differs between its + // outgoing and our cached active_addr) doesn't + // accidentally include them. + self.client_manager + .active_addr(*h) + .map(|a| a.ip() != from_addr.ip()) + .unwrap_or(true) + }) + .collect(); + if forward_targets.is_empty() { + return; + } + self.recent_forwarded.insert(key, Instant::now()); + log::info!( + "forwarding clipboard ({} bytes, originator {}) to {} peer(s)", + content.len(), + &from_fingerprint[..from_fingerprint.len().min(8)], + forward_targets.len() + ); + for handle in forward_targets { + let event = ProtoEvent::Clipboard { + from_fingerprint: from_fingerprint.clone(), + content: content.clone(), + }; + if let Err(e) = self.conn.send(event, handle).await { + log::debug!("clipboard forward to client {handle} failed: {e}"); + } + } + } + + fn prune_recent_forwarded(&mut self) { + self.recent_forwarded + .retain(|_, ts| ts.elapsed() < RECENT_FORWARD_TTL); + } + fn handle_capture_event(&mut self, event: ICaptureEvent) { match event { ICaptureEvent::CaptureBegin(handle) => { @@ -767,3 +969,17 @@ impl Service { }); } } + +/// `tokio::select!` arm helper for the optional [`ClipboardMonitor`]. +/// Resolves to `Some(event)` when the monitor surfaces a change and +/// to a never-completing future when no monitor is alive — keeping +/// the surrounding `select!` from busy-spinning when clipboard sync +/// is unavailable on the host. +async fn recv_clipboard( + monitor: &mut Option, +) -> Option { + match monitor.as_mut() { + Some(m) => m.recv().await, + None => std::future::pending().await, + } +} From ecf46fb850e01e36e15588b97bbff7ace4daecba Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Thu, 7 May 2026 11:06:28 -0500 Subject: [PATCH 03/25] feat(gtk): per-pair clipboard toggles in client + key rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new AdwSwitchRow toggles mirroring the scroll/sensitivity pattern from PR #435: - ClientRow gets "Share Clipboard With This Peer" — drives FrontendRequest::SetClientClipboardSend on toggle, reads client.clipboard_send on update_client_config so the GUI stays in sync with the daemon's persisted state. - KeyRow (Incoming Connections) gets "Accept Clipboard From This Peer" — drives SetIncomingPeerClipboardReceive on toggle, picks up server-driven changes via property-notify so the in-place diff in set_authorized_keys flips the switch without re-creating rows. The collapsed-row settings_summary now includes a "Clipboard" token when receive is on, alongside Natural / N×. ClientObject and KeyObject each gain a matching GObject property (clipboard-send / clipboard-receive). The bindings + signal block/unblock dance follows the existing pattern so server- originated values don't ricochet back as fresh user requests. Co-Authored-By: Claude Opus 4.7 (1M context) --- lan-mouse-gtk/resources/client_row.ui | 16 ++++++ lan-mouse-gtk/resources/key_row.ui | 13 +++++ lan-mouse-gtk/src/client_object.rs | 2 + lan-mouse-gtk/src/client_object/imp.rs | 1 + lan-mouse-gtk/src/client_row.rs | 24 +++++++++ lan-mouse-gtk/src/client_row/imp.rs | 34 +++++++++++++ lan-mouse-gtk/src/key_object.rs | 1 + lan-mouse-gtk/src/key_object/imp.rs | 6 +++ lan-mouse-gtk/src/key_row.rs | 67 ++++++++++++++++++++++---- lan-mouse-gtk/src/key_row/imp.rs | 20 ++++++++ lan-mouse-gtk/src/window.rs | 37 ++++++++++++++ 11 files changed, 212 insertions(+), 9 deletions(-) diff --git a/lan-mouse-gtk/resources/client_row.ui b/lan-mouse-gtk/resources/client_row.ui index 82c2e1012..466365f12 100644 --- a/lan-mouse-gtk/resources/client_row.ui +++ b/lan-mouse-gtk/resources/client_row.ui @@ -72,6 +72,22 @@ + + + + Share Clipboard With This Peer + When you copy text on this device, send it to this peer. + + false + + + center + + + + diff --git a/lan-mouse-gtk/resources/key_row.ui b/lan-mouse-gtk/resources/key_row.ui index 9759fd695..864bdd9cd 100644 --- a/lan-mouse-gtk/resources/key_row.ui +++ b/lan-mouse-gtk/resources/key_row.ui @@ -80,6 +80,19 @@ + + + + Accept Clipboard From This Peer + Apply text this peer copies to your clipboard. + false + + + center + + + + diff --git a/lan-mouse-gtk/src/client_object.rs b/lan-mouse-gtk/src/client_object.rs index 0e34fda67..18e12b518 100644 --- a/lan-mouse-gtk/src/client_object.rs +++ b/lan-mouse-gtk/src/client_object.rs @@ -27,6 +27,7 @@ impl ClientObject { ) .property("resolving", state.resolving) .property("peer-commit", peer_commit_to_string(state.peer_commit)) + .property("clipboard-send", client.clipboard_send) .build() } @@ -53,4 +54,5 @@ pub struct ClientData { pub resolving: bool, pub ips: Vec, pub peer_commit: Option, + pub clipboard_send: bool, } diff --git a/lan-mouse-gtk/src/client_object/imp.rs b/lan-mouse-gtk/src/client_object/imp.rs index 2096584f7..4ca9520a5 100644 --- a/lan-mouse-gtk/src/client_object/imp.rs +++ b/lan-mouse-gtk/src/client_object/imp.rs @@ -20,6 +20,7 @@ pub struct ClientObject { #[property(name = "resolving", get, set, type = bool, member = resolving)] #[property(name = "ips", get, set, type = Vec, member = ips)] #[property(name = "peer-commit", get, set, type = Option, member = peer_commit)] + #[property(name = "clipboard-send", get, set, type = bool, member = clipboard_send)] pub data: RefCell, } diff --git a/lan-mouse-gtk/src/client_row.rs b/lan-mouse-gtk/src/client_row.rs index ac0258394..a76dfe042 100644 --- a/lan-mouse-gtk/src/client_row.rs +++ b/lan-mouse-gtk/src/client_row.rs @@ -135,6 +135,24 @@ impl ClientRow { .sync_create() .build(); + // bind clipboard-send to switch state + let clipboard_send_state_binding = client_object + .bind_property( + "clipboard-send", + &self.imp().clipboard_send_switch.get(), + "state", + ) + .sync_create() + .build(); + let clipboard_send_active_binding = client_object + .bind_property( + "clipboard-send", + &self.imp().clipboard_send_switch.get(), + "active", + ) + .sync_create() + .build(); + bindings.push(active_binding); bindings.push(switch_position_binding); bindings.push(hostname_binding); @@ -142,6 +160,8 @@ impl ClientRow { bindings.push(position_binding); bindings.push(resolve_binding); bindings.push(ip_binding); + bindings.push(clipboard_send_state_binding); + bindings.push(clipboard_send_active_binding); // Render the initial collapsed subtitle from whatever // peer_commit the ClientObject was created with. Subsequent @@ -181,6 +201,10 @@ impl ClientRow { self.imp().set_dns_state(resolved); } + pub fn set_clipboard_send(&self, value: bool) { + self.imp().set_clipboard_send(value); + } + /// Recompute the collapsed subtitle (Pango markup) based on the /// current `peer-commit` property and the local build's commit. /// Soft-warn semantics: a missing or mismatched peer commit diff --git a/lan-mouse-gtk/src/client_row/imp.rs b/lan-mouse-gtk/src/client_row/imp.rs index 1bdcf0173..8790422e2 100644 --- a/lan-mouse-gtk/src/client_row/imp.rs +++ b/lan-mouse-gtk/src/client_row/imp.rs @@ -17,6 +17,8 @@ pub struct ClientRow { #[template_child] pub enable_switch: TemplateChild, #[template_child] + pub clipboard_send_switch: TemplateChild, + #[template_child] pub dns_button: TemplateChild, #[template_child] pub hostname: TemplateChild, @@ -36,6 +38,7 @@ pub struct ClientRow { port_change_handler: RefCell>, position_change_handler: RefCell>, set_state_handler: RefCell>, + pub clipboard_send_handler: RefCell>, pub client_object: RefCell>, } @@ -103,6 +106,18 @@ impl ObjectImpl for ClientRow { } )); self.set_state_handler.replace(Some(handler)); + let handler = self.clipboard_send_switch.connect_state_set(clone!( + #[weak(rename_to = row)] + self, + #[upgrade_or] + glib::Propagation::Proceed, + move |_, state| { + row.obj() + .emit_by_name::<()>("request-clipboard-send-change", &[&state]); + glib::Propagation::Proceed + } + )); + self.clipboard_send_handler.replace(Some(handler)); } fn signals() -> &'static [glib::subclass::Signal] { @@ -123,6 +138,9 @@ impl ObjectImpl for ClientRow { Signal::builder("request-position-change") .param_types([u32::static_type()]) .build(), + Signal::builder("request-clipboard-send-change") + .param_types([bool::static_type()]) + .build(), ] }) } @@ -222,6 +240,22 @@ impl ClientRow { self.dns_button.set_css_classes(&["warning"]) } } + + /// Push a server-originated `clipboard-send` value into the + /// switch without retriggering the user-change signal — same + /// block/unblock pattern as `set_active` for the activate + /// switch. + pub(super) fn set_clipboard_send(&self, value: bool) { + let handler = self.clipboard_send_handler.borrow(); + let handler = handler.as_ref().expect("signal handler"); + self.clipboard_send_switch.block_signal(handler); + self.client_object + .borrow_mut() + .as_mut() + .expect("client object") + .set_clipboard_send(value); + self.clipboard_send_switch.unblock_signal(handler); + } } impl WidgetImpl for ClientRow {} diff --git a/lan-mouse-gtk/src/key_object.rs b/lan-mouse-gtk/src/key_object.rs index 04282b6ff..d22a0e43f 100644 --- a/lan-mouse-gtk/src/key_object.rs +++ b/lan-mouse-gtk/src/key_object.rs @@ -17,6 +17,7 @@ impl KeyObject { .property("mouse-sensitivity", peer.mouse_sensitivity) .property("last-addr", peer.last_addr.unwrap_or_default()) .property("last-hostname", peer.last_hostname.unwrap_or_default()) + .property("clipboard-receive", peer.clipboard_receive) .build() } diff --git a/lan-mouse-gtk/src/key_object/imp.rs b/lan-mouse-gtk/src/key_object/imp.rs index 609b02616..b21c013e8 100644 --- a/lan-mouse-gtk/src/key_object/imp.rs +++ b/lan-mouse-gtk/src/key_object/imp.rs @@ -26,6 +26,11 @@ pub struct KeyObject { /// = no hostname resolution available. #[property(name = "last-hostname", get, set, type = String)] pub last_hostname: RefCell, + /// Whether this peer's clipboard text should be applied to + /// the local clipboard. Mirrors + /// [`lan_mouse_ipc::IncomingPeerConfig::clipboard_receive`]. + #[property(name = "clipboard-receive", get, set, type = bool)] + pub clipboard_receive: Cell, } impl Default for KeyObject { @@ -37,6 +42,7 @@ impl Default for KeyObject { mouse_sensitivity: Cell::new(1.0), last_addr: RefCell::new(String::new()), last_hostname: RefCell::new(String::new()), + clipboard_receive: Cell::new(false), } } } diff --git a/lan-mouse-gtk/src/key_row.rs b/lan-mouse-gtk/src/key_row.rs index 18a0b9db5..d036a4075 100644 --- a/lan-mouse-gtk/src/key_row.rs +++ b/lan-mouse-gtk/src/key_row.rs @@ -47,7 +47,12 @@ impl KeyRow { // user-change signals (no ping-pong on bind). self.refresh_natural_scroll_widget(key_object.natural_scroll()); self.refresh_sensitivity_widget(key_object.mouse_sensitivity()); - self.refresh_summary(key_object.natural_scroll(), key_object.mouse_sensitivity()); + self.refresh_clipboard_receive_widget(key_object.clipboard_receive()); + self.refresh_summary( + key_object.natural_scroll(), + key_object.mouse_sensitivity(), + key_object.clipboard_receive(), + ); self.refresh_identity_subtitle(&key_object.last_hostname(), &key_object.last_addr()); // Wire the copy-to-clipboard button. The handler reads the @@ -75,7 +80,11 @@ impl KeyRow { self, move |obj| { row.refresh_natural_scroll_widget(obj.natural_scroll()); - row.refresh_summary(obj.natural_scroll(), obj.mouse_sensitivity()); + row.refresh_summary( + obj.natural_scroll(), + obj.mouse_sensitivity(), + obj.clipboard_receive(), + ); } )); handlers.push((key_object.clone(), h)); @@ -85,7 +94,25 @@ impl KeyRow { self, move |obj| { row.refresh_sensitivity_widget(obj.mouse_sensitivity()); - row.refresh_summary(obj.natural_scroll(), obj.mouse_sensitivity()); + row.refresh_summary( + obj.natural_scroll(), + obj.mouse_sensitivity(), + obj.clipboard_receive(), + ); + } + )); + handlers.push((key_object.clone(), h)); + + let h = key_object.connect_clipboard_receive_notify(clone!( + #[weak(rename_to = row)] + self, + move |obj| { + row.refresh_clipboard_receive_widget(obj.clipboard_receive()); + row.refresh_summary( + obj.natural_scroll(), + obj.mouse_sensitivity(), + obj.clipboard_receive(), + ); } )); handlers.push((key_object.clone(), h)); @@ -145,6 +172,20 @@ impl KeyRow { } } + fn refresh_clipboard_receive_widget(&self, value: bool) { + let imp = self.imp(); + let switch = &imp.clipboard_receive_switch; + let handler = imp.clipboard_receive_handler.borrow(); + if let Some(id) = handler.as_ref() { + switch.block_signal(id); + } + switch.set_active(value); + switch.set_state(value); + if let Some(id) = handler.as_ref() { + switch.unblock_signal(id); + } + } + /// Compute the title-row subtitle from the peer's most recent /// connection identity. mDNS gives us a hostname most of the /// time, falling back to a bare IP, falling back to a "never @@ -165,11 +206,11 @@ impl KeyRow { } /// Update the title-row summary label so a collapsed row hints - /// at non-default settings. Hidden when both fields are at - /// defaults so a freshly-authorized peer's row is uncluttered. - fn refresh_summary(&self, natural_scroll: bool, sensitivity: f64) { + /// at non-default settings. Hidden when every field is at + /// default so a freshly-authorized peer's row is uncluttered. + fn refresh_summary(&self, natural_scroll: bool, sensitivity: f64, clipboard_receive: bool) { let label = &self.imp().settings_summary; - let parts = format_summary_parts(natural_scroll, sensitivity); + let parts = format_summary_parts(natural_scroll, sensitivity, clipboard_receive); if parts.is_empty() { label.set_visible(false); label.set_text(""); @@ -181,8 +222,13 @@ impl KeyRow { } /// Render the non-default settings as short tokens (e.g. `Natural`, -/// `1.5×`). Returns an empty Vec when both are at default. -fn format_summary_parts(natural_scroll: bool, sensitivity: f64) -> Vec { +/// `1.5×`, `Clipboard`). Returns an empty Vec when every field is +/// at default. +fn format_summary_parts( + natural_scroll: bool, + sensitivity: f64, + clipboard_receive: bool, +) -> Vec { let mut parts = Vec::new(); if natural_scroll { parts.push("Natural".to_owned()); @@ -190,6 +236,9 @@ fn format_summary_parts(natural_scroll: bool, sensitivity: f64) -> Vec { if (sensitivity - 1.0).abs() > 1e-6 { parts.push(format_sensitivity(sensitivity)); } + if clipboard_receive { + parts.push("Clipboard".to_owned()); + } parts } diff --git a/lan-mouse-gtk/src/key_row/imp.rs b/lan-mouse-gtk/src/key_row/imp.rs index 2a52ed39a..0264e87fd 100644 --- a/lan-mouse-gtk/src/key_row/imp.rs +++ b/lan-mouse-gtk/src/key_row/imp.rs @@ -21,6 +21,8 @@ pub struct KeyRow { #[template_child] pub sensitivity_spin: TemplateChild, #[template_child] + pub clipboard_receive_switch: TemplateChild, + #[template_child] pub settings_summary: TemplateChild