391f8fb9f7
ci / web (pull_request) Successful in 1m9s
apple / swift (pull_request) Successful in 1m19s
apple / screenshots (pull_request) Has been skipped
ci / docs-site (pull_request) Successful in 1m30s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 5m16s
ci / bench (pull_request) Successful in 6m8s
ci / rust (pull_request) Failing after 7m6s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 6m33s
android / android (pull_request) Successful in 12m39s
The host half of the shared clipboard (design/clipboard-and-file-transfer.md §4),
ported from feat/shared-clipboard (6bd8c18b) into the post-W6 crate shape: the
backends land as a pf-clipboard subsystem crate (the pf-inject/pf-capture
pattern) instead of growing punktfunk-host back out, and the ~340-line
punktfunk1.rs integration is re-implemented against the native.rs/control.rs
split that replaced it.
pf-clipboard:
- host::wayland — ext-data-control-v1 (KWin / wlroots / Sway / Hyprland).
- host::mutter — GNOME via Mutter's *direct* org.gnome.Mutter.RemoteDesktop
clipboard (no data-control at any GNOME version; the xdg portal needs an
interactive grant a headless host can't answer).
- host::windows + host::winfmt — Win32 clipboard on a hidden message-loop
window: WM_CLIPBOARDUPDATE listener + OLE delayed rendering (WM_RENDERFORMAT)
for text / CF_HTML / RTF / PNG.
- host::session — the backend-agnostic coordinator bridging HostClipboard to
the QUIC clipboard plane (offers, fetch accept-loop, remote offers, pastes).
- A portable facade (policy / enabled / cap_advertised / ClipCoordCmd / start /
spawn_decline_loop) so the orchestrator compiles cfg-free on every platform;
ClipCoordCmd moves into the crate (it was host-owned before).
punktfunk-host glue:
- handshake.rs advertises HOST_CAP_CLIPBOARD via pf_clipboard::cap_advertised.
- serve_session starts the coordinator (gated on a real compositor — the
synthetic source stays out of the session clipboard) and spawns the
CLIP_FETCH_UNAVAILABLE decline loop when the policy is on but no backend bound.
- control.rs gains the ClipControl/ClipOffer arms + the host-offer forward
branch, and the e2e session test (cap advertise → ClipState ack with
BACKEND_UNAVAILABLE → fetch decline) rides in native.rs's tests.
Still opt-in default OFF (PUNKTFUNK_CLIPBOARD). Remaining: the macOS client
(design §5) — then this becomes user-visible.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
148 lines
7.0 KiB
Rust
148 lines
7.0 KiB
Rust
//! 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;
|
|
});
|
|
}
|
|
});
|
|
}
|