-
v0.16.0
Stableaudit / bun-audit (push) Successful in 18sapple / swift (push) Successful in 1m34saudit / cargo-audit (push) Successful in 2m10sci / web (push) Successful in 54sci / docs-site (push) Successful in 1m3sapple / screenshots (push) Failing after 1m56sci / rust (push) Failing after 6m33sci / bench (push) Successful in 7m17sandroid-screenshots / screenshots (push) Successful in 2m58sdecky / build-publish (push) Successful in 19srelease / apple (push) Successful in 11m22sdeb / build-publish (push) Successful in 11m14sdeb / build-publish-host (push) Successful in 11m17sandroid / android (push) Successful in 13m10sarch / build-publish (push) Successful in 14m6sweb-screenshots / screenshots (push) Successful in 2m57slinux-client-screenshots / screenshots (push) Successful in 7m33srpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 22m38srpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 22m47sflatpak / build-publish (push) Successful in 8m9swindows / build (aarch64-pc-windows-msvc) (push) Successful in 8m7swindows / build (x86_64-pc-windows-msvc) (push) Successful in 9m3sdocker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13sdocker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 13sdocker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13sdocker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15sdocker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14sdocker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12sdocker / deploy-docs (push) Successful in 9swindows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 6m17swindows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m3swindows-host / package (push) Successful in 12m35sreleased this
2026-07-20 05:47:58 +00:00 | 48 commits to main since this releaseWire-compatible with 0.15.x and 0.14.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 who is allowed to do what — the plugin runner stops being a full-privilege process, and the video data plane stops trusting an unauthenticated packet — plus the long tail of defects a systematic sweep of the core, capture, encode, zero-copy and inject crates turned up.
The data plane now requires the authenticated peer
The UDP hole-punch adopted the source address of any datagram whose first 8 bytes matched
PUNCH_MAGIC— a fixed public constant with no key, nonce or session id — and the authenticated QUIC peer was consulted only as the no-punch fallback, so it never validated anything. Hole-punch is the default bring-up path and the data socket is an OS ephemeral port, so spraying the ephemeral range during the 2500 ms punch window let an attacker on the network take over the video plane: the legitimate client is then filtered out and receives nothing, while QUIC stays healthy so no reconnect fires. With a spoofed source address, the same 8 bytes could aim a full-rate video stream at an uninvolved third party.The punch is now required to match the authenticated peer's IP. Also in the same pass: adaptive FEC no longer wedges on large frames.
The plugin runner is no longer privileged
Operator-installed plugin code used to run with far more authority than it needs. Four changes take that away:
- It no longer holds the console's admin token. The scripting runner previously carried the full-admin management token, so a plugin defect could rewrite
hooks.json— arbitrary command execution — or administer pairing. The host now mints a second, separately persisted secret (plugin-token) with its own auth lane: loopback-confined like the admin token, but explicitly denied/hooks(read and write), everything under/pair,/native/pair,/native/pending, client unpair deletes, and other plugins' UI credentials. Everything a plugin legitimately does — status, library, events, sessions, its own UI lease — is untouched. - On Windows it runs as
LocalService, notSYSTEM. The scheduled task ran plugin code asSYSTEMwithRunLevel Highest; any plugin defect was a full compromise of the machine. The principal is nowNT AUTHORITY\LocalService, andplugins enableconverges an older SYSTEM-registered task idempotently before starting it, so existing installs migrate. It grants read on exactly the two files the runner needs — the scoped plugin token and the TLS pin — and never the admin token.plugins statusprints the principal so you can verify the migration. - Public-registry installs are gated.
resolvePackage()used to hand any plugin-shaped name straight tobun add, so a typo or a squatted look-alike on npmjs.org would install operator-privileged code. Only the@punktfunkscope (pinned to the Gitea registry) resolves by default; anything else needs--allow-public-registryand prints a loud warning. Removal is never gated — uninstalling stays safe regardless of origin. - The systemd user unit is sandboxed —
NoNewPrivileges,PrivateTmp,ProtectSystem=strictwithReadWritePaths=%h, and address families restricted to Unix and IP.
Windows unit-file trust is enforced properly too:
fileIsSafe()returnedtrueunconditionally on win32 with an "ACL check is a follow-up" note. It now reads the unit's SDDL and refuses unless the owner is SYSTEM, Administrators, TrustedInstaller or the runner's own account, with no other principal holding a write-capable ACE — matching what the Unix mode-bits path always did.Two things follow from de-privileging, both added here: a dedicated writable plugin-state directory (a
LocalServicerunner can't write under%ProgramData%\punktfunk, so a state-writing plugin's first-runmkdirandsaveCachefailedEPERM), and an ingest inbox —<config_dir>\ingest, writable byBUILTIN\Users— because the de-privileged runner can no longer traverse your user profile to read what an app running as you produced. The acute case is the Playnite exporter's library JSON under%APPDATA%. An interactive app drops files iningest\<plugin>\…and the runner reads them there.If you run plugins, note
- A script that genuinely needs the admin surface must now set
PUNKTFUNK_MGMT_TOKENexplicitly. The SDK's zero-configconnect()prefers the scoped plugin token automatically, and hosts without one fall back to the old behaviour unchanged. - Installing anything outside the
@punktfunkscope now requires--allow-public-registry. - Plugins writing outside
$HOMEon Linux, or on distros restricting unprivileged user namespaces for user units, may need a documentedsystemctl --user editdrop-in.
Latency measured where it actually happens
Two wired Mac clients sat at a rock-steady ~18–19 ms of reported network latency that outlived the load causing it and cleared only on reconnect. It was not the network. Receipt was stamped when the embedder pulled a frame rather than when reassembly completed, so client-side queueing was silently attributed to the link and was invisible from the HUD. The core now stamps receipt as the access unit crosses the session boundary; the Apple client keeps the pull instant separately and reports the gap as its own client queue term (detailed HUD tier from 2 ms, plus
queue_p50in the stats log). Decode stages keep their pull anchor everywhere, so no existing stage changes meaning.Separately, a small constant elevation was never recovered from: the jump-to-live detectors ignore anything under 6 queued frames or 400 ms behind, so a sub-frame standing backlog — or a stale clock offset after a wall-clock step — was carried for the whole session. A third detector now watches for a window-minimum one-way delay ≥ 10 ms above the session floor with zero loss for ~4.5 s and escalates gently: a free clock re-sync first, then at most three flush-and-keyframe bleeds sharing the jump-to-live cooldown, then a loud disarm. Loss windows reset the run — congestion belongs to FEC and ABR.
Loss recovery, finished
0.15.0 made LTR-RFI recovery sound under sustained loss; two paths were still short. Vulkan Video never received that taint sweep — recovery-slot selection accepted any resident slot older than the current loss, but "resident and older" is not "the client decoded it", so a later loss could anchor on an undecodable frame and ship it tagged as a recovery anchor. QSV's sweep was incomplete in its most common case: with two LTR slots, "exactly one swept" is the ordinary situation, and the rejection loop skipped precisely the entry the sweep exists to distrust. Both now match AMF. Driver-reported encode feedback is also bounds-checked against the bitstream buffer instead of trusted.
The capture / encode / zero-copy / inject sweep
A systematic review of those crates landed, and the results are mostly invisible until they bite:
- A wedged GPU could park the one thread that recovers the session. Vulkan Video's backpressure fence waited with
u64::MAXon the host encode thread — the same thread the stall watchdog'sreset()runs on — so there was no error, no reset, and teardown blocked on the join. This is the default encode path on AMD and Intel Linux hosts. Now bounded, with expiry surfaced as an error. - A failed Vulkan Video open leaked everything it had built — around 20 objects across 15 fallible steps, including a
VkDeviceand GPU memory per retried open, on a path where transient failure is expected. Teardown is now a guard that unwinds any prefix of the build. - Remote input could be frozen by a peer. The Steam Deck gadget's drop joined a thread parked in a blocking no-timeout ioctl, so the join hung — on the session input thread, driven by the client's active pad mask, meaning a remote peer clearing its pad bit could freeze all session input.
- Capture could silently wedge while still reporting healthy. A caught panic in the PipeWire
.processcallback unwound past every requeue and permanently stranded that buffer; once the pool drained, capture stopped while still reporting negotiated and active. - Torn frames on Windows NVENC. Async pipelining depth was bounded by an encoder-side cap that consulted nothing about the capturer, despite a comment claiming otherwise — so the encoder could still be reading a texture the capturer had already handed out and overwritten. It is now bounded by the capturer's ring.
- A PyroWave plane-import cache keyed on raw texture pointers could return an image bound to a texture that no longer existed once the capturer recycled its ring at identical dimensions. Now keyed on the capturer's ring generation.
- Plus: error-path leaks that recurred every frame for a worker's lifetime, an odd-height NV12 overrun, a cursor-metadata out-of-bounds read, a GL→CUDA copy race, an exception-unsafe fence wait, and a heap-allocated PnP callback context (a late completion could previously corrupt a reused stack frame and signal a recycled handle).
Connection tests and control frames
- "Test connection" could corrupt automatic bitrate. There is one probe slot per session and no correlation id, but two independent requesters share it: the pump's startup link-capacity probe and the embedder's
request_probebehind the shipped "Test connection" button in the Windows and Linux apps. They collide by default — both apps probe right after connect, and the pump's own probe fires two seconds later, inside that burst — and only the startup path had a busy check, a watchdog and a window rebase. Four defects in that one cluster are fixed. - Control-stream reads are cancel-safe now. Message framing used two
read_exactcalls, which quinn documents as explicitly not cancel-safe — bytes already taken live only in the future's buffer and are lost on drop. Both long-lived control loops drive that read from aselect!with nobiased;, so any sibling becoming ready could drop a partially-read frame. A frame only has to straddle two wakeups to bite, and a clipboard offer can reach ~2 KB, exceeding one QUIC packet.
Also in this release
- Management commands are quiet again.
punktfunk-host plugins add …printed the host startup banner and, on Windows, ran a GPU-preference hook whose DPI probe emitted an alarmingaccess deniedwarning that read like a failure. Management verbs now skip both; the SCM-launchedservice runis deliberately unaffected. - The plugin/script runner is always deployed on Windows.
Note for embedders
The C ABI moves from 8 to 9 —
PunktfunkFramegrows areceived_nsfield. The wire protocol is untouched, so this affects only code linking the punktfunk core directly; every client shipped from this tag is built against it.Downloads
- It no longer holds the console's admin token. The scripting runner previously carried the full-admin management token, so a plugin defect could rewrite