a4aa89be31273b761b0c0c96fec9f4b02f42cff1
1915
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a4aa89be31 |
fix(decky): drive a native client install, not only the flatpak
Every headless call went through `flatpak run io.unom.Punktfunk`, and the launch wrapper exec'd the same. On a Deck whose client came from a sysext, a .deb/rpm, an AUR build or a nix profile there is no such app: discovery worked (it is mDNS), and everything that needed the client — pairing, the library fetch, the host store, the stream launch itself — failed. Resolve the client once. The flatpak still wins when it is actually installed, so an existing Deck is byte-for-byte unchanged, and a native `punktfunk-client` is the fallback; `PF_DECKY_CLIENT` forces one on a machine with both. Both kinds share ~/.config/punktfunk — the flatpak's sandbox HOME is the real home — so identity, known-hosts and settings need no divergence. The wrapper takes the resolved binary as PF_CLIENT_BIN and execs it in place of `flatpak run`; kill_stream sends a name-matched SIGTERM where there is no flatpak instance to kill; and the client-update check, which reads the flatpak's tracked remote, simply reports nothing for a native install, whose updates belong to whatever installed it. Installed-ness is now decided by the app's exported directory rather than by the presence of the `flatpak` binary, so a machine that has flatpak but not this app no longer resolves to a client that cannot run. Closes #11 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f98547c41f |
fix(web-console): the paired-clients card counts both pairing planes
GameStream and native (punktfunk/1) clients pair into separate stores, and `/status` only ever reported the GameStream certificate count. Native is the DEFAULT plane, so a host whose clients had all paired normally — and which Moonlight had never touched — showed "0 paired" on the dashboard. Report the native count alongside it, the way the tray's own summary route already does, and sum the two in the card. Closes #10 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
198b1d5e80 |
fix(apple): iPad mouse buttons, held-key repeat, and the scroll direction
Three separate things an iPad's keyboard and mouse got wrong. Every mouse button past the first two clicked LEFT on the host. UIKit's button mask is 1-based over the HID order, and only `.secondary` was read — middle, back and forward all fell into the `else` and became button 1. Map 3/4/5 through `.button(_:)`; only middle and right swap places, because the wire numbers them the other way round. Holding a key deleted exactly one character. GameController reports a key once on press and once on release — it has no repeat channel — and the host injects exactly what it is told, so nothing downstream ever turned a held key into a stream of presses. Generate the repeat client-side at the usual delay-then-rate, newest key only, with modifiers and lock keys excluded. macOS was never affected: NSEvent hands it the window server's own repeats. Scrolling ignored the system's Natural Scrolling switch. UIKit has already applied that preference by the time it hands over a pan translation — it is what makes every UIScrollView on the device turn the right way — so negating y pinned the stream to traditional scrolling and inverted the setting for everyone on the default. Pass the sign through, exactly as the macOS path passes `NSEvent.scrollingDeltaY` through, and let that one recognizer own scroll under pointer lock too: it is the only way trackpad two-finger scrolling ever arrives, so gating it on the lock had been dropping it entirely there, and the raw GameController axis it deferred to carries no such preference. iOS installs no GCMouse scroll handler now, so nothing double-sends; tvOS, which has no scroll pan, keeps it. Closes #7, closes #15 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
580ea0d338 |
fix(android): a mouse's side buttons work even when they arrive as keys
Not every mouse reports back/forward the same way. One that carries them on the HID button page (BTN_SIDE/BTN_EXTRA) gets BUTTON_BACK/BUTTON_FORWARD in the motion button state, which is the only shape the forwarder listened for. A mouse that carries them on the consumer page (AC Back / AC Forward — the common Bluetooth shape, and what Android TV boxes tend to see) produces only synthesized KEYCODE_BACK/KEYCODE_FORWARD, and those were swallowed outright to stop them doubling as Android navigation. On that hardware both side buttons were simply dead. Route the key shape into the same wire buttons, deciding by the DEVICE (it has to be able to be a mouse) rather than by the event's source, since the consumer-page collection is a separate sub-device that may be stamped SOURCE_KEYBOARD. A D-pad-capable device is excluded so an air-mouse remote's BACK stays the couch user's way out of the stream. Both paths now funnel through one press/release helper whose held-set guard collapses a device that reports BOTH into a single wire press. Closes #8 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2f39e15e88 |
fix(ci): the shader drift gate never ran — dash has no process substitution
ci / rust (push) Failing after 5m29s
arch / build-publish (push) Failing after 6m45s
ci / web (push) Successful in 1m50s
ci / docs-site (push) Successful in 2m38s
android / android (push) Successful in 12m23s
decky / build-publish (push) Successful in 18s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
ci / rust-arm64 (push) Successful in 9m36s
ci / bench (push) Successful in 6m51s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
deb / build-publish-client-arm64 (push) Successful in 9m47s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7m44s
deb / build-publish (push) Successful in 12m27s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8m8s
docker / deploy-docs (push) Successful in 13s
deb / build-publish-host (push) Successful in 13m1s
apple / swift (push) Successful in 5m40s
docker / build-push-arm64cross (push) Successful in 3m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m31s
apple / screenshots (push) Successful in 24m33s
`diff <(spirv-dis …) <(spirv-dis …)` is bash syntax, and Gitea's runner executes
a step's `run:` under `sh -e`. dash rejected the script at PARSE time, so the
gate compared nothing — and, being step 2 of the `rust` job, it took Format,
Clippy, Build, Test, the feature-gated encode backends, the C ABI harness and
the committed-header check down with it. Every one of those has been skipped on
the 35 commits between the gate landing (
|
||
|
|
4065b5bd03 |
docs(gamescope): the missing cursor was never the whole story
ci / rust (push) Failing after 12s
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 1m7s
apple / swift (push) Successful in 5m29s
ci / bench (push) Successful in 7m56s
windows-host / package (push) Failing after 8m13s
windows-host / winget-source (push) Skipped
decky / build-publish (push) Successful in 1m1s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
deb / build-publish (push) Successful in 9m28s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 1m7s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m0s
deb / build-publish-host (push) Successful in 10m49s
android / android (push) Successful in 12m19s
deb / build-publish-client-arm64 (push) Successful in 11m37s
ci / rust-arm64 (push) Successful in 13m59s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6m36s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 7m45s
arch / build-publish (push) Successful in 21m57s
apple / screenshots (push) Successful in 25m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m15s
docker / build-push-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 30s
"The mouse cursor isn't included in the captured image" sat in Known Limits contradicting the section above it, which already explained that the host draws the pointer back in. Both are half-true and the difference matters to a reader choosing whether to install anything: gamescope does leave the pointer out, you do still see one, and what it costs is a full pass over every frame — plus, on the fastest encode paths, the pointer itself, because a fixed-function front end has nowhere to blend it. Which is the real argument for `punktfunk-gamescope` on a box that will never turn HDR on, and the page never made it. Release notes gain the spawn-flag verification and the packaging wiring. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c801465469 |
fix(packaging): every channel that ships punktfunk-gamescope now builds it
The docs already told users where to get it — the Bazzite sysext, an Arch package, a NixOS option — and none of the three were true. `rpm.yml` built the sysext without ever passing `--gamescope`, `arch.yml` did not know the PKGBUILD existed, and the nix derivation had never been evaluated once. Evaluating it found its central assumption wrong: `gamescope.unwrapped` does not exist on current nixpkgs, where `gamescope` IS the buildable derivation, so the override threw on every build. It now prefers `.unwrapped` where a future nixpkgs wraps it and checks the RESULT is patchable — `overrideAttrs` on a symlinkJoin succeeds and does nothing, which would install an UNPATCHED gamescope under a name the host reads as a promise of HDR. Both it and the PKGBUILD had also drifted to a stale patch list: two patches named where three exist, one of them under the level-1 filename retired when the cursor patch landed. Both read the patch directory now, so the list cannot go stale again. The PKGBUILD additionally delegates the whole build to `build-punktfunk-gamescope.sh` rather than re-deriving the meson invocation — its copy had already lost `force_fallback_for=wlroots`, and unlike Fedora 43, Arch ships wlroots, so that package would have linked it shared and shipped a binary that starts only on machines carrying the dev library. It asserts its own pinned rev matches the script's, the one thing makepkg needs statically. Both CI builds are cached on `packaging/gamescope/**`, which is the only reason this is affordable: that tree depends on nothing else in the repo, so a normal push restores a binary instead of spending ten minutes on someone else's C++. And both are best-effort. punktfunk works without this binary — SDR on the gamescope backend, which is what every release before this one did — so a hiccup building gamescope must not cost the packages those workflows exist to publish. A failure warns and is never cached, so the next run retries. `rpm.yml` also installs Fedora's own gamescope for its runtime libraries: the sysext verifies our binary by executing `--version`, and on a cache hit nothing else in the job would have pulled libavif/luajit/seatd in. Verified by evaluating and patch-phase-building the nix derivation against nixos-unstable: all three patches apply to nixpkgs' already-patched 3.16.25 tree, and the banner stamps +pfhdr2. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0c4e582b29 |
fix(gamescope): a managed session now proves our flags reached it
The bare spawn builds gamescope's argv itself. The two managed modes hand the flags over indirectly — `gamescope-session-plus` through `GAMESCOPE_BIN` + `PF_HDR_ARGS`, SteamOS through a PATH shim — and a session free to ignore either would exec the distro's gamescope carrying none of them. Half of that failure was already loud: HDR dies on the bit depth the Welcome fixed before the display existed. The other half was silent. The host had been told the compositor would paint the pointer, so it stopped painting one, and nobody did — a stream that is correct in every respect except that it has no cursor, which nothing in the logs would have said. So both managed paths now read the running compositor's `/proc/<pid>/cmdline` once its node appears, and refuse the session when a flag we passed is missing. The plan is fixed by then (`cursor_blend` feeds the encoder open, which precedes the display), so this session cannot be corrected in place; instead the capability is latched off for the process and the spawn fails, and the retry resolves a correct SDR host-composited session. One rejected attempt per boot, then it converges. Fails OPEN at every ambiguity: no flags expected, or no readable gamescope in `/proc`, says nothing. And it accepts ANY running gamescope carrying the flags, because a box commonly has a second one — the Nobara test box runs its own game-mode `/usr/bin/gamescope --steam` beside ours. That leaves only false PASSES possible, and the flag that matters cannot produce one: `--pipewire-composite-cursor` exists nowhere but our patch set. Two things fell out of writing it. `gamescope_argvs` matches argv[0]'s basename with `ends_with`, not `==` — the old equality test would have missed our own `punktfunk-gamescope`, which is what `current_gamescope_output_size` was already scanning for. And `hdr-probe` gained a line for who paints the cursor, the one capability with no symptom of its own until you compare two streams. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ee716d0137 |
fix(encode): pf-encode did not build on Linux without vulkan-encode
`vulkan_encode_available_at` and `vulkan_encode_caps` were gated on bare `target_os = "linux"`, but the second returns `vulkan_video::VulkanEncodeCaps` and both reference that module — which only exists under the feature. Every CALL SITE was already correctly gated, so nothing pointed at them; the two definitions alone were enough to fail the build. That is CI's default-feature line (`cargo clippy --workspace --all-targets`), so this was going to be caught — it was caught on a Fedora 44 box first. `cursor_blend_capable`'s `ten_bit` goes unused in the featureless arm for the same reason, which `-D warnings` also rejects. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0d2cc65f17 |
fix(gamescope): force the VENDORED wlroots — the binary's portability was luck
Built the patches on a second distro (Nobara 44 / Fedora 44, for the NVIDIA
leg) and the resulting binary would not start on its own host:
libwlroots-0.19.so: cannot open shared object file
gamescope vendors wlroots as a submodule, but meson prefers a SYSTEM one when
the build host has `wlroots-devel` — and links it shared. Fedora 43 has no such
package, so the `.116` build fell back to the submodule and linked it
statically; Fedora 44's `dnf builddep gamescope` pulls one in, so the same
script produced a binary bound to a library that exists only inside the build
container.
Confirmed rather than assumed: `ldd` on the f43 binary lists no wlroots at all
and its log says `Subproject wlroots finished`, while the f44 log says
`Dependency wlroots-0.19 found` from the system.
The consequence, had Fedora 43 ever shipped `wlroots-devel`: the sysext payload
would have carried a gamescope that cannot start, and it would have surfaced to
users as "HDR just doesn't work" rather than as a build failure. `-Dforce_
fallback_for=libliftoff,vkroots,wlroots` makes every distro produce the same
self-contained binary. All three entries go together — gamescope's own
meson.build hard-errors if the first two are dropped from that list.
Verified after the fix: `ldd` shows no wlroots/liftoff/vkroots and no missing
deps, the binary starts on the Nobara host, and its node offers the same four
formats with the colorimetry props — same result as Bazzite f43.
|
||
|
|
3d3ecf1e82 |
test(encode/nvenc): the NVIDIA HDR leg, verified on an RTX 5070 Ti
The NVIDIA half of "zero-copy, no host CSC, 10-bit HDR" had never met a GPU. Two `#[ignore]`d smokes now drive it, run on `.41` (Bazzite f43, RTX 5070 Ti, driver 595.58.03): * `nvenc_cuda_hdr10_packed_rgb` — a packed 2:10:10:10 CUDA payload straight into NVENC as `ARGB10`, HEVC **and** AV1. Asserts what would catch a mislabelled stream: the encoder DERIVED 10-bit and HDR from the input format rather than being told, and picked `ARGB10` for `X2Rgb10`. That derivation is what selects Main10 / AV1-at-10 and the BT.2020 PQ signalling. * `nvenc_cuda_hdr10_cursor_blend` — `cursor_blend.comp` MODE 3/4, the 10-bit channel-unpacking twin. Asserts the blend targets `SlotFormat::X2Rgb10` and not the 8-bit layout, which would tint the pointer and shift its channels. Both green, and the dumped bitstreams decode 0-error reporting `Main 10` / AV1 `Main`, `yuv420p10le`, `bt2020nc` / `smpte2084` / `bt2020`. There is no host colour conversion anywhere on this path: the frame arrives as a LINEAR dmabuf, crosses to CUDA through the Vulkan bridge, and NVENC's ASIC does the BT.2020 conversion following the VUI the session configured. The "no CSC" property AMD gets from the EFC, NVIDIA gets from the encoder itself. The whole pre-existing `nvenc_` suite was re-run alongside them — 16/16, no regression from the depth-derivation and buffer-format changes underneath it. Capture half checked too: the patched gamescope binary built on the AMD box runs unmodified on the NVIDIA one (same Fedora 43 base) and its node offers the same four formats with the colorimetry props — so headless gamescope + 10-bit PQ is not AMD-specific. |
||
|
|
576bf7e294 |
test(encode/vulkan): 10-bit smokes — and they pass on a real AMD GPU
The Vulkan Video 10-bit path was the least-exercised code on this branch:
nothing had ever created a Main10 video session, so the profile query, the
`G10X6…3PACK16` picture allocation, the scratch→plane copy, the hand-packed
AV1 sequence header and the colour signalling were all first-run-on-glass.
Three `#[ignore]`d smokes now drive them, and they were run.
`.116` (Bazzite f43, AMD 780M, RADV / Mesa 26.0.4), all three green:
* `vulkan_smoke_10bit` — HEVC Main10 through the compute CSC;
* `vulkan_smoke_10bit_av1` — AV1 at 10 bits;
* `vulkan_smoke_rgb_10bit` — HDR with NO host CSC, the EFC converting BT.2020
off the packed 10-bit source. It did **not** soft-skip, which answers the one
capability in this whole feature I had only ever read in a registry: RADV's
VCN EFC really does advertise `MODEL_YCBCR_2020` and accept a 10-bit
packed-RGB encode source.
Every stream decodes 0-error and reports `yuv420p10le` + `bt2020nc` /
`smpte2084` / `bt2020`. The AV1 one parsing at all is the load-bearing result
there: `high_bitdepth` precedes the CICP bytes in `color_config()`, so a wrong
bit would have thrown every later field out of phase rather than merely
mislabelling the depth.
Round-trip on solid frames, fed (160,160,800) as 10-bit codes:
compute CSC -> (159, 158, 796)
EFC -> (159, 158, 796)
AV1 -> (158, 159, 794)
Under 0.5%, all of it limited-range quantisation and lossy encode. The compute
and EFC results being IDENTICAL is the strongest check available: two
independent BT.2020 NCL implementations — my shader and AMD's fixed-function
block — agreeing to within rounding.
The smokes deliberately assert structure (submits encode, AU count, the depth
the encoder settled on), not colour: a shader writing the 10 bits into the
wrong end of the word still produces a decodable stream. Colour is the dump +
ffmpeg round-trip above, which is what actually caught nothing this time.
Also corrects a comment: I claimed a wrong store factor was a "~1.6% luminance
error". It is not. The `<< 6` PLACEMENT is the load-bearing part (dropping it
is 64x too dark); `1/1023` vs `64/65535` is ~0.1% and harmless.
|
||
|
|
9eb9f74893 |
fix(gamescope): the patch did not compile on Fedora/Bazzite — three real defects
Built it for the first time, on the AMD Bazzite box (`.116`, RADV 780M, Fedora 43 toolbox). The patches applied cleanly to the pinned rev and the configure got all the way through wlroots — then found three things no amount of reading would have: **1. `SPA_VIDEO_TRANSFER_SMPTE2084` does not exist on Fedora 43.** PipeWire 1.4.11's transfer-function enum stops at `ADOBERGB`: SPA grew BT2020_10 / SMPTE2084 / ARIB_STD_B67 as one later block. So the patch simply did not compile on the distro it is primarily FOR. This is the same trap punktfunk's own consumer documents and works around — `pf-capture`'s `pw_pods.rs` spells the value out as `14` for exactly this reason — and the fix is the same: spell out both colorimetry values, because the enum is wire ABI mirroring GStreamer's, not a private detail. `SPA_VIDEO_COLOR_PRIMARIES_BT2020` is present here but gets the same treatment, since its own header comment says `\since 1.6` and there is no reason to depend on that. The `PW_CHECK_VERSION(1, 0, 0)` guard was also just wrong: it tests the library version, which says nothing about which enum members a header names. It now guards only the FORMAT constants, which are what it was actually right about. **2. The build pulled in three subprojects we never ship.** gamescope's own unit tests want Catch2 **v3** (`catch2-with-main`) and Fedora ships v2, so the configure died on a test suite that is not ours to build. Also disabled: the OpenVR integration (a code path a headless capture session never enters) and the WSI layer — which would have been WORSE than wasted work, since the distro gamescope package installs that same layer and ours would have collided with it file-for-file. **3. `dnf builddep gamescope` is not sufficient.** It resolves Fedora's *packaged* gamescope, which is older than the master we pin, and misses `xorg-x11-server-Xwayland-devel` — without which wlroots fails several minutes in with a `xserver.wrap` error that names nothing useful. Documented with the symptom, so the next person recognises it in one line instead of ten minutes. |
||
|
|
fd648aa776 |
feat(gamescope): put the cursor in the node, so a session can be zero-copy
gamescope keeps the pointer out of its PipeWire node — it lives on a hardware plane for scanout, and `paint_pipewire()` composites a separate, reduced frame that never includes it. So punktfunk has always reconstructed it from XFixes and blended it in host-side. That blend is what has been blocking the zero-CSC encode path, and the cost is larger than it sounds: `VulkanVideoEncoder::open` refuses the RGB-direct (EFC) source for any session with `cursor_blend`, because that front end is fixed-function and has no blend stage. `cursor_blend_for` sets it unconditionally for gamescope. Net effect: a gamescope session paid a full-frame colour-conversion pass per frame, forever, for a pointer. So put the cursor where it belongs. A second carried gamescope patch adds `--pipewire-composite-cursor` (off by default — the node has never carried it, and a consumer that draws its own would get two), painting it with the same `MouseCursor::paint` call the scanout composite uses. It scales for free: paint_pipewire has already set `currentOutputWidth/Height` to the capture size, which is what that function scales against. The repaint test grows the cursor's state beside the commit ids — a pointer-only move produces no commit, so without it the composited cursor would freeze on a static screen while the real one moved, and a cursor that became hidden would never be erased. Host side, the `+pfhdr` marker becomes a monotonic PATCH LEVEL, so one probe answers every capability the session must know before it is planned (level 1 = HDR formats, level 2 = the cursor flag). `cursor_blend_for` and the `gamescope_cursor` resolver both consult it through one helper, because they have to agree: the reader without the blend is a wasted X11 connection, the blend without the reader is a stream with no pointer, and both together with a gamescope that paints its own would draw two. ⚠ The two indirect spawn modes carry the flag through `PF_HDR_ARGS`, so this shares a dependency with the HDR flags: a session that ignores `GAMESCOPE_BIN`/`PATH` and execs the distro's gamescope gets neither. HDR fails loudly there (negotiation timeout + SDR latch); a missing cursor would be silent. Noted in packaging/gamescope/README.md as worth a post-spawn `/proc/<pid>/cmdline` check if it ever bites. |
||
|
|
2a13bc5252 |
fix(gamestream): advertise AV1 Main10, and honour HDR per negotiated codec
`ServerCodecModeSupport` layered `SCM_HEVC_MAIN10` and never `SCM_AV1_MAIN10`, on the stated theory that "the GameStream AV1 path is left off until live-confirmed". But the SDR baseline has always offered AV1 **Main8** to every client, so that path is either live or it is not — the DEPTH was never the uncertain part, and the omission only cost AV1-preferring clients their HDR. Now that the encoders probe 10-bit per codec, each bit is gated on that codec's own `can_encode_10bit` AND the SDR baseline already advertising it. A box that does HEVC Main10 but not 10-bit AV1 — or the reverse — advertises the truth instead of one bit standing in for both. Two gates follow from that: * `host_hdr_capable` becomes codec-agnostic (ANY 10-bit-capable codec makes the host HDR-capable). It was asking about HEVC alone, which would have hidden HDR entirely on a hypothetical AV1-only-10-bit box; * the RTSP honor gains the per-session half: a client that negotiated the codec this host CANNOT do 10-bit with degrades to 8-bit SDR there, rather than being handed a PQ label over an 8-bit stream. H.264 always lands there — there is no 10-bit H.264 encode anywhere. The unit test now pins each bit independently, including the two one-without- the-other cases a single shared flag got wrong in both directions. |
||
|
|
cb4690f216 |
feat(encode/vulkan): probe the device instead of guessing — AV1 10-bit + zero-CSC HDR
Three fixes to the same mistake: deciding what the Vulkan Video backend can do
from a table in our heads rather than from the driver, and routing everything
that didn't fit to libav VAAPI — where a session loses real RFI recovery and
the cursor blend for no reason the hardware asked for.
**Capability probe, per codec AND depth.** `probe_encode_support`'s "is there
an encode queue" boolean becomes `VulkanEncodeCaps { supported, eight_bit,
ten_bit }`, answered by `vkGetPhysicalDeviceVideoCapabilitiesKHR` against the
very profile chain the session open builds. So the dispatcher's prediction
cannot disagree with reality: a capable device keeps the Vulkan path, an
incapable one routes to VAAPI BEFORE burning a failed open, and the
cursor-blend mirror stays honest for free. This is the shape the direct-SDK
NVENC path already uses for its codec GUIDs.
**AV1 10-bit.** It was excluded on a guess about driver coverage; now the
device answers. `color_config()` carries `high_bitdepth` + the BT.2020/PQ CICP
triplet in both the `StdVideoAV1ColorConfig` and the sequence-header OBU we
bit-pack ourselves — they must stay identical or the driver's frame OBUs parse
against a header we didn't write. `high_bitdepth` sits BEFORE the CICP bytes,
so getting it wrong doesn't just mislabel the depth, it puts every following
field one bit out of phase; the new test reads the packed bits back.
**Zero-CSC RGB-direct in HDR.** The EFC probe assumed BT.709 and BGRA. It now
asks for the model this session's colourimetry needs (`MODEL_YCBCR_2020` for
10-bit — the extension has always had it) and for the CAPTURED format as an
encode-source format, and the session create-info selects the matching model.
An HDR session with no pointer to composite therefore hands the captured
buffer straight to the fixed-function front end and runs no host CSC at all.
Sessions that DO composite a pointer keep the compute CSC, unchanged: the EFC
cannot blend, and that rule outranks everything.
Also: `can_encode_10bit` on AMD/Intel now reports the union of VAAPI's and
Vulkan Video's answers instead of VAAPI's alone. `open_amd_intel` tries Vulkan
first and falls back, so either one being able to encode Main10 makes the
session 10-bit-capable — answering `false` because only one of them said yes
stranded encodable HDR sessions at 8 bits.
|
||
|
|
479f0965ee |
feat(encode/vulkan): Vulkan Video encodes 10-bit, so AMD/Intel HDR keeps the good path
The Vulkan Video backend was 8-bit for no structural reason — the API has `VK_VIDEO_COMPONENT_BIT_DEPTH_10_BIT` and `PROFILE_IDC_MAIN_10` in the very fields this pinned to 8 and MAIN, and AMD VCN and Intel both encode Main10. It was six hardcoded sites, and the cost of leaving them was paid twice over: an HDR session had to take libav VAAPI, losing real RFI loss recovery AND the compute CSC's cursor blend — which on gamescope is the only way the pointer reaches the stream at all, since gamescope has no embedded-cursor mode. An HDR session now opens a Main10 profile with 10-bit component depths, a `G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16` picture + DPB, and an SPS carrying `bit_depth_*_minus8 = 2` with the BT.2020/PQ CICP triplet instead of BT.709. `rgb2yuv10.comp` is the CSC's twin, and the two interesting parts of it are: * it is a PURE 3x3 matrix. The samples arrive already PQ-encoded (gamescope composites into the PQ container), so BT.2020 NCL applies to the code values as they are — there is no transfer function to apply here and applying one would be wrong; * the scratch planes are `R16`/`RG16`, not the picture's plane formats. The 10-bit ycbcr plane formats are not storage-image formats, so the shader writes the value into the HIGH bits by hand (`code10 << 6`, hence the `64/65535` factor and not `1/1023`) into planes that are merely SIZE-compatible with the picture's — which is all `vkCmdCopyImage` requires. Scope and safety: * HEVC only. AV1 10-bit encode has far thinner driver coverage, and a session open is not the place to gamble on it — those stay on VAAPI, as does a device that fails the Main10 profile query inside the open (the pre-existing "failed Vulkan open falls back to VAAPI" net, no new probe needed). * HDR pins the compute-CSC arm over the EFC RGB-direct one, which the EFC could not serve anyway: its fixed-function conversion is 8-bit BT.709 narrow with no knob for BT.2020. * `open_inner` binds `hdr` to the parameter-set HEADER bytes, so the depth flag is `ten_bit` there — the one name collision this change had to route around. |
||
|
|
86f4f950ba |
docs: the gamescope path is no longer 8-bit-only
Five pages asserted "gamescope's capture output is 8-bit" as a flat fact. It is a fact about the STOCK binary, so say that instead, and say what to install to change it: a new "HDR on gamescope" section covering the extra package, the two knobs, what has to line up for a session to go HDR at all, and the two things that surprise people — SDR content rides the same PQ stream at `--hdr-sdr-content-nits`, and AMD/Intel HDR sessions currently lose the composited pointer (the encode path that carries 10-bit is the one that cannot blend it). The roadmap's "parked / blocked" entry keeps the half that is still blocked (Mutter's `RecordVirtual` is SDR-only through the GNOME 51 dev branch) and drops the half that no longer is. |
||
|
|
17f824c3e9 |
feat(encode/nvenc): an HDR capture stays zero-copy on NVIDIA
AMD/Intel needed no new encoder code for HDR — the VAAPI path already ingests an XR30 dmabuf into `format=p010:out_color_matrix=bt2020`. NVIDIA did: the 10-bit formats were excluded from the GPU import outright, so an HDR session fell back to a CPU readback plus swscale, which is the one thing the capture path is not allowed to ship. It turns out no CSC kernel is needed. NVENC ingests packed 10-bit RGB natively as `ARGB10`/`ABGR10` and does the conversion itself following the configured VUI matrix — which `apply_low_latency_config` already sets to BT.2020 NCL for an HDR session. So the frame travels LINEAR dmabuf → Vulkan bridge → CUDA → NVENC unconverted: no host CSC pass, no depth loss, no extra work on a contended SM. * invariant 1 is restated rather than dropped: HDR must never take the TILED EGL de-tile blit (it renders into an 8-bit `GL_RGBA8` texture). The HDR pods are LINEAR-only by construction, so the plan may build the importer; the per-frame gate — which sees the negotiated modifier the plan cannot — is what enforces the tiled half, and falls back to the CPU path if a producer ever ignores our offer. * …but only where the encoder can actually take the payload (`linux_hdr_cuda_ok`). libav's HDR route builds a P010 hardware frames context and swscales into it, so on a host without the direct-SDK backend a packed-2:10:10:10 CUDA buffer would land in a P010 surface as garbage. Those keep the CPU path. * `nvenc_cuda` stops pinning 8-bit/SDR. Depth and HDR now follow the INPUT format, like the Windows backend: a 10-bit session whose capture came back 8-bit encodes AND labels 8-bit rather than mislabelling. * the cursor-blend compute shader gains two 10-bit modes, so the pointer gamescope leaves out of its node survives the HDR path. Same display-referred blend the CPU path's `composite_cursor_rgb10` already does — the samples are PQ, and a real sRGB→PQ cursor LUT is polish, not correctness for a pointer. |
||
|
|
689297c86e |
feat(hdr): a gamescope session streams true HDR10, decided before the Welcome
The Linux native plane has been 8-bit for a reason that stopped being true: `capturer_supports_hdr()` returned a flat `false` because Mutter's virtual monitors are SDR-only upstream. That is still right for Mutter, KWin and wlroots — and wrong for gamescope, whose node can now offer 10-bit BT.2020 PQ (packaging/gamescope). Open the three gates that held it off, together: * the capture-side gate becomes SOURCE-AWARE (`capturer_supports_hdr_for`): Windows keeps its platform answer, gamescope asks whether the resolved binary offers the formats, everything else stays false. It must be truthful rather than optimistic — the Welcome is irrevocable, and PQ frames on an 8-bit encoder are a deliberate hard error — so every term is a static fact resolved before anything is spawned, including "do we spawn gamescope at all" (an attach inherits someone else's flags and can promise nothing); * `want_hdr` reaches the capturer: `open_virtual_output` grew the parameter it never had, and the host arm stopped dropping `want.hdr` on the floor; * the spawn gains `--hdr-enabled --hdr-debug-force-support` on all three sub-modes (bare args, the `GAMESCOPE_BIN` wrapper, the SteamOS PATH shim), from one shared `hdr_args` — the force flag is what makes it work headless, and forgetting it looks exactly like a negotiation failure. Two things that would have gone wrong quietly: * the keep-alive reuse key gains `hdr`. gamescope cannot turn HDR on live, so a kept SDR display handed to an HDR session would give the game no HDR surfaces while the stream negotiated PQ over an SDR composite — wrong, and not obviously broken. Same for the managed session-plus/SteamOS reuse check. * the HDR-negotiation-failure latch becomes PER SOURCE. It was one process-wide flag, so a monitor that left HDR mode would have disabled gamescope HDR until the host restarted, and vice versa — two facts with nothing in common but the word HDR. GameStream follows the same shape: `host_hdr_capable` gains the gamescope arm, and the RTSP gate's live BT.2100 monitor probe is scoped to the portal source — a headless box has no monitor to be in HDR mode, so running it there would hard-refuse every gamescope HDR session. Off by default for one release (`PUNKTFUNK_GAMESCOPE_HDR=1`), and `punktfunk-host hdr-probe` now answers for both Linux HDR sources. |
||
|
|
d6818263ce |
feat(gamescope): a build of gamescope whose capture output can be HDR
gamescope's built-in PipeWire node has always been SDR-only: it offers BGRx and NV12, and `paint_pipewire()` hardcodes a Gamma-2.2 composite with the SDR screenshot LUT set. So an HDR game reaches punktfunk already tone-mapped down, and the whole gamescope backend streams 8-bit no matter what the client can decode. Games CAN render HDR on a headless gamescope today — only the capture half is missing. Carry the two patches that close it (packaging/gamescope/patches): the node additionally offers `xRGB_210LE`/`xBGR_210LE` with MANDATORY SMPTE ST.2084 + BT.2020 props, mapped to the same 10-bit capture texture the HDR AVIF screenshot path already allocates, and `paint_pipewire()` composites into them with the HDR screenshot LUTs + `EOTF_PQ` — which is exactly the in-tree `bHDRScreenshot` branch. The new formats are listed LAST, so every existing consumer keeps negotiating the 8-bit stream bit-for-bit. Offered upstream against ValveSoftware/gamescope#2126. Ship it as `punktfunk-gamescope`, beside the distro's own binary rather than replacing it: a Bazzite sysext payload (`build-sysext.sh --gamescope`, which refuses a binary without the marker), an Arch package, a nix derivation + `services.punktfunk.host.gamescopeHdr`, and one distro-agnostic build script the rest call. The second patch stamps `+pfhdr1` into the `--version` banner. That is not cosmetic: punktfunk fixes a session's bit depth in the Welcome, before the display exists and with no way to take it back, so its capability answer has to be a static property of the binary it will spawn. |
||
|
|
28c50d1c5b |
fix(encode): every backend signals its colour, so no decoder has to guess
audit / cargo-audit (push) Successful in 2m38s
audit / bun-audit (push) Successful in 13s
ci / rust (push) Failing after 12s
ci / web (push) Successful in 1m4s
ci / docs-site (push) Successful in 1m8s
ci / bench (push) Successful in 6m59s
ci / rust-arm64 (push) Successful in 10m2s
android / android (push) Successful in 13m6s
decky / build-publish (push) Successful in 23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
arch / build-publish (push) Failing after 14m19s
deb / build-publish (push) Successful in 11m13s
deb / build-publish-host (push) Failing after 4m36s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 44s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m6s
docker / build-push-arm64cross (push) Successful in 11s
docker / deploy-docs (push) Successful in 35s
windows-host / package (push) Failing after 7m59s
windows-host / winget-source (push) Skipped
deb / build-publish-client-arm64 (push) Successful in 7m11s
apple / swift (push) Successful in 5m17s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m21s
flatpak / build-publish (push) Successful in 6m43s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m30s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m57s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 6m23s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m6s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m14s
apple / screenshots (push) Successful in 22m57s
Three encode paths shipped a bitstream with no colour description at all, leaving primaries/transfer/matrix/range "unspecified": - Vulkan Video HEVC (`vk_build.rs`) built an SPS with no VUI whatsoever. This is the DEFAULT backend for AMD/Intel Linux hosts on HEVC/AV1. - Vulkan Video AV1 packed `color_description_present_flag = 0`. - The openh264 software path wrote nothing (it converts BT.709 limited and relied on decoders defaulting to that). - The libav-NVENC Linux path excluded packed-RGB 4:2:0, on the belief that "NVENC's internal CSC writes its own VUI". It doesn't: libavcodec derives `colourDescriptionPresentFlag` from the AVCodecContext colour fields, so leaving them unspecified emits none. Reachable on a CPU/dmabuf capture, a build without `--features nvenc`, or PUNKTFUNK_NVENC_DIRECT=0. Unsignalled looks fine on every punktfunk client — `csc_rows` falls back to BT.709 on "unspecified" — which is why this survived. Vendor TV decoders do not: they guess colorimetry from RESOLUTION, and an LG webOS panel reads a 4K SDR stream as BT.2020 and renders it visibly washed out. All four now signal BT.709 limited, which is what every host CSC actually produces (`rgb2yuv.comp`, `convert_bt709`, the swscale paths) and what the Welcome's `ColorInfo::SDR_BT709` already advertises out-of-band. NVENC, VAAPI, QSV, AMF and the Windows libav path were already correct. Two tests, both parsing the REAL emitted bitstream rather than re-asserting the constants: an independent bit-walk of the AV1 sequence header (the packed OBU must stay identical to the `StdVideoAV1ColorConfig` handed to the driver), and an H.264 SPS/VUI parse proving openh264 honours the request instead of dropping it. Not yet verified on hardware: the HEVC VUI depends on the driver's SPS writer emitting `vui_parameters()`. PUNKTFUNK_VULKAN_ENCODE=0 falls back to VAAPI if a driver mishandles it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 3c56ff5717b2c9a0871953127da3dadd6a84220d) |
||
|
|
1db8f7631b |
fix(nix): move the bun packages to bun2nix — no more hand-bumped deps hash
`nix build .#punktfunk-web` has been broken since |
||
|
|
0d8862457b |
style(nix): run the repo's own nix fmt (nixfmt-rfc-style) over the flake
flake.nix, packaging/nix/packages.nix and packaging/nix/nixos-module.nix had drifted from the formatter the flake itself declares (`formatter = nixfmt-rfc-style`). Pure reformat — no expression changes — split out so the bun2nix migration that follows is reviewable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 927b27c4fade6d646e3ef822678b6738f1e2f28a) |
||
|
|
347c106498 |
fix(packaging/tray): the .deb must build the tray in its own cargo invocation
punktfunk-tray panicked at every launch on Debian/Ubuntu: zbus-5.16.0/src/abstractions/executor.rs:190 there is no reactor running, must be called from the context of a Tokio 1.x runtime Not a code bug — cargo feature unification. zbus picks its executor from its own feature flags; the host's ashpd enables zbus/tokio while the tray runs ksni's async-io executor with no tokio runtime by design. Features are additive across everything built in ONE invocation, and deb.yml built `-p punktfunk-host -p punktfunk-tray` together, handing the tray a tokio-flavoured zbus with no runtime anywhere near it. The invariant is already established and explained inline in the RPM spec, the Arch PKGBUILD and the Nix packages.nix — all three build the tray alone, and their comments even claim "(Same split the .deb does.)" The .deb did not, in two places at once: the workflow co-built it, and build-deb.sh's own correct standalone build was skipped by an `if [ ! -x "$TRAY_BIN" ]` guard, so it packaged the poisoned artifact. That is also why only Debian/Ubuntu users saw it. Drops the tray from the workflow's host build and makes build-deb.sh's tray build unconditional — cargo no-ops when the artifact is already async-io-resolved and rebuilds it when it is not, so a stale co-built binary can no longer be shipped. Corrects the Nix README note that had recorded deb/rpm/arch as sharing a "latent" crash, and its nix develop recipe, which co-built all four. Verified on a Linux box: co-built reproduces the panic exactly; built alone, the tray reaches the session bus and exits with the intended "no StatusNotifier tray available". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit f91983a84c56464bd2c18d537963cb7fb3a6e059) |
||
|
|
0868b6a364 |
fix(vdisplay): compositor availability follows the live session, not the env
GNOME/Mutter reported "Unavailable" on a host sitting in a live Mutter session — and, on the same request, "Default", because the two columns had different sources. available() asked each backend, and those probes read the process env (XDG_CURRENT_DESKTOP for Mutter, WAYLAND_DISPLAY for KWin's registry handshake, SWAYSOCK for sway) — env a host started outside the session (systemd --user, a TTY, ssh) never inherited. It is only retargeted at the live session on the connect path, so the answer also flipped depending on whether anyone had connected yet. Both columns now come from the same /proc scan detect() already used: the live session's compositor is usable by definition, as is an explicit operator pin, and the per-backend probe stays as the fallback for backends that are not the live session (gamescope, which spawns its own). A live KWin without the zkde_screencast grant now surfaces as available and fails at create with that probe's precise message, which beats "no usable compositor" on a box visibly running KDE. Mutter's env sniff stays deliberately narrow — one var, not three. XDG_CURRENT_DESKTOP is the one apply_session_env owns end to end (written per connect, scrubbed when nothing is live); sniffing DESKTOP_SESSION alongside would resurrect the bug that scrub exists to prevent, where a stale value after a gnome-shell crash routes the next client into a dead session. Non-Linux hosts now report no compositors at all rather than five Linux backends flagged unavailable with no default — on Windows the pf-vdisplay driver is the only backend and vdisplay::open ignores the argument, so the old list read as broken detection instead of "not applicable here". The console says so explicitly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit eb7ba3d6177552f5c3ed6a15439404084869c636) |
||
|
|
3b11288c97 |
fix(web): unsaved custom display settings are visible and recoverable
Everything else on the Displays page auto-applies — a preset click, the game-session choice, the experimental toggles — but the Custom block does not, and its Save button sat at the bottom of a block taller than most viewports. People edited, never scrolled far enough to find it, navigated away, and silently lost the lot. The draft is now compared against the last-seeded server value, and that one boolean drives the whole affordance: a badge in the card header (visible without scrolling), an amber ring plus a title on the block, and a sticky action bar pinned to the viewport for as long as any part of the block is on screen. The bar states which of the two states you are in rather than leaving it implied, Save disables when there is nothing to save, and Discard changes puts the stored policy back. Two ways edits could still vanish are closed: a preset click now confirms before overwriting pending edits, and a reload/close warns. Re-picking Custom while already on Custom is a no-op instead of re-seeding, which would otherwise fill in defaults for unset fields and report "unsaved changes" for a click that changed nothing. Adds a `warning` badge variant + --warning token for the pending state — attention, not failure, and distinct from the destructive red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit c4318609c0da5ae1313081c5e488f685504f70d1) |
||
|
|
5ea087ca47 |
feat(session): the stats overlay scales with the display's DPI
The stream chrome — stats OSD, capture hint, start banner, resize label — was hardcoded at 14 px with 12/10/8 px insets. The overlay composites into the swapchain 1:1 in PHYSICAL pixels, so on a 4K panel at 200 % all of it rendered at half its intended physical size: fine on a 1080p monitor, a squint on a HiDPI laptop. FrameCtx now carries a scale — SDL's window display scale (DPI × the display's content scale) times a PUNKTFUNK_OSD_SCALE preference — and every metric moved into a `base` module that is multiplied by it. Re-read per frame and quantized into the damage key, so dragging the window to a differently-scaled monitor re-renders at the new size rather than keeping the stale one. Sanitized because SDL returns 0.0 when it cannot resolve the window's display, which would collapse the panel to nothing. Two details worth keeping: the face is re-derived at the scaled size rather than the canvas transformed, because Skia rasterizes glyphs at the requested size where a magnified 14 px bitmap would be mush; and the long capture hint is fit-clamped to the window — at 2× it is wider than a 1080p screen, so scaling it naively would have traded a small OSD for a truncated one. Linux and Windows share this path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 744467d13a28b91ba88a23d6038da70263e9b502) |
||
|
|
b444308592 |
feat(host): PUNKTFUNK_HOST_NAME names the host in Moonlight and the clients
A box called `bazzite-htpc` had no way to present itself as "Living Room" short of renaming the machine. The new knob overrides the name everywhere a human sees it: the GameStream serverinfo <hostname> element and the mDNS service instance name both adverts carry. Unset (the default) is the machine's own hostname, exactly as before. Free text is the point, so the DNS-level name is now a separate concern from the display name. The instance label may contain spaces and accents; an A-record target may not, and mdns-sd rejects the whole ServiceInfo if the target is not a legal name — which would take discovery down rather than merely look wrong. dns_label() sanitizes the target and passes an already-legal name through byte-for-byte, so hosts without the override advertise precisely what they always did. The display name loses `.` (it would split the label, and clients derive the name as the first label of the fullname, so "Ben's PC v1.2" would arrive as "Ben's PC v1") and is capped at the 63-byte DNS-SD ceiling on a char boundary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit bbf72261a12e0e67601f549d40db65ae61979268) |
||
|
|
40714317a8 |
fix(web): empty-state cards get their top padding back
"No games found yet." sat flush against the top edge of its card on any screen ≥640px. The call sites overrode CardContent's padding with a bare `p-8`, but tailwind-merge only resolves conflicts within a variant: `p-8` cancels `p-4` and leaves `sm:p-6 sm:pt-0` standing, so the desktop breakpoint kept the zero top inset that exists for cards WITH a header. These three have none. Uses `flush` — the escape hatch CardContent documents for exactly this — so the padding is owned outright at every breakpoint instead of fought from the outside. The library grid is the one that was reported; the store catalogue and the recordings list are the same idiom with the same bug. PairedDevices deliberately keeps its `p-6`: it is a table under a header, where pt-0 is the intended look. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 71fc47f32af7c74327a26425035298f8e6464c96) |
||
|
|
188f55d3b1 |
fix(encode/nvenc): the host advertises what the driver lists, not a superset
Every NVIDIA host advertised a static H.264|HEVC|AV1 superset, so a 1st-gen Maxwell (GTX 960M, no HEVC/AV1 encode) offered HEVC — a client that believed it got ~15 s of blank video and a disconnect instead of a stream. Both OSes now ask the driver itself (nvEncGetEncodeGUIDs) on one throwaway direct-SDK session: Linux on the shared CUDA context, Windows on the selected render adapter, wired into host_wire_caps AND the GameStream serverinfo mask (which had been left on the superset for NVIDIA on both OSes). Fails open — an unanswerable probe keeps the historical superset, so it can only ever narrow the advertisement to codecs the GPU really encodes. The HEVC 4:4:4 answer rides the same session on Linux instead of opening a libav hevc_nvenc FREXT probe: that open is the prime suspect for the field bug where one probe wedges NVENC process-wide (NV_ENC_ERR_INVALID_VERSION on every later session until a host restart), and the direct backend re-checks the same caps bit at session open anyway. The ffmpeg probe remains only for hosts that really stream over libav (PUNKTFUNK_NVENC_DIRECT=0 or a build without the nvenc feature), where ffmpeg's NVENC client runs regardless. The 10-bit probe deliberately stays libav — Linux HDR rides the libav P010 path. On-hardware: .136 (RTX 5070 Ti) 14/14 nvenc tests in one process incl. the probe followed by real sessions and dirty teardown; .173 (Windows RTX) probe + 47 release lib tests. The Windows probe test documents the pre-existing MSVC debug-link failure (LNK2019 via the sdk crate's unused lazy loader) — run it with --release, the same reason windows-host.yml gates with clippy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 0346ec8090568eb499e8cb7d735305b28471185e) |
||
|
|
560e663aef |
fix(drivers): the pad channel asks the devnode who to trust, not the mailbox
ci / rust (push) Failing after 12s
windows-drivers / probe-and-proto (push) Successful in 48s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m6s
deb / build-publish-client-arm64 (push) Failing after 10s
decky / build-publish (push) Successful in 47s
windows-drivers / driver-build (push) Successful in 1m40s
apple / swift (push) Successful in 3m6s
ci / bench (push) Successful in 7m39s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8m2s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m0s
android / android (push) Successful in 12m28s
deb / build-publish (push) Successful in 12m13s
ci / rust-arm64 (push) Successful in 12m31s
arch / build-publish (push) Successful in 12m40s
deb / build-publish-host (push) Successful in 12m17s
windows-host / package (push) Successful in 18m26s
windows-host / winget-source (push) Skipped
apple / screenshots (push) Successful in 23m25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m34s
docker / build-push-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 31s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m24s
A LocalService principal could take over a virtual pad's shared input section and
forge HID input into the interactive desktop.
The host duplicates each pad's unnamed DATA section into the driver's WUDFHost, and
through gamepad proto v2 it learned that process from `driver_pid` in the named
bootstrap mailbox. That mailbox has to be LocalService-writable — that is what the
driver's own WUDFHost runs as — and the delivery gate, verify_is_wudfhost, only checks
that the target's IMAGE is %SystemRoot%\System32\WUDFHost.exe. That image is
world-executable. So anything running as LocalService — notably the deliberately
de-privileged plugin runner — could spawn its own WUDFHost (CREATE_SUSPENDED parks it
indefinitely with the right image path), publish that pid, and be handed
SECTION_MAP_READ|WRITE on a live section. For pf-mouse that section drives a real
absolute pointer, so it was desktop control; for the pads it was forged gamepad input
plus a read of the remote user's controller state.
The module docs claimed mailbox tampering "yields at worst a gamepad DoS, never a read
or an injection". That was wrong, and the reasoning behind it — that a LocalService
token is DACL-denied OpenProcess on a UMDF WUDFHost — only covers the REAL host, not
one the attacker spawned itself.
The pid now comes from the device stack (ChannelProof, proto 2 -> 3). The host asks the
devnode it SwDeviceCreate'd who is serving it, looked up by the instance id PnP handed
back, so a planted look-alike devnode is not a candidate and the kernel — not anything
the attacker supplies — does the routing. Only the driver PnP actually bound to that
device can answer. `driver_pid` survives as a liveness hint; a tamperer can still deny a
pad, which squatting the name always allowed, but can no longer choose the recipient.
Two rules keep the state machine honest around it: a delivery stands until its target
process EXITS (judged on a retained SYNCHRONIZE handle, so a recycled pid cannot fake
it, and UMDF's restart-after-driver-crash still re-attaches), and a pad with no
SwDeviceCreate devnode refuses to deliver rather than fall back — unless an operator
sets PUNKTFUNK_PAD_CHANNEL_TRUST_MAILBOX, which says so loudly.
Three transports, because Windows carries different things to different driver shapes,
and the obvious two did not survive contact with hidclass. Measured on .173 (Win11
26200): HidD_GetIndexedString is NOT forwarded to a UMDF HID minidriver at all — it
failed for every index including ones the driver demonstrably serves through the named
wrappers; and a private device interface registers and enumerates but cannot be OPENED
(ERROR_GEN_FAILURE), because hidclass owns IRP_MJ_CREATE on a devnode it is the FDO for.
That is exactly why pf-xusb was never affected: it is not a HID minidriver, so nothing
sits above it. What works:
* pf-xusb — a private IOCTL on its own GUID_DEVINTERFACE_XUSB.
* pf-mouse — the HID serial string. Verified: PFCP:3:0:7296, and 7296 was a genuine
service-spawned WUDFHost.exe. Safe here alone: nothing reads the virtual
mouse's serial, whereas a pad's is SDL/Steam dedup material.
* pf-gamepad — a HID feature report, and it cost NO report-descriptor change. The
captured descriptors already declare far more Feature ids than the driver
ever served: 0x85 is declared on DualSense, DualShock 4 and Edge alike and
used to fail with STATUS_INVALID_PARAMETER, so hidclass lets it through and
nothing can have depended on the old failure. The Deck's one feature report
is unnumbered and Steam drives it command->response, so its proof rides that
existing contract via a private two-byte command. Verified: feature 0x85
returned magic "PFCP", proto 3, pad_index 0, wudf_pid 18456 — and 18456 was
a WUDFHost — with the product string still 'DualSense Wireless Controller'.
Also renamed pf-dualsense -> pf-gamepad. One driver has always served four identities, so
the old name read as if the other three lived elsewhere. ONLY the package identity moved
(crate, INF/CAT/DLL, UMDF service, build script, CI lines, log file, env var). The four
HARDWARE IDS are deliberately unchanged — they bind every devnode the host creates and
every installed system — as are the Global\pfds-boot-<i> mailbox and PAD_MAGIC, which are
wire contract. `driver install --gamepad` now retires the pre-rename store package first,
matched on pf_dualsense.dll because that string appears only in the OLD inf; matching on
the hardware ids would delete what we are about to install. On .173 that separated 14
stale packages from the 1 new one with 0 ambiguous, and the renamed package binds the old
hwid (devgen root\pf_dualsense -> oem143.inf = pf_gamepad.inf).
The repo's own pre-commit/pre-push rustfmt hooks named the old crate, so they caught the
rename before the commit did — they now check pf-gamepad, and pf-mouse alongside it, which
they had been missing relative to the CI line.
Host and drivers MUST ship together: v2<->v3 fails closed in both directions by design,
with the existing "update host + drivers together" diagnostic.
The rename moved files that also carry the security change, so splitting this into two
commits would mean reconstructing an intermediate state that was never gated. It is one
commit on purpose.
Gated on the windows-amd64 runner with cargo clean first (the box's clock lags, so stale
artifacts would read as a vacuous green): clippy -D warnings clean for pf-inject,
pf-capture and pf-driver-proto, drivers workspace build + the CI clippy line clean,
cargo check --release -p punktfunk-host clean, 19 + 58 tests green. Also fixes pf-mouse
still writing its debug log to world-writable C:\Users\Public, which the 2026-07-17
review moved for the other three drivers and missed here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
9b3ec9204c |
fix(capture): a refused EGL→CUDA dmabuf offer stops being asked
ci / rust (push) Failing after 41s
android / android (push) Canceled after 1m19s
apple / screenshots (push) Canceled after 0s
apple / swift (push) Canceled after 1m20s
arch / build-publish (push) Canceled after 1m23s
ci / rust-arm64 (push) Canceled after 1m24s
ci / web (push) Canceled after 1m24s
ci / docs-site (push) Canceled after 1m23s
ci / bench (push) Canceled after 1m22s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / build-publish (push) Canceled after 46s
deb / build-publish-host (push) Canceled after 2s
decky / build-publish (push) Canceled after 1s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-host / package (push) Canceled after 1m35s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 5s
The raw-dmabuf passthrough has had a negotiation-timeout latch since the hybrid-Intel case: one conclusive timeout and later captures negotiate the CPU path instead of re-paying it. The EGL→CUDA dmabuf-only offer had no equivalent — a compositor that accepts none of the importer's modifiers timed out the same 10 s negotiation on every session, forever, under the generic 'format negotiation never completed' diagnosis. Now the symmetric latch (note_gpu_dmabuf_negotiation_failed) gates build_importer in the one negotiation resolver, scoped to this offer alone (raw passthrough, worker-death latch, encoder untouched), with the same operator escape as the raw arm: an explicit PUNKTFUNK_ZEROCOPY=1 keeps erroring loudly instead of downgrading. One correctness detail beyond the handoff's sketch: whether the GPU offer was ACTUALLY advertised is a runtime fact of the PipeWire thread (the importer may fail to construct — no CUDA — in which case no dmabuf was offered and a timeout must not latch it off). plan.build_importer alone cannot answer that, so the thread records the made-offer on CaptureSignals (gpu_dmabuf_offer) and the timeout diagnosis branches on the offer that really happened, not the plan's intent. Also renames vaapi_dmabuf_forced → zerocopy_forced (it now guards both timeout arms; single caller). New plan invariant pinned in tests: the latch gates only the importer, never the raw passthrough. (V3 from design/pf-zerocopy-sweep-handoff.md — the deferred design call, now decided in favour of the symmetric latch.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e92a0aaa00 |
fix(zerocopy/vkslot): a timed-out blend can't corrupt its slot, and NV12 cursor chroma sits on the grid
Two [GPU]-class defects from design/pf-zerocopy-sweep-handoff.md; the code is compile- and unit-verified, the visual halves still owe on-glass time. - blend_ref reused a fence and command buffer after a wait timeout: on TIMEOUT it reset a fence that still had a pending signal and re-recorded a command buffer the GPU might still be executing — reached exactly in the contended case the CPU-synced path exists for (no timeline export + visible cursor + a >1 s GPU stall). A failed wait now drains the device before the reset (the VkBridge::import_linear precedent), and free_slots quiesces unconditionally instead of only when a timeline exists, so teardown after a timed-out sync blend no longer frees objects an outstanding submission references. (C1) - cursor_blend.comp anchored NV12 chroma blocks to the cursor's oy, not the surface chroma grid: for odd oy every UV sample averaged luma rows one below the rows it covers (a one-row colour fringe on the cursor's edges, ~half of all cursor positions) and the cursor's last row's chroma was never written. Block rows now anchor to the chroma grid the same way spans anchor to the word grid — y0 = floor(oy/2)*2, per-row cy guards for the straddle block — and blend_geometry counts blocks from the same anchor (one extra block when oy is odd; covered by new tests, including negative oy). cursor_blend.spv rebuilt (glslangValidator, spirv-val clean, drift gate passes). (C6) Owed on-glass: a stalled-GPU cursor session for C1; an odd-oy cursor colour check for C6 (subtle fringe on the cursor's top/bottom edge). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
fa083f50d3 |
fix(zerocopy): unsafe fn bodies join the unsafe-proof program
#![deny(clippy::undocumented_unsafe_blocks)] never inspected the body of an unsafe fn — operations there are not "unsafe blocks" — so roughly 20 functions' worth of raw GL/CUDA/Vulkan driver calls sat outside the invariant the crate advertises. Concretely, that blind spot is why the constructor-leak and teardown-ordering shapes fixed earlier in this series could ship without ever prompting a reviewer. #![deny(unsafe_op_in_unsafe_fn)] now closes the gap: every unsafe fn body is an explicit unsafe block carrying a SAFETY comment that names the caller contract it relies on (the dlopen'd CUDA wrapper table gets a uniform forward-to-live-table proof). Mechanical; no behavior change. (V2 from design/pf-zerocopy-sweep-handoff.md.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c677732c60 |
fix(zerocopy/client): a wedged worker can't park the reaper, and no worker outlives the host
Three related reaper problems (R5 from design/pf-zerocopy-sweep-handoff.md): - sweep_reaper called kill() + blocking wait() while holding the global REAPER mutex. A worker wedged in a driver ioctl is in D state and ignores SIGKILL, so wait() never returned — parking every later spawn() and every importer drop() behind the lock. Expired entries are now drained under the lock and killed outside it, with a bounded (~100 ms) try_wait poll; a worker that still won't die is parked again for a later sweep (re-killing is harmless) instead of blocking anyone. - No PR_SET_PDEATHSIG: if the host died, the worker survived holding its CUcontext + BufferPool — by the code's own comment, hundreds of MB of VRAM. The pre_exec closure (async-signal-safe: prctl/getppid/dup2/fcntl only, no allocation) now arms SIGKILL-on-parent-death with the standard getppid race guard. Not taken: arming the 20 s kill deadline from a timer instead of the next spawn/drop (the handoff's optional third leg). PDEATHSIG closes the worst-case orphan; a wedged worker after the LAST capture of a session still waits for the next spawn to be swept. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3c62da3b8e |
fix(zerocopy/client): renegotiation retires the old generation's IPC mappings
Shared::mappings only ever grew: clear_cache reset sent_keys and told the worker to drop its fd cache, but nothing closed the host-side CUDA IPC mappings for the previous pool generation. A session that renegotiates repeatedly (mode changes, HDR toggles, client reconnects) accumulated a pool's worth of stale mappings each time, each pinning a host VA reservation to peer memory the worker had already freed. Mappings now carry a refcount and a retired flag. clear_cache closes every unreferenced mapping immediately and marks the rest retired; a retired mapping closes when its last in-flight DeviceBuffer releases. The worker's half of the contract: ClearCache also forgets its VA→id map, so anything delivered after the boundary gets a fresh id WITH its descriptor — without that, a same-shape renegotiation (worker keeps its pool) would re-deliver an old id whose host mapping was just closed, and the host would misread it as a desync. Ids never repeat (next_id only counts up), so fresh ids cannot collide with the graveyard. (R6 from design/pf-zerocopy-sweep-handoff.md.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a2033d6c82 |
fix(zerocopy/vulkan): VkBridge bring-up and the CSC build unwind instead of leaking
VkBridge::new leaked its instance (and past device creation, the device and command pool too) on every error path — reached repeatedly, because a box whose Vulkan device refuses the external-memory extensions retries the bridge on every LINEAR frame. Pre-device failures now destroy the instance explicitly (the VkSlotBlend::new shape); after device creation the remaining objects build into an incrementally-filled struct whose existing Drop tolerates the nulls a partial init leaves (Vulkan destroy calls are defined no-ops on VK_NULL_HANDLE). ensure_csc had the same hole across its six-object pipeline build; the fallible half now fills the Csc front to back and a mid-build failure destroys exactly what was created, leaving self.csc None for a clean retry. (R3 from design/pf-zerocopy-sweep-handoff.md.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8d02255703 |
fix(zerocopy/egl): construction can fail late, so teardown must be structural
Three unwind holes in the EGL side, all of the same shape — fallible construction over raw handles with no Drop to unwind through — and all retried per frame, so a sustained failure (VRAM pressure is the realistic one) leaked unboundedly: - The GlBlit/Nv12Blit/Yuv444Blit constructors interleave GL-object creation with fallible steps (FBO completeness, CUDA registration, pool allocation). A GlNameGuard now owns every bare GL name until the final struct exists; on unwind it deletes them AFTER the RegisteredTexture locals unregister (declaration order), preserving the unregister-before-delete invariant. gl.rs gets the same treatment for its compile paths: the vertex shader on a fragment-compile failure, the program on a link failure. (R1) - EglImporter::new leaked the DRM render-node fd and the whole gbm_device on every '?' after their creation (most realistically cuda::context() failing on a host where EGL comes up but CUDA does not). Both now live in a GbmDevice with its own Drop: destroy the device, then close the fd. (R2) - EglImporter's manual Drop destroyed the gbm device and closed the fd BEFORE field drops ran the blit destructors, which then called cuGraphicsUnregisterResource/glDeleteTextures against a dead native display — the stale-driver-state class this path once crashed on. The Drop impl is gone; teardown is now purely field order (blits and bridge first, GbmDevice last), and the blit SAFETY comments cite that order instead of the previously-false claim. (R4) (R1/R2/R4 from design/pf-zerocopy-sweep-handoff.md.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8de5ba4092 |
test(zerocopy): the fd must actually cross the socket, not just claim to
SCM_RIGHTS descriptor passing is the mechanism the whole worker isolation design rests on, and no test verified it end to end: both suites asserted only the has_fd boolean parsed from the JSON body. Setting the send-site's descriptor to None — zero-copy broken in production — stayed green; so did dropping the received fd in the worker's dispatch loop. Now the client-side scripted peer records the st_ino of every descriptor that arrives and the tests assert the sequence against dmabuf_key() of the sent plane (SCM_RIGHTS re-numbers the fd but preserves the open file description, so the inode is the identity the worker keys its cache on); the worker dispatch test sends one import with a live fd and asserts the backend received a descriptor with the sender's identity. (T1 from design/pf-zerocopy-sweep-handoff.md) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
143a707f76 |
fix(zerocopy): the mechanical sweep batch — truthful fence waits, forgiving env flags, no leaked planes
From the pf-zerocopy review sweep (design/pf-zerocopy-sweep-handoff.md), the compile-verifiable batch: - dmabuf_fence: the blocking poll's result was discarded — EINTR silently skipped the wait (reopening the stale-frame race a SIGCHLD away) and a timeout reported as waited. Now EINTR retries with the remaining budget and the caller gets Signaled/TimedOut/NoFence, so the one diagnostic operators have about implicit fencing stops lying. (C2) - env flags: PUNKTFUNK_ZEROCOPY=TRUE meant *off* — values are case-folded now, and an unrecognised spelling falls back to the flag's default with a one-shot warning instead of silently inverting the operator's intent. (C3) - cuda: alloc_pitched_nv12 leaked the Y plane when the UV allocation failed (per-frame under VRAM pressure — the worst possible time to leak); a failed async-copy enqueue now drains the stream before returning, so a recycled pool buffer can't race an orphaned in-flight copy. (C4, C5) - worker: --fd is validated (>= 3, fstat + S_ISSOCK) before OwnedFd adoption — 'zerocopy-worker --fd -1' was constructing OwnedFd's niche value. (V1) - docs: all 15 rustdoc warnings fixed, including the link to the renamed note_raw_dmabuf_negotiation_failed. (D1) - CI: a SPIR-V drift gate — the committed .spv blobs are include_bytes!'d and rebuilt by hand; the gate diffs disassembly (filtering only the shaderc/glslang generator difference) so a forgotten rebuild fails CI instead of shipping the old kernel. Both blobs verified in sync. (S1) - tests: bt709_limited pinned to external BT.709 anchors (it is the sole oracle for the GPU colour self-test); blend_geometry gets its first tests — empty rect, CURSOR_MAX clamp, per-format group counts, and negative-ox floor alignment. (T2, T3) Verified on .25: clippy -D warnings clean, 27/27 tests, cargo doc 0 warnings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c4e80fd455 |
docs(release): v0.21.0 notes should only cover its own delta
apple / swift (push) Successful in 1m33s
android / android (push) Successful in 12m23s
arch / build-publish (push) Successful in 13m11s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m5s
ci / bench (push) Successful in 5m47s
ci / rust-arm64 (push) Successful in 9m23s
ci / rust (push) Successful in 23m7s
apple / screenshots (push) Successful in 21m43s
deb / build-publish (push) Successful in 8m56s
deb / build-publish-client-arm64 (push) Successful in 7m24s
decky / build-publish (push) Successful in 32s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 40s
deb / build-publish-host (push) Successful in 9m55s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7m52s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m36s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10m55s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m7s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5m32s
docker / build-push-arm64cross (push) Successful in 4m23s
docker / deploy-docs (push) Successful in 31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m39s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m8s
Duplicated the entire v0.20.1 fix bundle verbatim instead of following house convention (each vX.Y.Z.md covers only what changed since the immediately preceding release file — see v0.19.1 -> v0.19.2, neither restates the other). v0.20.1 is a real, already-tagged release with its own notes file; 0.21.0 only needed to add the monitor-streaming feature, the KDE registry fix found while building it, and the CI-only winget fix, with a pointer back to v0.20.1 for the rest. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
d08893383a |
chore(release): bump workspace version to 0.21.0
audit / cargo-audit (push) Successful in 2m21s
audit / bun-audit (push) Successful in 14s
apple / swift (push) Successful in 5m3s
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 1m9s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m9s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m51s
ci / bench (push) Successful in 5m35s
ci / rust-arm64 (push) Successful in 9m19s
ci / rust (push) Successful in 22m18s
windows-host / package (push) Successful in 12m9s
android / android (push) Successful in 11m45s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m16s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 5m23s
android-screenshots / screenshots (push) Successful in 3m5s
release / apple (push) Successful in 30m41s
deb / build-publish (push) Successful in 9m31s
arch / build-publish (push) Successful in 12m29s
decky / build-publish (push) Successful in 21s
deb / build-publish-host (push) Successful in 9m45s
deb / build-publish-client-arm64 (push) Successful in 7m34s
flatpak / build-publish (push) Successful in 6m19s
linux-client-screenshots / screenshots (push) Successful in 7m11s
web-screenshots / screenshots (push) Successful in 3m5s
apple / screenshots (push) Successful in 23m51s
windows-host / winget-source (push) Successful in 37s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m40s
docker / build-push-arm64cross (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m53s
docker / deploy-docs (push) Successful in 10s
42 commits since v0.20.0 (v0.20.1 was tagged but never announced; superseded by this release rather than repointed, since real new functionality landed after it was cut). Minor, not patch: the headline is streaming one of the machine's own physical monitors instead of always creating a virtual display — pick it from a new console card or pin it in host.env, on KWin, Mutter, sway and Hyprland, in both the Punktfunk app and Moonlight. Also folds in the full v0.20.1 fix bundle: three Windows install blockers found within hours of 0.20.0, GameStream/Moonlight compat back to opt-in on fresh installs, three gamescope Game Mode takeover faults, a laptop-panel stall misdiagnosis, and the PyroWave high-bitrate latency-creep bundle — plus a KWin 6.7 regression found while building the monitor feature (silent kscreen-doctor fallback on every session) and a winget release-verification CI fix. Wire protocol stays at 2, the embeddable C ABI at 13 and the Windows virtual-display driver protocol at 6. GET /display/monitors is a new additive endpoint; the display policy gains an optional capture_monitor field. No embedder rebuild required. Notes authored ahead of the tag per docs/releases/README.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>v0.21.0 |
||
|
|
74179c4c2e |
fix(ci/winget): envs: must live under with:, not as a step sibling
|
||
|
|
8fe71be424 |
fix(web): applying a saved preset kept switching the streamed screen off
ci / web (push) Successful in 1m10s
ci / docs-site (push) Successful in 1m17s
apple / swift (push) Successful in 5m27s
ci / bench (push) Successful in 7m9s
windows-host / winget-source (push) Canceled after 0s
windows-host / package (push) Canceled after 9m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
android / android (push) Canceled after 10m20s
apple / screenshots (push) Canceled after 4m46s
arch / build-publish (push) Canceled after 10m24s
ci / rust (push) Canceled after 10m25s
ci / rust-arm64 (push) Canceled after 10m24s
deb / build-publish (push) Canceled after 9m14s
deb / build-publish-host (push) Canceled after 9m6s
deb / build-publish-client-arm64 (push) Canceled after 3m14s
decky / build-publish (push) Canceled after 0s
`applyCustomPreset` builds a FRESH policy object instead of spreading the draft, so every field it doesn't name is dropped. `capture_monitor` was not named — so picking one of your own saved presets silently took a host that was mirroring a real monitor and put it back on a virtual display. The same omission in the Custom switch would have done it there too. Found on-glass against a running serve on .136: the PUT is whole-object, so the console is the only thing keeping the orthogonal axes alive, and these two sites were the ones building a policy from scratch rather than from what was already in force. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a98174ebfa |
feat(host): anchor-test, and libei says which output absolute input landed on
The absolute-region ladder existed for one case a unit test can only simulate — two heads of the SAME size, where matching a libei region by the streamed mode is a coin flip — and that case had never been run against a real compositor. It was also unobservable: the log said which regions the EIS server offered, never which one a coordinate went into, so "the pointer is on the wrong monitor" could only be discovered by looking at a monitor. libei now reports the chosen region once per distinct answer, beside the existing miss warning: one covers an anchor that named nothing, this one covers an anchor that matched something, by saying which. `anchor-test` is the gate that reads it, in the shape mirror-test established: it enumerates the heads, SAYS whether the rig actually has two of the same size (a green run on a single-head box proves nothing), anchors at a named one (or `--none` for the A/B that makes the anchored run mean anything), and walks the corners. It refuses to run off libei rather than emitting a green result about a ladder that isn't there. On-glass on KWin 6.7.3 with `kwin_wayland --virtual --output-count 2` (regions 1920x1080+0+0 and +1920+0): unanchored picks +0+0, anchored at Virtual-1 picks +1920+0, anchored at Virtual-0 picks +0+0. That is open item 3 of design/per-monitor-portal-capture.md §8b, closed. Clearing the pin now logs too — setting one always did, and the streamed screen going back to virtual is the same size of change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ea0c61fb0f |
docs: streaming a real monitor, and the CLI that names one
The console grew a "Streamed screen" card and the host grew a PUNKTFUNK_CAPTURE_MONITOR knob and a list-monitors command, and the docs knew about none of it — a control with no explanation anywhere. virtual-displays.md gets the feature section: what it is for, that the monitor is never touched, that its resolution wins and a client scales, that a bad name is a hard error rather than a different screen, and that there is no chooser dialog on any of the four backends (which is what makes it work unattended). Plus the three troubleshooting entries the shape of the feature predicts: settings that do nothing while mirroring, a console card the env var has locked, and a pin that names no head. host-cli.md documents list-monitors and mirror-test; configuration.md gets the knob, including that it outranks the console on purpose; running-as-a-service.md gets the desktop-session drop-in and states that the host needs nothing exported to find its session. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
19deac75fe |
feat(packaging): a desktop-login host restarts with its desktop
The shipped unit's PartOf=punktfunk-kde-session.service covers the APPLIANCE route, where we start the compositor ourselves. A host on a machine somebody logs into has no such unit: when Plasma or GNOME restarts, the daemon keeps running while holding a Wayland socket and a portal D-Bus connection that both died with the old compositor, and it cannot recover either in-process. It fails quietly — the host still listens, still answers, and every session it then serves dies at capture. A host that mirrors a monitor idles for days between sessions, which is the shape that finds this at the worst moment. The drop-in binds the host to graphical-session.target: PartOf takes it down with the session, WantedBy brings it back with the new one. Shipped under /usr/share rather than as an active drop-in, because it is wrong for the appliance route (which may never reach that target at all, and would then leave the host permanently stopped) — the operator opts in. Closes the restart half of design/per-monitor-portal-capture.md §6. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
45f04f50fe |
test(mgmt): gamescope reports no monitors, and that is not a missing explanation
/display/monitors must never answer empty AND silent — the console reads that as "the host is broken". But gamescope is nested: it owns no physical heads by construction, so empty-and-silent is the correct answer there, not an unexplained one. Any dev box that has ever been in game mode keeps gamescope-0 sockets in its runtime dir, so detect() resolves gamescope and the test failed on the machine rather than on the code (found running the suite on .136). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6afc05155b |
feat(vdisplay/session): derive SWAYSOCK — the last session var a --user host lacked
Session detection already hands every backend the live session's env: WAYLAND_DISPLAY from a socket scan, XDG_RUNTIME_DIR from the uid, the session bus, XDG_CURRENT_DESKTOP, and Hyprland's instance signature. sway was the exception — SWAYSOCK had to be INHERITED from the login shell, so a systemd --user host that never saw one had no sway IPC at all: no output enumeration, no capture chooser, no wlroots backend (its is_available() keys off that very variable). It is derivable, and by identity rather than guess: sway names its socket sway-ipc.<uid>.<pid>.sock, and detection already knows the compositor PID. Ladder: a valid inherited value, then the exact PID's socket, then the newest one we own (a sway re-exec can leave the name pointing at a PID we didn't see). None on river — the other wlroots desktop ships no sway IPC, and saying so is what stops apply_session_env exporting a SWAYSOCK that points at nothing. Closes the session-env half of design/per-monitor-portal-capture.md §6 for a desktop-login host: nothing has to be exported for the host to find its session. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |