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:
2026-07-17 15:38:11 +02:00
36 changed files with 5864 additions and 25 deletions
+37 -3
View File
@@ -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();