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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user