//! OS-clipboard bridge for the spawned session client (`design/clipboard-and-file-transfer.md` //! §5). The protocol half already exists in `punktfunk_core::clipboard` — the per-session task //! that runs fetch streams — and `NativeClient` exposes it as `clip_control` / `clip_offer` / //! `clip_fetch` / `clip_serve` / `next_clip`. What was missing on Windows is exactly what §5.2 //! writes in Swift for macOS: the code that talks to the actual pasteboard. This is that half. //! //! Shape (one thread, owned by the session pump): //! //! * **Local → remote** stays lazy by construction. A poll of `GetClipboardSequenceNumber` //! spots a local copy, we announce the FORMAT LIST (`clip_offer`) and nothing else; the //! bytes are read only if the host actually pastes and sends a `FetchRequest`. //! * **Remote → local** is EAGER in this first cut, and that is a deliberate deviation from //! §5.2's promise-based apply. macOS gets laziness free from `NSPasteboardItemDataProvider`; //! the Windows equivalent is delayed rendering (`SetClipboardData(fmt, NULL)` answered on //! `WM_RENDERFORMAT`), which needs a clipboard-owning window running its own message pump — //! a bigger piece than this. So we fetch on the offer and place real bytes, under //! [`EAGER_FETCH_CAP`] so a huge host-side copy can't pull megabytes nobody pastes. Text is //! tiny and always crosses; a large image simply isn't mirrored until delayed rendering lands. //! * **Echo suppression** is §3.4's Windows rule verbatim: record the clipboard sequence //! number right after our own `SetClipboardData` and ignore exactly that change, or every //! copy ping-pongs between the two machines forever. //! //! Secrets are respected: a clipboard carrying `ExcludeClipboardContentFromMonitorProcessing` //! (what password managers set) is never announced and never served — the Windows counterpart //! of §5.2's `org.nspasteboard.ConcealedType` skip. use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; use punktfunk_core::client::NativeClient; use punktfunk_core::clipboard::ClipEventCore; use punktfunk_core::quic::{ClipKind, CLIP_FILE_INDEX_NONE, HOST_CAP_CLIPBOARD}; /// Wire mime for UTF-8 text — the one format every peer must handle (§3.5). const MIME_TEXT: &str = "text/plain;charset=utf-8"; /// Wire mime for the image floor (§3.5). Read/written through the "PNG" registered clipboard /// format; apps that only publish `CF_DIB` are a follow-up (the conversion the host already /// has in `image_to_dib`). const MIME_PNG: &str = "image/png"; /// Ceiling on an EAGERLY fetched remote payload (see the module docs). Text never approaches /// it; it exists so a host-side copy of something enormous doesn't cross for a paste that may /// never happen. Lifted once delayed rendering makes the fetch lazy. const EAGER_FETCH_CAP: u64 = 4 << 20; /// How often the local clipboard is polled for changes. §3.2 asks for ≥ 100 ms between offers; /// 400 ms keeps a copy→focus→paste round trip comfortably ahead of the user. const POLL: Duration = Duration::from_millis(400); /// Drain-and-poll cadence: how long `next_clip` blocks before we re-check the clipboard and /// the stop flag. const EVENT_WAIT: Duration = Duration::from_millis(120); /// Run the clipboard bridge until `stop` is set or the session closes. Returns immediately /// (doing nothing) when the host didn't advertise `HOST_CAP_CLIPBOARD` — an older host, or one /// whose backend can't do it — so this is safe to spawn unconditionally. pub fn run(client: Arc, stop: Arc) { if client.host_caps() & HOST_CAP_CLIPBOARD == 0 { tracing::info!("host has no clipboard capability — shared clipboard off"); return; } // Opt-in per §3.1: nothing is announced or served until this crosses enabled. if let Err(e) = client.clip_control(true, 0) { tracing::warn!(error = %e, "clipboard: enable failed"); return; } tracing::info!("shared clipboard enabled"); let mut state = State { // Adopt the CURRENT sequence number without announcing: whatever is on the clipboard // from before the session started is the user's, not a copy they made for this stream. last_seq: os::sequence_number(), ..Default::default() }; let mut next_poll = Instant::now() + POLL; while !stop.load(Ordering::SeqCst) { // Inbound first — a pending FetchRequest is the host waiting on us. `NoFrame` is the // ordinary poll timeout (nothing pending); anything else means the connection is gone // and the session teardown is already on its way. match client.next_clip(EVENT_WAIT) { Ok(ev) => handle_event(&client, &mut state, ev), Err(punktfunk_core::error::PunktfunkError::NoFrame) => {} Err(_) => break, } // The local clipboard is polled on its OWN cadence, not once per inbound wait: the // event wait is short (it bounds teardown latency), and hammering the Win32 clipboard // eight times a second would contend with whatever app the user is actually copying in. let now = Instant::now(); if now >= next_poll { poll_local(&client, &mut state); next_poll = now + POLL; } } // Best-effort: tell the host to stop announcing into a session that's ending. let _ = client.clip_control(false, 0); } #[derive(Default)] struct State { /// Clipboard sequence number as of our last look — a change means someone copied. last_seq: u32, /// The sequence number our OWN `SetClipboardData` produced (§3.4 echo suppression). self_written_seq: Option, /// Monotonic offer counter (§3.2 — newest wins). offer_seq: u32, /// Rate-limit guard for offers (§3.2 asks ≥ 100 ms). last_offer: Option, /// The host's current offer, so a fetch can name its `seq`. remote_offer: Option, /// In-flight eager fetch → the mime it will deliver, so `Data` knows how to place it. pending_fetch: Option<(u32, String)>, /// The last payload we placed locally, kept only to answer a host fetch of our own echo /// without re-reading the OS clipboard. last_applied: Option<(String, Vec)>, } /// A local clipboard change → announce the format list (never the bytes). fn poll_local(client: &NativeClient, state: &mut State) { let seq = os::sequence_number(); if seq == state.last_seq { return; } state.last_seq = seq; // Our own apply — swallow it, or the two clipboards chase each other forever. if state.self_written_seq == Some(seq) { state.self_written_seq = None; return; } if let Some(t) = state.last_offer { if t.elapsed() < Duration::from_millis(100) { return; } } if os::is_concealed() { tracing::debug!("clipboard: concealed content — not announced"); return; } let mut kinds: Vec = Vec::new(); for (mime, size) in os::available_kinds() { kinds.push(ClipKind { mime: mime.to_string(), size_hint: size, }); } if kinds.is_empty() { return; } state.offer_seq = state.offer_seq.wrapping_add(1); state.last_offer = Some(Instant::now()); let seq_id = state.offer_seq; tracing::debug!( seq = seq_id, kinds = kinds.len(), "clipboard: offering local copy" ); if let Err(e) = client.clip_offer(seq_id, kinds) { tracing::warn!(error = %e, "clipboard: offer failed"); } } fn handle_event(client: &NativeClient, state: &mut State, ev: ClipEventCore) { match ev { ClipEventCore::State { enabled, policy, reason, } => { tracing::info!(enabled, policy, reason, "clipboard: host state"); } // The host copied. Pull the best format we can place (see the module docs on why this // is eager for now) — text preferred, then PNG. ClipEventCore::RemoteOffer { seq, kinds } => { state.remote_offer = Some(seq); let pick = kinds .iter() .find(|k| k.mime == MIME_TEXT) .or_else(|| kinds.iter().find(|k| k.mime == MIME_PNG)); let Some(kind) = pick else { tracing::debug!("clipboard: remote offer has no format we can place"); return; }; if kind.size_hint > EAGER_FETCH_CAP { tracing::info!( mime = %kind.mime, size = kind.size_hint, "clipboard: remote payload over the eager-fetch cap — not mirrored" ); return; } match client.clip_fetch(seq, kind.mime.clone(), CLIP_FILE_INDEX_NONE) { Ok(xfer) => state.pending_fetch = Some((xfer, kind.mime.clone())), Err(e) => tracing::warn!(error = %e, "clipboard: fetch failed to start"), } } // Bytes for the fetch above — place them, then record the sequence number they cause. ClipEventCore::Data { xfer_id, bytes, last, } => { let Some((pending, mime)) = state.pending_fetch.clone() else { return; }; if pending != xfer_id { return; } if last { state.pending_fetch = None; } match os::set(&mime, &bytes) { Ok(()) => { // §3.4: this is the change WE caused; ignore exactly it. state.self_written_seq = Some(os::sequence_number()); state.last_applied = Some((mime.clone(), bytes)); tracing::debug!(mime = %mime, "clipboard: applied remote content"); } Err(e) => tracing::warn!(error = %e, mime = %mime, "clipboard: apply failed"), } } // The host is pasting what we offered: read the bytes NOW (this is the lazy half) and // answer. A read failure still answers — with a cancel — so the host isn't left waiting. ClipEventCore::FetchRequest { req_id, seq: _, file_index: _, mime, } => { if os::is_concealed() { let _ = client.clip_cancel(req_id); return; } // Serve our own last-applied payload verbatim when it still matches — avoids a // lossy OS round trip for content that originated on the host anyway. let bytes = match &state.last_applied { Some((m, b)) if *m == mime && state.self_written_seq.is_some() => Ok(b.clone()), _ => os::get(&mime), }; match bytes { Ok(b) => { tracing::debug!(mime = %mime, len = b.len(), "clipboard: serving to host"); if let Err(e) = client.clip_serve(req_id, b, true) { tracing::warn!(error = %e, "clipboard: serve failed"); } } Err(e) => { tracing::debug!(error = %e, mime = %mime, "clipboard: nothing to serve"); let _ = client.clip_cancel(req_id); } } } ClipEventCore::Cancelled { id } => tracing::debug!(id, "clipboard: transfer cancelled"), ClipEventCore::Error { id, code } => { tracing::debug!(id, code, "clipboard: transfer error"); if state.pending_fetch.as_ref().is_some_and(|(x, _)| *x == id) { state.pending_fetch = None; } } } } #[cfg(windows)] mod os { //! The Win32 clipboard seam. Every entry point opens the clipboard, does one thing and //! closes it — holding it across a network fetch would block every other app on the box. use super::{MIME_PNG, MIME_TEXT}; use anyhow::{anyhow, bail, Result}; use windows::core::PCWSTR; use windows::Win32::Foundation::{HANDLE, HGLOBAL}; use windows::Win32::System::DataExchange::{ CloseClipboard, EmptyClipboard, GetClipboardData, GetClipboardSequenceNumber, IsClipboardFormatAvailable, OpenClipboard, RegisterClipboardFormatW, SetClipboardData, }; use windows::Win32::System::Memory::{ GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, GMEM_MOVEABLE, }; const CF_UNICODETEXT: u32 = 13; /// A registered clipboard format id, by name (`PNG`, the concealed marker, …). fn registered(name: &str) -> u32 { let wide: Vec = name.encode_utf16().chain(std::iter::once(0)).collect(); unsafe { RegisterClipboardFormatW(PCWSTR(wide.as_ptr())) } } fn png_format() -> u32 { registered("PNG") } /// RAII clipboard open — `CloseClipboard` must run even on the error paths. struct Clip; impl Clip { fn open() -> Result { // A retry loop: another app can hold the clipboard for a moment. for _ in 0..10 { if unsafe { OpenClipboard(None) }.is_ok() { return Ok(Clip); } std::thread::sleep(std::time::Duration::from_millis(10)); } bail!("clipboard busy") } } impl Drop for Clip { fn drop(&mut self) { let _ = unsafe { CloseClipboard() }; } } pub fn sequence_number() -> u32 { unsafe { GetClipboardSequenceNumber() } } /// Password managers mark secrets with this format; §5.2's concealed-type rule. pub fn is_concealed() -> bool { let fmt = registered("ExcludeClipboardContentFromMonitorProcessing"); unsafe { IsClipboardFormatAvailable(fmt) }.is_ok() } /// The wire kinds the current clipboard can supply, with size hints where they're free. pub fn available_kinds() -> Vec<(&'static str, u64)> { let mut out = Vec::new(); if unsafe { IsClipboardFormatAvailable(CF_UNICODETEXT) }.is_ok() { out.push((MIME_TEXT, 0)); } if unsafe { IsClipboardFormatAvailable(png_format()) }.is_ok() { out.push((MIME_PNG, 0)); } out } /// Read one wire format off the clipboard. pub fn get(mime: &str) -> Result> { let _clip = Clip::open()?; match mime { MIME_TEXT => { let h = unsafe { GetClipboardData(CF_UNICODETEXT) }?; let g = HGLOBAL(h.0); let p = unsafe { GlobalLock(g) } as *const u16; if p.is_null() { bail!("clipboard text lock failed"); } // GlobalSize is a byte count of a NUL-terminated UTF-16 buffer. let bytes = unsafe { GlobalSize(g) }; let mut len = bytes / 2; let slice = unsafe { std::slice::from_raw_parts(p, len) }; if let Some(nul) = slice.iter().position(|&c| c == 0) { len = nul; } let text = String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(p, len) }); let _ = unsafe { GlobalUnlock(g) }; Ok(text.into_bytes()) } MIME_PNG => { let h = unsafe { GetClipboardData(png_format()) }?; let g = HGLOBAL(h.0); let p = unsafe { GlobalLock(g) } as *const u8; if p.is_null() { bail!("clipboard png lock failed"); } let len = unsafe { GlobalSize(g) }; let out = unsafe { std::slice::from_raw_parts(p, len) }.to_vec(); let _ = unsafe { GlobalUnlock(g) }; Ok(out) } other => Err(anyhow!("unsupported clipboard format {other}")), } } /// Place one wire format on the clipboard, replacing its contents. pub fn set(mime: &str, bytes: &[u8]) -> Result<()> { let (fmt, payload) = match mime { MIME_TEXT => { let text = String::from_utf8_lossy(bytes); let wide: Vec = text.encode_utf16().chain(std::iter::once(0)).collect(); let raw: Vec = wide.iter().flat_map(|c| c.to_le_bytes()).collect(); (CF_UNICODETEXT, raw) } MIME_PNG => (png_format(), bytes.to_vec()), other => bail!("unsupported clipboard format {other}"), }; let _clip = Clip::open()?; unsafe { EmptyClipboard() }?; // The clipboard OWNS this block once SetClipboardData succeeds — do not free it. let g = unsafe { GlobalAlloc(GMEM_MOVEABLE, payload.len()) }?; let p = unsafe { GlobalLock(g) } as *mut u8; if p.is_null() { bail!("clipboard alloc lock failed"); } unsafe { std::ptr::copy_nonoverlapping(payload.as_ptr(), p, payload.len()) }; let _ = unsafe { GlobalUnlock(g) }; unsafe { SetClipboardData(fmt, Some(HANDLE(g.0))) }?; Ok(()) } } #[cfg(not(windows))] mod os { //! Non-Windows stub. Linux needs the Wayland `data-control` seam (the same protocol the //! host side already speaks) — the bridge above is platform-neutral and will drive it //! unchanged once this module grows a real implementation. use anyhow::{bail, Result}; pub fn sequence_number() -> u32 { 0 } pub fn is_concealed() -> bool { false } pub fn available_kinds() -> Vec<(&'static str, u64)> { Vec::new() } pub fn get(_mime: &str) -> Result> { bail!("clipboard unsupported on this platform") } pub fn set(_mime: &str, _bytes: &[u8]) -> Result<()> { bail!("clipboard unsupported on this platform") } }