merge(core): reconcile the W7/W8 client refactor with origin's shared-clipboard feature
origin/main landed the shared clipboard (design/clipboard-and-file-transfer.md) while
this branch split quic/msgs.rs -> quic/{caps,control,...} and client.rs ->
client/{mod,control,worker,pump,planes,...} (W7) and deleted the two monoliths. The
feature had modified both deleted files, so its delta is re-applied onto the split
instead of resurrecting the monoliths:
- HOST_CAP_CLIPBOARD -> quic/caps.rs
- MSG_CLIP_* / CLIP_* consts, the six Clip*
structs, and their encode/decode impls -> quic/control.rs (beside the clock codecs)
- CtrlRequest::{ClipControl,ClipOffer} +
Negotiated.host_caps -> client/control.rs
- WorkerArgs.{clip_event_tx,clip_cmd_rx} -> client/worker.rs
- CLIP_EVENT_QUEUE -> client/planes.rs
- NativeClient clip fields, the 7 clip_* /
host_caps / next_clip methods, connect()
channel wiring -> client/mod.rs
- the control-task encode/decode arms and
the clipboard-task spawn -> client/pump.rs
Cargo.lock reconciled (adds pf-clipboard), punktfunk-host/Cargo.toml unions the W6
pf-* subsystem deps with pf-clipboard, and include/punktfunk_core.h is the cbindgen
union (clipboard + rumble C-ABI). punktfunk-core builds --all-features and its 174
lib tests pass, including quic::tests::clip_loopback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
# Shared clipboard (plan §W6 shape, design/clipboard-and-file-transfer.md §4): the host-side
|
||||
# session-clipboard backends — `ext-data-control-v1` (KWin/wlroots/Sway/Hyprland) and Mutter's
|
||||
# *direct* `org.gnome.Mutter.RemoteDesktop.Session` clipboard on Linux, the Win32 clipboard
|
||||
# (delayed rendering) on Windows — behind one `HostClipboard`, plus the backend-agnostic session
|
||||
# coordinator bridging it to the QUIC clipboard plane. The wire protocol and the client half live
|
||||
# in `punktfunk-core`; the orchestrator consumes only the portable facade (policy / `ClipCoordCmd` /
|
||||
# `start`), so it stays free of platform cfg.
|
||||
[package]
|
||||
name = "pf-clipboard"
|
||||
version = "0.12.0"
|
||||
edition = "2021"
|
||||
rust-version.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "punktfunk host shared clipboard: per-OS session-clipboard backends behind one HostClipboard + the QUIC clipboard-plane coordinator."
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
quinn = "0.11"
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Mutter's direct RemoteDesktop clipboard is raw D-Bus via `ashpd::zbus` — NOT the xdg
|
||||
# `org.freedesktop.portal.Clipboard`, which needs an interactive grant a headless host can't
|
||||
# answer. Reusing ashpd's zbus re-export keeps one zbus version across the workspace.
|
||||
ashpd = "0.13"
|
||||
futures-util = "0.3"
|
||||
# Raw fd plumbing on the paste pipes: `pipe2(O_CLOEXEC)` + `poll` on the data-control receive
|
||||
# side, `fcntl` un-nonblocking on Mutter's transfer fd.
|
||||
libc = "0.2"
|
||||
wayland-client = "0.31"
|
||||
# `staging`: `ext_data_control_v1` (the session-clipboard protocol) ships in the staging set.
|
||||
wayland-protocols = { version = "0.32", features = ["client", "staging"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# The Win32 clipboard on a hidden message-loop window: `WM_CLIPBOARDUPDATE` listener + OLE
|
||||
# delayed rendering (`WM_RENDERFORMAT`) for text / CF_HTML / RTF / PNG.
|
||||
windows = { version = "0.62", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_DataExchange",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Ole",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
@@ -0,0 +1,409 @@
|
||||
//! Host-side shared-clipboard backend.
|
||||
//!
|
||||
//! The wire protocol and the client half live in `punktfunk-core`
|
||||
//! (`punktfunk_core::quic` + `punktfunk_core::clipboard`); this module drives the **host's** real
|
||||
//! session clipboard so it can offer what a host app copied and paste what the remote client
|
||||
//! offered (`design/clipboard-and-file-transfer.md` §4).
|
||||
//!
|
||||
//! Concrete backends, selected at session start ([`HostClipboard::open`]) and presented as one
|
||||
//! [`HostClipboard`] to the [`session`] coordinator:
|
||||
//! * [`wayland`] (Linux) — `ext-data-control-v1` (KWin, wlroots / Sway, Hyprland). Preferred when present.
|
||||
//! * [`mutter`] (Linux) — GNOME. Mutter implements **no** wlr/ext data-control, but its *direct*
|
||||
//! `org.gnome.Mutter.RemoteDesktop.Session` D-Bus API carries the same clipboard operations (the
|
||||
//! xdg `org.freedesktop.portal.Clipboard` would need an interactive grant a headless host can't
|
||||
//! answer — so we skip it and talk to Mutter directly, as the input injector already does).
|
||||
//! * [`windows`] — the Win32 clipboard: a hidden message-only window watches `WM_CLIPBOARDUPDATE`
|
||||
//! and serves client content via OLE delayed rendering (`WM_RENDERFORMAT`).
|
||||
//!
|
||||
//! The `zwlr-data-control-unstable-v1` fallback (older wlroots/KWin) is a follow-up. The module
|
||||
//! compiles on Linux and Windows; the [`session`] coordinator is backend-agnostic.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod mutter;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod wayland;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows;
|
||||
/// Pure Win32-clipboard ↔ wire byte conversions (CF_HTML offset math, UTF-16 text, RTF NUL
|
||||
/// trimming). Free of any Win32 dependency, so it compiles — and its unit tests run — on any host
|
||||
/// (`cfg(test)`); the Windows backend is the only production consumer.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod winfmt;
|
||||
|
||||
pub mod session;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::io::Write as _;
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A clipboard event surfaced by a host backend to the [`session`] coordinator. Both the
|
||||
/// data-control and Mutter backends emit this identical shape.
|
||||
pub enum ClipEvent {
|
||||
/// The host selection changed (a host app copied). `mimes` are the **wire** MIMEs offered (empty
|
||||
/// = the clipboard was cleared). The coordinator forwards these as a `ClipOffer` to the client;
|
||||
/// bytes cross only if the client later fetches.
|
||||
Selection { mimes: Vec<String> },
|
||||
/// A host app is pasting content the client offered. The coordinator fetches the wire-`mime`
|
||||
/// bytes from the client and hands them to `responder`.
|
||||
Paste {
|
||||
mime: String,
|
||||
responder: PasteResponder,
|
||||
},
|
||||
/// The backend ended (compositor / session gone).
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// How a backend receives the bytes answering a [`ClipEvent::Paste`]. The two host clipboard
|
||||
/// mechanisms complete a paste differently, so the coordinator stays agnostic by handing bytes to
|
||||
/// whichever responder the backend attached.
|
||||
pub enum PasteResponder {
|
||||
/// data-control: the compositor handed us the destination pipe on the `send` event — write the
|
||||
/// bytes and close it (EOF completes the paste).
|
||||
#[cfg(target_os = "linux")]
|
||||
Fd(OwnedFd),
|
||||
/// Mutter: hand the bytes back to the backend actor, which owns the `SelectionWrite` fd and the
|
||||
/// trailing `SelectionWriteDone` call that Mutter's transfer requires.
|
||||
#[cfg(target_os = "linux")]
|
||||
Channel(tokio::sync::oneshot::Sender<Vec<u8>>),
|
||||
/// Windows: hand the bytes to the `WM_RENDERFORMAT` handler blocking the clipboard message-loop
|
||||
/// thread, which then `SetClipboardData`s them for the pasting app (`std::sync::mpsc`, since that
|
||||
/// thread waits synchronously — see [`windows`]).
|
||||
#[cfg(target_os = "windows")]
|
||||
Sync(std::sync::mpsc::Sender<Vec<u8>>),
|
||||
}
|
||||
|
||||
impl PasteResponder {
|
||||
/// Deliver the fetched bytes (empty on a failed fetch → an empty paste, never a hang).
|
||||
pub async fn respond(self, bytes: Vec<u8>) {
|
||||
match self {
|
||||
#[cfg(target_os = "linux")]
|
||||
PasteResponder::Fd(fd) => {
|
||||
let _ = tokio::task::spawn_blocking(move || fulfill_paste(fd, &bytes)).await;
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
PasteResponder::Channel(tx) => {
|
||||
let _ = tx.send(bytes);
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
PasteResponder::Sync(tx) => {
|
||||
let _ = tx.send(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `bytes` into a paste pipe `fd` and close it (EOF signals the reader). Blocking — run off the
|
||||
/// reactor for large payloads.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn fulfill_paste(fd: OwnedFd, bytes: &[u8]) -> std::io::Result<()> {
|
||||
let mut file = std::fs::File::from(fd);
|
||||
file.write_all(bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The active host clipboard backend, chosen per session: `ext-data-control`
|
||||
/// (KWin/wlroots/Hyprland/Sway) or Mutter's direct RemoteDesktop clipboard (GNOME) on Linux, or the
|
||||
/// Win32 clipboard on Windows. Presented as one type so the [`session`] coordinator is
|
||||
/// backend-agnostic.
|
||||
pub enum HostClipboard {
|
||||
#[cfg(target_os = "linux")]
|
||||
DataControl(wayland::ClipboardBackend),
|
||||
#[cfg(target_os = "linux")]
|
||||
Mutter(mutter::MutterClipboard),
|
||||
#[cfg(target_os = "windows")]
|
||||
Windows(windows::WindowsClipboard),
|
||||
}
|
||||
|
||||
impl HostClipboard {
|
||||
/// Open whichever backend this session supports. Linux tries data-control first
|
||||
/// (KWin/wlroots/Hyprland/Sway) then Mutter's direct clipboard (GNOME); Windows opens the Win32
|
||||
/// clipboard. Errors when none is available (gamescope, no live compositor) — the caller then
|
||||
/// reports `BACKEND_UNAVAILABLE`.
|
||||
pub async fn open() -> anyhow::Result<(
|
||||
HostClipboard,
|
||||
tokio::sync::mpsc::UnboundedReceiver<ClipEvent>,
|
||||
)> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// data-control's bind does blocking Wayland roundtrips — keep them off the reactor.
|
||||
let dc = tokio::task::spawn_blocking(wayland::ClipboardBackend::open)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("data-control open join: {e}"))?;
|
||||
match dc {
|
||||
Ok((b, rx)) => return Ok((HostClipboard::DataControl(b), rx)),
|
||||
Err(e) => tracing::debug!(
|
||||
error = format!("{e:#}"),
|
||||
"no ext-data-control — trying Mutter direct clipboard"
|
||||
),
|
||||
}
|
||||
let (m, rx) = mutter::MutterClipboard::open().await.map_err(|e| {
|
||||
e.context("no clipboard backend (neither ext-data-control nor Mutter)")
|
||||
})?;
|
||||
Ok((HostClipboard::Mutter(m), rx))
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let (b, rx) = windows::WindowsClipboard::open().await?;
|
||||
Ok((HostClipboard::Windows(b), rx))
|
||||
}
|
||||
}
|
||||
|
||||
/// The current host selection's wire MIMEs (empty = nothing to offer).
|
||||
pub fn current_wire_mimes(&self) -> Vec<String> {
|
||||
match self {
|
||||
#[cfg(target_os = "linux")]
|
||||
HostClipboard::DataControl(b) => b.current_wire_mimes(),
|
||||
#[cfg(target_os = "linux")]
|
||||
HostClipboard::Mutter(m) => m.current_wire_mimes(),
|
||||
#[cfg(target_os = "windows")]
|
||||
HostClipboard::Windows(w) => w.current_wire_mimes(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Install a client's offered formats as the host selection.
|
||||
pub fn set_offer(&self, wire_mimes: &[String]) -> anyhow::Result<()> {
|
||||
match self {
|
||||
#[cfg(target_os = "linux")]
|
||||
HostClipboard::DataControl(b) => b.set_offer(wire_mimes),
|
||||
#[cfg(target_os = "linux")]
|
||||
HostClipboard::Mutter(m) => {
|
||||
m.set_offer(wire_mimes);
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
HostClipboard::Windows(w) => {
|
||||
w.set_offer(wire_mimes);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the host selection we own.
|
||||
pub fn clear_offer(&self) -> anyhow::Result<()> {
|
||||
match self {
|
||||
#[cfg(target_os = "linux")]
|
||||
HostClipboard::DataControl(b) => b.clear_offer(),
|
||||
#[cfg(target_os = "linux")]
|
||||
HostClipboard::Mutter(m) => {
|
||||
m.clear_offer();
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
HostClipboard::Windows(w) => {
|
||||
w.clear_offer();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one wire format of the current host selection (a client's fetch). Async: data-control
|
||||
/// blocks on a pipe (offloaded), Mutter round-trips D-Bus + reads a pipe, Windows reads the
|
||||
/// clipboard on a blocking thread.
|
||||
pub async fn read_current(self: &Arc<Self>, wire_mime: &str) -> anyhow::Result<Vec<u8>> {
|
||||
match &**self {
|
||||
#[cfg(target_os = "linux")]
|
||||
HostClipboard::DataControl(_) => {
|
||||
let me = Arc::clone(self);
|
||||
let wire = wire_mime.to_string();
|
||||
tokio::task::spawn_blocking(move || match &*me {
|
||||
HostClipboard::DataControl(b) => b.read_current(&wire),
|
||||
_ => unreachable!("variant checked above"),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("data-control read join: {e}"))?
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
HostClipboard::Mutter(m) => m.read_current(wire_mime).await,
|
||||
#[cfg(target_os = "windows")]
|
||||
HostClipboard::Windows(w) => w.read_current(wire_mime).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Format normalization (design/clipboard-and-file-transfer.md §3.5) ------------------------
|
||||
//
|
||||
// One portable vocabulary crosses the wire; each end maps to platform types at fetch time. Phase 1
|
||||
// covers text / RTF / HTML / PNG (files are Phase 2). The wire MIMEs match the core's table.
|
||||
|
||||
/// Wire MIME for UTF-8 plain text.
|
||||
pub const WIRE_TEXT: &str = "text/plain;charset=utf-8";
|
||||
/// Wire MIME for HTML.
|
||||
pub const WIRE_HTML: &str = "text/html";
|
||||
/// Wire MIME for rich text.
|
||||
pub const WIRE_RTF: &str = "text/rtf";
|
||||
/// Wire MIME for a PNG image.
|
||||
pub const WIRE_PNG: &str = "image/png";
|
||||
|
||||
/// Map a Wayland selection MIME to its canonical wire MIME, or `None` to drop it (internal targets
|
||||
/// like `TARGETS`/`TIMESTAMP`/`SAVE_TARGETS`, and formats we don't sync in Phase 1). Aliases
|
||||
/// collapse onto one canonical wire name so the offered list dedups cleanly.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn wayland_to_wire(wl: &str) -> Option<&'static str> {
|
||||
// Strip any parameter noise for the plain-text aliases (some apps send `text/plain;charset=...`
|
||||
// with odd charsets, or bare `text/plain`).
|
||||
let base = wl.split(';').next().unwrap_or(wl).trim();
|
||||
match wl {
|
||||
"text/html" => Some(WIRE_HTML),
|
||||
"text/rtf" | "application/rtf" | "text/richtext" => Some(WIRE_RTF),
|
||||
"image/png" => Some(WIRE_PNG),
|
||||
_ => match base {
|
||||
"text/plain" | "UTF8_STRING" | "STRING" | "TEXT" => Some(WIRE_TEXT),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The Wayland MIME candidates to request, in preference order, when a client fetches `wire` from
|
||||
/// the host clipboard. The first one present in the current offer is used.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn wayland_candidates(wire: &str) -> &'static [&'static str] {
|
||||
match wire {
|
||||
WIRE_TEXT => &[
|
||||
"text/plain;charset=utf-8",
|
||||
"text/plain",
|
||||
"UTF8_STRING",
|
||||
"STRING",
|
||||
"TEXT",
|
||||
],
|
||||
WIRE_HTML => &["text/html"],
|
||||
WIRE_RTF => &["text/rtf", "application/rtf", "text/richtext"],
|
||||
WIRE_PNG => &["image/png"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the Wayland MIME to `receive()` for a wire fetch: the first [`wayland_candidates`] entry the
|
||||
/// current selection actually advertises.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn pick_wayland_mime(wire: &str, available: &[String]) -> Option<String> {
|
||||
wayland_candidates(wire)
|
||||
.iter()
|
||||
.find(|c| available.iter().any(|a| a == *c))
|
||||
.map(|c| c.to_string())
|
||||
}
|
||||
|
||||
/// Normalize a raw Wayland offer's MIME list into the deduplicated wire MIME list announced to the
|
||||
/// client (drops internal targets; collapses aliases; preserves a stable order).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn offer_wire_mimes(raw: &[String]) -> Vec<&'static str> {
|
||||
let mut out: Vec<&'static str> = Vec::new();
|
||||
for m in raw {
|
||||
if let Some(wire) = wayland_to_wire(m) {
|
||||
if !out.contains(&wire) {
|
||||
out.push(wire);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The Wayland MIMEs to advertise when installing a source for a client's offer. Each wire MIME
|
||||
/// expands to its canonical Wayland name(s); a rich-text-only offer also advertises `text/plain`
|
||||
/// so plain-text targets always paste (§3.5 synthesis — destination-side, one direction only).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn wayland_offers_for(wire_mimes: &[String]) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut push = |s: &str| {
|
||||
if !out.iter().any(|o| o == s) {
|
||||
out.push(s.to_string());
|
||||
}
|
||||
};
|
||||
let mut has_plain = false;
|
||||
let mut has_rich = false;
|
||||
for w in wire_mimes {
|
||||
match w.as_str() {
|
||||
WIRE_TEXT => {
|
||||
has_plain = true;
|
||||
push("text/plain;charset=utf-8");
|
||||
push("text/plain");
|
||||
push("UTF8_STRING");
|
||||
push("STRING");
|
||||
}
|
||||
WIRE_HTML => {
|
||||
has_rich = true;
|
||||
push("text/html");
|
||||
}
|
||||
WIRE_RTF => {
|
||||
has_rich = true;
|
||||
push("text/rtf");
|
||||
}
|
||||
WIRE_PNG => push("image/png"),
|
||||
other => push(other),
|
||||
}
|
||||
}
|
||||
// Synthesis: rich text without plain text → also advertise plain (the source derives it lazily).
|
||||
if has_rich && !has_plain {
|
||||
push("text/plain;charset=utf-8");
|
||||
push("text/plain");
|
||||
push("UTF8_STRING");
|
||||
push("STRING");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wayland_to_wire_canonicalizes_and_drops_targets() {
|
||||
assert_eq!(wayland_to_wire("text/plain"), Some(WIRE_TEXT));
|
||||
assert_eq!(wayland_to_wire("UTF8_STRING"), Some(WIRE_TEXT));
|
||||
assert_eq!(wayland_to_wire("text/plain;charset=utf-8"), Some(WIRE_TEXT));
|
||||
assert_eq!(wayland_to_wire("text/html"), Some(WIRE_HTML));
|
||||
assert_eq!(wayland_to_wire("application/rtf"), Some(WIRE_RTF));
|
||||
assert_eq!(wayland_to_wire("image/png"), Some(WIRE_PNG));
|
||||
// Internal targets and unsupported formats are dropped.
|
||||
assert_eq!(wayland_to_wire("TARGETS"), None);
|
||||
assert_eq!(wayland_to_wire("TIMESTAMP"), None);
|
||||
assert_eq!(wayland_to_wire("image/jpeg"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offer_wire_mimes_dedups_aliases() {
|
||||
let raw = vec![
|
||||
"TARGETS".to_string(),
|
||||
"UTF8_STRING".to_string(),
|
||||
"text/plain;charset=utf-8".to_string(),
|
||||
"text/plain".to_string(),
|
||||
"text/html".to_string(),
|
||||
];
|
||||
// text aliases collapse to one WIRE_TEXT; TARGETS dropped; html kept.
|
||||
assert_eq!(offer_wire_mimes(&raw), vec![WIRE_TEXT, WIRE_HTML]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_wayland_mime_prefers_canonical() {
|
||||
let avail = vec!["text/plain".to_string(), "UTF8_STRING".to_string()];
|
||||
// Canonical charset form isn't present, so it falls to the next candidate.
|
||||
assert_eq!(
|
||||
pick_wayland_mime(WIRE_TEXT, &avail),
|
||||
Some("text/plain".to_string())
|
||||
);
|
||||
let avail2 = vec![
|
||||
"text/plain;charset=utf-8".to_string(),
|
||||
"text/plain".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
pick_wayland_mime(WIRE_TEXT, &avail2),
|
||||
Some("text/plain;charset=utf-8".to_string())
|
||||
);
|
||||
assert_eq!(pick_wayland_mime(WIRE_PNG, &avail2), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wayland_offers_synthesizes_plain_for_rich_only() {
|
||||
let offers = wayland_offers_for(&[WIRE_HTML.to_string()]);
|
||||
assert!(offers.iter().any(|m| m == "text/html"));
|
||||
assert!(
|
||||
offers.iter().any(|m| m == "text/plain;charset=utf-8"),
|
||||
"rich-only offer must synthesize plain text: {offers:?}"
|
||||
);
|
||||
// Plain already present → no duplicate synthesis, and text aliases included.
|
||||
let offers2 = wayland_offers_for(&[WIRE_TEXT.to_string()]);
|
||||
assert!(offers2.iter().any(|m| m == "UTF8_STRING"));
|
||||
assert_eq!(offers2.iter().filter(|m| *m == "text/plain").count(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
//! GNOME clipboard backend via Mutter's **direct** `org.gnome.Mutter.RemoteDesktop.Session` D-Bus
|
||||
//! API (`design/clipboard-and-file-transfer.md` §4.1).
|
||||
//!
|
||||
//! Mutter implements no wlr/ext `data-control` (a deliberate privacy stance), so [`super::wayland`]
|
||||
//! can't bind on GNOME. But Mutter's own RemoteDesktop session — the same interface the input
|
||||
//! injector drives directly to dodge the xdg-portal approval dialog (`inject/linux/libei.rs`
|
||||
//! `connect_mutter`) — carries the full clipboard surface: `EnableClipboard`, `SetSelection`,
|
||||
//! `SelectionRead`/`SelectionWrite`/`SelectionWriteDone`, and the `SelectionOwnerChanged` /
|
||||
//! `SelectionTransfer` signals. We open our **own** standalone session for it (it coexists with the
|
||||
//! injector's input session; validated on GNOME 50), so this backend is self-contained just like the
|
||||
//! data-control one.
|
||||
//!
|
||||
//! One actor task owns the zbus connection + session and multiplexes the two signals with a command
|
||||
//! channel; the fds Mutter hands out are **non-blocking**, so reads/writes flip them to blocking and
|
||||
//! run on a blocking thread. Option/signal dict keys are hyphenated: `mime-types`, `session-is-owner`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read as _, Write as _};
|
||||
use std::os::fd::{AsRawFd, OwnedFd};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use ashpd::zbus::{
|
||||
self,
|
||||
zvariant::{OwnedObjectPath, OwnedValue, Value},
|
||||
};
|
||||
use futures_util::StreamExt;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
use super::{ClipEvent, PasteResponder};
|
||||
|
||||
const RD_BUS: &str = "org.gnome.Mutter.RemoteDesktop";
|
||||
const RD_PATH: &str = "/org/gnome/Mutter/RemoteDesktop";
|
||||
const RD_IFACE: &str = "org.gnome.Mutter.RemoteDesktop";
|
||||
const SESSION_IFACE: &str = "org.gnome.Mutter.RemoteDesktop.Session";
|
||||
|
||||
/// Upper bound on one `SelectionRead` (matches the wire clipboard cap, §7).
|
||||
const CLIP_READ_CAP: u64 = 64 << 20;
|
||||
|
||||
/// Handle to the Mutter clipboard actor, held (inside a [`super::HostClipboard`]) by the session
|
||||
/// coordinator.
|
||||
pub struct MutterClipboard {
|
||||
cmd_tx: mpsc::UnboundedSender<Cmd>,
|
||||
/// Raw MIMEs the current host selection advertises (empty when we own it, or nothing is copied).
|
||||
/// Written by the actor on `SelectionOwnerChanged`; read for `current_wire_mimes` / fetches.
|
||||
current_raw: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
enum Cmd {
|
||||
SetOffer(Vec<String>),
|
||||
ClearOffer,
|
||||
ReadCurrent {
|
||||
wire: String,
|
||||
reply: oneshot::Sender<Result<Vec<u8>>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl MutterClipboard {
|
||||
/// Create a standalone Mutter RemoteDesktop session, `Start` + `EnableClipboard` it, and spawn
|
||||
/// the actor. Errors when Mutter isn't running (not GNOME) — the caller falls through to
|
||||
/// `BACKEND_UNAVAILABLE`.
|
||||
pub async fn open() -> Result<(MutterClipboard, mpsc::UnboundedReceiver<ClipEvent>)> {
|
||||
let conn = zbus::Connection::session()
|
||||
.await
|
||||
.context("session D-Bus (Mutter clipboard)")?;
|
||||
let rd = zbus::Proxy::new(&conn, RD_BUS, RD_PATH, RD_IFACE)
|
||||
.await
|
||||
.context("Mutter RemoteDesktop proxy (is gnome-shell running?)")?;
|
||||
let session_path: OwnedObjectPath = rd
|
||||
.call("CreateSession", &())
|
||||
.await
|
||||
.context("Mutter RemoteDesktop.CreateSession (clipboard)")?;
|
||||
let session = zbus::Proxy::new(&conn, RD_BUS, session_path, SESSION_IFACE)
|
||||
.await
|
||||
.context("Mutter RemoteDesktop.Session proxy")?;
|
||||
session
|
||||
.call_method("Start", &())
|
||||
.await
|
||||
.context("Mutter RemoteDesktop.Session.Start (clipboard)")?;
|
||||
let empty: HashMap<&str, Value> = HashMap::new();
|
||||
session
|
||||
.call_method("EnableClipboard", &(empty,))
|
||||
.await
|
||||
.context("Mutter EnableClipboard")?;
|
||||
|
||||
let (event_tx, event_rx) = mpsc::unbounded_channel();
|
||||
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
|
||||
let current_raw = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
tokio::spawn(actor(conn, session, cmd_rx, event_tx, current_raw.clone()));
|
||||
tracing::info!("clipboard backend bound (Mutter RemoteDesktop direct)");
|
||||
Ok((
|
||||
MutterClipboard {
|
||||
cmd_tx,
|
||||
current_raw,
|
||||
},
|
||||
event_rx,
|
||||
))
|
||||
}
|
||||
|
||||
/// Install a client's offered formats as the host selection (fire-and-forget on the actor).
|
||||
pub fn set_offer(&self, wire_mimes: &[String]) {
|
||||
let _ = self.cmd_tx.send(Cmd::SetOffer(wire_mimes.to_vec()));
|
||||
}
|
||||
|
||||
/// Relinquish the selection we own.
|
||||
pub fn clear_offer(&self) {
|
||||
let _ = self.cmd_tx.send(Cmd::ClearOffer);
|
||||
}
|
||||
|
||||
/// The current host selection's wire MIMEs (empty = nothing / we own it).
|
||||
pub fn current_wire_mimes(&self) -> Vec<String> {
|
||||
super::offer_wire_mimes(&self.current_raw.lock().unwrap())
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read one wire format of the current host selection (a client's fetch). Round-trips the actor
|
||||
/// (SelectionRead + a blocking fd read).
|
||||
pub async fn read_current(&self, wire: &str) -> Result<Vec<u8>> {
|
||||
let (reply, rx) = oneshot::channel();
|
||||
self.cmd_tx
|
||||
.send(Cmd::ReadCurrent {
|
||||
wire: wire.to_string(),
|
||||
reply,
|
||||
})
|
||||
.map_err(|_| anyhow!("Mutter clipboard actor gone"))?;
|
||||
rx.await
|
||||
.map_err(|_| anyhow!("Mutter clipboard read dropped"))?
|
||||
}
|
||||
}
|
||||
|
||||
/// The actor: owns the connection + session, subscribes to the two clipboard signals, and serves
|
||||
/// commands. Exits when the command channel closes (session ending) or a signal stream ends.
|
||||
async fn actor(
|
||||
conn: zbus::Connection,
|
||||
session: zbus::Proxy<'static>,
|
||||
mut cmd_rx: mpsc::UnboundedReceiver<Cmd>,
|
||||
event_tx: mpsc::UnboundedSender<ClipEvent>,
|
||||
current_raw: Arc<Mutex<Vec<String>>>,
|
||||
) {
|
||||
let (mut owner, mut transfer) = match (
|
||||
session.receive_signal("SelectionOwnerChanged").await,
|
||||
session.receive_signal("SelectionTransfer").await,
|
||||
) {
|
||||
(Ok(o), Ok(t)) => (o, t),
|
||||
_ => {
|
||||
tracing::warn!("Mutter clipboard: could not subscribe to selection signals");
|
||||
let _ = event_tx.send(ClipEvent::Closed);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
sig = owner.next() => {
|
||||
let Some(msg) = sig else { break };
|
||||
let Ok((opts,)) = msg.body().deserialize::<(HashMap<String, OwnedValue>,)>() else {
|
||||
continue;
|
||||
};
|
||||
let is_owner = dict_bool(&opts, "session-is-owner").unwrap_or(false);
|
||||
let raw = dict_mimes(&opts, "mime-types");
|
||||
if is_owner {
|
||||
// Our own offer (the client's content) — not host clipboard; don't announce it,
|
||||
// and clear `current_raw` so a fetch never reads our own source back.
|
||||
current_raw.lock().unwrap().clear();
|
||||
} else {
|
||||
*current_raw.lock().unwrap() = raw.clone();
|
||||
let wire = super::offer_wire_mimes(&raw)
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
if event_tx.send(ClipEvent::Selection { mimes: wire }).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
sig = transfer.next() => {
|
||||
let Some(msg) = sig else { break };
|
||||
let Ok((mime, serial)) = msg.body().deserialize::<(String, u32)>() else {
|
||||
continue;
|
||||
};
|
||||
match super::wayland_to_wire(&mime) {
|
||||
Some(wire) => {
|
||||
// A host app pastes our offer: hand the fetch to the coordinator, then serve
|
||||
// the returned bytes into the SelectionWrite fd off-task. NB Mutter issues
|
||||
// *two* transfers per read (a size probe + the real read), so the coordinator
|
||||
// fetches from the client twice per paste — correct, just not deduplicated.
|
||||
let (tx, rx) = oneshot::channel();
|
||||
if event_tx
|
||||
.send(ClipEvent::Paste {
|
||||
mime: wire.to_string(),
|
||||
responder: PasteResponder::Channel(tx),
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
let session = session.clone();
|
||||
tokio::spawn(async move {
|
||||
let bytes = rx.await.unwrap_or_default();
|
||||
serve_write(&session, serial, bytes).await;
|
||||
});
|
||||
}
|
||||
// Format we don't sync — fail the transfer cleanly.
|
||||
None => serve_write(&session, serial, Vec::new()).await,
|
||||
}
|
||||
}
|
||||
cmd = cmd_rx.recv() => {
|
||||
let Some(cmd) = cmd else { break }; // coordinator gone → session ending
|
||||
match cmd {
|
||||
Cmd::SetOffer(wire) => {
|
||||
let wl = super::wayland_offers_for(&wire);
|
||||
if let Err(e) = set_selection(&session, &wl).await {
|
||||
tracing::debug!(error = %e, "Mutter SetSelection failed");
|
||||
}
|
||||
}
|
||||
Cmd::ClearOffer => {
|
||||
if let Err(e) = set_selection(&session, &[]).await {
|
||||
tracing::debug!(error = %e, "Mutter clear selection failed");
|
||||
}
|
||||
}
|
||||
Cmd::ReadCurrent { wire, reply } => {
|
||||
let raw = current_raw.lock().unwrap().clone();
|
||||
let _ = reply.send(read_selection(&session, &wire, &raw).await);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Keep the connection owned for the actor's whole life (Mutter ties the session to it).
|
||||
drop(conn);
|
||||
let _ = event_tx.send(ClipEvent::Closed);
|
||||
}
|
||||
|
||||
/// Offer `wl_mimes` as the host selection; an empty list relinquishes ownership.
|
||||
async fn set_selection(session: &zbus::Proxy<'_>, wl_mimes: &[String]) -> Result<()> {
|
||||
let mut opts: HashMap<&str, Value> = HashMap::new();
|
||||
if !wl_mimes.is_empty() {
|
||||
let refs: Vec<&str> = wl_mimes.iter().map(String::as_str).collect();
|
||||
opts.insert("mime-types", Value::from(refs));
|
||||
}
|
||||
session
|
||||
.call_method("SetSelection", &(opts,))
|
||||
.await
|
||||
.context("Mutter SetSelection")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the current selection's `wire` format: pick a concrete offered MIME, `SelectionRead` it, and
|
||||
/// read the (non-blocking) fd to EOF on a blocking thread.
|
||||
async fn read_selection(session: &zbus::Proxy<'_>, wire: &str, raw: &[String]) -> Result<Vec<u8>> {
|
||||
let mime =
|
||||
super::pick_wayland_mime(wire, raw).context("format not offered by the host clipboard")?;
|
||||
let fd: zbus::zvariant::OwnedFd = session
|
||||
.call("SelectionRead", &(mime.as_str(),))
|
||||
.await
|
||||
.context("Mutter SelectionRead")?;
|
||||
let fd = OwnedFd::from(fd);
|
||||
tokio::task::spawn_blocking(move || read_fd_to_end(fd))
|
||||
.await
|
||||
.map_err(|e| anyhow!("SelectionRead join: {e}"))?
|
||||
}
|
||||
|
||||
/// Serve one `SelectionTransfer`: `SelectionWrite` → write `bytes` → `SelectionWriteDone`. Any write
|
||||
/// failure still reports done (success=false) so Mutter completes the transfer.
|
||||
async fn serve_write(session: &zbus::Proxy<'_>, serial: u32, bytes: Vec<u8>) {
|
||||
let ok = match write_selection(session, serial, bytes).await {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "Mutter SelectionWrite failed");
|
||||
false
|
||||
}
|
||||
};
|
||||
let _ = session
|
||||
.call_method("SelectionWriteDone", &(serial, ok))
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn write_selection(session: &zbus::Proxy<'_>, serial: u32, bytes: Vec<u8>) -> Result<()> {
|
||||
let fd: zbus::zvariant::OwnedFd = session
|
||||
.call("SelectionWrite", &(serial,))
|
||||
.await
|
||||
.context("Mutter SelectionWrite")?;
|
||||
let fd = OwnedFd::from(fd);
|
||||
tokio::task::spawn_blocking(move || write_fd(fd, &bytes))
|
||||
.await
|
||||
.map_err(|e| anyhow!("SelectionWrite join: {e}"))?
|
||||
}
|
||||
|
||||
/// Read a Mutter clipboard fd to EOF (capped). The fd is `O_NONBLOCK`; flip it to blocking first.
|
||||
fn read_fd_to_end(fd: OwnedFd) -> Result<Vec<u8>> {
|
||||
set_blocking(&fd)?;
|
||||
let file = std::fs::File::from(fd);
|
||||
let mut buf = Vec::new();
|
||||
file.take(CLIP_READ_CAP)
|
||||
.read_to_end(&mut buf)
|
||||
.context("read SelectionRead fd")?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Write `bytes` into a Mutter clipboard fd and close it (EOF). Flip the `O_NONBLOCK` fd to blocking.
|
||||
fn write_fd(fd: OwnedFd, bytes: &[u8]) -> Result<()> {
|
||||
set_blocking(&fd)?;
|
||||
let mut file = std::fs::File::from(fd);
|
||||
file.write_all(bytes).context("write SelectionWrite fd")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Peel any `Value::Value` (variant) wrappers to the concrete value. The `a{sv}` dict values Mutter
|
||||
/// sends arrive as variants, so a plain `TryFrom<OwnedValue>` (which matches the concrete type) never
|
||||
/// sees through them — this strips the layer first.
|
||||
fn peel<'a>(v: &'a Value<'a>) -> &'a Value<'a> {
|
||||
let mut cur = v;
|
||||
while let Value::Value(inner) = cur {
|
||||
cur = inner;
|
||||
}
|
||||
cur
|
||||
}
|
||||
|
||||
/// Extract a boolean dict entry (e.g. `session-is-owner`), unwrapping the variant.
|
||||
fn dict_bool(opts: &HashMap<String, OwnedValue>, key: &str) -> Option<bool> {
|
||||
match peel(opts.get(key)?) {
|
||||
Value::Bool(b) => Some(*b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a string-array dict entry (e.g. `mime-types`), unwrapping the variant. Mutter wraps the
|
||||
/// array in a single-field struct (`(as)`, seen on `SelectionOwnerChanged`), so unwrap that too.
|
||||
fn dict_mimes(opts: &HashMap<String, OwnedValue>, key: &str) -> Vec<String> {
|
||||
let Some(v) = opts.get(key) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut val = peel(v);
|
||||
if let Value::Structure(s) = val {
|
||||
match s.fields().first() {
|
||||
Some(first) => val = peel(first),
|
||||
None => return Vec::new(),
|
||||
}
|
||||
}
|
||||
let Value::Array(arr) = val else {
|
||||
return Vec::new();
|
||||
};
|
||||
arr.inner()
|
||||
.iter()
|
||||
.filter_map(|e| match peel(e) {
|
||||
Value::Str(s) => Some(s.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Clear `O_NONBLOCK` on a Mutter clipboard fd so a blocking `spawn_blocking` read/write works.
|
||||
fn set_blocking(fd: &OwnedFd) -> Result<()> {
|
||||
let raw = fd.as_raw_fd();
|
||||
// SAFETY: `raw` is a valid fd owned by `fd` for the duration of these fcntl calls.
|
||||
let flags = unsafe { libc::fcntl(raw, libc::F_GETFL) };
|
||||
if flags < 0 {
|
||||
return Err(anyhow!(
|
||||
"fcntl F_GETFL: {}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
// SAFETY: as above; clearing O_NONBLOCK on our own fd.
|
||||
let rc = unsafe { libc::fcntl(raw, libc::F_SETFL, flags & !libc::O_NONBLOCK) };
|
||||
if rc < 0 {
|
||||
return Err(anyhow!(
|
||||
"fcntl F_SETFL: {}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// On-glass tests against a **live GNOME/Mutter** session (`WAYLAND_DISPLAY=wayland-0`). `#[ignore]`d
|
||||
/// — run explicitly under GNOME (Mutter has no `wl-clipboard`, so a second Mutter session plays the
|
||||
/// "host app"):
|
||||
///
|
||||
/// ```text
|
||||
/// cargo test -p punktfunk-host --bin punktfunk-host -- --ignored --nocapture clipboard::mutter::live
|
||||
/// ```
|
||||
///
|
||||
/// Skips (does not fail) when Mutter isn't running, so `--ignored` off-GNOME is a clean no-op.
|
||||
#[cfg(test)]
|
||||
mod live {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
/// A second Mutter session standing in for a host clipboard app.
|
||||
struct Helper {
|
||||
session: zbus::Proxy<'static>,
|
||||
_conn: zbus::Connection,
|
||||
}
|
||||
|
||||
impl Helper {
|
||||
async fn open() -> Result<Helper> {
|
||||
let conn = zbus::Connection::session().await?;
|
||||
let rd = zbus::Proxy::new(&conn, RD_BUS, RD_PATH, RD_IFACE).await?;
|
||||
let path: OwnedObjectPath = rd.call("CreateSession", &()).await?;
|
||||
let session = zbus::Proxy::new(&conn, RD_BUS, path, SESSION_IFACE).await?;
|
||||
session.call_method("Start", &()).await?;
|
||||
let empty: HashMap<&str, Value> = HashMap::new();
|
||||
session.call_method("EnableClipboard", &(empty,)).await?;
|
||||
Ok(Helper {
|
||||
session,
|
||||
_conn: conn,
|
||||
})
|
||||
}
|
||||
|
||||
/// Own the selection offering plain text, serving `payload` on every transfer request.
|
||||
async fn offer_text(&self, payload: &'static [u8]) {
|
||||
let mut transfer = self
|
||||
.session
|
||||
.receive_signal("SelectionTransfer")
|
||||
.await
|
||||
.unwrap();
|
||||
let session = self.session.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(msg) = transfer.next().await {
|
||||
if let Ok((_mime, serial)) = msg.body().deserialize::<(String, u32)>() {
|
||||
serve_write(&session, serial, payload.to_vec()).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
set_selection(
|
||||
&self.session,
|
||||
&[
|
||||
"text/plain;charset=utf-8".to_string(),
|
||||
"text/plain".to_string(),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Paste the current selection's plain text.
|
||||
async fn read_text(&self) -> Vec<u8> {
|
||||
let fd: zbus::zvariant::OwnedFd = self
|
||||
.session
|
||||
.call("SelectionRead", &("text/plain;charset=utf-8",))
|
||||
.await
|
||||
.unwrap();
|
||||
let fd = OwnedFd::from(fd);
|
||||
tokio::task::spawn_blocking(move || read_fd_to_end(fd))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
async fn next_selection(
|
||||
rx: &mut mpsc::UnboundedReceiver<ClipEvent>,
|
||||
timeout: Duration,
|
||||
) -> Option<Vec<String>> {
|
||||
tokio::time::timeout(timeout, async {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Some(ClipEvent::Selection { mimes }) if !mimes.is_empty() => {
|
||||
return Some(mimes)
|
||||
}
|
||||
Some(_) => continue,
|
||||
None => return None,
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs a live GNOME/Mutter session (WAYLAND_DISPLAY=wayland-0)"]
|
||||
fn live_mutter_roundtrip() {
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
rt.block_on(async {
|
||||
let (backend, mut rx) = match MutterClipboard::open().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("SKIP: no Mutter clipboard (not GNOME?): {e:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let helper = Helper::open().await.expect("helper Mutter session");
|
||||
|
||||
// --- host copy → backend observes Selection + read_current returns the bytes ---
|
||||
helper.offer_text(b"gnome-host-copied").await;
|
||||
let mimes = next_selection(&mut rx, Duration::from_secs(3))
|
||||
.await
|
||||
.expect("Selection after the helper offered text");
|
||||
assert!(
|
||||
mimes.iter().any(|m| m == super::super::WIRE_TEXT),
|
||||
"offer carries wire text: {mimes:?}"
|
||||
);
|
||||
let got = backend
|
||||
.read_current(super::super::WIRE_TEXT)
|
||||
.await
|
||||
.expect("read_current text");
|
||||
assert_eq!(got, b"gnome-host-copied");
|
||||
|
||||
// --- backend offers client content → the host app (helper) pastes it ---
|
||||
backend.set_offer(&[super::super::WIRE_TEXT.to_string()]);
|
||||
tokio::time::sleep(Duration::from_millis(500)).await; // let SetSelection take effect
|
||||
// Answer every Paste request the host app (helper) triggers, until the read completes.
|
||||
let paste_side = async {
|
||||
while let Some(ev) = rx.recv().await {
|
||||
if let ClipEvent::Paste { responder, .. } = ev {
|
||||
responder.respond(b"punktfunk-served".to_vec()).await;
|
||||
}
|
||||
}
|
||||
};
|
||||
let read = tokio::select! {
|
||||
r = helper.read_text() => r,
|
||||
_ = paste_side => Vec::new(),
|
||||
};
|
||||
assert_eq!(read, b"punktfunk-served");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Host clipboard coordinator (`design/clipboard-and-file-transfer.md` §4.2).
|
||||
//!
|
||||
//! One async task per streaming session that bridges the real session clipboard (whichever
|
||||
//! [`super::HostClipboard`] backend this platform opened) to the QUIC clipboard plane. It owns all
|
||||
//! four data paths:
|
||||
//!
|
||||
//! * **host copy → client** — a backend [`ClipEvent::Selection`] becomes a [`ClipOffer`] pushed to the
|
||||
//! control loop (`offer_tx`), which forwards it to the client.
|
||||
//! * **client fetch of the host clipboard** — the fetch-stream `accept_bi` loop lives here; each
|
||||
//! stream is answered by reading the current host selection ([`ClipboardBackend::read_current`]).
|
||||
//! * **client copy → host** — a [`ClipCoordCmd::RemoteOffer`] installs the client's formats as a lazy
|
||||
//! host selection ([`HostClipboard::set_offer`]).
|
||||
//! * **host paste of client content** — a backend [`ClipEvent::Paste`] triggers an *outbound* fetch to
|
||||
//! the client, whose bytes are handed to the backend's [`PasteResponder`].
|
||||
//!
|
||||
//! The coordinator is backend-agnostic (Linux data-control / Mutter, Windows Win32); the control loop
|
||||
//! reaches it through the portable [`ClipCoordCmd`] channel so the host's native control loop
|
||||
//! compiles on every host platform.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
|
||||
|
||||
use punktfunk_core::clipboard::CLIP_FETCH_CAP;
|
||||
use punktfunk_core::quic::{
|
||||
clipstream, ClipFetch, ClipFetchHdr, ClipKind, ClipOffer, CLIP_FETCH_OK, CLIP_FETCH_STALE,
|
||||
CLIP_FETCH_UNAVAILABLE, CLIP_FILE_INDEX_NONE,
|
||||
};
|
||||
|
||||
use super::{ClipEvent, HostClipboard, PasteResponder};
|
||||
use crate::ClipCoordCmd;
|
||||
|
||||
/// Upper bound on one outbound fetch (host pasting client content). A client that never answers must
|
||||
/// not hang the pasting host app's pipe read (§3.4) — the paste falls back to empty instead.
|
||||
const FETCH_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Open whichever host clipboard backend this session supports (data-control, else Mutter direct) and
|
||||
/// spawn the coordinator. Returns `true` when a live backend was bound (the caller's control loop
|
||||
/// then serves real clipboard data); `false` when none is available (gamescope, no live compositor),
|
||||
/// in which case the channels are dropped so the control loop reports `CLIP_REASON_BACKEND_UNAVAILABLE`
|
||||
/// and declines fetches defensively.
|
||||
pub async fn start(
|
||||
conn: quinn::Connection,
|
||||
clip_enabled: Arc<AtomicBool>,
|
||||
cmd_rx: UnboundedReceiver<ClipCoordCmd>,
|
||||
offer_tx: UnboundedSender<ClipOffer>,
|
||||
) -> bool {
|
||||
match HostClipboard::open().await {
|
||||
Ok((backend, clip_rx)) => {
|
||||
tokio::spawn(run(
|
||||
conn,
|
||||
Arc::new(backend),
|
||||
clip_rx,
|
||||
clip_enabled,
|
||||
cmd_rx,
|
||||
offer_tx,
|
||||
));
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::info!(error = %format!("{e:#}"), "clipboard backend unavailable — fetches will be declined");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The coordinator loop. Multiplexes control-loop commands, backend clipboard events, and inbound
|
||||
/// fetch streams; exits when any of the three peers goes away (session ending).
|
||||
async fn run(
|
||||
conn: quinn::Connection,
|
||||
backend: Arc<HostClipboard>,
|
||||
mut clip_rx: UnboundedReceiver<ClipEvent>,
|
||||
clip_enabled: Arc<AtomicBool>,
|
||||
mut cmd_rx: UnboundedReceiver<ClipCoordCmd>,
|
||||
offer_tx: UnboundedSender<ClipOffer>,
|
||||
) {
|
||||
// Seq of the offer the host most recently announced; a client fetch naming a different seq is
|
||||
// stale (the host clipboard moved on) and is declined.
|
||||
let host_seq = Arc::new(AtomicU32::new(0));
|
||||
let mut next_seq: u32 = 1;
|
||||
// Seq of the client's most recent offer, echoed on the outbound fetch we open when a host app
|
||||
// pastes client content (informational for the client's serve side).
|
||||
let mut client_seq: u32 = 0;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
cmd = cmd_rx.recv() => {
|
||||
let Some(cmd) = cmd else { break }; // control loop gone → session ending
|
||||
match cmd {
|
||||
ClipCoordCmd::SetEnabled(true) => {
|
||||
// A just-enabled client should see whatever the host already has copied.
|
||||
let mimes = backend.current_wire_mimes();
|
||||
if !mimes.is_empty() {
|
||||
let _ = offer_tx.send(build_offer(&mut next_seq, &host_seq, mimes));
|
||||
}
|
||||
}
|
||||
ClipCoordCmd::SetEnabled(false) => {
|
||||
if let Err(e) = backend.clear_offer() {
|
||||
tracing::debug!(error = %e, "clipboard clear_offer failed");
|
||||
}
|
||||
}
|
||||
ClipCoordCmd::RemoteOffer { seq, mimes } => {
|
||||
client_seq = seq;
|
||||
let res = if mimes.is_empty() {
|
||||
backend.clear_offer()
|
||||
} else {
|
||||
backend.set_offer(&mimes)
|
||||
};
|
||||
if let Err(e) = res {
|
||||
tracing::debug!(error = %e, "clipboard apply remote offer failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ev = clip_rx.recv() => {
|
||||
let Some(ev) = ev else { break }; // backend dispatch thread ended
|
||||
match ev {
|
||||
ClipEvent::Selection { mimes } => {
|
||||
// Forward host copies (empty `mimes` = the clipboard was cleared) only while
|
||||
// the client has sync on — the offer is metadata; bytes still cross lazily.
|
||||
if clip_enabled.load(Ordering::SeqCst) {
|
||||
let _ = offer_tx.send(build_offer(&mut next_seq, &host_seq, mimes));
|
||||
}
|
||||
}
|
||||
ClipEvent::Paste { mime, responder } => {
|
||||
// A host app is pasting the client's offered content: pull that format from
|
||||
// the client and hand it to the backend's responder. Off-task so the loop
|
||||
// keeps serving.
|
||||
tokio::spawn(fetch_into_pipe(conn.clone(), client_seq, mime, responder));
|
||||
}
|
||||
ClipEvent::Closed => break,
|
||||
}
|
||||
}
|
||||
accepted = conn.accept_bi() => {
|
||||
let Ok((send, recv)) = accepted else { break }; // connection gone
|
||||
// The control stream is already accepted at the handshake, so every stream here is a
|
||||
// clipboard fetch. Serve it off-task (the read blocks on the source app's pipe).
|
||||
tokio::spawn(serve_fetch(
|
||||
send,
|
||||
recv,
|
||||
Arc::clone(&backend),
|
||||
Arc::clone(&host_seq),
|
||||
clip_enabled.load(Ordering::SeqCst),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Session ending: don't leave our lazy source as the compositor's active selection.
|
||||
let _ = backend.clear_offer();
|
||||
}
|
||||
|
||||
/// Mint a [`ClipOffer`] for `mimes`, advancing the host offer seq (skipping 0, the "never offered"
|
||||
/// sentinel) and publishing it as the current one for staleness checks.
|
||||
fn build_offer(next_seq: &mut u32, host_seq: &AtomicU32, mimes: Vec<String>) -> ClipOffer {
|
||||
let seq = *next_seq;
|
||||
*next_seq = next_seq.wrapping_add(1);
|
||||
if *next_seq == 0 {
|
||||
*next_seq = 1;
|
||||
}
|
||||
host_seq.store(seq, Ordering::SeqCst);
|
||||
let kinds = mimes
|
||||
.into_iter()
|
||||
.map(|mime| ClipKind { mime, size_hint: 0 })
|
||||
.collect();
|
||||
ClipOffer { seq, kinds }
|
||||
}
|
||||
|
||||
/// Serve one inbound fetch stream (a client pulling the host clipboard): validate the header +
|
||||
/// request, then answer with the current host selection's bytes for the requested wire MIME.
|
||||
async fn serve_fetch(
|
||||
mut send: quinn::SendStream,
|
||||
mut recv: quinn::RecvStream,
|
||||
backend: Arc<HostClipboard>,
|
||||
host_seq: Arc<AtomicU32>,
|
||||
enabled: bool,
|
||||
) {
|
||||
let _ = send.set_priority(-1);
|
||||
match clipstream::read_stream_header(&mut recv).await {
|
||||
Ok(k) if k == clipstream::CLIP_STREAM_KIND_FETCH => {}
|
||||
_ => {
|
||||
let _ = send.reset(clipstream::cancelled_code());
|
||||
return;
|
||||
}
|
||||
}
|
||||
let req = match clipstream::read_fetch(&mut recv).await {
|
||||
Ok(r) => r,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let decline = |status: u8| ClipFetchHdr {
|
||||
status,
|
||||
total_size: 0,
|
||||
};
|
||||
if !enabled {
|
||||
let _ = clipstream::write_fetch_hdr(&mut send, &decline(CLIP_FETCH_UNAVAILABLE)).await;
|
||||
return;
|
||||
}
|
||||
if req.seq != host_seq.load(Ordering::SeqCst) {
|
||||
let _ = clipstream::write_fetch_hdr(&mut send, &decline(CLIP_FETCH_STALE)).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// `read_current` reads the host selection (a blocking pipe read, offloaded by the backend).
|
||||
match backend.read_current(&req.mime).await {
|
||||
Ok(data) => {
|
||||
let hdr = ClipFetchHdr {
|
||||
status: CLIP_FETCH_OK,
|
||||
total_size: data.len() as u64,
|
||||
};
|
||||
if clipstream::write_fetch_hdr(&mut send, &hdr).await.is_ok() {
|
||||
let _ = clipstream::write_data(&mut send, &data).await;
|
||||
}
|
||||
}
|
||||
// The format vanished (clipboard changed mid-fetch) or the read failed → nothing to send.
|
||||
Err(_) => {
|
||||
let _ = clipstream::write_fetch_hdr(&mut send, &decline(CLIP_FETCH_UNAVAILABLE)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull `mime` of the client's current offer (`seq`) over an outbound fetch stream and hand the bytes
|
||||
/// to the backend's paste `responder`. Any failure (timeout, decline, I/O) responds with empty bytes
|
||||
/// so the pasting host app gets an empty paste instead of hanging.
|
||||
async fn fetch_into_pipe(
|
||||
conn: quinn::Connection,
|
||||
seq: u32,
|
||||
mime: String,
|
||||
responder: PasteResponder,
|
||||
) {
|
||||
let req = ClipFetch {
|
||||
seq,
|
||||
file_index: CLIP_FILE_INDEX_NONE,
|
||||
mime,
|
||||
};
|
||||
let fetched = tokio::time::timeout(FETCH_TIMEOUT, async {
|
||||
let (send, mut recv) = clipstream::open_fetch(&conn, &req).await.ok()?;
|
||||
let hdr = clipstream::read_fetch_hdr(&mut recv).await.ok()?;
|
||||
if hdr.status != CLIP_FETCH_OK {
|
||||
return None;
|
||||
}
|
||||
let data = clipstream::read_data(&mut recv, CLIP_FETCH_CAP)
|
||||
.await
|
||||
.ok()?;
|
||||
drop(send); // clean close of our half
|
||||
Some(data)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
responder.respond(fetched.unwrap_or_default()).await;
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
//! `ext-data-control-v1` clipboard backend (`design/clipboard-and-file-transfer.md` §4.1).
|
||||
//!
|
||||
//! A dedicated thread owns the `wayland-client` [`EventQueue`] and runs a poll loop that dispatches
|
||||
//! selection + paste events, emitting them over a channel. Everything else — installing a lazy
|
||||
//! source (a client's offer) and `receive()`-ing the host selection (a client's fetch) — is issued
|
||||
//! from the session thread on the shared, `Send + Sync` proxy handles; only *dispatch* is
|
||||
//! single-threaded (per the wayland-client contract). Templated on `inject/linux/wlr.rs`.
|
||||
//!
|
||||
//! The `zwlr-data-control-unstable-v1` fallback for older wlroots/KWin is a mechanical parallel of
|
||||
//! this file (the protocols are 1:1) — a follow-up.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use wayland_client::backend::ObjectId;
|
||||
use wayland_client::protocol::wl_registry;
|
||||
use wayland_client::protocol::wl_seat::WlSeat;
|
||||
use wayland_client::{event_created_child, Connection, Dispatch, Proxy, QueueHandle};
|
||||
use wayland_protocols::ext::data_control::v1::client::{
|
||||
ext_data_control_device_v1::{self, ExtDataControlDeviceV1},
|
||||
ext_data_control_manager_v1::ExtDataControlManagerV1,
|
||||
ext_data_control_offer_v1::{self, ExtDataControlOfferV1},
|
||||
ext_data_control_source_v1::{self, ExtDataControlSourceV1},
|
||||
};
|
||||
|
||||
use super::{ClipEvent, PasteResponder};
|
||||
|
||||
/// Upper bound on bytes read from one `receive()` transfer (matches the wire clipboard cap, §7) so a
|
||||
/// hostile host app can't stream unboundedly into our buffer.
|
||||
const CLIP_READ_CAP: u64 = 64 << 20;
|
||||
|
||||
/// The current host selection, shared between the dispatch thread (writer) and the session thread
|
||||
/// (reader, for `receive()`).
|
||||
struct CurrentSelection {
|
||||
offer: ExtDataControlOfferV1,
|
||||
/// Raw Wayland MIMEs the offer advertises (what `receive()` accepts).
|
||||
mimes: Vec<String>,
|
||||
}
|
||||
|
||||
/// Dispatch-thread state. Also collects the manager + seat during the bind roundtrip.
|
||||
struct State {
|
||||
mgr: Option<ExtDataControlManagerV1>,
|
||||
seat: Option<WlSeat>,
|
||||
/// Offers accumulating their MIME list before the `selection` event promotes one.
|
||||
pending: HashMap<ObjectId, Vec<String>>,
|
||||
current: Arc<Mutex<Option<CurrentSelection>>>,
|
||||
/// Pending count of our own `set_selection`s whose `selection` echo must be dropped rather than
|
||||
/// announced back to the client (loop prevention, §3.4). Bumped by the session before each set;
|
||||
/// each of our sets produces exactly one echo on wlroots/KWin, so one decrement per echo pairs
|
||||
/// them up — a counter (not a bool) keeps rapid back-to-back offers from leaking a self-echo.
|
||||
suppress_echoes: Arc<AtomicU32>,
|
||||
tx: tokio::sync::mpsc::UnboundedSender<ClipEvent>,
|
||||
}
|
||||
|
||||
impl Dispatch<wl_registry::WlRegistry, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
registry: &wl_registry::WlRegistry,
|
||||
event: wl_registry::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_registry::Event::Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} = event
|
||||
{
|
||||
match interface.as_str() {
|
||||
"ext_data_control_manager_v1" => {
|
||||
state.mgr = Some(registry.bind(name, version.min(1), qh, ()));
|
||||
}
|
||||
"wl_seat" => {
|
||||
state.seat = Some(registry.bind(name, version.min(7), qh, ()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Manager + seat emit nothing we consume.
|
||||
impl Dispatch<ExtDataControlManagerV1, ()> for State {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
_: &ExtDataControlManagerV1,
|
||||
_: <ExtDataControlManagerV1 as Proxy>::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
impl Dispatch<WlSeat, ()> for State {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
_: &WlSeat,
|
||||
_: <WlSeat as Proxy>::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ExtDataControlDeviceV1, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_dev: &ExtDataControlDeviceV1,
|
||||
event: ext_data_control_device_v1::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
use ext_data_control_device_v1::Event;
|
||||
match event {
|
||||
// A new offer is being introduced; its `offer` events follow before `selection`.
|
||||
Event::DataOffer { id } => {
|
||||
state.pending.insert(id.id(), Vec::new());
|
||||
}
|
||||
// The active selection changed. `Some` = a new clipboard; `None` = cleared.
|
||||
Event::Selection { id } => {
|
||||
// Consume one pending self-echo if any (atomic vs. the session thread's bumps; the
|
||||
// dispatch thread is the only decrementer). `Ok` = there was one → suppress.
|
||||
let suppressed = state
|
||||
.suppress_echoes
|
||||
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |c| c.checked_sub(1))
|
||||
.is_ok();
|
||||
match id {
|
||||
Some(offer) => {
|
||||
let mimes = state.pending.remove(&offer.id()).unwrap_or_default();
|
||||
if suppressed {
|
||||
// Our own source's echo — don't store it as the host clipboard and
|
||||
// don't announce it back to the client.
|
||||
return;
|
||||
}
|
||||
let wire = super::offer_wire_mimes(&mimes)
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
*state.current.lock().unwrap() = Some(CurrentSelection { offer, mimes });
|
||||
let _ = state.tx.send(ClipEvent::Selection { mimes: wire });
|
||||
}
|
||||
None => {
|
||||
*state.current.lock().unwrap() = None;
|
||||
if !suppressed {
|
||||
let _ = state.tx.send(ClipEvent::Selection { mimes: Vec::new() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Finished => {
|
||||
let _ = state.tx.send(ClipEvent::Closed);
|
||||
}
|
||||
// Primary selection is out of scope for the shared clipboard.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
event_created_child!(State, ExtDataControlDeviceV1, [
|
||||
ext_data_control_device_v1::EVT_DATA_OFFER_OPCODE => (ExtDataControlOfferV1, ()),
|
||||
]);
|
||||
}
|
||||
|
||||
impl Dispatch<ExtDataControlOfferV1, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
offer: &ExtDataControlOfferV1,
|
||||
event: ext_data_control_offer_v1::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let ext_data_control_offer_v1::Event::Offer { mime_type } = event {
|
||||
if let Some(list) = state.pending.get_mut(&offer.id()) {
|
||||
list.push(mime_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ExtDataControlSourceV1, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_src: &ExtDataControlSourceV1,
|
||||
event: ext_data_control_source_v1::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
use ext_data_control_source_v1::Event;
|
||||
match event {
|
||||
// A host app pasted our (the client's) offered data.
|
||||
Event::Send { mime_type, fd } => match super::wayland_to_wire(&mime_type) {
|
||||
Some(wire) => {
|
||||
let _ = state.tx.send(ClipEvent::Paste {
|
||||
mime: wire.to_string(),
|
||||
responder: PasteResponder::Fd(fd),
|
||||
});
|
||||
}
|
||||
// We can't satisfy this format — closing the fd yields an empty paste.
|
||||
None => drop(fd),
|
||||
},
|
||||
// Our source was superseded (a host app or another client set a new selection).
|
||||
Event::Cancelled => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The host clipboard backend handle used by the session thread.
|
||||
pub struct ClipboardBackend {
|
||||
conn: Connection,
|
||||
mgr: ExtDataControlManagerV1,
|
||||
device: ExtDataControlDeviceV1,
|
||||
qh: QueueHandle<State>,
|
||||
current: Arc<Mutex<Option<CurrentSelection>>>,
|
||||
suppress_echoes: Arc<AtomicU32>,
|
||||
active_source: Mutex<Option<ExtDataControlSourceV1>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl ClipboardBackend {
|
||||
/// Connect to the active session's Wayland display (env already applied by
|
||||
/// `vdisplay::apply_session_env`), bind `ext_data_control`, and start the dispatch thread.
|
||||
/// Returns the handle plus the event stream. Errors if the compositor lacks the protocol
|
||||
/// (caller reports `BackendUnavailable`).
|
||||
pub fn open() -> Result<(
|
||||
ClipboardBackend,
|
||||
tokio::sync::mpsc::UnboundedReceiver<ClipEvent>,
|
||||
)> {
|
||||
let conn = Connection::connect_to_env()
|
||||
.context("connect to Wayland for clipboard (WAYLAND_DISPLAY/XDG_RUNTIME_DIR set?)")?;
|
||||
let mut queue = conn.new_event_queue();
|
||||
let qh = queue.handle();
|
||||
let _registry = conn.display().get_registry(&qh, ());
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let current = Arc::new(Mutex::new(None));
|
||||
let suppress_echoes = Arc::new(AtomicU32::new(0));
|
||||
let mut state = State {
|
||||
mgr: None,
|
||||
seat: None,
|
||||
pending: HashMap::new(),
|
||||
current: current.clone(),
|
||||
suppress_echoes: suppress_echoes.clone(),
|
||||
tx,
|
||||
};
|
||||
queue
|
||||
.roundtrip(&mut state)
|
||||
.context("Wayland registry roundtrip")?;
|
||||
|
||||
let mgr = state
|
||||
.mgr
|
||||
.clone()
|
||||
.context("compositor lacks ext_data_control_manager_v1")?;
|
||||
let seat = state
|
||||
.seat
|
||||
.clone()
|
||||
.context("compositor advertised no wl_seat")?;
|
||||
let device = mgr.get_data_device(&seat, &qh, ());
|
||||
// Second roundtrip: the compositor sends the initial selection for the freshly-bound device
|
||||
// (the current host clipboard), which the session announces to the client.
|
||||
queue
|
||||
.roundtrip(&mut state)
|
||||
.context("Wayland get_data_device roundtrip")?;
|
||||
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let thread = {
|
||||
let conn = conn.clone();
|
||||
let stop = stop.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-clipboard".into())
|
||||
.spawn(move || dispatch_loop(conn, queue, state, stop))
|
||||
.context("spawn clipboard dispatch thread")?
|
||||
};
|
||||
|
||||
Ok((
|
||||
ClipboardBackend {
|
||||
conn,
|
||||
mgr,
|
||||
device,
|
||||
qh,
|
||||
current,
|
||||
suppress_echoes,
|
||||
active_source: Mutex::new(None),
|
||||
stop,
|
||||
thread: Some(thread),
|
||||
},
|
||||
rx,
|
||||
))
|
||||
}
|
||||
|
||||
/// Install a lazy source advertising a client's offered formats (wire MIMEs) as the host
|
||||
/// selection. A later host-app paste fires a [`ClipEvent::Paste`]. Replaces any previous offer.
|
||||
pub fn set_offer(&self, wire_mimes: &[String]) -> Result<()> {
|
||||
let wl_mimes = super::wayland_offers_for(wire_mimes);
|
||||
if wl_mimes.is_empty() {
|
||||
return self.clear_offer();
|
||||
}
|
||||
let src = self.mgr.create_data_source(&self.qh, ());
|
||||
for m in &wl_mimes {
|
||||
src.offer(m.clone());
|
||||
}
|
||||
// Suppress the selection echo our own set triggers (loop prevention).
|
||||
self.suppress_echoes.fetch_add(1, Ordering::SeqCst);
|
||||
self.device.set_selection(Some(&src));
|
||||
self.conn.flush().context("flush set_selection")?;
|
||||
let mut slot = self.active_source.lock().unwrap();
|
||||
if let Some(old) = slot.take() {
|
||||
old.destroy();
|
||||
}
|
||||
*slot = Some(src);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop the host selection we own (client disabled sync / offered nothing).
|
||||
pub fn clear_offer(&self) -> Result<()> {
|
||||
let mut slot = self.active_source.lock().unwrap();
|
||||
if let Some(old) = slot.take() {
|
||||
self.suppress_echoes.fetch_add(1, Ordering::SeqCst);
|
||||
self.device.set_selection(None);
|
||||
old.destroy();
|
||||
self.conn.flush().context("flush clear selection")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The current host selection's wire MIMEs (what a client offer announcement would carry), or
|
||||
/// empty if the clipboard is empty. Used to answer an immediate query.
|
||||
pub fn current_wire_mimes(&self) -> Vec<String> {
|
||||
match self.current.lock().unwrap().as_ref() {
|
||||
Some(sel) => super::offer_wire_mimes(&sel.mimes)
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one format (`wire_mime`) of the current host selection into a byte vector — a client's
|
||||
/// lazy fetch. BLOCKS on the pipe until the source app finishes, so call from a blocking
|
||||
/// context (e.g. `spawn_blocking`). Errors if there is no selection or the format isn't offered.
|
||||
pub fn read_current(&self, wire_mime: &str) -> Result<Vec<u8>> {
|
||||
let (offer, wl_mime) = {
|
||||
let cur = self.current.lock().unwrap();
|
||||
let sel = cur.as_ref().context("no current host selection")?;
|
||||
let wl = super::pick_wayland_mime(wire_mime, &sel.mimes)
|
||||
.context("format not offered by the host clipboard")?;
|
||||
(sel.offer.clone(), wl)
|
||||
};
|
||||
let (read_fd, write_fd) = make_pipe()?;
|
||||
offer.receive(wl_mime, write_fd.as_fd());
|
||||
self.conn.flush().context("flush receive")?;
|
||||
// Close our write end so the pipe reaches EOF once the source app closes its dup.
|
||||
drop(write_fd);
|
||||
let mut buf = Vec::new();
|
||||
// `read_fd` is a fresh, uniquely-owned pipe read end; `File` takes sole ownership and closes
|
||||
// it on drop.
|
||||
let file = std::fs::File::from(read_fd);
|
||||
file.take(CLIP_READ_CAP)
|
||||
.read_to_end(&mut buf)
|
||||
.context("read clipboard transfer")?;
|
||||
Ok(buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ClipboardBackend {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::SeqCst);
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The dispatch thread: poll the Wayland socket with a short timeout so `stop` is honored promptly,
|
||||
/// dispatching selection/paste events into `state`.
|
||||
fn dispatch_loop(
|
||||
conn: Connection,
|
||||
mut queue: wayland_client::EventQueue<State>,
|
||||
mut state: State,
|
||||
stop: Arc<AtomicBool>,
|
||||
) {
|
||||
while !stop.load(Ordering::SeqCst) {
|
||||
if queue.dispatch_pending(&mut state).is_err() {
|
||||
break;
|
||||
}
|
||||
if conn.flush().is_err() {
|
||||
break;
|
||||
}
|
||||
let Some(guard) = conn.prepare_read() else {
|
||||
// Events are already queued; loop to dispatch them.
|
||||
continue;
|
||||
};
|
||||
let raw_fd = guard.connection_fd().as_raw_fd();
|
||||
let mut pfd = libc::pollfd {
|
||||
fd: raw_fd,
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
// SAFETY: `pfd` is a single valid pollfd; `poll` reads/writes exactly it for 200 ms.
|
||||
let rc = unsafe { libc::poll(&mut pfd, 1, 200) };
|
||||
if rc < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
drop(guard);
|
||||
if err.kind() == std::io::ErrorKind::Interrupted {
|
||||
continue; // EINTR — recheck stop, retry
|
||||
}
|
||||
break;
|
||||
}
|
||||
if rc == 0 {
|
||||
drop(guard); // timeout — recheck stop
|
||||
continue;
|
||||
}
|
||||
if pfd.revents & libc::POLLIN != 0 {
|
||||
if guard.read().is_err() {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
drop(guard); // POLLHUP / POLLERR — connection gone
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = state.tx.send(ClipEvent::Closed);
|
||||
}
|
||||
|
||||
/// Create a `pipe2(O_CLOEXEC)`, returning `(read_end, write_end)` as owned fds.
|
||||
fn make_pipe() -> Result<(OwnedFd, OwnedFd)> {
|
||||
let mut fds = [0 as libc::c_int; 2];
|
||||
// SAFETY: `pipe2` fully initializes the 2-element `fds` on success (returns 0); on failure (-1)
|
||||
// we bail before reading it. Each returned fd is fresh and owned by exactly one `OwnedFd`.
|
||||
let rc = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) };
|
||||
if rc < 0 {
|
||||
return Err(anyhow!("pipe2 failed: {}", std::io::Error::last_os_error()));
|
||||
}
|
||||
// SAFETY: `fds[0]`/`fds[1]` are the fresh, uniquely-owned pipe ends from the checked `pipe2`.
|
||||
let read_fd = unsafe { OwnedFd::from_raw_fd(fds[0]) };
|
||||
// SAFETY: as above for the write end.
|
||||
let write_fd = unsafe { OwnedFd::from_raw_fd(fds[1]) };
|
||||
Ok((read_fd, write_fd))
|
||||
}
|
||||
|
||||
/// On-glass tests against a **live** `data-control` compositor (Hyprland / Sway / KWin). `#[ignore]`d
|
||||
/// — run explicitly under such a session with `wl-clipboard` present:
|
||||
///
|
||||
/// ```text
|
||||
/// WAYLAND_DISPLAY=wayland-1 cargo test -p punktfunk-host --bin punktfunk-host \
|
||||
/// -- --ignored --nocapture clipboard::wayland::live
|
||||
/// ```
|
||||
///
|
||||
/// Each test skips (does not fail) when `open()` finds no backend — so `--ignored` on GNOME (no
|
||||
/// data-control) or a headless CI runner is a clean no-op instead of a false failure.
|
||||
#[cfg(test)]
|
||||
mod live {
|
||||
use super::*;
|
||||
use std::io::Write as _;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Poll the event channel (sync `try_recv`, no runtime) until `pred` matches or `timeout`.
|
||||
fn wait_event(
|
||||
rx: &mut tokio::sync::mpsc::UnboundedReceiver<ClipEvent>,
|
||||
timeout: Duration,
|
||||
mut pred: impl FnMut(&ClipEvent) -> bool,
|
||||
) -> Option<ClipEvent> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match rx.try_recv() {
|
||||
Ok(ev) if pred(&ev) => return Some(ev),
|
||||
Ok(_) => {}
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {
|
||||
if Instant::now() >= deadline {
|
||||
return None;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the compositor selection from a "host app" (`wl-copy`, which forks a server that holds it).
|
||||
fn wl_copy(bytes: &[u8], mime: &str) {
|
||||
let mut child = Command::new("wl-copy")
|
||||
.arg("--type")
|
||||
.arg(mime)
|
||||
.stdin(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("spawn wl-copy");
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.unwrap()
|
||||
.write_all(bytes)
|
||||
.expect("write to wl-copy");
|
||||
let _ = child.wait(); // foreground exits; the fork keeps serving
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
}
|
||||
|
||||
fn open_or_skip() -> Option<(
|
||||
ClipboardBackend,
|
||||
tokio::sync::mpsc::UnboundedReceiver<ClipEvent>,
|
||||
)> {
|
||||
if Command::new("wl-copy").arg("--version").output().is_err() {
|
||||
eprintln!("SKIP: wl-clipboard not installed");
|
||||
return None;
|
||||
}
|
||||
match ClipboardBackend::open() {
|
||||
Ok(v) => Some(v),
|
||||
Err(e) => {
|
||||
eprintln!("SKIP: no data-control backend on this compositor: {e:#}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Host copy → we observe a `Selection` and can `read_current` the exact bytes back — both text
|
||||
/// and PNG (§3.5 format normalization end to end).
|
||||
#[test]
|
||||
#[ignore = "needs a live data-control compositor (WAYLAND_DISPLAY)"]
|
||||
fn live_host_copy_is_readable() {
|
||||
let Some((backend, mut rx)) = open_or_skip() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Text.
|
||||
wl_copy(b"hello-from-host-app", "text/plain;charset=utf-8");
|
||||
let ev = wait_event(&mut rx, Duration::from_secs(3), |e| {
|
||||
matches!(e, ClipEvent::Selection { mimes } if mimes.iter().any(|m| m == super::super::WIRE_TEXT))
|
||||
})
|
||||
.expect("Selection event carrying text after wl-copy");
|
||||
assert!(matches!(ev, ClipEvent::Selection { .. }));
|
||||
assert_eq!(
|
||||
backend.read_current(super::super::WIRE_TEXT).unwrap(),
|
||||
b"hello-from-host-app"
|
||||
);
|
||||
|
||||
// PNG (arbitrary bytes tagged image/png — data-control is format-agnostic).
|
||||
let png = b"\x89PNG\r\n\x1a\n-fake-but-tagged-image/png";
|
||||
wl_copy(png, "image/png");
|
||||
wait_event(&mut rx, Duration::from_secs(3), |e| {
|
||||
matches!(e, ClipEvent::Selection { mimes } if mimes.iter().any(|m| m == super::super::WIRE_PNG))
|
||||
})
|
||||
.expect("Selection event carrying image/png");
|
||||
assert_eq!(backend.read_current(super::super::WIRE_PNG).unwrap(), png);
|
||||
}
|
||||
|
||||
/// We install a client's offer as the host selection; a host app (`wl-paste`) pasting it fires a
|
||||
/// `Paste` event that we fulfill with bytes, and the host app receives exactly those bytes.
|
||||
#[test]
|
||||
#[ignore = "needs a live data-control compositor (WAYLAND_DISPLAY)"]
|
||||
fn live_set_offer_is_pasteable() {
|
||||
let Some((backend, mut rx)) = open_or_skip() else {
|
||||
return;
|
||||
};
|
||||
|
||||
backend
|
||||
.set_offer(&[super::super::WIRE_TEXT.to_string()])
|
||||
.expect("install offer");
|
||||
|
||||
// A host app pastes our offered selection.
|
||||
let child = Command::new("wl-paste")
|
||||
.arg("-n")
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("spawn wl-paste");
|
||||
|
||||
let paste = wait_event(&mut rx, Duration::from_secs(3), |e| {
|
||||
matches!(e, ClipEvent::Paste { .. })
|
||||
})
|
||||
.expect("Paste event after wl-paste reads our offer");
|
||||
match paste {
|
||||
ClipEvent::Paste { mime, responder } => {
|
||||
assert_eq!(
|
||||
mime,
|
||||
super::super::WIRE_TEXT,
|
||||
"paste requested the text format"
|
||||
);
|
||||
match responder {
|
||||
PasteResponder::Fd(fd) => {
|
||||
super::super::fulfill_paste(fd, b"served-by-punktfunk").expect("fulfill");
|
||||
}
|
||||
PasteResponder::Channel(_) => panic!("data-control paste must carry an fd"),
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
let out = child.wait_with_output().expect("wl-paste output");
|
||||
assert_eq!(out.stdout, b"served-by-punktfunk");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
//! Host-side shared-clipboard backend for Windows (`design/clipboard-and-file-transfer.md` §4, Phase
|
||||
//! 3). The Win32 clipboard is thread-affine and message-driven, so the whole backend lives on one
|
||||
//! dedicated **message-loop thread** owning a hidden message-only window:
|
||||
//!
|
||||
//! * **host copy → client** — `AddClipboardFormatListener` delivers `WM_CLIPBOARDUPDATE`; we map the
|
||||
//! available formats to wire MIMEs, cache them, and emit [`ClipEvent::Selection`]. Our own offers
|
||||
//! are suppressed by the owner-check (we never forward what we ourselves put on the clipboard).
|
||||
//! * **client fetch of the host clipboard** — a `Cmd::Read` reads the requested format's HGLOBAL and
|
||||
//! converts it to wire bytes ([`super::winfmt`]).
|
||||
//! * **client copy → host** — a `Cmd::SetOffer` installs the client's formats via OLE **delayed
|
||||
//! rendering**: `SetClipboardData(fmt, NULL)`, so no bytes cross until a host app actually pastes.
|
||||
//! * **host paste of client content** — the paste triggers `WM_RENDERFORMAT`; the message-loop thread
|
||||
//! blocks (bounded) while the coordinator fetches the bytes from the client, then `SetClipboardData`s
|
||||
//! them for the pasting app.
|
||||
//!
|
||||
//! The async coordinator drives the thread through a [`Cmd`] channel woken by `PostMessage(WM_APP_CMD)`
|
||||
//! (`PostMessage` is the documented thread-safe way to poke a message loop). Per-window state hangs
|
||||
//! off `GWLP_USERDATA`, so multiple concurrent sessions each get their own window + state.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context as _;
|
||||
|
||||
use ::windows::core::{w, PCWSTR};
|
||||
use ::windows::Win32::Foundation::{
|
||||
GetLastError, GlobalFree, HANDLE, HGLOBAL, HINSTANCE, HWND, LPARAM, LRESULT, WPARAM,
|
||||
};
|
||||
use ::windows::Win32::System::DataExchange::{
|
||||
AddClipboardFormatListener, CloseClipboard, EmptyClipboard, GetClipboardData,
|
||||
GetClipboardOwner, IsClipboardFormatAvailable, OpenClipboard, RegisterClipboardFormatW,
|
||||
SetClipboardData,
|
||||
};
|
||||
use ::windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use ::windows::Win32::System::Memory::{
|
||||
GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, GMEM_MOVEABLE, GMEM_ZEROINIT,
|
||||
};
|
||||
use ::windows::Win32::System::Ole::CF_UNICODETEXT;
|
||||
use ::windows::Win32::UI::WindowsAndMessaging::{
|
||||
CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetMessageW,
|
||||
GetWindowLongPtrW, PostMessageW, PostQuitMessage, RegisterClassW, SetWindowLongPtrW,
|
||||
TranslateMessage, GWLP_USERDATA, HWND_MESSAGE, MSG, WINDOW_EX_STYLE, WINDOW_STYLE, WM_APP,
|
||||
WM_CLIPBOARDUPDATE, WM_DESTROY, WM_RENDERFORMAT, WNDCLASSW,
|
||||
};
|
||||
|
||||
use super::winfmt;
|
||||
use super::{ClipEvent, PasteResponder, WIRE_HTML, WIRE_PNG, WIRE_RTF, WIRE_TEXT};
|
||||
|
||||
/// Custom app message that wakes the pump to drain the [`Cmd`] channel.
|
||||
const WM_APP_CMD: u32 = WM_APP + 1;
|
||||
/// Upper bound the message-loop thread waits for the client's bytes during a `WM_RENDERFORMAT` paste.
|
||||
/// The pasting app is frozen until we answer, so this caps how long a paste can hang; on expiry the
|
||||
/// format is left unrendered (an empty paste) rather than blocking indefinitely.
|
||||
const RENDER_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// `OpenClipboard` fails while another process transiently holds the clipboard (clipboard managers do
|
||||
/// this constantly); retry briefly before giving up.
|
||||
const OPEN_RETRIES: u32 = 20;
|
||||
const OPEN_RETRY_DELAY: Duration = Duration::from_millis(5);
|
||||
|
||||
/// `RegisterClassW` returns this when the (process-global) class already exists — expected on the 2nd+
|
||||
/// concurrent session, and not an error (we never unregister the class).
|
||||
const ERROR_CLASS_ALREADY_EXISTS: u32 = 1410;
|
||||
|
||||
type ClipTx = tokio::sync::mpsc::UnboundedSender<ClipEvent>;
|
||||
|
||||
/// A command from the async coordinator into the message-loop thread. Delivered over a tokio channel
|
||||
/// and drained on `WM_APP_CMD`.
|
||||
enum Cmd {
|
||||
/// Install the client's wire MIMEs as a delayed-render host selection (empty ⇒ clear).
|
||||
SetOffer(Vec<String>),
|
||||
/// Drop the selection we own.
|
||||
Clear,
|
||||
/// Read one wire format of the current host selection for a client fetch.
|
||||
Read {
|
||||
wire: String,
|
||||
resp: tokio::sync::oneshot::Sender<anyhow::Result<Vec<u8>>>,
|
||||
},
|
||||
/// Tear the window + thread down.
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
/// Message-loop-thread-owned state, reached from the `WndProc` via `GWLP_USERDATA`. Only the message
|
||||
/// thread ever dereferences it, so the `RefCell`s are sound (no cross-thread sharing); the fields the
|
||||
/// async handle also touches (`current_wire`) are behind their own `Arc<Mutex>`.
|
||||
struct WinClip {
|
||||
/// Backend → coordinator events.
|
||||
clip_tx: ClipTx,
|
||||
/// The current host selection's wire MIMEs, shared with the [`WindowsClipboard`] handle.
|
||||
current_wire: Arc<Mutex<Vec<String>>>,
|
||||
/// Coordinator → backend commands, drained on `WM_APP_CMD`.
|
||||
cmd_rx: RefCell<tokio::sync::mpsc::UnboundedReceiver<Cmd>>,
|
||||
/// Clipboard format ids we currently promise via delayed rendering (for `WM_RENDERFORMAT`).
|
||||
offered: RefCell<Vec<u32>>,
|
||||
fmt_html: u32,
|
||||
fmt_rtf: u32,
|
||||
fmt_png: u32,
|
||||
/// Our own message window — used for the owner-check and clipboard opens.
|
||||
own_hwnd: HWND,
|
||||
}
|
||||
|
||||
impl WinClip {
|
||||
/// `WM_CLIPBOARDUPDATE`: a host app copied (or the clipboard was cleared). Suppress our own
|
||||
/// delayed-render echoes via the owner-check, else announce the new wire MIMEs.
|
||||
fn on_clipboard_update(&self, hwnd: HWND) {
|
||||
// SAFETY: GetClipboardOwner has no preconditions and needs no open clipboard.
|
||||
let owner = unsafe { GetClipboardOwner() }.unwrap_or_default();
|
||||
if owner.0 == hwnd.0 {
|
||||
// Our own offer's echo (we own the clipboard) — not a host copy.
|
||||
return;
|
||||
}
|
||||
let mimes = self.available_wire_mimes();
|
||||
*self.current_wire.lock().unwrap() = mimes.clone();
|
||||
let _ = self.clip_tx.send(ClipEvent::Selection { mimes });
|
||||
}
|
||||
|
||||
/// The wire MIMEs the current clipboard advertises, in a stable order.
|
||||
fn available_wire_mimes(&self) -> Vec<String> {
|
||||
// SAFETY: IsClipboardFormatAvailable has no preconditions and needs no open clipboard.
|
||||
let avail = |fmt: u32| unsafe { IsClipboardFormatAvailable(fmt) }.is_ok();
|
||||
let mut out = Vec::new();
|
||||
if avail(CF_UNICODETEXT.0 as u32) {
|
||||
out.push(WIRE_TEXT.to_string());
|
||||
}
|
||||
if avail(self.fmt_html) {
|
||||
out.push(WIRE_HTML.to_string());
|
||||
}
|
||||
if avail(self.fmt_rtf) {
|
||||
out.push(WIRE_RTF.to_string());
|
||||
}
|
||||
if avail(self.fmt_png) {
|
||||
out.push(WIRE_PNG.to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `WM_APP_CMD`: run every queued coordinator command on this thread. Drained into a `Vec` first so
|
||||
/// the `cmd_rx` borrow is released before any command runs (defensive against re-entry).
|
||||
fn drain_commands(&self, hwnd: HWND) {
|
||||
let mut cmds = Vec::new();
|
||||
{
|
||||
let mut rx = self.cmd_rx.borrow_mut();
|
||||
while let Ok(c) = rx.try_recv() {
|
||||
cmds.push(c);
|
||||
}
|
||||
}
|
||||
for c in cmds {
|
||||
match c {
|
||||
Cmd::SetOffer(wire) => self.apply_offer(hwnd, &wire),
|
||||
Cmd::Clear => self.clear(hwnd),
|
||||
Cmd::Read { wire, resp } => {
|
||||
let _ = resp.send(self.read(&wire));
|
||||
}
|
||||
Cmd::Shutdown => {
|
||||
// Drop our offer first so no WM_RENDERALLFORMATS fires as the window dies (we do
|
||||
// NOT want the client's content to outlive the session on the host clipboard).
|
||||
self.clear(hwnd);
|
||||
// SAFETY: our own live window; triggers WM_DESTROY → PostQuitMessage → pump exit.
|
||||
unsafe {
|
||||
let _ = DestroyWindow(hwnd);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the client's offer as a delayed-render host selection.
|
||||
fn apply_offer(&self, hwnd: HWND, wire: &[String]) {
|
||||
let fmts = self.formats_for_offer(wire);
|
||||
if fmts.is_empty() {
|
||||
self.clear(hwnd);
|
||||
return;
|
||||
}
|
||||
if open_clipboard_retry(hwnd).is_err() {
|
||||
tracing::debug!("clipboard: OpenClipboard for set_offer failed");
|
||||
return;
|
||||
}
|
||||
let _guard = ClipboardGuard;
|
||||
// SAFETY: the clipboard is open (ClipboardGuard closes it); EmptyClipboard makes us the owner,
|
||||
// then each SetClipboardData(_, None) registers a delayed-render promise for that format.
|
||||
unsafe {
|
||||
let _ = EmptyClipboard();
|
||||
for &f in &fmts {
|
||||
let _ = SetClipboardData(f, None);
|
||||
}
|
||||
}
|
||||
*self.offered.borrow_mut() = fmts;
|
||||
}
|
||||
|
||||
/// Drop the selection we own (empty the clipboard iff we're still its owner).
|
||||
fn clear(&self, hwnd: HWND) {
|
||||
let had = {
|
||||
let mut o = self.offered.borrow_mut();
|
||||
let was = !o.is_empty();
|
||||
o.clear();
|
||||
was
|
||||
};
|
||||
if !had {
|
||||
return;
|
||||
}
|
||||
// SAFETY: GetClipboardOwner has no preconditions.
|
||||
let owner = unsafe { GetClipboardOwner() }.unwrap_or_default();
|
||||
if owner.0 != hwnd.0 {
|
||||
return; // someone else took the clipboard already
|
||||
}
|
||||
if open_clipboard_retry(hwnd).is_err() {
|
||||
return;
|
||||
}
|
||||
let _guard = ClipboardGuard;
|
||||
// SAFETY: the clipboard is open (ClipboardGuard closes it); empty it to drop our promises.
|
||||
unsafe {
|
||||
let _ = EmptyClipboard();
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one wire format of the current host selection (a client fetch).
|
||||
fn read(&self, wire: &str) -> anyhow::Result<Vec<u8>> {
|
||||
let fmt = self
|
||||
.format_for_wire(wire)
|
||||
.context("unsupported wire MIME")?;
|
||||
// If we own the clipboard, its content is our own delayed-render offer (the client's copy),
|
||||
// not a host selection — declining avoids GetClipboardData re-entering our own WM_RENDERFORMAT.
|
||||
// SAFETY: GetClipboardOwner has no preconditions.
|
||||
if unsafe { GetClipboardOwner() }.unwrap_or_default().0 == self.own_hwnd.0 {
|
||||
anyhow::bail!("clipboard currently held by our own offer");
|
||||
}
|
||||
open_clipboard_retry(self.own_hwnd)?;
|
||||
let _guard = ClipboardGuard;
|
||||
// SAFETY: the clipboard is open (ClipboardGuard closes it). GetClipboardData hands back a
|
||||
// clipboard-owned HGLOBAL (we must NOT free it); GlobalLock/Size/Unlock are balanced and we
|
||||
// copy exactly GlobalSize bytes out before the lock is released.
|
||||
let raw = unsafe {
|
||||
let handle = GetClipboardData(fmt).context("GetClipboardData")?;
|
||||
let hg = HGLOBAL(handle.0);
|
||||
let p = GlobalLock(hg);
|
||||
if p.is_null() {
|
||||
anyhow::bail!("GlobalLock failed");
|
||||
}
|
||||
let n = GlobalSize(hg);
|
||||
let mut buf = vec![0u8; n];
|
||||
std::ptr::copy_nonoverlapping(p as *const u8, buf.as_mut_ptr(), n);
|
||||
let _ = GlobalUnlock(hg);
|
||||
buf
|
||||
};
|
||||
Ok(convert_from_win(wire, &raw))
|
||||
}
|
||||
|
||||
/// `WM_RENDERFORMAT`: a host app is pasting a format we promised. Fetch the bytes from the client
|
||||
/// (blocking this thread, bounded) and `SetClipboardData` them for the paster.
|
||||
fn on_render_format(&self, fmt: u32) {
|
||||
let Some(wire) = self.wire_for_format(fmt) else {
|
||||
return;
|
||||
};
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
||||
let ev = ClipEvent::Paste {
|
||||
mime: wire.to_string(),
|
||||
responder: PasteResponder::Sync(tx),
|
||||
};
|
||||
if self.clip_tx.send(ev).is_err() {
|
||||
return; // coordinator gone
|
||||
}
|
||||
let bytes = match rx.recv_timeout(RENDER_TIMEOUT) {
|
||||
Ok(b) => b,
|
||||
Err(_) => return, // timeout / dropped → leave the format unrendered (empty paste)
|
||||
};
|
||||
let win_bytes = convert_to_win(wire, &bytes);
|
||||
let Ok(hg) = alloc_hglobal(&win_bytes) else {
|
||||
return;
|
||||
};
|
||||
// Do NOT OpenClipboard here — the pasting app already holds it open across WM_RENDERFORMAT.
|
||||
// SAFETY: `hg` is a freshly-filled moveable HGLOBAL. On success the clipboard takes ownership
|
||||
// (we must not free it); on failure ownership stays with us, so we free it.
|
||||
unsafe {
|
||||
if SetClipboardData(fmt, Some(HANDLE(hg.0))).is_err() {
|
||||
let _ = GlobalFree(Some(hg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Win32 clipboard format id for a wire MIME (`None` = unsupported).
|
||||
fn format_for_wire(&self, wire: &str) -> Option<u32> {
|
||||
match wire {
|
||||
WIRE_TEXT => Some(CF_UNICODETEXT.0 as u32),
|
||||
WIRE_HTML => Some(self.fmt_html),
|
||||
WIRE_RTF => Some(self.fmt_rtf),
|
||||
WIRE_PNG => Some(self.fmt_png),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The wire MIME for a Win32 clipboard format id (`None` = one we don't offer).
|
||||
fn wire_for_format(&self, fmt: u32) -> Option<&'static str> {
|
||||
if fmt == CF_UNICODETEXT.0 as u32 {
|
||||
Some(WIRE_TEXT)
|
||||
} else if fmt == self.fmt_html {
|
||||
Some(WIRE_HTML)
|
||||
} else if fmt == self.fmt_rtf {
|
||||
Some(WIRE_RTF)
|
||||
} else if fmt == self.fmt_png {
|
||||
Some(WIRE_PNG)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The clipboard format ids to promise for a client offer (dedup, 1:1 with the wire MIMEs — the OS
|
||||
/// auto-synthesizes CF_TEXT/CF_OEMTEXT from CF_UNICODETEXT, so no manual text fan-out is needed).
|
||||
fn formats_for_offer(&self, wire: &[String]) -> Vec<u32> {
|
||||
let mut out = Vec::new();
|
||||
for w in wire {
|
||||
if let Some(f) = self.format_for_wire(w) {
|
||||
if !out.contains(&f) {
|
||||
out.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The active Windows clipboard backend handle held by [`super::HostClipboard`]. All Win32 work runs
|
||||
/// on the message-loop thread; this is just the async-side control surface.
|
||||
pub struct WindowsClipboard {
|
||||
cmd_tx: tokio::sync::mpsc::UnboundedSender<Cmd>,
|
||||
/// The message window's `HWND` as an `isize` (so the handle stays `Send`/`Sync`); rebuilt for the
|
||||
/// `PostMessage` wakeups, which are documented thread-safe.
|
||||
hwnd: isize,
|
||||
current_wire: Arc<Mutex<Vec<String>>>,
|
||||
join: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl WindowsClipboard {
|
||||
/// Spin up the message-loop thread + hidden window and return once it has bound (or failed).
|
||||
pub async fn open() -> anyhow::Result<(
|
||||
WindowsClipboard,
|
||||
tokio::sync::mpsc::UnboundedReceiver<ClipEvent>,
|
||||
)> {
|
||||
let (clip_tx, clip_rx) = tokio::sync::mpsc::unbounded_channel::<ClipEvent>();
|
||||
let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<Cmd>();
|
||||
let current_wire = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
// Register the three custom formats up front — process-global and thread-agnostic, so this is
|
||||
// fine off the message thread and lets bring-up fail cleanly if the atoms can't be created.
|
||||
let fmt_html = register_format(w!("HTML Format"))?;
|
||||
let fmt_rtf = register_format(w!("Rich Text Format"))?;
|
||||
let fmt_png = register_format(w!("PNG"))?;
|
||||
|
||||
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<anyhow::Result<isize>>();
|
||||
let cw = Arc::clone(¤t_wire);
|
||||
let join = std::thread::Builder::new()
|
||||
.name("punktfunk-clipboard-win".into())
|
||||
.spawn(move || pump_thread(clip_tx, cmd_rx, cw, fmt_html, fmt_rtf, fmt_png, ready_tx))
|
||||
.context("spawn windows clipboard thread")?;
|
||||
|
||||
let hwnd = match tokio::time::timeout(Duration::from_secs(3), ready_rx).await {
|
||||
Ok(Ok(Ok(h))) => h,
|
||||
Ok(Ok(Err(e))) => return Err(e),
|
||||
Ok(Err(_)) => anyhow::bail!("windows clipboard thread exited during bring-up"),
|
||||
Err(_) => anyhow::bail!("windows clipboard bring-up timed out"),
|
||||
};
|
||||
|
||||
Ok((
|
||||
WindowsClipboard {
|
||||
cmd_tx,
|
||||
hwnd,
|
||||
current_wire,
|
||||
join: Some(join),
|
||||
},
|
||||
clip_rx,
|
||||
))
|
||||
}
|
||||
|
||||
/// The current host selection's wire MIMEs (empty = nothing to offer).
|
||||
pub fn current_wire_mimes(&self) -> Vec<String> {
|
||||
self.current_wire.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Install a client's offered formats as the host selection (fire-and-forget onto the thread).
|
||||
pub fn set_offer(&self, wire_mimes: &[String]) {
|
||||
let _ = self.cmd_tx.send(Cmd::SetOffer(wire_mimes.to_vec()));
|
||||
self.wake();
|
||||
}
|
||||
|
||||
/// Drop the host selection we own (fire-and-forget onto the thread).
|
||||
pub fn clear_offer(&self) {
|
||||
let _ = self.cmd_tx.send(Cmd::Clear);
|
||||
self.wake();
|
||||
}
|
||||
|
||||
/// Read one wire format of the current host selection (a client's fetch).
|
||||
pub async fn read_current(&self, wire_mime: &str) -> anyhow::Result<Vec<u8>> {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
self.cmd_tx
|
||||
.send(Cmd::Read {
|
||||
wire: wire_mime.to_string(),
|
||||
resp: tx,
|
||||
})
|
||||
.map_err(|_| anyhow::anyhow!("clipboard thread gone"))?;
|
||||
self.wake();
|
||||
rx.await
|
||||
.map_err(|_| anyhow::anyhow!("clipboard read dropped"))?
|
||||
}
|
||||
|
||||
/// Poke the message loop so it drains the command channel.
|
||||
fn wake(&self) {
|
||||
// SAFETY: PostMessageW is documented thread-safe; `hwnd` is our message window (or already
|
||||
// destroyed, in which case the post harmlessly fails and is ignored).
|
||||
let _ = unsafe {
|
||||
PostMessageW(
|
||||
Some(HWND(self.hwnd as *mut core::ffi::c_void)),
|
||||
WM_APP_CMD,
|
||||
WPARAM(0),
|
||||
LPARAM(0),
|
||||
)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WindowsClipboard {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.cmd_tx.send(Cmd::Shutdown);
|
||||
self.wake();
|
||||
if let Some(j) = self.join.take() {
|
||||
let _ = j.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII `CloseClipboard` guard — pairs with a successful `open_clipboard_retry`, closing on scope exit
|
||||
/// (including early `?`/`bail!` returns).
|
||||
struct ClipboardGuard;
|
||||
|
||||
impl Drop for ClipboardGuard {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: constructed only after a successful OpenClipboard on this thread.
|
||||
unsafe {
|
||||
let _ = CloseClipboard();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Register (or resolve the existing id of) a custom clipboard format.
|
||||
fn register_format(name: PCWSTR) -> anyhow::Result<u32> {
|
||||
// SAFETY: RegisterClipboardFormatW is thread-agnostic and process-global; `name` is a static
|
||||
// NUL-terminated wide literal.
|
||||
let id = unsafe { RegisterClipboardFormatW(name) };
|
||||
if id == 0 {
|
||||
anyhow::bail!("RegisterClipboardFormatW failed");
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Allocate a moveable HGLOBAL holding `bytes` (zero-init so an empty payload is still a valid, locked
|
||||
/// buffer). Ownership is transferred to the clipboard by a following `SetClipboardData`.
|
||||
fn alloc_hglobal(bytes: &[u8]) -> anyhow::Result<HGLOBAL> {
|
||||
// SAFETY: allocate at least one byte (GlobalLock of a 0-size block is unreliable), lock it, copy
|
||||
// the payload in, unlock. Alloc/lock/unlock are balanced; on lock failure we free before erroring.
|
||||
unsafe {
|
||||
let hg = GlobalAlloc(GMEM_MOVEABLE | GMEM_ZEROINIT, bytes.len().max(1))
|
||||
.context("GlobalAlloc")?;
|
||||
let p = GlobalLock(hg);
|
||||
if p.is_null() {
|
||||
let _ = GlobalFree(Some(hg));
|
||||
anyhow::bail!("GlobalLock failed");
|
||||
}
|
||||
std::ptr::copy_nonoverlapping(bytes.as_ptr(), p as *mut u8, bytes.len());
|
||||
let _ = GlobalUnlock(hg);
|
||||
Ok(hg)
|
||||
}
|
||||
}
|
||||
|
||||
/// `OpenClipboard(hwnd)` with a brief retry loop (another process often holds it transiently).
|
||||
fn open_clipboard_retry(hwnd: HWND) -> anyhow::Result<()> {
|
||||
for _ in 0..OPEN_RETRIES {
|
||||
// SAFETY: OpenClipboard with our window as owner; balanced by ClipboardGuard/CloseClipboard.
|
||||
if unsafe { OpenClipboard(Some(hwnd)) }.is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(OPEN_RETRY_DELAY);
|
||||
}
|
||||
anyhow::bail!("OpenClipboard failed after retries")
|
||||
}
|
||||
|
||||
/// Convert a Win32 clipboard payload to wire bytes.
|
||||
fn convert_from_win(wire: &str, raw: &[u8]) -> Vec<u8> {
|
||||
match wire {
|
||||
WIRE_TEXT => winfmt::text_from_utf16(raw),
|
||||
WIRE_HTML => winfmt::html_from_cf(raw),
|
||||
WIRE_RTF => winfmt::rtf_from_cf(raw),
|
||||
_ => raw.to_vec(), // PNG + anything else: verbatim
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert wire bytes to a Win32 clipboard payload.
|
||||
fn convert_to_win(wire: &str, wire_bytes: &[u8]) -> Vec<u8> {
|
||||
match wire {
|
||||
WIRE_TEXT => winfmt::text_to_utf16(wire_bytes),
|
||||
WIRE_HTML => winfmt::html_to_cf(wire_bytes),
|
||||
_ => wire_bytes.to_vec(), // RTF + PNG + anything else: verbatim
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the hidden message-only window (registering the class once, process-wide).
|
||||
fn create_window() -> anyhow::Result<HWND> {
|
||||
// SAFETY: standard window-class registration + message-only window creation; every argument is a
|
||||
// valid handle / static literal, and `wndproc` matches the WNDPROC ABI.
|
||||
unsafe {
|
||||
let hinstance: HINSTANCE = GetModuleHandleW(PCWSTR::null())
|
||||
.context("GetModuleHandleW")?
|
||||
.into();
|
||||
let class_name = w!("PunktfunkClipboardWindow");
|
||||
let wc = WNDCLASSW {
|
||||
lpfnWndProc: Some(wndproc),
|
||||
hInstance: hinstance,
|
||||
lpszClassName: class_name,
|
||||
..Default::default()
|
||||
};
|
||||
if RegisterClassW(&wc) == 0 {
|
||||
let code = GetLastError();
|
||||
if code.0 != ERROR_CLASS_ALREADY_EXISTS {
|
||||
anyhow::bail!("RegisterClassW failed: {code:?}");
|
||||
}
|
||||
}
|
||||
let hwnd = CreateWindowExW(
|
||||
WINDOW_EX_STYLE(0),
|
||||
class_name,
|
||||
w!(""),
|
||||
WINDOW_STYLE(0),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
Some(HWND_MESSAGE),
|
||||
None,
|
||||
Some(hinstance),
|
||||
None,
|
||||
)
|
||||
.context("CreateWindowExW")?;
|
||||
Ok(hwnd)
|
||||
}
|
||||
}
|
||||
|
||||
/// The message-loop thread body: build the window, wire up state, then pump until `WM_QUIT`.
|
||||
fn pump_thread(
|
||||
clip_tx: ClipTx,
|
||||
cmd_rx: tokio::sync::mpsc::UnboundedReceiver<Cmd>,
|
||||
current_wire: Arc<Mutex<Vec<String>>>,
|
||||
fmt_html: u32,
|
||||
fmt_rtf: u32,
|
||||
fmt_png: u32,
|
||||
ready_tx: tokio::sync::oneshot::Sender<anyhow::Result<isize>>,
|
||||
) {
|
||||
let hwnd = match create_window() {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
let _ = ready_tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// A clone that outlives the boxed state, so we can announce Closed after the pump ends.
|
||||
let closed_tx = clip_tx.clone();
|
||||
|
||||
let state = Box::new(WinClip {
|
||||
clip_tx,
|
||||
current_wire,
|
||||
cmd_rx: RefCell::new(cmd_rx),
|
||||
offered: RefCell::new(Vec::new()),
|
||||
fmt_html,
|
||||
fmt_rtf,
|
||||
fmt_png,
|
||||
own_hwnd: hwnd,
|
||||
});
|
||||
let ptr = Box::into_raw(state);
|
||||
// SAFETY: stash the state pointer for the WndProc; the window was created on this thread and the
|
||||
// pointer stays valid until we reclaim the Box after the pump exits.
|
||||
unsafe {
|
||||
SetWindowLongPtrW(hwnd, GWLP_USERDATA, ptr as isize);
|
||||
}
|
||||
|
||||
// Snapshot whatever is already on the host clipboard, so the first client `enable` announces it
|
||||
// (AddClipboardFormatListener only delivers *subsequent* changes).
|
||||
{
|
||||
// SAFETY: `ptr` is the live state we just stored; only this thread dereferences it.
|
||||
let st = unsafe { &*ptr };
|
||||
*st.current_wire.lock().unwrap() = st.available_wire_mimes();
|
||||
}
|
||||
|
||||
// SAFETY: `hwnd` is our live window; start receiving WM_CLIPBOARDUPDATE.
|
||||
if let Err(e) = unsafe { AddClipboardFormatListener(hwnd) } {
|
||||
// SAFETY: tear down the half-built window and reclaim the leaked state box.
|
||||
unsafe {
|
||||
let _ = DestroyWindow(hwnd);
|
||||
drop(Box::from_raw(ptr));
|
||||
}
|
||||
let _ = ready_tx.send(Err(
|
||||
anyhow::Error::new(e).context("AddClipboardFormatListener")
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = ready_tx.send(Ok(hwnd.0 as isize));
|
||||
|
||||
// SAFETY: the standard Win32 message pump. GetMessageW returns >0 for a message, 0 for WM_QUIT,
|
||||
// and -1 on error — `.0 > 0` exits on both 0 and -1.
|
||||
unsafe {
|
||||
let mut msg = MSG::default();
|
||||
while GetMessageW(&mut msg, None, 0, 0).0 > 0 {
|
||||
let _ = TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Pump exited (window destroyed): reclaim the leaked state box. No WndProc runs after this point.
|
||||
// SAFETY: `ptr` came from Box::into_raw above, is dereferenced only on this thread, and the
|
||||
// message loop has ended so no further access occurs.
|
||||
unsafe {
|
||||
drop(Box::from_raw(ptr));
|
||||
}
|
||||
let _ = closed_tx.send(ClipEvent::Closed);
|
||||
}
|
||||
|
||||
/// The window procedure. Reaches per-window state through `GWLP_USERDATA`; runs only on the message
|
||||
/// thread. Registered as the class `WNDPROC` (a safe fn coerces to the `unsafe extern "system"` ABI).
|
||||
extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||
// SAFETY: GWLP_USERDATA holds the `*const WinClip` stored right after window creation (0/null for
|
||||
// the WM_(NC)CREATE messages that fire before that — handled by the null check below).
|
||||
let ptr = unsafe { GetWindowLongPtrW(hwnd, GWLP_USERDATA) } as *const WinClip;
|
||||
if ptr.is_null() {
|
||||
// SAFETY: default processing before our state pointer is attached.
|
||||
return unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) };
|
||||
}
|
||||
// SAFETY: `ptr` is the live Box<WinClip> leaked in pump_thread, owned by this (the only) message
|
||||
// thread and freed only after the pump exits; the WndProc is not re-entered for this window, so
|
||||
// `&*ptr` is a valid shared borrow.
|
||||
let st = unsafe { &*ptr };
|
||||
match msg {
|
||||
WM_CLIPBOARDUPDATE => {
|
||||
st.on_clipboard_update(hwnd);
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_RENDERFORMAT => {
|
||||
st.on_render_format(wparam.0 as u32);
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_APP_CMD => {
|
||||
st.drain_commands(hwnd);
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_DESTROY => {
|
||||
// SAFETY: ends the GetMessageW pump by posting WM_QUIT to this thread's queue.
|
||||
unsafe {
|
||||
PostQuitMessage(0);
|
||||
}
|
||||
LRESULT(0)
|
||||
}
|
||||
// SAFETY: default handling for every other message.
|
||||
_ => unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
//! Pure byte conversions between the Win32 clipboard formats and the portable wire MIMEs
|
||||
//! (`design/clipboard-and-file-transfer.md` §3.5). Kept free of any `windows`-crate dependency so it
|
||||
//! compiles on every host and its unit tests exercise the fiddly bits (CF_HTML offset math, UTF-16
|
||||
//! (de)serialization) without a Windows box. The [`super::windows`] backend is the only production
|
||||
//! consumer; it wraps these with the actual `GetClipboardData`/`SetClipboardData` calls.
|
||||
//!
|
||||
//! Format map (Win32 ↔ wire):
|
||||
//! * `CF_UNICODETEXT` (UTF-16LE + NUL) ↔ `text/plain;charset=utf-8`
|
||||
//! * `"HTML Format"` (CF_HTML, UTF-8 + ASCII header) ↔ `text/html`
|
||||
//! * `"Rich Text Format"` (raw RTF) ↔ `text/rtf`
|
||||
//! * `"PNG"` (raw PNG) ↔ `image/png` — identity, handled inline by the backend.
|
||||
|
||||
// ---- CF_UNICODETEXT ↔ text/plain;charset=utf-8 -----------------------------------------------
|
||||
|
||||
/// `CF_UNICODETEXT` HGLOBAL bytes → UTF-8 wire bytes. `raw` is the exact `GlobalSize`-length buffer;
|
||||
/// it holds little-endian UTF-16 code units terminated by a single `0x0000`.
|
||||
pub fn text_from_utf16(raw: &[u8]) -> Vec<u8> {
|
||||
// Reinterpret each LE 2-byte pair as a UTF-16 code unit; a stray odd trailing byte (never present
|
||||
// in valid data) is dropped by `chunks_exact`.
|
||||
let mut units: Vec<u16> = raw
|
||||
.chunks_exact(2)
|
||||
.map(|c| u16::from_le_bytes([c[0], c[1]]))
|
||||
.collect();
|
||||
// Strip exactly one trailing NUL terminator if present (guard against eating a real code unit).
|
||||
if units.last() == Some(&0) {
|
||||
units.pop();
|
||||
}
|
||||
String::from_utf16_lossy(&units).into_bytes()
|
||||
}
|
||||
|
||||
/// UTF-8 wire bytes → `CF_UNICODETEXT` HGLOBAL bytes (UTF-16LE + a required `0x0000` terminator).
|
||||
pub fn text_to_utf16(wire: &[u8]) -> Vec<u8> {
|
||||
let s = String::from_utf8_lossy(wire);
|
||||
let mut out = Vec::with_capacity(wire.len() * 2 + 2);
|
||||
for u in s.encode_utf16() {
|
||||
out.extend_from_slice(&u.to_le_bytes());
|
||||
}
|
||||
out.extend_from_slice(&0u16.to_le_bytes()); // REQUIRED NUL terminator for CF_UNICODETEXT
|
||||
out
|
||||
}
|
||||
|
||||
// ---- "HTML Format" (CF_HTML) ↔ text/html -----------------------------------------------------
|
||||
//
|
||||
// CF_HTML is UTF-8: an ASCII `Key:Value\r\n` header carrying byte offsets, then the HTML with
|
||||
// `<!--StartFragment-->`/`<!--EndFragment-->` markers. Offsets are byte counts from buffer start;
|
||||
// the offsets live *inside* the header, so their digit-width feeds back into the header length. The
|
||||
// spec-blessed fix (Chromium/Firefox/LibreOffice) is fixed-width 10-digit zero-padded offsets, which
|
||||
// makes the header a compile-time constant and every offset a one-pass computation.
|
||||
|
||||
const CF_HTML_HEADER: &str = "Version:0.9\r\n\
|
||||
StartHTML:0000000000\r\n\
|
||||
EndHTML:0000000000\r\n\
|
||||
StartFragment:0000000000\r\n\
|
||||
EndFragment:0000000000\r\n";
|
||||
const CF_HTML_PREFIX: &str = "<html><body>\r\n<!--StartFragment-->";
|
||||
const CF_HTML_SUFFIX: &str = "<!--EndFragment-->\r\n</body></html>";
|
||||
|
||||
/// UTF-8 HTML fragment (wire bytes) → a `CF_HTML` buffer, NUL-terminated. The trailing NUL is the
|
||||
/// conventional CF_HTML expectation (§4); `EndHTML` still points at content end, before the NUL.
|
||||
pub fn html_to_cf(wire: &[u8]) -> Vec<u8> {
|
||||
let fragment = String::from_utf8_lossy(wire);
|
||||
let start_html = CF_HTML_HEADER.len(); // 105
|
||||
let start_fragment = start_html + CF_HTML_PREFIX.len(); // 139
|
||||
let end_fragment = start_fragment + fragment.len(); // byte length — fragment may be multibyte
|
||||
let end_html = end_fragment + CF_HTML_SUFFIX.len();
|
||||
|
||||
let mut buf = Vec::with_capacity(end_html + 1);
|
||||
buf.extend_from_slice(CF_HTML_HEADER.as_bytes());
|
||||
buf.extend_from_slice(CF_HTML_PREFIX.as_bytes());
|
||||
buf.extend_from_slice(fragment.as_bytes());
|
||||
buf.extend_from_slice(CF_HTML_SUFFIX.as_bytes());
|
||||
|
||||
// Overwrite the four zero-padded fields in place, restricting the search to the header region so a
|
||||
// fragment that happens to contain "StartHTML:" can't fool the patcher.
|
||||
patch_offset(&mut buf[..start_html], b"StartHTML:", start_html);
|
||||
patch_offset(&mut buf[..start_html], b"EndHTML:", end_html);
|
||||
patch_offset(&mut buf[..start_html], b"StartFragment:", start_fragment);
|
||||
patch_offset(&mut buf[..start_html], b"EndFragment:", end_fragment);
|
||||
|
||||
buf.push(0); // conventional NUL terminator
|
||||
buf
|
||||
}
|
||||
|
||||
/// A `CF_HTML` buffer → the UTF-8 HTML fragment (wire bytes). Uses the `StartFragment`/`EndFragment`
|
||||
/// offsets; falls back to `StartHTML`/`EndHTML`, then to the whole buffer, if the markers are absent.
|
||||
pub fn html_from_cf(raw: &[u8]) -> Vec<u8> {
|
||||
let range = header_range(raw, b"StartFragment:", b"EndFragment:")
|
||||
.or_else(|| header_range(raw, b"StartHTML:", b"EndHTML:"));
|
||||
match range {
|
||||
// Content is UTF-8 per spec; return the exact slice (drop any trailing NUL for cleanliness).
|
||||
Some((start, end)) => {
|
||||
let slice = &raw[start..end];
|
||||
strip_trailing_nul(slice).to_vec()
|
||||
}
|
||||
None => strip_trailing_nul(raw).to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve `[start_label .. end_label]` into a validated byte range within `raw`.
|
||||
fn header_range(raw: &[u8], start_label: &[u8], end_label: &[u8]) -> Option<(usize, usize)> {
|
||||
let start = read_header_offset(raw, start_label)?;
|
||||
let end = read_header_offset(raw, end_label)?;
|
||||
if start <= end && end <= raw.len() {
|
||||
Some((start, end))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Overwrite the 10 ASCII digits following `label` in `header` with `value`, zero-padded.
|
||||
fn patch_offset(header: &mut [u8], label: &[u8], value: usize) {
|
||||
if let Some(pos) = find(header, label) {
|
||||
let at = pos + label.len();
|
||||
if at + 10 <= header.len() {
|
||||
let digits = format!("{value:010}");
|
||||
header[at..at + 10].copy_from_slice(digits.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the decimal integer following `label:` in the ASCII header. The colon-suffixed labels only
|
||||
/// match in the header, never the marker comments (`<!--StartFragment-->`) or fragment text.
|
||||
fn read_header_offset(raw: &[u8], label: &[u8]) -> Option<usize> {
|
||||
let mut at = find(raw, label)? + label.len();
|
||||
let mut n: usize = 0;
|
||||
let mut any = false;
|
||||
while let Some(&b) = raw.get(at) {
|
||||
if b.is_ascii_digit() {
|
||||
n = n.checked_mul(10)?.checked_add((b - b'0') as usize)?;
|
||||
any = true;
|
||||
at += 1;
|
||||
} else {
|
||||
break; // stops at '\r'
|
||||
}
|
||||
}
|
||||
any.then_some(n)
|
||||
}
|
||||
|
||||
fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
if needle.is_empty() || needle.len() > hay.len() {
|
||||
return None;
|
||||
}
|
||||
hay.windows(needle.len()).position(|w| w == needle)
|
||||
}
|
||||
|
||||
// ---- "Rich Text Format" ↔ text/rtf -----------------------------------------------------------
|
||||
|
||||
/// `"Rich Text Format"` HGLOBAL bytes → RTF wire bytes. RTF is `{ }`-delimited; some producers append
|
||||
/// a NUL past the final `}`, so strip a single trailing NUL to keep the wire payload byte-clean.
|
||||
pub fn rtf_from_cf(raw: &[u8]) -> Vec<u8> {
|
||||
strip_trailing_nul(raw).to_vec()
|
||||
}
|
||||
|
||||
fn strip_trailing_nul(b: &[u8]) -> &[u8] {
|
||||
match b.last() {
|
||||
Some(0) => &b[..b.len() - 1],
|
||||
_ => b,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn text_round_trips_and_handles_terminator() {
|
||||
// UTF-8 → UTF-16LE+NUL → UTF-8.
|
||||
let wire = "héllo 🌍".as_bytes();
|
||||
let cf = text_to_utf16(wire);
|
||||
// Ends with a single 0x0000 terminator.
|
||||
assert_eq!(&cf[cf.len() - 2..], &[0, 0]);
|
||||
assert_eq!(text_from_utf16(&cf), wire);
|
||||
|
||||
// A CF buffer *without* a terminator still decodes (no code unit eaten).
|
||||
let no_term: Vec<u8> = "hi".encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
assert_eq!(text_from_utf16(&no_term), b"hi");
|
||||
|
||||
// Empty text → just the terminator → empty wire.
|
||||
assert_eq!(text_to_utf16(b""), vec![0, 0]);
|
||||
assert_eq!(text_from_utf16(&[0, 0]), b"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cf_html_matches_the_spec_offsets() {
|
||||
// The worked example from the format reference: fragment "Hello".
|
||||
let cf = html_to_cf(b"Hello");
|
||||
let s = String::from_utf8(cf.clone()).unwrap();
|
||||
assert!(s.contains("StartHTML:0000000105"), "{s}");
|
||||
assert!(s.contains("EndHTML:0000000178"), "{s}");
|
||||
assert!(s.contains("StartFragment:0000000139"), "{s}");
|
||||
assert!(s.contains("EndFragment:0000000144"), "{s}");
|
||||
// The declared fragment range must slice back to exactly "Hello".
|
||||
let start = read_header_offset(&cf, b"StartFragment:").unwrap();
|
||||
let end = read_header_offset(&cf, b"EndFragment:").unwrap();
|
||||
assert_eq!(&cf[start..end], b"Hello");
|
||||
// Trailing NUL present, and EndHTML points *before* it.
|
||||
assert_eq!(*cf.last().unwrap(), 0);
|
||||
assert_eq!(read_header_offset(&cf, b"EndHTML:").unwrap(), cf.len() - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cf_html_round_trips_including_multibyte() {
|
||||
for frag in [
|
||||
"Hello",
|
||||
"<b>bold</b> & <i>ital</i>",
|
||||
"café ☕ <span>x</span>",
|
||||
"",
|
||||
] {
|
||||
let cf = html_to_cf(frag.as_bytes());
|
||||
assert_eq!(html_from_cf(&cf), frag.as_bytes(), "fragment {frag:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cf_html_extract_tolerates_foreign_producers() {
|
||||
// A producer that adds SourceURL and uses Version 1.0 — offsets must still drive extraction,
|
||||
// never a hardcoded 105-byte header.
|
||||
let fragment = "picked";
|
||||
let prefix = "<html><body><!--StartFragment-->";
|
||||
let header_body = format!(
|
||||
"Version:1.0\r\nStartHTML:{sh:010}\r\nEndHTML:{eh:010}\r\n\
|
||||
StartFragment:{sf:010}\r\nEndFragment:{ef:010}\r\nSourceURL:https://x/\r\n",
|
||||
sh = 0,
|
||||
eh = 0,
|
||||
sf = 0,
|
||||
ef = 0,
|
||||
);
|
||||
// Compute real offsets against this ad-hoc layout.
|
||||
let start_html = header_body.len();
|
||||
let start_fragment = start_html + prefix.len();
|
||||
let end_fragment = start_fragment + fragment.len();
|
||||
let end_html = end_fragment + "<!--EndFragment--></body></html>".len();
|
||||
let full = format!(
|
||||
"Version:1.0\r\nStartHTML:{start_html:010}\r\nEndHTML:{end_html:010}\r\n\
|
||||
StartFragment:{start_fragment:010}\r\nEndFragment:{end_fragment:010}\r\nSourceURL:https://x/\r\n\
|
||||
{prefix}{fragment}<!--EndFragment--></body></html>"
|
||||
);
|
||||
assert_eq!(html_from_cf(full.as_bytes()), fragment.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cf_html_extract_falls_back_without_markers() {
|
||||
// No fragment markers at all → whole buffer (minus any NUL).
|
||||
let mut b = b"<p>no markers</p>".to_vec();
|
||||
assert_eq!(html_from_cf(&b), b"<p>no markers</p>");
|
||||
b.push(0);
|
||||
assert_eq!(html_from_cf(&b), b"<p>no markers</p>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtf_strips_one_trailing_nul() {
|
||||
assert_eq!(rtf_from_cf(br"{\rtf1 hi}"), br"{\rtf1 hi}");
|
||||
assert_eq!(rtf_from_cf(b"{\\rtf1 hi}\0"), br"{\rtf1 hi}");
|
||||
// Only one NUL is stripped.
|
||||
assert_eq!(rtf_from_cf(b"x\0\0"), b"x\0");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Shared clipboard, host side (plan §W6 shape; `design/clipboard-and-file-transfer.md` §4).
|
||||
//!
|
||||
//! The wire protocol and the client half live in `punktfunk-core` (`punktfunk_core::quic` +
|
||||
//! `punktfunk_core::clipboard`); this crate drives the **host's** real session clipboard through
|
||||
//! the per-OS backends in [`host`] and bridges it to the QUIC clipboard plane through the
|
||||
//! [`host::session`] coordinator.
|
||||
//!
|
||||
//! The orchestrator consumes only this portable facade — [`policy`] / [`enabled`] /
|
||||
//! [`cap_advertised`], the [`ClipCoordCmd`] channel vocabulary, [`start`], and
|
||||
//! [`spawn_decline_loop`] — so its control loop compiles unchanged on every host platform; the
|
||||
//! platform split lives entirely behind [`start`].
|
||||
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
|
||||
use punktfunk_core::quic::ClipOffer;
|
||||
|
||||
/// The per-OS backends (`ext-data-control-v1` / Mutter direct / Win32) behind one
|
||||
/// `HostClipboard`, plus the backend-agnostic [`host::session`] coordinator.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
pub mod host;
|
||||
|
||||
/// Operator clipboard policy from `PUNKTFUNK_CLIPBOARD` (`design/clipboard-and-file-transfer.md`
|
||||
/// §4.2): `off` (default — the whole feature is dark), `on` / `1` (text + files), `text-only` /
|
||||
/// `no-files` (text/RTF/HTML/image only). Returns `None` when clipboard is off (the host neither
|
||||
/// advertises the cap nor accepts fetch streams); otherwise the permitted-format
|
||||
/// [`punktfunk_core::quic::CLIP_POLICY_TEXT`] / `CLIP_POLICY_FILES` bitfield.
|
||||
///
|
||||
/// The policy gates the advertised capability and whether the [`host::session`] coordinator
|
||||
/// starts. `off` keeps the whole feature dark.
|
||||
pub fn policy() -> Option<u8> {
|
||||
use punktfunk_core::quic::{CLIP_POLICY_FILES, CLIP_POLICY_TEXT};
|
||||
match std::env::var("PUNKTFUNK_CLIPBOARD")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"" | "0" | "off" | "false" => None,
|
||||
"text-only" | "no-files" | "text" => Some(CLIP_POLICY_TEXT),
|
||||
_ => Some(CLIP_POLICY_TEXT | CLIP_POLICY_FILES), // "on" / "1" / anything truthy
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the shared clipboard is enabled at all for this host (policy not `off`).
|
||||
pub fn enabled() -> bool {
|
||||
policy().is_some()
|
||||
}
|
||||
|
||||
/// Whether the host should advertise `HOST_CAP_CLIPBOARD` in the `Welcome`: the operator policy
|
||||
/// enables it AND this platform has a backend (Linux data-control / Mutter, or the Win32
|
||||
/// clipboard) — the client greys the toggle out otherwise. A Linux host whose compositor lacks
|
||||
/// data-control still advertises it and answers a later enable with `BACKEND_UNAVAILABLE`, so the
|
||||
/// client can surface *why* it's unavailable.
|
||||
pub fn cap_advertised() -> bool {
|
||||
enabled() && cfg!(any(target_os = "linux", target_os = "windows"))
|
||||
}
|
||||
|
||||
/// A command from the session control loop into the host clipboard coordinator
|
||||
/// ([`host::session`]). Defined here — portable — so the control loop compiles on every host
|
||||
/// platform; the coordinator that consumes it exists only where a backend does.
|
||||
pub enum ClipCoordCmd {
|
||||
/// The client toggled sync. When enabled, the coordinator (re)announces the current host
|
||||
/// clipboard; when disabled, it drops any selection it owns and stops forwarding host copies.
|
||||
SetEnabled(bool),
|
||||
/// The client copied: install its offered wire MIMEs as a lazy host selection (empty = clear).
|
||||
RemoteOffer { seq: u32, mimes: Vec<String> },
|
||||
}
|
||||
|
||||
/// Handle to the host clipboard coordinator, held by the session control loop.
|
||||
pub struct ClipCoord {
|
||||
/// Whether a real backend is live. `false` on gamescope / older GNOME / an unsupported
|
||||
/// platform; the control loop then answers an enable request with
|
||||
/// `CLIP_REASON_BACKEND_UNAVAILABLE` and [`spawn_decline_loop`] handles any stray fetch stream.
|
||||
pub available: bool,
|
||||
pub cmd_tx: tokio::sync::mpsc::UnboundedSender<ClipCoordCmd>,
|
||||
/// Host-copy announcements from the coordinator → control loop → client.
|
||||
pub offer_rx: tokio::sync::mpsc::UnboundedReceiver<ClipOffer>,
|
||||
}
|
||||
|
||||
/// Open the host clipboard backend (when the operator policy allows it, this session mirrors a
|
||||
/// real compositor, and the platform has a backend) and spawn its coordinator, returning a handle.
|
||||
/// Otherwise the handle is inert (`available = false`, channels dropped) so the caller's control
|
||||
/// loop stays platform-agnostic. `has_compositor` is false for the synthetic protocol-test source,
|
||||
/// which has no display/clipboard to share — keeping it out of the real session clipboard.
|
||||
pub async fn start(
|
||||
conn: quinn::Connection,
|
||||
clip_enabled: Arc<AtomicBool>,
|
||||
has_compositor: bool,
|
||||
) -> ClipCoord {
|
||||
let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let (offer_tx, offer_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
let available = if has_compositor && enabled() {
|
||||
host::session::start(conn, clip_enabled, cmd_rx, offer_tx).await
|
||||
} else {
|
||||
drop((conn, clip_enabled, cmd_rx, offer_tx));
|
||||
false
|
||||
};
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
let available = {
|
||||
let _ = (conn, clip_enabled, cmd_rx, offer_tx, has_compositor);
|
||||
false
|
||||
};
|
||||
ClipCoord {
|
||||
available,
|
||||
cmd_tx,
|
||||
offer_rx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Clipboard fetch-stream accept loop, fallback flavor (`design/clipboard-and-file-transfer.md`
|
||||
/// §3.3, §4.2). When a backend is live the coordinator (spawned by [`start`]) owns `accept_bi` and
|
||||
/// serves real host clipboard bytes. This is for the other case: the operator allowed the cap but
|
||||
/// no backend bound (gamescope / older GNOME / a not-yet-implemented platform), so a stray or
|
||||
/// hostile fetch stream is answered `CLIP_FETCH_UNAVAILABLE` instead of hanging. Exactly one
|
||||
/// `accept_bi` consumer runs (this OR the coordinator). The control stream is the FIRST bi-stream
|
||||
/// (already accepted at the handshake), so this loop only ever sees clipboard fetch streams; it
|
||||
/// dies with the connection.
|
||||
pub fn spawn_decline_loop(conn: quinn::Connection) {
|
||||
tokio::spawn(async move {
|
||||
use punktfunk_core::quic::{clipstream, ClipFetchHdr, CLIP_FETCH_UNAVAILABLE};
|
||||
while let Ok((mut send, mut recv)) = conn.accept_bi().await {
|
||||
tokio::spawn(async move {
|
||||
// Validate the stream header + request; a malformed/unknown stream is dropped.
|
||||
match clipstream::read_stream_header(&mut recv).await {
|
||||
Ok(k) if k == clipstream::CLIP_STREAM_KIND_FETCH => {}
|
||||
_ => {
|
||||
let _ = send.reset(clipstream::cancelled_code());
|
||||
return;
|
||||
}
|
||||
}
|
||||
if clipstream::read_fetch(&mut recv).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let _ = clipstream::write_fetch_hdr(
|
||||
&mut send,
|
||||
&ClipFetchHdr {
|
||||
status: CLIP_FETCH_UNAVAILABLE,
|
||||
total_size: 0,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -527,6 +527,10 @@ pub struct PunktfunkConnection {
|
||||
/// `inner.audio_channels`; `pcm` is a fixed-capacity reusable buffer the returned pointer
|
||||
/// borrows until the next PCM call (same contract as `last_audio`).
|
||||
audio_pcm: std::sync::Mutex<AudioPcmState>,
|
||||
/// Backs the `data`/`len` pointer of the last `punktfunk_connection_next_clipboard` event
|
||||
/// (a fetched payload, an offer's format list, or a fetch-request's MIME) —
|
||||
/// borrow-until-next-call, same contract as `last`.
|
||||
last_clip: std::sync::Mutex<Option<Vec<u8>>>,
|
||||
}
|
||||
|
||||
/// Lazily-initialized in-core Opus decode state. A coupled-1-stream multistream decoder is
|
||||
@@ -948,6 +952,14 @@ pub const PUNKTFUNK_CODEC_AV1: u8 = 0x04;
|
||||
/// (design/pyrowave-codec-plan.md §3). (Mirrors `quic::CODEC_PYROWAVE`.)
|
||||
pub const PUNKTFUNK_CODEC_PYROWAVE: u8 = 0x08;
|
||||
|
||||
/// Host-capability bit in [`punktfunk_connection_host_caps`]: the host applies gamepad-state
|
||||
/// snapshots (a capable client sends full-state snapshots instead of per-transition events).
|
||||
/// (Mirrors `quic::HOST_CAP_GAMEPAD_STATE`.)
|
||||
pub const PUNKTFUNK_HOST_CAP_GAMEPAD_STATE: u8 = 0x01;
|
||||
/// Host-capability bit in [`punktfunk_connection_host_caps`]: the host supports the shared
|
||||
/// clipboard, so a client may offer the toggle. (Mirrors `quic::HOST_CAP_CLIPBOARD`.)
|
||||
pub const PUNKTFUNK_HOST_CAP_CLIPBOARD: u8 = 0x02;
|
||||
|
||||
// Keep the ABI cap bits in lockstep with the wire constants (compile-time guard against drift).
|
||||
#[cfg(feature = "quic")]
|
||||
const _: () = {
|
||||
@@ -958,6 +970,8 @@ const _: () = {
|
||||
assert!(PUNKTFUNK_CODEC_HEVC == crate::quic::CODEC_HEVC);
|
||||
assert!(PUNKTFUNK_CODEC_AV1 == crate::quic::CODEC_AV1);
|
||||
assert!(PUNKTFUNK_CODEC_PYROWAVE == crate::quic::CODEC_PYROWAVE);
|
||||
assert!(PUNKTFUNK_HOST_CAP_GAMEPAD_STATE == crate::quic::HOST_CAP_GAMEPAD_STATE);
|
||||
assert!(PUNKTFUNK_HOST_CAP_CLIPBOARD == crate::quic::HOST_CAP_CLIPBOARD);
|
||||
};
|
||||
|
||||
// Keep the ABI gamepad constants in lockstep with the wire enum (compile-time guard against drift).
|
||||
@@ -1552,6 +1566,7 @@ unsafe fn connect_ex_impl(
|
||||
last: std::sync::Mutex::new(None),
|
||||
last_audio: std::sync::Mutex::new(None),
|
||||
audio_pcm: std::sync::Mutex::new(AudioPcmState::default()),
|
||||
last_clip: std::sync::Mutex::new(None),
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -2555,6 +2570,397 @@ pub unsafe extern "C" fn punktfunk_connection_gamepad(
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================================
|
||||
// Shared clipboard (design/clipboard-and-file-transfer.md §5.1). Additive, ABI v6. All poll/serve
|
||||
// bytes ride the mTLS-pinned QUIC session; nothing here opens a new listener or port.
|
||||
// ============================================================================================
|
||||
|
||||
/// [`PunktfunkClipEvent::kind`] — the host announced clipboard content is available
|
||||
/// (`transfer_id` = the offer `seq`; `data`/`len` = a `\n`-separated `"<mime>\t<size_hint>"`
|
||||
/// format list). Fetch it lazily (only on a local paste) via
|
||||
/// [`punktfunk_connection_clipboard_fetch`].
|
||||
pub const PUNKTFUNK_CLIP_REMOTE_OFFER: u8 = 1;
|
||||
/// [`PunktfunkClipEvent::kind`] — host ack / policy / backend update (`enabled`/`policy`/`reason`
|
||||
/// valid). Reflect it in the toggle UI.
|
||||
pub const PUNKTFUNK_CLIP_STATE: u8 = 2;
|
||||
/// [`PunktfunkClipEvent::kind`] — the host is pasting our offered data: answer with
|
||||
/// [`punktfunk_connection_clipboard_serve`] (`transfer_id` = `req_id`; `seq`/`file_index` valid;
|
||||
/// `data`/`len` = the requested MIME).
|
||||
pub const PUNKTFUNK_CLIP_FETCH_REQUEST: u8 = 3;
|
||||
/// [`PunktfunkClipEvent::kind`] — bytes for a fetch we started (`transfer_id` = `xfer_id`;
|
||||
/// `data`/`len` = the payload, borrowed until the next `next_clipboard`; `last` = final chunk).
|
||||
pub const PUNKTFUNK_CLIP_DATA: u8 = 4;
|
||||
/// [`PunktfunkClipEvent::kind`] — a transfer was cancelled (`transfer_id` = the id).
|
||||
pub const PUNKTFUNK_CLIP_CANCELLED: u8 = 5;
|
||||
/// [`PunktfunkClipEvent::kind`] — a transfer failed (`transfer_id` = the id; `status` = a
|
||||
/// `PunktfunkStatus` code).
|
||||
pub const PUNKTFUNK_CLIP_ERROR: u8 = 6;
|
||||
|
||||
/// One advertised clipboard format passed to [`punktfunk_connection_clipboard_offer`].
|
||||
#[cfg(feature = "quic")]
|
||||
#[repr(C)]
|
||||
pub struct PunktfunkClipKind {
|
||||
/// NUL-terminated UTF-8 wire MIME (e.g. `text/plain;charset=utf-8`). ≤ 128 bytes on the wire.
|
||||
pub mime: *const std::os::raw::c_char,
|
||||
/// Best-effort size in bytes; `0` = unknown.
|
||||
pub size_hint: u64,
|
||||
}
|
||||
|
||||
/// A shared-clipboard event, filled by [`punktfunk_connection_next_clipboard`]. A flat tagged
|
||||
/// struct (like `PunktfunkHidOutput`): read the fields named in the `kind`'s doc; the rest are 0.
|
||||
#[cfg(feature = "quic")]
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct PunktfunkClipEvent {
|
||||
/// One of `PUNKTFUNK_CLIP_*`.
|
||||
pub kind: u8,
|
||||
/// `State`: 1 = enabled, 0 = disabled.
|
||||
pub enabled: u8,
|
||||
/// `State`: bitfield of `quic::CLIP_POLICY_*` — what the host currently permits.
|
||||
pub policy: u8,
|
||||
/// `State`: one of `quic::CLIP_REASON_*`.
|
||||
pub reason: u8,
|
||||
/// `Data`: 1 = final chunk of this transfer.
|
||||
pub last: u8,
|
||||
/// Per-transfer id: the offer `seq` (RemoteOffer), the `req_id` (FetchRequest), or the
|
||||
/// `xfer_id` (Data/Cancelled/Error).
|
||||
pub transfer_id: u32,
|
||||
/// `FetchRequest`: the offer `seq` the request is against.
|
||||
pub seq: u32,
|
||||
/// `FetchRequest`: file index, or `quic::CLIP_FILE_INDEX_NONE`.
|
||||
pub file_index: u32,
|
||||
/// `Error`: a `PunktfunkStatus` code (negative); 0 otherwise.
|
||||
pub status: i32,
|
||||
/// RemoteOffer/FetchRequest/Data: a pointer into a per-connection slot, valid until the next
|
||||
/// `next_clipboard` call; NULL for the other kinds.
|
||||
pub data: *const u8,
|
||||
/// Byte length of `data` (0 when `data` is NULL).
|
||||
pub len: usize,
|
||||
}
|
||||
|
||||
/// Fill a [`PunktfunkClipEvent`] from a core event, parking any variable-length bytes in `slot`
|
||||
/// (borrow-until-next-call) and pointing `data`/`len` at them.
|
||||
#[cfg(feature = "quic")]
|
||||
fn build_clip_event(
|
||||
ev: crate::clipboard::ClipEventCore,
|
||||
slot: &mut Option<Vec<u8>>,
|
||||
) -> PunktfunkClipEvent {
|
||||
use crate::clipboard::ClipEventCore as E;
|
||||
let mut out = PunktfunkClipEvent {
|
||||
kind: 0,
|
||||
enabled: 0,
|
||||
policy: 0,
|
||||
reason: 0,
|
||||
last: 0,
|
||||
transfer_id: 0,
|
||||
seq: 0,
|
||||
file_index: 0,
|
||||
status: 0,
|
||||
data: std::ptr::null(),
|
||||
len: 0,
|
||||
};
|
||||
*slot = None;
|
||||
match ev {
|
||||
E::RemoteOffer { seq, kinds } => {
|
||||
out.kind = PUNKTFUNK_CLIP_REMOTE_OFFER;
|
||||
out.transfer_id = seq;
|
||||
let mut blob = String::new();
|
||||
for k in &kinds {
|
||||
blob.push_str(&k.mime);
|
||||
blob.push('\t');
|
||||
blob.push_str(&k.size_hint.to_string());
|
||||
blob.push('\n');
|
||||
}
|
||||
*slot = Some(blob.into_bytes());
|
||||
}
|
||||
E::State {
|
||||
enabled,
|
||||
policy,
|
||||
reason,
|
||||
} => {
|
||||
out.kind = PUNKTFUNK_CLIP_STATE;
|
||||
out.enabled = enabled as u8;
|
||||
out.policy = policy;
|
||||
out.reason = reason;
|
||||
}
|
||||
E::FetchRequest {
|
||||
req_id,
|
||||
seq,
|
||||
file_index,
|
||||
mime,
|
||||
} => {
|
||||
out.kind = PUNKTFUNK_CLIP_FETCH_REQUEST;
|
||||
out.transfer_id = req_id;
|
||||
out.seq = seq;
|
||||
out.file_index = file_index;
|
||||
*slot = Some(mime.into_bytes());
|
||||
}
|
||||
E::Data {
|
||||
xfer_id,
|
||||
bytes,
|
||||
last,
|
||||
} => {
|
||||
out.kind = PUNKTFUNK_CLIP_DATA;
|
||||
out.transfer_id = xfer_id;
|
||||
out.last = last as u8;
|
||||
*slot = Some(bytes);
|
||||
}
|
||||
E::Cancelled { id } => {
|
||||
out.kind = PUNKTFUNK_CLIP_CANCELLED;
|
||||
out.transfer_id = id;
|
||||
}
|
||||
E::Error { id, code } => {
|
||||
out.kind = PUNKTFUNK_CLIP_ERROR;
|
||||
out.transfer_id = id;
|
||||
out.status = code;
|
||||
}
|
||||
}
|
||||
if let Some(v) = slot.as_ref() {
|
||||
out.data = v.as_ptr();
|
||||
out.len = v.len();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The host capability bitfield the session's `Welcome` carried — a bitfield of
|
||||
/// `PUNKTFUNK_HOST_CAP_GAMEPAD_STATE` / `PUNKTFUNK_HOST_CAP_CLIPBOARD`. A client tests
|
||||
/// `caps & PUNKTFUNK_HOST_CAP_CLIPBOARD` to decide whether to offer the shared-clipboard toggle.
|
||||
/// Safe any time after connect.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `caps` is writable (NULL is skipped).
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_host_caps(
|
||||
c: *const PunktfunkConnection,
|
||||
caps: *mut u8,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
unsafe {
|
||||
if !caps.is_null() {
|
||||
*caps = c.inner.host_caps();
|
||||
}
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// Enable or disable the shared clipboard for this session (`design` §3.1). Opt-in: nothing is
|
||||
/// announced or served until this is called with `enabled = true`. `flags` carries
|
||||
/// `quic::CLIP_FLAG_FILES` (allow file transfer). The host replies with a `State` event.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_clipboard_control(
|
||||
c: *const PunktfunkConnection,
|
||||
enabled: bool,
|
||||
flags: u8,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
match c.inner.clip_control(enabled, flags) {
|
||||
Ok(()) => PunktfunkStatus::Ok,
|
||||
Err(e) => e.status(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Announce that the local clipboard changed — the lazy format-list offer. `seq` is a monotonic
|
||||
/// per-sender counter (newest wins); `kinds`/`n` is the advertised formats (≤ 16). The bytes cross
|
||||
/// only if the host later fetches.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `kinds` points to `n` `PunktfunkClipKind`s (NULL allowed only
|
||||
/// when `n == 0`), each `mime` a valid NUL-terminated UTF-8 string.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_clipboard_offer(
|
||||
c: *const PunktfunkConnection,
|
||||
seq: u32,
|
||||
kinds: *const PunktfunkClipKind,
|
||||
n: usize,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
if kinds.is_null() && n != 0 {
|
||||
return PunktfunkStatus::NullPointer;
|
||||
}
|
||||
let mut out = Vec::with_capacity(n);
|
||||
if n != 0 {
|
||||
let slice = unsafe { std::slice::from_raw_parts(kinds, n) };
|
||||
for k in slice {
|
||||
let mime = if k.mime.is_null() {
|
||||
String::new()
|
||||
} else {
|
||||
match unsafe { std::ffi::CStr::from_ptr(k.mime) }.to_str() {
|
||||
Ok(s) => s.to_string(),
|
||||
Err(_) => return PunktfunkStatus::InvalidArg,
|
||||
}
|
||||
};
|
||||
out.push(crate::quic::ClipKind {
|
||||
mime,
|
||||
size_hint: k.size_hint,
|
||||
});
|
||||
}
|
||||
}
|
||||
match c.inner.clip_offer(seq, out) {
|
||||
Ok(()) => PunktfunkStatus::Ok,
|
||||
Err(e) => e.status(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Start pulling one format (`mime`) of the host's current offer `seq` — lazily, on a local paste.
|
||||
/// `file_index` selects a file for a file transfer, or `quic::CLIP_FILE_INDEX_NONE` for a non-file
|
||||
/// format. Writes the transfer id (echoed on the resulting `Data`/`Error`/`Cancelled` event) to
|
||||
/// `xfer_id_out`.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `mime` is a valid NUL-terminated UTF-8 string; `xfer_id_out`
|
||||
/// is writable (NULL is skipped).
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_clipboard_fetch(
|
||||
c: *const PunktfunkConnection,
|
||||
seq: u32,
|
||||
mime: *const std::os::raw::c_char,
|
||||
file_index: u32,
|
||||
xfer_id_out: *mut u32,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
if mime.is_null() {
|
||||
return PunktfunkStatus::NullPointer;
|
||||
}
|
||||
let mime = match unsafe { std::ffi::CStr::from_ptr(mime) }.to_str() {
|
||||
Ok(s) => s.to_string(),
|
||||
Err(_) => return PunktfunkStatus::InvalidArg,
|
||||
};
|
||||
match c.inner.clip_fetch(seq, mime, file_index) {
|
||||
Ok(xfer_id) => {
|
||||
unsafe {
|
||||
if !xfer_id_out.is_null() {
|
||||
*xfer_id_out = xfer_id;
|
||||
}
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
}
|
||||
Err(e) => e.status(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Provide bytes answering a `FetchRequest` event (the host is pasting our offered data). Call
|
||||
/// repeatedly to stream a large payload; `last = true` completes it. `data` may be NULL only when
|
||||
/// `len == 0` (e.g. a final empty chunk). `punktfunk_connection_clipboard_cancel(req_id)` aborts.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `data` points to `len` bytes (NULL allowed only when
|
||||
/// `len == 0`).
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_clipboard_serve(
|
||||
c: *const PunktfunkConnection,
|
||||
req_id: u32,
|
||||
data: *const u8,
|
||||
len: usize,
|
||||
last: bool,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
if data.is_null() && len != 0 {
|
||||
return PunktfunkStatus::NullPointer;
|
||||
}
|
||||
let bytes = if len == 0 {
|
||||
Vec::new()
|
||||
} else {
|
||||
unsafe { std::slice::from_raw_parts(data, len) }.to_vec()
|
||||
};
|
||||
match c.inner.clip_serve(req_id, bytes, last) {
|
||||
Ok(()) => PunktfunkStatus::Ok,
|
||||
Err(e) => e.status(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Cancel a clipboard transfer by id — either an outbound fetch (`xfer_id` from
|
||||
/// [`punktfunk_connection_clipboard_fetch`]) or an inbound serve (`req_id` from a `FetchRequest`).
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_clipboard_cancel(
|
||||
c: *const PunktfunkConnection,
|
||||
id: u32,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
match c.inner.clip_cancel(id) {
|
||||
Ok(()) => PunktfunkStatus::Ok,
|
||||
Err(e) => e.status(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Pull the next shared-clipboard event into `*out`. [`PunktfunkStatus::NoFrame`] on timeout,
|
||||
/// [`PunktfunkStatus::Closed`] once the session ended. A native client drains this on its own
|
||||
/// thread and drives the OS pasteboard from it. The `data`/`len` pointer (when non-NULL) borrows a
|
||||
/// per-connection buffer valid until the next `next_clipboard` call on this handle.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `out` is writable for one `PunktfunkClipEvent`.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_next_clipboard(
|
||||
c: *mut PunktfunkConnection,
|
||||
out: *mut PunktfunkClipEvent,
|
||||
timeout_ms: u32,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
if out.is_null() {
|
||||
return PunktfunkStatus::NullPointer;
|
||||
}
|
||||
match c
|
||||
.inner
|
||||
.next_clip(std::time::Duration::from_millis(timeout_ms as u64))
|
||||
{
|
||||
Ok(ev) => {
|
||||
let mut slot = c.last_clip.lock().unwrap();
|
||||
let out_ev = build_clip_event(ev, &mut slot);
|
||||
unsafe { *out = out_ev };
|
||||
PunktfunkStatus::Ok
|
||||
}
|
||||
Err(e) => e.status(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The compositor backend the host actually resolved for this session (one of the
|
||||
/// `PUNKTFUNK_COMPOSITOR_*` values; the `Welcome`'s echo of the [`punktfunk_connect_ex`]
|
||||
/// preference). `PUNKTFUNK_COMPOSITOR_AUTO` = an older host that didn't say. Clients use it for
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! `CtrlRequest` (the embedder's control-stream requests) and `Negotiated` (the handshake result).
|
||||
|
||||
use crate::config::{CompositorPref, GamepadPref, Mode};
|
||||
use crate::quic::{ColorInfo, LossReport, ProbeRequest, RfiRequest};
|
||||
use crate::quic::{ClipControl, ClipOffer, ColorInfo, LossReport, ProbeRequest, RfiRequest};
|
||||
|
||||
/// A control-stream request the embedder makes on the open handshake stream: a mode switch or a
|
||||
/// speed test. One outbound channel carries both so the worker's `select!` has a single writer
|
||||
@@ -22,6 +22,12 @@ pub(crate) enum CtrlRequest {
|
||||
/// its report tick after the first no-op clock flush — the "the clock stepped under me"
|
||||
/// signal; the control task also self-triggers one every [`CLOCK_RESYNC_INTERVAL`].
|
||||
ClockResync,
|
||||
/// Shared-clipboard enable/disable for this session (`design/clipboard-and-file-transfer.md`
|
||||
/// §3.1). Idempotent; carries the file-permission flag.
|
||||
ClipControl(ClipControl),
|
||||
/// Announce that the local clipboard changed — the lazy format-list offer (bytes cross later on
|
||||
/// a fetch stream). Symmetric message; the host may send one too.
|
||||
ClipOffer(ClipOffer),
|
||||
}
|
||||
|
||||
/// What the worker reports to [`NativeClient::connect`] once the handshake lands: the
|
||||
@@ -55,4 +61,8 @@ pub(crate) struct Negotiated {
|
||||
pub(crate) audio_channels: u8,
|
||||
/// The single codec the host will emit (`quic::CODEC_*`).
|
||||
pub(crate) codec: u8,
|
||||
/// The host capability bitfield ([`crate::quic::Welcome::host_caps`]):
|
||||
/// [`crate::quic::HOST_CAP_GAMEPAD_STATE`], [`crate::quic::HOST_CAP_CLIPBOARD`]. Exposed to the
|
||||
/// embedder via [`NativeClient::host_caps`] so a native client greys out unsupported toggles.
|
||||
pub(crate) host_caps: u8,
|
||||
}
|
||||
|
||||
@@ -11,12 +11,16 @@
|
||||
//! invariant) plus a blocking data-plane pump; frames cross to the embedder over a bounded
|
||||
//! channel. All methods are safe to call from any single embedder thread.
|
||||
|
||||
use crate::clipboard::{ClipCommand, ClipEventCore};
|
||||
use crate::config::{CompositorPref, GamepadPref, Mode};
|
||||
use crate::error::{PunktfunkError, Result};
|
||||
use crate::input::InputEvent;
|
||||
use crate::quic::{endpoint, ColorInfo, HdrMeta, HidOutput, ProbeRequest, RfiRequest, RichInput};
|
||||
use crate::quic::{
|
||||
endpoint, ClipControl, ClipKind, ClipOffer, ColorInfo, HdrMeta, HidOutput, ProbeRequest,
|
||||
RfiRequest, RichInput,
|
||||
};
|
||||
use crate::session::Frame;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -38,7 +42,8 @@ pub use self::rumble::{ActuatorQuirks, RumbleCommand};
|
||||
use self::control::{CtrlRequest, Negotiated};
|
||||
use self::frame_channel::{DecodeLatAcc, FrameChannel, FramePop};
|
||||
use self::planes::{
|
||||
RumbleUpdate, AUDIO_QUEUE, HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE, RUMBLE_QUEUE,
|
||||
RumbleUpdate, AUDIO_QUEUE, CLIP_EVENT_QUEUE, HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE,
|
||||
RUMBLE_QUEUE,
|
||||
};
|
||||
use self::probe::ProbeState;
|
||||
use self::pump::run_pump;
|
||||
@@ -99,6 +104,20 @@ pub struct NativeClient {
|
||||
/// Bounded ([`CTRL_QUEUE`]) — the requests are sparse; a full queue means the control task
|
||||
/// is wedged/dead, and callers treat it like a closed session.
|
||||
ctrl_tx: tokio::sync::mpsc::Sender<CtrlRequest>,
|
||||
/// Inbound shared-clipboard events (remote offers, host acks, fetch-requests, fetched
|
||||
/// payloads), drained by [`NativeClient::next_clip`] → the C ABI poll. Fed by the control task
|
||||
/// (metadata) and the clipboard task (fetch data).
|
||||
clip: Mutex<Receiver<ClipEventCore>>,
|
||||
/// Outbound clipboard fetch/serve/cancel commands → the worker's clipboard task
|
||||
/// ([`crate::clipboard::run`]). Unbounded like `input_tx`; the commands are sparse and each
|
||||
/// carries at most one paste's bytes.
|
||||
clip_cmd_tx: tokio::sync::mpsc::UnboundedSender<ClipCommand>,
|
||||
/// Monotonic id for outbound fetches ([`NativeClient::clip_fetch`]); stays below
|
||||
/// [`crate::clipboard::INBOUND_REQ_FLAG`] so it never collides with an inbound serve `req_id`.
|
||||
next_xfer_id: AtomicU32,
|
||||
/// The host capability bitfield ([`crate::quic::Welcome::host_caps`]) — see
|
||||
/// [`NativeClient::host_caps`].
|
||||
pub host_caps: u8,
|
||||
/// Speed-test accumulator, shared with the data-plane pump + control task.
|
||||
probe: Arc<Mutex<ProbeState>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
@@ -315,6 +334,9 @@ impl NativeClient {
|
||||
let (mic_tx, mic_rx) = tokio::sync::mpsc::channel::<(u32, u64, Vec<u8>)>(MIC_QUEUE);
|
||||
let (rich_input_tx, rich_input_rx) = tokio::sync::mpsc::unbounded_channel::<RichInput>();
|
||||
let (ctrl_tx, ctrl_rx) = tokio::sync::mpsc::channel::<CtrlRequest>(CTRL_QUEUE);
|
||||
let (clip_event_tx, clip_event_rx) =
|
||||
std::sync::mpsc::sync_channel::<ClipEventCore>(CLIP_EVENT_QUEUE);
|
||||
let (clip_cmd_tx, clip_cmd_rx) = tokio::sync::mpsc::unbounded_channel::<ClipCommand>();
|
||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Negotiated>>();
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
let quit = Arc::new(AtomicBool::new(false));
|
||||
@@ -383,6 +405,8 @@ impl NativeClient {
|
||||
rich_input_rx,
|
||||
ctrl_rx,
|
||||
ctrl_tx: ctrl_tx_pump,
|
||||
clip_event_tx,
|
||||
clip_cmd_rx,
|
||||
ready_tx,
|
||||
shutdown: shutdown_w,
|
||||
quit: quit_w,
|
||||
@@ -418,6 +442,10 @@ impl NativeClient {
|
||||
mic_tx,
|
||||
rich_input_tx,
|
||||
ctrl_tx,
|
||||
clip: Mutex::new(clip_event_rx),
|
||||
clip_cmd_tx,
|
||||
next_xfer_id: AtomicU32::new(1),
|
||||
host_caps: negotiated.host_caps,
|
||||
probe,
|
||||
shutdown,
|
||||
quit,
|
||||
@@ -860,6 +888,84 @@ impl NativeClient {
|
||||
self.input_tx.send(*ev).map_err(|_| PunktfunkError::Closed)
|
||||
}
|
||||
|
||||
/// The host capability bitfield the [`crate::quic::Welcome`] carried
|
||||
/// ([`crate::quic::HOST_CAP_GAMEPAD_STATE`], [`crate::quic::HOST_CAP_CLIPBOARD`]). A native
|
||||
/// client tests `host_caps() & HOST_CAP_CLIPBOARD` to decide whether to offer the
|
||||
/// shared-clipboard toggle.
|
||||
pub fn host_caps(&self) -> u8 {
|
||||
self.host_caps
|
||||
}
|
||||
|
||||
/// Enable or disable the shared clipboard for this session (`design/clipboard-and-file-transfer.md`
|
||||
/// §3.1). Opt-in: nothing is announced or served until this crosses with `enabled = true`.
|
||||
/// `flags` carries [`crate::quic::CLIP_FLAG_FILES`]. Non-blocking; the host replies with a
|
||||
/// `State` event ([`NativeClient::next_clip`]).
|
||||
pub fn clip_control(&self, enabled: bool, flags: u8) -> Result<()> {
|
||||
self.ctrl_tx
|
||||
.try_send(CtrlRequest::ClipControl(ClipControl { enabled, flags }))
|
||||
.map_err(|_| PunktfunkError::Closed)
|
||||
}
|
||||
|
||||
/// Announce that the local clipboard changed — the lazy format-list offer. `seq` is a
|
||||
/// monotonic per-sender counter (newest wins); `kinds` is the advertised formats (≤
|
||||
/// [`crate::quic::CLIP_MAX_KINDS`]). The bytes cross only if the host later fetches.
|
||||
pub fn clip_offer(&self, seq: u32, kinds: Vec<ClipKind>) -> Result<()> {
|
||||
self.ctrl_tx
|
||||
.try_send(CtrlRequest::ClipOffer(ClipOffer { seq, kinds }))
|
||||
.map_err(|_| PunktfunkError::Closed)
|
||||
}
|
||||
|
||||
/// Start pulling one format (`mime`) of the host's current offer `seq` — lazily, when a local
|
||||
/// app pastes. `file_index` selects a file for a file transfer, or
|
||||
/// [`crate::quic::CLIP_FILE_INDEX_NONE`] for a non-file format. Returns the `xfer_id` echoed on
|
||||
/// the resulting `Data` / `Error` / `Cancelled` event.
|
||||
pub fn clip_fetch(&self, seq: u32, mime: String, file_index: u32) -> Result<u32> {
|
||||
let xfer_id = self.next_xfer_id.fetch_add(1, Ordering::Relaxed);
|
||||
// Stay in the low id space (inbound serve ids carry the high bit); wrap defensively.
|
||||
let xfer_id = xfer_id & !crate::clipboard::INBOUND_REQ_FLAG;
|
||||
self.clip_cmd_tx
|
||||
.send(ClipCommand::Fetch {
|
||||
xfer_id,
|
||||
seq,
|
||||
file_index,
|
||||
mime,
|
||||
})
|
||||
.map_err(|_| PunktfunkError::Closed)?;
|
||||
Ok(xfer_id)
|
||||
}
|
||||
|
||||
/// Provide bytes answering a `FetchRequest` event (the host is pasting our offered data). Call
|
||||
/// repeatedly to stream a large payload; `last = true` completes it. `clip_cancel(req_id)`
|
||||
/// aborts instead.
|
||||
pub fn clip_serve(&self, req_id: u32, bytes: Vec<u8>, last: bool) -> Result<()> {
|
||||
self.clip_cmd_tx
|
||||
.send(ClipCommand::Serve {
|
||||
req_id,
|
||||
bytes,
|
||||
last,
|
||||
})
|
||||
.map_err(|_| PunktfunkError::Closed)
|
||||
}
|
||||
|
||||
/// Cancel a clipboard transfer by id — either an outbound fetch (`xfer_id` from
|
||||
/// [`NativeClient::clip_fetch`]) or an inbound serve (`req_id` from a `FetchRequest` event).
|
||||
pub fn clip_cancel(&self, id: u32) -> Result<()> {
|
||||
self.clip_cmd_tx
|
||||
.send(ClipCommand::Cancel { id })
|
||||
.map_err(|_| PunktfunkError::Closed)
|
||||
}
|
||||
|
||||
/// Pull the next shared-clipboard event (remote offer, host ack/state, fetch-request, fetched
|
||||
/// data, cancel, error); same timeout/closed semantics as [`NativeClient::next_hidout`]. A
|
||||
/// native client drains this on its own thread and drives the OS pasteboard from it.
|
||||
pub fn next_clip(&self, timeout: Duration) -> Result<ClipEventCore> {
|
||||
match self.clip.lock().unwrap().recv_timeout(timeout) {
|
||||
Ok(e) => Ok(e),
|
||||
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
|
||||
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue one Opus mic frame for delivery as a 0xCB uplink datagram (the inverse of
|
||||
/// [`next_audio`](Self::next_audio)). `seq`/`pts_ns` are the caller's own counters (the host
|
||||
/// uses them only for diagnostics). The host decodes it into a virtual microphone source.
|
||||
|
||||
@@ -29,6 +29,12 @@ pub(crate) const HDR_META_QUEUE: usize = 8;
|
||||
/// harmless, it's per-frame observability, not state.
|
||||
pub(crate) const HOST_TIMING_QUEUE: usize = 512;
|
||||
|
||||
/// Clipboard event plane depth (offers, host acks, fetch-requests, fetched payloads). Clipboard
|
||||
/// activity is human-paced and sparse; a small ring is ample. Overflow drops the newest event
|
||||
/// (try_send), same discipline as the other planes — a dropped offer heals on the next copy, and
|
||||
/// a dropped fetch-request makes the serving stream time out and reset cleanly.
|
||||
pub(crate) const CLIP_EVENT_QUEUE: usize = 32;
|
||||
|
||||
/// One Opus packet from the host's audio datagram stream (48 kHz stereo, 5 ms frames).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AudioPacket {
|
||||
|
||||
@@ -11,9 +11,9 @@ use crate::config::{CompositorPref, GamepadPref, Role};
|
||||
use crate::error::PunktfunkError;
|
||||
use crate::packet::FLAG_PROBE;
|
||||
use crate::quic::{
|
||||
accept_resync, endpoint, io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClockEcho,
|
||||
ClockResync, Hello, HidOutput, LossReport, ProbeRequest, ProbeResult, Reconfigure,
|
||||
Reconfigured, RequestKeyframe, ResyncStep, SetBitrate, Start, Welcome,
|
||||
accept_resync, endpoint, io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipOffer,
|
||||
ClipState, ClockEcho, ClockResync, Hello, HidOutput, LossReport, ProbeRequest, ProbeResult,
|
||||
Reconfigure, Reconfigured, RequestKeyframe, ResyncStep, SetBitrate, Start, Welcome,
|
||||
};
|
||||
use crate::session::Session;
|
||||
use crate::transport::UdpTransport;
|
||||
@@ -49,6 +49,8 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
mut rich_input_rx,
|
||||
mut ctrl_rx,
|
||||
ctrl_tx,
|
||||
clip_event_tx,
|
||||
clip_cmd_rx,
|
||||
ready_tx,
|
||||
shutdown,
|
||||
quit,
|
||||
@@ -213,6 +215,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
audio_channels: welcome.audio_channels,
|
||||
codec: welcome.codec,
|
||||
shard_payload: welcome.shard_payload,
|
||||
host_caps: welcome.host_caps,
|
||||
},
|
||||
welcome.host_caps,
|
||||
))
|
||||
@@ -413,6 +416,9 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
let bitrate_ack = bitrate_ack.clone();
|
||||
let clock_offset = clock_offset.clone();
|
||||
let clock_gen = clock_gen.clone();
|
||||
// The control task feeds clipboard metadata events (ClipState/ClipOffer) onto the same event
|
||||
// plane the clipboard task uses for fetch data; the original tx goes to that task below.
|
||||
let clip_event_tx = clip_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
// Mid-stream clock re-sync (see [`ClockResync`]): a batch runs every
|
||||
// CLOCK_RESYNC_INTERVAL and whenever the pump asks (CtrlRequest::ClockResync after
|
||||
@@ -442,6 +448,8 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
}
|
||||
resync.begin(wall_clock_ns()).encode()
|
||||
}
|
||||
CtrlRequest::ClipControl(c) => c.encode(),
|
||||
CtrlRequest::ClipOffer(o) => o.encode(),
|
||||
};
|
||||
if io::write_msg(&mut ctrl_send, &bytes).await.is_err() {
|
||||
break;
|
||||
@@ -523,6 +531,21 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
}
|
||||
ResyncStep::Idle => {}
|
||||
}
|
||||
} else if let Ok(state) = ClipState::decode(&msg) {
|
||||
// Host ack / policy / backend update for the toggle UI (try_send: a
|
||||
// lagging embedder drops the newest — a stale toggle heals on the next).
|
||||
let _ = clip_event_tx.try_send(ClipEventCore::State {
|
||||
enabled: state.enabled,
|
||||
policy: state.policy,
|
||||
reason: state.reason,
|
||||
});
|
||||
} else if let Ok(offer) = ClipOffer::decode(&msg) {
|
||||
// The host copied something: surface the lazy format list; the embedder
|
||||
// fetches only if a local app pastes.
|
||||
let _ = clip_event_tx.try_send(ClipEventCore::RemoteOffer {
|
||||
seq: offer.seq,
|
||||
kinds: offer.kinds,
|
||||
});
|
||||
} else {
|
||||
tracing::warn!(
|
||||
tag = ?msg.first(),
|
||||
@@ -606,6 +629,17 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
}
|
||||
});
|
||||
|
||||
// Clipboard task: the fetch-stream accept loop (host pulls what we offered) + outbound fetches
|
||||
// (we pull what the host offered). Metadata (enable/offer/state) rides the control task above;
|
||||
// only bulk bytes flow here. Dies with the connection (accept_bi errors) or when the embedder
|
||||
// drops the command sender. Always spawned — a host without HOST_CAP_CLIPBOARD simply never
|
||||
// opens a clip stream, and our control-plane offers hit its "unknown message" arm harmlessly.
|
||||
tokio::spawn(crate::clipboard::run(
|
||||
conn.clone(),
|
||||
clip_event_tx,
|
||||
clip_cmd_rx,
|
||||
));
|
||||
|
||||
// Watch for connection close → stop the pump.
|
||||
{
|
||||
let shutdown = shutdown.clone();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! `WorkerArgs` (the pump's constructor payload) and `reject_from_close`.
|
||||
|
||||
use super::*;
|
||||
use crate::clipboard::{ClipCommand, ClipEventCore};
|
||||
use crate::config::{CompositorPref, GamepadPref, Mode};
|
||||
use crate::error::Result;
|
||||
use crate::input::InputEvent;
|
||||
@@ -38,6 +39,11 @@ pub(crate) struct WorkerArgs {
|
||||
pub(crate) rich_input_rx: tokio::sync::mpsc::UnboundedReceiver<RichInput>,
|
||||
pub(crate) ctrl_rx: tokio::sync::mpsc::Receiver<CtrlRequest>,
|
||||
pub(crate) ctrl_tx: tokio::sync::mpsc::Sender<CtrlRequest>,
|
||||
/// Inbound clipboard event plane feed — the control task pushes ClipState/ClipOffer, the
|
||||
/// clipboard task pushes fetch data; drained by [`NativeClient::next_clip`].
|
||||
pub(crate) clip_event_tx: SyncSender<ClipEventCore>,
|
||||
/// Outbound clipboard fetch/serve/cancel commands from the embedder → [`crate::clipboard::run`].
|
||||
pub(crate) clip_cmd_rx: tokio::sync::mpsc::UnboundedReceiver<ClipCommand>,
|
||||
pub(crate) ready_tx: std::sync::mpsc::Sender<Result<Negotiated>>,
|
||||
pub(crate) shutdown: Arc<AtomicBool>,
|
||||
/// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set.
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Client-side shared-clipboard transport (`design/clipboard-and-file-transfer.md` §5.1).
|
||||
//!
|
||||
//! This is the client-core half of Phase 0: the per-session async task that runs the fetch-stream
|
||||
//! **accept loop** (so the host can pull data the client offered) and drives **outbound fetches**
|
||||
//! (so the client can pull what the host offered), surfacing everything to the embedder as
|
||||
//! poll-style [`ClipEventCore`] events. The metadata plane — enabling sync and announcing offers —
|
||||
//! rides the control stream as ordinary [`ClipControl`]/[`ClipOffer`] control messages
|
||||
//! ([`crate::client`] routes those); only the bulk bytes flow through here, over the
|
||||
//! [`crate::quic::clipstream`] fetch bi-streams.
|
||||
//!
|
||||
//! There is intentionally **no OS pasteboard code here** — that lives in the native client (macOS
|
||||
//! first). The event/command seam is what the C ABI ([`crate::abi`]) exposes so a native client
|
||||
//! polls offers/fetch-requests and answers with bytes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::mpsc::SyncSender;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tokio::sync::mpsc::UnboundedReceiver;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::error::PunktfunkStatus;
|
||||
use crate::quic::{
|
||||
clipstream, ClipFetch, ClipFetchHdr, ClipKind, CLIP_FETCH_DENIED, CLIP_FETCH_OK,
|
||||
CLIP_FETCH_STALE, CLIP_FETCH_UNAVAILABLE,
|
||||
};
|
||||
|
||||
/// Per-fetch requester-side size cap (bytes). A holder that streams more than this is treated as a
|
||||
/// cap breach and the fetch fails rather than buffering unboundedly (§7). Phase 0 uses one fixed
|
||||
/// value; a future host-policy `PUNKTFUNK_CLIP_MAX_MB` tightens it per session.
|
||||
pub const CLIP_FETCH_CAP: usize = 64 << 20;
|
||||
|
||||
/// Inbound-serve `req_id`s carry this high bit so they never collide with the client-assigned
|
||||
/// outbound-fetch `xfer_id`s (which count up from 1). A single [`ClipCommand::Cancel`] `id` can
|
||||
/// then be routed to the right table.
|
||||
pub const INBOUND_REQ_FLAG: u32 = 0x8000_0000;
|
||||
|
||||
/// Overall stall bound for one outbound fetch (`design/clipboard-and-file-transfer.md` §3.4): a
|
||||
/// holder that never answers fails the transfer instead of hanging it.
|
||||
const FETCH_STALL_SECS: u64 = 60;
|
||||
|
||||
/// A clipboard event surfaced to the embedder via `NativeClient::next_clip` (→ the C ABI poll
|
||||
/// `punktfunk_connection_next_clipboard`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ClipEventCore {
|
||||
/// The host announced new clipboard content (the host copied). The embedder decides whether to
|
||||
/// fetch it — lazily, only when a local app actually pastes.
|
||||
RemoteOffer { seq: u32, kinds: Vec<ClipKind> },
|
||||
/// Host ack / unsolicited policy or backend update, for the toggle UI.
|
||||
State {
|
||||
enabled: bool,
|
||||
policy: u8,
|
||||
reason: u8,
|
||||
},
|
||||
/// The host is pasting content the client offered: it opened a fetch stream for
|
||||
/// `(mime, file_index)`. The embedder must answer with `clip_serve(req_id, …)` (or
|
||||
/// `clip_cancel(req_id)`).
|
||||
FetchRequest {
|
||||
req_id: u32,
|
||||
seq: u32,
|
||||
file_index: u32,
|
||||
mime: String,
|
||||
},
|
||||
/// Bytes for a fetch the embedder started (`xfer_id` from `clip_fetch`). Phase 0 delivers the
|
||||
/// whole payload in one event (`last = true`).
|
||||
Data {
|
||||
xfer_id: u32,
|
||||
bytes: Vec<u8>,
|
||||
last: bool,
|
||||
},
|
||||
/// A transfer was cancelled (by either side).
|
||||
Cancelled { id: u32 },
|
||||
/// A transfer failed; `code` is a [`PunktfunkStatus`] value (negative).
|
||||
Error { id: u32, code: i32 },
|
||||
}
|
||||
|
||||
/// A command from the embedder (via the C ABI) into the clipboard task. `ClipControl`/`ClipOffer`
|
||||
/// are *not* here — they ride the control stream as ordinary control messages.
|
||||
pub enum ClipCommand {
|
||||
/// Open a fetch of the remote's offered content; `xfer_id` is client-assigned and echoed back
|
||||
/// on the resulting [`ClipEventCore::Data`]/`Error`/`Cancelled`.
|
||||
Fetch {
|
||||
xfer_id: u32,
|
||||
seq: u32,
|
||||
file_index: u32,
|
||||
mime: String,
|
||||
},
|
||||
/// Provide bytes answering an inbound [`ClipEventCore::FetchRequest`] (`req_id`). Chunks
|
||||
/// accumulate; `last` completes the transfer.
|
||||
Serve {
|
||||
req_id: u32,
|
||||
bytes: Vec<u8>,
|
||||
last: bool,
|
||||
},
|
||||
/// Cancel a transfer by id — either an outbound fetch (`xfer_id`) or an inbound serve
|
||||
/// (`req_id`, high bit set).
|
||||
Cancel { id: u32 },
|
||||
}
|
||||
|
||||
type ServeWaiters = Arc<Mutex<HashMap<u32, oneshot::Sender<Option<Vec<u8>>>>>>;
|
||||
|
||||
/// Map a non-OK [`ClipFetchHdr::status`] to the [`PunktfunkStatus`] surfaced on an
|
||||
/// [`ClipEventCore::Error`].
|
||||
fn fetch_status_to_code(status: u8) -> i32 {
|
||||
let s = match status {
|
||||
CLIP_FETCH_STALE => PunktfunkStatus::NoFrame, // stale offer → "nothing to insert"
|
||||
CLIP_FETCH_UNAVAILABLE => PunktfunkStatus::Unsupported,
|
||||
CLIP_FETCH_DENIED => PunktfunkStatus::InvalidArg,
|
||||
_ => PunktfunkStatus::BadPacket,
|
||||
};
|
||||
s as i32
|
||||
}
|
||||
|
||||
/// The per-session clipboard task. Runs until the connection closes or the embedder drops the
|
||||
/// command sender. It owns no clipboard *content* — the embedder supplies bytes on demand.
|
||||
pub async fn run(
|
||||
conn: quinn::Connection,
|
||||
events: SyncSender<ClipEventCore>,
|
||||
mut cmd_rx: UnboundedReceiver<ClipCommand>,
|
||||
) {
|
||||
// Inbound fetch-serve waiters: req_id -> a oneshot the serving stream task parks on until the
|
||||
// embedder answers with bytes (`Some`) or denies/cancels (`None`).
|
||||
let serve_waiters: ServeWaiters = Arc::new(Mutex::new(HashMap::new()));
|
||||
// Accumulation buffers for chunked `clip_serve` (req_id -> bytes so far).
|
||||
let mut serve_bufs: HashMap<u32, Vec<u8>> = HashMap::new();
|
||||
// Outbound fetch cancel triggers: xfer_id -> a oneshot that aborts the fetch task.
|
||||
let mut fetch_cancels: HashMap<u32, oneshot::Sender<()>> = HashMap::new();
|
||||
let mut next_req_id: u32 = 1;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
// The host opened a fetch bi-stream toward us (it is pasting our offered data).
|
||||
accepted = conn.accept_bi() => {
|
||||
let Ok((send, recv)) = accepted else { break }; // connection gone
|
||||
let req_id = INBOUND_REQ_FLAG | next_req_id;
|
||||
next_req_id = next_req_id.wrapping_add(1);
|
||||
if next_req_id == 0 {
|
||||
next_req_id = 1;
|
||||
}
|
||||
let events = events.clone();
|
||||
let waiters = serve_waiters.clone();
|
||||
tokio::spawn(serve_inbound(send, recv, req_id, events, waiters));
|
||||
}
|
||||
cmd = cmd_rx.recv() => {
|
||||
let Some(cmd) = cmd else { break }; // NativeClient dropped
|
||||
match cmd {
|
||||
ClipCommand::Fetch { xfer_id, seq, file_index, mime } => {
|
||||
let (cancel_tx, cancel_rx) = oneshot::channel();
|
||||
fetch_cancels.insert(xfer_id, cancel_tx);
|
||||
let conn = conn.clone();
|
||||
let events = events.clone();
|
||||
let req = ClipFetch { seq, file_index, mime };
|
||||
tokio::spawn(run_outbound_fetch(conn, xfer_id, req, events, cancel_rx));
|
||||
}
|
||||
ClipCommand::Serve { req_id, bytes, last } => {
|
||||
serve_bufs.entry(req_id).or_default().extend_from_slice(&bytes);
|
||||
if last {
|
||||
let full = serve_bufs.remove(&req_id).unwrap_or_default();
|
||||
if let Some(tx) = serve_waiters.lock().unwrap().remove(&req_id) {
|
||||
let _ = tx.send(Some(full));
|
||||
}
|
||||
}
|
||||
}
|
||||
ClipCommand::Cancel { id } => {
|
||||
// Route to whichever table owns the id (they are disjoint by the high bit).
|
||||
if let Some(tx) = fetch_cancels.remove(&id) {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
serve_bufs.remove(&id);
|
||||
if let Some(tx) = serve_waiters.lock().unwrap().remove(&id) {
|
||||
let _ = tx.send(None); // deny — the serving task writes UNAVAILABLE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve one inbound fetch stream: validate the header + request, emit a [`ClipEventCore::FetchRequest`],
|
||||
/// then park until the embedder supplies bytes (or denies), and stream them back.
|
||||
async fn serve_inbound(
|
||||
mut send: quinn::SendStream,
|
||||
mut recv: quinn::RecvStream,
|
||||
req_id: u32,
|
||||
events: SyncSender<ClipEventCore>,
|
||||
waiters: ServeWaiters,
|
||||
) {
|
||||
let _ = send.set_priority(-1);
|
||||
let kind = match clipstream::read_stream_header(&mut recv).await {
|
||||
Ok(k) => k,
|
||||
Err(_) => return,
|
||||
};
|
||||
if kind != clipstream::CLIP_STREAM_KIND_FETCH {
|
||||
let _ = send.reset(clipstream::cancelled_code());
|
||||
return;
|
||||
}
|
||||
let req = match clipstream::read_fetch(&mut recv).await {
|
||||
Ok(r) => r,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
// Register the waiter before emitting the event, so an immediate `clip_serve` can't race ahead
|
||||
// of the insert.
|
||||
let (tx, rx) = oneshot::channel();
|
||||
waiters.lock().unwrap().insert(req_id, tx);
|
||||
let ev = ClipEventCore::FetchRequest {
|
||||
req_id,
|
||||
seq: req.seq,
|
||||
file_index: req.file_index,
|
||||
mime: req.mime,
|
||||
};
|
||||
if events.try_send(ev).is_err() {
|
||||
// The embedder isn't draining events (or the session is ending): refuse cleanly.
|
||||
waiters.lock().unwrap().remove(&req_id);
|
||||
let _ = clipstream::write_fetch_hdr(
|
||||
&mut send,
|
||||
&ClipFetchHdr {
|
||||
status: CLIP_FETCH_UNAVAILABLE,
|
||||
total_size: 0,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
match rx.await {
|
||||
Ok(Some(bytes)) => {
|
||||
if clipstream::write_fetch_hdr(
|
||||
&mut send,
|
||||
&ClipFetchHdr {
|
||||
status: CLIP_FETCH_OK,
|
||||
total_size: bytes.len() as u64,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
let _ = clipstream::write_data(&mut send, &bytes).await;
|
||||
}
|
||||
}
|
||||
// Denied, cancelled, or the waiter was dropped (task ending): tell the peer it's gone.
|
||||
_ => {
|
||||
let _ = clipstream::write_fetch_hdr(
|
||||
&mut send,
|
||||
&ClipFetchHdr {
|
||||
status: CLIP_FETCH_UNAVAILABLE,
|
||||
total_size: 0,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive one outbound fetch: open the stream, read the header + data (bounded), and emit a
|
||||
/// [`ClipEventCore::Data`] / `Error` / `Cancelled`.
|
||||
async fn run_outbound_fetch(
|
||||
conn: quinn::Connection,
|
||||
xfer_id: u32,
|
||||
req: ClipFetch,
|
||||
events: SyncSender<ClipEventCore>,
|
||||
cancel_rx: oneshot::Receiver<()>,
|
||||
) {
|
||||
let transfer = async {
|
||||
let (send, mut recv) = clipstream::open_fetch(&conn, &req)
|
||||
.await
|
||||
.map_err(|_| PunktfunkStatus::Io as i32)?;
|
||||
let hdr = clipstream::read_fetch_hdr(&mut recv)
|
||||
.await
|
||||
.map_err(|_| PunktfunkStatus::Io as i32)?;
|
||||
if hdr.status != CLIP_FETCH_OK {
|
||||
return Err(fetch_status_to_code(hdr.status));
|
||||
}
|
||||
let data = clipstream::read_data(&mut recv, CLIP_FETCH_CAP)
|
||||
.await
|
||||
.map_err(|_| PunktfunkStatus::Io as i32)?;
|
||||
drop(send); // done — dropping the send half is a clean FIN-less close on our side
|
||||
Ok(data)
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
r = transfer => match r {
|
||||
Ok(data) => {
|
||||
let _ = events.try_send(ClipEventCore::Data { xfer_id, bytes: data, last: true });
|
||||
}
|
||||
Err(code) => {
|
||||
let _ = events.try_send(ClipEventCore::Error { id: xfer_id, code });
|
||||
}
|
||||
},
|
||||
_ = cancel_rx => {
|
||||
// The `transfer` future is dropped here; its streams reset on drop.
|
||||
let _ = events.try_send(ClipEventCore::Cancelled { id: xfer_id });
|
||||
}
|
||||
// Overall stall bound (design/clipboard-and-file-transfer.md §3.4): a holder that never
|
||||
// answers must not hang the transfer. Dropping `transfer` resets the streams.
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(FETCH_STALL_SECS)) => {
|
||||
let _ = events.try_send(ClipEventCore::Error {
|
||||
id: xfer_id,
|
||||
code: PunktfunkStatus::Timeout as i32,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,11 @@ mod abr;
|
||||
pub mod audio;
|
||||
#[cfg(feature = "quic")]
|
||||
pub mod client;
|
||||
/// Client-side shared-clipboard transport: the per-session task that runs the fetch-stream accept
|
||||
/// loop, drives outbound fetches, and serves inbound ones — surfaced to the embedder as poll
|
||||
/// events. Wire codecs live in [`quic`]; the OS pasteboard integration lives in the native client.
|
||||
#[cfg(feature = "quic")]
|
||||
pub mod clipboard;
|
||||
pub mod config;
|
||||
pub mod crypto;
|
||||
pub mod error;
|
||||
@@ -73,7 +78,11 @@ pub use stats::Stats;
|
||||
/// application close) and the `PunktfunkStatus` −20 block itself. Additive — the close codes are
|
||||
/// new application-close vocabulary an old peer simply never sends/reads, so [`WIRE_VERSION`] is
|
||||
/// unchanged.
|
||||
pub const ABI_VERSION: u32 = 7;
|
||||
/// v8: added the shared-clipboard client surface — `punktfunk_connection_host_caps` and
|
||||
/// `punktfunk_connection_clipboard_{control,offer,fetch,serve,cancel}` +
|
||||
/// `punktfunk_connection_next_clipboard`. Additive; the wire grows only backward-compatible control
|
||||
/// messages (0x40-0x44) and a new `Welcome::host_caps` bit, so [`WIRE_VERSION`] is unchanged.
|
||||
pub const ABI_VERSION: u32 = 8;
|
||||
|
||||
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
|
||||
@@ -40,6 +40,14 @@ pub const VIDEO_CAP_PROBE_SEQ: u8 = 0x10;
|
||||
/// button/axis events; toward a host that doesn't set the bit it keeps the legacy events.
|
||||
pub const HOST_CAP_GAMEPAD_STATE: u8 = 0x01;
|
||||
|
||||
/// [`Welcome::host_caps`] bit: the host has a shared-clipboard service (a working OS backend)
|
||||
/// **and** its operator policy does not hard-disable it, so the client may offer the clipboard
|
||||
/// toggle. Absent (an older host, or `PUNKTFUNK_CLIPBOARD` off) ⇒ the client greys the toggle
|
||||
/// out. Purely additive: nothing clipboard-related happens until a [`ClipControl`]`{ enabled:
|
||||
/// true }` crosses (see `design/clipboard-and-file-transfer.md` §3.1). Packs into the existing
|
||||
/// trailing `host_caps` byte — no wire-layout change.
|
||||
pub const HOST_CAP_CLIPBOARD: u8 = 0x02;
|
||||
|
||||
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||
/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||
/// advertise this.
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Per-transfer clipboard fetch streams (`design/clipboard-and-file-transfer.md` §3.3).
|
||||
//!
|
||||
//! Bulk clipboard / file bytes never ride the control stream (u16-capped) or datagrams (lossy,
|
||||
//! single-packet). The **requester opens a fresh QUIC bi-stream** toward the data holder, writes a
|
||||
//! small stream header + a [`ClipFetch`]; the holder replies with a [`ClipFetchHdr`] then raw data
|
||||
//! chunks until FIN. One transfer per stream ⇒ natural flow control, clean cancelation
|
||||
//! (`RESET_STREAM`), and no head-of-line blocking against control or other transfers.
|
||||
//!
|
||||
//! These helpers are the transport half only; they hold no clipboard state, so the host and the
|
||||
//! client-core reuse the exact same open/accept/serve wire dance (the accept-loop that dispatches
|
||||
//! by stream kind lives on each side, since the two sides own their connections differently).
|
||||
|
||||
use super::{io, ClipFetch, ClipFetchHdr};
|
||||
|
||||
/// First bytes an opener writes on a freshly-opened clipboard bi-stream: a magic keeping this
|
||||
/// stream namespace disjoint from any future stream kind, plus a 1-byte kind discriminator. A
|
||||
/// distinct magic means a stream opened for some other future purpose can never be misrouted here.
|
||||
pub const STREAM_MAGIC: &[u8; 4] = b"PKFs";
|
||||
|
||||
/// Stream-kind byte: a clipboard fetch (request/response of one format). Future stream kinds
|
||||
/// (e.g. a bulk file-content push) mux under the same [`STREAM_MAGIC`] with a different byte.
|
||||
pub const CLIP_STREAM_KIND_FETCH: u8 = 0x01;
|
||||
|
||||
/// QUIC application error code used to `reset`/`stop` a clipboard fetch stream on cancel — sync
|
||||
/// disabled mid-transfer, paste timed out, size cap exceeded, teardown. Distinct from the
|
||||
/// connection close codes ([`super::QUIT_CLOSE_CODE`] `0x51` / [`super::APP_EXITED_CLOSE_CODE`]
|
||||
/// `0x52`), the connection reject code `0x42`, and the pairing-rejection close block
|
||||
/// `0x60`–`0x67` — stream reset codes and connection close codes are separate QUIC namespaces,
|
||||
/// but the vocabularies stay disjoint on purpose so a captured code is unambiguous.
|
||||
pub const CLIP_CANCELLED_CODE: u32 = 0x70;
|
||||
|
||||
/// Chunk size for streaming fetch data (64 KiB writes — matches the control-frame bound).
|
||||
pub const CLIP_CHUNK: usize = 64 * 1024;
|
||||
|
||||
/// The `VarInt` form of [`CLIP_CANCELLED_CODE`], for `SendStream::reset` / `RecvStream::stop`.
|
||||
pub fn cancelled_code() -> quinn::VarInt {
|
||||
quinn::VarInt::from_u32(CLIP_CANCELLED_CODE)
|
||||
}
|
||||
|
||||
/// Requester side: open a fresh bi-stream toward the holder, deprioritize it under the control
|
||||
/// stream, write the stream header + the [`ClipFetch`], and hand back both halves. The send half
|
||||
/// is returned so the caller can `reset`/`finish` for cancelation; the recv half is positioned to
|
||||
/// read the [`ClipFetchHdr`] next (see [`read_fetch_hdr`]).
|
||||
pub async fn open_fetch(
|
||||
conn: &quinn::Connection,
|
||||
req: &ClipFetch,
|
||||
) -> std::io::Result<(quinn::SendStream, quinn::RecvStream)> {
|
||||
let (mut send, recv) = conn.open_bi().await.map_err(std::io::Error::other)?;
|
||||
// Yield to the control stream (default priority 0) so a large paste never head-of-line-blocks
|
||||
// the input/audio/control traffic sharing this connection.
|
||||
let _ = send.set_priority(-1);
|
||||
// The opener MUST write before the peer's `accept_bi()` can return (quinn contract), so send
|
||||
// the whole request eagerly.
|
||||
let mut hdr = Vec::with_capacity(5);
|
||||
hdr.extend_from_slice(STREAM_MAGIC);
|
||||
hdr.push(CLIP_STREAM_KIND_FETCH);
|
||||
send.write_all(&hdr).await.map_err(std::io::Error::other)?;
|
||||
io::write_msg(&mut send, &req.encode()).await?;
|
||||
Ok((send, recv))
|
||||
}
|
||||
|
||||
/// Holder side, step 1: after `accept_bi()`, read and validate the 5-byte stream header. Returns
|
||||
/// the kind byte (e.g. [`CLIP_STREAM_KIND_FETCH`]); an unknown magic is an error and the caller
|
||||
/// should `stop` the stream.
|
||||
pub async fn read_stream_header(recv: &mut quinn::RecvStream) -> std::io::Result<u8> {
|
||||
let mut hdr = [0u8; 5];
|
||||
recv.read_exact(&mut hdr)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
if &hdr[0..4] != STREAM_MAGIC {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"bad clip stream magic",
|
||||
));
|
||||
}
|
||||
Ok(hdr[4])
|
||||
}
|
||||
|
||||
/// Holder side, step 2: read the [`ClipFetch`] request that follows the header.
|
||||
pub async fn read_fetch(recv: &mut quinn::RecvStream) -> std::io::Result<ClipFetch> {
|
||||
let raw = io::read_msg(recv).await?;
|
||||
ClipFetch::decode(&raw)
|
||||
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "bad ClipFetch"))
|
||||
}
|
||||
|
||||
/// Holder side, step 3: send the response header (before any data chunks).
|
||||
pub async fn write_fetch_hdr(
|
||||
send: &mut quinn::SendStream,
|
||||
hdr: &ClipFetchHdr,
|
||||
) -> std::io::Result<()> {
|
||||
io::write_msg(send, &hdr.encode()).await
|
||||
}
|
||||
|
||||
/// Holder side, step 4 (only when the header was [`super::CLIP_FETCH_OK`]): stream `data` as
|
||||
/// 64 KiB chunks then FIN so the requester's [`read_data`] terminates.
|
||||
pub async fn write_data(send: &mut quinn::SendStream, data: &[u8]) -> std::io::Result<()> {
|
||||
for chunk in data.chunks(CLIP_CHUNK) {
|
||||
send.write_all(chunk).await.map_err(std::io::Error::other)?;
|
||||
}
|
||||
send.finish().map_err(std::io::Error::other)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Requester side: read the [`ClipFetchHdr`] the holder sends before any data chunks.
|
||||
pub async fn read_fetch_hdr(recv: &mut quinn::RecvStream) -> std::io::Result<ClipFetchHdr> {
|
||||
let raw = io::read_msg(recv).await?;
|
||||
ClipFetchHdr::decode(&raw)
|
||||
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "bad ClipFetchHdr"))
|
||||
}
|
||||
|
||||
/// Requester side: after an OK [`ClipFetchHdr`], drain the data chunks to a `Vec`, bounded by
|
||||
/// `max_bytes` (the requester's size cap — a breach errors, and the caller resets the stream).
|
||||
pub async fn read_data(recv: &mut quinn::RecvStream, max_bytes: usize) -> std::io::Result<Vec<u8>> {
|
||||
recv.read_to_end(max_bytes)
|
||||
.await
|
||||
.map_err(std::io::Error::other)
|
||||
}
|
||||
@@ -468,3 +468,317 @@ pub fn frame(payload: &[u8]) -> Vec<u8> {
|
||||
b.extend_from_slice(payload);
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Shared clipboard & file transfer — wire codecs (ported from the pre-W7 quic/msgs.rs on the
|
||||
// clipboard-feature merge; the control-stream metadata messages live beside the clock codecs).
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Shared clipboard & file transfer (design/clipboard-and-file-transfer.md §3). The small
|
||||
// metadata messages ride the control stream (0x40-0x42); the two fetch-stream messages
|
||||
// (0x43-0x44) travel on a per-transfer bi-stream (see the [`super::clipstream`] helpers), never
|
||||
// the control stream, so they are never dispatched by the control loops. All are typed
|
||||
// (`CTL_MAGIC` + type byte), so an older peer hits its "unknown control message" arm and drops
|
||||
// any it doesn't know — the whole feature is forward-safe.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Type byte of [`ClipControl`] (client → host): enable/disable the shared clipboard for this
|
||||
/// session. Idempotent; opt-in is enforced here, not just in UI.
|
||||
pub const MSG_CLIP_CONTROL: u8 = 0x40;
|
||||
/// Type byte of [`ClipState`] (host → client): ack + unsolicited policy/backend updates.
|
||||
pub const MSG_CLIP_STATE: u8 = 0x41;
|
||||
/// Type byte of [`ClipOffer`] (symmetric): the lazy announcement — format list only, no bytes.
|
||||
pub const MSG_CLIP_OFFER: u8 = 0x42;
|
||||
/// Type byte of [`ClipFetch`] (requester → holder, **fetch stream only**): pull one format of the
|
||||
/// current offer.
|
||||
pub const MSG_CLIP_FETCH: u8 = 0x43;
|
||||
/// Type byte of [`ClipFetchHdr`] (holder → requester, **fetch stream only**): the fetch response
|
||||
/// header that precedes the data chunks.
|
||||
pub const MSG_CLIP_FETCH_HDR: u8 = 0x44;
|
||||
|
||||
/// [`ClipControl::flags`] bit: the client permits file kinds to be offered/fetched this session.
|
||||
/// Absent ⇒ files are filtered out of offers in both directions (text/rich/image only).
|
||||
pub const CLIP_FLAG_FILES: u8 = 0x01;
|
||||
|
||||
/// [`ClipState::policy`] bit: the host permits non-file formats (text/RTF/HTML/image). Always set
|
||||
/// while enabled unless a future direction limit clears it.
|
||||
pub const CLIP_POLICY_TEXT: u8 = 0x01;
|
||||
/// [`ClipState::policy`] bit: the host permits file formats. Cleared by the operator `no-files`
|
||||
/// / `text-only` policy so the client can grey out "Include files".
|
||||
pub const CLIP_POLICY_FILES: u8 = 0x02;
|
||||
|
||||
/// [`ClipState::reason`]: normal ack, nothing exceptional.
|
||||
pub const CLIP_REASON_OK: u8 = 0;
|
||||
/// [`ClipState::reason`]: this session type has no working clipboard backend (e.g. a gamescope
|
||||
/// session with no data-control global) — the client shows "not supported in this session type".
|
||||
pub const CLIP_REASON_BACKEND_UNAVAILABLE: u8 = 1;
|
||||
/// [`ClipState::reason`]: another client took over the single per-desktop clipboard binding; this
|
||||
/// one was disabled (last `ClipControl{enabled}` wins).
|
||||
pub const CLIP_REASON_TAKEN_OVER: u8 = 2;
|
||||
/// [`ClipState::reason`]: the host operator policy (`PUNKTFUNK_CLIPBOARD=off`) disables clipboard.
|
||||
pub const CLIP_REASON_POLICY_DISABLED: u8 = 3;
|
||||
/// [`ClipState::reason`]: enabled, but the host policy forbids file transfer (`no-files` /
|
||||
/// `text-only`) — surfaced so the client greys "Include files" with a footnote.
|
||||
pub const CLIP_REASON_NO_FILES: u8 = 4;
|
||||
|
||||
/// [`ClipFetchHdr::status`]: the requested format is being served; data chunks follow until FIN.
|
||||
pub const CLIP_FETCH_OK: u8 = 0;
|
||||
/// [`ClipFetchHdr::status`]: the fetch named a `seq` that is no longer the holder's current offer;
|
||||
/// the requester degrades the paste to "nothing inserted" rather than wrong data. No chunks follow.
|
||||
pub const CLIP_FETCH_STALE: u8 = 1;
|
||||
/// [`ClipFetchHdr::status`]: the format/index is not available (no backend, or it vanished). No
|
||||
/// chunks follow.
|
||||
pub const CLIP_FETCH_UNAVAILABLE: u8 = 2;
|
||||
/// [`ClipFetchHdr::status`]: policy/cap denies this fetch (e.g. a file fetch under `no-files`). No
|
||||
/// chunks follow.
|
||||
pub const CLIP_FETCH_DENIED: u8 = 3;
|
||||
|
||||
/// Maximum number of [`ClipKind`] entries in one [`ClipOffer`] (resource cap, §7).
|
||||
pub const CLIP_MAX_KINDS: usize = 16;
|
||||
/// Maximum length in bytes of a [`ClipKind::mime`] string (resource cap, §7).
|
||||
pub const CLIP_MAX_MIME: usize = 128;
|
||||
/// [`ClipFetch::file_index`] sentinel meaning "not a file fetch" (a whole non-file format, or the
|
||||
/// file *manifest* itself). Real file fetches use `0..n`.
|
||||
pub const CLIP_FILE_INDEX_NONE: u32 = u32::MAX;
|
||||
|
||||
/// One advertised clipboard format inside a [`ClipOffer`] — a portable MIME name plus a size hint.
|
||||
/// The bytes never ride here; they cross lazily on a fetch stream only when the destination pastes.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ClipKind {
|
||||
/// Portable wire MIME, e.g. `text/plain;charset=utf-8`, `text/html`, `image/png`,
|
||||
/// `application/x-punktfunk-files`. Each end maps it to a platform type at fetch time. ≤
|
||||
/// [`CLIP_MAX_MIME`] bytes; a longer one is rejected on decode.
|
||||
pub mime: String,
|
||||
/// Best-effort total size of this format in bytes; `0` = unknown (a streaming provider).
|
||||
pub size_hint: u64,
|
||||
}
|
||||
|
||||
/// `client → host` ([`MSG_CLIP_CONTROL`]): flip the shared clipboard on/off for this session.
|
||||
/// Sent when the user toggles the per-host pref and once at session start if it is on. **Nothing
|
||||
/// clipboard-related happens on either side until an `enabled: true` arrives** — opt-in at the
|
||||
/// protocol layer.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ClipControl {
|
||||
pub enabled: bool,
|
||||
/// Bitfield of [`CLIP_FLAG_FILES`] (+ reserved bits for future direction limits).
|
||||
pub flags: u8,
|
||||
}
|
||||
|
||||
/// `host → client` ([`MSG_CLIP_STATE`]): acknowledge a [`ClipControl`] and push unsolicited
|
||||
/// updates (policy changed, backend lost). The client surfaces `reason`/`policy` in the toggle UI
|
||||
/// instead of failing silently.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ClipState {
|
||||
pub enabled: bool,
|
||||
/// Bitfield of [`CLIP_POLICY_TEXT`] / [`CLIP_POLICY_FILES`] — what the host currently permits.
|
||||
pub policy: u8,
|
||||
/// One of the `CLIP_REASON_*` values explaining `enabled`/`policy`.
|
||||
pub reason: u8,
|
||||
}
|
||||
|
||||
/// Symmetric ([`MSG_CLIP_OFFER`], either direction): the lazy announcement. Sent when the local
|
||||
/// clipboard changes; carries the **format list only** (comfortably inside the 64 KiB control
|
||||
/// frame). A new offer replaces the sender's previous one; `seq` lets the holder reject stale
|
||||
/// fetches (§3.4). Files are announced as one `application/x-punktfunk-files` kind — the file
|
||||
/// list itself is fetched lazily, never inlined here.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ClipOffer {
|
||||
/// Monotonic per sender; newest wins.
|
||||
pub seq: u32,
|
||||
/// ≤ [`CLIP_MAX_KINDS`] entries.
|
||||
pub kinds: Vec<ClipKind>,
|
||||
}
|
||||
|
||||
/// `requester → holder` ([`MSG_CLIP_FETCH`], **fetch stream only**): the first message on a
|
||||
/// per-transfer bi-stream, naming which format (and, for files, which entry) of `seq` to pull.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ClipFetch {
|
||||
/// The offer `seq` this fetch is against; the holder answers [`CLIP_FETCH_STALE`] if it is no
|
||||
/// longer current.
|
||||
pub seq: u32,
|
||||
/// File index for a file transfer, or [`CLIP_FILE_INDEX_NONE`] for a non-file format / the
|
||||
/// file manifest.
|
||||
pub file_index: u32,
|
||||
/// The requested wire MIME (≤ [`CLIP_MAX_MIME`] bytes).
|
||||
pub mime: String,
|
||||
}
|
||||
|
||||
/// `holder → requester` ([`MSG_CLIP_FETCH_HDR`], **fetch stream only**): the response header that
|
||||
/// precedes the raw data chunks (which run until the stream's FIN). When `status` is anything
|
||||
/// other than [`CLIP_FETCH_OK`] no chunks follow.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ClipFetchHdr {
|
||||
/// One of the `CLIP_FETCH_*` values.
|
||||
pub status: u8,
|
||||
/// Total byte count that will follow; `0` = unknown (a streaming provider — FIN ends it).
|
||||
pub total_size: u64,
|
||||
}
|
||||
|
||||
/// Append one [`ClipKind`] to `b`: `mime_len u8 || mime bytes || size_hint u64 LE`.
|
||||
fn put_clip_kind(b: &mut Vec<u8>, k: &ClipKind) {
|
||||
let mime = k.mime.as_bytes();
|
||||
let n = mime.len().min(CLIP_MAX_MIME);
|
||||
b.push(n as u8);
|
||||
b.extend_from_slice(&mime[..n]);
|
||||
b.extend_from_slice(&k.size_hint.to_le_bytes());
|
||||
}
|
||||
|
||||
/// Read one [`ClipKind`] at `off`, returning it and the next offset.
|
||||
fn get_clip_kind(b: &[u8], off: usize) -> Result<(ClipKind, usize)> {
|
||||
if off >= b.len() {
|
||||
return Err(PunktfunkError::InvalidArg("truncated ClipKind"));
|
||||
}
|
||||
let n = b[off] as usize;
|
||||
if n > CLIP_MAX_MIME {
|
||||
return Err(PunktfunkError::InvalidArg("ClipKind mime too long"));
|
||||
}
|
||||
let mime_start = off + 1;
|
||||
let size_start = mime_start + n;
|
||||
if size_start + 8 > b.len() {
|
||||
return Err(PunktfunkError::InvalidArg("ClipKind overruns message"));
|
||||
}
|
||||
let mime = String::from_utf8_lossy(&b[mime_start..size_start]).into_owned();
|
||||
let size_hint = u64::from_le_bytes(b[size_start..size_start + 8].try_into().unwrap());
|
||||
Ok((ClipKind { mime, size_hint }, size_start + 8))
|
||||
}
|
||||
|
||||
impl ClipControl {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] enabled[5] flags[6]
|
||||
let mut b = Vec::with_capacity(7);
|
||||
b.extend_from_slice(CTL_MAGIC);
|
||||
b.push(MSG_CLIP_CONTROL);
|
||||
b.push(self.enabled as u8);
|
||||
b.push(self.flags);
|
||||
b
|
||||
}
|
||||
|
||||
pub fn decode(b: &[u8]) -> Result<ClipControl> {
|
||||
if b.len() != 7 || &b[0..4] != CTL_MAGIC || b[4] != MSG_CLIP_CONTROL {
|
||||
return Err(PunktfunkError::InvalidArg("bad ClipControl"));
|
||||
}
|
||||
Ok(ClipControl {
|
||||
enabled: b[5] != 0,
|
||||
flags: b[6],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ClipState {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] enabled[5] policy[6] reason[7]
|
||||
let mut b = Vec::with_capacity(8);
|
||||
b.extend_from_slice(CTL_MAGIC);
|
||||
b.push(MSG_CLIP_STATE);
|
||||
b.push(self.enabled as u8);
|
||||
b.push(self.policy);
|
||||
b.push(self.reason);
|
||||
b
|
||||
}
|
||||
|
||||
pub fn decode(b: &[u8]) -> Result<ClipState> {
|
||||
if b.len() != 8 || &b[0..4] != CTL_MAGIC || b[4] != MSG_CLIP_STATE {
|
||||
return Err(PunktfunkError::InvalidArg("bad ClipState"));
|
||||
}
|
||||
Ok(ClipState {
|
||||
enabled: b[5] != 0,
|
||||
policy: b[6],
|
||||
reason: b[7],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ClipOffer {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] seq[5..9] count[9] then `count` ClipKinds
|
||||
let mut b = Vec::with_capacity(10 + self.kinds.len() * 16);
|
||||
b.extend_from_slice(CTL_MAGIC);
|
||||
b.push(MSG_CLIP_OFFER);
|
||||
b.extend_from_slice(&self.seq.to_le_bytes());
|
||||
let count = self.kinds.len().min(CLIP_MAX_KINDS);
|
||||
b.push(count as u8);
|
||||
for k in &self.kinds[..count] {
|
||||
put_clip_kind(&mut b, k);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
pub fn decode(b: &[u8]) -> Result<ClipOffer> {
|
||||
if b.len() < 10 || &b[0..4] != CTL_MAGIC || b[4] != MSG_CLIP_OFFER {
|
||||
return Err(PunktfunkError::InvalidArg("bad ClipOffer"));
|
||||
}
|
||||
let seq = u32::from_le_bytes(b[5..9].try_into().unwrap());
|
||||
let count = b[9] as usize;
|
||||
if count > CLIP_MAX_KINDS {
|
||||
return Err(PunktfunkError::InvalidArg("ClipOffer too many kinds"));
|
||||
}
|
||||
let mut kinds = Vec::with_capacity(count);
|
||||
let mut off = 10;
|
||||
for _ in 0..count {
|
||||
let (k, next) = get_clip_kind(b, off)?;
|
||||
kinds.push(k);
|
||||
off = next;
|
||||
}
|
||||
if off != b.len() {
|
||||
return Err(PunktfunkError::InvalidArg("trailing bytes"));
|
||||
}
|
||||
Ok(ClipOffer { seq, kinds })
|
||||
}
|
||||
}
|
||||
|
||||
impl ClipFetch {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] seq[5..9] file_index[9..13] mime(len u8 || bytes)[13..]
|
||||
let mime = self.mime.as_bytes();
|
||||
let n = mime.len().min(CLIP_MAX_MIME);
|
||||
let mut b = Vec::with_capacity(14 + n);
|
||||
b.extend_from_slice(CTL_MAGIC);
|
||||
b.push(MSG_CLIP_FETCH);
|
||||
b.extend_from_slice(&self.seq.to_le_bytes());
|
||||
b.extend_from_slice(&self.file_index.to_le_bytes());
|
||||
b.push(n as u8);
|
||||
b.extend_from_slice(&mime[..n]);
|
||||
b
|
||||
}
|
||||
|
||||
pub fn decode(b: &[u8]) -> Result<ClipFetch> {
|
||||
if b.len() < 14 || &b[0..4] != CTL_MAGIC || b[4] != MSG_CLIP_FETCH {
|
||||
return Err(PunktfunkError::InvalidArg("bad ClipFetch"));
|
||||
}
|
||||
let seq = u32::from_le_bytes(b[5..9].try_into().unwrap());
|
||||
let file_index = u32::from_le_bytes(b[9..13].try_into().unwrap());
|
||||
let n = b[13] as usize;
|
||||
if n > CLIP_MAX_MIME || b.len() != 14 + n {
|
||||
return Err(PunktfunkError::InvalidArg("bad ClipFetch mime"));
|
||||
}
|
||||
let mime = String::from_utf8_lossy(&b[14..14 + n]).into_owned();
|
||||
Ok(ClipFetch {
|
||||
seq,
|
||||
file_index,
|
||||
mime,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ClipFetchHdr {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] status[5] total_size[6..14]
|
||||
let mut b = Vec::with_capacity(14);
|
||||
b.extend_from_slice(CTL_MAGIC);
|
||||
b.push(MSG_CLIP_FETCH_HDR);
|
||||
b.push(self.status);
|
||||
b.extend_from_slice(&self.total_size.to_le_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
pub fn decode(b: &[u8]) -> Result<ClipFetchHdr> {
|
||||
if b.len() != 14 || &b[0..4] != CTL_MAGIC || b[4] != MSG_CLIP_FETCH_HDR {
|
||||
return Err(PunktfunkError::InvalidArg("bad ClipFetchHdr"));
|
||||
}
|
||||
Ok(ClipFetchHdr {
|
||||
status: b[5],
|
||||
total_size: u64::from_le_bytes(b[6..14].try_into().unwrap()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ pub mod endpoint;
|
||||
/// Async framed-message IO over a quinn stream (`u16 LE length || payload`).
|
||||
pub mod io;
|
||||
|
||||
/// Per-transfer clipboard fetch bi-streams (`PKFs` magic + kind byte, then request/response). The
|
||||
/// transport half of the shared clipboard; wire codecs are in [`control`], state lives per side.
|
||||
pub mod clipstream;
|
||||
|
||||
/// SPAKE2 over Ed25519 for the pairing ceremony. The two roles use the asymmetric flow so
|
||||
/// the identities are ordered; each side binds **both** certificate fingerprints as the
|
||||
/// SPAKE2 identities, so the derived key only matches when client and host agree on the PIN
|
||||
|
||||
@@ -1316,3 +1316,390 @@ fn fingerprint_is_sha256_of_der() {
|
||||
assert_eq!(a, endpoint::cert_fingerprint(b"cert-a"));
|
||||
assert_ne!(a, endpoint::cert_fingerprint(b"cert-b"));
|
||||
}
|
||||
|
||||
// ---- Shared clipboard control + fetch-stream message codecs (0x40-0x44) -----------------------
|
||||
|
||||
#[test]
|
||||
fn clip_control_roundtrip() {
|
||||
for (enabled, flags) in [
|
||||
(true, 0u8),
|
||||
(false, 0),
|
||||
(true, CLIP_FLAG_FILES),
|
||||
(false, 0xFF),
|
||||
] {
|
||||
let m = ClipControl { enabled, flags };
|
||||
assert_eq!(ClipControl::decode(&m.encode()).unwrap(), m);
|
||||
}
|
||||
// Disjoint from its host→client sibling (type byte + length) and exact length.
|
||||
assert!(ClipControl::decode(
|
||||
&ClipState {
|
||||
enabled: true,
|
||||
policy: 0,
|
||||
reason: 0
|
||||
}
|
||||
.encode()
|
||||
)
|
||||
.is_err());
|
||||
let bytes = ClipControl {
|
||||
enabled: true,
|
||||
flags: 0,
|
||||
}
|
||||
.encode();
|
||||
assert!(ClipControl::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
|
||||
assert!(ClipControl::decode(&bytes[..bytes.len() - 1]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_state_roundtrip() {
|
||||
let cases = [
|
||||
ClipState {
|
||||
enabled: true,
|
||||
policy: CLIP_POLICY_TEXT | CLIP_POLICY_FILES,
|
||||
reason: CLIP_REASON_OK,
|
||||
},
|
||||
ClipState {
|
||||
enabled: false,
|
||||
policy: 0,
|
||||
reason: CLIP_REASON_BACKEND_UNAVAILABLE,
|
||||
},
|
||||
ClipState {
|
||||
enabled: true,
|
||||
policy: CLIP_POLICY_TEXT,
|
||||
reason: CLIP_REASON_NO_FILES,
|
||||
},
|
||||
];
|
||||
for m in cases {
|
||||
assert_eq!(ClipState::decode(&m.encode()).unwrap(), m);
|
||||
}
|
||||
// A ClipControl must not decode as a ClipState (type byte).
|
||||
assert!(ClipState::decode(
|
||||
&ClipControl {
|
||||
enabled: true,
|
||||
flags: 0
|
||||
}
|
||||
.encode()
|
||||
)
|
||||
.is_err());
|
||||
let bytes = cases[0].encode();
|
||||
assert!(ClipState::decode(&bytes[..bytes.len() - 1]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_offer_roundtrip() {
|
||||
// Empty offer, one kind, and a full multi-format offer (text/rich/image/files).
|
||||
let cases = [
|
||||
ClipOffer {
|
||||
seq: 0,
|
||||
kinds: vec![],
|
||||
},
|
||||
ClipOffer {
|
||||
seq: 1,
|
||||
kinds: vec![ClipKind {
|
||||
mime: "text/plain;charset=utf-8".into(),
|
||||
size_hint: 12,
|
||||
}],
|
||||
},
|
||||
ClipOffer {
|
||||
seq: u32::MAX,
|
||||
kinds: vec![
|
||||
ClipKind {
|
||||
mime: "text/plain;charset=utf-8".into(),
|
||||
size_hint: 0,
|
||||
},
|
||||
ClipKind {
|
||||
mime: "text/html".into(),
|
||||
size_hint: 4096,
|
||||
},
|
||||
ClipKind {
|
||||
mime: "image/png".into(),
|
||||
size_hint: 1 << 30,
|
||||
},
|
||||
ClipKind {
|
||||
mime: "application/x-punktfunk-files".into(),
|
||||
size_hint: 5_000_000_000,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
for m in &cases {
|
||||
assert_eq!(&ClipOffer::decode(&m.encode()).unwrap(), m);
|
||||
}
|
||||
// Trailing bytes are rejected (get_clip_kind consumes exactly to the end).
|
||||
let mut padded = cases[1].encode();
|
||||
padded.push(0);
|
||||
assert!(ClipOffer::decode(&padded).is_err());
|
||||
// A count byte over the cap is rejected before allocating.
|
||||
let mut over = cases[0].encode();
|
||||
over[9] = (CLIP_MAX_KINDS + 1) as u8;
|
||||
assert!(ClipOffer::decode(&over).is_err());
|
||||
// Disjoint from a same-family control message.
|
||||
assert!(ClipOffer::decode(
|
||||
&ClipControl {
|
||||
enabled: true,
|
||||
flags: 0
|
||||
}
|
||||
.encode()
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_fetch_roundtrip() {
|
||||
let cases = [
|
||||
ClipFetch {
|
||||
seq: 1,
|
||||
file_index: CLIP_FILE_INDEX_NONE,
|
||||
mime: "text/plain;charset=utf-8".into(),
|
||||
},
|
||||
ClipFetch {
|
||||
seq: 7,
|
||||
file_index: 0,
|
||||
mime: "application/x-punktfunk-files".into(),
|
||||
},
|
||||
ClipFetch {
|
||||
seq: u32::MAX,
|
||||
file_index: 41,
|
||||
mime: String::new(),
|
||||
},
|
||||
];
|
||||
for m in &cases {
|
||||
assert_eq!(&ClipFetch::decode(&m.encode()).unwrap(), m);
|
||||
}
|
||||
// Trailing + truncation both rejected (exact-length mime check).
|
||||
let bytes = cases[0].encode();
|
||||
assert!(ClipFetch::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
|
||||
assert!(ClipFetch::decode(&bytes[..bytes.len() - 1]).is_err());
|
||||
// A fetch-stream message must not decode as a control-stream offer, and vice-versa.
|
||||
assert!(ClipOffer::decode(&cases[0].encode()).is_err());
|
||||
assert!(ClipFetch::decode(
|
||||
&ClipOffer {
|
||||
seq: 1,
|
||||
kinds: vec![]
|
||||
}
|
||||
.encode()
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_fetch_hdr_roundtrip() {
|
||||
for (status, total_size) in [
|
||||
(CLIP_FETCH_OK, 15u64),
|
||||
(CLIP_FETCH_STALE, 0),
|
||||
(CLIP_FETCH_UNAVAILABLE, 0),
|
||||
(CLIP_FETCH_DENIED, 0),
|
||||
(CLIP_FETCH_OK, u64::MAX),
|
||||
] {
|
||||
let m = ClipFetchHdr { status, total_size };
|
||||
assert_eq!(ClipFetchHdr::decode(&m.encode()).unwrap(), m);
|
||||
}
|
||||
let bytes = ClipFetchHdr {
|
||||
status: CLIP_FETCH_OK,
|
||||
total_size: 1,
|
||||
}
|
||||
.encode();
|
||||
assert!(ClipFetchHdr::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
|
||||
assert!(ClipFetchHdr::decode(&bytes[..bytes.len() - 1]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_cap_clipboard_bit_is_distinct_and_survives_welcome() {
|
||||
// The new cap packs into the existing trailing host_caps byte with no layout change.
|
||||
assert_ne!(HOST_CAP_CLIPBOARD, HOST_CAP_GAMEPAD_STATE);
|
||||
let mut w = Welcome {
|
||||
abi_version: 1,
|
||||
udp_port: 1,
|
||||
mode: Mode {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
refresh_hz: 60,
|
||||
},
|
||||
fec: FecConfig {
|
||||
scheme: FecScheme::Gf16,
|
||||
fec_percent: 0,
|
||||
max_data_per_block: 1024,
|
||||
},
|
||||
shard_payload: 1024,
|
||||
encrypt: false,
|
||||
key: [0; 16],
|
||||
salt: [0; 4],
|
||||
frames: 0,
|
||||
compositor: CompositorPref::Auto,
|
||||
gamepad: GamepadPref::Auto,
|
||||
bitrate_kbps: 0,
|
||||
bit_depth: 8,
|
||||
color: ColorInfo::SDR_BT709,
|
||||
chroma_format: CHROMA_IDC_420,
|
||||
audio_channels: 2,
|
||||
codec: CODEC_HEVC,
|
||||
host_caps: HOST_CAP_GAMEPAD_STATE | HOST_CAP_CLIPBOARD,
|
||||
};
|
||||
let got = Welcome::decode(&w.encode()).unwrap();
|
||||
assert_eq!(got.host_caps & HOST_CAP_CLIPBOARD, HOST_CAP_CLIPBOARD);
|
||||
assert_eq!(
|
||||
got.host_caps & HOST_CAP_GAMEPAD_STATE,
|
||||
HOST_CAP_GAMEPAD_STATE
|
||||
);
|
||||
// Clipboard-off host: the bit is clear, gamepad bit still set.
|
||||
w.host_caps = HOST_CAP_GAMEPAD_STATE;
|
||||
assert_eq!(
|
||||
Welcome::decode(&w.encode()).unwrap().host_caps & HOST_CAP_CLIPBOARD,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
// ---- In-process QUIC loopback: the real clipstream fetch transport, both success and cancel ----
|
||||
|
||||
mod clip_loopback {
|
||||
use super::*;
|
||||
use crate::quic::clipstream;
|
||||
|
||||
/// Stand up two loopback quinn endpoints, connect, and return
|
||||
/// `(server_ep, client_ep, host_conn, client_conn)`. Both endpoints are returned so the caller
|
||||
/// keeps them in scope — dropping a `quinn::Endpoint` tears down its connections.
|
||||
async fn connect_pair() -> (
|
||||
quinn::Endpoint,
|
||||
quinn::Endpoint,
|
||||
quinn::Connection,
|
||||
quinn::Connection,
|
||||
) {
|
||||
let server = endpoint::server("127.0.0.1:0".parse().unwrap()).unwrap();
|
||||
let addr = server.local_addr().unwrap();
|
||||
let client = endpoint::client_insecure().unwrap();
|
||||
let accept = tokio::spawn(async move {
|
||||
let incoming = server.accept().await.expect("incoming connection");
|
||||
let conn = incoming.await.expect("host side connects");
|
||||
(server, conn)
|
||||
});
|
||||
let client_conn = client
|
||||
.connect(addr, "punktfunk")
|
||||
.unwrap()
|
||||
.await
|
||||
.expect("client side connects");
|
||||
let (server, host_conn) = accept.await.unwrap();
|
||||
(server, client, host_conn, client_conn)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_text_transfers_then_cancel_resets() {
|
||||
let (_server_ep, _client_ep, host_conn, client_conn) = connect_pair().await;
|
||||
|
||||
let payload = b"hello clipboard \xf0\x9f\x93\x8b".to_vec(); // text + a 4-byte emoji
|
||||
let holder_payload = payload.clone();
|
||||
|
||||
// Holder = the host side: accept two fetch streams. Serve the first; cancel the second.
|
||||
let holder = tokio::spawn(async move {
|
||||
// Fetch #1 — serve the payload.
|
||||
let (mut send, mut recv) = host_conn.accept_bi().await.expect("accept fetch #1");
|
||||
let kind = clipstream::read_stream_header(&mut recv)
|
||||
.await
|
||||
.expect("stream header #1");
|
||||
assert_eq!(kind, clipstream::CLIP_STREAM_KIND_FETCH);
|
||||
let req = clipstream::read_fetch(&mut recv)
|
||||
.await
|
||||
.expect("fetch req #1");
|
||||
assert_eq!(req.seq, 1);
|
||||
assert_eq!(req.file_index, CLIP_FILE_INDEX_NONE);
|
||||
assert_eq!(req.mime, "text/plain;charset=utf-8");
|
||||
clipstream::write_fetch_hdr(
|
||||
&mut send,
|
||||
&ClipFetchHdr {
|
||||
status: CLIP_FETCH_OK,
|
||||
total_size: holder_payload.len() as u64,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("write hdr #1");
|
||||
clipstream::write_data(&mut send, &holder_payload)
|
||||
.await
|
||||
.expect("write data #1");
|
||||
|
||||
// Fetch #2 — read the request, then cancel mid-transfer with RESET_STREAM.
|
||||
let (mut send2, mut recv2) = host_conn.accept_bi().await.expect("accept fetch #2");
|
||||
clipstream::read_stream_header(&mut recv2)
|
||||
.await
|
||||
.expect("stream header #2");
|
||||
let _ = clipstream::read_fetch(&mut recv2)
|
||||
.await
|
||||
.expect("fetch req #2");
|
||||
send2.reset(clipstream::cancelled_code()).unwrap();
|
||||
|
||||
host_conn // keep alive until the requester side is done
|
||||
});
|
||||
|
||||
// Requester = the client side.
|
||||
// #1: full lazy fetch of the text payload.
|
||||
let req = ClipFetch {
|
||||
seq: 1,
|
||||
file_index: CLIP_FILE_INDEX_NONE,
|
||||
mime: "text/plain;charset=utf-8".into(),
|
||||
};
|
||||
let (_send, mut recv) = clipstream::open_fetch(&client_conn, &req)
|
||||
.await
|
||||
.expect("open fetch #1");
|
||||
let hdr = clipstream::read_fetch_hdr(&mut recv)
|
||||
.await
|
||||
.expect("read hdr #1");
|
||||
assert_eq!(hdr.status, CLIP_FETCH_OK);
|
||||
assert_eq!(hdr.total_size as usize, payload.len());
|
||||
let got = clipstream::read_data(&mut recv, 8 << 20)
|
||||
.await
|
||||
.expect("read data #1");
|
||||
assert_eq!(got, payload);
|
||||
|
||||
// #2: the holder resets the stream — the requester surfaces an error rather than hanging.
|
||||
let req2 = ClipFetch {
|
||||
seq: 2,
|
||||
file_index: CLIP_FILE_INDEX_NONE,
|
||||
mime: "text/plain;charset=utf-8".into(),
|
||||
};
|
||||
let (_send2, mut recv2) = clipstream::open_fetch(&client_conn, &req2)
|
||||
.await
|
||||
.expect("open fetch #2");
|
||||
assert!(
|
||||
clipstream::read_fetch_hdr(&mut recv2).await.is_err(),
|
||||
"a cancelled fetch must surface as an error, not a hang"
|
||||
);
|
||||
|
||||
let _host_conn = holder.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_data_enforces_size_cap() {
|
||||
let (_server_ep, _client_ep, host_conn, client_conn) = connect_pair().await;
|
||||
|
||||
let big = vec![0xABu8; 200_000]; // > the 64 KiB chunk, and > the cap we set below
|
||||
let holder_payload = big.clone();
|
||||
let holder = tokio::spawn(async move {
|
||||
let (mut send, mut recv) = host_conn.accept_bi().await.expect("accept");
|
||||
clipstream::read_stream_header(&mut recv).await.unwrap();
|
||||
let _ = clipstream::read_fetch(&mut recv).await.unwrap();
|
||||
clipstream::write_fetch_hdr(
|
||||
&mut send,
|
||||
&ClipFetchHdr {
|
||||
status: CLIP_FETCH_OK,
|
||||
total_size: holder_payload.len() as u64,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let _ = clipstream::write_data(&mut send, &holder_payload).await;
|
||||
host_conn
|
||||
});
|
||||
|
||||
let req = ClipFetch {
|
||||
seq: 1,
|
||||
file_index: CLIP_FILE_INDEX_NONE,
|
||||
mime: "application/octet-stream".into(),
|
||||
};
|
||||
let (_send, mut recv) = clipstream::open_fetch(&client_conn, &req).await.unwrap();
|
||||
assert_eq!(
|
||||
clipstream::read_fetch_hdr(&mut recv).await.unwrap().status,
|
||||
CLIP_FETCH_OK
|
||||
);
|
||||
// Cap below the payload size ⇒ read_data errors instead of buffering unboundedly.
|
||||
assert!(clipstream::read_data(&mut recv, 64 * 1024).await.is_err());
|
||||
|
||||
let _host_conn = holder.await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,10 @@ pf-inject = { path = "../pf-inject" }
|
||||
# Virtual-display orchestration (per-compositor Linux backends + the Windows IddCx driver backend),
|
||||
# extracted to a subsystem crate (plan §W6). The DDC panel control + KWin zkde protocol moved with it.
|
||||
pf-vdisplay = { path = "../pf-vdisplay" }
|
||||
# Shared clipboard (design/clipboard-and-file-transfer.md §4): the per-OS session-clipboard
|
||||
# backends + coordinator. Portable dep — the crate's facade (policy / ClipCoordCmd / start)
|
||||
# compiles everywhere; the backends are cfg-gated inside it.
|
||||
pf-clipboard = { path = "../pf-clipboard" }
|
||||
# M3 native control plane (the `punktfunk/1` QUIC handshake; data plane stays native-thread UDP).
|
||||
quinn = "0.11"
|
||||
anyhow = "1"
|
||||
|
||||
@@ -847,6 +847,17 @@ async fn serve_session(
|
||||
let fec_target_ctl = fec_target.clone();
|
||||
// The session's negotiated rate — the pin PyroWave retarget-refusals ack (§4.6).
|
||||
let session_bitrate_kbps = welcome.bitrate_kbps;
|
||||
// Shared-clipboard enable state (client `ClipControl` → host). The coordinator reads it to
|
||||
// decide whether to forward host copies; the control task flips it on each `ClipControl`.
|
||||
let clip_enabled = Arc::new(AtomicBool::new(false));
|
||||
// Start the host clipboard coordinator. On success it watches the session clipboard, forwards
|
||||
// host copies as `ClipOffer`s (`clip.offer_rx` → control task → client), installs client
|
||||
// offers as a lazy source, and owns the fetch-stream accept loop. `available` is false when
|
||||
// there's no backend (gamescope / older GNOME / an unsupported platform) — the control task
|
||||
// then answers `ClipControl` with `BACKEND_UNAVAILABLE` and the decline loop below handles
|
||||
// stray fetch streams.
|
||||
let clip = pf_clipboard::start(conn.clone(), clip_enabled.clone(), compositor.is_some()).await;
|
||||
let clip_available = clip.available;
|
||||
tokio::spawn(control::run(
|
||||
ctrl_send,
|
||||
ctrl_recv,
|
||||
@@ -863,7 +874,14 @@ async fn serve_session(
|
||||
probe_tx,
|
||||
probe_result_rx,
|
||||
reconfig_result_rx,
|
||||
clip_enabled,
|
||||
clip,
|
||||
));
|
||||
// Fetch streams with no backend behind them are answered `CLIP_FETCH_UNAVAILABLE` instead of
|
||||
// hanging (the coordinator owns `accept_bi` when a backend is live — exactly one consumer).
|
||||
if !clip_available && pf_clipboard::enabled() {
|
||||
pf_clipboard::spawn_decline_loop(conn.clone());
|
||||
}
|
||||
|
||||
// Input plane: QUIC datagrams → channel → a native per-session thread. Pointer/keyboard
|
||||
// events are forwarded to the host-lifetime [`InjectorService`] (`inj_tx`) so the portal
|
||||
@@ -1731,6 +1749,138 @@ mod tests {
|
||||
host.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
/// Shared clipboard end to end over a real synthetic session
|
||||
/// (`design/clipboard-and-file-transfer.md`): with the operator policy enabled, the host
|
||||
/// advertises the capability, acknowledges an enable with a `ClipState`, and — a synthetic
|
||||
/// session mirrors no compositor, so no clipboard backend binds — declines a fetch with an
|
||||
/// `Error` the client surfaces. Exercises the whole 0x40-0x44 control+fetch path across two real
|
||||
/// endpoints (client `NativeClient` ↔ host `serve_session`). The live-backend paths (a real
|
||||
/// compositor) are covered by the on-glass test against GNOME/Hyprland.
|
||||
#[test]
|
||||
fn clipboard_control_and_fetch_decline_over_session() {
|
||||
let _serial = SESSION_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::clipboard::ClipEventCore;
|
||||
use punktfunk_core::quic::{
|
||||
CLIP_FILE_INDEX_NONE, CLIP_FLAG_FILES, CLIP_POLICY_FILES, HOST_CAP_CLIPBOARD,
|
||||
};
|
||||
|
||||
// Restore the env even on a panicking assert (the poisoned lock is recovered above, so a
|
||||
// leaked var could otherwise reach the next session test).
|
||||
struct EnvGuard(&'static str);
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
std::env::remove_var(self.0);
|
||||
}
|
||||
}
|
||||
let _env = EnvGuard("PUNKTFUNK_CLIPBOARD");
|
||||
// Operator policy on. Session tests serialize on SESSION_TEST_LOCK, and only the session
|
||||
// path (a session test) reads this env, so the mutation is race-free here.
|
||||
std::env::set_var("PUNKTFUNK_CLIPBOARD", "1");
|
||||
|
||||
let host = std::thread::spawn(|| {
|
||||
run(Punktfunk1Options {
|
||||
port: 19781,
|
||||
source: Punktfunk1Source::Synthetic,
|
||||
seconds: 0,
|
||||
frames: 600, // keep the session alive well past the control exchange
|
||||
max_sessions: 1,
|
||||
max_concurrent: 1,
|
||||
require_pairing: false,
|
||||
allow_pairing: false,
|
||||
pairing_pin: None,
|
||||
paired_store: None,
|
||||
data_port: None,
|
||||
idle_timeout: None,
|
||||
mdns: false,
|
||||
})
|
||||
});
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
|
||||
let mode = punktfunk_core::Mode {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
refresh_hz: 60,
|
||||
};
|
||||
let client = NativeClient::connect(
|
||||
"127.0.0.1",
|
||||
19781,
|
||||
mode,
|
||||
CompositorPref::Auto,
|
||||
GamepadPref::Auto,
|
||||
0, // bitrate_kbps
|
||||
0, // video_caps
|
||||
2, // audio_channels
|
||||
0, // video_codecs (HEVC-only)
|
||||
0, // preferred_codec
|
||||
None, // display_hdr
|
||||
None, // launch
|
||||
None, // pin (TOFU)
|
||||
None, // identity (host doesn't require pairing)
|
||||
std::time::Duration::from_secs(10),
|
||||
)
|
||||
.expect("client connects to synthetic host");
|
||||
|
||||
assert_ne!(
|
||||
client.host_caps() & HOST_CAP_CLIPBOARD,
|
||||
0,
|
||||
"an enabled host advertises HOST_CAP_CLIPBOARD"
|
||||
);
|
||||
|
||||
// A bounded poll over the clipboard event plane.
|
||||
let poll = |pred: &dyn Fn(&ClipEventCore) -> bool| -> Option<ClipEventCore> {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
while std::time::Instant::now() < deadline {
|
||||
match client.next_clip(std::time::Duration::from_millis(200)) {
|
||||
Ok(ev) if pred(&ev) => return Some(ev),
|
||||
Ok(_) => {}
|
||||
Err(punktfunk_core::PunktfunkError::NoFrame) => {}
|
||||
Err(_) => break, // session closed
|
||||
}
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
// Enable sync (requesting files) → the host acks with a ClipState. A synthetic session
|
||||
// mirrors no compositor, so no clipboard backend binds: the host refuses the enable with
|
||||
// `BACKEND_UNAVAILABLE` while still reporting the operator policy (files permitted).
|
||||
client.clip_control(true, CLIP_FLAG_FILES).unwrap();
|
||||
let state = poll(&|e| matches!(e, ClipEventCore::State { .. }))
|
||||
.expect("host replies with a ClipState ack");
|
||||
match state {
|
||||
ClipEventCore::State {
|
||||
enabled,
|
||||
policy,
|
||||
reason,
|
||||
} => {
|
||||
assert!(!enabled, "no backend for a synthetic session → not enabled");
|
||||
assert_eq!(
|
||||
reason,
|
||||
punktfunk_core::quic::CLIP_REASON_BACKEND_UNAVAILABLE,
|
||||
"the refusal reason is BACKEND_UNAVAILABLE"
|
||||
);
|
||||
assert_ne!(
|
||||
policy & CLIP_POLICY_FILES,
|
||||
0,
|
||||
"PUNKTFUNK_CLIPBOARD=1 permits files"
|
||||
);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
// Fetch the host clipboard: a synthetic session has no backend, so the host declines and
|
||||
// the client surfaces an Error for that transfer id.
|
||||
let xfer = client
|
||||
.clip_fetch(1, "text/plain;charset=utf-8".into(), CLIP_FILE_INDEX_NONE)
|
||||
.unwrap();
|
||||
let err = poll(&|e| matches!(e, ClipEventCore::Error { id, .. } if *id == xfer))
|
||||
.expect("host declines the fetch (no backend) → Error event");
|
||||
assert!(matches!(err, ClipEventCore::Error { .. }));
|
||||
|
||||
drop(client);
|
||||
host.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
fn test_paired_path() -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!("punktfunk-paired-test-{}.json", std::process::id()))
|
||||
}
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
//! the data-plane thread over the session's mpsc bridges.
|
||||
|
||||
use super::*;
|
||||
use pf_clipboard::ClipCoordCmd;
|
||||
use punktfunk_core::quic::{ClipControl, ClipOffer, ClipState};
|
||||
|
||||
/// Run the control task for one live session. Owns the control streams (`serve_session` hands them
|
||||
/// off after negotiation) plus every channel end that bridges to the data-plane thread. Returns
|
||||
/// when the control stream closes or a data-plane channel drops.
|
||||
/// off after negotiation) plus every channel end that bridges to the data-plane thread, and the
|
||||
/// [`pf_clipboard::ClipCoord`] handle bridging to the clipboard coordinator. Returns when the
|
||||
/// control stream closes or a data-plane channel drops.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn run(
|
||||
mut ctrl_send: quinn::SendStream,
|
||||
@@ -27,7 +30,17 @@ pub(super) async fn run(
|
||||
probe_tx: std::sync::mpsc::Sender<ProbeRequest>,
|
||||
mut probe_result_rx: tokio::sync::mpsc::UnboundedReceiver<ProbeResult>,
|
||||
mut reconfig_result_rx: tokio::sync::mpsc::UnboundedReceiver<Reconfigured>,
|
||||
clip_enabled: Arc<AtomicBool>,
|
||||
clip: pf_clipboard::ClipCoord,
|
||||
) {
|
||||
let pf_clipboard::ClipCoord {
|
||||
available: clip_available,
|
||||
cmd_tx: clip_cmd_tx,
|
||||
offer_rx: mut clip_offer_rx,
|
||||
} = clip;
|
||||
// Set once `clip_offer_rx` closes (coordinator gone / inert handle) so its `select!` branch
|
||||
// stops firing on a perpetually-ready `None`.
|
||||
let mut clip_offer_closed = false;
|
||||
let mut active = initial_mode;
|
||||
// Host-side switch rate limit (a backstop against a hostile/broken client spamming
|
||||
// Reconfigure into pipeline-rebuild churn — the drain-to-newest in the data plane already
|
||||
@@ -180,6 +193,61 @@ pub(super) async fn run(
|
||||
if io::write_msg(&mut ctrl_send, &echo.encode()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
} else if let Ok(ctl) = ClipControl::decode(&msg) {
|
||||
// Shared clipboard enable/disable (design/clipboard-and-file-transfer.md
|
||||
// §3.1). Reply with the resolved state; the operator policy is authoritative
|
||||
// over the client's request. When the policy allows it but no backend bound
|
||||
// (gamescope / older GNOME), enable is refused with BACKEND_UNAVAILABLE so the
|
||||
// client can say *why*. The resolved `enabled` gates the coordinator.
|
||||
let policy = pf_clipboard::policy();
|
||||
let (enabled, resolved_policy, reason) = match policy {
|
||||
None => (false, 0, punktfunk_core::quic::CLIP_REASON_POLICY_DISABLED),
|
||||
Some(p) if ctl.enabled && !clip_available => {
|
||||
(false, p, punktfunk_core::quic::CLIP_REASON_BACKEND_UNAVAILABLE)
|
||||
}
|
||||
Some(p) => {
|
||||
let files_ok = p & punktfunk_core::quic::CLIP_POLICY_FILES != 0;
|
||||
let wants_files =
|
||||
ctl.flags & punktfunk_core::quic::CLIP_FLAG_FILES != 0;
|
||||
let reason = if wants_files && !files_ok {
|
||||
punktfunk_core::quic::CLIP_REASON_NO_FILES
|
||||
} else {
|
||||
punktfunk_core::quic::CLIP_REASON_OK
|
||||
};
|
||||
(ctl.enabled, p, reason)
|
||||
}
|
||||
};
|
||||
clip_enabled.store(enabled, Ordering::SeqCst);
|
||||
// Drive the coordinator: enable re-announces the current host clipboard,
|
||||
// disable drops any selection we own. A dropped send (inert handle) is fine.
|
||||
let _ = clip_cmd_tx.send(ClipCoordCmd::SetEnabled(enabled));
|
||||
tracing::info!(
|
||||
enabled,
|
||||
files = enabled
|
||||
&& resolved_policy & punktfunk_core::quic::CLIP_POLICY_FILES != 0,
|
||||
"clipboard control"
|
||||
);
|
||||
let state = ClipState {
|
||||
enabled,
|
||||
policy: resolved_policy,
|
||||
reason,
|
||||
};
|
||||
if io::write_msg(&mut ctrl_send, &state.encode()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
} else if let Ok(offer) = ClipOffer::decode(&msg) {
|
||||
// The client copied: hand its lazy format list to the coordinator, which
|
||||
// installs a host-side source that fetches from the client on host paste.
|
||||
tracing::debug!(
|
||||
seq = offer.seq,
|
||||
kinds = offer.kinds.len(),
|
||||
"clipboard offer from client"
|
||||
);
|
||||
let mimes = offer.kinds.iter().map(|k| k.mime.clone()).collect();
|
||||
let _ = clip_cmd_tx.send(ClipCoordCmd::RemoteOffer {
|
||||
seq: offer.seq,
|
||||
mimes,
|
||||
});
|
||||
} else {
|
||||
tracing::warn!("unknown control message — ignoring");
|
||||
}
|
||||
@@ -190,6 +258,21 @@ pub(super) async fn run(
|
||||
break;
|
||||
}
|
||||
}
|
||||
offer = clip_offer_rx.recv(), if !clip_offer_closed => {
|
||||
// Host copied → the coordinator minted a `ClipOffer`; forward it to the client
|
||||
// (only while sync is on — a race with a just-received disable would otherwise
|
||||
// leak a stale offer). `None` = coordinator gone; disable this branch.
|
||||
match offer {
|
||||
Some(offer) => {
|
||||
if clip_enabled.load(Ordering::SeqCst)
|
||||
&& io::write_msg(&mut ctrl_send, &offer.encode()).await.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => clip_offer_closed = true,
|
||||
}
|
||||
}
|
||||
correction = reconfig_result_rx.recv() => {
|
||||
// H2 rollback/correction ack: the data plane reports the mode ACTUALLY live
|
||||
// after a rebuild that failed (stayed at the old mode) or that the backend
|
||||
|
||||
@@ -365,8 +365,16 @@ pub(super) async fn negotiate(
|
||||
// assuming HEVC.
|
||||
codec: codec_bit,
|
||||
// This host applies sequence-gated gamepad-state snapshots (InputKind::GamepadState),
|
||||
// so capable clients send those instead of the loss-fragile per-transition events.
|
||||
host_caps: punktfunk_core::quic::HOST_CAP_GAMEPAD_STATE,
|
||||
// so capable clients send those instead of the loss-fragile per-transition events. The
|
||||
// clipboard bit is advertised only when the operator policy enables it (design
|
||||
// clipboard-and-file-transfer.md §3.1) AND this platform has a backend — see
|
||||
// `pf_clipboard::cap_advertised` for the deliberate compositor-lacks-data-control case.
|
||||
host_caps: punktfunk_core::quic::HOST_CAP_GAMEPAD_STATE
|
||||
| if pf_clipboard::cap_advertised() {
|
||||
punktfunk_core::quic::HOST_CAP_CLIPBOARD
|
||||
} else {
|
||||
0
|
||||
},
|
||||
};
|
||||
io::write_msg(send, &welcome.encode()).await?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user