Compare commits

..
Author SHA1 Message Date
enricobuehler e0464e7407 docs(bazzite): warn on the user-facing page that 0.26.0-1 cannot stream the Desktop
packaging/bazzite/README.md already carries this, but that file ships inside the repo — the page a
field user actually lands on is /docs/bazzite, and it said only that the virtual output 'needs no
config'. On 0.26.0-1 that reads as a lie: the image granted the host CAP_SYS_NICE, so KWin cannot
read the /proc/<pid>/exe it identifies clients by, the shipped .desktop can never match, and every
Desktop session dies with 'KWin does not expose zkde_screencast_unstable_v1 to this client' —
looking exactly like the setup on that page was done wrong.

Says so, and gives the only repair that works: a merged sysext's /usr is read-only, so it is the
next image (punktfunk-sysext update), not a setcap -r. Notes Gaming Mode is unaffected.

Written as a bolded blockquote, the admonition style every other page uses. NOT a {{< callout >}}
shortcode — this site is Fumadocs/MDX, not Hugo, and no shortcode exists anywhere in it. Verified by
running the real build (bun install + vite build) rather than assuming: it completes clean, and the
backticked /proc/<pid>/exe follows the same pattern as the existing <pkg>/<token> in arch.md and
automation.md, which MDX leaves alone inside inline code.
2026-08-09 10:07:18 +02:00
39 changed files with 246 additions and 1510 deletions
-56
View File
@@ -344,45 +344,10 @@ jobs:
apt-get update
apt-get install -y --no-install-recommends meson ninja-build glslc git || true
apt-get build-dep -y gamescope || true
# NOT best-effort. `build-dep gamescope` resolves the distro's much older packaged
# gamescope — where noble has one at all — so it misses what the master tree needs, and
# wayland-protocols is the gap that actually stops the build: meson dies in
# protocol/meson.build with "Neither a subproject directory nor a wayland-protocols.wrap
# file was found", because the tree has no wrap fallback for it. That is what happened on
# the v0.26.0 tag: the step warned and skipped, the job stayed green, and the release
# shipped with no gamescope .deb while the notes said it had one.
apt-get install -y --no-install-recommends wayland-protocols
# The remaining Arch makedepends the older packaged gamescope does not necessarily pull.
# Best-effort: meson falls back or does without, and a name that moves between Ubuntu
# releases should not fail the job. (No libstdc++ static package is needed here — g++
# ships libstdc++.a, which is why only Fedora tripped the sanity check.)
# `build-dep gamescope` gives noble almost nothing — the distro has no comparable package
# — so the tree's real dependency set has to be named outright. One `apt-get` per name on
# purpose: a single transaction aborts wholesale on one unknown package, which would
# install NOTHING and hide the real gap behind a name typo. Best-effort per package, with
# the missing one named; the end-of-job gate below is what actually decides.
for p in libxdamage-dev libxcomposite-dev libxrender-dev libxext-dev libxxf86vm-dev \
libxtst-dev libx11-dev libxres-dev libxmu-dev libxcursor-dev libxi-dev \
libxfixes-dev libxkbcommon-dev libxkbcommon-x11-dev libcap-dev libdrm-dev \
libinput-dev libudev-dev libpipewire-0.3-dev libseat-dev libsdl2-dev \
libluajit-5.1-dev libavif-dev libdecor-0-dev hwdata libglm-dev libbenchmark-dev \
glslang-tools libvulkan-dev libwayland-dev libxcb1-dev libxcb-composite0-dev \
libxcb-xfixes0-dev libxcb-res0-dev libxcb-ewmh-dev libxcb-icccm4-dev \
libxcb-errors-dev libpixman-1-dev libdisplay-info-dev libgbm-dev libegl-dev \
cmake xwayland; do
apt-get install -y --no-install-recommends "$p" \
|| echo "::warning::no such noble package: $p (gamescope may still build without it)"
done
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
else
# Warn only, even on a tag. The hard gate moved to the END of this job: failing HERE
# skips the host .deb's own publish + release-attach steps below, which is how the
# v0.26.0 release ended up still carrying the pre-CAP_SYS_NICE host .deb from an
# earlier tag commit — a KDE-breaking artifact withheld from replacement by a gate
# meant to protect the release. Never let a missing EXTRA stop a good artifact
# shipping; go red afterwards instead.
echo "::warning::punktfunk-gamescope failed to build on noble — no .deb this run (gamescope sessions stay SDR)"
fi
@@ -392,7 +357,6 @@ jobs:
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
bash packaging/debian/build-gamescope-deb.sh --binary gs-cache/punktfunk-gamescope
else
# Warn only — see the note on the build step. The gate is the last step of this job.
echo "::warning::no usable punktfunk-gamescope — skipping its .deb"
fi
@@ -423,26 +387,6 @@ jobs:
upsert_asset "$RID" "$DEB"
done
# A release must not be able to make a claim its own CI silently dropped: v0.26.0's notes and
# docs-site said the patched gamescope was apt-installable while no .deb had ever been built,
# because every failure on this path was a `::warning::` that returned 0.
#
# ⚠ LAST step on purpose. The first version of this gate failed at the build step instead, and
# that skipped the host .deb's own publish + attach below — so the release kept the PREVIOUS
# tag commit's host .deb, which still carried the CAP_SYS_NICE postinst that breaks KDE. A
# gate protecting the release withheld the fix for it. Everything good ships first; the job
# goes red afterwards.
- name: A stable tag must ship the gamescope .deb
if: startsWith(gitea.ref, 'refs/tags/v')
run: |
shopt -s nullglob
built=(dist/punktfunk-gamescope_*.deb)
if [ ${#built[@]} -eq 0 ]; then
echo "::error::no punktfunk-gamescope .deb was built — a stable tag must not ship without it (the release notes and docs-site say it is apt-installable). Everything else in this job published normally; see the gamescope build step above for the meson error."
exit 1
fi
echo "gamescope .deb present: ${built[*]}"
# ---------------------------------------------------------------------------------------------
# The aarch64 CLIENT .deb. Cross-compiled on the ordinary amd64 runner in the
# punktfunk-rust-ci-arm64cross image (the rust-ci toolchain + an arm64 multiarch sysroot — see
-65
View File
@@ -206,26 +206,10 @@ jobs:
dnf -y install dnf-plugins-core meson ninja-build glslc || true
dnf builddep -y gamescope || true
dnf -y install xorg-x11-server-Xwayland-devel || true
# NOT best-effort: build-punktfunk-gamescope.sh appends `-static-libstdc++` to LDFLAGS
# (so the binary still starts on SteamOS's older libstdc++ — see its comment), and
# without the static library meson's very FIRST sanity check dies with
# "cannot find -lstdc++ / have you installed the static version", so nothing builds at
# all. That is what happened on the v0.26.0 tag: both Fedora bases warned and skipped,
# the job stayed green, and the release shipped with no gamescope RPM while the notes
# said it had one. A rename here must be LOUD, hence no `|| true`.
dnf -y install libstdc++-static
# The rest of the Arch package's makedepends that Fedora's older packaged gamescope does
# not necessarily pull. Best-effort: unlike the static runtime, meson finds fallbacks or
# does without, and a name that moves between Fedora releases should not fail the job.
dnf -y install wayland-protocols-devel glm-devel cmake libXcursor-devel || true
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
else
# Warn only, even on a tag — the hard gate is the LAST step of this job. Failing here
# would skip the sysext build, the sysext feed, AND the release attach below, so a
# missing gamescope would also withhold the punktfunk RPMs and the .raw images that
# built perfectly well. deb.yml learned that the expensive way on v0.26.0.
echo "::warning::punktfunk-gamescope failed to build for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
fi
@@ -243,35 +227,9 @@ jobs:
--binary gs-cache/punktfunk-gamescope \
--release "$PF_RELEASE"
else
# Warn only — see the note on the build step. The gate is the last step of this job.
echo "::warning::no usable punktfunk-gamescope for f${{ matrix.fedver }} — skipping its RPM"
fi
# A SECOND signing pass, for this package only. The main "Sign RPMs" step ran back at build
# time, long before this RPM existed — the gamescope build sits behind its own ~10-minute
# cache and deliberately runs after the host RPMs are already published. So every
# punktfunk-gamescope RPM went to the registry UNSIGNED, and the repo file we tell users to
# install carries gpgcheck=1: `dnf install punktfunk-gamescope` failed with "The package is
# not signed" on every Fedora and Nobara box. The package was in the channel the whole time
# and could not be installed from it — which is worse than absent, because the release notes
# and the docs-site both say it is there.
#
# Same fail-closed rule as the first pass: sign-rpms.sh hard-fails on refs/tags/v* if the org
# secret is missing, rather than republishing something a user's dnf will reject.
- name: Sign punktfunk-gamescope
env:
RPM_GPG_PRIVATE_KEY: ${{ secrets.RPM_GPG_PRIVATE_KEY }}
RPM_GPG_PASSPHRASE: ${{ secrets.RPM_GPG_PASSPHRASE }}
run: |
shopt -s nullglob
rpms=(dist/punktfunk-gamescope-*.rpm)
# No RPM here is the best-effort skip above, already warned about — not a signing failure.
if [ "${#rpms[@]}" -eq 0 ]; then
echo "no punktfunk-gamescope RPM to sign (see the packaging step above)"
exit 0
fi
bash packaging/rpm/sign-rpms.sh "${rpms[@]}"
- name: Publish punktfunk-gamescope to the Gitea RPM registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -352,26 +310,3 @@ jobs:
for raw in dist-sysext/*.raw; do
upsert_asset "$RID" "$raw" "$(basename "$raw" .raw).f${{ matrix.fedver }}.raw"
done
# A release must not be able to make a claim its own CI silently dropped — v0.26.0's notes
# said the patched gamescope was dnf-installable while both Fedora bases had skipped it on a
# `::warning::` (missing libstdc++-static, which the -static-libstdc++ link needs).
#
# ⚠ LAST step on purpose, matching deb.yml: failing at the build step instead would skip the
# sysext image, the feed publish AND the attach above, withholding the punktfunk RPMs and
# .raw images that built perfectly well. Everything good ships first; the job goes red after.
- name: A stable tag must ship the gamescope RPM
if: startsWith(gitea.ref, 'refs/tags/v')
run: |
shopt -s nullglob
built=(dist/punktfunk-gamescope-*.rpm)
keep=()
for r in "${built[@]}"; do
case "$r" in *debuginfo*|*debugsource*) continue;; esac
keep+=("$r")
done
if [ ${#keep[@]} -eq 0 ]; then
echo "::error::no punktfunk-gamescope RPM was built for f${{ matrix.fedver }} — a stable tag must not ship without it (the release notes and docs-site say it is installable). Everything else in this job published normally; see the gamescope build step above for the meson error."
exit 1
fi
echo "gamescope RPM present: ${keep[*]}"
+1 -1
View File
@@ -14,7 +14,7 @@ with the version table of the release you are moving to, then read **Breaking ch
## v0.26.0
52 commits since v0.25.0.
47 commits since v0.25.0.
### Versions
@@ -1,129 +0,0 @@
// "The audio output moved under us" the one signal `SessionAudio` needs to survive a device
// change, and the one piece of it that can be tested without a stream.
//
// Split out of SessionAudio deliberately. An end-to-end test of the recovery needs a live session,
// which needs a host, and punktfunk-host does not build on macOS so the wiring that matters most
// (is the observer actually installed? does the identity check let the notification through?) would
// otherwise ship unverified, and a silent failure in it costs the session ALL of its audio. On its
// own this can be pointed at the real hardware from a unit test: see AudioDeviceWatcherTests.
//
// What it does NOT own: anything with session semantics. The iOS route-change steer and the
// media-services-reset re-activation stay in SessionAudio, next to the AVAudioSession they act on.
import AVFoundation
import os
#if os(macOS)
import CoreAudio
#endif
private let log = Logger(subsystem: "io.unom.punktfunk", category: "audio")
final class AudioDeviceWatcher {
/// Why the owner is being told. Only for the log line every reason leads to the same
/// question, "is playback still on the device it should be on".
enum Reason: String {
/// An engine stopped itself because its IO hardware changed underneath it.
case engineConfiguration = "the audio hardware configuration changed"
/// The system's default output device moved (macOS).
case defaultOutputDevice = "the default output device changed"
}
/// Does this configuration change belong to an engine the session still owns? A retired engine
/// posts one last change as it is torn down, and other AVAudioEngines in the process are not
/// ours to restart.
private let isOurs: (AnyObject?) -> Bool
/// Delivered on the main queue.
private let onChange: (Reason) -> Void
private let lock = NSLock()
private var configObserver: NSObjectProtocol?
#if os(macOS)
private var defaultOutputListener: AudioObjectPropertyListenerBlock?
#endif
init(isOurs: @escaping (AnyObject?) -> Bool, onChange: @escaping (Reason) -> Void) {
self.isOurs = isOurs
self.onChange = onChange
}
deinit { stop() }
/// Idempotent.
func start() {
lock.lock()
let already = configObserver != nil
lock.unlock()
guard !already else { return }
let token = NotificationCenter.default.addObserver(
forName: .AVAudioEngineConfigurationChange, object: nil, queue: nil
) { [weak self] note in
// Posted from whatever thread the IO unit noticed on. The engine is the notification's
// object; it is only ever compared by identity, never resurrected.
let posted = note.object as AnyObject?
DispatchQueue.main.async {
guard let self, self.isOurs(posted) else { return }
self.onChange(.engineConfiguration)
}
}
lock.lock()
configObserver = token
lock.unlock()
#if os(macOS)
// The engine notification is the direct signal, but it is delivered BY an engine useless
// in the two places it is needed most: after a rebuild that could not start (no engine left
// to notify anyone) and on an engine topology whose notification behaviour is unverified
// (the voice-processing engine, which is the DEFAULT macOS configuration and which no Mac
// here can even initialize). The HAL is told either way.
let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in
self?.onChange(.defaultOutputDevice) // on the main queue registered against it below
}
var address = Self.defaultOutputAddress()
let status = AudioObjectAddPropertyListenerBlock(
AudioObjectID(kAudioObjectSystemObject), &address, DispatchQueue.main, block)
guard status == noErr else {
log.warning("""
could not watch the default output device (\(status)) an output device change \
mid-stream may need a reconnect
""")
return
}
lock.lock()
defaultOutputListener = block
lock.unlock()
#endif
}
/// Idempotent, and safe from any thread. After it returns, no further `onChange` is delivered
/// except one already in flight on the main queue which the owner's own stopped-flag catches.
func stop() {
lock.lock()
let token = configObserver
configObserver = nil
#if os(macOS)
let listener = defaultOutputListener
defaultOutputListener = nil
#endif
lock.unlock()
if let token { NotificationCenter.default.removeObserver(token) }
#if os(macOS)
guard let listener else { return }
var address = Self.defaultOutputAddress()
AudioObjectRemovePropertyListenerBlock(
AudioObjectID(kAudioObjectSystemObject), &address, DispatchQueue.main, listener)
#endif
}
#if os(macOS)
/// Freshly built per call rather than held in a mutable static: the HAL takes the address
/// `inout` and copies it, so there is nothing to share and a shared one would only be a
/// mutable global.
private static func defaultOutputAddress() -> AudioObjectPropertyAddress {
AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
}
#endif
}
@@ -43,21 +43,8 @@ public enum AudioDevices {
}
private static func defaultInputDevice() -> AudioDeviceID? {
systemDevice(kAudioHardwarePropertyDefaultInputDevice)
}
/// The device the system is currently playing to what an engine with no pinned speaker UID
/// follows, and so what `SessionAudio` compares its live output device against when the
/// default moves (AirPods in or out, a headset unplugged).
static func defaultOutputDevice() -> AudioDeviceID? {
systemDevice(kAudioHardwarePropertyDefaultOutputDevice)
}
private static func systemDevice(
_ selector: AudioObjectPropertySelector
) -> AudioDeviceID? {
var address = AudioObjectPropertyAddress(
mSelector: selector,
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var dev = AudioDeviceID(0)
@@ -21,10 +21,6 @@
//
// Devices are chosen by UID ("" = system default: the engine is then never pinned to a
// concrete device and follows default-device changes).
//
// Surviving the hardware. An AVAudioEngine does NOT follow the audio hardware: when the output
// device changes underneath a running engine, the engine stops itself and stays stopped. The
// session therefore watches for that and rebuilds its engines see "Device changes" below.
import AVFoundation
import os
@@ -83,49 +79,14 @@ public final class SessionAudio {
/// session's activate.
private static let sessionQueue = DispatchQueue(label: "io.unom.punktfunk.audio.session")
#endif
#if !os(macOS)
/// Token for the route-change observer: it revives an engine the route change stopped, and on
/// iOS re-applies the earpiece steer (see `installRouteObserver`). Guarded by `stateLock`.
#if os(iOS)
/// Live only for a `.playAndRecord` session: the token for the route-change observer that
/// keeps the BUILT-IN output on the speaker rather than the earpiece (see
/// `steerBuiltInOutputToSpeaker`). A `.playback` session already prefers the speaker and
/// never needs steering, so the mic-off path installs nothing. Guarded by `stateLock`.
private var routeObserver: NSObjectProtocol?
/// Token for the media-services-reset observer the audio server restarting takes the
/// session's configuration and every engine with it. Guarded by `stateLock`.
private var mediaResetObserver: NSObjectProtocol?
#endif
// MARK: - Device changes (see `installDeviceChangeRecovery`)
/// What `start()` was asked for, so a rebuild can put back the SAME topology the session was
/// started with. Main-thread confined, like the start paths that read it.
private var startConfig: StartConfig?
private struct StartConfig {
let speakerUID: String
let micUID: String
let micChannel: Int
let micEnabled: Bool
let echoCancel: Bool
}
/// Watches the hardware for us (see `AudioDeviceWatcher`). Guarded by `stateLock`.
private var deviceWatcher: AudioDeviceWatcher?
/// Whether the engines have been built at least once. Distinguishes "not started yet" (iOS
/// starts asynchronously) from "started and dead", which is what the recovery may act on.
/// Main-thread confined.
private var enginesAttempted = false
/// A rebuild is already on the main queue one device switch produces a burst of triggers
/// and they must collapse into one restart. Main-thread confined.
private var rebuildQueued = false
/// `systemUptime` of the last rebuild, so a device that renegotiates in a loop cannot spin
/// the session. Main-thread confined.
private var lastRebuildAt: TimeInterval = 0
/// Let the burst of triggers from one switch land before rebuilding.
private static let rebuildDebounce: TimeInterval = 0.15
/// Floor between two rebuilds.
private static let rebuildFloor: TimeInterval = 0.5
/// Retries when a rebuild's `start()` loses the race with a device that is still going away
/// (0.3 s, 0.6 s, 1.2 s). A failed rebuild leaves no engine to post the next notification,
/// so this ladder and, on macOS, the HAL listener is all that stands between a mistimed
/// switch and a silent session.
private static let rebuildAttempts = 3
public init(connection: PunktfunkConnection) {
self.connection = connection
}
@@ -135,14 +96,10 @@ public final class SessionAudio {
/// Engine teardown still belongs to stop().
deinit {
flag.stop()
// The observers only hold self weakly, so we can be deinited with them still registered;
// drop them here too rather than leaking them when an owner skips stop().
deviceWatcher?.stop()
#if !os(macOS)
#if os(iOS)
// The observer only holds self weakly, so we can be deinited with it still registered;
// drop the token here too rather than leaking it when an owner skips stop().
if let routeObserver { NotificationCenter.default.removeObserver(routeObserver) }
if let mediaResetObserver {
NotificationCenter.default.removeObserver(mediaResetObserver)
}
#endif
}
@@ -163,12 +120,6 @@ public final class SessionAudio {
videoLatency: LatencyMeter? = nil
) {
self.videoLatency = videoLatency
// Before any engine exists: the recovery watches the hardware, not the engines, and the
// config it rebuilds from has to be recorded whether or not this start succeeds.
startConfig = StartConfig(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
micEnabled: micEnabled, echoCancel: echoCancel)
installDeviceChangeRecovery(micEnabled: micEnabled)
#if os(macOS)
// No AVAudioSession on macOS start the engines directly (caller's thread, as before).
startEngines(
@@ -238,10 +189,10 @@ public final class SessionAudio {
#if os(iOS)
// Only the `.playAndRecord` session can land on the earpiece, and only it accepts an
// output override so the mic-off (`.playback`) path deliberately does neither.
// (The route OBSERVER that re-applies this per route is installed by
// `installDeviceChangeRecovery`, for every session a `.playback` session steers
// nothing but still has engines a route change can stop.)
if micEnabled { steerBuiltInOutputToSpeaker(session) }
if micEnabled {
steerBuiltInOutputToSpeaker(session)
installRouteObserver()
}
#endif
} catch {
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
@@ -269,20 +220,11 @@ public final class SessionAudio {
}
}
#endif
#if !os(macOS)
/// Routes change under a live session: a headset connects mid-stream, or disconnects and hands
/// the stream back to the built-in output. Two things follow from that.
///
/// iOS drops an output override whenever the route changes which is what lets a newly-
/// connected headset win so the earpiece steer is a property of the CURRENT route and has to
/// be re-applied per route. Without it, dropping Bluetooth mid-stream lands the game on the
/// earpiece.
///
/// And on every platform a route change can take the engines down with it (see
/// `installDeviceChangeRecovery`), which is why this is installed for `.playback` sessions and
/// on tvOS too, where there is no earpiece to steer away from.
/// the stream back to the built-in output. iOS drops an output override whenever the route
/// changes which is what lets a newly-connected headset win so the earpiece steer is a
/// property of the CURRENT route and has to be re-applied per route. Without this, dropping
/// Bluetooth mid-stream would land the game on the earpiece.
private func installRouteObserver() {
let observer = NotificationCenter.default.addObserver(
forName: AVAudioSession.routeChangeNotification,
@@ -293,10 +235,7 @@ public final class SessionAudio {
// other call into it.
SessionAudio.sessionQueue.async {
guard let self, !self.flag.isStopped else { return }
#if os(iOS)
self.steerBuiltInOutputToSpeaker(AVAudioSession.sharedInstance())
#endif
DispatchQueue.main.async { self.reviveStoppedEngines("the audio route changed") }
}
}
stateLock.lock()
@@ -313,7 +252,6 @@ public final class SessionAudio {
private func startEngines(
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool
) {
enginesAttempted = true // even if every path below fails see `reviveStoppedEngines`
#if os(tvOS)
// No app-accessible microphone input on tvOS playback only.
startPlayback(speakerUID: speakerUID)
@@ -387,27 +325,33 @@ public final class SessionAudio {
public func stop() {
flag.stop() // before taking the engines see stateLock's comment
stateLock.lock()
let capture = captureEngine
captureEngine = nil
let playback = playbackEngine
playbackEngine = nil
let combined = combinedEngine
combinedEngine = nil
let wasDraining = drainStarted
drainStarted = false
let watcher = deviceWatcher
deviceWatcher = nil
#if !os(macOS)
#if os(iOS)
let route = routeObserver
routeObserver = nil
let mediaReset = mediaResetObserver
mediaResetObserver = nil
#endif
stateLock.unlock()
// Every watcher goes before the engines do: a device change landing during teardown must
// not schedule a rebuild of a session we are in the middle of releasing. (`flag` already
// guards that, but not arming the trigger is better than catching it.) On iOS this is
// also ahead of the deactivate below, so a route change cannot re-steer a dying session.
watcher?.stop()
#if !os(macOS)
#if os(iOS)
// Before the deactivate below, so a route change during teardown can't re-steer a session
// we are in the middle of releasing.
if let route { NotificationCenter.default.removeObserver(route) }
if let mediaReset { NotificationCenter.default.removeObserver(mediaReset) }
#endif
tearDownEngines()
if let capture {
capture.inputNode.removeTap(onBus: 0)
capture.stop()
}
playback?.stop()
if let combined {
combined.inputNode.removeTap(onBus: 0)
combined.stop()
}
#if !os(macOS)
// Release the session so audio we interrupted (Music, podcasts) gets its resume cue. Like
// activation, setActive is synchronous/blocking run it on the shared serial session queue
@@ -428,234 +372,6 @@ public final class SessionAudio {
}
}
/// Stop and release every engine we own, leaving the ring, the drain thread, the observers and
/// the audio session alone the teardown half shared by `stop()` and a rebuild. Safe from any
/// thread; the engines are taken under the lock before any of them is touched.
private func tearDownEngines() {
stateLock.lock()
let capture = captureEngine
captureEngine = nil
let playback = playbackEngine
playbackEngine = nil
let combined = combinedEngine
combinedEngine = nil
stateLock.unlock()
if let capture {
capture.inputNode.removeTap(onBus: 0)
capture.stop()
}
playback?.stop()
if let combined {
combined.inputNode.removeTap(onBus: 0)
combined.stop()
}
}
// MARK: - Device changes
/// An AVAudioEngine does not follow the audio hardware. When the output device changes under a
/// running engine AirPods taken out of an ear, a headset unplugged, the default switched in
/// System Settings the engine's IO unit sees the new hardware, THE ENGINE STOPS ITSELF, and
/// it posts `AVAudioEngineConfigurationChange`. It stays stopped until somebody starts it
/// again. Nothing here ever did, so from that moment the session rendered silence: no audio on
/// the speakers the stream had just moved to, and none in the AirPods when they went back in
/// (that is a second stop, not a recovery), until the whole stream was restarted. Measured on
/// this exact topology: render callbacks go from ~94/s to zero the instant the default output
/// device changes, and both restarting the same engine and building a fresh one resume them.
///
/// Three triggers feed one rebuild, because no single one of them covers the ground:
///
/// - the engine notification, everywhere the direct signal, but only an engine that still
/// EXISTS can post it, so it cannot report a rebuild that failed to start;
/// - the HAL default-output-device listener, macOS independent of any engine and of the
/// engine's topology. It is what makes the recovery work for the voice-processing engine
/// (mic + echo cancellation, the DEFAULT macOS configuration) without having to assume that
/// a VPIO engine posts the notification the plain one demonstrably does;
/// - the route-change and media-services-reset notifications, iOS/tvOS, where the session and
/// not the device is what moves.
///
/// `micEnabled` only decides whether the mic-bearing session observers are worth installing.
/// Main thread.
private func installDeviceChangeRecovery(micEnabled: Bool) {
stateLock.lock()
let already = deviceWatcher != nil
stateLock.unlock()
guard !already else { return } // a second start() on one SessionAudio: keep the first set
let watcher = AudioDeviceWatcher(
isOurs: { [weak self] posted in self?.ownsEngine(posted) ?? false },
onChange: { [weak self] reason in self?.hardwareMoved(reason) })
stateLock.lock()
deviceWatcher = watcher
stateLock.unlock()
watcher.start()
#if !os(macOS)
installRouteObserver()
installMediaResetObserver(micEnabled: micEnabled)
#endif
}
/// Is `posted` one of the engines this session currently owns? A retired engine posts one last
/// configuration change as it is torn down, and another AVAudioEngine in the process is none of
/// our business identity only, the object is never resurrected.
private func ownsEngine(_ posted: AnyObject?) -> Bool {
stateLock.lock()
defer { stateLock.unlock() }
return posted === playbackEngine || posted === captureEngine || posted === combinedEngine
}
/// The hardware moved (main queue, from `AudioDeviceWatcher`). Both reasons ask the same
/// question is playback still where it should be but they answer it differently: an engine
/// that told us it stopped is definitive, while the default device moving might not concern us
/// at all.
private func hardwareMoved(_ reason: AudioDeviceWatcher.Reason) {
guard !flag.isStopped else { return }
switch reason {
case .engineConfiguration:
scheduleEngineRebuild(reason: reason.rawValue)
case .defaultOutputDevice:
#if os(macOS)
defaultOutputChanged()
#else
break // the watcher only raises this one on macOS
#endif
}
}
/// Restart the engines if and only if playback is down. The conservative trigger: it is
/// what a route change (iOS/tvOS) and the macOS backstop get to do, since a HEALTHY engine
/// that followed the change on its own must not be interrupted for it.
///
/// Gated on a start having been ATTEMPTED rather than on an engine existing, which is the
/// difference between recovering a session whose very first `startPlayback` failed no
/// output device at the moment it connected and leaving it silent for good. On iOS the same
/// flag keeps this from racing the asynchronous start, where no engine yet is normal.
private func reviveStoppedEngines(_ reason: String) {
guard !flag.isStopped, enginesAttempted, !playbackIsLive else { return }
scheduleEngineRebuild(reason: "playback is stopped and \(reason)")
}
/// Is the render side actually running? Both engines can carry it (`combinedEngine` when the
/// voice processor is engaged, `playbackEngine` otherwise). Taken out from under `stateLock`
/// before asking AVAudioEngine anything the lock guards our handles, not the framework.
private var playbackIsLive: Bool {
stateLock.lock()
let playback = playbackEngine
let combined = combinedEngine
stateLock.unlock()
return (playback?.isRunning ?? false) || (combined?.isRunning ?? false)
}
/// Coalesce: one device switch produces a burst the old device leaving, the default moving,
/// the new device settling, and each engine we own posting its own change and one rebuild
/// serves all of it. The floor between rebuilds keeps a device that renegotiates in a loop
/// from spinning the session. Main thread.
private func scheduleEngineRebuild(reason: String) {
guard !rebuildQueued else { return }
rebuildQueued = true
let since = ProcessInfo.processInfo.systemUptime - lastRebuildAt
let delay = max(Self.rebuildDebounce, Self.rebuildFloor - since)
log.info("\(reason) — restarting the audio engines in \(Int(delay * 1000)) ms")
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.rebuildEngines(attempt: 0)
}
}
/// Put back the topology this session was started with, on whatever hardware is there now.
///
/// A full rebuild rather than a `start()` on the stopped engine, because the mic side has to
/// follow too: `installMicTap` reads the input's live format, and the voice processor
/// renegotiates its own. The RING is deliberately not touched it is the one thing carried
/// across (`makePlaybackChain` reuses it, `startDrain` is idempotent), so the drain thread
/// keeps decoding right through the switch and its overflow policy has already dropped
/// everything that went stale while the engine was down.
private func rebuildEngines(attempt: Int) {
rebuildQueued = false
guard !flag.isStopped, let config = startConfig else { return }
lastRebuildAt = ProcessInfo.processInfo.systemUptime
tearDownEngines()
startEngines(
speakerUID: config.speakerUID, micUID: config.micUID, micChannel: config.micChannel,
micEnabled: config.micEnabled, echoCancel: config.echoCancel)
// Did playback actually come back? A device caught mid-transition can refuse to start, and
// a rebuild that fails leaves no engine to post the next notification so this is the one
// path that must not just give up. (`startEngines` has logged the reason already.)
if playbackIsLive {
log.info("audio engines restarted on the current device")
return
}
guard attempt < Self.rebuildAttempts else {
#if os(macOS)
log.error("""
audio did not come back after the device change the default-output watcher will \
try again when a device appears
""")
#else
log.error("audio did not come back after the route change")
#endif
return
}
rebuildQueued = true // holds off a trigger that would only race this ladder
let delay = Self.rebuildDebounce * Double(1 << (attempt + 1))
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.rebuildEngines(attempt: attempt + 1)
}
}
#if os(macOS)
/// The system's output device moved. Rebuild only when it actually concerns this session: the
/// engine is gone or stopped, or it is playing to a device that is no longer the one we should
/// be on. Somebody changing the default while we are pinned to a named speaker is none of our
/// business, and rebuilding for it would cost an audible gap for nothing. Main queue (the
/// listener block is registered against it).
private func defaultOutputChanged() {
guard !flag.isStopped, let config = startConfig else { return }
stateLock.lock()
let engine = combinedEngine ?? playbackEngine
stateLock.unlock()
guard let engine, engine.isRunning, let unit = engine.outputNode.audioUnit,
let playingOn = Self.currentDevice(of: unit)
else {
// Nothing is playing. If an engine was expected at all, this is the backstop firing.
reviveStoppedEngines("the default output device moved")
return
}
// Empty UID = follow the system default; a pinned UID only moves if that device itself
// came or went, which `deviceID(forUID:)` reports by resolving to a different ID or none.
let shouldBeOn = config.speakerUID.isEmpty
? AudioDevices.defaultOutputDevice()
: AudioDevices.deviceID(forUID: config.speakerUID)
guard let shouldBeOn, shouldBeOn != playingOn else { return }
scheduleEngineRebuild(reason: "the output device changed under the session")
}
#endif
#if !os(macOS)
/// The audio server can die and restart. It takes the session's configuration and every engine
/// with it, and the documented recovery is to build all of it again the same rebuild a route
/// change uses, with the session activation back in front of it.
private func installMediaResetObserver(micEnabled: Bool) {
let observer = NotificationCenter.default.addObserver(
forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: nil
) { [weak self] _ in
SessionAudio.sessionQueue.async {
guard let self, !self.flag.isStopped else { return }
self.activateAudioSession(micEnabled: micEnabled)
DispatchQueue.main.async {
self.scheduleEngineRebuild(reason: "the audio services were reset")
}
}
}
stateLock.lock()
let stale = mediaResetObserver
mediaResetObserver = observer
stateLock.unlock()
if let stale { NotificationCenter.default.removeObserver(stale) }
}
#endif
/// Silence the mic uplink (no room audio leaves the device) or restore it. THE one muting
/// mechanism: the owner composes its reasons the user's in-stream mute and the background
/// keep-alive's privacy mute into one effective state and passes that here, so neither can
@@ -721,21 +437,6 @@ public final class SessionAudio {
return Stats(bufferMS: s.bufferedMS, avOffsetMS: s.avOffsetMS)
}
#if os(macOS)
/// Whether playback is rendering, and the device it is rendering to. The device-change
/// recovery has exactly one observable signature from outside "running again, on the device
/// the system just moved to" and nothing else here could tell the two halves apart: a
/// stopped engine can still name the old device, and a retargeted one can still be stopped.
/// Used by `AudioDeviceSwitchTests`.
var playbackState: (running: Bool, device: AudioDeviceID?) {
stateLock.lock()
let engine = combinedEngine ?? playbackEngine
stateLock.unlock()
guard let engine else { return (false, nil) }
return (engine.isRunning, engine.outputNode.audioUnit.flatMap(Self.currentDevice(of:)))
}
#endif
// MARK: - Playback (host speaker)
/// The playback jitter ring + the source node draining it shared by the plain playback
@@ -1,102 +0,0 @@
// The device-switch regression, end to end against a real session.
//
// An AVAudioEngine does not follow the audio hardware: when the output device changes under a
// running engine it STOPS ITSELF and stays stopped. Nothing restarted it, so a stream whose
// output moved mid-session AirPods taken out of an ear, a headset unplugged, the default
// changed in System Settings played silence from that moment on: nothing on the speakers the
// system had just moved to, and nothing in the AirPods when they went back in, since that is a
// second stop rather than a recovery. Only restarting the whole stream brought audio back.
//
// This drives the real `SessionAudio` against the loopback host and moves the system's default
// output device out from under it, twice out and back, the exact shape of the field report.
// Playback-only (mic off): it is the render side that died, and a mic would drag the microphone
// permission and the voice processor into a test that is about neither.
//
// Driven by clients/apple/test-loopback.sh, like its LoopbackIntegrationTests siblings.
#if os(macOS)
import AVFoundation
import CoreAudio
import XCTest
@testable import PunktfunkKit
final class AudioDeviceSwitchTests: XCTestCase {
/// Set the system default output device. Test-local on purpose: nothing in the app ever
/// changes the user's device, it only follows it.
private func setDefaultOutput(_ id: AudioDeviceID) -> OSStatus {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var dev = id
return AudioObjectSetPropertyData(
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil,
UInt32(MemoryLayout<AudioDeviceID>.size), &dev)
}
/// Pump the MAIN runloop until playback is running on `device`, or the deadline passes. The
/// recovery lands on the main queue (a debounced hop, then possibly a retry ladder), so a
/// sleeping test would block the very thing it is waiting for.
private func waitForPlayback(
_ audio: SessionAudio, on device: AudioDeviceID, timeout: TimeInterval
) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
let state = audio.playbackState
if state.running, state.device == device { return true }
}
return false
}
func testPlaybackFollowsAnOutputDeviceChange() throws {
guard let portStr = ProcessInfo.processInfo.environment["PUNKTFUNK_LOOPBACK_PORT"],
let port = UInt16(portStr)
else {
throw XCTSkip("needs a running punktfunk1-host — use clients/apple/test-loopback.sh")
}
guard let original = AudioDevices.defaultOutputDevice() else {
throw XCTSkip("no default output device")
}
let others = AudioDevices.outputs()
.compactMap { AudioDevices.deviceID(forUID: $0.uid) }
.filter { $0 != original }
guard let target = others.first else {
throw XCTSkip("needs a second output device to switch to")
}
let conn = try PunktfunkConnection(
host: "127.0.0.1", port: port, width: 1280, height: 720, refreshHz: 60,
bitrateKbps: 50_000)
let audio = SessionAudio(connection: conn)
// "" speaker UID = follow the system default, which is what the report was running and
// the only configuration a default-device change is supposed to move.
audio.start(
speakerUID: "", micUID: "", micChannel: 0, micEnabled: false, echoCancel: false)
defer {
audio.stop()
_ = setDefaultOutput(original)
}
XCTAssertTrue(
waitForPlayback(audio, on: original, timeout: 5),
"playback never started on the current default output device")
// Out: the device the stream was playing to goes away underneath it.
XCTAssertEqual(setDefaultOutput(target), noErr)
XCTAssertTrue(
waitForPlayback(audio, on: target, timeout: 10),
"playback did not come back after the output device changed — this is the field "
+ "report: no sound on the device the system moved to, until the stream is "
+ "restarted")
// And back: the second half of the report, where putting the AirPods back in produced a
// second stop rather than a recovery.
XCTAssertEqual(setDefaultOutput(original), noErr)
XCTAssertTrue(
waitForPlayback(audio, on: original, timeout: 10),
"playback did not come back after the output device changed back")
}
}
#endif
@@ -1,121 +0,0 @@
// The trigger half of surviving a device change: does the session actually get TOLD?
//
// An AVAudioEngine stops itself when its output hardware changes and never restarts on its own, so
// everything downstream of these notifications is dead code if the notification never arrives. The
// rebuild itself needs a live session to exercise (and so a host, which does not build on macOS),
// but the wiring does not and the wiring is where a silent failure costs a session all of its
// audio, which is exactly the shape of the bug this watcher exists to fix.
import AVFoundation
import XCTest
#if os(macOS)
import CoreAudio
#endif
@testable import PunktfunkKit
final class AudioDeviceWatcherTests: XCTestCase {
/// The callbacks land on the main queue, so a test that slept would block the thing it waits
/// for. Pumps until `predicate` holds or the deadline passes.
private func pump(until predicate: () -> Bool, timeout: TimeInterval = 2) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if predicate() { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.02))
}
return predicate()
}
/// The identity gate is the one line that could swallow every notification silently: get it
/// wrong and the recovery compiles, installs, runs and never fires.
func testAConfigurationChangeFromOurEngineReachesTheOwner() {
let engine = AVAudioEngine()
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(
isOurs: { $0 === engine }, onChange: { reasons.append($0) })
watcher.start()
defer { watcher.stop() }
NotificationCenter.default.post(
name: .AVAudioEngineConfigurationChange, object: engine)
XCTAssertTrue(
pump(until: { reasons.contains(.engineConfiguration) }),
"the session was never told its engine's configuration changed")
}
/// A retired engine posts one last change as it is torn down, and other AVAudioEngines in the
/// process are not ours to restart rebuilding for either would interrupt healthy playback.
func testAConfigurationChangeFromAForeignEngineIsIgnored() {
let ours = AVAudioEngine()
let stranger = AVAudioEngine()
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(
isOurs: { $0 === ours }, onChange: { reasons.append($0) })
watcher.start()
defer { watcher.stop() }
NotificationCenter.default.post(
name: .AVAudioEngineConfigurationChange, object: stranger)
// Give it the same grace the positive case gets, then require silence.
_ = pump(until: { !reasons.isEmpty }, timeout: 0.5)
XCTAssertTrue(reasons.isEmpty, "a foreign engine's change was taken for ours")
}
func testStopSilencesTheWatcher() {
let engine = AVAudioEngine()
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(
isOurs: { $0 === engine }, onChange: { reasons.append($0) })
watcher.start()
watcher.stop()
NotificationCenter.default.post(
name: .AVAudioEngineConfigurationChange, object: engine)
_ = pump(until: { !reasons.isEmpty }, timeout: 0.5)
XCTAssertTrue(reasons.isEmpty, "a stopped watcher still reported")
}
#if os(macOS)
/// The backstop, against the real HAL: move the system's default output device the thing that
/// happens when AirPods come out of an ear and require that the session hears about it. This
/// is the trigger the recovery leans on for the voice-processing engine, whose own notification
/// behaviour cannot be verified here (no Mac in this project's fleet can initialize VPIO).
func testTheDefaultOutputDeviceMovingReachesTheOwner() throws {
guard let original = AudioDevices.defaultOutputDevice() else {
throw XCTSkip("no default output device")
}
let others = AudioDevices.outputs()
.compactMap { AudioDevices.deviceID(forUID: $0.uid) }
.filter { $0 != original }
guard let target = others.first else {
throw XCTSkip("needs a second output device to switch to")
}
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(isOurs: { _ in false }, onChange: { reasons.append($0) })
watcher.start()
defer {
_ = Self.setDefaultOutput(original)
watcher.stop()
}
XCTAssertEqual(Self.setDefaultOutput(target), noErr)
XCTAssertTrue(
pump(until: { reasons.contains(.defaultOutputDevice) }, timeout: 5),
"the session was never told the default output device moved")
}
/// Test-local on purpose: nothing in the app ever changes the user's device, it only follows it.
private static func setDefaultOutput(_ id: AudioDeviceID) -> OSStatus {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var dev = id
return AudioObjectSetPropertyData(
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil,
UInt32(MemoryLayout<AudioDeviceID>.size), &dev)
}
#endif
}
+2 -5
View File
@@ -26,11 +26,8 @@ mkdir -p "$CFG/open" "$CFG/paired" "$CFG/guess"
trap 'kill "${HOST_PID:-}" "${PAIR_PID:-}" "${GUESS_PID:-}" 2>/dev/null || true' EXIT
# The open host also scripts a feedback burst (rumble + DualSense hidout) right after the
# handshake, so the Swift test can assert the host→client feedback planes end to end.
# The open host outlives the others on purpose: AudioDeviceSwitchTests connects to it and then
# spends tens of seconds moving the system's output device around, long after the 300 frames the
# round-trip test needs.
HOME="$CFG/open" XDG_CONFIG_HOME="$CFG/open/.config" PUNKTFUNK_TEST_FEEDBACK=1 \
target/release/punktfunk-host punktfunk1-host --port "$PORT" --source synthetic --frames 12000 \
target/release/punktfunk-host punktfunk1-host --port "$PORT" --source synthetic --frames 300 \
--allow-tofu &
HOST_PID=$!
HOME="$CFG/paired" XDG_CONFIG_HOME="$CFG/paired/.config" \
@@ -64,4 +61,4 @@ cd clients/apple
PUNKTFUNK_LOOPBACK_PORT="$PORT" PUNKTFUNK_PAIRING_PORT="$PAIR_PORT" PUNKTFUNK_PAIRING_PIN="$PIN" \
PUNKTFUNK_GUESS_PORT="$GUESS_PORT" PUNKTFUNK_GUESS_PIN="$GUESS_PIN" \
PUNKTFUNK_TEST_FEEDBACK=1 \
swift test --filter 'LoopbackIntegrationTests|AudioDeviceSwitchTests'
swift test --filter LoopbackIntegrationTests
+5 -8
View File
@@ -4,7 +4,6 @@ use super::pw_cursor::{composite_cursor, update_cursor_meta, CursorState};
use super::pw_pods::{
build_cursor_meta_param, build_default_format_obj, build_dmabuf_buffers, build_dmabuf_format,
build_hdr_dmabuf_format, build_mappable_buffers, build_shm_only_buffers, serialize_pod,
HDR_FORMAT_ORDER,
};
use super::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat, ZeroCopyPolicy};
use anyhow::{Context, Result};
@@ -1851,15 +1850,13 @@ pub fn pipewire_thread(
// negotiation-timeout path latches the process-wide SDR downgrade if nothing matches.
let format_pods: Vec<Vec<u8>> = if want_hdr {
tracing::info!(
"HDR capture: offering xBGR_210LE/xRGB_210LE LINEAR dmabufs with MANDATORY \
"HDR capture: offering xRGB_210LE/xBGR_210LE LINEAR dmabufs with MANDATORY \
BT.2020 + SMPTE-2084 (PQ) colorimetry (GNOME 50+ monitor stream)"
);
// ⚠ Order is the whole fix — see the NVIDIA note on `HDR_FORMAT_ORDER`. The first
// compatible consumer pod wins, so this is what a gamescope session actually lands on.
HDR_FORMAT_ORDER
.iter()
.map(|fmt| build_hdr_dmabuf_format(*fmt, preferred))
.collect::<Result<Vec<_>>>()?
vec![
build_hdr_dmabuf_format(VideoFormat::xRGB_210LE, preferred)?,
build_hdr_dmabuf_format(VideoFormat::xBGR_210LE, preferred)?,
]
} else if want_dmabuf {
let mut pods = Vec::with_capacity(if prefer_native_nv12 { 2 } else { 1 });
if prefer_native_nv12 {
-65
View File
@@ -121,38 +121,6 @@ pub(super) fn build_dmabuf_format(
/// SDR — the same outcome as not offering HDR.
const SPA_VIDEO_TRANSFER_SMPTE2084: u32 = 14;
/// The two 10-bit PQ formats an HDR session offers, **in negotiation order**. The order is not a
/// style choice — on NVIDIA it is the difference between correct colour and red/blue swapped.
///
/// `xBGR_210LE` (DRM `XBGR2101010`, Vulkan `A2B10G10R10_UNORM_PACK32`) comes FIRST because the
/// first compatible consumer pod wins, and it is the only one gamescope fills correctly on every
/// vendor:
///
/// * `A2R10G10B10_UNORM_PACK32` **linear-tiled storage** is an optional Vulkan feature that
/// NVIDIA does not implement. gamescope's capture textures are mappable, hence linear, so on
/// NVIDIA its composite `imageStore` into that image lands in XBGR order — the bytes come out
/// byte-reversed while the buffer is still LABELLED `XRGB2101010`.
/// * The host believes the label: `xRGB_210LE → PixelFormat::X2Rgb10 →`
/// `NV_ENC_BUFFER_FORMAT_ARGB10`. Every mapping in that chain is individually correct, which is
/// exactly why the bug is invisible from this side — the *content* is what's wrong.
/// * Upstream gamescope hit the same wall and fixed it with `vulkan_get_rgb10_capture_format()`,
/// which probes `linearTilingFeatures` for STORAGE+SAMPLED and falls back to `XBGR2101010`.
/// That landed AFTER 3.16.25, so the pinned `punktfunk-gamescope` (3.16.25-7-g60561e2 +pfhdr4)
/// predates it and cannot self-correct — hence fixing the preference host-side, where it ships
/// in the host binary with no gamescope rebuild.
///
/// Preferring xBGR costs nothing anywhere else: `A2B10G10R10_UNORM_PACK32` is the universally
/// supported packed-10 format (it is the standard HDR10 swapchain format), it is what upstream
/// falls back to, and `X2Bgr10` has a first-class encoder path (NVENC `ABGR10`, VAAPI
/// `X2BGR10LE`). `xRGB_210LE` stays as the second pod so a producer that somehow offers only it
/// can still negotiate HDR rather than falling off to the SDR downgrade.
///
/// ⚠ The real fix belongs upstream in the patch set: `spa_format_to_drm()` should offer only the
/// format `vulkan_get_rgb10_capture_format()` reports. Until the gamescope pin moves past that
/// commit, THIS ORDER is what keeps NVIDIA HDR sessions correct — do not "tidy" it.
pub(super) const HDR_FORMAT_ORDER: [VideoFormat; 2] =
[VideoFormat::xBGR_210LE, VideoFormat::xRGB_210LE];
pub(super) fn build_hdr_dmabuf_format(
format: VideoFormat,
preferred: Option<(u32, u32, u32)>,
@@ -628,37 +596,4 @@ mod tests {
// The minimum must not exceed what producers already serve, or the ask becomes a demand.
const { assert!(POOL_MIN <= 2) };
}
/// xBGR_210LE must be offered FIRST, and this is a correctness test, not a style one.
///
/// The first compatible consumer pod wins the negotiation. Leading with `xRGB_210LE` makes an
/// NVIDIA gamescope session land on `XRGB2101010`, whose linear-tiled `A2R10G10B10` storage
/// NVIDIA does not support — gamescope's composite `imageStore` writes XBGR bytes under an
/// XRGB label and the whole stream comes out with red and blue swapped. Every format mapping
/// on the host side is individually correct, so nothing downstream can detect it.
///
/// Field-confirmed 2026-08-09 on the RTX 5070 Ti Bazzite host with 0.26.0. See the
/// [`HDR_FORMAT_ORDER`] docs for the upstream fix this predates.
#[test]
fn hdr_offers_xbgr_before_xrgb() {
assert_eq!(
HDR_FORMAT_ORDER[0],
VideoFormat::xBGR_210LE,
"xBGR_210LE must be offered first — leading with xRGB_210LE swaps red and blue on \
every NVIDIA gamescope HDR session"
);
assert_eq!(
HDR_FORMAT_ORDER[1],
VideoFormat::xRGB_210LE,
"xRGB_210LE stays as the fallback pod so a producer offering only it can still \
negotiate HDR instead of dropping to the SDR downgrade"
);
// Both must still build: the order is a preference, never a removal.
for fmt in HDR_FORMAT_ORDER {
assert!(
!build_hdr_dmabuf_format(fmt, None).unwrap().is_empty(),
"{fmt:?} must still produce a format pod"
);
}
}
}
@@ -328,7 +328,7 @@ impl VirtualDisplay for GamescopeDisplay {
// client's resolution (the box is headless, so its game-mode mode is ours to set).
// Reuse if it already matches (fast, no restart); otherwise relaunch the box's own
// session at the client mode. Without this the client gets the box's default mode.
ensure_box_gamescope_mode(mode, self.hdr)?
ensure_box_gamescope_mode(mode)?
} else {
id.parse()
.context("PUNKTFUNK_GAMESCOPE_NODE must be a node id or 'auto'")?
@@ -494,7 +494,7 @@ fn create_managed_session(client: &str, mode: Mode, hdr: bool) -> Result<Virtual
"gamescope: managed takeover unavailable — degrading to ATTACH (mirroring the box's \
own game-mode session)"
);
let node_id = ensure_box_gamescope_mode(mode, hdr)?;
let node_id = ensure_box_gamescope_mode(mode)?;
point_injector_at_eis();
return Ok(VirtualOutput {
node_id,
@@ -923,68 +923,6 @@ fn remove_steamos_dropin() {
let _ = std::fs::remove_file(steamos_dropin_path());
}
/// Drop-in for the box's OWN autologin `gamescope-session-plus@*.service`.
///
/// The transient-unit path ([`launch_session`]) can pass `BindReadOnlyPaths` straight to
/// `systemd-run`, but a box that owns an autologin session is RESTARTED in place instead — no
/// `systemd-run`, so the bind has to arrive as a drop-in or Nobara's hardcoded
/// `/usr/bin/gamescope` wins there too (see [`DISTRO_GAMESCOPE_PATH`]).
///
/// `zz-` so it sorts last, matching the SteamOS drop-in convention above.
fn session_plus_dropin_path() -> std::path::PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/deck".to_string());
std::path::Path::new(&home)
.join(".config/systemd/user/gamescope-session-plus@.service.d/zz-punktfunk-bind.conf")
}
/// Write the box-session drop-in carrying the same two fixes the transient path gets: the bind, and
/// the WSI opt-out when the box's layer was built for a different gamescope. `PF_HZ`/`PF_HDR_ARGS`
/// ride along because the wrapper reads them (without `PF_HZ` it falls back to 60).
///
/// A no-op returning `Ok(false)` when there is nothing to redirect, so a box already running our
/// binary keeps a clean unit.
fn write_session_plus_dropin(
wrapper: &std::path::Path,
mode: Mode,
hdr: bool,
wsi_ok: bool,
) -> Result<bool> {
if gamescope_bin() == DISTRO_GAMESCOPE_PATH {
return Ok(false);
}
let path = session_plus_dropin_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("mkdir {}", parent.display()))?;
}
let body = format!(
"[Service]\n\
BindReadOnlyPaths={wrapper}:{DISTRO_GAMESCOPE_PATH}\n\
Environment=PF_HZ={hz}\n\
Environment=\"PF_HDR_ARGS={hdr_args}\"\n\
{wsi}",
wrapper = wrapper.display(),
hz = game_hz(mode.refresh_hz),
hdr_args = hdr_args(hdr)
.into_iter()
.chain(cursor_args())
.collect::<Vec<_>>()
.join(" "),
wsi = if wsi_ok {
String::new()
} else {
"Environment=ENABLE_GAMESCOPE_WSI=0\n".to_string()
},
);
std::fs::write(&path, body).with_context(|| format!("write drop-in {}", path.display()))?;
Ok(true)
}
/// Remove the box-session drop-in (restore-on-disconnect). Best-effort, mirroring
/// [`remove_steamos_dropin`].
fn remove_session_plus_dropin() {
let _ = std::fs::remove_file(session_plus_dropin_path());
}
/// Take over SteamOS's `gamescope-session.target` headless at the CLIENT's mode: write the shim + a
/// drop-in carrying the mode, `daemon-reload`, then RESTART the target so `steam-launcher.service`
/// brings Steam up in the fresh headless gamescope — and attach to its node. A same-mode reconnect
@@ -1074,7 +1012,7 @@ fn create_managed_session_steamos(mode: Mode, hdr: bool) -> Result<VirtualOutput
/// box's own unit (rather than spawning a competing one) avoids the autologin-respawn fight the old
/// MANAGED path hit. A headless box has no physical panel, so its game-mode resolution is ours to set;
/// Steam restarts only on an actual resolution CHANGE.
fn ensure_box_gamescope_mode(mode: Mode, hdr: bool) -> Result<u32> {
fn ensure_box_gamescope_mode(mode: Mode) -> Result<u32> {
let target = (mode.width, mode.height);
// Fast path: already at the client's resolution — just attach to the live node.
if current_gamescope_output_size() == Some(target) {
@@ -1146,26 +1084,6 @@ fn ensure_box_gamescope_mode(mode: Mode, hdr: bool) -> Result<u32> {
&format!("SCREEN_HEIGHT={}", mode.height),
&format!("CUSTOM_REFRESH_RATES={}", mode.refresh_hz.max(1)),
]);
// Same two fixes the transient path gets, but this unit is the BOX's own — they have to arrive
// as a drop-in, and `daemon-reload` before the restart or systemd runs the old unit.
match write_gamescope_bin_wrapper()
.and_then(|w| write_session_plus_dropin(&w, mode, hdr, wsi_layer_matches_our_gamescope()))
{
Ok(true) => {
tracing::info!(
bin = %gamescope_bin(),
%unit,
"gamescope: dropped in a bind over {DISTRO_GAMESCOPE_PATH} for the box's own \
session unit a session script that hardcodes that path (Nobara) gets the \
patched build on this restart too"
);
systemctl_user(&["daemon-reload"]);
}
Ok(false) => {}
// Best-effort: a box whose session already runs our binary loses nothing, and a failure
// here must not block a restart that would otherwise work.
Err(e) => tracing::warn!(error = %e, "gamescope: could not write the box-session drop-in"),
}
systemctl_user(&["restart", &unit]);
// Wait for the relaunched session to come up at the new size and publish its capture node. The
// node appears when gamescope is up (well before Steam finishes booting); the caller's
@@ -2339,12 +2257,6 @@ fn do_restore_tv_session() {
}
return;
}
// Hand the box back its OWN gamescope before restarting its session: our bind drop-in exists
// to serve a punktfunk stream, and leaving it would silently put the patched build (plus our
// HDR/cursor flags) under the user's ordinary game mode — exactly the "sits beside the distro
// package" rule this whole design rests on.
remove_session_plus_dropin();
systemctl_user(&["daemon-reload"]);
for unit in units {
let _ = Command::new("systemctl")
.args(["--user", "start", &unit])
@@ -2501,60 +2413,6 @@ fn write_gamescope_bin_wrapper() -> Result<std::path::PathBuf> {
Ok(path)
}
/// The absolute path a session script may hardcode instead of honouring `GAMESCOPE_BIN`.
///
/// Nobara's `gamescope-session-plus` builds its command as `GAMESCOPECMD="/usr/bin/gamescope …"`
/// and reads `GAMESCOPE_BIN` NOWHERE, so all three of our spawn levers miss at once: the env var
/// is ignored, and an absolute path cannot be redirected by a PATH shim. The session then runs a
/// stock gamescope, the capability probe rejects it, and every session dies with
/// "pipeline build failed (out of retries)".
const DISTRO_GAMESCOPE_PATH: &str = "/usr/bin/gamescope";
/// Bind our wrapper over [`DISTRO_GAMESCOPE_PATH`] **inside the session unit's mount namespace**,
/// so a script that hardcodes that path still gets the patched build.
///
/// Deliberately a bind rather than replacing the distro's binary: `punktfunk-gamescope` ships under
/// its own name precisely so it sits BESIDE the distro package (a Steam gaming session keeps using
/// its own gamescope — see packaging/gamescope/README.md). The bind is scoped to this transient
/// unit, so nothing outside the session sees it and nothing is written to `/usr`.
///
/// Skipped when the resolved binary IS the distro path (nothing to redirect) — binding a file over
/// itself is pointless, and on a box with no `punktfunk-gamescope` we must not pretend otherwise.
fn session_gamescope_bind(wrapper: &std::path::Path) -> Option<String> {
if gamescope_bin() == DISTRO_GAMESCOPE_PATH {
return None;
}
Some(format!(
"--property=BindReadOnlyPaths={}:{DISTRO_GAMESCOPE_PATH}",
wrapper.display()
))
}
/// Whether the box's `VkLayer_FROG_gamescope_wsi` can be trusted against the gamescope we run.
///
/// The layer ships with the DISTRO's gamescope and speaks its `gamescope_swapchain` protocol; we
/// run our own build. When the two disagree the compositor rejects the client's
/// `swapchain_feedback` ("message too short") and **kills every Vulkan client** — Steam never
/// paints and the stream is a black screen with no error anywhere else.
///
/// Measured on Nobara 44 (`vkcube` under each build, layer on):
/// distro 3.16.23.2 → 0 errors; our 3.16.25 → 1 rejected client. The upstream protocol XML is
/// byte-identical between those commits, so this is the distro PATCHING gamescope, not a version
/// bump — which is why the check is "do the version triples differ", not a floor.
///
/// `ENABLE_GAMESCOPE_WSI=0` is gamescope's own opt-out and costs only the layer's extras
/// (present-mode control, client HDR metadata) — far cheaper than a client that cannot start.
fn wsi_layer_matches_our_gamescope() -> bool {
let ours = discovery::gamescope_version_of(std::path::Path::new(gamescope_bin()));
let distro = discovery::gamescope_version_of(std::path::Path::new(DISTRO_GAMESCOPE_PATH));
match (ours, distro) {
// Same upstream triple ⇒ the layer was built from the same protocol. Keep it.
(Some(a), Some(b)) => a == b,
// Either side unreadable: leave the layer alone rather than degrade a box that works.
_ => true,
}
}
/// Launch `gamescope-session-plus <client>` headless at `mode` as a transient `systemd --user`
/// unit (clean cgroup teardown of the whole Steam tree on stop). Injects `--nested-refresh` (via
/// the wrapper) + `--generate-drm-mode cvt` so games see exactly `mode` (resolution + refresh) and
@@ -2593,38 +2451,9 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
r.dedup();
r.iter().map(u32::to_string).collect::<Vec<_>>().join(",")
};
// Redirect a hardcoded `/usr/bin/gamescope` at our wrapper, for session scripts that never
// read `GAMESCOPE_BIN` (Nobara). Computed once so the log line below reflects what we did.
let bind = session_gamescope_bind(&wrapper);
if bind.is_some() {
tracing::info!(
bin = %gamescope_bin(),
"gamescope: binding the patched build over {DISTRO_GAMESCOPE_PATH} inside the session \
unit a session script that hardcodes that path (Nobara) gets the patched build \
instead of the distro's stock one. Nothing outside this unit is affected."
);
}
// The distro's Vulkan WSI layer speaks the distro gamescope's protocol; ours may differ, and a
// mismatch kills every Vulkan client (Steam included) with no error but a black screen.
let wsi_ok = wsi_layer_matches_our_gamescope();
if !wsi_ok {
tracing::warn!(
"gamescope: this box's VkLayer_FROG_gamescope_wsi was built for a different gamescope \
than the one we run disabling it for this session (ENABLE_GAMESCOPE_WSI=0). Left \
enabled it rejects the client's swapchain_feedback and every Vulkan client dies, \
which shows up as a black screen with no other symptom."
);
}
let start_unit = || -> Result<()> {
let mut cmd = Command::new("systemd-run");
cmd.args(["--user", "--collect", &format!("--unit={unit_name}")]);
if let Some(b) = bind.as_deref() {
cmd.arg(b);
}
if !wsi_ok {
cmd.arg("--setenv=ENABLE_GAMESCOPE_WSI=0");
}
let status = cmd
let status = Command::new("systemd-run")
.args(["--user", "--collect", &format!("--unit={unit_name}")])
// Same headless-must-not-attach rule as [`spawn`]: the transient unit inherits the
// user manager env, which can carry a (possibly stale) desktop DISPLAY/WAYLAND_DISPLAY
// that would abort gamescope at startup.
@@ -524,22 +524,6 @@ fn parse_patch_level(banner: &str) -> u32 {
.unwrap_or(0)
}
/// The upstream `X.Y.Z` a specific gamescope binary reports, or `None` if it cannot be run/parsed.
///
/// Split from [`check_gamescope_version`] (which only ever probes the RESOLVED binary) because the
/// WSI-layer check has to compare TWO binaries — ours and the distro's — and a `None` there means
/// "leave the layer alone", not "assume old".
pub(super) fn gamescope_version_of(bin: &std::path::Path) -> Option<(u32, u32, u32)> {
let out = Command::new(bin).arg("--version").output().ok()?;
// Same stdout/stderr split as the version gate: builds disagree on where the banner goes.
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
parse_version(&text)
}
/// Minimum gamescope that captures reliably: below 3.16.22, headless PipeWire capture deadlocks
/// against PipeWire ≥ 1.6 (a loop-lock bug) and a stuck link head-blocks the whole daemon.
const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22);
+2 -35
View File
@@ -1093,19 +1093,6 @@ fn capability_denial_hint() -> String {
let permitted = std::fs::read_to_string("/proc/self/status")
.ok()
.and_then(|status| permitted_caps_from_status(&status));
capability_denial_hint_for(permitted)
}
/// The message half of [`capability_denial_hint`], split from the `/proc/self/status` read so it is
/// testable against a *given* mask instead of whatever the test process happens to hold.
///
/// That distinction is not academic: the first version of this asserted the empty case by calling
/// the real thing and trusting the test process to be uncapped. That holds on a dev box and is
/// false in CI, where the runner container is root with a full permitted set
/// (`CapPrm=0x000001ffffffffff`) — so the hint fired, correctly, and the test failed on a machine
/// where nothing was wrong. A check whose answer depends on the ambient environment tests the
/// environment, not the code.
fn capability_denial_hint_for(permitted: Option<u64>) -> String {
match permitted {
Some(caps) if caps != 0 => format!(
" — NOTE: this process carries capabilities (CapPrm={caps:#018x}), which is enough on \
@@ -1148,30 +1135,10 @@ mod capability_hint_tests {
/// A capability-free host must not append the hint — the message it decorates is also printed
/// on genuinely missing `.desktop` files, and a spurious "you have capabilities" line would
/// send the reader chasing a setcap that was never there.
///
/// Driven off an explicit mask rather than the test process's own: see
/// [`capability_denial_hint_for`] for why calling the real reader here fails in CI.
/// send the reader chasing a setcap that was never there. The test process has no capabilities.
#[test]
fn silent_without_capabilities() {
assert_eq!(
capability_denial_hint_for(permitted_caps_from_status(CLEAN)),
""
);
// Absent or unparseable field: also silent, never a panic and never a spurious hint.
assert_eq!(capability_denial_hint_for(None), "");
}
/// ...and the case that matters actually speaks, naming the mask and the repair. Without this
/// the test above passes just as well against a function that returns `""` unconditionally.
#[test]
fn names_the_mask_and_the_repair_when_capped() {
let hint = capability_denial_hint_for(permitted_caps_from_status(CAPPED));
assert!(
hint.contains("0x0000000000800000"),
"names the mask: {hint}"
);
assert!(hint.contains("setcap -r"), "names the repair: {hint}");
assert_eq!(capability_denial_hint(), "");
}
}
+10
View File
@@ -195,6 +195,16 @@ bash /usr/share/punktfunk/bazzite/kde-desktop-setup.sh
Gaming Mode needs none of this — it auto-attaches.
> **Version 0.26.0-1 cannot stream the Desktop, and nothing on this page fixes it.** Every session
> ends with `KWin does not expose zkde_screencast_unstable_v1 to this client`. That image gave the
> host binary the `CAP_SYS_NICE` capability, and Linux will not let KWin read the `/proc/<pid>/exe`
> it uses to work out which program is asking — so the grant above can never match, however
> correctly it is installed. Reinstalling does not help: it looks like a missing `.desktop` file and
> is not one. The image's `/usr` is read-only, so it cannot be repaired on the box — take the next
> image with `sudo punktfunk-sysext update`, then
> `systemctl --user restart punktfunk-host`. Gaming Mode is unaffected; gamescope has no such
> identity check.
## Run as an always-on host
Bazzite hosts are typically headless. Enable the host service and linger so it starts at boot — see
+1 -2
View File
@@ -34,7 +34,7 @@ Most people need to do nothing. Check this list if any of it applies to you.
- **Audio catches jitter before you can hear it.** The rule that decides how much sound to keep buffered only ever learned from failures you could already hear: it waited for three audible dropouts before deepening the buffer, and it re-tested a shallower one every few quiet seconds, paying for a wrong guess with a click, forever. It now reads the near-misses nobody hears, backs off after a probe that fails, and refills in one go rather than limping. Simulating ten minutes of a Wi-Fi power-saving pattern went from roughly two thousand audible events to nine. Update the client.
- **A gamescope session says so when its refresh rate has been lost.** If something in the session's own configuration drops the setting that carries it, the stream still runs and still looks right while the game underneath is capped to 60 — which is exactly the kind of fault that costs a week to find. It is now one line in the log.
- **The low-latency wavelet codec got a serious round of work on Linux hosts.** It encodes on the same graphics cores your game is using, so under heavy load it was being crowded out — the encode step measured around 2 ms idle and 1518 ms at 95% game load, with the stream's frame rate collapsing along with it. The encoder can now work on two frames at once, and the capture path asks your desktop for enough buffers to keep up instead of taking whatever it was handed without ever expressing a preference. The switch that asks your graphics card to put that work ahead of the game's had also never been applied on Linux, and now is — though it stays dormant on an ordinary install, because switching it on requires a system privilege that turns out to stop KDE recognising the host at all. There is more on that below.
- **The low-latency wavelet codec got a serious round of work on Linux hosts.** It encodes on the same graphics cores your game is using, so under heavy load it was being crowded out — the encode step measured around 2 ms idle and 1518 ms at 95% game load, with the stream's frame rate collapsing along with it. The switch that asks the graphics driver for priority had never actually been applied on Linux; it is now, and the package grants the host the permission that switch needs. Alongside it, the encoder can now work on two frames at once and the capture path asks your desktop for enough buffers to keep up, where before it took whatever it was given and never even expressed a preference.
- **Jumbo frames can now be proven rather than hoped for.** The whole path was dead code: the discovery that was supposed to find a larger packet size could never settle above the ordinary limit, so the setting that grows mid-stream was unreachable on every path that has ever existed. A network that genuinely carries big packets is now detected, and a wavelet session starts at the large size instead of never getting there — around six times fewer packets per frame. Still opt-in, on both ends.
- **The configuration documentation caught up with 0.25**, including the jumbo-frame option and several other settings that had shipped with nothing written about them.
@@ -42,7 +42,6 @@ Most people need to do nothing. Check this list if any of it applies to you.
- **Bluetooth headphones got no game audio on iPhone and iPad.** With the microphone on — which is the default — the app was forcing output to the phone's own speaker, and that override outranks a Bluetooth headset. Wired headphones beat it, which is why plugging in a cable made it look correct. Turning the microphone off was the accidental workaround people found. Audio now goes to whatever you have connected, and dropping a headset mid-stream no longer lands on the earpiece. Update the client.
- **On a Steam Deck, the Steam menu and the Quick Access Menu also moved the game.** Both are driven by the same physical controller the client is forwarding, so opening either one played the game behind it at the same time — a second, invisible player picking things up and walking into walls while you browsed. Steam masks a normal game here and cannot mask this one, because the client deliberately forwards your real controller rather than Steam's stand-in, which has no gyro, trackpads or paddles. The stream now stops forwarding while an overlay has the controller, and hands it back without the button that dismissed the menu firing in the game. Update the client.
- **Streaming a KDE desktop keeps working.** An interim build of this release gave the host an extra system privilege, so that the wavelet encoder could ask your graphics card for priority. On KDE the side effect was total: KDE decides whether it trusts a program by looking up which file it is running from, the system refuses that lookup for any program holding a privilege, and so KDE stopped recognising the host at all — desktop streaming failed outright, complaining about a missing screen-capture interface, and it survived a clean reinstall of both host and client. Reported from CachyOS on both NVIDIA and AMD. No Linux package grants that privilege any more, on any of the five ways we ship, and upgrading strips it from a machine that already has it. If a host somehow holds one anyway, the error now names it and gives you the command that undoes it, instead of blaming a missing desktop file.
- **One capture timeout could slow a Linux host down for good.** Two very different problems shared a single switch: a graphics driver that genuinely cannot handle what your desktop produces, and a desktop that simply happened to be restarting. The second was being treated as permanently as the first, so a single moment of bad timing put that host on the slow capture path for every session until the process was restarted — including sessions against a completely different desktop that had never failed at anything, and with nothing at all in the log. The two now have the lifetimes they should, and a capture that works credits the budget back.
- **A wavelet-codec session could quietly fall back to slow capture and log nothing whatsoever.** The warning was asking a host-wide question about a per-session decision, so a degraded host and a healthy one produced identical logs while one of them touched every pixel on the processor.
- **"Full chroma" could cost a Steam Deck its codec.** The client advertised the feature on the strength of the setting alone, with nothing checking whether the device could decode it — and no AMD hardware can. The host grants it on HEVC only, so a Deck with the switch on lost HEVC entirely and reconnected on H.264. It looked intermittent because it is a per-profile setting: a "Work" profile lost HEVC where "Game" kept it, on the same machine and the same host. The client now asks the graphics driver the same question the decoder will, so the advertisement and what actually works cannot disagree.
+2 -31
View File
@@ -55,9 +55,7 @@ sy5uhYGZD6lMJ4uZAQC7W81H2gHlTDTA2Nq35HKW9IOU+Ll2c9fqa7fAIKf9Bg==
usage() {
sed -n 's/^#\( \|$\)//p' "$0" | sed -n '1,20p'
echo "usage: punktfunk-sysext install [--channel stable|canary] [--from-file X.raw]"
echo " punktfunk-sysext update [--from-file X.raw] | reapply | status | remove"
echo " reapply: re-run the host-state steps a sysext image cannot carry (groups, /etc"
echo " mirrors, udev, sysctl, modules) without reinstalling the image."
echo " punktfunk-sysext update [--from-file X.raw] | status | remove"
exit "${1:-0}"
}
need_root() { [ "$(id -u)" = 0 ] || { echo "run as root (sudo)" >&2; exit 1; }; }
@@ -176,17 +174,6 @@ post_merge() {
# 'input': writing 'attach' materialises an arbitrary emulated USB device (review 2026-08-05 M-4),
# so it stays a group users join on purpose — see `ujust add-user-to-input-group` for the other one.
getent group punktfunk >/dev/null 2>&1 || groupadd --system punktfunk 2>/dev/null || :
# Creating the group is necessary but NOT sufficient, and the difference is invisible until a
# stream fails: `pf-dm-helper` gates on MEMBERSHIP, so a host whose user never joined gets
# "stopping the display manager needs privilege" on every managed takeover — sddm's autologin
# Relogin loop then churns logind sessions for the whole stream. Joining stays opt-in (writing
# vhci `attach` materialises an arbitrary emulated USB device), so say so instead of doing it.
local _pf_user="${SUDO_USER:-}"
if [ -n "$_pf_user" ] && ! id -nG "$_pf_user" 2>/dev/null | tr ' ' '\n' | grep -qx punktfunk; then
echo "!! $_pf_user is not in the 'punktfunk' group — the managed gamescope takeover cannot stop"
echo "!! the display manager, and the virtual Steam Deck pad cannot attach. To opt in:"
echo "!! sudo usermod -aG punktfunk $_pf_user"
fi
modprobe vhci-hcd 2>/dev/null || :
# Re-fire the vhci rule against the (possibly already-present) controller so attach/detach pick up
# the input-group ownership even when the module's original add event predated the reloaded rule.
@@ -278,22 +265,7 @@ cmd_update() {
[ -n "$l" ] || { echo "no image in the feed $(feed_url)" >&2; exit 1; }
ver="${l%% *}"
if [ "$ver" = "$cur" ] && merged; then
# NOT "nothing to do": re-run post_merge. Every step in it is idempotent, and skipping it here
# is how host state silently rots one release behind the image.
#
# The trap, field-proven on a Bazzite host that took 0.25.0 -> 0.26.0 (2026-08-09): an upgrade
# is driven by the script from the OLD image — this file is replaced by the very
# `systemd-sysext refresh` that runs mid-upgrade — so a post_merge step ADDED in the new
# release is executed by nobody. The old script doesn't have it, and the new script never gets
# a turn, because from then on `update` matches this branch and returns. The step is then
# permanently unreachable on exactly the installs that need it.
#
# That cost the `punktfunk` group (added to post_merge in 0.26.0): it was never created, so
# `pf-dm-helper` refused every caller — it gates on membership — and every managed gamescope
# takeover fell back to "stopping the display manager needs privilege", leaving sddm's autologin
# Relogin loop churning for the whole stream.
echo "already on $cur (channel $(channel)) — re-applying host state."
post_merge
echo "already on $cur (channel $(channel)) — nothing to do."
return
fi
echo "updating: ${cur:-<none>} -> $ver"
@@ -339,7 +311,6 @@ cmd_remove() {
case "${1:-}" in
install) shift; cmd_install "$@" ;;
update) shift; cmd_update "$@" ;;
reapply) shift; need_root; post_merge ;;
status) shift; cmd_status ;;
remove) shift; cmd_remove ;;
*) usage ;;
+1 -1
View File
@@ -272,7 +272,7 @@ modules:
- type: git
url: https://github.com/ValveSoftware/gamescope.git
# KEEP IN SYNC with `_gsrev` in packaging/gamescope/PKGBUILD.
commit: 5fb8dce4a09d0a68d097b9faf9513782106bc843
commit: 8c676c399c761e4540587f61004c957993d12fea
# Wrap pins as of that rev (`subprojects/*.wrap`). These are meson WRAPS, not gamescope
# submodules, so nothing else populates them and they need explicit sources.
#
+2 -8
View File
@@ -18,17 +18,11 @@ pkgname=punktfunk-gamescope
# The `.pfhdrN` suffix is the patch-set revision the binary stamps into its banner (see README.md);
# bump it with the marker so pacman sees a new version when only our patches moved.
_gsver=3.16.25
_gsrev=5fb8dce4a09d0a68d097b9faf9513782106bc843
_gsrev=8c676c399c761e4540587f61004c957993d12fea
pkgver="${_gsver}.pfhdr4"
# 2: patch 0006 (never destroy the Vulkan device/output at exit). No capability moved, so the
# `.pfhdrN` level deliberately stays put — see README.md.
# 3: pin moved 8c676c39 -> 5fb8dce4 (3.16.25-1 -> 3.16.25-11), which brings upstream's own
# `vulkan_get_rgb10_capture_format()` — the XBGR2101010 fallback for devices with no linear-tiled
# A2R10G10B10 storage (every NVIDIA). That fixes the NV12/P010 capture intermediate and AVIF
# screenshots, which are upstream's paths, not ours. Patch 0001 additionally now offers
# `xBGR_210LE` BEFORE `xRGB_210LE` so a third-party consumer cannot pick the one NVIDIA fills
# byte-reversed. Still no capability the host probes for, so `.pfhdrN` stays at 4.
pkgrel=3
pkgrel=2
pkgdesc="gamescope with 10-bit BT.2020/PQ PipeWire capture, for punktfunk HDR streaming"
arch=('x86_64' 'aarch64')
url="https://git.unom.io/unom/punktfunk"
+4 -11
View File
@@ -97,21 +97,14 @@ distro's `gamescope`.
## Building
Pinned upstream: `5fb8dce4` (master, 2026-08-03 — `3.16.25-11-g5fb8dce`). The patches apply
cleanly to that commit; they touch `src/pipewire.cpp`, `src/steamcompmgr.cpp`,
`src/rendervulkan.cpp`, `src/rendervulkan.hpp` and `src/meson.build` only.
The bump from `8c676c39` is deliberate: it brings upstream's `vulkan_get_rgb10_capture_format()`
(`ff6b924`), which probes `linearTilingFeatures` for STORAGE+SAMPLED and falls back to
`DRM_FORMAT_XBGR2101010` on devices that cannot do linear-tiled `A2R10G10B10` — i.e. every
NVIDIA. That covers the paths that are upstream's rather than ours: the RGB intermediate
`paint_pipewire()` acquires when the stream is YCbCr, and AVIF screenshots. Our own 10-bit RGB
node is covered by patch `0001`, which offers `xBGR_210LE` first for the same reason.
Pinned upstream: `8c676c39` (master, 2026-07-27 — tags through 3.16.25). The patches apply
cleanly to that commit; they touch `src/pipewire.cpp`, `src/steamcompmgr.cpp` and
`src/meson.build` only.
```sh
git clone https://github.com/ValveSoftware/gamescope.git
cd gamescope
git checkout 5fb8dce4
git checkout 8c676c39
git submodule update --init --recursive # or let meson fetch the subprojects
git am /path/to/punktfunk/packaging/gamescope/patches/*.patch
@@ -25,7 +25,7 @@ set -euo pipefail
# The pinned upstream. Bump together with the patches (they are `git am`-able and rebase cheaply —
# two files, mirroring code that already exists in-tree; see README.md).
GAMESCOPE_REV="5fb8dce4a09d0a68d097b9faf9513782106bc843"
GAMESCOPE_REV="8c676c399c761e4540587f61004c957993d12fea"
GAMESCOPE_REPO="https://github.com/ValveSoftware/gamescope.git"
REV="$GAMESCOPE_REV" PREFIX=/usr DESTDIR="" SRCDIR="" JOBS="" SETCAP=1
@@ -32,12 +32,12 @@ follow what the app happens to render.
Works on the headless backend as well as a real connector: no HDR display is
involved anywhere in the LUT set.
---
src/pipewire.cpp | 123 ++++++++++++++++++++++++++++++++++---------
src/pipewire.cpp | 114 +++++++++++++++++++++++++++++++++----------
src/steamcompmgr.cpp | 21 ++++++--
2 files changed, 115 insertions(+), 29 deletions(-)
2 files changed, 106 insertions(+), 29 deletions(-)
diff --git a/src/pipewire.cpp b/src/pipewire.cpp
index 76b3ea8..c84b19c 100644
index 76b3ea8..6b56b01 100644
--- a/src/pipewire.cpp
+++ b/src/pipewire.cpp
@@ -18,6 +18,40 @@
@@ -159,7 +159,7 @@ index 76b3ea8..c84b19c 100644
params.push_back((const struct spa_pod *) spa_pod_builder_pop(builder, &obj_frame));
// for (auto& param : params)
@@ -166,6 +209,23 @@ static std::vector<const struct spa_pod *> build_format_params(struct spa_pod_bu
@@ -166,6 +209,14 @@ static std::vector<const struct spa_pod *> build_format_params(struct spa_pod_bu
build_format_params(builder, SPA_VIDEO_FORMAT_BGRx, params);
build_format_params(builder, SPA_VIDEO_FORMAT_NV12, params);
@@ -168,22 +168,13 @@ index 76b3ea8..c84b19c 100644
+ // negotiates today's 8-bit stream keeps negotiating it bit-for-bit. Only a consumer that
+ // asks for a 10-bit format by name — and accepts the MANDATORY BT.2020 + PQ colorimetry
+ // above — ever reaches these.
+ //
+ // xBGR_210LE FIRST, and that order is correctness, not style. A consumer takes the first pod
+ // it can use, and xBGR is the only one every vendor fills correctly: capture textures are
+ // mappable, hence linear-tiled, and linear STORAGE for A2R10G10B10 is an optional Vulkan
+ // feature NVIDIA does not implement — there the composite's `imageStore` lands in XBGR order,
+ // so a consumer that took xRGB_210LE gets a buffer LABELLED XRGB2101010 and FILLED as XBGR,
+ // i.e. red and blue swapped, with every format mapping on both ends individually correct.
+ // A2B10G10R10 is the universally supported packed-10 format, so leading with it costs nothing
+ // on AMD or Intel. xRGB_210LE stays as the second pod for a consumer that only speaks it.
+ build_format_params(builder, SPA_VIDEO_FORMAT_xBGR_210LE, params);
+ build_format_params(builder, SPA_VIDEO_FORMAT_xRGB_210LE, params);
+ build_format_params(builder, SPA_VIDEO_FORMAT_xBGR_210LE, params);
+#endif
return params;
}
@@ -288,7 +348,7 @@ static void dispatch_nudge(struct pipewire_state *state, int fd)
@@ -288,7 +339,7 @@ static void dispatch_nudge(struct pipewire_state *state, int fd)
if (s_nCaptureWidth != state->video_info.size.width || s_nCaptureHeight != state->video_info.size.height) {
pwr_log.debugf("renegotiating stream params (size: %dx%d)", s_nCaptureWidth, s_nCaptureHeight);
@@ -192,7 +183,7 @@ index 76b3ea8..c84b19c 100644
struct spa_pod_builder builder = SPA_POD_BUILDER_INIT(buf, sizeof(buf));
std::vector<const struct spa_pod *> format_params = build_format_params(&builder);
int ret = pw_stream_update_params(state->stream, format_params.data(), format_params.size());
@@ -412,6 +472,12 @@ static void stream_handle_param_changed(void *data, uint32_t id, const struct sp
@@ -412,6 +463,12 @@ static void stream_handle_param_changed(void *data, uint32_t id, const struct sp
state->video_info.size.width, state->video_info.size.height,
s_nRequestedWidth, s_nRequestedHeight,
state->video_info.format, state->shm_stride, shm_size, state->dmabuf);
@@ -205,7 +196,7 @@ index 76b3ea8..c84b19c 100644
}
static void randname(char *buf)
@@ -450,6 +516,11 @@ uint32_t spa_format_to_drm(uint32_t spa_format)
@@ -450,6 +507,11 @@ uint32_t spa_format_to_drm(uint32_t spa_format)
switch (spa_format)
{
case SPA_VIDEO_FORMAT_NV12: return DRM_FORMAT_NV12;
@@ -217,7 +208,7 @@ index 76b3ea8..c84b19c 100644
default:
case SPA_VIDEO_FORMAT_BGR: return DRM_FORMAT_XRGB8888;
}
@@ -715,7 +786,7 @@ bool init_pipewire(void)
@@ -715,7 +777,7 @@ bool init_pipewire(void)
s_nOutputHeight = g_nOutputHeight;
calculate_capture_size();
@@ -227,7 +218,7 @@ index 76b3ea8..c84b19c 100644
std::vector<const struct spa_pod *> format_params = build_format_params(&builder);
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
index ecb3808..83c751b 100644
index ff9ae1f..01b2abf 100644
--- a/src/steamcompmgr.cpp
+++ b/src/steamcompmgr.cpp
@@ -2335,17 +2335,32 @@ static void paint_pipewire()
@@ -58,7 +58,7 @@ index 9fc54f0..1eb35b3 100644
" If this is not set, and there is a HDR client, it will be tonemapped SDR.\n"
" --sdr-gamut-wideness Set the 'wideness' of the gamut for SDR comment. 0 - 1.\n"
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
index 83c751b..a8a816f 100644
index 01b2abf..5c65420 100644
--- a/src/steamcompmgr.cpp
+++ b/src/steamcompmgr.cpp
@@ -2316,6 +2316,13 @@ static void update_touch_scaling( const struct FrameInfo_t *frameInfo )
@@ -146,9 +146,9 @@ index 83c751b..a8a816f 100644
+ }
+
gamescope::Rc<CVulkanTexture> pRGBTexture = s_pPipewireBuffer->texture->isYcbcr()
? vulkan_acquire_capture_texture( uWidth, uHeight, false, vulkan_get_rgb10_capture_format() )
? vulkan_acquire_capture_texture( uWidth, uHeight, false, DRM_FORMAT_XRGB2101010 )
: gamescope::Rc<CVulkanTexture>{ s_pPipewireBuffer->texture };
@@ -8404,6 +8459,12 @@ steamcompmgr_main(int argc, char **argv)
@@ -8397,6 +8452,12 @@ steamcompmgr_main(int argc, char **argv)
g_FadeOutDuration = atoi(optarg);
} else if (strcmp(opt_name, "force-windows-fullscreen") == 0) {
bForceWindowsFullscreen = true;
@@ -62,7 +62,7 @@ index 2c4fb50..b406caf 100644
" If this is not set, and there is a HDR client, it will be tonemapped SDR.\n"
" --sdr-gamut-wideness Set the 'wideness' of the gamut for SDR comment. 0 - 1.\n"
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
index a8a816f..64e1a8c 100644
index 5c65420..0d293c6 100644
--- a/src/steamcompmgr.cpp
+++ b/src/steamcompmgr.cpp
@@ -2323,6 +2323,12 @@ gamescope::ConVar<bool> cv_pipewire_composite_cursor{ "pipewire_composite_cursor
@@ -127,7 +127,7 @@ index a8a816f..64e1a8c 100644
// The cursor, when this stream was asked for it. gamescope keeps the pointer OUT of the
// PipeWire node by default — it lives on a hardware plane for scanout, and a remote-play
// consumer that draws its own would end up with two — so a consumer that has no cursor of
@@ -8464,6 +8497,12 @@ steamcompmgr_main(int argc, char **argv)
@@ -8457,6 +8490,12 @@ steamcompmgr_main(int argc, char **argv)
cv_pipewire_composite_cursor = true;
#else
fprintf( stderr, "gamescope: --pipewire-composite-cursor ignored (built without PipeWire)\n" );
@@ -1,4 +1,4 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From 509fb928c7dc3307372629ca692f4c895c4fe984 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Enrico=20B=C3=BChler?= <enrico.buehler@unom.io>
Date: Sat, 8 Aug 2026 19:17:25 +0200
Subject: [PATCH] punktfunk: never destroy the Vulkan device or output at exit
@@ -49,7 +49,7 @@ Both are needed: pinning only the device relocated the fault into
2 files changed, 35 insertions(+), 4 deletions(-)
diff --git a/src/rendervulkan.cpp b/src/rendervulkan.cpp
index 3f7ea90..0382a4b 100644
index 5c2dd11..8cd5ca2 100644
--- a/src/rendervulkan.cpp
+++ b/src/rendervulkan.cpp
@@ -118,7 +118,37 @@ static VkResult vulkan_load_module()
@@ -102,10 +102,10 @@ index 3f7ea90..0382a4b 100644
static bool allDMABUFsEqual( wlr_dmabuf_attributes *pDMA )
{
diff --git a/src/rendervulkan.hpp b/src/rendervulkan.hpp
index c49b95f..ee98b38 100644
index b6749d4..a9335c4 100644
--- a/src/rendervulkan.hpp
+++ b/src/rendervulkan.hpp
@@ -565,7 +565,7 @@ enum ShaderType {
@@ -564,7 +564,7 @@ enum ShaderType {
SHADER_TYPE_COUNT
};
@@ -114,9 +114,12 @@ index c49b95f..ee98b38 100644
struct SamplerState
{
@@ -1010,4 +1010,4 @@ bool vulkan_has_drm_props();
bool vulkan_has_drm_modifiers_for_features(VkFormat format, VkFormatFeatureFlags features);
@@ -1007,4 +1007,4 @@ void vulkan_wait_idle();
// Whether the driver implements VK_EXT_physical_device_drm
bool vulkan_has_drm_props();
-extern CVulkanDevice g_device;
+extern CVulkanDevice &g_device;
--
2.55.0
-8
View File
@@ -20,14 +20,6 @@
# been stable across the 3.16 series (`src/pipewire.cpp`'s format builders, `paint_pipewire()` in
# `src/steamcompmgr.cpp`), so this normally just works — and when it does not, the build fails
# loudly at `patchPhase` rather than producing a gamescope that quietly cannot do HDR.
#
# ⚠️ Kept deliberately free of any dependency on the pinned rev. The pin moved past upstream's
# `vulkan_get_rgb10_capture_format()` (`ff6b924`, after 3.16.25) to fix red/blue on NVIDIA, and it
# would have been natural to have patch `0001` call it — that is what the host-side note in
# `crates/pf-capture/src/linux/pw_pods.rs` proposes. It does NOT, precisely so this derivation
# keeps building against a nixpkgs that still pins 3.16.25, where that symbol does not exist and
# the failure would be an opaque C++ error rather than a patch conflict. Patch `0001` gets the
# same outcome version-independently by offering `xBGR_210LE` ahead of `xRGB_210LE`.
{
lib,
gamescope,
+3 -19
View File
@@ -17,24 +17,8 @@
# can't supply a passphrase non-interactively here.
#
# Usage (in rpm.yml, after build-rpm.sh): RPM_GPG_PRIVATE_KEY=... bash packaging/rpm/sign-rpms.sh
#
# Takes the RPMs to sign as arguments, defaulting to `dist/*.rpm`. The argument form exists because
# punktfunk-gamescope is built LATER in the job than the host RPMs — it is a ~10-minute C++ build
# behind its own cache — so it misses the main signing pass entirely and needs a second one just
# for itself. It shipped unsigned into a `gpgcheck=1` repo that way, which made
# `dnf install punktfunk-gamescope` fail with "The package is not signed" for every Fedora/Nobara
# user: the package was in the channel and still uninstallable.
set -euo pipefail
# Default target, and a real glob rather than a literal when nothing matched.
if [ "$#" -gt 0 ]; then
RPMS=("$@")
else
shopt -s nullglob
RPMS=(dist/*.rpm)
fi
[ "${#RPMS[@]}" -gt 0 ] || { echo "no RPMs to sign" >&2; exit 1; }
if [ -z "${RPM_GPG_PRIVATE_KEY:-}" ]; then
case "${GITHUB_REF:-}" in
refs/tags/v*)
@@ -63,11 +47,11 @@ KEYID="$(gpg --list-secret-keys --with-colons | awk -F: '/^sec:/{print $5; exit}
# correctly. (A custom __gpg_sign_cmd passed via --define reached gpg with those filename macros
# UNEXPANDED -> "No such file or directory".) Just point rpm at our key; the GNUPGHOME above
# (passphrase-less key + loopback) lets gpg sign headless.
for rpm in "${RPMS[@]}"; do
for rpm in dist/*.rpm; do
rpmsign --define "_gpg_name $KEYID" --addsign "$rpm"
done
# Verify locally so a bad signature fails the build before publishing.
rpm --import <(gpg --export --armor "$KEYID")
rpmkeys --checksig "${RPMS[@]}"
echo "signed + verified ${#RPMS[@]} RPM(s) with key $KEYID"
rpmkeys --checksig dist/*.rpm
echo "signed + verified $(find dist -name '*.rpm' | wc -l) RPM(s) with key $KEYID"
+2 -2
View File
@@ -10,7 +10,7 @@
"@tanstack/react-router": "^1.170.18",
"@tanstack/react-start": "^1.168.32",
"@unom/style": "^0.4.4",
"@unom/ui": "^0.9.2",
"@unom/ui": "^0.8.16",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
@@ -982,7 +982,7 @@
"@unom/style": ["@unom/style@0.4.4", "https://git.unom.io/api/packages/unom/npm/%40unom%2Fstyle/-/0.4.4/style-0.4.4.tgz", { "peerDependencies": { "motion": "^12" } }, "sha512-M45nihK+LGyxwy2mmHYRKggaocTt+EKNVFNaMpTvTaIUpozi7bmKIkbM2/enMYS0/UYTaZrBSZs/a0nPXqkAKw=="],
"@unom/ui": ["@unom/ui@0.9.2", "https://git.unom.io/api/packages/unom/npm/%40unom%2Fui/-/0.9.2/ui-0.9.2.tgz", { "dependencies": { "@tanstack/react-router": "^1.170.11", "@tsdown/css": "^0.22.1", "clsx": "^2.1.1", "howler": "^2.2.4", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0" }, "peerDependencies": { "@payloadcms/richtext-lexical": "^3.85.0", "@tanstack/react-virtual": "^3.14.2", "@unom/style": "^0.4.4", "class-variance-authority": "^0.7.1", "embla-carousel-react": "^8.6.0", "lucide-react": "^1.17.0", "motion": "^12.40.0", "radix-ui": "^1.4.3", "react": "^19.2.7", "react-dom": "^19.2.7", "typescript": "^6.0.3", "zod": "^4.4.3" } }, "sha512-UbpNQEu6zRNMkAxsINRj6HvT53ty7+/QxN3TZv6WgQd/rLiLe767mjz4Zh765ASc3NY7EguHqVNuWX6L7V9TLA=="],
"@unom/ui": ["@unom/ui@0.8.16", "https://git.unom.io/api/packages/unom/npm/%40unom%2Fui/-/0.8.16/ui-0.8.16.tgz", { "dependencies": { "@tanstack/react-router": "^1.170.11", "@tsdown/css": "^0.22.1", "clsx": "^2.1.1", "howler": "^2.2.4", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0" }, "peerDependencies": { "@payloadcms/richtext-lexical": "^3.85.0", "@tanstack/react-virtual": "^3.14.2", "@unom/style": "^0.4.4", "class-variance-authority": "^0.7.1", "embla-carousel-react": "^8.6.0", "lucide-react": "^1.17.0", "motion": "^12.40.0", "radix-ui": "^1.4.3", "react": "^19.2.7", "react-dom": "^19.2.7", "typescript": "^6.0.3", "zod": "^4.4.3" } }, "sha512-ZH7VOyaRDT81VY8nm1hmx8a4CeObykP8egZbnV4Nju6kE8rQ28wdpBo0X+Zsdu8WvTEmHZGwPR53NHWJULyciw=="],
"@vercel/nft": ["@vercel/nft@1.10.2", "", { "dependencies": { "@mapbox/node-pre-gyp": "^2.0.0", "@rollup/pluginutils": "^5.1.3", "acorn": "^8.6.0", "acorn-import-attributes": "^1.9.5", "async-sema": "^3.1.1", "bindings": "^1.4.0", "estree-walker": "2.0.2", "glob": "^13.0.0", "graceful-fs": "^4.2.9", "node-gyp-build": "^4.2.2", "picomatch": "^4.0.2", "resolve-from": "^5.0.0" }, "bin": { "nft": "out/cli.js" } }, "sha512-w+WyX5Ulmj4dtTZrxaulqrjaLZHSbnPzx75SJsTNYmotKsqn1JlLnDJa+lz5hn90HJofhl/2MAtw0mCrgM3qYw=="],
+4 -4
View File
@@ -1902,10 +1902,10 @@
hash = "sha512-M45nihK+LGyxwy2mmHYRKggaocTt+EKNVFNaMpTvTaIUpozi7bmKIkbM2/enMYS0/UYTaZrBSZs/a0nPXqkAKw==";
name = "style-0.4.4.tgz";
};
"@unom/ui@0.9.2" = fetchurl {
url = "https://git.unom.io/api/packages/unom/npm/%40unom%2Fui/-/0.9.2/ui-0.9.2.tgz";
hash = "sha512-UbpNQEu6zRNMkAxsINRj6HvT53ty7+/QxN3TZv6WgQd/rLiLe767mjz4Zh765ASc3NY7EguHqVNuWX6L7V9TLA==";
name = "ui-0.9.2.tgz";
"@unom/ui@0.8.16" = fetchurl {
url = "https://git.unom.io/api/packages/unom/npm/%40unom%2Fui/-/0.8.16/ui-0.8.16.tgz";
hash = "sha512-ZH7VOyaRDT81VY8nm1hmx8a4CeObykP8egZbnV4Nju6kE8rQ28wdpBo0X+Zsdu8WvTEmHZGwPR53NHWJULyciw==";
name = "ui-0.8.16.tgz";
};
"@vercel/nft@1.10.2" = fetchurl {
url = "https://registry.npmjs.org/@vercel/nft/-/nft-1.10.2.tgz";
+73 -73
View File
@@ -1,75 +1,75 @@
{
"name": "punktfunk-web",
"private": true,
"type": "module",
"description": "punktfunk management console TanStack Start + React Query (orval) + @unom/ui + Paraglide i18n",
"scripts": {
"prepare": "bun run codegen",
"postinstall": "bun2nix -o bun.nix",
"codegen": "orval --config orval.config.ts && paraglide-js compile --project ./project.inlang --outdir ./src/paraglide && node tools/check-i18n.mjs",
"predev": "orval --config orval.config.ts",
"dev": "vite dev --port 47992",
"prebuild": "orval --config orval.config.ts",
"build": "vite build",
"postbuild": "node tools/check-i18n.mjs",
"start": "bun run .output/server/index.mjs",
"api:gen": "orval --config orval.config.ts",
"lint": "tsc --noEmit",
"test": "bun test server/",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"screenshots": "node tools/screenshots.mjs",
"screenshots:build": "bun run build-storybook && node tools/screenshots.mjs"
},
"dependencies": {
"@fontsource-variable/geist": "^5.3.0",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-router": "^1.170.18",
"@tanstack/react-start": "^1.168.32",
"@unom/style": "^0.4.4",
"@unom/ui": "^0.9.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"motion": "^12.42.2",
"radix-ui": "^1.6.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"recharts": "^3.10.0",
"tailwind-merge": "^2.6.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "^2.5.5",
"@inlang/paraglide-js": "^2.22.0",
"@inlang/plugin-message-format": "^4.4.0",
"@storybook/react-vite": "^10.5.3",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/nitro-v2-vite-plugin": "^1.155.0",
"@types/node": "^22.20.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"bun2nix": "2.1.2",
"orval": "^8.22.0",
"playwright": "^1.61.1",
"storybook": "^10.5.3",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.3.6",
"vite-tsconfig-paths": "^5.1.4"
},
"overrides": {
"tar": "^7.5.21",
"dompurify": "^3.4.12",
"linkify-it": "^5.0.2",
"sharp": "^0.35.3",
"fast-uri": "^3.1.5",
"immutable": "^4.3.9",
"undici": "^7.29.0",
"postcss": "^8.5.25",
"js-yaml": "^4.3.0",
"brace-expansion": "^5.0.9"
}
"name": "punktfunk-web",
"private": true,
"type": "module",
"description": "punktfunk management console \u2014 TanStack Start + React Query (orval) + @unom/ui + Paraglide i18n",
"scripts": {
"prepare": "bun run codegen",
"postinstall": "bun2nix -o bun.nix",
"codegen": "orval --config orval.config.ts && paraglide-js compile --project ./project.inlang --outdir ./src/paraglide && node tools/check-i18n.mjs",
"predev": "orval --config orval.config.ts",
"dev": "vite dev --port 47992",
"prebuild": "orval --config orval.config.ts",
"build": "vite build",
"postbuild": "node tools/check-i18n.mjs",
"start": "bun run .output/server/index.mjs",
"api:gen": "orval --config orval.config.ts",
"lint": "tsc --noEmit",
"test": "bun test server/",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"screenshots": "node tools/screenshots.mjs",
"screenshots:build": "bun run build-storybook && node tools/screenshots.mjs"
},
"dependencies": {
"@fontsource-variable/geist": "^5.3.0",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-router": "^1.170.18",
"@tanstack/react-start": "^1.168.32",
"@unom/style": "^0.4.4",
"@unom/ui": "^0.8.16",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"motion": "^12.42.2",
"radix-ui": "^1.6.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"recharts": "^3.10.0",
"tailwind-merge": "^2.6.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "^2.5.5",
"@inlang/paraglide-js": "^2.22.0",
"@inlang/plugin-message-format": "^4.4.0",
"@storybook/react-vite": "^10.5.3",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/nitro-v2-vite-plugin": "^1.155.0",
"@types/node": "^22.20.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"bun2nix": "2.1.2",
"orval": "^8.22.0",
"playwright": "^1.61.1",
"storybook": "^10.5.3",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.3.6",
"vite-tsconfig-paths": "^5.1.4"
},
"overrides": {
"tar": "^7.5.21",
"dompurify": "^3.4.12",
"linkify-it": "^5.0.2",
"sharp": "^0.35.3",
"fast-uri": "^3.1.5",
"immutable": "^4.3.9",
"undici": "^7.29.0",
"postcss": "^8.5.25",
"js-yaml": "^4.3.0",
"brace-expansion": "^5.0.9"
}
}
+8 -33
View File
@@ -27,37 +27,13 @@ const Card = ({
);
Card.displayName = "Card";
/**
* The card inset, as ONE utility.
*
* It used to be `p-4 sm:p-6`, and that responsive pair is what made every padding override in this
* codebase unreliable: tailwind-merge resolves conflicts only *within* a variant, so a call-site
* `pt-6` beat the base `pt-0` and lost to `sm:pt-0` correct on mobile, zero on desktop. Seven call
* sites had grown their own compensation for that in five different dialects.
*
* A single-variant token cannot half-lose. `--spacing-padding-card` is also what @unom/ui's own
* `Card` uses, so nested cards finally agree on their inset.
*/
const INSET = "p-padding-card";
/**
* Body/footer padding, minus the top when something already sits above.
*
* The old code hard-coded `pt-0` because "a CardHeader supplies the top inset" an assumption about
* a SIBLING that nothing enforced. Delete the header (exactly what tabbing a page does, since the
* tab label replaces the card title) and the top inset silently vanished at 640px. Asking the DOM
* instead of the author makes it self-correcting: first child keeps its inset, later children drop
* it.
*/
const INSET_AFTER_SIBLING = `${INSET} [&:not(:first-child)]:pt-0`;
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5", INSET, className)}
className={cn("flex flex-col space-y-1.5 p-4 sm:p-6", className)}
{...props}
/>
));
@@ -91,12 +67,11 @@ CardDescription.displayName = "CardDescription";
* Card body. Pass `flush` for content that should meet the card's edges a full-bleed table, most
* commonly instead of trying to cancel the padding from the outside.
*
* Do NOT reach for `className="p-0"`: `flush` exists precisely so that intent is expressed as a prop
* the component honours, rather than as a utility that has to out-argue the one already there.
*
* Conversely, you no longer need to ADD top padding when there is no header that is automatic now.
* If you find yourself writing `pt-*` on a CardContent, the layout is telling you something else is
* wrong.
* `className="p-0"` does NOT work for that: tailwind-merge only resolves conflicts *within the same
* variant*, so `p-0` cancels `p-4` but leaves `sm:p-6` standing, and the padding silently returns at
* 640px. Every call site that tried it ended up with a doubled inset once a `CardHeader` (which
* brings its own `sm:p-6`) was nested inside visible as one card whose title sits 24px further in
* than its neighbours'.
*/
const CardContent = React.forwardRef<
HTMLDivElement,
@@ -104,7 +79,7 @@ const CardContent = React.forwardRef<
>(({ className, flush = false, ...props }, ref) => (
<div
ref={ref}
className={cn(!flush && INSET_AFTER_SIBLING, className)}
className={cn(!flush && "p-4 pt-0 sm:p-6 sm:pt-0", className)}
{...props}
/>
));
@@ -116,7 +91,7 @@ const CardFooter = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center", INSET_AFTER_SIBLING, className)}
className={cn("flex items-center p-4 pt-0 sm:p-6 sm:pt-0", className)}
{...props}
/>
));
+4 -7
View File
@@ -62,7 +62,7 @@ export const DashboardView: FC<{
only the GameStream certs read as "0 paired" on a host every
one of whose clients was in fact paired. */}
<Card>
<CardContent className="flex flex-1 items-center justify-between">
<CardContent className="flex flex-1 items-center justify-between p-4 sm:pt-6">
<span className="text-sm text-muted-foreground">
{m.status_paired_count()}
</span>
@@ -72,7 +72,7 @@ export const DashboardView: FC<{
</CardContent>
</Card>
<Card>
<CardContent className="flex flex-1 items-center justify-between">
<CardContent className="flex flex-1 items-center justify-between p-4 sm:pt-6">
<span className="text-sm text-muted-foreground">
{m.status_pin_pending()}
</span>
@@ -206,10 +206,7 @@ export const DashboardView: FC<{
* else except the host log.
*/
const AudioWiringCard: FC<{ audio: AudioWiring }> = ({ audio }) => {
const badge: {
variant: "success" | "secondary" | "destructive";
text: string;
} =
const badge: { variant: "success" | "secondary" | "destructive"; text: string } =
audio.readiness === "full"
? { variant: "success", text: m.audio_ready() }
: audio.readiness === "audio_only"
@@ -260,7 +257,7 @@ const StatCard: FC<{ icon: ReactNode; label: string; on: boolean }> = ({
on,
}) => (
<Card>
<CardContent className="flex flex-1 items-center justify-between">
<CardContent className="flex flex-1 items-center justify-between p-4 sm:pt-6">
<span className="flex items-center gap-2 text-sm text-muted-foreground">
{icon}
{label}
+23 -62
View File
@@ -41,10 +41,9 @@ import { QueryState } from "@/components/query-state";
import { Stagger } from "@/components/stagger";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { InputNumber } from "@/components/ui/input-number";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { apiErrorMessage } from "@/lib/errors";
import { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
@@ -178,11 +177,17 @@ export const DisplaySection: FC = () => {
});
return (
<DisplayTabs
dirty={dirty}
live={<LiveDisplays />}
configuration={
<>
<div className="flex flex-col gap-card">
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle>{m.display_config_title()}</CardTitle>
{/* Visible without scrolling to the save button the card is taller than the
viewport, which is exactly how the pending edits went unnoticed. */}
{dirty && <Badge variant="warning">{m.display_unsaved()}</Badge>}
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="max-w-prose text-sm text-muted-foreground">
{m.host_displays_help()}
</p>
@@ -221,64 +226,20 @@ export const DisplaySection: FC = () => {
/>
)}
</QueryState>
</>
}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{m.display_live()}</CardTitle>
</CardHeader>
<CardContent>
<LiveDisplays />
</CardContent>
</Card>
</div>
);
};
/**
* The page's tab shell: **Configuration** and **Live displays** as the same pill strip the plugin
* UIs use, over a card per tab.
*
* Tabs rather than two stacked cards because the configuration card alone is taller than the
* viewport which is how pending edits went unnoticed and the live list sat below it, effectively
* off screen.
*
* Presentational on purpose, taking both panes as nodes: `DisplaySection` cannot be rendered in
* Storybook (it calls `useBlocker`, which needs a router), so putting the strip here is what keeps
* it reachable from a story. That matters more than usual on this page `Displays.stories.tsx`
* exists to pin the MOTION NESTING of the preset grid, and inserting tabs changes that ancestor
* chain, so the story has to render the real one.
*/
export const DisplayTabs: FC<{
dirty: boolean;
configuration: ReactNode;
live: ReactNode;
}> = ({ dirty, configuration, live }) => (
<Tabs defaultValue="configuration" className="gap-card">
<TabsList>
<TabsTrigger value="configuration">
{m.display_config_title()}
{/* The dirty marker rides the TAB, not the card header. It used to sit inside a card
taller than the viewport; behind a tab it would vanish altogether while the Live
tab was open. On the trigger it survives both and the Custom block keeps its
own inline badge, so nothing is lost when this tab IS open. */}
{dirty && (
<span
role="status"
aria-label={m.display_unsaved()}
className="ml-1.5 size-2 shrink-0 rounded-full bg-[var(--warning)]"
/>
)}
</TabsTrigger>
<TabsTrigger value="live">{m.display_live()}</TabsTrigger>
</TabsList>
<TabsContent value="configuration">
<Card>
<CardContent className="space-y-4">{configuration}</CardContent>
</Card>
</TabsContent>
<TabsContent value="live">
<Card>
<CardContent>{live}</CardContent>
</Card>
</TabsContent>
</Tabs>
);
/**
* The gate on anything that would throw unsaved Custom fields away asked from three places (a
* preset click, applying a saved preset, and leaving the page), so it is written once. A function
+1 -1
View File
@@ -28,7 +28,7 @@ export const ConflictsCard: FC = () => {
if (conflicts.length === 0) return null;
return (
<Card className="border-amber-600/40 dark:border-amber-500/40">
<CardContent className="flex items-start gap-3">
<CardContent className="flex items-start gap-3 p-card pt-card sm:pt-card">
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-amber-600 dark:text-amber-500" />
<div className="min-w-0 flex-1 space-y-2">
<p className="text-sm font-medium text-amber-600 dark:text-amber-500">
+7 -4
View File
@@ -238,10 +238,13 @@ export const LogsCard: FC<{
return (
<Card>
{/* No CardHeader here, and that no longer needs saying: CardContent keeps its top inset
unless something precedes it. This card used to restore it by hand at both
breakpoints. */}
<CardContent className="flex flex-col gap-3">
{/* This card has no CardHeader, so it has to put the top padding back itself and it
must do so at BOTH breakpoints. `CardContent` is `p-4 pt-0 sm:p-6 sm:pt-0`, and
tailwind-merge only resolves conflicts within the same variant: a bare `pt-6` cancels
`pt-0` but leaves `sm:pt-0` standing, so the padding was 24px on a phone and 0 on a
desktop, with the filter row touching the card's edge. (Same trap the `p-0` note in
components/ui/card.tsx describes, in the other direction.) */}
<CardContent className="flex flex-col gap-3 pt-4 sm:pt-6">
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1">
{LEVELS.map((l) => (
+1 -1
View File
@@ -135,7 +135,7 @@ export const PairedDevices: FC<{
<h2 className="text-lg font-medium">{m.pairing_native_devices()}</h2>
</CardHeader>
<CardContent>
<CardContent className="p-6">
<QueryState isLoading={isLoading} error={error} refetch={refetch}>
{rows.length === 0 ? (
m.pairing_native_empty()
+2 -2
View File
@@ -55,7 +55,7 @@ export const JobProgressSection: FC<{
if (!job.isError) return null;
return (
<Card className="ring-2 ring-destructive/60">
<CardContent className="flex items-start gap-3">
<CardContent className="flex items-start gap-3 p-card pt-card sm:pt-card">
<XCircle className="mt-0.5 size-5 shrink-0 text-destructive" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{m.store_job_lost()}</p>
@@ -92,7 +92,7 @@ export const JobProgressCard: FC<{
className={failed ? "ring-2 ring-destructive/60" : undefined}
aria-live="polite"
>
<CardContent className="space-y-3">
<CardContent className="space-y-3 p-card pt-card sm:pt-card">
<div className="flex items-start gap-3">
{running ? (
<Spinner className="mt-0.5 size-5 shrink-0" />
-36
View File
@@ -21,42 +21,6 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;
/**
* The inset contract the thing this card got wrong most often.
*
* `CardContent` drops its top padding only when something already sits above it. The pair below is
* the regression guard: both cards must show the same inset on every side, and the headerless one
* must not have its first line touching the top edge.
*
* It used to be wrong invisibly, and only on desktop. The padding was `p-4 pt-0 sm:p-6 sm:pt-0`, so
* a headerless card had to restore the top inset itself and a call-site `pt-6` beat the base
* `pt-0` while losing to `sm:pt-0`, because tailwind-merge resolves conflicts only within a variant.
* Right on a phone, zero on a desktop. Seven call sites had grown their own workaround for it.
*
* Check this at BOTH viewport widths. A single width cannot show that class of bug.
*/
export const InsetWithAndWithoutHeader: Story = {
render: () => (
<div className="grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>With a header</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
The body drops its top inset because the header above already supplied
one.
</CardContent>
</Card>
<Card>
<CardContent className="text-sm text-muted-foreground">
No header, so the body keeps its own top inset automatically, with
nothing for the call site to remember.
</CardContent>
</Card>
</div>
),
};
export const HostCard: Story = {
render: () => (
<Card className="max-w-sm">
+21 -46
View File
@@ -1,8 +1,9 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useState } from "react";
import { userEvent, within } from "storybook/test";
import type { DisplayPolicy } from "@/api/gen/model/displayPolicy";
import { DisplayForm, DisplayTabs } from "@/sections/Displays/DisplayCard";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { m } from "@/paraglide/messages";
import { DisplayForm } from "@/sections/Displays/DisplayCard";
import {
displayCustomPresets,
displayEffective,
@@ -18,29 +19,17 @@ import {
* frame while every other grid in the console staggered. It is invisible in a diff and invisible to
* `tsc`; only a rendered page shows it.
*
* So the wrapper below is NOT decoration. It reproduces the page's motion nesting, which is the
* thing under test dropping it would make the story pass for the wrong reason. It renders the
* page's real `DisplayTabs` shell for exactly that reason: the tabs sit between the page `<Section>`
* and the card, so they are part of the ancestor chain this story exists to pin.
* So the `<Card>` wrapper below is NOT decoration. It reproduces the page's motion nesting, which is
* the thing under test dropping it would make the story pass for the wrong reason.
*/
const Harness = ({
seed,
dirty = false,
}: {
seed: DisplayPolicy;
dirty?: boolean;
}) => {
const Harness = ({ seed }: { seed: DisplayPolicy }) => {
const [draft, setDraft] = useState<DisplayPolicy>(seed);
return (
<DisplayTabs
dirty={dirty}
live={
<p className="text-sm text-muted-foreground">
The live list reads `/display/state`, so it is not part of this story
see the tab strip and the Configuration pane.
</p>
}
configuration={
<Card>
<CardHeader>
<CardTitle>{m.display_config_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<DisplayForm
draft={draft}
setDraft={setDraft}
@@ -52,11 +41,11 @@ const Harness = ({
applyAxis={(patch) => setDraft({ ...draft, ...patch })}
saveDraft={() => {}}
busy={false}
dirty={dirty}
dirty={false}
revert={() => {}}
/>
}
/>
</CardContent>
</Card>
);
};
@@ -81,10 +70,11 @@ export const CustomFields: Story = {
export const NoCustomPresets: Story = {
args: { seed: displayPolicy },
render: (args) => (
<DisplayTabs
dirty={false}
live={null}
configuration={
<Card>
<CardHeader>
<CardTitle>{m.display_config_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<DisplayForm
draft={args.seed}
setDraft={() => {}}
@@ -99,22 +89,7 @@ export const NoCustomPresets: Story = {
dirty={false}
revert={() => {}}
/>
}
/>
</CardContent>
</Card>
),
};
/**
* Unsaved Custom edits, with the Configuration tab NOT open.
*
* The dirty marker has to survive being on the other tab the whole reason it moved off the card
* header and onto the trigger. If this story ever shows a bare "Configuration" label, the warning
* has gone silent exactly when it matters most.
*/
export const UnsavedOnOtherTab: Story = {
args: { seed: { ...displayPolicy, preset: "custom" }, dirty: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole("tab", { name: /Live/i }));
},
};