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 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 18:24:46 +02:00
parent a042dc2850
commit cab6350723
4 changed files with 434 additions and 0 deletions
+4
View File
@@ -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]
+411
View File
@@ -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<NativeClient>, stop: Arc<AtomicBool>) {
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<u32>,
/// Monotonic offer counter (§3.2 — newest wins).
offer_seq: u32,
/// Rate-limit guard for offers (§3.2 asks ≥ 100 ms).
last_offer: Option<Instant>,
/// The host's current offer, so a fetch can name its `seq`.
remote_offer: Option<u32>,
/// 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<u8>)>,
}
/// 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<ClipKind> = 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<u16> = 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<Clip> {
// 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<Vec<u8>> {
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<u16> = text.encode_utf16().chain(std::iter::once(0)).collect();
let raw: Vec<u8> = 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<Vec<u8>> {
bail!("clipboard unsupported on this platform")
}
pub fn set(_mime: &str, _bytes: &[u8]) -> Result<()> {
bail!("clipboard unsupported on this platform")
}
}
+5
View File
@@ -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
+14
View File
@@ -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));
}