Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bd8c18b4d | |||
| af3a7d8cd5 | |||
| 76594f27c1 | |||
| e89b2f60eb | |||
| 63bc2bb10f | |||
| ad532b08a0 | |||
| 93093f3cf9 | |||
| fdda7144ed | |||
| 0dc414f197 | |||
| a95b518ef3 | |||
| f77fdee3e9 | |||
| a85be8e467 | |||
| 1dfcb0b2f6 | |||
| e87fd42cee | |||
| dd02e1f402 | |||
| 2271f67202 | |||
| 89aa6767f9 | |||
| 7cea893db5 | |||
| e55ff1bb28 | |||
| 890c7531d8 | |||
| e6fbcecdb9 | |||
| 64b9d11ee6 |
@@ -90,7 +90,11 @@ jobs:
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# punktfunk-client-session is the Vulkan/Skia streamer the shell execs for a connect —
|
||||
# both client binaries must ship (build-client-deb.sh installs both).
|
||||
cargo build --release --locked \
|
||||
# --features punktfunk-host/nvenc: the direct-SDK NVENC path (real RFI + recovery anchor on
|
||||
# Linux NVIDIA; design/linux-direct-nvenc.md). AMD/Intel-safe — NVENC/CUDA is dlopen'd at
|
||||
# runtime (no link-time dep; identical DT_NEEDED to a plain build), and the encoder is only
|
||||
# constructed for a CUDA capture frame + PUNKTFUNK_NVENC_DIRECT, never on VAAPI hosts.
|
||||
cargo build --release --locked --features punktfunk-host/nvenc \
|
||||
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
|
||||
|
||||
- name: Build + smoke-boot web console (bun preset)
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bedtime
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
|
||||
/**
|
||||
* Which phase of the connect flow to draw — the pure view model [ConnectOverlay] resolves from the
|
||||
* live dial/wake state, so [ConnectTakeover] / [ConnectModal] can render (and be screenshot-tested)
|
||||
* statelessly.
|
||||
*/
|
||||
internal sealed interface ConnectPhase {
|
||||
val hostName: String
|
||||
|
||||
/** The dial is in flight (shown the instant a host is picked). */
|
||||
data class Connecting(override val hostName: String) : ConnectPhase
|
||||
|
||||
/** A sleeping host is being Wake-on-LAN'd and we're waiting for it to advertise again. */
|
||||
data class Waking(override val hostName: String, val seconds: Int, val connectsAfter: Boolean) : ConnectPhase
|
||||
|
||||
/** The wake wait ran out — offer retry / cancel. */
|
||||
data class WakeTimedOut(override val hostName: String) : ConnectPhase
|
||||
}
|
||||
|
||||
/** Per-phase copy, shared by the console takeover and the touch modal so both read identically. */
|
||||
private data class ConnectCopy(
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
/** Monospace the subtitle so a ticking seconds counter doesn't jitter its width. */
|
||||
val monoSubtitle: Boolean,
|
||||
val cancelLabel: String,
|
||||
)
|
||||
|
||||
private fun connectCopy(phase: ConnectPhase): ConnectCopy = when (phase) {
|
||||
is ConnectPhase.Connecting -> ConnectCopy(
|
||||
"Connecting to ${phase.hostName}", "Establishing a secure connection…", false, "Cancel",
|
||||
)
|
||||
is ConnectPhase.Waking -> ConnectCopy(
|
||||
"Waking ${phase.hostName}…",
|
||||
"Waiting for it to come online · ${phase.seconds}s",
|
||||
true,
|
||||
// A wake-only wait (no dial after) says "Stop Waiting"; a wake that will connect says "Cancel".
|
||||
if (!phase.connectsAfter) "Stop Waiting" else "Cancel",
|
||||
)
|
||||
is ConnectPhase.WakeTimedOut -> ConnectCopy(
|
||||
"${phase.hostName} didn't wake",
|
||||
"It may still be booting, or it's powered off / off this network.",
|
||||
false,
|
||||
"Cancel",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The unified "getting you connected" feedback — one flow for BOTH phases of reaching a host, so the
|
||||
* user gets feedback the instant they pick one and it flows seamlessly into a wake if the host turns
|
||||
* out to be asleep:
|
||||
*
|
||||
* - **Connecting** ([connectingHostName] non-null): the dial is in flight. Shown immediately on tap,
|
||||
* so a host that takes a beat to answer no longer looks like nothing happened.
|
||||
* - **Waking** ([WakeController.waking] non-null): the dial failed on a sleeping host, so we're firing
|
||||
* Wake-on-LAN and waiting for it to advertise again, escalating to a retry/cancel prompt on timeout.
|
||||
*
|
||||
* Presentation is mode-aware (mirrors the Apple client): in the **console / gamepad** UI it's a
|
||||
* full-screen aurora [ConnectTakeover] — the same signature backdrop the console home uses, driven by
|
||||
* the pad (B cancels, A retries once timed out) with a hint bar. In the **default touch** UI it's a
|
||||
* Material [ConnectModal] over the host grid, matching the app's other dialogs — the aurora takeover
|
||||
* looked out of place there.
|
||||
*
|
||||
* The two phases hand off within a single Compose frame (see ConnectScreen's `doConnectDirect` →
|
||||
* `waker.start` → redial), so nothing blinks between them.
|
||||
*/
|
||||
@Composable
|
||||
fun ConnectOverlay(
|
||||
connectingHostName: String?,
|
||||
waker: WakeController,
|
||||
gamepadUi: Boolean,
|
||||
onCancelConnect: () -> Unit,
|
||||
) {
|
||||
val waking = waker.waking
|
||||
// Waking takes precedence (it only exists after a dial has failed) so a stray overlap can't strand
|
||||
// the "Connecting…" phase over a wake in progress.
|
||||
val phase = when {
|
||||
waking != null && waking.timedOut -> ConnectPhase.WakeTimedOut(waking.hostName)
|
||||
waking != null -> ConnectPhase.Waking(waking.hostName, waking.seconds, waking.connectsAfter)
|
||||
connectingHostName != null -> ConnectPhase.Connecting(connectingHostName)
|
||||
else -> return
|
||||
}
|
||||
|
||||
// System Back / pad B (remapped) cancels whatever's in flight — a plain dial or the wake wait.
|
||||
val cancel = { if (waking != null) waker.cancel() else onCancelConnect() }
|
||||
|
||||
if (gamepadUi) {
|
||||
BackHandler { cancel() }
|
||||
// A retries once a wake has timed out; B falls through to the BackHandler above.
|
||||
GamepadNavEffect2D(
|
||||
active = true,
|
||||
onDirection = {},
|
||||
onActivate = { if (phase is ConnectPhase.WakeTimedOut) waker.retry() },
|
||||
)
|
||||
ConnectTakeover(phase = phase, onCancel = cancel, onRetry = { waker.retry() })
|
||||
} else {
|
||||
// The AlertDialog owns its own scrim + system-Back handling (routed to cancel).
|
||||
ConnectModal(phase = phase, onCancel = cancel, onRetry = { waker.retry() })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The default-UI presentation: a Material dialog over the host grid, matching the app's other touch
|
||||
* dialogs. A spinner (or the sleep glyph once timed out) sits above the title; the scrim is inert so a
|
||||
* stray tap can't drop a connect in flight — only the buttons or system Back cancel.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConnectModal(
|
||||
phase: ConnectPhase,
|
||||
onCancel: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
val copy = connectCopy(phase)
|
||||
val timedOut = phase is ConnectPhase.WakeTimedOut
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
properties = DialogProperties(dismissOnClickOutside = false),
|
||||
icon = {
|
||||
if (timedOut) {
|
||||
Icon(Icons.Filled.Bedtime, contentDescription = null)
|
||||
} else {
|
||||
CircularProgressIndicator(modifier = Modifier.size(28.dp), strokeWidth = 3.dp)
|
||||
}
|
||||
},
|
||||
title = { Text(copy.title, textAlign = TextAlign.Center) },
|
||||
text = {
|
||||
Text(
|
||||
copy.subtitle,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = if (copy.monoSubtitle) FontFamily.Monospace else FontFamily.Default,
|
||||
)
|
||||
},
|
||||
// No confirm action until the wake times out; then "Try Again" is the primary button.
|
||||
confirmButton = {
|
||||
if (timedOut) TextButton(onClick = onRetry) { Text("Try Again") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onCancel) { Text(copy.cancelLabel) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The console / gamepad presentation: an opaque aurora backdrop with a centred spinner/title/subtitle
|
||||
* for [phase], plus a bottom hint bar spelling out the pad actions (B cancels, A retries once timed
|
||||
* out) — glyph-driven like every other console screen. onClick keeps the hints tappable too, so a
|
||||
* user without a working pad can still get out.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConnectTakeover(
|
||||
phase: ConnectPhase,
|
||||
onCancel: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
val copy = connectCopy(phase)
|
||||
val timedOut = phase is ConnectPhase.WakeTimedOut
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
// Swallow taps so the screen behind can't be touched through the takeover.
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
GamepadAuroraBackground(Modifier.fillMaxSize())
|
||||
Column(
|
||||
Modifier.padding(horizontal = 40.dp).widthIn(max = 460.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
if (timedOut) {
|
||||
Box(Modifier.size(120.dp), contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
Icons.Filled.Bedtime,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.9f),
|
||||
modifier = Modifier.size(46.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
PulsingSpinner()
|
||||
}
|
||||
Text(
|
||||
copy.title,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 24.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
copy.subtitle,
|
||||
color = Color.White.copy(alpha = 0.65f),
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = if (copy.monoSubtitle) FontFamily.Monospace else FontFamily.Default,
|
||||
)
|
||||
}
|
||||
val hints = buildList {
|
||||
add(PadGlyph.hint('B', copy.cancelLabel, onClick = onCancel))
|
||||
if (timedOut) add(PadGlyph.hint('A', "Try Again", onClick = onRetry))
|
||||
}
|
||||
GamepadHintBar(hints, Modifier.align(Alignment.BottomCenter).padding(bottom = 28.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The connecting/waking indicator: a white progress ring inside two brand-violet halo rings that
|
||||
* expand and fade on a staggered loop — a small sign of life so the takeover reads as working, not
|
||||
* stalled.
|
||||
*/
|
||||
@Composable
|
||||
private fun PulsingSpinner() {
|
||||
val transition = rememberInfiniteTransition(label = "connectPulse")
|
||||
val pulse by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(tween(1600, easing = LinearEasing), RepeatMode.Restart),
|
||||
label = "pulse",
|
||||
)
|
||||
Box(Modifier.size(120.dp), contentAlignment = Alignment.Center) {
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val maxR = size.minDimension / 2f
|
||||
for (i in 0..1) {
|
||||
val p = (pulse + i * 0.5f) % 1f
|
||||
drawCircle(
|
||||
color = Color(0xFF8678F5).copy(alpha = (1f - p) * 0.35f),
|
||||
radius = maxR * (0.42f + p * 0.58f),
|
||||
style = Stroke(width = 2.dp.toPx()),
|
||||
)
|
||||
}
|
||||
}
|
||||
CircularProgressIndicator(
|
||||
color = Color.White,
|
||||
strokeWidth = 3.dp,
|
||||
modifier = Modifier.size(54.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,16 @@ private class RequestAccessState(val target: PendingTrust) {
|
||||
val cancelled = AtomicBoolean(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* A plain dial in flight — [hostName] labels the unified [ConnectOverlay]'s "Connecting…" phase, and
|
||||
* [cancelled] lets its Cancel abort. The native connect is a blocking call with no abort, so Cancel
|
||||
* returns the UI immediately and a late-arriving handle is torn down silently rather than navigating
|
||||
* into a session the user already backed out of. Mirrors [RequestAccessState]'s late-result handling.
|
||||
*/
|
||||
private class ConnectAttempt(val hostName: String) {
|
||||
val cancelled = AtomicBoolean(false)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConnectScreen(
|
||||
settings: Settings,
|
||||
@@ -107,6 +117,9 @@ fun ConnectScreen(
|
||||
var port by remember { mutableStateOf("9777") }
|
||||
var connecting by remember { mutableStateOf(false) }
|
||||
var status by remember { mutableStateOf<String?>(null) }
|
||||
// A plain dial in flight (drives the "Connecting…" phase of the full-screen ConnectOverlay); null
|
||||
// when idle or when the request-access / wake flows own the screen instead.
|
||||
var attempt by remember { mutableStateOf<ConnectAttempt?>(null) }
|
||||
// The host streams at exactly this mode; "Native" settings resolve from the device display.
|
||||
val (w, h, hz) = settings.effectiveMode(context)
|
||||
|
||||
@@ -267,11 +280,20 @@ fun ConnectScreen(
|
||||
status = "Identity not ready yet — try again in a moment"
|
||||
return
|
||||
}
|
||||
val thisAttempt = ConnectAttempt(name)
|
||||
attempt = thisAttempt // shows the ConnectOverlay's "Connecting…" phase immediately
|
||||
connecting = true
|
||||
status = "Connecting to $targetHost:$targetPort…"
|
||||
status = null
|
||||
discovery.stop() // free the Wi-Fi radio before the stream session
|
||||
scope.launch {
|
||||
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS)
|
||||
// Cancelled mid-dial: the UI's already been returned (and discovery restarted) by
|
||||
// cancelConnect — drop the just-opened session silently rather than navigating into it.
|
||||
if (thisAttempt.cancelled.get()) {
|
||||
if (handle != 0L) withContext(Dispatchers.IO) { NativeBridge.nativeClose(handle) }
|
||||
return@launch
|
||||
}
|
||||
attempt = null
|
||||
connecting = false
|
||||
if (handle != 0L) {
|
||||
if (pinHex == null) { // TOFU: pin what we observed (unpaired)
|
||||
@@ -284,7 +306,9 @@ fun ConnectScreen(
|
||||
} else {
|
||||
discovery.start()
|
||||
if (onFailure != null) {
|
||||
status = ""
|
||||
// Hand off to the wake-and-wait flow — clearing `attempt` above and setting
|
||||
// `waker.waking` here land in one recompose, so the overlay slides
|
||||
// Connecting → Waking without a blank frame.
|
||||
onFailure()
|
||||
} else {
|
||||
status = "Connection failed — check host/port, PIN, and logcat"
|
||||
@@ -293,6 +317,16 @@ fun ConnectScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel a plain dial in flight (the overlay's "Connecting…" phase, B / Cancel). The native
|
||||
// connect can't be aborted, so flag this attempt (a late handle is closed silently in
|
||||
// doConnectDirect) and return the UI now, resuming the discovery we paused for the dial.
|
||||
fun cancelConnect() {
|
||||
attempt?.cancelled?.set(true)
|
||||
attempt = null
|
||||
connecting = false
|
||||
discovery.start()
|
||||
}
|
||||
|
||||
// Wake-aware connect. If auto-wake is on (Settings.autoWakeEnabled) and the target is a saved
|
||||
// host with a learned MAC that ISN'T currently advertising, fire a wake packet and DIAL
|
||||
// IMMEDIATELY — mDNS absence does NOT mean unreachable (a host reached over a routed network —
|
||||
@@ -506,40 +540,21 @@ fun ConnectScreen(
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
status?.let {
|
||||
// While connecting it's progress (spinner, neutral); otherwise it's a
|
||||
// result/error (red). Previously every status showed in error-red, so a
|
||||
// normal "Connecting…" looked like a failure.
|
||||
if (connecting) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Result/error: a filled error container reads as a real failure banner,
|
||||
// not just red text lost in the layout.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
// In-flight progress (connecting / waking) is the full-screen ConnectOverlay's
|
||||
// job now, so `status` only ever carries a result/error here — a filled error
|
||||
// container reads as a real failure banner, not just red text lost in the layout.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
@@ -837,8 +852,15 @@ fun ConnectScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Topmost: the "Waking…" overlay rides over both the touch grid and the console home.
|
||||
WakeOverlay(waker, gamepadUi)
|
||||
// Topmost: the full-screen connect takeover — instant "Connecting…" feedback on any dial, flowing
|
||||
// seamlessly into the "Waking…" wait if the host turns out to be asleep. Rides over both the touch
|
||||
// grid and the console home.
|
||||
ConnectOverlay(
|
||||
connectingHostName = attempt?.hostName,
|
||||
waker = waker,
|
||||
gamepadUi = gamepadUi,
|
||||
onCancelConnect = { cancelConnect() },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,7 +26,7 @@ import kotlinx.coroutines.launch
|
||||
* [isOnline]/[onOnline] callbacks all run on the main thread; only the blocking send is off-loaded.
|
||||
*/
|
||||
class WakeController(private val scope: CoroutineScope) {
|
||||
/** null = idle; non-null drives [WakeOverlay]. */
|
||||
/** null = idle; non-null drives the "Waking…" phase of [ConnectOverlay]. */
|
||||
data class Waking(
|
||||
val hostName: String,
|
||||
/** Whether coming online chains into a connect (Wake & Connect) vs. just stopping. */
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bedtime
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* The "Waking <host>…" modal shown while [WakeController] brings a sleeping host back — a spinner + a
|
||||
* live elapsed counter, escalating to a retry/cancel prompt on timeout. The Android mirror of the
|
||||
* Apple client's `WakeOverlay`. Rendered over BOTH the touch grid and the console home; it swallows
|
||||
* input to the screen behind it, and in console mode the pad drives it (B cancels, A retries once
|
||||
* timed out) while the touch buttons work for a pointer.
|
||||
*/
|
||||
@Composable
|
||||
fun WakeOverlay(waker: WakeController, gamepadUi: Boolean) {
|
||||
val w = waker.waking ?: return
|
||||
|
||||
BackHandler { waker.cancel() } // system Back / pad B (remapped) cancels the wait
|
||||
if (gamepadUi) {
|
||||
// A retries once timed out; B falls through to the BackHandler above.
|
||||
GamepadNavEffect2D(
|
||||
active = true,
|
||||
onDirection = {},
|
||||
onActivate = { if (w.timedOut) waker.retry() },
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
// Swallow taps so the home behind can't be touched while waking.
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(40.dp)
|
||||
.widthIn(max = 380.dp)
|
||||
.clip(RoundedCornerShape(22.dp))
|
||||
.background(Color(0xF01A1730))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(22.dp))
|
||||
.padding(28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
if (w.timedOut) {
|
||||
Icon(
|
||||
Icons.Filled.Bedtime,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.85f),
|
||||
modifier = Modifier.size(34.dp),
|
||||
)
|
||||
Text(
|
||||
"${w.hostName} didn't wake",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 19.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
"It may still be booting, or it's powered off / off this network.",
|
||||
color = Color.White.copy(alpha = 0.6f),
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
) {
|
||||
OutlinedButton(onClick = { waker.cancel() }) { Text("Cancel") }
|
||||
Button(onClick = { waker.retry() }) { Text("Try Again") }
|
||||
}
|
||||
} else {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
Text(
|
||||
"Waking ${w.hostName}…",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 19.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
"Waiting for it to come online · ${w.seconds}s",
|
||||
color = Color.White.copy(alpha = 0.6f),
|
||||
fontSize = 13.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
OutlinedButton(onClick = { waker.cancel() }, modifier = Modifier.padding(top = 6.dp)) {
|
||||
Text(if (w.connectsAfter) "Cancel" else "Stop Waiting")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,29 @@ class ScreenshotTest {
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
|
||||
|
||||
// The touch flow is a Material dialog over the host grid (a separate window → shootScreen).
|
||||
@Test
|
||||
fun connecting() = shootScreen("connecting") {
|
||||
HostsScene()
|
||||
ConnectingScene()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun waking() = shootScreen("waking") {
|
||||
HostsScene()
|
||||
WakingScene()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wakeTimedOut() = shootScreen("wake-timed-out") {
|
||||
HostsScene()
|
||||
WakeTimedOutScene()
|
||||
}
|
||||
|
||||
// The console flow is the full-screen aurora takeover (a root capture).
|
||||
@Test
|
||||
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
|
||||
|
||||
@Test
|
||||
fun trust() = shootScreen("trust") {
|
||||
HostsScene()
|
||||
|
||||
@@ -26,6 +26,9 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.BrandDark
|
||||
import io.unom.punktfunk.ConnectModal
|
||||
import io.unom.punktfunk.ConnectPhase
|
||||
import io.unom.punktfunk.ConnectTakeover
|
||||
import io.unom.punktfunk.Settings
|
||||
import io.unom.punktfunk.TouchMode
|
||||
import io.unom.punktfunk.SettingsScreen
|
||||
@@ -215,3 +218,31 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The default-UI connect flow (the real [ConnectModal]) in each phase — instant "Connecting…"
|
||||
* feedback, the "Waking…" wait, and the wake-timed-out prompt. These render as a Material dialog over
|
||||
* the host grid, so the test composes [HostsScene] behind them and captures the whole screen.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConnectingScene() =
|
||||
ConnectModal(ConnectPhase.Connecting("Living Room PC"), onCancel = {}, onRetry = {})
|
||||
|
||||
@Composable
|
||||
internal fun WakingScene() =
|
||||
ConnectModal(
|
||||
ConnectPhase.Waking("Living Room PC", seconds = 12, connectsAfter = true),
|
||||
onCancel = {}, onRetry = {},
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun WakeTimedOutScene() =
|
||||
ConnectModal(ConnectPhase.WakeTimedOut("Living Room PC"), onCancel = {}, onRetry = {})
|
||||
|
||||
/**
|
||||
* The console / gamepad connect flow (the real full-screen [ConnectTakeover]) — the aurora backdrop
|
||||
* with a bottom hint bar, the same signature look the console home uses.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConnectConsoleScene() =
|
||||
ConnectTakeover(ConnectPhase.Connecting("Living Room PC"), onCancel = {}, onRetry = {})
|
||||
|
||||
@@ -243,6 +243,11 @@ fn run_sync(
|
||||
if pending.is_none() {
|
||||
match client.next_frame(Duration::from_millis(5)) {
|
||||
Ok(frame) => {
|
||||
// Loss recovery (RFI): feed the frame index so a forward gap fires a throttled
|
||||
// reference-frame-invalidation request — an RFI-capable host (AMD LTR / NVENC)
|
||||
// recovers with a cheap clean P-frame instead of a full IDR. The frames_dropped
|
||||
// keyframe path below stays the backstop when the recovery frame itself is lost.
|
||||
let _ = client.note_frame_index(frame.frame_index);
|
||||
if fed == 0 {
|
||||
let p = &frame.data;
|
||||
log::info!(
|
||||
@@ -1026,6 +1031,10 @@ fn feeder_loop(
|
||||
while !shutdown.load(Ordering::Relaxed) {
|
||||
match client.next_frame(Duration::from_millis(5)) {
|
||||
Ok(frame) => {
|
||||
// Loss recovery (RFI): a forward frame-index gap fires a throttled reference-frame-
|
||||
// invalidation request so an RFI-capable host recovers with a cheap clean P-frame
|
||||
// instead of a full IDR (the frames_dropped keyframe path is the backstop).
|
||||
let _ = client.note_frame_index(frame.frame_index);
|
||||
if stats.enabled() {
|
||||
let received_ns = now_realtime_ns();
|
||||
let clock_offset = clock_offset.load(Ordering::Relaxed) as i128;
|
||||
|
||||
@@ -14,19 +14,11 @@
|
||||
<!-- Wake-on-LAN needs to send a UDP broadcast magic packet (a sleeping host has no ARP
|
||||
entry, so unicast can't wake it). Since iOS 14 / tvOS 14 the OS blocks sending to
|
||||
broadcast/multicast addresses unless the app carries this managed entitlement — it must
|
||||
be requested from and approved by Apple for the App ID, then enabled in the provisioning
|
||||
profile. macOS is not gated by this (its App Sandbox network.client/server cover it).
|
||||
|
||||
GATED pending Apple's approval of the request (form filed) — an unauthorized managed
|
||||
entitlement breaks iOS/tvOS signing, so it's commented out to keep those apps releasable.
|
||||
ON APPROVAL: (1) uncomment the two lines below, and (2) flip
|
||||
PunktfunkConnection.wakeOnLANAvailable (PunktfunkConnection.swift) to enable the iOS/tvOS
|
||||
wake path + UI. Until then iOS/tvOS Wake-on-LAN is a clean no-op — MACs are still learned
|
||||
from mDNS so it works immediately once ungated. macOS is unaffected (separate entitlements
|
||||
file, no multicast entitlement needed). -->
|
||||
<!--
|
||||
be approved by Apple for the App ID and enabled in the provisioning profile. macOS is not
|
||||
gated by this (its App Sandbox network.client/server cover it), hence its separate file.
|
||||
Approved and provisioned, so it's enabled here and PunktfunkConnection.wakeOnLANAvailable
|
||||
is true on iOS/tvOS too. -->
|
||||
<key>com.apple.developer.networking.multicast</key>
|
||||
<true/>
|
||||
-->
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -60,7 +60,8 @@ struct ContentView: View {
|
||||
@State private var speedTestTarget: StoredHost?
|
||||
@State private var libraryTarget: StoredHost?
|
||||
/// Wakes a sleeping host and waits for it to come back online before connecting (drives the
|
||||
/// "Waking…" overlay). macOS-only in practice — WoL is gated off on iOS/tvOS.
|
||||
/// "Waking…" phase of the connect overlay). Available on every platform now that the iOS/tvOS
|
||||
/// multicast entitlement is granted (see PunktfunkConnection.wakeOnLANAvailable).
|
||||
@StateObject private var waker = HostWaker()
|
||||
#if os(macOS)
|
||||
/// Whether the hosting window is native-fullscreen right now (reported by
|
||||
@@ -86,6 +87,10 @@ struct ContentView: View {
|
||||
// with no (extended) controller attached tvOS falls back to HomeView as before.
|
||||
@ObservedObject private var gamepadManager = GamepadManager.shared
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
/// Auto-wake on connect (Settings → General). On (default): a dial to an offline saved host
|
||||
/// fires Wake-on-LAN up front and falls into the "Waking…" wait if the dial fails. Off: connects
|
||||
/// go straight through with no wake. The explicit "Wake Host" action is unaffected either way.
|
||||
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||
private var gamepadUIActive: Bool {
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
||||
@@ -259,9 +264,26 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private var home: some View {
|
||||
// The "Waking…" overlay rides over BOTH home UIs (and the pre-connect window is still
|
||||
// `home`, so it covers the whole wake→online→connect sequence).
|
||||
homeBase.overlay { WakeOverlay(waker: waker) }
|
||||
// The full-screen connect takeover rides over BOTH home UIs (and the pre-connect window is
|
||||
// still `home`, so it covers the whole dial → wake → online → connect sequence): instant
|
||||
// "Connecting…" feedback on any dial, flowing seamlessly into the "Waking…" wait if the host
|
||||
// turns out to be asleep.
|
||||
homeBase.overlay {
|
||||
ConnectOverlay(
|
||||
connectingHostName: connectingOverlayName,
|
||||
waker: waker,
|
||||
gamepadUI: gamepadUIActive,
|
||||
onCancelConnect: { model.disconnect() })
|
||||
}
|
||||
}
|
||||
|
||||
/// The host label for the connect takeover's "Connecting…" phase — a plain dial in flight. Nil
|
||||
/// during the delegated-approval wait (that has its own "Waiting for approval" prompt, so the
|
||||
/// takeover must not stack over it) and, of course, when idle or streaming.
|
||||
private var connectingOverlayName: String? {
|
||||
guard awaitingApproval == nil, model.phase == .connecting, let host = model.activeHost
|
||||
else { return nil }
|
||||
return host.displayName
|
||||
}
|
||||
|
||||
@ViewBuilder private var homeBase: some View {
|
||||
@@ -567,7 +589,8 @@ struct ContentView: View {
|
||||
// packet up front, so a genuinely-asleep host is waking while the connect times out; only
|
||||
// when that dial FAILS do we fall into the visible "Waking…" wait — a cold box takes far
|
||||
// longer to boot than a connect will sit — and redial once it's back on mDNS.
|
||||
if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty, !discovery.advertises(host) {
|
||||
if autoWakeEnabled, PunktfunkConnection.wakeOnLANAvailable,
|
||||
!host.wakeMacs.isEmpty, !discovery.advertises(host) {
|
||||
discovery.start() // so the wake-wait can observe it reappear
|
||||
startSessionDirect(
|
||||
host, launchID: launchID, allowTofu: allowTofu,
|
||||
@@ -624,7 +647,9 @@ struct ContentView: View {
|
||||
private func prepareWake(for host: StoredHost) {
|
||||
if let live = discovery.hosts.first(where: { host.matches($0) }) {
|
||||
store.updateMacs(host.id, macs: live.macAddresses) // learn — on every platform
|
||||
} else if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty {
|
||||
} else if autoWakeEnabled, PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty {
|
||||
// Auto-wake only: fire the up-front packet so a genuinely-asleep host is booting while the
|
||||
// dial times out. With auto-wake off, connects go straight through (no packet).
|
||||
let macs = host.wakeMacs
|
||||
let ip = host.address
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// The unified "getting you connected" overlay — one look for BOTH phases of reaching a host, so the
|
||||
// user gets feedback the instant they pick one and it flows seamlessly into a wake if the host turns
|
||||
// out to be asleep. The Apple mirror of the Android client's `ConnectOverlay` and the shared console
|
||||
// UI's connect/wake takeover; it replaces the old centered-card `WakeOverlay`.
|
||||
//
|
||||
// - Connecting (`connectingHostName` non-nil): the dial is in flight. Shown immediately on activate
|
||||
// so a host that takes a beat to answer no longer looks like nothing happened.
|
||||
// - Waking (`waker.waking` non-nil): the dial failed on a sleeping host, so we're firing
|
||||
// Wake-on-LAN and waiting for it to advertise again, escalating to a retry/cancel prompt on
|
||||
// timeout.
|
||||
//
|
||||
// Presentation is mode-aware: the gamepad ("console") UI gets a full-screen aurora takeover — the
|
||||
// same living backdrop the console home wears, so it reads as a deliberate 10-foot moment; the
|
||||
// default touch/desktop UI gets a Liquid Glass modal over a dim scrim, which sits right at home among
|
||||
// the app's other floating surfaces (the trust card, the HUD) instead of a full-screen aurora that
|
||||
// looked out of place there.
|
||||
//
|
||||
// The two phases hand off within a single view update (HostWaker clears `waking` and starts the
|
||||
// connect in the same MainActor step), so the overlay never blinks between them. It swallows input to
|
||||
// the screen behind it, and on iOS/macOS the pad drives it (B cancels, A retries once timed out).
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
struct ConnectOverlay: View {
|
||||
/// Non-nil while a plain dial is in flight (the delegated-approval wait has its own prompt, so it
|
||||
/// passes nil here). Drives the "Connecting…" phase.
|
||||
let connectingHostName: String?
|
||||
@ObservedObject var waker: HostWaker
|
||||
/// The console launcher is up → full-screen aurora takeover; otherwise the default UI's Liquid
|
||||
/// Glass modal.
|
||||
var gamepadUI: Bool
|
||||
/// Cancel a dial in flight — tears down the (uncancelable) connect and returns the UI; the late
|
||||
/// result is discarded by SessionModel's connect guard.
|
||||
var onCancelConnect: () -> Void
|
||||
|
||||
private enum Phase {
|
||||
case connecting(name: String)
|
||||
case waking(HostWaker.Waking)
|
||||
}
|
||||
|
||||
/// Waking takes precedence — it only ever exists after a dial has already failed, so a stray
|
||||
/// overlap can't strand the "Connecting…" phase over a wake in progress.
|
||||
private var phase: Phase? {
|
||||
if let w = waker.waking { return .waking(w) }
|
||||
if let name = connectingHostName { return .connecting(name: name) }
|
||||
return nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let phase {
|
||||
ZStack {
|
||||
if gamepadUI {
|
||||
// Console: an opaque, living aurora over everything.
|
||||
Color.black.ignoresSafeArea()
|
||||
GamepadScreenBackground().ignoresSafeArea()
|
||||
Color.clear.contentShape(Rectangle()).onTapGesture {}
|
||||
content(phase).padding(40).frame(maxWidth: 460)
|
||||
} else {
|
||||
// Default UI: a Liquid Glass modal over a dim scrim.
|
||||
Rectangle().fill(.black.opacity(0.5)).ignoresSafeArea()
|
||||
.contentShape(Rectangle()).onTapGesture {}
|
||||
content(phase)
|
||||
.padding(28)
|
||||
.frame(maxWidth: 380)
|
||||
.glassBackground(RoundedRectangle(cornerRadius: 26, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
||||
.padding(40)
|
||||
}
|
||||
}
|
||||
.environment(\.colorScheme, .dark)
|
||||
.transition(.opacity)
|
||||
#if os(iOS) || os(macOS)
|
||||
.background { ConnectControllerInput(waker: waker, onCancelConnect: onCancelConnect) }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func content(_ phase: Phase) -> some View {
|
||||
// The takeover carries larger type than the compact modal.
|
||||
let titleSize: CGFloat = gamepadUI ? 24 : 19
|
||||
let bodySize: CGFloat = gamepadUI ? 14 : 13
|
||||
VStack(spacing: gamepadUI ? 16 : 14) {
|
||||
switch phase {
|
||||
case .connecting(let name):
|
||||
ProgressView().controlSize(.large).tint(.white)
|
||||
Text("Connecting to \(name)")
|
||||
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("Establishing a secure connection…")
|
||||
.font(.geist(bodySize, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
|
||||
Button("Cancel") { onCancelConnect() }.buttonStyle(.bordered).padding(.top, 6)
|
||||
case .waking(let w) where w.timedOut:
|
||||
Image(systemName: "moon.zzz.fill")
|
||||
.font(.system(size: gamepadUI ? 40 : 34)).foregroundStyle(.white.opacity(0.9))
|
||||
Text("\(w.hostName) didn't wake")
|
||||
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("It may still be booting, or it's powered off / off this network.")
|
||||
.font(.geist(bodySize, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
|
||||
.multilineTextAlignment(.center)
|
||||
HStack(spacing: 12) {
|
||||
Button("Cancel") { waker.cancel() }.buttonStyle(.bordered)
|
||||
Button("Try Again") { waker.retry() }.glassProminentButtonStyle()
|
||||
}
|
||||
.padding(.top, 6)
|
||||
case .waking(let w):
|
||||
ProgressView().controlSize(.large).tint(.white)
|
||||
Text("Waking \(w.hostName)…")
|
||||
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("Waiting for it to come online · \(w.seconds)s")
|
||||
.font(.geistFixed(bodySize)).foregroundStyle(.white.opacity(0.6)).monospacedDigit()
|
||||
// A wake-only wait (no dial after) offers "Stop Waiting"; a wake-&-connect is "Cancel".
|
||||
Button(w.connectsAfter ? "Cancel" : "Stop Waiting") { waker.cancel() }
|
||||
.buttonStyle(.bordered).padding(.top, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
/// Controller binding for the overlay: B cancels whatever's in flight (a dial or the wake wait); A
|
||||
/// retries once a wake has timed out. The closures read the live state on each press, so they stay
|
||||
/// correct across the Connecting ↔ Waking handoff without the view re-mounting. A zero-size backing
|
||||
/// view owning a `GamepadMenuInput` for the overlay's lifetime (the home is gated inactive while the
|
||||
/// overlay is up, so nothing else is consuming the pad).
|
||||
private struct ConnectControllerInput: View {
|
||||
@ObservedObject var waker: HostWaker
|
||||
var onCancelConnect: () -> Void
|
||||
@State private var input = GamepadMenuInput(manager: .shared)
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.onAppear {
|
||||
input.onBack = { if waker.waking != nil { waker.cancel() } else { onCancelConnect() } }
|
||||
input.onConfirm = { if waker.waking?.timedOut == true { waker.retry() } }
|
||||
input.start()
|
||||
}
|
||||
.onDisappear { input.stop() }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -65,6 +65,9 @@ struct GamepadHomeView: View {
|
||||
/// Same gate the touch grid's "Browse Library…" context-menu item uses (default ON; the
|
||||
/// Settings "Game library" toggle opts out).
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||
/// Auto-wake on connect (default ON) — when off, activating an offline host just dials (no wake),
|
||||
/// so the tile drops its "Wake & Connect" affordance for a plain "Connect".
|
||||
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||
#if os(iOS)
|
||||
/// `.compact` in a landscape phone window — drives tighter chrome so everything still fits.
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
@@ -192,9 +195,12 @@ struct GamepadHomeView: View {
|
||||
onActivate: { $0.activate() },
|
||||
onSecondary: { openLibraryForSelected() },
|
||||
onTertiary: { showSettings = true },
|
||||
// Stop consuming the controller while another screen (or the wake overlay) is on top —
|
||||
// otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad).
|
||||
isActive: libraryTarget == nil && !showSettings && !showAddHost && waker.waking == nil
|
||||
// Stop consuming the controller while another screen (or the connect/wake takeover) is on
|
||||
// top — otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad),
|
||||
// and a second A during a dial would launch a concurrent connect. `.connecting` covers the
|
||||
// takeover's Connecting phase; `waker.waking` covers its Waking phase.
|
||||
isActive: libraryTarget == nil && !showSettings && !showAddHost
|
||||
&& waker.waking == nil && model.phase != .connecting
|
||||
) { tile in
|
||||
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
|
||||
}
|
||||
@@ -256,7 +262,7 @@ struct GamepadHomeView: View {
|
||||
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
||||
filled: true,
|
||||
hasLibrary: true,
|
||||
canWake: PunktfunkConnection.wakeOnLANAvailable
|
||||
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
|
||||
&& !discovery.advertises(host) && !store.probedOnline.contains(host.id)
|
||||
&& !host.wakeMacs.isEmpty,
|
||||
activate: { connect(host) })
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
// The "Waking <host>…" modal shown while HostWaker brings a sleeping host back — a spinner + a
|
||||
// live elapsed counter, escalating to a retry/cancel prompt on timeout. Presented over BOTH the
|
||||
// touch and gamepad home (a wake only ever starts on macOS today, where WoL is ungated), and it
|
||||
// drives from either a pointer (the buttons) or a controller (B cancels, A retries once timed out).
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
struct WakeOverlay: View {
|
||||
@ObservedObject var waker: HostWaker
|
||||
|
||||
var body: some View {
|
||||
if let w = waker.waking {
|
||||
ZStack {
|
||||
// Dim + swallow input to the home behind it.
|
||||
Rectangle().fill(.black.opacity(0.6)).ignoresSafeArea()
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {}
|
||||
card(w)
|
||||
.frame(maxWidth: 380)
|
||||
.padding(28)
|
||||
.consoleGlass(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
||||
.padding(40)
|
||||
}
|
||||
.environment(\.colorScheme, .dark)
|
||||
.transition(.opacity)
|
||||
#if os(iOS) || os(macOS)
|
||||
.background { WakeControllerInput(waker: waker) }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func card(_ w: HostWaker.Waking) -> some View {
|
||||
VStack(spacing: 14) {
|
||||
if w.timedOut {
|
||||
Image(systemName: "moon.zzz.fill")
|
||||
.font(.system(size: 34)).foregroundStyle(.white.opacity(0.85))
|
||||
Text("\(w.hostName) didn't wake")
|
||||
.font(.geist(19, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||
Text("It may still be booting, or it's powered off / off this network.")
|
||||
.font(.geist(13, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
|
||||
.multilineTextAlignment(.center)
|
||||
HStack(spacing: 12) {
|
||||
Button("Cancel") { waker.cancel() }.buttonStyle(.bordered)
|
||||
Button("Try Again") { waker.retry() }.glassProminentButtonStyle()
|
||||
}
|
||||
.padding(.top, 6)
|
||||
} else {
|
||||
ProgressView().controlSize(.large).tint(.white)
|
||||
Text("Waking \(w.hostName)…")
|
||||
.font(.geist(19, .bold, relativeTo: .title3)).foregroundStyle(.white)
|
||||
Text("Waiting for it to come online · \(w.seconds)s")
|
||||
.font(.geistFixed(13)).foregroundStyle(.white.opacity(0.6))
|
||||
.monospacedDigit()
|
||||
Button(w.connectsAfter ? "Cancel" : "Stop Waiting") { waker.cancel() }
|
||||
.buttonStyle(.bordered)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
/// Controller binding for the overlay: B cancels; A retries once it has timed out. A zero-size
|
||||
/// backing view owning a `GamepadMenuInput` for the overlay's lifetime (the home carousel/list is
|
||||
/// gated inactive while a wake is up, so nothing else is consuming the pad).
|
||||
private struct WakeControllerInput: View {
|
||||
@ObservedObject var waker: HostWaker
|
||||
@State private var input = GamepadMenuInput(manager: .shared)
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.onAppear {
|
||||
input.onBack = { waker.cancel() }
|
||||
input.onConfirm = { if waker.waking?.timedOut == true { waker.retry() } }
|
||||
input.start()
|
||||
}
|
||||
.onDisappear { input.stop() }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -49,8 +49,24 @@ enum ShotScenes {
|
||||
ShotScene(name: "08-gamepad-addhost", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotGamepadAddHost())
|
||||
},
|
||||
ShotScene(name: "09-waking", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotWaking())
|
||||
ShotScene(name: "09-connecting", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotConnect(kind: .connecting))
|
||||
},
|
||||
ShotScene(name: "09b-waking", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotConnect(kind: .waking))
|
||||
},
|
||||
ShotScene(name: "09c-wake-timed-out", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotConnect(kind: .timedOut))
|
||||
},
|
||||
// The default-UI presentation (Liquid Glass modal over the touch grid) of the same phases.
|
||||
ShotScene(name: "09d-connecting-modal", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotConnect(kind: .connecting, gamepadUI: false))
|
||||
},
|
||||
ShotScene(name: "09e-waking-modal", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotConnect(kind: .waking, gamepadUI: false))
|
||||
},
|
||||
ShotScene(name: "09f-wake-timed-out-modal", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotConnect(kind: .timedOut, gamepadUI: false))
|
||||
},
|
||||
]
|
||||
#endif
|
||||
@@ -137,23 +153,53 @@ private struct ShotGamepadAddHost: View {
|
||||
var body: some View { GamepadAddHostView(onAdd: { _ in }) }
|
||||
}
|
||||
|
||||
private struct ShotWaking: View {
|
||||
/// The unified connect overlay (the real `ConnectOverlay`) in each phase — instant "Connecting…"
|
||||
/// feedback, the "Waking…" wait, and the wake-timed-out prompt. `gamepadUI` picks the presentation:
|
||||
/// the console's full-screen aurora takeover over the gamepad home, or the default UI's Liquid Glass
|
||||
/// modal over the touch host grid.
|
||||
private struct ShotConnect: View {
|
||||
enum Kind { case connecting, waking, timedOut }
|
||||
let kind: Kind
|
||||
var gamepadUI = true
|
||||
|
||||
@StateObject private var store = ShotMock.hostStore()
|
||||
@StateObject private var model = SessionModel()
|
||||
@StateObject private var discovery = HostDiscovery()
|
||||
@StateObject private var waker = HostWaker()
|
||||
|
||||
var body: some View {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: .constant(nil), waker: waker,
|
||||
connect: { _ in }, connectDiscovered: { _ in }
|
||||
)
|
||||
.overlay { WakeOverlay(waker: waker) }
|
||||
.onAppear {
|
||||
waker.debugSet(.init(
|
||||
hostID: store.hosts.first?.id ?? UUID(),
|
||||
hostName: "Battlestation", connectsAfter: true, seconds: 14))
|
||||
backdrop
|
||||
.overlay {
|
||||
ConnectOverlay(
|
||||
connectingHostName: kind == .connecting ? "Battlestation" : nil,
|
||||
waker: waker,
|
||||
gamepadUI: gamepadUI,
|
||||
onCancelConnect: {})
|
||||
}
|
||||
.onAppear {
|
||||
switch kind {
|
||||
case .connecting:
|
||||
break
|
||||
case .waking:
|
||||
waker.debugSet(.init(
|
||||
hostID: store.hosts.first?.id ?? UUID(),
|
||||
hostName: "Battlestation", connectsAfter: true, seconds: 14))
|
||||
case .timedOut:
|
||||
waker.debugSet(.init(
|
||||
hostID: store.hosts.first?.id ?? UUID(),
|
||||
hostName: "Battlestation", connectsAfter: true, seconds: 90, timedOut: true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var backdrop: some View {
|
||||
if gamepadUI {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: .constant(nil), waker: waker,
|
||||
connect: { _ in }, connectDiscovered: { _ in })
|
||||
} else {
|
||||
ShotHome()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ final class HostWaker: ObservableObject {
|
||||
var timedOut = false
|
||||
}
|
||||
|
||||
/// nil = idle; non-nil drives `WakeOverlay`.
|
||||
/// nil = idle; non-nil drives the "Waking…" phase of `ConnectOverlay`.
|
||||
@Published private(set) var waking: Waking?
|
||||
|
||||
/// How long to wait for the host to reappear before giving up. Generous — a cold boot + service
|
||||
|
||||
@@ -36,6 +36,7 @@ struct GamepadSettingsView: View {
|
||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||
@AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault
|
||||
@ObservedObject private var gamepads = GamepadManager.shared
|
||||
|
||||
@@ -258,6 +259,11 @@ struct GamepadSettingsView: View {
|
||||
+ "available on the host.",
|
||||
options: SettingsOptions.compositors, current: compositor
|
||||
) { compositor = $0 },
|
||||
toggleRow(
|
||||
id: "autoWake", icon: "power", label: "Auto-wake on connect",
|
||||
detail: "Send Wake-on-LAN to a sleeping saved host and wait for it before "
|
||||
+ "streaming. Off connects straight through.",
|
||||
value: $autoWakeEnabled),
|
||||
|
||||
choiceRow(
|
||||
id: "codec", header: "Video", icon: "film", label: "Video codec",
|
||||
|
||||
@@ -42,9 +42,10 @@ extension SettingsView {
|
||||
} footer: {
|
||||
Text(matchWindow
|
||||
? "The stream follows this window — the host resizes its virtual output to match "
|
||||
+ "as you resize, no scaling. \(Self.bitrateFooter)"
|
||||
: "The host creates a virtual output at exactly this mode — "
|
||||
+ "native resolution, no scaling. \(Self.bitrateFooter)")
|
||||
+ "as you resize, so the picture stays pixel-exact (1:1) with no scaling. "
|
||||
+ "\(Self.bitrateFooter)"
|
||||
: "The host creates a virtual output at exactly this mode — native resolution, but "
|
||||
+ "a window that isn't this size is scaled to fit. \(Self.bitrateFooter)")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -294,6 +295,24 @@ extension SettingsView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-wake on connect — fire Wake-on-LAN + wait for a sleeping saved host to come back before
|
||||
/// giving up. Now available on every platform (the iOS/tvOS multicast entitlement is granted).
|
||||
@ViewBuilder var wakeSection: some View {
|
||||
Section {
|
||||
Toggle("Auto-wake on connect", isOn: $autoWakeEnabled)
|
||||
} header: {
|
||||
Text("Wake-on-LAN")
|
||||
} footer: {
|
||||
Text("Connecting to a saved host that isn't on the network yet sends a Wake-on-LAN "
|
||||
+ "packet and waits for it to come back before streaming. Turn off if a host that's "
|
||||
+ "already on just isn't visible here (e.g. over a VPN), so connects go straight "
|
||||
+ "through instead of waiting out the wake. A host's “Wake” action still works either "
|
||||
+ "way.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var windowSection: some View {
|
||||
#if os(macOS)
|
||||
Section {
|
||||
|
||||
@@ -21,7 +21,10 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.streamWidth) var width = 1920
|
||||
@AppStorage(DefaultsKey.streamHeight) var height = 1080
|
||||
@AppStorage(DefaultsKey.streamHz) var hz = 60
|
||||
@AppStorage(DefaultsKey.matchWindow) var matchWindow = false
|
||||
// Default ON: a windowed session streams at the window's native pixels (1:1, no scaling) so it
|
||||
// stays pixel-exact instead of the presenter resampling a fixed-mode frame into the window.
|
||||
// Off falls back to the explicit mode below (fixed output, scaled to non-matching windows).
|
||||
@AppStorage(DefaultsKey.matchWindow) var matchWindow = true
|
||||
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
||||
@@ -45,6 +48,7 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@ObservedObject var gamepads = GamepadManager.shared
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
|
||||
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
|
||||
#if DEBUG && !os(tvOS)
|
||||
@State var showControllerTest = false
|
||||
#endif
|
||||
@@ -106,6 +110,7 @@ struct SettingsView: View {
|
||||
Form {
|
||||
streamModeSection
|
||||
compositorSection
|
||||
wakeSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.tabItem { Label("General", systemImage: "gearshape") }
|
||||
@@ -235,6 +240,7 @@ struct SettingsView: View {
|
||||
streamModeSection
|
||||
pointerSection
|
||||
compositorSection
|
||||
wakeSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("General")
|
||||
@@ -305,6 +311,10 @@ struct SettingsView: View {
|
||||
Binding(get: { gamepadUIEnabled ? "on" : "off" }, set: { gamepadUIEnabled = $0 == "on" })
|
||||
}
|
||||
|
||||
private var autoWakeEnabledTag: Binding<String> {
|
||||
Binding(get: { autoWakeEnabled ? "on" : "off" }, set: { autoWakeEnabled = $0 == "on" })
|
||||
}
|
||||
|
||||
private var tvBody: some View {
|
||||
let currentTag = "\(width)x\(height)x\(hz)"
|
||||
let bounds = UIScreen.main.nativeBounds
|
||||
@@ -344,9 +354,13 @@ struct SettingsView: View {
|
||||
TVSelectionRow(
|
||||
title: "10-bit HDR",
|
||||
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
||||
TVSelectionRow(
|
||||
title: "Auto-wake on connect",
|
||||
options: [("On", "on"), ("Off", "off")], selection: autoWakeEnabledTag)
|
||||
Text("The host creates a virtual output at exactly this mode — native "
|
||||
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
|
||||
+ "is honored only if available on the host.")
|
||||
+ "is honored only if available on the host. Auto-wake sends Wake-on-LAN to a "
|
||||
+ "sleeping saved host and waits for it before streaming.")
|
||||
.font(.geist(20, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
@@ -70,19 +70,10 @@ func withOptionalCString<R>(_ s: String?, _ body: (UnsafePointer<CChar>?) -> R)
|
||||
public extension PunktfunkConnection {
|
||||
/// Whether the Wake-on-LAN broadcast path is usable on this platform/build. macOS can always
|
||||
/// broadcast (its App Sandbox network entitlements cover it). iOS/tvOS need the managed
|
||||
/// `com.apple.developer.networking.multicast` entitlement, which is GATED pending Apple's
|
||||
/// approval (see `Config/Punktfunk.entitlements`) — until it's granted, sending a broadcast is
|
||||
/// blocked by the OS, so the wake path + its UI are gated off there to avoid a dead action.
|
||||
/// The MAC-learning path stays active on every platform, so flipping this on once the
|
||||
/// entitlement lands makes wake work immediately. ON APPROVAL: change `#if os(macOS)` below to
|
||||
/// `true` for iOS/tvOS too (and uncomment the entitlement).
|
||||
static var wakeOnLANAvailable: Bool {
|
||||
#if os(macOS)
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
/// `com.apple.developer.networking.multicast` entitlement — now approved and enabled (see
|
||||
/// `Config/Punktfunk.entitlements`), so wake is available on every platform. Kept as the single
|
||||
/// switch every call site gates on, should a future build ever need to disable it.
|
||||
static var wakeOnLANAvailable: Bool { true }
|
||||
|
||||
/// Send a Wake-on-LAN magic packet to wake a sleeping host. `macs` are the host's NIC MAC(s)
|
||||
/// (`aa:bb:cc:dd:ee:ff`, learned from its mDNS `mac` TXT while awake); malformed entries are
|
||||
@@ -445,6 +436,20 @@ public final class PunktfunkConnection {
|
||||
_ = punktfunk_connection_request_keyframe(h)
|
||||
}
|
||||
|
||||
/// Feed each received AU's `frameIndex` (in receive order) so the client recovers from loss with a
|
||||
/// cheap reference-frame invalidation instead of always paying for a full IDR. On a forward gap —
|
||||
/// a `frameIndex` jump means the intervening frames were lost and the following AUs reference a
|
||||
/// picture that never arrived — the core fires a THROTTLED RFI request for the lost range, and an
|
||||
/// RFI-capable host (AMD LTR / NVENC) recovers with a clean P-frame rather than a 20-40× IDR
|
||||
/// spike. Call it for every received AU; the `framesDropped`-driven `requestKeyframe()` path stays
|
||||
/// the backstop for when the recovery frame itself is lost. Cheap; silently dropped after close.
|
||||
public func noteFrameIndex(_ frameIndex: UInt32) {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return }
|
||||
_ = punktfunk_connection_note_frame_index(h, frameIndex, nil)
|
||||
}
|
||||
|
||||
/// Cumulative access units the host→client reassembler dropped as unrecoverable (FEC couldn't
|
||||
/// rebuild them). The video pump polls this and calls `requestKeyframe()` when it climbs — the
|
||||
/// correct loss trigger under the host's infinite GOP, where unrecoverable loss yields
|
||||
|
||||
@@ -97,6 +97,12 @@ public enum DefaultsKey {
|
||||
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
|
||||
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
|
||||
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
|
||||
/// Auto-wake on connect: when connecting to a saved host that isn't advertising on mDNS, fire
|
||||
/// Wake-on-LAN and, if the dial fails, wait for it to come back before retrying (the "Waking…"
|
||||
/// overlay). On by default. Turn off if a host that's already on just isn't seen on mDNS (a
|
||||
/// routed/VPN host), so connects go straight through instead of waiting out the wake timeout.
|
||||
/// The explicit "Wake Host" action stays available regardless. Read by ContentView.startSession.
|
||||
public static let autoWake = "punktfunk.autoWake"
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
|
||||
@@ -70,6 +70,15 @@ final class SessionPresenter {
|
||||
private var stage2Link: CADisplayLink?
|
||||
private var metalLayer: CAMetalLayer?
|
||||
private var connection: PunktfunkConnection?
|
||||
/// The decoded frame's REAL pixel dimensions (ground truth, pushed by the view from the pump's
|
||||
/// `onDecodedSize` new-mode-IDR callback). Used for the aspect-fit in `layout` in preference to
|
||||
/// `connection.currentMode()`, which (a) lags a mid-stream resize — it only updates on the
|
||||
/// `Reconfigured` ack, and a resize-END produces no bounds change to re-run `layout` afterward —
|
||||
/// and (b) can disagree with what the host actually DELIVERED (Windows corrective-ack falls back
|
||||
/// to an advertised mode). The pixels we're drawing are the only correct aspect source; a wrong
|
||||
/// one here is the "black bars + stretched" resize artifact. nil until the first frame → `layout`
|
||||
/// falls back to `currentMode()`. Main-thread only.
|
||||
private var contentSize: CGSize?
|
||||
|
||||
/// Start the presenter for `connection`. `baseLayer` is the view's AVSampleBufferDisplayLayer:
|
||||
/// stage-1 enqueues into it; stage-2 leaves it idle and composites an opaque CAMetalLayer
|
||||
@@ -184,11 +193,18 @@ final class SessionPresenter {
|
||||
guard let metalLayer, let connection else { return }
|
||||
let mode = connection.currentMode()
|
||||
syncFrameRate(hz: mode.refreshHz) // track a mid-session Reconfigure's new refresh
|
||||
let fit: CGRect = (mode.width > 0 && mode.height > 0)
|
||||
? AVMakeRect(
|
||||
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)),
|
||||
insideRect: bounds)
|
||||
: bounds
|
||||
// Aspect source: the ACTUAL decoded dims when known (survives a lagging `currentMode()` and a
|
||||
// host that delivered a different size than requested), else the negotiated mode. The shader
|
||||
// stretches the frame across the WHOLE drawable, so this rect's aspect is the only thing that
|
||||
// keeps the picture undistorted — a stale aspect here is the post-resize black-bars+stretch.
|
||||
let aspect: CGSize? = {
|
||||
if let c = contentSize, c.width > 0, c.height > 0 { return c }
|
||||
if mode.width > 0, mode.height > 0 {
|
||||
return CGSize(width: Int(mode.width), height: Int(mode.height))
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
let fit: CGRect = aspect.map { AVMakeRect(aspectRatio: $0, insideRect: bounds) } ?? bounds
|
||||
// No implicit resize animation; contentsScale tracks the view's backing/display scale.
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
@@ -209,10 +225,20 @@ final class SessionPresenter {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Record the decoded frame's real dimensions (the view hops the pump's `onDecodedSize` to main
|
||||
/// and calls this) so `layout` aspect-fits to what's actually on screen instead of the possibly-
|
||||
/// stale `currentMode()`. Only stores — the caller re-runs `layout` right after, because a
|
||||
/// resize-END produces no bounds change to trigger one. No-op on a zero/unchanged size.
|
||||
func setContentSize(_ size: CGSize) {
|
||||
guard size.width > 0, size.height > 0, size != contentSize else { return }
|
||||
contentSize = size
|
||||
}
|
||||
|
||||
/// Stop the active pump/pipeline (≤ one poll timeout; stage-2 joins its pump) and detach the
|
||||
/// stage-2 layer + link. Does not close the connection — that stays with whoever owns it.
|
||||
/// Idempotent.
|
||||
func stop() {
|
||||
contentSize = nil // a new session re-derives it from its first frame
|
||||
pump?.stop()
|
||||
pump = nil
|
||||
stage2Link?.invalidate()
|
||||
|
||||
@@ -388,6 +388,11 @@ public final class Stage2Pipeline {
|
||||
presenter.setHdrMeta(meta)
|
||||
}
|
||||
guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
|
||||
// Loss recovery (RFI): a forward frame-index gap fires a throttled reference-
|
||||
// frame-invalidation request so an RFI-capable host (AMD LTR / NVENC) recovers
|
||||
// with a cheap clean P-frame instead of a full IDR. The framesDropped-driven
|
||||
// recovery below stays the backstop for when the recovery frame itself is lost.
|
||||
connection.noteFrameIndex(au.frameIndex)
|
||||
onFrame?(au)
|
||||
if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) {
|
||||
format = f // refreshed on every IDR (mode changes included)
|
||||
|
||||
@@ -79,6 +79,11 @@ final class StreamPump {
|
||||
if awaitingIDR { recovery.request() }
|
||||
|
||||
guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
|
||||
// Loss recovery (RFI): a forward frame-index gap fires a throttled reference-
|
||||
// frame-invalidation request so an RFI-capable host (AMD LTR / NVENC) recovers
|
||||
// with a cheap clean P-frame instead of a full IDR. The framesDropped-driven
|
||||
// recovery above stays the backstop for when the recovery frame itself is lost.
|
||||
connection.noteFrameIndex(au.frameIndex)
|
||||
onFrame?(au)
|
||||
let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data)
|
||||
if let f = idrFormat {
|
||||
|
||||
@@ -176,8 +176,14 @@ public final class StreamLayerView: NSView {
|
||||
private let presenter = SessionPresenter()
|
||||
public private(set) var connection: PunktfunkConnection?
|
||||
/// Match-window resize follower (C3) — non-nil while a session is active AND the `matchWindow`
|
||||
/// setting is on; fed the view's physical-pixel size on every relayout.
|
||||
/// setting is on (DEFAULT on, for pixel-exact windowed streaming); fed the view's physical-pixel
|
||||
/// size on every relayout so the host mode tracks the window (1:1, no presenter resample).
|
||||
private var matchFollower: MatchWindowFollower?
|
||||
/// Last decoded frame size fed into the presenter's aspect-fit. A new-mode IDR after a resize
|
||||
/// re-fits the metal sublayer to the REAL content aspect here — `layout()` only re-runs on a
|
||||
/// bounds change and a resize-END has none, so without this the layer keeps its pre-resize aspect
|
||||
/// and the shader stretches the new frame into it (black bars + squish). Main-thread only.
|
||||
private var lastDecodedContentSize: CGSize?
|
||||
private let cursorCapture = CursorCapture()
|
||||
private var inputCapture: InputCapture?
|
||||
private var appObservers: [NSObjectProtocol] = []
|
||||
@@ -638,6 +644,10 @@ public final class StreamLayerView: NSView {
|
||||
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
|
||||
// default, the stage-1 pump as the Metal-missing / DEBUG fallback. The link comes from
|
||||
// NSView.displayLink so it tracks the display this view is on.
|
||||
// Intercept the pump's coded-dims callback: re-fit the metal sublayer to the real content
|
||||
// aspect (main thread) BEFORE forwarding to the owner's overlay END-signal. Fires only on a
|
||||
// size CHANGE (first frame + each resolved resize), so this is rare, not per-frame.
|
||||
let overlayDecodedSize = onDecodedSize
|
||||
presenter.start(
|
||||
connection: connection,
|
||||
baseLayer: displayLayer,
|
||||
@@ -647,13 +657,19 @@ public final class StreamLayerView: NSView {
|
||||
makeDisplayLink: { displayLink(target: $0, selector: $1) },
|
||||
onFrame: onFrame,
|
||||
onSessionEnd: onSessionEnd,
|
||||
onDecodedSize: onDecodedSize) // resize overlay END signal (new-mode IDR dims)
|
||||
// Match-window (C3): follow the window's pixel size when the setting is on. Latched at
|
||||
// session start (mirrors the other clients); the first real `layout()` feeds the initial
|
||||
// size, so the stream converges to the window even if the connect used the explicit mode.
|
||||
onDecodedSize: { [weak self] w, h in // resize overlay END signal (new-mode IDR dims)
|
||||
DispatchQueue.main.async { self?.noteDecodedContentSize(width: w, height: h) }
|
||||
overlayDecodedSize?(w, h)
|
||||
})
|
||||
// Match-window (C3): follow the window's pixel size — DEFAULT ON, so a windowed session
|
||||
// streams 1:1 (pixel-exact) instead of the presenter resampling a fixed-mode frame into a
|
||||
// non-matching window. The first real `layout()` feeds the initial size, so the stream
|
||||
// converges to the window even though the connect used the explicit/display mode; entering
|
||||
// fullscreen reports the full-display px, restoring a native-res 1:1 present there too.
|
||||
// `?? true` so an unset default matches the Settings toggle (which also defaults on).
|
||||
let follower = MatchWindowFollower(
|
||||
connection: connection,
|
||||
enabled: UserDefaults.standard.bool(forKey: DefaultsKey.matchWindow))
|
||||
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? true)
|
||||
follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower)
|
||||
matchFollower = follower
|
||||
layoutPresenter()
|
||||
@@ -679,6 +695,18 @@ public final class StreamLayerView: NSView {
|
||||
layoutPresenter() // backing scale changed (e.g. moved to a non-retina display)
|
||||
}
|
||||
|
||||
/// A new decoded size landed (a new-mode IDR after a resize, or the session's first frame): push
|
||||
/// it to the presenter's aspect-fit and re-layout NOW. A resize-END triggers no `layout()`, so
|
||||
/// this is what makes the metal sublayer track the new content aspect instead of stretching the
|
||||
/// new frame into the pre-resize box. Deduped so a same-size repeat is a no-op. Main thread.
|
||||
private func noteDecodedContentSize(width: Int, height: Int) {
|
||||
let size = CGSize(width: width, height: height)
|
||||
guard size.width > 0, size.height > 0, size != lastDecodedContentSize else { return }
|
||||
lastDecodedContentSize = size
|
||||
presenter.setContentSize(size)
|
||||
layoutPresenter()
|
||||
}
|
||||
|
||||
/// Stop pumping (≤ one poll timeout). Does not close the connection — that stays with
|
||||
/// whoever owns it (PunktfunkConnection.close() is safe alongside a draining pump).
|
||||
public func stop() {
|
||||
@@ -688,6 +716,7 @@ public final class StreamLayerView: NSView {
|
||||
inputCapture = nil
|
||||
presenter.stop()
|
||||
matchFollower = nil
|
||||
lastDecodedContentSize = nil // the next session re-derives it from its first frame
|
||||
connection = nil
|
||||
}
|
||||
|
||||
|
||||
@@ -158,9 +158,10 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
/// mouse/keyboard stay released after navigating out and nothing re-grabs them.
|
||||
private var wasCapturedOnResign = false
|
||||
/// Match-window resize follower (C3) — non-nil while a session is active AND the `matchWindow`
|
||||
/// setting is on; fed the view's physical-pixel size from `viewDidLayoutSubviews` so an iPad
|
||||
/// Stage Manager / Split View scene resize renegotiates the host mode. iOS only (iPhone
|
||||
/// naturally no-ops fullscreen; tvOS drives display modes via AVDisplayManager instead).
|
||||
/// setting is on (DEFAULT on, for pixel-exact scene streaming); fed the view's physical-pixel
|
||||
/// size from `viewDidLayoutSubviews` so an iPad Stage Manager / Split View scene resize
|
||||
/// renegotiates the host mode (1:1, no presenter resample). iOS only (iPhone naturally no-ops
|
||||
/// its fixed full-screen scene; tvOS drives display modes via AVDisplayManager instead).
|
||||
private var matchFollower: MatchWindowFollower?
|
||||
#endif
|
||||
|
||||
@@ -183,6 +184,11 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
/// Resize-overlay END: the presenter reports the coded dims of each new-mode IDR here, so the
|
||||
/// overlay clears when a frame at the requested size actually decodes.
|
||||
var onDecodedSize: (@Sendable (Int, Int) -> Void)?
|
||||
/// Last decoded size fed into the presenter's aspect-fit. A new-mode IDR (an iPad scene resize,
|
||||
/// or a tvOS AVDisplayManager mode switch) re-fits the metal sublayer to the REAL content aspect
|
||||
/// here — `viewDidLayoutSubviews` only re-runs on a bounds change, which a resize-END lacks, so
|
||||
/// without this the layer keeps its pre-resize aspect and stretches the new frame into it. Main.
|
||||
private var lastDecodedContentSize: CGSize?
|
||||
|
||||
var captureEnabled = true {
|
||||
didSet {
|
||||
@@ -349,12 +355,14 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
}
|
||||
capture.start()
|
||||
inputCapture = capture
|
||||
// Match-window (C3): follow the scene's pixel size when the setting is on. Latched at
|
||||
// session start (mirrors the other clients); `viewDidLayoutSubviews` feeds it — covers
|
||||
// Stage Manager / Split View resizes and rotation. iPhone fullscreen naturally no-ops.
|
||||
// Match-window (C3): follow the scene's pixel size — DEFAULT ON, so a resizable iPad scene
|
||||
// streams 1:1 (pixel-exact) instead of the presenter resampling a fixed-mode frame into it.
|
||||
// `viewDidLayoutSubviews` feeds it — covers Stage Manager / Split View resizes and rotation.
|
||||
// iPhone is a fixed full-screen scene, so this naturally no-ops (reports the device mode).
|
||||
// `?? true` so an unset default matches the Settings toggle (which also defaults on).
|
||||
let follower = MatchWindowFollower(
|
||||
connection: connection,
|
||||
enabled: UserDefaults.standard.bool(forKey: DefaultsKey.matchWindow))
|
||||
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? true)
|
||||
follower.onResizeTarget = onResizeTarget
|
||||
matchFollower = follower
|
||||
#endif
|
||||
@@ -362,6 +370,10 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
// Presenter choice + lifecycle live in SessionPresenter (shared with macOS): stage-2
|
||||
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
|
||||
// default, the stage-1 pump as the Metal-missing / DEBUG fallback.
|
||||
// Intercept the pump's coded-dims callback: re-fit the metal sublayer to the real content
|
||||
// aspect (main thread) BEFORE forwarding to the owner's overlay END-signal. Fires only on a
|
||||
// size CHANGE (first frame + each resolved resize), so this is rare, not per-frame.
|
||||
let overlayDecodedSize = onDecodedSize
|
||||
presenter.start(
|
||||
connection: connection,
|
||||
baseLayer: streamView.displayLayer,
|
||||
@@ -371,7 +383,10 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
|
||||
onFrame: onFrame,
|
||||
onSessionEnd: onSessionEnd,
|
||||
onDecodedSize: onDecodedSize)
|
||||
onDecodedSize: { [weak self] w, h in
|
||||
DispatchQueue.main.async { self?.noteDecodedContentSize(width: w, height: h) }
|
||||
overlayDecodedSize?(w, h)
|
||||
})
|
||||
layoutMetalLayer()
|
||||
|
||||
#if os(iOS)
|
||||
@@ -451,6 +466,7 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
sessionDisplayManager = nil
|
||||
#endif
|
||||
presenter.stop()
|
||||
lastDecodedContentSize = nil // the next session re-derives it from its first frame
|
||||
connection = nil
|
||||
}
|
||||
|
||||
@@ -527,6 +543,18 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
presenter.layout(in: streamView.bounds, contentsScale: renderScale)
|
||||
}
|
||||
|
||||
/// A new decoded size landed (a scene/mode resize's new IDR, or the first frame): push it to the
|
||||
/// presenter's aspect-fit and re-layout NOW. A resize-END triggers no `viewDidLayoutSubviews`, so
|
||||
/// this is what makes the metal sublayer track the new content aspect instead of stretching the
|
||||
/// new frame into the pre-resize box. Deduped so a same-size repeat is a no-op. Main thread.
|
||||
private func noteDecodedContentSize(width: Int, height: Int) {
|
||||
let size = CGSize(width: width, height: height)
|
||||
guard size.width > 0, size.height > 0, size != lastDecodedContentSize else { return }
|
||||
lastDecodedContentSize = size
|
||||
presenter.setContentSize(size)
|
||||
layoutMetalLayer()
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private func setCaptured(_ on: Bool) {
|
||||
if on {
|
||||
|
||||
@@ -177,9 +177,7 @@ mod session_main {
|
||||
/// debounced resize→`Reconfigure` machinery on; the callback stores each resize-end's
|
||||
/// logical window size (load-modify-save, like the console settings screen) so the
|
||||
/// next launch opens at it.
|
||||
pub(crate) fn match_window(
|
||||
settings: &trust::Settings,
|
||||
) -> Option<Box<dyn FnMut(u32, u32)>> {
|
||||
pub(crate) fn match_window(settings: &trust::Settings) -> Option<Box<dyn FnMut(u32, u32)>> {
|
||||
settings.match_window.then(|| {
|
||||
Box::new(|w: u32, h: u32| {
|
||||
let mut s = trust::Settings::load();
|
||||
|
||||
@@ -189,14 +189,21 @@ fn status_row(online: Option<bool>, badge: &str, kind: Pill) -> Element {
|
||||
|
||||
/// The in-tile rename editor (ContentDialog can't hold a text field): name box + save/cancel.
|
||||
/// No tap-to-connect while editing — a click into the box would bubble `Tapped` to the region.
|
||||
/// `initial` seeds the text box's displayed value and is CONSTANT for the life of the edit — the
|
||||
/// field is uncontrolled, its live value kept in `live` (read at Save). Driving a *controlled* box
|
||||
/// from an always-deferred `AsyncSetState` round-trip fights the caret on fast typing and can drop
|
||||
/// the last char if Save is clicked before the write lands; an uncontrolled box + a ref sidesteps
|
||||
/// both (and skips a full-page re-render per keystroke). See the seed block in `hosts_page`.
|
||||
fn rename_editor(
|
||||
draft: &str,
|
||||
initial: &str,
|
||||
fp: String,
|
||||
live: HookRef<String>,
|
||||
set_rename: AsyncSetState<Option<(String, String)>>,
|
||||
) -> Element {
|
||||
let commit = {
|
||||
let (fp, draft, sr) = (fp.clone(), draft.to_string(), set_rename.clone());
|
||||
let (fp, live, sr) = (fp.clone(), live.clone(), set_rename.clone());
|
||||
move || {
|
||||
let draft = live.borrow();
|
||||
let name = draft.trim();
|
||||
if !name.is_empty() {
|
||||
let mut known = KnownHosts::load();
|
||||
@@ -209,12 +216,12 @@ fn rename_editor(
|
||||
}
|
||||
};
|
||||
let on_changed = {
|
||||
let sr = set_rename.clone();
|
||||
move |s: String| sr.call(Some((fp.clone(), s)))
|
||||
let live = live.clone();
|
||||
move |s: String| live.set(s)
|
||||
};
|
||||
card(
|
||||
vstack((
|
||||
text_box(draft)
|
||||
text_box(initial)
|
||||
.placeholder_text("Host name")
|
||||
.on_text_changed(on_changed),
|
||||
hstack((
|
||||
@@ -240,6 +247,14 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
let set_screen = &props.svc.set_screen;
|
||||
let set_status = &props.svc.set_status;
|
||||
let (manual, set_manual) = cx.use_state(String::new());
|
||||
// The Add-host field's live value, read by Connect at click time. This page's `use_state` is
|
||||
// unreliable as the click's source of truth: while the modal is open the page usually has no
|
||||
// reason to re-render (you open it precisely because the host ISN'T being discovered, so no
|
||||
// discovery tick fires), and the top-down reconcile skips this unchanged-props subtree — so a
|
||||
// sync `set_manual` write never re-renders the Connect button to re-capture the address, and it
|
||||
// would connect to the empty mount-time value. Mirror every keystroke into this stable ref (the
|
||||
// pair-screen PIN pattern). `manual` still drives the text box's displayed value.
|
||||
let manual_live = cx.use_ref(String::new());
|
||||
// "Add host" modal open state lives in ROOT (see `HostsProps`).
|
||||
let show_add = props.show_add;
|
||||
let set_show_add = &props.set_show_add;
|
||||
@@ -249,6 +264,18 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
let rename = props.rename.clone();
|
||||
let set_forget = &props.set_forget;
|
||||
let set_rename = &props.set_rename;
|
||||
// The live rename draft, read at Save time (see `rename_editor`). Root `rename` carries only the
|
||||
// INITIAL name, so it no longer round-trips per keystroke. Seed the draft each time the rename
|
||||
// TARGET changes (start, cancel, or a switch to another host).
|
||||
let rename_draft = cx.use_ref(String::new());
|
||||
let rename_seed = cx.use_ref(Option::<String>::None);
|
||||
{
|
||||
let active = rename.as_ref().map(|(fp, _)| fp.clone());
|
||||
if *rename_seed.borrow() != active {
|
||||
rename_draft.set(rename.as_ref().map(|(_, n)| n.clone()).unwrap_or_default());
|
||||
rename_seed.set(active);
|
||||
}
|
||||
}
|
||||
let hover = Hover {
|
||||
current: props.hover.clone(),
|
||||
set: props.set_hover.clone(),
|
||||
@@ -393,8 +420,13 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
for k in &known.hosts {
|
||||
// Rust 2021 (no let-chains): match the "this tile is being renamed" case explicitly.
|
||||
if matches!(&rename, Some((fp, _)) if fp == &k.fp_hex) {
|
||||
let (fp, draft) = rename.clone().unwrap();
|
||||
tiles.push(rename_editor(&draft, fp, set_rename.clone()));
|
||||
let (fp, initial) = rename.clone().unwrap();
|
||||
tiles.push(rename_editor(
|
||||
&initial,
|
||||
fp,
|
||||
rename_draft.clone(),
|
||||
set_rename.clone(),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let target = Target {
|
||||
@@ -595,14 +627,15 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
// field). The scrim border fills the cell and is hit-testable, so it blocks the page behind;
|
||||
// it closes only via Cancel/Connect (a scrim tap would bubble `Tapped` up from the card too).
|
||||
let connect_manual = {
|
||||
let (ctx2, ss, st, text, sa) = (
|
||||
let (ctx2, ss, st, live, sa) = (
|
||||
ctx.clone(),
|
||||
set_screen.clone(),
|
||||
set_status.clone(),
|
||||
manual.clone(),
|
||||
manual_live.clone(),
|
||||
set_show_add.clone(),
|
||||
);
|
||||
move || {
|
||||
let text = live.borrow();
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return;
|
||||
@@ -640,7 +673,13 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
text_box(manual)
|
||||
.header("Address")
|
||||
.placeholder_text("192.168.1.20 or my-pc.local")
|
||||
.on_text_changed(move |s| set_manual.call(s))
|
||||
.on_text_changed({
|
||||
let live = manual_live.clone();
|
||||
move |s: String| {
|
||||
live.set(s.clone());
|
||||
set_manual.call(s);
|
||||
}
|
||||
})
|
||||
.margin(edges(0.0, 6.0, 0.0, 0.0)),
|
||||
hstack((
|
||||
button("Connect")
|
||||
|
||||
@@ -23,7 +23,7 @@ pub(crate) fn licenses_page(set_screen: &AsyncSetState<Screen>) -> Element {
|
||||
|
||||
let app_card = card(
|
||||
vstack((
|
||||
text_block("punktfunk").font_size(15.0).semibold(),
|
||||
text_block("Punktfunk").font_size(15.0).semibold(),
|
||||
text_block("Licensed under MIT OR Apache-2.0, at your option.")
|
||||
.font_size(12.0)
|
||||
.wrap()
|
||||
|
||||
@@ -14,21 +14,28 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
||||
let set_screen = &props.set_screen;
|
||||
let set_status = &props.set_status;
|
||||
let (code, set_code) = cx.use_state(String::new());
|
||||
// The PIN's live value, read directly by the click handler. This page's props (`Svc`) never
|
||||
// change, and root wraps every screen in an animated `border` that compares equal once the
|
||||
// entrance tween settles — so the top-down reconcile `can_skip_update`s this subtree and never
|
||||
// re-renders the pair component off its *local* `use_state`. A button rebuilt only at mount
|
||||
// would forever capture the empty mount-time PIN (pairing then fails as a "wrong PIN"). Mirror
|
||||
// every keystroke into this stable ref instead, so the click reads exactly what was typed.
|
||||
let live_pin = cx.use_ref(String::new());
|
||||
let target = ctx.shared.target.lock().unwrap().clone();
|
||||
|
||||
let pair_btn = {
|
||||
let (ctx2, ss, st, code2, target2) = (
|
||||
let (ctx2, ss, st, live, target2) = (
|
||||
ctx.clone(),
|
||||
set_screen.clone(),
|
||||
set_status.clone(),
|
||||
code.clone(),
|
||||
live_pin.clone(),
|
||||
target.clone(),
|
||||
);
|
||||
button("Pair & Connect")
|
||||
.accent()
|
||||
.icon(Symbol::Accept)
|
||||
.on_click(move || {
|
||||
let pin = code2.trim().to_string();
|
||||
let pin = live.borrow().trim().to_string();
|
||||
let (ctx3, ss, st, target3) =
|
||||
(ctx2.clone(), ss.clone(), st.clone(), target2.clone());
|
||||
std::thread::spawn(move || {
|
||||
@@ -109,7 +116,16 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
||||
text_box(code)
|
||||
.placeholder_text("PIN")
|
||||
.font_size(28.0)
|
||||
.on_text_changed(move |s| set_code.call(s)),
|
||||
.on_text_changed({
|
||||
let live = live_pin.clone();
|
||||
move |s: String| {
|
||||
// Record the live value for the click handler (the source of truth for the
|
||||
// PIN), and mirror it into `code` so the field stays correct if anything ever
|
||||
// does re-render this page (theme/DPI change).
|
||||
live.set(s.clone());
|
||||
set_code.call(s);
|
||||
}
|
||||
}),
|
||||
hstack((pair_btn, cancel_btn)).spacing(8.0),
|
||||
text_block(
|
||||
"Don\u{2019}t have a PIN? Request access instead and approve this device on the host \
|
||||
|
||||
@@ -369,6 +369,16 @@ pub(crate) fn settings_page(
|
||||
"Adds \u{201C}Browse library\u{2026}\u{201D} to paired hosts \u{2014} pick a game and it \
|
||||
launches in the stream. Mirrors the Apple client's toggle.",
|
||||
);
|
||||
// App identity + version at the top of the About card (the WinUI Settings convention; the About
|
||||
// screen previously showed no version at all). CARGO_PKG_VERSION is the workspace version, baked
|
||||
// in at compile time.
|
||||
let about_identity = vstack((
|
||||
text_block("Punktfunk").font_size(20.0).semibold(),
|
||||
text_block(concat!("Version ", env!("CARGO_PKG_VERSION")))
|
||||
.font_size(12.0)
|
||||
.foreground(ThemeRef::SecondaryText),
|
||||
))
|
||||
.spacing(2.0);
|
||||
|
||||
// The selected section's content — per-control guidance lives on hover tooltips, so the
|
||||
// card is just the controls.
|
||||
@@ -403,7 +413,11 @@ pub(crate) fn settings_page(
|
||||
),
|
||||
"about" => (
|
||||
"About",
|
||||
settings_card(vec![library_toggle.into(), licenses_button.into()]),
|
||||
settings_card(vec![
|
||||
about_identity.into(),
|
||||
library_toggle.into(),
|
||||
licenses_button.into(),
|
||||
]),
|
||||
),
|
||||
_ => (
|
||||
"Display",
|
||||
|
||||
@@ -380,6 +380,11 @@ fn pump(
|
||||
Ok(frame) => {
|
||||
// The `received` point: AU fully reassembled, handed to us, before decode.
|
||||
let received_ns = now_ns();
|
||||
// Loss recovery (RFI): a forward frame-index gap fires a throttled reference-frame-
|
||||
// invalidation request so an RFI-capable host (AMD LTR / NVENC) recovers with a cheap
|
||||
// clean P-frame instead of a full IDR. The frames_dropped keyframe path below is the
|
||||
// backstop for when the recovery frame itself is lost.
|
||||
let _ = connector.note_frame_index(frame.frame_index);
|
||||
// fps = AUs received per second, Mb/s = received goodput (spec: counted at the
|
||||
// received point, not the decoded one).
|
||||
frames_n += 1;
|
||||
|
||||
@@ -104,6 +104,81 @@ pub struct Stats {
|
||||
/// IDR (or a mid-GOP join) unfreezes almost immediately instead of never.
|
||||
const NO_OUTPUT_KEYFRAME_STREAK: u32 = 3;
|
||||
|
||||
/// Longest the pump holds the last good frame waiting for a post-loss re-anchor keyframe before it
|
||||
/// gives up and resumes display. After a reference loss the hardware decoder does not error — it
|
||||
/// conceals the reference-missing deltas (on RADV, the DPB-and-output-COINCIDE path renders them as
|
||||
/// a gray plate with the new frame's motion painted over it) and returns Ok, so displaying them is
|
||||
/// the "gray frames mid-stream" artifact. We instead freeze on the last good picture until a fresh
|
||||
/// IDR re-anchors decode — the behaviour NVIDIA already shows (its DISTINCT output image + different
|
||||
/// concealment reads as a brief freeze, not gray). This cap only bounds the freeze when recovery
|
||||
/// genuinely stalls (host ignores the request, or an RFI recovery that never emits a keyframe), so a
|
||||
/// glitch can never become a permanent freeze. A recovery IDR round-trips well under this on any
|
||||
/// live link.
|
||||
const REANCHOR_FREEZE_MAX: Duration = Duration::from_millis(500);
|
||||
|
||||
/// How many host intra-refresh recovery marks ([`USER_FLAG_RECOVERY_POINT`]) must arrive since the
|
||||
/// latest frame gap before the pump lifts its freeze on an IDR-free stream. TWO, not one: with a
|
||||
/// continuous rolling wave the host marks phase-fixed wave boundaries, so the FIRST boundary after a
|
||||
/// loss is only partially healed — stripes swept BEFORE the loss still reference the lost frame — and
|
||||
/// lifting there would flash a partially-stale picture. The SECOND boundary guarantees a full wave
|
||||
/// swept entirely after the loss, so the picture is clean. This stays correct under repeated loss
|
||||
/// because every new gap resets the count. The cost is up to ~2 wave periods of holding the last good
|
||||
/// frame — the deliberate "hold longer, never show garbage" trade.
|
||||
///
|
||||
/// [`USER_FLAG_RECOVERY_POINT`]: punktfunk_core::packet::USER_FLAG_RECOVERY_POINT
|
||||
const REANCHOR_MARKS_TO_LIFT: u32 = 2;
|
||||
|
||||
/// Backstop patience while a host intra-refresh heal is visibly in progress. Each recovery mark
|
||||
/// pushes the freeze deadline out by this much, so a live mark stream (the host actively healing via
|
||||
/// its wave) keeps the client patiently holding the last good frame instead of tripping the IDR
|
||||
/// floor mid-heal. Must exceed the inter-mark interval (one wave period, ~0.5 s) with margin; if the
|
||||
/// marks STOP (heal stalled, or the host isn't running intra-refresh) the deadline lapses and the
|
||||
/// normal recovery-IDR floor fires, so a real stall still recovers.
|
||||
const RECOVERY_MARK_PATIENCE: Duration = Duration::from_millis(1500);
|
||||
|
||||
/// Frames skipped when `got` arrives while `expected` was the next index, or `None` if `got` is
|
||||
/// contiguous (`== expected`) or a straggler we have already passed. Frame indices are u32 counters
|
||||
/// that wrap, so the "ahead" test is a wrapping subtraction split at the half-space: a small
|
||||
/// positive delta is a forward gap (missing frames whose dependents will decode against absent
|
||||
/// references); a delta in the top half is an index behind us.
|
||||
fn index_gap(expected: u32, got: u32) -> Option<u32> {
|
||||
let ahead = got.wrapping_sub(expected);
|
||||
(ahead != 0 && ahead < u32::MAX / 2).then_some(ahead)
|
||||
}
|
||||
|
||||
/// Fold one decoded frame into the re-anchor state and decide whether it lifts the post-loss freeze.
|
||||
///
|
||||
/// `is_keyframe` — a real IDR (always a clean re-anchor). `has_anchor` — this AU carried
|
||||
/// [`USER_FLAG_RECOVERY_ANCHOR`](punktfunk_core::packet::USER_FLAG_RECOVERY_ANCHOR), the host's
|
||||
/// definitive single-frame re-anchor from an LTR-RFI recovery (a clean P-frame coded against a
|
||||
/// known-good reference), so it lifts on the FIRST occurrence exactly like an IDR — no two-mark wait.
|
||||
/// `has_mark` — this AU carried [`USER_FLAG_RECOVERY_POINT`](punktfunk_core::packet::USER_FLAG_RECOVERY_POINT),
|
||||
/// a host-signalled intra-refresh wave boundary (only *half* a re-anchor). `marks` — recovery marks
|
||||
/// seen since the latest gap.
|
||||
///
|
||||
/// Returns `(lift, new_marks)`: `lift` clears the freeze; `new_marks` is the running count (reset to 0
|
||||
/// on a lift). The two-mark rule ([`REANCHOR_MARKS_TO_LIFT`]) lives here so it is unit-tested
|
||||
/// independent of the pump's channel/decoder plumbing — the first wave boundary after a loss is only
|
||||
/// partially healed, so a single mark must NOT lift. An anchor (or IDR) is a *whole* re-anchor and
|
||||
/// lifts immediately.
|
||||
fn reanchor_after_frame(
|
||||
is_keyframe: bool,
|
||||
has_anchor: bool,
|
||||
has_mark: bool,
|
||||
marks: u32,
|
||||
) -> (bool, u32) {
|
||||
let marks = if has_mark {
|
||||
marks.saturating_add(1)
|
||||
} else {
|
||||
marks
|
||||
};
|
||||
if is_keyframe || has_anchor || marks >= REANCHOR_MARKS_TO_LIFT {
|
||||
(true, 0)
|
||||
} else {
|
||||
(false, marks)
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames the pump keeps waiting for their 0xCF host timing (pts → capture→received µs).
|
||||
/// ~2 s at 120 Hz — a timing arrives within a frame or two of its AU, and against an old
|
||||
/// host (no 0xCF at all) this just caps the dead-weight ring.
|
||||
@@ -319,6 +394,20 @@ fn pump(
|
||||
// never drops, so the drop-count trigger below stays silent and the stream freezes
|
||||
// on the last good frame. A short streak forces a fresh IDR to re-anchor.
|
||||
let mut no_output_streak = 0u32;
|
||||
// Freeze-until-reanchor: armed the moment we request a recovery keyframe (loss, decode error, or
|
||||
// a no-output streak), it withholds the decoder's concealed frames from the presenter — which
|
||||
// then redraws the last good picture — until a fresh keyframe re-anchors decode. See
|
||||
// [`REANCHOR_FREEZE_MAX`] for why this exists and its backstop deadline.
|
||||
let mut awaiting_reanchor = false;
|
||||
let mut reanchor_deadline: Option<Instant> = None;
|
||||
// Host intra-refresh recovery marks seen since the latest gap (see [`REANCHOR_MARKS_TO_LIFT`]).
|
||||
// Reset to 0 whenever the freeze is (re-)armed, so a fresh loss always waits out two fresh marks.
|
||||
let mut recovery_marks: u32 = 0;
|
||||
// The frame_index we expect next (the host numbers frames consecutively). A jump means a frame
|
||||
// went missing — the earliest, most reliable signal that the decoder is about to conceal, ~120 ms
|
||||
// ahead of `frames_dropped` (the reassembler only declares a straggler lost once it ages out of
|
||||
// the loss window, by which point the concealment already reached the screen).
|
||||
let mut next_expected_index: Option<u32> = None;
|
||||
|
||||
let end: Option<String> = loop {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
@@ -334,9 +423,103 @@ fn pump(
|
||||
// fps / goodput count every received AU (spec), decoded or not.
|
||||
frames_n += 1;
|
||||
bytes_n += frame.data.len() as u64;
|
||||
// Reference-continuity gate: the host numbers frames consecutively, so a jump in
|
||||
// frame_index means a frame is missing (lost, or an out-of-order straggler the
|
||||
// reassembler emitted a newer frame ahead of) and this AU references a picture we
|
||||
// never decoded. On RADV the decoder conceals that as a gray plate with the new
|
||||
// motion on top — the reported artifact, and it shows most on high-motion frames (a
|
||||
// full-screen pan bursts far more packets than a static desktop or a UFO-test's small
|
||||
// moving sprite, so it is the frame that loses shards). Arm the freeze at the FIRST
|
||||
// such frame — ~120 ms before `frames_dropped` would — so the gray never reaches the
|
||||
// screen; recovery IDRs stay on the existing throttled path (see the arm below).
|
||||
match next_expected_index {
|
||||
Some(exp) if frame.frame_index == exp => {
|
||||
next_expected_index = Some(exp.wrapping_add(1)); // contiguous
|
||||
}
|
||||
// A forward gap: hold the last good frame — but DO NOT ask for a keyframe here.
|
||||
// Hiding the concealment is free (the presenter redraws the last picture); an IDR
|
||||
// is not — at 4K120 it is a multi-megabyte frame and a visible stutter, and it can
|
||||
// re-trigger the very burst loss that caused this. The existing loss recovery below
|
||||
// (`frames_dropped`, host-coalesced + throttled) still requests it at exactly the
|
||||
// cadence it did before this change, so we add zero IDR pressure per pan. A
|
||||
// straggler behind us (`index_gap` → None) leaves the expectation put so the real
|
||||
// gap still trips.
|
||||
Some(exp) => {
|
||||
if let Some(gap) = index_gap(exp, frame.frame_index) {
|
||||
let now = Instant::now();
|
||||
awaiting_reanchor = true;
|
||||
recovery_marks = 0;
|
||||
reanchor_deadline = Some(now + REANCHOR_FREEZE_MAX);
|
||||
next_expected_index = Some(frame.frame_index.wrapping_add(1));
|
||||
// The gap carries the PRECISE lost range — [first missing, newest
|
||||
// received - 1] — so this is the one recovery signal that can drive true
|
||||
// reference-frame invalidation. Prefer an RFI request over a keyframe: an
|
||||
// RFI-capable host (AMD LTR / NVENC) re-references a known-good picture and
|
||||
// emits a clean P-frame tagged USER_FLAG_RECOVERY_ANCHOR (the freeze lifts
|
||||
// on ONE frame, no 20-40× IDR spike); an incapable/old host forces a
|
||||
// host-coalesced IDR instead, or ignores it (then the frames_dropped /
|
||||
// overdue keyframe paths below are the backstop). Throttled with those
|
||||
// paths (one recovery ask per 100 ms) so a burst of gaps — a full-screen
|
||||
// pan shedding shards — can't storm the control stream. This fires ~120 ms
|
||||
// before frames_dropped would, so recovery also starts sooner.
|
||||
//
|
||||
// A gap wider than RFI_MAX_RANGE is beyond any encoder's reference
|
||||
// history (a seconds-long outage — or a phantom index jump, e.g. the
|
||||
// first real AU after an old host's speed-test burst consumed video
|
||||
// indexes): RFI is hopeless there, so ask for the IDR resync directly.
|
||||
if last_kf_req
|
||||
.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||
{
|
||||
last_kf_req = Some(now);
|
||||
if gap > punktfunk_core::packet::RFI_MAX_RANGE {
|
||||
let _ = connector.request_keyframe();
|
||||
} else {
|
||||
let _ = connector
|
||||
.request_rfi(exp, frame.frame_index.wrapping_sub(1));
|
||||
}
|
||||
}
|
||||
tracing::trace!(
|
||||
gap,
|
||||
"frame gap — RFI recovery, holding last frame until re-anchor"
|
||||
);
|
||||
}
|
||||
}
|
||||
None => next_expected_index = Some(frame.frame_index.wrapping_add(1)),
|
||||
}
|
||||
match decoder.decode(&frame.data) {
|
||||
Ok(Some(image)) => {
|
||||
no_output_streak = 0; // a decoded frame — the anchor holds
|
||||
// A decoded frame — the anchor holds.
|
||||
no_output_streak = 0;
|
||||
// Host-signalled intra-refresh recovery mark: on an IDR-free intra-refresh
|
||||
// stream this wave-boundary flag is the only clean point the client can honor
|
||||
// (the decoder never flags the re-anchor — the coded frame stays `P`). A live
|
||||
// mark stream also means the host is actively healing, so push the backstop out
|
||||
// rather than trip a mid-heal IDR (see `RECOVERY_MARK_PATIENCE`).
|
||||
let has_mark =
|
||||
frame.flags & punktfunk_core::packet::USER_FLAG_RECOVERY_POINT != 0;
|
||||
// The host's definitive single-frame re-anchor: an LTR-RFI recovery frame (a
|
||||
// clean P-frame off a known-good reference), the AMD twin of an IDR re-anchor
|
||||
// but without the spike. It lifts on the FIRST occurrence.
|
||||
let has_anchor =
|
||||
frame.flags & punktfunk_core::packet::USER_FLAG_RECOVERY_ANCHOR != 0;
|
||||
if has_mark && awaiting_reanchor {
|
||||
reanchor_deadline = Some(Instant::now() + RECOVERY_MARK_PATIENCE);
|
||||
}
|
||||
// A fresh clean re-anchor lifts the freeze and shows this frame: a real intra
|
||||
// keyframe (IDR, always clean), an LTR-RFI recovery anchor (also whole), OR the
|
||||
// second recovery mark since the gap (the first wave boundary is only
|
||||
// half-healed — see `reanchor_after_frame`).
|
||||
let (lift, marks) = reanchor_after_frame(
|
||||
image.is_keyframe(),
|
||||
has_anchor,
|
||||
has_mark,
|
||||
recovery_marks,
|
||||
);
|
||||
recovery_marks = marks;
|
||||
if lift {
|
||||
awaiting_reanchor = false;
|
||||
reanchor_deadline = None;
|
||||
}
|
||||
total_frames += 1;
|
||||
dec_path = match &image {
|
||||
DecodedImage::Cpu(_) => "software",
|
||||
@@ -391,11 +574,20 @@ fn pump(
|
||||
DecodedImage::VkFrame(v) => Some((v.timeline_sem, v.decode_done_value)),
|
||||
_ => None,
|
||||
};
|
||||
let _ = frame_tx.force_send(DecodedFrame {
|
||||
pts_ns: frame.pts_ns,
|
||||
decoded_ns,
|
||||
image,
|
||||
});
|
||||
if awaiting_reanchor {
|
||||
// Post-loss concealment: withhold this frame (it references a lost/gray
|
||||
// reference) so the presenter keeps redrawing the last good picture
|
||||
// rather than flashing the decoder's gray plate. Dropped here — the
|
||||
// hw-decode stat below still samples via `hw_fence` (raw handle + value,
|
||||
// valid past the guard). Cleared by the next keyframe or the backstop.
|
||||
tracing::trace!("holding last frame — awaiting post-loss re-anchor");
|
||||
} else {
|
||||
let _ = frame_tx.force_send(DecodedFrame {
|
||||
pts_ns: frame.pts_ns,
|
||||
decoded_ns,
|
||||
image,
|
||||
});
|
||||
}
|
||||
// `decode` stage: received→decode COMPLETE, single clock.
|
||||
match hw_fence {
|
||||
Some((sem, value)) => {
|
||||
@@ -424,6 +616,12 @@ fn pump(
|
||||
// trip before asking again instead of flooding.
|
||||
if no_output_streak >= NO_OUTPUT_KEYFRAME_STREAK {
|
||||
let now = Instant::now();
|
||||
// Wedged on missing references: hold the last good frame until re-anchor
|
||||
// (armed even when the IDR request itself is throttled — the stream is broken
|
||||
// regardless of whether we ask again this iteration).
|
||||
awaiting_reanchor = true;
|
||||
recovery_marks = 0;
|
||||
reanchor_deadline = Some(now + REANCHOR_FREEZE_MAX);
|
||||
if last_kf_req
|
||||
.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||
{
|
||||
@@ -451,6 +649,9 @@ fn pump(
|
||||
// through the same throttle as loss recovery below.
|
||||
if decoder.take_keyframe_request() {
|
||||
let now = Instant::now();
|
||||
awaiting_reanchor = true;
|
||||
recovery_marks = 0;
|
||||
reanchor_deadline = Some(now + REANCHOR_FREEZE_MAX);
|
||||
if last_kf_req
|
||||
.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||
{
|
||||
@@ -487,12 +688,33 @@ fn pump(
|
||||
if dropped > last_dropped {
|
||||
last_dropped = dropped;
|
||||
let now = Instant::now();
|
||||
// A dropped AU means the frames after it reference a picture we never decoded — the
|
||||
// decoder will conceal them (gray on RADV). Freeze on the last good frame until a fresh
|
||||
// IDR re-anchors, so the concealment never reaches the screen.
|
||||
awaiting_reanchor = true;
|
||||
recovery_marks = 0;
|
||||
reanchor_deadline = Some(now + REANCHOR_FREEZE_MAX);
|
||||
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
|
||||
last_kf_req = Some(now);
|
||||
let _ = connector.request_keyframe();
|
||||
tracing::debug!(dropped, "requested keyframe (loss recovery)");
|
||||
}
|
||||
}
|
||||
// Re-anchor overdue: the freeze has held the whole window with no keyframe — a lost recovery
|
||||
// IDR, or a benign reorder that produced no `frames_dropped` and so requested none. Do NOT
|
||||
// resume to gray (the one thing worse than a freeze): keep holding the last good frame and
|
||||
// (re-)request a keyframe, throttled + host-coalesced, so a CLEAN re-anchor is what un-freezes
|
||||
// us. A genuinely dead stream — host gone, link collapsed — is caught by the QUIC idle-timeout
|
||||
// watchdog (returns to the menu), never by painting the decoder's concealment.
|
||||
if awaiting_reanchor && reanchor_deadline.is_some_and(|d| Instant::now() >= d) {
|
||||
let now = Instant::now();
|
||||
reanchor_deadline = Some(now + REANCHOR_FREEZE_MAX);
|
||||
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
|
||||
last_kf_req = Some(now);
|
||||
let _ = connector.request_keyframe();
|
||||
tracing::debug!("re-anchor overdue — still holding, re-requesting keyframe");
|
||||
}
|
||||
}
|
||||
|
||||
if window_start.elapsed() >= Duration::from_secs(1) {
|
||||
let secs = window_start.elapsed().as_secs_f32();
|
||||
@@ -614,3 +836,111 @@ fn spawn_audio(
|
||||
.map_err(|e| tracing::warn!(error = %e, "audio thread failed to start — audio disabled"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{index_gap, reanchor_after_frame, REANCHOR_MARKS_TO_LIFT};
|
||||
|
||||
// Simulate the pump's re-anchor state across a sequence of decoded frames: each `(is_keyframe,
|
||||
// has_mark)` pair is folded through `reanchor_after_frame`, returning the frame index (0-based)
|
||||
// at which the freeze first lifts, or `None` if it never does. `gap_before` reset points model a
|
||||
// fresh loss re-arming the freeze (the pump zeroes the count at every gap/arm site).
|
||||
fn lift_at(frames: &[(bool, bool)]) -> Option<usize> {
|
||||
let mut marks = 0u32;
|
||||
for (i, &(is_kf, has_mark)) in frames.iter().enumerate() {
|
||||
// The intra-refresh-mark model never carries an LTR-RFI anchor (that path is exercised
|
||||
// by `an_rfi_anchor_lifts_immediately`), so `has_anchor` is always false here.
|
||||
let (lift, m) = reanchor_after_frame(is_kf, false, has_mark, marks);
|
||||
marks = m;
|
||||
if lift {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_recovery_mark_does_not_lift() {
|
||||
// The first wave boundary after a loss is only half-healed — one mark must hold the freeze.
|
||||
assert_eq!(REANCHOR_MARKS_TO_LIFT, 2);
|
||||
assert_eq!(lift_at(&[(false, true)]), None);
|
||||
assert_eq!(
|
||||
lift_at(&[(false, false), (false, true), (false, false)]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_second_recovery_mark_lifts() {
|
||||
// Two marks = a full wave swept after the loss → clean re-anchor.
|
||||
assert_eq!(lift_at(&[(false, true), (false, true)]), Some(1));
|
||||
assert_eq!(
|
||||
lift_at(&[(false, false), (false, true), (false, false), (false, true)]),
|
||||
Some(3)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_keyframe_lifts_immediately() {
|
||||
// An IDR is always a clean anchor — no marks needed.
|
||||
assert_eq!(lift_at(&[(true, false)]), Some(0));
|
||||
assert_eq!(lift_at(&[(false, true), (true, false)]), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_gap_resets_the_mark_count() {
|
||||
// The pump zeroes `recovery_marks` at each arm site, so one mark before a new gap plus one
|
||||
// after must NOT lift — the model resets the running count to imitate that.
|
||||
let mut marks = 0u32;
|
||||
let (_, m) = reanchor_after_frame(false, false, true, marks); // mark #1 (pre-gap)
|
||||
marks = m;
|
||||
assert_eq!(marks, 1);
|
||||
marks = 0; // a new gap re-arms the freeze → count reset
|
||||
let (lift, m) = reanchor_after_frame(false, false, true, marks); // first mark of the new wave
|
||||
assert!(!lift, "a single post-gap mark must not lift");
|
||||
assert_eq!(m, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_rfi_anchor_lifts_immediately() {
|
||||
// An LTR-RFI recovery anchor is a WHOLE re-anchor (a clean P-frame off a known-good
|
||||
// reference), so — like an IDR — it lifts on the FIRST occurrence, no two-mark wait.
|
||||
let (lift, marks) = reanchor_after_frame(false, true, false, 0);
|
||||
assert!(lift, "an RFI anchor must lift the freeze immediately");
|
||||
assert_eq!(marks, 0, "a lift resets the running mark count");
|
||||
// Even with zero prior marks and no keyframe, the anchor alone is sufficient.
|
||||
let (lift, _) = reanchor_after_frame(false, true, true, 1);
|
||||
assert!(lift, "an anchor lifts regardless of the pending mark count");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contiguous_indices_are_not_a_gap() {
|
||||
assert_eq!(index_gap(5, 5), None);
|
||||
assert_eq!(index_gap(0, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forward_jump_reports_the_skip_count() {
|
||||
assert_eq!(index_gap(5, 6), Some(1)); // one frame missing
|
||||
assert_eq!(index_gap(5, 9), Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_straggler_behind_us_is_not_a_gap() {
|
||||
// The reassembler emitted a newer frame first; the late one must not re-arm.
|
||||
assert_eq!(index_gap(9, 5), None);
|
||||
assert_eq!(index_gap(1, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_index_counter_wraps_cleanly() {
|
||||
// last frame = u32::MAX, so the next expected wraps to 0.
|
||||
// Contiguous across the wrap.
|
||||
assert_eq!(index_gap(0, 0), None);
|
||||
// waiting on u32::MAX, frame 0 arrived → MAX was skipped.
|
||||
assert_eq!(index_gap(u32::MAX, 0), Some(1));
|
||||
assert_eq!(index_gap(u32::MAX, 2), Some(3));
|
||||
// an old frame arriving just after the wrap is still a straggler.
|
||||
assert_eq!(index_gap(0, u32::MAX), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,10 @@ pub struct VkVideoFrame {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub color: ColorDesc,
|
||||
/// Intra keyframe (IDR/I): the stream's re-anchor point. The pump resumes display on
|
||||
/// one after suppressing the concealed frames a reference loss leaves in its wake (on
|
||||
/// RADV a lost reference decodes to a gray plate with the new motion painted on top).
|
||||
pub keyframe: bool,
|
||||
/// Keeps the cloned AVFrame (and through it the VkImage + frames context) alive
|
||||
/// until the presenter's fence proves the GPU reads done — same mechanism as the
|
||||
/// VAAPI path's DRM guard.
|
||||
@@ -143,6 +147,58 @@ impl ColorDesc {
|
||||
}
|
||||
}
|
||||
|
||||
/// True if the decoder tagged this frame as a full IDR keyframe — a guaranteed clean re-anchor
|
||||
/// after which the picture is loss-free, so the pump can lift a post-loss display freeze here.
|
||||
///
|
||||
/// Keys off `AV_FRAME_FLAG_KEY` (with `pict_type == I` as a belt for decoders that fill pict_type
|
||||
/// but not the flag). NOTE: FFmpeg's H.264/HEVC decode layer sets this flag **only for true IDR
|
||||
/// frames**, never for an *intra-refresh recovery point*. H.264 flags key only when a picture's
|
||||
/// `recovery_frame_cnt == 0` (a moving band uses `> 0`); HEVC clears the flag on every non-IRAP
|
||||
/// frame regardless of the recovery-point SEI. So an intra-refresh host (NVENC/AMF/QSV) heals the
|
||||
/// picture over N P-frames with no decoded frame ever flagged key — this function cannot detect
|
||||
/// that clean point, and the pump would freeze until the `REANCHOR_FREEZE_MAX` backstop (in
|
||||
/// `session.rs`) forces a real IDR. Detecting an intra-refresh re-anchor requires an out-of-band
|
||||
/// host wire signal on the AU that completes the wave; that is not yet plumbed.
|
||||
///
|
||||
/// # Safety
|
||||
/// `frame` must point to a valid `AVFrame` alive for the duration of the call.
|
||||
pub unsafe fn frame_is_keyframe(frame: *const ffmpeg::ffi::AVFrame) -> bool {
|
||||
// SAFETY: caller guarantees a live AVFrame; plain field reads.
|
||||
unsafe {
|
||||
((*frame).flags & ffmpeg::ffi::AV_FRAME_FLAG_KEY) != 0
|
||||
|| (*frame).pict_type == ffmpeg::ffi::AVPictureType::AV_PICTURE_TYPE_I
|
||||
}
|
||||
}
|
||||
|
||||
impl DecodedImage {
|
||||
/// Whether the frame is an intra keyframe — see [`frame_is_keyframe`]. The pump uses
|
||||
/// this as the stream's re-anchor signal after a loss.
|
||||
pub fn is_keyframe(&self) -> bool {
|
||||
match self {
|
||||
DecodedImage::Cpu(f) => f.keyframe,
|
||||
#[cfg(target_os = "linux")]
|
||||
DecodedImage::Dmabuf(f) => f.keyframe,
|
||||
DecodedImage::VkFrame(f) => f.keyframe,
|
||||
#[cfg(windows)]
|
||||
DecodedImage::D3d11(f) => f.keyframe,
|
||||
}
|
||||
}
|
||||
|
||||
/// The decoded image's pixel dimensions. The presenter's resize indicator uses these
|
||||
/// as the mid-stream-resize END signal: a frame arriving at the target size means the
|
||||
/// new-mode picture is on glass (the ack alone lands before the host's rebuild does).
|
||||
pub fn dimensions(&self) -> (u32, u32) {
|
||||
match self {
|
||||
DecodedImage::Cpu(f) => (f.width, f.height),
|
||||
#[cfg(target_os = "linux")]
|
||||
DecodedImage::Dmabuf(f) => (f.width, f.height),
|
||||
DecodedImage::VkFrame(f) => (f.width, f.height),
|
||||
#[cfg(windows)]
|
||||
DecodedImage::D3d11(f) => (f.width, f.height),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Y′CbCr→RGB conversion as three vec4 rows for a shader constant buffer / push-constant
|
||||
/// block: `rgb[i] = dot(r[i].xyz, yuv) + r[i].w` — bit-depth exact. The ONE coefficient
|
||||
/// implementation every presenter derives its CSC from (Vulkan push constants, the Windows
|
||||
@@ -205,6 +261,8 @@ pub struct CpuFrame {
|
||||
/// pixels are full-range RGB), but a PQ/BT.2020 stream keeps its transfer + primaries
|
||||
/// baked in — the presenter tags the texture so GTK tone-maps it.
|
||||
pub color: ColorDesc,
|
||||
/// Intra keyframe (IDR/I) — the pump's post-loss re-anchor signal. See [`VkVideoFrame`].
|
||||
pub keyframe: bool,
|
||||
}
|
||||
|
||||
/// A decoded frame still on the GPU: dmabuf fds + plane layout for
|
||||
@@ -222,6 +280,8 @@ pub struct DmabufFrame {
|
||||
/// Signaling of the source frame — drives the `GdkDmabufTexture` color state (BT.709
|
||||
/// narrow for SDR, BT.2020 PQ for an HDR stream).
|
||||
pub color: ColorDesc,
|
||||
/// Intra keyframe (IDR/I) — the pump's post-loss re-anchor signal. See [`VkVideoFrame`].
|
||||
pub keyframe: bool,
|
||||
pub guard: DrmFrameGuard,
|
||||
}
|
||||
|
||||
@@ -644,6 +704,9 @@ impl SoftwareDecoder {
|
||||
stride: dst_linesize[0] as usize,
|
||||
rgba,
|
||||
color,
|
||||
// `is_key()` reads the same intra flag `frame_is_keyframe` derives from pict_type
|
||||
// for the hardware paths; ffmpeg-next handles the FFmpeg-version binding split.
|
||||
keyframe: frame.is_key(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -844,6 +907,7 @@ impl VaapiDecoder {
|
||||
// SAFETY: `self.frame` is the live decoded AVFrame (unref'd only after
|
||||
// this returns); plain CICP field reads.
|
||||
color: ColorDesc::from_raw(self.frame),
|
||||
keyframe: frame_is_keyframe(self.frame),
|
||||
guard,
|
||||
})
|
||||
}
|
||||
@@ -1363,6 +1427,7 @@ impl VulkanDecoder {
|
||||
width: (*self.frame).width as u32,
|
||||
height: (*self.frame).height as u32,
|
||||
color: ColorDesc::from_raw(self.frame),
|
||||
keyframe: frame_is_keyframe(self.frame),
|
||||
guard: DrmFrameGuard(clone),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -99,6 +99,9 @@ pub struct D3d11Frame {
|
||||
/// BT.709 full-range RGB — regardless of the stream's own CICP (a PQ stream was
|
||||
/// tone-mapped). The presenter keys SDR/HDR handling off this, so it always reads SDR.
|
||||
pub color: ColorDesc,
|
||||
/// Intra keyframe (IDR/I) — the pump's post-loss re-anchor signal. See
|
||||
/// `crate::video::VkVideoFrame`.
|
||||
pub keyframe: bool,
|
||||
/// The ring slot's NT shared handle (`IDXGIResource1::CreateSharedHandle`), stable for the
|
||||
/// ring's lifetime. Raw `isize` so the frame crosses the pump→presenter channel.
|
||||
pub handle: isize,
|
||||
@@ -692,6 +695,8 @@ impl D3d11vaDecoder {
|
||||
matrix: 0, // identity — RGB
|
||||
full_range: true,
|
||||
},
|
||||
// SAFETY: `self.frame` is the live decoded AVFrame for this call.
|
||||
keyframe: crate::video::frame_is_keyframe(self.frame),
|
||||
handle,
|
||||
generation,
|
||||
})
|
||||
|
||||
@@ -420,7 +420,10 @@ mod tests {
|
||||
assert!(ctx.settings.match_window, "Native → Match window");
|
||||
assert_eq!((ctx.settings.width, ctx.settings.height), (0, 0));
|
||||
assert!(adjust(RowId::Resolution, 1, false, &mut ctx));
|
||||
assert!(!ctx.settings.match_window, "explicit size clears the policy");
|
||||
assert!(
|
||||
!ctx.settings.match_window,
|
||||
"explicit size clears the policy"
|
||||
);
|
||||
assert_eq!((ctx.settings.width, ctx.settings.height), (1280, 720));
|
||||
// Stepping back from an explicit size returns to Match window, then Native.
|
||||
assert!(adjust(RowId::Resolution, -1, false, &mut ctx));
|
||||
|
||||
+158
-130
@@ -19,7 +19,9 @@ use anyhow::{anyhow, Result};
|
||||
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse, PadInfo};
|
||||
use pf_client_core::trust;
|
||||
use pf_presenter::overlay::OverlayAction;
|
||||
use skia_safe::{Canvas, Color4f, Data, Paint, Rect, RuntimeEffect};
|
||||
use skia_safe::{
|
||||
gradient_shader, Canvas, Color4f, Data, Paint, Point, Rect, RuntimeEffect, TileMode,
|
||||
};
|
||||
use std::collections::VecDeque;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -240,6 +242,11 @@ impl Shell {
|
||||
self.wake = None;
|
||||
if let Some(Some(intent)) = intent {
|
||||
self.start_connect(intent);
|
||||
// The wake takeover was already full-screen; skip the connect fade-in so the
|
||||
// Waking → Connecting handoff is seamless (no flash of the home behind).
|
||||
if let Some(c) = &mut self.connecting {
|
||||
c.appear = 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -607,77 +614,68 @@ impl Shell {
|
||||
t: f64,
|
||||
fonts: &Fonts,
|
||||
) {
|
||||
if let Some(c) = &mut self.connecting {
|
||||
c.appear = approach(c.appear, 1.0, dt, 0.07);
|
||||
let a = c.appear;
|
||||
canvas.draw_rect(
|
||||
Rect::from_wh(w as f32, h as f32),
|
||||
&Paint::new(Color4f::new(0.0, 0.0, 0.0, (0.45 * a) as f32), None),
|
||||
);
|
||||
let title = if c.canceling {
|
||||
"Canceling…".to_string()
|
||||
// Resolve the connect/wake takeover — the two phases of reaching a host — into one
|
||||
// full-screen shape (spinner, title, one detail line, its own hints). Connecting flows
|
||||
// straight out of a wake (see `sync`) so they share the same backdrop and never blink
|
||||
// between them. Mirrors the Android client's unified `ConnectOverlay`.
|
||||
let takeover: Option<(f64, bool, String, String, Vec<Hint>)> =
|
||||
if let Some(c) = &mut self.connecting {
|
||||
c.appear = approach(c.appear, 1.0, dt, 0.07);
|
||||
if c.canceling {
|
||||
Some((
|
||||
c.appear,
|
||||
true,
|
||||
"Canceling…".to_string(),
|
||||
String::new(),
|
||||
vec![],
|
||||
))
|
||||
} else {
|
||||
Some((
|
||||
c.appear,
|
||||
true,
|
||||
format!("Connecting to {}…", c.title),
|
||||
"Starting the stream in this window.".to_string(),
|
||||
vec![Hint::new(HintKey::Back, "Cancel")],
|
||||
))
|
||||
}
|
||||
} else if let Some(wk) = &self.wake {
|
||||
// Service-driven, so it appears settled (no fade-in).
|
||||
if wk.timed_out {
|
||||
Some((
|
||||
1.0,
|
||||
false,
|
||||
format!("{} didn't wake", wk.name),
|
||||
"Check its power settings, or wake it manually and try again.".to_string(),
|
||||
vec![
|
||||
Hint::new(HintKey::Confirm, "Try Again"),
|
||||
Hint::new(HintKey::Back, "Cancel"),
|
||||
],
|
||||
))
|
||||
} else {
|
||||
Some((
|
||||
1.0,
|
||||
true,
|
||||
format!("Waking {}…", wk.name),
|
||||
format!("Waiting for it to come online · {} s", wk.seconds),
|
||||
// A wake-only wait (no dial after) offers "Stop Waiting"; a wake-&-connect
|
||||
// is a plain "Cancel".
|
||||
vec![Hint::new(
|
||||
HintKey::Back,
|
||||
if wk.then_connect {
|
||||
"Cancel"
|
||||
} else {
|
||||
"Stop Waiting"
|
||||
},
|
||||
)],
|
||||
))
|
||||
}
|
||||
} else {
|
||||
format!("Connecting to {}…", c.title)
|
||||
None
|
||||
};
|
||||
let hints = if c.canceling {
|
||||
vec![]
|
||||
} else {
|
||||
vec![Hint::new(HintKey::Back, "Cancel")]
|
||||
};
|
||||
card(
|
||||
canvas,
|
||||
fonts,
|
||||
w,
|
||||
h,
|
||||
k,
|
||||
a,
|
||||
t,
|
||||
self.glyphs,
|
||||
true,
|
||||
&title,
|
||||
"Starting the stream in this window.",
|
||||
&hints,
|
||||
if let Some((appear, spinner, title, body, hints)) = takeover {
|
||||
self.draw_takeover(
|
||||
canvas, w, h, k, appear, t, fonts, spinner, &title, &body, &hints,
|
||||
);
|
||||
} else if let Some(wk) = &self.wake {
|
||||
let a = 1.0; // the wake card is service-driven; it appears settled
|
||||
canvas.draw_rect(
|
||||
Rect::from_wh(w as f32, h as f32),
|
||||
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.45), None),
|
||||
);
|
||||
if wk.timed_out {
|
||||
card(
|
||||
canvas,
|
||||
fonts,
|
||||
w,
|
||||
h,
|
||||
k,
|
||||
a,
|
||||
t,
|
||||
self.glyphs,
|
||||
false,
|
||||
&format!("{} didn't wake", wk.name),
|
||||
"Check its power settings, or wake it manually and try again.",
|
||||
&[
|
||||
Hint::new(HintKey::Confirm, "Try Again"),
|
||||
Hint::new(HintKey::Back, "Cancel"),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
card(
|
||||
canvas,
|
||||
fonts,
|
||||
w,
|
||||
h,
|
||||
k,
|
||||
a,
|
||||
t,
|
||||
self.glyphs,
|
||||
true,
|
||||
&format!("Waking {}…", wk.name),
|
||||
&format!("Waiting for it to come online · {} s", wk.seconds),
|
||||
&[Hint::new(HintKey::Back, "Cancel")],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The toast: a transient pill above the hint bar; slides in, fades out.
|
||||
@@ -799,70 +797,91 @@ impl LayerEnv<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A centered modal card: spinner (or not), a title, one detail line, and its own hint
|
||||
/// row — the connect/wake overlays share this one shape.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn card(
|
||||
canvas: &Canvas,
|
||||
fonts: &Fonts,
|
||||
w: f64,
|
||||
h: f64,
|
||||
k: f64,
|
||||
appear: f64,
|
||||
t: f64,
|
||||
glyphs: GlyphStyle,
|
||||
spinner: bool,
|
||||
title: &str,
|
||||
body: &str,
|
||||
hints: &[Hint],
|
||||
) {
|
||||
let cw = (440.0 * k).min(w * 0.86);
|
||||
let ch = 190.0 * k;
|
||||
let cx = w / 2.0;
|
||||
let top = h / 2.0 - ch / 2.0 + (1.0 - appear) * 14.0 * k;
|
||||
canvas.save_layer_alpha_f(None, appear as f32);
|
||||
let rect = Rect::from_xywh((cx - cw / 2.0) as f32, top as f32, cw as f32, ch as f32);
|
||||
crate::theme::drop_shadow(canvas, rect, 22.0, k as f32, 0.5);
|
||||
crate::theme::panel(
|
||||
canvas,
|
||||
rect,
|
||||
22.0,
|
||||
Some(Color4f::new(0.07, 0.06, 0.12, 0.85)),
|
||||
PanelStroke::Plain(0.14),
|
||||
k as f32,
|
||||
);
|
||||
let mut y = top + 44.0 * k;
|
||||
if spinner {
|
||||
crate::theme::spinner(canvas, cx, y, 14.0 * k, t);
|
||||
y += 34.0 * k;
|
||||
} else {
|
||||
y += 6.0 * k;
|
||||
}
|
||||
fonts.centered(canvas, title, W::SemiBold, 19.0 * k, WHITE, cx, y, cw * 0.9);
|
||||
fonts.centered(
|
||||
canvas,
|
||||
body,
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
DIM,
|
||||
cx,
|
||||
y + 30.0 * k,
|
||||
cw * 0.86,
|
||||
);
|
||||
if !hints.is_empty() {
|
||||
// Centered inside the card's bottom band.
|
||||
let probe = hint_bar(canvas, fonts, hints, glyphs, -10_000.0, -10_000.0, k);
|
||||
hint_bar(
|
||||
impl Shell {
|
||||
/// A full-screen connect/wake takeover: a fresh aurora over everything (so the carousel and
|
||||
/// chrome fall away), a centered spinner (or none, when a wake has timed out), a title, one
|
||||
/// detail line, and its own bottom hint row. `appear` fades the whole thing in over the home;
|
||||
/// a wake that hands off to a connect passes 1.0 so the two never blink between them. The
|
||||
/// console counterpart of the Android/Apple `ConnectOverlay` — one full-screen shape, not a
|
||||
/// centered modal card.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_takeover(
|
||||
&self,
|
||||
canvas: &Canvas,
|
||||
w: f64,
|
||||
h: f64,
|
||||
k: f64,
|
||||
appear: f64,
|
||||
t: f64,
|
||||
fonts: &Fonts,
|
||||
spinner: bool,
|
||||
title: &str,
|
||||
body: &str,
|
||||
hints: &[Hint],
|
||||
) {
|
||||
let cx = w / 2.0;
|
||||
canvas.save_layer_alpha_f(None, appear as f32);
|
||||
// Opaque aurora — the same living backdrop the home wears, so the takeover reads as the
|
||||
// console taking over rather than a card popping up.
|
||||
self.draw_aurora(canvas, w, h, t);
|
||||
// A soft pool of shade under the centre seats the white text against a bright aurora.
|
||||
let mut vignette = Paint::default();
|
||||
vignette.set_shader(gradient_shader::radial(
|
||||
Point::new(cx as f32, (h / 2.0) as f32),
|
||||
(w.max(h) * 0.42) as f32,
|
||||
gradient_shader::GradientShaderColors::Colors(&[
|
||||
Color4f::new(0.0, 0.0, 0.0, 0.5).to_color(),
|
||||
Color4f::new(0.0, 0.0, 0.0, 0.0).to_color(),
|
||||
]),
|
||||
None,
|
||||
TileMode::Clamp,
|
||||
None,
|
||||
None,
|
||||
));
|
||||
canvas.draw_rect(Rect::from_wh(w as f32, h as f32), &vignette);
|
||||
|
||||
// Centre the spinner + title + detail as a group around the middle of the screen.
|
||||
let title_y = h / 2.0 + if spinner { 14.0 * k } else { 0.0 };
|
||||
if spinner {
|
||||
crate::theme::spinner(canvas, cx, title_y - 52.0 * k, 22.0 * k, t);
|
||||
}
|
||||
fonts.centered(
|
||||
canvas,
|
||||
fonts,
|
||||
hints,
|
||||
glyphs,
|
||||
cx - probe.0 / 2.0,
|
||||
top + ch - 16.0 * k,
|
||||
k,
|
||||
title,
|
||||
W::SemiBold,
|
||||
23.0 * k,
|
||||
WHITE,
|
||||
cx,
|
||||
title_y,
|
||||
w * 0.82,
|
||||
);
|
||||
if !body.is_empty() {
|
||||
fonts.centered(
|
||||
canvas,
|
||||
body,
|
||||
W::Regular,
|
||||
14.0 * k,
|
||||
DIM,
|
||||
cx,
|
||||
title_y + 32.0 * k,
|
||||
w * 0.66,
|
||||
);
|
||||
}
|
||||
if !hints.is_empty() {
|
||||
// Centered near the bottom, where every console screen's legend sits.
|
||||
let probe = hint_bar(canvas, fonts, hints, self.glyphs, -10_000.0, -10_000.0, k);
|
||||
hint_bar(
|
||||
canvas,
|
||||
fonts,
|
||||
hints,
|
||||
self.glyphs,
|
||||
cx - probe.0 / 2.0,
|
||||
h - 34.0 * k,
|
||||
k,
|
||||
);
|
||||
}
|
||||
canvas.restore();
|
||||
}
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1154,6 +1173,15 @@ mod tests {
|
||||
then_connect: true,
|
||||
}));
|
||||
dump(&mut s, 10, 8, "08-waking", true);
|
||||
console.set_wake(Some(WakeStatus {
|
||||
key: "bb22".into(),
|
||||
name: "Office Tower".into(),
|
||||
seconds: 90,
|
||||
timed_out: true,
|
||||
online: false,
|
||||
then_connect: true,
|
||||
}));
|
||||
dump(&mut s, 10, 8, "08b-wake-timed-out", true);
|
||||
console.set_wake(None);
|
||||
s.set_connecting(Some("Elden Ring".into()));
|
||||
dump(&mut s, 10, 8, "09-connecting", true);
|
||||
|
||||
@@ -50,6 +50,10 @@ struct Drawn {
|
||||
hint: Option<String>,
|
||||
/// The start banner's alpha, quantized — a fade step is a redraw, steady is not.
|
||||
banner_step: u8,
|
||||
/// The resize scrim's spinner phase, quantized — a nonzero, ever-changing step while a
|
||||
/// mid-stream resize is in flight forces the per-frame redraw the spinner needs; `0`
|
||||
/// when no resize is showing (so a still stream stays damage-free).
|
||||
resize_step: u16,
|
||||
}
|
||||
|
||||
/// Where the console starts (the session binary's `--browse` forms).
|
||||
@@ -85,6 +89,9 @@ pub struct SkiaOverlay {
|
||||
streaming_since: Option<Instant>,
|
||||
/// The banner's words (set per stream from the active-pad state).
|
||||
banner_text: Option<String>,
|
||||
/// When the current mid-stream resize scrim began showing — drives its spinner phase.
|
||||
/// `None` = no resize in flight (`FrameCtx::resizing` was false last frame).
|
||||
resizing_since: Option<Instant>,
|
||||
}
|
||||
|
||||
struct Gpu {
|
||||
@@ -114,6 +121,7 @@ impl SkiaOverlay {
|
||||
shell: None,
|
||||
streaming_since: None,
|
||||
banner_text: None,
|
||||
resizing_since: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,10 +348,15 @@ impl Overlay for SkiaOverlay {
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Stream chrome: stats OSD + capture hint + the start banner ---------------
|
||||
// --- Stream chrome: stats OSD + capture hint + start banner + resize scrim -----
|
||||
let banner_alpha = self.banner_alpha(ctx);
|
||||
let banner_step = (banner_alpha * 32.0).round() as u8;
|
||||
if ctx.stats.is_none() && ctx.hint.is_none() && banner_step == 0 {
|
||||
let resize_phase = self.resize_phase(ctx);
|
||||
// 120 steps/s: every ~16 ms frame lands on a fresh step, so the spinner keeps
|
||||
// spinning through the damage gate; `+ 1` keeps an active resize's step nonzero
|
||||
// even on its first frame (phase 0) so the guard below doesn't skip it.
|
||||
let resize_step = resize_phase.map_or(0, |p| (p * 120.0) as u16 + 1);
|
||||
if ctx.stats.is_none() && ctx.hint.is_none() && banner_step == 0 && resize_step == 0 {
|
||||
self.drawn = Drawn::default(); // forget content so re-show re-renders
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -353,6 +366,7 @@ impl Overlay for SkiaOverlay {
|
||||
stats: ctx.stats.map(str::to_owned),
|
||||
hint: ctx.hint.map(str::to_owned),
|
||||
banner_step,
|
||||
resize_step,
|
||||
};
|
||||
if want == self.drawn {
|
||||
// Unchanged — hand the presenter the already-rendered image.
|
||||
@@ -374,6 +388,10 @@ impl Overlay for SkiaOverlay {
|
||||
let canvas = slot.surface.canvas();
|
||||
canvas.clear(Color4f::new(0.0, 0.0, 0.0, 0.0));
|
||||
let font = self.font.as_ref().expect("init ran");
|
||||
// The resize scrim sits UNDER the OSD/hint so those stay legible over it.
|
||||
if let Some(phase) = resize_phase {
|
||||
draw_resize_scrim(canvas, font, ctx.width, ctx.height, phase);
|
||||
}
|
||||
if let Some(stats) = &want.stats {
|
||||
draw_osd_panel(canvas, font, stats, 12.0, 12.0);
|
||||
}
|
||||
@@ -445,6 +463,23 @@ impl SkiaOverlay {
|
||||
((BANNER_S - age) / BANNER_FADE_S).min(1.0)
|
||||
}
|
||||
|
||||
/// The mid-stream-resize spinner's phase (elapsed seconds since the scrim came up), or
|
||||
/// `None` when no resize is in flight. Latches the start on the first `resizing` frame
|
||||
/// and clears it the moment the run loop drops the flag (the target frame landed or the
|
||||
/// switch timed out), so the next resize starts its spinner from zero.
|
||||
fn resize_phase(&mut self, ctx: &FrameCtx) -> Option<f64> {
|
||||
if !ctx.resizing {
|
||||
self.resizing_since = None;
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
self.resizing_since
|
||||
.get_or_insert_with(Instant::now)
|
||||
.elapsed()
|
||||
.as_secs_f64(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Make `slots[i]` a render target of exactly `width`×`height` (rebuilt on resize).
|
||||
fn ensure_slot(&mut self, i: usize, width: u32, height: u32) -> Result<()> {
|
||||
if self.slots[i]
|
||||
@@ -540,6 +575,33 @@ fn draw_osd_panel(canvas: &Canvas, font: &Font, text: &str, x: f32, y: f32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The mid-stream-resize cover: a full-screen dark scrim, the shared rotating spinner, and
|
||||
/// a "Resizing…" label centered over it — so the host's 0.3–2 s virtual-display + encoder
|
||||
/// rebuild reads as a deliberate pause rather than the stream stretching to the changed
|
||||
/// window. This is the presenter's analog of the Apple client's blur overlay: the overlay
|
||||
/// composites its own RGBA quad and cannot sample the video to blur it, so an opaque scrim
|
||||
/// hides the stretched in-between frame instead (same intent, one draw).
|
||||
fn draw_resize_scrim(canvas: &Canvas, font: &Font, width: u32, height: u32, phase: f64) {
|
||||
let (wf, hf) = (width as f32, height as f32);
|
||||
canvas.draw_rect(
|
||||
Rect::from_wh(wf, hf),
|
||||
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.55), None),
|
||||
);
|
||||
// Spinner slightly above center; the label sits below it.
|
||||
let (cx, cy) = (f64::from(width) / 2.0, f64::from(height) / 2.0);
|
||||
let r = (f64::from(width.min(height)) * 0.045).clamp(16.0, 44.0);
|
||||
crate::theme::spinner(canvas, cx, cy - r, r, phase);
|
||||
let (_, metrics) = font.metrics();
|
||||
let label = "Resizing\u{2026}";
|
||||
let tw = font.measure_str(label, None).0;
|
||||
canvas.draw_str(
|
||||
label,
|
||||
Point::new((wf - tw) / 2.0, (cy + r * 0.9) as f32 - metrics.ascent),
|
||||
font,
|
||||
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.9), None),
|
||||
);
|
||||
}
|
||||
|
||||
/// The capture hint / start banner: a centered pill near the bottom edge.
|
||||
fn draw_hint_pill(canvas: &Canvas, font: &Font, text: &str, width: u32, height: u32, alpha: f32) {
|
||||
let (_, metrics) = font.metrics();
|
||||
|
||||
@@ -37,6 +37,11 @@ pub struct FrameCtx<'a> {
|
||||
pub stats: Option<&'a str>,
|
||||
/// The capture hint (bottom-center pill, "click to capture…"); `None` = hidden.
|
||||
pub hint: Option<&'a str>,
|
||||
/// A mid-stream Match-window resize is in flight (design/midstream-resolution-resize.md,
|
||||
/// client UX): draw a full-screen scrim + spinner so the host's 0.3–2 s virtual-display
|
||||
/// and encoder rebuild reads as an intentional pause rather than the stream stretching to
|
||||
/// the changed window. Cleared the instant the sharp new-resolution frame is on glass.
|
||||
pub resizing: bool,
|
||||
/// The active gamepad's name (the console library's controller chip).
|
||||
pub pad: Option<&'a str>,
|
||||
/// The active pad's resolved kind — drives the console UI's button glyphs
|
||||
|
||||
+181
-15
@@ -201,6 +201,9 @@ struct StreamState {
|
||||
/// The connector mode last shown in the HUD/title — a change (an accepted switch's
|
||||
/// ack, or a corrective rollback) refreshes both.
|
||||
shown_mode: Option<Mode>,
|
||||
/// Resize-in-progress overlay (scrim + spinner) — armed by [`resize_tick`] when it
|
||||
/// requests a switch, cleared when a decoded frame reaches the target (or on timeout).
|
||||
resize_overlay: ResizeIndicator,
|
||||
}
|
||||
|
||||
impl StreamState {
|
||||
@@ -249,6 +252,7 @@ impl StreamState {
|
||||
resize_sent_at: None,
|
||||
resize_requested: None,
|
||||
shown_mode: None,
|
||||
resize_overlay: ResizeIndicator::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -808,6 +812,11 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
resize_tick(st, &mut window, &opts.window_title, persist.as_mut());
|
||||
}
|
||||
}
|
||||
// Resize overlay timeout: a switch the host rejected/capped never delivers the exact
|
||||
// target frame — drop the scrim so it can't linger. A no-op unless one is showing.
|
||||
if let Some(st) = stream.as_mut() {
|
||||
st.resize_overlay.tick(Instant::now());
|
||||
}
|
||||
|
||||
// --- Console UI: damage-driven overlay re-render for this iteration --------------
|
||||
if let Some(o) = overlay.as_mut() {
|
||||
@@ -832,11 +841,15 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
};
|
||||
let pad = gamepad.active();
|
||||
let pads = gamepad.pads();
|
||||
let resizing = stream
|
||||
.as_ref()
|
||||
.is_some_and(|st| st.connector.is_some() && st.resize_overlay.active());
|
||||
let ctx = FrameCtx {
|
||||
width: pw,
|
||||
height: ph,
|
||||
stats,
|
||||
hint,
|
||||
resizing,
|
||||
pad: pad.as_ref().map(|p| p.name.as_str()),
|
||||
pad_pref: pad.as_ref().map(|p| p.pref),
|
||||
pads: &pads,
|
||||
@@ -870,6 +883,10 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
newest = Some(f);
|
||||
}
|
||||
if let Some(f) = newest {
|
||||
// Resize END: a frame at the steered target size means the sharp new-mode
|
||||
// picture is here — lift the scrim. A no-op unless a switch is in flight.
|
||||
let (fw, fh) = f.image.dimensions();
|
||||
st.resize_overlay.decoded(fw, fh);
|
||||
let DecodedFrame {
|
||||
pts_ns,
|
||||
decoded_ns,
|
||||
@@ -1047,12 +1064,15 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
}
|
||||
}
|
||||
|
||||
// Browse with no video driving presents (library / connecting): composite the
|
||||
// overlay every iteration — FIFO vsync-throttles this to the display rate.
|
||||
if matches!(mode, ModeCtl::Browse(_))
|
||||
&& !presented_video
|
||||
&& stream.as_ref().is_none_or(|s| s.connector.is_none())
|
||||
{
|
||||
// Composite the overlay every iteration when no video frame drove a present but
|
||||
// something on-screen still animates: browse-idle (library / connecting), OR a
|
||||
// mid-stream resize scrim + spinner (the host's virtual-display + encoder rebuild
|
||||
// leaves a gap with no frames — without this the spinner would freeze). FIFO
|
||||
// vsync-throttles this to the display rate; the 15 ms wait keeps it smooth.
|
||||
let resize_scrim = stream.as_ref().is_some_and(|s| s.resize_overlay.active());
|
||||
let browse_idle = matches!(mode, ModeCtl::Browse(_))
|
||||
&& stream.as_ref().is_none_or(|s| s.connector.is_none());
|
||||
if !presented_video && (resize_scrim || browse_idle) {
|
||||
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
|
||||
}
|
||||
};
|
||||
@@ -1129,18 +1149,20 @@ fn resize_tick(
|
||||
persist(lw, lh);
|
||||
let Some((w, h)) = target else { return };
|
||||
tracing::info!(w, h, "window resized — requesting mode switch");
|
||||
if c
|
||||
.request_mode(Mode {
|
||||
width: w,
|
||||
height: h,
|
||||
refresh_hz: m.refresh_hz,
|
||||
})
|
||||
.is_err()
|
||||
if c.request_mode(Mode {
|
||||
width: w,
|
||||
height: h,
|
||||
refresh_hz: m.refresh_hz,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!("mode-switch request dropped — control channel closed");
|
||||
}
|
||||
st.resize_requested = Some((w, h));
|
||||
st.resize_sent_at = Some(Instant::now());
|
||||
// Show the scrim + spinner until a frame at this size lands (or the timeout):
|
||||
// the live drag itself stays sharp; only the host's rebuild gap is covered.
|
||||
st.resize_overlay.steering(w, h, Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1188,6 +1210,74 @@ fn resize_decision(
|
||||
ResizeAction::Settled(Some(target))
|
||||
}
|
||||
|
||||
/// Resize-in-progress overlay state (design/midstream-resolution-resize.md — client UX),
|
||||
/// ported from the Apple client's `ResizeIndicator`. A mid-stream Match-window switch takes
|
||||
/// the host 0.3–2 s to rebuild its virtual display + encoder, and the first new-mode frame
|
||||
/// is an IDR the decoder re-inits on. Rather than let the stream stretch to the changed
|
||||
/// window during that gap, the presenter EMBRACES the delay: a deliberate scrim + spinner
|
||||
/// the instant a switch is requested, cleared the instant the sharp new-resolution frame is
|
||||
/// on screen — so the wait reads as intentional, not as lag.
|
||||
///
|
||||
/// Driven entirely by signals the presenter already has (no new protocol):
|
||||
/// * START — [`resize_tick`] reports the size it just requested (`steering`).
|
||||
/// * END — the decode pipeline reports each frame's dimensions; when they reach the
|
||||
/// target the new picture is here (`decoded`). The accepted-switch ack alone can't
|
||||
/// end it: the ack round-trips in milliseconds, ahead of the host's rebuild.
|
||||
/// * TIMEOUT — the safety net for a switch that never delivers the exact target (a
|
||||
/// gamescope reject, an advertised-mode cap, or a corrective ack landing a different
|
||||
/// size); `tick` clears it after [`ResizeIndicator::TIMEOUT`].
|
||||
///
|
||||
/// Pure + clock-injected so the transition logic is unit-tested without a live session.
|
||||
#[derive(Default)]
|
||||
struct ResizeIndicator {
|
||||
/// The size the follower is steering toward — cleared once a decoded frame reaches it.
|
||||
/// `Some` ⇔ the scrim + spinner should be shown.
|
||||
target: Option<(u32, u32)>,
|
||||
/// When the current active span began — the timeout is measured from here.
|
||||
since: Option<Instant>,
|
||||
}
|
||||
|
||||
impl ResizeIndicator {
|
||||
/// How long to keep the overlay up if the target frame never arrives.
|
||||
const TIMEOUT: Duration = Duration::from_millis(2500);
|
||||
|
||||
/// Whether the scrim + spinner should be shown.
|
||||
fn active(&self) -> bool {
|
||||
self.target.is_some()
|
||||
}
|
||||
|
||||
/// A switch to `w`×`h` was just requested — show the overlay now. The timeout re-arms
|
||||
/// only when the target actually changes, so a drag that walks through several sizes
|
||||
/// (each its own request) never trips the timeout mid-gesture.
|
||||
fn steering(&mut self, w: u32, h: u32, now: Instant) {
|
||||
if self.target != Some((w, h)) {
|
||||
self.since = Some(now);
|
||||
}
|
||||
self.target = Some((w, h));
|
||||
}
|
||||
|
||||
/// A decoded frame arrived at `w`×`h`. Clears the overlay once it matches the steered
|
||||
/// target — the sharp new-resolution picture is on glass.
|
||||
fn decoded(&mut self, w: u32, h: u32) {
|
||||
if self.target == Some((w, h)) {
|
||||
self.target = None;
|
||||
self.since = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Timeout safety net: stop showing once [`TIMEOUT`](Self::TIMEOUT) has elapsed with no
|
||||
/// matching frame (a rejected or host-capped switch never delivers the exact target).
|
||||
fn tick(&mut self, now: Instant) {
|
||||
if self
|
||||
.since
|
||||
.is_some_and(|s| now.duration_since(s) >= Self::TIMEOUT)
|
||||
{
|
||||
self.target = None;
|
||||
self.since = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the capture state to the window: pointer lock (relative mouse + hidden cursor)
|
||||
/// and — on Windows — a keyboard grab, so system chords (Alt+Tab, the Windows key) reach
|
||||
/// the host while captured instead of the local shell. SDL implements the grab there
|
||||
@@ -1362,7 +1452,14 @@ mod tests {
|
||||
// Equal to the streamed mode → settle (persist) but no request.
|
||||
let mut pending = Some(t0);
|
||||
assert_eq!(
|
||||
resize_decision(t0 + ms(400), &mut pending, None, None, (1280, 720), (1280, 720)),
|
||||
resize_decision(
|
||||
t0 + ms(400),
|
||||
&mut pending,
|
||||
None,
|
||||
None,
|
||||
(1280, 720),
|
||||
(1280, 720)
|
||||
),
|
||||
ResizeAction::Settled(None)
|
||||
);
|
||||
|
||||
@@ -1384,11 +1481,80 @@ mod tests {
|
||||
// Tiny windows clamp to the host's floor.
|
||||
let mut pending = Some(t0);
|
||||
assert_eq!(
|
||||
resize_decision(t0 + ms(400), &mut pending, None, None, (1280, 720), (100, 80)),
|
||||
resize_decision(
|
||||
t0 + ms(400),
|
||||
&mut pending,
|
||||
None,
|
||||
None,
|
||||
(1280, 720),
|
||||
(100, 80)
|
||||
),
|
||||
ResizeAction::Settled(Some((320, 200)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_indicator_shows_until_the_target_frame_or_timeout() {
|
||||
let t0 = Instant::now();
|
||||
let ms = Duration::from_millis;
|
||||
|
||||
// Idle at rest.
|
||||
let mut ind = ResizeIndicator::default();
|
||||
assert!(!ind.active());
|
||||
|
||||
// A requested switch shows the overlay immediately.
|
||||
ind.steering(1000, 600, t0);
|
||||
assert!(ind.active());
|
||||
|
||||
// A frame at a DIFFERENT size (a stale old-mode frame still draining) doesn't lift it.
|
||||
ind.decoded(1280, 720);
|
||||
assert!(ind.active(), "an off-target frame keeps the scrim up");
|
||||
|
||||
// The sharp new-resolution frame arrives → cleared.
|
||||
ind.decoded(1000, 600);
|
||||
assert!(!ind.active(), "the target frame lifts the scrim");
|
||||
ind.tick(t0 + ms(10_000)); // a late tick after clearing is inert
|
||||
assert!(!ind.active());
|
||||
|
||||
// A switch whose target frame never arrives (rejected / host-capped) times out.
|
||||
let mut ind = ResizeIndicator::default();
|
||||
ind.steering(1000, 600, t0);
|
||||
ind.tick(t0 + ResizeIndicator::TIMEOUT - ms(1));
|
||||
assert!(ind.active(), "still within the timeout window");
|
||||
ind.tick(t0 + ResizeIndicator::TIMEOUT);
|
||||
assert!(!ind.active(), "timeout lifts a switch that never delivered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_indicator_retargets_and_rearms_the_timeout_mid_drag() {
|
||||
let t0 = Instant::now();
|
||||
let ms = Duration::from_millis;
|
||||
|
||||
// A drag that walks through sizes (each a fresh request) re-arms the timeout, so a
|
||||
// slow gesture never trips it: at t0 steer A, then near-timeout steer B, then a B
|
||||
// frame lands well after A's timeout would have fired.
|
||||
let mut ind = ResizeIndicator::default();
|
||||
ind.steering(1000, 600, t0);
|
||||
let near = t0 + ResizeIndicator::TIMEOUT - ms(1);
|
||||
ind.steering(1200, 700, near); // new target → timeout re-armed from `near`
|
||||
ind.tick(t0 + ResizeIndicator::TIMEOUT + ms(1)); // past A's window, within B's
|
||||
assert!(
|
||||
ind.active(),
|
||||
"retarget re-armed the timeout — no mid-drag flicker"
|
||||
);
|
||||
|
||||
// Re-steering the SAME size does NOT re-arm (so a repeated identical request can't
|
||||
// hold the scrim open forever).
|
||||
let mut ind = ResizeIndicator::default();
|
||||
ind.steering(1000, 600, t0);
|
||||
ind.steering(1000, 600, t0 + ms(500)); // same target, later — `since` unchanged
|
||||
ind.tick(t0 + ResizeIndicator::TIMEOUT);
|
||||
assert!(
|
||||
!ind.active(),
|
||||
"an unchanged target keeps the original timeout"
|
||||
);
|
||||
}
|
||||
|
||||
fn sample() -> (Stats, PresentedWindow) {
|
||||
(
|
||||
Stats {
|
||||
|
||||
@@ -526,6 +526,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
|
||||
@@ -922,6 +926,14 @@ pub const PUNKTFUNK_CODEC_HEVC: u8 = 0x02;
|
||||
/// Codec bit: AV1. (Mirrors `quic::CODEC_AV1`.)
|
||||
pub const PUNKTFUNK_CODEC_AV1: u8 = 0x04;
|
||||
|
||||
/// 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 _: () = {
|
||||
@@ -931,6 +943,8 @@ const _: () = {
|
||||
assert!(PUNKTFUNK_CODEC_H264 == crate::quic::CODEC_H264);
|
||||
assert!(PUNKTFUNK_CODEC_HEVC == crate::quic::CODEC_HEVC);
|
||||
assert!(PUNKTFUNK_CODEC_AV1 == crate::quic::CODEC_AV1);
|
||||
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).
|
||||
@@ -1395,6 +1409,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex7(
|
||||
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(_) => std::ptr::null_mut(),
|
||||
@@ -2261,6 +2276,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
|
||||
@@ -2405,6 +2811,70 @@ pub unsafe extern "C" fn punktfunk_connection_request_keyframe(
|
||||
})
|
||||
}
|
||||
|
||||
/// Ask the host to recover from loss by **reference-frame invalidation** rather than a full IDR:
|
||||
/// report the range `[first_frame, last_frame]` of access units the client can no longer trust
|
||||
/// (the first missing `frame_index` through the newest received). An RFI-capable host (AMD LTR /
|
||||
/// NVENC) re-references a known-good picture before `first_frame` and emits a clean P-frame tagged
|
||||
/// `USER_FLAG_RECOVERY_ANCHOR` — no 20-40x IDR spike; a host that can't RFI forces an IDR instead
|
||||
/// (same effect as [`punktfunk_connection_request_keyframe`]). Non-blocking, fire-and-forget; the
|
||||
/// recovered frame is the only ack, so THROTTLE it exactly like the keyframe request. Prefer this
|
||||
/// over the keyframe request on loss so AMD/RFI hosts avoid the spike; keep the keyframe request as
|
||||
/// the backstop for when the recovery frame itself is lost.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_request_rfi(
|
||||
c: *const PunktfunkConnection,
|
||||
first_frame: u32,
|
||||
last_frame: u32,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
match c.inner.request_rfi(first_frame, last_frame) {
|
||||
Ok(()) => PunktfunkStatus::Ok,
|
||||
Err(e) => e.status(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Feed each received frame's `frame_index` (the [`PunktfunkFrame::frame_index`] field, in receive
|
||||
/// order) so the client recovers from loss with a cheap reference-frame invalidation instead of a
|
||||
/// full IDR. On a forward gap (a `frame_index` jump = the intervening frames were lost and the
|
||||
/// following AUs reference a picture that never arrived) this fires a THROTTLED
|
||||
/// [`punktfunk_connection_request_rfi`] for the lost range; an RFI-capable host (AMD LTR / NVENC)
|
||||
/// then recovers with a clean P-frame instead of a 20-40x IDR spike. Call it for every received
|
||||
/// frame — it is cheap and idempotent, and the [`punktfunk_connection_frames_dropped`]-driven
|
||||
/// keyframe request stays the backstop. Writes whether a forward gap was detected this call to
|
||||
/// `gap_out` (nullable — a client with a post-loss display freeze can use it to re-arm; most
|
||||
/// clients pass NULL and ignore it).
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `gap_out` is writable or NULL.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_note_frame_index(
|
||||
c: *const PunktfunkConnection,
|
||||
frame_index: u32,
|
||||
gap_out: *mut bool,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
let gap = c.inner.note_frame_index(frame_index);
|
||||
if !gap_out.is_null() {
|
||||
unsafe { *gap_out = gap };
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// Cumulative access units the host→client reassembler dropped as unrecoverable (FEC couldn't
|
||||
/// rebuild them). A video loop polls this and calls [`punktfunk_connection_request_keyframe`]
|
||||
/// when it climbs — the correct loss trigger under the host's infinite GOP, where unrecoverable
|
||||
|
||||
@@ -12,14 +12,16 @@
|
||||
//! channel. All methods are safe to call from any single embedder thread.
|
||||
|
||||
use crate::abr::BitrateController;
|
||||
use crate::clipboard::{ClipCommand, ClipEventCore};
|
||||
use crate::config::{CompositorPref, GamepadPref, Mode, Role};
|
||||
use crate::error::{PunktfunkError, Result};
|
||||
use crate::input::InputEvent;
|
||||
use crate::packet::FLAG_PROBE;
|
||||
use crate::quic::{
|
||||
accept_resync, endpoint, io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClockEcho,
|
||||
ClockResync, ColorInfo, HdrMeta, Hello, HidOutput, LossReport, ProbeRequest, ProbeResult,
|
||||
Reconfigure, Reconfigured, RequestKeyframe, ResyncStep, RichInput, SetBitrate, Start, Welcome,
|
||||
accept_resync, endpoint, io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipControl,
|
||||
ClipKind, ClipOffer, ClipState, ClockEcho, ClockResync, ColorInfo, HdrMeta, Hello, HidOutput,
|
||||
LossReport, ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, ResyncStep,
|
||||
RfiRequest, RichInput, SetBitrate, Start, Welcome,
|
||||
};
|
||||
use crate::session::{Frame, Session};
|
||||
use crate::transport::UdpTransport;
|
||||
@@ -49,6 +51,10 @@ enum CtrlRequest {
|
||||
Mode(Mode),
|
||||
Probe(ProbeRequest),
|
||||
Keyframe,
|
||||
/// Reference-frame-invalidation recovery: the client saw a `frame_index` gap and reports the
|
||||
/// invalidation range so an RFI-capable host re-references a known-good picture instead of
|
||||
/// forcing a full IDR. See [`RfiRequest`].
|
||||
Rfi(RfiRequest),
|
||||
Loss(LossReport),
|
||||
/// Adaptive bitrate: ask the host to re-target its encoder (kbps). Sent by the pump's
|
||||
/// [`BitrateController`] when the user's bitrate setting is Automatic.
|
||||
@@ -57,6 +63,12 @@ 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
|
||||
@@ -88,6 +100,10 @@ struct Negotiated {
|
||||
audio_channels: u8,
|
||||
/// The single codec the host will emit (`quic::CODEC_*`).
|
||||
codec: u8,
|
||||
/// The host capability bitfield ([`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.
|
||||
host_caps: u8,
|
||||
}
|
||||
|
||||
/// Accumulated state of an in-flight / finished speed test. The data-plane pump mirrors the
|
||||
@@ -346,6 +362,12 @@ const HDR_META_QUEUE: usize = 8;
|
||||
/// harmless, it's per-frame observability, not state.
|
||||
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.
|
||||
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 {
|
||||
@@ -355,6 +377,83 @@ pub struct AudioPacket {
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// At most one client→host RFI request per this window, so a burst of frame-index gaps (a
|
||||
/// full-screen pan shedding shards) can't storm the control stream. Matches the shared Vulkan pump's
|
||||
/// recovery-request throttle; the host coalesces further.
|
||||
const RFI_THROTTLE: Duration = Duration::from_millis(100);
|
||||
|
||||
/// State for [`NativeClient::note_frame_index`] — the client-side loss-range detector shared by every
|
||||
/// embedder (Android, the C-ABI Apple client, the Windows shell pump) so none re-derives the wrapping
|
||||
/// frame-index arithmetic. `next_expected` is the `frame_index` expected next in receive order;
|
||||
/// `last_req` throttles the RFI requests a gap fires.
|
||||
#[derive(Default)]
|
||||
struct RfiRecovery {
|
||||
next_expected: Option<u32>,
|
||||
last_req: Option<Instant>,
|
||||
}
|
||||
|
||||
/// What a forward gap should ask the host for: a precise RFI for a recoverable range, a plain
|
||||
/// keyframe for a range wider than any encoder's reference history
|
||||
/// ([`crate::packet::RFI_MAX_RANGE`] — a seconds-long outage, or a phantom index jump such as an
|
||||
/// old host's speed-test burst consuming video indexes), or nothing (contiguous / straggler /
|
||||
/// throttled).
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum RecoveryAsk {
|
||||
None,
|
||||
Rfi(u32, u32),
|
||||
Keyframe,
|
||||
}
|
||||
|
||||
impl RfiRecovery {
|
||||
/// Pure decision behind [`NativeClient::note_frame_index`]: fold one received `frame_index` (in
|
||||
/// receive order) observed at `now`, advancing the expectation and returning `(gap, ask)`.
|
||||
/// `gap` is whether this frame revealed a forward gap (the embedder arms its post-loss display
|
||||
/// freeze on it); `ask` is the (throttled) recovery request to fire — an RFI naming the exact
|
||||
/// lost span, or a keyframe when the span exceeds [`crate::packet::RFI_MAX_RANGE`] (RFI is
|
||||
/// hopeless there: no encoder holds references that old, and a huge jump is more likely a
|
||||
/// resync — e.g. the first real AU after an old host's speed test — than a real loss). Split
|
||||
/// out from the connection so the wrapping arithmetic + [`RFI_THROTTLE`] are unit-testable
|
||||
/// without a live session (see the tests below).
|
||||
fn observe(&mut self, frame_index: u32, now: Instant) -> (bool, RecoveryAsk) {
|
||||
match self.next_expected {
|
||||
Some(exp) => {
|
||||
// Wrapping split at the half-space: a small positive delta is a forward gap
|
||||
// (missing frames); a delta in the top half is a straggler behind us.
|
||||
let ahead = frame_index.wrapping_sub(exp);
|
||||
if ahead == 0 {
|
||||
self.next_expected = Some(frame_index.wrapping_add(1)); // contiguous
|
||||
(false, RecoveryAsk::None)
|
||||
} else if ahead < u32::MAX / 2 {
|
||||
// Forward gap: [exp, frame_index-1] lost. Advance past this frame so the same
|
||||
// gap isn't re-detected, then fire a throttled recovery ask for the lost range.
|
||||
self.next_expected = Some(frame_index.wrapping_add(1));
|
||||
let send = self
|
||||
.last_req
|
||||
.is_none_or(|t| now.duration_since(t) >= RFI_THROTTLE);
|
||||
if send {
|
||||
self.last_req = Some(now);
|
||||
}
|
||||
let ask = if !send {
|
||||
RecoveryAsk::None
|
||||
} else if ahead > crate::packet::RFI_MAX_RANGE {
|
||||
RecoveryAsk::Keyframe
|
||||
} else {
|
||||
RecoveryAsk::Rfi(exp, frame_index.wrapping_sub(1))
|
||||
};
|
||||
(true, ask)
|
||||
} else {
|
||||
// Straggler behind the delivery point — leave the expectation.
|
||||
(false, RecoveryAsk::None)
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.next_expected = Some(frame_index.wrapping_add(1));
|
||||
(false, RecoveryAsk::None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NativeClient {
|
||||
// Each plane's receiver sits behind its own mutex so `NativeClient` is `Sync` and Rust
|
||||
// embedders can share one `Arc<NativeClient>` across their plane threads (the same
|
||||
@@ -381,6 +480,19 @@ 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 ([`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>,
|
||||
@@ -398,6 +510,10 @@ pub struct NativeClient {
|
||||
/// for the client stats HUDs (the unified spec's per-window `FEC` counter — proof FEC is
|
||||
/// earning its keep); readers window it by diffing successive reads.
|
||||
fec_recovered: Arc<AtomicU64>,
|
||||
/// Client-side RFI-on-loss detector state for [`note_frame_index`](Self::note_frame_index): the
|
||||
/// next `frame_index` expected in receive order + the last RFI-request time (throttle). Lets every
|
||||
/// embedder share one loss-range detector instead of re-deriving the wrapping frame arithmetic.
|
||||
rfi: Mutex<RfiRecovery>,
|
||||
/// Kernel ids of the client's latency-critical native threads: the internal data-plane pump
|
||||
/// (UDP receive + FEC reassembly) plus any embedder plane threads registered via
|
||||
/// [`NativeClient::register_hot_thread`]. The Android client feeds these to an ADPF hint session
|
||||
@@ -580,6 +696,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));
|
||||
@@ -645,6 +764,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,
|
||||
@@ -678,12 +799,17 @@ 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,
|
||||
worker: Some(worker),
|
||||
frames_dropped,
|
||||
fec_recovered,
|
||||
rfi: Mutex::new(RfiRecovery::default()),
|
||||
hot_tids,
|
||||
clock_offset,
|
||||
mode: mode_slot,
|
||||
@@ -868,6 +994,62 @@ impl NativeClient {
|
||||
.map_err(|_| PunktfunkError::Closed)
|
||||
}
|
||||
|
||||
/// Ask the host to recover from loss by **reference-frame invalidation** rather than a full IDR:
|
||||
/// the client reports the range `[first_frame, last_frame]` of access units it can no longer trust
|
||||
/// (from the first missing `frame_index` through the newest received). An RFI-capable host
|
||||
/// re-references a known-good picture before `first_frame` (AMD LTR / NVENC RFI) and emits a clean
|
||||
/// P-frame tagged [`crate::packet::USER_FLAG_RECOVERY_ANCHOR`]; a host that can't RFI forces an IDR
|
||||
/// instead (same as [`request_keyframe`](Self::request_keyframe)). Non-blocking, fire-and-forget —
|
||||
/// the recovered frame is the only ack; throttle it like the keyframe request. Prefer this over
|
||||
/// `request_keyframe` on loss so AMD/RFI hosts avoid the IDR spike; the keyframe request remains
|
||||
/// the backstop when the recovery frame itself is lost.
|
||||
pub fn request_rfi(&self, first_frame: u32, last_frame: u32) -> Result<()> {
|
||||
self.ctrl_tx
|
||||
.try_send(CtrlRequest::Rfi(RfiRequest {
|
||||
first_frame,
|
||||
last_frame,
|
||||
}))
|
||||
.map_err(|_| PunktfunkError::Closed)
|
||||
}
|
||||
|
||||
/// Feed each received AU's `frame_index` (in receive order) so the client recovers from loss with
|
||||
/// a cheap reference-frame invalidation instead of always paying for a full IDR. On a **forward
|
||||
/// gap** — a `frame_index` jump means the intervening frames were lost and the following AUs
|
||||
/// reference a picture the decoder never got — this fires a **throttled**
|
||||
/// [`request_rfi`](Self::request_rfi) for the lost range `[first_missing, frame_index-1]`. An
|
||||
/// RFI-capable host (AMD LTR / NVENC) then re-references a known-good frame (a clean P-frame, no
|
||||
/// 20-40x IDR spike); a host that can't RFI forces an IDR, same as the keyframe path.
|
||||
///
|
||||
/// Call it for EVERY received frame; it is cheap and idempotent, and the
|
||||
/// [`frames_dropped`](Self::frames_dropped)-driven [`request_keyframe`](Self::request_keyframe)
|
||||
/// loop stays the backstop for when the recovery frame itself is lost. Returns `true` when a
|
||||
/// forward gap was detected on this call (whether or not the RFI was throttled), so a client with
|
||||
/// a post-loss display freeze can (re-)arm it on the same signal.
|
||||
///
|
||||
/// This centralizes the loss-range detection so every embedder gets identical behavior. (The
|
||||
/// in-process Vulkan session pump keeps its own copy because it gates a display freeze on the same
|
||||
/// signal and shares one throttle across RFI + keyframe requests.)
|
||||
pub fn note_frame_index(&self, frame_index: u32) -> bool {
|
||||
// Decide (and update state) under the lock; fire the request after releasing it.
|
||||
let (gap, ask) = self
|
||||
.rfi
|
||||
.lock()
|
||||
.unwrap()
|
||||
.observe(frame_index, Instant::now());
|
||||
match ask {
|
||||
RecoveryAsk::Rfi(first, last) => {
|
||||
let _ = self.request_rfi(first, last);
|
||||
}
|
||||
// A gap wider than any encoder's reference history (RFI_MAX_RANGE) — a seconds-long
|
||||
// outage or a phantom index jump: RFI can't repair it, resync on a keyframe instead.
|
||||
RecoveryAsk::Keyframe => {
|
||||
let _ = self.request_keyframe();
|
||||
}
|
||||
RecoveryAsk::None => {}
|
||||
}
|
||||
gap
|
||||
}
|
||||
|
||||
/// Cumulative access units the host→client reassembler dropped as unrecoverable (FEC couldn't
|
||||
/// rebuild them). A video loop polls this and calls [`request_keyframe`](Self::request_keyframe)
|
||||
/// when it increases — the correct loss trigger under infinite GOP, where unrecoverable loss
|
||||
@@ -1097,6 +1279,83 @@ impl NativeClient {
|
||||
self.input_tx.send(*ev).map_err(|_| PunktfunkError::Closed)
|
||||
}
|
||||
|
||||
/// The host capability bitfield the [`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.
|
||||
@@ -1196,6 +1455,8 @@ struct WorkerArgs {
|
||||
rich_input_rx: tokio::sync::mpsc::UnboundedReceiver<RichInput>,
|
||||
ctrl_rx: tokio::sync::mpsc::Receiver<CtrlRequest>,
|
||||
ctrl_tx: tokio::sync::mpsc::Sender<CtrlRequest>,
|
||||
clip_event_tx: SyncSender<ClipEventCore>,
|
||||
clip_cmd_rx: tokio::sync::mpsc::UnboundedReceiver<ClipCommand>,
|
||||
ready_tx: std::sync::mpsc::Sender<Result<Negotiated>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
/// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set.
|
||||
@@ -1239,6 +1500,8 @@ async fn worker_main(args: WorkerArgs) {
|
||||
mut rich_input_rx,
|
||||
mut ctrl_rx,
|
||||
ctrl_tx,
|
||||
clip_event_tx,
|
||||
clip_cmd_rx,
|
||||
ready_tx,
|
||||
shutdown,
|
||||
quit,
|
||||
@@ -1297,7 +1560,12 @@ async fn worker_main(args: WorkerArgs) {
|
||||
// when the matching bit is set, so `0` stays an 8-bit BT.709 stream. HOST_TIMING is
|
||||
// OR'd in unconditionally: every NativeClient build demuxes the 0xCF plane, and the
|
||||
// bit only asks the host for observability datagrams (never changes the encode).
|
||||
video_caps: video_caps | crate::quic::VIDEO_CAP_HOST_TIMING,
|
||||
// PROBE_SEQ likewise: the shared reassembler keeps probe filler in its own window
|
||||
// (every embedder inherits it), so the host may burst speed tests without consuming
|
||||
// video frame indexes.
|
||||
video_caps: video_caps
|
||||
| crate::quic::VIDEO_CAP_HOST_TIMING
|
||||
| crate::quic::VIDEO_CAP_PROBE_SEQ,
|
||||
// Requested surround channel count; the host echoes the resolved value in Welcome.
|
||||
audio_channels,
|
||||
// The codecs this client can decode + its soft preference (0 = auto). The host
|
||||
@@ -1384,22 +1652,22 @@ async fn worker_main(args: WorkerArgs) {
|
||||
chroma_format: welcome.chroma_format,
|
||||
audio_channels: welcome.audio_channels,
|
||||
codec: welcome.codec,
|
||||
host_caps: welcome.host_caps,
|
||||
},
|
||||
welcome.host_caps,
|
||||
))
|
||||
};
|
||||
|
||||
let (conn, mut session, mut ctrl_send, mut ctrl_recv, negotiated, host_caps) = match setup.await
|
||||
{
|
||||
let (conn, mut session, mut ctrl_send, mut ctrl_recv, negotiated) = match setup.await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
let _ = ready_tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Copies the pump needs after `negotiated` is handed over to `connect`.
|
||||
// Copies the worker needs after `negotiated` is handed over to `connect`.
|
||||
let clock_rtt_ns = negotiated.clock_rtt_ns;
|
||||
let resolved_bitrate_kbps = negotiated.bitrate_kbps;
|
||||
let host_caps = negotiated.host_caps;
|
||||
// Seed the live offset with the connect-time estimate BEFORE the embedder can observe the
|
||||
// client (ready_tx): clock_offset_now_ns() never reads a pre-handshake 0 on a skewed pair.
|
||||
clock_offset.store(negotiated.clock_offset_ns, Ordering::Relaxed);
|
||||
@@ -1491,6 +1759,9 @@ async fn worker_main(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
|
||||
@@ -1511,6 +1782,7 @@ async fn worker_main(args: WorkerArgs) {
|
||||
CtrlRequest::Mode(m) => Reconfigure { mode: m }.encode(),
|
||||
CtrlRequest::Probe(p) => p.encode(),
|
||||
CtrlRequest::Keyframe => RequestKeyframe.encode(),
|
||||
CtrlRequest::Rfi(r) => r.encode(),
|
||||
CtrlRequest::Loss(r) => r.encode(),
|
||||
CtrlRequest::SetBitrate(k) => SetBitrate { bitrate_kbps: k }.encode(),
|
||||
CtrlRequest::ClockResync => {
|
||||
@@ -1519,6 +1791,8 @@ async fn worker_main(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;
|
||||
@@ -1600,6 +1874,21 @@ async fn worker_main(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!("unknown control message — ignoring");
|
||||
}
|
||||
@@ -1676,6 +1965,17 @@ async fn worker_main(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();
|
||||
@@ -1952,6 +2252,131 @@ mod host_port_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rfi_recovery_tests {
|
||||
//! The client-side loss-range detector shared by every embedder (Android, the C-ABI Apple
|
||||
//! client, the Windows shell pump). `observe` is pure over `(frame_index, now)`, so the wrapping
|
||||
//! frame arithmetic and the RFI throttle are exercised here without a live session.
|
||||
use super::{RecoveryAsk, RfiRecovery, RFI_THROTTLE};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// A fixed base instant; offsets model the throttle window deterministically (no sleeping).
|
||||
fn base() -> Instant {
|
||||
Instant::now()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_frame_arms_without_a_gap() {
|
||||
let mut r = RfiRecovery::default();
|
||||
// The opening frame only seeds the expectation — there is no prior frame to be missing.
|
||||
assert_eq!(r.observe(100, base()), (false, RecoveryAsk::None));
|
||||
assert_eq!(r.next_expected, Some(101));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contiguous_frames_never_gap() {
|
||||
let mut r = RfiRecovery::default();
|
||||
let t = base();
|
||||
r.observe(100, t);
|
||||
assert_eq!(r.observe(101, t), (false, RecoveryAsk::None));
|
||||
assert_eq!(r.observe(102, t), (false, RecoveryAsk::None));
|
||||
assert_eq!(r.observe(103, t), (false, RecoveryAsk::None));
|
||||
assert_eq!(r.next_expected, Some(104));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forward_gap_reports_the_exact_lost_range() {
|
||||
let mut r = RfiRecovery::default();
|
||||
let t = base();
|
||||
r.observe(100, t); // expecting 101 next
|
||||
// 101..=104 were lost; 105 arrived. The RFI must name exactly the missing span.
|
||||
assert_eq!(r.observe(105, t), (true, RecoveryAsk::Rfi(101, 104)));
|
||||
// The expectation advances past the delivered frame so the same gap can't re-fire.
|
||||
assert_eq!(r.next_expected, Some(106));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_frame_drop_names_a_unit_range() {
|
||||
let mut r = RfiRecovery::default();
|
||||
let t = base();
|
||||
r.observe(100, t);
|
||||
// Exactly one frame (101) lost → range is the single index [101, 101].
|
||||
assert_eq!(r.observe(102, t), (true, RecoveryAsk::Rfi(101, 101)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn throttle_suppresses_bursts_then_re_opens() {
|
||||
let mut r = RfiRecovery::default();
|
||||
let t0 = base();
|
||||
r.observe(100, t0);
|
||||
// First gap fires the request and stamps the throttle.
|
||||
assert_eq!(r.observe(105, t0), (true, RecoveryAsk::Rfi(101, 104)));
|
||||
// A second gap 50 ms later is still a gap, but the request is throttled away.
|
||||
assert_eq!(
|
||||
r.observe(110, t0 + Duration::from_millis(50)),
|
||||
(true, RecoveryAsk::None)
|
||||
);
|
||||
// Past the window, the request re-opens for the still-accurate lost span.
|
||||
assert_eq!(
|
||||
r.observe(120, t0 + RFI_THROTTLE + Duration::from_millis(1)),
|
||||
(true, RecoveryAsk::Rfi(111, 119))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stragglers_behind_the_delivery_point_are_ignored() {
|
||||
let mut r = RfiRecovery::default();
|
||||
let t = base();
|
||||
r.observe(100, t);
|
||||
r.observe(105, t); // expecting 106 next
|
||||
// A reordered late arrival (103, well behind 106) is neither a gap nor a request, and it
|
||||
// must not rewind the expectation — otherwise the next in-order frame would false-gap.
|
||||
assert_eq!(r.observe(103, t), (false, RecoveryAsk::None));
|
||||
assert_eq!(r.next_expected, Some(106));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wraparound_is_contiguous_across_u32_max() {
|
||||
let mut r = RfiRecovery::default();
|
||||
let t = base();
|
||||
r.observe(u32::MAX - 1, t); // expecting u32::MAX next
|
||||
assert_eq!(r.observe(u32::MAX, t), (false, RecoveryAsk::None)); // contiguous, wraps to 0
|
||||
assert_eq!(r.next_expected, Some(0));
|
||||
assert_eq!(r.observe(0, t), (false, RecoveryAsk::None)); // still contiguous across the wrap
|
||||
assert_eq!(r.next_expected, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gap_range_wraps_across_u32_max() {
|
||||
let mut r = RfiRecovery::default();
|
||||
let t = base();
|
||||
r.observe(u32::MAX - 1, t); // expecting u32::MAX next
|
||||
// u32::MAX was lost and 1 arrived → the lost span wraps: [u32::MAX, 0].
|
||||
assert_eq!(r.observe(1, t), (true, RecoveryAsk::Rfi(u32::MAX, 0)));
|
||||
assert_eq!(r.next_expected, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_gap_resyncs_via_keyframe_not_rfi() {
|
||||
let mut r = RfiRecovery::default();
|
||||
let t = base();
|
||||
r.observe(100, t); // expecting 101 next
|
||||
// A jump wider than any encoder's reference history (RFI_MAX_RANGE): no valid
|
||||
// reference exists for an RFI, and the jump may be a phantom (an old host's
|
||||
// speed-test burst consuming video indexes) — ask for the IDR resync instead.
|
||||
let jump = 100 + crate::packet::RFI_MAX_RANGE + 2;
|
||||
assert_eq!(r.observe(jump, t), (true, RecoveryAsk::Keyframe));
|
||||
// The expectation still advances past the delivered frame (no re-fire on the next one).
|
||||
assert_eq!(r.next_expected, Some(jump + 1));
|
||||
assert_eq!(r.observe(jump + 1, t), (false, RecoveryAsk::None));
|
||||
// A huge gap consumes the shared throttle too — an immediate follow-up gap stays quiet.
|
||||
assert_eq!(
|
||||
r.observe(jump + 10, t + Duration::from_millis(1)),
|
||||
(true, RecoveryAsk::None)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod frame_channel_tests {
|
||||
use super::{FrameChannel, FramePop, FRAME_QUEUE_HARD_CAP};
|
||||
|
||||
@@ -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;
|
||||
@@ -61,7 +66,11 @@ pub use stats::Stats;
|
||||
/// TTL of a v2 envelope; `punktfunk_connection_next_rumble` is unchanged and drops it). Additive —
|
||||
/// the wire is backward-compatible (the envelope is a length-tolerant tail on 0xCA), so
|
||||
/// [`WIRE_VERSION`] is unchanged.
|
||||
pub const ABI_VERSION: u32 = 5;
|
||||
/// v6: 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 = 6;
|
||||
|
||||
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
|
||||
@@ -35,6 +35,34 @@ pub const FLAG_SOF: u8 = 0x4;
|
||||
/// feeding them to the decoder. Punktfunk/1 only (GameStream never sets it).
|
||||
pub const FLAG_PROBE: u8 = 0x8;
|
||||
|
||||
/// Application `user_flags` bit (the u32 [`PacketHeader::user_flags`] word, surfaced to the client
|
||||
/// as [`crate::session::Frame::flags`]) — NOT a transport packet flag. Marks the access unit that
|
||||
/// **completes an intra-refresh wave**: the picture is loss-free from here even though the frame is
|
||||
/// a coded `P` (no IDR, so the decoder never sets `AV_FRAME_FLAG_KEY`). The client lifts its
|
||||
/// post-loss display freeze on this bit as well as on a real keyframe — the only bitstream-invisible
|
||||
/// clean point it can honor without forcing a full IDR. Lives above the low nibble because the host
|
||||
/// reuses `FLAG_PIC`/`FLAG_SOF`/`FLAG_PROBE` bit values inside `user_flags`; `0x10` clears all four.
|
||||
pub const USER_FLAG_RECOVERY_POINT: u32 = 0x10;
|
||||
|
||||
/// Application `user_flags` bit — a **definitive single-frame clean re-anchor**. Unlike
|
||||
/// [`USER_FLAG_RECOVERY_POINT`] (an intra-refresh wave boundary, where the first boundary after a loss
|
||||
/// is only half-healed so the client waits for the second), this marks an access unit the host coded
|
||||
/// to reference a **known-good** picture on purpose — an AMD **LTR reference-frame-invalidation**
|
||||
/// recovery frame (`ForceLTRReferenceBitfield`): a clean P-frame off a long-term reference the client
|
||||
/// already has, not an IDR. The picture is loss-free the instant this AU decodes, so the client lifts
|
||||
/// its post-loss freeze on the **first** such mark. Coded `P` (no IDR), so the decoder never sets
|
||||
/// `AV_FRAME_FLAG_KEY` — this host flag is the only signal.
|
||||
pub const USER_FLAG_RECOVERY_ANCHOR: u32 = 0x20;
|
||||
|
||||
/// Widest lost-frame range (frames, wrapping `last - first`) a reference-frame-invalidation
|
||||
/// recovery may be asked to repair; anything wider goes straight to the keyframe path on BOTH
|
||||
/// ends. RFI can only re-reference history the encoder still holds — NVENC keeps a 5-frame DPB,
|
||||
/// AMD LTR ~1 s of marks — and a genuine loss this wide (>1 s even at 240 fps) has no valid
|
||||
/// reference anywhere, so an RFI request for it is either hopeless or (worse) a phantom range
|
||||
/// from a desynced counter. Shared by the host's RFI dispatch (range → keyframe fallback) and the
|
||||
/// client-side gap detectors (huge gap → resync + keyframe request, no RFI).
|
||||
pub const RFI_MAX_RANGE: u32 = 256;
|
||||
|
||||
/// Crypto framing overhead [`Session`](crate::session::Session) adds when encrypting:
|
||||
/// an 8-byte sequence prefix plus the GCM tag.
|
||||
pub const CRYPTO_OVERHEAD: usize = 8 + crate::crypto::TAG_LEN;
|
||||
@@ -98,8 +126,18 @@ const _: () = assert!(HEADER_LEN == 40, "PacketHeader must be 40 bytes / unpadde
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Splits encoded access units into FEC-protected shard packets. Host-side only.
|
||||
///
|
||||
/// Frame numbering: a caller can pass an **explicit** `frame_index` to
|
||||
/// [`packetize_each`](Self::packetize_each) (the punktfunk/1 encode loop owns the video numbering
|
||||
/// so the encoder's reference-frame-invalidation bookkeeping stays 1:1 with the wire across
|
||||
/// encoder rebuilds/resets), or pass `None` to draw from the internal counter (the legacy path —
|
||||
/// synthetic/spike/ABI sessions where no encoder cares). Speed-test probe filler draws from a
|
||||
/// **separate** index space ([`alloc_probe_index`](Self::alloc_probe_index)) so a burst never
|
||||
/// consumes video indexes — see [`crate::quic::VIDEO_CAP_PROBE_SEQ`].
|
||||
pub struct Packetizer {
|
||||
next_frame_index: u32,
|
||||
/// Probe-space frame counter (see [`alloc_probe_index`](Self::alloc_probe_index)).
|
||||
next_probe_index: u32,
|
||||
next_seq: u32,
|
||||
shard_payload: usize,
|
||||
fec: crate::config::FecConfig,
|
||||
@@ -115,6 +153,7 @@ impl Packetizer {
|
||||
pub fn new(config: &Config) -> Self {
|
||||
Packetizer {
|
||||
next_frame_index: 0,
|
||||
next_probe_index: 0,
|
||||
next_seq: 0,
|
||||
shard_payload: config.shard_payload,
|
||||
fec: config.fec,
|
||||
@@ -123,6 +162,17 @@ impl Packetizer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Allocate the next **probe-space** frame index (speed-test filler). A separate counter from
|
||||
/// the video `frame_index`es so a multi-thousand-AU probe burst never advances the video
|
||||
/// numbering — the client routes [`FLAG_PROBE`]-flagged shards into its own reassembly window
|
||||
/// (see [`Reassembler`]), so the two spaces never collide. Only used against clients that
|
||||
/// advertise [`crate::quic::VIDEO_CAP_PROBE_SEQ`].
|
||||
pub fn alloc_probe_index(&mut self) -> u32 {
|
||||
let i = self.next_probe_index;
|
||||
self.next_probe_index = i.wrapping_add(1);
|
||||
i
|
||||
}
|
||||
|
||||
/// Live-adjust the FEC recovery percentage (adaptive FEC). Takes effect on the next
|
||||
/// [`packetize`](Self::packetize); the wire is self-describing (each packet carries its block's
|
||||
/// data/recovery counts), so the receiver needs no notification. Clamped to ≤ 90.
|
||||
@@ -146,7 +196,7 @@ impl Packetizer {
|
||||
coder: &dyn ErasureCoder,
|
||||
) -> Result<Vec<Vec<u8>>> {
|
||||
let mut packets = Vec::new();
|
||||
self.packetize_each(frame, pts_ns, user_flags, coder, |hdr, body| {
|
||||
self.packetize_each(frame, pts_ns, user_flags, None, coder, |hdr, body| {
|
||||
let mut pkt = Vec::with_capacity(HEADER_LEN + body.len());
|
||||
pkt.extend_from_slice(hdr.as_bytes());
|
||||
pkt.extend_from_slice(body);
|
||||
@@ -162,17 +212,27 @@ impl Packetizer {
|
||||
/// shard straight into a pooled wire buffer and seal in place
|
||||
/// ([`Session::seal_frame`](crate::session::Session::seal_frame)). An `emit` error aborts
|
||||
/// the frame mid-way (packet numbering has already advanced — callers treat it as fatal).
|
||||
///
|
||||
/// `frame_index`: `Some(i)` stamps the AU with the caller's index — the punktfunk/1 encode
|
||||
/// loop numbers video AUs itself so the encoder's RFI bookkeeping (LTR marks, DPB timestamps)
|
||||
/// is 1:1 with what the client sees, surviving encoder rebuilds/resets that restart internal
|
||||
/// counters. `None` draws from the internal counter (the legacy/self-numbering path). A
|
||||
/// session must not mix the two styles for the same index space.
|
||||
pub fn packetize_each(
|
||||
&mut self,
|
||||
frame: &[u8],
|
||||
pts_ns: u64,
|
||||
user_flags: u32,
|
||||
frame_index: Option<u32>,
|
||||
coder: &dyn ErasureCoder,
|
||||
mut emit: impl FnMut(&PacketHeader, &[u8]) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
let payload = self.shard_payload;
|
||||
let frame_index = self.next_frame_index;
|
||||
self.next_frame_index = self.next_frame_index.wrapping_add(1);
|
||||
let frame_index = frame_index.unwrap_or_else(|| {
|
||||
let i = self.next_frame_index;
|
||||
self.next_frame_index = i.wrapping_add(1);
|
||||
i
|
||||
});
|
||||
|
||||
// At least one (zero-padded) data shard even for an empty frame.
|
||||
let total_data = frame.len().div_ceil(payload).max(1);
|
||||
@@ -324,10 +384,13 @@ impl ReassemblerLimits {
|
||||
}
|
||||
}
|
||||
|
||||
/// Buffers incoming shards, recovers lost ones via FEC, and emits whole access units.
|
||||
/// Client-side only.
|
||||
pub struct Reassembler {
|
||||
limits: ReassemblerLimits,
|
||||
/// One frame-index space's reassembly state: the in-flight frames, the recently-emitted memory,
|
||||
/// and the loss-window anchor. The [`Reassembler`] keeps two — video and speed-test probe filler —
|
||||
/// because the two ride **separate index counters** on a [`VIDEO_CAP_PROBE_SEQ`]-aware host
|
||||
/// (a probe burst must neither advance the video loss window nor be dropped as "stale" against
|
||||
/// it). [`VIDEO_CAP_PROBE_SEQ`]: crate::quic::VIDEO_CAP_PROBE_SEQ
|
||||
#[derive(Default)]
|
||||
struct ReassemblyWindow {
|
||||
frames: HashMap<u32, FrameBuf>,
|
||||
/// Recently-emitted frames, so stray/late shards can't resurrect them. Pruned to
|
||||
/// the reorder window alongside `frames`.
|
||||
@@ -338,13 +401,27 @@ pub struct Reassembler {
|
||||
newest_frame: Option<(u32, u64)>,
|
||||
}
|
||||
|
||||
/// Buffers incoming shards, recovers lost ones via FEC, and emits whole access units.
|
||||
/// Client-side only.
|
||||
pub struct Reassembler {
|
||||
limits: ReassemblerLimits,
|
||||
/// The video stream's window — its aged-out incomplete frames count into `frames_dropped`
|
||||
/// (the client's loss-recovery trigger).
|
||||
video: ReassemblyWindow,
|
||||
/// Speed-test probe filler ([`FLAG_PROBE`] in `user_flags`). Routed by the flag, so it also
|
||||
/// captures an OLD host's probe frames (which still carry video-space indexes — they complete
|
||||
/// fine here, and keeping them out of the video window means a burst can no longer advance the
|
||||
/// video loss anchor). Aged-out probe frames are NOT `frames_dropped` — probe loss is measured
|
||||
/// bytes-wise by the probe accumulator and must not fire video recovery.
|
||||
probe: ReassemblyWindow,
|
||||
}
|
||||
|
||||
impl Reassembler {
|
||||
pub fn new(limits: ReassemblerLimits) -> Self {
|
||||
Reassembler {
|
||||
limits,
|
||||
frames: HashMap::new(),
|
||||
completed: HashSet::new(),
|
||||
newest_frame: None,
|
||||
video: ReassemblyWindow::default(),
|
||||
probe: ReassemblyWindow::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,18 +482,28 @@ impl Reassembler {
|
||||
}
|
||||
let payload = pkt[HEADER_LEN..HEADER_LEN + shard_bytes].to_vec();
|
||||
|
||||
self.advance_window(hdr.frame_index, hdr.pts_ns, stats);
|
||||
// Route by index space: speed-test probe filler (FLAG_PROBE in user_flags) reassembles in
|
||||
// its own window so its indexes never interact with the video loss window — a probe burst
|
||||
// can neither advance the video anchor nor be dropped as stale against it (and its aged-out
|
||||
// frames never count as `frames_dropped`, which would fire video loss recovery).
|
||||
let is_probe = hdr.user_flags & (FLAG_PROBE as u32) != 0;
|
||||
let win = if is_probe {
|
||||
&mut self.probe
|
||||
} else {
|
||||
&mut self.video
|
||||
};
|
||||
win.advance_window(hdr.frame_index, hdr.pts_ns, stats, !is_probe);
|
||||
|
||||
// Drop shards for frames we've already emitted (e.g. the recovery shards of a
|
||||
// frame that completed early via the all-originals-present fast path) or that
|
||||
// have fallen out of the loss window.
|
||||
if self.completed.contains(&hdr.frame_index) || self.is_stale(hdr.frame_index, hdr.pts_ns) {
|
||||
if win.completed.contains(&hdr.frame_index) || win.is_stale(hdr.frame_index, hdr.pts_ns) {
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// First packet of a frame establishes its geometry; later packets must agree.
|
||||
let frame = self
|
||||
let frame = win
|
||||
.frames
|
||||
.entry(hdr.frame_index)
|
||||
.or_insert_with(|| FrameBuf {
|
||||
@@ -502,8 +589,8 @@ impl Reassembler {
|
||||
|
||||
// Whole frame ready?
|
||||
if frame.block_data.len() == frame.block_count {
|
||||
let frame = self.frames.remove(&hdr.frame_index).unwrap();
|
||||
self.completed.insert(hdr.frame_index);
|
||||
let frame = win.frames.remove(&hdr.frame_index).unwrap();
|
||||
win.completed.insert(hdr.frame_index);
|
||||
// Reserve based on the bytes we actually hold, not the (already-bounded but
|
||||
// still caller-supplied) frame_bytes, so a small frame can't over-reserve.
|
||||
let actual: usize = frame.block_data.values().map(|b| b.len()).sum();
|
||||
@@ -522,11 +609,30 @@ impl Reassembler {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Drop all in-flight state — every partially-assembled frame and the completed/abandoned
|
||||
/// index memory, in both index spaces — as if the session just started. Used by the client's
|
||||
/// backlog flush ([`Session::flush_backlog`](crate::session::Session::flush_backlog)): after
|
||||
/// the socket backlog is discarded wholesale, the partial frames here can never complete
|
||||
/// (their remaining shards were just thrown away) and the window anchors (`newest_frame`)
|
||||
/// point into the discarded past.
|
||||
pub fn reset(&mut self) {
|
||||
self.video = ReassemblyWindow::default();
|
||||
self.probe = ReassemblyWindow::default();
|
||||
}
|
||||
}
|
||||
|
||||
impl ReassemblyWindow {
|
||||
/// Track the newest frame, declare incomplete frames that fell out of the loss window
|
||||
/// ([`LOSS_WINDOW_NS`] behind the newest pts, or [`HARD_LOSS_WINDOW`] indices) lost — counting
|
||||
/// them dropped, which is what drives the client's recovery-keyframe request — and prune the
|
||||
/// completed-index memory to [`REORDER_WINDOW`].
|
||||
fn advance_window(&mut self, frame_index: u32, pts_ns: u64, stats: &StatsCounters) {
|
||||
/// ([`LOSS_WINDOW_NS`] behind the newest pts, or [`HARD_LOSS_WINDOW`] indices) lost — for the
|
||||
/// video window (`count_drops`) counting them dropped, which is what drives the client's
|
||||
/// recovery-keyframe request — and prune the completed-index memory to [`REORDER_WINDOW`].
|
||||
fn advance_window(
|
||||
&mut self,
|
||||
frame_index: u32,
|
||||
pts_ns: u64,
|
||||
stats: &StatsCounters,
|
||||
count_drops: bool,
|
||||
) {
|
||||
let (newest, newest_pts) = match self.newest_frame {
|
||||
// `frame_index` is newer iff it's within the forward half of the index space.
|
||||
Some((n, p)) if frame_index.wrapping_sub(n) > u32::MAX / 2 => (n, p),
|
||||
@@ -548,29 +654,17 @@ impl Reassembler {
|
||||
keep
|
||||
});
|
||||
let pruned = before - self.frames.len();
|
||||
if pruned > 0 {
|
||||
if pruned > 0 && count_drops {
|
||||
StatsCounters::add(&stats.frames_dropped, pruned as u64);
|
||||
}
|
||||
self.completed
|
||||
.retain(|&idx| newest.wrapping_sub(idx) <= REORDER_WINDOW);
|
||||
}
|
||||
|
||||
/// Drop all in-flight state — every partially-assembled frame and the completed/abandoned
|
||||
/// index memory — as if the session just started. Used by the client's backlog flush
|
||||
/// ([`Session::flush_backlog`](crate::session::Session::flush_backlog)): after the socket
|
||||
/// backlog is discarded wholesale, the partial frames here can never complete (their remaining
|
||||
/// shards were just thrown away) and the window anchor (`newest_frame`) points into the
|
||||
/// discarded past.
|
||||
pub fn reset(&mut self) {
|
||||
self.frames.clear();
|
||||
self.completed.clear();
|
||||
self.newest_frame = None;
|
||||
}
|
||||
|
||||
/// True if this packet's frame lies outside the loss window (behind the newest frame by more
|
||||
/// than [`LOSS_WINDOW_NS`] of capture time or [`HARD_LOSS_WINDOW`] indices) — its shards
|
||||
/// arrive too late to be useful, and accepting one would only create a frame buffer the next
|
||||
/// [`advance_window`] immediately declares lost.
|
||||
/// [`advance_window`](Self::advance_window) immediately declares lost.
|
||||
fn is_stale(&self, frame_index: u32, pts_ns: u64) -> bool {
|
||||
match self.newest_frame {
|
||||
Some((n, newest_pts)) => {
|
||||
@@ -750,6 +844,119 @@ mod tests {
|
||||
assert_eq!(stats.snapshot().frames_dropped, 1, "no double-count");
|
||||
}
|
||||
|
||||
/// The explicit-index path stamps the caller's `frame_index` and leaves the internal video
|
||||
/// counter untouched — the punktfunk/1 encode loop owns the numbering, and mixing must not
|
||||
/// perturb the legacy self-numbering path (tests/ABI/synthetic).
|
||||
#[test]
|
||||
fn explicit_frame_index_is_stamped_and_internal_counter_untouched() {
|
||||
use crate::config::{FecConfig, FecScheme, ProtocolPhase, Role};
|
||||
let cfg = Config {
|
||||
role: Role::Host,
|
||||
phase: ProtocolPhase::P2Punktfunk,
|
||||
fec: FecConfig {
|
||||
scheme: FecScheme::Gf16,
|
||||
fec_percent: 0,
|
||||
max_data_per_block: 8,
|
||||
},
|
||||
shard_payload: 16,
|
||||
max_frame_bytes: 4096,
|
||||
encrypt: false,
|
||||
key: [0u8; 16],
|
||||
salt: [0u8; 4],
|
||||
loopback_drop_period: 0,
|
||||
};
|
||||
let coder = coder_for(FecScheme::Gf16);
|
||||
let mut pk = Packetizer::new(&cfg);
|
||||
let mut seen = Vec::new();
|
||||
pk.packetize_each(&[1u8; 16], 0, 0, Some(4242), coder.as_ref(), |hdr, _| {
|
||||
seen.push(hdr.frame_index);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(seen, vec![4242]);
|
||||
// The legacy wrapper still numbers from the untouched internal counter.
|
||||
let pkts = pk.packetize(&[1u8; 16], 0, 0, coder.as_ref()).unwrap();
|
||||
let hdr = PacketHeader::read_from_bytes(&pkts[0][..HEADER_LEN]).unwrap();
|
||||
assert_eq!(hdr.frame_index, 0);
|
||||
// The probe space is a third, independent counter.
|
||||
assert_eq!(pk.alloc_probe_index(), 0);
|
||||
assert_eq!(pk.alloc_probe_index(), 1);
|
||||
}
|
||||
|
||||
/// Probe filler (FLAG_PROBE in user_flags) reassembles in its OWN window: a probe frame whose
|
||||
/// index is far behind the video stream's completes anyway (an old client's single window
|
||||
/// would drop it as stale), and video frames complete undisturbed around it.
|
||||
#[test]
|
||||
fn probe_frames_reassemble_in_their_own_window() {
|
||||
let mut r = Reassembler::new(limits());
|
||||
let coder = coder_for(FecScheme::Gf8);
|
||||
let stats = StatsCounters::default();
|
||||
|
||||
// Establish a video stream far into its index space.
|
||||
let mut v = base_header();
|
||||
v.frame_index = 100_000;
|
||||
v.pts_ns = 1_000_000_000;
|
||||
assert!(r
|
||||
.push(&packet(v), coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.is_some());
|
||||
|
||||
// A probe frame at index 0 — 100k "behind" the video window — must still complete.
|
||||
let mut p = base_header();
|
||||
p.frame_index = 0;
|
||||
p.pts_ns = 1_000_000_100;
|
||||
p.user_flags = FLAG_PROBE as u32;
|
||||
let got = r.push(&packet(p), coder.as_ref(), &stats).unwrap();
|
||||
assert!(got.is_some(), "probe frame must complete in its own window");
|
||||
assert_eq!(got.unwrap().flags & FLAG_PROBE as u32, FLAG_PROBE as u32);
|
||||
|
||||
// The probe burst must not have advanced the VIDEO window: the next video frame is
|
||||
// contiguous and completes, with nothing counted dropped.
|
||||
let mut v2 = base_header();
|
||||
v2.frame_index = 100_001;
|
||||
v2.pts_ns = 1_000_000_200;
|
||||
assert!(r
|
||||
.push(&packet(v2), coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.is_some());
|
||||
assert_eq!(stats.snapshot().frames_dropped, 0);
|
||||
}
|
||||
|
||||
/// An incomplete probe frame aging out of the probe window is NOT a video `frames_dropped`
|
||||
/// (which would fire the client's loss recovery) — probe loss is measured bytes-wise by the
|
||||
/// probe accumulator.
|
||||
#[test]
|
||||
fn aged_out_probe_frames_do_not_count_as_dropped() {
|
||||
let mut r = Reassembler::new(limits());
|
||||
let coder = coder_for(FecScheme::Gf8);
|
||||
let stats = StatsCounters::default();
|
||||
|
||||
// Probe frame 0: one of two shards — incomplete.
|
||||
let mut p = base_header();
|
||||
p.user_flags = FLAG_PROBE as u32;
|
||||
p.data_shards = 2;
|
||||
p.frame_bytes = 32;
|
||||
assert!(r
|
||||
.push(&packet(p), coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
|
||||
// A much newer probe frame ages it out of the probe window.
|
||||
let mut p2 = base_header();
|
||||
p2.user_flags = FLAG_PROBE as u32;
|
||||
p2.frame_index = 1;
|
||||
p2.pts_ns = LOSS_WINDOW_NS + 1;
|
||||
assert!(r
|
||||
.push(&packet(p2), coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.is_some());
|
||||
assert_eq!(
|
||||
stats.snapshot().frames_dropped,
|
||||
0,
|
||||
"probe-window drops must not fire video loss recovery"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_shard_bytes_and_oversized_frame() {
|
||||
let coder = coder_for(FecScheme::Gf8);
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
//! 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`) and the connection reject code `0x42`.
|
||||
pub const CLIP_CANCELLED_CODE: u32 = 0x60;
|
||||
|
||||
/// 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)
|
||||
}
|
||||
@@ -49,6 +49,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 [`msgs`], 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
|
||||
|
||||
@@ -107,6 +107,18 @@ pub const VIDEO_CAP_444: u8 = 0x04;
|
||||
/// host ignores it and simply never sends any); a client that doesn't set it keeps the combined
|
||||
/// stage. Purely observability — never changes what the host encodes.
|
||||
pub const VIDEO_CAP_HOST_TIMING: u8 = 0x08;
|
||||
/// [`Hello::video_caps`] bit: the client's reassembler keeps **speed-test probe filler in its own
|
||||
/// frame-index space** (a second reassembly window keyed on the [`crate::packet::FLAG_PROBE`]
|
||||
/// user-flag), so probe bursts no longer consume video `frame_index`es. Without this, a mid-session
|
||||
/// speed test burns thousands of video indexes that are invisible to every client-side gap detector
|
||||
/// (probe frames are filtered before the pump sees them) — the first real AU afterwards reads as a
|
||||
/// phantom multi-thousand-frame loss (spurious freeze + a nonsense RFI). It also lets the host's
|
||||
/// encode loop own the video numbering outright (the wire-index contract
|
||||
/// [`crate::packet::Packetizer::packetize_each`] documents), which reference-frame invalidation
|
||||
/// depends on. The host runs mid-session probe bursts ONLY against clients that set this bit — an
|
||||
/// older client gets a declined (zeroed) [`ProbeResult`] instead of a measurement its single-window
|
||||
/// reassembler would silently drop as stale.
|
||||
pub const VIDEO_CAP_PROBE_SEQ: u8 = 0x10;
|
||||
|
||||
/// QUIC application error code a punktfunk/1 client closes the control connection with on a
|
||||
/// **deliberate quit** (a user "stop", not a network drop). The host reads it off the connection's
|
||||
@@ -130,6 +142,14 @@ pub const APP_EXITED_CLOSE_CODE: u32 = 0x52;
|
||||
/// 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.
|
||||
@@ -355,6 +375,24 @@ pub struct Reconfigured {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct RequestKeyframe;
|
||||
|
||||
/// `client → host`: reference-frame-invalidation recovery — the loss-aware sibling of
|
||||
/// [`RequestKeyframe`]. The client detected a `frame_index` gap and reports the range `[first_frame,
|
||||
/// last_frame]` of access units it can no longer trust (from the first missing index through the
|
||||
/// newest received). Instead of a full IDR (a 20-40× spike that deepens the loss it recovers), a host
|
||||
/// whose encoder supports RFI re-references a known-good picture *before* `first_frame` — an AMD LTR
|
||||
/// force-reference or an NVENC `nvEncInvalidateRefFrames` — emitting a single clean P-frame it tags
|
||||
/// [`crate::packet::USER_FLAG_RECOVERY_ANCHOR`] so the client lifts its freeze on it. A host that
|
||||
/// can't RFI (no valid reference / libavcodec backend) forces an IDR instead, exactly as for a bare
|
||||
/// [`RequestKeyframe`]; a host that predates this ignores the unknown message and the client's
|
||||
/// keyframe backstop still recovers. Fire-and-forget — the recovered frame is the only ack.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct RfiRequest {
|
||||
/// First access-unit `frame_index` the client can no longer trust (the gap start).
|
||||
pub first_frame: u32,
|
||||
/// Newest received `frame_index` at the time of the report (the invalidation range end).
|
||||
pub last_frame: u32,
|
||||
}
|
||||
|
||||
/// `client → host`, periodic: the client's observed data-plane loss, so the host can size FEC to
|
||||
/// the link instead of a flat percentage (adaptive FEC). `loss_ppm` is parts-per-million of shards
|
||||
/// that arrived missing-but-recovered (plus a bump when frames went unrecoverable) over the report
|
||||
@@ -467,6 +505,8 @@ pub const MSG_LOSS_REPORT: u8 = 0x04;
|
||||
pub const MSG_SET_BITRATE: u8 = 0x05;
|
||||
/// Type byte of [`BitrateChanged`].
|
||||
pub const MSG_BITRATE_CHANGED: u8 = 0x06;
|
||||
/// Type byte of [`RfiRequest`].
|
||||
pub const MSG_RFI_REQUEST: u8 = 0x07;
|
||||
/// Type byte of [`ProbeRequest`].
|
||||
pub const MSG_PROBE_REQUEST: u8 = 0x20;
|
||||
/// Type byte of [`ProbeResult`].
|
||||
@@ -476,6 +516,147 @@ pub const MSG_CLOCK_PROBE: u8 = 0x30;
|
||||
/// Type byte of [`ClockEcho`].
|
||||
pub const MSG_CLOCK_ECHO: u8 = 0x31;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 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,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Pairing ceremony (typed control messages): instead of a session Hello, a client may open
|
||||
// the control stream with PairRequest. The host shows a short PIN out-of-band (log/UI); the
|
||||
@@ -1032,6 +1213,28 @@ impl RequestKeyframe {
|
||||
}
|
||||
}
|
||||
|
||||
impl RfiRequest {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] first_frame[5..9] last_frame[9..13]
|
||||
let mut b = Vec::with_capacity(13);
|
||||
b.extend_from_slice(CTL_MAGIC);
|
||||
b.push(MSG_RFI_REQUEST);
|
||||
b.extend_from_slice(&self.first_frame.to_le_bytes());
|
||||
b.extend_from_slice(&self.last_frame.to_le_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
pub fn decode(b: &[u8]) -> Result<RfiRequest> {
|
||||
if b.len() != 13 || &b[0..4] != CTL_MAGIC || b[4] != MSG_RFI_REQUEST {
|
||||
return Err(PunktfunkError::InvalidArg("bad RfiRequest"));
|
||||
}
|
||||
Ok(RfiRequest {
|
||||
first_frame: u32::from_le_bytes(b[5..9].try_into().unwrap()),
|
||||
last_frame: u32::from_le_bytes(b[9..13].try_into().unwrap()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl LossReport {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] loss_ppm[5..9]
|
||||
@@ -1213,6 +1416,174 @@ impl ClockEcho {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame a message for the control stream: `u16 LE length || payload`.
|
||||
pub fn frame(payload: &[u8]) -> Vec<u8> {
|
||||
let mut b = Vec::with_capacity(2 + payload.len());
|
||||
|
||||
@@ -633,6 +633,35 @@ fn request_keyframe_roundtrip() {
|
||||
assert!(RequestKeyframe::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfi_request_roundtrip() {
|
||||
for (first_frame, last_frame) in [(0u32, 0u32), (40, 47), (5, 5), (1_000_000, u32::MAX)] {
|
||||
let r = RfiRequest {
|
||||
first_frame,
|
||||
last_frame,
|
||||
};
|
||||
assert_eq!(RfiRequest::decode(&r.encode()).unwrap(), r);
|
||||
}
|
||||
// Disjoint from the bare keyframe request (its loss-unaware sibling) and others: type byte + length.
|
||||
assert!(RfiRequest::decode(&RequestKeyframe.encode()).is_err());
|
||||
assert!(RequestKeyframe::decode(
|
||||
&RfiRequest {
|
||||
first_frame: 1,
|
||||
last_frame: 2
|
||||
}
|
||||
.encode()
|
||||
)
|
||||
.is_err());
|
||||
// Exact length — no trailing bytes.
|
||||
let bytes = RfiRequest {
|
||||
first_frame: 3,
|
||||
last_frame: 9,
|
||||
}
|
||||
.encode();
|
||||
assert!(RfiRequest::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
|
||||
assert!(RfiRequest::decode(&bytes[..bytes.len() - 1]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loss_report_roundtrip() {
|
||||
for loss_ppm in [0u32, 1, 12_345, 50_000, 1_000_000] {
|
||||
@@ -1130,3 +1159,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +161,31 @@ impl Session {
|
||||
data: &[u8],
|
||||
pts_ns: u64,
|
||||
user_flags: u32,
|
||||
) -> Result<Vec<Vec<u8>>> {
|
||||
self.seal_frame_inner(data, pts_ns, user_flags, None)
|
||||
}
|
||||
|
||||
/// [`seal_frame`](Self::seal_frame) with the caller's **explicit** `frame_index` instead of the
|
||||
/// packetizer's internal counter. The punktfunk/1 encode loop owns the video numbering (one
|
||||
/// session-lifetime counter, stamped per AU) so the encoder's reference-frame-invalidation
|
||||
/// bookkeeping stays 1:1 with the wire across encoder rebuilds/resets — see
|
||||
/// [`Packetizer::packetize_each`]. A session must use ONE numbering style per index space.
|
||||
pub fn seal_frame_at(
|
||||
&mut self,
|
||||
data: &[u8],
|
||||
pts_ns: u64,
|
||||
user_flags: u32,
|
||||
frame_index: u32,
|
||||
) -> Result<Vec<Vec<u8>>> {
|
||||
self.seal_frame_inner(data, pts_ns, user_flags, Some(frame_index))
|
||||
}
|
||||
|
||||
fn seal_frame_inner(
|
||||
&mut self,
|
||||
data: &[u8],
|
||||
pts_ns: u64,
|
||||
user_flags: u32,
|
||||
frame_index: Option<u32>,
|
||||
) -> Result<Vec<Vec<u8>>> {
|
||||
if self.config.role != Role::Host {
|
||||
return Err(PunktfunkError::InvalidArg(
|
||||
@@ -184,35 +209,36 @@ impl Session {
|
||||
} = self;
|
||||
let mut wires = std::mem::take(wire_pool);
|
||||
let mut used = 0usize;
|
||||
let result = packetizer.packetize_each(data, pts_ns, user_flags, coder.as_ref(), {
|
||||
let wires = &mut wires;
|
||||
let used = &mut used;
|
||||
move |hdr, body| {
|
||||
if *used == wires.len() {
|
||||
wires.push(Vec::new());
|
||||
}
|
||||
let wire = &mut wires[*used];
|
||||
*used += 1;
|
||||
let seq = *next_seq;
|
||||
*next_seq = next_seq.wrapping_add(1);
|
||||
wire.clear();
|
||||
match crypto {
|
||||
Some(c) => {
|
||||
// seq(8) ‖ header(40) ‖ shard ‖ tag scratch(16), sealed over [8..].
|
||||
wire.extend_from_slice(&seq.to_be_bytes());
|
||||
wire.extend_from_slice(hdr.as_bytes());
|
||||
wire.extend_from_slice(body);
|
||||
wire.resize(wire.len() + crate::crypto::TAG_LEN, 0);
|
||||
c.seal_in_place(seq, &mut wire[8..])?;
|
||||
let result =
|
||||
packetizer.packetize_each(data, pts_ns, user_flags, frame_index, coder.as_ref(), {
|
||||
let wires = &mut wires;
|
||||
let used = &mut used;
|
||||
move |hdr, body| {
|
||||
if *used == wires.len() {
|
||||
wires.push(Vec::new());
|
||||
}
|
||||
None => {
|
||||
wire.extend_from_slice(hdr.as_bytes());
|
||||
wire.extend_from_slice(body);
|
||||
let wire = &mut wires[*used];
|
||||
*used += 1;
|
||||
let seq = *next_seq;
|
||||
*next_seq = next_seq.wrapping_add(1);
|
||||
wire.clear();
|
||||
match crypto {
|
||||
Some(c) => {
|
||||
// seq(8) ‖ header(40) ‖ shard ‖ tag scratch(16), sealed over [8..].
|
||||
wire.extend_from_slice(&seq.to_be_bytes());
|
||||
wire.extend_from_slice(hdr.as_bytes());
|
||||
wire.extend_from_slice(body);
|
||||
wire.resize(wire.len() + crate::crypto::TAG_LEN, 0);
|
||||
c.seal_in_place(seq, &mut wire[8..])?;
|
||||
}
|
||||
None => {
|
||||
wire.extend_from_slice(hdr.as_bytes());
|
||||
wire.extend_from_slice(body);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
});
|
||||
result?;
|
||||
// A smaller frame uses fewer buffers than the pool holds: drop the unused tail, same
|
||||
// as the previous `resize_with(packets.len(), ..)` did.
|
||||
@@ -258,6 +284,23 @@ impl Session {
|
||||
r.map(|_| ())
|
||||
}
|
||||
|
||||
/// Host: seal + send one **speed-test probe filler** access unit in the probe index space
|
||||
/// (its own frame counter + the [`crate::packet::FLAG_PROBE`] user-flag) so a burst never
|
||||
/// consumes video `frame_index`es — the client reassembles probe frames in a separate window
|
||||
/// and its gap detectors never see them. Only call this against a client that advertised
|
||||
/// [`crate::quic::VIDEO_CAP_PROBE_SEQ`]; an older client's single-window reassembler would
|
||||
/// drop probe-space indexes as stale against the video stream.
|
||||
pub fn submit_probe_frame(&mut self, data: &[u8], pts_ns: u64) -> Result<()> {
|
||||
let idx = self.packetizer.alloc_probe_index();
|
||||
let wires =
|
||||
self.seal_frame_inner(data, pts_ns, crate::packet::FLAG_PROBE as u32, Some(idx))?;
|
||||
let refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect();
|
||||
let r = self.send_sealed(&refs);
|
||||
drop(refs);
|
||||
self.reclaim_wires(wires);
|
||||
r.map(|_| ())
|
||||
}
|
||||
|
||||
/// Host: live-adjust the FEC recovery percentage (adaptive FEC). Affects the next
|
||||
/// [`submit_frame`](Self::submit_frame)/[`seal_frame`](Self::seal_frame); the receiver needs no
|
||||
/// notification (each packet's header carries its block's data/recovery shard counts).
|
||||
|
||||
@@ -88,12 +88,22 @@ openh264 = "0.9"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# `screencast` gates the ScreenCast portal module; `remote_desktop` adds the RemoteDesktop
|
||||
# portal we use for libei input on KWin/GNOME; `tokio` is the default runtime.
|
||||
# `open_pipe_wire_remote` is unconditional, so ashpd's own `pipewire` feature is not
|
||||
# needed — we drive PipeWire with the `pipewire` crate below.
|
||||
# portal we use for libei input on KWin/GNOME; `tokio` is the default runtime. `open_pipe_wire_remote`
|
||||
# is unconditional, so ashpd's own `pipewire` feature is not needed — we drive PipeWire with the
|
||||
# `pipewire` crate below. (The GNOME shared-clipboard backend uses Mutter's *direct*
|
||||
# `org.gnome.Mutter.RemoteDesktop.Session` clipboard via raw `ashpd::zbus` — NOT the xdg
|
||||
# `org.freedesktop.portal.Clipboard`, which needs interactive approval — so no `clipboard` feature.)
|
||||
ashpd = { version = "0.13", features = ["screencast", "remote_desktop"] }
|
||||
ffmpeg-next = "8"
|
||||
libc = "0.2"
|
||||
# Direct-SDK NVENC on Linux (design/linux-direct-nvenc.md): the RAW `sys::nvEncodeAPI` types only —
|
||||
# the entry points are resolved at RUNTIME from the driver's `libnvidia-encode.so.1`
|
||||
# (encode/linux/nvenc_cuda.rs), NOT link-imported, so the same binary starts fine on AMD/Intel
|
||||
# Linux boxes (no NVIDIA driver) and falls through to VAAPI/software. `ci-check` = vendored
|
||||
# bindings + cudarc `dynamic-loading` (no CUDA toolkit/headers at build); we never call the crate's
|
||||
# cudarc — CUDA is driven through the existing `zerocopy::cuda` dlopen table. Same crate + feature
|
||||
# as the Windows target dep (Cargo.toml, windows target section) so the `sys` structs never drift.
|
||||
nvidia-video-codec-sdk = { version = "0.4", features = ["ci-check"], optional = true }
|
||||
# Must match the pipewire crate ashpd 0.13 links (libspa/pipewire-sys `links` key is
|
||||
# unique per build), i.e. 0.9 — NOT the 0.10 the setup doc mentions.
|
||||
pipewire = "0.9"
|
||||
@@ -107,7 +117,7 @@ wayland-protocols-wlr = { version = "0.3", features = ["client"] }
|
||||
wayland-protocols-misc = { version = "0.3", features = ["client"] }
|
||||
# `xdg-output` (zxdg_output_v1): the per-output *logical* geometry (post-scale size + global
|
||||
# position), used by the KWin fake_input backend to map absolute coordinates under display scaling.
|
||||
wayland-protocols = { version = "0.32", features = ["client"] }
|
||||
wayland-protocols = { version = "0.32", features = ["client", "staging"] }
|
||||
# Codegen for KDE's `zkde_screencast_unstable_v1` (vendored in `protocols/`): create a KWin
|
||||
# virtual output sized to the client's resolution and get its PipeWire node (KRdp's path).
|
||||
# `wayland-backend` is referenced by the generated interface tables.
|
||||
@@ -192,6 +202,11 @@ windows = { version = "0.62", features = [
|
||||
# See capture/windows/dxgi.rs `install_gpu_pref_hook`. No trampoline (we fully replace the fn) → no detour
|
||||
# crate / no C length-disassembler dep; a 12-byte absolute-jmp prologue patch suffices.
|
||||
"Win32_System_Memory",
|
||||
# Shared-clipboard host backend (src/clipboard/windows.rs): the Win32 clipboard API
|
||||
# (Open/Get/SetClipboardData, AddClipboardFormatListener, delayed rendering) plus the
|
||||
# CLIPBOARD_FORMAT / CF_UNICODETEXT newtype from Ole (design/clipboard-and-file-transfer.md §3).
|
||||
"Win32_System_DataExchange",
|
||||
"Win32_System_Ole",
|
||||
# Per-monitor-v2 DPI awareness — IDXGIOutput5::DuplicateOutput1 (the modern capture path Apollo
|
||||
# uses; FP16/format-list, robust to overlay/format churn) requires the process to be DPI-aware.
|
||||
"Win32_UI_HiDpi",
|
||||
|
||||
@@ -468,3 +468,6 @@ pub mod dxgi;
|
||||
pub mod idd_push;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "capture/windows/synthetic_nv12.rs"]
|
||||
pub mod synthetic_nv12;
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
//! A headless synthetic **NV12 D3D11** capture source for exercising the GPU encoders on Windows
|
||||
//! without a real capture session.
|
||||
//!
|
||||
//! The native AMF path (and the D3D11 zero-copy NVENC/QSV paths) require an NV12 texture that lives
|
||||
//! on the GPU — the CPU-Bgrx [`SyntheticCapturer`](crate::capture::SyntheticCapturer) can't provide
|
||||
//! one, and DXGI Desktop Duplication can't create one under an ssh session-0 (E_ACCESSDENIED). This
|
||||
//! source builds an NV12 texture on the selected render adapter and fills it with a **moving** luma
|
||||
//! ramp each frame, so the encoder sees genuine motion (P-frame residuals + the intra-refresh wave
|
||||
//! under content change) — exactly what an intra-refresh recovery validation needs. Driven by
|
||||
//! `spike --source synthetic-nv12`.
|
||||
|
||||
use crate::capture::dxgi::{make_device, D3d11Frame};
|
||||
use crate::capture::{CapturedFrame, Capturer, FramePayload, PixelFormat};
|
||||
use anyhow::{Context, Result};
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D, D3D11_BIND_SHADER_RESOURCE,
|
||||
D3D11_CPU_ACCESS_WRITE, D3D11_MAPPED_SUBRESOURCE, D3D11_MAP_WRITE, D3D11_TEXTURE2D_DESC,
|
||||
D3D11_USAGE, D3D11_USAGE_DEFAULT, D3D11_USAGE_STAGING,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_NV12, DXGI_SAMPLE_DESC};
|
||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
|
||||
|
||||
/// Synthetic NV12 frames on the GPU. Owns its own D3D11 device + immediate context and two NV12
|
||||
/// textures: a CPU-writable STAGING scratch it fills each frame, and a DEFAULT texture it copies
|
||||
/// into and hands to the encoder. The encoder copies out of the DEFAULT texture synchronously
|
||||
/// (spike drives capture→submit→poll on one thread), so reusing one DEFAULT texture is sound.
|
||||
pub struct SyntheticNv12Capturer {
|
||||
device: ID3D11Device,
|
||||
context: ID3D11DeviceContext,
|
||||
default_tex: ID3D11Texture2D,
|
||||
staging: ID3D11Texture2D,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
frame_idx: u64,
|
||||
}
|
||||
|
||||
// SAFETY: mirrors `D3d11Frame`'s reasoning — the device is created free-threaded (`make_device`
|
||||
// passes no `SINGLETHREADED` flag) and D3D11 uses interlocked COM refcounting, so moving the whole
|
||||
// capturer (device + immediate context + textures) to its owning thread and using it only there is
|
||||
// sound. The value is moved, never aliased (no `Sync`), so the single-threaded immediate context is
|
||||
// never touched concurrently.
|
||||
unsafe impl Send for SyntheticNv12Capturer {}
|
||||
|
||||
impl SyntheticNv12Capturer {
|
||||
pub fn new(width: u32, height: u32, fps: u32) -> Result<Self> {
|
||||
// NV12 is 4:2:0 — both dimensions must be even (the chroma plane is width/2 × height/2).
|
||||
let width = (width & !1).max(2);
|
||||
let height = (height & !1).max(2);
|
||||
// SAFETY: a self-contained builder owning every handle it creates; each COM call is checked
|
||||
// and the returned owners drop with their wrappers.
|
||||
unsafe {
|
||||
let adapter =
|
||||
resolve_render_adapter().context("resolve render adapter for NV12 source")?;
|
||||
let (device, context) = make_device(&adapter).context("create D3D11 device")?;
|
||||
let default_tex = create_nv12(
|
||||
&device,
|
||||
width,
|
||||
height,
|
||||
D3D11_USAGE_DEFAULT,
|
||||
0,
|
||||
D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
||||
)
|
||||
.context("create NV12 default texture")?;
|
||||
let staging = create_nv12(
|
||||
&device,
|
||||
width,
|
||||
height,
|
||||
D3D11_USAGE_STAGING,
|
||||
D3D11_CPU_ACCESS_WRITE.0 as u32,
|
||||
0,
|
||||
)
|
||||
.context("create NV12 staging texture")?;
|
||||
Ok(SyntheticNv12Capturer {
|
||||
device,
|
||||
context,
|
||||
default_tex,
|
||||
staging,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
frame_idx: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Capturer for SyntheticNv12Capturer {
|
||||
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
||||
let pts_ns = self.frame_idx * 1_000_000_000 / self.fps.max(1) as u64;
|
||||
// SAFETY: Map/Unmap/CopyResource on this capturer's own single-threaded immediate context;
|
||||
// all writes stay within the mapped NV12 surface (Y: H rows of RowPitch; UV: H/2 rows of
|
||||
// RowPitch beginning at RowPitch*H — the standard NV12 plane layout).
|
||||
unsafe {
|
||||
let mut map = D3D11_MAPPED_SUBRESOURCE::default();
|
||||
self.context
|
||||
.Map(&self.staging, 0, D3D11_MAP_WRITE, 0, Some(&mut map))
|
||||
.context("Map(NV12 staging)")?;
|
||||
let pitch = map.RowPitch as usize;
|
||||
let base = map.pData as *mut u8;
|
||||
// A diagonal luma ramp that shifts 4 codes/frame — strong, deterministic motion.
|
||||
let shift = (self.frame_idx as u32).wrapping_mul(4);
|
||||
for y in 0..self.height {
|
||||
let row = base.add(y as usize * pitch);
|
||||
for x in 0..self.width {
|
||||
*row.add(x as usize) = x.wrapping_add(y).wrapping_add(shift) as u8;
|
||||
}
|
||||
}
|
||||
// UV plane (neutral gray = 128) at offset RowPitch*H: H/2 rows, `width` bytes each
|
||||
// (width/2 interleaved Cb,Cr pairs).
|
||||
let uv = base.add(pitch * self.height as usize);
|
||||
for r in 0..(self.height / 2) {
|
||||
let row = uv.add(r as usize * pitch);
|
||||
for c in 0..self.width {
|
||||
*row.add(c as usize) = 128;
|
||||
}
|
||||
}
|
||||
self.context.Unmap(&self.staging, 0);
|
||||
self.context.CopyResource(&self.default_tex, &self.staging);
|
||||
}
|
||||
self.frame_idx += 1;
|
||||
Ok(CapturedFrame {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
pts_ns,
|
||||
format: PixelFormat::Nv12,
|
||||
payload: FramePayload::D3d11(D3d11Frame {
|
||||
texture: self.default_tex.clone(),
|
||||
device: self.device.clone(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the same render adapter the encoder will pick (`PUNKTFUNK_RENDER_ADAPTER` / preference /
|
||||
/// max-VRAM LUID), falling back to adapter 0.
|
||||
///
|
||||
/// # Safety
|
||||
/// Calls DXGI factory/adapter enumeration; returns owned COM objects or an error.
|
||||
unsafe fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
|
||||
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?;
|
||||
if let Some(luid) = crate::win_adapter::resolve_render_adapter_luid() {
|
||||
if let Ok(a) = factory.EnumAdapterByLuid::<IDXGIAdapter1>(luid) {
|
||||
return Ok(a);
|
||||
}
|
||||
}
|
||||
factory.EnumAdapters1(0).context("EnumAdapters1(0)")
|
||||
}
|
||||
|
||||
/// Create an NV12 `Texture2D` with the given usage/CPU-access/bind flags.
|
||||
///
|
||||
/// # Safety
|
||||
/// `device` must be a live D3D11 device; the returned texture is owned by the caller.
|
||||
unsafe fn create_nv12(
|
||||
device: &ID3D11Device,
|
||||
width: u32,
|
||||
height: u32,
|
||||
usage: D3D11_USAGE,
|
||||
cpu_access: u32,
|
||||
bind: u32,
|
||||
) -> Result<ID3D11Texture2D> {
|
||||
let desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: width,
|
||||
Height: height,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_NV12,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: usage,
|
||||
BindFlags: bind,
|
||||
CPUAccessFlags: cpu_access,
|
||||
..Default::default()
|
||||
};
|
||||
let mut tex: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&desc, None, Some(&mut tex))
|
||||
.context("CreateTexture2D(NV12)")?;
|
||||
tex.context("CreateTexture2D returned a null NV12 texture")
|
||||
}
|
||||
@@ -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 `punktfunk1` 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::punktfunk1::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");
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,16 @@ pub struct EncodedFrame {
|
||||
pub pts_ns: u64,
|
||||
/// True for IDR/keyframes (sets the SOF/keyframe wire flags).
|
||||
pub keyframe: bool,
|
||||
/// True when this AU is a **reference-frame-invalidation recovery frame** — a clean P-frame the
|
||||
/// encoder coded against a known-good reference in response to
|
||||
/// [`invalidate_ref_frames`](Encoder::invalidate_ref_frames). The pump tags it
|
||||
/// [`punktfunk_core::packet::USER_FLAG_RECOVERY_ANCHOR`] so the client lifts its post-loss
|
||||
/// freeze on it without an IDR. Set by BOTH RFI backends: native AMF (the LTR force-reference
|
||||
/// frame) and Windows direct-NVENC (the first frame encoded after `nvEncInvalidateRefFrames` —
|
||||
/// the invalidation applies at the next `encode_picture`, so that AU is by construction the
|
||||
/// clean re-anchor). Without it the client's freeze can only lift on an IDR — which the host
|
||||
/// suppresses after a successful RFI (the cooldown), a ~1 s frozen stall per loss event.
|
||||
pub recovery_anchor: bool,
|
||||
}
|
||||
|
||||
/// Codec selection negotiated with the client.
|
||||
@@ -194,8 +204,9 @@ pub struct EncoderCaps {
|
||||
/// The encoder can perform real reference-frame invalidation — i.e.
|
||||
/// [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) can return `true`. When `false`
|
||||
/// the caller skips that always-`false` call and forces a keyframe directly on loss recovery.
|
||||
/// Only the Windows direct-NVENC path implements RFI; libavcodec (Linux NVENC), VAAPI and
|
||||
/// AMF/QSV always keyframe.
|
||||
/// Two backends implement RFI: Windows direct-NVENC (`nvEncInvalidateRefFrames`) and native
|
||||
/// AMF (user-LTR force-reference, when the driver accepted the LTR slots at open). The
|
||||
/// libavcodec paths (Linux NVENC, VAAPI, QSV) can't express it and always keyframe.
|
||||
pub supports_rfi: bool,
|
||||
/// The encoder emits in-band HDR mastering/CLL SEI from [`set_hdr_meta`](Encoder::set_hdr_meta).
|
||||
/// When `false`, `set_hdr_meta` is a no-op and no in-band grade reaches the client. Only the
|
||||
@@ -208,17 +219,49 @@ pub struct EncoderCaps {
|
||||
/// the encoder's real chroma disagrees with what was negotiated (the in-band SPS is authoritative
|
||||
/// for the decoder either way).
|
||||
pub chroma_444: bool,
|
||||
/// The encoder runs a periodic **intra-refresh wave** (a moving band of intra blocks +
|
||||
/// recovery-point SEI, no periodic IDR): FEC-unrecoverable loss self-heals within one wave, so
|
||||
/// the session glue rate-limits client keyframe requests instead of answering each with a full
|
||||
/// IDR (the 20-40× frame-size spike that cascades under loss). Linux NVENC sets it when
|
||||
/// `PUNKTFUNK_INTRA_REFRESH` opened the encoder in that mode; VAAPI/software never do.
|
||||
/// The encoder runs a periodic **intra-refresh wave** — a moving band of intra blocks that
|
||||
/// re-codes the whole picture over ~0.5 s, no periodic IDR. FEC-unrecoverable loss self-heals as
|
||||
/// the band sweeps, so the session glue rate-limits client keyframe requests instead of answering
|
||||
/// each with a full IDR (the 20-40× frame-size spike that cascades under loss). Linux NVENC / AMF
|
||||
/// set it when `PUNKTFUNK_INTRA_REFRESH` opened the encoder in that mode; VAAPI/QSV/software never
|
||||
/// do. NOTE — the wave carries NO decoder-visible clean-point: FFmpeg never sets `AV_FRAME_FLAG_KEY`
|
||||
/// at a recovery point (H.264 flags key only when `recovery_frame_cnt == 0`; HEVC only on IRAP),
|
||||
/// and AMF emits no recovery-point SEI at all. So this cap ALONE does not let the client lift its
|
||||
/// post-loss freeze without an IDR — that needs [`intra_refresh_recovery`](Self::intra_refresh_recovery).
|
||||
pub intra_refresh: bool,
|
||||
/// The intra-refresh wave is a *validated constrained GDR* — verified on real hardware to fully
|
||||
/// heal a lost picture within one wave period with no residual artifacts. Only then does the host
|
||||
/// tag each wave-boundary AU with [`USER_FLAG_RECOVERY_POINT`](punktfunk_core::packet::USER_FLAG_RECOVERY_POINT),
|
||||
/// so the client can lift its freeze on the second mark (a proven clean re-anchor) instead of
|
||||
/// waiting out its backstop and forcing a full IDR. Default `false` on every backend until on-glass
|
||||
/// validation flips it — an un-validated encoder keeps the IDR recovery path, so this is inert and
|
||||
/// cannot regress. Meaningless unless [`intra_refresh`](Self::intra_refresh) is also set.
|
||||
pub intra_refresh_recovery: bool,
|
||||
/// Length of the intra-refresh wave in frames — the boundary period the host marks on (it sets
|
||||
/// `USER_FLAG_RECOVERY_POINT` on every Nth emitted AU, re-phased at each IDR). 0 when intra-refresh
|
||||
/// is off. Only consulted when [`intra_refresh_recovery`](Self::intra_refresh_recovery) is set.
|
||||
pub intra_refresh_period: u32,
|
||||
}
|
||||
|
||||
/// A hardware encoder. One per session; runs on the encode thread.
|
||||
pub trait Encoder: Send {
|
||||
fn submit(&mut self, frame: &CapturedFrame) -> Result<()>;
|
||||
/// [`submit`](Self::submit) with the **wire frame index** this frame's AU will carry — the
|
||||
/// number the packetizer stamps on it and the client's loss reports/RFI requests name. The
|
||||
/// session glue predicts it exactly as `AUs sent so far + frames in flight` (AUs are emitted
|
||||
/// FIFO, one per submission; anything that would break the prediction — an in-place reset, a
|
||||
/// device-change teardown, an encoder rebuild — forfeits the in-flight frames on BOTH sides
|
||||
/// and clears the encoder's reference state, so stale predictions die with it). The RFI
|
||||
/// backends pin their frame numbering (LTR marks, DPB timestamps) to this so
|
||||
/// [`invalidate_ref_frames`](Self::invalidate_ref_frames) compares client frame numbers
|
||||
/// against the same domain — an encoder-internal counter desyncs from the wire on the first
|
||||
/// mid-stream rebuild (adaptive bitrate steps do this under congestion, exactly when losses
|
||||
/// happen). Default: ignore the index and delegate to `submit` (backends without per-frame
|
||||
/// reference bookkeeping don't care).
|
||||
fn submit_indexed(&mut self, frame: &CapturedFrame, wire_index: u32) -> Result<()> {
|
||||
let _ = wire_index;
|
||||
self.submit(frame)
|
||||
}
|
||||
/// This encoder's static [capabilities](EncoderCaps) (RFI, HDR SEI), so the session glue can
|
||||
/// route by query rather than rely on the no-op/`false` defaults of
|
||||
/// [`invalidate_ref_frames`](Self::invalidate_ref_frames) / [`set_hdr_meta`](Self::set_hdr_meta).
|
||||
@@ -236,13 +279,14 @@ pub trait Encoder: Send {
|
||||
/// Default: no-op (SDR encoders / libavcodec paths that don't attach it yet). Cheap to call
|
||||
/// every frame; only the direct-NVENC path consumes it.
|
||||
fn set_hdr_meta(&mut self, _meta: Option<punktfunk_core::quic::HdrMeta>) {}
|
||||
/// Invalidate a contiguous range of previously-encoded reference frames (client frame numbers,
|
||||
/// as reported in a loss-recovery request) so the encoder re-references an older still-valid
|
||||
/// frame instead of emitting a full IDR. Returns `true` if a real reference invalidation was
|
||||
/// performed; `false` means the encoder couldn't (range older than the DPB, or the backend has
|
||||
/// no RFI) and the caller should fall back to [`request_keyframe`](Self::request_keyframe).
|
||||
/// Default: `false` — only the Windows direct-NVENC path implements true RFI; libavcodec
|
||||
/// (Linux NVENC) and VAAPI can't express `nvEncInvalidateRefFrames`, so they keyframe.
|
||||
/// Invalidate a contiguous range of previously-encoded reference frames (client frame numbers
|
||||
/// — WIRE frame indexes, the domain [`submit_indexed`](Self::submit_indexed) pins the encoder's
|
||||
/// bookkeeping to) so the encoder re-references an older still-valid frame instead of emitting
|
||||
/// a full IDR. Returns `true` if a real reference invalidation was performed; `false` means the
|
||||
/// encoder couldn't (range older than the DPB/LTR history, or the backend has no RFI) and the
|
||||
/// caller should fall back to [`request_keyframe`](Self::request_keyframe). Default: `false` —
|
||||
/// the Windows direct-NVENC path (`nvEncInvalidateRefFrames`) and native AMF (LTR
|
||||
/// force-reference) implement true RFI; the libavcodec paths can't express it, so they keyframe.
|
||||
fn invalidate_ref_frames(&mut self, _first_frame: i64, _last_frame: i64) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -418,6 +462,9 @@ impl Encoder for TrackedEncoder {
|
||||
fn submit(&mut self, frame: &CapturedFrame) -> Result<()> {
|
||||
self.inner.submit(frame)
|
||||
}
|
||||
fn submit_indexed(&mut self, frame: &CapturedFrame, wire_index: u32) -> Result<()> {
|
||||
self.inner.submit_indexed(frame, wire_index)
|
||||
}
|
||||
fn caps(&self) -> EncoderCaps {
|
||||
self.inner.caps()
|
||||
}
|
||||
@@ -710,6 +757,29 @@ fn open_nvenc_probed(
|
||||
bit_depth: u8,
|
||||
chroma: ChromaFormat,
|
||||
) -> Result<Box<dyn Encoder>> {
|
||||
// Direct-SDK NVENC (design/linux-direct-nvenc.md): the DEFAULT on NVIDIA, and only for a CUDA
|
||||
// capture payload (it registers CUDADEVICEPTR inputs — a CPU/dmabuf frame can't feed it, so those
|
||||
// keep the libav path; and `cuda` is false on AMD/Intel, so they stay on VAAPI). Set
|
||||
// PUNKTFUNK_NVENC_DIRECT=0 to fall back to libav. It self-clamps the bitrate internally (its own
|
||||
// level-ceiling binary search at session open), so it skips the probe-loop stepping below.
|
||||
#[cfg(feature = "nvenc")]
|
||||
if cuda && nvenc_direct_enabled() {
|
||||
tracing::info!(
|
||||
codec = codec.nvenc_name(),
|
||||
"Linux direct-SDK NVENC (real RFI + recovery anchor) — set PUNKTFUNK_NVENC_DIRECT=0 for libav"
|
||||
);
|
||||
return Ok(Box::new(nvenc_cuda::NvencCudaEncoder::open(
|
||||
codec,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
cuda,
|
||||
bit_depth,
|
||||
chroma,
|
||||
)?) as Box<dyn Encoder>);
|
||||
}
|
||||
const MIN_PROBE_BPS: u64 = 50_000_000;
|
||||
let mut candidates = vec![bitrate_bps];
|
||||
let cap = codec.max_bitrate_bps();
|
||||
@@ -747,6 +817,19 @@ fn open_nvenc_probed(
|
||||
Err(last.unwrap_or_else(|| anyhow::anyhow!("encoder open failed at every probed bitrate")))
|
||||
}
|
||||
|
||||
/// Whether the direct-SDK NVENC path is active. **Default ON** — on-glass validated 2026-07-12:
|
||||
/// real RFI landed 73/73 as clean P-frame recovery anchors (never IDR) on an RTX host with a real
|
||||
/// Steam Deck client (design/linux-direct-nvenc.md §9). `PUNKTFUNK_NVENC_DIRECT=0` (also `false`/
|
||||
/// `no`/`off`) is the libav escape hatch. Only consulted for a CUDA capture payload on an NVIDIA
|
||||
/// host — the `cuda` gate in `open_nvenc_probed` keeps AMD/Intel on VAAPI regardless — and only
|
||||
/// with `--features nvenc`.
|
||||
#[cfg(all(target_os = "linux", feature = "nvenc"))]
|
||||
fn nvenc_direct_enabled() -> bool {
|
||||
std::env::var("PUNKTFUNK_NVENC_DIRECT")
|
||||
.map(|v| !matches!(v.trim(), "0" | "false" | "no" | "off"))
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Cheap, side-effect-free NVIDIA-presence probe for the `auto` backend selector: the NVIDIA
|
||||
/// kernel driver exposes these device nodes, AMD/Intel boxes have neither. Deliberately does NOT
|
||||
/// create a CUDA context (that would allocate GPU state on every host that merely *might* be
|
||||
@@ -1084,9 +1167,16 @@ mod amf;
|
||||
mod ffmpeg_win;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
// Direct-SDK NVENC on Linux (CUDA input; design/linux-direct-nvenc.md) — real RFI + recovery anchor
|
||||
// + reset() lever the libavcodec `linux::NvencEncoder` can't express. Opt-in behind
|
||||
// `PUNKTFUNK_NVENC_DIRECT` until on-glass validated; the `.so` resolves at runtime like the Windows
|
||||
// path, so `--features nvenc` stays safe on a driver-less/AMD Linux box.
|
||||
#[cfg(all(target_os = "windows", feature = "nvenc"))]
|
||||
#[path = "encode/windows/nvenc.rs"]
|
||||
mod nvenc;
|
||||
#[cfg(all(target_os = "linux", feature = "nvenc"))]
|
||||
#[path = "encode/linux/nvenc_cuda.rs"]
|
||||
mod nvenc_cuda;
|
||||
// Software (openh264) H.264 encoder — the GPU-less path on BOTH Windows and Linux (a headless /
|
||||
// GPU-less test box, or a fallback when no hardware encoder is available). Platform-agnostic: it
|
||||
// consumes CPU RGB `CapturedFrame`s and the statically-bundled openh264 build.
|
||||
|
||||
@@ -149,6 +149,22 @@ fn nvenc_input(format: PixelFormat) -> (Pixel, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`NvencEncoder::open`] arguments, kept on the encoder so [`Encoder::reset`] can rebuild it
|
||||
/// in place with the session's negotiated parameters — the encode-stall watchdog's recovery lever
|
||||
/// (drop the wedged libavcodec encoder, reopen fresh, forfeit the owed AUs, restart at an IDR).
|
||||
#[derive(Clone, Copy)]
|
||||
struct OpenArgs {
|
||||
codec: Codec,
|
||||
format: PixelFormat,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
bitrate_bps: u64,
|
||||
cuda: bool,
|
||||
bit_depth: u8,
|
||||
chroma: ChromaFormat,
|
||||
}
|
||||
|
||||
pub struct NvencEncoder {
|
||||
enc: encoder::video::Encoder,
|
||||
/// Reusable 4-bpp CPU input frame (CPU path only; `None` for the zero-copy/CUDA path).
|
||||
@@ -177,6 +193,12 @@ pub struct NvencEncoder {
|
||||
/// Opened in intra-refresh mode (surfaced via [`caps`](Encoder::caps) so the session glue
|
||||
/// rate-limits forced IDRs — the wave heals loss without them).
|
||||
intra_refresh: bool,
|
||||
/// Resolved wave length in frames when [`intra_refresh`](Self::intra_refresh), else 0. Cached at
|
||||
/// open so the pump's per-AU `caps()` doesn't re-read `PUNKTFUNK_IR_PERIOD_FRAMES`; the pump marks
|
||||
/// every Nth AU with `USER_FLAG_RECOVERY_POINT` for the client's clean re-anchor.
|
||||
intra_refresh_period: u32,
|
||||
/// The open arguments, for the in-place [`reset`](Encoder::reset) rebuild.
|
||||
args: OpenArgs,
|
||||
}
|
||||
|
||||
// `CudaHw` holds raw `AVBufferRef`s and `sws_444` a raw `SwsContext`; the encoder lives on a single
|
||||
@@ -525,6 +547,22 @@ impl NvencEncoder {
|
||||
frame_idx: 0,
|
||||
force_kf: false,
|
||||
intra_refresh,
|
||||
intra_refresh_period: if intra_refresh {
|
||||
intra_refresh_period(fps).max(1) as u32
|
||||
} else {
|
||||
0
|
||||
},
|
||||
args: OpenArgs {
|
||||
codec,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
cuda,
|
||||
bit_depth,
|
||||
chroma,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -536,6 +574,12 @@ impl Encoder for NvencEncoder {
|
||||
// convert. RFI/HDR-SEI stay unsupported on libavcodec NVENC (the trait defaults).
|
||||
chroma_444: self.want_444,
|
||||
intra_refresh: self.intra_refresh,
|
||||
// NVENC intra-refresh is purpose-built GDR loss recovery (moving band + recovery-point
|
||||
// SEI): the wave heals a lost picture within one period, so mark the boundary AUs and let
|
||||
// the client re-anchor on them instead of forcing a full IDR. Tied to `intra_refresh`
|
||||
// (already the `PUNKTFUNK_INTRA_REFRESH` opt-in), unlike AMF/QSV which stay unvalidated.
|
||||
intra_refresh_recovery: self.intra_refresh,
|
||||
intra_refresh_period: self.intra_refresh_period,
|
||||
..super::EncoderCaps::default()
|
||||
}
|
||||
}
|
||||
@@ -567,6 +611,35 @@ impl Encoder for NvencEncoder {
|
||||
self.force_kf = true;
|
||||
}
|
||||
|
||||
/// Encode-stall recovery: drop the wedged libavcodec encoder and reopen it fresh with the
|
||||
/// session's negotiated parameters (the stored [`OpenArgs`]) — the drop-and-reopen lever the
|
||||
/// QSV/VAAPI paths use, so the encode-stall watchdog can heal a wedged NVENC/driver instead of
|
||||
/// ending the session. Owed AUs are forfeited; the fresh encoder opens on an IDR.
|
||||
fn reset(&mut self) -> bool {
|
||||
let a = self.args;
|
||||
match Self::open(
|
||||
a.codec,
|
||||
a.format,
|
||||
a.width,
|
||||
a.height,
|
||||
a.fps,
|
||||
a.bitrate_bps,
|
||||
a.cuda,
|
||||
a.bit_depth,
|
||||
a.chroma,
|
||||
) {
|
||||
Ok(mut fresh) => {
|
||||
fresh.force_kf = true;
|
||||
*self = fresh; // drops the wedged encoder (frees its contexts) in the same step
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "NVENC in-place reopen failed");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||
let mut pkt = Packet::empty();
|
||||
match self.enc.receive_packet(&mut pkt) {
|
||||
@@ -578,6 +651,7 @@ impl Encoder for NvencEncoder {
|
||||
data,
|
||||
pts_ns,
|
||||
keyframe: pkt.is_key(),
|
||||
recovery_anchor: false,
|
||||
}))
|
||||
}
|
||||
// No packet ready yet (need another input frame).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -294,6 +294,7 @@ fn poll_encoder(enc: &mut encoder::video::Encoder, fps: u32) -> Result<Option<En
|
||||
data,
|
||||
pts_ns: pts * 1_000_000_000 / fps as u64,
|
||||
keyframe: pkt.is_key(),
|
||||
recovery_anchor: false,
|
||||
}))
|
||||
}
|
||||
Err(ffmpeg::Error::Other { errno })
|
||||
@@ -1125,6 +1126,19 @@ impl Encoder for VaapiEncoder {
|
||||
self.force_kf = true;
|
||||
}
|
||||
|
||||
/// Encode-stall recovery: drop the wedged libavcodec encoder (its `Drop` releases the VA
|
||||
/// surfaces/filter graph/devices) and let the next `submit` rebuild it lazily from the first
|
||||
/// frame's payload, exactly like first-frame bring-up — the same drop-and-reopen lever the
|
||||
/// Windows QSV path has. The owed AUs are forfeited (`in_flight` zeroed) and the rebuilt
|
||||
/// encoder's first frame is forced IDR so the client resyncs immediately. Without this the
|
||||
/// encode-stall watchdog had no lever on Linux AMD/Intel and a wedged driver ended the session.
|
||||
fn reset(&mut self) -> bool {
|
||||
self.inner = None;
|
||||
self.in_flight = 0;
|
||||
self.force_kf = true;
|
||||
true
|
||||
}
|
||||
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||
// With `async_depth > 1`, `submit` no longer waits for the ASIC — the AU for the frame we
|
||||
// just sent lands ~one hardware-encode-time later. Wait for it (bounded) so it still ships
|
||||
|
||||
@@ -20,6 +20,7 @@ use openh264::encoder::{
|
||||
};
|
||||
use openh264::formats::YUVSlices;
|
||||
use openh264::OpenH264API;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
pub struct OpenH264Encoder {
|
||||
enc: Oh264,
|
||||
@@ -34,8 +35,11 @@ pub struct OpenH264Encoder {
|
||||
v_plane: Vec<u8>,
|
||||
frame_idx: i64,
|
||||
force_kf: bool,
|
||||
/// At most one AU per submit (no lookahead), handed back by the next `poll`.
|
||||
pending: Option<EncodedFrame>,
|
||||
/// One AU per submit (no lookahead), handed back FIFO by `poll`. A queue, not an `Option`:
|
||||
/// the session loop pipelines up to `capturer.pipeline_depth()` submits before polling, and a
|
||||
/// single-slot pending would silently overwrite (lose) the older AUs — including the opening
|
||||
/// IDR — and permanently skew the loop's FIFO pts pairing.
|
||||
pending: VecDeque<EncodedFrame>,
|
||||
}
|
||||
|
||||
// openh264's Encoder holds a raw C handle (not auto-Send); it lives on the single encode thread.
|
||||
@@ -88,7 +92,7 @@ impl OpenH264Encoder {
|
||||
v_plane: vec![0; (w / 2) * (h / 2)],
|
||||
frame_idx: 0,
|
||||
force_kf: false,
|
||||
pending: None,
|
||||
pending: VecDeque::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -207,10 +211,11 @@ impl Encoder for OpenH264Encoder {
|
||||
if !data.is_empty() {
|
||||
let keyframe = matches!(bs.frame_type(), FrameType::IDR | FrameType::I);
|
||||
let pts_ns = self.frame_idx as u64 * 1_000_000_000 / self.fps.max(1) as u64;
|
||||
self.pending = Some(EncodedFrame {
|
||||
self.pending.push_back(EncodedFrame {
|
||||
data,
|
||||
pts_ns,
|
||||
keyframe,
|
||||
recovery_anchor: false,
|
||||
});
|
||||
}
|
||||
self.frame_idx += 1;
|
||||
@@ -222,7 +227,7 @@ impl Encoder for OpenH264Encoder {
|
||||
}
|
||||
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||
Ok(self.pending.take())
|
||||
Ok(self.pending.pop_front())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Result<()> {
|
||||
|
||||
@@ -644,6 +644,26 @@ struct CodecProps {
|
||||
/// HEVC 64-px CTBs. `None` on AV1 (v1.4.36 exposes only a mode enum, no slot-size control —
|
||||
/// loss recovery stays IDR there).
|
||||
intra_refresh: Option<(PCWSTR, u32)>,
|
||||
/// LTR-RFI recovery property names (design: the AMD twin of NVENC intra-refresh recovery).
|
||||
/// `None` on AV1 — its reference management uses a frame-marking OBU mechanism this path does
|
||||
/// not drive, so LTR recovery is AVC/HEVC-only.
|
||||
ltr: Option<LtrProps>,
|
||||
}
|
||||
|
||||
/// The four AMF LTR (long-term-reference) property names, codec-prefixed (AVC bare, HEVC `Hevc*`).
|
||||
/// Two are static (`max_*`, set once at open); two are per-frame (`mark`/`force`, set on the input
|
||||
/// surface each `submit`). Together they let a loss re-reference a known-good older frame — a clean
|
||||
/// P-frame instead of a 20–40× IDR spike.
|
||||
struct LtrProps {
|
||||
/// `MaxOfLTRFrames` — number of user LTR slots (we request [`NUM_LTR_SLOTS`]).
|
||||
max_ltr_frames: PCWSTR,
|
||||
/// `MaxNumRefFrames` — reference-picture budget; must exceed 1 for LTR to engage.
|
||||
max_num_ref_frames: PCWSTR,
|
||||
/// `MarkCurrentWithLTRIndex` (per-frame) — tag the current frame as long-term reference slot N.
|
||||
mark_ltr_index: PCWSTR,
|
||||
/// `ForceLTRReferenceBitfield` (per-frame) — force the current frame to reference only the LTR
|
||||
/// slots in the bitfield (`1<<N`), breaking the corrupted short-term chain after a loss.
|
||||
force_ltr_bitfield: PCWSTR,
|
||||
}
|
||||
|
||||
/// The two payload shapes `lowlatency` takes across codecs.
|
||||
@@ -689,6 +709,12 @@ fn codec_props(codec: Codec) -> CodecProps {
|
||||
out_primaries: w!("OutColorPrimaries"),
|
||||
hdr_metadata: None,
|
||||
intra_refresh: Some((w!("IntraRefreshMBsNumberPerSlot"), 16)),
|
||||
ltr: Some(LtrProps {
|
||||
max_ltr_frames: w!("MaxOfLTRFrames"),
|
||||
max_num_ref_frames: w!("MaxNumRefFrames"),
|
||||
mark_ltr_index: w!("MarkCurrentWithLTRIndex"),
|
||||
force_ltr_bitfield: w!("ForceLTRReferenceBitfield"),
|
||||
}),
|
||||
},
|
||||
Codec::H265 => CodecProps {
|
||||
component: w!("AMFVideoEncoderHW_HEVC"),
|
||||
@@ -716,6 +742,12 @@ fn codec_props(codec: Codec) -> CodecProps {
|
||||
out_primaries: w!("HevcOutColorPrimaries"),
|
||||
hdr_metadata: Some(w!("HevcInHDRMetadata")),
|
||||
intra_refresh: Some((w!("HevcIntraRefreshCTBsNumberPerSlot"), 64)),
|
||||
ltr: Some(LtrProps {
|
||||
max_ltr_frames: w!("HevcMaxOfLTRFrames"),
|
||||
max_num_ref_frames: w!("HevcMaxNumRefFrames"),
|
||||
mark_ltr_index: w!("HevcMarkCurrentWithLTRIndex"),
|
||||
force_ltr_bitfield: w!("HevcForceLTRReferenceBitfield"),
|
||||
}),
|
||||
},
|
||||
Codec::Av1 => CodecProps {
|
||||
component: w!("AMFVideoEncoderHW_AV1"),
|
||||
@@ -743,6 +775,7 @@ fn codec_props(codec: Codec) -> CodecProps {
|
||||
out_primaries: w!("Av1OutputColorPrimaries"),
|
||||
hdr_metadata: Some(w!("Av1InHDRMetadata")),
|
||||
intra_refresh: None,
|
||||
ltr: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -797,6 +830,45 @@ fn intra_refresh_period(fps: u32) -> u32 {
|
||||
.unwrap_or_else(|| (fps.max(16) / 2).max(2))
|
||||
}
|
||||
|
||||
/// Number of user-controlled LTR slots. AMD exposes up to 2; two rotating slots hold a sliding pair
|
||||
/// of recent long-term references, so a loss can re-reference the newest one *before* the loss point.
|
||||
const NUM_LTR_SLOTS: usize = 2;
|
||||
|
||||
/// AMD's real clean loss-recovery path (the NVENC-RFI twin): the encoder marks frames as long-term
|
||||
/// references, and on loss forces a later frame to re-reference a known-good one — a clean P-frame,
|
||||
/// not a 20-40× IDR spike. On by default when the driver supports it (AMF intra-refresh cannot heal —
|
||||
/// no constrained-intra-prediction property exists in the API, header-confirmed + PSNR-proven — and
|
||||
/// LTR is mutually exclusive with it, so LTR wins). `PUNKTFUNK_NO_AMF_LTR=1` forces the old full-IDR
|
||||
/// recovery for debugging.
|
||||
fn ltr_disabled() -> bool {
|
||||
std::env::var("PUNKTFUNK_NO_AMF_LTR")
|
||||
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Cadence (frames) between LTR marks — a fresh long-term reference roughly every half second by
|
||||
/// default (`PUNKTFUNK_LTR_INTERVAL_FRAMES` overrides). With [`NUM_LTR_SLOTS`] slots this keeps ~one
|
||||
/// second of recent references, so a loss up to ~1 s old still has a known-good frame to force; a
|
||||
/// smaller interval means the forced reference is more recent (a smaller recovery-frame residual).
|
||||
fn ltr_mark_interval(fps: u32) -> i64 {
|
||||
std::env::var("PUNKTFUNK_LTR_INTERVAL_FRAMES")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.filter(|v| *v >= 1)
|
||||
.unwrap_or_else(|| (fps.max(2) / 2).max(1) as i64)
|
||||
}
|
||||
|
||||
/// Validation hook (`PUNKTFUNK_LTR_FORCE_AT=N`, spike-only): at `frame_idx == N` the encoder
|
||||
/// self-triggers its real [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) path, so a
|
||||
/// headless spike run can exercise LTR recovery end-to-end (mark → force → recovery-anchor tag)
|
||||
/// without a live client sending an [`RfiRequest`](punktfunk_core::quic::RfiRequest). `None` normally.
|
||||
fn ltr_test_force_at() -> Option<i64> {
|
||||
std::env::var("PUNKTFUNK_LTR_FORCE_AT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.filter(|v| *v > 0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Owned-pointer guards (release exactly once; Terminate before Release for context/component,
|
||||
// mirroring amfenc.c's teardown order).
|
||||
@@ -930,11 +1002,12 @@ struct Inner {
|
||||
dctx: ID3D11DeviceContext,
|
||||
ring: Vec<ID3D11Texture2D>,
|
||||
next: usize,
|
||||
/// (pts_ns, forced-IDR) per submitted-but-unretrieved frame, FIFO — the AMF encoder emits
|
||||
/// AUs in submit order (B-frames are never enabled), pairing with `QueryOutput`. Its length is
|
||||
/// the count of input surfaces AMF still holds, so `submit` bounds it below [`RING`] to keep
|
||||
/// the input ring from being overwritten under it.
|
||||
pending: VecDeque<(u64, bool)>,
|
||||
/// (pts_ns, forced-IDR, recovery-anchor) per submitted-but-unretrieved frame, FIFO — the AMF
|
||||
/// encoder emits AUs in submit order (B-frames are never enabled), pairing with `QueryOutput`.
|
||||
/// The third field tags the LTR-RFI re-anchor frame so the AU carries `recovery_anchor` for the
|
||||
/// client's freeze-lift. Its length is the count of input surfaces AMF still holds, so `submit`
|
||||
/// bounds it below [`RING`] to keep the input ring from being overwritten under it.
|
||||
pending: VecDeque<(u64, bool, bool)>,
|
||||
/// AUs already pulled by `submit`'s backpressure drain, waiting to be handed out by `poll`
|
||||
/// (FIFO, strictly older than anything still in `pending`). Empty in the steady state — only
|
||||
/// fills when the encoder falls behind and `submit` drains to free an input slot.
|
||||
@@ -988,6 +1061,26 @@ pub struct AmfEncoder {
|
||||
/// gates [`EncoderCaps::intra_refresh`] so keyframe-request rate-limiting only happens when
|
||||
/// the wave really runs.
|
||||
ir_active: bool,
|
||||
// --- Long-Term-Reference reference-frame-invalidation recovery (the AMD RFI path) ---
|
||||
/// The driver accepted the LTR properties at open — gates [`EncoderCaps::supports_rfi`] and all
|
||||
/// the per-frame LTR marking/forcing below. When true, intra-refresh is NOT set (mutually
|
||||
/// exclusive) and loss recovery re-references a known-good LTR instead of forcing a full IDR.
|
||||
ltr_active: bool,
|
||||
/// The `frame_idx` currently stored in each of the two LTR slots (`None` = never marked). On loss
|
||||
/// the newest slot with an index *before* the loss is the known-good reference to force.
|
||||
ltr_slots: [Option<i64>; NUM_LTR_SLOTS],
|
||||
/// The slot the next LTR mark writes (round-robins `0,1,0,1,…` so the two slots hold a sliding
|
||||
/// pair of recent references).
|
||||
next_ltr_slot: usize,
|
||||
/// Cadence (frames) between LTR marks — a fresh long-term reference roughly this often.
|
||||
ltr_mark_interval: i64,
|
||||
/// Set by [`invalidate_ref_frames`](Encoder::invalidate_ref_frames): the LTR slot the *next*
|
||||
/// submitted frame must force-reference (`ForceLTRReferenceBitfield`). Consumed on that submit.
|
||||
pending_force: Option<usize>,
|
||||
/// Validation hook (`PUNKTFUNK_LTR_FORCE_AT=N`, spike-only): at `frame_idx == N`, self-trigger the
|
||||
/// real [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) path so a headless spike run can
|
||||
/// exercise LTR recovery end-to-end without a live client. `None` in normal operation.
|
||||
ltr_test_force_at: Option<i64>,
|
||||
/// Consecutive [`reset`](Self::reset)s that have NOT been followed by a produced AU (cleared in
|
||||
/// `poll` on any output). An in-place `Terminate`+re-`Init` heals a transient component stall,
|
||||
/// but it re-inits the SAME context — so if the fault is the context / VCN session itself (the
|
||||
@@ -1084,17 +1177,33 @@ impl AmfEncoder {
|
||||
force_kf: false,
|
||||
hdr_meta: None,
|
||||
ir_active: false,
|
||||
ltr_active: false,
|
||||
ltr_slots: [None; NUM_LTR_SLOTS],
|
||||
next_ltr_slot: 0,
|
||||
ltr_mark_interval: ltr_mark_interval(fps),
|
||||
pending_force: None,
|
||||
ltr_test_force_at: ltr_test_force_at(),
|
||||
resets_without_output: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether this encoder should *attempt* the LTR-RFI recovery path (design: the AMD twin of
|
||||
/// NVENC intra-refresh recovery). Gated to AVC/HEVC — AMF exposes user LTR only for those two
|
||||
/// codecs — and defeatable via `PUNKTFUNK_NO_AMF_LTR`. Whether the driver actually *accepts* the
|
||||
/// properties is a separate question answered by [`apply_static_props`], which sets `ltr_active`.
|
||||
fn ltr_wanted(&self) -> bool {
|
||||
!ltr_disabled() && matches!(self.codec, Codec::H264 | Codec::H265)
|
||||
}
|
||||
|
||||
/// Apply the static encoder configuration (design §3.4 — the native mirror of the ffmpeg
|
||||
/// opts block in `open_win_encoder`). Called before `Init`, and again on a `reset()`
|
||||
/// re-`Init` (Terminate does not guarantee property retention across every driver).
|
||||
/// Returns whether the intra-refresh wave was requested AND accepted by this driver — the
|
||||
/// caller stores it so [`Encoder::caps`] only rate-limits keyframe requests when the wave
|
||||
/// really runs.
|
||||
unsafe fn apply_static_props(&self, comp: *mut sys::AmfComponent) -> Result<bool> {
|
||||
/// Returns `(ir_active, ltr_active)`: whether the intra-refresh wave / the LTR-RFI slots were
|
||||
/// requested AND accepted by this driver. The two are mutually exclusive (LTR wins when both are
|
||||
/// wanted). The caller stores both — `ir_active` so [`Encoder::caps`] only rate-limits keyframe
|
||||
/// requests when a wave runs, `ltr_active` so [`Encoder::caps`] advertises `supports_rfi` and the
|
||||
/// per-frame mark/force logic in `submit` only fires when the slots exist.
|
||||
unsafe fn apply_static_props(&self, comp: *mut sys::AmfComponent) -> Result<(bool, bool)> {
|
||||
let p = &self.props;
|
||||
// Usage first: it "fully configures parameter set" — everything after is an override.
|
||||
set_prop(
|
||||
@@ -1145,7 +1254,39 @@ impl AmfEncoder {
|
||||
// whole picture refreshes every `period` frames — per-slot units = ceil(total blocks /
|
||||
// period). Optional by VCN generation; the return value gates `caps().intra_refresh`.
|
||||
let mut ir_active = false;
|
||||
if let Some((name, block)) = p.intra_refresh {
|
||||
let mut ltr_active = false;
|
||||
if let Some(ltr) = p.ltr.as_ref().filter(|_| self.ltr_wanted()) {
|
||||
// LTR-RFI recovery (design: the AMD twin of NVENC intra-refresh recovery). Request
|
||||
// NUM_LTR_SLOTS user-controlled long-term references. LTR needs >1 reference frames and
|
||||
// is MUTUALLY EXCLUSIVE with intra-refresh (AMF disables one if both are set), so the
|
||||
// intra-refresh block below is skipped whenever LTR engages.
|
||||
let ref_ok = set_prop(
|
||||
comp,
|
||||
ltr.max_num_ref_frames,
|
||||
AmfVariant::from_i64(NUM_LTR_SLOTS as i64),
|
||||
false,
|
||||
)?;
|
||||
let ltr_ok = set_prop(
|
||||
comp,
|
||||
ltr.max_ltr_frames,
|
||||
AmfVariant::from_i64(NUM_LTR_SLOTS as i64),
|
||||
false,
|
||||
)?;
|
||||
ltr_active = ref_ok && ltr_ok;
|
||||
if ltr_active {
|
||||
tracing::info!(
|
||||
slots = NUM_LTR_SLOTS,
|
||||
mark_interval = self.ltr_mark_interval,
|
||||
"AMF LTR-RFI recovery enabled (loss recovery re-references a known-good LTR, not a full IDR)"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
ref_ok,
|
||||
ltr_ok,
|
||||
"this VCN/driver rejected an LTR property — loss recovery stays full-IDR"
|
||||
);
|
||||
}
|
||||
} else if let Some((name, block)) = p.intra_refresh {
|
||||
if intra_refresh_requested() {
|
||||
let period = intra_refresh_period(self.fps);
|
||||
let blocks = self.width.div_ceil(block) * self.height.div_ceil(block);
|
||||
@@ -1273,7 +1414,7 @@ impl AmfEncoder {
|
||||
AmfVariant::from_i64(primaries),
|
||||
self.ten_bit,
|
||||
)?;
|
||||
Ok(ir_active)
|
||||
Ok((ir_active, ltr_active))
|
||||
}
|
||||
|
||||
/// Build (or rebuild, on a capture-device change) the AMF context + encoder component on the
|
||||
@@ -1323,7 +1464,7 @@ impl AmfEncoder {
|
||||
bail!("AMF CreateComponent returned null");
|
||||
}
|
||||
let comp = Component(comp);
|
||||
let ir_active = self.apply_static_props(comp.0)?;
|
||||
let (ir_active, ltr_active) = self.apply_static_props(comp.0)?;
|
||||
let fmt = if self.ten_bit {
|
||||
sys::AMF_SURFACE_P010
|
||||
} else {
|
||||
@@ -1334,6 +1475,14 @@ impl AmfEncoder {
|
||||
"AMF encoder Init",
|
||||
)?;
|
||||
self.ir_active = ir_active;
|
||||
// A rebuilt component starts with fresh (empty) LTR slots — a new context has no
|
||||
// reference history, so any prior marks are void and the first frame re-IDRs anyway.
|
||||
self.ltr_active = ltr_active;
|
||||
if ltr_active {
|
||||
self.ltr_slots = [None; NUM_LTR_SLOTS];
|
||||
self.next_ltr_slot = 0;
|
||||
self.pending_force = None;
|
||||
}
|
||||
|
||||
// Owned input ring on the capturer's device (design §3.2): RENDER_TARGET |
|
||||
// SHADER_RESOURCE, the same bind flags the validated ffmpeg zero-copy pool uses.
|
||||
@@ -1594,7 +1743,7 @@ enum DrainOutcome {
|
||||
/// single encode thread with no other AMF call to this component in flight.
|
||||
unsafe fn drain_one_output(
|
||||
comp: *mut sys::AmfComponent,
|
||||
pending: &mut VecDeque<(u64, bool)>,
|
||||
pending: &mut VecDeque<(u64, bool, bool)>,
|
||||
output_data_type: PCWSTR,
|
||||
output_key_max: i64,
|
||||
) -> Result<DrainOutcome> {
|
||||
@@ -1641,11 +1790,12 @@ unsafe fn drain_one_output(
|
||||
bail!("AMF output buffer is empty");
|
||||
}
|
||||
let au = std::slice::from_raw_parts(native as *const u8, size).to_vec();
|
||||
let (pts_ns, forced) = pending.pop_front().unwrap_or((0, false));
|
||||
let (pts_ns, forced, recovery_anchor) = pending.pop_front().unwrap_or((0, false, false));
|
||||
Ok(DrainOutcome::Frame(EncodedFrame {
|
||||
data: au,
|
||||
pts_ns,
|
||||
keyframe: key_prop || forced,
|
||||
recovery_anchor,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1689,9 +1839,62 @@ impl Encoder for AmfEncoder {
|
||||
expected
|
||||
);
|
||||
self.ensure_inner(&frame.device)?;
|
||||
let forced = std::mem::take(&mut self.force_kf) || self.frame_idx == 0;
|
||||
let cur_idx = self.frame_idx;
|
||||
// A component's FIRST submission must be a forced IDR (stream-start contract: in-band
|
||||
// headers + LTR re-anchor). Detected via the fresh ring counter, NOT `frame_idx == 0`:
|
||||
// `submit_indexed` pins frame_idx to the wire index, which is non-zero when a mid-session
|
||||
// rebuild (bitrate step / reset escalation) brings a new component up.
|
||||
let opening = self.inner.as_ref().is_none_or(|i| i.next == 0);
|
||||
let forced = std::mem::take(&mut self.force_kf) || opening;
|
||||
let pts_100ns = self.frame_idx * 10_000_000 / self.fps.max(1) as i64;
|
||||
self.frame_idx += 1;
|
||||
// --- LTR-RFI per-frame decisions (design: the AMD twin of NVENC intra-refresh recovery) ---
|
||||
// Decided here, before borrowing `inner`, because the test hook re-enters `&mut self`
|
||||
// (`invalidate_ref_frames`) and the mark cadence mutates the slot bookkeeping. The two
|
||||
// per-frame property names are copied out (PCWSTR is Copy) so the unsafe surface block can
|
||||
// set them without re-borrowing `self.props` under the live `inner` borrow.
|
||||
let ltr_names = self
|
||||
.props
|
||||
.ltr
|
||||
.as_ref()
|
||||
.map(|l| (l.mark_ltr_index, l.force_ltr_bitfield));
|
||||
let mut mark_slot: Option<usize> = None;
|
||||
let mut force_slot: Option<usize> = None;
|
||||
let mut recovery_anchor = false;
|
||||
if self.ltr_active {
|
||||
if forced {
|
||||
// An IDR resets the decoder's reference buffers — every prior LTR mark is void.
|
||||
// Re-anchor from scratch: drop the stale slots (the mark cadence below tags the IDR
|
||||
// as the first fresh long-term reference) and cancel any force queued against them.
|
||||
self.ltr_slots = [None; NUM_LTR_SLOTS];
|
||||
self.next_ltr_slot = 0;
|
||||
self.pending_force = None;
|
||||
} else if self.ltr_test_force_at == Some(cur_idx) {
|
||||
// Spike-only validation hook: self-trigger the real invalidate path so a headless
|
||||
// run exercises mark → force → recovery-anchor without a live client's RfiRequest.
|
||||
let triggered = self.invalidate_ref_frames(cur_idx, cur_idx);
|
||||
tracing::info!(
|
||||
frame = cur_idx,
|
||||
triggered,
|
||||
"AMF LTR test hook fired invalidate_ref_frames"
|
||||
);
|
||||
}
|
||||
// Apply a queued force (from invalidate_ref_frames / the test hook) to THIS frame: it
|
||||
// becomes the clean re-anchor P-frame the client lifts its post-loss freeze on.
|
||||
if let Some(slot) = self.pending_force.take() {
|
||||
force_slot = Some(slot);
|
||||
recovery_anchor = true;
|
||||
}
|
||||
// Mark cadence: refresh a long-term reference on every IDR and every `ltr_mark_interval`
|
||||
// frames — but never on the recovery frame itself (marking rotates `next_ltr_slot` and
|
||||
// could overwrite the very slot being forced; the next cadence mark re-establishes it).
|
||||
if force_slot.is_none() && (forced || cur_idx % self.ltr_mark_interval == 0) {
|
||||
let slot = self.next_ltr_slot;
|
||||
self.ltr_slots[slot] = Some(cur_idx);
|
||||
self.next_ltr_slot = (self.next_ltr_slot + 1) % NUM_LTR_SLOTS;
|
||||
mark_slot = Some(slot);
|
||||
}
|
||||
}
|
||||
let inner = self.inner.as_mut().expect("ensure_inner succeeded");
|
||||
// Push the HDR mastering metadata when it changed (or a rebuilt component lost it) — a
|
||||
// dynamic property, so mid-stream regrades take effect on the next IDR. Best-effort: a
|
||||
@@ -1831,6 +2034,47 @@ impl Encoder for AmfEncoder {
|
||||
Codec::Av1 => {}
|
||||
}
|
||||
}
|
||||
// LTR-RFI per-frame properties (design: the AMD twin of NVENC intra-refresh recovery).
|
||||
// `mark_slot`/`force_slot` were decided above. Marking tags the current frame as a
|
||||
// long-term reference; forcing makes it re-reference a known-good LTR — a clean P-frame
|
||||
// that breaks the corrupted short-term chain after a loss, no 20-40× IDR. Best-effort:
|
||||
// a rejecting driver just leaves the client on its keyframe-request fallback.
|
||||
if let Some((mark_name, force_name)) = ltr_names {
|
||||
if let Some(slot) = mark_slot {
|
||||
let r = ((*(*surf.0).vtbl).set_property)(
|
||||
surf.0,
|
||||
mark_name.0,
|
||||
AmfVariant::from_i64(slot as i64),
|
||||
);
|
||||
if r != sys::AMF_OK {
|
||||
tracing::warn!(
|
||||
slot,
|
||||
result = %format!("{} ({r})", result_name(r)),
|
||||
"AMF LTR mark rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(slot) = force_slot {
|
||||
let r = ((*(*surf.0).vtbl).set_property)(
|
||||
surf.0,
|
||||
force_name.0,
|
||||
AmfVariant::from_i64(1_i64 << slot),
|
||||
);
|
||||
if r == sys::AMF_OK {
|
||||
tracing::info!(
|
||||
slot,
|
||||
frame = cur_idx,
|
||||
"AMF LTR-RFI: re-referencing known-good LTR (clean recovery, no IDR)"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
slot,
|
||||
result = %format!("{} ({r})", result_name(r)),
|
||||
"AMF LTR force-reference rejected — client stays frozen until its IDR fallback"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut r = ((*(*inner.comp.0).vtbl).submit_input)(inner.comp.0, surf.0);
|
||||
// Backstop back-pressure: the in-flight bound above already keeps a slot free, but if
|
||||
// AMF's own input queue is momentarily full, AMF_INPUT_FULL is "busy, drain me and
|
||||
@@ -1873,10 +2117,27 @@ impl Encoder for AmfEncoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
inner.pending.push_back((captured.pts_ns, forced));
|
||||
inner
|
||||
.pending
|
||||
.push_back((captured.pts_ns, forced, recovery_anchor));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pin this submission's frame number to the wire frame index its AU will carry (see the
|
||||
/// trait doc): the LTR slots then store WIRE indexes, so [`invalidate_ref_frames`]'s
|
||||
/// pre-loss check (`slot < first`, both in client frame numbers) stays correct across every
|
||||
/// encoder rebuild/reset — an internal counter desyncs on the first adaptive-bitrate rebuild,
|
||||
/// making the check vacuously true and risking a force-reference to an LTR marked INSIDE the
|
||||
/// lost range (a corrupted frame shipped as a clean recovery anchor). `frame_idx` also feeds
|
||||
/// the AMF SetPts; a re-pin only ever moves it backward across a reset (fresh component, so a
|
||||
/// pts restart is harmless) and forward on a rebuild (monotonic within any one component).
|
||||
///
|
||||
/// [`invalidate_ref_frames`]: Encoder::invalidate_ref_frames
|
||||
fn submit_indexed(&mut self, frame: &CapturedFrame, wire_index: u32) -> Result<()> {
|
||||
self.frame_idx = wire_index as i64;
|
||||
self.submit(frame)
|
||||
}
|
||||
|
||||
fn request_keyframe(&mut self) {
|
||||
self.force_kf = true;
|
||||
}
|
||||
@@ -1887,11 +2148,65 @@ impl Encoder for AmfEncoder {
|
||||
self.hdr_meta = meta;
|
||||
}
|
||||
|
||||
/// LTR-RFI recovery (the AMD twin of the Windows NVENC `nvEncInvalidateRefFrames` path): a loss
|
||||
/// of client frames `[first, last]` is answered by forcing the *next* submitted frame to
|
||||
/// re-reference the newest long-term reference marked *before* the loss — a clean P-frame the
|
||||
/// client can decode against a picture it still holds, instead of a 20-40× IDR spike.
|
||||
///
|
||||
/// Returns `true` when a usable pre-loss LTR exists (so the caller must NOT also force an IDR);
|
||||
/// `false` when the loss predates every live LTR — then the only correct recovery is a keyframe,
|
||||
/// and the caller falls back to [`request_keyframe`](Self::request_keyframe). Runs on the encode
|
||||
/// thread (like submit/poll); the force is applied on the next `submit`.
|
||||
fn invalidate_ref_frames(&mut self, first: i64, last: i64) -> bool {
|
||||
// No live LTR session (driver declined the slots, or AV1 which has no user-LTR path) or a
|
||||
// nonsense range → caller forces a full IDR.
|
||||
if !self.ltr_active || first < 0 || first > last {
|
||||
return false;
|
||||
}
|
||||
// Pick the newest LTR strictly OLDER than the loss: the most recent known-good reference the
|
||||
// client still holds, so re-referencing it costs the least (smallest recovery-frame residual).
|
||||
// `ltr_slots` store the WIRE frame index of the marked frame (`submit_indexed` pins
|
||||
// `frame_idx` to it per submission), so they compare directly against the client's `first`
|
||||
// — and stay comparable across encoder rebuilds/resets, where an internal counter would
|
||||
// make this check vacuous and risk force-referencing an LTR marked INSIDE the lost range.
|
||||
let mut best: Option<(usize, i64)> = None;
|
||||
for (slot, marked) in self.ltr_slots.iter().enumerate() {
|
||||
if let Some(idx) = *marked {
|
||||
if idx < first && best.is_none_or(|(_, b)| idx > b) {
|
||||
best = Some((slot, idx));
|
||||
}
|
||||
}
|
||||
}
|
||||
match best {
|
||||
Some((slot, ltr_frame)) => {
|
||||
// Queue the force for the next submit; that frame ships tagged `recovery_anchor`.
|
||||
self.pending_force = Some(slot);
|
||||
tracing::info!(
|
||||
first,
|
||||
last,
|
||||
slot,
|
||||
ltr_frame,
|
||||
"AMF LTR-RFI: forcing the next frame to re-reference a known-good LTR (no IDR)"
|
||||
);
|
||||
true
|
||||
}
|
||||
None => {
|
||||
tracing::info!(
|
||||
first,
|
||||
last,
|
||||
"AMF LTR-RFI: no live LTR older than the loss — falling back to IDR recovery"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn caps(&self) -> EncoderCaps {
|
||||
EncoderCaps {
|
||||
// AMF has no NVENC-style reference invalidation — the intra-refresh wave is the
|
||||
// loss-recovery substitute; without it every unrecoverable loss costs an IDR.
|
||||
supports_rfi: false,
|
||||
// LTR-RFI: AMD's reference invalidation is the user long-term-reference path (mark a
|
||||
// frame, force a later one to re-reference it). True only when the live driver accepted
|
||||
// the LTR slots at open — otherwise loss recovery falls back to a full IDR.
|
||||
supports_rfi: self.ltr_active,
|
||||
// In-band mastering/CLL via `*InHDRMetadata` (HEVC SEI / AV1 metadata OBU); AVC has
|
||||
// no such property (and no HDR sessions negotiate H.264).
|
||||
supports_hdr_metadata: self.ten_bit && self.props.hdr_metadata.is_some(),
|
||||
@@ -1901,6 +2216,11 @@ impl Encoder for AmfEncoder {
|
||||
// accepted the property (queried per loss event, so the post-first-frame value is
|
||||
// what the session glue's IDR rate-limiting sees).
|
||||
intra_refresh: self.ir_active,
|
||||
// Not yet: the AMD VCN wave heals in principle, but its constrained-GDR
|
||||
// heal-within-a-period is unvalidated on-glass and AMF emits no recovery-point SEI, so
|
||||
// the host keeps the IDR recovery path. Flip both once verified on real hardware.
|
||||
intra_refresh_recovery: false,
|
||||
intra_refresh_period: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1992,6 +2312,7 @@ impl Encoder for AmfEncoder {
|
||||
self.inner = None;
|
||||
self.bound_device = 0;
|
||||
self.ir_active = false;
|
||||
self.ltr_active = false;
|
||||
return true;
|
||||
}
|
||||
let inner = self
|
||||
@@ -2016,8 +2337,14 @@ impl Encoder for AmfEncoder {
|
||||
sys::AMF_SURFACE_NV12
|
||||
};
|
||||
match self.apply_static_props(comp) {
|
||||
Ok(ir) => {
|
||||
Ok((ir, ltr)) => {
|
||||
self.ir_active = ir;
|
||||
// Re-Init voids the reference history: the rebuilt stream restarts at IDR with
|
||||
// empty LTR slots, so any prior marks are stale and must be dropped.
|
||||
self.ltr_active = ltr;
|
||||
self.ltr_slots = [None; NUM_LTR_SLOTS];
|
||||
self.next_ltr_slot = 0;
|
||||
self.pending_force = None;
|
||||
((*(*comp).vtbl).init)(comp, fmt, self.width as i32, self.height as i32)
|
||||
== sys::AMF_OK
|
||||
}
|
||||
@@ -2030,6 +2357,7 @@ impl Encoder for AmfEncoder {
|
||||
);
|
||||
} else {
|
||||
self.ir_active = false;
|
||||
self.ltr_active = false;
|
||||
// Full teardown; the next submit reopens context + component on the current device.
|
||||
tracing::warn!("AMF in-place re-Init failed — full context teardown, reopening lazily");
|
||||
self.inner = None;
|
||||
|
||||
@@ -339,6 +339,7 @@ fn poll_encoder(enc: &mut encoder::video::Encoder, fps: u32) -> Result<PollOutco
|
||||
data,
|
||||
pts_ns: pts * 1_000_000_000 / fps as u64,
|
||||
keyframe: pkt.is_key(),
|
||||
recovery_anchor: false,
|
||||
}))
|
||||
}
|
||||
Err(ffmpeg::Error::Other { errno })
|
||||
|
||||
@@ -429,10 +429,25 @@ pub struct NvencD3d11Encoder {
|
||||
async_rt: Option<AsyncRetrieve>,
|
||||
/// `NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT` from the caps probe — gates the async retrieve mode.
|
||||
async_supported: bool,
|
||||
/// (bitstream, mapped input resource to unmap after retrieval, pts_ns) per in-flight encode.
|
||||
pending: VecDeque<(nv::NV_ENC_OUTPUT_PTR, nv::NV_ENC_INPUT_PTR, u64)>,
|
||||
/// (bitstream, mapped input resource to unmap after retrieval, pts_ns, recovery-anchor) per
|
||||
/// in-flight encode. The fourth field tags the first frame encoded after a successful
|
||||
/// [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) — the clean re-anchor P-frame the
|
||||
/// client lifts its post-loss freeze on (see [`EncodedFrame::recovery_anchor`]).
|
||||
pending: VecDeque<(nv::NV_ENC_OUTPUT_PTR, nv::NV_ENC_INPUT_PTR, u64, bool)>,
|
||||
/// The frame number of the NEXT submission (also its `inputTimeStamp`). Pinned per frame by
|
||||
/// [`Encoder::submit_indexed`] to the WIRE frame index the AU will carry, so the DPB timestamps
|
||||
/// `invalidate_ref_frames` compares client frame numbers against stay 1:1 with the wire across
|
||||
/// encoder rebuilds/resets (an internal counter desyncs on the first adaptive-bitrate rebuild —
|
||||
/// RFI then never matches again). Self-increments as a fallback for un-indexed callers (tests).
|
||||
frame_idx: i64,
|
||||
force_kf: bool,
|
||||
/// A successful [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) arms this; the next
|
||||
/// `submit` consumes it into `pending` so that AU ships as the recovery anchor. NVENC applies
|
||||
/// the invalidation at the next `encode_picture`, so that frame is by construction the first
|
||||
/// one coded against only-valid references — without tagging it the client's freeze can only
|
||||
/// lift on an IDR, which the session glue suppresses after an RFI success (the cooldown):
|
||||
/// a ~1 s frozen stall per loss event on NVIDIA hosts.
|
||||
pending_anchor: bool,
|
||||
inited: bool,
|
||||
/// GPU capabilities probed once via `nvEncGetEncodeCaps` before configuring (Apollo's
|
||||
/// `get_encoder_cap`): gates 10-bit/custom-VBV/RFI on what this card actually supports instead
|
||||
@@ -507,6 +522,7 @@ impl NvencD3d11Encoder {
|
||||
pending: VecDeque::new(),
|
||||
frame_idx: 0,
|
||||
force_kf: false,
|
||||
pending_anchor: false,
|
||||
inited: false,
|
||||
rfi_supported: false,
|
||||
custom_vbv: false,
|
||||
@@ -536,7 +552,7 @@ impl NvencD3d11Encoder {
|
||||
while rt.done_rx.try_recv().is_ok() {}
|
||||
}
|
||||
// Unmap any in-flight inputs, then unregister every cached texture and destroy the bitstreams.
|
||||
for (_, map, _) in &self.pending {
|
||||
for (_, map, _, _) in &self.pending {
|
||||
if !map.is_null() {
|
||||
let _ = (api().unmap_input_resource)(self.encoder, *map);
|
||||
}
|
||||
@@ -569,8 +585,10 @@ impl NvencD3d11Encoder {
|
||||
self.inited = false;
|
||||
self.next = 0;
|
||||
// The new session starts with an empty DPB (its first frame is an IDR), so any prior
|
||||
// invalidation range is meaningless against it.
|
||||
// invalidation range is meaningless against it — and the IDR is itself the re-anchor,
|
||||
// so a pending anchor tag from a pre-teardown RFI is stale too.
|
||||
self.last_rfi_range = None;
|
||||
self.pending_anchor = false;
|
||||
}
|
||||
|
||||
/// Query one `NV_ENC_CAPS` value for this codec on an open session; 0 on any error (treat an
|
||||
@@ -1133,7 +1151,7 @@ impl NvencD3d11Encoder {
|
||||
/// error surfaces AFTER the unmap (the resource is retired either way) so the session glue's
|
||||
/// rebuild path starts from clean state.
|
||||
fn absorb_done(&mut self, done: RetrieveDone) -> Result<()> {
|
||||
let Some((bs, map, pts_ns)) = self.pending.pop_front() else {
|
||||
let Some((bs, map, pts_ns, anchor)) = self.pending.pop_front() else {
|
||||
bail!("NVENC async: completion with no in-flight frame (pairing bug)");
|
||||
};
|
||||
if bs as usize != done.bs {
|
||||
@@ -1157,6 +1175,7 @@ impl NvencD3d11Encoder {
|
||||
data,
|
||||
pts_ns,
|
||||
keyframe,
|
||||
recovery_anchor: anchor,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -1248,6 +1267,11 @@ impl Encoder for NvencD3d11Encoder {
|
||||
self.init_session(&device)?;
|
||||
self.init_device = dev_raw;
|
||||
}
|
||||
// The session's opening frame — NVENC emits it as an IDR regardless of pic flags, so the
|
||||
// in-band HDR SEI must ride it too. Detected via the still-empty output slot counter
|
||||
// (`teardown` zeroes it), NOT via `pts == 0`: `submit_indexed` pins pts to the wire frame
|
||||
// index, which is non-zero on a mid-session encoder rebuild's first frame.
|
||||
let opening = self.next == 0;
|
||||
// Async backpressure: never hand NVENC an output bitstream that is still in flight, and
|
||||
// keep in-flight depth within the capturer's texture ring (see `async_inflight_cap`). At
|
||||
// the cap, block on the OLDEST completion (the retrieve thread is already waiting on its
|
||||
@@ -1322,6 +1346,10 @@ impl Encoder for NvencD3d11Encoder {
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// Recovery anchor (armed by a successful invalidate_ref_frames): THIS frame is the
|
||||
// first one encoded after the invalidation — the clean re-anchor. A simultaneous
|
||||
// forced IDR is itself the re-anchor, so the tag is dropped in that case.
|
||||
let anchor = std::mem::take(&mut self.pending_anchor) && flags == 0;
|
||||
let mut pic = nv::NV_ENC_PIC_PARAMS {
|
||||
version: nv::NV_ENC_PIC_PARAMS_VER,
|
||||
inputWidth: self.width,
|
||||
@@ -1348,7 +1376,7 @@ impl Encoder for NvencD3d11Encoder {
|
||||
// built from the source display's metadata. Any decoder — incl. stock Moonlight — then
|
||||
// tone-maps from the real grade. HEVC/H.264 carry SEI; AV1 uses metadata OBUs (follow-up).
|
||||
// The scratch buffers must outlive `encode_picture`, so they live in this scope.
|
||||
let is_idr = flags != 0 || pts == 0;
|
||||
let is_idr = flags != 0 || opening;
|
||||
let mastering_sei = self
|
||||
.hdr_meta
|
||||
.map(|m| crate::hdr::hevc_mastering_display_sei(&m));
|
||||
@@ -1390,8 +1418,12 @@ impl Encoder for NvencD3d11Encoder {
|
||||
(api().encode_picture)(self.encoder, &mut pic)
|
||||
.nv_ok()
|
||||
.map_err(|e| anyhow!("encode_picture: {e:?}"))?;
|
||||
self.pending
|
||||
.push_back((self.bitstreams[slot], mp.mappedResource, captured.pts_ns));
|
||||
self.pending.push_back((
|
||||
self.bitstreams[slot],
|
||||
mp.mappedResource,
|
||||
captured.pts_ns,
|
||||
anchor,
|
||||
));
|
||||
// Async: hand the in-flight encode to the retrieve thread (channel capacity = POOL ≥
|
||||
// in-flight, so this send never blocks). The pending entry above pairs with its
|
||||
// completion FIFO in `absorb_done`.
|
||||
@@ -1408,6 +1440,16 @@ impl Encoder for NvencD3d11Encoder {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pin this submission's frame number (= its `inputTimeStamp`) to the wire frame index the AU
|
||||
/// will carry, so the DPB timestamps `invalidate_ref_frames` matches client frame numbers
|
||||
/// against are the wire's — 1:1 across every rebuild/reset (see the trait doc). Within a
|
||||
/// session the loop's prediction is nondecreasing; a repeat after a reset lands on a fresh
|
||||
/// session (teardown cleared the DPB and `last_rfi_range`), so re-pinning is always sound.
|
||||
fn submit_indexed(&mut self, frame: &CapturedFrame, wire_index: u32) -> Result<()> {
|
||||
self.frame_idx = wire_index as i64;
|
||||
self.submit(frame)
|
||||
}
|
||||
|
||||
fn request_keyframe(&mut self) {
|
||||
self.force_kf = true;
|
||||
}
|
||||
@@ -1424,6 +1466,8 @@ impl Encoder for NvencD3d11Encoder {
|
||||
// The direct-NVENC path recovers via real RFI (or a forced IDR), not the Linux
|
||||
// libavcodec intra-refresh mode.
|
||||
intra_refresh: false,
|
||||
intra_refresh_recovery: false,
|
||||
intra_refresh_period: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1439,9 +1483,13 @@ impl Encoder for NvencD3d11Encoder {
|
||||
if self.encoder.is_null() || !self.rfi_supported || first < 0 || first > last {
|
||||
return false;
|
||||
}
|
||||
// Already invalidated a covering range for this loss event — nothing more to do, no IDR.
|
||||
// Already invalidated a covering range for this loss event — no new driver calls needed,
|
||||
// no IDR. RE-ARM the anchor though: the client re-asking means the previous recovery
|
||||
// anchor AU may itself have been lost, and the next frame is just as clean a re-anchor
|
||||
// (it too references only valid frames).
|
||||
if let Some((pf, pl)) = self.last_rfi_range {
|
||||
if first >= pf && last <= pl {
|
||||
self.pending_anchor = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1457,9 +1505,11 @@ impl Encoder for NvencD3d11Encoder {
|
||||
if first > last {
|
||||
return false;
|
||||
}
|
||||
// We tag each input with `inputTimeStamp = frame_idx` (0,1,2,…), which is also the client's
|
||||
// frame number (the packetizer numbers frames in submit order), so the client's lost-frame
|
||||
// range maps 1:1 onto the timestamps NVENC invalidates here.
|
||||
// Each input's `inputTimeStamp` is `frame_idx`, which `submit_indexed` pins to the WIRE
|
||||
// frame index the AU carries — so the client's lost-frame range maps 1:1 onto the
|
||||
// timestamps NVENC invalidates here, and stays 1:1 across encoder rebuilds/resets (an
|
||||
// internal counter would desync on the first adaptive-bitrate rebuild and RFI would then
|
||||
// clamp every range into first > last, silently degrading to IDR-only forever).
|
||||
// SAFETY: `invalidate_ref_frames` is a function pointer from the runtime-loaded `EncodeApi` table.
|
||||
// `self.encoder` was checked non-null at the top of this fn and is the live session; this runs
|
||||
// on the encode thread (like submit/poll), so there is no concurrent NVENC use. Each `ts` was
|
||||
@@ -1477,6 +1527,11 @@ impl Encoder for NvencD3d11Encoder {
|
||||
}
|
||||
}
|
||||
self.last_rfi_range = Some((first, last));
|
||||
// The next submitted frame is the first one encoded after the invalidation — the clean
|
||||
// re-anchor P-frame. Arm the tag so its AU ships with `recovery_anchor` and the client
|
||||
// lifts its post-loss freeze on it (instead of waiting ~1 s for the cooldown-suppressed
|
||||
// IDR fallback).
|
||||
self.pending_anchor = true;
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1501,7 +1556,7 @@ impl Encoder for NvencD3d11Encoder {
|
||||
.ready
|
||||
.pop_front());
|
||||
}
|
||||
let Some((bs, map, pts_ns)) = self.pending.pop_front() else {
|
||||
let Some((bs, map, pts_ns, anchor)) = self.pending.pop_front() else {
|
||||
return Ok(None);
|
||||
};
|
||||
// SAFETY: a non-empty `pending` implies `submit` ran, so `self.encoder` is the live session
|
||||
@@ -1542,10 +1597,28 @@ impl Encoder for NvencD3d11Encoder {
|
||||
data,
|
||||
pts_ns,
|
||||
keyframe,
|
||||
recovery_anchor: anchor,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode-stall recovery: tear the whole session down (the same teardown a capture-device
|
||||
/// change uses) and let the next `submit` rebuild it lazily on the current device — the owed
|
||||
/// AUs are forfeited and the fresh session opens on an IDR. Gives the encode-stall watchdog a
|
||||
/// healing lever on NVENC instead of ending the session. Caveat: the SYNC retrieve mode blocks
|
||||
/// inside `lock_bitstream`, so a driver wedge that hangs the lock never returns to the loop
|
||||
/// for the watchdog to fire — this lever fully protects the async retrieve mode (5 s event
|
||||
/// timeouts surface as poll errors) and the submit-side failure paths.
|
||||
fn reset(&mut self) -> bool {
|
||||
// SAFETY: `teardown` (an `unsafe fn`) requires the encode thread with no NVENC call in
|
||||
// flight and a session whose cached resources belong to `self.encoder` — all hold here
|
||||
// (reset is called from the session loop between submit/poll, like every other method),
|
||||
// and it early-returns on an already-null session.
|
||||
unsafe { self.teardown() };
|
||||
self.force_kf = true;
|
||||
true
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Result<()> {
|
||||
Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain.
|
||||
}
|
||||
|
||||
@@ -436,7 +436,10 @@ fn sendmmsg_all(sock: &UdpSocket, pkts: &[Vec<u8>]) -> std::io::Result<()> {
|
||||
/// behind encode (measured ~3 ms/frame at 4K, which capped GameStream's frame rate well below what
|
||||
/// the encoder alone can sustain).
|
||||
struct RawFrame {
|
||||
aus: Vec<(Vec<u8>, FrameType)>,
|
||||
/// `(bitstream, type, wire frameIndex)` per AU. The stream loop assigns the index (it owns
|
||||
/// the numbering — see its `au_seq`), so the encoder's RFI bookkeeping stays 1:1 with what
|
||||
/// Moonlight sees across mid-stream encoder rebuilds.
|
||||
aus: Vec<(Vec<u8>, FrameType, u32)>,
|
||||
ts: u32,
|
||||
}
|
||||
|
||||
@@ -460,8 +463,8 @@ fn spawn_packetizer(
|
||||
crate::punktfunk1::boost_thread_priority(false);
|
||||
while let Ok(frame) = rx.recv() {
|
||||
let mut batch: PacketBatch = Vec::new();
|
||||
for (au, ft) in frame.aus {
|
||||
batch.extend(pk.packetize(&au, ft, frame.ts));
|
||||
for (au, ft, idx) in frame.aus {
|
||||
batch.extend(pk.packetize(&au, ft, frame.ts, Some(idx)));
|
||||
}
|
||||
if batch.is_empty() {
|
||||
continue;
|
||||
@@ -660,6 +663,16 @@ fn stream_body(
|
||||
// routed through the same coalesce gate as client IDR requests so a burst of drops (congestion)
|
||||
// can't become an IDR storm.
|
||||
let mut recover_after_drop = false;
|
||||
// The stream's wire frameIndex numbering, owned HERE (the index of the next AU handed to the
|
||||
// packetizer thread; a dropped-at-the-queue frame consumes none). A submission's future index
|
||||
// is `au_seq + enc_inflight` (AUs are emitted FIFO, one per submission); passing it to
|
||||
// `Encoder::submit_indexed` keeps the encoder's RFI bookkeeping 1:1 with Moonlight's frame
|
||||
// numbers across the in-place encoder rebuild above (an internal counter would desync there).
|
||||
// A pipeline-head drop desyncs the prediction by the dropped AU count for the frames already
|
||||
// in flight — bounded and self-healing: the drop arms `recover_after_drop`, whose forced IDR
|
||||
// resets the encoder's reference state (stale LTR/DPB bookkeeping dies with it).
|
||||
let mut au_seq: u32 = 0;
|
||||
let mut enc_inflight: u32 = 0;
|
||||
|
||||
while running.load(Ordering::SeqCst) {
|
||||
let tick = Instant::now();
|
||||
@@ -728,6 +741,10 @@ fn stream_body(
|
||||
enc.request_keyframe();
|
||||
last_keyframe = Some(Instant::now());
|
||||
next_frame = Instant::now();
|
||||
// The old encoder died with its in-flight submissions — their AUs will never
|
||||
// arrive, so the numbering prediction restarts at `au_seq` (the fresh encoder's
|
||||
// reference state is empty, so the reused predictions meet no stale bookkeeping).
|
||||
enc_inflight = 0;
|
||||
tracing::info!("gamestream: source rebuilt — stream continues");
|
||||
continue;
|
||||
}
|
||||
@@ -742,7 +759,13 @@ fn stream_body(
|
||||
if let Some((first, last)) = rfi_range.lock().unwrap().take() {
|
||||
// Prefer reference-frame invalidation when the encoder supports it (no costly IDR
|
||||
// spike); otherwise — or if the range is too old to invalidate — fall back to a keyframe.
|
||||
if !(supports_rfi && enc.invalidate_ref_frames(first, last)) {
|
||||
// Sanity-cap the range first: wider than RFI_MAX_RANGE exceeds any encoder's reference
|
||||
// history (or is a phantom range from a desynced counter) — keyframe, never a
|
||||
// force-reference that could ship corruption as a clean frame.
|
||||
let width = (last as u32).wrapping_sub(first as u32);
|
||||
if width > punktfunk_core::packet::RFI_MAX_RANGE
|
||||
|| !(supports_rfi && enc.invalidate_ref_frames(first, last))
|
||||
{
|
||||
want_keyframe = true;
|
||||
}
|
||||
}
|
||||
@@ -766,21 +789,27 @@ fn stream_body(
|
||||
tracing::debug!("video: keyframe request coalesced (IDR still in flight)");
|
||||
}
|
||||
}
|
||||
enc.submit(&frame).context("encoder submit")?;
|
||||
enc.submit_indexed(&frame, au_seq.wrapping_add(enc_inflight))
|
||||
.context("encoder submit")?;
|
||||
enc_inflight = enc_inflight.wrapping_add(1);
|
||||
let t_enc = tick.elapsed();
|
||||
|
||||
// 90 kHz RTP timestamp from wall-clock, so a variable capture rate stays correct.
|
||||
let ts = (stream_start.elapsed().as_secs_f64() * 90_000.0) as u32;
|
||||
// Drain the encoder's access units (owned buffers) — FEC/packetization runs on the
|
||||
// packetizer thread, off this loop, so it never serializes behind encode.
|
||||
let mut aus: Vec<(Vec<u8>, FrameType)> = Vec::new();
|
||||
// packetizer thread, off this loop, so it never serializes behind encode. Each AU is
|
||||
// stamped with its wire frameIndex here (`au_seq + position`); the numbering only
|
||||
// ADVANCES if the batch is actually enqueued below (a dropped batch consumes none).
|
||||
let mut aus: Vec<(Vec<u8>, FrameType, u32)> = Vec::new();
|
||||
while let Some(au) = enc.poll().context("encoder poll")? {
|
||||
let ft = if au.keyframe {
|
||||
FrameType::Idr
|
||||
} else {
|
||||
FrameType::P
|
||||
};
|
||||
aus.push((au.data, ft));
|
||||
let idx = au_seq.wrapping_add(aus.len() as u32);
|
||||
aus.push((au.data, ft, idx));
|
||||
enc_inflight = enc_inflight.saturating_sub(1);
|
||||
}
|
||||
let t_pkt = tick.elapsed();
|
||||
|
||||
@@ -788,9 +817,11 @@ fn stream_body(
|
||||
// (packetizer, or the paced sender behind it) is behind — drop this frame (FEC/RFI covers the
|
||||
// client) and keep encoding, so a downstream stall can never cap the encode rate.
|
||||
if !aus.is_empty() {
|
||||
let batch_len = aus.len() as u32;
|
||||
match raw_tx.try_send(RawFrame { aus, ts }) {
|
||||
Ok(()) => {
|
||||
sent_batches += 1;
|
||||
au_seq = au_seq.wrapping_add(batch_len);
|
||||
}
|
||||
Err(std::sync::mpsc::TrySendError::Full(_)) => {
|
||||
dropped_batches += 1;
|
||||
|
||||
@@ -69,14 +69,22 @@ impl VideoPacketizer {
|
||||
}
|
||||
|
||||
/// Packetize one encoded AU into wire datagrams (data shards + Cauchy RS parity shards).
|
||||
///
|
||||
/// `frame_index`: `Some(i)` stamps the caller's index (the stream loop owns the numbering so
|
||||
/// the encoder's RFI bookkeeping stays 1:1 with the wire across mid-stream encoder rebuilds —
|
||||
/// see `Encoder::submit_indexed`); `None` draws from the internal counter (tests/harnesses).
|
||||
pub fn packetize(
|
||||
&mut self,
|
||||
au: &[u8],
|
||||
frame_type: FrameType,
|
||||
timestamp_90k: u32,
|
||||
frame_index: Option<u32>,
|
||||
) -> Vec<Vec<u8>> {
|
||||
let frame_index = self.frame_index;
|
||||
self.frame_index = self.frame_index.wrapping_add(1);
|
||||
let frame_index = frame_index.unwrap_or_else(|| {
|
||||
let i = self.frame_index;
|
||||
self.frame_index = i.wrapping_add(1);
|
||||
i
|
||||
});
|
||||
let pps = self.payload_per_shard;
|
||||
let blocksize = SHARD_HEADER + pps; // = packet_size + 16
|
||||
let pct = self.fec_percentage;
|
||||
@@ -235,7 +243,7 @@ mod tests {
|
||||
let mut pk = VideoPacketizer::new(1392, 0, 0); // data-only; pps = 1392+16-32 = 1376
|
||||
assert_eq!(pk.payload_per_shard, 1376);
|
||||
let au = vec![0xABu8; 4000]; // 8+4000 = 4008 → ceil(4008/1376) = 3 data shards
|
||||
let pkts = pk.packetize(&au, FrameType::Idr, 90_000);
|
||||
let pkts = pk.packetize(&au, FrameType::Idr, 90_000, None);
|
||||
assert_eq!(pkts.len(), 3);
|
||||
for p in &pkts {
|
||||
assert_eq!(p.len(), SHARD_HEADER + 1376);
|
||||
@@ -266,7 +274,7 @@ mod tests {
|
||||
for ps in [0usize, 15, 16, 17, 32] {
|
||||
let mut pk = VideoPacketizer::new(ps, 20, 2);
|
||||
assert!(pk.payload_per_shard >= 1, "pps must never be 0 (ps={ps})");
|
||||
let _ = pk.packetize(&[0xCDu8; 200], FrameType::Idr, 0); // must not panic
|
||||
let _ = pk.packetize(&[0xCDu8; 200], FrameType::Idr, 0, None); // must not panic
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,7 +282,7 @@ mod tests {
|
||||
fn multi_block_split() {
|
||||
let mut pk = VideoPacketizer::new(1392, 0, 0); // data-only
|
||||
let au = vec![0u8; 600_000];
|
||||
let pkts = pk.packetize(&au, FrameType::P, 0);
|
||||
let pkts = pk.packetize(&au, FrameType::P, 0, None);
|
||||
let total = (8 + au.len()).div_ceil(1376);
|
||||
assert_eq!(pkts.len(), total);
|
||||
let n_blocks = total.div_ceil(255).clamp(1, 4);
|
||||
@@ -286,7 +294,7 @@ mod tests {
|
||||
fn emits_parity_shards() {
|
||||
let mut pk = VideoPacketizer::new(1392, 20, 0); // pps = 1376, 20% FEC
|
||||
let au = vec![0xABu8; 4000]; // 8+4000 = 4008 → 3 data shards (k=3)
|
||||
let pkts = pk.packetize(&au, FrameType::Idr, 0);
|
||||
let pkts = pk.packetize(&au, FrameType::Idr, 0, None);
|
||||
// m = ceil(3*20/100) = 1 parity shard → 4 packets; wire_pct = 100*1/3 = 33.
|
||||
assert_eq!(pkts.len(), 4);
|
||||
for p in &pkts {
|
||||
@@ -313,7 +321,7 @@ mod tests {
|
||||
fn parity_recovers_full_datagram_incl_flags() {
|
||||
let mut pk = VideoPacketizer::new(1392, 50, 0); // high pct → plenty of parity
|
||||
let au = vec![0x5Au8; 4000]; // k = 3
|
||||
let pkts = pk.packetize(&au, FrameType::Idr, 0);
|
||||
let pkts = pk.packetize(&au, FrameType::Idr, 0, None);
|
||||
let k = 3usize;
|
||||
let m = pkts.len() - k;
|
||||
assert!(m >= 1);
|
||||
|
||||
@@ -687,6 +687,88 @@ fn alloc_pitched_nv12(
|
||||
Ok(((y_ptr, y_pitch), (uv_ptr, uv_pitch)))
|
||||
}
|
||||
|
||||
/// Allocate ONE pitched buffer holding a *contiguous* NV12 surface — Y rows `[0, H)` immediately
|
||||
/// followed by interleaved-chroma rows `[H, 3H/2)`, all at the driver's single pitch. Unlike
|
||||
/// [`alloc_pitched_nv12`] (two separate allocations, the capture/IPC layout) this is the layout the
|
||||
/// direct-SDK NVENC encoder registers as a single `CUDADEVICEPTR` input: NVENC reads the UV plane
|
||||
/// at `ptr + pitch*height`. Used only by [`InputSurface`] (encode side), never the wire.
|
||||
fn alloc_pitched_nv12_contiguous(width: u32, height: u32) -> Result<(CUdeviceptr, usize)> {
|
||||
let mut ptr: CUdeviceptr = 0;
|
||||
let mut pitch: usize = 0;
|
||||
// Y is `width` bytes/row × H rows; the interleaved chroma plane is W/2 samples × 2 bytes =
|
||||
// `width` bytes/row × H/2 rows. One allocation of `H + H/2` rows keeps them contiguous under a
|
||||
// single pitch so NVENC finds UV at `ptr + pitch*H`.
|
||||
let rows = height as usize + (height as usize / 2).max(1);
|
||||
// SAFETY: `cuMemAllocPitch_v2` (wrapper → live table) writes the allocation pointer and pitch
|
||||
// into the two live, distinct stack out-params `&mut ptr`/`&mut pitch`, which outlive the
|
||||
// synchronous call; width/rows/element-size are by-value ints. No aliasing.
|
||||
unsafe {
|
||||
ck(
|
||||
cuMemAllocPitch_v2(&mut ptr, &mut pitch, width as usize, rows, 16),
|
||||
"cuMemAllocPitch_v2(NV12 contiguous)",
|
||||
)?;
|
||||
}
|
||||
Ok((ptr, pitch))
|
||||
}
|
||||
|
||||
/// An encoder-owned, contiguous pitched CUDA surface that the direct-SDK NVENC Linux backend
|
||||
/// (`encode/linux/nvenc_cuda.rs`, design/linux-direct-nvenc.md) registers **once** as a
|
||||
/// `NV_ENC_INPUT_RESOURCE_TYPE_CUDADEVICEPTR` input and copies each captured frame into (via the
|
||||
/// `copy_*_to_device` helpers) before `encode_picture`. Distinct from [`DeviceBuffer`]: these are
|
||||
/// laid out exactly as NVENC's single-pointer register expects — NV12 = Y then interleaved-UV under
|
||||
/// one pitch, YUV444 = Y|U|V stacked, RGB = packed 4-byte — and are never pooled or sent on the
|
||||
/// wire. Frees its allocation on drop (context made current first, since drop may run off-thread).
|
||||
pub struct InputSurface {
|
||||
/// Base device pointer NVENC registers. For NV12 the chroma plane lives at `ptr + pitch*height`;
|
||||
/// for YUV444 the U/V planes at `ptr + pitch*height` / `ptr + 2*pitch*height`.
|
||||
pub ptr: CUdeviceptr,
|
||||
/// Row stride in bytes (the driver's pitch), shared by every plane of the surface.
|
||||
pub pitch: usize,
|
||||
/// Luma height in rows — the plane stride multiplier NVENC / the copy helpers key off.
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
impl InputSurface {
|
||||
/// Contiguous NV12 (8-bit 4:2:0): one allocation, Y then interleaved UV under one pitch.
|
||||
pub fn alloc_nv12(width: u32, height: u32) -> Result<InputSurface> {
|
||||
let (ptr, pitch) = alloc_pitched_nv12_contiguous(width, height)?;
|
||||
Ok(InputSurface { ptr, pitch, height })
|
||||
}
|
||||
|
||||
/// Planar YUV444 (8-bit 4:4:4): one allocation, Y|U|V full-res planes stacked (see
|
||||
/// [`alloc_pitched_yuv444`]).
|
||||
pub fn alloc_yuv444(width: u32, height: u32) -> Result<InputSurface> {
|
||||
let (ptr, pitch) = alloc_pitched_yuv444(width, height)?;
|
||||
Ok(InputSurface { ptr, pitch, height })
|
||||
}
|
||||
|
||||
/// Packed 4-byte RGB/BGRx: one contiguous pitched allocation (NVENC does the internal CSC when
|
||||
/// registered as an `ABGR`/`ARGB` input).
|
||||
pub fn alloc_rgb(width: u32, height: u32) -> Result<InputSurface> {
|
||||
let (ptr, pitch) = alloc_pitched(width, height)?;
|
||||
Ok(InputSurface { ptr, pitch, height })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InputSurface {
|
||||
fn drop(&mut self) {
|
||||
if self.ptr == 0 {
|
||||
return;
|
||||
}
|
||||
// SAFETY: this surface exclusively owns `self.ptr` (a single `cuMemAllocPitch_v2` allocation
|
||||
// from one of the constructors above), freed exactly once here — `drop` runs once and the
|
||||
// `ptr == 0` guard skips a moved-out/empty surface, so no double-free. The shared context is
|
||||
// made current first because drop may run on a thread where it isn't, and `cuMemFree_v2`
|
||||
// needs it. Wrapper → live table; result ignored (best-effort teardown).
|
||||
unsafe {
|
||||
if let Some(c) = CONTEXT.get() {
|
||||
let _ = cuCtxSetCurrent(c.0);
|
||||
}
|
||||
let _ = cuMemFree_v2(self.ptr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Free-list of recycled device allocations for one resolution. Shared (via `Arc`) between the
|
||||
/// capture thread that hands out buffers and the encode thread where a [`DeviceBuffer`] drops and
|
||||
/// returns its allocation here. Bulk-freed when the last reference drops. For NV12 each free entry
|
||||
|
||||
@@ -20,6 +20,11 @@
|
||||
|
||||
mod audio;
|
||||
mod capture;
|
||||
/// Host-side shared-clipboard backend. The wire protocol + client live in `punktfunk-core`; this
|
||||
/// drives the host session's real clipboard (`design/clipboard-and-file-transfer.md` §4). Linux uses
|
||||
/// Wayland data-control / Mutter; Windows uses the Win32 clipboard (delayed rendering).
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
mod clipboard;
|
||||
mod config;
|
||||
mod discovery;
|
||||
mod wol;
|
||||
@@ -696,10 +701,14 @@ fn parse_spike(args: &[String]) -> Result<Options> {
|
||||
"--source" => {
|
||||
source = match next()?.as_str() {
|
||||
"synthetic" => Source::Synthetic,
|
||||
"synthetic-nv12" => Source::SyntheticNv12,
|
||||
"portal" => Source::Portal,
|
||||
"kwin-virtual" => Source::KwinVirtual,
|
||||
other => {
|
||||
bail!("unknown --source '{other}' (synthetic|portal|kwin-virtual)")
|
||||
bail!(
|
||||
"unknown --source '{other}' \
|
||||
(synthetic|synthetic-nv12|portal|kwin-virtual)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1008,11 +1008,11 @@ struct DisplaySettingsState {
|
||||
|
||||
fn preset_summary(id: &str) -> &'static str {
|
||||
match id {
|
||||
"default" => "Today's behavior: a short linger absorbs reconnects, the streamed output is the sole desktop, extra clients get their own view.",
|
||||
"gaming-rig" => "Dedicated couch/headless box: the game and its display survive disconnects; whoever connects takes the box over.",
|
||||
"shared-desktop" => "A desktop you also use in person: never blank the real monitors, never keep ghost displays, concurrent viewers each get a view.",
|
||||
"hotdesk" => "One user at a time with fast reattach; a second user is told the box is busy; each device+resolution keeps its own scaling.",
|
||||
"workstation" => "Multi-monitor daily driver: your displays come back exactly where you arranged them, per-client identity, exclusive.",
|
||||
"default" => "Good for most setups. Reconnects resume quickly, the stream is the whole desktop, and extra viewers each get their own screen.",
|
||||
"gaming-rig" => "For a machine with no monitor that you only stream from. The game keeps running when you disconnect, and whoever connects next takes it over.",
|
||||
"shared-desktop" => "For a PC you also use in person. Your real monitors are never blanked or left with a leftover display, and extra viewers each get their own screen.",
|
||||
"hotdesk" => "One person at a time — roam between your own devices with an instant reconnect. Anyone else is told the box is busy.",
|
||||
"workstation" => "Your multi-monitor daily driver. Displays come back exactly where you arranged them, each client keeps its own settings, and the desktop is yours alone.",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,12 +26,16 @@ pub fn pump_once(
|
||||
data,
|
||||
pts_ns,
|
||||
keyframe,
|
||||
recovery_anchor,
|
||||
}) = encoder.poll()?
|
||||
{
|
||||
let mut flags = FLAG_PIC as u32;
|
||||
if keyframe {
|
||||
flags |= FLAG_SOF as u32;
|
||||
}
|
||||
if recovery_anchor {
|
||||
flags |= punktfunk_core::packet::USER_FLAG_RECOVERY_ANCHOR;
|
||||
}
|
||||
// core does FEC + packetize + pace + send.
|
||||
session.submit_frame(&data, pts_ns, flags)?;
|
||||
}
|
||||
|
||||
@@ -32,9 +32,10 @@ use punktfunk_core::config::{
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use punktfunk_core::packet::{FLAG_PIC, FLAG_PROBE, FLAG_SOF};
|
||||
use punktfunk_core::quic::{
|
||||
endpoint, io, BitrateChanged, ClockEcho, ClockProbe, ColorInfo, Hello, LossReport,
|
||||
PairChallenge, PairProof, PairRequest, PairResult, ProbeRequest, ProbeResult, Reconfigure,
|
||||
Reconfigured, RequestKeyframe, SetBitrate, Start, Welcome,
|
||||
endpoint, io, BitrateChanged, ClipControl, ClipFetchHdr, ClipOffer, ClipState, ClockEcho,
|
||||
ClockProbe, ColorInfo, Hello, LossReport, PairChallenge, PairProof, PairRequest, PairResult,
|
||||
ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, RfiRequest, SetBitrate,
|
||||
Start, Welcome,
|
||||
};
|
||||
use punktfunk_core::transport::UdpTransport;
|
||||
use punktfunk_core::Session;
|
||||
@@ -476,6 +477,86 @@ fn fec_static_override() -> Option<u8> {
|
||||
.map(|p| p.min(90))
|
||||
}
|
||||
|
||||
/// 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 [`crate::clipboard::session`]
|
||||
/// coordinator (Linux data-control backend) starts. `off` keeps the whole feature dark.
|
||||
fn clipboard_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`).
|
||||
fn clipboard_enabled() -> bool {
|
||||
clipboard_policy().is_some()
|
||||
}
|
||||
|
||||
/// A command from the session control loop into the host clipboard coordinator
|
||||
/// (`crate::clipboard::session`, Linux data-control). Defined here — portable — so the control loop
|
||||
/// compiles on every host platform; the coordinator that consumes it is Linux-only.
|
||||
pub(crate) 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.
|
||||
struct ClipCoord {
|
||||
/// Whether a real backend is live. `false` on gamescope / older GNOME / non-Linux; the control
|
||||
/// loop then answers an enable request with `CLIP_REASON_BACKEND_UNAVAILABLE` and a defensive
|
||||
/// decline loop handles any stray fetch stream.
|
||||
available: bool,
|
||||
cmd_tx: tokio::sync::mpsc::UnboundedSender<ClipCoordCmd>,
|
||||
/// Host-copy announcements from the coordinator → control loop → client.
|
||||
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.
|
||||
async fn start_clip_coord(
|
||||
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 && clipboard_enabled() {
|
||||
crate::clipboard::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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Adaptive-FEC band + starting point. Every recovery shard is extra wire bytes AND an extra
|
||||
/// packet, so on a clean link FEC decays toward [`FEC_MIN`] (fewer packets — the win for a
|
||||
/// packet-rate-bound uplink like the Steam Deck's WiFi tx); loss ramps it toward [`FEC_MAX`].
|
||||
@@ -1069,8 +1150,19 @@ async fn serve_session(
|
||||
// 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 (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.
|
||||
host_caps: punktfunk_core::quic::HOST_CAP_GAMEPAD_STATE
|
||||
| if clipboard_enabled() && cfg!(any(target_os = "linux", target_os = "windows")) {
|
||||
punktfunk_core::quic::HOST_CAP_CLIPBOARD
|
||||
} else {
|
||||
0
|
||||
},
|
||||
};
|
||||
io::write_msg(&mut send, &welcome.encode()).await?;
|
||||
|
||||
@@ -1124,6 +1216,10 @@ async fn serve_session(
|
||||
// (inbound requests, outbound probe results) are multiplexed with `select!`.
|
||||
let (reconfig_tx, reconfig_rx) = std::sync::mpsc::channel::<punktfunk_core::Mode>();
|
||||
let (keyframe_tx, keyframe_rx) = std::sync::mpsc::channel::<()>();
|
||||
// Client LTR-RFI recovery: the control task forwards each `RfiRequest`'s lost-frame range here;
|
||||
// the encode loop prefers `Encoder::invalidate_ref_frames` (a clean re-anchor P-frame) over a
|
||||
// full IDR when the encoder supports it (native-AMF LTR / Windows NVENC).
|
||||
let (rfi_tx, rfi_rx) = std::sync::mpsc::channel::<(u32, u32)>();
|
||||
let (bitrate_tx, bitrate_rx) = std::sync::mpsc::channel::<u32>();
|
||||
let (probe_tx, probe_rx) = std::sync::mpsc::channel::<ProbeRequest>();
|
||||
let (probe_result_tx, mut probe_result_rx) =
|
||||
@@ -1141,8 +1237,26 @@ async fn serve_session(
|
||||
let adaptive_fec = fec_static_override().is_none();
|
||||
let fec_target = Arc::new(AtomicU8::new(welcome.fec.fec_percent));
|
||||
let fec_target_ctl = fec_target.clone();
|
||||
// Shared-clipboard enable state (client `ClipControl` → host). The coordinator reads it to decide
|
||||
// whether to forward host copies; the control loop flips it on each `ClipControl`.
|
||||
let clip_enabled = Arc::new(AtomicBool::new(false));
|
||||
let clip_enabled_ctl = clip_enabled.clone();
|
||||
// Start the host clipboard coordinator (Linux data-control backend). On success it watches the
|
||||
// session clipboard, forwards host copies as `ClipOffer`s (`clip_offer_rx` → this control loop →
|
||||
// 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 / non-Linux) — the
|
||||
// control loop then answers `ClipControl` with `BACKEND_UNAVAILABLE` and the defensive decline
|
||||
// loop below handles stray fetch streams.
|
||||
let ClipCoord {
|
||||
available: clip_available,
|
||||
cmd_tx: clip_cmd_tx,
|
||||
offer_rx: mut clip_offer_rx,
|
||||
} = start_clip_coord(conn.clone(), clip_enabled.clone(), compositor.is_some()).await;
|
||||
tokio::spawn(async move {
|
||||
let mut active = hello.mode;
|
||||
// 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;
|
||||
// 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
|
||||
// coalesces a well-behaved resize drag; compliant clients self-limit to ≥ 1 s).
|
||||
@@ -1199,6 +1313,19 @@ async fn serve_session(
|
||||
if keyframe_tx.send(()).is_err() {
|
||||
break; // data plane gone
|
||||
}
|
||||
} else if let Ok(req) = RfiRequest::decode(&msg) {
|
||||
// Client LTR-RFI recovery: it lost the frame range `[first, last]` and asks
|
||||
// the encoder to re-reference a known-good older frame instead of paying for
|
||||
// a full IDR. The encode loop attempts `invalidate_ref_frames`, falling back
|
||||
// to a coalesced keyframe when the encoder can't (range too old / no RFI).
|
||||
tracing::debug!(
|
||||
first = req.first_frame,
|
||||
last = req.last_frame,
|
||||
"client requested reference-frame invalidation (loss recovery)"
|
||||
);
|
||||
if rfi_tx.send((req.first_frame, req.last_frame)).is_err() {
|
||||
break; // data plane gone
|
||||
}
|
||||
} else if let Ok(rep) = LossReport::decode(&msg) {
|
||||
// Adaptive FEC: size recovery to the loss the client is seeing. The data-plane
|
||||
// send loop reads `fec_target_ctl` and applies it per frame. Ignored when FEC
|
||||
@@ -1268,6 +1395,44 @@ async fn serve_session(
|
||||
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 = 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_ctl.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");
|
||||
}
|
||||
@@ -1278,6 +1443,21 @@ async fn serve_session(
|
||||
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_ctl.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
|
||||
@@ -1359,6 +1539,43 @@ async fn serve_session(
|
||||
);
|
||||
});
|
||||
|
||||
// Clipboard fetch-stream accept loop (design/clipboard-and-file-transfer.md §3.3, §4.2). When a
|
||||
// backend is live the coordinator (spawned above) owns `accept_bi` and serves real host
|
||||
// clipboard bytes. This is the *fallback*: 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.
|
||||
if !clip_available && clipboard_enabled() {
|
||||
let clip_conn = conn.clone();
|
||||
tokio::spawn(async move {
|
||||
use punktfunk_core::quic::clipstream;
|
||||
while let Ok((mut send, mut recv)) = clip_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: punktfunk_core::quic::CLIP_FETCH_UNAVAILABLE,
|
||||
total_size: 0,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Stop signal: stream duration elapsed or the client went away.
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
// Deliberate-quit signal: set (before `stop`, so the display lease reads it on teardown) when the
|
||||
@@ -1523,6 +1740,10 @@ async fn serve_session(
|
||||
// and gets no extra datagrams.
|
||||
let timing_conn =
|
||||
(hello.video_caps & punktfunk_core::quic::VIDEO_CAP_HOST_TIMING != 0).then(|| conn.clone());
|
||||
// Probe-sequence capability: the client reassembles speed-test filler in its own index window,
|
||||
// so mid-session bursts don't consume video frame indexes. An older client (bit clear) gets
|
||||
// mid-session probes declined instead — see `run_probe_burst`.
|
||||
let probe_seq = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ != 0;
|
||||
let stats_dp = stats; // data-plane handle to the shared stats recorder
|
||||
// Short label for web-console stats captures: the client's cert-fingerprint prefix, else its
|
||||
// peer IP (no fingerprint = anonymous TOFU/--open client).
|
||||
@@ -1578,6 +1799,7 @@ async fn serve_session(
|
||||
&probe_result_tx,
|
||||
&fec_target_dp,
|
||||
timing_conn.as_ref(),
|
||||
probe_seq,
|
||||
),
|
||||
Punktfunk1Source::Virtual => {
|
||||
let compositor = compositor
|
||||
@@ -1590,6 +1812,7 @@ async fn serve_session(
|
||||
quit: quit_stream,
|
||||
reconfig: reconfig_rx,
|
||||
keyframe: keyframe_rx,
|
||||
rfi: rfi_rx,
|
||||
bitrate_rx,
|
||||
compositor,
|
||||
bitrate_kbps,
|
||||
@@ -1602,6 +1825,7 @@ async fn serve_session(
|
||||
fec_target: fec_target_dp,
|
||||
conn: conn_stream,
|
||||
timing_conn,
|
||||
probe_seq,
|
||||
stats: stats_dp,
|
||||
client_label,
|
||||
launch: launch_for_dp,
|
||||
@@ -2396,6 +2620,30 @@ fn audio_thread(
|
||||
tracing::warn!("punktfunk/1 audio requires Linux or Windows — session continues without it");
|
||||
}
|
||||
|
||||
/// Advance the intra-refresh wave position and decide whether this emitted AU is a wave boundary
|
||||
/// that should carry [`USER_FLAG_RECOVERY_POINT`](punktfunk_core::packet::USER_FLAG_RECOVERY_POINT).
|
||||
///
|
||||
/// `ir_wave_pos` counts frames since the last IDR/wave start; a real IDR re-phases it to 0 (an IDR
|
||||
/// restarts the encoder's wave AND is itself a clean anchor, so it is never additionally marked).
|
||||
/// Every `period`-th non-IDR AU is a boundary — the client lifts its post-loss freeze on the SECOND
|
||||
/// such mark. Pure so the marking cadence is unit-tested without a GPU (see the pump's use in the
|
||||
/// encode-poll loop).
|
||||
fn mark_recovery_boundary(ir_wave_pos: &mut u32, is_keyframe: bool, period: u32) -> bool {
|
||||
if is_keyframe {
|
||||
*ir_wave_pos = 0;
|
||||
false
|
||||
} else {
|
||||
*ir_wave_pos += 1;
|
||||
if *ir_wave_pos >= period {
|
||||
*ir_wave_pos = 0;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn synthetic_stream(
|
||||
session: &mut Session,
|
||||
frames: u32,
|
||||
@@ -2404,6 +2652,7 @@ fn synthetic_stream(
|
||||
probe_result_tx: &tokio::sync::mpsc::UnboundedSender<ProbeResult>,
|
||||
fec_target: &AtomicU8,
|
||||
timing_conn: Option<&quinn::Connection>,
|
||||
probe_seq: bool,
|
||||
) -> Result<()> {
|
||||
let interval = std::time::Duration::from_millis(1000 / 60);
|
||||
for idx in 0..frames {
|
||||
@@ -2412,7 +2661,7 @@ fn synthetic_stream(
|
||||
}
|
||||
apply_fec_target(session, fec_target);
|
||||
// Service speed-test probes between synthetic frames (loopback bandwidth tests).
|
||||
service_probes(session, stop, probe_rx, probe_result_tx);
|
||||
service_probes(session, stop, probe_rx, probe_result_tx, probe_seq);
|
||||
let data = test_frame(idx, 64 * 1024);
|
||||
let pts_ns = now_ns();
|
||||
session
|
||||
@@ -2754,9 +3003,34 @@ const MAX_PROBE_MS: u32 = 5_000;
|
||||
/// was actually offered so the client can compute delivery ratio (`received / bytes_sent`) and
|
||||
/// throughput. Video is paused for the duration (the caller's loop is blocked here) — a speed test
|
||||
/// is a deliberate, short interruption the client initiates.
|
||||
fn run_probe_burst(session: &mut Session, req: ProbeRequest, stop: &AtomicBool) -> ProbeResult {
|
||||
fn run_probe_burst(
|
||||
session: &mut Session,
|
||||
req: ProbeRequest,
|
||||
stop: &AtomicBool,
|
||||
probe_seq: bool,
|
||||
) -> ProbeResult {
|
||||
let target_kbps = req.target_kbps.min(MAX_PROBE_KBPS);
|
||||
let duration_ms = req.duration_ms.min(MAX_PROBE_MS);
|
||||
// Probe filler is sealed in the PROBE index space (its own frame counter — video indexes are
|
||||
// owned by the encode loop and must stay 1:1 with the encoder's RFI bookkeeping). A client
|
||||
// that didn't advertise VIDEO_CAP_PROBE_SEQ reassembles everything in one window and would
|
||||
// drop probe-space frames as stale against the video stream — measuring garbage — so its
|
||||
// mid-session probe is DECLINED (zeroed result) instead. Old sealing (probe filler consuming
|
||||
// video indexes) is not an option anymore: those indexes are invisible to every client gap
|
||||
// detector and read as a phantom multi-thousand-frame loss after the burst.
|
||||
if !probe_seq {
|
||||
tracing::info!(
|
||||
"declining speed-test probe: client predates VIDEO_CAP_PROBE_SEQ (its reassembler \
|
||||
cannot window probe-space frames)"
|
||||
);
|
||||
return ProbeResult {
|
||||
bytes_sent: 0,
|
||||
packets_sent: 0,
|
||||
duration_ms: 0,
|
||||
wire_packets_sent: 0,
|
||||
send_dropped: 0,
|
||||
};
|
||||
}
|
||||
if target_kbps == 0 || duration_ms == 0 {
|
||||
return ProbeResult {
|
||||
bytes_sent: 0,
|
||||
@@ -2790,8 +3064,9 @@ fn run_probe_burst(session: &mut Session, req: ProbeRequest, stop: &AtomicBool)
|
||||
let allowed = (start.elapsed().as_secs_f64() * bytes_per_sec as f64) as u64;
|
||||
if bytes_sent < allowed {
|
||||
// A full send buffer drops on WouldBlock/ENOBUFS (UdpTransport returns Ok) — that loss is
|
||||
// part of what the probe measures (it surfaces as send_dropped), so keep going.
|
||||
let _ = session.submit_frame(&filler, now_ns(), FLAG_PROBE as u32);
|
||||
// part of what the probe measures (it surfaces as send_dropped), so keep going. Sealed
|
||||
// in the probe index space (FLAG_PROBE + its own counter) — never a video frame_index.
|
||||
let _ = session.submit_probe_frame(&filler, now_ns());
|
||||
bytes_sent += chunk as u64;
|
||||
packets_sent += 1;
|
||||
} else {
|
||||
@@ -2822,15 +3097,17 @@ fn run_probe_burst(session: &mut Session, req: ProbeRequest, stop: &AtomicBool)
|
||||
}
|
||||
|
||||
/// Drain any pending speed-test requests and run each burst, replying with its [`ProbeResult`].
|
||||
/// Called once per data-plane loop iteration so a probe runs between frames.
|
||||
/// Called once per data-plane loop iteration so a probe runs between frames. `probe_seq` = the
|
||||
/// client advertised [`punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ`] (see [`run_probe_burst`]).
|
||||
fn service_probes(
|
||||
session: &mut Session,
|
||||
stop: &AtomicBool,
|
||||
probe_rx: &std::sync::mpsc::Receiver<ProbeRequest>,
|
||||
probe_result_tx: &tokio::sync::mpsc::UnboundedSender<ProbeResult>,
|
||||
probe_seq: bool,
|
||||
) {
|
||||
while let Ok(req) = probe_rx.try_recv() {
|
||||
let result = run_probe_burst(session, req, stop);
|
||||
let result = run_probe_burst(session, req, stop, probe_seq);
|
||||
let _ = probe_result_tx.send(result);
|
||||
}
|
||||
}
|
||||
@@ -2845,16 +3122,18 @@ fn service_probes(
|
||||
/// buffer → EAGAIN drop → under infinite GOP, a freeze until the next keyframe). With no slack
|
||||
/// (encode ≈ interval) the budget collapses to 0 and even the overflow goes out immediately, so
|
||||
/// this is never slower than unpaced.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paced_submit(
|
||||
session: &mut Session,
|
||||
data: &[u8],
|
||||
pts_ns: u64,
|
||||
flags: u32,
|
||||
frame_index: u32,
|
||||
deadline: std::time::Instant,
|
||||
burst_cap: usize,
|
||||
) -> Result<PaceStat> {
|
||||
let wires = session
|
||||
.seal_frame(data, pts_ns, flags)
|
||||
.seal_frame_at(data, pts_ns, flags, frame_index)
|
||||
.map_err(|e| anyhow!("seal_frame: {e:?}"))?;
|
||||
let mut refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect();
|
||||
// FEC/recovery test knob (PUNKTFUNK_VIDEO_DROP) — same knob the GameStream plane honors.
|
||||
@@ -2884,6 +3163,12 @@ struct FrameMsg {
|
||||
data: Vec<u8>,
|
||||
capture_ns: u64,
|
||||
flags: u32,
|
||||
/// The wire `frame_index` this AU is sealed with. Assigned by the encode loop's
|
||||
/// session-lifetime counter (`au_seq`) — the loop owns the video numbering so the index it
|
||||
/// PREDICTED at submit time (`au_seq + inflight`, handed to `Encoder::submit_indexed`) is
|
||||
/// exactly what the packetizer stamps, keeping the encoder's RFI bookkeeping 1:1 with the
|
||||
/// wire across encoder rebuilds/resets. Sealed via `Session::seal_frame_at`.
|
||||
frame_index: u32,
|
||||
/// When this frame's packets should have fully left (the next frame's due time) = the pacing
|
||||
/// budget. In the past when the send thread is behind → immediate send (catch up).
|
||||
deadline: std::time::Instant,
|
||||
@@ -3053,6 +3338,7 @@ fn delivered_mode(
|
||||
/// * a **per-client-mode identity** policy: the mode is part of the display-identity slot key, so a
|
||||
/// resize resolves a DIFFERENT slot (a fresh Windows monitor / a differently-named KWin output),
|
||||
/// defeating the policy — honest downgrade is to reject and let the client scale.
|
||||
///
|
||||
/// Every other compositor (and the synthetic protocol-test source) with the default identity accepts.
|
||||
fn reconfig_allowed(
|
||||
compositor: Option<crate::vdisplay::Compositor>,
|
||||
@@ -3075,6 +3361,9 @@ fn send_loop(
|
||||
// `Some` = the client advertised VIDEO_CAP_HOST_TIMING: emit one 0xCF datagram per AU right
|
||||
// after its last packet left the socket (capture→sent, the whole host pipeline incl. pacing).
|
||||
timing_conn: Option<quinn::Connection>,
|
||||
// The client advertised VIDEO_CAP_PROBE_SEQ — mid-session speed-test bursts may run in the
|
||||
// probe index space (else they're declined; see `run_probe_burst`).
|
||||
probe_seq: bool,
|
||||
) {
|
||||
boost_thread_priority(false); // transmit thread: above-normal (Apollo's encoder-thread level)
|
||||
let mut last_perf = std::time::Instant::now();
|
||||
@@ -3103,7 +3392,7 @@ fn send_loop(
|
||||
}
|
||||
// Probes run here (they need the Session); a burst pauses video — the encode thread blocks
|
||||
// on the full frame channel meanwhile, which is exactly the intended pause.
|
||||
service_probes(&mut session, &stop, &probe_rx, &probe_result_tx);
|
||||
service_probes(&mut session, &stop, &probe_rx, &probe_result_tx, probe_seq);
|
||||
// Adaptive FEC: pick up any new recovery target the control task set from client LossReports.
|
||||
apply_fec_target(&mut session, &fec_target);
|
||||
// Short timeout so we keep re-checking `stop` + probes when no frames are flowing.
|
||||
@@ -3113,6 +3402,7 @@ fn send_loop(
|
||||
&msg.data,
|
||||
msg.capture_ns,
|
||||
msg.flags,
|
||||
msg.frame_index,
|
||||
msg.deadline,
|
||||
burst_cap,
|
||||
) {
|
||||
@@ -3396,6 +3686,9 @@ struct SessionContext {
|
||||
reconfig: std::sync::mpsc::Receiver<punktfunk_core::Mode>,
|
||||
/// Client decode-recovery keyframe requests.
|
||||
keyframe: std::sync::mpsc::Receiver<()>,
|
||||
/// Client LTR-RFI recovery requests — the lost-frame range `(first, last)`. The encode loop
|
||||
/// prefers `Encoder::invalidate_ref_frames` over a full IDR when the encoder supports it.
|
||||
rfi: std::sync::mpsc::Receiver<(u32, u32)>,
|
||||
/// Accepted mid-stream bitrate changes (adaptive bitrate, already clamped) — the encoder
|
||||
/// alone is rebuilt in place at the new rate; capture + virtual output are untouched.
|
||||
bitrate_rx: std::sync::mpsc::Receiver<u32>,
|
||||
@@ -3427,6 +3720,12 @@ struct SessionContext {
|
||||
/// thread emits one 0xCF datagram per AU (capture→sent µs) on it, so the client can split its
|
||||
/// `host+network` latency stage. `None` = older client, no emission.
|
||||
timing_conn: Option<quinn::Connection>,
|
||||
/// The client advertised [`punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ`]: speed-test bursts may
|
||||
/// run mid-session in the probe index space (its reassembler keeps a separate probe window).
|
||||
/// `false` = older client whose single-window reassembler would drop probe-space frames as
|
||||
/// stale — mid-session probes are DECLINED for it (a zeroed [`ProbeResult`]) rather than
|
||||
/// consuming video frame indexes its gap detectors can't see (the phantom-gap freeze).
|
||||
probe_seq: bool,
|
||||
/// Shared streaming-stats recorder. The capture loop reads `is_armed()` per frame to decide
|
||||
/// whether to measure the per-stage split; the send thread builds + pushes the aggregated
|
||||
/// `StatsSample` at its 2 s boundary.
|
||||
@@ -3467,6 +3766,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
quit,
|
||||
reconfig,
|
||||
keyframe,
|
||||
rfi,
|
||||
bitrate_rx,
|
||||
compositor,
|
||||
mut bitrate_kbps,
|
||||
@@ -3481,6 +3781,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
fec_target,
|
||||
conn,
|
||||
timing_conn,
|
||||
probe_seq,
|
||||
stats,
|
||||
client_label,
|
||||
launch,
|
||||
@@ -3618,6 +3919,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
fec_target,
|
||||
send_stats,
|
||||
timing_conn,
|
||||
probe_seq,
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -3643,6 +3945,16 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(seconds as u64);
|
||||
let mut next = std::time::Instant::now();
|
||||
let mut sent: u64 = 0;
|
||||
// The session's video frame numbering, owned HERE (the wire `frame_index` of the next AU this
|
||||
// loop hands to the send thread; the packetizer seals with exactly this via `seal_frame_at`).
|
||||
// A submission's future index is predicted as `au_seq + inflight.len()` — exact because AUs
|
||||
// are emitted FIFO, one per submission, and every event that forfeits in-flight frames
|
||||
// (reset/rebuild/teardown) clears `inflight` AND the encoder's reference state, so the reused
|
||||
// predictions can never meet stale bookkeeping. Passing it to `Encoder::submit_indexed` keeps
|
||||
// the RFI backends' frame numbers 1:1 with the client's across encoder rebuilds — an
|
||||
// encoder-internal counter desyncs on the first adaptive-bitrate rebuild (NVENC RFI then
|
||||
// silently dies; AMF may anchor onto a post-loss LTR).
|
||||
let mut au_seq: u32 = 0;
|
||||
// Rebuild-in-place on capture loss: track the live mode (a mode switch updates it) so a rebuild
|
||||
// targets the CURRENT mode, and cap consecutive rebuilds so a flapping source can't loop the
|
||||
// client through endless cold restarts.
|
||||
@@ -3684,6 +3996,11 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
// Self-diagnosis for the periodic-stutter class: warns when the served recovery IDRs settle
|
||||
// into a stable multi-second rhythm (see [`crate::metronome::Metronome`]).
|
||||
let mut recovery_cadence = crate::metronome::Metronome::new();
|
||||
// Position within the current intra-refresh wave (frames since the last IDR/wave start). Only
|
||||
// meaningful on a `caps().intra_refresh_recovery` encoder; the pump tags every wave-boundary AU
|
||||
// with `USER_FLAG_RECOVERY_POINT` so the client can lift its post-loss freeze on a clean
|
||||
// re-anchor without a full IDR. Re-phased to 0 at each emitted IDR (which restarts the wave).
|
||||
let mut ir_wave_pos: u32 = 0;
|
||||
// Per-stage latency breakdown (PUNKTFUNK_PERF): per-call µs for the GPU-bound stages so we see
|
||||
// exactly where the capture→encoded latency goes — cap=try_latest (ring read + colour convert),
|
||||
// submit=encode_picture launch, wait=lock_bitstream (the scheduling wait + ASIC encode, the one
|
||||
@@ -3900,6 +4217,45 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
while keyframe.try_recv().is_ok() {
|
||||
want_kf = true;
|
||||
}
|
||||
// Client LTR-RFI recovery: prefer re-referencing a known-good older frame (a clean recovery
|
||||
// P-frame — no 20-40× IDR spike) over a full keyframe when the encoder supports it (native
|
||||
// AMF LTR / Windows NVENC). Drain the backlog (the client re-requests until the recovery
|
||||
// frame lands) coalesced to the widest lost range. Attempt the invalidate only when a full
|
||||
// IDR isn't already queued — an explicit keyframe request means a fully wedged decoder that
|
||||
// needs the IDR, which supersedes an RFI recovery. A failure (range older than the encoder's
|
||||
// live references, or no RFI backend) falls through to the coalesced keyframe path below.
|
||||
let mut rfi_range: Option<(u32, u32)> = None;
|
||||
while let Ok((first, last)) = rfi.try_recv() {
|
||||
rfi_range = Some(match rfi_range {
|
||||
Some((pf, pl)) => (pf.min(first), pl.max(last)),
|
||||
None => (first, last),
|
||||
});
|
||||
}
|
||||
if !want_kf {
|
||||
if let Some((first, last)) = rfi_range {
|
||||
// Sanity-cap the range before consulting the encoder: RFI can only re-reference
|
||||
// history the encoder still holds (NVENC: a 5-frame DPB; AMD LTR: ~1 s of marks).
|
||||
// A range wider than RFI_MAX_RANGE is either a seconds-long outage (no valid
|
||||
// reference anywhere) or a phantom jump from a desynced counter — both belong on
|
||||
// the keyframe path, never a force-reference that could ship corruption as a
|
||||
// recovery anchor. Wrapping width: frame indexes are u32 counters.
|
||||
let width = last.wrapping_sub(first);
|
||||
if width > punktfunk_core::packet::RFI_MAX_RANGE {
|
||||
tracing::debug!(first, last, width, "RFI range too wide — keyframe instead");
|
||||
want_kf = true;
|
||||
} else if enc.caps().supports_rfi
|
||||
&& enc.invalidate_ref_frames(first as i64, last as i64)
|
||||
{
|
||||
// The RFI recovered the loss with a clean re-anchor P-frame (no IDR). Anchor the
|
||||
// keyframe cooldown so the client's echo of the SAME loss — its frames_dropped-
|
||||
// driven keyframe request, arriving ~one loss-window later — is coalesced away
|
||||
// instead of emitting a redundant full IDR right after the cheap recovery.
|
||||
last_forced_idr = Some(std::time::Instant::now());
|
||||
} else {
|
||||
want_kf = true; // range too old / no RFI backend → coalesced keyframe below
|
||||
}
|
||||
}
|
||||
}
|
||||
if want_kf {
|
||||
// Clients request a keyframe on EVERY FEC-unrecoverable frame (`frames_dropped` polling)
|
||||
// and keep asking until the IDR actually arrives + decodes — a full round-trip on a link
|
||||
@@ -4156,7 +4512,11 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
st_queue.push(queue_us);
|
||||
}
|
||||
let t_submit = std::time::Instant::now();
|
||||
if let Err(e) = enc.submit(&frame) {
|
||||
// This submission's future wire frame index (see `au_seq`): AUs are emitted FIFO one per
|
||||
// submission, so it lands `inflight.len()` AUs after the `au_seq` the loop is about to
|
||||
// assign next. The RFI backends pin their frame numbering to it.
|
||||
let wire_index = au_seq.wrapping_add(inflight.len() as u32);
|
||||
if let Err(e) = enc.submit_indexed(&frame, wire_index) {
|
||||
// The input half of an encode stall: once the driver stops draining AUs, libavcodec's
|
||||
// one-frame buffer fills and avcodec_send_frame starts failing (EAGAIN) — the same
|
||||
// wedge the watchdog below catches, seen from submit. Rebuild the encoder in place
|
||||
@@ -4225,11 +4585,28 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
last_au_at = std::time::Instant::now();
|
||||
encoder_resets = 0;
|
||||
let (cap_ns, sub_ns, deadline) = inflight.pop_front().expect("inflight non-empty");
|
||||
let flags = if au.keyframe {
|
||||
let mut flags = if au.keyframe {
|
||||
(FLAG_PIC | FLAG_SOF) as u32
|
||||
} else {
|
||||
FLAG_PIC as u32
|
||||
};
|
||||
// Intra-refresh recovery marking (inert unless the backend validated its constrained GDR
|
||||
// via `intra_refresh_recovery`): tag every wave-boundary AU with USER_FLAG_RECOVERY_POINT
|
||||
// so the client lifts its post-loss freeze on the second mark — a proven clean re-anchor —
|
||||
// instead of forcing a full IDR. See [`mark_recovery_boundary`] for the cadence.
|
||||
let caps = enc.caps();
|
||||
if caps.intra_refresh_recovery
|
||||
&& caps.intra_refresh_period > 0
|
||||
&& mark_recovery_boundary(&mut ir_wave_pos, au.keyframe, caps.intra_refresh_period)
|
||||
{
|
||||
flags |= punktfunk_core::packet::USER_FLAG_RECOVERY_POINT;
|
||||
}
|
||||
// Reference-frame-invalidation recovery frame (AMD LTR force-reference): a clean P-frame
|
||||
// off a known-good reference. Tag it so the client lifts its post-loss freeze on this one
|
||||
// AU without an IDR — the definitive single-frame re-anchor (see USER_FLAG_RECOVERY_ANCHOR).
|
||||
if au.recovery_anchor {
|
||||
flags |= punktfunk_core::packet::USER_FLAG_RECOVERY_ANCHOR;
|
||||
}
|
||||
// Re-send the HDR mastering metadata (0xCE) on each keyframe (a decoder-resync point) and
|
||||
// whenever it changed, so a client that dropped the best-effort datagram re-converges.
|
||||
if let Some(m) = last_hdr_meta {
|
||||
@@ -4244,6 +4621,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
data: au.data,
|
||||
capture_ns: cap_ns,
|
||||
flags,
|
||||
frame_index: au_seq,
|
||||
deadline,
|
||||
encode_us,
|
||||
queue_us,
|
||||
@@ -4259,6 +4637,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
send_gone = true;
|
||||
break;
|
||||
}
|
||||
au_seq = au_seq.wrapping_add(1);
|
||||
sent += 1;
|
||||
}
|
||||
if send_gone {
|
||||
@@ -4319,6 +4698,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
data: au.data,
|
||||
capture_ns: cap_ns,
|
||||
flags,
|
||||
frame_index: au_seq,
|
||||
deadline,
|
||||
encode_us,
|
||||
queue_us: 0,
|
||||
@@ -4331,6 +4711,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
if frame_tx.send(msg).is_err() {
|
||||
break;
|
||||
}
|
||||
au_seq = au_seq.wrapping_add(1);
|
||||
sent += 1;
|
||||
}
|
||||
// Signal the send thread to drain + exit (drop the channel), then join it.
|
||||
@@ -4654,6 +5035,32 @@ mod tests {
|
||||
assert!(reconfig_allowed(None, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_marks_land_every_period_and_rephase_at_idr() {
|
||||
let period = 4;
|
||||
let mut pos = 0u32;
|
||||
// Frames 1..=3 are mid-wave (no mark), frame 4 is the boundary; then it repeats.
|
||||
let marks: Vec<bool> = (0..10)
|
||||
.map(|_| mark_recovery_boundary(&mut pos, false, period))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
marks,
|
||||
vec![false, false, false, true, false, false, false, true, false, false]
|
||||
);
|
||||
|
||||
// An IDR mid-wave re-phases: the counter restarts, so the next boundary is a full period
|
||||
// later (an IDR is itself a clean anchor, so it is not additionally marked).
|
||||
let mut pos = 0u32;
|
||||
assert!(!mark_recovery_boundary(&mut pos, false, period)); // pos 1
|
||||
assert!(!mark_recovery_boundary(&mut pos, false, period)); // pos 2
|
||||
assert!(!mark_recovery_boundary(&mut pos, true, period)); // IDR → pos 0, no mark
|
||||
// Now a fresh full period is needed, not just the 2 remaining frames.
|
||||
assert!(!mark_recovery_boundary(&mut pos, false, period)); // pos 1
|
||||
assert!(!mark_recovery_boundary(&mut pos, false, period)); // pos 2
|
||||
assert!(!mark_recovery_boundary(&mut pos, false, period)); // pos 3
|
||||
assert!(mark_recovery_boundary(&mut pos, false, period)); // pos 4 → mark
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pad_snapshot_replaces_state_and_seq_gates() {
|
||||
use punktfunk_core::input::{gamepad, GamepadSnapshot};
|
||||
@@ -5152,6 +5559,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 data-control 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 serve_session
|
||||
// (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 data-control 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()))
|
||||
}
|
||||
|
||||
@@ -22,6 +22,11 @@ use std::time::Instant;
|
||||
pub enum Source {
|
||||
/// Deterministic moving BGRx test pattern — no capture session required.
|
||||
Synthetic,
|
||||
/// Deterministic moving NV12 texture on the GPU (Windows only) — no capture session required.
|
||||
/// Feeds the native AMF / D3D11 zero-copy encoders, which demand an NV12 GPU texture the CPU
|
||||
/// `Synthetic` source can't give them. Used to validate GPU-encoder behaviour (e.g. AMF
|
||||
/// intra-refresh) headlessly.
|
||||
SyntheticNv12,
|
||||
/// Live monitor via the xdg ScreenCast portal + PipeWire.
|
||||
Portal,
|
||||
/// KWin virtual output created at `width`x`height` (zkde_screencast). Lets us validate
|
||||
@@ -56,6 +61,31 @@ pub fn run(opts: Options) -> Result<()> {
|
||||
);
|
||||
Box::new(SyntheticCapturer::new(opts.width, opts.height, opts.fps))
|
||||
}
|
||||
Source::SyntheticNv12 => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
tracing::info!(
|
||||
width = opts.width,
|
||||
height = opts.height,
|
||||
fps = opts.fps,
|
||||
"spike source: synthetic NV12 GPU texture (moving luma ramp)"
|
||||
);
|
||||
Box::new(
|
||||
capture::synthetic_nv12::SyntheticNv12Capturer::new(
|
||||
opts.width,
|
||||
opts.height,
|
||||
opts.fps,
|
||||
)
|
||||
.context("open synthetic NV12 capturer")?,
|
||||
)
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
anyhow::bail!(
|
||||
"--source synthetic-nv12 is Windows-only (native AMF / D3D11 encoders)"
|
||||
);
|
||||
}
|
||||
}
|
||||
Source::Portal => {
|
||||
tracing::info!("spike source: xdg ScreenCast portal (live monitor)");
|
||||
capture::open_portal_monitor().context("open portal capturer")?
|
||||
|
||||
@@ -85,7 +85,7 @@ fallback without one. More detail — including the CLI `punktfunk-host service
|
||||
systemctl --user enable --now punktfunk-web
|
||||
```
|
||||
|
||||
Then open `http://<host-ip>:47992`. Reading its [login password](/docs/web-console#login-password)
|
||||
Then open `https://<host-ip>:47992`. Reading its [login password](/docs/web-console#login-password)
|
||||
and [arming PIN pairing](/docs/web-console#arm-pairing) are covered in
|
||||
[The Web Console](/docs/web-console).
|
||||
|
||||
|
||||
@@ -86,7 +86,8 @@ When it finishes it prints the web-console URL and how to pair.
|
||||
|
||||
By default the host **requires PIN pairing** (secure). Two ways to pair:
|
||||
|
||||
- **Web console** (printed at the end of step 2): open `http://<device-ip>:47992`,
|
||||
- **Web console** (printed at the end of step 2): open `https://<device-ip>:47992` (self-signed host
|
||||
cert — your browser warns once; trust it and continue),
|
||||
[arm pairing](/docs/web-console#arm-pairing), and enter the PIN on your client.
|
||||
- **From the client directly**: pick this host (it advertises over mDNS as `_punktfunk._udp`) and
|
||||
enter the PIN the host shows.
|
||||
|
||||
@@ -15,8 +15,8 @@ on Windows). A change applies to the **next** connection — a running session k
|
||||
opened on.
|
||||
|
||||
> **You rarely need to touch this.** The default behavior matches how punktfunk has always worked.
|
||||
> Reach for a preset when you want a specific experience — a dedicated couch/gaming box, a desktop
|
||||
> you also use in person, or a multi-monitor workstation.
|
||||
> Reach for a preset when you want a specific experience — a dedicated box you only stream from, a
|
||||
> desktop you also use in person, or a multi-monitor workstation.
|
||||
|
||||
> **What's live today:** **keep-alive** (linger, or **forever**), **topology** (extend / primary /
|
||||
> exclusive), **conflict handling**, **per-client identity + persistent scaling** (Windows, KDE/KWin
|
||||
@@ -32,11 +32,11 @@ the individual options documented further down.
|
||||
|
||||
| Preset | What it's for |
|
||||
|---|---|
|
||||
| **Default** | Today's behavior. A short linger absorbs reconnects, the streamed output becomes the sole desktop, and extra clients each get their own view. |
|
||||
| **Gaming rig** | A dedicated couch/headless box. The game and its display survive disconnects indefinitely (keep-alive **forever**), and whoever connects takes the box over. Release it from the console when you're done. |
|
||||
| **Shared desktop** | A desktop you also use in person. punktfunk never blanks your real monitors and never leaves a ghost display behind; concurrent viewers each get a view. |
|
||||
| **Hot-desk** | One user at a time with fast reattach — roaming between your own devices. A second user is told the box is busy, and each device+resolution keeps its own scaling. |
|
||||
| **Workstation** | The multi-monitor daily driver. Your displays come back exactly where you arranged them, with per-client identity and an exclusive desktop. |
|
||||
| **Default** | Good for most setups. Reconnects resume quickly, the streamed output becomes the whole desktop, and extra viewers each get their own screen. |
|
||||
| **Headless box** | A machine with no monitor that you only ever stream from. The game and its display survive disconnects indefinitely (keep-alive **forever**), and whoever connects next takes the box over. Release it from the console when you're done. |
|
||||
| **Shared desktop** | A PC you also use in person. punktfunk never blanks your real monitors and never leaves a leftover display behind; extra viewers each get their own screen. |
|
||||
| **Hot-desk** | One person at a time — roam between your own devices with an instant reconnect. Anyone else is told the box is busy, and each device+resolution keeps its own scaling. |
|
||||
| **Workstation** | Your multi-monitor daily driver. Displays come back exactly where you arranged them, each client keeps its own settings, and the desktop is yours alone. |
|
||||
|
||||
## Save your own preset
|
||||
|
||||
@@ -69,7 +69,7 @@ this also keeps the **game itself running** so you can reconnect straight back i
|
||||
- **A duration** (seconds) — keep it for that long; a reconnect inside the window drops you straight
|
||||
back in, with no re-negotiation and no desktop reshuffle.
|
||||
- **Forever** — keep it until you stop the host or **release it** from the console (Host → *Virtual
|
||||
displays* → *Release*). This is the gaming-rig model.
|
||||
displays* → *Release*). This is the headless-box model.
|
||||
|
||||
Default: **10 seconds**. Windows has always lingered 10 s; the Linux backends previously tore down
|
||||
immediately — a short linger makes reconnects smoother on both.
|
||||
@@ -194,6 +194,6 @@ landing on a dead stream — and switching between game mode and the KDE / GNOME
|
||||
follows the switch. If a launched game **exits**, a dedicated session ends and returns you to your
|
||||
library; a game mode / desktop session keeps streaming.
|
||||
|
||||
**My couch box's TV stayed on the streamed session after I disconnected.** With the **gaming-rig**
|
||||
**My couch box's TV stayed on the streamed session after I disconnected.** With the **Headless box**
|
||||
preset (keep alive = *forever*), a managed Steam session is held indefinitely so a reconnect resumes
|
||||
instantly — return to game mode on the box (or restart the host) to hand the TV back.
|
||||
|
||||
@@ -5,7 +5,8 @@ description: Enable the punktfunk browser console, read or change its login pass
|
||||
|
||||
The web console is the browser UI for a punktfunk host — live status, paired devices, and the PIN
|
||||
pairing flow. It ships as the **`punktfunk-web`** systemd user unit on Linux and the **`PunktfunkWeb`**
|
||||
task on Windows, and serves on **`http://<host-ip>:47992`**. It's the surface you expose on the LAN to
|
||||
task on Windows, and serves on **`https://<host-ip>:47992`** (HTTPS with the host's own self-signed
|
||||
identity cert — your browser warns once; trust it and continue). It's the surface you expose on the LAN to
|
||||
administer the host; the host's own management API (47990) keeps every admin action loopback-only and
|
||||
off-loopback serves only read-only status + game-library browsing to paired clients.
|
||||
|
||||
@@ -19,11 +20,11 @@ off-loopback serves only read-only status + game-library browsing to paired clie
|
||||
|
||||
```sh
|
||||
systemctl --user enable --now punktfunk-web
|
||||
# then browse to http://<host-ip>:47992
|
||||
# then browse to https://<host-ip>:47992
|
||||
```
|
||||
|
||||
- **Windows host:** the installer sets up the console, its runtime, and the `PunktfunkWeb` task and
|
||||
starts it at boot. There is nothing to enable — open `http://<this-PC>:47992`.
|
||||
starts it at boot. There is nothing to enable — open `https://<this-PC>:47992`.
|
||||
|
||||
- **SteamOS host:** the install script builds and starts the console as a user service for you. It
|
||||
prints the URL when it finishes.
|
||||
|
||||
@@ -64,7 +64,7 @@ the Windows specifics follow.
|
||||
|
||||
The installer also sets up the **web management console** (status, paired devices, the PIN pairing
|
||||
flow): it bundles the console plus its own runtime and runs it as the **`PunktfunkWeb`** task on
|
||||
**`http://<this-PC>:47992`**, starting at boot.
|
||||
**`https://<this-PC>:47992`**, starting at boot.
|
||||
|
||||
#### Console login password
|
||||
|
||||
|
||||
@@ -0,0 +1,719 @@
|
||||
# Embedding punktfunk-core (the C ABI)
|
||||
|
||||
This guide is for developers who want to build their **own** punktfunk client — on a platform we
|
||||
don't ship an app for — by linking `punktfunk-core` through its stable C ABI. It covers what the
|
||||
core does (and, importantly, what it doesn't), how to build and link it, the full client lifecycle,
|
||||
and worked integration blueprints for **webOS**, **Xbox**, and **Tizen**.
|
||||
|
||||
The authoritative header is [`include/punktfunk_core.h`](../include/punktfunk_core.h) — it is
|
||||
generated from Rust by cbindgen and every symbol carries a doc comment. This guide is the narrative;
|
||||
the header is the contract.
|
||||
|
||||
---
|
||||
|
||||
## 1. What the core gives you — and what it doesn't
|
||||
|
||||
`punktfunk-core` is the *one* implementation of the wire format, shared by the host and every
|
||||
first-party client. When you embed it you get the entire network side of a client for free:
|
||||
|
||||
**The core does:**
|
||||
|
||||
- The `punktfunk/1` handshake (Hello/Welcome/Start) over a **QUIC control plane**.
|
||||
- The **UDP video data plane**: packetization, reassembly, pacing, socket tuning.
|
||||
- **Forward error correction** (GF(2⁸) Reed–Solomon and GF(2¹⁶) Leopard-RS) — loss recovery.
|
||||
- **AES-128-GCM** session crypto, **cert pinning / TOFU**, and **SPAKE2 PIN pairing**.
|
||||
- **Clock synchronization** (host↔client offset for glass-to-glass latency math).
|
||||
- Delivery of every plane: reassembled **video access units**, **Opus audio** (raw or decoded to
|
||||
PCM in-core), **rumble**, **DualSense HID output**, **HDR metadata**, **host timing**.
|
||||
- The uplink: **input events**, **mic**, **rich input** (touchpad/motion).
|
||||
- Loss-recovery signalling: keyframe requests and **reference-frame invalidation** (RFI).
|
||||
|
||||
**The core does NOT do (this is your job):**
|
||||
|
||||
- **Video decoding.** The core hands you encoded access units (H.264, HEVC, or AV1 NAL units /
|
||||
OBUs). You feed them to the platform's hardware decoder.
|
||||
- **Rendering / presentation.** You own the swapchain / surface and put decoded frames on glass.
|
||||
- **Audio output.** You own the audio sink; the core only delivers Opus (or f32 PCM).
|
||||
- **Input capture / controller enumeration.** You read the platform's input and translate it into
|
||||
`PunktfunkInputEvent`s.
|
||||
- **UI, discovery UX, settings.** (mDNS discovery is not part of the C ABI; see §4.)
|
||||
|
||||
Think of it as: **the core is the modem and the codec-agnostic protocol brain; you are the TV and
|
||||
the remote.**
|
||||
|
||||
```
|
||||
┌─────────────────────────── punktfunk-core (C ABI) ───────────────────────────┐
|
||||
your platform │ │ punktfunk host
|
||||
┌───────────┐ input │ punktfunk_connection_send_input / _send_mic / _send_rich_input ───────────────▶
|
||||
│ remote / │──────────▶│ │
|
||||
│ gamepad │ │ QUIC control + UDP data + FEC + AES-GCM │
|
||||
└───────────┘ │ │
|
||||
┌───────────┐ AUs │ ◀── punktfunk_connection_next_au (H.264 / HEVC / AV1 access units) │◀─── encoder
|
||||
│ HW decoder│◀──────────│ ◀── punktfunk_connection_next_audio(_pcm)(Opus / f32 PCM) │
|
||||
│ + display │ │ ◀── punktfunk_connection_next_rumble2 / _next_hidout / _next_hdr_meta │
|
||||
└───────────┘ └──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Building and linking the library
|
||||
|
||||
### 2.1 The `quic` feature is mandatory for clients
|
||||
|
||||
The entire connection API (`punktfunk_connect*`, `punktfunk_connection_*`, pairing, probe) is
|
||||
gated, in both Rust and the C header, behind the **`quic`** Cargo feature. Everything under
|
||||
`#if defined(PUNKTFUNK_FEATURE_QUIC)` in the header only exists when the core was built with it.
|
||||
|
||||
> **Two things must line up or nothing links:**
|
||||
> 1. Build the library with `--features quic`.
|
||||
> 2. Compile your C/C++ with `-DPUNKTFUNK_FEATURE_QUIC` so the header declares those prototypes.
|
||||
|
||||
The non-QUIC surface (`punktfunk_session_new`, `punktfunk_host_submit_frame`,
|
||||
`punktfunk_client_poll_frame`, the loopback test pair) is the **raw transport** used by the host and
|
||||
by tests. **Client embedders do not use it** — you use the `punktfunk_connect*` family.
|
||||
|
||||
### 2.2 Build outputs
|
||||
|
||||
```sh
|
||||
# Native (host machine) — good for a desktop prototype
|
||||
cargo build -p punktfunk-core --features quic --release
|
||||
```
|
||||
|
||||
`crate-type = ["lib", "cdylib", "staticlib"]`, so one build produces all three:
|
||||
|
||||
| Output | File (per platform) | Use when |
|
||||
|-------------|----------------------------------------------------------------|----------|
|
||||
| `cdylib` | `libpunktfunk_core.so` / `.dylib` / `punktfunk_core.dll` | dynamic linking (most TV apps, sandboxed apps) |
|
||||
| `staticlib` | `libpunktfunk_core.a` / `punktfunk_core.lib` | static embedding into a single binary |
|
||||
|
||||
The header lands at `include/punktfunk_core.h` (regenerated on build; also checked in).
|
||||
|
||||
### 2.3 Cross-compiling for your device
|
||||
|
||||
Every target below is a standard Rust target. Add it and point Cargo at the platform sysroot/linker.
|
||||
|
||||
```sh
|
||||
rustup target add aarch64-unknown-linux-gnu # most ARM Smart TVs (webOS, Tizen)
|
||||
rustup target add armv7-unknown-linux-gnueabihf # older 32-bit TV SoCs
|
||||
rustup target add x86_64-pc-windows-msvc # Xbox (GDK consoles are x64)
|
||||
|
||||
# Tell Cargo which cross-linker + sysroot to use (example: aarch64 TV NDK)
|
||||
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
|
||||
export CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc
|
||||
cargo build -p punktfunk-core --features quic --release \
|
||||
--target aarch64-unknown-linux-gnu
|
||||
```
|
||||
|
||||
The QUIC tree is deliberately **ring-only** (no aws-lc-rs / no cmake), so cross builds do not need a
|
||||
C crypto toolchain — this is what keeps the TV cross-compiles simple. `libopus` (for the in-core PCM
|
||||
decode path) is the only C dependency and builds with the target `CC`.
|
||||
|
||||
### 2.4 Compiling and linking your app
|
||||
|
||||
```sh
|
||||
# compile
|
||||
cc -std=c11 -DPUNKTFUNK_FEATURE_QUIC -I path/to/include -c myclient.c
|
||||
|
||||
# link, dynamic
|
||||
cc myclient.o -L target/aarch64-unknown-linux-gnu/release -lpunktfunk_core -o myclient
|
||||
|
||||
# link, static — you also need the native libs rustc pulls in
|
||||
NATIVE=$(cargo rustc -p punktfunk-core --features quic --release \
|
||||
--target aarch64-unknown-linux-gnu \
|
||||
--crate-type staticlib -- --print native-static-libs 2>&1 \
|
||||
| sed -n 's/.*native-static-libs: //p' | tail -1)
|
||||
cc myclient.o target/aarch64-unknown-linux-gnu/release/libpunktfunk_core.a $NATIVE -o myclient
|
||||
```
|
||||
|
||||
`--print native-static-libs` is the reliable way to discover the platform libs (`-lpthread`,
|
||||
`-lm`, WinSock, etc.) a static link needs — the C harness at
|
||||
[`crates/punktfunk-core/tests/c/run.sh`](../crates/punktfunk-core/tests/c/run.sh) does exactly this
|
||||
and is your smallest end-to-end reference.
|
||||
|
||||
### 2.5 Version check first, always
|
||||
|
||||
```c
|
||||
#include "punktfunk_core.h"
|
||||
|
||||
if (punktfunk_abi_version() != ABI_VERSION) {
|
||||
// The .so you loaded is a different core than this header was generated from. Abort.
|
||||
}
|
||||
```
|
||||
|
||||
`ABI_VERSION` (the embeddable C surface) is distinct from `WIRE_VERSION` (what the handshake
|
||||
carries). The ABI can grow — new `connect_ex` variants, new planes — without touching the wire, so a
|
||||
newer client keeps talking to a deployed host. Only equal `WIRE_VERSION`s interoperate on the wire;
|
||||
the core enforces that inside the handshake.
|
||||
|
||||
---
|
||||
|
||||
## 3. Core concepts
|
||||
|
||||
### 3.1 The connection handle
|
||||
|
||||
`PunktfunkConnection *` is an **opaque handle to one live client session** — QUIC control plane plus
|
||||
UDP data plane, with all the I/O pumped on threads the core owns internally. You obtain it from a
|
||||
`punktfunk_connect*` call and release it with `punktfunk_connection_close`.
|
||||
|
||||
### 3.2 The threading contract (read this before you design your loops)
|
||||
|
||||
The planes are **independent single-consumer queues**. The rule:
|
||||
|
||||
> Each plane (video, audio, rumble, HID-out, HDR, host-timing) may be pulled from **its own thread,
|
||||
> at most one thread per plane**. Different planes may be pulled concurrently. Never pull the *same*
|
||||
> plane from two threads.
|
||||
|
||||
A typical client runs **three threads**:
|
||||
|
||||
- **Video thread** — blocks on `punktfunk_connection_next_au`, decodes, presents.
|
||||
- **Audio thread** — blocks on `punktfunk_connection_next_audio_pcm` (or `_next_audio`).
|
||||
- **Feedback thread** — pulls rumble / HID-out / HDR-meta (all low-rate; poll or short-timeout).
|
||||
|
||||
Input is sent from whatever thread reads your input (send calls are non-blocking enqueues and are
|
||||
safe to call from any thread).
|
||||
|
||||
### 3.3 Borrowed memory
|
||||
|
||||
`next_au`, `next_audio`, `next_audio_pcm` return a struct whose `data`/`samples` pointer **borrows
|
||||
core-owned memory that is valid only until your next pull on that same handle/plane.** Decode or copy
|
||||
before you call again. Cross-plane pulls do not invalidate each other (the audio pull won't free the
|
||||
video buffer).
|
||||
|
||||
### 3.4 Status codes
|
||||
|
||||
Every fallible call returns `PunktfunkStatus`: `PUNKTFUNK_STATUS_OK == 0`, all errors negative
|
||||
(`rc < 0`). The two you branch on constantly in the pull loops:
|
||||
|
||||
- `PUNKTFUNK_STATUS_NO_FRAME` (-5) — nothing ready within `timeout_ms`; loop again.
|
||||
- `PUNKTFUNK_STATUS_CLOSED` (-10) — the session ended; tear down and leave the loop.
|
||||
|
||||
---
|
||||
|
||||
## 4. Identity, pairing, discovery, wake
|
||||
|
||||
### 4.1 Generate a persistent identity once
|
||||
|
||||
```c
|
||||
char cert[4096], key[4096];
|
||||
if (punktfunk_generate_identity(cert, sizeof cert, key, sizeof key) != PUNKTFUNK_STATUS_OK) { /* … */ }
|
||||
// Persist BOTH strings securely (platform secure storage / keychain).
|
||||
// The certificate's SHA-256 is how a host recognizes this client after pairing.
|
||||
```
|
||||
|
||||
Do this **once per device/install** and store the PEM strings. Pass them to every `pair` and
|
||||
`connect` call. Anonymous (`NULL`/`NULL`) sessions are rejected by hosts running `--require-pairing`.
|
||||
|
||||
### 4.2 Pair (PIN ceremony)
|
||||
|
||||
```c
|
||||
uint8_t host_fp[32];
|
||||
PunktfunkStatus rc = punktfunk_pair(host, port, cert, key,
|
||||
user_typed_pin, "Living Room TV",
|
||||
host_fp, /*timeout_ms=*/10000);
|
||||
// rc == PUNKTFUNK_STATUS_CRYPTO => wrong PIN.
|
||||
// rc == PUNKTFUNK_STATUS_OK => persist host_fp; it's the pin for future connects.
|
||||
```
|
||||
|
||||
The host shows a PIN; the user types it into your UI. On success you get the host's verified
|
||||
fingerprint — store it keyed by host, and pass it as `pin_sha256` on every later connect (that pins
|
||||
the host and defeats MITM). For a first connect without prior pairing you may pass `NULL` for the pin
|
||||
and capture `observed_sha256_out` (trust-on-first-use), then pin it thereafter.
|
||||
|
||||
### 4.3 Reachability probe and Wake-on-LAN
|
||||
|
||||
- `punktfunk_probe(host, port, timeout_ms)` — a bounded, trust-agnostic handshake attempt. Returns
|
||||
`OK` if the host answered (drives "online" pips), `TIMEOUT` otherwise. Works over routed
|
||||
networks (Tailscale/VPN) where mDNS never reaches. Call off the UI thread.
|
||||
- `punktfunk_wake_on_lan(macs, mac_count, last_known_ip)` — sends WoL magic packets. The host's wake
|
||||
MAC(s) arrive out-of-band via the mDNS `mac` TXT record, so no connection is needed to wake a
|
||||
sleeping host.
|
||||
|
||||
> **Discovery is out of scope for the C ABI.** mDNS/DNS-SD browsing is the embedder's job (use the
|
||||
> platform's Bonjour/Avahi/`nsd` API to find `_punktfunk._udp` hosts, or let the user type an
|
||||
> IP/hostname). The core only *connects*.
|
||||
|
||||
---
|
||||
|
||||
## 5. Connecting
|
||||
|
||||
There is a **ladder** of `connect` variants; each adds parameters and defaults the rest to the prior
|
||||
variant's behavior. Use the highest one you need — they all return `PunktfunkConnection *` (NULL on
|
||||
failure) and block up to `timeout_ms` for the handshake, so **call off your UI thread.**
|
||||
|
||||
| Variant | Adds |
|
||||
|------------------|------|
|
||||
| `punktfunk_connect` | base: `width`, `height`, `refresh_hz`, pin, identity |
|
||||
| `punktfunk_connect_ex` | `compositor` preference (Linux hosts) |
|
||||
| `punktfunk_connect_ex2` | `gamepad` backend (X-Box 360 / DualSense / …) |
|
||||
| `punktfunk_connect_ex3` | `bitrate_kbps` |
|
||||
| `punktfunk_connect_ex4` | `launch_id` (auto-launch a library title) |
|
||||
| `punktfunk_connect_ex5` | `video_caps` (10-bit / HDR / 4:4:4 / host-timing) |
|
||||
| `punktfunk_connect_ex6` | `audio_channels` (2 / 6 / 8) |
|
||||
| **`punktfunk_connect_ex7`** | **`video_codecs` + `preferred_codec`** — the recommended full call |
|
||||
|
||||
```c
|
||||
uint8_t caps = 0; // add PUNKTFUNK_VIDEO_CAP_10BIT | _HDR | _444 as supported
|
||||
uint8_t codecs = PUNKTFUNK_CODEC_HEVC; // OR-in _H264 (needed for software hosts) / _AV1
|
||||
uint8_t host_fp[32]; uint8_t observed[32];
|
||||
|
||||
PunktfunkConnection *c = punktfunk_connect_ex7(
|
||||
"192.168.1.50", 9777,
|
||||
1920, 1080, 60, // mode
|
||||
PUNKTFUNK_COMPOSITOR_AUTO,
|
||||
PUNKTFUNK_GAMEPAD_XBOX360,
|
||||
/*bitrate_kbps=*/20000,
|
||||
caps,
|
||||
/*audio_channels=*/2,
|
||||
codecs, /*preferred_codec=*/PUNKTFUNK_CODEC_HEVC,
|
||||
/*launch_id=*/NULL,
|
||||
host_fp, // pin (or NULL for TOFU)
|
||||
observed, // filled with the observed fp
|
||||
cert, key, // identity
|
||||
/*timeout_ms=*/8000);
|
||||
if (!c) { /* handshake failed */ }
|
||||
```
|
||||
|
||||
> **Advertise only what you can actually decode and present.** The host upgrades to 10-bit / HDR /
|
||||
> 4:4:4 / AV1 **only** when you set the matching bit *and* it opted in. Setting a cap you can't honor
|
||||
> gives you a stream you can't render.
|
||||
|
||||
### 5.1 Read the resolved session (right after connect)
|
||||
|
||||
The host echoes what it actually chose. Build your decoder and present path from **these**, never
|
||||
from your request:
|
||||
|
||||
```c
|
||||
uint8_t codec, prim, trc, mtx, full, depth, chroma, ch;
|
||||
punktfunk_connection_codec(c, &codec); // PUNKTFUNK_CODEC_{H264,HEVC,AV1}
|
||||
punktfunk_connection_color_info(c, &prim, &trc, &mtx, &full, &depth); // CICP + bit depth
|
||||
punktfunk_connection_chroma_format(c, &chroma); // 1 = 4:2:0, 3 = 4:4:4
|
||||
punktfunk_connection_audio_channels(c, &ch); // 2 / 6 / 8
|
||||
|
||||
int64_t clk = 0; punktfunk_connection_clock_offset_ns(c, &clk); // host−client ns, for latency math
|
||||
|
||||
uint32_t w, h, hz; punktfunk_connection_mode(c, &w, &h, &hz);
|
||||
```
|
||||
|
||||
`trc == 16` (PQ) or `18` (HLG) means an **HDR** session — set up an HDR present path and drain
|
||||
`punktfunk_connection_next_hdr_meta`. On `PUNKTFUNK_COMPOSITOR_GAMESCOPE`
|
||||
(`punktfunk_connection_compositor`) draw a client-side cursor by default (that capture carries none).
|
||||
|
||||
---
|
||||
|
||||
## 6. The video loop
|
||||
|
||||
The core delivers **complete, in-order access units** with in-band parameter sets. The first AU is
|
||||
an IDR — build your decoder from it (and from the resolved codec/color above). The stream is then an
|
||||
**infinite-GOP** of P-frames: no periodic IDRs, so loss recovery is explicit and is the part you must
|
||||
get right.
|
||||
|
||||
```c
|
||||
PunktfunkFrame f;
|
||||
for (;;) {
|
||||
PunktfunkStatus rc = punktfunk_connection_next_au(c, &f, /*timeout_ms=*/20);
|
||||
if (rc == PUNKTFUNK_STATUS_NO_FRAME) continue;
|
||||
if (rc == PUNKTFUNK_STATUS_CLOSED) break;
|
||||
if (rc != PUNKTFUNK_STATUS_OK) continue;
|
||||
|
||||
// (1) Loss recovery — let the core do the hard part. Call this EVERY frame:
|
||||
bool gap = false;
|
||||
punktfunk_connection_note_frame_index(c, f.frame_index, &gap);
|
||||
// On a forward gap it fires a throttled RFI request for you (clean P-frame recovery on
|
||||
// AMD-LTR/NVENC hosts, no IDR spike). `gap == true` is your cue to freeze on the last good
|
||||
// picture until re-anchor (see (3)).
|
||||
|
||||
// (2) Clean re-anchor points (infinite-GOP has no periodic keyframes):
|
||||
bool recovery_point = f.flags & USER_FLAG_RECOVERY_POINT; // intra-refresh wave boundary
|
||||
bool recovery_anchor = f.flags & USER_FLAG_RECOVERY_ANCHOR; // definitive LTR/RFI re-anchor
|
||||
|
||||
// (3) Decode + present. f.data/f.len are valid until the next next_au call.
|
||||
decode_and_present(f.data, f.len, f.pts_ns);
|
||||
// If you implement freeze-until-reanchor: while frozen, keep redrawing the last good frame
|
||||
// and lift the freeze on a real keyframe, on the FIRST recovery_anchor, or the SECOND
|
||||
// recovery_point after the gap.
|
||||
|
||||
// (4) Backstop trigger: poll the reassembler's unrecoverable-drop count. Under infinite GOP,
|
||||
// unrecoverable loss yields reference-missing deltas the decoder *silently conceals*
|
||||
// (frozen/garbage, no decode error) — so this counter, not a decode error, is the signal.
|
||||
uint64_t dropped = 0; punktfunk_connection_frames_dropped(c, &dropped);
|
||||
if (dropped > last_dropped) { // THROTTLE this (≤ ~1/100ms) — see below
|
||||
punktfunk_connection_request_keyframe(c);
|
||||
last_dropped = dropped;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Recovery, in priority order:**
|
||||
|
||||
1. `note_frame_index` every frame — the cheapest, earliest signal. It issues a **throttled RFI**
|
||||
request (`request_rfi`) for the exact lost range so RFI-capable hosts recover with a clean
|
||||
P-frame (no 20–40× IDR bandwidth spike).
|
||||
2. `frames_dropped` climbing → `request_keyframe` as the backstop when RFI can't help or the recovery
|
||||
frame itself was lost.
|
||||
3. A wedged decoder (received AUs but no decoded output for several frames) → `request_keyframe`.
|
||||
|
||||
**Throttle** both `request_keyframe` and `request_rfi` — decode stays wedged for several frames until
|
||||
recovery lands, so one request per ~100 ms is right; requesting per frame floods the control stream.
|
||||
If you prefer not to hand-roll this, `note_frame_index` + a throttled `frames_dropped`→keyframe check
|
||||
is a complete, correct recovery policy on its own.
|
||||
|
||||
### 6.1 Mid-session resolution / refresh changes
|
||||
|
||||
```c
|
||||
punktfunk_connection_request_mode(c, new_w, new_h, new_hz);
|
||||
```
|
||||
|
||||
Non-blocking. On acceptance the next AU is a fresh IDR with in-band parameter sets — **rebuild your
|
||||
decoder from it** — and `punktfunk_connection_mode` reflects the switch. Use this when your window
|
||||
resizes or the display refresh changes.
|
||||
|
||||
---
|
||||
|
||||
## 7. Audio
|
||||
|
||||
Two mutually-exclusive ways to consume audio — pick one, on one dedicated thread:
|
||||
|
||||
- **`punktfunk_connection_next_audio_pcm`** — the core decodes to interleaved **f32 PCM** at 48 kHz
|
||||
in channel order `FL FR FC LFE RL RR SL SR`. Use this if you lack a *multistream*-capable Opus
|
||||
decoder (e.g. Apple's AudioToolbox is stereo-only; many TV audio stacks are too). Simplest path.
|
||||
- **`punktfunk_connection_next_audio`** — raw Opus packets (5 ms frames). Use only if you have a
|
||||
multistream Opus decoder and build it from `punktfunk_connection_audio_channels` (see
|
||||
`audio::layout_for`). Do **not** mix the two on one connection.
|
||||
|
||||
```c
|
||||
PunktfunkAudioPcm a;
|
||||
for (;;) {
|
||||
PunktfunkStatus rc = punktfunk_connection_next_audio_pcm(c, &a, /*timeout_ms=*/100);
|
||||
if (rc == PUNKTFUNK_STATUS_CLOSED) break;
|
||||
if (rc != PUNKTFUNK_STATUS_OK) continue;
|
||||
// a.samples = a.frame_count * a.channels f32s, valid until the next PCM call.
|
||||
audio_sink_write(a.samples, a.frame_count, a.channels);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Input (uplink)
|
||||
|
||||
Fill a `PunktfunkInputEvent` and send it — non-blocking, from any thread:
|
||||
|
||||
```c
|
||||
PunktfunkInputEvent ev; memset(&ev, 0, sizeof ev);
|
||||
```
|
||||
|
||||
Field meaning depends on `kind` (`PunktfunkInputKind`). The contracts that trip people up:
|
||||
|
||||
- **Absolute pointer / touch** (`MOUSE_MOVE_ABS`, `TOUCH_DOWN/MOVE`): `x`/`y` are pixel coordinates
|
||||
and **`flags` must pack your coordinate space as `(width << 16) | height`** — the host normalizes
|
||||
against it. A **zero `flags` is dropped**, so always set it.
|
||||
- **Gamepad button** (`GAMEPAD_BUTTON`): `code` = a `PUNKTFUNK_BTN_*` bit, `x != 0` = pressed,
|
||||
`flags` = pad index (0..15).
|
||||
- **Gamepad axis** (`GAMEPAD_AXIS`): `code` = `PUNKTFUNK_AXIS_*`, `flags` = pad index. Sticks are
|
||||
**i16 (−32768..32767), +y = up** (opposite of screen coordinates); triggers are 0..255.
|
||||
- **Relative mouse** (`MOUSE_MOVE`): `x`/`y` carry `dx`/`dy`. **Scroll** (`MOUSE_SCROLL`): `x` is the
|
||||
signed delta.
|
||||
|
||||
```c
|
||||
// Example: press A on pad 0
|
||||
ev.kind = PUNKTFUNK_INPUT_KIND_GAMEPAD_BUTTON;
|
||||
ev.code = PUNKTFUNK_BTN_A; ev.x = 1; ev.flags = 0;
|
||||
punktfunk_connection_send_input(c, &ev);
|
||||
|
||||
// Example: absolute touch at (640,360) on a 1280x720 surface
|
||||
ev.kind = PUNKTFUNK_INPUT_KIND_TOUCH_DOWN;
|
||||
ev.code = 0 /*finger id*/; ev.x = 640; ev.y = 360;
|
||||
ev.flags = (1280u << 16) | 720u;
|
||||
punktfunk_connection_send_input(c, &ev);
|
||||
```
|
||||
|
||||
> Gamepad state on the C ABI uses **per-transition** button/axis events (above). The wire also has an
|
||||
> idempotent full-pad *snapshot* mode (`HOST_CAP_GAMEPAD_STATE`), but it is not exposed as a
|
||||
> dedicated C entry point — per-transition events are accepted by every host and are the C path.
|
||||
|
||||
**Other uplinks (all non-blocking):**
|
||||
|
||||
- `punktfunk_connection_send_mic(c, opus, len, seq, pts_ns)` — Opus mic frames (you encode).
|
||||
- `punktfunk_connection_send_rich_input(c, &rich)` — DualSense touchpad contact / motion sample.
|
||||
- `punktfunk_connection_send_rich_input2(c, &richEx)` — the forward-compatible superset (Steam
|
||||
trackpads, signed coords, pressure); set `struct_size = sizeof(PunktfunkRichInputEx)`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Feedback planes (rumble, HID, HDR)
|
||||
|
||||
Pull these on your feedback thread (or poll with `timeout_ms = 0`). Same
|
||||
`NO_FRAME`/`CLOSED` semantics as everywhere.
|
||||
|
||||
- **Rumble** — `punktfunk_connection_next_rumble2(c, &pad, &low, &high, &ttl_ms, timeout)`.
|
||||
Amplitudes 0..0xFFFF; `(0,0)` = stop. `ttl_ms` is a host-supplied self-terminating lease — render
|
||||
the level for that long unless renewed; `PUNKTFUNK_RUMBLE_NO_TTL` means fall back to your own
|
||||
staleness timeout. (The v1 `_next_rumble` drops the TTL — prefer v2.)
|
||||
- **DualSense HID output** — `punktfunk_connection_next_hidout(c, &out, timeout)`. `out.kind` selects
|
||||
lightbar RGB / player LEDs / adaptive-trigger effect / trackpad haptic. Replay on a real DualSense
|
||||
via the platform's controller API. Only a DualSense-backend session emits these.
|
||||
- **HDR metadata** — `punktfunk_connection_next_hdr_meta(c, &meta, timeout)`. ST.2086 mastering
|
||||
display + content light level, in HDR10 SEI fixed-point units — ready to hand to DXGI
|
||||
`DXGI_HDR_METADATA_HDR10`, Apple `CAEDRMetadata`, or Android `KEY_HDR_STATIC_INFO`. Only an HDR
|
||||
session emits these; apply the latest to your display.
|
||||
- **Host timing** (optional, if you advertised `VIDEO_CAP_HOST_TIMING`) —
|
||||
`punktfunk_connection_next_host_timing` gives the host's capture→sent µs per AU, so your stats HUD
|
||||
can split `host` vs `network` latency.
|
||||
|
||||
---
|
||||
|
||||
## 10. Speed test and stats
|
||||
|
||||
```c
|
||||
punktfunk_connection_speed_test(c, /*target_kbps=*/50000, /*duration_ms=*/2000); // pauses video briefly
|
||||
PunktfunkProbeResult r;
|
||||
do { punktfunk_connection_probe_result(c, &r); } while (!r.done); // poll
|
||||
// r.throughput_kbps drives a bitrate choice; r.loss_pct vs r.host_drop_pct
|
||||
// distinguishes a lossy link from a host that can't keep up.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Teardown
|
||||
|
||||
```c
|
||||
punktfunk_connection_disconnect_quit(c); // user pressed "stop": host tears down now, no linger
|
||||
punktfunk_connection_close(c); // joins internal threads, frees the handle
|
||||
```
|
||||
|
||||
Call `disconnect_quit` **only** on a deliberate user quit — it makes the host drop the virtual
|
||||
display immediately. On a network drop / backgrounding, skip it (a plain `close`) so the host lingers
|
||||
and a reconnect can resume. After `close`, the handle is dead; stop all your plane threads first.
|
||||
|
||||
---
|
||||
|
||||
# Platform integration blueprints
|
||||
|
||||
Everything above is exact — it's the punktfunk side, which is identical on every platform. The
|
||||
sections below are **blueprints**: they name the real decode/present/input subsystems on each target
|
||||
and show where the punktfunk glue plugs in. Treat the platform-SDK calls as illustrative — confirm
|
||||
signatures against that platform's current SDK. The pattern is always the same:
|
||||
|
||||
> **connect → build HW decoder from the resolved codec/color → three threads (video decode+present,
|
||||
> audio, feedback) → translate native input into `PunktfunkInputEvent`s.**
|
||||
|
||||
---
|
||||
|
||||
## 12. webOS (LG Smart TV)
|
||||
|
||||
**App model.** webOS ships both web apps and **native** apps via the **webOS NDK** (Linux, ARM —
|
||||
usually `aarch64`, some older panels `armv7`). A low-latency streaming client wants the native path:
|
||||
you get real threads, a hardware video pipeline, and SDL for window/input. (This mirrors how the
|
||||
community Moonlight webOS client is built.)
|
||||
|
||||
**Cross-compile the core.** Build `libpunktfunk_core.so` with the webOS NDK sysroot:
|
||||
|
||||
```sh
|
||||
rustup target add aarch64-unknown-linux-gnu
|
||||
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=$WEBOS_NDK/.../aarch64-webos-linux-gnu-gcc
|
||||
export CC_aarch64_unknown_linux_gnu=$WEBOS_NDK/.../aarch64-webos-linux-gnu-gcc
|
||||
cargo build -p punktfunk-core --features quic --release --target aarch64-unknown-linux-gnu
|
||||
```
|
||||
|
||||
Bundle the `.so` in your `.ipk` alongside the header-compiled client.
|
||||
|
||||
**Video decode + present.** Use the webOS media pipeline for the SoC's hardware decoder — historically
|
||||
**NDL-DirectMedia** and on newer panels the **Starfish** media pipeline
|
||||
(`com.webos.service.mediapipeline`). You push the punktfunk AUs (Annex-B H.264/HEVC/AV1) into the
|
||||
pipeline's `feed`/`push` entry point; it decodes and composits to the TV's video plane, and you draw
|
||||
your overlay/UI on the graphics plane over it. Present cadence is driven by the pipeline, not you.
|
||||
|
||||
**Audio.** Simplest is `punktfunk_connection_next_audio_pcm` → **PulseAudio** (webOS's audio server)
|
||||
via a simple playback stream. Or route the raw Opus into the media pipeline if it accepts a
|
||||
secondary audio ES.
|
||||
|
||||
**Input.** The Magic Remote / standard remote and Bluetooth gamepads surface as **SDL2** events (LG
|
||||
ships SDL for NDK apps). Map remote keys to `KEY_DOWN/UP`, the pointer to `MOUSE_MOVE_ABS` (set
|
||||
`flags = (w<<16)|h`), and `SDL_GameController` axes/buttons to `GAMEPAD_AXIS`/`GAMEPAD_BUTTON`.
|
||||
|
||||
**Skeleton:**
|
||||
|
||||
```c
|
||||
// 1. connect (HEVC, stereo, SDR to start)
|
||||
PunktfunkConnection *c = punktfunk_connect_ex7(host, 9777, 1920,1080,60,
|
||||
PUNKTFUNK_COMPOSITOR_AUTO, PUNKTFUNK_GAMEPAD_XBOX360, 20000,
|
||||
/*caps=*/0, /*ch=*/2, PUNKTFUNK_CODEC_HEVC, PUNKTFUNK_CODEC_HEVC,
|
||||
NULL, host_fp, observed, cert, key, 8000);
|
||||
|
||||
uint8_t codec; punktfunk_connection_codec(c, &codec);
|
||||
StarfishPipeline *p = starfish_open(codec /*→ "video/hevc" etc*/, 1920,1080);
|
||||
|
||||
// 2. video thread
|
||||
PunktfunkFrame f;
|
||||
while (running) {
|
||||
if (punktfunk_connection_next_au(c, &f, 20) != PUNKTFUNK_STATUS_OK) continue;
|
||||
bool gap; punktfunk_connection_note_frame_index(c, f.frame_index, &gap);
|
||||
starfish_feed(p, f.data, f.len, f.pts_ns); // HW decode + present
|
||||
uint64_t d; punktfunk_connection_frames_dropped(c, &d);
|
||||
if (d > last_d) { punktfunk_connection_request_keyframe(c); last_d = d; } // throttle
|
||||
}
|
||||
// 3. audio thread → next_audio_pcm → pulse_write(...)
|
||||
// 4. SDL input thread → fill PunktfunkInputEvent → punktfunk_connection_send_input(c, &ev)
|
||||
```
|
||||
|
||||
**Gotchas.** webOS app networking is permitted for UDP/QUIC, but background/suspend policy is
|
||||
aggressive — pull-loop `CLOSED` on backgrounding and reconnect on resume. Video-plane / graphics-plane
|
||||
z-order and scaling are set through the pipeline's display-window API, not by drawing pixels yourself.
|
||||
|
||||
---
|
||||
|
||||
## 13. Xbox (GDK — Series X|S / One)
|
||||
|
||||
**App model.** Use the **Microsoft GDK** (Game Development Kit) for consoles — a **C++** title with
|
||||
**Direct3D 12** and the **GameInput** API. Consoles are **x64**, so the core target is
|
||||
`x86_64-pc-windows-msvc`. (Requires an ID@Xbox / partner console in Developer Mode; UWP on retail is
|
||||
possible but GDK is the low-latency path.)
|
||||
|
||||
**Build the core.** From an MSVC environment:
|
||||
|
||||
```sh
|
||||
rustup target add x86_64-pc-windows-msvc
|
||||
cargo build -p punktfunk-core --features quic --release --target x86_64-pc-windows-msvc
|
||||
```
|
||||
|
||||
You get `punktfunk_core.dll` (+ import lib) and `punktfunk_core.lib` (static). Compile your C++ with
|
||||
`/DPUNKTFUNK_FEATURE_QUIC` and add `include/` to the include path. The header is `extern "C"`-clean
|
||||
for C++ (`__cplusplus` guards are in place).
|
||||
|
||||
**Video decode + present.** Feed the AUs to **Media Foundation** with a **DXVA2 / D3D12
|
||||
hardware-accelerated decoder** (`IMFTransform` H.264/HEVC/AV1 decoder), or a D3D12 Video decode
|
||||
(`ID3D12VideoDecoder`) if you want to own the DPB. Output NV12/P010 textures and present with your
|
||||
D3D12 swapchain. For HDR, set the swapchain to `DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020` and pass
|
||||
`punktfunk_connection_next_hdr_meta` straight into `IDXGISwapChain4::SetHDRMetaData` — the core's
|
||||
`PunktfunkHdrMeta` is already in `DXGI_HDR_METADATA_HDR10` units.
|
||||
|
||||
**Audio.** `punktfunk_connection_next_audio_pcm` (f32) → **XAudio2** source voice, or **WASAPI**
|
||||
shared-mode render. Request 6/8 channels at connect for surround.
|
||||
|
||||
**Input.** **GameInput** (`IGameInput::GetCurrentReading`) gives you gamepad state; diff it per frame
|
||||
and emit `GAMEPAD_BUTTON`/`GAMEPAD_AXIS` events. Because a real Xbox pad drives this, connect with
|
||||
`PUNKTFUNK_GAMEPAD_XBOXONE` for matching glyphs. Rumble comes **back** from the host — feed
|
||||
`punktfunk_connection_next_rumble2` into `IGameInputDevice::SetRumbleState` (map `low`→
|
||||
low-frequency, `high`→high-frequency motors).
|
||||
|
||||
**Skeleton (C++):**
|
||||
|
||||
```cpp
|
||||
auto* c = punktfunk_connect_ex7(host, 9777, 3840,2160,60,
|
||||
PUNKTFUNK_COMPOSITOR_AUTO, PUNKTFUNK_GAMEPAD_XBOXONE, /*bitrate=*/60000,
|
||||
PUNKTFUNK_VIDEO_CAP_10BIT | PUNKTFUNK_VIDEO_CAP_HDR, // 4K HDR
|
||||
/*ch=*/6, PUNKTFUNK_CODEC_HEVC, PUNKTFUNK_CODEC_HEVC,
|
||||
nullptr, hostFp, observed, cert, key, 8000);
|
||||
|
||||
uint8_t trc; punktfunk_connection_color_info(c, nullptr,&trc,nullptr,nullptr,nullptr);
|
||||
bool hdr = (trc == 16 || trc == 18);
|
||||
auto decoder = MakeMFHevcDecoder(d3d12Device, hdr /*P010*/);
|
||||
|
||||
// video thread
|
||||
PunktfunkFrame f;
|
||||
while (running) {
|
||||
if (punktfunk_connection_next_au(c, &f, 20) != PUNKTFUNK_STATUS_OK) continue;
|
||||
bool gap; punktfunk_connection_note_frame_index(c, f.frame_index, &gap);
|
||||
decoder.Decode(f.data, f.len, f.pts_ns); // → NV12/P010 texture → D3D12 present
|
||||
}
|
||||
// feedback thread
|
||||
uint16_t pad, lo, hi; uint32_t ttl;
|
||||
while (punktfunk_connection_next_rumble2(c,&pad,&lo,&hi,&ttl, 100) == PUNKTFUNK_STATUS_OK)
|
||||
SetRumble(pad, lo, hi, ttl);
|
||||
// HDR: PunktfunkHdrMeta hm; next_hdr_meta(...) → swapChain4->SetHDRMetaData(HDR10, &hm-as-DXGI)
|
||||
```
|
||||
|
||||
**Gotchas.** GDK console socket use is allowed but goes through the title's network stack — enable it
|
||||
in your MicrosoftGame.config and test in the console sandbox. Retail devices need proper cert; keep
|
||||
the DLL and header ABI versions locked (§2.5) in your CI.
|
||||
|
||||
---
|
||||
|
||||
## 14. Tizen (Samsung Smart TV)
|
||||
|
||||
**App model.** Use **Tizen Native** (C, EFL) — .NET/web apps can't get low-latency HW-decode feed
|
||||
access cleanly. TVs are ARM (`aarch64` on modern panels, `armv7` on older). Build with the Tizen
|
||||
Studio / GBS toolchain and its sysroot.
|
||||
|
||||
**Cross-compile the core.**
|
||||
|
||||
```sh
|
||||
rustup target add aarch64-unknown-linux-gnu # or armv7-unknown-linux-gnueabihf
|
||||
export CC_aarch64_unknown_linux_gnu=$TIZEN_TOOLCHAIN/bin/aarch64-tizen-linux-gnu-gcc
|
||||
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=$CC_aarch64_unknown_linux_gnu
|
||||
cargo build -p punktfunk-core --features quic --release --target aarch64-unknown-linux-gnu
|
||||
```
|
||||
|
||||
Add the `.so` to your project's `lib/` and link with `-lpunktfunk_core`, compiling with
|
||||
`-DPUNKTFUNK_FEATURE_QUIC`.
|
||||
|
||||
**Video decode + present.** Use **`capi-media-codec`** (`mediacodec_*`) — Tizen's hardware codec
|
||||
binding over OpenMAX. Configure it from the resolved codec (`MEDIACODEC_H264`/`_HEVC`/AV1), wrap each
|
||||
AU in a **`media_packet`** (`capi-media-tool`) and `mediacodec_process_input`; on the output callback
|
||||
render the decoded packet to an **Evas GL** surface (or hand tunpacked frames to the TV video plane).
|
||||
For HDR, drive the panel's HDR mode from `punktfunk_connection_color_info` + `next_hdr_meta`.
|
||||
|
||||
**Audio.** `punktfunk_connection_next_audio_pcm` (f32, 48 kHz) → **`capi-media-audio-io`**
|
||||
(`audio_out_*`). Convert f32→s16 if you open the sink as PCM S16.
|
||||
|
||||
**Input.** TV remote and Bluetooth gamepads arrive as **Ecore** key events (`Ecore_Event_Key`).
|
||||
Register key grabs (`elm_win_keygrab_set`) for the remote, translate D-pad/OK/back to
|
||||
`KEY_DOWN/UP` or gamepad buttons, and map any HID gamepad to `GAMEPAD_AXIS`/`GAMEPAD_BUTTON`.
|
||||
|
||||
**Skeleton:**
|
||||
|
||||
```c
|
||||
PunktfunkConnection *c = punktfunk_connect_ex7(host, 9777, 1920,1080,60,
|
||||
PUNKTFUNK_COMPOSITOR_AUTO, PUNKTFUNK_GAMEPAD_XBOX360, 20000,
|
||||
/*caps=*/0, /*ch=*/2, PUNKTFUNK_CODEC_HEVC | PUNKTFUNK_CODEC_H264,
|
||||
PUNKTFUNK_CODEC_HEVC, NULL, host_fp, observed, cert, key, 8000);
|
||||
|
||||
uint8_t codec; punktfunk_connection_codec(c, &codec);
|
||||
mediacodec_h mc; mediacodec_create(&mc);
|
||||
mediacodec_set_codec(mc, codec==PUNKTFUNK_CODEC_HEVC?MEDIACODEC_HEVC:MEDIACODEC_H264,
|
||||
MEDIACODEC_DECODER | MEDIACODEC_SUPPORT_TYPE_HW);
|
||||
mediacodec_set_output_buffer_available_cb(mc, on_decoded /*→ Evas GL present*/, NULL);
|
||||
mediacodec_prepare(mc);
|
||||
|
||||
// video thread
|
||||
PunktfunkFrame f;
|
||||
while (running) {
|
||||
if (punktfunk_connection_next_au(c, &f, 20) != PUNKTFUNK_STATUS_OK) continue;
|
||||
bool gap; punktfunk_connection_note_frame_index(c, f.frame_index, &gap);
|
||||
media_packet_h pkt = wrap_au(f.data, f.len, f.pts_ns); // capi-media-tool
|
||||
mediacodec_process_input(mc, pkt, 0);
|
||||
uint64_t d; punktfunk_connection_frames_dropped(c, &d);
|
||||
if (d > last_d) { punktfunk_connection_request_keyframe(c); last_d = d; } // throttle
|
||||
}
|
||||
// audio thread: next_audio_pcm → audio_out_write(...)
|
||||
// Ecore key handler: fill PunktfunkInputEvent → punktfunk_connection_send_input(c, &ev)
|
||||
```
|
||||
|
||||
**Gotchas.** Declare the `internet` and `network.get` privileges in `tizen-manifest.xml` or the QUIC
|
||||
socket won't open. `mediacodec` prefers Annex-B start codes and periodic parameter sets — the
|
||||
punktfunk IDR carries in-band parameter sets, but if your SoC decoder wants an explicit
|
||||
codec-config/CSD, extract VPS/SPS/PPS from the first IDR and feed it before the stream. On a
|
||||
mid-session mode change (`request_mode`), tear down and re-prepare the `mediacodec` from the new IDR.
|
||||
|
||||
---
|
||||
|
||||
## 15. Checklist for a new port
|
||||
|
||||
- [ ] Build the core with `--features quic`; compile your app with `-DPUNKTFUNK_FEATURE_QUIC`.
|
||||
- [ ] `punktfunk_abi_version() == ABI_VERSION` at startup.
|
||||
- [ ] Persist a generated identity; implement PIN pairing; pin the host fingerprint.
|
||||
- [ ] Connect off the UI thread; **build the decoder from the *resolved* codec/color/chroma**, not
|
||||
your request.
|
||||
- [ ] One thread per plane; never two on the same plane; decode/copy borrowed buffers before the next
|
||||
pull.
|
||||
- [ ] `note_frame_index` every video frame; throttled `frames_dropped`→`request_keyframe` backstop.
|
||||
- [ ] Rebuild the decoder on the IDR after any accepted `request_mode`.
|
||||
- [ ] Set `flags = (w<<16)|h` on absolute pointer/touch events (nonzero!).
|
||||
- [ ] `disconnect_quit` only on deliberate user quit; always `close` and stop plane threads on
|
||||
teardown.
|
||||
|
||||
## 16. Reference
|
||||
|
||||
- **Header (the contract):** [`include/punktfunk_core.h`](../include/punktfunk_core.h)
|
||||
- **Minimal C link + round-trip proof:** [`crates/punktfunk-core/tests/c/`](../crates/punktfunk-core/tests/c/)
|
||||
- **Core crate README:** [`crates/punktfunk-core/README.md`](../crates/punktfunk-core/README.md)
|
||||
- **A full reference client (Rust, same ABI surface):** `crates/pf-client-core/src/session.rs`
|
||||
+412
-1
@@ -25,7 +25,11 @@
|
||||
// TTL of a v2 envelope; `punktfunk_connection_next_rumble` is unchanged and drops it). Additive —
|
||||
// the wire is backward-compatible (the envelope is a length-tolerant tail on 0xCA), so
|
||||
// [`WIRE_VERSION`] is unchanged.
|
||||
#define ABI_VERSION 5
|
||||
// v6: 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.
|
||||
#define ABI_VERSION 6
|
||||
|
||||
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
@@ -159,11 +163,60 @@
|
||||
// Codec bit: AV1. (Mirrors `quic::CODEC_AV1`.)
|
||||
#define PUNKTFUNK_CODEC_AV1 4
|
||||
|
||||
// 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`.)
|
||||
#define PUNKTFUNK_HOST_CAP_GAMEPAD_STATE 1
|
||||
|
||||
// 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`.)
|
||||
#define PUNKTFUNK_HOST_CAP_CLIPBOARD 2
|
||||
|
||||
// `*ttl_ms` sentinel written by [`punktfunk_connection_next_rumble2`] for a legacy (v1) rumble
|
||||
// datagram — an old host that sent no self-termination lease. The client then falls back to its
|
||||
// own staleness heuristic for that update instead of a host-supplied deadline.
|
||||
#define PUNKTFUNK_RUMBLE_NO_TTL 4294967295
|
||||
|
||||
// [`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`].
|
||||
#define PUNKTFUNK_CLIP_REMOTE_OFFER 1
|
||||
|
||||
// [`PunktfunkClipEvent::kind`] — host ack / policy / backend update (`enabled`/`policy`/`reason`
|
||||
// valid). Reflect it in the toggle UI.
|
||||
#define PUNKTFUNK_CLIP_STATE 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).
|
||||
#define PUNKTFUNK_CLIP_FETCH_REQUEST 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).
|
||||
#define PUNKTFUNK_CLIP_DATA 4
|
||||
|
||||
// [`PunktfunkClipEvent::kind`] — a transfer was cancelled (`transfer_id` = the id).
|
||||
#define PUNKTFUNK_CLIP_CANCELLED 5
|
||||
|
||||
// [`PunktfunkClipEvent::kind`] — a transfer failed (`transfer_id` = the id; `status` = a
|
||||
// `PunktfunkStatus` code).
|
||||
#define PUNKTFUNK_CLIP_ERROR 6
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// 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.
|
||||
#define CLIP_FETCH_CAP (64 << 20)
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// 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.
|
||||
#define INBOUND_REQ_FLAG 2147483648
|
||||
#endif
|
||||
|
||||
// 16-byte AEAD authentication tag appended by GCM.
|
||||
#define TAG_LEN 16
|
||||
|
||||
@@ -255,6 +308,34 @@
|
||||
// feeding them to the decoder. Punktfunk/1 only (GameStream never sets it).
|
||||
#define FLAG_PROBE 8
|
||||
|
||||
// Application `user_flags` bit (the u32 [`PacketHeader::user_flags`] word, surfaced to the client
|
||||
// as [`crate::session::Frame::flags`]) — NOT a transport packet flag. Marks the access unit that
|
||||
// **completes an intra-refresh wave**: the picture is loss-free from here even though the frame is
|
||||
// a coded `P` (no IDR, so the decoder never sets `AV_FRAME_FLAG_KEY`). The client lifts its
|
||||
// post-loss display freeze on this bit as well as on a real keyframe — the only bitstream-invisible
|
||||
// clean point it can honor without forcing a full IDR. Lives above the low nibble because the host
|
||||
// reuses `FLAG_PIC`/`FLAG_SOF`/`FLAG_PROBE` bit values inside `user_flags`; `0x10` clears all four.
|
||||
#define USER_FLAG_RECOVERY_POINT 16
|
||||
|
||||
// Application `user_flags` bit — a **definitive single-frame clean re-anchor**. Unlike
|
||||
// [`USER_FLAG_RECOVERY_POINT`] (an intra-refresh wave boundary, where the first boundary after a loss
|
||||
// is only half-healed so the client waits for the second), this marks an access unit the host coded
|
||||
// to reference a **known-good** picture on purpose — an AMD **LTR reference-frame-invalidation**
|
||||
// recovery frame (`ForceLTRReferenceBitfield`): a clean P-frame off a long-term reference the client
|
||||
// already has, not an IDR. The picture is loss-free the instant this AU decodes, so the client lifts
|
||||
// its post-loss freeze on the **first** such mark. Coded `P` (no IDR), so the decoder never sets
|
||||
// `AV_FRAME_FLAG_KEY` — this host flag is the only signal.
|
||||
#define USER_FLAG_RECOVERY_ANCHOR 32
|
||||
|
||||
// Widest lost-frame range (frames, wrapping `last - first`) a reference-frame-invalidation
|
||||
// recovery may be asked to repair; anything wider goes straight to the keyframe path on BOTH
|
||||
// ends. RFI can only re-reference history the encoder still holds — NVENC keeps a 5-frame DPB,
|
||||
// AMD LTR ~1 s of marks — and a genuine loss this wide (>1 s even at 240 fps) has no valid
|
||||
// reference anywhere, so an RFI request for it is either hopeless or (worse) a phantom range
|
||||
// from a desynced counter. Shared by the host's RFI dispatch (range → keyframe fallback) and the
|
||||
// client-side gap detectors (huge gap → resync + keyframe request, no RFI).
|
||||
#define RFI_MAX_RANGE 256
|
||||
|
||||
// Largest UDP datagram the core will send or accept. `Config::validate` bounds
|
||||
// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
|
||||
#define MAX_DATAGRAM_BYTES 2048
|
||||
@@ -363,6 +444,21 @@
|
||||
#define VIDEO_CAP_HOST_TIMING 8
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Hello::video_caps`] bit: the client's reassembler keeps **speed-test probe filler in its own
|
||||
// frame-index space** (a second reassembly window keyed on the [`crate::packet::FLAG_PROBE`]
|
||||
// user-flag), so probe bursts no longer consume video `frame_index`es. Without this, a mid-session
|
||||
// speed test burns thousands of video indexes that are invisible to every client-side gap detector
|
||||
// (probe frames are filtered before the pump sees them) — the first real AU afterwards reads as a
|
||||
// phantom multi-thousand-frame loss (spurious freeze + a nonsense RFI). It also lets the host's
|
||||
// encode loop own the video numbering outright (the wire-index contract
|
||||
// [`crate::packet::Packetizer::packetize_each`] documents), which reference-frame invalidation
|
||||
// depends on. The host runs mid-session probe bursts ONLY against clients that set this bit — an
|
||||
// older client gets a declined (zeroed) [`ProbeResult`] instead of a measurement its single-window
|
||||
// reassembler would silently drop as stale.
|
||||
#define VIDEO_CAP_PROBE_SEQ 16
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// QUIC application error code a punktfunk/1 client closes the control connection with on a
|
||||
// **deliberate quit** (a user "stop", not a network drop). The host reads it off the connection's
|
||||
@@ -391,6 +487,16 @@
|
||||
#define HOST_CAP_GAMEPAD_STATE 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`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.
|
||||
#define HOST_CAP_CLIPBOARD 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`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
|
||||
@@ -462,6 +568,11 @@
|
||||
#define MSG_BITRATE_CHANGED 6
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`RfiRequest`].
|
||||
#define MSG_RFI_REQUEST 7
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ProbeRequest`].
|
||||
#define MSG_PROBE_REQUEST 32
|
||||
@@ -482,6 +593,119 @@
|
||||
#define MSG_CLOCK_ECHO 49
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ClipControl`] (client → host): enable/disable the shared clipboard for this
|
||||
// session. Idempotent; opt-in is enforced here, not just in UI.
|
||||
#define MSG_CLIP_CONTROL 64
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ClipState`] (host → client): ack + unsolicited policy/backend updates.
|
||||
#define MSG_CLIP_STATE 65
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ClipOffer`] (symmetric): the lazy announcement — format list only, no bytes.
|
||||
#define MSG_CLIP_OFFER 66
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ClipFetch`] (requester → holder, **fetch stream only**): pull one format of the
|
||||
// current offer.
|
||||
#define MSG_CLIP_FETCH 67
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ClipFetchHdr`] (holder → requester, **fetch stream only**): the fetch response
|
||||
// header that precedes the data chunks.
|
||||
#define MSG_CLIP_FETCH_HDR 68
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`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).
|
||||
#define CLIP_FLAG_FILES 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipState::policy`] bit: the host permits non-file formats (text/RTF/HTML/image). Always set
|
||||
// while enabled unless a future direction limit clears it.
|
||||
#define CLIP_POLICY_TEXT 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`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".
|
||||
#define CLIP_POLICY_FILES 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipState::reason`]: normal ack, nothing exceptional.
|
||||
#define CLIP_REASON_OK 0
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`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".
|
||||
#define CLIP_REASON_BACKEND_UNAVAILABLE 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipState::reason`]: another client took over the single per-desktop clipboard binding; this
|
||||
// one was disabled (last `ClipControl{enabled}` wins).
|
||||
#define CLIP_REASON_TAKEN_OVER 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipState::reason`]: the host operator policy (`PUNKTFUNK_CLIPBOARD=off`) disables clipboard.
|
||||
#define CLIP_REASON_POLICY_DISABLED 3
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipState::reason`]: enabled, but the host policy forbids file transfer (`no-files` /
|
||||
// `text-only`) — surfaced so the client greys "Include files" with a footnote.
|
||||
#define CLIP_REASON_NO_FILES 4
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipFetchHdr::status`]: the requested format is being served; data chunks follow until FIN.
|
||||
#define CLIP_FETCH_OK 0
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`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.
|
||||
#define CLIP_FETCH_STALE 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipFetchHdr::status`]: the format/index is not available (no backend, or it vanished). No
|
||||
// chunks follow.
|
||||
#define CLIP_FETCH_UNAVAILABLE 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`ClipFetchHdr::status`]: policy/cap denies this fetch (e.g. a file fetch under `no-files`). No
|
||||
// chunks follow.
|
||||
#define CLIP_FETCH_DENIED 3
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Maximum number of [`ClipKind`] entries in one [`ClipOffer`] (resource cap, §7).
|
||||
#define CLIP_MAX_KINDS 16
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Maximum length in bytes of a [`ClipKind::mime`] string (resource cap, §7).
|
||||
#define CLIP_MAX_MIME 128
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`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`.
|
||||
#define CLIP_FILE_INDEX_NONE UINT32_MAX
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`PairRequest`].
|
||||
#define MSG_PAIR_REQUEST 16
|
||||
@@ -538,6 +762,25 @@
|
||||
#define ColorInfo_MC_BT2020_NCL 9
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// 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.
|
||||
#define CLIP_STREAM_KIND_FETCH 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// 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`) and the connection reject code `0x42`.
|
||||
#define CLIP_CANCELLED_CODE 96
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Chunk size for streaming fetch data (64 KiB writes — matches the control-frame bound).
|
||||
#define CLIP_CHUNK (64 * 1024)
|
||||
#endif
|
||||
|
||||
// Stable C ABI status codes. `Ok` is 0; all errors are negative so callers can
|
||||
// test `rc < 0`. Do not renumber existing variants — only append.
|
||||
enum PunktfunkStatus
|
||||
@@ -867,6 +1110,47 @@ typedef struct {
|
||||
} PunktfunkRichInputEx;
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// One advertised clipboard format passed to [`punktfunk_connection_clipboard_offer`].
|
||||
typedef struct {
|
||||
// NUL-terminated UTF-8 wire MIME (e.g. `text/plain;charset=utf-8`). ≤ 128 bytes on the wire.
|
||||
const char *mime;
|
||||
// Best-effort size in bytes; `0` = unknown.
|
||||
uint64_t size_hint;
|
||||
} PunktfunkClipKind;
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// 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.
|
||||
typedef struct {
|
||||
// One of `PUNKTFUNK_CLIP_*`.
|
||||
uint8_t kind;
|
||||
// `State`: 1 = enabled, 0 = disabled.
|
||||
uint8_t enabled;
|
||||
// `State`: bitfield of `quic::CLIP_POLICY_*` — what the host currently permits.
|
||||
uint8_t policy;
|
||||
// `State`: one of `quic::CLIP_REASON_*`.
|
||||
uint8_t reason;
|
||||
// `Data`: 1 = final chunk of this transfer.
|
||||
uint8_t last;
|
||||
// Per-transfer id: the offer `seq` (RemoteOffer), the `req_id` (FetchRequest), or the
|
||||
// `xfer_id` (Data/Cancelled/Error).
|
||||
uint32_t transfer_id;
|
||||
// `FetchRequest`: the offer `seq` the request is against.
|
||||
uint32_t seq;
|
||||
// `FetchRequest`: file index, or `quic::CLIP_FILE_INDEX_NONE`.
|
||||
uint32_t file_index;
|
||||
// `Error`: a `PunktfunkStatus` code (negative); 0 otherwise.
|
||||
int32_t status;
|
||||
// RemoteOffer/FetchRequest/Data: a pointer into a per-connection slot, valid until the next
|
||||
// `next_clipboard` call; NULL for the other kinds.
|
||||
const uint8_t *data;
|
||||
// Byte length of `data` (0 when `data` is NULL).
|
||||
uintptr_t len;
|
||||
} PunktfunkClipEvent;
|
||||
#endif
|
||||
|
||||
// A speed-test measurement, filled by [`punktfunk_connection_probe_result`]. `done` is 0 until
|
||||
// the host's end-of-burst report lands, then 1 (the numbers are final). `throughput_kbps` is the
|
||||
// delivered wire throughput to drive a bitrate choice from; `loss_pct` is the link loss and
|
||||
@@ -1506,6 +1790,96 @@ PunktfunkStatus punktfunk_connection_mode(const PunktfunkConnection *c,
|
||||
PunktfunkStatus punktfunk_connection_gamepad(const PunktfunkConnection *c, uint32_t *gamepad);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// 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).
|
||||
PunktfunkStatus punktfunk_connection_host_caps(const PunktfunkConnection *c, uint8_t *caps);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// 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.
|
||||
PunktfunkStatus punktfunk_connection_clipboard_control(const PunktfunkConnection *c,
|
||||
bool enabled,
|
||||
uint8_t flags);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// 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.
|
||||
PunktfunkStatus punktfunk_connection_clipboard_offer(const PunktfunkConnection *c,
|
||||
uint32_t seq,
|
||||
const PunktfunkClipKind *kinds,
|
||||
uintptr_t n);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// 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).
|
||||
PunktfunkStatus punktfunk_connection_clipboard_fetch(const PunktfunkConnection *c,
|
||||
uint32_t seq,
|
||||
const char *mime,
|
||||
uint32_t file_index,
|
||||
uint32_t *xfer_id_out);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// 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`).
|
||||
PunktfunkStatus punktfunk_connection_clipboard_serve(const PunktfunkConnection *c,
|
||||
uint32_t req_id,
|
||||
const uint8_t *data,
|
||||
uintptr_t len,
|
||||
bool last);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// 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.
|
||||
PunktfunkStatus punktfunk_connection_clipboard_cancel(const PunktfunkConnection *c, uint32_t id);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// 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`.
|
||||
PunktfunkStatus punktfunk_connection_next_clipboard(PunktfunkConnection *c,
|
||||
PunktfunkClipEvent *out,
|
||||
uint32_t timeout_ms);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// 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`]
|
||||
@@ -1573,6 +1947,43 @@ PunktfunkStatus punktfunk_connection_request_mode(const PunktfunkConnection *c,
|
||||
PunktfunkStatus punktfunk_connection_request_keyframe(const PunktfunkConnection *c);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Ask the host to recover from loss by **reference-frame invalidation** rather than a full IDR:
|
||||
// report the range `[first_frame, last_frame]` of access units the client can no longer trust
|
||||
// (the first missing `frame_index` through the newest received). An RFI-capable host (AMD LTR /
|
||||
// NVENC) re-references a known-good picture before `first_frame` and emits a clean P-frame tagged
|
||||
// `USER_FLAG_RECOVERY_ANCHOR` — no 20-40x IDR spike; a host that can't RFI forces an IDR instead
|
||||
// (same effect as [`punktfunk_connection_request_keyframe`]). Non-blocking, fire-and-forget; the
|
||||
// recovered frame is the only ack, so THROTTLE it exactly like the keyframe request. Prefer this
|
||||
// over the keyframe request on loss so AMD/RFI hosts avoid the spike; keep the keyframe request as
|
||||
// the backstop for when the recovery frame itself is lost.
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle.
|
||||
PunktfunkStatus punktfunk_connection_request_rfi(const PunktfunkConnection *c,
|
||||
uint32_t first_frame,
|
||||
uint32_t last_frame);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Feed each received frame's `frame_index` (the [`PunktfunkFrame::frame_index`] field, in receive
|
||||
// order) so the client recovers from loss with a cheap reference-frame invalidation instead of a
|
||||
// full IDR. On a forward gap (a `frame_index` jump = the intervening frames were lost and the
|
||||
// following AUs reference a picture that never arrived) this fires a THROTTLED
|
||||
// [`punktfunk_connection_request_rfi`] for the lost range; an RFI-capable host (AMD LTR / NVENC)
|
||||
// then recovers with a clean P-frame instead of a 20-40x IDR spike. Call it for every received
|
||||
// frame — it is cheap and idempotent, and the [`punktfunk_connection_frames_dropped`]-driven
|
||||
// keyframe request stays the backstop. Writes whether a forward gap was detected this call to
|
||||
// `gap_out` (nullable — a client with a post-loss display freeze can use it to re-arm; most
|
||||
// clients pass NULL and ignore it).
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle; `gap_out` is writable or NULL.
|
||||
PunktfunkStatus punktfunk_connection_note_frame_index(const PunktfunkConnection *c,
|
||||
uint32_t frame_index,
|
||||
bool *gap_out);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Cumulative access units the host→client reassembler dropped as unrecoverable (FEC couldn't
|
||||
// rebuild them). A video loop polls this and calls [`punktfunk_connection_request_keyframe`]
|
||||
|
||||
@@ -67,7 +67,13 @@ build() {
|
||||
# caveat the RPM documents): ln -s "$(find / -name libcuda.so -path '*stubs*'|head -1)" /usr/lib/
|
||||
# punktfunk-client-session is the Vulkan/Skia streamer the shell (punktfunk-client) execs for
|
||||
# a connect — both binaries must ship or streaming from the desktop client breaks.
|
||||
cargo build --release --locked \
|
||||
# `--features punktfunk-host/nvenc` compiles in the direct-SDK NVENC path (real RFI + recovery
|
||||
# anchor on Linux NVIDIA; design/linux-direct-nvenc.md). AMD/Intel-SAFE: the NVENC/CUDA entry
|
||||
# points are dlopen'd at RUNTIME (libloading), never link-imported — `objdump -p` shows the same
|
||||
# DT_NEEDED as a plain build (no libcuda/libnvidia-encode), so the binary starts fine driver-less
|
||||
# and only touches NVIDIA on a CUDA capture frame (default on NVIDIA; PUNKTFUNK_NVENC_DIRECT=0
|
||||
# opts back to libav). AMD/Intel never reach it — the `cuda` gate leaves them on VAAPI.
|
||||
cargo build --release --locked --features punktfunk-host/nvenc \
|
||||
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-tray
|
||||
# Management web console (opt-in): the Nitro `bun`-preset .output bundle (Bun.serve TLS),
|
||||
# built AND run with bun.
|
||||
|
||||
@@ -85,7 +85,7 @@ ujust add-user-to-input-group # virtual gamepads need /dev/uinput (re-
|
||||
mkdir -p ~/.config/punktfunk
|
||||
cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env # gamescope defaults
|
||||
systemctl --user enable --now punktfunk-host
|
||||
# Web console — enable it and read the auto-generated login password (then open http://<host-ip>:47992):
|
||||
# Web console — enable it and read the auto-generated login password (then open https://<host-ip>:47992):
|
||||
systemctl --user enable --now punktfunk-web
|
||||
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
|
||||
```
|
||||
|
||||
@@ -175,7 +175,12 @@ export PUNKTFUNK_BUILD_VERSION="%{version}-%{release}"
|
||||
# --locked: reproducible from (commit + Cargo.lock), matching the .deb build path.
|
||||
# punktfunk-client-session is the Vulkan/Skia streamer the shell execs for a connect — both
|
||||
# client binaries must ship or streaming from the desktop client breaks.
|
||||
cargo build --release --locked \
|
||||
# --features punktfunk-host/nvenc: the direct-SDK NVENC path (real RFI + recovery anchor on Linux
|
||||
# NVIDIA; design/linux-direct-nvenc.md). AMD/Intel-safe — the NVENC/CUDA entry points are dlopen'd
|
||||
# at runtime (no link-time dep; __requires_exclude already drops libcuda), so the binary starts
|
||||
# driver-less; the encoder engages only on a CUDA frame (default on NVIDIA; PUNKTFUNK_NVENC_DIRECT=0
|
||||
# opts back to libav) — the `cuda` gate keeps AMD/Intel on VAAPI regardless.
|
||||
cargo build --release --locked --features punktfunk-host/nvenc \
|
||||
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-tray
|
||||
|
||||
%if %{with web}
|
||||
@@ -383,7 +388,7 @@ echo "punktfunk-web installed. Enable the console for your user:"
|
||||
echo " systemctl --user enable --now punktfunk-web"
|
||||
echo "A login password is generated on first start — read it with:"
|
||||
echo " journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'"
|
||||
echo "Then open http://<host-ip>:3000"
|
||||
echo "Then open https://<host-ip>:47992"
|
||||
%endif
|
||||
|
||||
%changelog
|
||||
|
||||
@@ -16,6 +16,34 @@ trap {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- reclaim disk before building -----------------------------------------------------------------
|
||||
# The windows-amd64 runner's system volume is intentionally small (100 GB) and a full Windows CI pass
|
||||
# writes ~50 GB of cargo target output into C:\t (x64) / C:\t-a64 (arm64). Left to accumulate across
|
||||
# runs that overflows the disk and the build dies with "no space on device" (os error 112) - exactly
|
||||
# what took the Windows host build down. The runner bakes in a reclaimer + a scheduled task that keeps
|
||||
# an idle box lean (unom/infra's setup-gitea-runner-base.ps1 ->
|
||||
# C:\Users\Public\act-runner\clean-runner-disk.ps1); call it here too so THIS job starts with headroom
|
||||
# regardless of when that task last ran. Threshold mode (no -Force): it only prunes when actually low,
|
||||
# so incremental-compile caches survive when there's room. Best-effort - a cleanup hiccup must never
|
||||
# fail the build.
|
||||
$reclaimer = 'C:\Users\Public\act-runner\clean-runner-disk.ps1'
|
||||
try {
|
||||
if (Test-Path $reclaimer) {
|
||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $reclaimer
|
||||
}
|
||||
else {
|
||||
# Fallback for a runner not yet re-baked with the infra reclaimer: prune the big target dirs when low.
|
||||
$freeGb = [math]::Round((Get-PSDrive C).Free / 1GB, 1)
|
||||
Write-Host "[ensure-toolchain] clean-runner-disk.ps1 absent; C: free ${freeGb} GB"
|
||||
if ($freeGb -lt 35) {
|
||||
foreach ($d in 'C:\t', 'C:\t-a64') {
|
||||
if (Test-Path $d) { Write-Host " reclaiming $d"; Remove-Item $d -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { Write-Warning "disk reclaim step failed (non-fatal): $_" }
|
||||
|
||||
$ciDir = $PSScriptRoot
|
||||
& "$ciDir\provision-windows-wdk.ps1"
|
||||
& "$ciDir\provision-windows-punktfunk-extras.ps1"
|
||||
|
||||
+1
-1
@@ -15,5 +15,5 @@ if [ ! -s "$PWFILE" ]; then
|
||||
(umask 077; printf 'PUNKTFUNK_UI_PASSWORD=%s\n' "$PW" > "$PWFILE")
|
||||
chmod 600 "$PWFILE" 2>/dev/null || true
|
||||
echo "punktfunk web console login password generated: $PW"
|
||||
echo "(stored in $PWFILE — open http://<host-ip>:47992 and log in)"
|
||||
echo "(stored in $PWFILE — open https://<host-ip>:47992 and log in)"
|
||||
fi
|
||||
|
||||
@@ -40,7 +40,7 @@ bun and runs `punktfunk-host.exe web setup`, which registers the **`PunktfunkWeb
|
||||
(at boot, as SYSTEM, restart-on-failure) running `{app}\web\web-run.cmd` →
|
||||
`bun …\.output\server\index.mjs` on `:47992`, opens inbound TCP 47992, and writes the login password to
|
||||
`%ProgramData%\punktfunk\web-password` (ACL'd to Administrators + SYSTEM). The mgmt bearer token it
|
||||
proxies with is the host's own `%ProgramData%\punktfunk\mgmt-token`. Browse `http://<host-ip>:47992`
|
||||
proxies with is the host's own `%ProgramData%\punktfunk\mgmt-token`. Browse `https://<host-ip>:47992`
|
||||
and log in with the password the installer shows on its final page. To change it, edit
|
||||
`web-password` and re-run the task: `schtasks /run /tn PunktfunkWeb`.
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"display_preset": "Voreinstellung",
|
||||
"display_preset_custom": "Benutzerdefiniert",
|
||||
"display_preset_default": "Standard",
|
||||
"display_preset_gaming_rig": "Gaming-Rig",
|
||||
"display_preset_gaming_rig": "Headless-Box",
|
||||
"display_preset_shared_desktop": "Geteilter Desktop",
|
||||
"display_preset_hotdesk": "Hot-Desk",
|
||||
"display_preset_workstation": "Workstation",
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"display_preset": "Preset",
|
||||
"display_preset_custom": "Custom",
|
||||
"display_preset_default": "Default",
|
||||
"display_preset_gaming_rig": "Gaming rig",
|
||||
"display_preset_gaming_rig": "Headless box",
|
||||
"display_preset_shared_desktop": "Shared desktop",
|
||||
"display_preset_hotdesk": "Hot-desk",
|
||||
"display_preset_workstation": "Workstation",
|
||||
|
||||
Reference in New Issue
Block a user