From cab63507234ef355872965fb107655a4eb4ca8e9 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 19 Jul 2026 18:24:46 +0200 Subject: [PATCH] feat(client): shared clipboard on Windows The protocol half has been in punktfunk-core since the clipboard work landed -- the per-session fetch task, plus clip_control/clip_offer/ clip_fetch/clip_serve/next_clip on NativeClient -- but only the Apple client ever drove it, through the C ABI. The Windows and Linux clients link the core directly and simply never wired it, so copy-paste between host and client worked on a Mac and nowhere else. This adds the missing half: the OS-clipboard bridge, on its own session thread beside the audio one. Local -> remote stays lazy by construction, as the design asks: a GetClipboardSequenceNumber poll spots a local copy and we announce only the FORMAT LIST; bytes are read when (and only when) the host actually pastes and sends a FetchRequest. Remote -> local is EAGER in this first cut, which is a deliberate deviation worth naming. macOS gets laziness free from NSPasteboardItemDataProvider; the Windows equivalent is delayed rendering, which needs a clipboard-owning window running its own message pump. So we fetch on the offer and place real bytes under a 4 MB cap -- text always crosses, a large image is skipped rather than pulled for a paste that may never come. Delayed rendering lifts the cap later. Echo suppression is the design's Windows rule verbatim (capture the sequence number right after our own SetClipboardData); without it every copy ping-pongs between the machines forever. Content marked ExcludeClipboardContentFromMonitorProcessing -- what password managers set -- is never announced and never served. Text and PNG for now. Apps that publish only CF_DIB need the conversion the host already has. Linux keeps a stub: the bridge itself is platform-neutral and will drive a data-control seam unchanged. Co-Authored-By: Claude Fable 5 --- crates/pf-client-core/Cargo.toml | 4 + crates/pf-client-core/src/clipboard.rs | 411 +++++++++++++++++++++++++ crates/pf-client-core/src/lib.rs | 5 + crates/pf-client-core/src/session.rs | 14 + 4 files changed, 434 insertions(+) create mode 100644 crates/pf-client-core/src/clipboard.rs diff --git a/crates/pf-client-core/Cargo.toml b/crates/pf-client-core/Cargo.toml index 8f43a0ac..0cb973f7 100644 --- a/crates/pf-client-core/Cargo.toml +++ b/crates/pf-client-core/Cargo.toml @@ -64,6 +64,10 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63 # IDXGIResource1::CreateSharedHandle takes an optional SECURITY_ATTRIBUTES — the # method itself is feature-gated behind this. "Win32_Security", + # The OS-clipboard bridge (clipboard.rs): Open/Get/SetClipboardData + the sequence + # number, and the GlobalAlloc block the clipboard takes ownership of. + "Win32_System_DataExchange", + "Win32_System_Memory", ] } [features] diff --git a/crates/pf-client-core/src/clipboard.rs b/crates/pf-client-core/src/clipboard.rs new file mode 100644 index 00000000..5bb80645 --- /dev/null +++ b/crates/pf-client-core/src/clipboard.rs @@ -0,0 +1,411 @@ +//! 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::default(); + // 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 just made for this stream. + state.last_seq = os::sequence_number(); + + 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, + } + poll_local(&client, &mut state); + } + // 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") + } +} diff --git a/crates/pf-client-core/src/lib.rs b/crates/pf-client-core/src/lib.rs index 32f7f511..5266331b 100644 --- a/crates/pf-client-core/src/lib.rs +++ b/crates/pf-client-core/src/lib.rs @@ -41,6 +41,11 @@ mod video_software; mod video_vaapi; #[cfg(any(target_os = "linux", windows))] mod video_vulkan; +// The OS-clipboard bridge for the shared clipboard (design/clipboard-and-file-transfer.md §5). +// Built everywhere the session client is; the platform seam inside is Windows-real, +// stub elsewhere. +#[cfg(any(target_os = "linux", windows))] +pub mod clipboard; // PyroWave decode — Linux + Windows (plan §4.5; the Apple Metal port is its own phase). // Windows joined once its client moved to the SAME spawned Vulkan session presenter as // Linux's: the decoder is plain Vulkan compute on the presenter's device (no fds, no diff --git a/crates/pf-client-core/src/session.rs b/crates/pf-client-core/src/session.rs index d91bcd8a..459d43c4 100644 --- a/crates/pf-client-core/src/session.rs +++ b/crates/pf-client-core/src/session.rs @@ -339,6 +339,17 @@ fn pump( // app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own // thread (one puller per plane), blocking on the audio queue like the Apple client. let audio_thread = spawn_audio(connector.clone(), stop.clone()); + // The shared clipboard (design/clipboard-and-file-transfer.md §5): its own thread, since + // `next_clip` blocks and the OS clipboard calls can wait on other apps. Returns straight + // away when the host has no clipboard capability, so spawning is unconditional. + let clipboard_thread = { + let c = connector.clone(); + let s = stop.clone(); + std::thread::Builder::new() + .name("pf-clipboard".into()) + .spawn(move || crate::clipboard::run(c, s)) + .ok() + }; let _mic = params .mic_enabled .then(|| { @@ -799,6 +810,9 @@ fn pump( if let Some(t) = audio_thread { let _ = t.join(); // exits within its 100 ms pull timeout once `stop` is set } + if let Some(t) = clipboard_thread { + let _ = t.join(); // exits within its next_clip wait once `stop` is set + } let _ = ev_tx.send_blocking(SessionEvent::Ended(end)); }