-
v0.17.0
Stableci / web (push) Successful in 55sci / docs-site (push) Successful in 58sapple / swift (push) Successful in 1m18sci / bench (push) Successful in 6m42sapple / screenshots (push) Successful in 6m21sci / rust (push) Successful in 24m18sandroid-screenshots / screenshots (push) Successful in 3m2sdecky / build-publish (push) Successful in 21sdocker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12sandroid / android (push) Successful in 15m7sdocker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10sdocker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10sdocker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 37sdocker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12sdocker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9swindows-host / package (push) Successful in 10m7srelease / apple (push) Successful in 10m12sflatpak / build-publish (push) Successful in 6m55sdeb / build-publish (push) Successful in 11m44slinux-client-screenshots / screenshots (push) Successful in 7m40sdocker / deploy-docs (push) Successful in 28swindows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m22sweb-screenshots / screenshots (push) Successful in 3m27sdeb / build-publish-host (push) Successful in 12m25sarch / build-publish (push) Successful in 15m42swindows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m32srpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m48srpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m41sreleased this
2026-07-20 22:55:02 +00:00 | 1 commits to main since this releaseWire-compatible with 0.16.x and 0.15.x (
WIRE_VERSIONstays 2) and the virtual-display driver protocol is unchanged at v4 — existing pairings, clients and installed drivers keep working, with no re-pairing and no driver update required.This release is about a front door for plugins — a console store backed by signed, version-pinned catalogs, and the framework that makes writing the plugins behind it a much smaller job — plus a substantial cut of Linux encode latency on both AMD and NVIDIA hosts.
The plugin store
Installing a plugin used to mean typing a package name and trusting that it was the one you meant. The console now has a Plugins section with Browse, Installed and Sources tabs, backed by twelve new endpoints under
/api/v1/store.The property the whole thing rests on: a catalog entry pins one exact version and that version's tarball integrity hash. Nothing in the catalog format can express "track latest". Before downloading, the host re-checks the pinned hash against the registry, so a package quietly republished under a version number you already approved is refused rather than installed.
Trust is a visible tier, and install friction scales with it. An entry from the built-in official index is Verified and installs behind a plain confirmation. An entry from a source you added yourself is External source, carrying a from <source> chip that names its curator. A raw package spec is Unverified — it never appears in Browse at all, and installing one directly puts you through a danger dialog that makes you retype the spec before it will proceed. A plugin installed from the command line reports as Installed via CLI, since the host has no provenance record for it. The badge is permanent — it follows the plugin onto its own UI page, so "where did this come from" stays answerable later, not just at install time.
The store surface is denied to the plugin token in full, reads included. A plugin that can install plugins is a persistence and privilege-escalation primitive, so the capability-limited token 0.16.0 introduced does not reach any of the twelve routes.
Operationally: the official index is fetched from the
punktfunk-plugin-indexrepo and verified against a pinned ed25519 key, with a second key slot reserved so a rotation can be staged — sign with the new key, ship a host that trusts both, retire the old one — instead of a flag day that cuts older hosts off from the catalog. Catalogs cache for six hours. You can add up to 32 of your own sources (stored inplugin-sources.json); a source without a pinned key is accepted but marked unsigned for as long as it has no key. Signature checking fails closed — a host that cannot verify a fresh index keeps serving its last good copy and marks it stale rather than trusting the new one. Installs run as jobs you can watch through/store/jobs.Several defects only running it could find are fixed here. Runner discovery matched
@punktfunk/plugin-*by name, so a third-party scoped plugin would install and then never run — and that same pattern matched@punktfunk/plugin-kititself, listing a plugin's own framework as an installed plugin. The same blind spot had a second call site that Windows on-glass caught: uninstall would accept@punktfunk/plugin-kit, offering to pull the framework out from under every plugin depending on it. (Nothing actually broke — bun no-ops removing a non-dependency — but the store should never have offered.) All three now key off the plugins directory's top-level dependency list rather than the shape of a package name.The host also writes the runner's registry-scope mapping itself rather than passing a new flag, because an older separately-shipped scripting package reads an unrecognised flag's value as a package name. And a conditional catalog refresh returning 304 arrived as success with an empty body, which made every refresh after the first verify a signature over zero bytes and sit stale.
@punktfunk/plugin-kit
@punktfunk/plugin-kit0.1.4 is published — the Effect framework that was roughly 80% of every plugin by copy-paste. It ships adefinePluginKitruntime with signal-driven interruption and a bounded shutdown grace, Schema-driven config with atomic writes, refusal of world-writable files and a changes stream, a cache store, a generic sync engine (poll/watch, debounce, single-flight coalescing, fingerprint skip, status PubSub), aserveUiHTTP surface over the SDK'sservePluginUiplus a raw SSE route, a CLI dispatcher, and a./reactsubpath carrying the plugin router with path→hash→fallback deep-link restore, thepf-ui:navigatebridge,ResultGateandsseAtom.Also in the kit line: a browser-safe
./wiresubpath so provider schemas can be imported from UI code while the kit root stays node-only, two theme fixes (this palette uses--secondaryas a dark surface where@unom/uireads it as muted text, so captions, badge text and table headers rendered dark-on-dark; and the card ring shouted because--accentis the brand violet), and one real bug:servePluginUileftBun.serve'sidleTimeoutat its 10 s default while keepalives were 15 s apart, so an idle status feed was closed before its first ping ever arrived. Keepalive is now 5 s.@punktfunk/host0.1.2 is published alongside it, exporting thepluginStateDirandpluginIngestDirpaths that de-privileging made necessary.Linux encode latency
AMD and Intel — Vulkan Video
One full frame period of latency is gone from every Vulkan Video session.
poll()was probing the encode slot's fence withget_fence_statusrather than blocking on it, which violates the depth-1 pump contract: every finished access unit sat unharvested until the tick after the next frame was submitted. It measured ~17 ms ofencode_usat 60 Hz against VAAPI's ~5.3 ms on the same VCN. This is the default encode path on AMD and Intel Linux hosts in every official package, so it applies whether or not anything below is in play.RGB-direct encoding is now the default on gamescope hosts. Where the hardware offers VCN's EFC front-end, the captured BGRA frame becomes the encode source and the colour conversion happens inline, deleting the compute CSC dispatch, both NV12 plane copies, a semaphore hop and a queue submit from every frame — around 17 MB of GPU traffic per frame at 1080p, scaling with resolution. Quality is a non-issue: CSC-encoded and EFC-encoded H.265 match at 49.9 dB average PSNR (minimum 48.9).
The gate is narrower than "AMD Linux", so it is worth being precise:
- It defaults on only for gamescope sessions (Steam Deck and gamescope-session hosts). Elsewhere — KWin, Mutter, wlroots, Hyprland — the cursor is composited by the CSC shader and the EFC front-end cannot do that, so the CSC path stays.
PUNKTFUNK_VULKAN_RGB_DIRECT=1forces it on anyway if you don't need the composited pointer;=0disables it everywhere. - It needs
VK_VALVE_video_encode_rgb_conversion— in practice Mesa 26.0 or newer on EFC-capable hardware. (The Vulkan Video backend it rides on is itself HEVC/AV1-only, and a 10-bit HDR session hands off to libav VAAPI, which has the P010/Main10/PQ wiring.) - One INFO line at session open states the verdict (
rgb_direct=active,active(padded-copy: …),available(off: …), or the first missing requirement), so you never have to infer whether it engaged.
1080p works through it too. The coded extent is 64×16-aligned, so 1920×1080 rounds to 1088; the frame is blitted into an aligned staging image with edge rows duplicated into the padding, transfer-only with no shader — one ~8 MB copy against the CSC path's ~17 MB plus the dispatch and plane copies. Aligned modes — 720p, 1440p, 4K — keep the true zero-copy direct import and pay nothing. That padding matters more than it sounds: the unpadded version of this fed the encoder 8 rows past the end of the captured buffer and hard-reset the GPU. On RDNA2/VCN3 at 1080p it produced VCN VM protection faults, ring timeouts, two ring resets and then a full MODE1 GPU reset with VRAM loss.
Two smaller ones:
PUNKTFUNK_VULKAN_QUALITYnow exists and defaults to0, the fastest tier the profile exposes, clamped to the driver'smaxQualityLevels— previously no quality-level op was issued at all and every session silently ran the firmware default preset. (On RADV that maps 0 SPEED, 1 BALANCE, 2 QUALITY, 3 HIGH_QUALITY, though the driver pins H.265 on pre-RDNA4 to BALANCE regardless.) And the bitstream buffer is mapped once per session rather than mapped and unmapped every frame.NVIDIA
The per-frame submit path loses every
cuStreamSynchronize— one per input-plane copy plus one for the cursor blend, so between one and four depending on the session's chroma format. Session IO streams are bound to the encode thread's copy stream viaNvEncSetIOCudaStreams, so the input copy and cursor blend enqueue without stalling the CPU. This is on by default on every Linux NVIDIA host (PUNKTFUNK_NVENC_STREAM_ORDERED=0reverts) and engages only at true depth-1, when nothing is in flight.Pipelined retrieve escalates adaptively.
PUNKTFUNK_NVENC_ASYNCis now a tri-state — unset is adaptive: off until the host loop's cadence-overrun detector asks for it, at which point the session is rebuilt cleanly and the first access unit after the switch is an IDR.=1forces it on at open,=0vetoes it.PUNKTFUNK_VK_QUEUE_PRIORITY(defaultrealtime) requests an elevated global-priority Vulkan queue for the RGB→NV12 compute conversion in the LINEAR/gamescope bridge, so the driver can schedule it ahead of the game; a refused priority class degrades quietly and never fails the bridge. Honestly labelled: this is a contended-tail lever with no measured win yet.Also:
zeroReorderDelayis now pinned explicitly on both Linux and Windows NVENC rather than inherited from the preset.Streamed access units — on by default on Linux NVIDIA
A frame no longer has to finish encoding before any of it reaches the network. Linux direct-NVENC now encodes in 4 slices by default and reads them back sub-frame, so each completed slice is FEC-encoded and sent while the tail of the same frame is still on the encoder. On a live three-leg A/B, p99 frame latency fell from 8527 µs to 5363 µs against sliced-whole-frame encoding, with zero corrupted frames; the first slice reaches the send thread about 500 µs into a ~1250 µs encode.
The slice-header cost — the usual objection to multi-slice encoding — does not reach the wire. Under CBR with a one-frame VBV, rate control absorbs the 1–2%, so bitrate is governed rather than inflated.
On the wire this rides a new
VIDEO_CAP_STREAMED_AUcapability bit. Non-final blocks use sentinel headers; the final block carries the real totals and retro-validates the whole frame, so a mismatch kills the frame wholesale with no spliced delivery. FEC recovery measured identical to whole-AU at every loss rate tested, and an adversarial security review found no exploitable memory-safety, amplification, splice or resurrection issue — its actionable findings, a defence-in-depth shard cross-check, a partial-delivery fix for unpinned frames, and tests for two load-bearing guards, landed on top.Scope and escapes:
- Linux direct-NVENC only. The sub-frame default is gated on the GPU actually advertising
NV_ENC_CAPS_SUPPORT_SUBFRAME_READBACK, so a GPU without it is never forced into the mode. Windows NVENC is unchanged — single-slice, sub-frame off, byte-identical config when the environment is unset. PUNKTFUNK_NVENC_SLICES=1is a new explicit single-slice escape (the knob accepts 1–32, H.264/HEVC only; other codecs are always single-slice).PUNKTFUNK_NVENC_SUBFRAMEis a tri-state:0disarms streaming while 4-slice encoding continues on the plain poll path,1forces it even where the caps probe says unsupported, unset takes the backend default.- GameStream/Moonlight is unaffected — that plane has its own RTP packetizer and zero code change here.
Core fixes
The tail of the crate-quality sweep, most with a regression test:
- A large frame could be delivered as half an access unit, reported as success. The two-lane seal's dead-worker fallback dropped the frame's back half and returned
Ok, and the dead lane was never respawned, so it stayed broken. The failure arm now reclaims the tail through a single-lane seal, both arms drop the lane so the next large frame respawns it, and a receive-side death surfaces as an error instead of silent truncation. - An unanswered clipboard fetch parked its task, waiter and bidirectional stream forever. Around a hundred of them exhausted the connection's bidi budget. The park is now bounded and watches for the peer stopping. In the same area: one dead oneshot leaked per paste for the session's life, served chunks accumulated unbounded under any request id, and a one-off 50 MiB paste stayed resident all session because an empty poll never released the parked payload.
- A Linux GSO bug that read as a bad network. The super-buffer was capped at 65535 instead of the real UDP payload ceiling, so segment sizes at or above 1024 could return
EMSGSIZEand latch segmentation offload off for the whole process. - A stale frame was delivered as the first frame after jump-to-live —
reset()left a pre-flush partial parked, and it survived the backlog flush. - The host could panic at startup on a large
stream_transport_idle— the value was floored but never ceilinged, so an operator-supplied idle past QUIC's VarInt millisecond limit panicked. Now clamped to 1 s..1 h. - Device names with non-ASCII characters showed replacement characters permanently, because the pair request truncated at a raw 64-byte boundary and split multi-byte UTF-8 mid-character.
- FEC reconstruct rebuilt a full generator matrix and decode inversion per lossy block on the pump thread instead of reusing the codec cache. A safe-code out-of-bounds write class in the vendored
fec-rsshims is closed alongside it. - Plus: audio decode synthesised 120 ms of concealment for every 5 ms DTX slot and grew the playout ring without bound; codec negotiation echoed a non-conformant multi-bit preference verbatim; the host lingered with the virtual display up after a
connect()timeout; and the Linux settings dialog rewrote an unlisted-but-valid stored gamepad preference to "auto" on close.
Console
A blank or crashing web console on NixOS and any offline build is fixed. The inlang config pointed its plugin
modulesat a CDN URL — stock scaffolding — so in a sandbox with no network the import failed and paraglide compiled zero messages while still printing "Successfully compiled" and exiting 0. The console then died at request time, inside SSR, because every message function was undefined. The plugin is now a devDependency referenced by relative path, so the full catalog compiles offline, and a new check runs at the end of codegen and fails the build on a remote module entry, an unresolvable module, or a compiled message count below what the message catalog defines.Three card-padding bugs, one root cause.
CardContentzeroes its own top padding because it normally sits beneath aCardHeaderthat supplies it, andp-0only cancels padding within its own responsive variant — so a header-less card lost its top padding outright, and a full-bleed card kept itssm:p-6and doubled the inset of anything nested inside. In practice: the Dashboard's live-status tiles sat flush against the tile edge at 640 px and up, the store's plugin cards had no space above the icon, and on the Plugins page "Installed plugins" sat 24 px further in than "Plugin runner" beside it. The header-less cards now restore their top padding explicitly at both breakpoints, andCardContentgains aflushprop that omits padding outright — used at the three call sites that want a full-bleed child (Store, Pairing, Stats) rather than leaving the footgun in place to be rediscovered.Also: store platform chips read
windowsandlinux— the catalog's wire identifiers — and now render as Windows, Linux and macOS.Note for embedders
The C ABI moves from 9 to 10, additively — one new entry point,
punktfunk_connection_clock_offset_now_ns, returning the live mid-stream re-synced host↔client clock offset. The existingpunktfunk_connection_clock_offset_nsstays frozen at connect time by contract, which is why the new one exists. No struct layout moved and no symbol was removed or re-signatured, so embedders recompile and keep working. Client adoption of the live offset follows in a later release; the wire protocol is untouched.Two ABI-surface fixes ride along:
host_poll_inputno longer holds a mutable borrow across the embedder callback (re-entry aliased it, so a mid-drain callback clearing input could be discarded), and a checked narrowing ofmax_frame_bytesthat a 32-bit build truncated to a plausible-looking residue.Downloads
- It defaults on only for gamescope sessions (Steam Deck and gamescope-session hosts). Elsewhere — KWin, Mutter, wlroots, Hyprland — the cursor is composited by the CSC shader and the EFC front-end cannot do that, so the CSC path stays.