Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d407a866d | ||
|
|
bf2d8505cf | ||
|
|
414380fc9e | ||
|
|
6267dcdcd3 | ||
|
|
8042a2fd52 | ||
|
|
7e40098bc6 | ||
|
|
ac5299d4ce | ||
|
|
017c37b78a | ||
|
|
2fd303e22f | ||
|
|
f84c5b8114 | ||
|
|
aec02b9d26 | ||
|
|
48bb1769b4 | ||
|
|
454fa2e0cb | ||
|
|
9f1f23eb40 | ||
|
|
d1c4cb18dd | ||
|
|
91aa684f0d | ||
|
|
e629606e39 | ||
|
|
ff5602361f | ||
|
|
5e319f3b77 | ||
|
|
34ad3cc611 | ||
|
|
857d7d7b6b | ||
|
|
f3c0ee47d7 | ||
|
|
80b4eccff9 | ||
|
|
63a4f583b9 | ||
|
|
290d760ea4 | ||
|
|
69f1db5ea9 | ||
|
|
7331be0a40 | ||
|
|
4bc7eecf05 | ||
|
|
dbc12dedcc | ||
|
|
2dfb7791a2 | ||
|
|
5e19a4611f | ||
|
|
6f54fcdd2d | ||
|
|
c6597cbeb5 | ||
|
|
2cfc82e96c | ||
|
|
e9a209ef61 | ||
|
|
a12f1f092c | ||
|
|
3055e29ebb | ||
|
|
7077b0a0df | ||
|
|
e5453aebb7 |
@@ -50,10 +50,12 @@ import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -250,6 +252,139 @@ fun GamepadHostOptionsDialog(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pin-to-hosts picker the settings screen's Profiles section opens — the Android mirror of the
|
||||
* desktop console's PinHostsScreen (design §5.2a): one toggle row per SAVED host, D-pad up/down
|
||||
* moves, A flips the focused pin, left/right unpins/pins (the settings-toggle semantics), B closes.
|
||||
* A toggle is presentation only: it edits the host's pinned cards through the same store write the
|
||||
* carousel's unpin uses, never the profile itself and never the host's default binding.
|
||||
*
|
||||
* Pin state is read live from [pinned] (backed by the host records), so what a switch shows is
|
||||
* always what the store holds — the row can't disagree with the carousel it feeds.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun GamepadPinHostsDialog(
|
||||
profileName: String,
|
||||
hosts: List<KnownHost>,
|
||||
pinned: (KnownHost) -> Boolean,
|
||||
onToggle: (KnownHost) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// 0..hosts.lastIndex = host rows, hosts.size = the Done button (with no hosts, index 0 IS
|
||||
// Done, so it starts focused).
|
||||
var focus by remember { mutableIntStateOf(0) }
|
||||
BackHandler(onBack = onDismiss)
|
||||
GamepadNavEffect2D(
|
||||
active = true,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
NavDir.DOWN -> if (focus < hosts.size) focus++
|
||||
// Directional = state-targeted (left → unpinned, right → pinned), so holding a
|
||||
// direction can't oscillate; asking for the state it's already in is a no-op.
|
||||
NavDir.LEFT -> hosts.getOrNull(focus)?.let { if (pinned(it)) onToggle(it) }
|
||||
NavDir.RIGHT -> hosts.getOrNull(focus)?.let { if (!pinned(it)) onToggle(it) }
|
||||
}
|
||||
},
|
||||
onActivate = {
|
||||
val kh = hosts.getOrNull(focus)
|
||||
if (kh != null) onToggle(kh) else onDismiss()
|
||||
},
|
||||
)
|
||||
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(24.dp)
|
||||
.widthIn(max = 520.dp)
|
||||
.heightIn(max = maxCardHeight)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF01A1730))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
|
||||
.padding(28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text(
|
||||
"Pin “$profileName”",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Column(
|
||||
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (hosts.isEmpty()) {
|
||||
DialogText("No saved hosts yet — pair with a host first, then pin this profile to it.")
|
||||
} else {
|
||||
DialogText("A pinned profile appears as its own card on the host — one press connects with it.")
|
||||
hosts.forEachIndexed { i, kh ->
|
||||
PinHostRow(
|
||||
label = kh.name,
|
||||
on = pinned(kh),
|
||||
focused = i == focus,
|
||||
onClick = { onToggle(kh) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(4.dp))
|
||||
DialogButton(
|
||||
"Done",
|
||||
focused = focus == hosts.size,
|
||||
primary = true,
|
||||
enabled = true,
|
||||
onClick = onDismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One host's pin toggle: name + a [ConsoleSwitch], with the shared console focus visuals. */
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () -> Unit) {
|
||||
val visuals = animateConsoleFocus(active = focused)
|
||||
// Inside the dialog's scroll region, like DialogButton: a focused row scrolled out of a short
|
||||
// landscape window pulls itself into view.
|
||||
val intoView = remember { BringIntoViewRequester() }
|
||||
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.bringIntoViewRequester(intoView)
|
||||
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
|
||||
.clip(shape)
|
||||
.background(visuals.background)
|
||||
.border(1.dp, visuals.border, shape)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
ConsoleSwitch(on = on, focused = focused)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Console counterpart of [SpeedTestDialog]. Same measurement, same targeting rule — a TV box on a
|
||||
* powerline adapter is exactly the machine whose link is worth measuring, so this belongs on the
|
||||
|
||||
@@ -57,6 +57,8 @@ import androidx.compose.ui.unit.sp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
|
||||
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
|
||||
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
|
||||
@@ -72,6 +74,8 @@ private class GpRow(
|
||||
val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed
|
||||
val activate: () -> Unit, // A → cycle forward (wrapping) / flip
|
||||
val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text)
|
||||
val adjustable: Boolean = true, // false = the row navigates/acts instead of stepping — no chevrons
|
||||
val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail)
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -89,7 +93,39 @@ fun GamepadSettingsScreen(
|
||||
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
|
||||
// Gates the AV1 codec row the same way the touch settings do (see `codecOptionsFor`).
|
||||
val av1Capable = remember { io.unom.punktfunk.kit.VideoDecoders.pickDecoder("video/av01") != null }
|
||||
val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update)
|
||||
|
||||
// The Profiles section's stores, constructed here the way ConnectScreen constructs its own.
|
||||
// The catalog is read once per screen entry: this screen can't create or edit profiles
|
||||
// (design §5.4 — the touch interface does), so the list is stable for its lifetime. The saved
|
||||
// hosts DO change under it — every pin toggle writes one — so they live in state and refresh
|
||||
// on each toggle, keeping the "Pinned to N hosts" counts honest.
|
||||
val knownHostStore = remember { KnownHostStore(context) }
|
||||
val profileStore = remember { ProfileStore(context) }
|
||||
val profiles = remember { profileStore.all() }
|
||||
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
|
||||
// The profile whose pin-to-hosts picker is up, or null. While it's showing, it owns the pad
|
||||
// (this screen's nav gates on it, the ConnectScreen-dialog pattern).
|
||||
var pinProfile by remember { mutableStateOf<StreamProfile?>(null) }
|
||||
|
||||
// Toggle a host+profile pin — the same store write ConnectScreen's togglePin does. Presentation
|
||||
// only: pin appends at the end (card order), unpin removes, and the host's default binding
|
||||
// (profileId) is never touched.
|
||||
fun togglePin(kh: KnownHost, profile: StreamProfile) {
|
||||
val pins = if (profile.id in kh.pinnedProfileIds) {
|
||||
kh.pinnedProfileIds - profile.id
|
||||
} else {
|
||||
kh.pinnedProfileIds + profile.id
|
||||
}
|
||||
knownHostStore.save(kh.copy(pinnedProfileIds = pins))
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
|
||||
// On a TV "the touch interface" is confusing advice (no touch to reach it with) — the honest
|
||||
// path there is this screen's own Controller-optimized UI toggle, which swaps in the standard
|
||||
// interface remote-navigably. The strings branch on it.
|
||||
val tv = remember { isTvDevice(context) }
|
||||
val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) +
|
||||
buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
|
||||
var focus by remember { mutableIntStateOf(0) }
|
||||
if (focus > rows.lastIndex) focus = rows.lastIndex
|
||||
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
|
||||
@@ -101,7 +137,9 @@ fun GamepadSettingsScreen(
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
GamepadNavEffect2D(
|
||||
active = navActive,
|
||||
// The pin picker owns the pad while it's up (its own nav + BackHandler), so this screen
|
||||
// drops its probes — the pattern ConnectScreen's dialogs use.
|
||||
active = navActive && pinProfile == null,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
@@ -162,16 +200,41 @@ fun GamepadSettingsScreen(
|
||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
// The legend follows the focused row (the desktop console's hints() does the same):
|
||||
// a profile row doesn't adjust, it opens the pin picker, and the "No profiles yet"
|
||||
// placeholder does nothing at all — advertising ↔/A on those would be a lie.
|
||||
val focused = rows.getOrNull(focus)
|
||||
GamepadHintBar(
|
||||
listOf(
|
||||
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
|
||||
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
|
||||
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
),
|
||||
when {
|
||||
focused != null && !focused.enabled -> listOf(
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
focused != null && !focused.adjustable -> listOf(
|
||||
PadGlyph.hint('A', "Pin to hosts") { focused.activate() },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
else -> listOf(
|
||||
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
|
||||
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
|
||||
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
},
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
|
||||
// The pin-to-hosts picker for the activated profile row — the console counterpart of the
|
||||
// touch UI's per-profile pin toggles in the host edit sheet.
|
||||
pinProfile?.let { p ->
|
||||
GamepadPinHostsDialog(
|
||||
profileName = p.name,
|
||||
hosts = savedHosts,
|
||||
pinned = { kh -> p.id in kh.pinnedProfileIds },
|
||||
onToggle = { kh -> togglePin(kh, p) },
|
||||
onDismiss = { pinProfile = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,8 +243,13 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
val visuals = animateConsoleFocus(active = focused)
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
// The chevrons keep their layout slot and only fade, so the value never jumps sideways when
|
||||
// focus arrives; the value colour cross-fades with them.
|
||||
val chevronAlpha by animateFloatAsState(if (focused) 0.6f else 0f, tween(160), label = "chevrons")
|
||||
// focus arrives; the value colour cross-fades with them. A non-adjustable row (a profile row
|
||||
// navigates, the empty-catalog placeholder does nothing) never shows them at all.
|
||||
val chevronAlpha by animateFloatAsState(
|
||||
if (focused && row.adjustable) 0.6f else 0f,
|
||||
tween(160),
|
||||
label = "chevrons",
|
||||
)
|
||||
val valueColor by animateColorAsState(
|
||||
Color.White.copy(alpha = if (focused) 1f else 0.6f),
|
||||
tween(160),
|
||||
@@ -216,7 +284,9 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
row.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
// A disabled row (the "No profiles yet" placeholder) dims but stays focusable,
|
||||
// so its detail line can still explain what would go here.
|
||||
color = Color.White.copy(alpha = if (row.enabled) 1f else 0.45f),
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
@@ -435,3 +505,62 @@ private fun buildSettingsRows(
|
||||
) { update(s.copy(sc2Capture = it)) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The trailing Profiles section — the Android mirror of the desktop console's (design §5.2a, §5.4):
|
||||
* one row per catalog profile, valued with how many saved hosts pin it, activating into the
|
||||
* pin-to-hosts picker. Read-only beyond pinning: profiles are created and edited in the standard
|
||||
* interface, so an empty catalog shows one dimmed placeholder explaining where they come from
|
||||
* instead of a dead-looking empty header. On a TV that phrasing changes: "touch interface" points
|
||||
* nowhere useful on a touchless device, so the strings name the actual route — the
|
||||
* Controller-optimized UI toggle a few rows up, which swaps the standard interface in
|
||||
* (d-pad-navigable; the profile editor lives there on every device, unlike tvOS where none exists).
|
||||
*/
|
||||
private fun buildProfileRows(
|
||||
profiles: List<StreamProfile>,
|
||||
savedHosts: List<KnownHost>,
|
||||
tv: Boolean,
|
||||
openPinPicker: (StreamProfile) -> Unit,
|
||||
): List<GpRow> {
|
||||
val createHint = if (tv) {
|
||||
"To create or edit profiles on this device, turn off Controller-optimized UI above " +
|
||||
"and use the standard interface."
|
||||
} else {
|
||||
"Profiles are created and edited in the touch interface."
|
||||
}
|
||||
if (profiles.isEmpty()) {
|
||||
return listOf(
|
||||
GpRow(
|
||||
id = "noProfiles",
|
||||
header = "Profiles",
|
||||
label = "No profiles yet",
|
||||
value = "",
|
||||
detail = "Profiles bundle stream settings for different uses — pinned ones become " +
|
||||
"one-press connect cards here. " + createHint,
|
||||
adjust = { false },
|
||||
activate = {},
|
||||
adjustable = false,
|
||||
enabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
return profiles.mapIndexed { i, p ->
|
||||
// Counted straight off the host records, so it agrees with what the carousel renders.
|
||||
val pins = savedHosts.count { p.id in it.pinnedProfileIds }
|
||||
GpRow(
|
||||
id = "profile:${p.id}",
|
||||
header = if (i == 0) "Profiles" else null,
|
||||
label = p.name,
|
||||
value = when (pins) {
|
||||
0 -> "Not pinned"
|
||||
1 -> "Pinned to 1 host"
|
||||
else -> "Pinned to $pins hosts"
|
||||
},
|
||||
detail = "Pin this profile to a host and it appears as its own card — one press " +
|
||||
"connects with it. " + createHint,
|
||||
adjust = { false },
|
||||
activate = { openPinPicker(p) },
|
||||
adjustable = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,14 @@
|
||||
//! realtime callback and makes us own the buffer. So this client diverges deliberately to stop the
|
||||
//! Android-only crackle: (1) the callback is allocation/free-free — decoded buffers are recycled to
|
||||
//! the producer via a free-list instead of being freed on the audio thread (Android's Scudo `free`
|
||||
//! has unbounded tail latency); (2) the jitter ring is deeper (~40 ms prime / ~150 ms hard cap) and
|
||||
//! decoupled from the tiny LowLatency burst size, with de-prime hysteresis so a transient drain
|
||||
//! doesn't manufacture a silence; (3) the AAudio HW buffer is primed above its 2-burst default and
|
||||
//! grown on XRuns (Google's anti-glitch technique).
|
||||
//! has unbounded tail latency); (2) the jitter ring is deeper than the other clients' and decoupled
|
||||
//! from the tiny LowLatency burst size, with de-prime hysteresis so a transient drain doesn't
|
||||
//! manufacture a silence; (3) the AAudio HW buffer is primed above its 2-burst default and grown on
|
||||
//! XRuns (Google's anti-glitch technique).
|
||||
//!
|
||||
//! (2) is now the SHARED `punktfunk_core::audio::JitterPolicy` at `JitterTuning::AAUDIO`, which also
|
||||
//! fixed what this ring was missing: it had a hard cap but nothing that walked the depth back down,
|
||||
//! so drift and arrival bursts raised latency permanently and Android settled on its ceiling.
|
||||
|
||||
use ndk::audio::{
|
||||
AudioCallbackResult, AudioContentType, AudioDirection, AudioFormat, AudioPerformanceMode,
|
||||
@@ -34,26 +38,18 @@ const SAMPLE_RATE: i32 = 48_000;
|
||||
/// Decoded-chunk hand-off depth: 64 × 5 ms = 320 ms slack (matches the core's AUDIO_QUEUE).
|
||||
const RING_CHUNKS: usize = 64;
|
||||
|
||||
// --- Jitter-ring depths, in MILLISECONDS (scaled to interleaved-f32 samples at runtime). --------
|
||||
// The channel count is negotiated, not a compile-time const, so these are kept in ms and multiplied
|
||||
// by `ms` (interleaved-f32 samples per millisecond at the resolved layout) inside `start`.
|
||||
// Unlike the Linux client (PipeWire adaptively rate-matches the stream to the graph clock, masking
|
||||
// host↔DAC drift + a shallow ring), AAudio hands us a raw callback and we own the buffer: drift and
|
||||
// WiFi power-save bunching land as underruns/overflows = crackle. So Android runs a deliberately
|
||||
// deeper, smoothly-managed ring than Linux — keep the two clients' depths intentionally divergent.
|
||||
/// Prime/target floor: fill to ~40 ms before playing (and after a sustained drain). Deep enough to
|
||||
/// ride out WiFi arrival jitter + clock drift; the dominant Android-only anti-crackle lever.
|
||||
const PRIME_FLOOR_MS: usize = 40;
|
||||
/// Ceiling for the burst-scaled target (so a large quantum can't push the prime depth too high).
|
||||
const PRIME_CEIL_MS: usize = 80;
|
||||
/// Drop-oldest headroom above the target before trimming — a ~80 ms band swallows an arrival burst
|
||||
/// without overflowing.
|
||||
const JITTER_HEADROOM_MS: usize = 80;
|
||||
/// Hard latency bound: never let the ring exceed ~150 ms (the only thing that caps added latency).
|
||||
const HARD_CAP_MS: usize = 150;
|
||||
/// Re-prime (go silent to refill) only after this many CONSECUTIVE empty callbacks, so one transient
|
||||
/// drain doesn't manufacture a fresh 40 ms silence (the old `if ring.is_empty()` re-primed instantly).
|
||||
const DEPRIME_AFTER_CALLBACKS: u32 = 5;
|
||||
// --- Jitter-ring depths now come from the SHARED policy (`punktfunk_core::audio::JitterTuning`). --
|
||||
// They used to be four Android-only constants here. The rationale for Android being DEEPER than the
|
||||
// other clients still holds and is preserved in `JitterTuning::AAUDIO`: unlike PipeWire, which
|
||||
// adaptively rate-matches the stream to the graph clock and masks host↔DAC drift, AAudio hands us a
|
||||
// raw callback and we own the buffer, so drift and Wi-Fi power-save bunching land as
|
||||
// underruns/overflows = crackle.
|
||||
//
|
||||
// Two things changed with the move. The prime floor drops 40 ms → 25 ms, because the policy GROWS
|
||||
// the target on the devices that actually underrun instead of every device pre-paying for the worst
|
||||
// one. And the ring finally sheds: it had a hard cap but nothing that walked the depth back down, so
|
||||
// any drift or burst raised latency permanently and Android converged on its 120 ms ceiling and
|
||||
// stayed there — the "audio latency is too high" report.
|
||||
/// Throttle the AAudio XRun-driven HW-buffer grow check (cheap, but no need to poll every quantum).
|
||||
const XRUN_CHECK_EVERY: u32 = 128;
|
||||
|
||||
@@ -104,6 +100,7 @@ struct Counters {
|
||||
pcm_written: AtomicU64, // PCM frames copied out to AAudio (device clock is pulling)
|
||||
underruns: AtomicU64, // callbacks that emitted silence (ring not primed / drained)
|
||||
ring_depth: AtomicU64, // ring sample count at the last callback
|
||||
target_ms: AtomicU64, // the policy's LIVE target depth (it grows on this device's underruns)
|
||||
}
|
||||
|
||||
/// Owned by [`crate::session::SessionHandle`]: the live AAudio stream + the decode thread.
|
||||
@@ -126,10 +123,9 @@ impl AudioPlayback {
|
||||
// Interleaved f32 samples per millisecond at this layout (48 kHz × channels); the ms-
|
||||
// denominated jitter-ring depths scale by it.
|
||||
let ms = (SAMPLE_RATE as usize / 1000) * channels;
|
||||
let prime_floor = PRIME_FLOOR_MS * ms;
|
||||
let prime_ceil = PRIME_CEIL_MS * ms;
|
||||
let jitter_headroom = JITTER_HEADROOM_MS * ms;
|
||||
let hard_cap_max = HARD_CAP_MS * ms;
|
||||
let tuning = punktfunk_core::audio::JitterTuning::AAUDIO;
|
||||
// Worst transient the ring can hold before the policy trims it.
|
||||
let hard_cap_max = tuning.hard_cap_ms as usize * ms;
|
||||
let counters = Arc::new(Counters::default());
|
||||
|
||||
// One open attempt at a given sharing mode. Everything the realtime callback captures
|
||||
@@ -157,8 +153,10 @@ impl AudioPlayback {
|
||||
// `decode_loop`.
|
||||
let mut ring: VecDeque<f32> =
|
||||
VecDeque::with_capacity(hard_cap_max + RING_CHUNKS * 5 * ms);
|
||||
let mut primed = false;
|
||||
let mut empties: u32 = 0; // consecutive empty callbacks (de-prime hysteresis)
|
||||
// Shared de-jitter policy — prime depth, drift correction, de-prime hysteresis. The
|
||||
// hysteresis this replaces was Android-only; Linux and Windows carried the instant
|
||||
// `if ring.is_empty()` re-prime until now.
|
||||
let mut policy = punktfunk_core::audio::JitterPolicy::new(tuning, channels as u8);
|
||||
let mut cb_count: u32 = 0; // callbacks since open (throttles the XRun grow check)
|
||||
let mut last_xrun: i32 = 0; // last AAudio XRun count we grew the buffer for
|
||||
let callback = move |s: &AudioStream, data: *mut c_void, num_frames: i32| {
|
||||
@@ -173,21 +171,25 @@ impl AudioPlayback {
|
||||
ring.extend(chunk.drain(..));
|
||||
let _ = free_tx.try_send(chunk);
|
||||
}
|
||||
// Jitter buffer: prime to ~40 ms (prime_floor) before playing and after a sustained
|
||||
// drain; drop-oldest only above a wide ~120 ms band. Decoupled from the AAudio burst
|
||||
// `want` (tiny on the LowLatency MMAP path) so the depth doesn't collapse to a single
|
||||
// quantum.
|
||||
let target = (3 * want).clamp(prime_floor, prime_ceil);
|
||||
let hard_cap = (target + jitter_headroom).min(hard_cap_max);
|
||||
while ring.len() > hard_cap {
|
||||
ring.pop_front();
|
||||
// Jitter buffer: the shared policy decides prime/silence, trims a burst, and —
|
||||
// new here — sheds ONE crossfaded 5 ms frame when the depth average has sat above
|
||||
// target long enough to be drift rather than jitter. Without that shed this ring
|
||||
// had no way back down: it clamped at 120 ms and stayed pinned there.
|
||||
let step = policy.step(ring.len(), want);
|
||||
if step.drop_front > 0 {
|
||||
punktfunk_core::audio::crossfade_drop(
|
||||
&mut ring,
|
||||
step.drop_front,
|
||||
step.crossfade,
|
||||
);
|
||||
}
|
||||
if !primed && ring.len() >= target {
|
||||
primed = true;
|
||||
}
|
||||
if primed {
|
||||
let mut ran_short = false;
|
||||
if !step.silence {
|
||||
for slot in out.iter_mut() {
|
||||
*slot = ring.pop_front().unwrap_or(0.0);
|
||||
*slot = ring.pop_front().unwrap_or_else(|| {
|
||||
ran_short = true;
|
||||
0.0
|
||||
});
|
||||
}
|
||||
cb_counters
|
||||
.pcm_written
|
||||
@@ -196,20 +198,15 @@ impl AudioPlayback {
|
||||
out.fill(0.0);
|
||||
cb_counters.underruns.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
// Re-prime only after a RUN of empty callbacks, not a single transient one —
|
||||
// otherwise every momentary drain costs a fresh 40 ms silence (the old behaviour,
|
||||
// self-inflicted crackle on any jitter spike).
|
||||
if ring.is_empty() {
|
||||
empties += 1;
|
||||
if empties >= DEPRIME_AFTER_CALLBACKS {
|
||||
primed = false;
|
||||
}
|
||||
} else {
|
||||
empties = 0;
|
||||
}
|
||||
// No-op while un-primed, so a deliberate priming silence is never counted as an
|
||||
// underrun (which would otherwise drive the adaptive floor up for no reason).
|
||||
policy.note_read(ran_short);
|
||||
cb_counters
|
||||
.ring_depth
|
||||
.store(ring.len() as u64, Ordering::Relaxed);
|
||||
cb_counters
|
||||
.target_ms
|
||||
.store(policy.target_ms() as u64, Ordering::Relaxed);
|
||||
// Google's AAudio anti-glitch technique: when the device reports new XRuns, grow the
|
||||
// HW buffer by one burst (up to capacity). getXRunCount + setBufferSizeInFrames are
|
||||
// both callback-safe / non-blocking, and set clamps to capacity so it self-limits.
|
||||
@@ -408,10 +405,11 @@ fn decode_loop(
|
||||
}
|
||||
if count % 600 == 0 {
|
||||
log::info!(
|
||||
"audio: opus={count} pcm_frames={} underruns={} ring={} peak={window_peak:.3}",
|
||||
"audio: opus={count} pcm_frames={} underruns={} buffer_ms={} target_ms={} peak={window_peak:.3}",
|
||||
counters.pcm_written.load(Ordering::Relaxed),
|
||||
counters.underruns.load(Ordering::Relaxed),
|
||||
counters.ring_depth.load(Ordering::Relaxed),
|
||||
counters.ring_depth.load(Ordering::Relaxed) / ms.max(1) as u64,
|
||||
counters.target_ms.load(Ordering::Relaxed),
|
||||
);
|
||||
window_peak = 0.0;
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ struct GamepadHomeView: View {
|
||||
// fullScreenCover, so they become generously sized sheets over the dimmed launcher.
|
||||
#if os(macOS)
|
||||
.sheet(isPresented: $showSettings) {
|
||||
GamepadSettingsView()
|
||||
GamepadSettingsView(store: store)
|
||||
.frame(width: 720, height: 640)
|
||||
}
|
||||
.sheet(isPresented: $showAddHost) {
|
||||
@@ -144,7 +144,7 @@ struct GamepadHomeView: View {
|
||||
}
|
||||
.frame(minWidth: 640, minHeight: 420)
|
||||
#else
|
||||
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView() }
|
||||
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView(store: store) }
|
||||
.fullScreenCover(isPresented: $showAddHost) {
|
||||
GamepadAddHostView { store.add($0) }
|
||||
}
|
||||
|
||||
@@ -146,7 +146,9 @@ private struct ShotGamepadHome: View {
|
||||
}
|
||||
|
||||
private struct ShotGamepadSettings: View {
|
||||
var body: some View { GamepadSettingsView() }
|
||||
@StateObject private var store = ShotMock.hostStore()
|
||||
|
||||
var body: some View { GamepadSettingsView(store: store) }
|
||||
}
|
||||
|
||||
private struct ShotGamepadAddHost: View {
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
// on stale captured state. Left/right CLAMPS at a choice list's ends (the dull boundary thud tells
|
||||
// the thumb it's the last option); A always cycles forward, wrapping, so every option is reachable
|
||||
// with one button. Toggles read left = off, right = on — refusing a no-op with the same thud.
|
||||
//
|
||||
// The trailing Profiles section (design/client-settings-profiles.md §5.2a/§5.4) is the pin manager
|
||||
// for this controller-first surface: a row per catalog profile opens the pin-to-hosts picker — an
|
||||
// in-place swap of the row list (B peels back, the "one layer" rule GamepadAddHostView set) with
|
||||
// one toggle row per saved host, writing `StoredHost.pinnedProfileIDs` via HostStore.setPinned.
|
||||
// Pins are presentation only: never the host's default binding, never the profile itself —
|
||||
// profiles are created and edited in the standard interface (and can't be on tvOS, whose
|
||||
// per-device catalog the detail strings are honest about).
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
@@ -21,6 +29,10 @@ import CoreHaptics
|
||||
|
||||
struct GamepadSettingsView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
/// The saved-host store — the pin picker writes `setPinned` through it and the profile rows
|
||||
/// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen
|
||||
/// itself (ContentView owns the instance).
|
||||
@ObservedObject var store: HostStore
|
||||
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
||||
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
||||
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
||||
@@ -52,6 +64,10 @@ struct GamepadSettingsView: View {
|
||||
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
||||
#endif
|
||||
@ObservedObject private var gamepads = GamepadManager.shared
|
||||
/// The profile catalog (ProfileStore.shared, like every other surface that reads it) — the
|
||||
/// Profiles rows re-derive from it each render, so a rename/delete made in the standard
|
||||
/// interface shows up live.
|
||||
@ObservedObject private var profiles = ProfileStore.shared
|
||||
|
||||
#if os(iOS)
|
||||
/// `.compact` in a landscape phone window — tighter chrome so more rows fit.
|
||||
@@ -62,6 +78,9 @@ struct GamepadSettingsView: View {
|
||||
private let compact = false // no size classes on macOS; the sheet is sized generously
|
||||
#endif
|
||||
@State private var focusID: String?
|
||||
/// The pin-to-hosts picker's profile — non-nil swaps the row list for one toggle row per
|
||||
/// saved host (§5.2a); B (Menu on tvOS) peels back to the settings rows.
|
||||
@State private var pinTarget: StreamProfile?
|
||||
/// The direction of the last value step (+1 right/forward, -1 left) — picks which edge the
|
||||
/// changed value slides in from, so the animation follows the user's motion.
|
||||
@State private var lastAdjustDelta = 1
|
||||
@@ -72,7 +91,7 @@ struct GamepadSettingsView: View {
|
||||
focusID: $focusID,
|
||||
onAdjust: { row, delta in adjust(id: row.id, by: delta) },
|
||||
onActivate: { activate(id: $0.id) },
|
||||
onBack: { dismiss() }
|
||||
onBack: { back() }
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
||||
@@ -80,7 +99,7 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
Text("Settings")
|
||||
Text(title)
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
@@ -96,11 +115,7 @@ struct GamepadSettingsView: View {
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
.lineLimit(2, reservesSpace: true)
|
||||
.animation(.smooth(duration: 0.2), value: focusID)
|
||||
GamepadHintBar(hints: [
|
||||
.init(glyph: "arrow.left.and.right", text: "Adjust"),
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
||||
])
|
||||
GamepadHintBar(hints: hints)
|
||||
}
|
||||
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
|
||||
.padding(.leading, compact ? 12 : 18)
|
||||
@@ -138,6 +153,43 @@ struct GamepadSettingsView: View {
|
||||
.accessibilityLabel("Close settings")
|
||||
}
|
||||
|
||||
/// "Settings", or "Pin “Work”" while the pin picker is up — the title is what says which
|
||||
/// layer the row list currently is.
|
||||
private var title: String {
|
||||
pinTarget.map { "Pin “\($0.name)”" } ?? "Settings"
|
||||
}
|
||||
|
||||
/// The legend follows the layer: value-editing hints on the settings rows, pin/unpin on the
|
||||
/// picker — where B reads "Back" (it peels to the settings rows, GamepadAddHostView's "one
|
||||
/// layer" rule), and a hostless picker has nothing to pin, so only Back remains.
|
||||
private var hints: [GamepadHint] {
|
||||
guard pinTarget != nil else {
|
||||
return [
|
||||
.init(glyph: "arrow.left.and.right", text: "Adjust"),
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
||||
]
|
||||
}
|
||||
guard !store.hosts.isEmpty else {
|
||||
return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back")]
|
||||
}
|
||||
return [
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Pin / Unpin"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back"),
|
||||
]
|
||||
}
|
||||
|
||||
/// B peels one layer: the pin picker back to the settings rows — focus returning to the
|
||||
/// profile row it came from — then the screen itself.
|
||||
private func back() {
|
||||
if let profile = pinTarget {
|
||||
pinTarget = nil
|
||||
focusID = "profile-\(profile.id)"
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Row rendering
|
||||
|
||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||
@@ -164,7 +216,7 @@ struct GamepadSettingsView: View {
|
||||
HStack(spacing: 9) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: m.chevronFont, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
|
||||
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
|
||||
// Keyed by the value so a change slides the new option in instead of
|
||||
// hard-swapping the string — a QUIET horizontal slip following the user's
|
||||
// motion (a right-step enters from the right), crossfading over ~14 pt.
|
||||
@@ -185,7 +237,7 @@ struct GamepadSettingsView: View {
|
||||
.animation(.smooth(duration: 0.22), value: row.value)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: m.chevronFont, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
|
||||
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, m.rowHPad)
|
||||
@@ -219,6 +271,9 @@ struct GamepadSettingsView: View {
|
||||
let value: String
|
||||
/// One-line explanation shown near the hint bar while this row is focused.
|
||||
let detail: String
|
||||
/// Whether left/right means anything here — false hides the value's chevrons (the
|
||||
/// Profiles rows navigate, and the placeholder rows do nothing at all).
|
||||
var adjustable = true
|
||||
/// Left/right step; returns whether the value actually changed (false ⇒ boundary thud).
|
||||
let adjust: (Int) -> Bool
|
||||
/// A — cycle forward (wrapping) / flip.
|
||||
@@ -238,6 +293,9 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
|
||||
private var rows: [Row] {
|
||||
// The pin picker replaces the whole list while it's up — same screen, one layer deeper,
|
||||
// so the focus list's controller wiring (and the tvOS focus engine) carries over as is.
|
||||
if let profile = pinTarget { return pinRows(for: profile) }
|
||||
let resolution = resolutionOptions
|
||||
let refresh = SettingsOptions.refreshRates(including: hz)
|
||||
.map { (label: "\($0) Hz", tag: $0) }
|
||||
@@ -394,7 +452,98 @@ struct GamepadSettingsView: View {
|
||||
at: at + 1)
|
||||
}
|
||||
#endif
|
||||
return list
|
||||
return list + profileRows
|
||||
}
|
||||
|
||||
// MARK: - Profiles (§5.2a)
|
||||
|
||||
/// The trailing Profiles section: one row per catalog profile, its value how many saved
|
||||
/// hosts pin it, A opening the pin-to-hosts picker. Read-only beyond that — this surface
|
||||
/// pins and unpins, but profiles are created and edited elsewhere (design §5.4), so
|
||||
/// left/right is a boundary thud, not an editor.
|
||||
private var profileRows: [Row] {
|
||||
guard !profiles.profiles.isEmpty else {
|
||||
return [Row(
|
||||
id: "noProfiles", header: "Profiles", icon: "slider.horizontal.3",
|
||||
label: "No profiles yet", value: "",
|
||||
detail: emptyCatalogDetail,
|
||||
adjustable: false,
|
||||
adjust: { _ in false }, activate: {})]
|
||||
}
|
||||
return profiles.profiles.enumerated().map { i, profile in
|
||||
let pins = store.hosts
|
||||
.filter { ($0.pinnedProfileIDs ?? []).contains(profile.id) }.count
|
||||
return Row(
|
||||
id: "profile-\(profile.id)", header: i == 0 ? "Profiles" : nil,
|
||||
icon: "slider.horizontal.3", label: profile.name,
|
||||
value: pins == 0 ? "Not pinned" : "Pinned to \(pins) host\(pins == 1 ? "" : "s")",
|
||||
detail: profileDetail,
|
||||
adjustable: false,
|
||||
adjust: { _ in false },
|
||||
activate: {
|
||||
// Focus lands on the picker's first row — the focus list's reconcile
|
||||
// follows this id when the row set swaps underneath it.
|
||||
focusID = store.hosts.first.map { "pinHost-\($0.id.uuidString)" } ?? "noHosts"
|
||||
pinTarget = profile
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The pin-to-hosts picker: one toggle row per SAVED host, sharing the settings rows'
|
||||
/// toggle semantics (left = unpin, right = pin, A flips; asking for the state it's in is a
|
||||
/// boundary thud). Writes ride `HostStore.setPinned` — pin appends, unpin removes — and
|
||||
/// NEVER the host's default binding (`profileID`): a pin is presentation only (§5.2a).
|
||||
private func pinRows(for profile: StreamProfile) -> [Row] {
|
||||
guard !store.hosts.isEmpty else {
|
||||
return [Row(
|
||||
id: "noHosts", icon: "desktopcomputer", label: "No saved hosts yet",
|
||||
value: "",
|
||||
detail: "Pair with a host first, then pin this profile to it.",
|
||||
adjustable: false,
|
||||
adjust: { _ in false }, activate: {})]
|
||||
}
|
||||
return store.hosts.map { host in
|
||||
let hostID = host.id
|
||||
let pinned = (host.pinnedProfileIDs ?? []).contains(profile.id)
|
||||
return Row(
|
||||
id: "pinHost-\(hostID.uuidString)", icon: "desktopcomputer",
|
||||
label: host.displayName,
|
||||
value: pinned ? "Pinned" : "Off",
|
||||
detail: "A pinned profile appears as its own card on the host — one press "
|
||||
+ "connects with it.",
|
||||
adjust: { delta in
|
||||
let target = delta > 0
|
||||
guard pinned != target else { return false }
|
||||
store.setPinned(hostID, profileID: profile.id, pinned: target)
|
||||
return true
|
||||
},
|
||||
activate: { store.setPinned(hostID, profileID: profile.id, pinned: !pinned) })
|
||||
}
|
||||
}
|
||||
|
||||
/// The profile rows' explainer. tvOS gets its own: the catalog is per-device (the App Group
|
||||
/// suite — nothing syncs it) and tvOS has no profile editor at all (§5.4), so pointing a TV
|
||||
/// user at a "standard interface" would promise profiles that can never arrive there.
|
||||
private var profileDetail: String {
|
||||
#if os(tvOS)
|
||||
return "Pin this profile to a host and it appears as its own card on the home screen — "
|
||||
+ "one press connects with it."
|
||||
#else
|
||||
return "Pin this profile to a host and it appears as its own card — one press connects "
|
||||
+ "with it. Profiles are created and edited in Punktfunk's standard interface."
|
||||
#endif
|
||||
}
|
||||
|
||||
/// What the empty catalog's placeholder explains — again honest on tvOS, where profiles
|
||||
/// cannot be created (on the device or anywhere that would reach its per-device catalog).
|
||||
private var emptyCatalogDetail: String {
|
||||
#if os(tvOS)
|
||||
return "Profiles bundle stream settings for different uses. Creating them isn't "
|
||||
+ "available on Apple TV yet."
|
||||
#else
|
||||
return "Profiles bundle stream settings for different uses. Create them in Punktfunk's "
|
||||
+ "standard interface, then pin them here as one-press connect cards."
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Resolution choices as "WxH" tags — the current size is inserted when it's a custom mode
|
||||
|
||||
@@ -3,28 +3,66 @@ import os
|
||||
|
||||
/// SPSC-ish jitter ring (interleaved float, `channels` per frame), drain thread → render
|
||||
/// callback. The unfair lock is held for microseconds; fine at render-callback rates. Priming:
|
||||
/// reads return silence until enough is buffered (at least `prefill`, and at least one
|
||||
/// reads return silence until enough is buffered (at least the target, and at least one
|
||||
/// packet more than the device's render quantum — large-buffer devices would otherwise
|
||||
/// chronically out-demand the prefill and oscillate prime → dropout → re-prime), and an
|
||||
/// underrun re-primes, concealing jitter as one short dip instead of sustained crackle.
|
||||
/// chronically out-demand the prefill and oscillate prime → dropout → re-prime).
|
||||
/// All counts stay whole frames (multiples of `channels`), so the interleave can never slip.
|
||||
///
|
||||
/// **Drift correction.** Both ends run at 48 kHz but on different crystals, so backlog from a
|
||||
/// network stall or plain host-vs-DAC skew never drains on its own: without correction one 300 ms
|
||||
/// hiccup leaves audio 300 ms behind video for the rest of the session. This used to be handled by
|
||||
/// a `highWater` shed that dropped a whole `2 × prefill` at once — its own comment called that "one
|
||||
/// audible blip". It is now the same two-stage scheme the Rust clients share
|
||||
/// (`punktfunk_core::audio::JitterPolicy`): a slow depth average that sits above target for a
|
||||
/// sustained window sheds ONE 5 ms frame with a crossfade, and the hard cap is only a backstop.
|
||||
/// Keep the constants here in step with `JitterTuning.COREAUDIO`.
|
||||
final class AudioRing: @unchecked Sendable {
|
||||
/// Mirrors `JitterTuning::COREAUDIO` — see that type for the rationale.
|
||||
private static let targetMS = 20
|
||||
private static let headroomMS = 30
|
||||
private static let hardCapMS = 90
|
||||
private static let deprimeAfter = 4
|
||||
/// The protocol's frame: the shed unit, and the slack added over a large device quantum.
|
||||
private static let frameMS = 5
|
||||
/// Depth average must exceed target by this before drift correction fires — the middle of the
|
||||
/// headroom band, so the smooth shed always gets its chance BEFORE the hard cap trims.
|
||||
private static let shedExcessMS = 15
|
||||
/// …and must stay there for this much consumed audio. Long, because a shed is the only thing
|
||||
/// here a listener could notice; it must never fire on a transient.
|
||||
private static let shedSustainMS = 2_000
|
||||
private static let crossfadeMS = 2
|
||||
/// Time constant of the depth average.
|
||||
private static let ewmaTauMS = 1_000
|
||||
|
||||
private var buf: [Float]
|
||||
private var readIdx = 0
|
||||
private var writeIdx = 0
|
||||
private var primed = false
|
||||
private var renderQuantum = 0
|
||||
private let prefill: Int
|
||||
private let highWater: Int
|
||||
private var emptyReads = 0
|
||||
private var depthAvg: Double = 0
|
||||
private var overRun = 0
|
||||
/// Reported, not acted on: short reads that actually starved the callback, and smooth drift
|
||||
/// corrections. A rising underrun count means the ring is being starved (network or CPU),
|
||||
/// which is a different problem from the depth being wrong.
|
||||
private var underrunCount = 0
|
||||
private var shedCount = 0
|
||||
private let channels: Int
|
||||
private let perMS: Int
|
||||
private let lock = OSAllocatedUnfairLock()
|
||||
|
||||
/// `capacity`/`prefill` in samples (interleaved — `channels` per frame, both whole frames).
|
||||
init(capacity: Int, prefill: Int, channels: Int) {
|
||||
/// `capacity` in samples (interleaved — `channels` per frame, a whole number of frames).
|
||||
/// The de-jitter depth is the ring's own business (`targetMS`), not a caller's prefill.
|
||||
init(capacity: Int, channels: Int) {
|
||||
buf = [Float](repeating: 0, count: capacity)
|
||||
self.prefill = prefill
|
||||
self.channels = channels
|
||||
highWater = prefill * 4
|
||||
perMS = 48 * channels
|
||||
}
|
||||
|
||||
/// Live target depth in interleaved samples, lifted so it can always serve one device quantum
|
||||
/// plus a packet (a large-buffer device cannot sustain a target below its own quantum).
|
||||
private var target: Int {
|
||||
max(Self.targetMS * perMS, renderQuantum + Self.frameMS * perMS)
|
||||
}
|
||||
|
||||
func write(_ samples: UnsafePointer<Float>, count: Int) {
|
||||
@@ -42,12 +80,12 @@ final class AudioRing: @unchecked Sendable {
|
||||
buf[(writeIdx + i) % capacity] = samples[i]
|
||||
}
|
||||
writeIdx += count
|
||||
// Latency clamp: both ends run at 48 kHz, so backlog from a network stall (or
|
||||
// creeping host-vs-DAC clock skew) never drains on its own — without this, one
|
||||
// 300 ms hiccup leaves audio 300 ms behind video for the rest of the session.
|
||||
// Shedding down to 2× prefill costs one audible blip instead.
|
||||
if writeIdx - readIdx > highWater {
|
||||
readIdx = writeIdx - prefill * 2
|
||||
// Backstop only: the smooth shed in `read` is what normally holds the depth down.
|
||||
let cap = min(target + Self.headroomMS * perMS, Self.hardCapMS * perMS)
|
||||
if writeIdx - readIdx > cap {
|
||||
readIdx = writeIdx - cap
|
||||
depthAvg = Double(cap)
|
||||
overRun = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,16 +95,37 @@ final class AudioRing: @unchecked Sendable {
|
||||
defer { lock.unlock() }
|
||||
renderQuantum = max(renderQuantum, count)
|
||||
let available = writeIdx - readIdx
|
||||
|
||||
// Depth average, weighted by the callback size so its time constant is independent of the
|
||||
// device quantum.
|
||||
let alpha = min(1.0, Double(count) / Double(Self.ewmaTauMS * perMS))
|
||||
depthAvg += (Double(available) - depthAvg) * alpha
|
||||
|
||||
if !primed {
|
||||
// One 5 ms host packet (240 frames × channels) of slack beyond the device's demand.
|
||||
if available >= max(prefill, renderQuantum + 240 * channels) {
|
||||
if available >= target {
|
||||
primed = true
|
||||
emptyReads = 0
|
||||
} else {
|
||||
for i in 0..<count { out[i] = 0 }
|
||||
return
|
||||
}
|
||||
}
|
||||
let n = min(available, count)
|
||||
|
||||
// Drift correction: shed exactly one frame, crossfaded, once the AVERAGE has sat above
|
||||
// the threshold for the sustain window. Anything shorter is jitter and must be left alone.
|
||||
if depthAvg > Double(target + Self.shedExcessMS * perMS) {
|
||||
overRun += count
|
||||
if overRun >= Self.shedSustainMS * perMS {
|
||||
overRun = 0
|
||||
shedOneFrame()
|
||||
shedCount += 1
|
||||
depthAvg = Double(writeIdx - readIdx)
|
||||
}
|
||||
} else {
|
||||
overRun = 0
|
||||
}
|
||||
|
||||
let n = min(writeIdx - readIdx, count)
|
||||
let capacity = buf.count
|
||||
for i in 0..<n {
|
||||
out[i] = buf[(readIdx + i) % capacity]
|
||||
@@ -74,9 +133,63 @@ final class AudioRing: @unchecked Sendable {
|
||||
readIdx += n
|
||||
if n < count {
|
||||
for i in n..<count { out[i] = 0 }
|
||||
primed = false // underrun — re-prime before resuming
|
||||
// De-prime only after a RUN of short reads: a single transient drain must not
|
||||
// manufacture a whole target's worth of fresh silence.
|
||||
emptyReads += 1
|
||||
underrunCount += 1
|
||||
if emptyReads >= Self.deprimeAfter { primed = false }
|
||||
} else {
|
||||
emptyReads = 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop one protocol frame from the front, linearly crossfading the seam so the correction is
|
||||
/// inaudible rather than a click. Mirrors `punktfunk_core::audio::crossfade_drop`; caller holds
|
||||
/// the lock.
|
||||
private func shedOneFrame() {
|
||||
let drop = Self.frameMS * perMS
|
||||
let available = writeIdx - readIdx
|
||||
guard available > drop else { return }
|
||||
let fade = min(Self.crossfadeMS * perMS, min(drop, available - drop))
|
||||
let capacity = buf.count
|
||||
if fade > 0 {
|
||||
// The tail of what we discard fades out into the head of what survives.
|
||||
for i in 0..<fade {
|
||||
let old = buf[(readIdx + drop - fade + i) % capacity]
|
||||
let new = buf[(readIdx + drop + i) % capacity]
|
||||
let t = Float(i + 1) / Float(fade + 1)
|
||||
buf[(readIdx + drop + i) % capacity] = old * (1 - t) + new * t
|
||||
}
|
||||
}
|
||||
readIdx += drop
|
||||
}
|
||||
|
||||
/// Current buffered depth in milliseconds — for the stats overlay and the drain thread's
|
||||
/// periodic log.
|
||||
var bufferedMS: Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return (writeIdx - readIdx) / max(perMS, 1)
|
||||
}
|
||||
|
||||
/// One consistent snapshot of the ring's vitals, taken under a single lock so the numbers in
|
||||
/// a log line describe the same instant. Mirrors what the three Rust clients report.
|
||||
struct Stats {
|
||||
let bufferedMS: Int
|
||||
let targetMS: Int
|
||||
let underruns: Int
|
||||
let sheds: Int
|
||||
}
|
||||
|
||||
var stats: Stats {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return Stats(
|
||||
bufferedMS: (writeIdx - readIdx) / max(perMS, 1),
|
||||
targetMS: target / max(perMS, 1),
|
||||
underruns: underrunCount,
|
||||
sheds: shedCount)
|
||||
}
|
||||
}
|
||||
|
||||
/// CoreAudio channel layout for the canonical wire order FL FR FC LFE RL RR [SL SR]. nil for
|
||||
|
||||
@@ -317,10 +317,10 @@ public final class SessionAudio {
|
||||
// Build the playback layout from the host-RESOLVED channel count (never the request):
|
||||
// 2 = stereo / 6 = 5.1 / 8 = 7.1, canonical wire order FL FR FC LFE RL RR SL SR.
|
||||
let channels = Int(connection.resolvedAudioChannels)
|
||||
// 1 s interleaved capacity, ~20 ms prefill (four 5 ms host packets of jitter absorption
|
||||
// before the first sample plays), both scaled by the channel count.
|
||||
let ring = self.ring ?? AudioRing(
|
||||
capacity: 48_000 * channels, prefill: 960 * channels, channels: channels)
|
||||
// 1 s interleaved capacity, scaled by the channel count. The de-jitter depth itself is
|
||||
// the ring's own business now (`AudioRing.targetMS`, mirroring `JitterTuning::COREAUDIO`)
|
||||
// rather than a prefill passed in here.
|
||||
let ring = self.ring ?? AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
self.ring = ring
|
||||
|
||||
// Engine-native deinterleaved float; the render block deinterleaves from the ring. Surround
|
||||
@@ -403,6 +403,7 @@ public final class SessionAudio {
|
||||
stateLock.unlock()
|
||||
let thread = Thread { [connection, flag, drainDone] in
|
||||
defer { drainDone.signal() }
|
||||
var drained = 0
|
||||
// Decode happens IN-CORE (libopus multistream) — AudioToolbox's Opus path is
|
||||
// stereo-only — and is handed back as interleaved f32 PCM in wire channel order.
|
||||
// Per-iteration autorelease pool: no runloop on this thread (see Stage2Pipeline).
|
||||
@@ -421,6 +422,17 @@ public final class SessionAudio {
|
||||
ring.write(base, count: pcm.frameCount * pcm.channels)
|
||||
}
|
||||
}
|
||||
// Periodic vitals (~10 s at the protocol's 5 ms frames). The other three clients
|
||||
// log buffer depth and underruns; without this an Apple audio report — latency or
|
||||
// dropout — arrives with no numbers at all, which is the position every platform
|
||||
// was in before the 2026-08 audio work.
|
||||
drained += 1
|
||||
if drained % 2_000 == 0 {
|
||||
let s = ring.stats
|
||||
log.info(
|
||||
"audio: buffer_ms=\(s.bufferedMS) target_ms=\(s.targetMS) underruns=\(s.underruns) drift_sheds=\(s.sheds)"
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +186,16 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
// pointer back to iPadOS, so an unwanted drop is re-requested below. The DELIBERATE releases
|
||||
// (⌘⎋, ⌃⌥⇧Q, the Stream menu, backgrounding) all clear `captured` first, so `wantsPointerLock`
|
||||
// is already false when their drop is observed and none of them are fought here.
|
||||
//
|
||||
// Recovery is TWO-STAGE, because either stage alone leaves a hole:
|
||||
// 1. the burst below, fired the instant the drop is observed — wins back a lock the system
|
||||
// is willing to return immediately (a transient drop that wasn't Escape at all);
|
||||
// 2. a CLICK into the video while still captured (`onPointerButton`) — the fallback for the
|
||||
// Escape case proper, where the platform declines during the moment right after its own
|
||||
// release gesture and the burst therefore expires having achieved nothing.
|
||||
// Stage 2 is what keeps a lost burst from being permanent: `captured` is still true, so no
|
||||
// other path would ever ask again, and the capture would spend the rest of its life on the
|
||||
// absolute pointer — clicking correctly, aiming not at all.
|
||||
/// Whether this capture ever actually held the lock. Only a lock we HELD is worth winning back
|
||||
/// — never having been granted one means the scene doesn't qualify, not that Esc took it.
|
||||
/// Cleared when capture ends, so each capture starts from a clean slate.
|
||||
@@ -446,6 +456,31 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
}
|
||||
guard self.inputCapture?.gcMouseForwarding == false else { return }
|
||||
self.inputCapture?.sendMouseButton(button, pressed: down)
|
||||
// …and if we're captured but NOT locked, this click is also the recovery gesture for an
|
||||
// Escape-drop the burst lost. iPadOS refuses to re-lock in the moment right after its
|
||||
// own "let me out" gesture, so the burst fired at the drop can spend its whole budget
|
||||
// and give up while the capture is still wanted. Nothing else would ever re-ask —
|
||||
// setCaptured is the only other requester and a bare Esc never clears `captured` — so
|
||||
// without this the session stays on the absolute path for the rest of the capture:
|
||||
// clicks still land where you aim (absolute positions keep forwarding) but the game
|
||||
// gets no relative deltas, so camera look is dead. A click is a real user gesture,
|
||||
// which is exactly what the platform wants before it will hand the lock back.
|
||||
//
|
||||
// On the button UP, so the click has fully forwarded on ONE transport first: asking on
|
||||
// the DOWN can flip `gcMouseForwarding` mid-click and strand the release on the GCMouse
|
||||
// path. Gated on `pointerLockWasEngaged` exactly as the drop path is, so a scene that
|
||||
// never qualifies (Stage Manager, Split View) is never bursted at, and on a burst not
|
||||
// already being in flight — a pending burst mutes absolute motion, so re-arming one on
|
||||
// every click of a menu the user is still aiming around would freeze the cursor between
|
||||
// clicks. Only once it has settled does a further click buy a fresh budget (clearing the
|
||||
// attempt counter, so a gesture isn't refused inside the 2 s window the drop's own burst
|
||||
// may have just spent).
|
||||
if !down, self.wantsPointerLock, self.pointerLockWasEngaged,
|
||||
!self.pointerRelockPending, self.pointerLockEngaged() != true {
|
||||
self.pointerRelockAttempt = 0
|
||||
self.updatePointerLockChain() // a reparent since the drop would break the walk to us
|
||||
self.requestPointerRelock()
|
||||
}
|
||||
}
|
||||
// Scroll is the ONE indirect channel that is NOT gated on the lock. The scroll pan keeps
|
||||
// firing while the scene is pointer-locked (it is the only way trackpad two-finger scrolling
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// The Apple half of the shared de-jitter policy (`punktfunk_core::audio::JitterPolicy`, whose
|
||||
// constants `AudioRing` mirrors). These pin the two behaviours a listener actually notices, in the
|
||||
// one client where the policy is hand-written in a second language rather than shared as code — so
|
||||
// a divergence from the Rust side shows up here rather than as a field report.
|
||||
//
|
||||
// The defect being pinned: the ring primed *up* to a target and clamped at a ceiling, with nothing
|
||||
// walking the depth back *down*. Host-vs-DAC clock skew of a few dozen ppm therefore added latency
|
||||
// permanently, and the only correction was a `highWater` shed that dropped `2 x prefill` at once —
|
||||
// its own comment called that "one audible blip".
|
||||
|
||||
#if !os(tvOS)
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class AudioRingDriftTests: XCTestCase {
|
||||
private let channels = 2
|
||||
private var perMS: Int { 48 * channels }
|
||||
|
||||
/// Run `ms` of audio through the ring at a `quantumMS` device where the producer delivers
|
||||
/// `driftPPM` more than the consumer takes. Returns `(final ms, peak ms, silent callbacks)`.
|
||||
private func simulate(ms: Int, quantumMS: Int, driftPPM: Int) -> (Int, Int, Int) {
|
||||
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
let want = quantumMS * perMS
|
||||
var scratch = [Float](repeating: 0, count: want)
|
||||
// Non-zero so a silent callback is distinguishable from real audio.
|
||||
let producer = [Float](repeating: 0.25, count: want + 8)
|
||||
var carry = 0, peak = 0, final = 0, silent = 0
|
||||
|
||||
for i in 0..<(ms / quantumMS) {
|
||||
carry += want * driftPPM
|
||||
let extra = carry / 1_000_000
|
||||
carry -= extra * 1_000_000
|
||||
producer.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: want + extra) }
|
||||
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
// Skip the priming window at the very start.
|
||||
if i > 20, scratch.allSatisfy({ $0 == 0 }) { silent += 1 }
|
||||
peak = max(peak, ring.bufferedMS)
|
||||
final = ring.bufferedMS
|
||||
}
|
||||
return (final, peak, silent)
|
||||
}
|
||||
|
||||
/// THE regression: with the host clock running fast, buffered latency must return to target
|
||||
/// instead of climbing to the hard cap and staying pinned there. +200 ppm is deliberately
|
||||
/// harsher than real hardware (tens of ppm).
|
||||
func testDriftDoesNotRatchetLatencyToTheCeiling() {
|
||||
let (final, peak, silent) = simulate(ms: 5 * 60 * 1_000, quantumMS: 5, driftPPM: 200)
|
||||
// Must settle inside the headroom band (target 20 + headroom 30), never near the 90 ms cap.
|
||||
XCTAssertLessThanOrEqual(final, 50, "settled at \(final) ms — that is the ratchet")
|
||||
XCTAssertLessThanOrEqual(peak, 50, "peaked at \(peak) ms")
|
||||
XCTAssertEqual(silent, 0, "drift correction must never starve the callback")
|
||||
}
|
||||
|
||||
/// The mirror case: a host clock running SLOW must keep audio flowing rather than being
|
||||
/// "corrected" into a stutter.
|
||||
func testNegativeDriftKeepsPlaying() {
|
||||
let (_, _, silent) = simulate(ms: 2 * 60 * 1_000, quantumMS: 5, driftPPM: -200)
|
||||
XCTAssertEqual(silent, 0, "a draining ring must re-prime, not chatter")
|
||||
}
|
||||
|
||||
/// A device that pulls a large quantum cannot sustain a target below it — the ring must lift
|
||||
/// its target rather than oscillating prime → dropout → re-prime forever.
|
||||
func testLargeDeviceQuantumStillPlays() {
|
||||
let (_, _, silent) = simulate(ms: 60 * 1_000, quantumMS: 40, driftPPM: 0)
|
||||
XCTAssertEqual(silent, 0, "a 40 ms quantum must not starve a 20 ms target")
|
||||
}
|
||||
|
||||
/// One transient drain must not manufacture a whole target's worth of fresh silence: the ring
|
||||
/// de-primes only after a RUN of short reads.
|
||||
func testSingleShortReadDoesNotDeprime() {
|
||||
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
let want = 5 * perMS
|
||||
var scratch = [Float](repeating: 0, count: want)
|
||||
// Prime well past target.
|
||||
let big = [Float](repeating: 0.5, count: 60 * perMS)
|
||||
big.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: big.count) }
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
XCTAssertTrue(scratch.contains { $0 != 0 }, "should be playing after priming")
|
||||
|
||||
// Drain it dry with one oversized read, then feed a normal quantum again. The length comes
|
||||
// off the buffer pointer, not off `huge`: touching the array inside the closure that is
|
||||
// already holding it exclusively is an exclusivity violation.
|
||||
var huge = [Float](repeating: 0, count: 200 * perMS)
|
||||
huge.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: $0.count) }
|
||||
let feed = [Float](repeating: 0.5, count: want)
|
||||
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: want) }
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
XCTAssertTrue(
|
||||
scratch.contains { $0 != 0 },
|
||||
"a single short read must not force a full re-prime")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+399
-12
@@ -41,16 +41,23 @@ mod cli {
|
||||
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_millis(2500);
|
||||
|
||||
/// The handshake budget `--request-access` runs on. Matches the host's `PENDING_APPROVAL_WAIT`
|
||||
/// — the connect is PARKED for that long while an operator decides, so anything shorter would
|
||||
/// give up while the approval prompt is still on their screen.
|
||||
const REQUEST_ACCESS_TIMEOUT_SECS: u64 = 185;
|
||||
|
||||
const USAGE: &str = "\
|
||||
punktfunk — the Punktfunk client, headless
|
||||
|
||||
punktfunk discover [--json] [--timeout SECS]
|
||||
punktfunk pair <host[:port]> [--pin N] [--name LABEL]
|
||||
punktfunk hosts list [--probe] [--json]
|
||||
punktfunk hosts add <host[:port]> [--name LABEL] [--fp HEX]
|
||||
punktfunk hosts forget <host-ref>
|
||||
punktfunk wake <host-ref> [--wait]
|
||||
punktfunk library <host-ref> [--json]
|
||||
punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec] [--fullscreen]
|
||||
punktfunk launch <host-ref> [--game ID] [--profile REF] [--request-access]
|
||||
[--exec] [--fullscreen]
|
||||
punktfunk open <punktfunk://…>
|
||||
punktfunk reachable <host-ref>
|
||||
punktfunk speed-test <host-ref>
|
||||
@@ -68,6 +75,24 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
|
||||
/// (what goes to stdout vs stderr, and which exit codes mean what).
|
||||
fn verb_help(verb: &str) -> Option<&'static str> {
|
||||
Some(match verb {
|
||||
"discover" => {
|
||||
"\
|
||||
punktfunk discover [--json] [--timeout SECS] — browse the LAN for hosts
|
||||
|
||||
Listens for Punktfunk hosts advertising over mDNS and prints what answered:
|
||||
name TAB addr:port TAB saved|new TAB paired|unpaired. `saved` means this
|
||||
device already has a record for it, matched by fingerprint first and address
|
||||
second — the same rule every other surface joins the two lists by.
|
||||
|
||||
--timeout SECS how long to browse (default 3, capped at 30) — a bounded
|
||||
call, so a panel can wait for it
|
||||
--json {\"hosts\":[{\"name\",\"addr\",\"port\",\"fp\",\"pair\",\"id\",\"mgmt\",
|
||||
\"os\",\"saved\",\"paired\"}]}
|
||||
|
||||
Nothing answering is an answer, not a failure: an empty list exits 0. A host
|
||||
mDNS never sees (Tailscale, another subnet) will not appear here — save it by
|
||||
address with `punktfunk hosts add` and it shows in `hosts list --probe`."
|
||||
}
|
||||
"pair" => {
|
||||
"\
|
||||
punktfunk pair <host[:port]> — enrol this device with a host (PIN ceremony)
|
||||
@@ -96,6 +121,13 @@ punktfunk hosts — the saved-hosts store (shared with the desktop client)
|
||||
another subnet). Without --fp it is a placeholder to pair later; with a
|
||||
64-hex fingerprint it is pinned immediately (still unpaired).
|
||||
|
||||
Idempotent, and keyed on the FINGERPRINT once there is one: re-running it
|
||||
for a host already saved is a no-op, and giving a known fingerprint a new
|
||||
address MOVES that host's record there rather than filing a second one
|
||||
(which is how a host that changed DHCP lease stays reachable by its id).
|
||||
A different fingerprint for an address already saved is refused, exit 3 —
|
||||
a changed identity is a decision for a person.
|
||||
|
||||
punktfunk hosts forget <host-ref>
|
||||
Remove a saved host, its pinned fingerprint included. A later connect
|
||||
must pair or trust it again."
|
||||
@@ -119,7 +151,8 @@ this. Needs a paired host (exit 6 otherwise)."
|
||||
}
|
||||
"launch" => {
|
||||
"\
|
||||
punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec] [--fullscreen]
|
||||
punktfunk launch <host-ref> [--game ID] [--profile REF] [--request-access]
|
||||
[--exec] [--fullscreen]
|
||||
|
||||
Start a stream — waking the host first if it is asleep and its MAC is known.
|
||||
The stream runs in the punktfunk-session renderer; this command supervises it
|
||||
@@ -132,6 +165,16 @@ and relays its lifecycle to stderr.
|
||||
--exec become the session process instead of supervising it — the
|
||||
gamescope-wrapper mode, where the launched process must BE
|
||||
the streaming one for focus and lifecycle to work
|
||||
--request-access
|
||||
ask the host's operator to let this device in instead of
|
||||
typing a PIN. The host PARKS the connect until somebody
|
||||
approves it in its console or web UI (up to ~185 s), then
|
||||
admits it and the stream starts by itself; the host is
|
||||
recorded as paired once that happens, so later streams are
|
||||
silent. Needs the host's fingerprint pinned already
|
||||
(`punktfunk hosts add <addr> --fp <hex>`), and cannot be
|
||||
combined with --exec — under --exec there is no process
|
||||
left to record the approval.
|
||||
|
||||
Exit 0 when the stream ends cleanly, 2 connect failed, 3 the host no longer
|
||||
trusts this device (re-pair), 4 the renderer could not start."
|
||||
@@ -222,7 +265,7 @@ from the config directory for a true factory reset."
|
||||
fn flag_takes_value(flag: &str) -> bool {
|
||||
matches!(
|
||||
flag,
|
||||
"--pin" | "--name" | "--fp" | "--game" | "--profile" | "--port"
|
||||
"--pin" | "--name" | "--fp" | "--game" | "--profile" | "--port" | "--timeout"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -269,6 +312,7 @@ from the config directory for a true factory reset."
|
||||
return OK;
|
||||
}
|
||||
match verb.as_str() {
|
||||
"discover" => discover(&rest),
|
||||
"pair" => pair(&rest),
|
||||
"hosts" => hosts(&rest),
|
||||
"wake" => wake(&rest),
|
||||
@@ -306,6 +350,104 @@ from the config directory for a true factory reset."
|
||||
}
|
||||
}
|
||||
|
||||
/// How long `discover` browses when nobody says, and the ceiling on what they can ask for.
|
||||
/// The cap is not politeness: this verb is called from a Quick Access panel, and a typo'd
|
||||
/// `--timeout 3000` would hang that panel with no way to cancel it.
|
||||
const DISCOVER_DEFAULT_SECS: f64 = 3.0;
|
||||
const DISCOVER_MAX_SECS: f64 = 30.0;
|
||||
|
||||
/// `discover [--json] [--timeout SECS]` — browse the LAN over mDNS and print what answered,
|
||||
/// annotated against the saved-hosts store.
|
||||
///
|
||||
/// The annotation is the point: a caller wants "can I stream this", which is a question
|
||||
/// about BOTH lists, and joining them itself is how two surfaces end up disagreeing about
|
||||
/// the same host. So the match rule lives here, once, and is the same one every other
|
||||
/// surface uses — fingerprint first (survives a DHCP move), address second.
|
||||
fn discover(args: &[String]) -> u8 {
|
||||
let secs = value(args, "--timeout")
|
||||
.and_then(|v| v.parse::<f64>().ok())
|
||||
.filter(|s| *s > 0.0)
|
||||
.unwrap_or(DISCOVER_DEFAULT_SECS)
|
||||
.min(DISCOVER_MAX_SECS);
|
||||
let found = pf_client_core::discovery::discover_for(Duration::from_secs_f64(secs));
|
||||
// `read`, not `load`: this verb only LOOKS at the records to annotate what it found, and
|
||||
// never hands their ids back. `load` would mint ids for a pre-mint store and save them —
|
||||
// a write from a read-only verb, and one that races the `hosts list` a caller is very
|
||||
// likely running at the same moment (the Decky panel issues both together).
|
||||
let known = KnownHosts::read();
|
||||
let rows: Vec<(
|
||||
&pf_client_core::discovery::DiscoveredHost,
|
||||
Option<&KnownHost>,
|
||||
)> = found.iter().map(|d| (d, match_saved(&known, d))).collect();
|
||||
if has(args, "--json") {
|
||||
let hosts: Vec<serde_json::Value> = rows
|
||||
.iter()
|
||||
.map(|(d, saved)| {
|
||||
serde_json::json!({
|
||||
"name": d.name,
|
||||
"addr": d.addr,
|
||||
"port": d.port,
|
||||
"fp": d.fp_hex,
|
||||
"pair": d.pair,
|
||||
"id": d.advertised_id(),
|
||||
// 0 = not advertised, which is what a consumer's own "no mgmt port"
|
||||
// already means — an older host simply omits the TXT.
|
||||
"mgmt": d.mgmt_port.unwrap_or(0),
|
||||
"os": d.os,
|
||||
"saved": saved.is_some(),
|
||||
"paired": saved.is_some_and(|h| h.paired),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
println!("{}", serde_json::json!({ "hosts": hosts }));
|
||||
} else {
|
||||
for (d, saved) in &rows {
|
||||
println!(
|
||||
"{}\t{}:{}\t{}\t{}",
|
||||
d.name,
|
||||
d.addr,
|
||||
d.port,
|
||||
if saved.is_some() { "saved" } else { "new" },
|
||||
if saved.is_some_and(|h| h.paired) {
|
||||
"paired"
|
||||
} else {
|
||||
"unpaired"
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
// An empty LAN is an answer, not a failure — a caller branching on the exit code is
|
||||
// asking "did the browse run", and it did.
|
||||
OK
|
||||
}
|
||||
|
||||
/// The saved record an advert belongs to, if any: fingerprint first, address second.
|
||||
///
|
||||
/// Fingerprint FIRST is deliberate and load-bearing — a host that moved to a new DHCP lease
|
||||
/// still matches its record, and a *different* host that inherited the old address does not
|
||||
/// inherit its pairing. This is the rule the plugin's `mergeHosts` and the shells' hosts
|
||||
/// pages already use; keeping one copy is what stops two surfaces disagreeing about whether
|
||||
/// the box in front of you is paired.
|
||||
fn match_saved<'a>(
|
||||
known: &'a KnownHosts,
|
||||
advert: &pf_client_core::discovery::DiscoveredHost,
|
||||
) -> Option<&'a KnownHost> {
|
||||
known
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| {
|
||||
!h.fp_hex.is_empty()
|
||||
&& !advert.fp_hex.is_empty()
|
||||
&& h.fp_hex.eq_ignore_ascii_case(&advert.fp_hex)
|
||||
})
|
||||
.or_else(|| {
|
||||
known
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == advert.addr && h.port == advert.port)
|
||||
})
|
||||
}
|
||||
|
||||
/// `pair <host[:port]> [--pin N]` — the SPAKE2 ceremony. Without `--pin` it prompts, which
|
||||
/// is the interactive shape; with one it is scriptable. Refuses rather than prompting when
|
||||
/// stdin isn't a terminal and no PIN was given: a pairing that silently blocks a CI job
|
||||
@@ -424,16 +566,69 @@ from the config directory for a true factory reset."
|
||||
return UNRESOLVED;
|
||||
};
|
||||
let (addr, port) = split_host_port(&target);
|
||||
let fp = value(args, "--fp").unwrap_or_default();
|
||||
let name = value(args, "--name");
|
||||
let mut known = KnownHosts::load();
|
||||
if known.hosts.iter().any(|h| h.addr == addr && h.port == port) {
|
||||
eprintln!("{addr}:{port} is already saved");
|
||||
return OK;
|
||||
if let Some(i) = known
|
||||
.hosts
|
||||
.iter()
|
||||
.position(|h| h.addr == addr && h.port == port)
|
||||
{
|
||||
return match merge_saved_host(&mut known, i, &fp, name.as_deref()) {
|
||||
AddOutcome::Unchanged => {
|
||||
eprintln!("{addr}:{port} is already saved");
|
||||
OK
|
||||
}
|
||||
AddOutcome::Conflict => {
|
||||
eprintln!(
|
||||
"{addr}:{port} is already saved with a different fingerprint — \
|
||||
forget it first if you really mean to replace it \
|
||||
(punktfunk hosts forget {addr}:{port})"
|
||||
);
|
||||
TRUST_REJECTED
|
||||
}
|
||||
AddOutcome::Pinned => match known.save() {
|
||||
Ok(()) => {
|
||||
println!("updated {addr}:{port}");
|
||||
OK
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("saving: {e:#}");
|
||||
CONNECT_FAILED
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
// No record at this address — but a record carrying this exact FINGERPRINT is
|
||||
// this same host at a new one. Re-point it rather than filing a second record:
|
||||
// the fingerprint is the identity, and a host that changed DHCP lease is the
|
||||
// whole reason `hosts add --fp` is idempotent in the first place. Without this a
|
||||
// moved host accumulates one record per address it has ever held, and the one a
|
||||
// stable id resolves to keeps the address it can no longer be reached at.
|
||||
if let Some(i) = known
|
||||
.hosts
|
||||
.iter()
|
||||
.position(|h| !fp.is_empty() && h.fp_hex.eq_ignore_ascii_case(&fp))
|
||||
{
|
||||
let was = format!("{}:{}", known.hosts[i].addr, known.hosts[i].port);
|
||||
known.hosts[i].addr = addr.clone();
|
||||
known.hosts[i].port = port;
|
||||
return match known.save() {
|
||||
Ok(()) => {
|
||||
println!("moved {was} to {addr}:{port}");
|
||||
OK
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("saving: {e:#}");
|
||||
CONNECT_FAILED
|
||||
}
|
||||
};
|
||||
}
|
||||
known.hosts.push(KnownHost {
|
||||
name: value(args, "--name").unwrap_or_else(|| addr.clone()),
|
||||
name: name.unwrap_or_else(|| addr.clone()),
|
||||
addr: addr.clone(),
|
||||
port,
|
||||
fp_hex: value(args, "--fp").unwrap_or_default(),
|
||||
fp_hex: fp,
|
||||
..Default::default()
|
||||
});
|
||||
match known.save() {
|
||||
@@ -475,6 +670,55 @@ from the config directory for a true factory reset."
|
||||
}
|
||||
}
|
||||
|
||||
/// What `hosts add` did to a record that was ALREADY saved for this address.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum AddOutcome {
|
||||
/// Nothing to do — no fingerprint was offered, or the record already carries this one.
|
||||
/// Exits 0 on purpose: a panel retrying step 1 of request access must not have to
|
||||
/// invent an error to show for a state that is already correct.
|
||||
Unchanged,
|
||||
/// The record had no fingerprint and now has this one.
|
||||
Pinned,
|
||||
/// The record carries a DIFFERENT fingerprint. Refused, never overwritten.
|
||||
Conflict,
|
||||
}
|
||||
|
||||
/// `hosts add --fp` against an address that is already saved. The difference between these
|
||||
/// three is a trust decision, not bookkeeping.
|
||||
///
|
||||
/// Filling in an empty fingerprint is step 1 of request access (design §5): a host found by
|
||||
/// advert is saved by address first and pinned second. Without it the `--fp` is dropped on
|
||||
/// the floor and the launch that follows refuses for want of a pin — which is what this did
|
||||
/// before, silently and with exit 0.
|
||||
///
|
||||
/// A *different* fingerprint is refused because a changed identity is a decision for a
|
||||
/// person, at a surface that can show them both. That is what `upsert_trusted` exists to
|
||||
/// enforce; quietly overwriting it here would be a back door through the pinning the rest
|
||||
/// of the client is built on.
|
||||
fn merge_saved_host(
|
||||
known: &mut KnownHosts,
|
||||
i: usize,
|
||||
fp: &str,
|
||||
name: Option<&str>,
|
||||
) -> AddOutcome {
|
||||
let existing = known.hosts[i].fp_hex.clone();
|
||||
if fp.is_empty() || existing.eq_ignore_ascii_case(fp) {
|
||||
return AddOutcome::Unchanged;
|
||||
}
|
||||
if !existing.is_empty() {
|
||||
return AddOutcome::Conflict;
|
||||
}
|
||||
known.hosts[i].fp_hex = fp.to_string();
|
||||
// Only a record still named after its own address is renamed: a label the user chose is
|
||||
// theirs, and an advert's name must not quietly overwrite it.
|
||||
if let Some(label) = name {
|
||||
if known.hosts[i].name == known.hosts[i].addr {
|
||||
known.hosts[i].name = label.to_string();
|
||||
}
|
||||
}
|
||||
AddOutcome::Pinned
|
||||
}
|
||||
|
||||
/// `wake <host-ref> [--wait]` — a magic packet, and with `--wait` the same bounded
|
||||
/// wake-and-wait the shells run (`WakeWait`: a packet every 6 s, presence polled every
|
||||
/// second, 90 s budget).
|
||||
@@ -585,6 +829,19 @@ from the config directory for a true factory reset."
|
||||
eprintln!("usage: punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec]");
|
||||
return UNRESOLVED;
|
||||
};
|
||||
let exec = has(args, "--exec");
|
||||
let request_access = has(args, "--request-access");
|
||||
// Refused rather than silently downgraded: under `--exec` this process BECOMES the
|
||||
// session, so nothing survives to see `Ready` and record the approval. A launch that
|
||||
// quietly dropped the persistence would leave hosts reading "trusted" forever with
|
||||
// nobody able to say why.
|
||||
if request_access && exec {
|
||||
eprintln!(
|
||||
"--request-access can't be combined with --exec: under --exec there is no \
|
||||
process left to record the host's approval"
|
||||
);
|
||||
return UNRESOLVED;
|
||||
}
|
||||
let (known, i) = match resolve(&reference) {
|
||||
Ok(v) => v,
|
||||
Err(code) => return code,
|
||||
@@ -597,7 +854,10 @@ from the config directory for a true factory reset."
|
||||
if has(args, "--fullscreen") {
|
||||
plan.settings.fullscreen_on_stream = true;
|
||||
}
|
||||
run_plan(plan, has(args, "--exec"))
|
||||
if request_access {
|
||||
plan.connect_timeout_secs = Some(REQUEST_ACCESS_TIMEOUT_SECS);
|
||||
}
|
||||
run_plan(plan, exec, request_access)
|
||||
}
|
||||
|
||||
/// `open <url>` — the `punktfunk://` grammar, headless. Same parser, same refusal rules and
|
||||
@@ -622,7 +882,7 @@ from the config directory for a true factory reset."
|
||||
&trust::Settings::load(),
|
||||
);
|
||||
match outcome {
|
||||
Ok(PlanOutcome::Connect(plan)) => run_plan(*plan, has(args, "--exec")),
|
||||
Ok(PlanOutcome::Connect(plan)) => run_plan(*plan, has(args, "--exec"), false),
|
||||
// A URL may never pair or trust on its own — that is a decision for a person, at a
|
||||
// surface that can show them the fingerprint.
|
||||
Ok(PlanOutcome::ConfirmUnknown(u)) => {
|
||||
@@ -646,7 +906,13 @@ from the config directory for a true factory reset."
|
||||
}
|
||||
|
||||
/// Wake if needed, then run the session — supervising it, or becoming it under `--exec`.
|
||||
fn run_plan(plan: ConnectPlan, exec: bool) -> u8 {
|
||||
///
|
||||
/// `persist_paired` records the host as *paired* when the child reports ready. Only
|
||||
/// `launch --request-access` passes true: there, the host parked the connect until an
|
||||
/// operator approved this device, so `Ready` IS the approval arriving — the same thing
|
||||
/// `SpawnOpts::persist_paired` means in the GTK shell. Every other launch records nothing,
|
||||
/// which is correct: a plain connect proves reachability, not a new trust decision.
|
||||
fn run_plan(plan: ConnectPlan, exec: bool, persist_paired: bool) -> u8 {
|
||||
if plan.host.fp_hex.is_none() {
|
||||
eprintln!(
|
||||
"{} has no pinned fingerprint — punktfunk pair {}",
|
||||
@@ -708,7 +974,24 @@ from the config directory for a true factory reset."
|
||||
let mut failure: Option<(String, bool)> = None;
|
||||
while let Ok(ev) = rx.recv() {
|
||||
match ev {
|
||||
SessionEvent::Ready => eprintln!("streaming"),
|
||||
SessionEvent::Ready => {
|
||||
eprintln!("streaming");
|
||||
// The pin we connected WITH, not one re-derived from the store: the record
|
||||
// is what we are about to rewrite, and the session proved the host holds
|
||||
// exactly this identity by completing a pinned handshake against it.
|
||||
if persist_paired {
|
||||
if let Some(fp_hex) = &plan.host.fp_hex {
|
||||
trust::persist_host(
|
||||
&plan.host.name,
|
||||
&plan.host.addr,
|
||||
plan.host.port,
|
||||
fp_hex,
|
||||
true,
|
||||
);
|
||||
trust::forget_placeholder(&plan.host.addr, plan.host.port);
|
||||
}
|
||||
}
|
||||
}
|
||||
SessionEvent::Error {
|
||||
msg,
|
||||
trust_rejected,
|
||||
@@ -967,6 +1250,7 @@ from the config directory for a true factory reset."
|
||||
#[test]
|
||||
fn every_usage_verb_has_help() {
|
||||
for verb in [
|
||||
"discover",
|
||||
"pair",
|
||||
"hosts",
|
||||
"wake",
|
||||
@@ -988,6 +1272,109 @@ from the config directory for a true factory reset."
|
||||
assert!(verb_help("bogus").is_none());
|
||||
}
|
||||
|
||||
fn saved(name: &str, addr: &str, fp: &str) -> KnownHost {
|
||||
KnownHost {
|
||||
name: name.into(),
|
||||
addr: addr.into(),
|
||||
port: 9777,
|
||||
fp_hex: fp.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Step 1 of request access: a host saved by address gains the fingerprint its advert
|
||||
/// carried. Before this, `hosts add --fp` on an existing record exited 0 having done
|
||||
/// NOTHING — the launch that followed then refused for want of a pin, and the panel had
|
||||
/// no way to tell why.
|
||||
#[test]
|
||||
fn adding_a_fingerprint_to_a_placeholder_fills_it_in() {
|
||||
let mut known = KnownHosts {
|
||||
hosts: vec![saved("192.168.1.9", "192.168.1.9", "")],
|
||||
};
|
||||
assert_eq!(
|
||||
merge_saved_host(&mut known, 0, "abc123", Some("living-room")),
|
||||
AddOutcome::Pinned
|
||||
);
|
||||
assert_eq!(known.hosts[0].fp_hex, "abc123");
|
||||
assert_eq!(
|
||||
known.hosts[0].name, "living-room",
|
||||
"a record still named after its address takes the offered label"
|
||||
);
|
||||
}
|
||||
|
||||
/// A label the user chose is theirs — an advert's name must not overwrite it.
|
||||
#[test]
|
||||
fn filling_in_a_fingerprint_keeps_a_user_chosen_name() {
|
||||
let mut known = KnownHosts {
|
||||
hosts: vec![saved("Basement rig", "192.168.1.9", "")],
|
||||
};
|
||||
merge_saved_host(&mut known, 0, "abc123", Some("living-room"));
|
||||
assert_eq!(known.hosts[0].name, "Basement rig");
|
||||
}
|
||||
|
||||
/// Idempotent: the panel may retry step 1, and re-offering the fingerprint a record
|
||||
/// already carries is a state that is already correct, not an error to render.
|
||||
#[test]
|
||||
fn re_adding_the_same_fingerprint_changes_nothing() {
|
||||
let mut known = KnownHosts {
|
||||
hosts: vec![saved("desk", "192.168.1.9", "ABC123")],
|
||||
};
|
||||
assert_eq!(
|
||||
merge_saved_host(&mut known, 0, "abc123", None),
|
||||
AddOutcome::Unchanged,
|
||||
"fingerprints compare case-insensitively"
|
||||
);
|
||||
// And a bare `hosts add` with no --fp at all leaves the pin alone.
|
||||
assert_eq!(
|
||||
merge_saved_host(&mut known, 0, "", None),
|
||||
AddOutcome::Unchanged
|
||||
);
|
||||
assert_eq!(known.hosts[0].fp_hex, "ABC123");
|
||||
}
|
||||
|
||||
/// A changed identity is a decision for a person. Never a silent overwrite — this is the
|
||||
/// same rule `upsert_trusted` enforces, and a back door here would defeat it everywhere.
|
||||
#[test]
|
||||
fn a_different_fingerprint_is_refused_not_overwritten() {
|
||||
let mut known = KnownHosts {
|
||||
hosts: vec![saved("desk", "192.168.1.9", "abc123")],
|
||||
};
|
||||
assert_eq!(
|
||||
merge_saved_host(&mut known, 0, "deadbeef", None),
|
||||
AddOutcome::Conflict
|
||||
);
|
||||
assert_eq!(
|
||||
known.hosts[0].fp_hex, "abc123",
|
||||
"the pin must survive intact"
|
||||
);
|
||||
}
|
||||
|
||||
/// A host that changed DHCP lease is re-pointed, not filed a second time. Without this
|
||||
/// the record a stable id resolves to keeps an address the host has left, so a launch
|
||||
/// dials into the void while the panel shows the live one.
|
||||
#[test]
|
||||
fn a_known_fingerprint_at_a_new_address_moves_the_record() {
|
||||
let mut known = KnownHosts {
|
||||
hosts: vec![saved("desk", "192.168.1.9", "abc123")],
|
||||
};
|
||||
// Simulates `hosts add 192.168.1.50 --fp abc123` finding no record at that address.
|
||||
let by_addr = known
|
||||
.hosts
|
||||
.iter()
|
||||
.position(|h| h.addr == "192.168.1.50" && h.port == 9777);
|
||||
assert!(
|
||||
by_addr.is_none(),
|
||||
"the new address is not yet on any record"
|
||||
);
|
||||
let by_fp = known
|
||||
.hosts
|
||||
.iter()
|
||||
.position(|h| h.fp_hex.eq_ignore_ascii_case("abc123"));
|
||||
assert_eq!(by_fp, Some(0), "the fingerprint still identifies the host");
|
||||
known.hosts[0].addr = "192.168.1.50".into();
|
||||
assert_eq!(known.hosts.len(), 1, "one host, one record");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_reads_the_argument_after_its_flag() {
|
||||
let a = argv(&["--game", "steam:570", "--exec"]);
|
||||
|
||||
@@ -67,3 +67,21 @@ fn unknown_verbs_refuse_with_the_not_found_code() {
|
||||
let out = punktfunk(&["help", "frobnicate"]);
|
||||
assert_eq!(out.status.code(), Some(5), "unknown help topic exits 5");
|
||||
}
|
||||
|
||||
/// `discover` and `launch --request-access` document themselves. Help only — the verbs
|
||||
/// themselves browse the LAN and dial a host, which no runner may be asked to do.
|
||||
///
|
||||
/// The Decky panel detects a too-old client by exactly the signature the test above pins
|
||||
/// (exit 5 + `unknown command`), so this is the other half of that contract: on a client new
|
||||
/// enough, `discover` is a verb with help rather than an unknown word.
|
||||
#[test]
|
||||
fn the_request_access_surfaces_document_themselves() {
|
||||
let out = punktfunk(&["help", "discover"]);
|
||||
assert!(out.status.success(), "discover has its own help topic");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(stdout.contains("--timeout"), "discover documents --timeout");
|
||||
assert!(stdout.contains("--json"), "discover documents --json");
|
||||
|
||||
let out = punktfunk(&["launch", "--help"]);
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains("--request-access"));
|
||||
}
|
||||
|
||||
+86
-56
@@ -2,49 +2,61 @@
|
||||
|
||||
Stream to your **Steam Deck** without ever leaving Gaming Mode. This
|
||||
**[Decky Loader](https://decky.xyz/)** plugin adds a **Punktfunk** panel to the Quick Access Menu
|
||||
(the `…` button): discover hosts on your network, pair with a PIN, tweak stream settings, and launch
|
||||
a fullscreen, gamescope-focused stream — all from the couch, gamepad-navigable.
|
||||
(the `…` button): the hosts you can stream, the pinned cards you set up, and one tap into each.
|
||||
|
||||
The video itself is the native GTK4 Linux client (the `io.unom.Punktfunk` flatpak); the plugin
|
||||
discovers, pairs, configures, and *launches it the right way* so gamescope fullscreens it — the same
|
||||
Steam-shortcut trick MoonDeck uses. Because it's built from real Steam UI primitives (`@decky/ui`),
|
||||
the panel looks and feels native to Gaming Mode.
|
||||
The plugin is a **launcher**, not a client. It doesn't decode video, browse your library, or hold
|
||||
any settings of its own — the Rust client does all of that, and the plugin's job is to start it
|
||||
*the right way* so gamescope fullscreens and focuses it (the same Steam-shortcut trick MoonDeck
|
||||
uses). Everything the panel doesn't do is one tap away in the client's own gamepad UI.
|
||||
|
||||
## What it does
|
||||
|
||||
1. **Discover** — browses the LAN over mDNS for Punktfunk hosts, in both the QAM panel and a
|
||||
fullscreen page; each host row opens a details view (address, pairing policy, certificate
|
||||
fingerprint to cross-check against the host's log).
|
||||
2. **Pair** — for a host that requires it, a gamepad-navigable PIN keypad runs the SPAKE2 pairing
|
||||
ceremony headlessly, then remembers the host so future streams connect silently.
|
||||
3. **Stream** — launches fullscreen via a branded "Punktfunk" Steam shortcut so gamescope focuses it.
|
||||
4. **Games** — each host row has a games button that opens its **library picker**: pin titles as
|
||||
one-tap "Stream <Game>" rows in the QAM (jump straight into e.g. Playnite on the host), or
|
||||
**"Open library on screen"** to launch the client's controller-driven, console-style library
|
||||
browser (aurora backdrop + poster coverflow; A plays, B returns to Gaming Mode). Pins survive
|
||||
plugin reinstalls (stored next to the client's config) and follow a host across IP changes
|
||||
(matched by certificate fingerprint).
|
||||
5. **Settings** — the client's whole settings store, written to its config. Laid out like SteamOS's
|
||||
own Settings: a left rail of categories (`SidebarNavigation`), one page each, so no page needs
|
||||
scrolling. The categories and their order are the console settings screen's — Stream (resolution
|
||||
/ refresh / render scale / bitrate / compositor), Video (codec / decoder / GPU / HDR / 4:4:4),
|
||||
Presentation (prioritize / smoothness buffer / V-Sync / VRR), Audio (channels / output + mic
|
||||
device / echo cancellation), Controllers, Touch & mouse, Interface (stats overlay / auto-wake /
|
||||
library / fullscreen). The device pickers are populated
|
||||
from the session binary (`--list-adapters` / `--list-audio`); the GPU row appears only where
|
||||
there is more than one adapter.
|
||||
6. **About** — plugin version, an explicit "Check for updates" button, the setup-guide link, and
|
||||
a force-stop for a wedged stream client.
|
||||
1. **Hosts** — the hosts on your network plus the ones you've saved, in one list. Discovery is
|
||||
mDNS; saved hosts are also probed directly, so a box reached over Tailscale or a VPN shows as
|
||||
online even though it never advertises. Rows sort online-first, then most recently used.
|
||||
2. **Trust** — an unpaired host opens a small sheet with two ways in:
|
||||
- **Request access** (the default) — no PIN. The host's operator approves this Deck in its
|
||||
console or web UI and the stream starts by itself. See [Request access](#request-access).
|
||||
- **Use a PIN instead** — the gamepad-navigable keypad, running the same SPAKE2 ceremony.
|
||||
3. **Stream** — launches fullscreen via a branded "Punktfunk" Steam shortcut so gamescope focuses
|
||||
it. A sleeping host is woken first (the client runs the real wake-and-wait loop, then dials).
|
||||
4. **Pinned cards** — a *(host, profile)* pair renders nested under its host as `▸ <Profile name>`
|
||||
and streams with that settings profile applied. Cards are the **shared** pinning model every
|
||||
other client speaks, stored on the host's record — so one you make in the desktop client shows
|
||||
up here, and vice versa. The plugin renders them; it doesn't create or edit them.
|
||||
5. **Open Punktfunk** — launches the client's **console home**: the host picker, add-host by
|
||||
address, PIN pairing, the game library browser, and the **full settings screen**. This is where
|
||||
everything the panel no longer does now lives.
|
||||
6. **About** — plugin version, "Check for updates", "Recreate library shortcut", and a force-stop
|
||||
for a wedged stream.
|
||||
|
||||
To leave a stream: the in-client controller chord (**L1 + R1 + Start + Select**), or close the
|
||||
"game" from the Steam overlay — either returns you to Gaming Mode.
|
||||
|
||||
### Request access
|
||||
|
||||
Request access is not a second pairing ceremony — it is a **launch**. The plugin saves the host
|
||||
with the fingerprint it **advertised**, then starts an ordinary identified connect with the
|
||||
handshake budget stretched to 185 s. The host *parks* that connection until its operator approves
|
||||
the device, then admits the same connection; the stream starts on its own, and the record flips
|
||||
to **paired** so every later stream is silent.
|
||||
|
||||
**No advertised fingerprint, no request access.** That pinned fingerprint is the only thing
|
||||
standing between a 185-second wait and an impostor answering for the host, so a host you typed in
|
||||
by address gets the PIN path only — and the sheet says why. The plugin never trusts-on-first-use
|
||||
past a missing fingerprint.
|
||||
|
||||
## Install on the Deck
|
||||
|
||||
You need **[Decky Loader](https://decky.xyz/)** and the **`io.unom.Punktfunk` flatpak**
|
||||
([`packaging/flatpak`](../../packaging/flatpak/README.md)) installed on the Deck — SteamOS `/usr` is
|
||||
read-only, so the flatpak (which bundles libadwaita/SDL3) is the canonical client. Discovery uses
|
||||
`avahi-browse`, which ships on SteamOS/Bazzite.
|
||||
You need **[Decky Loader](https://decky.xyz/)** and a **Punktfunk client** on the Deck. On a normal
|
||||
Deck that's the `io.unom.Punktfunk` flatpak ([`packaging/flatpak`](../../packaging/flatpak/README.md)) —
|
||||
SteamOS `/usr` is read-only, so the flatpak (which bundles libadwaita/SDL3) is the canonical client.
|
||||
A native install (sysext, distro package, nix profile, your own build) works too.
|
||||
|
||||
**The client must be v0.22.0 or newer** — that is when the headless `punktfunk` CLI shipped, and
|
||||
the panel drives everything through it. An older client says so in the panel, with the update
|
||||
button that fixes it right there. (Discovery no longer needs `avahi-browse` on the Deck; the
|
||||
client's own mDNS does it.)
|
||||
|
||||
**Recommended — install from URL** (published by CI): in Decky → Settings → **Developer Mode** →
|
||||
**Install Plugin from URL**, paste:
|
||||
@@ -55,17 +67,15 @@ https://unom.io/pf-decky
|
||||
|
||||
(short link for `https://git.unom.io/api/packages/unom/generic/punktfunk-decky/latest/punktfunk.zip`;
|
||||
for a pinned version use `https://git.unom.io/api/packages/unom/generic/punktfunk-decky/<version>/punktfunk.zip`
|
||||
directly). The plugin then **self-updates** without
|
||||
the Decky store — when a newer build exists, an **Update** button appears and drives Decky
|
||||
Loader's own (SHA-256-verified) install. Installs and updates can take a couple of minutes on some
|
||||
networks: Decky's installer also contacts its plugin store first, which may be slow or blackholed
|
||||
before the actual download proceeds.
|
||||
directly). The plugin then **self-updates** without the Decky store — when a newer build exists, an
|
||||
**Update** button appears and drives Decky Loader's own (SHA-256-verified) install. Installs and
|
||||
updates can take a couple of minutes on some networks: Decky's installer also contacts its plugin
|
||||
store first, which may be slow or blackholed before the actual download proceeds.
|
||||
|
||||
### Updating the client
|
||||
|
||||
The plugin also reports — and where it can, installs — updates for the **client** it launches.
|
||||
What is possible depends on how that client was installed, and the About tab names the install
|
||||
kind so the answer is never a mystery:
|
||||
What is possible depends on how that client was installed:
|
||||
|
||||
| Install | Update |
|
||||
| --- | --- |
|
||||
@@ -88,6 +98,8 @@ pnpm install
|
||||
pnpm build # rollup → dist/index.js
|
||||
pnpm run package # → out/punktfunk/ + out/punktfunk-v<ver>.zip
|
||||
DECK=deck@<deck-ip> pnpm run deploy # rsync → /tmp, sudo-install into the root-owned plugins dir, restart loader
|
||||
|
||||
python3.13 scripts/test-backend.py # backend unit checks (needs Python ≥3.10)
|
||||
```
|
||||
|
||||
`~/homebrew/plugins/` is root-owned (the loader runs as root), so `deploy.sh` stages to a temp dir
|
||||
@@ -96,28 +108,46 @@ restart is required for an out-of-band install to appear.
|
||||
|
||||
## Architecture
|
||||
|
||||
Everything below the panel is the CLI. `main.py` builds argv and maps exit codes; it parses none of
|
||||
the client's data files and re-implements none of its rules.
|
||||
|
||||
| File | Role |
|
||||
| --- | --- |
|
||||
| `src/index.tsx` | Plugin entry: the QAM panel + route registration. |
|
||||
| `src/page.tsx` | The `/punktfunk` fullscreen page — Hosts (with per-host details) / Settings / About tabs. |
|
||||
| `src/settings.tsx` · `src/pair.tsx` | The settings screen (a `SidebarNavigation` of seven category pages over one shared settings object); the gamepad-navigable PIN-pairing modal. |
|
||||
| `src/library.tsx` | The per-host game picker (pin/unpin, "Open library on screen") + the pinned-game launch helper. |
|
||||
| `src/hostmgmt.tsx` | Add / edit host dialogs — mutate the shared known-hosts store (`client-known-hosts.json`) via the flatpak client's headless modes, so a host saved here shows up in the desktop client too. |
|
||||
| `src/ui.tsx` | Shared UI primitives for the fullscreen page + modals (right-aligned row actions, consistent Field layout). |
|
||||
| `src/hooks.ts` · `src/boundary.tsx` | Shared discovery/update/pins hooks + actions; the render error boundary. |
|
||||
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. The shortcut's exe is `/bin/sh` with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). Launch extras ride env-prefix tokens: `PF_LAUNCH=<id>` (pinned game) / `PF_BROWSE=1` + `PF_MGMT=<port>` (on-screen library); ids are validated space/quote-free at pin AND launch time. |
|
||||
| `src/backend.ts` | Typed `callable` bridges to `main.py`. |
|
||||
| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut runs (so the window is focusable); maps `PF_LAUNCH`/`PF_BROWSE`/`PF_MGMT` to `--launch`/`--browse`/`--mgmt`. An older flatpak ignores the flags harmlessly (plain stream / hosts page). |
|
||||
| `main.py` | Backend: `discover` (via `avahi-browse`) / `pair` / `library` (headless flatpak `--library`, TSV) / pins store (`decky-pinned.json`) / settings / `kill_stream` / `check_update` (with an explicit CA-bundle search — Decky's embedded Python has no usable default TLS roots on SteamOS). |
|
||||
| `scripts/test-backend.py` | Stdlib-only checks for the backend's pure parsers (TSV, error classes, avahi TXT) + the pins round trip. |
|
||||
| `src/index.tsx` | Plugin entry + the QAM panel: update banner, hosts (with nested pinned cards), the console-home door, about. |
|
||||
| `src/hooks.ts` | `useHosts` (one call merging discovery and the saved store), the update hooks, and the launch action. Also the trust-state model the rows render. |
|
||||
| `src/trust.tsx` · `src/pair.tsx` | The trust sheet (Request access / Use a PIN instead / Cancel) and the gamepad-navigable PIN keypad. |
|
||||
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. The shortcut's exe is `/bin/sh` with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). |
|
||||
| `src/backend.ts` · `src/boundary.tsx` · `src/os-icon.tsx` | Typed `callable` bridges to `main.py`; the render error boundary; the host row's OS mark. |
|
||||
| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut runs (so the window is focusable). Reads `PF_REF` / `PF_PROFILE` / `PF_REQUEST_ACCESS` / `PF_BROWSE` and runs `punktfunk launch` — or the session's `--browse` for console home. |
|
||||
| `main.py` | Backend: four thin CLI shells (`discover` / `hosts` / `pair` / `trust_host`) plus the Steam-side work only a plugin can do — `runner_info`, `shortcut_art`, `apply_controller_config`, `kill_stream`, `check_update` / `update_client` (with an explicit CA-bundle search — Decky's embedded Python has no usable default TLS roots on SteamOS). |
|
||||
| `scripts/test-backend.py` | Stdlib-only checks: argv shape, the CLI exit-code mapping, and the Steam configset editor. |
|
||||
| `plugin.json` · `update.json` | Decky manifest; CI-baked update channel. |
|
||||
|
||||
### Why the launch goes through Steam
|
||||
|
||||
gamescope only gives focus and fullscreen to the window tree Steam launched via `reaper` (it
|
||||
detects the "current app" by AppID — gamescope#484). A client spawned from the plugin's own
|
||||
backend comes up invisible and unfocused. So the plugin registers non-Steam shortcuts whose exe is
|
||||
`/bin/sh` running `bin/punktfunkrun.sh`, and starts them with `RunGame`.
|
||||
|
||||
There are **two** shortcuts, both named `Punktfunk` so Steam keys them to one Steam Input
|
||||
configset (the key is the lowercase name): a hidden, stateful one that carries the stream, and the
|
||||
visible, stateless library entry that opens console home.
|
||||
|
||||
## Limitations / next steps
|
||||
|
||||
- No manual "add host by IP" entry yet (discovery is mDNS-only).
|
||||
- No in-stream overlay inside the plugin — the client owns the session once launched.
|
||||
- Pairing needs the operator to **arm pairing on the host** so it shows the PIN; the plugin can't arm
|
||||
it remotely.
|
||||
- **Profiles and pinned cards can't be created here** — the panel renders them; making one needs
|
||||
the desktop client, or the client's own gamepad UI once that work lands. A Deck with no profiles
|
||||
simply sees host rows, and nothing is broken.
|
||||
- **Per-game pins are on hold.** The shared model pins *host+profile*; nothing in the shared store
|
||||
persists a pinned *game* yet. The old `decky-pinned.json` is left on disk untouched so a later
|
||||
migration can read it.
|
||||
- Pairing with a PIN needs the operator to **arm pairing on the host** so it shows the PIN; the
|
||||
plugin can't arm it remotely. Request access needs no arming — just an approval.
|
||||
- **A parked connect looks like a hanging one.** The plugin toasts before launching a request-access
|
||||
stream to set expectations, which is a patch rather than a fix; teaching the session's connect
|
||||
screen the same "waiting for approval" copy the console shell already has would pay off for every
|
||||
shell.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -1,33 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# punktfunk stream runner — the target of the hidden non-Steam shortcut the plugin creates.
|
||||
# punktfunk stream runner — the target of the non-Steam shortcuts the plugin creates.
|
||||
#
|
||||
# WHY A WRAPPER SCRIPT (load-bearing, from MoonDeck's hard-won knowledge): the stream client
|
||||
# must be a descendant of the process Steam launches via `reaper`, or gamescope never gives
|
||||
# its window focus/fullscreen in Gaming Mode (gamescope detects the "current app" by AppID,
|
||||
# which only attaches to reaper's descendants — see gamescope#484). So the Decky plugin
|
||||
# launches THIS script through SteamClient.Apps.RunGame; the script then execs the flatpak
|
||||
# client, which inherits the shortcut's AppID and is focused. Launching the flatpak directly
|
||||
# from the (root) Decky backend produces an unfocused, invisible window.
|
||||
# launches THIS script through SteamClient.Apps.RunGame; the script then runs the client,
|
||||
# which inherits the shortcut's AppID and is focused. Launching the client directly from the
|
||||
# (root) Decky backend produces an unfocused, invisible window.
|
||||
#
|
||||
# Per-session parameters arrive as environment variables, set as the shortcut's Steam launch
|
||||
# options by the plugin (SteamClient.Apps.SetAppLaunchOptions), so ONE generic shortcut serves
|
||||
# every host (and every pinned game):
|
||||
# PF_HOST host[:port] to connect to (required for streaming; optional for browse)
|
||||
# PF_LAUNCH library id to launch on connect (optional, e.g. steam:570 — pinned games)
|
||||
# PF_BROWSE non-empty = open the gamepad library (optional; --browse instead of --connect)
|
||||
# PF_MGMT management-API port for --browse (optional; client defaults to 47990)
|
||||
# PF_CONNECT_TIMEOUT connect budget in seconds (optional; the plugin stretches it after
|
||||
# firing Wake-on-LAN so the connect survives the host's resume)
|
||||
# PF_APPID flatpak app id (default io.unom.Punktfunk)
|
||||
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
|
||||
# every host:
|
||||
# PF_REF host reference — a saved host's stable id, or addr[:port] (required to stream)
|
||||
# PF_PROFILE settings-profile id for a pinned card (optional)
|
||||
# PF_REQUEST_ACCESS non-empty = ask the host's operator to admit this device instead of
|
||||
# pairing with a PIN. The connect PARKS until somebody approves it.
|
||||
# PF_BROWSE non-empty = open the client's console home instead of streaming
|
||||
# PF_APPID flatpak app id (default io.unom.Punktfunk)
|
||||
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
|
||||
# PF_CLIENT_BIN absolute path of a NATIVE client (optional; set by the plugin when it
|
||||
# resolved a non-flatpak install — then the client is exec'd directly and
|
||||
# resolved a non-flatpak install — then the client is run directly and
|
||||
# PF_APPID/PF_FLATPAK are unused)
|
||||
#
|
||||
# Values are plain tokens (the plugin validates launch ids to space/quote-free ASCII before
|
||||
# they ever reach Steam launch options). An older flatpak without --launch/--browse ignores
|
||||
# the unknown flags harmlessly (hand-scanned argv): PF_LAUNCH degrades to the plain desktop
|
||||
# session, PF_BROWSE to the client's hosts page.
|
||||
# A REFERENCE, NEVER A VALUE. Host refs and profile ids are the only things that ride this
|
||||
# channel; no resolution, bitrate or codec ever does. The client resolves both against its own
|
||||
# stores, which is what keeps a Steam launch option from becoming a second settings surface.
|
||||
# The plugin validates them to space/quote-free ASCII before they reach Steam's tokenizer.
|
||||
#
|
||||
# Runs as the `deck` user (Steam launched it), so the --user flatpak install is visible and
|
||||
# WAYLAND_DISPLAY / XDG_RUNTIME_DIR are already correct for gamescope.
|
||||
@@ -42,13 +41,22 @@ APPID="${PF_APPID:-io.unom.Punktfunk}"
|
||||
FLATPAK="${PF_FLATPAK:-flatpak}"
|
||||
|
||||
# The client is not always the flatpak: a sysext, a .deb/.rpm, an AUR build or a nix profile
|
||||
# installs a native `punktfunk-client`, and the plugin passes its absolute path here when that
|
||||
# is what it resolved. Both kinds take the same argv and share ~/.config/punktfunk, so the only
|
||||
# difference is the prefix in front of it.
|
||||
# installs a native `punktfunk-client` with the CLI as its sibling, and the plugin passes the
|
||||
# client's absolute path here when that is what it resolved.
|
||||
#
|
||||
# exec so the client IS the game process — when it exits, Steam ends the "game" and Gaming Mode
|
||||
# reclaims focus automatically (no manual refocus needed).
|
||||
run_client() {
|
||||
# run_cli execs the HEADLESS CLI (`punktfunk`); run_session execs the GTK/console shell
|
||||
# (`punktfunk-client`). Both live in the same place in both install kinds — /app/bin inside the
|
||||
# flatpak, reachable with `--command=`, and one bindir natively.
|
||||
run_cli() {
|
||||
if [ -n "${PF_CLIENT_BIN:-}" ]; then
|
||||
# `${VAR%/*}` rather than `dirname`: pure parameter expansion, so this works with no
|
||||
# PATH at all — which is the environment a Steam launch option can leave us in.
|
||||
exec "${PF_CLIENT_BIN%/*}/punktfunk" "$@"
|
||||
fi
|
||||
exec "$FLATPAK" run --arch=x86_64 --command=punktfunk "$APPID" "$@"
|
||||
}
|
||||
|
||||
run_session() {
|
||||
if [ -n "${PF_CLIENT_BIN:-}" ]; then
|
||||
exec "$PF_CLIENT_BIN" "$@"
|
||||
fi
|
||||
@@ -58,40 +66,35 @@ run_client() {
|
||||
# What we are about to run, for the log line each branch prints.
|
||||
CLIENT_LABEL="${PF_CLIENT_BIN:-$APPID}"
|
||||
|
||||
# --fullscreen: present the stream chrome-less and fullscreen (the client also auto-detects the
|
||||
# Deck/gamescope env, and ignores the flag harmlessly on older builds that predate it).
|
||||
# The console home: the client's own gamepad UI (host picker, pairing, add-host by address, the
|
||||
# library browser and the full settings screen). UNCHANGED from before this rework — the shell
|
||||
# binary already execs the session for `--browse`, so there is nothing to repoint here.
|
||||
if [ -n "${PF_BROWSE:-}" ]; then
|
||||
# The gamepad UI. BARE `--browse` (no PF_HOST) opens the console home — the self-contained
|
||||
# host picker + pairing + settings, gamepad-navigable — which is what the stateless, visible
|
||||
# library shortcut launches. `--browse <host>` opens straight into that host's library (the
|
||||
# per-host "open on screen" action). A streams a game, session end returns here, B quits.
|
||||
if [ -z "${PF_HOST:-}" ]; then
|
||||
echo "punktfunkrun: gamepad UI $CLIENT_LABEL --browse (console home)" >&2
|
||||
run_client --browse --fullscreen
|
||||
fi
|
||||
echo "punktfunkrun: library $CLIENT_LABEL --browse $PF_HOST" >&2
|
||||
if [ -n "${PF_MGMT:-}" ]; then
|
||||
run_client --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen
|
||||
fi
|
||||
run_client --browse "$PF_HOST" --fullscreen
|
||||
echo "punktfunkrun: gamepad UI $CLIENT_LABEL --browse (console home)" >&2
|
||||
run_session --browse --fullscreen
|
||||
fi
|
||||
|
||||
# Streaming modes need a host (browse above is the only host-less path).
|
||||
if [ -z "${PF_HOST:-}" ]; then
|
||||
echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2
|
||||
if [ -z "${PF_REF:-}" ]; then
|
||||
echo "punktfunkrun: PF_REF is not set (the plugin sets it as a launch option)" >&2
|
||||
exit 2
|
||||
fi
|
||||
# Trailing args shared by both streaming execs. A stretched connect budget rides along when the
|
||||
# plugin set one (it just fired Wake-on-LAN, so the host may still be resuming); an older flatpak
|
||||
# without --connect-timeout ignores the flag harmlessly (hand-scanned argv).
|
||||
|
||||
set -- --fullscreen
|
||||
if [ -n "${PF_CONNECT_TIMEOUT:-}" ]; then
|
||||
set -- --connect-timeout "$PF_CONNECT_TIMEOUT" "$@"
|
||||
if [ -n "${PF_PROFILE:-}" ]; then
|
||||
set -- --profile "$PF_PROFILE" "$@"
|
||||
fi
|
||||
if [ -n "${PF_LAUNCH:-}" ]; then
|
||||
# A pinned game: the id rides the session Hello and the host launches that title.
|
||||
echo "punktfunkrun: streaming $CLIENT_LABEL --connect $PF_HOST --launch $PF_LAUNCH" >&2
|
||||
run_client --connect "$PF_HOST" --launch "$PF_LAUNCH" "$@"
|
||||
|
||||
# REQUEST ACCESS RUNS SUPERVISED — no `--exec`. Under --exec the CLI BECOMES the session, so no
|
||||
# process survives to see the stream come up and record the host as paired; the CLI refuses the
|
||||
# combination outright rather than downgrading silently. This is safe for gamescope because
|
||||
# focus follows reaper's DESCENDANT TREE, not a single process, and `flatpak run`/`bwrap`
|
||||
# already sit between reaper and the client on every other path.
|
||||
if [ -n "${PF_REQUEST_ACCESS:-}" ]; then
|
||||
echo "punktfunkrun: request access $CLIENT_LABEL launch $PF_REF (waiting for approval)" >&2
|
||||
run_cli launch "$PF_REF" --request-access "$@"
|
||||
fi
|
||||
echo "punktfunkrun: streaming $CLIENT_LABEL --connect $PF_HOST" >&2
|
||||
run_client --connect "$PF_HOST" "$@"
|
||||
|
||||
# The ordinary stream. `--exec` is the documented gamescope-wrapper mode: the CLI becomes the
|
||||
# session, so the process tree stays flat and Steam's "game" ends exactly when the stream does.
|
||||
echo "punktfunkrun: streaming $CLIENT_LABEL launch $PF_REF" >&2
|
||||
run_cli launch "$PF_REF" --exec "$@"
|
||||
|
||||
+214
-725
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit checks for main.py's pure helpers — stdlib only, no Decky runtime needed.
|
||||
|
||||
Stubs the ``decky`` module (main.py imports it at module level), then asserts the
|
||||
avahi/TSV/error parsers against fixture strings. The LibraryError fixtures are pinned to
|
||||
the REAL Display strings in clients/linux/src/library.rs — if those are reworded, the
|
||||
classifier degrades to ``client-error`` and the matching assertion here fails on purpose.
|
||||
Stubs the ``decky`` module (main.py imports it at module level), then asserts the argv
|
||||
shapes, the exit-code mapping and the Steam VDF editor against fixtures.
|
||||
|
||||
python3 clients/decky/scripts/test-backend.py
|
||||
Needs Python >= 3.10 for `X | None` annotations — macOS ships 3.9, so run it explicitly:
|
||||
|
||||
python3.13 clients/decky/scripts/test-backend.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
@@ -40,136 +40,135 @@ def check(name: str, cond: bool):
|
||||
failures += 1
|
||||
|
||||
|
||||
# ---- _parse_library_tsv -----------------------------------------------------------------
|
||||
tsv = (
|
||||
"steam:570\tsteam\tDota 2\n"
|
||||
"custom:abc\tcustom\tTabs\tin\ttitle\n" # tabs inside the title survive (split max 2)
|
||||
"2 game(s)\n" # the count trailer has no tabs — self-skips
|
||||
# ---- _cli_argv: the flatpak app id must stay LAST ---------------------------------------
|
||||
#
|
||||
# `flatpak run --command=X <app-id> ARGS` — everything after the app id is the APP's argv, so
|
||||
# an app id that drifts left silently turns our flags into the client's. This is the shape the
|
||||
# deleted _session_argv used and the one thing about it that is easy to get wrong.
|
||||
main._client_argv = lambda: ["/usr/bin/flatpak", "run", "--arch=x86_64", "io.unom.Punktfunk"]
|
||||
main._flatpak = lambda: "/usr/bin/flatpak"
|
||||
check(
|
||||
"cli argv: flatpak form, app id last",
|
||||
main._cli_argv()
|
||||
== [
|
||||
"/usr/bin/flatpak",
|
||||
"run",
|
||||
"--arch=x86_64",
|
||||
"--command=punktfunk",
|
||||
"io.unom.Punktfunk",
|
||||
],
|
||||
)
|
||||
games = main._parse_library_tsv(tsv)
|
||||
check("tsv: two games parsed", len(games) == 2)
|
||||
check("tsv: fields", games[0] == {"id": "steam:570", "store": "steam", "title": "Dota 2"})
|
||||
check("tsv: tabs in title preserved", games[1]["title"] == "Tabs\tin\ttitle")
|
||||
check("tsv: empty input", main._parse_library_tsv("0 game(s)\n") == [])
|
||||
|
||||
# ---- _classify_library_error (fixtures = library.rs Display strings) --------------------
|
||||
check(
|
||||
"err: not-paired",
|
||||
main._classify_library_error(
|
||||
"library: The host didn't recognize this device. Pair with the host first — the "
|
||||
"library is authorized by this device's certificate (no token needed)."
|
||||
)
|
||||
== "not-paired",
|
||||
)
|
||||
check(
|
||||
"err: pin-mismatch",
|
||||
main._classify_library_error(
|
||||
"library: The host's certificate doesn't match the pinned fingerprint. "
|
||||
"Re-pair with a PIN to re-establish trust."
|
||||
)
|
||||
== "pin-mismatch",
|
||||
)
|
||||
check(
|
||||
"err: unreachable",
|
||||
main._classify_library_error(
|
||||
"library: Couldn't reach the host's management API: connection refused. Check the "
|
||||
"host is updated and reachable."
|
||||
)
|
||||
== "unreachable",
|
||||
)
|
||||
check(
|
||||
"err: http",
|
||||
main._classify_library_error("library: The management API returned HTTP 500.") == "http",
|
||||
)
|
||||
check(
|
||||
"err: outdated client (GTK init noise)",
|
||||
main._classify_library_error("cannot open display: \nGtk-WARNING: init failed")
|
||||
== "client-outdated",
|
||||
)
|
||||
check("err: generic fallback", main._classify_library_error("boom") == "client-error")
|
||||
|
||||
# ---- _parse_avahi_browse (incl. the new id/mgmt TXT keys) --------------------------------
|
||||
avahi = (
|
||||
"+;eth0;IPv4;living-room;_punktfunk._udp;local\n"
|
||||
"=;eth0;IPv4;living-room;_punktfunk._udp;local;lr.local;192.168.1.42;9777;"
|
||||
'"proto=punktfunk/1" "fp=aabbcc" "pair=required" "id=abc123" "mgmt=47990"\n'
|
||||
"=;eth0;IPv6;living-room;_punktfunk._udp;local;lr.local;fe80::1;9777;"
|
||||
'"proto=punktfunk/1" "fp=aabbcc" "pair=required" "id=abc123" "mgmt=47990"\n'
|
||||
"=;eth0;IPv4;bare-host;_punktfunk._udp;local;bh.local;192.168.1.77;9777;"
|
||||
'"proto=punktfunk/1" "fp=ddeeff" "pair=optional"\n'
|
||||
)
|
||||
hosts = main._parse_avahi_browse(avahi)
|
||||
check("avahi: two hosts (id-dedup, IPv4 preferred)", len(hosts) == 2)
|
||||
lr = next(h for h in hosts if h["name"] == "living-room")
|
||||
check("avahi: ipv4 wins", lr["host"] == "192.168.1.42")
|
||||
check("avahi: mgmt parsed", lr["mgmt"] == 47990)
|
||||
check("avahi: id parsed", lr["id"] == "abc123")
|
||||
bare = next(h for h in hosts if h["name"] == "bare-host")
|
||||
check("avahi: mgmt absent -> 0", bare["mgmt"] == 0)
|
||||
check("avahi: id absent -> empty", bare["id"] == "")
|
||||
|
||||
# ---- pins store (round-trip through the real methods, isolated HOME) --------------------
|
||||
import asyncio # noqa: E402
|
||||
# A native install: the CLI is the client binary's sibling. Absent => no CLI at all, which the
|
||||
# caller must see as "unavailable" rather than as an empty result.
|
||||
#
|
||||
# The fixture dir is torn down FIRST, not just created: leaving the sibling behind made the
|
||||
# "absent" assertion below pass only on the first run of the day and fail on every rerun.
|
||||
import shutil # noqa: E402
|
||||
|
||||
shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True)
|
||||
plugin = main.Plugin()
|
||||
pin = {
|
||||
"game_id": "steam:570",
|
||||
"title": "Dota 2",
|
||||
"store": "steam",
|
||||
"host_fp": "AABBCC",
|
||||
"host_id": "abc123",
|
||||
"host_name": "living-room",
|
||||
"host": "192.168.1.42",
|
||||
"port": 9777,
|
||||
"mgmt": 47990,
|
||||
"added_at": 1780000000,
|
||||
}
|
||||
dupe = dict(pin, title="Dota 2 again")
|
||||
junk = {"title": "no game id"}
|
||||
res = asyncio.run(plugin.set_pins([pin, dupe, junk]))
|
||||
check("pins: write ok", res.get("ok") is True)
|
||||
got = asyncio.run(plugin.get_pins())["pins"]
|
||||
check("pins: dedup + junk dropped", len(got) == 1)
|
||||
check("pins: unpaired without known-hosts", got[0]["paired"] is False)
|
||||
# Mark the host paired in the client's known-hosts store — get_pins must pick it up.
|
||||
cfg = main._client_config_dir()
|
||||
cfg.mkdir(parents=True, exist_ok=True)
|
||||
(cfg / "client-known-hosts.json").write_text(
|
||||
'{"hosts": [{"name": "living-room", "addr": "192.168.1.42", "port": 9777, '
|
||||
'"fp_hex": "aabbcc", "paired": true}]}'
|
||||
)
|
||||
got = asyncio.run(plugin.get_pins())["pins"]
|
||||
check("pins: paired via known-hosts fp (case-insensitive)", got[0]["paired"] is True)
|
||||
shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True)
|
||||
shutil.rmtree("/tmp/pf-test-native", ignore_errors=True)
|
||||
tmp = Path("/tmp/pf-test-native/bin")
|
||||
tmp.mkdir(parents=True, exist_ok=True)
|
||||
(tmp / "punktfunk-client").write_text("")
|
||||
main._client_argv = lambda: [str(tmp / "punktfunk-client")]
|
||||
check("cli argv: native without a sibling CLI is None", main._cli_argv() is None)
|
||||
(tmp / "punktfunk").write_text("")
|
||||
check("cli argv: native sibling found", main._cli_argv() == [str(tmp / "punktfunk")])
|
||||
|
||||
# ---- `--list-audio` parsing (the settings tab's device pickers) --------------------------
|
||||
sinks, sources = main._parse_audio_endpoints(
|
||||
"sink\talsa_output.pci-0000_04_00.6.analog-stereo\tSteam Deck Speakers\n"
|
||||
"sink\tbluez_output.AC_12_2F.1\tWH-1000XM4\n"
|
||||
"source\talsa_input.pci-0000_04_00.6.analog-stereo\tSteam Deck Microphone\n"
|
||||
# ---- _cli_error: the CLI's exit-code contract -------------------------------------------
|
||||
#
|
||||
# Exit 5 + `unknown command` is how a client too old for a verb announces itself — the ONE
|
||||
# signature the panel turns into "update the client" plus the button that fixes it. Getting it
|
||||
# wrong makes an out-of-date client look like a broken plugin.
|
||||
check(
|
||||
"err: unknown verb => client-outdated",
|
||||
main._cli_error(5, 'unknown command "discover"\n\npunktfunk — the Punktfunk client')
|
||||
== "client-outdated",
|
||||
)
|
||||
check("audio: sinks parsed", [d["name"] for d in sinks] == [
|
||||
"alsa_output.pci-0000_04_00.6.analog-stereo", "bluez_output.AC_12_2F.1"
|
||||
])
|
||||
check("audio: sources parsed", len(sources) == 1)
|
||||
check("audio: description kept", sinks[1]["description"] == "WH-1000XM4")
|
||||
check(
|
||||
"err: exit 5 without that phrase is NOT outdated",
|
||||
main._cli_error(5, 'no saved host matches "desk"') == "unresolved",
|
||||
)
|
||||
check("err: connect failed", main._cli_error(2, "unreachable 10.0.0.1:9777") == "unreachable")
|
||||
check("err: trust rejected", main._cli_error(3, "wrong PIN") == "refused")
|
||||
check("err: needs a person", main._cli_error(6, "pair it first") == "needs-pairing")
|
||||
check("err: nothing ran", main._cli_error(-1, "") == "client-unavailable")
|
||||
check("err: unmapped code falls back", main._cli_error(4, "renderer") == "client-error")
|
||||
|
||||
# Junk the picker must not offer: no node.name is unusable (it is the id that gets stored), a
|
||||
# short line is malformed, and an unknown kind belongs to neither list. A blank description
|
||||
# falls back to the name so no entry renders unlabelled.
|
||||
sinks, sources = main._parse_audio_endpoints(
|
||||
"sink\t\tNo node name\n"
|
||||
"sink\tonly-two-columns\n"
|
||||
"monitor\tsome.monitor\tNot a sink or source\n"
|
||||
"source\tbare.node\t\n"
|
||||
"\n"
|
||||
# ---- _cli_json: a zero exit with junk on stdout is a FAILURE, not an empty result --------
|
||||
import asyncio # noqa: E402
|
||||
|
||||
|
||||
def _fake_cli(rc: int, out: str, err: str = ""):
|
||||
async def run(_args, timeout=20.0):
|
||||
return rc, out, err
|
||||
|
||||
return run
|
||||
|
||||
|
||||
main._run_cli = _fake_cli(0, '{"hosts": [{"name": "desk"}]}')
|
||||
got = asyncio.run(main._cli_json(["discover", "--json"]))
|
||||
check("json: payload merged under ok", got == {"ok": True, "hosts": [{"name": "desk"}]})
|
||||
|
||||
main._run_cli = _fake_cli(0, "not json at all")
|
||||
got = asyncio.run(main._cli_json(["discover", "--json"]))
|
||||
check("json: unparseable stdout is an error, not an empty list", got["ok"] is False)
|
||||
check("json: ...and says so specifically", got["error"] == "client-error")
|
||||
|
||||
main._run_cli = _fake_cli(5, "", 'unknown command "discover"')
|
||||
got = asyncio.run(main._cli_json(["discover", "--json"]))
|
||||
check("json: old client surfaces as client-outdated", got["error"] == "client-outdated")
|
||||
check("json: detail carries the CLI's own last line", "unknown command" in got["detail"])
|
||||
|
||||
# ---- _field_from (flatpak info parsing, drives the client update check) ------------------
|
||||
info = " ID: io.unom.Punktfunk\n Origin: punktfunk-origin\n Commit: abc123def\n"
|
||||
check("field: commit", main._field_from(info, "Commit") == "abc123def")
|
||||
check("field: origin", main._field_from(info, "Origin") == "punktfunk-origin")
|
||||
check("field: absent", main._field_from(info, "Nope") == "")
|
||||
|
||||
# ---- _looks_outdated (the GTK-init signature of a client predating a headless flag) ------
|
||||
check("outdated: gtk init noise", main._looks_outdated("cannot open display: \nGtk-WARNING") is True)
|
||||
check("outdated: an ordinary error is not", main._looks_outdated("connection refused") is False)
|
||||
|
||||
# ---- _semver_tuple (plugin update comparison) --------------------------------------------
|
||||
check("semver: plain", main._semver_tuple("1.2.3") == (1, 2, 3))
|
||||
check("semver: pre-release suffix dropped", main._semver_tuple("1.2.3-rc1") == (1, 2, 3))
|
||||
check("semver: short forms pad", main._semver_tuple("2") == (2, 0, 0))
|
||||
check("semver: ordering", main._semver_tuple("0.10.0") > main._semver_tuple("0.9.9"))
|
||||
|
||||
# ---- _upsert_configset_entry (Steam Input layout binding) --------------------------------
|
||||
#
|
||||
# Untested until now, and the riskiest thing that survived the cut: it edits a file holding
|
||||
# HUNDREDS of other games' controller bindings, in place. Every assertion below is about not
|
||||
# touching them.
|
||||
empty = main._upsert_configset_entry("", "punktfunk", "template", "punktfunk.vdf")
|
||||
check("vdf: builds the skeleton when the file is new", '"controller_config"' in empty)
|
||||
check("vdf: the entry lands", '"punktfunk"' in empty and '"punktfunk.vdf"' in empty)
|
||||
|
||||
existing = (
|
||||
'"controller_config"\n'
|
||||
"{\n"
|
||||
'\t"halflife2"\n'
|
||||
"\t{\n"
|
||||
'\t\t"template"\t\t"other.vdf"\n'
|
||||
"\t}\n"
|
||||
"}\n"
|
||||
)
|
||||
check("audio: junk lines dropped", sinks == [])
|
||||
check("audio: blank description falls back to the node name", sources == [
|
||||
{"name": "bare.node", "description": "bare.node"}
|
||||
])
|
||||
added = main._upsert_configset_entry(existing, "punktfunk", "template", "punktfunk.vdf")
|
||||
check("vdf: an existing game's entry survives insertion", '"halflife2"' in added)
|
||||
check("vdf: ours is inserted", '"punktfunk"' in added)
|
||||
|
||||
# Re-running must REPLACE our block, not accumulate a second one (this runs on every plugin
|
||||
# session gated only by a localStorage marker, so idempotence is the whole contract).
|
||||
twice = main._upsert_configset_entry(added, "punktfunk", "template", "punktfunk.vdf")
|
||||
check("vdf: idempotent", twice.count('"punktfunk"\n') == 1)
|
||||
check("vdf: neighbour still intact after the rewrite", '"halflife2"' in twice)
|
||||
|
||||
# Steam keys non-Steam games by their LOWERCASE name, and files on disk may carry either case —
|
||||
# a case-sensitive match would append a duplicate the game never reads.
|
||||
mixed = existing.replace('"halflife2"', '"Punktfunk"')
|
||||
replaced = main._upsert_configset_entry(mixed, "punktfunk", "template", "punktfunk.vdf")
|
||||
check("vdf: matches an existing key case-insensitively", replaced.count("unktfunk\"\n") == 1)
|
||||
|
||||
print()
|
||||
if failures:
|
||||
|
||||
+92
-218
@@ -1,95 +1,94 @@
|
||||
// Bridge to the Python backend (main.py) + shared types.
|
||||
//
|
||||
// Every call here is a thin shell over the headless `punktfunk` CLI, so these types are the
|
||||
// CLI's JSON shapes rather than anything this plugin invents. That is deliberate: the plugin
|
||||
// used to model the client's stores itself and drifted from them with every field the client
|
||||
// added.
|
||||
|
||||
import { callable } from "@decky/api";
|
||||
|
||||
export interface Host {
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
pair: string; // "required" | "optional" — the HOST's policy
|
||||
fp: string; // host cert SHA-256 fingerprint (lowercase hex) from the mDNS advert
|
||||
proto: string; // advertised protocol, e.g. "punktfunk/1"
|
||||
paired: boolean; // whether THIS device has already PIN-paired this host (by fingerprint)
|
||||
id: string; // the host's stable instance id (mDNS TXT `id`; "" when not advertised)
|
||||
mgmt: number; // management-API port (mDNS TXT `mgmt`; 0 = not advertised → default 47990)
|
||||
os: string; // OS-identity chain (mDNS TXT `os`, e.g. "linux/fedora/bazzite"); "" on older hosts
|
||||
}
|
||||
|
||||
// One title from a host's game library (the flatpak client's --library TSV, parsed by the
|
||||
// backend). `id` is store-qualified (steam:<appid> / custom:<id>) and doubles as the
|
||||
// launch handle (PF_LAUNCH → the session Hello).
|
||||
export interface GameEntry {
|
||||
/** A settings profile as the CLI resolves it — ids are dangling-checked and names attached. */
|
||||
export interface Profile {
|
||||
id: string;
|
||||
store: string; // "steam" | "custom" | "heroic" | "lutris" | …
|
||||
title: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface LibraryResult {
|
||||
ok: boolean;
|
||||
games?: GameEntry[];
|
||||
// "flatpak-not-found" | "timeout" | "not-paired" | "pin-mismatch" | "unreachable" |
|
||||
// "http" | "client-outdated" | "client-error"
|
||||
error?: string;
|
||||
detail?: string; // the client's own one-line reason, for the generic error copy
|
||||
}
|
||||
|
||||
// A pinned game — a one-tap stream row in the QAM. The host is identified primarily by
|
||||
// cert fingerprint (survives IP changes; pairing is fp-keyed too), with the stored
|
||||
// address as the launch fallback when the host isn't currently advertising.
|
||||
export interface PinnedGame {
|
||||
game_id: string;
|
||||
title: string;
|
||||
store: string;
|
||||
host_fp: string;
|
||||
host_id: string;
|
||||
host_name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
mgmt: number;
|
||||
added_at: number; // unix seconds
|
||||
paired?: boolean; // annotated by get_pins from the client's known-hosts store
|
||||
}
|
||||
|
||||
export interface PairResult {
|
||||
ok: boolean;
|
||||
fp?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// A host in the SHARED saved-hosts store (client-known-hosts.json) — the same file the desktop
|
||||
// client reads/writes, so add/rename/pair in either surface shows up in both. `online` comes
|
||||
// from a mDNS-INDEPENDENT reachability probe (a Tailscale/VPN host isn't shown offline just
|
||||
// because it doesn't advertise); `null` means reachability is unknown (probe skipped or a client
|
||||
// too old for `--list-hosts`, which then also can't probe).
|
||||
export interface SavedHost {
|
||||
/**
|
||||
* A host answering on mDNS right now (`punktfunk discover --json`).
|
||||
*
|
||||
* `saved`/`paired` are annotated BY THE CLI against the saved-hosts store — fingerprint first,
|
||||
* address second. The plugin does not join the two lists itself; that rule living in one place
|
||||
* is what stops this surface disagreeing with the desktop client about the same box.
|
||||
*/
|
||||
export interface DiscoveredHost {
|
||||
name: string;
|
||||
addr: string;
|
||||
port: number;
|
||||
fp_hex: string; // host cert fingerprint (lowercase hex); "" for a not-yet-paired manual entry
|
||||
fp: string; // advertised cert fingerprint (lowercase hex); "" when not advertised
|
||||
pair: string; // the HOST's policy: "required" | "optional"
|
||||
id: string; // the host's advertised stable id; "" when not advertised
|
||||
mgmt: number; // management-API port; 0 = not advertised
|
||||
os: string; // OS-identity chain, e.g. "linux/fedora/bazzite"; "" on older hosts
|
||||
saved: boolean;
|
||||
paired: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A host in the shared saved-hosts store (`punktfunk hosts list --probe --json`) — the same
|
||||
* `client-known-hosts.json` the desktop client owns.
|
||||
*
|
||||
* `online` comes from a mDNS-INDEPENDENT probe, so a host reached over Tailscale/VPN is not
|
||||
* shown offline merely because it never advertises; `null` means the probe was skipped.
|
||||
*
|
||||
* `profile` is the host's DEFAULT binding, which a plain connect applies silently. It is not
|
||||
* the same thing as `pinned_profiles`, which are the cards a user chose to surface. Both come
|
||||
* back already resolved against the profile catalog, so this plugin never opens it.
|
||||
*/
|
||||
export interface SavedHost {
|
||||
id: string | null; // the record's stable id — the reference a launch should use
|
||||
name: string;
|
||||
addr: string;
|
||||
port: number;
|
||||
fp_hex: string; // "" for a placeholder saved by address with no pin yet
|
||||
paired: boolean;
|
||||
mac: string[];
|
||||
// OS-identity chain learned by the desktop client; optional because the installed
|
||||
// flatpak client may predate the field.
|
||||
os?: string;
|
||||
os: string;
|
||||
last_used: number | null;
|
||||
clipboard_sync: boolean;
|
||||
profile: Profile | null;
|
||||
pinned_profiles: Profile[];
|
||||
online: boolean | null;
|
||||
}
|
||||
|
||||
export interface HostsResult {
|
||||
ok: boolean;
|
||||
hosts: SavedHost[];
|
||||
probed: boolean;
|
||||
fallback?: boolean; // true when read straight off disk (client too old for --list-hosts)
|
||||
}
|
||||
|
||||
// The result of a host-store mutation (add/edit/forget). `error` is a stable code:
|
||||
// "client-unavailable" (flatpak missing) | "client-outdated" (client predates the mode) |
|
||||
// "unreachable"/"http"/… (from the client) | "client-error" (generic; see `detail`).
|
||||
export interface MutationResult {
|
||||
/**
|
||||
* Every backend call answers in this shape. `error` is a stable code, never prose:
|
||||
*
|
||||
* - `client-unavailable` — no client is installed, or the call never ran
|
||||
* - `client-outdated` — the installed client predates the verb (exit 5 + `unknown command`)
|
||||
* - `unreachable` — the host did not answer
|
||||
* - `refused` — trust rejected: a wrong PIN, or a fingerprint that already differs
|
||||
* - `needs-pairing` — the CLI refused because it needs a person
|
||||
* - `unresolved` — nothing matched what was named
|
||||
* - `client-error` — anything else; `detail` carries the CLI's own last line
|
||||
*/
|
||||
export interface CliResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface DiscoverResult extends CliResult {
|
||||
hosts?: DiscoveredHost[];
|
||||
}
|
||||
|
||||
export interface HostsResult extends CliResult {
|
||||
hosts?: SavedHost[];
|
||||
}
|
||||
|
||||
export interface PairResult extends CliResult {
|
||||
fp?: string;
|
||||
}
|
||||
|
||||
export interface RunnerInfo {
|
||||
runner: string; // absolute path to bin/punktfunkrun.sh
|
||||
app_id: string; // flatpak app id
|
||||
@@ -101,99 +100,6 @@ export interface RunnerInfo {
|
||||
client_bin?: string;
|
||||
}
|
||||
|
||||
// The flatpak client's settings JSON — the SAME `client-gtk-settings.json` the desktop client
|
||||
// and the console's settings screen own, so a value changed in any of them shows in the others.
|
||||
//
|
||||
// Every field the client's `Settings` struct persists is modelled here EXCEPT the ones that
|
||||
// cannot be answered from a plugin backend or aren't settings at all:
|
||||
// • `forward_pad` — which physical pad is player 1. Needs SDL's live device list, which only
|
||||
// the client process has; there is no CLI that enumerates pads.
|
||||
// • `last_window_w/h` — the session's remembered window size, written BY the client, not a
|
||||
// preference anyone sets.
|
||||
// Both round-trip untouched: get_settings returns the whole parsed file, patches are object
|
||||
// spreads, and set_settings merges onto what's on disk.
|
||||
//
|
||||
// Optional (`?`) marks a key the client writes with a serde `default`, so a store written before
|
||||
// that key existed simply lacks it. Read those through the same fallback the client uses —
|
||||
// `?? true` for the default-on ones, never `!!` — or a pre-existing file reads as "off" here
|
||||
// while the stream runs with it on.
|
||||
export interface StreamSettings {
|
||||
// ---- Stream mode ----
|
||||
width: number; // 0 = native
|
||||
height: number; // 0 = native
|
||||
refresh_hz: number; // 0 = native
|
||||
render_scale?: number; // render-resolution multiplier; 1.0 = native (absent in pre-scale files)
|
||||
bitrate_kbps: number; // 0 = host default
|
||||
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
|
||||
// Stream mode follows the session window instead of width/height, renegotiating on resize.
|
||||
// Overrides width/height while on; degenerates to the display's native mode on fullscreen.
|
||||
match_window?: boolean;
|
||||
|
||||
// ---- Video ----
|
||||
codec?: string; // "auto" | "hevc" | "h264" | "av1" | "pyrowave" (absent in pre-codec files)
|
||||
decoder?: string; // "auto" | "vulkan" | "vaapi" | "software"
|
||||
hdr_enabled?: boolean; // default ON — advertise 10-bit/HDR10
|
||||
enable_444?: boolean; // default off — ask for full chroma
|
||||
adapter?: string; // decode/present GPU by marketing name; "" = automatic
|
||||
|
||||
// ---- Presentation ----
|
||||
// What the client optimises for when a decoded frame is ready: "latency" | "smooth". Shared
|
||||
// with the Apple and Android clients under this name, so one profile reads the same everywhere.
|
||||
present_priority?: string;
|
||||
smooth_buffer?: number; // frames held back under "smooth"; 0 = Automatic (resolves to 2), else 1–3
|
||||
vsync?: boolean; // default ON — tear-free; off asks for a tearing present mode (best-effort)
|
||||
allow_vrr?: boolean; // default ON — let a VRR panel refresh in step with the stream
|
||||
|
||||
// ---- Audio ----
|
||||
audio_channels?: number; // 2 (stereo) | 6 (5.1) | 8 (7.1)
|
||||
speaker_device?: string; // PipeWire node.name for playback; "" = system default
|
||||
mic_enabled: boolean;
|
||||
mic_device?: string; // PipeWire node.name for capture; "" = system default
|
||||
echo_cancel?: boolean; // default ON; only meaningful while mic_enabled
|
||||
|
||||
// ---- Controllers ----
|
||||
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
|
||||
// Forward this device's controllers at all. Absent in pre-forwarding files, where the
|
||||
// client's own serde default (true) applies — so `?? true` at every read, never `!!`.
|
||||
gamepad_forwarding?: boolean;
|
||||
|
||||
// ---- Touchscreen, mouse & keyboard ----
|
||||
touch_mode?: string; // "trackpad" | "pointer" | "touch"
|
||||
mouse_mode?: string; // "capture" | "desktop"
|
||||
invert_scroll?: boolean;
|
||||
// Whether the session grabs the keyboard so Alt+Tab/Super reach the host.
|
||||
inhibit_shortcuts: boolean;
|
||||
|
||||
// ---- Interface & behaviour ----
|
||||
// Stats-overlay tier: "off" | "compact" | "normal" | "detailed". Absent in a pre-tier file,
|
||||
// which resolves through `show_stats` — read both the way the client's
|
||||
// `Settings::stats_verbosity` does, and write both the way `set_stats_verbosity` does.
|
||||
stats_verbosity?: string;
|
||||
// The legacy on/off the tier supersedes; kept written in sync so a client that predates the
|
||||
// tiers still honours an Off chosen here.
|
||||
show_stats?: boolean;
|
||||
fullscreen_on_stream?: boolean;
|
||||
auto_wake?: boolean; // default ON — Wake-on-LAN a sleeping host before connecting
|
||||
library_enabled?: boolean; // the CLIENT's own library browser (this plugin has its own)
|
||||
}
|
||||
|
||||
// One audio endpoint from the client's enumeration: the stable id that gets stored, plus the
|
||||
// human name to show.
|
||||
export interface AudioDevice {
|
||||
name: string; // PipeWire node.name — what `speaker_device` / `mic_device` store
|
||||
description: string; // human label ("Steam Deck Speakers")
|
||||
}
|
||||
|
||||
// What the device pickers need, read from the session binary (`--list-adapters` / `--list-audio`).
|
||||
// `ok: false` = the session binary couldn't be run or failed; every list is then empty and the
|
||||
// pickers stay on their stored value rather than pretending the device is gone.
|
||||
export interface DeviceLists {
|
||||
ok: boolean;
|
||||
adapters: string[]; // Vulkan physical devices, discrete first
|
||||
sinks: AudioDevice[]; // playback endpoints
|
||||
sources: AudioDevice[]; // capture endpoints
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
current: string; // installed PLUGIN version (package.json)
|
||||
latest: string; // newest plugin version in our registry for this channel
|
||||
@@ -229,21 +135,30 @@ export interface ShortcutArt {
|
||||
icon_path: string;
|
||||
}
|
||||
|
||||
export const discover = callable<[], Host[]>("discover");
|
||||
// ---- The four CLI shells --------------------------------------------------------------
|
||||
|
||||
/** Browse the LAN over mDNS. Bounded by the CLI (3 s) plus a cold-start allowance. */
|
||||
export const discover = callable<[], DiscoverResult>("discover");
|
||||
/** The saved hosts, probed for reachability, with profiles and pinned cards resolved. */
|
||||
export const hosts = callable<[], HostsResult>("hosts");
|
||||
/** The PIN ceremony. `refused` = wrong PIN or a host that isn't armed. */
|
||||
export const pair = callable<
|
||||
[host: string, port: number, pin: string, name: string],
|
||||
[addr: string, port: number, pin: string, name: string],
|
||||
PairResult
|
||||
>("pair");
|
||||
// Fetch a paired host's game library (headless flatpak --library; can take seconds on a
|
||||
// cold client start — show a spinner). Pass fp whenever known so the pin can't degrade.
|
||||
export const library = callable<
|
||||
[host: string, mgmt_port: number, fp: string],
|
||||
LibraryResult
|
||||
>("library");
|
||||
export const getPins = callable<[], { pins: PinnedGame[] }>("get_pins");
|
||||
export const setPins = callable<[pins: PinnedGame[]], { ok: boolean; error?: string }>(
|
||||
"set_pins",
|
||||
);
|
||||
/**
|
||||
* Step 1 of request access: save the host with its ADVERTISED fingerprint, pinned but unpaired.
|
||||
* The launch that follows pins the same fingerprint, which is the only thing standing between a
|
||||
* 185 s wait for approval and an impostor answering for the host. Idempotent; a host already
|
||||
* saved under a DIFFERENT fingerprint comes back `refused` rather than being overwritten.
|
||||
*/
|
||||
export const trustHost = callable<
|
||||
[addr: string, port: number, fp: string, name: string],
|
||||
CliResult
|
||||
>("trust_host");
|
||||
|
||||
// ---- Steam / plugin business (only a Decky plugin can do these) ------------------------
|
||||
|
||||
export const runnerInfo = callable<[], RunnerInfo>("runner_info");
|
||||
export const shortcutArt = callable<[], ShortcutArt>("shortcut_art");
|
||||
// Install the Steam Input layout (native touchscreen `ts_n` + gamepad passthrough) and point our
|
||||
@@ -254,48 +169,7 @@ export const applyControllerConfig = callable<
|
||||
[name: string],
|
||||
{ ok: boolean; applied?: string[]; errors?: string[]; accounts?: number; error?: string; detail?: string }
|
||||
>("apply_controller_config");
|
||||
export const getSettings = callable<[], StreamSettings>("get_settings");
|
||||
export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>(
|
||||
"set_settings",
|
||||
);
|
||||
// GPUs + audio endpoints for the device pickers. Costs a subprocess that initialises Vulkan and
|
||||
// PipeWire, so it is called ONCE when the settings tab mounts and never on the launch path.
|
||||
export const listDevices = callable<[], DeviceLists>("list_devices");
|
||||
// The same, bypassing the backend's cache — for the user who just plugged in a headset.
|
||||
export const refreshDevices = callable<[], DeviceLists>("refresh_devices");
|
||||
export const killStream = callable<[], { ok: boolean }>("kill_stream");
|
||||
// Send a Wake-on-LAN magic packet to a saved host (headless flatpak --wake) so a sleeping host is
|
||||
// up by the time the stream connects. The MAC is looked up from the flatpak client's own
|
||||
// known-hosts store; `ok: false` (no-op) when none has been learned yet. Fire before launching.
|
||||
export const wake = callable<[host: string, port: number], { ok: boolean; error?: string }>(
|
||||
"wake",
|
||||
);
|
||||
// ---- Shared saved-hosts store (the SAME client-known-hosts.json the desktop client owns) ----
|
||||
// The saved hosts, each annotated with a live (mDNS-independent) `online` probe when `probe` is
|
||||
// true. Falls back to a direct JSON read (no reachability) on a client too old for --list-hosts.
|
||||
export const listHosts = callable<[probe: boolean], HostsResult>("list_hosts");
|
||||
// Save a host by address (survives mDNS-blind networks). `fp` empty = unpaired placeholder to
|
||||
// pair next; a later pair replaces it with the fingerprinted entry.
|
||||
export const addHost = callable<[target: string, name: string, fp: string], MutationResult>(
|
||||
"add_host",
|
||||
);
|
||||
// Rename and/or re-point a saved host. `selector` = its fingerprint (survives IP change) or
|
||||
// current addr[:port]; empty fields are left untouched.
|
||||
export const editHost = callable<
|
||||
[selector: string, name: string, addr: string, port: number],
|
||||
MutationResult
|
||||
>("edit_host");
|
||||
// Remove a saved host by fingerprint or addr[:port] (idempotent).
|
||||
export const forgetHost = callable<[selector: string], MutationResult>("forget_host");
|
||||
// Reset this device's Punktfunk state (saved hosts + stream settings + pins); KEEPS the client
|
||||
// identity so the box isn't seen as new everywhere (re-pairing re-adds hosts).
|
||||
export const resetConfig = callable<[], { ok: boolean; error?: string }>("reset_config");
|
||||
// Reachability of one host[:port] via the client's mDNS-independent QUIC probe (a "test address"
|
||||
// check). `{ ok: true, online }` when determined, else `{ ok: false, error }`.
|
||||
export const probeHost = callable<
|
||||
[target: string],
|
||||
{ ok: boolean; online?: boolean; error?: string }
|
||||
>("probe_host");
|
||||
export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
|
||||
// Update the client by whichever route its install supports: `flatpak update --user` for the
|
||||
// flatpak, `punktfunk-client --apply-update` (the packaged root helper) for a one-tap-capable
|
||||
|
||||
+194
-347
@@ -1,18 +1,14 @@
|
||||
// Shared state hooks + user actions for the QAM panel and the fullscreen page.
|
||||
// Shared state hooks + user actions for the QAM panel.
|
||||
import { toaster } from "@decky/api";
|
||||
import { Navigation } from "@decky/ui";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
checkUpdate,
|
||||
discover,
|
||||
GameEntry,
|
||||
getPins,
|
||||
Host,
|
||||
listHosts,
|
||||
PinnedGame,
|
||||
resetConfig,
|
||||
DiscoveredHost,
|
||||
hosts as listHosts,
|
||||
Profile,
|
||||
SavedHost,
|
||||
setPins as setPinsBackend,
|
||||
updateClient,
|
||||
UpdateInfo,
|
||||
} from "./backend";
|
||||
@@ -37,19 +33,191 @@ declare global {
|
||||
// PluginInstallType.UPDATE in decky-loader's browser.py (INSTALL=0/REINSTALL=1/UPDATE=2/…).
|
||||
const INSTALL_TYPE_UPDATE = 2;
|
||||
|
||||
/**
|
||||
* How far this device has got with a host. The three states are what the row says under the
|
||||
* name, and which of them a host is in decides whether pressing it streams or opens the trust
|
||||
* sheet.
|
||||
*
|
||||
* - `paired` — the host approved this device (a PIN ceremony, or request access).
|
||||
* - `trusted` — its fingerprint is pinned but nobody has approved us yet. Streams work if
|
||||
* the host's policy is `optional`; under `required` the connect parks.
|
||||
* - `needs-access` — no pinned fingerprint. Not streamable until the trust sheet runs.
|
||||
*/
|
||||
export type TrustState = "paired" | "trusted" | "needs-access";
|
||||
|
||||
/**
|
||||
* One host as the panel shows it — the union of the saved store and the live mDNS browse.
|
||||
*
|
||||
* A saved host is ONLINE when it either advertises or answers the reachability probe, so a box
|
||||
* reached over Tailscale/VPN stops reading as offline. Discovered hosts that aren't saved are
|
||||
* appended as extra rows.
|
||||
*/
|
||||
export interface HostView {
|
||||
name: string;
|
||||
addr: string;
|
||||
port: number;
|
||||
/**
|
||||
* The fingerprint PINNED ON THE RECORD. "" means nothing is pinned, which is exactly what
|
||||
* makes a host unstreamable — the session binary refuses a pinless connect.
|
||||
*
|
||||
* Deliberately NOT filled in from a live advert. A host saved by address that happens to be
|
||||
* advertising right now still has an empty pin on disk, and borrowing the advert's here would
|
||||
* draw it as ready to stream while every launch refused for want of a fingerprint. What the
|
||||
* advert offers is [`advertisedFp`], and moving it onto the record is a trust decision the
|
||||
* user makes in the sheet.
|
||||
*/
|
||||
fp: string;
|
||||
/** What the host is advertising right now, if anything — what request access would pin. */
|
||||
advertisedFp: string;
|
||||
/**
|
||||
* The host is answering at an address its record does not carry — it changed DHCP lease.
|
||||
*
|
||||
* This matters because a launch names the host by [`ref`], and the CLI dials whatever address
|
||||
* the RECORD holds. So the row would show the live address and dial the dead one. The record
|
||||
* has to be re-pointed before such a host can stream; `startStream` does it.
|
||||
*/
|
||||
moved: boolean;
|
||||
paired: boolean;
|
||||
online: boolean;
|
||||
saved: boolean;
|
||||
/** The advert's policy ("required"|"optional"); "" when the host isn't advertising. */
|
||||
pairPolicy: string;
|
||||
/** OS-identity chain (live advert preferred, else the stored one); "" unknown. */
|
||||
os: string;
|
||||
/**
|
||||
* What a launch should NAME this host by: the record's stable id, which survives renames and
|
||||
* DHCP moves, falling back to `addr:port` for a row that has no record yet (a discovered host
|
||||
* the trust sheet is about to save, or a client too old to have minted ids).
|
||||
*/
|
||||
ref: string;
|
||||
/** The host's default profile binding — applied silently by a plain connect, not a card. */
|
||||
profile: Profile | null;
|
||||
/** The cards to render nested under this host; already resolved against the catalog. */
|
||||
pinnedProfiles: Profile[];
|
||||
lastUsed: number | null;
|
||||
}
|
||||
|
||||
export function trustState(v: HostView): TrustState {
|
||||
if (v.paired) return "paired";
|
||||
return v.fp ? "trusted" : "needs-access";
|
||||
}
|
||||
|
||||
/**
|
||||
* Must this host go through the trust sheet before it can stream?
|
||||
*
|
||||
* A pinned fingerprint is the ONLY rule. The session binary refuses a pinless connect, so a row
|
||||
* without one can offer nothing but a button that fails; with one, the connect is verified and
|
||||
* the host either admits it or parks it for an operator. The old rule also consulted the
|
||||
* advertised policy for unsaved hosts, which made the answer depend on which of two lists a row
|
||||
* came from — the same box could read differently before and after being saved.
|
||||
*/
|
||||
export function needsPair(v: HostView): boolean {
|
||||
return v.fp === "";
|
||||
}
|
||||
|
||||
function advertMatchesSaved(a: DiscoveredHost, s: SavedHost): boolean {
|
||||
return (
|
||||
(!!s.fp_hex && !!a.fp && s.fp_hex.toLowerCase() === a.fp.toLowerCase()) ||
|
||||
(s.addr === a.addr && s.port === a.port)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Join the saved store and the live browse into the rows the panel draws.
|
||||
*
|
||||
* Fingerprint first, address second — a host that moved DHCP lease still matches its record,
|
||||
* and a different box that inherited the old address does not inherit its pairing. The CLI's
|
||||
* `discover` annotates `saved`/`paired` by exactly this rule too, so the two can't disagree.
|
||||
*/
|
||||
export function mergeHosts(saved: SavedHost[], discovered: DiscoveredHost[]): HostView[] {
|
||||
const views: HostView[] = saved.map((s) => {
|
||||
// Prefer a live advert's address: the host may have moved since it was last saved.
|
||||
const advert = discovered.find((a) => advertMatchesSaved(a, s));
|
||||
return {
|
||||
name: s.name || s.addr,
|
||||
addr: advert?.addr ?? s.addr,
|
||||
port: advert?.port ?? s.port,
|
||||
fp: s.fp_hex,
|
||||
advertisedFp: advert?.fp ?? "",
|
||||
moved: !!advert && (advert.addr !== s.addr || advert.port !== s.port),
|
||||
paired: s.paired,
|
||||
online: !!advert || s.online === true,
|
||||
saved: true,
|
||||
pairPolicy: advert?.pair ?? "",
|
||||
os: advert?.os || s.os || "",
|
||||
ref: s.id || `${advert?.addr ?? s.addr}:${advert?.port ?? s.port}`,
|
||||
profile: s.profile,
|
||||
pinnedProfiles: s.pinned_profiles ?? [],
|
||||
lastUsed: s.last_used,
|
||||
};
|
||||
});
|
||||
for (const a of discovered) {
|
||||
if (saved.some((s) => advertMatchesSaved(a, s))) {
|
||||
continue; // already rendered as its saved row, with a live pip
|
||||
}
|
||||
views.push({
|
||||
name: a.name,
|
||||
addr: a.addr,
|
||||
port: a.port,
|
||||
// No record, so nothing is pinned — whatever it advertises is an OFFER, not a pin.
|
||||
fp: "",
|
||||
advertisedFp: a.fp,
|
||||
moved: false, // no record, so nothing to be stale
|
||||
paired: a.paired,
|
||||
online: true,
|
||||
saved: false,
|
||||
pairPolicy: a.pair,
|
||||
os: a.os,
|
||||
ref: `${a.addr}:${a.port}`,
|
||||
profile: null,
|
||||
pinnedProfiles: [],
|
||||
lastUsed: null,
|
||||
});
|
||||
}
|
||||
return views.sort(sortRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Online first, then most recently used, then by name. The host you streamed last night should
|
||||
* be the first thing under your thumb; a host that is off right now should never be.
|
||||
*/
|
||||
function sortRows(a: HostView, b: HostView): number {
|
||||
if (a.online !== b.online) return a.online ? -1 : 1;
|
||||
if ((a.lastUsed ?? 0) !== (b.lastUsed ?? 0)) return (b.lastUsed ?? 0) - (a.lastUsed ?? 0);
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Discovery — mDNS scan state shared by the QAM panel and the full page.
|
||||
// Hosts — ONE call site for both lists. They were separate hooks when the plugin had two
|
||||
// views mounting them independently; the panel is the only view now, and merging them means
|
||||
// the "scanning" state covers the whole row set rather than half of it flickering in first.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
export function useHosts() {
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [views, setViews] = useState<HostView[]>([]);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
// Why the list is empty, when it is empty for a reason other than an empty LAN. Rendering
|
||||
// either of these as "No hosts yet" would blame the user's network for the plugin's problem:
|
||||
// "client-outdated" — the installed client predates `punktfunk discover`
|
||||
// "client-unavailable" — there is no client installed at all
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setScanning(true);
|
||||
try {
|
||||
setHosts(await discover());
|
||||
// Both in flight at once: the browse is time-bounded and the probe is network-bound, so
|
||||
// running them in sequence would cost the sum of two waits for no benefit.
|
||||
const [d, s] = await Promise.all([discover(), listHosts()]);
|
||||
// Both calls run the same binary, so they fail the same way; take whichever answered.
|
||||
setProblem(
|
||||
d.error === "client-unavailable" || s.error === "client-unavailable"
|
||||
? "client-unavailable"
|
||||
: d.error === "client-outdated" || s.error === "client-outdated"
|
||||
? "client-outdated"
|
||||
: null,
|
||||
);
|
||||
setViews(mergeHosts(s.hosts ?? [], d.hosts ?? []));
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Discovery failed: ${e}` });
|
||||
toaster.toast({ title: "Punktfunk", body: `Couldn't list hosts: ${e}` });
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
@@ -59,157 +227,7 @@ export function useHosts() {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { hosts, scanning, refresh };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Saved hosts — the SHARED known-hosts store (client-known-hosts.json), the same file the
|
||||
// desktop client reads/writes. Fetched WITH a reachability probe so a host reached over a
|
||||
// routed network (Tailscale/VPN) reports online without ever appearing on mDNS.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
export function useSavedHosts() {
|
||||
const [saved, setSaved] = useState<SavedHost[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await listHosts(true);
|
||||
setSaved(r.hosts ?? []);
|
||||
} catch {
|
||||
/* backend unavailable — keep the current view */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { saved, loading, refresh };
|
||||
}
|
||||
|
||||
/**
|
||||
* One host as the UI shows it — the union of the saved store and the live mDNS scan. A saved
|
||||
* host is ONLINE when it either advertises on mDNS OR answers the reachability probe (so
|
||||
* mDNS-blind-but-reachable hosts stop reading as offline). Discovered hosts not in the store
|
||||
* are appended as unsaved rows.
|
||||
*/
|
||||
export interface HostView {
|
||||
name: string;
|
||||
addr: string;
|
||||
port: number;
|
||||
fp: string; // "" for a saved-but-unpaired placeholder
|
||||
paired: boolean; // PIN-paired specifically (a TOFU host has fp but paired=false)
|
||||
online: boolean;
|
||||
saved: boolean; // present in the known-hosts store
|
||||
pairPolicy: string; // the advert's policy ("required"|"optional"), "" when not advertising
|
||||
mgmt: number; // advertised mgmt-API port (0 = not advertised → default)
|
||||
id: string; // advertised stable host id ("" when not advertising)
|
||||
os: string; // OS-identity chain (live advert preferred, else the stored one); "" unknown
|
||||
}
|
||||
|
||||
function advertMatchesSaved(a: Host, s: SavedHost): boolean {
|
||||
return (
|
||||
(!!s.fp_hex && !!a.fp && s.fp_hex.toLowerCase() === a.fp.toLowerCase()) ||
|
||||
(s.addr === a.host && s.port === a.port)
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeHosts(saved: SavedHost[], discovered: Host[]): HostView[] {
|
||||
const views: HostView[] = saved.map((s) => {
|
||||
// Prefer a live advert's address (a host may have moved DHCP leases since it was saved).
|
||||
const advert = discovered.find((a) => advertMatchesSaved(a, s));
|
||||
return {
|
||||
name: s.name || s.addr,
|
||||
addr: advert?.host ?? s.addr,
|
||||
port: advert?.port ?? s.port,
|
||||
fp: s.fp_hex || advert?.fp || "",
|
||||
paired: s.paired,
|
||||
online: !!advert || s.online === true,
|
||||
saved: true,
|
||||
pairPolicy: advert?.pair ?? "",
|
||||
mgmt: advert?.mgmt ?? 0,
|
||||
id: advert?.id ?? "",
|
||||
os: advert?.os || s.os || "",
|
||||
};
|
||||
});
|
||||
for (const a of discovered) {
|
||||
if (saved.some((s) => advertMatchesSaved(a, s))) {
|
||||
continue; // already rendered as its saved card (with a live pip)
|
||||
}
|
||||
views.push({
|
||||
name: a.name,
|
||||
addr: a.host,
|
||||
port: a.port,
|
||||
fp: a.fp,
|
||||
paired: a.paired,
|
||||
online: true,
|
||||
saved: false,
|
||||
pairPolicy: a.pair,
|
||||
mgmt: a.mgmt,
|
||||
id: a.id,
|
||||
os: a.os,
|
||||
});
|
||||
}
|
||||
return views;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this host must be paired before it can stream. A saved host is streamable once it
|
||||
* has a pinned fingerprint (PIN-paired OR TOFU-trusted); a saved placeholder (no fp yet) must be
|
||||
* paired. For an unsaved discovered host we keep the advertised-policy rule the UI always used.
|
||||
*/
|
||||
export function needsPair(v: HostView): boolean {
|
||||
return v.saved ? v.fp === "" : v.pairPolicy === "required" && !v.paired;
|
||||
}
|
||||
|
||||
/** Adapt a merged view back into the `Host` shape the pair/library/stream helpers consume. */
|
||||
export function toHost(v: HostView): Host {
|
||||
return {
|
||||
name: v.name,
|
||||
host: v.addr,
|
||||
port: v.port,
|
||||
pair: v.pairPolicy || (needsPair(v) ? "required" : "optional"),
|
||||
fp: v.fp,
|
||||
proto: "",
|
||||
paired: v.paired,
|
||||
id: v.id,
|
||||
mgmt: v.mgmt,
|
||||
os: v.os,
|
||||
};
|
||||
}
|
||||
|
||||
/** Is a pinned game's host currently online, considering BOTH the live scan and saved probe? */
|
||||
export function pinIsOnline(pin: PinnedGame, views: HostView[]): boolean {
|
||||
const fp = pin.host_fp.toLowerCase();
|
||||
return views.some(
|
||||
(v) =>
|
||||
v.online &&
|
||||
((!!fp && v.fp.toLowerCase() === fp) ||
|
||||
(!!pin.host_id && v.id === pin.host_id) ||
|
||||
(v.addr === pin.host && v.port === pin.port)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all Punktfunk state (saved hosts + stream settings + pins), keeping the client identity.
|
||||
* Refreshes whatever views are passed so the UI clears immediately. Ends in a toast.
|
||||
*/
|
||||
export async function resetAll(refreshers: Array<() => void | Promise<void>>): Promise<void> {
|
||||
try {
|
||||
const r = await resetConfig();
|
||||
for (const fn of refreshers) void fn();
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: r.ok
|
||||
? "Reset — saved hosts, settings, and pins cleared."
|
||||
: `Reset failed${r.error ? ` (${r.error})` : ""}.`,
|
||||
});
|
||||
} catch {
|
||||
toaster.toast({ title: "Punktfunk", body: "Reset failed." });
|
||||
}
|
||||
return { views, scanning, problem, refresh };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
@@ -260,36 +278,6 @@ export function clientUpdateIsOneTap(info: UpdateInfo | null | undefined): boole
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How the client got onto this box, in words a Deck user recognises. The raw kind comes from
|
||||
* the client's own detector (`pf_update_check::detect`); anything unmapped falls through as
|
||||
* itself rather than as "unknown", because the raw word is still more useful than a shrug.
|
||||
*/
|
||||
export function clientInstallLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case "flatpak":
|
||||
return "Flatpak (per-user)";
|
||||
case "apt":
|
||||
return "System package (apt)";
|
||||
case "dnf":
|
||||
return "System package (dnf)";
|
||||
case "rpm-ostree":
|
||||
return "Layered package (rpm-ostree)";
|
||||
case "pacman":
|
||||
return "System package (pacman)";
|
||||
case "sysext":
|
||||
return "System extension (sysext)";
|
||||
case "nix":
|
||||
return "Nix profile";
|
||||
case "steamos-source":
|
||||
return "On-device build";
|
||||
case "source":
|
||||
return "Built from source";
|
||||
default:
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the only pending update is one this Deck can't apply itself. */
|
||||
export function clientUpdateIsManualOnly(info: UpdateInfo | null | undefined): boolean {
|
||||
return !!info && info.client_update_available && !clientUpdateIsOneTap(info);
|
||||
@@ -427,167 +415,26 @@ export async function applyUpdate(
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Stream launch — via the hidden Steam shortcut (see steam.ts for why).
|
||||
// Stream launch — via the hidden Steam shortcut (see steam.ts for why it can't be direct).
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Stream this host. `opts.profileId` streams one of its pinned cards; `opts.requestAccess`
|
||||
* runs the supervised launch that waits for the host's operator to approve this Deck.
|
||||
*
|
||||
* The host is named by REFERENCE (`v.ref`), never by value — no resolution, bitrate or codec
|
||||
* ever rides the launch path, which is the same rule the deep-link grammar enforces.
|
||||
*/
|
||||
export async function startStream(
|
||||
h: Host,
|
||||
v: HostView,
|
||||
opts: LaunchOpts = {},
|
||||
label?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await launchStream(h.host, h.port, opts);
|
||||
await launchStream(v.ref, opts);
|
||||
Navigation.CloseSideMenus();
|
||||
toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"} — ${h.name}` });
|
||||
toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"} — ${v.name}` });
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
|
||||
}
|
||||
}
|
||||
|
||||
/** Open the GTK client's gamepad library launcher for a host (`--browse` via PF_BROWSE). */
|
||||
export async function startBrowse(h: Host): Promise<void> {
|
||||
try {
|
||||
await launchStream(h.host, h.port, { browse: true, mgmt: h.mgmt });
|
||||
Navigation.CloseSideMenus();
|
||||
toaster.toast({ title: "Punktfunk", body: `Opening library — ${h.name}` });
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Pinned games — the QAM's one-tap game rows, persisted by the backend next to the
|
||||
// client's config (survives plugin reinstalls).
|
||||
// ----------------------------------------------------------------------------------------
|
||||
export interface PinsApi {
|
||||
pins: PinnedGame[];
|
||||
addPin: (h: Host, g: GameEntry) => void;
|
||||
removePin: (hostFp: string, gameId: string) => void;
|
||||
isPinned: (hostFp: string, gameId: string) => boolean;
|
||||
/** Refresh a pin's stored address from a live advert (hosts change IPs). */
|
||||
updatePinHost: (pin: PinnedGame, h: Host) => void;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function usePins(): PinsApi {
|
||||
const [pins, setPins] = useState<PinnedGame[]>([]);
|
||||
// A live mirror of `pins`. The Games picker is mounted by Decky's `showModal` into a
|
||||
// detached portal that captures this hook's callbacks ONCE and never re-renders with fresh
|
||||
// props, so a mutator closing over the `pins` array reads a frozen base — pinning a second
|
||||
// game in the same session would compute from the stale `[]` and clobber the first (silent
|
||||
// data loss). Reading the ref keeps every mutation based on the current set, and lets the
|
||||
// callbacks keep a stable identity (deps free of `pins`).
|
||||
const pinsRef = useRef<PinnedGame[]>([]);
|
||||
pinsRef.current = pins;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setPins((await getPins()).pins);
|
||||
} catch {
|
||||
/* backend unavailable — keep the current view */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Optimistic local state; the backend validates/dedups and is re-read on failure.
|
||||
const save = useCallback(
|
||||
(next: PinnedGame[]) => {
|
||||
pinsRef.current = next;
|
||||
setPins(next);
|
||||
setPinsBackend(next).catch(() => void refresh());
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
const addPin = useCallback(
|
||||
(h: Host, g: GameEntry) => {
|
||||
const pin: PinnedGame = {
|
||||
game_id: g.id,
|
||||
title: g.title,
|
||||
store: g.store,
|
||||
host_fp: h.fp,
|
||||
host_id: h.id,
|
||||
host_name: h.name,
|
||||
host: h.host,
|
||||
port: h.port,
|
||||
mgmt: h.mgmt,
|
||||
added_at: Math.floor(Date.now() / 1000),
|
||||
paired: h.paired,
|
||||
};
|
||||
save([
|
||||
...pinsRef.current.filter(
|
||||
(p) => !(p.host_fp === pin.host_fp && p.game_id === pin.game_id),
|
||||
),
|
||||
pin,
|
||||
]);
|
||||
},
|
||||
[save],
|
||||
);
|
||||
|
||||
const removePin = useCallback(
|
||||
(hostFp: string, gameId: string) => {
|
||||
save(pinsRef.current.filter((p) => !(p.host_fp === hostFp && p.game_id === gameId)));
|
||||
},
|
||||
[save],
|
||||
);
|
||||
|
||||
const isPinned = useCallback(
|
||||
(hostFp: string, gameId: string) =>
|
||||
pins.some((p) => p.host_fp === hostFp && p.game_id === gameId),
|
||||
[pins],
|
||||
);
|
||||
|
||||
const updatePinHost = useCallback(
|
||||
(pin: PinnedGame, h: Host) => {
|
||||
if (pin.host === h.host && pin.port === h.port && pin.mgmt === h.mgmt) {
|
||||
return;
|
||||
}
|
||||
save(
|
||||
pinsRef.current.map((p) =>
|
||||
p.host_fp === pin.host_fp && p.game_id === pin.game_id
|
||||
? { ...p, host: h.host, port: h.port, mgmt: h.mgmt, host_name: h.name }
|
||||
: p,
|
||||
),
|
||||
);
|
||||
},
|
||||
[save],
|
||||
);
|
||||
|
||||
return { pins, addPin, removePin, isPinned, updatePinHost, refresh };
|
||||
}
|
||||
|
||||
/**
|
||||
* The host a pin should launch against right now: match the live mDNS scan by cert
|
||||
* fingerprint first (pairing is fp-keyed, survives IP changes), then by the host's stable
|
||||
* id, else fall back to the stored address (host offline or scan flaky — still launch).
|
||||
*/
|
||||
export function resolvePinHost(
|
||||
pin: PinnedGame,
|
||||
live: Host[],
|
||||
): { host: Host; online: boolean } {
|
||||
const fp = pin.host_fp.toLowerCase();
|
||||
const match =
|
||||
(fp && live.find((h) => h.fp && h.fp.toLowerCase() === fp)) ||
|
||||
(pin.host_id && live.find((h) => h.id && h.id === pin.host_id)) ||
|
||||
undefined;
|
||||
if (match) {
|
||||
return { host: match, online: true };
|
||||
}
|
||||
return {
|
||||
host: {
|
||||
name: pin.host_name || pin.host,
|
||||
host: pin.host,
|
||||
port: pin.port,
|
||||
pair: pin.paired ? "optional" : "required",
|
||||
fp: pin.host_fp,
|
||||
proto: "",
|
||||
paired: !!pin.paired,
|
||||
id: pin.host_id,
|
||||
mgmt: pin.mgmt,
|
||||
os: "", // pins don't store the chain; the icon is a hosts-tab affordance
|
||||
},
|
||||
online: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
// Add / edit host dialogs for the fullscreen page. These mutate the SHARED known-hosts store
|
||||
// (client-known-hosts.json) through the flatpak client's headless modes, so a host saved or
|
||||
// renamed here shows up in the desktop client too. Text entry uses @decky/ui's TextField, which
|
||||
// brings up Steam's on-screen keyboard on focus (the digit-grid trick in pair.tsx is only needed
|
||||
// for the numeric PIN).
|
||||
import { DialogButton, Focusable, ModalRoot, Spinner, TextField } from "@decky/ui";
|
||||
import { toaster } from "@decky/api";
|
||||
import { ChangeEvent, FC, useState } from "react";
|
||||
import { addHost, editHost, MutationResult } from "./backend";
|
||||
import { HostView } from "./hooks";
|
||||
import { actionButton } from "./ui";
|
||||
|
||||
/** Stable copy for a failed host-store mutation. */
|
||||
export function mutationError(r: MutationResult): string {
|
||||
switch (r.error) {
|
||||
case "client-unavailable":
|
||||
return "The Punktfunk client isn't installed (flatpak io.unom.Punktfunk).";
|
||||
case "client-outdated":
|
||||
return "The installed client is too old for host management — update it from the About tab.";
|
||||
default:
|
||||
return r.detail || "Couldn't save the host.";
|
||||
}
|
||||
}
|
||||
|
||||
// Split a typed address: a pasted `host:port` wins over the separate port field. IPv6 literals
|
||||
// aren't supported by the host advert/known-hosts format, so a bare colon is treated as host:port.
|
||||
function targetFrom(addr: string, port: string): string {
|
||||
const a = addr.trim();
|
||||
if (a.includes(":")) {
|
||||
return a;
|
||||
}
|
||||
const p = port.trim() || "9777";
|
||||
return `${a}:${p}`;
|
||||
}
|
||||
|
||||
const field: React.CSSProperties = { marginBottom: "0.8em" };
|
||||
|
||||
const HostForm: FC<{
|
||||
title: string;
|
||||
submitLabel: string;
|
||||
initial: { addr: string; port: string; name: string };
|
||||
addrDisabled?: boolean;
|
||||
onSubmit: (addr: string, port: string, name: string) => Promise<MutationResult>;
|
||||
onDone: () => void;
|
||||
closeModal?: () => void;
|
||||
}> = ({ title, submitLabel, initial, addrDisabled, onSubmit, onDone, closeModal }) => {
|
||||
const [addr, setAddr] = useState(initial.addr);
|
||||
const [port, setPort] = useState(initial.port);
|
||||
const [name, setName] = useState(initial.name);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async () => {
|
||||
if (!addr.trim()) {
|
||||
setError("Enter an address.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await onSubmit(addr.trim(), port.trim(), name.trim());
|
||||
if (r.ok) {
|
||||
onDone();
|
||||
closeModal?.();
|
||||
} else {
|
||||
setError(mutationError(r));
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalRoot closeModal={closeModal}>
|
||||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.6em" }}>{title}</div>
|
||||
<div style={field}>
|
||||
<TextField
|
||||
label="Address"
|
||||
description="IP or hostname (a Tailscale/VPN name works too). Add :port to override."
|
||||
value={addr}
|
||||
disabled={addrDisabled || busy}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setAddr(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={field}>
|
||||
<TextField
|
||||
label="Port"
|
||||
value={port}
|
||||
mustBeNumeric
|
||||
disabled={busy}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setPort(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={field}>
|
||||
<TextField
|
||||
label="Name (optional)"
|
||||
value={name}
|
||||
disabled={busy}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div style={{ color: "#ff6b6b", marginBottom: "0.6em" }}>{error}</div>
|
||||
)}
|
||||
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
|
||||
<DialogButton style={actionButton} disabled={busy} onClick={() => closeModal?.()}>
|
||||
Cancel
|
||||
</DialogButton>
|
||||
<DialogButton style={actionButton} disabled={busy} onClick={submit}>
|
||||
{busy ? <Spinner style={{ height: "1em" }} /> : submitLabel}
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
|
||||
/** "+" — save a new host by address (unpaired placeholder; the user pairs it next). */
|
||||
export const AddHostModal: FC<{ onDone: () => void; closeModal?: () => void }> = ({
|
||||
onDone,
|
||||
closeModal,
|
||||
}) => (
|
||||
<HostForm
|
||||
title="Add host"
|
||||
submitLabel="Add"
|
||||
initial={{ addr: "", port: "9777", name: "" }}
|
||||
onSubmit={async (addr, port, name) => {
|
||||
const r = await addHost(targetFrom(addr, port), name, "");
|
||||
if (r.ok) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Added ${name || addr}` });
|
||||
}
|
||||
return r;
|
||||
}}
|
||||
onDone={onDone}
|
||||
closeModal={closeModal}
|
||||
/>
|
||||
);
|
||||
|
||||
/** Rename / re-point a saved host. Identified by fingerprint when it has one (survives IP
|
||||
* changes), else by its current address. */
|
||||
export const EditHostModal: FC<{
|
||||
host: HostView;
|
||||
onDone: () => void;
|
||||
closeModal?: () => void;
|
||||
}> = ({ host, onDone, closeModal }) => {
|
||||
const selector = host.fp || `${host.addr}:${host.port}`;
|
||||
return (
|
||||
<HostForm
|
||||
title={`Edit ${host.name}`}
|
||||
submitLabel="Save"
|
||||
initial={{ addr: host.addr, port: String(host.port), name: host.name }}
|
||||
onSubmit={async (addr, port, name) => {
|
||||
const r = await editHost(selector, name, addr, parseInt(port, 10) || 0);
|
||||
if (r.ok) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Updated ${name || addr}` });
|
||||
}
|
||||
return r;
|
||||
}}
|
||||
onDone={onDone}
|
||||
closeModal={closeModal}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+148
-117
@@ -1,46 +1,47 @@
|
||||
// Plugin entry: the Quick Access Menu panel + route registration. The fullscreen page lives
|
||||
// in page.tsx; shared hooks/actions in hooks.ts; the Steam-shortcut launch in steam.ts.
|
||||
// Plugin entry: the Quick Access Menu panel. That is the whole plugin now — the fullscreen
|
||||
// route, the settings screen, the host editor and the games picker are gone, because the
|
||||
// client's own console home does all four one shortcut away (and is gamepad-navigable, which
|
||||
// a QAM panel re-implementing them never quite was).
|
||||
//
|
||||
// What is left is what only a Decky plugin can do: start a stream through Steam so gamescope
|
||||
// focuses it (see steam.ts), and stand in front of the trust decision that gates it.
|
||||
import {
|
||||
ButtonItem,
|
||||
Field,
|
||||
Navigation,
|
||||
PanelSection,
|
||||
PanelSectionRow,
|
||||
Spinner,
|
||||
showModal,
|
||||
staticClasses,
|
||||
} from "@decky/ui";
|
||||
import { definePlugin, routerHook, toaster } from "@decky/api";
|
||||
import { definePlugin, toaster } from "@decky/api";
|
||||
import { FC } from "react";
|
||||
import {
|
||||
FaDownload,
|
||||
FaLock,
|
||||
FaLockOpen,
|
||||
FaPlay,
|
||||
FaPlus,
|
||||
FaStopCircle,
|
||||
FaSyncAlt,
|
||||
FaTv,
|
||||
} from "react-icons/fa";
|
||||
import { killStream } from "./backend";
|
||||
import { PluginErrorBoundary } from "./boundary";
|
||||
import {
|
||||
applyUpdate,
|
||||
checkForUpdatesNow,
|
||||
clientUpdateIsManualOnly,
|
||||
hasUpdate,
|
||||
mergeHosts,
|
||||
HostView,
|
||||
needsPair,
|
||||
pinIsOnline,
|
||||
startStream,
|
||||
toHost,
|
||||
trustState,
|
||||
useHosts,
|
||||
usePins,
|
||||
useSavedHosts,
|
||||
useUpdate,
|
||||
} from "./hooks";
|
||||
import { streamPin } from "./library";
|
||||
import { PunktfunkRoute, ROUTE } from "./page";
|
||||
import { PairModal } from "./pair";
|
||||
import { ensureGamepadUiShortcut, recreateShortcuts } from "./steam";
|
||||
import { OsMark } from "./os-icon";
|
||||
import { ensureGamepadUiShortcut, launchGamepadUi, recreateShortcuts, stopStream } from "./steam";
|
||||
import { TrustSheet } from "./trust";
|
||||
|
||||
// Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut.
|
||||
// Deleting the shortcut (optionally + reinstalling the plugin) leaves a stale appId in Steam's
|
||||
@@ -54,22 +55,78 @@ async function recreatePunktfunkShortcut(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts
|
||||
// and pinned games.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const QamPanel: FC = () => {
|
||||
const { hosts: discovered, scanning, refresh: refreshDiscovered } = useHosts();
|
||||
const { saved, loading: loadingSaved, refresh: refreshSaved } = useSavedHosts();
|
||||
const { info: update, checking, check } = useUpdate();
|
||||
const pins = usePins();
|
||||
/** Force-stop a wedged stream: end Steam's "game", then make sure the client itself is gone. */
|
||||
async function forceStop(): Promise<void> {
|
||||
stopStream();
|
||||
try {
|
||||
await killStream();
|
||||
} catch {
|
||||
/* best-effort — the TerminateApp above is usually enough */
|
||||
}
|
||||
toaster.toast({ title: "Punktfunk", body: "Stopped the stream" });
|
||||
}
|
||||
|
||||
const hosts = mergeHosts(saved, discovered);
|
||||
const busy = scanning || loadingSaved;
|
||||
const refresh = () => {
|
||||
void refreshDiscovered();
|
||||
void refreshSaved();
|
||||
};
|
||||
/** The line under a host's name: where it is, whether it's up, and how far trust has got. */
|
||||
function hostDescription(v: HostView): string {
|
||||
const trust = {
|
||||
paired: "paired",
|
||||
trusted: "trusted",
|
||||
"needs-access": "needs access",
|
||||
}[trustState(v)];
|
||||
return `${v.addr}:${v.port} · ${v.online ? "online" : "offline"} · ${trust}`;
|
||||
}
|
||||
|
||||
const HostRow: FC<{ host: HostView; refresh: () => void }> = ({ host, refresh }) => {
|
||||
const gated = needsPair(host);
|
||||
const stream = (opts: { requestAccess?: boolean } = {}) => void startStream(host, opts);
|
||||
return (
|
||||
<>
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() =>
|
||||
gated
|
||||
? showModal(
|
||||
<TrustSheet host={host} onStream={stream} onChanged={refresh} />,
|
||||
)
|
||||
: stream()
|
||||
}
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||
{gated ? <FaLock /> : <OsMark os={host.os} />}
|
||||
{host.name}
|
||||
</span>
|
||||
}
|
||||
description={hostDescription(host)}
|
||||
>
|
||||
{gated ? "Connect…" : "Stream"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
{/* Pinned cards, nested under their host rather than in a section of their own: a card
|
||||
IS a (host, profile) pair, and a row that floats free of its host is the "a pinned
|
||||
tile reads as a duplicate host" problem the desktop shells still have. The host's
|
||||
own BOUND profile is deliberately not a card — it applies silently on the plain row
|
||||
above, and showing it twice would suggest they do different things. */}
|
||||
{!gated &&
|
||||
host.pinnedProfiles.map((p) => (
|
||||
<PanelSectionRow key={`${host.ref}:${p.id}`}>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() => void startStream(host, { profileId: p.id }, `“${p.name}”`)}
|
||||
label={`▸ ${p.name}`}
|
||||
>
|
||||
<FaPlay style={{ marginRight: "0.5em" }} />
|
||||
Stream
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const QamPanel: FC = () => {
|
||||
const { views, scanning, problem, refresh } = useHosts();
|
||||
const { info: update, checking, check } = useUpdate();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -110,15 +167,62 @@ const QamPanel: FC = () => {
|
||||
</PanelSection>
|
||||
))}
|
||||
|
||||
<PanelSection title="Hosts">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem layout="below" onClick={() => void refresh()} disabled={scanning}>
|
||||
{scanning ? (
|
||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||
) : (
|
||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||
)}
|
||||
{scanning ? "Scanning…" : "Refresh"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
{/* A client that is missing or too old explains itself rather than rendering an empty
|
||||
list — "no hosts on your LAN" would blame the network for the plugin's problem, and
|
||||
for the outdated case the button that fixes it is in this same panel. */}
|
||||
{problem && (
|
||||
<PanelSectionRow>
|
||||
<Field
|
||||
focusable={false}
|
||||
label={
|
||||
problem === "client-unavailable"
|
||||
? "Punktfunk isn’t installed"
|
||||
: "Update the Punktfunk client"
|
||||
}
|
||||
description={
|
||||
problem === "client-unavailable"
|
||||
? "This panel launches the Punktfunk app, which isn’t on this Deck yet. Install it in Desktop Mode."
|
||||
: "This client is too old to find hosts on your network. Saved hosts still work."
|
||||
}
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{views.length === 0 && scanning && (
|
||||
<PanelSectionRow>
|
||||
<Field focusable={false} description="Scanning your network…" />
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{views.length === 0 && !scanning && !problem && (
|
||||
<PanelSectionRow>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="No hosts yet"
|
||||
description="Open Punktfunk to find and pair one."
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{views.map((v) => (
|
||||
<HostRow key={v.ref} host={v} refresh={refresh} />
|
||||
))}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Punktfunk">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
description="Host details, stream settings, and help"
|
||||
onClick={() => {
|
||||
Navigation.Navigate(ROUTE);
|
||||
Navigation.CloseSideMenus();
|
||||
}}
|
||||
description="Settings, adding a host by address, and browsing a host's games all live here."
|
||||
onClick={() => void launchGamepadUi()}
|
||||
>
|
||||
<FaTv style={{ marginRight: "0.5em" }} />
|
||||
Open Punktfunk
|
||||
@@ -126,85 +230,6 @@ const QamPanel: FC = () => {
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
|
||||
{/* Pinned games — the "jump straight into Playnite" rows. Pin games from a host's
|
||||
picker (fullscreen page → host row → games button). */}
|
||||
{pins.pins.length > 0 && (
|
||||
<PanelSection title="Pinned Games">
|
||||
{pins.pins.map((pin) => {
|
||||
const online = pinIsOnline(pin, hosts);
|
||||
return (
|
||||
<PanelSectionRow key={`${pin.host_fp}:${pin.game_id}`}>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() => streamPin(pin, hosts.map(toHost), pins)}
|
||||
label={pin.title}
|
||||
description={`${pin.host_name}${online ? "" : " · offline?"}${
|
||||
pin.paired ? "" : " · pairing required"
|
||||
}`}
|
||||
>
|
||||
<FaPlay style={{ marginRight: "0.5em" }} />
|
||||
Stream
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
);
|
||||
})}
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
<PanelSection title="Hosts">
|
||||
<PanelSectionRow>
|
||||
<ButtonItem layout="below" onClick={refresh} disabled={busy}>
|
||||
{busy ? (
|
||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||
) : (
|
||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||
)}
|
||||
{busy ? "Scanning…" : "Refresh"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
{hosts.length === 0 && busy && (
|
||||
<PanelSectionRow>
|
||||
<Field focusable={false} description="Scanning your network…" />
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{hosts.length === 0 && !busy && (
|
||||
<PanelSectionRow>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="No hosts found"
|
||||
description="Open Punktfunk to add a host by address, or start a host on this network and refresh."
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{hosts.map((v) => {
|
||||
const pair = needsPair(v);
|
||||
const h = toHost(v);
|
||||
return (
|
||||
<PanelSectionRow key={v.fp || `${v.addr}:${v.port}`}>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
onClick={() =>
|
||||
pair
|
||||
? showModal(<PairModal host={h} onPaired={() => startStream(h)} />)
|
||||
: startStream(h)
|
||||
}
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||
{pair ? <FaLock /> : <FaLockOpen />}
|
||||
{v.name}
|
||||
</span>
|
||||
}
|
||||
description={`${v.addr}:${v.port} · ${v.online ? "online" : "offline"}${
|
||||
pair ? " · pairing required" : v.paired ? " · paired" : ""
|
||||
}`}
|
||||
>
|
||||
{pair ? "Pair & Stream" : "Stream"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
);
|
||||
})}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="About">
|
||||
<PanelSectionRow>
|
||||
<Field
|
||||
@@ -236,13 +261,22 @@ const QamPanel: FC = () => {
|
||||
Recreate library shortcut
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
description="Ends a stream that stopped responding."
|
||||
onClick={() => void forceStop()}
|
||||
>
|
||||
<FaStopCircle style={{ marginRight: "0.5em" }} />
|
||||
Force-stop
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default definePlugin(() => {
|
||||
routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true });
|
||||
// Ensure the visible, stateless "Punktfunk" library entry (opens the gamepad UI / console
|
||||
// home) exists and is repointed to the current plugin dir — also installs the native-touch
|
||||
// controller config. Fire-and-forget: cosmetic library upkeep must never block plugin load.
|
||||
@@ -260,8 +294,5 @@ export default definePlugin(() => {
|
||||
</PluginErrorBoundary>
|
||||
),
|
||||
icon: <FaTv />,
|
||||
onDismount() {
|
||||
routerHook.removeRoute(ROUTE);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
// The per-host game picker + pinned-game launch helper. The picker fetches a paired
|
||||
// host's library through the backend (headless flatpak --library — a cold client start
|
||||
// can take seconds, hence the explicit spinner copy) and pins titles as one-tap rows in
|
||||
// the QAM's Games section; its header also launches the GTK client's on-screen gamepad
|
||||
// library (`--browse`).
|
||||
import { DialogButton, Field, ModalRoot, Spinner, showModal } from "@decky/ui";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { FaThLarge, FaTv } from "react-icons/fa";
|
||||
import { GameEntry, Host, library, LibraryResult, PinnedGame } from "./backend";
|
||||
import { PinsApi, resolvePinHost, startBrowse, startStream } from "./hooks";
|
||||
import { isSafeLaunchId } from "./steam";
|
||||
import { PairModal } from "./pair";
|
||||
import { RowActions, actionButton } from "./ui";
|
||||
|
||||
/** Human store tag (mirrors the GTK client's `store_label`). */
|
||||
export function storeLabel(store: string): string {
|
||||
switch (store) {
|
||||
case "steam":
|
||||
return "Steam";
|
||||
case "custom":
|
||||
return "Custom";
|
||||
case "heroic":
|
||||
return "Heroic";
|
||||
case "lutris":
|
||||
return "Lutris";
|
||||
case "epic":
|
||||
return "Epic";
|
||||
case "gog":
|
||||
return "GOG";
|
||||
case "xbox":
|
||||
return "Xbox";
|
||||
default:
|
||||
return "Game";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a pinned game: resolve the host from the live scan (fp → id → stored address),
|
||||
* opportunistically refresh a drifted stored address, and route through pairing first if
|
||||
* this device is no longer paired with the host.
|
||||
*/
|
||||
export function streamPin(pin: PinnedGame, live: Host[], pins: PinsApi): void {
|
||||
const { host, online } = resolvePinHost(pin, live);
|
||||
if (online) {
|
||||
pins.updatePinHost(pin, host); // no-op unless the address actually drifted
|
||||
}
|
||||
if (!pin.paired) {
|
||||
showModal(
|
||||
<PairModal
|
||||
host={host}
|
||||
onPaired={() => {
|
||||
void pins.refresh(); // pick up the now-paired annotation
|
||||
void startStream(host, { launchId: pin.game_id }, pin.title);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
return;
|
||||
}
|
||||
void startStream(host, { launchId: pin.game_id }, pin.title);
|
||||
}
|
||||
|
||||
// Copy per backend error code (LibraryResult.error); `detail` covers the generic case.
|
||||
function errorCopy(res: LibraryResult): string {
|
||||
switch (res.error) {
|
||||
case "not-paired":
|
||||
return "This Deck isn't paired with the host — pair first, then browse its library.";
|
||||
case "pin-mismatch":
|
||||
return "The host's identity changed — re-pair to re-establish trust.";
|
||||
case "unreachable":
|
||||
return "Couldn't reach the host's management API. Is the host online and up to date?";
|
||||
case "timeout":
|
||||
return "Timed out talking to the host — try again.";
|
||||
case "flatpak-not-found":
|
||||
return "The Punktfunk client isn't installed (flatpak io.unom.Punktfunk).";
|
||||
case "client-outdated":
|
||||
return "The installed client is too old for library browsing — update it from the About tab.";
|
||||
default:
|
||||
return res.detail || "Couldn't fetch the library.";
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// The picker modal: "open on screen" + a pin-toggle list of the host's games.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
export const GamePickerModal: FC<{
|
||||
host: Host;
|
||||
pins: PinsApi;
|
||||
clientUpdatePending?: boolean;
|
||||
closeModal?: () => void;
|
||||
}> = ({ host, pins, clientUpdatePending, closeModal }) => {
|
||||
const [result, setResult] = useState<LibraryResult | null>(null);
|
||||
const [attempt, setAttempt] = useState(0); // bump to refetch (retry / after pairing)
|
||||
// The modal is a detached `showModal` portal that never re-renders from the page's pin
|
||||
// state, so `pins.isPinned` would read a frozen snapshot and the Pin/Unpin label would
|
||||
// never flip within a session. Track this host's pinned ids locally, seeded once from the
|
||||
// snapshot at open; persistence still goes through the (stale-closure-safe) pins API.
|
||||
const [pinnedIds, setPinnedIds] = useState<Set<string>>(
|
||||
() => new Set(pins.pins.filter((p) => p.host_fp === host.fp).map((p) => p.game_id)),
|
||||
);
|
||||
const togglePin = (g: GameEntry) => {
|
||||
const wasPinned = pinnedIds.has(g.id);
|
||||
setPinnedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (wasPinned) next.delete(g.id);
|
||||
else next.add(g.id);
|
||||
return next;
|
||||
});
|
||||
if (wasPinned) pins.removePin(host.fp, g.id);
|
||||
else pins.addPin(host, g);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let stale = false;
|
||||
setResult(null);
|
||||
library(host.host, host.mgmt, host.fp)
|
||||
.then((res) => {
|
||||
if (!stale) setResult(res);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!stale) setResult({ ok: false, error: "client-error", detail: String(e) });
|
||||
});
|
||||
return () => {
|
||||
stale = true;
|
||||
};
|
||||
}, [host.host, host.mgmt, host.fp, attempt]);
|
||||
|
||||
const games = (result?.ok && result.games) || [];
|
||||
const sorted = [...games].sort((a, b) => a.title.localeCompare(b.title));
|
||||
|
||||
return (
|
||||
<ModalRoot closeModal={closeModal}>
|
||||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.4em" }}>
|
||||
{host.name} — Games
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Open library on screen"
|
||||
description="Browse this host's games with the controller, full screen"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => {
|
||||
closeModal?.();
|
||||
void startBrowse(host);
|
||||
}}
|
||||
>
|
||||
<FaTv style={{ marginRight: "0.4em" }} />
|
||||
Open
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
|
||||
{clientUpdatePending && (
|
||||
<Field
|
||||
focusable={false}
|
||||
description="A client update is available — direct game launch and on-screen browsing need the latest client."
|
||||
/>
|
||||
)}
|
||||
|
||||
{result === null && (
|
||||
<Field
|
||||
focusable={false}
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.6em" }}>
|
||||
<Spinner style={{ height: "1em" }} />
|
||||
Fetching the library…
|
||||
</span>
|
||||
}
|
||||
description="This starts the client headlessly — a cold start can take a few seconds."
|
||||
/>
|
||||
)}
|
||||
|
||||
{result !== null && !result.ok && (
|
||||
<Field label="Couldn't fetch the library" description={errorCopy(result)} childrenContainerWidth="max">
|
||||
<RowActions>
|
||||
{result.error === "not-paired" && (
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() =>
|
||||
showModal(<PairModal host={host} onPaired={() => setAttempt((n) => n + 1)} />)
|
||||
}
|
||||
>
|
||||
Pair
|
||||
</DialogButton>
|
||||
)}
|
||||
<DialogButton style={actionButton} onClick={() => setAttempt((n) => n + 1)}>
|
||||
Retry
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{result?.ok && sorted.length === 0 && (
|
||||
<Field
|
||||
focusable={false}
|
||||
label="No games found"
|
||||
description="Install Steam titles or add custom entries in the host's web console."
|
||||
/>
|
||||
)}
|
||||
|
||||
{sorted.length > 0 && (
|
||||
<div style={{ maxHeight: "55vh", overflowY: "auto" }}>
|
||||
{sorted.map((g: GameEntry) => {
|
||||
const pinned = pinnedIds.has(g.id);
|
||||
const safe = isSafeLaunchId(g.id);
|
||||
return (
|
||||
<Field
|
||||
key={g.id}
|
||||
label={g.title}
|
||||
description={
|
||||
storeLabel(g.store) + (safe ? "" : " · unsupported id — can't be pinned")
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} disabled={!safe} onClick={() => togglePin(g)}>
|
||||
<FaThLarge style={{ marginRight: "0.4em" }} />
|
||||
{pinned ? "Unpin" : "Pin"}
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
@@ -1,596 +0,0 @@
|
||||
// The fullscreen page (registered as the /punktfunk route) — Hosts / Settings / About tabs.
|
||||
import {
|
||||
ConfirmModal,
|
||||
DialogButton,
|
||||
Field,
|
||||
Focusable,
|
||||
ModalRoot,
|
||||
Navigation,
|
||||
Spinner,
|
||||
Tabs,
|
||||
showModal,
|
||||
staticClasses,
|
||||
} from "@decky/ui";
|
||||
import { RowActions, actionButton, iconButton } from "./ui";
|
||||
import { toaster } from "@decky/api";
|
||||
import { CSSProperties, FC, useState } from "react";
|
||||
import {
|
||||
FaArrowLeft,
|
||||
FaDownload,
|
||||
FaExternalLinkAlt,
|
||||
FaInfoCircle,
|
||||
FaLock,
|
||||
FaLockOpen,
|
||||
FaPen,
|
||||
FaPlay,
|
||||
FaPlus,
|
||||
FaSyncAlt,
|
||||
FaThLarge,
|
||||
FaTrashAlt,
|
||||
} from "react-icons/fa";
|
||||
import { UpdateInfo, forgetHost, killStream } from "./backend";
|
||||
import { PluginErrorBoundary } from "./boundary";
|
||||
import { OsMark } from "./os-icon";
|
||||
import {
|
||||
DOCS_URL,
|
||||
HostView,
|
||||
PinsApi,
|
||||
applyUpdate,
|
||||
checkForUpdatesNow,
|
||||
clientInstallLabel,
|
||||
clientUpdateIsManualOnly,
|
||||
hasUpdate,
|
||||
mergeHosts,
|
||||
needsPair,
|
||||
pinIsOnline,
|
||||
resetAll,
|
||||
startStream,
|
||||
toHost,
|
||||
useHosts,
|
||||
usePins,
|
||||
useSavedHosts,
|
||||
useUpdate,
|
||||
} from "./hooks";
|
||||
import { AddHostModal, EditHostModal, mutationError } from "./hostmgmt";
|
||||
import { GamePickerModal, storeLabel, streamPin } from "./library";
|
||||
import { PairModal } from "./pair";
|
||||
import { SettingsSection } from "./settings";
|
||||
import { stopStream } from "./steam";
|
||||
|
||||
export const ROUTE = "/punktfunk";
|
||||
|
||||
// Bottom inset so the last control clears Gaming Mode's footer hint bar. Routed pages render
|
||||
// *under* that bar otherwise — that's why the last Stream-settings row was getting hidden. The
|
||||
// value is generous on purpose (and harmless where the tab area already insets); tune to taste.
|
||||
const SAFE_BOTTOM = "80px";
|
||||
|
||||
// Each tab is its own scroll area so long content is always reachable above the footer.
|
||||
const tabScroll: CSSProperties = {
|
||||
height: "100%",
|
||||
overflowY: "auto",
|
||||
padding: "0.5em 2.5em",
|
||||
paddingBottom: SAFE_BOTTOM,
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
// The one-line status under a host name: address, live presence, and trust state.
|
||||
function hostSubtitle(v: HostView): string {
|
||||
const parts = [`${v.addr}:${v.port}`, v.online ? "online" : "offline"];
|
||||
if (needsPair(v)) {
|
||||
parts.push("pairing required");
|
||||
} else if (v.paired) {
|
||||
parts.push("paired");
|
||||
} else if (v.saved) {
|
||||
parts.push("trusted");
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/** Confirm + forget a saved host, then refresh the list. */
|
||||
function confirmForget(v: HostView, refresh: () => void): void {
|
||||
const selector = v.fp || `${v.addr}:${v.port}`;
|
||||
showModal(
|
||||
<ConfirmModal
|
||||
strTitle={`Forget ${v.name}?`}
|
||||
strDescription="You'll need to pair or trust it again to reconnect."
|
||||
strOKButtonText="Forget"
|
||||
bDestructiveWarning
|
||||
onOK={async () => {
|
||||
const r = await forgetHost(selector);
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: r.ok ? `Forgot ${v.name}` : mutationError(r),
|
||||
});
|
||||
refresh();
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Host details — everything we know, plus (for a saved host) rename / edit / forget.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const HostDetailsModal: FC<{
|
||||
host: HostView;
|
||||
onChanged: () => void;
|
||||
closeModal?: () => void;
|
||||
}> = ({ host, onChanged, closeModal }) => {
|
||||
const fp = host.fp ? (host.fp.match(/.{1,4}/g) ?? [host.fp]).join(" ") : "not known yet";
|
||||
return (
|
||||
<ModalRoot closeModal={closeModal}>
|
||||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.4em" }}>
|
||||
{host.name}
|
||||
</div>
|
||||
<Field focusable={false} label="Address">
|
||||
{host.addr}:{host.port}
|
||||
</Field>
|
||||
<Field focusable={false} label="Presence">
|
||||
{host.online ? "Online" : "Offline"}
|
||||
</Field>
|
||||
<Field focusable={false} label="This Deck">
|
||||
{host.paired ? "Paired" : host.fp ? "Trusted" : "Not paired yet"}
|
||||
</Field>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="Certificate fingerprint (SHA-256)"
|
||||
description={
|
||||
<span
|
||||
style={{ fontFamily: "monospace", fontSize: "0.85em", wordBreak: "break-word" }}
|
||||
>
|
||||
{fp}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{host.saved && (
|
||||
<Field label="Manage" childrenContainerWidth="max">
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => {
|
||||
closeModal?.();
|
||||
showModal(<EditHostModal host={host} onDone={onChanged} />);
|
||||
}}
|
||||
>
|
||||
<FaPen style={{ marginRight: "0.4em" }} />
|
||||
Edit
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => {
|
||||
closeModal?.();
|
||||
confirmForget(host, onChanged);
|
||||
}}
|
||||
>
|
||||
<FaTrashAlt style={{ marginRight: "0.4em" }} />
|
||||
Forget
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
)}
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// One host row: status icon + address, details / pair / stream actions.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
const HostRow: FC<{
|
||||
host: HostView;
|
||||
onChanged: () => void;
|
||||
onGames: () => void;
|
||||
}> = ({ host, onChanged, onGames }) => {
|
||||
const pair = needsPair(host);
|
||||
const h = toHost(host);
|
||||
return (
|
||||
<Field
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
|
||||
<OsMark os={host.os} />
|
||||
{pair ? <FaLock /> : <FaLockOpen />}
|
||||
{host.name}
|
||||
</span>
|
||||
}
|
||||
description={hostSubtitle(host)}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={iconButton}
|
||||
onClick={() => showModal(<HostDetailsModal host={host} onChanged={onChanged} />)}
|
||||
>
|
||||
<FaInfoCircle />
|
||||
</DialogButton>
|
||||
{/* Labeled, not icon-only: this is the entry to the game picker AND the on-screen
|
||||
library browser, and controller nav has no hover tooltip to explain a bare icon. */}
|
||||
<DialogButton style={actionButton} onClick={onGames}>
|
||||
<FaThLarge style={{ marginRight: "0.4em" }} />
|
||||
Games
|
||||
</DialogButton>
|
||||
{pair && (
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => showModal(<PairModal host={h} onPaired={onChanged} />)}
|
||||
>
|
||||
Pair
|
||||
</DialogButton>
|
||||
)}
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() =>
|
||||
pair
|
||||
? showModal(<PairModal host={h} onPaired={() => startStream(h)} />)
|
||||
: startStream(h)
|
||||
}
|
||||
>
|
||||
<FaPlay style={{ marginRight: "0.4em" }} />
|
||||
Stream
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
const HostsTab: FC<{
|
||||
hosts: HostView[];
|
||||
scanning: boolean;
|
||||
refresh: () => void;
|
||||
pins: PinsApi;
|
||||
clientUpdatePending: boolean;
|
||||
}> = ({ hosts, scanning, refresh, pins, clientUpdatePending }) => (
|
||||
<div style={tabScroll}>
|
||||
<Field
|
||||
label="Hosts"
|
||||
description={
|
||||
scanning
|
||||
? "Scanning the LAN…"
|
||||
: `${hosts.length} host${hosts.length === 1 ? "" : "s"} — saved and on your network`
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
bottomSeparator={hosts.length ? "standard" : "none"}
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => showModal(<AddHostModal onDone={refresh} />)}
|
||||
>
|
||||
<FaPlus style={{ marginRight: "0.5em" }} />
|
||||
Add
|
||||
</DialogButton>
|
||||
<DialogButton style={actionButton} disabled={scanning} onClick={refresh}>
|
||||
{scanning ? (
|
||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||
) : (
|
||||
<FaSyncAlt style={{ marginRight: "0.5em" }} />
|
||||
)}
|
||||
{scanning ? "Scanning…" : "Refresh"}
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
|
||||
{hosts.length === 0 && !scanning && (
|
||||
<Field
|
||||
focusable={false}
|
||||
label="No hosts yet"
|
||||
description="Add one by address with +, or start a Punktfunk host on this network and refresh. The setup guide (About tab) covers installing a host."
|
||||
/>
|
||||
)}
|
||||
{hosts.map((h) => (
|
||||
<HostRow
|
||||
key={h.fp || `${h.addr}:${h.port}`}
|
||||
host={h}
|
||||
onChanged={refresh}
|
||||
onGames={() =>
|
||||
showModal(
|
||||
<GamePickerModal
|
||||
host={toHost(h)}
|
||||
pins={pins}
|
||||
clientUpdatePending={clientUpdatePending}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Pinned games — also the cleanup surface for pins whose host is gone from the scan. */}
|
||||
{pins.pins.length > 0 && (
|
||||
<>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="Pinned games"
|
||||
description="One-tap streams — they also live in the quick-access menu"
|
||||
bottomSeparator="standard"
|
||||
/>
|
||||
{pins.pins.map((pin) => {
|
||||
const online = pinIsOnline(pin, hosts);
|
||||
return (
|
||||
<Field
|
||||
key={`${pin.host_fp}:${pin.game_id}`}
|
||||
label={pin.title}
|
||||
description={`${storeLabel(pin.store)} · ${pin.host_name}${
|
||||
online ? "" : " · offline?"
|
||||
}${pin.paired ? "" : " · pairing required"}`}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => streamPin(pin, hosts.map(toHost), pins)}
|
||||
>
|
||||
<FaPlay style={{ marginRight: "0.4em" }} />
|
||||
Play
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => pins.removePin(pin.host_fp, pin.game_id)}
|
||||
>
|
||||
Remove
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// NOT `tabScroll`: the settings screen is a SidebarNavigation, which lays out its own rail +
|
||||
// content pane and scrolls the pane itself. Wrapping it in an outer scroll area would give it an
|
||||
// indefinite height to fill, collapsing the rail — so this pane only hands it the full height and
|
||||
// keeps its hands off the overflow. The footer inset lives inside the pages instead.
|
||||
const settingsPane: CSSProperties = { height: "100%", overflow: "hidden" };
|
||||
|
||||
const SettingsTab: FC = () => (
|
||||
<div style={settingsPane}>
|
||||
<SettingsSection />
|
||||
</div>
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// About — plugin version + explicit update check, docs link, stream-exit help, force-stop,
|
||||
// and the destructive "reset everything" action.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
async function forceStopStream(): Promise<void> {
|
||||
stopStream(); // ask Steam to end the "game" first (clean path)
|
||||
const res = await killStream(); // then the flatpak-level hammer for a wedged client
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: res.ok ? "Stream client stopped." : "Couldn’t stop the stream client.",
|
||||
});
|
||||
}
|
||||
|
||||
function confirmReset(refreshers: Array<() => void | Promise<void>>): void {
|
||||
showModal(
|
||||
<ConfirmModal
|
||||
strTitle="Reset Punktfunk?"
|
||||
strDescription="Clears every saved host, your stream settings, and all pinned games on this Deck. Your client identity is kept, so you'll re-pair hosts to reconnect. This can't be undone."
|
||||
strOKButtonText="Reset"
|
||||
bDestructiveWarning
|
||||
onOK={() => void resetAll(refreshers)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
const AboutTab: FC<{
|
||||
update: UpdateInfo | null;
|
||||
checking: boolean;
|
||||
check: (force: boolean) => Promise<UpdateInfo | null>;
|
||||
onReset: () => void;
|
||||
}> = ({ update, checking, check, onReset }) => (
|
||||
<div style={tabScroll}>
|
||||
<Field
|
||||
label="Version"
|
||||
description={
|
||||
update
|
||||
? `v${update.current}${
|
||||
update.channel ? ` · ${update.channel} channel` : " · development build"
|
||||
}`
|
||||
: "…"
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
disabled={checking}
|
||||
onClick={() => void checkForUpdatesNow(check)}
|
||||
>
|
||||
{checking ? <Spinner style={{ height: "1em" }} /> : "Check for updates"}
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
{/* What the client IS, so "why is there no Update button?" has a visible answer. The
|
||||
install kind decides everything below it. */}
|
||||
{!!update?.client_install && (
|
||||
<Field
|
||||
label="Client"
|
||||
description={`${clientInstallLabel(update.client_install)}${
|
||||
update.client_current ? ` · ${update.client_current}` : ""
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
{hasUpdate(update) && (
|
||||
<Field
|
||||
label={
|
||||
update!.update_available
|
||||
? `Plugin update — v${update!.latest}${
|
||||
update!.client_update_available ? " + client" : ""
|
||||
}`
|
||||
: `Client update — ${update!.client_latest || "available"}`
|
||||
}
|
||||
description={
|
||||
// Only promise a one-tap install when there is one. On a notify-only install the
|
||||
// row becomes the command itself, which is the whole answer for that box.
|
||||
clientUpdateIsManualOnly(update) && !update!.update_available
|
||||
? update!.client_opt_in || update!.client_command
|
||||
: "Installing can take a couple of minutes; Decky reloads the plugin when done"
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
{clientUpdateIsManualOnly(update) && !update!.update_available ? null : (
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} onClick={() => applyUpdate(update!, check)}>
|
||||
<FaDownload style={{ marginRight: "0.4em" }} />
|
||||
Update
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
{!!update?.client_error && (
|
||||
<Field
|
||||
label="Client update check"
|
||||
description={
|
||||
update.client_error === "client-outdated"
|
||||
? "This client predates update checks — update it once by hand and the check starts working."
|
||||
: "Couldn’t check the client for updates."
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Field
|
||||
label="Setup guide"
|
||||
description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={actionButton}
|
||||
onClick={() => Navigation.NavigateToExternalWeb(DOCS_URL)}
|
||||
>
|
||||
<FaExternalLinkAlt style={{ marginRight: "0.4em" }} />
|
||||
Open
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<Field
|
||||
focusable={false}
|
||||
label="Leaving a stream"
|
||||
description="Hold L1 + R1 + Start + Select inside the stream, or close the “game” from the Steam overlay — either returns you to Gaming Mode."
|
||||
/>
|
||||
<Field
|
||||
label="Stream stuck?"
|
||||
description="Force-stop the stream client if a session wedges"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} onClick={() => void forceStopStream()}>
|
||||
Force-stop
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<Field
|
||||
label="Reset Punktfunk"
|
||||
description="Clear saved hosts, stream settings, and pinned games on this Deck (keeps your client identity)"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} onClick={onReset}>
|
||||
<FaTrashAlt style={{ marginRight: "0.4em" }} />
|
||||
Reset
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PunktfunkPage: FC = () => {
|
||||
const { hosts: discovered, scanning, refresh: refreshDiscovered } = useHosts();
|
||||
const { saved, loading: loadingSaved, refresh: refreshSaved } = useSavedHosts();
|
||||
const { info: update, checking, check } = useUpdate();
|
||||
const pins = usePins();
|
||||
const [tab, setTab] = useState("hosts");
|
||||
|
||||
const hosts = mergeHosts(saved, discovered);
|
||||
// A host action (pair/add/edit/forget) can change either store, so refresh both.
|
||||
const refreshHosts = () => {
|
||||
void refreshDiscovered();
|
||||
void refreshSaved();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "40px",
|
||||
height: "calc(100% - 40px)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* Header is title + back only — updates live on the About tab (and the QAM banner). */}
|
||||
<Focusable
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "1em",
|
||||
padding: "0 2.5em",
|
||||
marginBottom: "0.4em",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<DialogButton style={iconButton} onClick={() => Navigation.NavigateBack()}>
|
||||
<FaArrowLeft />
|
||||
</DialogButton>
|
||||
<div className={staticClasses?.Title} style={{ flex: 1, margin: 0 }}>
|
||||
Punktfunk
|
||||
</div>
|
||||
</Focusable>
|
||||
|
||||
{/* Two things fight each other on an L1/R1 tab switch:
|
||||
1. Valve's Tabs slides the incoming panel in from the right with a CSS transform.
|
||||
2. `autoFocusContents` then focuses a control inside that still-offscreen panel, which
|
||||
fires scrollIntoView. Because the panel is offset by a *transform* (not by scroll
|
||||
position), scrollIntoView can't satisfy it by scrolling any one ancestor, so it walks
|
||||
up and pans the whole page — the "screen jumps right, then animates back" glitch.
|
||||
Dropping autoFocusContents removes the scrollIntoView entirely, so nothing fights the
|
||||
slide. L1/R1 still cycles tabs (that handler lives on the Tabs focus scope, active while
|
||||
focus is anywhere inside — including the tab strip); after a switch, focus stays on the
|
||||
strip and Down enters the content, which is how Steam's own tabbed pages behave.
|
||||
The overflow:hidden clip stays as defense-in-depth against any stray horizontal pan. */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
||||
<Tabs
|
||||
activeTab={tab}
|
||||
onShowTab={(id: string) => setTab(id)}
|
||||
tabs={[
|
||||
{
|
||||
id: "hosts",
|
||||
title: "Hosts",
|
||||
content: (
|
||||
<HostsTab
|
||||
hosts={hosts}
|
||||
scanning={scanning || loadingSaved}
|
||||
refresh={refreshHosts}
|
||||
pins={pins}
|
||||
clientUpdatePending={!!update?.client_update_available}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
title: "Settings",
|
||||
content: <SettingsTab />,
|
||||
},
|
||||
{
|
||||
id: "about",
|
||||
title: "About",
|
||||
content: (
|
||||
<AboutTab
|
||||
update={update}
|
||||
checking={checking}
|
||||
check={check}
|
||||
onReset={() => confirmReset([refreshHosts, pins.refresh])}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Full page behind the boundary — registered as the /punktfunk route.
|
||||
export const PunktfunkRoute: FC = () => (
|
||||
<PluginErrorBoundary>
|
||||
<PunktfunkPage />
|
||||
</PluginErrorBoundary>
|
||||
);
|
||||
@@ -3,10 +3,32 @@
|
||||
import { DialogButton, Focusable, ModalRoot, Spinner } from "@decky/ui";
|
||||
import { toaster } from "@decky/api";
|
||||
import { FC, useState } from "react";
|
||||
import { Host, pair } from "./backend";
|
||||
import { pair } from "./backend";
|
||||
import { HostView } from "./hooks";
|
||||
|
||||
/**
|
||||
* User-facing copy for a failed ceremony. The CLI's stable exit codes say WHICH failure it was,
|
||||
* so the keypad can name the fix instead of echoing a log line: `refused` is overwhelmingly a
|
||||
* mistyped PIN or a host nobody armed, and telling someone to check their network for that
|
||||
* would send them the wrong way entirely.
|
||||
*/
|
||||
function pairErrorBody(error: string | undefined, name: string): string {
|
||||
switch (error) {
|
||||
case "refused":
|
||||
return "Wrong PIN, or the host isn’t showing one. Arm pairing again and retry.";
|
||||
case "unreachable":
|
||||
return `Couldn’t reach ${name}.`;
|
||||
case "client-outdated":
|
||||
return "Update the Punktfunk client to pair from here.";
|
||||
case "client-unavailable":
|
||||
return "Couldn’t reach the Punktfunk client — is it still installed?";
|
||||
default:
|
||||
return "Pairing failed.";
|
||||
}
|
||||
}
|
||||
|
||||
export const PairModal: FC<{
|
||||
host: Host;
|
||||
host: HostView;
|
||||
closeModal?: () => void;
|
||||
onPaired: () => void;
|
||||
}> = ({ host, closeModal, onPaired }) => {
|
||||
@@ -21,13 +43,13 @@ export const PairModal: FC<{
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await pair(host.host, host.port, pin, "Steam Deck");
|
||||
const res = await pair(host.addr, host.port, pin, "Steam Deck");
|
||||
if (res.ok) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Paired with ${host.name}` });
|
||||
onPaired();
|
||||
closeModal?.();
|
||||
} else {
|
||||
setError(res.error ?? "pairing failed");
|
||||
setError(pairErrorBody(res.error, host.name));
|
||||
setPin("");
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,657 +0,0 @@
|
||||
// Stream settings — the client's WHOLE settings store, written to the JSON the client reads on
|
||||
// launch (main.py set_settings, merged onto what's on disk). This is the same
|
||||
// `client-gtk-settings.json` the desktop client and the console's settings screen own, so a value
|
||||
// changed in any of the three shows in the other two.
|
||||
//
|
||||
// SHAPE OF THIS SCREEN. Thirty rows is too many to scroll past on a thumbstick, so they are split
|
||||
// across a `SidebarNavigation` — the same left-rail-of-categories layout SteamOS's own Settings
|
||||
// uses, and the one Deck users already know. Every page fits on screen without scrolling, which is
|
||||
// the whole point of the split: the rail is the index, so nothing is more than one hop away.
|
||||
//
|
||||
// The categories, their order, and the wording of the rows are the console's settings screen
|
||||
// (pf-console-ui/src/screens/settings.rs) — that screen is the other settings editor a user
|
||||
// reaches without leaving Gaming Mode, and two different orders for one store is how people stop
|
||||
// trusting either. It shows them as one steppable list because it has no pointer and no room for
|
||||
// a rail; here they become the rail's pages, same groups, same sequence. Three more rules:
|
||||
//
|
||||
// • A setting that depends on another is INDENTED under it and DISABLED, never hidden — the
|
||||
// console dims those rows rather than dropping them, and a row that vanishes as you toggle
|
||||
// the one above it is a moving target for a thumbstick.
|
||||
// • A picker whose options this device doesn't have doesn't appear at all (the GPU row on a
|
||||
// one-GPU Deck). A dead control is worse than an absent one.
|
||||
// • Anything that behaves differently *here* than it does on a desktop says so in its own
|
||||
// description, rather than being silently dropped from the screen.
|
||||
//
|
||||
// The accepted gamepad/compositor/codec/decoder names mirror punktfunk-core's `*Pref::from_name`
|
||||
// and the console's tables; the tier/mode names mirror the `StatsVerbosity` / `TouchMode` /
|
||||
// `MouseMode` enums, which serialize lowercase.
|
||||
import {
|
||||
DialogButton,
|
||||
Dropdown,
|
||||
Field,
|
||||
SidebarNavigation,
|
||||
SliderField,
|
||||
Spinner,
|
||||
ToggleField,
|
||||
} from "@decky/ui";
|
||||
import { CSSProperties, FC, ReactElement, ReactNode, useEffect, useState } from "react";
|
||||
import {
|
||||
FaDesktop,
|
||||
FaGamepad,
|
||||
FaHandPointer,
|
||||
FaSlidersH,
|
||||
FaTv,
|
||||
FaVideo,
|
||||
FaVolumeUp,
|
||||
} from "react-icons/fa";
|
||||
import {
|
||||
AudioDevice,
|
||||
DeviceLists,
|
||||
getSettings,
|
||||
listDevices,
|
||||
refreshDevices,
|
||||
setSettings,
|
||||
StreamSettings,
|
||||
} from "./backend";
|
||||
import { actionButton, RowActions } from "./ui";
|
||||
|
||||
// Decky's Dropdown has no width prop — it fills whatever container it's in, and a
|
||||
// `childrenContainerWidth="max"` Field is the whole row. Wrapping it in this fit-content shell
|
||||
// (inside the right-aligned RowActions) shrinks the control to its selected label, with a floor
|
||||
// so short values like "60 Hz" don't collapse to a nub and a ceiling so nothing runs edge to
|
||||
// edge. Matches the right-aligned, content-sized buttons everywhere else.
|
||||
const selectShell: CSSProperties = {
|
||||
width: "fit-content",
|
||||
minWidth: "10em",
|
||||
maxWidth: "24em",
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Option tables — the console's, so the two Gaming-Mode editors offer the same choices.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
// "native" and "match" are virtual: they store `width`/`height` of 0 with `match_window` off/on.
|
||||
// Match window is offered even though this plugin's launches are always fullscreen (where it
|
||||
// degenerates to the display's native mode) — leaving it out would make the row lie about a
|
||||
// store the desktop client can set it in.
|
||||
const MATCH_WINDOW = "match";
|
||||
const RESOLUTIONS: [number, number, string][] = [
|
||||
[0, 0, "Native display"],
|
||||
[1280, 720, "1280 × 720"],
|
||||
[1280, 800, "1280 × 800 (Deck)"],
|
||||
[1920, 1080, "1920 × 1080"],
|
||||
[2560, 1440, "2560 × 1440"],
|
||||
[3840, 2160, "3840 × 2160"],
|
||||
];
|
||||
const resolutionKey = (w: number, h: number): string => (w === 0 && h === 0 ? "native" : `${w}x${h}`);
|
||||
|
||||
const REFRESH = [0, 30, 60, 90, 120];
|
||||
// Render-resolution multipliers (mirrors punktfunk_core::render_scale::PRESETS). 1.0 = native.
|
||||
const RENDER_SCALES = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
|
||||
const renderScaleLabel = (x: number): string =>
|
||||
x === 1 ? "Native (1×)" : x > 1 ? `${x}× · supersample` : `${x}×`;
|
||||
|
||||
const COMPOSITORS: [string, string][] = [
|
||||
["auto", "Automatic"],
|
||||
["kwin", "KDE Plasma (KWin)"],
|
||||
["wlroots", "Sway (wlroots)"],
|
||||
["mutter", "GNOME (Mutter)"],
|
||||
["gamescope", "gamescope"],
|
||||
];
|
||||
const CODECS: [string, string][] = [
|
||||
["auto", "Automatic"],
|
||||
["hevc", "HEVC (H.265)"],
|
||||
["h264", "H.264 (AVC)"],
|
||||
["av1", "AV1"],
|
||||
// Opt-in wired-LAN low-latency codec (100–400 Mbit/s class, 8-bit SDR). Only ever selected
|
||||
// when the host advertises it too; anything else falls back to HEVC.
|
||||
["pyrowave", "PyroWave (wired LAN)"],
|
||||
];
|
||||
const DECODERS: [string, string][] = [
|
||||
["auto", "Automatic"],
|
||||
["vulkan", "Vulkan Video"],
|
||||
["vaapi", "VAAPI"],
|
||||
["software", "Software"],
|
||||
];
|
||||
// Presentation intent — the `present_priority` key shared with the Apple and Android clients, so
|
||||
// one profile reads the same on every device.
|
||||
const PRESENT_PRIORITIES: [string, string][] = [
|
||||
["latency", "Lowest latency"],
|
||||
["smooth", "Smoothness"],
|
||||
];
|
||||
// Smoothness buffer depth in frames; 0 = Automatic (resolves to 2).
|
||||
const SMOOTH_BUFFERS: [number, string][] = [
|
||||
[0, "Automatic"],
|
||||
[1, "1 frame"],
|
||||
[2, "2 frames"],
|
||||
[3, "3 frames"],
|
||||
];
|
||||
const AUDIO_CHANNELS: [number, string][] = [
|
||||
[2, "Stereo"],
|
||||
[6, "5.1 surround"],
|
||||
[8, "7.1 surround"],
|
||||
];
|
||||
const GAMEPADS: [string, string][] = [
|
||||
["auto", "Automatic"],
|
||||
["xbox360", "Xbox 360"],
|
||||
["xboxone", "Xbox One"],
|
||||
["dualsense", "DualSense"],
|
||||
["dualshock4", "DualShock 4"],
|
||||
["steamdeck", "Steam Deck"],
|
||||
];
|
||||
const TOUCH_MODES: [string, string][] = [
|
||||
["trackpad", "Trackpad"],
|
||||
["pointer", "Direct pointer"],
|
||||
["touch", "Touch passthrough"],
|
||||
];
|
||||
const MOUSE_MODES: [string, string][] = [
|
||||
["capture", "Capture (games)"],
|
||||
["desktop", "Desktop (absolute)"],
|
||||
];
|
||||
const STATS_TIERS: [string, string][] = [
|
||||
["off", "Off"],
|
||||
["compact", "Compact"],
|
||||
["normal", "Normal"],
|
||||
["detailed", "Detailed"],
|
||||
];
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Row primitives — every picker row is Field + right-aligned, content-sized Dropdown, so the
|
||||
// twelve of them below stay one line each and can't drift apart.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
const SelectRow = <T extends string | number>({
|
||||
label,
|
||||
description,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
formatUnknown,
|
||||
disabled,
|
||||
indent,
|
||||
}: {
|
||||
label: string;
|
||||
description?: ReactNode;
|
||||
options: [T, string][];
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
// How to name a stored value this table doesn't list (see below); defaults to the raw value.
|
||||
formatUnknown?: (v: T) => string;
|
||||
disabled?: boolean;
|
||||
indent?: boolean;
|
||||
}): ReactElement => {
|
||||
// A Dropdown can only display a value that is one of its options, and this store has four other
|
||||
// writers — the desktop client, the console, a settings profile, a newer client with presets
|
||||
// this build doesn't know. Rather than render a blank control (or, worse, silently show a
|
||||
// different value than the stream will actually use), carry the stored one as its own entry.
|
||||
const shown: [T, string][] = options.some(([v]) => v === value)
|
||||
? options
|
||||
: [...options, [value, formatUnknown ? formatUnknown(value) : String(value)]];
|
||||
return (
|
||||
<Field
|
||||
label={label}
|
||||
description={description}
|
||||
disabled={disabled}
|
||||
indentLevel={indent ? 1 : undefined}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
disabled={disabled}
|
||||
rgOptions={shown.map(([data, l]) => ({ data, label: l }))}
|
||||
selectedOption={value}
|
||||
onChange={(o) => onChange(o.data as T)}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
// An audio-endpoint picker. The stored value is a PipeWire `node.name`; "" means "whatever the OS
|
||||
// is using". A stored endpoint that isn't in the current enumeration still gets an entry — it is
|
||||
// a real preference that simply isn't plugged in right now, and dropping it would silently
|
||||
// re-point the next stream at the default without ever showing the user why.
|
||||
const DeviceRow: FC<{
|
||||
label: string;
|
||||
description: string;
|
||||
devices: AudioDevice[] | null;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
indent?: boolean;
|
||||
}> = ({ label, description, devices, value, onChange, disabled, indent }) => {
|
||||
const options: [string, string][] = [["", "System default"]];
|
||||
for (const d of devices ?? []) options.push([d.name, d.description]);
|
||||
if (value && !options.some(([name]) => name === value)) {
|
||||
options.push([value, `${value} (not connected)`]);
|
||||
}
|
||||
return (
|
||||
<SelectRow
|
||||
label={label}
|
||||
description={devices === null ? "Reading this device's audio endpoints…" : description}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled || devices === null}
|
||||
indent={indent}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// The pages. One settings object, seven views on it — every page takes the same context rather
|
||||
// than fetching or holding state of its own, so a change on one page is visible on the others
|
||||
// the moment you switch.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
interface PageCtx {
|
||||
s: StreamSettings;
|
||||
patch: (p: Partial<StreamSettings>) => void;
|
||||
devices: DeviceLists | null;
|
||||
reading: boolean;
|
||||
readDevices: (again: boolean) => void;
|
||||
}
|
||||
|
||||
// SidebarNavigation gives each page Steam's own padding, but the routed page still renders
|
||||
// UNDER Gaming Mode's footer hint bar, so the last row of a page needs to clear it (the same
|
||||
// inset the tabs use).
|
||||
const pageBody: CSSProperties = { paddingBottom: "80px" };
|
||||
|
||||
const StreamPage: FC<PageCtx> = ({ s, patch }) => {
|
||||
const renderScale = s.render_scale ?? 1;
|
||||
const resolution = s.match_window ? MATCH_WINDOW : resolutionKey(s.width, s.height);
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Resolution"
|
||||
description="The host creates a virtual display at exactly this size — no scaling. Match window follows the stream window instead, which in Gaming Mode means the Deck's native size."
|
||||
options={[
|
||||
...RESOLUTIONS.map(([w, h, label]) => [resolutionKey(w, h), label] as [string, string]),
|
||||
[MATCH_WINDOW, "Match window"] as [string, string],
|
||||
]}
|
||||
value={resolution}
|
||||
// A size set from a desktop profile that isn't one of these presets, spelled the way the
|
||||
// presets are rather than left as the raw "1600x900" key.
|
||||
formatUnknown={(v) => v.replace("x", " × ")}
|
||||
onChange={(v) => {
|
||||
if (v === MATCH_WINDOW) {
|
||||
// The tri-state the console stores: the flag on, the explicit size cleared.
|
||||
patch({ match_window: true, width: 0, height: 0 });
|
||||
return;
|
||||
}
|
||||
const found = RESOLUTIONS.find(([w, h]) => resolutionKey(w, h) === v);
|
||||
patch({ match_window: false, width: found?.[0] ?? 0, height: found?.[1] ?? 0 });
|
||||
}}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Refresh rate"
|
||||
description="Native follows the display the stream is on."
|
||||
options={REFRESH.map((r) => [r, r === 0 ? "Native" : `${r} Hz`] as [number, string])}
|
||||
value={s.refresh_hz}
|
||||
formatUnknown={(v) => `${v} Hz`}
|
||||
onChange={(v) => patch({ refresh_hz: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Render scale"
|
||||
description="The host renders larger or smaller than the stream mode and the Deck resamples — above 1× supersamples for sharpness, below 1× saves bandwidth."
|
||||
options={RENDER_SCALES.map((x) => [x, renderScaleLabel(x)] as [number, string])}
|
||||
// Snap the stored value to the nearest preset so the dropdown always shows a match.
|
||||
value={RENDER_SCALES.reduce((best, x) =>
|
||||
Math.abs(x - renderScale) < Math.abs(best - renderScale) ? x : best,
|
||||
)}
|
||||
onChange={(v) => patch({ render_scale: v })}
|
||||
/>
|
||||
<SliderField
|
||||
label="Bitrate"
|
||||
description="0 = the host's own default (20 Mbit/s)."
|
||||
value={Math.round(s.bitrate_kbps / 1000)}
|
||||
min={0}
|
||||
max={150}
|
||||
step={5}
|
||||
showValue
|
||||
valueSuffix=" Mbit/s"
|
||||
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Host compositor"
|
||||
description="Which compositor drives the virtual display — honoured only if it's available on the host. Automatic suits almost every host."
|
||||
options={COMPOSITORS}
|
||||
value={s.compositor}
|
||||
onChange={(v) => patch({ compositor: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const VideoPage: FC<PageCtx> = ({ s, patch, devices }) => {
|
||||
// Only worth a row on a box that actually has a choice to make. A Deck has one adapter, and a
|
||||
// picker with a single option is a control that can't do anything.
|
||||
const showGpuRow = (devices?.adapters.length ?? 0) > 1;
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Video codec"
|
||||
description="A preference — the host falls back when its GPU can't encode this one."
|
||||
options={CODECS}
|
||||
value={s.codec ?? "auto"}
|
||||
onChange={(v) => patch({ codec: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Video decoder"
|
||||
description="How the Deck decodes the stream. Automatic prefers Vulkan Video, then VAAPI, then software."
|
||||
options={DECODERS}
|
||||
value={s.decoder ?? "auto"}
|
||||
onChange={(v) => patch({ decoder: v })}
|
||||
/>
|
||||
{showGpuRow && (
|
||||
<SelectRow
|
||||
label="Decode GPU"
|
||||
description="Which adapter decodes and presents the stream. Automatic picks the discrete GPU where there is one."
|
||||
options={[
|
||||
["", "Automatic"],
|
||||
...(devices?.adapters ?? []).map((a) => [a, a] as [string, string]),
|
||||
]}
|
||||
value={s.adapter ?? ""}
|
||||
onChange={(v) => patch({ adapter: v })}
|
||||
/>
|
||||
)}
|
||||
<ToggleField
|
||||
label="10-bit HDR"
|
||||
description="Advertise HDR10 so the host sends 10-bit when the content is HDR. Off means never ask for 10-bit."
|
||||
checked={s.hdr_enabled ?? true}
|
||||
onChange={(v) => patch({ hdr_enabled: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Full chroma (4:4:4)"
|
||||
description="Full-colour video: crisp small text and thin lines, at more bandwidth. Needs an NVIDIA host (NVENC) or the PyroWave codec — other encoders stream 4:2:0 and the session falls back silently."
|
||||
checked={s.enable_444 ?? false}
|
||||
onChange={(v) => patch({ enable_444: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PresentationPage: FC<PageCtx> = ({ s, patch }) => {
|
||||
const smooth = (s.present_priority ?? "latency") === "smooth";
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Prioritize"
|
||||
description="What to optimise for when a decoded frame is ready. Lowest latency shows each frame the moment the display can take it — a network hiccup becomes an occasional repeated or skipped frame. Smoothness buffers a little to even those out."
|
||||
options={PRESENT_PRIORITIES}
|
||||
value={s.present_priority ?? "latency"}
|
||||
onChange={(v) => patch({ present_priority: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Smoothness buffer"
|
||||
description="Frames held back before showing. Each one absorbs about a refresh of network hiccup and adds a refresh of delay. Automatic holds two."
|
||||
options={SMOOTH_BUFFERS}
|
||||
value={s.smooth_buffer ?? 0}
|
||||
formatUnknown={(v) => `${v} frames`}
|
||||
onChange={(v) => patch({ smooth_buffer: v })}
|
||||
disabled={!smooth}
|
||||
indent
|
||||
/>
|
||||
<ToggleField
|
||||
label="V-Sync"
|
||||
description="Tear-free. Off removes the wait for the screen's refresh — the lowest possible delay, at the cost of visible tearing. Best-effort: not every driver offers it, and the Detailed stats overlay names the mode actually in use."
|
||||
checked={s.vsync ?? true}
|
||||
onChange={(v) => patch({ vsync: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Follow variable refresh"
|
||||
description="On a VRR screen, let the panel refresh in step with the stream instead of on a fixed cadence. Applies to fullscreen sessions — which a Gaming-Mode stream always is — and is harmless on a fixed-refresh screen."
|
||||
checked={s.allow_vrr ?? true}
|
||||
onChange={(v) => patch({ allow_vrr: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AudioPage: FC<PageCtx> = ({ s, patch, devices, reading, readDevices }) => {
|
||||
const micOn = s.mic_enabled;
|
||||
// What the pickers get: null while the enumeration is in flight (they show a loading state),
|
||||
// [] when it answered but couldn't read the endpoints (System default plus whatever is
|
||||
// stored), and the real list otherwise.
|
||||
const endpoints = (list: AudioDevice[] | undefined): AudioDevice[] | null =>
|
||||
reading || !devices ? null : devices.ok ? (list ?? []) : [];
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Audio channels"
|
||||
description="The speaker layout requested from the host, which clamps it to what it can capture."
|
||||
options={AUDIO_CHANNELS}
|
||||
value={s.audio_channels ?? 2}
|
||||
formatUnknown={(v) => `${v} channels`}
|
||||
onChange={(v) => patch({ audio_channels: v })}
|
||||
/>
|
||||
<DeviceRow
|
||||
label="Output device"
|
||||
description="Where stream audio plays. System default follows whatever the Deck is using, including a headset you plug in mid-stream."
|
||||
devices={endpoints(devices?.sinks)}
|
||||
value={s.speaker_device ?? ""}
|
||||
onChange={(v) => patch({ speaker_device: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Stream microphone"
|
||||
description="Send the Deck's microphone to the host's virtual mic. Ctrl+Alt+Shift+V mutes and unmutes it mid-stream."
|
||||
checked={micOn}
|
||||
onChange={(v) => patch({ mic_enabled: v })}
|
||||
/>
|
||||
<DeviceRow
|
||||
label="Microphone device"
|
||||
description="Which input the mic uplink captures from."
|
||||
devices={endpoints(devices?.sources)}
|
||||
value={s.mic_device ?? ""}
|
||||
onChange={(v) => patch({ mic_device: v })}
|
||||
disabled={!micOn}
|
||||
indent
|
||||
/>
|
||||
<ToggleField
|
||||
label="Echo cancellation"
|
||||
description="Stops the host's audio, playing from the Deck's speakers, being picked up and sent back. Turn it off if your microphone already runs its own processing."
|
||||
checked={s.echo_cancel ?? true}
|
||||
onChange={(v) => patch({ echo_cancel: v })}
|
||||
disabled={!micOn}
|
||||
indentLevel={1}
|
||||
/>
|
||||
{/* The escape hatch for a headset plugged in after this page was opened, and the honest
|
||||
answer when the enumeration failed outright (a client too old to ship the session
|
||||
binary). Rendered unconditionally, including while it is reading: a row that comes and
|
||||
goes under a thumbstick is a moving target, so only its wording changes. */}
|
||||
<Field
|
||||
label={
|
||||
!reading && devices && !devices.ok ? "Couldn't read this device's hardware" : "Devices"
|
||||
}
|
||||
description={
|
||||
reading
|
||||
? "Reading this device's audio endpoints and GPUs…"
|
||||
: devices && !devices.ok
|
||||
? "The output, microphone and GPU pickers fall back to Automatic. Reading them needs the client's session binary, which a client older than the two-binary split doesn't ship — update it from the About tab."
|
||||
: "Plugged something in just now? Read the audio endpoints and GPUs again."
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} disabled={reading} onClick={() => readDevices(true)}>
|
||||
{reading ? <Spinner style={{ height: "1em" }} /> : "Refresh"}
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ControllersPage: FC<PageCtx> = ({ s, patch }) => {
|
||||
const forwarding = s.gamepad_forwarding ?? true;
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<ToggleField
|
||||
label="Forward controllers"
|
||||
description="Send controllers connected to the Deck to the host. Turn it off when your controller already reaches the host another way — USB passthrough such as VirtualHere, or a pad plugged into the host — so games don't see two of them."
|
||||
checked={forwarding}
|
||||
onChange={(v) => patch({ gamepad_forwarding: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Controller type"
|
||||
description="The virtual pad the host creates. Automatic matches the controller you're holding."
|
||||
options={GAMEPADS}
|
||||
value={s.gamepad}
|
||||
onChange={(v) => patch({ gamepad: v })}
|
||||
disabled={!forwarding}
|
||||
indent
|
||||
/>
|
||||
{forwarding && (s.gamepad === "steamdeck" || s.gamepad === "auto") && (
|
||||
<Field
|
||||
label="⚠ Disable Steam Input"
|
||||
description="On a Deck, Automatic forwards the built-in controller as a Steam Deck pad — paddles, both trackpads, and gyro included. For that, Steam Input must be OFF for Punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
|
||||
indentLevel={1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PointerPage: FC<PageCtx> = ({ s, patch }) => (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Touch mode"
|
||||
description="How the touchscreen drives the host: Trackpad (relative cursor, tap to click), Direct pointer (the cursor jumps to your finger), or Touch passthrough (every finger is a host contact — only helps apps that understand touch)."
|
||||
options={TOUCH_MODES}
|
||||
value={s.touch_mode ?? "trackpad"}
|
||||
onChange={(v) => patch({ touch_mode: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Mouse mode"
|
||||
description="How a physical mouse drives the host: Capture locks the pointer for games, Desktop leaves it free and sends absolute positions. Ctrl+Alt+Shift+M switches it live mid-stream."
|
||||
options={MOUSE_MODES}
|
||||
value={s.mouse_mode ?? "capture"}
|
||||
onChange={(v) => patch({ mouse_mode: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Invert scroll direction"
|
||||
description="Reverses the wheel and trackpad scroll direction sent to the host."
|
||||
checked={s.invert_scroll ?? false}
|
||||
onChange={(v) => patch({ invert_scroll: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Capture system shortcuts"
|
||||
description="Sends Alt+Tab, Super and friends to the host while input is captured, instead of leaving them to the local desktop. Gaming Mode is gamescope, which has no shortcuts to hold back — this is for a keyboard attached to the Deck in Desktop Mode, and for the desktop client sharing these settings."
|
||||
checked={s.inhibit_shortcuts}
|
||||
onChange={(v) => patch({ inhibit_shortcuts: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const InterfacePage: FC<PageCtx> = ({ s, patch }) => {
|
||||
// `Settings::stats_verbosity`: no tier = a pre-tier store, resolved through the legacy bool,
|
||||
// which itself defaults to true.
|
||||
const statsTier = s.stats_verbosity ?? ((s.show_stats ?? true) ? "normal" : "off");
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Statistics overlay"
|
||||
description="How much the in-stream overlay shows: Compact (fps · latency · bitrate on one line) → Normal → Detailed. A three-finger tap on the touchscreen cycles it mid-stream."
|
||||
options={STATS_TIERS}
|
||||
value={statsTier}
|
||||
// Both keys, in sync — the same pairing `Settings::set_stats_verbosity` keeps, so a
|
||||
// client too old for the tiers still honours an Off chosen here.
|
||||
onChange={(v) => patch({ stats_verbosity: v, show_stats: v !== "off" })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Wake hosts automatically"
|
||||
description="Send Wake-on-LAN to a sleeping host before connecting and wait for it to boot. Turn it off for hosts reached over a VPN, where an offline-looking host is really just unreachable by broadcast and the wait only adds delay."
|
||||
checked={s.auto_wake ?? true}
|
||||
onChange={(v) => patch({ auto_wake: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Show game library in the client"
|
||||
description="Lets the client's own host cards browse a paired host's games. This plugin's library browser works either way — this is for the client's screens."
|
||||
checked={s.library_enabled ?? false}
|
||||
onChange={(v) => patch({ library_enabled: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Start streams fullscreen"
|
||||
description="Streams open fullscreen instead of windowed. Launches from this plugin are always fullscreen whatever this says — it's here because the desktop client reads the same settings."
|
||||
checked={s.fullscreen_on_stream ?? true}
|
||||
onChange={(v) => patch({ fullscreen_on_stream: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
export const SettingsSection: FC = () => {
|
||||
const [s, setS] = useState<StreamSettings | null>(null);
|
||||
// null until the enumeration answers — the pickers show a loading state rather than briefly
|
||||
// claiming this device has no endpoints.
|
||||
const [devices, setDevices] = useState<DeviceLists | null>(null);
|
||||
const [reading, setReading] = useState(true);
|
||||
|
||||
const readDevices = (again: boolean) => {
|
||||
setReading(true);
|
||||
void (again ? refreshDevices() : listDevices())
|
||||
.then(setDevices)
|
||||
.finally(() => setReading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void getSettings().then(setS);
|
||||
// Deliberately not awaited together with the settings: a cold flatpak initialising Vulkan
|
||||
// takes seconds, and the rest of the screen must not wait for it.
|
||||
readDevices(false);
|
||||
}, []);
|
||||
|
||||
const patch = (p: Partial<StreamSettings>) => {
|
||||
setS((cur) => {
|
||||
if (!cur) return cur;
|
||||
const next = { ...cur, ...p };
|
||||
void setSettings(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (!s) return <Spinner style={{ height: "1.5em" }} />;
|
||||
|
||||
const ctx: PageCtx = { s, patch, devices, reading, readDevices };
|
||||
return (
|
||||
<SidebarNavigation
|
||||
// We are already inside the plugin's own `/punktfunk` route, rendered in a tab. Route
|
||||
// reporting would have this nav push entries of its own onto the router and fight the
|
||||
// page for the back gesture; the pages are addressed by `identifier` instead.
|
||||
disableRouteReporting
|
||||
pages={[
|
||||
{ title: "Stream", identifier: "stream", icon: <FaDesktop />, content: <StreamPage {...ctx} /> },
|
||||
{ title: "Video", identifier: "video", icon: <FaVideo />, content: <VideoPage {...ctx} /> },
|
||||
{
|
||||
title: "Presentation",
|
||||
identifier: "presentation",
|
||||
icon: <FaTv />,
|
||||
content: <PresentationPage {...ctx} />,
|
||||
},
|
||||
{ title: "Audio", identifier: "audio", icon: <FaVolumeUp />, content: <AudioPage {...ctx} /> },
|
||||
{
|
||||
title: "Controllers",
|
||||
identifier: "controllers",
|
||||
icon: <FaGamepad />,
|
||||
content: <ControllersPage {...ctx} />,
|
||||
},
|
||||
{
|
||||
title: "Touch & mouse",
|
||||
identifier: "pointer",
|
||||
icon: <FaHandPointer />,
|
||||
content: <PointerPage {...ctx} />,
|
||||
},
|
||||
{
|
||||
title: "Interface",
|
||||
identifier: "interface",
|
||||
icon: <FaSlidersH />,
|
||||
content: <InterfacePage {...ctx} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+64
-59
@@ -8,16 +8,16 @@
|
||||
//
|
||||
// TWO shortcuts, both named "Punktfunk" (so they share ONE Steam Input controller-config key —
|
||||
// see applyControllerConfig):
|
||||
// • STREAM — hidden, stateful: the per-session launcher. Its launch options carry the host /
|
||||
// pinned game (PF_HOST/PF_LAUNCH/PF_BROWSE), rewritten per launch, so one shortcut serves
|
||||
// every host. Driven by the QAM/pins/host-library actions. Hidden — an implementation detail.
|
||||
// • STREAM — hidden, stateful: the per-session launcher. Its launch options carry the host
|
||||
// reference and the card's profile (PF_REF/PF_PROFILE/PF_REQUEST_ACCESS), rewritten per
|
||||
// launch, so one shortcut serves every host. Hidden — an implementation detail.
|
||||
// • GAMEPAD UI — visible, stateless: fixed launch options = bare `--browse` (PF_BROWSE, no
|
||||
// host) → the client's console home (host picker + pairing + settings, gamepad-navigable).
|
||||
// This is the library-visible "Punktfunk" app the user opens directly.
|
||||
//
|
||||
// Both get the shipped artwork and the native-touch controller config.
|
||||
|
||||
import { applyControllerConfig, runnerInfo, shortcutArt, wake } from "./backend";
|
||||
import { applyControllerConfig, runnerInfo, shortcutArt } from "./backend";
|
||||
|
||||
// SteamClient is a Steam-internal global injected into the CEF context; it is not fully typed
|
||||
// by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the
|
||||
@@ -257,11 +257,12 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
}
|
||||
const startDir = info.runner.replace(/\/[^/]*$/, "");
|
||||
void ensureControllerConfig();
|
||||
// Bare browse: PF_BROWSE with no PF_HOST → the wrapper runs `--browse --fullscreen` (console
|
||||
// home). %command% expands to the shortcut exe (/bin/sh); the wrapper rides behind as an arg.
|
||||
// PF_CLIENT_BIN only when the backend resolved a NATIVE client — else the wrapper's flatpak
|
||||
// default stands and this shortcut is exactly what it always was.
|
||||
const clientBin = info.client_bin ? `PF_CLIENT_BIN=${info.client_bin} ` : "";
|
||||
// PF_BROWSE → the wrapper runs the SESSION's `--browse --fullscreen` (console home), which is
|
||||
// the one branch this rework deliberately left alone. %command% expands to the shortcut exe
|
||||
// (/bin/sh); the wrapper rides behind as an arg. PF_CLIENT_BIN only when the backend resolved
|
||||
// a NATIVE client — else the wrapper's flatpak default stands and this shortcut is exactly
|
||||
// what it always was.
|
||||
const clientBin = safeClientBin(info.client_bin) ? `PF_CLIENT_BIN=${info.client_bin} ` : "";
|
||||
const launchOpts = `${clientBin}PF_BROWSE=1 %command% "${info.runner}"`;
|
||||
|
||||
// Reuse the remembered entry only if it still exists; a stale appId (deleted shortcut whose
|
||||
@@ -319,77 +320,81 @@ export async function launchGamepadUi(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-launch extras beyond the host target (all optional — {} is the plain stream). */
|
||||
/** Per-launch extras beyond the host reference (all optional — {} is the plain stream). */
|
||||
export interface LaunchOpts {
|
||||
/** Library id to launch on connect (a pinned game) — rides PF_LAUNCH → `--launch`. */
|
||||
launchId?: string;
|
||||
/** Open the gamepad library launcher instead of streaming (PF_BROWSE → `--browse`). */
|
||||
browse?: boolean;
|
||||
/** Management-API port for the launcher's library fetch (PF_MGMT; 0/absent = default). */
|
||||
mgmt?: number;
|
||||
/** A pinned card: stream with this settings profile, one-off (PF_PROFILE → `--profile`). */
|
||||
profileId?: string;
|
||||
/**
|
||||
* Ask the host's operator to admit this Deck rather than typing a PIN (PF_REQUEST_ACCESS).
|
||||
* The connect PARKS until somebody approves it, and the launch runs SUPERVISED — see the
|
||||
* wrapper for why `--exec` is dropped on this path alone.
|
||||
*/
|
||||
requestAccess?: boolean;
|
||||
}
|
||||
|
||||
// Launch ids ride Steam launch options as an env-prefix token (`PF_LAUNCH=<id>`), so they
|
||||
// must be space/quote-free — Steam's tokenizer and the wrapper's env both break otherwise.
|
||||
// Real ids are `steam:<digits>` / `custom:<slug>`, so this rejects nothing in practice;
|
||||
// it's VALIDATION, never encoding (the host must match the opaque token verbatim).
|
||||
const UNSAFE_LAUNCH_ID = /["'\\$`\s]/;
|
||||
// Host refs and profile ids ride Steam launch options as env-prefix tokens (`PF_REF=<ref>`),
|
||||
// so they must be space/quote-free — Steam's tokenizer and the wrapper's env both break
|
||||
// otherwise. Real values are UUIDs or `addr:port`, so this rejects nothing in practice; it is
|
||||
// VALIDATION, never encoding (the client must receive the opaque token verbatim).
|
||||
const UNSAFE_TOKEN = /["'\\$`\s]/;
|
||||
export function isSafeLaunchId(id: string): boolean {
|
||||
return (
|
||||
id.length > 0 &&
|
||||
id.length <= 128 &&
|
||||
UNSAFE_LAUNCH_ID.exec(id) === null &&
|
||||
UNSAFE_TOKEN.exec(id) === null &&
|
||||
/^[\x21-\x7e]+$/.test(id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch a stream to `host:port` fullscreen in Gaming Mode (optionally straight into a
|
||||
* library title, or into a host's gamepad library). Encodes the target into the STREAM
|
||||
* shortcut's launch options (so one hidden shortcut serves every host and every pinned game),
|
||||
* then RunGame.
|
||||
* Is a resolved native-client path safe to put in Steam's launch options? Same rule, separate
|
||||
* name because the failure is different: an unsafe id is a bug in our own data, an unsafe path
|
||||
* is just where the user installed the client — so the browse shortcut degrades to its flatpak
|
||||
* default rather than refusing to exist.
|
||||
*/
|
||||
export async function launchStream(
|
||||
host: string,
|
||||
port: number,
|
||||
opts: LaunchOpts = {},
|
||||
): Promise<void> {
|
||||
// Wake-on-LAN: if this host is asleep, nudge it awake before the stream connects. Kicked off now
|
||||
// so it races with the shortcut setup (near-zero added latency); its outcome is needed below
|
||||
// (the connect budget), and RunGame follows the await either way, so nothing is slower for it.
|
||||
// Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is
|
||||
// known), and the connect that follows has its own retry window, so a failure never blocks launch.
|
||||
const waking = wake(host, port).catch(() => ({ ok: false }));
|
||||
const [{ appId, runner, clientBin }, woke] = await Promise.all([ensureStreamShortcut(), waking]);
|
||||
const target = port && port !== 9777 ? `${host}:${port}` : host;
|
||||
const env = [`PF_HOST=${target}`];
|
||||
function safeClientBin(bin: string | undefined): bin is string {
|
||||
return !!bin && isSafeLaunchId(bin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream `ref` fullscreen in Gaming Mode, optionally with a pinned card's profile. Encodes the
|
||||
* target into the STREAM shortcut's launch options — one hidden shortcut serves every host —
|
||||
* then RunGame.
|
||||
*
|
||||
* No Wake-on-LAN here any more. The plugin used to fire a magic packet itself and then stretch
|
||||
* the connect budget to 75 s to cover the host's resume, which was a workaround for the era
|
||||
* before the CLI existed. `punktfunk launch` now runs the real wake-and-wait loop (packet at
|
||||
* t=0, re-sent every 6 s, presence polled every second) and only dials once the host answers —
|
||||
* strictly better, and it deletes a backend method, a frontend call and a shell branch.
|
||||
*/
|
||||
export async function launchStream(ref: string, opts: LaunchOpts = {}): Promise<void> {
|
||||
if (!isSafeLaunchId(ref)) {
|
||||
throw new Error(`unsupported host reference: ${ref}`);
|
||||
}
|
||||
if (opts.profileId && !isSafeLaunchId(opts.profileId)) {
|
||||
throw new Error(`unsupported profile id: ${opts.profileId}`);
|
||||
}
|
||||
const { appId, runner, clientBin } = await ensureStreamShortcut();
|
||||
const env = [`PF_REF=${ref}`];
|
||||
// Set only for a NATIVE client install; absent, the wrapper takes its flatpak default, so every
|
||||
// existing Deck install produces byte-identical launch options to before.
|
||||
if (clientBin) {
|
||||
// The one launch-option value that comes from the backend rather than a store id, and so
|
||||
// the one that could carry a space: a path like `/home/deck/my apps/punktfunk-client` would
|
||||
// split Steam's tokenizer and land its tail in front of %command% as a bogus env token.
|
||||
if (!isSafeLaunchId(clientBin)) {
|
||||
throw new Error(`client path can't ride Steam's launch options: ${clientBin}`);
|
||||
}
|
||||
env.push(`PF_CLIENT_BIN=${clientBin}`);
|
||||
}
|
||||
// A magic packet actually went out (a MAC was known), so the host may be mid-resume from
|
||||
// suspend — that takes far longer than the client's default 15 s connect budget. Stretch the
|
||||
// budget so the client's wake-tolerant dial keeps retrying across the resume; against an
|
||||
// already-awake host the connect still lands in under a second, so this costs nothing.
|
||||
if (woke.ok) {
|
||||
env.push("PF_CONNECT_TIMEOUT=75");
|
||||
if (opts.profileId) {
|
||||
env.push(`PF_PROFILE=${opts.profileId}`);
|
||||
}
|
||||
if (opts.browse) {
|
||||
env.push("PF_BROWSE=1");
|
||||
if (opts.mgmt) {
|
||||
env.push(`PF_MGMT=${Math.floor(opts.mgmt)}`);
|
||||
}
|
||||
} else if (opts.launchId) {
|
||||
if (!isSafeLaunchId(opts.launchId)) {
|
||||
// Enforced at pin time too (the picker disables Pin) — this is the backstop.
|
||||
throw new Error(`unsupported launch id: ${opts.launchId}`);
|
||||
}
|
||||
env.push(`PF_LAUNCH=${opts.launchId}`);
|
||||
if (opts.requestAccess) {
|
||||
env.push("PF_REQUEST_ACCESS=1");
|
||||
}
|
||||
// KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper
|
||||
// script rides behind it as an argument and reads PF_* from the environment. The wake was
|
||||
// awaited above, so the magic packet is out before the connect attempt.
|
||||
// script rides behind it as an argument and reads PF_* from the environment.
|
||||
SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`);
|
||||
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// The trust sheet — the step between "I can see a host" and "I can stream it".
|
||||
//
|
||||
// Two ways in, in the order the GTK dialog and the console's pair screen offer them:
|
||||
//
|
||||
// • REQUEST ACCESS (default) — no PIN. Save the host with the fingerprint it ADVERTISED,
|
||||
// then launch. The host parks that connect until its operator approves this Deck in the
|
||||
// console or web UI, admits it, and the stream starts by itself. It is not a second
|
||||
// pairing ceremony; it is an ordinary identified connect with a stretched budget, which
|
||||
// is why it costs no ceremony surface here at all.
|
||||
// • USE A PIN INSTEAD — the existing gamepad-navigable keypad (pair.tsx).
|
||||
//
|
||||
// NO FINGERPRINT, NO REQUEST ACCESS. The parked connect pins the advertised fingerprint, and
|
||||
// that pin is the only thing standing between a 185 s wait and an impostor answering for the
|
||||
// host. A host typed in by address advertises nothing, so it gets the PIN path only — and is
|
||||
// told why, rather than being shown a button that could only fail. Under no circumstances does
|
||||
// this sheet trust-on-first-use its way past a missing fingerprint.
|
||||
import { DialogButton, Focusable, ModalRoot, Spinner, showModal } from "@decky/ui";
|
||||
import { toaster } from "@decky/api";
|
||||
import { FC, useRef, useState } from "react";
|
||||
import { trustHost } from "./backend";
|
||||
import { HostView } from "./hooks";
|
||||
import { PairModal } from "./pair";
|
||||
|
||||
/** User-facing copy for a `trustHost` failure code. */
|
||||
function trustErrorBody(error: string | undefined, name: string): string {
|
||||
switch (error) {
|
||||
case "refused":
|
||||
return `${name} is already saved under a different identity. Forget it in the Punktfunk app before trusting it again.`;
|
||||
case "client-outdated":
|
||||
return "Update the Punktfunk client to use request access.";
|
||||
case "client-unavailable":
|
||||
return "Couldn’t reach the Punktfunk client — is it still installed?";
|
||||
default:
|
||||
return `Couldn’t save ${name}.`;
|
||||
}
|
||||
}
|
||||
|
||||
export const TrustSheet: FC<{
|
||||
host: HostView;
|
||||
closeModal?: () => void;
|
||||
/** Stream this host, having just been let in. */
|
||||
onStream: (opts: { requestAccess?: boolean }) => void;
|
||||
/** Re-read the host list — the record changed underneath the panel. */
|
||||
onChanged: () => void;
|
||||
}> = ({ host, closeModal, onStream, onChanged }) => {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ⚠ This sheet is a `showModal` PORTAL: it captures its callbacks ONCE and never re-renders
|
||||
// from panel state. Anything it needs to act on later must be read through a ref, not out of
|
||||
// a captured value — reading a captured array is exactly what made pinning a second game
|
||||
// compute from a stale base and clobber the first.
|
||||
const props = useRef({ host, onStream, onChanged });
|
||||
props.current = { host, onStream, onChanged };
|
||||
|
||||
// Request access pins what the host ADVERTISES. The record's own pin is a different thing:
|
||||
// a host that already has one streams without ever opening this sheet.
|
||||
const hasIdentity = host.advertisedFp !== "";
|
||||
// A host advertising `pair=optional` admits anyone who pins its identity — there is no
|
||||
// operator decision to wait for, and asking for one would be a wait that never ends and a
|
||||
// record claiming somebody approved this Deck when nobody did. `paired` means the PIN
|
||||
// ceremony or a real approval; the desktop client records exactly this case as *trusted*.
|
||||
const needsApproval = host.pairPolicy !== "optional";
|
||||
const canRequestAccess = hasIdentity && needsApproval;
|
||||
const canTrustDirectly = hasIdentity && !needsApproval;
|
||||
|
||||
/**
|
||||
* Pin the advertised identity, then stream.
|
||||
*
|
||||
* `approval` is what differs between the two doors, and it is not cosmetic: it decides whether
|
||||
* the launch waits ~185 s for an operator AND whether the record ends up marked paired.
|
||||
*/
|
||||
const letIn = async (approval: boolean) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const { host: h, onStream: stream, onChanged: changed } = props.current;
|
||||
try {
|
||||
// Step 1: save it with the ADVERTISED fingerprint, pinned but unpaired ("trusted").
|
||||
// Idempotent, so a retry after a declined approval is free.
|
||||
const r = await trustHost(h.addr, h.port, h.advertisedFp, h.name);
|
||||
if (!r.ok) {
|
||||
setError(trustErrorBody(r.error, h.name));
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
changed();
|
||||
// Step 2: the launch. Under approval it PARKS — and the session's plain connecting screen
|
||||
// looks identical whether it is parked or hanging, so say what is about to happen BEFORE
|
||||
// it starts. That toast is a patch over that, and the real fix belongs in the session.
|
||||
if (approval) {
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: `Approve this Deck in ${h.name}’s console — the stream starts by itself`,
|
||||
duration: 10_000,
|
||||
});
|
||||
}
|
||||
stream({ requestAccess: approval });
|
||||
closeModal?.();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const usePin = () => {
|
||||
// Hand off to the keypad. Closing first keeps one modal on screen at a time, which is what
|
||||
// the gamepad focus model expects.
|
||||
const { host: h, onStream: stream, onChanged: changed } = props.current;
|
||||
closeModal?.();
|
||||
showModal(
|
||||
<PairModal
|
||||
host={h}
|
||||
onPaired={() => {
|
||||
changed();
|
||||
stream({});
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalRoot closeModal={closeModal}>
|
||||
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.3em" }}>
|
||||
Connect to {host.name}
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, marginBottom: "1em" }}>
|
||||
{!hasIdentity
|
||||
? "No advertised identity for this host — pair with a PIN instead."
|
||||
: canTrustDirectly
|
||||
? `${host.name} accepts new devices. Connecting pins its identity so later streams are silent.`
|
||||
: `${host.name} needs to let this device in before it can stream.`}
|
||||
</div>
|
||||
{error && (
|
||||
<div style={{ color: "#ff6b6b", marginBottom: "0.6em" }}>{error}</div>
|
||||
)}
|
||||
|
||||
<Focusable style={{ display: "flex", flexDirection: "column", gap: "0.5em" }}>
|
||||
{canRequestAccess && (
|
||||
<DialogButton disabled={busy} onClick={() => void letIn(true)}>
|
||||
{busy ? <Spinner style={{ height: "1em" }} /> : "Request access"}
|
||||
</DialogButton>
|
||||
)}
|
||||
{canTrustDirectly && (
|
||||
<DialogButton disabled={busy} onClick={() => void letIn(false)}>
|
||||
{busy ? <Spinner style={{ height: "1em" }} /> : "Connect"}
|
||||
</DialogButton>
|
||||
)}
|
||||
<DialogButton disabled={busy} onClick={usePin}>
|
||||
Use a PIN instead…
|
||||
</DialogButton>
|
||||
<DialogButton disabled={busy} onClick={() => closeModal?.()}>
|
||||
Cancel
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
|
||||
{canRequestAccess && (
|
||||
<div style={{ opacity: 0.6, fontSize: "0.85em", marginTop: "0.8em" }}>
|
||||
Request access asks {host.name}’s operator to approve this Deck in its console or web
|
||||
UI. No PIN to type — the stream starts as soon as they do.
|
||||
</div>
|
||||
)}
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
// Shared UI primitives for the fullscreen page + modals. The one rule that keeps every row
|
||||
// looking consistent: a Field's action(s) always sit right-aligned, with real space between
|
||||
// them and the label text — never hugging it.
|
||||
//
|
||||
// Decky lays a Field out as `[ label .......... children ]`. When the children container is
|
||||
// grown (`childrenContainerWidth="max"`, which we want so multi-button clusters have room), a
|
||||
// bare `fit-content` button LEFT-aligns inside that grown container and ends up pressed against
|
||||
// the label with the space wasted to its right. Wrapping the action(s) in `RowActions` pushes
|
||||
// them to the right edge and evenly spaces multiples — the same treatment every row now gets.
|
||||
import { Focusable } from "@decky/ui";
|
||||
import { CSSProperties, FC, ReactNode } from "react";
|
||||
|
||||
export const RowActions: FC<{ children: ReactNode }> = ({ children }) => (
|
||||
<Focusable
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5em",
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Focusable>
|
||||
);
|
||||
|
||||
// A single action button sized to its content (not the gamepad-UI default of 100% width), with
|
||||
// a floor so short labels ("Pair", "Remove") don't render as tiny nubs and every row's button
|
||||
// reads at the same weight.
|
||||
export const actionButton: CSSProperties = {
|
||||
width: "fit-content",
|
||||
minWidth: "7em",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
// Square icon-only button (details ⓘ, header back arrow). Needs an explicit height or the zero
|
||||
// padding collapses it to the icon's line height.
|
||||
export const iconButton: CSSProperties = {
|
||||
width: "40px",
|
||||
minWidth: "40px",
|
||||
height: "40px",
|
||||
padding: 0,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
};
|
||||
@@ -558,6 +558,10 @@ async fn session(args: Args) -> Result<()> {
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// Like STREAMED_AU above: the shared-core reassembler pins geometry per-frame, so
|
||||
// the probe accepts a mid-session shard change (and jumbo growth) up to the
|
||||
// receive ceiling — and it's exactly the tool to measure both.
|
||||
max_shard_payload: punktfunk_core::config::max_shard_payload() as u16,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
|
||||
@@ -79,20 +79,22 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
can_wake: false,
|
||||
last_used: k.and_then(|h| h.last_used),
|
||||
os: k.map(|h| h.os.clone()).unwrap_or_default(),
|
||||
pin: None,
|
||||
bound_profile: None,
|
||||
};
|
||||
let label = row.name.clone();
|
||||
if k.is_none() {
|
||||
seed = Some(row.clone());
|
||||
}
|
||||
if row.paired {
|
||||
(ConsoleEntry::Library(row), Some(label))
|
||||
(ConsoleEntry::Library(Box::new(row)), Some(label))
|
||||
} else {
|
||||
(ConsoleEntry::Home, Some(label))
|
||||
}
|
||||
}
|
||||
None if fake => {
|
||||
let row = fake_host_row();
|
||||
(ConsoleEntry::Library(row), None)
|
||||
(ConsoleEntry::Library(Box::new(row)), None)
|
||||
}
|
||||
None => (ConsoleEntry::Home, None),
|
||||
};
|
||||
@@ -207,6 +209,7 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
launch,
|
||||
title,
|
||||
request_access,
|
||||
profile,
|
||||
} => {
|
||||
let Some(pin) = trust::parse_hex32(&fp_hex) else {
|
||||
// Connect (and request-access) pin the host's advertised fingerprint;
|
||||
@@ -221,9 +224,11 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
// have changed the defaults since the last stream, and the host may carry
|
||||
// a profile binding. Console (and therefore Decky, which spawns this
|
||||
// binary) honors bindings with no console-side work — the resolver is the
|
||||
// same one `--connect` goes through. No one-off here: picking a profile is
|
||||
// a desktop-shell affordance in v1, pinned cards are the console's.
|
||||
let (settings, profile) = trust::effective_settings(&addr, port, None);
|
||||
// same one `--connect` goes through. A pinned card's connect arrives as a
|
||||
// one-off profile id; the resolver prefers it over the binding, and a
|
||||
// dangling id falls back to the defaults without blocking the connect.
|
||||
let (settings, profile) =
|
||||
trust::effective_settings(&addr, port, profile.as_deref());
|
||||
let mut params = session_params(
|
||||
&settings,
|
||||
profile.map(|p| p.name),
|
||||
@@ -303,6 +308,8 @@ fn fake_host_row() -> HostRow {
|
||||
can_wake: false,
|
||||
last_used: None,
|
||||
os: "linux/arch/steamos".into(),
|
||||
pin: None,
|
||||
bound_profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,6 +513,38 @@ impl ServiceState {
|
||||
ConsoleCmd::Probe => {
|
||||
self.last_probe = Instant::now() - Duration::from_secs(60);
|
||||
}
|
||||
ConsoleCmd::SetPin {
|
||||
key,
|
||||
profile_id,
|
||||
pin,
|
||||
} => {
|
||||
// Presentation only (design §5.2a): order = card order, appended at the
|
||||
// end; never touches `profile_id` (the default binding). Idempotent, so
|
||||
// a repeated press inside one refresh window can't double-pin.
|
||||
let mut known = trust::KnownHosts::load();
|
||||
let idx = known
|
||||
.hosts
|
||||
.iter()
|
||||
.position(|h| !h.fp_hex.is_empty() && h.fp_hex == key)
|
||||
.or_else(|| {
|
||||
let (addr, port) = key.rsplit_once(':')?;
|
||||
known.index_by_addr(addr, port.parse().ok()?)
|
||||
});
|
||||
let Some(h) = idx.and_then(|i| known.hosts.get_mut(i)) else {
|
||||
tracing::warn!(%key, "pin toggle for an unknown host — ignoring");
|
||||
return;
|
||||
};
|
||||
if pin && !h.pinned_profiles.contains(&profile_id) {
|
||||
h.pinned_profiles.push(profile_id);
|
||||
} else if !pin {
|
||||
h.pinned_profiles.retain(|id| *id != profile_id);
|
||||
}
|
||||
if let Err(e) = known.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"), "saving known hosts");
|
||||
}
|
||||
// `run` refreshes the rows right after this drain, so the carousel and
|
||||
// the pin screen reflect the new card within the same service pass.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,12 +583,21 @@ impl ServiceState {
|
||||
})
|
||||
}
|
||||
|
||||
/// The console home's rows: saved hosts (most recent first), then
|
||||
/// discovered-but-unsaved ones, then a still-uncovered `--browse` seed.
|
||||
/// The console home's rows: saved hosts (most recent first) — each followed by its
|
||||
/// pinned profile cards (design §5.2a) — then discovered-but-unsaved ones, then a
|
||||
/// still-uncovered `--browse` seed.
|
||||
fn rows(&self) -> Vec<HostRow> {
|
||||
let known = trust::KnownHosts::load();
|
||||
let catalog = pf_client_core::profiles::ProfilesFile::load();
|
||||
let probed = self.probed.lock().unwrap();
|
||||
let mut rows: Vec<HostRow> = known
|
||||
let chip = |p: &pf_client_core::profiles::StreamProfile| pf_console_ui::ProfileChip {
|
||||
id: p.id.clone(),
|
||||
name: p.name.clone(),
|
||||
accent: p.accent.clone(),
|
||||
};
|
||||
// Primary rows paired with their pinned cards, so the sort below can order hosts
|
||||
// while every host's cards stay glued behind its primary tile.
|
||||
let mut saved: Vec<(HostRow, Vec<HostRow>)> = known
|
||||
.hosts
|
||||
.iter()
|
||||
.map(|h| {
|
||||
@@ -563,8 +611,8 @@ impl ServiceState {
|
||||
|| (d.addr == h.addr && d.port == h.port)
|
||||
});
|
||||
let online = advert.is_some() || probed.get(&key).copied().unwrap_or(false);
|
||||
HostRow {
|
||||
key,
|
||||
let row = HostRow {
|
||||
key: key.clone(),
|
||||
name: host_display_name(&h.name, &h.addr),
|
||||
addr: h.addr.clone(),
|
||||
port: h.port,
|
||||
@@ -581,10 +629,34 @@ impl ServiceState {
|
||||
.filter(|d| !d.os.is_empty())
|
||||
.map(|d| d.os.clone())
|
||||
.unwrap_or_else(|| h.os.clone()),
|
||||
}
|
||||
pin: None,
|
||||
bound_profile: h
|
||||
.profile_id
|
||||
.as_deref()
|
||||
.and_then(|id| catalog.find_by_id(id))
|
||||
.map(chip),
|
||||
};
|
||||
// A pinned card shares the primary tile's live state; its key rides the
|
||||
// profile id behind a NUL (impossible in a fingerprint or `addr:port`),
|
||||
// so cursor-follow and the wake path address the card itself.
|
||||
let pins = h
|
||||
.resolved_pins(&catalog)
|
||||
.into_iter()
|
||||
.map(|p| HostRow {
|
||||
key: format!("{key}\0{}", p.id),
|
||||
pin: Some(chip(p)),
|
||||
bound_profile: None,
|
||||
..row.clone()
|
||||
})
|
||||
.collect();
|
||||
(row, pins)
|
||||
})
|
||||
.collect();
|
||||
rows.sort_by(|a, b| b.last_used.cmp(&a.last_used).then(a.name.cmp(&b.name)));
|
||||
saved.sort_by(|(a, _), (b, _)| b.last_used.cmp(&a.last_used).then(a.name.cmp(&b.name)));
|
||||
let mut rows: Vec<HostRow> = saved
|
||||
.into_iter()
|
||||
.flat_map(|(row, pins)| std::iter::once(row).chain(pins))
|
||||
.collect();
|
||||
|
||||
let mut extra: Vec<HostRow> = self
|
||||
.discovered
|
||||
@@ -612,6 +684,8 @@ impl ServiceState {
|
||||
can_wake: false,
|
||||
last_used: None,
|
||||
os: d.os.clone(),
|
||||
pin: None,
|
||||
bound_profile: None,
|
||||
})
|
||||
.collect();
|
||||
extra.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
@@ -1045,13 +1045,18 @@ pub(crate) fn settings_page(
|
||||
let ss = set_screen.clone();
|
||||
button("Third-party licenses").on_click(move || ss.call(Screen::Licenses))
|
||||
};
|
||||
// The client log's home (%LOCALAPPDATA%\punktfunk\logs) — the file every "check the
|
||||
// client log" message means, which until this row had no way in from the UI at all.
|
||||
// The folder rather than the file so the rotated `.old` generation is in reach too.
|
||||
// Best-effort, like the log itself: a missing dir or a failed spawn stays silent.
|
||||
// The client log's home — the file every "check the client log" message means, which until
|
||||
// this row had no way in from the UI at all. The folder rather than the file so the rotated
|
||||
// `.old` generation is in reach too.
|
||||
//
|
||||
// `real_dir` (not the literal %LOCALAPPDATA% path) because Explorer lives outside our MSIX
|
||||
// container: handed a path the package redirection keeps from ever existing, it silently
|
||||
// opens the user's Documents folder instead of failing, which is precisely what this button
|
||||
// shipped doing. The `is_dir` guard keeps that fallback unreachable — if the resolve ever
|
||||
// comes back wrong, the click does nothing rather than landing somewhere misleading.
|
||||
// Best-effort otherwise, like the log itself: a failed spawn stays silent.
|
||||
let logs_button = button("Open log folder").on_click(|| {
|
||||
if let Some(dir) = crate::logfile::log_dir() {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
if let Some(dir) = crate::logfile::real_dir().filter(|d| d.is_dir()) {
|
||||
let _ = std::process::Command::new("explorer.exe").arg(&dir).spawn();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
//! Mirrors the host's convention (`%ProgramData%\punktfunk\logs`, size-capped): a file over
|
||||
//! 10 MB is rotated to `.old` at the next client start, one generation kept. Everything is
|
||||
//! best-effort — a missing/locked directory degrades to plain stderr, never a startup failure.
|
||||
//!
|
||||
//! Two paths, deliberately: [`log_dir`] is what we open files through, [`real_dir`] is where
|
||||
//! they actually land. Under MSIX those differ, and only the second one is fit to show a user
|
||||
//! or hand to Explorer.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, BufRead, Write};
|
||||
@@ -21,14 +25,74 @@ const ROTATE_BYTES: u64 = 10 * 1024 * 1024;
|
||||
|
||||
static SINK: OnceLock<Option<Arc<Mutex<File>>>> = OnceLock::new();
|
||||
|
||||
/// The log directory — Settings ▸ About's "Open log folder" opens it in Explorer.
|
||||
pub(crate) fn log_dir() -> Option<PathBuf> {
|
||||
/// The log directory we WRITE through: `%LOCALAPPDATA%\punktfunk\logs`.
|
||||
///
|
||||
/// Correct to open files under, but NOT necessarily where the bytes land — see [`real_dir`].
|
||||
/// Anything shown to a user or handed to another process wants that one instead.
|
||||
fn log_dir() -> Option<PathBuf> {
|
||||
Some(PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join(r"punktfunk\logs"))
|
||||
}
|
||||
|
||||
/// The log directory as it exists ON DISK — Settings ▸ About's "Open log folder" opens this in
|
||||
/// Explorer, and [`path`] names it in the startup line and the failed-spawn banner.
|
||||
///
|
||||
/// The shipping client is a full-trust MSIX package, and Windows redirects a packaged app's
|
||||
/// `%LOCALAPPDATA%` writes into its private `…\Packages\<family>\LocalCache\Local\…`. We create
|
||||
/// and append through that redirection without ever seeing it, so [`log_dir`] is the right path
|
||||
/// to WRITE to yet names a directory that never exists on disk. Explorer runs OUTSIDE the
|
||||
/// container: it resolves the literal path, finds nothing, and silently falls back to the user's
|
||||
/// Documents folder — which is exactly what "Open log folder" did in every packaged install, and
|
||||
/// what the two "check <path>" messages pointed at. An unpackaged dev run creates the literal
|
||||
/// directory for real, which is why this only ever showed up in the field.
|
||||
///
|
||||
/// Canonicalizing the directory we just created resolves through the redirection on a packaged
|
||||
/// run and changes nothing on an unpackaged one, so there is no package identity to detect.
|
||||
pub(crate) fn real_dir() -> Option<PathBuf> {
|
||||
let dir = log_dir()?;
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
Some(std::fs::canonicalize(&dir).map_or(dir, strip_verbatim))
|
||||
}
|
||||
|
||||
/// Undo the `\\?\` that [`std::fs::canonicalize`] always prefixes. Explorer refuses a verbatim
|
||||
/// path — it would take the very same silent Documents fallback [`real_dir`] exists to avoid —
|
||||
/// and it is noise in a line a user is meant to read and act on.
|
||||
fn strip_verbatim(p: PathBuf) -> PathBuf {
|
||||
use std::path::{Component, Prefix};
|
||||
|
||||
// Scoped so the borrow ends before the `return p` below can move it.
|
||||
let head = match p.components().next() {
|
||||
Some(Component::Prefix(pre)) => match pre.kind() {
|
||||
// `\\?\C:\…` → `C:\…`
|
||||
Prefix::VerbatimDisk(drive) => Some(PathBuf::from(format!(r"{}:\", drive as char))),
|
||||
// `\\?\UNC\server\share\…` → `\\server\share\…` (a roaming profile on a share).
|
||||
// Built through `OsString`, which appends verbatim — `PathBuf::push` would apply
|
||||
// separator logic to the bare `\\` and mangle it.
|
||||
Prefix::VerbatimUNC(server, share) => {
|
||||
let mut unc = std::ffi::OsString::from(r"\\");
|
||||
unc.push(server);
|
||||
unc.push(r"\");
|
||||
unc.push(share);
|
||||
Some(PathBuf::from(unc))
|
||||
}
|
||||
// Already a plain path — nothing to undo.
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
let Some(mut out) = head else { return p };
|
||||
// `skip(1)` drops the prefix; the `RootDir` that follows it is already in `head`.
|
||||
out.extend(
|
||||
p.components()
|
||||
.skip(1)
|
||||
.filter(|c| !matches!(c, Component::RootDir)),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// The log file's path, for the "logs land here" startup line and the failed-spawn banner.
|
||||
/// Resolved like [`real_dir`] — a path a user is told to check has to be the one on disk.
|
||||
pub(crate) fn path() -> Option<PathBuf> {
|
||||
Some(log_dir()?.join("client.log"))
|
||||
Some(real_dir()?.join("client.log"))
|
||||
}
|
||||
|
||||
/// Open (rotating first) and cache the sink. Called once at startup, before the tracing
|
||||
@@ -97,3 +161,67 @@ pub(crate) fn forward_child_stderr(stderr: impl io::Read + Send + 'static) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The shape `canonicalize` actually returns for a local profile. Explorer treats a `\\?\`
|
||||
/// path as unresolvable and opens Documents instead, so the prefix has to come off.
|
||||
#[test]
|
||||
fn verbatim_disk_prefix_comes_off() {
|
||||
let p = PathBuf::from(r"\\?\C:\Users\ada\AppData\Local\punktfunk\logs");
|
||||
assert_eq!(
|
||||
strip_verbatim(p),
|
||||
PathBuf::from(r"C:\Users\ada\AppData\Local\punktfunk\logs")
|
||||
);
|
||||
}
|
||||
|
||||
/// The MSIX-redirected form is what the fix is for: same treatment, longer path.
|
||||
#[test]
|
||||
fn verbatim_disk_prefix_comes_off_for_the_package_local_cache() {
|
||||
let p = PathBuf::from(
|
||||
r"\\?\C:\Users\ada\AppData\Local\Packages\unom.Punktfunk_8wekyb3d8bbwe\LocalCache\Local\punktfunk\logs",
|
||||
);
|
||||
assert_eq!(
|
||||
strip_verbatim(p),
|
||||
PathBuf::from(
|
||||
r"C:\Users\ada\AppData\Local\Packages\unom.Punktfunk_8wekyb3d8bbwe\LocalCache\Local\punktfunk\logs"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// A roaming profile on a share canonicalizes to `\\?\UNC\…`; the plain UNC form is what
|
||||
/// Explorer takes. `\\server\share` must survive intact — dropping either half, or letting
|
||||
/// `PathBuf::push`'s separator logic at the bare `\\`, yields a path that opens nothing.
|
||||
#[test]
|
||||
fn verbatim_unc_prefix_becomes_a_plain_unc_path() {
|
||||
let p = PathBuf::from(r"\\?\UNC\fileserv\profiles\ada\AppData\Local\punktfunk\logs");
|
||||
assert_eq!(
|
||||
strip_verbatim(p),
|
||||
PathBuf::from(r"\\fileserv\profiles\ada\AppData\Local\punktfunk\logs")
|
||||
);
|
||||
}
|
||||
|
||||
/// An unpackaged dev run resolves to a path that was never verbatim — leave it alone.
|
||||
#[test]
|
||||
fn plain_path_is_untouched() {
|
||||
let p = PathBuf::from(r"C:\Users\ada\AppData\Local\punktfunk\logs");
|
||||
assert_eq!(strip_verbatim(p.clone()), p);
|
||||
}
|
||||
|
||||
/// Whatever the run, the resolved directory is one Explorer can open: it exists, and it
|
||||
/// carries no verbatim prefix. This is the button's actual precondition.
|
||||
#[test]
|
||||
fn real_dir_is_an_openable_directory() {
|
||||
let Some(dir) = real_dir() else {
|
||||
return; // no LOCALAPPDATA (not a normal user session) — nothing to assert
|
||||
};
|
||||
assert!(dir.is_dir(), "{} is not a directory", dir.display());
|
||||
assert!(
|
||||
!dir.to_string_lossy().starts_with(r"\\?\"),
|
||||
"{} kept its verbatim prefix",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,9 +168,18 @@ struct PlayerData {
|
||||
/// Drained chunk Vecs go back here for the decode side to refill (allocation pool).
|
||||
recycle: SyncSender<Vec<f32>>,
|
||||
ring: VecDeque<f32>,
|
||||
primed: bool,
|
||||
/// Shared ms-denominated de-jitter policy: prime depth, drift correction, de-prime
|
||||
/// hysteresis. Replaces the old `3 × quantum` target, which meant 15 ms at a 5 ms graph
|
||||
/// quantum and a silent 64 ms at a 20 ms one, and the `if ring.is_empty()` re-prime, where
|
||||
/// one transient drain manufactured a whole target's worth of fresh silence.
|
||||
policy: punktfunk_core::audio::JitterPolicy,
|
||||
/// Interleaved channel count this stream was opened with (2/6/8).
|
||||
channels: usize,
|
||||
/// Diagnostics (WP0.3), logged ~every 10 s: the audio plane used to be entirely silent in a
|
||||
/// client log, so a latency or dropout report had nothing to go on.
|
||||
underruns: u64,
|
||||
sheds: u64,
|
||||
callbacks: u64,
|
||||
}
|
||||
|
||||
fn pw_thread(
|
||||
@@ -223,8 +232,14 @@ fn pw_thread(
|
||||
rx: pcm_rx,
|
||||
recycle: recycle_tx,
|
||||
ring: VecDeque::new(),
|
||||
primed: false,
|
||||
policy: punktfunk_core::audio::JitterPolicy::new(
|
||||
punktfunk_core::audio::JitterTuning::PIPEWIRE,
|
||||
channels as u8,
|
||||
),
|
||||
channels,
|
||||
underruns: 0,
|
||||
sheds: 0,
|
||||
callbacks: 0,
|
||||
};
|
||||
|
||||
let _listener = stream
|
||||
@@ -252,23 +267,29 @@ fn pw_thread(
|
||||
let want_frames = data.data().map(|s| s.len() / stride).unwrap_or(0);
|
||||
let want = want_frames * ud.channels;
|
||||
|
||||
// Adaptive jitter buffer (same shape as the host's virtual mic): prime to
|
||||
// ~3 quanta, cap at ~1 quantum of slack beyond that, re-prime after a
|
||||
// genuine drain.
|
||||
let target = (3 * want).clamp(720 * ud.channels, 9600 * ud.channels);
|
||||
while ud.ring.len() > target.max(want) + want {
|
||||
ud.ring.pop_front();
|
||||
}
|
||||
if !ud.primed && ud.ring.len() >= target {
|
||||
ud.primed = true;
|
||||
// Shared de-jitter policy: prime depth in MILLISECONDS, smooth drift correction
|
||||
// (a crossfaded 5 ms shed) so latency returns to target instead of ratcheting,
|
||||
// and a hard cap as the backstop.
|
||||
let step = ud.policy.step(ud.ring.len(), want);
|
||||
if step.drop_front > 0 {
|
||||
ud.sheds += 1;
|
||||
punktfunk_core::audio::crossfade_drop(
|
||||
&mut ud.ring,
|
||||
step.drop_front,
|
||||
step.crossfade,
|
||||
);
|
||||
}
|
||||
|
||||
let mut ran_short = false;
|
||||
let n_frames = if let Some(slice) = data.data() {
|
||||
for k in 0..want {
|
||||
let s = if ud.primed {
|
||||
ud.ring.pop_front().unwrap_or(0.0)
|
||||
} else {
|
||||
let s = if step.silence {
|
||||
0.0
|
||||
} else {
|
||||
ud.ring.pop_front().unwrap_or_else(|| {
|
||||
ran_short = true;
|
||||
0.0
|
||||
})
|
||||
};
|
||||
let off = k * 4;
|
||||
slice[off..off + 4].copy_from_slice(&s.to_le_bytes());
|
||||
@@ -277,8 +298,21 @@ fn pw_thread(
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if ud.ring.is_empty() {
|
||||
ud.primed = false;
|
||||
// No-op while un-primed (the policy ignores it), so a deliberate priming silence
|
||||
// is never miscounted as an underrun.
|
||||
ud.policy.note_read(ran_short);
|
||||
ud.underruns += u64::from(ran_short);
|
||||
ud.callbacks += 1;
|
||||
// ~10 s at a 5 ms quantum; the exact cadence does not matter, only that the
|
||||
// plane stops being invisible.
|
||||
if ud.callbacks % 2_000 == 0 {
|
||||
tracing::debug!(
|
||||
buffer_ms = ud.policy.avg_depth_ms(),
|
||||
target_ms = ud.policy.target_ms(),
|
||||
underruns = ud.underruns,
|
||||
drift_sheds = ud.sheds,
|
||||
"audio playback"
|
||||
);
|
||||
}
|
||||
let chunk = data.chunk_mut();
|
||||
*chunk.offset_mut() = 0;
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
//!
|
||||
//! The WASAPI twin of `audio.rs` (PipeWire) — same public surface (`AudioPlayer::spawn`/
|
||||
//! `take_buffer`/`push`, `MicStreamer::spawn`), swapped in by lib.rs's `#[path]` so the
|
||||
//! session pump compiles against one `crate::audio` on both OSes. Adapted from
|
||||
//! `clients/windows/src/audio.rs` (which remains the WinUI shell's own copy until its
|
||||
//! built-in streaming path is deleted).
|
||||
//! session pump compiles against one `crate::audio` on both OSes. It began as a copy of the
|
||||
//! WinUI shell's own audio path; that shell's built-in streaming path has since been deleted,
|
||||
//! so this is now the only WASAPI client ring.
|
||||
//!
|
||||
//! 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.
|
||||
//! Playback: 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. The depth policy between
|
||||
//! them is the SHARED `punktfunk_core::audio::JitterPolicy` (`JitterTuning::WASAPI`) — target in
|
||||
//! milliseconds, crossfaded drift correction, de-prime hysteresis — so all four clients behave
|
||||
//! the same way and none of them can ratchet latency upward.
|
||||
//!
|
||||
//! 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 channels + stop flag
|
||||
@@ -250,10 +251,20 @@ fn render_thread(
|
||||
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;
|
||||
// De-jitter ring, in interleaved f32 SAMPLES (it used to be raw bytes, which made the
|
||||
// depth arithmetic byte-vs-sample and kept it from sharing the policy and the crossfade
|
||||
// helper with the other three clients).
|
||||
let mut ring: VecDeque<f32> = VecDeque::new();
|
||||
// Shared ms-denominated policy: prime depth, crossfaded drift correction so latency
|
||||
// returns to target instead of ratcheting, and de-prime hysteresis — the last replacing
|
||||
// the old `if ring.is_empty()`, where a single transient drain manufactured a whole
|
||||
// target's worth of fresh silence.
|
||||
let mut policy = punktfunk_core::audio::JitterPolicy::new(
|
||||
punktfunk_core::audio::JitterTuning::WASAPI,
|
||||
channels,
|
||||
);
|
||||
let mut out = Vec::new(); // per-quantum scratch, reused across iterations
|
||||
let (mut underruns, mut sheds, mut callbacks) = (0u64, 0u64, 0u64);
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
if h_event.wait_for_event(100).is_err() {
|
||||
@@ -262,9 +273,7 @@ fn render_thread(
|
||||
// Drain everything the pump has queued into the ring, returning each drained
|
||||
// Vec to the pool (a full/closed pool drops it).
|
||||
while let Ok(mut chunk) = pcm_rx.try_recv() {
|
||||
for s in chunk.iter() {
|
||||
ring.extend(s.to_le_bytes());
|
||||
}
|
||||
ring.extend(chunk.iter().copied());
|
||||
chunk.clear();
|
||||
let _ = recycle_tx.try_send(chunk);
|
||||
}
|
||||
@@ -274,28 +283,40 @@ fn render_thread(
|
||||
if avail_frames == 0 {
|
||||
continue;
|
||||
}
|
||||
let want_bytes = avail_frames * block_align;
|
||||
let want = avail_frames * channels as usize;
|
||||
|
||||
// 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;
|
||||
let step = policy.step(ring.len(), want);
|
||||
if step.drop_front > 0 {
|
||||
sheds += 1;
|
||||
punktfunk_core::audio::crossfade_drop(&mut ring, step.drop_front, step.crossfade);
|
||||
}
|
||||
|
||||
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;
|
||||
out.resize(avail_frames * block_align, 0);
|
||||
let mut ran_short = false;
|
||||
if !step.silence {
|
||||
// `out` is exactly `want` f32s wide (avail_frames × channels × 4 bytes).
|
||||
for dst in out.chunks_exact_mut(4) {
|
||||
let s = ring.pop_front().unwrap_or_else(|| {
|
||||
ran_short = true;
|
||||
0.0
|
||||
});
|
||||
dst.copy_from_slice(&s.to_le_bytes());
|
||||
}
|
||||
}
|
||||
if ring.is_empty() {
|
||||
primed = false;
|
||||
// No-op while un-primed (the policy ignores it), so a deliberate priming silence is
|
||||
// never miscounted as an underrun.
|
||||
policy.note_read(ran_short);
|
||||
underruns += u64::from(ran_short);
|
||||
callbacks += 1;
|
||||
if callbacks % 1_000 == 0 {
|
||||
tracing::debug!(
|
||||
buffer_ms = policy.avg_depth_ms(),
|
||||
target_ms = policy.target_ms(),
|
||||
underruns,
|
||||
drift_sheds = sheds,
|
||||
"audio playback"
|
||||
);
|
||||
}
|
||||
render_client
|
||||
.write_to_device(avail_frames, &out, None)
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! cards and flip a saved host's online pip when its advert disappears.
|
||||
|
||||
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DiscoveredHost {
|
||||
@@ -31,6 +33,19 @@ pub struct DiscoveredHost {
|
||||
pub os: String,
|
||||
}
|
||||
|
||||
impl DiscoveredHost {
|
||||
/// The host's advertised stable id (mDNS TXT `id`), or `""` when it doesn't advertise one.
|
||||
/// [`DiscoveredHost::key`] falls back to the mDNS fullname in that case, so the two being
|
||||
/// equal is exactly the "no id" signal — read it through here rather than re-deriving it.
|
||||
pub fn advertised_id(&self) -> &str {
|
||||
if self.key == self.fullname {
|
||||
""
|
||||
} else {
|
||||
&self.key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One discovery update for the UI's advert map.
|
||||
pub enum DiscoveryEvent {
|
||||
/// A host advert appeared or refreshed (new address, pairing flipped, …).
|
||||
@@ -39,8 +54,8 @@ pub enum DiscoveryEvent {
|
||||
Removed { fullname: String },
|
||||
}
|
||||
|
||||
/// Browse continuously for the app's lifetime. The thread exits when the receiver is
|
||||
/// dropped (the send fails) or the daemon dies.
|
||||
/// Browse continuously. The worker exits when the returned receiver is dropped, or when the
|
||||
/// daemon dies — checked on a tick, so it stops even on a LAN where no advert ever arrives.
|
||||
pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
std::thread::Builder::new()
|
||||
@@ -60,7 +75,24 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
||||
return;
|
||||
}
|
||||
};
|
||||
while let Ok(event) = receiver.recv() {
|
||||
// Polled rather than blocked on: the worker has to notice that its consumer went
|
||||
// away even when NOTHING is arriving, which is the normal state of a LAN with no
|
||||
// hosts on it. A plain `recv()` parks forever there, and the ignored-event arm below
|
||||
// never touches `tx` — so a bounded consumer like `discover_for` would leak this
|
||||
// thread and its daemon (another thread, and a socket bound to :5353) on every call.
|
||||
loop {
|
||||
// Checked at the TOP so it also covers the arms below that `continue` without
|
||||
// ever touching `tx` — the ignored event kinds, and an advert with no IPv4
|
||||
// address. Those are the paths that would otherwise keep this thread alive with
|
||||
// nobody to send to.
|
||||
if tx.is_closed() {
|
||||
break;
|
||||
}
|
||||
let event = match receiver.recv_timeout(Duration::from_millis(250)) {
|
||||
Ok(event) => event,
|
||||
Err(_) if receiver.is_disconnected() => break,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let update = match event {
|
||||
ServiceEvent::ServiceResolved(info) => {
|
||||
let props = info.get_properties();
|
||||
@@ -117,3 +149,154 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
||||
.expect("spawn mdns thread");
|
||||
rx
|
||||
}
|
||||
|
||||
/// The advert map one browse window folded down to. Kept separate from [`discover_for`] so the
|
||||
/// fold — which is where dedupe and removal actually live — is testable without a network.
|
||||
type Adverts = BTreeMap<String, DiscoveredHost>;
|
||||
|
||||
/// Apply one event to the map. A refreshed advert WINS over the one already there (it carries
|
||||
/// the newer address — a host that changed DHCP lease re-announces), and a removal drops
|
||||
/// whichever entry that mDNS fullname produced, whatever it was keyed under.
|
||||
fn fold(adverts: &mut Adverts, event: DiscoveryEvent) {
|
||||
match event {
|
||||
DiscoveryEvent::Resolved(host) => {
|
||||
adverts.insert(host.key.clone(), host);
|
||||
}
|
||||
DiscoveryEvent::Removed { fullname } => {
|
||||
adverts.retain(|_, h| h.fullname != fullname);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Browse for `timeout`, then return what answered — deduped by `key`, address-sorted.
|
||||
///
|
||||
/// Blocking; intended for one-shot consumers (the CLI's `discover` verb, a plugin backend that
|
||||
/// wants one bounded call rather than a stream). The streaming [`browse`] stays the UI's door:
|
||||
/// a live hosts page wants adverts as they land, not a snapshot taken `timeout` after it opened.
|
||||
pub fn discover_for(timeout: Duration) -> Vec<DiscoveredHost> {
|
||||
let rx = browse();
|
||||
let deadline = Instant::now() + timeout;
|
||||
let mut adverts = Adverts::new();
|
||||
while Instant::now() < deadline {
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
fold(&mut adverts, event);
|
||||
}
|
||||
// A short tick rather than a blocking recv with a deadline: `async_channel`'s blocking
|
||||
// receive has no timeout, and the whole point of this call is that it is bounded.
|
||||
std::thread::sleep(Duration::from_millis(50).min(timeout));
|
||||
}
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
fold(&mut adverts, event);
|
||||
}
|
||||
// Dropping the receiver is what stops the worker — it polls for that, so this holds even
|
||||
// when nothing is advertising. Without it a one-shot consumer would leak a browse per call.
|
||||
drop(rx);
|
||||
sorted(adverts)
|
||||
}
|
||||
|
||||
/// The map as the list a caller gets: sorted by address, then port. IPv4 is compared
|
||||
/// NUMERICALLY (a lexical sort puts `.10` before `.9`, which reads as scrambled in a host list).
|
||||
fn sorted(adverts: Adverts) -> Vec<DiscoveredHost> {
|
||||
let mut hosts: Vec<DiscoveredHost> = adverts.into_values().collect();
|
||||
hosts.sort_by_key(|h| {
|
||||
(
|
||||
h.addr.parse::<std::net::Ipv4Addr>().ok().map(u32::from),
|
||||
h.addr.clone(),
|
||||
h.port,
|
||||
)
|
||||
});
|
||||
hosts
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn host(key: &str, fullname: &str, addr: &str) -> DiscoveredHost {
|
||||
DiscoveredHost {
|
||||
key: key.into(),
|
||||
fullname: fullname.into(),
|
||||
name: fullname.split('.').next().unwrap_or("?").into(),
|
||||
addr: addr.into(),
|
||||
port: 9777,
|
||||
fp_hex: "aa".into(),
|
||||
pair: "required".into(),
|
||||
mgmt_port: Some(47990),
|
||||
mac: vec![],
|
||||
os: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Two adverts for the same host collapse to one row, and the LATER one wins — that is how
|
||||
/// a host that moved to a new address stops being listed at the stale one.
|
||||
#[test]
|
||||
fn refreshed_advert_supersedes_the_earlier_one() {
|
||||
let mut adverts = Adverts::new();
|
||||
fold(
|
||||
&mut adverts,
|
||||
DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.9")),
|
||||
);
|
||||
fold(
|
||||
&mut adverts,
|
||||
DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.20")),
|
||||
);
|
||||
let out = sorted(adverts);
|
||||
assert_eq!(out.len(), 1, "same key must not render twice");
|
||||
assert_eq!(out[0].addr, "192.168.1.20", "the newer address wins");
|
||||
}
|
||||
|
||||
/// A host that goes away during the browse window is not in the answer.
|
||||
#[test]
|
||||
fn removal_drops_the_advert_it_names() {
|
||||
let mut adverts = Adverts::new();
|
||||
fold(
|
||||
&mut adverts,
|
||||
DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.9")),
|
||||
);
|
||||
fold(
|
||||
&mut adverts,
|
||||
DiscoveryEvent::Resolved(host("id-2", "tv._punktfunk._udp.local.", "192.168.1.10")),
|
||||
);
|
||||
fold(
|
||||
&mut adverts,
|
||||
DiscoveryEvent::Removed {
|
||||
fullname: "desk._punktfunk._udp.local.".into(),
|
||||
},
|
||||
);
|
||||
let out = sorted(adverts);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].key, "id-2");
|
||||
}
|
||||
|
||||
/// A host with no `id` TXT is keyed by its fullname — and must not then report that
|
||||
/// fullname as an id, which would send a caller launching against a nonexistent reference.
|
||||
#[test]
|
||||
fn advertised_id_is_empty_without_the_txt() {
|
||||
let named = host("id-1", "desk._punktfunk._udp.local.", "10.0.0.1");
|
||||
assert_eq!(named.advertised_id(), "id-1");
|
||||
let anonymous = host(
|
||||
"desk._punktfunk._udp.local.",
|
||||
"desk._punktfunk._udp.local.",
|
||||
"10.0.0.1",
|
||||
);
|
||||
assert_eq!(anonymous.advertised_id(), "");
|
||||
}
|
||||
|
||||
/// Addresses sort the way a person reads them, not the way strings compare.
|
||||
#[test]
|
||||
fn addresses_sort_numerically() {
|
||||
let mut adverts = Adverts::new();
|
||||
for (i, addr) in ["192.168.1.20", "192.168.1.9", "192.168.1.100"]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
fold(
|
||||
&mut adverts,
|
||||
DiscoveryEvent::Resolved(host(&format!("id-{i}"), &format!("h{i}."), addr)),
|
||||
);
|
||||
}
|
||||
let out = sorted(adverts);
|
||||
let addrs: Vec<&str> = out.iter().map(|h| h.addr.as_str()).collect();
|
||||
assert_eq!(addrs, ["192.168.1.9", "192.168.1.20", "192.168.1.100"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,17 +232,29 @@ impl KnownHosts {
|
||||
/// A read-only config dir just keeps re-minting in memory, which harms nothing: no lookup
|
||||
/// is keyed by the id yet (design §4.5).
|
||||
pub fn load() -> KnownHosts {
|
||||
let mut k: KnownHosts = Self::path()
|
||||
.and_then(|p| Ok(std::fs::read_to_string(p)?))
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
let mut k = Self::read();
|
||||
if k.mint_missing_ids() {
|
||||
let _ = k.save();
|
||||
}
|
||||
k
|
||||
}
|
||||
|
||||
/// The store exactly as it is on disk — no mint, and so no write.
|
||||
///
|
||||
/// For a consumer that only needs to LOOK at the records (annotating a discovery result
|
||||
/// against them, say) and never dials one by id. [`KnownHosts::load`]'s mint is a write, and
|
||||
/// two processes started together against a pre-mint store will each mint a *different* id
|
||||
/// for the same record and race to save it — after which whichever one already handed its
|
||||
/// ids to a caller has handed out references that no longer resolve. A read that stays a
|
||||
/// read cannot take part in that.
|
||||
pub fn read() -> KnownHosts {
|
||||
Self::path()
|
||||
.and_then(|p| Ok(std::fs::read_to_string(p)?))
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Give every record still missing one a stable id; returns true if anything changed
|
||||
/// (i.e. whether this needs persisting). Idempotent — a store that has been through it
|
||||
/// once is left byte-identical.
|
||||
|
||||
@@ -35,7 +35,9 @@ mod widgets;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub use library::{LibraryGame, LibraryPhase, LibraryShared};
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub use model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus};
|
||||
pub use model::{
|
||||
ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, ProfileChip, WakeStatus,
|
||||
};
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub use shell::ConsoleOptions;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
|
||||
@@ -7,9 +7,20 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// A settings profile as the console shows it (design client-settings-profiles.md §5.2a):
|
||||
/// the resolved name and accent of a catalog entry, keyed by its stable id. The service
|
||||
/// thread resolves these against the catalog; the shell never opens the profiles file.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ProfileChip {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// `#RRGGBB`, the catalog's optional tint for pinned cards.
|
||||
pub accent: Option<String>,
|
||||
}
|
||||
|
||||
/// One row on the console home carousel — a saved host, a discovered-but-unsaved one,
|
||||
/// or (client-side) the trailing Add Host tile. Fully resolved by the service thread;
|
||||
/// the shell renders it verbatim.
|
||||
/// a pinned profile card, or (client-side) the trailing Add Host tile. Fully resolved by
|
||||
/// the service thread; the shell renders it verbatim.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct HostRow {
|
||||
/// Stable identity across refreshes: the pinned fingerprint when known, else
|
||||
@@ -35,6 +46,14 @@ pub struct HostRow {
|
||||
/// future tile OS glyph. Empty = unknown (older host). Plumbed now; drawing is a
|
||||
/// follow-up — the Skia glyph set doesn't exist yet.
|
||||
pub os: String,
|
||||
/// `Some` = this row is a pinned profile card (§5.2a): a shortcut tile rendered right
|
||||
/// after its host's primary tile, sharing its live state, that connects with THIS
|
||||
/// profile. `None` = the host's primary tile.
|
||||
pub pin: Option<ProfileChip>,
|
||||
/// The primary tile's default-profile chip: the profile bound as this host's default
|
||||
/// (`KnownHost::profile_id`), resolved, so the tile can say what a plain A-press uses.
|
||||
/// Always `None` on pinned rows — there the profile IS `pin`.
|
||||
pub bound_profile: Option<ProfileChip>,
|
||||
}
|
||||
|
||||
/// The pairing ceremony's observable state (one at a time — the ceremony is modal).
|
||||
@@ -143,6 +162,16 @@ pub enum ConsoleCmd {
|
||||
CancelWake,
|
||||
/// Sweep reachability now (the home screen refreshes its presence pips).
|
||||
Probe,
|
||||
/// Pin (or unpin) a profile as an extra connect card on a saved host
|
||||
/// (`KnownHost::pinned_profiles`, design §5.2a). `key` is the HOST row's key
|
||||
/// (fingerprint or `addr:port`); presentation only — never touches the host's
|
||||
/// default binding or the profile itself. Idempotent: re-pinning a pinned profile
|
||||
/// (or unpinning an absent one) is a no-op.
|
||||
SetPin {
|
||||
key: String,
|
||||
profile_id: String,
|
||||
pin: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// The overlay→binary command queue. A plain deque under the same locking discipline as
|
||||
@@ -184,6 +213,8 @@ mod tests {
|
||||
can_wake: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
bound_profile: None,
|
||||
};
|
||||
shared.set_hosts(vec![row.clone()]);
|
||||
let g1 = shared.hosts_gen();
|
||||
|
||||
@@ -7,6 +7,7 @@ pub(crate) mod add_host;
|
||||
pub(crate) mod home;
|
||||
pub(crate) mod library;
|
||||
pub(crate) mod pair;
|
||||
pub(crate) mod pin_hosts;
|
||||
pub(crate) mod settings;
|
||||
|
||||
use crate::glyphs::Hint;
|
||||
@@ -57,6 +58,9 @@ pub(crate) struct ConnectIntent {
|
||||
/// shell shows a "waiting for approval" takeover instead of "connecting", and the
|
||||
/// binary parks on a long budget and persists the host as paired once let in.
|
||||
pub request_access: bool,
|
||||
/// One-off settings-profile id for this launch (a pinned card's connect); `None`
|
||||
/// keeps the host's default binding.
|
||||
pub profile: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) enum Nav {
|
||||
@@ -91,6 +95,7 @@ pub(crate) enum Screen {
|
||||
Settings(settings::SettingsScreen),
|
||||
AddHost(add_host::AddHostScreen),
|
||||
Pair(pair::PairScreen),
|
||||
PinHosts(pin_hosts::PinHostsScreen),
|
||||
}
|
||||
|
||||
impl Screen {
|
||||
@@ -106,6 +111,7 @@ impl Screen {
|
||||
Screen::Settings(s) => s.menu(ev, ctx, fx),
|
||||
Screen::AddHost(s) => s.menu(ev, ctx, fx),
|
||||
Screen::Pair(s) => s.menu(ev, ctx, fx),
|
||||
Screen::PinHosts(s) => s.menu(ev, ctx, fx),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +158,7 @@ impl Screen {
|
||||
Screen::Settings(_) => "Settings".into(),
|
||||
Screen::AddHost(_) => "Add Host".into(),
|
||||
Screen::Pair(s) => format!("Pair with {}", s.host_name()),
|
||||
Screen::PinHosts(s) => format!("Pin \u{201c}{}\u{201d}", s.profile_name()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +169,7 @@ impl Screen {
|
||||
Screen::Settings(s) => s.hints(ctx),
|
||||
Screen::AddHost(s) => s.hints(ctx),
|
||||
Screen::Pair(s) => s.hints(ctx),
|
||||
Screen::PinHosts(s) => s.hints(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +191,7 @@ impl Screen {
|
||||
Screen::Settings(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::AddHost(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::Pair(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::PinHosts(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,13 +94,19 @@ impl HomeScreen {
|
||||
Some(h) => {
|
||||
// Dial-first even when the presence pips say offline — a
|
||||
// routed/VPN host is mDNS-blind and probe-shy but dials fine.
|
||||
// A pinned card connects with ITS profile (one-off, §5.2a);
|
||||
// the primary tile keeps the host's default binding.
|
||||
fx.connect = Some(ConnectIntent {
|
||||
addr: h.addr.clone(),
|
||||
port: h.port,
|
||||
fp_hex: h.fp_hex.clone(),
|
||||
launch: None,
|
||||
title: h.name.clone(),
|
||||
title: match &h.pin {
|
||||
Some(p) => format!("{} · {}", h.name, p.name),
|
||||
None => h.name.clone(),
|
||||
},
|
||||
request_access: false,
|
||||
profile: h.pin.as_ref().map(|p| p.id.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -295,16 +301,62 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6
|
||||
|
||||
let max_w = f64::from(rect.width()) - 2.0 * pad;
|
||||
let sub_base = f64::from(rect.bottom) - pad;
|
||||
fonts.draw_clipped(
|
||||
canvas,
|
||||
&format!("{}:{}", h.addr, h.port),
|
||||
l,
|
||||
sub_base,
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
white(0.55),
|
||||
max_w,
|
||||
);
|
||||
match (&h.pin, &h.bound_profile) {
|
||||
// A pinned card: the profile name IS the subtitle, tinted with its accent —
|
||||
// the card's whole point is "this host, with these settings" (§5.2a).
|
||||
(Some(p), _) => {
|
||||
fonts.draw_clipped(
|
||||
canvas,
|
||||
&p.name,
|
||||
l,
|
||||
sub_base,
|
||||
W::SemiBold,
|
||||
13.0 * k,
|
||||
accent_color(p.accent.as_deref()),
|
||||
max_w,
|
||||
);
|
||||
}
|
||||
// The primary tile says which profile a plain press uses, after the address.
|
||||
(None, Some(b)) => {
|
||||
let addr = format!("{}:{}", h.addr, h.port);
|
||||
let addr_w = f64::from(fonts.measure(&addr, W::Regular, 13.0 * k));
|
||||
fonts.draw_clipped(
|
||||
canvas,
|
||||
&addr,
|
||||
l,
|
||||
sub_base,
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
white(0.55),
|
||||
max_w,
|
||||
);
|
||||
let x = l + addr_w + 8.0 * k;
|
||||
if x < l + max_w {
|
||||
fonts.draw_clipped(
|
||||
canvas,
|
||||
&format!("· {}", b.name),
|
||||
x,
|
||||
sub_base,
|
||||
W::SemiBold,
|
||||
13.0 * k,
|
||||
accent_color(b.accent.as_deref()),
|
||||
l + max_w - x,
|
||||
);
|
||||
}
|
||||
}
|
||||
(None, None) => {
|
||||
fonts.draw_clipped(
|
||||
canvas,
|
||||
&format!("{}:{}", h.addr, h.port),
|
||||
l,
|
||||
sub_base,
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
white(0.55),
|
||||
max_w,
|
||||
);
|
||||
}
|
||||
}
|
||||
fonts.draw_clipped(
|
||||
canvas,
|
||||
&h.name,
|
||||
@@ -317,6 +369,26 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6
|
||||
);
|
||||
}
|
||||
|
||||
/// A profile's `#RRGGBB` accent as a color, defaulting to the brand tint. Parsed
|
||||
/// leniently — a malformed accent (hand-edited catalog) falls back rather than erroring.
|
||||
fn accent_color(accent: Option<&str>) -> skia_safe::Color4f {
|
||||
let Some(hex) = accent
|
||||
.and_then(|a| a.strip_prefix('#'))
|
||||
.filter(|h| h.len() == 6)
|
||||
else {
|
||||
return BRAND;
|
||||
};
|
||||
let Ok(v) = u32::from_str_radix(hex, 16) else {
|
||||
return BRAND;
|
||||
};
|
||||
skia_safe::Color4f::new(
|
||||
((v >> 16) & 0xff) as f32 / 255.0,
|
||||
((v >> 8) & 0xff) as f32 / 255.0,
|
||||
(v & 0xff) as f32 / 255.0,
|
||||
1.0,
|
||||
)
|
||||
}
|
||||
|
||||
fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) {
|
||||
crate::theme::panel(
|
||||
canvas,
|
||||
@@ -484,6 +556,8 @@ mod tests {
|
||||
can_wake,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
bound_profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,6 +625,38 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// A pinned card's A-press is a connect WITH its profile (one-off), titled so the
|
||||
/// connecting takeover says which settings are coming (§5.2a).
|
||||
#[test]
|
||||
fn pinned_card_connects_with_its_profile() {
|
||||
let mut settings = ctx_settings();
|
||||
let mut pinned = host("ab\0p1", true, true, false);
|
||||
pinned.name = "Tower".into();
|
||||
pinned.pin = Some(crate::model::ProfileChip {
|
||||
id: "p1".into(),
|
||||
name: "Work".into(),
|
||||
accent: None,
|
||||
});
|
||||
let hosts = [pinned];
|
||||
let pads: Vec<pf_client_core::gamepad::PadInfo> = Vec::new();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &hosts,
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = HomeScreen::new();
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
let intent = fx.connect.expect("a pinned card connects");
|
||||
assert_eq!(intent.profile.as_deref(), Some("p1"));
|
||||
assert_eq!(intent.title, "Tower · Work");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_tile_is_always_last() {
|
||||
let mut settings = ctx_settings();
|
||||
|
||||
@@ -120,6 +120,8 @@ impl LibraryScreen {
|
||||
launch: Some(g.id.clone()),
|
||||
title: g.title.clone(),
|
||||
request_access: false,
|
||||
// Game launches follow the host's default binding.
|
||||
profile: None,
|
||||
});
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
|
||||
@@ -221,6 +221,7 @@ impl PairScreen {
|
||||
launch: None,
|
||||
title: self.host_name.clone(),
|
||||
request_access: true,
|
||||
profile: None,
|
||||
});
|
||||
fx.pop();
|
||||
}
|
||||
@@ -430,6 +431,8 @@ mod tests {
|
||||
can_wake: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
bound_profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
//! "Pin “Work”" — choose which saved hosts show a profile as an extra connect card
|
||||
//! (design/client-settings-profiles.md §5.2a), reached from the settings screen's
|
||||
//! Profiles section. One toggle row per saved host; a toggle rides
|
||||
//! [`ConsoleCmd::SetPin`] to the binary, which persists `KnownHost::pinned_profiles`
|
||||
//! and refreshes the rows — the row's shown state follows the model, so what the list
|
||||
//! says is always what the store holds (and what Decky's host list will render).
|
||||
|
||||
use crate::glyphs::{Hint, HintKey};
|
||||
use crate::model::ConsoleCmd;
|
||||
use crate::screens::{Ctx, Outbox};
|
||||
use crate::theme::{Fonts, DIM, W};
|
||||
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||
use skia_safe::{Canvas, Rect};
|
||||
|
||||
pub(crate) struct PinHostsScreen {
|
||||
profile_id: String,
|
||||
profile_name: String,
|
||||
list: MenuList,
|
||||
}
|
||||
|
||||
/// The toggle rows' domain: every SAVED host, primary tiles only (a pinned card is the
|
||||
/// OUTPUT of this screen, not a row in it), in the model's carousel order.
|
||||
fn host_indices(ctx: &Ctx) -> Vec<usize> {
|
||||
ctx.hosts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, h)| h.saved && h.pin.is_none())
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl PinHostsScreen {
|
||||
pub(crate) fn new(profile_id: String, profile_name: String) -> PinHostsScreen {
|
||||
PinHostsScreen {
|
||||
profile_id,
|
||||
profile_name,
|
||||
list: MenuList::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn profile_name(&self) -> &str {
|
||||
&self.profile_name
|
||||
}
|
||||
|
||||
/// Is this profile currently pinned on the host at `ctx.hosts[host_idx]`? Read from
|
||||
/// the model — the pinned card's row IS the state, so the toggle can never disagree
|
||||
/// with what the carousel shows.
|
||||
fn pinned(&self, ctx: &Ctx, host_idx: usize) -> bool {
|
||||
let host = &ctx.hosts[host_idx];
|
||||
ctx.hosts.iter().any(|r| {
|
||||
r.addr == host.addr
|
||||
&& r.port == host.port
|
||||
&& r.pin.as_ref().is_some_and(|p| p.id == self.profile_id)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn menu(
|
||||
&mut self,
|
||||
ev: MenuEvent,
|
||||
ctx: &mut Ctx,
|
||||
fx: &mut Outbox,
|
||||
) -> Option<MenuPulse> {
|
||||
if ev == MenuEvent::Back {
|
||||
fx.pop();
|
||||
return None;
|
||||
}
|
||||
let indices = host_indices(ctx);
|
||||
let (msg, pulse) = self.list.menu(ev, indices.len());
|
||||
let Some(&host_idx) = indices.get(self.list.cursor) else {
|
||||
return pulse;
|
||||
};
|
||||
// Toggle semantics shared with the settings rows: left = unpin, right = pin,
|
||||
// A flips; asking for the state it's already in is a boundary thud.
|
||||
let target = match msg {
|
||||
ListMsg::Adjust(delta) => delta > 0,
|
||||
ListMsg::Activate => !self.pinned(ctx, host_idx),
|
||||
ListMsg::None => return pulse,
|
||||
};
|
||||
if self.pinned(ctx, host_idx) == target {
|
||||
return Some(MenuPulse::Boundary);
|
||||
}
|
||||
fx.cmds.push(ConsoleCmd::SetPin {
|
||||
key: ctx.hosts[host_idx].key.clone(),
|
||||
profile_id: self.profile_id.clone(),
|
||||
pin: target,
|
||||
});
|
||||
Some(MenuPulse::Move)
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
|
||||
if host_indices(ctx).is_empty() {
|
||||
return vec![Hint::new(HintKey::Back, "Done")];
|
||||
}
|
||||
vec![
|
||||
Hint::new(HintKey::Confirm, "Pin / Unpin"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
&mut self,
|
||||
canvas: &Canvas,
|
||||
rect: Rect,
|
||||
k: f64,
|
||||
dt: f64,
|
||||
fonts: &Fonts,
|
||||
ctx: &mut Ctx,
|
||||
) {
|
||||
let indices = host_indices(ctx);
|
||||
let cx = f64::from(rect.left) + f64::from(rect.width()) / 2.0;
|
||||
if indices.is_empty() {
|
||||
fonts.centered(
|
||||
canvas,
|
||||
"No saved hosts yet — pair with a host first, then pin this profile to it.",
|
||||
W::Regular,
|
||||
14.0 * k,
|
||||
DIM,
|
||||
cx,
|
||||
f64::from(rect.top) + f64::from(rect.height()) / 2.0,
|
||||
f64::from(rect.width()) * 0.7,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The explainer band under the list, like the settings screen's detail text.
|
||||
let detail_h = 34.0 * k;
|
||||
let list_rect = Rect::from_ltrb(
|
||||
rect.left,
|
||||
rect.top,
|
||||
rect.right,
|
||||
rect.bottom - detail_h as f32,
|
||||
);
|
||||
let rows: Vec<RowSpec> = indices
|
||||
.iter()
|
||||
.map(|&i| {
|
||||
let h = &ctx.hosts[i];
|
||||
let pinned = self.pinned(ctx, i);
|
||||
RowSpec {
|
||||
header: None,
|
||||
label: h.name.clone(),
|
||||
value: Some(if pinned {
|
||||
"Pinned".into()
|
||||
} else {
|
||||
"Off".into()
|
||||
}),
|
||||
value_dim: !pinned,
|
||||
caret: false,
|
||||
adjustable: true,
|
||||
enabled: true,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.list
|
||||
.render(canvas, list_rect, &rows, fonts, k, dt, true);
|
||||
fonts.centered(
|
||||
canvas,
|
||||
"A pinned profile appears as its own card on the host — one press connects with it.",
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
DIM,
|
||||
cx,
|
||||
f64::from(rect.bottom) - detail_h + 6.0 * k,
|
||||
f64::from(rect.width()) * 0.8,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::{HostRow, ProfileChip};
|
||||
use crate::screens::Outbox;
|
||||
use pf_client_core::trust::Settings;
|
||||
|
||||
fn host(key: &str, saved: bool, pin: Option<&str>) -> HostRow {
|
||||
HostRow {
|
||||
key: key.into(),
|
||||
name: key.into(),
|
||||
addr: "10.0.0.9".into(),
|
||||
port: 9777,
|
||||
fp_hex: key.into(),
|
||||
paired: true,
|
||||
saved,
|
||||
online: true,
|
||||
mgmt_port: 47990,
|
||||
can_wake: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: pin.map(|id| ProfileChip {
|
||||
id: id.into(),
|
||||
name: "Work".into(),
|
||||
accent: None,
|
||||
}),
|
||||
bound_profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggling_sends_set_pin_for_the_focused_host() {
|
||||
let mut settings = Settings::default();
|
||||
let pads = Vec::new();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let hosts = [host("aa", true, None), host("bb", true, None)];
|
||||
let mut ctx = Ctx {
|
||||
hosts: &hosts,
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = PinHostsScreen::new("p1".into(), "Work".into());
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::SetPin {
|
||||
key: "aa".into(),
|
||||
profile_id: "p1".into(),
|
||||
pin: true,
|
||||
}]
|
||||
);
|
||||
|
||||
// Left on an unpinned host = already off = boundary, no command.
|
||||
let mut fx = Outbox::default();
|
||||
let pulse = s.menu(
|
||||
MenuEvent::Move(pf_client_core::gamepad::MenuDir::Left),
|
||||
&mut ctx,
|
||||
&mut fx,
|
||||
);
|
||||
assert!(fx.cmds.is_empty());
|
||||
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_reads_from_the_models_pinned_rows() {
|
||||
let mut settings = Settings::default();
|
||||
let pads = Vec::new();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
// Host "aa" already carries a pinned card for p1; its primary row toggles OFF.
|
||||
let hosts = [host("aa", true, None), host("aa\0p1", true, Some("p1"))];
|
||||
let mut ctx = Ctx {
|
||||
hosts: &hosts,
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = PinHostsScreen::new("p1".into(), "Work".into());
|
||||
// Only the primary row is a toggle row.
|
||||
assert_eq!(host_indices(&ctx).len(), 1);
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::SetPin {
|
||||
key: "aa".into(),
|
||||
profile_id: "p1".into(),
|
||||
pin: false,
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
//! read the same file, so values round-trip freely.
|
||||
|
||||
use crate::glyphs::{Hint, HintKey};
|
||||
use crate::screens::{Ctx, Outbox};
|
||||
use crate::screens::{Ctx, Outbox, Screen};
|
||||
use crate::theme::{Fonts, DIM, W};
|
||||
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||
@@ -15,8 +15,13 @@ use skia_safe::{Canvas, Rect};
|
||||
|
||||
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
|
||||
/// index when the pad list under the "Use controller" row churns.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum RowId {
|
||||
/// A catalog profile (index into [`SettingsScreen::profiles`]) — activating opens
|
||||
/// the pin-to-hosts screen. The console never edits profiles (design §5.4).
|
||||
Profile(usize),
|
||||
/// The Profiles section's placeholder while the catalog is empty.
|
||||
NoProfiles,
|
||||
Resolution,
|
||||
Refresh,
|
||||
RenderScale,
|
||||
@@ -50,7 +55,8 @@ enum RowId {
|
||||
// Gaming Mode, so a field it omits is simply unreachable there (render scale, 4:4:4,
|
||||
// scroll/shortcut behavior, fullscreen-on-stream, auto-wake, the library toggle and echo
|
||||
// cancellation all were). Still deliberately smaller than the desktop dialogs — device
|
||||
// pickers (GPU/speaker/mic) and the profile catalog stay desktop-only.
|
||||
// pickers (GPU/speaker/mic) stay desktop-only, and profiles are pinnable here (the
|
||||
// trailing Profiles section) but created and edited only in the desktop app (design §5.4).
|
||||
const ROWS: [RowId; 27] = [
|
||||
RowId::Resolution,
|
||||
RowId::Refresh,
|
||||
@@ -149,15 +155,42 @@ const PAD_TYPES: [(&str, &str); 6] = [
|
||||
|
||||
pub(crate) struct SettingsScreen {
|
||||
list: MenuList,
|
||||
/// The profile catalog's `(id, name)` pairs, loaded once at construction — the console
|
||||
/// can't create profiles (design §5.4: the desktop app does), so the list is stable
|
||||
/// for the screen's lifetime.
|
||||
profiles: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl SettingsScreen {
|
||||
pub(crate) fn new() -> SettingsScreen {
|
||||
Self::with_profiles(
|
||||
pf_client_core::profiles::ProfilesFile::load()
|
||||
.profiles
|
||||
.into_iter()
|
||||
.map(|p| (p.id, p.name))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn with_profiles(profiles: Vec<(String, String)>) -> SettingsScreen {
|
||||
SettingsScreen {
|
||||
list: MenuList::new(),
|
||||
profiles,
|
||||
}
|
||||
}
|
||||
|
||||
/// The full row list: the fixed settings rows, then the Profiles section — one row
|
||||
/// per catalog profile, or the explainer placeholder while there are none.
|
||||
fn row_ids(&self) -> Vec<RowId> {
|
||||
let mut ids = ROWS.to_vec();
|
||||
if self.profiles.is_empty() {
|
||||
ids.push(RowId::NoProfiles);
|
||||
} else {
|
||||
ids.extend((0..self.profiles.len()).map(RowId::Profile));
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
pub(crate) fn menu(
|
||||
&mut self,
|
||||
ev: MenuEvent,
|
||||
@@ -168,7 +201,31 @@ impl SettingsScreen {
|
||||
fx.pop();
|
||||
return None;
|
||||
}
|
||||
let (msg, pulse) = self.list.menu(ev, ROWS.len());
|
||||
let ids = self.row_ids();
|
||||
let (msg, pulse) = self.list.menu(ev, ids.len());
|
||||
// The Profiles rows navigate instead of editing the settings file.
|
||||
match ids[self.list.cursor] {
|
||||
RowId::Profile(i) => {
|
||||
return match msg {
|
||||
ListMsg::Activate => {
|
||||
let (id, name) = self.profiles[i].clone();
|
||||
fx.push(Screen::PinHosts(super::pin_hosts::PinHostsScreen::new(
|
||||
id, name,
|
||||
)));
|
||||
pulse
|
||||
}
|
||||
ListMsg::Adjust(_) => Some(MenuPulse::Boundary),
|
||||
ListMsg::None => pulse,
|
||||
}
|
||||
}
|
||||
RowId::NoProfiles => {
|
||||
return match msg {
|
||||
ListMsg::Adjust(_) | ListMsg::Activate => Some(MenuPulse::Boundary),
|
||||
ListMsg::None => pulse,
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Rebase the shell-lifetime snapshot on the file before an adjust-then-save: this
|
||||
// screen is one of the settings file's several whole-file writers (profiles.rs
|
||||
// documents the no-merge debt), and adjusting a stale snapshot would silently
|
||||
@@ -180,7 +237,7 @@ impl SettingsScreen {
|
||||
}
|
||||
match msg {
|
||||
ListMsg::Adjust(delta) => {
|
||||
let changed = adjust(ROWS[self.list.cursor], delta, false, ctx);
|
||||
let changed = adjust(ids[self.list.cursor], delta, false, ctx);
|
||||
if changed {
|
||||
ctx.settings.save();
|
||||
Some(MenuPulse::Move)
|
||||
@@ -190,7 +247,7 @@ impl SettingsScreen {
|
||||
}
|
||||
ListMsg::Activate => {
|
||||
// A cycles forward WRAPPING, so every option is reachable one-handed.
|
||||
if adjust(ROWS[self.list.cursor], 1, true, ctx) {
|
||||
if adjust(ids[self.list.cursor], 1, true, ctx) {
|
||||
ctx.settings.save();
|
||||
}
|
||||
pulse
|
||||
@@ -200,11 +257,18 @@ impl SettingsScreen {
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
|
||||
vec![
|
||||
Hint::new(HintKey::Adjust, "Adjust"),
|
||||
Hint::new(HintKey::Confirm, "Change"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
]
|
||||
match self.row_ids()[self.list.cursor] {
|
||||
RowId::Profile(_) => vec![
|
||||
Hint::new(HintKey::Confirm, "Pin to hosts…"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
],
|
||||
RowId::NoProfiles => vec![Hint::new(HintKey::Back, "Done")],
|
||||
_ => vec![
|
||||
Hint::new(HintKey::Adjust, "Adjust"),
|
||||
Hint::new(HintKey::Confirm, "Change"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
@@ -224,10 +288,14 @@ impl SettingsScreen {
|
||||
rect.right,
|
||||
rect.bottom - detail_h as f32,
|
||||
);
|
||||
let rows: Vec<RowSpec> = ROWS.iter().map(|id| row_spec(*id, ctx)).collect();
|
||||
let ids = self.row_ids();
|
||||
let rows: Vec<RowSpec> = ids
|
||||
.iter()
|
||||
.map(|id| row_spec(*id, ctx, &self.profiles))
|
||||
.collect();
|
||||
self.list
|
||||
.render(canvas, list_rect, &rows, fonts, k, dt, true);
|
||||
let detail = detail(ROWS[self.list.cursor]);
|
||||
let detail = detail(ids[self.list.cursor]);
|
||||
fonts.centered(
|
||||
canvas,
|
||||
detail,
|
||||
@@ -241,7 +309,38 @@ impl SettingsScreen {
|
||||
}
|
||||
}
|
||||
|
||||
fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
||||
fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
// The Profiles section: name + how many hosts pin it (counted from the live rows, so
|
||||
// it reflects what the carousel shows). Read-only here beyond opening the pin screen.
|
||||
match id {
|
||||
RowId::Profile(i) => {
|
||||
let (pid, name) = &profiles[i];
|
||||
let pins = ctx
|
||||
.hosts
|
||||
.iter()
|
||||
.filter(|h| h.pin.as_ref().is_some_and(|p| &p.id == pid))
|
||||
.count();
|
||||
return RowSpec {
|
||||
header: (i == 0).then_some("Profiles"),
|
||||
label: name.clone(),
|
||||
value: Some(match pins {
|
||||
0 => "Not pinned".into(),
|
||||
1 => "Pinned to 1 host".into(),
|
||||
n => format!("Pinned to {n} hosts"),
|
||||
}),
|
||||
value_dim: pins == 0,
|
||||
caret: false,
|
||||
adjustable: false,
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
RowId::NoProfiles => {
|
||||
let mut row = RowSpec::action("No profiles yet", false);
|
||||
row.header = Some("Profiles");
|
||||
return row;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let s = &ctx.settings;
|
||||
// Several rows follow another: echo cancellation only means anything while the mic
|
||||
// streams, the pad rows only while any controller is forwarded at all, and the
|
||||
@@ -382,6 +481,7 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
||||
),
|
||||
RowId::AutoWake => (None, "Wake hosts automatically", on_off(s.auto_wake).into()),
|
||||
RowId::Library => (None, "Game library", on_off(s.library_enabled).into()),
|
||||
RowId::Profile(_) | RowId::NoProfiles => unreachable!("returned above"),
|
||||
};
|
||||
RowSpec {
|
||||
header,
|
||||
@@ -477,6 +577,16 @@ fn detail(id: RowId) -> &'static str {
|
||||
reached over a VPN, where the wake wait only adds delay."
|
||||
}
|
||||
RowId::Library => "Show paired hosts' game libraries (tap a title to stream it).",
|
||||
RowId::Profile(_) => {
|
||||
"Pin this profile to a host and it appears as its own card — one press \
|
||||
connects with these settings. Profiles are created and edited in the \
|
||||
Punktfunk desktop app."
|
||||
}
|
||||
RowId::NoProfiles => {
|
||||
"Profiles bundle stream settings for different uses (a low-latency one, a \
|
||||
quality one…). Create them in the Punktfunk desktop app, then pin them \
|
||||
here as one-press connect cards."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -611,6 +721,8 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
RowId::Fullscreen => toggle(&mut s.fullscreen_on_stream, delta, wrap),
|
||||
RowId::AutoWake => toggle(&mut s.auto_wake, delta, wrap),
|
||||
RowId::Library => toggle(&mut s.library_enabled, delta, wrap),
|
||||
// Navigation rows, handled before the settings path in `menu` — never a value edit.
|
||||
RowId::Profile(_) | RowId::NoProfiles => None,
|
||||
}
|
||||
.is_some()
|
||||
}
|
||||
@@ -736,7 +848,7 @@ mod tests {
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
assert!(!row_spec(RowId::EchoCancel, &ctx).enabled);
|
||||
assert!(!row_spec(RowId::EchoCancel, &ctx, &[]).enabled);
|
||||
assert!(
|
||||
!adjust(RowId::EchoCancel, -1, false, &mut ctx),
|
||||
"mic off = thud"
|
||||
@@ -745,7 +857,7 @@ mod tests {
|
||||
assert!(ctx.settings.echo_cancel, "and nothing was written");
|
||||
|
||||
ctx.settings.mic_enabled = true;
|
||||
assert!(row_spec(RowId::EchoCancel, &ctx).enabled);
|
||||
assert!(row_spec(RowId::EchoCancel, &ctx, &[]).enabled);
|
||||
assert!(adjust(RowId::EchoCancel, -1, false, &mut ctx));
|
||||
assert!(!ctx.settings.echo_cancel);
|
||||
assert!(adjust(RowId::EchoCancel, 1, true, &mut ctx));
|
||||
@@ -771,7 +883,7 @@ mod tests {
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
assert!(!row_spec(RowId::SmoothBuffer, &ctx).enabled);
|
||||
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
|
||||
assert!(
|
||||
!adjust(RowId::SmoothBuffer, 1, false, &mut ctx),
|
||||
"latency intent = thud"
|
||||
@@ -781,14 +893,14 @@ mod tests {
|
||||
// Stepping the intent to Smoothness brings the buffer row to life.
|
||||
assert!(adjust(RowId::PresentPriority, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.present_priority, "smooth");
|
||||
assert!(row_spec(RowId::SmoothBuffer, &ctx).enabled);
|
||||
assert!(row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
|
||||
assert!(adjust(RowId::SmoothBuffer, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.smooth_buffer, 1);
|
||||
|
||||
// The intent wraps back and the row goes inert again.
|
||||
assert!(adjust(RowId::PresentPriority, -1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.present_priority, "latency");
|
||||
assert!(!row_spec(RowId::SmoothBuffer, &ctx).enabled);
|
||||
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -864,4 +976,110 @@ mod tests {
|
||||
assert!(adjust(RowId::Bitrate, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.bitrate_kbps, 0, "snapped to Automatic");
|
||||
}
|
||||
|
||||
/// The Profiles section trails the settings rows: one row per catalog profile whose
|
||||
/// value counts the pinned cards in the live model, activating opens the pin screen,
|
||||
/// and left/right (which edits every other row) is a boundary — a profile row
|
||||
/// navigates, it must never fall into the settings save path.
|
||||
#[test]
|
||||
fn profile_rows_navigate_instead_of_editing() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut pinned = crate::model::HostRow {
|
||||
key: "aa\0p1".into(),
|
||||
name: "Tower".into(),
|
||||
addr: "10.0.0.9".into(),
|
||||
port: 9777,
|
||||
fp_hex: "aa".into(),
|
||||
paired: true,
|
||||
saved: true,
|
||||
online: true,
|
||||
mgmt_port: 47990,
|
||||
can_wake: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: Some(crate::model::ProfileChip {
|
||||
id: "p1".into(),
|
||||
name: "Work".into(),
|
||||
accent: None,
|
||||
}),
|
||||
bound_profile: None,
|
||||
};
|
||||
let hosts = [pinned.clone(), {
|
||||
pinned.key = "aa".into();
|
||||
pinned.pin = None;
|
||||
pinned
|
||||
}];
|
||||
let mut ctx = Ctx {
|
||||
hosts: &hosts,
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = SettingsScreen::with_profiles(vec![
|
||||
("p1".into(), "Work".into()),
|
||||
("p2".into(), "Game".into()),
|
||||
]);
|
||||
let ids = s.row_ids();
|
||||
assert_eq!(ids.len(), ROWS.len() + 2);
|
||||
assert_eq!(ids[ROWS.len()], RowId::Profile(0));
|
||||
|
||||
let spec = row_spec(RowId::Profile(0), &ctx, &s.profiles);
|
||||
assert_eq!(spec.header, Some("Profiles"));
|
||||
assert_eq!(spec.label, "Work");
|
||||
assert_eq!(spec.value.as_deref(), Some("Pinned to 1 host"));
|
||||
let spec = row_spec(RowId::Profile(1), &ctx, &s.profiles);
|
||||
assert_eq!(spec.header, None, "only the first row carries the header");
|
||||
assert_eq!(spec.value.as_deref(), Some("Not pinned"));
|
||||
|
||||
s.list.cursor = ROWS.len(); // onto "Work"
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert!(
|
||||
matches!(fx.nav, Some(crate::screens::Nav::Push(b))
|
||||
if matches!(*b, Screen::PinHosts(ref p) if p.profile_name() == "Work")),
|
||||
"A on a profile row opens its pin screen"
|
||||
);
|
||||
|
||||
let mut fx = Outbox::default();
|
||||
let pulse = s.menu(
|
||||
MenuEvent::Move(pf_client_core::gamepad::MenuDir::Right),
|
||||
&mut ctx,
|
||||
&mut fx,
|
||||
);
|
||||
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
|
||||
assert!(fx.nav.is_none() && fx.cmds.is_empty());
|
||||
}
|
||||
|
||||
/// An empty catalog shows the explainer placeholder — present, inert, and dimmed —
|
||||
/// so the section still tells the user where profiles come from.
|
||||
#[test]
|
||||
fn empty_catalog_shows_the_placeholder() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||
let ids = s.row_ids();
|
||||
assert_eq!(*ids.last().unwrap(), RowId::NoProfiles);
|
||||
let spec = row_spec(RowId::NoProfiles, &ctx, &s.profiles);
|
||||
assert_eq!(spec.header, Some("Profiles"));
|
||||
assert!(!spec.enabled);
|
||||
|
||||
s.list.cursor = ids.len() - 1;
|
||||
let mut fx = Outbox::default();
|
||||
let pulse = s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
|
||||
assert!(fx.nav.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,8 +239,14 @@ impl Shell {
|
||||
port: h.port,
|
||||
fp_hex: h.fp_hex.clone(),
|
||||
launch: None,
|
||||
title: h.name.clone(),
|
||||
// A wake started from a pinned card carries its profile
|
||||
// through to the connect (the row's key found it again).
|
||||
title: match &h.pin {
|
||||
Some(p) => format!("{} · {}", h.name, p.name),
|
||||
None => h.name.clone(),
|
||||
},
|
||||
request_access: false,
|
||||
profile: h.pin.as_ref().map(|p| p.id.clone()),
|
||||
})
|
||||
});
|
||||
self.bus.send(ConsoleCmd::CancelWake);
|
||||
@@ -269,6 +275,7 @@ impl Shell {
|
||||
launch: intent.launch,
|
||||
title: intent.title,
|
||||
request_access: intent.request_access,
|
||||
profile: intent.profile,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ fn hosts() -> Vec<HostRow> {
|
||||
can_wake: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
bound_profile: None,
|
||||
};
|
||||
vec![
|
||||
HostRow {
|
||||
|
||||
@@ -88,8 +88,9 @@ pub enum ConsoleEntry {
|
||||
/// The host list (bare `--browse`).
|
||||
Home,
|
||||
/// Home with this host's library already pushed (`--browse host` — the Decky
|
||||
/// per-host launch; B backs out to Home).
|
||||
Library(HostRow),
|
||||
/// per-host launch; B backs out to Home). Boxed: `HostRow` outgrew the dataless
|
||||
/// `Home` variant when it learned its profile chips.
|
||||
Library(Box<HostRow>),
|
||||
}
|
||||
|
||||
/// The binary's ends of the console: models to write, commands to serve.
|
||||
|
||||
@@ -57,6 +57,82 @@ pub fn env_on(name: &str) -> Option<bool> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Where desktop audio should be audible — which decides the render endpoint the loopback captures.
|
||||
///
|
||||
/// Supersedes the two env-only knobs that used to encode this (`PUNKTFUNK_HOST_AUDIO`,
|
||||
/// `PUNKTFUNK_KEEP_DEFAULT`), which stay honoured as back-compat spellings so nobody's `host.env`
|
||||
/// breaks. Named modes exist because "which endpoint do we capture" is a routing decision an
|
||||
/// operator has to be able to make deliberately — the 2026-08-03 field report is what happens when
|
||||
/// the only way to express it is an undocumented environment variable.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum AudioOutputMode {
|
||||
/// Default. Prefer a render endpoint that is silent on the host, so streamed audio does not
|
||||
/// also play out of the host's speakers. Since 2026-08 a silent sink has to be able to carry
|
||||
/// the mix without narrowing it — otherwise real hardware wins anyway.
|
||||
#[default]
|
||||
ClientOnly,
|
||||
/// Prefer real hardware: audio plays on the host as well as the client. The old
|
||||
/// `PUNKTFUNK_HOST_AUDIO=1`.
|
||||
HostAndClient,
|
||||
/// Touch nothing — capture whatever the operator's own default playback device is, and never
|
||||
/// write the default-device policy. The old `PUNKTFUNK_KEEP_DEFAULT=1`.
|
||||
FollowDefault,
|
||||
}
|
||||
|
||||
impl AudioOutputMode {
|
||||
/// `PUNKTFUNK_AUDIO_OUTPUT_MODE` wins; otherwise fall back to the legacy flags, `follow_default`
|
||||
/// first (it is the more restrictive promise — "do not touch my devices" must not be overridden
|
||||
/// by a stale `PUNKTFUNK_HOST_AUDIO` in the same `host.env`).
|
||||
fn from_env() -> AudioOutputMode {
|
||||
if let Ok(raw) = std::env::var("PUNKTFUNK_AUDIO_OUTPUT_MODE") {
|
||||
if !raw.trim().is_empty() {
|
||||
if let Some(m) = AudioOutputMode::parse(&raw) {
|
||||
return m;
|
||||
}
|
||||
// Never silently fall through to a different routing than the operator asked for.
|
||||
eprintln!(
|
||||
"punktfunk: PUNKTFUNK_AUDIO_OUTPUT_MODE={raw:?} is not one of \
|
||||
client_only/host_and_client/follow_default — using client_only"
|
||||
);
|
||||
}
|
||||
}
|
||||
if std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some() {
|
||||
return AudioOutputMode::FollowDefault;
|
||||
}
|
||||
if std::env::var_os("PUNKTFUNK_HOST_AUDIO").is_some() {
|
||||
return AudioOutputMode::HostAndClient;
|
||||
}
|
||||
AudioOutputMode::ClientOnly
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<AudioOutputMode> {
|
||||
match s.trim().to_ascii_lowercase().replace('-', "_").as_str() {
|
||||
"client_only" | "client" => Some(AudioOutputMode::ClientOnly),
|
||||
"host_and_client" | "both" | "host" => Some(AudioOutputMode::HostAndClient),
|
||||
"follow_default" | "follow" => Some(AudioOutputMode::FollowDefault),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
AudioOutputMode::ClientOnly => "client_only",
|
||||
AudioOutputMode::HostAndClient => "host_and_client",
|
||||
AudioOutputMode::FollowDefault => "follow_default",
|
||||
}
|
||||
}
|
||||
|
||||
/// The loopback plan should prefer real hardware over a silent sink.
|
||||
pub fn prefers_host_hardware(self) -> bool {
|
||||
matches!(self, AudioOutputMode::HostAndClient)
|
||||
}
|
||||
|
||||
/// Leave the operator's default playback/recording devices completely alone.
|
||||
pub fn keeps_default(self) -> bool {
|
||||
matches!(self, AudioOutputMode::FollowDefault)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved host configuration. Holds the genuinely-constant operator/dispatch knobs (see module docs for
|
||||
/// what is deliberately excluded). Fields read on only one platform are kept alive cross-platform by the
|
||||
/// derived `Debug` impl, so the parser can stay a single platform-neutral function.
|
||||
@@ -99,6 +175,24 @@ pub struct HostConfig {
|
||||
/// e.g. webOS TVs, whose GCM decrypt caps at ~100 Mbps); everyone else stays AES-128-GCM.
|
||||
/// `PUNKTFUNK_CHACHA20=0`/`false`/`off`/`no` disables.
|
||||
pub chacha20: bool,
|
||||
/// `PUNKTFUNK_AUDIO_OUTPUT_MODE` — where desktop audio should be audible, and therefore which
|
||||
/// render endpoint the loopback captures (`client_only` / `host_and_client` / `follow_default`).
|
||||
///
|
||||
/// A first-class setting because the 2026-08-03 field report needed one: the default
|
||||
/// client-only routing sent that box's whole desktop mix through Steam's voice-carrier virtual
|
||||
/// endpoint for 25 sessions, and the only way to change it was an undocumented environment
|
||||
/// variable. See [`AudioOutputMode`].
|
||||
pub audio_output_mode: AudioOutputMode,
|
||||
/// `PUNKTFUNK_AUDIO_QUALITY` — desktop-audio encode tier (`low` / `standard` / `high`; default
|
||||
/// `high`). Kept as the raw string here because the tier table lives in `punktfunk-core`, and
|
||||
/// this crate is deliberately dependency-free (see the crate doc). The audio thread resolves it
|
||||
/// via `punktfunk_core::audio::AudioTier::parse` and warns on an unknown spelling rather than
|
||||
/// silently downgrading someone's audio.
|
||||
pub audio_quality: Option<String>,
|
||||
/// `PUNKTFUNK_AUDIO_REDUNDANCY` — force the redundant `0xD2` audio plane on or off. `None`
|
||||
/// (the default) = automatic: sent only to a client that asked for it, and only while the link
|
||||
/// is actually losing packets.
|
||||
pub audio_redundancy: Option<bool>,
|
||||
/// `PUNKTFUNK_PERF` — per-stage timing instrumentation.
|
||||
pub perf: bool,
|
||||
/// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select. `virtual` (the default — a
|
||||
@@ -246,6 +340,9 @@ impl HostConfig {
|
||||
// Default ON, explicit-off grammar (the client's VIDEO_CAP_CHACHA20 bit is the real
|
||||
// per-session switch; see the field doc).
|
||||
chacha20: env_on("PUNKTFUNK_CHACHA20").unwrap_or(true),
|
||||
audio_output_mode: AudioOutputMode::from_env(),
|
||||
audio_quality: val("PUNKTFUNK_AUDIO_QUALITY").map(|s| s.trim().to_lowercase()),
|
||||
audio_redundancy: env_on("PUNKTFUNK_AUDIO_REDUNDANCY"),
|
||||
perf: flag("PUNKTFUNK_PERF"),
|
||||
// Default ON while the interval-stutter field program runs (see the field doc).
|
||||
stall_probes: env_on("PUNKTFUNK_STALL_PROBES").unwrap_or(true),
|
||||
@@ -348,4 +445,50 @@ mod tests {
|
||||
// An invalid rate stays invalid rather than being laundered into a real one.
|
||||
assert_eq!(c.game_fps(0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_output_mode_parses_its_spellings() {
|
||||
for (s, want) in [
|
||||
("client_only", AudioOutputMode::ClientOnly),
|
||||
("client-only", AudioOutputMode::ClientOnly),
|
||||
(" CLIENT ", AudioOutputMode::ClientOnly),
|
||||
("host_and_client", AudioOutputMode::HostAndClient),
|
||||
("both", AudioOutputMode::HostAndClient),
|
||||
("follow_default", AudioOutputMode::FollowDefault),
|
||||
("follow", AudioOutputMode::FollowDefault),
|
||||
] {
|
||||
assert_eq!(AudioOutputMode::parse(s), Some(want), "{s:?}");
|
||||
}
|
||||
// Unknown spellings are rejected so the caller can say so, not silently re-routed.
|
||||
for s in ["", "silent", "off", "true"] {
|
||||
assert_eq!(AudioOutputMode::parse(s), None, "{s:?}");
|
||||
}
|
||||
// Round-trip through the canonical spelling.
|
||||
for m in [
|
||||
AudioOutputMode::ClientOnly,
|
||||
AudioOutputMode::HostAndClient,
|
||||
AudioOutputMode::FollowDefault,
|
||||
] {
|
||||
assert_eq!(AudioOutputMode::parse(m.as_str()), Some(m));
|
||||
}
|
||||
}
|
||||
|
||||
/// The two predicates are what the wiring plan and the capture loop actually branch on, and
|
||||
/// they must stay mutually exclusive: "prefer host hardware" and "touch nothing" are different
|
||||
/// promises, and conflating them would either silence the host or stomp the operator's devices.
|
||||
#[test]
|
||||
fn audio_output_mode_predicates_are_disjoint() {
|
||||
assert_eq!(AudioOutputMode::default(), AudioOutputMode::ClientOnly);
|
||||
for m in [
|
||||
AudioOutputMode::ClientOnly,
|
||||
AudioOutputMode::HostAndClient,
|
||||
AudioOutputMode::FollowDefault,
|
||||
] {
|
||||
assert!(!(m.prefers_host_hardware() && m.keeps_default()), "{m:?}");
|
||||
}
|
||||
assert!(AudioOutputMode::HostAndClient.prefers_host_hardware());
|
||||
assert!(AudioOutputMode::FollowDefault.keeps_default());
|
||||
assert!(!AudioOutputMode::ClientOnly.prefers_host_hardware());
|
||||
assert!(!AudioOutputMode::ClientOnly.keeps_default());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -819,77 +819,46 @@ impl DriverAttach {
|
||||
|
||||
/// One-shot WARN with everything the host can find out about WHY the driver isn't attached:
|
||||
/// driver-store presence, the devnode's PnP status/problem code, and where to look next.
|
||||
///
|
||||
/// Runs on its own thread and returns immediately. The caller is the session's pad service
|
||||
/// thread — the one feeding input and rumble — and everything below is slow: the driver-store
|
||||
/// check waits up to [`INVENTORY_WAIT`] for a `pnputil` enumeration that can take tens of
|
||||
/// seconds, and the devnode lookup is a synchronous PnP call. Blocking there stalled input for
|
||||
/// up to two seconds *per unattached pad* (the wait is a deadline, not a one-off: while the
|
||||
/// enumeration is still outstanding every pad pays it again), at exactly the moment a session
|
||||
/// is already going wrong. Diagnostics must never be able to hurt the thing they diagnose.
|
||||
///
|
||||
/// Off the hot path the wait also stops being a compromise — it can afford to be patient and
|
||||
/// report what it actually found rather than "still enumerating".
|
||||
fn diagnose(&self) {
|
||||
let (driver, inf, driver_log) = (self.driver, self.inf, self.driver_log);
|
||||
let shm_name = self.shm_name.clone();
|
||||
let instance_id = self.instance_id.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pf-driver-diagnose".into())
|
||||
.spawn(move || diagnose_blocking(driver, inf, driver_log, &shm_name, instance_id))
|
||||
.ok();
|
||||
let store = match driver_store_has(self.inf) {
|
||||
Some(true) => "driver package present in the driver store",
|
||||
Some(false) => {
|
||||
"driver package NOT in the driver store — run: punktfunk-host.exe driver install --gamepad"
|
||||
}
|
||||
None => "driver store could not be queried (pnputil failed or still enumerating)",
|
||||
};
|
||||
let devnode = match &self.instance_id {
|
||||
Some(id) => devnode_status_line(id),
|
||||
None => {
|
||||
"no per-session devnode (SwDeviceCreate failed earlier — see the warning above)"
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
tracing::warn!(
|
||||
driver = self.driver,
|
||||
shm = %self.shm_name,
|
||||
grace_secs = ATTACH_GRACE.as_secs(),
|
||||
store,
|
||||
devnode = %devnode,
|
||||
driver_log = self.driver_log,
|
||||
"gamepad driver has not attached to the shared section — the virtual pad exists but no \
|
||||
driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \
|
||||
reads as not-attached: update with punktfunk-host.exe driver install --gamepad \
|
||||
(driver_log is only written by debug driver builds, or with the PFXUSB_DEBUG_LOG / \
|
||||
PFGAMEPAD_DEBUG_LOG / PFMOUSE_DEBUG_LOG system env var set + the device restarted)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The body of [`DriverAttach::diagnose`], on its own thread. Split out rather than inlined into
|
||||
/// the closure so the blocking calls stay visible as blocking.
|
||||
fn diagnose_blocking(
|
||||
driver: &'static str,
|
||||
inf: &'static str,
|
||||
driver_log: &'static str,
|
||||
shm_name: &str,
|
||||
instance_id: Option<String>,
|
||||
) {
|
||||
let store = match driver_store_has(inf) {
|
||||
Some(true) => "driver package present in the driver store",
|
||||
Some(false) => {
|
||||
"driver package NOT in the driver store — run: punktfunk-host.exe driver install --gamepad"
|
||||
}
|
||||
None => "driver store could not be queried (pnputil failed or still enumerating)",
|
||||
};
|
||||
let devnode = match &instance_id {
|
||||
Some(id) => devnode_status_line(id),
|
||||
None => "no per-session devnode (SwDeviceCreate failed earlier — see the warning above)"
|
||||
.to_string(),
|
||||
};
|
||||
tracing::warn!(
|
||||
driver,
|
||||
shm = %shm_name,
|
||||
grace_secs = ATTACH_GRACE.as_secs(),
|
||||
store,
|
||||
devnode = %devnode,
|
||||
driver_log,
|
||||
"gamepad driver has not attached to the shared section — the virtual pad exists but no \
|
||||
driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \
|
||||
reads as not-attached: update with punktfunk-host.exe driver install --gamepad \
|
||||
(driver_log is only written by debug driver builds, or with the PFXUSB_DEBUG_LOG / \
|
||||
PFGAMEPAD_DEBUG_LOG / PFMOUSE_DEBUG_LOG system env var set + the device restarted)"
|
||||
);
|
||||
}
|
||||
|
||||
/// How long [`driver_store_inventory`] waits for the background pnputil query before reporting
|
||||
/// without it. Only [`diagnose_blocking`] waits, and that has a thread to itself, so this is
|
||||
/// generous: pnputil routinely takes longer than a couple of seconds on a busy driver store, and
|
||||
/// the old two-second budget — chosen to limit the damage while this ran on the pad service thread
|
||||
/// — meant the diagnosis usually gave up and printed "still enumerating", which is the one answer
|
||||
/// that helps nobody. Nothing waits on this thread, so patience costs only a late log line.
|
||||
const INVENTORY_WAIT: Duration = Duration::from_secs(30);
|
||||
/// How long [`driver_store_inventory`] lets the caller wait for the background pnputil query
|
||||
/// before reporting without it — [`observe`] runs on the pad service thread, which must keep
|
||||
/// draining pad slots even when the driver store is wedged.
|
||||
const INVENTORY_WAIT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Driver-store inventory (`pnputil /enum-drivers`), lower-cased, fetched once per process — only
|
||||
/// consulted on the failure path, so the subprocess cost never hits a healthy session. The query
|
||||
/// runs on its OWN thread: pnputil can block for tens of seconds on a busy/wedged driver store,
|
||||
/// and this keeps one wedged query from being re-run per pad. `None` = not available yet (query
|
||||
/// still running past [`INVENTORY_WAIT`]) or
|
||||
/// and the caller is the pad service thread. `None` = not available yet (query still running) or
|
||||
/// failed; a query that outlives [`INVENTORY_WAIT`] still lands in the cache for later reports.
|
||||
fn driver_store_inventory() -> Option<&'static str> {
|
||||
static INV: OnceLock<String> = OnceLock::new();
|
||||
|
||||
@@ -84,6 +84,11 @@ pub enum OverlayAction {
|
||||
fp_hex: String,
|
||||
launch: Option<String>,
|
||||
title: String,
|
||||
/// One-off settings-profile override for THIS launch (a profile id — a pinned
|
||||
/// card's connect). `None` resolves the host's default binding as before; the
|
||||
/// binary feeds it to `trust::effective_settings`, so a dangling id quietly
|
||||
/// falls back to the defaults and never blocks the connect.
|
||||
profile: Option<String>,
|
||||
/// The no-PIN delegated-approval path: pin the host's advertised fingerprint and
|
||||
/// open a connect the host PARKS until the operator approves this device in its
|
||||
/// console (a long connect budget), then persist it as paired. `false` = an
|
||||
|
||||
@@ -48,6 +48,13 @@ exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"]
|
||||
"AXIS_RT" = "PUNKTFUNK_AXIS_RT"
|
||||
"AUDIO_MAGIC" = "PUNKTFUNK_AUDIO_MAGIC"
|
||||
"RUMBLE_MAGIC" = "PUNKTFUNK_RUMBLE_MAGIC"
|
||||
"AUDIO_RED_MAGIC" = "PUNKTFUNK_AUDIO_RED_MAGIC"
|
||||
"AUDIO_RED_HEADER" = "PUNKTFUNK_AUDIO_RED_HEADER"
|
||||
# Same hazard as the BTN_* block above, one step worse: `FRAME_MS` and `SAMPLE_RATE_HZ` are
|
||||
# generic enough that an embedder is likely to have its own, and a clashing #define silently
|
||||
# takes the last definition rather than failing to compile.
|
||||
"FRAME_MS" = "PUNKTFUNK_AUDIO_FRAME_MS"
|
||||
"SAMPLE_RATE_HZ" = "PUNKTFUNK_AUDIO_SAMPLE_RATE_HZ"
|
||||
|
||||
# QualifiedScreamingSnakeCase already qualifies each variant with the enum name
|
||||
# (PunktfunkStatus::Ok -> PUNKTFUNK_STATUS_OK); do NOT also set prefix_with_name or it doubles.
|
||||
|
||||
+536
-155
@@ -150,11 +150,14 @@ const ENCODE_SEVERE_US: i64 = 12_000;
|
||||
/// the same reason: the decoder's knee moves with content and thermals.
|
||||
const CAP_REPROBE_WINDOWS_MIN: u32 = 16;
|
||||
const CAP_REPROBE_WINDOWS_MAX: u32 = 128;
|
||||
/// Two consecutive decode-driven backoffs latch the
|
||||
/// Two decode-driven backoffs latch the
|
||||
/// [`decode cap`](BitrateController::decode_cap_kbps) only when their pre-backoff rates agree
|
||||
/// within ±1/8: the decoder's knee is a RATE, so repeated chokes at the same rate are its
|
||||
/// signature — two unrelated events (a Wi-Fi flush at 300 Mbps, a decode spike at 500) share
|
||||
/// no knee and must not teach one.
|
||||
/// no knee and must not teach one. Each sample must come from a rate the controller CLIMBED
|
||||
/// back to (`climb_since_backoff`) — the knee's real signature is choke, recover, re-climb,
|
||||
/// choke again at the same place, and only backoffs at a climbed-to rate can agree within the
|
||||
/// band (a cascade's second backoff sits at ×0.7 of the first: outside it by construction).
|
||||
const DECODE_CAP_SIMILAR_DIV: u32 = 8;
|
||||
/// Rolling window (in 750 ms report windows, ~30 s) whose minimum mean is the OWD baseline.
|
||||
/// Long enough to remember the uncongested floor, short enough to follow genuine path changes.
|
||||
@@ -286,6 +289,20 @@ pub(crate) struct BitrateController {
|
||||
/// decode-driven): the reference the next one must land near ([`DECODE_CAP_SIMILAR_DIV`])
|
||||
/// to latch the cap — one spurious flush teaches nothing.
|
||||
decode_backoff_kbps: u32,
|
||||
/// Decode-flagged windows in the CURRENT bad-window streak. The ordinary two-window backoff
|
||||
/// path is the decoder knee's most common presentation (a standing 15–45 ms decode rise —
|
||||
/// deep enough to hurt, not deep enough for the severe tier), and judging decode evidence
|
||||
/// from the FINAL window alone threw that attribution away: the backoff the decode signal
|
||||
/// itself caused then RESET the knee streak. Counted per bad window, cleared with the streak.
|
||||
streak_decode_windows: u32,
|
||||
/// Whether `current_kbps` has RISEN (via an ack — ours or a host-initiated re-target) since
|
||||
/// the last backoff. A knee sample is only meaningful for a rate the controller climbed to
|
||||
/// or held; a backoff that fires while the previous backoff's damage is still draining
|
||||
/// samples a rate the decoder never choked at (the host acks a ×0.7 request in ~100 ms, so
|
||||
/// a cascade's second backoff ALWAYS sits at the already-reduced rate — dissimilar to the
|
||||
/// knee by construction, 0.7 < 7/8). Such a backoff neither samples nor erases the
|
||||
/// reference.
|
||||
climb_since_backoff: bool,
|
||||
/// Clean windows spent parked at the learned decode cap (its re-probe clock), and that
|
||||
/// clock's own backoff interval — same schedule as the host cap's.
|
||||
decode_cap_probe_windows: u32,
|
||||
@@ -341,6 +358,10 @@ impl BitrateController {
|
||||
cap_reprobe_after: CAP_REPROBE_WINDOWS_MIN,
|
||||
decode_cap_kbps: None,
|
||||
decode_backoff_kbps: 0,
|
||||
streak_decode_windows: 0,
|
||||
// The negotiated start rate was held, not drained to — the first backoff ever is a
|
||||
// legitimate knee sample.
|
||||
climb_since_backoff: true,
|
||||
decode_cap_probe_windows: 0,
|
||||
decode_cap_reprobe_after: CAP_REPROBE_WINDOWS_MIN,
|
||||
proven_kbps: 0,
|
||||
@@ -433,6 +454,13 @@ impl BitrateController {
|
||||
}
|
||||
}
|
||||
}
|
||||
if kbps > self.current_kbps {
|
||||
// The rate ROSE — whatever the pipeline chokes on next, it will choke at a rate
|
||||
// it was driven up to: a fresh knee sample (see `climb_since_backoff`). An ack'd
|
||||
// decrease deliberately does not arm this — the drain after a backoff is not a
|
||||
// knee encounter.
|
||||
self.climb_since_backoff = true;
|
||||
}
|
||||
self.current_kbps = kbps;
|
||||
// The host may run ABOVE our climb ceiling, and be right to: it sends an unsolicited
|
||||
// `BitrateChanged` when a rebuild re-resolves an Automatic rate for what it actually
|
||||
@@ -472,6 +500,8 @@ impl BitrateController {
|
||||
self.cap_reprobe_after = CAP_REPROBE_WINDOWS_MIN;
|
||||
self.decode_cap_kbps = None;
|
||||
self.decode_backoff_kbps = 0;
|
||||
self.streak_decode_windows = 0;
|
||||
self.climb_since_backoff = true;
|
||||
self.decode_cap_probe_windows = 0;
|
||||
self.owd_means.clear();
|
||||
self.decode_means.clear();
|
||||
@@ -571,12 +601,20 @@ impl BitrateController {
|
||||
}
|
||||
if bad {
|
||||
self.bad_windows += 1;
|
||||
if decode_bad {
|
||||
// Per-window decode attribution for the streak (see `streak_decode_windows`) —
|
||||
// scored HERE because at backoff time only the final window's signals are in
|
||||
// scope, and on the two-window path the first bad window never even reaches a
|
||||
// decision (the cooldown eats it).
|
||||
self.streak_decode_windows += 1;
|
||||
}
|
||||
self.clean_windows = 0;
|
||||
// Any congestion signal ends slow start for good — from here on, climbs are additive.
|
||||
self.probing = false;
|
||||
} else {
|
||||
self.clean_windows += 1;
|
||||
self.bad_windows = 0;
|
||||
self.streak_decode_windows = 0;
|
||||
}
|
||||
// The learned host cap re-probe (see [`CAP_REPROBE_WINDOWS_MIN`]): after a clean run
|
||||
// parked at the cap, lift it one step (+12.5 %, ceiling-bounded) so a scene-dependent
|
||||
@@ -635,21 +673,42 @@ impl BitrateController {
|
||||
&& self.current_kbps > self.floor_kbps
|
||||
{
|
||||
// Decode-cap learning (see [`decode_cap_kbps`](Self::decode_cap_kbps)): a backoff
|
||||
// with decode-severe evidence — the deep decode excursion, or the flush that
|
||||
// drained the queue behind a stalled decoder — remembers its pre-backoff rate; the
|
||||
// SECOND consecutive one at a similar rate latches that rate as the decoder's
|
||||
// knee. One event never latches (a spurious flush must stay a one-off), and a
|
||||
// backoff without decode evidence in between breaks the streak — whatever it saw,
|
||||
// it wasn't the same knee.
|
||||
// A bare flush counts as decode evidence only where the decode signal can't speak
|
||||
// for itself. On an embedder that reports decode latency, a flush with FLAT decode
|
||||
// is a network event (a stall, a clock step) that drained a queue the decoder was
|
||||
// keeping up with — teaching a "decoder knee" from it caps the session on the wrong
|
||||
// end of the pipe. Where the signal is absent the old reading stands: the flush is
|
||||
// the only decoder-saturation evidence there is.
|
||||
let decode_evidence =
|
||||
decode_severe || (flushed && (decode_bad || decode_mean_us.is_none()));
|
||||
if decode_evidence {
|
||||
// with decode evidence remembers its pre-backoff rate; the next one at a similar
|
||||
// rate latches that rate as the decoder's knee. One event never latches (a spurious
|
||||
// flush must stay a one-off), and a decode-free backoff in between breaks the
|
||||
// streak — whatever it saw, it wasn't the same knee.
|
||||
//
|
||||
// Decode evidence, in order:
|
||||
// - a decode-SEVERE excursion in the deciding window;
|
||||
// - the ordinary two-window path where EVERY bad window was decode-flagged
|
||||
// (`streak_decode_windows`) — the knee's most common presentation is a standing
|
||||
// 15–45 ms rise, below the severe tier, and the deciding window alone can't see
|
||||
// that the streak it ends was decode's doing;
|
||||
// - a keyframe-ask storm without meaningful loss: a decoder begging for fresh
|
||||
// pictures on a clean link is being overdriven, whatever its latency figure says
|
||||
// (some decoders wedge rather than queue — the Steam Deck presentation). With
|
||||
// real loss present the asks are network-attributed and teach nothing here;
|
||||
// - a flush, where the decode signal can't speak against it: on an embedder that
|
||||
// reports decode latency, a flush with FLAT decode is a network event (a stall, a
|
||||
// clock step) that drained a queue the decoder was keeping up with — teaching a
|
||||
// "decoder knee" from it caps the session on the wrong end of the pipe. Where the
|
||||
// signal is absent the flush is the only decoder-saturation evidence there is.
|
||||
let decode_evidence = decode_severe
|
||||
|| self.streak_decode_windows >= BAD_WINDOWS_TO_DECREASE
|
||||
|| (recovery_kf >= RECOVERY_KF_BAD && loss_ppm < HEAVY_LOSS_PPM)
|
||||
|| (flushed && (decode_bad || decode_mean_us.is_none()));
|
||||
if !self.climb_since_backoff {
|
||||
// Still draining the previous backoff: the host acks a ×0.7 request in ~100 ms,
|
||||
// so this window's rate is one the decoder never choked at while keeping up —
|
||||
// its distress is residue of the choke above. Not a knee sample either way:
|
||||
// neither latch against it nor let it erase the reference the real knee set.
|
||||
tracing::debug!(
|
||||
at_kbps = self.current_kbps,
|
||||
reference_kbps = self.decode_backoff_kbps,
|
||||
"adaptive bitrate: backoff without an intervening climb — draining the \
|
||||
previous choke, not a knee sample"
|
||||
);
|
||||
} else if decode_evidence {
|
||||
let rate = self.current_kbps;
|
||||
let similar = self.decode_backoff_kbps > 0
|
||||
&& rate.abs_diff(self.decode_backoff_kbps)
|
||||
@@ -683,8 +742,10 @@ impl BitrateController {
|
||||
} else {
|
||||
self.decode_backoff_kbps = 0;
|
||||
}
|
||||
self.climb_since_backoff = false;
|
||||
let next = ((self.current_kbps as u64 * 7 / 10) as u32).max(self.floor_kbps);
|
||||
self.bad_windows = 0;
|
||||
self.streak_decode_windows = 0;
|
||||
return self.request(next, now);
|
||||
}
|
||||
// Climbs only fire off a UTILIZED clean window (actual delivered ≥ ¾ of the target — the
|
||||
@@ -1945,71 +2006,100 @@ mod tests {
|
||||
assert_eq!(run_clean(&mut c, start, 24, 20), None);
|
||||
}
|
||||
|
||||
fn calm_window(c: &mut BitrateController, at: Instant) {
|
||||
// One calm, unutilized window (2 Mb/s actual): seeds the latency baselines without
|
||||
// authorizing climbs, and must decide nothing.
|
||||
assert_eq!(
|
||||
c.on_window(at, 0, 0, Some(10_000), Some(8_000), None, 2_000, false, 0),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// Drive clean, fully-utilized windows (1 Gb/s actual), acking every climb the controller
|
||||
/// asks for — a live host answers in ~100 ms — until `current_kbps` reaches `target`.
|
||||
/// Bounded so a climb-path regression fails loudly instead of spinning.
|
||||
fn climb_to(c: &mut BitrateController, start: Instant, tick: &mut u32, target: u32) {
|
||||
for _ in 0..600 {
|
||||
if c.current_kbps >= target {
|
||||
return;
|
||||
}
|
||||
if let Some(k) = c.on_window(
|
||||
ticks(start, *tick),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false,
|
||||
0,
|
||||
) {
|
||||
c.on_ack(k);
|
||||
}
|
||||
*tick += 1;
|
||||
}
|
||||
panic!(
|
||||
"no climb to {target} within 600 windows (stuck at {})",
|
||||
c.current_kbps
|
||||
);
|
||||
}
|
||||
|
||||
/// One decode-SEVERE window (60 ms against the ~8 ms baseline) at the current rate — a
|
||||
/// knee choke. Steps past the change cooldown first so the decision can fire.
|
||||
fn choke(c: &mut BitrateController, start: Instant, tick: &mut u32) -> Option<u32> {
|
||||
*tick += 2;
|
||||
let r = c.on_window(
|
||||
ticks(start, *tick),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(60_000),
|
||||
None,
|
||||
c.current_kbps,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
*tick += 1;
|
||||
r
|
||||
}
|
||||
|
||||
/// The latch's only production-reachable shape: choke at the knee, the host ACKS the ×0.7
|
||||
/// (a live host answers in ~100 ms, so a cascade's second backoff always sits at the
|
||||
/// already-reduced rate — dissimilar by construction), the controller climbs back, and the
|
||||
/// re-climb chokes inside the ±1/8 band. Latches, acks the backoff, returns the cap.
|
||||
fn latch_knee(c: &mut BitrateController, start: Instant, tick: &mut u32) -> u32 {
|
||||
for _ in 0..4 {
|
||||
calm_window(c, ticks(start, *tick));
|
||||
*tick += 1;
|
||||
}
|
||||
let knee = c.current_kbps;
|
||||
let r1 = choke(c, start, tick).expect("first choke must back off");
|
||||
assert!(c.decode_cap_kbps.is_none(), "one event must not latch");
|
||||
c.on_ack(r1);
|
||||
climb_to(c, start, tick, knee - knee / DECODE_CAP_SIMILAR_DIV);
|
||||
let rate = c.current_kbps;
|
||||
let r2 = choke(c, start, tick).expect("re-climb choke must back off");
|
||||
assert_eq!(c.decode_cap_kbps, Some(rate - rate / 16));
|
||||
c.on_ack(r2);
|
||||
rate - rate / 16
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_cap_latches_after_two_consecutive_decode_severe_backoffs() {
|
||||
fn decode_cap_latches_when_the_reclimb_chokes_at_the_same_knee() {
|
||||
// The 1440p120 field sawtooth: a decoder knee (~500 Mbps) well under the (inflated)
|
||||
// link ceiling — nothing ever LEARNED the knee, so every re-climb ended in a flush +
|
||||
// dropped-frame burst. Establish a decode baseline on calm windows, choke twice at the
|
||||
// same rate, and the second decode-severe backoff must latch the knee.
|
||||
// dropped-frame burst. Choke, recover, climb back, choke again inside the band: latch.
|
||||
let mut c = BitrateController::new(500_000);
|
||||
c.set_ceiling(900_000);
|
||||
let start = Instant::now();
|
||||
// Calm baseline windows (2 Mb/s actual: unutilized, so no climb interferes).
|
||||
for i in 0..4 {
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
2_000,
|
||||
false,
|
||||
0
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
// First deep decode excursion → immediate ×0.7, but ONE event must not latch.
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, 4),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(60_000),
|
||||
None,
|
||||
490_000,
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(350_000)
|
||||
);
|
||||
assert!(c.decode_cap_kbps.is_none());
|
||||
// Second consecutive decode-severe backoff at the same pre-backoff rate: latch.
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, 6),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(60_000),
|
||||
None,
|
||||
490_000,
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(350_000)
|
||||
);
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000 - 500_000 / 16));
|
||||
// The backoff applies; from here every climb must stop AT the knee — not the 900 Mbps
|
||||
let mut t = 0;
|
||||
latch_knee(&mut c, start, &mut t);
|
||||
// The latch applies; from here every climb must stop AT the knee — not the 900 Mbps
|
||||
// link ceiling the old sawtooth kept re-poking.
|
||||
c.on_ack(350_000);
|
||||
let mut max_req = 0;
|
||||
for i in 8..70 {
|
||||
for _ in 0..62 {
|
||||
if let Some(k) = c.on_window(
|
||||
ticks(start, i),
|
||||
ticks(start, t),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
@@ -2030,6 +2120,7 @@ mod tests {
|
||||
max_req = max_req.max(k);
|
||||
c.on_ack(k);
|
||||
}
|
||||
t += 1;
|
||||
}
|
||||
assert!(
|
||||
max_req < 600_000,
|
||||
@@ -2039,37 +2130,82 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_single_flush_or_dissimilar_backoffs_never_latch_a_decode_cap() {
|
||||
// The latch's false-positive guards. A lone jump-to-live flush (a Wi-Fi clump can
|
||||
// flush once at ANY rate) backs off but teaches nothing…
|
||||
// The latch's false-positive guards, every event at a rate the controller climbed to
|
||||
// or held (drain-time backoffs are no sample at all —
|
||||
// `cascade_backoffs_neither_sample_nor_erase_the_knee_reference` owns those). A lone
|
||||
// jump-to-live flush (a Wi-Fi clump can flush once at ANY rate) backs off but teaches
|
||||
// nothing…
|
||||
let mut c = BitrateController::new(500_000);
|
||||
c.set_ceiling(900_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 0, 0, None, None, None, 490_000, true, 0),
|
||||
Some(350_000)
|
||||
);
|
||||
let mut t = 0;
|
||||
let r1 = c
|
||||
.on_window(ticks(start, t), 0, 0, None, None, None, 490_000, true, 0)
|
||||
.expect("flush must back off");
|
||||
assert_eq!(r1, 350_000);
|
||||
assert!(c.decode_cap_kbps.is_none());
|
||||
c.on_ack(350_000);
|
||||
// …a LOSS-driven backoff in between breaks the streak…
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 2), 1, 0, None, None, None, 340_000, false, 0),
|
||||
Some(245_000)
|
||||
);
|
||||
c.on_ack(r1);
|
||||
// …a LOSS-driven backoff at the re-climbed rate breaks the streak (whatever choked
|
||||
// there, it wasn't the decoder — even inside the similarity band)…
|
||||
climb_to(&mut c, start, &mut t, 460_000);
|
||||
t += 2;
|
||||
let r2 = c
|
||||
.on_window(
|
||||
ticks(start, t),
|
||||
1,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
c.current_kbps,
|
||||
false,
|
||||
0,
|
||||
)
|
||||
.expect("loss must back off");
|
||||
t += 1;
|
||||
assert!(c.decode_cap_kbps.is_none());
|
||||
c.on_ack(245_000);
|
||||
assert_eq!(
|
||||
c.decode_backoff_kbps, 0,
|
||||
"a climbed-to non-decode backoff must reset the knee reference"
|
||||
);
|
||||
c.on_ack(r2);
|
||||
// …so the next flush counts as a FIRST decode event again — still no latch…
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 4), 0, 0, None, None, None, 240_000, true, 0),
|
||||
Some(171_500)
|
||||
);
|
||||
climb_to(&mut c, start, &mut t, 460_000);
|
||||
t += 2;
|
||||
let r3 = c
|
||||
.on_window(
|
||||
ticks(start, t),
|
||||
0,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
c.current_kbps,
|
||||
true,
|
||||
0,
|
||||
)
|
||||
.expect("flush must back off");
|
||||
t += 1;
|
||||
assert!(c.decode_cap_kbps.is_none());
|
||||
c.on_ack(171_500);
|
||||
// …and two consecutive decode events at DISSIMILAR rates (245 vs 171.5 Mbps — no
|
||||
c.on_ack(r3);
|
||||
// …and two decode events at DISSIMILAR climbed-to rates (~460 vs ~350 Mbps — no
|
||||
// common knee) must not latch either.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 6), 0, 0, None, None, None, 170_000, true, 0),
|
||||
Some(120_050)
|
||||
);
|
||||
let dissimilar_target = c.current_kbps + 20_000;
|
||||
climb_to(&mut c, start, &mut t, dissimilar_target);
|
||||
t += 2;
|
||||
let _ = c
|
||||
.on_window(
|
||||
ticks(start, t),
|
||||
0,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
c.current_kbps,
|
||||
true,
|
||||
0,
|
||||
)
|
||||
.expect("flush must back off");
|
||||
assert!(c.decode_cap_kbps.is_none());
|
||||
}
|
||||
|
||||
@@ -2082,38 +2218,14 @@ mod tests {
|
||||
let mut c = BitrateController::new(500_000);
|
||||
c.set_ceiling(900_000);
|
||||
let start = Instant::now();
|
||||
for i in 0..4 {
|
||||
let mut t = 0;
|
||||
let knee = latch_knee(&mut c, start, &mut t);
|
||||
// The host parks the session at the knee (an unsolicited re-target up to it — its
|
||||
// clamp is authoritative).
|
||||
c.on_ack(knee);
|
||||
for _ in 0..CAP_REPROBE_WINDOWS_MIN {
|
||||
let _ = c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
2_000,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
}
|
||||
for i in [4, 6] {
|
||||
let _ = c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(60_000),
|
||||
None,
|
||||
490_000,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
}
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000 - 500_000 / 16));
|
||||
// The host's ack parks the session at the knee (its clamp is authoritative).
|
||||
c.on_ack(500_000 - 500_000 / 16);
|
||||
for i in 0..CAP_REPROBE_WINDOWS_MIN {
|
||||
let _ = c.on_window(
|
||||
ticks(start, 8 + i),
|
||||
ticks(start, t),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
@@ -2123,8 +2235,8 @@ mod tests {
|
||||
false,
|
||||
0,
|
||||
);
|
||||
t += 1;
|
||||
}
|
||||
let knee = 500_000 - 500_000 / 16;
|
||||
assert_eq!(c.decode_cap_kbps, Some(knee + knee / 8));
|
||||
}
|
||||
|
||||
@@ -2135,38 +2247,307 @@ mod tests {
|
||||
let mut c = BitrateController::new(500_000);
|
||||
c.set_ceiling(900_000);
|
||||
let start = Instant::now();
|
||||
for i in 0..4 {
|
||||
let _ = c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
2_000,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
}
|
||||
for i in [4, 6] {
|
||||
let _ = c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(60_000),
|
||||
None,
|
||||
490_000,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
}
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000 - 500_000 / 16));
|
||||
let mut t = 0;
|
||||
let _ = latch_knee(&mut c, start, &mut t);
|
||||
c.on_mode_switch();
|
||||
assert!(c.decode_cap_kbps.is_none());
|
||||
assert_eq!(c.ceiling_kbps, 900_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_decode_bad_window_pairs_latch_the_knee_field_trace() {
|
||||
// The 2026-08-03 780M field trace, numbers from the log. The knee's most common
|
||||
// presentation is a standing ~26 ms decode rise — deep enough for the ordinary
|
||||
// two-window backoff, below the 45 ms severe tier. Judging evidence from the deciding
|
||||
// window alone read those backoffs as decode-free and RESET the knee streak each
|
||||
// time; the session sawtoothed 220↔450 Mbps for its remaining minutes.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
c.set_ceiling(657_788); // the log's probe ceiling
|
||||
let start = Instant::now();
|
||||
let mut t = 0;
|
||||
for _ in 0..4 {
|
||||
calm_window(&mut c, ticks(start, t));
|
||||
t += 1;
|
||||
}
|
||||
// A single heavy-loss window ends slow start (as the field session's startup hitch
|
||||
// did) so the climb below is the additive one the trace shows.
|
||||
let _ = c.on_window(
|
||||
ticks(start, t),
|
||||
0,
|
||||
HEAVY_LOSS_PPM,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
15_000,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
t += 1;
|
||||
// Choke #1 (00:35:56Z): flush + 40 ms decode at ~417 Mbps — evidence, first sample.
|
||||
climb_to(&mut c, start, &mut t, 417_277);
|
||||
let first = c.current_kbps;
|
||||
t += 2;
|
||||
let r1 = c
|
||||
.on_window(
|
||||
ticks(start, t),
|
||||
0,
|
||||
0,
|
||||
Some(8_313),
|
||||
Some(40_087),
|
||||
None,
|
||||
first,
|
||||
true,
|
||||
1,
|
||||
)
|
||||
.expect("flush choke must back off");
|
||||
t += 1;
|
||||
assert!(c.decode_cap_kbps.is_none());
|
||||
assert_eq!(c.decode_backoff_kbps, first);
|
||||
c.on_ack(r1);
|
||||
// Choke #2 (00:36:32Z): TWO consecutive ~26 ms decode-bad windows at ~446 Mbps — the
|
||||
// ordinary two-window path, no flush, nothing severe. This is the backoff the old
|
||||
// evidence gate threw away.
|
||||
climb_to(&mut c, start, &mut t, 440_000);
|
||||
let second = c.current_kbps;
|
||||
t += 2;
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, t),
|
||||
0,
|
||||
0,
|
||||
Some(6_877),
|
||||
Some(26_474),
|
||||
None,
|
||||
second,
|
||||
false,
|
||||
0
|
||||
),
|
||||
None,
|
||||
"the first bad window must not decide"
|
||||
);
|
||||
t += 1;
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, t),
|
||||
0,
|
||||
0,
|
||||
Some(6_877),
|
||||
Some(26_474),
|
||||
None,
|
||||
second,
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(((second as u64 * 7 / 10) as u32).max(FLOOR_KBPS))
|
||||
);
|
||||
assert_eq!(
|
||||
c.decode_cap_kbps,
|
||||
Some(second - second / 16),
|
||||
"two decode-bad windows are knee evidence"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_backoffs_neither_sample_nor_erase_the_knee_reference() {
|
||||
// Choke at the knee (reference set), the host acks the ×0.7 within ~100 ms, and the
|
||||
// drain flushes → a second backoff fires at the REDUCED rate. That rate is one the
|
||||
// decoder never choked at while keeping up — the old code overwrote the reference
|
||||
// with it (and could never latch from a cascade at all: ×0.7 sits outside the ±1/8
|
||||
// band by construction). A drain backoff must neither latch nor erase; the eventual
|
||||
// re-climb's choke latches against the ORIGINAL sample.
|
||||
let mut c = BitrateController::new(500_000);
|
||||
c.set_ceiling(900_000);
|
||||
let start = Instant::now();
|
||||
let mut t = 0;
|
||||
for _ in 0..4 {
|
||||
calm_window(&mut c, ticks(start, t));
|
||||
t += 1;
|
||||
}
|
||||
let r1 = choke(&mut c, start, &mut t).expect("knee choke must back off");
|
||||
assert_eq!(c.decode_backoff_kbps, 500_000);
|
||||
c.on_ack(r1);
|
||||
t += 2;
|
||||
let r2 = c
|
||||
.on_window(
|
||||
ticks(start, t),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(43_305),
|
||||
None,
|
||||
r1,
|
||||
true,
|
||||
1,
|
||||
)
|
||||
.expect("drain flush must back off");
|
||||
t += 1;
|
||||
assert!(
|
||||
c.decode_cap_kbps.is_none(),
|
||||
"a drain backoff must not latch"
|
||||
);
|
||||
assert_eq!(
|
||||
c.decode_backoff_kbps, 500_000,
|
||||
"…nor erase the knee reference"
|
||||
);
|
||||
c.on_ack(r2);
|
||||
climb_to(&mut c, start, &mut t, 460_000);
|
||||
let rate = c.current_kbps;
|
||||
choke(&mut c, start, &mut t).expect("re-climb choke must back off");
|
||||
assert_eq!(c.decode_cap_kbps, Some(rate - rate / 16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframe_storms_on_a_clean_link_latch_the_knee() {
|
||||
// The Steam Deck presentation of the knee: an overdriven decoder that WEDGES instead
|
||||
// of queueing — decode latency reads absent-to-flat while the client begs for
|
||||
// keyframes with zero loss (the field traces: 14–19 asks at ~300 Mbps, loss_ppm=0).
|
||||
// The asks are the decode evidence.
|
||||
let mut c = BitrateController::new(300_000);
|
||||
c.set_ceiling(900_000);
|
||||
let start = Instant::now();
|
||||
let mut t = 0;
|
||||
for _ in 0..4 {
|
||||
calm_window(&mut c, ticks(start, t));
|
||||
t += 1;
|
||||
}
|
||||
t += 2;
|
||||
let r1 = c
|
||||
.on_window(
|
||||
ticks(start, t),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
None,
|
||||
300_000,
|
||||
false,
|
||||
RECOVERY_KF_SEVERE,
|
||||
)
|
||||
.expect("keyframe storm must back off");
|
||||
t += 1;
|
||||
assert!(c.decode_cap_kbps.is_none());
|
||||
c.on_ack(r1);
|
||||
climb_to(&mut c, start, &mut t, 280_000);
|
||||
let rate = c.current_kbps;
|
||||
t += 2;
|
||||
let _ = c
|
||||
.on_window(
|
||||
ticks(start, t),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
None,
|
||||
rate,
|
||||
false,
|
||||
RECOVERY_KF_SEVERE,
|
||||
)
|
||||
.expect("second storm must back off");
|
||||
assert_eq!(c.decode_cap_kbps, Some(rate - rate / 16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframe_storms_with_real_loss_teach_no_knee() {
|
||||
// The same storm WITH heavy loss is network-attributed (a lost reference forces
|
||||
// recovery asks; loss_ppm already prices that path): it must not latch, and it must
|
||||
// break the streak like any other non-decode backoff.
|
||||
let mut c = BitrateController::new(300_000);
|
||||
c.set_ceiling(900_000);
|
||||
let start = Instant::now();
|
||||
let mut t = 0;
|
||||
for _ in 0..4 {
|
||||
calm_window(&mut c, ticks(start, t));
|
||||
t += 1;
|
||||
}
|
||||
t += 2;
|
||||
let r1 = c
|
||||
.on_window(
|
||||
ticks(start, t),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
None,
|
||||
300_000,
|
||||
false,
|
||||
RECOVERY_KF_SEVERE,
|
||||
)
|
||||
.expect("clean storm must back off");
|
||||
t += 1;
|
||||
assert_eq!(c.decode_backoff_kbps, 300_000);
|
||||
c.on_ack(r1);
|
||||
climb_to(&mut c, start, &mut t, 280_000);
|
||||
t += 2;
|
||||
let _ = c
|
||||
.on_window(
|
||||
ticks(start, t),
|
||||
0,
|
||||
SEVERE_LOSS_PPM,
|
||||
Some(10_000),
|
||||
None,
|
||||
None,
|
||||
c.current_kbps,
|
||||
false,
|
||||
RECOVERY_KF_SEVERE,
|
||||
)
|
||||
.expect("lossy storm must back off");
|
||||
assert!(c.decode_cap_kbps.is_none());
|
||||
assert_eq!(
|
||||
c.decode_backoff_kbps, 0,
|
||||
"a loss-attributed storm must reset the knee reference"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mixed_streak_without_decode_attribution_is_no_knee_evidence() {
|
||||
// Two bad windows, only ONE decode-flagged (OWD carried the other): the backoff is
|
||||
// not decode-attributed — the reference must reset, not sample.
|
||||
let mut c = BitrateController::new(500_000);
|
||||
c.set_ceiling(900_000);
|
||||
let start = Instant::now();
|
||||
let mut t = 0;
|
||||
for _ in 0..4 {
|
||||
calm_window(&mut c, ticks(start, t));
|
||||
t += 1;
|
||||
}
|
||||
t += 2;
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, t),
|
||||
0,
|
||||
0,
|
||||
Some(40_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
490_000,
|
||||
false,
|
||||
0
|
||||
),
|
||||
None,
|
||||
"one OWD-bad window must not decide"
|
||||
);
|
||||
t += 1;
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, t),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(26_000),
|
||||
None,
|
||||
490_000,
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(350_000)
|
||||
);
|
||||
assert!(c.decode_cap_kbps.is_none());
|
||||
assert_eq!(
|
||||
c.decode_backoff_kbps, 0,
|
||||
"a mixed-attribution backoff must reset the knee reference"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ack_silence_disables_the_controller() {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -490,7 +490,13 @@ impl NativeClient {
|
||||
video_codecs,
|
||||
preferred_codec,
|
||||
display_hdr,
|
||||
client_caps,
|
||||
// Redundant audio (`0xD2`) is advertised by CORE, not by the embedder: the
|
||||
// recovery happens on the demux side (`AudioRedRecovery` in the datagram
|
||||
// task) and re-inserts the rebuilt frame into the same queue, so every
|
||||
// embedder benefits without knowing the plane exists — and none of them can
|
||||
// forget to opt in. The bit is a pure "I can decode it"; the host still
|
||||
// decides whether to spend the extra ~1 %.
|
||||
client_caps: client_caps | crate::quic::CLIENT_CAP_AUDIO_RED,
|
||||
frame_parts,
|
||||
launch,
|
||||
name,
|
||||
|
||||
@@ -243,6 +243,38 @@ impl ControlTask {
|
||||
seq: offer.seq,
|
||||
kinds: offer.kinds,
|
||||
});
|
||||
} else if let Ok(chg) = crate::quic::ShardPayloadChanged::decode(&msg) {
|
||||
// Mid-session shard renegotiation (design/shard-payload-reneg.md): the
|
||||
// host re-keys the sealed video geometry. Per-frame pinning means there
|
||||
// is nothing to re-key on the receive path — the reassembler follows
|
||||
// each frame's own header and every buffer is statically sized for the
|
||||
// ceiling — so the dispatch is validate + ack. The ack is telemetry for
|
||||
// a shrink and the GATE for a grow (the host emits nothing above the
|
||||
// old size until it lands). Validate against our own receive bounds —
|
||||
// the same ceiling we advertised in `Hello::max_shard_payload` — and
|
||||
// answer an out-of-bounds request with SILENCE, not an ack: a buggy
|
||||
// host must never read a granted grow out of garbage.
|
||||
let n = chg.shard_payload as usize;
|
||||
if (crate::config::MIN_SHARD_PAYLOAD..=crate::config::max_shard_payload())
|
||||
.contains(&n)
|
||||
&& n % 2 == 0
|
||||
{
|
||||
tracing::info!(
|
||||
shard_payload = n,
|
||||
"host re-keyed the wire shard payload — acking"
|
||||
);
|
||||
let ack = crate::quic::ShardPayloadAck {
|
||||
shard_payload: chg.shard_payload,
|
||||
};
|
||||
if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
shard_payload = n,
|
||||
"out-of-bounds shard-payload change — ignoring (no ack)"
|
||||
);
|
||||
}
|
||||
} else if let Ok(shape) = crate::quic::CursorShape::decode(&msg) {
|
||||
// Pointer bitmap changed (cursor channel, only when negotiated). try_send:
|
||||
// an overflowing ring drops the newest shape — the next change resends.
|
||||
|
||||
@@ -23,6 +23,10 @@ pub(super) async fn run(
|
||||
// gate): a datagram the network reordered must not roll a stopped motor back on. Legacy v1
|
||||
// datagrams carry no seq and bypass it (an old host's own periodic re-send is the only heal).
|
||||
let mut rumble_last_seq: [Option<u8>; crate::input::MAX_PADS] = [None; crate::input::MAX_PADS];
|
||||
// Redundant-audio-plane rebuild (`0xD2`). Recovery happens HERE rather than in the four
|
||||
// client decoders: the recovered frame is re-inserted into this queue in order, so every
|
||||
// embedder gets a complete stream without knowing the plane exists.
|
||||
let mut audio_red = crate::audio::AudioRedRecovery::new();
|
||||
while let Ok(d) = conn.read_datagram().await {
|
||||
match d.first() {
|
||||
Some(&crate::quic::AUDIO_MAGIC) => {
|
||||
@@ -34,6 +38,26 @@ pub(super) async fn run(
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(&crate::quic::AUDIO_RED_MAGIC) => {
|
||||
if let Some((seq, pts_ns, opus, prev)) = crate::quic::decode_audio_red_datagram(&d)
|
||||
{
|
||||
if audio_red.recover_before(seq, prev.is_some()) {
|
||||
// The copy is the frame BEFORE this one, so it carries the previous
|
||||
// sequence and presentation time — one protocol frame earlier.
|
||||
let _ = audio_tx.try_send(AudioPacket {
|
||||
seq: seq.wrapping_sub(1),
|
||||
pts_ns: pts_ns
|
||||
.saturating_sub(crate::audio::FRAME_MS as u64 * 1_000_000),
|
||||
data: prev.unwrap_or_default().to_vec(),
|
||||
});
|
||||
}
|
||||
let _ = audio_tx.try_send(AudioPacket {
|
||||
seq,
|
||||
pts_ns,
|
||||
data: opus.to_vec(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(&crate::quic::RUMBLE_MAGIC) => {
|
||||
if let Some(u) = crate::quic::decode_rumble_envelope(&d) {
|
||||
// Gate v2 envelopes on their per-pad seq; forward v1 (envelope: None) as-is.
|
||||
|
||||
@@ -156,6 +156,12 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
|
||||
// stop compositing the pointer, so only an embedder that actually renders the
|
||||
// cursor locally may set it (the embedder decides, we pass through).
|
||||
client_caps: args.client_caps,
|
||||
// Unconditional like STREAMED_AU: the shared reassembler pins geometry
|
||||
// per-frame and every receive buffer is sized from MAX_DATAGRAM_BYTES, so
|
||||
// every embedder accepts a mid-session shard change up to this ceiling
|
||||
// (design/shard-payload-reneg.md W0.3 — the host only renegotiates, and only
|
||||
// grows to jumbo, when this advertises it).
|
||||
max_shard_payload: crate::config::max_shard_payload() as u16,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
|
||||
@@ -341,6 +341,88 @@ pub fn mtu1500_shard_payload_for(peer: core::net::IpAddr) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// Floor for a negotiated `shard_payload` (even, well under every real path). A path whose UDP
|
||||
/// budget lands below this can't carry the QUIC control plane either (QUIC's own minimum is a
|
||||
/// 1200-byte UDP payload), so shrinking video shards further buys nothing — the clamp helpers
|
||||
/// bottom out here instead of producing degenerate confetti-sized shards.
|
||||
pub const MIN_SHARD_PAYLOAD: usize = 512;
|
||||
|
||||
/// The sealed wire size of a video datagram carrying `shard_payload` bytes of shard — what
|
||||
/// actually leaves the socket as UDP payload (punktfunk header + shard + crypto overhead).
|
||||
pub const fn sealed_datagram_bytes(shard_payload: usize) -> usize {
|
||||
HEADER_LEN + shard_payload + CRYPTO_OVERHEAD
|
||||
}
|
||||
|
||||
/// The UDP-payload size a path must carry for full-size IPv4 video datagrams: the sealed size
|
||||
/// of the [`mtu1500_shard_payload`] default (= 1472, the exact 1500-MTU IPv4 ceiling). Doubles
|
||||
/// as the QUIC MTU-discovery probe ceiling (`quic/endpoint.rs`): with the ceiling set to
|
||||
/// exactly this value, a control connection whose discovery settles AT the ceiling has proven
|
||||
/// the path carries full-size video datagrams, and one that settles BELOW it has proven the
|
||||
/// path cannot — a discrimination quinn's stock 1452 ceiling can't make in either direction.
|
||||
pub const fn video_datagram_udp_ceiling() -> usize {
|
||||
sealed_datagram_bytes(mtu1500_shard_payload())
|
||||
}
|
||||
|
||||
/// Largest even shard payload whose sealed datagram fits in `udp_budget` bytes of UDP payload
|
||||
/// (the quantity QUIC MTU discovery measures — [`video_datagram_udp_ceiling`] is its probe
|
||||
/// ceiling). Clamped to the peer's family default ([`mtu1500_shard_payload_for`]) so a generous
|
||||
/// budget never grows packets past today's wire, and floored at [`MIN_SHARD_PAYLOAD`].
|
||||
pub fn shard_payload_for_udp_budget(udp_budget: usize, peer: core::net::IpAddr) -> usize {
|
||||
let p = udp_budget.saturating_sub(HEADER_LEN + CRYPTO_OVERHEAD);
|
||||
let p = p - p % 2; // FEC requires even shards
|
||||
p.clamp(MIN_SHARD_PAYLOAD, mtu1500_shard_payload_for(peer))
|
||||
}
|
||||
|
||||
/// The family's IP+UDP header bytes between an on-wire IP MTU and its UDP payload budget —
|
||||
/// 28 for IPv4 (and IPv4-mapped), 48 for IPv6.
|
||||
fn ip_udp_overhead(peer: core::net::IpAddr) -> usize {
|
||||
match peer {
|
||||
core::net::IpAddr::V4(_) => 28,
|
||||
core::net::IpAddr::V6(v6) if v6.to_ipv4_mapped().is_some() => 28,
|
||||
core::net::IpAddr::V6(_) => 48,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`shard_payload_for_udp_budget`] for an operator-supplied ON-WIRE IP MTU (the number
|
||||
/// `netsh interface ipv4 show subinterfaces` / `ip link` shows): subtracts the family's IP+UDP
|
||||
/// headers first — 28 for IPv4 (and IPv4-mapped), 48 for IPv6.
|
||||
pub fn shard_payload_for_wire_mtu(wire_mtu: usize, peer: core::net::IpAddr) -> usize {
|
||||
shard_payload_for_udp_budget(wire_mtu.saturating_sub(ip_udp_overhead(peer)), peer)
|
||||
}
|
||||
|
||||
/// The operator's jumbo-frames opt-in (design/shard-payload-reneg.md Phase 2): the target
|
||||
/// on-wire IP MTU, or `None` = no opt-in (nothing above the 1500-default wire is ever probed
|
||||
/// or grown to). One knob, one code path: a `PUNKTFUNK_WIRE_MTU` above the standard 1500
|
||||
/// derives the target from the operator's number; `PUNKTFUNK_JUMBO=1` is the fixed 9000
|
||||
/// profile for operators who don't want to think in MTUs. Raising the wire above 1500 is
|
||||
/// only ever an ACK-GATED mid-session grow toward a client that advertised
|
||||
/// [`max_shard_payload`] headroom — sessions still START at the family default.
|
||||
pub fn jumbo_wire_mtu() -> Option<usize> {
|
||||
if let Ok(v) = std::env::var("PUNKTFUNK_WIRE_MTU") {
|
||||
if let Ok(mtu) = v.trim().parse::<usize>() {
|
||||
if mtu > 1500 {
|
||||
return Some(mtu);
|
||||
}
|
||||
}
|
||||
}
|
||||
match std::env::var("PUNKTFUNK_JUMBO") {
|
||||
Ok(v) if v.trim() == "1" => Some(9000),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The jumbo sibling of [`shard_payload_for_wire_mtu`]: the largest even shard payload whose
|
||||
/// sealed datagram fits `wire_mtu`, clamped to the RECEIVE ceiling ([`max_shard_payload`])
|
||||
/// instead of the family 1500-default — the up-leg's grow target. Still floored at
|
||||
/// [`MIN_SHARD_PAYLOAD`].
|
||||
pub fn jumbo_shard_payload_for(wire_mtu: usize, peer: core::net::IpAddr) -> usize {
|
||||
let p = wire_mtu
|
||||
.saturating_sub(ip_udp_overhead(peer))
|
||||
.saturating_sub(HEADER_LEN + CRYPTO_OVERHEAD);
|
||||
let p = p - p % 2; // FEC requires even shards
|
||||
p.clamp(MIN_SHARD_PAYLOAD, max_shard_payload())
|
||||
}
|
||||
|
||||
/// Everything needed to construct a [`Session`](crate::session::Session).
|
||||
///
|
||||
/// `Debug` is implemented by hand to redact `key`/`salt`, and `key`/`salt` are zeroized
|
||||
@@ -514,6 +596,97 @@ mod tests {
|
||||
assert!(HEADER_LEN + (p + 2) + CRYPTO_OVERHEAD > 1452, "not maximal");
|
||||
}
|
||||
|
||||
/// The video-datagram ceiling IS the exact v4 sealed size — the QUIC MTU-discovery probe
|
||||
/// ceiling (endpoint.rs) relies on this equality for its settled-at-vs-below verdict.
|
||||
#[test]
|
||||
fn video_datagram_ceiling_is_the_sealed_default() {
|
||||
assert_eq!(
|
||||
video_datagram_udp_ceiling(),
|
||||
HEADER_LEN + mtu1500_shard_payload() + CRYPTO_OVERHEAD
|
||||
);
|
||||
assert_eq!(video_datagram_udp_ceiling(), 1472);
|
||||
}
|
||||
|
||||
/// Budget-derived sizing: even, sealed-fits-the-budget, clamped to the family default
|
||||
/// above and [`MIN_SHARD_PAYLOAD`] below.
|
||||
#[test]
|
||||
fn shard_payload_for_udp_budget_math() {
|
||||
use core::net::IpAddr;
|
||||
let v4: IpAddr = "192.168.1.50".parse().unwrap();
|
||||
let v6: IpAddr = "fd00::50".parse().unwrap();
|
||||
// The full ceiling reproduces the default exactly.
|
||||
assert_eq!(
|
||||
shard_payload_for_udp_budget(video_datagram_udp_ceiling(), v4),
|
||||
mtu1500_shard_payload()
|
||||
);
|
||||
// A WARP/Tailscale-shaped 1280 budget: sealed result must fit the budget, stay even.
|
||||
let p = shard_payload_for_udp_budget(1280, v4);
|
||||
assert_eq!(p % 2, 0);
|
||||
assert!(sealed_datagram_bytes(p) <= 1280);
|
||||
assert!(sealed_datagram_bytes(p + 2) > 1280, "not maximal");
|
||||
// Odd budgets round down to even shards.
|
||||
assert_eq!(shard_payload_for_udp_budget(1281, v4) % 2, 0);
|
||||
// A generous budget never grows past the family default (either family).
|
||||
assert_eq!(
|
||||
shard_payload_for_udp_budget(9000, v4),
|
||||
mtu1500_shard_payload()
|
||||
);
|
||||
assert_eq!(
|
||||
shard_payload_for_udp_budget(9000, v6),
|
||||
mtu1500_shard_payload_v6()
|
||||
);
|
||||
// Degenerate budgets bottom out at the floor instead of confetti.
|
||||
assert_eq!(shard_payload_for_udp_budget(100, v4), MIN_SHARD_PAYLOAD);
|
||||
}
|
||||
|
||||
/// Operator-facing wire-MTU sizing subtracts the right IP+UDP header per family, and 1500
|
||||
/// reproduces today's defaults exactly.
|
||||
#[test]
|
||||
fn shard_payload_for_wire_mtu_math() {
|
||||
use core::net::IpAddr;
|
||||
let v4: IpAddr = "192.168.1.50".parse().unwrap();
|
||||
let v6: IpAddr = "fd00::50".parse().unwrap();
|
||||
let mapped: IpAddr = "::ffff:192.168.1.50".parse().unwrap();
|
||||
assert_eq!(
|
||||
shard_payload_for_wire_mtu(1500, v4),
|
||||
mtu1500_shard_payload()
|
||||
);
|
||||
assert_eq!(
|
||||
shard_payload_for_wire_mtu(1500, mapped),
|
||||
mtu1500_shard_payload()
|
||||
);
|
||||
assert_eq!(
|
||||
shard_payload_for_wire_mtu(1500, v6),
|
||||
mtu1500_shard_payload_v6()
|
||||
);
|
||||
// 1280 wire − 28 − 64 = 1188 (v4); − 48 − 64 = 1168 (v6).
|
||||
assert_eq!(shard_payload_for_wire_mtu(1280, v4), 1188);
|
||||
assert_eq!(shard_payload_for_wire_mtu(1280, v6), 1168);
|
||||
}
|
||||
|
||||
/// Jumbo grow-target sizing (the up-leg, design/shard-payload-reneg.md): even, sealed
|
||||
/// fits the wire, clamped to the RECEIVE ceiling instead of the family 1500-default —
|
||||
/// and the standard 9000 profile lands on the exact documented value.
|
||||
#[test]
|
||||
fn jumbo_shard_payload_math() {
|
||||
use core::net::IpAddr;
|
||||
let v4: IpAddr = "192.168.1.50".parse().unwrap();
|
||||
let v6: IpAddr = "fd00::50".parse().unwrap();
|
||||
// 9000 − 28 (IPv4+UDP) − 64 (header+crypto) = 8908 even; sealed 8972 ≤ the 9216
|
||||
// datagram ceiling. The v6 sibling: 9000 − 48 − 64 = 8888.
|
||||
assert_eq!(jumbo_shard_payload_for(9000, v4), 8908);
|
||||
assert_eq!(sealed_datagram_bytes(8908), 8972);
|
||||
assert!(sealed_datagram_bytes(8908) <= MAX_DATAGRAM_BYTES);
|
||||
assert_eq!(jumbo_shard_payload_for(9000, v6), 8888);
|
||||
// An operator MTU larger than the receive path clamps to the ceiling, smaller ones
|
||||
// track the wire, and degenerate ones floor at MIN_SHARD_PAYLOAD.
|
||||
assert_eq!(jumbo_shard_payload_for(64_000, v4), max_shard_payload());
|
||||
let p = jumbo_shard_payload_for(4000, v4);
|
||||
assert_eq!(p % 2, 0);
|
||||
assert!(sealed_datagram_bytes(p) <= 4000 - 28);
|
||||
assert_eq!(jumbo_shard_payload_for(100, v4), MIN_SHARD_PAYLOAD);
|
||||
}
|
||||
|
||||
/// Family selection: genuine v6 remotes get the v6 size; v4 — including the IPv4-mapped v6
|
||||
/// form a dual-stack `[::]` socket reports for a v4 client — keeps the v4 size.
|
||||
#[test]
|
||||
|
||||
@@ -70,7 +70,17 @@ pub const CRYPTO_OVERHEAD: usize = 8 + crate::crypto::TAG_LEN;
|
||||
|
||||
/// Largest UDP datagram the core will send or accept. `Config::validate` bounds
|
||||
/// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
|
||||
pub const MAX_DATAGRAM_BYTES: usize = 2048;
|
||||
///
|
||||
/// Sized for **jumbo frames** (design/shard-payload-reneg.md W0.2): a 9000-MTU LAN carries
|
||||
/// ~8908-byte shards (sealed 8972-byte UDP payloads), and every receive path — the transport
|
||||
/// `RECV_BUF`, the session's `recvmmsg` ring — is sized from this constant, so a deployed
|
||||
/// client can accept a jumbo geometry the moment its host negotiates one. The ring cost is
|
||||
/// 128 × ~9 KiB ≈ 1.1 MiB per **client** session (lazily allocated on first poll; hosts never
|
||||
/// allocate it) — measured against the ~256 KiB it was at 2048, an acceptable static price
|
||||
/// for never having to resize buffers on a mid-session grow. Senders still derive their
|
||||
/// shard payload from the path MTU (`config::mtu1500_shard_payload*`, the wire-MTU clamps);
|
||||
/// this is the acceptance ceiling, not a transmit size.
|
||||
pub const MAX_DATAGRAM_BYTES: usize = 9216;
|
||||
|
||||
/// Fixed per-packet header. `#[repr(C)]`, no padding, zero-copy (de)serializable.
|
||||
#[repr(C)]
|
||||
|
||||
@@ -25,6 +25,10 @@ pub struct Packetizer {
|
||||
next_probe_index: u32,
|
||||
next_seq: u32,
|
||||
shard_payload: usize,
|
||||
/// The negotiated frame-size cap — kept so a live shard-payload swap
|
||||
/// ([`set_shard_payload`](Self::set_shard_payload)) can re-derive the per-frame block
|
||||
/// ceilings from the same formulas construction used.
|
||||
max_frame_bytes: usize,
|
||||
fec: crate::config::FecConfig,
|
||||
version: u8,
|
||||
/// Reusable zero-padded scratch for the frame's final data shard when the frame isn't an
|
||||
@@ -47,10 +51,12 @@ pub struct Packetizer {
|
||||
/// where every packet of the block is dropped wholesale, the frame never completes, and the
|
||||
/// resulting loss pushes adaptive FEC *higher*. See the `recovery_for` clamp in `packetize_each`.
|
||||
max_total_shards: usize,
|
||||
/// The peer's per-frame block ceiling, mirroring [`ReassemblerLimits::from_config`]'s
|
||||
/// `max_blocks` — the streamed path's bound on how many sentinel blocks it may emit (a
|
||||
/// streamed AU's size isn't known up front, so this is the only pre-emission guard against
|
||||
/// producing a frame the receiver must reject).
|
||||
/// The peer's per-frame block ceiling — the streamed path's bound on how many sentinel
|
||||
/// blocks it may emit (a streamed AU's size isn't known up front, so this is the only
|
||||
/// pre-emission guard against producing a frame the receiver must reject). The receiver
|
||||
/// derives the same ceiling per packet from the packet's own `shard_bytes`
|
||||
/// (`Reassembler::push` — geometry is per-frame), so this stays in step as long as it is
|
||||
/// computed from the shard size this packetizer actually stamps.
|
||||
max_blocks: usize,
|
||||
/// The streamed path's block-count ceiling in SLICE mode ([`USER_FLAG_SLICE_STREAM`]) —
|
||||
/// variable-K blocks, floored at `min(MIN_STREAM_BLOCK_SHARDS, max_data_per_block)` shards.
|
||||
@@ -105,15 +111,12 @@ impl StreamedAu {
|
||||
impl Packetizer {
|
||||
pub fn new(config: &Config) -> Self {
|
||||
let max_data = config.fec.max_data_per_block as usize;
|
||||
let total_data_max = config
|
||||
.max_frame_bytes
|
||||
.div_ceil(config.shard_payload.max(1))
|
||||
.max(1);
|
||||
Packetizer {
|
||||
let mut p = Packetizer {
|
||||
next_frame_index: 0,
|
||||
next_probe_index: 0,
|
||||
next_seq: 0,
|
||||
shard_payload: config.shard_payload,
|
||||
max_frame_bytes: config.max_frame_bytes,
|
||||
fec: config.fec,
|
||||
version: config.phase as u8,
|
||||
tail: Vec::new(),
|
||||
@@ -121,12 +124,37 @@ impl Packetizer {
|
||||
// Mirrors `ReassemblerLimits::from_config` — keep the two in step.
|
||||
max_total_shards: (max_data + config.fec.recovery_for(max_data))
|
||||
.min(config.fec.scheme.max_total_shards()),
|
||||
max_blocks: total_data_max.div_ceil(max_data).max(1),
|
||||
// Every non-final SLICE block carries at least `min(MIN_STREAM_BLOCK_SHARDS, K)`
|
||||
// data shards (the flush floor, clamped by the block size), so a max-size frame
|
||||
// bounds the block count. Mirrors the receiver's slice firewall — keep in step.
|
||||
slice_block_cap: total_data_max / MIN_STREAM_BLOCK_SHARDS.min(max_data.max(1)) + 2,
|
||||
}
|
||||
// Derived from the shard size below (single source of truth for the formulas).
|
||||
max_blocks: 0,
|
||||
slice_block_cap: 0,
|
||||
};
|
||||
p.set_shard_payload(config.shard_payload);
|
||||
p
|
||||
}
|
||||
|
||||
/// Live-swap the wire shard payload (mid-session shard renegotiation,
|
||||
/// design/shard-payload-reneg.md Phase 1). Takes effect on the next packetized AU — call
|
||||
/// ONLY between AUs, never with a [`StreamedAu`] in flight: an open streamed AU's
|
||||
/// shard-aligned tiling derives from the size it began with, and re-keying under it would
|
||||
/// corrupt the frame's layout. The per-frame block ceilings follow the new size here; the
|
||||
/// receiver re-derives its side per packet from the header's own `shard_bytes` (geometry
|
||||
/// is per-frame there), so the two stay in step by construction. Bounds are the caller's
|
||||
/// contract — go through [`Session::set_shard_payload`](crate::session::Session::set_shard_payload),
|
||||
/// which enforces the `Config::validate` rules.
|
||||
pub fn set_shard_payload(&mut self, shard_payload: usize) {
|
||||
let max_data = self.fec.max_data_per_block as usize;
|
||||
let total_data_max = self.max_frame_bytes.div_ceil(shard_payload.max(1)).max(1);
|
||||
self.shard_payload = shard_payload;
|
||||
self.max_blocks = total_data_max.div_ceil(max_data).max(1);
|
||||
// Every non-final SLICE block carries at least `min(MIN_STREAM_BLOCK_SHARDS, K)`
|
||||
// data shards (the flush floor, clamped by the block size), so a max-size frame
|
||||
// bounds the block count. Mirrors the receiver's slice firewall — keep in step.
|
||||
self.slice_block_cap = total_data_max / MIN_STREAM_BLOCK_SHARDS.min(max_data.max(1)) + 2;
|
||||
}
|
||||
|
||||
/// The wire shard payload AUs are currently packetized at.
|
||||
pub fn shard_payload(&self) -> usize {
|
||||
self.shard_payload
|
||||
}
|
||||
|
||||
/// Allocate the next **probe-space** frame index (speed-test filler). A separate counter from
|
||||
|
||||
@@ -76,6 +76,12 @@ struct BlockState {
|
||||
}
|
||||
|
||||
struct FrameBuf {
|
||||
/// The frame's PINNED shard payload — set by its first-arriving packet (bounds-checked by
|
||||
/// the firewall), matched by every later packet of the frame. Geometry is per-frame so a
|
||||
/// mid-session `shard_payload` change (design/shard-payload-reneg.md) is safe on an
|
||||
/// unordered wire: frames in flight complete under their own pin while new frames arrive
|
||||
/// under the new one, and no cross-geometry splice can land in one buffer.
|
||||
shard_bytes: usize,
|
||||
/// Exact AU size. 0 = unknown: the frame was opened by a streamed-AU SENTINEL packet
|
||||
/// ([`crate::quic::VIDEO_CAP_STREAMED_AU`]) and the final block's real totals haven't
|
||||
/// arrived yet — the frame can't complete before they do (and retro-validate).
|
||||
@@ -105,16 +111,28 @@ struct FrameBuf {
|
||||
/// Per-session bounds the reassembler enforces on every packet header *before*
|
||||
/// allocating, so a hostile or corrupt header cannot drive unbounded memory use. All
|
||||
/// derived from the negotiated [`Config`].
|
||||
///
|
||||
/// Shard geometry is PER-FRAME, not per-session (mid-session shard-payload renegotiation,
|
||||
/// design/shard-payload-reneg.md W0.1): a frame's first-arriving packet pins the frame's
|
||||
/// `shard_bytes` within `[min_shard_bytes, max_shard_bytes]`, later packets must match the
|
||||
/// pin, and the per-frame block ceiling derives from the pinned size (a shrunk shard needs
|
||||
/// more blocks for the same bytes). The reorder race between an ordered control-stream
|
||||
/// geometry change and the unordered video datagrams is thereby killed structurally — every
|
||||
/// frame is wholly one geometry, whichever order its packets and the change arrive in.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ReassemblerLimits {
|
||||
/// Expected shard payload length; every shard in the stream must match exactly.
|
||||
pub shard_bytes: usize,
|
||||
/// Floor for a frame's pinned shard payload — [`crate::config::MIN_SHARD_PAYLOAD`] in
|
||||
/// production (or the negotiated value when a session legitimately starts below it).
|
||||
pub min_shard_bytes: usize,
|
||||
/// Ceiling for a frame's pinned shard payload — what this receive path accepts and what
|
||||
/// the client advertises in `Hello::max_shard_payload`
|
||||
/// ([`crate::config::max_shard_payload`]): the transport recv buffers are sized for a
|
||||
/// sealed datagram of exactly this shard size.
|
||||
pub max_shard_bytes: usize,
|
||||
/// Max data shards per block (the negotiated `max_data_per_block`).
|
||||
pub max_data_shards: usize,
|
||||
/// Max total shards per block (data + recovery), capped by the FEC scheme ceiling.
|
||||
pub max_total_shards: usize,
|
||||
/// Max FEC blocks per frame.
|
||||
pub max_blocks: usize,
|
||||
/// Max accepted access-unit size.
|
||||
pub max_frame_bytes: usize,
|
||||
}
|
||||
@@ -135,12 +153,13 @@ impl ReassemblerLimits {
|
||||
// snapshot of it.
|
||||
let max_total =
|
||||
(max_data + (max_data * 90).div_ceil(100)).min(c.fec.scheme.max_total_shards());
|
||||
let total_data = c.max_frame_bytes.div_ceil(c.shard_payload.max(1)).max(1);
|
||||
ReassemblerLimits {
|
||||
shard_bytes: c.shard_payload,
|
||||
// `.min(c.shard_payload)`: never reject the session's own negotiated value — a
|
||||
// hand-configured session below the production floor still reassembles itself.
|
||||
min_shard_bytes: crate::config::MIN_SHARD_PAYLOAD.min(c.shard_payload),
|
||||
max_shard_bytes: crate::config::max_shard_payload(),
|
||||
max_data_shards: max_data,
|
||||
max_total_shards: max_total,
|
||||
max_blocks: total_data.div_ceil(max_data).max(1),
|
||||
max_frame_bytes: c.max_frame_bytes,
|
||||
}
|
||||
}
|
||||
@@ -179,6 +198,9 @@ const IN_FLIGHT_BUF_FACTOR: usize = 4;
|
||||
|
||||
/// Recovery-shard buffer pool ceiling (shard-sized buffers): enough for several max-recovery
|
||||
/// blocks in flight, small enough (~720 KB at a 1408-byte shard) to keep after a loss burst.
|
||||
/// Entries size themselves to the largest shard they ever held, so a jumbo session (opt-in,
|
||||
/// desktop-LAN — shards up to [`ReassemblerLimits::max_shard_bytes`]) retains proportionally
|
||||
/// more; it also needs ~6× fewer buffers per block, so the pool rarely fills there.
|
||||
const RECOVERY_POOL_MAX: usize = 512;
|
||||
|
||||
/// Buffers incoming shards, recovers lost ones via FEC, and emits whole access units.
|
||||
@@ -295,11 +317,16 @@ impl Reassembler {
|
||||
// Bound every attacker-controllable header field against the negotiated limits
|
||||
// BEFORE allocating anything keyed on it — this is the firewall against a tiny
|
||||
// datagram triggering a huge `vec![None; total]` / `Vec::with_capacity`.
|
||||
// `shard_bytes` is bounds-checked (not equality-checked) because geometry is
|
||||
// per-frame — the frame-pin check below is what rejects a size CHANGE mid-frame;
|
||||
// the even requirement mirrors `Config::validate` (FEC requires even shards).
|
||||
let drop = |stats: &StatsCounters| {
|
||||
StatsCounters::add(&stats.packets_dropped, 1);
|
||||
};
|
||||
if hdr.magic != PUNKTFUNK_MAGIC
|
||||
|| shard_bytes != lim.shard_bytes
|
||||
|| shard_bytes < lim.min_shard_bytes
|
||||
|| shard_bytes > lim.max_shard_bytes
|
||||
|| shard_bytes % 2 != 0
|
||||
|| pkt.len() < HEADER_LEN + shard_bytes
|
||||
|| data_shards == 0
|
||||
|| data_shards > lim.max_data_shards
|
||||
@@ -330,6 +357,11 @@ impl Reassembler {
|
||||
// later pin — the maximum the negotiated limits allow (the design's "allocate at
|
||||
// max_frame_bytes"; the existing in-flight budget bounds the amplification).
|
||||
let total_data_max = lim.max_frame_bytes.div_ceil(shard_bytes).max(1);
|
||||
// The per-frame FEC-block ceiling under THIS packet's shard size (geometry is
|
||||
// per-frame: a shrunk shard needs more blocks for the same bytes, so a session-level
|
||||
// cap from the negotiated size would reject legitimate post-shrink frames). Mirrors
|
||||
// the sender's `Packetizer::new` for whatever size it currently packetizes at.
|
||||
let max_blocks = total_data_max.div_ceil(lim.max_data_shards).max(1);
|
||||
// The slice pipeline's per-frame block ceiling: every non-final slice block carries at
|
||||
// least `min(MIN_STREAM_BLOCK_SHARDS, max_data_per_block)` data shards (the sender's
|
||||
// flush floor, clamped by the block size), so a max-size frame bounds the block count
|
||||
@@ -350,9 +382,7 @@ impl Reassembler {
|
||||
return Ok(None);
|
||||
}
|
||||
} else if sentinel {
|
||||
if frame_bytes != 0
|
||||
|| data_shards != lim.max_data_shards
|
||||
|| block_idx + 1 >= lim.max_blocks
|
||||
if frame_bytes != 0 || data_shards != lim.max_data_shards || block_idx + 1 >= max_blocks
|
||||
{
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
@@ -361,7 +391,7 @@ impl Reassembler {
|
||||
let block_cap = if slice_stream {
|
||||
slice_block_cap
|
||||
} else {
|
||||
lim.max_blocks
|
||||
max_blocks
|
||||
};
|
||||
if block_count > block_cap || block_idx >= block_count {
|
||||
drop(stats);
|
||||
@@ -513,6 +543,7 @@ impl Reassembler {
|
||||
}
|
||||
*in_flight_bytes += buf_len;
|
||||
e.insert(FrameBuf {
|
||||
shard_bytes,
|
||||
// A slice-stream sentinel's `frame_bytes` is its block's BASE offset, not a
|
||||
// frame size — the unpinned marker stays 0 until the final block's totals.
|
||||
frame_bytes: if sentinel { 0 } else { frame_bytes },
|
||||
@@ -527,6 +558,15 @@ impl Reassembler {
|
||||
})
|
||||
}
|
||||
};
|
||||
// Per-frame geometry pin: the frame's first packet pinned its shard size; a later
|
||||
// packet claiming a different (even in-bounds) size is dropped — otherwise two
|
||||
// geometries would compute different offsets into one buffer (a splice). This is
|
||||
// also what makes a mid-session `shard_payload` change safe against reorder: a
|
||||
// straggler of the old geometry can only ever land in ITS OWN frame's buffer.
|
||||
if frame.shard_bytes != shard_bytes {
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
// The slice marker must be frame-consistent: a mixed frame would firewall under one
|
||||
// placement rule and place under the other. The per-packet checks above and the
|
||||
// placement bounds guard below stay memory-safe without this — it's the tighter drop.
|
||||
@@ -883,6 +923,16 @@ impl Reassembler {
|
||||
// jump-to-live, exactly the stale content the flush existed to discard.
|
||||
self.pending_partial = None;
|
||||
}
|
||||
|
||||
/// Test-only: the current in-flight frame-buffer byte commitment (see
|
||||
/// [`IN_FLIGHT_BUF_FACTOR`]). The mixed-geometry budget tests assert it returns to
|
||||
/// exactly zero once every frame has terminated — the 0.23.0 lesson: geometry changes
|
||||
/// breed sizing bugs, and accounting drift here surfaces in the field as a permanent
|
||||
/// loss storm once the budget wedges.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn in_flight(&self) -> usize {
|
||||
self.in_flight_bytes
|
||||
}
|
||||
}
|
||||
|
||||
/// The data shards of a terminating frame that only exist because parity restored them
|
||||
@@ -1024,10 +1074,10 @@ mod reset_tests {
|
||||
#[test]
|
||||
fn reset_drops_a_parked_partial() {
|
||||
let mut r = Reassembler::new(ReassemblerLimits {
|
||||
shard_bytes: 64,
|
||||
min_shard_bytes: 64,
|
||||
max_shard_bytes: 64,
|
||||
max_data_shards: 8,
|
||||
max_total_shards: 16,
|
||||
max_blocks: 4,
|
||||
max_frame_bytes: 4096,
|
||||
});
|
||||
r.pending_partial = Some(Frame {
|
||||
|
||||
@@ -7,11 +7,14 @@ use crate::stats::StatsCounters;
|
||||
use zerocopy::{FromBytes, IntoBytes};
|
||||
|
||||
fn limits() -> ReassemblerLimits {
|
||||
// `min == max` pins the whole stream to 16-byte shards — the strictest geometry, so the
|
||||
// firewall tests below exercise the bounds checks; per-frame-pinning tests build their own
|
||||
// limits with a real range. Derived per-frame block ceiling: 4096/16 = 256 shards → 32.
|
||||
ReassemblerLimits {
|
||||
shard_bytes: 16,
|
||||
min_shard_bytes: 16,
|
||||
max_shard_bytes: 16,
|
||||
max_data_shards: 8,
|
||||
max_total_shards: 12,
|
||||
max_blocks: 4,
|
||||
max_frame_bytes: 4096,
|
||||
}
|
||||
}
|
||||
@@ -840,7 +843,7 @@ fn streamed_sentinel_firewall_bounds() {
|
||||
.unwrap()
|
||||
.is_none());
|
||||
// Sits on the last block the limits allow (no room for the final block after it).
|
||||
let h = sentinel(|h| h.block_index = 3); // limits().max_blocks == 4
|
||||
let h = sentinel(|h| h.block_index = 31); // derived max_blocks == 32 (see `limits()`)
|
||||
assert!(r
|
||||
.push(&packet(h), coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
@@ -1769,3 +1772,408 @@ fn slice_streamed_in_flight_budget_matches_legacy() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-frame shard geometry (mid-session shard-payload renegotiation — W0.1,
|
||||
// design/shard-payload-reneg.md). The 0.23.0 lesson applies in full: geometry
|
||||
// changes breed sizing bugs, so the slice/sentinel suite re-runs at every
|
||||
// production shard size and mixed-geometry streams are tortured under reorder.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The shard sizes the renegotiation actually moves between: the clamp floor (512), a
|
||||
/// WARP/Tailscale-shaped 1280-MTU path (1216), the 1500-MTU default (1408), and 9000-MTU
|
||||
/// jumbo (8908 — sealed 8972, inside [`MAX_DATAGRAM_BYTES`]).
|
||||
const PRODUCTION_SHARDS: [usize; 4] = [512, 1216, 1408, 8908];
|
||||
|
||||
/// [`prod_slice_config`] at an arbitrary shard payload.
|
||||
fn geo_config(shard_payload: usize) -> Config {
|
||||
let mut c = prod_slice_config();
|
||||
c.shard_payload = shard_payload;
|
||||
c.validate().expect("geometry config must be valid");
|
||||
c
|
||||
}
|
||||
|
||||
/// Packetize one legacy AU at the packetizer's CURRENT shard payload with an explicit
|
||||
/// frame index, returning wire packets + source bytes.
|
||||
fn legacy_packets_with(
|
||||
pk: &mut Packetizer,
|
||||
frame_index: u32,
|
||||
pts_ns: u64,
|
||||
len: usize,
|
||||
coder: &dyn crate::fec::ErasureCoder,
|
||||
) -> (Vec<Vec<u8>>, Vec<u8>) {
|
||||
let src: Vec<u8> = (0..len)
|
||||
.map(|i| (i * 131 + frame_index as usize * 7 + 3) as u8)
|
||||
.collect();
|
||||
let mut pkts: Vec<Vec<u8>> = Vec::new();
|
||||
pk.packetize_each(&src, pts_ns, 0, Some(frame_index), coder, |h, b| {
|
||||
let mut p = Vec::with_capacity(HEADER_LEN + b.len());
|
||||
p.extend_from_slice(h.as_bytes());
|
||||
p.extend_from_slice(b);
|
||||
pkts.push(p);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
(pkts, src)
|
||||
}
|
||||
|
||||
/// The slice-wire regression suite re-run at every production shard size (the design's
|
||||
/// non-negotiable verification): the exact-multiple sweep (the 0.23.0 filler-shard bug
|
||||
/// shape), lossy + reversed slice roundtrips, the legacy-streamed sentinel path, and the
|
||||
/// in-flight budget — each asserting DELIVERED byte-identical frames, never just an
|
||||
/// absence of errors.
|
||||
#[test]
|
||||
fn slice_wire_suite_at_production_shard_sizes() {
|
||||
let coder = coder_for(FecScheme::Gf16);
|
||||
for &shard in &PRODUCTION_SHARDS {
|
||||
let cfg = geo_config(shard);
|
||||
|
||||
// Exact-shard-multiple AUs + the off-by-one sweep around one of them.
|
||||
for shards in [16usize, 30, 64] {
|
||||
for extra in 0..3usize {
|
||||
let n = shards * shard + extra;
|
||||
let (pkts, src) = streamed_packets_with(&cfg, 1, 1000, true, &[n]);
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts)
|
||||
.unwrap_or_else(|| panic!("shard {shard}: {n}-byte slice AU must complete"));
|
||||
assert_eq!(
|
||||
f.data, src,
|
||||
"shard {shard}: {n}-byte AU must be byte-identical"
|
||||
);
|
||||
assert_eq!(
|
||||
r.in_flight(),
|
||||
0,
|
||||
"shard {shard}: budget must return to zero"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A multi-slice AU under loss (one data shard of the first flushed block — within
|
||||
// its ≥ 20% parity) in both delivery orders. Reversed is the critical order: the
|
||||
// final block's totals arrive first and every sentinel validates against the pin.
|
||||
for reverse in [false, true] {
|
||||
let chunks = [20 * shard + 13, 7 * shard + 1, 17 * shard];
|
||||
let (pkts, src) = streamed_packets_with(&cfg, 2, 2000, true, &chunks);
|
||||
let killed = pkts
|
||||
.iter()
|
||||
.position(|p| {
|
||||
let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
|
||||
h.shard_index < h.data_shards && h.recovery_shards >= 1
|
||||
})
|
||||
.expect("suite frame must have a recoverable data shard");
|
||||
let mut delivery: Vec<Vec<u8>> = pkts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| *i != killed)
|
||||
.map(|(_, p)| p.clone())
|
||||
.collect();
|
||||
if reverse {
|
||||
delivery.reverse();
|
||||
}
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
let f = push_all(&mut r, coder.as_ref(), &stats, &delivery).unwrap_or_else(|| {
|
||||
panic!("shard {shard} reverse={reverse}: lossy slice AU must complete")
|
||||
});
|
||||
assert_eq!(f.data, src, "shard {shard} reverse={reverse}");
|
||||
assert_eq!(r.in_flight(), 0);
|
||||
}
|
||||
|
||||
// Legacy-streamed (uniform full-K sentinel) path: one AU spanning a sentinel block
|
||||
// (K = 200) plus a final block.
|
||||
{
|
||||
let (pkts, src) = streamed_packets_with(&cfg, 3, 3000, false, &[230 * shard]);
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts)
|
||||
.unwrap_or_else(|| panic!("shard {shard}: legacy-streamed AU must complete"));
|
||||
assert_eq!(f.data, src);
|
||||
assert_eq!(r.in_flight(), 0);
|
||||
}
|
||||
|
||||
// The budget regression at this size: 12 ordinary AUs opened concurrently, no drops.
|
||||
for slice in [false, true] {
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
for i in 0..12u32 {
|
||||
let (pkts, _) =
|
||||
streamed_packets_with(&cfg, i, 1_000_000 * i as u64, slice, &[40_000]);
|
||||
r.push(&pkts[0], coder.as_ref(), &stats).unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
stats
|
||||
.packets_dropped
|
||||
.load(std::sync::atomic::Ordering::Relaxed),
|
||||
0,
|
||||
"shard {shard} slice={slice}: 12 AUs in flight must fit the budget"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One packetizer, one reassembler, one continuous stream — the shard payload swapped
|
||||
/// live between AUs ([`Packetizer::set_shard_payload`], the Phase 1 host seam): every
|
||||
/// frame across shrink → grow-to-jumbo → shrink-again delivers byte-identically under its
|
||||
/// own per-frame pin, and the budget returns to zero.
|
||||
#[test]
|
||||
fn mid_stream_shard_swap_delivers_every_frame() {
|
||||
let cfg = geo_config(1408);
|
||||
let coder = coder_for(FecScheme::Gf16);
|
||||
let mut pk = Packetizer::new(&cfg);
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
|
||||
// (shard size to swap to, AU length) — swaps happen between AUs, as Phase 1 will.
|
||||
let schedule = [
|
||||
(1408usize, 3 * 1408 + 100),
|
||||
(1408, 9 * 1408),
|
||||
(512, 5 * 512 + 17), // shrink (the VPN heal)
|
||||
(512, 512),
|
||||
(8908, 12 * 8908 + 1), // grow (jumbo)
|
||||
(1216, 4 * 1216 + 9), // revert (a mis-proven jumbo hop self-corrects)
|
||||
];
|
||||
for (i, &(shard, len)) in schedule.iter().enumerate() {
|
||||
pk.set_shard_payload(shard);
|
||||
let pts = 1_000_000 * (i as u64 + 1);
|
||||
let (pkts, src) = legacy_packets_with(&mut pk, i as u32, pts, len, coder.as_ref());
|
||||
for p in &pkts {
|
||||
let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
|
||||
assert_eq!(
|
||||
h.shard_bytes as usize, shard,
|
||||
"sender must stamp the live size"
|
||||
);
|
||||
}
|
||||
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts)
|
||||
.unwrap_or_else(|| panic!("frame {i} at shard {shard} must complete"));
|
||||
assert_eq!(
|
||||
f.data, src,
|
||||
"frame {i} at shard {shard} must be byte-identical"
|
||||
);
|
||||
assert!(f.complete);
|
||||
}
|
||||
assert_eq!(
|
||||
r.in_flight(),
|
||||
0,
|
||||
"budget must be exact across geometry swaps"
|
||||
);
|
||||
assert_eq!(stats.snapshot().frames_dropped, 0);
|
||||
}
|
||||
|
||||
/// The reorder race the design kills structurally: an old-geometry frame still in flight
|
||||
/// when new-geometry frames start arriving completes under its OWN pin — its straggler
|
||||
/// lands in its own buffer, not the new geometry's.
|
||||
#[test]
|
||||
fn old_geometry_frame_completes_after_new_geometry_arrived() {
|
||||
let cfg = geo_config(1408);
|
||||
let coder = coder_for(FecScheme::Gf16);
|
||||
let mut pk = Packetizer::new(&cfg);
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
|
||||
// Frame 0 at 1408: 7 data shards + 2 parity (20% FEC), data-first wire order. Withhold
|
||||
// THREE data shards — more than parity can bridge — so the frame genuinely stays
|
||||
// incomplete until a straggler returns (fewer, and FEC would complete it early).
|
||||
let (pkts0, src0) = legacy_packets_with(&mut pk, 0, 1_000_000, 6 * 1408 + 50, coder.as_ref());
|
||||
assert_eq!(
|
||||
pkts0.len(),
|
||||
9,
|
||||
"expected geometry changed — update the split"
|
||||
);
|
||||
let head: Vec<Vec<u8>> = pkts0[..4].iter().chain(&pkts0[7..]).cloned().collect();
|
||||
let straggler = &pkts0[4];
|
||||
assert!(
|
||||
push_all(&mut r, coder.as_ref(), &stats, &head).is_none(),
|
||||
"frame 0 must still be incomplete"
|
||||
);
|
||||
|
||||
// The stream re-keys to 512: frames 1..=2 arrive whole and deliver.
|
||||
pk.set_shard_payload(512);
|
||||
for i in 1..=2u32 {
|
||||
let pts = 1_000_000 + 1_000_000 * i as u64;
|
||||
let (pkts, src) = legacy_packets_with(&mut pk, i, pts, 3 * 512 + 7, coder.as_ref());
|
||||
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts).expect("new-geometry frame");
|
||||
assert_eq!(f.data, src);
|
||||
}
|
||||
|
||||
// Frame 0's old-geometry straggler arrives last — the frame completes byte-identically.
|
||||
let f = r
|
||||
.push(straggler, coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.expect("old-geometry frame must complete under its own pin");
|
||||
assert_eq!(f.data, src0);
|
||||
assert_eq!(f.frame_index, 0);
|
||||
assert_eq!(r.in_flight(), 0);
|
||||
assert_eq!(stats.snapshot().frames_dropped, 0);
|
||||
}
|
||||
|
||||
/// The anti-splice pin: a packet claiming a DIFFERENT (but in-bounds) shard size for an
|
||||
/// already-pinned frame is dropped — and the frame still completes from its real packets.
|
||||
#[test]
|
||||
fn cross_geometry_packet_for_a_pinned_frame_is_dropped() {
|
||||
let cfg = geo_config(1408);
|
||||
let coder = coder_for(FecScheme::Gf16);
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
|
||||
let mut pk_a = Packetizer::new(&geo_config(1408));
|
||||
let mut pk_b = Packetizer::new(&geo_config(1216));
|
||||
let (pkts, src) = legacy_packets_with(&mut pk_a, 0, 1_000_000, 5 * 1408 + 9, coder.as_ref());
|
||||
// The impostor: the same frame index packetized at 1216 — self-consistent (it passes
|
||||
// the firewall standalone), wrong for THIS frame's pin.
|
||||
let (impostor, _) = legacy_packets_with(&mut pk_b, 0, 1_000_000, 5 * 1216, coder.as_ref());
|
||||
|
||||
assert!(r.push(&pkts[0], coder.as_ref(), &stats).unwrap().is_none());
|
||||
let before = stats.snapshot().packets_dropped;
|
||||
assert!(r
|
||||
.push(&impostor[1], coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
stats.snapshot().packets_dropped,
|
||||
before + 1,
|
||||
"cross-geometry packet must be dropped by the frame pin"
|
||||
);
|
||||
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts[1..])
|
||||
.expect("the pinned frame must still complete from its real packets");
|
||||
assert_eq!(f.data, src, "no impostor bytes may reach the frame");
|
||||
}
|
||||
|
||||
/// The firewall bounds on a frame's pinned size: below the floor, above the receive
|
||||
/// ceiling, or odd ⇒ dropped before any allocation; the exact floor and ceiling are
|
||||
/// accepted AND deliver (proving the rejections aren't vacuous).
|
||||
#[test]
|
||||
fn shard_size_firewall_bounds() {
|
||||
let cfg = geo_config(1408);
|
||||
let lim = ReassemblerLimits::from_config(&cfg);
|
||||
assert_eq!(lim.min_shard_bytes, crate::config::MIN_SHARD_PAYLOAD);
|
||||
assert_eq!(lim.max_shard_bytes, crate::config::max_shard_payload());
|
||||
let coder = coder_for(FecScheme::Gf16);
|
||||
let mut r = Reassembler::new(lim);
|
||||
let stats = StatsCounters::default();
|
||||
|
||||
let single = |shard: usize, frame_index: u32| {
|
||||
let mut h = base_header();
|
||||
h.frame_index = frame_index;
|
||||
h.shard_bytes = shard as u16;
|
||||
h.frame_bytes = shard as u32;
|
||||
h
|
||||
};
|
||||
// Below the floor (even), above the ceiling (even), odd within bounds: all dropped.
|
||||
for (i, shard) in [510usize, 9154, 1409].into_iter().enumerate() {
|
||||
let before = stats.snapshot().packets_dropped;
|
||||
assert!(r
|
||||
.push(&packet(single(shard, i as u32)), coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
stats.snapshot().packets_dropped,
|
||||
before + 1,
|
||||
"shard {shard} must be firewalled"
|
||||
);
|
||||
}
|
||||
// The exact bounds deliver whole single-shard frames.
|
||||
for (i, shard) in [
|
||||
crate::config::MIN_SHARD_PAYLOAD,
|
||||
crate::config::max_shard_payload(),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let f = r
|
||||
.push(
|
||||
&packet(single(shard, 10 + i as u32)),
|
||||
coder.as_ref(),
|
||||
&stats,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("boundary shard {shard} must deliver"));
|
||||
assert_eq!(f.data.len(), shard);
|
||||
}
|
||||
}
|
||||
|
||||
mod geometry_proptests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
/// One generated frame: shard size, slice-vs-legacy wire, size factor, and whether to
|
||||
/// kill one recoverable data shard.
|
||||
type GenFrame = (usize, bool, usize, bool);
|
||||
|
||||
fn frame_strategy() -> impl Strategy<Value = GenFrame> {
|
||||
(
|
||||
proptest::sample::select(&PRODUCTION_SHARDS[..]),
|
||||
any::<bool>(),
|
||||
1usize..30,
|
||||
any::<bool>(),
|
||||
)
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(48))]
|
||||
|
||||
/// Mixed-geometry reorder torture: frames of DIFFERENT shard sizes and wire shapes
|
||||
/// interleaved into one shuffled delivery, with per-frame recoverable loss — every
|
||||
/// frame must deliver byte-identically and the in-flight budget must return to
|
||||
/// exactly zero (the 0.23.0 budget-drift shape, now across geometries).
|
||||
#[test]
|
||||
fn mixed_geometry_reorder_torture(
|
||||
frames in proptest::collection::vec(frame_strategy(), 2..6),
|
||||
seed in any::<u64>(),
|
||||
) {
|
||||
let coder = coder_for(FecScheme::Gf16);
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&geo_config(1408)));
|
||||
let stats = StatsCounters::default();
|
||||
|
||||
let mut all: Vec<(u64, u32, Vec<u8>)> = Vec::new(); // (shuffle key, frame, pkt)
|
||||
let mut sources: Vec<(u32, Vec<u8>)> = Vec::new();
|
||||
for (i, &(shard, slice, factor, kill)) in frames.iter().enumerate() {
|
||||
let cfg = geo_config(shard);
|
||||
let pts = 1_000_000 * (i as u64 + 1);
|
||||
let len = factor * shard + (factor % shard.min(7));
|
||||
let (mut pkts, src) = if slice {
|
||||
streamed_packets_with(&cfg, i as u32, pts, true, &[len.max(1)])
|
||||
} else {
|
||||
let mut pk = Packetizer::new(&cfg);
|
||||
legacy_packets_with(&mut pk, i as u32, pts, len.max(1), coder.as_ref())
|
||||
};
|
||||
if kill {
|
||||
if let Some(k) = pkts.iter().position(|p| {
|
||||
let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
|
||||
h.shard_index < h.data_shards && h.recovery_shards >= 1
|
||||
}) {
|
||||
pkts.remove(k);
|
||||
}
|
||||
}
|
||||
for (j, p) in pkts.into_iter().enumerate() {
|
||||
// Deterministic pseudo-shuffle key: interleaves frames and reorders
|
||||
// within a frame, differently per proptest case.
|
||||
let key = (seed | 1)
|
||||
.wrapping_mul(j as u64 + 1)
|
||||
.wrapping_add((i as u64) << 17)
|
||||
.rotate_left((j % 61) as u32);
|
||||
all.push((key, i as u32, p));
|
||||
}
|
||||
sources.push((i as u32, src));
|
||||
}
|
||||
all.sort_by_key(|(k, _, _)| *k);
|
||||
|
||||
let mut delivered: std::collections::HashMap<u32, Vec<u8>> =
|
||||
std::collections::HashMap::new();
|
||||
for (_, _, p) in &all {
|
||||
if let Some(f) = r.push(p, coder.as_ref(), &stats).unwrap() {
|
||||
prop_assert!(f.complete);
|
||||
prop_assert!(delivered.insert(f.frame_index, f.data).is_none(),
|
||||
"a frame must deliver exactly once");
|
||||
}
|
||||
}
|
||||
for (i, src) in &sources {
|
||||
let got = delivered.get(i);
|
||||
prop_assert!(got.is_some(), "frame {i} must be DELIVERED, not merely error-free");
|
||||
prop_assert_eq!(got.unwrap(), src, "frame {} must be byte-identical", i);
|
||||
}
|
||||
prop_assert_eq!(r.in_flight(), 0, "budget must be exact after all frames terminate");
|
||||
prop_assert_eq!(stats.snapshot().frames_dropped, 0u64);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,17 @@ pub const CLIENT_CAP_CURSOR: u8 = 0x01;
|
||||
/// simply ignored — no behavior change in either direction.
|
||||
pub const CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
|
||||
|
||||
/// `Hello.client_caps` bit: this client can decode the redundant desktop-audio plane
|
||||
/// ([`AUDIO_RED_MAGIC`](super::datagram::AUDIO_RED_MAGIC), `0xD2`), where every datagram also
|
||||
/// carries a copy of the previous frame so a single lost packet is reconstructed instead of
|
||||
/// papered over with packet-loss concealment.
|
||||
///
|
||||
/// Active only when the host answers with [`HOST_CAP_AUDIO_RED`] (capable-and-agreed, the
|
||||
/// cursor/clipboard precedent). Toward an older host, or a host that declines because the link is
|
||||
/// clean, the client keeps receiving the plain `0xC9` plane — so a client may always set this bit.
|
||||
/// `0x04` — `0x01`/`0x02` are cursor / phase-lock.
|
||||
pub const CLIENT_CAP_AUDIO_RED: u8 = 0x04;
|
||||
|
||||
/// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
|
||||
/// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
|
||||
/// whose capture carries no cursor, and NOT Windows yet, where DWM composites into the IDD
|
||||
@@ -132,6 +143,18 @@ pub const HOST_CAP_CURSOR: u8 = 0x08;
|
||||
/// [`HOST_CAP_TEXT_INPUT`], `0x01`/`0x02` are gamepad-state / clipboard.
|
||||
pub const HOST_CAP_PEN: u8 = 0x10;
|
||||
|
||||
/// [`Welcome::host_caps`] bit: the host is sending the REDUNDANT desktop-audio plane
|
||||
/// ([`AUDIO_RED_MAGIC`](super::datagram::AUDIO_RED_MAGIC), `0xD2`) instead of plain `0xC9` — each
|
||||
/// datagram carries its own frame plus a copy of the previous one.
|
||||
///
|
||||
/// Set only when the client asked via [`CLIENT_CAP_AUDIO_RED`]. It is a statement about the WIRE,
|
||||
/// not a negotiation the client can decline: with the bit set the client must decode `0xD2`, and
|
||||
/// without it `0xC9`. The host may also drop back to `0xC9` mid-session (the redundancy is
|
||||
/// loss-gated — a clean LAN shouldn't pay for it), which is why clients decode BOTH tags
|
||||
/// unconditionally and treat this bit as "expect redundancy", not "only redundancy".
|
||||
/// `0x20` — `0x10` is [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`].
|
||||
pub const HOST_CAP_AUDIO_RED: u8 = 0x20;
|
||||
|
||||
/// [`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.
|
||||
|
||||
@@ -55,6 +55,36 @@ pub struct RfiRequest {
|
||||
pub last_frame: u32,
|
||||
}
|
||||
|
||||
/// `host → client`, any time after [`Start`]: the video data plane's sealed shard payload
|
||||
/// changes mid-session (design/shard-payload-reneg.md Phase 1). Sent ONLY to a client whose
|
||||
/// [`Hello::max_shard_payload`] advertised per-frame geometry (0/absent = legacy — the host
|
||||
/// must never send this), and never above that advertised ceiling. Asymmetric semantics:
|
||||
///
|
||||
/// - **Shrink** (the mid-session MTU heal): the host may re-key its packetizer at the next
|
||||
/// AU boundary immediately after sending — per-frame pinning on the client makes the
|
||||
/// control-vs-datagram reorder race irrelevant and a smaller shard always fits existing
|
||||
/// buffers. The [`ShardPayloadAck`] is telemetry.
|
||||
/// - **Grow** (jumbo): the host must not emit a single sealed datagram above the OLD size
|
||||
/// until the ack arrives — the ack IS the gate, even when the client's buffers would
|
||||
/// happen to fit (the rule must not erode if the buffer strategy changes later).
|
||||
///
|
||||
/// No `effective_frame_index`: per-frame pinning makes it redundant — every video packet
|
||||
/// carries its own `shard_bytes` and the receiver follows each frame's pin.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ShardPayloadChanged {
|
||||
/// The new sealed shard payload in bytes (even, within the client's advertised bounds).
|
||||
pub shard_payload: u16,
|
||||
}
|
||||
|
||||
/// `client → host`: answer to [`ShardPayloadChanged`] — echoes the value the client applied.
|
||||
/// Only sent for an in-bounds request; an out-of-bounds one is dropped WITHOUT an ack (a
|
||||
/// buggy host must not read silence-then-garbage as a granted grow). The host treats the
|
||||
/// echoed value as the grant for a pending grow.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ShardPayloadAck {
|
||||
pub shard_payload: u16,
|
||||
}
|
||||
|
||||
/// `client → host`, periodic: the client's observed data-plane loss, so the host can size FEC to
|
||||
/// the link instead of a flat percentage (adaptive FEC). `loss_ppm` is parts-per-million of shards
|
||||
/// that arrived missing-but-recovered (plus a bump when frames went unrecoverable) over the report
|
||||
@@ -200,6 +230,10 @@ pub const MSG_SET_BITRATE: u8 = 0x05;
|
||||
pub const MSG_BITRATE_CHANGED: u8 = 0x06;
|
||||
/// Type byte of [`RfiRequest`].
|
||||
pub const MSG_RFI_REQUEST: u8 = 0x07;
|
||||
/// Type byte of [`ShardPayloadChanged`].
|
||||
pub const MSG_SHARD_PAYLOAD_CHANGED: u8 = 0x08;
|
||||
/// Type byte of [`ShardPayloadAck`].
|
||||
pub const MSG_SHARD_PAYLOAD_ACK: u8 = 0x09;
|
||||
/// Type byte of [`ProbeRequest`].
|
||||
pub const MSG_PROBE_REQUEST: u8 = 0x20;
|
||||
/// Type byte of [`ProbeResult`].
|
||||
@@ -306,6 +340,46 @@ impl RfiRequest {
|
||||
}
|
||||
}
|
||||
|
||||
impl ShardPayloadChanged {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] shard_payload[5..7]
|
||||
let mut b = Vec::with_capacity(7);
|
||||
b.extend_from_slice(CTL_MAGIC);
|
||||
b.push(MSG_SHARD_PAYLOAD_CHANGED);
|
||||
b.extend_from_slice(&self.shard_payload.to_le_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
pub fn decode(b: &[u8]) -> Result<ShardPayloadChanged> {
|
||||
if b.len() != 7 || &b[0..4] != CTL_MAGIC || b[4] != MSG_SHARD_PAYLOAD_CHANGED {
|
||||
return Err(PunktfunkError::InvalidArg("bad ShardPayloadChanged"));
|
||||
}
|
||||
Ok(ShardPayloadChanged {
|
||||
shard_payload: u16::from_le_bytes(b[5..7].try_into().unwrap()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ShardPayloadAck {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] shard_payload[5..7]
|
||||
let mut b = Vec::with_capacity(7);
|
||||
b.extend_from_slice(CTL_MAGIC);
|
||||
b.push(MSG_SHARD_PAYLOAD_ACK);
|
||||
b.extend_from_slice(&self.shard_payload.to_le_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
pub fn decode(b: &[u8]) -> Result<ShardPayloadAck> {
|
||||
if b.len() != 7 || &b[0..4] != CTL_MAGIC || b[4] != MSG_SHARD_PAYLOAD_ACK {
|
||||
return Err(PunktfunkError::InvalidArg("bad ShardPayloadAck"));
|
||||
}
|
||||
Ok(ShardPayloadAck {
|
||||
shard_payload: u16::from_le_bytes(b[5..7].try_into().unwrap()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl LossReport {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] loss_ppm[5..9]
|
||||
@@ -1146,6 +1220,33 @@ mod tests {
|
||||
assert!(SetBitrate::decode(&LossReport { loss_ppm: 7 }.encode()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shard_payload_messages_roundtrip() {
|
||||
for shard_payload in [512u16, 1216, 1408, 8908] {
|
||||
let chg = ShardPayloadChanged { shard_payload };
|
||||
assert_eq!(ShardPayloadChanged::decode(&chg.encode()).unwrap(), chg);
|
||||
let ack = ShardPayloadAck { shard_payload };
|
||||
assert_eq!(ShardPayloadAck::decode(&ack.encode()).unwrap(), ack);
|
||||
// Identical payload shape — the type byte alone must keep the pair disjoint (a
|
||||
// change echoed back must never re-decode as a change).
|
||||
assert!(ShardPayloadChanged::decode(&ack.encode()).is_err());
|
||||
assert!(ShardPayloadAck::decode(&chg.encode()).is_err());
|
||||
}
|
||||
// Exact length — no trailing bytes, no truncation.
|
||||
let bytes = ShardPayloadChanged { shard_payload: 512 }.encode();
|
||||
assert!(ShardPayloadChanged::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
|
||||
assert!(ShardPayloadChanged::decode(&bytes[..bytes.len() - 1]).is_err());
|
||||
// Disjoint from the neighboring ids either side (0x07 RfiRequest / 0x20 ProbeRequest).
|
||||
assert!(ShardPayloadChanged::decode(
|
||||
&RfiRequest {
|
||||
first_frame: 1,
|
||||
last_frame: 2
|
||||
}
|
||||
.encode()
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_messages_roundtrip() {
|
||||
let req = ProbeRequest {
|
||||
|
||||
@@ -42,6 +42,80 @@ pub fn decode_audio_datagram(b: &[u8]) -> Option<(u32, u64, &[u8])> {
|
||||
Some((seq, pts_ns, &b[13..]))
|
||||
}
|
||||
|
||||
/// Redundant audio datagram, host → client: the [`AUDIO_MAGIC`] plane plus a copy of the PREVIOUS
|
||||
/// frame, so a single lost datagram is *reconstructed* rather than concealed.
|
||||
///
|
||||
/// `[0xD2][u32 seq LE][u64 pts_ns LE][u16 primary_len LE][primary opus][previous opus]`
|
||||
///
|
||||
/// **Why this and not Opus in-band FEC.** LBRR is a SILK-layer feature: the desktop-audio encoder
|
||||
/// runs `RESTRICTED_LOWDELAY` (CELT-only) at 5 ms frames, which is below SILK's 10 ms minimum, so
|
||||
/// `set_inband_fec(true)` on that encoder is a no-op. Nothing in libopus can protect this plane —
|
||||
/// the redundancy has to be at the application layer. (The mic uplink is a different encoder, VoIP
|
||||
/// mode at 10 ms, and *does* use real in-band FEC.)
|
||||
///
|
||||
/// **Why it costs no latency.** The copy rides the SUCCESSOR of the frame it protects, and the
|
||||
/// client is already holding 15–90 ms of de-jitter buffer — far more than the 5 ms the successor
|
||||
/// takes to arrive. So the recovery happens inside slack that already exists.
|
||||
///
|
||||
/// The previous frame's sequence is implicitly `seq - 1`; a host with nothing to duplicate yet
|
||||
/// (the first frame of a session, or straight after a capture reopen) simply sends an empty tail,
|
||||
/// which decodes to `None`.
|
||||
///
|
||||
/// Sent ONLY when the client advertised [`CLIENT_CAP_AUDIO_RED`](super::caps::CLIENT_CAP_AUDIO_RED)
|
||||
/// and the host answered [`HOST_CAP_AUDIO_RED`](super::caps::HOST_CAP_AUDIO_RED) — the
|
||||
/// capable-and-agreed handshake the cursor and 4:4:4 planes already use. Every other session keeps
|
||||
/// the plain [`AUDIO_MAGIC`] wire byte-for-byte.
|
||||
///
|
||||
/// NB `0xD1` is deliberately skipped: the DualSense pad-audio program has reserved it for the
|
||||
/// per-pad audio plane.
|
||||
pub const AUDIO_RED_MAGIC: u8 = 0xD2;
|
||||
|
||||
/// Fixed header length of an [`AUDIO_RED_MAGIC`] datagram (tag + seq + pts + primary length).
|
||||
pub const AUDIO_RED_HEADER: usize = 1 + 4 + 8 + 2;
|
||||
|
||||
/// Encode a redundant audio datagram. `prev` is the immediately-preceding frame's Opus payload
|
||||
/// (empty when there is none yet).
|
||||
pub fn encode_audio_red_datagram(seq: u32, pts_ns: u64, opus: &[u8], prev: &[u8]) -> Vec<u8> {
|
||||
let mut b = Vec::with_capacity(AUDIO_RED_HEADER + opus.len() + prev.len());
|
||||
b.push(AUDIO_RED_MAGIC);
|
||||
b.extend_from_slice(&seq.to_le_bytes());
|
||||
b.extend_from_slice(&pts_ns.to_le_bytes());
|
||||
// A frame longer than u16::MAX cannot occur (5 ms of Opus is tens of bytes; the buffer the
|
||||
// encoder writes into is 4 KiB) — but truncating silently would desync the split, so clamp
|
||||
// the redundancy off instead of the primary.
|
||||
let primary_len = u16::try_from(opus.len()).unwrap_or(u16::MAX);
|
||||
b.extend_from_slice(&primary_len.to_le_bytes());
|
||||
b.extend_from_slice(opus);
|
||||
if opus.len() == primary_len as usize {
|
||||
b.extend_from_slice(prev);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// Parse a redundant audio datagram → `(seq, pts_ns, primary, previous)`. `previous` is `None`
|
||||
/// when the host had nothing to duplicate. `None` overall on bad tag/length, including a
|
||||
/// `primary_len` that overruns the datagram (a truncated or hostile packet must not panic).
|
||||
///
|
||||
/// The tuple shape deliberately mirrors [`decode_audio_datagram`] (one extra slot for the
|
||||
/// redundant copy) so the two planes read the same at every call site; a named struct here would
|
||||
/// be the odd one out on this module's decode surface, and cbindgen would then have to be taught
|
||||
/// to skip it.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn decode_audio_red_datagram(b: &[u8]) -> Option<(u32, u64, &[u8], Option<&[u8]>)> {
|
||||
if b.len() < AUDIO_RED_HEADER || b[0] != AUDIO_RED_MAGIC {
|
||||
return None;
|
||||
}
|
||||
let seq = u32::from_le_bytes(b[1..5].try_into().unwrap());
|
||||
let pts_ns = u64::from_le_bytes(b[5..13].try_into().unwrap());
|
||||
let primary_len = u16::from_le_bytes(b[13..15].try_into().unwrap()) as usize;
|
||||
let rest = &b[AUDIO_RED_HEADER..];
|
||||
if primary_len > rest.len() {
|
||||
return None; // truncated: the split point is outside the datagram
|
||||
}
|
||||
let (primary, prev) = rest.split_at(primary_len);
|
||||
Some((seq, pts_ns, primary, (!prev.is_empty()).then_some(prev)))
|
||||
}
|
||||
|
||||
/// Legacy rumble datagram (v1), host → client: `[0xCA][u16 pad LE][u16 low LE][u16 high LE]`.
|
||||
/// Force-feedback state for pad `pad` (0xFFFF amplitudes, 0/0 = stop) as *level-triggered* state
|
||||
/// — it persists until superseded, which is why the host re-sends it periodically as its loss
|
||||
@@ -806,6 +880,8 @@ mod tests {
|
||||
#[test]
|
||||
fn audio_datagram_roundtrip() {
|
||||
let opus = [0x42u8; 97];
|
||||
let d = encode_audio_red_datagram(7, 42, &opus, &[]);
|
||||
assert_eq!(d[0], AUDIO_RED_MAGIC);
|
||||
let d = encode_audio_datagram(7, 1_000_000_123, &opus);
|
||||
assert_eq!(d[0], AUDIO_MAGIC);
|
||||
let (seq, pts, payload) = decode_audio_datagram(&d).unwrap();
|
||||
@@ -820,6 +896,83 @@ mod tests {
|
||||
assert!(empty.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_red_datagram_roundtrip() {
|
||||
let cur = [0x42u8; 97];
|
||||
let prev = [0x37u8; 88];
|
||||
let d = encode_audio_red_datagram(7, 1_000_000_123, &cur, &prev);
|
||||
assert_eq!(d[0], AUDIO_RED_MAGIC);
|
||||
let (seq, pts, primary, previous) = decode_audio_red_datagram(&d).unwrap();
|
||||
assert_eq!((seq, pts), (7, 1_000_000_123));
|
||||
assert_eq!(primary, cur);
|
||||
assert_eq!(previous, Some(&prev[..]));
|
||||
|
||||
// No predecessor yet (first frame of a session / after a capture reopen).
|
||||
let d = encode_audio_red_datagram(0, 5, &cur, &[]);
|
||||
let (_, _, primary, previous) = decode_audio_red_datagram(&d).unwrap();
|
||||
assert_eq!(primary, cur);
|
||||
assert_eq!(
|
||||
previous, None,
|
||||
"an empty tail must decode as absent, not as a zero-length frame"
|
||||
);
|
||||
|
||||
// Frames of equal length must still split at the right place — the length prefix is the
|
||||
// only thing that can tell them apart.
|
||||
let a = [1u8; 64];
|
||||
let b = [2u8; 64];
|
||||
let d = encode_audio_red_datagram(9, 0, &a, &b);
|
||||
let (_, _, primary, previous) = decode_audio_red_datagram(&d).unwrap();
|
||||
assert_eq!(primary, a);
|
||||
assert_eq!(previous, Some(&b[..]));
|
||||
}
|
||||
|
||||
/// A truncated or hostile `0xD2` must be rejected, never panic — the split point comes off
|
||||
/// the wire, so an over-long `primary_len` is the obvious attack on `split_at`.
|
||||
#[test]
|
||||
fn audio_red_datagram_rejects_bad_input() {
|
||||
let d = encode_audio_red_datagram(1, 2, &[0xAAu8; 30], &[0xBBu8; 20]);
|
||||
for n in 0..AUDIO_RED_HEADER {
|
||||
assert!(decode_audio_red_datagram(&d[..n]).is_none(), "len {n}");
|
||||
}
|
||||
// primary_len larger than the datagram: must be refused, not sliced.
|
||||
let mut bad = d.clone();
|
||||
bad[13..15].copy_from_slice(&u16::MAX.to_le_bytes());
|
||||
assert!(decode_audio_red_datagram(&bad).is_none());
|
||||
// Wrong tag.
|
||||
let mut wrong = d.clone();
|
||||
wrong[0] = AUDIO_MAGIC;
|
||||
assert!(decode_audio_red_datagram(&wrong).is_none());
|
||||
}
|
||||
|
||||
/// The two audio planes must not alias each other or any neighbouring plane: a client
|
||||
/// demultiplexes purely on the first byte.
|
||||
#[test]
|
||||
fn audio_red_tag_is_disjoint() {
|
||||
for other in [
|
||||
AUDIO_MAGIC,
|
||||
RUMBLE_MAGIC,
|
||||
MIC_MAGIC,
|
||||
RICH_INPUT_MAGIC,
|
||||
HIDOUT_MAGIC,
|
||||
HDR_META_MAGIC,
|
||||
HOST_TIMING_MAGIC,
|
||||
CURSOR_STATE_MAGIC,
|
||||
crate::input::INPUT_MAGIC,
|
||||
] {
|
||||
assert_ne!(AUDIO_RED_MAGIC, other);
|
||||
}
|
||||
let red = encode_audio_red_datagram(1, 2, &[9u8; 40], &[8u8; 40]);
|
||||
assert!(
|
||||
decode_audio_datagram(&red).is_none(),
|
||||
"0xC9 must not accept a 0xD2"
|
||||
);
|
||||
let plain = encode_audio_datagram(1, 2, &[9u8; 40]);
|
||||
assert!(
|
||||
decode_audio_red_datagram(&plain).is_none(),
|
||||
"0xD2 must not accept a 0xC9"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rumble_datagram_roundtrip() {
|
||||
let d = encode_rumble_datagram(1, 0x1234, 0xFFFF);
|
||||
|
||||
@@ -47,6 +47,38 @@ fn stream_transport_idle(idle: std::time::Duration) -> Arc<quinn::TransportConfi
|
||||
// plane latest-wins at the source — ~200 ms of stereo Opus (proportionally less at
|
||||
// surround bitrates), so sustained congestion costs concealable drops, never lag.
|
||||
t.datagram_send_buffer_size(4 * 1024);
|
||||
// MTU discovery probes up to EXACTLY the sealed size of a full IPv4 video datagram (1472)
|
||||
// instead of quinn's stock 1452. Two reasons: (a) on a clean 1500-MTU path QUIC gets the
|
||||
// last 20 bytes per packet; (b) the ceiling turns discovery into a video-path verdict the
|
||||
// host's wire-MTU watcher reads (`punktfunk-host` `native/wire_mtu.rs`) — settled == ceiling
|
||||
// proves the path carries full-size video datagrams, settled BELOW it proves it cannot (a
|
||||
// VPN/overlay adapter at MTU ~1280 blackholes every video packet while all the small flows
|
||||
// pass: the "connects fine, black screen forever" field shape). With the stock 1452 ceiling
|
||||
// a healthy path and a constrained one are indistinguishable at the top. This is the ONLY
|
||||
// behavioral change on healthy paths, and it's confined to discovery: probes are padded
|
||||
// PINGs quinn already expects to lose above a constrained hop — a lost probe settles the
|
||||
// search lower, exactly as it did before.
|
||||
let mut mtud = quinn::MtuDiscoveryConfig::default();
|
||||
// Jumbo opt-in (design/shard-payload-reneg.md Phase 2): with `PUNKTFUNK_JUMBO=1` /
|
||||
// `PUNKTFUNK_WIRE_MTU` > 1500 set, discovery probes up to the sealed JUMBO datagram
|
||||
// size so a settled connection can PROVE a jumbo path — the actual grow stays
|
||||
// client-ack-gated (`native/wire_mtu.rs`). The ceiling is per-ENDPOINT, not
|
||||
// per-connection: with the opt-in set, connections to non-jumbo peers spend a few extra
|
||||
// failed probes (one PTO each) settling lower; zero cost for anyone who doesn't opt in.
|
||||
// Derived with the IPv4 overhead — a v6 peer's sealed jumbo target is smaller, so the
|
||||
// ceiling covers it and discovery settles at the v6 path's own budget.
|
||||
let probe_ceiling = match crate::config::jumbo_wire_mtu() {
|
||||
Some(mtu) => {
|
||||
let shard = crate::config::jumbo_shard_payload_for(
|
||||
mtu,
|
||||
std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
|
||||
);
|
||||
crate::config::sealed_datagram_bytes(shard) as u16
|
||||
}
|
||||
None => crate::config::video_datagram_udp_ceiling() as u16,
|
||||
};
|
||||
mtud.upper_bound(probe_ceiling);
|
||||
t.mtu_discovery_config(Some(mtud));
|
||||
Arc::new(t)
|
||||
}
|
||||
|
||||
|
||||
@@ -90,8 +90,19 @@ pub struct Hello {
|
||||
/// disambiguated by REMAINING LENGTH at decode: fewer than `HDR_META_BODY_LEN` bytes after
|
||||
/// `preferred_codec` ⇒ no HDR block, the tail bytes are the post-HDR fields directly. This
|
||||
/// caps everything after `display_hdr` at `HDR_META_BODY_LEN − 1` bytes total — document any
|
||||
/// future field here and mind the budget. Omitted when zero and by older clients (→ `0`).
|
||||
/// future field here and mind the budget (`client_caps` 1 + `max_shard_payload` 2 = 3 of the
|
||||
/// 27 spent). Omitted when zero and by older clients (→ `0`).
|
||||
pub client_caps: u8,
|
||||
/// The largest video shard payload this client's receive path accepts — sealed datagrams for
|
||||
/// shards up to this size fit its transport buffers ([`crate::config::max_shard_payload`]).
|
||||
/// One field carries BOTH facts the host needs for mid-session shard renegotiation
|
||||
/// (design/shard-payload-reneg.md W0.3): non-zero ⇒ the client reassembles per-frame
|
||||
/// geometry (a mid-session `shard_payload` change is safe to send), and the value is the
|
||||
/// hard ceiling a jumbo grow may never exceed. Appended after `client_caps` as 2 trailing
|
||||
/// LE bytes (forcing the earlier placeholders). Omitted by older clients (decodes to `0`
|
||||
/// = legacy: the host must not change the sealed geometry mid-session, and never above
|
||||
/// the `Welcome` value).
|
||||
pub max_shard_payload: u16,
|
||||
}
|
||||
|
||||
/// QUIC application error code a punktfunk/1 client closes the control connection with on a
|
||||
@@ -254,12 +265,14 @@ impl Hello {
|
||||
let pref_present = self.preferred_codec != 0;
|
||||
let hdr_present = self.display_hdr.is_some();
|
||||
let ccaps_present = self.client_caps != 0;
|
||||
let msp_present = self.max_shard_payload != 0;
|
||||
let need_placeholders = self.video_caps != 0
|
||||
|| ac_present
|
||||
|| vcodecs_present
|
||||
|| pref_present
|
||||
|| hdr_present
|
||||
|| ccaps_present;
|
||||
|| ccaps_present
|
||||
|| msp_present;
|
||||
match (&self.name, &self.launch) {
|
||||
(None, None) if !need_placeholders => {}
|
||||
(name, _) => {
|
||||
@@ -280,15 +293,21 @@ impl Hello {
|
||||
b.push(self.video_caps);
|
||||
}
|
||||
// audio_channels: emitted when non-stereo OR a later field follows.
|
||||
if ac_present || vcodecs_present || pref_present || hdr_present || ccaps_present {
|
||||
if ac_present
|
||||
|| vcodecs_present
|
||||
|| pref_present
|
||||
|| hdr_present
|
||||
|| ccaps_present
|
||||
|| msp_present
|
||||
{
|
||||
b.push(self.audio_channels);
|
||||
}
|
||||
// video_codecs: emitted when non-zero OR a later field follows.
|
||||
if vcodecs_present || pref_present || hdr_present || ccaps_present {
|
||||
if vcodecs_present || pref_present || hdr_present || ccaps_present || msp_present {
|
||||
b.push(self.video_codecs);
|
||||
}
|
||||
// preferred_codec: emitted when non-zero OR a later field follows.
|
||||
if pref_present || hdr_present || ccaps_present {
|
||||
if pref_present || hdr_present || ccaps_present || msp_present {
|
||||
b.push(self.preferred_codec);
|
||||
}
|
||||
// display_hdr: fixed HDR_META_BODY_LEN-byte HdrMeta body; omitted when `None` even if
|
||||
@@ -297,10 +316,15 @@ impl Hello {
|
||||
if let Some(m) = &self.display_hdr {
|
||||
super::datagram::write_hdr_meta_body(m, &mut b);
|
||||
}
|
||||
// client_caps: single byte after the (optional) HDR block. Emitted when non-zero.
|
||||
if ccaps_present {
|
||||
// client_caps: single byte after the (optional) HDR block. Emitted when non-zero OR a
|
||||
// later field follows.
|
||||
if ccaps_present || msp_present {
|
||||
b.push(self.client_caps);
|
||||
}
|
||||
// max_shard_payload: 2 trailing LE bytes after client_caps. Emitted when non-zero.
|
||||
if msp_present {
|
||||
b.extend_from_slice(&self.max_shard_payload.to_le_bytes());
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
@@ -386,6 +410,19 @@ impl Hello {
|
||||
};
|
||||
b.get(off).copied().unwrap_or(0)
|
||||
},
|
||||
// max_shard_payload: 2 LE bytes after client_caps (same post-HDR offset rule).
|
||||
// Absent on an older client → 0 = no mid-session renegotiation, no jumbo.
|
||||
max_shard_payload: {
|
||||
let off = if b.len().saturating_sub(tail + 4) >= super::datagram::HDR_META_BODY_LEN
|
||||
{
|
||||
tail + 4 + super::datagram::HDR_META_BODY_LEN
|
||||
} else {
|
||||
tail + 4
|
||||
};
|
||||
b.get(off + 1..off + 3)
|
||||
.map(|s| u16::from_le_bytes(s.try_into().unwrap()))
|
||||
.unwrap_or(0)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -867,6 +904,7 @@ mod tests {
|
||||
preferred_codec: CODEC_H264,
|
||||
display_hdr: None,
|
||||
client_caps: 0,
|
||||
max_shard_payload: 0,
|
||||
};
|
||||
let enc = h.encode();
|
||||
let dec = Hello::decode(&enc).unwrap();
|
||||
@@ -944,6 +982,7 @@ mod tests {
|
||||
preferred_codec: CODEC_HEVC,
|
||||
display_hdr: None,
|
||||
client_caps: 0,
|
||||
max_shard_payload: 0,
|
||||
};
|
||||
assert_eq!(Hello::decode(&h.encode()).unwrap(), h);
|
||||
let s = Start {
|
||||
@@ -975,6 +1014,7 @@ mod tests {
|
||||
preferred_codec: 0,
|
||||
display_hdr: None,
|
||||
client_caps: 0,
|
||||
max_shard_payload: 0,
|
||||
};
|
||||
let enc = h.encode();
|
||||
assert_eq!(enc.len(), 26);
|
||||
@@ -1093,6 +1133,7 @@ mod tests {
|
||||
preferred_codec: 0,
|
||||
display_hdr: None,
|
||||
client_caps: 0,
|
||||
max_shard_payload: 0,
|
||||
};
|
||||
let enc = base.encode();
|
||||
assert_eq!(
|
||||
@@ -1145,6 +1186,7 @@ mod tests {
|
||||
preferred_codec: 0,
|
||||
display_hdr: None,
|
||||
client_caps: 0,
|
||||
max_shard_payload: 0,
|
||||
};
|
||||
// launch alone (no name): a zero-length name placeholder keeps the offset deterministic.
|
||||
let with_launch = Hello {
|
||||
@@ -1205,6 +1247,7 @@ mod tests {
|
||||
preferred_codec: 0,
|
||||
display_hdr: None,
|
||||
client_caps: 0,
|
||||
max_shard_payload: 0,
|
||||
};
|
||||
// A real client-panel volume (P3 primaries, 800-nit peak, 0.05-nit floor, 400-nit FALL).
|
||||
let vol = HdrMeta {
|
||||
@@ -1273,6 +1316,7 @@ mod tests {
|
||||
preferred_codec: 0,
|
||||
display_hdr: None,
|
||||
client_caps: 0,
|
||||
max_shard_payload: 0,
|
||||
}
|
||||
.encode();
|
||||
assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair");
|
||||
@@ -1306,6 +1350,7 @@ mod tests {
|
||||
preferred_codec: 0,
|
||||
display_hdr: None,
|
||||
client_caps: 0,
|
||||
max_shard_payload: 0,
|
||||
};
|
||||
let vol = HdrMeta {
|
||||
display_primaries: [[13250, 34500], [7500, 3000], [34000, 16000]],
|
||||
@@ -1319,6 +1364,7 @@ mod tests {
|
||||
// fixed block length, so the decoder must NOT read it as a truncated HdrMeta).
|
||||
let caps_only = Hello {
|
||||
client_caps: CLIENT_CAP_CURSOR,
|
||||
max_shard_payload: 0,
|
||||
..base.clone()
|
||||
};
|
||||
assert_eq!(Hello::decode(&caps_only.encode()).unwrap(), caps_only);
|
||||
@@ -1326,6 +1372,7 @@ mod tests {
|
||||
let both = Hello {
|
||||
display_hdr: Some(vol),
|
||||
client_caps: CLIENT_CAP_CURSOR,
|
||||
max_shard_payload: 0,
|
||||
..base.clone()
|
||||
};
|
||||
assert_eq!(Hello::decode(&both.encode()).unwrap(), both);
|
||||
@@ -1344,8 +1391,73 @@ mod tests {
|
||||
Hello::decode(&enc[..enc.len() - 1]).unwrap(),
|
||||
Hello {
|
||||
client_caps: 0,
|
||||
max_shard_payload: 0,
|
||||
..both.clone()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// `max_shard_payload` (mid-session shard renegotiation, design/shard-payload-reneg.md
|
||||
/// W0.3): roundtrips, forces the earlier placeholders (deterministic offset), composes
|
||||
/// with the optional HDR block, and degrades to 0 = legacy in BOTH directions.
|
||||
#[test]
|
||||
fn hello_max_shard_payload_roundtrip_and_back_compat() {
|
||||
let base = Hello {
|
||||
abi_version: 2,
|
||||
mode: Mode {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
refresh_hz: 60,
|
||||
},
|
||||
compositor: CompositorPref::Auto,
|
||||
gamepad: GamepadPref::Auto,
|
||||
bitrate_kbps: 0,
|
||||
name: None,
|
||||
launch: None,
|
||||
video_caps: 0,
|
||||
audio_channels: 2,
|
||||
video_codecs: 0,
|
||||
preferred_codec: 0,
|
||||
display_hdr: None,
|
||||
client_caps: 0,
|
||||
max_shard_payload: 0,
|
||||
};
|
||||
// The advertisement alone: every earlier trailing field is emitted as a placeholder
|
||||
// so the 2 LE bytes land at a deterministic offset — and the whole thing roundtrips.
|
||||
let adv = Hello {
|
||||
max_shard_payload: crate::config::max_shard_payload() as u16,
|
||||
..base.clone()
|
||||
};
|
||||
assert_eq!(Hello::decode(&adv.encode()).unwrap(), adv);
|
||||
// Composes with client_caps AND the fixed HDR block (the remaining-length
|
||||
// disambiguation must still find both fields after it).
|
||||
let vol = HdrMeta {
|
||||
display_primaries: [[13250, 34500], [7500, 3000], [34000, 16000]],
|
||||
white_point: [15635, 16450],
|
||||
max_display_mastering_luminance: 8_000_000,
|
||||
min_display_mastering_luminance: 500,
|
||||
max_cll: 0,
|
||||
max_fall: 400,
|
||||
};
|
||||
let full = Hello {
|
||||
display_hdr: Some(vol),
|
||||
client_caps: CLIENT_CAP_CURSOR,
|
||||
max_shard_payload: 8908,
|
||||
..base.clone()
|
||||
};
|
||||
assert_eq!(Hello::decode(&full.encode()).unwrap(), full);
|
||||
// An older client (no trailing bytes at all) decodes to 0 = legacy: the host must
|
||||
// not change the sealed geometry mid-session.
|
||||
assert_eq!(Hello::decode(&base.encode()).unwrap().max_shard_payload, 0);
|
||||
// An older HOST reading an advertising Hello never looks past the fields it knows —
|
||||
// truncating the 2 trailing bytes yields the same Hello minus the advertisement.
|
||||
let enc = full.encode();
|
||||
assert_eq!(
|
||||
Hello::decode(&enc[..enc.len() - 2]).unwrap(),
|
||||
Hello {
|
||||
max_shard_payload: 0,
|
||||
..full.clone()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -603,6 +603,31 @@ impl Session {
|
||||
self.packetizer.set_fec_percent(pct);
|
||||
}
|
||||
|
||||
/// Host: live-swap the wire shard payload between AUs (mid-session shard renegotiation,
|
||||
/// design/shard-payload-reneg.md). Affects the next sealed AU; call only between AUs
|
||||
/// (never with a `StreamedAu` in flight — see [`Packetizer::set_shard_payload`]). The new
|
||||
/// value must satisfy the exact bounds `Config::validate` imposed on the negotiated one
|
||||
/// (even, > 0, fits a datagram, block count fits the wire) — validated here against a
|
||||
/// probe of the session config. The PROTOCOL side is the caller's contract: a current
|
||||
/// client reassembles any in-bounds size per-frame, but a shrink may be sent immediately
|
||||
/// while a grow must be client-acked and never exceed the client's advertised
|
||||
/// `Hello::max_shard_payload` ceiling.
|
||||
pub fn set_shard_payload(&mut self, shard_payload: usize) -> Result<()> {
|
||||
if self.config.role != Role::Host {
|
||||
return Err(PunktfunkError::InvalidArg(
|
||||
"set_shard_payload called on a client session",
|
||||
));
|
||||
}
|
||||
// Full `Config::validate` parity, zero drift: probe a copy (its key/salt copies are
|
||||
// zeroized on drop) rather than re-spelling the shard clauses here.
|
||||
let mut probe = self.config.clone();
|
||||
probe.shard_payload = shard_payload;
|
||||
probe.validate()?;
|
||||
self.config.shard_payload = shard_payload;
|
||||
self.packetizer.set_shard_payload(shard_payload);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The current FEC recovery percentage (host side).
|
||||
pub fn fec_percent(&self) -> u8 {
|
||||
self.packetizer.fec_percent()
|
||||
@@ -1060,4 +1085,147 @@ mod wire_equivalence_tests {
|
||||
"unflagged AUs must never be delivered partial"
|
||||
);
|
||||
}
|
||||
|
||||
/// The low-MTU PyroWave guarantee (design/shard-payload-reneg.md): mid-session
|
||||
/// renegotiation is gated OFF for chunk-aligned sessions, so a constrained path serves
|
||||
/// them through the leg-1 SESSION-START clamp instead — the learned budget (or
|
||||
/// `PUNKTFUNK_WIRE_MTU`) sizes `Welcome::shard_payload`, and everything chunk-aligned
|
||||
/// derives from that ONE number fixed at the handshake: the host packetizes at it, the
|
||||
/// client's parse window reads it back ([`Session::shard_payload`] → the C-ABI
|
||||
/// `punktfunk_connection_shard_payload` every embedder walks windows with), and partial
|
||||
/// delivery zero-fills exact windows of it. Pin that consistency at the clamp shapes a
|
||||
/// constrained path actually produces: the WARP/Tailscale budget (1216) and the floor
|
||||
/// (512) — chunk-aligned frames deliver, lose whole windows (never splice), and the
|
||||
/// window arithmetic matches the session value end to end.
|
||||
#[test]
|
||||
fn chunk_aligned_sessions_work_at_clamped_shard_sizes() {
|
||||
use crate::packet::USER_FLAG_CHUNK_ALIGNED;
|
||||
for shard in [1216usize, crate::config::MIN_SHARD_PAYLOAD] {
|
||||
let mk = |role| Config {
|
||||
role,
|
||||
phase: ProtocolPhase::P2Punktfunk,
|
||||
fec: FecConfig {
|
||||
scheme: FecScheme::Gf16,
|
||||
fec_percent: 0, // no parity — any drop leaves a hole
|
||||
max_data_per_block: 64,
|
||||
},
|
||||
shard_payload: shard,
|
||||
max_frame_bytes: 8 * 1024 * 1024,
|
||||
encrypt: true,
|
||||
key: SessionKey::Aes128Gcm([7u8; 16]),
|
||||
salt: [3, 1, 4, 1],
|
||||
loopback_drop_period: 0,
|
||||
};
|
||||
let (h, c) = crate::transport::loopback_pair(3, 1);
|
||||
let mut host = Session::new(mk(Role::Host), Box::new(h)).unwrap();
|
||||
let mut client = Session::new(mk(Role::Client), Box::new(c)).unwrap();
|
||||
client.set_deliver_partial_frames(true);
|
||||
// The window every embedder parses with IS the clamped session value.
|
||||
assert_eq!(client.shard_payload(), shard);
|
||||
assert_eq!(host.shard_payload(), shard);
|
||||
|
||||
let frame = pattern(8 * shard);
|
||||
host.submit_frame(&frame, 1_000, USER_FLAG_CHUNK_ALIGNED)
|
||||
.unwrap();
|
||||
let mut got_partial = None;
|
||||
let mut completes = 0;
|
||||
for i in 0..80u64 {
|
||||
host.submit_frame(&pattern(shard), 2_000 + i, USER_FLAG_CHUNK_ALIGNED)
|
||||
.unwrap();
|
||||
loop {
|
||||
match client.poll_frame() {
|
||||
Ok(f) if !f.complete => got_partial = Some(f),
|
||||
Ok(_) => completes += 1,
|
||||
Err(PunktfunkError::NoFrame) => break,
|
||||
Err(e) => panic!("shard {shard}: unexpected: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
let p = got_partial.expect("the lossy frame must be delivered partial");
|
||||
assert_eq!(p.data.len(), frame.len(), "shard {shard}");
|
||||
// Loss lands on exact `shard`-sized window boundaries: zeroed windows for the
|
||||
// dropped datagrams, byte-identical survivors — nothing spliced across windows.
|
||||
let mut zero_windows = 0;
|
||||
for w in 0..8 {
|
||||
let win = &p.data[w * shard..(w + 1) * shard];
|
||||
if win.iter().all(|&b| b == 0) {
|
||||
zero_windows += 1;
|
||||
} else {
|
||||
assert_eq!(
|
||||
win,
|
||||
&frame[w * shard..(w + 1) * shard],
|
||||
"shard {shard}: window {w} corrupt"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
(1..8).contains(&zero_windows),
|
||||
"shard {shard}: dropped shards zero-filled (got {zero_windows})"
|
||||
);
|
||||
assert!(
|
||||
completes > 40,
|
||||
"shard {shard}: surviving filler frames flow normally"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mid-session shard renegotiation end to end over the SEALED loopback wire
|
||||
/// (design/shard-payload-reneg.md): one host session re-keys its packetizer between AUs
|
||||
/// — shrink, jumbo grow, revert — through one continuous crypto/replay stream, and one
|
||||
/// client session must DELIVER every frame byte-identically (the vacuous-green lesson:
|
||||
/// assert delivered frames, never the absence of errors).
|
||||
#[test]
|
||||
fn mid_session_shard_swap_delivers_frames_over_the_sealed_wire() {
|
||||
let mk = |role: Role| {
|
||||
let mut c = host_cfg(FecScheme::Gf16, 20, true);
|
||||
c.role = role;
|
||||
c.shard_payload = 1408;
|
||||
c.fec.max_data_per_block = 64;
|
||||
c
|
||||
};
|
||||
let (ht, ct) = loopback_pair(0, 0);
|
||||
let mut host = Session::new(mk(Role::Host), Box::new(ht)).unwrap();
|
||||
let mut client = Session::new(mk(Role::Client), Box::new(ct)).unwrap();
|
||||
|
||||
let phases: [(usize, &[usize]); 4] = [
|
||||
(1408, &[3000, 3 * 1408]), // the negotiated default (incl. exact multiple)
|
||||
(512, &[2000, 5 * 512 + 17]), // shrink — the mid-session VPN heal
|
||||
(8908, &[100_000]), // grow — jumbo on a 9000-MTU LAN
|
||||
(1216, &[2 * 1216 + 9]), // revert — a mis-proven jumbo hop self-corrects
|
||||
];
|
||||
let mut pts = 0u64;
|
||||
let mut delivered = 0usize;
|
||||
for (shard, lens) in phases {
|
||||
host.set_shard_payload(shard).unwrap();
|
||||
assert_eq!(host.shard_payload(), shard);
|
||||
for &len in lens {
|
||||
pts += 1_000_000;
|
||||
let src = pattern(len);
|
||||
host.submit_frame(&src, pts, 0).unwrap();
|
||||
let f = client
|
||||
.poll_frame()
|
||||
.unwrap_or_else(|e| panic!("shard {shard}: frame must be DELIVERED ({e})"));
|
||||
assert_eq!(
|
||||
f.data, src,
|
||||
"shard {shard}: {len} B frame must be byte-identical"
|
||||
);
|
||||
assert!(f.complete);
|
||||
delivered += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(delivered, 6, "every submitted frame must be delivered");
|
||||
// The setter is host-side machinery: a client session must refuse it, and an
|
||||
// invalid size (odd / oversized) must be rejected without touching the live config.
|
||||
assert!(client.set_shard_payload(1408).is_err());
|
||||
assert!(
|
||||
host.set_shard_payload(1407).is_err(),
|
||||
"odd must be rejected"
|
||||
);
|
||||
assert!(
|
||||
host.set_shard_payload(crate::config::max_shard_payload() + 2)
|
||||
.is_err(),
|
||||
"oversized must be rejected"
|
||||
);
|
||||
assert_eq!(host.shard_payload(), 1216, "failed swaps must not stick");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +192,11 @@ mod wasapi_mic;
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
#[path = "audio/wiring_plan.rs"]
|
||||
pub(crate) mod wiring_plan;
|
||||
// Pure capture-loop policy, split out for the same reason `wiring_plan` is: it encodes field
|
||||
// behaviour, so its tests must run on every platform's CI, not only Windows.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
#[path = "audio/capture_policy.rs"]
|
||||
pub(crate) mod capture_policy;
|
||||
|
||||
mod mic_jitter;
|
||||
mod mic_pump;
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
//! Desktop-audio capture POLICY — the parts of [`wasapi_cap`](super::wasapi_cap) that are pure
|
||||
//! decisions rather than WASAPI plumbing, split out for the same reason
|
||||
//! [`wiring_plan`](super::wiring_plan) is: so they compile and their unit tests RUN on every
|
||||
//! platform. Both of these encode field-report behaviour, and regressing either must fail CI on
|
||||
//! Linux too, not only on a Windows box.
|
||||
//!
|
||||
//! * [`FightDamper`] — how hard to fight another program for the default playback device.
|
||||
//! * [`CaptureStats`] — the audio plane's vitals, so a log can tell a quiet host from a broken
|
||||
//! endpoint from one we are damaging ourselves.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Default-playback re-assertions inside [`FIGHT_WINDOW`] before we stop fighting.
|
||||
pub(crate) const FIGHT_LIMIT: u32 = 4;
|
||||
pub(crate) const FIGHT_WINDOW: Duration = Duration::from_secs(20);
|
||||
/// How long to leave the default alone once another program has proven it will take it back.
|
||||
pub(crate) const FIGHT_BACKOFF: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Damping for the default-playback tug-of-war (WP2.4).
|
||||
///
|
||||
/// The 2026-08-03 field log recorded seven full re-assert cycles in sixteen seconds — something on
|
||||
/// that box re-set the default playback to CABLE Input every ~4 s and we snapped it back every
|
||||
/// time, each round a capture teardown plus a wiring pass with `IPolicyConfig` writes. Winning that
|
||||
/// argument is not possible and every round was an audible dropout, so: re-assert a few times
|
||||
/// (transient churn does settle), then concede for a minute and say so once.
|
||||
///
|
||||
/// Time is passed IN rather than read here, which keeps the policy pure and testable.
|
||||
pub(crate) struct FightDamper {
|
||||
/// Re-assertions in the current window, and when the window opened.
|
||||
count: u32,
|
||||
window_started: Instant,
|
||||
/// Set while we are deliberately not fighting.
|
||||
paused_until: Option<Instant>,
|
||||
/// One warning per fight burst, and one per concession.
|
||||
warned_fighting: bool,
|
||||
warned_giving_up: bool,
|
||||
now: Instant,
|
||||
}
|
||||
|
||||
impl FightDamper {
|
||||
pub(crate) fn new(now: Instant) -> FightDamper {
|
||||
FightDamper {
|
||||
count: 0,
|
||||
window_started: now,
|
||||
paused_until: None,
|
||||
warned_fighting: false,
|
||||
warned_giving_up: false,
|
||||
now,
|
||||
}
|
||||
}
|
||||
|
||||
/// A dud default-device change was observed at `now`.
|
||||
pub(crate) fn observed_at(&mut self, now: Instant) {
|
||||
self.now = now;
|
||||
if now.duration_since(self.window_started) >= FIGHT_WINDOW {
|
||||
self.window_started = now;
|
||||
self.count = 0;
|
||||
self.warned_fighting = false;
|
||||
}
|
||||
if self.paused_until.is_some_and(|t| now >= t) {
|
||||
self.paused_until = None;
|
||||
self.warned_giving_up = false;
|
||||
self.count = 0;
|
||||
self.window_started = now;
|
||||
}
|
||||
}
|
||||
|
||||
/// Should we put the default back? False while paused, or once this window's budget is spent.
|
||||
pub(crate) fn should_reassert(&mut self) -> bool {
|
||||
if self.paused_until.is_some() {
|
||||
return false;
|
||||
}
|
||||
if self.count >= FIGHT_LIMIT {
|
||||
self.paused_until = Some(self.now + FIGHT_BACKOFF);
|
||||
return false;
|
||||
}
|
||||
self.count += 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// Warn on the FIRST re-assert of a burst only (the rest are noise).
|
||||
pub(crate) fn warn_now(&mut self) -> bool {
|
||||
!std::mem::replace(&mut self.warned_fighting, true)
|
||||
}
|
||||
|
||||
/// Warn once when we concede.
|
||||
pub(crate) fn warn_giving_up(&mut self) -> bool {
|
||||
self.paused_until.is_some() && !std::mem::replace(&mut self.warned_giving_up, true)
|
||||
}
|
||||
|
||||
/// Currently conceding (test/diagnostic accessor).
|
||||
pub(crate) fn is_paused(&self) -> bool {
|
||||
self.paused_until.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// How often the capture loop reports its vitals (WP0.2).
|
||||
pub(crate) const STATS_EVERY: Duration = Duration::from_secs(30);
|
||||
|
||||
/// One reporting window's worth of capture vitals.
|
||||
///
|
||||
/// The point is to make three states that used to look identical in a log tell themselves apart: a
|
||||
/// genuinely quiet host (`peak` ~0, no drops), a working stream (`peak` > 0), and a stream we are
|
||||
/// damaging ourselves (`dropped_chunks` > 0). The 2026-08-03 field log — 3,600 lines, filed over an
|
||||
/// audio-quality complaint — could distinguish none of them, because the audio plane logged nothing
|
||||
/// at all between "capturing" and the session ending.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct CaptureStats {
|
||||
pub(crate) frames: u64,
|
||||
/// Interleaved SAMPLES seen — the RMS denominator. Deliberately separate from `frames`:
|
||||
/// dividing the sum of squares by the frame count instead inflates RMS by sqrt(channels),
|
||||
/// which made a sine report an RMS equal to its own peak.
|
||||
pub(crate) samples: u64,
|
||||
/// Loudest |sample| in the window — tells a silent endpoint from a working one.
|
||||
pub(crate) peak: f32,
|
||||
/// Sum of squares, for the window's RMS: a level far below peak means a badly attenuated
|
||||
/// endpoint (a parked device sitting at 20 % volume costs ~14 dB before Opus ever sees it).
|
||||
pub(crate) sumsq: f64,
|
||||
/// Chunks the encode thread was too slow to take. Silent data loss, previously uncounted:
|
||||
/// the encoder simply concatenates across the hole, so it is a click AND a permanent shift of
|
||||
/// everything after it.
|
||||
pub(crate) dropped_chunks: u64,
|
||||
}
|
||||
|
||||
impl CaptureStats {
|
||||
pub(crate) fn observe(&mut self, samples: &[f32], channels: u32) {
|
||||
self.frames += (samples.len() / channels.max(1) as usize) as u64;
|
||||
self.samples += samples.len() as u64;
|
||||
for &s in samples {
|
||||
let a = s.abs();
|
||||
if a > self.peak {
|
||||
self.peak = a;
|
||||
}
|
||||
self.sumsq += (s as f64) * (s as f64);
|
||||
}
|
||||
}
|
||||
|
||||
/// `(peak dBFS, rms dBFS, delivered %)` for this window. Silence reports -120 dB rather than
|
||||
/// -inf so the log line stays parseable.
|
||||
pub(crate) fn summary(&self, elapsed: Duration, sample_rate: u32) -> (f64, f64, f64) {
|
||||
let rms = (self.sumsq / (self.samples as f64).max(1.0)).sqrt();
|
||||
let db = |v: f64| if v > 0.0 { 20.0 * v.log10() } else { -120.0 };
|
||||
// Expected frames for the window — a shortfall means the endpoint is not delivering at
|
||||
// real time (a stalling virtual device), which a peak/RMS alone cannot show.
|
||||
let expected = elapsed.as_secs_f64() * sample_rate as f64;
|
||||
(
|
||||
db(self.peak as f64),
|
||||
db(rms),
|
||||
(self.frames as f64 / expected.max(1.0)) * 100.0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Replays the 2026-08-03 field shape: a dud default change every ~2 s, forever. We must put
|
||||
/// the default back a few times, then concede — and warn exactly once for each.
|
||||
#[test]
|
||||
fn fight_damper_concedes_instead_of_looping_forever() {
|
||||
let t0 = Instant::now();
|
||||
let mut d = FightDamper::new(t0);
|
||||
let mut reasserts = 0;
|
||||
let (mut warns_fighting, mut warns_giving_up) = (0, 0);
|
||||
for i in 0..8 {
|
||||
d.observed_at(t0 + Duration::from_millis(i * 2_000));
|
||||
if d.should_reassert() {
|
||||
reasserts += 1;
|
||||
if d.warn_now() {
|
||||
warns_fighting += 1;
|
||||
}
|
||||
} else if d.warn_giving_up() {
|
||||
warns_giving_up += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
reasserts, FIGHT_LIMIT,
|
||||
"must stop after the window's budget"
|
||||
);
|
||||
assert_eq!(warns_fighting, 1, "one warning per burst, not one per flip");
|
||||
assert_eq!(warns_giving_up, 1, "concede exactly once");
|
||||
}
|
||||
|
||||
/// Occasional, genuinely transient churn must ALWAYS be corrected — the damper must not
|
||||
/// accumulate across widely-spaced events and quietly stop doing its job.
|
||||
#[test]
|
||||
fn fight_damper_always_fixes_isolated_changes() {
|
||||
let t0 = Instant::now();
|
||||
let mut d = FightDamper::new(t0);
|
||||
let mut reasserts = 0;
|
||||
for i in 1..=10 {
|
||||
d.observed_at(t0 + FIGHT_WINDOW * i);
|
||||
if d.should_reassert() {
|
||||
reasserts += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(reasserts, 10, "isolated changes must always be corrected");
|
||||
}
|
||||
|
||||
/// After the backoff expires the damper re-arms, so a program that goes quiet and comes back
|
||||
/// later is fought again rather than being conceded to for the rest of the session.
|
||||
#[test]
|
||||
fn fight_damper_rearms_after_the_backoff() {
|
||||
let t0 = Instant::now();
|
||||
let mut d = FightDamper::new(t0);
|
||||
for i in 0..FIGHT_LIMIT + 2 {
|
||||
d.observed_at(t0 + Duration::from_millis(i as u64 * 500));
|
||||
d.should_reassert();
|
||||
}
|
||||
assert!(d.is_paused(), "should have conceded");
|
||||
d.observed_at(t0 + FIGHT_BACKOFF + FIGHT_WINDOW * 2);
|
||||
assert!(d.should_reassert(), "must re-arm once the backoff expires");
|
||||
}
|
||||
|
||||
/// Peak/RMS must separate the states a log could not previously tell apart.
|
||||
#[test]
|
||||
fn capture_stats_separate_silence_from_signal() {
|
||||
let mut quiet = CaptureStats::default();
|
||||
quiet.observe(&[0.0; 480], 2);
|
||||
let (peak, rms, _) = quiet.summary(Duration::from_secs(1), 48_000);
|
||||
assert_eq!(peak, -120.0, "digital silence reports the floor, not -inf");
|
||||
assert_eq!(rms, -120.0);
|
||||
|
||||
let mut loud = CaptureStats::default();
|
||||
let tone: Vec<f32> = (0..480).map(|i| (i as f32 * 0.13).sin() * 0.5).collect();
|
||||
loud.observe(&tone, 2);
|
||||
assert_eq!(
|
||||
loud.frames, 240,
|
||||
"480 interleaved stereo samples = 240 frames"
|
||||
);
|
||||
let (peak, rms, _) = loud.summary(Duration::from_secs(1), 48_000);
|
||||
assert!(
|
||||
peak > -8.0 && peak <= 0.0,
|
||||
"peak {peak} dBFS should track a 0.5 tone"
|
||||
);
|
||||
// A sine's RMS is its amplitude / sqrt(2) — about 3 dB below peak. Getting this equal to
|
||||
// peak is exactly what a frames-vs-samples mix-up in the denominator looks like, so the
|
||||
// margin is asserted rather than just the ordering.
|
||||
assert!(
|
||||
rms < peak - 2.0,
|
||||
"RMS {rms} vs peak {peak}: a sine must sit ~3 dB below its peak"
|
||||
);
|
||||
}
|
||||
|
||||
/// The delivered-percentage is what shows an endpoint that has stopped feeding us in real
|
||||
/// time — invisible in peak/RMS, and the shape a stalling virtual device makes.
|
||||
#[test]
|
||||
fn capture_stats_report_a_delivery_shortfall() {
|
||||
let mut full = CaptureStats::default();
|
||||
full.observe(&vec![0.1f32; 48_000 * 2], 2); // exactly 1 s of stereo
|
||||
let (_, _, pct) = full.summary(Duration::from_secs(1), 48_000);
|
||||
assert!((pct - 100.0).abs() < 1.0, "expected ~100 %, got {pct}");
|
||||
|
||||
let mut half = CaptureStats::default();
|
||||
half.observe(&vec![0.1f32; 48_000], 2); // 0.5 s of stereo in a 1 s window
|
||||
let (_, _, pct) = half.summary(Duration::from_secs(1), 48_000);
|
||||
assert!((pct - 50.0).abs() < 1.0, "expected ~50 %, got {pct}");
|
||||
}
|
||||
}
|
||||
@@ -674,6 +674,8 @@ fn pw_thread(
|
||||
})
|
||||
.register();
|
||||
|
||||
// Which source the negotiated format below actually describes — see the note there.
|
||||
let sink_mode = sink_name.is_some();
|
||||
let props = match &sink_name {
|
||||
// Stream-sink mode: this stream IS the sink (media.class + Direction::Input). Apps
|
||||
// play into it, PipeWire mixes them, process() receives the mix. Mirrors the
|
||||
@@ -710,8 +712,25 @@ fn pw_thread(
|
||||
let stream = pw::stream::StreamBox::new(&core, "punktfunk-audio", props)
|
||||
.context("pw audio Stream")?;
|
||||
|
||||
// The capture callback's state: the hand-off channel plus this plane's vitals. Before
|
||||
// this it was the bare `tx`, and the desktop-audio plane logged NOTHING between "capture
|
||||
// started" and the session ending — no level, no cadence, and in particular no sign of
|
||||
// the silent drop below. That is exactly what made the 2026-08-03 Windows field report
|
||||
// un-triageable, and the Linux half kept it after the Windows half was fixed.
|
||||
struct CapUd {
|
||||
tx: std::sync::mpsc::SyncSender<Vec<f32>>,
|
||||
channels: u32,
|
||||
stats: crate::audio::capture_policy::CaptureStats,
|
||||
last_stats: std::time::Instant,
|
||||
}
|
||||
let ud = CapUd {
|
||||
tx,
|
||||
channels,
|
||||
stats: Default::default(),
|
||||
last_stats: std::time::Instant::now(),
|
||||
};
|
||||
let _listener = stream
|
||||
.add_local_listener_with_user_data(tx)
|
||||
.add_local_listener_with_user_data(ud)
|
||||
.state_changed({
|
||||
let mainloop = mainloop.clone();
|
||||
move |_s, _ud, old, new| {
|
||||
@@ -723,22 +742,32 @@ fn pw_thread(
|
||||
}
|
||||
}
|
||||
})
|
||||
.param_changed(|_stream, _tx, id, param| {
|
||||
.param_changed(move |_stream, _tx, id, param| {
|
||||
let Some(param) = param else { return };
|
||||
if id != pw::spa::param::ParamType::Format.as_raw() {
|
||||
return;
|
||||
}
|
||||
let mut info = AudioInfoRaw::default();
|
||||
if info.parse(param).is_ok() {
|
||||
// `stream_sink` says WHICH source this format describes, and that changes how
|
||||
// much it is worth. In stream-sink mode the host owns the sink, so this IS the
|
||||
// format apps render into and the desktop mix cannot have been narrowed before
|
||||
// we saw it. In LEGACY monitor mode we are capturing someone else's sink
|
||||
// through PipeWire's resampler: a 16 kHz Bluetooth headset upstream would
|
||||
// still be reported here as a clean 48 kHz, exactly the way WASAPI's
|
||||
// autoconvert hid the same thing on Windows (the 2026-08-03 report). Reading
|
||||
// the monitored node's OWN rate needs a registry lookup this stream does not
|
||||
// do — recorded as an open gap rather than implied to be covered.
|
||||
tracing::info!(
|
||||
format = ?info.format(),
|
||||
rate = info.rate(),
|
||||
channels = info.channels(),
|
||||
stream_sink = sink_mode,
|
||||
"audio format negotiated"
|
||||
);
|
||||
}
|
||||
})
|
||||
.process(|stream, tx| {
|
||||
.process(|stream, ud| {
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let Some(mut buffer) = stream.dequeue_buffer() else {
|
||||
return;
|
||||
@@ -774,7 +803,34 @@ fn pw_thread(
|
||||
];
|
||||
samples.push(f32::from_le_bytes(b));
|
||||
}
|
||||
let _ = tx.try_send(samples); // drop if the encoder is behind
|
||||
ud.stats.observe(&samples, ud.channels);
|
||||
// Non-blocking and lossy, as before — but COUNTED. A full channel means the
|
||||
// encode thread is not keeping up, and because the encoder simply
|
||||
// concatenates across the hole every dropped chunk is a click AND a
|
||||
// permanent shift of everything after it.
|
||||
if ud.tx.try_send(samples).is_err() {
|
||||
ud.stats.dropped_chunks += 1;
|
||||
}
|
||||
if ud.last_stats.elapsed() >= crate::audio::capture_policy::STATS_EVERY {
|
||||
let (peak_db, rms_db, delivered_pct) =
|
||||
ud.stats.summary(ud.last_stats.elapsed(), SAMPLE_RATE);
|
||||
if ud.stats.dropped_chunks > 0 {
|
||||
tracing::warn!(
|
||||
dropped_chunks = ud.stats.dropped_chunks,
|
||||
"the audio encode thread could not keep up — captured audio was \
|
||||
DROPPED; the stream will click and everything after it shifts"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
peak_db = format!("{peak_db:.1}"),
|
||||
rms_db = format!("{rms_db:.1}"),
|
||||
delivered_pct = format!("{delivered_pct:.0}"),
|
||||
dropped_chunks = ud.stats.dropped_chunks,
|
||||
"desktop audio capture"
|
||||
);
|
||||
ud.stats = Default::default();
|
||||
ud.last_stats = std::time::Instant::now();
|
||||
}
|
||||
}));
|
||||
if outcome.is_err() {
|
||||
tracing::error!("panic in pipewire audio callback — chunk dropped");
|
||||
|
||||
@@ -14,8 +14,11 @@
|
||||
//! * default **PLAYBACK** → the plan's loopback endpoint, applied ONLY while a desktop-audio capture
|
||||
//! is open (`set_playback` — the mic pump must never park the playback default while the host is
|
||||
//! idle). By default that endpoint is the SILENT sink (Steam Streaming Microphone render side) so
|
||||
//! audio plays on the client only; `PUNKTFUNK_HOST_AUDIO` prefers real hardware instead (audible on
|
||||
//! both ends). **Never** the Steam Streaming Speakers, whose loopback is silent — validated live;
|
||||
//! audio plays on the client only; `audio.output_mode = host_and_client` (formerly
|
||||
//! `PUNKTFUNK_HOST_AUDIO`) prefers real hardware instead (audible on both ends). Since 2026-08 a
|
||||
//! silent sink must also be able to CARRY the mix — one that narrows it (a voice-carrier endpoint
|
||||
//! mixing mono or at 24 kHz) loses to real hardware; see [`super::wiring_plan`]. **Never** the
|
||||
//! Steam Streaming Speakers, whose loopback is silent — validated live;
|
||||
//! * default **RECORDING** → the mic target's capture endpoint (VB-Cable "CABLE Output") so host apps
|
||||
//! record the client's mic by default.
|
||||
//!
|
||||
@@ -33,18 +36,44 @@
|
||||
//!
|
||||
//! Setting a default endpoint uses the undocumented `IPolicyConfig` COM interface (the only way to set
|
||||
//! a default device programmatically — neither the `windows` nor `wasapi` crate exposes it; it is the
|
||||
//! same call `mmsys.cpl` makes). Opt out with `PUNKTFUNK_KEEP_DEFAULT` to leave the user's chosen
|
||||
//! defaults untouched (the plan is still computed — the mic must still pick a target).
|
||||
//! same call `mmsys.cpl` makes). The `audio.output_mode = follow_default` setting (formerly
|
||||
//! `PUNKTFUNK_KEEP_DEFAULT`) leaves the user's chosen defaults untouched — the plan is still
|
||||
//! computed, since the mic must still pick a target.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::wiring_plan::{self, plan, Endpoint, Wiring};
|
||||
use super::wiring_plan::{self, plan, plan_with_formats, Endpoint, MixFormat, Wiring};
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use std::ffi::c_void;
|
||||
use std::sync::Mutex;
|
||||
use wasapi::Direction;
|
||||
|
||||
/// A render endpoint's engine mix format, or `None` if it cannot be asked right now.
|
||||
///
|
||||
/// This is the number the 2026-08-03 field report needed and no log had: the capture side requests
|
||||
/// 48 kHz f32 with `autoconvert`, so WASAPI converts silently from whatever the endpoint really
|
||||
/// runs — and a voice-carrier endpoint (Steam's Streaming Microphone) narrowing the desktop mix to
|
||||
/// mono or 24 kHz was invisible. Reading it costs one `IAudioClient` activation per endpoint, done
|
||||
/// only during a wiring pass.
|
||||
///
|
||||
/// Deliberately total: EVERY failure maps to `None` ("assume it is fine"), because the wiring plan
|
||||
/// treats an unknown format as non-narrowing. A box where activation fails therefore plans exactly
|
||||
/// as it did before formats existed, instead of mis-demoting a perfectly good endpoint.
|
||||
fn mix_format_of(ep: &Endpoint) -> Option<MixFormat> {
|
||||
let fmt = open_endpoint(ep)
|
||||
.ok()?
|
||||
.get_iaudioclient()
|
||||
.ok()?
|
||||
.get_mixformat()
|
||||
.ok()?;
|
||||
Some(MixFormat {
|
||||
rate_hz: fmt.get_samplespersec(),
|
||||
channels: fmt.get_nchannels(),
|
||||
bits: fmt.get_bitspersample(),
|
||||
})
|
||||
}
|
||||
|
||||
/// `(friendly_name, endpoint_id)` for every ACTIVE endpoint in direction `dir`.
|
||||
fn list_endpoints(dir: Direction) -> Vec<Endpoint> {
|
||||
let mut out = Vec::new();
|
||||
@@ -69,10 +98,22 @@ fn list_endpoints(dir: Direction) -> Vec<Endpoint> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_HOST_AUDIO`: the operator wants the stream audible on the host too — the loopback
|
||||
/// plan prefers real hardware over the silent sink (the pre-client-only-default behavior).
|
||||
/// The operator wants the stream audible on the host too — the loopback plan prefers real
|
||||
/// hardware over the silent sink (the pre-client-only-default behavior).
|
||||
///
|
||||
/// Now driven by the first-class `audio.output_mode` setting
|
||||
/// ([`AudioOutputMode`](pf_host_config::AudioOutputMode)), which still honours the older
|
||||
/// `PUNKTFUNK_HOST_AUDIO` spelling.
|
||||
pub(crate) fn host_audio_requested() -> bool {
|
||||
std::env::var_os("PUNKTFUNK_HOST_AUDIO").is_some()
|
||||
pf_host_config::config()
|
||||
.audio_output_mode
|
||||
.prefers_host_hardware()
|
||||
}
|
||||
|
||||
/// The operator's default playback/recording devices must not be touched at all — the
|
||||
/// `follow_default` mode, formerly `PUNKTFUNK_KEEP_DEFAULT`.
|
||||
pub(crate) fn keep_default_devices() -> bool {
|
||||
pf_host_config::config().audio_output_mode.keeps_default()
|
||||
}
|
||||
|
||||
/// One wiring pass plus the inputs the desktop-audio capture loop's failure handling needs:
|
||||
@@ -118,7 +159,27 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
||||
let want = std::env::var("PUNKTFUNK_MIC_DEVICE")
|
||||
.ok()
|
||||
.map(|s| s.to_lowercase());
|
||||
let wiring = plan(&renders, &captures, want.as_deref(), host_audio_requested());
|
||||
// Mix formats are read only when we are actually going to park the playback default (i.e. a
|
||||
// desktop-audio capture is opening). The mic pump wires on every open while the host is idle
|
||||
// and does not care which loopback endpoint wins, so it must not pay an IAudioClient
|
||||
// activation per render endpoint on every pass.
|
||||
let probe: &dyn Fn(&Endpoint) -> Option<MixFormat> = if set_playback {
|
||||
&mix_format_of
|
||||
} else {
|
||||
&wiring_plan::no_formats
|
||||
};
|
||||
let wiring = plan_with_formats(
|
||||
&renders,
|
||||
&captures,
|
||||
want.as_deref(),
|
||||
host_audio_requested(),
|
||||
probe,
|
||||
// The loopback is opened at the session's negotiated channel count, but the wiring pass
|
||||
// runs before (and outside) any session. Stereo is the floor every session uses and the
|
||||
// only count a *narrowing* verdict can be made against without guessing: an endpoint that
|
||||
// cannot carry stereo cannot carry 5.1 either.
|
||||
2,
|
||||
);
|
||||
let done = |wiring: Wiring| WiredPlan {
|
||||
wiring,
|
||||
fingerprint,
|
||||
@@ -142,6 +203,18 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
||||
renders = ?renders.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
|
||||
"audio wiring plan"
|
||||
);
|
||||
// The quality warning the 2026-08-03 report had no way to produce. Says WHICH endpoint,
|
||||
// WHY it is narrow, and the two things the operator can actually do about it.
|
||||
if let (Some(why), Some((name, _))) = (&wiring.loopback_narrowing, &wiring.loopback_render)
|
||||
{
|
||||
tracing::warn!(
|
||||
device = %name,
|
||||
"the desktop-audio loopback endpoint {why} — streamed audio will sound worse \
|
||||
than it does on the host. Attach or select a 48 kHz stereo output device, or \
|
||||
set audio.output_mode = host_and_client (PUNKTFUNK_HOST_AUDIO=1) to prefer \
|
||||
real hardware"
|
||||
);
|
||||
}
|
||||
if wiring.mic_render.is_some() && wiring.loopback_unsatisfiable() {
|
||||
// Inventory + per-endpoint reasons + ONLY the remedies not already taken — the old
|
||||
// static advice here suggested installing the Steam pair to a field box that had it
|
||||
@@ -153,10 +226,11 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
||||
}
|
||||
}
|
||||
|
||||
if std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some() {
|
||||
if keep_default_devices() {
|
||||
if changed {
|
||||
tracing::info!(
|
||||
"PUNKTFUNK_KEEP_DEFAULT set — leaving the audio default devices untouched"
|
||||
mode = %pf_host_config::config().audio_output_mode.as_str(),
|
||||
"audio output mode is follow_default — leaving the audio default devices untouched"
|
||||
);
|
||||
}
|
||||
return done(wiring);
|
||||
@@ -317,6 +391,25 @@ fn park_default_playback(name: &str, id: &str, changed: bool, mic_id: Option<&st
|
||||
}
|
||||
}
|
||||
|
||||
/// Put the default playback device back on the endpoint we are already capturing, WITHOUT a
|
||||
/// wiring pass (WP2.4).
|
||||
///
|
||||
/// The capture loop uses this when something else takes the default mid-stream: in Assert mode the
|
||||
/// capture is bound to the planned endpoint explicitly, so the only thing a hijacked default
|
||||
/// changes is where *apps* render — one `IPolicyConfig` write fixes that, where the old path tore
|
||||
/// the capture down and re-ran the whole wiring pass. Deliberately does not touch the [`PARKED`]
|
||||
/// memo: the endpoint is the one we already parked, so the operator's original default is
|
||||
/// unchanged and still owed back at stream end.
|
||||
pub(crate) fn reassert_default_playback(id: &str) -> bool {
|
||||
match set_default_endpoint(id) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %format!("{e:#}"), "failed to re-assert the default playback device");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Put the operator's default playback device back after streaming — the inverse of
|
||||
/// [`park_default_playback`]. No-op if we never parked it, and a default the operator changed
|
||||
/// themselves mid-stream is left alone (their choice wins). Must run on a COM-initialized thread
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
//! succeed). On thread exit (capturer dropped at stream end) the parked default playback
|
||||
//! device is restored.
|
||||
|
||||
use super::capture_policy::{CaptureStats, FightDamper, FIGHT_BACKOFF, STATS_EVERY};
|
||||
use super::{audio_control, wiring_plan, AudioCapturer, SAMPLE_RATE};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::collections::VecDeque;
|
||||
@@ -359,7 +360,7 @@ fn capture_once(
|
||||
) -> Result<Next> {
|
||||
// Interleaved f32: channels * 4 bytes per frame.
|
||||
let block_align = channels as usize * 4;
|
||||
let keep_default = std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some();
|
||||
let keep_default = audio_control::keep_default_devices();
|
||||
// Assert-mode without KEEP_DEFAULT is the only shape that parks the playback default.
|
||||
let assert_plan = mode == TargetMode::Assert && !keep_default;
|
||||
let mut plan = audio_control::wire_now_full(assert_plan);
|
||||
@@ -454,12 +455,25 @@ fn capture_once(
|
||||
channels as usize,
|
||||
Some(mask),
|
||||
);
|
||||
let (default_period, _min_period) =
|
||||
audio_client.get_device_period().context("device period")?;
|
||||
// WP0.1 — the endpoint's ACTUAL engine mix format, read BEFORE we initialize. Everything the
|
||||
// old log printed ("48 kHz f32 channels=2") was our REQUEST; with `autoconvert` WASAPI
|
||||
// silently converts from whatever the endpoint really runs, so a voice-carrier endpoint
|
||||
// narrowing the desktop mix to mono or 24 kHz was invisible in a 3,600-line field log. This
|
||||
// line is what makes an audio-quality report triageable without a round trip.
|
||||
let engine = audio_client.get_mixformat().ok();
|
||||
// NB the plan's WP4.5 ("open the loopback at the MINIMUM device period, worth ~5–10 ms") is
|
||||
// deliberately NOT done here, because its premise is wrong: in shared mode
|
||||
// `IAudioClient::Initialize` cannot change the engine period at all — `hnsBufferDuration` sizes
|
||||
// the buffer, and the callback still fires at the engine's fixed default period. Lowering it
|
||||
// needs `IAudioClient3::InitializeSharedAudioStream`, which the `wasapi` crate does not wrap.
|
||||
// Passing `min_period` here would therefore be a no-op at best and a new Initialize failure
|
||||
// path at worst, on a device this tree cannot compile for, let alone test. Left as real work.
|
||||
let (default_period, min_period) = audio_client.get_device_period().context("device period")?;
|
||||
let stream_mode = StreamMode::EventsShared {
|
||||
autoconvert: true,
|
||||
buffer_duration_hns: default_period,
|
||||
};
|
||||
let used_period = default_period;
|
||||
audio_client
|
||||
.initialize_client(&desired, &Direction::Capture, &stream_mode)
|
||||
.context("initialize loopback client")?;
|
||||
@@ -476,7 +490,17 @@ fn capture_once(
|
||||
tracing::info!(device = %dev_name,
|
||||
follow = matches!(mode, TargetMode::Follow) || keep_default,
|
||||
last_resort,
|
||||
// The endpoint's own format — NOT the one we asked for.
|
||||
engine_hz = engine.as_ref().map(|f| f.get_samplespersec()),
|
||||
engine_ch = engine.as_ref().map(|f| f.get_nchannels()),
|
||||
engine_bits = engine.as_ref().map(|f| f.get_bitspersample()),
|
||||
buffer_ms = used_period as f32 / 10_000.0,
|
||||
min_buffer_ms = min_period as f32 / 10_000.0,
|
||||
"audio loopback capturing");
|
||||
if let Some(why) = &wiring.loopback_narrowing {
|
||||
tracing::warn!(device = %dev_name,
|
||||
"capturing an endpoint that {why} — the stream cannot sound better than this source");
|
||||
}
|
||||
|
||||
// Watchdog seed: the default as it stands right after our open. In Assert mode the plan just
|
||||
// parked the default on our endpoint — if it did NOT stick (IPolicyConfig denied) converge
|
||||
@@ -514,6 +538,15 @@ fn capture_once(
|
||||
let opened_at = Instant::now();
|
||||
let mut saw_packets = false;
|
||||
let mut silence_noted = false;
|
||||
// WP0.2 — the audio plane's own vitals, logged periodically. Before this, a host log said
|
||||
// nothing whatsoever about audio between "capturing" and the session ending: no level, no
|
||||
// cadence, and in particular no sign of the SILENT, uncounted drop below, where a stalled
|
||||
// encode thread loses chunks and the encoder simply concatenates across the hole (a click,
|
||||
// and a permanent A/V offset, with nothing in any log).
|
||||
let mut stats = CaptureStats::default();
|
||||
let mut last_stats = Instant::now();
|
||||
// WP2.4 — damping for the default-playback tug-of-war.
|
||||
let mut fight = FightDamper::new(Instant::now());
|
||||
loop {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
audio_client.stop_stream().ok();
|
||||
@@ -556,7 +589,34 @@ fn capture_once(
|
||||
for c in raw.chunks_exact(4) {
|
||||
samples.push(f32::from_le_bytes([c[0], c[1], c[2], c[3]]));
|
||||
}
|
||||
let _ = tx.try_send(samples); // non-blocking, lossy — same discipline as PipeWire
|
||||
stats.observe(&samples, channels);
|
||||
// Non-blocking, lossy — same discipline as PipeWire. Now COUNTED: a full channel
|
||||
// means the encode thread is not keeping up, and every dropped chunk is a click plus
|
||||
// a permanent shift of everything after it.
|
||||
if tx.try_send(samples).is_err() {
|
||||
stats.dropped_chunks += 1;
|
||||
}
|
||||
}
|
||||
if last_stats.elapsed() >= STATS_EVERY {
|
||||
let (peak_db, rms_db, delivered_pct) = stats.summary(last_stats.elapsed(), SAMPLE_RATE);
|
||||
if stats.dropped_chunks > 0 {
|
||||
tracing::warn!(
|
||||
device = %dev_name,
|
||||
dropped_chunks = stats.dropped_chunks,
|
||||
"the audio encode thread could not keep up — captured audio was DROPPED; the \
|
||||
stream will click and everything after it shifts"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
device = %dev_name,
|
||||
peak_db = format!("{peak_db:.1}"),
|
||||
rms_db = format!("{rms_db:.1}"),
|
||||
delivered_pct = format!("{delivered_pct:.0}"),
|
||||
dropped_chunks = stats.dropped_chunks,
|
||||
"desktop audio capture"
|
||||
);
|
||||
last_stats = Instant::now();
|
||||
stats = CaptureStats::default();
|
||||
}
|
||||
|
||||
// Watchdog: react when the default render device CHANGES from what we last observed —
|
||||
@@ -568,29 +628,68 @@ fn capture_once(
|
||||
if seen_default.as_deref() != Some(nid.as_str()) {
|
||||
seen_default = Some(nid.clone());
|
||||
if nid != dev_id {
|
||||
audio_client.stop_stream().ok();
|
||||
// NB the stream is stopped per-branch below, NOT here: the WP2.4 Dud
|
||||
// path deliberately keeps capturing, and stopping first would have made
|
||||
// the "no teardown" fix silently useless.
|
||||
if keep_default {
|
||||
audio_client.stop_stream().ok();
|
||||
tracing::info!(
|
||||
"default render device changed (PUNKTFUNK_KEEP_DEFAULT) — \
|
||||
following it"
|
||||
);
|
||||
return Ok(Next::Reopen(TargetMode::Follow));
|
||||
}
|
||||
return Ok(match judge_default(&en, wiring, &nid) {
|
||||
match judge_default(&en, wiring, &nid) {
|
||||
DefaultKind::Capturable(name) => {
|
||||
audio_client.stop_stream().ok();
|
||||
tracing::info!(device = %name,
|
||||
"operator changed the output device mid-stream — following \
|
||||
it (audio now also plays on the host)");
|
||||
Next::Reopen(TargetMode::Follow)
|
||||
return Ok(Next::Reopen(TargetMode::Follow));
|
||||
}
|
||||
// WP2.4 — a DUD default does not affect what we are capturing:
|
||||
// Assert mode binds the capture to the plan's endpoint EXPLICITLY,
|
||||
// not to whatever the default happens to be. Only where *apps*
|
||||
// render has moved. So put the default back and KEEP THE STREAM —
|
||||
// the old full reopen tore the capture down for nothing, and the
|
||||
// 2026-08-03 field log shows what that cost: something re-set the
|
||||
// default to CABLE Input every ~4 s and each round trip was a
|
||||
// teardown, a re-plan with IPolicyConfig writes, and an audible
|
||||
// dropout — seven of them in sixteen seconds, one ending in a 2 s
|
||||
// error backoff.
|
||||
DefaultKind::Dud(name) => {
|
||||
tracing::warn!(device = %name,
|
||||
"default playback moved to an endpoint whose loopback cannot \
|
||||
work — re-asserting the audio wiring plan");
|
||||
Next::Reopen(TargetMode::Assert)
|
||||
if !assert_plan {
|
||||
// Follow/KEEP_DEFAULT shapes still need the old behaviour:
|
||||
// there the capture IS bound to the default.
|
||||
audio_client.stop_stream().ok();
|
||||
return Ok(Next::Reopen(TargetMode::Assert));
|
||||
}
|
||||
fight.observed_at(Instant::now());
|
||||
if fight.should_reassert() {
|
||||
audio_control::reassert_default_playback(&dev_id);
|
||||
// Believe our own write: the next watchdog tick sees the
|
||||
// default back on our endpoint and stays quiet.
|
||||
seen_default = Some(dev_id.clone());
|
||||
if fight.warn_now() {
|
||||
tracing::warn!(device = %name, planned = %dev_name,
|
||||
"something keeps moving the default playback to an \
|
||||
endpoint whose loopback cannot work — putting it \
|
||||
back (the capture is unaffected)");
|
||||
}
|
||||
} else if fight.warn_giving_up() {
|
||||
tracing::warn!(device = %name, planned = %dev_name,
|
||||
backoff_s = FIGHT_BACKOFF.as_secs(),
|
||||
"another program is repeatedly taking the default \
|
||||
playback device — backing off rather than fighting it. \
|
||||
Desktop audio keeps streaming from the planned endpoint, \
|
||||
but apps rendering to the other device will not be heard");
|
||||
}
|
||||
}
|
||||
DefaultKind::Unknown => Next::Reopen(TargetMode::Assert),
|
||||
});
|
||||
DefaultKind::Unknown => {
|
||||
audio_client.stop_stream().ok();
|
||||
return Ok(Next::Reopen(TargetMode::Assert));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,59 @@
|
||||
/// A `(friendly_name, endpoint_id)` pair as enumerated from WASAPI.
|
||||
pub(crate) type Endpoint = (String, String);
|
||||
|
||||
/// A render endpoint's ENGINE MIX FORMAT, as `IAudioClient::GetMixFormat` reports it.
|
||||
///
|
||||
/// This is the number the 2026-08-03 field report needed and the log did not have. The capture
|
||||
/// side opens with `autoconvert: true` and asks for 48 kHz f32 in the wire layout, so WASAPI
|
||||
/// silently converts whatever the endpoint really runs — and the "48 kHz f32 channels=2" we
|
||||
/// logged was our REQUEST, not the source. An endpoint that mixes at 24 kHz mono therefore
|
||||
/// produced a 48 kHz stereo stream that had already been through a 24 kHz mono bottleneck, with
|
||||
/// nothing in any log to say so.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct MixFormat {
|
||||
pub rate_hz: u32,
|
||||
pub channels: u16,
|
||||
pub bits: u16,
|
||||
}
|
||||
|
||||
impl MixFormat {
|
||||
/// Why this endpoint would NARROW a `want`-channel desktop mix, or `None` if it carries it
|
||||
/// intact. Bit depth is deliberately not a criterion: 16-bit is ~96 dB of headroom, far below
|
||||
/// Opus's own noise floor, whereas a lost channel or halved bandwidth is plainly audible.
|
||||
pub(crate) fn narrowing(&self, want: u8) -> Option<String> {
|
||||
if self.rate_hz < 48_000 && self.channels < want as u16 {
|
||||
return Some(format!(
|
||||
"mixes at {} Hz and only {} channel(s)",
|
||||
self.rate_hz, self.channels
|
||||
));
|
||||
}
|
||||
if self.rate_hz < 48_000 {
|
||||
return Some(format!(
|
||||
"mixes at {} Hz, so the stream is band-limited to ~{} kHz before Opus sees it",
|
||||
self.rate_hz,
|
||||
self.rate_hz / 2000
|
||||
));
|
||||
}
|
||||
if self.channels < want as u16 {
|
||||
return Some(format!(
|
||||
"mixes {} channel(s), so a {want}-channel desktop mix is downmixed and re-expanded",
|
||||
self.channels
|
||||
));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Looks up a render endpoint's mix format by endpoint id. `None` = unknown (enumeration failed,
|
||||
/// or the caller has no way to ask) — treated as "assume it is fine", so a probe failure can
|
||||
/// never make the plan worse than it was before formats existed.
|
||||
pub(crate) type FormatProbe<'a> = &'a dyn Fn(&Endpoint) -> Option<MixFormat>;
|
||||
|
||||
/// A [`FormatProbe`] that knows nothing — the pre-WP2.1 behaviour.
|
||||
pub(crate) fn no_formats(_: &Endpoint) -> Option<MixFormat> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The coherent endpoint assignment for one wiring pass. Computed fresh on every mic/capture
|
||||
/// (re)open — Windows endpoints churn (boot-time registration, hotplug, driver installs), so a
|
||||
/// once-per-process plan goes stale.
|
||||
@@ -60,6 +113,11 @@ pub(crate) struct Wiring {
|
||||
/// the mic reservation. The capture side treats it as a stopgap: it warns when the silence
|
||||
/// materializes and re-plans on any endpoint-set change instead of riding it out.
|
||||
pub loopback_last_resort: bool,
|
||||
/// Set when the chosen loopback endpoint's mix format NARROWS the desktop mix (see
|
||||
/// [`MixFormat::narrowing`]) and the plan took it anyway because nothing better existed. Carries
|
||||
/// the human-readable reason for the capture side to log — a quality risk the operator can act
|
||||
/// on (attach a real output, or set the output mode to prefer hardware), not a failure.
|
||||
pub loopback_narrowing: Option<String>,
|
||||
}
|
||||
|
||||
impl Wiring {
|
||||
@@ -137,6 +195,32 @@ pub(crate) fn plan(
|
||||
captures: &[Endpoint],
|
||||
mic_want: Option<&str>,
|
||||
host_audio: bool,
|
||||
) -> Wiring {
|
||||
plan_with_formats(renders, captures, mic_want, host_audio, &no_formats, 2)
|
||||
}
|
||||
|
||||
/// [`plan`] with knowledge of each render endpoint's engine mix format, and the channel count the
|
||||
/// session wants to carry.
|
||||
///
|
||||
/// **The 2026-08-03 field report is this function's reason to exist.** The default client-only
|
||||
/// preference takes the "silent sink" — Steam's Streaming *Microphone* render endpoint — over real
|
||||
/// hardware unconditionally, because it is silent on the host. But that endpoint exists to carry
|
||||
/// remote *voice*, and nothing checked whether it could carry music. On the reporter's box it won
|
||||
/// all 31 loopback opens across 25 sessions while a clean AMD HD Audio endpoint sat idle, and the
|
||||
/// whole desktop mix went through it before reaching Opus.
|
||||
///
|
||||
/// So a silent sink now has to EARN its preference: if its mix format narrows the mix (see
|
||||
/// [`MixFormat::narrowing`]) it drops below real hardware. It is still taken when nothing better
|
||||
/// exists — narrow audio beats no audio — but flagged in [`Wiring::loopback_narrowing`] so the
|
||||
/// capture side can say why. An unknown format (probe failed) counts as fine, so this can never
|
||||
/// make the plan worse than it was before formats existed.
|
||||
pub(crate) fn plan_with_formats(
|
||||
renders: &[Endpoint],
|
||||
captures: &[Endpoint],
|
||||
mic_want: Option<&str>,
|
||||
host_audio: bool,
|
||||
format_of: FormatProbe,
|
||||
want_channels: u8,
|
||||
) -> Wiring {
|
||||
let find_render = |needle: &str| {
|
||||
renders
|
||||
@@ -172,10 +256,18 @@ pub(crate) fn plan(
|
||||
not_mic(id) && !excluded_from_loopback(&ln) && !virtualish(&ln)
|
||||
})
|
||||
};
|
||||
let silent = || {
|
||||
renders
|
||||
.iter()
|
||||
.find(|(n, id)| not_mic(id) && silent_sink(&n.to_lowercase()))
|
||||
// A silent sink splits in two: one that carries the mix intact, and one that narrows it. The
|
||||
// first keeps the historical preference; the second falls BELOW real hardware.
|
||||
let narrowing_of = |ep: &Endpoint| format_of(ep).and_then(|f| f.narrowing(want_channels));
|
||||
let silent_intact = || {
|
||||
renders.iter().find(|ep| {
|
||||
not_mic(&ep.1) && silent_sink(&ep.0.to_lowercase()) && narrowing_of(ep).is_none()
|
||||
})
|
||||
};
|
||||
let silent_narrow = || {
|
||||
renders.iter().find(|ep| {
|
||||
not_mic(&ep.1) && silent_sink(&ep.0.to_lowercase()) && narrowing_of(ep).is_some()
|
||||
})
|
||||
};
|
||||
// LAST RESORT — the Steam Streaming Speakers, and ONLY them. Their loopback is known-silent
|
||||
// (validated live): a QUALITY risk, flagged so the capture side can warn when the silence
|
||||
@@ -192,10 +284,13 @@ pub(crate) fn plan(
|
||||
.iter()
|
||||
.find(|(n, id)| not_mic(id) && n.to_lowercase().contains("steam streaming speakers"))
|
||||
};
|
||||
// A narrowing silent sink sits below real hardware in BOTH modes: preferring silence on the
|
||||
// host is a routing choice, but it must not silently cost audio quality when a clean endpoint
|
||||
// is right there.
|
||||
let preferred = if host_audio {
|
||||
real_hw().or_else(silent)
|
||||
real_hw().or_else(silent_intact).or_else(silent_narrow)
|
||||
} else {
|
||||
silent().or_else(real_hw)
|
||||
silent_intact().or_else(real_hw).or_else(silent_narrow)
|
||||
};
|
||||
let (loopback_render, loopback_last_resort) = match preferred {
|
||||
Some(ep) => (Some(ep.clone()), false),
|
||||
@@ -204,12 +299,16 @@ pub(crate) fn plan(
|
||||
None => (None, false),
|
||||
},
|
||||
};
|
||||
// Report narrowing for whatever we actually chose — including real hardware, which can also
|
||||
// be a 24 kHz mono endpoint (a headset's hands-free profile is exactly that).
|
||||
let loopback_narrowing = loopback_render.as_ref().and_then(narrowing_of);
|
||||
|
||||
Wiring {
|
||||
mic_render,
|
||||
mic_capture,
|
||||
loopback_render,
|
||||
loopback_last_resort,
|
||||
loopback_narrowing,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,6 +649,169 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- format-aware loopback selection (WP2.1) -----------------------------------------
|
||||
|
||||
fn fmt(rate_hz: u32, channels: u16) -> MixFormat {
|
||||
MixFormat {
|
||||
rate_hz,
|
||||
channels,
|
||||
bits: 32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe helper: give endpoints whose (lowercased) name contains a needle that format,
|
||||
/// everything else unknown. Owns its table so call sites can pass a literal inline.
|
||||
fn probe(table: Vec<(&'static str, MixFormat)>) -> impl Fn(&Endpoint) -> Option<MixFormat> {
|
||||
move |ep: &Endpoint| {
|
||||
let name = ep.0.to_lowercase();
|
||||
table
|
||||
.iter()
|
||||
.find_map(|(needle, f)| name.contains(needle).then_some(*f))
|
||||
}
|
||||
}
|
||||
|
||||
/// THE 2026-08-03 field case, with formats. The reporter's exact endpoint inventory: the plan
|
||||
/// took the Steam Streaming Microphone on all 31 opens while a clean AMD HD Audio endpoint sat
|
||||
/// idle. Once we can see that the silent sink narrows the mix, real hardware must win.
|
||||
#[test]
|
||||
fn narrowing_silent_sink_loses_to_real_hardware() {
|
||||
let renders = [
|
||||
ep("CABLE In 16ch (VB-Audio Virtual Cable)"),
|
||||
ep("Altavoces (Steam Streaming Speakers)"),
|
||||
ep("Altavoces (Steam Streaming Microphone)"),
|
||||
ep("CABLE Input (VB-Audio Virtual Cable)"),
|
||||
ep("1 - Odyssey G60SD (AMD High Definition Audio Device)"),
|
||||
];
|
||||
let captures = [
|
||||
ep("CABLE Output (VB-Audio Virtual Cable)"),
|
||||
ep("Microphone (Steam Streaming Microphone)"),
|
||||
];
|
||||
// A voice-carrier endpoint: 24 kHz mono.
|
||||
let p = probe(vec![
|
||||
("steam streaming microphone", fmt(24_000, 1)),
|
||||
("odyssey", fmt(48_000, 2)),
|
||||
]);
|
||||
let w = plan_with_formats(&renders, &captures, None, false, &p, 2);
|
||||
assert_eq!(
|
||||
w.loopback_render.as_ref().unwrap().0,
|
||||
"1 - Odyssey G60SD (AMD High Definition Audio Device)",
|
||||
"a narrowing silent sink must not beat clean real hardware"
|
||||
);
|
||||
assert!(
|
||||
w.loopback_narrowing.is_none(),
|
||||
"the chosen endpoint is intact"
|
||||
);
|
||||
// The mic assignment is untouched by any of this.
|
||||
assert_eq!(
|
||||
w.mic_render.unwrap().0,
|
||||
"CABLE Input (VB-Audio Virtual Cable)"
|
||||
);
|
||||
}
|
||||
|
||||
/// …but a silent sink that carries the mix intact keeps its historical preference: the
|
||||
/// client-only routing default is not being abandoned, only made conditional on quality.
|
||||
#[test]
|
||||
fn intact_silent_sink_still_wins() {
|
||||
let renders = [
|
||||
ep("Speakers (Realtek HD Audio)"),
|
||||
ep("CABLE Input (VB-Audio Virtual Cable)"),
|
||||
ep("Speakers (Steam Streaming Microphone)"),
|
||||
];
|
||||
let p = probe(vec![
|
||||
("steam streaming microphone", fmt(48_000, 2)),
|
||||
("realtek", fmt(48_000, 2)),
|
||||
]);
|
||||
let w = plan_with_formats(&renders, &[], None, false, &p, 2);
|
||||
assert_eq!(
|
||||
w.loopback_render.unwrap().0,
|
||||
"Speakers (Steam Streaming Microphone)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Narrow audio still beats NO audio: with nothing else available the narrowing sink is taken
|
||||
/// and flagged, not refused.
|
||||
#[test]
|
||||
fn narrowing_sink_is_taken_when_it_is_all_there_is() {
|
||||
let renders = [
|
||||
ep("CABLE Input (VB-Audio Virtual Cable)"),
|
||||
ep("Speakers (Steam Streaming Microphone)"),
|
||||
];
|
||||
let p = probe(vec![("steam streaming microphone", fmt(16_000, 1))]);
|
||||
let w = plan_with_formats(&renders, &[], None, false, &p, 2);
|
||||
assert_eq!(
|
||||
w.loopback_render.as_ref().unwrap().0,
|
||||
"Speakers (Steam Streaming Microphone)"
|
||||
);
|
||||
let why = w.loopback_narrowing.expect("must be flagged");
|
||||
assert!(why.contains("16000"), "{why}");
|
||||
}
|
||||
|
||||
/// Real hardware can narrow too — a headset in its hands-free profile is 16 kHz mono — and
|
||||
/// must be flagged just the same. The flag is about the CHOSEN endpoint, not about which tier
|
||||
/// it came from.
|
||||
#[test]
|
||||
fn narrowing_is_reported_for_real_hardware_too() {
|
||||
let renders = [ep("Headset (Hands-Free AG Audio)")];
|
||||
let p = probe(vec![("headset", fmt(16_000, 1))]);
|
||||
let w = plan_with_formats(&renders, &[], None, false, &p, 2);
|
||||
assert_eq!(
|
||||
w.loopback_render.as_ref().unwrap().0,
|
||||
"Headset (Hands-Free AG Audio)"
|
||||
);
|
||||
assert!(w.loopback_narrowing.is_some());
|
||||
}
|
||||
|
||||
/// An unknown format must never make the plan WORSE than it was before formats existed: a
|
||||
/// probe that answers nothing has to reproduce `plan` exactly.
|
||||
#[test]
|
||||
fn unknown_formats_reproduce_the_formatless_plan() {
|
||||
let renders = [
|
||||
ep("Speakers (Apple Audio Device)"),
|
||||
ep("CABLE Input (VB-Audio Virtual Cable)"),
|
||||
ep("Speakers (Steam Streaming Speakers)"),
|
||||
ep("Speakers (Steam Streaming Microphone)"),
|
||||
];
|
||||
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
|
||||
for host_audio in [false, true] {
|
||||
let a = plan(&renders, &captures, None, host_audio);
|
||||
let b = plan_with_formats(&renders, &captures, None, host_audio, &no_formats, 2);
|
||||
assert_eq!(a, b, "host_audio={host_audio}");
|
||||
assert!(a.loopback_narrowing.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
/// `host_audio` still prefers real hardware, and a narrowing silent sink stays last in that
|
||||
/// mode too.
|
||||
#[test]
|
||||
fn host_audio_ordering_survives_formats() {
|
||||
let renders = [
|
||||
ep("Speakers (Realtek HD Audio)"),
|
||||
ep("Speakers (Steam Streaming Microphone)"),
|
||||
];
|
||||
let p = probe(vec![
|
||||
("steam streaming microphone", fmt(24_000, 1)),
|
||||
("realtek", fmt(48_000, 2)),
|
||||
]);
|
||||
let w = plan_with_formats(&renders, &[], None, true, &p, 2);
|
||||
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
|
||||
}
|
||||
|
||||
/// The narrowing test is channel-count aware: an endpoint that is fine for stereo narrows a
|
||||
/// 5.1 session.
|
||||
#[test]
|
||||
fn narrowing_depends_on_the_session_channel_count() {
|
||||
let stereo_only = fmt(48_000, 2);
|
||||
assert_eq!(stereo_only.narrowing(2), None);
|
||||
assert!(stereo_only.narrowing(6).is_some());
|
||||
// Rate is judged independently of channels.
|
||||
assert!(fmt(44_100, 8).narrowing(2).is_some());
|
||||
// And an endpoint wider than the session is never "narrowing".
|
||||
assert_eq!(fmt(48_000, 8).narrowing(2), None);
|
||||
// Both wrong: the message must name both problems.
|
||||
let both = fmt(16_000, 1).narrowing(6).unwrap();
|
||||
assert!(both.contains("16000") && both.contains("channel"), "{both}");
|
||||
}
|
||||
|
||||
/// Operator override beats the candidate order.
|
||||
#[test]
|
||||
fn env_override_wins() {
|
||||
|
||||
@@ -26,9 +26,7 @@
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use punktfunk_core::config::{
|
||||
mtu1500_shard_payload_for, CompositorPref, FecConfig, FecScheme, GamepadPref, Role,
|
||||
};
|
||||
use punktfunk_core::config::{CompositorPref, FecConfig, FecScheme, GamepadPref, Role};
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use punktfunk_core::packet::{FLAG_PIC, FLAG_PROBE, FLAG_SOF};
|
||||
use punktfunk_core::quic::{
|
||||
@@ -72,6 +70,9 @@ use input::{input_thread, ClientInput};
|
||||
/// The Hello→Welcome→Start negotiation (plan §W1); `serve_session` calls `handshake::negotiate`
|
||||
/// after the pairing gate.
|
||||
mod handshake;
|
||||
/// MTU resilience for the video data plane: `PUNKTFUNK_WIRE_MTU` override, the per-session
|
||||
/// path-MTU watch on the control connection, and the per-peer learned shard-payload clamp.
|
||||
mod wire_mtu;
|
||||
|
||||
/// The mid-stream control task (plan §W1); `serve_session` spawns `control::run` after the
|
||||
/// handshake to multiplex renegotiation / speed-test control messages onto the data-plane channels.
|
||||
@@ -1090,6 +1091,30 @@ async fn serve_session(
|
||||
// just never fires then.
|
||||
let (cursor_shape_tx, cursor_shape_rx) =
|
||||
tokio::sync::mpsc::unbounded_channel::<punktfunk_core::quic::CursorShape>();
|
||||
// Mid-session shard renegotiation (design/shard-payload-reneg.md Phase 2): the wire-MTU
|
||||
// watcher decides (constrained-path shrink / ack-gated jumbo grow), the control task
|
||||
// writes the `ShardPayloadChanged` and routes the acks back, and the data-plane loop
|
||||
// applies `Session::set_shard_payload` between AUs (drained next to `bitrate_rx`).
|
||||
// Channels are wired unconditionally (they just never fire); the DRIVER exists only for
|
||||
// a client that advertised `Hello::max_shard_payload` on a non-chunk-aligned session —
|
||||
// PyroWave clients parse chunk-aligned AUs in windows of the `Welcome` value pinned at
|
||||
// session start (read once over the C ABI), so those sessions keep the leg-1
|
||||
// next-session clamp instead of a mid-stream re-key.
|
||||
let (shard_change_tx, shard_change_rx) = tokio::sync::mpsc::unbounded_channel::<u16>();
|
||||
let (shard_ack_tx, shard_ack_rx) = tokio::sync::mpsc::unbounded_channel::<u16>();
|
||||
let (shard_apply_tx, shard_apply_rx) = std::sync::mpsc::channel::<usize>();
|
||||
let shard_reneg = (hello.max_shard_payload > 0 && codec != crate::encode::Codec::PyroWave)
|
||||
.then_some(wire_mtu::ShardReneg {
|
||||
client_ceiling: hello.max_shard_payload,
|
||||
change_tx: shard_change_tx,
|
||||
ack_rx: shard_ack_rx,
|
||||
apply_tx: shard_apply_tx,
|
||||
});
|
||||
// The session is real: watch this connection's MTU discovery settle and turn it into a
|
||||
// path verdict (WARN + learned clamp for the next session on a constrained path; clears
|
||||
// a stale clamp on a healthy one) — and, with the driver above, heal or grow THIS
|
||||
// session mid-stream. Bounded ~10 s task unless a jumbo grow leaves it as revert guard.
|
||||
wire_mtu::spawn_watch(conn.clone(), welcome.shard_payload as usize, shard_reneg);
|
||||
// Negotiated cursor forwarding: the HOST_CAP_CURSOR bit the Welcome advertised, read back
|
||||
// rather than recomputed (`handshake::cursor_forward` computed it once, with the encoder
|
||||
// blend-capability gate — re-running it here could drift, and would re-probe).
|
||||
@@ -1145,6 +1170,8 @@ async fn serve_session(
|
||||
probe_result_rx,
|
||||
reconfig_result_rx,
|
||||
retarget_rx,
|
||||
shard_change_rx,
|
||||
shard_ack_tx,
|
||||
cursor_shape_rx,
|
||||
cursor_client_draws,
|
||||
clip_enabled,
|
||||
@@ -1307,9 +1334,18 @@ async fn serve_session(
|
||||
let stop = stop.clone();
|
||||
let cap = audio_cap.clone();
|
||||
let channels = welcome.audio_channels;
|
||||
// Read the granted bit back off the Welcome (the cursor plane's precedent), so the wire
|
||||
// the client was promised and the wire we actually send cannot disagree — then re-derive
|
||||
// the SAME budget rung from it, so the encode tier and the redundancy decision are one
|
||||
// choice made once rather than two settings that can drift apart.
|
||||
let budget = handshake::audio_budget(
|
||||
welcome.host_caps & punktfunk_core::quic::HOST_CAP_AUDIO_RED != 0,
|
||||
welcome.bitrate_kbps,
|
||||
channels,
|
||||
);
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk1-audio".into())
|
||||
.spawn(move || audio_thread(conn, stop, cap, channels))
|
||||
.spawn(move || audio_thread(conn, stop, cap, channels, budget))
|
||||
.map_err(|e| tracing::warn!(error = %e, "audio thread spawn failed — session continues without audio"))
|
||||
.ok()
|
||||
} else {
|
||||
@@ -1578,6 +1614,7 @@ async fn serve_session(
|
||||
keyframe: keyframe_rx,
|
||||
rfi: rfi_rx,
|
||||
bitrate_rx,
|
||||
shard_rx: shard_apply_rx,
|
||||
compositor,
|
||||
gamescope_route,
|
||||
bitrate_kbps,
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
//! The native audio plane (plan §W1 — carved out of the [`super`] module): desktop capture → Opus
|
||||
//! (48 kHz, 5 ms, CBR — the same tuning as the GameStream path) → `AUDIO_MAGIC` QUIC datagrams, at
|
||||
//! the negotiated channel count. The encoder ([`NativeAudioEnc`]) and the capture/encode/send loop
|
||||
//! ([`audio_thread`]) are gated to linux/windows (libopus + a real capturer); other targets get the
|
||||
//! stub, so a dev build streams video-only rather than failing to compile.
|
||||
//! (48 kHz, 5 ms, constrained VBR at the configured [`AudioTier`](punktfunk_core::audio::AudioTier))
|
||||
//! → `AUDIO_MAGIC` QUIC datagrams — or `AUDIO_RED_MAGIC` when the session negotiated redundancy —
|
||||
//! at the negotiated channel count. The encoder ([`NativeAudioEnc`]) and the capture/encode/send
|
||||
//! loop ([`audio_thread`]) are gated to linux/windows (libopus + a real capturer); other targets
|
||||
//! get the stub, so a dev build streams video-only rather than failing to compile.
|
||||
//!
|
||||
//! Two things here deliberately DIVERGE from the GameStream plane, which used to share this
|
||||
//! tuning: hard CBR (its audio FEC needs fixed-size packets; this plane has no FEC, so CBR was a
|
||||
//! pure quality tax) and the fixed 128 kbps stereo bitrate. See [`NativeAudioEnc::new`].
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -17,20 +22,36 @@ enum NativeAudioEnc {
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
impl NativeAudioEnc {
|
||||
/// Build the encoder for `channels` (2/6/8), hard-CBR + RESTRICTED_LOWDELAY like the
|
||||
/// GameStream path; bitrate from the shared layout table (stereo keeps the validated 128 kbps).
|
||||
fn new(channels: u8) -> Result<NativeAudioEnc, opus::Error> {
|
||||
/// Build the encoder for `channels` (2/6/8) at `tier`, RESTRICTED_LOWDELAY like the GameStream
|
||||
/// path but — unlike it — in CONSTRAINED VBR.
|
||||
///
|
||||
/// **Why not hard CBR (WP1.2).** The layout table's comment justifies `set_vbr(false)` with
|
||||
/// "constant packet size, which GameStream's audio FEC relies on" — true of the GameStream
|
||||
/// plane, and irrelevant here: the native `punktfunk/1` audio plane has no FEC at all (see
|
||||
/// `punktfunk_core::audio::AudioGapTracker`, which exists precisely because a lost packet has
|
||||
/// nothing to rebuild it from). So this path was paying a pure quality tax for a constraint
|
||||
/// that does not apply to it. Constrained VBR keeps the same average bitrate and the same
|
||||
/// bounded packet size, and spends the bits where the signal needs them.
|
||||
///
|
||||
/// The GameStream encoder (`crate::gamestream::audio`) is deliberately NOT changed: its FEC
|
||||
/// really does need fixed-size packets.
|
||||
fn new(
|
||||
channels: u8,
|
||||
tier: punktfunk_core::audio::AudioTier,
|
||||
) -> Result<NativeAudioEnc, opus::Error> {
|
||||
let l = punktfunk_core::audio::layout_for(channels, false);
|
||||
let bitrate = l.bitrate_for(tier);
|
||||
if channels == 2 {
|
||||
let mut e = opus::Encoder::new(
|
||||
crate::audio::SAMPLE_RATE,
|
||||
opus::Channels::Stereo,
|
||||
opus::Application::LowDelay,
|
||||
)?;
|
||||
e.set_bitrate(opus::Bitrate::Bits(128_000)).ok();
|
||||
e.set_vbr(false).ok();
|
||||
e.set_bitrate(opus::Bitrate::Bits(bitrate)).ok();
|
||||
e.set_vbr(true).ok();
|
||||
e.set_vbr_constraint(true).ok();
|
||||
Ok(NativeAudioEnc::Stereo(e))
|
||||
} else {
|
||||
let l = punktfunk_core::audio::layout_for(channels, false);
|
||||
let mut e = opus::MSEncoder::new(
|
||||
crate::audio::SAMPLE_RATE,
|
||||
l.streams,
|
||||
@@ -38,8 +59,9 @@ impl NativeAudioEnc {
|
||||
l.mapping,
|
||||
opus::Application::LowDelay,
|
||||
)?;
|
||||
e.set_bitrate(opus::Bitrate::Bits(l.bitrate)).ok();
|
||||
e.set_vbr(false).ok();
|
||||
e.set_bitrate(opus::Bitrate::Bits(bitrate)).ok();
|
||||
e.set_vbr(true).ok();
|
||||
e.set_vbr_constraint(true).ok();
|
||||
Ok(NativeAudioEnc::Surround(e))
|
||||
}
|
||||
}
|
||||
@@ -52,8 +74,8 @@ impl NativeAudioEnc {
|
||||
}
|
||||
}
|
||||
|
||||
/// The audio thread: desktop capture → Opus (48 kHz, 5 ms, CBR — same tuning as the GameStream
|
||||
/// path) → `AUDIO_MAGIC` datagrams, at the negotiated `channels` (2 stereo / 6 = 5.1 / 8 = 7.1,
|
||||
/// The audio thread: desktop capture → Opus (48 kHz, 5 ms, constrained VBR at the configured
|
||||
/// tier) → `AUDIO_MAGIC` (or `AUDIO_RED_MAGIC`) datagrams, at the negotiated `channels` (2 stereo / 6 = 5.1 / 8 = 7.1,
|
||||
/// canonical wire order FL FR FC LFE RL RR SL SR). QUIC already encrypts; no extra layer. The
|
||||
/// capturer comes from (and returns to) the persistent slot — see [`AudioCapSlot`].
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
@@ -62,11 +84,16 @@ pub(super) fn audio_thread(
|
||||
stop: Arc<AtomicBool>,
|
||||
audio_cap: AudioCapSlot,
|
||||
channels: u8,
|
||||
budget: punktfunk_core::audio::AudioBudget,
|
||||
) {
|
||||
use crate::audio::SAMPLE_RATE;
|
||||
const FRAME_MS: usize = 5;
|
||||
const SAMPLES_PER_FRAME: usize = SAMPLE_RATE as usize * FRAME_MS / 1000; // 240
|
||||
let want = punktfunk_core::audio::normalize_channels(channels);
|
||||
// Tier and redundancy are ONE decision, budgeted against the session's video bitrate — see
|
||||
// `handshake::audio_budget`. An unparseable `audio.quality` was already warned about there
|
||||
// and fell back to the default, so nothing here can silently downgrade someone's audio.
|
||||
let (tier, redundancy) = (budget.tier, budget.redundancy);
|
||||
|
||||
// Reuse the cached capturer ONLY when its channel count matches this session's; a stereo
|
||||
// capturer left by a prior session must not feed a 5.1/7.1 session (the encoder + the client's
|
||||
@@ -92,7 +119,7 @@ pub(super) fn audio_thread(
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut enc = match NativeAudioEnc::new(want) {
|
||||
let mut enc = match NativeAudioEnc::new(want, tier) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "opus encoder init failed — session continues without audio");
|
||||
@@ -120,9 +147,16 @@ pub(super) fn audio_thread(
|
||||
// A stuck Opus encoder would fail on every 5 ms frame (~200/s); power-of-two throttle the
|
||||
// warn so it can't flood stderr + the log ring while still surfacing that it's failing.
|
||||
let mut opus_encode_errs: u64 = 0;
|
||||
// WP3.1 — the previous frame's Opus bytes, for the redundant `0xD2` plane. Cleared whenever
|
||||
// continuity breaks (a capture reopen), so we never advertise a predecessor the client's
|
||||
// sequence numbering does not agree with.
|
||||
let mut prev_frame: Vec<u8> = Vec::new();
|
||||
if capturer.is_some() {
|
||||
tracing::info!(
|
||||
channels = want,
|
||||
tier = tier.as_str(),
|
||||
kbps = budget.kbps,
|
||||
redundancy,
|
||||
"punktfunk/1 audio streaming (Opus 48 kHz, 5 ms datagrams)"
|
||||
);
|
||||
}
|
||||
@@ -138,6 +172,10 @@ pub(super) fn audio_thread(
|
||||
capturer = Some(c);
|
||||
last_failed = None;
|
||||
acc.clear(); // drop the partial frame straddling the gap
|
||||
// The next frame has no valid predecessor across the gap: sending the
|
||||
// pre-gap frame as "the previous one" would hand the client audio from
|
||||
// before the discontinuity to splice in.
|
||||
prev_frame.clear();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %format!("{e:#}"), "audio reopen failed — will retry");
|
||||
@@ -162,11 +200,24 @@ pub(super) fn audio_thread(
|
||||
let pts_ns = now_ns();
|
||||
match enc.encode_float(&frame, &mut opus_buf) {
|
||||
Ok(n) => {
|
||||
let d =
|
||||
punktfunk_core::quic::encode_audio_datagram(seq, pts_ns, &opus_buf[..n]);
|
||||
let opus = &opus_buf[..n];
|
||||
let d = if redundancy {
|
||||
punktfunk_core::quic::encode_audio_red_datagram(
|
||||
seq,
|
||||
pts_ns,
|
||||
opus,
|
||||
&prev_frame,
|
||||
)
|
||||
} else {
|
||||
punktfunk_core::quic::encode_audio_datagram(seq, pts_ns, opus)
|
||||
};
|
||||
if conn.send_datagram(d.into()).is_err() {
|
||||
break 'session; // connection gone
|
||||
}
|
||||
if redundancy {
|
||||
prev_frame.clear();
|
||||
prev_frame.extend_from_slice(opus);
|
||||
}
|
||||
seq = seq.wrapping_add(1);
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -199,6 +250,7 @@ pub(super) fn audio_thread(
|
||||
_stop: Arc<AtomicBool>,
|
||||
_audio_cap: AudioCapSlot,
|
||||
_channels: u8,
|
||||
_budget: punktfunk_core::audio::AudioBudget,
|
||||
) {
|
||||
tracing::warn!("punktfunk/1 audio requires Linux or Windows — session continues without it");
|
||||
}
|
||||
|
||||
@@ -43,6 +43,11 @@ pub(super) async fn run(
|
||||
// Host-initiated bitrate re-target (a rebuild re-resolved an Automatic rate): forwarded to
|
||||
// the client as a `BitrateChanged` so its controller's climb base tracks the real encoder.
|
||||
mut retarget_rx: tokio::sync::mpsc::UnboundedReceiver<u32>,
|
||||
// Mid-session shard renegotiation (design/shard-payload-reneg.md): the wire-MTU watcher
|
||||
// asks for a `ShardPayloadChanged` here (this task is the control stream's sole writer),
|
||||
// and the client's `ShardPayloadAck`s flow back on `shard_ack_tx` — the grow gate.
|
||||
mut shard_change_rx: tokio::sync::mpsc::UnboundedReceiver<u16>,
|
||||
shard_ack_tx: tokio::sync::mpsc::UnboundedSender<u16>,
|
||||
mut cursor_shape_rx: tokio::sync::mpsc::UnboundedReceiver<punktfunk_core::quic::CursorShape>,
|
||||
cursor_client_draws: Arc<AtomicBool>,
|
||||
clip_enabled: Arc<AtomicBool>,
|
||||
@@ -56,6 +61,9 @@ pub(super) async fn run(
|
||||
// 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;
|
||||
// Same discipline for the wire-MTU watcher's channel — its bounded lifetime ends mid-session
|
||||
// on every healthy path.
|
||||
let mut shard_change_closed = false;
|
||||
let mut active = initial_mode;
|
||||
// Host-side switch rate limit (a backstop against a hostile/broken client spamming
|
||||
// Reconfigure into pipeline-rebuild churn — the drain-to-newest in the data plane already
|
||||
@@ -214,6 +222,16 @@ pub(super) async fn run(
|
||||
if bitrate_tx.send(resolved).is_err() {
|
||||
break; // data plane gone
|
||||
}
|
||||
} else if let Ok(ack) = punktfunk_core::quic::ShardPayloadAck::decode(&msg) {
|
||||
// Mid-session shard renegotiation: the client applied (or granted) a
|
||||
// geometry change. Forward to the wire-MTU watcher — for a grow this IS
|
||||
// the gate that lets the packetizer go above the old size. A dropped
|
||||
// send just means the watcher already ended (shrink acks are telemetry).
|
||||
tracing::info!(
|
||||
shard_payload = ack.shard_payload,
|
||||
"client acked shard-payload change"
|
||||
);
|
||||
let _ = shard_ack_tx.send(ack.shard_payload);
|
||||
} else if let Ok(req) = ProbeRequest::decode(&msg) {
|
||||
tracing::info!(
|
||||
target_kbps = req.target_kbps,
|
||||
@@ -317,6 +335,19 @@ pub(super) async fn run(
|
||||
break;
|
||||
}
|
||||
}
|
||||
n = shard_change_rx.recv(), if !shard_change_closed => {
|
||||
// Mid-session shard renegotiation: the wire-MTU watcher decided (shrink on a
|
||||
// constrained-path verdict / ack-gated jumbo grow). Only ever fires toward a
|
||||
// client that advertised `Hello::max_shard_payload` — the watcher owns that
|
||||
// gate. `None` = the watcher's bounded lifetime ended (normal, NOT a session
|
||||
// end): disable this branch, exactly the `clip_offer_closed` pattern — a
|
||||
// closed mpsc yields `None` perpetually and would busy-spin the select.
|
||||
let Some(n) = n else { shard_change_closed = true; continue };
|
||||
let msg = punktfunk_core::quic::ShardPayloadChanged { shard_payload: n };
|
||||
if io::write_msg(&mut ctrl_send, &msg.encode()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
shape = cursor_shape_rx.recv() => {
|
||||
// Cursor-forward bridge (M2): the encode loop diffed a new pointer bitmap.
|
||||
// Rare (shape changes are human-paced); ≤ ~58 KiB fits the u16 frame by
|
||||
|
||||
@@ -24,6 +24,63 @@ use super::*;
|
||||
/// paints on a Mutter virtual stream), and only a can't-blend backend falls back to the
|
||||
/// compositor EMBED. THE single predicate: the Welcome's `HOST_CAP_CURSOR` bit is computed
|
||||
/// from it, and the session wiring reads that bit back.
|
||||
/// THE single audio-plane decision for a session: the encode tier AND whether the redundant
|
||||
/// `0xD2` plane is sent. The Welcome's `HOST_CAP_AUDIO_RED` bit is computed from it, and
|
||||
/// `serve_session` reads that bit back to configure the audio thread — so the wire the client is
|
||||
/// promised and the wire we send cannot disagree.
|
||||
///
|
||||
/// Capable-and-agreed for redundancy: the client must have advertised `CLIENT_CAP_AUDIO_RED`, so a
|
||||
/// session with an older client keeps the plain `0xC9` wire byte-for-byte.
|
||||
///
|
||||
/// **Both halves are then BUDGETED against the session's video bitrate**
|
||||
/// ([`plan_audio_budget`](punktfunk_core::audio::plan_audio_budget)). Tier `High` and redundancy
|
||||
/// were introduced separately, each costed as "~1 % of the video budget", and they multiply:
|
||||
/// 256 kbps stereo sent twice is 512 kbps — ~10 % of a 5 Mbps session. Audio rides QUIC datagrams,
|
||||
/// outside the ABR loop, so ABR can neither see that nor reclaim it. The budget is what stops a
|
||||
/// constrained link silently handing a tenth of its bandwidth to audio.
|
||||
///
|
||||
/// The operator's `audio.quality` / `audio.redundancy` settings are the REQUEST; the budget may
|
||||
/// lower them, never raise them.
|
||||
///
|
||||
/// NB the plan's "only while the link is actually losing packets" gate is deliberately not here:
|
||||
/// turning redundancy on and off mid-session changes the wire tag, and the client's decoder would
|
||||
/// have to re-derive which plane it is on from every datagram. Deciding once, at handshake, against
|
||||
/// a bitrate we already know is both cheaper and more predictable.
|
||||
/// `wants_redundancy` is the caller's answer to "is `0xD2` even on the table" — at handshake that
|
||||
/// is the client's cap AND the operator's setting; afterwards it is the GRANTED
|
||||
/// `HOST_CAP_AUDIO_RED` bit, so the audio thread re-derives the same rung of the same ladder.
|
||||
pub(super) fn audio_budget(
|
||||
wants_redundancy: bool,
|
||||
video_kbps: u32,
|
||||
channels: u8,
|
||||
) -> punktfunk_core::audio::AudioBudget {
|
||||
let configured = pf_host_config::config().audio_quality.as_deref();
|
||||
let requested = match configured {
|
||||
None => punktfunk_core::audio::AudioTier::default(),
|
||||
Some(s) => punktfunk_core::audio::AudioTier::parse(s).unwrap_or_else(|| {
|
||||
// Once per process: this runs per session, and an operator with a typo in host.env
|
||||
// does not need it on every connect. Never silently downgrade someone's audio.
|
||||
static WARNED: std::sync::Once = std::sync::Once::new();
|
||||
WARNED.call_once(|| {
|
||||
tracing::warn!(
|
||||
value = %s,
|
||||
"audio.quality (PUNKTFUNK_AUDIO_QUALITY) is not one of low/standard/high — \
|
||||
using the default"
|
||||
);
|
||||
});
|
||||
punktfunk_core::audio::AudioTier::default()
|
||||
}),
|
||||
};
|
||||
punktfunk_core::audio::plan_audio_budget(video_kbps, channels, requested, wants_redundancy)
|
||||
}
|
||||
|
||||
/// The operator's answer to "may this session use redundancy at all", before the budget is
|
||||
/// consulted: the client must be able to decode it and the operator must not have forced it off.
|
||||
pub(super) fn redundancy_offered(client_caps: u8) -> bool {
|
||||
client_caps & punktfunk_core::quic::CLIENT_CAP_AUDIO_RED != 0
|
||||
&& pf_host_config::config().audio_redundancy.unwrap_or(true)
|
||||
}
|
||||
|
||||
pub(super) fn cursor_forward(
|
||||
client_caps: u8,
|
||||
compositor: Option<crate::vdisplay::Compositor>,
|
||||
@@ -491,7 +548,12 @@ pub(super) async fn negotiate(
|
||||
// per-datagram loss on Wi-Fi — the "100 Mbps badly fails on the phone" root cause.
|
||||
// Negotiated, so the client follows. Jumbo (≈8900) is a future negotiated bump (needs
|
||||
// MAX_DATAGRAM_BYTES raised + end-to-end 9000 MTU).
|
||||
shard_payload: mtu1500_shard_payload_for(peer.ip()) as u16,
|
||||
// Resolution order (wire_mtu.rs): `PUNKTFUNK_WIRE_MTU` operator override, then a path
|
||||
// budget learned from a prior session whose QUIC MTU discovery settled below the
|
||||
// video-datagram ceiling (the "VPN on the host blackholes every video packet" field
|
||||
// shape — small flows pass, the stream is an endless black screen), then this family
|
||||
// default. Healthy paths take the default branch and are byte-identical to before.
|
||||
shard_payload: wire_mtu::negotiated_shard_payload(peer.ip()) as u16,
|
||||
encrypt: true,
|
||||
key,
|
||||
salt,
|
||||
@@ -564,6 +626,20 @@ pub(super) async fn negotiate(
|
||||
punktfunk_core::quic::HOST_CAP_PEN
|
||||
} else {
|
||||
0
|
||||
}
|
||||
// Redundant desktop-audio plane (0xD2): the client asked, the operator has not forced
|
||||
// it off, AND it fits the session's audio budget. Capable-and-agreed like the cursor
|
||||
// bit — a client that did not ask keeps the plain 0xC9 wire byte-for-byte.
|
||||
| if audio_budget(
|
||||
redundancy_offered(hello.client_caps),
|
||||
bitrate_kbps,
|
||||
audio_channels,
|
||||
)
|
||||
.redundancy
|
||||
{
|
||||
punktfunk_core::quic::HOST_CAP_AUDIO_RED
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// The negotiated session AEAD (resolved above) + its 32-byte key toward a ChaCha
|
||||
// client; toward everyone else cipher 0 keeps the Welcome byte-identical to the
|
||||
@@ -658,6 +734,9 @@ pub(super) async fn negotiate(
|
||||
let start =
|
||||
Start::decode(&io::read_msg(recv).await?).map_err(|e| anyhow!("Start decode: {e:?}"))?;
|
||||
bringup.mark("start");
|
||||
// The wire-MTU watch (`wire_mtu::spawn_watch`) is spawned by `serve_session` after the
|
||||
// control-task channels exist — it now also DRIVES the mid-session shard renegotiation
|
||||
// (design/shard-payload-reneg.md), which needs the control stream's writer.
|
||||
Ok::<_, anyhow::Error>((
|
||||
hello,
|
||||
welcome,
|
||||
|
||||
@@ -762,6 +762,9 @@ fn send_loop(
|
||||
slice_wire: bool,
|
||||
burst_cap: Option<usize>,
|
||||
fec_target: Arc<AtomicU8>,
|
||||
// Mid-session shard-payload re-keys from the wire-MTU watcher (validated + ack-gated
|
||||
// there) — applied between AUs only (design/shard-payload-reneg.md Phase 1).
|
||||
shard_rx: std::sync::mpsc::Receiver<usize>,
|
||||
stats: SendStats,
|
||||
// `Some` = the client advertised VIDEO_CAP_HOST_TIMING: emit one 0xCF datagram per AU right
|
||||
// after its last packet left the socket (capture→sent, the whole host pipeline incl. pacing).
|
||||
@@ -818,6 +821,25 @@ fn send_loop(
|
||||
}
|
||||
// Adaptive FEC: pick up any new recovery target the control task set from client LossReports.
|
||||
apply_fec_target(&mut session, &fec_target);
|
||||
// Mid-session shard renegotiation: apply a re-key from the wire-MTU watcher — between
|
||||
// AUs only, NEVER with a streamed AU open (its shard-aligned tiling derives from the
|
||||
// size it began with; same gate as the probe burst above). Drain to the newest; the
|
||||
// protocol side (client advertisement, ack-gated grow) was enforced by the watcher.
|
||||
if streamed.is_none() {
|
||||
let mut want_shard = None;
|
||||
while let Ok(s) = shard_rx.try_recv() {
|
||||
want_shard = Some(s);
|
||||
}
|
||||
if let Some(s) = want_shard {
|
||||
match session.set_shard_payload(s) {
|
||||
Ok(()) => tracing::info!(shard_payload = s, "wire shard payload re-keyed"),
|
||||
// Can't fire for a watcher-driven value (it validates the same bounds) —
|
||||
// belt-and-suspenders for a future driver.
|
||||
Err(e) => tracing::warn!(shard_payload = s, error = ?e,
|
||||
"shard re-key refused by session validation"),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Short timeout so we keep re-checking `stop` + probes when no frames are flowing.
|
||||
match frame_rx.recv_timeout(std::time::Duration::from_millis(50)) {
|
||||
Ok(send_msg) => {
|
||||
@@ -1171,6 +1193,11 @@ pub(super) struct SessionContext {
|
||||
/// Accepted mid-stream bitrate changes (adaptive bitrate, already clamped) — the encoder
|
||||
/// alone is rebuilt in place at the new rate; capture + virtual output are untouched.
|
||||
pub(super) bitrate_rx: std::sync::mpsc::Receiver<u32>,
|
||||
/// Mid-session shard-payload changes from the wire-MTU watcher (already validated +
|
||||
/// protocol-gated there; a grow arrives only after the client's ack). Applied between
|
||||
/// AUs via [`Session::set_shard_payload`] — the packetizer re-keys, capture/encoder/
|
||||
/// virtual output are untouched (design/shard-payload-reneg.md Phase 1).
|
||||
pub(super) shard_rx: std::sync::mpsc::Receiver<usize>,
|
||||
/// The resolved compositor backend (moot on Windows — `vdisplay::open` ignores it there).
|
||||
pub(super) compositor: crate::vdisplay::Compositor,
|
||||
/// This session's resolved gamescope sub-mode, or `None` for every other backend. Carried here
|
||||
@@ -1385,6 +1412,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
keyframe,
|
||||
rfi,
|
||||
bitrate_rx,
|
||||
shard_rx,
|
||||
compositor,
|
||||
gamescope_route,
|
||||
mut bitrate_kbps,
|
||||
@@ -1771,6 +1799,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
slice_wire,
|
||||
burst_cap,
|
||||
fec_target,
|
||||
shard_rx,
|
||||
send_stats,
|
||||
timing_conn,
|
||||
phase_send,
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
//! MTU resilience for the video data plane (the "connects fine, black screen forever" field
|
||||
//! shape).
|
||||
//!
|
||||
//! Video datagrams are sealed at a per-session `shard_payload` sized for a clean 1500-byte MTU
|
||||
//! (1472-byte UDP payloads). A host whose route to the client runs through a smaller-MTU hop —
|
||||
//! a VPN/overlay adapter (Tailscale/WARP/ZeroTier default to 1280) claiming the LAN route, or a
|
||||
//! lowered NIC MTU — delivers every SMALL flow (QUIC control, hole punch, input, audio) while
|
||||
//! 100 % of video datagrams die by fragmentation or local `WSAEMSGSIZE`: the client sits on a
|
||||
//! black screen reporting `loss_ppm=0` (it can't see gaps in packets it never saw any of) and
|
||||
//! the host streams into the void with every gauge green. Neither side observes the failure
|
||||
//! directly — but the control connection CAN: its MTU discovery probes up to exactly the sealed
|
||||
//! video-datagram size ([`video_datagram_udp_ceiling`], set in `quic/endpoint.rs`), so its
|
||||
//! settled MTU is a verdict on the path.
|
||||
//!
|
||||
//! Three legs, none of which changes a session on a healthy path:
|
||||
//! - **`PUNKTFUNK_WIRE_MTU=<bytes>`** — operator override; the shard payload is derived from
|
||||
//! the given on-wire IP MTU. Wire-compatible with every deployed client:
|
||||
//! `Welcome::shard_payload` is already negotiated per session (the v4/v6 split ships two
|
||||
//! values today) and clients follow the negotiated value.
|
||||
//! - **Watch** — a per-session task samples the control connection's discovered MTU once the
|
||||
//! search has had time to finish. A connection still alive that settled BELOW the ceiling is
|
||||
//! proof the path can't carry full-size video: log an actionable WARN and record the measured
|
||||
//! budget for the peer.
|
||||
//! - **Heal** — the next handshake from that peer clamps `shard_payload` to the recorded
|
||||
//! budget, so a reconnect fixes the stream. A later session that reaches the ceiling erases
|
||||
//! the record (the learn/heal loop is self-correcting in both directions).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use punktfunk_core::config::{
|
||||
jumbo_shard_payload_for, jumbo_wire_mtu, mtu1500_shard_payload_for, sealed_datagram_bytes,
|
||||
shard_payload_for_udp_budget, shard_payload_for_wire_mtu, video_datagram_udp_ceiling,
|
||||
};
|
||||
|
||||
/// Everything the MID-SESSION renegotiation driver needs (design/shard-payload-reneg.md
|
||||
/// Phase 2) — `None` at [`spawn_watch`] makes the watcher observe-and-learn only (leg-1
|
||||
/// behavior). Constructed ONLY when the client's `Hello::max_shard_payload` advertised
|
||||
/// per-frame geometry AND the session's wire is not chunk-aligned: a PyroWave client parses
|
||||
/// chunk-aligned AUs in windows of the `Welcome` value pinned at session start (Apple
|
||||
/// `Stage2Pipeline` / `pf-client-core` video.rs read it once over the C ABI), so re-keying
|
||||
/// such a session mid-stream would corrupt its parse — those sessions keep the leg-1
|
||||
/// next-session clamp instead.
|
||||
pub(super) struct ShardReneg {
|
||||
/// The client's advertised receive ceiling (bytes of shard; > 0 by construction).
|
||||
pub client_ceiling: u16,
|
||||
/// → control task (the control stream's sole writer): send `ShardPayloadChanged{n}`.
|
||||
pub change_tx: tokio::sync::mpsc::UnboundedSender<u16>,
|
||||
/// ← control task: the client's `ShardPayloadAck`s (the grow gate).
|
||||
pub ack_rx: tokio::sync::mpsc::UnboundedReceiver<u16>,
|
||||
/// → data plane: apply [`Session::set_shard_payload`] between AUs
|
||||
/// (drained next to `bitrate_rx` in the encode loop).
|
||||
pub apply_tx: std::sync::mpsc::Sender<usize>,
|
||||
}
|
||||
|
||||
/// Measured UDP-payload budget per peer IP, learned from live control connections whose MTU
|
||||
/// discovery settled below the video-datagram ceiling. In-memory only: a host restart
|
||||
/// re-learns in one session, and entries self-correct (a later ceiling-hit erases, a lower
|
||||
/// re-measure overwrites).
|
||||
fn learned() -> &'static Mutex<HashMap<IpAddr, u16>> {
|
||||
static LEARNED: OnceLock<Mutex<HashMap<IpAddr, u16>>> = OnceLock::new();
|
||||
LEARNED.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// The shard payload for a new session to `peer`: `PUNKTFUNK_WIRE_MTU` override, else the
|
||||
/// peer's learned path budget, else the family default (today's exact behavior). Logs whenever
|
||||
/// the result differs from the default.
|
||||
pub(super) fn negotiated_shard_payload(peer: IpAddr) -> usize {
|
||||
let env = match std::env::var("PUNKTFUNK_WIRE_MTU") {
|
||||
Ok(v) => match v.trim().parse::<usize>() {
|
||||
Ok(mtu) => Some(mtu),
|
||||
Err(_) => {
|
||||
tracing::warn!(value = %v, "PUNKTFUNK_WIRE_MTU is not a number — ignoring it");
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(_) => None,
|
||||
};
|
||||
let learned_budget = learned().lock().unwrap().get(&peer).copied();
|
||||
resolve(env, learned_budget, peer)
|
||||
}
|
||||
|
||||
/// Pure resolution (env override > learned budget > family default) — the tested core of
|
||||
/// [`negotiated_shard_payload`].
|
||||
fn resolve(env_wire_mtu: Option<usize>, learned_udp_budget: Option<u16>, peer: IpAddr) -> usize {
|
||||
let default = mtu1500_shard_payload_for(peer);
|
||||
if let Some(mtu) = env_wire_mtu {
|
||||
let p = shard_payload_for_wire_mtu(mtu, peer);
|
||||
if p != default {
|
||||
tracing::info!(
|
||||
wire_mtu = mtu,
|
||||
shard_payload = p,
|
||||
default,
|
||||
"wire MTU: shard payload set from PUNKTFUNK_WIRE_MTU"
|
||||
);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
if let Some(budget) = learned_udp_budget {
|
||||
let p = shard_payload_for_udp_budget(budget as usize, peer);
|
||||
if p != default {
|
||||
tracing::info!(
|
||||
peer = %peer,
|
||||
udp_budget = budget,
|
||||
shard_payload = p,
|
||||
default,
|
||||
"wire MTU: shard payload clamped to this peer's measured path MTU (learned \
|
||||
from a prior session's QUIC MTU discovery) — video datagrams now fit the \
|
||||
constrained hop"
|
||||
);
|
||||
return p;
|
||||
}
|
||||
}
|
||||
default
|
||||
}
|
||||
|
||||
/// Sample the control connection's discovered MTU after the search has settled and turn it
|
||||
/// into a verdict — and, with a [`ShardReneg`] driver, act on it MID-SESSION
|
||||
/// (design/shard-payload-reneg.md Phase 2): a below-ceiling verdict shrinks the live wire at
|
||||
/// the ~3–10 s mark (session 1 heals instead of staying black), and a settled-at-jumbo
|
||||
/// verdict grows it, ack-gated, when the operator opted in. Spawned once per negotiated
|
||||
/// session; without a grow the task ends after the final sample (bounded ~10 s lifetime,
|
||||
/// holding only a cheap `Connection` handle) — after a grow it stays as the revert guard
|
||||
/// until the connection closes.
|
||||
pub(super) fn spawn_watch(
|
||||
conn: quinn::Connection,
|
||||
session_shard_payload: usize,
|
||||
reneg: Option<ShardReneg>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let peer = conn.remote_address().ip();
|
||||
let ceiling = video_datagram_udp_ceiling() as u16;
|
||||
// Discovery finishes in a handful of RTTs on a LAN (well under the first sample) but
|
||||
// needs a loss timeout per failed probe on a constrained path — the second sample
|
||||
// covers that with margin. Max, because discovery only ever raises `current_mtu`
|
||||
// (the post-grow revert guard below re-reads it live, where blackhole detection CAN
|
||||
// lower it again).
|
||||
let mut settled = 0u16;
|
||||
for wait_s in [3u64, 7] {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(wait_s)).await;
|
||||
settled = settled.max(conn.stats().path.current_mtu);
|
||||
if settled >= ceiling {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// The wire this session is CURRENTLY sealed at — moves on a mid-session shrink/grow.
|
||||
let mut current = session_shard_payload;
|
||||
let mut reneg = reneg;
|
||||
if settled >= ceiling {
|
||||
// The path carries full-size video datagrams — erase any stale learned clamp so
|
||||
// the next session returns to the default wire.
|
||||
if learned().lock().unwrap().remove(&peer).is_some() {
|
||||
tracing::info!(peer = %peer,
|
||||
"wire MTU: path re-measured at full size — learned clamp cleared");
|
||||
}
|
||||
} else {
|
||||
// A closed connection stops discovering, so a session that ended before the final
|
||||
// sample proves nothing (a healthy high-RTT path could still be mid-search): learn
|
||||
// only from a connection that stayed alive through the whole window.
|
||||
if conn.close_reason().is_some() {
|
||||
return;
|
||||
}
|
||||
learned().lock().unwrap().insert(peer, settled);
|
||||
if sealed_datagram_bytes(current) <= settled as usize {
|
||||
// This session was already clamped small enough — the path is still constrained
|
||||
// (keep the record fresh) but video fits, so no alarm.
|
||||
tracing::info!(peer = %peer, discovered_udp_mtu = settled,
|
||||
"wire MTU: constrained path re-measured; this session's video is sized to fit");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
peer = %peer,
|
||||
discovered_udp_mtu = settled,
|
||||
needed_udp_mtu = ceiling,
|
||||
"wire MTU: this path CANNOT carry full-size video datagrams — the control \
|
||||
plane works but every video packet is oversized for a hop, which streams as \
|
||||
an endless black screen with zero reported loss. Typical cause: a VPN/overlay \
|
||||
adapter (Tailscale / Cloudflare WARP / ZeroTier) claiming the LAN route, or a \
|
||||
lowered NIC MTU — compare `ping <client> -f -l 1450` vs `-l 1200` and check \
|
||||
`netsh interface ipv4 show subinterfaces` (Windows) / `ip link` (Linux). The \
|
||||
measured budget is recorded: the NEXT session from this client sizes video to \
|
||||
fit automatically. To pin it for all sessions set PUNKTFUNK_WIRE_MTU."
|
||||
);
|
||||
// Phase 2 down-leg: heal THIS session at the verdict mark. Shrink is sent
|
||||
// and applied immediately — per-frame pinning on the client makes ordering
|
||||
// irrelevant and smaller always fits; the ack is telemetry. The learned
|
||||
// record above still makes session 2 START right.
|
||||
if let Some(r) = reneg.as_ref() {
|
||||
let target = shard_payload_for_udp_budget(settled as usize, peer);
|
||||
if target < current
|
||||
&& r.change_tx.send(target as u16).is_ok()
|
||||
&& r.apply_tx.send(target).is_ok()
|
||||
{
|
||||
tracing::info!(
|
||||
peer = %peer,
|
||||
shard_payload = target,
|
||||
was = current,
|
||||
"wire MTU: video re-keyed mid-session to fit the constrained path \
|
||||
— the stream heals now instead of on the next connect"
|
||||
);
|
||||
current = target;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Phase 2 up-leg: jumbo grow — operator opt-in (PUNKTFUNK_JUMBO / PUNKTFUNK_WIRE_MTU
|
||||
// > 1500, which also raised the endpoint's probe ceiling so `settled` can even reach
|
||||
// here), client-advertised headroom, and a settled-at-jumbo proof. The grow is
|
||||
// ACK-GATED: not one sealed datagram above the old size leaves before the client's
|
||||
// ack, even though its buffers are statically sized — the rule must not erode.
|
||||
let (Some(mtu), Some(r)) = (jumbo_wire_mtu(), reneg.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
let target = jumbo_shard_payload_for(mtu, peer).min(r.client_ceiling as usize);
|
||||
let target = target - target % 2;
|
||||
if target <= current || (settled as usize) < sealed_datagram_bytes(target) {
|
||||
return;
|
||||
}
|
||||
if r.change_tx.send(target as u16).is_err() {
|
||||
return;
|
||||
}
|
||||
let acked = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
while let Some(v) = r.ack_rx.recv().await {
|
||||
if v as usize == target {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !acked {
|
||||
tracing::warn!(peer = %peer, shard_payload = target,
|
||||
"wire MTU: jumbo grow not acked — staying at the current wire");
|
||||
return;
|
||||
}
|
||||
if r.apply_tx.send(target).is_err() {
|
||||
return;
|
||||
}
|
||||
tracing::info!(
|
||||
peer = %peer,
|
||||
shard_payload = target,
|
||||
was = current,
|
||||
wire_mtu = mtu,
|
||||
"wire MTU: jumbo grow acked and applied — packets-per-frame cut ~6×"
|
||||
);
|
||||
current = target;
|
||||
// Revert guard: a mis-proven jumbo hop must self-correct instead of blackholing.
|
||||
// quinn's PMTU blackhole detection lowers `current_mtu` when the big packets start
|
||||
// vanishing; sample it and shrink back through the same path the down-leg uses.
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
if conn.close_reason().is_some() {
|
||||
return;
|
||||
}
|
||||
let mtu_now = conn.stats().path.current_mtu;
|
||||
if (mtu_now as usize) < sealed_datagram_bytes(current) {
|
||||
let back = shard_payload_for_udp_budget(mtu_now as usize, peer);
|
||||
tracing::warn!(peer = %peer, discovered_udp_mtu = mtu_now,
|
||||
shard_payload = back, was = current,
|
||||
"wire MTU: jumbo path stopped fitting — reverting the wire to match");
|
||||
if r.change_tx.send(back as u16).is_err() || r.apply_tx.send(back).is_err() {
|
||||
return;
|
||||
}
|
||||
current = back;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
const V4: IpAddr = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 2));
|
||||
const V6: IpAddr = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1));
|
||||
|
||||
#[test]
|
||||
fn default_when_nothing_known() {
|
||||
assert_eq!(resolve(None, None, V4), mtu1500_shard_payload_for(V4));
|
||||
assert_eq!(resolve(None, None, V6), mtu1500_shard_payload_for(V6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_beats_learned() {
|
||||
// 1280 wire − 28 IP/UDP − 64 header/crypto = 1188.
|
||||
assert_eq!(resolve(Some(1280), Some(1472), V4), 1188);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn learned_budget_clamps() {
|
||||
// A WARP-shaped path: 1280-byte UDP budget → 1280 − 64 = 1216.
|
||||
assert_eq!(resolve(None, Some(1280), V4), 1216);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn learned_at_or_above_ceiling_is_the_default_wire() {
|
||||
assert_eq!(resolve(None, Some(1472), V4), mtu1500_shard_payload_for(V4));
|
||||
assert_eq!(resolve(None, Some(2000), V4), mtu1500_shard_payload_for(V4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_full_mtu_is_the_default_wire_both_families() {
|
||||
assert_eq!(resolve(Some(1500), None, V4), mtu1500_shard_payload_for(V4));
|
||||
assert_eq!(resolve(Some(1500), None, V6), mtu1500_shard_payload_for(V6));
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ track per machine; switching is a one-line change.
|
||||
| **Windows client** (MSIX) | `…/generic/punktfunk-client-windows/canary/punktfunk-client-windows_x64.msix` | `…/latest/…` + the release page |
|
||||
| **Windows host** (installer) | `…/generic/punktfunk-host-windows/canary/punktfunk-host-setup.exe` | `…/latest/…` + the release page |
|
||||
| **Windows host** (winget) | — *(stable only)* | `winget install unom.PunktfunkHost` / `winget upgrade unom.PunktfunkHost`, after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest` |
|
||||
| **Android** | Play **Internal testing** + sideload `…/generic/punktfunk-android/canary/punktfunk-android.apk` | Play **closed (alpha)** track + the release page |
|
||||
| **Android** | Play **Internal testing** (invite-only) + sideload `…/generic/punktfunk-android/canary/punktfunk-android.apk` | **[Google Play](https://play.google.com/store/apps/details?id=io.unom.punktfunk)** (production) + the release page |
|
||||
| **Apple** (mac/iOS/tvOS) | **TestFlight** | TestFlight + a notarized `.dmg` on the release page |
|
||||
|
||||
The apt distribution and the rpm group are just path segments in the URL — switching tracks is a
|
||||
@@ -120,14 +120,16 @@ major bump, or a patch), just tag it — the canary base re-derives from whateve
|
||||
Pre-release tags work too: `v0.2.0-rc1` builds a real release (the `-rc1` suffix is dropped where a
|
||||
strictly-numeric version is required — MSIX, the App Store marketing version).
|
||||
|
||||
### App-store promotion (manual, after the tag)
|
||||
### App-store publication (after the tag)
|
||||
|
||||
CI uploads stable to **testing** tracks only — it never auto-publishes to the public stores:
|
||||
|
||||
- **Apple** — the build lands in **TestFlight**. Promote to the App Store from App Store Connect
|
||||
(submit for review). The notarized `.dmg` on the release page is the direct-download path.
|
||||
- **Android** — the build lands in Play's **closed (alpha)** track. Promote alpha → production in
|
||||
the Play Console when ready.
|
||||
- **Android** — a `vX.Y.Z` tag publishes straight to Google Play **production** at 100%, with no
|
||||
further click. Canary `main` builds go to Play **Internal testing**. To ramp a release gradually
|
||||
instead of shipping it to everyone at once — or to halt or roll one back — use the Play Console,
|
||||
or `android-promote.yml`, which moves a versionCode already on Play between tracks without
|
||||
rebuilding.
|
||||
- **Apple** — still manual. The build lands in **TestFlight**; promote it to the App Store from App
|
||||
Store Connect (submit for review). The notarized `.dmg` on the release page is the
|
||||
direct-download path.
|
||||
|
||||
## Why two tracks (the version-shadow trap)
|
||||
|
||||
|
||||
@@ -15,13 +15,14 @@ The Linux, Windows, Mac, iPhone/iPad and Android apps group settings the same wa
|
||||
**Display**, **Input**, **Audio**, **Controllers** — under *Preferences* on Linux and *Settings*
|
||||
elsewhere. The Apple TV app shows one scrolling list instead, and so does any client's settings
|
||||
screen reached with a controller. A controller-driven launch (Steam Deck Gaming Mode) opens the
|
||||
client's **console home**, whose settings screen is one steppable list; the Decky plugin's Settings
|
||||
tab covers the same store in the same groups and the same order, as a left rail of categories the
|
||||
way SteamOS's own Settings looks. The console home is part of the
|
||||
client — it is not the host's
|
||||
[web console](/docs/web-console).
|
||||
client's **console home**, whose settings screen is one steppable list of sections — **Stream**,
|
||||
**Video**, **Presentation**, **Audio**, **Controller**, **Touchscreen**, **Interface**,
|
||||
**Profiles**. On a Steam Deck that list *is* the settings surface: the
|
||||
[Decky plugin](/docs/steam-deck) is a launcher and keeps no settings of its own, and its **Open
|
||||
Punktfunk** button puts the console home one tap from the Quick Access Menu. The console home is
|
||||
part of the client — it is not the host's [web console](/docs/web-console).
|
||||
|
||||
Linux stores them in `~/.config/punktfunk/client-gtk-settings.json`, the same file the Decky plugin
|
||||
Linux stores them in `~/.config/punktfunk/client-gtk-settings.json`, the same file the console home
|
||||
writes, so a change in either shows up in the other. Windows uses
|
||||
`%APPDATA%\punktfunk\client-windows-settings.json`; the Apple and Android apps use their own stores.
|
||||
|
||||
@@ -45,9 +46,9 @@ and your client scales what it gets — see
|
||||
|
||||
**Match window** — *default: off.* The stream mode follows your window instead, and each resize
|
||||
renegotiates the host's display and encoder, so a windowed session stays pixel-exact. Fullscreen
|
||||
degenerates to the display's native mode. Offered by the Linux, Windows, Mac, iPhone/iPad, console
|
||||
home and Decky screens (on Decky it sits in the Resolution picker, and Gaming-Mode streams are
|
||||
always fullscreen, so it lands on native); not by Android.
|
||||
degenerates to the display's native mode. Offered by the Linux, Windows, Mac, iPhone/iPad and
|
||||
console-home screens (in the console home it is an option inside the Resolution picker, and a
|
||||
Gaming-Mode stream is always fullscreen, so there it lands on native); not by Android.
|
||||
|
||||
**Refresh rate** — *default: Native*, the refresh of the display your window is on. The Apple app
|
||||
stores an explicit rate (60 Hz by default): iPhone and iPad offer the rates the device can display,
|
||||
@@ -68,11 +69,11 @@ capacity probe stay off for the whole session.
|
||||
multiplied by this, and your device resamples the result to its window. Above 1× supersamples for
|
||||
sharpness, at more bandwidth *and* more decode work; below 1× is lighter on both the host and the
|
||||
link. The stops run 0.5× to 4×. The result is floored to an even size and capped per axis at
|
||||
4096 px for H.264, 8192 px otherwise. Offered everywhere except the console home's list.
|
||||
4096 px for H.264, 8192 px otherwise. Offered everywhere.
|
||||
|
||||
**Video codec** — *default: Automatic.* A soft preference: the host emits your choice when it can
|
||||
also produce it, otherwise the best codec you both speak, in the order HEVC → AV1 → H.264.
|
||||
**PyroWave** is never auto-picked — pick it explicitly on Linux, Windows, the console home, Decky, or
|
||||
**PyroWave** is never auto-picked — pick it explicitly on Linux, Windows, the console home, or
|
||||
an Apple device whose decode probe passes; anywhere else it isn't offered, and asking for it lands on
|
||||
that same order. See [PyroWave](/docs/pyrowave). The Android and Apple apps hide AV1 unless the
|
||||
device has a hardware AV1 decoder; Android never offers PyroWave.
|
||||
@@ -86,13 +87,13 @@ Full detail: [HDR](/docs/hdr).
|
||||
needs HEVC or PyroWave, the host's own 4:4:4 policy left on, a capture path that delivers full
|
||||
chroma, and a GPU that can encode it; if any gate fails the host says 4:2:0 before your decoder is
|
||||
built. The Apple, Linux and Windows apps all advertise it (Apple additionally requires its hardware
|
||||
decode probe to pass). The console home and Decky offer the toggle; Android doesn't.
|
||||
decode probe to pass). The console home offers the toggle; Android doesn't.
|
||||
|
||||
**Prioritize** — *default: Lowest latency.* What the client optimizes for when a decoded frame is
|
||||
ready. **Lowest latency** shows every frame the moment the display can take it, so a network hiccup
|
||||
becomes an occasional repeated or skipped frame. **Smoothness** holds a small buffer that evens
|
||||
those hiccups out, at that buffer's worth of added delay. Linux and Windows apps, the console home
|
||||
and Decky; the Apple and Android apps have carried the same setting for a while, and it is stored
|
||||
those hiccups out, at that buffer's worth of added delay. Linux and Windows apps and the console
|
||||
home; the Apple and Android apps have carried the same setting for a while, and it is stored
|
||||
under the same name, so a [profile](/docs/profiles-and-links) means the same thing on every device.
|
||||
|
||||
**Smoothness buffer** — *default: Automatic (two frames).* Only shown under **Smoothness**. How
|
||||
@@ -106,7 +107,7 @@ the instant it's ready instead of waiting for the screen's next refresh: the low
|
||||
can give you, at the cost of visible tearing on fast motion. It is **best-effort** — not every
|
||||
driver or compositor offers a tearing mode, and where none is available the stream stays tear-free.
|
||||
The Detailed [stats overlay](/docs/stats) names the mode actually in use, so you can tell "off"
|
||||
from "off but unavailable". Linux and Windows apps, the console home and Decky.
|
||||
from "off but unavailable". Linux and Windows apps and the console home.
|
||||
|
||||
**Follow variable refresh rate** — *default: on.* On a VRR / FreeSync / G-Sync screen, let the panel
|
||||
refresh in step with the stream rather than on a fixed cadence — which removes the wait between a
|
||||
@@ -115,8 +116,8 @@ windowed one is at the compositor's mercy) and is harmless on a fixed-refresh sc
|
||||
graphics driver that offers the modern queue-free display mode; on an older driver it does nothing
|
||||
unless you also set `PUNKTFUNK_VRR_FIFO=1` (see [configuration](/docs/configuration)), because the
|
||||
older way of following a panel costs noticeable latency on a fixed-refresh screen. The stats overlay
|
||||
reports `vrr yes` once it has *measured* that the panel really is following. Linux and Windows apps,
|
||||
the console home and Decky.
|
||||
reports `vrr yes` once it has *measured* that the panel really is following. Linux and Windows apps
|
||||
and the console home.
|
||||
|
||||
**Host compositor** — *default: Automatic.* Which backend a **Linux** host uses to drive the virtual
|
||||
output. Advisory: a host without that backend quietly auto-detects instead.
|
||||
@@ -130,7 +131,7 @@ claims a sink advertising exactly that many channels, so applications produce re
|
||||
**Windows** host loopback-captures your current output endpoint and lets Windows convert it — so 5.1
|
||||
from a stereo endpoint is an upmix, not new channels. Offered everywhere.
|
||||
|
||||
**Microphone** — *default: off on Linux, Windows, Android, the console home and Decky; on in the
|
||||
**Microphone** — *default: off on Linux, Windows, Android and the console home; on in the
|
||||
Apple app.* Sends this device's microphone to the host's virtual mic. On Linux and Windows the
|
||||
row is spelled *Stream microphone*, and **Ctrl+Alt+Shift+V** mutes it mid-stream without ending
|
||||
anything — see [Muting your microphone](/docs/input#muting-your-microphone).
|
||||
@@ -142,18 +143,18 @@ from an echo-cancelled PipeWire source when your desktop provides one, on **Wind
|
||||
for the Communications stream category so the endpoint's processing engages, and on **Apple** and
|
||||
**Android** the platform's voice-processing mode. Turn it off if your microphone already runs its
|
||||
own processing, or if the canceller makes your voice sound thin. The row sits under the microphone
|
||||
toggle and greys out while the microphone is off. Offered by the Linux, Windows, Apple, Android,
|
||||
console-home and Decky clients. What it can and can't fix is in
|
||||
toggle and greys out while the microphone is off. Offered by the Linux, Windows, Apple, Android and
|
||||
console-home clients. What it can and can't fix is in
|
||||
[Why do I hear myself](/docs/echo).
|
||||
|
||||
**Speaker** and **Microphone** device pickers — *default: System default.* Which endpoint stream
|
||||
audio plays out of, and which input feeds the uplink. Only the Linux app (PipeWire nodes), the
|
||||
**Mac** app (which also has a microphone *channel* picker) and **Decky** have these — iPhone, iPad,
|
||||
Apple TV, Android and the console home have none, and the Windows app has none and ignores a stored
|
||||
speaker choice. On Linux, a device that has since disappeared keeps a "(not detected)" entry rather
|
||||
than silently snapping back to the default; the Mac shows it as "Unavailable device" and Decky as
|
||||
"(not connected)". Decky reads the endpoint list from the client's session binary, so a client
|
||||
older than the two-binary split leaves these pickers on Automatic.
|
||||
audio plays out of, and which input feeds the uplink. Only the Linux app (PipeWire nodes) and the
|
||||
**Mac** app (which also has a microphone *channel* picker) have these — iPhone, iPad, Apple TV,
|
||||
Android and the console home have none, and the Windows app has none and ignores a stored speaker
|
||||
choice. On Linux, a device that has since disappeared keeps a "(not detected)" entry rather than
|
||||
silently snapping back to the default; the Mac shows it as "Unavailable device". A Steam Deck in
|
||||
Gaming Mode therefore has no endpoint picker at all: the session uses whatever the Desktop-Mode app
|
||||
last stored, and the system default otherwise.
|
||||
|
||||
## Input
|
||||
|
||||
@@ -182,7 +183,7 @@ client greys them out to say so.
|
||||
|
||||
**Gamepad type** (*Controller type* on Apple, Android and the console home) — *default: Automatic*,
|
||||
which matches each physical controller. The pickers offer Xbox 360, Xbox One, DualSense and
|
||||
DualShock 4 everywhere, plus Steam Deck on Linux, Android, the console home and Decky. Your client
|
||||
DualShock 4 everywhere, plus Steam Deck on Linux, Android and the console home. Your client
|
||||
declares a type per pad as it connects — Automatic declares what that controller really is, an
|
||||
explicit choice declares your choice — and the host builds each virtual pad from that. A type the
|
||||
host has no backend for degrades to an Xbox 360 pad rather than failing: Xbox One on a Windows host,
|
||||
@@ -193,8 +194,8 @@ which forwards *every* connected controller, each as its own player, on Linux, W
|
||||
console home. Pinning one restricts the session to that controller alone — single-player. The Android
|
||||
app has no such picker.
|
||||
|
||||
**Capture system shortcuts** — *default: on.* Offered by the Linux and Windows apps, the console home
|
||||
and Decky; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it
|
||||
**Capture system shortcuts** — *default: on.* Offered by the Linux and Windows apps and the console
|
||||
home; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it
|
||||
matters only for a keyboard you attached yourself, for the reason the paragraph below gives: Gaming
|
||||
Mode is gamescope, which has nothing to hold back. On, Alt+Tab and the Windows key
|
||||
(Super on Linux) reach the host while the stream has input captured. Off, they act on this machine
|
||||
@@ -215,21 +216,22 @@ the wlroots compositors all do, and X11 sessions grab the keyboard directly. Und
|
||||
Wake-on-LAN and waits for it to boot — only for a host whose MAC address this client has already
|
||||
learned. Turn it off for hosts you reach over a VPN, where "offline" usually means "not reachable by
|
||||
broadcast" and the wake only adds a delay. The Linux, Windows, Apple and Android apps have this
|
||||
toggle, as do the console home and Decky — and note that the Decky plugin sends a wake of its own
|
||||
before a stream starts whatever this setting says, so on a Deck it governs the client's connect
|
||||
rather than the launch. The console home also offers wake as an explicit action on an offline host.
|
||||
See
|
||||
toggle, as does the console home — and on a Steam Deck it governs the
|
||||
[Decky plugin's](/docs/steam-deck) launches too, because the plugin starts every stream through the
|
||||
client, which reads this setting like any other connect. The console home also offers wake as an
|
||||
explicit action on an offline host, whatever the toggle says. See
|
||||
[Wake-on-LAN](/docs/wake-on-lan).
|
||||
|
||||
**Show game library** — *default: off on Linux and Windows; on in the Apple and Android apps.* Browse
|
||||
a paired host's games and launch one directly; the Windows app still labels it experimental. The
|
||||
console home and Decky have the toggle too — on Decky it governs the *client's* screens, since the
|
||||
plugin's own library browser works either way. See [Game library](/docs/game-library).
|
||||
console home has the toggle too, and it governs the desktop clients that share the store — the
|
||||
console's own **Library** button is offered on any paired host either way. See
|
||||
[Game library](/docs/game-library).
|
||||
|
||||
**Start streams in fullscreen** — *default: on.* On Linux and Windows, F11 or Alt+Enter leaves
|
||||
fullscreen live. On a Mac the setting is **Fullscreen while streaming**, and the window comes back
|
||||
when you return to the host list. The console home and Decky carry the row for the desktop client
|
||||
that shares the store — a Gaming-Mode launch is fullscreen whatever it says. iPhone, iPad, Apple TV
|
||||
when you return to the host list. The console home carries the row for the desktop client that
|
||||
shares the store — a Gaming-Mode launch is fullscreen whatever it says. iPhone, iPad, Apple TV
|
||||
and Android have no equivalent.
|
||||
|
||||
## Overlay
|
||||
@@ -237,9 +239,9 @@ and Android have no equivalent.
|
||||
**Statistics overlay** — *default: Normal.* Four tiers — Off, Compact, Normal, Detailed — each a
|
||||
superset of the one before. This setting only picks the tier a session *starts* at — you can cycle
|
||||
them live in-stream, with a shortcut that differs by platform. The Apple app additionally lets you
|
||||
choose which corner the overlay sits in (Top Left, Top Right, Bottom Left, Bottom Right). The Decky
|
||||
plugin has the tier picker too, in its Settings section. The shortcuts, and every number in the
|
||||
overlay, are in
|
||||
choose which corner the overlay sits in (Top Left, Top Right, Bottom Left, Bottom Right). The
|
||||
console home has the tier picker too, as **Statistics overlay** under **Interface**. The shortcuts,
|
||||
and every number in the overlay, are in
|
||||
[Understanding the stats overlay](/docs/stats).
|
||||
|
||||
## Settings that are facts about your device
|
||||
@@ -251,8 +253,8 @@ stay global and **cannot be put in a settings profile**:
|
||||
vendor-ordered and falls back on its own; change it only when debugging, and note that
|
||||
`PUNKTFUNK_DECODER` overrides it
|
||||
([Configuration](/docs/configuration#client-side-native-clients)). The decoder picker is on Linux,
|
||||
Windows, in the console home and in Decky; the GPU picker on Windows, and on Linux and Decky only
|
||||
when the machine has more than one adapter — which a Deck doesn't, so the row isn't there. The
|
||||
Windows and in the console home; the GPU picker on Windows, and on Linux only when the machine has
|
||||
more than one adapter — the console home has none, and a Deck has a single adapter anyway. The
|
||||
Apple and Android apps have neither.
|
||||
- **Speaker** and **Microphone** device pickers — this device's audio endpoints.
|
||||
- **Forwarded controller** — which physical pad is in your hands. The *type* the host creates is a
|
||||
|
||||
@@ -100,11 +100,10 @@ capture state, and the switch that turns this off is *DualSense / DualShock pass
|
||||
Settings. Over **Bluetooth** the pad still works as an ordinary gamepad, but adaptive triggers and
|
||||
the lightbar need the USB connection.
|
||||
|
||||
The app is on Google Play as a **test track** (closed testing for stable, internal testing for
|
||||
canary) — request a tester invite on our [**Discord**](https://discord.gg/kaPNvzMuGU) and we'll add
|
||||
you, or sideload the public APK instead (see
|
||||
[Install a Client](/docs/install-client#android)). Then open the app, pick your host,
|
||||
[pair](/docs/pairing) once, and stream.
|
||||
The app is on **[Google Play](https://play.google.com/store/apps/details?id=io.unom.punktfunk)** as a
|
||||
public listing — no invite — or you can sideload the public APK instead (see
|
||||
[Install a Client](/docs/install-client#android)); canary builds ride a separate, invite-only Play
|
||||
Internal testing track. Then open the app, pick your host, [pair](/docs/pairing) once, and stream.
|
||||
|
||||
## Windows desktop client
|
||||
|
||||
|
||||
@@ -77,7 +77,8 @@ The setting is read when a session starts, so if you change it while streaming,
|
||||
macOS can also flip it mid-session: **Stream ▸ Share Clipboard** (⌃⌥⇧C), which becomes **Stop
|
||||
Sharing Clipboard** once the host has acknowledged it.
|
||||
|
||||
iOS, iPadOS, tvOS and the Steam Deck Decky plugin have no clipboard switch — see
|
||||
iOS, iPadOS, tvOS and a Steam Deck in Gaming Mode have no clipboard switch — neither the Decky
|
||||
panel nor the client's console home has a host edit sheet — see
|
||||
[what each client does](#which-hosts-and-clients-support-it) below.
|
||||
|
||||
## Nothing crosses until something pastes
|
||||
@@ -134,8 +135,9 @@ when a host application pastes.
|
||||
|
||||
The **Linux client has the switch but no working clipboard bridge**: it enables the plane and then
|
||||
has no code to read or write the desktop's own clipboard, so nothing is announced and nothing is
|
||||
pasted. Turning it on there is harmless but has no effect today. The Decky plugin on the Steam Deck
|
||||
has no switch at all.
|
||||
pasted. Turning it on there is harmless but has no effect today. On a Steam Deck in Gaming Mode
|
||||
there is no switch at all — the Decky panel doesn't edit hosts — and since a Deck streams with that
|
||||
same Linux client, a switch there would have nothing to move anyway.
|
||||
|
||||
When you copy **on the Windows client**, images cross only if the copying application publishes the
|
||||
registered `PNG` clipboard format. Many Windows apps publish only a bitmap, and those copies aren't
|
||||
|
||||
@@ -132,11 +132,10 @@ and runs what it already knows about the title, so a client can never hand the h
|
||||
- **Android** — the library lives only in the controller-optimized home, which a TV always uses and a
|
||||
phone or tablet switches to when a controller is connected. Press **Y** on a saved host, or open its
|
||||
options and choose **Library**.
|
||||
- **Steam Deck (Decky)** — the plugin's per-host **Games** picker lists the library and lets you
|
||||
**Pin** titles; a pinned game becomes a one-tap row under **Pinned Games** in the Quick Access Menu.
|
||||
The picker itself doesn't launch anything — either tap a pinned row, or use **Open library on
|
||||
screen** to browse the host's games full-screen on the Deck and launch from there. See
|
||||
[Steam Deck](/docs/steam-deck).
|
||||
- **Steam Deck (Decky)** — the panel is a launcher and browses nothing itself: tap **Open
|
||||
Punktfunk**, which opens the client's console home, and a paired host's **Library** button is
|
||||
right there — full-screen covers, gamepad-navigable, and a press starts the stream with the title
|
||||
launching. See [Steam Deck](/docs/steam-deck).
|
||||
- **Moonlight** — when the host runs with `--gamestream`, your library appears in Moonlight's app
|
||||
list beside `Desktop`, with covers served by the host. A title keeps the same app id across host
|
||||
restarts, so Moonlight's cached tiles stay correct. Titles with no launch recipe are left out.
|
||||
|
||||
@@ -49,8 +49,9 @@ your settings. If the stream isn't sending a microphone at all (**Stream microph
|
||||
[client settings](/docs/client-settings#audio)) the shortcut does nothing and no badge appears,
|
||||
rather than pretending to mute something.
|
||||
|
||||
This is on the **Linux and Windows** clients. The Apple, Android and Decky clients have no mute
|
||||
shortcut yet; turn **Stream microphone** off in their settings instead.
|
||||
This is on the **Linux and Windows** clients — including a Steam Deck stream, which is the Linux
|
||||
client, so an attached keyboard gets the chord. The Apple and Android clients have no mute shortcut
|
||||
yet; turn **Stream microphone** off in their settings instead.
|
||||
|
||||
Alt-Tabbing away releases input on its own and takes it back when you return. A release you asked
|
||||
for with the chord stays released until you opt back in. Either way, keys and buttons you were
|
||||
|
||||
@@ -25,7 +25,7 @@ Already installed? Skip to [Keeping a client up to date](#keeping-a-client-up-to
|
||||
| **Windows** | [Signed MSIX](#windows) from the package registry |
|
||||
| **macOS** | [Notarized `.dmg`](#macos) from the releases page |
|
||||
| **iPhone / iPad / Apple TV** | [TestFlight beta](#ios-ipados-apple-tv) |
|
||||
| **Android / Android TV** | [Beta — a Play test track, or sideload the APK](#android) |
|
||||
| **Android / Android TV** | [Google Play](#android), or sideload the APK |
|
||||
| **LG webOS TV** | [Community client](#lg-webos-tv-community) (sideloaded `.ipk`) |
|
||||
| Anything else (browser, old phone, TV) | [Moonlight](/docs/moonlight) |
|
||||
|
||||
@@ -79,9 +79,11 @@ list: [Clients → the `punktfunk` CLI](/docs/clients#scripting-the-punktfunk-cl
|
||||
## Steam Deck
|
||||
|
||||
Most Deck users want **Gaming Mode**: install the **[Decky plugin](/docs/steam-deck)** and a
|
||||
**Punktfunk** panel lands in the Quick Access Menu, so you can discover hosts, pair with a PIN, and
|
||||
stream **without dropping to the desktop**. Follow the **[Steam Deck (Decky) guide](/docs/steam-deck)**
|
||||
— it walks through Decky Loader, the plugin, and the one-time client install.
|
||||
**Punktfunk** panel lands in the Quick Access Menu, so you can find a host, get let in (a PIN, or a
|
||||
request the host's operator approves), and stream **without dropping to the desktop**. Everything
|
||||
else — settings, the game library, adding a host by address — is one tap away in the client's own
|
||||
gamepad UI. Follow the **[Steam Deck (Decky) guide](/docs/steam-deck)** — it walks through Decky
|
||||
Loader, the plugin, and the one-time client install.
|
||||
|
||||
> The plugin doesn't decode video itself — it drives whichever `punktfunk-client` is installed on
|
||||
> the Deck. The Flatpak below is the tested default; a native package or a sysext works too. If your
|
||||
@@ -162,19 +164,15 @@ Open the app, and your hosts appear automatically under *On this network*.
|
||||
|
||||
## Android
|
||||
|
||||
The Android client (phone + Android TV) is on Google Play as a **test track** — **closed testing**
|
||||
for stable releases, **internal testing** for canary builds. To join, request a tester invite on our
|
||||
[**Discord**](https://discord.gg/kaPNvzMuGU) and we'll add your Google account:
|
||||
|
||||
**[Request access on Discord →](https://discord.gg/kaPNvzMuGU)**
|
||||
|
||||
Once you're added, install it from Google Play, then open the app and pick your host:
|
||||
The Android client (phone + Android TV — one package, the TV layout is the same app in leanback
|
||||
mode) is published on **Google Play**. It's a public listing: no invite, no tester list.
|
||||
|
||||
**[Get Punktfunk on Google Play →](https://play.google.com/store/apps/details?id=io.unom.punktfunk)**
|
||||
_(only resolves once your account is on the tester list)_
|
||||
|
||||
**Prefer not to wait for an invite?** The signed APK is published publicly on every build, so you can
|
||||
sideload it instead — no account, no invite:
|
||||
Install it, open the app, and pick your host.
|
||||
|
||||
**Prefer not to go through Play?** The signed APK is published publicly on every build, so you can
|
||||
sideload it instead — no Play account needed:
|
||||
|
||||
```text
|
||||
https://git.unom.io/api/packages/unom/generic/punktfunk-android/latest/punktfunk-android.apk
|
||||
@@ -184,6 +182,10 @@ Swap `latest` for `canary` to track `main`. Release APKs are also attached to ea
|
||||
[release](https://git.unom.io/unom/punktfunk/releases). Android asks you to allow installs from your
|
||||
browser or file manager the first time.
|
||||
|
||||
**Canary on Play** is a separate **Internal testing** track, and that one *is* invite-only — ask on
|
||||
[Discord](https://discord.gg/kaPNvzMuGU) and we'll add your Google account. The `canary` APK above
|
||||
needs no invite.
|
||||
|
||||
## LG webOS TV (community)
|
||||
|
||||
> **Community project.** [`pf-webos`](https://github.com/dyptan-io/pf-webos) is built and maintained
|
||||
|
||||
@@ -61,8 +61,8 @@ Then, on the client:
|
||||
- **[Native clients](/docs/clients) (Apple, Linux, Windows, Android):** select the host (or use
|
||||
*Pair with PIN…* from its menu) and enter the PIN the host displays.
|
||||
- **[Steam Deck](/docs/steam-deck) (the Decky plugin):** open Punktfunk from the Quick Access menu
|
||||
and pick the host — an unpaired one's button reads **Pair & Stream**. Enter the PIN on the
|
||||
4-digit pad it opens.
|
||||
and pick the host — an unpaired one opens a sheet offering **Request access** (no PIN: somebody
|
||||
approves the Deck at the host) or **Use a PIN instead**, which opens the 4-digit pad.
|
||||
- **[Moonlight](/docs/moonlight):** choose **Pair**; Moonlight shows a 4-digit PIN, and you type
|
||||
that PIN into the console's **Moonlight (GameStream) pairing** card and press **Submit PIN**.
|
||||
(This direction is the reverse of the native flow, and arming doesn't apply to it.)
|
||||
|
||||
@@ -11,8 +11,9 @@ Both live in the client apps — the Apple app, the Linux GTK client, the Window
|
||||
Android app. Neither exists in the host's [web console](/docs/web-console).
|
||||
|
||||
The controller-driven surfaces are a half-exception: Apple TV, the Android app's console mode and
|
||||
the Steam Deck console the Decky plugin launches all *use* the profile a host is bound to, but none
|
||||
of them can create or edit one. Do that on a desktop or a phone first.
|
||||
the Steam Deck console the Decky plugin launches all *use* the profile a host is bound to and can
|
||||
pin one as its own card, but none of them can create or edit one. Do that on a desktop or a phone
|
||||
first. The Decky panel itself only *shows* those pins, nested under their host as one-tap cards.
|
||||
|
||||
## What a profile is
|
||||
|
||||
|
||||
@@ -7,10 +7,12 @@ The **Decky plugin** adds a **Punktfunk** panel to the Steam Deck's Quick Access
|
||||
button), so you can find a host, pair, and start streaming **without leaving Gaming Mode**. It's the
|
||||
couch-friendly front end for the Steam Deck — built from real Steam UI, gamepad-navigable end to end.
|
||||
|
||||
Under the hood the plugin doesn't decode video itself: it discovers hosts, runs the PIN pairing, and
|
||||
**launches the regular [Linux client](/docs/clients#linux-desktop-client-gtk4)** (usually the
|
||||
`io.unom.Punktfunk` Flatpak) the way gamescope needs so it fullscreens correctly. So the Deck has two
|
||||
ways to stream, and they share one client + one paired identity:
|
||||
The plugin is a **launcher**, not a second client. It doesn't decode video, browse your library or
|
||||
hold settings of its own — it starts the regular
|
||||
[Linux client](/docs/clients#linux-desktop-client-gtk4) (usually the `io.unom.Punktfunk` Flatpak)
|
||||
the way gamescope needs so it fullscreens correctly. Everything the panel doesn't do is one tap
|
||||
away in that client's own gamepad UI. So the Deck has two ways to stream, and they share one
|
||||
client + one paired identity:
|
||||
|
||||
- **Gaming Mode** → the **Decky plugin** (this page).
|
||||
- **Desktop Mode** → run the [Flatpak](/docs/install-client#steam-deck) directly, like any Linux app.
|
||||
@@ -30,11 +32,13 @@ You need three things on the Deck:
|
||||
|
||||
(Full options: [Install a Client → Steam Deck](/docs/install-client#steam-deck).) If you have
|
||||
no Flatpak but a native `punktfunk-client` — a sysext, a distro package, a nix profile, your own
|
||||
build — the plugin launches that instead; with both installed the Flatpak wins, unless
|
||||
`PF_DECKY_CLIENT=native` (or `flatpak`) is set in the plugin backend's environment. But
|
||||
**pairing, Wake-on-LAN and the host game library still go through the Flatpak**, so install it
|
||||
on the Deck even then. Both kinds share `~/.config/punktfunk`, so your identity, known hosts
|
||||
and settings are the same either way.
|
||||
build — the plugin uses that instead; with both installed the Flatpak wins, unless
|
||||
`PF_DECKY_CLIENT=native` (or `flatpak`) is set in the plugin backend's environment. Both kinds
|
||||
share `~/.config/punktfunk`, so your identity, known hosts and settings are the same either way.
|
||||
|
||||
**The client must be v0.22.0 or newer.** The panel drives everything through the client's
|
||||
headless `punktfunk` command, which shipped in that release. An older client says so in the
|
||||
panel, with the update button that fixes it right there.
|
||||
3. **A Punktfunk host** running on your LAN — see [Install the Host](/docs/install). The Deck finds
|
||||
it automatically over mDNS, so nothing to configure here.
|
||||
|
||||
@@ -64,40 +68,68 @@ The **Punktfunk** panel appears in the Quick Access Menu right away — no Deck
|
||||
|
||||
## Use it
|
||||
|
||||
Open the **Punktfunk** panel from the Quick Access Menu, or **Open Punktfunk** for the full-screen
|
||||
page (host list + stream settings).
|
||||
Open the **Punktfunk** panel from the Quick Access Menu. It has one list — the hosts you can
|
||||
stream — plus a door into the client's own gamepad UI for everything else.
|
||||
|
||||
- **Discover** — hosts on your network appear automatically (mDNS). Tap **Refresh** to rescan. A
|
||||
lock icon means the host requires [pairing](/docs/pairing).
|
||||
- **Add a host by hand** — if mDNS can't reach it (another subnet, a VPN), tap **+** on the Hosts
|
||||
tab and enter its address; the port defaults to **9777**. Saved hosts can be renamed, re-pointed
|
||||
at a new address, or forgotten from the same row.
|
||||
- **Sleeping host?** Streaming sends a [Wake-on-LAN](/docs/wake-on-lan) packet first, and when one
|
||||
actually went out the Deck waits far longer than usual for the host to answer, so a stream
|
||||
survives a resume from sleep. Nothing to enable — it's a no-op until the plugin has learned that
|
||||
host's MAC address, and the packet only lands if the host machine is armed to wake in its
|
||||
BIOS and its network card.
|
||||
- **Pair** — for a locked host, [arm pairing on the host](/docs/pairing) (its console or web
|
||||
console shows a 4-digit PIN), then enter that PIN on the Deck's keypad. Pairing persists, so the
|
||||
next connection is silent.
|
||||
- **Stream** — pick a host and the stream launches fullscreen in Gaming Mode. The plugin drives a
|
||||
- **Hosts** — hosts on your network appear automatically (mDNS), alongside the ones you've already
|
||||
saved. A saved host is also probed directly, so a box reached over a VPN or Tailscale shows as
|
||||
online even though it never advertises. Tap **Refresh** to rescan. The list sorts online hosts
|
||||
first, then whichever you streamed most recently. A lock icon means the host still has to let
|
||||
this Deck in.
|
||||
- **Let a host in** — tapping a locked host opens a small sheet with two ways through:
|
||||
- **Request access** — no PIN at all. See [Request access](#request-access) below.
|
||||
- **Use a PIN instead** — [arm pairing on the host](/docs/pairing) (its console or web console
|
||||
shows a 4-digit PIN), then enter it on the Deck's keypad.
|
||||
|
||||
Either way the host is remembered, so the next connection is silent.
|
||||
- **Stream** — tap a host and the stream launches fullscreen in Gaming Mode. The plugin drives a
|
||||
hidden Steam shortcut behind the scenes so gamescope focuses and fullscreens it.
|
||||
- **Library entry** — a visible, branded **Punktfunk** app also appears in your Steam library.
|
||||
Launching it opens the client's console home (host picker, pairing, settings), gamepad-navigable
|
||||
— it does not resume a stream. If it ever disappears, the Quick Access Menu panel has a button to
|
||||
put it back.
|
||||
- **Games** — tap **Games** on a host row to browse that host's [library](/docs/game-library), and
|
||||
**Pin** the ones you play. Pinned games show up on the full page *and* in the Quick Access Menu
|
||||
as one-tap streams that launch straight into the game.
|
||||
- **Settings** — resolution, refresh rate, **render scale**, bitrate, **video codec**, gamepad type,
|
||||
**host compositor**, and mic, written to the client the plugin launches. Leave **Resolution** /
|
||||
**Refresh** on *Native* to get the Deck's own mode, **Render scale** at 1× unless you want to
|
||||
trade bandwidth for sharpness (>1×) or sharpness for bandwidth (<1×), and **Video codec** /
|
||||
**Host compositor** on *Automatic* — that suits almost every host, so change them only when
|
||||
you're troubleshooting. With **Gamepad type** on *Automatic* the Deck's built-in controller is
|
||||
forwarded as a **Steam Deck** pad (paddles, both trackpads, gyro) — that needs Steam Input set to
|
||||
**Off** for Punktfunk (game page → ⚙ → Controller Settings), else Steam keeps those controls and
|
||||
only sticks + buttons reach the host.
|
||||
- **Sleeping host?** Streaming sends a [Wake-on-LAN](/docs/wake-on-lan) packet and waits for the
|
||||
host to actually come back before dialling, so a stream survives a resume from sleep. Nothing to
|
||||
enable — it's a no-op until the client has learned that host's MAC address, and the packet only
|
||||
lands if the host machine is armed to wake in its BIOS and its network card.
|
||||
- **Pinned cards** — a host with pinned [settings profiles](/docs/client-settings) shows them
|
||||
nested underneath it as `▸ <Profile name>`. Tapping one streams that host with that profile
|
||||
applied — your "4K on the TV" and "battery saver" presets, one tap each. Pins are made in the
|
||||
Punktfunk app (or any other client) and shared across all of them; the panel shows them, it
|
||||
doesn't create them.
|
||||
- **Open Punktfunk** — opens the client's console home: the host picker, adding a host by address,
|
||||
pairing, browsing a host's [game library](/docs/game-library), and the **full settings screen**.
|
||||
This is where resolution, bitrate, codec, audio, controllers and the stats overlay live.
|
||||
- **Library entry** — a visible, branded **Punktfunk** app also appears in your Steam library, and
|
||||
launching it opens that same console home — it does not resume a stream. If it ever disappears,
|
||||
the Quick Access Menu panel has a button to put it back.
|
||||
|
||||
> **Where did the plugin's settings tab go?** Into the app, at **Open Punktfunk → Settings** — the
|
||||
> same rows over the same settings, gamepad-navigable, and one tap from the same panel. The plugin
|
||||
> used to carry its own copy of that screen, which meant two places to change one setting and a
|
||||
> copy that fell behind. There is now one.
|
||||
|
||||
With **Controller type** on *Automatic* the Deck's built-in controller is forwarded as a **Steam
|
||||
Deck** pad (paddles, both trackpads, gyro) — that needs Steam Input set to **Off** for Punktfunk
|
||||
(game page → ⚙ → Controller Settings), else Steam keeps those controls and only sticks + buttons
|
||||
reach the host.
|
||||
|
||||
### Request access
|
||||
|
||||
**Request access lets you in without typing a PIN**: instead of the host showing you a code, you
|
||||
ask, and whoever is at the host approves the Deck in its [web console](/docs/web-console) or on
|
||||
screen.
|
||||
|
||||
Tap the host → **Request access**. The Deck says *"Approve this Deck in <host>'s console — the
|
||||
stream starts by itself"*, and the stream opens and waits. The moment somebody approves it, the
|
||||
picture comes up — no going back to the panel, nothing else to tap. If nobody approves within
|
||||
about three minutes, it gives up like any failed connection and you can try again or use a PIN.
|
||||
|
||||
It's the better option when you're not the person sitting at the host, or when reading a PIN off
|
||||
another screen is awkward. Two things to know:
|
||||
|
||||
- The host must be **advertising on your network** for this to be offered. A host you added by
|
||||
address (a VPN box, another subnet) has no advertised identity for the Deck to pin, so the sheet
|
||||
offers the PIN path only and says so. That's a safety rule, not a limitation to work around:
|
||||
pinning the advertised identity is what stops something else answering in the host's place while
|
||||
the Deck waits.
|
||||
- Once approved, the host shows as **paired** and every later stream connects silently.
|
||||
|
||||
> **Steam Input off is a trade-off, not a free win.** The plugin installs a Steam Input layout
|
||||
> called **Punktfunk** and points its shortcuts at it, and that layout's whole job is making the
|
||||
@@ -115,9 +147,9 @@ input, so it is safe to hit by accident.
|
||||
|
||||
The plugin **checks for updates itself** — no Decky store needed. It covers **both** the plugin *and*
|
||||
the streaming client (they version independently), so when either has a newer build the panel shows an
|
||||
**Update** button (in the Quick Access Menu and on the full page). Tap it: the client updates in
|
||||
place, and if the plugin itself changed it downloads, verifies, replaces itself, and reloads — all
|
||||
without leaving Gaming Mode.
|
||||
**Update** button at the top of the panel. Tap it: the client updates in place, and if the plugin
|
||||
itself changed it downloads, verifies, replaces itself, and reloads — all without leaving Gaming
|
||||
Mode.
|
||||
|
||||
One exception: if your client isn't one the plugin can install for you (a sysext, a nix profile, a
|
||||
source build), the panel shows you the update **command** instead of a button — tap-to-install would
|
||||
@@ -139,13 +171,16 @@ The plugin check follows the [channel](/docs/channels) you installed from: a plu
|
||||
|
||||
| Symptom | Fix |
|
||||
|---|---|
|
||||
| The stream never starts, **Pair** reports `flatpak-not-found`, or **Games** says the client isn't installed | Install the client Flatpak in Desktop Mode (see [Before you start](#before-you-start)). |
|
||||
| No hosts listed | Make sure the host is running and on the **same LAN**; the Deck needs `avahi` (shipped on SteamOS). Tap **Refresh**. |
|
||||
| Pairing fails / "not armed" | The PIN is shown only after you **arm pairing on the host**. Arm it, then enter the PIN within the window. |
|
||||
| Stream launches but doesn't focus | Start it from the panel (not by launching the Flatpak by hand) so Steam/gamescope focuses it. |
|
||||
| The stream wedges — black, or won't close | Open the full page → **About** tab → **Force-stop**, then start it again. |
|
||||
| The **Punktfunk** library entry disappeared | Quick Access Menu → **Recreate library shortcut**; it puts the entry back in place. |
|
||||
| You want a clean slate | **About** tab → **Reset Punktfunk** — clears saved hosts, stream settings and pinned games on this Deck, and keeps your paired identity. |
|
||||
| The panel says **"Update the Punktfunk client"** | The installed client predates v0.22.0 and has no `punktfunk` command to drive. Tap the update button in the same panel, or update it in Desktop Mode. |
|
||||
| The stream never starts, or the panel can't reach the client | Install the client Flatpak in Desktop Mode (see [Before you start](#before-you-start)). |
|
||||
| No hosts listed | Make sure the host is running and on the **same LAN**. Tap **Refresh**. For a host mDNS can't reach, add it by address in **Open Punktfunk → Add host**. |
|
||||
| Pairing fails / "not armed" | The PIN is shown only after you **arm pairing on the host**. Arm it, then enter the PIN within the window — or use **Request access** instead, which needs no PIN. |
|
||||
| **Request access** isn't offered | The host isn't advertising on this network, so there's no identity to pin. Use the PIN path. |
|
||||
| A request-access stream sits there | That's it waiting — somebody has to approve the Deck on the host. It gives up after about three minutes. |
|
||||
| Stream launches but doesn't focus | Start it from the panel (not by launching the client by hand) so Steam/gamescope focuses it. |
|
||||
| The stream wedges — black, or won't close | Panel → **About** → **Force-stop**, then start it again. |
|
||||
| The **Punktfunk** library entry disappeared | Panel → **Recreate library shortcut**; it puts the entry back in place. |
|
||||
| You want a clean slate | **Open Punktfunk → Settings** for stream settings, or `punktfunk reset` in Desktop Mode to forget every saved host. Your paired identity is kept either way. |
|
||||
|
||||
Nothing here matching? The problem is probably on the host side — start at
|
||||
[Troubleshooting](/docs/troubleshooting), which is organised by symptom (host not found, pairing
|
||||
|
||||
@@ -354,7 +354,7 @@ macOS, iOS/iPadOS and tvOS. Android is one app, with Android TV being the same a
|
||||
| iPhone · iPad | ✅ | ✅ | ✅ ⁴ | ✅ | ✅ | ❌ ³ |
|
||||
| Apple TV | ⚠️ ⁵ | ✅ | ✅ ⁴ | ✅ | ✅ | ❌ ³ |
|
||||
| Android · Android TV | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ ³ |
|
||||
| Decky (Steam Deck) | ❌ ⁶ | ❌ | ✅ ⁷ | ❌ | ✅ | ✅ ⁸ |
|
||||
| Decky (Steam Deck) | ⚠️ ⁶ | ❌ | ⚠️ ⁷ | ❌ | ✅ | ✅ ⁸ |
|
||||
| `punktfunk` CLI | ✅ | ✅ ⁹ | ✅ | ✅ | ✅ | ❌ |
|
||||
| Moonlight | ❌ ¹⁰ | ❌ ¹⁰ | ✅ ¹¹ | ❓ ¹² | ❓ ¹² | ❓ ¹² |
|
||||
|
||||
@@ -375,10 +375,12 @@ macOS, iOS/iPadOS and tvOS. Android is one app, with Android TV being the same a
|
||||
exists, so a fresh Apple TV has none. Of the settings a profile can carry, tvOS also drops the
|
||||
ones the platform has no input for: inverted scroll, modifier layout, variable refresh rate,
|
||||
mouse mode and touch mode.
|
||||
6. The plugin writes flat values into the shared client settings; it has no profile surface. The
|
||||
client it launches still honours whatever profile that settings file names.
|
||||
7. Including pinned one-tap "Stream *game*" rows in the Quick Access Menu, which follow a host
|
||||
across IP changes. Not subject to the desktop opt-in.
|
||||
6. The panel *shows* the profiles a host has pinned, as nested one-tap cards, and streams with
|
||||
them; it has no profile surface of its own. Pins are made in a client's own UI — including the
|
||||
console home **Open Punktfunk** opens — and are shared, so every client shows the same cards.
|
||||
Creating and editing a profile stays a desktop-app job.
|
||||
7. Not in the panel: **Open Punktfunk** opens the client's console home, and a paired host's
|
||||
library is one button from there.
|
||||
8. Both the plugin itself and, where the install kind allows it, the client it launches.
|
||||
9. The CLI parses and follows links; it does not register the URL scheme — the graphical apps do.
|
||||
10. [Profiles and links](/docs/profiles-and-links) are Punktfunk-app concepts and do not exist on
|
||||
@@ -400,7 +402,8 @@ macOS, iOS/iPadOS and tvOS. Android is one app, with Android TV being the same a
|
||||
|
||||
1. Multiple controllers, each on its own stable slot, arriving and leaving independently. The pad
|
||||
**type** the host emulates is picked per pad; the pickers are not identical across apps — Linux,
|
||||
Android and Decky offer six presets including Steam Deck, Windows and Apple offer five.
|
||||
Android and the console home offer six presets including Steam Deck, Windows and Apple offer
|
||||
five.
|
||||
2. DualSense and DualShock 4 touchpad and motion are forwarded, and the host's adaptive-trigger and
|
||||
lightbar effects are replayed on a real DualSense. On the desktop clients any controller SDL
|
||||
exposes a gyro on forwards motion — a Switch Pro or the Steam Deck's own pad included — and the
|
||||
@@ -513,8 +516,8 @@ capability.
|
||||
| **GameStream / Moonlight plane** | Works, and whether it is on depends on how you installed. Every Linux package (deb, RPM, Arch, the Bazzite sysext) and the SteamOS installer ship the unit as `serve --gamestream`, so GameStream is **on** there; NixOS defaults it on too. The Windows installer's checkbox is unticked, so it is **off** unless you asked for it, and a bare `punktfunk-host serve` is off. It pairs over plain HTTP with weaker legacy encryption — trusted LAN only, and worth turning off if you don't use Moonlight (see [Security](/docs/security#gamestream--moonlight-compatibility-is-the-weak-crypto-path)). It is a compatibility surface, so Punktfunk-only features (profiles, links, clipboard, microphone) are not on it. |
|
||||
| **Linux and Windows desktop clients** | Packaged and current. They are one codebase: the same session binary streams for both, and for the Decky plugin and the `punktfunk` CLI. |
|
||||
| **Apple client** (macOS · iOS · iPadOS · tvOS) | One universal build, distributed as a **TestFlight beta**; the Mac also has a notarized DMG. Feature-complete apart from the platform gaps named above (no microphone on tvOS, clipboard on macOS only). |
|
||||
| **Android client** (phone · TV) | Distributed on Play's **closed (alpha)** track for releases, Internal testing for canary, plus a sideloadable APK. The same app in leanback mode is the TV client. |
|
||||
| **Decky plugin** (Steam Deck) | Ships through install-from-URL rather than the Decky store, and keeps itself and the client it launches up to date. It launches the Linux client rather than streaming itself, and has no settings surface of its own beyond the flat values it writes into the shared client settings. |
|
||||
| **Android client** (phone · TV) | Published on **Google Play** as a public listing for releases, with an invite-only Internal testing track for canary, plus a sideloadable APK. The same app in leanback mode is the TV client. |
|
||||
| **Decky plugin** (Steam Deck) | Ships through install-from-URL rather than the Decky store, and keeps itself and the client it launches up to date. It is a launcher, not a second client: it starts the Linux client rather than streaming itself, and holds no settings, no library and no host editor of its own — its **Open Punktfunk** button hands all of that to the client's console home. |
|
||||
| **Web console** | The full management surface — dashboard and sessions, pairing, library, displays, plugins and the plugin store, logs, stats, settings, and host updates. It cannot yet run a speed test or set a bitrate; the client apps can. |
|
||||
| **Plugins** | Three first-party ones (ROM Manager, Playnite, VirtualHere) plus the SDK, installed from the console. See [Plugins](/docs/plugins). |
|
||||
| **`pf-webos`** (LG TV) | A community client in a separate repository. Nothing here can establish its state; ask that project. |
|
||||
|
||||
@@ -302,6 +302,73 @@ told your client so. [When the client and the host
|
||||
disagree](/docs/client-settings#when-the-client-and-the-host-disagree) lists what it does with each
|
||||
one.
|
||||
|
||||
## Streamed audio sounds worse than the host does
|
||||
|
||||
The host does not capture "the sound card" — it captures a **render endpoint**, and by default it
|
||||
picks one that is *silent on the host* so the audio plays on your client only. On a PC with Steam
|
||||
installed that silent endpoint is Steam's **Streaming Microphone**, which exists to carry remote
|
||||
*voice*. If Windows has it configured as a narrow device — mono, or below 48 kHz — then the whole
|
||||
desktop mix is squeezed through that before it is ever encoded, and no amount of bitrate will bring
|
||||
it back.
|
||||
|
||||
Since 0.25 the host checks for this: it reads each candidate endpoint's real format, prefers a real
|
||||
output device over a narrow virtual one, and says so in the log —
|
||||
|
||||
```
|
||||
WARN the desktop-audio loopback endpoint mixes at 24000 Hz, so the stream is band-limited …
|
||||
INFO audio loopback capturing device="…" engine_hz=48000 engine_ch=2 engine_bits=32
|
||||
```
|
||||
|
||||
That `engine_*` line is the endpoint's **own** format, so it tells you directly whether the source
|
||||
was ever full quality. To choose the routing yourself, set in `host.env`:
|
||||
|
||||
```ini
|
||||
# client_only — default; audio plays on the client only (a silent endpoint)
|
||||
# host_and_client — capture a real output device; audio plays on BOTH ends
|
||||
# follow_default — capture whatever YOUR default playback device is, and never change it
|
||||
PUNKTFUNK_AUDIO_OUTPUT_MODE=host_and_client
|
||||
```
|
||||
|
||||
`host_and_client` is also the quickest way to A/B the problem: if the stream sounds right that way
|
||||
and wrong on the default, the endpoint was the cause.
|
||||
|
||||
Two related knobs:
|
||||
|
||||
```ini
|
||||
PUNKTFUNK_AUDIO_QUALITY=high # low | standard | high (default high — stereo 256 kbps)
|
||||
PUNKTFUNK_AUDIO_REDUNDANCY=1 # force the loss-resilient audio plane on (default: automatic)
|
||||
```
|
||||
|
||||
Both are a **request**, not a guarantee: the host budgets audio against the session's video
|
||||
bitrate and steps it down on a narrow link, because audio is not managed by adaptive bitrate — so
|
||||
whatever it takes is taken off the top. On a roomy link you get 256 kbps plus loss redundancy; as
|
||||
the link narrows the host drops redundancy first, then the tier, and never goes below ~96 kbps. The
|
||||
session log line says what it settled on:
|
||||
|
||||
```
|
||||
INFO punktfunk/1 audio streaming … tier=high kbps=512 redundancy=true
|
||||
```
|
||||
|
||||
`standard` reproduces the pre-0.25 encoder exactly if you want to A/B it.
|
||||
|
||||
## Audio lags behind the picture
|
||||
|
||||
The client buffers a little audio to absorb network jitter. Since 0.25 that buffer **corrects
|
||||
itself**: if it drifts deeper — a Wi-Fi burst, a stall, or just the two devices' clocks running at
|
||||
fractionally different speeds — it trims itself back a few milliseconds at a time, inaudibly.
|
||||
Before, it could only grow, so a single hiccup left audio permanently behind the video and the only
|
||||
cure was reconnecting.
|
||||
|
||||
If audio is still noticeably late:
|
||||
|
||||
- **Reconnect once.** It confirms whether the delay was accumulated (gone after a reconnect) or
|
||||
constant (something else).
|
||||
- **Check for underruns** rather than guessing. The client logs its buffer depth periodically; a
|
||||
rising `underruns` count means the buffer is being starved, which is a network or CPU problem, not
|
||||
a buffering one.
|
||||
- **Wired or 5 GHz Wi-Fi.** Arrival jitter is what the buffer exists to absorb; less jitter lets it
|
||||
run shallower.
|
||||
|
||||
## Windows: the host or the web console won't start
|
||||
|
||||
The **`PunktfunkHost` service** runs both halves of the Windows host: the streaming host itself and
|
||||
|
||||
@@ -300,8 +300,9 @@ stop testing — that removes the app and its data with it.
|
||||
|
||||
### Android / Android TV
|
||||
|
||||
Uninstall the app from Google Play or from Settings → Apps. The Android client is still an invited
|
||||
test track, so if you also want your account taken off the tester list, say so on
|
||||
Uninstall the app from Google Play or from Settings → Apps. That's the whole job — it's a public
|
||||
Play listing, so there's no tester list to leave. If you were on the invite-only **canary**
|
||||
(Internal testing) track and want off that too, say so on
|
||||
[Discord](https://discord.gg/kaPNvzMuGU).
|
||||
|
||||
### Steam Deck — Decky plugin
|
||||
|
||||
@@ -74,9 +74,10 @@ saved host's own menu, and only appears when that host is offline *and* an addre
|
||||
| Android · Android TV | **Wake host** — waits, showing the "Waking…" screen | **Wake-on-LAN MAC** in **Edit host** |
|
||||
| Punktfunk Console (controller shell) | on an offline host with a known address, the confirm button reads **Wake & Connect** — it waits, then connects | not offered |
|
||||
|
||||
Punktfunk Console has no auto-wake setting of its own, and offers **Wake & Connect** whatever the
|
||||
desktop app's setting says. In the Apple apps the same button appears when you drive them with a
|
||||
controller, but there it does follow the auto-wake setting.
|
||||
Punktfunk Console carries the row too — **Wake hosts automatically**, in the same settings list the
|
||||
desktop apps write — but its **Wake & Connect** button is an explicit action and appears whatever
|
||||
that row says. In the Apple apps the same button appears when you drive them with a controller, but
|
||||
there it does follow the auto-wake setting.
|
||||
|
||||
The Apple apps also publish a **Wake Host** action to Shortcuts, so an automation can wake a host
|
||||
without opening the app. On iPhone and iPad it has a ready-made phrase: *"Wake ⟨host⟩ with
|
||||
@@ -88,10 +89,14 @@ host list, and shows an explanation with a link to system settings if you declin
|
||||
|
||||
### On the Steam Deck
|
||||
|
||||
The [Decky plugin](/docs/steam-deck) has no wake button and no wake setting. It sends a wake through
|
||||
the Flatpak client just before **every** stream launch, and it is a no-op until that client has
|
||||
learned the host's address. When a packet really did go out, the plugin also stretches the stream's
|
||||
connect budget to 75 seconds, so the connection survives the host resuming from sleep.
|
||||
The [Decky plugin](/docs/steam-deck) has no wake button and no wake setting of its own. It starts
|
||||
every stream through the client, so the wake is the client's, on exactly the terms above: a packet
|
||||
the moment the host doesn't answer, re-sent every 6 seconds while the client watches for it once a
|
||||
second, and the dial only when it really is back. It follows **Wake hosts automatically** in the
|
||||
client's own settings — **Open Punktfunk → Settings** from the same panel — and is a no-op until the
|
||||
client has learned that host's MAC address. (The plugin used to fire a packet itself and stretch the
|
||||
connect budget to 75 seconds to cover the resume; a wait that watches for the host beats a fixed
|
||||
budget, so that is gone.)
|
||||
|
||||
### From the command line
|
||||
|
||||
|
||||
+112
-1
@@ -312,6 +312,13 @@
|
||||
// `PunktfunkStatus` code).
|
||||
#define PUNKTFUNK_CLIP_ERROR 6
|
||||
|
||||
// The protocol's audio frame, in milliseconds — every host datagram carries exactly one
|
||||
// ([`crate::quic::encode_audio_datagram`]), so it is also the smallest useful shed unit.
|
||||
#define PUNKTFUNK_AUDIO_FRAME_MS 5
|
||||
|
||||
// Sample rate of every audio plane in the protocol.
|
||||
#define PUNKTFUNK_AUDIO_SAMPLE_RATE_HZ 48000
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// The uniform no-TTL-host staleness bound: a legacy host refreshes state every 500 ms, so two
|
||||
// missed refreshes = quiet host → silence. Replaces the per-platform zoo (1.6 s / 60 s / 1.5 s /
|
||||
@@ -333,6 +340,12 @@
|
||||
#define INBOUND_REQ_FLAG 2147483648
|
||||
#endif
|
||||
|
||||
// Floor for a negotiated `shard_payload` (even, well under every real path). A path whose UDP
|
||||
// budget lands below this can't carry the QUIC control plane either (QUIC's own minimum is a
|
||||
// 1200-byte UDP payload), so shrinking video shards further buys nothing — the clamp helpers
|
||||
// bottom out here instead of producing degenerate confetti-sized shards.
|
||||
#define MIN_SHARD_PAYLOAD 512
|
||||
|
||||
// 16-byte AEAD authentication tag appended by either session cipher.
|
||||
#define TAG_LEN 16
|
||||
|
||||
@@ -475,7 +488,17 @@
|
||||
|
||||
// Largest UDP datagram the core will send or accept. `Config::validate` bounds
|
||||
// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
|
||||
#define MAX_DATAGRAM_BYTES 2048
|
||||
//
|
||||
// Sized for **jumbo frames** (design/shard-payload-reneg.md W0.2): a 9000-MTU LAN carries
|
||||
// ~8908-byte shards (sealed 8972-byte UDP payloads), and every receive path — the transport
|
||||
// `RECV_BUF`, the session's `recvmmsg` ring — is sized from this constant, so a deployed
|
||||
// client can accept a jumbo geometry the moment its host negotiates one. The ring cost is
|
||||
// 128 × ~9 KiB ≈ 1.1 MiB per **client** session (lazily allocated on first poll; hosts never
|
||||
// allocate it) — measured against the ~256 KiB it was at 2048, an acceptable static price
|
||||
// for never having to resize buffers on a mid-session grow. Senders still derive their
|
||||
// shard payload from the path MTU (`config::mtu1500_shard_payload*`, the wire-MTU clamps);
|
||||
// this is the acceptance ceiling, not a transmit size.
|
||||
#define MAX_DATAGRAM_BYTES 9216
|
||||
|
||||
// The slice-flush floor: a sentinel block below this many data shards costs disproportionate
|
||||
// per-block FEC parity (`ceil(k × pct/100)` ≥ 1 whatever `k`), so slice boundaries only flush
|
||||
@@ -627,6 +650,19 @@
|
||||
#define CLIENT_CAP_PHASE_LOCK 2
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// `Hello.client_caps` bit: this client can decode the redundant desktop-audio plane
|
||||
// ([`AUDIO_RED_MAGIC`](super::datagram::AUDIO_RED_MAGIC), `0xD2`), where every datagram also
|
||||
// carries a copy of the previous frame so a single lost packet is reconstructed instead of
|
||||
// papered over with packet-loss concealment.
|
||||
//
|
||||
// Active only when the host answers with [`HOST_CAP_AUDIO_RED`] (capable-and-agreed, the
|
||||
// cursor/clipboard precedent). Toward an older host, or a host that declines because the link is
|
||||
// clean, the client keeps receiving the plain `0xC9` plane — so a client may always set this bit.
|
||||
// `0x04` — `0x01`/`0x02` are cursor / phase-lock.
|
||||
#define CLIENT_CAP_AUDIO_RED 4
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
|
||||
// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
|
||||
@@ -652,6 +688,20 @@
|
||||
#define HOST_CAP_PEN 16
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Welcome::host_caps`] bit: the host is sending the REDUNDANT desktop-audio plane
|
||||
// ([`AUDIO_RED_MAGIC`](super::datagram::AUDIO_RED_MAGIC), `0xD2`) instead of plain `0xC9` — each
|
||||
// datagram carries its own frame plus a copy of the previous one.
|
||||
//
|
||||
// Set only when the client asked via [`CLIENT_CAP_AUDIO_RED`]. It is a statement about the WIRE,
|
||||
// not a negotiation the client can decline: with the bit set the client must decode `0xD2`, and
|
||||
// without it `0xC9`. The host may also drop back to `0xC9` mid-session (the redundancy is
|
||||
// loss-gated — a clean LAN shouldn't pay for it), which is why clients decode BOTH tags
|
||||
// unconditionally and treat this bit as "expect redundancy", not "only redundancy".
|
||||
// `0x20` — `0x10` is [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`].
|
||||
#define HOST_CAP_AUDIO_RED 32
|
||||
#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
|
||||
@@ -776,6 +826,16 @@
|
||||
#define MSG_RFI_REQUEST 7
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ShardPayloadChanged`].
|
||||
#define MSG_SHARD_PAYLOAD_CHANGED 8
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ShardPayloadAck`].
|
||||
#define MSG_SHARD_PAYLOAD_ACK 9
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`ProbeRequest`].
|
||||
#define MSG_PROBE_REQUEST 32
|
||||
@@ -967,6 +1027,41 @@
|
||||
#define HIDOUT_MAGIC 205
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Redundant audio datagram, host → client: the [`AUDIO_MAGIC`] plane plus a copy of the PREVIOUS
|
||||
// frame, so a single lost datagram is *reconstructed* rather than concealed.
|
||||
//
|
||||
// `[0xD2][u32 seq LE][u64 pts_ns LE][u16 primary_len LE][primary opus][previous opus]`
|
||||
//
|
||||
// **Why this and not Opus in-band FEC.** LBRR is a SILK-layer feature: the desktop-audio encoder
|
||||
// runs `RESTRICTED_LOWDELAY` (CELT-only) at 5 ms frames, which is below SILK's 10 ms minimum, so
|
||||
// `set_inband_fec(true)` on that encoder is a no-op. Nothing in libopus can protect this plane —
|
||||
// the redundancy has to be at the application layer. (The mic uplink is a different encoder, VoIP
|
||||
// mode at 10 ms, and *does* use real in-band FEC.)
|
||||
//
|
||||
// **Why it costs no latency.** The copy rides the SUCCESSOR of the frame it protects, and the
|
||||
// client is already holding 15–90 ms of de-jitter buffer — far more than the 5 ms the successor
|
||||
// takes to arrive. So the recovery happens inside slack that already exists.
|
||||
//
|
||||
// The previous frame's sequence is implicitly `seq - 1`; a host with nothing to duplicate yet
|
||||
// (the first frame of a session, or straight after a capture reopen) simply sends an empty tail,
|
||||
// which decodes to `None`.
|
||||
//
|
||||
// Sent ONLY when the client advertised [`CLIENT_CAP_AUDIO_RED`](super::caps::CLIENT_CAP_AUDIO_RED)
|
||||
// and the host answered [`HOST_CAP_AUDIO_RED`](super::caps::HOST_CAP_AUDIO_RED) — the
|
||||
// capable-and-agreed handshake the cursor and 4:4:4 planes already use. Every other session keeps
|
||||
// the plain [`AUDIO_MAGIC`] wire byte-for-byte.
|
||||
//
|
||||
// NB `0xD1` is deliberately skipped: the DualSense pad-audio program has reserved it for the
|
||||
// per-pad audio plane.
|
||||
#define PUNKTFUNK_AUDIO_RED_MAGIC 210
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Fixed header length of an [`AUDIO_RED_MAGIC`] datagram (tag + seq + pts + primary length).
|
||||
#define PUNKTFUNK_AUDIO_RED_HEADER (((1 + 4) + 8) + 2)
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Wire length of a v1 (legacy, level) rumble datagram.
|
||||
#define RUMBLE_V1_LEN 7
|
||||
@@ -1397,6 +1492,14 @@ typedef uint8_t PunktfunkInputKind;
|
||||
typedef struct ColorInfo ColorInfo;
|
||||
#endif
|
||||
|
||||
// Tuning for [`JitterPolicy`], in MILLISECONDS.
|
||||
//
|
||||
// Denominating the depth in time rather than in device quanta is the point. Every client used to
|
||||
// compute its target as `3 × quantum`, which is a sane 15 ms at a 5 ms quantum and a silent 64 ms
|
||||
// at a 20 ms one — the same source line meaning two very different latencies depending on what
|
||||
// else happened to be using the audio graph that day.
|
||||
typedef struct JitterTuning JitterTuning;
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Opaque handle to a live `punktfunk/1` connection (QUIC control plane + UDP data plane, all
|
||||
// pumped on internal threads).
|
||||
@@ -1788,6 +1891,14 @@ typedef struct {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// The multipliers a picker offers. `1.0` (Native) is the default; the rest are the round stops
|
||||
// users reason about. Shared so every client's list stays identical.
|
||||
#define PRESETS { 0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, }
|
||||
|
||||
@@ -360,32 +360,9 @@ fn ring_len(view: &pf_umdf_util::section::MappedView) -> u32 {
|
||||
/// from being coalesced away by a following LED/trigger report inside one host poll window (the
|
||||
/// confirmed stuck-rumble path).
|
||||
fn publish_output(view: &pf_umdf_util::section::MappedView, bytes: &[u8]) {
|
||||
// Serialized: the whole publish is a read-modify-write (read the cursor, write the slot it
|
||||
// names, then advance it) and the framework dispatches output callbacks in PARALLEL, so two
|
||||
// can be inside this at once. Unsynchronized, both read the same `ring_head`, both write the
|
||||
// SAME slot — tearing one report's bytes across the other's — and both store head+1, so the
|
||||
// cursor advances once for two reports and the host sees a single torn entry.
|
||||
//
|
||||
// An atomic `fetch_add` on the head does not fix it. That hands each writer a distinct slot,
|
||||
// but it advances the cursor BEFORE the slot bytes exist, so the host can read a slot that is
|
||||
// still being filled — trading a torn slot for a torn slot the host is invited to read. Making
|
||||
// the head-advance mean "the slot below is complete" is exactly what the lock buys.
|
||||
//
|
||||
// Poison-tolerant on purpose. Poison is sticky, so the repo's usual `if let Ok(g) = lock()`
|
||||
// would skip the publish for the REST OF THE PROCESS after a single panic elsewhere — silently
|
||||
// ending game output. Recovering the guard is safe here: the protected state is bytes in a
|
||||
// shared section, not an invariant a panic could have broken.
|
||||
let _publish = RING_PUBLISH
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
view.write_bytes(OFF_OUTPUT, bytes);
|
||||
let seq = view.read_u32(OFF_OUT_SEQ).wrapping_add(1);
|
||||
// Release, not a plain write: the host loads `out_seq` with Acquire specifically to order its
|
||||
// copy of the report bytes after it (`dualsense_windows.rs`, "Acquire pairs with the driver's
|
||||
// publish-then-bump store order"). An Acquire load pairs with a Release store and nothing
|
||||
// else, so as a plain write this promised the host an ordering it never actually established —
|
||||
// on a weakly-ordered core (ARM64) the fresh seq could arrive ahead of the bytes it announces.
|
||||
view.store_u32(OFF_OUT_SEQ, seq, Ordering::Release);
|
||||
view.write_u32(OFF_OUT_SEQ, seq);
|
||||
let len = ring_len(view);
|
||||
if len != 0 {
|
||||
let head = view.read_u32(OFF_RING_HEAD);
|
||||
@@ -398,11 +375,6 @@ fn publish_output(view: &pf_umdf_util::section::MappedView, bytes: &[u8]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes [`publish_output`] against itself — see the note there for why an atomic cursor is
|
||||
/// not enough. Uncontended in the common case: one output report at a time is the norm, and the
|
||||
/// critical section is a few dozen bytes of memcpy into an already-mapped view.
|
||||
static RING_PUBLISH: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// The sealed-channel client (per-pad: `ProcessSharingDisabled` gives each pad its own WUDFHost, so
|
||||
/// this static is per-pad). The handshake/adoption/validation state machine lives in `pf_umdf_util`.
|
||||
static CHANNEL: ChannelClient = ChannelClient::new();
|
||||
|
||||
@@ -358,48 +358,20 @@ fn read_state(data: Option<&MappedView>) -> (u32, u16, u8, u8, i16, i16, i16, i1
|
||||
/// host can tell "driver bound and alive" apart from "driver package missing/failed to bind" and see
|
||||
/// the game-visible polling path advance.
|
||||
fn touch_driver_marks(data: &MappedView) {
|
||||
let _marks = SECTION_PUBLISH
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
data.write_u32(OFF_DRIVER_PROTO, GAMEPAD_PROTO_VERSION);
|
||||
let hb = data.read_u32(OFF_DRIVER_HEARTBEAT).wrapping_add(1);
|
||||
data.write_u32(OFF_DRIVER_HEARTBEAT, hb);
|
||||
}
|
||||
|
||||
/// Publish a game's rumble (from SET_STATE) into the DATA section for the host to forward.
|
||||
///
|
||||
/// Serialized and Release-published, because IOCTLs arrive concurrently and neither property held
|
||||
/// before. `seq` was a read-modify-write across the two motor bytes: two `SET_STATE` calls could
|
||||
/// both read the same value and both write back `seq + 1`, so the host — which treats an unchanged
|
||||
/// seq as "nothing new" — saw one bump for two writes and skipped a level entirely. A skipped
|
||||
/// **stop** is the one that hurts: the pad keeps buzzing until the host's ~2.5 s idle force-off
|
||||
/// notices the game went quiet, which is where the bound on this bug comes from.
|
||||
///
|
||||
/// The seq store is Release for the same reason as `pf-gamepad`'s `out_seq`: the host loads it with
|
||||
/// Acquire and documents that as ordering its read of the motor bytes ("the driver bumps
|
||||
/// `rumble_seq` AFTER writing the rumble bytes", `gamepad_windows.rs`). A plain write gives that
|
||||
/// Acquire nothing to pair with, so the guarantee the host's comment claims did not exist in either
|
||||
/// direction — the host could read a fresh seq against stale motor levels on a weakly-ordered core.
|
||||
fn publish_rumble(data: Option<&MappedView>, large: u8, small: u8) {
|
||||
let Some(v) = data else { return };
|
||||
let _publish = SECTION_PUBLISH
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
v.write_u8(OFF_RUMBLE_LARGE, large);
|
||||
v.write_u8(OFF_RUMBLE_SMALL, small);
|
||||
let seq = v.read_u32(OFF_RUMBLE_SEQ).wrapping_add(1);
|
||||
v.store_u32(OFF_RUMBLE_SEQ, seq, Ordering::Release);
|
||||
v.write_u32(OFF_RUMBLE_SEQ, seq);
|
||||
}
|
||||
|
||||
/// Serializes the section's read-modify-write publishes ([`publish_rumble`], [`touch_driver_marks`])
|
||||
/// against each other. One lock rather than one per field: they are all short byte writes into the
|
||||
/// same mapped view, and the contention is nil compared to the IOCTL round trip that reaches them.
|
||||
///
|
||||
/// Poison-tolerant deliberately — poison is sticky, so bailing out on it would silently stop
|
||||
/// forwarding rumble for the rest of the process. The protected state is bytes in a shared section,
|
||||
/// not an invariant a panic elsewhere could have violated.
|
||||
static SECTION_PUBLISH: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
// Build the 29-byte GET_STATE buffer (the layout xinput1_4 parses).
|
||||
fn build_get_state(data: Option<&MappedView>) -> [u8; 29] {
|
||||
let (packet, buttons, lt, rt, lx, ly, rx, ry) = read_state(data);
|
||||
|
||||
Reference in New Issue
Block a user