Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9dc6efe55 | |||
| 9822fc3b1c | |||
| cdb43f00fe | |||
| 644c035a34 | |||
| 3c16c1dd30 | |||
| c0fc2d8ee8 | |||
| ef5808254a | |||
| 8a18e130a2 | |||
| cd701a9594 | |||
| 05868ef634 | |||
| 2d37835545 | |||
| 9514a8c0e2 | |||
| 97c67b2692 | |||
| 0ad4e6eff7 | |||
| 76be4c3e12 | |||
| 84329205eb | |||
| e9d8f2bc04 | |||
| bbbb7f5723 | |||
| 5ab6daa694 |
@@ -94,7 +94,10 @@ jobs:
|
||||
# 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 \
|
||||
# --features punktfunk-host/vulkan-encode: the AMD/Intel twin — raw VK_KHR_video_encode_h265
|
||||
# with real RFI (design/linux-vulkan-video-encode.md). Pure Rust ash (no new lib / link dep);
|
||||
# default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0 → libav VAAPI), failed open falls back to VAAPI.
|
||||
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
|
||||
|
||||
- name: Build + smoke-boot web console (bun preset)
|
||||
|
||||
Generated
+14
-27
@@ -870,15 +870,6 @@ dependencies = [
|
||||
"itertools 0.10.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
@@ -2154,7 +2145,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.9.2"
|
||||
version = "0.10.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2286,7 +2277,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.9.2"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2765,7 +2756,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.9.2"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -2787,7 +2778,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.9.2"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2808,7 +2799,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.9.2"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2817,7 +2808,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.9.2"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3001,7 +2992,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.9.2"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3017,7 +3008,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.9.2"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3033,7 +3024,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.9.2"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3048,14 +3039,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.9.2"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
"crossbeam-channel",
|
||||
"ffmpeg-next",
|
||||
"mdns-sd",
|
||||
"opus",
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
"sdl3",
|
||||
@@ -3063,7 +3051,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"wasapi",
|
||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
|
||||
"windows-reactor",
|
||||
"windows-reactor-setup",
|
||||
@@ -3072,7 +3059,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.9.2"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3103,7 +3090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.9.2"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3175,7 +3162,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.9.2"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3189,7 +3176,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.9.2"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.9.2"
|
||||
version = "0.10.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -158,8 +158,11 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
pads.forEachIndexed { i, dev ->
|
||||
PadRow(dev, forwarded = i == 0, gamepadSetting = gamepadSetting)
|
||||
// Every real controller is forwarded now (Automatic forwards them all, each on its own
|
||||
// wire pad index) — not just the first. A joystick-only device Android doesn't classify as
|
||||
// a gamepad still can't be forwarded (the host wants a gamepad), so gate the badge on it.
|
||||
pads.forEach { dev ->
|
||||
PadRow(dev, forwarded = isForwarded(dev), gamepadSetting = gamepadSetting)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,8 +225,12 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(dev.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
if (forwarded) {
|
||||
// Android's own controller number (1-based; 0 = unassigned), shown so a multi-pad
|
||||
// user can tell which physical pad is which. The stream's wire pad index is
|
||||
// assigned separately (lowest-free per device) once streaming starts.
|
||||
val number = dev.controllerNumber
|
||||
Text(
|
||||
"forwarded to host",
|
||||
if (number > 0) "forwarded · player $number" else "forwarded to host",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
@@ -319,6 +326,15 @@ private fun Group(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this device is actually forwarded to the host — the same rule the stream's [GamepadRouter]
|
||||
* applies: a real, non-virtual controller whose source classes include GAMEPAD. A joystick-only node
|
||||
* (e.g. a DualSense motion-sensor sibling, or an adapter that enumerates as bare joystick) shows in
|
||||
* the list but isn't forwarded.
|
||||
*/
|
||||
private fun isForwarded(dev: InputDevice): Boolean =
|
||||
!dev.isVirtual && dev.sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD
|
||||
|
||||
/** Whether the controller reports a rumble motor — via VibratorManager (API 31+) or the legacy Vibrator. */
|
||||
private fun deviceHasVibrator(dev: InputDevice): Boolean =
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.GamepadRouter
|
||||
import io.unom.punktfunk.kit.Keymap
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
|
||||
@@ -27,8 +28,12 @@ class MainActivity : ComponentActivity() {
|
||||
*/
|
||||
var streamHandle: Long = 0L
|
||||
|
||||
/** Joystick-axis state mapper for the active session (built/reset by StreamScreen). */
|
||||
var axisMapper: Gamepad.AxisMapper? = null
|
||||
/**
|
||||
* Multi-controller router for the active session (built/released by StreamScreen): assigns each
|
||||
* connected pad a stable wire index, threads it onto every event, declares/removes pads on
|
||||
* hot-plug, and routes rumble/HID feedback back by pad index. Null while not streaming.
|
||||
*/
|
||||
var gamepadRouter: GamepadRouter? = null
|
||||
|
||||
/**
|
||||
* Input observers for the Controllers debug screen (set while it is shown, like [streamHandle]).
|
||||
@@ -44,9 +49,6 @@ class MainActivity : ComponentActivity() {
|
||||
*/
|
||||
var requestStreamExit: (() -> Unit)? = null
|
||||
|
||||
/** Currently-held forwarded pad buttons (bitmask of `Gamepad.BTN_*`), for chord detection. */
|
||||
private var heldPadButtons = 0
|
||||
|
||||
/**
|
||||
* Whether the last console input came from a real gamepad (face buttons / stick) vs. a TV D-pad
|
||||
* remote (which has no A/B/X/Y). The console UI reads this to show glyphs the user recognises — pad
|
||||
@@ -125,22 +127,11 @@ class MainActivity : ComponentActivity() {
|
||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
val bit = Gamepad.buttonBit(event.keyCode)
|
||||
if (bit != 0) {
|
||||
when (event.action) {
|
||||
// repeatCount guard: don't re-send a held button as auto-repeat.
|
||||
KeyEvent.ACTION_DOWN -> {
|
||||
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true)
|
||||
heldPadButtons = heldPadButtons or bit
|
||||
// Emergency exit: Select + Start + L1 + R1 held together leaves the stream
|
||||
// (a couch user has no keyboard/Back). Fired once per full chord.
|
||||
if (heldPadButtons and STREAM_EXIT_CHORD == STREAM_EXIT_CHORD) {
|
||||
heldPadButtons = 0
|
||||
requestStreamExit?.let { exit -> window.decorView.post { exit() } }
|
||||
}
|
||||
}
|
||||
KeyEvent.ACTION_UP -> {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false)
|
||||
heldPadButtons = heldPadButtons and bit.inv()
|
||||
}
|
||||
// The router forwards the bit on this device's own wire pad index, tracks held
|
||||
// state per pad, and reports when the emergency-exit chord (Select + Start + L1 +
|
||||
// R1) completed on any one pad (a couch user has no keyboard/Back).
|
||||
if (gamepadRouter?.onButton(event, bit) == true) {
|
||||
requestStreamExit?.let { exit -> window.decorView.post { exit() } }
|
||||
}
|
||||
return true // consumed
|
||||
}
|
||||
@@ -203,7 +194,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
||||
if (streamHandle != 0L) {
|
||||
if (axisMapper?.onMotion(event) == true) return true
|
||||
if (gamepadRouter?.onMotion(event) == true) return true
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
// The Controllers debug screen sees pad motion before the stick→D-pad synthesis below.
|
||||
@@ -248,9 +239,4 @@ class MainActivity : ComponentActivity() {
|
||||
-> true
|
||||
else -> KeyEvent.isGamepadButton(kc)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Emergency stream-exit chord: Select + Start + L1 + R1 held together. */
|
||||
val STREAM_EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.GamepadFeedback
|
||||
import io.unom.punktfunk.kit.GamepadRouter
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
@@ -174,18 +174,24 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
||||
val priorOrientation = activity?.requestedOrientation
|
||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
activity?.streamHandle = handle // route hardware keys to this session
|
||||
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
|
||||
// Multi-controller router: a stable wire pad index per connected controller, per-device axis
|
||||
// state, Arrival/Remove on hot-plug, and feedback routed back by pad index. Forwards every
|
||||
// controller (Automatic). Built here, released on dispose.
|
||||
val router = GamepadRouter(context, handle, initialSettings.gamepad)
|
||||
activity?.gamepadRouter = router
|
||||
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
|
||||
// the keep-alive linger), unlike a host-ended / backgrounded drop.
|
||||
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
||||
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
||||
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
|
||||
val feedback = GamepadFeedback(handle).also { it.start() }
|
||||
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
|
||||
// index via the router; poll threads stopped + joined before the router is released and the
|
||||
// session closed.
|
||||
val feedback = GamepadFeedback(handle, router).also { it.start() }
|
||||
onDispose {
|
||||
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
|
||||
feedback.stop() // stop + join the poll threads BEFORE nativeClose frees the handle
|
||||
activity?.axisMapper?.reset() // release-all so nothing sticks on the host
|
||||
activity?.axisMapper = null
|
||||
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
||||
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
|
||||
activity?.gamepadRouter = null
|
||||
activity?.streamHandle = 0L
|
||||
activity?.requestStreamExit = null
|
||||
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
|
||||
|
||||
@@ -171,47 +171,26 @@ object Gamepad {
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps joystick MotionEvents to axis (+ HAT→dpad) sends for one session, **on change only**.
|
||||
* Holds the previous axis/hat state so an unchanged frame emits nothing. One instance per
|
||||
* session; call [reset] on release-all (focus loss / disconnect / session stop) so nothing
|
||||
* sticks on the host (which has no client-side held-state knowledge).
|
||||
* Maps one controller's joystick MotionEvents to axis (+ HAT→dpad) sends on wire pad index [pad],
|
||||
* **on change only**. Holds the previous axis/hat state so an unchanged frame emits nothing. One
|
||||
* instance per forwarded controller (owned by [GamepadRouter], which routes each device's events
|
||||
* to its own mapper so a second pad can't clobber the first); call [reset] on that slot closing
|
||||
* (disconnect / session stop) so nothing sticks on the host (which has no client-side held-state
|
||||
* knowledge).
|
||||
*
|
||||
* Single-source: only ONE qualifying controller feeds pad 0. Events must come from a device
|
||||
* whose source classes include GAMEPAD (see [onMotion]) and the mapper pins itself to the
|
||||
* first such device — a controller's joystick-classified sibling nodes (DualSense/DS4 motion
|
||||
* sensors) and any second pad report every axis as 0, and folding them into the same state
|
||||
* flapped a held trigger/stick between its value and 0 on every event interleave.
|
||||
* The router only ever feeds this a qualifying event from the mapper's own device — a real
|
||||
* gamepad (its source classes include GAMEPAD), never a controller's joystick-classified sibling
|
||||
* node (DualSense/DS4 motion sensors), which reports every pad axis as 0. [onMotion] therefore
|
||||
* folds the event straight in without re-qualifying it.
|
||||
*/
|
||||
class AxisMapper(private val handle: Long) {
|
||||
class AxisMapper(private val handle: Long, private val pad: Int) {
|
||||
// Sentinel so the first real value (incl. 0) always sends once after attach (Linux parity).
|
||||
private val last = IntArray(6) { Int.MIN_VALUE }
|
||||
private var hatX = 0 // -1 / 0 / +1
|
||||
private var hatY = 0
|
||||
|
||||
/** deviceId of the controller pad 0 is pinned to; −1 until the first qualifying event. */
|
||||
private var deviceId = -1
|
||||
|
||||
/** Returns true if this was a joystick ACTION_MOVE we consumed. */
|
||||
fun onMotion(event: MotionEvent): Boolean {
|
||||
if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK)) return false
|
||||
if (event.actionMasked != MotionEvent.ACTION_MOVE) return false
|
||||
// Only a true gamepad drives pad 0. A joystick ACTION_MOVE's own source is plain
|
||||
// JOYSTICK for every sender, so qualify by the DEVICE's source classes: a real pad
|
||||
// carries the GAMEPAD (button) class too, its sensor/touchpad sibling nodes and
|
||||
// joystick-class remotes don't — and those report every pad axis as 0 (see the
|
||||
// class doc for the held-trigger flap this caused).
|
||||
val dev = event.device ?: return false
|
||||
if (dev.sources and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD) return false
|
||||
// Single-pad model: pin to the first qualifying controller so a second pad (or its
|
||||
// stick drift) can't fight pad 0; re-adopt only once the pinned device is gone.
|
||||
if (deviceId != event.deviceId) {
|
||||
if (deviceId != -1) {
|
||||
if (InputDevice.getDevice(deviceId) != null) return false
|
||||
reset() // the pinned pad is gone — lift its held state before adopting
|
||||
}
|
||||
deviceId = event.deviceId
|
||||
}
|
||||
|
||||
/** Fold one joystick ACTION_MOVE from this mapper's controller onto its pad index. */
|
||||
fun onMotion(event: MotionEvent) {
|
||||
// Sticks: Android floats −1..1, +y = down → ±32767, negate Y for the wire's +y = up.
|
||||
sendAxis(AXIS_LS_X, stick(event.getAxisValue(MotionEvent.AXIS_X)))
|
||||
sendAxis(AXIS_LS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_Y)))
|
||||
@@ -253,10 +232,9 @@ object Gamepad {
|
||||
if (hy < 0) btn(BTN_DPAD_UP, true) else if (hy > 0) btn(BTN_DPAD_DOWN, true)
|
||||
hatY = hy
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Release-all: zero every axis and clear the held dpad. */
|
||||
/** Release-all: zero every axis and clear the held dpad (all on this mapper's pad index). */
|
||||
fun reset() {
|
||||
for (id in 0..5) sendAxis(id, 0)
|
||||
if (hatX < 0) btn(BTN_DPAD_LEFT, false) else if (hatX > 0) btn(BTN_DPAD_RIGHT, false)
|
||||
@@ -268,10 +246,10 @@ object Gamepad {
|
||||
private fun sendAxis(id: Int, v: Int) {
|
||||
if (last[id] == v) return
|
||||
last[id] = v
|
||||
NativeBridge.nativeSendGamepadAxis(handle, id, v)
|
||||
NativeBridge.nativeSendGamepadAxis(handle, id, v, pad)
|
||||
}
|
||||
|
||||
private fun btn(bit: Int, down: Boolean) = NativeBridge.nativeSendGamepadButton(handle, bit, down)
|
||||
private fun btn(bit: Int, down: Boolean) = NativeBridge.nativeSendGamepadButton(handle, bit, down, pad)
|
||||
|
||||
// −1..1 float → ±32767 i16 (matches the Apple client's 32767 scale).
|
||||
private fun stick(v: Float): Int = (v.coerceIn(-1f, 1f) * 32767f).toInt()
|
||||
|
||||
@@ -15,21 +15,26 @@ import android.view.InputDevice
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* Host→client gamepad feedback for one session (single-pad model — pad 0 only). Two daemon poll
|
||||
* threads drain the blocking native pulls and render in Kotlin: rumble → the controller's
|
||||
* `VibratorManager` (API 31+) or its single legacy `Vibrator` on API 28–30; HID-output → lightbar /
|
||||
* player-LED via `LightsManager` (API 33+); adaptive
|
||||
* triggers are parse-validated and logged (Android has no public adaptive-trigger API).
|
||||
* Host→client gamepad feedback for one session, routed per controller by wire pad index. Two daemon
|
||||
* poll threads drain the blocking native pulls and render in Kotlin: rumble → the addressed
|
||||
* controller's `VibratorManager` (API 31+) or its single legacy `Vibrator` on API 28–30; HID-output
|
||||
* → that controller's lightbar / player-LED via `LightsManager` (API 33+); adaptive triggers are
|
||||
* parse-validated and logged (Android has no public adaptive-trigger API).
|
||||
*
|
||||
* Each pull carries the wire pad index it is addressed to; [GamepadRouter.deviceForPad] resolves it
|
||||
* to the physical controller currently holding that index — so a rumble the host aimed at pad 1
|
||||
* drives pad 1's motors, and an update for an index with no live controller (a pad that just
|
||||
* unplugged) is dropped. Per-controller rumble/light bindings are built lazily and cached by device
|
||||
* id (bounded — at most 16 pads).
|
||||
*
|
||||
* Mirrors `nativeStartAudio`'s lifecycle: [start]/[stop] driven by the StreamScreen. [stop] flips a
|
||||
* flag; the ~100 ms native pull timeout lets the threads exit, then they're joined (bounded) — and
|
||||
* this MUST run before `nativeClose` frees the session handle.
|
||||
* this MUST run before the router is released and `nativeClose` frees the session handle.
|
||||
*
|
||||
* The active pad is resolved from the connected input devices (first gamepad/joystick). With none
|
||||
* connected (emulator) rumble/lights become logged no-ops — exactly the verification path; the
|
||||
* `Log.i` receipt lines fire regardless of rendering hardware.
|
||||
* With no controller connected (emulator) rumble/lights become logged no-ops — exactly the
|
||||
* verification path; the `Log.i` receipt lines fire regardless of rendering hardware.
|
||||
*/
|
||||
class GamepadFeedback(private val handle: Long) {
|
||||
class GamepadFeedback(private val handle: Long, private val router: GamepadRouter?) {
|
||||
private companion object {
|
||||
const val TAG = "pf.feedback"
|
||||
const val TAG_LED: Byte = 0x01
|
||||
@@ -40,42 +45,48 @@ class GamepadFeedback(private val handle: Long) {
|
||||
const val LEGACY_RUMBLE_MS = 60_000L
|
||||
}
|
||||
|
||||
/** One controller's rumble binding — VibratorManager (API 31+) OR the legacy single Vibrator (API 28–30). */
|
||||
private class RumbleBind(
|
||||
val vm: VibratorManager?,
|
||||
val legacy: Vibrator?,
|
||||
val ids: IntArray,
|
||||
val amplitudeControlled: Boolean,
|
||||
)
|
||||
|
||||
/** One controller's lights binding (API 33+): its open session + the RGB / player-id lights it exposes. */
|
||||
private class LightBind(
|
||||
val session: LightsManager.LightsSession,
|
||||
val rgb: Light?,
|
||||
val player: Light?,
|
||||
)
|
||||
|
||||
@Volatile private var running = false
|
||||
private var rumbleThread: Thread? = null
|
||||
private var hidoutThread: Thread? = null
|
||||
|
||||
private var vm: VibratorManager? = null
|
||||
// API 28–30 fallback: the controller's single legacy Vibrator (no per-motor VibratorManager
|
||||
// until API 31). Exactly one of [vm] / [legacy] is bound; rumble degrades to one blended motor.
|
||||
private var legacy: Vibrator? = null
|
||||
private var vibratorIds: IntArray = IntArray(0)
|
||||
private var amplitudeControlled = false
|
||||
|
||||
private var lightsSession: LightsManager.LightsSession? = null
|
||||
private var rgbLight: Light? = null
|
||||
private var playerLight: Light? = null
|
||||
// Per-controller bindings, keyed by device id, built lazily. rumbleBinds is touched ONLY by the
|
||||
// rumble thread and lightBinds ONLY by the hidout thread while running; stop() reads both from the
|
||||
// main thread AFTER joining those threads (join establishes the happens-before), so plain maps are
|
||||
// race-free. A null value caches "this controller has no vibrator / no controllable lights".
|
||||
private val rumbleBinds = HashMap<Int, RumbleBind?>()
|
||||
private val lightBinds = HashMap<Int, LightBind?>()
|
||||
|
||||
fun start() {
|
||||
val dev = resolvePad()
|
||||
bindRumble(dev)
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
bindLights(dev)
|
||||
} else {
|
||||
Log.i(TAG, "lights need API 33 (have ${Build.VERSION.SDK_INT}) — lightbar/playerLed no-op")
|
||||
}
|
||||
|
||||
running = true
|
||||
rumbleThread = Thread({
|
||||
while (running) {
|
||||
val ev = NativeBridge.nativeNextRumble(handle)
|
||||
if (ev < 0L) continue // timeout / closed
|
||||
// ev bit 48 = has a v2 lease; bits 32..47 = ttl_ms; 16..31 = low; 0..15 = high. The
|
||||
// lease flag is out-of-band, so any ttl_ms (incl. 0xFFFF) is a real lease — no
|
||||
// in-band sentinel. No lease (legacy host) → the prior long one-shot.
|
||||
// ev bits 49..52 = wire pad index; bit 48 = has a v2 lease; bits 32..47 = ttl_ms;
|
||||
// 16..31 = low; 0..15 = high. The lease flag is out-of-band, so any ttl_ms (incl.
|
||||
// 0xFFFF) is a real lease — no in-band sentinel. No lease (legacy host) → the prior
|
||||
// long one-shot.
|
||||
val pad = ((ev ushr 49) and 0xFL).toInt()
|
||||
val hasLease = ((ev ushr 48) and 0x1L) == 0x1L
|
||||
val ttl = ((ev ushr 32) and 0xFFFF).toInt()
|
||||
val durationMs = if (hasLease) ttl.toLong() else LEGACY_RUMBLE_MS
|
||||
renderRumble(
|
||||
pad,
|
||||
((ev ushr 16) and 0xFFFF).toInt(),
|
||||
(ev and 0xFFFF).toInt(),
|
||||
durationMs,
|
||||
@@ -93,100 +104,99 @@ class GamepadFeedback(private val handle: Long) {
|
||||
}, "pf-hidout").apply { isDaemon = true; start() }
|
||||
}
|
||||
|
||||
/** Idempotent. Stops + joins the poll threads (must complete before the session handle is freed). */
|
||||
/** Idempotent. Stops + joins the poll threads (must complete before the router is released / handle freed). */
|
||||
fun stop() {
|
||||
running = false
|
||||
rumbleThread?.interrupt()
|
||||
hidoutThread?.interrupt()
|
||||
runCatching { vm?.cancel() } // drop any held rumble immediately
|
||||
runCatching { legacy?.cancel() }
|
||||
// Join WITHOUT a timeout. These poll threads dereference the native session handle on every
|
||||
// pull (nativeNextRumble/nativeNextHidout), so they MUST be dead before StreamScreen's
|
||||
// onDispose reaches nativeClose, which frees that handle. A *bounded* join that times out
|
||||
// would let a thread survive into the freed handle → use-after-free SIGSEGV (the
|
||||
// back-while-streaming crash, on the one path the main-thread `closed` guard can't cover).
|
||||
// Safe to block unbounded: the native pulls are internally time-bounded (PULL_TIMEOUT ~100 ms)
|
||||
// and rendering is a quick best-effort binder call, so each thread observes running=false and
|
||||
// exits within ~one timeout — the join returns promptly (well under any ANR threshold).
|
||||
// pull (nativeNextRumble/nativeNextHidout) and read the router, so they MUST be dead before
|
||||
// StreamScreen's onDispose reaches router.release() / nativeClose, which free that state. A
|
||||
// *bounded* join that times out would let a thread survive into the freed handle → use-after-
|
||||
// free SIGSEGV (the back-while-streaming crash, on the one path the main-thread `closed` guard
|
||||
// can't cover). Safe to block unbounded: the native pulls are internally time-bounded
|
||||
// (PULL_TIMEOUT ~100 ms) and rendering is a quick best-effort binder call, so each thread
|
||||
// observes running=false and exits within ~one timeout — the join returns promptly.
|
||||
runCatching { rumbleThread?.join() }
|
||||
runCatching { hidoutThread?.join() }
|
||||
rumbleThread = null
|
||||
hidoutThread = null
|
||||
runCatching { lightsSession?.close() }
|
||||
lightsSession = null
|
||||
rgbLight = null
|
||||
playerLight = null
|
||||
vm = null
|
||||
legacy = null
|
||||
vibratorIds = IntArray(0)
|
||||
// Threads are dead — drop any held rumble and close every lights session.
|
||||
for (b in rumbleBinds.values) b?.let {
|
||||
runCatching { it.vm?.cancel() }
|
||||
runCatching { it.legacy?.cancel() }
|
||||
}
|
||||
for (b in lightBinds.values) b?.let { runCatching { it.session.close() } }
|
||||
rumbleBinds.clear()
|
||||
lightBinds.clear()
|
||||
}
|
||||
|
||||
/** First connected gamepad/joystick InputDevice, or null (→ logged no-op on the emulator). */
|
||||
private fun resolvePad(): InputDevice? = Gamepad.firstPad()
|
||||
|
||||
// ---- Rumble ----
|
||||
|
||||
private fun bindRumble(dev: InputDevice?) {
|
||||
if (dev == null) {
|
||||
Log.i(TAG, "rumble: no controller connected — rumble no-op (emulator path)")
|
||||
return
|
||||
}
|
||||
/** The rumble binding for the controller on wire pad [pad], or null (no live pad / no vibrator). Cached by device id. */
|
||||
private fun rumbleBindFor(pad: Int): RumbleBind? {
|
||||
val dev = router?.deviceForPad(pad) ?: return null
|
||||
if (rumbleBinds.containsKey(dev.id)) return rumbleBinds[dev.id]
|
||||
val bind = bindRumble(dev)
|
||||
rumbleBinds[dev.id] = bind
|
||||
return bind
|
||||
}
|
||||
|
||||
private fun bindRumble(dev: InputDevice): RumbleBind? {
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
val m = dev.vibratorManager
|
||||
val ids = m.vibratorIds
|
||||
if (ids.isEmpty()) {
|
||||
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrators — rumble no-op")
|
||||
return
|
||||
return null
|
||||
}
|
||||
vm = m
|
||||
vibratorIds = ids
|
||||
amplitudeControlled = ids.all { m.getVibrator(it).hasAmplitudeControl() }
|
||||
Log.i(TAG, "rumble: bound ${ids.size} vibrators amplitudeControl=$amplitudeControlled")
|
||||
} else {
|
||||
// API 28–30: no VibratorManager — fall back to the controller's single legacy Vibrator.
|
||||
@Suppress("DEPRECATION")
|
||||
val v = dev.vibrator
|
||||
if (!v.hasVibrator()) {
|
||||
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrator — rumble no-op")
|
||||
return
|
||||
}
|
||||
legacy = v
|
||||
amplitudeControlled = v.hasAmplitudeControl()
|
||||
Log.i(TAG, "rumble: bound legacy vibrator amplitudeControl=$amplitudeControlled")
|
||||
val amp = ids.all { m.getVibrator(it).hasAmplitudeControl() }
|
||||
Log.i(TAG, "rumble: bound ${ids.size} vibrators for '${dev.name}' amplitudeControl=$amp")
|
||||
return RumbleBind(m, null, ids, amp)
|
||||
}
|
||||
// API 28–30: no VibratorManager — fall back to the controller's single legacy Vibrator.
|
||||
@Suppress("DEPRECATION")
|
||||
val v = dev.vibrator
|
||||
if (!v.hasVibrator()) {
|
||||
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrator — rumble no-op")
|
||||
return null
|
||||
}
|
||||
Log.i(TAG, "rumble: bound legacy vibrator for '${dev.name}' amplitudeControl=${v.hasAmplitudeControl()}")
|
||||
return RumbleBind(null, v, IntArray(0), v.hasAmplitudeControl())
|
||||
}
|
||||
|
||||
/**
|
||||
* low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes).
|
||||
* `durationMs` is the host's v2 envelope TTL — the one-shot self-terminates after it unless the
|
||||
* host renews, so a lost stop (or a dead host) silences at the lease instead of the old fixed
|
||||
* 60 s. Against a legacy host it is [LEGACY_RUMBLE_MS] (the prior fixed duration).
|
||||
* low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes),
|
||||
* addressed to wire pad [pad]. `durationMs` is the host's v2 envelope TTL — the one-shot self-
|
||||
* terminates after it unless the host renews, so a lost stop (or a dead host) silences at the
|
||||
* lease instead of the old fixed 60 s. Against a legacy host it is [LEGACY_RUMBLE_MS].
|
||||
*/
|
||||
private fun renderRumble(low: Int, high: Int, durationMs: Long) {
|
||||
Log.i(TAG, "rumble low=$low high=$high ttlMs=$durationMs") // verification line — BEFORE any no-op return
|
||||
private fun renderRumble(pad: Int, low: Int, high: Int, durationMs: Long) {
|
||||
Log.i(TAG, "rumble pad=$pad low=$low high=$high ttlMs=$durationMs") // verification line — BEFORE any no-op return
|
||||
val bind = rumbleBindFor(pad) ?: return
|
||||
val lo = toAmplitude(low)
|
||||
val hi = toAmplitude(high)
|
||||
val m = vm
|
||||
val m = bind.vm
|
||||
if (m != null) {
|
||||
if (lo == 0 && hi == 0) {
|
||||
m.cancel() // (0,0) = stop
|
||||
return
|
||||
}
|
||||
val combo = CombinedVibration.startParallel()
|
||||
if (amplitudeControlled && vibratorIds.size >= 2) {
|
||||
if (bind.amplitudeControlled && bind.ids.size >= 2) {
|
||||
// ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention).
|
||||
if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi, durationMs))
|
||||
if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo, durationMs))
|
||||
if (hi != 0) combo.addVibrator(bind.ids[0], oneShot(hi, durationMs))
|
||||
if (lo != 0) combo.addVibrator(bind.ids[1], oneShot(lo, durationMs))
|
||||
} else {
|
||||
// Single motor or no amplitude control: blend both into one effect.
|
||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||
for (id in vibratorIds) combo.addVibrator(id, oneShot(a, durationMs))
|
||||
for (id in bind.ids) combo.addVibrator(id, oneShot(a, durationMs))
|
||||
}
|
||||
runCatching { m.vibrate(combo.combine()) }
|
||||
return
|
||||
}
|
||||
// API 28–30 legacy single-motor path: blend both motors into one effect.
|
||||
val lv = legacy ?: return
|
||||
val lv = bind.legacy ?: return
|
||||
if (lo == 0 && hi == 0) {
|
||||
lv.cancel() // (0,0) = stop
|
||||
return
|
||||
@@ -194,7 +204,7 @@ class GamepadFeedback(private val handle: Long) {
|
||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||
runCatching {
|
||||
lv.vibrate(
|
||||
if (amplitudeControlled) oneShot(a, durationMs)
|
||||
if (bind.amplitudeControlled) oneShot(a, durationMs)
|
||||
else oneShot(VibrationEffect.DEFAULT_AMPLITUDE, durationMs)
|
||||
)
|
||||
}
|
||||
@@ -215,28 +225,29 @@ class GamepadFeedback(private val handle: Long) {
|
||||
|
||||
private fun dispatchHidout(buf: ByteBuffer, n: Int) {
|
||||
buf.rewind()
|
||||
val pad = buf.get().toInt() and 0xFF // wire pad index the event is addressed to
|
||||
when (buf.get()) { // kind tag
|
||||
TAG_LED -> {
|
||||
val r = buf.get().toInt() and 0xFF
|
||||
val g = buf.get().toInt() and 0xFF
|
||||
val b = buf.get().toInt() and 0xFF
|
||||
Log.i(TAG, "hidout Led r=$r g=$g b=$b") // verification line
|
||||
if (Build.VERSION.SDK_INT >= 33) setLightbar(Color.rgb(r, g, b))
|
||||
Log.i(TAG, "hidout pad=$pad Led r=$r g=$g b=$b") // verification line
|
||||
if (Build.VERSION.SDK_INT >= 33) setLightbar(pad, Color.rgb(r, g, b))
|
||||
}
|
||||
TAG_PLAYER_LEDS -> {
|
||||
val bits = buf.get().toInt() and 0x1F
|
||||
val player = playerIndexForBits(bits)
|
||||
Log.i(TAG, "hidout PlayerLeds bits=$bits player=$player") // verification line
|
||||
if (Build.VERSION.SDK_INT >= 33) setPlayerId(player)
|
||||
Log.i(TAG, "hidout pad=$pad PlayerLeds bits=$bits player=$player") // verification line
|
||||
if (Build.VERSION.SDK_INT >= 33) setPlayerId(pad, player)
|
||||
}
|
||||
TAG_TRIGGER -> {
|
||||
val which = buf.get().toInt() and 0xFF // 0 = L2, 1 = R2
|
||||
val effLen = n - 2
|
||||
val effLen = n - 3 // [pad][kind][which] header, then the effect block
|
||||
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0
|
||||
// No public adaptive-trigger API on Android — parse-validate the mode + log only.
|
||||
Log.i(
|
||||
TAG,
|
||||
"hidout Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
|
||||
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
|
||||
)
|
||||
}
|
||||
else -> Log.d(TAG, "hidout: unknown kind, dropped")
|
||||
@@ -253,37 +264,46 @@ class GamepadFeedback(private val handle: Long) {
|
||||
else -> Integer.bitCount(bits and 0x1F).coerceIn(1, 4)
|
||||
}
|
||||
|
||||
private fun bindLights(dev: InputDevice?) {
|
||||
if (dev == null) {
|
||||
Log.i(TAG, "lights: no controller connected — lightbar/playerLed no-op (emulator path)")
|
||||
return
|
||||
}
|
||||
/** The lights binding for the controller on wire pad [pad], or null (no live pad / no lights / < API 33). Cached by device id. */
|
||||
private fun lightBindFor(pad: Int): LightBind? {
|
||||
if (Build.VERSION.SDK_INT < 33) return null
|
||||
val dev = router?.deviceForPad(pad) ?: return null
|
||||
if (lightBinds.containsKey(dev.id)) return lightBinds[dev.id]
|
||||
val bind = bindLights(dev)
|
||||
lightBinds[dev.id] = bind
|
||||
return bind
|
||||
}
|
||||
|
||||
private fun bindLights(dev: InputDevice): LightBind? {
|
||||
val lm = dev.lightsManager
|
||||
var rgb: Light? = null
|
||||
var player: Light? = null
|
||||
for (l in lm.lights) {
|
||||
if (rgbLight == null && l.hasRgbControl()) rgbLight = l
|
||||
if (playerLight == null && l.type == Light.LIGHT_TYPE_PLAYER_ID) playerLight = l
|
||||
if (rgb == null && l.hasRgbControl()) rgb = l
|
||||
if (player == null && l.type == Light.LIGHT_TYPE_PLAYER_ID) player = l
|
||||
}
|
||||
if (rgbLight == null && playerLight == null) {
|
||||
if (rgb == null && player == null) {
|
||||
Log.i(TAG, "lights: controller '${dev.name}' exposes no controllable lights — no-op")
|
||||
return
|
||||
return null
|
||||
}
|
||||
lightsSession = lm.openSession()
|
||||
Log.i(TAG, "lights: bound rgb=${rgbLight != null} playerLed=${playerLight != null}")
|
||||
val session = lm.openSession()
|
||||
Log.i(TAG, "lights: bound rgb=${rgb != null} playerLed=${player != null} for '${dev.name}'")
|
||||
return LightBind(session, rgb, player)
|
||||
}
|
||||
|
||||
private fun setLightbar(argb: Int) {
|
||||
val s = lightsSession ?: return
|
||||
val l = rgbLight ?: return
|
||||
private fun setLightbar(pad: Int, argb: Int) {
|
||||
val bind = lightBindFor(pad) ?: return
|
||||
val l = bind.rgb ?: return
|
||||
runCatching {
|
||||
s.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setColor(argb).build()).build())
|
||||
bind.session.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setColor(argb).build()).build())
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPlayerId(player: Int) {
|
||||
val s = lightsSession ?: return
|
||||
val l = playerLight ?: return
|
||||
private fun setPlayerId(pad: Int, player: Int) {
|
||||
val bind = lightBindFor(pad) ?: return
|
||||
val l = bind.player ?: return
|
||||
runCatching {
|
||||
s.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setPlayerId(player).build()).build())
|
||||
bind.session.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setPlayerId(player).build()).build())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.input.InputManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Multi-controller router for one stream session — the Android analogue of the Linux client's gamepad
|
||||
* `Worker`/`Slot` model (`pf-client-core/src/gamepad.rs`) over the shared native-plane wire contract
|
||||
* (`punktfunk-core/src/input.rs`). Each physical controller (Android `deviceId`) gets a STABLE
|
||||
* lowest-free wire pad index (0..15) held for its lifetime and freed only on disconnect, so a pad
|
||||
* dropping never renumbers the others (a game must not see its players shuffle). Every forwarded event
|
||||
* carries that pad index; a [NativeBridge.nativeSendGamepadArrival] declaring the pad's type is sent
|
||||
* once BEFORE its first input, a [NativeBridge.nativeSendGamepadRemove] on disconnect. Per-device axis
|
||||
* state lives in each slot's [Gamepad.AxisMapper] so a second controller can't clobber the first.
|
||||
* Feedback (rumble / HID) is routed BACK to the originating device by pad index via [deviceForPad].
|
||||
*
|
||||
* Selection: forward EVERY real controller (the Linux client's single-player pin has no Android UI
|
||||
* surface yet — Automatic is the only mode). Lifetime matches the session: constructed on stream
|
||||
* attach (opening a slot for every already-connected pad, so its Arrival lands before any input),
|
||||
* released on detach.
|
||||
*
|
||||
* A single controller lands on wire index 0, so its per-transition button/axis wire is byte-identical
|
||||
* to the old single-pad path (plus the Arrival/Remove declarations the contract requires — which an
|
||||
* older host simply ignores).
|
||||
*
|
||||
* Threading: slot mutation + dispatch run on the main thread (Android input dispatch and the
|
||||
* InputManager hot-plug callbacks both land there). [deviceForPad] is read from the feedback poll
|
||||
* threads, so the slot table is a [ConcurrentHashMap].
|
||||
*/
|
||||
class GamepadRouter(context: Context, private val handle: Long, private val setting: Int) {
|
||||
|
||||
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
|
||||
private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) {
|
||||
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
|
||||
var held = 0
|
||||
}
|
||||
|
||||
/** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */
|
||||
private val slots = ConcurrentHashMap<Int, Slot>()
|
||||
|
||||
private val inputManager = context.getSystemService(InputManager::class.java)
|
||||
private val listener = object : InputManager.InputDeviceListener {
|
||||
override fun onInputDeviceAdded(deviceId: Int) {
|
||||
InputDevice.getDevice(deviceId)?.let { if (isForwardable(it)) openSlot(it) }
|
||||
}
|
||||
|
||||
override fun onInputDeviceRemoved(deviceId: Int) = closeSlot(deviceId)
|
||||
override fun onInputDeviceChanged(deviceId: Int) {}
|
||||
}
|
||||
|
||||
init {
|
||||
inputManager?.registerInputDeviceListener(listener, Handler(Looper.getMainLooper()))
|
||||
// Open a slot for every controller already connected when the session starts — the pads that
|
||||
// will never fire onInputDeviceAdded during this session; their Arrival lands before any input.
|
||||
for (id in InputDevice.getDeviceIds()) {
|
||||
InputDevice.getDevice(id)?.let { if (isForwardable(it)) openSlot(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One gamepad button transition for the device that produced [event] (already resolved to BTN_*
|
||||
* bit [bit]). Opens the device's slot (declaring its type) if unseen, forwards the bit on the
|
||||
* slot's pad index, tracks held state, and returns true when this press completed the emergency
|
||||
* stream-exit chord (Select + Start + L1 + R1) on THIS pad — the caller then leaves the stream
|
||||
* (mirrors the Linux client's escape chord: any one controller can leave).
|
||||
*/
|
||||
fun onButton(event: KeyEvent, bit: Int): Boolean {
|
||||
val slot = slotFor(event.device) ?: return false
|
||||
when (event.action) {
|
||||
KeyEvent.ACTION_DOWN -> {
|
||||
// repeatCount guard: don't re-send a held button as auto-repeat.
|
||||
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
|
||||
slot.held = slot.held or bit
|
||||
if (slot.held and EXIT_CHORD == EXIT_CHORD) {
|
||||
slot.held = 0
|
||||
return true
|
||||
}
|
||||
}
|
||||
KeyEvent.ACTION_UP -> {
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
slot.held = slot.held and bit.inv()
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* One joystick MotionEvent — routed to the producing device's own [Gamepad.AxisMapper] (per-device
|
||||
* state). Returns true if consumed. Only a real gamepad drives a pad: a DualSense/DS4 motion-sensor
|
||||
* sibling node classifies as bare joystick (no GAMEPAD source class) and reports every pad axis as
|
||||
* 0, so [isForwardable] filters it out before it can open a slot or clobber axes.
|
||||
*/
|
||||
fun onMotion(event: MotionEvent): Boolean {
|
||||
if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK)) return false
|
||||
if (event.actionMasked != MotionEvent.ACTION_MOVE) return false
|
||||
val dev = event.device ?: return false
|
||||
if (!isForwardable(dev)) return false
|
||||
val slot = slotFor(dev) ?: return false
|
||||
slot.mapper.onMotion(event)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The controller currently mapped to wire pad [pad], for feedback routing; null if that index
|
||||
* holds no live slot (a pad that just unplugged — the update is then dropped). Read from the
|
||||
* feedback poll threads.
|
||||
*/
|
||||
fun deviceForPad(pad: Int): InputDevice? {
|
||||
for ((deviceId, slot) in slots) {
|
||||
if (slot.index == pad) return InputDevice.getDevice(deviceId)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush + drop every slot and unregister the hot-plug listener. Call on session teardown, AFTER
|
||||
* the feedback poll threads are joined (they read [deviceForPad]).
|
||||
*/
|
||||
fun release() {
|
||||
inputManager?.unregisterInputDeviceListener(listener)
|
||||
// Snapshot the ids first — closeSlot mutates the map.
|
||||
for (id in slots.keys.toList()) closeSlot(id)
|
||||
}
|
||||
|
||||
// ---- slots ----
|
||||
|
||||
/** A real, non-virtual controller we forward — its source classes include GAMEPAD (excludes a pad's bare-joystick sensor node). */
|
||||
private fun isForwardable(dev: InputDevice): Boolean =
|
||||
!dev.isVirtual && dev.sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD
|
||||
|
||||
/**
|
||||
* The slot for [dev], opening one (and declaring the pad) if this device is unseen; null when [dev]
|
||||
* isn't a forwardable controller or every wire index is taken. The [isForwardable] gate lives here —
|
||||
* the single lazy-open chokepoint both [onButton] and [onMotion] funnel through — so no entry point
|
||||
* can open a phantom slot for a virtual/non-gamepad source (the hot-plug listener and init loop
|
||||
* pre-filter and call [openSlot] directly).
|
||||
*/
|
||||
private fun slotFor(dev: InputDevice?): Slot? {
|
||||
if (dev == null) return null
|
||||
slots[dev.id]?.let { return it }
|
||||
if (!isForwardable(dev)) return null
|
||||
return openSlot(dev)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a slot for [dev] on the lowest free wire index, declaring its kind ([NativeBridge.nativeSendGamepadArrival])
|
||||
* before any input so the host builds a matching virtual device (mixed types across pads).
|
||||
* Idempotent; null when all 16 wire indices are already forwarded.
|
||||
*/
|
||||
private fun openSlot(dev: InputDevice): Slot? {
|
||||
slots[dev.id]?.let { return it }
|
||||
val index = lowestFreeIndex() ?: return null // 16 pads already forwarded — drop this one
|
||||
// Automatic resolves the pad's type from its VID/PID; an explicit setting forces every pad
|
||||
// to that type (a single global choice — matches the handshake's session-default pref).
|
||||
val pref = if (setting == Gamepad.PREF_AUTO) Gamepad.prefFor(dev) else setting
|
||||
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
val slot = Slot(index, Gamepad.AxisMapper(handle, index))
|
||||
slots[dev.id] = slot
|
||||
return slot
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush a slot's held wire state (so nothing sticks host-side), signal the removal, and free its
|
||||
* index. Safe against an already-gone device — the flush emits wire events only, no device access.
|
||||
*/
|
||||
private fun closeSlot(deviceId: Int) {
|
||||
val slot = slots.remove(deviceId) ?: return
|
||||
releaseHeld(slot)
|
||||
NativeBridge.nativeSendGamepadRemove(handle, slot.index)
|
||||
}
|
||||
|
||||
/** Lift every held button + zero the axes/HAT dpad for [slot] (wire events only, all on its index). */
|
||||
private fun releaseHeld(slot: Slot) {
|
||||
var bits = slot.held
|
||||
while (bits != 0) {
|
||||
val bit = bits and -bits // lowest set bit
|
||||
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
|
||||
bits = bits and bit.inv()
|
||||
}
|
||||
slot.held = 0
|
||||
slot.mapper.reset() // zero sticks/triggers + release the HAT dpad
|
||||
}
|
||||
|
||||
/** Lowest wire index 0..[MAX_PADS) not held by a slot, or null when full — stable lowest-free keeps indices from shuffling on hot-plug. */
|
||||
private fun lowestFreeIndex(): Int? {
|
||||
val taken = slots.values.mapTo(HashSet()) { it.index }
|
||||
for (i in 0 until MAX_PADS) if (i !in taken) return i
|
||||
return null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Mirror of `punktfunk-core::input::MAX_PADS` — wire pad indices 0..15. */
|
||||
const val MAX_PADS = 16
|
||||
|
||||
/** Emergency stream-exit chord: Select + Start + L1 + R1 held together (matches the legacy single-pad chord). */
|
||||
const val EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB
|
||||
}
|
||||
}
|
||||
@@ -269,26 +269,43 @@ object NativeBridge {
|
||||
/** One key transition. vk: Windows VK (0 = dropped by Rust). mods: VK modifier mask (0 for now). */
|
||||
external fun nativeSendKey(handle: Long, vk: Int, down: Boolean, mods: Int)
|
||||
|
||||
// ---- Gamepad: one pad forwarded as pad 0 (Rust hardcodes flags=0) ----
|
||||
// ---- Gamepad: each controller forwarded on its own wire pad index (0..15, low byte of flags) ----
|
||||
// The pad index is assigned per Android device by GamepadRouter; a single controller lands on 0,
|
||||
// so its wire is byte-identical to the old single-pad path. The core folds the per-transition
|
||||
// events into seq'd GamepadState snapshots keyed on this index and owns the per-pad seq.
|
||||
|
||||
/** One gamepad button transition. bit: a [Gamepad].BTN_* bit. down: press/release. */
|
||||
external fun nativeSendGamepadButton(handle: Long, bit: Int, down: Boolean)
|
||||
/** One gamepad button transition on wire pad [pad] (0..15). bit: a [Gamepad].BTN_* bit. down: press/release. */
|
||||
external fun nativeSendGamepadButton(handle: Long, bit: Int, down: Boolean, pad: Int)
|
||||
|
||||
/** One gamepad axis update. axisId: [Gamepad].AXIS_* (0..5). value: stick i16 (+y=up) / trigger 0..255. */
|
||||
external fun nativeSendGamepadAxis(handle: Long, axisId: Int, value: Int)
|
||||
/** One gamepad axis update on wire pad [pad] (0..15). axisId: [Gamepad].AXIS_* (0..5). value: stick i16 (+y=up) / trigger 0..255. */
|
||||
external fun nativeSendGamepadAxis(handle: Long, axisId: Int, value: Int, pad: Int)
|
||||
|
||||
/**
|
||||
* Declare the controller KIND presented on wire pad [pad] (0..15) so the host builds a matching
|
||||
* virtual device (mixed types across pads). pref: a [Gamepad].PREF_* wire byte. Send ONCE when a
|
||||
* pad opens, BEFORE any of its input; an older host ignores it (that pad then uses the handshake's
|
||||
* session-default kind — the pre-existing single-pad behaviour on pad 0).
|
||||
*/
|
||||
external fun nativeSendGamepadArrival(handle: Long, pref: Int, pad: Int)
|
||||
|
||||
/** Signal wire pad [pad] (0..15) was unplugged so the host tears its virtual device down. The core stamps the seq + re-sends. */
|
||||
external fun nativeSendGamepadRemove(handle: Long, pad: Int)
|
||||
|
||||
// ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ----
|
||||
|
||||
/**
|
||||
* Block up to ~100 ms for the next rumble update. Returns `(low shl 16) or high` (each
|
||||
* 0..0xFFFF; 0 = stop), or -1 on timeout / session closed. Call from a dedicated poll thread.
|
||||
* Block up to ~100 ms for the next rumble update. Returns a packed positive long: bits 49..52 =
|
||||
* wire pad index (0..15), bit 48 = has a v2 lease, bits 32..47 = ttl_ms, bits 16..31 = low, bits
|
||||
* 0..15 = high (each amplitude 0..0xFFFF; 0/0 = stop), or -1 on timeout / session closed. Kotlin
|
||||
* routes the update to the controller holding that pad index. Call from a dedicated poll thread.
|
||||
*/
|
||||
external fun nativeNextRumble(handle: Long): Long
|
||||
|
||||
/**
|
||||
* Block up to ~100 ms for the next DualSense HID-output event, written into [buf] (a direct
|
||||
* ByteBuffer, capacity >= 64) as `[kind][fields…]`: Led=01 r g b, PlayerLeds=02 bits,
|
||||
* Trigger=03 which effect…. Returns the byte count, or -1 on timeout / session closed.
|
||||
* ByteBuffer, capacity >= 64) as `[pad][kind][fields…]` (leading pad = the wire pad index to
|
||||
* route to): Led=pad 01 r g b, PlayerLeds=pad 02 bits, Trigger=pad 03 which effect…. Returns the
|
||||
* byte count, or -1 on timeout / session closed.
|
||||
*/
|
||||
external fun nativeNextHidout(handle: Long, buf: java.nio.ByteBuffer): Int
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use ndk::media::media_format::MediaFormat;
|
||||
use ndk::native_window::NativeWindow;
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::error::PunktfunkError;
|
||||
use punktfunk_core::reanchor::{GateVerdict, ReanchorGate};
|
||||
use punktfunk_core::session::Frame;
|
||||
use std::collections::VecDeque;
|
||||
use std::ffi::c_void;
|
||||
@@ -208,9 +209,15 @@ fn run_sync(
|
||||
// pressure the AU stays parked here instead of being dropped (a drop forces a keyframe
|
||||
// round-trip) and we only pop the next one once it's queued.
|
||||
let mut pending: Option<Frame> = None;
|
||||
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it
|
||||
// climbs.
|
||||
let mut last_dropped = client.frames_dropped();
|
||||
// Freeze-until-reanchor: the shared post-loss gate ([`punktfunk_core::reanchor::ReanchorGate`]).
|
||||
// Armed on a frame-index gap or a dropped-count climb, it withholds the decoder's concealed output
|
||||
// (released WITHOUT rendering — the SurfaceView keeps the last rendered frame on glass) until a
|
||||
// proven clean re-anchor lifts it: an IDR (wire FLAG_SOF), an RFI anchor, or the 2nd recovery mark.
|
||||
// `last_kf_req` throttles the keyframe intents it emits; `recovery_flags` carries each AU's
|
||||
// user_flags from feed to present (keyed by the codec-echoed pts) so `on_decoded` reads the
|
||||
// re-anchor signalling the platform decoder doesn't expose.
|
||||
let mut gate = ReanchorGate::new(client.frames_dropped());
|
||||
let mut recovery_flags: VecDeque<(u64, u32)> = VecDeque::new();
|
||||
let mut last_kf_req: Option<Instant> = None;
|
||||
// Skew-corrected latency stats (spec: design/stats-unification.md) use the negotiated
|
||||
// host-minus-client clock offset (0 if the host didn't answer the skew handshake — then the
|
||||
@@ -245,9 +252,18 @@ fn run_sync(
|
||||
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);
|
||||
// recovers with a cheap clean P-frame instead of a full IDR. The same forward gap
|
||||
// arms the freeze gate so the decoder's concealment is held off the screen until the
|
||||
// recovery re-anchors. The frames_dropped keyframe path below stays the backstop.
|
||||
if client.note_frame_index(frame.frame_index) {
|
||||
gate.arm(Instant::now());
|
||||
}
|
||||
// Park this AU's re-anchor flags for the present side (keyed by the pts the codec
|
||||
// echoes on the output buffer) — unconditional, unlike the HUD's `in_flight` map.
|
||||
recovery_flags.push_back((frame.pts_ns / 1000, frame.flags));
|
||||
if recovery_flags.len() > IN_FLIGHT_CAP {
|
||||
recovery_flags.pop_front();
|
||||
}
|
||||
if fed == 0 {
|
||||
let p = &frame.data;
|
||||
log::info!(
|
||||
@@ -336,6 +352,8 @@ fn run_sync(
|
||||
&mut in_flight,
|
||||
clock_offset.load(Ordering::Relaxed),
|
||||
&tracker,
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
);
|
||||
rendered += r;
|
||||
discarded += d;
|
||||
@@ -375,21 +393,19 @@ fn run_sync(
|
||||
work_accum_ns = 0;
|
||||
}
|
||||
|
||||
// Loss recovery: under infinite GOP the only recovery keyframe is one we request. The
|
||||
// reassembler drops unrecoverable AUs (frames_dropped); the decoder then conceals the
|
||||
// reference-missing delta frames that follow and renders them without error, so keying off
|
||||
// a decode error rarely fires. Request an IDR when the drop count climbs, throttled — the
|
||||
// decode stays wedged for several frames until the IDR lands, so requesting every frame
|
||||
// would flood the control stream.
|
||||
let dropped = client.frames_dropped();
|
||||
if dropped > last_dropped {
|
||||
last_dropped = dropped;
|
||||
let now = Instant::now();
|
||||
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
|
||||
last_kf_req = Some(now);
|
||||
let _ = client.request_keyframe();
|
||||
log::debug!("decode: requested keyframe (loss recovery, dropped={dropped})");
|
||||
}
|
||||
// Loss recovery + overdue backstop, folded through the gate. Under infinite GOP the only
|
||||
// recovery keyframe is one we request; the reassembler drops unrecoverable AUs (frames_dropped)
|
||||
// and the decoder then conceals the reference-missing deltas and renders them without error, so
|
||||
// a decode-error trigger rarely fires — the gate arms the freeze on the drop-count climb
|
||||
// instead. An overdue freeze (held REANCHOR_FREEZE_MAX with no clean re-anchor) re-asks while it
|
||||
// keeps holding: never resume to gray — a dead stream is the QUIC idle-timeout watchdog's job.
|
||||
let now = Instant::now();
|
||||
if gate.poll(client.frames_dropped(), now)
|
||||
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||
{
|
||||
last_kf_req = Some(now);
|
||||
let _ = client.request_keyframe();
|
||||
log::debug!("decode: requested keyframe (loss recovery / overdue re-anchor)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -707,8 +723,10 @@ struct OutputReady {
|
||||
/// internal looper thread) push the codec ones; the feeder thread pushes `Au`. Each carries only
|
||||
/// owned/`Copy` data so the callback closures satisfy the `Send` bound and never touch the codec.
|
||||
enum DecodeEvent {
|
||||
/// A received access unit from the feeder, ready to queue into the decoder.
|
||||
Au(Frame),
|
||||
/// A received access unit from the feeder, ready to queue into the decoder. The `bool` is the
|
||||
/// feeder's [`NativeClient::note_frame_index`] verdict — `true` when this AU revealed a forward
|
||||
/// frame-index gap, so the loop arms the freeze gate (the feeder already fired the RFI request).
|
||||
Au(Frame, bool),
|
||||
/// An input buffer slot freed (index) — we can queue an AU into it.
|
||||
InputAvailable(usize),
|
||||
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
|
||||
@@ -894,7 +912,12 @@ fn run_async(
|
||||
let mut discarded: u64 = 0;
|
||||
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
|
||||
let mut oversized_dropped: u64 = 0;
|
||||
let mut last_dropped = client.frames_dropped();
|
||||
// Freeze-until-reanchor gate (see the sync loop for the rationale). Armed on a frame-index gap
|
||||
// (the feeder's Au verdict), a parked-AU overflow drop, a dropped-count climb, or a recoverable
|
||||
// codec error; `recovery_flags` carries each AU's user_flags from `dispatch_event` (feed) to
|
||||
// `present_ready` (present), keyed by the codec-echoed pts.
|
||||
let mut gate = ReanchorGate::new(client.frames_dropped());
|
||||
let mut recovery_flags: VecDeque<(u64, u32)> = VecDeque::new();
|
||||
let mut last_kf_req: Option<Instant> = None;
|
||||
// Productive (dispatch+feed+present) time between displayed frames; reported to ADPF once one is
|
||||
// presented. The blocking event wait is excluded (idle, not work) — same accounting as the sync loop.
|
||||
@@ -920,6 +943,8 @@ fn run_async(
|
||||
&mut ready,
|
||||
&mut fmt_dirty,
|
||||
&mut fatal,
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
));
|
||||
}
|
||||
// Coalesce every other event already queued into this one work pass — correct newest-only
|
||||
@@ -932,6 +957,8 @@ fn run_async(
|
||||
&mut ready,
|
||||
&mut fmt_dirty,
|
||||
&mut fatal,
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
));
|
||||
}
|
||||
stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too
|
||||
@@ -956,6 +983,8 @@ fn run_async(
|
||||
&tracker,
|
||||
&mut rendered,
|
||||
&mut discarded,
|
||||
&mut gate,
|
||||
&mut recovery_flags,
|
||||
);
|
||||
|
||||
work_accum_ns += work_t0.elapsed().as_nanos() as i64;
|
||||
@@ -987,17 +1016,19 @@ fn run_async(
|
||||
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
|
||||
}
|
||||
}
|
||||
// Loss recovery: request an IDR when the reassembler's unrecoverable-drop count climbs (or we
|
||||
// dropped a parked AU on overflow), throttled so a multi-frame recovery gap doesn't flood the
|
||||
// control stream.
|
||||
let dropped = client.frames_dropped();
|
||||
if dropped > last_dropped || aus_dropped > 0 {
|
||||
last_dropped = dropped;
|
||||
let now = Instant::now();
|
||||
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
|
||||
last_kf_req = Some(now);
|
||||
let _ = client.request_keyframe();
|
||||
}
|
||||
// Loss recovery + overdue backstop, folded through the gate. A parked-AU overflow drop is itself
|
||||
// a loss, so it arms the freeze directly; the gate's `poll` then arms on a dropped-count climb
|
||||
// and re-asks on an overdue freeze. All keyframe intents route through the shared 100 ms
|
||||
// throttle so a multi-frame recovery gap can't flood the control stream.
|
||||
let now = Instant::now();
|
||||
if aus_dropped > 0 {
|
||||
gate.arm(now);
|
||||
}
|
||||
if (gate.poll(client.frames_dropped(), now) || aus_dropped > 0)
|
||||
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||
{
|
||||
last_kf_req = Some(now);
|
||||
let _ = client.request_keyframe();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1033,8 +1064,9 @@ fn feeder_loop(
|
||||
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);
|
||||
// instead of a full IDR (the frames_dropped keyframe path is the backstop). The gap
|
||||
// verdict rides the Au event so the decode loop arms its freeze gate on the same signal.
|
||||
let gap = 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;
|
||||
@@ -1067,7 +1099,7 @@ fn feeder_loop(
|
||||
}
|
||||
}
|
||||
}
|
||||
if ev_tx.send(DecodeEvent::Au(frame)).is_err() {
|
||||
if ev_tx.send(DecodeEvent::Au(frame, gap)).is_err() {
|
||||
break; // the decode loop is gone
|
||||
}
|
||||
}
|
||||
@@ -1079,6 +1111,7 @@ fn feeder_loop(
|
||||
|
||||
/// Route one [`DecodeEvent`] into the loop's working sets. Returns `true` only when a parked AU was
|
||||
/// dropped on overflow (the caller then requests a keyframe).
|
||||
#[allow(clippy::too_many_arguments)] // two call sites; the freeze gate + flag map are threaded in
|
||||
fn dispatch_event(
|
||||
ev: DecodeEvent,
|
||||
pending_aus: &mut VecDeque<Frame>,
|
||||
@@ -1086,9 +1119,20 @@ fn dispatch_event(
|
||||
ready: &mut Vec<OutputReady>,
|
||||
fmt_dirty: &mut bool,
|
||||
fatal: &mut bool,
|
||||
gate: &mut ReanchorGate,
|
||||
recovery_flags: &mut VecDeque<(u64, u32)>,
|
||||
) -> bool {
|
||||
match ev {
|
||||
DecodeEvent::Au(f) => {
|
||||
DecodeEvent::Au(f, gap) => {
|
||||
// A forward frame-index gap arms the freeze; park this AU's flags for the present side to
|
||||
// fold `on_decoded` (keyed by the pts the codec will echo).
|
||||
if gap {
|
||||
gate.arm(Instant::now());
|
||||
}
|
||||
recovery_flags.push_back((f.pts_ns / 1000, f.flags));
|
||||
if recovery_flags.len() > IN_FLIGHT_CAP {
|
||||
recovery_flags.pop_front();
|
||||
}
|
||||
pending_aus.push_back(f);
|
||||
if pending_aus.len() > FRAME_PARK_CAP {
|
||||
pending_aus.pop_front(); // sustained overflow — drop oldest, signal a keyframe request
|
||||
@@ -1109,6 +1153,10 @@ fn dispatch_event(
|
||||
DecodeEvent::Error { fatal: f } => {
|
||||
if f {
|
||||
*fatal = true;
|
||||
} else {
|
||||
// A recoverable/transient codec error is a decode hiccup on a broken reference chain —
|
||||
// arm the freeze so the concealed output it recovers into is held off the screen.
|
||||
gate.arm(Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1180,6 +1228,8 @@ fn present_ready(
|
||||
tracker: &DisplayTracker,
|
||||
rendered: &mut u64,
|
||||
discarded: &mut u64,
|
||||
gate: &mut ReanchorGate,
|
||||
recovery_flags: &mut VecDeque<(u64, u32)>,
|
||||
) {
|
||||
if ready.is_empty() {
|
||||
return;
|
||||
@@ -1192,10 +1242,16 @@ fn present_ready(
|
||||
note_decoded_pts(stats, &mut g, clock_offset, o.pts_us, o.decoded_ns);
|
||||
}
|
||||
}
|
||||
// Fold EVERY output through the gate in pts (== decode) order — even the ones newest-wins discards —
|
||||
// so the two-mark re-anchor count stays correct; the newest's verdict decides whether it reaches
|
||||
// glass (`false` = withheld concealment; the SurfaceView keeps the last rendered frame frozen on).
|
||||
let now = Instant::now();
|
||||
let last = ready.len() - 1;
|
||||
let mut skipped: u64 = 0;
|
||||
for (i, o) in ready.drain(..).enumerate() {
|
||||
let render = i == last;
|
||||
let flags = take_flags(recovery_flags, o.pts_us);
|
||||
let present = gate.on_decoded(flags, false, now) == GateVerdict::Present;
|
||||
let render = i == last && present;
|
||||
match codec.release_output_buffer_by_index(o.index, render) {
|
||||
Ok(()) if render => {
|
||||
*rendered += 1;
|
||||
@@ -1215,7 +1271,7 @@ fn present_ready(
|
||||
}
|
||||
}
|
||||
}
|
||||
stats.note_skipped(skipped); // HUD `skipped` counter (newest-wins drops); no-op while hidden
|
||||
stats.note_skipped(skipped); // HUD `skipped` counter (newest-wins + held-off drops); no-op hidden
|
||||
}
|
||||
|
||||
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
|
||||
@@ -1411,19 +1467,30 @@ fn drain(
|
||||
in_flight: &mut VecDeque<(u64, i128)>,
|
||||
clock_offset: i64,
|
||||
tracker: &DisplayTracker,
|
||||
gate: &mut ReanchorGate,
|
||||
recovery_flags: &mut VecDeque<(u64, u32)>,
|
||||
) -> (u64, u64) {
|
||||
// Newest ready buffer so far (presented after the loop) with its HUD metadata —
|
||||
// `Some((pts_us, decoded_ns))` only while the HUD is visible (the stamp read is gated).
|
||||
// `Some((pts_us, decoded_ns))` only while the HUD is visible. `held_present` is the freeze gate's
|
||||
// verdict for that newest buffer (`false` = a post-loss concealment to withhold).
|
||||
let mut held: Option<(OutputBuffer<'_>, Option<(u64, i128)>)> = None;
|
||||
let mut held_present = true;
|
||||
let mut discarded: u64 = 0;
|
||||
let mut wait = first_wait;
|
||||
loop {
|
||||
match codec.dequeue_output_buffer(wait) {
|
||||
Ok(DequeuedOutputBufferInfoResult::Buffer(buf)) => {
|
||||
wait = Duration::ZERO; // only the first dequeue may block
|
||||
// Only the first dequeue may block; later ones poll (wait == ZERO).
|
||||
wait = Duration::ZERO;
|
||||
// Fold every dequeued frame through the gate in pts (== decode) order — even the ones
|
||||
// the newest-wins policy discards — so the two-mark re-anchor count stays correct; the
|
||||
// verdict of the newest (last folded) buffer decides whether it reaches glass.
|
||||
let pts_us = buf.info().presentation_time_us().max(0) as u64;
|
||||
let flags = take_flags(recovery_flags, pts_us);
|
||||
held_present =
|
||||
gate.on_decoded(flags, false, Instant::now()) == GateVerdict::Present;
|
||||
let meta = if stats.enabled() {
|
||||
// The dequeue IS the sync loop's decoded-availability instant.
|
||||
let pts_us = buf.info().presentation_time_us().max(0) as u64;
|
||||
let decoded_ns = now_realtime_ns();
|
||||
note_decoded_pts(stats, in_flight, clock_offset, pts_us, decoded_ns);
|
||||
Some((pts_us, decoded_ns))
|
||||
@@ -1469,16 +1536,19 @@ fn drain(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Present the newest ready frame, if any, and park its metadata for the render callback.
|
||||
// Present the newest ready frame — UNLESS the gate is withholding it as a post-loss concealment,
|
||||
// in which case release it without rendering (the SurfaceView keeps the last rendered frame frozen
|
||||
// on glass) and count it as a discard rather than a display.
|
||||
let mut rendered = 0;
|
||||
if let Some((buf, meta)) = held {
|
||||
match codec.release_output_buffer(buf, true) {
|
||||
Ok(()) => {
|
||||
match codec.release_output_buffer(buf, held_present) {
|
||||
Ok(()) if held_present => {
|
||||
rendered = 1;
|
||||
if let Some((pts_us, decoded_ns)) = meta {
|
||||
tracker.note_rendered(pts_us, decoded_ns);
|
||||
}
|
||||
}
|
||||
Ok(()) => discarded += 1, // held off the screen — awaiting a clean re-anchor
|
||||
Err(e) => log::warn!("decode: release_output_buffer: {e}"),
|
||||
}
|
||||
}
|
||||
@@ -1520,6 +1590,25 @@ fn note_decoded_pts(
|
||||
stats.note_decoded(e2e_us, decode_us);
|
||||
}
|
||||
|
||||
/// The AU `user_flags` for a decoded output, keyed by the echoed `presentationTimeUs`. Recovery
|
||||
/// signalling (FLAG_SOF IDR marker / RECOVERY_ANCHOR / RECOVERY_POINT) rides the AU's flags, which are
|
||||
/// only in scope at feed time — so the feed side parks `(pts_us, flags)` here and the present side
|
||||
/// looks them up to fold [`ReanchorGate::on_decoded`]. Decode order == input order (low-latency, no
|
||||
/// B-frames), so this evicts entries older than `pts_us` as it goes; a miss (probe filler, or an entry
|
||||
/// aged past the cap) reads `0` — no recovery flags, decoded normally.
|
||||
fn take_flags(map: &mut VecDeque<(u64, u32)>, pts_us: u64) -> u32 {
|
||||
while let Some(&(p, f)) = map.front() {
|
||||
if p > pts_us {
|
||||
break; // future frame — leave it for its own output buffer
|
||||
}
|
||||
map.pop_front();
|
||||
if p == pts_us {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// Map the decoder's reported output colour to a BT.2020 HDR dataspace, or `None` for SDR. The
|
||||
/// integer values are the Android MediaFormat colour constants the NDK shares: COLOR_TRANSFER
|
||||
/// ST2084 = 6 (PQ/HDR10), HLG = 7; COLOR_RANGE FULL = 1, LIMITED = 2 (the host encodes limited).
|
||||
|
||||
@@ -24,12 +24,13 @@ const TAG_PLAYER_LEDS: u8 = 0x02;
|
||||
const TAG_TRIGGER: u8 = 0x03;
|
||||
|
||||
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update.
|
||||
/// Returns a packed positive long: bit 48 = "has a v2 lease", bits 32..47 = `ttl_ms`, bits 16..31 =
|
||||
/// `low`, bits 0..15 = `high` (`low`/`high` 0..=0xFFFF, `0/0` = stop). The lease flag is
|
||||
/// out-of-band so ANY 16-bit `ttl_ms` — including 0xFFFF — is unambiguous (no in-band sentinel to
|
||||
/// collide with a real 65535 ms lease). No lease (legacy host) → bit 48 clear, and Kotlin falls
|
||||
/// back to its long one-shot. `-1` on timeout / session closed (all packed values are positive, so
|
||||
/// `-1` stays unambiguous). Pad index is dropped (single-pad model). Run from a Kotlin poll thread.
|
||||
/// Returns a packed positive long: bits 49..52 = wire `pad` index (0..15), bit 48 = "has a v2 lease",
|
||||
/// bits 32..47 = `ttl_ms`, bits 16..31 = `low`, bits 0..15 = `high` (`low`/`high` 0..=0xFFFF, `0/0` =
|
||||
/// stop). The lease flag is out-of-band so ANY 16-bit `ttl_ms` — including 0xFFFF — is unambiguous (no
|
||||
/// in-band sentinel to collide with a real 65535 ms lease). No lease (legacy host) → bit 48 clear, and
|
||||
/// Kotlin falls back to its long one-shot. `-1` on timeout / session closed (all packed values are
|
||||
/// positive, so `-1` stays unambiguous). Kotlin routes the update back to the controller holding that
|
||||
/// wire `pad` index (multi-pad rumble). Run from a Kotlin poll thread.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
_env: JNIEnv,
|
||||
@@ -46,14 +47,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
// threads (and joins them — unbounded) before nativeClose frees the handle.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
match h.client.next_rumble_ttl(PULL_TIMEOUT) {
|
||||
Ok((_pad, low, high, ttl)) => {
|
||||
Ok((pad, low, high, ttl)) => {
|
||||
// The reorder gate already ran in the core, so this update is fresh. Encode the
|
||||
// Option out-of-band: a real lease sets bit 48 and carries ttl_ms verbatim.
|
||||
// Option out-of-band: a real lease sets bit 48 and carries ttl_ms verbatim. The pad
|
||||
// index rides above the lease flag (bits 49..52), keeping the whole word positive.
|
||||
let (lease_flag, ttl_bits) = match ttl {
|
||||
Some(ms) => (1i64 << 48, jlong::from(ms) << 32),
|
||||
None => (0, 0),
|
||||
};
|
||||
lease_flag | ttl_bits | (jlong::from(low) << 16) | jlong::from(high)
|
||||
(jlong::from(pad & 0xF) << 49)
|
||||
| lease_flag
|
||||
| ttl_bits
|
||||
| (jlong::from(low) << 16)
|
||||
| jlong::from(high)
|
||||
}
|
||||
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
||||
}
|
||||
@@ -61,10 +67,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeNextHidout(handle, buf): Int` — block up to ~100 ms for the next DualSense
|
||||
/// HID-output event, written into the caller's direct ByteBuffer as `[kind][fields…]`:
|
||||
/// Led → `[0x01][r][g][b]` (len 4)
|
||||
/// PlayerLeds → `[0x02][bits]` (len 2)
|
||||
/// Trigger → `[0x03][which][effect…]` (len 2 + effect.len())
|
||||
/// HID-output event, written into the caller's direct ByteBuffer as `[pad][kind][fields…]` (the
|
||||
/// leading `pad` is the wire pad index the event is addressed to, so Kotlin routes it to that
|
||||
/// controller — multi-pad HID feedback):
|
||||
/// Led → `[pad][0x01][r][g][b]` (len 5)
|
||||
/// PlayerLeds → `[pad][0x02][bits]` (len 3)
|
||||
/// Trigger → `[pad][0x03][which][effect…]` (len 3 + effect.len())
|
||||
/// Returns the byte count written, or `-1` on timeout / session closed / buffer too small.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||
@@ -97,33 +105,37 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call.
|
||||
let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) };
|
||||
|
||||
// out[0] = wire pad index; out[1] = kind tag; the rest is the per-kind payload.
|
||||
let n = match ev {
|
||||
HidOutput::Led { r, g, b, .. } => {
|
||||
if cap < 4 {
|
||||
HidOutput::Led { pad, r, g, b } => {
|
||||
if cap < 5 {
|
||||
return -1;
|
||||
}
|
||||
out[0] = TAG_LED;
|
||||
out[1] = r;
|
||||
out[2] = g;
|
||||
out[3] = b;
|
||||
4
|
||||
out[0] = pad;
|
||||
out[1] = TAG_LED;
|
||||
out[2] = r;
|
||||
out[3] = g;
|
||||
out[4] = b;
|
||||
5
|
||||
}
|
||||
HidOutput::PlayerLeds { bits, .. } => {
|
||||
if cap < 2 {
|
||||
HidOutput::PlayerLeds { pad, bits } => {
|
||||
if cap < 3 {
|
||||
return -1;
|
||||
}
|
||||
out[0] = TAG_PLAYER_LEDS;
|
||||
out[1] = bits;
|
||||
2
|
||||
out[0] = pad;
|
||||
out[1] = TAG_PLAYER_LEDS;
|
||||
out[2] = bits;
|
||||
3
|
||||
}
|
||||
HidOutput::Trigger { which, effect, .. } => {
|
||||
let n = 2 + effect.len();
|
||||
HidOutput::Trigger { pad, which, effect } => {
|
||||
let n = 3 + effect.len();
|
||||
if cap < n {
|
||||
return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64
|
||||
}
|
||||
out[0] = TAG_TRIGGER;
|
||||
out[1] = which;
|
||||
out[2..n].copy_from_slice(&effect);
|
||||
out[0] = pad;
|
||||
out[1] = TAG_TRIGGER;
|
||||
out[2] = which;
|
||||
out[3..n].copy_from_slice(&effect);
|
||||
n
|
||||
}
|
||||
HidOutput::TrackpadHaptic { .. } => {
|
||||
|
||||
@@ -145,13 +145,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
|
||||
}
|
||||
|
||||
// ---- Gamepad: Kotlin captures (KeyEvent/MotionEvent) → NativeClient::send_input ---------------
|
||||
// Single-pad model: exactly one controller, forwarded as pad 0 (flags = 0). Buttons carry the
|
||||
// gamepad::BTN_* bit in `code` and pressed/released in `x` (1/0); axes carry the gamepad::AXIS_* id
|
||||
// in `code` and the value in `x` (sticks i16 −32768..32767, +y = up; triggers 0..255). The host
|
||||
// accumulates the incremental events into its virtual xpad. Wire contract: input.rs::gamepad.
|
||||
// Multi-pad model: each physical controller is forwarded on its own wire pad index (0..15), carried
|
||||
// in the low byte of `flags` on every per-pad event — the Kotlin side (`GamepadRouter`) assigns a
|
||||
// stable lowest-free index per Android device and threads it here. Buttons carry the gamepad::BTN_*
|
||||
// bit in `code` and pressed/released in `x` (1/0); axes carry the gamepad::AXIS_* id in `code` and
|
||||
// the value in `x` (sticks i16 −32768..32767, +y = up; triggers 0..255). The host accumulates the
|
||||
// incremental events per pad into a matching virtual device. The core input task folds these into
|
||||
// the seq'd GamepadState snapshots (keyed on this same `flags` index) and owns the per-pad seq — so
|
||||
// the only thing this layer must get right is the index. Wire contract: input.rs::gamepad. A single
|
||||
// controller lands on index 0, so its wire is byte-identical to the old single-pad path.
|
||||
|
||||
/// `NativeBridge.nativeSendGamepadButton(handle, bit, down)` — one gamepad button transition.
|
||||
/// `bit`: a `gamepad::BTN_*` bit (e.g. BTN_A = 0x1000). `down`: 1=press, 0=release.
|
||||
/// `NativeBridge.nativeSendGamepadButton(handle, bit, down, pad)` — one gamepad button transition on
|
||||
/// wire pad index `pad`. `bit`: a `gamepad::BTN_*` bit (e.g. BTN_A = 0x1000). `down`: 1=press,
|
||||
/// 0=release. `pad`: wire pad index 0..15 (rides `flags`).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadButton(
|
||||
_env: JNIEnv,
|
||||
@@ -159,21 +165,21 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
||||
handle: jlong,
|
||||
bit: jint,
|
||||
down: jboolean,
|
||||
pad: jint,
|
||||
) {
|
||||
// flags = 0: pad index 0 — single-pad model.
|
||||
send_event(
|
||||
handle,
|
||||
InputKind::GamepadButton,
|
||||
bit as u32,
|
||||
i32::from(down != 0),
|
||||
0,
|
||||
0,
|
||||
pad as u32,
|
||||
);
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendGamepadAxis(handle, axisId, value)` — one gamepad axis update.
|
||||
/// `axisId`: a `gamepad::AXIS_*` id (LS_X=0..RT=5). `value`: stick i16 (−32768..32767, +y=up) or
|
||||
/// trigger 0..255.
|
||||
/// `NativeBridge.nativeSendGamepadAxis(handle, axisId, value, pad)` — one gamepad axis update on wire
|
||||
/// pad index `pad`. `axisId`: a `gamepad::AXIS_*` id (LS_X=0..RT=5). `value`: stick i16
|
||||
/// (−32768..32767, +y=up) or trigger 0..255. `pad`: wire pad index 0..15 (rides `flags`).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadAxis(
|
||||
_env: JNIEnv,
|
||||
@@ -181,7 +187,52 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
||||
handle: jlong,
|
||||
axis_id: jint,
|
||||
value: jint,
|
||||
pad: jint,
|
||||
) {
|
||||
// flags = 0: pad index 0 — single-pad model.
|
||||
send_event(handle, InputKind::GamepadAxis, axis_id as u32, value, 0, 0);
|
||||
send_event(
|
||||
handle,
|
||||
InputKind::GamepadAxis,
|
||||
axis_id as u32,
|
||||
value,
|
||||
0,
|
||||
pad as u32,
|
||||
);
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendGamepadArrival(handle, pref, pad)` — declare the controller KIND presented
|
||||
/// on wire pad index `pad` so the host builds a matching virtual device (mixed types — pad 0 a
|
||||
/// DualSense, pad 1 an Xbox pad). `pref`: the `GamepadPref` wire byte (rides `code`). `pad`: wire pad
|
||||
/// index 0..15 (rides `flags`). Sent ONCE when a pad opens, BEFORE any of its input; the core re-sends
|
||||
/// it a few times against datagram loss, and an older host ignores the unknown tag (that pad then uses
|
||||
/// the session-default kind from the handshake — the pre-existing single-pad behaviour on pad 0).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadArrival(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pref: jint,
|
||||
pad: jint,
|
||||
) {
|
||||
send_event(
|
||||
handle,
|
||||
InputKind::GamepadArrival,
|
||||
pref as u32,
|
||||
0,
|
||||
0,
|
||||
pad as u32,
|
||||
);
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendGamepadRemove(handle, pad)` — signal that wire pad index `pad` was
|
||||
/// unplugged so the host tears its virtual device down. `pad` (rides `flags`) is the only field; the
|
||||
/// core stamps the per-pad seq (in the snapshot seq space, so a reordered snapshot can't resurrect the
|
||||
/// pad) and arms a re-send burst against datagram loss. An older host ignores the unknown tag.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadRemove(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jint,
|
||||
) {
|
||||
send_event(handle, InputKind::GamepadRemove, 0, 0, 0, pad as u32);
|
||||
}
|
||||
|
||||
@@ -2,24 +2,22 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Custom keys merged into the auto-generated Info.plist (GENERATE_INFOPLIST_FILE=YES
|
||||
supplies the rest). NSBonjourServices is required for NWBrowser to browse this
|
||||
service type on iOS/tvOS — without it the system blocks the browse and discovery
|
||||
returns nothing. Kept OUT of the synchronized App/ + Sources/ groups so it isn't
|
||||
auto-added as a bundle resource (which collides with Info.plist processing). -->
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>GCSupportedGameControllers</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>ProfileName</key>
|
||||
<string>ExtendedGamepad</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>ProfileName</key>
|
||||
<string>MicroGamepad</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_punktfunk._udp</string>
|
||||
</array>
|
||||
<!-- Standard-algorithm crypto only (AES-GCM via the Rust core) — exempt from export
|
||||
compliance, but the key must be declared or every TestFlight build stalls on the
|
||||
compliance question. -->
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<!-- Allow CADisplayLink above 60 Hz on ProMotion iPhones: without this key the system
|
||||
silently caps the link at 60 even when SessionPresenter asks for the stream's rate
|
||||
via preferredFrameRateRange, so a 120 fps stream would present at half rate. -->
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -40,6 +40,8 @@ let package = Package(
|
||||
// its manifest breaks SwiftPM whole-graph validation on macOS, and only the
|
||||
// Punktfunk-tvOS target links it; the #if os(tvOS) import never compiles here.)
|
||||
.executableTarget(name: "PunktfunkClient", dependencies: ["PunktfunkKit"]),
|
||||
.testTarget(name: "PunktfunkKitTests", dependencies: ["PunktfunkKit"]),
|
||||
// PunktfunkCore is a direct dep too so the wire tests can name the C ABI's
|
||||
// `PunktfunkInputEvent` / `PUNKTFUNK_INPUT_KIND_*` when asserting the gamepad byte layout.
|
||||
.testTarget(name: "PunktfunkKitTests", dependencies: ["PunktfunkKit", "PunktfunkCore"]),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -436,6 +436,7 @@
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
||||
INFOPLIST_KEY_GCSupportsGameMode = YES;
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||
@@ -477,6 +478,7 @@
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
||||
INFOPLIST_KEY_GCSupportsGameMode = YES;
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
|
||||
|
||||
@@ -49,6 +49,13 @@
|
||||
ReferencedContainer = "container:Punktfunk.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "PUNKTFUNK_BILINEAR_LUMA"
|
||||
value = "1"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
||||
@@ -419,9 +419,10 @@ final class SessionModel: ObservableObject {
|
||||
micChannel: defaults.integer(forKey: DefaultsKey.micChannel),
|
||||
micEnabled: defaults.object(forKey: DefaultsKey.micEnabled) as? Bool ?? true)
|
||||
self.audio = audio
|
||||
// Gamepads: forward GamepadManager's active controller as pad 0 and render the
|
||||
// host's feedback (rumble always; lightbar/player-LEDs/adaptive-triggers when the
|
||||
// session's virtual pad is a DualSense). Same trust gate as audio — nothing is
|
||||
// Gamepads: forward every controller GamepadManager selected — each on its own wire pad
|
||||
// index (a pin forwards only one, Automatic forwards all) — and render the host's feedback
|
||||
// back to the pad it's addressed to (rumble always; lightbar/player-LEDs/adaptive-triggers
|
||||
// when a pad's virtual device is a DualSense). Same trust gate as audio — nothing is
|
||||
// forwarded during the trust prompt.
|
||||
let capture = GamepadCapture(connection: conn, manager: .shared)
|
||||
// The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) — on tvOS the only
|
||||
|
||||
@@ -133,8 +133,10 @@ extension SettingsView {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if gamepads.active?.id == controller.id {
|
||||
Text("In use")
|
||||
// Every forwarded controller is surfaced (not just the primary `active`) with its
|
||||
// wire pad index as a player number — a pin forwards only one, Automatic forwards all.
|
||||
if let pad = gamepads.padIndex(for: controller) {
|
||||
Text("Player \(pad + 1)")
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
|
||||
@@ -21,10 +21,10 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.streamWidth) var width = 1920
|
||||
@AppStorage(DefaultsKey.streamHeight) var height = 1080
|
||||
@AppStorage(DefaultsKey.streamHz) var hz = 60
|
||||
// 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
|
||||
// Opt-in (default OFF): the explicit mode below is used and never auto-resized. When ON, a
|
||||
// windowed session instead streams at the window's native pixels (1:1, no scaling) so it stays
|
||||
// pixel-exact rather than the presenter resampling a fixed-mode frame into the window.
|
||||
@AppStorage(DefaultsKey.matchWindow) var matchWindow = false
|
||||
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
||||
|
||||
@@ -59,6 +59,26 @@ public extension PunktfunkInputEvent {
|
||||
make(PUNKTFUNK_INPUT_KIND_GAMEPAD_AXIS.rawValue, code: axis, x: value, y: 0, flags: pad)
|
||||
}
|
||||
|
||||
/// Declare a pad's controller KIND (`InputKind::GamepadArrival`): `pref` is the
|
||||
/// `GamepadType` wire byte (Auto=0, Xbox360=1, DualSense=2, XboxOne=3, DualShock4=4,
|
||||
/// SteamController=5, SteamDeck=6), `pad` the wire index. Sent once when a controller slot
|
||||
/// opens — BEFORE that pad's first input — so the host builds a matching virtual device and a
|
||||
/// session can mix types (pad 0 a DualSense, pad 1 an Xbox pad). The core re-sends it a few
|
||||
/// times against datagram loss and folds per-pad state behind it; a host that predates the tag
|
||||
/// ignores it and uses the session-default kind from the handshake. Idempotent on the host.
|
||||
static func gamepadArrival(pref: UInt32, pad: UInt32) -> PunktfunkInputEvent {
|
||||
make(PUNKTFUNK_INPUT_KIND_GAMEPAD_ARRIVAL.rawValue, code: pref, x: 0, y: 0, flags: pad)
|
||||
}
|
||||
|
||||
/// A pad disconnected (`InputKind::GamepadRemove`): `flags` = pad index. The client sends the
|
||||
/// bare index; the core stamps the per-pad removal seq (`encode_gamepad_remove`) in the shared
|
||||
/// snapshot seq space and arms a loss-resistant re-send burst, so the host tears the pad's
|
||||
/// virtual device down and no reordered snapshot can resurrect it. A host that predates the tag
|
||||
/// ignores it (the pad then lingers until session end — the pre-existing behaviour).
|
||||
static func gamepadRemove(pad: UInt32) -> PunktfunkInputEvent {
|
||||
make(PUNKTFUNK_INPUT_KIND_GAMEPAD_REMOVE.rawValue, code: 0, x: 0, y: 0, flags: pad)
|
||||
}
|
||||
|
||||
// Touch (host-side: libei ei_touchscreen on the virtual output). `id` distinguishes
|
||||
// fingers and is reusable after touchUp; coordinates are absolute pixels on the
|
||||
// client's touch surface, whose size rides in `flags` so the host can rescale —
|
||||
|
||||
@@ -450,6 +450,21 @@ public final class PunktfunkConnection {
|
||||
_ = punktfunk_connection_note_frame_index(h, frameIndex, nil)
|
||||
}
|
||||
|
||||
/// Like `noteFrameIndex`, but also reports whether the core saw a FORWARD frame-index gap — the
|
||||
/// signal that intervening frames were lost and the following AUs reference a picture that never
|
||||
/// arrived. The post-loss re-anchor gate arms its display freeze on a gap (the earliest, most
|
||||
/// precise loss trigger — ahead of the `framesDropped` climb). Same core side effect as
|
||||
/// `noteFrameIndex` (the throttled RFI request); call it for every received AU. Returns false
|
||||
/// after close.
|
||||
public func noteFrameIndexGap(_ frameIndex: UInt32) -> Bool {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return false }
|
||||
var gap = false
|
||||
_ = punktfunk_connection_note_frame_index(h, frameIndex, &gap)
|
||||
return gap
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
// Gamepad capture → punktfunk/1 datagrams. Forwards exactly ONE controller — whatever
|
||||
// GamepadManager selected — as pad 0, for the lifetime of a streaming session.
|
||||
// Gamepad capture → punktfunk/1 datagrams. Forwards EVERY controller GamepadManager selected —
|
||||
// each on its own stable wire pad index (pf-client-core's slot model) — for the lifetime of a
|
||||
// streaming session. One physical controller with no pin is player 0 (byte-identical to the old
|
||||
// single-pad path); a pin forwards only that one, also as pad 0.
|
||||
//
|
||||
// The wire is incremental (one button/axis transition per 18-byte event, accumulated
|
||||
// host-side into the virtual pad — see punktfunk_core::input::gamepad), so we snapshot the
|
||||
// full GCExtendedGamepad state on every valueChanged and diff against the previous
|
||||
// snapshot. Sticks are ±32767 with +y = up (GC already matches, no flip), triggers 0...255.
|
||||
// Each forwarded controller gets a `Slot`: its open GC handlers plus the wire state (buttons,
|
||||
// axes, touchpad fingers, motion throttle) for its pad index — isolated per device so two
|
||||
// controllers never clobber each other. On connect a slot opens (GamepadArrival declares its
|
||||
// kind, then input flows); on disconnect / pin change / stop it closes (held state flushed to
|
||||
// rest on the wire, then GamepadRemove tells the host to tear the pad's virtual device down).
|
||||
//
|
||||
// The wire is incremental (one button/axis transition per 18-byte event, accumulated host-side
|
||||
// into the virtual pad — see punktfunk_core::input::gamepad), so we snapshot the full
|
||||
// GCExtendedGamepad state on every valueChanged and diff against the previous snapshot. Sticks
|
||||
// are ±32767 with +y = up (GC already matches, no flip), triggers 0...255. The core folds these
|
||||
// per-pad transitions into idempotent, sequence-numbered snapshots keyed on the same pad index,
|
||||
// so all this layer must get right is the index — one controller per slot, one slot per index.
|
||||
//
|
||||
// PlayStation-pad extras ride the rich-input plane (0xCC): touchpad contacts normalized
|
||||
// 0...65535 (origin top-left, +y down — GC's ±1/+y-up is converted here) and motion
|
||||
// samples in raw DualSense sensor units (gyro 20 LSB per deg/s, accel 10000 LSB per g —
|
||||
// derived from the host's fixed calibration blob; the conversion lives in ONE place,
|
||||
// `Wire`, so a live sign/scale correction is a one-line change). The host ignores both
|
||||
// unless the session's virtual pad is a DualSense or DualShock 4 — both carry a touchpad
|
||||
// and motion, so the capture below covers either (`GCDualShockGamepad` exposes the same
|
||||
// `touchpad*` surface as `GCDualSenseGamepad`).
|
||||
// 0...65535 (origin top-left, +y down — GC's ±1/+y-up is converted here) and motion samples in
|
||||
// raw DualSense sensor units (gyro 20 LSB per deg/s, accel 10000 LSB per g — derived from the
|
||||
// host's fixed calibration blob; the conversion lives in ONE place, `Wire`, so a live sign/scale
|
||||
// correction is a one-line change). The host ignores both unless a pad's virtual device is a
|
||||
// DualSense or DualShock 4 — both carry a touchpad and motion, so the capture below covers either
|
||||
// (`GCDualShockGamepad` exposes the same `touchpad*` surface as `GCDualSenseGamepad`).
|
||||
//
|
||||
// Unlike mouse/keyboard capture, gamepad forwarding is NOT gated on the mouse-capture
|
||||
// toggle — a controller can't click local UI, so it always drives the host while the app
|
||||
// is active. On deactivation, controller switch, or stop, every held control is released
|
||||
// on the wire (the host pad would otherwise stay stuck on the last state).
|
||||
// Unlike mouse/keyboard capture, gamepad forwarding is NOT gated on the mouse-capture toggle — a
|
||||
// controller can't click local UI, so it always drives the host while the app is active. On
|
||||
// deactivation, controller switch, or stop, every held control is released on the wire (the host
|
||||
// pad would otherwise stay stuck on the last state).
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
@@ -33,17 +42,35 @@ import GameController
|
||||
public final class GamepadCapture {
|
||||
private let connection: PunktfunkConnection
|
||||
private let manager: GamepadManager
|
||||
private var activeSub: AnyCancellable?
|
||||
private var forwardedSub: AnyCancellable?
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
private var bound: GCController?
|
||||
/// App inactive → GC stops delivering; everything is released and stays silent.
|
||||
private var suspended = false
|
||||
|
||||
// Last wire state (the diff base — also what releaseAll() unwinds).
|
||||
private var buttons: UInt32 = 0
|
||||
private var axes: [Int32] = [0, 0, 0, 0, 0, 0]
|
||||
private var fingerActive: [Bool] = [false, false]
|
||||
private var lastMotionNs: UInt64 = 0
|
||||
/// One forwarded controller: the open device plus the last wire state for its pad index (the
|
||||
/// diff base — also what `flush` unwinds). Held per Slot so two controllers never clobber each
|
||||
/// other's held buttons/axes/fingers. Mirrors pf-client-core's `Slot`.
|
||||
private final class Slot {
|
||||
let controller: GCController
|
||||
/// Wire pad index (GamepadManager's stable lowest-free assignment), threaded onto every
|
||||
/// event this controller sends — the low byte of `flags`.
|
||||
let pad: UInt32
|
||||
/// The controller KIND declared to the host (GamepadArrival) when the slot opened.
|
||||
let pref: PunktfunkConnection.GamepadType
|
||||
var buttons: UInt32 = 0
|
||||
var axes: [Int32] = [0, 0, 0, 0, 0, 0]
|
||||
var fingerActive: [Bool] = [false, false]
|
||||
var lastMotionNs: UInt64 = 0
|
||||
init(controller: GCController, pad: UInt32, pref: PunktfunkConnection.GamepadType) {
|
||||
self.controller = controller
|
||||
self.pad = pad
|
||||
self.pref = pref
|
||||
}
|
||||
}
|
||||
|
||||
/// Open forwarded controllers, one Slot per physical pad on its own wire index. Reconciled
|
||||
/// against `manager.forwarded` (empty until a session's `start`, cleared by `stop`).
|
||||
private var slots: [Slot] = []
|
||||
|
||||
/// Motion forwarding floor: ≥ 4 ms between samples (≈ 250 Hz, the DualSense's own rate).
|
||||
private static let motionIntervalNs: UInt64 = 4_000_000
|
||||
@@ -71,10 +98,14 @@ public final class GamepadCapture {
|
||||
}
|
||||
|
||||
public func start() {
|
||||
// Fires immediately with the current selection, then on every change — a switch
|
||||
// releases the old controller's wire state before the new one takes over.
|
||||
activeSub = manager.$active.sink { [weak self] dc in
|
||||
MainActor.assumeIsolated { self?.rebind(to: dc?.controller) }
|
||||
// Session-scoped index assignment: a controller pinned before the session forwards as
|
||||
// pad 0 (pf-client-core assigns indices at slot-open time, not app-launch time).
|
||||
manager.resetForwardingAssignment()
|
||||
// Fires immediately with the current forwarded set, then on every change — a connect,
|
||||
// disconnect, or pin change reconciles the open slots against it (opening/closing devices
|
||||
// and flushing wire state so nothing sticks down).
|
||||
forwardedSub = manager.$forwarded.sink { [weak self] list in
|
||||
MainActor.assumeIsolated { self?.reconcile(list) }
|
||||
}
|
||||
#if os(macOS)
|
||||
let resign = NSApplication.willResignActiveNotification
|
||||
@@ -97,53 +128,56 @@ public final class GamepadCapture {
|
||||
MainActor.assumeIsolated {
|
||||
guard let self else { return }
|
||||
self.suspended = false
|
||||
if let ext = self.bound?.extendedGamepad { self.sync(ext) }
|
||||
// Re-send every open pad's current state (GC delivered nothing while inactive).
|
||||
for slot in self.slots {
|
||||
if let ext = slot.controller.extendedGamepad { self.sync(slot, ext) }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
releaseAll()
|
||||
rebind(to: nil)
|
||||
activeSub = nil
|
||||
closeAllSlots()
|
||||
forwardedSub = nil
|
||||
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
||||
observers.removeAll()
|
||||
}
|
||||
|
||||
private func rebind(to controller: GCController?) {
|
||||
guard controller !== bound else { return }
|
||||
releaseAll()
|
||||
if let ext = bound?.extendedGamepad {
|
||||
ext.valueChangedHandler = nil
|
||||
let tp = Self.touchpad(ext)
|
||||
tp?.primary.valueChangedHandler = nil
|
||||
tp?.secondary.valueChangedHandler = nil
|
||||
/// Bring `slots` in line with the forwarded set: close any slot no longer wanted (flushing its
|
||||
/// held wire state and sending GamepadRemove first) and open any newly-forwarded controller into
|
||||
/// its assigned wire index. A controller that stays forwarded keeps its slot untouched, so a
|
||||
/// second pad connecting never disturbs the first. Mirrors pf-client-core's `reconcile_slots`.
|
||||
private func reconcile(_ forwarded: [GamepadManager.DiscoveredController]) {
|
||||
let wantIDs = Set(forwarded.map { ObjectIdentifier($0.controller) })
|
||||
for slot in slots where !wantIDs.contains(ObjectIdentifier(slot.controller)) {
|
||||
closeSlot(slot)
|
||||
}
|
||||
// Hand the system gestures back to the OS before letting the old pad go — outside a
|
||||
// stream the share button's screenshot and the Home overlay are the user's, not ours.
|
||||
if let old = bound {
|
||||
for element in old.physicalInputProfile.elements.values {
|
||||
element.preferredSystemGestureState = .enabled
|
||||
}
|
||||
for dc in forwarded where !slots.contains(where: { $0.controller === dc.controller }) {
|
||||
openSlot(dc)
|
||||
}
|
||||
if let motion = bound?.motion {
|
||||
motion.valueChangedHandler = nil
|
||||
// Power the sensors back down — left active they keep the pad streaming
|
||||
// gyro/accel over Bluetooth (battery drain) long after the session.
|
||||
if motion.sensorsRequireManualActivation { motion.sensorsActive = false }
|
||||
}
|
||||
bound = controller
|
||||
guard let c = controller, let ext = c.extendedGamepad else { return }
|
||||
// A chord-holding pad may have just unplugged — re-evaluate so a stale hold disarms.
|
||||
updateEscapeChord()
|
||||
}
|
||||
|
||||
ext.valueChangedHandler = { [weak self] g, _ in
|
||||
MainActor.assumeIsolated { self?.sync(g) }
|
||||
/// Open one forwarded controller on its assigned wire index: attach GC handlers, claim its
|
||||
/// system gestures, declare its kind (GamepadArrival — before any input), then wake the host
|
||||
/// pad and send its initial state. Skipped when the pad has no wire index (every slot taken)
|
||||
/// or exposes no extended profile.
|
||||
private func openSlot(_ dc: GamepadManager.DiscoveredController) {
|
||||
guard let pad = manager.padIndex(for: dc), let ext = dc.controller.extendedGamepad else { return }
|
||||
let c = dc.controller
|
||||
let slot = Slot(controller: c, pad: UInt32(pad), pref: dc.kind)
|
||||
slots.append(slot)
|
||||
|
||||
ext.valueChangedHandler = { [weak self, weak slot] g, _ in
|
||||
MainActor.assumeIsolated { if let self, let slot { self.sync(slot, g) } }
|
||||
}
|
||||
// Claim EVERY element's system gesture while this pad drives a stream. The OS attaches
|
||||
// gestures to several controller buttons — share/create → local screenshot/recording,
|
||||
// Home → Game Center overlay (iOS) / Launchpad's Games folder (macOS) — and with a
|
||||
// gesture attached the press is the system's, not the game's. During capture the remote
|
||||
// session IS the game: the share button must reach the host (e.g. Steam screenshots),
|
||||
// the PS button must open the host's Steam overlay. Restored to .enabled on unbind.
|
||||
// the PS button must open the host's Steam overlay. Restored to .enabled on close.
|
||||
for element in c.physicalInputProfile.elements.values {
|
||||
element.preferredSystemGestureState = .disabled
|
||||
}
|
||||
@@ -153,42 +187,83 @@ public final class GamepadCapture {
|
||||
// `extendedGamepad.buttonHome` is unreliable/often nil even when the physical element
|
||||
// exists. On tvOS the element is absent (reserved) → nil, the whole block no-ops.
|
||||
if let home = c.physicalInputProfile.buttons[GCInputButtonHome] {
|
||||
home.pressedChangedHandler = { [weak self] _, _, pressed in
|
||||
MainActor.assumeIsolated { self?.sendGuide(down: pressed) }
|
||||
home.pressedChangedHandler = { [weak self, weak slot] _, _, pressed in
|
||||
MainActor.assumeIsolated { if let self, let slot { self.sendGuide(slot, down: pressed) } }
|
||||
}
|
||||
}
|
||||
// Wake the host pad immediately (pads are created lazily from the first event;
|
||||
// a DualSense's UHID handshake + initial lightbar write only start then).
|
||||
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: 0))
|
||||
sync(ext)
|
||||
// Declare this pad's controller KIND before any of its input, so the host builds a
|
||||
// matching virtual device (mixed types — pad 0 a DualSense, pad 1 an Xbox pad). The core
|
||||
// re-sends it a few times against datagram loss; an older host ignores it and uses the
|
||||
// session-default kind. Then wake the host pad (pads are created lazily from the first
|
||||
// event; a DualSense's UHID handshake + initial lightbar write only start then).
|
||||
connection.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
|
||||
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
|
||||
sync(slot, ext)
|
||||
|
||||
if let tp = Self.touchpad(ext) {
|
||||
tp.primary.valueChangedHandler = { [weak self] _, x, y in
|
||||
MainActor.assumeIsolated { self?.touch(finger: 0, x: x, y: y) }
|
||||
tp.primary.valueChangedHandler = { [weak self, weak slot] _, x, y in
|
||||
MainActor.assumeIsolated { if let self, let slot { self.touch(slot, finger: 0, x: x, y: y) } }
|
||||
}
|
||||
tp.secondary.valueChangedHandler = { [weak self] _, x, y in
|
||||
MainActor.assumeIsolated { self?.touch(finger: 1, x: x, y: y) }
|
||||
tp.secondary.valueChangedHandler = { [weak self, weak slot] _, x, y in
|
||||
MainActor.assumeIsolated { if let self, let slot { self.touch(slot, finger: 1, x: x, y: y) } }
|
||||
}
|
||||
}
|
||||
if let motion = c.motion {
|
||||
if motion.sensorsRequireManualActivation { motion.sensorsActive = true }
|
||||
motion.valueChangedHandler = { [weak self] m in
|
||||
MainActor.assumeIsolated { self?.forwardMotion(m) }
|
||||
motion.valueChangedHandler = { [weak self, weak slot] m in
|
||||
MainActor.assumeIsolated { if let self, let slot { self.forwardMotion(slot, m) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot the profile into wire state and send every transition since the last one.
|
||||
private func sync(_ g: GCExtendedGamepad) {
|
||||
/// Flush a slot's held wire state (so nothing sticks down host-side) and signal the host to tear
|
||||
/// its virtual device down (GamepadRemove), then detach GC handlers, hand the system gestures
|
||||
/// back, and power the sensors down. Wire-only until the GC cleanup, so it is safe even when the
|
||||
/// device already physically unplugged. Mirrors pf-client-core's `close_slot_at`.
|
||||
private func closeSlot(_ slot: Slot) {
|
||||
flush(slot)
|
||||
// Sent after the flush so the core stamps it with a seq past the zeroing snapshots; the host
|
||||
// seq-gates it, so a reordered snapshot can't resurrect the removed pad.
|
||||
connection.send(.gamepadRemove(pad: slot.pad))
|
||||
let c = slot.controller
|
||||
if let ext = c.extendedGamepad {
|
||||
ext.valueChangedHandler = nil
|
||||
let tp = Self.touchpad(ext)
|
||||
tp?.primary.valueChangedHandler = nil
|
||||
tp?.secondary.valueChangedHandler = nil
|
||||
}
|
||||
c.physicalInputProfile.buttons[GCInputButtonHome]?.pressedChangedHandler = nil
|
||||
// Hand the system gestures back to the OS before letting the pad go — outside a stream the
|
||||
// share button's screenshot and the Home overlay are the user's, not ours.
|
||||
for element in c.physicalInputProfile.elements.values {
|
||||
element.preferredSystemGestureState = .enabled
|
||||
}
|
||||
if let motion = c.motion {
|
||||
motion.valueChangedHandler = nil
|
||||
// Power the sensors back down — left active they keep the pad streaming gyro/accel
|
||||
// over Bluetooth (battery drain) long after the session.
|
||||
if motion.sensorsRequireManualActivation { motion.sensorsActive = false }
|
||||
}
|
||||
slots.removeAll { $0 === slot }
|
||||
}
|
||||
|
||||
private func closeAllSlots() {
|
||||
while let slot = slots.first { closeSlot(slot) }
|
||||
chordTimer?.invalidate()
|
||||
chordTimer = nil
|
||||
}
|
||||
|
||||
/// Snapshot the profile into a slot's wire state and send every transition since the last one,
|
||||
/// tagged with the slot's wire pad index.
|
||||
private func sync(_ slot: Slot, _ g: GCExtendedGamepad) {
|
||||
guard !suspended else { return }
|
||||
let newButtons = Self.buttonMask(g)
|
||||
updateEscapeChord(newButtons)
|
||||
let changed = newButtons ^ buttons
|
||||
let changed = newButtons ^ slot.buttons
|
||||
if changed != 0 {
|
||||
for bit in GamepadWire.allButtons where changed & bit != 0 {
|
||||
connection.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: 0))
|
||||
connection.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
|
||||
}
|
||||
buttons = newButtons
|
||||
slot.buttons = newButtons
|
||||
}
|
||||
let newAxes: [Int32] = [
|
||||
Int32((g.leftThumbstick.xAxis.value * 32767).rounded()),
|
||||
@@ -198,22 +273,23 @@ public final class GamepadCapture {
|
||||
Int32((g.leftTrigger.value * 255).rounded()),
|
||||
Int32((g.rightTrigger.value * 255).rounded()),
|
||||
]
|
||||
for (i, v) in newAxes.enumerated() where v != axes[i] {
|
||||
connection.send(.gamepadAxis(UInt32(i), value: v, pad: 0))
|
||||
axes[i] = v
|
||||
for (i, v) in newAxes.enumerated() where v != slot.axes[i] {
|
||||
connection.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad))
|
||||
slot.axes[i] = v
|
||||
}
|
||||
updateEscapeChord()
|
||||
}
|
||||
|
||||
/// Forward the guide (Home/PS) transition directly — it's kept out of `buttonMask` (the legacy
|
||||
/// `buttonHome` element is unreliable). Folds into `buttons` so a held PS button is released by
|
||||
/// `releaseAll` on focus loss just like the others.
|
||||
private func sendGuide(down: Bool) {
|
||||
/// `buttonHome` element is unreliable). Folds into the slot's `buttons` so a held PS button is
|
||||
/// released by `flush` on focus loss / close just like the others.
|
||||
private func sendGuide(_ slot: Slot, down: Bool) {
|
||||
guard !suspended else { return }
|
||||
let bit = GamepadWire.guide
|
||||
let now = down ? (buttons | bit) : (buttons & ~bit)
|
||||
guard now != buttons else { return }
|
||||
connection.send(.gamepadButton(bit, down: down, pad: 0))
|
||||
buttons = now
|
||||
let now = down ? (slot.buttons | bit) : (slot.buttons & ~bit)
|
||||
guard now != slot.buttons else { return }
|
||||
connection.send(.gamepadButton(bit, down: down, pad: slot.pad))
|
||||
slot.buttons = now
|
||||
}
|
||||
|
||||
private static func buttonMask(_ g: GCExtendedGamepad) -> UInt32 {
|
||||
@@ -234,7 +310,7 @@ public final class GamepadCapture {
|
||||
if g.leftShoulder.isPressed { b |= GamepadWire.leftShoulder }
|
||||
if g.rightShoulder.isPressed { b |= GamepadWire.rightShoulder }
|
||||
// guide (Home/PS) is NOT read here — it's forwarded directly by the Home button's
|
||||
// pressedChangedHandler (the legacy `buttonHome` element is unreliable). See `rebind`.
|
||||
// pressedChangedHandler (the legacy `buttonHome` element is unreliable). See `openSlot`.
|
||||
if g.buttonA.isPressed { b |= GamepadWire.a }
|
||||
if g.buttonB.isPressed { b |= GamepadWire.b }
|
||||
if g.buttonX.isPressed { b |= GamepadWire.x }
|
||||
@@ -262,29 +338,29 @@ public final class GamepadCapture {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// One touchpad finger moved. GC reports ±1 positions and snaps to exactly (0, 0) on
|
||||
/// lift — treated as the lift signal (a real finger landing on the precise center
|
||||
/// One touchpad finger moved on a slot's pad. GC reports ±1 positions and snaps to exactly
|
||||
/// (0, 0) on lift — treated as the lift signal (a real finger landing on the precise center
|
||||
/// momentarily reads as a lift; harmless for a 1-in-65k coincidence).
|
||||
private func touch(finger: Int, x: Float, y: Float) {
|
||||
private func touch(_ slot: Slot, finger: Int, x: Float, y: Float) {
|
||||
guard !suspended else { return }
|
||||
let lifted = x == 0 && y == 0
|
||||
if lifted {
|
||||
if fingerActive[finger] {
|
||||
fingerActive[finger] = false
|
||||
connection.sendTouchpad(finger: UInt8(finger), active: false, x: 0, y: 0)
|
||||
if slot.fingerActive[finger] {
|
||||
slot.fingerActive[finger] = false
|
||||
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: false, x: 0, y: 0)
|
||||
}
|
||||
return
|
||||
}
|
||||
fingerActive[finger] = true
|
||||
slot.fingerActive[finger] = true
|
||||
let w = GamepadWire.touchpad(x: x, y: y)
|
||||
connection.sendTouchpad(finger: UInt8(finger), active: true, x: w.x, y: w.y)
|
||||
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: true, x: w.x, y: w.y)
|
||||
}
|
||||
|
||||
private func forwardMotion(_ m: GCMotion) {
|
||||
private func forwardMotion(_ slot: Slot, _ m: GCMotion) {
|
||||
guard !suspended else { return }
|
||||
let now = DispatchTime.now().uptimeNanoseconds
|
||||
guard now &- lastMotionNs >= Self.motionIntervalNs else { return }
|
||||
lastMotionNs = now
|
||||
guard now &- slot.lastMotionNs >= Self.motionIntervalNs else { return }
|
||||
slot.lastMotionNs = now
|
||||
// Total acceleration in g: gravity + user when split, else the raw vector.
|
||||
let ax: Float
|
||||
let ay: Float
|
||||
@@ -301,6 +377,7 @@ public final class GamepadCapture {
|
||||
let gs = GamepadWire.gyroLSBPerRadS
|
||||
let as_ = GamepadWire.accelLSBPerG
|
||||
connection.sendMotion(
|
||||
pad: UInt8(slot.pad),
|
||||
gyro: (
|
||||
GamepadWire.motionRaw(Float(m.rotationRate.x), scale: gs),
|
||||
GamepadWire.motionRaw(Float(m.rotationRate.y), scale: gs),
|
||||
@@ -313,13 +390,12 @@ public final class GamepadCapture {
|
||||
))
|
||||
}
|
||||
|
||||
/// Unwind everything held on the wire: button-ups, neutral axes, lifted fingers. The
|
||||
/// host's virtual pad returns to rest instead of running with the last state.
|
||||
/// Arm the disconnect timer when the full chord lands, disarm the moment any of the four
|
||||
/// releases. Events only arrive on state CHANGES, so a held chord needs the timer — the
|
||||
/// handler won't fire again until something moves.
|
||||
private func updateEscapeChord(_ newButtons: UInt32) {
|
||||
let held = newButtons & Self.escapeChord == Self.escapeChord
|
||||
/// Arm the disconnect timer when ANY forwarded pad holds the full escape chord, disarm the
|
||||
/// moment none do — a release, or the holding pad unplugged (pf-client-core's `chord_held` is
|
||||
/// likewise any-slot). GC events only arrive on state CHANGES, so a held chord needs the timer:
|
||||
/// the handler won't fire again until something moves.
|
||||
private func updateEscapeChord() {
|
||||
let held = slots.contains { $0.buttons & Self.escapeChord == Self.escapeChord }
|
||||
if held, chordTimer == nil {
|
||||
let timer = Timer(timeInterval: Self.disconnectHold, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor in self?.onDisconnectRequest?() }
|
||||
@@ -332,20 +408,31 @@ public final class GamepadCapture {
|
||||
}
|
||||
}
|
||||
|
||||
/// Unwind everything a slot holds on the wire: button-ups, neutral axes, lifted fingers. The
|
||||
/// host's virtual pad returns to rest instead of running with the last state. Wire events only
|
||||
/// (no GC calls) — safe against an already-removed device. Does NOT close the slot or send
|
||||
/// GamepadRemove (that's `closeSlot`).
|
||||
private func flush(_ slot: Slot) {
|
||||
for bit in GamepadWire.allButtons where slot.buttons & bit != 0 {
|
||||
connection.send(.gamepadButton(bit, down: false, pad: slot.pad))
|
||||
}
|
||||
slot.buttons = 0
|
||||
for (i, v) in slot.axes.enumerated() where v != 0 {
|
||||
connection.send(.gamepadAxis(UInt32(i), value: 0, pad: slot.pad))
|
||||
slot.axes[i] = 0
|
||||
}
|
||||
for (f, active) in slot.fingerActive.enumerated() where active {
|
||||
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0)
|
||||
slot.fingerActive[f] = false
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush every open slot's held state (app deactivation) — keeps the slots open (GC just stops
|
||||
/// delivering; resume re-syncs), disarms the escape chord. Distinct from `closeAllSlots`, which
|
||||
/// also sends GamepadRemove and detaches handlers.
|
||||
private func releaseAll() {
|
||||
chordTimer?.invalidate()
|
||||
chordTimer = nil
|
||||
for bit in GamepadWire.allButtons where buttons & bit != 0 {
|
||||
connection.send(.gamepadButton(bit, down: false, pad: 0))
|
||||
}
|
||||
buttons = 0
|
||||
for (i, v) in axes.enumerated() where v != 0 {
|
||||
connection.send(.gamepadAxis(UInt32(i), value: 0, pad: 0))
|
||||
axes[i] = 0
|
||||
}
|
||||
for (f, active) in fingerActive.enumerated() where active {
|
||||
connection.sendTouchpad(finger: UInt8(f), active: false, x: 0, y: 0)
|
||||
fingerActive[f] = false
|
||||
}
|
||||
for slot in slots { flush(slot) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
// Host→client gamepad feedback rendering: one drain thread polls the rumble (0xCA) and
|
||||
// HID-output (0xCD) planes and replays them on the active physical controller —
|
||||
// HID-output (0xCD) planes and replays each update on the forwarded physical controller it is
|
||||
// ADDRESSED TO by wire pad index —
|
||||
//
|
||||
// rumble → CHHapticEngine players (per-handle localities when the pad has them,
|
||||
// one combined engine otherwise),
|
||||
// one combined engine otherwise), a RumbleRenderer per pad,
|
||||
// lightbar → GCDeviceLight,
|
||||
// player LEDs → GCController.playerIndex (the DS bit patterns map to player 1–4),
|
||||
// trigger FX → DualSenseTriggerEffect.parse → GCDualSenseAdaptiveTrigger.
|
||||
//
|
||||
// Only pad 0 is rendered (exactly one controller is forwarded). HID-output traffic exists
|
||||
// only on PlayStation-pad sessions (a DualSense, or a DualShock 4 = lightbar only) — the
|
||||
// drain always polls both planes with short timeouts and never spins, so an Xbox session
|
||||
// just renders rumble. GameController profile mutation
|
||||
// happens on main; CHHapticEngine work on its own serial queue; the drain thread itself
|
||||
// touches neither. When GamepadManager switches the active controller mid-session, the
|
||||
// old pad is reset (triggers off, player index unset) and the last known feedback state
|
||||
// is replayed onto the new one.
|
||||
// Every forwarded controller gets a per-pad feedback slot (its RumbleRenderer + last light /
|
||||
// player-LED / trigger state) keyed on the same wire index GamepadCapture streams it on, so a
|
||||
// rumble the host aimed at pad 1 drives pad 1's actuator and nothing else. An update for a pad
|
||||
// with no live slot (one that just closed) is dropped. HID-output traffic exists only on
|
||||
// PlayStation-pad sessions (a DualSense, or a DualShock 4 = lightbar only); the drain always
|
||||
// polls both planes with short timeouts and never spins, so an Xbox pad just renders rumble.
|
||||
// GameController profile mutation happens on main; CHHapticEngine work on the renderer's serial
|
||||
// queue; the drain thread itself touches neither (it routes rumble to the pad's renderer under a
|
||||
// lock and hops HID to main). When a controller leaves the forwarded set the old pad is reset
|
||||
// (triggers off, player index unset) and its renderer silenced.
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
@@ -22,26 +25,40 @@ import GameController
|
||||
|
||||
public final class GamepadFeedback {
|
||||
private let connection: PunktfunkConnection
|
||||
private let manager: GamepadManager
|
||||
private let flag = StopFlag()
|
||||
private let drainDone = DispatchSemaphore(value: 0)
|
||||
private var drainStarted = false
|
||||
private let rumble = RumbleRenderer(policy: .session)
|
||||
private var activeSub: AnyCancellable?
|
||||
private var forwardedSub: AnyCancellable?
|
||||
|
||||
// Last applied feedback (main-actor) — replayed when the active controller changes.
|
||||
@MainActor private var target: GCController?
|
||||
@MainActor private var lastLight: (r: UInt8, g: UInt8, b: UInt8)?
|
||||
@MainActor private var lastPlayerBits: UInt8?
|
||||
@MainActor private var lastTrigger: [DualSenseTriggerEffect?] = [nil, nil]
|
||||
/// One forwarded controller's non-rumble feedback state (main-actor) — the GC target plus the
|
||||
/// last applied lightbar / player-LED / trigger, replayed if the controller on this pad swaps.
|
||||
@MainActor private final class Slot {
|
||||
var controller: GCController?
|
||||
var lastLight: (r: UInt8, g: UInt8, b: UInt8)?
|
||||
var lastPlayerBits: UInt8?
|
||||
var lastTrigger: [DualSenseTriggerEffect?] = [nil, nil]
|
||||
init(controller: GCController?) { self.controller = controller }
|
||||
}
|
||||
/// HID / lightbar / player-LED slots, keyed by wire pad index. Main-actor only.
|
||||
@MainActor private var slots: [UInt8: Slot] = [:]
|
||||
|
||||
/// Rumble renderers keyed by wire pad index, guarded by `routingLock` so the background drain
|
||||
/// thread can route an incoming envelope to the right pad's renderer while the main actor
|
||||
/// reconciles the set. RumbleRenderer serializes on its own queue, so calling `apply` from the
|
||||
/// drain thread is safe — only the map lookup needs the lock.
|
||||
private let routingLock = NSLock()
|
||||
private var rumbleByPad: [UInt8: RumbleRenderer] = [:]
|
||||
|
||||
public init(connection: PunktfunkConnection, manager: GamepadManager) {
|
||||
self.connection = connection
|
||||
self.manager = manager
|
||||
// Capture self weakly in the hop too, so the inner sink's weak capture isn't shadowing
|
||||
// an implicit strong one — and the subscription (stored on self) never retain-cycles.
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
self.activeSub = manager.$active.sink { [weak self] dc in
|
||||
MainActor.assumeIsolated { self?.retarget(dc?.controller) }
|
||||
self.forwardedSub = manager.$forwarded.sink { [weak self] list in
|
||||
MainActor.assumeIsolated { self?.reconcile(list) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +84,38 @@ public final class GamepadFeedback {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring the per-pad feedback slots in line with the forwarded set: drop pads no longer
|
||||
/// forwarded (silence + release their renderer, reset their controller), add a slot +
|
||||
/// renderer for each new pad, and retarget a pad whose controller changed (a re-plug into the
|
||||
/// same freed index) — replaying its cached feedback onto the new device.
|
||||
@MainActor
|
||||
private func reconcile(_ forwarded: [GamepadManager.DiscoveredController]) {
|
||||
var want: [UInt8: GCController] = [:]
|
||||
for dc in forwarded {
|
||||
if let pad = manager.padIndex(for: dc) { want[pad] = dc.controller }
|
||||
}
|
||||
for (pad, slot) in slots where want[pad] == nil {
|
||||
reset(slot.controller)
|
||||
slots[pad] = nil
|
||||
let renderer = withRouting { rumbleByPad.removeValue(forKey: pad) }
|
||||
renderer?.stop()
|
||||
}
|
||||
for (pad, controller) in want {
|
||||
if let slot = slots[pad] {
|
||||
guard slot.controller !== controller else { continue }
|
||||
reset(slot.controller)
|
||||
slot.controller = controller
|
||||
withRouting { rumbleByPad[pad]?.retarget(controller) }
|
||||
replay(slot)
|
||||
} else {
|
||||
slots[pad] = Slot(controller: controller)
|
||||
let renderer = RumbleRenderer(policy: .session)
|
||||
renderer.retarget(controller)
|
||||
withRouting { rumbleByPad[pad] = renderer }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func start() {
|
||||
guard !drainStarted else { return }
|
||||
drainStarted = true
|
||||
@@ -88,19 +137,19 @@ public final class GamepadFeedback {
|
||||
// rumble/HID latency low while leaving the lock free between polls.
|
||||
//
|
||||
// Rumble is idempotent state, so drain the plane DRY and apply only the newest
|
||||
// level. The old one-datagram-per-cycle shape let a burst outpace the ~125 Hz
|
||||
// drain: levels rendered up to ~130 ms late through the core's 16-deep queue,
|
||||
// and its drop-newest overflow could shed a stop while stale nonzero states
|
||||
// queued ahead of it — buzzing until the host's next 500 ms refresh.
|
||||
var newest: (low: UInt16, high: UInt16, ttl: UInt32)?
|
||||
// level PER PAD. The old one-datagram-per-cycle shape let a burst outpace the
|
||||
// ~125 Hz drain: levels rendered up to ~130 ms late through the core's 16-deep
|
||||
// queue, and its drop-newest overflow could shed a stop while stale nonzero
|
||||
// states queued ahead of it — buzzing until the host's next 500 ms refresh.
|
||||
var newestByPad: [UInt8: (low: UInt16, high: UInt16, ttl: UInt32)] = [:]
|
||||
var rumbleBurst = 0
|
||||
while rumbleBurst < 64, !flag.isStopped,
|
||||
let r = try connection.nextRumble2(timeoutMs: 0) {
|
||||
if r.pad == 0 { newest = (r.low, r.high, r.ttlMs) }
|
||||
newestByPad[UInt8(truncatingIfNeeded: r.pad)] = (r.low, r.high, r.ttlMs)
|
||||
rumbleBurst += 1
|
||||
}
|
||||
if let n = newest {
|
||||
self?.rumble.apply(low: n.low, high: n.high, ttlMs: n.ttl)
|
||||
for (pad, n) in newestByPad {
|
||||
self?.routeRumble(pad: pad, low: n.low, high: n.high, ttlMs: n.ttl)
|
||||
}
|
||||
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
|
||||
// per-frame LED/trigger reports) can't spin here or block stop() past one cycle.
|
||||
@@ -126,7 +175,7 @@ public final class GamepadFeedback {
|
||||
thread.start()
|
||||
}
|
||||
|
||||
/// Stop the drain and silence the motors. Blocks until the drain thread exits (≤ one
|
||||
/// Stop the drain and silence every pad's motors. Blocks until the drain thread exits (≤ one
|
||||
/// poll cycle) — call off the main actor, before `connection.close()`.
|
||||
public func stop() {
|
||||
flag.stop()
|
||||
@@ -134,17 +183,32 @@ public final class GamepadFeedback {
|
||||
drainDone.wait()
|
||||
drainStarted = false
|
||||
}
|
||||
rumble.stop()
|
||||
// Drop the retarget subscription and the dead session's cached feedback — a
|
||||
// controller change after teardown must not replay this session's triggers/LEDs.
|
||||
Task { @MainActor in
|
||||
self.activeSub = nil
|
||||
self.lastLight = nil
|
||||
self.lastPlayerBits = nil
|
||||
self.lastTrigger = [nil, nil]
|
||||
self.reset(self.target)
|
||||
self.target = nil
|
||||
let renderers = withRouting { () -> [RumbleRenderer] in
|
||||
let r = Array(rumbleByPad.values)
|
||||
rumbleByPad.removeAll()
|
||||
return r
|
||||
}
|
||||
for r in renderers { r.stop() }
|
||||
// Drop the subscription and every dead pad's cached feedback — a controller change after
|
||||
// teardown must not replay this session's triggers/LEDs.
|
||||
Task { @MainActor in
|
||||
self.forwardedSub = nil
|
||||
for slot in self.slots.values { self.reset(slot.controller) }
|
||||
self.slots.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
/// Route one rumble envelope to its pad's renderer (drain thread). An update for a pad with no
|
||||
/// live renderer — one that just left the forwarded set — is dropped.
|
||||
private func routeRumble(pad: UInt8, low: UInt16, high: UInt16, ttlMs: UInt32) {
|
||||
let renderer = withRouting { rumbleByPad[pad] }
|
||||
renderer?.apply(low: low, high: high, ttlMs: ttlMs)
|
||||
}
|
||||
|
||||
private func withRouting<R>(_ body: () -> R) -> R {
|
||||
routingLock.lock()
|
||||
defer { routingLock.unlock() }
|
||||
return body()
|
||||
}
|
||||
|
||||
private func render(_ ev: PunktfunkConnection.HidOutputEvent) {
|
||||
@@ -157,40 +221,37 @@ public final class GamepadFeedback {
|
||||
private func apply(_ ev: PunktfunkConnection.HidOutputEvent) {
|
||||
switch ev {
|
||||
case let .led(pad, r, g, b):
|
||||
guard pad == 0 else { return }
|
||||
lastLight = (r, g, b)
|
||||
target?.light?.color = GCColor(
|
||||
guard let slot = slots[pad] else { return }
|
||||
slot.lastLight = (r, g, b)
|
||||
slot.controller?.light?.color = GCColor(
|
||||
red: Float(r) / 255, green: Float(g) / 255, blue: Float(b) / 255)
|
||||
case let .playerLEDs(pad, bits):
|
||||
guard pad == 0 else { return }
|
||||
lastPlayerBits = bits
|
||||
target?.playerIndex = Self.playerIndex(forBits: bits)
|
||||
guard let slot = slots[pad] else { return }
|
||||
slot.lastPlayerBits = bits
|
||||
slot.controller?.playerIndex = Self.playerIndex(forBits: bits)
|
||||
case let .triggerEffect(pad, which, effect):
|
||||
guard pad == 0, which < 2 else { return }
|
||||
guard which < 2, let slot = slots[pad] else { return }
|
||||
let parsed = DualSenseTriggerEffect.parse(effect)
|
||||
lastTrigger[Int(which)] = parsed
|
||||
if let trigger = adaptiveTrigger(which) {
|
||||
slot.lastTrigger[Int(which)] = parsed
|
||||
if let trigger = adaptiveTrigger(slot.controller, which) {
|
||||
parsed.apply(to: trigger)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replay a pad's cached feedback onto its (swapped-in) controller so a re-plug looks the same.
|
||||
@MainActor
|
||||
private func retarget(_ controller: GCController?) {
|
||||
guard controller !== target else { return }
|
||||
reset(target)
|
||||
target = controller
|
||||
rumble.retarget(controller)
|
||||
// Replay the session's feedback state so a swapped-in controller looks the same.
|
||||
if let (r, g, b) = lastLight {
|
||||
controller?.light?.color = GCColor(
|
||||
private func replay(_ slot: Slot) {
|
||||
if let (r, g, b) = slot.lastLight {
|
||||
slot.controller?.light?.color = GCColor(
|
||||
red: Float(r) / 255, green: Float(g) / 255, blue: Float(b) / 255)
|
||||
}
|
||||
if let bits = lastPlayerBits {
|
||||
controller?.playerIndex = Self.playerIndex(forBits: bits)
|
||||
if let bits = slot.lastPlayerBits {
|
||||
slot.controller?.playerIndex = Self.playerIndex(forBits: bits)
|
||||
}
|
||||
for which in 0..<2 {
|
||||
if let effect = lastTrigger[which], let trigger = adaptiveTrigger(UInt8(which)) {
|
||||
if let effect = slot.lastTrigger[which],
|
||||
let trigger = adaptiveTrigger(slot.controller, UInt8(which)) {
|
||||
effect.apply(to: trigger)
|
||||
}
|
||||
}
|
||||
@@ -207,8 +268,8 @@ public final class GamepadFeedback {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func adaptiveTrigger(_ which: UInt8) -> GCDualSenseAdaptiveTrigger? {
|
||||
guard let ds = target?.extendedGamepad as? GCDualSenseGamepad else { return nil }
|
||||
private func adaptiveTrigger(_ controller: GCController?, _ which: UInt8) -> GCDualSenseAdaptiveTrigger? {
|
||||
guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return nil }
|
||||
return which == 0 ? ds.leftTrigger : ds.rightTrigger
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
// Controller discovery + selection, app-lifetime. One GamepadManager (`.shared`) watches
|
||||
// GCController connect/disconnect from launch, so the Settings page shows live controller
|
||||
// state without a session, and the session components (GamepadCapture / GamepadFeedback)
|
||||
// follow `active` — exactly ONE physical controller is forwarded to the host, as pad 0.
|
||||
// follow `forwarded` — every forwarded controller is streamed to the host, each on its own
|
||||
// wire pad index (pf-client-core parity; up to `GamepadWire.maxPads`).
|
||||
//
|
||||
// Selection: the user can pin a controller in Settings (persisted under
|
||||
// DefaultsKey.gamepadID); with no pin — or the pinned one absent — the most recently
|
||||
// connected extended gamepad wins. GCController has no stable hardware serial, so the pin
|
||||
// is a fingerprint of vendorName|productCategory (+ a connect-order suffix for twins);
|
||||
// identical twin controllers may swap a pin across reconnects, which the Settings footer
|
||||
// documents.
|
||||
// Selection (mirrors pf-client-core's `forwarded_ids` + slot model): with no pin, EVERY
|
||||
// extended controller is forwarded — each assigned a stable lowest-free pad index held for
|
||||
// its forwarded lifetime, so a disconnect frees only its own index and never renumbers the
|
||||
// others. A pin (Settings, persisted under DefaultsKey.gamepadID) forwards ONLY that one pad
|
||||
// — an explicit single-player choice. `active` stays the single "primary" pad (the pinned
|
||||
// one, else the most recently connected extended gamepad) that the Settings / launcher / menu
|
||||
// UI reads. GCController has no stable hardware serial, so the pin is a fingerprint of
|
||||
// vendorName|productCategory (+ a connect-order suffix for twins); identical twin controllers
|
||||
// may swap a pin across reconnects, which the Settings footer documents.
|
||||
//
|
||||
// A singleton (not a SwiftUI environment object) because macOS shows Settings in its own
|
||||
// `Settings{}` scene — there is no common ancestor view to inject from.
|
||||
@@ -60,9 +64,23 @@ public final class GamepadManager: ObservableObject {
|
||||
/// Every detected controller, in connect order (Settings lists these).
|
||||
@Published public private(set) var controllers: [DiscoveredController] = []
|
||||
|
||||
/// The one controller forwarded to the host (pad 0); nil when none qualifies.
|
||||
/// The single "primary" controller — the pinned one, else the most recently connected
|
||||
/// extended gamepad; nil when none qualifies. The Settings / launcher / menu UI and the
|
||||
/// connect-time `resolveType` read this; the streaming input path uses `forwarded`.
|
||||
@Published public private(set) var active: DiscoveredController?
|
||||
|
||||
/// The controllers forwarded to the host this session, in wire-pad-index preference order
|
||||
/// (pf-client-core's `forwarded_ids`): a pin forwards ONLY the pinned pad; Automatic forwards
|
||||
/// every extended controller. GamepadCapture opens a slot per entry and GamepadFeedback routes
|
||||
/// feedback back to it, each on the index from `padIndex(for:)`.
|
||||
@Published public private(set) var forwarded: [DiscoveredController] = []
|
||||
|
||||
/// Stable wire pad index (0..<`GamepadWire.maxPads`) per forwarded controller, keyed by
|
||||
/// GCController identity. Lowest-free, held while the controller stays forwarded — a
|
||||
/// disconnect frees only its own index so the others never renumber (pf-client-core's
|
||||
/// `lowest_free_index`). Recomputed by `assignPadIndices` whenever `forwarded` changes.
|
||||
private var padIndexByController: [ObjectIdentifier: UInt8] = [:]
|
||||
|
||||
/// The user's pinned controller fingerprint ("" = automatic). Persisted; updating it
|
||||
/// reselects immediately, so a Settings Picker can bind straight to this.
|
||||
@Published public var preferredID: String {
|
||||
@@ -159,7 +177,52 @@ public final class GamepadManager: ObservableObject {
|
||||
let candidates = controllers.filter(\.isExtended)
|
||||
// The pin wins when present; otherwise the most recently connected extended pad
|
||||
// (list is in connect order). A stale pin falls back to automatic.
|
||||
active = candidates.last { $0.id == preferredID } ?? candidates.last
|
||||
let pinned = candidates.last { $0.id == preferredID }
|
||||
active = pinned ?? candidates.last
|
||||
// Forwarded set (pf-client-core's `forwarded_ids`): a pin forwards ONLY the pinned pad
|
||||
// (explicit single-player); Automatic forwards every extended controller in connect order
|
||||
// (oldest→newest), so a game's player numbers are stable across hot-plug churn.
|
||||
let next = pinned.map { [$0] } ?? candidates
|
||||
// Update the pad-index assignment BEFORE publishing `forwarded`: @Published emits in
|
||||
// `willSet`, so GamepadCapture/GamepadFeedback reconcile against `padIndex(for:)` the
|
||||
// instant this assignment lands — a stale map here would skip a newly-forwarded pad.
|
||||
assignPadIndices(for: next)
|
||||
forwarded = next
|
||||
}
|
||||
|
||||
/// Assign each forwarded controller a stable wire pad index (lowest-free, held while it stays
|
||||
/// forwarded) — mirrors pf-client-core's slot model, where a disconnect frees only its own
|
||||
/// index and the others keep theirs. A controller already holding an index keeps it across the
|
||||
/// churn; a slot beyond `GamepadWire.maxPads` goes unassigned (that pad is not forwarded).
|
||||
private func assignPadIndices(for next: [DiscoveredController]) {
|
||||
let live = Set(next.map { ObjectIdentifier($0.controller) })
|
||||
padIndexByController = padIndexByController.filter { live.contains($0.key) }
|
||||
for dc in next {
|
||||
let key = ObjectIdentifier(dc.controller)
|
||||
guard padIndexByController[key] == nil,
|
||||
let free = Self.lowestFreeIndex(Set(padIndexByController.values)) else { continue }
|
||||
padIndexByController[key] = free
|
||||
}
|
||||
}
|
||||
|
||||
/// The lowest wire pad index not already taken, or nil when all `GamepadWire.maxPads` are in
|
||||
/// use (pf-client-core's `lowest_free_index`).
|
||||
private static func lowestFreeIndex(_ taken: Set<UInt8>) -> UInt8? {
|
||||
(0..<UInt8(GamepadWire.maxPads)).first { !taken.contains($0) }
|
||||
}
|
||||
|
||||
/// The wire pad index a forwarded controller streams on, or nil when it isn't forwarded.
|
||||
public func padIndex(for controller: DiscoveredController) -> UInt8? {
|
||||
padIndexByController[ObjectIdentifier(controller.controller)]
|
||||
}
|
||||
|
||||
/// Drop every pad-index assignment and recompute from the current forwarded set — called when
|
||||
/// a streaming session begins so the assignment starts fresh (a controller pinned before the
|
||||
/// session forwards as pad 0, not whatever index it held for the Settings list). pf-client-core
|
||||
/// assigns indices at slot-open time; this reproduces that session-scoped start.
|
||||
public func resetForwardingAssignment() {
|
||||
padIndexByController.removeAll()
|
||||
reselect()
|
||||
}
|
||||
|
||||
private static func describe(_ c: GCController, id: String) -> DiscoveredController {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
// The gamepad wire contract shared by capture (GamepadCapture), feedback (GamepadFeedback),
|
||||
// and the tests — button bits, axis ids, and the touchpad/motion unit conversions.
|
||||
// and the tests — the pad count, button bits, axis ids, and the touchpad/motion unit conversions.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The gamepad wire contract (mirrors `punktfunk_core::input::gamepad`).
|
||||
public enum GamepadWire {
|
||||
/// Gamepads addressable on the wire — the pad index rides the low byte of `flags` on every
|
||||
/// per-pad event, 0...15 (`punktfunk_core::input::MAX_PADS`).
|
||||
public static let maxPads: Int = 16
|
||||
|
||||
public static let dpadUp: UInt32 = 0x0001
|
||||
public static let dpadDown: UInt32 = 0x0002
|
||||
public static let dpadLeft: UInt32 = 0x0004
|
||||
|
||||
@@ -85,6 +85,12 @@ public final class InputCapture {
|
||||
/// its Esc suppression need it in both states).
|
||||
private var cmdKeysDown: Set<UInt32> = []
|
||||
|
||||
/// Physical Control/Option/Shift keys currently held (Windows VKs, both L/R sides). iPad only:
|
||||
/// the ⌃⌥⇧Q release chord is recognized from the HID stream here (iOS has no NSEvent monitor,
|
||||
/// like the ⌘⎋ toggle), so it needs the live modifier state — tracked in both forwarding states,
|
||||
/// exactly like `cmdKeysDown`, and flushed by `releaseAll` when GC delivery stops.
|
||||
private var chordModifiersDown: Set<UInt32> = []
|
||||
|
||||
/// While true, mouse/keyboard flow to the host and key NSEvents are swallowed
|
||||
/// locally; while false the user is interacting with the local UI (dragging the
|
||||
/// window, clicking the HUD) and nothing is forwarded. Main-queue only.
|
||||
@@ -119,6 +125,21 @@ public final class InputCapture {
|
||||
public var onDisconnect: (() -> Void)?
|
||||
public var onCycleStats: (() -> Void)?
|
||||
|
||||
#if os(iOS)
|
||||
/// Windows VKs of the three modifier classes in the ⌃⌥⇧Q release chord, both L/R sides:
|
||||
/// control (0xA2/0xA3), option (0xA4/0xA5), shift (0xA0/0xA1). Used to sift the HID key stream.
|
||||
private static let chordModifierVKs: Set<UInt32> = [0xA2, 0xA3, 0xA4, 0xA5, 0xA0, 0xA1]
|
||||
|
||||
/// Whether Control AND Option AND Shift are all currently held (either side of each counts) —
|
||||
/// the modifier precondition for the iPad ⌃⌥⇧Q release chord.
|
||||
private var hasReleaseChordModifiers: Bool {
|
||||
let m = chordModifiersDown
|
||||
return (m.contains(0xA2) || m.contains(0xA3)) // control
|
||||
&& (m.contains(0xA4) || m.contains(0xA5)) // option
|
||||
&& (m.contains(0xA0) || m.contains(0xA1)) // shift
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Fired when a newer InputCapture takes the process-global GC handler slots (the
|
||||
/// singletons hold ONE handler each): the preempted owner must drop its capture
|
||||
/// state — its handlers are gone, so it would otherwise sit "captured" with dead
|
||||
@@ -294,6 +315,7 @@ public final class InputCapture {
|
||||
/// in another app would otherwise stay "held" here forever — hijacking Esc).
|
||||
private func releaseAll() {
|
||||
cmdKeysDown.removeAll()
|
||||
chordModifiersDown.removeAll()
|
||||
suppressedVK = nil
|
||||
for vk in pressedVKs {
|
||||
connection.send(.key(vk, down: false))
|
||||
@@ -576,6 +598,13 @@ public final class InputCapture {
|
||||
self.cmdKeysDown.remove(vk)
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
// Track Control/Option/Shift for the ⌃⌥⇧Q release chord below — in both forwarding
|
||||
// states (like `cmdKeysDown`) so a modifier held before capture engaged still counts.
|
||||
if Self.chordModifierVKs.contains(vk) {
|
||||
if pressed { self.chordModifiersDown.insert(vk) } else { self.chordModifiersDown.remove(vk) }
|
||||
}
|
||||
#endif
|
||||
// The ⌘⎋ toggle's Esc — checked before the forwarding gate, because in the
|
||||
// engage direction forwarding is already true when this fires.
|
||||
if vk == self.suppressedVK {
|
||||
@@ -592,6 +621,18 @@ public final class InputCapture {
|
||||
}
|
||||
#endif
|
||||
guard self.forwarding else { return }
|
||||
#if os(iOS)
|
||||
// ⌃⌥⇧Q releases the captured mouse/keyboard (cross-client parity — the same combo the
|
||||
// macOS keyDown monitor handles). Recognized only while forwarding (nothing to release
|
||||
// otherwise). The Q is latched (`suppressedVK`) so its keyUp can't type into the host;
|
||||
// the ⌃⌥⇧ modifiers were forwarded as they went down and are flushed by the release
|
||||
// path (setCaptured(false) → releaseAll). VK 0x51 is layout-independent (physical Q).
|
||||
if pressed, vk == 0x51, self.hasReleaseChordModifiers {
|
||||
self.suppressedVK = 0x51
|
||||
self.onReleaseCapture?()
|
||||
return
|
||||
}
|
||||
#endif
|
||||
// Release direction of the toggle: GC's Esc-down can beat the NSEvent
|
||||
// monitor — never type Esc into the host while ⌘ is held (⌘⎋ is reserved).
|
||||
if vk == 0x1B, !self.cmdKeysDown.isEmpty {
|
||||
|
||||
@@ -124,7 +124,16 @@ float2 chromaUV(texture2d<float> lumaTex, texture2d<float> chromaTex, float2 uv)
|
||||
float3 sampleRgb(texture2d<float> lumaTex, texture2d<float> chromaTex, float2 uv,
|
||||
constant CscUniform& csc) {
|
||||
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
||||
float3 yuv = float3(catmullRomLuma(lumaTex, s, uv),
|
||||
#ifdef PF_BILINEAR_LUMA
|
||||
// DEBUG (PUNKTFUNK_BILINEAR_LUMA=1): plain bilinear luma — Catmull-Rom OFF. A/B lever to see if
|
||||
// the bicubic overshoot contributes to edge fringing. NOTE: at a true 1:1 present both paths
|
||||
// reduce to the identity texel, so if this toggle VISIBLY changes the picture, the present is
|
||||
// NOT 1:1 (there's a resample); if it looks identical, the fringing is upstream (codec/source/OS).
|
||||
float lumaY = lumaTex.sample(s, uv).r;
|
||||
#else
|
||||
float lumaY = catmullRomLuma(lumaTex, s, uv);
|
||||
#endif
|
||||
float3 yuv = float3(lumaY,
|
||||
chromaTex.sample(s, chromaUV(lumaTex, chromaTex, uv)).rg);
|
||||
return saturate(float3(dot(csc.r0.xyz, yuv) + csc.r0.w,
|
||||
dot(csc.r1.xyz, yuv) + csc.r1.w,
|
||||
@@ -250,7 +259,16 @@ public final class MetalVideoPresenter {
|
||||
let pipelineHDR: MTLRenderPipelineState
|
||||
let pipelineHDRToneMap: MTLRenderPipelineState?
|
||||
do {
|
||||
let library = try device.makeLibrary(source: shaderSource, options: nil)
|
||||
// DEBUG A/B lever: PUNKTFUNK_BILINEAR_LUMA=1 compiles the shader with Catmull-Rom OFF
|
||||
// (plain bilinear luma) by prepending a #define ahead of the source. Default (unset) is
|
||||
// the normal bicubic path. Read at presenter creation — set it in the environment and
|
||||
// relaunch to flip; the log line confirms which path built.
|
||||
let bilinearLuma = ProcessInfo.processInfo.environment["PUNKTFUNK_BILINEAR_LUMA"] == "1"
|
||||
let source = (bilinearLuma ? "#define PF_BILINEAR_LUMA 1\n" : "") + shaderSource
|
||||
if bilinearLuma {
|
||||
presenterLog.info("stage2: PUNKTFUNK_BILINEAR_LUMA=1 — Catmull-Rom luma DISABLED (bilinear)")
|
||||
}
|
||||
let library = try device.makeLibrary(source: source, options: nil)
|
||||
let vtx = library.makeFunction(name: "pf_vtx")
|
||||
let sdr = MTLRenderPipelineDescriptor()
|
||||
sdr.vertexFunction = vtx
|
||||
@@ -590,8 +608,17 @@ public final class MetalVideoPresenter {
|
||||
let sig = "\(Int(decoded.width))x\(Int(decoded.height))→\(Int(drawable.width))x\(Int(drawable.height))|hdr\(hdrActive ? 1 : 0)"
|
||||
if sig != lastSizeSig {
|
||||
lastSizeSig = sig
|
||||
// Explicit verdict: is the shader presenting 1:1 (decoded == drawable) or resampling? The
|
||||
// scale ratio makes a residual match-window mismatch obvious. If this says 1:1 but the
|
||||
// picture is still soft, the resample is downstream of us (macOS compositor — a scaled
|
||||
// display mode, or a fractional-pixel window position), not the shader.
|
||||
let sx = decoded.width > 0 ? drawable.width / decoded.width : 0
|
||||
let sy = decoded.height > 0 ? drawable.height / decoded.height : 0
|
||||
let verdict = decoded == drawable
|
||||
? "1:1 (no resample)"
|
||||
: String(format: "RESAMPLE scale=%.4fx%.4f", sx, sy)
|
||||
let msg =
|
||||
"stage2: decoded \(Int(decoded.width))x\(Int(decoded.height)) → drawable \(Int(drawable.width))x\(Int(drawable.height)) hdr=\(hdrActive)"
|
||||
"stage2: decoded \(Int(decoded.width))x\(Int(decoded.height)) → drawable \(Int(drawable.width))x\(Int(drawable.height)) [\(verdict)] hdr=\(hdrActive)"
|
||||
presenterLog.info("\(msg, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// Swift wrapper around the punktfunk-core C ABI's post-loss re-anchor gate
|
||||
// (`punktfunk_reanchor_gate_*`, ABI v6). The shared Rust gate (crates/punktfunk-core/src/reanchor.rs)
|
||||
// is what the Linux/Windows desktop pump and the Android client use directly; the Swift clients reach
|
||||
// it across the C ABI so the freeze-until-reanchor policy is defined ONCE for every platform.
|
||||
//
|
||||
// Why a freeze at all: after unrecoverable loss the host keeps sending delta frames that reference a
|
||||
// picture the client never got. Hardware decoders (VideoToolbox included) don't reliably error on
|
||||
// that — they CONCEAL, returning a gray/garbage frame with a success status. Presenting those is the
|
||||
// visible "gray flash with motion" of the loss reports. The gate withholds concealed frames and holds
|
||||
// the last good picture on glass until a PROVEN clean re-anchor lands — an IDR (wire `FLAG_SOF`), an
|
||||
// RFI recovery anchor (`USER_FLAG_RECOVERY_ANCHOR`), or the 2nd of two intra-refresh recovery marks
|
||||
// (`USER_FLAG_RECOVERY_POINT`) — with a bounded backstop so a lost re-anchor can never freeze forever.
|
||||
// See punktfunk-planning design/client-reanchor-freeze-parity.md.
|
||||
//
|
||||
// Threading: one gate per session. Its calls arrive from two threads — the pump thread (`arm` on a
|
||||
// frame-index gap / a submit failure, `poll` per iteration) and a VideoToolbox decode thread
|
||||
// (`onDecoded` per decoded frame, `onNoOutput` on a decode error). The raw Rust gate is a plain
|
||||
// struct behind an opaque pointer with no internal synchronization, so every call is serialized under
|
||||
// `lock` here — the calls are cheap field updates, so contention is negligible. `@unchecked Sendable`:
|
||||
// the lock enforces the contract.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkCore
|
||||
|
||||
final class ReanchorGate: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
/// The opaque `ReanchorGate *`. `var` so `reseed` can swap it at session start. Never NULL
|
||||
/// (`punktfunk_reanchor_gate_new` never returns NULL).
|
||||
private var ptr: OpaquePointer
|
||||
|
||||
/// Seed the baseline with the connection's current `framesDropped` so the first `poll` doesn't
|
||||
/// read the session's starting drop count as a fresh loss.
|
||||
init(framesDropped: UInt64) {
|
||||
ptr = punktfunk_reanchor_gate_new(framesDropped)
|
||||
}
|
||||
|
||||
deinit { punktfunk_reanchor_gate_free(ptr) }
|
||||
|
||||
/// Re-anchor the drop-count baseline to `framesDropped` for a (re)started session. The gate is
|
||||
/// created in the pipeline's init (before a connection exists, seeded 0); `start` calls this once
|
||||
/// the live connection's count is known so a mid-life connection's non-zero baseline isn't
|
||||
/// mistaken for loss on the first poll.
|
||||
func reseed(framesDropped: UInt64) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
punktfunk_reanchor_gate_free(ptr)
|
||||
ptr = punktfunk_reanchor_gate_new(framesDropped)
|
||||
}
|
||||
|
||||
/// Arm the freeze: a loss was detected (a frame-index gap, or a decoder wedge). Zeroes the
|
||||
/// recovery-mark count and (re)sets the backstop deadline.
|
||||
func arm() {
|
||||
lock.lock()
|
||||
punktfunk_reanchor_gate_arm(ptr)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Fold one decoded frame. `flags` is the AU's wire `user_flags`. Returns true to PRESENT the
|
||||
/// frame, false to WITHHOLD it as a post-loss concealment (hold the last good picture). Pass
|
||||
/// `decoderKeyframe: false` — VideoToolbox doesn't flag IDRs, so the wire `FLAG_SOF` covers it.
|
||||
func onDecoded(flags: UInt32, decoderKeyframe: Bool = false) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
var present = false
|
||||
_ = punktfunk_reanchor_gate_on_decoded(ptr, flags, decoderKeyframe, &present)
|
||||
return present
|
||||
}
|
||||
|
||||
/// A received AU produced no decoded frame (a VideoToolbox decode error). Returns true when the
|
||||
/// no-output streak has tripped (the gate armed the freeze) and the caller should — throttled —
|
||||
/// request a keyframe.
|
||||
func onNoOutput() -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
var requestKf = false
|
||||
_ = punktfunk_reanchor_gate_on_no_output(ptr, &requestKf)
|
||||
return requestKf
|
||||
}
|
||||
|
||||
/// Periodic fold of the session's `framesDropped` plus the overdue backstop. Returns true when the
|
||||
/// caller should — throttled — request a keyframe (a drop-count climb armed a fresh freeze, or the
|
||||
/// freeze is overdue and re-asks while it keeps holding).
|
||||
func poll(framesDropped: UInt64) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
var requestKf = false
|
||||
_ = punktfunk_reanchor_gate_poll(ptr, framesDropped, &requestKf)
|
||||
return requestKf
|
||||
}
|
||||
|
||||
/// Whether the gate is currently withholding concealed frames (frozen on the last good picture).
|
||||
var isHolding: Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
var holding = false
|
||||
_ = punktfunk_reanchor_gate_is_holding(ptr, &holding)
|
||||
return holding
|
||||
}
|
||||
}
|
||||
@@ -205,17 +205,33 @@ final class SessionPresenter {
|
||||
return nil
|
||||
}()
|
||||
let fit: CGRect = aspect.map { AVMakeRect(aspectRatio: $0, insideRect: bounds) } ?? bounds
|
||||
// Snap the sublayer frame to the BACKING PIXEL GRID. AVMakeRect centers the aspect-fit rect,
|
||||
// so its origin/size are usually fractional points; a metal sublayer whose frame doesn't land
|
||||
// on whole device pixels is RESAMPLED by the macOS/UIKit compositor during composite — a
|
||||
// uniform "everything looks soft" blur — even when the drawable itself is pixel-exact 1:1
|
||||
// (verified via the stage2 "[1:1 (no resample)]" log while the picture was still soft). Round
|
||||
// origin AND size to device pixels so the composite is a true 1:1 blit. Idempotent when the
|
||||
// frame is already aligned (e.g. fullscreen fit == integer bounds), so it's a no-op there.
|
||||
let scale = contentsScale > 0 ? contentsScale : 1
|
||||
let snapped = CGRect(
|
||||
x: (fit.origin.x * scale).rounded() / scale,
|
||||
y: (fit.origin.y * scale).rounded() / scale,
|
||||
width: (fit.width * scale).rounded() / scale,
|
||||
height: (fit.height * scale).rounded() / scale)
|
||||
// No implicit resize animation; contentsScale tracks the view's backing/display scale.
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
metalLayer.contentsScale = contentsScale
|
||||
metalLayer.frame = fit
|
||||
metalLayer.frame = snapped
|
||||
CATransaction.commit()
|
||||
// Hand the resulting pixel size to the render thread (it must not read layer geometry
|
||||
// cross-thread) — this is what the presenter sizes its drawable to.
|
||||
// cross-thread) — this is what the presenter sizes its drawable to. Uses the SNAPPED size so
|
||||
// the drawable's texel count equals the on-screen device-pixel count exactly (1 texel ↔ 1
|
||||
// device pixel); with the frame snapped, this equals the pre-snap rounded value, so the
|
||||
// decoded↔drawable 1:1 the log confirmed is preserved.
|
||||
stage2?.setDrawableTarget(CGSize(
|
||||
width: (fit.width * contentsScale).rounded(),
|
||||
height: (fit.height * contentsScale).rounded()))
|
||||
width: (snapped.width * scale).rounded(),
|
||||
height: (snapped.height * scale).rounded()))
|
||||
#if os(tvOS)
|
||||
// Push the display's live EDR headroom alongside: > 1 means the TV is composited in an
|
||||
// HDR mode (the session's AVDisplayManager request landed — see StreamViewIOS), and HDR
|
||||
|
||||
@@ -259,6 +259,10 @@ public final class Stage2Pipeline {
|
||||
private let endToEndMeter: LatencyMeter?
|
||||
private let displayMeter: LatencyMeter?
|
||||
private let recovery = KeyframeRecovery()
|
||||
/// Post-loss freeze-until-reanchor gate (shared core policy via the C ABI). Created here seeded 0;
|
||||
/// `start` reseeds it to the live connection's drop count. Captured by the decoder callbacks
|
||||
/// (which withhold concealed frames) and driven by the pump (arm on a gap, poll per iteration).
|
||||
private let gate = ReanchorGate(framesDropped: 0)
|
||||
private var token = StopFlag()
|
||||
private var offsetNs: Int64 = 0
|
||||
/// Signalled when the pump thread exits, so `stop()` can join it (bounded) before `decoder.reset()`
|
||||
@@ -306,21 +310,29 @@ public final class Stage2Pipeline {
|
||||
let ring = ring
|
||||
let recovery = recovery
|
||||
let renderSignal = renderSignal
|
||||
let gate = gate
|
||||
self.decoder = VideoDecoder(
|
||||
onDecoded: { frame in
|
||||
// Decode stage = received→decoded, both client CLOCK_REALTIME (offset 0 — no
|
||||
// skew applies). Stamped at decode completion, so it covers every decoded frame,
|
||||
// including ones the newest-wins ring drops before present.
|
||||
// including ones the re-anchor gate withholds or the newest-wins ring drops.
|
||||
decodeMeter?.record(
|
||||
ptsNs: UInt64(frame.receivedNs), atNs: frame.decodedNs, offsetNs: 0)
|
||||
// Freeze-until-reanchor: WITHHOLD a decoder-concealed post-loss frame (the gray/
|
||||
// garbage VideoToolbox returns Ok for a reference-missing delta) — don't submit it,
|
||||
// so the CAMetalLayer keeps its last good drawable on glass. The gate lifts (returns
|
||||
// present) on a proven clean re-anchor (IDR / RFI anchor / 2nd recovery mark) or the
|
||||
// bounded backstop. decoderKeyframe=false: VT doesn't flag IDRs, the wire FLAG_SOF does.
|
||||
guard gate.onDecoded(flags: frame.flags) else { return }
|
||||
ring.submit(frame)
|
||||
// FRAME ARRIVAL is the render trigger (never the display link — see the header).
|
||||
renderSignal.signal()
|
||||
},
|
||||
// Async decode failure (a bad P-frame referencing a lost/corrupt IDR): the pump resets to
|
||||
// re-gate on the next IDR, and we ask the host to send one now (infinite GOP — it wouldn't
|
||||
// Async decode failure (a bad P-frame referencing a lost/corrupt IDR): fold it into the
|
||||
// gate's no-output streak (which arms the freeze after a short run, matching the desktop),
|
||||
// and when that trips ask the host for a fresh IDR now (infinite GOP — it wouldn't
|
||||
// otherwise come soon). Throttled in KeyframeRecovery.
|
||||
onDecodeError: { _ in recovery.request() })
|
||||
onDecodeError: { _ in if gate.onNoOutput() { recovery.request() } })
|
||||
}
|
||||
|
||||
/// Start pulling AUs into the decoder. MAIN THREAD. `onFrame` fires per AU at receipt (the
|
||||
@@ -334,6 +346,7 @@ public final class Stage2Pipeline {
|
||||
) {
|
||||
offsetNs = connection.clockOffsetNs
|
||||
recovery.bind(connection) // arm host-keyframe recovery for this session
|
||||
gate.reseed(framesDropped: connection.framesDropped()) // baseline the freeze to this session
|
||||
token = StopFlag() // fresh token per start — a stop is permanent (like StreamPump)
|
||||
|
||||
// Configure the decoder's chroma + the layer's initial colorimetry before the first frame. The
|
||||
@@ -348,6 +361,7 @@ public final class Stage2Pipeline {
|
||||
let recovery = recovery
|
||||
let presenter = presenter
|
||||
let pumpStopped = pumpStopped
|
||||
let reanchorGate = gate
|
||||
let thread = Thread {
|
||||
defer { pumpStopped.signal() } // let stop() join the pump (bounded) before decoder.reset()
|
||||
var format: CMVideoFormatDescription?
|
||||
@@ -379,6 +393,9 @@ public final class Stage2Pipeline {
|
||||
awaitingIDR = true
|
||||
}
|
||||
if awaitingIDR { recovery.request() }
|
||||
// Freeze backstop: a drop-count climb arms the gate (in case the frame-index gap
|
||||
// below was itself lost), and an overdue freeze re-asks for the re-anchor.
|
||||
if reanchorGate.poll(framesDropped: dropped) { recovery.request() }
|
||||
// Drain HDR mastering metadata (0xCE) and hand it to the PRESENTER (→ CAEDRMetadata).
|
||||
// Polled UNCONDITIONALLY (not gated on connection.isHDR, the fixed Welcome flag): the
|
||||
// host sends 0xCE only for HDR, INCLUDING a mid-session SDR→HDR transition (a game
|
||||
@@ -391,8 +408,10 @@ public final class Stage2Pipeline {
|
||||
// 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)
|
||||
// recovery above stays the backstop for when the recovery frame itself is lost.
|
||||
// The same gap is the earliest, most precise signal to ARM the display freeze —
|
||||
// the following concealed frames are withheld until a clean re-anchor.
|
||||
if connection.noteFrameIndexGap(au.frameIndex) { reanchorGate.arm() }
|
||||
onFrame?(au)
|
||||
if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) {
|
||||
format = f // refreshed on every IDR (mode changes included)
|
||||
|
||||
@@ -28,6 +28,11 @@ final class StreamPump {
|
||||
// Coalesced host keyframe requests (100 ms throttle — see KeyframeRecovery).
|
||||
let recovery = KeyframeRecovery()
|
||||
recovery.bind(connection)
|
||||
// Post-loss freeze-until-reanchor (shared core policy via the C ABI). Stage-1 has no per-frame
|
||||
// decode callback, so the gate is folded at ENQUEUE (from the AU's wire flags): a withheld
|
||||
// frame is still enqueued but flagged DoNotDisplay so the layer's decoder keeps the reference
|
||||
// chain fed while the last GOOD picture stays on glass — until a clean re-anchor lifts it.
|
||||
let gate = ReanchorGate(framesDropped: connection.framesDropped())
|
||||
// The layer is non-Sendable but its enqueue/flush are documented thread-safe, and after
|
||||
// this point only the pump thread drives it — assert that so the @Sendable Thread closure
|
||||
// may capture it.
|
||||
@@ -77,13 +82,17 @@ final class StreamPump {
|
||||
awaitingIDR = true
|
||||
}
|
||||
if awaitingIDR { recovery.request() }
|
||||
// Freeze backstop: a drop-count climb arms the gate (should the frame-index gap
|
||||
// below be lost too), and an overdue freeze re-asks for the re-anchor.
|
||||
if gate.poll(framesDropped: dropped) { 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)
|
||||
// The same gap is the earliest, most precise signal to ARM the display freeze.
|
||||
if connection.noteFrameIndexGap(au.frameIndex) { gate.arm() }
|
||||
onFrame?(au)
|
||||
let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data)
|
||||
if let f = idrFormat {
|
||||
@@ -107,6 +116,7 @@ final class StreamPump {
|
||||
// delta into a failed layer can't recover it.
|
||||
if !wasFailed { pumpLog.warning("video: display layer .failed — flushing + re-anchoring") }
|
||||
layer.flush()
|
||||
gate.arm() // a wedged decoder is a loss — freeze until the re-anchor
|
||||
if idrFormat == nil {
|
||||
format = nil
|
||||
awaitingIDR = true
|
||||
@@ -117,6 +127,13 @@ final class StreamPump {
|
||||
let sample = connection.videoCodec.sampleBuffer(au: au, format: f),
|
||||
!token.isStopped // don't enqueue a stale frame after a restart
|
||||
else { return true }
|
||||
// Freeze-until-reanchor: while holding, WITHHOLD this concealed post-loss frame by
|
||||
// flagging it DoNotDisplay — the layer still decodes it (keeping the reference
|
||||
// chain fed) but shows the last GOOD picture until a clean re-anchor lifts the
|
||||
// gate. Folded from the AU's wire flags (stage-1 has no decode callback).
|
||||
if !gate.onDecoded(flags: au.flags) {
|
||||
StreamPump.setDoNotDisplay(sample)
|
||||
}
|
||||
layer.enqueue(sample)
|
||||
return true
|
||||
} catch {
|
||||
@@ -133,6 +150,21 @@ final class StreamPump {
|
||||
thread.start()
|
||||
}
|
||||
|
||||
/// Flag a sample decode-but-don't-display (`kCMSampleAttachmentKey_DoNotDisplay`). Used to
|
||||
/// withhold decoder-concealed post-loss frames while the re-anchor gate holds: the layer keeps
|
||||
/// its reference chain fed without flipping the frozen picture. No-op if the attachments array
|
||||
/// can't be materialized (then the frame just displays — the freeze degrades to the old behavior).
|
||||
private static func setDoNotDisplay(_ sample: CMSampleBuffer) {
|
||||
guard let attachments = CMSampleBufferGetSampleAttachmentsArray(
|
||||
sample, createIfNecessary: true), CFArrayGetCount(attachments) > 0
|
||||
else { return }
|
||||
let dict = unsafeBitCast(CFArrayGetValueAtIndex(attachments, 0), to: CFMutableDictionary.self)
|
||||
CFDictionarySetValue(
|
||||
dict,
|
||||
Unmanaged.passUnretained(kCMSampleAttachmentKey_DoNotDisplay).toOpaque(),
|
||||
Unmanaged.passUnretained(kCFBooleanTrue).toOpaque())
|
||||
}
|
||||
|
||||
/// Stop pumping (≤ one poll timeout). Does not close the connection.
|
||||
func stop() {
|
||||
token.stop()
|
||||
|
||||
@@ -27,19 +27,40 @@ public struct ReadyFrame: @unchecked Sendable {
|
||||
/// True when the stream is HDR (BT.2020 PQ): the buffer is 10-bit P010 and the presenter must
|
||||
/// configure EDR + BT.2020 PQ output. Derived from the decoded buffer's pixel format.
|
||||
public let isHDR: Bool
|
||||
/// The AU's wire `user_flags` (`AccessUnit.flags`), threaded through the decode via the frame
|
||||
/// context so the re-anchor gate can classify this decoded frame (IDR / RFI anchor / recovery
|
||||
/// mark) at present time — the async decode callback has no other access to it. 0 when unknown.
|
||||
public let flags: UInt32
|
||||
}
|
||||
|
||||
/// Per-frame context threaded through the VideoToolbox frame refcon: the AU's receipt instant (for
|
||||
/// the decode-stage meter) and its wire `user_flags` (for the re-anchor gate). Retained across the
|
||||
/// async decode and reclaimed exactly once — by the output callback for every frame VideoToolbox
|
||||
/// accepts, or by `decode`'s error branch for a frame `DecodeFrame` rejected outright (the callback
|
||||
/// then never fires). A tiny per-frame allocation, the price of smuggling two values (a 64-bit
|
||||
/// instant plus the flags) through the single `void*` a bit-pattern scalar can't hold.
|
||||
private final class FrameContext {
|
||||
let receivedNs: Int64
|
||||
let flags: UInt32
|
||||
init(receivedNs: Int64, flags: UInt32) {
|
||||
self.receivedNs = receivedNs
|
||||
self.flags = flags
|
||||
}
|
||||
}
|
||||
|
||||
/// The C output callback can't capture context, so VideoToolbox hands it the refcon we set at
|
||||
/// session creation — a pointer back to the owning `VideoDecoder`. The per-frame refcon carries
|
||||
/// the AU's `receivedNs` as a pointer bit pattern (a scalar smuggled through the C void*, never
|
||||
/// dereferenced) so the decode stage can be computed against decode-completion.
|
||||
/// session creation — a pointer back to the owning `VideoDecoder`. The per-frame refcon is the
|
||||
/// retained `FrameContext` set at submit; reclaim it here (balancing `passRetained`) and unpack the
|
||||
/// AU's receipt instant (for the decode stage) and wire flags (for the re-anchor gate).
|
||||
private let decoderOutputCallback: VTDecompressionOutputCallback = {
|
||||
refcon, frameRefcon, status, _, imageBuffer, pts, _ in
|
||||
guard let refcon else { return }
|
||||
let receivedNs = frameRefcon.map { Int64(Int(bitPattern: $0)) } ?? 0
|
||||
let ctx = frameRefcon.map { Unmanaged<FrameContext>.fromOpaque($0).takeRetainedValue() }
|
||||
Unmanaged<VideoDecoder>.fromOpaque(refcon)
|
||||
.takeUnretainedValue()
|
||||
.handleDecoded(status: status, imageBuffer: imageBuffer, pts: pts, receivedNs: receivedNs)
|
||||
.handleDecoded(
|
||||
status: status, imageBuffer: imageBuffer, pts: pts,
|
||||
receivedNs: ctx?.receivedNs ?? 0, flags: ctx?.flags ?? 0)
|
||||
}
|
||||
|
||||
/// Owns a `VTDecompressionSession` rebuilt whenever the format description changes (every IDR /
|
||||
@@ -117,16 +138,21 @@ public final class VideoDecoder: @unchecked Sendable {
|
||||
let sample = codec.sampleBuffer(au: au, format: newFormat)
|
||||
else { lock.unlock(); return false }
|
||||
var infoOut = VTDecodeInfoFlags()
|
||||
// The AU's receipt instant + wire flags ride through as a retained context; the output
|
||||
// callback reclaims it. Retain immediately before submit so no early return can leak it.
|
||||
let ctx = FrameContext(receivedNs: au.receivedNs, flags: au.flags)
|
||||
let refcon = Unmanaged.passRetained(ctx).toOpaque()
|
||||
let status = VTDecompressionSessionDecodeFrame(
|
||||
session,
|
||||
sampleBuffer: sample,
|
||||
flags: [._EnableAsynchronousDecompression],
|
||||
// The AU's receipt instant rides through as a bit pattern (nil for 0 — the output
|
||||
// callback maps that back to 0); the callback needs it to stamp the decode stage.
|
||||
frameRefcon: UnsafeMutableRawPointer(bitPattern: Int(au.receivedNs)),
|
||||
frameRefcon: refcon,
|
||||
infoFlagsOut: &infoOut)
|
||||
lock.unlock()
|
||||
if status != noErr {
|
||||
// DecodeFrame rejected the frame outright — the output callback will NOT fire, so
|
||||
// reclaim the context here (balancing passRetained) to avoid leaking it.
|
||||
Unmanaged<FrameContext>.fromOpaque(refcon).release()
|
||||
onDecodeError(status)
|
||||
return false
|
||||
}
|
||||
@@ -231,9 +257,10 @@ public final class VideoDecoder: @unchecked Sendable {
|
||||
}
|
||||
|
||||
/// VT thread. Stamp decode-completion and enqueue, or report the error. `receivedNs` is the
|
||||
/// AU's receipt instant threaded through the frame refcon (0 = unknown).
|
||||
/// AU's receipt instant and `flags` its wire `user_flags`, both threaded through the frame refcon
|
||||
/// (0 = unknown).
|
||||
fileprivate func handleDecoded(
|
||||
status: OSStatus, imageBuffer: CVImageBuffer?, pts: CMTime, receivedNs: Int64
|
||||
status: OSStatus, imageBuffer: CVImageBuffer?, pts: CMTime, receivedNs: Int64, flags: UInt32
|
||||
) {
|
||||
guard status == noErr, let imageBuffer else {
|
||||
onDecodeError(status)
|
||||
@@ -259,6 +286,6 @@ public final class VideoDecoder: @unchecked Sendable {
|
||||
onDecoded(
|
||||
ReadyFrame(
|
||||
ptsNs: ptsNs, receivedNs: receivedNs, decodedNs: decodedNs,
|
||||
pixelBuffer: imageBuffer, isHDR: isHDR))
|
||||
pixelBuffer: imageBuffer, isHDR: isHDR, flags: flags))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,15 +661,16 @@ public final class StreamLayerView: NSView {
|
||||
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
|
||||
// Match-window (C3): when ON, follow the window's pixel size 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).
|
||||
// OPT-IN — `?? false` matches the Settings toggle (which also defaults off); an unset
|
||||
// default keeps the explicit mode.
|
||||
let follower = MatchWindowFollower(
|
||||
connection: connection,
|
||||
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? true)
|
||||
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false)
|
||||
follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower)
|
||||
matchFollower = follower
|
||||
layoutPresenter()
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
// (== locked): GCMouse forwards only WHILE locked, the UIKit indirect path (motion, buttons AND
|
||||
// scroll) only while NOT locked — so a pointer that emits both channels under lock can't double-send.
|
||||
// Hardware keyboard forwarding shares InputCapture with macOS — auto-engaged when streaming
|
||||
// starts, ⌘⎋ toggles (detected from the HID stream; there is no NSEvent monitor here).
|
||||
// starts, ⌘⎋ toggles and ⌃⌥⇧Q releases (both detected from the HID stream; there is no NSEvent
|
||||
// monitor here). ⌃⌥⇧Q is the cross-client Ctrl+Alt+Shift+Q — it un-captures so the Magic Keyboard
|
||||
// trackpad drives the local iPad UI again.
|
||||
//
|
||||
// The public type is named StreamView like its macOS twin (each is platform-gated), so
|
||||
// the SwiftUI app layer is identical on both platforms.
|
||||
@@ -337,7 +339,19 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
x: p.x, y: p.y, surfaceWidth: p.w, surfaceHeight: p.h)
|
||||
}
|
||||
streamView.onPointerButton = { [weak self] button, down in
|
||||
guard let self, self.inputCapture?.gcMouseForwarding == false else { return }
|
||||
guard let self else { return }
|
||||
// Released → a trackpad/mouse click into the video RE-ENGAGES capture (the iPad
|
||||
// analogue of macOS's `mouseDown → engageCapture(fromClick:)`, and the click-mirror of
|
||||
// the ⌘⎋ / ⌃⌥⇧Q keyboard toggles). Only the button-DOWN engages; that click is the local
|
||||
// engage gesture, so it's suppressed toward the host (`fromClick`) and never forwarded —
|
||||
// its release is swallowed by InputCapture's suppress latch, whichever path delivers it.
|
||||
// (Finger taps are untouched: touch always plays directly, so only the indirect pointer
|
||||
// re-captures.) Captured already → the absolute path forwards the button as before.
|
||||
if !self.captured {
|
||||
if down, self.captureEnabled { self.setCaptured(true, fromClick: true) }
|
||||
return
|
||||
}
|
||||
guard self.inputCapture?.gcMouseForwarding == false else { return }
|
||||
self.inputCapture?.sendMouseButton(button, pressed: down)
|
||||
}
|
||||
streamView.onScroll = { [weak self] dx, dy in
|
||||
@@ -350,19 +364,27 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
guard let self else { return }
|
||||
self.setCaptured(!self.captured)
|
||||
}
|
||||
// ⌃⌥⇧Q (cross-client parity with macOS/Windows/Linux) releases the captured pointer +
|
||||
// keyboard so the Magic Keyboard trackpad returns to driving the local iPad UI. Detected
|
||||
// from the HID stream in InputCapture (no NSEvent monitor on iOS); unlike the ⌘⎋ toggle it
|
||||
// only ever RELEASES — re-pressing it while already released is a no-op (setCaptured guards).
|
||||
capture.onReleaseCapture = { [weak self] in
|
||||
self?.setCaptured(false)
|
||||
}
|
||||
capture.onPreempted = { [weak self] in
|
||||
self?.setCaptured(false)
|
||||
}
|
||||
capture.start()
|
||||
inputCapture = capture
|
||||
// Match-window (C3): follow the scene's pixel size — DEFAULT ON, so a resizable iPad scene
|
||||
// Match-window (C3): when ON, follow the scene's pixel size 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).
|
||||
// OPT-IN — `?? false` matches the Settings toggle (which also defaults off); an unset
|
||||
// default keeps the explicit mode.
|
||||
let follower = MatchWindowFollower(
|
||||
connection: connection,
|
||||
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? true)
|
||||
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false)
|
||||
follower.onResizeTarget = onResizeTarget
|
||||
matchFollower = follower
|
||||
#endif
|
||||
@@ -422,6 +444,19 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
) { [weak self] _ in
|
||||
self?.syncPointerLock()
|
||||
})
|
||||
// The Stream menu's "Release Mouse" (⌃⌥⇧Q) posts this — the discoverable menu surface for
|
||||
// the RELEASED state. While CAPTURED the combo is recognized from the HID stream in
|
||||
// InputCapture (onReleaseCapture) before the menu sees it, so in practice this fires as a
|
||||
// not-captured no-op (setCaptured guards it); wired for honesty + a non-GC fallback. Only the
|
||||
// foreground-active scene's stream acts — the iPad analogue of macOS's key-window guard, so a
|
||||
// second Stage Manager scene isn't released out from under the user.
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
forName: .punktfunkReleaseCapture, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self,
|
||||
self.view.window?.windowScene?.activationState == .foregroundActive else { return }
|
||||
self.setCaptured(false)
|
||||
})
|
||||
|
||||
if captureEnabled {
|
||||
setCaptured(true) // entering a session is the deliberate "capture me" moment
|
||||
@@ -556,11 +591,15 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private func setCaptured(_ on: Bool) {
|
||||
/// `fromClick` marks a click-driven engage (the released-state pointer click that re-captures):
|
||||
/// that click's press/release are suppressed toward the host — it's the local engage gesture,
|
||||
/// not a host click — exactly as macOS's `engageCapture(fromClick:)` does. Keyboard-driven
|
||||
/// engages (⌘⎋) pass false so a normal click still reaches the host.
|
||||
private func setCaptured(_ on: Bool, fromClick: Bool = false) {
|
||||
if on {
|
||||
// `connection != nil` is the session-active gate (presenter internals are opaque here).
|
||||
guard captureEnabled, !captured, connection != nil else { return }
|
||||
inputCapture?.setForwarding(true)
|
||||
inputCapture?.setForwarding(true, suppressClick: fromClick)
|
||||
captured = true
|
||||
} else {
|
||||
guard captured else { return }
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// player-LED-bits → GCControllerPlayerIndex map. All pure functions.
|
||||
|
||||
import GameController
|
||||
import PunktfunkCore
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
@@ -40,6 +41,43 @@ final class GamepadWireTests: XCTestCase {
|
||||
XCTAssertEqual(GamepadWire.axisRT, 5)
|
||||
}
|
||||
|
||||
func testPadIndexRidesFlagsOnEveryPerPadEvent() {
|
||||
// The wire pad index is the low byte of `flags` (punktfunk_core::input) on button + axis.
|
||||
let btn = PunktfunkInputEvent.gamepadButton(GamepadWire.a, down: true, pad: 3)
|
||||
XCTAssertEqual(btn.kind, UInt8(PUNKTFUNK_INPUT_KIND_GAMEPAD_BUTTON.rawValue))
|
||||
XCTAssertEqual(btn.code, GamepadWire.a)
|
||||
XCTAssertEqual(btn.x, 1)
|
||||
XCTAssertEqual(btn.flags, 3)
|
||||
let axis = PunktfunkInputEvent.gamepadAxis(GamepadWire.axisRT, value: 200, pad: 5)
|
||||
XCTAssertEqual(axis.kind, UInt8(PUNKTFUNK_INPUT_KIND_GAMEPAD_AXIS.rawValue))
|
||||
XCTAssertEqual(axis.code, GamepadWire.axisRT)
|
||||
XCTAssertEqual(axis.x, 200)
|
||||
XCTAssertEqual(axis.flags, 5)
|
||||
// Single-controller path stays byte-identical: pad 0 ⇒ flags 0, exactly as before.
|
||||
XCTAssertEqual(PunktfunkInputEvent.gamepadButton(GamepadWire.a, down: false, pad: 0).flags, 0)
|
||||
XCTAssertEqual(PunktfunkInputEvent.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: 0).flags, 0)
|
||||
}
|
||||
|
||||
func testArrivalAndRemoveWireLayout() {
|
||||
// GamepadArrival (kind 14): code = the GamepadType wire byte, flags = pad index.
|
||||
let arrival = PunktfunkInputEvent.gamepadArrival(
|
||||
pref: PunktfunkConnection.GamepadType.dualSense.rawValue, pad: 2)
|
||||
XCTAssertEqual(arrival.kind, UInt8(PUNKTFUNK_INPUT_KIND_GAMEPAD_ARRIVAL.rawValue))
|
||||
XCTAssertEqual(arrival.code, PunktfunkConnection.GamepadType.dualSense.rawValue) // 2
|
||||
XCTAssertEqual(arrival.flags, 2)
|
||||
// The GamepadType raw values ARE the GamepadPref wire bytes the host resolves.
|
||||
XCTAssertEqual(PunktfunkConnection.GamepadType.xbox360.rawValue, 1)
|
||||
XCTAssertEqual(PunktfunkConnection.GamepadType.dualSense.rawValue, 2)
|
||||
XCTAssertEqual(PunktfunkConnection.GamepadType.xboxOne.rawValue, 3)
|
||||
XCTAssertEqual(PunktfunkConnection.GamepadType.dualShock4.rawValue, 4)
|
||||
// GamepadRemove (kind 13): flags = pad index (the core stamps the per-pad seq).
|
||||
let remove = PunktfunkInputEvent.gamepadRemove(pad: 7)
|
||||
XCTAssertEqual(remove.kind, UInt8(PUNKTFUNK_INPUT_KIND_GAMEPAD_REMOVE.rawValue))
|
||||
XCTAssertEqual(remove.flags, 7)
|
||||
// 16 addressable pads (punktfunk_core::input::MAX_PADS).
|
||||
XCTAssertEqual(GamepadWire.maxPads, 16)
|
||||
}
|
||||
|
||||
func testTouchpadConversionCorners() {
|
||||
// GC ±1 with +y up → wire 0...65535 with origin top-left, +y down.
|
||||
let topLeft = GamepadWire.touchpad(x: -1, y: 1)
|
||||
|
||||
@@ -347,13 +347,14 @@ pub fn show(
|
||||
stream.add(stats_row.widget());
|
||||
|
||||
let input = adw::PreferencesGroup::builder().title("Input").build();
|
||||
// Which physical controller forwards as pad 0: automatic = the most recently connected
|
||||
// real pad (Steam's virtual pad skipped). A pin is persisted by stable key
|
||||
// (`Settings::forward_pad`), so it survives restarts — and disconnects: an offline
|
||||
// pinned pad keeps its entry here instead of silently snapping back to Automatic.
|
||||
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad
|
||||
// (Steam's virtual pad skipped); pinning one restricts the session to that single
|
||||
// controller (single-player). The pin is persisted by stable key (`Settings::forward_pad`),
|
||||
// so it survives restarts — and disconnects: an offline pinned pad keeps its entry here
|
||||
// instead of silently snapping back to Automatic.
|
||||
let pads = gamepads.pads();
|
||||
let saved_pin = settings.borrow().forward_pad.clone();
|
||||
let mut pad_names = vec!["Automatic (most recent)".to_string()];
|
||||
let mut pad_names = vec!["Automatic (all controllers)".to_string()];
|
||||
let mut pad_keys: Vec<String> = Vec::new();
|
||||
for p in &pads {
|
||||
let kind = p.kind_label();
|
||||
@@ -379,7 +380,7 @@ pub fn show(
|
||||
if pads.is_empty() {
|
||||
"No controllers detected"
|
||||
} else {
|
||||
"Exactly one controller is forwarded to the host"
|
||||
"All controllers are forwarded, each as its own player; pick one to force single-player"
|
||||
},
|
||||
&pad_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "punktfunk-client-windows"
|
||||
description = "Native Windows punktfunk/1 client — WinUI 3 (windows-reactor) shell, D3D11/SwapChainPanel present, FFmpeg decode, WASAPI audio, SDL3 gamepads"
|
||||
description = "Native Windows punktfunk/1 client — WinUI 3 (windows-reactor) shell, SDL3 gamepads; streaming runs in the spawned punktfunk-session binary"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
@@ -57,13 +57,10 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
# Video decode (same FFmpeg pin as the host/Linux client) — software HEVC on the GPU-less dev
|
||||
# box; D3D11VA hardware decode is a follow-up for the real-GPU box.
|
||||
# FFmpeg — used only to enumerate which codecs this client can decode (probe::decodable_codecs),
|
||||
# advertised to the host on the speed-test connect. Same pin as the host/Linux client. (Real
|
||||
# decode + present live in the spawned punktfunk-session binary.)
|
||||
ffmpeg-next = "8"
|
||||
opus = "0.3"
|
||||
|
||||
# Audio render + mic capture (the WASAPI analogue of the Linux client's PipeWire backend).
|
||||
wasapi = "0.23"
|
||||
|
||||
# Gamepads: capture + feedback (full DualSense fidelity needs hidapi). SDL3 is cross-platform;
|
||||
# built from source via the bundled CMake on Windows (no system SDL3).
|
||||
@@ -71,12 +68,8 @@ sdl3 = { version = "0.18", features = ["build-from-source", "hidapi"] }
|
||||
|
||||
mdns-sd = "0.20"
|
||||
async-channel = "2"
|
||||
# The decoded-frame channel (session pump → render thread): crossbeam because the render loop
|
||||
# blocks with `recv_timeout`, which async-channel has no sync analogue of.
|
||||
crossbeam-channel = "0.5"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
|
||||
@@ -6,10 +6,7 @@
|
||||
use super::style::*;
|
||||
use super::{AppCtx, Screen, Svc, Target};
|
||||
use crate::discovery::DiscoveredHost;
|
||||
use crate::session::{self, SessionEvent, SessionParams, Stats};
|
||||
use crate::trust::{self, KnownHost, KnownHosts, Settings};
|
||||
use crate::video::DecoderPref;
|
||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||
use crate::trust::{self, KnownHost, KnownHosts};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -117,82 +114,6 @@ pub(crate) fn initiate_launch(
|
||||
);
|
||||
}
|
||||
|
||||
/// The mode to request: explicit settings, with `0` fields resolved to the native size/refresh
|
||||
/// of the display our window is on (mirrors the Linux/Swift clients' native-display default).
|
||||
pub(crate) fn resolve_mode(s: &Settings) -> Mode {
|
||||
let mut mode = Mode {
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
refresh_hz: s.refresh_hz,
|
||||
};
|
||||
if mode.width == 0 || mode.refresh_hz == 0 {
|
||||
if let Some((w, h, hz)) = current_display_mode() {
|
||||
if mode.width == 0 {
|
||||
(mode.width, mode.height) = (w, h);
|
||||
}
|
||||
if mode.refresh_hz == 0 {
|
||||
mode.refresh_hz = hz;
|
||||
}
|
||||
}
|
||||
}
|
||||
// No display info (headless session, RDP oddities) — a sane floor.
|
||||
if mode.width == 0 {
|
||||
(mode.width, mode.height) = (1920, 1080);
|
||||
}
|
||||
if mode.refresh_hz == 0 {
|
||||
mode.refresh_hz = 60;
|
||||
}
|
||||
mode
|
||||
}
|
||||
|
||||
/// The current mode (physical pixels + refresh) of the display our window occupies:
|
||||
/// `MonitorFromWindow` on the foreground window — ours, the user just clicked in it — then
|
||||
/// `EnumDisplaySettingsW(ENUM_CURRENT_SETTINGS)` on that monitor's device. Defaults to the
|
||||
/// primary display when we're not foreground (e.g. a scripted connect).
|
||||
fn current_display_mode() -> Option<(u32, u32, u32)> {
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
EnumDisplaySettingsW, GetMonitorInfoW, MonitorFromWindow, DEVMODEW, ENUM_CURRENT_SETTINGS,
|
||||
MONITORINFO, MONITORINFOEXW,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow;
|
||||
unsafe {
|
||||
let monitor = MonitorFromWindow(
|
||||
GetForegroundWindow(),
|
||||
windows::Win32::Graphics::Gdi::MONITOR_DEFAULTTOPRIMARY,
|
||||
);
|
||||
let mut info = MONITORINFOEXW::default();
|
||||
info.monitorInfo.cbSize = std::mem::size_of::<MONITORINFOEXW>() as u32;
|
||||
if !GetMonitorInfoW(
|
||||
monitor,
|
||||
&mut info as *mut MONITORINFOEXW as *mut MONITORINFO,
|
||||
)
|
||||
.as_bool()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut dm = DEVMODEW {
|
||||
dmSize: std::mem::size_of::<DEVMODEW>() as u16,
|
||||
..Default::default()
|
||||
};
|
||||
if !EnumDisplaySettingsW(
|
||||
PCWSTR(info.szDevice.as_ptr()),
|
||||
ENUM_CURRENT_SETTINGS,
|
||||
&mut dm,
|
||||
)
|
||||
.as_bool()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// dmDisplayFrequency of 0/1 means "hardware default" — unusable as a mode request.
|
||||
(dm.dmPelsWidth > 0 && dm.dmDisplayFrequency > 1).then_some((
|
||||
dm.dmPelsWidth,
|
||||
dm.dmPelsHeight,
|
||||
dm.dmDisplayFrequency,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Tunables that differ between the normal connect and the no-PIN "request access" flow.
|
||||
/// `Default` is the normal connect: short handshake budget, persist *unpaired* on TOFU, and the
|
||||
/// plain "Connecting" screen.
|
||||
@@ -220,9 +141,7 @@ pub(crate) struct ConnectOpts {
|
||||
/// so it can't loop.
|
||||
wake_on_fail: bool,
|
||||
/// A library title id (`steam:570`, …) the host launches during the connect handshake —
|
||||
/// the library page's tap-to-play. Spawn mode passes it as `--launch`; the legacy
|
||||
/// in-process path has no launch plumbing (it predates the library and is slated for
|
||||
/// deletion).
|
||||
/// the library page's tap-to-play, passed to the spawned session child as `--launch`.
|
||||
launch: Option<String>,
|
||||
}
|
||||
|
||||
@@ -265,128 +184,11 @@ fn connect_with(
|
||||
opts: ConnectOpts,
|
||||
) {
|
||||
// Session-always: every stream runs in the spawned punktfunk-session Vulkan binary.
|
||||
// The in-process D3D11VA path below stays reachable via the "Streaming engine"
|
||||
// setting / PUNKTFUNK_BUILTIN_STREAM=1 as the A/B baseline until its deletion.
|
||||
if !super::use_builtin_stream(ctx) {
|
||||
return connect_spawn(ctx, target, pin, set_screen, set_status, opts);
|
||||
}
|
||||
let s = ctx.settings.lock().unwrap().clone();
|
||||
let gamepad_pref = match GamepadPref::from_name(&s.gamepad) {
|
||||
Some(GamepadPref::Auto) | None => ctx.gamepad.auto_pref(),
|
||||
Some(explicit) => explicit,
|
||||
};
|
||||
let handle = session::start(SessionParams {
|
||||
host: target.addr.clone(),
|
||||
port: target.port,
|
||||
mode: resolve_mode(&s),
|
||||
compositor: CompositorPref::from_name(&s.compositor).unwrap_or(CompositorPref::Auto),
|
||||
gamepad: gamepad_pref,
|
||||
bitrate_kbps: s.bitrate_kbps,
|
||||
audio_channels: s.audio_channels,
|
||||
mic_enabled: s.mic_enabled,
|
||||
hdr_enabled: s.hdr_enabled,
|
||||
decoder: DecoderPref::from_name(&s.decoder),
|
||||
preferred_codec: s.preferred_codec(),
|
||||
pin,
|
||||
identity: ctx.identity.clone(),
|
||||
connect_timeout: opts.connect_timeout,
|
||||
});
|
||||
set_status.call(String::new());
|
||||
set_screen.call(if opts.awaiting_approval {
|
||||
Screen::RequestAccess
|
||||
} else {
|
||||
Screen::Connecting
|
||||
});
|
||||
|
||||
let tofu = pin.is_none();
|
||||
let persist_paired = opts.persist_paired;
|
||||
let cancel = opts.cancel;
|
||||
let wake_on_fail = opts.wake_on_fail;
|
||||
let ctx = ctx.clone();
|
||||
let (shared, gamepad) = (ctx.shared.clone(), ctx.gamepad.clone());
|
||||
let (ss, st) = (set_screen.clone(), set_status.clone());
|
||||
let target = target.clone();
|
||||
std::thread::spawn(move || loop {
|
||||
let event = match handle.events.recv_blocking() {
|
||||
Ok(e) => e,
|
||||
Err(_) => {
|
||||
gamepad.detach();
|
||||
ss.call(Screen::Hosts);
|
||||
break;
|
||||
}
|
||||
};
|
||||
// A cancelled request-access connect that resolved late (the host approved or the park
|
||||
// timed out after the user walked away): tear down silently. Cancel already returned the
|
||||
// UI to the host list; dropping `event` (and with it any connector) closes the connection
|
||||
// without popping a stream or a stray error over the screen a new session may own.
|
||||
if cancel.as_ref().is_some_and(|c| c.load(Ordering::SeqCst)) {
|
||||
break;
|
||||
}
|
||||
match event {
|
||||
SessionEvent::Connected {
|
||||
connector,
|
||||
fingerprint,
|
||||
..
|
||||
} => {
|
||||
if persist_paired || tofu {
|
||||
// Request-access: the operator approved this device, so record the host as a
|
||||
// trusted PAIRED host — future connects are then silent (rule 1), exactly like
|
||||
// after a PIN ceremony. A plain TOFU connect persists it *unpaired* (pinned).
|
||||
let mut k = KnownHosts::load();
|
||||
k.upsert(KnownHost {
|
||||
name: target.name.clone(),
|
||||
addr: target.addr.clone(),
|
||||
port: target.port,
|
||||
fp_hex: trust::hex(&fingerprint),
|
||||
paired: persist_paired,
|
||||
last_used: None,
|
||||
mac: target.mac.clone(),
|
||||
});
|
||||
let _ = k.save();
|
||||
}
|
||||
trust::touch_last_used(&trust::hex(&fingerprint));
|
||||
gamepad.attach(connector.clone());
|
||||
*shared.stats.lock().unwrap() = Stats::default(); // clear any prior session's numbers
|
||||
*shared.handoff.lock().unwrap() =
|
||||
Some((connector, handle.frames.clone(), handle.stop.clone()));
|
||||
ss.call(Screen::Stream);
|
||||
}
|
||||
SessionEvent::Failed {
|
||||
msg,
|
||||
trust_rejected,
|
||||
} => {
|
||||
st.call(msg);
|
||||
gamepad.detach();
|
||||
if trust_rejected {
|
||||
// Pinned-fingerprint mismatch / pairing required → re-pair via the PIN screen.
|
||||
// The host ANSWERED, so this never takes the wake fallback.
|
||||
*shared.target.lock().unwrap() = target.clone();
|
||||
ss.call(Screen::Pair);
|
||||
} else if wake_on_fail {
|
||||
// The dial-first attempt to a non-advertising host failed — it may genuinely
|
||||
// be asleep. NOW wake and wait (its resolved redial uses default opts, so a
|
||||
// second failure lands on the host list, not back here).
|
||||
wake_and_connect(&ctx, target.clone(), &ss, &st);
|
||||
} else {
|
||||
ss.call(Screen::Hosts);
|
||||
}
|
||||
break;
|
||||
}
|
||||
SessionEvent::Ended(err) => {
|
||||
// `None` = the user ended the session themselves (the disconnect shortcut) —
|
||||
// return to the host list silently; an error banner would read as a failure.
|
||||
st.call(err.unwrap_or_default());
|
||||
gamepad.detach();
|
||||
ss.call(Screen::Hosts);
|
||||
break;
|
||||
}
|
||||
SessionEvent::Stats(s) => *shared.stats.lock().unwrap() = s,
|
||||
}
|
||||
});
|
||||
connect_spawn(ctx, target, pin, set_screen, set_status, opts)
|
||||
}
|
||||
|
||||
/// Spawn-mode connect: run the stream in the punktfunk-session binary and translate its
|
||||
/// stdout contract into the same navigation the in-process event loop drove. The child
|
||||
/// stdout contract into the app's connect-flow navigation. The child
|
||||
/// NEVER connects unpinned — a stored/ceremony pin, else the host's advertised
|
||||
/// fingerprint (TOFU: persisted once the child reports ready, which proves the host
|
||||
/// really holds that identity, mirroring the GTK shell); no fingerprint at all routes to
|
||||
@@ -723,9 +525,7 @@ pub(crate) fn request_access_page(
|
||||
.on_click(move || {
|
||||
// Return the UI immediately; trip the flag this request's event loop
|
||||
// captured so it tears down silently when the connect resolves (see
|
||||
// ConnectOpts::cancel). Spawn mode: killing the parked child IS the abort
|
||||
// (builtin mode's in-process connect is blocking with none — it just
|
||||
// resolves/times out later).
|
||||
// ConnectOpts::cancel). Killing the parked session child IS the abort.
|
||||
if let Some(c) = ctx.shared.cancel.lock().unwrap().as_ref() {
|
||||
c.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! The Shortcuts screen: a short note on the in-stream capture model plus a reference of the
|
||||
//! keyboard shortcuts — reached from the Shortcuts button on the host list. The Windows
|
||||
//! counterpart of the GTK client's Keyboard Shortcuts window; the bindings themselves live in
|
||||
//! the session window (and [`crate::input`] for the legacy builtin path), so both clients
|
||||
//! document the same set.
|
||||
//! the session window, so both clients document the same set.
|
||||
|
||||
use super::style::*;
|
||||
use super::Screen;
|
||||
@@ -10,8 +9,7 @@ use windows_reactor::*;
|
||||
|
||||
/// The in-stream keyboard shortcuts, in the GTK Shortcuts window's order: the chord, then what it
|
||||
/// does. Read-only — the keyboard bindings live in the session window (`pf-presenter`'s run
|
||||
/// loop; the legacy builtin path's in [`crate::input`]), the controller chord in its gamepad
|
||||
/// service.
|
||||
/// loop), the controller chord in its gamepad service.
|
||||
const STREAM_SHORTCUTS: &[(&str, &str)] = &[
|
||||
("F11 / Alt+Enter", "Toggle fullscreen"),
|
||||
(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! The WinUI 3 (windows-reactor) application shell.
|
||||
//!
|
||||
//! Declarative React-like model: this root component routes on a `Screen` value held in
|
||||
//! `use_async_state` so background threads (discovery, the session pump) can drive navigation.
|
||||
//! Each screen lives in its own submodule:
|
||||
//! `use_async_state` so background threads (discovery, the spawned session's stdout reader) can
|
||||
//! drive navigation. Each screen lives in its own submodule:
|
||||
//!
|
||||
//! * [`hosts`] — saved/discovered/manual host list, plus per-host forget + speed test
|
||||
//! * [`connect`] — the trust gate and session lifecycle glue (connect / request-access flows)
|
||||
@@ -10,7 +10,7 @@
|
||||
//! * [`speed`] — the per-host network speed test (probe burst over the real data plane)
|
||||
//! * [`settings`] — persisted preferences · [`licenses`] — the license notices screen ·
|
||||
//! [`help`] — the in-stream keyboard-shortcuts reference (reached from the host list)
|
||||
//! * [`stream`] — the live stream: `SwapChainPanel` + D3D11 presenter + HUD overlay
|
||||
//! * [`stream`] — the stream status card (the stream itself runs in the spawned session window)
|
||||
//! * [`style`] — the shared look (cards, pills, monograms), following the windows-reactor
|
||||
//! gallery: Mica backdrop, a centred max-width column, theme brushes (`ThemeRef`)
|
||||
//!
|
||||
@@ -19,9 +19,6 @@
|
||||
//! marks it dirty and re-renders it; an `AsyncSetState` written from a background thread does
|
||||
//! NOT (the child is pruned when its props are unchanged) — so everything thread-driven
|
||||
//! (discovery, HUD stats, speed-test results) is held as *root* state and passed down as props.
|
||||
//! The present + decoded-frame handoff crosses to the UI thread through a `Mutex` side-channel
|
||||
//! and thread-locals (the windows-reactor SwapChainPanel sample's pattern), since the per-frame
|
||||
//! present must not go through state/rerender.
|
||||
|
||||
mod connect;
|
||||
mod help;
|
||||
@@ -36,7 +33,6 @@ mod style;
|
||||
|
||||
use crate::discovery::{self, DiscoveredHost};
|
||||
use crate::gamepad::GamepadService;
|
||||
use crate::session::Stats;
|
||||
use crate::trust::{KnownHosts, Settings};
|
||||
use hosts::HostsProps;
|
||||
use punktfunk_core::client::NativeClient;
|
||||
@@ -45,7 +41,6 @@ use std::collections::HashMap;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use stream::StreamProps;
|
||||
use windows_reactor::*;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
@@ -88,7 +83,7 @@ pub(crate) struct Target {
|
||||
}
|
||||
|
||||
/// Stable app services handed to the page components as props. Each routed screen that uses
|
||||
/// hooks (`hosts_page`/`pair_page`/`stream_page`/`speed_page`) is mounted as its own
|
||||
/// hooks (`hosts_page`/`pair_page`/`speed_page`/`library_page`) is mounted as its own
|
||||
/// `component(...)`, so its hooks live in an isolated slot list — calling them on the shared
|
||||
/// parent `cx` would change the hook order whenever the screen changes (reactor's
|
||||
/// Rules-of-Hooks guard aborts).
|
||||
@@ -115,18 +110,12 @@ impl PartialEq for Svc {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cross-thread handoff from the session pump (off-thread) to the stream page (UI thread):
|
||||
/// the connector (input sends), the decoded-frame channel (render thread), and the session's
|
||||
/// stop flag (the disconnect shortcut trips it).
|
||||
/// Cross-thread shell state driven off the UI thread: the current target, the live spawned
|
||||
/// session child (Disconnect/Cancel kill it) and its latest stats line, plus the connect-flow
|
||||
/// cancel flag and the discovery/library/speed-test generation guards.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Shared {
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(crate) handoff:
|
||||
Mutex<Option<(Arc<NativeClient>, crate::session::FrameRx, Arc<AtomicBool>)>>,
|
||||
pub(crate) target: Mutex<Target>,
|
||||
/// Latest stream stats, written by the session's event loop and mirrored into reactor state
|
||||
/// by the HUD poll thread to drive the overlay.
|
||||
pub(crate) stats: Mutex<Stats>,
|
||||
/// The live session child (spawn mode) — the status page's Disconnect and the
|
||||
/// request-access Cancel kill it. A FRESH handle is installed per spawn.
|
||||
pub(crate) session: Mutex<crate::spawn::SessionChild>,
|
||||
@@ -157,14 +146,6 @@ pub struct AppCtx {
|
||||
pub(crate) shared: Arc<Shared>,
|
||||
}
|
||||
|
||||
/// The legacy in-process streaming path (SwapChainPanel + D3D11VA) instead of the
|
||||
/// spawned punktfunk-session window: the `PUNKTFUNK_BUILTIN_STREAM=1` env override — a
|
||||
/// developer A/B knob only (the former Settings "Streaming engine" pick is gone), removed
|
||||
/// with the legacy path once the Vulkan session is fully validated.
|
||||
pub(crate) fn use_builtin_stream(_ctx: &AppCtx) -> bool {
|
||||
std::env::var_os("PUNKTFUNK_BUILTIN_STREAM").is_some_and(|v| v == "1")
|
||||
}
|
||||
|
||||
pub fn run(identity: (String, String), gamepad: GamepadService) -> windows_reactor::Result<()> {
|
||||
let ctx = Arc::new(AppCtx {
|
||||
identity,
|
||||
@@ -302,10 +283,9 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
}
|
||||
});
|
||||
|
||||
// HUD sample: the session event loop writes `shared.stats` and the input hooks track capture
|
||||
// state; this poll thread mirrors both into root state so the stream page gets them as a
|
||||
// *prop* (thread-driven state must be root state — see the module docs). The compare in
|
||||
// `AsyncSetState::call` makes the idle case free.
|
||||
// HUD sample: the spawned session child's latest `stats:` line, mirrored into root state so
|
||||
// the stream status page gets it as a *prop* (thread-driven state must be root state — see the
|
||||
// module docs). The compare in `AsyncSetState::call` makes the idle case free.
|
||||
cx.use_effect((), {
|
||||
let shared = ctx.shared.clone();
|
||||
let set_hud = set_hud.clone();
|
||||
@@ -315,10 +295,6 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
.spawn(move || loop {
|
||||
std::thread::sleep(std::time::Duration::from_millis(400));
|
||||
set_hud.call(stream::HudSample {
|
||||
stats: *shared.stats.lock().unwrap(),
|
||||
captured: crate::input::is_captured(),
|
||||
visible: crate::input::hud_visible(),
|
||||
present: crate::render::present_stats(),
|
||||
stats_line: shared.stats_line.lock().unwrap().clone(),
|
||||
});
|
||||
})
|
||||
@@ -525,16 +501,13 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
state: library,
|
||||
},
|
||||
),
|
||||
// Spawn mode (the default): the stream runs in the punktfunk-session child's own
|
||||
// window; this screen is a status page (no hooks — inline is sound). The legacy
|
||||
// in-process SwapChainPanel page stays behind the "Streaming engine" setting /
|
||||
// PUNKTFUNK_BUILTIN_STREAM=1.
|
||||
Screen::Stream if !use_builtin_stream(ctx) => stream::session_page(ctx, &hud),
|
||||
Screen::Stream => component(stream::stream_page, StreamProps { svc, hud }),
|
||||
// The stream runs in the punktfunk-session child's own window; this screen is a
|
||||
// status page (no hooks — inline is sound).
|
||||
Screen::Stream => stream::session_page(ctx, &hud),
|
||||
};
|
||||
|
||||
// The Stream screen owns the SwapChainPanel + per-frame present; never wrap it in an animated
|
||||
// opacity/offset layer. Everything else slides + fades in on navigation.
|
||||
// The Stream screen is a plain status card (the session child owns the real stream window);
|
||||
// it's shown without the navigation entrance tween. Everything else slides + fades in.
|
||||
if matches!(screen, Screen::Stream) {
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -267,12 +267,13 @@ pub(crate) fn settings_page(
|
||||
);
|
||||
|
||||
// --- Input -----------------------------------------------------------------------------
|
||||
// Which physical controller forwards as pad 0: automatic = the most recently connected.
|
||||
// Persisted by stable key (`Settings::forward_pad`, GTK parity) so the pin survives
|
||||
// restarts AND reaches the spawned session binary, whose service applies the same key.
|
||||
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad;
|
||||
// pinning one restricts the session to that single controller (single-player). Persisted
|
||||
// by stable key (`Settings::forward_pad`, GTK parity) so the pin survives restarts AND
|
||||
// reaches the spawned session binary, whose service applies the same key.
|
||||
let pads = ctx.gamepad.pads();
|
||||
let (fwd_names, fwd_i) = {
|
||||
let mut names = vec!["Automatic (most recent)".to_string()];
|
||||
let mut names = vec!["Automatic (all controllers)".to_string()];
|
||||
names.extend(pads.iter().map(|p| {
|
||||
let kind = p.kind_label();
|
||||
if kind.is_empty() {
|
||||
@@ -301,16 +302,16 @@ pub(crate) fn settings_page(
|
||||
} else {
|
||||
keys.get(sel - 1).cloned()
|
||||
};
|
||||
// Apply live (the in-process service, legacy builtin streams) and persist —
|
||||
// the spawned session reads `forward_pad` at connect.
|
||||
// Apply live to the gamepad service and persist — the spawned session
|
||||
// reads `forward_pad` at connect.
|
||||
svc.set_pinned(key.clone());
|
||||
let mut s = ctx2.settings.lock().unwrap();
|
||||
s.forward_pad = key.unwrap_or_default();
|
||||
s.save();
|
||||
})
|
||||
.tooltip(
|
||||
"Exactly one controller is forwarded to the host; \u{201C}Automatic\u{201D} \
|
||||
picks the most recently connected.",
|
||||
"Every connected controller is forwarded, each as its own player. Pick one \
|
||||
to force single-player \u{2014} only it reaches the host.",
|
||||
)
|
||||
};
|
||||
let (pad_names, pad_i) = presets(GAMEPADS, |v| {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use super::style::*;
|
||||
use super::{Screen, Svc};
|
||||
use crate::session::run_speed_probe;
|
||||
use crate::probe::run_speed_probe;
|
||||
use windows_reactor::*;
|
||||
|
||||
/// Speed-test lifecycle. Held as ROOT state (the probe worker completes it via
|
||||
|
||||
@@ -1,163 +1,20 @@
|
||||
//! The stream page: a `SwapChainPanel` whose composition swapchain is created (and bound) once on
|
||||
//! the UI thread, then handed — presenter and all — to the dedicated render thread
|
||||
//! ([`crate::render`]), which presents decoded frames at stream cadence. The page itself only
|
||||
//! forwards panel size/DPI changes and draws the status-chip HUD overlay (mode · decode path ·
|
||||
//! HDR · fps/goodput · end-to-end latency + stage equation · capture hint).
|
||||
//! The stream status page: streams run in the spawned `punktfunk-session` child's own window,
|
||||
//! so the shell shows a status card in the app's card language — host header, the child's live
|
||||
//! `stats:` line as a chip row + stage lines, the in-window shortcuts, and a Disconnect.
|
||||
|
||||
use super::style::{edges, uniform};
|
||||
use super::Svc;
|
||||
use crate::present::Presenter;
|
||||
use crate::render::{self, RenderThread};
|
||||
use crate::session::Stats;
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::config::Mode;
|
||||
use std::cell::RefCell;
|
||||
use std::sync::Arc;
|
||||
use windows_reactor::*;
|
||||
|
||||
/// One HUD refresh: the latest session stats, the input hooks' capture state, and the render
|
||||
/// thread's display-side window. Mirrored into root state by the poll thread (`pf-hud`) and
|
||||
/// passed down as a prop.
|
||||
/// One HUD refresh: the session child's latest formatted `stats:` line, mirrored into root state
|
||||
/// by the poll thread (`pf-hud`) and passed down as a prop.
|
||||
#[derive(Clone, Default, PartialEq)]
|
||||
pub(crate) struct HudSample {
|
||||
pub(crate) stats: Stats,
|
||||
pub(crate) captured: bool,
|
||||
/// Whether the stats overlay should be shown — the Settings default at stream start, then
|
||||
/// whatever Ctrl+Alt+Shift+S last set (see [`crate::input::hud_visible`]). Carried in the
|
||||
/// sample so a live toggle changes the sample and re-renders the page (the stream page is a
|
||||
/// child component — only a changed prop re-renders it).
|
||||
pub(crate) visible: bool,
|
||||
/// The render thread's glass-side window (presents/s, skips, end-to-end p50/p95, display
|
||||
/// stage p50) — see [`crate::render::present_stats`].
|
||||
pub(crate) present: crate::render::PresentStats,
|
||||
/// Spawn mode: the session child's latest formatted `stats:` line, for the status
|
||||
/// page. Empty in builtin mode / before the first window.
|
||||
/// The session child's latest formatted `stats:` line, for the status page. Empty before the
|
||||
/// child's first stats window.
|
||||
pub(crate) stats_line: String,
|
||||
}
|
||||
|
||||
/// Props for the stream page: the services plus the live HUD sample that drives the overlay
|
||||
/// (compared by value, so each new sample re-renders the overlay).
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct StreamProps {
|
||||
pub(crate) svc: Svc,
|
||||
pub(crate) hud: HudSample,
|
||||
}
|
||||
|
||||
impl PartialEq for StreamProps {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.svc == other.svc && self.hud == other.hud
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Frames + host clock offset, stashed by the mount effect for `on_mounted` (which fires
|
||||
/// later, once the native panel exists).
|
||||
static PENDING: RefCell<Option<(crate::session::FrameRx, std::sync::Arc<std::sync::atomic::AtomicI64>)>> = const { RefCell::new(None) };
|
||||
/// The live render thread; stopped + joined by the unmount cleanup (before panel teardown).
|
||||
static RENDER: RefCell<Option<RenderThread>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// The app window's DPI (96 when the window can't be found — then DIPs == pixels). Reactor's
|
||||
/// `on_resize` reports DIPs and exposes no CompositionScale, so the window DPI is the scale.
|
||||
fn window_dpi() -> u32 {
|
||||
use windows::Win32::UI::HiDpi::GetDpiForWindow;
|
||||
use windows::Win32::UI::WindowsAndMessaging::FindWindowW;
|
||||
unsafe {
|
||||
FindWindowW(None, windows::core::w!("Punktfunk"))
|
||||
.ok()
|
||||
.map(|h| GetDpiForWindow(h))
|
||||
.filter(|d| *d > 0)
|
||||
.unwrap_or(96)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
|
||||
let ctx = &props.svc.ctx;
|
||||
// Take the connector + frames handoff once on mount; keep the connector alive (and for input)
|
||||
// in a use_ref, stash frames for `on_mounted`, install the input hooks. The cleanup stops the
|
||||
// render thread FIRST (it must not present into a panel that's tearing down), then removes
|
||||
// the input hooks.
|
||||
let connector_ref = cx.use_ref::<Option<Arc<NativeClient>>>(None);
|
||||
cx.use_effect_with_cleanup((), {
|
||||
let shared = ctx.shared.clone();
|
||||
let (inhibit, show_stats) = {
|
||||
let s = ctx.settings.lock().unwrap();
|
||||
(s.inhibit_shortcuts, s.show_stats)
|
||||
};
|
||||
let connector_ref = connector_ref.clone();
|
||||
move || {
|
||||
if let Some((connector, frames, stop)) = shared.handoff.lock().unwrap().take() {
|
||||
let mode = connector.mode();
|
||||
let clock_offset = connector.clock_offset_shared();
|
||||
connector_ref.set(Some(connector.clone()));
|
||||
PENDING.with(|c| *c.borrow_mut() = Some((frames, clock_offset)));
|
||||
crate::input::install(connector, mode, inhibit, show_stats, stop);
|
||||
}
|
||||
Some(|| {
|
||||
RENDER.with(|c| {
|
||||
if let Some(mut rt) = c.borrow_mut().take() {
|
||||
rt.stop_and_join();
|
||||
}
|
||||
});
|
||||
PENDING.with(|c| c.borrow_mut().take());
|
||||
crate::input::uninstall();
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
let mode = connector_ref.borrow().as_ref().map(|c| c.mode());
|
||||
let host = ctx.shared.target.lock().unwrap().name.clone();
|
||||
let mut layers: Vec<Element> = vec![swap_chain_panel()
|
||||
.on_mounted(|panel| {
|
||||
// Placeholder size — the first `on_resize` (fired after the first layout pass)
|
||||
// resizes to the panel's real pixel size.
|
||||
let dpi = window_dpi();
|
||||
match Presenter::new(1280, 720, dpi) {
|
||||
Ok(p) => {
|
||||
if let Err(e) = panel.set_swap_chain(p.swap_chain()) {
|
||||
tracing::error!(error = %e, "set_swap_chain");
|
||||
return;
|
||||
}
|
||||
if let Some((frames, clock_offset)) = PENDING.with(|c| c.borrow_mut().take()) {
|
||||
let shared = render::RenderShared::new(1280, 720, dpi);
|
||||
RENDER.with(|cell| {
|
||||
*cell.borrow_mut() =
|
||||
Some(render::spawn(p, frames, shared, clock_offset));
|
||||
});
|
||||
tracing::info!(dpi, "stream presenter bound — render thread started");
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!(error = %e, "create presenter"),
|
||||
}
|
||||
})
|
||||
.on_resize(|w, h| {
|
||||
// DIPs → physical pixels; the presenter maps back via SetMatrixTransform.
|
||||
let dpi = window_dpi();
|
||||
let px = |v: f64| (v * f64::from(dpi) / 96.0).round() as u32;
|
||||
RENDER.with(|cell| {
|
||||
if let Some(rt) = cell.borrow().as_ref() {
|
||||
rt.shared().set_dpi(dpi);
|
||||
rt.shared().set_size(px(w), px(h));
|
||||
}
|
||||
});
|
||||
})
|
||||
.into()];
|
||||
// The overlay follows the LIVE visibility (Settings default, then Ctrl+Alt+Shift+S): the page
|
||||
// re-renders on every HUD sample (~400 ms), so a toggle takes effect promptly mid-stream.
|
||||
if props.hud.visible {
|
||||
layers.push(hud_overlay(&props.hud, mode, &host));
|
||||
}
|
||||
// Flash the shortcut key set for the first few seconds of every session, regardless of the
|
||||
// HUD setting — so "how do I get back out" is answered the moment the stream comes up (parity
|
||||
// with the GTK client's stream-start hint). Uptime drives it, so it needs no timer/state: the
|
||||
// HUD poll re-renders the page each second and the banner drops once the session passes the
|
||||
// threshold.
|
||||
if props.hud.stats.uptime_secs < START_HINT_SECS {
|
||||
layers.push(start_hint());
|
||||
}
|
||||
grid(layers).into()
|
||||
}
|
||||
|
||||
/// Spawn mode's Stream screen: the stream runs in the punktfunk-session child's own
|
||||
/// window, so the shell shows a status card in the app's card language — monogram +
|
||||
/// host header, the child's live `stats:` line as a chip row + stage lines, the
|
||||
@@ -277,164 +134,3 @@ pub(crate) fn session_page(ctx: &Arc<super::AppCtx>, hud: &HudSample) -> Element
|
||||
.vertical_alignment(VerticalAlignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// How long the stream-start shortcut banner stays up (seconds of session uptime).
|
||||
const START_HINT_SECS: u32 = 6;
|
||||
|
||||
/// The stream-start shortcut banner: the full client key set on a translucent pill, bottom-centre,
|
||||
/// shown for [`START_HINT_SECS`] at the start of every session (see the call site). Independent of
|
||||
/// the stats overlay, so it appears even with the HUD turned off.
|
||||
fn start_hint() -> Element {
|
||||
border(
|
||||
text_block(
|
||||
"Click the stream to capture \u{00B7} Ctrl+Alt+Shift+Q releases \u{00B7} \
|
||||
Ctrl+Alt+Shift+D disconnects \u{00B7} Ctrl+Alt+Shift+S stats \u{00B7} F11 fullscreen",
|
||||
)
|
||||
.font_size(12.0)
|
||||
.semibold()
|
||||
.foreground(Color::rgb(235, 235, 235)),
|
||||
)
|
||||
.background(Color::rgb(0, 0, 0))
|
||||
.corner_radius(10.0)
|
||||
.padding(edges(14.0, 8.0, 14.0, 8.0))
|
||||
.opacity(0.82)
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
.vertical_alignment(VerticalAlignment::Bottom)
|
||||
.margin(edges(0.0, 0.0, 0.0, 28.0))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// A small chip for the dark HUD: coloured text on a translucent dark fill.
|
||||
fn hud_chip(text: &str, color: Color) -> Border {
|
||||
border(
|
||||
text_block(text)
|
||||
.font_size(11.0)
|
||||
.semibold()
|
||||
.foreground(color),
|
||||
)
|
||||
.background(Color::rgb(38, 38, 38))
|
||||
.corner_radius(8.0)
|
||||
.padding(edges(8.0, 2.0, 8.0, 2.0))
|
||||
}
|
||||
|
||||
/// The negotiated wire codec's display name (`quic::CODEC_*` bit → label).
|
||||
fn codec_name(bits: u8) -> &'static str {
|
||||
match bits {
|
||||
punktfunk_core::quic::CODEC_H264 => "H.264",
|
||||
punktfunk_core::quic::CODEC_AV1 => "AV1",
|
||||
_ => "HEVC",
|
||||
}
|
||||
}
|
||||
|
||||
/// `mm:ss` (or `h:mm:ss`) session time.
|
||||
fn fmt_uptime(secs: u32) -> String {
|
||||
let (h, m, s) = (secs / 3600, secs / 60 % 60, secs % 60);
|
||||
if h > 0 {
|
||||
format!("{h}:{m:02}:{s:02}")
|
||||
} else {
|
||||
format!("{m}:{s:02}")
|
||||
}
|
||||
}
|
||||
|
||||
/// The streaming HUD overlay (top-right), unified stats vocabulary (design/stats-unification.md):
|
||||
/// a chip row (mode · codec · decode path · HDR), a stream line (received fps · goodput ·
|
||||
/// presenter fps), the end-to-end headline (capture→on-glass p50/p95, host-clock corrected), the
|
||||
/// stage equation (= host + network + decode + display when the host reports 0xCF timings, else
|
||||
/// the combined = host+network + decode + display; stage p50s), a session line
|
||||
/// (host · time · loss/skips), and the shortcut hints. Layered over the `SwapChainPanel` in the
|
||||
/// same grid cell.
|
||||
fn hud_overlay(hud: &HudSample, mode: Option<Mode>, host: &str) -> Element {
|
||||
let stats = &hud.stats;
|
||||
let present = &hud.present;
|
||||
let res = mode
|
||||
.map(|m| format!("{}\u{00D7}{}@{}", m.width, m.height, m.refresh_hz))
|
||||
.unwrap_or_else(|| "\u{2014}".into());
|
||||
let mut chips: Vec<Element> = vec![
|
||||
hud_chip(&res, Color::rgb(235, 235, 235)).into(),
|
||||
hud_chip(codec_name(stats.codec), Color::rgb(180, 190, 255)).into(),
|
||||
];
|
||||
chips.push(if stats.hardware {
|
||||
hud_chip("GPU decode", Color::rgb(120, 220, 150)).into()
|
||||
} else {
|
||||
hud_chip("CPU decode", Color::rgb(240, 190, 90)).into()
|
||||
});
|
||||
if stats.hdr {
|
||||
chips.push(hud_chip("HDR", Color::rgb(255, 205, 90)).into());
|
||||
}
|
||||
// Received fps + goodput, plus the presenter's own rate (Moonlight's "Rendering frame rate"
|
||||
// analog — how often the display actually gets a new frame).
|
||||
let stream_line = format!(
|
||||
"{:.0} fps \u{00B7} {:.1} Mb/s \u{00B7} display {} fps",
|
||||
stats.fps, stats.mbps, present.fps
|
||||
);
|
||||
// The headline: end-to-end capture→displayed, measured directly post-Present (never the sum
|
||||
// of the stage percentiles). `(same-host clock)` flags an uncorrected clock (offset == 0:
|
||||
// same host, or the host skipped the skew handshake).
|
||||
let mut e2e_line = format!(
|
||||
"end-to-end {:.1} ms p50 \u{00B7} {:.1} p95 \u{00B7} capture\u{2192}on-glass",
|
||||
present.e2e_p50_ms, present.e2e_p95_ms
|
||||
);
|
||||
if stats.same_host {
|
||||
e2e_line.push_str(" (same-host clock)");
|
||||
}
|
||||
// The equation: the stages tile the headline interval per frame; the window p50s only
|
||||
// approximately sum (percentiles aren't additive). With per-AU 0xCF host timings the opaque
|
||||
// `host+network` term splits into `host` (host capture→sent) + `network` (the remainder);
|
||||
// an old host emits none and the combined term stays.
|
||||
let stage_line = if stats.split {
|
||||
format!(
|
||||
"= host {:.1} + network {:.1} + decode {:.1} + display {:.1}",
|
||||
stats.host_ms, stats.net_ms, stats.decode_ms, present.display_p50_ms
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"= host+network {:.1} + decode {:.1} + display {:.1}",
|
||||
stats.hostnet_ms, stats.decode_ms, present.display_p50_ms
|
||||
)
|
||||
};
|
||||
let mut session_bits: Vec<String> = Vec::new();
|
||||
if !host.is_empty() {
|
||||
session_bits.push(host.to_string());
|
||||
}
|
||||
// `lost` = unrecoverable network drops (session-cumulative); `skipped` = the render thread's
|
||||
// newest-wins drops last window (expected when the stream outpaces the display).
|
||||
session_bits.push(fmt_uptime(stats.uptime_secs));
|
||||
session_bits.push(format!("{} lost", stats.dropped));
|
||||
if present.skipped > 0 {
|
||||
session_bits.push(format!("{} skipped", present.skipped));
|
||||
}
|
||||
let session_line = session_bits.join(" \u{00B7} ");
|
||||
let hint = if hud.captured {
|
||||
"Ctrl+Alt+Shift+Q releases the mouse \u{00B7} Ctrl+Alt+Shift+D disconnects \u{00B7} \
|
||||
Ctrl+Alt+Shift+S stats \u{00B7} F11 fullscreen"
|
||||
} else {
|
||||
"Click the stream to capture \u{00B7} Ctrl+Alt+Shift+D disconnects \u{00B7} \
|
||||
Ctrl+Alt+Shift+S stats \u{00B7} F11 fullscreen"
|
||||
};
|
||||
let dim = |t: &str| {
|
||||
text_block(t)
|
||||
.font_size(11.0)
|
||||
.foreground(Color::rgb(210, 210, 210))
|
||||
};
|
||||
border(
|
||||
vstack((
|
||||
hstack(chips).spacing(6.0),
|
||||
dim(&stream_line),
|
||||
dim(&e2e_line),
|
||||
dim(&stage_line),
|
||||
dim(&session_line),
|
||||
text_block(hint)
|
||||
.font_size(11.0)
|
||||
.foreground(Color::rgb(150, 150, 150)),
|
||||
))
|
||||
.spacing(6.0),
|
||||
)
|
||||
.background(Color::rgb(0, 0, 0))
|
||||
.corner_radius(10.0)
|
||||
.padding(uniform(10.0))
|
||||
.opacity(0.82)
|
||||
.horizontal_alignment(HorizontalAlignment::Right)
|
||||
.vertical_alignment(VerticalAlignment::Top)
|
||||
.margin(uniform(12.0))
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
//! Audio: playback (decoded PCM → a WASAPI shared-mode render stream) and the microphone
|
||||
//! uplink (WASAPI capture → Opus → 0xCB datagrams, the inverse of the host's virtual mic).
|
||||
//!
|
||||
//! The WASAPI analogue of the Linux client's PipeWire backend. Playback mirrors the host's
|
||||
//! virtual-mic producer's adaptive jitter buffer: the session pump pushes 5 ms Opus-decoded
|
||||
//! chunks on the network clock; the WASAPI render thread pulls whole event-driven quanta on
|
||||
//! the device clock. Prime to ~3 quanta before producing, cap the ring so latency stays
|
||||
//! bounded, re-prime after a real drain.
|
||||
//!
|
||||
//! WASAPI objects are COM-apartment-bound and not `Send`, so they live on a dedicated thread
|
||||
//! (the same discipline as the host's `wasapi_cap`); only the channel + stop flag + join
|
||||
//! handle cross the boundary.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{Receiver, SyncSender, TrySendError};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use wasapi::{DeviceEnumerator, Direction, SampleType, StreamMode, WaveFormat};
|
||||
|
||||
const SAMPLE_RATE: usize = 48_000;
|
||||
/// The microphone uplink stays stereo (the host's virtual mic is stereo). The render path is
|
||||
/// multichannel — its channel count + block align are runtime, driven by the host-resolved layout.
|
||||
const CHANNELS: usize = 2;
|
||||
/// Mic frames are 20 ms (960 samples/channel) — any size ≤ 120 ms is fine host-side.
|
||||
const MIC_FRAME: usize = 960;
|
||||
|
||||
pub struct AudioPlayer {
|
||||
pcm_tx: SyncSender<Vec<f32>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl AudioPlayer {
|
||||
/// Spawn the WASAPI render thread for `channels` (2/6/8, canonical wire order
|
||||
/// FL FR FC LFE RL RR SL SR). Failure (no render endpoint on this box) is survivable — the
|
||||
/// caller streams video-only.
|
||||
pub fn spawn(channels: u8) -> Result<AudioPlayer> {
|
||||
// 64 × 5 ms = 320 ms of slack between the pump and the WASAPI loop.
|
||||
let (pcm_tx, pcm_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(64);
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::<Result<()>>(1);
|
||||
let stop_t = stop.clone();
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("punktfunk-audio".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = render_thread(pcm_rx, stop_t, ready_tx, channels) {
|
||||
tracing::warn!(error = format!("{e:#}"), "audio playback thread ended");
|
||||
}
|
||||
})
|
||||
.context("spawn audio thread")?;
|
||||
match ready_rx.recv_timeout(Duration::from_secs(3)) {
|
||||
Ok(Ok(())) => {
|
||||
tracing::info!(channels, "WASAPI render: 48 kHz f32 (default endpoint)");
|
||||
Ok(AudioPlayer {
|
||||
pcm_tx,
|
||||
stop,
|
||||
thread: Some(thread),
|
||||
})
|
||||
}
|
||||
Ok(Err(e)) => Err(e),
|
||||
Err(_) => Err(anyhow!(
|
||||
"wasapi render init timed out (no render endpoint?)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue one interleaved f32 chunk (in the session's channel layout). Drops the chunk if the
|
||||
/// WASAPI side is wedged (the renderer conceals the gap; never block the session pump).
|
||||
pub fn push(&self, pcm: Vec<f32>) {
|
||||
if let Err(TrySendError::Disconnected(_)) = self.pcm_tx.try_send(pcm) {
|
||||
// Thread already dead — Drop will reap it; nothing to do per-chunk.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AudioPlayer {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::SeqCst);
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_thread(
|
||||
pcm_rx: Receiver<Vec<f32>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
ready: SyncSender<Result<()>>,
|
||||
channels: u8,
|
||||
) -> Result<()> {
|
||||
if let Err(e) = wasapi::initialize_mta()
|
||||
.ok()
|
||||
.context("CoInitializeEx (MTA)")
|
||||
{
|
||||
let _ = ready.send(Err(e));
|
||||
return Ok(());
|
||||
}
|
||||
let res = (|| -> Result<()> {
|
||||
// F32LE interleaved: channels × 4 bytes/sample. Stereo (channels == 2) is byte-identical
|
||||
// to the old fixed path (mask 0x3, block align 8).
|
||||
let block_align = channels as usize * 4;
|
||||
let device = DeviceEnumerator::new()
|
||||
.context("DeviceEnumerator")?
|
||||
.get_default_device(&Direction::Render)
|
||||
.context("default render endpoint")?;
|
||||
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
|
||||
// The explicit dwChannelMask is the wire order (FL FR FC LFE RL RR SL SR); 5.1 = 0x3F,
|
||||
// 7.1 = 0x63F. WASAPI delivers channels in ascending mask-bit order, which equals the wire
|
||||
// order, so the render mapping is the identity — no permute. `autoconvert` (below) lets the
|
||||
// audio engine downmix when the endpoint has fewer speakers.
|
||||
let desired = WaveFormat::new(
|
||||
32,
|
||||
32,
|
||||
&SampleType::Float,
|
||||
SAMPLE_RATE,
|
||||
channels as usize,
|
||||
Some(punktfunk_core::audio::wasapi_channel_mask(channels)),
|
||||
);
|
||||
let (default_period, _min_period) =
|
||||
audio_client.get_device_period().context("device period")?;
|
||||
let mode = StreamMode::EventsShared {
|
||||
autoconvert: true,
|
||||
buffer_duration_hns: default_period,
|
||||
};
|
||||
audio_client
|
||||
.initialize_client(&desired, &Direction::Render, &mode)
|
||||
.context("initialize render client")?;
|
||||
let h_event = audio_client.set_get_eventhandle().context("event handle")?;
|
||||
let render_client = audio_client
|
||||
.get_audiorenderclient()
|
||||
.context("IAudioRenderClient")?;
|
||||
audio_client.start_stream().context("start render stream")?;
|
||||
let _ = ready.send(Ok(()));
|
||||
|
||||
// Adaptive jitter buffer, in f32-byte units (same shape as the host's virtual mic).
|
||||
let mut ring: VecDeque<u8> = VecDeque::new();
|
||||
let mut primed = false;
|
||||
let mut out = Vec::new(); // per-quantum scratch, reused across iterations
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
if h_event.wait_for_event(100).is_err() {
|
||||
continue;
|
||||
}
|
||||
// Drain everything the pump has queued into the ring.
|
||||
while let Ok(chunk) = pcm_rx.try_recv() {
|
||||
for s in chunk {
|
||||
ring.extend(s.to_le_bytes());
|
||||
}
|
||||
}
|
||||
let avail_frames = audio_client
|
||||
.get_available_space_in_frames()
|
||||
.context("available space")? as usize;
|
||||
if avail_frames == 0 {
|
||||
continue;
|
||||
}
|
||||
let want_bytes = avail_frames * block_align;
|
||||
|
||||
// Prime to ~3 quanta; cap at ~1 quantum of slack beyond that; re-prime on drain.
|
||||
let target = (3 * want_bytes).clamp(720 * block_align, 9600 * block_align);
|
||||
let cap = target.max(want_bytes) + want_bytes;
|
||||
if ring.len() > cap {
|
||||
ring.drain(..ring.len() - cap);
|
||||
}
|
||||
if !primed && ring.len() >= target {
|
||||
primed = true;
|
||||
}
|
||||
|
||||
out.clear();
|
||||
out.resize(want_bytes, 0);
|
||||
if primed {
|
||||
let n = ring.len().min(want_bytes);
|
||||
for (dst, b) in out.iter_mut().zip(ring.drain(..n)) {
|
||||
*dst = b;
|
||||
}
|
||||
}
|
||||
if ring.is_empty() {
|
||||
primed = false;
|
||||
}
|
||||
render_client
|
||||
.write_to_device(avail_frames, &out, None)
|
||||
.context("write_to_device")?;
|
||||
}
|
||||
audio_client.stop_stream().ok();
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(ref e) = res {
|
||||
let _ = ready.send(Err(anyhow!("{e:#}")));
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
/// The microphone uplink: capture the default input device, Opus-encode 20 ms chunks, ship
|
||||
/// them as 0xCB datagrams into the host's virtual mic source.
|
||||
pub struct MicStreamer {
|
||||
stop: Arc<AtomicBool>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl MicStreamer {
|
||||
pub fn spawn(connector: Arc<NativeClient>) -> Result<MicStreamer> {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_t = stop.clone();
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("punktfunk-mic".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = mic_thread(&connector, stop_t) {
|
||||
tracing::warn!(error = format!("{e:#}"), "mic uplink thread ended");
|
||||
}
|
||||
})
|
||||
.context("spawn mic thread")?;
|
||||
Ok(MicStreamer {
|
||||
stop,
|
||||
thread: Some(thread),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MicStreamer {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::SeqCst);
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mic_thread(connector: &Arc<NativeClient>, stop: Arc<AtomicBool>) -> Result<()> {
|
||||
wasapi::initialize_mta()
|
||||
.ok()
|
||||
.context("CoInitializeEx (MTA)")?;
|
||||
|
||||
let mut encoder = opus::Encoder::new(
|
||||
SAMPLE_RATE as u32,
|
||||
opus::Channels::Stereo,
|
||||
opus::Application::Voip,
|
||||
)
|
||||
.map_err(|e| anyhow!("opus encoder: {e}"))?;
|
||||
let _ = encoder.set_bitrate(opus::Bitrate::Bits(64_000));
|
||||
|
||||
let device = DeviceEnumerator::new()
|
||||
.context("DeviceEnumerator")?
|
||||
.get_default_device(&Direction::Capture)
|
||||
.context("default capture endpoint (no microphone?)")?;
|
||||
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
|
||||
let desired = WaveFormat::new(32, 32, &SampleType::Float, SAMPLE_RATE, CHANNELS, None);
|
||||
let (default_period, _min_period) =
|
||||
audio_client.get_device_period().context("device period")?;
|
||||
let mode = StreamMode::EventsShared {
|
||||
autoconvert: true,
|
||||
buffer_duration_hns: default_period,
|
||||
};
|
||||
audio_client
|
||||
.initialize_client(&desired, &Direction::Capture, &mode)
|
||||
.context("initialize capture client")?;
|
||||
let h_event = audio_client.set_get_eventhandle().context("event handle")?;
|
||||
let capture_client = audio_client
|
||||
.get_audiocaptureclient()
|
||||
.context("IAudioCaptureClient")?;
|
||||
audio_client
|
||||
.start_stream()
|
||||
.context("start capture stream")?;
|
||||
|
||||
let mut bytes: VecDeque<u8> = VecDeque::new();
|
||||
let mut ring: VecDeque<f32> = VecDeque::new();
|
||||
let mut out = vec![0u8; 4000];
|
||||
let mut seq = 0u32;
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
if h_event.wait_for_event(100).is_err() {
|
||||
continue;
|
||||
}
|
||||
loop {
|
||||
match capture_client.get_next_packet_size() {
|
||||
Ok(Some(0)) | Ok(None) => break,
|
||||
Ok(Some(_n)) => {
|
||||
capture_client
|
||||
.read_from_device_to_deque(&mut bytes)
|
||||
.context("read capture")?;
|
||||
}
|
||||
Err(e) => return Err(anyhow!("get_next_packet_size: {e}")),
|
||||
}
|
||||
}
|
||||
let whole = (bytes.len() / 4) * 4;
|
||||
for c in bytes.drain(..whole).collect::<Vec<u8>>().chunks_exact(4) {
|
||||
ring.push_back(f32::from_le_bytes([c[0], c[1], c[2], c[3]]));
|
||||
}
|
||||
// Ship every complete 20 ms stereo frame.
|
||||
while ring.len() >= MIC_FRAME * CHANNELS {
|
||||
let pcm: Vec<f32> = ring.drain(..MIC_FRAME * CHANNELS).collect();
|
||||
match encoder.encode_float(&pcm, &mut out) {
|
||||
Ok(len) => {
|
||||
let pts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
let _ = connector.send_mic(seq, pts, out[..len].to_vec());
|
||||
seq = seq.wrapping_add(1);
|
||||
}
|
||||
Err(e) => tracing::debug!(error = %e, "opus mic encode"),
|
||||
}
|
||||
}
|
||||
}
|
||||
audio_client.stop_stream().ok();
|
||||
Ok(())
|
||||
}
|
||||
@@ -74,30 +74,26 @@ fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
|
||||
}
|
||||
|
||||
enum Ctl {
|
||||
Attach(Arc<NativeClient>),
|
||||
Detach,
|
||||
Pin(Option<String>),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GamepadService {
|
||||
pads: Arc<Mutex<Vec<PadInfo>>>,
|
||||
active: Arc<Mutex<Option<PadInfo>>>,
|
||||
// `Arc<Mutex<…>>` (not a bare `Sender`, which is `!Sync`) so the service is `Sync` — the
|
||||
// WinUI app shares it across the UI thread and the session-pump thread (attach/detach).
|
||||
// WinUI app shares it across the UI thread and the settings-pin path.
|
||||
ctl: Arc<Mutex<Sender<Ctl>>>,
|
||||
}
|
||||
|
||||
impl GamepadService {
|
||||
pub fn start() -> GamepadService {
|
||||
let pads = Arc::new(Mutex::new(Vec::new()));
|
||||
let active = Arc::new(Mutex::new(None));
|
||||
let (ctl, ctl_rx) = std::sync::mpsc::channel();
|
||||
let (p, a) = (pads.clone(), active.clone());
|
||||
let p = pads.clone();
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
.name("punktfunk-gamepad".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = run(&p, &a, &ctl_rx) {
|
||||
if let Err(e) = run(&p, &ctl_rx) {
|
||||
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
|
||||
}
|
||||
})
|
||||
@@ -106,7 +102,6 @@ impl GamepadService {
|
||||
}
|
||||
GamepadService {
|
||||
pads,
|
||||
active,
|
||||
ctl: Arc::new(Mutex::new(ctl)),
|
||||
}
|
||||
}
|
||||
@@ -116,33 +111,13 @@ impl GamepadService {
|
||||
self.pads.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn active(&self) -> Option<PadInfo> {
|
||||
self.active.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Pin the forwarded controller by stable key (`PadInfo::key`) — `None` = automatic.
|
||||
/// The pin survives the pad disconnecting: it re-applies the moment a matching
|
||||
/// controller shows up again (same semantics as `pf-client-core`'s service).
|
||||
/// controller shows up again (same semantics as `pf-client-core`'s service). The spawned
|
||||
/// `punktfunk-session` binary owns the actual forwarding; this persists the selection.
|
||||
pub fn set_pinned(&self, key: Option<String>) {
|
||||
let _ = self.ctl.lock().unwrap().send(Ctl::Pin(key));
|
||||
}
|
||||
|
||||
pub fn attach(&self, connector: Arc<NativeClient>) {
|
||||
let _ = self.ctl.lock().unwrap().send(Ctl::Attach(connector));
|
||||
}
|
||||
|
||||
pub fn detach(&self) {
|
||||
let _ = self.ctl.lock().unwrap().send(Ctl::Detach);
|
||||
}
|
||||
|
||||
/// What "Automatic" resolves to right now — the virtual pad matching the physical one
|
||||
/// (Swift parity); no pad connected leaves the host's own default.
|
||||
pub fn auto_pref(&self) -> GamepadPref {
|
||||
match self.active() {
|
||||
Some(p) => p.pref,
|
||||
None => GamepadPref::Auto,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32) {
|
||||
@@ -404,11 +379,7 @@ impl Worker {
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn run(
|
||||
pads_out: &Mutex<Vec<PadInfo>>,
|
||||
active_out: &Mutex<Option<PadInfo>>,
|
||||
ctl: &Receiver<Ctl>,
|
||||
) -> Result<(), String> {
|
||||
fn run(pads_out: &Mutex<Vec<PadInfo>>, ctl: &Receiver<Ctl>) -> Result<(), String> {
|
||||
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its own
|
||||
// thread.
|
||||
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
|
||||
@@ -437,23 +408,12 @@ fn run(
|
||||
let mut list: Vec<PadInfo> = w.order.iter().filter_map(|&id| w.pad_info(id)).collect();
|
||||
list.reverse(); // most recent first — the Settings list order
|
||||
*pads_out.lock().unwrap() = list;
|
||||
*active_out.lock().unwrap() = w.active_id().and_then(|id| w.pad_info(id));
|
||||
};
|
||||
|
||||
loop {
|
||||
// Control plane from the UI thread.
|
||||
loop {
|
||||
match ctl.try_recv() {
|
||||
Ok(Ctl::Attach(c)) => {
|
||||
w.attached = Some(c);
|
||||
w.last_axis = [i32::MIN; 6];
|
||||
w.set_sensors(true);
|
||||
}
|
||||
Ok(Ctl::Detach) => {
|
||||
w.flush_held();
|
||||
w.set_sensors(false);
|
||||
w.attached = None;
|
||||
}
|
||||
Ok(Ctl::Pin(key)) => {
|
||||
let before = w.active_id();
|
||||
w.pinned = key;
|
||||
|
||||
+7
-230
@@ -1,104 +1,14 @@
|
||||
//! The single Direct3D 11 device shared by the video decoder (D3D11VA hardware decode) and the
|
||||
//! presenter (the `SwapChainPanel` composition swapchain + the present draw).
|
||||
//! DXGI adapter enumeration for the Settings "GPU" picker.
|
||||
//!
|
||||
//! Zero-copy hardware decode requires FFmpeg to decode HEVC into `ID3D11Texture2D`s created by the
|
||||
//! **same** device the presenter binds as shader resources and draws with — a texture from one
|
||||
//! device can't be sampled by another. So the device is created once, here, and both subsystems
|
||||
//! pull it from a process-global `OnceLock` (initialised on whichever thread asks first: the
|
||||
//! session pump when it builds the decoder, or the UI thread when it builds the presenter).
|
||||
//!
|
||||
//! **Adapter selection** (matters on hybrid boxes — e.g. an Intel iGPU driving the panel next to
|
||||
//! an NVIDIA dGPU): `PUNKTFUNK_ADAPTER` (index or case-insensitive name substring, a debugging
|
||||
//! override) wins; else the persisted Settings GPU pick ([`crate::trust::Settings::adapter`], the
|
||||
//! Settings-page selector on multi-GPU boxes); else the adapter whose output owns the monitor our
|
||||
//! window is on — that's the adapter DWM composes that monitor with, so presents are copy-free
|
||||
//! and decode runs on the near GPU; else the default adapter. Deliberately NOT "the adapter with
|
||||
//! the best decoder": if the monitor's adapter can't decode the codec we demote to software,
|
||||
//! which beats a per-frame cross-adapter present copy. The device is cached **keyed by the
|
||||
//! resolved preference**, so a Settings change takes effect at the next session (the pump and the
|
||||
//! presenter both resolve at session start and read the same value) without an app restart.
|
||||
//!
|
||||
//! `PUNKTFUNK_D3D_DEBUG=1` adds the D3D11 debug layer (validation messages in the debugger /
|
||||
//! DebugView) — invaluable for present-path bugs, which D3D11 otherwise drops silently.
|
||||
//!
|
||||
//! **Thread-safety.** windows-rs COM interfaces are deliberately `!Send`/`!Sync` — thread-safety
|
||||
//! is per-object, not universal. An `ID3D11Device` and its immediate context become free-threaded
|
||||
//! once `ID3D11Multithread::SetMultithreadProtected(TRUE)` is set, which FFmpeg's D3D11VA backend
|
||||
//! does inside `av_hwdevice_ctx_init` (it installs an `ID3D11Multithread`-based default lock when we
|
||||
//! leave `AVD3D11VADeviceContext.lock` null). The decoder then uses FFmpeg's separate
|
||||
//! `ID3D11VideoContext` for decode while the presenter uses the immediate context for draw; under
|
||||
//! multithread protection D3D serialises the two internally, and decode/draw touch disjoint context
|
||||
//! state. That makes the `unsafe impl Send + Sync` below sound for exactly this usage.
|
||||
//! Streaming (decode + present) runs in the spawned `punktfunk-session` binary; the shell only
|
||||
//! needs the list of real (hardware) adapters to offer on a multi-GPU box (a hybrid laptop or an
|
||||
//! eGPU). The picked adapter description is persisted (`crate::trust::Settings::adapter`) and read
|
||||
//! by the session child at connect (`PUNKTFUNK_ADAPTER` remains the session binary's env override).
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use windows::core::Interface;
|
||||
use windows::Win32::Graphics::Direct3D::{
|
||||
D3D_DRIVER_TYPE, D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN, D3D_DRIVER_TYPE_WARP,
|
||||
D3D_FEATURE_LEVEL_11_0, D3D_FEATURE_LEVEL_11_1,
|
||||
};
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Multithread,
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_CREATE_DEVICE_DEBUG, D3D11_CREATE_DEVICE_FLAG,
|
||||
D3D11_CREATE_DEVICE_VIDEO_SUPPORT, D3D11_SDK_VERSION,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1};
|
||||
|
||||
pub struct SharedDevice {
|
||||
pub device: ID3D11Device,
|
||||
pub context: ID3D11DeviceContext,
|
||||
/// True when this is a real GPU (hardware) adapter — a precondition for D3D11VA decode. WARP
|
||||
/// (the GPU-less dev box) creates fine for present but cannot hardware-decode HEVC, so the
|
||||
/// decoder skips straight to the software path there.
|
||||
pub hardware: bool,
|
||||
}
|
||||
|
||||
// Sound for our usage — see the module docs: the device + immediate context are free-threaded under
|
||||
// the multithread protection FFmpeg installs, and decode (video context) / present (immediate
|
||||
// context) never share mutable context state.
|
||||
unsafe impl Send for SharedDevice {}
|
||||
unsafe impl Sync for SharedDevice {}
|
||||
|
||||
/// The shared device, cached with the GPU preference it was resolved from (empty = automatic).
|
||||
/// Re-created when the preference changes — in practice only between sessions: within one session
|
||||
/// the decoder and the presenter both call [`shared`] at session start with the same value.
|
||||
static SHARED: Mutex<Option<(String, Arc<SharedDevice>)>> = Mutex::new(None);
|
||||
|
||||
/// The user's decode/present GPU preference: the `PUNKTFUNK_ADAPTER` env (debugging override)
|
||||
/// wins, else the persisted Settings pick; empty = automatic.
|
||||
fn adapter_pref() -> String {
|
||||
std::env::var("PUNKTFUNK_ADAPTER")
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| crate::trust::Settings::load().adapter)
|
||||
}
|
||||
|
||||
/// The process-shared D3D11 device for the current GPU preference, created (or re-created after
|
||||
/// a preference change) on demand. `None` only if D3D11 device creation fails for both a hardware
|
||||
/// adapter and WARP (effectively never — WARP is always present).
|
||||
pub fn shared() -> Option<Arc<SharedDevice>> {
|
||||
let pref = adapter_pref();
|
||||
let mut cached = SHARED.lock().unwrap();
|
||||
if let Some((key, dev)) = cached.as_ref() {
|
||||
if *key == pref {
|
||||
return Some(dev.clone());
|
||||
}
|
||||
}
|
||||
match create_device(&pref) {
|
||||
Ok(d) => {
|
||||
let d = Arc::new(d);
|
||||
*cached = Some((pref, d.clone()));
|
||||
Some(d)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "shared D3D11 device creation failed — no present/decode");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The adapter's human-readable description, for the logs.
|
||||
/// The adapter's human-readable description.
|
||||
fn adapter_name(adapter: &IDXGIAdapter) -> String {
|
||||
unsafe {
|
||||
adapter
|
||||
@@ -112,7 +22,7 @@ fn adapter_name(adapter: &IDXGIAdapter) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every DXGI adapter, in enumeration order (`PUNKTFUNK_ADAPTER=<index>` uses these indices).
|
||||
/// Every DXGI adapter, in enumeration order.
|
||||
fn all_adapters() -> Vec<IDXGIAdapter> {
|
||||
let factory: IDXGIFactory1 = match unsafe { CreateDXGIFactory1() } {
|
||||
Ok(f) => f,
|
||||
@@ -144,136 +54,3 @@ pub fn adapter_names() -> Vec<String> {
|
||||
.map(adapter_name)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Resolve an explicit adapter: a non-empty `pref` (index or case-insensitive name substring, from
|
||||
/// env or Settings) wins; else the adapter whose output owns the monitor the app window is on (see
|
||||
/// module docs); else `None` → the default adapter (also the headless-CLI path with no window).
|
||||
fn resolve_adapter(pref: &str) -> Option<IDXGIAdapter> {
|
||||
let adapters = all_adapters();
|
||||
|
||||
if !pref.is_empty() {
|
||||
let found = if let Ok(idx) = pref.parse::<usize>() {
|
||||
adapters.get(idx).cloned()
|
||||
} else {
|
||||
let needle = pref.to_lowercase();
|
||||
adapters
|
||||
.iter()
|
||||
.find(|a| adapter_name(a).to_lowercase().contains(&needle))
|
||||
.cloned()
|
||||
};
|
||||
match &found {
|
||||
Some(a) => tracing::info!(pref, adapter = %adapter_name(a), "GPU preference matched"),
|
||||
None => tracing::warn!(pref, "GPU preference matched no adapter — using automatic"),
|
||||
}
|
||||
if found.is_some() {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
// The adapter driving the monitor our window sits on: DWM composes that monitor with it, so
|
||||
// presenting from it is copy-free (a hybrid box's other adapter would pay a cross-adapter
|
||||
// copy per frame).
|
||||
let monitor = unsafe {
|
||||
use windows::Win32::Graphics::Gdi::{MonitorFromWindow, MONITOR_DEFAULTTONULL};
|
||||
use windows::Win32::UI::WindowsAndMessaging::FindWindowW;
|
||||
let hwnd = FindWindowW(None, windows::core::w!("Punktfunk")).ok()?;
|
||||
MonitorFromWindow(hwnd, MONITOR_DEFAULTTONULL)
|
||||
};
|
||||
if monitor.is_invalid() {
|
||||
return None;
|
||||
}
|
||||
for adapter in &adapters {
|
||||
let mut oi = 0u32;
|
||||
while let Ok(output) = unsafe { adapter.EnumOutputs(oi) } {
|
||||
oi += 1;
|
||||
if let Ok(desc) = unsafe { output.GetDesc() } {
|
||||
if desc.Monitor == monitor {
|
||||
tracing::info!(adapter = %adapter_name(adapter), "using the window's monitor adapter");
|
||||
return Some(adapter.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn create_device(pref: &str) -> Result<SharedDevice> {
|
||||
// Preference order: the resolved adapter (or the default hardware adapter) with video support
|
||||
// (enables D3D11VA); the same without the VIDEO flag (a driver that rejects it still presents +
|
||||
// software-decodes); finally WARP for the GPU-less box. BGRA_SUPPORT is required for the
|
||||
// composition swapchain in every case. An explicit adapter requires D3D_DRIVER_TYPE_UNKNOWN.
|
||||
let adapter = resolve_adapter(pref);
|
||||
let attempts: [(Option<&IDXGIAdapter>, D3D_DRIVER_TYPE, bool, bool); 3] = match &adapter {
|
||||
Some(a) => [
|
||||
(Some(a), D3D_DRIVER_TYPE_UNKNOWN, true, true),
|
||||
(Some(a), D3D_DRIVER_TYPE_UNKNOWN, false, true),
|
||||
(None, D3D_DRIVER_TYPE_WARP, false, false),
|
||||
],
|
||||
None => [
|
||||
(None, D3D_DRIVER_TYPE_HARDWARE, true, true),
|
||||
(None, D3D_DRIVER_TYPE_HARDWARE, false, true),
|
||||
(None, D3D_DRIVER_TYPE_WARP, false, false),
|
||||
],
|
||||
};
|
||||
// The debug layer needs the SDK layers installed (Graphics Tools); when they're missing the
|
||||
// creation fails, so each attempt retries without the flag rather than failing the ladder.
|
||||
let debug = std::env::var("PUNKTFUNK_D3D_DEBUG").is_ok_and(|v| v == "1");
|
||||
for (adapter, driver, video, hardware) in attempts {
|
||||
let mut flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT;
|
||||
if video {
|
||||
flags |= D3D11_CREATE_DEVICE_VIDEO_SUPPORT;
|
||||
}
|
||||
let flag_sets: &[D3D11_CREATE_DEVICE_FLAG] = if debug {
|
||||
&[flags | D3D11_CREATE_DEVICE_DEBUG, flags]
|
||||
} else {
|
||||
&[flags]
|
||||
};
|
||||
for &flags in flag_sets {
|
||||
let mut device = None;
|
||||
let mut context = None;
|
||||
let r = unsafe {
|
||||
D3D11CreateDevice(
|
||||
adapter,
|
||||
driver,
|
||||
None,
|
||||
flags,
|
||||
Some(&[D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_11_0]),
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
None,
|
||||
Some(&mut context),
|
||||
)
|
||||
};
|
||||
if r.is_ok() {
|
||||
let (device, context) = (device.unwrap(), context.unwrap());
|
||||
// Make the device + immediate context free-threaded: the decoder (D3D11VA video
|
||||
// context, pump thread) and the presenter (immediate context, render thread) both
|
||||
// touch this device. FFmpeg also sets this during hwdevice init, but doing it up
|
||||
// front keeps the cross-thread `Send`/`Sync` sound from the moment the device exists.
|
||||
if let Ok(mt) = context.cast::<ID3D11Multithread>() {
|
||||
unsafe {
|
||||
let _ = mt.SetMultithreadProtected(true); // returns the prior state; ignore
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
adapter = %adapter.map(adapter_name).unwrap_or_else(|| if hardware {
|
||||
"default".into()
|
||||
} else {
|
||||
"WARP (software)".into()
|
||||
}),
|
||||
video,
|
||||
debug = (flags & D3D11_CREATE_DEVICE_DEBUG).0 != 0,
|
||||
"shared D3D11 device created"
|
||||
);
|
||||
return Ok(SharedDevice {
|
||||
device,
|
||||
context,
|
||||
hardware,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(anyhow!(
|
||||
"D3D11CreateDevice failed for both hardware and WARP"
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1,742 +0,0 @@
|
||||
//! Stream input: Win32 low-level keyboard + mouse hooks forwarding to the host while the WinUI
|
||||
//! window is focused and the pointer is captured.
|
||||
//!
|
||||
//! windows-reactor exposes no raw key-down/up or pointer-position/wheel events (only keyboard
|
||||
//! *accelerators* and pointer button-state), which is insufficient for a game stream. So this
|
||||
//! drops below XAML to `WH_KEYBOARD_LL` / `WH_MOUSE_LL`, installed on the UI thread when the
|
||||
//! stream page mounts and removed when it unmounts.
|
||||
//!
|
||||
//! **Pointer lock.** While captured the cursor is *locked* the way a game-streaming client locks
|
||||
//! it (Moonlight/Parsec): the OS cursor is hidden + confined to the window (`ClipCursor`), and
|
||||
//! every physical move is turned into a **relative** delta (`InputKind::MouseMove`) — we read the
|
||||
//! offset from the window centre, ship it (scaled screen→host through the Contain-fit factor, with
|
||||
//! sub-pixel remainder carried so slow drags aren't lost), then warp the cursor back to centre so
|
||||
//! it never reaches a screen edge. This is why the old absolute path froze: swallowing
|
||||
//! `WM_MOUSEMOVE` pinned the OS cursor, so `pt` never travelled and the absolute coordinate
|
||||
//! snapped to one point. Keys carry the **US-positional VK** for the pressed physical key (the
|
||||
//! punktfunk wire contract shared by every first-party client — see [`scan_to_positional_vk`]):
|
||||
//! the hook's layout-resolved `vkCode` must NOT go on the wire, or a non-US pair re-maps
|
||||
//! positions through two layouts (German: y↔z swapped, ü lands on ö).
|
||||
//!
|
||||
//! **Capture state machine** (parity with the GTK/Swift clients): capture engages at stream
|
||||
//! start, **Ctrl+Alt+Shift+Q** releases it (handing the cursor back to the local desktop), and a
|
||||
//! **click on the stream** re-engages it. Losing foreground also releases the lock so the cursor
|
||||
//! is never stranded; regaining it while still captured re-locks. When "capture system
|
||||
//! shortcuts" is off in Settings, Alt+Tab / Alt+Esc / Ctrl+Esc / the Win keys act on the local
|
||||
//! desktop instead of being forwarded. **Ctrl+Alt+Shift+D disconnects** the session (consumed
|
||||
//! locally, works captured or released while our window is foreground): it trips the session's
|
||||
//! stop flag, the pump winds down, and the event loop navigates back to the host list.
|
||||
//! **Ctrl+Alt+Shift+S** toggles the stats overlay live and **F11** toggles fullscreen — both are
|
||||
//! client-local shortcuts (consumed, never forwarded), matching the GTK client's stream key set.
|
||||
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::config::Mode;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use windows::core::BOOL;
|
||||
use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, POINT, RECT, WPARAM};
|
||||
use windows::Win32::Graphics::Gdi::ClientToScreen;
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::{VK_D, VK_F11, VK_Q, VK_S};
|
||||
use windows::Win32::UI::Shell::{DefSubclassProc, RemoveWindowSubclass, SetWindowSubclass};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
CallNextHookEx, ClipCursor, EnumChildWindows, GetClientRect, GetForegroundWindow, SetCursor,
|
||||
SetCursorPos, SetWindowsHookExW, ShowCursor, UnhookWindowsHookEx, HC_ACTION, HHOOK,
|
||||
KBDLLHOOKSTRUCT, LLKHF_EXTENDED, LLMHF_INJECTED, MSLLHOOKSTRUCT, WH_KEYBOARD_LL, WH_MOUSE_LL,
|
||||
WM_KEYUP, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, WM_MOUSEHWHEEL,
|
||||
WM_MOUSEMOVE, WM_MOUSEWHEEL, WM_RBUTTONDOWN, WM_RBUTTONUP, WM_SETCURSOR, WM_SYSKEYUP,
|
||||
WM_XBUTTONDOWN, WM_XBUTTONUP,
|
||||
};
|
||||
|
||||
struct State {
|
||||
connector: Arc<NativeClient>,
|
||||
mode: Mode,
|
||||
/// The session's stop flag (Ctrl+Alt+Shift+D trips it; the pump then ends the session).
|
||||
stop: Arc<AtomicBool>,
|
||||
/// Our window handle, stored as the raw `isize` so `State` is `Send` (`HWND` is not).
|
||||
hwnd: isize,
|
||||
/// User intent: forward input to the host (toggled by Ctrl+Alt+Shift+Q / click-to-capture).
|
||||
captured: bool,
|
||||
/// Forward system shortcuts (Alt+Tab, Win, …) to the host; off = they act locally.
|
||||
inhibit_shortcuts: bool,
|
||||
/// The OS pointer is currently locked (hidden + confined + recentering). Tracks the real
|
||||
/// `ClipCursor`/`ShowCursor` state so we engage/disengage exactly once per transition.
|
||||
locked: bool,
|
||||
/// Lock geometry, captured when the lock engages: the confinement rect (screen coordinates,
|
||||
/// also the click-to-capture hit test), its centre (the cursor is warped here after every
|
||||
/// move), and the screen→host scale (the Contain-fit display scale's inverse). Stable while
|
||||
/// locked — the window can't be moved or resized with the cursor confined inside it.
|
||||
clip: RECT,
|
||||
center_x: i32,
|
||||
center_y: i32,
|
||||
scale: f32,
|
||||
/// Sub-pixel remainder of the screen→host scale, carried so slow drags aren't truncated away.
|
||||
acc_x: f32,
|
||||
acc_y: f32,
|
||||
/// Modifier state, tracked from the hook's own event stream (see `kbd_proc`).
|
||||
ctrl: bool,
|
||||
alt: bool,
|
||||
shift: bool,
|
||||
held_keys: HashSet<u8>,
|
||||
held_buttons: HashSet<u32>,
|
||||
}
|
||||
|
||||
// `State` carries no `!Send` handle (hwnd is an `isize`), so the static is sound. The hook procs
|
||||
// run on the same UI thread that installs/removes the hooks, so the lock is uncontended.
|
||||
static STATE: Mutex<Option<State>> = Mutex::new(None);
|
||||
static KBD_HOOK: AtomicIsize = AtomicIsize::new(0);
|
||||
static MOUSE_HOOK: AtomicIsize = AtomicIsize::new(0);
|
||||
/// Mirror of `State::captured` for lock-free reads off the UI thread (the HUD poll).
|
||||
static CAPTURED: AtomicBool = AtomicBool::new(false);
|
||||
/// Live stats-overlay visibility. Seeded from `Settings::show_stats` at `install`, then toggled by
|
||||
/// Ctrl+Alt+Shift+S for the session (parity with the GTK client's live `s` toggle); the HUD poll
|
||||
/// reads it lock-free to drive the overlay.
|
||||
static HUD_VISIBLE: AtomicBool = AtomicBool::new(false);
|
||||
/// Whether the pointer lock currently wants the OS cursor hidden. Read lock-free by
|
||||
/// [`cursor_subclass_proc`] (which runs on the UI thread inside `WM_SETCURSOR`) so it can override
|
||||
/// WinUI's per-move arrow re-assertion — a one-shot `ShowCursor(false)` alone loses that race
|
||||
/// because the content island re-sets the arrow every time the pointer moves.
|
||||
static CURSOR_HIDDEN: AtomicBool = AtomicBool::new(false);
|
||||
/// Our `SetWindowSubclass` id on the WinUI window + its content-island children (any stable value;
|
||||
/// scopes the subclass so install/remove target exactly our proc).
|
||||
const CURSOR_SUBCLASS_ID: usize = 0x7066_6375; // 'pfcu'
|
||||
|
||||
/// Whether stream input is currently captured (drives the HUD's release/capture hint).
|
||||
pub fn is_captured() -> bool {
|
||||
CAPTURED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Whether the stats overlay should be shown: the Settings default at stream start, then whatever
|
||||
/// Ctrl+Alt+Shift+S last set for the session. Read by the HUD poll thread.
|
||||
pub fn hud_visible() -> bool {
|
||||
HUD_VISIBLE.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Set the capture intent and engage/release the pointer lock to match.
|
||||
fn set_captured(st: &mut State, on: bool) {
|
||||
st.captured = on;
|
||||
CAPTURED.store(on, Ordering::Relaxed);
|
||||
set_locked(st, on);
|
||||
if !on {
|
||||
flush_held(st); // release held keys/buttons so nothing sticks on the host
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the hooks for a streaming session. Call from the UI thread once the window is shown.
|
||||
/// `inhibit_shortcuts` forwards system shortcuts (Alt+Tab, Win, …) to the host; off = local.
|
||||
/// `show_stats` seeds the stats-overlay visibility that Ctrl+Alt+Shift+S then toggles live.
|
||||
/// `stop` is the session's stop flag, tripped by the disconnect shortcut.
|
||||
pub fn install(
|
||||
connector: Arc<NativeClient>,
|
||||
mode: Mode,
|
||||
inhibit_shortcuts: bool,
|
||||
show_stats: bool,
|
||||
stop: Arc<AtomicBool>,
|
||||
) {
|
||||
HUD_VISIBLE.store(show_stats, Ordering::Relaxed);
|
||||
let hwnd = unsafe { GetForegroundWindow() };
|
||||
let mut st = State {
|
||||
connector,
|
||||
mode,
|
||||
stop,
|
||||
hwnd: hwnd.0 as isize,
|
||||
captured: false,
|
||||
inhibit_shortcuts,
|
||||
locked: false,
|
||||
clip: RECT::default(),
|
||||
center_x: 0,
|
||||
center_y: 0,
|
||||
scale: 1.0,
|
||||
acc_x: 0.0,
|
||||
acc_y: 0.0,
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
held_keys: HashSet::new(),
|
||||
held_buttons: HashSet::new(),
|
||||
};
|
||||
// Capture immediately (the window is foreground at mount, like Moonlight grabbing on stream
|
||||
// start).
|
||||
set_captured(&mut st, true);
|
||||
*STATE.lock().unwrap() = Some(st);
|
||||
unsafe {
|
||||
let hinst = GetModuleHandleW(None).ok();
|
||||
if let Ok(h) = SetWindowsHookExW(WH_KEYBOARD_LL, Some(kbd_proc), hinst.map(Into::into), 0) {
|
||||
KBD_HOOK.store(h.0 as isize, Ordering::SeqCst);
|
||||
}
|
||||
if let Ok(h) = SetWindowsHookExW(WH_MOUSE_LL, Some(mouse_proc), hinst.map(Into::into), 0) {
|
||||
MOUSE_HOOK.store(h.0 as isize, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
inhibit_shortcuts,
|
||||
"stream input hooks installed — pointer locked (Ctrl+Alt+Shift+Q toggles capture)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Remove the hooks, release the pointer lock, and flush any held keys/buttons (so nothing
|
||||
/// sticks down on the host).
|
||||
pub fn uninstall() {
|
||||
unsafe {
|
||||
let k = KBD_HOOK.swap(0, Ordering::SeqCst);
|
||||
if k != 0 {
|
||||
let _ = UnhookWindowsHookEx(HHOOK(k as *mut _));
|
||||
}
|
||||
let m = MOUSE_HOOK.swap(0, Ordering::SeqCst);
|
||||
if m != 0 {
|
||||
let _ = UnhookWindowsHookEx(HHOOK(m as *mut _));
|
||||
}
|
||||
}
|
||||
if let Some(mut st) = STATE.lock().unwrap().take() {
|
||||
// Hand the cursor back + flush held state.
|
||||
set_captured(&mut st, false);
|
||||
// Drop the WM_SETCURSOR subclass so the long-lived app window (reused for the host list
|
||||
// once the stream ends) is left pristine — set_captured already cleared CURSOR_HIDDEN.
|
||||
remove_cursor_subclass(HWND(st.hwnd as *mut _));
|
||||
// Fullscreen is a streaming-only mode: if F11 put us there, drop back to a normal window
|
||||
// so the GUI (the host list) is never left borderless-fullscreen after the stream ends.
|
||||
exit_fullscreen(HWND(st.hwnd as *mut _));
|
||||
}
|
||||
}
|
||||
|
||||
/// Release every held key/button on the host, so nothing sticks down when capture is dropped
|
||||
/// (toggled off) or the session ends.
|
||||
fn flush_held(st: &mut State) {
|
||||
let c = st.connector.clone();
|
||||
for vk in st.held_keys.drain() {
|
||||
send(&c, InputKind::KeyUp, vk as u32, 0, 0, 0);
|
||||
}
|
||||
for b in st.held_buttons.drain() {
|
||||
send(&c, InputKind::MouseButtonUp, b, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Subclass proc on the WinUI window + its content-island children: while the pointer lock wants
|
||||
/// the cursor hidden ([`CURSOR_HIDDEN`]), answer `WM_SETCURSOR` ourselves with `SetCursor(None)`
|
||||
/// and return TRUE — halting WinUI's default handling before it re-asserts the arrow. This is what
|
||||
/// actually keeps the cursor hidden while captured; the sibling `ShowCursor(false)` cannot, because
|
||||
/// WinUI re-sets the arrow on every pointer move (the content island answers `WM_SETCURSOR` itself,
|
||||
/// which a low-level mouse hook never sees). When not hidden, we defer to the chain untouched.
|
||||
unsafe extern "system" fn cursor_subclass_proc(
|
||||
hwnd: HWND,
|
||||
msg: u32,
|
||||
wparam: WPARAM,
|
||||
lparam: LPARAM,
|
||||
_id: usize,
|
||||
_ref: usize,
|
||||
) -> LRESULT {
|
||||
if msg == WM_SETCURSOR && CURSOR_HIDDEN.load(Ordering::Relaxed) {
|
||||
unsafe {
|
||||
let _ = SetCursor(None);
|
||||
}
|
||||
return LRESULT(1); // handled — suppress the framework's arrow re-assertion
|
||||
}
|
||||
unsafe { DefSubclassProc(hwnd, msg, wparam, lparam) }
|
||||
}
|
||||
|
||||
unsafe extern "system" fn subclass_install_cb(child: HWND, _l: LPARAM) -> BOOL {
|
||||
unsafe {
|
||||
let _ = SetWindowSubclass(child, Some(cursor_subclass_proc), CURSOR_SUBCLASS_ID, 0);
|
||||
}
|
||||
BOOL(1) // keep enumerating
|
||||
}
|
||||
|
||||
unsafe extern "system" fn subclass_remove_cb(child: HWND, _l: LPARAM) -> BOOL {
|
||||
unsafe {
|
||||
let _ = RemoveWindowSubclass(child, Some(cursor_subclass_proc), CURSOR_SUBCLASS_ID);
|
||||
}
|
||||
BOOL(1)
|
||||
}
|
||||
|
||||
/// Install [`cursor_subclass_proc`] on the top-level WinUI window and every descendant — the video
|
||||
/// is a composition SwapChainPanel, so the pointer actually sits over WinUI's internal content-
|
||||
/// island child window, which is the window that receives `WM_SETCURSOR`. `EnumChildWindows`
|
||||
/// recurses into all descendants, so one pass covers it. Idempotent (re-installing the same
|
||||
/// id+proc just refreshes it), so it's safe to call on every lock engage.
|
||||
fn install_cursor_subclass(top: HWND) {
|
||||
unsafe {
|
||||
let _ = SetWindowSubclass(top, Some(cursor_subclass_proc), CURSOR_SUBCLASS_ID, 0);
|
||||
let _ = EnumChildWindows(Some(top), Some(subclass_install_cb), LPARAM(0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove our subclass from the top-level window and every descendant. Called on teardown so the
|
||||
/// long-lived app window (reused for the host list after the stream ends) is left pristine.
|
||||
fn remove_cursor_subclass(top: HWND) {
|
||||
unsafe {
|
||||
let _ = RemoveWindowSubclass(top, Some(cursor_subclass_proc), CURSOR_SUBCLASS_ID);
|
||||
let _ = EnumChildWindows(Some(top), Some(subclass_remove_cb), LPARAM(0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Engage or release the pointer lock: confine + hide + recentre on, free + show on off.
|
||||
/// Guarded so the `ClipCursor`/`ShowCursor` calls stay balanced (one each per transition).
|
||||
/// Engaging captures the lock geometry (rect, centre, screen→host scale) — see `State::clip`.
|
||||
fn set_locked(st: &mut State, on: bool) {
|
||||
if on == st.locked {
|
||||
return;
|
||||
}
|
||||
let hwnd = HWND(st.hwnd as *mut _);
|
||||
unsafe {
|
||||
if on {
|
||||
let mut rc = RECT::default();
|
||||
if GetClientRect(hwnd, &mut rc).is_ok() {
|
||||
let mut tl = POINT {
|
||||
x: rc.left,
|
||||
y: rc.top,
|
||||
};
|
||||
let mut br = POINT {
|
||||
x: rc.right,
|
||||
y: rc.bottom,
|
||||
};
|
||||
let _ = ClientToScreen(hwnd, &mut tl);
|
||||
let _ = ClientToScreen(hwnd, &mut br);
|
||||
st.clip = RECT {
|
||||
left: tl.x,
|
||||
top: tl.y,
|
||||
right: br.x,
|
||||
bottom: br.y,
|
||||
};
|
||||
let _ = ClipCursor(Some(&st.clip as *const RECT));
|
||||
st.center_x = (tl.x + br.x) / 2;
|
||||
st.center_y = (tl.y + br.y) / 2;
|
||||
// Screen px → host px: the Contain-fit display scale's inverse, so the host
|
||||
// cursor tracks the physical mouse 1:1 on screen at any window size.
|
||||
let (ww, wh) = ((br.x - tl.x).max(1) as f32, (br.y - tl.y).max(1) as f32);
|
||||
let (vw, vh) = (st.mode.width.max(1) as f32, st.mode.height.max(1) as f32);
|
||||
st.scale = (ww / vw).min(wh / vh).max(0.01);
|
||||
let _ = SetCursorPos(st.center_x, st.center_y);
|
||||
}
|
||||
// Hide the OS cursor. ShowCursor(false) is the coarse gate; the subclass is what
|
||||
// actually holds it hidden against WinUI's per-move arrow re-assertion — see
|
||||
// cursor_subclass_proc / install_cursor_subclass.
|
||||
let _ = ShowCursor(false);
|
||||
CURSOR_HIDDEN.store(true, Ordering::Relaxed);
|
||||
install_cursor_subclass(hwnd);
|
||||
st.acc_x = 0.0;
|
||||
st.acc_y = 0.0;
|
||||
} else {
|
||||
CURSOR_HIDDEN.store(false, Ordering::Relaxed);
|
||||
let _ = ClipCursor(None);
|
||||
let _ = ShowCursor(true);
|
||||
}
|
||||
}
|
||||
st.locked = on;
|
||||
}
|
||||
|
||||
/// The pre-fullscreen window placement, saved on entering fullscreen and restored on leaving it.
|
||||
/// Module-level (not a `toggle_fullscreen`-local static) so the F11 toggle and the stream-stop exit
|
||||
/// ([`uninstall`]) share the one saved placement, and its presence is also the "are we fullscreen?"
|
||||
/// flag for [`exit_fullscreen`]. Only ever touched on the UI thread (the hook proc / the stream
|
||||
/// page's unmount), but a Mutex keeps the static sound + `Sync`.
|
||||
static SAVED_PLACEMENT: Mutex<Option<windows::Win32::UI::WindowsAndMessaging::WINDOWPLACEMENT>> =
|
||||
Mutex::new(None);
|
||||
|
||||
/// Whether our top-level window is currently borderless-fullscreen. Entering strips
|
||||
/// `WS_OVERLAPPEDWINDOW`, so its absence is the flag — no extra state beyond [`SAVED_PLACEMENT`].
|
||||
fn is_fullscreen(hwnd: HWND) -> bool {
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetWindowLongPtrW, GWL_STYLE, WS_OVERLAPPEDWINDOW,
|
||||
};
|
||||
let overlapped = WS_OVERLAPPEDWINDOW.0 as isize;
|
||||
unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) & overlapped == 0 }
|
||||
}
|
||||
|
||||
/// Enter borderless fullscreen: remember the window placement, drop the frame
|
||||
/// (`WS_OVERLAPPEDWINDOW`), and size the window to cover the whole monitor. windows-reactor owns
|
||||
/// the WinUI window but exposes no fullscreen API, so we drive the HWND directly (parity with the
|
||||
/// GTK client's F11). The SwapChainPanel follows the resulting `WM_SIZE` like any window resize.
|
||||
fn enter_fullscreen(hwnd: HWND) {
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTOPRIMARY,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetWindowLongPtrW, GetWindowPlacement, SetWindowLongPtrW, SetWindowPos, GWL_STYLE,
|
||||
SWP_FRAMECHANGED, SWP_NOOWNERZORDER, SWP_NOZORDER, WINDOWPLACEMENT, WS_OVERLAPPEDWINDOW,
|
||||
};
|
||||
let overlapped = WS_OVERLAPPEDWINDOW.0 as isize;
|
||||
unsafe {
|
||||
let style = GetWindowLongPtrW(hwnd, GWL_STYLE);
|
||||
let mut wp = WINDOWPLACEMENT {
|
||||
length: std::mem::size_of::<WINDOWPLACEMENT>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut mi = MONITORINFO {
|
||||
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY);
|
||||
if GetWindowPlacement(hwnd, &mut wp).is_ok() && GetMonitorInfoW(mon, &mut mi).as_bool() {
|
||||
*SAVED_PLACEMENT.lock().unwrap() = Some(wp);
|
||||
SetWindowLongPtrW(hwnd, GWL_STYLE, style & !overlapped);
|
||||
let r = mi.rcMonitor;
|
||||
let _ = SetWindowPos(
|
||||
hwnd,
|
||||
None,
|
||||
r.left,
|
||||
r.top,
|
||||
r.right - r.left,
|
||||
r.bottom - r.top,
|
||||
SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_FRAMECHANGED,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Leave borderless fullscreen: restore the frame style and the saved placement. A no-op when we
|
||||
/// aren't fullscreen (nothing saved), so it's safe to call unconditionally on stream stop.
|
||||
fn exit_fullscreen(hwnd: HWND) {
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetWindowLongPtrW, SetWindowLongPtrW, SetWindowPlacement, SetWindowPos, GWL_STYLE,
|
||||
SWP_FRAMECHANGED, SWP_NOMOVE, SWP_NOOWNERZORDER, SWP_NOSIZE, SWP_NOZORDER,
|
||||
WS_OVERLAPPEDWINDOW,
|
||||
};
|
||||
let Some(wp) = SAVED_PLACEMENT.lock().unwrap().take() else {
|
||||
return; // never went fullscreen — nothing to restore
|
||||
};
|
||||
let overlapped = WS_OVERLAPPEDWINDOW.0 as isize;
|
||||
unsafe {
|
||||
let style = GetWindowLongPtrW(hwnd, GWL_STYLE);
|
||||
SetWindowLongPtrW(hwnd, GWL_STYLE, style | overlapped);
|
||||
let _ = SetWindowPlacement(hwnd, &wp);
|
||||
let _ = SetWindowPos(
|
||||
hwnd,
|
||||
None,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_FRAMECHANGED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle borderless fullscreen for our top-level window (F11), the classic Win32 dance split into
|
||||
/// [`enter_fullscreen`] / [`exit_fullscreen`] so the stream-stop path can force windowed too.
|
||||
fn toggle_fullscreen(hwnd: isize) {
|
||||
let hwnd = HWND(hwnd as *mut _);
|
||||
if is_fullscreen(hwnd) {
|
||||
exit_fullscreen(hwnd);
|
||||
} else {
|
||||
enter_fullscreen(hwnd);
|
||||
}
|
||||
}
|
||||
|
||||
fn send(c: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) {
|
||||
let _ = c.send_input(&InputEvent {
|
||||
kind,
|
||||
_pad: [0; 3],
|
||||
code,
|
||||
x,
|
||||
y,
|
||||
flags,
|
||||
});
|
||||
}
|
||||
|
||||
/// System shortcuts that act on the LOCAL desktop when "capture system shortcuts" is off:
|
||||
/// the Win keys, Alt+Tab, and Alt/Ctrl+Esc.
|
||||
fn is_system_shortcut(st: &State, vk: u16) -> bool {
|
||||
match vk {
|
||||
0x5B | 0x5C => true, // L/R Win
|
||||
0x09 => st.alt, // Alt+Tab
|
||||
0x1B => st.alt || st.ctrl, // Alt+Esc / Ctrl+Esc
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "system" fn kbd_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||
if code == HC_ACTION as i32 {
|
||||
let kb = unsafe { &*(lparam.0 as *const KBDLLHOOKSTRUCT) };
|
||||
let msg = wparam.0 as u32;
|
||||
let up = msg == WM_KEYUP || msg == WM_SYSKEYUP;
|
||||
let vk = kb.vkCode as u16;
|
||||
let mut guard = STATE.lock().unwrap();
|
||||
if let Some(st) = guard.as_mut() {
|
||||
// Track modifier state from the hook's own event stream — reliable even while we
|
||||
// swallow these keys (GetAsyncKeyState doesn't reflect keys suppressed by our own LL
|
||||
// hook, which is why the shortcut never fired). Handles the generic + L/R vk codes.
|
||||
match kb.vkCode {
|
||||
0x11 | 0xA2 | 0xA3 => st.ctrl = !up, // (L/R)CONTROL
|
||||
0x12 | 0xA4 | 0xA5 => st.alt = !up, // (L/R)MENU (Alt)
|
||||
0x10 | 0xA0 | 0xA1 => st.shift = !up, // (L/R)SHIFT
|
||||
_ => {}
|
||||
}
|
||||
let foreground = unsafe { GetForegroundWindow() }.0 as isize == st.hwnd;
|
||||
if foreground {
|
||||
// Capture toggle: Ctrl+Alt+Shift+Q (consumed locally, never forwarded).
|
||||
if !up && vk == VK_Q.0 && st.ctrl && st.alt && st.shift {
|
||||
let on = !st.captured;
|
||||
set_captured(st, on);
|
||||
tracing::info!(captured = on, "capture toggled (Ctrl+Alt+Shift+Q)");
|
||||
return LRESULT(1);
|
||||
}
|
||||
// Disconnect: Ctrl+Alt+Shift+D (consumed locally). Release capture immediately so
|
||||
// the cursor is free while the session winds down and the UI navigates home.
|
||||
if !up && vk == VK_D.0 && st.ctrl && st.alt && st.shift {
|
||||
set_captured(st, false);
|
||||
// Deliberate user exit → close with QUIT_CLOSE_CODE so the host tears the session
|
||||
// down immediately instead of holding the keep-alive linger for a reconnect.
|
||||
st.connector.disconnect_quit();
|
||||
st.stop.store(true, Ordering::SeqCst);
|
||||
tracing::info!("disconnect requested (Ctrl+Alt+Shift+D)");
|
||||
return LRESULT(1);
|
||||
}
|
||||
// Toggle the stats overlay: Ctrl+Alt+Shift+S (consumed locally). Seeded from
|
||||
// Settings at install; this live toggle overrides it for the session — parity
|
||||
// with the GTK client, where `s` flips the OSD without leaving the stream.
|
||||
if !up && vk == VK_S.0 && st.ctrl && st.alt && st.shift {
|
||||
let on = !HUD_VISIBLE.load(Ordering::Relaxed);
|
||||
HUD_VISIBLE.store(on, Ordering::Relaxed);
|
||||
tracing::info!(hud = on, "stats overlay toggled (Ctrl+Alt+Shift+S)");
|
||||
return LRESULT(1);
|
||||
}
|
||||
// Toggle fullscreen: F11 (consumed locally, no modifiers — a client shortcut,
|
||||
// never a wire key). Works captured or released. The window resize changes the
|
||||
// client rect, so re-lock to recompute the pointer confinement + recentre.
|
||||
if !up && vk == VK_F11.0 {
|
||||
toggle_fullscreen(st.hwnd);
|
||||
if st.locked {
|
||||
set_locked(st, false);
|
||||
set_locked(st, true);
|
||||
}
|
||||
tracing::info!("fullscreen toggled (F11)");
|
||||
return LRESULT(1);
|
||||
}
|
||||
if st.captured {
|
||||
// With shortcut capture off, hand Alt+Tab & co. to the local desktop —
|
||||
// neither forwarded nor swallowed.
|
||||
if !st.inhibit_shortcuts && is_system_shortcut(st, vk) {
|
||||
return unsafe { CallNextHookEx(None, code, wparam, lparam) };
|
||||
}
|
||||
// Wire key: the US-positional VK for this physical key (module docs), derived
|
||||
// from the scancode. `vkCode` is layout-semantic and only passes through for
|
||||
// keys the table doesn't cover — extended keys and everything outside the
|
||||
// typing area, where positional == semantic (plus injected events with
|
||||
// scanCode 0 from remapping tools, best-effort).
|
||||
let ext = (kb.flags.0 & LLKHF_EXTENDED.0) != 0;
|
||||
let v = if ext {
|
||||
vk as u8
|
||||
} else {
|
||||
scan_to_positional_vk(kb.scanCode as u16).unwrap_or(vk as u8)
|
||||
};
|
||||
if up {
|
||||
if st.held_keys.remove(&v) {
|
||||
send(&st.connector, InputKind::KeyUp, v as u32, 0, 0, 0);
|
||||
}
|
||||
} else {
|
||||
st.held_keys.insert(v);
|
||||
send(&st.connector, InputKind::KeyDown, v as u32, 0, 0, 0);
|
||||
}
|
||||
return LRESULT(1); // swallow so it reaches the host, not the local OS
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
unsafe { CallNextHookEx(None, code, wparam, lparam) }
|
||||
}
|
||||
|
||||
/// Whether a screen point lies inside the window's CURRENT client area (the click-to-capture
|
||||
/// hit test — computed fresh per click, since the window can move/resize while released).
|
||||
fn in_client_area(hwnd: isize, pt: POINT) -> bool {
|
||||
let hwnd = HWND(hwnd as *mut _);
|
||||
let mut rc = RECT::default();
|
||||
if unsafe { GetClientRect(hwnd, &mut rc) }.is_err() {
|
||||
return false;
|
||||
}
|
||||
let mut tl = POINT {
|
||||
x: rc.left,
|
||||
y: rc.top,
|
||||
};
|
||||
let mut br = POINT {
|
||||
x: rc.right,
|
||||
y: rc.bottom,
|
||||
};
|
||||
unsafe {
|
||||
let _ = ClientToScreen(hwnd, &mut tl);
|
||||
let _ = ClientToScreen(hwnd, &mut br);
|
||||
}
|
||||
pt.x >= tl.x && pt.x < br.x && pt.y >= tl.y && pt.y < br.y
|
||||
}
|
||||
|
||||
unsafe extern "system" fn mouse_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||
if code == HC_ACTION as i32 {
|
||||
let ms = unsafe { &*(lparam.0 as *const MSLLHOOKSTRUCT) };
|
||||
let msg = wparam.0 as u32;
|
||||
let injected = (ms.flags & LLMHF_INJECTED) != 0;
|
||||
let mut guard = STATE.lock().unwrap();
|
||||
if let Some(st) = guard.as_mut() {
|
||||
let foreground = unsafe { GetForegroundWindow() }.0 as isize == st.hwnd;
|
||||
let want_lock = st.captured && foreground;
|
||||
if want_lock != st.locked {
|
||||
set_locked(st, want_lock); // sync to focus changes (e.g. lost foreground)
|
||||
}
|
||||
// Click-to-capture: after a Ctrl+Alt+Shift+Q release, a primary click on the stream
|
||||
// re-engages capture. The click is consumed — it starts the grab, it isn't gameplay.
|
||||
if !st.captured
|
||||
&& foreground
|
||||
&& msg == WM_LBUTTONDOWN
|
||||
&& !injected
|
||||
&& in_client_area(st.hwnd, ms.pt)
|
||||
{
|
||||
set_captured(st, true);
|
||||
tracing::info!("capture re-engaged (click on stream)");
|
||||
return LRESULT(1);
|
||||
}
|
||||
if st.locked {
|
||||
// Skip the synthetic move our own SetCursorPos recentre generates.
|
||||
if injected {
|
||||
return unsafe { CallNextHookEx(None, code, wparam, lparam) };
|
||||
}
|
||||
let c = st.connector.clone();
|
||||
match msg {
|
||||
WM_MOUSEMOVE => {
|
||||
let dx = (ms.pt.x - st.center_x) as f32;
|
||||
let dy = (ms.pt.y - st.center_y) as f32;
|
||||
if dx != 0.0 || dy != 0.0 {
|
||||
st.acc_x += dx / st.scale;
|
||||
st.acc_y += dy / st.scale;
|
||||
let (hx, hy) = (st.acc_x.trunc() as i32, st.acc_y.trunc() as i32);
|
||||
st.acc_x -= hx as f32;
|
||||
st.acc_y -= hy as f32;
|
||||
if hx != 0 || hy != 0 {
|
||||
send(&c, InputKind::MouseMove, 0, hx, hy, 0);
|
||||
}
|
||||
}
|
||||
let _ = unsafe { SetCursorPos(st.center_x, st.center_y) };
|
||||
}
|
||||
WM_LBUTTONDOWN => button(st, 1, true),
|
||||
WM_LBUTTONUP => button(st, 1, false),
|
||||
WM_RBUTTONDOWN => button(st, 3, true),
|
||||
WM_RBUTTONUP => button(st, 3, false),
|
||||
WM_MBUTTONDOWN => button(st, 2, true),
|
||||
WM_MBUTTONUP => button(st, 2, false),
|
||||
WM_XBUTTONDOWN => button(st, 3 + ((ms.mouseData >> 16) as u16 as u32), true),
|
||||
WM_XBUTTONUP => button(st, 3 + ((ms.mouseData >> 16) as u16 as u32), false),
|
||||
WM_MOUSEWHEEL => send(
|
||||
&c,
|
||||
InputKind::MouseScroll,
|
||||
0,
|
||||
(ms.mouseData >> 16) as i16 as i32,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
WM_MOUSEHWHEEL => send(
|
||||
&c,
|
||||
InputKind::MouseScroll,
|
||||
1,
|
||||
(ms.mouseData >> 16) as i16 as i32,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
return LRESULT(1); // swallow inside the locked window
|
||||
}
|
||||
}
|
||||
}
|
||||
unsafe { CallNextHookEx(None, code, wparam, lparam) }
|
||||
}
|
||||
|
||||
fn button(st: &mut State, id: u32, down: bool) {
|
||||
let c = st.connector.clone();
|
||||
if down {
|
||||
st.held_buttons.insert(id);
|
||||
send(&c, InputKind::MouseButtonDown, id, 0, 0, 0);
|
||||
} else if st.held_buttons.remove(&id) {
|
||||
send(&c, InputKind::MouseButtonUp, id, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set-1 make scancode → US-positional VK for the layout-**variant** typing area (letters, digit
|
||||
/// row, OEM punctuation, the ISO 102nd key) — the exact inverse of the host injector's positional
|
||||
/// table and the Windows analogue of the Linux client's `evdev_to_vk`. Keys not listed (F-row,
|
||||
/// nav cluster, numpad, modifiers — plus every E0-extended key, which the caller filters out)
|
||||
/// have layout-invariant VKs, so the hook's `vkCode` is already correct for them.
|
||||
fn scan_to_positional_vk(scan: u16) -> Option<u8> {
|
||||
Some(match scan {
|
||||
0x02..=0x0A => (scan - 0x02) as u8 + 0x31, // 1..9
|
||||
0x0B => 0x30, // 0
|
||||
0x0C => 0xBD, // -_ VK_OEM_MINUS (DE: ß)
|
||||
0x0D => 0xBB, // =+ VK_OEM_PLUS
|
||||
0x10 => 0x51, // Q
|
||||
0x11 => 0x57, // W
|
||||
0x12 => 0x45, // E
|
||||
0x13 => 0x52, // R
|
||||
0x14 => 0x54, // T
|
||||
0x15 => 0x59, // Y position (QWERTZ: the Z key)
|
||||
0x16 => 0x55, // U
|
||||
0x17 => 0x49, // I
|
||||
0x18 => 0x4F, // O
|
||||
0x19 => 0x50, // P
|
||||
0x1A => 0xDB, // [{ VK_OEM_4 (DE: ü)
|
||||
0x1B => 0xDD, // ]} VK_OEM_6
|
||||
0x1E => 0x41, // A
|
||||
0x1F => 0x53, // S
|
||||
0x20 => 0x44, // D
|
||||
0x21 => 0x46, // F
|
||||
0x22 => 0x47, // G
|
||||
0x23 => 0x48, // H
|
||||
0x24 => 0x4A, // J
|
||||
0x25 => 0x4B, // K
|
||||
0x26 => 0x4C, // L
|
||||
0x27 => 0xBA, // ;: VK_OEM_1 (DE: ö)
|
||||
0x28 => 0xDE, // '" VK_OEM_7 (DE: ä)
|
||||
0x29 => 0xC0, // `~ VK_OEM_3 (DE: ^)
|
||||
0x2B => 0xDC, // \| VK_OEM_5
|
||||
0x2C => 0x5A, // Z position (QWERTZ: the Y key)
|
||||
0x2D => 0x58, // X
|
||||
0x2E => 0x43, // C
|
||||
0x2F => 0x56, // V
|
||||
0x30 => 0x42, // B
|
||||
0x31 => 0x4E, // N
|
||||
0x32 => 0x4D, // M
|
||||
0x33 => 0xBC, // ,< VK_OEM_COMMA
|
||||
0x34 => 0xBE, // .> VK_OEM_PERIOD
|
||||
0x35 => 0xBF, // /? VK_OEM_2
|
||||
0x56 => 0xE2, // <>| VK_OEM_102 (ISO)
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The German-scramble regression pins: the physical keys a QWERTZ board labels Z/Y/ö/ü must
|
||||
/// leave this client as their US-position VKs, regardless of the local layout's vkCode.
|
||||
#[test]
|
||||
fn positional_pins_for_the_qwertz_scramble() {
|
||||
assert_eq!(scan_to_positional_vk(0x15), Some(0x59)); // QWERTZ Z key → VK_Y (US position)
|
||||
assert_eq!(scan_to_positional_vk(0x2C), Some(0x5A)); // QWERTZ Y key → VK_Z (US position)
|
||||
assert_eq!(scan_to_positional_vk(0x27), Some(0xBA)); // ö key → VK_OEM_1 (US ;: position)
|
||||
assert_eq!(scan_to_positional_vk(0x1A), Some(0xDB)); // ü key → VK_OEM_4 (US [{ position)
|
||||
assert_eq!(scan_to_positional_vk(0x28), Some(0xDE)); // ä key → VK_OEM_7 (US '" position)
|
||||
assert_eq!(scan_to_positional_vk(0x0C), Some(0xBD)); // ß key → VK_OEM_MINUS (US -_ position)
|
||||
}
|
||||
|
||||
/// Keys outside the layout-variant typing area stay un-mapped (vkCode passes through).
|
||||
#[test]
|
||||
fn invariant_keys_fall_through() {
|
||||
for scan in [
|
||||
0x01u16, 0x0E, 0x0F, 0x1C, 0x1D, 0x2A, 0x36, 0x38, 0x39, 0x3B, 0x45, 0x57,
|
||||
] {
|
||||
assert_eq!(scan_to_positional_vk(scan), None, "scan 0x{scan:02X}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Exactly the 48 typing-area keys are covered (10 digits + 26 letters + 12 OEM), and every
|
||||
/// mapping is unique — two physical keys must never collapse onto one wire VK.
|
||||
#[test]
|
||||
fn table_covers_the_typing_area_bijectively() {
|
||||
let mapped: Vec<(u16, u8)> = (0u16..=0xFF)
|
||||
.filter_map(|sc| scan_to_positional_vk(sc).map(|vk| (sc, vk)))
|
||||
.collect();
|
||||
assert_eq!(mapped.len(), 48);
|
||||
let mut vks: Vec<u8> = mapped.iter().map(|&(_, vk)| vk).collect();
|
||||
vks.sort_unstable();
|
||||
vks.dedup();
|
||||
assert_eq!(vks.len(), 48, "duplicate wire VK in the positional table");
|
||||
}
|
||||
}
|
||||
+15
-162
@@ -1,17 +1,16 @@
|
||||
//! `punktfunk-client` — the native Windows punktfunk/1 client.
|
||||
//!
|
||||
//! Pure Rust: `NativeClient` linked as a crate (no C ABI, like the GTK Linux client) · FFmpeg
|
||||
//! decode · WASAPI audio · SDL3 gamepads · a **WinUI 3** shell (windows-reactor) with the video
|
||||
//! on a `SwapChainPanel` bound to a D3D11 composition swapchain. The trust surface mirrors the
|
||||
//! Pure Rust: `NativeClient` linked as a crate (no C ABI, like the GTK Linux client) · SDL3
|
||||
//! gamepads · a **WinUI 3** shell (windows-reactor). Streaming (decode + present + audio) runs in
|
||||
//! the spawned `punktfunk-session` Vulkan binary; the shell owns host selection, trust and
|
||||
//! pairing. The trust surface mirrors the
|
||||
//! other native clients: persistent identity, trust-on-first-use, SPAKE2 PIN pairing — all in-app
|
||||
//! (host list, settings, pairing). `--headless` keeps a CLI connect path for tests/measurement.
|
||||
//! (host list, settings, pairing). Streaming runs in the spawned `punktfunk-session` binary;
|
||||
//! `--headless --speed-test` keeps a decode-less CLI measurement path.
|
||||
//!
|
||||
//! Usage:
|
||||
//! punktfunk-client (open the WinUI 3 window: host list, settings, pairing)
|
||||
//! punktfunk-client --discover (list punktfunk hosts on the LAN)
|
||||
//! punktfunk-client --headless --connect host[:port] [--pin HEX] [--pair PIN] [--mode WxHxHz]
|
||||
//! [--bitrate MBPS] [--mic] [--decoder auto|hardware|software] [--no-hdr]
|
||||
//! (no window; count frames + print stats)
|
||||
//! punktfunk-client --headless --speed-test --connect host[:port]
|
||||
//! (measure the path: probe burst → goodput / loss / recommended bitrate)
|
||||
|
||||
@@ -23,29 +22,19 @@
|
||||
#[cfg(windows)]
|
||||
mod app;
|
||||
#[cfg(windows)]
|
||||
mod audio;
|
||||
#[cfg(windows)]
|
||||
mod discovery;
|
||||
#[cfg(windows)]
|
||||
mod gamepad;
|
||||
#[cfg(windows)]
|
||||
mod gpu;
|
||||
#[cfg(windows)]
|
||||
mod input;
|
||||
#[cfg(windows)]
|
||||
mod present;
|
||||
#[cfg(windows)]
|
||||
mod render;
|
||||
#[cfg(windows)]
|
||||
mod session;
|
||||
mod probe;
|
||||
#[cfg(windows)]
|
||||
mod shell_window;
|
||||
#[cfg(windows)]
|
||||
mod spawn;
|
||||
#[cfg(windows)]
|
||||
mod trust;
|
||||
#[cfg(windows)]
|
||||
mod video;
|
||||
|
||||
#[cfg(windows)]
|
||||
mod wol;
|
||||
@@ -124,13 +113,11 @@ fn set_app_user_model_id() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `--headless --connect host[:port] …`: connect from the CLI, count frames, print stats — the
|
||||
/// Windows analogue of `punktfunk-probe`.
|
||||
/// `--headless --speed-test --connect host[:port]`: measure the path over the real data plane and
|
||||
/// print the outcome — the Windows analogue of `punktfunk-probe`. The former in-process
|
||||
/// frame-count connect path went with the legacy builtin stream; real streaming is windowed-only.
|
||||
#[cfg(windows)]
|
||||
fn run_headless_cli(args: &[String], identity: (String, String)) {
|
||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
let arg = |name: &str| -> Option<String> {
|
||||
args.iter()
|
||||
.position(|a| a == name)
|
||||
@@ -154,7 +141,7 @@ fn run_headless_cli(args: &[String], identity: (String, String)) {
|
||||
let fp = trust::KnownHosts::load()
|
||||
.find_by_addr(&host, port)
|
||||
.map(|k| k.fp_hex.clone());
|
||||
match session::run_speed_probe(&host, port, fp.as_deref(), identity) {
|
||||
match probe::run_speed_probe(&host, port, fp.as_deref(), identity) {
|
||||
Ok(r) => {
|
||||
let mbps = f64::from(r.throughput_kbps) / 1000.0;
|
||||
let recommended = f64::from(r.throughput_kbps / 10 * 7) / 1000.0;
|
||||
@@ -171,144 +158,10 @@ fn run_headless_cli(args: &[String], identity: (String, String)) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mode = arg("--mode")
|
||||
.and_then(|m| {
|
||||
let mut it = m.split(['x', 'X']);
|
||||
Some(Mode {
|
||||
width: it.next()?.parse().ok()?,
|
||||
height: it.next()?.parse().ok()?,
|
||||
refresh_hz: it.next()?.parse().ok()?,
|
||||
})
|
||||
})
|
||||
.unwrap_or(Mode {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
refresh_hz: 60,
|
||||
});
|
||||
let bitrate_kbps = arg("--bitrate")
|
||||
.and_then(|b| b.parse::<u32>().ok())
|
||||
.map(|m| m * 1000)
|
||||
.unwrap_or(0);
|
||||
|
||||
let known = trust::KnownHosts::load();
|
||||
let mut pin = arg("--pin")
|
||||
.and_then(|h| trust::parse_hex32(&h))
|
||||
.or_else(|| {
|
||||
known
|
||||
.find_by_addr(&host, port)
|
||||
.and_then(|k| trust::parse_hex32(&k.fp_hex))
|
||||
});
|
||||
if let Some(code) = arg("--pair") {
|
||||
let name = std::env::var("COMPUTERNAME").unwrap_or_else(|_| "windows-client".into());
|
||||
match punktfunk_core::client::NativeClient::pair(
|
||||
&host,
|
||||
port,
|
||||
(&identity.0, &identity.1),
|
||||
code.trim(),
|
||||
&name,
|
||||
Duration::from_secs(90),
|
||||
) {
|
||||
Ok(fp) => {
|
||||
let mut k = trust::KnownHosts::load();
|
||||
k.upsert(trust::KnownHost {
|
||||
name: host.clone(),
|
||||
addr: host.clone(),
|
||||
port,
|
||||
fp_hex: trust::hex(&fp),
|
||||
paired: true,
|
||||
last_used: None,
|
||||
mac: Vec::new(),
|
||||
});
|
||||
let _ = k.save();
|
||||
tracing::info!(fp = %trust::hex(&fp), "paired");
|
||||
pin = Some(fp);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Pairing failed: {e:?}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let decoder = arg("--decoder")
|
||||
.map(|d| crate::video::DecoderPref::from_name(&d))
|
||||
.unwrap_or_default();
|
||||
|
||||
tracing::info!(%host, port, ?mode, tofu = pin.is_none(), ?decoder, "connecting (headless)");
|
||||
let handle = session::start(session::SessionParams {
|
||||
host,
|
||||
port,
|
||||
mode,
|
||||
compositor: CompositorPref::Auto,
|
||||
gamepad: GamepadPref::Auto,
|
||||
bitrate_kbps,
|
||||
// Headless CLI path (test/scripting) — stereo baseline; the GUI sources this from settings.
|
||||
audio_channels: 2,
|
||||
mic_enabled: flag("--mic"),
|
||||
hdr_enabled: !flag("--no-hdr"),
|
||||
decoder,
|
||||
// `--codec h264|hevc|av1` sets the soft preference; default auto (host decides).
|
||||
preferred_codec: match arg("--codec").as_deref() {
|
||||
Some("h264") | Some("avc") => punktfunk_core::quic::CODEC_H264,
|
||||
Some("hevc") | Some("h265") => punktfunk_core::quic::CODEC_HEVC,
|
||||
Some("av1") => punktfunk_core::quic::CODEC_AV1,
|
||||
_ => 0,
|
||||
},
|
||||
pin,
|
||||
identity,
|
||||
// Headless CLI uses the normal (short) handshake budget; the long request-access wait is a
|
||||
// GUI-only flow.
|
||||
connect_timeout: Duration::from_secs(15),
|
||||
});
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
let mut frames_seen = 0u64;
|
||||
loop {
|
||||
while let Ok(ev) = handle.events.try_recv() {
|
||||
match ev {
|
||||
session::SessionEvent::Connected {
|
||||
mode, fingerprint, ..
|
||||
} => tracing::info!(?mode, fp = %trust::hex(&fingerprint), "connected"),
|
||||
// With per-AU 0xCF host timings the combined host+network stage splits into
|
||||
// host (capture→sent on the host) + net; an old host emits none → combined only.
|
||||
session::SessionEvent::Stats(s) if s.split => tracing::info!(
|
||||
fps = format!("{:.0}", s.fps),
|
||||
mbps = format!("{:.1}", s.mbps),
|
||||
decode_p50_ms = format!("{:.2}", s.decode_ms),
|
||||
hostnet_p50_ms = format!("{:.2}", s.hostnet_ms),
|
||||
host_p50_ms = format!("{:.2}", s.host_ms),
|
||||
net_p50_ms = format!("{:.2}", s.net_ms),
|
||||
frames_seen,
|
||||
"stats"
|
||||
),
|
||||
session::SessionEvent::Stats(s) => tracing::info!(
|
||||
fps = format!("{:.0}", s.fps),
|
||||
mbps = format!("{:.1}", s.mbps),
|
||||
decode_p50_ms = format!("{:.2}", s.decode_ms),
|
||||
hostnet_p50_ms = format!("{:.2}", s.hostnet_ms),
|
||||
frames_seen,
|
||||
"stats"
|
||||
),
|
||||
session::SessionEvent::Failed { msg, .. } => {
|
||||
tracing::error!(%msg, "connect failed");
|
||||
return;
|
||||
}
|
||||
session::SessionEvent::Ended(err) => {
|
||||
tracing::info!(reason = err.as_deref().unwrap_or("done"), "session ended");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
while handle.frames.try_recv().is_ok() {
|
||||
frames_seen += 1;
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
tracing::info!(frames_seen, "harness deadline — stopping");
|
||||
handle.stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
// Only --speed-test remains headless: real streaming runs in the windowed app's spawned
|
||||
// punktfunk-session binary, which the deleted in-process frame-count path was replaced by.
|
||||
eprintln!("--headless supports only --speed-test now \u{2014} run the windowed app to stream");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
/// `--discover`: browse the LAN for punktfunk hosts (mDNS) and print them, then exit.
|
||||
|
||||
@@ -1,915 +0,0 @@
|
||||
//! Direct3D11 presenter for a WinUI 3 `SwapChainPanel`. It draws a decoded frame Contain-fit into a
|
||||
//! **composition** flip-model swapchain, which the reactor stream page binds to the panel via
|
||||
//! `SwapChainPanelHandle::set_swap_chain`. After that one UI-thread bind, the presenter lives on
|
||||
//! the dedicated render thread ([`crate::render`]) — presenting never touches (or is stalled by)
|
||||
//! the XAML thread.
|
||||
//!
|
||||
//! Two frame sources, ONE Y′CbCr→RGB shader whose conversion rows arrive per frame in a constant
|
||||
//! buffer (`pf_client_core::video::csc_rows` from the frame's CICP signaling — identical colour
|
||||
//! math for both sources, and the stream's signaled matrix/range is honored, not assumed):
|
||||
//!
|
||||
//! * **GPU (D3D11VA)** — [`crate::video::GpuFrame`] is a slice of the decoder-only NV12/P010
|
||||
//! texture array. One `CopySubresourceRegion` with a display-size box moves the slice — **both
|
||||
//! planes; in D3D11 a planar slice is a single subresource** (unlike D3D12) — into our
|
||||
//! sampleable texture, which per-plane SRVs (R8/R8G8, R16/R16G16) expose to the shaders. The
|
||||
//! source box is mandatory: the decode array is coded-size (e.g. 1920×1088), the target
|
||||
//! display-size (1920×1080), and D3D11 silently drops size-mismatched full-resource copies.
|
||||
//! * **CPU upload** — [`crate::video::CpuFrame`] carries NV12/P010 planes from the software
|
||||
//! decoder; they upload into two dynamic plane textures feeding the same SRV slots/shaders.
|
||||
//!
|
||||
//! **Pacing**: the swapchain is created with `DXGI_SWAP_CHAIN_FLAG_FRAME_LATENCY_WAITABLE_OBJECT`
|
||||
//! and `SetMaximumFrameLatency(1)` (flagless fallback for odd drivers). The render thread waits
|
||||
//! on the latency waitable before drawing, so at most one present is ever queued (minimum compose
|
||||
//! latency) and a stream faster than the display drops frames *before* any GPU work. Every
|
||||
//! `ResizeBuffers` must re-pass the creation flags — that's `swap_flags`.
|
||||
//!
|
||||
//! **HiDPI**: buffers are sized in physical pixels and `IDXGISwapChain2::SetMatrixTransform`
|
||||
//! (scale 96/DPI) maps them to the panel's DIP coordinate space — without it XAML samples a
|
||||
//! DIP-sized buffer up and the video is blurry at 125/150 % scaling.
|
||||
//!
|
||||
//! **HDR10**: when a frame is BT.2020 PQ the swapchain flips to `R10G10B10A2` +
|
||||
//! `DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020` (+ HDR10 metadata) via `ResizeBuffers`/
|
||||
//! `SetColorSpace1`; the shader output is already PQ-encoded so the compositor maps PQ→display. SDR
|
||||
//! stays 8-bit B8G8R8A8.
|
||||
//!
|
||||
//! All `windows` types here come from the same windows-rs commit as `windows-reactor`, so the
|
||||
//! `IDXGISwapChain1` handed to `set_swap_chain` satisfies reactor's `windows_core::Interface`.
|
||||
|
||||
use crate::video::{CpuFrame, DecodedFrame, GpuFrame};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use windows::core::{Interface, PCSTR};
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE, WAIT_OBJECT_0};
|
||||
use windows::Win32::Graphics::Direct3D::Fxc::{D3DCompile, D3DCOMPILE_OPTIMIZATION_LEVEL3};
|
||||
use windows::Win32::Graphics::Direct3D::{
|
||||
ID3DBlob, D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST, D3D_SRV_DIMENSION_TEXTURE2D,
|
||||
};
|
||||
use windows::Win32::Graphics::Direct3D11::*;
|
||||
use windows::Win32::Graphics::Dxgi::Common::*;
|
||||
use windows::Win32::Graphics::Dxgi::*;
|
||||
use windows::Win32::System::Threading::WaitForSingleObject;
|
||||
|
||||
// One vertex shader (fullscreen triangle) + ONE pixel shader for every colour combination:
|
||||
// tex0 is the luma plane, tex1 the chroma plane, and the Y′CbCr→RGB conversion arrives as three
|
||||
// constant-buffer rows precomputed on the CPU per frame (`pf_client_core::video::csc_rows` —
|
||||
// bit-depth exact, range expansion + the P010 ×65535/65472 high-bit repack folded in). One shader
|
||||
// honors whatever the stream signals (BT.601/709/2020, full/limited, 8/10-bit) instead of the old
|
||||
// two hardcoded matrices — a BT.601-signaled stream (a Linux host's RGB-input NVENC) used to
|
||||
// render with BT.709 coefficients, a constant hue error. A PQ stream's rows yield PQ-encoded
|
||||
// R′G′B′ passed through as-is to the HDR10 swapchain, exactly as before.
|
||||
const SHADER_HLSL: &str = r#"
|
||||
struct VSOut { float4 pos : SV_Position; float2 uv : TEXCOORD0; };
|
||||
VSOut vs_main(uint vid : SV_VertexID) {
|
||||
float2 uv = float2((vid << 1) & 2, vid & 2);
|
||||
VSOut o;
|
||||
o.pos = float4(uv * float2(2, -2) + float2(-1, 1), 0, 1);
|
||||
o.uv = uv;
|
||||
return o;
|
||||
}
|
||||
Texture2D tex0 : register(t0);
|
||||
Texture2D tex1 : register(t1);
|
||||
SamplerState smp : register(s0);
|
||||
cbuffer Csc : register(b0) {
|
||||
float4 r0; // rgb[i] = dot(ri.xyz, yuv) + ri.w
|
||||
float4 r1;
|
||||
float4 r2;
|
||||
};
|
||||
|
||||
float4 ps_yuv(VSOut i) : SV_Target {
|
||||
// 4:2:0 chroma is left-cosited (H.273 type 0 — the default inference when unsignaled, and
|
||||
// what the hosts produce), but sampling the half-res plane at the luma UV assumes CENTER
|
||||
// siting — a ~0.5-luma-px rightward chroma shift on hard colored edges. Offset +0.25 chroma
|
||||
// texels to re-align (the same correction the Apple client applies). Self-disables when the
|
||||
// plane widths match (a full-size 4:4:4 chroma plane has no subsampling to correct).
|
||||
float lw, lh, cw, ch;
|
||||
tex0.GetDimensions(lw, lh);
|
||||
tex1.GetDimensions(cw, ch);
|
||||
float2 cuv = i.uv;
|
||||
if (cw < lw) { cuv.x += 0.25 / cw; }
|
||||
float3 yuv = float3(tex0.Sample(smp, i.uv).r, tex1.Sample(smp, cuv).rg);
|
||||
float3 rgb = float3(dot(r0.xyz, yuv) + r0.w,
|
||||
dot(r1.xyz, yuv) + r1.w,
|
||||
dot(r2.xyz, yuv) + r2.w);
|
||||
return float4(saturate(rgb), 1.0);
|
||||
}
|
||||
"#;
|
||||
|
||||
/// The currently bound frame: per-plane SRVs (over the GPU sample texture or the CPU plane
|
||||
/// textures). Redraws (resize, letterbox) re-present it — the CSC constant buffer still holds
|
||||
/// this frame's rows, and the swapchain mode was latched by `set_hdr` when the frame arrived.
|
||||
struct Bound {
|
||||
y: ID3D11ShaderResourceView,
|
||||
c: ID3D11ShaderResourceView,
|
||||
}
|
||||
|
||||
pub struct Presenter {
|
||||
device: ID3D11Device,
|
||||
context: ID3D11DeviceContext,
|
||||
vs: ID3D11VertexShader,
|
||||
ps_yuv: ID3D11PixelShader,
|
||||
/// Dynamic constant buffer holding the bound frame's three CSC rows (`csc_rows`), rewritten
|
||||
/// on every bind (colour signaling can flip in-band, e.g. the host's SDR→HDR re-init).
|
||||
csc_buf: ID3D11Buffer,
|
||||
sampler: ID3D11SamplerState,
|
||||
swap: IDXGISwapChain1,
|
||||
/// Creation flags — MUST be re-passed to every `ResizeBuffers` or it fails.
|
||||
swap_flags: u32,
|
||||
/// The frame-latency waitable (owned; closed in `Drop`), `None` on the flagless fallback.
|
||||
waitable: Option<HANDLE>,
|
||||
rtv: Option<ID3D11RenderTargetView>,
|
||||
/// GPU path: sampleable copy target for the decoded slice — `(tex, w, h, ten_bit)`, recreated
|
||||
/// when the decoded size/bit depth changes. Format must equal the decode array's (NV12/P010).
|
||||
sample_tex: Option<(ID3D11Texture2D, u32, u32, bool)>,
|
||||
/// The last GPU frame, held until the NEXT bind so its decode surface stays out of the reuse
|
||||
/// pool at least until this frame's copy has been queued ahead of any later decoder write.
|
||||
gpu_frame: Option<GpuFrame>,
|
||||
/// CPU path: dynamic luma + chroma plane textures + their SRVs — `(y, uv, y_srv, uv_srv, w, h,
|
||||
/// ten_bit)`, recreated when the decoded size/bit depth changes.
|
||||
#[allow(clippy::type_complexity)]
|
||||
plane_tex: Option<(
|
||||
ID3D11Texture2D,
|
||||
ID3D11Texture2D,
|
||||
ID3D11ShaderResourceView,
|
||||
ID3D11ShaderResourceView,
|
||||
u32,
|
||||
u32,
|
||||
bool,
|
||||
)>,
|
||||
bound: Option<Bound>,
|
||||
/// Source frame dimensions, for the Contain-fit letterbox.
|
||||
src_w: u32,
|
||||
src_h: u32,
|
||||
/// Panel (swapchain) size in physical pixels + the window DPI, updated on resize.
|
||||
panel_w: u32,
|
||||
panel_h: u32,
|
||||
dpi: u32,
|
||||
/// Whether the swapchain is currently in 10-bit HDR10 (R10G10B10A2 + ST.2084) mode.
|
||||
hdr: bool,
|
||||
/// The source's static HDR mastering metadata received over the protocol (`0xCE`), applied via
|
||||
/// `SetHDRMetaData` so the display tone-maps from the real grade instead of a generic 1000-nit
|
||||
/// guess. `None` until the first update arrives (then the generic baseline is used).
|
||||
hdr_meta: Option<punktfunk_core::quic::HdrMeta>,
|
||||
}
|
||||
|
||||
/// Latest source HDR mastering metadata, written by the session pump (`session.rs`, the sole
|
||||
/// `next_hdr_meta` consumer) and read by the render thread before each present — decoupled so the
|
||||
/// presenter doesn't need the connector. One session at a time on the client, so a single slot.
|
||||
pub static LATEST_HDR_META: std::sync::Mutex<Option<punktfunk_core::quic::HdrMeta>> =
|
||||
std::sync::Mutex::new(None);
|
||||
|
||||
impl Presenter {
|
||||
/// Create the presenter on the process-wide shared D3D11 device (the one the decoder uses), plus
|
||||
/// the composition swapchain + shaders, sized to the panel in physical pixels at `dpi`.
|
||||
pub fn new(width: u32, height: u32, dpi: u32) -> Result<Presenter> {
|
||||
let shared = crate::gpu::shared().ok_or_else(|| anyhow!("no shared D3D11 device"))?;
|
||||
let device = shared.device.clone();
|
||||
let context = shared.context.clone();
|
||||
let (vs, ps_yuv, sampler) = build_pipeline(&device)?;
|
||||
// The per-frame CSC rows (three float4s). Dynamic: rewritten with Map-discard on bind.
|
||||
let csc_desc = D3D11_BUFFER_DESC {
|
||||
ByteWidth: 48,
|
||||
Usage: D3D11_USAGE_DYNAMIC,
|
||||
BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32,
|
||||
CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let csc_buf = unsafe {
|
||||
let mut b = None;
|
||||
device
|
||||
.CreateBuffer(&csc_desc, None, Some(&mut b))
|
||||
.context("CreateBuffer (CSC rows)")?;
|
||||
b.ok_or_else(|| anyhow!("null CSC constant buffer"))?
|
||||
};
|
||||
let (swap, swap_flags) =
|
||||
create_composition_swapchain(&device, width.max(1), height.max(1))?;
|
||||
// ≤1 queued present: the render thread blocks on the waitable, so a frame is only drawn
|
||||
// when the compositor is ready to take it — the newest-wins drain happens after the wait.
|
||||
let waitable = (swap_flags & DXGI_SWAP_CHAIN_FLAG_FRAME_LATENCY_WAITABLE_OBJECT.0 as u32
|
||||
!= 0)
|
||||
.then(|| unsafe {
|
||||
let sc2: IDXGISwapChain2 = swap.cast().ok()?;
|
||||
sc2.SetMaximumFrameLatency(1).ok()?;
|
||||
let h = sc2.GetFrameLatencyWaitableObject();
|
||||
(!h.is_invalid()).then_some(h)
|
||||
})
|
||||
.flatten();
|
||||
let p = Presenter {
|
||||
device,
|
||||
context,
|
||||
vs,
|
||||
ps_yuv,
|
||||
csc_buf,
|
||||
sampler,
|
||||
swap,
|
||||
swap_flags,
|
||||
waitable,
|
||||
rtv: None,
|
||||
sample_tex: None,
|
||||
gpu_frame: None,
|
||||
plane_tex: None,
|
||||
bound: None,
|
||||
src_w: 1,
|
||||
src_h: 1,
|
||||
panel_w: width.max(1),
|
||||
panel_h: height.max(1),
|
||||
dpi: dpi.max(96),
|
||||
hdr: false,
|
||||
hdr_meta: None,
|
||||
};
|
||||
p.apply_dpi_matrix();
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// Block until the swapchain can take another present (≤ `timeout_ms`). True when a present
|
||||
/// slot is free; also true on the flagless fallback (no throttle available, just present).
|
||||
pub fn wait_present_slot(&self, timeout_ms: u32) -> bool {
|
||||
match self.waitable {
|
||||
Some(h) => unsafe { WaitForSingleObject(h, timeout_ms) == WAIT_OBJECT_0 },
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the source HDR mastering metadata (from the `0xCE` plane). Stored for the next HDR
|
||||
/// swapchain switch, and applied immediately if already presenting HDR. A no-op when unchanged
|
||||
/// (so it's cheap to call every frame from the render loop).
|
||||
pub fn set_hdr_metadata(&mut self, meta: punktfunk_core::quic::HdrMeta) {
|
||||
if self.hdr_meta == Some(meta) {
|
||||
return;
|
||||
}
|
||||
self.hdr_meta = Some(meta);
|
||||
if self.hdr {
|
||||
unsafe { self.apply_hdr_metadata() };
|
||||
}
|
||||
}
|
||||
|
||||
/// The DXGI swapchain to hand to `SwapChainPanelHandle::set_swap_chain`.
|
||||
pub fn swap_chain(&self) -> &IDXGISwapChain1 {
|
||||
&self.swap
|
||||
}
|
||||
|
||||
/// Resize the back buffers to the panel's new size in physical pixels at `dpi` (drops the
|
||||
/// stale RTV, re-applies the DIP↔pixel matrix).
|
||||
pub fn resize(&mut self, width: u32, height: u32, dpi: u32) {
|
||||
let dpi = dpi.max(96);
|
||||
if width == 0
|
||||
|| height == 0
|
||||
|| (width == self.panel_w && height == self.panel_h && dpi == self.dpi)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.rtv = None; // release all back-buffer refs before ResizeBuffers
|
||||
unsafe {
|
||||
if let Err(e) = self.swap.ResizeBuffers(
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
DXGI_FORMAT_UNKNOWN,
|
||||
DXGI_SWAP_CHAIN_FLAG(self.swap_flags as i32),
|
||||
) {
|
||||
tracing::warn!(error = %e, "ResizeBuffers failed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.panel_w = width;
|
||||
self.panel_h = height;
|
||||
self.dpi = dpi;
|
||||
self.apply_dpi_matrix();
|
||||
}
|
||||
|
||||
/// Map the pixel-sized buffers into the panel's DIP coordinate space (scale 96/DPI) — XAML
|
||||
/// otherwise stretches whatever size the buffers are to the panel's DIP bounds (blurry).
|
||||
fn apply_dpi_matrix(&self) {
|
||||
let s = 96.0 / self.dpi as f32;
|
||||
if let Ok(sc2) = self.swap.cast::<IDXGISwapChain2>() {
|
||||
let m = DXGI_MATRIX_3X2_F {
|
||||
_11: s,
|
||||
_22: s,
|
||||
..Default::default()
|
||||
};
|
||||
if let Err(e) = unsafe { sc2.SetMatrixTransform(&m) } {
|
||||
tracing::warn!(error = %e, "SetMatrixTransform failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Present one decoded frame (Contain-fit) — or, when `frame` is `None`, re-present the last
|
||||
/// one (or black). Called from the render thread. Takes the frame by value: the GPU path
|
||||
/// retains the decoder surface until the next bind.
|
||||
pub fn present(&mut self, frame: Option<DecodedFrame>) {
|
||||
match frame {
|
||||
Some(DecodedFrame::Cpu(c)) => {
|
||||
if c.hdr != self.hdr {
|
||||
self.set_hdr(c.hdr);
|
||||
}
|
||||
if let Err(e) = self.upload(&c) {
|
||||
tracing::warn!(error = %e, "frame upload failed");
|
||||
}
|
||||
}
|
||||
Some(DecodedFrame::Gpu(g)) => {
|
||||
if g.hdr != self.hdr {
|
||||
self.set_hdr(g.hdr);
|
||||
}
|
||||
if let Err(e) = self.bind_gpu(g) {
|
||||
tracing::warn!(error = %e, "GPU frame bind failed");
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
self.draw();
|
||||
}
|
||||
|
||||
/// Copy the decoded slice into our sampleable texture and build per-plane SRVs over it. The
|
||||
/// decode array is decoder-only (NVIDIA won't bind a decoder array as a shader resource), so
|
||||
/// it can't be sampled directly — one GPU-to-GPU copy makes the frame sampleable on every
|
||||
/// vendor. D3D11 planar semantics: the slice is ONE subresource (both planes copy together),
|
||||
/// and the source box is display-size (the array is coded-size; a full-resource copy would
|
||||
/// size-mismatch and be silently dropped).
|
||||
fn bind_gpu(&mut self, g: GpuFrame) -> Result<()> {
|
||||
let src: ID3D11Texture2D = unsafe {
|
||||
let raw = g.texture_ptr();
|
||||
ID3D11Texture2D::from_raw_borrowed(&raw)
|
||||
.ok_or_else(|| anyhow!("null D3D11 texture"))?
|
||||
.clone()
|
||||
};
|
||||
self.ensure_sample_tex(g.width, g.height, g.ten_bit)?;
|
||||
let dst = self.sample_tex.as_ref().unwrap().0.clone();
|
||||
// Even-aligned luma coordinates (NV12/P010 chroma is 2×2 subsampled).
|
||||
let src_box = D3D11_BOX {
|
||||
left: 0,
|
||||
top: 0,
|
||||
front: 0,
|
||||
right: g.width & !1,
|
||||
bottom: g.height & !1,
|
||||
back: 1,
|
||||
};
|
||||
unsafe {
|
||||
self.context
|
||||
.CopySubresourceRegion(&dst, 0, 0, 0, 0, &src, g.index, Some(&src_box));
|
||||
}
|
||||
let (fy, fc) = plane_formats(g.ten_bit);
|
||||
let y = self.plane_srv(&dst, fy)?;
|
||||
let c = self.plane_srv(&dst, fc)?;
|
||||
self.write_csc_rows(g.color, g.ten_bit)?;
|
||||
self.src_w = g.width;
|
||||
self.src_h = g.height;
|
||||
self.bound = Some(Bound { y, c });
|
||||
// Hold the frame until the next bind: its decode surface stays out of the reuse pool
|
||||
// until this copy is queued ahead of any later decoder write (previous frame drops here).
|
||||
self.gpu_frame = Some(g);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure the sampleable copy texture matches the decoded frame's size + bit depth (NV12 for
|
||||
/// 8-bit, P010 for 10-bit — the same format as the decode array, a `CopySubresourceRegion`
|
||||
/// requirement), recreating it on a change.
|
||||
fn ensure_sample_tex(&mut self, w: u32, h: u32, ten_bit: bool) -> Result<()> {
|
||||
if matches!(&self.sample_tex, Some((_, tw, th, tb)) if *tw == w && *th == h && *tb == ten_bit)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: w,
|
||||
Height: h,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: if ten_bit {
|
||||
DXGI_FORMAT_P010
|
||||
} else {
|
||||
DXGI_FORMAT_NV12
|
||||
},
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
||||
CPUAccessFlags: 0,
|
||||
MiscFlags: 0,
|
||||
};
|
||||
let tex = unsafe {
|
||||
let mut t = None;
|
||||
self.device
|
||||
.CreateTexture2D(&desc, None, Some(&mut t))
|
||||
.context("CreateTexture2D (sample target)")?;
|
||||
t.ok_or_else(|| anyhow!("null sample texture"))?
|
||||
};
|
||||
self.sample_tex = Some((tex, w, h, ten_bit));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A shader-resource view over one plane of a single (non-array) NV12/P010 texture — the
|
||||
/// R8/R8G8 (or R16/R16G16) format selects the luma vs. chroma plane (the D3D11 video
|
||||
/// sub-format trick).
|
||||
fn plane_srv(
|
||||
&self,
|
||||
tex: &ID3D11Texture2D,
|
||||
format: DXGI_FORMAT,
|
||||
) -> Result<ID3D11ShaderResourceView> {
|
||||
let desc = D3D11_SHADER_RESOURCE_VIEW_DESC {
|
||||
Format: format,
|
||||
ViewDimension: D3D_SRV_DIMENSION_TEXTURE2D,
|
||||
Anonymous: D3D11_SHADER_RESOURCE_VIEW_DESC_0 {
|
||||
Texture2D: D3D11_TEX2D_SRV {
|
||||
MostDetailedMip: 0,
|
||||
MipLevels: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
unsafe {
|
||||
let mut srv = None;
|
||||
self.device
|
||||
.CreateShaderResourceView(tex, Some(&desc), Some(&mut srv))
|
||||
.context("CreateShaderResourceView (plane)")?;
|
||||
srv.ok_or_else(|| anyhow!("null SRV"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload a software-decoded frame's two planes into the dynamic plane textures (created to
|
||||
/// match size/bit depth), feeding the same SRV slots + shaders as the GPU path.
|
||||
fn upload(&mut self, frame: &CpuFrame) -> Result<()> {
|
||||
let (w, h) = (frame.width, frame.height);
|
||||
let rebuild = !matches!(&self.plane_tex,
|
||||
Some((.., tw, th, tb)) if *tw == w && *th == h && *tb == frame.ten_bit);
|
||||
if rebuild {
|
||||
let (fy, fc) = plane_formats(frame.ten_bit);
|
||||
let y = self.dynamic_tex(w, h, fy)?;
|
||||
let uv = self.dynamic_tex(w.div_ceil(2), h.div_ceil(2), fc)?;
|
||||
let y_srv = self.plane_srv(&y, fy)?;
|
||||
let uv_srv = self.plane_srv(&uv, fc)?;
|
||||
self.plane_tex = Some((y, uv, y_srv, uv_srv, w, h, frame.ten_bit));
|
||||
}
|
||||
let (y, uv, y_srv, uv_srv, ..) = self.plane_tex.as_ref().unwrap();
|
||||
let bytes = if frame.ten_bit { 2 } else { 1 };
|
||||
self.map_rows(y, &frame.y, frame.y_stride, w as usize * bytes, h as usize)?;
|
||||
self.map_rows(
|
||||
uv,
|
||||
&frame.uv,
|
||||
frame.uv_stride,
|
||||
w.div_ceil(2) as usize * 2 * bytes,
|
||||
h.div_ceil(2) as usize,
|
||||
)?;
|
||||
let (y_srv, uv_srv) = (y_srv.clone(), uv_srv.clone());
|
||||
self.write_csc_rows(frame.color, frame.ten_bit)?;
|
||||
self.src_w = w;
|
||||
self.src_h = h;
|
||||
self.bound = Some(Bound {
|
||||
y: y_srv,
|
||||
c: uv_srv,
|
||||
});
|
||||
self.gpu_frame = None; // drop any held GPU frame
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dynamic_tex(&self, w: u32, h: u32, format: DXGI_FORMAT) -> Result<ID3D11Texture2D> {
|
||||
let desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: w,
|
||||
Height: h,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: format,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DYNAMIC,
|
||||
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
||||
CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
|
||||
MiscFlags: 0,
|
||||
};
|
||||
unsafe {
|
||||
let mut t = None;
|
||||
self.device
|
||||
.CreateTexture2D(&desc, None, Some(&mut t))
|
||||
.context("CreateTexture2D (plane)")?;
|
||||
t.ok_or_else(|| anyhow!("null plane texture"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Recompute the bound frame's Y′CbCr→RGB rows from its CICP signaling and Map-discard them
|
||||
/// into the CSC constant buffer. `ten_bit` selects the 10-bit code points AND the P010
|
||||
/// high-bit repack (the plane SRVs are R16/R16G16 UNORM for 10-bit).
|
||||
fn write_csc_rows(&self, color: pf_client_core::video::ColorDesc, ten_bit: bool) -> Result<()> {
|
||||
let rows = pf_client_core::video::csc_rows(color, if ten_bit { 10 } else { 8 }, ten_bit);
|
||||
unsafe {
|
||||
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
|
||||
self.context
|
||||
.Map(
|
||||
&self.csc_buf,
|
||||
0,
|
||||
D3D11_MAP_WRITE_DISCARD,
|
||||
0,
|
||||
Some(&mut mapped),
|
||||
)
|
||||
.context("Map CSC constant buffer")?;
|
||||
std::ptr::copy_nonoverlapping(
|
||||
rows.as_ptr() as *const u8,
|
||||
mapped.pData as *mut u8,
|
||||
48, // [[f32; 4]; 3]
|
||||
);
|
||||
self.context.Unmap(&self.csc_buf, 0);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Map-discard `tex` and copy `rows` rows of `row_bytes` from `src` (stride `src_pitch`).
|
||||
fn map_rows(
|
||||
&self,
|
||||
tex: &ID3D11Texture2D,
|
||||
src: &[u8],
|
||||
src_pitch: usize,
|
||||
row_bytes: usize,
|
||||
rows: usize,
|
||||
) -> Result<()> {
|
||||
unsafe {
|
||||
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
|
||||
self.context
|
||||
.Map(tex, 0, D3D11_MAP_WRITE_DISCARD, 0, Some(&mut mapped))
|
||||
.context("Map plane texture")?;
|
||||
let dst = mapped.pData as *mut u8;
|
||||
let dst_pitch = mapped.RowPitch as usize;
|
||||
let n = row_bytes.min(src_pitch);
|
||||
for r in 0..rows {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
src.as_ptr().add(r * src_pitch),
|
||||
dst.add(r * dst_pitch),
|
||||
n,
|
||||
);
|
||||
}
|
||||
self.context.Unmap(tex, 0);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn draw(&mut self) {
|
||||
let Ok(rtv) = self.rtv() else {
|
||||
return;
|
||||
};
|
||||
let (pw, ph) = (self.panel_w, self.panel_h);
|
||||
unsafe {
|
||||
let c = &self.context;
|
||||
c.ClearRenderTargetView(&rtv, &[0.0, 0.0, 0.0, 1.0]);
|
||||
if let Some(bound) = &self.bound {
|
||||
// Contain-fit viewport: scale to the smaller axis, centre, letterbox the rest.
|
||||
let (ww, wh, vfw, vfh) = (
|
||||
pw as f32,
|
||||
ph as f32,
|
||||
self.src_w.max(1) as f32,
|
||||
self.src_h.max(1) as f32,
|
||||
);
|
||||
let scale = (ww / vfw).min(wh / vfh);
|
||||
let (dw, dh) = (vfw * scale, vfh * scale);
|
||||
let (ox, oy) = ((ww - dw) / 2.0, (wh - dh) / 2.0);
|
||||
c.OMSetRenderTargets(Some(&[Some(rtv.clone())]), None);
|
||||
let vp = D3D11_VIEWPORT {
|
||||
TopLeftX: ox,
|
||||
TopLeftY: oy,
|
||||
Width: dw,
|
||||
Height: dh,
|
||||
MinDepth: 0.0,
|
||||
MaxDepth: 1.0,
|
||||
};
|
||||
c.RSSetViewports(Some(&[vp]));
|
||||
c.IASetInputLayout(None);
|
||||
c.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
|
||||
c.VSSetShader(&self.vs, None);
|
||||
c.PSSetShader(&self.ps_yuv, None);
|
||||
c.PSSetConstantBuffers(0, Some(&[Some(self.csc_buf.clone())]));
|
||||
c.PSSetShaderResources(0, Some(&[Some(bound.y.clone()), Some(bound.c.clone())]));
|
||||
c.PSSetSamplers(0, Some(&[Some(self.sampler.clone())]));
|
||||
c.Draw(3, 0);
|
||||
}
|
||||
let _ = self.swap.Present(1, DXGI_PRESENT(0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch the swapchain between 8-bit SDR (B8G8R8A8, BT.709) and 10-bit HDR10 (R10G10B10A2,
|
||||
/// ST.2084 PQ BT.2020). `ResizeBuffers` changes the back-buffer format in place, so the panel
|
||||
/// binding (`set_swap_chain`) stays valid — no rebind. Both frame sources already produce
|
||||
/// PQ-encoded BT.2020 for HDR, so the colour space is all the compositor needs.
|
||||
fn set_hdr(&mut self, on: bool) {
|
||||
self.rtv = None; // release back-buffer refs before ResizeBuffers
|
||||
let format = if on {
|
||||
DXGI_FORMAT_R10G10B10A2_UNORM
|
||||
} else {
|
||||
DXGI_FORMAT_B8G8R8A8_UNORM
|
||||
};
|
||||
unsafe {
|
||||
if let Err(e) = self.swap.ResizeBuffers(
|
||||
0,
|
||||
self.panel_w,
|
||||
self.panel_h,
|
||||
format,
|
||||
DXGI_SWAP_CHAIN_FLAG(self.swap_flags as i32),
|
||||
) {
|
||||
tracing::warn!(error = %e, "ResizeBuffers for HDR switch failed");
|
||||
return;
|
||||
}
|
||||
let colorspace = if on {
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020
|
||||
} else {
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709
|
||||
};
|
||||
if let Ok(sc3) = self.swap.cast::<IDXGISwapChain3>() {
|
||||
// Only set a colour space the swapchain accepts for present (on an SDR desktop the
|
||||
// DWM still tone-maps HDR10 → SDR, so leaving the default there is fine).
|
||||
if let Ok(support) = sc3.CheckColorSpaceSupport(colorspace) {
|
||||
if support & DXGI_SWAP_CHAIN_COLOR_SPACE_SUPPORT_FLAG_PRESENT.0 as u32 != 0 {
|
||||
if let Err(e) = sc3.SetColorSpace1(colorspace) {
|
||||
// A silent failure here presents PQ content as SDR gamma (crushed/dark) —
|
||||
// surface it instead of swallowing it.
|
||||
tracing::warn!(error = %e, ?colorspace, "SetColorSpace1 failed");
|
||||
}
|
||||
} else if on {
|
||||
tracing::warn!("swapchain rejects BT.2020 PQ present colour space (SDR display?) — DWM tone-maps");
|
||||
}
|
||||
}
|
||||
}
|
||||
self.hdr = on;
|
||||
if on {
|
||||
self.apply_hdr_metadata();
|
||||
}
|
||||
}
|
||||
self.apply_dpi_matrix(); // belt-and-braces: keep the DIP mapping across the format switch
|
||||
tracing::info!(hdr = on, "swapchain colour mode switched");
|
||||
}
|
||||
|
||||
/// Push the current `DXGI_HDR_METADATA_HDR10` to the swapchain. Uses the source's received
|
||||
/// mastering metadata when known, else a generic HDR10 baseline. Caller ensures HDR mode.
|
||||
unsafe fn apply_hdr_metadata(&self) {
|
||||
if let Ok(sc4) = self.swap.cast::<IDXGISwapChain4>() {
|
||||
let md = self
|
||||
.hdr_meta
|
||||
.map(hdr_meta_to_dxgi)
|
||||
.unwrap_or_else(generic_hdr10_metadata);
|
||||
let bytes = std::slice::from_raw_parts(
|
||||
&md as *const DXGI_HDR_METADATA_HDR10 as *const u8,
|
||||
std::mem::size_of::<DXGI_HDR_METADATA_HDR10>(),
|
||||
);
|
||||
if let Err(e) = sc4.SetHDRMetaData(DXGI_HDR_METADATA_TYPE_HDR10, Some(bytes)) {
|
||||
tracing::warn!(error = %e, "SetHDRMetaData failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rtv(&mut self) -> Result<ID3D11RenderTargetView> {
|
||||
if self.rtv.is_none() {
|
||||
let back: ID3D11Texture2D = unsafe { self.swap.GetBuffer(0).context("GetBuffer")? };
|
||||
let rtv = unsafe {
|
||||
let mut v = None;
|
||||
self.device
|
||||
.CreateRenderTargetView(&back, None, Some(&mut v))
|
||||
.context("CreateRenderTargetView")?;
|
||||
v.unwrap()
|
||||
};
|
||||
self.rtv = Some(rtv);
|
||||
}
|
||||
Ok(self.rtv.clone().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Presenter {
|
||||
fn drop(&mut self) {
|
||||
if let Some(h) = self.waitable.take() {
|
||||
unsafe {
|
||||
let _ = CloseHandle(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Luma + chroma plane view formats for NV12 (8-bit) vs P010 (10-in-16-bit).
|
||||
fn plane_formats(ten_bit: bool) -> (DXGI_FORMAT, DXGI_FORMAT) {
|
||||
if ten_bit {
|
||||
(DXGI_FORMAT_R16_UNORM, DXGI_FORMAT_R16G16_UNORM)
|
||||
} else {
|
||||
(DXGI_FORMAT_R8_UNORM, DXGI_FORMAT_R8G8_UNORM)
|
||||
}
|
||||
}
|
||||
|
||||
/// A composition flip-model swapchain (no HWND) for binding to a XAML `SwapChainPanel`, with the
|
||||
/// frame-latency waitable when the driver allows it. Returns the swapchain + the flags it was
|
||||
/// created with (every `ResizeBuffers` must re-pass them).
|
||||
fn create_composition_swapchain(
|
||||
device: &ID3D11Device,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<(IDXGISwapChain1, u32)> {
|
||||
let dxdev: IDXGIDevice = device.cast().context("IDXGIDevice cast")?;
|
||||
let factory: IDXGIFactory2 = unsafe {
|
||||
let adapter = dxdev.GetAdapter().context("GetAdapter")?;
|
||||
adapter.GetParent().context("GetParent (IDXGIFactory2)")?
|
||||
};
|
||||
let mut desc = DXGI_SWAP_CHAIN_DESC1 {
|
||||
Width: width,
|
||||
Height: height,
|
||||
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
Stereo: false.into(),
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT,
|
||||
BufferCount: 2,
|
||||
Scaling: DXGI_SCALING_STRETCH,
|
||||
SwapEffect: DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL,
|
||||
// IGNORE (opaque), not PREMULTIPLIED: the video fills the panel with opaque RGB either way.
|
||||
AlphaMode: DXGI_ALPHA_MODE_IGNORE,
|
||||
Flags: DXGI_SWAP_CHAIN_FLAG_FRAME_LATENCY_WAITABLE_OBJECT.0 as u32,
|
||||
};
|
||||
unsafe {
|
||||
match factory.CreateSwapChainForComposition(device, &desc, None) {
|
||||
Ok(sc) => Ok((sc, desc.Flags)),
|
||||
Err(e) => {
|
||||
// Odd driver/WARP combinations can reject the waitable — fall back to plain
|
||||
// Present(1) pacing rather than failing the stream page.
|
||||
tracing::warn!(error = %e, "waitable swapchain rejected — creating without");
|
||||
desc.Flags = 0;
|
||||
let sc = factory
|
||||
.CreateSwapChainForComposition(device, &desc, None)
|
||||
.context("CreateSwapChainForComposition")?;
|
||||
Ok((sc, 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_pipeline(
|
||||
device: &ID3D11Device,
|
||||
) -> Result<(ID3D11VertexShader, ID3D11PixelShader, ID3D11SamplerState)> {
|
||||
let vs_blob = compile(SHADER_HLSL, "vs_main", "vs_5_0")?;
|
||||
let yuv_blob = compile(SHADER_HLSL, "ps_yuv", "ps_5_0")?;
|
||||
unsafe {
|
||||
let mut vs = None;
|
||||
device
|
||||
.CreateVertexShader(blob_bytes(&vs_blob), None, Some(&mut vs))
|
||||
.context("CreateVertexShader")?;
|
||||
let mut ps_yuv = None;
|
||||
device
|
||||
.CreatePixelShader(blob_bytes(&yuv_blob), None, Some(&mut ps_yuv))
|
||||
.context("CreatePixelShader (yuv)")?;
|
||||
let sdesc = D3D11_SAMPLER_DESC {
|
||||
Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR,
|
||||
AddressU: D3D11_TEXTURE_ADDRESS_CLAMP,
|
||||
AddressV: D3D11_TEXTURE_ADDRESS_CLAMP,
|
||||
AddressW: D3D11_TEXTURE_ADDRESS_CLAMP,
|
||||
MaxLOD: D3D11_FLOAT32_MAX,
|
||||
..Default::default()
|
||||
};
|
||||
let mut sampler = None;
|
||||
device
|
||||
.CreateSamplerState(&sdesc, Some(&mut sampler))
|
||||
.context("CreateSamplerState")?;
|
||||
Ok((vs.unwrap(), ps_yuv.unwrap(), sampler.unwrap()))
|
||||
}
|
||||
}
|
||||
|
||||
fn compile(src: &str, entry: &str, target: &str) -> Result<ID3DBlob> {
|
||||
let entry_c = std::ffi::CString::new(entry).unwrap();
|
||||
let target_c = std::ffi::CString::new(target).unwrap();
|
||||
let mut code = None;
|
||||
let mut errors = None;
|
||||
let r = unsafe {
|
||||
D3DCompile(
|
||||
src.as_ptr() as *const _,
|
||||
src.len(),
|
||||
PCSTR::null(),
|
||||
None,
|
||||
None,
|
||||
PCSTR(entry_c.as_ptr() as *const u8),
|
||||
PCSTR(target_c.as_ptr() as *const u8),
|
||||
D3DCOMPILE_OPTIMIZATION_LEVEL3,
|
||||
0,
|
||||
&mut code,
|
||||
Some(&mut errors),
|
||||
)
|
||||
};
|
||||
if r.is_err() {
|
||||
let msg = errors
|
||||
.as_ref()
|
||||
.map(|b| unsafe {
|
||||
let p = b.GetBufferPointer() as *const u8;
|
||||
let n = b.GetBufferSize();
|
||||
String::from_utf8_lossy(std::slice::from_raw_parts(p, n)).to_string()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
return Err(anyhow!("D3DCompile {entry}: {msg}"));
|
||||
}
|
||||
code.ok_or_else(|| anyhow!("D3DCompile produced no bytecode"))
|
||||
}
|
||||
|
||||
fn blob_bytes(blob: &ID3DBlob) -> &[u8] {
|
||||
unsafe {
|
||||
let p = blob.GetBufferPointer() as *const u8;
|
||||
let n = blob.GetBufferSize();
|
||||
std::slice::from_raw_parts(p, n)
|
||||
}
|
||||
}
|
||||
|
||||
/// True if any attached display is currently in HDR (BT.2020 PQ) mode. The client advertises HDR
|
||||
/// caps only when this holds, so an SDR display gets a proper 8-bit BT.709 stream instead of PQ it
|
||||
/// would mis-tone-map (the washed-out/dark failure); an HDR display self-tone-maps from the
|
||||
/// mastering metadata. Coarse — checks ANY output, not the app's specific monitor; a mid-session
|
||||
/// monitor move to/from HDR is a follow-up (the `Reconfigure` downgrade).
|
||||
pub fn display_supports_hdr() -> bool {
|
||||
unsafe {
|
||||
let factory: IDXGIFactory1 = match CreateDXGIFactory1() {
|
||||
Ok(f) => f,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let mut ai = 0u32;
|
||||
while let Ok(adapter) = factory.EnumAdapters1(ai) {
|
||||
ai += 1;
|
||||
let mut oi = 0u32;
|
||||
while let Ok(output) = adapter.EnumOutputs(oi) {
|
||||
oi += 1;
|
||||
if let Ok(o6) = output.cast::<IDXGIOutput6>() {
|
||||
if let Ok(desc) = o6.GetDesc1() {
|
||||
if desc.ColorSpace == DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The HDR display's colour volume from `IDXGIOutput6::GetDesc1` — the first output currently in
|
||||
/// HDR (BT.2020 PQ) mode, as [`HdrMeta`](punktfunk_core::quic::HdrMeta) for `Hello::display_hdr`.
|
||||
/// The host writes this volume into its virtual display's EDID, so host apps tone-map to THIS
|
||||
/// panel and the PQ stream needs no client-side rescue. Chromaticities come as CIE xy floats
|
||||
/// (×50000 → ST.2086 units, G/B/R order); luminances as nits floats (max ×10000 → 0.0001-cd/m²
|
||||
/// units); `MaxFullFrameLuminance` → MaxFALL (whole nits); MaxCLL stays 0 (a display has no
|
||||
/// content light level). Same ANY-output coarseness as [`display_supports_hdr`] — the session
|
||||
/// gates on that check first, so both look at the same panel in the single-HDR-display case.
|
||||
pub fn display_hdr_volume() -> Option<punktfunk_core::quic::HdrMeta> {
|
||||
let to_2086 = |v: f32| (v * 50000.0).round().clamp(0.0, 65535.0) as u16;
|
||||
unsafe {
|
||||
let factory: IDXGIFactory1 = CreateDXGIFactory1().ok()?;
|
||||
let mut ai = 0u32;
|
||||
while let Ok(adapter) = factory.EnumAdapters1(ai) {
|
||||
ai += 1;
|
||||
let mut oi = 0u32;
|
||||
while let Ok(output) = adapter.EnumOutputs(oi) {
|
||||
oi += 1;
|
||||
let Ok(o6) = output.cast::<IDXGIOutput6>() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(desc) = o6.GetDesc1() else { continue };
|
||||
if desc.ColorSpace != DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020 {
|
||||
continue;
|
||||
}
|
||||
return Some(punktfunk_core::quic::HdrMeta {
|
||||
// ST.2086 order is G, B, R.
|
||||
display_primaries: [
|
||||
[to_2086(desc.GreenPrimary[0]), to_2086(desc.GreenPrimary[1])],
|
||||
[to_2086(desc.BluePrimary[0]), to_2086(desc.BluePrimary[1])],
|
||||
[to_2086(desc.RedPrimary[0]), to_2086(desc.RedPrimary[1])],
|
||||
],
|
||||
white_point: [to_2086(desc.WhitePoint[0]), to_2086(desc.WhitePoint[1])],
|
||||
max_display_mastering_luminance: (desc.MaxLuminance.max(0.0) * 10_000.0).round()
|
||||
as u32,
|
||||
min_display_mastering_luminance: (desc.MinLuminance.max(0.0) * 10_000.0).round()
|
||||
as u32,
|
||||
max_cll: 0,
|
||||
max_fall: desc.MaxFullFrameLuminance.max(0.0).round() as u16,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Generic HDR10 mastering metadata: BT.2020 primaries + D65 white, a 1000-nit mastering display,
|
||||
/// MaxCLL 1000 / MaxFALL 400. The fallback used only until the host's real `0xCE` metadata arrives.
|
||||
fn generic_hdr10_metadata() -> DXGI_HDR_METADATA_HDR10 {
|
||||
DXGI_HDR_METADATA_HDR10 {
|
||||
RedPrimary: [35400, 14600],
|
||||
GreenPrimary: [8500, 39850],
|
||||
BluePrimary: [6550, 2300],
|
||||
WhitePoint: [15635, 16450],
|
||||
MaxMasteringLuminance: 1000,
|
||||
MinMasteringLuminance: 1, // 0.0001-nit units → 0.0001 nits
|
||||
MaxContentLightLevel: 1000,
|
||||
MaxFrameAverageLightLevel: 400,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the protocol's [`HdrMeta`](punktfunk_core::quic::HdrMeta) to `DXGI_HDR_METADATA_HDR10`.
|
||||
/// Two careful conversions: HdrMeta stores primaries in **ST.2086 G,B,R order**, DXGI wants
|
||||
/// **R,G,B**; and HdrMeta mastering luminance is in **0.0001-cd/m² units** while DXGI's
|
||||
/// `MaxMasteringLuminance` is in **whole nits** (MinMasteringLuminance stays 0.0001-nit). Chromaticity
|
||||
/// units (1/50000) and MaxCLL/MaxFALL (nits) match 1:1.
|
||||
fn hdr_meta_to_dxgi(m: punktfunk_core::quic::HdrMeta) -> DXGI_HDR_METADATA_HDR10 {
|
||||
let [g, b, r] = m.display_primaries; // ST.2086 order
|
||||
DXGI_HDR_METADATA_HDR10 {
|
||||
RedPrimary: r,
|
||||
GreenPrimary: g,
|
||||
BluePrimary: b,
|
||||
WhitePoint: m.white_point,
|
||||
MaxMasteringLuminance: m.max_display_mastering_luminance / 10_000, // 0.0001-nit → nit
|
||||
MinMasteringLuminance: m.min_display_mastering_luminance, // already 0.0001-nit
|
||||
MaxContentLightLevel: m.max_cll,
|
||||
MaxFrameAverageLightLevel: m.max_fall,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Network speed-test probe — the GUI's per-host "Test Network Speed…" ([`crate::app`]'s
|
||||
//! speed page) and the `--headless --speed-test` CLI.
|
||||
//!
|
||||
//! Split out of the former in-process session module: the shared spawned-`punktfunk-session`
|
||||
//! binary owns real streaming now, but the speed test is a shell-side, decode-less measurement
|
||||
//! over the real data plane, so it stays here. [`decodable_codecs`] rode along for the same
|
||||
//! reason — the probe connect still advertises which codecs this client can decode.
|
||||
|
||||
use ffmpeg_next as ffmpeg;
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The `quic` codec bitfield this client can decode — whatever FFmpeg has a decoder for (HEVC/H.264
|
||||
/// always; AV1 when built in). Advertised to the host so it never emits a codec we can't decode.
|
||||
pub fn decodable_codecs() -> u8 {
|
||||
let _ = ffmpeg::init();
|
||||
let mut bits = 0u8;
|
||||
for (id, bit) in [
|
||||
(ffmpeg::codec::Id::HEVC, punktfunk_core::quic::CODEC_HEVC),
|
||||
(ffmpeg::codec::Id::H264, punktfunk_core::quic::CODEC_H264),
|
||||
(ffmpeg::codec::Id::AV1, punktfunk_core::quic::CODEC_AV1),
|
||||
] {
|
||||
if ffmpeg::decoder::find(id).is_some() {
|
||||
bits |= bit;
|
||||
}
|
||||
}
|
||||
bits
|
||||
}
|
||||
|
||||
/// Blocking speed-test probe (the GUI's per-host "Test" and the `--headless --speed-test` CLI):
|
||||
/// a minimal identified connect (720p60 — the host builds a virtual output, but nothing is
|
||||
/// decoded), then `request_probe` (a 2 s burst up to the host's 3 Gbps ceiling) polled to
|
||||
/// completion. Run on a worker thread.
|
||||
pub fn run_speed_probe(
|
||||
addr: &str,
|
||||
port: u16,
|
||||
fp_hex: Option<&str>,
|
||||
identity: (String, String),
|
||||
) -> Result<punktfunk_core::client::ProbeOutcome, String> {
|
||||
// Pin the saved/advertised fingerprint when we have one; a manual host measures over TOFU.
|
||||
let pin = fp_hex.and_then(crate::trust::parse_hex32);
|
||||
let c = NativeClient::connect(
|
||||
addr,
|
||||
port,
|
||||
Mode {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
refresh_hz: 60,
|
||||
},
|
||||
CompositorPref::Auto,
|
||||
GamepadPref::Auto,
|
||||
0, // bitrate_kbps: host default
|
||||
0, // video_caps: probe connect, nothing is decoded
|
||||
2, // audio_channels: stereo baseline
|
||||
decodable_codecs(),
|
||||
0, // preferred_codec: no preference
|
||||
None, // display_hdr: probe connect, nothing presents
|
||||
None, // launch: no game
|
||||
pin,
|
||||
Some(identity),
|
||||
Duration::from_secs(15),
|
||||
)
|
||||
.map_err(|e| format!("connect: {e:?}"))?;
|
||||
c.request_probe(3_000_000, 2_000)
|
||||
.map_err(|e| format!("probe: {e:?}"))?;
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
if c.probe_result().done {
|
||||
// Let the last UDP shards land before tearing down.
|
||||
std::thread::sleep(Duration::from_millis(400));
|
||||
return Ok(c.probe_result());
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
return Err("probe timed out".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
//! The dedicated video render thread: decoded frames flow session pump → bounded channel → here →
|
||||
//! `Presenter::present`. Presenting off the XAML thread means UI jank (layout, input, dialogs)
|
||||
//! never stalls video, and a filled present queue never blocks the UI thread — the two failure
|
||||
//! modes of the old present-from-`on_rendering` design.
|
||||
//!
|
||||
//! Pacing: block on the channel (the host paces the stream), then on the swapchain's
|
||||
//! frame-latency waitable (≤1 queued present — see `present.rs`), then drain to the NEWEST frame
|
||||
//! so a stream faster than the display drops backlog before any GPU work. The UI thread only
|
||||
//! writes panel size/DPI into [`RenderShared`] atomics; the loop applies them before the next
|
||||
//! draw (and redraws the held frame after a resize — fresh back buffers are blank).
|
||||
|
||||
use crate::present::Presenter;
|
||||
use crate::session::{FrameRx, FrameTimes};
|
||||
use crossbeam_channel::RecvTimeoutError;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The last 1-second render window, published for the HUD (one render thread at a time):
|
||||
/// presents/s, frames skipped by the newest-wins drain, the end-to-end (capture→on-glass)
|
||||
/// p50/p95 and the `display` stage (decoded→displayed) p50, all stamped post-`Present()`, in µs.
|
||||
/// Zeroed when a render thread starts so a new session never shows the previous one's numbers.
|
||||
static PRESENT_FPS: AtomicU32 = AtomicU32::new(0);
|
||||
static PRESENT_SKIPPED: AtomicU32 = AtomicU32::new(0);
|
||||
static E2E_P50_US: AtomicU64 = AtomicU64::new(0);
|
||||
static E2E_P95_US: AtomicU64 = AtomicU64::new(0);
|
||||
static DISPLAY_P50_US: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// The last render window's glass-side numbers (see the statics above) — the HUD's headline
|
||||
/// (end-to-end) and trailing stage (display) come from here.
|
||||
#[derive(Clone, Copy, Default, PartialEq)]
|
||||
pub struct PresentStats {
|
||||
/// Presents per second (includes resize redraws of a held frame).
|
||||
pub fps: u32,
|
||||
/// Frames dropped by the newest-wins drain this window (client-side pacing skips).
|
||||
pub skipped: u32,
|
||||
/// End-to-end capture→displayed p50, ms (host-clock corrected, measured directly).
|
||||
pub e2e_p50_ms: f32,
|
||||
/// End-to-end capture→displayed p95, ms.
|
||||
pub e2e_p95_ms: f32,
|
||||
/// `display` stage p50, ms: decoded → displayed, single-clock client-local.
|
||||
pub display_p50_ms: f32,
|
||||
}
|
||||
|
||||
pub fn present_stats() -> PresentStats {
|
||||
PresentStats {
|
||||
fps: PRESENT_FPS.load(Ordering::Relaxed),
|
||||
skipped: PRESENT_SKIPPED.load(Ordering::Relaxed),
|
||||
e2e_p50_ms: E2E_P50_US.load(Ordering::Relaxed) as f32 / 1000.0,
|
||||
e2e_p95_ms: E2E_P95_US.load(Ordering::Relaxed) as f32 / 1000.0,
|
||||
display_p50_ms: DISPLAY_P50_US.load(Ordering::Relaxed) as f32 / 1000.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// UI-thread → render-thread state. Size is packed into ONE atomic (w<<32|h) so a resize never
|
||||
/// tears into a (new-width, old-height) pair.
|
||||
pub struct RenderShared {
|
||||
size_px: AtomicU64,
|
||||
dpi: AtomicU32,
|
||||
stop: AtomicBool,
|
||||
}
|
||||
|
||||
impl RenderShared {
|
||||
pub fn new(width: u32, height: u32, dpi: u32) -> Arc<RenderShared> {
|
||||
Arc::new(RenderShared {
|
||||
size_px: AtomicU64::new(pack(width, height)),
|
||||
dpi: AtomicU32::new(dpi),
|
||||
stop: AtomicBool::new(false),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_size(&self, width: u32, height: u32) {
|
||||
self.size_px.store(pack(width, height), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn set_dpi(&self, dpi: u32) {
|
||||
self.dpi.store(dpi, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> (u32, u32, u32) {
|
||||
let s = self.size_px.load(Ordering::Relaxed);
|
||||
((s >> 32) as u32, s as u32, self.dpi.load(Ordering::Relaxed))
|
||||
}
|
||||
}
|
||||
|
||||
fn pack(w: u32, h: u32) -> u64 {
|
||||
((w as u64) << 32) | h as u64
|
||||
}
|
||||
|
||||
/// Handle owned by the stream page; stops + joins the thread on unmount (and on drop, so a
|
||||
/// navigation away can't leak a presenting thread).
|
||||
pub struct RenderThread {
|
||||
shared: Arc<RenderShared>,
|
||||
join: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl RenderThread {
|
||||
pub fn shared(&self) -> &Arc<RenderShared> {
|
||||
&self.shared
|
||||
}
|
||||
|
||||
pub fn stop_and_join(&mut self) {
|
||||
self.shared.stop.store(true, Ordering::SeqCst);
|
||||
if let Some(j) = self.join.take() {
|
||||
let _ = j.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RenderThread {
|
||||
fn drop(&mut self) {
|
||||
self.stop_and_join();
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the presenter (COM interfaces, `!Send` by default) onto the render thread. Sound here:
|
||||
/// the shared device + immediate context are multithread-protected (see `crate::gpu`), D3D/DXGI
|
||||
/// objects are apartment-agile, and after this one handoff the swapchain/RTV/context calls happen
|
||||
/// on exactly the render thread — the same single-owner discipline as `SharedDevice`.
|
||||
struct SendPresenter(Presenter);
|
||||
unsafe impl Send for SendPresenter {}
|
||||
|
||||
/// Spawn the render thread. `frames` carries `(frame, FrameTimes)`; `clock_offset_ns` maps our
|
||||
/// wall clock onto the host's so the end-to-end (capture→on-glass) number is cross-machine valid
|
||||
/// (same math as the pump's host+network stage). A live handle (loaded per present) so
|
||||
/// mid-stream clock re-syncs keep the number honest after an NTP step / drift.
|
||||
pub fn spawn(
|
||||
presenter: Presenter,
|
||||
frames: FrameRx,
|
||||
shared: Arc<RenderShared>,
|
||||
clock_offset_ns: Arc<AtomicI64>,
|
||||
) -> RenderThread {
|
||||
let boxed = SendPresenter(presenter);
|
||||
let shared_w = shared.clone();
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-render".into())
|
||||
.spawn(move || run(boxed, frames, shared_w, clock_offset_ns))
|
||||
.expect("spawn render thread");
|
||||
RenderThread {
|
||||
shared,
|
||||
join: Some(join),
|
||||
}
|
||||
}
|
||||
|
||||
fn now_ns() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The window DPI, polled ~1 Hz as belt-and-braces for a monitor move that changes DPI without a
|
||||
/// `SizeChanged` (same DIP size on both screens). `None` when the window isn't up (headless).
|
||||
fn poll_window_dpi() -> Option<u32> {
|
||||
use windows::Win32::UI::HiDpi::GetDpiForWindow;
|
||||
use windows::Win32::UI::WindowsAndMessaging::FindWindowW;
|
||||
unsafe {
|
||||
let hwnd = FindWindowW(None, windows::core::w!("Punktfunk")).ok()?;
|
||||
match GetDpiForWindow(hwnd) {
|
||||
0 => None,
|
||||
d => Some(d),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run(
|
||||
presenter: SendPresenter,
|
||||
frames: FrameRx,
|
||||
shared: Arc<RenderShared>,
|
||||
clock_offset_ns: Arc<AtomicI64>,
|
||||
) {
|
||||
let mut p = presenter.0;
|
||||
let mut applied = (0u32, 0u32, 0u32); // last (w, h, dpi) handed to the presenter
|
||||
let mut presented = 0u32;
|
||||
let mut dropped = 0u32;
|
||||
// 1 s tumbling windows: end-to-end (capture→displayed) and the display stage
|
||||
// (decoded→displayed), sampled post-Present. Percentiles only (spec: stats-unification.md).
|
||||
let mut e2e_us: Vec<u64> = Vec::with_capacity(256);
|
||||
let mut display_us: Vec<u64> = Vec::with_capacity(256);
|
||||
let mut window_start = Instant::now();
|
||||
let mut last_dpi_poll = Instant::now();
|
||||
PRESENT_FPS.store(0, Ordering::Relaxed);
|
||||
PRESENT_SKIPPED.store(0, Ordering::Relaxed);
|
||||
E2E_P50_US.store(0, Ordering::Relaxed);
|
||||
E2E_P95_US.store(0, Ordering::Relaxed);
|
||||
DISPLAY_P50_US.store(0, Ordering::Relaxed);
|
||||
|
||||
loop {
|
||||
if shared.stop.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
let first = match frames.recv_timeout(Duration::from_millis(50)) {
|
||||
Ok(f) => Some(f),
|
||||
Err(RecvTimeoutError::Timeout) => None,
|
||||
Err(RecvTimeoutError::Disconnected) => break,
|
||||
};
|
||||
|
||||
if last_dpi_poll.elapsed() >= Duration::from_secs(1) {
|
||||
last_dpi_poll = Instant::now();
|
||||
if let Some(dpi) = poll_window_dpi() {
|
||||
shared.set_dpi(dpi);
|
||||
}
|
||||
}
|
||||
let snap = shared.snapshot();
|
||||
let resized = snap != applied && snap.0 > 0 && snap.1 > 0;
|
||||
if resized {
|
||||
p.resize(snap.0, snap.1, snap.2);
|
||||
applied = snap;
|
||||
}
|
||||
if first.is_none() && !resized {
|
||||
continue; // nothing new to show — don't burn GPU re-presenting a static frame
|
||||
}
|
||||
|
||||
// Throttle to the compositor: with ≤1 present outstanding this returns as DWM frees a
|
||||
// slot, and frames decoded meanwhile are drained below so the newest is what's drawn.
|
||||
if !p.wait_present_slot(1000) {
|
||||
tracing::debug!("frame-latency waitable timed out — presenting anyway");
|
||||
}
|
||||
let mut newest = first;
|
||||
while let Ok(f) = frames.try_recv() {
|
||||
if newest.is_some() {
|
||||
dropped += 1;
|
||||
}
|
||||
newest = Some(f);
|
||||
}
|
||||
|
||||
// The session pump is the sole 0xCE consumer and stashes the latest here (rare updates).
|
||||
if let Some(meta) = *crate::present::LATEST_HDR_META.lock().unwrap() {
|
||||
p.set_hdr_metadata(meta);
|
||||
}
|
||||
|
||||
let times: Option<FrameTimes> = newest.as_ref().map(|(_, t)| *t);
|
||||
p.present(newest.map(|(f, _)| f));
|
||||
presented += 1;
|
||||
if let Some(t) = times {
|
||||
// The `displayed` point: post-Present() on this thread (the honest best-effort
|
||||
// presentation instant on Windows — endpoint label `capture→on-glass`).
|
||||
let displayed_ns = now_ns();
|
||||
// End-to-end = capture → displayed, host-clock corrected, measured directly
|
||||
// (never the sum of stage percentiles). Clamped (0, 10 s).
|
||||
let e2e = (displayed_ns as i128 + clock_offset_ns.load(Ordering::Relaxed) as i128
|
||||
- t.pts_ns as i128)
|
||||
.max(0) as u64;
|
||||
if e2e > 0 && e2e < 10_000_000_000 {
|
||||
e2e_us.push(e2e / 1000);
|
||||
}
|
||||
// `display` stage = decoded → displayed, single-clock client-local.
|
||||
let disp = displayed_ns.saturating_sub(t.decoded_ns);
|
||||
if disp < 10_000_000_000 {
|
||||
display_us.push(disp / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
if window_start.elapsed() >= Duration::from_secs(1) {
|
||||
e2e_us.sort_unstable();
|
||||
display_us.sort_unstable();
|
||||
let p50 = |v: &[u64]| v.get(v.len() / 2).copied().unwrap_or(0);
|
||||
// p95 = sorted[min(len*95/100, len-1)] — the empty-window case falls to 0 via `get`.
|
||||
let p95 = |v: &[u64]| {
|
||||
v.get((v.len() * 95 / 100).min(v.len().saturating_sub(1)))
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
};
|
||||
tracing::debug!(
|
||||
presented,
|
||||
dropped,
|
||||
e2e_p50_us = p50(&e2e_us),
|
||||
e2e_p95_us = p95(&e2e_us),
|
||||
display_p50_us = p50(&display_us),
|
||||
"render window"
|
||||
);
|
||||
PRESENT_FPS.store(presented, Ordering::Relaxed);
|
||||
PRESENT_SKIPPED.store(dropped, Ordering::Relaxed);
|
||||
E2E_P50_US.store(p50(&e2e_us), Ordering::Relaxed);
|
||||
E2E_P95_US.store(p95(&e2e_us), Ordering::Relaxed);
|
||||
DISPLAY_P50_US.store(p50(&display_us), Ordering::Relaxed);
|
||||
window_start = Instant::now();
|
||||
presented = 0;
|
||||
dropped = 0;
|
||||
e2e_us.clear();
|
||||
display_us.clear();
|
||||
}
|
||||
}
|
||||
tracing::info!("render thread exiting");
|
||||
}
|
||||
@@ -1,555 +0,0 @@
|
||||
//! Session controller: one worker thread runs connect → pump (video pull + decode, audio
|
||||
//! pull + Opus decode, stats), feeding the UI over channels. The UI keeps the
|
||||
//! `Arc<NativeClient>` from the `Connected` event for direct input sends (no extra hop on
|
||||
//! the input path) — `NativeClient` is `Sync`, planes stay one-consumer-per-thread:
|
||||
//! video+audio here, rumble+hidout on the gamepad thread.
|
||||
//!
|
||||
//! Ported from the GTK Linux client; the platform-specific pieces are the video decoder
|
||||
//! (software-only here) and the audio backend (WASAPI). The pump body is identical.
|
||||
|
||||
use crate::audio;
|
||||
use crate::video::{DecodedFrame, Decoder, DecoderPref};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||
use punktfunk_core::PunktfunkError;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub struct SessionParams {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub mode: Mode,
|
||||
pub compositor: CompositorPref,
|
||||
pub gamepad: GamepadPref,
|
||||
pub bitrate_kbps: u32,
|
||||
/// Requested audio channel count (2/6/8); the host echoes the resolved value.
|
||||
pub audio_channels: u8,
|
||||
/// Stream the default microphone to the host's virtual mic source.
|
||||
pub mic_enabled: bool,
|
||||
/// Advertise 10-bit + HDR10 so the host may upgrade HDR content to a Main10/PQ stream.
|
||||
pub hdr_enabled: bool,
|
||||
/// Which video decode backend to use (auto/hardware/software).
|
||||
pub decoder: DecoderPref,
|
||||
/// The user's preferred video codec (a `quic::CODEC_*` bit, `0` = auto). Soft — the host honors
|
||||
/// it when it can emit it, else falls back; the resolved codec drives the decoder.
|
||||
pub preferred_codec: u8,
|
||||
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
|
||||
pub pin: Option<[u8; 32]>,
|
||||
pub identity: (String, String),
|
||||
/// How long to wait for the handshake. The normal path uses a short budget; the
|
||||
/// "request access" (delegated-approval) path uses a long one, because the host PARKS the
|
||||
/// connection until the operator clicks Approve in its console (so this must exceed the
|
||||
/// host's approval window — see `PENDING_APPROVAL_WAIT`).
|
||||
pub connect_timeout: Duration,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, PartialEq)]
|
||||
pub struct Stats {
|
||||
/// AUs received (reassembled) per second — actual-elapsed-time denominator.
|
||||
pub fps: f32,
|
||||
/// Received payload goodput (excludes FEC overhead).
|
||||
pub mbps: f32,
|
||||
/// `decode` stage p50 over the last 1 s window: received → decoded, client-local clock.
|
||||
pub decode_ms: f32,
|
||||
/// `host+network` stage p50 over the last 1 s window: capture (`pts_ns`) → received,
|
||||
/// host-clock corrected via `clock_offset_ns`.
|
||||
pub hostnet_ms: f32,
|
||||
/// `host` stage p50 (host capture→sent, from the per-AU 0xCF host-timing plane). Valid only
|
||||
/// when `split` — an old host emits no 0xCF and the HUD keeps the combined stage.
|
||||
pub host_ms: f32,
|
||||
/// `network` stage p50 (`hostnet − host`, tiled per frame before taking the percentile).
|
||||
/// Valid only when `split`.
|
||||
pub net_ms: f32,
|
||||
/// True when any 0xCF host timings matched received AUs this window — the HUD then renders
|
||||
/// `host + network` instead of the combined `host+network` term.
|
||||
pub split: bool,
|
||||
/// True when `clock_offset_ns == 0` (host didn't answer the skew handshake / same host) —
|
||||
/// the HUD appends `(same-host clock)` to the end-to-end line.
|
||||
pub same_host: bool,
|
||||
/// True when decoding on the GPU (D3D11VA) vs. CPU (software).
|
||||
pub hardware: bool,
|
||||
/// True when the stream is BT.2020 PQ HDR10 (last decoded frame).
|
||||
pub hdr: bool,
|
||||
/// The negotiated wire codec (a `quic::CODEC_*` bit) — the HUD's codec chip.
|
||||
pub codec: u8,
|
||||
/// Frames lost to unrecoverable network drops since session start (reassembler count; each
|
||||
/// triggers a keyframe re-request).
|
||||
pub dropped: u64,
|
||||
/// Seconds since the stream started.
|
||||
pub uptime_secs: u32,
|
||||
}
|
||||
|
||||
pub enum SessionEvent {
|
||||
Connected {
|
||||
connector: Arc<NativeClient>,
|
||||
mode: Mode,
|
||||
fingerprint: [u8; 32],
|
||||
},
|
||||
/// `trust_rejected` is set when the connect failed the TLS trust check (a `Crypto`
|
||||
/// error): for a pinned connect this is the fingerprint-changed signal, so the UI can
|
||||
/// offer a re-pair (PIN) path rather than a dead-end error.
|
||||
Failed {
|
||||
msg: String,
|
||||
trust_rejected: bool,
|
||||
},
|
||||
Ended(Option<String>),
|
||||
Stats(Stats),
|
||||
}
|
||||
|
||||
/// Per-frame measurement points carried with a decoded frame to the render thread: the host
|
||||
/// capture clock (`pts_ns`) and our local `decoded` stamp (wall-clock ns). Post-`Present()` the
|
||||
/// render thread derives the `display` stage (displayed − decoded, single-clock) and the
|
||||
/// end-to-end headline (displayed + clock_offset − pts) from them.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct FrameTimes {
|
||||
pub pts_ns: u64,
|
||||
pub decoded_ns: u64,
|
||||
}
|
||||
|
||||
/// Decoded frames + their measurement points, session pump → render thread (crossbeam so that
|
||||
/// thread can block with a timeout — async-channel has no `recv_timeout`).
|
||||
pub type FrameRx = crossbeam_channel::Receiver<(DecodedFrame, FrameTimes)>;
|
||||
|
||||
pub struct SessionHandle {
|
||||
pub events: async_channel::Receiver<SessionEvent>,
|
||||
pub frames: FrameRx,
|
||||
pub stop: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// Blocking speed-test probe (the GUI's per-host "Test" and the `--headless --speed-test` CLI):
|
||||
/// a minimal identified connect (720p60 — the host builds a virtual output, but nothing is
|
||||
/// decoded), then `request_probe` (a 2 s burst up to the host's 3 Gbps ceiling) polled to
|
||||
/// completion. Run on a worker thread.
|
||||
pub fn run_speed_probe(
|
||||
addr: &str,
|
||||
port: u16,
|
||||
fp_hex: Option<&str>,
|
||||
identity: (String, String),
|
||||
) -> Result<punktfunk_core::client::ProbeOutcome, String> {
|
||||
// Pin the saved/advertised fingerprint when we have one; a manual host measures over TOFU.
|
||||
let pin = fp_hex.and_then(crate::trust::parse_hex32);
|
||||
let c = NativeClient::connect(
|
||||
addr,
|
||||
port,
|
||||
Mode {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
refresh_hz: 60,
|
||||
},
|
||||
CompositorPref::Auto,
|
||||
GamepadPref::Auto,
|
||||
0, // bitrate_kbps: host default
|
||||
0, // video_caps: probe connect, nothing is decoded
|
||||
2, // audio_channels: stereo baseline
|
||||
crate::video::decodable_codecs(),
|
||||
0, // preferred_codec: no preference
|
||||
None, // display_hdr: probe connect, nothing presents
|
||||
None, // launch: no game
|
||||
pin,
|
||||
Some(identity),
|
||||
Duration::from_secs(15),
|
||||
)
|
||||
.map_err(|e| format!("connect: {e:?}"))?;
|
||||
c.request_probe(3_000_000, 2_000)
|
||||
.map_err(|e| format!("probe: {e:?}"))?;
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
if c.probe_result().done {
|
||||
// Let the last UDP shards land before tearing down.
|
||||
std::thread::sleep(Duration::from_millis(400));
|
||||
return Ok(c.probe_result());
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
return Err("probe timed out".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(params: SessionParams) -> SessionHandle {
|
||||
let (ev_tx, ev_rx) = async_channel::unbounded();
|
||||
// Tiny frame queue, newest wins: the pump displaces the oldest when the renderer lags (it
|
||||
// keeps a Receiver clone for exactly that).
|
||||
let (frame_tx, frame_rx) = crossbeam_channel::bounded(2);
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_w = stop.clone();
|
||||
let frame_rx_pump = frame_rx.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-session".into())
|
||||
.spawn(move || pump(params, ev_tx, frame_tx, frame_rx_pump, stop_w))
|
||||
.expect("spawn session thread");
|
||||
SessionHandle {
|
||||
events: ev_rx,
|
||||
frames: frame_rx,
|
||||
stop,
|
||||
}
|
||||
}
|
||||
|
||||
fn now_ns() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Opus decoder for the audio plane: a plain stereo decoder (the validated path) or a multistream
|
||||
/// decoder for 5.1/7.1, both behind one `decode_float`. Built from the host-RESOLVED channel count
|
||||
/// via the shared layout table.
|
||||
enum AudioDec {
|
||||
Stereo(opus::Decoder),
|
||||
Surround(opus::MSDecoder),
|
||||
}
|
||||
|
||||
impl AudioDec {
|
||||
fn new(channels: u8) -> Result<AudioDec, opus::Error> {
|
||||
if channels == 2 {
|
||||
Ok(AudioDec::Stereo(opus::Decoder::new(
|
||||
48_000,
|
||||
opus::Channels::Stereo,
|
||||
)?))
|
||||
} else {
|
||||
let l = punktfunk_core::audio::layout_for(channels, false);
|
||||
Ok(AudioDec::Surround(opus::MSDecoder::new(
|
||||
48_000, l.streams, l.coupled, l.mapping,
|
||||
)?))
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_float(
|
||||
&mut self,
|
||||
input: &[u8],
|
||||
out: &mut [f32],
|
||||
fec: bool,
|
||||
) -> Result<usize, opus::Error> {
|
||||
match self {
|
||||
AudioDec::Stereo(d) => d.decode_float(input, out, fec),
|
||||
AudioDec::Surround(d) => d.decode_float(input, out, fec),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pump(
|
||||
params: SessionParams,
|
||||
ev_tx: async_channel::Sender<SessionEvent>,
|
||||
frame_tx: crossbeam_channel::Sender<(DecodedFrame, FrameTimes)>,
|
||||
frame_rx: FrameRx,
|
||||
stop: Arc<AtomicBool>,
|
||||
) {
|
||||
// Advertise 10-bit + HDR10 only when the user enabled HDR AND a display is actually in HDR
|
||||
// mode: the host then upgrades HDR content to a Main10/PQ stream (its own 10-bit gate still
|
||||
// applies). On an SDR display we advertise `0` so the host sends a proper 8-bit BT.709 stream
|
||||
// rather than PQ the panel would mis-tone-map (washed-out/dark). The presenter handles BT.2020
|
||||
// PQ frames (P010 / X2BGR10).
|
||||
let hdr_active = params.hdr_enabled && crate::present::display_supports_hdr();
|
||||
if params.hdr_enabled && !hdr_active {
|
||||
tracing::info!("HDR enabled in settings but no HDR display detected — requesting SDR");
|
||||
}
|
||||
// With HDR active, also report the panel's real colour volume (GetDesc1): the host writes it
|
||||
// into its virtual display's EDID, so host apps tone-map to THIS panel and the PQ stream
|
||||
// arrives already inside its volume — the client presents it untouched.
|
||||
// PUNKTFUNK_CLIENT_PEAK_NITS pins a synthetic volume for A/B runs.
|
||||
let display_hdr = if hdr_active {
|
||||
let vol = punktfunk_core::client::display_hdr_env_override()
|
||||
.or_else(crate::present::display_hdr_volume);
|
||||
if let Some(m) = vol {
|
||||
tracing::info!(
|
||||
max_nits = m.max_display_mastering_luminance / 10_000,
|
||||
min_millinits = m.min_display_mastering_luminance / 10,
|
||||
max_fall = m.max_fall,
|
||||
"advertising this display's HDR volume to the host"
|
||||
);
|
||||
}
|
||||
vol
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let connector = match NativeClient::connect(
|
||||
¶ms.host,
|
||||
params.port,
|
||||
params.mode,
|
||||
params.compositor,
|
||||
params.gamepad,
|
||||
params.bitrate_kbps,
|
||||
if hdr_active {
|
||||
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
|
||||
} else {
|
||||
0
|
||||
},
|
||||
params.audio_channels,
|
||||
crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1)
|
||||
params.preferred_codec, // the user's soft codec preference (0 = auto)
|
||||
display_hdr,
|
||||
None, // launch: the Windows client has no library picker yet
|
||||
params.pin,
|
||||
Some(params.identity),
|
||||
params.connect_timeout,
|
||||
) {
|
||||
Ok(c) => Arc::new(c),
|
||||
Err(e) => {
|
||||
let trust_rejected = matches!(e, PunktfunkError::Crypto);
|
||||
let msg = match e {
|
||||
PunktfunkError::Crypto => {
|
||||
"Host identity rejected — wrong fingerprint, or the host requires pairing"
|
||||
.to_string()
|
||||
}
|
||||
PunktfunkError::Timeout => "Connection timed out".to_string(),
|
||||
other => format!("Connect failed: {other:?}"),
|
||||
};
|
||||
let _ = ev_tx.send_blocking(SessionEvent::Failed {
|
||||
msg,
|
||||
trust_rejected,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = ev_tx.send_blocking(SessionEvent::Connected {
|
||||
connector: connector.clone(),
|
||||
mode: connector.mode(),
|
||||
fingerprint: connector.host_fingerprint,
|
||||
});
|
||||
|
||||
// Build the decoder for the codec the host resolved (never assume HEVC).
|
||||
let codec_id = crate::video::ffmpeg_codec_id(connector.codec);
|
||||
tracing::info!(
|
||||
?codec_id,
|
||||
welcome_codec = connector.codec,
|
||||
"negotiated video codec"
|
||||
);
|
||||
let mut decoder = match Decoder::new(params.decoder, codec_id) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
let _ = ev_tx.send_blocking(SessionEvent::Ended(Some(format!("video decoder: {e}"))));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut hardware = decoder.is_hardware();
|
||||
let mut hdr = false;
|
||||
// Audio is best-effort: a session without it still streams. Gamepads are the
|
||||
// app-lifetime service's job (the UI attaches it on Connected). Build the decoder + playback
|
||||
// from the host-RESOLVED channel count (never the request), so an older/clamping host that
|
||||
// resolves stereo is decoded as stereo.
|
||||
let channels = connector.audio_channels;
|
||||
let player = audio::AudioPlayer::spawn(channels)
|
||||
.map_err(|e| tracing::warn!(error = %e, "audio disabled"))
|
||||
.ok();
|
||||
let mut opus_dec = AudioDec::new(channels)
|
||||
.map_err(|e| tracing::warn!(error = %e, "opus decoder failed — audio disabled"))
|
||||
.ok();
|
||||
let _mic = params
|
||||
.mic_enabled
|
||||
.then(|| {
|
||||
audio::MicStreamer::spawn(connector.clone())
|
||||
.map_err(|e| tracing::warn!(error = %e, "mic uplink disabled"))
|
||||
.ok()
|
||||
})
|
||||
.flatten();
|
||||
|
||||
// Force an immediate IDR (with in-band parameter sets) rather than waiting for the host's own
|
||||
// first keyframe — under infinite GOP a late/missed IDR means the decoder sits on
|
||||
// "PPS id out of range" (a black screen) until one arrives.
|
||||
let _ = connector.request_keyframe();
|
||||
|
||||
// Live host↔client clock offset: loaded per use (Relaxed) so mid-stream re-syncs (an NTP
|
||||
// step, drift) keep the capture-clock latency stats honest — never cached at session start.
|
||||
let clock_offset_live = connector.clock_offset_shared();
|
||||
let mut total_frames = 0u64;
|
||||
let session_start = Instant::now();
|
||||
let mut window_start = Instant::now();
|
||||
let mut frames_n = 0u32;
|
||||
let mut bytes_n = 0u64;
|
||||
// 1 s tumbling stage windows (spec: design/stats-unification.md — percentiles, never means).
|
||||
let mut hostnet_us: Vec<u64> = Vec::with_capacity(256);
|
||||
let mut decode_us: Vec<u64> = Vec::with_capacity(256);
|
||||
// Host/network split (Phase 2): received AUs awaiting their 0xCF host timing, `(pts_ns,
|
||||
// hostnet_us)`, matched as the datagrams arrive. Bounded — an old host never sends any.
|
||||
let mut pending_split: std::collections::VecDeque<(u64, u64)> =
|
||||
std::collections::VecDeque::with_capacity(256);
|
||||
let mut host_us_w: Vec<u64> = Vec::with_capacity(256);
|
||||
let mut net_us_w: Vec<u64> = Vec::with_capacity(256);
|
||||
let mut pcm = vec![0f32; 5760 * channels as usize]; // scratch: max Opus frame (120 ms) × channels
|
||||
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it climbs.
|
||||
let mut last_dropped = connector.frames_dropped();
|
||||
let mut last_kf_req: Option<Instant> = None;
|
||||
|
||||
let end: Option<String> = loop {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
break None;
|
||||
}
|
||||
match connector.next_frame(Duration::from_millis(4)) {
|
||||
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;
|
||||
bytes_n += frame.data.len() as u64;
|
||||
// `host+network` stage: capture → received, host-clock corrected. Clamped (0, 10 s).
|
||||
let clock_offset = clock_offset_live.load(Ordering::Relaxed);
|
||||
let hostnet = (received_ns as i128 + clock_offset as i128 - frame.pts_ns as i128)
|
||||
.max(0) as u64;
|
||||
if hostnet > 0 && hostnet < 10_000_000_000 {
|
||||
hostnet_us.push(hostnet / 1000);
|
||||
// Remember this AU for the 0xCF match below (host/network split).
|
||||
pending_split.push_back((frame.pts_ns, hostnet / 1000));
|
||||
if pending_split.len() > 256 {
|
||||
pending_split.pop_front();
|
||||
}
|
||||
}
|
||||
// A D3D11VA→software demotion (see `Decoder::decode`) starts a FRESH decoder that
|
||||
// has none of the stream's parameter sets; under infinite GOP it would sit on
|
||||
// "PPS id out of range" forever. Detect the transition and force a new IDR so the
|
||||
// rebuilt decoder resynchronizes immediately.
|
||||
let was_hw = decoder.is_hardware();
|
||||
let decoded = decoder.decode(&frame.data);
|
||||
if was_hw && !decoder.is_hardware() {
|
||||
tracing::info!("decoder demoted to software — requesting keyframe to resync");
|
||||
let _ = connector.request_keyframe();
|
||||
}
|
||||
match decoded {
|
||||
Ok(Some(decoded)) => {
|
||||
// The `decoded` point: decoder output frame available.
|
||||
let decoded_ns = now_ns();
|
||||
total_frames += 1;
|
||||
hdr = decoded.hdr();
|
||||
// The backend can demote D3D11VA → software mid-session on a hardware error.
|
||||
hardware = decoder.is_hardware();
|
||||
if total_frames == 1 {
|
||||
let (w, h) = decoded.dims();
|
||||
tracing::info!(
|
||||
width = w,
|
||||
height = h,
|
||||
path = if hardware { "d3d11va" } else { "software" },
|
||||
hdr,
|
||||
"first frame decoded"
|
||||
);
|
||||
}
|
||||
// `decode` stage: received → decoded, single-clock client-local.
|
||||
decode_us.push(decoded_ns.saturating_sub(received_ns) / 1000);
|
||||
// Newest wins: displace the oldest queued frame when the renderer lags.
|
||||
if let Err(crossbeam_channel::TrySendError::Full(item)) =
|
||||
frame_tx.try_send((
|
||||
decoded,
|
||||
FrameTimes {
|
||||
pts_ns: frame.pts_ns,
|
||||
decoded_ns,
|
||||
},
|
||||
))
|
||||
{
|
||||
let _ = frame_rx.try_recv();
|
||||
let _ = frame_tx.try_send(item);
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
// Survivable (loss until the next IDR/RFI recovery) — keep feeding.
|
||||
Err(e) => tracing::debug!(error = %e, "decode error (recovering)"),
|
||||
}
|
||||
}
|
||||
Err(PunktfunkError::NoFrame) => {}
|
||||
Err(PunktfunkError::Closed) => break Some("Host ended the session".to_string()),
|
||||
Err(e) => break Some(format!("session: {e:?}")),
|
||||
}
|
||||
|
||||
// Loss recovery: under infinite GOP the only recovery keyframe is one we request. The
|
||||
// reassembler drops unrecoverable AUs (frames_dropped); the decoder conceals the
|
||||
// reference-missing delta frames that follow and returns Ok, so keying off a decode error
|
||||
// rarely fires. Request an IDR when the drop count climbs, throttled.
|
||||
let dropped = connector.frames_dropped();
|
||||
if dropped > last_dropped {
|
||||
last_dropped = dropped;
|
||||
let now = Instant::now();
|
||||
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)");
|
||||
}
|
||||
}
|
||||
|
||||
// Drain audio between frames (packets land every 5 ms; the queue holds 320 ms).
|
||||
while let Ok(pkt) = connector.next_audio(Duration::ZERO) {
|
||||
if let (Some(player), Some(dec)) = (&player, opus_dec.as_mut()) {
|
||||
match dec.decode_float(&pkt.data, &mut pcm, false) {
|
||||
// `samples` is per-channel; the interleaved frame is `samples * channels`.
|
||||
Ok(samples) => player.push(pcm[..samples * channels as usize].to_vec()),
|
||||
Err(e) => tracing::debug!(error = %e, "opus decode"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the HDR static-metadata plane (0xCE): the source's real mastering display + content
|
||||
// light level. Stash the latest for the UI-thread presenter to apply via SetHDRMetaData —
|
||||
// this pump is the sole consumer of the plane. Rare (start + on change/keyframe).
|
||||
while let Ok(meta) = connector.next_hdr_meta(Duration::ZERO) {
|
||||
*crate::present::LATEST_HDR_META.lock().unwrap() = Some(meta);
|
||||
}
|
||||
|
||||
// Drain the per-AU host-timing plane (0xCF) and match by pts: `host` = the host's own
|
||||
// capture→sent, `network` = our capture→received minus it — the two tile per frame
|
||||
// (design/stats-unification.md Phase 2). An old host never emits any; `split` stays false
|
||||
// and the HUD keeps the combined `host+network` stage.
|
||||
while let Ok(t) = connector.next_host_timing(Duration::ZERO) {
|
||||
if let Some(i) = pending_split.iter().position(|(p, _)| *p == t.pts_ns) {
|
||||
let (_, hn_us) = pending_split.remove(i).unwrap();
|
||||
host_us_w.push(t.host_us as u64);
|
||||
net_us_w.push(hn_us.saturating_sub(t.host_us as u64));
|
||||
}
|
||||
}
|
||||
|
||||
if window_start.elapsed() >= Duration::from_secs(1) {
|
||||
let secs = window_start.elapsed().as_secs_f32();
|
||||
hostnet_us.sort_unstable();
|
||||
decode_us.sort_unstable();
|
||||
host_us_w.sort_unstable();
|
||||
net_us_w.sort_unstable();
|
||||
let p50 = |v: &[u64]| v.get(v.len() / 2).copied().unwrap_or(0);
|
||||
let (hostnet_p50, decode_p50) = (p50(&hostnet_us), p50(&decode_us));
|
||||
let (host_p50, net_p50) = (p50(&host_us_w), p50(&net_us_w));
|
||||
let split = !host_us_w.is_empty();
|
||||
tracing::debug!(
|
||||
fps = frames_n,
|
||||
hostnet_p50_us = hostnet_p50,
|
||||
host_p50_us = host_p50,
|
||||
net_p50_us = net_p50,
|
||||
split,
|
||||
decode_p50_us = decode_p50,
|
||||
total_frames,
|
||||
"stream window"
|
||||
);
|
||||
let _ = ev_tx.try_send(SessionEvent::Stats(Stats {
|
||||
fps: frames_n as f32 / secs,
|
||||
mbps: bytes_n as f32 * 8.0 / 1e6 / secs,
|
||||
decode_ms: decode_p50 as f32 / 1000.0,
|
||||
hostnet_ms: hostnet_p50 as f32 / 1000.0,
|
||||
host_ms: host_p50 as f32 / 1000.0,
|
||||
net_ms: net_p50 as f32 / 1000.0,
|
||||
split,
|
||||
same_host: clock_offset_live.load(Ordering::Relaxed) == 0,
|
||||
hardware,
|
||||
hdr,
|
||||
codec: connector.codec,
|
||||
dropped: last_dropped,
|
||||
uptime_secs: session_start.elapsed().as_secs() as u32,
|
||||
}));
|
||||
window_start = Instant::now();
|
||||
frames_n = 0;
|
||||
bytes_n = 0;
|
||||
hostnet_us.clear();
|
||||
decode_us.clear();
|
||||
host_us_w.clear();
|
||||
net_us_w.clear();
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
total_frames,
|
||||
reason = end.as_deref().unwrap_or("user"),
|
||||
"session ended"
|
||||
);
|
||||
stop.store(true, Ordering::SeqCst);
|
||||
let _ = ev_tx.send_blocking(SessionEvent::Ended(end));
|
||||
}
|
||||
@@ -6,10 +6,6 @@
|
||||
//! [`SpawnEvent`]s a reader thread hands to the app's navigation closure: spinner until
|
||||
//! `{"ready":true}`, banner from the `{"error"|"ended": …}` line, `trust_rejected`
|
||||
//! routed to the re-pair PIN ceremony, `stats:` lines to the session status page.
|
||||
//!
|
||||
//! The legacy in-process D3D11VA presenter remains reachable via the
|
||||
//! `PUNKTFUNK_BUILTIN_STREAM=1` env override (`app::use_builtin_stream`) — the
|
||||
//! developer A/B baseline until its deletion.
|
||||
|
||||
use std::io::BufRead as _;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
|
||||
@@ -5,10 +5,8 @@
|
||||
//!
|
||||
//! The shell is the settings file's only writer; the session only reads it. The shell's
|
||||
//! former private `Settings` copy (≤ 0.8.4: `show_hud`, `engine`) is gone — old files
|
||||
//! still load via a serde alias in core, and the legacy in-process presenter is now
|
||||
//! reachable only through `PUNKTFUNK_BUILTIN_STREAM=1` (see `app::use_builtin_stream`).
|
||||
//! still load via a serde alias in core.
|
||||
|
||||
pub use pf_client_core::trust::{
|
||||
hex, learn_mac, load_or_create_identity, parse_hex32, touch_last_used, KnownHost, KnownHosts,
|
||||
Settings,
|
||||
hex, learn_mac, load_or_create_identity, parse_hex32, KnownHost, KnownHosts, Settings,
|
||||
};
|
||||
|
||||
@@ -1,653 +0,0 @@
|
||||
//! Video decode: reassembled HEVC access units → frames for the D3D11 presenter.
|
||||
//!
|
||||
//! Two backends, picked at session start (override via [`DecoderPref`] / the Settings UI):
|
||||
//!
|
||||
//! * **D3D11VA** (any GPU — the vendor-agnostic DXVA path on NVIDIA/AMD/Intel): libavcodec decodes
|
||||
//! on the GPU into an `ID3D11Texture2D` decode array (decoder-only bind — NVIDIA rejects a
|
||||
//! decoder array that is also a shader resource). The presenter copies each decoded slice into
|
||||
//! its own sampleable NV12/P010 texture and converts YUV→RGB in a shader — one cheap GPU-to-GPU
|
||||
//! copy per frame (no swscale, no CPU readback). The decode array is created by the process-wide
|
||||
//! shared device ([`crate::gpu`]) the presenter also draws with, so the copy stays on-GPU. This
|
||||
//! is the big latency/throughput win over software.
|
||||
//! * **Software**: libavcodec on the CPU + swscale to the same planar layout the hardware path
|
||||
//! produces (NV12, or P010 for 10-bit) — the presenter uploads the two planes and runs the SAME
|
||||
//! YUV→RGB shaders, so hw/sw color math is identical. The fallback on a GPU-less box (WARP),
|
||||
//! when D3D11VA init fails, or when a mid-session hardware error demotes us — the host's
|
||||
//! IDR/RFI recovery resynchronizes on the next keyframe either way.
|
||||
//!
|
||||
//! D3D11VA viability is settled **before the session's first frame** by two probes: the adapter
|
||||
//! must expose the negotiated codec's DXVA decode profile ([`decode_profile_supported`] — hwaccel
|
||||
//! init otherwise only fails at the first AU, burning the IDR), and it must be able to create the
|
||||
//! decode surface pool ([`d3d11va_decode_supported`]). Either failing commits to software decode
|
||||
//! from frame one (a clean, gap-free stream) instead of dying mid-stream.
|
||||
//!
|
||||
//! Both run `AV_CODEC_FLAG_LOW_DELAY`; the host encodes zero-reorder streams (no B-frames, in-band
|
||||
//! parameter sets on every IDR), so decode is strictly one-in/one-out.
|
||||
//!
|
||||
//! HDR is detected in-band from the decoded frame's transfer characteristic (`SMPTE2084` / PQ in the
|
||||
//! HEVC VUI) — the same signal every other punktfunk client keys off — not from a protocol field.
|
||||
|
||||
use anyhow::{anyhow, bail, Context as _, Result};
|
||||
use ffmpeg::format::Pixel;
|
||||
use ffmpeg::software::scaling;
|
||||
use ffmpeg::util::frame::Video as AvFrame;
|
||||
use ffmpeg_next as ffmpeg;
|
||||
use pf_client_core::video::ColorDesc;
|
||||
use std::ffi::c_void;
|
||||
use std::ptr;
|
||||
use windows::core::{Interface, GUID};
|
||||
use windows::Win32::Graphics::Direct3D11::{ID3D11Device, ID3D11VideoDevice};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT, DXGI_FORMAT_NV12, DXGI_FORMAT_P010};
|
||||
|
||||
/// Which decode backend to use; the Settings UI persists this as a string.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
pub enum DecoderPref {
|
||||
/// Try D3D11VA, fall back to software.
|
||||
#[default]
|
||||
Auto,
|
||||
/// Force D3D11VA (error out if unavailable, for debugging).
|
||||
Hardware,
|
||||
/// Force software decode.
|
||||
Software,
|
||||
}
|
||||
|
||||
impl DecoderPref {
|
||||
pub fn from_name(s: &str) -> DecoderPref {
|
||||
match s {
|
||||
"hardware" => DecoderPref::Hardware,
|
||||
"software" => DecoderPref::Software,
|
||||
_ => DecoderPref::Auto,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum DecodedFrame {
|
||||
Cpu(CpuFrame),
|
||||
Gpu(GpuFrame),
|
||||
}
|
||||
|
||||
impl DecodedFrame {
|
||||
pub fn dims(&self) -> (u32, u32) {
|
||||
match self {
|
||||
DecodedFrame::Cpu(c) => (c.width, c.height),
|
||||
DecodedFrame::Gpu(g) => (g.width, g.height),
|
||||
}
|
||||
}
|
||||
pub fn hdr(&self) -> bool {
|
||||
match self {
|
||||
DecodedFrame::Cpu(c) => c.hdr,
|
||||
DecodedFrame::Gpu(g) => g.hdr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A software-decoded frame in the same planar layout the hardware path produces: an NV12 (or
|
||||
/// P010 for 10-bit) luma plane + interleaved chroma plane, each with its swscale row stride
|
||||
/// (≥ the row bytes — swscale pads rows for SIMD). The presenter uploads them into two dynamic
|
||||
/// plane textures sampled by the same shaders as the D3D11VA path.
|
||||
pub struct CpuFrame {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Luma plane (`W×H` samples, 1 byte each; 2 for 10-bit) + its row stride in bytes.
|
||||
pub y: Vec<u8>,
|
||||
pub y_stride: usize,
|
||||
/// Interleaved chroma plane (`⌈W/2⌉×⌈H/2⌉` UV pairs) + its row stride in bytes.
|
||||
pub uv: Vec<u8>,
|
||||
pub uv_stride: usize,
|
||||
/// P010 sample layout (10 bits in the high bits of 16) vs NV12. Selects texture/SRV formats.
|
||||
pub ten_bit: bool,
|
||||
/// BT.2020 PQ HDR10 vs ordinary BT.709 SDR. Selects the swapchain colour space.
|
||||
pub hdr: bool,
|
||||
/// The frame's CICP signaling (HEVC VUI → `AVFrame`), read per-frame — the presenter derives
|
||||
/// its Y′CbCr→RGB constant buffer from it (`csc_rows`), so a BT.601-signaled stream (a Linux
|
||||
/// host's RGB-input NVENC) no longer renders with BT.709 coefficients.
|
||||
pub color: ColorDesc,
|
||||
}
|
||||
|
||||
/// A decoded frame still on the GPU: a D3D11 texture **array** plus the slice index the decoder
|
||||
/// wrote this frame into. The presenter copies the slice into its own sampleable texture and
|
||||
/// converts YUV→RGB in a pixel shader. The underlying surface stays alive — and out of the decoder's
|
||||
/// reuse pool — for exactly as long as `guard` (an `av_frame_clone` of the decoded frame) lives.
|
||||
pub struct GpuFrame {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Texture-array slice this frame occupies (`AVFrame::data[1]`).
|
||||
pub index: u32,
|
||||
/// The decode pool is P010 (10 bits in the high bits) vs NV12 — from the frames context's
|
||||
/// `sw_format`. The presenter keys its copy-texture/SRV formats off this: they must match the
|
||||
/// source array exactly for `CopySubresourceRegion`.
|
||||
pub ten_bit: bool,
|
||||
/// BT.2020 PQ HDR10 (ST.2084 transfer) vs ordinary BT.709 SDR. Selects the swapchain colour
|
||||
/// space only (the host couples 10-bit ⟺ HDR today, but formats key off `ten_bit`).
|
||||
pub hdr: bool,
|
||||
/// Per-frame CICP signaling — see [`CpuFrame::color`].
|
||||
pub color: ColorDesc,
|
||||
guard: D3d11FrameGuard,
|
||||
}
|
||||
|
||||
impl GpuFrame {
|
||||
/// The decoder's D3D11 texture array holding this frame's slice, borrowed from the live cloned
|
||||
/// `AVFrame`. Construct the windows-rs interface on the thread that will use it (the render
|
||||
/// thread): COM interfaces are `!Send`, but the raw pointer is fine to carry across threads.
|
||||
pub fn texture_ptr(&self) -> *mut c_void {
|
||||
unsafe { (*self.guard.0).data[0] as *mut c_void }
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns a cloned decoded `AVFrame` (which refs the D3D11 surface in the decoder pool). Dropping it
|
||||
/// releases the surface back for reuse. The clone is plain refcounted data; freeing it from the
|
||||
/// render thread is fine.
|
||||
pub struct D3d11FrameGuard(*mut ffmpeg::ffi::AVFrame);
|
||||
unsafe impl Send for D3d11FrameGuard {}
|
||||
impl Drop for D3d11FrameGuard {
|
||||
fn drop(&mut self) {
|
||||
unsafe { ffmpeg::ffi::av_frame_free(&mut self.0) };
|
||||
}
|
||||
}
|
||||
|
||||
enum Backend {
|
||||
D3d11va(D3d11vaDecoder),
|
||||
Software(SoftwareDecoder),
|
||||
}
|
||||
|
||||
pub struct Decoder {
|
||||
backend: Backend,
|
||||
/// The negotiated codec, so a mid-session D3D11VA→software demotion rebuilds for the same codec.
|
||||
codec_id: ffmpeg::codec::Id,
|
||||
}
|
||||
|
||||
/// Map a negotiated `quic` codec bit to the FFmpeg decoder id the client opens.
|
||||
pub fn ffmpeg_codec_id(wire: u8) -> ffmpeg::codec::Id {
|
||||
match wire {
|
||||
punktfunk_core::quic::CODEC_H264 => ffmpeg::codec::Id::H264,
|
||||
punktfunk_core::quic::CODEC_AV1 => ffmpeg::codec::Id::AV1,
|
||||
_ => ffmpeg::codec::Id::HEVC,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `quic` codec bitfield this client can decode — whatever FFmpeg has a decoder for (HEVC/H.264
|
||||
/// always; AV1 when built in). Advertised to the host so it never emits a codec we can't decode.
|
||||
/// Deliberately NOT gated on the DXVA profiles: software decode covers anything FFmpeg can.
|
||||
pub fn decodable_codecs() -> u8 {
|
||||
let _ = ffmpeg::init();
|
||||
let mut bits = 0u8;
|
||||
for (id, bit) in [
|
||||
(ffmpeg::codec::Id::HEVC, punktfunk_core::quic::CODEC_HEVC),
|
||||
(ffmpeg::codec::Id::H264, punktfunk_core::quic::CODEC_H264),
|
||||
(ffmpeg::codec::Id::AV1, punktfunk_core::quic::CODEC_AV1),
|
||||
] {
|
||||
if ffmpeg::decoder::find(id).is_some() {
|
||||
bits |= bit;
|
||||
}
|
||||
}
|
||||
bits
|
||||
}
|
||||
|
||||
impl Decoder {
|
||||
pub fn new(pref: DecoderPref, codec_id: ffmpeg::codec::Id) -> Result<Decoder> {
|
||||
ffmpeg::init().context("ffmpeg init")?;
|
||||
if pref != DecoderPref::Software {
|
||||
match D3d11vaDecoder::new(codec_id) {
|
||||
Ok(d) => {
|
||||
tracing::info!(?codec_id, "D3D11VA hardware decode active");
|
||||
return Ok(Decoder {
|
||||
backend: Backend::D3d11va(d),
|
||||
codec_id,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
if pref == DecoderPref::Hardware {
|
||||
return Err(e.context("decoder=hardware but D3D11VA failed"));
|
||||
}
|
||||
tracing::info!(reason = %e, "D3D11VA unavailable — software decode");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Decoder {
|
||||
backend: Backend::Software(SoftwareDecoder::new(codec_id)?),
|
||||
codec_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// True for the GPU hardware backend (shown in the stream HUD).
|
||||
pub fn is_hardware(&self) -> bool {
|
||||
matches!(self.backend, Backend::D3d11va(_))
|
||||
}
|
||||
|
||||
/// Feed one access unit; returns the decoded frame (the host's streams are one-in/one-out). A
|
||||
/// software decode error after packet loss is survivable — keep feeding. A D3D11VA error demotes
|
||||
/// to software for the rest of the session (the next IDR resynchronizes).
|
||||
pub fn decode(&mut self, au: &[u8]) -> Result<Option<DecodedFrame>> {
|
||||
match &mut self.backend {
|
||||
Backend::D3d11va(d) => match d.decode(au) {
|
||||
Ok(f) => Ok(f.map(DecodedFrame::Gpu)),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "D3D11VA decode failed — falling back to software");
|
||||
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
||||
Ok(None)
|
||||
}
|
||||
},
|
||||
Backend::Software(s) => Ok(s.decode(au)?.map(DecodedFrame::Cpu)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- DXVA decode-profile probe --------------------------------------------------------
|
||||
|
||||
/// DXVA decode-profile GUIDs (`dxva.h`), defined locally so no extra windows-rs feature or
|
||||
/// metadata surface is pulled in for four constants.
|
||||
const PROFILE_H264_VLD_NOFGT: GUID = GUID::from_u128(0x1b81be68_a0c7_11d3_b984_00c04f2e73c5);
|
||||
const PROFILE_HEVC_VLD_MAIN: GUID = GUID::from_u128(0x5b11d51b_2f4c_4452_bcc3_09f2a1160cc0);
|
||||
const PROFILE_HEVC_VLD_MAIN10: GUID = GUID::from_u128(0x107af0e0_ef1a_4d19_aba8_67a163073d13);
|
||||
const PROFILE_AV1_VLD_PROFILE0: GUID = GUID::from_u128(0xb8be4ccb_cf53_46ba_8d59_d6b8a6da5d2a);
|
||||
|
||||
/// Does the shared device's adapter expose a DXVA decode profile for `codec_id`? Checked before
|
||||
/// building the FFmpeg hwdevice because hwaccel selection (`get_format`) only runs on the FIRST
|
||||
/// access unit — an unsupported profile would otherwise burn the opening IDR and recover through
|
||||
/// the mid-stream demotion path instead of committing to software up front. Also logs (once) the
|
||||
/// adapter's full profile list plus Main10 availability — the forensics for a new GPU/driver.
|
||||
fn decode_profile_supported(device: &ID3D11Device, codec_id: ffmpeg::codec::Id) -> Result<()> {
|
||||
let video: ID3D11VideoDevice = device
|
||||
.cast()
|
||||
.context("device lacks ID3D11VideoDevice (created without VIDEO_SUPPORT)")?;
|
||||
let profiles: Vec<GUID> = unsafe {
|
||||
let n = video.GetVideoDecoderProfileCount();
|
||||
(0..n)
|
||||
.filter_map(|i| video.GetVideoDecoderProfile(i).ok())
|
||||
.collect()
|
||||
};
|
||||
log_profiles_once(&profiles);
|
||||
|
||||
let (wanted, format, name): (GUID, DXGI_FORMAT, &str) = match codec_id {
|
||||
ffmpeg::codec::Id::H264 => (PROFILE_H264_VLD_NOFGT, DXGI_FORMAT_NV12, "H.264 VLD NoFGT"),
|
||||
ffmpeg::codec::Id::HEVC => (PROFILE_HEVC_VLD_MAIN, DXGI_FORMAT_NV12, "HEVC Main"),
|
||||
ffmpeg::codec::Id::AV1 => (PROFILE_AV1_VLD_PROFILE0, DXGI_FORMAT_NV12, "AV1 Profile 0"),
|
||||
other => bail!("no DXVA profile known for {other:?}"),
|
||||
};
|
||||
let ok = profiles.contains(&wanted)
|
||||
&& unsafe { video.CheckVideoDecoderFormat(&wanted, format) }
|
||||
.map(|b| b.as_bool())
|
||||
.unwrap_or(false);
|
||||
if !ok {
|
||||
bail!("adapter exposes no {name} decode profile");
|
||||
}
|
||||
// 10-bit (a mid-session HDR upgrade needs Main10): informational — if it's missing the
|
||||
// decode error → software demotion + keyframe re-request path covers the switch.
|
||||
if codec_id == ffmpeg::codec::Id::HEVC {
|
||||
let main10 = profiles.contains(&PROFILE_HEVC_VLD_MAIN10)
|
||||
&& unsafe { video.CheckVideoDecoderFormat(&PROFILE_HEVC_VLD_MAIN10, DXGI_FORMAT_P010) }
|
||||
.map(|b| b.as_bool())
|
||||
.unwrap_or(false);
|
||||
tracing::info!(main10, "HEVC Main10 (10-bit/HDR) decode profile");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One-time dump of the adapter's DXVA decode profiles.
|
||||
fn log_profiles_once(profiles: &[GUID]) {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static ONCE: AtomicBool = AtomicBool::new(true);
|
||||
if ONCE.swap(false, Ordering::Relaxed) {
|
||||
let list: Vec<String> = profiles.iter().map(|g| format!("{g:?}")).collect();
|
||||
tracing::info!(count = profiles.len(), profiles = ?list, "adapter DXVA decode profiles");
|
||||
}
|
||||
}
|
||||
|
||||
// --- software backend ---------------------------------------------------------------
|
||||
|
||||
struct SoftwareDecoder {
|
||||
decoder: ffmpeg::decoder::Video,
|
||||
/// Rebuilt whenever the decoded format/size **or output format** changes (mid-stream
|
||||
/// `Reconfigure`, or an 8↔10-bit flip): `(ctx, src_fmt, w, h, dst_fmt)`.
|
||||
sws: Option<(scaling::Context, Pixel, u32, u32, Pixel)>,
|
||||
}
|
||||
|
||||
impl SoftwareDecoder {
|
||||
fn new(codec_id: ffmpeg::codec::Id) -> Result<SoftwareDecoder> {
|
||||
let codec = ffmpeg::decoder::find(codec_id)
|
||||
.ok_or_else(|| anyhow!("no {codec_id:?} decoder in libavcodec"))?;
|
||||
let mut ctx = ffmpeg::codec::Context::new_with_codec(codec);
|
||||
unsafe {
|
||||
let raw = ctx.as_mut_ptr();
|
||||
(*raw).flags |= ffmpeg::ffi::AV_CODEC_FLAG_LOW_DELAY as i32;
|
||||
// Slice threading adds no frame delay (frame threading adds thread_count-1).
|
||||
(*raw).thread_type = ffmpeg::ffi::FF_THREAD_SLICE;
|
||||
(*raw).thread_count = 0; // auto
|
||||
}
|
||||
let decoder = ctx.decoder().video().context("open video decoder")?;
|
||||
Ok(SoftwareDecoder { decoder, sws: None })
|
||||
}
|
||||
|
||||
fn decode(&mut self, au: &[u8]) -> Result<Option<CpuFrame>> {
|
||||
let packet = ffmpeg::Packet::copy(au);
|
||||
self.decoder
|
||||
.send_packet(&packet)
|
||||
.map_err(|e| anyhow!("send_packet: {e}"))?;
|
||||
let mut frame = AvFrame::empty();
|
||||
let mut out = None;
|
||||
while self.decoder.receive_frame(&mut frame).is_ok() {
|
||||
out = Some(self.convert(&frame)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Convert the decoded planar YUV to the hardware path's layout: NV12 for 8-bit, P010 for
|
||||
/// 10-bit — a chroma interleave (and 10→16-high-bits shift), NOT a colour conversion. The
|
||||
/// matrix/range/transfer handling all lives in the presenter's shaders, shared with the
|
||||
/// D3D11VA path, so software frames are bit-comparable with hardware ones.
|
||||
fn convert(&mut self, frame: &AvFrame) -> Result<CpuFrame> {
|
||||
let (fmt, w, h) = (frame.format(), frame.width(), frame.height());
|
||||
// SAFETY: `frame` wraps a live decoded AVFrame for the duration of this call.
|
||||
let color = unsafe { ColorDesc::from_raw(frame.as_ptr()) };
|
||||
let hdr = color.is_pq();
|
||||
// Source bit depth from the pix-fmt descriptor (stable FFmpeg public API).
|
||||
let ten_bit = unsafe {
|
||||
let desc = ffmpeg::ffi::av_pix_fmt_desc_get(fmt.into());
|
||||
!desc.is_null() && (*desc).comp[0].depth > 8
|
||||
};
|
||||
let dst = if ten_bit { Pixel::P010LE } else { Pixel::NV12 };
|
||||
let rebuild = !matches!(&self.sws, Some((_, f, sw, sh, d)) if *f == fmt && *sw == w && *sh == h && *d == dst);
|
||||
if rebuild {
|
||||
let ctx = scaling::Context::get(fmt, w, h, dst, w, h, scaling::Flags::POINT)
|
||||
.context("swscale context")?;
|
||||
self.sws = Some((ctx, fmt, w, h, dst));
|
||||
}
|
||||
let (sws, ..) = self.sws.as_mut().unwrap();
|
||||
let mut conv = AvFrame::empty();
|
||||
sws.run(frame, &mut conv).map_err(|e| anyhow!("sws: {e}"))?;
|
||||
Ok(CpuFrame {
|
||||
width: w,
|
||||
height: h,
|
||||
y: conv.data(0).to_vec(),
|
||||
y_stride: conv.stride(0),
|
||||
uv: conv.data(1).to_vec(),
|
||||
uv_stride: conv.stride(1),
|
||||
ten_bit,
|
||||
hdr,
|
||||
color,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- D3D11VA backend ------------------------------------------------------------------
|
||||
//
|
||||
// Raw FFI: ffmpeg-next has no hwaccel wrappers. The COM-typed hwcontext structs are declared here
|
||||
// (stable FFmpeg public ABI) rather than relied on from ffmpeg-sys bindgen — the generic
|
||||
// AVHWDeviceContext / AVHWFramesContext (whose payload is an opaque `void *hwctx`) come from
|
||||
// ffmpeg-sys, and we cast `hwctx` to the structs below. All owned pointers are freed in Drop;
|
||||
// decoded surfaces transfer out through D3d11FrameGuard.
|
||||
|
||||
const AVERROR_EAGAIN: i32 = -11; // -EAGAIN
|
||||
|
||||
/// D3D11VA decode surface pool depth: the zero-reorder DPB (1–2 refs) + the bounded decoded channel
|
||||
/// (2) + the frame the presenter currently holds (until its copy flushes) + one in-flight decode —
|
||||
/// 12 is comfortable. A GPU that can't create the pool at all is gated out by
|
||||
/// `d3d11va_decode_supported` and the session uses software decode.
|
||||
const DECODE_POOL_SIZE: i32 = 12;
|
||||
|
||||
/// `hwcontext_d3d11va.h` — `AVHWDeviceContext::hwctx`. Leaving `lock` null makes FFmpeg install an
|
||||
/// `ID3D11Multithread` default lock + set multithread protection on `device_context` during init,
|
||||
/// which is what lets the presenter share this device's immediate context from the render thread.
|
||||
#[repr(C)]
|
||||
struct AVD3D11VADeviceContext {
|
||||
device: *mut c_void, // ID3D11Device*
|
||||
device_context: *mut c_void, // ID3D11DeviceContext*
|
||||
video_device: *mut c_void, // ID3D11VideoDevice*
|
||||
video_context: *mut c_void, // ID3D11VideoContext*
|
||||
lock: *mut c_void, // void (*)(void*)
|
||||
unlock: *mut c_void, // void (*)(void*)
|
||||
lock_ctx: *mut c_void,
|
||||
}
|
||||
|
||||
/// `hwcontext_d3d11va.h` — `AVHWFramesContext::hwctx`. The header is explicit: "The user must at
|
||||
/// least set D3D11_BIND_DECODER if the frames context is to be used for video decoding" — a
|
||||
/// user-built frames context gets NO default (BindFlags 0 → `CreateTexture2D` E_INVALIDARG); the
|
||||
/// automatic OR-in lives only in libavcodec's own frames-param path, which we bypass.
|
||||
#[repr(C)]
|
||||
struct AVD3D11VAFramesContext {
|
||||
texture: *mut c_void, // ID3D11Texture2D* (null → FFmpeg allocates the pool)
|
||||
bind_flags: u32, // UINT BindFlags
|
||||
misc_flags: u32, // UINT MiscFlags
|
||||
texture_infos: *mut c_void, // AVD3D11FrameDescriptor* (FFmpeg-managed)
|
||||
}
|
||||
|
||||
/// `D3D11_BIND_DECODER` — the decode pool's ONLY bind flag. Adding `D3D11_BIND_SHADER_RESOURCE`
|
||||
/// is what NVIDIA rejects on a decoder texture ARRAY; the presenter samples via its own copy.
|
||||
const BIND_DECODER: u32 = 0x200;
|
||||
|
||||
fn averr(what: &str, code: i32) -> anyhow::Error {
|
||||
anyhow!("{what}: {}", ffmpeg::Error::from(code))
|
||||
}
|
||||
|
||||
/// libavcodec's `get_format` callback: pick the D3D11 hw surface format and nothing else.
|
||||
/// Deliberately does NOT build a frames context — with `hw_device_ctx` set and `hw_frames_ctx`
|
||||
/// left null, libavcodec derives the decode pool itself (`ff_decode_get_hw_frames_ctx`), applying
|
||||
/// every vendor quirk: DXVA surface alignment (128 for HEVC/AV1), DPB-based pool sizing, and the
|
||||
/// decoder-only `D3D11_BIND_DECODER` flags. A hand-built context validated on NVIDIA was rejected
|
||||
/// by Intel at the first `SubmitDecoderBuffers` (E_INVALIDARG) — the vendor-proof path is the one
|
||||
/// the ffmpeg CLI/mpv ship. Returning anything but `AV_PIX_FMT_D3D11` aborts hardware decode →
|
||||
/// the session demotes to software.
|
||||
unsafe extern "C" fn get_format_d3d11(
|
||||
avctx: *mut ffmpeg::ffi::AVCodecContext,
|
||||
mut list: *const ffmpeg::ffi::AVPixelFormat,
|
||||
) -> ffmpeg::ffi::AVPixelFormat {
|
||||
use ffmpeg::ffi::*;
|
||||
unsafe {
|
||||
if (*avctx).hw_device_ctx.is_null() {
|
||||
return AVPixelFormat::AV_PIX_FMT_NONE;
|
||||
}
|
||||
while *list != AVPixelFormat::AV_PIX_FMT_NONE {
|
||||
if *list == AVPixelFormat::AV_PIX_FMT_D3D11 {
|
||||
return AVPixelFormat::AV_PIX_FMT_D3D11;
|
||||
}
|
||||
list = list.add(1);
|
||||
}
|
||||
AVPixelFormat::AV_PIX_FMT_NONE
|
||||
}
|
||||
}
|
||||
|
||||
/// Predict whether D3D11VA decode will work by doing EXACTLY what the decoder's `get_format` does —
|
||||
/// allocate an `AVHWFramesContext` (decoder-only pool, no shader-resource bind) and initialize it,
|
||||
/// which creates the real NV12 decode surface array. On a GPU/driver that can't create the pool this
|
||||
/// fails here, up front, so the session commits to software decode from the first frame (a clean,
|
||||
/// gap-free stream) rather than decoding the IDR then dying mid-stream on a texture error that a
|
||||
/// software demotion can't reliably recover from (the host's infinite GOP won't re-send an IDR).
|
||||
unsafe fn d3d11va_decode_supported(hw_device: *mut ffmpeg::ffi::AVBufferRef) -> bool {
|
||||
use ffmpeg::ffi::*;
|
||||
unsafe {
|
||||
let frames_ref = av_hwframe_ctx_alloc(hw_device);
|
||||
if frames_ref.is_null() {
|
||||
return false;
|
||||
}
|
||||
let frames = (*frames_ref).data as *mut AVHWFramesContext;
|
||||
(*frames).format = AVPixelFormat::AV_PIX_FMT_D3D11;
|
||||
(*frames).sw_format = AVPixelFormat::AV_PIX_FMT_NV12;
|
||||
(*frames).width = 1920;
|
||||
(*frames).height = 1152; // 128-aligned 1080p surface (the HEVC DXVA alignment, see get_format)
|
||||
(*frames).initial_pool_size = DECODE_POOL_SIZE;
|
||||
// Decoder-only — matches get_format exactly.
|
||||
let fhw = (*frames).hwctx as *mut AVD3D11VAFramesContext;
|
||||
(*fhw).bind_flags = BIND_DECODER;
|
||||
let r = av_hwframe_ctx_init(frames_ref);
|
||||
let mut fr = frames_ref;
|
||||
av_buffer_unref(&mut fr);
|
||||
r >= 0
|
||||
}
|
||||
}
|
||||
|
||||
struct D3d11vaDecoder {
|
||||
ctx: *mut ffmpeg::ffi::AVCodecContext,
|
||||
hw_device: *mut ffmpeg::ffi::AVBufferRef,
|
||||
packet: *mut ffmpeg::ffi::AVPacket,
|
||||
frame: *mut ffmpeg::ffi::AVFrame,
|
||||
}
|
||||
|
||||
// Single-owner pointers, only touched from the session pump thread.
|
||||
unsafe impl Send for D3d11vaDecoder {}
|
||||
|
||||
impl D3d11vaDecoder {
|
||||
fn new(codec_id: ffmpeg::codec::Id) -> Result<D3d11vaDecoder> {
|
||||
use ffmpeg::ffi;
|
||||
let shared = crate::gpu::shared().ok_or_else(|| anyhow!("no shared D3D11 device"))?;
|
||||
if !shared.hardware {
|
||||
bail!("shared device is WARP (no hardware video decode)");
|
||||
}
|
||||
// The adapter must expose the codec's DXVA profile — checked here, not at the first AU.
|
||||
decode_profile_supported(&shared.device, codec_id)?;
|
||||
unsafe {
|
||||
// Build a D3D11VA hwdevice context around the *shared* device, so decoded textures live
|
||||
// on the same device the presenter samples + draws with.
|
||||
let hw_device =
|
||||
ffi::av_hwdevice_ctx_alloc(ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA);
|
||||
if hw_device.is_null() {
|
||||
bail!("av_hwdevice_ctx_alloc(D3D11VA) failed");
|
||||
}
|
||||
let devctx = (*hw_device).data as *mut ffi::AVHWDeviceContext;
|
||||
let d3dctx = (*devctx).hwctx as *mut AVD3D11VADeviceContext;
|
||||
// Hand FFmpeg an owned ref to the device + immediate context (it Releases them when the
|
||||
// hwdevice ctx is freed). `into_raw()` transfers a +1 ref without releasing.
|
||||
(*d3dctx).device = shared.device.clone().into_raw();
|
||||
(*d3dctx).device_context = shared.context.clone().into_raw();
|
||||
// lock left null → FFmpeg installs the ID3D11Multithread default lock in init.
|
||||
let r = ffi::av_hwdevice_ctx_init(hw_device);
|
||||
if r < 0 {
|
||||
let mut hw = hw_device;
|
||||
ffi::av_buffer_unref(&mut hw);
|
||||
bail!("av_hwdevice_ctx_init: {}", ffmpeg::Error::from(r));
|
||||
}
|
||||
|
||||
// Up-front viability probe (see `d3d11va_decode_supported`): a GPU/driver that can't
|
||||
// create the decode surface pool commits to software NOW, so it decodes cleanly from the
|
||||
// first frame instead of failing mid-stream (which a demotion can't reliably recover).
|
||||
if !d3d11va_decode_supported(hw_device) {
|
||||
let mut hw = hw_device;
|
||||
ffi::av_buffer_unref(&mut hw);
|
||||
bail!("GPU can't create the D3D11VA decode surface pool — using software decode");
|
||||
}
|
||||
|
||||
let codec = ffi::avcodec_find_decoder(codec_id.into());
|
||||
if codec.is_null() {
|
||||
let mut hw = hw_device;
|
||||
ffi::av_buffer_unref(&mut hw);
|
||||
bail!("no {codec_id:?} decoder");
|
||||
}
|
||||
let ctx = ffi::avcodec_alloc_context3(codec);
|
||||
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device);
|
||||
(*ctx).get_format = Some(get_format_d3d11);
|
||||
(*ctx).flags |= ffi::AV_CODEC_FLAG_LOW_DELAY as i32;
|
||||
// hwaccel: threads only add latency.
|
||||
(*ctx).thread_count = 1;
|
||||
// On top of the DPB-based pool libavcodec sizes for us: the bounded decoded channel
|
||||
// (2) + the frame the presenter holds until its copy flushes + margin.
|
||||
(*ctx).extra_hw_frames = 4;
|
||||
let r = ffi::avcodec_open2(ctx, codec, ptr::null_mut());
|
||||
if r < 0 {
|
||||
let mut ctx = ctx;
|
||||
ffi::avcodec_free_context(&mut ctx);
|
||||
let mut hw = hw_device;
|
||||
ffi::av_buffer_unref(&mut hw);
|
||||
bail!("avcodec_open2 (D3D11VA): {}", ffmpeg::Error::from(r));
|
||||
}
|
||||
Ok(D3d11vaDecoder {
|
||||
ctx,
|
||||
hw_device,
|
||||
packet: ffi::av_packet_alloc(),
|
||||
frame: ffi::av_frame_alloc(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn decode(&mut self, au: &[u8]) -> Result<Option<GpuFrame>> {
|
||||
use ffmpeg::ffi;
|
||||
unsafe {
|
||||
let r = ffi::av_new_packet(self.packet, au.len() as i32);
|
||||
if r < 0 {
|
||||
return Err(averr("av_new_packet", r));
|
||||
}
|
||||
ptr::copy_nonoverlapping(au.as_ptr(), (*self.packet).data, au.len());
|
||||
let r = ffi::avcodec_send_packet(self.ctx, self.packet);
|
||||
ffi::av_packet_unref(self.packet);
|
||||
if r < 0 {
|
||||
return Err(averr("send_packet", r));
|
||||
}
|
||||
let mut out = None;
|
||||
loop {
|
||||
let r = ffi::avcodec_receive_frame(self.ctx, self.frame);
|
||||
if r == AVERROR_EAGAIN {
|
||||
break;
|
||||
}
|
||||
if r < 0 {
|
||||
return Err(averr("receive_frame", r));
|
||||
}
|
||||
out = Some(self.lift()?); // newest wins; older guards drop here
|
||||
ffi::av_frame_unref(self.frame);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Lift the decoded D3D11 surface into a `GpuFrame`. `data[0]` is the texture array, `data[1]`
|
||||
/// the slice index. We `av_frame_clone` so the surface stays referenced (kept out of the reuse
|
||||
/// pool) until the presenter drops the guard.
|
||||
unsafe fn lift(&mut self) -> Result<GpuFrame> {
|
||||
use ffmpeg::ffi;
|
||||
unsafe {
|
||||
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_D3D11 as i32 {
|
||||
bail!("decoder returned a software frame (no D3D11 surface)");
|
||||
}
|
||||
// SAFETY: `self.frame` is the live decoded AVFrame for the duration of this call.
|
||||
let color = ColorDesc::from_raw(self.frame);
|
||||
let hdr = color.is_pq();
|
||||
let ten_bit = {
|
||||
let hwfc = (*self.frame).hw_frames_ctx;
|
||||
!hwfc.is_null()
|
||||
&& (*((*hwfc).data as *const ffi::AVHWFramesContext)).sw_format
|
||||
== ffi::AVPixelFormat::AV_PIX_FMT_P010LE
|
||||
};
|
||||
let cloned = ffi::av_frame_clone(self.frame);
|
||||
if cloned.is_null() {
|
||||
bail!("av_frame_clone failed");
|
||||
}
|
||||
let frame = GpuFrame {
|
||||
width: (*self.frame).width as u32,
|
||||
height: (*self.frame).height as u32,
|
||||
index: (*self.frame).data[1] as usize as u32,
|
||||
ten_bit,
|
||||
hdr,
|
||||
color,
|
||||
guard: D3d11FrameGuard(cloned),
|
||||
};
|
||||
log_layout_once(frame.width, frame.height, frame.index, hdr, ten_bit);
|
||||
Ok(frame)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for D3d11vaDecoder {
|
||||
fn drop(&mut self) {
|
||||
use ffmpeg::ffi;
|
||||
unsafe {
|
||||
ffi::av_packet_free(&mut self.packet);
|
||||
ffi::av_frame_free(&mut self.frame);
|
||||
ffi::avcodec_free_context(&mut self.ctx);
|
||||
ffi::av_buffer_unref(&mut self.hw_device);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One-time dump of the first decoded surface's layout — so a new GPU/driver combination's real
|
||||
/// format (slice index range, HDR/bit-depth) is visible in the logs without a debugger.
|
||||
fn log_layout_once(width: u32, height: u32, index: u32, hdr: bool, ten_bit: bool) {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static ONCE: AtomicBool = AtomicBool::new(true);
|
||||
if ONCE.swap(false, Ordering::Relaxed) {
|
||||
tracing::info!(
|
||||
width,
|
||||
height,
|
||||
slice = index,
|
||||
hdr,
|
||||
ten_bit,
|
||||
"D3D11VA first frame"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ use crate::audio;
|
||||
use crate::video::{DecodedFrame, DecodedImage, Decoder};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||
use punktfunk_core::reanchor::{index_gap, GateVerdict, ReanchorGate};
|
||||
use punktfunk_core::PunktfunkError;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
@@ -99,86 +100,6 @@ pub struct Stats {
|
||||
pub decoder: &'static str,
|
||||
}
|
||||
|
||||
/// Consecutive no-output AUs that force a keyframe request. ~50 ms at 60 Hz — long
|
||||
/// enough not to fire on a one-frame decoder hiccup, short enough that a lost initial
|
||||
/// 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.
|
||||
@@ -382,27 +303,17 @@ fn pump(
|
||||
// What actually decoded the last frame — a VAAPI failure demotes mid-session, so
|
||||
// this is read off each frame's image variant rather than fixed at startup.
|
||||
let mut dec_path: &'static str = "";
|
||||
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it climbs.
|
||||
let mut last_dropped = connector.frames_dropped();
|
||||
// The stats window keeps its own drop cursor — the OSD shows the per-window delta.
|
||||
let mut window_dropped = last_dropped;
|
||||
let mut window_dropped = connector.frames_dropped();
|
||||
let mut last_kf_req: Option<Instant> = None;
|
||||
// Consecutive received AUs that produced NO decoded frame (decode error, or the
|
||||
// decoder swallowed a reference-missing delta and returned nothing). Distinct from
|
||||
// `frames_dropped`, which counts reassembler drops: when the initial IDR is lost (or
|
||||
// we join mid-GOP) the reassembler delivers complete-but-undecodable deltas — it
|
||||
// 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;
|
||||
// Freeze-until-reanchor: the shared post-loss gate ([`punktfunk_core::reanchor::ReanchorGate`]).
|
||||
// Armed on any loss signal (frame-index gap, dropped-count climb, decoder wedge/demotion), it
|
||||
// withholds the decoder's concealed frames from the presenter — which then redraws the last good
|
||||
// picture — until a proven clean re-anchor (IDR / RFI anchor / second recovery mark) lifts it. It
|
||||
// also owns the no-output streak and the overdue-freeze backstop; the client keeps its own
|
||||
// `last_kf_req` request throttle and routes the gate's keyframe intents through it. Seeded with the
|
||||
// current drop count so the first `poll` doesn't read the baseline as a loss.
|
||||
let mut gate = ReanchorGate::new(connector.frames_dropped());
|
||||
// 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
|
||||
@@ -447,9 +358,7 @@ fn pump(
|
||||
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);
|
||||
gate.arm(now);
|
||||
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
|
||||
@@ -488,38 +397,14 @@ fn pump(
|
||||
}
|
||||
match decoder.decode(&frame.data) {
|
||||
Ok(Some(image)) => {
|
||||
// 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;
|
||||
}
|
||||
// Fold this decoded frame through the shared freeze gate: it reads the AU's
|
||||
// re-anchor wire flags (FLAG_SOF IDR marker / RECOVERY_ANCHOR / RECOVERY_POINT),
|
||||
// takes `image.is_keyframe()` as the ffmpeg keyframe belt, applies the two-mark
|
||||
// rule + the mark-patience backstop, clears the no-output streak, and returns
|
||||
// whether to present this frame or withhold it as a post-loss concealment.
|
||||
let present =
|
||||
gate.on_decoded(frame.flags, image.is_keyframe(), Instant::now())
|
||||
== GateVerdict::Present;
|
||||
total_frames += 1;
|
||||
dec_path = match &image {
|
||||
DecodedImage::Cpu(_) => "software",
|
||||
@@ -574,19 +459,19 @@ fn pump(
|
||||
DecodedImage::VkFrame(v) => Some((v.timeline_sem, v.decode_done_value)),
|
||||
_ => None,
|
||||
};
|
||||
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 {
|
||||
if present {
|
||||
let _ = frame_tx.force_send(DecodedFrame {
|
||||
pts_ns: frame.pts_ns,
|
||||
decoded_ns,
|
||||
image,
|
||||
});
|
||||
} else {
|
||||
// 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). The gate lifts the freeze on the next clean re-anchor / backstop.
|
||||
tracing::trace!("holding last frame — awaiting post-loss re-anchor");
|
||||
}
|
||||
// `decode` stage: received→decode COMPLETE, single clock.
|
||||
match hw_fence {
|
||||
@@ -602,36 +487,35 @@ fn pump(
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => no_output_streak += 1,
|
||||
// The decoder produced nothing — under zero-reorder LOW_DELAY (one-in/one-out) that
|
||||
// means it's wedged on missing references with no reassembler drop to trigger
|
||||
// recovery. The gate counts the streak and, once it trips, arms the freeze and tells
|
||||
// us to (throttled) request a fresh IDR to re-anchor. Both the empty-output and the
|
||||
// survivable-decode-error arms feed it; a decoded frame resets the streak in
|
||||
// `on_decoded`.
|
||||
Ok(None) => {
|
||||
let now = Instant::now();
|
||||
if gate.on_no_output(now)
|
||||
&& 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!("requested keyframe (decoder produced no output)");
|
||||
}
|
||||
}
|
||||
// Survivable (loss until the next IDR/RFI recovery) — keep feeding.
|
||||
Err(e) => {
|
||||
no_output_streak += 1;
|
||||
tracing::debug!(error = %e, "decode error (recovering)");
|
||||
}
|
||||
}
|
||||
// The decoder has produced nothing for a short run — under zero-reorder
|
||||
// LOW_DELAY (one-in/one-out) that means it's wedged on missing references
|
||||
// with no reassembler drop to trigger recovery below. Ask for a fresh IDR
|
||||
// (throttled), then re-arm the streak so we wait out the request→IDR round
|
||||
// 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))
|
||||
{
|
||||
last_kf_req = Some(now);
|
||||
let _ = connector.request_keyframe();
|
||||
tracing::debug!(
|
||||
streak = no_output_streak,
|
||||
"requested keyframe (decoder produced no output)"
|
||||
);
|
||||
no_output_streak = 0;
|
||||
let now = Instant::now();
|
||||
if gate.on_no_output(now)
|
||||
&& 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!("requested keyframe (decode error recovery)");
|
||||
}
|
||||
}
|
||||
}
|
||||
// The presenter's verdict: hardware frames can't be displayed (GL converter
|
||||
@@ -649,9 +533,7 @@ 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);
|
||||
gate.arm(now);
|
||||
if last_kf_req
|
||||
.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||
{
|
||||
@@ -679,41 +561,26 @@ fn pump(
|
||||
}
|
||||
}
|
||||
|
||||
// Loss recovery: under infinite GOP the only recovery keyframe is one we request. The
|
||||
// reassembler drops unrecoverable AUs (frames_dropped); the decoder then conceals the
|
||||
// reference-missing delta frames that follow and returns Ok, so keying off a decode error
|
||||
// rarely fires. Request an IDR when the drop count climbs, throttled — the decode stays
|
||||
// wedged for several frames until the IDR lands, so requesting every frame would flood.
|
||||
// Loss recovery + overdue backstop, folded through the shared gate. A climb in the
|
||||
// reassembler's unrecoverable-drop count (`frames_dropped`) means the AUs after the lost one
|
||||
// reference a picture we never decoded — the decoder conceals them (gray on RADV) and returns
|
||||
// Ok, so a decode-error trigger rarely fires; the gate arms the freeze on the climb instead. An
|
||||
// overdue freeze (held a full REANCHOR_FREEZE_MAX with no clean re-anchor — a lost recovery IDR,
|
||||
// or a benign reorder that produced no `frames_dropped`) re-asks while it keeps holding: NEVER
|
||||
// resume to gray — a genuinely dead stream is the QUIC idle-timeout watchdog's job. Both route
|
||||
// the gate's keyframe intent through the shared 100 ms throttle; under infinite GOP the only
|
||||
// recovery keyframe is one we request.
|
||||
let dropped = connector.frames_dropped();
|
||||
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");
|
||||
}
|
||||
let now = Instant::now();
|
||||
if gate.poll(dropped, now)
|
||||
&& 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 / overdue re-anchor)"
|
||||
);
|
||||
}
|
||||
|
||||
if window_start.elapsed() >= Duration::from_secs(1) {
|
||||
@@ -836,111 +703,3 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
+141
-406
@@ -13,6 +13,7 @@
|
||||
use crate::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role};
|
||||
use crate::error::PunktfunkStatus;
|
||||
use crate::input::InputEvent;
|
||||
use crate::reanchor::{GateVerdict, ReanchorGate};
|
||||
use crate::session::Session;
|
||||
use crate::stats::Stats;
|
||||
use crate::transport::{loopback_pair, Transport, UdpTransport};
|
||||
@@ -526,10 +527,6 @@ 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
|
||||
@@ -926,14 +923,6 @@ 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 _: () = {
|
||||
@@ -943,8 +932,6 @@ 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).
|
||||
@@ -1409,7 +1396,6 @@ 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(),
|
||||
@@ -2276,397 +2262,6 @@ 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
|
||||
@@ -3026,3 +2621,143 @@ pub unsafe extern "C" fn punktfunk_connection_close(c: *mut PunktfunkConnection)
|
||||
drop(unsafe { Box::from_raw(c) });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Post-loss re-anchor freeze gate ----
|
||||
//
|
||||
// The shared [`ReanchorGate`](crate::reanchor::ReanchorGate) exposed for the Swift client (Rust
|
||||
// embedders — Android/Windows/Linux — use the struct directly). After an unrecoverable reference
|
||||
// loss the decoder silently conceals the missing-reference deltas (gray/garbage picture, no error);
|
||||
// the client freezes on the last good frame and lifts only on a proven clean re-anchor. The gate
|
||||
// takes time internally (`Instant::now`) so no timestamps cross the boundary. Drive it per session:
|
||||
// `arm` on a loss (frame-index gap from `punktfunk_connection_note_frame_index`, a decoder
|
||||
// wedge/demotion), `on_decoded` per decoded frame to gate presentation, `on_no_output` per AU that
|
||||
// produced nothing, and `poll` each iteration for the dropped-count climb + overdue backstop. Route
|
||||
// the returned keyframe intents through the client's existing request throttle.
|
||||
|
||||
/// Create a re-anchor gate seeded with the session's current `frames_dropped` (so the first
|
||||
/// [`punktfunk_reanchor_gate_poll`] doesn't read the baseline as a loss). Free with
|
||||
/// [`punktfunk_reanchor_gate_free`]. Never returns NULL.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn punktfunk_reanchor_gate_new(frames_dropped: u64) -> *mut ReanchorGate {
|
||||
Box::into_raw(Box::new(ReanchorGate::new(frames_dropped)))
|
||||
}
|
||||
|
||||
/// Free a gate created by [`punktfunk_reanchor_gate_new`]. NULL is a no-op.
|
||||
///
|
||||
/// # Safety
|
||||
/// `g` was returned by [`punktfunk_reanchor_gate_new`] and is not used after this call.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_reanchor_gate_free(g: *mut ReanchorGate) {
|
||||
if !g.is_null() {
|
||||
drop(unsafe { Box::from_raw(g) });
|
||||
}
|
||||
}
|
||||
|
||||
/// Arm the freeze: a loss was detected (a frame-index gap, or a decoder wedge/demotion). Zeroes the
|
||||
/// recovery-mark count and (re-)sets the backstop deadline. NULL is a no-op.
|
||||
///
|
||||
/// # Safety
|
||||
/// `g` is a valid gate handle.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_reanchor_gate_arm(g: *mut ReanchorGate) {
|
||||
if let Some(g) = unsafe { g.as_mut() } {
|
||||
g.arm(std::time::Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold one decoded frame and write to `out_present` whether to display it (`true`) or withhold it as
|
||||
/// a post-loss concealment (`false`). `flags` is the AU's `user_flags` word ([`PunktfunkFrame::flags`]):
|
||||
/// the gate reads `FLAG_SOF` (the host's IDR marker), `USER_FLAG_RECOVERY_ANCHOR` and
|
||||
/// `USER_FLAG_RECOVERY_POINT`. Pass `decoder_keyframe = false` where the platform decoder doesn't flag
|
||||
/// IDRs (VideoToolbox/MediaCodec) — the wire `FLAG_SOF` covers it.
|
||||
///
|
||||
/// # Safety
|
||||
/// `g` is a valid gate handle; `out_present` is writable or NULL.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_reanchor_gate_on_decoded(
|
||||
g: *mut ReanchorGate,
|
||||
flags: u32,
|
||||
decoder_keyframe: bool,
|
||||
out_present: *mut bool,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let g = match unsafe { g.as_mut() } {
|
||||
Some(g) => g,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
let present = g.on_decoded(flags, decoder_keyframe, std::time::Instant::now())
|
||||
== GateVerdict::Present;
|
||||
if !out_present.is_null() {
|
||||
unsafe { *out_present = present };
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// A received AU produced no decoded frame. Writes to `out_request_kf` whether the no-output streak has
|
||||
/// tripped and the client should (throttled) request a keyframe — the gate arms the freeze at the same
|
||||
/// time.
|
||||
///
|
||||
/// # Safety
|
||||
/// `g` is a valid gate handle; `out_request_kf` is writable or NULL.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_reanchor_gate_on_no_output(
|
||||
g: *mut ReanchorGate,
|
||||
out_request_kf: *mut bool,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let g = match unsafe { g.as_mut() } {
|
||||
Some(g) => g,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
let request = g.on_no_output(std::time::Instant::now());
|
||||
if !out_request_kf.is_null() {
|
||||
unsafe { *out_request_kf = request };
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// Periodic fold of the session's `frames_dropped` counter plus the overdue backstop. Writes to
|
||||
/// `out_request_kf` whether the client should (throttled) request a keyframe (a drop-count climb armed
|
||||
/// a fresh freeze, or the freeze is overdue and re-asks while it keeps holding).
|
||||
///
|
||||
/// # Safety
|
||||
/// `g` is a valid gate handle; `out_request_kf` is writable or NULL.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_reanchor_gate_poll(
|
||||
g: *mut ReanchorGate,
|
||||
frames_dropped: u64,
|
||||
out_request_kf: *mut bool,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let g = match unsafe { g.as_mut() } {
|
||||
Some(g) => g,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
let request = g.poll(frames_dropped, std::time::Instant::now());
|
||||
if !out_request_kf.is_null() {
|
||||
unsafe { *out_request_kf = request };
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the gate is currently withholding concealed frames (frozen on the last good picture).
|
||||
/// Writes `false` on a NULL gate.
|
||||
///
|
||||
/// # Safety
|
||||
/// `g` is a valid gate handle; `out_holding` is writable or NULL.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
|
||||
g: *const ReanchorGate,
|
||||
out_holding: *mut bool,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let holding = unsafe { g.as_ref() }.is_some_and(ReanchorGate::is_holding);
|
||||
if !out_holding.is_null() {
|
||||
unsafe { *out_holding = holding };
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,16 +12,15 @@
|
||||
//! 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, ClipControl,
|
||||
ClipKind, ClipOffer, ClipState, ClockEcho, ClockResync, ColorInfo, HdrMeta, Hello, HidOutput,
|
||||
LossReport, ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, ResyncStep,
|
||||
RfiRequest, RichInput, SetBitrate, Start, Welcome,
|
||||
accept_resync, endpoint, io, wall_clock_ns, window_loss_ppm, BitrateChanged, 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;
|
||||
@@ -63,12 +62,6 @@ 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
|
||||
@@ -100,10 +93,6 @@ 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
|
||||
@@ -362,12 +351,6 @@ 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 {
|
||||
@@ -480,19 +463,6 @@ 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>,
|
||||
@@ -696,9 +666,6 @@ 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));
|
||||
@@ -764,8 +731,6 @@ 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,
|
||||
@@ -799,10 +764,6 @@ 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,
|
||||
@@ -1279,83 +1240,6 @@ 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.
|
||||
@@ -1455,8 +1339,6 @@ 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.
|
||||
@@ -1500,8 +1382,6 @@ 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,
|
||||
@@ -1652,22 +1532,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) = match setup.await {
|
||||
let (conn, mut session, mut ctrl_send, mut ctrl_recv, negotiated, host_caps) = match setup.await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
let _ = ready_tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Copies the worker needs after `negotiated` is handed over to `connect`.
|
||||
// Copies the pump 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);
|
||||
@@ -1693,6 +1573,23 @@ async fn worker_main(args: WorkerArgs) {
|
||||
// Touched pads only: an entry appears on the first gamepad event for that index, so the
|
||||
// refresh never conjures a virtual pad the embedder didn't drive.
|
||||
let mut pads: [Option<GamepadSnapshot>; MAX_PADS] = [None; MAX_PADS];
|
||||
// Per-pad wrapping seq that PERSISTS across a pad's remove/re-add on the same index (the
|
||||
// snapshot itself is cleared to `None` on removal). A removal takes `seq[idx] + 1` so it
|
||||
// supersedes every prior snapshot; the re-added pad's first snapshot takes the next value
|
||||
// after that, so the host's seq gate accepts it instead of rejecting a restarted-at-0 seq.
|
||||
let mut seq: [u8; MAX_PADS] = [0; MAX_PADS];
|
||||
// Re-sends of a removal still owed on refresh ticks (the removal rides the lossy datagram
|
||||
// plane; a single lost one would silently strand a ghost pad on the host — the exact bug
|
||||
// the removal fixes). Mirrors the host's rumble stop burst: a few time-spread re-sends,
|
||||
// each with a fresh (higher) seq, and canceled the moment the pad is driven again.
|
||||
const REMOVE_RESENDS: u8 = 2;
|
||||
let mut remove_owed: [u8; MAX_PADS] = [0; MAX_PADS];
|
||||
// Per-pad declared controller kind ([`GamepadArrival`]) + its owed re-sends: the host needs
|
||||
// the kind before the pad's first frame to build a matching virtual device (mixed types), so
|
||||
// like the removal it rides the lossy plane with a small time-spread re-send burst.
|
||||
const ARRIVAL_RESENDS: u8 = 2;
|
||||
let mut arrival: [Option<u8>; MAX_PADS] = [None; MAX_PADS];
|
||||
let mut arrival_owed: [u8; MAX_PADS] = [0; MAX_PADS];
|
||||
let mut refresh = tokio::time::interval(Duration::from_millis(100));
|
||||
refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
@@ -1704,24 +1601,89 @@ async fn worker_main(args: WorkerArgs) {
|
||||
&& matches!(ev.kind, InputKind::GamepadButton | InputKind::GamepadAxis)
|
||||
&& idx < MAX_PADS
|
||||
{
|
||||
// The pad is being driven — cancel any owed removal (a re-plug on this
|
||||
// index; its fresh snapshot seq already supersedes the removal's).
|
||||
remove_owed[idx] = 0;
|
||||
let snap = pads[idx].get_or_insert(GamepadSnapshot {
|
||||
pad: idx as u8,
|
||||
..Default::default()
|
||||
});
|
||||
// Unknown axis ids don't send (the host's legacy fold drops them too).
|
||||
if snap.fold(&ev) {
|
||||
snap.seq = snap.seq.wrapping_add(1);
|
||||
seq[idx] = seq[idx].wrapping_add(1);
|
||||
snap.seq = seq[idx];
|
||||
let _ = input_conn
|
||||
.send_datagram(snap.to_event().encode().to_vec().into());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if gamepad_snapshots && ev.kind == InputKind::GamepadRemove && idx < MAX_PADS {
|
||||
// Stop refreshing the pad and forward a seq-stamped removal (in the shared
|
||||
// seq space) so the host tears its virtual device down and no reordered
|
||||
// snapshot can resurrect it; arm the re-send burst against datagram loss.
|
||||
// Drop any owed kind declaration too — a re-plug on this index sends its own.
|
||||
pads[idx] = None;
|
||||
arrival[idx] = None;
|
||||
arrival_owed[idx] = 0;
|
||||
seq[idx] = seq[idx].wrapping_add(1);
|
||||
remove_owed[idx] = REMOVE_RESENDS;
|
||||
let rem = crate::input::InputEvent {
|
||||
flags: crate::input::encode_gamepad_remove(idx as u8, seq[idx]),
|
||||
..ev
|
||||
};
|
||||
let _ = input_conn.send_datagram(rem.encode().to_vec().into());
|
||||
continue;
|
||||
}
|
||||
if gamepad_snapshots && ev.kind == InputKind::GamepadArrival && idx < MAX_PADS {
|
||||
// Remember the declared kind (`code`) and forward it, arming a re-send burst
|
||||
// so the host learns it before the pad's first frame even under loss.
|
||||
arrival[idx] = Some(ev.code as u8);
|
||||
arrival_owed[idx] = ARRIVAL_RESENDS;
|
||||
let _ = input_conn.send_datagram(ev.encode().to_vec().into());
|
||||
continue;
|
||||
}
|
||||
let _ = input_conn.send_datagram(ev.encode().to_vec().into());
|
||||
}
|
||||
_ = refresh.tick() => {
|
||||
for snap in pads.iter_mut().flatten() {
|
||||
snap.seq = snap.seq.wrapping_add(1);
|
||||
let _ = input_conn.send_datagram(snap.to_event().encode().to_vec().into());
|
||||
for idx in 0..MAX_PADS {
|
||||
// Re-send an owed kind declaration (independent of whether the pad has state
|
||||
// yet — it may be idle-but-connected). Idempotent on the host.
|
||||
if arrival_owed[idx] > 0 {
|
||||
if let Some(kind) = arrival[idx] {
|
||||
arrival_owed[idx] -= 1;
|
||||
let arr = crate::input::InputEvent {
|
||||
kind: InputKind::GamepadArrival,
|
||||
_pad: [0; 3],
|
||||
code: kind as u32,
|
||||
x: 0,
|
||||
y: 0,
|
||||
flags: idx as u32,
|
||||
};
|
||||
let _ = input_conn.send_datagram(arr.encode().to_vec().into());
|
||||
} else {
|
||||
arrival_owed[idx] = 0;
|
||||
}
|
||||
}
|
||||
if let Some(snap) = pads[idx].as_mut() {
|
||||
seq[idx] = seq[idx].wrapping_add(1);
|
||||
snap.seq = seq[idx];
|
||||
let _ = input_conn.send_datagram(snap.to_event().encode().to_vec().into());
|
||||
} else if remove_owed[idx] > 0 {
|
||||
// Idempotent removal re-send with a fresh seq (the host drops it as a
|
||||
// no-op once the pad is already gone, but a re-plug's later snapshot
|
||||
// still wins by seq).
|
||||
remove_owed[idx] -= 1;
|
||||
seq[idx] = seq[idx].wrapping_add(1);
|
||||
let rem = crate::input::InputEvent {
|
||||
kind: InputKind::GamepadRemove,
|
||||
_pad: [0; 3],
|
||||
code: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
flags: crate::input::encode_gamepad_remove(idx as u8, seq[idx]),
|
||||
};
|
||||
let _ = input_conn.send_datagram(rem.encode().to_vec().into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1759,9 +1721,6 @@ 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
|
||||
@@ -1791,8 +1750,6 @@ 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;
|
||||
@@ -1874,21 +1831,6 @@ 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");
|
||||
}
|
||||
@@ -1965,17 +1907,6 @@ 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();
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
//! 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,39 @@ pub enum InputKind {
|
||||
/// [`HOST_CAP_GAMEPAD_STATE`](crate::quic::HOST_CAP_GAMEPAD_STATE); older hosts keep
|
||||
/// receiving the per-transition events.
|
||||
GamepadState = 12,
|
||||
/// A pad was unplugged client-side (the native plane's answer to GameStream's
|
||||
/// `activeGamepadMask`, which the per-transition/snapshot planes otherwise lack — see
|
||||
/// [`encode_gamepad_remove`]). `flags` packs `seq << 24 | pad`: the low byte is the pad
|
||||
/// index, the high byte a per-pad wrapping seq sharing the [`GamepadSnapshot`] sequence
|
||||
/// space. The host clears the pad's `active_mask` bit so its virtual device is torn down,
|
||||
/// seq-gated against snapshots so one the network reordered past the removal can't resurrect
|
||||
/// the pad, and the shared seq space keeps the same index reusable by a later re-plug. Sent
|
||||
/// only to a host that advertised [`HOST_CAP_GAMEPAD_STATE`](crate::quic::HOST_CAP_GAMEPAD_STATE);
|
||||
/// an older host ignores the unknown tag (the pad then lingers until session end — the
|
||||
/// pre-existing behaviour).
|
||||
GamepadRemove = 13,
|
||||
/// Declares which controller KIND a pad presents so a session can MIX types (pad 0 a
|
||||
/// DualSense, pad 1 an Xbox pad). `code` = the [`GamepadPref`](crate::config::GamepadPref)
|
||||
/// wire byte, `flags` = pad index. Sent when the client opens a pad slot — before that pad's
|
||||
/// first input — and re-sent a few times against datagram loss (like [`GamepadRemove`]). The
|
||||
/// host resolves the kind to a buildable backend and routes that pad's virtual device to it; a
|
||||
/// pad the client never declares (an older client, or a fully-lost declaration) falls back to
|
||||
/// the session-default kind from the handshake. Idempotent (no seq): re-declaring the same kind
|
||||
/// is a no-op. Meaningful only to a host that advertised
|
||||
/// [`HOST_CAP_GAMEPAD_STATE`](crate::quic::HOST_CAP_GAMEPAD_STATE); an older host ignores the
|
||||
/// unknown tag (every pad then uses the session-default kind — the pre-existing behaviour).
|
||||
GamepadArrival = 14,
|
||||
}
|
||||
|
||||
/// Pack a [`InputKind::GamepadRemove`] `flags` word (`seq << 24 | pad`) — the same low-byte-pad /
|
||||
/// high-byte-seq layout as [`GamepadSnapshot::to_event`], so a removal seq-gates against snapshots.
|
||||
pub fn encode_gamepad_remove(pad: u8, seq: u8) -> u32 {
|
||||
((seq as u32) << 24) | (pad as u32)
|
||||
}
|
||||
|
||||
/// Unpack a [`InputKind::GamepadRemove`] `flags` word into `(pad, seq)`.
|
||||
pub fn decode_gamepad_remove(flags: u32) -> (u8, u8) {
|
||||
(flags as u8, (flags >> 24) as u8)
|
||||
}
|
||||
|
||||
/// The gamepad wire contract for [`InputKind::GamepadButton`]/[`InputKind::GamepadAxis`].
|
||||
@@ -123,6 +156,8 @@ impl InputKind {
|
||||
10 => TouchMove,
|
||||
11 => TouchUp,
|
||||
12 => GamepadState,
|
||||
13 => GamepadRemove,
|
||||
14 => GamepadArrival,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -321,8 +356,26 @@ mod tests {
|
||||
};
|
||||
assert_eq!(InputEvent::decode(&e.encode()), Some(e));
|
||||
}
|
||||
// 13 (one past GamepadState) is not a valid kind.
|
||||
assert_eq!(InputKind::from_u8(13), None);
|
||||
// GamepadRemove + GamepadArrival are valid kinds; 15 (one past them) is not.
|
||||
assert_eq!(InputKind::from_u8(13), Some(InputKind::GamepadRemove));
|
||||
assert_eq!(InputKind::from_u8(14), Some(InputKind::GamepadArrival));
|
||||
assert_eq!(InputKind::from_u8(15), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepad_remove_flags_roundtrip() {
|
||||
for (pad, seq) in [(0u8, 0u8), (3, 200), (15, 255), (7, 1)] {
|
||||
let flags = encode_gamepad_remove(pad, seq);
|
||||
assert_eq!(decode_gamepad_remove(flags), (pad, seq));
|
||||
}
|
||||
// Layout matches the snapshot's pad/seq packing (low byte pad, high byte seq).
|
||||
let snap = GamepadSnapshot {
|
||||
pad: 9,
|
||||
seq: 123,
|
||||
..Default::default()
|
||||
};
|
||||
let (pad, seq) = decode_gamepad_remove(snap.to_event().flags);
|
||||
assert_eq!((pad, seq), (9, 123));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -30,11 +30,6 @@ 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;
|
||||
@@ -43,6 +38,7 @@ pub mod input;
|
||||
pub mod packet;
|
||||
#[cfg(feature = "quic")]
|
||||
pub mod quic;
|
||||
pub mod reanchor;
|
||||
pub mod session;
|
||||
pub mod stats;
|
||||
pub mod transport;
|
||||
@@ -66,10 +62,9 @@ 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.
|
||||
/// 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.
|
||||
/// v6: added the `punktfunk_reanchor_gate_*` surface (post-loss freeze-until-reanchor gate for the
|
||||
/// Swift client; Rust embedders use [`reanchor::ReanchorGate`] directly). Additive, client-local —
|
||||
/// no wire change, so [`WIRE_VERSION`] is unchanged.
|
||||
pub const ABI_VERSION: u32 = 6;
|
||||
|
||||
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
//! 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,10 +49,6 @@ 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
|
||||
|
||||
@@ -142,14 +142,6 @@ 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.
|
||||
@@ -516,147 +508,6 @@ 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
|
||||
@@ -1416,174 +1267,6 @@ 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());
|
||||
|
||||
@@ -1159,390 +1159,3 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
//! Post-loss display freeze — the shared "freeze-until-reanchor" gate.
|
||||
//!
|
||||
//! After an unrecoverable reference loss the hardware decoder does **not** error: it *conceals* the
|
||||
//! reference-missing delta frames (on RADV, the DPB-and-output-COINCIDE path paints a gray plate with
|
||||
//! the new frame's motion on top) and returns Ok. Displaying that is the "gray frames mid-stream"
|
||||
//! artifact. Instead every client freezes on the last good picture — withholds the concealed frames
|
||||
//! from its presenter, which keeps redrawing the held frame — and lifts the freeze ONLY on a proven
|
||||
//! clean re-anchor: a real IDR, an LTR-RFI recovery anchor ([`USER_FLAG_RECOVERY_ANCHOR`]), or the
|
||||
//! second intra-refresh recovery mark ([`USER_FLAG_RECOVERY_POINT`]) since the loss.
|
||||
//!
|
||||
//! This module owns that decision so every embedder shares ONE implementation instead of re-deriving
|
||||
//! it (the Linux/Deck pump in `pf-client-core`, the Windows in-process pump, the Android decode loops,
|
||||
//! and — over the C ABI — the Apple client). The state machine is time-driven but takes `now` as a
|
||||
//! parameter so it is unit-testable without a clock; the C ABI wrappers supply `Instant::now()`.
|
||||
//!
|
||||
//! [`USER_FLAG_RECOVERY_POINT`]: crate::packet::USER_FLAG_RECOVERY_POINT
|
||||
//! [`USER_FLAG_RECOVERY_ANCHOR`]: crate::packet::USER_FLAG_RECOVERY_ANCHOR
|
||||
|
||||
use crate::packet::{FLAG_SOF, USER_FLAG_RECOVERY_ANCHOR, USER_FLAG_RECOVERY_POINT};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Consecutive no-output AUs that force a keyframe request. ~50 ms at 60 Hz — long enough not to fire
|
||||
/// on a one-frame decoder hiccup, short enough that a lost initial IDR (or a mid-GOP join) unfreezes
|
||||
/// almost immediately instead of never.
|
||||
pub const NO_OUTPUT_KEYFRAME_STREAK: u32 = 3;
|
||||
|
||||
/// Longest the gate holds the last good frame waiting for a post-loss re-anchor keyframe before it
|
||||
/// re-asks. 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): the freeze is NEVER lifted to
|
||||
/// the concealed picture — the deadline re-asks for a keyframe and keeps holding, so a glitch can never
|
||||
/// become a permanent freeze while a clean re-anchor is what un-freezes. A recovery IDR round-trips well
|
||||
/// under this on any live link.
|
||||
pub 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 loss before the gate 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 fresh
|
||||
/// arm 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`]: crate::packet::USER_FLAG_RECOVERY_POINT
|
||||
pub 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 gate 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.
|
||||
pub 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.
|
||||
pub 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`](crate::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`](crate::packet::USER_FLAG_RECOVERY_POINT), a
|
||||
/// host-signalled intra-refresh wave boundary (only *half* a re-anchor). `marks` — recovery marks seen
|
||||
/// since the latest loss.
|
||||
///
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a decoded frame should be shown or withheld while the gate is (or isn't) frozen.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GateVerdict {
|
||||
/// Present this frame — the gate is not frozen, or this frame is the clean re-anchor that lifts it.
|
||||
Present,
|
||||
/// Withhold this frame — it is a post-loss concealment; the presenter keeps the last good picture.
|
||||
Hold,
|
||||
}
|
||||
|
||||
/// The shared post-loss freeze state machine. A client feeds it three kinds of event — an *arm* (a
|
||||
/// loss was detected: a frame-index gap, a dropped-count climb, or a decoder wedge/demotion), each
|
||||
/// *decoded frame* ([`on_decoded`](Self::on_decoded), which decides present-vs-hold and interprets the
|
||||
/// re-anchor wire flags), and each *no-output* AU ([`on_no_output`](Self::on_no_output)) — plus a
|
||||
/// periodic [`poll`](Self::poll) that folds the dropped counter and fires the overdue backstop.
|
||||
///
|
||||
/// The gate emits *intents* only: [`on_no_output`](Self::on_no_output) and [`poll`](Self::poll) return
|
||||
/// `true` when the client should ask the host for a keyframe. The client routes that through its own
|
||||
/// ~100 ms request throttle (and the precise RFI-vs-keyframe range decision stays in the loss-range
|
||||
/// tracker behind [`crate::client::NativeClient::note_frame_index`]) — the gate never touches the wire.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReanchorGate {
|
||||
/// Frozen on the last good frame, withholding the decoder's concealed output until a clean
|
||||
/// re-anchor. Armed by any loss signal; cleared only by [`on_decoded`](Self::on_decoded) lifting.
|
||||
awaiting: bool,
|
||||
/// Host intra-refresh recovery marks seen since the latest arm (see [`REANCHOR_MARKS_TO_LIFT`]).
|
||||
/// Reset to 0 whenever the freeze is (re-)armed, so a fresh loss always waits out two fresh marks.
|
||||
marks: u32,
|
||||
/// When the freeze becomes overdue and [`poll`](Self::poll) re-asks for a keyframe (holding, never
|
||||
/// resuming to the concealed picture). `None` when not frozen.
|
||||
deadline: Option<Instant>,
|
||||
/// Consecutive received AUs that produced no decoded frame — a decoder wedged on missing references
|
||||
/// with no reassembler drop to trigger recovery. A short streak forces a fresh IDR.
|
||||
no_output_streak: u32,
|
||||
/// The last `frames_dropped` value [`poll`](Self::poll) observed; a climb means the reassembler
|
||||
/// declared an AU unrecoverable and the following deltas will conceal, so arm.
|
||||
last_dropped: u64,
|
||||
}
|
||||
|
||||
impl ReanchorGate {
|
||||
/// Seed the gate with the session's current `frames_dropped` so the first [`poll`](Self::poll)
|
||||
/// doesn't read the baseline as a loss.
|
||||
pub fn new(frames_dropped: u64) -> Self {
|
||||
ReanchorGate {
|
||||
awaiting: false,
|
||||
marks: 0,
|
||||
deadline: None,
|
||||
no_output_streak: 0,
|
||||
last_dropped: frames_dropped,
|
||||
}
|
||||
}
|
||||
|
||||
/// Arm the freeze: a loss was detected (a frame-index gap, a dropped-count climb, or a decoder
|
||||
/// wedge/demotion). Zeroes the mark count so a fresh loss waits out two fresh recovery marks, and
|
||||
/// (re-)sets the backstop deadline. Idempotent while already frozen (re-arming just re-zeroes the
|
||||
/// marks and pushes the deadline — the correct behaviour when a second loss lands mid-freeze).
|
||||
pub fn arm(&mut self, now: Instant) {
|
||||
self.awaiting = true;
|
||||
self.marks = 0;
|
||||
self.deadline = Some(now + REANCHOR_FREEZE_MAX);
|
||||
}
|
||||
|
||||
/// Fold one decoded frame and decide whether to present or withhold it.
|
||||
///
|
||||
/// `wire_flags` is the AU's `user_flags` word ([`crate::session::Frame::flags`] /
|
||||
/// `PunktfunkFrame.flags`); the gate reads [`FLAG_SOF`](crate::packet::FLAG_SOF) (the host sets it
|
||||
/// only on IDR AUs — the codec-agnostic keyframe signal the platform decoders don't expose),
|
||||
/// [`USER_FLAG_RECOVERY_ANCHOR`] and [`USER_FLAG_RECOVERY_POINT`]. `decoder_keyframe` is an optional
|
||||
/// belt from decoders that flag IDRs themselves (libavcodec's `AV_FRAME_FLAG_KEY` on Linux/Windows);
|
||||
/// pass `false` where the decoder doesn't (Android MediaCodec, Apple VideoToolbox) and rely on the
|
||||
/// wire `FLAG_SOF`.
|
||||
///
|
||||
/// A decoded frame always clears the no-output streak. When frozen, a live mark stream pushes the
|
||||
/// backstop out ([`RECOVERY_MARK_PATIENCE`]) so a healing wave isn't pre-empted by a mid-heal IDR.
|
||||
///
|
||||
/// [`USER_FLAG_RECOVERY_ANCHOR`]: crate::packet::USER_FLAG_RECOVERY_ANCHOR
|
||||
/// [`USER_FLAG_RECOVERY_POINT`]: crate::packet::USER_FLAG_RECOVERY_POINT
|
||||
pub fn on_decoded(
|
||||
&mut self,
|
||||
wire_flags: u32,
|
||||
decoder_keyframe: bool,
|
||||
now: Instant,
|
||||
) -> GateVerdict {
|
||||
self.no_output_streak = 0;
|
||||
let is_keyframe = decoder_keyframe || (wire_flags & FLAG_SOF as u32 != 0);
|
||||
let has_anchor = wire_flags & USER_FLAG_RECOVERY_ANCHOR != 0;
|
||||
let has_mark = wire_flags & USER_FLAG_RECOVERY_POINT != 0;
|
||||
if has_mark && self.awaiting {
|
||||
self.deadline = Some(now + RECOVERY_MARK_PATIENCE);
|
||||
}
|
||||
let (lift, marks) = reanchor_after_frame(is_keyframe, has_anchor, has_mark, self.marks);
|
||||
self.marks = marks;
|
||||
if lift {
|
||||
self.awaiting = false;
|
||||
self.deadline = None;
|
||||
}
|
||||
if self.awaiting {
|
||||
GateVerdict::Hold
|
||||
} else {
|
||||
GateVerdict::Present
|
||||
}
|
||||
}
|
||||
|
||||
/// A received AU produced no decoded frame (decode error, or the decoder swallowed a
|
||||
/// reference-missing delta). Returns `true` when the streak has tripped and the client should
|
||||
/// (throttled) request a keyframe — arming the freeze at the same time, since the stream is broken
|
||||
/// regardless of whether the throttle lets the request through this iteration.
|
||||
pub fn on_no_output(&mut self, now: Instant) -> bool {
|
||||
self.no_output_streak += 1;
|
||||
if self.no_output_streak >= NO_OUTPUT_KEYFRAME_STREAK {
|
||||
self.arm(now);
|
||||
self.no_output_streak = 0;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Periodic fold of the session's `frames_dropped` counter plus the overdue backstop. Returns
|
||||
/// `true` when the client should (throttled) request a keyframe: either the drop count climbed (a
|
||||
/// fresh unrecoverable loss — arm the freeze) or the freeze has held a full [`REANCHOR_FREEZE_MAX`]
|
||||
/// window with no re-anchor (re-ask and keep holding — NEVER resume to the concealed picture; a
|
||||
/// genuinely dead stream is the QUIC idle-timeout watchdog's job, not the gate's).
|
||||
pub fn poll(&mut self, frames_dropped: u64, now: Instant) -> bool {
|
||||
let mut want_keyframe = false;
|
||||
if frames_dropped > self.last_dropped {
|
||||
self.last_dropped = frames_dropped;
|
||||
self.arm(now);
|
||||
want_keyframe = true;
|
||||
}
|
||||
if self.awaiting && self.deadline.is_some_and(|d| now >= d) {
|
||||
self.deadline = Some(now + REANCHOR_FREEZE_MAX);
|
||||
want_keyframe = true;
|
||||
}
|
||||
want_keyframe
|
||||
}
|
||||
|
||||
/// Whether the gate is currently withholding concealed frames (frozen on the last good picture).
|
||||
pub fn is_holding(&self) -> bool {
|
||||
self.awaiting
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Simulate the gate'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. A reset to 0 models a fresh loss
|
||||
// re-arming the freeze (the gate zeroes the count at every 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 gate zeroes `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.
|
||||
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);
|
||||
}
|
||||
|
||||
// ---- gate-level sequence tests (the whole behavioural contract) ----
|
||||
|
||||
const SOF: u32 = FLAG_SOF as u32; // IDR wire flag
|
||||
const ANCHOR: u32 = USER_FLAG_RECOVERY_ANCHOR;
|
||||
const POINT: u32 = USER_FLAG_RECOVERY_POINT;
|
||||
|
||||
fn t0() -> Instant {
|
||||
Instant::now()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_link_never_holds() {
|
||||
// Disarmed gate presents every frame, keyframe or not, and never asks for anything.
|
||||
let mut g = ReanchorGate::new(0);
|
||||
let now = t0();
|
||||
assert_eq!(g.on_decoded(0, false, now), GateVerdict::Present);
|
||||
assert_eq!(g.on_decoded(SOF, true, now), GateVerdict::Present);
|
||||
assert!(!g.is_holding());
|
||||
assert!(!g.poll(0, now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gap_holds_until_the_wire_keyframe_lifts() {
|
||||
// Android/Apple path: no decoder keyframe flag, lift comes from the wire FLAG_SOF alone.
|
||||
let mut g = ReanchorGate::new(0);
|
||||
let now = t0();
|
||||
g.arm(now); // frame-index gap
|
||||
assert!(g.is_holding());
|
||||
assert_eq!(g.on_decoded(0, false, now), GateVerdict::Hold); // concealed delta withheld
|
||||
assert_eq!(g.on_decoded(0, false, now), GateVerdict::Hold);
|
||||
assert_eq!(g.on_decoded(SOF, false, now), GateVerdict::Present); // IDR re-anchors
|
||||
assert!(!g.is_holding());
|
||||
assert_eq!(g.on_decoded(0, false, now), GateVerdict::Present); // stays presenting
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gap_lifts_on_the_first_rfi_anchor() {
|
||||
let mut g = ReanchorGate::new(0);
|
||||
let now = t0();
|
||||
g.arm(now);
|
||||
assert_eq!(g.on_decoded(0, false, now), GateVerdict::Hold);
|
||||
assert_eq!(g.on_decoded(ANCHOR, false, now), GateVerdict::Present);
|
||||
assert!(!g.is_holding());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gap_lifts_on_the_second_recovery_mark() {
|
||||
let mut g = ReanchorGate::new(0);
|
||||
let now = t0();
|
||||
g.arm(now);
|
||||
assert_eq!(g.on_decoded(POINT, false, now), GateVerdict::Hold); // first boundary: half-healed
|
||||
assert_eq!(g.on_decoded(0, false, now), GateVerdict::Hold);
|
||||
assert_eq!(g.on_decoded(POINT, false, now), GateVerdict::Present); // second: clean
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_gap_mid_freeze_resets_the_marks() {
|
||||
let mut g = ReanchorGate::new(0);
|
||||
let now = t0();
|
||||
g.arm(now);
|
||||
assert_eq!(g.on_decoded(POINT, false, now), GateVerdict::Hold); // mark #1
|
||||
g.arm(now); // a fresh loss re-arms → mark count zeroed
|
||||
assert_eq!(g.on_decoded(POINT, false, now), GateVerdict::Hold); // this is mark #1 of the new wave
|
||||
assert_eq!(g.on_decoded(POINT, false, now), GateVerdict::Present); // #2 lifts
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dropped_climb_arms_and_asks() {
|
||||
let mut g = ReanchorGate::new(5);
|
||||
let now = t0();
|
||||
assert!(!g.poll(5, now), "no climb → no ask"); // baseline
|
||||
assert!(g.poll(6, now), "a climb asks for a keyframe");
|
||||
assert!(g.is_holding(), "and arms the freeze");
|
||||
assert!(
|
||||
!g.poll(6, now),
|
||||
"same value → no repeat ask from the drop path"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_no_output_streak_trips_at_three() {
|
||||
let mut g = ReanchorGate::new(0);
|
||||
let now = t0();
|
||||
assert!(!g.on_no_output(now));
|
||||
assert!(!g.on_no_output(now));
|
||||
assert!(g.on_no_output(now), "third no-output trips the streak");
|
||||
assert!(g.is_holding());
|
||||
// A decoded frame resets the streak.
|
||||
g.on_decoded(SOF, false, now); // lifts + resets streak
|
||||
assert!(!g.on_no_output(now));
|
||||
assert!(!g.on_no_output(now));
|
||||
assert!(g.on_no_output(now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overdue_freeze_re_asks_but_keeps_holding() {
|
||||
let mut g = ReanchorGate::new(0);
|
||||
let start = t0();
|
||||
g.arm(start);
|
||||
// Before the deadline: holding, no re-ask.
|
||||
assert!(!g.poll(0, start));
|
||||
assert!(g.is_holding());
|
||||
// Past REANCHOR_FREEZE_MAX with no re-anchor: re-ask, still holding.
|
||||
let later = start + REANCHOR_FREEZE_MAX + Duration::from_millis(1);
|
||||
assert!(g.poll(0, later), "overdue freeze re-asks for a keyframe");
|
||||
assert!(g.is_holding(), "but never resumes to the concealed picture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_mark_stream_pushes_the_deadline_out() {
|
||||
// A healing wave (marks arriving) must not be pre-empted by the overdue IDR floor.
|
||||
let mut g = ReanchorGate::new(0);
|
||||
let start = t0();
|
||||
g.arm(start);
|
||||
// A mark past the original freeze deadline pushes it out by RECOVERY_MARK_PATIENCE.
|
||||
let t = start + REANCHOR_FREEZE_MAX + Duration::from_millis(10);
|
||||
// mark #1 pushes the deadline out; at a time that WOULD have been overdue on the ORIGINAL
|
||||
// deadline, poll does not re-ask.
|
||||
assert_eq!(g.on_decoded(POINT, false, t), GateVerdict::Hold);
|
||||
assert!(!g.poll(0, t + Duration::from_millis(1)));
|
||||
assert!(g.is_holding());
|
||||
}
|
||||
}
|
||||
@@ -88,11 +88,9 @@ 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. (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.)
|
||||
# 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.
|
||||
ashpd = { version = "0.13", features = ["screencast", "remote_desktop"] }
|
||||
ffmpeg-next = "8"
|
||||
libc = "0.2"
|
||||
@@ -117,7 +115,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", "staging"] }
|
||||
wayland-protocols = { version = "0.32", features = ["client"] }
|
||||
# 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.
|
||||
@@ -202,11 +200,6 @@ 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",
|
||||
@@ -266,6 +259,12 @@ nvenc = ["dep:nvidia-video-codec-sdk"]
|
||||
# so the LGPL build suffices and keeps the bundled DLLs LGPL, not GPL) at build time and bundles the
|
||||
# FFmpeg DLLs at runtime. Build the all-vendor GPU host with `--features nvenc,amf-qsv`.
|
||||
amf-qsv = ["dep:ffmpeg-next"]
|
||||
# Raw Vulkan Video HEVC encode on Linux (AMD/Intel) — real reference-frame-invalidation loss
|
||||
# recovery via explicit DPB reference slots (design/linux-vulkan-video-encode.md), the open-stack
|
||||
# twin of the direct-NVENC path. OFF by default; pulls NO new dependency (reuses the `ash` Vulkan
|
||||
# bindings already carried for the dmabuf zero-copy bridge). Runtime-gated further by
|
||||
# PUNKTFUNK_VULKAN_ENCODE (opt-in for now). Build the AMD/Intel RFI host with `--features vulkan-encode`.
|
||||
vulkan-encode = []
|
||||
|
||||
# Build-time icon/version-info embedding (build.rs; Windows dev/CI hosts only — Linux packaging
|
||||
# builds of this crate never execute the winresource block).
|
||||
|
||||
@@ -1,409 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -1,524 +0,0 @@
|
||||
//! 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");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
//! 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;
|
||||
}
|
||||
@@ -1,599 +0,0 @@
|
||||
//! `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");
|
||||
}
|
||||
}
|
||||
@@ -1,664 +0,0 @@
|
||||
//! 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) },
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -373,7 +373,7 @@ pub fn open_video(
|
||||
bit_depth: u8,
|
||||
chroma: ChromaFormat,
|
||||
) -> Result<Box<dyn Encoder>> {
|
||||
let inner = open_video_backend(
|
||||
let (inner, backend) = open_video_backend(
|
||||
codec,
|
||||
format,
|
||||
width,
|
||||
@@ -385,10 +385,12 @@ pub fn open_video(
|
||||
chroma,
|
||||
)?;
|
||||
// Record what this session encodes on (the mgmt API's "currently used GPU"): the backend label
|
||||
// mirrors the dispatch `open_video_backend` just took, the GPU identity is the same selection
|
||||
// the capturer was created on ([`crate::gpu::selected_gpu`]). Dropping the returned encoder
|
||||
// ends the record, so the live count is correct by construction.
|
||||
let backend = resolved_backend_label(cuda);
|
||||
// is reported by `open_video_backend` from the branch that ACTUALLY opened — not re-derived by
|
||||
// mirroring its dispatch, which went stale the moment a backend gained an internal fallback
|
||||
// (the default-on Vulkan Video path falls back to VAAPI on a failed open, and a dispatch
|
||||
// mirror would report "vaapi" for every Vulkan session or vice versa). The GPU identity is the
|
||||
// same selection the capturer was created on ([`crate::gpu::selected_gpu`]). Dropping the
|
||||
// returned encoder ends the record, so the live count is correct by construction.
|
||||
let gpu = if backend == "software" {
|
||||
crate::gpu::ActiveGpu {
|
||||
id: String::new(),
|
||||
@@ -418,39 +420,6 @@ pub fn open_video(
|
||||
}))
|
||||
}
|
||||
|
||||
/// The display label of the backend [`open_video_backend`] resolves — kept in lockstep with its
|
||||
/// dispatch (`windows_resolved_backend` on Windows; the `PUNKTFUNK_ENCODER`/auto match on Linux).
|
||||
#[cfg(target_os = "windows")]
|
||||
fn resolved_backend_label(_cuda: bool) -> &'static str {
|
||||
match windows_resolved_backend() {
|
||||
WindowsBackend::Nvenc => "nvenc",
|
||||
WindowsBackend::Amf => "amf",
|
||||
WindowsBackend::Qsv => "qsv",
|
||||
WindowsBackend::Software => "software",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn resolved_backend_label(cuda: bool) -> &'static str {
|
||||
match crate::config::config().encoder_pref.as_str() {
|
||||
"nvenc" | "nvidia" | "cuda" => "nvenc",
|
||||
"vaapi" | "amd" | "intel" => "vaapi",
|
||||
"software" | "sw" | "openh264" => "software",
|
||||
_ => {
|
||||
if cuda || !linux_auto_is_vaapi() {
|
||||
"nvenc"
|
||||
} else {
|
||||
"vaapi"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
fn resolved_backend_label(_cuda: bool) -> &'static str {
|
||||
"none"
|
||||
}
|
||||
|
||||
/// Ties the [`crate::gpu`] live-session record to the encoder's lifetime; pure delegation
|
||||
/// otherwise.
|
||||
struct TrackedEncoder {
|
||||
@@ -488,6 +457,10 @@ impl Encoder for TrackedEncoder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the platform encoder backend. Returns the encoder together with the display label of the
|
||||
/// branch that ACTUALLY opened (`nvenc`/`vaapi`/`vulkan`/`amf`/`qsv`/`software`) — the label feeds
|
||||
/// the mgmt API's live-session record, and only the open site knows which internal fallback won
|
||||
/// (e.g. Vulkan Video falling back to VAAPI).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn open_video_backend(
|
||||
codec: Codec,
|
||||
@@ -499,7 +472,7 @@ fn open_video_backend(
|
||||
cuda: bool,
|
||||
bit_depth: u8,
|
||||
chroma: ChromaFormat,
|
||||
) -> Result<Box<dyn Encoder>> {
|
||||
) -> Result<(Box<dyn Encoder>, &'static str)> {
|
||||
validate_dimensions(codec, width, height)?;
|
||||
// Refresh/fps must be positive and sane: fps feeds the encoder time_base (`Rational(1, fps)`)
|
||||
// and the pts→ns conversion (`pts * 1e9 / fps`), so 0 builds a 1/0 rational / divides by zero.
|
||||
@@ -528,7 +501,30 @@ fn open_video_backend(
|
||||
// Linux binary serves any GPU; `PUNKTFUNK_ENCODER` forces a specific backend (and surfaces
|
||||
// its errors crisply instead of silently trying the other).
|
||||
let pref = crate::config::config().encoder_pref.as_str();
|
||||
let open_vaapi = || -> Result<Box<dyn Encoder>> {
|
||||
// AMD/Intel opener. Default = libav VAAPI. With `--features vulkan-encode` +
|
||||
// PUNKTFUNK_VULKAN_ENCODE, an HEVC session instead opens the raw Vulkan Video backend (real
|
||||
// RFI loss recovery the VAAPI path can't express); a failed open falls back to VAAPI so the
|
||||
// stream never dies over the new path. `format`/`bit_depth`/`chroma` only matter to VAAPI —
|
||||
// the Vulkan backend imports the dmabuf and does its own 8-bit 4:2:0 CSC.
|
||||
let open_amd_intel = || -> Result<(Box<dyn Encoder>, &'static str)> {
|
||||
#[cfg(feature = "vulkan-encode")]
|
||||
if matches!(codec, Codec::H265 | Codec::Av1) && vulkan_encode_enabled() {
|
||||
match vulkan_video::VulkanVideoEncoder::open(codec, width, height, fps, bitrate_bps)
|
||||
{
|
||||
Ok(e) => {
|
||||
tracing::info!(
|
||||
codec = ?codec,
|
||||
"Linux Vulkan Video encode (real RFI via DPB reference slots) — \
|
||||
set PUNKTFUNK_VULKAN_ENCODE=0 for libav VAAPI"
|
||||
);
|
||||
return Ok((Box::new(e) as Box<dyn Encoder>, "vulkan"));
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"Vulkan Video encode open failed — falling back to libav VAAPI"
|
||||
),
|
||||
}
|
||||
}
|
||||
vaapi::VaapiEncoder::open(
|
||||
codec,
|
||||
format,
|
||||
@@ -539,10 +535,10 @@ fn open_video_backend(
|
||||
bit_depth,
|
||||
chroma,
|
||||
)
|
||||
.map(|e| Box::new(e) as Box<dyn Encoder>)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "vaapi"))
|
||||
};
|
||||
match pref {
|
||||
"nvenc" | "nvidia" | "cuda" => open_nvenc_probed(
|
||||
let open_nvidia = || -> Result<(Box<dyn Encoder>, &'static str)> {
|
||||
open_nvenc_probed(
|
||||
codec,
|
||||
format,
|
||||
width,
|
||||
@@ -552,8 +548,32 @@ fn open_video_backend(
|
||||
cuda,
|
||||
bit_depth,
|
||||
chroma,
|
||||
),
|
||||
"vaapi" | "amd" | "intel" => open_vaapi(),
|
||||
)
|
||||
.map(|e| (e, "nvenc"))
|
||||
};
|
||||
match pref {
|
||||
"nvenc" | "nvidia" | "cuda" => open_nvidia(),
|
||||
"vaapi" | "amd" | "intel" => open_amd_intel(),
|
||||
// Force the raw Vulkan Video HEVC backend (real RFI). Needs `--features vulkan-encode`.
|
||||
"vulkan" | "vulkan-video" => {
|
||||
#[cfg(feature = "vulkan-encode")]
|
||||
{
|
||||
if !matches!(codec, Codec::H265 | Codec::Av1) {
|
||||
anyhow::bail!(
|
||||
"the Vulkan Video encoder supports HEVC + AV1; the session negotiated {codec:?}"
|
||||
);
|
||||
}
|
||||
vulkan_video::VulkanVideoEncoder::open(codec, width, height, fps, bitrate_bps)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "vulkan"))
|
||||
}
|
||||
#[cfg(not(feature = "vulkan-encode"))]
|
||||
{
|
||||
let _ = (format, bit_depth, chroma);
|
||||
anyhow::bail!(
|
||||
"PUNKTFUNK_ENCODER=vulkan requires a build with --features vulkan-encode"
|
||||
)
|
||||
}
|
||||
}
|
||||
// GPU-less software H.264 (openh264) — for a headless / GPU-lost box. Explicit-only:
|
||||
// `auto` never picks it (a box with `/dev/nvidiactl` present but a dead driver would
|
||||
// otherwise wrongly resolve to NVENC). Needs H.264 (openh264 emits only that) and a CPU
|
||||
@@ -567,30 +587,20 @@ fn open_video_backend(
|
||||
}
|
||||
let _ = (cuda, bit_depth); // software path is CPU + 8-bit only
|
||||
sw::OpenH264Encoder::open(format, width, height, fps, bitrate_bps)
|
||||
.map(|e| Box::new(e) as Box<dyn Encoder>)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "software"))
|
||||
}
|
||||
"auto" | "" => {
|
||||
// A CUDA frame can ONLY be consumed by NVENC. Otherwise the shared auto decision
|
||||
// (manual web-console GPU preference, else the NVIDIA-presence probe) picks the
|
||||
// backend — see `linux_auto_is_vaapi`.
|
||||
if cuda || !linux_auto_is_vaapi() {
|
||||
open_nvenc_probed(
|
||||
codec,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
cuda,
|
||||
bit_depth,
|
||||
chroma,
|
||||
)
|
||||
open_nvidia()
|
||||
} else {
|
||||
open_vaapi()
|
||||
open_amd_intel()
|
||||
}
|
||||
}
|
||||
other => anyhow::bail!(
|
||||
"unknown PUNKTFUNK_ENCODER={other:?} — use auto (default), nvenc, vaapi, or software"
|
||||
"unknown PUNKTFUNK_ENCODER={other:?} — use auto (default), nvenc, vaapi, vulkan, or software"
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -637,7 +647,7 @@ fn open_video_backend(
|
||||
bit_depth,
|
||||
chroma,
|
||||
)
|
||||
.map(|e| Box::new(e) as Box<dyn Encoder>)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "nvenc"))
|
||||
}
|
||||
#[cfg(not(feature = "nvenc"))]
|
||||
{
|
||||
@@ -666,7 +676,7 @@ fn open_video_backend(
|
||||
bit_depth,
|
||||
chroma,
|
||||
)
|
||||
.map(|e| Box::new(e) as Box<dyn Encoder>)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "amf"))
|
||||
.map_err(|e| {
|
||||
e.context(
|
||||
"native AMF encode failed to open (update the AMD driver / amfrt64.dll \
|
||||
@@ -690,7 +700,7 @@ fn open_video_backend(
|
||||
bit_depth,
|
||||
chroma,
|
||||
)
|
||||
.map(|e| Box::new(e) as Box<dyn Encoder>)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "qsv"))
|
||||
}
|
||||
#[cfg(not(feature = "amf-qsv"))]
|
||||
{
|
||||
@@ -717,7 +727,7 @@ fn open_video_backend(
|
||||
fps,
|
||||
bitrate_bps.min(SW_BITRATE_CEIL),
|
||||
)
|
||||
.map(|e| Box::new(e) as Box<dyn Encoder>)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "software"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -830,6 +840,22 @@ fn nvenc_direct_enabled() -> bool {
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Whether the raw Vulkan Video HEVC encode backend is active for AMD/Intel. **Default ON** —
|
||||
/// on-glass validated 2026-07-12 on an AMD RADV 780M with a real Deck-class client: the pipelined
|
||||
/// encoder ran a rock-solid 1080p@240 HEVC session and healed loss with clean P-frame recovery
|
||||
/// anchors (never IDR) via explicit DPB reference slots — real reference-frame invalidation the
|
||||
/// libavcodec VAAPI path can't express (design/linux-vulkan-video-encode.md). `PUNKTFUNK_VULKAN_ENCODE=0`
|
||||
/// (also `false`/`no`/`off`) is the libav-VAAPI escape hatch. Only consulted with
|
||||
/// `--features vulkan-encode`, and a failed open falls back to VAAPI, so an unsupported device
|
||||
/// (e.g. a Mesa without h265 encode, or an untested Intel/ANV where the path misbehaves at open)
|
||||
/// degrades gracefully to the old backend rather than breaking the stream.
|
||||
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
||||
fn vulkan_encode_enabled() -> bool {
|
||||
std::env::var("PUNKTFUNK_VULKAN_ENCODE")
|
||||
.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
|
||||
@@ -1185,6 +1211,20 @@ mod sw;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "encode/linux/vaapi.rs"]
|
||||
mod vaapi;
|
||||
// Raw Vulkan Video HEVC encode on Linux (AMD/Intel; design/linux-vulkan-video-encode.md) — real RFI
|
||||
// via explicit DPB reference slots (the app owns the DPB), the open-stack twin of the direct-NVENC
|
||||
// path. Does an on-GPU RGB→NV12 compute CSC since capture delivers packed-RGB dmabufs. Opt-in behind
|
||||
// `PUNKTFUNK_VULKAN_ENCODE` until on-glass validated; needs `--features vulkan-encode`.
|
||||
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
||||
#[path = "encode/linux/vulkan_video.rs"]
|
||||
mod vulkan_video;
|
||||
// Vendored `VK_KHR_video_encode_av1` bindings (host-only) — the AV1 encode structs our pinned
|
||||
// `ash 0.38.0+1.3.281` predates (finalized Vulkan 1.3.290). Copied verbatim from ash-master's
|
||||
// generated code rather than bumping `ash` (which breaks the SDL/Vulkan client). Consumed by
|
||||
// `vulkan_video.rs` via `super::vk_av1_encode`.
|
||||
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
||||
#[path = "encode/linux/vk_av1_encode.rs"]
|
||||
mod vk_av1_encode;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
#version 450
|
||||
// RGB(A) -> NV12 (BT.709 limited range). One invocation per chroma sample = 2x2 luma block.
|
||||
layout(local_size_x = 8, local_size_y = 8) in;
|
||||
layout(binding = 0) uniform sampler2D rgb; // packed RGB input (sampled; BGRA import ok)
|
||||
layout(binding = 1, r8) uniform writeonly image2D yImg; // full-res Y
|
||||
layout(binding = 2, rg8) uniform writeonly image2D uvImg; // half-res UV (interleaved)
|
||||
|
||||
float lumaY(vec3 c) { return 16.0/255.0 + 0.1826*c.r + 0.6142*c.g + 0.0620*c.b; }
|
||||
|
||||
// Source may be SMALLER than the coded (16-aligned) Y plane — e.g. 1080 source vs 1088 coded. Clamp
|
||||
// every fetch to the source edge so the alignment-padding rows duplicate the last real row instead
|
||||
// of reading out of bounds (undefined → green garbage that shows if a client ignores the SPS
|
||||
// conformance-window crop). `textureSize` gives the bound source's real extent.
|
||||
void main() {
|
||||
ivec2 sz = imageSize(yImg);
|
||||
ivec2 rmax = textureSize(rgb, 0) - 1;
|
||||
ivec2 uvc = ivec2(gl_GlobalInvocationID.xy);
|
||||
ivec2 p = uvc * 2;
|
||||
if (p.x >= sz.x || p.y >= sz.y) return;
|
||||
vec3 c00 = texelFetch(rgb, min(p, rmax), 0).rgb;
|
||||
vec3 c10 = texelFetch(rgb, min(p + ivec2(1, 0), rmax), 0).rgb;
|
||||
vec3 c01 = texelFetch(rgb, min(p + ivec2(0, 1), rmax), 0).rgb;
|
||||
vec3 c11 = texelFetch(rgb, min(p + ivec2(1, 1), rmax), 0).rgb;
|
||||
imageStore(yImg, p, vec4(lumaY(c00), 0, 0, 1));
|
||||
imageStore(yImg, p + ivec2(1, 0), vec4(lumaY(c10), 0, 0, 1));
|
||||
imageStore(yImg, p + ivec2(0, 1), vec4(lumaY(c01), 0, 0, 1));
|
||||
imageStore(yImg, p + ivec2(1, 1), vec4(lumaY(c11), 0, 0, 1));
|
||||
vec3 a = (c00 + c10 + c01 + c11) * 0.25;
|
||||
float U = 128.0/255.0 - 0.1006*a.r - 0.3386*a.g + 0.4392*a.b;
|
||||
float V = 128.0/255.0 + 0.4392*a.r - 0.3989*a.g - 0.0403*a.b;
|
||||
imageStore(uvImg, uvc, vec4(U, V, 0, 1));
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,500 @@
|
||||
//! Vendored `VK_KHR_video_encode_av1` bindings — the AV1-encode structs, `StdVideoEncodeAV1*`
|
||||
//! types and struct-type constants that our pinned `ash 0.38.0+1.3.281` does not ship (the
|
||||
//! extension was finalized in Vulkan 1.3.290). Bumping `ash` to git-master (`+1.4.352`, which has
|
||||
//! them) breaks the *client*: it drops the lifetime on `vk::AllocationCallbacks`, and `sdl3-sys`'s
|
||||
//! `ash` feature still generates `AllocationCallbacks<'static>`, so the presenter's SDL/Vulkan
|
||||
//! surface path won't compile. Rather than churn the client for a host-only need, we vendor just
|
||||
//! the encode-side definitions here, **copied verbatim from ash-master's generated code** (so the
|
||||
//! layouts are correct-by-construction) and chain them into ash's generic video-encode-queue calls
|
||||
//! via raw `p_next`, exactly as the HEVC path already chains its rate-control struct.
|
||||
//!
|
||||
//! Everything *common* to AV1 (sequence header, tile/quant/loop-filter/CDEF/… sub-structs, the
|
||||
//! `StdVideoAV1*` enums) is already present in 1.3.281's `ash::vk::native` — AV1 **decode** brought
|
||||
//! it in — so we reuse those and vendor only the encode-specific pieces. Delete this module and
|
||||
//! switch to `ash::vk::*` once `ash` publishes a 1.4.x release and `sdl3-sys` regenerates.
|
||||
#![allow(non_snake_case, non_camel_case_types, dead_code)]
|
||||
|
||||
use ash::vk;
|
||||
use ash::vk::native::{
|
||||
StdVideoAV1FrameType, StdVideoAV1InterpolationFilter, StdVideoAV1Level, StdVideoAV1Profile,
|
||||
StdVideoAV1SequenceHeader, StdVideoAV1TxMode,
|
||||
};
|
||||
use std::ffi::{c_void, CStr};
|
||||
|
||||
/// `VK_KHR_video_encode_av1` extension name — ash 0.38's `ash::khr::video_encode_av1` doesn't exist,
|
||||
/// so we pass this raw to `enabled_extension_names`.
|
||||
pub const EXTENSION_NAME: &CStr = c"VK_KHR_video_encode_av1";
|
||||
|
||||
// ---------- struct-type (VkStructureType) values — construct via `vk::StructureType::from_raw` ----------
|
||||
pub const ST_CAPABILITIES: i32 = 1_000_513_000;
|
||||
pub const ST_SESSION_PARAMETERS_CREATE_INFO: i32 = 1_000_513_001;
|
||||
pub const ST_PICTURE_INFO: i32 = 1_000_513_002;
|
||||
pub const ST_DPB_SLOT_INFO: i32 = 1_000_513_003;
|
||||
pub const ST_PHYSICAL_DEVICE_FEATURES: i32 = 1_000_513_004;
|
||||
pub const ST_PROFILE_INFO: i32 = 1_000_513_005;
|
||||
pub const ST_RATE_CONTROL_INFO: i32 = 1_000_513_006;
|
||||
pub const ST_RATE_CONTROL_LAYER_INFO: i32 = 1_000_513_007;
|
||||
pub const ST_SESSION_CREATE_INFO: i32 = 1_000_513_009;
|
||||
pub const ST_GOP_REMAINING_FRAME_INFO: i32 = 1_000_513_010;
|
||||
|
||||
/// `VK_VIDEO_CODEC_OPERATION_ENCODE_AV1_BIT_KHR` (bit 18).
|
||||
pub const VIDEO_CODEC_OPERATION_ENCODE_AV1: u32 = 0x0004_0000;
|
||||
/// `VK_MAX_VIDEO_AV1_REFERENCES_PER_FRAME_KHR` — LAST..ALTREF (the 7 inter reference names).
|
||||
pub const MAX_VIDEO_AV1_REFERENCES_PER_FRAME: usize = 7;
|
||||
/// `STD_VIDEO_AV1_PRIMARY_REF_NONE` — a frame that inherits no CDF/context from any reference
|
||||
/// (the recovery-anchor lever: a clean P-frame independent of prior probability state).
|
||||
pub const PRIMARY_REF_NONE: u8 = 7;
|
||||
/// `VK_VIDEO_ENCODE_AV1_SUPERBLOCK_SIZE_128_BIT_KHR` (bit 1 of the superblock-size flags).
|
||||
pub const SUPERBLOCK_SIZE_128: u32 = 0x2;
|
||||
|
||||
// `VkVideoEncodeAV1PredictionModeKHR`
|
||||
pub const PREDICTION_MODE_INTRA_ONLY: i32 = 0;
|
||||
pub const PREDICTION_MODE_SINGLE_REFERENCE: i32 = 1;
|
||||
// `VkVideoEncodeAV1RateControlGroupKHR`
|
||||
pub const RC_GROUP_INTRA: i32 = 0;
|
||||
pub const RC_GROUP_PREDICTIVE: i32 = 1;
|
||||
pub const RC_GROUP_BIPREDICTIVE: i32 = 2;
|
||||
|
||||
// AV1 reference names (index into `reference_name_slot_indices`, which is 0-based over LAST..ALTREF).
|
||||
pub const REFERENCE_NAME_LAST_FRAME_IDX: usize = 0; // STD_VIDEO_AV1_REFERENCE_NAME_LAST_FRAME - 1
|
||||
|
||||
// ---------- bindgen bitfield helper (copied verbatim from ash-master native.rs) ----------
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Default, Copy, Clone)]
|
||||
pub struct __BindgenBitfieldUnit<Storage> {
|
||||
storage: Storage,
|
||||
}
|
||||
impl<Storage> __BindgenBitfieldUnit<Storage> {
|
||||
#[inline]
|
||||
pub const fn new(storage: Storage) -> Self {
|
||||
Self { storage }
|
||||
}
|
||||
}
|
||||
impl<Storage> __BindgenBitfieldUnit<Storage>
|
||||
where
|
||||
Storage: AsRef<[u8]> + AsMut<[u8]>,
|
||||
{
|
||||
#[inline]
|
||||
pub fn get_bit(&self, index: usize) -> bool {
|
||||
let byte_index = index / 8;
|
||||
let byte = self.storage.as_ref()[byte_index];
|
||||
let bit_index = if cfg!(target_endian = "big") {
|
||||
7 - (index % 8)
|
||||
} else {
|
||||
index % 8
|
||||
};
|
||||
byte & (1 << bit_index) == (1 << bit_index)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_bit(&mut self, index: usize, val: bool) {
|
||||
let byte_index = index / 8;
|
||||
let byte = &mut self.storage.as_mut()[byte_index];
|
||||
let bit_index = if cfg!(target_endian = "big") {
|
||||
7 - (index % 8)
|
||||
} else {
|
||||
index % 8
|
||||
};
|
||||
let mask = 1 << bit_index;
|
||||
if val {
|
||||
*byte |= mask;
|
||||
} else {
|
||||
*byte &= !mask;
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
pub fn get(&self, bit_offset: usize, bit_width: u8) -> u64 {
|
||||
let mut val = 0;
|
||||
for i in 0..(bit_width as usize) {
|
||||
if self.get_bit(i + bit_offset) {
|
||||
let index = if cfg!(target_endian = "big") {
|
||||
bit_width as usize - 1 - i
|
||||
} else {
|
||||
i
|
||||
};
|
||||
val |= 1 << index;
|
||||
}
|
||||
}
|
||||
val
|
||||
}
|
||||
#[inline]
|
||||
pub fn set(&mut self, bit_offset: usize, bit_width: u8, val: u64) {
|
||||
for i in 0..(bit_width as usize) {
|
||||
let mask = 1 << i;
|
||||
let val_bit_is_set = val & mask == mask;
|
||||
let index = if cfg!(target_endian = "big") {
|
||||
bit_width as usize - 1 - i
|
||||
} else {
|
||||
i
|
||||
};
|
||||
self.set_bit(index + bit_offset, val_bit_is_set);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Std encode structs (copied from ash-master native.rs; common Std types reused from ash) ----------
|
||||
#[repr(C, align(4))]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct StdVideoEncodeAV1PictureInfoFlags {
|
||||
pub _bitfield_align_1: [u8; 0],
|
||||
pub _bitfield_1: __BindgenBitfieldUnit<[u8; 4usize]>,
|
||||
}
|
||||
impl StdVideoEncodeAV1PictureInfoFlags {
|
||||
#[inline]
|
||||
pub fn set_error_resilient_mode(&mut self, val: u32) {
|
||||
self._bitfield_1.set(0, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_disable_cdf_update(&mut self, val: u32) {
|
||||
self._bitfield_1.set(1, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_use_superres(&mut self, val: u32) {
|
||||
self._bitfield_1.set(2, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_render_and_frame_size_different(&mut self, val: u32) {
|
||||
self._bitfield_1.set(3, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_allow_screen_content_tools(&mut self, val: u32) {
|
||||
self._bitfield_1.set(4, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_is_filter_switchable(&mut self, val: u32) {
|
||||
self._bitfield_1.set(5, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_force_integer_mv(&mut self, val: u32) {
|
||||
self._bitfield_1.set(6, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_frame_size_override_flag(&mut self, val: u32) {
|
||||
self._bitfield_1.set(7, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_buffer_removal_time_present_flag(&mut self, val: u32) {
|
||||
self._bitfield_1.set(8, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_allow_intrabc(&mut self, val: u32) {
|
||||
self._bitfield_1.set(9, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_frame_refs_short_signaling(&mut self, val: u32) {
|
||||
self._bitfield_1.set(10, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_allow_high_precision_mv(&mut self, val: u32) {
|
||||
self._bitfield_1.set(11, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_is_motion_mode_switchable(&mut self, val: u32) {
|
||||
self._bitfield_1.set(12, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_use_ref_frame_mvs(&mut self, val: u32) {
|
||||
self._bitfield_1.set(13, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_disable_frame_end_update_cdf(&mut self, val: u32) {
|
||||
self._bitfield_1.set(14, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_allow_warped_motion(&mut self, val: u32) {
|
||||
self._bitfield_1.set(15, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_reduced_tx_set(&mut self, val: u32) {
|
||||
self._bitfield_1.set(16, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_skip_mode_present(&mut self, val: u32) {
|
||||
self._bitfield_1.set(17, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_delta_q_present(&mut self, val: u32) {
|
||||
self._bitfield_1.set(18, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_delta_lf_present(&mut self, val: u32) {
|
||||
self._bitfield_1.set(19, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_delta_lf_multi(&mut self, val: u32) {
|
||||
self._bitfield_1.set(20, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_segmentation_enabled(&mut self, val: u32) {
|
||||
self._bitfield_1.set(21, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_segmentation_update_map(&mut self, val: u32) {
|
||||
self._bitfield_1.set(22, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_segmentation_temporal_update(&mut self, val: u32) {
|
||||
self._bitfield_1.set(23, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_segmentation_update_data(&mut self, val: u32) {
|
||||
self._bitfield_1.set(24, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_UsesLr(&mut self, val: u32) {
|
||||
self._bitfield_1.set(25, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_usesChromaLr(&mut self, val: u32) {
|
||||
self._bitfield_1.set(26, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_show_frame(&mut self, val: u32) {
|
||||
self._bitfield_1.set(27, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_showable_frame(&mut self, val: u32) {
|
||||
self._bitfield_1.set(28, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_reserved(&mut self, val: u32) {
|
||||
self._bitfield_1.set(29, 3, val as u64)
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct StdVideoEncodeAV1PictureInfo {
|
||||
pub flags: StdVideoEncodeAV1PictureInfoFlags,
|
||||
pub frame_type: StdVideoAV1FrameType,
|
||||
pub frame_presentation_time: u32,
|
||||
pub current_frame_id: u32,
|
||||
pub order_hint: u8,
|
||||
pub primary_ref_frame: u8,
|
||||
pub refresh_frame_flags: u8,
|
||||
pub coded_denom: u8,
|
||||
pub render_width_minus_1: u16,
|
||||
pub render_height_minus_1: u16,
|
||||
pub interpolation_filter: StdVideoAV1InterpolationFilter,
|
||||
pub TxMode: StdVideoAV1TxMode,
|
||||
pub delta_q_res: u8,
|
||||
pub delta_lf_res: u8,
|
||||
pub ref_order_hint: [u8; 8usize],
|
||||
pub ref_frame_idx: [i8; 7usize],
|
||||
pub reserved1: [u8; 3usize],
|
||||
pub delta_frame_id_minus_1: [u32; 7usize],
|
||||
pub pTileInfo: *const ash::vk::native::StdVideoAV1TileInfo,
|
||||
pub pQuantization: *const ash::vk::native::StdVideoAV1Quantization,
|
||||
pub pSegmentation: *const ash::vk::native::StdVideoAV1Segmentation,
|
||||
pub pLoopFilter: *const ash::vk::native::StdVideoAV1LoopFilter,
|
||||
pub pCDEF: *const ash::vk::native::StdVideoAV1CDEF,
|
||||
pub pLoopRestoration: *const ash::vk::native::StdVideoAV1LoopRestoration,
|
||||
pub pGlobalMotion: *const ash::vk::native::StdVideoAV1GlobalMotion,
|
||||
pub pExtensionHeader: *const StdVideoEncodeAV1ExtensionHeader,
|
||||
pub pBufferRemovalTimes: *const u32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct StdVideoEncodeAV1ReferenceInfoFlags {
|
||||
pub _bitfield_align_1: [u32; 0],
|
||||
pub _bitfield_1: __BindgenBitfieldUnit<[u8; 4usize]>,
|
||||
}
|
||||
impl StdVideoEncodeAV1ReferenceInfoFlags {
|
||||
#[inline]
|
||||
pub fn set_disable_frame_end_update_cdf(&mut self, val: u32) {
|
||||
self._bitfield_1.set(0, 1, val as u64)
|
||||
}
|
||||
#[inline]
|
||||
pub fn set_segmentation_enabled(&mut self, val: u32) {
|
||||
self._bitfield_1.set(1, 1, val as u64)
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct StdVideoEncodeAV1ReferenceInfo {
|
||||
pub flags: StdVideoEncodeAV1ReferenceInfoFlags,
|
||||
pub RefFrameId: u32,
|
||||
pub frame_type: StdVideoAV1FrameType,
|
||||
pub OrderHint: u8,
|
||||
pub reserved1: [u8; 3usize],
|
||||
pub pExtensionHeader: *const StdVideoEncodeAV1ExtensionHeader,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct StdVideoEncodeAV1ExtensionHeader {
|
||||
pub temporal_id: u8,
|
||||
pub spatial_id: u8,
|
||||
}
|
||||
|
||||
// ---------- KHR extension structs (repr(C); lifetimes/PhantomData dropped — layout-identical,
|
||||
// chained by raw p_next). Flag/enum newtypes flattened to their u32/i32 repr. ----------
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct VideoEncodeAV1ProfileInfoKHR {
|
||||
pub s_type: vk::StructureType,
|
||||
pub p_next: *const c_void,
|
||||
pub std_profile: StdVideoAV1Profile,
|
||||
}
|
||||
|
||||
/// `VkPhysicalDeviceVideoEncodeAV1FeaturesKHR` — the `videoEncodeAV1` feature MUST be enabled at
|
||||
/// device creation for any `VK_VIDEO_CODEC_OPERATION_ENCODE_AV1` use (a spec requirement RADV may
|
||||
/// tolerate omitting but validation layers and stricter drivers do not).
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct PhysicalDeviceVideoEncodeAV1FeaturesKHR {
|
||||
pub s_type: vk::StructureType,
|
||||
pub p_next: *mut c_void,
|
||||
pub video_encode_av1: vk::Bool32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct VideoEncodeAV1CapabilitiesKHR {
|
||||
pub s_type: vk::StructureType,
|
||||
pub p_next: *mut c_void,
|
||||
pub flags: u32,
|
||||
pub max_level: StdVideoAV1Level,
|
||||
pub coded_picture_alignment: vk::Extent2D,
|
||||
pub max_tiles: vk::Extent2D,
|
||||
pub min_tile_size: vk::Extent2D,
|
||||
pub max_tile_size: vk::Extent2D,
|
||||
pub superblock_sizes: u32,
|
||||
pub max_single_reference_count: u32,
|
||||
pub single_reference_name_mask: u32,
|
||||
pub max_unidirectional_compound_reference_count: u32,
|
||||
pub max_unidirectional_compound_group1_reference_count: u32,
|
||||
pub unidirectional_compound_reference_name_mask: u32,
|
||||
pub max_bidirectional_compound_reference_count: u32,
|
||||
pub max_bidirectional_compound_group1_reference_count: u32,
|
||||
pub max_bidirectional_compound_group2_reference_count: u32,
|
||||
pub bidirectional_compound_reference_name_mask: u32,
|
||||
pub max_temporal_layer_count: u32,
|
||||
pub max_spatial_layer_count: u32,
|
||||
pub max_operating_points: u32,
|
||||
pub min_q_index: u32,
|
||||
pub max_q_index: u32,
|
||||
pub prefers_gop_remaining_frames: vk::Bool32,
|
||||
pub requires_gop_remaining_frames: vk::Bool32,
|
||||
pub std_syntax_flags: u32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct VideoEncodeAV1SessionParametersCreateInfoKHR {
|
||||
pub s_type: vk::StructureType,
|
||||
pub p_next: *const c_void,
|
||||
pub p_std_sequence_header: *const StdVideoAV1SequenceHeader,
|
||||
pub p_std_decoder_model_info: *const c_void,
|
||||
pub std_operating_point_count: u32,
|
||||
pub p_std_operating_points: *const c_void,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct VideoEncodeAV1PictureInfoKHR {
|
||||
pub s_type: vk::StructureType,
|
||||
pub p_next: *const c_void,
|
||||
pub prediction_mode: i32,
|
||||
pub rate_control_group: i32,
|
||||
pub constant_q_index: u32,
|
||||
pub p_std_picture_info: *const StdVideoEncodeAV1PictureInfo,
|
||||
pub reference_name_slot_indices: [i32; MAX_VIDEO_AV1_REFERENCES_PER_FRAME],
|
||||
pub primary_reference_cdf_only: vk::Bool32,
|
||||
pub generate_obu_extension_header: vk::Bool32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct VideoEncodeAV1DpbSlotInfoKHR {
|
||||
pub s_type: vk::StructureType,
|
||||
pub p_next: *const c_void,
|
||||
pub p_std_reference_info: *const StdVideoEncodeAV1ReferenceInfo,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct VideoEncodeAV1RateControlInfoKHR {
|
||||
pub s_type: vk::StructureType,
|
||||
pub p_next: *const c_void,
|
||||
pub flags: u32,
|
||||
pub gop_frame_count: u32,
|
||||
pub key_frame_period: u32,
|
||||
pub consecutive_bipredictive_frame_count: u32,
|
||||
pub temporal_layer_count: u32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct VideoEncodeAV1QIndexKHR {
|
||||
pub intra_q_index: u32,
|
||||
pub predictive_q_index: u32,
|
||||
pub bipredictive_q_index: u32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct VideoEncodeAV1FrameSizeKHR {
|
||||
pub intra_frame_size: u32,
|
||||
pub predictive_frame_size: u32,
|
||||
pub bipredictive_frame_size: u32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct VideoEncodeAV1RateControlLayerInfoKHR {
|
||||
pub s_type: vk::StructureType,
|
||||
pub p_next: *const c_void,
|
||||
pub use_min_q_index: vk::Bool32,
|
||||
pub min_q_index: VideoEncodeAV1QIndexKHR,
|
||||
pub use_max_q_index: vk::Bool32,
|
||||
pub max_q_index: VideoEncodeAV1QIndexKHR,
|
||||
pub use_max_frame_size: vk::Bool32,
|
||||
pub max_frame_size: VideoEncodeAV1FrameSizeKHR,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct VideoEncodeAV1GopRemainingFrameInfoKHR {
|
||||
pub s_type: vk::StructureType,
|
||||
pub p_next: *const c_void,
|
||||
pub use_gop_remaining_frames: vk::Bool32,
|
||||
pub gop_remaining_intra: u32,
|
||||
pub gop_remaining_predictive: u32,
|
||||
pub gop_remaining_bipredictive: u32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct VideoEncodeAV1SessionCreateInfoKHR {
|
||||
pub s_type: vk::StructureType,
|
||||
pub p_next: *const c_void,
|
||||
pub use_max_level: vk::Bool32,
|
||||
pub max_level: StdVideoAV1Level,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct StdVideoEncodeAV1OperatingPointInfoFlags {
|
||||
pub _bitfield_align_1: [u32; 0],
|
||||
pub _bitfield_1: __BindgenBitfieldUnit<[u8; 4usize]>,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct StdVideoEncodeAV1OperatingPointInfo {
|
||||
pub flags: StdVideoEncodeAV1OperatingPointInfoFlags,
|
||||
pub operating_point_idc: u16,
|
||||
pub seq_level_idx: u8,
|
||||
pub seq_tier: u8,
|
||||
pub decoder_buffer_delay: u32,
|
||||
pub encoder_buffer_delay: u32,
|
||||
pub initial_display_delay_minus_1: u8,
|
||||
}
|
||||
|
||||
/// `vk::StructureType` for a raw `ST_*` constant above.
|
||||
#[inline]
|
||||
pub fn stype(raw: i32) -> vk::StructureType {
|
||||
vk::StructureType::from_raw(raw)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,8 +12,11 @@
|
||||
//! Drives the AMF runtime through its **C vtable ABI**: the GPUOpen public headers define
|
||||
//! C-compatible vtable structs for every interface, and FFmpeg's `amfenc.c` (plain C) drives AMF
|
||||
//! exclusively through them, so that ABI — not the C++ classes — is the stable, supported
|
||||
//! surface. The FFI below mirrors ONLY the interfaces/slots we call, pinned to header version
|
||||
//! **v1.4.36** (`AMF_FULL_VERSION` 1.4.36.0, gated at load via `AMFQueryVersion`). The runtime is
|
||||
//! surface. The FFI below mirrors ONLY the interfaces/slots we call, written against header
|
||||
//! version **v1.4.36** (`AMF_FULL_VERSION` 1.4.36.0). At load the runtime is accepted down to a
|
||||
//! stable-ABI floor of **v1.4.34** (the `AMFQueryVersion` gate); the 1.4.35/1.4.36-only encoder
|
||||
//! features are string-keyed properties that degrade individually on older drivers, not vtable
|
||||
//! changes (see [`sys::AMF_MIN_VERSION`]). The runtime is
|
||||
//! loaded at runtime from the driver-installed `amfrt64.dll` — exactly as `nvenc.rs` loads
|
||||
//! `nvEncodeAPI64.dll` — so this compiles unconditionally on Windows (**no build feature, no new
|
||||
//! dependency**). Since Phase 3 (design §7) this is the sole AMD dispatch: a box without a
|
||||
@@ -50,6 +53,7 @@ use std::collections::VecDeque;
|
||||
use std::ffi::c_void;
|
||||
use std::ptr;
|
||||
use windows::core::{w, Interface, PCWSTR};
|
||||
use windows::Win32::Foundation::HMODULE;
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
ID3D11Device, ID3D11DeviceContext, ID3D11Resource, ID3D11Texture2D, D3D11_BIND_RENDER_TARGET,
|
||||
D3D11_BIND_SHADER_RESOURCE, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT,
|
||||
@@ -57,9 +61,15 @@ use windows::Win32::Graphics::Direct3D11::{
|
||||
use windows::Win32::Graphics::Dxgi::Common::{
|
||||
DXGI_FORMAT_NV12, DXGI_FORMAT_P010, DXGI_SAMPLE_DESC,
|
||||
};
|
||||
use windows::Win32::Storage::FileSystem::{
|
||||
GetFileVersionInfoSizeW, GetFileVersionInfoW, VerQueryValueW, VS_FIXEDFILEINFO,
|
||||
};
|
||||
use windows::Win32::System::LibraryLoader::GetModuleFileNameW;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Mirrored AMF C ABI (pinned to GPUOpen header release v1.4.36 — amf/public/include).
|
||||
// Mirrored AMF C ABI (written against GPUOpen header release v1.4.36 — amf/public/include; every
|
||||
// slot below is a base-interface slot whose layout is stable since <= v1.4.34, the loader's
|
||||
// accepted ABI floor, so the mirror is valid on every runtime the loader admits).
|
||||
//
|
||||
// Layout rules this mirror relies on: every AMF interface is a struct whose sole member is a
|
||||
// pointer to a C vtable; derived interfaces PREPEND their base's slots in order (AMFInterface →
|
||||
@@ -118,11 +128,24 @@ mod sys {
|
||||
}
|
||||
}
|
||||
|
||||
/// The pinned header version this FFI mirrors: `AMF_FULL_VERSION` for 1.4.36.0
|
||||
/// (core/Version.h `AMF_MAKE_FULL_VERSION`). The loader requires the runtime to report at
|
||||
/// least this via `AMFQueryVersion`, guaranteeing every vtable slot mirrored below exists at
|
||||
/// the mirrored offset.
|
||||
pub const AMF_PINNED_VERSION: u64 = (1u64 << 48) | (4u64 << 32) | (36u64 << 16);
|
||||
/// The AMF header release this FFI mirror was written against: `AMF_FULL_VERSION` for 1.4.36.0
|
||||
/// (core/Version.h `AMF_MAKE_FULL_VERSION`). This is the version claimed to `AMFInit` — but
|
||||
/// capped at the runtime's own reported version (see `load_factory`), so an older-but-accepted
|
||||
/// runtime is asked only for the ABI it actually provides.
|
||||
pub const AMF_HEADER_VERSION: u64 = (1u64 << 48) | (4u64 << 32) | (36u64 << 16);
|
||||
|
||||
/// The oldest AMF runtime the loader accepts (`AMF_FULL_VERSION` 1.4.34.0 — AMD Adrenalin
|
||||
/// 24.6.1). This is an **ABI floor, not a feature floor**: every vtable slot mirrored in this
|
||||
/// module belongs to a base interface (`AMFFactory`/`AMFContext`/`AMFComponent`/`AMFData`/
|
||||
/// `AMFBuffer`) whose layout has been stable — append-only, no mid-vtable insertions — since
|
||||
/// well before 1.4.34, so a 1.4.34 runtime is guaranteed to expose every mirrored slot at its
|
||||
/// mirrored offset. Everything 1.4.35/1.4.36 added that this path can touch (new HQ presets,
|
||||
/// AV1 B-frame / picture management) is a *string-keyed encoder property*, applied through
|
||||
/// [`set_prop`] with `required=false` — a runtime that lacks it rejects the property (logged)
|
||||
/// and the feature degrades, rather than shifting any vtable offset. Below this floor the
|
||||
/// mirror is not guaranteed to match, so the loader declines cleanly (an old-driver decline,
|
||||
/// never UB).
|
||||
pub const AMF_MIN_VERSION: u64 = (1u64 << 48) | (4u64 << 32) | (34u64 << 16);
|
||||
|
||||
/// `AMF_SURFACE_FORMAT` (core/Surface.h).
|
||||
pub const AMF_SURFACE_NV12: i32 = 1;
|
||||
@@ -474,6 +497,76 @@ fn amf_ok(r: sys::AmfResult, what: &str) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Format an `AMF_FULL_VERSION` u64 as `major.minor.patch` (the build field is dropped — every
|
||||
/// version comparison and log line in this module ignores it).
|
||||
fn amf_version_str(v: u64) -> String {
|
||||
format!(
|
||||
"{}.{}.{}",
|
||||
(v >> 48) & 0xffff,
|
||||
(v >> 32) & 0xffff,
|
||||
(v >> 16) & 0xffff
|
||||
)
|
||||
}
|
||||
|
||||
/// Best-effort on-disk identity of the loaded `amfrt64.dll`: `(full path, file-version resource)`.
|
||||
/// The file-version is the driver build baked into the DLL (e.g. `31.0.24033.1003`), which — unlike
|
||||
/// the AMF runtime version — is directly comparable to the display-driver version. This pins down
|
||||
/// the Boot Camp failure mode: the display driver can report 25.x while the `amfrt64.dll` actually
|
||||
/// loaded (System32, via the SYSTEM32-only search) is a stale build whose AMF + file versions lag
|
||||
/// it. Diagnostics only — any failure yields `None` and never affects the load.
|
||||
///
|
||||
/// # Safety
|
||||
/// `module` must be a live module handle owned by the caller (here, the never-unloaded
|
||||
/// `amfrt64.dll` from `LoadLibraryExW`).
|
||||
unsafe fn loaded_dll_identity(module: HMODULE) -> (Option<String>, Option<String>) {
|
||||
let mut buf = [0u16; 512];
|
||||
let n = GetModuleFileNameW(Some(module), &mut buf) as usize;
|
||||
// n == 0 → failed; n >= len → path truncated (no guaranteed NUL) — bail either way. Otherwise
|
||||
// `GetModuleFileNameW` NUL-terminates at `buf[n]`, so `buf` is a valid PCWSTR for the query.
|
||||
if n == 0 || n >= buf.len() {
|
||||
return (None, None);
|
||||
}
|
||||
let path = String::from_utf16_lossy(&buf[..n]);
|
||||
(Some(path), dll_file_version(PCWSTR(buf.as_ptr())))
|
||||
}
|
||||
|
||||
/// Read a DLL's `\`-root `VS_FIXEDFILEINFO` file version as `a.b.c.d`. `None` if the file has no
|
||||
/// version resource or any step fails (diagnostics only).
|
||||
///
|
||||
/// # Safety
|
||||
/// `path` must be a valid NUL-terminated wide string pointing at a readable file path.
|
||||
unsafe fn dll_file_version(path: PCWSTR) -> Option<String> {
|
||||
let size = GetFileVersionInfoSizeW(path, None);
|
||||
if size == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut block = vec![0u8; size as usize];
|
||||
GetFileVersionInfoW(path, None, size, block.as_mut_ptr() as *mut c_void).ok()?;
|
||||
let mut value: *mut c_void = ptr::null_mut();
|
||||
let mut len: u32 = 0;
|
||||
let ok = VerQueryValueW(
|
||||
block.as_ptr() as *const c_void,
|
||||
w!("\\"),
|
||||
&mut value,
|
||||
&mut len,
|
||||
);
|
||||
if !ok.as_bool() || value.is_null() || (len as usize) < std::mem::size_of::<VS_FIXEDFILEINFO>()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// SAFETY: on success `VerQueryValueW` points `value` at a `VS_FIXEDFILEINFO` living inside
|
||||
// `block` and valid for `len` bytes (checked >= its size); `block` outlives this read.
|
||||
let ffi = &*(value as *const VS_FIXEDFILEINFO);
|
||||
let (ms, ls) = (ffi.dwFileVersionMS, ffi.dwFileVersionLS);
|
||||
Some(format!(
|
||||
"{}.{}.{}.{}",
|
||||
ms >> 16,
|
||||
ms & 0xffff,
|
||||
ls >> 16,
|
||||
ls & 0xffff
|
||||
))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Runtime loader (the analogue of nvenc.rs `load_api`): resolve amfrt64.dll's two exports once
|
||||
// per process, gate on the pinned header version, and keep the factory singleton forever.
|
||||
@@ -493,9 +586,9 @@ unsafe impl Send for AmfLib {}
|
||||
unsafe impl Sync for AmfLib {}
|
||||
|
||||
/// Resolve the AMF runtime once per process. `Err` = AMF genuinely unavailable here (no AMD
|
||||
/// driver / `amfrt64.dll`, or a runtime older than the pinned v1.4.36 headers) — callers fail
|
||||
/// their open cleanly with an "update the AMD driver" message (the session then fails; since
|
||||
/// Phase 3 there is no libavcodec AMF fallback).
|
||||
/// driver / `amfrt64.dll`, or a runtime older than the minimum-supported v1.4.34 — see
|
||||
/// [`sys::AMF_MIN_VERSION`]) — callers fail their open cleanly with an "update the AMD driver"
|
||||
/// message (the session then fails; since Phase 3 there is no libavcodec AMF fallback).
|
||||
fn try_factory() -> std::result::Result<&'static AmfLib, &'static str> {
|
||||
static LIB: std::sync::OnceLock<std::result::Result<AmfLib, String>> =
|
||||
std::sync::OnceLock::new();
|
||||
@@ -520,10 +613,11 @@ fn load_factory() -> std::result::Result<AmfLib, String> {
|
||||
// System32-only search path keeps a planted DLL out of the SYSTEM-service process (same
|
||||
// hardening as the NVENC loader). The two transmutes cast the resolved exports to their
|
||||
// documented prototypes (core/Factory.h `AMFQueryVersion_Fn`/`AMFInit_Fn`).
|
||||
// `AMFQueryVersion` writes one u64 through a live pointer; `AMFInit` is passed the pinned
|
||||
// header version and fills `factory` with the process-global singleton only on AMF_OK
|
||||
// (null-checked after). The module is never freed, so the factory and both entry points stay
|
||||
// valid for the process lifetime.
|
||||
// `AMFQueryVersion` writes one u64 through a live pointer; `AMFInit` is passed the header
|
||||
// version capped at the runtime's own (never newer than what the runtime provides) and fills
|
||||
// `factory` with the process-global singleton only on AMF_OK (null-checked after). The module
|
||||
// is never freed, so the factory and both entry points stay valid for the process lifetime.
|
||||
// `loaded_dll_identity` only reads that module's own path + version resource (diagnostics).
|
||||
unsafe {
|
||||
let module = LoadLibraryExW(w!("amfrt64.dll"), None, LOAD_LIBRARY_SEARCH_SYSTEM32)
|
||||
.map_err(|e| {
|
||||
@@ -540,33 +634,75 @@ fn load_factory() -> std::result::Result<AmfLib, String> {
|
||||
if r != sys::AMF_OK {
|
||||
return Err(format!("AMFQueryVersion failed: {} ({r})", result_name(r)));
|
||||
}
|
||||
// The vtable layouts mirrored above are the pinned header's; an older runtime may lack
|
||||
// trailing slots (or predate an insertion), so require at least the pinned version — an
|
||||
// old driver is a clean decline (clear session error), not UB.
|
||||
if version < sys::AMF_PINNED_VERSION {
|
||||
// On-disk identity of the DLL we actually loaded (System32's amfrt64.dll, via the
|
||||
// SYSTEM32-only search above) — the Boot Camp diagnostic: the display driver can read 25.x
|
||||
// while THIS file is a stale build whose AMF + file versions lag it, so an "update the
|
||||
// driver" decline is confusing (they did — this DLL just didn't follow).
|
||||
let (dll_path, dll_file_ver) = loaded_dll_identity(module);
|
||||
let dll_desc = format!(
|
||||
"{}{}",
|
||||
dll_path.as_deref().unwrap_or("amfrt64.dll"),
|
||||
dll_file_ver
|
||||
.as_deref()
|
||||
.map(|v| format!(" (file version {v})"))
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
// Accept any runtime at or above the ABI floor (AMF_MIN_VERSION): every vtable slot this
|
||||
// module mirrors predates it, so its layout is guaranteed; 1.4.35/1.4.36-only encoder
|
||||
// features are string-keyed properties that degrade via `set_prop(required=false)`, not
|
||||
// vtable changes. Below the floor the mirror is not guaranteed — decline cleanly (a clear
|
||||
// old-driver session error, never UB).
|
||||
if version < sys::AMF_MIN_VERSION {
|
||||
return Err(format!(
|
||||
"AMF runtime {}.{}.{} is older than the host's pinned headers 1.4.36 — update \
|
||||
the AMD driver",
|
||||
(version >> 48) & 0xffff,
|
||||
(version >> 32) & 0xffff,
|
||||
(version >> 16) & 0xffff,
|
||||
"AMF runtime {amf} (loaded from {dll_desc}) is older than the minimum supported \
|
||||
1.4.34 — update the AMD driver (Adrenalin 24.6.1+; 25.1.1+ for the \
|
||||
fully-validated feature set). If the display driver already reports a newer \
|
||||
version, this amfrt64.dll did not update — reboot, then DDU + reinstall so \
|
||||
System32's copy is refreshed.",
|
||||
amf = amf_version_str(version),
|
||||
));
|
||||
}
|
||||
// Claim no more than the runtime provides: passing a version NEWER than the runtime can
|
||||
// make AMFInit reject an otherwise-usable older driver, and this path only ever calls ABI
|
||||
// present at/below the runtime's version. On a >=1.4.36 runtime this is a no-op (== header).
|
||||
let init_version = sys::AMF_HEADER_VERSION.min(version);
|
||||
let mut factory: *mut sys::AmfFactory = ptr::null_mut();
|
||||
let r = init(sys::AMF_PINNED_VERSION, &mut factory);
|
||||
let r = init(init_version, &mut factory);
|
||||
if r != sys::AMF_OK {
|
||||
return Err(format!("AMFInit failed: {} ({r})", result_name(r)));
|
||||
}
|
||||
if factory.is_null() {
|
||||
return Err("AMFInit returned a null factory".into());
|
||||
}
|
||||
// Visible once per process (this runs inside `try_factory`'s OnceLock init). Both the AMF
|
||||
// runtime version AND the loaded DLL's path + file version are logged, so a field report
|
||||
// shows "display driver says 25.x but amfrt64.dll is an old build" at a glance.
|
||||
if version >= sys::AMF_HEADER_VERSION {
|
||||
tracing::info!(
|
||||
amf_version = %amf_version_str(version),
|
||||
dll = %dll_desc,
|
||||
"AMF runtime loaded (meets the validated 1.4.36 baseline)"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
amf_version = %amf_version_str(version),
|
||||
dll = %dll_desc,
|
||||
"AMF runtime is older than the validated 1.4.36 baseline — accepted (the core \
|
||||
encode ABI is stable), but advanced features (LTR / intra-refresh recovery, AV1 \
|
||||
coded-size alignment, in-band HDR metadata) validated on 1.4.36 may be \
|
||||
unavailable on this driver and will degrade individually (see the per-property \
|
||||
logs below). Update to AMD Adrenalin 25.1.1+ for the fully-validated path."
|
||||
);
|
||||
}
|
||||
Ok(AmfLib { factory, version })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Per-codec property tables (names verified against the pinned v1.4.36 headers —
|
||||
// components/VideoEncoderVCE.h, VideoEncoderHEVC.h and VideoEncoderAV1.h; the enum VALUES differ
|
||||
// Per-codec property tables (names verified against the v1.4.36 headers —
|
||||
// components/VideoEncoderVCE.h, VideoEncoderHEVC.h and VideoEncoderAV1.h; a name a pre-1.4.36
|
||||
// runtime doesn't recognise is applied through `set_prop(required=false)`, which logs and
|
||||
// continues, so an older driver degrades that one feature rather than failing. The enum VALUES differ
|
||||
// between the codecs, e.g. CBR is 1 on AVC but 3 on HEVC/AV1, SPEED is 1 vs 10 vs 100, and AV1
|
||||
// swaps the ULTRA_LOW_LATENCY/LOW_LATENCY usage values relative to AVC/HEVC).
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -1118,15 +1254,12 @@ impl AmfEncoder {
|
||||
bit_depth: u8,
|
||||
chroma: ChromaFormat,
|
||||
) -> Result<Self> {
|
||||
// The once-per-process runtime version (and the older-than-baseline warning) is logged in
|
||||
// `load_factory`; this per-session line ties an individual encoder open to that version.
|
||||
let lib = try_factory().map_err(|e| anyhow!("native AMF unavailable: {e}"))?;
|
||||
tracing::debug!(
|
||||
version = %format!(
|
||||
"{}.{}.{}",
|
||||
(lib.version >> 48) & 0xffff,
|
||||
(lib.version >> 32) & 0xffff,
|
||||
(lib.version >> 16) & 0xffff
|
||||
),
|
||||
"AMF runtime loaded"
|
||||
version = %amf_version_str(lib.version),
|
||||
"opening AMF encoder"
|
||||
);
|
||||
let props = codec_props(codec);
|
||||
// AV1 is RDNA3+ — probe at open (never assume), so a pre-RDNA3 box fails HERE with a clear
|
||||
@@ -3062,7 +3195,7 @@ mod tests {
|
||||
return;
|
||||
}
|
||||
};
|
||||
assert!(lib.version >= sys::AMF_PINNED_VERSION);
|
||||
assert!(lib.version >= sys::AMF_MIN_VERSION);
|
||||
// SAFETY: same contracts as `ensure_inner`, minus the external device: `CreateContext`
|
||||
// fills `ctx` only on AMF_OK; `InitDX11(null)` asks AMF to create its own D3D11 device
|
||||
// (may fail on exotic boxes — treated as a skip); `CreateComponent` likewise. Guards
|
||||
|
||||
@@ -425,7 +425,11 @@ impl InputInjector for KwinFakeInjector {
|
||||
self.fake.touch_frame();
|
||||
}
|
||||
// Gamepads are injected through uinput, not the compositor.
|
||||
InputKind::GamepadState | InputKind::GamepadButton | InputKind::GamepadAxis => {}
|
||||
InputKind::GamepadState
|
||||
| InputKind::GamepadButton
|
||||
| InputKind::GamepadAxis
|
||||
| InputKind::GamepadRemove
|
||||
| InputKind::GamepadArrival => {}
|
||||
}
|
||||
// Surface protocol errors / disconnects, then push the batch to the compositor.
|
||||
self.queue
|
||||
|
||||
@@ -404,6 +404,8 @@ fn kind_bit(kind: InputKind) -> u32 {
|
||||
InputKind::GamepadButton => 10,
|
||||
InputKind::GamepadAxis => 11,
|
||||
InputKind::GamepadState => 12,
|
||||
InputKind::GamepadRemove => 13,
|
||||
InputKind::GamepadArrival => 14,
|
||||
};
|
||||
1 << i
|
||||
}
|
||||
@@ -546,7 +548,11 @@ impl EiState {
|
||||
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {
|
||||
DeviceCapability::Touch
|
||||
}
|
||||
InputKind::GamepadState | InputKind::GamepadButton | InputKind::GamepadAxis => return, // uinput path (later)
|
||||
InputKind::GamepadState
|
||||
| InputKind::GamepadButton
|
||||
| InputKind::GamepadAxis
|
||||
| InputKind::GamepadRemove
|
||||
| InputKind::GamepadArrival => return, // uinput path (later)
|
||||
};
|
||||
self.injected += 1;
|
||||
let n = self.injected;
|
||||
@@ -693,9 +699,11 @@ impl EiState {
|
||||
Some(t) => t.up(ev.code),
|
||||
None => emitted = false,
|
||||
},
|
||||
InputKind::GamepadState | InputKind::GamepadButton | InputKind::GamepadAxis => {
|
||||
emitted = false
|
||||
}
|
||||
InputKind::GamepadState
|
||||
| InputKind::GamepadButton
|
||||
| InputKind::GamepadAxis
|
||||
| InputKind::GamepadRemove
|
||||
| InputKind::GamepadArrival => emitted = false,
|
||||
}
|
||||
|
||||
if emitted {
|
||||
|
||||
@@ -254,7 +254,11 @@ impl InputInjector for WlrootsInjector {
|
||||
tracing::debug!(vk = event.code, "unmapped VK keycode — dropped");
|
||||
}
|
||||
}
|
||||
InputKind::GamepadState | InputKind::GamepadButton | InputKind::GamepadAxis => {} // not yet injected
|
||||
InputKind::GamepadState
|
||||
| InputKind::GamepadButton
|
||||
| InputKind::GamepadAxis
|
||||
| InputKind::GamepadRemove
|
||||
| InputKind::GamepadArrival => {} // not yet injected
|
||||
// wlroots has no virtual-touch protocol wired here; touch is the libei path only.
|
||||
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {}
|
||||
}
|
||||
|
||||
@@ -301,6 +301,8 @@ impl InputInjector for SendInputInjector {
|
||||
InputKind::GamepadButton
|
||||
| InputKind::GamepadAxis
|
||||
| InputKind::GamepadState
|
||||
| InputKind::GamepadRemove
|
||||
| InputKind::GamepadArrival
|
||||
| InputKind::TouchDown
|
||||
| InputKind::TouchMove
|
||||
| InputKind::TouchUp => Ok(()),
|
||||
|
||||
@@ -20,11 +20,6 @@
|
||||
|
||||
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;
|
||||
|
||||
@@ -32,10 +32,9 @@ 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, ClipControl, ClipFetchHdr, ClipOffer, ClipState, ClockEcho,
|
||||
ClockProbe, ColorInfo, Hello, LossReport, PairChallenge, PairProof, PairRequest, PairResult,
|
||||
ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, RfiRequest, SetBitrate,
|
||||
Start, Welcome,
|
||||
endpoint, io, BitrateChanged, 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;
|
||||
@@ -477,86 +476,6 @@ 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`].
|
||||
@@ -1150,19 +1069,8 @@ 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. 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
|
||||
},
|
||||
// so capable clients send those instead of the loss-fragile per-transition events.
|
||||
host_caps: punktfunk_core::quic::HOST_CAP_GAMEPAD_STATE,
|
||||
};
|
||||
io::write_msg(&mut send, &welcome.encode()).await?;
|
||||
|
||||
@@ -1237,26 +1145,8 @@ 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).
|
||||
@@ -1395,44 +1285,6 @@ 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");
|
||||
}
|
||||
@@ -1443,21 +1295,6 @@ 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
|
||||
@@ -1539,43 +1376,6 @@ 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
|
||||
@@ -1940,166 +1740,315 @@ const MAX_WIRE_PADS: usize = punktfunk_core::input::MAX_PADS;
|
||||
/// virtual mic has its own tuning — see [`crate::audio::MicPump`].)
|
||||
const INJECTOR_REOPEN_BACKOFF: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
/// The session's virtual-gamepad backend, resolved once per session (sessions run serially).
|
||||
/// Per-pad virtual-gamepad router: each pad index is served by a backend of that pad's declared
|
||||
/// kind ([`InputKind::GamepadArrival`](punktfunk_core::input::InputKind::GamepadArrival)), so ONE
|
||||
/// session can MIX controller types — pad 0 a DualSense, pad 1 an Xbox pad. A pad the client never
|
||||
/// declares uses `default` (the session kind resolved from the Hello — the pre-existing single-kind
|
||||
/// behaviour).
|
||||
///
|
||||
/// - `Xbox360` — uinput X-Box-360 pads on Linux ([`GamepadManager`](crate::inject::gamepad::GamepadManager)),
|
||||
/// the in-tree XUSB companion driver (classic XInput) on Windows. Also the X-Box One/Series identity
|
||||
/// (`PUNKTFUNK_GAMEPAD=xboxone`): the same
|
||||
/// backend with the One/Series USB VID/PID so games show One/Series glyphs (XInput-identical
|
||||
/// otherwise). The Linux pad carries it as a [`PadIdentity`](crate::inject::gamepad::PadIdentity).
|
||||
/// - `DualSense` (`PUNKTFUNK_GAMEPAD=dualsense`) — virtual DualSense via UHID + `hid-playstation`,
|
||||
/// so a game sees a *real* DualSense (adaptive triggers, lightbar, touchpad, motion); feedback
|
||||
/// flows back over the rich HID-output plane.
|
||||
/// - `DualShock4` (`PUNKTFUNK_GAMEPAD=ps4`) — virtual DualShock 4 via the same UHID path: lightbar,
|
||||
/// touchpad, motion, rumble (DualSense minus adaptive triggers / player LEDs / mute).
|
||||
/// Backends are created lazily per kind (an empty manager holds no device), and each owns only the
|
||||
/// indices routed to it. A manager's `active_mask` unplug sweep stays correct across managers
|
||||
/// because an index another manager owns is `None` in this one, so the sweep never touches it.
|
||||
///
|
||||
/// DualShock 4 + One/Series are Linux-only; DualSense has both a Linux (UHID) and a Windows (UMDF
|
||||
/// minidriver) backend. The resolver folds any type a platform can't build into `Xbox360`, so a
|
||||
/// build never constructs a variant it lacks.
|
||||
enum PadBackend {
|
||||
Xbox360(crate::inject::gamepad::GamepadManager),
|
||||
/// - Xbox 360 / One — uinput on Linux ([`GamepadManager`](crate::inject::gamepad::GamepadManager),
|
||||
/// two identities), the XUSB companion driver (classic XInput) on Windows.
|
||||
/// - DualSense / DualShock 4 — Linux UHID `hid-playstation`, or the Windows UMDF minidriver.
|
||||
/// - Steam Deck — Linux UHID `hid-steam`.
|
||||
///
|
||||
/// [`resolve_pad_kind`] folds any kind a platform can't build into one it can, so this never
|
||||
/// constructs a manager the build lacks.
|
||||
struct Pads {
|
||||
/// Declared (and host-resolved) kind per pad index; `default` until a `GamepadArrival` lands.
|
||||
kinds: [GamepadPref; MAX_WIRE_PADS],
|
||||
/// The kind of the manager that currently OWNS a built device at each index (`None` = no
|
||||
/// device). A live device stays in its manager even if `kinds[idx]` later changes (the rare
|
||||
/// arrival-after-first-frame reorder), so a pad is never duplicated across managers and its
|
||||
/// removal always reaches the manager that actually holds it.
|
||||
owner: [Option<GamepadPref>; MAX_WIRE_PADS],
|
||||
xbox360: Option<crate::inject::gamepad::GamepadManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
DualSense(crate::inject::dualsense::DualSenseManager),
|
||||
xboxone: Option<crate::inject::gamepad::GamepadManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
DualShock4(crate::inject::dualshock4::DualShock4Manager),
|
||||
dualsense: Option<crate::inject::dualsense::DualSenseManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
SteamDeck(crate::inject::steam_controller::SteamControllerManager),
|
||||
dualshock4: Option<crate::inject::dualshock4::DualShock4Manager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamdeck: Option<crate::inject::steam_controller::SteamControllerManager>,
|
||||
#[cfg(target_os = "windows")]
|
||||
DualSenseWindows(crate::inject::dualsense_windows::DualSenseWindowsManager),
|
||||
dualsense_win: Option<crate::inject::dualsense_windows::DualSenseWindowsManager>,
|
||||
#[cfg(target_os = "windows")]
|
||||
DualShock4Windows(crate::inject::dualshock4_windows::DualShock4WindowsManager),
|
||||
dualshock4_win: Option<crate::inject::dualshock4_windows::DualShock4WindowsManager>,
|
||||
}
|
||||
|
||||
impl PadBackend {
|
||||
/// `kind` is the session's resolved backend (see [`resolve_gamepad`] — client preference,
|
||||
/// env var, X-Box 360, in that order). Defensive cfg guard: a non-Linux build can only ever
|
||||
/// construct the X-Box backend, whatever the resolution said.
|
||||
fn select(kind: GamepadPref) -> PadBackend {
|
||||
#[cfg(target_os = "linux")]
|
||||
match kind {
|
||||
GamepadPref::DualSense => {
|
||||
tracing::info!("gamepad backend: virtual DualSense (UHID hid-playstation)");
|
||||
return PadBackend::DualSense(crate::inject::dualsense::DualSenseManager::new());
|
||||
}
|
||||
GamepadPref::DualShock4 => {
|
||||
tracing::info!("gamepad backend: virtual DualShock 4 (UHID hid-playstation)");
|
||||
return PadBackend::DualShock4(crate::inject::dualshock4::DualShock4Manager::new());
|
||||
}
|
||||
GamepadPref::SteamDeck => {
|
||||
tracing::info!("gamepad backend: virtual Steam Deck (UHID hid-steam)");
|
||||
return PadBackend::SteamDeck(
|
||||
crate::inject::steam_controller::SteamControllerManager::new(),
|
||||
);
|
||||
}
|
||||
GamepadPref::XboxOne => {
|
||||
tracing::info!("gamepad backend: uinput X-Box One/Series pad");
|
||||
return PadBackend::Xbox360(crate::inject::gamepad::GamepadManager::with_identity(
|
||||
crate::inject::gamepad::PadIdentity::xbox_one(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
impl Pads {
|
||||
/// `default` is the session kind (see [`resolve_gamepad`]); every pad starts on it until the
|
||||
/// client declares its own kind.
|
||||
fn new(default: GamepadPref) -> Pads {
|
||||
let default = resolve_pad_kind(default);
|
||||
tracing::info!(
|
||||
default = default.as_str(),
|
||||
"gamepad backends: per-pad router (session default)"
|
||||
);
|
||||
Pads {
|
||||
kinds: [default; MAX_WIRE_PADS],
|
||||
owner: [None; MAX_WIRE_PADS],
|
||||
xbox360: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
xboxone: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
dualsense: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
dualshock4: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamdeck: None,
|
||||
#[cfg(target_os = "windows")]
|
||||
dualsense_win: None,
|
||||
#[cfg(target_os = "windows")]
|
||||
dualshock4_win: None,
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
match kind {
|
||||
GamepadPref::DualSense => {
|
||||
tracing::info!("gamepad backend: virtual DualSense (Windows UMDF shm channel)");
|
||||
return PadBackend::DualSenseWindows(
|
||||
crate::inject::dualsense_windows::DualSenseWindowsManager::new(),
|
||||
);
|
||||
}
|
||||
GamepadPref::DualShock4 => {
|
||||
tracing::info!("gamepad backend: virtual DualShock 4 (Windows UMDF shm channel)");
|
||||
return PadBackend::DualShock4Windows(
|
||||
crate::inject::dualshock4_windows::DualShock4WindowsManager::new(),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
/// Record a pad's client-declared kind (resolved to a buildable backend). Takes effect on the
|
||||
/// pad's next frame; the arrival is sent before the pad's first input, so a device already
|
||||
/// built under the wrong kind is only the rare arrival-after-first-frame reorder — it then
|
||||
/// keeps the earlier kind until re-plug (no live device swap).
|
||||
fn set_kind(&mut self, idx: usize, kind: GamepadPref) {
|
||||
if idx >= MAX_WIRE_PADS {
|
||||
return;
|
||||
}
|
||||
let _ = kind;
|
||||
PadBackend::Xbox360(crate::inject::gamepad::GamepadManager::new())
|
||||
let resolved = resolve_pad_kind(kind);
|
||||
if self.kinds[idx] != resolved {
|
||||
tracing::info!(
|
||||
pad = idx,
|
||||
kind = resolved.as_str(),
|
||||
"gamepad kind declared (per-pad)"
|
||||
);
|
||||
}
|
||||
self.kinds[idx] = resolved;
|
||||
}
|
||||
|
||||
fn handle(&mut self, ev: &crate::gamestream::gamepad::GamepadEvent) {
|
||||
match self {
|
||||
PadBackend::Xbox360(m) => m.handle(ev),
|
||||
use crate::gamestream::gamepad::GamepadEvent;
|
||||
// Present = a create/update frame (the pad's mask bit is set); a cleared bit is the
|
||||
// removal frame emitted by the native detach path (`GamepadRemove`).
|
||||
let (idx, present) = match ev {
|
||||
GamepadEvent::State(f) => {
|
||||
let idx = f.index as usize;
|
||||
(idx, f.active_mask & (1 << idx) != 0)
|
||||
}
|
||||
GamepadEvent::Arrival { index, .. } => (*index as usize, true),
|
||||
};
|
||||
if idx >= MAX_WIRE_PADS {
|
||||
return;
|
||||
}
|
||||
let (kind, new_owner) = route_decision(self.owner[idx], self.kinds[idx], present);
|
||||
self.owner[idx] = new_owner;
|
||||
self.route_handle(kind, ev);
|
||||
}
|
||||
|
||||
/// Dispatch a decoded event to the manager for `kind`, creating it lazily.
|
||||
fn route_handle(&mut self, kind: GamepadPref, ev: &crate::gamestream::gamepad::GamepadEvent) {
|
||||
match kind {
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::DualSense(m) => m.handle(ev),
|
||||
GamepadPref::DualSense => self
|
||||
.dualsense
|
||||
.get_or_insert_with(crate::inject::dualsense::DualSenseManager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::DualShock4(m) => m.handle(ev),
|
||||
GamepadPref::DualShock4 => self
|
||||
.dualshock4
|
||||
.get_or_insert_with(crate::inject::dualshock4::DualShock4Manager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::SteamDeck(m) => m.handle(ev),
|
||||
GamepadPref::SteamDeck => self
|
||||
.steamdeck
|
||||
.get_or_insert_with(crate::inject::steam_controller::SteamControllerManager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::XboxOne => self
|
||||
.xboxone
|
||||
.get_or_insert_with(|| {
|
||||
crate::inject::gamepad::GamepadManager::with_identity(
|
||||
crate::inject::gamepad::PadIdentity::xbox_one(),
|
||||
)
|
||||
})
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "windows")]
|
||||
PadBackend::DualSenseWindows(m) => m.handle(ev),
|
||||
GamepadPref::DualSense => self
|
||||
.dualsense_win
|
||||
.get_or_insert_with(crate::inject::dualsense_windows::DualSenseWindowsManager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "windows")]
|
||||
PadBackend::DualShock4Windows(m) => m.handle(ev),
|
||||
GamepadPref::DualShock4 => self
|
||||
.dualshock4_win
|
||||
.get_or_insert_with(
|
||||
crate::inject::dualshock4_windows::DualShock4WindowsManager::new,
|
||||
)
|
||||
.handle(ev),
|
||||
_ => self
|
||||
.xbox360
|
||||
.get_or_insert_with(crate::inject::gamepad::GamepadManager::new)
|
||||
.handle(ev),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a rich client→host event (touchpad / motion). A no-op for the X-Box pad, which has no
|
||||
/// equivalent; the DualSense and DualShock 4 pads both carry a touchpad + motion sensors.
|
||||
fn apply_rich(&mut self, _rich: punktfunk_core::quic::RichInput) {
|
||||
match self {
|
||||
PadBackend::Xbox360(_) => {}
|
||||
/// Apply a rich client→host event (touchpad / motion) to the pad's kind manager, if it exists
|
||||
/// (rich before the first frame = no device yet = a no-op anyway). The X-Box pads have no rich
|
||||
/// plane, so those indices ignore it.
|
||||
fn apply_rich(&mut self, rich: punktfunk_core::quic::RichInput) {
|
||||
use punktfunk_core::quic::RichInput;
|
||||
let idx = match rich {
|
||||
RichInput::Touchpad { pad, .. }
|
||||
| RichInput::Motion { pad, .. }
|
||||
| RichInput::TouchpadEx { pad, .. } => pad as usize,
|
||||
};
|
||||
// Route to the manager that actually owns the device (falling back to the declared kind
|
||||
// before the first frame builds it), so a pad's touchpad/motion never lands on the wrong
|
||||
// backend after a kind change.
|
||||
let kind = self
|
||||
.owner
|
||||
.get(idx)
|
||||
.copied()
|
||||
.flatten()
|
||||
.or_else(|| self.kinds.get(idx).copied())
|
||||
.unwrap_or(GamepadPref::Xbox360);
|
||||
match kind {
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::DualSense(m) => m.apply_rich(_rich),
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::DualShock4(m) => m.apply_rich(_rich),
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::SteamDeck(m) => m.apply_rich(_rich),
|
||||
#[cfg(target_os = "windows")]
|
||||
PadBackend::DualSenseWindows(m) => m.apply_rich(_rich),
|
||||
#[cfg(target_os = "windows")]
|
||||
PadBackend::DualShock4Windows(m) => m.apply_rich(_rich),
|
||||
}
|
||||
}
|
||||
|
||||
/// Service feedback every cycle. `rumble` carries motor force-feedback on the universal plane
|
||||
/// (every backend); `hidout` carries rich feedback on the HID-output plane — lightbar (both
|
||||
/// UHID pads), plus player LEDs / adaptive triggers (DualSense only). The X-Box pad has no
|
||||
/// rich-feedback plane.
|
||||
fn pump(
|
||||
&mut self,
|
||||
rumble: impl FnMut(u16, u16, u16),
|
||||
hidout: impl FnMut(punktfunk_core::quic::HidOutput),
|
||||
) {
|
||||
match self {
|
||||
PadBackend::Xbox360(m) => {
|
||||
let _ = hidout; // the X-Box pad has no rich-feedback plane
|
||||
m.pump_rumble(rumble)
|
||||
GamepadPref::DualSense => {
|
||||
if let Some(m) = &mut self.dualsense {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::DualSense(m) => m.pump(rumble, hidout),
|
||||
GamepadPref::DualShock4 => {
|
||||
if let Some(m) = &mut self.dualshock4 {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::DualShock4(m) => m.pump(rumble, hidout),
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::SteamDeck(m) => m.pump(rumble, hidout),
|
||||
GamepadPref::SteamDeck => {
|
||||
if let Some(m) = &mut self.steamdeck {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
PadBackend::DualSenseWindows(m) => m.pump(rumble, hidout),
|
||||
GamepadPref::DualSense => {
|
||||
if let Some(m) = &mut self.dualsense_win {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
PadBackend::DualShock4Windows(m) => m.pump(rumble, hidout),
|
||||
GamepadPref::DualShock4 => {
|
||||
if let Some(m) = &mut self.dualshock4_win {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep a virtual UHID pad alive during input silence: re-emit its current HID report if it's
|
||||
/// gone quiet, so the kernel `hid-playstation` driver / SDL don't treat a held-steady pad as
|
||||
/// unplugged ("controller disconnected every few seconds"). No-op for the X-Box pad (evdev
|
||||
/// holds last-known state with no periodic-report requirement). Called every input-thread tick;
|
||||
/// the per-pad gap timer (not the tick rate) governs the actual emit cadence.
|
||||
fn heartbeat(&mut self) {
|
||||
match self {
|
||||
PadBackend::Xbox360(_) => {}
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::DualSense(m) => m.heartbeat(std::time::Duration::from_millis(8)),
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::DualShock4(m) => m.heartbeat(std::time::Duration::from_millis(8)),
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::SteamDeck(m) => m.heartbeat(std::time::Duration::from_millis(8)),
|
||||
#[cfg(target_os = "windows")]
|
||||
PadBackend::DualSenseWindows(m) => m.heartbeat(std::time::Duration::from_millis(8)),
|
||||
#[cfg(target_os = "windows")]
|
||||
PadBackend::DualShock4Windows(m) => m.heartbeat(std::time::Duration::from_millis(8)),
|
||||
/// Service feedback for every instantiated backend each cycle. `rumble` carries motor
|
||||
/// force-feedback on the universal plane (every backend, tagged with its own pad index);
|
||||
/// `hidout` carries rich feedback (lightbar / player LEDs / adaptive triggers) for the UHID/UMDF
|
||||
/// pads. The `&mut` closure re-borrows satisfy `FnMut` for each backend.
|
||||
fn pump(
|
||||
&mut self,
|
||||
mut rumble: impl FnMut(u16, u16, u16),
|
||||
mut hidout: impl FnMut(punktfunk_core::quic::HidOutput),
|
||||
) {
|
||||
if let Some(m) = &mut self.xbox360 {
|
||||
m.pump_rumble(&mut rumble); // the X-Box pad has no rich-feedback plane
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Some(m) = &mut self.xboxone {
|
||||
m.pump_rumble(&mut rumble);
|
||||
}
|
||||
if let Some(m) = &mut self.dualsense {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.dualshock4 {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.steamdeck {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(m) = &mut self.dualsense_win {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.dualshock4_win {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep every instantiated virtual UHID/UMDF pad alive during input silence (re-emit its HID
|
||||
/// report so the kernel driver / SDL don't drop a held-steady pad). The X-Box pads need no
|
||||
/// heartbeat (evdev holds last-known state). Per-pad gap timers inside each manager govern the
|
||||
/// actual emit cadence, not this per-tick call.
|
||||
fn heartbeat(&mut self) {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let gap = std::time::Duration::from_millis(8);
|
||||
if let Some(m) = &mut self.dualsense {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.dualshock4 {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.steamdeck {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let gap = std::time::Duration::from_millis(8);
|
||||
if let Some(m) = &mut self.dualsense_win {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.dualshock4_win {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-pad routing decision for one frame ([`Pads::handle`]): given `owner` (the manager
|
||||
/// holding a live device at this index, if any), the client-`declared` kind, and whether this is a
|
||||
/// create/update frame (`present`) vs a removal, return `(kind to route to, new owner)`.
|
||||
///
|
||||
/// A live device stays in its owning manager even if the declared kind later changes (so a pad is
|
||||
/// never duplicated across managers); the declared kind takes effect only when no device exists
|
||||
/// yet; a removal routes to the owner's manager (so it tears the right device down) and clears the
|
||||
/// owner.
|
||||
fn route_decision(
|
||||
owner: Option<GamepadPref>,
|
||||
declared: GamepadPref,
|
||||
present: bool,
|
||||
) -> (GamepadPref, Option<GamepadPref>) {
|
||||
match (owner, present) {
|
||||
(Some(k), true) => (k, Some(k)), // keep the existing device in its manager
|
||||
(Some(k), false) => (k, None), // removal → owner's manager, then clear
|
||||
(None, true) => (declared, Some(declared)), // create in the declared kind's manager
|
||||
(None, false) => (declared, None), // removal with no device — a harmless no-op
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve one client-declared per-pad kind to a backend this host can actually build (mixed
|
||||
/// types): the platform map + the runtime UHID / Steam-conflict degrades that [`resolve_gamepad`]
|
||||
/// applies to the session default, minus the Auto/env session logic (a per-pad declaration is
|
||||
/// always a concrete kind).
|
||||
fn resolve_pad_kind(kind: GamepadPref) -> GamepadPref {
|
||||
let chosen = pick_gamepad(
|
||||
kind,
|
||||
None,
|
||||
cfg!(target_os = "linux"),
|
||||
cfg!(target_os = "windows"),
|
||||
);
|
||||
degrade_steam_on_conflict(degrade_if_no_uhid(chosen))
|
||||
}
|
||||
|
||||
/// One client→host input item, both planes on ONE channel so the input thread wakes the
|
||||
@@ -2156,8 +2105,9 @@ fn send_rumble(
|
||||
}
|
||||
|
||||
/// The per-session input thread: route pointer/keyboard events to the host-lifetime injector
|
||||
/// service (`inj_tx`) and gamepad events to this session's [`PadBackend`] (`gamepad` — the
|
||||
/// resolved Hello preference: uinput X-Box pads or virtual DualSense pads), with rich
|
||||
/// service (`inj_tx`) and gamepad events to this session's [`Pads`] router (`gamepad` — the
|
||||
/// resolved Hello preference is the per-pad default; clients declare each pad's kind so a session
|
||||
/// can mix uinput X-Box pads and virtual DualSense pads), with rich
|
||||
/// client→host input (touchpad / motion, [`ClientInput::Rich`]) applied on arrival and
|
||||
/// feedback pumped between events — rumble on the universal datagram plane, DualSense
|
||||
/// LED/trigger feedback on the HID-output plane. The gamepads are created and torn down with
|
||||
@@ -2175,7 +2125,7 @@ fn input_thread(
|
||||
inj_tx: std::sync::mpsc::Sender<InputEvent>,
|
||||
gamepad: GamepadPref,
|
||||
) {
|
||||
let mut pads = PadBackend::select(gamepad);
|
||||
let mut pads = Pads::new(gamepad);
|
||||
// Motion-cadence observability (debug level): inter-arrival percentiles per 5 s window,
|
||||
// the measurement a "gyro feels floaty" report needs. Bounded: 5 s at even a 1 kHz pad
|
||||
// is 5000 u32s.
|
||||
@@ -2298,6 +2248,44 @@ fn input_thread(
|
||||
}
|
||||
}
|
||||
}
|
||||
InputKind::GamepadRemove => {
|
||||
// Mid-session hot-unplug from a snapshot-capable client (the native plane's
|
||||
// `activeGamepadMask` equivalent). Seq-gated in the SAME per-pad sequence
|
||||
// space as snapshots, so a snapshot the network reordered past this removal
|
||||
// is dropped (older seq) and can't resurrect the pad — while a later re-plug
|
||||
// on the same index arrives with a still-newer seq and is accepted. Clearing
|
||||
// the `active_mask` bit and re-emitting the frame fires every backend's
|
||||
// unplug sweep (`inject/*/gamepad.rs`), tearing down just this pad's device.
|
||||
let (pad, seq) = punktfunk_core::input::decode_gamepad_remove(ev.flags);
|
||||
let idx = pad as usize;
|
||||
if idx < MAX_WIRE_PADS
|
||||
&& punktfunk_core::input::GamepadSnapshot::seq_newer(seq, pad_seq[idx])
|
||||
{
|
||||
pad_seq[idx] = Some(seq);
|
||||
if pad_mask & (1 << idx) != 0 {
|
||||
pad_mask &= !(1 << idx);
|
||||
pad_state[idx] = PadState::default();
|
||||
let frame = pad_state[idx].frame(idx, pad_mask);
|
||||
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(frame));
|
||||
tracing::info!(pad = idx, "gamepad unplugged (native detach)");
|
||||
}
|
||||
// Fresh feedback bookkeeping so a later re-plug on this index inherits no
|
||||
// stale rumble lease/seq (a lease still ticking would buzz the new pad).
|
||||
rumble_state[idx] = (0, 0);
|
||||
rumble_seen[idx] = false;
|
||||
rumble_seq[idx] = 0;
|
||||
rumble_stop_burst[idx] = 0;
|
||||
}
|
||||
}
|
||||
InputKind::GamepadArrival => {
|
||||
// Per-pad controller kind declaration (mixed types): route this pad's future
|
||||
// frames to a backend of the declared kind. `code` = the GamepadPref wire byte,
|
||||
// `flags` = pad index. Applied before the pad's first frame (the client sends it
|
||||
// on slot open), so the device is built as the right type from the start.
|
||||
let idx = ev.flags as usize;
|
||||
let kind = GamepadPref::from_u8(ev.code as u8);
|
||||
pads.set_kind(idx, kind);
|
||||
}
|
||||
_ => {
|
||||
// Track press/release so a mid-press disconnect can be undone below.
|
||||
match ev.kind {
|
||||
@@ -4966,6 +4954,34 @@ fn build_pipeline(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn per_pad_route_decision() {
|
||||
use GamepadPref::{DualSense, Xbox360};
|
||||
// First frame with no device: create in the declared kind's manager, record ownership.
|
||||
assert_eq!(
|
||||
route_decision(None, DualSense, true),
|
||||
(DualSense, Some(DualSense))
|
||||
);
|
||||
// Subsequent frame: stays in the owning manager even if the declared kind now differs
|
||||
// (the arrival-after-first-frame reorder) — never a second device in another manager.
|
||||
assert_eq!(
|
||||
route_decision(Some(DualSense), Xbox360, true),
|
||||
(DualSense, Some(DualSense))
|
||||
);
|
||||
// Removal (cleared bit): routes to the owner so the RIGHT device is torn down, then clears.
|
||||
assert_eq!(
|
||||
route_decision(Some(DualSense), Xbox360, false),
|
||||
(DualSense, None)
|
||||
);
|
||||
// Removal with no device is a harmless no-op route (owner stays cleared).
|
||||
assert_eq!(route_decision(None, Xbox360, false), (Xbox360, None));
|
||||
// A fresh device after a re-plug picks up the newly-declared kind (owner was cleared).
|
||||
assert_eq!(
|
||||
route_decision(None, Xbox360, true),
|
||||
(Xbox360, Some(Xbox360))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_mode_pack_roundtrips_and_interval_recovers_hz() {
|
||||
// The live-stats mode slot (H3): pack → unpack is exact for real modes.
|
||||
@@ -5559,138 +5575,6 @@ 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()))
|
||||
}
|
||||
|
||||
+109
-324
@@ -25,10 +25,9 @@
|
||||
// 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.
|
||||
// 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.
|
||||
// v6: added the `punktfunk_reanchor_gate_*` surface (post-loss freeze-until-reanchor gate for the
|
||||
// Swift client; Rust embedders use [`reanchor::ReanchorGate`] directly). Additive, client-local —
|
||||
// no wire change, so [`WIRE_VERSION`] is unchanged.
|
||||
#define ABI_VERSION 6
|
||||
|
||||
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
@@ -163,60 +162,11 @@
|
||||
// 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
|
||||
|
||||
@@ -487,16 +437,6 @@
|
||||
#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
|
||||
@@ -593,119 +533,6 @@
|
||||
#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
|
||||
@@ -762,24 +589,22 @@
|
||||
#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
|
||||
// Consecutive no-output AUs that force a keyframe request. ~50 ms at 60 Hz — long enough not to fire
|
||||
// on a one-frame decoder hiccup, short enough that a lost initial IDR (or a mid-GOP join) unfreezes
|
||||
// almost immediately instead of never.
|
||||
#define NO_OUTPUT_KEYFRAME_STREAK 3
|
||||
|
||||
#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
|
||||
// How many host intra-refresh recovery marks ([`USER_FLAG_RECOVERY_POINT`]) must arrive since the
|
||||
// latest loss before the gate 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 fresh
|
||||
// arm 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`]: crate::packet::USER_FLAG_RECOVERY_POINT
|
||||
#define REANCHOR_MARKS_TO_LIFT 2
|
||||
|
||||
// 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.
|
||||
@@ -853,6 +678,28 @@ enum PunktfunkInputKind
|
||||
// [`HOST_CAP_GAMEPAD_STATE`](crate::quic::HOST_CAP_GAMEPAD_STATE); older hosts keep
|
||||
// receiving the per-transition events.
|
||||
PUNKTFUNK_INPUT_KIND_GAMEPAD_STATE = 12,
|
||||
// A pad was unplugged client-side (the native plane's answer to GameStream's
|
||||
// `activeGamepadMask`, which the per-transition/snapshot planes otherwise lack — see
|
||||
// [`encode_gamepad_remove`]). `flags` packs `seq << 24 | pad`: the low byte is the pad
|
||||
// index, the high byte a per-pad wrapping seq sharing the [`GamepadSnapshot`] sequence
|
||||
// space. The host clears the pad's `active_mask` bit so its virtual device is torn down,
|
||||
// seq-gated against snapshots so one the network reordered past the removal can't resurrect
|
||||
// the pad, and the shared seq space keeps the same index reusable by a later re-plug. Sent
|
||||
// only to a host that advertised [`HOST_CAP_GAMEPAD_STATE`](crate::quic::HOST_CAP_GAMEPAD_STATE);
|
||||
// an older host ignores the unknown tag (the pad then lingers until session end — the
|
||||
// pre-existing behaviour).
|
||||
PUNKTFUNK_INPUT_KIND_GAMEPAD_REMOVE = 13,
|
||||
// Declares which controller KIND a pad presents so a session can MIX types (pad 0 a
|
||||
// DualSense, pad 1 an Xbox pad). `code` = the [`GamepadPref`](crate::config::GamepadPref)
|
||||
// wire byte, `flags` = pad index. Sent when the client opens a pad slot — before that pad's
|
||||
// first input — and re-sent a few times against datagram loss (like [`GamepadRemove`]). The
|
||||
// host resolves the kind to a buildable backend and routes that pad's virtual device to it; a
|
||||
// pad the client never declares (an older client, or a fully-lost declaration) falls back to
|
||||
// the session-default kind from the handshake. Idempotent (no seq): re-declaring the same kind
|
||||
// is a no-op. Meaningful only to a host that advertised
|
||||
// [`HOST_CAP_GAMEPAD_STATE`](crate::quic::HOST_CAP_GAMEPAD_STATE); an older host ignores the
|
||||
// unknown tag (every pad then uses the session-default kind — the pre-existing behaviour).
|
||||
PUNKTFUNK_INPUT_KIND_GAMEPAD_ARRIVAL = 14,
|
||||
};
|
||||
#ifndef __cplusplus
|
||||
#if __STDC_VERSION__ >= 202311L
|
||||
@@ -887,6 +734,18 @@ typedef struct PunktfunkConnection PunktfunkConnection;
|
||||
// Opaque session handle. Pointer-only from C.
|
||||
typedef struct PunktfunkSession PunktfunkSession;
|
||||
|
||||
// The shared post-loss freeze state machine. A client feeds it three kinds of event — an *arm* (a
|
||||
// loss was detected: a frame-index gap, a dropped-count climb, or a decoder wedge/demotion), each
|
||||
// *decoded frame* ([`on_decoded`](Self::on_decoded), which decides present-vs-hold and interprets the
|
||||
// re-anchor wire flags), and each *no-output* AU ([`on_no_output`](Self::on_no_output)) — plus a
|
||||
// periodic [`poll`](Self::poll) that folds the dropped counter and fires the overdue backstop.
|
||||
//
|
||||
// The gate emits *intents* only: [`on_no_output`](Self::on_no_output) and [`poll`](Self::poll) return
|
||||
// `true` when the client should ask the host for a keyframe. The client routes that through its own
|
||||
// ~100 ms request throttle (and the precise RFI-vs-keyframe range decision stays in the loss-range
|
||||
// tracker behind [`crate::client::NativeClient::note_frame_index`]) — the gate never touches the wire.
|
||||
typedef struct ReanchorGate ReanchorGate;
|
||||
|
||||
// Forward-compatible session configuration. The caller MUST set `struct_size` to
|
||||
// `sizeof(PunktfunkConfig)`; the core uses it to detect ABI skew.
|
||||
typedef struct {
|
||||
@@ -1110,47 +969,6 @@ 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
|
||||
@@ -1790,96 +1608,6 @@ 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`]
|
||||
@@ -2041,6 +1769,63 @@ void punktfunk_connection_disconnect_quit(PunktfunkConnection *c);
|
||||
void punktfunk_connection_close(PunktfunkConnection *c);
|
||||
#endif
|
||||
|
||||
// Create a re-anchor gate seeded with the session's current `frames_dropped` (so the first
|
||||
// [`punktfunk_reanchor_gate_poll`] doesn't read the baseline as a loss). Free with
|
||||
// [`punktfunk_reanchor_gate_free`]. Never returns NULL.
|
||||
ReanchorGate *punktfunk_reanchor_gate_new(uint64_t frames_dropped);
|
||||
|
||||
// Free a gate created by [`punktfunk_reanchor_gate_new`]. NULL is a no-op.
|
||||
//
|
||||
// # Safety
|
||||
// `g` was returned by [`punktfunk_reanchor_gate_new`] and is not used after this call.
|
||||
void punktfunk_reanchor_gate_free(ReanchorGate *g);
|
||||
|
||||
// Arm the freeze: a loss was detected (a frame-index gap, or a decoder wedge/demotion). Zeroes the
|
||||
// recovery-mark count and (re-)sets the backstop deadline. NULL is a no-op.
|
||||
//
|
||||
// # Safety
|
||||
// `g` is a valid gate handle.
|
||||
void punktfunk_reanchor_gate_arm(ReanchorGate *g);
|
||||
|
||||
// Fold one decoded frame and write to `out_present` whether to display it (`true`) or withhold it as
|
||||
// a post-loss concealment (`false`). `flags` is the AU's `user_flags` word ([`PunktfunkFrame::flags`]):
|
||||
// the gate reads `FLAG_SOF` (the host's IDR marker), `USER_FLAG_RECOVERY_ANCHOR` and
|
||||
// `USER_FLAG_RECOVERY_POINT`. Pass `decoder_keyframe = false` where the platform decoder doesn't flag
|
||||
// IDRs (VideoToolbox/MediaCodec) — the wire `FLAG_SOF` covers it.
|
||||
//
|
||||
// # Safety
|
||||
// `g` is a valid gate handle; `out_present` is writable or NULL.
|
||||
PunktfunkStatus punktfunk_reanchor_gate_on_decoded(ReanchorGate *g,
|
||||
uint32_t flags,
|
||||
bool decoder_keyframe,
|
||||
bool *out_present);
|
||||
|
||||
// A received AU produced no decoded frame. Writes to `out_request_kf` whether the no-output streak has
|
||||
// tripped and the client should (throttled) request a keyframe — the gate arms the freeze at the same
|
||||
// time.
|
||||
//
|
||||
// # Safety
|
||||
// `g` is a valid gate handle; `out_request_kf` is writable or NULL.
|
||||
PunktfunkStatus punktfunk_reanchor_gate_on_no_output(ReanchorGate *g,
|
||||
bool *out_request_kf);
|
||||
|
||||
// Periodic fold of the session's `frames_dropped` counter plus the overdue backstop. Writes to
|
||||
// `out_request_kf` whether the client should (throttled) request a keyframe (a drop-count climb armed
|
||||
// a fresh freeze, or the freeze is overdue and re-asks while it keeps holding).
|
||||
//
|
||||
// # Safety
|
||||
// `g` is a valid gate handle; `out_request_kf` is writable or NULL.
|
||||
PunktfunkStatus punktfunk_reanchor_gate_poll(ReanchorGate *g,
|
||||
uint64_t frames_dropped,
|
||||
bool *out_request_kf);
|
||||
|
||||
// Whether the gate is currently withholding concealed frames (frozen on the last good picture).
|
||||
// Writes `false` on a NULL gate.
|
||||
//
|
||||
// # Safety
|
||||
// `g` is a valid gate handle; `out_holding` is writable or NULL.
|
||||
PunktfunkStatus punktfunk_reanchor_gate_is_holding(const ReanchorGate *g, bool *out_holding);
|
||||
|
||||
#ifdef __cplusplus
|
||||
} // extern "C"
|
||||
#endif // __cplusplus
|
||||
|
||||
@@ -73,7 +73,11 @@ build() {
|
||||
# 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 \
|
||||
# `punktfunk-host/vulkan-encode` is the AMD/Intel twin: a raw VK_KHR_video_encode_h265 backend with
|
||||
# real RFI (clean P-frame recovery anchor via DPB reference slots; design/linux-vulkan-video-encode.md).
|
||||
# Pure Rust `ash` (no new lib, no link-time deps); default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0
|
||||
# opts back to libav VAAPI), and a failed open falls back to VAAPI so unsupported devices are safe.
|
||||
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||
-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.
|
||||
|
||||
@@ -180,7 +180,11 @@ export PUNKTFUNK_BUILD_VERSION="%{version}-%{release}"
|
||||
# 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 \
|
||||
# --features punktfunk-host/vulkan-encode: the AMD/Intel twin — a raw VK_KHR_video_encode_h265 backend
|
||||
# with real RFI (clean P-frame recovery anchor via DPB reference slots; design/linux-vulkan-video-encode.md).
|
||||
# Pure Rust `ash` (no new lib / no link-time dep); default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0 opts
|
||||
# back to libav VAAPI), and a failed open falls back to VAAPI so unsupported devices degrade gracefully.
|
||||
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-tray
|
||||
|
||||
%if %{with web}
|
||||
|
||||
@@ -146,6 +146,12 @@ pub struct FramePublisher {
|
||||
/// Set when a surface is dropped for a descriptor mismatch (a game mode-set the display), cleared on a
|
||||
/// matched publish — throttles the drop log to once per mismatch episode (game-capture bug GB1).
|
||||
mismatch_logged: bool,
|
||||
/// The render adapter (LUID) this publisher's device + opened ring textures live on. A worker
|
||||
/// re-adopts a publisher preserved across a swap-chain unassign→reassign flap ONLY when the
|
||||
/// freshly-assigned swap-chain renders on this SAME adapter (else the opened textures would be
|
||||
/// cross-device); see [`Self::render_adapter`] + `swap_chain_processor::run_core`.
|
||||
render_luid_low: u32,
|
||||
render_luid_high: i32,
|
||||
}
|
||||
|
||||
// SAFETY: created and used only on the swap-chain processor thread.
|
||||
@@ -356,6 +362,8 @@ impl FramePublisher {
|
||||
ring_format: unsafe { (*header).dxgi_format },
|
||||
generation: header_gen,
|
||||
mismatch_logged: false,
|
||||
render_luid_low,
|
||||
render_luid_high,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -379,6 +387,13 @@ impl FramePublisher {
|
||||
cur != self.generation
|
||||
}
|
||||
|
||||
/// The render adapter (LUID) this publisher's device + opened ring textures live on. The swap-chain
|
||||
/// worker re-adopts a publisher preserved across an unassign→reassign flap only when the freshly-
|
||||
/// assigned swap-chain renders on this same adapter (see the field docs + `run_core`).
|
||||
pub fn render_adapter(&self) -> (u32, i32) {
|
||||
(self.render_luid_low, self.render_luid_high)
|
||||
}
|
||||
|
||||
/// Copy `surface` into the next free ring slot and signal the host. Never blocks (0 ms try-acquire).
|
||||
pub fn publish(&mut self, surface: &ID3D11Texture2D) {
|
||||
let ring_len = self.slots.len() as u32;
|
||||
|
||||
@@ -58,6 +58,17 @@ pub struct MonitorObject {
|
||||
/// closes an unconsumed channel's handles via [`FrameChannel`]'s `Drop`, so no delivery can leak
|
||||
/// handles in the WUDFHost table whatever the monitor's fate.
|
||||
pub frame_channel: Option<crate::frame_transport::FrameChannel>,
|
||||
/// A live [`FramePublisher`](crate::frame_transport::FramePublisher) preserved across a swap-chain
|
||||
/// unassign→reassign flap (STEP 6 sibling-join fix). The OS unassigns a monitor's swap-chain
|
||||
/// whenever a SIBLING display churns the desktop topology (a second client joining / leaving /
|
||||
/// resizing), which drops the swap-chain worker — but the HOST-owned ring (header / event /
|
||||
/// textures) the publisher holds stays valid, and the host only re-delivers the frame channel on a
|
||||
/// ring RECREATE (a descriptor change), so a fresh worker had nothing to re-attach from and the
|
||||
/// first client's stream froze (repeat frames forever). The exiting worker stashes its still-live
|
||||
/// publisher here ([`preserve_publisher`]); the next worker on the SAME render adapter takes it back
|
||||
/// ([`take_preserved_publisher`]) and resumes publishing into the same ring. Dropped with the
|
||||
/// `MonitorObject` on teardown (closing its ring handles) if no worker ever reclaims it.
|
||||
pub preserved_publisher: Option<crate::frame_transport::FramePublisher>,
|
||||
/// When the entry was created — the watchdog skips still-initializing monitors.
|
||||
pub created_at: Instant,
|
||||
}
|
||||
@@ -318,6 +329,49 @@ pub fn has_frame_channel(target_id: u32) -> bool {
|
||||
.any(|m| m.target_id == target_id && m.frame_channel.is_some())
|
||||
}
|
||||
|
||||
/// Stash a swap-chain worker's still-live [`FramePublisher`](crate::frame_transport::FramePublisher) on
|
||||
/// its monitor across a swap-chain unassign→reassign flap (STEP 6 sibling-join fix; see the field docs
|
||||
/// on [`MonitorObject::preserved_publisher`]). Called from the EXITING worker thread — the caller must
|
||||
/// NOT hold `MONITOR_MODES` (this locks it), matching the same drop-outside-the-lock discipline the
|
||||
/// processor teardown paths use. Returns `Err(publisher)` when no monitor with `target_id` exists (a
|
||||
/// genuine teardown, not a flap: the entry was already removed) so the caller drops it, closing the ring
|
||||
/// handles. Replacing an already-stashed publisher (should not happen — one worker exits at a time)
|
||||
/// drops the old one, so it can never accumulate. Returning the publisher in the `Err` makes the
|
||||
/// `Result` itself `#[must_use]`, so a caller can't silently drop the not-preserved publisher.
|
||||
pub fn preserve_publisher(
|
||||
target_id: u32,
|
||||
publisher: crate::frame_transport::FramePublisher,
|
||||
) -> Result<(), crate::frame_transport::FramePublisher> {
|
||||
if target_id == 0 {
|
||||
return Err(publisher);
|
||||
}
|
||||
let mut lock = lock_monitors();
|
||||
if let Some(m) = lock.iter_mut().find(|m| m.target_id == target_id) {
|
||||
m.preserved_publisher = Some(publisher);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(publisher)
|
||||
}
|
||||
}
|
||||
|
||||
/// Take (remove) a preserved [`FramePublisher`](crate::frame_transport::FramePublisher) for a freshly-
|
||||
/// (re)assigned swap-chain worker (STEP 6 sibling-join fix). The caller re-adopts it ONLY when the new
|
||||
/// swap-chain's render adapter matches the publisher's ([`FramePublisher::render_adapter`]) — same
|
||||
/// pooled device, so its context + opened ring textures are still valid; on a mismatch the caller drops
|
||||
/// it and waits for a fresh channel delivery instead. `None` until a worker has stashed one.
|
||||
pub fn take_preserved_publisher(
|
||||
target_id: u32,
|
||||
) -> Option<crate::frame_transport::FramePublisher> {
|
||||
if target_id == 0 {
|
||||
return None;
|
||||
}
|
||||
lock_monitors()
|
||||
.iter_mut()
|
||||
.find(|m| m.target_id == target_id)?
|
||||
.preserved_publisher
|
||||
.take()
|
||||
}
|
||||
|
||||
/// Install a swap-chain processor on the monitor whose handle matches, returning any PREVIOUS processor
|
||||
/// for the caller to drop OUTSIDE the lock. Dropping a processor RAII-joins its worker thread, so it must
|
||||
/// never happen while holding `MONITOR_MODES` (the worker would block the whole control plane / risk a
|
||||
@@ -406,6 +460,7 @@ pub fn create_monitor(
|
||||
adapter_luid_high: 0,
|
||||
swap_chain_processor: None,
|
||||
frame_channel: None,
|
||||
preserved_publisher: None,
|
||||
created_at: Instant::now(),
|
||||
});
|
||||
id
|
||||
|
||||
@@ -241,7 +241,31 @@ impl SwapChainProcessor {
|
||||
// the values via IOCTL_SET_FRAME_CHANNEL, which the control plane stashes on our monitor
|
||||
// (`monitor::take_frame_channel`). Until a delivery lands we just drain — exactly the STEP-5
|
||||
// behaviour — so a non-IDD-push session never stalls. The stash is polled every ~30 iterations.
|
||||
let mut publisher: Option<FramePublisher> = None;
|
||||
// STEP 6 sibling-join fix: re-adopt a FramePublisher PRESERVED across a swap-chain
|
||||
// unassign→reassign flap. When a SIBLING display churns the desktop topology (a second client
|
||||
// joining / leaving / resizing), the OS reassigns THIS monitor's swap-chain and the previous
|
||||
// worker exited — but the host-owned ring it published into is still live, and the host only
|
||||
// re-delivers the frame channel on a ring RECREATE (a descriptor change). Without this the
|
||||
// fresh worker has nothing to attach to and the first client's stream freezes (repeat frames
|
||||
// forever). Re-adopt ONLY when the freshly-assigned swap-chain renders on the SAME adapter as
|
||||
// the preserved publisher (same pooled Direct3DDevice → its immediate context + opened ring
|
||||
// textures are valid); a mismatch drops it and falls back to a fresh channel delivery. A
|
||||
// preserved publisher that the host superseded meanwhile (ring recreate → is_stale, or a
|
||||
// pending delivery) is dropped + replaced by the existing re-attach logic at the loop top.
|
||||
let mut publisher: Option<FramePublisher> =
|
||||
crate::monitor::take_preserved_publisher(target_id).and_then(|p| {
|
||||
if p.render_adapter() == (render_luid_low, render_luid_high) {
|
||||
dbglog!(
|
||||
"[pf-vd] swap-chain run_core: re-adopted preserved publisher (target={target_id}) — resuming the host ring across the swap-chain flap"
|
||||
);
|
||||
Some(p)
|
||||
} else {
|
||||
dbglog!(
|
||||
"[pf-vd] swap-chain run_core: preserved publisher's render adapter changed (target={target_id}) — dropping it, will re-attach from a fresh channel"
|
||||
);
|
||||
None
|
||||
}
|
||||
});
|
||||
let mut frames_since_try: u32 = u32::MAX; // attach attempt on the first loop iteration
|
||||
|
||||
let mut logged_pending = false;
|
||||
@@ -392,6 +416,17 @@ impl SwapChainProcessor {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// STEP 6 sibling-join fix: the drain loop exited (the OS unassigned this swap-chain — typically
|
||||
// because a SIBLING display churned the desktop topology — or it errored), but the host-owned
|
||||
// ring the publisher holds is still live. Hand it to the monitor so the NEXT worker assigned to
|
||||
// this monitor resumes publishing into the same ring instead of freezing (the host re-delivers
|
||||
// the channel only on a ring recreate). If the monitor is GONE (a genuine teardown, not a flap),
|
||||
// `preserve_publisher` hands the publisher back inside the `Err` and dropping the returned
|
||||
// `Result` closes the ring handles here — no leak, no stale ring left behind.
|
||||
if let Some(p) = publisher.take() {
|
||||
let _ = crate::monitor::preserve_publisher(target_id, p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user