Compare commits
46
Commits
+24
-30
@@ -6,17 +6,12 @@
|
||||
#
|
||||
# The plugin backend is PURE PYTHON (clients/decky/main.py — no compiled binary), so we do NOT
|
||||
# need the Decky CLI (which requires Docker + rust-nightly only to compile native backends).
|
||||
# We build the frontend with pnpm and assemble the store-layout zip by hand:
|
||||
#
|
||||
# punktfunk.zip
|
||||
# punktfunk/ <- single top-level dir == plugin.json "name"
|
||||
# plugin.json [required]
|
||||
# package.json [required; CI stamps "version" — Decky reads the installed version here]
|
||||
# main.py [required: python backend]
|
||||
# dist/index.js [required: rollup output]
|
||||
# update.json [CI-baked {channel, manifest}: where the plugin's self-update check polls]
|
||||
# README.md (recommended)
|
||||
# LICENSE [required by the plugin store]
|
||||
# We build the frontend with pnpm and stage the store-layout tree with the SAME script local
|
||||
# builds use (clients/decky/scripts/package.sh) — the plugin's file list lives in exactly ONE
|
||||
# place, so a file added there (bin/, assets/, controller_config/, …) can never be silently
|
||||
# missing from the published build. (Hand-assembling the zip here is how the shipped plugin
|
||||
# lost the shortcut artwork + Steam Input layout for a while.) CI only adds `update.json` on
|
||||
# top: the {channel, manifest} pointer the plugin's self-update check polls.
|
||||
#
|
||||
# SELF-UPDATE (no Decky store): alongside the zip we also publish a tiny per-channel
|
||||
# `manifest.json` ({version, artifact=<immutable per-version zip URL>, sha256}). The installed
|
||||
@@ -90,28 +85,27 @@ jobs:
|
||||
- name: Assemble store-layout zip
|
||||
working-directory: ${{ gitea.workspace }}
|
||||
run: |
|
||||
apt-get update && apt-get install -y --no-install-recommends zip >/dev/null
|
||||
STAGE="$RUNNER_TEMP/decky"
|
||||
DEST="$STAGE/$PLUGIN"
|
||||
rm -rf "$STAGE"; mkdir -p "$DEST/dist" "$DEST/bin"
|
||||
cp clients/decky/plugin.json "$DEST/"
|
||||
cp clients/decky/package.json "$DEST/"
|
||||
cp clients/decky/main.py "$DEST/"
|
||||
cp clients/decky/dist/index.js "$DEST/dist/"
|
||||
cp clients/decky/README.md "$DEST/"
|
||||
# The stream-launch wrapper (target of the Steam shortcut); keep it executable
|
||||
# (runner_info() also re-chmods at runtime in case the zip/extract drops the bit).
|
||||
cp clients/decky/bin/punktfunkrun.sh "$DEST/bin/"
|
||||
chmod 0755 "$DEST/bin/punktfunkrun.sh"
|
||||
# Store requires a LICENSE in the plugin root; the project is MIT OR Apache-2.0.
|
||||
cp LICENSE-MIT "$DEST/LICENSE"
|
||||
# Self-update channel pointer the backend reads (main.py check_update). It points at
|
||||
# THIS channel's manifest.json (published below); that manifest in turn points at the
|
||||
# immutable per-version zip, so its sha256 stays valid across future alias re-uploads.
|
||||
# node:22-bookworm ships python3 (a package.sh dep) but not zip; install both anyway
|
||||
# so an image change can't silently break the build.
|
||||
apt-get update && apt-get install -y --no-install-recommends zip python3 >/dev/null
|
||||
# Stage the canonical plugin tree (dist/, main.py, bin/, assets/, controller_config/,
|
||||
# LICENSE, …) with the same script local/sideload builds use — see the header comment.
|
||||
# Runs AFTER the version stamp, so the staged package.json carries $VERSION.
|
||||
bash clients/decky/scripts/package.sh
|
||||
DEST="clients/decky/out/$PLUGIN"
|
||||
# CI-only addition: the self-update channel pointer the backend reads (main.py
|
||||
# check_update). It points at THIS channel's manifest.json (published below); that
|
||||
# manifest in turn points at the immutable per-version zip, so its sha256 stays valid
|
||||
# across future alias re-uploads.
|
||||
printf '{"channel":"%s","manifest":"%s/%s/manifest.json"}\n' "$ALIAS" "$BASE" "$ALIAS" > "$DEST/update.json"
|
||||
( cd "$STAGE" && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" )
|
||||
( cd clients/decky/out && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" )
|
||||
ls -lh "$RUNNER_TEMP/punktfunk.zip"
|
||||
unzip -l "$RUNNER_TEMP/punktfunk.zip"
|
||||
# Backstop against packaging drift: the runtime-loaded pieces MUST be in the zip.
|
||||
for f in main.py dist/index.js bin/punktfunkrun.sh assets/grid.png \
|
||||
controller_config/punktfunk.vdf update.json; do
|
||||
unzip -l "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN/$f" >/dev/null || { echo "MISSING $f" >&2; exit 1; }
|
||||
done
|
||||
# The update manifest the plugin polls: the immutable per-version artifact + its
|
||||
# sha256 (Decky's installer verifies the download against this hash, aborting on
|
||||
# mismatch — so it MUST be the per-version URL, never the mutable alias).
|
||||
|
||||
Generated
+67
-27
@@ -656,6 +656,30 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20poly1305"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"chacha20",
|
||||
"cipher",
|
||||
"poly1305",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ciborium"
|
||||
version = "0.2.2"
|
||||
@@ -691,6 +715,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2159,7 +2184,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2264,7 +2289,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2299,7 +2324,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2788,7 +2813,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2808,7 +2833,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2832,7 +2857,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2850,7 +2875,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2871,7 +2896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2881,6 +2906,7 @@ dependencies = [
|
||||
"libvpl-sys",
|
||||
"nvidia-video-codec-sdk",
|
||||
"openh264",
|
||||
"pf-capture",
|
||||
"pf-frame",
|
||||
"pf-gpu",
|
||||
"pf-host-config",
|
||||
@@ -2894,7 +2920,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2903,7 +2929,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -2915,7 +2941,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -2929,11 +2955,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2961,14 +2987,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2983,7 +3009,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3013,7 +3039,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3025,7 +3051,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3136,6 +3162,17 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "poly1305"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
||||
dependencies = [
|
||||
"cpufeatures",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
@@ -3221,7 +3258,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3237,7 +3274,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3253,7 +3290,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3268,7 +3305,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3287,11 +3324,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
"cbindgen",
|
||||
"chacha20poly1305",
|
||||
"criterion",
|
||||
"fec-rs",
|
||||
"hmac",
|
||||
@@ -3318,7 +3356,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3363,11 +3401,13 @@ dependencies = [
|
||||
"rand 0.8.6",
|
||||
"rcgen",
|
||||
"reis",
|
||||
"ring",
|
||||
"roxmltree",
|
||||
"rsa",
|
||||
"rusqlite",
|
||||
"rustls",
|
||||
"rusty_enet",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -3400,7 +3440,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3414,7 +3454,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3437,7 +3477,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.16.0"
|
||||
version = "0.17.2"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+1133
-1
File diff suppressed because it is too large
Load Diff
@@ -705,8 +705,11 @@ final class SessionModel: ObservableObject {
|
||||
// captured before the 2026-07 floor policy); the appended trio carries the
|
||||
// measured OS present floor and the floor-shaved values the HUD displays.
|
||||
let line = String(
|
||||
format: "fps=%d presents=%d e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
|
||||
+ "decode_p50=%.1f display_p50=%.1f lost=%d "
|
||||
// Swift Int is 64-bit → %lld, NOT %d (which is a 32-bit C int); macOS 26's
|
||||
// strict String(format:) validator rejects the %d/Int mismatch and drops
|
||||
// the whole line (a cascade error that also mis-blames the float args).
|
||||
format: "fps=%lld presents=%lld e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
|
||||
+ "decode_p50=%.1f display_p50=%.1f lost=%lld "
|
||||
+ "floor_p50=%.1f display_adj=%.1f e2e_adj=%.1f queue_p50=%.1f",
|
||||
frames,
|
||||
displayWindow?.count ?? 0,
|
||||
|
||||
@@ -43,6 +43,9 @@ struct GamepadSettingsView: View {
|
||||
@AppStorage(DefaultsKey.presentPriority) private var presentPriority =
|
||||
SettingsOptions.presentPriorityDefault
|
||||
@AppStorage(DefaultsKey.smoothBuffer) private var smoothBuffer = 0
|
||||
#if os(macOS)
|
||||
@AppStorage(DefaultsKey.windowedSafePresent) private var windowedSafePresent = true
|
||||
#endif
|
||||
#if os(iOS)
|
||||
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
||||
#endif
|
||||
@@ -345,6 +348,22 @@ struct GamepadSettingsView: View {
|
||||
detail: "Turn off to use the touch interface even with a controller connected.",
|
||||
value: $gamepadUIEnabled),
|
||||
]
|
||||
#if os(macOS)
|
||||
// The windowed safe-present toggle slots in after "Smoothness buffer" (staying inside
|
||||
// the Video group) — macOS only, mirroring the touch SettingsView's Presentation row
|
||||
// (the DCP swapID-panic mitigation; see DefaultsKey.windowedSafePresent).
|
||||
if let at = list.firstIndex(where: { $0.id == "smoothBuffer" }) {
|
||||
list.insert(
|
||||
toggleRow(
|
||||
id: "windowedSafePresent", icon: "macwindow.badge.plus",
|
||||
label: "Safe windowed presentation",
|
||||
detail: "Windowed streams present in step with the compositor — avoids a "
|
||||
+ "macOS display-driver crash on high-refresh displays, at a small "
|
||||
+ "latency cost. Fullscreen always uses the fastest path.",
|
||||
value: $windowedSafePresent),
|
||||
at: at + 1)
|
||||
}
|
||||
#endif
|
||||
#if os(iOS)
|
||||
// The device-rumble mirror slots in after "Controller type" (staying inside the
|
||||
// Controller group — the next row carries the "Interface" header). iPhone only in
|
||||
|
||||
@@ -300,6 +300,18 @@ extension SettingsView {
|
||||
+ "of added latency. Off shows frames as soon as they're ready.") {
|
||||
Toggle("V-Sync", isOn: $vsync)
|
||||
}
|
||||
// The DCP swapID-panic mitigation's user handle (see DefaultsKey.windowedSafePresent
|
||||
// for the saga). Default ON: turning it off re-arms a WHOLE-MACHINE kernel panic on
|
||||
// affected setups, so the caption says so in plain words.
|
||||
described(windowedSafePresent
|
||||
? "Windowed streams present in step with the system compositor — avoids a macOS "
|
||||
+ "display-driver crash seen on high-refresh displays, at a small latency "
|
||||
+ "cost. Fullscreen always uses the fastest path."
|
||||
: "Windowed streams use the fastest present path. On some high-refresh setups "
|
||||
+ "this can crash macOS itself (kernel panic) — turn back on if your Mac "
|
||||
+ "restarts during windowed streaming.") {
|
||||
Toggle("Safe windowed presentation", isOn: $windowedSafePresent)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -435,6 +447,14 @@ extension SettingsView {
|
||||
/// (always on macOS; an attached keyboard/mouse on iPad). Absent on tvOS (no such input path).
|
||||
@ViewBuilder var inputSection: some View {
|
||||
Section("Keyboard & mouse") {
|
||||
#if os(macOS)
|
||||
described(mouseModeDescription) {
|
||||
Picker("Mouse input", selection: $mouseMode) {
|
||||
Text("Capture (games)").tag(MouseInputMode.capture.rawValue)
|
||||
Text("Desktop (absolute)").tag(MouseInputMode.desktop.rawValue)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
described((ModifierLayout(rawValue: modifierLayout) ?? .mac).detail) {
|
||||
Picker("Modifier keys", selection: $modifierLayout) {
|
||||
ForEach(ModifierLayout.allCases, id: \.self) { layout in
|
||||
@@ -447,6 +467,20 @@ extension SettingsView {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// The SELECTED mouse model explained — dynamic, like the touch-mode caption.
|
||||
private var mouseModeDescription: String {
|
||||
switch MouseInputMode(rawValue: mouseMode) ?? .capture {
|
||||
case .capture:
|
||||
return "The pointer locks to the stream and sends relative motion — best for "
|
||||
+ "games. ⌃⌥⇧M switches live; applies from the next capture otherwise."
|
||||
case .desktop:
|
||||
return "The pointer moves freely in and out of the stream and sends absolute "
|
||||
+ "positions — best for remote desktop work. Unavailable on gamescope hosts."
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
// MARK: - Audio
|
||||
|
||||
@@ -40,6 +40,7 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.smoothBuffer) var smoothBuffer = 0
|
||||
#if os(macOS)
|
||||
@AppStorage(DefaultsKey.vsync) var vsync = false
|
||||
@AppStorage(DefaultsKey.windowedSafePresent) var windowedSafePresent = true
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
@AppStorage(DefaultsKey.allowVRR) var allowVRR = true
|
||||
@@ -88,6 +89,7 @@ struct SettingsView: View {
|
||||
@State var customMode = false
|
||||
#endif
|
||||
#if os(macOS)
|
||||
@AppStorage(DefaultsKey.mouseMode) var mouseMode = MouseInputMode.capture.rawValue
|
||||
@AppStorage(DefaultsKey.speakerUID) var speakerUID = ""
|
||||
@AppStorage(DefaultsKey.micUID) var micUID = ""
|
||||
@AppStorage(DefaultsKey.micChannel) var micChannel = 0
|
||||
|
||||
@@ -110,11 +110,11 @@ public final class InputCapture {
|
||||
/// event itself is swallowed). Main queue.
|
||||
public var onToggleCapture: (() -> Void)?
|
||||
|
||||
/// Fired on ⌘⇧C (the client-side-cursor toggle — flips between the captured/disassociated
|
||||
/// relative path and the visible-cursor absolute path; detected here, like ⌘⎋, so it works
|
||||
/// regardless of the current capture state and the event itself is swallowed). macOS only;
|
||||
/// the absolute-vs-relative forwarding lives entirely in StreamLayerView. Main queue.
|
||||
public var onToggleCursor: (() -> Void)?
|
||||
/// Fired on ⌃⌥⇧M (the mouse-model flip, capture ⇄ desktop — cross-client parity with the
|
||||
/// SDL clients' Ctrl+Alt+Shift+M; detected here, like ⌘⎋, so it works regardless of the
|
||||
/// current capture state and the event itself is swallowed). macOS only; the
|
||||
/// absolute-vs-relative forwarding lives entirely in StreamLayerView. Main queue.
|
||||
public var onToggleMouseMode: (() -> Void)?
|
||||
|
||||
/// The cross-client combos (Windows/Linux parity: Ctrl+Alt+Shift+Q/D/S), fired from the macOS
|
||||
/// keyDown monitor only WHILE FORWARDING — that's the state in which the app's menu (which
|
||||
@@ -245,13 +245,14 @@ public final class InputCapture {
|
||||
self.onToggleCapture?()
|
||||
return nil
|
||||
}
|
||||
// ⌘⇧C toggles the client-side cursor (visible-cursor absolute path vs the
|
||||
// captured relative path). keyCode 8 = kVK_ANSI_C; layout-independent so it
|
||||
// fires the same on any keyboard. Suppress the C (latched like ⌘⎋'s Esc) so it
|
||||
// doesn't type into the host, and swallow the event so it doesn't beep.
|
||||
if event.keyCode == 8 /* C */, flags == [.command, .shift] {
|
||||
self.suppressedVK = 0x43 // VK_C — the same physical C is en route via GC
|
||||
self.onToggleCursor?()
|
||||
// ⌃⌥⇧M flips the mouse model (capture ⇄ desktop — the SDL clients' identical
|
||||
// chord). Detected in both capture states, like ⌘⎋, so the model can be set
|
||||
// before engaging. keyCode 46 = kVK_ANSI_M; layout-independent. Suppress the M
|
||||
// (latched like ⌘⎋'s Esc) so it doesn't type into the host, and swallow the
|
||||
// event so it doesn't beep.
|
||||
if event.keyCode == 46 /* M */, flags == [.control, .option, .shift] {
|
||||
self.suppressedVK = 0x4D // VK_M — the same physical M is en route via GC
|
||||
self.onToggleMouseMode?()
|
||||
return nil
|
||||
}
|
||||
// The cross-client combos (Ctrl+Alt+Shift+Q/D/S — the same set every other
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/// How a physical mouse drives the host — the cross-client mouse model (the SDL clients'
|
||||
/// `MouseMode` / `Settings::mouse_mode`, design/remote-desktop-sweep.md M1). Stored stringly
|
||||
/// under `DefaultsKey.mouseMode`.
|
||||
public enum MouseInputMode: String, CaseIterable, Sendable {
|
||||
/// Pointer capture (disassociated, hidden cursor, relative deltas) — the game model,
|
||||
/// and the default: the only cursor you see is the host's.
|
||||
case capture
|
||||
/// Absolute pointer, uncaptured: the cursor enters and leaves the stream freely and
|
||||
/// motion is forwarded as absolute positions through the letterbox. The remote desktop
|
||||
/// model. Requires a host injector with absolute support (not gamescope).
|
||||
case desktop
|
||||
}
|
||||
@@ -23,6 +23,34 @@ import os
|
||||
|
||||
private let presenterLog = Logger(subsystem: "io.unom.punktfunk", category: "presenter")
|
||||
|
||||
#if os(macOS)
|
||||
/// HOW a windowed (composited) macOS session pushes finished frames to glass — the DCP
|
||||
/// "mismatched swapID's" kernel-panic saga's mechanism picker. Fullscreen always presents
|
||||
/// `async` (direct-scanout promotion, lowest latency, no panic reports there); the windowed
|
||||
/// mechanism is resolved per session by SessionPresenter (user setting +
|
||||
/// PUNKTFUNK_WINDOWED_PRESENT env override) and routed here via `setWindowedPresent`.
|
||||
///
|
||||
/// - `async`: the CAMetalLayer image queue (`commandBuffer.present`) — the fastest composited
|
||||
/// path and the PANIC TRIGGER on high-refresh displays (the out-of-band swaps race
|
||||
/// WindowServer's compositor; it survived glass pacing and every codec).
|
||||
/// - `transaction`: `CAMetalLayer.presentsWithTransaction` — the swap commits WITH the layer
|
||||
/// tree, in lockstep with the compositor (Apple's documented remedy; validated no-panic on
|
||||
/// the 240 Hz repro machine). The present is committed from the RENDER thread inside an
|
||||
/// explicit CATransaction + flush — see `encodePresent` for why that beats the original
|
||||
/// main-thread hop.
|
||||
/// - `surface`: no image queue at all — render into a pooled IOSurface and swap it into a plain
|
||||
/// CALayer's `contents` (the f407f418 PyroWave mitigation, resurrected format-aware:
|
||||
/// rgba16Float + PQ tagging keeps HDR). WindowServer treats it as ordinary layer damage on
|
||||
/// its own composite cadence. PROTOTYPE: whether the compositor honors PQ/EDR for plain-layer
|
||||
/// IOSurface contents still needs an on-glass eyeball — the metal layer stays underneath with
|
||||
/// `wantsExtendedDynamicRangeContent` as the EDR anchor.
|
||||
enum WindowedPresentMode: String, Sendable {
|
||||
case async
|
||||
case transaction
|
||||
case surface
|
||||
}
|
||||
#endif
|
||||
|
||||
/// HDR reference white (BT.2408 "HDR Reference White"): the absolute luminance, in nits, that the
|
||||
/// PQ signal's diffuse white sits at. Passed to `CAEDRMetadata.hdr10(opticalOutputScale:)`, it anchors
|
||||
/// 203-nit diffuse white at EDR 1.0 (the display's SDR-white level) and lets the system tone-map the
|
||||
@@ -198,8 +226,8 @@ fragment float4 pf_frag_hdr(VOut in [[stage_in]],
|
||||
// in a genuine HDR10 output, PQ passthrough is the correct emission and the TV tone-maps.)
|
||||
// The shared PQ→display-referred-SDR tail (see pf_frag_hdr_tv's rationale above): ST 2084
|
||||
// EOTF → 203-nit-anchored scene light → BT.2020→709 primaries → extended-Reinhard rolloff →
|
||||
// BT.709 OETF. Used by the tvOS biplanar tone-map and the planar (PyroWave) tone-map — the
|
||||
// latter also on macOS windowed sessions, whose IOSurface present path is BGRA8-only.
|
||||
// BT.709 OETF. Used by the tvOS biplanar tone-map and the tvOS planar (PyroWave) tone-map (the
|
||||
// no-HDR-headroom fallback). macOS keeps real HDR windowed now — see `WindowedPresentMode`.
|
||||
static inline float3 pqToSdr(float3 pq) {
|
||||
const float m1 = 2610.0/16384.0;
|
||||
const float m2 = 78.84375;
|
||||
@@ -230,8 +258,7 @@ fragment float4 pf_frag_hdr_tv(VOut in [[stage_in]],
|
||||
|
||||
// PyroWave planar HDR tone-map: three separate R16 planes (P010-style studio codes; the rows
|
||||
// fold in depth-10 MSB packing) → PQ R′G′B′ → the shared SDR tail. Used when a PQ pyrowave
|
||||
// stream must land on an 8-bit surface: tvOS without HDR headroom, and macOS WINDOWED sessions
|
||||
// (the IOSurface present path — the DCP-panic mitigation — is BGRA8). The passthrough planar
|
||||
// stream must land on an 8-bit surface: tvOS without HDR headroom. The passthrough planar
|
||||
// HDR pipeline reuses pf_frag_planar itself on an rgba16Float drawable (identical math — the
|
||||
// layer's itur_2100_PQ colour space + EDR metadata do the interpretation).
|
||||
fragment float4 pf_frag_planar_tm(VOut in [[stage_in]],
|
||||
@@ -259,21 +286,51 @@ public final class MetalVideoPresenter {
|
||||
public let layer: CAMetalLayer
|
||||
|
||||
#if os(macOS)
|
||||
/// The WINDOWED-mode PyroWave present target: a plain CALayer sized like `layer` (installed
|
||||
/// as a sibling ABOVE it), fed IOSurfaces via `contents` inside ordinary CATransactions.
|
||||
/// WINDOWED-mode present coordination — the macOS DCP KERNEL PANIC mitigation.
|
||||
///
|
||||
/// Why this exists — the macOS DCP KERNEL PANIC ("mismatched swapID's" @UnifiedPipeline.cpp,
|
||||
/// WindowServer dies, machine reboots): out-of-band CAMetalLayer image-queue swaps into a
|
||||
/// COMPOSITED (windowed) session race WindowServer's own swap submissions on high-refresh
|
||||
/// displays, and the race survives glass pacing — a fully serialized one-in-flight present
|
||||
/// stream still panicked a 240 Hz Mac Studio (2026-07-18, twice). So in windowed mode we stop
|
||||
/// using the image queue entirely and present the way video players do: render the planar CSC
|
||||
/// into an IOSurface pool and swap `contents` on main — WindowServer treats it as ordinary
|
||||
/// damage on its own composite cadence, coalescing faster-than-refresh updates instead of
|
||||
/// latching queue swaps mid-cycle. Fullscreen keeps the CAMetalLayer path (direct-scanout
|
||||
/// promotion, no compositing, no panic reports). Contents updates are transparent to the
|
||||
/// layer below when nil, so flipping modes just covers/uncovers the metal layer.
|
||||
public let surfaceLayer: CALayer = {
|
||||
/// The panic ("mismatched swapID's" @UnifiedPipeline.cpp, WindowServer dies, machine reboots):
|
||||
/// the CAMetalLayer's ASYNCHRONOUS image queue (`commandBuffer.present(drawable)` — an
|
||||
/// out-of-band flip, mandatory with `displaySyncEnabled=false`) diverges from WindowServer's
|
||||
/// compositor on a high-refresh COMPOSITED (windowed) session — the compositor's notion of the
|
||||
/// current swap and the layer's queued swap disagree, and the DCP asserts. It survived glass
|
||||
/// pacing: a fully serialized one-in-flight present stream still panicked a 240 Hz Mac Studio
|
||||
/// (2026-07-18, PyroWave), and a windowed HEVC session panicked the same machine 2026-07-21 —
|
||||
/// so it is the async image queue itself, at any pacing or codec, not a present rate.
|
||||
///
|
||||
/// The fix keeps the full render path (rgba16Float / PQ / EDR — real HDR is preserved) and
|
||||
/// only changes HOW the drawable is presented: `CAMetalLayer.presentsWithTransaction`. With it
|
||||
/// set, we don't hand the drawable to the command buffer; we commit, wait until scheduled, then
|
||||
/// call `drawable.present()` INSIDE a CATransaction — the present is enrolled in Core
|
||||
/// Animation's transaction and committed together with the layer tree, so the swap stays in
|
||||
/// lockstep with the compositor instead of racing it (Apple's documented remedy for Metal
|
||||
/// presentation drifting out of sync with CA). Fullscreen keeps the async path (direct-scanout
|
||||
/// promotion, lowest latency, no compositor and no panic reports there).
|
||||
///
|
||||
/// 2026-07-21 latency rework: the mitigation MECHANISM is now a three-way pick
|
||||
/// (`WindowedPresentMode`) and the transactional present commits from the RENDER thread —
|
||||
/// see `encodePresent`. Staged under `stagingLock` (main pushes it via
|
||||
/// `setComposited`→`setWindowedPresent`); the render thread drains it and toggles the layer
|
||||
/// property + present style. `Active` is the render-thread copy so the layer property flips
|
||||
/// exactly once per mode change.
|
||||
private var windowedPresentStaged: WindowedPresentMode = .async
|
||||
private var windowedPresentActive: WindowedPresentMode = .async
|
||||
|
||||
/// PUNKTFUNK_TXN_PRESENT=main — the ORIGINAL transactional present (commit →
|
||||
/// waitUntilScheduled → hop to the MAIN thread and present inside its CATransaction), kept
|
||||
/// as a field A/B lever. The default is the render-thread commit: the present harness
|
||||
/// (2026-07-21, this saga) measured the main hop landing a runloop turn late on a busy main
|
||||
/// thread, and an ACTIVE implicit transaction there NESTS the explicit one — presents batch
|
||||
/// at runloop-iteration rate (the field's presents=55 @ fps=240, display_p50 18.6 ms).
|
||||
/// Off-main commits measured immune to main-thread churn (~10 ms glass p50 at 240 Hz
|
||||
/// full-size vs 14+ ms under a churned main hop).
|
||||
private let txnPresentOnMain =
|
||||
ProcessInfo.processInfo.environment["PUNKTFUNK_TXN_PRESENT"] == "main"
|
||||
|
||||
/// The WINDOWED-mode `surface` present target: a plain CALayer sized like `layer` (installed
|
||||
/// as a sibling ABOVE it by SessionPresenter), fed IOSurfaces via `contents` inside explicit
|
||||
/// CATransactions. Transparent (nil contents) whenever surface mode is off, so the metal
|
||||
/// layer below shows through. See `WindowedPresentMode.surface`.
|
||||
let surfaceLayer: CALayer = {
|
||||
let l = CALayer()
|
||||
l.contentsGravity = .resize // frame is already aspect-fit + pixel-snapped by layout
|
||||
l.isOpaque = true
|
||||
@@ -281,8 +338,8 @@ public final class MetalVideoPresenter {
|
||||
return l
|
||||
}()
|
||||
|
||||
/// One IOSurface-backed render target of the windowed present pool. All pool state is
|
||||
/// RENDER-THREAD confined; only the immutable surface refs cross to main (contents swap).
|
||||
/// One IOSurface-backed render target of the windowed surface-present pool. All pool state
|
||||
/// is RENDER-THREAD confined; only the immutable surface refs cross threads (contents swap).
|
||||
private struct SurfaceSlot {
|
||||
let surface: IOSurfaceRef
|
||||
let texture: MTLTexture
|
||||
@@ -292,15 +349,52 @@ public final class MetalVideoPresenter {
|
||||
|
||||
private var surfacePool: [SurfaceSlot] = []
|
||||
private var surfacePoolSize: CGSize = .zero
|
||||
private var surfacePoolHDR = false
|
||||
private var surfaceSeq: UInt64 = 0
|
||||
/// Index of the slot most recently handed to the layer — never rewritten next, even if its
|
||||
/// use count already dropped (the compositor may still be scanning out the previous frame).
|
||||
private var lastHandedOff: Int?
|
||||
/// Staged (under `stagingLock`, like every cross-thread input): the hosting view's windowed
|
||||
/// vs fullscreen state, pushed from main via `setSurfacePresents`. Drained in `renderPlanar`.
|
||||
private var surfacePresentsStaged = false
|
||||
/// Render-thread copy, so pool teardown happens exactly once on a mode flip.
|
||||
private var surfacePresentsActive = false
|
||||
|
||||
/// Once-per-second decomposition of the ACTIVE windowed present path (the field-diagnosis
|
||||
/// half of the DCP-latency work): scheduled/completed wait + commit/flush cost per present,
|
||||
/// and how many presents/swaps were issued. The pf-present line shows the GLASS side
|
||||
/// (latchMs / dropped); this shows the ISSUE side. Logged via `presenterLog` only while a
|
||||
/// windowed mechanism is active (zero cost fullscreen). Lock-guarded: transaction mode
|
||||
/// records from the render thread, surface mode from Metal completion threads.
|
||||
private final class WindowedPresentDiag: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var presents = 0
|
||||
private var schedMs: [Double] = []
|
||||
private var commitMs: [Double] = []
|
||||
private var last = CACurrentMediaTime()
|
||||
|
||||
func record(schedMs sched: Double, commitMs commit: Double, mode: WindowedPresentMode) {
|
||||
lock.lock()
|
||||
presents += 1
|
||||
schedMs.append(sched)
|
||||
commitMs.append(commit)
|
||||
let now = CACurrentMediaTime()
|
||||
guard now - last >= 1 else {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
last = now
|
||||
let sSched = schedMs.sorted()
|
||||
let sCommit = commitMs.sorted()
|
||||
let line = String(
|
||||
format: "pf-windowed mode=%@ presents=%d schedMs p50=%.2f max=%.2f "
|
||||
+ "commitMs p50=%.2f max=%.2f",
|
||||
mode.rawValue, presents, sSched[sSched.count / 2], sSched.last ?? 0,
|
||||
sCommit[sCommit.count / 2], sCommit.last ?? 0)
|
||||
presents = 0
|
||||
schedMs.removeAll(keepingCapacity: true)
|
||||
commitMs.removeAll(keepingCapacity: true)
|
||||
lock.unlock()
|
||||
presenterLog.info("\(line, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private let windowedDiag = WindowedPresentDiag()
|
||||
#endif
|
||||
|
||||
private let device: MTLDevice
|
||||
@@ -316,7 +410,7 @@ public final class MetalVideoPresenter {
|
||||
private let pipelinePlanar: MTLRenderPipelineState
|
||||
/// PyroWave planar HDR passthrough (pf_frag_planar → rgba16Float; the layer's PQ colour
|
||||
/// space + EDR interpret the samples) and the planar PQ→SDR tone-map (pf_frag_planar_tm →
|
||||
/// bgra8; tvOS without headroom + macOS windowed IOSurface presents).
|
||||
/// bgra8; tvOS without HDR headroom).
|
||||
private let pipelinePlanarHDR: MTLRenderPipelineState
|
||||
private let pipelinePlanarToneMap: MTLRenderPipelineState
|
||||
private var textureCache: CVMetalTextureCache?
|
||||
@@ -591,13 +685,14 @@ public final class MetalVideoPresenter {
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Park the windowed-vs-fullscreen present routing (MAIN thread — the hosting view pushes its
|
||||
/// window state on every layout). true = PyroWave frames present via `surfaceLayer` contents
|
||||
/// (the DCP swapID-panic mitigation — see `surfaceLayer`); false = the CAMetalLayer path.
|
||||
/// Park the windowed present mechanism (MAIN thread — the hosting view pushes its window
|
||||
/// state on every layout; SessionPresenter resolves the mechanism per session). `.async` =
|
||||
/// FULLSCREEN (or the user opted out of the mitigation): the image queue. `.transaction` /
|
||||
/// `.surface` = COMPOSITED (windowed) mitigation mechanisms — see `WindowedPresentMode`.
|
||||
/// Applied by the render thread on the next frame, like every other staged value here.
|
||||
public func setSurfacePresents(_ on: Bool) {
|
||||
func setWindowedPresent(_ mode: WindowedPresentMode) {
|
||||
stagingLock.lock()
|
||||
surfacePresentsStaged = on
|
||||
windowedPresentStaged = mode
|
||||
stagingLock.unlock()
|
||||
}
|
||||
#endif
|
||||
@@ -734,36 +829,12 @@ public final class MetalVideoPresenter {
|
||||
) -> Bool {
|
||||
stagingLock.lock()
|
||||
let targetFromLayout = drawableTarget
|
||||
#if os(macOS)
|
||||
let surfaceMode = surfacePresentsStaged
|
||||
#endif
|
||||
stagingLock.unlock()
|
||||
// A PQ (HDR) pyrowave stream drives the same layer/EDR machinery as the biplanar path;
|
||||
// macOS WINDOWED sessions stay on the SDR layer (the IOSurface path tone-maps in-shader).
|
||||
#if os(macOS)
|
||||
configure(hdr: planes.pq && !surfaceMode)
|
||||
#else
|
||||
// A PQ (HDR) pyrowave stream drives the same layer/EDR machinery as the biplanar path —
|
||||
// including macOS windowed sessions, which keep real HDR (the DCP mitigation is the
|
||||
// transactional present in `encodePresent`, not a colour downgrade).
|
||||
configure(hdr: planes.pq)
|
||||
#endif
|
||||
var csc = planes.csc
|
||||
#if os(macOS)
|
||||
if surfaceMode != surfacePresentsActive {
|
||||
surfacePresentsActive = surfaceMode
|
||||
presenterLog.info(
|
||||
"stage2: windowed surface presents \(surfaceMode ? "ON" : "OFF", privacy: .public) (PyroWave DCP-panic mitigation)")
|
||||
if !surfaceMode {
|
||||
// Back to the metal path (fullscreen): drop the pool — at 5K it holds >100 MB,
|
||||
// and re-entering windowed mode rebuilds it in one frame.
|
||||
surfacePool.removeAll()
|
||||
surfacePoolSize = .zero
|
||||
lastHandedOff = nil
|
||||
}
|
||||
}
|
||||
if surfaceMode {
|
||||
return renderPlanarToSurface(
|
||||
planes, targetFromLayout: targetFromLayout, csc: &csc, onPresented: onPresented)
|
||||
}
|
||||
#endif
|
||||
// PQ passthrough needs the HDR drawable; a PQ frame while the drawable is (still)
|
||||
// 8-bit — tvOS without display headroom, or a not-yet-flipped layer — tone-maps
|
||||
// in-shader instead (the pipeline must match the drawable's pixel format).
|
||||
@@ -792,118 +863,6 @@ public final class MetalVideoPresenter {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// The windowed-mode present tail (see `surfaceLayer` for why this path exists): render the
|
||||
/// planar CSC into a pooled IOSurface and hand it to `surfaceLayer.contents` on MAIN inside a
|
||||
/// plain CATransaction — an ordinary damaged-layer update on WindowServer's own composite
|
||||
/// cadence, no CAMetalLayer image-queue swap anywhere. `presentAtMediaTime` doesn't apply
|
||||
/// (the compositor paces); `onPresented` fires after the contents swap is committed, stamped
|
||||
/// with CLOCK_REALTIME then — the closest observable analogue of "reached glass" here (the
|
||||
/// composite follows within a refresh, so the meters' display stage reads slightly optimistic).
|
||||
private func renderPlanarToSurface(
|
||||
_ planes: WaveletPlanes, targetFromLayout: CGSize, csc: inout CscUniform,
|
||||
onPresented: ((Int64?) -> Void)?
|
||||
) -> Bool {
|
||||
let decodedSize = CGSize(width: planes.width, height: planes.height)
|
||||
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
|
||||
? targetFromLayout : decodedSize
|
||||
ensureSurfacePool(size: targetSize)
|
||||
guard let slotIndex = takeSurfaceSlot(),
|
||||
let commandBuffer = queue.makeCommandBuffer()
|
||||
else { return false }
|
||||
let slot = surfacePool[slotIndex]
|
||||
|
||||
let pass = MTLRenderPassDescriptor()
|
||||
pass.colorAttachments[0].texture = slot.texture
|
||||
pass.colorAttachments[0].loadAction = .clear
|
||||
pass.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
|
||||
pass.colorAttachments[0].storeAction = .store
|
||||
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
|
||||
return false
|
||||
}
|
||||
encoder.setRenderPipelineState(planes.pq ? pipelinePlanarToneMap : pipelinePlanar)
|
||||
encoder.setFragmentTexture(planes.y, index: 0)
|
||||
encoder.setFragmentTexture(planes.cb, index: 1)
|
||||
encoder.setFragmentTexture(planes.cr, index: 2)
|
||||
encoder.setFragmentBytes(&csc, length: MemoryLayout<CscUniform>.stride, index: 0)
|
||||
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
||||
encoder.endEncoding()
|
||||
let surface = slot.surface
|
||||
let surfaceLayer = surfaceLayer // captured directly — the handler must not retain self
|
||||
let keepAlive: [Any] = [planes.y, planes.cb, planes.cr]
|
||||
commandBuffer.addCompletedHandler { _ in
|
||||
_ = keepAlive // ring textures pinned until the GPU finished sampling
|
||||
DispatchQueue.main.async {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
surfaceLayer.contents = surface
|
||||
CATransaction.commit()
|
||||
onPresented?(
|
||||
Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime()))
|
||||
}
|
||||
}
|
||||
commandBuffer.commit()
|
||||
lastHandedOff = slotIndex
|
||||
return true
|
||||
}
|
||||
|
||||
/// (Re)build the pool at `size` — 4 BGRA8 IOSurface render targets (one on glass, one queued
|
||||
/// in CA, one rendering, one spare). RENDER THREAD. A failed allocation leaves the pool empty;
|
||||
/// the caller returns false and the ring's putBack + display-link retry take over.
|
||||
private func ensureSurfacePool(size: CGSize) {
|
||||
guard size != surfacePoolSize else { return }
|
||||
surfacePool.removeAll()
|
||||
surfacePoolSize = size
|
||||
lastHandedOff = nil
|
||||
let w = Int(size.width)
|
||||
let h = Int(size.height)
|
||||
guard w > 0, h > 0 else { return }
|
||||
// 256-byte row alignment satisfies both IOSurface and Metal linear-texture rules.
|
||||
let bytesPerRow = ((w * 4) + 255) & ~255
|
||||
let props: [String: Any] = [
|
||||
kIOSurfaceWidth as String: w,
|
||||
kIOSurfaceHeight as String: h,
|
||||
kIOSurfaceBytesPerElement as String: 4,
|
||||
kIOSurfaceBytesPerRow as String: bytesPerRow,
|
||||
kIOSurfacePixelFormat as String: kCVPixelFormatType_32BGRA,
|
||||
]
|
||||
let desc = MTLTextureDescriptor.texture2DDescriptor(
|
||||
pixelFormat: .bgra8Unorm, width: w, height: h, mipmapped: false)
|
||||
desc.usage = [.renderTarget]
|
||||
desc.storageMode = .shared
|
||||
for _ in 0..<4 {
|
||||
guard let surface = IOSurfaceCreate(props as CFDictionary),
|
||||
let texture = device.makeTexture(descriptor: desc, iosurface: surface, plane: 0)
|
||||
else {
|
||||
surfacePool.removeAll()
|
||||
return
|
||||
}
|
||||
surfacePool.append(SurfaceSlot(surface: surface, texture: texture))
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the slot to render into: never the one just handed to the layer (the compositor may
|
||||
/// still scan it), prefer surfaces the window server isn't holding (`IOSurfaceIsInUse`), and
|
||||
/// among those the least recently rendered. Falls back to the LRU busy slot rather than
|
||||
/// stalling — a visible glitch at worst, never a queue-up. RENDER THREAD.
|
||||
private func takeSurfaceSlot() -> Int? {
|
||||
guard !surfacePool.isEmpty else { return nil }
|
||||
var free: Int?
|
||||
var busy: Int?
|
||||
for i in surfacePool.indices where i != lastHandedOff {
|
||||
if !IOSurfaceIsInUse(surfacePool[i].surface) {
|
||||
if free == nil || surfacePool[i].seq < surfacePool[free!].seq { free = i }
|
||||
} else {
|
||||
if busy == nil || surfacePool[i].seq < surfacePool[busy!].seq { busy = i }
|
||||
}
|
||||
}
|
||||
guard let pick = free ?? busy else { return nil }
|
||||
surfaceSeq += 1
|
||||
surfacePool[pick].seq = surfaceSeq
|
||||
return pick
|
||||
}
|
||||
#endif
|
||||
|
||||
/// The shared present tail of `render`/`renderPlanar`: size the drawable, encode one
|
||||
/// fullscreen triangle with `pipeline` (`bind` supplies the fragment resources), schedule
|
||||
/// the present and the on-glass callback.
|
||||
@@ -936,6 +895,36 @@ public final class MetalVideoPresenter {
|
||||
#if DEBUG
|
||||
logSizeIfChanged(decoded: decodedSize, drawable: targetSize)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
// Windowed (composited) → the DCP swapID-panic mitigation mechanism (see
|
||||
// `WindowedPresentMode`). Toggle the layer property BEFORE vending a drawable so the
|
||||
// vend matches how it will be presented; drained here on the render thread, flipped
|
||||
// exactly once per mode change.
|
||||
stagingLock.lock()
|
||||
let windowedMode = windowedPresentStaged
|
||||
stagingLock.unlock()
|
||||
if windowedMode != windowedPresentActive {
|
||||
windowedPresentActive = windowedMode
|
||||
layer.presentsWithTransaction = windowedMode == .transaction
|
||||
if windowedMode != .surface, !surfacePool.isEmpty {
|
||||
// Leaving surface mode (fullscreen entry / mechanism A/B): drop the pool — at 5K
|
||||
// it holds >100 MB, and re-entering rebuilds it in one frame. SessionPresenter
|
||||
// clears the surface layer's contents on main.
|
||||
surfacePool.removeAll()
|
||||
surfacePoolSize = .zero
|
||||
lastHandedOff = nil
|
||||
}
|
||||
presenterLog.info(
|
||||
"stage2: windowed present mode \(windowedMode.rawValue, privacy: .public) (DCP swapID-panic mitigation)")
|
||||
}
|
||||
if windowedMode == .surface {
|
||||
// No image queue at all: render into a pooled IOSurface and swap it into the
|
||||
// sibling layer's contents. The drawable/queue tail below never runs.
|
||||
return encodeToSurface(
|
||||
targetSize: targetSize, pipeline: pipeline, onPresented: onPresented,
|
||||
keepAlive: keepAlive, bind: bind)
|
||||
}
|
||||
#endif
|
||||
if let providedDrawable,
|
||||
providedDrawable.texture.pixelFormat != layer.pixelFormat {
|
||||
return false // config outran the vend (HDR flip) — next vend has the new format
|
||||
@@ -974,6 +963,61 @@ public final class MetalVideoPresenter {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
// Keep the bound sources alive until the GPU finishes sampling (see the callers).
|
||||
commandBuffer.addCompletedHandler { _ in _ = keepAlive }
|
||||
#if os(macOS)
|
||||
if windowedPresentActive == .transaction {
|
||||
// Windowed DCP mitigation: present the drawable THROUGH a Core Animation transaction
|
||||
// (`presentsWithTransaction`, set above) instead of the async image queue, so the swap
|
||||
// commits with the layer tree and stays in lockstep with the compositor (no out-of-band
|
||||
// flip to race WindowServer's swaps). Wait until the GPU work is scheduled (contents
|
||||
// will be ready — p50 ~0.1 ms), then present inside an EXPLICIT CATransaction ON THIS
|
||||
// RENDER THREAD and `flush()`. `presentAtMediaTime` does not apply — the transaction
|
||||
// paces.
|
||||
//
|
||||
// Threading history, because BOTH failure modes shipped or nearly shipped:
|
||||
// • A bare `present()` from this thread (no transaction) never flushes — nothing
|
||||
// commits a runloop-less thread's implicit transaction, so drawables are never
|
||||
// released; after maximumDrawableCount vends `nextDrawable()` blocks forever and
|
||||
// the stream FREEZES (the fullscreen→windowed switch did exactly this).
|
||||
// • The explicit begin/commit alone is NOT enough either: this thread has an ACTIVE
|
||||
// implicit transaction (the layer mutations above — drawableSize/colour — created
|
||||
// it), so the explicit transaction NESTS inside it and its commit defers to the
|
||||
// implicit one that never comes. The harness reproduced the exact freeze: every
|
||||
// present reported presentedTime=0, nothing reached glass. `CATransaction.flush()`
|
||||
// pushes the implicit transaction (present included) to the render server NOW.
|
||||
// • The original fix hopped to MAIN and presented there — correct, but slow in the
|
||||
// field (presents=55 @ fps=240, display_p50 18.6 ms on the 240 Hz Studio): each
|
||||
// present lands a runloop turn late, and main's own implicit transaction batches
|
||||
// enrolled presents at runloop-iteration rate. Kept as PUNKTFUNK_TXN_PRESENT=main.
|
||||
// The off-main commit measured immune to main-thread churn in the harness
|
||||
// (2026-07-21: glass p50 ~10 ms at 240 Hz full-size, cadence a clean 4.17 ms).
|
||||
commandBuffer.commit()
|
||||
let schedStart = CACurrentMediaTime()
|
||||
commandBuffer.waitUntilScheduled()
|
||||
let schedMs = (CACurrentMediaTime() - schedStart) * 1000
|
||||
let commitStart = CACurrentMediaTime()
|
||||
if txnPresentOnMain {
|
||||
let presentedDrawable = drawable
|
||||
DispatchQueue.main.async {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
presentedDrawable.present()
|
||||
CATransaction.commit()
|
||||
}
|
||||
} else {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
drawable.present()
|
||||
CATransaction.commit()
|
||||
CATransaction.flush()
|
||||
}
|
||||
windowedDiag.record(
|
||||
schedMs: schedMs, commitMs: (CACurrentMediaTime() - commitStart) * 1000,
|
||||
mode: .transaction)
|
||||
return true
|
||||
}
|
||||
#endif
|
||||
// Scheduled on the vsync when the pipeline gave us the link's target (see the doc comment);
|
||||
// immediate otherwise. A target already in the past presents immediately — same thing.
|
||||
if let presentAtMediaTime {
|
||||
@@ -981,12 +1025,146 @@ public final class MetalVideoPresenter {
|
||||
} else {
|
||||
commandBuffer.present(drawable)
|
||||
}
|
||||
// Keep the bound sources alive until the GPU finishes sampling (see the callers).
|
||||
commandBuffer.addCompletedHandler { _ in _ = keepAlive }
|
||||
commandBuffer.commit()
|
||||
return true
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// The WINDOWED `surface` present tail (see `WindowedPresentMode.surface`): render with the
|
||||
/// same per-frame pipeline into a pooled IOSurface and hand it to `surfaceLayer.contents`
|
||||
/// from the command buffer's COMPLETION handler, inside an explicit CATransaction + flush
|
||||
/// (the same off-main commit discipline as the transactional present — an ordinary
|
||||
/// damaged-layer update on WindowServer's own composite cadence, no image queue anywhere).
|
||||
/// RENDER THREAD. `onPresented` is stamped right after the contents swap commits — the
|
||||
/// closest observable analogue of "reached glass" here (the composite follows within a
|
||||
/// refresh, so the display-stage meters read slightly OPTIMISTIC in this mode).
|
||||
///
|
||||
/// The pool tracks `hdrActive`: bgra8 for SDR, rgba16Float tagged BT.2100 PQ for HDR —
|
||||
/// `configure` already ran, so the caller's `pipeline` attachment format always matches.
|
||||
/// HDR OPEN RISK (why this whole mode is a prototype): whether the compositor honors the
|
||||
/// PQ tag + EDR for plain-CALayer IOSurface contents needs an on-glass eyeball; the metal
|
||||
/// layer underneath keeps `wantsExtendedDynamicRangeContent` as the EDR anchor (the harness
|
||||
/// measured the display's EDR headroom engaging with this arrangement).
|
||||
private func encodeToSurface(
|
||||
targetSize: CGSize, pipeline: MTLRenderPipelineState,
|
||||
onPresented: ((Int64?) -> Void)?,
|
||||
keepAlive: [Any], bind: (MTLRenderCommandEncoder) -> Void
|
||||
) -> Bool {
|
||||
ensureSurfacePool(size: targetSize, hdr: hdrActive)
|
||||
guard let slotIndex = takeSurfaceSlot(),
|
||||
let commandBuffer = queue.makeCommandBuffer()
|
||||
else { return false }
|
||||
let slot = surfacePool[slotIndex]
|
||||
|
||||
let pass = MTLRenderPassDescriptor()
|
||||
pass.colorAttachments[0].texture = slot.texture
|
||||
pass.colorAttachments[0].loadAction = .clear
|
||||
pass.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
|
||||
pass.colorAttachments[0].storeAction = .store
|
||||
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
|
||||
return false
|
||||
}
|
||||
encoder.setRenderPipelineState(pipeline)
|
||||
bind(encoder)
|
||||
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
||||
encoder.endEncoding()
|
||||
let surface = slot.surface
|
||||
let surfaceLayer = surfaceLayer // captured directly — the handler must not retain self
|
||||
let diag = windowedDiag
|
||||
let commitStamp = CACurrentMediaTime()
|
||||
commandBuffer.addCompletedHandler { _ in
|
||||
_ = keepAlive // sources pinned until the GPU finished sampling
|
||||
let completedAt = CACurrentMediaTime()
|
||||
// Swap on THIS Metal completion thread: explicit transaction + flush, so the commit
|
||||
// reaches the render server now, independent of main (completion handlers for one
|
||||
// queue fire in execution order, so swaps can't reorder).
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
surfaceLayer.contents = surface
|
||||
CATransaction.commit()
|
||||
CATransaction.flush()
|
||||
diag.record(
|
||||
schedMs: (completedAt - commitStamp) * 1000,
|
||||
commitMs: (CACurrentMediaTime() - completedAt) * 1000, mode: .surface)
|
||||
onPresented?(Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime()))
|
||||
}
|
||||
commandBuffer.commit()
|
||||
lastHandedOff = slotIndex
|
||||
return true
|
||||
}
|
||||
|
||||
/// (Re)build the pool at `size`/`hdr` — 4 IOSurface render targets (one on glass, one
|
||||
/// committed in CA, one rendering, one spare). RENDER THREAD. A failed allocation leaves the
|
||||
/// pool empty; the caller returns false and the ring's putBack + display-link retry take
|
||||
/// over.
|
||||
private func ensureSurfacePool(size: CGSize, hdr: Bool) {
|
||||
guard size != surfacePoolSize || hdr != surfacePoolHDR else { return }
|
||||
surfacePool.removeAll()
|
||||
surfacePoolSize = size
|
||||
surfacePoolHDR = hdr
|
||||
lastHandedOff = nil
|
||||
let w = Int(size.width)
|
||||
let h = Int(size.height)
|
||||
guard w > 0, h > 0 else { return }
|
||||
// rgba16Float (8 B/px) carries the PQ-encoded HDR samples; bgra8 the SDR ones. 256-byte
|
||||
// row alignment satisfies both IOSurface and Metal linear-texture rules.
|
||||
let bytesPerElement = hdr ? 8 : 4
|
||||
let bytesPerRow = ((w * bytesPerElement) + 255) & ~255
|
||||
let props: [String: Any] = [
|
||||
kIOSurfaceWidth as String: w,
|
||||
kIOSurfaceHeight as String: h,
|
||||
kIOSurfaceBytesPerElement as String: bytesPerElement,
|
||||
kIOSurfaceBytesPerRow as String: bytesPerRow,
|
||||
kIOSurfacePixelFormat as String: hdr
|
||||
? kCVPixelFormatType_64RGBAHalf : kCVPixelFormatType_32BGRA,
|
||||
]
|
||||
let desc = MTLTextureDescriptor.texture2DDescriptor(
|
||||
pixelFormat: hdr ? .rgba16Float : .bgra8Unorm, width: w, height: h, mipmapped: false)
|
||||
desc.usage = [.renderTarget]
|
||||
desc.storageMode = .shared
|
||||
for _ in 0..<4 {
|
||||
guard let surface = IOSurfaceCreate(props as CFDictionary),
|
||||
let texture = device.makeTexture(descriptor: desc, iosurface: surface, plane: 0)
|
||||
else {
|
||||
surfacePool.removeAll()
|
||||
return
|
||||
}
|
||||
if hdr, let name = CGColorSpace(name: CGColorSpace.itur_2100_PQ)?.name {
|
||||
// Tag the surface BT.2100 PQ so the compositor interprets the half-float
|
||||
// samples as PQ-encoded HDR (the CALayer-contents analogue of the metal
|
||||
// layer's colorspace).
|
||||
IOSurfaceSetValue(surface, "IOSurfaceColorSpace" as CFString, name)
|
||||
}
|
||||
surfacePool.append(SurfaceSlot(surface: surface, texture: texture))
|
||||
}
|
||||
// The EDR request rides the SURFACE layer too (its contents are what composite); the
|
||||
// metal layer underneath keeps its own from configureColor as the anchor. Layer flags
|
||||
// are committed by the next swap's transaction flush.
|
||||
surfaceLayer.wantsExtendedDynamicRangeContent = hdr
|
||||
}
|
||||
|
||||
/// Pick the slot to render into: never the one just handed to the layer (the compositor may
|
||||
/// still scan it), prefer surfaces the window server isn't holding (`IOSurfaceIsInUse`), and
|
||||
/// among those the least recently rendered. Falls back to the LRU busy slot rather than
|
||||
/// stalling — a visible glitch at worst, never a queue-up. RENDER THREAD.
|
||||
private func takeSurfaceSlot() -> Int? {
|
||||
guard !surfacePool.isEmpty else { return nil }
|
||||
var free: Int?
|
||||
var busy: Int?
|
||||
for i in surfacePool.indices where i != lastHandedOff {
|
||||
if !IOSurfaceIsInUse(surfacePool[i].surface) {
|
||||
if free == nil || surfacePool[i].seq < surfacePool[free!].seq { free = i }
|
||||
} else {
|
||||
if busy == nil || surfacePool[i].seq < surfacePool[busy!].seq { busy = i }
|
||||
}
|
||||
}
|
||||
guard let pick = free ?? busy else { return nil }
|
||||
surfaceSeq += 1
|
||||
surfacePool[pick].seq = surfaceSeq
|
||||
return pick
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Returns the CVMetalTexture (not just its MTLTexture) so the caller can keep it alive past the
|
||||
/// draw — the MTLTexture is only valid while its CVMetalTexture is retained.
|
||||
private func makeTexture(
|
||||
|
||||
@@ -142,20 +142,17 @@ enum PresentPriority: Equatable {
|
||||
|
||||
final class SessionPresenter {
|
||||
/// Present pacing for this session. Stage-3 always means glass gating; under the stage-2
|
||||
/// default, macOS PyroWave sessions ALSO get glass gating — a kernel-panic mitigation, not a
|
||||
/// latency tweak. macOS's DCP panics ("mismatched swapID's" @UnifiedPipeline.cpp, the whole
|
||||
/// machine dies) when WindowServer's swap submissions race, and the reliable trigger is
|
||||
/// out-of-band CAMetalLayer presents (displaySyncEnabled=false — mandatory for us, see
|
||||
/// MetalVideoPresenter's init) arriving faster than the compositor latches them in a
|
||||
/// COMPOSITED (windowed) session. Arrival pacing does exactly that with PyroWave: the wavelet
|
||||
/// decode is near-instant Metal compute, so a network clump of frames presents within the
|
||||
/// same millisecond, and PyroWave is the codec that sustains stream rates above the panel's
|
||||
/// refresh. The glass gate admits one presented-but-undisplayed swap at a time (serialized on
|
||||
/// the on-glass callback, 100 ms stale backstop), which removes the racing pattern outright;
|
||||
/// frames the panel couldn't have shown anyway coalesce in the newest-wins ring. An explicit
|
||||
/// stage-2 pick (setting/env) still forces arrival pacing — that A/B lever must stay honest.
|
||||
/// VideoToolbox codecs keep arrival pacing: decode latency spaces their presents, and years
|
||||
/// of stage-2 defaults there predate any panic report.
|
||||
/// default, macOS PyroWave sessions ALSO get glass gating — for SMOOTHNESS, not as the panic
|
||||
/// fix (that is the windowed transactional present — see `setComposited`). PyroWave's wavelet
|
||||
/// decode is near-instant Metal compute, so a network clump presents within the same
|
||||
/// millisecond, and it is the codec that sustains stream rates above the panel's refresh; the
|
||||
/// glass gate admits one presented-but-undisplayed swap at a time (serialized on the on-glass
|
||||
/// callback, 100 ms stale backstop) so those bursts coalesce in the newest-wins ring instead
|
||||
/// of flooding the queue. (Glass pacing was ALSO the original DCP-panic mitigation attempt —
|
||||
/// disproven: a fully serialized stream still panicked, which is why the real fix moved to the
|
||||
/// present mechanism.) An explicit stage-2 pick (setting/env) still forces arrival pacing —
|
||||
/// that A/B lever must stay honest. VideoToolbox codecs keep arrival pacing: decode latency
|
||||
/// spaces their presents.
|
||||
static func pacing(
|
||||
for choice: PresenterChoice, explicit: PresenterChoice?, codec: VideoCodec
|
||||
) -> PresentPacing {
|
||||
@@ -178,10 +175,25 @@ final class SessionPresenter {
|
||||
/// that doesn't exist after the first Wi-Fi clump. Sub-refresh display latency needs pacing
|
||||
/// that can't queue at all — that's stage-4 (`PresentPacing.deadline`), not a deeper gate.
|
||||
///
|
||||
#if os(macOS)
|
||||
/// Resolve the windowed (composited) present MECHANISM for this session — the DCP
|
||||
/// swapID-panic mitigation picker (see `WindowedPresentMode`). The
|
||||
/// `PUNKTFUNK_WINDOWED_PRESENT=async|transaction|surface` env lever wins (dev A/B);
|
||||
/// otherwise the user's safe-present setting: ON/unset → `.transaction` (the validated
|
||||
/// mitigation), OFF → `.async` (the fast pre-mitigation path — the panic returns on
|
||||
/// affected high-refresh setups; the Settings caption says so). `.surface` is currently
|
||||
/// env-only (prototype — HDR-composite verification owed). Fullscreen always presents
|
||||
/// async regardless (`setComposited`). Internal (not private) for unit tests.
|
||||
static func windowedPresentMode(setting: Bool?, env: String?) -> WindowedPresentMode {
|
||||
if let env, let mode = WindowedPresentMode(rawValue: env) { return mode }
|
||||
return (setting ?? true) ? .transaction : .async
|
||||
}
|
||||
#endif
|
||||
|
||||
/// `PUNKTFUNK_GATE_DEPTH` (1…3) still overrides on iOS/tvOS so the standing-queue ladder
|
||||
/// stays reproducible on-device; macOS is pinned to 1, env ignored — glass pacing exists
|
||||
/// there as the DCP swapID kernel-panic mitigation (see `pacing`), and STRICT present
|
||||
/// serialization is its point. Internal (not private) for unit tests.
|
||||
/// stays reproducible on-device; macOS is pinned to 1, env ignored — a deeper gate only builds
|
||||
/// a standing queue (see above), and macOS glass pacing exists for PyroWave smoothness
|
||||
/// (see `pacing`), where depth 1 is the point. Internal (not private) for unit tests.
|
||||
static func gateDepth(env: String?) -> Int {
|
||||
#if os(macOS)
|
||||
return 1
|
||||
@@ -196,10 +208,16 @@ final class SessionPresenter {
|
||||
private var stage2Link: CADisplayLink?
|
||||
private var metalLayer: CAMetalLayer?
|
||||
#if os(macOS)
|
||||
/// The windowed-mode PyroWave present target (sibling above `metalLayer`) and the last
|
||||
/// routing pushed to the pipeline — see `setComposited`. Main-thread only, like all of this.
|
||||
/// The windowed present MECHANISM this session runs while composited (resolved once per
|
||||
/// session in `start` — the user's safe-present setting + the PUNKTFUNK_WINDOWED_PRESENT
|
||||
/// dev override) and the routing last pushed to the pipeline — see `setComposited` (the DCP
|
||||
/// swapID-panic mitigation). Main-thread only, like all of this.
|
||||
private var windowedMode: WindowedPresentMode = .transaction
|
||||
private var windowedPresentApplied: WindowedPresentMode = .async
|
||||
/// The windowed `surface` present target (sibling above `metalLayer`, transparent while
|
||||
/// unused) — installed whenever stage-2 runs so a mechanism flip never has to mutate the
|
||||
/// layer tree mid-session.
|
||||
private var surfaceLayer: CALayer?
|
||||
private var surfacePresentsActive = false
|
||||
#endif
|
||||
private var connection: PunktfunkConnection?
|
||||
/// The decoded frame's REAL pixel dimensions (ground truth, pushed by the view from the pump's
|
||||
@@ -283,11 +301,17 @@ final class SessionPresenter {
|
||||
baseLayer.addSublayer(metal)
|
||||
metalLayer = metal
|
||||
#if os(macOS)
|
||||
// The windowed-PyroWave present target sits ABOVE the metal layer: transparent (nil
|
||||
// contents) while the metal path presents, covering it while surface presents run.
|
||||
windowedPresentApplied = .async
|
||||
// Resolve THIS session's windowed mechanism once (setting + dev env lever) —
|
||||
// `setComposited` routes between it and fullscreen-async from every layout.
|
||||
windowedMode = Self.windowedPresentMode(
|
||||
setting: UserDefaults.standard.object(
|
||||
forKey: DefaultsKey.windowedSafePresent) as? Bool,
|
||||
env: ProcessInfo.processInfo.environment["PUNKTFUNK_WINDOWED_PRESENT"])
|
||||
// The surface present target sits ABOVE the metal layer: transparent (nil contents)
|
||||
// unless the surface mechanism actually presents, covering it while it does.
|
||||
baseLayer.addSublayer(pipeline.surfaceLayer)
|
||||
surfaceLayer = pipeline.surfaceLayer
|
||||
surfacePresentsActive = false
|
||||
#endif
|
||||
stage2 = pipeline
|
||||
// The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger
|
||||
@@ -432,19 +456,23 @@ final class SessionPresenter {
|
||||
|
||||
#if os(macOS)
|
||||
/// Route presents for the window's composited state (MAIN thread — the view pushes it on
|
||||
/// every layout, which fullscreen transitions always trigger). PyroWave sessions in a
|
||||
/// COMPOSITED (windowed) session present via `surfaceLayer` contents instead of the
|
||||
/// CAMetalLayer image queue — the DCP "mismatched swapID's" kernel-panic mitigation (see
|
||||
/// `MetalVideoPresenter.surfaceLayer`; the metal-swap race survives glass pacing, so pacing
|
||||
/// alone was not enough). VT codecs keep the metal path: no panic reports there, and their
|
||||
/// HDR/EDR presentation has no surface-contents equivalent wired.
|
||||
/// every layout, which fullscreen transitions always trigger). A COMPOSITED (windowed)
|
||||
/// session presents through this session's resolved mitigation mechanism (`windowedMode` —
|
||||
/// transactional by default, see `windowedPresentMode`) instead of the async image queue —
|
||||
/// the DCP "mismatched swapID's" kernel-panic mitigation (see `MetalVideoPresenter`; the
|
||||
/// async-swap race survives glass pacing, so pacing alone was not enough). ALL codecs:
|
||||
/// PyroWave hit it 2026-07-18 and windowed HEVC hit the same 240 Hz Mac Studio 2026-07-21 —
|
||||
/// it is the async image queue itself, not any codec or present rate. Fullscreen keeps the
|
||||
/// async path (direct scanout, lowest latency, no panic there). The full HDR/EDR render
|
||||
/// path is preserved in every mechanism.
|
||||
func setComposited(_ composited: Bool) {
|
||||
guard let stage2, let connection else { return }
|
||||
let wantsSurface = composited && connection.videoCodec == .pyrowave
|
||||
guard wantsSurface != surfacePresentsActive else { return }
|
||||
surfacePresentsActive = wantsSurface
|
||||
stage2.setSurfacePresents(wantsSurface)
|
||||
if !wantsSurface {
|
||||
guard let stage2 else { return }
|
||||
let mode: WindowedPresentMode = composited ? windowedMode : .async
|
||||
guard mode != windowedPresentApplied else { return }
|
||||
let wasSurface = windowedPresentApplied == .surface
|
||||
windowedPresentApplied = mode
|
||||
stage2.setWindowedPresent(mode)
|
||||
if wasSurface {
|
||||
// Uncover the metal layer NOW (its last drawable is still attached, so fullscreen
|
||||
// entry shows the previous frame until the next present — no black flash).
|
||||
CATransaction.begin()
|
||||
@@ -471,7 +499,7 @@ final class SessionPresenter {
|
||||
#if os(macOS)
|
||||
surfaceLayer?.removeFromSuperlayer()
|
||||
surfaceLayer = nil
|
||||
surfacePresentsActive = false
|
||||
windowedPresentApplied = .async
|
||||
#endif
|
||||
connection = nil
|
||||
}
|
||||
|
||||
@@ -1114,15 +1114,15 @@ public final class Stage2Pipeline {
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// The windowed-mode PyroWave present target (see `MetalVideoPresenter.surfaceLayer` — the
|
||||
/// DCP swapID-panic mitigation). The hosting view installs it as a sibling above `layer`.
|
||||
public var surfaceLayer: CALayer { presenter.surfaceLayer }
|
||||
|
||||
/// Forward the windowed-vs-fullscreen present routing (MAIN thread — see
|
||||
/// `MetalVideoPresenter.setSurfacePresents`).
|
||||
public func setSurfacePresents(_ on: Bool) {
|
||||
presenter.setSurfacePresents(on)
|
||||
/// Forward the windowed present mechanism (MAIN thread — see
|
||||
/// `MetalVideoPresenter.setWindowedPresent`, the DCP swapID-panic mitigation).
|
||||
func setWindowedPresent(_ mode: WindowedPresentMode) {
|
||||
presenter.setWindowedPresent(mode)
|
||||
}
|
||||
|
||||
/// The windowed `surface` present target the hosting SessionPresenter installs as a sibling
|
||||
/// ABOVE `layer` (transparent while unused — see `MetalVideoPresenter.surfaceLayer`).
|
||||
var surfaceLayer: CALayer { presenter.surfaceLayer }
|
||||
#endif
|
||||
|
||||
/// Forward the display's current EDR headroom to the presenter (MAIN thread — a `UIScreen`
|
||||
|
||||
@@ -38,10 +38,11 @@ private let streamInputDebug =
|
||||
/// dragged deltas become the relative motion StreamLayerView forwards), and hide it.
|
||||
/// hide/unhide and associate are balanced via `captured`.
|
||||
///
|
||||
/// In CLIENT-SIDE-CURSOR mode (gamescope, whose capture carries no host cursor) this is a
|
||||
/// no-op: the local cursor stays visible and free, and StreamLayerView forwards ABSOLUTE
|
||||
/// positions instead — the visible system cursor IS the on-screen cursor. `disassociate`
|
||||
/// selects between the two; `release()` only undoes what `capture` actually did.
|
||||
/// In the DESKTOP mouse model (absolute pointer, remote-desktop-sweep M1) this is a no-op:
|
||||
/// the pointer stays free (entering and leaving the stream at will) and StreamLayerView
|
||||
/// forwards ABSOLUTE positions instead; the local cursor is hidden only while over the view
|
||||
/// (cursor rects). `disassociate` selects between the two; `release()` only undoes what
|
||||
/// `capture` actually did.
|
||||
private final class CursorCapture {
|
||||
private var captured = false
|
||||
/// Whether the engaged capture actually disassociated+hid (false in cursor-visible mode),
|
||||
@@ -207,14 +208,17 @@ public final class StreamLayerView: NSView {
|
||||
/// forwarded). Main-thread only.
|
||||
public private(set) var captured = false
|
||||
|
||||
/// Client-side-cursor mode: when true the local system cursor stays VISIBLE over the
|
||||
/// stream and the mouse monitor forwards ABSOLUTE positions (the visible cursor is the
|
||||
/// on-screen cursor — gamescope draws none, so no double cursor); when false the existing
|
||||
/// captured/disassociated relative path runs unchanged. Initialized at session start from
|
||||
/// the `cursorMode` setting + the host's resolved compositor, toggled live by ⌘⇧C. A live
|
||||
/// flip re-engages capture in the new mode so disassociation + the abs/rel choice swap
|
||||
/// atomically. Main-thread only.
|
||||
private var cursorVisible = false
|
||||
/// Desktop (absolute) mouse model — remote-desktop-sweep M1: when true the pointer is
|
||||
/// never disassociated (it enters and leaves the stream freely) and the mouse monitor
|
||||
/// forwards ABSOLUTE positions through the letterbox; the local cursor is hidden only
|
||||
/// while over this view (cursor rects — the host's composited cursor, tracking our
|
||||
/// sends, is the one you see) and reappears the moment it leaves. When false the
|
||||
/// captured/disassociated relative path runs unchanged. Initialized at session start
|
||||
/// from the `mouseMode` setting gated by the host's resolved compositor (gamescope's
|
||||
/// EIS is relative-only — absolute sends would be dropped, so it pins to capture);
|
||||
/// flipped live by ⌃⌥⇧M. A live flip re-engages capture in the new model so
|
||||
/// disassociation + the abs/rel choice swap atomically. Main-thread only.
|
||||
private var desktopMouse = false
|
||||
/// One-shot auto-engage request (stream start, trust confirmed) — attempted as soon
|
||||
/// as the view is in a window with real bounds, then dropped, so it can never fire
|
||||
/// surprisingly later (e.g. on a resize).
|
||||
@@ -440,9 +444,9 @@ public final class StreamLayerView: NSView {
|
||||
// If the cursor grab is refused (e.g. the reactivating click arrives before the app is
|
||||
// frontmost), stay released so the NEXT click retries — never latch captured=true over
|
||||
// a free cursor, which would make mouseDown's `!captured` guard reject every later click.
|
||||
// In client-side-cursor mode there is no grab (the cursor stays visible) — capture
|
||||
// In the desktop mouse model there is no grab (the pointer stays free) — capture
|
||||
// always engages and the monitor forwards absolute positions instead.
|
||||
guard cursorCapture.capture(in: self, disassociate: !cursorVisible) else { return }
|
||||
guard cursorCapture.capture(in: self, disassociate: !desktopMouse) else { return }
|
||||
inputCapture?.setForwarding(true, suppressClick: fromClick)
|
||||
// Install AFTER the warp + setForwarding: the engage warp generates no forwarded
|
||||
// delta (the monitor isn't up yet), and the engage click's suppression latch is
|
||||
@@ -450,6 +454,7 @@ public final class StreamLayerView: NSView {
|
||||
installMouseMonitor()
|
||||
captured = true
|
||||
window?.makeFirstResponder(self)
|
||||
window?.invalidateCursorRects(for: self) // desktop model: hide-over-view engages
|
||||
notifyCaptureChange(true)
|
||||
}
|
||||
|
||||
@@ -459,9 +464,28 @@ public final class StreamLayerView: NSView {
|
||||
cursorCapture.release()
|
||||
inputCapture?.setForwarding(false)
|
||||
captured = false
|
||||
window?.invalidateCursorRects(for: self)
|
||||
notifyCaptureChange(false)
|
||||
}
|
||||
|
||||
/// A fully transparent cursor for the desktop mouse model's hide-over-view rect —
|
||||
/// an empty 1×1 image draws nothing.
|
||||
private static let invisibleCursor = NSCursor(
|
||||
image: NSImage(size: NSSize(width: 1, height: 1)), hotSpot: .zero)
|
||||
|
||||
/// Desktop mouse model: the local cursor is hidden while over the stream (the host's
|
||||
/// composited cursor, tracking our absolute sends, is the one you see) and reappears
|
||||
/// the moment it leaves the view — AppKit applies/removes the rect's cursor for us,
|
||||
/// so there is no hide/unhide balancing to get wrong. Capture model instead hides
|
||||
/// globally via `CursorCapture` (the pointer can't leave the view there).
|
||||
override public func resetCursorRects() {
|
||||
if captured && desktopMouse {
|
||||
addCursorRect(bounds, cursor: Self.invisibleCursor)
|
||||
} else {
|
||||
super.resetCursorRects()
|
||||
}
|
||||
}
|
||||
|
||||
/// A single local monitor for motion + buttons, installed only while captured. A local
|
||||
/// monitor is more robust than view overrides for relative motion: it sidesteps the
|
||||
/// `window.acceptsMouseMovedEvents`/tracking-area/responder-chain requirements, and
|
||||
@@ -473,12 +497,12 @@ public final class StreamLayerView: NSView {
|
||||
/// via IOHID. Events are returned (not swallowed): the cursor is frozen, so they're
|
||||
/// inert locally.
|
||||
///
|
||||
/// In client-side-cursor mode the cursor is NOT frozen, so bare `.mouseMoved` events are
|
||||
/// In the desktop mouse model the cursor is NOT frozen, so bare `.mouseMoved` events are
|
||||
/// only generated while `window.acceptsMouseMovedEvents` is true — we enable it here and
|
||||
/// restore it on removal so absolute hover-motion keeps flowing without a click held.
|
||||
private func installMouseMonitor() {
|
||||
guard mouseEventMonitor == nil else { return }
|
||||
if cursorVisible {
|
||||
if desktopMouse {
|
||||
savedAcceptsMouseMoved = window?.acceptsMouseMovedEvents
|
||||
window?.acceptsMouseMovedEvents = true
|
||||
}
|
||||
@@ -490,8 +514,8 @@ public final class StreamLayerView: NSView {
|
||||
guard let self, self.captured, let ic = self.inputCapture else { return event }
|
||||
switch event.type {
|
||||
case .mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
|
||||
if self.cursorVisible {
|
||||
// Client-side cursor: forward the ABSOLUTE position (mapped through the
|
||||
if self.desktopMouse {
|
||||
// Desktop mouse model: forward the ABSOLUTE position (mapped through the
|
||||
// aspect-fit letterbox into host pixels), the same path the iPad pointer
|
||||
// fallback uses. Events in the letterbox bars are dropped (nil host point).
|
||||
if let p = self.hostPoint(from: event) {
|
||||
@@ -609,14 +633,27 @@ public final class StreamLayerView: NSView {
|
||||
// be a cursor trap with dead input.
|
||||
self?.releaseCapture()
|
||||
}
|
||||
// ⌘⇧C flips the client-side cursor live. Only the key window's stream owns it (same
|
||||
// guard as the ⌘⎋ capture toggle). Re-engage capture in the new mode so disassociation
|
||||
// and the absolute/relative forwarding choice swap atomically — releaseCapture restores
|
||||
// the old mode's grab (if any), engageCapture installs the new one.
|
||||
// ⌘⇧C would flip the client-side cursor live — NEUTERED while the feature is disabled
|
||||
// (see the cursorVisible resolution below): toggling it on under gamescope's relative-only
|
||||
// input traps the pointer. Restore this body when absolute/synthetic-cursor support lands.
|
||||
capture.onToggleCursor = {}
|
||||
// ⌃⌥⇧M flips the mouse model (capture ⇄ desktop) live — the SDL clients' identical
|
||||
// chord. Only the key window's stream owns it (same guard as the ⌘⎋ capture toggle).
|
||||
// Re-engage capture in the new model so disassociation and the absolute/relative
|
||||
// forwarding choice swap atomically — releaseCapture restores the old model's grab
|
||||
// (if any), engageCapture installs the new one. On a gamescope host the chord is a
|
||||
// no-op: its EIS grants only a relative pointer, so the desktop model's absolute
|
||||
// sends would be silently dropped (pointer stuck = "all input dead").
|
||||
capture.onToggleMouseMode = { [weak self] in
|
||||
guard let self, self.window?.isKeyWindow == true,
|
||||
let conn = self.connection else { return }
|
||||
guard conn.resolvedCompositor != .gamescope else {
|
||||
streamInputLog.info("mouse-mode chord ignored: gamescope host is relative-only")
|
||||
return
|
||||
}
|
||||
let wasCaptured = self.captured
|
||||
if wasCaptured { self.releaseCapture() }
|
||||
self.desktopMouse.toggle()
|
||||
if wasCaptured { self.engageCapture(fromClick: false) }
|
||||
self.window?.invalidateCursorRects(for: self)
|
||||
streamInputLog.info("mouse mode: \(self.desktopMouse ? "desktop" : "capture", privacy: .public)")
|
||||
}
|
||||
// The cross-client combos (⌃⌥⇧Q/D/S — Ctrl+Alt+Shift on the other clients), delivered by
|
||||
// the monitor only while captured; the same key-window ownership rule as ⌘⎋ throughout.
|
||||
capture.onReleaseCapture = { [weak self] in
|
||||
@@ -643,15 +680,18 @@ public final class StreamLayerView: NSView {
|
||||
capture.start()
|
||||
inputCapture = capture
|
||||
|
||||
// Client-side cursor is TEMPORARILY DISABLED. It positions the host cursor with ABSOLUTE
|
||||
// events, but gamescope's input socket (EIS) grants only a relative pointer, so those are
|
||||
// silently dropped — the pointer never moves and clicks/scroll land on the stuck position
|
||||
// (looks like "all input dead"). gamescope is exactly the compositor Auto enabled it for.
|
||||
// Forced off until per-compositor gating (KWin/GNOME/Sway have absolute) or a synthetic-
|
||||
// cursor-over-relative path lands; the resolution logic below is kept for that. See the
|
||||
// ⌘⇧C handler (also neutered) and the cursorMode setting (hidden).
|
||||
cursorVisible = false
|
||||
_ = connection.resolvedCompositor // (was: Auto → gamescope; kept to document intent)
|
||||
// Desktop (absolute) mouse model — resolved at session start from the mouseMode
|
||||
// setting, gated by the host's compositor: gamescope's input socket (EIS) grants
|
||||
// only a relative pointer, so absolute sends would be silently dropped there
|
||||
// (pointer stuck = "all input dead") — pinned to capture. ⌃⌥⇧M flips it live.
|
||||
let mode = MouseInputMode(
|
||||
rawValue: UserDefaults.standard.string(forKey: DefaultsKey.mouseMode) ?? ""
|
||||
) ?? .capture
|
||||
let absOK = connection.resolvedCompositor != .gamescope
|
||||
desktopMouse = mode == .desktop && absOK
|
||||
if mode == .desktop && !absOK {
|
||||
streamInputLog.info("desktop mouse mode unavailable on a gamescope host (relative-only) — using capture")
|
||||
}
|
||||
|
||||
// Presenter choice + lifecycle live in SessionPresenter (shared with iOS/tvOS): stage-2
|
||||
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
|
||||
@@ -700,9 +740,9 @@ public final class StreamLayerView: NSView {
|
||||
private func layoutPresenter() {
|
||||
presenter.layout(in: bounds, contentsScale: window?.backingScaleFactor ?? 1)
|
||||
// Present routing tracks the window's composited state (fullscreen transitions always
|
||||
// re-layout, so this stays current): windowed PyroWave presents via surface contents —
|
||||
// the DCP swapID kernel-panic mitigation (see SessionPresenter.setComposited). A view
|
||||
// not yet in a window counts as composited (the safe default).
|
||||
// re-layout, so this stays current): a windowed session presents through a Core Animation
|
||||
// transaction — the DCP swapID kernel-panic mitigation (see SessionPresenter.setComposited).
|
||||
// A view not yet in a window counts as composited (the safe default).
|
||||
presenter.setComposited(!(window?.styleMask.contains(.fullScreen) ?? false))
|
||||
// Feed the follower only once in a window (backing scale is real then) and with real
|
||||
// bounds — a pre-window layout would report point-sized dimensions.
|
||||
|
||||
@@ -70,6 +70,16 @@ public enum DefaultsKey {
|
||||
/// (lowest latency — the default, OFF). Resolved once per session;
|
||||
/// PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B. See Stage2Pipeline's header.
|
||||
public static let vsync = "punktfunk.vsync"
|
||||
/// macOS: present WINDOWED sessions in lockstep with the system compositor (the DCP
|
||||
/// "mismatched swapID's" kernel-panic mitigation — see SessionPresenter.windowedPresentMode
|
||||
/// and the MetalVideoPresenter saga notes). ON/unset (the default): windowed presents ride
|
||||
/// a Core Animation transaction — validated panic-free on the 240 Hz repro machine, at a
|
||||
/// small display-latency cost vs the raw path. OFF: windowed sessions keep the fast async
|
||||
/// image queue — ON AFFECTED SETUPS (high-refresh displays) THAT PATH KERNEL-PANICS THE
|
||||
/// WHOLE MAC, which is why the default is ON. Fullscreen always presents async (fast path)
|
||||
/// regardless. Resolved once per session; PUNKTFUNK_WINDOWED_PRESENT=async|transaction|
|
||||
/// surface overrides it for dev A/B.
|
||||
public static let windowedSafePresent = "punktfunk.windowedSafePresent"
|
||||
/// Allow variable refresh rate: hand the display link a wide frame-rate RANGE (low floor,
|
||||
/// preferred = stream rate) so a ProMotion / adaptive-sync display can vary its physical
|
||||
/// refresh to match the stream. On by default; a no-op on fixed-refresh displays. When off,
|
||||
@@ -84,8 +94,11 @@ public enum DefaultsKey {
|
||||
/// stays 4:2:0). Sharper text/UI at the cost of more bandwidth.
|
||||
public static let enable444 = "punktfunk.enable444"
|
||||
public static let hosts = "punktfunk.hosts"
|
||||
/// Client-side cursor mode: "auto" (shown only in gamescope sessions), "always", "never".
|
||||
public static let cursorMode = "punktfunk.cursorMode"
|
||||
/// Physical-mouse model (macOS): "capture" (pointer lock + relative, the default) or
|
||||
/// "desktop" (uncaptured absolute pointer) — the cross-client `mouse_mode`. Replaces the
|
||||
/// never-shipped "punktfunk.cursorMode" (auto/always/never client-side-cursor setting,
|
||||
/// which was hidden while disabled and had no readers).
|
||||
public static let mouseMode = "punktfunk.mouseMode"
|
||||
/// Invert the scroll-wheel / two-finger-scroll direction sent to the host (both axes). Off by
|
||||
/// default: the local (natural-scrolling) sign passes through untouched. When on, the sign is
|
||||
/// negated at the single scroll sink (`InputCapture.sendScroll`), so it flips consistently across
|
||||
|
||||
@@ -316,6 +316,41 @@ final class PresentPacingTests: XCTestCase {
|
||||
SessionPresenter.pacing(for: .stage4, explicit: .stage4, codec: .pyrowave), .deadline)
|
||||
}
|
||||
|
||||
// MARK: - Windowed present mechanism (the macOS DCP swapID-panic mitigation picker)
|
||||
|
||||
#if os(macOS)
|
||||
/// The safe-present setting: ON/unset → the validated transactional mitigation; an explicit
|
||||
/// OFF → the fast async path (the user accepted the affected-setup panic risk). The
|
||||
/// PUNKTFUNK_WINDOWED_PRESENT env lever overrides both ways, `surface` is env-only (the
|
||||
/// prototype mechanism), and garbage/empty env values are "unset", not an override.
|
||||
func testWindowedPresentModeResolution() {
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.windowedPresentMode(setting: nil, env: nil), .transaction,
|
||||
"unset defaults to the panic mitigation")
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.windowedPresentMode(setting: true, env: nil), .transaction)
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.windowedPresentMode(setting: false, env: nil), .async,
|
||||
"an explicit opt-out gets the fast async path")
|
||||
// The dev env lever wins over the setting, both directions.
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.windowedPresentMode(setting: true, env: "async"), .async)
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.windowedPresentMode(setting: false, env: "transaction"),
|
||||
.transaction)
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.windowedPresentMode(setting: true, env: "surface"), .surface,
|
||||
"the surface prototype is reachable via env only")
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.windowedPresentMode(setting: false, env: "surface"), .surface)
|
||||
// Garbage/empty env = unset.
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.windowedPresentMode(setting: nil, env: "garbage"), .transaction)
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.windowedPresentMode(setting: false, env: ""), .async)
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Glass-gate depth
|
||||
|
||||
/// The in-flight present budget is 1 EVERYWHERE: any deeper gate keeps a standing queue —
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
# 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)
|
||||
#
|
||||
@@ -61,10 +63,17 @@ if [ -z "${PF_HOST:-}" ]; then
|
||||
echo "punktfunkrun: PF_HOST 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" "$@"
|
||||
fi
|
||||
if [ -n "${PF_LAUNCH:-}" ]; then
|
||||
# A pinned game: the id rides the session Hello and the host launches that title.
|
||||
echo "punktfunkrun: streaming $APPID --connect $PF_HOST --launch $PF_LAUNCH" >&2
|
||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --launch "$PF_LAUNCH" --fullscreen
|
||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --launch "$PF_LAUNCH" "$@"
|
||||
fi
|
||||
echo "punktfunkrun: streaming $APPID --connect $PF_HOST" >&2
|
||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --fullscreen
|
||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" "$@"
|
||||
|
||||
@@ -70,7 +70,9 @@ function setShortcutHidden(appId: number, hidden: boolean): void {
|
||||
};
|
||||
|
||||
// Bump when the shipped artwork changes so existing shortcuts re-apply it once (per appId).
|
||||
const ART_VERSION = 2;
|
||||
// v3: CI zips through 0.17.1 shipped no assets/ at all, yet v2 was still recorded as applied
|
||||
// on those installs — the bump makes them re-apply once on the first build that has the files.
|
||||
const ART_VERSION = 3;
|
||||
function artKey(appId: number): string {
|
||||
return `punktfunk:shortcutArt:${appId}`;
|
||||
}
|
||||
@@ -79,7 +81,7 @@ function artKey(appId: number): string {
|
||||
* Apply the plugin's grid/hero/logo/icon to a shortcut (idempotent, once per ART_VERSION per
|
||||
* appId). Cosmetic and fully best-effort: any failure is swallowed and retried on the next call.
|
||||
*/
|
||||
async function applyArtwork(appId: number): Promise<void> {
|
||||
async function applyArtwork(appId: number, isRetry = false): Promise<void> {
|
||||
try {
|
||||
if (localStorage.getItem(artKey(appId)) === `${ART_VERSION}`) {
|
||||
return;
|
||||
@@ -91,16 +93,29 @@ async function applyArtwork(appId: number): Promise<void> {
|
||||
[art.logo, 2],
|
||||
[art.gridwide, 3],
|
||||
];
|
||||
let applied = false;
|
||||
for (const [data, assetType] of assets) {
|
||||
if (data) {
|
||||
await SteamClient.Apps.SetCustomArtworkForApp(appId, data, "png", assetType);
|
||||
applied = true;
|
||||
}
|
||||
}
|
||||
if (art.icon_path) {
|
||||
SteamClient.Apps.SetShortcutIcon(appId, art.icon_path);
|
||||
applied = true;
|
||||
}
|
||||
// Only record "done" when something actually landed — a plugin build whose assets/ is
|
||||
// missing/empty must keep retrying on later mounts instead of poisoning the marker.
|
||||
if (applied) {
|
||||
localStorage.setItem(artKey(appId), `${ART_VERSION}`);
|
||||
}
|
||||
} catch (e) {
|
||||
// A shortcut fresh out of AddShortcut may not be registered yet (the same race
|
||||
// setShortcutHidden defers around) — one deferred second attempt, then leave it to
|
||||
// the next mount.
|
||||
if (!isRetry) {
|
||||
setTimeout(() => void applyArtwork(appId, true), 2500);
|
||||
}
|
||||
console.warn("punktfunk: shortcut artwork not applied", e);
|
||||
}
|
||||
}
|
||||
@@ -157,7 +172,9 @@ async function ensureControllerConfig(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
const r = await applyControllerConfig(SHORTCUT_NAME);
|
||||
if (r?.ok) {
|
||||
// `ok` alone isn't done: with zero account configset dirs (fresh Steam) the backend
|
||||
// succeeds without pointing any account at the template — keep retrying until one lands.
|
||||
if (r?.ok && (r.applied ?? []).some((a) => a.startsWith("configset:"))) {
|
||||
localStorage.setItem(CONFIG_KEY, `${CONFIG_VERSION}`);
|
||||
} else {
|
||||
console.warn("punktfunk: controller config not fully applied", r);
|
||||
@@ -283,13 +300,21 @@ export async function launchStream(
|
||||
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), and awaited just before RunGame.
|
||||
// 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 } = await ensureStreamShortcut();
|
||||
const [{ appId, runner }, woke] = await Promise.all([ensureStreamShortcut(), waking]);
|
||||
const target = port && port !== 9777 ? `${host}:${port}` : host;
|
||||
const env = [`PF_HOST=${target}`];
|
||||
// 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.browse) {
|
||||
env.push("PF_BROWSE=1");
|
||||
if (opts.mgmt) {
|
||||
@@ -303,9 +328,9 @@ export async function launchStream(
|
||||
env.push(`PF_LAUNCH=${opts.launchId}`);
|
||||
}
|
||||
// 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.
|
||||
// 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.
|
||||
SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`);
|
||||
await waking; // ensure the magic packet is out before the connect attempt
|
||||
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,14 @@ const TOUCH_MODE_CAPTIONS: &[&str] = &[
|
||||
"The cursor jumps to your finger — a tap clicks there",
|
||||
"Real multi-touch reaches the host — for touch-native apps",
|
||||
];
|
||||
/// Physical-mouse model values (persisted) + labels + dynamic captions — same idiom as
|
||||
/// the touch rows. Ctrl+Alt+Shift+M flips the model live in-stream.
|
||||
const MOUSE_MODES: &[&str] = &["capture", "desktop"];
|
||||
const MOUSE_MODE_LABELS: &[&str] = &["Capture (games)", "Desktop (absolute)"];
|
||||
const MOUSE_MODE_CAPTIONS: &[&str] = &[
|
||||
"Pointer locks to the stream — relative motion, best for games",
|
||||
"Pointer moves freely in and out — best for remote desktop work",
|
||||
];
|
||||
|
||||
/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
|
||||
const APP_LICENSE: &str = concat!(
|
||||
@@ -542,6 +550,20 @@ pub fn show(
|
||||
set_row_subtitle(&w, TOUCH_MODE_CAPTIONS[i]);
|
||||
});
|
||||
}
|
||||
let mouse_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Mouse input",
|
||||
MOUSE_MODE_CAPTIONS[0],
|
||||
MOUSE_MODE_LABELS,
|
||||
);
|
||||
{
|
||||
let w = mouse_row.widget().clone();
|
||||
mouse_row.connect_changed(move |i| {
|
||||
let i = (i as usize).min(MOUSE_MODE_CAPTIONS.len() - 1);
|
||||
set_row_subtitle(&w, MOUSE_MODE_CAPTIONS[i]);
|
||||
});
|
||||
}
|
||||
let inhibit_row = adw::SwitchRow::builder()
|
||||
.title("Capture system shortcuts")
|
||||
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
|
||||
@@ -718,6 +740,12 @@ pub fn show(
|
||||
touch_row.set_selected(touch_i as u32);
|
||||
// set_selected never fires the changed hook, so seed the dynamic caption directly.
|
||||
set_row_subtitle(touch_row.widget(), TOUCH_MODE_CAPTIONS[touch_i]);
|
||||
let mouse_i = MOUSE_MODES
|
||||
.iter()
|
||||
.position(|&m| m == s.mouse_mode)
|
||||
.unwrap_or(0);
|
||||
mouse_row.set_selected(mouse_i as u32);
|
||||
set_row_subtitle(mouse_row.widget(), MOUSE_MODE_CAPTIONS[mouse_i]);
|
||||
let comp_i = COMPOSITORS
|
||||
.iter()
|
||||
.position(|&c| c == s.compositor)
|
||||
@@ -788,6 +816,7 @@ pub fn show(
|
||||
touch_group.add(touch_row.widget());
|
||||
// Group titles are Pango markup — the ampersand must be an entity.
|
||||
let kbm_group = group("Keyboard & mouse", "");
|
||||
kbm_group.add(mouse_row.widget());
|
||||
kbm_group.add(&inhibit_row);
|
||||
kbm_group.add(&invert_row);
|
||||
input.add(&touch_group);
|
||||
@@ -867,6 +896,8 @@ pub fn show(
|
||||
}
|
||||
s.touch_mode =
|
||||
TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string();
|
||||
s.mouse_mode =
|
||||
MOUSE_MODES[(mouse_row.selected() as usize).min(MOUSE_MODES.len() - 1)].to_string();
|
||||
s.forward_pad = chosen_pin.borrow().clone();
|
||||
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
|
||||
.to_string();
|
||||
|
||||
@@ -487,14 +487,24 @@ async fn session(args: Args) -> Result<()> {
|
||||
// host/network split is exactly what it exists to report. Old hosts ignore the bit.
|
||||
// PROBE_SEQ: the shared-core reassembler windows probe-space frames, so the probe
|
||||
// qualifies for `--speed-test` bursts; without the bit the host declines them.
|
||||
// STREAMED_AU: the same shared reassembler accepts sentinel-headed streamed
|
||||
// blocks, and the probe is exactly the tool that measures the overlap win.
|
||||
let mut caps = punktfunk_core::quic::VIDEO_CAP_HOST_TIMING
|
||||
| punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ;
|
||||
| punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ
|
||||
| punktfunk_core::quic::VIDEO_CAP_STREAMED_AU;
|
||||
if std::env::var_os("PUNKTFUNK_CLIENT_10BIT").is_some() {
|
||||
caps |= punktfunk_core::quic::VIDEO_CAP_10BIT;
|
||||
}
|
||||
if std::env::var_os("PUNKTFUNK_CLIENT_444").is_some() {
|
||||
caps |= punktfunk_core::quic::VIDEO_CAP_444;
|
||||
}
|
||||
// PUNKTFUNK_CLIENT_CHACHA20=1 advertises VIDEO_CAP_CHACHA20 — drives the
|
||||
// host's ChaCha20-Poly1305 session-cipher resolution (the soft-AES armv7
|
||||
// negotiation, design/chacha20-session-cipher.md §7) without a webOS build;
|
||||
// the negotiated cipher is reported in the welcome log line below.
|
||||
if std::env::var_os("PUNKTFUNK_CLIENT_CHACHA20").is_some() {
|
||||
caps |= punktfunk_core::quic::VIDEO_CAP_CHACHA20;
|
||||
}
|
||||
caps
|
||||
},
|
||||
// `--audio-channels` (default stereo); the probe multistream-decodes + validates the
|
||||
@@ -532,6 +542,11 @@ async fn session(args: Args) -> Result<()> {
|
||||
chroma_444 = welcome.chroma_format == punktfunk_core::quic::CHROMA_IDC_444,
|
||||
chroma_format_idc = welcome.chroma_format,
|
||||
codec = codec_ext(welcome.codec),
|
||||
cipher = if welcome.cipher == punktfunk_core::quic::CIPHER_CHACHA20_POLY1305 {
|
||||
"chacha20-poly1305"
|
||||
} else {
|
||||
"aes-128-gcm"
|
||||
},
|
||||
"session offer"
|
||||
);
|
||||
|
||||
|
||||
@@ -158,6 +158,7 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
v => v,
|
||||
},
|
||||
touch_mode: settings_at_start.touch_mode(),
|
||||
mouse_mode: settings_at_start.mouse_mode(),
|
||||
invert_scroll: settings_at_start.invert_scroll,
|
||||
json_status,
|
||||
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
|
||||
|
||||
@@ -429,6 +429,7 @@ mod session_main {
|
||||
v => v,
|
||||
},
|
||||
touch_mode: settings.touch_mode(),
|
||||
mouse_mode: settings.mouse_mode(),
|
||||
invert_scroll: settings.invert_scroll,
|
||||
json_status: true,
|
||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||
|
||||
@@ -90,6 +90,13 @@ const TOUCH_MODES: &[(&str, &str)] = &[
|
||||
("pointer", "Direct pointer"),
|
||||
("touch", "Touch passthrough"),
|
||||
];
|
||||
/// Physical-mouse presets: `(stored value, display label)` — capture (pointer lock,
|
||||
/// relative, for games) vs desktop (uncaptured absolute pointer, for remote desktop
|
||||
/// work). Ctrl+Alt+Shift+M flips the model live in-stream.
|
||||
const MOUSE_MODES: &[(&str, &str)] = &[
|
||||
("capture", "Capture (games)"),
|
||||
("desktop", "Desktop (absolute)"),
|
||||
];
|
||||
/// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to
|
||||
/// auto-detect when the choice is unavailable. Only meaningful against a Linux host.
|
||||
const COMPOSITORS: &[(&str, &str)] = &[
|
||||
@@ -394,6 +401,10 @@ pub(crate) fn settings_page(
|
||||
let touch_combo = setting_combo(ctx, "Touch input", touch_names, touch_i, |s, i| {
|
||||
s.touch_mode = TOUCH_MODES[i].0.to_string();
|
||||
});
|
||||
let (mouse_names, mouse_i) = presets(MOUSE_MODES, |v| *v == s.mouse_mode);
|
||||
let mouse_combo = setting_combo(ctx, "Mouse input", mouse_names, mouse_i, |s, i| {
|
||||
s.mouse_mode = MOUSE_MODES[i].0.to_string();
|
||||
});
|
||||
let invert_scroll_toggle =
|
||||
setting_toggle(ctx, "Invert scroll direction", s.invert_scroll, |s, on| {
|
||||
s.invert_scroll = on
|
||||
@@ -542,6 +553,13 @@ pub(crate) fn settings_page(
|
||||
out.extend(group(
|
||||
Some("Keyboard & mouse"),
|
||||
vec with a caller-chosen first-frame budget instead of the
|
||||
/// backend's default. The pipeline retry loop shortens its FIRST attempt's wait: a PipeWire
|
||||
/// stream connected while gamescope re-inits its headless takeover can negotiate a format,
|
||||
/// reach `Streaming`, and still never receive a buffer — a fresh connect then delivers within
|
||||
/// ~0.5 s, so waiting out the full default budget on a doomed stream just delays the retry
|
||||
/// that fixes it. Backends without an internal wait budget ignore it (the default delegates).
|
||||
fn next_frame_within(&mut self, _budget: std::time::Duration) -> Result<CapturedFrame> {
|
||||
self.next_frame()
|
||||
}
|
||||
|
||||
/// Non-blocking: the freshest frame available since the last call, or `None` if none has
|
||||
/// arrived (the caller reuses its last frame to hold a steady output rate). The default
|
||||
/// just produces a frame each call — fine for instant synthetic sources; the portal
|
||||
@@ -249,6 +259,12 @@ pub struct ZeroCopyPolicy {
|
||||
/// passthrough (like the VAAPI backend) instead of the EGL→CUDA import whose payloads only
|
||||
/// NVENC can consume. Per-session (the codec is negotiated), unlike `backend_is_vaapi`.
|
||||
pub pyrowave_session: bool,
|
||||
/// THIS session's encoder can ingest a producer-native NV12 capture (the Linux raw Vulkan
|
||||
/// Video backend on an H265/AV1 session — resolved by the host facade via
|
||||
/// `pf_encode::linux_native_nv12_ok`). Gates whether the negotiation PREFERS gamescope's
|
||||
/// producer-side NV12 pod: libav VAAPI (H264's backend) would misread the two-plane buffer,
|
||||
/// so H264/GameStream/PyroWave sessions must never see NV12 frames.
|
||||
pub native_nv12_session: bool,
|
||||
/// The PyroWave encoder's Vulkan-importable dmabuf modifiers for the capture's packed-RGB fourcc,
|
||||
/// resolved when the session encodes PyroWave (the passthrough advertises them so Mutter+NVIDIA,
|
||||
/// which allocates tiled-only, still negotiates zero-copy). Empty otherwise.
|
||||
|
||||
@@ -299,29 +299,11 @@ fn spawn_pipewire(
|
||||
|
||||
impl Capturer for PortalCapturer {
|
||||
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
||||
// First frame can lag behind format negotiation; later frames arrive at ~fps. Wait in
|
||||
// short slices so a GPU-import poison (worker death) fails the capture within ~0.5 s
|
||||
// instead of sitting out the full first-frame budget.
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
if self.broken.load(Ordering::Relaxed) {
|
||||
return Err(anyhow!(
|
||||
"zero-copy GPU import lost (node {}): the import worker died or tiled imports \
|
||||
failed repeatedly — rebuilding capture",
|
||||
self.node_id
|
||||
));
|
||||
}
|
||||
if let Some(f) = self.pending.take() {
|
||||
return Ok(f); // a wait_arrival stash outranks the channel (it's older)
|
||||
}
|
||||
let slice = Duration::from_millis(500)
|
||||
.min(deadline.saturating_duration_since(std::time::Instant::now()));
|
||||
match self.frames.recv_timeout(slice) {
|
||||
Ok(frame) => return Ok(frame),
|
||||
Err(RecvTimeoutError::Timeout) if std::time::Instant::now() < deadline => continue,
|
||||
Err(e) => return self.next_frame_timed_out(e),
|
||||
}
|
||||
self.frame_within(Duration::from_secs(10))
|
||||
}
|
||||
|
||||
fn next_frame_within(&mut self, budget: Duration) -> Result<CapturedFrame> {
|
||||
self.frame_within(budget)
|
||||
}
|
||||
|
||||
fn supports_arrival_wait(&self) -> bool {
|
||||
@@ -417,9 +399,41 @@ impl Capturer for PortalCapturer {
|
||||
}
|
||||
|
||||
impl PortalCapturer {
|
||||
/// The [`Capturer::next_frame`] budget expired (or the thread ended) — turn it into the
|
||||
/// diagnosis-bearing error. Split out of the slicing loop above; behavior unchanged.
|
||||
fn next_frame_timed_out(&self, err: RecvTimeoutError) -> Result<CapturedFrame> {
|
||||
/// The blocking first-frame wait behind [`Capturer::next_frame`] /
|
||||
/// [`Capturer::next_frame_within`]. First frame can lag behind format negotiation; later
|
||||
/// frames arrive at ~fps. Wait in short slices so a GPU-import poison (worker death) fails
|
||||
/// the capture within ~0.5 s instead of sitting out the full first-frame budget.
|
||||
fn frame_within(&mut self, budget: Duration) -> Result<CapturedFrame> {
|
||||
let deadline = std::time::Instant::now() + budget;
|
||||
loop {
|
||||
if self.broken.load(Ordering::Relaxed) {
|
||||
return Err(anyhow!(
|
||||
"zero-copy GPU import lost (node {}): the import worker died or tiled imports \
|
||||
failed repeatedly — rebuilding capture",
|
||||
self.node_id
|
||||
));
|
||||
}
|
||||
if let Some(f) = self.pending.take() {
|
||||
return Ok(f); // a wait_arrival stash outranks the channel (it's older)
|
||||
}
|
||||
let slice = Duration::from_millis(500)
|
||||
.min(deadline.saturating_duration_since(std::time::Instant::now()));
|
||||
match self.frames.recv_timeout(slice) {
|
||||
Ok(frame) => return Ok(frame),
|
||||
Err(RecvTimeoutError::Timeout) if std::time::Instant::now() < deadline => continue,
|
||||
Err(e) => return self.next_frame_timed_out(e, budget),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`frame_within`](Self::frame_within) budget expired (or the thread ended) — turn it
|
||||
/// into the diagnosis-bearing error. Split out of the slicing loop above; behavior unchanged.
|
||||
fn next_frame_timed_out(
|
||||
&self,
|
||||
err: RecvTimeoutError,
|
||||
budget: Duration,
|
||||
) -> Result<CapturedFrame> {
|
||||
let within = budget.as_secs_f32();
|
||||
match err {
|
||||
RecvTimeoutError::Timeout => {
|
||||
// Split the two black-screen root causes apart so the operator gets a cause, not
|
||||
@@ -427,9 +441,10 @@ impl PortalCapturer {
|
||||
// not (no acceptable format / node never emitted a param)?
|
||||
if self.negotiated.load(Ordering::Relaxed) {
|
||||
Err(anyhow!(
|
||||
"no PipeWire frame within 10s (node {}): format negotiated but no buffers \
|
||||
arrived — the compositor produced no frames (virtual output idle/unmapped, \
|
||||
or capture never started)",
|
||||
"no PipeWire frame within {within}s (node {}): format negotiated but no \
|
||||
buffers arrived — the compositor produced no frames (virtual output \
|
||||
idle/unmapped, capture never started, or a stream bound during a \
|
||||
compositor (re)start that will never deliver — a reconnect fixes that)",
|
||||
self.node_id
|
||||
))
|
||||
} else if self.hdr_offer {
|
||||
@@ -440,10 +455,10 @@ impl PortalCapturer {
|
||||
// auto-reconnects) negotiates SDR instead of re-running this same timeout.
|
||||
super::note_hdr_capture_failed();
|
||||
Err(anyhow!(
|
||||
"no PipeWire frame within 10s (node {}): the compositor never accepted \
|
||||
the HDR (10-bit PQ/BT.2020 dmabuf) offer — is the mirrored monitor in \
|
||||
HDR mode on GNOME 50+? Downgrading this host to SDR capture; reconnect \
|
||||
to stream SDR",
|
||||
"no PipeWire frame within {within}s (node {}): the compositor never \
|
||||
accepted the HDR (10-bit PQ/BT.2020 dmabuf) offer — is the mirrored \
|
||||
monitor in HDR mode on GNOME 50+? Downgrading this host to SDR capture; \
|
||||
reconnect to stream SDR",
|
||||
self.node_id
|
||||
))
|
||||
} else if self.vaapi_dmabuf && !pf_zerocopy::vaapi_dmabuf_forced() {
|
||||
@@ -452,14 +467,15 @@ impl PortalCapturer {
|
||||
// retries on the CPU offer instead of failing this same negotiation forever.
|
||||
pf_zerocopy::note_vaapi_dmabuf_failed();
|
||||
Err(anyhow!(
|
||||
"no PipeWire frame within 10s (node {}): the compositor never accepted \
|
||||
the LINEAR-dmabuf offer (VAAPI zero-copy) — downgrading this host to the \
|
||||
CPU capture path; the pipeline rebuild will renegotiate without dmabuf",
|
||||
"no PipeWire frame within {within}s (node {}): the compositor never \
|
||||
accepted the LINEAR-dmabuf offer (VAAPI zero-copy) — downgrading this \
|
||||
host to the CPU capture path; the pipeline rebuild will renegotiate \
|
||||
without dmabuf",
|
||||
self.node_id
|
||||
))
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"no PipeWire frame within 10s (node {}): format negotiation never \
|
||||
"no PipeWire frame within {within}s (node {}): format negotiation never \
|
||||
completed — the compositor offered no format this consumer accepts \
|
||||
(pixel-format/modifier mismatch) or the node never emitted a Format param",
|
||||
self.node_id
|
||||
@@ -824,6 +840,7 @@ mod pipewire {
|
||||
VideoFormat::RGBA => PixelFormat::Rgba,
|
||||
VideoFormat::RGB => PixelFormat::Rgb,
|
||||
VideoFormat::BGR => PixelFormat::Bgr,
|
||||
VideoFormat::NV12 => PixelFormat::Nv12,
|
||||
// The GNOME 50+ HDR screencast formats (packed 2:10:10:10; only ever negotiated by
|
||||
// the `want_hdr` offer, whose MANDATORY colorimetry props pin them to PQ/BT.2020).
|
||||
VideoFormat::xRGB_210LE => PixelFormat::X2Rgb10,
|
||||
@@ -989,10 +1006,10 @@ mod pipewire {
|
||||
.into_inner())
|
||||
}
|
||||
|
||||
/// Build a BGRx dmabuf `EnumFormat` pod advertising the EGL-importable `modifiers` as a
|
||||
/// mandatory enum Choice; the compositor fixates to one of them that it can allocate, which
|
||||
/// we read back in `param_changed`.
|
||||
/// Build a LINEAR/modifier DMA-BUF `EnumFormat` pod. Packed BGRx is the existing import path;
|
||||
/// NV12 is gamescope's producer-side RGB→YUV path (opt-in during bring-up).
|
||||
fn build_dmabuf_format(
|
||||
format: VideoFormat,
|
||||
modifiers: &[u64],
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
) -> Result<Vec<u8>> {
|
||||
@@ -1003,7 +1020,7 @@ mod pipewire {
|
||||
pw::spa::param::ParamType::EnumFormat,
|
||||
pw::spa::pod::property!(FormatProperties::MediaType, Id, MediaType::Video),
|
||||
pw::spa::pod::property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw),
|
||||
pw::spa::pod::property!(FormatProperties::VideoFormat, Id, VideoFormat::BGRx),
|
||||
pw::spa::pod::property!(FormatProperties::VideoFormat, Id, format),
|
||||
pw::spa::pod::property!(
|
||||
FormatProperties::VideoSize,
|
||||
Choice,
|
||||
@@ -1032,6 +1049,22 @@ mod pipewire {
|
||||
pw::spa::utils::Fraction { num: 240, denom: 1 }
|
||||
),
|
||||
);
|
||||
if format == VideoFormat::NV12 {
|
||||
obj.properties.push(pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorMatrix,
|
||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
|
||||
pw::spa::sys::SPA_VIDEO_COLOR_MATRIX_BT709,
|
||||
)),
|
||||
});
|
||||
obj.properties.push(pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorRange,
|
||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
|
||||
pw::spa::sys::SPA_VIDEO_COLOR_RANGE_16_235,
|
||||
)),
|
||||
});
|
||||
}
|
||||
obj.properties.push(pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_modifier,
|
||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||
@@ -1604,8 +1637,8 @@ mod pipewire {
|
||||
}
|
||||
}
|
||||
|
||||
// VAAPI zero-copy passthrough: hand the raw dmabuf straight to the encoder, which imports
|
||||
// it into a VA surface and does RGB→NV12 on the GPU video engine. No CUDA importer here.
|
||||
// Raw DMA-BUF passthrough: packed RGB is imported for GPU CSC; producer-native NV12 can
|
||||
// be consumed by the Vulkan Video encoder without another color conversion.
|
||||
if ud.vaapi_passthrough {
|
||||
if let Some(fmt) = ud.format {
|
||||
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf {
|
||||
@@ -1613,9 +1646,41 @@ mod pipewire {
|
||||
let chunk = datas[0].chunk();
|
||||
let offset = chunk.offset();
|
||||
let stride = chunk.stride().max(0) as u32;
|
||||
// Native NV12 usually arrives as a two-plane SPA buffer over ONE buffer
|
||||
// object; plane 1's chunk carries the REAL UV offset/stride (compositors
|
||||
// may align the Y plane before UV). Pass it through instead of assuming
|
||||
// contiguity. Each spa_data holds its own (dup'd) fd, so BO identity is
|
||||
// by inode, not fd number; a genuinely two-BO frame cannot travel through
|
||||
// the single-fd import — drop it with a diagnosis instead of streaming
|
||||
// garbage chroma.
|
||||
let plane1 =
|
||||
if fmt == PixelFormat::Nv12 && datas.len() >= 2 && datas[1].fd() > 0 {
|
||||
// SAFETY: zeroed `libc::stat` is a valid POD initializer; both fds are
|
||||
// owned by the live PipeWire buffer for this callback, and `fstat`
|
||||
// only writes the out-param structs, whose fields are read only after
|
||||
// the `== 0` success checks.
|
||||
let same_bo = unsafe {
|
||||
let mut s0: libc::stat = std::mem::zeroed();
|
||||
let mut s1: libc::stat = std::mem::zeroed();
|
||||
libc::fstat(datas[0].fd() as i32, &mut s0) == 0
|
||||
&& libc::fstat(datas[1].fd() as i32, &mut s1) == 0
|
||||
&& (s0.st_dev, s0.st_ino) == (s1.st_dev, s1.st_ino)
|
||||
};
|
||||
if !same_bo {
|
||||
warn_once(
|
||||
"NV12 planes live in different buffer objects — frames \
|
||||
dropped (single-fd import only)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let c1 = datas[1].chunk();
|
||||
Some((c1.offset(), c1.stride().max(0) as u32))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// dup the fd so it survives the SPA buffer recycle — the encode thread
|
||||
// imports it. (Content stability across the brief map+CSC window relies on
|
||||
// the compositor's buffer-pool depth, like any zero-copy capture.)
|
||||
// imports it. Content stability across the brief import/encode window relies
|
||||
// on the compositor's buffer-pool depth, like any zero-copy capture.
|
||||
// SAFETY: `datas[0].fd()` is the dmabuf fd owned by the live PipeWire buffer (valid
|
||||
// for this callback). `fcntl(fd, F_DUPFD_CLOEXEC, 0)` reads only the integer fd,
|
||||
// touches no Rust memory, and returns a fresh independent CLOEXEC duplicate (or -1).
|
||||
@@ -1642,9 +1707,10 @@ mod pipewire {
|
||||
modifier: ud.modifier,
|
||||
offset,
|
||||
stride,
|
||||
plane1,
|
||||
}),
|
||||
// Cursor-as-metadata: the encoder blends this into its owned VA
|
||||
// surface (raw dmabuf never touched).
|
||||
// Cursor-as-metadata is blended only by RGB→NV12 backends. Gamescope
|
||||
// embeds its pointer in the produced pixels, so native NV12 has none.
|
||||
cursor: ud.cursor.overlay(),
|
||||
});
|
||||
static ONCE: std::sync::atomic::AtomicBool =
|
||||
@@ -1655,7 +1721,12 @@ mod pipewire {
|
||||
h,
|
||||
modifier = ud.modifier,
|
||||
fourcc = format_args!("{:#010x}", fourcc),
|
||||
"zero-copy: handing the raw dmabuf to the encoder (GPU import + CSC)"
|
||||
source = if fmt == PixelFormat::Nv12 {
|
||||
"producer-native NV12"
|
||||
} else {
|
||||
"packed RGB (encoder GPU CSC)"
|
||||
},
|
||||
"zero-copy: handing the raw DMA-BUF to the encoder"
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -2015,6 +2086,28 @@ mod pipewire {
|
||||
// PyroWave session (the wavelet encoder's own Vulkan device, any vendor) → hand the raw
|
||||
// dmabuf straight to the encoder.
|
||||
let vaapi_passthrough = zerocopy && !force_shm && importer.is_none() && raw_passthrough;
|
||||
// Producer-side NV12 (default-on; PUNKTFUNK_PIPEWIRE_NV12=0 escapes): gamescope offers a
|
||||
// one-fd LINEAR NV12 image when the consumer asks — its compositor pass does the RGB→YUV,
|
||||
// and the Vulkan Video encoder imports the buffer as its encode source directly (no host
|
||||
// CSC at all). `native_nv12_session` restricts this to sessions whose encoder can ingest
|
||||
// it (Linux vulkan-encode H265/AV1 — never H264/libav-VAAPI, GameStream-resolve, or
|
||||
// PyroWave, whose Vulkan compute CSC ingests packed RGB only). Raw passthrough is
|
||||
// required because the CUDA importer expects packed RGB, and 4:4:4/HDR must not be
|
||||
// silently subsampled/downconverted. Non-NV12 compositors (KWin/GNOME) simply match the
|
||||
// packed-RGB fallback pod.
|
||||
let prefer_native_nv12 = std::env::var("PUNKTFUNK_PIPEWIRE_NV12").as_deref() != Ok("0")
|
||||
&& policy.native_nv12_session
|
||||
&& backend_is_vaapi
|
||||
&& vaapi_passthrough
|
||||
&& !policy.pyrowave_session
|
||||
&& !want_444
|
||||
&& !want_hdr;
|
||||
if prefer_native_nv12 {
|
||||
tracing::info!(
|
||||
"zero-copy: preferring gamescope producer-side NV12 LINEAR DMA-BUF (no host \
|
||||
RGB CSC; PUNKTFUNK_PIPEWIRE_NV12=0 restores the packed-RGB negotiation)"
|
||||
);
|
||||
}
|
||||
// Modifiers our import stack handles for BGRx: the EGL-importable (tiled) set, plus LINEAR
|
||||
// (0) — NVIDIA's EGL won't list it, but LINEAR dmabufs (gamescope's only offer) import via
|
||||
// CUDA external memory instead. For the VAAPI passthrough path we advertise LINEAR only:
|
||||
@@ -2054,7 +2147,9 @@ mod pipewire {
|
||||
tracing::warn!("zero-copy: no importable dmabuf modifiers — using CPU path");
|
||||
} else if vaapi_passthrough && policy.pyrowave_modifiers.is_empty() {
|
||||
tracing::info!(
|
||||
"zero-copy: advertising LINEAR dmabuf for direct VAAPI import (GPU CSC)"
|
||||
native_nv12_preferred = prefer_native_nv12,
|
||||
"zero-copy: advertising LINEAR DMA-BUF for encoder import (native NV12 first \
|
||||
when enabled, packed RGB fallback)"
|
||||
);
|
||||
} else if want_dmabuf && !vaapi_passthrough {
|
||||
tracing::info!(
|
||||
@@ -2394,7 +2489,18 @@ mod pipewire {
|
||||
build_hdr_dmabuf_format(VideoFormat::xBGR_210LE, preferred)?,
|
||||
]
|
||||
} else if want_dmabuf {
|
||||
vec![build_dmabuf_format(&modifiers, preferred)?]
|
||||
let mut pods = Vec::with_capacity(if prefer_native_nv12 { 2 } else { 1 });
|
||||
if prefer_native_nv12 {
|
||||
// First compatible consumer pod wins. Gamescope advertises NV12 and BGRx; pinning
|
||||
// BT.709 limited here selects its RGB→NV12 shader with our bitstream colorimetry.
|
||||
pods.push(build_dmabuf_format(VideoFormat::NV12, &[0], preferred)?);
|
||||
}
|
||||
pods.push(build_dmabuf_format(
|
||||
VideoFormat::BGRx,
|
||||
&modifiers,
|
||||
preferred,
|
||||
)?);
|
||||
pods
|
||||
} else {
|
||||
vec![serialize_pod(obj)?]
|
||||
};
|
||||
|
||||
@@ -308,8 +308,9 @@ float2 main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_TARGET {
|
||||
/// Plane writes use per-plane render-target views of the single P010 texture: an `R16_UNORM` RTV
|
||||
/// selects plane 0 (luma, full WxH), an `R16G16_UNORM` RTV selects plane 1 (chroma, W/2 x H/2). This
|
||||
/// planar-RTV mechanism needs a D3D11.3+ runtime + driver support; [`HdrP010Converter::convert`]
|
||||
/// surfaces a clear error if `CreateRenderTargetView` rejects the plane format so the caller can fall
|
||||
/// back to the existing R10 path.
|
||||
/// surfaces a clear error if `CreateRenderTargetView` rejects the plane format. (There is no runtime
|
||||
/// fallback — the error propagates through `try_consume` and ends the session; the "R10 path" the
|
||||
/// original design referenced was never kept.)
|
||||
pub(crate) struct HdrP010Converter {
|
||||
vs: ID3D11VertexShader,
|
||||
ps_y: ID3D11PixelShader,
|
||||
@@ -737,14 +738,157 @@ fn p010_reference(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
|
||||
/// Y ≤ 4 codes, U/V ≤ 5 codes (rounding + chroma averaging). Prints a per-colour table + PASS/FAIL.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn hdr_p010_selftest() -> Result<()> {
|
||||
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_HARDWARE;
|
||||
use windows::Win32::Graphics::Dxgi::IDXGIAdapter;
|
||||
hdr_p010_selftest_at(64, 64, None)
|
||||
}
|
||||
|
||||
// 64x64, even dims. A 4x4 grid of 16x16 flat scRGB blocks (each 2x2 chroma footprint uniform →
|
||||
// exact chroma comparison) covering pure R/G/B/white/black/gray at plausible HDR nit levels, plus
|
||||
// a couple of bright (>1.0 scRGB) colours, then the rest is a gradient (compared on Y only).
|
||||
const W: u32 = 64;
|
||||
const H: u32 = 64;
|
||||
/// [`hdr_p010_selftest`] at an arbitrary even size and (optionally) on a specific GPU vendor
|
||||
/// (PCI vendor id, e.g. `0x8086` Intel / `0x10de` NVIDIA / `0x1002` AMD). The size matters on
|
||||
/// top of the 64×64 default because the field sessions run at capture resolutions whose height
|
||||
/// is NOT 16-aligned (1080 → the encoder's align16 pool seam) and a driver may treat the planar
|
||||
/// RTVs differently at real sizes; the vendor pin matters on dual-GPU boxes where the default
|
||||
/// adapter is not the one the session encodes on.
|
||||
/// Test support (used by pf-encode's live e2e): the 8 sRGB colour bars (white/yellow/cyan/green/
|
||||
/// magenta/red/blue/black, sRGB 1.0 = scRGB 1.0 = 80 nits) as a w×h FP16 scRGB texture on the
|
||||
/// adapter with `luid`, converted through the REAL [`HdrP010Converter`] into a P010 texture with
|
||||
/// **`BIND_RENDER_TARGET` only, `MiscFlags` 0 — the exact bind profile of the IDD out-ring** (the
|
||||
/// CPU-upload encoder tests can't use that profile, so only this path exercises "RTV-written P010
|
||||
/// → encoder ingest copy"). Returns `(device, p010)`; expected decoded codes per bar are the
|
||||
/// bars_pq2020 fixture's: (490,512,512) (478,423,518) (464,525,473) (450,432,476) (350,584,585)
|
||||
/// (325,448,598) (226,650,535) (64,512,512).
|
||||
#[cfg(target_os = "windows")]
|
||||
#[doc(hidden)]
|
||||
pub fn hdr_p010_convert_bars_on_luid(
|
||||
luid: [u8; 8],
|
||||
w: u32,
|
||||
h: u32,
|
||||
) -> Result<(ID3D11Device, ID3D11Texture2D)> {
|
||||
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
|
||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
|
||||
|
||||
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
|
||||
bail!("bars pattern needs even non-zero dimensions, got {w}x{h}");
|
||||
}
|
||||
// sRGB primaries at full/zero channels: sRGB EOTF(1.0)=1.0, (0)=0 → the scRGB pattern is
|
||||
// pure 0/1 floats and the PQ/BT.2020 reference codes above are exact.
|
||||
const BARS: [(f32, f32, f32); 8] = [
|
||||
(1.0, 1.0, 1.0),
|
||||
(1.0, 1.0, 0.0),
|
||||
(0.0, 1.0, 1.0),
|
||||
(0.0, 1.0, 0.0),
|
||||
(1.0, 0.0, 1.0),
|
||||
(1.0, 0.0, 0.0),
|
||||
(0.0, 0.0, 1.0),
|
||||
(0.0, 0.0, 0.0),
|
||||
];
|
||||
let bar_w = (w / 8).max(1) as usize;
|
||||
let mut fp16 = vec![0u16; (w * h * 4) as usize];
|
||||
for y in 0..h as usize {
|
||||
for x in 0..w as usize {
|
||||
let (r, g, b) = BARS[(x / bar_w).min(7)];
|
||||
let i = (y * w as usize + x) * 4;
|
||||
fp16[i] = f32_to_f16(r);
|
||||
fp16[i + 1] = f32_to_f16(g);
|
||||
fp16[i + 2] = f32_to_f16(b);
|
||||
fp16[i + 3] = f32_to_f16(1.0);
|
||||
}
|
||||
}
|
||||
// SAFETY: same single-device/single-thread contract as `hdr_p010_selftest_at`; the FP16
|
||||
// initial-data Vec outlives the synchronous CreateTexture2D; the returned COM handles own
|
||||
// their references.
|
||||
unsafe {
|
||||
let luid = windows::Win32::Foundation::LUID {
|
||||
LowPart: u32::from_le_bytes(luid[..4].try_into().unwrap()),
|
||||
HighPart: i32::from_le_bytes(luid[4..].try_into().unwrap()),
|
||||
};
|
||||
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("dxgi factory")?;
|
||||
let adapter: IDXGIAdapter1 = factory.EnumAdapterByLuid(luid).context("adapter by luid")?;
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
let mut context: Option<ID3D11DeviceContext> = None;
|
||||
D3D11CreateDevice(
|
||||
&adapter,
|
||||
D3D_DRIVER_TYPE_UNKNOWN,
|
||||
HMODULE::default(),
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
None,
|
||||
Some(&mut context),
|
||||
)
|
||||
.context("D3D11CreateDevice(luid) for bars convert")?;
|
||||
let device = device.context("null device")?;
|
||||
let context = context.context("null context")?;
|
||||
|
||||
let src_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: w,
|
||||
Height: h,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_R16G16B16A16_FLOAT,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let init = D3D11_SUBRESOURCE_DATA {
|
||||
pSysMem: fp16.as_ptr() as *const c_void,
|
||||
SysMemPitch: w * 8,
|
||||
SysMemSlicePitch: 0,
|
||||
};
|
||||
let mut src_tex: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&src_desc, Some(&init), Some(&mut src_tex))
|
||||
.context("CreateTexture2D(fp16 bars)")?;
|
||||
let src_tex = src_tex.context("null src tex")?;
|
||||
let mut src_srv: Option<ID3D11ShaderResourceView> = None;
|
||||
device
|
||||
.CreateShaderResourceView(&src_tex, None, Some(&mut src_srv))
|
||||
.context("CreateShaderResourceView(fp16 bars)")?;
|
||||
let src_srv = src_srv.context("null src srv")?;
|
||||
|
||||
// The IDD out-ring's exact profile: P010, RENDER_TARGET only, MiscFlags 0.
|
||||
let p010_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: w,
|
||||
Height: h,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_P010,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut p010: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&p010_desc, None, Some(&mut p010))
|
||||
.context("CreateTexture2D(P010 bars dst)")?;
|
||||
let p010 = p010.context("null p010 tex")?;
|
||||
|
||||
let conv = HdrP010Converter::new(&device)?;
|
||||
conv.convert(&device, &context, &src_srv, &p010, w, h)?;
|
||||
Ok((device, p010))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn hdr_p010_selftest_at(w: u32, h: u32, vendor: Option<u32>) -> Result<()> {
|
||||
use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN};
|
||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1};
|
||||
|
||||
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
|
||||
bail!("hdr-p010-selftest needs even non-zero dimensions, got {w}x{h}");
|
||||
}
|
||||
// A grid of 16x16 flat scRGB blocks (each 2x2 chroma footprint uniform → exact chroma
|
||||
// comparison) covering pure R/G/B/white/black/gray at plausible HDR nit levels, plus a couple
|
||||
// of bright (>1.0 scRGB) colours, then the rest is a gradient (compared on Y only).
|
||||
#[allow(non_snake_case)]
|
||||
let (W, H) = (w, h);
|
||||
const BLK: u32 = 16;
|
||||
// (name, r, g, b) scRGB linear (1.0 = 80 nits). Mix of SDR-ish and HDR (>1.0) values.
|
||||
let named: [(&str, f32, f32, f32); 8] = [
|
||||
@@ -797,12 +941,36 @@ pub fn hdr_p010_selftest() -> Result<()> {
|
||||
// `fp16` outlives the synchronous `CreateTexture2D` that reads it. The mapped-pointer reads are
|
||||
// proven individually at the `read_u16` closure below.
|
||||
unsafe {
|
||||
// Hardware D3D11 device (no adapter pin — the default GPU is fine for the self-test).
|
||||
// Device on the requested vendor's adapter (dual-GPU boxes encode on a specific one), else
|
||||
// the default hardware GPU. Always says which adapter ran — a PASS is only meaningful for
|
||||
// the GPU it actually tested.
|
||||
let adapter: Option<IDXGIAdapter> = match vendor {
|
||||
None => None,
|
||||
Some(want) => {
|
||||
let factory: IDXGIFactory1 = CreateDXGIFactory1().context("dxgi factory")?;
|
||||
let mut found = None;
|
||||
for i in 0.. {
|
||||
let Ok(a) = factory.EnumAdapters(i) else {
|
||||
break;
|
||||
};
|
||||
let desc = a.GetDesc().context("adapter desc")?;
|
||||
if desc.VendorId == want {
|
||||
found = Some(a);
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(found.with_context(|| format!("no adapter with vendor id {want:#x}"))?)
|
||||
}
|
||||
};
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
let mut context: Option<ID3D11DeviceContext> = None;
|
||||
D3D11CreateDevice(
|
||||
None::<&IDXGIAdapter>,
|
||||
D3D_DRIVER_TYPE_HARDWARE,
|
||||
adapter.as_ref(),
|
||||
if adapter.is_some() {
|
||||
D3D_DRIVER_TYPE_UNKNOWN
|
||||
} else {
|
||||
D3D_DRIVER_TYPE_HARDWARE
|
||||
},
|
||||
HMODULE::default(),
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
||||
@@ -814,6 +982,22 @@ pub fn hdr_p010_selftest() -> Result<()> {
|
||||
.context("D3D11CreateDevice(hardware) for hdr-p010-selftest")?;
|
||||
let device = device.context("null device")?;
|
||||
let context = context.context("null context")?;
|
||||
{
|
||||
let dxgi: windows::Win32::Graphics::Dxgi::IDXGIDevice =
|
||||
device.cast().context("device -> IDXGIDevice")?;
|
||||
let desc = dxgi.GetAdapter().context("GetAdapter")?.GetDesc()?;
|
||||
let name = String::from_utf16_lossy(
|
||||
&desc.Description[..desc
|
||||
.Description
|
||||
.iter()
|
||||
.position(|&c| c == 0)
|
||||
.unwrap_or(desc.Description.len())],
|
||||
);
|
||||
println!(
|
||||
"adapter: {name} (vendor {:#06x}, luid {:08x}:{:08x})",
|
||||
desc.VendorId, desc.AdapterLuid.HighPart, desc.AdapterLuid.LowPart
|
||||
);
|
||||
}
|
||||
|
||||
// Source FP16 texture (initialized) + SRV.
|
||||
let src_desc = D3D11_TEXTURE2D_DESC {
|
||||
@@ -1175,3 +1359,16 @@ impl VideoConverter {
|
||||
blt.context("VideoProcessorBlt")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hdr_selftests {
|
||||
/// LIVE (needs the GPU): [`super::hdr_p010_selftest_at`] at the field capture size — 1080 is
|
||||
/// NOT 16-aligned, and the planar-RTV write path is driver-specific per vendor. Pinned to the
|
||||
/// Intel adapter (`0x8086`), so it runs on the Intel validation boxes and errors out cleanly
|
||||
/// ("no adapter") elsewhere. `cargo test -p pf-capture -- --ignored hdr_p010 --nocapture`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn hdr_p010_selftest_intel_1080_live() {
|
||||
super::hdr_p010_selftest_at(1920, 1080, Some(0x8086)).expect("hdr p010 selftest @1080");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,6 +456,48 @@ impl TouchMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a physical mouse drives the host — the desktop-sweep mouse model
|
||||
/// (design/remote-desktop-sweep.md M1). Stored stringly in [`Settings::mouse_mode`] so the
|
||||
/// file stays readable; parsed with [`MouseMode::from_name`].
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum MouseMode {
|
||||
/// Pointer lock (relative deltas, hidden cursor) — the game model, and the default:
|
||||
/// the only cursor you see is the host's.
|
||||
Capture,
|
||||
/// Absolute pointer, uncaptured: the cursor enters and leaves the stream freely and
|
||||
/// motion goes on the wire as absolute positions through the letterbox. The remote
|
||||
/// desktop model. Requires a host injector with absolute support (not gamescope).
|
||||
Desktop,
|
||||
}
|
||||
|
||||
impl MouseMode {
|
||||
/// Cycle/picker order (also the settings pickers' option order).
|
||||
pub const ALL: [MouseMode; 2] = [MouseMode::Capture, MouseMode::Desktop];
|
||||
|
||||
/// Parse the persisted name, defaulting to `Capture` for unset/unknown values.
|
||||
pub fn from_name(s: &str) -> MouseMode {
|
||||
match s {
|
||||
"desktop" => MouseMode::Desktop,
|
||||
_ => MouseMode::Capture,
|
||||
}
|
||||
}
|
||||
|
||||
/// The persisted name (the inverse of [`from_name`](Self::from_name)).
|
||||
pub fn as_name(self) -> &'static str {
|
||||
match self {
|
||||
MouseMode::Capture => "capture",
|
||||
MouseMode::Desktop => "desktop",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
MouseMode::Capture => "Capture (games)",
|
||||
MouseMode::Desktop => "Desktop (absolute)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
||||
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
@@ -490,6 +532,12 @@ pub struct Settings {
|
||||
/// stores load as trackpad.
|
||||
#[serde(default = "default_touch_mode")]
|
||||
pub touch_mode: String,
|
||||
/// How a physical mouse drives the host: a [`MouseMode`] name — `"capture"` (default,
|
||||
/// pointer lock + relative) or `"desktop"` (uncaptured absolute pointer). Read at
|
||||
/// connect via [`Settings::mouse_mode`]. `default` so pre-existing stores load as
|
||||
/// capture — today's behavior.
|
||||
#[serde(default = "default_mouse_mode")]
|
||||
pub mouse_mode: String,
|
||||
/// Grab compositor shortcuts (Alt+Tab, Super…) while input is captured.
|
||||
pub inhibit_shortcuts: bool,
|
||||
/// Stream the default microphone to the host's virtual mic source.
|
||||
@@ -577,6 +625,10 @@ fn default_touch_mode() -> String {
|
||||
"trackpad".into()
|
||||
}
|
||||
|
||||
fn default_mouse_mode() -> String {
|
||||
"capture".into()
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -604,6 +656,10 @@ impl Settings {
|
||||
TouchMode::from_name(&self.touch_mode)
|
||||
}
|
||||
|
||||
pub fn mouse_mode(&self) -> MouseMode {
|
||||
MouseMode::from_name(&self.mouse_mode)
|
||||
}
|
||||
|
||||
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
|
||||
pub fn preferred_codec(&self) -> u8 {
|
||||
match self.codec.as_str() {
|
||||
@@ -631,6 +687,7 @@ impl Default for Settings {
|
||||
forward_pad: String::new(),
|
||||
compositor: "auto".into(),
|
||||
touch_mode: "trackpad".into(),
|
||||
mouse_mode: "capture".into(),
|
||||
inhibit_shortcuts: true,
|
||||
mic_enabled: false,
|
||||
audio_channels: 2,
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 pf_client_core::trust::{StatsVerbosity, TouchMode};
|
||||
use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode};
|
||||
use skia_safe::{Canvas, Rect};
|
||||
|
||||
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
|
||||
@@ -29,10 +29,11 @@ enum RowId {
|
||||
Pad,
|
||||
PadType,
|
||||
Touch,
|
||||
Mouse,
|
||||
Stats,
|
||||
}
|
||||
|
||||
const ROWS: [RowId; 13] = [
|
||||
const ROWS: [RowId; 14] = [
|
||||
RowId::Resolution,
|
||||
RowId::Refresh,
|
||||
RowId::Bitrate,
|
||||
@@ -45,6 +46,7 @@ const ROWS: [RowId; 13] = [
|
||||
RowId::Pad,
|
||||
RowId::PadType,
|
||||
RowId::Touch,
|
||||
RowId::Mouse,
|
||||
RowId::Stats,
|
||||
];
|
||||
|
||||
@@ -251,6 +253,7 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
||||
"Touch mode",
|
||||
s.touch_mode().label().into(),
|
||||
),
|
||||
RowId::Mouse => (None, "Mouse mode", s.mouse_mode().label().into()),
|
||||
RowId::Stats => (
|
||||
Some("Interface"),
|
||||
"Statistics overlay",
|
||||
@@ -292,6 +295,11 @@ fn detail(id: RowId) -> &'static str {
|
||||
"How the touchscreen drives the host: Trackpad (relative cursor), \
|
||||
Direct pointer (cursor jumps to your finger), or Touch passthrough (raw contacts)."
|
||||
}
|
||||
RowId::Mouse => {
|
||||
"How a physical mouse drives the host: Capture locks the pointer (relative, \
|
||||
for games), Desktop leaves it free and sends absolute positions. \
|
||||
Ctrl+Alt+Shift+M switches live while streaming."
|
||||
}
|
||||
RowId::Stats => {
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
|
||||
Ctrl+Alt+Shift+S cycles it live while streaming."
|
||||
@@ -367,6 +375,11 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
step_option(cur, TouchMode::ALL.len(), delta, wrap)
|
||||
.map(|i| s.touch_mode = TouchMode::ALL[i].as_name().to_string())
|
||||
}
|
||||
RowId::Mouse => {
|
||||
let cur = MouseMode::ALL.iter().position(|m| *m == s.mouse_mode());
|
||||
step_option(cur, MouseMode::ALL.len(), delta, wrap)
|
||||
.map(|i| s.mouse_mode = MouseMode::ALL[i].as_name().to_string())
|
||||
}
|
||||
RowId::Stats => {
|
||||
let cur = StatsVerbosity::ALL
|
||||
.iter()
|
||||
@@ -510,6 +523,33 @@ mod tests {
|
||||
assert_eq!(ctx.settings.touch_mode, "trackpad");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_mode_steps_and_wraps() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
assert_eq!(settings.mouse_mode, "capture");
|
||||
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,
|
||||
};
|
||||
// Capture → Desktop, then a step past the end is a boundary.
|
||||
assert!(
|
||||
!adjust(RowId::Mouse, -1, false, &mut ctx),
|
||||
"already first = thud"
|
||||
);
|
||||
assert!(adjust(RowId::Mouse, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.mouse_mode, "desktop");
|
||||
assert!(!adjust(RowId::Mouse, 1, false, &mut ctx), "last = thud");
|
||||
// A wraps back to the first.
|
||||
assert!(adjust(RowId::Mouse, 1, true, &mut ctx));
|
||||
assert_eq!(ctx.settings.mouse_mode, "capture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_value_snaps_to_first() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
|
||||
@@ -25,6 +25,11 @@ tracing = "0.1"
|
||||
# A test writer for the NVENC backend's unit tests (`with_test_writer().try_init()`).
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dev-dependencies]
|
||||
# The QSV live e2e drives the REAL HdrP010Converter output (an RTV-written, ring-profile P010
|
||||
# texture) into the encoder — the one seam the CPU-upload tests can't reach.
|
||||
pf-capture = { path = "../pf-capture" }
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies]
|
||||
# Software H.264 (openh264, BSD-2) — the GPU-less encode path on both platforms.
|
||||
openh264 = "0.9"
|
||||
|
||||
@@ -32,6 +32,46 @@ pub struct EncodedFrame {
|
||||
pub chunk_aligned: bool,
|
||||
}
|
||||
|
||||
/// One slice-boundary chunk of an encoded AU, emitted by a chunked-poll backend
|
||||
/// ([`Encoder::poll_chunk`], latency plan §7 LN1): the encoder hands out completed slices while
|
||||
/// the rest of the frame is still encoding, so packetize/FEC/pacing can overlap the encode tail.
|
||||
/// The chunks of one AU concatenate to exactly the bytes [`Encoder::poll`] would have returned,
|
||||
/// and every cut lands on an Annex-B NAL boundary (slice starts). AU-level metadata
|
||||
/// (`pts_ns`/`keyframe`/`recovery_anchor`/`chunk_aligned`) is authoritative on the FIRST chunk
|
||||
/// (`first`) — the host opens the wire frame from it; `last` closes the AU. `keyframe` on a
|
||||
/// non-final chunk is the encoder's own prediction (exact under the P-only/infinite-GOP config —
|
||||
/// the driver only ever emits an IDR we asked for); the final chunk re-checks it against the
|
||||
/// driver's reported picture type.
|
||||
pub struct AuChunk {
|
||||
pub data: Vec<u8>,
|
||||
pub pts_ns: u64,
|
||||
pub keyframe: bool,
|
||||
/// See [`EncodedFrame::recovery_anchor`].
|
||||
pub recovery_anchor: bool,
|
||||
/// See [`EncodedFrame::chunk_aligned`].
|
||||
pub chunk_aligned: bool,
|
||||
/// Opens the AU (carries the authoritative AU metadata).
|
||||
pub first: bool,
|
||||
/// Closes the AU (the concatenation is complete; the encoder's in-flight slot is released).
|
||||
pub last: bool,
|
||||
}
|
||||
|
||||
impl AuChunk {
|
||||
/// A whole AU as a single self-closing chunk — what every non-chunked backend's
|
||||
/// [`Encoder::poll_chunk`] default emits, so a chunk consumer needs no per-backend fork.
|
||||
pub fn whole(f: EncodedFrame) -> Self {
|
||||
AuChunk {
|
||||
data: f.data,
|
||||
pts_ns: f.pts_ns,
|
||||
keyframe: f.keyframe,
|
||||
recovery_anchor: f.recovery_anchor,
|
||||
chunk_aligned: f.chunk_aligned,
|
||||
first: true,
|
||||
last: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Codec selection negotiated with the client.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Codec {
|
||||
@@ -280,6 +320,26 @@ pub trait Encoder: Send {
|
||||
}
|
||||
/// Pull the next encoded AU if one is ready.
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>>;
|
||||
/// Whether [`poll_chunk`](Self::poll_chunk) currently emits sub-AU chunks — i.e. the LIVE
|
||||
/// session has slice-level readback armed (Linux direct-NVENC with the
|
||||
/// `PUNKTFUNK_NVENC_SLICES` and `PUNKTFUNK_NVENC_SUBFRAME` knobs on a sync depth-1
|
||||
/// retrieve). Dynamic, not static: a pipelined-retrieve escalation or a session rebuild can
|
||||
/// turn it off — re-query per AU, never cache across frames. `false` (the default) means
|
||||
/// `poll_chunk` degrades to one whole-AU chunk per frame.
|
||||
fn supports_chunked_poll(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Pull the next slice-boundary chunk of the oldest in-flight AU (latency plan §7 LN1).
|
||||
/// Semantics when chunking is live: BLOCKS until the next chunk is readable, and the final
|
||||
/// (`last`) chunk blocks exactly like [`poll`](Self::poll) does — the depth-1 pump treats
|
||||
/// `None` as re-poll-next-tick, so a non-blocking tail would ride the AU one tick late (the
|
||||
/// `6dc195f9` Vulkan bug class). `Ok(None)` only when no AU is in flight. Each AU must be
|
||||
/// drained through ONE method: calling `poll` on a partially-chunked AU is a caller bug (the
|
||||
/// backend errors rather than double-emit bytes). Default: delegates to `poll`, wrapping the
|
||||
/// whole AU as a single `first && last` chunk.
|
||||
fn poll_chunk(&mut self) -> Result<Option<AuChunk>> {
|
||||
Ok(self.poll()?.map(AuChunk::whole))
|
||||
}
|
||||
/// Tear the underlying hardware encoder down and rebuild it in place, keeping the session's
|
||||
/// negotiated parameters — the encode-stall watchdog's recovery lever (a wedged AMF/QSV
|
||||
/// driver stops emitting AUs or accepting frames without ever returning an error). Returns
|
||||
@@ -436,6 +496,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole-AU chunk (every non-chunked backend's `poll_chunk` shape) must carry the AU's
|
||||
/// metadata verbatim and be self-closing (`first && last`).
|
||||
#[test]
|
||||
fn whole_au_chunk_is_self_closing() {
|
||||
let c = AuChunk::whole(EncodedFrame {
|
||||
data: vec![0, 0, 0, 1, 0x40],
|
||||
pts_ns: 42,
|
||||
keyframe: true,
|
||||
recovery_anchor: true,
|
||||
chunk_aligned: false,
|
||||
});
|
||||
assert_eq!(c.data, vec![0, 0, 0, 1, 0x40]);
|
||||
assert_eq!(c.pts_ns, 42);
|
||||
assert!(c.keyframe && c.recovery_anchor && !c.chunk_aligned);
|
||||
assert!(c.first && c.last);
|
||||
}
|
||||
|
||||
/// Wire round-trip and the stats label stay in lockstep with the `quic::CODEC_*` bits.
|
||||
#[test]
|
||||
fn codec_wire_roundtrip_and_label() {
|
||||
|
||||
@@ -44,6 +44,15 @@
|
||||
//! the retrieve thread the same way it would hang the encode thread today (Linux has no
|
||||
//! event-timeout escape) — no regression, just no new watchdog either.
|
||||
//!
|
||||
//! **Sub-frame chunked poll** (latency plan §7 LN1 — **default-on since Phase 3**: 4 slices +
|
||||
//! sub-frame readback on every session whose GPU advertises `SUBFRAME_READBACK`; escapes are
|
||||
//! `PUNKTFUNK_NVENC_SLICES=1` and `PUNKTFUNK_NVENC_SUBFRAME=0`): on a sync depth-1 session,
|
||||
//! [`Encoder::poll_chunk`] hands the in-flight AU out as slice-boundary chunks read through
|
||||
//! `doNotWait` sub-frame locks while the tail is still encoding; one final blocking lock closes
|
||||
//! the AU (the completion authority — `numSlices` alone is not trusted across driver branches).
|
||||
//! Mutually exclusive with the pipelined retrieve (the escalated rebuild drops it); composes
|
||||
//! with stream-ordered submit (both are sync depth-1 features).
|
||||
//!
|
||||
//! Needs a real NVIDIA GPU at runtime (session creation fails otherwise); compiles GPU-less and
|
||||
//! starts driver-less (the `.so` resolves at runtime — on an AMD/Intel box [`try_api`] fails cleanly
|
||||
//! and the VAAPI/software backends carry the session).
|
||||
@@ -52,10 +61,11 @@
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::nvenc_core::{
|
||||
apply_low_latency_config, build_init_params, codec_guid, LowLatencyConfig, NvStatusExt, RFI_DPB,
|
||||
apply_low_latency_config, build_init_params, codec_guid, resolve_slices, resolve_subframe,
|
||||
LowLatencyConfig, NvStatusExt, RFI_DPB,
|
||||
};
|
||||
use super::nvenc_status;
|
||||
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||
use super::{AuChunk, ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use pf_frame::{CapturedFrame, FramePayload};
|
||||
use pf_zerocopy::cuda::{self, InputSurface};
|
||||
@@ -425,6 +435,41 @@ struct RingSlot {
|
||||
reg: nv::NV_ENC_REGISTERED_PTR,
|
||||
}
|
||||
|
||||
/// `doNotWait` sampling cadence inside [`Encoder::poll_chunk`] — the probe measured ~200 µs
|
||||
/// between slice completions on the 5070 Ti, so 50 µs keeps the added per-chunk delivery delay
|
||||
/// well under one slice time without hammering the driver.
|
||||
const CHUNK_SAMPLE_INTERVAL: std::time::Duration = std::time::Duration::from_micros(50);
|
||||
|
||||
/// Progress of a sub-frame chunked readback (§7 LN1 Phase 1) for the FRONT in-flight AU: how
|
||||
/// much of the bitstream has already been handed out as chunks. `Some` from an AU's first
|
||||
/// emitted chunk until its `last` — [`Encoder::poll`] refuses to run while it exists (a plain
|
||||
/// poll would re-emit the already-shipped prefix).
|
||||
struct ChunkState {
|
||||
/// Bytes emitted so far — also the next chunk's start offset (always a slice boundary).
|
||||
emitted: usize,
|
||||
/// Completed slices already covered by emitted chunks.
|
||||
slices_out: u32,
|
||||
/// The AU-opening chunk (`AuChunk::first`) has been handed out.
|
||||
opened: bool,
|
||||
/// Debug-build shadow of every emitted byte, cross-checked against the finishing blocking
|
||||
/// lock's full AU — a mis-cut chunk fails loudly in the on-hw tests instead of silently
|
||||
/// corrupting the wire. Compiled out of release builds.
|
||||
#[cfg(debug_assertions)]
|
||||
shadow: Vec<u8>,
|
||||
}
|
||||
|
||||
impl ChunkState {
|
||||
fn new() -> Self {
|
||||
ChunkState {
|
||||
emitted: 0,
|
||||
slices_out: 0,
|
||||
opened: false,
|
||||
#[cfg(debug_assertions)]
|
||||
shadow: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NvencCudaEncoder {
|
||||
encoder: *mut c_void,
|
||||
/// The shared process-wide `CUcontext` the session is bound to (from `zerocopy::cuda::context`).
|
||||
@@ -456,11 +501,14 @@ pub struct NvencCudaEncoder {
|
||||
/// sampled `PUNKTFUNK_PERF` submit-split log cadence, mirroring the VAAPI backend's counter.
|
||||
frames: u64,
|
||||
bitstreams: Vec<nv::NV_ENC_OUTPUT_PTR>,
|
||||
/// (bitstream, mapped input resource to unmap after retrieval, pts_ns, recovery-anchor) per
|
||||
/// in-flight encode. The fourth field tags the first frame encoded after a successful
|
||||
/// [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) — the clean re-anchor P-frame the
|
||||
/// client lifts its post-loss freeze on.
|
||||
pending: VecDeque<(nv::NV_ENC_OUTPUT_PTR, nv::NV_ENC_INPUT_PTR, u64, bool)>,
|
||||
/// (bitstream, mapped input resource to unmap after retrieval, pts_ns, recovery-anchor,
|
||||
/// IDR-predicted) per in-flight encode. The fourth field tags the first frame encoded after a
|
||||
/// successful [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) — the clean re-anchor
|
||||
/// P-frame the client lifts its post-loss freeze on. The fifth is the submit-time keyframe
|
||||
/// prediction (forced/opening IDR) that chunked poll stamps on chunks emitted before the
|
||||
/// driver reports the real picture type — exact under P-only + infinite GOP (the driver only
|
||||
/// emits IDRs we asked for); the finishing blocking lock cross-checks it.
|
||||
pending: VecDeque<(nv::NV_ENC_OUTPUT_PTR, nv::NV_ENC_INPUT_PTR, u64, bool, bool)>,
|
||||
/// The frame number of the NEXT submission (also its `inputTimeStamp`). Pinned per frame by
|
||||
/// [`Encoder::submit_indexed`] to the WIRE frame index the AU will carry, so the DPB timestamps
|
||||
/// `invalidate_ref_frames` compares client frame numbers against stay 1:1 with the wire across
|
||||
@@ -512,6 +560,24 @@ pub struct NvencCudaEncoder {
|
||||
/// Stream-ordered submit armed for the live session (sync-retrieve mode only; see
|
||||
/// [`stream_ordered_requested`]). The per-frame gate additionally requires `pending` empty.
|
||||
stream_ordered: bool,
|
||||
/// Slice count the live session was configured with ([`resolve_slices`] — env override,
|
||||
/// else the Linux direct-NVENC default of 4 since Phase 3; 1 = the preset's single slice).
|
||||
/// Chunked poll needs ≥ 2 to have boundaries to cut at. Latched at init, consumed by
|
||||
/// `build_config` (so an in-place reconfigure presents the same slicing).
|
||||
slices: u32,
|
||||
/// `NV_ENC_CAPS_SUPPORT_SUBFRAME_READBACK` from the caps probe — gates the DEFAULT-on
|
||||
/// sub-frame arming (an unsupported GPU must not have `enableSubFrameWrite` forced into its
|
||||
/// init params, which could fail the session open). `PUNKTFUNK_NVENC_SUBFRAME=1` overrides.
|
||||
subframe_cap: bool,
|
||||
/// Sub-frame readback resolved for the live session ([`resolve_subframe`] over
|
||||
/// [`subframe_cap`](Self::subframe_cap)); consumed by every `build_init_params` call so the
|
||||
/// open and the in-place reconfigure present identical init params.
|
||||
subframe_on: bool,
|
||||
/// Sub-frame chunked poll armed for the live session (§7 LN1 Phase 1): multi-slice +
|
||||
/// sub-frame readback configured AND sync retrieve at init. See [`Encoder::poll_chunk`].
|
||||
subframe_chunks: bool,
|
||||
/// In-progress chunked readback of the front in-flight AU. See [`ChunkState`].
|
||||
chunk: Option<ChunkState>,
|
||||
}
|
||||
|
||||
// SAFETY: the `!Send` fields are the raw NVENC session handle (`encoder`), the shared `CUcontext`
|
||||
@@ -594,6 +660,11 @@ impl NvencCudaEncoder {
|
||||
want_async: false,
|
||||
io_stream: ptr::null_mut(),
|
||||
stream_ordered: false,
|
||||
slices: 1,
|
||||
subframe_cap: false,
|
||||
subframe_on: false,
|
||||
subframe_chunks: false,
|
||||
chunk: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -635,7 +706,7 @@ impl NvencCudaEncoder {
|
||||
}
|
||||
}
|
||||
// Unmap any in-flight inputs, unregister every ring surface, destroy the bitstreams.
|
||||
for (_, map, _, _) in &self.pending {
|
||||
for (_, map, _, _, _) in &self.pending {
|
||||
if !map.is_null() {
|
||||
let _ = (api().unmap_input_resource)(self.encoder, *map);
|
||||
}
|
||||
@@ -664,6 +735,10 @@ impl NvencCudaEncoder {
|
||||
self.io_stream = ptr::null_mut();
|
||||
}
|
||||
self.stream_ordered = false;
|
||||
// Chunked-poll state is per session: a half-chunked AU dies with its in-flight frame
|
||||
// (the forfeit contract), and the next session re-latches the arming at init.
|
||||
self.subframe_chunks = false;
|
||||
self.chunk = None;
|
||||
self.ring.clear(); // drops the InputSurfaces, freeing their CUDA allocations
|
||||
self.bitstreams.clear();
|
||||
self.pending.clear();
|
||||
@@ -748,6 +823,15 @@ impl NvencCudaEncoder {
|
||||
}
|
||||
self.rfi_supported = rfi != 0;
|
||||
self.custom_vbv = custom_vbv != 0;
|
||||
self.subframe_cap = subframe != 0;
|
||||
// Phase-3 default-on (nvenc-subframe-slice-output.md): 4 slices + sub-frame readback on
|
||||
// every Linux direct-NVENC session, resolved HERE (before the session opens) so the
|
||||
// config author, the init params and the chunked-poll latch all agree; the caps probe
|
||||
// gates the sub-frame default so a GPU without SUBFRAME_READBACK never has it forced
|
||||
// into its init params. PUNKTFUNK_NVENC_SLICES=1 / PUNKTFUNK_NVENC_SUBFRAME=0 are the
|
||||
// escapes.
|
||||
self.slices = resolve_slices(self.codec, 4);
|
||||
self.subframe_on = resolve_subframe(self.subframe_cap);
|
||||
tracing::info!(
|
||||
rfi = self.rfi_supported,
|
||||
custom_vbv = self.custom_vbv,
|
||||
@@ -861,6 +945,7 @@ impl NvencCudaEncoder {
|
||||
av1_input_depth_minus8: 0,
|
||||
hdr: self.hdr,
|
||||
rfi_supported: self.rfi_supported,
|
||||
slices: self.slices,
|
||||
},
|
||||
);
|
||||
Ok(cfg)
|
||||
@@ -901,6 +986,7 @@ impl NvencCudaEncoder {
|
||||
&mut cfg,
|
||||
split_mode,
|
||||
false,
|
||||
self.subframe_on,
|
||||
);
|
||||
|
||||
match (api().initialize_encoder)(enc, &mut init).nv_ok() {
|
||||
@@ -1124,6 +1210,20 @@ impl NvencCudaEncoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sub-frame chunked poll (§7 LN1 Phase 1; default-on since Phase 3): armed iff this
|
||||
// session was CONFIGURED multi-slice + sub-frame readback (`self.slices` /
|
||||
// `self.subframe_on` were resolved once in `query_caps` and consumed by
|
||||
// `build_config` / `build_init_params`, so the latch can't disagree with the session
|
||||
// config) and the retrieve is sync — chunked poll is a depth-1 sync feature; a
|
||||
// pipelined session's non-blocking poll owns the bitstream from the retrieve thread
|
||||
// instead (the sub-frame write itself stays armed there; it's harmless).
|
||||
self.subframe_chunks = self.slices >= 2 && self.subframe_on && self.async_rt.is_none();
|
||||
if self.subframe_chunks {
|
||||
tracing::info!(
|
||||
slices = self.slices,
|
||||
"NVENC sub-frame chunked poll armed (poll_chunk emits slice-boundary AU chunks)"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
mode = %format_args!("{}x{}@{}", self.width, self.height, self.fps),
|
||||
bit_depth = self.bit_depth,
|
||||
@@ -1172,7 +1272,7 @@ impl NvencCudaEncoder {
|
||||
/// in-flight entry, cross-check FIFO pairing, unmap its input HERE (the encode thread — the
|
||||
/// retrieve thread never touches input resources), and queue the finished AU.
|
||||
fn absorb_done(&mut self, done: RetrieveDone) -> Result<()> {
|
||||
let Some((bs, map, pts_ns, anchor)) = self.pending.pop_front() else {
|
||||
let Some((bs, map, pts_ns, anchor, _)) = self.pending.pop_front() else {
|
||||
bail!("NVENC retrieve: completion with no in-flight frame (pairing bug)");
|
||||
};
|
||||
if bs as usize != done.bs {
|
||||
@@ -1471,6 +1571,10 @@ impl Encoder for NvencCudaEncoder {
|
||||
mp.mappedResource,
|
||||
captured.pts_ns,
|
||||
anchor,
|
||||
// The chunked-poll keyframe prediction: exactly the SEI gate's is_idr (forced
|
||||
// flags or the session-opening frame) — under P-only + infinite GOP the driver
|
||||
// never emits an IDR on its own, so this matches the eventual pictureType.
|
||||
is_idr,
|
||||
));
|
||||
}
|
||||
if sample {
|
||||
@@ -1580,6 +1684,11 @@ impl Encoder for NvencCudaEncoder {
|
||||
}
|
||||
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||
// A partially-chunked AU must be finished through `poll_chunk`: its emitted prefix is
|
||||
// already with the caller, so a whole-AU poll here would double-emit those bytes.
|
||||
if self.chunk.is_some() {
|
||||
bail!("NVENC poll() called mid-chunked-AU — drain it via poll_chunk (caller bug)");
|
||||
}
|
||||
// Two-thread mode: drain whatever the retrieve thread has finished (non-blocking) and
|
||||
// hand out the oldest ready AU. `None` = nothing completed yet — the session loop keeps
|
||||
// the frame in flight and re-polls next tick; capture never blocks on the encode wait.
|
||||
@@ -1600,7 +1709,7 @@ impl Encoder for NvencCudaEncoder {
|
||||
.ready
|
||||
.pop_front());
|
||||
}
|
||||
let Some((bs, map, pts_ns, anchor)) = self.pending.pop_front() else {
|
||||
let Some((bs, map, pts_ns, anchor, _)) = self.pending.pop_front() else {
|
||||
return Ok(None);
|
||||
};
|
||||
// SAFETY: a non-empty `pending` implies `submit` ran, so `self.encoder` is the live session
|
||||
@@ -1645,6 +1754,172 @@ impl Encoder for NvencCudaEncoder {
|
||||
}
|
||||
}
|
||||
|
||||
fn supports_chunked_poll(&self) -> bool {
|
||||
// Dynamic on purpose: a pipelined-retrieve escalation rebuilds the session with
|
||||
// `async_rt` present (and `teardown` drops the latch), so a caller re-querying per AU
|
||||
// sees the mode fall away instead of chunk-polling a session that can't serve it.
|
||||
self.subframe_chunks && self.async_rt.is_none()
|
||||
}
|
||||
|
||||
fn poll_chunk(&mut self) -> Result<Option<AuChunk>> {
|
||||
// Not a chunked session (knobs off, AV1, escalated to pipelined retrieve): degrade to a
|
||||
// single whole-AU chunk so a chunk-driven caller works against every session shape. The
|
||||
// `chunk.is_none()` arm is defensive — a mid-AU state must always finish below.
|
||||
if !self.supports_chunked_poll() && self.chunk.is_none() {
|
||||
return Ok(self.poll()?.map(AuChunk::whole));
|
||||
}
|
||||
let Some(&(bs, _, pts_ns, anchor, idr_hint)) = self.pending.front() else {
|
||||
return Ok(None);
|
||||
};
|
||||
// Sampling budget: if this driver branch never publishes intermediate slices, stop
|
||||
// burning CPU after ~2 frame intervals and finish through the blocking lock — worst
|
||||
// case poll_chunk behaves like sync `poll` plus a few failed doNotWait attempts.
|
||||
let budget = std::time::Duration::from_micros(2_000_000 / self.fps.max(1) as u64);
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut offsets = [0u32; 32];
|
||||
loop {
|
||||
let emitted = self.chunk.as_ref().map_or(0, |c| c.emitted);
|
||||
let slices_out = self.chunk.as_ref().map_or(0, |c| c.slices_out);
|
||||
// SAFETY: `bs` is the front `pending` entry's pool bitstream (a prior
|
||||
// `encode_picture` targeted it, and `teardown` clears `pending` whenever it nulls
|
||||
// the session), the session is live, and this runs on the encode thread. `lock`
|
||||
// (version set, doNotWait) and `offsets` are live stack locals across the
|
||||
// synchronous call; `reportSliceOffsets` was armed at init so the driver may write
|
||||
// up to `numSlices` ≤ 32 offsets (`sliceModeData` is clamped 2..=32 by
|
||||
// `resolve_slices`). On a successful sub-frame lock `bitstreamBufferPtr` holds
|
||||
// `bitstreamSizeInBytes` readable bytes of COMPLETED slices (enableSubFrameWrite
|
||||
// publishes them mid-encode; proven by the on-hw probe) valid until the matching
|
||||
// unlock; the emitted range is copied out BEFORE the unlock. Every successful lock
|
||||
// is unlocked exactly once on all paths through the body.
|
||||
unsafe {
|
||||
let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
|
||||
version: nv::NV_ENC_LOCK_BITSTREAM_VER,
|
||||
outputBitstream: bs,
|
||||
sliceOffsets: offsets.as_mut_ptr(),
|
||||
..Default::default()
|
||||
};
|
||||
lock.set_doNotWait(1);
|
||||
if (api().lock_bitstream)(self.encoder, &mut lock)
|
||||
.nv_ok()
|
||||
.is_ok()
|
||||
{
|
||||
let n = lock.numSlices;
|
||||
let bytes = lock.bitstreamSizeInBytes as usize;
|
||||
if n >= self.slices {
|
||||
// Every slice is readable — fall through to the finishing blocking
|
||||
// lock (the completion authority; `numSlices` alone is not trusted
|
||||
// across driver branches).
|
||||
let _ = (api().unlock_bitstream)(self.encoder, bs);
|
||||
break;
|
||||
}
|
||||
if n > slices_out && bytes > emitted {
|
||||
// New completed slice(s): cut `[emitted..bytes)`. `bytes` with `n`
|
||||
// reported slices is the end of slice n (slices are contiguous
|
||||
// Annex-B), so the cut lands on a NAL boundary.
|
||||
let data =
|
||||
std::slice::from_raw_parts(lock.bitstreamBufferPtr as *const u8, bytes)
|
||||
[emitted..]
|
||||
.to_vec();
|
||||
(api().unlock_bitstream)(self.encoder, bs)
|
||||
.nv_ok()
|
||||
.map_err(|e| nvenc_status::call_err("unlock_bitstream (chunk)", e))?;
|
||||
let cs = self.chunk.get_or_insert_with(ChunkState::new);
|
||||
#[cfg(debug_assertions)]
|
||||
cs.shadow.extend_from_slice(&data);
|
||||
let first = !cs.opened;
|
||||
cs.opened = true;
|
||||
cs.emitted = bytes;
|
||||
cs.slices_out = n;
|
||||
return Ok(Some(AuChunk {
|
||||
data,
|
||||
pts_ns,
|
||||
keyframe: idr_hint,
|
||||
recovery_anchor: anchor,
|
||||
chunk_aligned: false,
|
||||
first,
|
||||
last: false,
|
||||
}));
|
||||
}
|
||||
let _ = (api().unlock_bitstream)(self.encoder, bs);
|
||||
}
|
||||
// Non-SUCCESS (LOCK_BUSY on other branches) = not ready — never an error here;
|
||||
// the finishing blocking lock below owns real failures.
|
||||
}
|
||||
if t0.elapsed() > budget {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(CHUNK_SAMPLE_INTERVAL);
|
||||
}
|
||||
|
||||
// Finish: ONE blocking lock — the completion authority and the wedge-watchdog hook,
|
||||
// exactly like sync `poll` (so the final chunk blocks and the AU tail never rides a
|
||||
// +1 tick — the depth-1 pump contract). Emits whatever the sampler hadn't handed out.
|
||||
let (bs, map, pts_ns, anchor, idr_hint) =
|
||||
self.pending.pop_front().expect("front() checked above");
|
||||
// SAFETY: same contract as `poll`'s blocking lock: `bs` is the popped in-flight pool
|
||||
// bitstream on the live session (encode thread); the blocking `lock_bitstream` (version
|
||||
// set) returns when the encode finished, yielding `bitstreamSizeInBytes` CPU-readable
|
||||
// bytes at `bitstreamBufferPtr` valid until `unlock_bitstream` — every read (tail copy
|
||||
// + debug prefix check) happens BEFORE the unlock. `map` (paired with `bs` in `pending`)
|
||||
// is unmapped here, after completion, exactly once.
|
||||
unsafe {
|
||||
let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
|
||||
version: nv::NV_ENC_LOCK_BITSTREAM_VER,
|
||||
outputBitstream: bs,
|
||||
..Default::default()
|
||||
};
|
||||
(api().lock_bitstream)(self.encoder, &mut lock)
|
||||
.nv_ok()
|
||||
.map_err(|e| nvenc_status::call_err("lock_bitstream (chunk finish)", e))?;
|
||||
let total = lock.bitstreamSizeInBytes as usize;
|
||||
let full = std::slice::from_raw_parts(lock.bitstreamBufferPtr as *const u8, total);
|
||||
let cs = self.chunk.take().unwrap_or_else(ChunkState::new);
|
||||
if cs.emitted > total {
|
||||
let _ = (api().unlock_bitstream)(self.encoder, bs);
|
||||
bail!(
|
||||
"NVENC chunked poll: {} bytes already emitted but the finished AU is only \
|
||||
{} — sub-frame readback reported bytes the final lock disowns",
|
||||
cs.emitted,
|
||||
total
|
||||
);
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
if cs.shadow.as_slice() != &full[..cs.emitted] {
|
||||
let _ = (api().unlock_bitstream)(self.encoder, bs);
|
||||
bail!("NVENC chunked poll: emitted chunks diverge from the finished AU prefix");
|
||||
}
|
||||
let data = full[cs.emitted..].to_vec();
|
||||
let keyframe = matches!(
|
||||
lock.pictureType,
|
||||
nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_IDR | nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_I
|
||||
);
|
||||
(api().unlock_bitstream)(self.encoder, bs)
|
||||
.nv_ok()
|
||||
.map_err(|e| nvenc_status::call_err("unlock_bitstream (chunk finish)", e))?;
|
||||
if !map.is_null() {
|
||||
let _ = (api().unmap_input_resource)(self.encoder, map);
|
||||
}
|
||||
if cs.opened && keyframe != idr_hint {
|
||||
// Can't happen under P-only + infinite GOP; if a driver branch ever proves
|
||||
// otherwise, the earlier chunks carried the wrong flag — make it visible.
|
||||
tracing::warn!(
|
||||
predicted = idr_hint,
|
||||
actual = keyframe,
|
||||
"NVENC chunked poll: picture type diverged from the submit-time prediction"
|
||||
);
|
||||
}
|
||||
Ok(Some(AuChunk {
|
||||
data,
|
||||
pts_ns,
|
||||
keyframe,
|
||||
recovery_anchor: anchor,
|
||||
chunk_aligned: false,
|
||||
first: !cs.opened,
|
||||
last: true,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) -> bool {
|
||||
// SAFETY: `teardown` requires the encode thread with no NVENC call in flight and a session
|
||||
// whose cached resources belong to `self.encoder` — all hold here (reset is called from the
|
||||
@@ -1683,6 +1958,7 @@ impl Encoder for NvencCudaEncoder {
|
||||
&mut cfg,
|
||||
self.split_mode,
|
||||
false,
|
||||
self.subframe_on,
|
||||
),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -2328,4 +2604,165 @@ mod tests {
|
||||
enc.submit_indexed(&frame, 2).expect("submit follow-up");
|
||||
enc.poll().expect("poll").expect("follow-up AU");
|
||||
}
|
||||
|
||||
/// Every chunk must be cut at an Annex-B NAL boundary (slice starts carry a start code).
|
||||
fn starts_with_start_code(d: &[u8]) -> bool {
|
||||
d.starts_with(&[0, 0, 0, 1]) || d.starts_with(&[0, 0, 1])
|
||||
}
|
||||
|
||||
/// ON-HARDWARE (RTX box `.21`): LN1 Phase 1 — the chunked poll end to end, at the Phase-3
|
||||
/// DEFAULTS (no env knobs: 4 slices + sub-frame readback arm on their own on a
|
||||
/// SUBFRAME_READBACK-capable GPU). `poll_chunk` must (a) report the mode armed, (b) hand
|
||||
/// every AU out as chunks whose first chunk opens the AU with the right metadata and whose
|
||||
/// `last` closes it, (c) cut every chunk at an Annex-B start code, and (d) reassemble byte-
|
||||
/// identically to the finishing blocking lock's AU (enforced by the debug-build shadow check
|
||||
/// inside `poll_chunk` — a mismatch errors the test). At least one frame must actually chunk
|
||||
/// (>1 chunk) — the 5070 Ti probe shows every frame does. Run single-threaded (env vars are
|
||||
/// process-global): `-- --ignored --test-threads=1`.
|
||||
#[test]
|
||||
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
|
||||
fn nvenc_cuda_chunked_poll_end_to_end() {
|
||||
const W: u32 = 1920;
|
||||
const H: u32 = 1080;
|
||||
// Defaults under test — make sure another test's knobs aren't leaking in.
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::Nv12,
|
||||
W,
|
||||
H,
|
||||
60,
|
||||
20_000_000,
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
|
||||
let mut multi_chunk_frames = 0usize;
|
||||
let mut total_chunks = 0usize;
|
||||
for i in 0..6u32 {
|
||||
let frame = nv12_frame(W, H, i);
|
||||
enc.submit_indexed(&frame, i).expect("submit");
|
||||
assert!(
|
||||
enc.supports_chunked_poll(),
|
||||
"4 slices + subframe on a sync session must arm chunked poll"
|
||||
);
|
||||
let mut au = Vec::new();
|
||||
let mut chunks = 0usize;
|
||||
loop {
|
||||
let c = enc
|
||||
.poll_chunk()
|
||||
.expect("poll_chunk")
|
||||
.expect("an AU is in flight — poll_chunk must block, never None");
|
||||
if chunks == 0 {
|
||||
assert!(c.first, "the first chunk must open the AU");
|
||||
assert_eq!(
|
||||
c.keyframe,
|
||||
i == 0,
|
||||
"only the session-opening frame is an IDR"
|
||||
);
|
||||
}
|
||||
assert_eq!(c.pts_ns, i as u64 * 16_666_667, "pts rides every chunk");
|
||||
assert!(!c.recovery_anchor, "no RFI happened");
|
||||
if !c.data.is_empty() {
|
||||
assert!(
|
||||
starts_with_start_code(&c.data),
|
||||
"chunk cut must land on an Annex-B start code (frame {i}, chunk {chunks})"
|
||||
);
|
||||
}
|
||||
au.extend_from_slice(&c.data);
|
||||
chunks += 1;
|
||||
if c.last {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(!au.is_empty(), "frame {i} produced an empty AU");
|
||||
assert!(
|
||||
enc.chunk.is_none(),
|
||||
"chunk state must be cleared once the AU closes"
|
||||
);
|
||||
if chunks > 1 {
|
||||
multi_chunk_frames += 1;
|
||||
}
|
||||
total_chunks += chunks;
|
||||
println!("frame {i}: {chunks} chunks, {} bytes", au.len());
|
||||
}
|
||||
assert!(
|
||||
multi_chunk_frames >= 1,
|
||||
"sub-frame readback yielded no multi-chunk frame — incremental slice readback \
|
||||
regressed (the probe shows ~200 µs slice spacing on this GPU)"
|
||||
);
|
||||
println!(
|
||||
"nvenc_cuda chunked poll: {total_chunks} chunks over 6 frames, \
|
||||
{multi_chunk_frames} frames chunked"
|
||||
);
|
||||
|
||||
// Mode-mix across frames is legal: a fully-drained chunked AU leaves poll() usable.
|
||||
let frame = nv12_frame(W, H, 6);
|
||||
enc.submit_indexed(&frame, 6)
|
||||
.expect("submit plain-poll frame");
|
||||
let au = enc.poll().expect("poll").expect("AU");
|
||||
assert!(!au.data.is_empty());
|
||||
}
|
||||
|
||||
/// ON-HARDWARE (RTX box `.21`): the Phase-3 default-on ESCAPES — `PUNKTFUNK_NVENC_SLICES=1`
|
||||
/// must fully disarm chunked poll (and `poll_chunk` degrades
|
||||
/// to exactly one self-closing whole-AU chunk (the default-path contract every non-chunked
|
||||
/// session shares). Run with `--test-threads=1` (env vars are process-global).
|
||||
#[test]
|
||||
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
|
||||
fn nvenc_cuda_chunked_poll_fallback_whole_au() {
|
||||
const W: u32 = 1280;
|
||||
const H: u32 = 720;
|
||||
struct EnvGuard;
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
}
|
||||
}
|
||||
let _guard = EnvGuard;
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
|
||||
// Escape 1: explicit single slice — no boundaries to cut, chunked poll disarmed.
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SLICES", "1");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
let mut enc = open_h265();
|
||||
let frame = nv12_frame(W, H, 0);
|
||||
enc.submit_indexed(&frame, 0).expect("submit");
|
||||
assert!(
|
||||
!enc.supports_chunked_poll(),
|
||||
"PUNKTFUNK_NVENC_SLICES=1 → chunked poll must not arm"
|
||||
);
|
||||
let c = enc
|
||||
.poll_chunk()
|
||||
.expect("poll_chunk")
|
||||
.expect("whole-AU chunk");
|
||||
assert!(c.first && c.last, "fallback chunk must be self-closing");
|
||||
assert!(c.keyframe, "opening AU is the session IDR");
|
||||
assert!(!c.data.is_empty());
|
||||
assert!(
|
||||
enc.poll_chunk().expect("poll_chunk").is_none(),
|
||||
"nothing in flight → None"
|
||||
);
|
||||
drop(enc);
|
||||
|
||||
// Escape 2: sub-frame readback vetoed — slices stay (default 4) but chunked poll
|
||||
// disarms and the plain poll path carries the session.
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
let mut enc = open_h265();
|
||||
let frame = nv12_frame(W, H, 0);
|
||||
enc.submit_indexed(&frame, 0).expect("submit");
|
||||
assert!(
|
||||
!enc.supports_chunked_poll(),
|
||||
"PUNKTFUNK_NVENC_SUBFRAME=0 → chunked poll must not arm"
|
||||
);
|
||||
let au = enc.poll().expect("poll").expect("AU");
|
||||
assert!(au.keyframe && !au.data.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,9 +37,11 @@ pub(crate) fn fourcc_to_vk(fourcc: u32) -> Option<vk::Format> {
|
||||
const AR24: u32 = 0x3432_5241; // ARGB8888
|
||||
const XB24: u32 = 0x3432_4258; // XBGR8888
|
||||
const AB24: u32 = 0x3432_4241; // ABGR8888
|
||||
const NV12: u32 = 0x3231_564e; // DRM_FORMAT_NV12
|
||||
match fourcc {
|
||||
XR24 | AR24 => Some(vk::Format::B8G8R8A8_UNORM),
|
||||
XB24 | AB24 => Some(vk::Format::R8G8B8A8_UNORM),
|
||||
NV12 => Some(vk::Format::G8_B8R8_2PLANE_420_UNORM),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -90,9 +92,10 @@ pub(crate) unsafe fn import_rgb_dmabuf(
|
||||
)
|
||||
}
|
||||
|
||||
/// [`import_rgb_dmabuf`] with the image usage explicit and an optional video-profile list
|
||||
/// (chained into the image create) — the RGB-direct encode path imports the captured buffer
|
||||
/// as a profiled `VIDEO_ENCODE_SRC` image instead of a sampled one.
|
||||
/// [`import_rgb_dmabuf`] with the image usage explicit and an optional video-profile list.
|
||||
/// Despite the historical name, this also imports gamescope's one-fd LINEAR NV12: the UV
|
||||
/// subresource layout comes from the producer's plane-1 chunk when it reported one, falling
|
||||
/// back to the shared-stride contiguous-plane contract.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) unsafe fn import_rgb_dmabuf_as(
|
||||
device: &ash::Device,
|
||||
@@ -108,12 +111,27 @@ pub(crate) unsafe fn import_rgb_dmabuf_as(
|
||||
use std::os::fd::IntoRawFd;
|
||||
let fmt = fourcc_to_vk(d.fourcc)
|
||||
.with_context(|| format!("unsupported dmabuf fourcc {:#x}", d.fourcc))?;
|
||||
let plane = [vk::SubresourceLayout::default()
|
||||
let planes: Vec<vk::SubresourceLayout> = if fmt == vk::Format::G8_B8R8_2PLANE_420_UNORM {
|
||||
let (uv_offset, uv_stride) = d.plane1.map(|(o, s)| (o as u64, s as u64)).unwrap_or((
|
||||
d.offset as u64 + d.stride as u64 * ch as u64,
|
||||
d.stride as u64,
|
||||
));
|
||||
vec![
|
||||
vk::SubresourceLayout::default()
|
||||
.offset(d.offset as u64)
|
||||
.row_pitch(d.stride as u64)];
|
||||
.row_pitch(d.stride as u64),
|
||||
vk::SubresourceLayout::default()
|
||||
.offset(uv_offset)
|
||||
.row_pitch(uv_stride),
|
||||
]
|
||||
} else {
|
||||
vec![vk::SubresourceLayout::default()
|
||||
.offset(d.offset as u64)
|
||||
.row_pitch(d.stride as u64)]
|
||||
};
|
||||
let mut drm = vk::ImageDrmFormatModifierExplicitCreateInfoEXT::default()
|
||||
.drm_format_modifier(d.modifier)
|
||||
.plane_layouts(&plane);
|
||||
.plane_layouts(&planes);
|
||||
let mut ext = vk::ExternalMemoryImageCreateInfo::default()
|
||||
.handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT);
|
||||
let mut ci = vk::ImageCreateInfo::default()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,38 @@ pub(super) fn codec_guid(codec: Codec) -> nv::GUID {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved per-frame slice count for a session (latency plan §7 LN1, Phase 3): the
|
||||
/// `PUNKTFUNK_NVENC_SLICES` env override wins (1..=32; **1 = the explicit single-slice
|
||||
/// escape**, needed now that a backend can default higher), else the backend's
|
||||
/// `default_slices` — 4 on Linux direct-NVENC since the Phase-3 default-on, 1 everywhere else
|
||||
/// (the Windows async path is deliberately untouched). H.264/HEVC only (AV1 partitions via
|
||||
/// tiles). ONE parse shared by the config author ([`apply_low_latency_config`] via
|
||||
/// [`LowLatencyConfig::slices`]) and the Linux backend's chunked-poll arming, so the two can
|
||||
/// never disagree about whether a session is multi-slice.
|
||||
pub(super) fn resolve_slices(codec: Codec, default_slices: u32) -> u32 {
|
||||
if !matches!(codec, Codec::H264 | Codec::H265) {
|
||||
return 1;
|
||||
}
|
||||
std::env::var("PUNKTFUNK_NVENC_SLICES")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.filter(|n| (1..=32).contains(n))
|
||||
.unwrap_or(default_slices)
|
||||
}
|
||||
|
||||
/// Resolved sub-frame readback (`enableSubFrameWrite` + `reportSliceOffsets`; sync sessions
|
||||
/// only, see [`build_init_params`]): `PUNKTFUNK_NVENC_SUBFRAME` tri-state — `0` = never (the
|
||||
/// default-on escape), `1` = force (even where the caps probe says unsupported — an operator
|
||||
/// explicitly testing), unset = the backend's `default_on` (Linux direct-NVENC passes its
|
||||
/// SUBFRAME_READBACK caps-probe result since Phase 3; Windows passes `false`).
|
||||
pub(super) fn resolve_subframe(default_on: bool) -> bool {
|
||||
match std::env::var("PUNKTFUNK_NVENC_SUBFRAME").as_deref() {
|
||||
Ok("0") => false,
|
||||
Ok("1") => true,
|
||||
_ => default_on,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reference-frame DPB depth when RFI is supported (Apollo uses 5). A deeper DPB lets an invalidated
|
||||
/// reference fall back to an older still-valid frame instead of a full IDR; `numRefL0 = 1` keeps each
|
||||
/// P-frame single-reference for low latency. Also the window the backends' `invalidate_ref_frames`
|
||||
@@ -66,6 +98,9 @@ pub(super) struct LowLatencyConfig {
|
||||
pub hdr: bool,
|
||||
/// This GPU supports reference-frame invalidation (a deeper DPB for graceful loss recovery).
|
||||
pub rfi_supported: bool,
|
||||
/// Resolved per-frame slice count ([`resolve_slices`] — env override, else the backend
|
||||
/// default). ≤ 1 leaves the preset's single slice untouched.
|
||||
pub slices: u32,
|
||||
}
|
||||
|
||||
/// Author the shared `NV_ENC_INITIALIZE_PARAMS` (P1/ULL preset, PTD, the session dimensions/rate)
|
||||
@@ -82,6 +117,7 @@ pub(super) fn build_init_params(
|
||||
cfg: &mut nv::NV_ENC_CONFIG,
|
||||
split_mode: u32,
|
||||
enable_async: bool,
|
||||
subframe: bool,
|
||||
) -> nv::NV_ENC_INITIALIZE_PARAMS {
|
||||
let mut init = nv::NV_ENC_INITIALIZE_PARAMS {
|
||||
version: nv::NV_ENC_INITIALIZE_PARAMS_VER,
|
||||
@@ -101,12 +137,13 @@ pub(super) fn build_init_params(
|
||||
};
|
||||
// splitEncodeMode is a C bitfield — set via the generated accessor, not a struct field.
|
||||
init.set_splitEncodeMode(split_mode);
|
||||
// Sub-frame readback (latency plan §7 LN1 groundwork — EXPERIMENTAL, default off): the driver
|
||||
// writes each slice into the output buffer as it completes and reports per-slice offsets, so a
|
||||
// sync-mode consumer can read slices out while the frame is still encoding. Pair with
|
||||
// `PUNKTFUNK_NVENC_SLICES` (a single-slice frame yields nothing to read early).
|
||||
// `reportSliceOffsets` requires `enableEncodeAsync = 0`, so async (Windows) sessions never arm.
|
||||
if !enable_async && std::env::var("PUNKTFUNK_NVENC_SUBFRAME").as_deref() == Ok("1") {
|
||||
// Sub-frame readback (latency plan §7 LN1; default-on for Linux direct-NVENC since Phase 3 —
|
||||
// the caller resolves `subframe` via [`resolve_subframe`] + its caps probe): the driver
|
||||
// writes each slice into the output buffer as it completes and reports per-slice offsets, so
|
||||
// a sync-mode consumer can read slices out while the frame is still encoding. Pair with
|
||||
// multi-slice (a single-slice frame yields nothing to read early). `reportSliceOffsets`
|
||||
// requires `enableEncodeAsync = 0`, so async (Windows) sessions never arm.
|
||||
if !enable_async && subframe {
|
||||
init.set_enableSubFrameWrite(1);
|
||||
init.set_reportSliceOffsets(1);
|
||||
}
|
||||
@@ -158,16 +195,13 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
|
||||
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
|
||||
}
|
||||
|
||||
// Multi-slice frames (latency plan §7 LN1 groundwork — EXPERIMENTAL, default off = the preset's
|
||||
// single slice): `PUNKTFUNK_NVENC_SLICES=N` (2..=32) splits every frame into N slices
|
||||
// Multi-slice frames (latency plan §7 LN1): `c.slices` splits every frame into N slices
|
||||
// (sliceMode 3 = "N slices per frame"), the unit sub-frame readback ships early and loss
|
||||
// concealment can discard independently. Costs ~1-2 % bitrate in slice headers. H.264/HEVC
|
||||
// only — AV1 partitions via tiles, not slices.
|
||||
if let Some(n) = std::env::var("PUNKTFUNK_NVENC_SLICES")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.filter(|n| (2..=32).contains(n))
|
||||
{
|
||||
// only — AV1 partitions via tiles, not slices (the resolver already returns 1 there).
|
||||
// Default 4 on Linux direct-NVENC (Phase 3), env-only elsewhere; ≤ 1 keeps the preset's
|
||||
// single slice.
|
||||
if let Some(n) = Some(c.slices).filter(|n| *n >= 2) {
|
||||
match c.codec {
|
||||
Codec::H264 => {
|
||||
cfg.encodeCodecConfig.h264Config.sliceMode = 3;
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::nvenc_core::{
|
||||
apply_low_latency_config, build_init_params, codec_guid, LowLatencyConfig, NvStatusExt, RFI_DPB,
|
||||
apply_low_latency_config, build_init_params, codec_guid, resolve_slices, resolve_subframe,
|
||||
LowLatencyConfig, NvStatusExt, RFI_DPB,
|
||||
};
|
||||
use super::nvenc_status;
|
||||
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||
@@ -744,6 +745,10 @@ impl NvencD3d11Encoder {
|
||||
av1_input_depth_minus8: if ten_bit_in { 2 } else { 0 },
|
||||
hdr: self.hdr,
|
||||
rfi_supported: self.rfi_supported,
|
||||
// Env-only on Windows (default single slice): the Phase-3 default-on is a
|
||||
// Linux-direct-NVENC decision — the Windows async path stays untouched, and a
|
||||
// Windows operator opting in must choose slices+sync over async retrieve.
|
||||
slices: resolve_slices(self.codec, 1),
|
||||
},
|
||||
);
|
||||
Ok(cfg)
|
||||
@@ -791,6 +796,9 @@ impl NvencD3d11Encoder {
|
||||
&mut cfg,
|
||||
split_mode,
|
||||
enable_async,
|
||||
// Windows: env opt-in only ("1"), never a default — and build_init_params
|
||||
// additionally refuses it on an async session.
|
||||
resolve_subframe(false),
|
||||
);
|
||||
|
||||
match (api().initialize_encoder)(enc, &mut init).nv_ok() {
|
||||
@@ -1568,6 +1576,7 @@ impl Encoder for NvencD3d11Encoder {
|
||||
&mut cfg,
|
||||
self.split_mode,
|
||||
self.session_async,
|
||||
resolve_subframe(false),
|
||||
),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -478,10 +478,14 @@ fn build_params(cfg: &EncodeConfig) -> ParamSet {
|
||||
b
|
||||
});
|
||||
|
||||
// HDR signalling (10-bit sessions are the HDR path on Windows — same coupling as NVENC):
|
||||
// BT.2020/PQ colour description + the source's mastering/CLL grade at every IDR.
|
||||
// Colour signalling, written UNCONDITIONALLY (mirrors nvenc_core.rs): the input is already
|
||||
// CSC'd to a specific matrix — BT.709 limited for SDR (the capture-side VideoConverter),
|
||||
// BT.2020 PQ for HDR (HdrP010Converter) — so the stream must say so. An SDR stream without a
|
||||
// colour description leaves the choice to the decoder's "unspecified" default, and
|
||||
// Moonlight/third-party/Android-vendor decoders default to 601 at sub-HD → mis-rendered
|
||||
// colours. (10-bit sessions are the HDR path on Windows — same coupling as NVENC.)
|
||||
let hdr = cfg.ten_bit && cfg.codec != Codec::H264;
|
||||
let vsi = hdr.then(|| {
|
||||
let vsi = {
|
||||
// SAFETY: all-zero is valid; header stamped below.
|
||||
let mut b: Box<vpl::mfxExtVideoSignalInfo> = Box::new(unsafe { std::mem::zeroed() });
|
||||
b.Header.BufferId = vpl::MFX_EXTBUFF_VIDEO_SIGNAL_INFO as u32;
|
||||
@@ -489,11 +493,17 @@ fn build_params(cfg: &EncodeConfig) -> ParamSet {
|
||||
b.VideoFormat = 5; // unspecified
|
||||
b.VideoFullRange = 0;
|
||||
b.ColourDescriptionPresent = 1;
|
||||
if hdr {
|
||||
b.ColourPrimaries = 9; // BT.2020
|
||||
b.TransferCharacteristics = 16; // SMPTE ST 2084 (PQ)
|
||||
b.MatrixCoefficients = 9; // BT.2020 non-constant
|
||||
b
|
||||
});
|
||||
} else {
|
||||
b.ColourPrimaries = 1; // BT.709
|
||||
b.TransferCharacteristics = 1; // BT.709
|
||||
b.MatrixCoefficients = 1; // BT.709
|
||||
}
|
||||
Some(b)
|
||||
};
|
||||
let mastering = cfg.hdr_meta.filter(|_| hdr).map(|m| {
|
||||
// SAFETY: all-zero is valid; header stamped below.
|
||||
let mut b: Box<vpl::mfxExtMasteringDisplayColourVolume> =
|
||||
@@ -1994,4 +2004,246 @@ mod tests {
|
||||
"the bitrate retarget emitted a keyframe (StartNewSequence leak)"
|
||||
);
|
||||
}
|
||||
|
||||
/// FULL-CHAIN colour check at the field capture size: a known P010 colour-bar source at
|
||||
/// 1920x1080 — whose height is NOT 16-aligned, so the ingest `CopySubresourceRegion` copies
|
||||
/// into a 1920x1088 runtime pool surface whose chroma plane sits at a DIFFERENT row offset
|
||||
/// than the source's (the seam no 640x480 test exercises) — encoded to Main10 HEVC and
|
||||
/// dumped to `%TEMP%\pf_qsv_1080_bars.h265` for off-box decode verification against the
|
||||
/// same codes. On-box this asserts stream shape; the pixel verdict needs a decoder.
|
||||
#[test]
|
||||
fn qsv_live_p010_1080_colorbars_dump() {
|
||||
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11CreateDevice, D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE,
|
||||
D3D11_SDK_VERSION, D3D11_SUBRESOURCE_DATA, D3D11_USAGE_DEFAULT,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_P010, DXGI_SAMPLE_DESC};
|
||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
|
||||
|
||||
// (Y, Cb, Cr) 10-bit limited codes for the 8 sRGB bars white/yellow/cyan/green/magenta/
|
||||
// red/blue/black at 80-nit SDR white under PQ/BT.2020 — the same math as pf-capture's
|
||||
// `p010_reference` (and the bars_pq2020 client fixture). Stored MSB-aligned (`<<6`).
|
||||
const BARS: [(u16, u16, u16); 8] = [
|
||||
(490, 512, 512),
|
||||
(478, 423, 518),
|
||||
(464, 525, 473),
|
||||
(450, 432, 476),
|
||||
(350, 584, 585),
|
||||
(325, 448, 598),
|
||||
(226, 650, 535),
|
||||
(64, 512, 512),
|
||||
];
|
||||
const W: u32 = 1920;
|
||||
const H: u32 = 1080;
|
||||
|
||||
init_tracing();
|
||||
let Ok((_l, impls)) = intel_loader() else {
|
||||
eprintln!("skipping: no VPL loader");
|
||||
return;
|
||||
};
|
||||
let Some(imp) = impls.iter().find(|i| i.luid_valid) else {
|
||||
eprintln!("skipping: no Intel VPL implementation on this box");
|
||||
return;
|
||||
};
|
||||
if !probe_can_encode_10bit(Codec::H265) {
|
||||
eprintln!("skipping: this GPU declines 10-bit HEVC");
|
||||
return;
|
||||
}
|
||||
|
||||
// P010 initial data: plane 0 = H rows of W u16 luma; plane 1 = H/2 rows of W u16
|
||||
// (interleaved Cb,Cr pairs), same pitch. Bars are vertical: bar index = x / (W/8).
|
||||
let bar_w = (W / 8) as usize;
|
||||
let mut init = vec![0u16; (W as usize) * (H as usize + H as usize / 2)];
|
||||
for y in 0..H as usize {
|
||||
for x in 0..W as usize {
|
||||
init[y * W as usize + x] = BARS[(x / bar_w).min(7)].0 << 6;
|
||||
}
|
||||
}
|
||||
let chroma_base = (W as usize) * (H as usize);
|
||||
for cy in 0..(H as usize / 2) {
|
||||
for cx in 0..(W as usize / 2) {
|
||||
let (_, cb, cr) = BARS[((cx * 2) / bar_w).min(7)];
|
||||
init[chroma_base + cy * W as usize + cx * 2] = cb << 6;
|
||||
init[chroma_base + cy * W as usize + cx * 2 + 1] = cr << 6;
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: self-contained harness on one thread/device (same contract as `drive_live`);
|
||||
// the initial-data pointer outlives the synchronous CreateTexture2D that reads it.
|
||||
let (device, tex) = unsafe {
|
||||
let luid = windows::Win32::Foundation::LUID {
|
||||
LowPart: u32::from_le_bytes(imp.luid[..4].try_into().unwrap()),
|
||||
HighPart: i32::from_le_bytes(imp.luid[4..].try_into().unwrap()),
|
||||
};
|
||||
let factory: IDXGIFactory4 = CreateDXGIFactory1().expect("dxgi factory");
|
||||
let adapter: IDXGIAdapter1 = factory.EnumAdapterByLuid(luid).expect("intel adapter");
|
||||
let mut device = None;
|
||||
D3D11CreateDevice(
|
||||
&adapter,
|
||||
D3D_DRIVER_TYPE_UNKNOWN,
|
||||
windows::Win32::Foundation::HMODULE::default(),
|
||||
Default::default(),
|
||||
None,
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("d3d11 device on intel adapter");
|
||||
let device: ID3D11Device = device.expect("device");
|
||||
let desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: W,
|
||||
Height: H,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_P010,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
|
||||
CPUAccessFlags: 0,
|
||||
MiscFlags: 0,
|
||||
};
|
||||
let data = D3D11_SUBRESOURCE_DATA {
|
||||
pSysMem: init.as_ptr() as *const std::ffi::c_void,
|
||||
SysMemPitch: W * 2,
|
||||
SysMemSlicePitch: 0,
|
||||
};
|
||||
let mut t: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&desc, Some(&data), Some(&mut t))
|
||||
.expect("bar texture");
|
||||
(device.clone(), t.expect("texture"))
|
||||
};
|
||||
|
||||
let mut enc = QsvEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::P010,
|
||||
W,
|
||||
H,
|
||||
30,
|
||||
10_000_000,
|
||||
10,
|
||||
ChromaFormat::Yuv420,
|
||||
)
|
||||
.expect("open");
|
||||
enc.set_hdr_meta(Some(test_hdr_meta()));
|
||||
let mut stream = Vec::new();
|
||||
let mut aus = 0usize;
|
||||
let mut keyframes = 0usize;
|
||||
for i in 0..12u32 {
|
||||
let frame = CapturedFrame {
|
||||
width: W,
|
||||
height: H,
|
||||
pts_ns: i as u64 * 33_333_333,
|
||||
format: PixelFormat::P010,
|
||||
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
||||
texture: tex.clone(),
|
||||
device: device.clone(),
|
||||
pyro: None,
|
||||
}),
|
||||
cursor: None,
|
||||
};
|
||||
enc.submit_indexed(&frame, i).expect("submit");
|
||||
if let Some(au) = enc.poll().expect("poll") {
|
||||
aus += 1;
|
||||
keyframes += au.keyframe as usize;
|
||||
stream.extend_from_slice(&au.data);
|
||||
}
|
||||
}
|
||||
enc.flush().expect("flush");
|
||||
while let Some(au) = enc.poll().expect("drain") {
|
||||
aus += 1;
|
||||
keyframes += au.keyframe as usize;
|
||||
stream.extend_from_slice(&au.data);
|
||||
}
|
||||
assert!(aus >= 10, "expected ≥10 AUs, got {aus}");
|
||||
assert!(keyframes >= 1, "expected an IDR in the dump");
|
||||
let path = std::env::temp_dir().join("pf_qsv_1080_bars.h265");
|
||||
std::fs::write(&path, &stream).expect("write dump");
|
||||
println!(
|
||||
"wrote {} AUs ({} bytes, {keyframes} keyframes) to {}",
|
||||
aus,
|
||||
stream.len(),
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
/// The PRODUCTION host chain minus the IDD ring: the REAL `HdrP010Converter` renders the 8
|
||||
/// sRGB bars into a ring-profile P010 texture (`BIND_RENDER_TARGET` only — RTV-written, not
|
||||
/// CPU-uploaded) on the VPL implementation's own adapter, and THAT texture goes through the
|
||||
/// unaligned-height ingest copy into a Main10 encode. Dumped to
|
||||
/// `%TEMP%\pf_qsv_conv_1080_bars.h265`; expected decode codes = the bars_pq2020 fixture set
|
||||
/// (see `hdr_p010_convert_bars_on_luid`).
|
||||
#[test]
|
||||
fn qsv_live_hdr_converter_e2e_1080_dump() {
|
||||
const W: u32 = 1920;
|
||||
const H: u32 = 1080;
|
||||
|
||||
init_tracing();
|
||||
let Ok((_l, impls)) = intel_loader() else {
|
||||
eprintln!("skipping: no VPL loader");
|
||||
return;
|
||||
};
|
||||
let Some(imp) = impls.iter().find(|i| i.luid_valid) else {
|
||||
eprintln!("skipping: no Intel VPL implementation on this box");
|
||||
return;
|
||||
};
|
||||
if !probe_can_encode_10bit(Codec::H265) {
|
||||
eprintln!("skipping: this GPU declines 10-bit HEVC");
|
||||
return;
|
||||
}
|
||||
let (device, tex) = pf_capture::dxgi::hdr_p010_convert_bars_on_luid(imp.luid, W, H)
|
||||
.expect("converter bars");
|
||||
|
||||
let mut enc = QsvEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::P010,
|
||||
W,
|
||||
H,
|
||||
30,
|
||||
10_000_000,
|
||||
10,
|
||||
ChromaFormat::Yuv420,
|
||||
)
|
||||
.expect("open");
|
||||
enc.set_hdr_meta(Some(test_hdr_meta()));
|
||||
let mut stream = Vec::new();
|
||||
let mut aus = 0usize;
|
||||
for i in 0..12u32 {
|
||||
let frame = CapturedFrame {
|
||||
width: W,
|
||||
height: H,
|
||||
pts_ns: i as u64 * 33_333_333,
|
||||
format: PixelFormat::P010,
|
||||
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
||||
texture: tex.clone(),
|
||||
device: device.clone(),
|
||||
pyro: None,
|
||||
}),
|
||||
cursor: None,
|
||||
};
|
||||
enc.submit_indexed(&frame, i).expect("submit");
|
||||
if let Some(au) = enc.poll().expect("poll") {
|
||||
aus += 1;
|
||||
stream.extend_from_slice(&au.data);
|
||||
}
|
||||
}
|
||||
enc.flush().expect("flush");
|
||||
while let Some(au) = enc.poll().expect("drain") {
|
||||
aus += 1;
|
||||
stream.extend_from_slice(&au.data);
|
||||
}
|
||||
assert!(aus >= 10, "expected ≥10 AUs, got {aus}");
|
||||
let path = std::env::temp_dir().join("pf_qsv_conv_1080_bars.h265");
|
||||
std::fs::write(&path, &stream).expect("write dump");
|
||||
println!(
|
||||
"wrote {aus} AUs ({} bytes) to {}",
|
||||
stream.len(),
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,9 @@ pub fn open_video(
|
||||
cuda: bool,
|
||||
bit_depth: u8,
|
||||
chroma: ChromaFormat,
|
||||
// The session may hand this encoder cursor bitmaps to composite (cursor-as-metadata
|
||||
// captures). Backends whose fast path can't blend (Vulkan EFC RGB-direct) key off it.
|
||||
cursor_blend: bool,
|
||||
) -> Result<Box<dyn Encoder>> {
|
||||
let (inner, backend) = open_video_backend(
|
||||
codec,
|
||||
@@ -140,6 +143,7 @@ pub fn open_video(
|
||||
cuda,
|
||||
bit_depth,
|
||||
chroma,
|
||||
cursor_blend,
|
||||
)?;
|
||||
// Record what this session encodes on (the mgmt API's "currently used GPU"): the backend label
|
||||
// is reported by `open_video_backend` from the branch that ACTUALLY opened — not re-derived by
|
||||
@@ -203,6 +207,12 @@ impl Encoder for TrackedEncoder {
|
||||
fn invalidate_ref_frames(&mut self, first_frame: i64, last_frame: i64) -> bool {
|
||||
self.inner.invalidate_ref_frames(first_frame, last_frame)
|
||||
}
|
||||
// Forwarded for the same reason as `set_wire_chunking` below — the unforwarded default
|
||||
// (`false` = "backend can't pipeline, stop asking") silently killed the §7 LN3 contention
|
||||
// escalation for every session, since the host loop only ever holds the wrapped box.
|
||||
fn set_pipelined(&mut self, on: bool) -> bool {
|
||||
self.inner.set_pipelined(on)
|
||||
}
|
||||
// The classic TrackedEncoder trap: a defaulted trait method that isn't forwarded
|
||||
// silently no-ops through the wrapper (bit the direct-NVENC work, then THIS — the
|
||||
// §4.4 chunking probe run hit the default while the plan said Some(1408)).
|
||||
@@ -217,6 +227,14 @@ impl Encoder for TrackedEncoder {
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||
self.inner.poll()
|
||||
}
|
||||
// Both chunked-poll methods forwarded (the same trap class): the defaults would report
|
||||
// "not chunked" and wrap whole AUs, silently discarding the sub-frame overlap.
|
||||
fn supports_chunked_poll(&self) -> bool {
|
||||
self.inner.supports_chunked_poll()
|
||||
}
|
||||
fn poll_chunk(&mut self) -> Result<Option<AuChunk>> {
|
||||
self.inner.poll_chunk()
|
||||
}
|
||||
fn reset(&mut self) -> bool {
|
||||
self.inner.reset()
|
||||
}
|
||||
@@ -250,7 +268,9 @@ fn open_video_backend(
|
||||
cuda: bool,
|
||||
bit_depth: u8,
|
||||
chroma: ChromaFormat,
|
||||
cursor_blend: bool,
|
||||
) -> Result<(Box<dyn Encoder>, &'static str)> {
|
||||
let _ = cursor_blend; // consumed only by the Linux vulkan-encode arm below
|
||||
validate_dimensions(codec, width, height)?;
|
||||
// Refresh/fps must be positive and sane: fps feeds the encoder time_base (`Rational(1, fps)`)
|
||||
// and the pts→ns conversion (`pts * 1e9 / fps`), so 0 builds a 1/0 rational / divides by zero.
|
||||
@@ -308,8 +328,15 @@ fn open_video_backend(
|
||||
&& vulkan_encode_enabled()
|
||||
&& !(bit_depth == 10 && format.is_hdr_rgb10())
|
||||
{
|
||||
match vulkan_video::VulkanVideoEncoder::open(codec, width, height, fps, bitrate_bps)
|
||||
{
|
||||
match vulkan_video::VulkanVideoEncoder::open(
|
||||
codec,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
cursor_blend,
|
||||
) {
|
||||
Ok(e) => {
|
||||
tracing::info!(
|
||||
codec = ?codec,
|
||||
@@ -318,12 +345,32 @@ fn open_video_backend(
|
||||
);
|
||||
return Ok((Box::new(e) as Box<dyn Encoder>, "vulkan"));
|
||||
}
|
||||
// Native NV12 (PUNKTFUNK_PIPEWIRE_NV12 capture) has no VAAPI fallback:
|
||||
// libav's dmabuf lane would import the two-plane buffer as packed RGB
|
||||
// (silent garbage) and its CPU lane bails per frame — die crisply instead.
|
||||
Err(e) if format == PixelFormat::Nv12 => {
|
||||
return Err(e.context(
|
||||
"Vulkan Video open failed on a native-NV12 capture \
|
||||
— no VAAPI fallback exists; set PUNKTFUNK_PIPEWIRE_NV12=0 to \
|
||||
restore the packed-RGB negotiation",
|
||||
));
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"Vulkan Video encode open failed — falling back to libav VAAPI"
|
||||
),
|
||||
}
|
||||
}
|
||||
// Same rule when the Vulkan backend was never eligible (H264 session,
|
||||
// PUNKTFUNK_VULKAN_ENCODE=0, or a build without the feature).
|
||||
if format == PixelFormat::Nv12 {
|
||||
anyhow::bail!(
|
||||
"native NV12 capture requires the Vulkan Video encoder (HEVC/AV1 \
|
||||
session, --features vulkan-encode, PUNKTFUNK_VULKAN_ENCODE not 0) — this \
|
||||
session resolved to libav VAAPI; set PUNKTFUNK_PIPEWIRE_NV12=0 to restore \
|
||||
the packed-RGB negotiation"
|
||||
);
|
||||
}
|
||||
vaapi::VaapiEncoder::open(
|
||||
codec,
|
||||
format,
|
||||
@@ -362,7 +409,15 @@ fn open_video_backend(
|
||||
"the Vulkan Video encoder supports HEVC + AV1; the session negotiated {codec:?}"
|
||||
);
|
||||
}
|
||||
vulkan_video::VulkanVideoEncoder::open(codec, width, height, fps, bitrate_bps)
|
||||
vulkan_video::VulkanVideoEncoder::open(
|
||||
codec,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
cursor_blend,
|
||||
)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "vulkan"))
|
||||
}
|
||||
#[cfg(not(feature = "vulkan-encode"))]
|
||||
@@ -786,6 +841,33 @@ fn vulkan_encode_enabled() -> bool {
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Whether THIS session's encoder can ingest a producer-native NV12 capture: only the raw
|
||||
/// Vulkan Video backend does (libav VAAPI would misread the two-plane buffer as packed RGB —
|
||||
/// [`open_video`] refuses the combination), so the session's codec must be one it encodes and
|
||||
/// the backend must be eligible to open. The host facade threads the verdict into the capture
|
||||
/// negotiation (`OutputFormat::nv12_native` → `ZeroCopyPolicy::native_nv12_session`), which
|
||||
/// then PREFERS gamescope's producer-side NV12 pod (default-on; `PUNKTFUNK_PIPEWIRE_NV12=0`
|
||||
/// escapes at the capture gate).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn linux_native_nv12_ok(codec: Codec) -> bool {
|
||||
#[cfg(feature = "vulkan-encode")]
|
||||
{
|
||||
matches!(codec, Codec::H265 | Codec::Av1)
|
||||
&& vulkan_encode_enabled()
|
||||
// NVENC/PyroWave prefs never open the Vulkan Video backend; every other pref
|
||||
// (auto/vaapi/amd/intel/vulkan) tries it first on AMD/Intel — see [`open_video`].
|
||||
&& !matches!(
|
||||
pf_host_config::config().encoder_pref.as_str(),
|
||||
"nvenc" | "nvidia" | "cuda" | "pyrowave"
|
||||
)
|
||||
}
|
||||
#[cfg(not(feature = "vulkan-encode"))]
|
||||
{
|
||||
let _ = codec;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Cheap, side-effect-free NVIDIA-presence probe for the `auto` backend selector: the NVIDIA
|
||||
/// kernel driver exposes these device nodes, AMD/Intel boxes have neither. Deliberately does NOT
|
||||
/// create a CUDA context (that would allocate GPU state on every host that merely *might* be
|
||||
|
||||
+31
-10
@@ -105,13 +105,15 @@ pub fn drm_fourcc(format: PixelFormat) -> Option<u32> {
|
||||
Bgra => drm_fourcc_code(b"AR24"), // DRM_FORMAT_ARGB8888
|
||||
Rgbx => drm_fourcc_code(b"XB24"), // DRM_FORMAT_XBGR8888
|
||||
Rgba => drm_fourcc_code(b"AB24"), // DRM_FORMAT_ABGR8888
|
||||
// Linux native NV12 capture (gamescope PipeWire): one LINEAR dmabuf with contiguous Y then
|
||||
// interleaved UV, exposed under DRM_FORMAT_NV12.
|
||||
Nv12 => drm_fourcc_code(b"NV12"),
|
||||
// The GNOME 50+ HDR screencast formats (packed 2:10:10:10, PQ/BT.2020).
|
||||
X2Rgb10 => drm_fourcc_code(b"XR30"), // DRM_FORMAT_XRGB2101010
|
||||
X2Bgr10 => drm_fourcc_code(b"XB30"), // DRM_FORMAT_XBGR2101010
|
||||
// 24-bit packed RGB/BGR have no straightforward dmabuf import here; use the CPU path.
|
||||
// Rgb10a2/Nv12/P010 are the Windows HDR / video-processor formats — never produced on
|
||||
// Linux; Yuv444 is OUR convert's OUTPUT, never a capture source format.
|
||||
Rgb | Bgr | Rgb10a2 | Nv12 | P010 | Yuv444 => return None,
|
||||
// Rgb10a2/P010 are Windows formats; Yuv444 is OUR convert output, never a capture source.
|
||||
Rgb | Bgr | Rgb10a2 | P010 | Yuv444 => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -144,6 +146,12 @@ pub struct OutputFormat {
|
||||
/// (never BGRA-passthrough / P010). `false` on every non-PyroWave session and on Linux (the
|
||||
/// wavelet encoder ingests dmabufs / CPU RGB there, not a D3D11 texture).
|
||||
pub pyrowave: bool,
|
||||
/// THIS session's encoder can ingest a producer-native NV12 capture (Linux raw Vulkan Video
|
||||
/// backend on an H265/AV1 session — see `pf_encode::linux_native_nv12_ok`). The Linux capture
|
||||
/// negotiation only offers gamescope the NV12 pod when this is set: libav VAAPI (the H264
|
||||
/// codec's backend, and the fallback family) would misread the two-plane buffer as packed
|
||||
/// RGB. Always `false` on Windows (the IDD-push capturer owns its own formats).
|
||||
pub nv12_native: bool,
|
||||
}
|
||||
|
||||
impl OutputFormat {
|
||||
@@ -161,6 +169,11 @@ impl OutputFormat {
|
||||
chroma_444: false,
|
||||
// GameStream never negotiates PyroWave (native punktfunk/1 only).
|
||||
pyrowave: false,
|
||||
// Conservative: the GameStream + spike paths don't resolve the codec here, and a
|
||||
// Moonlight client may negotiate H264 (whose VAAPI backend can't ingest NV12) — so
|
||||
// they never prefer the producer-native NV12 pod. The punktfunk/1 plane opts in via
|
||||
// `SessionPlan::output_format()`, which knows the codec.
|
||||
nv12_native: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,18 +214,26 @@ pub struct CapturedFrame {
|
||||
pub cursor: Option<CursorOverlay>,
|
||||
}
|
||||
|
||||
/// A captured frame still living in a single-plane packed-RGB dmabuf (the VAAPI zero-copy path).
|
||||
/// A captured frame still living in a DMA-BUF. Packed RGB uses one plane. Native Linux NV12
|
||||
/// (gamescope PipeWire) travels in ONE fd: Y starts at `offset`, and the interleaved UV plane
|
||||
/// lives at `plane1`'s offset/stride when the producer reported them — else at the contiguous
|
||||
/// fallback `offset + stride * frame_height` with the shared `stride`.
|
||||
///
|
||||
/// Owns a *dup* of the PipeWire buffer's fd, so the frame can travel to the encode thread and be
|
||||
/// imported into a VA surface there without the compositor's buffer being closed underneath it.
|
||||
/// (Content stability across the brief import window relies on the compositor's buffer pool depth,
|
||||
/// same as any zero-copy capture — the VAAPI importer copies into its own NV12 surface promptly.)
|
||||
/// imported there without the compositor's buffer being closed underneath it. Content stability
|
||||
/// across the brief import window relies on the compositor's buffer pool depth, like any zero-copy
|
||||
/// capture.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub struct DmabufFrame {
|
||||
pub fd: std::os::fd::OwnedFd,
|
||||
/// DRM FourCC of the packed-RGB plane (e.g. `XR24` for BGRx).
|
||||
/// DRM FourCC (`XR24` for BGRx, `NV12` for native 4:2:0).
|
||||
pub fourcc: u32,
|
||||
/// DRM format modifier the compositor allocated (0 = LINEAR).
|
||||
pub modifier: u64,
|
||||
/// Second-plane `(offset, stride)` within the SAME fd, when the producer reported one (the
|
||||
/// PipeWire buffer's plane-1 chunk — NV12's interleaved UV). `None` falls back to the
|
||||
/// contiguous-plane contract above. Always `None` for single-plane packed RGB.
|
||||
pub plane1: Option<(u32, u32)>,
|
||||
pub offset: u32,
|
||||
pub stride: u32,
|
||||
}
|
||||
@@ -225,8 +246,8 @@ pub enum FramePayload {
|
||||
/// The dmabuf has already been imported + copied into this owned device buffer.
|
||||
#[cfg(target_os = "linux")]
|
||||
Cuda(pf_zerocopy::DeviceBuffer),
|
||||
/// A raw packed-RGB dmabuf — the AMD/Intel (VAAPI) zero-copy path. The encoder imports it into
|
||||
/// a VA surface and does RGB→NV12 on the GPU video engine (no host CSC, no upload).
|
||||
/// A raw DMA-BUF: packed RGB for the existing GPU CSC paths, or native NV12 from a producer
|
||||
/// such as gamescope. The encoder imports it without a host copy.
|
||||
#[cfg(target_os = "linux")]
|
||||
Dmabuf(DmabufFrame),
|
||||
/// A GPU-resident D3D11 texture (Windows zero-copy path for NVENC). Owns the copied frame.
|
||||
|
||||
@@ -63,6 +63,13 @@ pub struct HostConfig {
|
||||
/// deliver full chroma, and the GPU/driver passed the encode probe — otherwise 4:2:0.
|
||||
/// `PUNKTFUNK_444=0`/`false`/`off`/`no` disables. Independent of `ten_bit` (chroma vs depth).
|
||||
pub four_four_four: bool,
|
||||
/// `PUNKTFUNK_CHACHA20` — host policy gate for the negotiated ChaCha20-Poly1305 session
|
||||
/// cipher (design/chacha20-session-cipher.md). **Default ON** (pure rollout safety — perf-only,
|
||||
/// both AEADs are full-strength): the host merely *allows* it — a session only seals with
|
||||
/// ChaCha when the client advertised `VIDEO_CAP_CHACHA20` (set by soft-AES armv7 clients,
|
||||
/// 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_PERF` — per-stage timing instrumentation.
|
||||
pub perf: bool,
|
||||
/// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select (`virtual` / `portal` / unset → synthetic).
|
||||
@@ -147,6 +154,16 @@ impl HostConfig {
|
||||
)
|
||||
})
|
||||
.unwrap_or(true),
|
||||
// Default ON, explicit-off grammar (the client's VIDEO_CAP_CHACHA20 bit is the real
|
||||
// per-session switch; see the field doc).
|
||||
chacha20: val("PUNKTFUNK_CHACHA20")
|
||||
.map(|s| {
|
||||
!matches!(
|
||||
s.trim().to_ascii_lowercase().as_str(),
|
||||
"0" | "false" | "off" | "no"
|
||||
)
|
||||
})
|
||||
.unwrap_or(true),
|
||||
perf: flag("PUNKTFUNK_PERF"),
|
||||
video_source: val("PUNKTFUNK_VIDEO_SOURCE"),
|
||||
compositor: val("PUNKTFUNK_COMPOSITOR"),
|
||||
|
||||
@@ -14,10 +14,17 @@
|
||||
//! Keys are SDL scancodes → VK via `keymap_sdl`, layout-independent. Motion deltas are
|
||||
//! COALESCED: one summed `MouseMove` per loop iteration (a 1000 Hz mouse would
|
||||
//! otherwise send a datagram per event).
|
||||
//!
|
||||
//! The DESKTOP mouse model (design/remote-desktop-sweep.md M1) reuses this same engage/
|
||||
//! release state but never locks the pointer: the local cursor moves freely (hidden over
|
||||
//! the window — the host's composited cursor is the one you see) and motion goes on the
|
||||
//! wire as absolute positions through the letterbox (`MouseMoveAbs`, latest-wins per loop
|
||||
//! iteration). Requires a host injector with absolute support — gamescope's EIS is
|
||||
//! relative-only, so sessions there are pinned to capture ([`Capture::new`] `abs_ok`).
|
||||
|
||||
use crate::keymap_sdl;
|
||||
use crate::touch::{Abs, Act, Gestures};
|
||||
use pf_client_core::trust::TouchMode;
|
||||
use pf_client_core::trust::{MouseMode, TouchMode};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -41,6 +48,13 @@ pub struct Capture {
|
||||
held_buttons: HashSet<u32>,
|
||||
/// Relative motion not yet on the wire, summed per loop iteration.
|
||||
pending_rel: (i32, i32),
|
||||
/// Desktop-model position not yet on the wire, latest-wins per loop iteration.
|
||||
pending_abs: Option<Abs>,
|
||||
/// The desktop (absolute, uncaptured) mouse model is active. Flipped live by the
|
||||
/// Ctrl+Alt+Shift+M chord; never true unless `abs_ok`.
|
||||
desktop: bool,
|
||||
/// The host injector accepts `MouseMoveAbs` (any compositor but gamescope).
|
||||
abs_ok: bool,
|
||||
/// Fractional wheel remainder per axis (x, y) in 120-unit WHEEL_DELTA space —
|
||||
/// precision surfaces deliver sub-unit deltas; truncating each event drops the tail.
|
||||
scroll_acc: (f64, f64),
|
||||
@@ -70,10 +84,14 @@ fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, fl
|
||||
}
|
||||
|
||||
impl Capture {
|
||||
/// `abs_ok` = the host injector accepts absolute pointer events; without it the
|
||||
/// desktop model is unavailable and `mouse_mode` silently resolves to capture.
|
||||
pub fn new(
|
||||
connector: Arc<NativeClient>,
|
||||
touch_mode: TouchMode,
|
||||
invert_scroll: bool,
|
||||
mouse_mode: MouseMode,
|
||||
abs_ok: bool,
|
||||
) -> Capture {
|
||||
Capture {
|
||||
connector,
|
||||
@@ -82,6 +100,9 @@ impl Capture {
|
||||
held_keys: HashSet::new(),
|
||||
held_buttons: HashSet::new(),
|
||||
pending_rel: (0, 0),
|
||||
pending_abs: None,
|
||||
desktop: abs_ok && mouse_mode == MouseMode::Desktop,
|
||||
abs_ok,
|
||||
scroll_acc: (0.0, 0.0),
|
||||
touch_slots: HashMap::new(),
|
||||
touch_mode,
|
||||
@@ -94,6 +115,24 @@ impl Capture {
|
||||
self.captured
|
||||
}
|
||||
|
||||
/// The desktop (absolute, uncaptured) mouse model is active.
|
||||
pub fn desktop(&self) -> bool {
|
||||
self.desktop
|
||||
}
|
||||
|
||||
/// Flip capture ⇄ desktop (the Ctrl+Alt+Shift+M chord). `None` = the host can't take
|
||||
/// absolute pointer events (gamescope), so the chord has nothing to offer; otherwise
|
||||
/// the new desktop state. Motion gathered under the old model never crosses modes.
|
||||
pub fn toggle_desktop(&mut self) -> Option<bool> {
|
||||
if !self.abs_ok {
|
||||
return None;
|
||||
}
|
||||
self.desktop = !self.desktop;
|
||||
self.pending_rel = (0, 0);
|
||||
self.pending_abs = None;
|
||||
Some(self.desktop)
|
||||
}
|
||||
|
||||
/// Whether a regained focus should re-engage: yes unless the user released
|
||||
/// deliberately (the chord keeps its meaning across an Alt-Tab).
|
||||
pub fn should_reengage(&self) -> bool {
|
||||
@@ -117,6 +156,7 @@ impl Capture {
|
||||
return false;
|
||||
}
|
||||
self.pending_rel = (0, 0); // never flush motion gathered while captured
|
||||
self.pending_abs = None;
|
||||
for vk in self.held_keys.drain() {
|
||||
send(&self.connector, InputKind::KeyUp, vk as u32, 0, 0, 0);
|
||||
}
|
||||
@@ -132,22 +172,42 @@ impl Capture {
|
||||
true
|
||||
}
|
||||
|
||||
/// Forward the coalesced motion delta, if any — one datagram per loop iteration.
|
||||
/// Forward the coalesced motion, if any — one datagram per loop iteration. Only one
|
||||
/// of the two stores is ever populated (the run loop routes by [`desktop`](Self::desktop)).
|
||||
pub fn flush_motion(&mut self) {
|
||||
let (dx, dy) = std::mem::take(&mut self.pending_rel);
|
||||
if dx != 0 || dy != 0 {
|
||||
send(&self.connector, InputKind::MouseMove, 0, dx, dy, 0);
|
||||
}
|
||||
if let Some(a) = self.pending_abs.take() {
|
||||
send(
|
||||
&self.connector,
|
||||
InputKind::MouseMoveAbs,
|
||||
0,
|
||||
a.x,
|
||||
a.y,
|
||||
Self::touch_flags(a.w, a.h),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Relative motion (SDL relative mouse mode delivers raw deltas while locked).
|
||||
pub fn on_motion(&mut self, xrel: f32, yrel: f32) {
|
||||
if self.captured {
|
||||
if self.captured && !self.desktop {
|
||||
self.pending_rel.0 += xrel as i32;
|
||||
self.pending_rel.1 += yrel as i32;
|
||||
}
|
||||
}
|
||||
|
||||
/// Desktop-model motion: the cursor's position mapped into the letterboxed content
|
||||
/// rect. Latest-wins — intermediate positions carry no information the final one
|
||||
/// doesn't (unlike deltas, which must sum).
|
||||
pub fn on_motion_abs(&mut self, abs: Abs) {
|
||||
if self.captured && self.desktop {
|
||||
self.pending_abs = Some(abs);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_key_down(&mut self, sc: sdl3::keyboard::Scancode) {
|
||||
if !self.captured {
|
||||
return;
|
||||
|
||||
+102
-20
@@ -20,11 +20,11 @@ use crate::vk::{FrameInput, Presenter};
|
||||
use anyhow::{Context as _, Result};
|
||||
use pf_client_core::gamepad::GamepadService;
|
||||
use pf_client_core::session::{self, SessionEvent, SessionHandle, SessionParams, Stats};
|
||||
use pf_client_core::trust::{StatsVerbosity, TouchMode};
|
||||
use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode};
|
||||
use pf_client_core::video::VulkanDecodeDevice;
|
||||
use pf_client_core::video::{DecodedFrame, DecodedImage};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::config::Mode;
|
||||
use punktfunk_core::config::{CompositorPref, Mode};
|
||||
use sdl3::event::{Event, WindowEvent};
|
||||
use sdl3::keyboard::Mod;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -48,6 +48,11 @@ pub struct SessionOpts {
|
||||
/// `Pointer` (absolute cursor), or `Touch` (real multi-touch passthrough). Latched per
|
||||
/// session — a mouse-only client leaves this at the default and never sees a finger.
|
||||
pub touch_mode: TouchMode,
|
||||
/// Physical-mouse model: `Capture` (pointer lock + relative, the default) or `Desktop`
|
||||
/// (uncaptured absolute pointer — design/remote-desktop-sweep.md M1). Ctrl+Alt+Shift+M
|
||||
/// flips it live; silently resolves to capture on hosts without absolute injection
|
||||
/// (gamescope).
|
||||
pub mouse_mode: MouseMode,
|
||||
/// Reverse the scroll direction sent to the host ([`Settings::invert_scroll`]).
|
||||
pub invert_scroll: bool,
|
||||
/// Emit the `{"ready":true}` stdout line after the first presented frame.
|
||||
@@ -333,6 +338,11 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// bottom-right corner (the reported bug). The menu/library is keyboard+gamepad-driven
|
||||
// and consumes no mouse, so nothing wanted these synthetic events anyway.
|
||||
sdl3::hint::set("SDL_TOUCH_MOUSE_EVENTS", "0");
|
||||
// The Wayland `app_id` (and X11 WM_CLASS) — compositors match it against
|
||||
// io.unom.Punktfunk.desktop for the window/taskbar icon. Without it SDL uses a generic
|
||||
// identity and the session window gets the default-Wayland icon (the Linux analog of
|
||||
// the AppUserModelID adoption above).
|
||||
sdl3::hint::set("SDL_APP_ID", "io.unom.Punktfunk");
|
||||
let sdl = sdl3::init().context("SDL init")?;
|
||||
let video = sdl.video().context("SDL video")?;
|
||||
let events = sdl.event().context("SDL events")?;
|
||||
@@ -485,7 +495,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
WindowEvent::FocusLost => {
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
if cap.release(false) {
|
||||
apply_capture(&mut window, &mouse, false);
|
||||
apply_capture(&mut window, &mouse, false, false);
|
||||
tracing::info!("focus lost — input released");
|
||||
}
|
||||
}
|
||||
@@ -496,7 +506,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
if cap.should_reengage() {
|
||||
cap.engage();
|
||||
apply_capture(&mut window, &mouse, true);
|
||||
apply_capture(&mut window, &mouse, true, cap.desktop());
|
||||
tracing::info!("focus gained — input recaptured");
|
||||
}
|
||||
}
|
||||
@@ -532,20 +542,39 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
if cap.captured() {
|
||||
cap.release(true);
|
||||
apply_capture(&mut window, &mouse, false);
|
||||
apply_capture(&mut window, &mouse, false, false);
|
||||
} else {
|
||||
cap.engage();
|
||||
apply_capture(&mut window, &mouse, true);
|
||||
apply_capture(&mut window, &mouse, true, cap.desktop());
|
||||
}
|
||||
tracing::info!(captured = cap.captured(), "chord: release/engage");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Mouse model flip (capture ⇄ desktop) — applies immediately when
|
||||
// engaged; a released stream just changes what the next engage does.
|
||||
if chord && sc == Scancode::M {
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
match cap.toggle_desktop() {
|
||||
Some(desktop) => {
|
||||
if cap.captured() {
|
||||
apply_capture(&mut window, &mouse, true, desktop);
|
||||
}
|
||||
tracing::info!(desktop, "chord: mouse mode");
|
||||
}
|
||||
None => tracing::info!(
|
||||
"chord: mouse mode — host has no absolute pointer \
|
||||
(gamescope), staying captured"
|
||||
),
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if chord && sc == Scancode::D {
|
||||
if let Some(st) = &mut stream {
|
||||
tracing::info!("chord: disconnect");
|
||||
st.request_quit();
|
||||
apply_capture(&mut window, &mouse, false);
|
||||
apply_capture(&mut window, &mouse, false, false);
|
||||
// The pump emits Ended(None); the end path routes per mode.
|
||||
}
|
||||
continue;
|
||||
@@ -578,17 +607,42 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
cap.on_key_up(sc);
|
||||
}
|
||||
}
|
||||
Event::MouseMotion { xrel, yrel, .. } => {
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
Event::MouseMotion {
|
||||
x, y, xrel, yrel, ..
|
||||
} => {
|
||||
if let Some(st) = stream.as_mut() {
|
||||
let video = st.last_video;
|
||||
if let Some(cap) = st.capture.as_mut() {
|
||||
if cap.desktop() {
|
||||
// Desktop model: the cursor's window position through the
|
||||
// letterbox (same mapping as a pointer-mode finger).
|
||||
// Before the first decoded frame there is nothing to map
|
||||
// onto — dropped, like touch.
|
||||
if let Some(video) = video {
|
||||
let (lw, lh) = window.size();
|
||||
let nx = x / lw.max(1) as f32;
|
||||
let ny = y / lh.max(1) as f32;
|
||||
let (ax, ay, aw, ah) =
|
||||
finger_to_content(window.size_in_pixels(), video, nx, ny);
|
||||
cap.on_motion_abs(Abs {
|
||||
x: ax,
|
||||
y: ay,
|
||||
w: aw,
|
||||
h: ah,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
cap.on_motion(xrel, yrel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::MouseButtonDown { mouse_btn, .. } => {
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
if !cap.captured() {
|
||||
// The engaging click is suppressed toward the host.
|
||||
cap.engage();
|
||||
apply_capture(&mut window, &mouse, true);
|
||||
apply_capture(&mut window, &mouse, true, cap.desktop());
|
||||
} else {
|
||||
cap.on_button_down(mouse_btn);
|
||||
}
|
||||
@@ -709,7 +763,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
while escape_rx.try_recv().is_ok() {
|
||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||
if cap.release(true) {
|
||||
apply_capture(&mut window, &mouse, false);
|
||||
apply_capture(&mut window, &mouse, false, false);
|
||||
}
|
||||
}
|
||||
if fullscreen && !opts.fullscreen {
|
||||
@@ -722,7 +776,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
if let Some(st) = &mut stream {
|
||||
tracing::info!("controller chord: disconnect");
|
||||
st.request_quit();
|
||||
apply_capture(&mut window, &mouse, false);
|
||||
apply_capture(&mut window, &mouse, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -813,9 +867,26 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
.ok();
|
||||
gamepad.attach(c.clone());
|
||||
st.clock_offset = Some(c.clock_offset_shared());
|
||||
let mut cap = Capture::new(c.clone(), opts.touch_mode, opts.invert_scroll);
|
||||
// gamescope's EIS grants only a relative pointer — absolute sends
|
||||
// would be dropped, so the desktop model is pinned off there. Auto
|
||||
// (an older host that didn't say) stays allowed: Windows hosts and
|
||||
// pre-Welcome-compositor Linux hosts both take absolute.
|
||||
let abs_ok = c.resolved_compositor != CompositorPref::Gamescope;
|
||||
if opts.mouse_mode == MouseMode::Desktop && !abs_ok {
|
||||
tracing::info!(
|
||||
"desktop mouse mode unavailable on a gamescope host \
|
||||
(relative-only input) — using capture"
|
||||
);
|
||||
}
|
||||
let mut cap = Capture::new(
|
||||
c.clone(),
|
||||
opts.touch_mode,
|
||||
opts.invert_scroll,
|
||||
opts.mouse_mode,
|
||||
abs_ok,
|
||||
);
|
||||
cap.engage(); // capture engages when the stream starts (ui_stream parity)
|
||||
apply_capture(&mut window, &mouse, true);
|
||||
apply_capture(&mut window, &mouse, true, cap.desktop());
|
||||
st.capture = Some(cap);
|
||||
st.connector = Some(c);
|
||||
if let Some(f) = opts.on_connected.as_mut() {
|
||||
@@ -865,7 +936,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
if let Some(st) = stream.take() {
|
||||
st.shutdown();
|
||||
}
|
||||
apply_capture(&mut window, &mouse, false);
|
||||
apply_capture(&mut window, &mouse, false, false);
|
||||
if let Some(o) = overlay.as_mut() {
|
||||
// A user-canceled dial ends silently — no error scene.
|
||||
if canceled {
|
||||
@@ -882,7 +953,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
if let Some(cap) = &mut st.capture {
|
||||
cap.release(true);
|
||||
}
|
||||
apply_capture(&mut window, &mouse, false);
|
||||
apply_capture(&mut window, &mouse, false, false);
|
||||
match &mode {
|
||||
ModeCtl::Single(_) => break 'main Some(Outcome::Ended(reason)),
|
||||
ModeCtl::Browse(_) => {
|
||||
@@ -1472,11 +1543,22 @@ impl ResizeIndicator {
|
||||
/// with a low-level keyboard hook, the same mechanism the WinUI shell's in-process
|
||||
/// client used its own WH_KEYBOARD_LL hooks for. Not engaged on Linux: the compositor
|
||||
/// shortcut-inhibit story stays the shells' concern (Settings.inhibit_shortcuts).
|
||||
fn apply_capture(window: &mut sdl3::video::Window, mouse: &sdl3::mouse::MouseUtil, on: bool) {
|
||||
mouse.set_relative_mouse_mode(window, on);
|
||||
///
|
||||
/// The `desktop` mouse model never locks: the pointer roams (and leaves the window)
|
||||
/// freely, the local cursor is hidden over the window — the host's composited cursor,
|
||||
/// tracking our absolute sends, is the one you see (until the M2 cursor channel flips
|
||||
/// who draws it) — and system chords stay local (a remote desktop is something you
|
||||
/// Alt-Tab away from, not into). `desktop` only matters while `on`.
|
||||
fn apply_capture(
|
||||
window: &mut sdl3::video::Window,
|
||||
mouse: &sdl3::mouse::MouseUtil,
|
||||
on: bool,
|
||||
desktop: bool,
|
||||
) {
|
||||
mouse.set_relative_mouse_mode(window, on && !desktop);
|
||||
mouse.show_cursor(!on);
|
||||
#[cfg(windows)]
|
||||
window.set_keyboard_grab(on);
|
||||
window.set_keyboard_grab(on && !desktop);
|
||||
}
|
||||
|
||||
/// Is this SDL touch device a real touchscreen (DIRECT, window-relative coordinates)?
|
||||
@@ -1594,7 +1676,7 @@ struct PresentedWindow {
|
||||
|
||||
/// The capture hints (`ui_stream` parity — the words the user reads while released).
|
||||
const HINT_KEYBOARD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \
|
||||
Ctrl+Alt+Shift+D disconnects · Ctrl+Alt+Shift+S stats";
|
||||
Ctrl+Alt+Shift+M mouse mode · Ctrl+Alt+Shift+D disconnects · Ctrl+Alt+Shift+S stats";
|
||||
const HINT_WITH_PAD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \
|
||||
Ctrl+Alt+Shift+D disconnects · hold L1 + R1 + Start + Select to leave";
|
||||
|
||||
|
||||
@@ -30,9 +30,17 @@ impl Presenter {
|
||||
// switch modes before anything touches this frame. Only where the surface
|
||||
// offers HDR10 — otherwise PQ stays on the SDR swapchain and the CSC shader
|
||||
// tonemaps (mode 1).
|
||||
//
|
||||
// CPU frames NEVER take the HDR10 surface: software decode uploads swscale RGBA with
|
||||
// no CSC/tonemap pass, so on a mode-0 swapchain that sRGB-encoded content would be
|
||||
// composed as PQ — the field-reported psychedelic cyan/magenta picture (reproduced
|
||||
// 2026-07-21: Fedora-class client, no hw HEVC decode, GNOME/Mesa offering HDR10 even
|
||||
// on an SDR desktop). On the SDR swapchain the same frames are merely untonemapped
|
||||
// (washed out) — wrong in the known, benign way until the CPU lane grows a real
|
||||
// PQ→sRGB pass.
|
||||
let frame_pq = match &input {
|
||||
FrameInput::Redraw => None,
|
||||
FrameInput::Cpu(f) => Some(f.color.is_pq()),
|
||||
FrameInput::Cpu(_) => Some(false),
|
||||
#[cfg(target_os = "linux")]
|
||||
FrameInput::Dmabuf(d) => Some(d.color.is_pq()),
|
||||
FrameInput::VkFrame(v) => Some(v.color.is_pq()),
|
||||
|
||||
@@ -617,6 +617,15 @@ pub(super) fn pick_formats(
|
||||
surface: vk::SurfaceKHR,
|
||||
colorspace_ext: bool,
|
||||
) -> Result<(vk::SurfaceFormatKHR, Option<vk::SurfaceFormatKHR>)> {
|
||||
// `PUNKTFUNK_HDR10=0` (explicit-off grammar) refuses the HDR10/ST.2084 swapchain outright,
|
||||
// pinning PQ streams to the shader tonemap on an SDR surface. Two reasons this exists:
|
||||
// desktop compositors newly offer HDR10 even on SDR desktops (GNOME 48 / Plasma 6 with
|
||||
// Mesa ≥ 25.1 — a lane that otherwise engages silently), and it is the A/B lever that
|
||||
// splits "HDR10 passthrough composes wrong" from "the decoded planes are wrong" in the
|
||||
// field without rebuilding anything.
|
||||
let colorspace_ext = colorspace_ext
|
||||
&& !std::env::var("PUNKTFUNK_HDR10")
|
||||
.is_ok_and(|v| matches!(v.as_str(), "0" | "false" | "off" | "no"));
|
||||
let formats = unsafe { surface_i.get_physical_device_surface_formats(pdev, surface) }?;
|
||||
let mut sdr = None;
|
||||
for want in [vk::Format::B8G8R8A8_UNORM, vk::Format::R8G8B8A8_UNORM] {
|
||||
|
||||
@@ -254,6 +254,34 @@ pub fn detect() -> Result<Compositor> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach-only probes: while any scope is held, backend `create` paths must not stop, relaunch,
|
||||
/// or take over box sessions — they may only attach to an already-live output, and fail fast
|
||||
/// otherwise. The capture-loss rebuild holds one for its first seconds: right after a capture
|
||||
/// loss the active-session detection can be STALE (a Game→Desktop switch observed live: the
|
||||
/// probe's gamescope re-acquire restarted `gamescope-session.target` and yanked the user out of
|
||||
/// the KDE session they had just switched to). A counter, so overlapping scopes compose.
|
||||
static REBUILD_PROBES: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
|
||||
/// RAII scope marking pipeline builds as attach-only probes (see [`rebuild_probe_active`]).
|
||||
pub struct RebuildProbeScope(());
|
||||
|
||||
pub fn rebuild_probe_scope() -> RebuildProbeScope {
|
||||
REBUILD_PROBES.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
RebuildProbeScope(())
|
||||
}
|
||||
|
||||
impl Drop for RebuildProbeScope {
|
||||
fn drop(&mut self) {
|
||||
REBUILD_PROBES.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// Is any [`rebuild_probe_scope`] active? Destructive session operations (stop/relaunch/
|
||||
/// takeover-restart) must be skipped while true.
|
||||
pub fn rebuild_probe_active() -> bool {
|
||||
REBUILD_PROBES.load(std::sync::atomic::Ordering::SeqCst) > 0
|
||||
}
|
||||
|
||||
/// Open the virtual-display driver for `compositor`.
|
||||
pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -325,6 +325,29 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
|
||||
if steamos_session_present() {
|
||||
return create_managed_session_steamos(mode);
|
||||
}
|
||||
// Attach-only rebuild probe: reuse a live same-mode session, but NEVER stop/relaunch box
|
||||
// sessions — right after a capture loss the caller's session detection can be stale, and a
|
||||
// destructive rebuild here would fight the session the user just switched to.
|
||||
if crate::rebuild_probe_active() {
|
||||
let guard = MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let same_mode = guard.as_ref().is_some_and(|s| {
|
||||
s.width == mode.width && s.height == mode.height && s.refresh_hz == mode.refresh_hz
|
||||
});
|
||||
if same_mode {
|
||||
if let Some(node_id) = find_gamescope_node() {
|
||||
point_injector_at_eis();
|
||||
tracing::info!(
|
||||
node_id,
|
||||
"gamescope session: attach-only probe reusing live node"
|
||||
);
|
||||
return Ok(managed_output(node_id, mode));
|
||||
}
|
||||
}
|
||||
return Err(anyhow!(
|
||||
"gamescope session has no attachable live node — attach-only rebuild probe refuses \
|
||||
to stop/relaunch box sessions (re-detection follows the live session)"
|
||||
));
|
||||
}
|
||||
// Steam is single-instance: if the box autologged into gaming mode on a physical display (the
|
||||
// Bazzite default — `gamescope-session-plus@ogui-steam` on the TV), that session holds Steam and
|
||||
// renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by
|
||||
@@ -607,12 +630,17 @@ fn write_steamos_dropin(shim_dir: &std::path::Path, mode: Mode) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| format!("mkdir {}", parent.display()))?;
|
||||
}
|
||||
// UnsetEnvironment: the same headless-must-not-attach armor `launch_session` gives its
|
||||
// transient unit — the manager env can carry a stale desktop DISPLAY/WAYLAND_DISPLAY (from a
|
||||
// portal settle), and gamescope would abort trying to attach to it instead of becoming the
|
||||
// display server. Unit-scoped belt-and-suspenders on top of the observe_session_instance scrub.
|
||||
let body = format!(
|
||||
"[Service]\n\
|
||||
Environment=PATH={shim}:/usr/bin:/bin:/usr/local/bin\n\
|
||||
Environment=PF_W={w}\n\
|
||||
Environment=PF_H={h}\n\
|
||||
Environment=PF_HZ={hz}\n",
|
||||
Environment=PF_HZ={hz}\n\
|
||||
UnsetEnvironment=DISPLAY WAYLAND_DISPLAY\n",
|
||||
shim = shim_dir.display(),
|
||||
w = mode.width,
|
||||
h = mode.height,
|
||||
@@ -650,6 +678,16 @@ fn create_managed_session_steamos(mode: Mode) -> Result<VirtualOutput> {
|
||||
}
|
||||
*guard = None; // tracked session lost its node — fall through to a clean restart
|
||||
}
|
||||
// Attach-only rebuild probe: the reuse path above may attach, but a restart of the session
|
||||
// target is out of bounds — observed live on a Deck: a stale post-capture-loss detection made
|
||||
// this restart steal the seat back from the KDE session the user had just switched to.
|
||||
if crate::rebuild_probe_active() {
|
||||
return Err(anyhow!(
|
||||
"gamescope has no live node and this is an attach-only rebuild probe — refusing to \
|
||||
restart {STEAMOS_SESSION_TARGET} (the box may be mid-switch to another session; \
|
||||
re-detection follows it)"
|
||||
));
|
||||
}
|
||||
let shim_dir = write_headless_shim()?;
|
||||
write_steamos_dropin(&shim_dir, mode)?;
|
||||
systemctl_user(&["daemon-reload"]);
|
||||
|
||||
@@ -57,6 +57,13 @@ pub fn observe_session_instance(active: &ActiveSession) {
|
||||
if let Some(old) = compositor_for_kind(prev.0) {
|
||||
registry::invalidate_backend(old.id());
|
||||
}
|
||||
// The dead desktop's socket vars may still sit in the systemd --user manager env
|
||||
// ([`settle_desktop_portal`]'s import-environment) — scrub them NOW, or the next
|
||||
// `gamescope-session.target` start inherits a stale WAYLAND_DISPLAY and gamescope
|
||||
// runs NESTED against the dead desktop socket instead of becoming the display
|
||||
// server ("Failed to connect to wayland socket: wayland-0" — kept a Deck's Game
|
||||
// Mode from starting at all, observed live 2026-07-21).
|
||||
scrub_desktop_manager_env();
|
||||
}
|
||||
let epoch = bump_session_epoch();
|
||||
tracing::info!(
|
||||
@@ -70,6 +77,23 @@ pub fn observe_session_instance(active: &ActiveSession) {
|
||||
*last = Some(cur);
|
||||
}
|
||||
|
||||
/// Counterpart to [`settle_desktop_portal`]'s `import-environment`: drop the desktop session's
|
||||
/// socket vars from the systemd `--user` manager env once that desktop instance is GONE. They
|
||||
/// persist in the manager otherwise, and every later user unit inherits them — including
|
||||
/// `gamescope-session.target`, whose gamescope then aborts trying to attach to the dead desktop
|
||||
/// socket. Best-effort; the D-Bus activation env has no unset op, but gamescope-session is
|
||||
/// systemd-started, so the manager scrub is the one that matters. (A desktop restart re-imports
|
||||
/// via the next [`settle_desktop_portal`], so scrubbing on a bounce is harmless.)
|
||||
#[cfg(target_os = "linux")]
|
||||
fn scrub_desktop_manager_env() {
|
||||
let _ = std::process::Command::new("systemctl")
|
||||
.args(["--user", "unset-environment", "WAYLAND_DISPLAY", "DISPLAY"])
|
||||
.status();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn scrub_desktop_manager_env() {}
|
||||
|
||||
/// Is `kind` a **desktop** compositor (KWin / Mutter / wlroots) — one whose kept PipeWire outputs die
|
||||
/// with the compositor instance, so the session epoch tracks it? `Gaming` (gamescope) and `None` are
|
||||
/// not (gamescope spawns are independent nested sessions — see [`observe_session_instance`]).
|
||||
|
||||
@@ -33,6 +33,11 @@ reed-solomon-simd = "3.1" # GF(2^16) Leopard-RS, SIMD, O(n log n) — the w
|
||||
# NOT interoperable.) See vendor/fec-rs/LICENSE (BSD-2-Clause).
|
||||
fec-rs = { path = "vendor/fec-rs" }
|
||||
aes-gcm = "0.10" # AES-128-GCM session crypto, matches GameStream
|
||||
# ChaCha20-Poly1305 session crypto, negotiated by clients without hardware AES (the soft-AES
|
||||
# armv7 targets — webOS TVs — where GCM caps decrypt at ~100 Mbps; ARX runs 4-7x faster there).
|
||||
# Same RustCrypto `aead 0.5` generation as aes-gcm: identical trait/nonce/tag shapes, pure Rust,
|
||||
# cross-compiles like aes-gcm (no cmake). See design/chacha20-session-cipher.md.
|
||||
chacha20poly1305 = "0.10"
|
||||
zerocopy = { version = "0.8", features = ["derive"] }
|
||||
bytes = "1"
|
||||
socket2 = { version = "0.6", features = [
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Tier-1 microbenchmarks for the punktfunk/1 hot path — GPU-free, so they run in normal CI.
|
||||
//!
|
||||
//! Two layers:
|
||||
//! - `crypto/*` — the isolated AES-128-GCM primitives on one ~MTU shard.
|
||||
//! - `crypto/*` — the isolated AEAD primitives (AES-128-GCM + the negotiated
|
||||
//! ChaCha20-Poly1305) on one ~MTU shard.
|
||||
//! - `pipeline/*`— a whole frame through the real per-frame path end to end over the in-process
|
||||
//! loopback transport: FEC encode → AES-GCM seal → packetize → (loopback) → reassemble →
|
||||
//! FEC decode → open. This is what a throughput/latency regression in the core would show up in.
|
||||
@@ -11,11 +12,11 @@
|
||||
|
||||
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
|
||||
use punktfunk_core::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role};
|
||||
use punktfunk_core::crypto::SessionCrypto;
|
||||
use punktfunk_core::crypto::{SessionCrypto, SessionKey};
|
||||
use punktfunk_core::session::Session;
|
||||
use punktfunk_core::transport::loopback_pair;
|
||||
|
||||
const TAG_LEN: usize = 16; // AES-GCM authentication tag
|
||||
const TAG_LEN: usize = 16; // AEAD authentication tag (GCM and Poly1305 share the size)
|
||||
const SHARD: usize = punktfunk_core::config::mtu1500_shard_payload(); // one MTU-safe data shard
|
||||
|
||||
fn cfg(role: Role, scheme: FecScheme) -> Config {
|
||||
@@ -38,21 +39,29 @@ fn cfg(role: Role, scheme: FecScheme) -> Config {
|
||||
shard_payload: SHARD,
|
||||
max_frame_bytes: 8 * 1024 * 1024,
|
||||
encrypt: true, // bench the real path — crypto is always on for punktfunk/1
|
||||
key: [7u8; 16],
|
||||
key: SessionKey::Aes128Gcm([7u8; 16]),
|
||||
salt: [1, 2, 3, 4],
|
||||
loopback_drop_period: 0, // throughput run: no induced loss (loss-harness covers recovery)
|
||||
}
|
||||
}
|
||||
|
||||
fn bench_crypto(c: &mut Criterion) {
|
||||
let host = SessionCrypto::new(&[7u8; 16], [1, 2, 3, 4], Role::Host);
|
||||
let client = SessionCrypto::new(&[7u8; 16], [1, 2, 3, 4], Role::Client);
|
||||
let mut g = c.benchmark_group("crypto");
|
||||
g.throughput(Throughput::Bytes(SHARD as u64));
|
||||
// Both negotiated session AEADs. On the x86 / Apple Silicon this runs on, both must be
|
||||
// line-rate-trivial — the chacha20 series is the host-side sealing-cost check for the
|
||||
// negotiated soft-AES-armv7 path (design/chacha20-session-cipher.md §7). The AES series
|
||||
// keeps its unsuffixed names so the CI regression compare retains its history.
|
||||
for (suffix, key) in [
|
||||
("", SessionKey::Aes128Gcm([7u8; 16])),
|
||||
("_chacha20", SessionKey::ChaCha20Poly1305([7u8; 32])),
|
||||
] {
|
||||
let host = SessionCrypto::new(&key, [1, 2, 3, 4], Role::Host);
|
||||
let client = SessionCrypto::new(&key, [1, 2, 3, 4], Role::Client);
|
||||
let payload = vec![0xABu8; SHARD];
|
||||
let sealed = host.seal(0, &payload).unwrap();
|
||||
|
||||
let mut g = c.benchmark_group("crypto");
|
||||
g.throughput(Throughput::Bytes(SHARD as u64));
|
||||
g.bench_function("seal", |b| {
|
||||
g.bench_function(format!("seal{suffix}"), |b| {
|
||||
let mut seq = 0u64;
|
||||
b.iter(|| {
|
||||
let ct = host.seal(seq, black_box(&payload)).unwrap();
|
||||
@@ -60,7 +69,7 @@ fn bench_crypto(c: &mut Criterion) {
|
||||
black_box(ct)
|
||||
})
|
||||
});
|
||||
g.bench_function("seal_in_place", |b| {
|
||||
g.bench_function(format!("seal_in_place{suffix}"), |b| {
|
||||
let mut seq = 0u64;
|
||||
let mut buf = vec![0xABu8; SHARD + TAG_LEN];
|
||||
b.iter(|| {
|
||||
@@ -68,10 +77,10 @@ fn bench_crypto(c: &mut Criterion) {
|
||||
seq += 1;
|
||||
})
|
||||
});
|
||||
g.bench_function("open", |b| {
|
||||
g.bench_function(format!("open{suffix}"), |b| {
|
||||
b.iter(|| black_box(client.open(0, black_box(&sealed)).unwrap()))
|
||||
});
|
||||
g.bench_function("open_in_place", |b| {
|
||||
g.bench_function(format!("open_in_place{suffix}"), |b| {
|
||||
// In-place open consumes the buffer, so each iteration restores the ciphertext first —
|
||||
// one memcpy, mirroring what the recv ring does when the next datagram lands in the slot.
|
||||
let mut buf = sealed.clone();
|
||||
@@ -80,6 +89,7 @@ fn bench_crypto(c: &mut Criterion) {
|
||||
black_box(client.open_in_place(0, &mut buf).unwrap());
|
||||
})
|
||||
});
|
||||
}
|
||||
g.finish();
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! - Panics never cross the boundary: every entry point is wrapped in `catch_unwind`.
|
||||
|
||||
use crate::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role};
|
||||
use crate::crypto::SessionKey;
|
||||
use crate::error::PunktfunkStatus;
|
||||
use crate::input::InputEvent;
|
||||
use crate::reanchor::{GateVerdict, ReanchorGate};
|
||||
@@ -94,7 +95,10 @@ impl PunktfunkConfig {
|
||||
shard_payload: self.shard_payload as usize,
|
||||
max_frame_bytes,
|
||||
encrypt: self.encrypt != 0,
|
||||
key: self.key,
|
||||
// The C ABI keeps its fixed 16-byte key and always selects AES-128-GCM — no
|
||||
// ABI_VERSION bump. Raw-`Config` C embedders can't negotiate ChaCha; the Swift/
|
||||
// Kotlin clients are aarch64 with AES CE and never want it.
|
||||
key: SessionKey::Aes128Gcm(self.key),
|
||||
salt: self.salt,
|
||||
loopback_drop_period: self.loopback_drop_period,
|
||||
};
|
||||
|
||||
@@ -393,6 +393,7 @@ impl NativeClient {
|
||||
launch,
|
||||
pin,
|
||||
identity,
|
||||
connect_timeout: timeout,
|
||||
frames: frame_chan_w,
|
||||
audio_tx,
|
||||
rumble_tx,
|
||||
|
||||
@@ -32,21 +32,65 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
|
||||
identity.as_ref().map(|(c, k)| (c.as_str(), k.as_str())),
|
||||
);
|
||||
let ep = ep.map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?;
|
||||
let conn = ep
|
||||
// Dial with retry across the connect budget, not a single attempt: one quinn dial gives
|
||||
// up after the transport's idle window (~8 s of silence), which is shorter than a
|
||||
// suspend-to-RAM resume — the Steam Deck flow fires Wake-on-LAN and connects
|
||||
// immediately, so the host is still waking while the first Initials go out, and a
|
||||
// single-shot dial died just before the host came up. Short attempts keep the Initial
|
||||
// cadence dense (quinn's per-attempt retransmits back off toward multi-second gaps), so
|
||||
// the connect lands within ~a second of the host's network returning. Only SILENCE is
|
||||
// retried: a host that answers and rejects us (pin mismatch, ALPN/version, typed close)
|
||||
// must surface immediately, and the embedder's shutdown flag (budget expiry in
|
||||
// `connect`, or a user cancel) stops the loop between attempts.
|
||||
const DIAL_ATTEMPT: std::time::Duration = std::time::Duration::from_secs(3);
|
||||
// Redial headroom: leave room for the control handshake (Hello/Welcome/clock sync)
|
||||
// after a late dial success, so it still completes inside the embedder's budget.
|
||||
const CONTROL_HEADROOM: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
let start = tokio::time::Instant::now();
|
||||
let deadline = start + args.connect_timeout;
|
||||
let redial_until = start + args.connect_timeout.saturating_sub(CONTROL_HEADROOM);
|
||||
let conn = loop {
|
||||
let connecting = ep
|
||||
.connect(remote, "punktfunk")
|
||||
.map_err(|_| PunktfunkError::InvalidArg("connect"))?
|
||||
.await
|
||||
.map_err(|e| {
|
||||
.map_err(|_| PunktfunkError::InvalidArg("connect"))?;
|
||||
// Cap the attempt to the remaining budget so a success never lands after the
|
||||
// embedder's `ready_rx` wait has already given up and flagged a teardown.
|
||||
let now = tokio::time::Instant::now();
|
||||
let attempt = DIAL_ATTEMPT.min(deadline.saturating_duration_since(now));
|
||||
let gave_up = || {
|
||||
tokio::time::Instant::now() >= redial_until
|
||||
|| shutdown.load(std::sync::atomic::Ordering::SeqCst)
|
||||
};
|
||||
match tokio::time::timeout(attempt, connecting).await {
|
||||
Ok(Ok(conn)) => break conn,
|
||||
Ok(Err(e)) => {
|
||||
// A pin mismatch surfaces as a TLS failure; report it as a crypto error so
|
||||
// the embedder can distinguish "wrong host identity" from plain IO trouble.
|
||||
let fp_mismatch =
|
||||
pin.is_some() && observed.lock().unwrap().map(|fp| Some(fp) != pin) == Some(true);
|
||||
let fp_mismatch = pin.is_some()
|
||||
&& observed.lock().unwrap().map(|fp| Some(fp) != pin) == Some(true);
|
||||
if fp_mismatch {
|
||||
PunktfunkError::Crypto
|
||||
} else {
|
||||
PunktfunkError::Io(std::io::Error::other(e.to_string()))
|
||||
return Err(PunktfunkError::Crypto);
|
||||
}
|
||||
})?;
|
||||
// The transport's own idle expiry — the host never answered — is the one
|
||||
// retryable outcome; everything else is a real answer or a local failure.
|
||||
let host_silent = matches!(e, quinn::ConnectionError::TimedOut);
|
||||
if !host_silent {
|
||||
return Err(PunktfunkError::Io(std::io::Error::other(e.to_string())));
|
||||
}
|
||||
if gave_up() {
|
||||
return Err(PunktfunkError::Timeout);
|
||||
}
|
||||
}
|
||||
// Attempt window elapsed with the host still silent; dropping `connecting`
|
||||
// abandoned that dial — go again unless the budget is spent.
|
||||
Err(_) => {
|
||||
if gave_up() {
|
||||
return Err(PunktfunkError::Timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::debug!(%remote, "host silent — re-dialing (wake/resume tolerant connect)");
|
||||
};
|
||||
let fingerprint = observed.lock().unwrap().unwrap_or([0u8; 32]);
|
||||
// The rest of the handshake runs in an inner future so a failure can consult
|
||||
// `conn.close_reason()`: a host that turned us away with a typed application close
|
||||
@@ -83,10 +127,13 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
|
||||
// bit only asks the host for observability datagrams (never changes the encode).
|
||||
// PROBE_SEQ likewise: the shared reassembler keeps probe filler in its own window
|
||||
// (every embedder inherits it), so the host may burst speed tests without consuming
|
||||
// video frame indexes.
|
||||
// video frame indexes. STREAMED_AU the same way: the shared reassembler accepts
|
||||
// sentinel-headed streamed blocks (retro-validated at the final block), so the host
|
||||
// may overlap a multi-slice encode's tail with packetize/FEC/pacing.
|
||||
video_caps: video_caps
|
||||
| crate::quic::VIDEO_CAP_HOST_TIMING
|
||||
| crate::quic::VIDEO_CAP_PROBE_SEQ,
|
||||
| crate::quic::VIDEO_CAP_PROBE_SEQ
|
||||
| crate::quic::VIDEO_CAP_STREAMED_AU,
|
||||
// Requested surround channel count; the host echoes the resolved value in Welcome.
|
||||
audio_channels,
|
||||
// The codecs this client can decode + its soft preference (0 = auto). The host
|
||||
|
||||
@@ -25,6 +25,10 @@ pub(crate) struct WorkerArgs {
|
||||
pub(crate) launch: Option<String>,
|
||||
pub(crate) pin: Option<[u8; 32]>,
|
||||
pub(crate) identity: Option<(String, String)>,
|
||||
/// The embedder's connect budget (the same value `connect` bounds `ready_rx` with): the
|
||||
/// dial loop re-dials a silent host within it, so a host still resuming from Wake-on-LAN
|
||||
/// is caught the moment its network comes back instead of failing on the first attempt.
|
||||
pub(crate) connect_timeout: std::time::Duration,
|
||||
pub(crate) frames: Arc<FrameChannel>,
|
||||
pub(crate) audio_tx: SyncSender<AudioPacket>,
|
||||
pub(crate) rumble_tx: SyncSender<RumbleUpdate>,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Session configuration and protocol/FEC parameters.
|
||||
|
||||
use crate::crypto::SessionKey;
|
||||
use crate::error::{PunktfunkError, Result};
|
||||
use crate::packet::{CRYPTO_OVERHEAD, HEADER_LEN, MAX_DATAGRAM_BYTES};
|
||||
use zeroize::Zeroize;
|
||||
@@ -355,9 +356,11 @@ pub struct Config {
|
||||
/// hostile/corrupt headers; see [`Session`](crate::session::Session)).
|
||||
pub max_frame_bytes: usize,
|
||||
pub encrypt: bool,
|
||||
/// AES-128 session key established during pairing. MUST be unique per session when
|
||||
/// The negotiated session AEAD + its key, established during pairing/handshake —
|
||||
/// AES-128-GCM for every peer by default, ChaCha20-Poly1305 when the client negotiated it
|
||||
/// (soft-AES armv7 targets; see [`SessionKey`]). MUST be unique per session when
|
||||
/// `encrypt` is set (see the nonce-uniqueness contract in [`crate::crypto`]).
|
||||
pub key: [u8; 16],
|
||||
pub key: SessionKey,
|
||||
/// Per-session nonce salt, established alongside `key` during pairing. MUST be
|
||||
/// unique per (key, session).
|
||||
pub salt: [u8; 4],
|
||||
@@ -382,7 +385,8 @@ impl std::fmt::Debug for Config {
|
||||
.field("shard_payload", &self.shard_payload)
|
||||
.field("max_frame_bytes", &self.max_frame_bytes)
|
||||
.field("encrypt", &self.encrypt)
|
||||
.field("key", &"<redacted>")
|
||||
// SessionKey's own Debug redacts the material but keeps the cipher choice visible.
|
||||
.field("key", &self.key)
|
||||
.field("salt", &"<redacted>")
|
||||
.field("loopback_drop_period", &self.loopback_drop_period)
|
||||
.finish()
|
||||
@@ -426,7 +430,7 @@ impl Config {
|
||||
"max_frame_bytes too large for this shard/block configuration (block count overflows u16)",
|
||||
));
|
||||
}
|
||||
if self.encrypt && self.key == [0u8; 16] {
|
||||
if self.encrypt && self.key.is_zero() {
|
||||
return Err(PunktfunkError::InvalidArg(
|
||||
"encrypt requires a non-zero session key (see crypto nonce-uniqueness contract)",
|
||||
));
|
||||
@@ -449,7 +453,7 @@ impl Config {
|
||||
shard_payload: 1024,
|
||||
max_frame_bytes: 64 * 1024 * 1024,
|
||||
encrypt: false,
|
||||
key: [0u8; 16],
|
||||
key: SessionKey::Aes128Gcm([0u8; 16]),
|
||||
salt: [0u8; 4],
|
||||
loopback_drop_period: 0,
|
||||
}
|
||||
@@ -465,7 +469,12 @@ mod tests {
|
||||
let mut c = Config::p1_defaults(Role::Host);
|
||||
c.encrypt = true; // key is still all-zero
|
||||
assert!(c.validate().is_err());
|
||||
c.key = [1u8; 16];
|
||||
c.key = SessionKey::Aes128Gcm([1u8; 16]);
|
||||
assert!(c.validate().is_ok());
|
||||
// The rejection follows whichever cipher variant is active.
|
||||
c.key = SessionKey::ChaCha20Poly1305([0u8; 32]);
|
||||
assert!(c.validate().is_err());
|
||||
c.key = SessionKey::ChaCha20Poly1305([1u8; 32]);
|
||||
assert!(c.validate().is_ok());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! AES-128-GCM session sealing, matching GameStream's video crypto in P1.
|
||||
//! Session sealing with the negotiated AEAD — AES-128-GCM (matching GameStream's video
|
||||
//! crypto in P1) by default, ChaCha20-Poly1305 (RFC 8439) for clients without hardware AES.
|
||||
//!
|
||||
//! ## Nonce uniqueness (the GCM safety requirement)
|
||||
//! ## Nonce uniqueness (the AEAD safety requirement)
|
||||
//!
|
||||
//! The 96-bit nonce is `salt (4 bytes) || sequence (8 bytes, big-endian)`. Reusing a
|
||||
//! `(key, nonce)` pair under AES-GCM is catastrophic, so two precautions apply:
|
||||
//! `(key, nonce)` pair is catastrophic under either AEAD, so two precautions apply:
|
||||
//!
|
||||
//! 1. **Per-direction salts.** Host and client share one `key` and `salt`, and each
|
||||
//! counts its sequence from 0. To stop the host's video stream and the client's input
|
||||
@@ -17,17 +18,96 @@
|
||||
//! The sequence number is also passed as AEAD associated data, so tampering with the
|
||||
//! on-wire sequence is detected (the tag check fails) rather than silently shifting the
|
||||
//! nonce. Note: this layer does not provide anti-replay — see `Session`.
|
||||
//!
|
||||
//! ## Why two ciphers
|
||||
//!
|
||||
//! Both AEADs are full-strength; the choice (negotiated via `Welcome::cipher`) is purely a
|
||||
//! performance one. On targets without hardware AES — the soft-AES armv7 clients (webOS TVs) —
|
||||
//! GCM's fixsliced AES + software GHASH costs ~50–100 cycles/byte and caps decrypt at
|
||||
//! ~100 Mbps, while ChaCha20-Poly1305's ARX construction runs ~10–17 cycles/byte in portable
|
||||
//! software (design/chacha20-session-cipher.md). Same 96-bit nonce, 16-byte tag, and AAD
|
||||
//! shape, so the entire nonce discipline above carries over verbatim.
|
||||
|
||||
use crate::config::Role;
|
||||
use crate::error::{PunktfunkError, Result};
|
||||
use aes_gcm::aead::{Aead, AeadInPlace, KeyInit, Payload};
|
||||
use aes_gcm::{Aes128Gcm, Key, Nonce};
|
||||
use chacha20poly1305::ChaCha20Poly1305;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
/// 16-byte AEAD authentication tag appended by GCM.
|
||||
/// 16-byte AEAD authentication tag appended by either session cipher.
|
||||
pub const TAG_LEN: usize = 16;
|
||||
|
||||
// The wire (CRYPTO_OVERHEAD) and every in-place split assume both negotiated AEADs append
|
||||
// exactly TAG_LEN bytes — a different-tag cipher can never slip in behind this constant.
|
||||
const _: () = assert!(std::mem::size_of::<aes_gcm::Tag>() == TAG_LEN);
|
||||
const _: () = assert!(std::mem::size_of::<chacha20poly1305::Tag>() == TAG_LEN);
|
||||
|
||||
/// The negotiated session AEAD together with its key material — merged so the invalid state
|
||||
/// (a ChaCha cipher with an AES-sized key, or vice versa) is unrepresentable. AES-128-GCM is
|
||||
/// the default every peer speaks; ChaCha20-Poly1305 is granted to clients that advertised
|
||||
/// [`VIDEO_CAP_CHACHA20`](crate::quic::VIDEO_CAP_CHACHA20) (the soft-AES armv7 targets —
|
||||
/// see the module docs). 256 bits for ChaCha is what RFC 8439 requires.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SessionKey {
|
||||
Aes128Gcm([u8; 16]),
|
||||
ChaCha20Poly1305([u8; 32]),
|
||||
}
|
||||
|
||||
impl SessionKey {
|
||||
/// Canonical lowercase cipher name for session-start logs.
|
||||
pub fn cipher_name(&self) -> &'static str {
|
||||
match self {
|
||||
SessionKey::Aes128Gcm(_) => "aes-128-gcm",
|
||||
SessionKey::ChaCha20Poly1305(_) => "chacha20-poly1305",
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the key material is all zeros — the pairing-layer footgun `Config::validate`
|
||||
/// rejects when encryption is on (see the nonce-uniqueness contract in the module docs).
|
||||
pub fn is_zero(&self) -> bool {
|
||||
match self {
|
||||
SessionKey::Aes128Gcm(k) => k == &[0u8; 16],
|
||||
SessionKey::ChaCha20Poly1305(k) => k == &[0u8; 32],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Key material never appears in logs, whichever variant is active — only the cipher choice
|
||||
/// (`Config`'s hand-written `Debug` relies on this).
|
||||
impl std::fmt::Debug for SessionKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SessionKey::Aes128Gcm(_) => f.write_str("Aes128Gcm(<redacted>)"),
|
||||
SessionKey::ChaCha20Poly1305(_) => f.write_str("ChaCha20Poly1305(<redacted>)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Same zeroize-on-drop discipline the raw key array had (`Config`'s `Drop`).
|
||||
impl Zeroize for SessionKey {
|
||||
fn zeroize(&mut self) {
|
||||
match self {
|
||||
SessionKey::Aes128Gcm(k) => k.zeroize(),
|
||||
SessionKey::ChaCha20Poly1305(k) => k.zeroize(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The two negotiated AEADs behind one seal/open surface. Both are the same RustCrypto
|
||||
/// `aead 0.5` generation (identical trait shapes, nonce/tag types), so each call below is a
|
||||
/// two-arm match right next to the cipher work itself.
|
||||
// AES's precomputed round keys (~0.7 KB) dwarf ChaCha's 32-byte state, but there is exactly
|
||||
// one long-lived `SessionCrypto` per session — boxing the variant would trade that one-off
|
||||
// slack for a pointer chase on every per-datagram seal/open.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum Cipher {
|
||||
Aes128Gcm(Aes128Gcm),
|
||||
ChaCha20Poly1305(ChaCha20Poly1305),
|
||||
}
|
||||
|
||||
pub struct SessionCrypto {
|
||||
cipher: Aes128Gcm,
|
||||
cipher: Cipher,
|
||||
/// Salt for nonces we seal with (our direction).
|
||||
send_salt: [u8; 4],
|
||||
/// Salt for nonces we open with (the peer's direction).
|
||||
@@ -35,11 +115,18 @@ pub struct SessionCrypto {
|
||||
}
|
||||
|
||||
impl SessionCrypto {
|
||||
pub fn new(key: &[u8; 16], salt: [u8; 4], role: Role) -> Self {
|
||||
let key = Key::<Aes128Gcm>::from_slice(key);
|
||||
pub fn new(key: &SessionKey, salt: [u8; 4], role: Role) -> Self {
|
||||
let cipher = match key {
|
||||
SessionKey::Aes128Gcm(k) => {
|
||||
Cipher::Aes128Gcm(Aes128Gcm::new(Key::<Aes128Gcm>::from_slice(k)))
|
||||
}
|
||||
SessionKey::ChaCha20Poly1305(k) => Cipher::ChaCha20Poly1305(ChaCha20Poly1305::new(
|
||||
Key::<ChaCha20Poly1305>::from_slice(k),
|
||||
)),
|
||||
};
|
||||
let own = direction(role);
|
||||
SessionCrypto {
|
||||
cipher: Aes128Gcm::new(key),
|
||||
cipher,
|
||||
send_salt: dir_salt(salt, own),
|
||||
recv_salt: dir_salt(salt, own ^ 1),
|
||||
}
|
||||
@@ -49,14 +136,15 @@ impl SessionCrypto {
|
||||
/// authenticated as associated data.
|
||||
pub fn seal(&self, seq: u64, plaintext: &[u8]) -> Result<Vec<u8>> {
|
||||
let nonce = nonce(self.send_salt, seq);
|
||||
self.cipher
|
||||
.encrypt(
|
||||
Nonce::from_slice(&nonce),
|
||||
Payload {
|
||||
let aad = seq.to_be_bytes();
|
||||
let payload = Payload {
|
||||
msg: plaintext,
|
||||
aad: &seq.to_be_bytes(),
|
||||
},
|
||||
)
|
||||
aad: &aad,
|
||||
};
|
||||
match &self.cipher {
|
||||
Cipher::Aes128Gcm(c) => c.encrypt(Nonce::from_slice(&nonce), payload),
|
||||
Cipher::ChaCha20Poly1305(c) => c.encrypt(Nonce::from_slice(&nonce), payload),
|
||||
}
|
||||
.map_err(|_| PunktfunkError::Crypto)
|
||||
}
|
||||
|
||||
@@ -69,9 +157,15 @@ impl SessionCrypto {
|
||||
let nonce = nonce(self.send_salt, seq);
|
||||
let split = buf.len() - TAG_LEN;
|
||||
let (plaintext, tag_slot) = buf.split_at_mut(split);
|
||||
let tag = self
|
||||
.cipher
|
||||
.encrypt_in_place_detached(Nonce::from_slice(&nonce), &seq.to_be_bytes(), plaintext)
|
||||
let aad = seq.to_be_bytes();
|
||||
let tag = match &self.cipher {
|
||||
Cipher::Aes128Gcm(c) => {
|
||||
c.encrypt_in_place_detached(Nonce::from_slice(&nonce), &aad, plaintext)
|
||||
}
|
||||
Cipher::ChaCha20Poly1305(c) => {
|
||||
c.encrypt_in_place_detached(Nonce::from_slice(&nonce), &aad, plaintext)
|
||||
}
|
||||
}
|
||||
.map_err(|_| PunktfunkError::Crypto)?;
|
||||
tag_slot.copy_from_slice(&tag);
|
||||
Ok(())
|
||||
@@ -80,20 +174,21 @@ impl SessionCrypto {
|
||||
/// Open `ciphertext || tag` for sequence `seq` (also bound as associated data).
|
||||
pub fn open(&self, seq: u64, ciphertext: &[u8]) -> Result<Vec<u8>> {
|
||||
let nonce = nonce(self.recv_salt, seq);
|
||||
self.cipher
|
||||
.decrypt(
|
||||
Nonce::from_slice(&nonce),
|
||||
Payload {
|
||||
let aad = seq.to_be_bytes();
|
||||
let payload = Payload {
|
||||
msg: ciphertext,
|
||||
aad: &seq.to_be_bytes(),
|
||||
},
|
||||
)
|
||||
aad: &aad,
|
||||
};
|
||||
match &self.cipher {
|
||||
Cipher::Aes128Gcm(c) => c.decrypt(Nonce::from_slice(&nonce), payload),
|
||||
Cipher::ChaCha20Poly1305(c) => c.decrypt(Nonce::from_slice(&nonce), payload),
|
||||
}
|
||||
.map_err(|_| PunktfunkError::Crypto)
|
||||
}
|
||||
|
||||
/// Open in place, no per-packet allocation: `buf` holds `[ciphertext .. ][tag]` on entry and
|
||||
/// the plaintext in its first `buf.len() - TAG_LEN` bytes on success (returned as the length)
|
||||
/// — byte-identical to `open`, just written in place. GCM verifies the tag *before*
|
||||
/// — byte-identical to `open`, just written in place. Both AEADs verify the tag *before*
|
||||
/// decrypting, so on failure `buf` still holds the ciphertext (the caller drops the packet
|
||||
/// either way). The hot-path receiver (`Session::poll_frame`) uses this to avoid the `Vec`
|
||||
/// that `open`'s convenience API allocates for every datagram at line rate — the receive
|
||||
@@ -105,13 +200,21 @@ impl SessionCrypto {
|
||||
let nonce = nonce(self.recv_salt, seq);
|
||||
let split = buf.len() - TAG_LEN;
|
||||
let (ciphertext, tag) = buf.split_at_mut(split);
|
||||
self.cipher
|
||||
.decrypt_in_place_detached(
|
||||
let aad = seq.to_be_bytes();
|
||||
match &self.cipher {
|
||||
Cipher::Aes128Gcm(c) => c.decrypt_in_place_detached(
|
||||
Nonce::from_slice(&nonce),
|
||||
&seq.to_be_bytes(),
|
||||
&aad,
|
||||
ciphertext,
|
||||
aes_gcm::Tag::from_slice(tag),
|
||||
)
|
||||
),
|
||||
Cipher::ChaCha20Poly1305(c) => c.decrypt_in_place_detached(
|
||||
Nonce::from_slice(&nonce),
|
||||
&aad,
|
||||
ciphertext,
|
||||
chacha20poly1305::Tag::from_slice(tag),
|
||||
),
|
||||
}
|
||||
.map_err(|_| PunktfunkError::Crypto)?;
|
||||
Ok(split)
|
||||
}
|
||||
@@ -145,6 +248,13 @@ pub fn random_key() -> [u8; 16] {
|
||||
k
|
||||
}
|
||||
|
||||
/// Generate a fresh random ChaCha20-Poly1305 session key (RFC 8439's 256-bit size).
|
||||
pub fn random_key32() -> [u8; 32] {
|
||||
let mut k = [0u8; 32];
|
||||
rand::RngCore::fill_bytes(&mut rand::rng(), &mut k);
|
||||
k
|
||||
}
|
||||
|
||||
/// Generate a fresh random per-session nonce salt.
|
||||
pub fn random_salt() -> [u8; 4] {
|
||||
let mut s = [0u8; 4];
|
||||
@@ -156,9 +266,17 @@ pub fn random_salt() -> [u8; 4] {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// One fresh key per negotiated cipher — every sealing test below must hold for both.
|
||||
fn both_keys() -> [SessionKey; 2] {
|
||||
[
|
||||
SessionKey::Aes128Gcm(random_key()),
|
||||
SessionKey::ChaCha20Poly1305(random_key32()),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seal_open_roundtrip_cross_direction() {
|
||||
let key = random_key();
|
||||
for key in both_keys() {
|
||||
let salt = random_salt();
|
||||
let host = SessionCrypto::new(&key, salt, Role::Host);
|
||||
let client = SessionCrypto::new(&key, salt, Role::Client);
|
||||
@@ -175,10 +293,11 @@ mod tests {
|
||||
// open its own outbound packet → distinct nonce spaces per direction.
|
||||
assert!(host.open(42, &sealed).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directions_use_distinct_nonce_spaces() {
|
||||
let key = random_key();
|
||||
for key in both_keys() {
|
||||
let salt = [0u8; 4]; // even an all-zero base salt must separate the directions
|
||||
let host = SessionCrypto::new(&key, salt, Role::Host);
|
||||
let client = SessionCrypto::new(&key, salt, Role::Client);
|
||||
@@ -188,10 +307,11 @@ mod tests {
|
||||
client.seal(0, b"abc").unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_in_place_matches_open_and_rejects_tampering() {
|
||||
let key = random_key();
|
||||
for key in both_keys() {
|
||||
let salt = random_salt();
|
||||
let host = SessionCrypto::new(&key, salt, Role::Host);
|
||||
let client = SessionCrypto::new(&key, salt, Role::Client);
|
||||
@@ -221,10 +341,11 @@ mod tests {
|
||||
let mut runt = vec![0u8; TAG_LEN - 1];
|
||||
assert!(client.open_in_place(0, &mut runt).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seal_in_place_matches_seal_and_opens() {
|
||||
let key = random_key();
|
||||
for key in both_keys() {
|
||||
let salt = random_salt();
|
||||
let host = SessionCrypto::new(&key, salt, Role::Host);
|
||||
let client = SessionCrypto::new(&key, salt, Role::Client);
|
||||
@@ -246,3 +367,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ciphers_are_not_interchangeable() {
|
||||
// A packet sealed under one AEAD must not open under the other — negotiation skew has
|
||||
// to fail loudly (a tag mismatch), never decode garbage. The ChaCha key repeats the AES
|
||||
// key bytes so even overlapping key material can't accidentally interoperate.
|
||||
let salt = random_salt();
|
||||
let aes = SessionKey::Aes128Gcm([7u8; 16]);
|
||||
let chacha = SessionKey::ChaCha20Poly1305([7u8; 32]);
|
||||
let sealed = SessionCrypto::new(&aes, salt, Role::Host)
|
||||
.seal(1, b"cross-cipher")
|
||||
.unwrap();
|
||||
assert!(SessionCrypto::new(&chacha, salt, Role::Client)
|
||||
.open(1, &sealed)
|
||||
.is_err());
|
||||
let sealed = SessionCrypto::new(&chacha, salt, Role::Host)
|
||||
.seal(1, b"cross-cipher")
|
||||
.unwrap();
|
||||
assert!(SessionCrypto::new(&aes, salt, Role::Client)
|
||||
.open(1, &sealed)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_key_zero_check_and_debug_redaction() {
|
||||
assert!(SessionKey::Aes128Gcm([0u8; 16]).is_zero());
|
||||
assert!(SessionKey::ChaCha20Poly1305([0u8; 32]).is_zero());
|
||||
assert!(!SessionKey::Aes128Gcm([1u8; 16]).is_zero());
|
||||
assert!(!SessionKey::ChaCha20Poly1305([1u8; 32]).is_zero());
|
||||
// Key bytes must never reach a log, whichever variant — only the cipher choice.
|
||||
for key in both_keys() {
|
||||
let dbg = format!("{key:?}");
|
||||
assert!(dbg.contains("<redacted>"), "{dbg}");
|
||||
}
|
||||
let mut k = SessionKey::ChaCha20Poly1305([9u8; 32]);
|
||||
k.zeroize();
|
||||
assert!(k.is_zero());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,11 +47,50 @@ 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).
|
||||
max_blocks: usize,
|
||||
}
|
||||
|
||||
/// One in-progress **streamed** access unit (design/nvenc-subframe-slice-output.md Phase 2):
|
||||
/// the caller feeds encoder chunks in as they exist ([`Packetizer::push_streamed`]) and every
|
||||
/// completed `max_data_per_block × shard_payload` block leaves under SENTINEL headers
|
||||
/// (`frame_bytes = 0`, `block_count = 0` — "not final yet") before the AU's total size is
|
||||
/// known; [`Packetizer::finish_streamed`] seals the tail block with the real totals and
|
||||
/// `FLAG_EOF`. Only ever sent to a peer that advertised
|
||||
/// [`crate::quic::VIDEO_CAP_STREAMED_AU`].
|
||||
pub struct StreamedAu {
|
||||
frame_index: u32,
|
||||
pts_ns: u64,
|
||||
user_flags: u32,
|
||||
/// Bytes not yet sealed into a block. Kept ≤ one block by `push_streamed` (it flushes only
|
||||
/// while STRICTLY more than a block is buffered, so the final block always has ≥ 1 byte and
|
||||
/// a sentinel block is never retroactively the frame's last).
|
||||
pending: Vec<u8>,
|
||||
/// Sentinel blocks already emitted.
|
||||
blocks_out: u16,
|
||||
/// Total AU bytes accumulated so far (`pending` included).
|
||||
total_bytes: u64,
|
||||
/// The frame's first packet (block 0, shard 0 — carries `FLAG_SOF`) has been emitted.
|
||||
opened: bool,
|
||||
}
|
||||
|
||||
impl StreamedAu {
|
||||
/// The wire frame index this AU is sealed with (the caller's RFI bookkeeping domain).
|
||||
pub fn frame_index(&self) -> u32 {
|
||||
self.frame_index
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
next_frame_index: 0,
|
||||
next_probe_index: 0,
|
||||
@@ -64,6 +103,7 @@ 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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,4 +317,202 @@ impl Packetizer {
|
||||
self.next_seq = next_seq;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open a **streamed** access unit (see [`StreamedAu`]). `frame_index` follows the same
|
||||
/// contract as [`packetize_each`](Self::packetize_each): `Some(i)` = the caller owns the
|
||||
/// video numbering; `None` draws from the internal counter.
|
||||
pub fn begin_streamed(
|
||||
&mut self,
|
||||
pts_ns: u64,
|
||||
user_flags: u32,
|
||||
frame_index: Option<u32>,
|
||||
) -> StreamedAu {
|
||||
let frame_index = frame_index.unwrap_or_else(|| {
|
||||
let i = self.next_frame_index;
|
||||
self.next_frame_index = i.wrapping_add(1);
|
||||
i
|
||||
});
|
||||
StreamedAu {
|
||||
frame_index,
|
||||
pts_ns,
|
||||
user_flags,
|
||||
pending: Vec::new(),
|
||||
blocks_out: 0,
|
||||
total_bytes: 0,
|
||||
opened: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one encoder chunk into a streamed AU, emitting every block that COMPLETES under
|
||||
/// sentinel headers (`frame_bytes = 0`, `block_count = 0`, exactly `max_data_per_block`
|
||||
/// data shards — the receiver's offset formula needs no total). Flushes only while
|
||||
/// STRICTLY more than one block is buffered, so the frame's last block — whose header must
|
||||
/// carry the real totals — is never emitted here. Packets reach `emit` in wire order (the
|
||||
/// caller's nonce order), data then parity per block.
|
||||
pub fn push_streamed(
|
||||
&mut self,
|
||||
au: &mut StreamedAu,
|
||||
chunk: &[u8],
|
||||
coder: &dyn ErasureCoder,
|
||||
mut emit: impl FnMut(&PacketHeader, &[u8]) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
au.total_bytes += chunk.len() as u64;
|
||||
au.pending.extend_from_slice(chunk);
|
||||
let block_bytes = self.fec.max_data_per_block as usize * self.shard_payload;
|
||||
while au.pending.len() > block_bytes {
|
||||
// Room for this sentinel block AND the final block after it, within the peer's
|
||||
// per-frame block ceiling and the u16 wire field.
|
||||
if au.blocks_out as usize + 2 > self.max_blocks.min(u16::MAX as usize) {
|
||||
return Err(PunktfunkError::Unsupported(
|
||||
"streamed AU exceeds the negotiated max_frame_bytes",
|
||||
));
|
||||
}
|
||||
let sof = !au.opened;
|
||||
let (bi, pts, uf) = (au.blocks_out, au.pts_ns, au.user_flags);
|
||||
let fi = au.frame_index;
|
||||
self.emit_streamed_block(
|
||||
fi,
|
||||
pts,
|
||||
uf,
|
||||
bi,
|
||||
&au.pending[..block_bytes],
|
||||
0,
|
||||
0,
|
||||
sof,
|
||||
false,
|
||||
coder,
|
||||
&mut emit,
|
||||
)?;
|
||||
au.pending.drain(..block_bytes);
|
||||
au.blocks_out += 1;
|
||||
au.opened = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close a streamed AU: seal the final block — its headers carry the REAL
|
||||
/// `frame_bytes`/`block_count`, which retro-validate the whole frame at the receiver — with
|
||||
/// `FLAG_EOF` on the last emitted packet. An empty AU degenerates to today's single
|
||||
/// zero-padded-shard frame (`block_count = 1`, never a sentinel).
|
||||
pub fn finish_streamed(
|
||||
&mut self,
|
||||
au: StreamedAu,
|
||||
coder: &dyn ErasureCoder,
|
||||
mut emit: impl FnMut(&PacketHeader, &[u8]) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
let frame_bytes = u32::try_from(au.total_bytes)
|
||||
.map_err(|_| PunktfunkError::Unsupported("streamed AU exceeds u32 bytes"))?;
|
||||
let block_count = au.blocks_out + 1;
|
||||
self.emit_streamed_block(
|
||||
au.frame_index,
|
||||
au.pts_ns,
|
||||
au.user_flags,
|
||||
au.blocks_out,
|
||||
&au.pending,
|
||||
frame_bytes,
|
||||
block_count,
|
||||
!au.opened,
|
||||
true,
|
||||
coder,
|
||||
&mut emit,
|
||||
)
|
||||
}
|
||||
|
||||
/// Seal ONE streamed block (data shards + its parity, in wire order). Sentinel blocks pass
|
||||
/// `frame_bytes = 0` / `block_count = 0`; the final block passes the real totals. `sof`
|
||||
/// marks the frame's very first packet, `eof` its very last.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn emit_streamed_block(
|
||||
&mut self,
|
||||
frame_index: u32,
|
||||
pts_ns: u64,
|
||||
user_flags: u32,
|
||||
block_index: u16,
|
||||
bytes: &[u8],
|
||||
frame_bytes: u32,
|
||||
block_count: u16,
|
||||
sof: bool,
|
||||
eof: bool,
|
||||
coder: &dyn ErasureCoder,
|
||||
emit: &mut impl FnMut(&PacketHeader, &[u8]) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
let payload = self.shard_payload;
|
||||
if payload > u16::MAX as usize {
|
||||
return Err(PunktfunkError::InvalidArg("shard_payload exceeds u16"));
|
||||
}
|
||||
// At least one (zero-padded) data shard even for an empty final block (empty AU).
|
||||
let k = bytes.len().div_ceil(payload).max(1);
|
||||
let m = self
|
||||
.fec
|
||||
.recovery_for(k)
|
||||
.min(self.max_total_shards.saturating_sub(k));
|
||||
if k + m > u16::MAX as usize {
|
||||
return Err(PunktfunkError::Unsupported("block shard count exceeds u16"));
|
||||
}
|
||||
// Stage the one possibly-partial (or empty-frame) shard in the zero-padded scratch.
|
||||
let full_shards = bytes.len() / payload;
|
||||
self.tail.clear();
|
||||
self.tail.resize(payload, 0);
|
||||
let rem = bytes.len() % payload;
|
||||
if rem > 0 {
|
||||
self.tail[..rem].copy_from_slice(&bytes[full_shards * payload..]);
|
||||
}
|
||||
let tail = &self.tail;
|
||||
let shard_at = |s: usize| -> &[u8] {
|
||||
if s < full_shards {
|
||||
&bytes[s * payload..(s + 1) * payload]
|
||||
} else {
|
||||
tail.as_slice()
|
||||
}
|
||||
};
|
||||
let data_shards: Vec<&[u8]> = (0..k).map(shard_at).collect();
|
||||
if self.recovery.is_empty() {
|
||||
self.recovery.push(Vec::new());
|
||||
}
|
||||
coder.encode_into(&data_shards, m, &mut self.recovery[0])?;
|
||||
|
||||
let mut next_seq = self.next_seq;
|
||||
let mut emit_one = |next_seq: &mut u32, shard_index: usize, body: &[u8], flags: u8| {
|
||||
let seq = *next_seq;
|
||||
*next_seq = next_seq.wrapping_add(1);
|
||||
let hdr = PacketHeader {
|
||||
pts_ns,
|
||||
frame_index,
|
||||
stream_seq: seq,
|
||||
frame_bytes,
|
||||
user_flags,
|
||||
block_index,
|
||||
block_count,
|
||||
data_shards: k as u16,
|
||||
recovery_shards: m as u16,
|
||||
shard_index: shard_index as u16,
|
||||
shard_bytes: payload as u16,
|
||||
magic: PUNKTFUNK_MAGIC,
|
||||
version: self.version,
|
||||
fec_scheme: coder.scheme() as u8,
|
||||
flags,
|
||||
};
|
||||
emit(&hdr, body)
|
||||
};
|
||||
for (shard_index, body) in data_shards.iter().enumerate() {
|
||||
let mut flags = FLAG_PIC;
|
||||
if sof && shard_index == 0 {
|
||||
flags |= FLAG_SOF;
|
||||
}
|
||||
if eof && m == 0 && shard_index + 1 == k {
|
||||
flags |= FLAG_EOF;
|
||||
}
|
||||
emit_one(&mut next_seq, shard_index, body, flags)?;
|
||||
}
|
||||
for r in 0..m {
|
||||
let mut flags = FLAG_PIC;
|
||||
if eof && r + 1 == m {
|
||||
flags |= FLAG_EOF;
|
||||
}
|
||||
let body: &[u8] = &self.recovery[0][r];
|
||||
emit_one(&mut next_seq, k + r, body, flags)?;
|
||||
}
|
||||
self.next_seq = next_seq;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,12 @@ struct BlockState {
|
||||
}
|
||||
|
||||
struct FrameBuf {
|
||||
/// 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).
|
||||
frame_bytes: usize,
|
||||
/// Block count; 0 = unknown (sentinel-opened, totals not yet pinned). A legacy-opened
|
||||
/// frame always has ≥ 1 here, so 0 doubles as the "unpinned streamed" marker.
|
||||
block_count: usize,
|
||||
pts_ns: u64,
|
||||
user_flags: u32,
|
||||
@@ -277,25 +282,49 @@ impl Reassembler {
|
||||
|| total == 0
|
||||
|| total > lim.max_total_shards
|
||||
|| shard_index >= total
|
||||
|| block_count == 0
|
||||
|| block_count > lim.max_blocks
|
||||
|| hdr.block_index as usize >= block_count
|
||||
|| frame_bytes > lim.max_frame_bytes
|
||||
{
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
// Derived-geometry firewall: every sender (our Packetizer, any version) slices a frame
|
||||
// into consecutive blocks of exactly `max_data_per_block` data shards with only the LAST
|
||||
// block smaller, and stamps the exact `frame_bytes` in every header. That makes every
|
||||
// data shard's final AU offset computable on arrival —
|
||||
// Streamed-AU sentinel ([`crate::quic::VIDEO_CAP_STREAMED_AU`]): `block_count == 0` — a
|
||||
// value no legacy sender ever emits — marks a NON-FINAL block of an AU whose total size
|
||||
// doesn't exist yet (the host is still encoding its tail). The exact derived-geometry
|
||||
// check below can't run without a total, so a sentinel is bounded by the negotiated
|
||||
// limits instead — and by construction: full-K exactly (the offset formula needs it),
|
||||
// never the last block the limits allow (the real final block must still fit after it),
|
||||
// and no total to lie about. The frame-wide exact check runs retroactively the moment
|
||||
// the final block's real totals arrive (the pinning arm below).
|
||||
let sentinel = block_count == 0;
|
||||
let block_idx = hdr.block_index as usize;
|
||||
// For a sentinel-opened frame the buffer must hold ANY final geometry the totals may
|
||||
// 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);
|
||||
let total_data = frame_bytes.div_ceil(shard_bytes).max(1);
|
||||
if sentinel {
|
||||
if frame_bytes != 0
|
||||
|| data_shards != lim.max_data_shards
|
||||
|| block_idx + 1 >= lim.max_blocks
|
||||
{
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
} else {
|
||||
if block_count > lim.max_blocks || block_idx >= block_count {
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
// Derived-geometry firewall: every sender (our Packetizer, any version) slices a
|
||||
// frame into consecutive blocks of exactly `max_data_per_block` data shards with
|
||||
// only the LAST block smaller, and stamps the exact `frame_bytes` in every
|
||||
// non-sentinel header. That makes every data shard's final AU offset computable on
|
||||
// arrival —
|
||||
// offset = (block_index × max_data_per_block + shard_index) × shard_bytes
|
||||
// — which is what lets shards land straight in the frame buffer below. Enforce the
|
||||
// invariant so a header lying about its geometry is dropped instead of scribbling into
|
||||
// another shard's range.
|
||||
let total_data = frame_bytes.div_ceil(shard_bytes).max(1);
|
||||
// invariant so a header lying about its geometry is dropped instead of scribbling
|
||||
// into another shard's range.
|
||||
let expect_blocks = total_data.div_ceil(lim.max_data_shards).max(1);
|
||||
let block_idx = hdr.block_index as usize;
|
||||
let expect_data_shards = if block_idx + 1 == expect_blocks {
|
||||
total_data - (expect_blocks - 1) * lim.max_data_shards
|
||||
} else {
|
||||
@@ -305,6 +334,7 @@ impl Reassembler {
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
let body = &pkt[HEADER_LEN..HEADER_LEN + shard_bytes];
|
||||
|
||||
// Route by index space: speed-test probe filler (FLAG_PROBE in user_flags) reassembles in
|
||||
@@ -350,8 +380,13 @@ impl Reassembler {
|
||||
}
|
||||
|
||||
// First packet of a frame allocates its whole (zeroed) buffer, budget-gated; later
|
||||
// packets must agree with its geometry.
|
||||
let buf_len = total_data * shard_bytes;
|
||||
// packets must agree with its geometry. A sentinel-opened (streamed) frame allocates at
|
||||
// the limits' maximum — its real size doesn't exist yet.
|
||||
let buf_len = if sentinel {
|
||||
total_data_max * shard_bytes
|
||||
} else {
|
||||
total_data * shard_bytes
|
||||
};
|
||||
let frame = match win.frames.entry(hdr.frame_index) {
|
||||
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
|
||||
std::collections::hash_map::Entry::Vacant(e) => {
|
||||
@@ -374,7 +409,66 @@ impl Reassembler {
|
||||
})
|
||||
}
|
||||
};
|
||||
if frame.block_count != block_count || frame.frame_bytes != frame_bytes {
|
||||
if sentinel {
|
||||
// Sentinel packets carry no totals to cross-check: while the frame is unpinned
|
||||
// (`block_count == 0`) they match by construction, and once the totals are pinned —
|
||||
// whichever packet arrived first, sentinel or final (reorder is normal) — a
|
||||
// sentinel block must still be NON-final under them. Its full-K shape was already
|
||||
// enforced by the firewall, which is exactly what the pinned geometry demands of
|
||||
// every non-final block, and its shard offsets are within the pinned range by
|
||||
// `block_idx + 1 < block_count`.
|
||||
if frame.block_count != 0 && block_idx + 1 >= frame.block_count {
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
} else if frame.block_count == 0 {
|
||||
// A streamed frame meets its FINAL block's real totals: retro-validate every block
|
||||
// the sentinels created against the exact geometry these totals derive (the same
|
||||
// invariant the firewall enforces per-packet for legacy frames), then PIN them. A
|
||||
// header that lies — totals under which an already-received sentinel block is
|
||||
// out of range or not full-K — kills the WHOLE frame: its landed shards can't be
|
||||
// trusted to be at the offsets this geometry means, so delivering any of it would
|
||||
// hand the decoder spliced bytes.
|
||||
let expect_blocks = total_data.div_ceil(lim.max_data_shards).max(1);
|
||||
let final_k = total_data - (expect_blocks - 1) * lim.max_data_shards;
|
||||
let lied = frame.blocks.iter().any(|(&bi, b)| {
|
||||
let bi = bi as usize;
|
||||
bi >= expect_blocks
|
||||
|| (bi + 1 < expect_blocks && b.data_shards != lim.max_data_shards)
|
||||
|| (bi + 1 == expect_blocks && b.data_shards != final_k)
|
||||
});
|
||||
if lied {
|
||||
let mut f = win
|
||||
.frames
|
||||
.remove(&hdr.frame_index)
|
||||
.expect("frame entry exists");
|
||||
*in_flight_bytes -= f.buf.len();
|
||||
// Remember the index (with its late-shard memory, exactly like an aged-out
|
||||
// frame) so stragglers can't resurrect it, reclaim the parity buffers, and
|
||||
// count the loss — the client's recovery request is the right outcome for a
|
||||
// frame that was destroyed by a lying header.
|
||||
win.completed.insert(
|
||||
hdr.frame_index,
|
||||
reconstructed_shards(&f.blocks, lim.max_data_shards),
|
||||
);
|
||||
for block in f.blocks.values_mut() {
|
||||
for slot in block.recovery.iter_mut() {
|
||||
if let Some(rb) = slot.take() {
|
||||
if recovery_pool.len() < RECOVERY_POOL_MAX {
|
||||
recovery_pool.push(rb);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !is_probe {
|
||||
StatsCounters::add(&stats.frames_dropped, 1);
|
||||
}
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
frame.frame_bytes = frame_bytes;
|
||||
frame.block_count = block_count;
|
||||
} else if frame.block_count != block_count || frame.frame_bytes != frame_bytes {
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -382,6 +476,7 @@ impl Reassembler {
|
||||
buf,
|
||||
blocks,
|
||||
blocks_ok,
|
||||
block_count: frame_block_count,
|
||||
..
|
||||
} = frame;
|
||||
|
||||
@@ -402,6 +497,14 @@ impl Reassembler {
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
// Defense-in-depth (2026-07 security review): the geometry invariants above guarantee
|
||||
// every packet of a block agrees on K, so this can't fire today — but `have_data`
|
||||
// indexing and the recovery-slot math below assume it, and an explicit check keeps a
|
||||
// future firewall refactor from turning that assumption into an OOB panic.
|
||||
if block.data_shards != data_shards {
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
if block.done {
|
||||
// A data shard the parity reconstruct already restored (`!have_data`) was late, not
|
||||
// lost — net it out of the `fec_recovered_shards` it was counted into (see the
|
||||
@@ -485,8 +588,12 @@ impl Reassembler {
|
||||
}
|
||||
}
|
||||
|
||||
// Whole frame ready?
|
||||
if *blocks_ok == block_count {
|
||||
// Whole frame ready? Judged against the FRAME's pinned block count, not this packet's
|
||||
// header — a streamed frame can complete on a reordered sentinel packet (header says 0)
|
||||
// after the final block already pinned the real totals, and can never complete before
|
||||
// they're pinned (`0` never equals a non-zero `blocks_ok`).
|
||||
let block_count = *frame_block_count;
|
||||
if block_count != 0 && *blocks_ok == block_count {
|
||||
let mut done = win.frames.remove(&hdr.frame_index).unwrap();
|
||||
win.completed.insert(
|
||||
hdr.frame_index,
|
||||
@@ -595,7 +702,10 @@ impl ReassemblyWindow {
|
||||
// where shards are missing (the codec's block walk skips zero windows).
|
||||
// Newest-wins if several age out in one prune. Still counted dropped below.
|
||||
if let Some(sink) = partial_sink.as_deref_mut() {
|
||||
if f.user_flags & USER_FLAG_CHUNK_ALIGNED != 0 {
|
||||
// `frame_bytes > 0` also excludes an UNPINNED streamed frame (its total is
|
||||
// still the 0 sentinel value): truncating its max-sized buffer to 0 would
|
||||
// deliver an empty "partial" — worse than the plain drop it gets instead.
|
||||
if f.user_flags & USER_FLAG_CHUNK_ALIGNED != 0 && f.frame_bytes > 0 {
|
||||
let mut buf = std::mem::take(&mut f.buf);
|
||||
buf.truncate(f.frame_bytes);
|
||||
let newer = sink
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::reassemble::LOSS_WINDOW_NS;
|
||||
use super::*;
|
||||
use crate::config::{Config, FecScheme};
|
||||
use crate::crypto::SessionKey;
|
||||
use crate::fec::coder_for;
|
||||
use crate::stats::StatsCounters;
|
||||
use zerocopy::{FromBytes, IntoBytes};
|
||||
@@ -182,7 +183,7 @@ fn explicit_frame_index_is_stamped_and_internal_counter_untouched() {
|
||||
shard_payload: 16,
|
||||
max_frame_bytes: 4096,
|
||||
encrypt: false,
|
||||
key: [0u8; 16],
|
||||
key: SessionKey::Aes128Gcm([0u8; 16]),
|
||||
salt: [0u8; 4],
|
||||
loopback_drop_period: 0,
|
||||
};
|
||||
@@ -292,7 +293,7 @@ fn e2e_config(scheme: FecScheme, fec_percent: u8) -> Config {
|
||||
shard_payload: 16,
|
||||
max_frame_bytes: 4096,
|
||||
encrypt: false,
|
||||
key: [0u8; 16],
|
||||
key: SessionKey::Aes128Gcm([0u8; 16]),
|
||||
salt: [0u8; 4],
|
||||
loopback_drop_period: 0,
|
||||
}
|
||||
@@ -640,3 +641,402 @@ fn adaptive_fec_ramp_keeps_maximal_blocks_within_the_peers_ceiling() {
|
||||
src
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Streamed access units (VIDEO_CAP_STREAMED_AU — nvenc-subframe-slice-output.md Phase 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Packetize one streamed AU from `chunks` via begin/push/finish, returning the emitted wire
|
||||
/// packets (header ++ shard) and the concatenated source bytes.
|
||||
fn streamed_packets(
|
||||
scheme: FecScheme,
|
||||
fec_percent: u8,
|
||||
chunks: &[&[u8]],
|
||||
) -> (Vec<Vec<u8>>, Vec<u8>) {
|
||||
let cfg = e2e_config(scheme, fec_percent);
|
||||
let coder = coder_for(scheme);
|
||||
let mut pk = Packetizer::new(&cfg);
|
||||
let mut au = pk.begin_streamed(12345, 0, Some(0));
|
||||
let mut pkts: Vec<Vec<u8>> = Vec::new();
|
||||
let mut src = Vec::new();
|
||||
for c in chunks {
|
||||
src.extend_from_slice(c);
|
||||
pk.push_streamed(&mut au, c, coder.as_ref(), |h: &PacketHeader, b: &[u8]| {
|
||||
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();
|
||||
}
|
||||
pk.finish_streamed(au, coder.as_ref(), |h: &PacketHeader, b: &[u8]| {
|
||||
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)
|
||||
}
|
||||
|
||||
/// Deliver a streamed AU's packets (optionally with kills within the FEC budget, reversed
|
||||
/// order, and a duplicate) and assert byte-identical completion. Reversed order is the
|
||||
/// critical case: the FINAL block's real-total headers arrive FIRST, the frame opens
|
||||
/// legacy-shaped, and the sentinels must still be accepted against the pinned totals.
|
||||
fn streamed_roundtrip(scheme: FecScheme, kill: &[usize], reverse: bool) {
|
||||
let chunks: Vec<Vec<u8>> = (0..3)
|
||||
.map(|c| (0..50).map(|i| (c * 57 + i * 131 + 7) as u8).collect())
|
||||
.collect();
|
||||
let chunk_refs: Vec<&[u8]> = chunks.iter().map(|c| c.as_slice()).collect();
|
||||
let (pkts, src) = streamed_packets(scheme, 50, &chunk_refs);
|
||||
// 150 B / 16 B shards / 4-shard blocks → sentinel blocks 0,1 (4 data + 2 rec each) +
|
||||
// final block (2 data + 1 rec) = 15 packets.
|
||||
assert_eq!(
|
||||
pkts.len(),
|
||||
15,
|
||||
"expected geometry changed — update the kills"
|
||||
);
|
||||
|
||||
let mut delivery: Vec<Vec<u8>> = pkts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| !kill.contains(i))
|
||||
.map(|(_, p)| p.clone())
|
||||
.collect();
|
||||
if reverse {
|
||||
delivery.reverse();
|
||||
}
|
||||
if let Some(dup) = delivery.first().cloned() {
|
||||
delivery.push(dup);
|
||||
}
|
||||
|
||||
let cfg = e2e_config(scheme, 50);
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let coder = coder_for(scheme);
|
||||
let stats = StatsCounters::default();
|
||||
let mut got = None;
|
||||
for p in &delivery {
|
||||
if let Some(f) = r.push(p, coder.as_ref(), &stats).unwrap() {
|
||||
assert!(got.is_none(), "frame must complete exactly once");
|
||||
got = Some(f);
|
||||
}
|
||||
}
|
||||
let f = got.expect("streamed frame must complete within the FEC budget");
|
||||
assert_eq!(
|
||||
f.data, src,
|
||||
"reassembled streamed AU must be byte-identical"
|
||||
);
|
||||
assert_eq!(f.pts_ns, 12345);
|
||||
assert!(f.complete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_roundtrip_clean_and_reversed() {
|
||||
streamed_roundtrip(FecScheme::Gf16, &[], false);
|
||||
streamed_roundtrip(FecScheme::Gf16, &[], true);
|
||||
streamed_roundtrip(FecScheme::Gf8, &[], true);
|
||||
}
|
||||
|
||||
/// Loss within each block's FEC budget: one data shard from a sentinel block, one from the
|
||||
/// final block — in both delivery orders.
|
||||
#[test]
|
||||
fn streamed_roundtrip_survives_loss_and_reorder() {
|
||||
// Wire order: blk0 = 0..4 data + 4..6 rec, blk1 = 6..10 data + 10..12 rec,
|
||||
// final = 12..14 data + 14 rec.
|
||||
streamed_roundtrip(FecScheme::Gf16, &[1, 12], false);
|
||||
streamed_roundtrip(FecScheme::Gf16, &[1, 12], true);
|
||||
}
|
||||
|
||||
/// The wire shape of a streamed AU: sentinel headers (block_count = 0, frame_bytes = 0,
|
||||
/// full-K) on every non-final block, real totals + EOF on the final block, SOF on the very
|
||||
/// first packet only.
|
||||
#[test]
|
||||
fn streamed_headers_sentinel_then_final() {
|
||||
let chunks: Vec<Vec<u8>> = (0..3).map(|_| vec![0xA5u8; 50]).collect();
|
||||
let chunk_refs: Vec<&[u8]> = chunks.iter().map(|c| c.as_slice()).collect();
|
||||
let (pkts, src) = streamed_packets(FecScheme::Gf16, 50, &chunk_refs);
|
||||
let mut saw_final = false;
|
||||
for (i, p) in pkts.iter().enumerate() {
|
||||
let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
|
||||
assert_eq!(
|
||||
h.flags & FLAG_SOF != 0,
|
||||
i == 0,
|
||||
"SOF exactly on the first packet"
|
||||
);
|
||||
assert_eq!(
|
||||
h.flags & FLAG_EOF != 0,
|
||||
i + 1 == pkts.len(),
|
||||
"EOF exactly on the last packet"
|
||||
);
|
||||
if h.block_index < 2 {
|
||||
assert_eq!(
|
||||
h.block_count, 0,
|
||||
"non-final block must ride sentinel headers"
|
||||
);
|
||||
assert_eq!(h.frame_bytes, 0);
|
||||
assert_eq!(h.data_shards, 4, "sentinel blocks are exactly full-K");
|
||||
} else {
|
||||
saw_final = true;
|
||||
assert_eq!(h.block_count, 3, "final block carries the real block count");
|
||||
assert_eq!(h.frame_bytes as usize, src.len(), "and the real AU size");
|
||||
}
|
||||
}
|
||||
assert!(saw_final);
|
||||
}
|
||||
|
||||
/// A streamed AU smaller than one block emits NO sentinels — its single (final) block is
|
||||
/// byte-identical in shape to a legacy frame, so small frames pay zero streaming overhead
|
||||
/// and any receiver accepts them.
|
||||
#[test]
|
||||
fn streamed_small_frame_degenerates_to_legacy() {
|
||||
let (pkts, src) = streamed_packets(FecScheme::Gf16, 50, &[&[0x5Au8; 40]]);
|
||||
for p in &pkts {
|
||||
let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
|
||||
assert_eq!(
|
||||
h.block_count, 1,
|
||||
"single-block streamed AU must be legacy-shaped"
|
||||
);
|
||||
assert_eq!(h.frame_bytes as usize, src.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// Sentinel firewall: a sentinel header that is not exactly full-K, or claims a non-zero
|
||||
/// total, or sits where the final block could no longer follow, is dropped before any
|
||||
/// allocation happens.
|
||||
#[test]
|
||||
fn streamed_sentinel_firewall_bounds() {
|
||||
let mut r = Reassembler::new(limits());
|
||||
let coder = coder_for(FecScheme::Gf8);
|
||||
let stats = StatsCounters::default();
|
||||
let sentinel = |f: fn(&mut PacketHeader)| {
|
||||
let mut h = base_header();
|
||||
h.block_count = 0;
|
||||
h.frame_bytes = 0;
|
||||
h.data_shards = 8; // limits().max_data_shards — the only legal sentinel K
|
||||
h.recovery_shards = 0;
|
||||
f(&mut h);
|
||||
h
|
||||
};
|
||||
// Not full-K.
|
||||
let h = sentinel(|h| h.data_shards = 7);
|
||||
assert!(r
|
||||
.push(&packet(h), coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
// Claims a total.
|
||||
let h = sentinel(|h| h.frame_bytes = 64);
|
||||
assert!(r
|
||||
.push(&packet(h), coder.as_ref(), &stats)
|
||||
.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
|
||||
assert!(r
|
||||
.push(&packet(h), coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert_eq!(stats.snapshot().packets_dropped, 3);
|
||||
// A conformant sentinel IS accepted (proves the rejections above weren't vacuous).
|
||||
let h = sentinel(|_| {});
|
||||
assert!(r
|
||||
.push(&packet(h), coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
stats.snapshot().packets_dropped,
|
||||
3,
|
||||
"conformant sentinel accepted"
|
||||
);
|
||||
}
|
||||
|
||||
/// Retro-validation: final-block totals under which an already-received sentinel block is
|
||||
/// out of range (or mis-sized) kill the WHOLE frame — no spliced delivery — and the killed
|
||||
/// index cannot be resurrected by stragglers.
|
||||
#[test]
|
||||
fn streamed_lying_final_totals_kill_the_frame_wholesale() {
|
||||
let mut r = Reassembler::new(limits());
|
||||
let coder = coder_for(FecScheme::Gf8);
|
||||
let stats = StatsCounters::default();
|
||||
// Two sentinel blocks (indexes 0 and 1) open the frame and land shards.
|
||||
for bi in 0..2u16 {
|
||||
let mut h = base_header();
|
||||
h.block_count = 0;
|
||||
h.frame_bytes = 0;
|
||||
h.data_shards = 8;
|
||||
h.recovery_shards = 0;
|
||||
h.block_index = bi;
|
||||
assert!(r
|
||||
.push(&packet(h), coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
// A "final" header claiming the whole AU is ONE 16-byte shard: geometry-valid on its own
|
||||
// (expect_blocks = 1, K = 1), but it disowns both sentinel blocks → the frame dies.
|
||||
let mut lying = base_header();
|
||||
lying.block_count = 1;
|
||||
lying.frame_bytes = 16;
|
||||
lying.data_shards = 1;
|
||||
lying.recovery_shards = 0;
|
||||
assert!(r
|
||||
.push(&packet(lying), coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
let snap = stats.snapshot();
|
||||
assert_eq!(
|
||||
snap.frames_dropped, 1,
|
||||
"the lying frame must be counted lost"
|
||||
);
|
||||
// A straggler sentinel for the killed index must not resurrect it.
|
||||
let mut h = base_header();
|
||||
h.block_count = 0;
|
||||
h.frame_bytes = 0;
|
||||
h.data_shards = 8;
|
||||
h.recovery_shards = 0;
|
||||
let before = stats.snapshot().packets_dropped;
|
||||
assert!(r
|
||||
.push(&packet(h), coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
stats.snapshot().packets_dropped,
|
||||
before + 1,
|
||||
"straggler for a killed frame must be dropped, not re-open it"
|
||||
);
|
||||
}
|
||||
|
||||
/// A sentinel first-packet commits a MAX-sized frame buffer, so the in-flight budget must
|
||||
/// bite after IN_FLIGHT_BUF_FACTOR frames — the amplification bound for one-datagram opens.
|
||||
#[test]
|
||||
fn streamed_open_amplification_is_budget_bounded() {
|
||||
let mut r = Reassembler::new(limits());
|
||||
let coder = coder_for(FecScheme::Gf8);
|
||||
let stats = StatsCounters::default();
|
||||
// limits(): max_frame_bytes 4096 → each sentinel open commits 4096 B; budget = 4×4096.
|
||||
for fi in 0..5u32 {
|
||||
let mut h = base_header();
|
||||
h.block_count = 0;
|
||||
h.frame_bytes = 0;
|
||||
h.data_shards = 8;
|
||||
h.recovery_shards = 0;
|
||||
h.frame_index = fi;
|
||||
assert!(r
|
||||
.push(&packet(h), coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
assert_eq!(
|
||||
stats.snapshot().packets_dropped,
|
||||
1,
|
||||
"the fifth max-sized open must be refused by the in-flight budget"
|
||||
);
|
||||
}
|
||||
|
||||
/// The final-first order's single buffer-safety guard (2026-07 security review finding 3): a
|
||||
/// frame opened by its FINAL block allocates an EXACT-sized buffer; a sentinel aimed at (or
|
||||
/// past) the pinned final slot must be dropped — without the guard its full-K write would land
|
||||
/// outside that buffer. And the reject must not corrupt the frame: it still completes.
|
||||
#[test]
|
||||
fn streamed_out_of_range_sentinel_after_final_first_is_dropped() {
|
||||
let mut r = Reassembler::new(limits());
|
||||
let coder = coder_for(FecScheme::Gf8);
|
||||
let stats = StatsCounters::default();
|
||||
// Final block opens the frame: block_count = 1, frame_bytes = 32 → K = 2. Send shard 0
|
||||
// only, so the frame stays in flight with the totals pinned.
|
||||
let mut fin = base_header();
|
||||
fin.block_count = 1;
|
||||
fin.frame_bytes = 32;
|
||||
fin.data_shards = 2;
|
||||
fin.recovery_shards = 0;
|
||||
fin.shard_index = 0;
|
||||
assert!(r
|
||||
.push(&packet(fin), coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
// Sentinels at the final slot (0) and past it (1): both non-final-impossible under the
|
||||
// pinned block_count = 1 → dropped, never written into the 32-byte buffer.
|
||||
for bi in 0..2u16 {
|
||||
let mut h = base_header();
|
||||
h.block_count = 0;
|
||||
h.frame_bytes = 0;
|
||||
h.data_shards = 8;
|
||||
h.recovery_shards = 0;
|
||||
h.block_index = bi;
|
||||
assert!(r
|
||||
.push(&packet(h), coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
assert_eq!(stats.snapshot().packets_dropped, 2);
|
||||
// The frame is unharmed: its real second shard completes it at the exact pinned length.
|
||||
let mut fin2 = fin;
|
||||
fin2.shard_index = 1;
|
||||
let got = r
|
||||
.push(&packet(fin2), coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.expect("frame must still complete after the rejected sentinels");
|
||||
assert_eq!(got.data.len(), 32);
|
||||
assert!(got.complete);
|
||||
}
|
||||
|
||||
/// A second "final" header with DIFFERENT totals must be rejected once a streamed frame is
|
||||
/// pinned (re-pinning would re-interpret already-landed shards), and the frame must still
|
||||
/// complete under the first totals.
|
||||
#[test]
|
||||
fn streamed_second_final_with_different_totals_is_rejected() {
|
||||
let mut r = Reassembler::new(limits());
|
||||
let coder = coder_for(FecScheme::Gf8);
|
||||
let stats = StatsCounters::default();
|
||||
let sentinel_shard = |shard_index: u16| {
|
||||
let mut h = base_header();
|
||||
h.block_count = 0;
|
||||
h.frame_bytes = 0;
|
||||
h.data_shards = 8;
|
||||
h.recovery_shards = 0;
|
||||
h.block_index = 0;
|
||||
h.shard_index = shard_index;
|
||||
h
|
||||
};
|
||||
let final_shard = |frame_bytes: u32, data_shards: u16, shard_index: u16| {
|
||||
let mut h = base_header();
|
||||
h.block_count = 2;
|
||||
h.frame_bytes = frame_bytes;
|
||||
h.data_shards = data_shards;
|
||||
h.recovery_shards = 0;
|
||||
h.block_index = 1;
|
||||
h.shard_index = shard_index;
|
||||
h
|
||||
};
|
||||
// Sentinel opens block 0, then the real final pins totals: 10 shards = 160 bytes.
|
||||
assert!(r
|
||||
.push(&packet(sentinel_shard(0)), coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert!(r
|
||||
.push(&packet(final_shard(160, 2, 0)), coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
// A second final claiming 144 bytes (K = 1): geometry-valid alone, but it contradicts the
|
||||
// pinned totals → dropped.
|
||||
let before = stats.snapshot().packets_dropped;
|
||||
assert!(r
|
||||
.push(&packet(final_shard(144, 1, 0)), coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert_eq!(stats.snapshot().packets_dropped, before + 1);
|
||||
// The frame still completes under the FIRST totals: the rest of block 0 + the final tail.
|
||||
let mut got = None;
|
||||
for s in 1..8u16 {
|
||||
assert!(got.is_none());
|
||||
got = r
|
||||
.push(&packet(sentinel_shard(s)), coder.as_ref(), &stats)
|
||||
.unwrap();
|
||||
}
|
||||
assert!(got.is_none(), "block 1 still owes a shard");
|
||||
let got = r
|
||||
.push(&packet(final_shard(160, 2, 1)), coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.expect("frame completes under the first pinned totals");
|
||||
assert_eq!(got.data.len(), 160);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,29 @@ pub const VIDEO_CAP_HOST_TIMING: u8 = 0x08;
|
||||
/// older client gets a declined (zeroed) [`ProbeResult`] instead of a measurement its single-window
|
||||
/// reassembler would silently drop as stale.
|
||||
pub const VIDEO_CAP_PROBE_SEQ: u8 = 0x10;
|
||||
/// [`Hello::video_caps`] bit: the client's reassembler accepts **streamed access units**
|
||||
/// (design/nvenc-subframe-slice-output.md Phase 2): the host may ship an AU's early FEC blocks
|
||||
/// before the AU's total size exists — while the tail of the frame is still encoding — so the
|
||||
/// AU's last packet leaves the host sooner (latency plan §7 LN1). Non-final blocks ride
|
||||
/// SENTINEL headers (`block_count == 0` — a value no legacy sender emits — with
|
||||
/// `frame_bytes == 0` and exactly `max_data_per_block` data shards, so the shard-offset
|
||||
/// formula needs no total); the FINAL block's headers carry the real
|
||||
/// `frame_bytes`/`block_count` (+ `FLAG_EOF`), which retro-validate the whole frame's geometry
|
||||
/// — a mismatch drops the frame wholesale. The host streams ONLY to clients advertising this
|
||||
/// bit; every other client gets today's whole-AU path (chunks concatenated before sealing), so
|
||||
/// the fallback is zero-risk.
|
||||
pub const VIDEO_CAP_STREAMED_AU: u8 = 0x20;
|
||||
/// [`Hello::video_caps`] bit: the client can open **ChaCha20-Poly1305**-sealed session datagrams
|
||||
/// AND requests them — set by clients without hardware AES (the soft-AES armv7 targets, e.g.
|
||||
/// webOS TVs), where GCM's software AES + GHASH caps decrypt at ~100 Mbps while ChaCha's ARX
|
||||
/// construction runs 4–7× faster in portable code (design/chacha20-session-cipher.md).
|
||||
/// Support-plus-request in one bit mirrors [`VIDEO_CAP_444`]'s "capable AND turned on"
|
||||
/// precedent. The host grants it only when its `PUNKTFUNK_CHACHA20` kill-switch (default on)
|
||||
/// allows, answering with [`Welcome::cipher`] `= 1` + the 32-byte [`Welcome::key_chacha`];
|
||||
/// toward every other client the Welcome stays byte-identical AES-128-GCM. Purely a
|
||||
/// performance choice — both AEADs are full-strength, and Hello/Welcome ride the pinned-TLS
|
||||
/// control channel, so there is no downgrade surface.
|
||||
pub const VIDEO_CAP_CHACHA20: u8 = 0x40;
|
||||
|
||||
/// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`]
|
||||
/// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder
|
||||
@@ -213,6 +236,8 @@ mod tests {
|
||||
audio_channels: 2,
|
||||
codec: CODEC_HEVC,
|
||||
host_caps: HOST_CAP_GAMEPAD_STATE | HOST_CAP_CLIPBOARD,
|
||||
cipher: 0,
|
||||
key_chacha: None,
|
||||
};
|
||||
let got = Welcome::decode(&w.encode()).unwrap();
|
||||
assert_eq!(got.host_caps & HOST_CAP_CLIPBOARD, HOST_CAP_CLIPBOARD);
|
||||
|
||||
@@ -4,6 +4,7 @@ use super::*;
|
||||
use crate::config::{
|
||||
CompositorPref, Config, FecConfig, FecScheme, GamepadPref, Mode, ProtocolPhase, Role,
|
||||
};
|
||||
use crate::crypto::SessionKey;
|
||||
use crate::error::{PunktfunkError, Result};
|
||||
|
||||
/// `client → host`: open the session, requesting a display mode (the host creates its
|
||||
@@ -107,6 +108,13 @@ pub const HELLO_NAME_MAX: usize = 64;
|
||||
/// (`steam:<appid>` / `custom:<12 hex>`); the cap just bounds an attacker-controlled field.
|
||||
pub const HELLO_LAUNCH_MAX: usize = 128;
|
||||
|
||||
/// [`Welcome::cipher`] id: AES-128-GCM — the default session AEAD every peer speaks (and the
|
||||
/// only one pre-cipher builds know).
|
||||
pub const CIPHER_AES_128_GCM: u8 = 0;
|
||||
/// [`Welcome::cipher`] id: ChaCha20-Poly1305 (RFC 8439) — negotiated via
|
||||
/// [`VIDEO_CAP_CHACHA20`] for clients without hardware AES.
|
||||
pub const CIPHER_CHACHA20_POLY1305: u8 = 1;
|
||||
|
||||
/// `host → client`: the complete session offer.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Welcome {
|
||||
@@ -173,6 +181,22 @@ pub struct Welcome {
|
||||
/// per-transition events otherwise). Appended after `codec` as a single trailing byte; an
|
||||
/// older host that omits it decodes to `0` (no capabilities — legacy events only).
|
||||
pub host_caps: u8,
|
||||
/// The session AEAD the data plane seals with — [`CIPHER_AES_128_GCM`] (`0`, the default
|
||||
/// every peer speaks) or [`CIPHER_CHACHA20_POLY1305`] (`1`). The host sets `1` ONLY toward
|
||||
/// a client that advertised [`VIDEO_CAP_CHACHA20`] (the soft-AES armv7 targets). Appended
|
||||
/// after `host_caps` at offset 68 and — unlike the earlier trailing fields — emitted only
|
||||
/// when non-zero, so an AES session's Welcome stays **byte-identical** to the pre-cipher
|
||||
/// wire form; an older host omits it (→ `0`, AES). Decode is fail-closed: an unknown id is
|
||||
/// an `Err`, never a silent AES fallback — the host only picks a cipher this client
|
||||
/// advertised, so an unknown id reaching us is a bug, and falling back would yield an
|
||||
/// undecryptable session with a confusing failure signature.
|
||||
pub cipher: u8,
|
||||
/// The 256-bit ChaCha20-Poly1305 session key (RFC 8439 requires the full 32 bytes; wire
|
||||
/// cost is once per handshake) — present iff `cipher == 1`, at offsets 69..101. The legacy
|
||||
/// 16-byte `key` keeps its offset and stays independently random, so nothing downstream
|
||||
/// ever observes an all-zero key. Decode rejects `cipher == 1` with fewer than 32 key
|
||||
/// bytes following.
|
||||
pub key_chacha: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
/// `client → host`: data plane is bound, begin streaming.
|
||||
@@ -366,6 +390,21 @@ impl Welcome {
|
||||
b.push(self.codec);
|
||||
// Host input caps at offset 67 — older clients stop before this → 0 (legacy input only).
|
||||
b.push(self.host_caps);
|
||||
// Session cipher at offset 68 + the 32-byte ChaCha key at 69..101 — emitted ONLY when a
|
||||
// non-default cipher was negotiated, so an AES session's Welcome stays byte-identical
|
||||
// to the pre-cipher wire form. The host only sets cipher toward a client that
|
||||
// advertised VIDEO_CAP_CHACHA20, so an old client never sees these bytes at all.
|
||||
debug_assert_eq!(
|
||||
self.cipher == CIPHER_CHACHA20_POLY1305,
|
||||
self.key_chacha.is_some(),
|
||||
"key_chacha present iff cipher == 1"
|
||||
);
|
||||
if self.cipher != CIPHER_AES_128_GCM {
|
||||
b.push(self.cipher);
|
||||
if let Some(k) = &self.key_chacha {
|
||||
b.extend_from_slice(k);
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
@@ -374,8 +413,10 @@ impl Welcome {
|
||||
// scheme[22] pct[23] max_data[24..26] shard[26..28] encrypt[28] key[29..45]
|
||||
// salt[45..49] frames[49..53] compositor[53] gamepad[54] bitrate_kbps[55..59]
|
||||
// bit_depth[59] color.primaries[60] color.transfer[61] color.matrix[62] color.range[63]
|
||||
// chroma_format[64] audio_channels[65] codec[66] (everything from compositor on is an
|
||||
// optional trailing byte; an older host stops earlier).
|
||||
// chroma_format[64] audio_channels[65] codec[66] host_caps[67] cipher[68]
|
||||
// key_chacha[69..101] (everything from compositor on is an optional trailing byte; an
|
||||
// older host stops earlier; cipher/key_chacha are present only when ChaCha was
|
||||
// negotiated).
|
||||
if b.len() < 53 || &b[0..4] != MAGIC {
|
||||
return Err(PunktfunkError::InvalidArg("bad Welcome"));
|
||||
}
|
||||
@@ -385,6 +426,24 @@ impl Welcome {
|
||||
key.copy_from_slice(&b[29..45]);
|
||||
let mut salt = [0u8; 4];
|
||||
salt.copy_from_slice(&b[45..49]);
|
||||
// Session cipher at 68 — absent on an older host → AES-128-GCM. Fail-closed on
|
||||
// anything else: `cipher == 1` with fewer than 32 key bytes must be an error (a silent
|
||||
// AES fallback would yield an undecryptable session with a confusing failure
|
||||
// signature), and an unknown id (≥ 2) reaching us is a bug — a host only picks a
|
||||
// cipher this client advertised — never a legitimate negotiation.
|
||||
let cipher = b.get(68).copied().unwrap_or(CIPHER_AES_128_GCM);
|
||||
let key_chacha = match cipher {
|
||||
CIPHER_AES_128_GCM => None,
|
||||
CIPHER_CHACHA20_POLY1305 => {
|
||||
let bytes = b
|
||||
.get(69..101)
|
||||
.ok_or(PunktfunkError::InvalidArg("bad Welcome"))?;
|
||||
let mut k = [0u8; 32];
|
||||
k.copy_from_slice(bytes);
|
||||
Some(k)
|
||||
}
|
||||
_ => return Err(PunktfunkError::InvalidArg("bad Welcome")),
|
||||
};
|
||||
Ok(Welcome {
|
||||
abi_version: u32at(4),
|
||||
udp_port: u16at(8),
|
||||
@@ -452,6 +511,8 @@ impl Welcome {
|
||||
// Optional trailing host-caps byte — absent on an older host → 0 (no gamepad-state
|
||||
// snapshots; the client keeps sending legacy per-transition events).
|
||||
host_caps: b.get(67).copied().unwrap_or(0),
|
||||
cipher,
|
||||
key_chacha,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -462,7 +523,12 @@ impl Welcome {
|
||||
c.fec = self.fec;
|
||||
c.shard_payload = self.shard_payload as usize;
|
||||
c.encrypt = self.encrypt;
|
||||
c.key = self.key;
|
||||
// The negotiated AEAD: the ChaCha key when cipher == 1 (guaranteed present by decode —
|
||||
// the `(1, None)` shape is unreachable off the wire), the legacy AES key otherwise.
|
||||
c.key = match (self.cipher, self.key_chacha) {
|
||||
(CIPHER_CHACHA20_POLY1305, Some(k)) => SessionKey::ChaCha20Poly1305(k),
|
||||
_ => SessionKey::Aes128Gcm(self.key),
|
||||
};
|
||||
c.salt = self.salt;
|
||||
// Client-side reassembler ceiling: p1_defaults' 64 MiB hostile-header memory bound is
|
||||
// ~10x larger than any real access unit. Derive it from the negotiated rate instead:
|
||||
@@ -531,6 +597,8 @@ mod tests {
|
||||
audio_channels: 2,
|
||||
codec: CODEC_H264, // exercise a non-default codec through the roundtrip
|
||||
host_caps: HOST_CAP_GAMEPAD_STATE,
|
||||
cipher: 0,
|
||||
key_chacha: None,
|
||||
};
|
||||
assert_eq!(Welcome::decode(&w.encode()).unwrap(), w);
|
||||
|
||||
@@ -564,6 +632,81 @@ mod tests {
|
||||
assert!(derived > (8 << 20) && derived < (64 << 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn welcome_cipher_negotiation_wire_and_back_compat() {
|
||||
use crate::crypto::SessionKey;
|
||||
let base = Welcome {
|
||||
abi_version: 2,
|
||||
udp_port: 7000,
|
||||
mode: Mode {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
refresh_hz: 60,
|
||||
},
|
||||
fec: FecConfig {
|
||||
scheme: FecScheme::Gf16,
|
||||
fec_percent: 20,
|
||||
max_data_per_block: 4096,
|
||||
},
|
||||
shard_payload: 1200,
|
||||
encrypt: true,
|
||||
key: [7u8; 16],
|
||||
salt: [9, 8, 7, 6],
|
||||
frames: 0,
|
||||
compositor: CompositorPref::Auto,
|
||||
gamepad: GamepadPref::Auto,
|
||||
bitrate_kbps: 50_000,
|
||||
bit_depth: 8,
|
||||
color: ColorInfo::SDR_BT709,
|
||||
chroma_format: CHROMA_IDC_420,
|
||||
audio_channels: 2,
|
||||
codec: CODEC_HEVC,
|
||||
host_caps: 0,
|
||||
cipher: CIPHER_AES_128_GCM,
|
||||
key_chacha: None,
|
||||
};
|
||||
// An AES session's Welcome is byte-identical to the pre-cipher wire form (68 bytes) —
|
||||
// the old-client × new-host interop guarantee.
|
||||
let enc = base.encode();
|
||||
assert_eq!(enc.len(), 68);
|
||||
assert_eq!(Welcome::decode(&enc).unwrap(), base);
|
||||
|
||||
// ChaCha roundtrip: cipher byte at 68, the 32-byte key at 69..101.
|
||||
let k32: [u8; 32] = core::array::from_fn(|i| i as u8 + 1);
|
||||
let cha = Welcome {
|
||||
cipher: CIPHER_CHACHA20_POLY1305,
|
||||
key_chacha: Some(k32),
|
||||
..base
|
||||
};
|
||||
let cenc = cha.encode();
|
||||
assert_eq!(cenc.len(), 68 + 1 + 32);
|
||||
assert_eq!(Welcome::decode(&cenc).unwrap(), cha);
|
||||
|
||||
// A truncated old-host Welcome (no cipher byte) decodes to the AES default.
|
||||
let old_host = Welcome::decode(&cenc[..68]).unwrap();
|
||||
assert_eq!(old_host.cipher, CIPHER_AES_128_GCM);
|
||||
assert_eq!(old_host.key_chacha, None);
|
||||
|
||||
// cipher == 1 with a missing / short key → Err, fail-closed (a silent AES fallback
|
||||
// would yield an undecryptable session with a confusing failure signature).
|
||||
assert!(Welcome::decode(&cenc[..69]).is_err());
|
||||
assert!(Welcome::decode(&cenc[..100]).is_err());
|
||||
|
||||
// An unknown cipher id (≥ 2) → Err: the host only picks a cipher we advertised, so an
|
||||
// unknown id reaching us is a bug, never a legitimate negotiation.
|
||||
let mut bad = cenc.clone();
|
||||
bad[68] = 2;
|
||||
assert!(Welcome::decode(&bad).is_err());
|
||||
|
||||
// session_config maps both variants onto the data-plane key, and both validate.
|
||||
let aes_cfg = base.session_config(Role::Client);
|
||||
assert_eq!(aes_cfg.key, SessionKey::Aes128Gcm([7u8; 16]));
|
||||
aes_cfg.validate().expect("AES config validates");
|
||||
let cha_cfg = cha.session_config(Role::Client);
|
||||
assert_eq!(cha_cfg.key, SessionKey::ChaCha20Poly1305(k32));
|
||||
cha_cfg.validate().expect("ChaCha config validates");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_negotiation_and_back_compat() {
|
||||
// resolve_codec precedence (HEVC > AV1 > H.264), no preference (0).
|
||||
@@ -656,6 +799,8 @@ mod tests {
|
||||
audio_channels: 2,
|
||||
codec: CODEC_PYROWAVE,
|
||||
host_caps: 0,
|
||||
cipher: 0,
|
||||
key_chacha: None,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
@@ -726,6 +871,8 @@ mod tests {
|
||||
audio_channels: 2,
|
||||
codec: CODEC_H264,
|
||||
host_caps: 0,
|
||||
cipher: 0,
|
||||
key_chacha: None,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
@@ -831,6 +978,8 @@ mod tests {
|
||||
audio_channels: 6, // 5.1 — exercises the non-default trailing byte
|
||||
codec: CODEC_HEVC,
|
||||
host_caps: HOST_CAP_GAMEPAD_STATE,
|
||||
cipher: 0,
|
||||
key_chacha: None,
|
||||
};
|
||||
let wenc = w.encode();
|
||||
assert_eq!(wenc.len(), 68); // 60 base + 4 colour + chroma + audio-channels + codec + host-caps
|
||||
|
||||
@@ -13,7 +13,9 @@ use crate::crypto::SessionCrypto;
|
||||
use crate::error::{PunktfunkError, Result};
|
||||
use crate::fec::{coder_for, ErasureCoder};
|
||||
use crate::input::InputEvent;
|
||||
use crate::packet::{Packetizer, Reassembler, ReassemblerLimits, MAX_DATAGRAM_BYTES};
|
||||
use crate::packet::{
|
||||
PacketHeader, Packetizer, Reassembler, ReassemblerLimits, StreamedAu, MAX_DATAGRAM_BYTES,
|
||||
};
|
||||
use crate::stats::{Stats, StatsCounters};
|
||||
use crate::transport::Transport;
|
||||
use zerocopy::IntoBytes;
|
||||
@@ -274,6 +276,68 @@ impl Session {
|
||||
pts_ns: u64,
|
||||
user_flags: u32,
|
||||
frame_index: Option<u32>,
|
||||
) -> Result<Vec<Vec<u8>>> {
|
||||
self.seal_run(true, |p, coder, emit| {
|
||||
p.packetize_each(data, pts_ns, user_flags, frame_index, coder, emit)
|
||||
})
|
||||
}
|
||||
|
||||
/// Host: open a **streamed** access unit ([`crate::quic::VIDEO_CAP_STREAMED_AU`] — only
|
||||
/// toward a client that advertised it; anyone else must get the whole-AU
|
||||
/// [`seal_frame_at`](Self::seal_frame_at) path). The AU's bytes are fed in with
|
||||
/// [`seal_streamed_chunk`](Self::seal_streamed_chunk) as the encoder produces them and
|
||||
/// closed with [`seal_streamed_finish`](Self::seal_streamed_finish); the three calls'
|
||||
/// returned wire batches are ONE frame, and the nonce order is emission order across the
|
||||
/// calls — send each batch as it is returned, before sealing the next.
|
||||
pub fn begin_streamed_frame_at(
|
||||
&mut self,
|
||||
pts_ns: u64,
|
||||
user_flags: u32,
|
||||
frame_index: u32,
|
||||
) -> Result<StreamedAu> {
|
||||
if self.config.role != Role::Host {
|
||||
return Err(PunktfunkError::InvalidArg(
|
||||
"seal_frame called on a client session",
|
||||
));
|
||||
}
|
||||
Ok(self
|
||||
.packetizer
|
||||
.begin_streamed(pts_ns, user_flags, Some(frame_index)))
|
||||
}
|
||||
|
||||
/// Feed one encoder chunk into a streamed AU, sealing every FEC block it completes under
|
||||
/// sentinel headers (see [`begin_streamed_frame_at`](Self::begin_streamed_frame_at)). The
|
||||
/// returned batch is often EMPTY (the chunk is buffered until a block fills) — that's
|
||||
/// normal, not an error.
|
||||
pub fn seal_streamed_chunk(
|
||||
&mut self,
|
||||
au: &mut StreamedAu,
|
||||
chunk: &[u8],
|
||||
) -> Result<Vec<Vec<u8>>> {
|
||||
self.seal_run(false, |p, coder, emit| {
|
||||
p.push_streamed(au, chunk, coder, emit)
|
||||
})
|
||||
}
|
||||
|
||||
/// Close a streamed AU: seal the final block with the real totals (+ `FLAG_EOF`), which
|
||||
/// retro-validate the frame at the receiver. Counts the frame as submitted.
|
||||
pub fn seal_streamed_finish(&mut self, au: StreamedAu) -> Result<Vec<Vec<u8>>> {
|
||||
self.seal_run(true, |p, coder, emit| p.finish_streamed(au, coder, emit))
|
||||
}
|
||||
|
||||
/// The shared packetize → pooled-wire → seal machinery behind [`seal_frame`](Self::seal_frame)
|
||||
/// and the streamed sealers: `run` drives the packetizer against an emit sink that writes
|
||||
/// each packet's plaintext at its final wire offset; the seal pass (two-lane for large runs)
|
||||
/// then encrypts in place. `count_frame` gates the per-frame stats — a streamed AU counts
|
||||
/// once, at its finish call.
|
||||
fn seal_run(
|
||||
&mut self,
|
||||
count_frame: bool,
|
||||
run: impl FnOnce(
|
||||
&mut Packetizer,
|
||||
&dyn ErasureCoder,
|
||||
&mut dyn FnMut(&PacketHeader, &[u8]) -> Result<()>,
|
||||
) -> Result<()>,
|
||||
) -> Result<Vec<Vec<u8>>> {
|
||||
if self.config.role != Role::Host {
|
||||
return Err(PunktfunkError::InvalidArg(
|
||||
@@ -320,10 +384,10 @@ impl Session {
|
||||
// sealing itself is a separate pass so it can split across lanes.
|
||||
let seq_base = *next_seq;
|
||||
let encrypting = crypto.is_some();
|
||||
let result = packetizer.packetize_each(data, pts_ns, user_flags, frame_index, coder_ref, {
|
||||
let result = {
|
||||
let wires = &mut wires;
|
||||
let used = &mut used;
|
||||
move |hdr, body| {
|
||||
let mut emit = move |hdr: &PacketHeader, body: &[u8]| -> Result<()> {
|
||||
if *used == wires.len() {
|
||||
wires.push(Vec::new());
|
||||
}
|
||||
@@ -342,8 +406,9 @@ impl Session {
|
||||
wire.extend_from_slice(body);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
};
|
||||
run(packetizer, coder_ref, &mut emit)
|
||||
};
|
||||
result?;
|
||||
// A smaller frame uses fewer buffers than the pool holds: drop the unused tail, same
|
||||
// as the previous `resize_with(packets.len(), ..)` did. (Before the seal phase, so a
|
||||
@@ -421,10 +486,12 @@ impl Session {
|
||||
if let Some(p) = self.seal_perf.as_mut() {
|
||||
p.fec_ns += fec_ns.load(std::sync::atomic::Ordering::Relaxed);
|
||||
p.seal_ns += seal_ns;
|
||||
p.frames += 1;
|
||||
p.frames += count_frame as u64;
|
||||
p.packets += used as u64;
|
||||
}
|
||||
if count_frame {
|
||||
StatsCounters::add(&self.stats.frames_submitted, 1);
|
||||
}
|
||||
let bytes: u64 = wires.iter().map(|w| w.len() as u64).sum();
|
||||
StatsCounters::add(&self.stats.packets_sent, wires.len() as u64);
|
||||
StatsCounters::add(&self.stats.bytes_sent, bytes);
|
||||
@@ -714,6 +781,7 @@ impl Session {
|
||||
mod wire_equivalence_tests {
|
||||
use super::*;
|
||||
use crate::config::{FecConfig, FecScheme, ProtocolPhase};
|
||||
use crate::crypto::SessionKey;
|
||||
use crate::transport::loopback_pair;
|
||||
|
||||
fn host_cfg(scheme: FecScheme, fec_percent: u8, encrypt: bool) -> Config {
|
||||
@@ -731,7 +799,7 @@ mod wire_equivalence_tests {
|
||||
shard_payload: 64,
|
||||
max_frame_bytes: 8 * 1024 * 1024,
|
||||
encrypt,
|
||||
key: [7u8; 16],
|
||||
key: SessionKey::Aes128Gcm([7u8; 16]),
|
||||
salt: [3, 1, 4, 1],
|
||||
loopback_drop_period: 0,
|
||||
}
|
||||
@@ -863,7 +931,7 @@ mod wire_equivalence_tests {
|
||||
shard_payload: 1024,
|
||||
max_frame_bytes: 8 * 1024 * 1024,
|
||||
encrypt: false,
|
||||
key: [0u8; 16],
|
||||
key: SessionKey::Aes128Gcm([0u8; 16]),
|
||||
salt: [0u8; 4],
|
||||
loopback_drop_period: 0,
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
use proptest::prelude::*;
|
||||
use punktfunk_core::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role};
|
||||
use punktfunk_core::crypto::SessionKey;
|
||||
use punktfunk_core::fec::coder_for;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use punktfunk_core::session::Session;
|
||||
@@ -25,7 +26,7 @@ fn config(role: Role, scheme: FecScheme, encrypt: bool, drop_period: u32) -> Con
|
||||
shard_payload: 1024,
|
||||
max_frame_bytes: 8 * 1024 * 1024,
|
||||
encrypt,
|
||||
key: [7u8; 16],
|
||||
key: SessionKey::Aes128Gcm([7u8; 16]),
|
||||
salt: [1, 2, 3, 4],
|
||||
loopback_drop_period: drop_period,
|
||||
}
|
||||
@@ -101,6 +102,30 @@ fn encrypted_stream_recovers_under_loss() {
|
||||
assert_eq!(stats.frames_completed, frames.len() as u64);
|
||||
}
|
||||
|
||||
/// The negotiated ChaCha20-Poly1305 session cipher through the same lossy full-stream path:
|
||||
/// loss/replay behavior is cipher-independent (the replay window keys off the authenticated
|
||||
/// seq), so recovery must be byte-identical to the AES run above.
|
||||
#[test]
|
||||
fn chacha20_encrypted_stream_recovers_under_loss() {
|
||||
let frames = sample_frames();
|
||||
let mk = |role| {
|
||||
let mut c = config(role, FecScheme::Gf16, true, 8);
|
||||
c.key = SessionKey::ChaCha20Poly1305([7u8; 32]);
|
||||
c
|
||||
};
|
||||
let (host_tp, client_tp) = loopback_pair(8, 0);
|
||||
let mut host = Session::new(mk(Role::Host), Box::new(host_tp)).unwrap();
|
||||
let mut client = Session::new(mk(Role::Client), Box::new(client_tp)).unwrap();
|
||||
for (i, frame) in frames.iter().enumerate() {
|
||||
host.submit_frame(frame, i as u64 * 1_000_000, 0).unwrap();
|
||||
let got = client
|
||||
.poll_frame()
|
||||
.expect("frame should recover despite loss");
|
||||
assert_eq!(&got.data, frame, "frame {i} mismatched after recovery");
|
||||
}
|
||||
assert!(client.stats().fec_recovered_shards > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossless_stream_is_exact() {
|
||||
let frames = sample_frames();
|
||||
|
||||
@@ -110,6 +110,13 @@ serde_json = "1"
|
||||
# utoipa into axum 0.8 extractors; utoipa-axum collects `#[utoipa::path]` routes into the
|
||||
# spec; utoipa-scalar serves the interactive docs. Codegen-friendly: the spec is emitted
|
||||
# verbatim by the `openapi` subcommand. Control plane only — never the per-frame path.
|
||||
# Plugin-store index signatures: ed25519 verification of a catalog document before any field of
|
||||
# it is read (store/index.rs). Already in the tree via rustls — the workspace is ring-only, so this
|
||||
# is the one signature primitive available without pulling aws-lc-sys (which fails on Windows CI).
|
||||
ring = "0.17"
|
||||
# Semver comparisons for the plugin store: `minHost` gating and the revocation list's version
|
||||
# ranges (`<0.3.2`). Already in the lockfile transitively.
|
||||
semver = "1"
|
||||
utoipa = { version = "5", features = ["axum_extras"] }
|
||||
utoipa-axum = "0.2"
|
||||
utoipa-scalar = { version = "0.3", features = ["axum"] }
|
||||
|
||||
@@ -26,7 +26,10 @@ pub use pf_capture::{dxgi, synthetic_nv12};
|
||||
/// capture→encode cycle). Resolved here (the host facade) and threaded in, so the edge stays one-way
|
||||
/// (plan §2.4 / §W6).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn zero_copy_policy(pyrowave_session: bool) -> pf_capture::ZeroCopyPolicy {
|
||||
fn zero_copy_policy(
|
||||
pyrowave_session: bool,
|
||||
native_nv12_session: bool,
|
||||
) -> pf_capture::ZeroCopyPolicy {
|
||||
let backend_is_vaapi = crate::encode::linux_zero_copy_is_vaapi();
|
||||
// The raw-dmabuf passthrough serves a PyroWave session on ANY vendor (the wavelet encoder's
|
||||
// own Vulkan device imports the dmabuf) — per-session from the negotiated codec, plus the
|
||||
@@ -56,6 +59,7 @@ fn zero_copy_policy(pyrowave_session: bool) -> pf_capture::ZeroCopyPolicy {
|
||||
backend_is_gpu: crate::encode::resolved_backend_is_gpu(),
|
||||
pyrowave_session,
|
||||
pyrowave_modifiers,
|
||||
native_nv12_session,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +74,9 @@ pub fn open_portal_monitor(want_hdr: bool) -> Result<Box<dyn Capturer>> {
|
||||
let anchored = crate::inject::default_backend() == crate::inject::Backend::Libei;
|
||||
// Monitor mirrors never carry the native PyroWave plane (GameStream protocol) — per-session
|
||||
// passthrough is virtual-output-only; the global encoder-pref lever still applies inside.
|
||||
pf_capture::open_portal_monitor(anchored, want_hdr, zero_copy_policy(false))
|
||||
// Native NV12 stays off too: the mirror path doesn't resolve the codec here, and the desktop
|
||||
// compositors it mirrors (GNOME/KWin) don't produce NV12 anyway.
|
||||
pf_capture::open_portal_monitor(anchored, want_hdr, zero_copy_policy(false, false))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
@@ -101,7 +107,7 @@ pub fn capture_virtual_output(
|
||||
vout.keepalive,
|
||||
want.gpu,
|
||||
want.chroma_444,
|
||||
zero_copy_policy(want.pyrowave),
|
||||
zero_copy_policy(want.pyrowave, want.nv12_native),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -170,6 +170,12 @@ pub enum EventKind {
|
||||
/// lease-expired). A consumer re-reads `GET /api/v1/plugins` for the new set.
|
||||
id: String,
|
||||
},
|
||||
#[serde(rename = "store.changed")]
|
||||
/// The set of installed plugins, or what the store knows about them, changed — an install or
|
||||
/// uninstall finished, or a catalog refresh brought in new rows. A consumer re-reads
|
||||
/// `GET /api/v1/store/catalog` / `…/installed`. Deliberately payload-free: the store's answer
|
||||
/// is a join over several sources of truth, so "go look again" is the only honest signal.
|
||||
StoreChanged,
|
||||
#[serde(rename = "host.started")]
|
||||
HostStarted {
|
||||
version: String,
|
||||
@@ -197,6 +203,7 @@ impl EventKind {
|
||||
EventKind::DisplayReleased { .. } => "display.released",
|
||||
EventKind::LibraryChanged { .. } => "library.changed",
|
||||
EventKind::PluginsChanged { .. } => "plugins.changed",
|
||||
EventKind::StoreChanged => "store.changed",
|
||||
EventKind::HostStarted { .. } => "host.started",
|
||||
EventKind::HostStopping => "host.stopping",
|
||||
}
|
||||
|
||||
@@ -86,6 +86,12 @@ pub fn start(
|
||||
crate::events::emit(crate::events::EventKind::ClientConnected {
|
||||
client: event_client.clone(),
|
||||
});
|
||||
// GPU clock pin (Linux, opt-in `PUNKTFUNK_PIN_CLOCKS`): hold the box-wide vendor clock
|
||||
// floor while this compat-plane stream runs, refcounted with every other live session
|
||||
// across both planes. Released when the closure exits (stream stopped) — so idle clocks
|
||||
// aren't pinned between Moonlight sessions. No-op off Linux / when the flag is unset.
|
||||
#[cfg(target_os = "linux")]
|
||||
let _clock_pin = crate::gpuclocks::session_pin();
|
||||
let result = run(
|
||||
cfg,
|
||||
app.as_ref(),
|
||||
@@ -658,6 +664,9 @@ fn stream_body(
|
||||
// GameStream/Moonlight stays 4:2:0 — stock Moonlight clients can't decode 4:4:4, and the
|
||||
// Windows IDD-push capturer can't yet deliver full-chroma frames. 4:4:4 is punktfunk/1-native only.
|
||||
encode::ChromaFormat::Yuv420,
|
||||
// Desktop monitor capture negotiates cursor-as-metadata where available — the encoder
|
||||
// may be handed cursor bitmaps to composite.
|
||||
true,
|
||||
)
|
||||
.context("open video encoder for stream")?;
|
||||
// FEC overhead percent (Sunshine default 20). Override with PUNKTFUNK_FEC_PCT (0 = data-only).
|
||||
@@ -811,6 +820,7 @@ fn stream_body(
|
||||
frame.is_cuda(),
|
||||
gs_bit_depth(frame.format),
|
||||
encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0
|
||||
true, // metadata-cursor capture — see the first open
|
||||
)
|
||||
.context("reopen encoder after rebuild")?;
|
||||
supports_rfi = enc.caps().supports_rfi;
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
//! plan Tier 1B): the driver's adaptive P-state ramps clocks down between bursty encode frames,
|
||||
//! so every frame re-pays a spin-up. This is NOT theoretical — measured on the 780M (VCN 4),
|
||||
//! a 1440p HEVC encode takes ~4.4 ms/frame with the clocks hot (120 fps pacing) but ~8 ms/frame
|
||||
//! at a 60 fps duty cycle: the sag doubles per-frame encode latency.
|
||||
//! at a 60 fps duty cycle: the sag doubles per-frame encode latency. So the pin is worth holding
|
||||
//! only while a client is actively streaming: it is armed on the first live client and released
|
||||
//! when the last one disconnects (refcounted across both streaming planes — see [`session_pin`]),
|
||||
//! leaving the driver's idle power management alone the rest of the time.
|
||||
//!
|
||||
//! **AMD** (`PUNKTFUNK_PIN_CLOCKS=1`, root-gated by sysfs ownership): write `high` into each
|
||||
//! amdgpu card's `power_dpm_force_performance_level` for the host lifetime, restoring the prior
|
||||
//! value on exit. Non-root gets EACCES → logged once with the privilege recipe. Deliberately
|
||||
//! amdgpu card's `power_dpm_force_performance_level` while a client is streaming, restoring the
|
||||
//! prior value when the last client disconnects. Non-root gets EACCES → logged once with the
|
||||
//! privilege recipe. Deliberately
|
||||
//! opt-in: it defeats power management box-wide and is wrong on battery (Steam Deck!).
|
||||
//!
|
||||
//! **NVIDIA** — two independent halves, both no-ops off NVIDIA:
|
||||
@@ -28,14 +32,16 @@
|
||||
//! while leaving boost headroom — NVIDIA's own latency guidance is "raise the floor, don't pin
|
||||
//! the max" (locking above base just gets throttled; a max pin only burns idle watts). Non-root
|
||||
//! callers get `NVML_ERROR_NO_PERMISSION` — logged once with the privilege recipe, then the
|
||||
//! host runs unpinned. The pin is undone on drop (host exit); after a crash it persists until
|
||||
//! driver reload/reboot, which the reset-before-pin on the next start self-heals. Deliberately
|
||||
//! host runs unpinned. The pin is undone on drop (when the last client disconnects); after a
|
||||
//! crash it persists until driver reload/reboot, which the reset-before-pin on the next arm
|
||||
//! self-heals. Deliberately
|
||||
//! NOT default-on: it defeats idle downclocking for the whole box and is wrong on
|
||||
//! battery-powered hosts.
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use std::os::raw::{c_char, c_int, c_uint, c_void};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// `nvmlDevice_t` — an opaque driver handle.
|
||||
type NvmlDevice = *mut c_void;
|
||||
@@ -133,8 +139,9 @@ struct AmdPin {
|
||||
restore: String,
|
||||
}
|
||||
|
||||
/// Host-lifetime guard: holds the armed clock pins (NVML floor and/or amdgpu perf level) and
|
||||
/// undoes them on drop.
|
||||
/// Holds the armed clock pins (NVML floor and/or amdgpu perf level) and undoes them on drop. Owned
|
||||
/// by the [`session_pin`] refcount: constructed when the first live client arms the pin, dropped
|
||||
/// (clocks restored) when the last one disconnects.
|
||||
pub struct ClockGuard {
|
||||
nvml: Option<NvmlPin>,
|
||||
amd: Vec<AmdPin>,
|
||||
@@ -142,9 +149,10 @@ pub struct ClockGuard {
|
||||
|
||||
// SAFETY: `ClockGuard` holds opaque NVML device handles + resolved fn pointers from the loaded
|
||||
// driver library (plus plain sysfs paths/strings). NVML is documented thread-safe, the handles are
|
||||
// plain driver tokens with no thread affinity, and the guard is only ever *moved* (held in `main`,
|
||||
// dropped once at exit) and used through `&mut`/ownership — never shared. Transfer across threads
|
||||
// is therefore sound.
|
||||
// plain driver tokens with no thread affinity, and the guard is only ever *moved* (into the
|
||||
// `pin_refcount` mutex when armed, taken back out and dropped when the last client disconnects) and
|
||||
// used through exclusive ownership behind that mutex — never shared. Transfer across threads is
|
||||
// therefore sound.
|
||||
unsafe impl Send for ClockGuard {}
|
||||
|
||||
impl Drop for ClockGuard {
|
||||
@@ -182,22 +190,89 @@ impl Drop for ClockGuard {
|
||||
}
|
||||
|
||||
/// Startup hook for the host subcommands (`serve` / `punktfunk1-host`): install the NVIDIA P2-cap
|
||||
/// application profile and, when `PUNKTFUNK_PIN_CLOCKS` is set, arm the vendor clock pin (NVML
|
||||
/// core-clock floor / amdgpu `high` performance level). Returns the guard keeping the pins for
|
||||
/// the host lifetime. `None` when nothing was armed.
|
||||
pub fn on_host_start() -> Option<ClockGuard> {
|
||||
/// application profile — the process-scoped, no-root half of the NVIDIA lever, which the driver
|
||||
/// only acts on once the host holds a live CUDA/NVENC context (i.e. during a session).
|
||||
///
|
||||
/// The vendor clock *pin* is deliberately NOT armed here anymore: held for the whole host lifetime
|
||||
/// it kept the box's clocks hot even with no client connected. It is now refcounted per live client
|
||||
/// via [`session_pin`] (armed on both streaming planes), so idle clocks are left to the driver's
|
||||
/// power management until someone actually streams.
|
||||
pub fn on_host_start() {
|
||||
if nvidia_present() {
|
||||
ensure_cuda_perf_profile();
|
||||
}
|
||||
if !flag_truthy("PUNKTFUNK_PIN_CLOCKS") {
|
||||
return None;
|
||||
}
|
||||
|
||||
/// The box-wide clock-pin refcount, shared across BOTH streaming planes (native + GameStream): the
|
||||
/// vendor pin is a single global GPU setting, so N concurrent sessions share ONE pin — armed when
|
||||
/// the first client goes live, released when the last one leaves.
|
||||
struct PinRefcount {
|
||||
/// Number of live [`SessionClockPin`] handles.
|
||||
live: usize,
|
||||
/// The armed pins, present iff `live > 0` and something was actually pinnable.
|
||||
guard: Option<ClockGuard>,
|
||||
}
|
||||
|
||||
fn pin_refcount() -> &'static Mutex<PinRefcount> {
|
||||
static STATE: OnceLock<Mutex<PinRefcount>> = OnceLock::new();
|
||||
STATE.get_or_init(|| {
|
||||
Mutex::new(PinRefcount {
|
||||
live: 0,
|
||||
guard: None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// RAII handle that keeps the box-wide clock pin armed while it is alive. Obtain one per live client
|
||||
/// session on either plane via [`session_pin`]; when the last outstanding handle drops, the pin is
|
||||
/// released and the driver's idle downclocking resumes. A no-op handle when `PUNKTFUNK_PIN_CLOCKS`
|
||||
/// is unset (the opt-in gate) — the refcount only ticks for sessions that asked for pinning.
|
||||
pub struct SessionClockPin {
|
||||
/// Whether this handle actually incremented the refcount (false = opt-in gate off → no-op).
|
||||
counted: bool,
|
||||
}
|
||||
|
||||
/// Arm the box-wide clock pin for one live client session, refcounted across every plane. Returns
|
||||
/// an RAII handle; the pin is released when the last handle drops. A no-op (returns immediately,
|
||||
/// touches no GPU state) unless `PUNKTFUNK_PIN_CLOCKS` is set.
|
||||
pub fn session_pin() -> SessionClockPin {
|
||||
if !flag_truthy("PUNKTFUNK_PIN_CLOCKS") {
|
||||
return SessionClockPin { counted: false };
|
||||
}
|
||||
let mut state = pin_refcount().lock().unwrap();
|
||||
state.live += 1;
|
||||
if state.live == 1 {
|
||||
// 0 → 1: the first live client — arm the vendor pin (reset-before-pin inside `pin_nvidia`
|
||||
// heals a stale pin from a crashed previous run).
|
||||
let nvml = if nvidia_present() { pin_nvidia() } else { None };
|
||||
let amd = pin_amdgpu();
|
||||
if nvml.is_none() && amd.is_empty() {
|
||||
return None;
|
||||
}
|
||||
state.guard = if nvml.is_none() && amd.is_empty() {
|
||||
None // nothing pinnable (no perms / no supported GPU) — the session streams unpinned
|
||||
} else {
|
||||
Some(ClockGuard { nvml, amd })
|
||||
};
|
||||
}
|
||||
SessionClockPin { counted: true }
|
||||
}
|
||||
|
||||
impl Drop for SessionClockPin {
|
||||
fn drop(&mut self) {
|
||||
if !self.counted {
|
||||
return;
|
||||
}
|
||||
// Take the guard out under the lock but drop it *outside* — releasing the pin does NVML +
|
||||
// sysfs I/O we don't want to hold the refcount lock across.
|
||||
let release = {
|
||||
let mut state = pin_refcount().lock().unwrap();
|
||||
state.live = state.live.saturating_sub(1);
|
||||
if state.live == 0 {
|
||||
state.guard.take()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
drop(release);
|
||||
}
|
||||
}
|
||||
|
||||
/// Force every amdgpu card's DPM performance level to `high` for the session — the encode-latency
|
||||
@@ -238,7 +313,7 @@ fn pin_amdgpu() -> Vec<AmdPin> {
|
||||
card = %name,
|
||||
was = %prev,
|
||||
"amdgpu performance level pinned to high (encode clock sag removed) — \
|
||||
restored on host exit"
|
||||
restored when the last client disconnects"
|
||||
);
|
||||
pins.push(AmdPin {
|
||||
path,
|
||||
@@ -327,7 +402,8 @@ fn pin_nvidia() -> Option<NvmlPin> {
|
||||
}
|
||||
tracing::info!(
|
||||
devices = pinned.len(),
|
||||
"NVIDIA core-clock floor armed (min=TDP/base, max=boost) — released on host exit"
|
||||
"NVIDIA core-clock floor armed (min=TDP/base, max=boost) — released when the last \
|
||||
client disconnects"
|
||||
);
|
||||
Some(NvmlPin { nvml, pinned })
|
||||
}
|
||||
|
||||
@@ -74,6 +74,9 @@ mod session_plan;
|
||||
mod session_status;
|
||||
mod spike;
|
||||
mod stats_recorder;
|
||||
// The plugin store: signed catalogs, tiered trust, and install/uninstall jobs that run through the
|
||||
// same runner CLI the `plugins` subcommand uses (design/plugin-store.md).
|
||||
mod store;
|
||||
mod stream_marker;
|
||||
// `monitor_devnode::startup_recover()` (below) re-enables PnP monitor devnodes disabled by a prior
|
||||
// run; it lives in the `pf-win-display` leaf crate (plan §W6).
|
||||
@@ -243,14 +246,17 @@ fn real_main() -> Result<()> {
|
||||
crate::capture::dxgi::install_gpu_pref_hook();
|
||||
}
|
||||
|
||||
// NVIDIA clock hygiene (Linux, host subcommands only): install the P2-cap driver profile and,
|
||||
// under PUNKTFUNK_PIN_CLOCKS, hold the NVML core-clock floor for the host lifetime (reset on
|
||||
// exit via the guard's Drop). No-op off NVIDIA / on the tool subcommands.
|
||||
// NVIDIA clock hygiene (Linux, host subcommands only): install the P2-cap driver profile. The
|
||||
// vendor clock *pin* (PUNKTFUNK_PIN_CLOCKS) is no longer held for the host lifetime — it is
|
||||
// armed per live client via `gpuclocks::session_pin()` on both streaming planes, so idle clocks
|
||||
// are left alone while nobody is connected. No-op off NVIDIA / on the tool subcommands.
|
||||
#[cfg(target_os = "linux")]
|
||||
let _nv_clocks = match args.first().map(String::as_str) {
|
||||
Some("serve") | Some("punktfunk1-host") => gpuclocks::on_host_start(),
|
||||
_ => None,
|
||||
};
|
||||
if matches!(
|
||||
args.first().map(String::as_str),
|
||||
Some("serve") | Some("punktfunk1-host")
|
||||
) {
|
||||
gpuclocks::on_host_start();
|
||||
}
|
||||
|
||||
match args.first().map(String::as_str) {
|
||||
// The host: the native punktfunk/1 plane + management API by default (secure), and — with
|
||||
@@ -319,7 +325,33 @@ fn real_main() -> Result<()> {
|
||||
// `PUNKTFUNK_HDR_SHADER_P010` colour math without green-screening a live HDR stream. Prints
|
||||
// PASS/FAIL + max Y/Cb/Cr error.
|
||||
#[cfg(target_os = "windows")]
|
||||
Some("hdr-p010-selftest") => crate::capture::dxgi::hdr_p010_selftest(),
|
||||
Some("hdr-p010-selftest") => {
|
||||
// Optional args: a `WxH` size (default 64x64 — pass the real capture size: heights
|
||||
// like 1080 are NOT 16-aligned and exercise a different driver path) and a GPU
|
||||
// vendor (`intel`|`nvidia`|`amd` — dual-GPU boxes otherwise test the default
|
||||
// adapter, which may not be the one that encodes).
|
||||
let mut size = (64u32, 64u32);
|
||||
let mut vendor = None;
|
||||
for a in args.iter().skip(2) {
|
||||
match a.as_str() {
|
||||
"intel" => vendor = Some(0x8086),
|
||||
"nvidia" => vendor = Some(0x10de),
|
||||
"amd" => vendor = Some(0x1002),
|
||||
s => {
|
||||
let parsed = s
|
||||
.split_once('x')
|
||||
.and_then(|(w, h)| Some((w.parse().ok()?, h.parse().ok()?)));
|
||||
match parsed {
|
||||
Some(wh) => size = wh,
|
||||
None => anyhow::bail!(
|
||||
"hdr-p010-selftest: unrecognized arg {s:?} (want WxH or intel|nvidia|amd)"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::capture::dxgi::hdr_p010_selftest_at(size.0, size.1, vendor)
|
||||
}
|
||||
// Linux HDR readiness probe (GNOME 50+ portal path): prints whether a monitor is currently
|
||||
// in BT.2100 (HDR) colour mode, whether the NVENC/VAAPI backend probes Main10 for
|
||||
// HEVC/AV1, and the GameStream HDR capability the two combine into — the "why isn't my
|
||||
|
||||
@@ -42,6 +42,7 @@ mod plugins;
|
||||
mod session;
|
||||
mod shared;
|
||||
mod stats;
|
||||
mod store;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -240,7 +241,17 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
||||
.routes(routes!(hooks::get_hooks, hooks::set_hooks))
|
||||
.routes(routes!(plugins::list_plugins))
|
||||
.routes(routes!(plugins::register_plugin, plugins::delete_plugin))
|
||||
.routes(routes!(plugins::get_ui_credential)),
|
||||
.routes(routes!(plugins::get_ui_credential))
|
||||
.routes(routes!(store::get_catalog))
|
||||
.routes(routes!(store::refresh_catalog))
|
||||
.routes(routes!(store::list_installed))
|
||||
.routes(routes!(store::install_plugin))
|
||||
.routes(routes!(store::uninstall_plugin))
|
||||
.routes(routes!(store::list_jobs))
|
||||
.routes(routes!(store::get_job))
|
||||
.routes(routes!(store::list_sources))
|
||||
.routes(routes!(store::put_source, store::delete_source))
|
||||
.routes(routes!(store::get_runtime, store::set_runtime)),
|
||||
)
|
||||
.split_for_parts()
|
||||
}
|
||||
@@ -279,6 +290,7 @@ pub fn openapi_json() -> String {
|
||||
(name = "events", description = "Host lifecycle events: an SSE stream (client/session/stream lifecycle, pairing, displays, library, host) with Last-Event-ID resume and server-side kind filters"),
|
||||
(name = "hooks", description = "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)"),
|
||||
(name = "plugins", description = "Plugin directory: running `punktfunk-plugin-*` processes register a lease and, optionally, a loopback UI the web console proxies and adds to its nav"),
|
||||
(name = "store", description = "Plugin store: browse signed catalogs (verified first-party entries, attributed third-party sources), install/uninstall as tracked jobs, and switch the plugin runner on"),
|
||||
)
|
||||
)]
|
||||
struct ApiDoc;
|
||||
|
||||
@@ -131,8 +131,15 @@ pub(crate) async fn require_auth(
|
||||
/// or eject the operator's.
|
||||
/// - **UI proxy credentials** — a plugin has no business reading another plugin's per-boot UI
|
||||
/// secret; only the console proxy (admin token) needs it.
|
||||
/// - **the plugin store** — installing a plugin is running new code with operator privileges, and a
|
||||
/// plugin that can do that is a persistence/escalation primitive: it could install a helper that
|
||||
/// isn't constrained the way it is, or switch the runner's own service state. Denied wholesale
|
||||
/// (reads included — the catalog is not sensitive, but there is no reason a plugin needs it, and
|
||||
/// a whole-prefix deny can't be defeated by a route added later).
|
||||
pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool {
|
||||
let denied = path == "/api/v1/hooks"
|
||||
|| path == "/api/v1/store"
|
||||
|| path.starts_with("/api/v1/store/")
|
||||
|| path == "/api/v1/pair"
|
||||
|| path.starts_with("/api/v1/pair/")
|
||||
|| path == "/api/v1/native/pair"
|
||||
|
||||
@@ -202,6 +202,15 @@ impl PluginRegistry {
|
||||
})
|
||||
}
|
||||
|
||||
/// The ids of live registrations, without pruning or announcing anything.
|
||||
fn live_ids(&self) -> Vec<String> {
|
||||
let map = self.inner.read().unwrap_or_else(|e| e.into_inner());
|
||||
map.iter()
|
||||
.filter(|(_, s)| s.is_live())
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Remove `id`. Returns `true` if a **live** entry existed (a clean deregister); removing an
|
||||
/// already-expired or unknown id returns `false` (nothing to announce).
|
||||
fn remove(&self, id: &str) -> bool {
|
||||
@@ -222,6 +231,16 @@ pub(crate) fn registry() -> &'static PluginRegistry {
|
||||
REG.get_or_init(PluginRegistry::new)
|
||||
}
|
||||
|
||||
/// Which plugin ids are registered right now — the plugin store's "running" column
|
||||
/// ([`super::store::list_installed`]).
|
||||
///
|
||||
/// Deliberately **not** [`list_plugins`]: that one prunes expired leases and emits
|
||||
/// `plugins.changed` for each, which is right for the nav's poll but would turn the store's own
|
||||
/// polling into an event fire-hose. This is a pure read.
|
||||
pub(crate) fn live_plugin_ids() -> Vec<String> {
|
||||
registry().live_ids()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- validation
|
||||
|
||||
/// A plugin id: `definePlugin`'s kebab-case name (`^[a-z][a-z0-9-]*$`, ≤64) — the same regex the SDK
|
||||
|
||||
@@ -0,0 +1,779 @@
|
||||
//! Plugin **store** API: browse catalogs, install/uninstall, manage sources and the runner
|
||||
//! (design `plugin-store.md` §4.2). The domain logic lives in [`crate::store`]; this is its HTTP
|
||||
//! surface.
|
||||
//!
|
||||
//! Auth: **admin bearer + loopback**, like every mutation — and explicitly *denied to the plugin
|
||||
//! token* ([`super::auth::plugin_may_access`]). A plugin that can install plugins is a persistence
|
||||
//! and escalation primitive: it could install a helper that isn't constrained the way it is. That
|
||||
//! deny is a carve-out in the exclusion-based allowlist, so it is spelled out there and pinned by a
|
||||
//! test.
|
||||
//!
|
||||
//! (A console *session* can already reach arbitrary command execution via `PUT /hooks`, so the
|
||||
//! store adds convenience for a session holder rather than a new privilege class. It is still worth
|
||||
//! keeping the plugin lane away from it — the lanes exist precisely so a plugin defect isn't an
|
||||
//! operator compromise.)
|
||||
//!
|
||||
//! Everything here does blocking work — filesystem scans, network fetches, process spawns — so
|
||||
//! handlers hop to `spawn_blocking` rather than stalling the runtime.
|
||||
|
||||
use super::shared::*;
|
||||
use crate::store::{self, index, jobs, manifest, sources};
|
||||
|
||||
/// Run blocking store work off the async runtime, mapping a joined-thread panic to a 500.
|
||||
async fn blocking<T, F>(f: F) -> Result<T, Response>
|
||||
where
|
||||
F: FnOnce() -> T + Send + 'static,
|
||||
T: Send + 'static,
|
||||
{
|
||||
tokio::task::spawn_blocking(f).await.map_err(|e| {
|
||||
tracing::error!("plugin-store worker panicked: {e}");
|
||||
api_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"the plugin store worker failed",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- wire shapes
|
||||
|
||||
/// Facts about this host, so the console can grey out rows it can't install.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct HostFacts {
|
||||
pub version: String,
|
||||
/// `linux` / `windows` / `macos`.
|
||||
pub platform: String,
|
||||
}
|
||||
|
||||
/// A configured catalog source and how its last refresh went.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct SourceView {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
/// The built-in `unom` source: not editable, not removable, and the only source whose entries
|
||||
/// may carry the "verified" tier.
|
||||
pub builtin: bool,
|
||||
/// Whether we check a signature on this source's index. An unsigned source still works; the
|
||||
/// console marks it.
|
||||
pub signed: bool,
|
||||
/// The catalog we're serving is older than the last refresh attempt (offline, or the last
|
||||
/// fetch failed) — entries still install, because the pin travelled with the entry.
|
||||
pub stale: bool,
|
||||
/// Unix seconds of the data we hold, when we hold any.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fetched_at: Option<u64>,
|
||||
/// Why the last refresh failed, if it did.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
pub entry_count: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub public_key: Option<String>,
|
||||
}
|
||||
|
||||
/// One row on the shelf.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct CatalogEntry {
|
||||
pub id: String,
|
||||
pub pkg: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
pub author: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub homepage: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub license: Option<String>,
|
||||
/// The one installable version this entry pins.
|
||||
pub version: String,
|
||||
/// Which source listed it.
|
||||
pub source: String,
|
||||
/// `verified` (built-in source) or `external` (an operator-added source). Never `unverified`:
|
||||
/// unverified installs come from a raw spec and are never listed (D7).
|
||||
pub tier: String,
|
||||
/// When unom reviewed this exact tarball (built-in source only).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reviewed_at: Option<String>,
|
||||
pub platforms: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub min_host: Option<String>,
|
||||
/// Can this host install it?
|
||||
pub compatible: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub incompatible_reason: Option<String>,
|
||||
/// The version installed right now, if any.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub installed_version: Option<String>,
|
||||
/// Installed, but at a different version than the catalog pins.
|
||||
pub update_available: bool,
|
||||
/// A revocation covering the catalogued version — do not offer this without shouting.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub blocked: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct CatalogResponse {
|
||||
pub host: HostFacts,
|
||||
pub sources: Vec<SourceView>,
|
||||
pub plugins: Vec<CatalogEntry>,
|
||||
/// True while a package operation is in flight — the console disables install buttons.
|
||||
pub busy: bool,
|
||||
}
|
||||
|
||||
/// An installed plugin package, joined with its provenance and whether it's actually running.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct InstalledView {
|
||||
pub pkg: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
/// `verified` / `external` / `unverified` / `cli` — remembered from install time, so an
|
||||
/// unverified plugin stays visibly unverified long after the dialog is forgotten.
|
||||
pub tier: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<String>,
|
||||
/// The catalog entry this maps to, when it is on a shelf we know.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub entry_id: Option<String>,
|
||||
/// The plugin id it registers under — the key into `GET /plugins`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub plugin_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub installed_at: Option<String>,
|
||||
/// Is it registered in the live lease registry right now?
|
||||
pub running: bool,
|
||||
/// The catalog's version, when it's newer than what's installed.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub update_available: Option<String>,
|
||||
/// A revocation covering the *installed* version. Reported, never auto-removed: silently
|
||||
/// deleting running code is its own hazard, so the operator decides.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub blocked: Option<String>,
|
||||
}
|
||||
|
||||
/// `POST /store/install` — either a catalogued entry, or a raw spec the operator owns.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct InstallRequest {
|
||||
/// Catalog source name (with [`Self::id`]).
|
||||
#[serde(default)]
|
||||
pub source: Option<String>,
|
||||
/// Catalog entry id (with [`Self::source`]).
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
/// A raw package spec (`@scope/name`, `@scope/name@1.2.3`, an https tarball or git+https URL).
|
||||
/// Nothing reviewed it and nothing pins it.
|
||||
#[serde(default)]
|
||||
pub spec: Option<String>,
|
||||
/// Required with [`Self::spec`]: the operator's explicit acknowledgement that this installs
|
||||
/// unreviewed code with operator privileges. The console collects it behind a typed
|
||||
/// confirmation; the API refuses without it so no other caller can skip the decision.
|
||||
#[serde(default)]
|
||||
pub accept_unverified: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct UninstallRequest {
|
||||
pub pkg: String,
|
||||
}
|
||||
|
||||
/// 202 body: where to watch the work.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct JobRef {
|
||||
pub job: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct SourceInput {
|
||||
pub url: String,
|
||||
/// `ed25519:<base64>`. Omitted ⇒ an unsigned source (accepted, flagged everywhere).
|
||||
#[serde(default)]
|
||||
pub public_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct RuntimeView {
|
||||
/// Is the runner payload/unit present at all?
|
||||
pub installed: bool,
|
||||
pub enabled: bool,
|
||||
pub running: bool,
|
||||
/// systemd unit or scheduled-task name.
|
||||
pub unit: String,
|
||||
/// Windows: the account the task runs as.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub principal: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct RuntimeRequest {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- assembly
|
||||
|
||||
fn runtime_view() -> RuntimeView {
|
||||
let st = crate::plugins::runtime_status();
|
||||
RuntimeView {
|
||||
installed: st.installed,
|
||||
enabled: st.enabled,
|
||||
running: st.running,
|
||||
unit: st.unit.to_string(),
|
||||
principal: st.principal,
|
||||
detail: (!st.detail.is_empty()).then_some(st.detail),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the merged catalog view: every source's shelf, annotated with what this host has and can
|
||||
/// run. `force` refreshes past the TTL.
|
||||
fn build_catalog(force: bool) -> CatalogResponse {
|
||||
let states = store::catalogs(force);
|
||||
let installed = store::installed_packages(&store::plugins_dir());
|
||||
let mut plugins = Vec::new();
|
||||
let mut source_views = Vec::new();
|
||||
|
||||
for st in &states {
|
||||
let count = st.index.as_ref().map(|i| i.plugins.len()).unwrap_or(0);
|
||||
source_views.push(SourceView {
|
||||
name: st.source.name.clone(),
|
||||
url: st.source.url.clone(),
|
||||
builtin: st.source.is_official(),
|
||||
signed: st.source.is_signed(),
|
||||
stale: st.stale,
|
||||
fetched_at: st.fetched_at,
|
||||
error: st.error.clone(),
|
||||
entry_count: count as u32,
|
||||
public_key: st.source.public_key.clone(),
|
||||
});
|
||||
let Some(idx) = &st.index else { continue };
|
||||
let verified = st.source.is_official();
|
||||
for e in &idx.plugins {
|
||||
let installed_version = installed
|
||||
.iter()
|
||||
.find(|p| p.pkg == e.pkg)
|
||||
.and_then(|p| p.version.clone());
|
||||
let reason = e.incompatible_reason();
|
||||
plugins.push(CatalogEntry {
|
||||
id: e.id.clone(),
|
||||
pkg: e.pkg.clone(),
|
||||
title: e.title.clone(),
|
||||
description: e.description.clone(),
|
||||
icon: e.icon.clone(),
|
||||
author: e.author.clone(),
|
||||
homepage: e.homepage.clone(),
|
||||
license: e.license.clone(),
|
||||
version: e.version.clone(),
|
||||
source: st.source.name.clone(),
|
||||
// The badge is the built-in source's alone: a third-party curator can pin and
|
||||
// publish, but cannot confer unom's review (D6).
|
||||
tier: if verified { "verified" } else { "external" }.to_string(),
|
||||
reviewed_at: verified
|
||||
.then(|| e.verification.as_ref().map(|v| v.reviewed_at.clone()))
|
||||
.flatten(),
|
||||
platforms: e.platforms.clone(),
|
||||
min_host: e.min_host.clone(),
|
||||
compatible: reason.is_none(),
|
||||
incompatible_reason: reason,
|
||||
update_available: installed_version.as_deref().is_some_and(|v| v != e.version),
|
||||
installed_version,
|
||||
blocked: store::advisory_for(&e.pkg, Some(&e.version)).map(|a| a.reason),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Stable, useful order: alphabetical by title, then by source so duplicates across shelves
|
||||
// don't jitter between polls.
|
||||
plugins.sort_by(|a, b| {
|
||||
a.title
|
||||
.to_lowercase()
|
||||
.cmp(&b.title.to_lowercase())
|
||||
.then_with(|| a.source.cmp(&b.source))
|
||||
});
|
||||
CatalogResponse {
|
||||
host: HostFacts {
|
||||
version: index::host_version().to_string(),
|
||||
platform: index::HOST_PLATFORM.to_string(),
|
||||
},
|
||||
sources: source_views,
|
||||
plugins,
|
||||
busy: jobs::busy(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_installed(live: &[String]) -> Vec<InstalledView> {
|
||||
let dir = store::plugins_dir();
|
||||
let records = manifest::load(&dir);
|
||||
let catalogs = store::cached_catalogs();
|
||||
let mut out: Vec<InstalledView> = store::installed_packages(&dir)
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
let rec = records.get(&p.pkg);
|
||||
// The catalog row for this package, if any shelf carries it — the source of "an update
|
||||
// is available" and of a friendly title for CLI-installed packages.
|
||||
let entry = catalogs.iter().find_map(|s| {
|
||||
s.index
|
||||
.as_ref()?
|
||||
.plugins
|
||||
.iter()
|
||||
.find(|e| e.pkg == p.pkg)
|
||||
.map(|e| (e, s.source.name.clone()))
|
||||
});
|
||||
let plugin_id = entry
|
||||
.as_ref()
|
||||
.map(|(e, _)| e.id.clone())
|
||||
.or_else(|| store::plugin_id_for_pkg(&p.pkg));
|
||||
InstalledView {
|
||||
// Absence of a manifest record is meaningful: the CLI put it there (§4.5).
|
||||
tier: rec
|
||||
.map(|r| r.tier)
|
||||
.unwrap_or(manifest::Tier::Cli)
|
||||
.as_str()
|
||||
.to_string(),
|
||||
source: rec
|
||||
.and_then(|r| r.source.clone())
|
||||
.or_else(|| entry.as_ref().map(|(_, s)| s.clone())),
|
||||
entry_id: rec
|
||||
.and_then(|r| r.entry_id.clone())
|
||||
.or_else(|| entry.as_ref().map(|(e, _)| e.id.clone())),
|
||||
title: entry.as_ref().map(|(e, _)| e.title.clone()),
|
||||
installed_at: rec.and_then(|r| r.installed_at.clone()),
|
||||
running: plugin_id
|
||||
.as_deref()
|
||||
.is_some_and(|id| live.iter().any(|l| l == id)),
|
||||
update_available: entry.as_ref().and_then(|(e, _)| {
|
||||
(p.version.as_deref() != Some(e.version.as_str())).then(|| e.version.clone())
|
||||
}),
|
||||
blocked: store::advisory_for(&p.pkg, p.version.as_deref()).map(|a| a.reason),
|
||||
plugin_id,
|
||||
version: p.version,
|
||||
pkg: p.pkg,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.pkg.cmp(&b.pkg));
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- handlers
|
||||
|
||||
/// Browse the plugin catalog
|
||||
///
|
||||
/// The merged shelf across every configured source, annotated with what this host already has and
|
||||
/// what it can run. Sources past their freshness window are refreshed first; a source that can't be
|
||||
/// reached keeps serving its last good copy, marked `stale` (a LAN-only host still has a working
|
||||
/// store — an entry's pin travelled with the entry).
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/store/catalog",
|
||||
tag = "store",
|
||||
operation_id = "getPluginCatalog",
|
||||
responses(
|
||||
(status = OK, description = "The merged catalog", body = CatalogResponse),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn get_catalog() -> Response {
|
||||
match blocking(|| build_catalog(false)).await {
|
||||
Ok(c) => Json(c).into_response(),
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh every catalog now
|
||||
///
|
||||
/// Bypasses the freshness window and re-fetches all sources, then returns the merged catalog.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/store/refresh",
|
||||
tag = "store",
|
||||
operation_id = "refreshPluginCatalog",
|
||||
responses(
|
||||
(status = OK, description = "The freshly-fetched catalog", body = CatalogResponse),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn refresh_catalog() -> Response {
|
||||
match blocking(|| build_catalog(true)).await {
|
||||
Ok(c) => {
|
||||
crate::events::emit(crate::events::EventKind::StoreChanged);
|
||||
Json(c).into_response()
|
||||
}
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
|
||||
/// List installed plugins
|
||||
///
|
||||
/// What's actually in the plugins directory, joined with how it got there (the provenance manifest)
|
||||
/// and whether it is registered right now. A package with no provenance record was installed with
|
||||
/// the CLI and reports `tier: "cli"` — absence is the answer, not a gap.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/store/installed",
|
||||
tag = "store",
|
||||
operation_id = "listInstalledPlugins",
|
||||
responses(
|
||||
(status = OK, description = "Installed plugin packages", body = [InstalledView]),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn list_installed() -> Response {
|
||||
// The live set comes from the in-memory lease registry — cheap, and it must be read on this
|
||||
// thread rather than inside the blocking closure to keep the borrow simple.
|
||||
let live: Vec<String> = super::plugins::live_plugin_ids();
|
||||
match blocking(move || build_installed(&live)).await {
|
||||
Ok(v) => Json(v).into_response(),
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
|
||||
/// Install a plugin
|
||||
///
|
||||
/// Either `{source, id}` — a catalogued entry, installed at its pinned version after its integrity
|
||||
/// is re-checked against the registry — or `{spec, accept_unverified: true}`, which installs an
|
||||
/// unreviewed package the operator takes responsibility for. Returns `202` with a job id; watch it
|
||||
/// at `GET /store/jobs/{id}`.
|
||||
///
|
||||
/// One package operation runs at a time (`409` otherwise): `bun` operations share a lockfile and a
|
||||
/// `node_modules` tree.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/store/install",
|
||||
tag = "store",
|
||||
operation_id = "installPlugin",
|
||||
request_body = InstallRequest,
|
||||
responses(
|
||||
(status = ACCEPTED, description = "Install job started", body = JobRef),
|
||||
(status = BAD_REQUEST, description = "Unknown entry, bad spec, or missing acknowledgement", body = ApiError),
|
||||
(status = CONFLICT, description = "Another package operation is in flight", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn install_plugin(ApiJson(req): ApiJson<InstallRequest>) -> Response {
|
||||
let plan =
|
||||
match (
|
||||
req.source.as_deref(),
|
||||
req.id.as_deref(),
|
||||
req.spec.as_deref(),
|
||||
) {
|
||||
(Some(source), Some(id), None) => {
|
||||
let found = match blocking({
|
||||
let (source, id) = (source.to_string(), id.to_string());
|
||||
move || store::find_entry(&source, &id)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(f) => f,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let Some((entry, verified)) = found else {
|
||||
return api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"no such plugin in that source's catalog",
|
||||
);
|
||||
};
|
||||
if let Some(reason) = entry.incompatible_reason() {
|
||||
return api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!("this plugin cannot run on this host: {reason}"),
|
||||
);
|
||||
}
|
||||
match jobs::Plan::from_entry(&entry, source, verified) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return api_error(StatusCode::BAD_REQUEST, &format!("{e:#}")),
|
||||
}
|
||||
}
|
||||
(None, None, Some(spec)) => {
|
||||
if !req.accept_unverified {
|
||||
return api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"installing from a raw package spec runs unreviewed code with operator \
|
||||
privileges — set `accept_unverified` to confirm",
|
||||
);
|
||||
}
|
||||
match jobs::Plan::from_spec(spec) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return api_error(StatusCode::BAD_REQUEST, &format!("{e:#}")),
|
||||
}
|
||||
}
|
||||
_ => return api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"provide either {source, id} for a catalogued plugin or {spec} for a raw package",
|
||||
),
|
||||
};
|
||||
|
||||
match jobs::spawn_install(plan) {
|
||||
Ok(job) => (StatusCode::ACCEPTED, Json(JobRef { job })).into_response(),
|
||||
Err(e) => api_error(StatusCode::CONFLICT, &format!("{e:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Uninstall a plugin
|
||||
///
|
||||
/// Removes the package and forgets its provenance, then restarts the runner. Only names the runner
|
||||
/// would actually supervise are accepted, so this can't be used to rip a shared dependency out of
|
||||
/// the tree.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/store/uninstall",
|
||||
tag = "store",
|
||||
operation_id = "uninstallPlugin",
|
||||
request_body = UninstallRequest,
|
||||
responses(
|
||||
(status = ACCEPTED, description = "Uninstall job started", body = JobRef),
|
||||
(status = BAD_REQUEST, description = "Not a plugin package name", body = ApiError),
|
||||
(status = CONFLICT, description = "Another package operation is in flight", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn uninstall_plugin(ApiJson(req): ApiJson<UninstallRequest>) -> Response {
|
||||
if let Err(e) = store::valid_installed_pkg(&req.pkg) {
|
||||
return api_error(StatusCode::BAD_REQUEST, &format!("{e:#}"));
|
||||
}
|
||||
// The name shape is not enough. `@punktfunk/plugin-kit` — the framework every kit-built plugin
|
||||
// *depends on* — matches `@scope/plugin-*` exactly, so a syntactic guard waves it through and
|
||||
// the store offers to uninstall a library out from under the plugins using it (accepted on
|
||||
// Windows on-glass before this check existed). Only a package the operator actually installed
|
||||
// may be removed, which is precisely what `installed_packages` reports: the plugins dir's
|
||||
// top-level dependencies, transitive ones excluded.
|
||||
let pkg = req.pkg.clone();
|
||||
let known = match blocking(move || {
|
||||
store::installed_packages(&store::plugins_dir())
|
||||
.iter()
|
||||
.any(|p| p.pkg == pkg)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(k) => k,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if !known {
|
||||
return api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"that package is not an installed plugin — it may be a dependency of one, or already \
|
||||
removed",
|
||||
);
|
||||
}
|
||||
match jobs::spawn_uninstall(req.pkg) {
|
||||
Ok(job) => (StatusCode::ACCEPTED, Json(JobRef { job })).into_response(),
|
||||
Err(e) => api_error(StatusCode::CONFLICT, &format!("{e:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// List recent package jobs
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/store/jobs",
|
||||
tag = "store",
|
||||
operation_id = "listPluginJobs",
|
||||
responses(
|
||||
(status = OK, description = "Recent install/uninstall jobs, oldest first", body = [Job]),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn list_jobs() -> Json<Vec<jobs::Job>> {
|
||||
Json(jobs::list())
|
||||
}
|
||||
|
||||
/// Follow one package job
|
||||
///
|
||||
/// Poll this while `state` is `running`; `log` carries the tail of the package manager's output.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/store/jobs/{id}",
|
||||
tag = "store",
|
||||
operation_id = "getPluginJob",
|
||||
params(("id" = String, Path, description = "The job id returned by install/uninstall")),
|
||||
responses(
|
||||
(status = OK, description = "The job", body = Job),
|
||||
(status = NOT_FOUND, description = "No such job (they are kept for a bounded history)", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn get_job(Path(id): Path<String>) -> Response {
|
||||
match jobs::get(&id) {
|
||||
Some(j) => Json(j).into_response(),
|
||||
None => api_error(StatusCode::NOT_FOUND, "no such job"),
|
||||
}
|
||||
}
|
||||
|
||||
/// List catalog sources
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/store/sources",
|
||||
tag = "store",
|
||||
operation_id = "listPluginSources",
|
||||
responses(
|
||||
(status = OK, description = "Configured sources, built-in first", body = [SourceView]),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn list_sources() -> Response {
|
||||
match blocking(|| {
|
||||
store::cached_catalogs()
|
||||
.into_iter()
|
||||
.map(|st| SourceView {
|
||||
name: st.source.name.clone(),
|
||||
url: st.source.url.clone(),
|
||||
builtin: st.source.is_official(),
|
||||
signed: st.source.is_signed(),
|
||||
stale: st.stale,
|
||||
fetched_at: st.fetched_at,
|
||||
error: st.error,
|
||||
entry_count: st.index.map(|i| i.plugins.len()).unwrap_or(0) as u32,
|
||||
public_key: st.source.public_key,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(v) => Json(v).into_response(),
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add or update a catalog source
|
||||
///
|
||||
/// Adding a source is a trust decision: its entries become installable on this host. They are
|
||||
/// attributed to it in the console and never carry the "verified" badge, which belongs to the
|
||||
/// built-in source alone.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/store/sources/{name}",
|
||||
tag = "store",
|
||||
operation_id = "putPluginSource",
|
||||
params(("name" = String, Path, description = "Source slug (`[a-z][a-z0-9-]*`)")),
|
||||
request_body = SourceInput,
|
||||
responses(
|
||||
(status = NO_CONTENT, description = "Source saved"),
|
||||
(status = BAD_REQUEST, description = "Invalid name, url or key — or the reserved built-in name", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn put_source(
|
||||
Path(name): Path<String>,
|
||||
ApiJson(input): ApiJson<SourceInput>,
|
||||
) -> Response {
|
||||
let source = sources::Source {
|
||||
name: name.clone(),
|
||||
url: input.url,
|
||||
public_key: input.public_key.filter(|k| !k.trim().is_empty()),
|
||||
};
|
||||
let saved = blocking(move || {
|
||||
let r = sources::put(source);
|
||||
if r.is_ok() {
|
||||
// A redefined source must not keep serving what the old definition cached.
|
||||
store::drop_source_cache(&name);
|
||||
}
|
||||
r
|
||||
})
|
||||
.await;
|
||||
match saved {
|
||||
Err(e) => e,
|
||||
Ok(Err(e)) => api_error(StatusCode::BAD_REQUEST, &format!("{e:#}")),
|
||||
Ok(Ok(())) => {
|
||||
crate::events::emit(crate::events::EventKind::StoreChanged);
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a catalog source
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/store/sources/{name}",
|
||||
tag = "store",
|
||||
operation_id = "deletePluginSource",
|
||||
params(("name" = String, Path, description = "Source slug")),
|
||||
responses(
|
||||
(status = NO_CONTENT, description = "Removed (or already absent)"),
|
||||
(status = FORBIDDEN, description = "The built-in source cannot be removed, or the plugin token is not authorized", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn delete_source(Path(name): Path<String>) -> Response {
|
||||
if name == sources::OFFICIAL_NAME {
|
||||
return api_error(
|
||||
StatusCode::FORBIDDEN,
|
||||
"the built-in source cannot be removed",
|
||||
);
|
||||
}
|
||||
let removed = blocking(move || {
|
||||
let r = sources::remove(&name);
|
||||
if matches!(r, Ok(true)) {
|
||||
store::drop_source_cache(&name);
|
||||
}
|
||||
r
|
||||
})
|
||||
.await;
|
||||
match removed {
|
||||
Err(e) => e,
|
||||
Ok(Err(e)) => api_error(StatusCode::BAD_REQUEST, &format!("{e:#}")),
|
||||
Ok(Ok(_)) => {
|
||||
crate::events::emit(crate::events::EventKind::StoreChanged);
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin runner state
|
||||
///
|
||||
/// Installed plugins only run while the runner is on, and the runner discovers units at startup —
|
||||
/// so this is both the "is anything running" answer and the explanation for a freshly installed
|
||||
/// plugin that hasn't appeared yet.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/store/runtime",
|
||||
tag = "store",
|
||||
operation_id = "getPluginRuntime",
|
||||
responses(
|
||||
(status = OK, description = "Runner state", body = RuntimeView),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn get_runtime() -> Response {
|
||||
match blocking(runtime_view).await {
|
||||
Ok(v) => Json(v).into_response(),
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn the plugin runner on or off
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/store/runtime",
|
||||
tag = "store",
|
||||
operation_id = "setPluginRuntime",
|
||||
request_body = RuntimeRequest,
|
||||
responses(
|
||||
(status = OK, description = "The resulting runner state", body = RuntimeView),
|
||||
(status = BAD_REQUEST, description = "The runner could not be switched", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn set_runtime(ApiJson(req): ApiJson<RuntimeRequest>) -> Response {
|
||||
let switched =
|
||||
blocking(move || crate::plugins::set_runtime_enabled(req.enabled).map(|()| runtime_view()))
|
||||
.await;
|
||||
match switched {
|
||||
Err(e) => e,
|
||||
Ok(Err(e)) => api_error(StatusCode::BAD_REQUEST, &format!("{e:#}")),
|
||||
Ok(Ok(v)) => {
|
||||
crate::events::emit(crate::events::EventKind::StoreChanged);
|
||||
Json(v).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-exported so `routes!` can name the response body types.
|
||||
pub(crate) use crate::store::jobs::Job;
|
||||
@@ -434,6 +434,46 @@ fn plugin_allowlist_excludes_escalation_routes() {
|
||||
&Method::GET,
|
||||
"/api/v1/plugins/x/ui-credential"
|
||||
));
|
||||
|
||||
// The plugin STORE, wholesale. Installing a plugin runs new code with operator privileges, so a
|
||||
// plugin able to do it could install a helper that isn't constrained the way it is — and
|
||||
// `POST /store/runtime` would let it switch its own supervisor. Denied by whole-prefix so a
|
||||
// route added here later is denied by default rather than by remembering to list it.
|
||||
for path in [
|
||||
"/api/v1/store/catalog",
|
||||
"/api/v1/store/installed",
|
||||
"/api/v1/store/sources",
|
||||
"/api/v1/store/jobs",
|
||||
"/api/v1/store/jobs/job-1",
|
||||
"/api/v1/store/runtime",
|
||||
"/api/v1/store/some-route-that-does-not-exist-yet",
|
||||
] {
|
||||
assert!(
|
||||
!auth::plugin_may_access(&Method::GET, path),
|
||||
"plugin token must not reach {path}"
|
||||
);
|
||||
}
|
||||
for path in [
|
||||
"/api/v1/store/install",
|
||||
"/api/v1/store/uninstall",
|
||||
"/api/v1/store/refresh",
|
||||
"/api/v1/store/runtime",
|
||||
] {
|
||||
assert!(
|
||||
!auth::plugin_may_access(&Method::POST, path),
|
||||
"plugin token must not reach {path}"
|
||||
);
|
||||
}
|
||||
assert!(!auth::plugin_may_access(
|
||||
&Method::PUT,
|
||||
"/api/v1/store/sources/evil"
|
||||
));
|
||||
assert!(!auth::plugin_may_access(
|
||||
&Method::DELETE,
|
||||
"/api/v1/store/sources/unom"
|
||||
));
|
||||
// …but a route that merely starts with the same letters is unaffected.
|
||||
assert!(auth::plugin_may_access(&Method::GET, "/api/v1/status"));
|
||||
}
|
||||
|
||||
/// The plugin bearer lane end-to-end: scoped 403s on the carve-outs, 200s on the plugin surface,
|
||||
@@ -484,6 +524,12 @@ async fn plugin_token_lane_is_scoped_and_loopback_only() {
|
||||
(Method::GET, "/api/v1/native/pending"),
|
||||
(Method::DELETE, "/api/v1/clients/aabbcc"),
|
||||
(Method::GET, "/api/v1/plugins/x/ui-credential"),
|
||||
// The plugin store: a plugin must not be able to install plugins or switch its own runner.
|
||||
(Method::GET, "/api/v1/store/catalog"),
|
||||
(Method::POST, "/api/v1/store/install"),
|
||||
(Method::POST, "/api/v1/store/uninstall"),
|
||||
(Method::POST, "/api/v1/store/runtime"),
|
||||
(Method::PUT, "/api/v1/store/sources/evil"),
|
||||
] {
|
||||
let (status, body) = send(&app, plugin_req(method.clone(), path)).await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN, "{method} {path}");
|
||||
|
||||
@@ -439,7 +439,11 @@ pub(crate) async fn serve(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => tracing::info!(%peer, "session complete"),
|
||||
Ok(Served::Session) => tracing::info!(%peer, "session complete"),
|
||||
Ok(Served::ProbeClose) => tracing::debug!(
|
||||
%peer,
|
||||
"closed before the control handshake (reachability probe)"
|
||||
),
|
||||
Err(e) => {
|
||||
tracing::warn!(%peer, error = %format!("{e:#}"), "session ended with error")
|
||||
}
|
||||
@@ -629,6 +633,15 @@ type AudioCapSlot = Arc<std::sync::Mutex<Option<Box<dyn crate::audio::AudioCaptu
|
||||
/// connection (the host stops waiting at once).
|
||||
const PENDING_APPROVAL_WAIT: std::time::Duration = std::time::Duration::from_secs(180);
|
||||
|
||||
/// How a served connection ended. A peer that completes the QUIC handshake and closes cleanly
|
||||
/// (code 0) without ever opening the control stream is a reachability probe (the clients'
|
||||
/// hosts-page "online" pips / `--reachable`) or an abandoned connect — routine, and logged
|
||||
/// quietly: as a WARN it buried the real failures in a wake-on-LAN triage log.
|
||||
enum Served {
|
||||
Session,
|
||||
ProbeClose,
|
||||
}
|
||||
|
||||
/// One client session: handshake → input/audio planes → data plane until done/disconnect.
|
||||
/// Everything torn down on return (RAII: virtual output, encoder, threads via channel close).
|
||||
/// A connection whose first message is a PairRequest runs the pairing ceremony instead.
|
||||
@@ -651,14 +664,23 @@ async fn serve_session(
|
||||
// parked knock can't hold a streaming slot. `sem` is the pool it re-acquires from.
|
||||
mut permit: tokio::sync::OwnedSemaphorePermit,
|
||||
sem: Arc<tokio::sync::Semaphore>,
|
||||
) -> Result<()> {
|
||||
) -> Result<Served> {
|
||||
let peer = conn.remote_address();
|
||||
|
||||
// First message decides what this connection is: a pairing ceremony or a session.
|
||||
let (mut send, mut recv) = tokio::time::timeout(HANDSHAKE_TIMEOUT, conn.accept_bi())
|
||||
let (mut send, mut recv) = match tokio::time::timeout(HANDSHAKE_TIMEOUT, conn.accept_bi())
|
||||
.await
|
||||
.map_err(|_| anyhow!("control stream timeout"))?
|
||||
.context("accept control stream")?;
|
||||
{
|
||||
// A clean close before any control stream: a reachability probe / abandoned connect,
|
||||
// not a failed session (see [`Served::ProbeClose`]).
|
||||
Err(quinn::ConnectionError::ApplicationClosed(ref ac))
|
||||
if ac.error_code == quinn::VarInt::from_u32(0) =>
|
||||
{
|
||||
return Ok(Served::ProbeClose);
|
||||
}
|
||||
r => r.context("accept control stream")?,
|
||||
};
|
||||
let first = tokio::time::timeout(HANDSHAKE_TIMEOUT, io::read_msg(&mut recv))
|
||||
.await
|
||||
.map_err(|_| anyhow!("first message timeout"))??;
|
||||
@@ -709,7 +731,9 @@ async fn serve_session(
|
||||
}
|
||||
*last = Some(std::time::Instant::now());
|
||||
}
|
||||
return pair_ceremony(&conn, send, recv, req, host_fp, np, &pin).await;
|
||||
return pair_ceremony(&conn, send, recv, req, host_fp, np, &pin)
|
||||
.await
|
||||
.map(|()| Served::Session);
|
||||
}
|
||||
|
||||
// Pairing gate for a session Hello (a PairRequest was handled above). Lifted OUT of the
|
||||
@@ -1175,6 +1199,13 @@ async fn serve_session(
|
||||
launch: hello.launch.clone(),
|
||||
plane: crate::events::Plane::Native,
|
||||
});
|
||||
// GPU clock pin (Linux, opt-in `PUNKTFUNK_PIN_CLOCKS`): hold the box-wide vendor clock floor for
|
||||
// as long as THIS session streams, refcounted with every other live session across both planes.
|
||||
// RAII like the marker above — armed on the first live client, released when the last one
|
||||
// disconnects, so idle clocks aren't pinned while nobody is connected. No-op off Linux / when
|
||||
// the flag is unset.
|
||||
#[cfg(target_os = "linux")]
|
||||
let _clock_pin = crate::gpuclocks::session_pin();
|
||||
// The session's launch, threaded into the data plane. Windows carries the store-qualified id
|
||||
// (spawned into the interactive user session once capture is live); other hosts resolve the id
|
||||
// to its shell command HERE against the host's own library — a client can only ever pick an
|
||||
@@ -1238,6 +1269,9 @@ async fn serve_session(
|
||||
// so mid-session bursts don't consume video frame indexes. An older client (bit clear) gets
|
||||
// mid-session probes declined instead — see `run_probe_burst`.
|
||||
let probe_seq = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ != 0;
|
||||
// Streamed-AU capability: the client's reassembler accepts sentinel-headed streamed blocks,
|
||||
// so a chunked encoder session may ship an AU's early FEC blocks while its tail encodes.
|
||||
let streamed_au = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_STREAMED_AU != 0;
|
||||
let stats_dp = stats; // data-plane handle to the shared stats recorder
|
||||
// Short label for web-console stats captures: the client's cert-fingerprint prefix, else its
|
||||
// peer IP (no fingerprint = anonymous TOFU/--open client).
|
||||
@@ -1330,6 +1364,7 @@ async fn serve_session(
|
||||
conn: conn_stream,
|
||||
timing_conn,
|
||||
probe_seq,
|
||||
streamed_au,
|
||||
stats: stats_dp,
|
||||
client_label,
|
||||
launch: launch_for_dp,
|
||||
@@ -1388,7 +1423,7 @@ async fn serve_session(
|
||||
// host-managed gamescope path on a box that autologs into gaming mode (Bazzite default), put the
|
||||
// TV's gaming session back so it's the default when no one is streaming.
|
||||
crate::vdisplay::restore_managed_session();
|
||||
result
|
||||
result.map(|()| Served::Session)
|
||||
}
|
||||
|
||||
/// Backoff between reopen attempts after a host-lifetime service's backend (a capturer) fails
|
||||
|
||||
@@ -354,6 +354,28 @@ pub(super) async fn negotiate(
|
||||
// just follow.
|
||||
let mut salt = [0u8; 4];
|
||||
rand::thread_rng().fill_bytes(&mut salt);
|
||||
// Session AEAD: ChaCha20-Poly1305 when the client asked for it (VIDEO_CAP_CHACHA20 — the
|
||||
// soft-AES armv7 targets, whose GCM decrypt caps at ~100 Mbps) and the operator
|
||||
// kill-switch allows (PUNKTFUNK_CHACHA20, default on — pure rollout safety; perf-only,
|
||||
// both AEADs are full-strength). The fresh-per-session discipline above applies to this
|
||||
// key identically; the legacy 16-byte `key` stays independently random so nothing
|
||||
// downstream ever observes an all-zero key.
|
||||
let client_wants_chacha = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_CHACHA20 != 0;
|
||||
let chacha = client_wants_chacha && pf_host_config::config().chacha20;
|
||||
let key_chacha = chacha.then(|| {
|
||||
let mut k = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut k);
|
||||
k
|
||||
});
|
||||
tracing::info!(
|
||||
cipher = if chacha {
|
||||
"chacha20-poly1305"
|
||||
} else {
|
||||
"aes-128-gcm"
|
||||
},
|
||||
client_wants_chacha,
|
||||
"session cipher"
|
||||
);
|
||||
let welcome = Welcome {
|
||||
abi_version: punktfunk_core::WIRE_VERSION,
|
||||
udp_port,
|
||||
@@ -423,6 +445,16 @@ pub(super) async fn negotiate(
|
||||
} 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
|
||||
// pre-cipher wire form. The host's own data plane picks the cipher up via
|
||||
// `welcome.session_config` — no other host change.
|
||||
cipher: if chacha {
|
||||
punktfunk_core::quic::CIPHER_CHACHA20_POLY1305
|
||||
} else {
|
||||
punktfunk_core::quic::CIPHER_AES_128_GCM
|
||||
},
|
||||
key_chacha,
|
||||
};
|
||||
io::write_msg(send, &welcome.encode()).await?;
|
||||
bringup.mark("welcome");
|
||||
|
||||
@@ -252,6 +252,19 @@ fn paced_submit(
|
||||
let wires = session
|
||||
.seal_frame_at(data, pts_ns, flags, frame_index)
|
||||
.map_err(|e| anyhow!("seal_frame: {e:?}"))?;
|
||||
pace_sealed(session, wires, deadline, burst_cap, pace_rate_bps)
|
||||
}
|
||||
|
||||
/// The pace-and-send half of [`paced_submit`], for wires that are ALREADY sealed — shared with
|
||||
/// the streamed-AU path, whose seal happens per encoder chunk ([`handle_chunk`]) under the same
|
||||
/// microburst policy and frame deadline.
|
||||
fn pace_sealed(
|
||||
session: &mut Session,
|
||||
wires: Vec<Vec<u8>>,
|
||||
deadline: std::time::Instant,
|
||||
burst_cap: Option<usize>,
|
||||
pace_rate_bps: u64,
|
||||
) -> Result<PaceStat> {
|
||||
let mut refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect();
|
||||
// FEC/recovery test knob (PUNKTFUNK_VIDEO_DROP) — same knob the GameStream plane honors.
|
||||
crate::send_pacing::inject_video_drop(&mut refs);
|
||||
@@ -335,6 +348,116 @@ struct FrameMsg {
|
||||
was_measured: bool,
|
||||
}
|
||||
|
||||
/// What the encode thread hands the send thread: a whole AU (the legacy path — every session
|
||||
/// shape except a chunked encoder toward a streamed-capable client), or one slice-boundary
|
||||
/// chunk of a streamed AU (§7 LN1 Phase 2 — the send thread seals/paces each chunk's completed
|
||||
/// FEC blocks while the encoder still produces the AU's tail).
|
||||
enum SendMsg {
|
||||
Frame(FrameMsg),
|
||||
Chunk(ChunkMsg),
|
||||
}
|
||||
|
||||
/// One encoder chunk of a streamed AU. AU-level fields (`capture_ns`/`flags`/`frame_index`/
|
||||
/// `deadline`) are identical on every chunk of one AU (the send thread opens the streamed seal
|
||||
/// at `first`); the perf split fields are meaningful on `last` (whole-AU figures, exactly like
|
||||
/// [`FrameMsg`]'s).
|
||||
struct ChunkMsg {
|
||||
data: Vec<u8>,
|
||||
first: bool,
|
||||
last: bool,
|
||||
capture_ns: u64,
|
||||
flags: u32,
|
||||
frame_index: u32,
|
||||
deadline: std::time::Instant,
|
||||
encode_us: u32,
|
||||
queue_us: u32,
|
||||
cap_us: u32,
|
||||
submit_us: u32,
|
||||
wait_us: u32,
|
||||
repeat: bool,
|
||||
was_measured: bool,
|
||||
}
|
||||
|
||||
/// A streamed AU the send thread has open: the core's incremental sealer plus the pace
|
||||
/// aggregation across its per-chunk flushes (the accounting the whole-AU path reads off one
|
||||
/// [`paced_submit`] call).
|
||||
struct StreamedOpen {
|
||||
au: punktfunk_core::packet::StreamedAu,
|
||||
spread_us: u32,
|
||||
paced: bool,
|
||||
}
|
||||
|
||||
/// Feed one [`ChunkMsg`] through the streamed sealer: open at `first`, seal + pace every FEC
|
||||
/// block the chunk completes, close (+ final block, real totals) at `last`. Returns
|
||||
/// `Some((accounting, aggregated PaceStat))` when the AU finished — the caller runs the same
|
||||
/// per-AU accounting as the whole-frame path — and `None` mid-AU.
|
||||
fn handle_chunk(
|
||||
session: &mut Session,
|
||||
open: &mut Option<StreamedOpen>,
|
||||
c: ChunkMsg,
|
||||
burst_cap: Option<usize>,
|
||||
pace_rate_bps: u64,
|
||||
) -> Result<Option<(FrameMsg, PaceStat)>> {
|
||||
if c.first {
|
||||
if open.take().is_some() {
|
||||
// The encode loop abandoned a mid-flight AU (an encoder stall/rebuild forfeits the
|
||||
// in-flight frame). Its sentinel packets are already on the wire — the client ages
|
||||
// that frame out and the rebuild's IDR re-anchors; just don't leak the open state.
|
||||
tracing::warn!(
|
||||
"streamed AU abandoned mid-flight (encoder rebuild) — client ages it out"
|
||||
);
|
||||
}
|
||||
*open = Some(StreamedOpen {
|
||||
au: session
|
||||
.begin_streamed_frame_at(c.capture_ns, c.flags, c.frame_index)
|
||||
.map_err(|e| anyhow!("begin_streamed_frame: {e:?}"))?,
|
||||
spread_us: 0,
|
||||
paced: false,
|
||||
});
|
||||
}
|
||||
let Some(s) = open.as_mut() else {
|
||||
return Err(anyhow!(
|
||||
"streamed chunk without an open AU (encode-loop bug)"
|
||||
));
|
||||
};
|
||||
let wires = session
|
||||
.seal_streamed_chunk(&mut s.au, &c.data)
|
||||
.map_err(|e| anyhow!("seal_streamed_chunk: {e:?}"))?;
|
||||
if !wires.is_empty() {
|
||||
let stat = pace_sealed(session, wires, c.deadline, burst_cap, pace_rate_bps)?;
|
||||
s.spread_us = s.spread_us.saturating_add(stat.spread_us);
|
||||
s.paced |= stat.paced;
|
||||
}
|
||||
if !c.last {
|
||||
return Ok(None);
|
||||
}
|
||||
let s = open.take().expect("checked above");
|
||||
let tail = session
|
||||
.seal_streamed_finish(s.au)
|
||||
.map_err(|e| anyhow!("seal_streamed_finish: {e:?}"))?;
|
||||
let stat = pace_sealed(session, tail, c.deadline, burst_cap, pace_rate_bps)?;
|
||||
Ok(Some((
|
||||
FrameMsg {
|
||||
data: Vec::new(), // already on the wire — accounting only
|
||||
capture_ns: c.capture_ns,
|
||||
flags: c.flags,
|
||||
frame_index: c.frame_index,
|
||||
deadline: c.deadline,
|
||||
encode_us: c.encode_us,
|
||||
queue_us: c.queue_us,
|
||||
cap_us: c.cap_us,
|
||||
submit_us: c.submit_us,
|
||||
wait_us: c.wait_us,
|
||||
repeat: c.repeat,
|
||||
was_measured: c.was_measured,
|
||||
},
|
||||
PaceStat {
|
||||
spread_us: s.spread_us.saturating_add(stat.spread_us),
|
||||
paced: s.paced || stat.paced,
|
||||
},
|
||||
)))
|
||||
}
|
||||
|
||||
/// The dedicated send thread: it owns the whole [`Session`] (so no socket clone or shared stats are
|
||||
/// needed) and does FEC+seal + microburst-paced send OFF the capture/encode thread, plus the
|
||||
/// speed-test probe bursts (which also need the Session). Decoupling the paced send from encoding
|
||||
@@ -380,7 +503,7 @@ pub(super) fn reconfig_allowed(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn send_loop(
|
||||
mut session: Session,
|
||||
frame_rx: std::sync::mpsc::Receiver<FrameMsg>,
|
||||
frame_rx: std::sync::mpsc::Receiver<SendMsg>,
|
||||
probe_rx: std::sync::mpsc::Receiver<ProbeRequest>,
|
||||
probe_result_tx: tokio::sync::mpsc::UnboundedSender<ProbeResult>,
|
||||
stop: Arc<AtomicBool>,
|
||||
@@ -425,18 +548,33 @@ fn send_loop(
|
||||
let mut last_frames_dropped = 0u64;
|
||||
let mut last_packets_dropped = 0u64;
|
||||
let mut last_fec_recovered = 0u64;
|
||||
// The streamed AU currently open (VIDEO_CAP_STREAMED_AU chunked sends) — `Some` strictly
|
||||
// between a `ChunkMsg::first` and its `last`.
|
||||
let mut streamed: Option<StreamedOpen> = None;
|
||||
loop {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
// Probes run here (they need the Session); a burst pauses video — the encode thread blocks
|
||||
// on the full frame channel meanwhile, which is exactly the intended pause.
|
||||
// on the full frame channel meanwhile, which is exactly the intended pause. Never mid-AU:
|
||||
// a streamed frame's chunks are already leaving the socket, so a burst spliced between
|
||||
// them would push the AU's tail past its deadline (the exact latency the mode removes).
|
||||
if streamed.is_none() {
|
||||
service_probes(&mut session, &stop, &probe_rx, &probe_result_tx, probe_seq);
|
||||
}
|
||||
// Adaptive FEC: pick up any new recovery target the control task set from client LossReports.
|
||||
apply_fec_target(&mut session, &fec_target);
|
||||
// Short timeout so we keep re-checking `stop` + probes when no frames are flowing.
|
||||
match frame_rx.recv_timeout(std::time::Duration::from_millis(50)) {
|
||||
Ok(msg) => match paced_submit(
|
||||
Ok(send_msg) => {
|
||||
// Live ABR-tracked encoder bitrate → pace rate; 0 (not yet known) = uncapped.
|
||||
let pace_rate = (stats.bitrate_kbps.load(Ordering::Relaxed) as f64
|
||||
* 1000.0
|
||||
* pace_factor) as u64;
|
||||
// `Ok(Some(..))` = an AU fully left the socket (a whole frame, or a streamed
|
||||
// AU's last chunk) — run the per-AU accounting; `Ok(None)` = mid-AU chunk.
|
||||
let outcome = match send_msg {
|
||||
SendMsg::Frame(msg) => paced_submit(
|
||||
&mut session,
|
||||
&msg.data,
|
||||
msg.capture_ns,
|
||||
@@ -444,10 +582,17 @@ fn send_loop(
|
||||
msg.frame_index,
|
||||
msg.deadline,
|
||||
burst_cap,
|
||||
// Live ABR-tracked encoder bitrate → pace rate; 0 (not yet known) = uncapped.
|
||||
(stats.bitrate_kbps.load(Ordering::Relaxed) as f64 * 1000.0 * pace_factor) as u64,
|
||||
) {
|
||||
Ok(stat) => {
|
||||
pace_rate,
|
||||
)
|
||||
.map(|stat| Some((msg, stat))),
|
||||
SendMsg::Chunk(c) => {
|
||||
handle_chunk(&mut session, &mut streamed, c, burst_cap, pace_rate)
|
||||
}
|
||||
};
|
||||
match outcome {
|
||||
// Mid-AU chunk: sealed + on the wire; the per-AU accounting runs at `last`.
|
||||
Ok(None) => {}
|
||||
Ok(Some((msg, stat))) => {
|
||||
// First VIDEO packets are on the wire — complete the bring-up trace (P0.1;
|
||||
// once-only, no-op on every later frame). Speed-test filler isn't video.
|
||||
if msg.flags & FLAG_PROBE as u32 == 0 {
|
||||
@@ -514,7 +659,8 @@ fn send_loop(
|
||||
tracing::error!(error = %format!("{e:#}"), "send failed — stopping stream");
|
||||
break;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, // encode thread done
|
||||
}
|
||||
@@ -798,6 +944,11 @@ pub(super) struct SessionContext {
|
||||
/// stale — mid-session probes are DECLINED for it (a zeroed [`ProbeResult`]) rather than
|
||||
/// consuming video frame indexes its gap detectors can't see (the phantom-gap freeze).
|
||||
pub(super) probe_seq: bool,
|
||||
/// The client advertised [`punktfunk_core::quic::VIDEO_CAP_STREAMED_AU`]: when the session's
|
||||
/// encoder runs chunked poll (multi-slice sub-frame readback, §7 LN1), the host streams each
|
||||
/// AU's FEC blocks under sentinel headers as the slices complete instead of waiting for the
|
||||
/// whole AU. `false` = older client — chunks (if any) are drained whole-AU, zero wire change.
|
||||
pub(super) streamed_au: bool,
|
||||
/// Shared streaming-stats recorder. The capture loop reads `is_armed()` per frame to decide
|
||||
/// whether to measure the per-stage split; the send thread builds + pushes the aggregated
|
||||
/// `StatsSample` at its 2 s boundary.
|
||||
@@ -832,7 +983,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// path now reads this typed `SessionPlan` instead of re-deriving from config at each dispatch site
|
||||
// (the latent "capture and encode disagree on the backend" hazard, plan §2.4). `bit_depth` is the
|
||||
// only per-session input — capture/topology/encoder are otherwise pure functions of `HostConfig`.
|
||||
let mut plan = crate::session_plan::SessionPlan::resolve(ctx.bit_depth, ctx.chroma, ctx.codec);
|
||||
let mut plan = crate::session_plan::SessionPlan::resolve(
|
||||
ctx.bit_depth,
|
||||
ctx.chroma,
|
||||
ctx.codec,
|
||||
ctx.compositor != pf_vdisplay::Compositor::Gamescope,
|
||||
);
|
||||
// PyroWave rides the datagram-aligned wire mode (§4.4): every encoder this session opens
|
||||
// packetizes at the negotiated shard payload, so a lost datagram costs blocks, not frames.
|
||||
if ctx.codec == crate::encode::Codec::PyroWave {
|
||||
@@ -866,6 +1022,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
conn,
|
||||
timing_conn,
|
||||
probe_seq,
|
||||
streamed_au,
|
||||
stats,
|
||||
client_label,
|
||||
launch,
|
||||
@@ -873,6 +1030,16 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
bringup,
|
||||
resize_ms,
|
||||
} = ctx;
|
||||
// Streamed-AU wire mode: the client's cap AND the host escape hatch (`PUNKTFUNK_STREAMED_AU=0`
|
||||
// reverts to whole-AU sends without touching the encoder's slicing knobs). The third gate —
|
||||
// whether the ENCODER actually chunks — is dynamic (`supports_chunked_poll`, per AU).
|
||||
let streamed_wire = streamed_au && std::env::var("PUNKTFUNK_STREAMED_AU").as_deref() != Ok("0");
|
||||
if streamed_wire {
|
||||
tracing::info!(
|
||||
"client accepts streamed AUs (VIDEO_CAP_STREAMED_AU) — chunked encoder output \
|
||||
will stream per-slice"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
compositor = compositor.id(),
|
||||
?mode,
|
||||
@@ -944,6 +1111,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
plan,
|
||||
&quit,
|
||||
&stop,
|
||||
8,
|
||||
Some(bringup.as_ref()),
|
||||
)?;
|
||||
// Setup done — the IDD-push setup lock releases as the guard leaves this arm's scope,
|
||||
@@ -1032,7 +1200,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// encode of frame N+1 overlaps the paced transmit of frame N instead of waiting behind its tail.
|
||||
// The bounded channel applies backpressure (the encode thread blocks if the send falls behind,
|
||||
// so frames slow down rather than a dropped frame freezing the infinite-GOP stream).
|
||||
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<FrameMsg>(3);
|
||||
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<SendMsg>(3);
|
||||
// Live encoder bitrate, shared with the send thread's stats sample: a mid-stream adaptive
|
||||
// bitrate change (bitrate_rx below) updates it so the console shows the actual target.
|
||||
let live_bitrate = Arc::new(AtomicU32::new(bitrate_kbps));
|
||||
@@ -1259,6 +1427,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
plan,
|
||||
&quit,
|
||||
&stop,
|
||||
8,
|
||||
None,
|
||||
)?;
|
||||
Ok((new_vd, pipe))
|
||||
@@ -1355,6 +1524,10 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
bit_depth,
|
||||
plan,
|
||||
&quit,
|
||||
// No first-frame shortening here: this direct call has no retry wrapper to
|
||||
// absorb an early bail, and the resize source is a live compositor (the
|
||||
// takeover race doesn't apply) — keep the patient default.
|
||||
None,
|
||||
Some(resize_trace.as_ref()),
|
||||
) {
|
||||
Ok(next_pipe) => {
|
||||
@@ -1470,6 +1643,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
frame.is_cuda(),
|
||||
bit_depth,
|
||||
plan.chroma,
|
||||
plan.cursor_blend,
|
||||
) {
|
||||
Ok(mut new_enc) => {
|
||||
tracing::info!(
|
||||
@@ -1710,7 +1884,15 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// connected, frozen on the last frame, and the stream resumes when the new output
|
||||
// appears — no reconnect.
|
||||
const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40);
|
||||
let rebuild_deadline = std::time::Instant::now() + REBUILD_BUDGET;
|
||||
// Attach-only holdoff: for the first seconds after a capture loss the session
|
||||
// detection can be STALE (the new session isn't up yet), and a rebuild acting on
|
||||
// a stale "Gaming" answer restarts gamescope-session.target — which on SteamOS
|
||||
// steals the seat back from the session the user just switched to (observed
|
||||
// live). While the holdoff lasts, builds run under a vdisplay rebuild-probe
|
||||
// scope: attach to live outputs only, never stop/relaunch/take over sessions.
|
||||
const PROBE_HOLDOFF: std::time::Duration = std::time::Duration::from_secs(4);
|
||||
let loss_at = std::time::Instant::now();
|
||||
let rebuild_deadline = loss_at + REBUILD_BUDGET;
|
||||
let (new_cap, new_enc, new_frame, new_interval, new_node_id, new_display_gen) = loop {
|
||||
// Follow the active session unless an explicit PUNKTFUNK_COMPOSITOR pin forbids
|
||||
// retargeting (then we stick to the pinned backend and just rebuild it).
|
||||
@@ -1744,6 +1926,8 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
}
|
||||
}
|
||||
}
|
||||
let _probe = (loss_at.elapsed() < PROBE_HOLDOFF)
|
||||
.then(crate::vdisplay::rebuild_probe_scope);
|
||||
match build_pipeline_with_retry(
|
||||
&mut vd,
|
||||
cur_mode,
|
||||
@@ -1752,6 +1936,16 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
plan,
|
||||
&quit,
|
||||
&stop,
|
||||
// 1, not 8: this loop re-detects the active session per iteration — short
|
||||
// inner cycles are what let it FOLLOW a session switch instead of burning
|
||||
// retries against a compositor that no longer exists. One attempt per
|
||||
// cycle also keeps every probe on the SHORT first-frame window (attempt 1
|
||||
// = 2.5 s): a patient 10 s attempt here just waits on a stale backend
|
||||
// (observed live: it made a Game→Desktop switch 20 s instead of ~9 —
|
||||
// the winning KWin rebuild took 0.7 s once detection caught up). The
|
||||
// slow-new-session case is the OUTER loop's job (40 s budget, fresh
|
||||
// 2.5 s probes until the new compositor delivers).
|
||||
1,
|
||||
None,
|
||||
) {
|
||||
Ok(p) => break p,
|
||||
@@ -1764,6 +1958,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
}
|
||||
tracing::warn!(error = %format!("{e2:#}"),
|
||||
"capture lost — new session not up yet, retrying");
|
||||
// Probe failures are instant (attach-only bail) — pace the loop so
|
||||
// re-detection runs at ~2 Hz instead of spinning.
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1932,6 +2129,116 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// carry it to the shared stall recovery below instead of killing the session outright.
|
||||
let mut poll_err: Option<anyhow::Error> = None;
|
||||
while inflight.len() >= depth {
|
||||
// Streamed chunked drain (§7 LN1 Phase 2): toward a STREAMED_AU client with the
|
||||
// encoder's chunked poll live, forward each slice chunk to the send thread the
|
||||
// moment it's readable, so packetize/FEC/pacing overlap the encode tail. Re-queried
|
||||
// per AU (never cached): a pipelined-retrieve escalation or a session rebuild turns
|
||||
// the mode off and the next AU falls back to the whole-AU path below.
|
||||
if streamed_wire && enc.supports_chunked_poll() {
|
||||
let t_wait = std::time::Instant::now();
|
||||
let mut first_chunk_us = 0u32;
|
||||
let mut au_flags = 0u32;
|
||||
let mut au_done = false;
|
||||
loop {
|
||||
let c = match enc.poll_chunk() {
|
||||
Ok(Some(c)) => c,
|
||||
Ok(None) => break, // defensive: nothing in flight
|
||||
Err(e) => {
|
||||
poll_err = Some(e);
|
||||
break;
|
||||
}
|
||||
};
|
||||
// Every chunk proves the encoder is alive.
|
||||
last_au_at = std::time::Instant::now();
|
||||
encoder_resets = 0;
|
||||
if c.first {
|
||||
first_chunk_us = t_wait.elapsed().as_micros() as u32;
|
||||
au_flags = if c.keyframe {
|
||||
(FLAG_PIC | FLAG_SOF) as u32
|
||||
} else {
|
||||
FLAG_PIC as u32
|
||||
};
|
||||
let caps = enc.caps();
|
||||
if caps.intra_refresh_recovery
|
||||
&& caps.intra_refresh_period > 0
|
||||
&& mark_recovery_boundary(
|
||||
&mut ir_wave_pos,
|
||||
c.keyframe,
|
||||
caps.intra_refresh_period,
|
||||
)
|
||||
{
|
||||
au_flags |= punktfunk_core::packet::USER_FLAG_RECOVERY_POINT;
|
||||
}
|
||||
if c.recovery_anchor {
|
||||
au_flags |= punktfunk_core::packet::USER_FLAG_RECOVERY_ANCHOR;
|
||||
}
|
||||
if c.chunk_aligned {
|
||||
au_flags |= punktfunk_core::packet::USER_FLAG_CHUNK_ALIGNED;
|
||||
}
|
||||
if let Some(m) = last_hdr_meta {
|
||||
if c.keyframe || resend_meta {
|
||||
let _ = conn.send_datagram(
|
||||
punktfunk_core::quic::encode_hdr_meta_datagram(&m).into(),
|
||||
);
|
||||
resend_meta = false;
|
||||
}
|
||||
}
|
||||
bringup.mark("first_au");
|
||||
}
|
||||
let last = c.last;
|
||||
let (cap_ns, sub_ns, deadline) = *inflight.front().expect("inflight non-empty");
|
||||
let wait_total_us = t_wait.elapsed().as_micros() as u32;
|
||||
let encode_us = (now_ns().saturating_sub(sub_ns) / 1000) as u32;
|
||||
let msg = ChunkMsg {
|
||||
data: c.data,
|
||||
first: c.first,
|
||||
last,
|
||||
capture_ns: cap_ns,
|
||||
flags: au_flags,
|
||||
frame_index: au_seq,
|
||||
deadline,
|
||||
encode_us,
|
||||
queue_us,
|
||||
cap_us,
|
||||
submit_us,
|
||||
wait_us: if measure { wait_total_us } else { 0 },
|
||||
repeat,
|
||||
was_measured: measure,
|
||||
};
|
||||
if frame_tx.send(SendMsg::Chunk(msg)).is_err() {
|
||||
send_gone = true;
|
||||
break;
|
||||
}
|
||||
if last {
|
||||
inflight.pop_front();
|
||||
au_seq = au_seq.wrapping_add(1);
|
||||
sent += 1;
|
||||
au_done = true;
|
||||
if perf {
|
||||
st_wait.push(wait_total_us);
|
||||
// The overlap measurement the Phase-3 gate needs (sampled): how
|
||||
// early the first slice reached the send thread vs. the whole
|
||||
// encode — the win is roughly their difference per AU.
|
||||
if sent % 120 == 0 {
|
||||
tracing::info!(
|
||||
first_slice_us = first_chunk_us,
|
||||
encode_us,
|
||||
"streamed AU (sampled): first slice handed to send at \
|
||||
first_slice_us; encode finished at encode_us"
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if send_gone || poll_err.is_some() {
|
||||
break;
|
||||
}
|
||||
if au_done {
|
||||
continue; // drain the next in-flight frame, if depth allows
|
||||
}
|
||||
break; // defensive Ok(None): leave the frame in flight, re-poll next tick
|
||||
}
|
||||
let t_wait = std::time::Instant::now();
|
||||
let polled = enc.poll();
|
||||
let wait_us = if measure {
|
||||
@@ -2012,7 +2319,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// Hand to the send thread; this blocks (backpressure) if it's behind. An Err means it
|
||||
// exited (send failure / stop) — end the encode loop too.
|
||||
bringup.mark("first_au"); // P0.1 (first-crossing only; free afterwards)
|
||||
if frame_tx.send(msg).is_err() {
|
||||
if frame_tx.send(SendMsg::Frame(msg)).is_err() {
|
||||
send_gone = true;
|
||||
break;
|
||||
}
|
||||
@@ -2157,7 +2464,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
repeat: false,
|
||||
was_measured: false,
|
||||
};
|
||||
if frame_tx.send(msg).is_err() {
|
||||
if frame_tx.send(SendMsg::Frame(msg)).is_err() {
|
||||
break;
|
||||
}
|
||||
au_seq = au_seq.wrapping_add(1);
|
||||
@@ -2279,6 +2586,7 @@ fn try_inplace_resize(
|
||||
new_frame.is_cuda(),
|
||||
bit_depth,
|
||||
plan.chroma,
|
||||
plan.cursor_blend,
|
||||
) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
@@ -2347,7 +2655,12 @@ pub(super) fn prepare_display(
|
||||
// Same plan resolution as `virtual_stream` (pure in these inputs + host config), including
|
||||
// PyroWave's datagram-aligned wire mode — `Session::shard_payload()` echoes the negotiated
|
||||
// Welcome value passed here.
|
||||
let mut plan = crate::session_plan::SessionPlan::resolve(bit_depth, chroma, codec);
|
||||
let mut plan = crate::session_plan::SessionPlan::resolve(
|
||||
bit_depth,
|
||||
chroma,
|
||||
codec,
|
||||
compositor != pf_vdisplay::Compositor::Gamescope,
|
||||
);
|
||||
if codec == crate::encode::Codec::PyroWave {
|
||||
plan.wire_chunk = Some(shard_payload as usize);
|
||||
}
|
||||
@@ -2371,6 +2684,7 @@ pub(super) fn prepare_display(
|
||||
plan,
|
||||
quit,
|
||||
stop,
|
||||
8,
|
||||
Some(trace),
|
||||
)?;
|
||||
Ok(PreparedDisplay { vd, pipeline })
|
||||
@@ -2395,15 +2709,22 @@ fn build_pipeline_with_retry(
|
||||
plan: crate::session_plan::SessionPlan,
|
||||
quit: &Arc<AtomicBool>,
|
||||
stop: &Arc<AtomicBool>,
|
||||
// Retry budget: 8 everywhere EXCEPT the capture-loss rebuild (2). That path wraps this call
|
||||
// in its own outer loop that RE-DETECTS the active session between calls — during a
|
||||
// Gaming↔Desktop switch the old compositor is simply gone, so burning 8 attempts (~13 s)
|
||||
// against its dead socket only delays following the box to the session that replaced it
|
||||
// (observed live: a Desktop→Gaming switch spent 13 of its 27 s retrying gone-KWin).
|
||||
max_attempts: u32,
|
||||
// Transition trace (P0.1): `Some` for the traced builds (bring-up, resize); each stage stamps
|
||||
// once (first crossing) so the retry loop can pass it through unconditionally.
|
||||
trace: Option<&crate::bringup::Trace>,
|
||||
) -> Result<Pipeline> {
|
||||
// ~10s first-frame wait per attempt. 8 gives a ~90s budget for the SLOW case: a host-managed
|
||||
// gamescope session cold-starting Steam Big Picture (the SteamOS/Bazzite takeover) can take
|
||||
// 30-60s to produce its first frame, and a first-connect timeout would tear down the warm
|
||||
// session (forcing another cold start on reconnect). A genuinely permanent failure still fails
|
||||
// fast via `is_permanent_build_error`; only transient "no frame yet" retries consume the budget.
|
||||
// ~10s first-frame wait per attempt (attempt 1: see FIRST_ATTEMPT_FRAME_BUDGET below). 8
|
||||
// gives a ~80s budget for the SLOW case: a host-managed gamescope session cold-starting Steam
|
||||
// Big Picture (the SteamOS/Bazzite takeover) can take 30-60s to produce its first frame, and
|
||||
// a first-connect timeout would tear down the warm session (forcing another cold start on
|
||||
// reconnect). A genuinely permanent failure still fails fast via `is_permanent_build_error`;
|
||||
// only transient "no frame yet" retries consume the budget.
|
||||
// IDD-push only: HOLD one monitor lease across all build attempts. A failed attempt's capturer
|
||||
// drop releases ITS lease, but this held lease keeps the shared monitor Active (refs >= 1), so the
|
||||
// next attempt's `vd.create` JOINS it (refcount++) instead of finding it Lingering and tripping the
|
||||
@@ -2421,9 +2742,16 @@ fn build_pipeline_with_retry(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
const MAX_ATTEMPTS: u32 = 8;
|
||||
// Attempt 1 waits only briefly for the first frame: a PipeWire stream connected while
|
||||
// gamescope re-initializes its headless takeover negotiates a format and reaches `Streaming`
|
||||
// but never receives a buffer — a FRESH connect then delivers within ~0.5 s (observed on
|
||||
// SteamOS: every gamescope bring-up burned the full 10 s on attempt 1, then attempt 2 got
|
||||
// frames instantly → 17 s bring-ups). Healthy compositors deliver the first frame well inside
|
||||
// this window (KWin ~0.3 s), and the genuinely-slow cold start above still gets the patient
|
||||
// 10 s window on every later attempt.
|
||||
const FIRST_ATTEMPT_FRAME_BUDGET: std::time::Duration = std::time::Duration::from_millis(2500);
|
||||
let mut backoff = std::time::Duration::from_millis(500);
|
||||
for attempt in 1..=MAX_ATTEMPTS {
|
||||
for attempt in 1..=max_attempts {
|
||||
// The client is gone (connection closed → `stop`): every further attempt only churns the
|
||||
// box for a session no one is watching — on a Bazzite takeover that means SIGKILLing and
|
||||
// relaunching the box's Steam session once per attempt for minutes (the .181 storm
|
||||
@@ -2435,7 +2763,17 @@ fn build_pipeline_with_retry(
|
||||
attempt - 1
|
||||
);
|
||||
}
|
||||
match build_pipeline(vd, mode, bitrate_kbps, bit_depth, plan, quit, trace) {
|
||||
let first_frame_budget = (attempt == 1).then_some(FIRST_ATTEMPT_FRAME_BUDGET);
|
||||
match build_pipeline(
|
||||
vd,
|
||||
mode,
|
||||
bitrate_kbps,
|
||||
bit_depth,
|
||||
plan,
|
||||
quit,
|
||||
first_frame_budget,
|
||||
trace,
|
||||
) {
|
||||
Ok(pipe) => {
|
||||
if attempt > 1 {
|
||||
tracing::info!(attempt, "pipeline up after retry");
|
||||
@@ -2445,7 +2783,7 @@ fn build_pipeline_with_retry(
|
||||
Err(e) => {
|
||||
let chain = format!("{e:#}");
|
||||
let permanent = is_permanent_build_error(&chain);
|
||||
if permanent || attempt == MAX_ATTEMPTS {
|
||||
if permanent || attempt == max_attempts {
|
||||
let why = if permanent {
|
||||
"permanent"
|
||||
} else {
|
||||
@@ -2457,7 +2795,7 @@ fn build_pipeline_with_retry(
|
||||
}
|
||||
tracing::warn!(
|
||||
attempt,
|
||||
max = MAX_ATTEMPTS,
|
||||
max = max_attempts,
|
||||
backoff_ms = backoff.as_millis() as u64,
|
||||
error = %chain,
|
||||
"pipeline build failed — retrying"
|
||||
@@ -2518,6 +2856,10 @@ fn build_pipeline(
|
||||
bit_depth: u8,
|
||||
plan: crate::session_plan::SessionPlan,
|
||||
quit: &Arc<AtomicBool>,
|
||||
// First-frame wait override (`None` = the backend's default 10 s): the retry loop shortens
|
||||
// its FIRST attempt so a stream stuck in the gamescope takeover race fails over to the
|
||||
// reconnect that fixes it (see FIRST_ATTEMPT_FRAME_BUDGET in `build_pipeline_with_retry`).
|
||||
first_frame_budget: Option<std::time::Duration>,
|
||||
// Transition trace (P0.1): stamps the build's stages (display acquire, capture attach, first
|
||||
// frame, encoder open) into the bring-up/resize timeline. `None` on untraced rebuilds.
|
||||
trace: Option<&crate::bringup::Trace>,
|
||||
@@ -2573,7 +2915,11 @@ fn build_pipeline(
|
||||
t.mark("capture_attached");
|
||||
}
|
||||
capturer.set_active(true);
|
||||
let frame = match capturer.next_frame().context("first frame") {
|
||||
let first = match first_frame_budget {
|
||||
Some(budget) => capturer.next_frame_within(budget),
|
||||
None => capturer.next_frame(),
|
||||
};
|
||||
let frame = match first.context("first frame") {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
// A reused kept display was dead — invalidate it so the next attempt creates fresh (A2).
|
||||
@@ -2599,6 +2945,7 @@ fn build_pipeline(
|
||||
frame.is_cuda(),
|
||||
bit_depth,
|
||||
plan.chroma,
|
||||
plan.cursor_blend,
|
||||
)
|
||||
.context("open video encoder")?;
|
||||
if let Some(t) = trace {
|
||||
|
||||
@@ -116,7 +116,11 @@ fn forward_to_runner(args: &[String]) -> Result<()> {
|
||||
|
||||
/// Resolve how to invoke the runner CLI: the program plus any leading args (the bundled bun needs
|
||||
/// the runner script path passed to it).
|
||||
fn runner_command() -> Result<(std::path::PathBuf, Vec<String>)> {
|
||||
///
|
||||
/// Also the plugin store's executor seam ([`crate::store::jobs`]): a console-triggered install runs
|
||||
/// the *same* package ops through the *same* runner as the CLI, so there is exactly one
|
||||
/// implementation of "install a plugin" on the box (design D4).
|
||||
pub(crate) fn runner_command() -> Result<(std::path::PathBuf, Vec<String>)> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// The installer lays the payload out as {app}\punktfunk-host.exe, {app}\bun\bun.exe and
|
||||
@@ -177,17 +181,163 @@ fn disable() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn status() -> Result<()> {
|
||||
let active = systemctl_output(&["is-active", UNIT]).unwrap_or_else(|| "unknown".into());
|
||||
let enabled = systemctl_output(&["is-enabled", UNIT]).unwrap_or_else(|| "unknown".into());
|
||||
println!("runner: {UNIT}\nenabled: {enabled}\nactive: {active}");
|
||||
if active != "active" {
|
||||
let st = runtime_status();
|
||||
println!(
|
||||
"runner: {}\nstate: {}\nenabled: {}",
|
||||
st.unit,
|
||||
if !st.installed {
|
||||
"not installed"
|
||||
} else if st.running {
|
||||
"running"
|
||||
} else {
|
||||
"stopped"
|
||||
},
|
||||
st.enabled
|
||||
);
|
||||
if let Some(principal) = &st.principal {
|
||||
println!("runs as: {principal}");
|
||||
}
|
||||
if st.installed && !st.running {
|
||||
println!("\nStart it with: punktfunk-host plugins enable");
|
||||
} else if !st.installed {
|
||||
println!("\n{}", st.detail);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- runtime state, shared by the CLI and the plugin store's mgmt API --------------------------
|
||||
|
||||
/// Whether the plugin runner is present, switched on, and up.
|
||||
///
|
||||
/// The store's console surface needs this as data (to offer "enable the runner" before the first
|
||||
/// install, and to explain why a freshly installed plugin isn't running yet), so it lives here
|
||||
/// rather than being formatted straight to stdout like the CLI once did.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct RuntimeStatus {
|
||||
/// Is the runner payload / service unit on this box at all?
|
||||
pub installed: bool,
|
||||
/// Is it configured to start (systemd `enabled`, or a non-`Disabled` scheduled task)?
|
||||
pub enabled: bool,
|
||||
/// Is it up right now?
|
||||
pub running: bool,
|
||||
/// The unit / task name, so operator-facing copy can name the thing to look at.
|
||||
pub unit: &'static str,
|
||||
/// Windows: the account the task runs as (the SYSTEM→LocalService migration is visible here).
|
||||
pub principal: Option<String>,
|
||||
/// One line of human-readable context, mostly for the "not installed" case.
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn runtime_status() -> RuntimeStatus {
|
||||
let enabled_raw = systemctl_output(&["is-enabled", UNIT]);
|
||||
let active = systemctl_output(&["is-active", UNIT]).unwrap_or_default();
|
||||
// `is-enabled` answers `not-found` when the unit file isn't installed at all; the runner
|
||||
// payload being present is the other half of "can we install plugins".
|
||||
let unit_known = enabled_raw.as_deref().is_some_and(|s| s != "not-found");
|
||||
let installed = unit_known || runner_command().is_ok();
|
||||
RuntimeStatus {
|
||||
installed,
|
||||
enabled: enabled_raw.as_deref() == Some("enabled"),
|
||||
running: active == "active",
|
||||
unit: UNIT,
|
||||
principal: None,
|
||||
detail: if installed {
|
||||
String::new()
|
||||
} else {
|
||||
"the plugin runner package isn't installed (Debian/Ubuntu: `sudo apt install \
|
||||
punktfunk-scripting`)"
|
||||
.into()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn runtime_status() -> RuntimeStatus {
|
||||
let out = powershell_output(&format!(
|
||||
"$t = Get-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \
|
||||
if ($null -eq $t) {{ 'missing' }} else {{ \"$($t.State)|$($t.Principal.UserId)\" }}"
|
||||
));
|
||||
match out.as_deref().map(str::trim) {
|
||||
Some("missing") | None => RuntimeStatus {
|
||||
installed: false,
|
||||
enabled: false,
|
||||
running: false,
|
||||
unit: TASK,
|
||||
principal: None,
|
||||
detail: "reinstall punktfunk with the scripting component to get the plugin runner"
|
||||
.into(),
|
||||
},
|
||||
Some(raw) => {
|
||||
let (state, principal) = raw.split_once('|').unwrap_or((raw, ""));
|
||||
RuntimeStatus {
|
||||
installed: true,
|
||||
enabled: !state.eq_ignore_ascii_case("Disabled"),
|
||||
running: state.eq_ignore_ascii_case("Running"),
|
||||
unit: TASK,
|
||||
principal: (!principal.is_empty()).then(|| principal.to_string()),
|
||||
detail: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
pub(crate) fn runtime_status() -> RuntimeStatus {
|
||||
RuntimeStatus {
|
||||
installed: false,
|
||||
enabled: false,
|
||||
running: false,
|
||||
unit: "punktfunk-scripting",
|
||||
principal: None,
|
||||
detail: "the plugin runner is only available on Linux and Windows hosts".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch the runner on or off — the [`enable`]/[`disable`] the CLI runs, exposed for the store's
|
||||
/// `POST /store/runtime`. On Windows this is reached from the SYSTEM service, which already clears
|
||||
/// the elevation bar the CLI has to check for.
|
||||
pub(crate) fn set_runtime_enabled(enabled: bool) -> Result<()> {
|
||||
if enabled {
|
||||
enable()
|
||||
} else {
|
||||
disable()
|
||||
}
|
||||
}
|
||||
|
||||
/// Restart the runner so it rediscovers installed units. Returns `false` (not an error) when it
|
||||
/// isn't running — there is nothing to restart, and the store reports that as "installed, but the
|
||||
/// runner is off" rather than as a failure.
|
||||
///
|
||||
/// Unit discovery happens once at runner startup ([`sdk/src/runner.ts`]), so this restart *is* the
|
||||
/// activation step for a newly installed plugin.
|
||||
pub(crate) fn restart_runtime() -> Result<bool> {
|
||||
let st = runtime_status();
|
||||
if !st.installed || !st.running {
|
||||
return Ok(false);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
run_systemctl(&["restart", UNIT])?;
|
||||
Ok(true)
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Stop then start: `Restart-ScheduledTask` does not exist, and a Start on an already-
|
||||
// running task is a no-op rather than a restart.
|
||||
powershell(&format!(
|
||||
"Stop-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \
|
||||
Start-ScheduledTask -TaskName {TASK} -ErrorAction Stop"
|
||||
))?;
|
||||
Ok(true)
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn run_systemctl(args: &[&str]) -> Result<()> {
|
||||
let status = Command::new("systemctl")
|
||||
@@ -451,33 +601,6 @@ fn icacls_path() -> String {
|
||||
.unwrap_or_else(|_| "icacls".to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn status() -> Result<()> {
|
||||
let out = powershell_output(&format!(
|
||||
"$t = Get-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \
|
||||
if ($null -eq $t) {{ 'missing' }} else {{ \
|
||||
\"$($t.State)|$($t.Principal.UserId)\" }}"
|
||||
));
|
||||
match out.as_deref().map(str::trim) {
|
||||
Some("missing") | None => {
|
||||
println!(
|
||||
"runner: {TASK}\nstate: not installed\n\nReinstall punktfunk with the \
|
||||
scripting component to get the plugin runner."
|
||||
);
|
||||
}
|
||||
Some(state) => {
|
||||
// "State|Principal" — the principal line makes the SYSTEM→LocalService migration
|
||||
// verifiable at a glance (`plugins enable` converges a legacy SYSTEM task).
|
||||
let (state, principal) = state.split_once('|').unwrap_or((state, "?"));
|
||||
println!("runner: {TASK}\nstate: {state}\nruns as: {principal}");
|
||||
if state.eq_ignore_ascii_case("Disabled") {
|
||||
println!("\nEnable it with: punktfunk-host plugins enable");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve powershell by full System32 path rather than PATH — CreateProcess searches the launching
|
||||
/// EXE's own directory first, so a planted `powershell.exe` beside the host binary would otherwise
|
||||
/// run with our privileges (security-review 2026-07-17; matches service.rs / pf_vdisplay).
|
||||
@@ -603,8 +726,3 @@ fn enable() -> Result<()> {
|
||||
fn disable() -> Result<()> {
|
||||
bail!("the plugin runner is only available on Linux and Windows hosts")
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
fn status() -> Result<()> {
|
||||
bail!("the plugin runner is only available on Linux and Windows hosts")
|
||||
}
|
||||
|
||||
@@ -103,6 +103,11 @@ pub struct SessionPlan {
|
||||
/// PyroWave session — applied to EVERY encoder this plan opens (initial + all rebuilds) so
|
||||
/// AUs stay shard-aligned across mode/bitrate/stall rebuilds. `None` for the H.26x codecs.
|
||||
pub wire_chunk: Option<usize>,
|
||||
/// The session may hand the encoder cursor bitmaps to composite (cursor-as-metadata
|
||||
/// captures — every non-gamescope compositor; gamescope embeds the pointer itself).
|
||||
/// Encoders whose fast path cannot blend (the Vulkan EFC RGB-direct source) stay on their
|
||||
/// blending path when this is set, so the pointer never silently vanishes from the stream.
|
||||
pub cursor_blend: bool,
|
||||
}
|
||||
|
||||
impl SessionPlan {
|
||||
@@ -112,6 +117,7 @@ impl SessionPlan {
|
||||
bit_depth: u8,
|
||||
chroma: crate::encode::ChromaFormat,
|
||||
codec: crate::encode::Codec,
|
||||
cursor_blend: bool,
|
||||
) -> Self {
|
||||
SessionPlan {
|
||||
capture: CaptureBackend::resolve(),
|
||||
@@ -122,6 +128,7 @@ impl SessionPlan {
|
||||
chroma,
|
||||
codec,
|
||||
wire_chunk: None,
|
||||
cursor_blend,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +179,13 @@ impl SessionPlan {
|
||||
// Vulkan device; on Linux the capture facade flips the zero-copy policy to the
|
||||
// raw-dmabuf passthrough (see above).
|
||||
pyrowave: self.codec == crate::encode::Codec::PyroWave,
|
||||
// Producer-native NV12 (gamescope) is consumable only by the Linux Vulkan Video
|
||||
// backend — resolved HERE from the plan's codec so the capturer never reaches back
|
||||
// into encode (the same one-way edge as `gpu` above).
|
||||
#[cfg(target_os = "linux")]
|
||||
nv12_native: crate::encode::linux_native_nv12_ok(self.codec),
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
nv12_native: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +147,7 @@ pub fn run(opts: Options) -> Result<()> {
|
||||
first.is_cuda(),
|
||||
8, // spike synthetic harness: 8-bit
|
||||
encode::ChromaFormat::Yuv420, // ...and 4:2:0
|
||||
false, // synthetic frames carry no cursor
|
||||
)
|
||||
.context("open encoder")?;
|
||||
|
||||
|
||||
@@ -0,0 +1,619 @@
|
||||
//! The **plugin store**: discovering and installing plugins from signed catalogs
|
||||
//! (design `plugin-store.md`).
|
||||
//!
|
||||
//! Installing a plugin means running somebody's code with operator privileges, so the store is
|
||||
//! built around one idea: *the index is the verification gate*. A catalog entry pins one exact
|
||||
//! version and that version's tarball hash, and nothing here can express "track the latest
|
||||
//! release". Upstream can publish whatever they like; this host will keep offering the reviewed
|
||||
//! version until a newly reviewed entry lands in a signed index.
|
||||
//!
|
||||
//! That yields three tiers, which the whole surface is organized around:
|
||||
//!
|
||||
//! | tier | where it came from | surfaced? |
|
||||
//! |------|--------------------|-----------|
|
||||
//! | **verified** | the built-in `unom` source's index — unom reviewed this exact tarball | yes, with a badge |
|
||||
//! | **external** | an operator-added source's index — pinned and integrity-checked, curated by somebody else | yes, attributed, no badge |
|
||||
//! | **unverified** | a raw package spec typed into the console's danger dialog | never listed; install only |
|
||||
//!
|
||||
//! This module is the domain half (catalog state, installed-package facts, trust decisions); the
|
||||
//! HTTP surface lives in [`crate::mgmt::store`]. Everything here is **blocking** — callers on the
|
||||
//! async side hand it to `spawn_blocking`.
|
||||
|
||||
pub(crate) mod catalog;
|
||||
pub(crate) mod index;
|
||||
pub(crate) mod jobs;
|
||||
pub(crate) mod manifest;
|
||||
pub(crate) mod sources;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use index::{Advisory, Entry, Index};
|
||||
use sources::Source;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::RwLock;
|
||||
|
||||
/// How long a fetched catalog is considered fresh. Catalogs change when somebody reviews a
|
||||
/// release — hours, not seconds — and a streaming host should not be chatting to the internet on a
|
||||
/// timer for a page nobody has open.
|
||||
const CATALOG_TTL_SECS: u64 = 6 * 60 * 60;
|
||||
|
||||
/// Where plugin packages live: `<config_dir>/plugins` (the same dir the SDK and runner use).
|
||||
pub(crate) fn plugins_dir() -> PathBuf {
|
||||
pf_paths::config_dir().join("plugins")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- installed packages
|
||||
|
||||
/// A plugin package present in the plugins dir.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct InstalledPkg {
|
||||
pub pkg: String,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
/// The packages the operator actually asked for: the `dependencies` of the plugins dir's own
|
||||
/// `package.json`, which `bun add` maintains. `None` only when there is no readable `package.json`
|
||||
/// at all.
|
||||
///
|
||||
/// This is what separates a plugin from a plugin's *library*. `@punktfunk/plugin-kit` is the
|
||||
/// framework every kit-built plugin depends on — it matches the `plugin-*` naming convention
|
||||
/// exactly, lands in `node_modules` as a transitive dependency, and is emphatically not something
|
||||
/// the operator installed or can meaningfully uninstall. The convention alone cannot tell the two
|
||||
/// apart; the top-level dependency list can.
|
||||
///
|
||||
/// A `package.json` with **no** `dependencies` key returns an empty list, not `None`: `bun remove`
|
||||
/// drops the key entirely when the last plugin goes, and orphaned transitive packages can outlive
|
||||
/// it in `node_modules`. Falling back to the naming convention there resurrects a plugin's library
|
||||
/// as an installed plugin the moment you uninstall the last real one (seen on-glass). If bun is
|
||||
/// managing this tree at all, its answer is the answer — including when the answer is "nothing".
|
||||
fn top_level_deps(dir: &Path) -> Option<Vec<String>> {
|
||||
let bytes = std::fs::read(dir.join("package.json")).ok()?;
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
|
||||
Some(match v.get("dependencies").and_then(|d| d.as_object()) {
|
||||
Some(deps) => deps.keys().cloned().collect(),
|
||||
None => Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Enumerate installed plugin packages under `<dir>/node_modules`.
|
||||
///
|
||||
/// Mirrors the runner's own discovery so the store never claims something is installed that the
|
||||
/// runner wouldn't supervise: the unscoped `punktfunk-plugin-*` convention, and **any** scope's
|
||||
/// `plugin-*` (`@punktfunk/plugin-rom-manager`, `@retro-hub/plugin-x`). Scoped-any is what makes a
|
||||
/// third-party catalog entry work at all — a scoped name is required for the registry mapping
|
||||
/// (D8), so discovery must not be limited to the first-party scope.
|
||||
///
|
||||
/// Then narrowed to [`top_level_deps`] when a dependency list exists, so a plugin's *dependencies*
|
||||
/// (notably `@punktfunk/plugin-kit`) aren't reported as installed plugins. A tree with no readable
|
||||
/// `package.json` — hand-assembled, or an older layout — falls back to the convention alone rather
|
||||
/// than reporting nothing.
|
||||
pub(crate) fn installed_packages(dir: &Path) -> Vec<InstalledPkg> {
|
||||
let modules = dir.join("node_modules");
|
||||
let top_level = top_level_deps(dir);
|
||||
let mut out = Vec::new();
|
||||
let version_of = |pkg_dir: &Path| -> Option<String> {
|
||||
let bytes = std::fs::read(pkg_dir.join("package.json")).ok()?;
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
|
||||
v.get("version")?.as_str().map(str::to_string)
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(&modules) else {
|
||||
return out; // nothing installed yet
|
||||
};
|
||||
let mut names: Vec<String> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
names.sort();
|
||||
for name in names {
|
||||
if name.starts_with("punktfunk-plugin-") {
|
||||
let dir = modules.join(&name);
|
||||
out.push(InstalledPkg {
|
||||
version: version_of(&dir),
|
||||
pkg: name,
|
||||
});
|
||||
} else if name.starts_with('@') {
|
||||
let scope_dir = modules.join(&name);
|
||||
let Ok(scoped) = std::fs::read_dir(&scope_dir) else {
|
||||
continue;
|
||||
};
|
||||
let mut inner: Vec<String> = scoped
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
inner.sort();
|
||||
for s in inner {
|
||||
if s.starts_with("plugin-") {
|
||||
let dir = scope_dir.join(&s);
|
||||
out.push(InstalledPkg {
|
||||
pkg: format!("{name}/{s}"),
|
||||
version: version_of(&dir),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(top) = top_level {
|
||||
out.retain(|p| top.iter().any(|d| d == &p.pkg));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Point a package scope at its registry in the plugins dir's `bunfig.toml`.
|
||||
///
|
||||
/// The runner CLI can do this too (`--registry @scope=URL`), but the store must **not** depend on
|
||||
/// that: `runner_command()` resolves whatever scripting package is installed on the box, which can
|
||||
/// predate the host binary (the packaged runner and the host ship separately). An older runner
|
||||
/// treats an unknown flag's *value* as a package name and the install dies with
|
||||
/// "unrecognised dependency format" — found on-glass. Writing the mapping here keeps a
|
||||
/// catalog-driven install working against every runner that has ever shipped.
|
||||
///
|
||||
/// Idempotent and non-destructive, matching `sdk/src/plugins.ts::ensureBunfig`: a scope already
|
||||
/// mapped to this URL is left alone, one mapped elsewhere is rewritten, unrelated content survives.
|
||||
pub(crate) fn ensure_bunfig_scope(dir: &Path, scope: &str, url: &str) -> Result<()> {
|
||||
// The scope and URL both come from a signature-verified, field-validated index entry
|
||||
// (`@`-prefixed, `[a-z0-9._-]`, https), so neither can smuggle a quote or newline into the TOML.
|
||||
if !index::valid_scoped_pkg(&format!("{scope}/x")) || !url.starts_with("https://") {
|
||||
bail!("refusing to map scope `{scope}` to `{url}`");
|
||||
}
|
||||
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
|
||||
let path = dir.join("bunfig.toml");
|
||||
let existing = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
let wanted = format!("\"{scope}\" = \"{url}\"");
|
||||
|
||||
let is_mapping_for_scope = |line: &str| {
|
||||
let t = line.trim_start();
|
||||
t.starts_with(&format!("\"{scope}\"")) || t.starts_with(&format!("{scope} "))
|
||||
};
|
||||
if existing.lines().any(|l| l.trim() == wanted) {
|
||||
return Ok(()); // already correct
|
||||
}
|
||||
let updated = if existing.lines().any(is_mapping_for_scope) {
|
||||
existing
|
||||
.lines()
|
||||
.map(|l| {
|
||||
if is_mapping_for_scope(l) {
|
||||
wanted.clone()
|
||||
} else {
|
||||
l.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
+ "\n"
|
||||
} else if let Some(pos) = existing
|
||||
.lines()
|
||||
.position(|l| l.trim() == "[install.scopes]")
|
||||
{
|
||||
let mut lines: Vec<String> = existing.lines().map(str::to_string).collect();
|
||||
lines.insert(pos + 1, wanted);
|
||||
lines.join("\n") + "\n"
|
||||
} else if existing.trim().is_empty() {
|
||||
format!("[install.scopes]\n{wanted}\n")
|
||||
} else {
|
||||
format!(
|
||||
"{}{}\n[install.scopes]\n{wanted}\n",
|
||||
existing,
|
||||
if existing.ends_with('\n') { "" } else { "\n" }
|
||||
)
|
||||
};
|
||||
std::fs::write(&path, updated).with_context(|| format!("write {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Is `pkg` a name the runner would supervise? Guards the uninstall route so a stray
|
||||
/// `POST /store/uninstall {"pkg": "effect"}` can't rip a shared dependency out of the tree.
|
||||
pub(crate) fn valid_installed_pkg(pkg: &str) -> Result<()> {
|
||||
let plausible = pkg.starts_with("punktfunk-plugin-")
|
||||
|| (index::valid_scoped_pkg(pkg)
|
||||
&& pkg
|
||||
.split_once('/')
|
||||
.is_some_and(|(_, name)| name.starts_with("plugin-")));
|
||||
if !plausible {
|
||||
bail!("`{pkg}` is not a plugin package (`@scope/plugin-*` or `punktfunk-plugin-*`)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The plugin id a package registers under (`@punktfunk/plugin-rom-manager` → `rom-manager`), used
|
||||
/// to line an installed package up with the live lease registry. Best-effort: the authoritative id
|
||||
/// for a catalogued plugin is its index entry.
|
||||
pub(crate) fn plugin_id_for_pkg(pkg: &str) -> Option<String> {
|
||||
let last = pkg.rsplit('/').next()?;
|
||||
let id = last
|
||||
.strip_prefix("punktfunk-plugin-")
|
||||
.or_else(|| last.strip_prefix("plugin-"))?;
|
||||
index::valid_plugin_id(id).then(|| id.to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- catalog state
|
||||
|
||||
/// What we hold for one source: the last good index plus why it might be stale.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct SourceState {
|
||||
pub source: Source,
|
||||
pub index: Option<Index>,
|
||||
/// Unix seconds of the fetch that produced [`Self::index`].
|
||||
pub fetched_at: Option<u64>,
|
||||
/// True when the last refresh attempt failed and we're serving an older copy.
|
||||
pub stale: bool,
|
||||
/// Why the last attempt failed, for the console to show against the source.
|
||||
pub error: Option<String>,
|
||||
pub etag: Option<String>,
|
||||
}
|
||||
|
||||
impl SourceState {
|
||||
fn empty(source: Source) -> Self {
|
||||
Self {
|
||||
source,
|
||||
index: None,
|
||||
fetched_at: None,
|
||||
stale: false,
|
||||
error: None,
|
||||
etag: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_fresh(&self) -> bool {
|
||||
self.index.is_some()
|
||||
&& self
|
||||
.fetched_at
|
||||
.is_some_and(|t| catalog::unix_now().saturating_sub(t) < CATALOG_TTL_SECS)
|
||||
}
|
||||
}
|
||||
|
||||
fn state() -> &'static RwLock<Vec<SourceState>> {
|
||||
static STATE: std::sync::OnceLock<RwLock<Vec<SourceState>>> = std::sync::OnceLock::new();
|
||||
STATE.get_or_init(|| RwLock::new(Vec::new()))
|
||||
}
|
||||
|
||||
/// Reconcile the in-memory state list with the configured sources (added/removed/edited), seeding
|
||||
/// anything new from the on-disk cache so a cold host has a browsable store before its first fetch.
|
||||
fn sync_sources() {
|
||||
let configured = sources::load();
|
||||
let dir = catalog::cache_dir();
|
||||
let mut st = state().write().unwrap_or_else(|e| e.into_inner());
|
||||
st.retain(|s| configured.iter().any(|c| c.name == s.source.name));
|
||||
for c in configured {
|
||||
match st.iter_mut().find(|s| s.source.name == c.name) {
|
||||
// The URL or key may have been edited under a name we already hold — drop what we
|
||||
// cached for the old definition rather than attributing it to the new one.
|
||||
Some(existing) => {
|
||||
if existing.source.url != c.url || existing.source.public_key != c.public_key {
|
||||
*existing = SourceState::empty(c);
|
||||
} else {
|
||||
existing.source = c;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let mut fresh = SourceState::empty(c.clone());
|
||||
if let Some((index, meta)) = catalog::read_cache(&dir, &c.name) {
|
||||
fresh.index = Some(index);
|
||||
fresh.fetched_at = Some(meta.fetched_at);
|
||||
fresh.etag = meta.etag;
|
||||
fresh.stale = true; // from disk — unverified freshness until a fetch says otherwise
|
||||
}
|
||||
st.push(fresh);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every source's catalog, refreshing those past their TTL (or all of them when `force`).
|
||||
///
|
||||
/// **Blocking** — network I/O. The freshness decision is ours (our fetch clock), never the
|
||||
/// document's self-reported `generated` field.
|
||||
pub(crate) fn catalogs(force: bool) -> Vec<SourceState> {
|
||||
sync_sources();
|
||||
let dir = catalog::cache_dir();
|
||||
let todo: Vec<Source> = {
|
||||
let st = state().read().unwrap_or_else(|e| e.into_inner());
|
||||
st.iter()
|
||||
.filter(|s| force || !s.is_fresh())
|
||||
.map(|s| s.source.clone())
|
||||
.collect()
|
||||
};
|
||||
for source in todo {
|
||||
let etag = {
|
||||
let st = state().read().unwrap_or_else(|e| e.into_inner());
|
||||
st.iter()
|
||||
.find(|s| s.source.name == source.name)
|
||||
.and_then(|s| s.etag.clone())
|
||||
};
|
||||
let outcome = catalog::fetch(&source, etag.as_deref());
|
||||
let now = catalog::unix_now();
|
||||
let mut st = state().write().unwrap_or_else(|e| e.into_inner());
|
||||
let Some(slot) = st.iter_mut().find(|s| s.source.name == source.name) else {
|
||||
continue; // removed while we were fetching
|
||||
};
|
||||
match outcome {
|
||||
catalog::Fetched::Fresh { index, etag } => {
|
||||
let count = index.plugins.len();
|
||||
catalog::write_cache(
|
||||
&dir,
|
||||
&source.name,
|
||||
&index,
|
||||
&catalog::CacheMeta {
|
||||
etag: etag.clone(),
|
||||
fetched_at: now,
|
||||
},
|
||||
);
|
||||
slot.index = Some(*index);
|
||||
slot.fetched_at = Some(now);
|
||||
slot.etag = etag;
|
||||
slot.stale = false;
|
||||
slot.error = None;
|
||||
tracing::info!(source = %source.name, entries = count, "plugin catalog refreshed");
|
||||
}
|
||||
catalog::Fetched::NotModified => {
|
||||
slot.fetched_at = Some(now);
|
||||
slot.stale = false;
|
||||
slot.error = None;
|
||||
}
|
||||
catalog::Fetched::Failed(why) => {
|
||||
// Fail soft: keep the last good shelf, say plainly that it's stale.
|
||||
tracing::warn!(source = %source.name, "plugin catalog refresh failed: {why}");
|
||||
slot.stale = true;
|
||||
slot.error = Some(why);
|
||||
}
|
||||
}
|
||||
}
|
||||
state()
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The catalogs we already hold, without touching the network. For paths that must not block on a
|
||||
/// remote host (an install resolving its own entry, an advisory lookup).
|
||||
pub(crate) fn cached_catalogs() -> Vec<SourceState> {
|
||||
sync_sources();
|
||||
state()
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Find a catalog entry by `(source, id)`, plus whether that source is the built-in one — which is
|
||||
/// the single place "verified" is decided (D6).
|
||||
pub(crate) fn find_entry(source_name: &str, id: &str) -> Option<(Entry, bool)> {
|
||||
cached_catalogs().into_iter().find_map(|s| {
|
||||
if s.source.name != source_name {
|
||||
return None;
|
||||
}
|
||||
let entry = s.index?.plugins.into_iter().find(|e| e.id == id)?;
|
||||
Some((entry, s.source.is_official()))
|
||||
})
|
||||
}
|
||||
|
||||
/// The first advisory covering `pkg@version`, across every source we hold. Revocations are
|
||||
/// deliberately not scoped to the source a plugin came from: a package known-bad anywhere is
|
||||
/// known-bad here.
|
||||
pub(crate) fn advisory_for(pkg: &str, version: Option<&str>) -> Option<Advisory> {
|
||||
let version = version?;
|
||||
cached_catalogs().into_iter().find_map(|s| {
|
||||
s.index?
|
||||
.security
|
||||
.into_iter()
|
||||
.find(|a| a.matches(pkg, version))
|
||||
})
|
||||
}
|
||||
|
||||
/// Forget a removed source's cached shelf, so re-adding the name later can't serve stale rows.
|
||||
pub(crate) fn drop_source_cache(name: &str) {
|
||||
catalog::drop_cache(&catalog::cache_dir(), name);
|
||||
let mut st = state().write().unwrap_or_else(|e| e.into_inner());
|
||||
st.retain(|s| s.source.name != name);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn touch_pkg(root: &Path, pkg: &str, version: &str) {
|
||||
let dir = root.join("node_modules").join(pkg);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("package.json"),
|
||||
format!(r#"{{"name":"{pkg}","version":"{version}"}}"#),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scans_both_conventions_and_any_scope() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
touch_pkg(dir.path(), "@punktfunk/plugin-rom-manager", "0.3.0");
|
||||
// A third-party scoped plugin MUST be found: catalog entries are required to be scoped
|
||||
// (D8), so limiting discovery to @punktfunk would make every external source unusable.
|
||||
touch_pkg(dir.path(), "@retro-hub/plugin-x", "1.0.0");
|
||||
touch_pkg(dir.path(), "punktfunk-plugin-legacy", "0.1.0");
|
||||
// Not plugins: a plain dependency and a scoped non-plugin.
|
||||
touch_pkg(dir.path(), "effect", "4.0.0");
|
||||
touch_pkg(dir.path(), "@punktfunk/host", "0.1.2");
|
||||
|
||||
let found = installed_packages(dir.path());
|
||||
let names: Vec<&str> = found.iter().map(|p| p.pkg.as_str()).collect();
|
||||
assert!(
|
||||
names.contains(&"@punktfunk/plugin-rom-manager"),
|
||||
"{names:?}"
|
||||
);
|
||||
assert!(names.contains(&"@retro-hub/plugin-x"), "{names:?}");
|
||||
assert!(names.contains(&"punktfunk-plugin-legacy"), "{names:?}");
|
||||
assert!(!names.contains(&"effect"), "{names:?}");
|
||||
assert!(!names.contains(&"@punktfunk/host"), "{names:?}");
|
||||
assert_eq!(
|
||||
found
|
||||
.iter()
|
||||
.find(|p| p.pkg == "@punktfunk/plugin-rom-manager")
|
||||
.unwrap()
|
||||
.version
|
||||
.as_deref(),
|
||||
Some("0.3.0")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_of_a_missing_dir_is_empty_not_an_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(installed_packages(dir.path()).is_empty());
|
||||
}
|
||||
|
||||
/// A plugin's LIBRARY is not an installed plugin.
|
||||
///
|
||||
/// Regression from a live install: `@punktfunk/plugin-kit` is the framework every kit-built
|
||||
/// plugin depends on. It matches the `plugin-*` convention exactly and lands in `node_modules`
|
||||
/// transitively, so a convention-only scan reported the framework as an installed plugin the
|
||||
/// operator could uninstall. The plugins dir's own `dependencies` is the authority.
|
||||
#[test]
|
||||
fn transitive_plugin_named_dependencies_are_not_installed_plugins() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
touch_pkg(dir.path(), "@punktfunk/plugin-rom-manager", "0.3.1");
|
||||
touch_pkg(dir.path(), "@punktfunk/plugin-kit", "0.1.3"); // a dependency of the above
|
||||
touch_pkg(dir.path(), "@punktfunk/host", "0.1.2");
|
||||
std::fs::write(
|
||||
dir.path().join("package.json"),
|
||||
r#"{"dependencies":{"@punktfunk/plugin-rom-manager":"^0.3.1"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let found = installed_packages(dir.path());
|
||||
assert_eq!(
|
||||
found.iter().map(|p| p.pkg.as_str()).collect::<Vec<_>>(),
|
||||
vec!["@punktfunk/plugin-rom-manager"],
|
||||
"only the top-level install counts"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tree_with_no_package_json_falls_back_to_the_convention() {
|
||||
// Hand-assembled or older layouts must still be discovered, not silently reported empty.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
touch_pkg(dir.path(), "punktfunk-plugin-legacy", "0.1.0");
|
||||
assert_eq!(installed_packages(dir.path()).len(), 1);
|
||||
}
|
||||
|
||||
/// Uninstalling the last plugin must not resurrect its library as an installed plugin.
|
||||
///
|
||||
/// `bun remove` drops the `dependencies` key entirely once it empties, while orphaned
|
||||
/// transitive packages can linger in `node_modules`. Treating "package.json exists but has no
|
||||
/// dependencies" as "no authority, fall back to the naming convention" made
|
||||
/// `@punktfunk/plugin-kit` pop back into the installed list right after the operator removed
|
||||
/// the only real plugin — seen on-glass.
|
||||
#[test]
|
||||
fn an_emptied_dependency_list_means_nothing_is_installed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
touch_pkg(dir.path(), "@punktfunk/plugin-kit", "0.1.3"); // orphan left behind
|
||||
std::fs::write(dir.path().join("package.json"), r#"{"name":"plugins"}"#).unwrap();
|
||||
assert!(installed_packages(dir.path()).is_empty());
|
||||
|
||||
std::fs::write(
|
||||
dir.path().join("package.json"),
|
||||
r#"{"name":"plugins","dependencies":{}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(installed_packages(dir.path()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uninstall_target_must_be_a_plugin_package() {
|
||||
assert!(valid_installed_pkg("@punktfunk/plugin-rom-manager").is_ok());
|
||||
assert!(valid_installed_pkg("@retro-hub/plugin-x").is_ok());
|
||||
assert!(valid_installed_pkg("punktfunk-plugin-legacy").is_ok());
|
||||
// A shared dependency is not removable through the store.
|
||||
assert!(valid_installed_pkg("effect").is_err());
|
||||
assert!(valid_installed_pkg("@punktfunk/host").is_err());
|
||||
assert!(valid_installed_pkg("../../etc").is_err());
|
||||
assert!(valid_installed_pkg("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bunfig_scope_mapping_is_idempotent_and_preserves_other_scopes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let read = || std::fs::read_to_string(dir.path().join("bunfig.toml")).unwrap();
|
||||
|
||||
ensure_bunfig_scope(dir.path(), "@retro-hub", "https://retro.example/npm/").unwrap();
|
||||
assert!(read().contains("[install.scopes]"));
|
||||
assert!(read().contains("\"@retro-hub\" = \"https://retro.example/npm/\""));
|
||||
|
||||
// Idempotent — no duplicate line.
|
||||
ensure_bunfig_scope(dir.path(), "@retro-hub", "https://retro.example/npm/").unwrap();
|
||||
assert_eq!(read().matches("@retro-hub").count(), 1);
|
||||
|
||||
// A second scope joins the same table.
|
||||
ensure_bunfig_scope(
|
||||
dir.path(),
|
||||
"@punktfunk",
|
||||
"https://git.unom.io/api/packages/unom/npm/",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(read().contains("@punktfunk"));
|
||||
assert!(read().contains("@retro-hub"));
|
||||
|
||||
// A changed registry rewrites in place rather than duplicating.
|
||||
ensure_bunfig_scope(dir.path(), "@retro-hub", "https://new.example/npm/").unwrap();
|
||||
assert_eq!(read().matches("@retro-hub").count(), 1);
|
||||
assert!(read().contains("https://new.example/npm/"));
|
||||
assert!(!read().contains("retro.example"));
|
||||
assert!(read().contains("@punktfunk"), "unrelated scope survives");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bunfig_scope_mapping_refuses_junk() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// Only https, only a well-formed scope — both already guaranteed by index validation, held
|
||||
// here as the second line of defence for the one place we format TOML by hand.
|
||||
assert!(ensure_bunfig_scope(dir.path(), "@x", "http://insecure/").is_err());
|
||||
assert!(ensure_bunfig_scope(dir.path(), "no-at-sign", "https://e/").is_err());
|
||||
assert!(ensure_bunfig_scope(dir.path(), "@bad\"quote", "https://e/").is_err());
|
||||
assert!(!dir.path().join("bunfig.toml").exists());
|
||||
}
|
||||
|
||||
/// The name-shape guard is necessary but NOT sufficient — see `mgmt::store::uninstall_plugin`.
|
||||
///
|
||||
/// `@punktfunk/plugin-kit` is a plugin's *framework*, and it satisfies every syntactic rule
|
||||
/// here. Windows on-glass accepted an uninstall of it. The real gate is membership in
|
||||
/// [`installed_packages`], which excludes transitive dependencies; this test pins the fact that
|
||||
/// the shape check alone lets it through, so nobody "simplifies" the handler back.
|
||||
#[test]
|
||||
fn name_shape_alone_does_not_protect_a_plugins_framework() {
|
||||
assert!(
|
||||
valid_installed_pkg("@punktfunk/plugin-kit").is_ok(),
|
||||
"shape check passes plugin-kit — the handler must additionally require that the \
|
||||
package is a top-level install"
|
||||
);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
touch_pkg(dir.path(), "@punktfunk/plugin-rom-manager", "0.3.1");
|
||||
touch_pkg(dir.path(), "@punktfunk/plugin-kit", "0.1.3");
|
||||
std::fs::write(
|
||||
dir.path().join("package.json"),
|
||||
r#"{"dependencies":{"@punktfunk/plugin-rom-manager":"0.3.1"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let installed = installed_packages(dir.path());
|
||||
assert!(installed
|
||||
.iter()
|
||||
.any(|p| p.pkg == "@punktfunk/plugin-rom-manager"));
|
||||
assert!(
|
||||
!installed.iter().any(|p| p.pkg == "@punktfunk/plugin-kit"),
|
||||
"the framework is not an installed plugin, so uninstall must refuse it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_id_derivation() {
|
||||
assert_eq!(
|
||||
plugin_id_for_pkg("@punktfunk/plugin-rom-manager").as_deref(),
|
||||
Some("rom-manager")
|
||||
);
|
||||
assert_eq!(
|
||||
plugin_id_for_pkg("punktfunk-plugin-playnite").as_deref(),
|
||||
Some("playnite")
|
||||
);
|
||||
assert_eq!(plugin_id_for_pkg("@a/plugin-x").as_deref(), Some("x"));
|
||||
assert_eq!(plugin_id_for_pkg("effect"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Catalog **fetch + cache**: pulling a source's signed index over HTTPS and keeping the last good
|
||||
//! copy on disk (design `plugin-store.md` §4.1).
|
||||
//!
|
||||
//! Two properties matter more than freshness here:
|
||||
//!
|
||||
//! - **Fail closed on signatures, fail soft on the network.** A source with a pinned key whose
|
||||
//! document doesn't verify is an *error* — we keep serving the last good copy and say so, and we
|
||||
//! never fall back to "well, unsigned then". But a box that simply can't reach the internet
|
||||
//! (LAN-only, common for a streaming host) keeps a working store: the cached shelf still browses,
|
||||
//! and installs off it still pin and integrity-check, because the pin travelled with the entry.
|
||||
//! - **Bounded everything.** Size cap, timeout, redirect cap, https-only, no credentials ever
|
||||
//! attached. A source URL is operator config, not request-time input, so there is no lever here
|
||||
//! for a browser to aim the host at an arbitrary address.
|
||||
|
||||
use super::index::{Index, MAX_INDEX_BYTES};
|
||||
use super::sources::Source;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Wall-clock budget for one index (or signature) fetch.
|
||||
const FETCH_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// What a refresh attempt produced.
|
||||
pub(crate) enum Fetched {
|
||||
/// A new, verified, parsed document.
|
||||
Fresh {
|
||||
index: Box<Index>,
|
||||
etag: Option<String>,
|
||||
},
|
||||
/// The source says our cached copy is still current (HTTP 304).
|
||||
NotModified,
|
||||
/// Nothing usable arrived. The caller keeps whatever it had and marks the source stale.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Fetch, verify and parse a source's index.
|
||||
///
|
||||
/// Blocking (`ureq`) — callers run this on a blocking thread, never on the async runtime.
|
||||
pub(crate) fn fetch(source: &Source, etag: Option<&str>) -> Fetched {
|
||||
if !source.url.starts_with("https://") {
|
||||
return Fetched::Failed("source url must be https".into());
|
||||
}
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(FETCH_TIMEOUT)
|
||||
// A signed document doesn't need many hops to reach us; a redirect chain is a good way to
|
||||
// waste a host's time.
|
||||
.redirects(3)
|
||||
.user_agent(&format!("punktfunk-host/{}", super::index::host_version()))
|
||||
.build();
|
||||
|
||||
let mut req = agent.get(&source.url);
|
||||
if let Some(tag) = etag {
|
||||
req = req.set("If-None-Match", tag);
|
||||
}
|
||||
let resp = match req.call() {
|
||||
// `ureq` only turns status >= 400 into `Err(Status)`, so a conditional request's 304
|
||||
// arrives here as **Ok with an empty body** — not as an error. Reading it as an error arm
|
||||
// (the intuitive reading) means every refresh after the first one verifies a signature
|
||||
// over zero bytes and "fails"; the catalog then sits permanently stale, serving cache and
|
||||
// never picking up a new entry. Found on-glass; pinned by `ureq_returns_304_as_ok`.
|
||||
Ok(r) if r.status() == 304 => return Fetched::NotModified,
|
||||
Ok(r) => r,
|
||||
Err(ureq::Error::Status(code, _)) => {
|
||||
return Fetched::Failed(format!("index fetch returned HTTP {code}"))
|
||||
}
|
||||
Err(e) => return Fetched::Failed(format!("index fetch failed: {e}")),
|
||||
};
|
||||
let new_etag = resp.header("etag").map(str::to_string);
|
||||
let body = match read_capped(resp) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return Fetched::Failed(e),
|
||||
};
|
||||
|
||||
// Signature FIRST — nothing below may look at a field before this passes (design §6.3).
|
||||
let keys = source.keys();
|
||||
if !keys.is_empty() {
|
||||
let sig = match agent.get(&source.sig_url()).call() {
|
||||
Ok(r) => match read_capped(r) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return Fetched::Failed(format!("signature: {e}")),
|
||||
},
|
||||
Err(e) => {
|
||||
return Fetched::Failed(format!(
|
||||
"this source is signed but its signature could not be fetched: {e}"
|
||||
))
|
||||
}
|
||||
};
|
||||
let sig_text = match String::from_utf8(sig) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return Fetched::Failed("signature file is not text".into()),
|
||||
};
|
||||
if let Err(e) = super::index::verify_signature(&body, &sig_text, &keys) {
|
||||
return Fetched::Failed(format!("signature check failed: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
match Index::parse(&body) {
|
||||
Ok(index) => Fetched::Fresh {
|
||||
index: Box::new(index),
|
||||
etag: new_etag,
|
||||
},
|
||||
Err(e) => Fetched::Failed(format!("{e:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a response body, refusing anything past the cap without buffering it.
|
||||
fn read_capped(resp: ureq::Response) -> Result<Vec<u8>, String> {
|
||||
let mut buf = Vec::new();
|
||||
resp.into_reader()
|
||||
.take((MAX_INDEX_BYTES + 1) as u64)
|
||||
.read_to_end(&mut buf)
|
||||
.map_err(|e| format!("reading the response body failed: {e}"))?;
|
||||
if buf.len() > MAX_INDEX_BYTES {
|
||||
return Err(format!("response exceeds the {MAX_INDEX_BYTES}-byte cap"));
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- disk cache
|
||||
|
||||
/// `<config_dir>/store-cache` — the last good copy of every source's index, so a host that boots
|
||||
/// without a network still has a browsable store.
|
||||
pub(crate) fn cache_dir() -> PathBuf {
|
||||
pf_paths::config_dir().join("store-cache")
|
||||
}
|
||||
|
||||
fn body_path(dir: &Path, source: &str) -> PathBuf {
|
||||
dir.join(format!("{source}.json"))
|
||||
}
|
||||
|
||||
fn meta_path(dir: &Path, source: &str) -> PathBuf {
|
||||
dir.join(format!("{source}.meta.json"))
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Default)]
|
||||
pub(crate) struct CacheMeta {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub etag: Option<String>,
|
||||
/// Unix seconds of the fetch that produced the cached body.
|
||||
#[serde(default)]
|
||||
pub fetched_at: u64,
|
||||
}
|
||||
|
||||
/// The cached index for a source, if one is on disk and still parses. Re-validated on read: a
|
||||
/// cache file is just as untrusted as the wire (it lives in a directory an admin can write).
|
||||
///
|
||||
/// NOTE the signature is **not** re-checked here — it was checked when the bytes were accepted,
|
||||
/// and the cache directory is inside the host's own private config tree.
|
||||
pub(crate) fn read_cache(dir: &Path, source: &str) -> Option<(Index, CacheMeta)> {
|
||||
let body = std::fs::read(body_path(dir, source)).ok()?;
|
||||
let index = Index::parse(&body).ok()?;
|
||||
let meta = std::fs::read(meta_path(dir, source))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<CacheMeta>(&b).ok())
|
||||
.unwrap_or_default();
|
||||
Some((index, meta))
|
||||
}
|
||||
|
||||
/// Persist a freshly verified index as the new last-good copy.
|
||||
pub(crate) fn write_cache(dir: &Path, source: &str, index: &Index, meta: &CacheMeta) {
|
||||
if let Err(e) = pf_paths::create_private_dir(dir) {
|
||||
tracing::warn!("could not create the store cache dir: {e}");
|
||||
return;
|
||||
}
|
||||
let write = |path: PathBuf, bytes: Vec<u8>| {
|
||||
let tmp = path.with_extension("tmp");
|
||||
if std::fs::write(&tmp, bytes).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, &path);
|
||||
}
|
||||
};
|
||||
match serde_json::to_vec_pretty(index) {
|
||||
Ok(b) => write(body_path(dir, source), b),
|
||||
Err(e) => tracing::warn!("could not serialize the catalog cache: {e}"),
|
||||
}
|
||||
if let Ok(b) = serde_json::to_vec_pretty(meta) {
|
||||
write(meta_path(dir, source), b);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop a removed source's cache files so a later re-add can't be served a stale shelf.
|
||||
pub(crate) fn drop_cache(dir: &Path, source: &str) {
|
||||
let _ = std::fs::remove_file(body_path(dir, source));
|
||||
let _ = std::fs::remove_file(meta_path(dir, source));
|
||||
}
|
||||
|
||||
pub(crate) fn unix_now() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_index() -> Index {
|
||||
Index::parse(
|
||||
br#"{"schema":1,"name":"t","plugins":[{"id":"a","pkg":"@p/plugin-a",
|
||||
"registry":"https://r.example/","title":"A","version":"1.0.0",
|
||||
"integrity":"sha512-AAAA"}]}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_round_trips() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let idx = sample_index();
|
||||
let meta = CacheMeta {
|
||||
etag: Some("\"abc\"".into()),
|
||||
fetched_at: 1_700_000_000,
|
||||
};
|
||||
write_cache(dir.path(), "unom", &idx, &meta);
|
||||
|
||||
let (back, back_meta) = read_cache(dir.path(), "unom").expect("cache should be readable");
|
||||
assert_eq!(back.plugins.len(), 1);
|
||||
assert_eq!(back.plugins[0].id, "a");
|
||||
assert_eq!(back_meta.etag.as_deref(), Some("\"abc\""));
|
||||
assert_eq!(back_meta.fetched_at, 1_700_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_corrupt_cache_reads_as_none() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(read_cache(dir.path(), "nope").is_none());
|
||||
std::fs::write(dir.path().join("bad.json"), b"{ not json").unwrap();
|
||||
assert!(read_cache(dir.path(), "bad").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_is_revalidated_on_read() {
|
||||
// A tampered cache file (schema bumped by hand) must not be served.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("x.json"), br#"{"schema":99,"plugins":[]}"#).unwrap();
|
||||
assert!(read_cache(dir.path(), "x").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_cache_removes_both_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_cache(dir.path(), "s", &sample_index(), &CacheMeta::default());
|
||||
assert!(read_cache(dir.path(), "s").is_some());
|
||||
drop_cache(dir.path(), "s");
|
||||
assert!(read_cache(dir.path(), "s").is_none());
|
||||
assert!(!meta_path(dir.path(), "s").exists());
|
||||
}
|
||||
|
||||
/// Pins the HTTP-client assumption that [`fetch`]'s conditional-request handling rests on.
|
||||
///
|
||||
/// `ureq` converts only status >= 400 into `Err(Status)`. A 304 — which is exactly what a
|
||||
/// successful `If-None-Match` produces, i.e. the *steady state* of a healthy catalog — comes
|
||||
/// back as `Ok` with an empty body. Treating it as an error arm compiles, looks right, and
|
||||
/// silently breaks every refresh after the first: the empty body fails the signature check and
|
||||
/// the source sits stale forever. If a `ureq` upgrade ever changes this, fail here rather than
|
||||
/// on someone's host.
|
||||
#[test]
|
||||
fn ureq_returns_304_as_ok() {
|
||||
use std::io::Write as _;
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
if let Ok((mut sock, _)) = listener.accept() {
|
||||
let _ = sock.write_all(b"HTTP/1.1 304 Not Modified\r\nETag: \"x\"\r\n\r\n");
|
||||
let _ = sock.flush();
|
||||
}
|
||||
});
|
||||
|
||||
let resp = ureq::get(&format!("http://{addr}/index.json"))
|
||||
.set("If-None-Match", "\"x\"")
|
||||
.call();
|
||||
let _ = server.join();
|
||||
|
||||
match resp {
|
||||
Ok(r) => assert_eq!(r.status(), 304, "304 must arrive as Ok, and be checked for"),
|
||||
Err(e) => panic!("ureq now reports 304 as an error ({e}) — fetch() must be updated"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_https_source_never_reaches_the_network() {
|
||||
let s = Source {
|
||||
name: "x".into(),
|
||||
url: "http://example.org/i.json".into(),
|
||||
public_key: None,
|
||||
};
|
||||
assert!(matches!(fetch(&s, None), Fetched::Failed(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
//! The plugin-store **index**: the catalog document a source serves, and the ed25519 signature
|
||||
//! that makes it trustworthy (design `plugin-store.md` §3.2).
|
||||
//!
|
||||
//! The index is the verification gate. Each entry pins **one exact version plus that version's
|
||||
//! tarball integrity hash** — so "verified on every release" is a property of the data, not a
|
||||
//! promise about process: upstream can publish a newer version, but nothing in this host will
|
||||
//! offer it until a re-reviewed entry lands in a signed index. There is deliberately no way to
|
||||
//! express "track latest" for a catalogued plugin.
|
||||
//!
|
||||
//! Two rules keep parsing safe:
|
||||
//! 1. **Signature before parse.** [`verify_signature`] runs over the exact bytes; only then does
|
||||
//! anything here look at a field. A source with a pinned key whose signature fails is an
|
||||
//! error, never a fallback to "unsigned".
|
||||
//! 2. **Validate every field, drop what fails.** A malformed *entry* is dropped with a warning
|
||||
//! (one bad row shouldn't blind the operator to the rest of the shelf); a malformed
|
||||
//! *document* — unknown schema, non-JSON, oversized — is rejected whole.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The only index schema this host understands. A future breaking change bumps this and old
|
||||
/// hosts report the source as unreadable rather than guessing at unknown semantics.
|
||||
pub(crate) const SCHEMA: u32 = 1;
|
||||
|
||||
/// Hard cap on a fetched index body. Generous for a text catalog (the seed index is ~2 KB) and
|
||||
/// small enough that a hostile or broken source can't exhaust memory.
|
||||
pub(crate) const MAX_INDEX_BYTES: usize = 5 * 1024 * 1024;
|
||||
|
||||
/// Caps on how much of a (validly signed) index we'll keep. A curated shelf is small; these exist
|
||||
/// so a compromised signing key can't turn the console into an unbounded list.
|
||||
const MAX_PLUGINS: usize = 500;
|
||||
const MAX_ADVISORIES: usize = 200;
|
||||
|
||||
// ---------------------------------------------------------------- the document
|
||||
|
||||
/// A source's catalog document, as served (and signed).
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub(crate) struct Index {
|
||||
/// Document schema — must equal [`SCHEMA`].
|
||||
pub schema: u32,
|
||||
/// Human-readable name of the catalog ("unom official"). Display only.
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
/// RFC-3339 build timestamp. Display only — freshness is decided by our own fetch clock,
|
||||
/// never by a field the source controls.
|
||||
#[serde(default)]
|
||||
pub generated: String,
|
||||
/// The shelf.
|
||||
#[serde(default)]
|
||||
pub plugins: Vec<Entry>,
|
||||
/// Revocations/advisories, checked against **installed** packages as well as catalog rows.
|
||||
#[serde(default)]
|
||||
pub security: Vec<Advisory>,
|
||||
}
|
||||
|
||||
/// One catalogued plugin: exactly one installable version, pinned by integrity hash.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Entry {
|
||||
/// The plugin's `definePlugin` id (`[a-z][a-z0-9-]*`) — also how a running plugin appears in
|
||||
/// the lease registry, so the console can tell "catalogued" from "running".
|
||||
pub id: String,
|
||||
/// npm package name. **Must be scoped** (`@scope/name`): the scope is what maps the package
|
||||
/// to [`Entry::registry`] in bun's `bunfig.toml`, so an unscoped name would be ambiguous
|
||||
/// about where it installs from (design D8).
|
||||
pub pkg: String,
|
||||
/// The npm registry this package installs from. `https://` only.
|
||||
pub registry: String,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Lucide icon name for the console card (`[a-z0-9-]{1,48}`), matched against the console's
|
||||
/// curated set; anything unknown falls back to a generic puzzle piece.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default)]
|
||||
pub author: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub homepage: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub license: Option<String>,
|
||||
/// **The** installable version — exact, never a range. One version per entry: the index is a
|
||||
/// curated shelf, not a version archive (older releases live in the index repo's git history).
|
||||
pub version: String,
|
||||
/// npm integrity of that version's tarball (`sha512-<base64>`). Checked against the registry's
|
||||
/// own advertised integrity before any install runs — a republish under the same version with
|
||||
/// different content cannot pass.
|
||||
pub integrity: String,
|
||||
/// Present when a human reviewed this exact tarball. Only meaningful in the built-in source's
|
||||
/// index; a third-party source can write it, which is why it never grants the "Verified" badge
|
||||
/// (design D6).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub verification: Option<Verification>,
|
||||
/// Minimum host version this plugin supports (semver). Incompatible rows still list, greyed.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub min_host: Option<String>,
|
||||
/// Host platforms this plugin works on (`linux`/`windows`/`macos`). Empty ⇒ all.
|
||||
#[serde(default)]
|
||||
pub platforms: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Verification {
|
||||
/// ISO date the review landed. Display only.
|
||||
pub reviewed_at: String,
|
||||
}
|
||||
|
||||
/// A revocation: "this package at these versions is known-bad". Checked against what's *installed*,
|
||||
/// not just what's listed — the whole point is to reach plugins already on the box.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub(crate) struct Advisory {
|
||||
pub pkg: String,
|
||||
/// A semver requirement (`<0.3.2`, `>=1.0.0, <1.2.0`, `*`). Unparseable ⇒ the advisory is
|
||||
/// dropped rather than applied to everything.
|
||||
pub versions: String,
|
||||
pub reason: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- signature
|
||||
|
||||
/// An ed25519 public key pinned by a source record, spelled `ed25519:<base64 of the 32 raw bytes>`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PublicKey(Vec<u8>);
|
||||
|
||||
impl PublicKey {
|
||||
pub(crate) fn parse(s: &str) -> Result<Self> {
|
||||
use base64::Engine as _;
|
||||
let b64 = s
|
||||
.strip_prefix("ed25519:")
|
||||
.context("public key must be spelled `ed25519:<base64>`")?;
|
||||
let raw = base64::engine::general_purpose::STANDARD
|
||||
.decode(b64.trim())
|
||||
.context("public key is not valid base64")?;
|
||||
if raw.len() != 32 {
|
||||
bail!("ed25519 public key must be 32 bytes, got {}", raw.len());
|
||||
}
|
||||
Ok(Self(raw))
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify a detached ed25519 signature over the **exact** index bytes against any of the pinned
|
||||
/// keys (two slots, so a key rotation is "sign with the new one, ship a host that trusts both,
|
||||
/// retire the old" rather than a flag day).
|
||||
///
|
||||
/// `sig_text` is the `.sig` file's contents: base64, whitespace-tolerant.
|
||||
pub(crate) fn verify_signature(bytes: &[u8], sig_text: &str, keys: &[PublicKey]) -> Result<()> {
|
||||
use base64::Engine as _;
|
||||
if keys.is_empty() {
|
||||
bail!("no public key pinned for this source");
|
||||
}
|
||||
let sig = base64::engine::general_purpose::STANDARD
|
||||
.decode(sig_text.trim())
|
||||
.context("signature file is not valid base64")?;
|
||||
for key in keys {
|
||||
let pk = ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, &key.0);
|
||||
if pk.verify(bytes, &sig).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
bail!("index signature does not verify against any pinned key")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- parse + validate
|
||||
|
||||
impl Index {
|
||||
/// Parse and validate an index document. Rejects the whole document on a structural problem;
|
||||
/// drops individual entries/advisories that fail validation (with a warning) so one bad row
|
||||
/// can't hide the rest of the catalog.
|
||||
pub(crate) fn parse(bytes: &[u8]) -> Result<Index> {
|
||||
if bytes.len() > MAX_INDEX_BYTES {
|
||||
bail!("index is larger than the {MAX_INDEX_BYTES}-byte cap");
|
||||
}
|
||||
let mut idx: Index = serde_json::from_slice(bytes).context("index is not valid JSON")?;
|
||||
if idx.schema != SCHEMA {
|
||||
bail!(
|
||||
"unsupported index schema {} (this host understands {SCHEMA})",
|
||||
idx.schema
|
||||
);
|
||||
}
|
||||
idx.name = sanitize(&idx.name, 64);
|
||||
idx.generated = sanitize(&idx.generated, 40);
|
||||
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
idx.plugins.truncate(MAX_PLUGINS);
|
||||
idx.plugins.retain_mut(|e| match e.validate() {
|
||||
Err(why) => {
|
||||
tracing::warn!(pkg = %e.pkg, "dropping catalog entry: {why}");
|
||||
false
|
||||
}
|
||||
Ok(()) => {
|
||||
// Duplicate ids would make "install this one" ambiguous; first wins.
|
||||
if seen.iter().any(|s| s == &e.id) {
|
||||
tracing::warn!(id = %e.id, "dropping duplicate catalog entry");
|
||||
false
|
||||
} else {
|
||||
seen.push(e.id.clone());
|
||||
true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
idx.security.truncate(MAX_ADVISORIES);
|
||||
idx.security.retain_mut(|a| match a.validate() {
|
||||
Err(why) => {
|
||||
tracing::warn!(pkg = %a.pkg, "dropping advisory: {why}");
|
||||
false
|
||||
}
|
||||
Ok(()) => true,
|
||||
});
|
||||
Ok(idx)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
fn validate(&mut self) -> Result<()> {
|
||||
if !valid_plugin_id(&self.id) {
|
||||
bail!("id must be kebab-case `[a-z][a-z0-9-]*`, ≤64");
|
||||
}
|
||||
if !valid_scoped_pkg(&self.pkg) {
|
||||
bail!("pkg must be a scoped npm name (`@scope/name`)");
|
||||
}
|
||||
if !is_https(&self.registry) {
|
||||
bail!("registry must be an https:// URL");
|
||||
}
|
||||
self.title = sanitize(&self.title, 64);
|
||||
if self.title.is_empty() {
|
||||
bail!("title must not be empty");
|
||||
}
|
||||
self.description = sanitize(&self.description, 280);
|
||||
self.author = sanitize(&self.author, 64);
|
||||
self.version = sanitize(&self.version, 32);
|
||||
if semver::Version::parse(&self.version).is_err() {
|
||||
bail!("version must be exact semver (`1.2.3`), not a range");
|
||||
}
|
||||
if !valid_integrity(&self.integrity) {
|
||||
bail!("integrity must look like `sha512-<base64>`");
|
||||
}
|
||||
if let Some(icon) = &self.icon {
|
||||
if !valid_icon(icon) {
|
||||
self.icon = None; // cosmetic — drop it rather than the whole entry
|
||||
}
|
||||
}
|
||||
if let Some(h) = &self.homepage {
|
||||
if !is_https(h) || h.len() > 200 {
|
||||
self.homepage = None;
|
||||
}
|
||||
}
|
||||
if let Some(l) = &self.license {
|
||||
self.license = Some(sanitize(l, 64)).filter(|s| !s.is_empty());
|
||||
}
|
||||
if let Some(v) = &self.verification {
|
||||
if v.reviewed_at.len() > 32 {
|
||||
self.verification = None;
|
||||
}
|
||||
}
|
||||
if let Some(m) = &self.min_host {
|
||||
if semver::Version::parse(m).is_err() {
|
||||
self.min_host = None; // unusable constraint ⇒ no constraint
|
||||
}
|
||||
}
|
||||
self.platforms
|
||||
.retain(|p| matches!(p.as_str(), "linux" | "windows" | "macos"));
|
||||
self.platforms.truncate(4);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Is this entry installable on the running host? Returns the operator-facing reason when not.
|
||||
pub(crate) fn incompatible_reason(&self) -> Option<String> {
|
||||
if !self.platforms.is_empty() && !self.platforms.iter().any(|p| p == HOST_PLATFORM) {
|
||||
return Some(format!("requires {}", self.platforms.join(" or ")));
|
||||
}
|
||||
if let Some(min) = &self.min_host {
|
||||
let (Ok(min), Ok(host)) = (
|
||||
semver::Version::parse(min),
|
||||
semver::Version::parse(host_version()),
|
||||
) else {
|
||||
return None;
|
||||
};
|
||||
if host < min {
|
||||
return Some(format!("needs punktfunk {min} or newer"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Advisory {
|
||||
fn validate(&mut self) -> Result<()> {
|
||||
if self.pkg.trim().is_empty() || self.pkg.len() > 214 {
|
||||
bail!("advisory pkg is empty or too long");
|
||||
}
|
||||
semver::VersionReq::parse(&self.versions)
|
||||
.context("advisory `versions` is not a semver requirement")?;
|
||||
self.reason = sanitize(&self.reason, 280);
|
||||
if let Some(u) = &self.url {
|
||||
if !is_https(u) || u.len() > 200 {
|
||||
self.url = None;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Does this advisory cover `pkg@version`?
|
||||
pub(crate) fn matches(&self, pkg: &str, version: &str) -> bool {
|
||||
if self.pkg != pkg {
|
||||
return false;
|
||||
}
|
||||
let (Ok(req), Ok(v)) = (
|
||||
semver::VersionReq::parse(&self.versions),
|
||||
semver::Version::parse(version),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
req.matches(&v)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- small helpers
|
||||
|
||||
/// This host's platform token, as used in [`Entry::platforms`].
|
||||
pub(crate) const HOST_PLATFORM: &str = if cfg!(target_os = "windows") {
|
||||
"windows"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"macos"
|
||||
} else {
|
||||
"linux"
|
||||
};
|
||||
|
||||
/// The running host's version — the left-hand side of every `minHost` comparison.
|
||||
pub(crate) fn host_version() -> &'static str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
|
||||
/// Strip control characters (a catalog string ends up in a log line and in the console UI) and
|
||||
/// clamp to `max` characters.
|
||||
fn sanitize(s: &str, max: usize) -> String {
|
||||
s.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.take(max)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn valid_plugin_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id.as_bytes()[0].is_ascii_lowercase()
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||
}
|
||||
|
||||
/// `@scope/name` with npm's safe alphabet. Deliberately stricter than npm: no URL-ish characters
|
||||
/// can survive into a `bun add` argument or a `bunfig.toml` scope key.
|
||||
pub(crate) fn valid_scoped_pkg(pkg: &str) -> bool {
|
||||
let Some(rest) = pkg.strip_prefix('@') else {
|
||||
return false;
|
||||
};
|
||||
if pkg.len() > 214 {
|
||||
return false;
|
||||
}
|
||||
let Some((scope, name)) = rest.split_once('/') else {
|
||||
return false;
|
||||
};
|
||||
let ok = |s: &str| {
|
||||
!s.is_empty()
|
||||
&& s.bytes().all(|b| {
|
||||
b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_' | b'.')
|
||||
})
|
||||
};
|
||||
ok(scope) && ok(name)
|
||||
}
|
||||
|
||||
/// The `@scope` half of a scoped package name — the key `bunfig.toml` maps to a registry.
|
||||
pub(crate) fn scope_of(pkg: &str) -> Option<String> {
|
||||
let rest = pkg.strip_prefix('@')?;
|
||||
let (scope, _) = rest.split_once('/')?;
|
||||
Some(format!("@{scope}"))
|
||||
}
|
||||
|
||||
fn valid_icon(icon: &str) -> bool {
|
||||
(1..=48).contains(&icon.len())
|
||||
&& icon
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||
}
|
||||
|
||||
/// npm's Subresource-Integrity spelling: `<alg>-<base64>`.
|
||||
fn valid_integrity(s: &str) -> bool {
|
||||
if s.len() > 200 {
|
||||
return false;
|
||||
}
|
||||
let Some((alg, b64)) = s.split_once('-') else {
|
||||
return false;
|
||||
};
|
||||
matches!(alg, "sha512" | "sha384" | "sha256" | "sha1")
|
||||
&& !b64.is_empty()
|
||||
&& b64
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'='))
|
||||
}
|
||||
|
||||
fn is_https(url: &str) -> bool {
|
||||
url.starts_with("https://") && url.len() > "https://".len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn doc(entry_json: &str) -> Vec<u8> {
|
||||
format!(r#"{{"schema":1,"name":"t","plugins":[{entry_json}]}}"#).into_bytes()
|
||||
}
|
||||
|
||||
const GOOD: &str = r#"{"id":"rom-manager","pkg":"@punktfunk/plugin-rom-manager",
|
||||
"registry":"https://git.unom.io/api/packages/unom/npm/","title":"ROM Manager",
|
||||
"description":"d","author":"unom","version":"0.2.1","integrity":"sha512-AAAA",
|
||||
"verification":{"reviewedAt":"2026-07-19"},"platforms":["linux","windows"]}"#;
|
||||
|
||||
#[test]
|
||||
fn parses_a_good_entry() {
|
||||
let idx = Index::parse(&doc(GOOD)).unwrap();
|
||||
assert_eq!(idx.plugins.len(), 1);
|
||||
let e = &idx.plugins[0];
|
||||
assert_eq!(e.pkg, "@punktfunk/plugin-rom-manager");
|
||||
assert_eq!(e.version, "0.2.1");
|
||||
assert!(e.verification.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_schema_and_bad_json() {
|
||||
assert!(Index::parse(br#"{"schema":2,"plugins":[]}"#).is_err());
|
||||
assert!(Index::parse(b"not json").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_invalid_entries_but_keeps_the_rest() {
|
||||
let bad_unscoped = GOOD.replace("@punktfunk/plugin-rom-manager", "punktfunk-plugin-x");
|
||||
let body = format!(r#"{{"schema":1,"plugins":[{bad_unscoped},{GOOD}]}}"#);
|
||||
let idx = Index::parse(body.as_bytes()).unwrap();
|
||||
assert_eq!(idx.plugins.len(), 1, "unscoped pkg must be dropped (D8)");
|
||||
assert_eq!(idx.plugins[0].id, "rom-manager");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_version_ranges_and_http_registries() {
|
||||
assert!(Index::parse(&doc(
|
||||
&GOOD.replace(r#""version":"0.2.1""#, r#""version":"^0.2.1""#)
|
||||
))
|
||||
.unwrap()
|
||||
.plugins
|
||||
.is_empty());
|
||||
assert!(Index::parse(&doc(
|
||||
&GOOD.replace("https://git.unom.io", "http://git.unom.io")
|
||||
))
|
||||
.unwrap()
|
||||
.plugins
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_duplicate_ids() {
|
||||
let body = format!(r#"{{"schema":1,"plugins":[{GOOD},{GOOD}]}}"#);
|
||||
assert_eq!(Index::parse(body.as_bytes()).unwrap().plugins.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitizes_control_characters_in_display_fields() {
|
||||
let e = GOOD.replace("ROM Manager", "RO\\u0007M");
|
||||
let idx = Index::parse(&doc(&e)).unwrap();
|
||||
assert_eq!(idx.plugins[0].title, "ROM");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advisory_matching_is_semver_ranged() {
|
||||
let mut a = Advisory {
|
||||
pkg: "@x/y".into(),
|
||||
versions: "<0.3.2".into(),
|
||||
reason: "bad".into(),
|
||||
url: None,
|
||||
};
|
||||
a.validate().unwrap();
|
||||
assert!(a.matches("@x/y", "0.3.1"));
|
||||
assert!(!a.matches("@x/y", "0.3.2"));
|
||||
assert!(!a.matches("@other/z", "0.3.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advisory_with_unparseable_range_is_dropped() {
|
||||
let body = r#"{"schema":1,"plugins":[],"security":[
|
||||
{"pkg":"@x/y","versions":"not a range","reason":"r"}]}"#;
|
||||
assert!(Index::parse(body.as_bytes()).unwrap().security.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_extraction() {
|
||||
assert_eq!(scope_of("@punktfunk/plugin-x").unwrap(), "@punktfunk");
|
||||
assert_eq!(scope_of("@a/b").unwrap(), "@a");
|
||||
assert!(scope_of("punktfunk-plugin-x").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrity_shape() {
|
||||
assert!(valid_integrity("sha512-abcABC123+/="));
|
||||
assert!(!valid_integrity("md5-abc"));
|
||||
assert!(!valid_integrity("sha512-"));
|
||||
assert!(!valid_integrity("sha512"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_verifies_only_over_exact_bytes_and_pinned_keys() {
|
||||
use ring::signature::KeyPair as _;
|
||||
let rng = ring::rand::SystemRandom::new();
|
||||
let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
|
||||
let kp = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
|
||||
let key = {
|
||||
use base64::Engine as _;
|
||||
PublicKey::parse(&format!(
|
||||
"ed25519:{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(kp.public_key().as_ref())
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
let body = br#"{"schema":1,"plugins":[]}"#;
|
||||
let sig = {
|
||||
use base64::Engine as _;
|
||||
base64::engine::general_purpose::STANDARD.encode(kp.sign(body).as_ref())
|
||||
};
|
||||
|
||||
assert!(verify_signature(body, &sig, std::slice::from_ref(&key)).is_ok());
|
||||
// whitespace in the .sig file is tolerated
|
||||
assert!(verify_signature(body, &format!("{sig}\n"), std::slice::from_ref(&key)).is_ok());
|
||||
// one byte of tampering fails
|
||||
assert!(verify_signature(
|
||||
br#"{"schema":1,"plugins":[ ]}"#,
|
||||
&sig,
|
||||
std::slice::from_ref(&key)
|
||||
)
|
||||
.is_err());
|
||||
// a different key fails
|
||||
let other = ring::signature::Ed25519KeyPair::from_pkcs8(
|
||||
ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
|
||||
.unwrap()
|
||||
.as_ref(),
|
||||
)
|
||||
.unwrap();
|
||||
let other_key = {
|
||||
use base64::Engine as _;
|
||||
PublicKey::parse(&format!(
|
||||
"ed25519:{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(other.public_key().as_ref())
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
assert!(verify_signature(body, &sig, &[other_key]).is_err());
|
||||
// no pinned key at all fails closed
|
||||
assert!(verify_signature(body, &sig, &[]).is_err());
|
||||
}
|
||||
|
||||
/// The real published index must parse here, field for field.
|
||||
///
|
||||
/// This fixture is a snapshot of `unom/punktfunk-plugin-index`'s `v1/index.json`. The index
|
||||
/// repo's validator reimplements this module's rules in TypeScript (it has to — it gates PRs
|
||||
/// before anything is signed), and two hand-written parsers of the same grammar drift. This
|
||||
/// test is the seam that catches the drift from our side: if a document the publisher accepts
|
||||
/// stops being one we accept, an entry silently disappears from every operator's store.
|
||||
#[test]
|
||||
fn the_published_seed_index_parses() {
|
||||
let bytes = include_bytes!("testdata/seed-index.json");
|
||||
let idx = Index::parse(bytes).expect("the published index must parse");
|
||||
assert_eq!(idx.plugins.len(), 2, "no entry may be silently dropped");
|
||||
|
||||
let rom = idx
|
||||
.plugins
|
||||
.iter()
|
||||
.find(|e| e.id == "rom-manager")
|
||||
.expect("rom-manager entry");
|
||||
assert_eq!(rom.pkg, "@punktfunk/plugin-rom-manager");
|
||||
assert!(rom.integrity.starts_with("sha512-"));
|
||||
assert!(
|
||||
semver::Version::parse(&rom.version).is_ok(),
|
||||
"exact version"
|
||||
);
|
||||
assert_eq!(
|
||||
rom.verification.as_ref().map(|v| v.reviewed_at.as_str()),
|
||||
Some("2026-07-20"),
|
||||
"camelCase `reviewedAt` must decode"
|
||||
);
|
||||
assert_eq!(
|
||||
rom.min_host.as_deref(),
|
||||
Some("0.15.0"),
|
||||
"camelCase `minHost`"
|
||||
);
|
||||
assert_eq!(scope_of(&rom.pkg).unwrap(), "@punktfunk");
|
||||
|
||||
// A windows-only entry is listed everywhere but only *installable* where it can run.
|
||||
let playnite = idx
|
||||
.plugins
|
||||
.iter()
|
||||
.find(|e| e.id == "playnite")
|
||||
.expect("playnite entry");
|
||||
assert_eq!(playnite.platforms, vec!["windows"]);
|
||||
if HOST_PLATFORM == "windows" {
|
||||
assert!(playnite.incompatible_reason().is_none());
|
||||
} else {
|
||||
assert!(playnite.incompatible_reason().is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_key_parsing_rejects_junk() {
|
||||
assert!(PublicKey::parse("nope").is_err());
|
||||
assert!(PublicKey::parse("ed25519:!!!").is_err());
|
||||
assert!(PublicKey::parse("ed25519:AAAA").is_err()); // wrong length
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,692 @@
|
||||
//! Install / uninstall **jobs** (design `plugin-store.md` §4.3).
|
||||
//!
|
||||
//! A package operation takes tens of seconds, so the API hands back a job id immediately (202) and
|
||||
//! the console polls. One at a time: `bun add` and `bun remove` share a lockfile and a
|
||||
//! `node_modules` tree, so a second concurrent op is a corruption bug waiting to happen — a
|
||||
//! request that arrives while a job runs gets 409, not a queue.
|
||||
//!
|
||||
//! The pipeline, and why each step is there:
|
||||
//!
|
||||
//! ```text
|
||||
//! resolve → what exactly are we installing (catalog entry ⇒ pkg@version+integrity, or a raw spec)
|
||||
//! verify → the registry's advertised integrity for that version MUST equal the index pin
|
||||
//! install → spawn the runner CLI (`bun add`), streaming its output into the job log
|
||||
//! check → the version on disk is the version we pinned, else roll back
|
||||
//! record → provenance manifest, so the tier stays visible forever
|
||||
//! restart → the runner rediscovers units only at startup, so activation is a restart
|
||||
//! ```
|
||||
//!
|
||||
//! **verify** is the step that makes "verified" mean something. A reviewed entry pins a tarball
|
||||
//! hash; if the registry now advertises a different hash for that same version, someone republished
|
||||
//! it after review and the install is refused before any code is fetched, let alone run. Tier-3
|
||||
//! (raw spec) installs skip it — there is no pin to check against, and that asymmetry *is* the
|
||||
//! tier.
|
||||
|
||||
use super::index::{scope_of, Entry};
|
||||
use super::manifest::{self, Record, Tier};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How long a package op may run before we kill it. `bun add` over a cold cache on a slow link is
|
||||
/// the worst case; five minutes is far past it.
|
||||
const JOB_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
/// How many finished jobs we keep for the console to read back.
|
||||
const JOB_HISTORY: usize = 20;
|
||||
|
||||
/// Lines of runner output retained per job (the console shows a tail).
|
||||
const LOG_LINES: usize = 200;
|
||||
|
||||
// ---------------------------------------------------------------- job records
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(crate) enum State {
|
||||
Running,
|
||||
Done,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// A job as the console sees it. Field names are snake_case like the rest of the management API
|
||||
/// (the *file* formats — index, sources, manifest — follow npm's camelCase instead).
|
||||
#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
|
||||
pub(crate) struct Job {
|
||||
pub id: String,
|
||||
/// `install` or `uninstall`.
|
||||
pub kind: String,
|
||||
/// What the operator asked for — a package name, or the raw spec they typed.
|
||||
pub target: String,
|
||||
pub state: State,
|
||||
/// Coarse step name, for a progress line the operator can read.
|
||||
pub phase: String,
|
||||
/// Tail of the runner's combined stdout/stderr.
|
||||
pub log: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
pub started_at: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub finished_at: Option<u64>,
|
||||
}
|
||||
|
||||
struct Jobs {
|
||||
jobs: VecDeque<Job>,
|
||||
counter: u64,
|
||||
}
|
||||
|
||||
fn jobs() -> &'static Mutex<Jobs> {
|
||||
static JOBS: std::sync::OnceLock<Mutex<Jobs>> = std::sync::OnceLock::new();
|
||||
JOBS.get_or_init(|| {
|
||||
Mutex::new(Jobs {
|
||||
jobs: VecDeque::new(),
|
||||
counter: 0,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn lock() -> std::sync::MutexGuard<'static, Jobs> {
|
||||
jobs().lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Start tracking a job, refusing if one is already in flight (single-flight, §4.3).
|
||||
fn begin(kind: &str, target: &str) -> Result<String> {
|
||||
let mut g = lock();
|
||||
if let Some(active) = g.jobs.iter().find(|j| j.state == State::Running) {
|
||||
bail!(
|
||||
"another plugin operation is already running ({} {})",
|
||||
active.kind,
|
||||
active.target
|
||||
);
|
||||
}
|
||||
g.counter += 1;
|
||||
let id = format!("job-{}-{}", super::catalog::unix_now(), g.counter);
|
||||
let job = Job {
|
||||
id: id.clone(),
|
||||
kind: kind.to_string(),
|
||||
target: target.to_string(),
|
||||
state: State::Running,
|
||||
phase: "queued".into(),
|
||||
log: Vec::new(),
|
||||
error: None,
|
||||
started_at: super::catalog::unix_now(),
|
||||
finished_at: None,
|
||||
};
|
||||
g.jobs.push_back(job);
|
||||
while g.jobs.len() > JOB_HISTORY {
|
||||
g.jobs.pop_front();
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn update(id: &str, f: impl FnOnce(&mut Job)) {
|
||||
let mut g = lock();
|
||||
if let Some(job) = g.jobs.iter_mut().find(|j| j.id == id) {
|
||||
f(job);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_phase(id: &str, phase: &str) {
|
||||
tracing::info!(job = %id, phase, "plugin store job");
|
||||
update(id, |j| j.phase = phase.to_string());
|
||||
}
|
||||
|
||||
fn log_line(id: &str, line: String) {
|
||||
update(id, |j| {
|
||||
if j.log.len() >= LOG_LINES {
|
||||
j.log.remove(0);
|
||||
}
|
||||
j.log.push(line);
|
||||
});
|
||||
}
|
||||
|
||||
fn finish(id: &str, result: Result<()>) {
|
||||
update(id, |j| {
|
||||
j.finished_at = Some(super::catalog::unix_now());
|
||||
match &result {
|
||||
Ok(()) => {
|
||||
j.state = State::Done;
|
||||
j.phase = "done".into();
|
||||
}
|
||||
Err(e) => {
|
||||
j.state = State::Failed;
|
||||
j.error = Some(format!("{e:#}"));
|
||||
}
|
||||
}
|
||||
});
|
||||
match result {
|
||||
Ok(()) => tracing::info!(job = %id, "plugin store job finished"),
|
||||
Err(e) => tracing::warn!(job = %id, "plugin store job failed: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// One job by id.
|
||||
pub(crate) fn get(id: &str) -> Option<Job> {
|
||||
lock().jobs.iter().find(|j| j.id == id).cloned()
|
||||
}
|
||||
|
||||
/// Recent jobs, newest last.
|
||||
pub(crate) fn list() -> Vec<Job> {
|
||||
lock().jobs.iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// Is a job in flight? (The console disables install buttons on this.)
|
||||
pub(crate) fn busy() -> bool {
|
||||
lock().jobs.iter().any(|j| j.state == State::Running)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- install plans
|
||||
|
||||
/// A fully resolved install: everything the executor needs, with the trust decision already made.
|
||||
pub(crate) struct Plan {
|
||||
/// Package name without a version.
|
||||
pub pkg: Option<String>,
|
||||
/// The argument handed to `bun add` — `pkg@version` for a catalogued entry, the raw spec
|
||||
/// otherwise.
|
||||
pub spec: String,
|
||||
pub version: Option<String>,
|
||||
/// `(scope, registry_url)` to map in the plugins dir's `bunfig.toml`.
|
||||
pub registry: Option<(String, String)>,
|
||||
pub integrity: Option<String>,
|
||||
pub tier: Tier,
|
||||
pub source: Option<String>,
|
||||
pub entry_id: Option<String>,
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
/// From a catalog entry: exact version, pinned integrity, scope→registry mapping.
|
||||
pub(crate) fn from_entry(entry: &Entry, source: &str, verified: bool) -> Result<Plan> {
|
||||
let scope = scope_of(&entry.pkg).context("catalog entry package must be scoped")?;
|
||||
Ok(Plan {
|
||||
pkg: Some(entry.pkg.clone()),
|
||||
spec: format!("{}@{}", entry.pkg, entry.version),
|
||||
version: Some(entry.version.clone()),
|
||||
registry: Some((scope, entry.registry.clone())),
|
||||
integrity: Some(entry.integrity.clone()),
|
||||
tier: if verified {
|
||||
Tier::Verified
|
||||
} else {
|
||||
Tier::External
|
||||
},
|
||||
source: Some(source.to_string()),
|
||||
entry_id: Some(entry.id.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
/// From a raw package spec typed into the danger dialog. No pin, no review, no shelf.
|
||||
pub(crate) fn from_spec(spec: &str) -> Result<Plan> {
|
||||
let spec = validate_spec(spec)?;
|
||||
Ok(Plan {
|
||||
pkg: parse_spec_pkg(&spec),
|
||||
spec,
|
||||
version: None,
|
||||
registry: None,
|
||||
integrity: None,
|
||||
tier: Tier::Unverified,
|
||||
source: None,
|
||||
entry_id: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept only specs we're willing to hand to `bun add`.
|
||||
///
|
||||
/// This is not shell quoting (we always exec with an argument vector, never a shell) — it is about
|
||||
/// what the *package manager* will do with the string. Two real hazards: a leading `-` turns the
|
||||
/// operand into a flag, and `file:` specs would let a console session install code from anywhere on
|
||||
/// the host's filesystem, which is a different (and larger) capability than "install a package".
|
||||
/// Local paths remain available through the CLI, which is where local development belongs.
|
||||
fn validate_spec(spec: &str) -> Result<String> {
|
||||
let s = spec.trim();
|
||||
if s.is_empty() {
|
||||
bail!("empty package spec");
|
||||
}
|
||||
if s.len() > 400 {
|
||||
bail!("package spec is too long");
|
||||
}
|
||||
if s.starts_with('-') {
|
||||
bail!("a package spec cannot start with '-'");
|
||||
}
|
||||
if s.chars().any(|c| c.is_whitespace() || c.is_control()) {
|
||||
bail!("a package spec cannot contain whitespace or control characters");
|
||||
}
|
||||
let lower = s.to_ascii_lowercase();
|
||||
for bad in ["file:", "link:", "portal:"] {
|
||||
if lower.starts_with(bad) {
|
||||
bail!("`{bad}` specs are not installable from the console — use the `punktfunk-host plugins add` CLI");
|
||||
}
|
||||
}
|
||||
// URL-ish specs: only https and git+https. (http:// and git:// are unauthenticated transports.)
|
||||
if lower.contains("://")
|
||||
&& !(lower.starts_with("https://") || lower.starts_with("git+https://"))
|
||||
{
|
||||
bail!("only https:// and git+https:// URLs are accepted");
|
||||
}
|
||||
Ok(s.to_string())
|
||||
}
|
||||
|
||||
/// Best-effort package name from an npm-style spec (`@scope/name`, `@scope/name@1.2.3`, `name@1`).
|
||||
/// `None` for URL/git specs — those are identified after the fact by diffing `node_modules`.
|
||||
fn parse_spec_pkg(spec: &str) -> Option<String> {
|
||||
if spec.contains("://") {
|
||||
return None;
|
||||
}
|
||||
if let Some(rest) = spec.strip_prefix('@') {
|
||||
// Scoped: the version separator is the '@' AFTER the scope's slash.
|
||||
let (scope_and_name, _) = match rest.split_once('/') {
|
||||
Some((scope, tail)) => match tail.split_once('@') {
|
||||
Some((name, ver)) => (format!("@{scope}/{name}"), Some(ver)),
|
||||
None => (format!("@{scope}/{tail}"), None),
|
||||
},
|
||||
None => return None,
|
||||
};
|
||||
return Some(scope_and_name);
|
||||
}
|
||||
Some(
|
||||
spec.split_once('@')
|
||||
.map(|(n, _)| n)
|
||||
.unwrap_or(spec)
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- execution
|
||||
|
||||
/// Kick off an install. Returns the job id; the work runs on a detached thread.
|
||||
pub(crate) fn spawn_install(plan: Plan) -> Result<String> {
|
||||
let target = plan.pkg.clone().unwrap_or_else(|| plan.spec.clone());
|
||||
let id = begin("install", &target)?;
|
||||
let job_id = id.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pf-store-install".into())
|
||||
.spawn(move || {
|
||||
let result = run_install(&job_id, plan);
|
||||
finish(&job_id, result);
|
||||
crate::events::emit(crate::events::EventKind::StoreChanged);
|
||||
})
|
||||
.context("spawn the plugin-install worker")?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Kick off an uninstall.
|
||||
pub(crate) fn spawn_uninstall(pkg: String) -> Result<String> {
|
||||
if super::valid_installed_pkg(&pkg).is_err() {
|
||||
bail!("not an installable plugin package name");
|
||||
}
|
||||
let id = begin("uninstall", &pkg)?;
|
||||
let job_id = id.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pf-store-uninstall".into())
|
||||
.spawn(move || {
|
||||
let result = run_uninstall(&job_id, &pkg);
|
||||
finish(&job_id, result);
|
||||
crate::events::emit(crate::events::EventKind::StoreChanged);
|
||||
})
|
||||
.context("spawn the plugin-uninstall worker")?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn run_install(id: &str, plan: Plan) -> Result<()> {
|
||||
let dir = super::plugins_dir();
|
||||
|
||||
// ---- verify: the pin must still describe what the registry serves ------------------------
|
||||
if let (Some(integrity), Some(pkg), Some(version), Some((_, registry))) = (
|
||||
plan.integrity.as_deref(),
|
||||
plan.pkg.as_deref(),
|
||||
plan.version.as_deref(),
|
||||
plan.registry.as_ref(),
|
||||
) {
|
||||
set_phase(id, "verifying");
|
||||
let advertised = registry_integrity(registry, pkg, version)
|
||||
.with_context(|| format!("check {pkg}@{version} against {registry}"))?;
|
||||
if advertised != integrity {
|
||||
bail!(
|
||||
"integrity mismatch for {pkg}@{version}: the catalog pins {integrity} but the \
|
||||
registry now serves {advertised}. This version was republished after it was \
|
||||
reviewed — refusing to install."
|
||||
);
|
||||
}
|
||||
log_line(id, format!("integrity ok: {pkg}@{version}"));
|
||||
}
|
||||
|
||||
// ---- install ------------------------------------------------------------------------------
|
||||
set_phase(id, "installing");
|
||||
let before = super::installed_packages(&dir);
|
||||
// Map the entry's scope to its registry ourselves rather than through a runner flag: the
|
||||
// installed scripting package can be older than this binary, and an older runner would read an
|
||||
// unknown flag's value as a package name (see `ensure_bunfig_scope`).
|
||||
if let Some((scope, url)) = &plan.registry {
|
||||
super::ensure_bunfig_scope(&dir, scope, url)
|
||||
.with_context(|| format!("map {scope} to {url}"))?;
|
||||
}
|
||||
let mut args = vec!["add".to_string(), plan.spec.clone()];
|
||||
if plan.version.is_some() {
|
||||
// Pin the dependency range too, so a later `bun install` in this tree can't drift off the
|
||||
// reviewed version. Safely ignored by a runner too old to know it (an unknown `-`-prefixed
|
||||
// flag is skipped, not misread) — the version we install is exact either way, so the worst
|
||||
// case is a caret range recorded in package.json.
|
||||
args.push("--exact".into());
|
||||
}
|
||||
if !plan.spec.starts_with("@punktfunk/") {
|
||||
// The runner CLI's supply-chain gate refuses anything resolving off Punktfunk's own
|
||||
// registry unless told otherwise. Here the operator already made that decision — either by
|
||||
// adding the source, or in the danger dialog — so pass the flag rather than making the gate
|
||||
// unable to express "yes, deliberately".
|
||||
args.push("--allow-public-registry".into());
|
||||
}
|
||||
args.push("--plugins".into());
|
||||
args.push(dir.to_string_lossy().into_owned());
|
||||
|
||||
run_runner(id, &args)?;
|
||||
|
||||
// ---- check: is what landed what we asked for? ---------------------------------------------
|
||||
set_phase(id, "checking");
|
||||
let after = super::installed_packages(&dir);
|
||||
let added: Vec<_> = after
|
||||
.iter()
|
||||
.filter(|p| !before.iter().any(|b| b.pkg == p.pkg))
|
||||
.collect();
|
||||
// For a catalogued install we know the package name; for a URL/git spec we learn it here.
|
||||
let pkg = plan
|
||||
.pkg
|
||||
.clone()
|
||||
.or_else(|| added.first().map(|p| p.pkg.clone()))
|
||||
.context(
|
||||
"the install finished but no new plugin package appeared — is this package a \
|
||||
punktfunk plugin? (it must be named `@scope/plugin-*` or `punktfunk-plugin-*`)",
|
||||
)?;
|
||||
let installed = after
|
||||
.iter()
|
||||
.find(|p| p.pkg == pkg)
|
||||
.with_context(|| format!("{pkg} is not present after install"))?;
|
||||
|
||||
if let (Some(want), Some(got)) = (plan.version.as_deref(), installed.version.as_deref()) {
|
||||
if want != got {
|
||||
// Roll back rather than leave an unreviewed version installed under a verified badge.
|
||||
log_line(id, format!("rolling back: expected {want}, found {got}"));
|
||||
set_phase(id, "rolling back");
|
||||
let _ = run_runner(
|
||||
id,
|
||||
&[
|
||||
"remove".to_string(),
|
||||
pkg.clone(),
|
||||
"--plugins".to_string(),
|
||||
dir.to_string_lossy().into_owned(),
|
||||
],
|
||||
);
|
||||
bail!("installed {pkg}@{got} but the catalog pinned {want} — rolled back");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- record + activate ---------------------------------------------------------------------
|
||||
set_phase(id, "recording");
|
||||
manifest::record(
|
||||
&dir,
|
||||
&pkg,
|
||||
Record {
|
||||
tier: plan.tier,
|
||||
source: plan.source.clone(),
|
||||
entry_id: plan.entry_id.clone(),
|
||||
version: installed.version.clone().or(plan.version.clone()),
|
||||
spec: (plan.tier == Tier::Unverified).then(|| plan.spec.clone()),
|
||||
installed_at: Some(manifest::now_stamp()),
|
||||
},
|
||||
)
|
||||
.context("record install provenance")?;
|
||||
|
||||
restart_runner(id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_uninstall(id: &str, pkg: &str) -> Result<()> {
|
||||
let dir = super::plugins_dir();
|
||||
set_phase(id, "removing");
|
||||
run_runner(
|
||||
id,
|
||||
&[
|
||||
"remove".to_string(),
|
||||
pkg.to_string(),
|
||||
"--plugins".to_string(),
|
||||
dir.to_string_lossy().into_owned(),
|
||||
],
|
||||
)?;
|
||||
set_phase(id, "recording");
|
||||
manifest::forget(&dir, pkg).context("update install provenance")?;
|
||||
restart_runner(id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restart the runner so it rediscovers units. Best-effort and non-fatal: the package IS installed
|
||||
/// at this point, so a restart failure must not present as an install failure — it presents as
|
||||
/// "installed, runner needs a nudge", which the console says out loud.
|
||||
fn restart_runner(id: &str) {
|
||||
set_phase(id, "restarting runner");
|
||||
match crate::plugins::restart_runtime() {
|
||||
Ok(true) => log_line(id, "plugin runner restarted".into()),
|
||||
Ok(false) => log_line(
|
||||
id,
|
||||
"the plugin runner is not enabled — enable it to start this plugin".into(),
|
||||
),
|
||||
Err(e) => log_line(id, format!("could not restart the plugin runner: {e:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the bun runner CLI with `args`, streaming both streams into the job log.
|
||||
fn run_runner(id: &str, args: &[String]) -> Result<()> {
|
||||
let (program, prefix) = crate::plugins::runner_command()?;
|
||||
tracing::info!(job = %id, program = %program.display(), ?args, "spawning the plugin runner");
|
||||
let mut child = Command::new(&program)
|
||||
.args(&prefix)
|
||||
.args(args)
|
||||
// No stdin: `bun add` must never sit waiting on a prompt inside a service.
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.with_context(|| format!("run the plugin runner ({})", program.display()))?;
|
||||
|
||||
// Pump both streams concurrently: bun writes progress to one and errors to the other, and a
|
||||
// full pipe on either would block the child.
|
||||
let mut pumps = Vec::new();
|
||||
if let Some(out) = child.stdout.take() {
|
||||
let job = id.to_string();
|
||||
pumps.push(std::thread::spawn(move || pump(&job, out)));
|
||||
}
|
||||
if let Some(err) = child.stderr.take() {
|
||||
let job = id.to_string();
|
||||
pumps.push(std::thread::spawn(move || pump(&job, err)));
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + JOB_TIMEOUT;
|
||||
let status = loop {
|
||||
match child.try_wait().context("wait for the plugin runner")? {
|
||||
Some(status) => break status,
|
||||
None if Instant::now() >= deadline => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
bail!(
|
||||
"the plugin runner timed out after {}s",
|
||||
JOB_TIMEOUT.as_secs()
|
||||
);
|
||||
}
|
||||
None => std::thread::sleep(Duration::from_millis(150)),
|
||||
}
|
||||
};
|
||||
for p in pumps {
|
||||
let _ = p.join();
|
||||
}
|
||||
if !status.success() {
|
||||
bail!(
|
||||
"the plugin runner exited with status {} — see the job log",
|
||||
status.code().unwrap_or(-1)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy one child stream into the job log, line by line.
|
||||
fn pump(job: &str, stream: impl std::io::Read) {
|
||||
for line in BufReader::new(stream).lines().map_while(Result::ok) {
|
||||
let line = line.trim_end().to_string();
|
||||
if !line.is_empty() {
|
||||
log_line(job, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- registry preflight
|
||||
|
||||
/// The integrity hash the registry itself advertises for `pkg@version`.
|
||||
///
|
||||
/// This is the check that gives the pin teeth. npm clients already refuse a tarball whose bytes
|
||||
/// don't match the registry's advertised hash, so the remaining attack is a *republish* — same
|
||||
/// version, new content, new hash. Comparing the registry's current hash against the reviewed one
|
||||
/// catches exactly that, before anything is downloaded.
|
||||
fn registry_integrity(registry: &str, pkg: &str, version: &str) -> Result<String> {
|
||||
let base = registry.trim_end_matches('/');
|
||||
// npm registry convention: the scope separator is percent-encoded in the packument path.
|
||||
let url = format!("{base}/{}", pkg.replace('/', "%2f"));
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.redirects(3)
|
||||
.user_agent(&format!("punktfunk-host/{}", super::index::host_version()))
|
||||
.build();
|
||||
let resp = agent
|
||||
.get(&url)
|
||||
.set("Accept", "application/json")
|
||||
.call()
|
||||
.map_err(|e| match e {
|
||||
ureq::Error::Status(404, _) => {
|
||||
anyhow::anyhow!("the registry does not know this package")
|
||||
}
|
||||
other => anyhow::anyhow!("registry request failed: {other}"),
|
||||
})?;
|
||||
let mut body = Vec::new();
|
||||
resp.into_reader()
|
||||
.take(16 * 1024 * 1024)
|
||||
.read_to_end(&mut body)
|
||||
.context("read the registry response")?;
|
||||
let doc: serde_json::Value =
|
||||
serde_json::from_slice(&body).context("registry returned invalid JSON")?;
|
||||
let dist = doc
|
||||
.get("versions")
|
||||
.and_then(|v| v.get(version))
|
||||
.and_then(|v| v.get("dist"))
|
||||
.with_context(|| format!("the registry has no version {version} of this package"))?;
|
||||
dist.get("integrity")
|
||||
.and_then(|i| i.as_str())
|
||||
.map(str::to_string)
|
||||
.context("the registry did not advertise an integrity hash for this version")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn spec_validation_refuses_the_dangerous_shapes() {
|
||||
assert!(validate_spec("@scope/plugin-x").is_ok());
|
||||
assert!(validate_spec("@scope/plugin-x@1.2.3").is_ok());
|
||||
assert!(validate_spec("punktfunk-plugin-x").is_ok());
|
||||
assert!(validate_spec("https://e.org/p.tgz").is_ok());
|
||||
assert!(validate_spec("git+https://e.org/p.git").is_ok());
|
||||
|
||||
assert!(validate_spec("").is_err());
|
||||
assert!(validate_spec(" ").is_err());
|
||||
// a leading dash would be parsed by bun as a flag, not an operand
|
||||
assert!(validate_spec("--production").is_err());
|
||||
assert!(validate_spec("a b").is_err());
|
||||
// local-path specs are CLI-only: installing from anywhere on the filesystem is a bigger
|
||||
// capability than installing a package
|
||||
assert!(validate_spec("file:/etc/passwd").is_err());
|
||||
assert!(validate_spec("link:../evil").is_err());
|
||||
// unauthenticated transports
|
||||
assert!(validate_spec("http://e.org/p.tgz").is_err());
|
||||
assert!(validate_spec("git://e.org/p.git").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spec_package_name_parsing() {
|
||||
assert_eq!(parse_spec_pkg("@a/b").as_deref(), Some("@a/b"));
|
||||
assert_eq!(parse_spec_pkg("@a/b@1.2.3").as_deref(), Some("@a/b"));
|
||||
assert_eq!(parse_spec_pkg("plain").as_deref(), Some("plain"));
|
||||
assert_eq!(parse_spec_pkg("plain@2").as_deref(), Some("plain"));
|
||||
// URL specs are identified by diffing node_modules after the install, not by parsing
|
||||
assert_eq!(parse_spec_pkg("https://e.org/p.tgz"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_from_entry_pins_version_registry_and_integrity() {
|
||||
let idx = super::super::index::Index::parse(
|
||||
br#"{"schema":1,"plugins":[{"id":"rom-manager","pkg":"@punktfunk/plugin-rom-manager",
|
||||
"registry":"https://git.unom.io/api/packages/unom/npm/","title":"ROM",
|
||||
"version":"0.3.0","integrity":"sha512-AAAA"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let plan = Plan::from_entry(&idx.plugins[0], "unom", true).unwrap();
|
||||
assert_eq!(plan.spec, "@punktfunk/plugin-rom-manager@0.3.0");
|
||||
assert_eq!(plan.version.as_deref(), Some("0.3.0"));
|
||||
assert_eq!(plan.tier, Tier::Verified);
|
||||
assert_eq!(
|
||||
plan.registry.as_ref().unwrap().0,
|
||||
"@punktfunk",
|
||||
"the scope drives the bunfig registry mapping"
|
||||
);
|
||||
assert_eq!(plan.integrity.as_deref(), Some("sha512-AAAA"));
|
||||
|
||||
// The same entry from an operator-added source is External, never Verified (D6).
|
||||
let ext = Plan::from_entry(&idx.plugins[0], "retro-hub", false).unwrap();
|
||||
assert_eq!(ext.tier, Tier::External);
|
||||
assert_eq!(ext.source.as_deref(), Some("retro-hub"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_from_spec_is_unverified_and_unpinned() {
|
||||
let plan = Plan::from_spec(" @someone/punktfunk-plugin-x ").unwrap();
|
||||
assert_eq!(plan.tier, Tier::Unverified);
|
||||
assert_eq!(plan.spec, "@someone/punktfunk-plugin-x");
|
||||
assert!(plan.version.is_none());
|
||||
assert!(
|
||||
plan.integrity.is_none(),
|
||||
"nothing to check a raw spec against"
|
||||
);
|
||||
assert!(plan.registry.is_none());
|
||||
}
|
||||
|
||||
/// `begin` is process-global and single-flight, so tests that create jobs must not overlap.
|
||||
fn exclusive() -> std::sync::MutexGuard<'static, ()> {
|
||||
static M: Mutex<()> = Mutex::new(());
|
||||
M.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_flight_refuses_a_second_job() {
|
||||
let _g = exclusive();
|
||||
let first = begin("install", "@a/b").expect("first job starts");
|
||||
assert!(begin("install", "@c/d").is_err(), "second must be refused");
|
||||
assert!(busy());
|
||||
finish(&first, Ok(()));
|
||||
assert!(!busy());
|
||||
let second = begin("install", "@c/d").expect("a finished job frees the slot");
|
||||
finish(&second, Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_log_is_bounded_and_tails() {
|
||||
let _g = exclusive();
|
||||
let id = begin("install", "@log/test").unwrap();
|
||||
for i in 0..(LOG_LINES + 50) {
|
||||
log_line(&id, format!("line {i}"));
|
||||
}
|
||||
let job = get(&id).unwrap();
|
||||
assert_eq!(job.log.len(), LOG_LINES);
|
||||
assert_eq!(job.log.last().unwrap(), &format!("line {}", LOG_LINES + 49));
|
||||
finish(&id, Err(anyhow::anyhow!("boom")));
|
||||
let job = get(&id).unwrap();
|
||||
assert_eq!(job.state, State::Failed);
|
||||
assert!(job.error.unwrap().contains("boom"));
|
||||
assert!(job.finished_at.is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
//! The **provenance manifest**: how a plugin got onto this box, remembered for as long as it is
|
||||
//! installed (design `plugin-store.md` §4.5).
|
||||
//!
|
||||
//! `node_modules` knows *what* is installed; it has no idea whether the operator picked a reviewed
|
||||
//! entry off the official shelf or pasted a tarball URL into the danger dialog. That distinction is
|
||||
//! exactly what the console has to keep showing — an unverified plugin must stay visibly
|
||||
//! unverified in the Installed list and in the header above its own UI, long after the install
|
||||
//! dialog is forgotten.
|
||||
//!
|
||||
//! `<plugins_dir>/install-manifest.json`, written by the host (which is the only thing that ever
|
||||
//! performs a *store* install). **Absence is meaningful**: a package with no manifest entry was
|
||||
//! installed by the CLI, and is reported as [`Tier::Cli`] rather than being silently promoted.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// How a plugin came to be installed. Ordered by how much review stands behind it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(crate) enum Tier {
|
||||
/// From the built-in source's index: unom reviewed this exact tarball.
|
||||
Verified,
|
||||
/// From an operator-added source's index: pinned and integrity-checked, but curated by
|
||||
/// somebody else. Attribution, never the badge (D6).
|
||||
External,
|
||||
/// Installed from a raw package spec through the danger dialog. Nobody reviewed it.
|
||||
Unverified,
|
||||
/// No manifest entry — `punktfunk-host plugins add` put it there.
|
||||
Cli,
|
||||
}
|
||||
|
||||
impl Tier {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Tier::Verified => "verified",
|
||||
Tier::External => "external",
|
||||
Tier::Unverified => "unverified",
|
||||
Tier::Cli => "cli",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One remembered install.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Record {
|
||||
pub tier: Tier,
|
||||
/// The catalog source's slug, when the install came off a shelf.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<String>,
|
||||
/// The catalog entry id, when there was one — lets the console re-associate an installed
|
||||
/// package with its shelf row even if the package name is unusual.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub entry_id: Option<String>,
|
||||
/// The version we installed (as pinned). The *live* version always comes from disk; this is
|
||||
/// what we asked for.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
/// The raw spec, for [`Tier::Unverified`] installs — the only record of what the operator
|
||||
/// actually typed.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub spec: Option<String>,
|
||||
/// RFC-3339 install time (wall clock — display only).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub installed_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct ManifestFile {
|
||||
schema: u32,
|
||||
/// Keyed by npm package name. `BTreeMap` so the file has a stable, diffable order.
|
||||
#[serde(default)]
|
||||
plugins: BTreeMap<String, Record>,
|
||||
}
|
||||
|
||||
impl Default for ManifestFile {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
schema: 1,
|
||||
plugins: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest_path(plugins_dir: &std::path::Path) -> std::path::PathBuf {
|
||||
plugins_dir.join("install-manifest.json")
|
||||
}
|
||||
|
||||
/// Read the manifest. A missing or corrupt file reads as empty — every package then reports as
|
||||
/// CLI-installed, which is the honest answer when we have no provenance to show.
|
||||
pub(crate) fn load(plugins_dir: &std::path::Path) -> BTreeMap<String, Record> {
|
||||
let Ok(bytes) = std::fs::read(manifest_path(plugins_dir)) else {
|
||||
return BTreeMap::new();
|
||||
};
|
||||
match serde_json::from_slice::<ManifestFile>(&bytes) {
|
||||
Ok(f) if f.schema == 1 => f.plugins,
|
||||
Ok(f) => {
|
||||
tracing::warn!(
|
||||
schema = f.schema,
|
||||
"unknown install-manifest schema — ignoring"
|
||||
);
|
||||
BTreeMap::new()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("install-manifest.json is unreadable ({e}) — treating as empty");
|
||||
BTreeMap::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Record (or replace) one package's provenance.
|
||||
pub(crate) fn record(plugins_dir: &std::path::Path, pkg: &str, rec: Record) -> Result<()> {
|
||||
let mut plugins = load(plugins_dir);
|
||||
plugins.insert(pkg.to_string(), rec);
|
||||
write(plugins_dir, plugins)
|
||||
}
|
||||
|
||||
/// Forget a package (on uninstall). Silent if it wasn't recorded.
|
||||
pub(crate) fn forget(plugins_dir: &std::path::Path, pkg: &str) -> Result<()> {
|
||||
let mut plugins = load(plugins_dir);
|
||||
if plugins.remove(pkg).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
write(plugins_dir, plugins)
|
||||
}
|
||||
|
||||
fn write(plugins_dir: &std::path::Path, plugins: BTreeMap<String, Record>) -> Result<()> {
|
||||
std::fs::create_dir_all(plugins_dir)
|
||||
.with_context(|| format!("create {}", plugins_dir.display()))?;
|
||||
let json = serde_json::to_string_pretty(&ManifestFile { schema: 1, plugins })
|
||||
.context("serialize install-manifest.json")?;
|
||||
let path = manifest_path(plugins_dir);
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, format!("{json}\n"))
|
||||
.with_context(|| format!("write {}", tmp.display()))?;
|
||||
std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wall-clock RFC-3339-ish stamp for [`Record::installed_at`]. Display only — nothing compares
|
||||
/// these, so a clock jump costs a cosmetic oddity, never a decision.
|
||||
pub(crate) fn now_stamp() -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
// Civil-date arithmetic from a Unix timestamp (days since epoch → y/m/d), so this needs no
|
||||
// date crate. UTC, second resolution.
|
||||
let (days, rem) = ((secs / 86_400) as i64, secs % 86_400);
|
||||
let (h, mi, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rec(tier: Tier) -> Record {
|
||||
Record {
|
||||
tier,
|
||||
source: Some("unom".into()),
|
||||
entry_id: Some("rom-manager".into()),
|
||||
version: Some("0.2.1".into()),
|
||||
spec: None,
|
||||
installed_at: Some(now_stamp()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_and_absence_means_cli() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(load(dir.path()).is_empty(), "no file ⇒ nothing recorded");
|
||||
|
||||
record(
|
||||
dir.path(),
|
||||
"@punktfunk/plugin-rom-manager",
|
||||
rec(Tier::Verified),
|
||||
)
|
||||
.unwrap();
|
||||
let m = load(dir.path());
|
||||
let r = m.get("@punktfunk/plugin-rom-manager").unwrap();
|
||||
assert_eq!(r.tier, Tier::Verified);
|
||||
assert_eq!(r.version.as_deref(), Some("0.2.1"));
|
||||
// A package we never recorded has no entry — the caller reports it as CLI-installed.
|
||||
assert!(!m.contains_key("punktfunk-plugin-other"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_survives_a_reinstall_at_a_new_tier() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
record(dir.path(), "@x/y", rec(Tier::Verified)).unwrap();
|
||||
record(dir.path(), "@x/y", rec(Tier::Unverified)).unwrap();
|
||||
assert_eq!(load(dir.path()).get("@x/y").unwrap().tier, Tier::Unverified);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forget_removes_only_the_named_package() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
record(dir.path(), "@x/y", rec(Tier::Verified)).unwrap();
|
||||
record(dir.path(), "@x/z", rec(Tier::External)).unwrap();
|
||||
forget(dir.path(), "@x/y").unwrap();
|
||||
let m = load(dir.path());
|
||||
assert!(!m.contains_key("@x/y"));
|
||||
assert!(m.contains_key("@x/z"));
|
||||
forget(dir.path(), "@not/here").unwrap(); // no-op, no error
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_manifest_reads_as_empty_not_an_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("install-manifest.json"), b"{ not json").unwrap();
|
||||
assert!(load(dir.path()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_json_spelling_is_stable() {
|
||||
// The console keys its badges off these strings.
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Tier::Unverified).unwrap(),
|
||||
"\"unverified\""
|
||||
);
|
||||
assert_eq!(Tier::Verified.as_str(), "verified");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamp_is_rfc3339_shaped() {
|
||||
let s = now_stamp();
|
||||
assert_eq!(s.len(), 20, "{s}");
|
||||
assert!(s.ends_with('Z'));
|
||||
assert_eq!(&s[4..5], "-");
|
||||
assert_eq!(&s[10..11], "T");
|
||||
// A known epoch value, to catch the civil-date arithmetic drifting.
|
||||
assert!(s.as_str() > "2026-01-01T00:00:00Z", "{s}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Catalog **sources** — where the store's shelves come from (design `plugin-store.md` §3.1).
|
||||
//!
|
||||
//! One source = one URL serving a signed index. The **official** source is compiled in (URL plus
|
||||
//! two pinned key slots) and cannot be edited or removed; operator-added sources live in
|
||||
//! `<config_dir>/plugin-sources.json`.
|
||||
//!
|
||||
//! Adding a source is itself a trust decision, made once: a source's entries become installable
|
||||
//! on this host. That is why they carry attribution and warning styling in the console but never
|
||||
//! the "Verified" badge — verification is unom reviewing a specific tarball, and it is not
|
||||
//! transferable to a third-party curator (D6).
|
||||
|
||||
use super::index::PublicKey;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The built-in source's slug. Reserved: an operator source may not take this name.
|
||||
pub(crate) const OFFICIAL_NAME: &str = "unom";
|
||||
|
||||
/// The built-in source's index URL.
|
||||
///
|
||||
/// Served straight out of the index repo over Gitea's anonymous raw endpoint: real HTTPS with a
|
||||
/// real certificate, no new vhost to stand up, and "merged to main" *is* "published". The document
|
||||
/// is signed, so the transport is not what we're trusting — swapping this for a dedicated static
|
||||
/// host later is a one-constant change with no protocol impact.
|
||||
///
|
||||
/// One consequence worth knowing: CI signs *after* the merge, so between a merge and the signature
|
||||
/// commit the index is newer than its `.sig`. That window fails **closed** — the signature check
|
||||
/// rejects the document and hosts keep serving their last good copy, marked stale.
|
||||
pub(crate) const OFFICIAL_URL: &str =
|
||||
"https://git.unom.io/unom/punktfunk-plugin-index/raw/branch/main/v1/index.json";
|
||||
|
||||
/// Pinned signing keys for the built-in source. **Two slots** so a key rotation is "sign with the
|
||||
/// new key, ship a host that trusts both, retire the old one" instead of a flag day where old
|
||||
/// hosts lose the catalog. An empty slot is ignored.
|
||||
pub(crate) const OFFICIAL_KEYS: [&str; 2] = [
|
||||
"ed25519:V7KKMg8sq2A2TW7D/GFWaM0ruAvigpld9r93JdWcQHw=",
|
||||
"", // rotation slot
|
||||
];
|
||||
|
||||
/// How many operator sources we'll hold. A guard rail, not a design limit.
|
||||
const MAX_SOURCES: usize = 32;
|
||||
|
||||
/// A catalog source as stored in `plugin-sources.json`. camelCase like the index document it
|
||||
/// points at (the management API's own view of a source is snake_case — see `mgmt::store`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Source {
|
||||
/// Slug (`[a-z][a-z0-9-]*`, ≤32) — the cache-file name and the console's attribution chip.
|
||||
pub name: String,
|
||||
/// `https://` URL of the index document. The `.sig` is fetched from `<url>.sig`.
|
||||
pub url: String,
|
||||
/// Pinned ed25519 key. A source without one is accepted but marked **unsigned**, and its
|
||||
/// entries inherit that marker — we can still show you the shelf, we just can't promise the
|
||||
/// shelf wasn't rewritten in transit.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub public_key: Option<String>,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
/// The compiled-in official source.
|
||||
pub(crate) fn official() -> Source {
|
||||
Source {
|
||||
name: OFFICIAL_NAME.to_string(),
|
||||
url: OFFICIAL_URL.to_string(),
|
||||
// Held in [`OFFICIAL_KEYS`], not here: the built-in keys are code, not config, so a
|
||||
// config file can never downgrade the official source to unsigned.
|
||||
public_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_official(&self) -> bool {
|
||||
self.name == OFFICIAL_NAME
|
||||
}
|
||||
|
||||
/// The keys this source's index must verify against. Empty ⇒ unsigned source (accepted, but
|
||||
/// flagged); for the official source this is always the compiled-in pair.
|
||||
pub(crate) fn keys(&self) -> Vec<PublicKey> {
|
||||
if self.is_official() {
|
||||
return OFFICIAL_KEYS
|
||||
.iter()
|
||||
.filter(|k| !k.is_empty())
|
||||
.filter_map(|k| PublicKey::parse(k).ok())
|
||||
.collect();
|
||||
}
|
||||
self.public_key
|
||||
.as_deref()
|
||||
.and_then(|k| PublicKey::parse(k).ok())
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Does this source's index carry a signature we check? Drives the console's "unsigned" marker.
|
||||
pub(crate) fn is_signed(&self) -> bool {
|
||||
!self.keys().is_empty()
|
||||
}
|
||||
|
||||
/// Where the detached signature lives. Kept a pure function of the index URL so a source
|
||||
/// record can never point the signature fetch somewhere else than the document it signs.
|
||||
pub(crate) fn sig_url(&self) -> String {
|
||||
format!("{}.sig", self.url)
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<()> {
|
||||
if !valid_source_name(&self.name) {
|
||||
bail!("source name must be kebab-case `[a-z][a-z0-9-]*`, ≤32 characters");
|
||||
}
|
||||
if !self.url.starts_with("https://") || self.url.len() > 500 {
|
||||
bail!("source url must be an https:// URL (≤500 characters)");
|
||||
}
|
||||
if let Some(k) = &self.public_key {
|
||||
PublicKey::parse(k).context("source publicKey")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn valid_source_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 32
|
||||
&& name.as_bytes()[0].is_ascii_lowercase()
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- persistence
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
struct SourcesFile {
|
||||
#[serde(default = "one")]
|
||||
schema: u32,
|
||||
#[serde(default)]
|
||||
sources: Vec<Source>,
|
||||
}
|
||||
|
||||
fn one() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
fn sources_path() -> std::path::PathBuf {
|
||||
pf_paths::config_dir().join("plugin-sources.json")
|
||||
}
|
||||
|
||||
/// Every source this host reads, official first. The official source is prepended here rather
|
||||
/// than stored, so it survives a hand-edited (or deleted) config file.
|
||||
pub(crate) fn load() -> Vec<Source> {
|
||||
let mut out = vec![Source::official()];
|
||||
let path = sources_path();
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
return out; // no operator sources yet — the common case
|
||||
};
|
||||
match serde_json::from_slice::<SourcesFile>(&bytes) {
|
||||
Ok(file) => {
|
||||
for s in file.sources.into_iter().take(MAX_SOURCES) {
|
||||
// Defense in depth: a hand-edited file can't smuggle in a source that shadows the
|
||||
// official one or carries an unusable URL/key.
|
||||
if s.is_official() || s.validate().is_err() {
|
||||
tracing::warn!(name = %s.name, "ignoring invalid entry in plugin-sources.json");
|
||||
continue;
|
||||
}
|
||||
if !out.iter().any(|e| e.name == s.name) {
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
"plugin-sources.json is unreadable ({e}) — using the official source only"
|
||||
),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Add or replace an operator source. Refuses the reserved official name and invalid records.
|
||||
pub(crate) fn put(source: Source) -> Result<()> {
|
||||
source.validate()?;
|
||||
if source.is_official() {
|
||||
bail!("`{OFFICIAL_NAME}` is the built-in source and cannot be redefined");
|
||||
}
|
||||
let mut list: Vec<Source> = load().into_iter().filter(|s| !s.is_official()).collect();
|
||||
if list.len() >= MAX_SOURCES && !list.iter().any(|s| s.name == source.name) {
|
||||
bail!("too many plugin sources (max {MAX_SOURCES})");
|
||||
}
|
||||
list.retain(|s| s.name != source.name);
|
||||
list.push(source);
|
||||
save(list)
|
||||
}
|
||||
|
||||
/// Remove an operator source. Returns `false` if there was nothing to remove.
|
||||
pub(crate) fn remove(name: &str) -> Result<bool> {
|
||||
if name == OFFICIAL_NAME {
|
||||
bail!("the built-in `{OFFICIAL_NAME}` source cannot be removed");
|
||||
}
|
||||
let list: Vec<Source> = load().into_iter().filter(|s| !s.is_official()).collect();
|
||||
if !list.iter().any(|s| s.name == name) {
|
||||
return Ok(false); // nothing to remove — don't rewrite the file
|
||||
}
|
||||
save(list.into_iter().filter(|s| s.name != name).collect())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn save(list: Vec<Source>) -> Result<()> {
|
||||
let dir = pf_paths::config_dir();
|
||||
pf_paths::create_private_dir(&dir).context("create the punktfunk config dir")?;
|
||||
let file = SourcesFile {
|
||||
schema: 1,
|
||||
sources: list,
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&file).context("serialize plugin-sources.json")?;
|
||||
let path = sources_path();
|
||||
// Write-then-rename: a crash mid-write must not leave a half-file that makes the host forget
|
||||
// the operator's sources on next boot.
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, format!("{json}\n"))
|
||||
.with_context(|| format!("write {}", tmp.display()))?;
|
||||
std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn official_source_is_signed_by_compiled_in_keys() {
|
||||
let s = Source::official();
|
||||
assert!(s.is_official());
|
||||
assert!(s.is_signed(), "the built-in source must carry a pinned key");
|
||||
assert_eq!(s.sig_url(), format!("{OFFICIAL_URL}.sig"));
|
||||
// A config file cannot downgrade it: `keys()` ignores the record's own field entirely.
|
||||
let mut forged = Source::official();
|
||||
forged.public_key = Some("ed25519:AAAA".into());
|
||||
assert_eq!(forged.keys().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiled_in_keys_parse() {
|
||||
for k in OFFICIAL_KEYS.iter().filter(|k| !k.is_empty()) {
|
||||
PublicKey::parse(k).expect("compiled-in official key must parse");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsigned_third_party_source_is_flagged_not_rejected() {
|
||||
let s = Source {
|
||||
name: "retro-hub".into(),
|
||||
url: "https://example.org/index.json".into(),
|
||||
public_key: None,
|
||||
};
|
||||
s.validate().unwrap();
|
||||
assert!(!s.is_signed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_rejects_bad_names_urls_and_keys() {
|
||||
let base = Source {
|
||||
name: "ok".into(),
|
||||
url: "https://e.org/i.json".into(),
|
||||
public_key: None,
|
||||
};
|
||||
assert!(base.validate().is_ok());
|
||||
assert!(Source {
|
||||
name: "Bad".into(),
|
||||
..base.clone()
|
||||
}
|
||||
.validate()
|
||||
.is_err());
|
||||
assert!(Source {
|
||||
name: "9bad".into(),
|
||||
..base.clone()
|
||||
}
|
||||
.validate()
|
||||
.is_err());
|
||||
assert!(Source {
|
||||
url: "http://e.org/i.json".into(),
|
||||
..base.clone()
|
||||
}
|
||||
.validate()
|
||||
.is_err());
|
||||
assert!(Source {
|
||||
public_key: Some("ed25519:nope".into()),
|
||||
..base.clone()
|
||||
}
|
||||
.validate()
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sig_url_is_derived_not_configurable() {
|
||||
let s = Source {
|
||||
name: "x".into(),
|
||||
url: "https://e.org/v1/index.json".into(),
|
||||
public_key: None,
|
||||
};
|
||||
assert_eq!(s.sig_url(), "https://e.org/v1/index.json.sig");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"name": "unom official",
|
||||
"generated": "2026-07-20T18:00:00Z",
|
||||
"plugins": [
|
||||
{
|
||||
"id": "rom-manager",
|
||||
"pkg": "@punktfunk/plugin-rom-manager",
|
||||
"registry": "https://git.unom.io/api/packages/unom/npm/",
|
||||
"title": "ROM Manager",
|
||||
"description": "Scans your ROM directories, maps them to emulators, fetches box art, and reconciles everything into the host game library as a provider. Includes a console-hosted web UI for mapping and cleanup.",
|
||||
"icon": "gamepad-2",
|
||||
"author": "unom",
|
||||
"homepage": "https://git.unom.io/unom/punktfunk-plugin-rom-manager",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"version": "0.3.1",
|
||||
"integrity": "sha512-SGqMriqQPOQobXMYiT20w0rcTMNdYfZc8No3fPu57njMN+2eTVBVlQjyn1t3KoRFxfoRYGwuumN4X3aNmS0Tpw==",
|
||||
"verification": {
|
||||
"reviewedAt": "2026-07-20"
|
||||
},
|
||||
"minHost": "0.15.0",
|
||||
"platforms": [
|
||||
"linux",
|
||||
"windows"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "playnite",
|
||||
"pkg": "@punktfunk/plugin-playnite",
|
||||
"registry": "https://git.unom.io/api/packages/unom/npm/",
|
||||
"title": "Playnite",
|
||||
"description": "Bridges your Playnite library on Windows into the host game library, keeping installed games, metadata, and launch commands in sync as a library provider.",
|
||||
"icon": "library",
|
||||
"author": "unom",
|
||||
"homepage": "https://git.unom.io/unom/punktfunk-plugin-playnite",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"version": "0.1.1",
|
||||
"integrity": "sha512-H40QEcUN7g0wHTy5CFCOTA4+j/FypbVzWSZ0OlW5Ej5+FnnT1fMUDVOx8KEH34mavTy70fd1zx3zDucu9hNQuQ==",
|
||||
"verification": {
|
||||
"reviewedAt": "2026-07-20"
|
||||
},
|
||||
"minHost": "0.15.0",
|
||||
"platforms": [
|
||||
"windows"
|
||||
]
|
||||
}
|
||||
],
|
||||
"security": []
|
||||
}
|
||||
@@ -30,6 +30,7 @@ and nothing you configure here runs anywhere near the streaming path.
|
||||
| `display.created` / `display.released` | a virtual display is minted / kept displays are released | backend + mode / count |
|
||||
| `library.changed` | the game library is mutated | source: `manual`, or the provider id that reconciled (`PUT /api/v1/library/provider/{p}`) |
|
||||
| `plugins.changed` | a plugin's registration changes (registered, restarted, deregistered, or its lease expired) | plugin id |
|
||||
| `store.changed` | an install or uninstall finished, or a plugin catalog was refreshed | none — re-read `GET /api/v1/store/catalog` / `…/installed` |
|
||||
| `host.started` / `host.stopping` | the serve planes come up / wind down | version, whether GameStream is enabled |
|
||||
|
||||
Every event is a small JSON document with a monotonic `seq`, a `ts_ms` timestamp, a `schema`
|
||||
@@ -167,12 +168,29 @@ The canonical "decide, don't just observe" pattern — approve pairing from your
|
||||
## Recipe: full controller passthrough (VirtualHere)
|
||||
|
||||
To get a controller's *native* features on the host — DualSense gyro, touchpad, adaptive
|
||||
triggers, USB rumble — instead of the emulated pad, share the physical device from the couch with
|
||||
[VirtualHere](https://www.virtualhere.com/) (USB-over-IP) and bind it to the host only while a
|
||||
client is connected. The couch runs the VirtualHere **server** (sharing the pad); the host runs
|
||||
the VirtualHere **client** and this automation drives its `-t` IPC.
|
||||
triggers, USB rumble — instead of the emulated pad, hand the physical device from the couch to the
|
||||
host over [VirtualHere](https://www.virtualhere.com/) (USB-over-IP), bound only while a client is
|
||||
connected so the couch keeps its own controller the rest of the time.
|
||||
|
||||
Zero-code, bracketed on the stream with two hooks:
|
||||
**The two sides.** VirtualHere is a server/client pair, and you run both:
|
||||
|
||||
- **Server — on the couch** (where the pad is physically plugged in). Run the VirtualHere USB
|
||||
Server there; it shares the pad on the LAN. Leave it running.
|
||||
- **Client — on the host** (where the game and this automation run). Install the VirtualHere
|
||||
Client (as a service, or the tray app). It auto-discovers the couch's shared pad on the same
|
||||
LAN; across subnets, add it once with `<VH_CLIENT> -t "MANUAL HUB ADD,<couch-ip>:7575"`. The
|
||||
client binary is `vhclientx86_64` on Linux, `vhui64.exe` on Windows, `vhclientosx` on macOS,
|
||||
`vhclientarm64` on ARM Linux.
|
||||
|
||||
The client's `-t` flag is a one-shot IPC to the already-running client: `-t LIST` prints every
|
||||
visible device with its address (`server.port`, e.g. `couch-deck.11`); `-t "USE,<addr>"` mounts it
|
||||
onto the host; `-t "STOP USING,<addr>"` hands it back. The automation just brackets
|
||||
`USE` / `STOP USING` around a session.
|
||||
|
||||
### Zero-code: two hooks
|
||||
|
||||
Bracket it on the stream with two [hooks](#hooks-hooksjson) — mount when video starts, release
|
||||
when it stops. This is all most setups need:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -183,8 +201,42 @@ Zero-code, bracketed on the stream with two hooks:
|
||||
}
|
||||
```
|
||||
|
||||
`couch-deck.11` is the device's VirtualHere address (`vhclientx86_64 -t LIST`); same-LAN setups
|
||||
auto-discover it, otherwise `MANUAL HUB ADD,<couch-ip>:7575` once. For a version that resolves the
|
||||
device by name, filters to one couch, and releases the pad on a clean shutdown, see the
|
||||
`couch-deck.11` is the device's VirtualHere address from `vhclientx86_64 -t LIST`.
|
||||
|
||||
### Scripted: resolve by name, release on shutdown
|
||||
|
||||
The hooks above hard-code the address, and can strand the pad on the host if it's stopped
|
||||
mid-stream (the `stream.stopped` hook never fires). The
|
||||
[`virtualhere-dualsense.ts`](https://git.unom.io/unom/punktfunk/src/branch/main/sdk/examples/virtualhere-dualsense.ts)
|
||||
SDK example.
|
||||
SDK example is the robust version: it resolves the device by **name substring** (survives the
|
||||
address changing), can filter to **one couch** in a multi-client setup, and **releases the pad on
|
||||
a clean shutdown** (`systemctl stop`, ^C) so the couch always gets its controller back.
|
||||
|
||||
It's a standalone [`@punktfunk/host` script](https://git.unom.io/unom/punktfunk/src/branch/main/sdk#running-a-single-script-as-a-service)
|
||||
— run it in its own directory. The one edit is its import: the in-repo copy imports from
|
||||
`../src/index.js`; outside the repo it's the published package:
|
||||
|
||||
```sh
|
||||
mkdir ~/punktfunk-scripts && cd ~/punktfunk-scripts
|
||||
bun init -y
|
||||
bun add @punktfunk/host # point the @punktfunk scope at the registry first — see the SDK README
|
||||
# save the example as virtualhere-dualsense.ts, and change its first import to the package:
|
||||
# - import { connect } from "../src/index.js";
|
||||
# + import { connect } from "@punktfunk/host";
|
||||
VH_DEVICE=DualSense bun virtualhere-dualsense.ts
|
||||
```
|
||||
|
||||
`VH_DEVICE` is required — a VirtualHere address (`couch-deck.11`) or a device-name substring
|
||||
(`DualSense`). Optional: `VH_CLIENT` overrides the client binary (default `vhclientx86_64`);
|
||||
`VH_ONLY_CLIENT` binds only for one punktfunk client label. Running on the host box the SDK needs
|
||||
no token or URL — it reads the host's loopback credentials itself (see
|
||||
[Connection resolution](https://git.unom.io/unom/punktfunk/src/branch/main/sdk#connection-resolution)).
|
||||
|
||||
Keep it running as a
|
||||
[systemd user unit](https://git.unom.io/unom/punktfunk/src/branch/main/sdk#running-a-single-script-as-a-service)
|
||||
(its default `SIGTERM` triggers the script's own release step — so `systemctl stop` hands the pad
|
||||
back), or drop it under the [scripting runner](/docs/plugins) with your other units.
|
||||
|
||||
> The example brackets on `client.connected` / `client.disconnected` — the pad returns to the
|
||||
> couch the moment they disconnect. Switch to `stream.started` / `stream.stopped` if you'd rather
|
||||
> pass it through only while video is actually flowing; both are noted in the file's header.
|
||||
|
||||
@@ -90,6 +90,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|
||||
| `PUNKTFUNK_FEC_PCT` | `N` (percent) | Forward-error-correction redundancy for lossy links (the default is sensible for a normal LAN). Higher = more loss-resilient, more bandwidth. |
|
||||
| `PUNKTFUNK_10BIT` | `1` · `0` *(default on)* | HEVC Main10 / HDR. **On by default** — the host permits 10-bit; a session goes 10-bit only when the client advertises it (behind the client's HDR setting). Set `0` to force 8-bit. Windows host, plus the Linux **GNOME 50+ GameStream desktop mirror** (`PUNKTFUNK_VIDEO_SOURCE=portal`, mirrored monitor in HDR mode — check with `punktfunk-host hdr-probe`). Linux **virtual displays** (native protocol, GameStream default) stay 8-bit: Mutter's virtual-monitor screencast is SDR-only upstream. |
|
||||
| `PUNKTFUNK_444` | `1` · `0` *(default on)* | Full-chroma HEVC 4:4:4 (Range Extensions) — sharper text/desktop, no chroma loss. **On by default** on the host; the client's own 4:4:4 setting (default off) is the real switch. Set `0` to force 4:2:0. **punktfunk/1 native only** (Moonlight stays 4:2:0), HEVC-only, honored only when the client advertises 4:4:4 **and** the GPU supports it (probed; NVENC is the validated path — VAAPI/AMF/QSV decline). Independent of 10-bit. |
|
||||
| `PUNKTFUNK_CHACHA20` | `1` · `0` *(default on)* | ChaCha20-Poly1305 session encryption for clients without hardware AES (old ARM TVs, e.g. webOS), lifting their ~100 Mbps software-AES decrypt ceiling. **On by default** on the host; a session uses it only when the client requests it — everyone else stays on AES-GCM. Purely a performance choice (both ciphers are full-strength); set `0` to force AES-GCM for all sessions. |
|
||||
| `PUNKTFUNK_PYROWAVE_MAX_MBPS` | `N` (Mbps) | Cap the [PyroWave](/docs/pyrowave) Automatic bitrate pin, for a host on a link that the open-loop pin can outrun (e.g. 4:4:4 + HDR at 5120×1440@240 pins ~5.3 Gbps, over a 5GbE link). Unset = no cap. Only affects Automatic (bitrate `0`) PyroWave sessions; an explicit client bitrate bypasses it. |
|
||||
| `PUNKTFUNK_DSCP` | `1` | Opt-in DSCP / `SO_PRIORITY` QoS tagging on the media sockets. No-op on the wire on Windows without a qWAVE policy. |
|
||||
| `PUNKTFUNK_OH264_THREADS` / `PUNKTFUNK_OH264_GOP` | `N` | Software (openh264) encoder tuning: encode threads (default 2 — latency over throughput) and GOP length (default 0 = encoder-auto). Only relevant with `PUNKTFUNK_ENCODER=software`. |
|
||||
|
||||
@@ -14,7 +14,49 @@ Two first-party plugins today:
|
||||
| **ROM Manager** | Scans your ROM directories, matches each platform to an installed emulator, and syncs them into the library with box art. |
|
||||
| **Playnite** | Mirrors your [Playnite](https://playnite.link) library — every store and emulator it manages — into the library, launched back through Playnite. |
|
||||
|
||||
## Installing a plugin
|
||||
## Installing from the console
|
||||
|
||||
Open the [web console](/docs/web-console) → **Plugins**. The **Browse** tab lists the plugin
|
||||
catalog; pick one, confirm, and the host installs it and restarts the runner for you. **Installed**
|
||||
shows what you have (and switches the runner on and off), **Sources** is where the catalog comes
|
||||
from.
|
||||
|
||||
That is the whole install. The rest of this page covers the CLI, which does the same thing, and the
|
||||
trust model behind the badges — worth reading once, because a plugin runs with the same privileges
|
||||
as the host.
|
||||
|
||||
### What "Verified" means
|
||||
|
||||
Every catalogued plugin pins **one exact version** and that version's package hash. A verified entry
|
||||
means somebody at unom reviewed *that exact package* — not the project in general, and not whatever
|
||||
it publishes next. When a plugin releases a new version, the store keeps offering the reviewed one
|
||||
until the new release is reviewed too. Before anything is downloaded, the host re-checks the pinned
|
||||
hash against the registry, so a package that was quietly republished under the same version number
|
||||
is refused rather than installed.
|
||||
|
||||
Three things a plugin can be:
|
||||
|
||||
| Badge | Where it came from |
|
||||
|---|---|
|
||||
| **Verified** | The built-in catalog. unom reviewed this exact package. |
|
||||
| **Unverified**, *from <source>* | A catalog **you** added. Still pinned and hash-checked, but curated by somebody else — unom has not looked at the code. |
|
||||
| **Unverified** | Installed by hand from a package spec. Nobody reviewed it and nothing pins it; the console asks you to type the name to confirm, and the plugin stays marked this way for as long as it is installed. |
|
||||
|
||||
### Adding another catalog
|
||||
|
||||
**Sources** → *Add a catalog source*: a name and the URL of its index. Optionally paste the source's
|
||||
`ed25519:…` public key — with a key set, the host refuses any index from that source that isn't
|
||||
correctly signed, rather than falling back to an unsigned one.
|
||||
|
||||
Adding a source is a trust decision you make once: its plugins become installable on this host.
|
||||
They are always attributed to it and never carry the Verified badge, which belongs to the built-in
|
||||
catalog alone.
|
||||
|
||||
To publish a plugin to the built-in catalog, open a pull request against
|
||||
[`punktfunk-plugin-index`](https://git.unom.io/unom/punktfunk-plugin-index) — its README covers the
|
||||
format and what review looks for.
|
||||
|
||||
## Installing from the CLI
|
||||
|
||||
Two commands: install the plugin, then turn the runner on. The host CLI handles the rest — creating
|
||||
the plugins directory, pointing it at the package registry, and starting the supervisor.
|
||||
@@ -50,7 +92,13 @@ Open the [web console](/docs/web-console) and the plugin's page appears in the n
|
||||
that's the whole install.
|
||||
|
||||
The runner is **opt-in**: `plugins add` installs, `plugins enable` turns it on. You only need
|
||||
`enable` once — plugins you add later are picked up automatically.
|
||||
`enable` once. The runner discovers plugins when it starts, so one installed later needs a restart
|
||||
to come up (`systemctl --user restart punktfunk-scripting`, or `Restart` the `PunktfunkScripting`
|
||||
task) — the console does that restart for you as part of installing.
|
||||
|
||||
A plugin installed from the CLI shows up in the console as **Installed via CLI**: the console knows
|
||||
what is installed, but not who vouched for it. Install the same plugin from the store's Browse tab
|
||||
and it carries its catalog badge instead.
|
||||
|
||||
### The rest of the commands
|
||||
|
||||
|
||||
@@ -58,8 +58,10 @@ It is idempotent — safe to re-run. In one pass it:
|
||||
1. creates the `pf2` Debian-trixie distrobox and installs the build toolchain,
|
||||
2. builds `punktfunk-host` (and the web console),
|
||||
3. writes config to `~/.config/punktfunk/` (a generated web-console login password),
|
||||
4. raises the UDP socket buffers to 32 MB and adds you to the `input` group (needs `sudo`; skipped
|
||||
with a warning if unavailable),
|
||||
4. raises the UDP socket buffers to 32 MB, installs the gamepad udev rule + the `vhci-hcd` autoload
|
||||
and adds you to the `input` group (virtual gamepads / **native Steam Deck controller passthrough**),
|
||||
and seeds the KDE RemoteDesktop grant for Desktop-mode input — this step **prompts for your `sudo`
|
||||
password** (a stock Steam Deck requires one; without it gamepad passthrough and the UDP tuning are skipped),
|
||||
5. installs + starts the `punktfunk-host` and `punktfunk-web` **systemd user services** (with linger,
|
||||
so they run without a login session).
|
||||
|
||||
@@ -82,6 +84,14 @@ When it finishes it prints the web-console URL and how to pair.
|
||||
> If you only ever use native clients, install with `--no-gamestream` for a host with no GameStream
|
||||
> surface at all.
|
||||
|
||||
> **First install — reboot once before streaming.** KWin only authorizes Desktop-mode screen capture
|
||||
> on a fresh session, and the new `input` group (native Steam Deck controller passthrough) only takes
|
||||
> effect on a new login — so after the **first** install, **reboot the Deck** (a re-run that changes
|
||||
> nothing doesn't need it). Streaming **Game Mode** with a generic Xbox pad works right away; **Desktop
|
||||
> capture and the native Steam Deck controller need the reboot.** If a client connects and every
|
||||
> session ends with `KWin does not expose zkde_screencast_unstable_v1` or the pad shows up as an Xbox
|
||||
> 360 controller, you haven't rebooted yet.
|
||||
|
||||
## 3. Pair a device
|
||||
|
||||
By default the host **requires PIN pairing** (secure). Two ways to pair:
|
||||
@@ -128,6 +138,12 @@ bash ~/punktfunk/scripts/steamdeck/update.sh
|
||||
thrash the managed session. Pick one mode per session.
|
||||
- **Keep the device awake.** On handhelds, Game Mode auto-suspends on idle, which drops the host off
|
||||
the network mid stream — disable auto-suspend (Settings → Power) for a headless host.
|
||||
- **Native Steam Deck controller passthrough** presents the client's pad as a real Steam Deck
|
||||
controller (paddles, trackpads, gyro) via a virtual USB device — that needs the `input` group and the
|
||||
`vhci-hcd` module live, so it only works **after the first-install reboot** above; until then the pad
|
||||
degrades to a generic Xbox 360 controller (still fully playable). If you're streaming *to* another
|
||||
Steam Deck, also set Steam Input to **Off** for Punktfunk on that Deck — see
|
||||
[Stream to a Steam Deck](/docs/steam-deck).
|
||||
- **It survives OS updates**, but a major SteamOS bump can move library versions; if the host fails to
|
||||
start after an update, just re-run `update.sh` to rebuild against the new base.
|
||||
- Deeper reference (services, container, manual steps): [`scripts/steamdeck/README.md`](https://git.unom.io/unom/punktfunk/src/branch/main/scripts/steamdeck/README.md).
|
||||
|
||||
@@ -272,7 +272,7 @@
|
||||
#define INBOUND_REQ_FLAG 2147483648
|
||||
#endif
|
||||
|
||||
// 16-byte AEAD authentication tag appended by GCM.
|
||||
// 16-byte AEAD authentication tag appended by either session cipher.
|
||||
#define TAG_LEN 16
|
||||
|
||||
// Wire tag distinguishing an input datagram from a video packet.
|
||||
@@ -450,6 +450,35 @@
|
||||
#define VIDEO_CAP_PROBE_SEQ 16
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Hello::video_caps`] bit: the client's reassembler accepts **streamed access units**
|
||||
// (design/nvenc-subframe-slice-output.md Phase 2): the host may ship an AU's early FEC blocks
|
||||
// before the AU's total size exists — while the tail of the frame is still encoding — so the
|
||||
// AU's last packet leaves the host sooner (latency plan §7 LN1). Non-final blocks ride
|
||||
// SENTINEL headers (`block_count == 0` — a value no legacy sender emits — with
|
||||
// `frame_bytes == 0` and exactly `max_data_per_block` data shards, so the shard-offset
|
||||
// formula needs no total); the FINAL block's headers carry the real
|
||||
// `frame_bytes`/`block_count` (+ `FLAG_EOF`), which retro-validate the whole frame's geometry
|
||||
// — a mismatch drops the frame wholesale. The host streams ONLY to clients advertising this
|
||||
// bit; every other client gets today's whole-AU path (chunks concatenated before sealing), so
|
||||
// the fallback is zero-risk.
|
||||
#define VIDEO_CAP_STREAMED_AU 32
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Hello::video_caps`] bit: the client can open **ChaCha20-Poly1305**-sealed session datagrams
|
||||
// AND requests them — set by clients without hardware AES (the soft-AES armv7 targets, e.g.
|
||||
// webOS TVs), where GCM's software AES + GHASH caps decrypt at ~100 Mbps while ChaCha's ARX
|
||||
// construction runs 4–7× faster in portable code (design/chacha20-session-cipher.md).
|
||||
// Support-plus-request in one bit mirrors [`VIDEO_CAP_444`]'s "capable AND turned on"
|
||||
// precedent. The host grants it only when its `PUNKTFUNK_CHACHA20` kill-switch (default on)
|
||||
// allows, answering with [`Welcome::cipher`] `= 1` + the 32-byte [`Welcome::key_chacha`];
|
||||
// toward every other client the Welcome stays byte-identical AES-128-GCM. Purely a
|
||||
// performance choice — both AEADs are full-strength, and Hello/Welcome ride the pinned-TLS
|
||||
// control channel, so there is no downgrade surface.
|
||||
#define VIDEO_CAP_CHACHA20 64
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`]
|
||||
// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder
|
||||
@@ -840,6 +869,18 @@
|
||||
#define HELLO_LAUNCH_MAX 128
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Welcome::cipher`] id: AES-128-GCM — the default session AEAD every peer speaks (and the
|
||||
// only one pre-cipher builds know).
|
||||
#define CIPHER_AES_128_GCM 0
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`Welcome::cipher`] id: ChaCha20-Poly1305 (RFC 8439) — negotiated via
|
||||
// [`VIDEO_CAP_CHACHA20`] for clients without hardware AES.
|
||||
#define CIPHER_CHACHA20_POLY1305 1
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`PairRequest`].
|
||||
#define MSG_PAIR_REQUEST 16
|
||||
|
||||
+106
-11
@@ -48,6 +48,9 @@ BIN="$TARGET_DIR/release/punktfunk-host"
|
||||
CONFIG="$HOME/.config/punktfunk"
|
||||
UNITS="$HOME/.config/systemd/user"
|
||||
XRD="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||
# Set when this run does something that only a fresh login picks up (input-group add, first-time
|
||||
# KWin .desktop grant). Drives the loud "reboot before streaming" note in the summary.
|
||||
NEED_RELOGIN=0
|
||||
|
||||
# --- 0. preflight ----------------------------------------------------------
|
||||
log "Preflight"
|
||||
@@ -66,6 +69,33 @@ fi
|
||||
DISTROBOX="$(command -v distrobox)" # baked into the web unit (may be /usr/bin or ~/.local/bin)
|
||||
ok "distrobox: $DISTROBOX"
|
||||
|
||||
# --- acquire sudo up front (before the ~15-min build) ----------------------
|
||||
# Steps 4-5 (UDP buffers, gamepad udev rule, vhci-hcd, input group, linger) need root. Prompt NOW,
|
||||
# not after the build — so you authorize once and walk away, and a non-interactive run fails LOUDLY
|
||||
# here instead of silently skipping the tuning at the very end. A stock SteamOS 'deck' has no
|
||||
# password, so sudo can't work until you set one.
|
||||
SUDO_OK=0
|
||||
if sudo -n true 2>/dev/null; then
|
||||
SUDO_OK=1
|
||||
elif [ -t 0 ]; then
|
||||
warn "sudo is needed once (UDP buffers, gamepad udev rule, vhci-hcd, input group, linger):"
|
||||
if sudo -v; then
|
||||
SUDO_OK=1
|
||||
# keep the sudo timestamp warm across the long build so steps 4-5 don't re-prompt / expire
|
||||
( while sudo -n -v 2>/dev/null; do sleep 50; done ) &
|
||||
_pf_sudo_keepalive=$!
|
||||
trap '[ -n "${_pf_sudo_keepalive:-}" ] && kill "$_pf_sudo_keepalive" 2>/dev/null || true' EXIT
|
||||
fi
|
||||
fi
|
||||
if [ "$SUDO_OK" != 1 ]; then
|
||||
if [ -t 0 ]; then
|
||||
warn "No sudo — a stock SteamOS 'deck' account has no password. Set one and re-run: passwd"
|
||||
else
|
||||
warn "No TTY for the sudo prompt (non-interactive run) — system tuning + linger will be SKIPPED."
|
||||
warn "Run in Konsole or an interactive 'ssh -t' session (or pre-authorize sudo) to enable them."
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 1. build container + toolchain ---------------------------------------
|
||||
log "Build container '$BOX' ($BOX_IMAGE)"
|
||||
if distrobox list 2>/dev/null | awk -F'|' '{gsub(/ /,"",$2); print $2}' | grep -qx "$BOX"; then
|
||||
@@ -84,7 +114,7 @@ set -e
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq --no-install-recommends \
|
||||
build-essential pkg-config clang curl git ca-certificates \
|
||||
build-essential pkg-config clang cmake curl git ca-certificates \
|
||||
libavcodec-dev libavformat-dev libavutil-dev libavfilter-dev libswscale-dev libavdevice-dev \
|
||||
libpipewire-0.3-dev libspa-0.2-dev \
|
||||
libgbm-dev libegl-dev libgl-dev libdrm-dev libva-dev \
|
||||
@@ -101,10 +131,13 @@ ok "build deps ready"
|
||||
|
||||
# --- 2. build host (+ web) -------------------------------------------------
|
||||
log "Building punktfunk-host (release) — first build is slow (~10-15 min)"
|
||||
# vulkan-encode matches the packaged builds (deb/arch): the raw Vulkan Video HEVC/AV1 backend
|
||||
# (real RFI loss recovery). Pure-Rust ash — no extra system dep. A featureless hand build would
|
||||
# silently fall back to libav VAAPI.
|
||||
distrobox enter "$BOX" -- bash -lc "
|
||||
set -e
|
||||
export PATH=\$HOME/.cargo/bin:\$PATH CARGO_TARGET_DIR='$TARGET_DIR'
|
||||
cd '$SRC' && cargo build -r -p punktfunk-host
|
||||
cd '$SRC' && cargo build -r -p punktfunk-host --features punktfunk-host/vulkan-encode
|
||||
"
|
||||
[ -x "$BIN" ] || die "build did not produce $BIN"
|
||||
ok "host binary: $BIN"
|
||||
@@ -126,8 +159,12 @@ mkdir -p "$CONFIG"
|
||||
if [ ! -f "$CONFIG/host.env" ]; then
|
||||
cat > "$CONFIG/host.env" <<'EOF'
|
||||
# punktfunk Steam Deck host config (sourced by the punktfunk-host user service).
|
||||
# Auto encoder: VAAPI on the Deck's AMD GPU, NVENC on NVIDIA.
|
||||
# Auto encoder: Vulkan Video (or VAAPI fallback) on the Deck's AMD GPU, NVENC on NVIDIA.
|
||||
PUNKTFUNK_ENCODER=auto
|
||||
# Van Gogh (LCD/OLED Deck) RADV still gates VK_KHR_video_encode_* behind this perftest flag;
|
||||
# without it the Vulkan backend can't open and sessions fall back to libav VAAPI. Harmless on
|
||||
# GPUs where encode is exposed by default.
|
||||
RADV_PERFTEST=video_encode
|
||||
# The host auto-detects the live session (Game Mode gamescope / Desktop KDE) per connect.
|
||||
# Override the compositor only if detection misbehaves: PUNKTFUNK_COMPOSITOR=gamescope
|
||||
EOF
|
||||
@@ -136,6 +173,34 @@ else
|
||||
ok "host.env exists (left as-is)"
|
||||
fi
|
||||
|
||||
# KWin authorization for Desktop-Mode streaming (and mid-stream Game↔Desktop switches): KWin
|
||||
# resolves a connecting client's /proc/<pid>/exe against a .desktop `Exec=` and only then grants
|
||||
# the restricted Wayland globals it lists (see packaging/linux/io.unom.Punktfunk.Host.desktop).
|
||||
# Exec must therefore be THIS install's binary path, not the packaged /usr/bin one. KWin reads
|
||||
# grants at session start — after first install, restart the Desktop session (Game Mode and back).
|
||||
DESKTOP_DST="$HOME/.local/share/applications/io.unom.Punktfunk.Host.desktop"
|
||||
# First-time install of the grant: KWin only reads it at session start, so a fresh login is required
|
||||
# before Desktop-mode capture works. A re-run that just rewrites it needs no relogin.
|
||||
[ -f "$DESKTOP_DST" ] || NEED_RELOGIN=1
|
||||
mkdir -p "$HOME/.local/share/applications"
|
||||
sed "s|^Exec=.*|Exec=$BIN|" "$SRC/packaging/linux/io.unom.Punktfunk.Host.desktop" > "$DESKTOP_DST"
|
||||
ok "KWin desktop-capture authorization (io.unom.Punktfunk.Host.desktop → $BIN)"
|
||||
|
||||
# KDE Desktop-mode INPUT: a normal Plasma login lacks the RemoteDesktop portal grant the host's libei
|
||||
# input path needs, so it would pop an "Allow remote control?" dialog a headless host can't answer.
|
||||
# Seed it once (per-user, no root) — mirrors packaging/bazzite/kde-desktop-setup.sh. Game Mode
|
||||
# (gamescope) needs none of this; the .desktop above already grants org_kde_kwin_fake_input.
|
||||
GRANT_SRC="$SRC/scripts/headless/kde-authorized"
|
||||
GRANT_DST="$HOME/.local/share/flatpak/db/kde-authorized"
|
||||
if [ -s "$GRANT_DST" ]; then
|
||||
ok "KDE RemoteDesktop grant already present"
|
||||
elif [ -s "$GRANT_SRC" ]; then
|
||||
mkdir -p "$(dirname "$GRANT_DST")"
|
||||
install -m644 "$GRANT_SRC" "$GRANT_DST"
|
||||
systemctl --user restart xdg-permission-store 2>/dev/null || true
|
||||
ok "seeded KDE RemoteDesktop grant (Desktop-mode input)"
|
||||
fi
|
||||
|
||||
if [ "$WITH_WEB" = 1 ] && [ ! -f "$CONFIG/web.env" ]; then
|
||||
# Random login password + session secret for the web console, generated once.
|
||||
# `|| true` swallows the SIGPIPE `tr` takes when `head` closes the pipe (pipefail would abort).
|
||||
@@ -151,9 +216,11 @@ else
|
||||
[ "$WITH_WEB" = 1 ] && ok "web.env exists (login password unchanged)"
|
||||
fi
|
||||
|
||||
# --- 4. system tuning (needs sudo; skipped gracefully if unavailable) ------
|
||||
log "System tuning (UDP buffers + input group) — needs sudo"
|
||||
if sudo -n true 2>/dev/null; then
|
||||
# --- 4. system tuning (needs sudo: UDP buffers + gamepad udev rule + vhci-hcd + input group) --------
|
||||
log "System tuning (UDP buffers + gamepad rules + vhci-hcd + input group)"
|
||||
# sudo was acquired up front in preflight (SUDO_OK) so this never stalls behind the long build; a
|
||||
# skip here (no password / no TTY) was already reported loudly there.
|
||||
if [ "$SUDO_OK" = 1 ]; then
|
||||
printf 'net.core.wmem_max=33554432\nnet.core.rmem_max=33554432\n' \
|
||||
| sudo tee /etc/sysctl.d/99-punktfunk-net.conf >/dev/null
|
||||
sudo sysctl -q -p /etc/sysctl.d/99-punktfunk-net.conf >/dev/null
|
||||
@@ -161,13 +228,34 @@ if sudo -n true 2>/dev/null; then
|
||||
if [ -f "$SRC/scripts/60-punktfunk.rules" ]; then
|
||||
sudo install -m644 "$SRC/scripts/60-punktfunk.rules" /etc/udev/rules.d/60-punktfunk.rules
|
||||
sudo udevadm control --reload-rules && sudo udevadm trigger || true
|
||||
ok "installed udev rule (virtual gamepads)"
|
||||
ok "installed udev rule (virtual gamepads + native Steam Deck controller)"
|
||||
fi
|
||||
id -nG "$USER" | grep -qw input || { sudo usermod -aG input "$USER"; warn "added $USER to 'input' group — log out/in (or reboot) for gamepad support"; }
|
||||
# vhci-hcd: the usbip transport that makes the virtual Steam Deck pad a *real* USB device so Steam
|
||||
# Input adopts it (else it degrades to plain UHID, which Steam ignores — "no controller appears").
|
||||
# Persist the autoload AND load it now so passthrough works without waiting for a reboot.
|
||||
if [ -f "$SRC/scripts/punktfunk-modules.conf" ]; then
|
||||
sudo install -m644 "$SRC/scripts/punktfunk-modules.conf" /etc/modules-load.d/punktfunk.conf
|
||||
sudo modprobe vhci-hcd 2>/dev/null || warn "could not load vhci-hcd now (loads on next boot) — needed for the native Steam Deck pad"
|
||||
ok "vhci-hcd autoload installed (native Steam Deck controller transport)"
|
||||
fi
|
||||
if id -nG "$USER" | grep -qw input; then
|
||||
ok "already in the 'input' group"
|
||||
else
|
||||
warn "passwordless sudo unavailable — skipping UDP-buffer + udev tuning."
|
||||
warn "Without it, high-bitrate streaming drops packets. Apply manually later:"
|
||||
warn " echo -e 'net.core.wmem_max=33554432\\nnet.core.rmem_max=33554432' | sudo tee /etc/sysctl.d/99-punktfunk-net.conf && sudo sysctl --system"
|
||||
sudo usermod -aG input "$USER"
|
||||
NEED_RELOGIN=1
|
||||
warn "added $USER to the 'input' group (applies on next login)"
|
||||
fi
|
||||
else
|
||||
warn "no usable sudo — SKIPPED system tuning. Gamepad passthrough + clean streaming need root (udev"
|
||||
warn "rule, 'input' group, vhci-hcd, UDP buffers) — there is no user-space way to do these."
|
||||
warn "A stock SteamOS 'deck' account has NO password, so sudo can't work until you set one:"
|
||||
warn " passwd # set a sudo password once, then re-run this script"
|
||||
warn "Or apply it by hand (then reboot):"
|
||||
warn " sudo install -m644 $SRC/scripts/60-punktfunk.rules /etc/udev/rules.d/ &&"
|
||||
warn " sudo install -m644 $SRC/scripts/punktfunk-modules.conf /etc/modules-load.d/punktfunk.conf &&"
|
||||
warn " sudo usermod -aG input $USER &&"
|
||||
warn " printf 'net.core.wmem_max=33554432\\nnet.core.rmem_max=33554432\\n' | sudo tee /etc/sysctl.d/99-punktfunk-net.conf &&"
|
||||
warn " sudo sysctl --system && sudo udevadm control --reload-rules && sudo udevadm trigger"
|
||||
fi
|
||||
|
||||
# --- 5. systemd user services ---------------------------------------------
|
||||
@@ -248,3 +336,10 @@ else
|
||||
echo " • Pairing required (secure default). From a client, pick this host and enter the PIN the host shows."
|
||||
fi
|
||||
echo " • Update later: bash $SRC/scripts/steamdeck/update.sh"
|
||||
if [ "$NEED_RELOGIN" = 1 ]; then
|
||||
echo
|
||||
warn "ONE MORE STEP before streaming — reboot the Deck (or fully log out and back in)."
|
||||
echo " KWin only authorizes Desktop-mode screen capture on a fresh session, and the new 'input'"
|
||||
echo " group (native Steam Deck controller passthrough) only applies to a new login. Streaming"
|
||||
echo " Game Mode with a generic Xbox pad works now; Desktop capture + the native Deck pad need the reboot."
|
||||
fi
|
||||
|
||||
@@ -21,7 +21,8 @@ if [ "${1:-}" = "--pull" ]; then
|
||||
fi
|
||||
|
||||
log "Rebuilding host (release)"
|
||||
distrobox enter "$BOX" -- bash -lc "set -e; export PATH=\$HOME/.cargo/bin:\$PATH CARGO_TARGET_DIR='$TARGET_DIR'; cd '$SRC' && cargo build -r -p punktfunk-host"
|
||||
# vulkan-encode matches the packaged builds (deb/arch) — see install.sh.
|
||||
distrobox enter "$BOX" -- bash -lc "set -e; export PATH=\$HOME/.cargo/bin:\$PATH CARGO_TARGET_DIR='$TARGET_DIR'; cd '$SRC' && cargo build -r -p punktfunk-host --features punktfunk-host/vulkan-encode"
|
||||
ok "host rebuilt"
|
||||
if [ "$WEB" = 1 ]; then
|
||||
log "Rebuilding web console"
|
||||
@@ -29,6 +30,69 @@ if [ "$WEB" = 1 ]; then
|
||||
ok "web rebuilt"
|
||||
fi
|
||||
|
||||
# Retrofit config that install.sh now writes but older installs predate (both idempotent):
|
||||
# RADV_PERFTEST — Van Gogh RADV still gates VK_KHR_video_encode_* behind it; without it the
|
||||
# Vulkan backend can't open and sessions silently fall back to libav VAAPI. The KWin .desktop —
|
||||
# KWin only grants the restricted capture/input globals to the exe a .desktop authorizes.
|
||||
HOST_ENV="$HOME/.config/punktfunk/host.env"
|
||||
if [ -f "$HOST_ENV" ] && ! grep -q '^RADV_PERFTEST=' "$HOST_ENV"; then
|
||||
printf '\n# Van Gogh RADV gates VK_KHR_video_encode_* behind this (Vulkan Video encode).\nRADV_PERFTEST=video_encode\n' >> "$HOST_ENV"
|
||||
ok "host.env: added RADV_PERFTEST=video_encode"
|
||||
fi
|
||||
mkdir -p "$HOME/.local/share/applications"
|
||||
sed "s|^Exec=.*|Exec=$TARGET_DIR/release/punktfunk-host|" "$SRC/packaging/linux/io.unom.Punktfunk.Host.desktop" \
|
||||
> "$HOME/.local/share/applications/io.unom.Punktfunk.Host.desktop"
|
||||
ok "KWin desktop-capture authorization refreshed"
|
||||
|
||||
# Retrofit the system bits install.sh now sets up but older installs predate (idempotent). vhci-hcd =
|
||||
# usbip transport for the native Steam Deck pad; 60-punktfunk.rules = /dev/uhid + vhci access; input
|
||||
# group = uhid write; the kde-authorized grant (per-user, no root) = Desktop-mode input. A stock Deck
|
||||
# needs a sudo PASSWORD, so PROMPT for it rather than silently skipping (skipping = gamepads stay dead).
|
||||
SUDO_OK=0
|
||||
if sudo -n true 2>/dev/null; then
|
||||
SUDO_OK=1
|
||||
elif [ -t 0 ]; then
|
||||
warn "sudo needs your password to (re)apply the gamepad udev rule, vhci-hcd, input group, and UDP buffers:"
|
||||
sudo -v && SUDO_OK=1 || true
|
||||
fi
|
||||
if [ "$SUDO_OK" = 1 ]; then
|
||||
if [ -f "$SRC/scripts/60-punktfunk.rules" ]; then
|
||||
sudo install -m644 "$SRC/scripts/60-punktfunk.rules" /etc/udev/rules.d/60-punktfunk.rules
|
||||
sudo udevadm control --reload-rules >/dev/null 2>&1 || true
|
||||
sudo udevadm trigger >/dev/null 2>&1 || true
|
||||
ok "gamepad udev rule ensured"
|
||||
fi
|
||||
if [ -f "$SRC/scripts/punktfunk-modules.conf" ]; then
|
||||
sudo install -m644 "$SRC/scripts/punktfunk-modules.conf" /etc/modules-load.d/punktfunk.conf
|
||||
sudo modprobe vhci-hcd 2>/dev/null || true
|
||||
ok "vhci-hcd autoload ensured (native Steam Deck controller)"
|
||||
fi
|
||||
# UDP buffers: older installs (or sudo-skipped ones) still run the stock 416 KB cap.
|
||||
if [ ! -f /etc/sysctl.d/99-punktfunk-net.conf ]; then
|
||||
printf 'net.core.wmem_max=33554432\nnet.core.rmem_max=33554432\n' | sudo tee /etc/sysctl.d/99-punktfunk-net.conf >/dev/null
|
||||
sudo sysctl -q -p /etc/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || true
|
||||
ok "UDP socket buffers raised to 32 MB (persisted)"
|
||||
fi
|
||||
if id -nG "$USER" | grep -qw input; then :; else
|
||||
sudo usermod -aG input "$USER"
|
||||
warn "added $USER to the 'input' group — REBOOT (or log out/in) for it to apply"
|
||||
fi
|
||||
else
|
||||
warn "no usable sudo — SKIPPED gamepad/udev/vhci/UDP tuning (all root-only; no user-space alternative)."
|
||||
warn "A stock SteamOS 'deck' account has NO password — set one with 'passwd', then re-run. Gamepads stay"
|
||||
warn "Xbox-360 until this runs and you reboot."
|
||||
fi
|
||||
echo
|
||||
warn "If the controller still shows as an Xbox 360 pad, REBOOT the Deck once — the 'input' group and the"
|
||||
warn "vhci-hcd module only become live for the host service on a fresh login."
|
||||
GRANT_SRC="$SRC/scripts/headless/kde-authorized"
|
||||
GRANT_DST="$HOME/.local/share/flatpak/db/kde-authorized"
|
||||
if [ ! -s "$GRANT_DST" ] && [ -s "$GRANT_SRC" ]; then
|
||||
mkdir -p "$(dirname "$GRANT_DST")"
|
||||
install -m644 "$GRANT_SRC" "$GRANT_DST"
|
||||
ok "seeded KDE RemoteDesktop grant (Desktop-mode input)"
|
||||
fi
|
||||
|
||||
log "Restarting services"
|
||||
systemctl --user restart punktfunk-host.service
|
||||
ok "punktfunk-host restarted"
|
||||
|
||||
+4
-1
@@ -89,7 +89,10 @@ A complexity ladder in [`examples/`](./examples) — start at the top:
|
||||
4. [`couch-preset.effect.ts`](./examples/couch-preset.effect.ts) — **advanced, Effect-native**: only if you're composing Effect programs.
|
||||
|
||||
Examples 1–3 are the plain Promise facade and cover most automation; you only need example 4's
|
||||
Effect surface for composed, interruptible programs. Run any with `bun examples/<file>.ts`.
|
||||
Effect surface for composed, interruptible programs. Run any **in the repo** with
|
||||
`bun examples/<file>.ts`. To **deploy** one on a host, install the package into its own directory
|
||||
(`bun add @punktfunk/host`) and change its `../src/…` import to `@punktfunk/host` — see
|
||||
[Running a single script as a service](#running-a-single-script-as-a-service).
|
||||
|
||||
Plus a real-world recipe:
|
||||
|
||||
|
||||
@@ -15,6 +15,11 @@
|
||||
// VH_DEVICE=couch-deck.11 bun examples/virtualhere-dualsense.ts # address from `-t LIST`
|
||||
// VH_DEVICE=DualSense bun examples/virtualhere-dualsense.ts # …or match by name substring
|
||||
//
|
||||
// To run this *outside* the repo (the normal case), drop it in its own dir, `bun add
|
||||
// @punktfunk/host`, and change the import below from `../src/index.js` to `@punktfunk/host`; then
|
||||
// keep it alive as a systemd user unit (its SIGTERM release hands the pad back on `systemctl
|
||||
// stop`). Full walkthrough: docs → Events & hooks → "full controller passthrough (VirtualHere)".
|
||||
//
|
||||
// Env: VH_DEVICE required — a VirtualHere address (`server.port`) or a device-name substring.
|
||||
// VH_CLIENT client binary. Default `vhclientx86_64` (Linux); Windows `vhui64.exe`,
|
||||
// macOS `vhclientosx`, ARM Linux `vhclientarm64`.
|
||||
|
||||
+340
-2
File diff suppressed because one or more lines are too long
+64
-20
@@ -61,42 +61,75 @@ export const ensurePluginsDir = (dir = pluginsDirDefault()): string => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensure `<dir>/bunfig.toml` points the `@punktfunk` scope at the registry so `bun add` resolves
|
||||
* first-party plugins. Idempotent: a file already mapping the scope is left untouched; an existing
|
||||
* bunfig with an `[install.scopes]` table gets our line inserted under it; anything else appends a
|
||||
* fresh table.
|
||||
* Ensure `<dir>/bunfig.toml` maps every scope we need to its registry, so `bun add` resolves
|
||||
* plugins from the right place. `@punktfunk` → Punktfunk's own registry is always mapped;
|
||||
* `extraScopes` adds others — a plugin-store catalog entry carries its own registry, and the scope
|
||||
* is what binds a package name to it (design D8, which is why catalog entries must be scoped).
|
||||
*
|
||||
* Idempotent and non-destructive: a scope already mapped to the same URL is left alone, a scope
|
||||
* mapped to a *different* URL is rewritten, and any unrelated bunfig content is preserved.
|
||||
*/
|
||||
export const ensureBunfig = (dir = pluginsDirDefault()): void => {
|
||||
export const ensureBunfig = (
|
||||
dir = pluginsDirDefault(),
|
||||
extraScopes: Record<string, string> = {},
|
||||
): void => {
|
||||
const file = path.join(dir, "bunfig.toml");
|
||||
const scopeLine = `"@punktfunk" = "${REGISTRY}"`;
|
||||
const wanted: Record<string, string> = { "@punktfunk": REGISTRY, ...extraScopes };
|
||||
let existing = "";
|
||||
try {
|
||||
existing = fs.readFileSync(file, "utf8");
|
||||
} catch {
|
||||
// no bunfig yet — write a fresh one below
|
||||
}
|
||||
if (existing.includes("@punktfunk") && existing.includes(REGISTRY)) return; // already wired
|
||||
|
||||
const table = `[install.scopes]\n${scopeLine}\n`;
|
||||
if (!existing.trim()) {
|
||||
fs.writeFileSync(file, table);
|
||||
} else if (/^\[install\.scopes\][^\n]*$/m.test(existing)) {
|
||||
// Insert our scope line right after the existing table header.
|
||||
let out = existing;
|
||||
const missing: string[] = [];
|
||||
for (const [scope, url] of Object.entries(wanted)) {
|
||||
// Match `"@scope" = "…"` (quoted or bare key) anywhere in the file.
|
||||
const line = new RegExp(`^\\s*"?${escapeRe(scope)}"?\\s*=\\s*".*"\\s*$`, "m");
|
||||
const replacement = `"${scope}" = "${url}"`;
|
||||
if (line.test(out)) {
|
||||
const current = out.match(line)?.[0] ?? "";
|
||||
if (current.includes(`"${url}"`)) continue; // already correct
|
||||
out = out.replace(line, replacement);
|
||||
} else {
|
||||
missing.push(replacement);
|
||||
}
|
||||
}
|
||||
if (missing.length === 0) {
|
||||
if (out !== existing) fs.writeFileSync(file, out);
|
||||
return;
|
||||
}
|
||||
const block = missing.join("\n");
|
||||
if (!out.trim()) {
|
||||
fs.writeFileSync(file, `[install.scopes]\n${block}\n`);
|
||||
} else if (/^\[install\.scopes\][^\n]*$/m.test(out)) {
|
||||
// Insert under the existing table header.
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
existing.replace(/^\[install\.scopes\][^\n]*$/m, (m) => `${m}\n${scopeLine}`),
|
||||
out.replace(/^\[install\.scopes\][^\n]*$/m, (m) => `${m}\n${block}`),
|
||||
);
|
||||
} else {
|
||||
const sep = existing.endsWith("\n") ? "" : "\n";
|
||||
fs.writeFileSync(file, `${existing}${sep}\n${table}`);
|
||||
const sep = out.endsWith("\n") ? "" : "\n";
|
||||
fs.writeFileSync(file, `${out}${sep}\n[install.scopes]\n${block}\n`);
|
||||
}
|
||||
};
|
||||
|
||||
const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
export interface PkgOpts extends ResolveOptions {
|
||||
/** Plugins dir. Default `<config_dir>/plugins`. */
|
||||
dir?: string;
|
||||
/** Line sink for progress. Default stdout. */
|
||||
log?: (line: string) => void;
|
||||
/**
|
||||
* Record the resolved version exactly (`bun add --exact`) instead of a caret range. The plugin
|
||||
* store always sets this: a catalog entry pins one reviewed version, and a caret range in
|
||||
* `package.json` would let a later `bun install` in this tree drift off it.
|
||||
*/
|
||||
exact?: boolean;
|
||||
/** Extra `scope → registry URL` mappings to write into `bunfig.toml` before installing. */
|
||||
registries?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Run `bun add`/`bun remove` in the plugins dir on the current (vendored) bun. */
|
||||
@@ -104,11 +137,21 @@ const runBun = (action: "add" | "remove", pkgs: string[], opts: PkgOpts): void =
|
||||
const dir = opts.dir ?? pluginsDirDefault();
|
||||
const log = opts.log ?? ((l: string) => console.log(l));
|
||||
ensurePluginsDir(dir);
|
||||
if (action === "add") ensureBunfig(dir);
|
||||
if (action === "add") ensureBunfig(dir, opts.registries);
|
||||
log(`${action === "add" ? "installing" : "removing"} ${pkgs.join(", ")} in ${dir}`);
|
||||
// `process.execPath` is the bun running this file (the vendored one under the package), so a
|
||||
// system-wide bun on PATH is not required. Inherit stdio so `bun`'s progress reaches the user.
|
||||
const args = [process.execPath, action, ...pkgs];
|
||||
if (action === "add") {
|
||||
// NEVER run install lifecycle scripts. A plugin is code we chose to run under the runner,
|
||||
// where it is supervised and (on Windows) de-privileged; a postinstall script runs
|
||||
// immediately, as whoever is installing — which on a console-triggered install is the host
|
||||
// service. bun already declines untrusted scripts by default; this makes it explicit and
|
||||
// unconditional. A plugin that needs a native build step is a review rejection, not a case
|
||||
// to support.
|
||||
args.push("--ignore-scripts");
|
||||
if (opts.exact) args.push("--exact");
|
||||
}
|
||||
// Windows: install file COPIES, never bun's default hardlinks. A hardlinked file's canonical
|
||||
// path resolves into the installing admin's per-user bun cache
|
||||
// (C:\Users\<admin>\.bun\install\cache\…), which the de-privileged LocalService runner cannot
|
||||
@@ -156,9 +199,10 @@ export interface InstalledPlugin {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate installed plugin packages under `<dir>/node_modules` — both the scoped first-party
|
||||
* convention (`@punktfunk/plugin-*`) and the unscoped one (`punktfunk-plugin-*`). Mirrors the
|
||||
* discovery in runner.ts so `list` shows exactly what the runner would supervise.
|
||||
* Enumerate installed plugin packages under `<dir>/node_modules` — the unscoped convention
|
||||
* (`punktfunk-plugin-*`) and **any** scope's `plugin-*` (`@punktfunk/plugin-rom-manager`,
|
||||
* `@retro-hub/plugin-x`). Mirrors the discovery in runner.ts so `list` shows exactly what the
|
||||
* runner would supervise.
|
||||
*/
|
||||
export const listInstalled = (dir = pluginsDirDefault()): InstalledPlugin[] => {
|
||||
const modules = path.join(dir, "node_modules");
|
||||
@@ -182,7 +226,7 @@ export const listInstalled = (dir = pluginsDirDefault()): InstalledPlugin[] => {
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith("punktfunk-plugin-")) {
|
||||
out.push({ pkg: entry, version: versionOf(path.join(modules, entry)) });
|
||||
} else if (entry === "@punktfunk") {
|
||||
} else if (entry.startsWith("@")) {
|
||||
let scoped: string[] = [];
|
||||
try {
|
||||
scoped = fs.readdirSync(path.join(modules, entry)).sort();
|
||||
|
||||
+48
-1
@@ -16,6 +16,11 @@
|
||||
//
|
||||
// bun src/runner-cli.ts [--scripts DIR] [--plugins DIR] [--list] (run the runner)
|
||||
// bun src/runner-cli.ts add playnite [--plugins DIR] (package ops)
|
||||
//
|
||||
// Package-op flags: --exact pins the resolved version instead of a caret range, and
|
||||
// --registry @scope=https://… maps a scope to its registry in bunfig.toml. Both exist for the
|
||||
// plugin store (crates/punktfunk-host/src/store), which installs one reviewed version of a
|
||||
// package that may live on somebody else's registry — but they are ordinary CLI flags too.
|
||||
import { Effect, Fiber } from "effect";
|
||||
import { addPlugins, listInstalled, removePlugins } from "./plugins.js";
|
||||
import { discoverUnits, runner } from "./runner.js";
|
||||
@@ -25,18 +30,56 @@ const arg = (flag: string): string | undefined => {
|
||||
return i >= 0 ? process.argv[i + 1] : undefined;
|
||||
};
|
||||
|
||||
/** Every value of a repeatable flag (`--registry a=b --registry c=d`). */
|
||||
const argAll = (flag: string): string[] => {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < process.argv.length; i++) {
|
||||
if (process.argv[i] === flag && process.argv[i + 1] !== undefined) out.push(process.argv[++i]);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/** Flags that take a value — skipped (with their value) when collecting positionals. */
|
||||
const VALUE_FLAGS = ["--plugins", "--scripts", "--registry"];
|
||||
|
||||
const options = {
|
||||
scriptsDir: arg("--scripts"),
|
||||
pluginsDir: arg("--plugins"),
|
||||
};
|
||||
|
||||
/**
|
||||
* `--registry @scope=https://registry/` → `{ "@scope": "https://registry/" }`. The plugin store
|
||||
* passes one per catalog entry so a third-party package's scope resolves to *its* registry rather
|
||||
* than the public npm default.
|
||||
*/
|
||||
const parseRegistries = (): Record<string, string> => {
|
||||
const out: Record<string, string> = {};
|
||||
for (const spec of argAll("--registry")) {
|
||||
const eq = spec.indexOf("=");
|
||||
if (eq <= 0) {
|
||||
console.error(`[plugins] ignoring malformed --registry '${spec}' (expected @scope=URL)`);
|
||||
continue;
|
||||
}
|
||||
const scope = spec.slice(0, eq).trim();
|
||||
const url = spec.slice(eq + 1).trim();
|
||||
if (!scope.startsWith("@") || !url.startsWith("https://")) {
|
||||
console.error(
|
||||
`[plugins] ignoring --registry '${spec}' (scope must start with @, url with https://)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
out[scope] = url;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// Positional plugin names after the subcommand (argv: [bun, script, <cmd>, …]). Skip flags and the
|
||||
// value of `--plugins`/`--scripts` wherever they appear, so ordering doesn't matter.
|
||||
const positionals = (): string[] => {
|
||||
const out: string[] = [];
|
||||
for (let i = 3; i < process.argv.length; i++) {
|
||||
const a = process.argv[i];
|
||||
if (a === "--plugins" || a === "--scripts") {
|
||||
if (VALUE_FLAGS.includes(a)) {
|
||||
i++; // skip its value too
|
||||
continue;
|
||||
}
|
||||
@@ -51,6 +94,10 @@ const pkgOpts = {
|
||||
// Opt-in for names that resolve on the public npm registry (supply-chain gate in
|
||||
// plugins.ts::resolvePackage). Boolean flag, so positionals() skips it on its own.
|
||||
allowPublicRegistry: process.argv.includes("--allow-public-registry"),
|
||||
// Pin the exact version in package.json instead of a caret range — the plugin store installs
|
||||
// one reviewed version and must not let a later `bun install` drift off it.
|
||||
exact: process.argv.includes("--exact"),
|
||||
registries: parseRegistries(),
|
||||
};
|
||||
|
||||
const runPkgOp = (
|
||||
|
||||
+29
-5
@@ -302,8 +302,27 @@ export const discoverUnits = (
|
||||
// no scripts dir — fine
|
||||
}
|
||||
const modules = path.join(pluginsDir, "node_modules");
|
||||
// The packages the operator actually installed — `bun add` records them as the plugins dir's
|
||||
// own `dependencies`. This is what separates a plugin from a plugin's LIBRARY:
|
||||
// `@punktfunk/plugin-kit` matches the `plugin-*` naming convention exactly but arrives as a
|
||||
// transitive dependency of every kit-built plugin, and running it as a unit is nonsense.
|
||||
// `undefined` only when there is no readable `package.json` at all — a hand-assembled tree then
|
||||
// falls back to the naming convention rather than discovering nothing. A package.json with no
|
||||
// `dependencies` key yields an EMPTY set, not `undefined`: `bun remove` drops the key when the
|
||||
// last plugin goes, and orphaned transitive packages can outlive it, so falling back there
|
||||
// would start running a plugin's library the moment you uninstall the last real plugin.
|
||||
let topLevel: Set<string> | undefined;
|
||||
try {
|
||||
const root = JSON.parse(
|
||||
fs.readFileSync(path.join(pluginsDir, "package.json"), "utf8"),
|
||||
) as { dependencies?: Record<string, string> };
|
||||
topLevel = new Set(Object.keys(root.dependencies ?? {}));
|
||||
} catch {
|
||||
// no package.json — fall back to the convention
|
||||
}
|
||||
// Read a plugin package's manifest (`module`/`main` entry) and add it as a unit.
|
||||
const addPlugin = (dir: string, name: string): void => {
|
||||
if (topLevel && !topLevel.has(name)) return; // a dependency, not an installed plugin
|
||||
try {
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(dir, "package.json"), "utf8"),
|
||||
@@ -323,10 +342,15 @@ export const discoverUnits = (
|
||||
addPlugin(path.join(modules, pkg), pkg);
|
||||
continue;
|
||||
}
|
||||
// Scoped convention: `@punktfunk/plugin-*` (first-party). A scoped name resolves cleanly
|
||||
// from a single registry scope-map, so a plugin can depend on `@punktfunk/host` + `effect`
|
||||
// as shared (hoisted) deps rather than bundling its own copy of each.
|
||||
if (pkg === "@punktfunk") {
|
||||
// Scoped convention: `<any scope>/plugin-*`. A scoped name resolves cleanly from a
|
||||
// registry scope-map, so a plugin can depend on `@punktfunk/host` + `effect` as shared
|
||||
// (hoisted) deps rather than bundling its own copy of each.
|
||||
//
|
||||
// ANY scope, not just `@punktfunk`: the plugin store requires catalog entries to be
|
||||
// scoped precisely so the scope can map to that entry's registry, so a third-party
|
||||
// plugin necessarily arrives as `@their-scope/plugin-*`. Limiting discovery to the
|
||||
// first-party scope would let such a plugin install and then never run.
|
||||
if (pkg.startsWith("@")) {
|
||||
try {
|
||||
for (const scoped of fs.readdirSync(path.join(modules, pkg)).sort()) {
|
||||
if (scoped.startsWith("plugin-")) {
|
||||
@@ -334,7 +358,7 @@ export const discoverUnits = (
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// no @punktfunk scope dir — fine
|
||||
// not a readable scope dir — fine
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +136,55 @@ describe("listInstalled", () => {
|
||||
{ pkg: "punktfunk-plugin-broken", version: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
test("finds plugins in ANY scope, not just @punktfunk", () => {
|
||||
// Plugin-store catalog entries must be scoped so the scope can map to that entry's
|
||||
// registry, so a third-party plugin necessarily arrives as `@their-scope/plugin-*`.
|
||||
// Discovery limited to @punktfunk would let it install and then never run.
|
||||
const dir = tmp("list-foreign-scope");
|
||||
writePkg(dir, path.join("@retro-hub", "plugin-x"), "1.0.0");
|
||||
writePkg(dir, path.join("@retro-hub", "helper"), "1.0.0"); // scoped non-plugin — ignored
|
||||
expect(listInstalled(dir)).toEqual([{ pkg: "@retro-hub/plugin-x", version: "1.0.0" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureBunfig with extra scopes", () => {
|
||||
const read = (dir: string) => fs.readFileSync(path.join(dir, "bunfig.toml"), "utf8");
|
||||
|
||||
test("maps a third-party scope alongside @punktfunk", () => {
|
||||
const dir = tmp("bunfig-extra");
|
||||
ensureBunfig(dir, { "@retro-hub": "https://retro.example/npm/" });
|
||||
const out = read(dir);
|
||||
expect(out).toContain(`"@punktfunk" = "${REGISTRY}"`);
|
||||
expect(out).toContain('"@retro-hub" = "https://retro.example/npm/"');
|
||||
});
|
||||
|
||||
test("is idempotent and rewrites a scope whose registry changed", () => {
|
||||
const dir = tmp("bunfig-rewrite");
|
||||
ensureBunfig(dir, { "@retro-hub": "https://old.example/npm/" });
|
||||
ensureBunfig(dir, { "@retro-hub": "https://old.example/npm/" });
|
||||
expect(read(dir).match(/@retro-hub/g)?.length).toBe(1);
|
||||
|
||||
ensureBunfig(dir, { "@retro-hub": "https://new.example/npm/" });
|
||||
const out = read(dir);
|
||||
expect(out).toContain('"@retro-hub" = "https://new.example/npm/"');
|
||||
expect(out).not.toContain("old.example");
|
||||
// the first-party mapping survives an unrelated scope edit
|
||||
expect(out).toContain(`"@punktfunk" = "${REGISTRY}"`);
|
||||
});
|
||||
|
||||
test("preserves unrelated scopes already in the file", () => {
|
||||
const dir = tmp("bunfig-preserve");
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "bunfig.toml"),
|
||||
'[install.scopes]\n"@acme" = "https://acme.example/"\n',
|
||||
);
|
||||
ensureBunfig(dir, { "@retro-hub": "https://retro.example/npm/" });
|
||||
const out = read(dir);
|
||||
expect(out).toContain('"@acme" = "https://acme.example/"');
|
||||
expect(out).toContain('"@retro-hub" = "https://retro.example/npm/"');
|
||||
expect(out).toContain(`"@punktfunk" = "${REGISTRY}"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensurePluginsDir", () => {
|
||||
|
||||
@@ -107,6 +107,83 @@ describe("discovery", () => {
|
||||
const units = discoverUnits(d);
|
||||
expect(units.map((u) => u.name)).toEqual(["@punktfunk/plugin-y"]);
|
||||
});
|
||||
|
||||
test("discovers plugins in ANY scope, not just @punktfunk", () => {
|
||||
// Plugin-store catalog entries must be scoped so the scope can map to that entry's
|
||||
// registry, so a third-party plugin necessarily arrives under its own scope. Discovery
|
||||
// limited to @punktfunk would let it install and then never run.
|
||||
const d = mkdirs("discover-foreign-scope");
|
||||
write(
|
||||
path.join(d.pluginsDir, "node_modules", "@retro-hub", "plugin-z", "package.json"),
|
||||
JSON.stringify({ name: "@retro-hub/plugin-z", main: "index.js" }),
|
||||
);
|
||||
write(
|
||||
path.join(d.pluginsDir, "node_modules", "@retro-hub", "plugin-z", "index.js"),
|
||||
"export default { name: 'z', main: async () => {} };",
|
||||
);
|
||||
expect(discoverUnits(d).map((u) => u.name)).toEqual(["@retro-hub/plugin-z"]);
|
||||
});
|
||||
|
||||
test("a plugin's LIBRARY is not a unit — top-level dependencies win", () => {
|
||||
// Regression from a live install: `@punktfunk/plugin-kit` is the framework kit-built
|
||||
// plugins depend on. It matches the `plugin-*` convention exactly and lands in
|
||||
// node_modules transitively, so a convention-only scan would import and run the framework
|
||||
// as if it were a plugin.
|
||||
const d = mkdirs("discover-toplevel");
|
||||
for (const name of ["plugin-real", "plugin-kit"]) {
|
||||
write(
|
||||
path.join(d.pluginsDir, "node_modules", "@punktfunk", name, "package.json"),
|
||||
JSON.stringify({ name: `@punktfunk/${name}`, main: "index.js" }),
|
||||
);
|
||||
write(
|
||||
path.join(d.pluginsDir, "node_modules", "@punktfunk", name, "index.js"),
|
||||
"export default { name: 'p', main: async () => {} };",
|
||||
);
|
||||
}
|
||||
write(
|
||||
path.join(d.pluginsDir, "package.json"),
|
||||
JSON.stringify({ dependencies: { "@punktfunk/plugin-real": "^1.0.0" } }),
|
||||
);
|
||||
expect(discoverUnits(d).map((u) => u.name)).toEqual(["@punktfunk/plugin-real"]);
|
||||
});
|
||||
|
||||
test("no package.json at all falls back to the naming convention", () => {
|
||||
// Hand-assembled or older trees must still run, not silently discover nothing.
|
||||
const d = mkdirs("discover-nodeps");
|
||||
write(
|
||||
path.join(d.pluginsDir, "node_modules", "punktfunk-plugin-legacy", "package.json"),
|
||||
JSON.stringify({ name: "punktfunk-plugin-legacy", main: "index.js" }),
|
||||
);
|
||||
write(
|
||||
path.join(d.pluginsDir, "node_modules", "punktfunk-plugin-legacy", "index.js"),
|
||||
"export default { name: 'l', main: async () => {} };",
|
||||
);
|
||||
expect(discoverUnits(d).map((u) => u.name)).toEqual(["punktfunk-plugin-legacy"]);
|
||||
});
|
||||
|
||||
test("an emptied dependency list means nothing to run", () => {
|
||||
// `bun remove` drops the `dependencies` key when the last plugin goes, while orphaned
|
||||
// transitive packages linger in node_modules. Falling back to the naming convention there
|
||||
// would start running a plugin's LIBRARY right after the operator removed the only real
|
||||
// plugin — seen on-glass.
|
||||
const d = mkdirs("discover-emptied");
|
||||
write(
|
||||
path.join(d.pluginsDir, "node_modules", "@punktfunk", "plugin-kit", "package.json"),
|
||||
JSON.stringify({ name: "@punktfunk/plugin-kit", main: "index.js" }),
|
||||
);
|
||||
write(
|
||||
path.join(d.pluginsDir, "node_modules", "@punktfunk", "plugin-kit", "index.js"),
|
||||
"export default {};",
|
||||
);
|
||||
write(path.join(d.pluginsDir, "package.json"), JSON.stringify({ name: "plugins" }));
|
||||
expect(discoverUnits(d)).toEqual([]);
|
||||
|
||||
write(
|
||||
path.join(d.pluginsDir, "package.json"),
|
||||
JSON.stringify({ name: "plugins", dependencies: {} }),
|
||||
);
|
||||
expect(discoverUnits(d)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("windowsSddlUnsafeReason (the sshd rule's Windows half, pure)", () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user