Field-diagnosed on Bazzite 43 (2026-08-15): the virtual DualSense/DualShock 4
binds hid-playstation, and Valve's ds_inhibit (steamos-manager) reacts to every
open/close of any such hidraw by walking /proc/*/fd — it has no VID/PID or
virtual filtering. SELinux denies steamos_manager_t that walk (sys_ptrace,
dac_read_search, dac_override) at ~324 AVCs/sec, and setroubleshootd amplifies
the flood into a box-wide fork storm (267+ procs/sec, a core burned, RSS
climbing for 15+ min AFTER the denials stop) that starves the stream: gamescope
0 fps, encode submit ~150 ms/frame, tx 300 -> 1 Mbps, session death. punktfunk
is the trigger, not the defect — but we ship the trigger.
- packaging/bazzite/punktfunk-ds-inhibit.cil: a dontaudit drop-in (dontaudit,
not allow — granting another vendor's daemon sys_ptrace/dac_* is not ours to
do; the scan keeps failing quietly and ds_inhibit leaves the pad
uninhibited, which is what we want anyway). The RPM ships the source under
/usr/share/punktfunk/selinux/ (the policy STORE is host state, so a sysext
image can only carry source); inserted idempotently by punktfunk-sysext
post_merge / reapply and best-effort by the RPM %post, both keyed on the
steamos-manager binary and on the module name — rename the .cil if its rules
ever change, or existing installs never converge.
- native/gamepad.rs: warn_if_ds_inhibit_storm in the resolve_gamepad funnel —
one-shot, warn-only (a per-pad degrade has no wire channel back to the
client and would strip the DS5 feature set exactly where users want it).
Fires on steamos-manager running + SELinux enforcing, and puts the cause in
OUR logs: the AVC lines read comm="tokio-rt-worker" and look like us.
- packaging/bazzite/README.md: the failure chain, both diagnosis traps, and
the setroubleshootd mask as general hardening (any AVC burst reproduces the
amplifier; nothing depends on that daemon).
Not pursued: suppressing the touchpad mouse node to duck ds_inhibit's
selection — hid-playstation registers the touchpad from hardcoded driver code
(ps_touchpad_create in dualsense_create/dualshock4_create), not from our HID
descriptor, so no descriptor shaping can remove it.
Verified: gamepad tests incl. the new detection test pass on linux-gnu
(punktfunk-rust-ci container); clippy --all-targets -D warnings clean; the CIL
compiles under secilc against a stub base (planted-error control caught);
shellcheck clean on punktfunk-sysext.sh.
security-review 2026-08-15, two low/informational findings.
- clients/apple HTTPResponse: a malicious host sending Content-Length = Int.max
made bodyStart + length overflow, and Swift integer overflow TRAPS (an
uncatchable crash) rather than throwing. Use addingReportingOverflow and reject.
Verified in the Apple build.
- host library/art.rs: art_path_is_confined's UNC guard was a leading double-
backslash string test, so forward-slash (//server/share) and mixed UNC forms
slipped past it, and canonicalize() itself would then coerce the SYSTEM host
into outbound SMB auth. Reject ANY two leading path separators before touching
the filesystem.
security-review 2026-08-15 finding 12 (windows). bun-windows-x64.zip was
downloaded and Expand-Archived with no integrity check, then Authenticode-signed
into the installer and its hash published in the Ed25519 update manifest — our
signature vouching for bytes we never verified (GitHub release assets are mutable
at a fixed URL). Pin and verify the sha256. The Linux curl|bash sites
(arch/rpm/deb + builder Dockerfiles) still need version+hash pinning — tracked.
security-review 2026-08-15 finding 7. The PIN is a single global slot with no
binding to a specific handshake, so with N parked getservercert waiters whichever
polls first takes it — an attacker who floods the parking slots while the
operator pairs could take the operator's PIN and pin its own certificate. Real
pairing is one client at a time, so PinGate::submit now refuses (returns false)
when more than one handshake is parked, and POST /pair/pin answers 409. This
narrows the window to a tight post-submit timing race; the full fix keys the gate
by uniqueid (mgmt API + console change, tracked separately). Compiles on .133.
security-review 2026-08-15 findings 3c and 4. %ProgramData% lets BUILTIN\Users
pre-create the punktfunk dir and plant host.env / web-password before a
privileged install runs; the bytes were then adopted verbatim (SYSTEM service
environment + command line; the console password), with the plant's owner erased
by the dir re-own that runs first.
Add install::is_admin_owned() (reads the file owner SID via GetNamedSecurityInfoW,
reusing privileged_sids()) and consult it BEFORE create_private_dir re-owns the
file:
- ensure_default_host_env: a non-admin-owned host.env is renamed aside and the
default written over it (the !planted skip forces the overwrite even if the
rename fails).
- set_web_password: a non-admin-owned password file is rotated to a fresh random
instead of kept as an 'upgrade'.
A file from a prior privileged install is Administrators-owned and is kept.
Compiles clean on the windows-amd64 box (.133). The installer-side .iss freshness
signal (which also gates the password page) is a separate follow-up.
security-review 2026-08-15 finding 8. MgmtTransport's verify block replaces
system trust wholesale (the host cert is self-signed, no SAN) and, for a host
with no pinnedSHA256, accepted ANY certificate trust-on-first-use with no prompt
or log. A host can be saved yet pin-less (manual add, deep link, abandoned
pairing, or after Forget Identity), so a LAN MITM could serve a forged catalog
and harvest the device's mTLS pairing identity.
Gate the library entry points on host.pinnedSHA256 != nil — HomeView's browse
action, GamepadHomeView's hasLibrary tile flag, and a load() guard in LibraryView
(covering the deep-link path) — mirroring how the stream path already refuses an
unpinned connect. The transport's silent-accept is left for a follow-up (it is
also reached pre-pairing, so tightening it needs the QUIC path's approval flow).
NOTE: not compiled locally (no Xcode on the build host); verify on the Apple CI.
security-review 2026-08-15 finding 3 (part 1). load_host_env imported EVERY key
of %ProgramData%\punktfunk\host.env into the LocalSystem service's own
environment. Since %ProgramData% lets BUILTIN\Users pre-create the dir, an
unprivileged user could plant host.env before install; a planted SystemRoot then
redirected the absolute icacls.exe / powershell.exe paths pf-paths and the
network-profile warner build from it — code execution as SYSTEM. Import only the
PUNKTFUNK_* / RUST_LOG keys the child already allow-lists at the spawn boundary,
closing the SystemRoot/PATH class of sinks.
Residual (planted PUNKTFUNK_HOST_CMD / PUNKTFUNK_CONFIG_DIR, which are legitimate
installer knobs) needs distrusting a non-admin-owned host.env — findings 3c/4,
which share an installer provisioning-signal decision and Windows build
verification; tracked, not yet fixed here.
security-review 2026-08-15 findings 5 and 14.
- ci.yml: the cargo-home cache shared its unnamespaced key with the signed
release builds (deb.yml / android.yml). registry/src holds already-extracted
crate sources cargo compiles without re-checksumming, so a fork PR could poison
a release artifact through the shared pool. Namespace ci.yml's key to
cargo-home-ci- so its (untrusted) caches never reach the release pool. The
fork-approval gate remains the definitive operator-side control.
- docker.yml: the deploy-docs SSH step left a write:package PAT base64-encoded in
~/.docker/config.json on the long-lived internet-facing docs VM. Add a
trap ... EXIT docker logout so it is cleared on every exit path, matching the
ephemeral LAN-registry jobs.
security-review 2026-08-15 finding 6. confirmIfCommandExecution was wired into
only the two custom-entry routes; the provider reconcile route had no BFF handler
and fell through to the /api/** catch-all, which injects the full admin bearer —
so a bare session cookie could plant a persistent prep/launch.kind:command entry
without the password. Add the missing handler so it runs the same
command-execution gate before forwarding (an ordinary catalog reconcile is
untouched).
security-review 2026-08-15 finding 9. push_packet advanced by
payload_words*4; a 12-bit payload_words of 0 passed the length guard, and
decode_packet's duplicate-block early return fired before its own minimum-size
check — so a duplicate block_index with payload_words==0 spun the client decode
thread at 100% CPU forever (no allocation, no timeout, inside FFI). Hoist the
minimum-size check into push_packet before decode_packet is consulted. Carried
as vendored patch 0008.
security-review 2026-08-15 finding 11. The reassembler's memory firewall counted
only FrameBuf::buf bytes; BlockState (have_data + recovery vectors, both sized
from attacker-declared header fields) was allocated unmetered. A slice-streamed
frame can mint thousands of distinct-index blocks while keeping the metered
buffer pinned near zero, committing multiple GB against a ~13 MB accounted
figure — a deterministic remote client OOM from a hostile/compromised host.
Add block_state_bytes()/frame_cost(); gate each new block on the same
IN_FLIGHT_BUF_FACTOR x max_frame_bytes budget as the frame buffer, and release
the full frame cost (buffer + block state) at every removal site.
security-review 2026-08-15 findings 1, 2, 13. The Moonlight-compat plane bound
its UDP video/audio endpoints to the first datagram from anyone and let any ENet
peer keep a connection (pinning per-peer reassembly memory) — the peer_ip the
RTSP/launch planes already enforce was never threaded to the media/control
sockets.
- stream.rs/audio.rs: the video/audio endpoint learn now discards datagrams whose
source IP is not the launch owner's until the 10s budget is spent, so an
off-path LAN peer can no longer win the endpoint race and be handed the
(plaintext) video stream.
- control.rs: an OwnerFilteredSocket drops non-owner datagrams before ENet
allocates any per-peer state (closes the ~32 MiB x peer_limit pin and the
source-spoof injection variant), and the Event::Receive arm now honors only the
tracked session peer's input as defense-in-depth.
GameStream is runtime opt-in and off in the shipped unit, so this is deferrable
but the code's own comments claimed a peer bind already protected these paths.
One component family (sections/Pairing/access.tsx) serves all three grant
moments: the approve dialog (Full + Forever defaults per D1, one-click
'Approve as guest' = Controller only + 4 h per D2/D4, stored-access pre-fill
on the expired-guest re-knock), the arm card, and the paired-row edit sheet
(partial PATCH: extend / expire now / make permanent / remove).
The Access column derives its chip + countdown client-side from expires_unix
on ONE shared interval (no refetch storms), keeps expired rows listed as
'Expired' (D3), gives Moonlight rows an honest 'Full (ungoverned)' chip and
no editor, and renders '—' against hosts older than the fields — access_level
is the presence sentinel, and every new field is read defensively.
35 new strings in en + de; stories for both dialogs, the column matrix
(incl. old-host and Expired), and the edit sheet, on a fixed fixture clock.
The grants registry serves both paired stores, keyed on fingerprint hex
(design §8): a Moonlight fingerprint with NO record is ungoverned — an
existing pairing keeps full control (back-compat) — while a record that
exists (created via the console) governs exactly as on the native plane,
via the new NativePairing::moonlight_effective (one store snapshot, so a
deletion can't race into reading expired).
nvhttp: /launch and /resume check LAUNCH + expiry beside peer_is_paired
(an expired record fails closed exactly like unpaired); /cancel gates on
expiry only — it is Moonlight's Quit App, owner-restricted already, and
denying a downgraded owner its own quit would only wedge the session.
Control thread: the session's owner_fp resolves to the same mask, folded
per 2 ms tick from the fingerprint's watch channel; every decoded event
passes one mask test against the exhaustive classifier before injection
(deny-at-setup for pads — no GAMEPAD, no uinput node), with per-class
counters, one warn per class, totals at session end. The deadline check
rides the same tick and ends the session through quit_session — the
host-side-ended arm's TERMINATION + disconnect is the whole message,
since GameStream has no AccessUpdate vocabulary (silent enforcement,
accepted by the design).
The management API is where grants become operable (WP6): the paired-client and
pending-device payloads carry grants/expiry/grant-time plus a derived
access_level preset name, the approve and arm requests take an optional access
choice (expiry RELATIVE in the API, stored absolute), and a new
PATCH /native/clients/{fingerprint} does partial access edits — omitted halves
keep their current value, clear_expiry makes access permanent. Reserved grant
bits are a 400, never silently cleared.
Admission consults effective() (an expired record knocks into the pending
list; re-approval is the re-grant), the Welcome advertises the real mask +
remaining lifetime, and a per-session lifecycle task owns the deadline:
wall-clock re-evaluated every lap, AccessUpdate warnings at T−5m/T−1m,
console edits folded into the live Arc<AtomicU32> within one watch event,
and the typed 0x69 close on expiry / expire-now / unpair.
The datagram dispatch classifies every plane against that one atomic before
offer() (one relaxed load per event; per-class counters, one warn per class),
the input thread re-guards the pad-creating arms (deny-at-setup: no GAMEPAD,
no uinput/pad-audio), launch without LAUNCH is a typed 0x6A refusal before
the handshake, and clipboard ANDs the grant into the operator policy — new
CLIP_REASON_NOT_PERMITTED (5), coordinator never starts ungranted.
Events: access.granted / access.changed / access.expired from the facade
choke points and the deadline fire.
The connector now carries the session's LIVE access truth: the Welcome advert
seeds NativeClient::access_grants / access_deadline_unix (client-anchored, so
skew never moves the countdown), the control task folds every MSG_ACCESS_UPDATE
in latest-wins before waking next_access_update, and a typed mid-session close
latches as end_reject — an access expiry now ends as "your access to this host
has expired", not "the host ended the session with an error".
pf-client-core surfaces it as SessionEvent::Access { SessionAccess, notice }
(module `access`: derived preset labels, chip text, toast wording — the rules
the Apple/Android ports mirror), gates the mic uplink and clipboard bridge at
spawn on their grants (deny-at-setup, client half), and follows a live MIC edit
by stopping/starting the uplink without a reconnect.
The presenter gates capture on the mask (§7 "not capture what can't land"): no
pointer lock without POINTER, no keyboard grab without KEYBOARD, engage refuses
outright when neither is granted (the hint pill stays down), every wire send
funnels through the host's own classify(), and a live edit flushes what a
removed class still held. The overlay wears the chip — "Controller only · ends
in 1 h 58 m", top-right beside the mic badge, at every stats tier — and the
T−5 m / T−1 m warnings ride the pill slot as toasts. Full-control permanent
(every old host) renders exactly today's look.
The Android leg of per-client-access.md §7 (WP11). The bridge grows one
poll shim, nativeAccessState -> [grants, remainingSecs, updateSeq]: the
connector already folds every AccessUpdate latest-wins into its live
grants/deadline slots, so Kotlin polls the fold ~1 Hz alongside its
session-ended watchdog instead of holding a blocking event thread; the
seq counter is only how a fresh update (the host's T-5m/T-1m warnings)
is told apart from state the poll would re-read anyway. The countdown is
clamped to >= 1 once a deadline exists — 0 stays the permanent sentinel.
Kotlin gates what can't land rather than capturing it: GamepadRouter's
wire sends fold the GAMEPAD grant into the existing forwarding gate
(slots and the exit/mic/stats chords stay alive — they are local
controls that happen to sit on pad buttons, and the phone-gyro mirror
stands down through the same sendsEnabled read); without POINTER the
touch/stylus gesture layer is never installed, the mouse forwarder goes
inert and never grabs the pointer, and the TV remote can't enter pointer
mode; without KEYBOARD the VK path consumes without sending and the IME
summon (gesture and remote toggle) declines; without MIC no capture
opens — the recording indicator must not announce a mic nobody can hear
— and a mid-session revocation stops a running one; without CLIPBOARD
the sync never starts.
StreamScreen carries the Access chip top-end in the shared pill family
("Controller only · 1 h 58 m left"), composed only when there is
something to say — a full-control permanent session, which is every
session against an old host, looks exactly like today. The expiry
warnings surface as toasts, and a session that dies inside the final
countdown is worded with the shared rejection sentence ("Your access to
this host has expired") — recognized off the countdown because the
generic end-reason byte predates the typed close. ConnectErrors learns
the two new reject tokens (access-expired, launch-not-permitted).
Verified: cargo clippy -D warnings + fmt (host target), gradle
:kit:/:app:compileDebugKotlin, :kit:+:app: unit tests (new
SessionAccessTest pins the bit mirror and the preset labels), and the
release cargo-ndk cross-build of all three ABIs. The gradle
cargoNdkClippy leg could not complete on this machine — the shared disk
filled mid-run (environment, not code; the Rust delta is
target-independent and is covered by the host clippy + the ABI builds).
ABI v21 (per-client access WP10): punktfunk_connection_grants and
punktfunk_connection_access_expires_in read the session's LIVE access
state (Welcome seed, latest-wins over every mid-session AccessUpdate),
and punktfunk_connection_end_reject surfaces the typed rejection a
mid-session close carried, so an access expiry renders its real
sentence instead of the generic host-error one. NEW symbols only; the
Rust-side live slots they read landed with the pf-client-core work.
Swift: PunktfunkConnection wraps the three (grant bits, the derived
AccessLevel labels, and class-gated send funnels — key/pointer/pad/pen/
mic events the grants exclude never leave the device); SessionModel
polls at the 1 Hz stats tick for the chip ("Controller only · ends in
1 h 58 m"), the T−5 m / T−1 m warning toasts, mic + clipboard hiding,
and the live release of an engaged capture on revoke; macOS gates
engage + the cursor grab and the iPad gates pointer lock on the bits;
tvOS states the level as a stats-overlay line instead of a chip. A
full-and-permanent session — every old host — renders exactly today's
UI.
PairedClient grows grants/expires_unix/granted_unix (serde-defaulted; an
absent field means full/permanent, so pre-grants stores decode unchanged).
effective(fp, now) is the new authorization verb — None when unpaired OR
expired, reserved bits masked on read — while is_paired() stays the
expiry-blind listing verb (both documented in the facade header).
The security-critical change: add() is now name-only for an existing
fingerprint. It used to replace the record, so a guest limited to
Controller · tonight could re-run the pairing ceremony and silently walk
back to full control forever. The authorized grant paths take an explicit
Access (grants + absolute expiry): add_with_access, set_access, the
approve dialog via approve_pending(.., access), and the armed PIN window
via arm_for(.., access) — the ceremony reads armed_access() before the
single-use consume wipes it. A test now fails if add() ever escalates
again (plan §8 risk table).
NativePairing also gains the access watch registry: one watch channel per
fingerprint carrying (masked grants, raw deadline, revoked). Every
mutation — pair, edit, unpair — publishes through it, so a console edit
or unpair reaches every live session within one event (design §5.6);
sessions subscribe() at admission (WP3). Existing call sites pass None
everywhere: enforcement wiring is WP3, the mgmt/console fields are WP6/7.
The wire layer of design/per-client-access.md: quic/access.rs carries the
GRANT_* bits (u32, reserved-must-be-zero), the three presets, and the
exhaustive InputKind -> GrantClass classifier whose wildcard-free match is
the default-deny mechanism — a new input kind now fails to compile until
someone decides its grant class.
AccessUpdate { grants, remaining_secs } rides the control stream as 0x58
(verified free; both peers' dispatch loops drop unknown ids with a warn,
so old clients just miss the courtesy). reject.rs grows the 0x69/0x6A
close codes -> RejectReason::{AccessExpired, LaunchNotPermitted}, mirrored
into PunktfunkStatus -30/-31 and the shared client-facing sentences.
The Welcome advertises grants + expires_in_secs as trailing fields one
link past mgmt_port, with the same placeholder discipline: emitting the
advert forces the cipher byte and the mgmt port so the two u32s land at a
deterministic offset, while a full-control permanent session stays
byte-identical to the pre-grants wire form. Absent fields decode to
GRANT_ALL / permanent — exactly what an old host enforces. No
WIRE_VERSION bump (trailing fields, per the v20 mgmt_port precedent), no
ABI bump (no new C symbols; the header gains prefixed defines and two
appended status values only). The host sends GRANT_ALL until WP2/WP3
wire the trust store in.
The new owner-facing Access levels page documents the Full/Controller/View
presets, the six Advanced toggles, wall-clock expiry with T-5m/T-1m warnings
and one-click re-grant, and the three honest limits: shared-desktop
visibility is not isolated, Moonlight rows are ungoverned until the
GameStream phase, and older clients are enforced without the chrome. The
pairing page gains the one-dialog approval flow (access level + expiry,
'Approve as guest') and the Access column note.
The web console shows exactly what the host was told, and the host was told
"This device" by every Apple client — so the outstanding-pairings view and the
approve dialog listed identical rows for an iPad, an Apple TV and a Mac.
The name rides `Hello::name`, which embedders fill from `client::device_name()`.
That resolves `COMPUTERNAME` (Windows-only) then `HOSTNAME` (a shell variable
never exported into a launchd-started process), and its last resort was the
literal "This device". No Apple GUI app has either variable, and the C ABI had
no device-name parameter for one to pass a better answer through, so every
Apple device fell through to the placeholder. Linux (/etc/hostname) and Windows
were unaffected; Android sent `Build.MODEL`, which names the product rather than
the unit — two of the same tablet were still indistinguishable.
- core: `punktfunk_connect_ex10` = `ex9` + `device_name` (C ABI v21, no wire
change — `ex9` keeps its signature and passes a null name for the old
default). Truncated to `HELLO_NAME_MAX` on a character boundary, since
slicing a multi-byte name mid-scalar panics.
- core: `device_name()` falls back to `gethostname()` before the placeholder,
so an embedder that passes nothing still gets a real name.
- apple: `DeviceName.current` (`Host.localizedName` / `UIDevice.current.name`,
falling back to the hostname when 16+ answers with the bare model) is sent on
connect, and the two pairing sheets plus the ceremony now read that one source
instead of three separate literals.
- android: `Settings.Global.DEVICE_NAME` — the name the user typed in Settings —
ahead of `Build.MODEL`. The "approve this device" prompt quotes the same
string the connect knocks with, so it can't send the user looking for a row
the console does not show.
- web: the approve dialog names the device and its fingerprint. A pre-filled
field is editable text, not a statement of which knock is being approved.
53 commits since v0.28.1 (36 non-merge). Cut from origin/main 8c6099da.
THE NUMBER: 0.29.0 is forced, not chosen. The C ABI moved 19 -> 20 (#230
added punktfunk_connection_mgmt_port for the in-band mgmt-port advert),
and the Windows MSIX package identity changed with the Azure signing
move (#228) — either alone rules out a patch. scripts/ci/pf-version.sh
derives the canary base as latest-stable + one minor, so canaries move
from 0.29.x to 0.30.x after the tag; no collision either way.
Version table measured, not copied forward: wire stays 2 (Welcome grew
a trailing u16 older peers never read, with an explicit cipher byte
whenever a port rides along so offset 68 keeps meaning cipher), driver
protocol 6/min 3 (pf-driver-proto has no diff against the v0.28.1 tag),
gamepad channel 3, plugin index schema 1, edition 2024, MSRV 1.85, 27
crate dirs, gamescope +pfhdr7 (patch series untouched), SDK 0.1.4,
plugin-kit 0.4.1. api/openapi.json stays stamped 0.28.0 — the mgmt API
surface did not change this cycle — and docs-site/public/openapi.json
is byte-identical to it, so no re-sync is owed for once.
Re-synced once as main moved (b5cace3a -> 8c6099da, PRs #237–#241):
the Hyprland six-fix arc and the Windows mgmt-port completion joined
the notes and CHANGELOG; contract surfaces (include/, pf-driver-proto,
sdk, plugin-kit, api/) show no diff from the extra commits, so every
version-table row survived the re-sync unchanged.
Gates run on this tree: cargo fmt --all --check clean; cargo metadata
--locked ok; Cargo.lock diff versions-only (36/36 lines); cargo test
-p punktfunk-core green including the c_abi harness (the header with
the v20 symbol compiles and round-trips); Play whatsnew 444/500 chars
(counted as characters, not bytes) and not byte-identical to any prior
release's; notes voice scan finds internal names only in the For
developers section.
99eb679c wired the learned management port through every client, and its own commit message
records the hole: 'NOT verified: the Windows client (192.168.1.133 unreachable)'. The Windows
shell's usage sites landed, but the three definitions they lean on did not exist anywhere, so
main's windows-client build has been red since the merge of #230:
* clients/windows/src/trust.rs re-exports core's trust surface — learn_mgmt_port was added to
core but never joined the re-export list (hosts.rs:729, E0425).
* The Windows shell's own mDNS browser (discovery.rs, 'ported verbatim from the GTK client')
never learned the mgmt TXT that pf_client_core::discovery already parses — DiscoveredHost
gains the field, parsed the same way (hosts.rs:725/727/1062, E0609).
* HostTarget ('the host a plan dials') never carried the port, so the Target the shell builds
from a ConnectPlan had nothing to read (mod.rs:414, E0609). Wired From<&KnownHost> like mac;
the two spawn-path literals stay None — a spawn plan never fetches the library, and each
shell resolves the port itself at its fetch site.
cargo fmt --all --check clean. pf-client-core/linux/session compile via the rust CI job; the
windows-client job on this PR is the verification 99eb679c could not run.
Field report, working Hyprland stream: the pointer jumps to the screen
centre once a second for ~10 s at the start of every session, fighting
every mouse movement, then settles.
That is `park_pointer`'s schedule running its full cap. Parking exists
for a good reason — a pointer-locked client sends only RELATIVE deltas,
so nothing would ever move the seat pointer onto a freshly created
virtual output — and past its two unconditional attempts it keeps going
only while a host-composite session STILL has no live cursor overlay.
"No overlay ⇒ the pointer has not reached the streamed output" is sound
on Mutter, which suppresses `SPA_META_Cursor` while the pointer is off
the recorded view. It is meaningless on the whole wlr family: xdph and
xdpw advertise `AvailableCursorModes = 3` (Hidden|Embedded), so a session
that asks for metadata is served EMBEDDED — the compositor paints the
pointer into the frames and sends no cursor metadata, ever, wherever the
pointer is. The heuristic was reading noise and warping the user's
pointer over it.
Distinct from — and complementary to — 5a5397ca, which fixed WHERE the
warp landed (the wlr virtual pointer was bound to the operator's head,
so the park drove a screen nobody was streaming). That one makes the
park work; this one stops it repeating on evidence that does not exist.
Both are needed: with only 5a5397ca the pointer would be re-centred on
the *streamed* output once a second instead, which is the field report's
symptom exactly.
The same fact broke a second thing next to it. `metadata_composite` had
the host plan a metadata cursor composite on a backend that can never
deliver metadata: the stream logged "host-composite active but the
capture has no live cursor overlay" for its whole life and drew no host
pointer, which is why an earlier session on this box looked cursorless.
Under Embedded the compositor's burnt-in pointer IS the cursor, and the
host must not plan a composite at all.
So surface what the portal actually negotiated instead of inferring it:
`portal_cursor::negotiate` now returns our own `Mode` (re-exported as
`pf_vdisplay::PortalCursorMode`), the hyprland/wlroots portal threads
carry it back beside the fd and node id — alongside, not instead of, the
`closed_tx` teardown handshake and inside the same `HANDSHAKE_BUDGET`
bound — and the backends, plus the monitor mirror that delegates to
them, report it per session as `VirtualDisplay::last_portal_cursor_mode`.
`None` is the default and what every non-portal backend reports (KWin
`zkde_screencast`, Mutter `RecordVirtual`, gamescope, Windows all get
the mode they ask for), so nothing about the GNOME behaviour this was
built for changes.
The host settles both consequences from that one fact in
`settle_portal_cursor`, at bring-up and again after every capture-loss
rebuild (the retarget arm has to recompute `metadata_composite` from the
compositor alone, because it runs before the rebuild to set `hw_cursor`).
`plan.cursor_blend` is deliberately left alone: it is resolved before any
display exists, and pre-judging it would mean re-asserting what the wlr
portals advertise — the exact hardcode `portal_cursor` exists to have
deleted. It costs a colour conversion, not correctness.
Also cuts the park RETRY for a client that steers the seat pointer
itself. The doc claimed a desktop-model client "overrides it with its
first absolute move, so the jump is invisible in practice" — one park at
bring-up is, a repeat is not: such a client sends absolute positions,
the very same event the park synthesizes, only aimed where the user is
actually pointing. It keeps the single bring-up park, so the session's
first click cannot land on whatever monitor the seat pointer was left
on, and loses the retry that fights the user. A cold EIS connection
swallows the client's own moves too, and those keep coming.
On the reported session this is 2 parks in the first second instead of
11 over ten, and no phantom composite.
Verified: `cargo fmt --all --check`; `scripts/xcheck.sh linux clippy`
and `windows clippy`; `cargo clippy -p punktfunk-host -p pf-vdisplay
-p pf-inject --all-targets --locked -- -D warnings` and `cargo test` for
the three, run for Linux in the ci/rust-ci.Dockerfile image (this crate
does not build on macOS at all — opus, zerocopy and the Linux-only
vdisplay entry points are cfg'd out there, so the container is the only
way to compile it). pf-vdisplay 231 tests, pf-inject 130+7, and both new
tests pass. punktfunk-host's suite has two pre-existing failures under
that emulated container — `gamestream::stream::tests::sender_delivers_
batches` (EINTR on a socket recv) and one of the two `mgmt` local-summary
tests, which share process-global session state — and the SAME two fail
on this branch's parent without this commit; each passes in isolation.
Not verified on glass: the .138 Hyprland box is read-only and in use.
`WlrootsInjector::open` created its virtual pointer with `globals.output` — whatever
`wl_output` the registry roundtrip had bound, which was `state.output.is_none()`, i.e. the
FIRST one advertised. Registry globals arrive in creation order, so "first" is the
compositor's oldest output: the operator's physical head, never the per-session headless one
the client is looking at. The wlr protocol maps `motion_absolute` onto the output the pointer
was CREATED with ("if the output argument is set, the compositor should map the input device
to the requested output"), so every absolute sample from every session drove a screen nobody
was streaming. On the EXTEND backends — Hyprland and wlroots/sway, where the streamed head
sits beside the operator's — that is the field report "no cursor was visible in the session",
and it is also why `park_pointer`'s opening warp put the seat cursor on the operator's
desktop once a second instead of on the stream.
Not a startup race, though it looks like one. The host journal on the Hyprland box shows
BOTH orderings across sessions of the same build — the injector opening 3.7 s before the
headless output in one, 24 ms after it in another — and the bug in both, because
"first advertised" is the oldest global either way. `hyprctl monitors` on that box:
`HDMI-A-1` (ID 0) at +0+0, `PF-87756-3` (ID 1) at +1920+0. ID 0 is always first.
Three parts.
1. pf-vdisplay carries the head's compositor name out on `VirtualOutput::output_name`, the
Linux counterpart of what `win_capture` already carries on Windows. Set by hyprland and
wlroots (the two EXTEND backends) and by the monitor mirror; `None` on KWin/Mutter (they
inject through libei, which selects by region) and gamescope (it owns its whole seat).
Threaded through the registry pool so a keep-alive reuse answers with the same name a
fresh create would — no poolable backend sets it today, and this is so that stops being a
silent trap the day one does.
2. The host publishes it at capture bring-up, `pf_inject::set_stream_output`, in the same
place and shape as the Windows arm's existing `set_stream_target`.
3. The wlr injector binds EVERY `wl_output` at v4 (for the `name` event), matches the
published name, and re-creates its virtual pointer bound to that output whenever the
target changes — releasing any held button on the old device first, because nothing else
would and a virtual pointer destroyed mid-press leaves the host with a stuck button.
Matching is by NAME, with NO fallback, and the absence of the fallback is the fix: the old
"first output" behaviour WAS the fallback. Size could not stand in for it either —
`MouseMoveAbs`'s extent is the client's letterboxed video rect in its own window, not the
streamed mode, so no size ladder can identify the head. An unresolved target binds NO output,
which maps absolute coordinates over the whole layout: on a single-output compositor that is
identical to binding that output, and on a multi-head one it at least keeps the streamed head
reachable, unlike a pin to the wrong one.
`inject` now also READS the Wayland socket. It only ever called `dispatch_pending`, which is
documented to "not perform reads on the Wayland socket", so the queue held nothing but what
`open`'s roundtrips put there. Without this the retarget above would have been dead in
exactly the sessions that need it most — the injector could never learn about a `wl_output`
created after it opened — and, separately, everything the compositor sent had been piling up
unread in the socket buffer for the host's lifetime, including the protocol errors the
comment there claimed to be surfacing.
Concurrency, stated plainly: ONE slot per process. The injector is host-lifetime (in fact
there are two `InjectorService`s — the native plane's and one per GameStream control
listener) and `InputEvent` is an 18-byte `#[repr(C)]` ABI struct with no session field, so
with parallel sessions (up to `max_concurrent`, default 4) the LAST capture bring-up wins for
everyone's absolute input. That is the same trade `stream_target` already documents on
Windows, and it is strictly better than what it replaces, where every session aimed at a head
NO session was streaming. Making injection genuinely session-aware is the real fix and a much
larger one — it needs source-tagged input events through both control planes.
`set_absolute_anchor`'s warning is amended rather than quietly violated: it still must not be
called from a session path, and it now says which mechanism took the per-session trade, why
that is a separate slot (this one is the operator's host-wide capture pin, recomputed from
policy whenever the console writes it — which would wipe a per-session value), and where the
trade is written down.
Gates: `cargo clippy --all-targets -p pf-inject -- -D warnings`, `cargo build -p pf-inject`
and `cargo test -p pf-inject` (137 tests, incl. 3 new) on x86_64-unknown-linux-gnu in
`punktfunk-rust-ci`; `cargo check -p punktfunk-host` likewise; `scripts/xcheck.sh linux
clippy` plus 230 `pf-vdisplay` tests; `cargo fmt --all --check`. The two new `wlr` tests pin
the regression directly — an unknown target must bind NOTHING rather than fall back to the
first advertised output.
Not verified here: no on-glass run. The box at .138 is in live use and read-only to me, so
the change is unproven against a real Hyprland seat. Two smaller things also rest on reading
rather than observation — that Hyprland's `hyprctl` monitor name is byte-identical to its
`wl_output.name` (the protocol says the name is "the same for all clients", and xdph already
resolves our `hyprctl`-minted name to the same output for screencast, which is field-proven),
and the exact on-screen arithmetic of the "moves to mid-screen then jumps back" symptom,
which does not follow from the protocol's normalize-by-extent mapping and would need
Hyprland's own source to pin down.
THE reason the first stream of a host process worked and every later one was
black. Not xdph, not the compositor, not the formats — ours, and a lifetime
mistake.
ashpd caches its D-Bus connection process-globally:
static SESSION: OnceLock<zbus::Connection> // ashpd 0.13.13, src/proxy.rs:27
The first `Screencast::new()` in the process creates that connection, and zbus
spawns its background reader as a task on whichever tokio runtime is current at
that moment. Both wlr backends built their OWN multi-thread runtime per cast and
dropped it at teardown — so the first cast created the cached connection on a
runtime that was then destroyed with it, and the OnceLock went on handing the
same executor-less connection to every later `Screencast::new()`, which awaited
a reply nothing was alive to read.
Measured 2026-08-14 (Hyprland 0.55.4, xdph 1.3.12): first cast of a host process
streamed, every cast after it hung, and the surviving cast thread sat in
futex_do_wait inside runtime shutdown. The discriminator that pins it on us: a
freshly spawned process completed the identical handshake against the identical
xdph, repeatedly, while the long-lived host completed none — with xdph itself
idle at 28 ms of CPU, so it was never the one wedged. Teardown was already
correct by then: the log shows `hyprland headless output removed` in the right
order.
One shared runtime (`portal_rt`), built once, never dropped, `block_on(&self)`
from every cast thread. It outlives the cached connection because it must.
Also bound `Screencast::new()` itself, not just the handshake after it: with the
connection orphaned that call is exactly where the thread hung, so the earlier
bound started one step too late and the failure still surfaced as the caller's
generic 20 s timeout.
Verified: cargo fmt --all --check, scripts/xcheck.sh linux clippy (-D warnings),
cargo test -p pf-vdisplay 122 passed. Not verified: on-glass.
MEASURED 2026-08-14 on the Hyprland box, with the ordered-teardown fix already
in place. The first cast of a host process streamed:
hyprland headless output ready … output=PF-44694-1 w=5120 h=1440 hz=240
pipewire stream state old=Paused new=Streaming
and every cast after it timed out in select_and_cast. The host had NINE live
`punktfunk-hypr-cast` threads and 28 tokio workers at that point.
`select_sources`/`start` await a D-Bus reply that a wedged portal never sends.
That await cannot be cancelled by the `stop` flag, because the flag is only
read by the park loop further down — a thread stuck in the handshake never
reaches it. So every timed-out attempt left a thread parked forever on a
half-created portal session, holding this process's shared D-Bus connection,
and from the first hang onwards every later request from the SAME process hung
too.
The discriminator that proves it is the process, not the portal: a freshly
spawned process (`punktfunk-host spike --source portal`, driven through the
same custom picker) completed the identical handshake against the very same
xdph — repeatedly — while the long-lived host could not complete any. xdph
itself was idle, 28 ms of CPU since start, so it was not spinning.
Bound the handshake at 15 s, under select_and_cast's 20 s wait so the failure
is reported by the thread that owns it, with a reason, and — the point — so
that thread EXITS instead of leaking. Same change in the wlroots/sway backend:
xdpw carries the identical unbounded node-id spin (screencast.c), so it can
wedge the same way.
Verified: cargo fmt --all --check, scripts/xcheck.sh linux clippy (-D warnings),
cargo test -p pf-vdisplay 122 passed. Not verified: on-glass — needs the box.
Only the FIRST stream after a portal start ever worked on Hyprland; every
one after it died in `select_and_cast` with
create virtual output: timed out waiting for the ScreenCast portal on PF-…
The mitigation on `worktree-capture-bgra-dmabuf-pod` chased the symptom. This
is the mechanism, read out of xdph 1.3.12's source and the box's own journal.
WE YANK THE OUTPUT OUT FROM UNDER A LIVE CAST. `Keepalive` drops `StopGuard`
then `OutputGuard`, and `StopGuard::drop` only SET an atomic and returned. The
portal thread noticed 200 ms later and merely dropped its zbus connection. So
`hyprctl output remove` ran — synchronously, microseconds later — on an output
xdph was still capturing, every single teardown.
Nothing closed the session either. xdph destroys one on exactly one event, an
explicit `org.freedesktop.impl.portal.Session.Close` (`Session.cpp:37`); it has
no peer-vanished watcher. The frontend does (`xdg-desktop-portal.c:230`
`peer_died_cb` → `close_sessions_for_sender`), but only once our bus name goes
away — after the poll, asynchronously, on a GTask thread. Long after the output
is gone. Proof from the box: xdph's toplevel lock stayed at 2 for 4.5 minutes
after our stream ended and its output was removed, and that session's
`Session destroyed` never came.
XDPH THEN SPINS AT 100% CPU, FOREVER. Handed that wreckage, `startSharing`
falls into `Screencopy.cpp:307-313`
while (pSession->sharingData.nodeID == SPA_ID_INVALID) {
int ret = pw_loop_iterate(g_pPortalManager->m_sPipewire.loop, 0);
— timeout 0, i.e. NON-blocking, i.e. an unbounded hot spin on xdph's only
event-loop thread, inside the `Start` handler, holding its `m_mEventLock`. From
there it answers no D-Bus, no Wayland, no PipeWire, ever again. MEASURED: the
wedged instance's unit reported `Consumed 3min 51.971s CPU time over 23min
41.092s wall clock`, and there were 232.70 s of wall clock between its last log
flush and its restart — 231.971 s of CPU against 232.70 s of wall, one core
pinned solid for precisely the wedged interval.
Everything after that is queueing. Our next handshake gets nothing, times out at
20 s, and `SelectionFile` deletes the per-session selection file on its way out;
if xdph is restarted mid-queue it finally runs the picker for that stale request
and reads an empty file — the `SHAREDATA returned selection -1` in the log.
THE FIX IS THE ORDER. `StopGuard::drop` now signals and then WAITS for the
portal thread to have closed the ScreenCast session, and only then does
`OutputGuard` remove the output. The close is answered synchronously by the
frontend (`xdp-session.c:217` `handle_close` → `xdp_dbus_impl_session_call_close_sync`),
so when it returns xdph has already run `destroyStream`. The output we remove
next is one nobody is capturing. Bounded at 3 s on each side — an already-wedged
portal must not be able to wedge our teardown with it — and the park poll drops
to 20 ms now that teardown waits on it.
wlroots gets the same change, and NOT on an assumption of symmetry: xdpw was
read to confirm both preconditions. `src/core/session.c` gives its session
object exactly one method, `Close`; and `src/screencast/screencast.c:599-605` is
the identical unbounded `while (cast->node_id == SPA_ID_INVALID)` spin — xdph's
copy is that code. sway's `output unplug` yanks a captured output exactly the
way Hyprland's `output remove` did. Not observed on glass; no sway box.
THE PICKER LINE WAS ALSO MALFORMED, AND IS A RED HERRING FOR THE STALL. xdph
splits the picker's line on the first `/` into flags and selection
(`ScreencopyShared.cpp:86-87`) and we never sent one. `find_first_of` then
returns npos, so FLAGS became the whole payload — and SEL became the whole
payload too, purely because `npos + 1` wraps to 0, which is why the output name
still parsed and why this hid. What did not hide is the flag loop walking
`screen:<name>` one character at a time (`unknown flag from share-picker: s`,
`c`, `e`, …) and setting `allowToken` on the `r` of `sc*r*een`, so xdph answered
every Start with a `restore_data` + `persist_mode: 2` we never asked for. The
reference picker prints the separator unconditionally
(`hyprland-share-picker/main.cpp:133-136`), so empty flags are a bare leading
`/`. Fixed to `[SELECTION]/screen:<NAME>`.
It is NOT what stalled anything: the sessions that streamed fine logged the
identical flag spam and the identical restore token, so it never discriminated.
The format moves to `portal_picker.rs`, declared unconditionally like
`portal_config` and `portal_cursor`, with xdph's parser transcribed into the
tests — including the npos arithmetic. A wire format with no schema and no error
report is invisible from the string alone: the old line's one assertion passed
the entire time it was wrong. Those tests now run on every platform's CI rather
than only the leg that compiles `mod hyprland`.
Verified: cargo fmt --all --check, scripts/xcheck.sh linux clippy AND windows
clippy (-D warnings), cargo test -p pf-vdisplay 122 passed (5 new). Not verified:
on-glass behaviour — the box is in use for live testing and read-only to me.
Upstream bugs worth filing, both wlr-family: the unbounded node-id spin
(hyprwm/xdg-desktop-portal-hyprland Screencopy.cpp:307,
emersion/xdg-desktop-portal-wlr screencast.c:599) should be bounded and fail the
request rather than pinning a core forever; and xdph's picker parse should reject
a line with no `/` instead of reading the whole selection as flags.
A Hyprland/sway client went black with no error of ours: PipeWire failed the
link itself with
pw.link: (73.0.0 -> 81.0.0) negotiating -> error no more input formats (-22)
Measured on Hyprland 0.55.4 + xdph 1.3.12 by dumping both EnumFormat pods from
the PipeWire DAEMON (`PIPEWIRE_DEBUG=*:1,pw.link:5` — the pods are not in our
own process's log, which is why this hid for so long):
ours BGRx only | 12 NVIDIA tiled + 0 (LINEAR) | MANDATORY
xdph BGRA only | the same 12 + MOD_INVALID | MANDATORY|DONT_FIXATE
xdph BGRA or BGRx, no modifier | (the SHM pod)
The modifiers intersect perfectly. Only the fourcc never does, which is exactly
why the failure reads as a GPU/modifier problem and is not one — the host's own
message ("the compositor never accepted the dmabuf-only offer (EGL->CUDA GPU
import)") points at the GPU, and our advert line prints only the first 6 of 13
modifiers so LINEAR is invisible. Both misled a full session of debugging.
Since our offer is dmabuf-only, xdph's mixed SHM pod could not rescue it.
Offer a BGRA dmabuf pod beside the BGRx one. BGRA and BGRx are the same 32-bit
layout, the alpha byte is ignored all the way to the encoder (`vk_util` maps
both to B8G8R8A8_UNORM, VAAPI both to Pixel::BGRA), and the import is driven by
the NEGOTIATED format's fourcc, so an AR24 frame imports as AR24.
Vendor-neutral by construction: the two modifier lists are enumerated PER FOURCC
(`XR24` and `AR24` asked separately), because EGL and libva answer per format and
nothing entitles us to assume a driver importing one imports the other. On the
VAAPI passthrough path there is no importer, so both lists are LINEAR (plus the
PyroWave Vulkan set when armed) — AMD and Intel get the BGRA pod on the same
terms as NVIDIA rather than an NVIDIA-shaped guess.
The BGRA pod is listed AFTER BGRx, so a producer offering both still lands on
the pre-existing path — first compatible consumer pod wins, so this is purely
additive. Both pods are now guarded on a non-empty list (`build_dmabuf_format`
indexes `modifiers[0]`).
Also name `linear_offered` and both counts in the advert log, so the truncated
`sample` can no longer be misread as the whole offer.
Azure Artifact Signing chains to a public root, so neither the host installer nor the client MSIX ships a .cer any more and users import nothing. install.md, install-client.md and windows-host.md still walked through importing one — and because the pre-Azure files are still sitting in the package registry those URLs return 200 rather than 404, so following the docs didn't fail loudly, it quietly planted a retired self-signed cert in machine Root and TrustedPublisher.
Verified on glass against the 0.29 canary while checking the release: both artifacts verify Valid, timestamped, and publicly trusted, and the MSIX installs with nothing imported.
Also corrected while here: the MSIX publisher change makes a different package identity, so installs from 0.28.1 or earlier need an uninstall rather than an upgrade (and a packaged app's settings go with it, so the client pairs again); a silent UPGRADE reuses the task selection the previous install recorded instead of the wizard defaults, so a once-declined installgamepad silently keeps stale gamepad drivers; installaudiocable stopped being a task name in 4a621de6; and Add-AppxPackage from a non-interactive session can fail 0x80070005 when the Windows App Runtime it depends on is in use.
The Apple scenes passed artLoader nil, so the store's library frames shipped
empty frosted cards next to Android's populated shelf. LibraryArtSource (a
two-method protocol the production LibraryArtLoader already satisfies) lets the
harness answer art from a canned source, and the four posters are drawn
procedurally at capture time — CoreGraphics on Apple, Canvas on Android, same
designs, same seeds — replacing Android's gradient-plus-monogram tiles. Nothing
is bundled; release builds ship none of it.
Merging two PRs seconds apart can leave the older merge sha with no run at all -
Gitea attributes the window's runs to the newer head. 1e5dca4c (PR #235, the pad
Select/KEYCODE_BACK fix) lost its run to b5cace3a 12 s later and reached main
having never been built, so no canary carries it.
Re-running the PR run cannot recover that: a re-run replays the original
pull_request event, and all four publish steps are gated on a push, so they stay
skipped. Until now the only cure was inventing a filler push touching one of
android.yml's paths.
Add a workflow_dispatch 'publish' input, default false, and widen the four gates
to accept a dispatch that opts in. A plain manual run stays build-only, so a
stray click still cannot reach testers. String-typed and compared against 'true'
to match apple.yml's testflight input, the form proven on this Gitea.
With the default modifier layout ⌘ is Super on the host, which makes ⌘Q the chord
a Hyprland/KDE/GNOME user reaches for first. AppKit dispatches menu key
equivalents before the stream view ever sees a keyDown, so it quit the client
instead. Not Hyprland-specific.
`InputCapture`'s local keyDown monitor now claims every ⌘ chord while input is
captured and forwards it to the host itself. It has to send from there: the
monitor runs ahead of BOTH the menu and `StreamLayerView.keyDown`, and on macOS
that second one is the host's only key path (the GCKeyboard send has been
iOS-only since e414ec0) — so returning nil to keep the menu out takes the host's
copy with it. The file's own comment said the opposite, that swallowing keys
"risks starving GC's own delivery"; on macOS there is no GC delivery to starve,
which is why this could never have been a one-line `return nil`.
Verified against AppKit rather than assumed: a standalone harness posting a
synthetic ⌘Q confirms the monitor sees it first and that returning nil stops the
menu item firing, with a passed-through ⌘W as the control.
⌘⎋ and ⌃⌘F stay client-side whatever the setting says — forward those and a
captured stream is a room with no door. ⌘Tab, ⌘Space and Mission Control are out
of reach for a local monitor: macOS claims them before any app sees them, and
catching them needs a CGEventTap and an Accessibility prompt, which is a product
decision rather than a code one.
This answers to the cross-client "Capture system shortcuts"
(`Settings::inhibit_shortcuts`), which the Apple client had no answer to because
SDL's keyboard grab is what implements it everywhere else. Default on,
profileable like its siblings, and — matching the SDL clients — inert under the
desktop mouse model, which is something you ⌘Tab away from.
Two adjacent defects fixed on the way, both the same root cause. macOS stops
delivering keyUp while ⌘ is held, so a forwarded chord key is released when the
last ⌘ comes up rather than waiting for an up that may never arrive, and the
one-shot `suppressedVK` latch is cleared in the same place — left pending (⌃⌘F's
F, ⌘⎋'s Esc) it would go on to eat the next press of that key. Chord matching
also stopped comparing the raw `deviceIndependentFlagsMask`, which carries Caps
Lock and the arrows' .function/.numericPad bits: with Caps Lock on, ⌘⎋ and
⌃⌥⇧Q — both escape hatches — were not recognized at all.
On glass still owed: a clean compile proves nothing for an input grab.
Field report: pressing Select disconnected the stream. The host log was
unambiguous about what it was NOT — "client datagram stream ended" plus "virtual
display torn down (deliberate quit — keep-alive skipped)" is a client that said
it was leaving, not a drop and not a compositor crash.
Only two client paths raise that: StreamScreen's BackHandler, and the exit chord
(router.onExitChord). The chord is excluded by construction — `armExit` posts a
1 s timer and releasing any member calls `disarmExit`, so a tap always cancels.
That leaves the back stack, and from a SOURCE_GAMEPAD device KEYCODE_BACK is the
ONLY keycode that reaches it: a mapped button is consumed in the gamepad branch,
anything with a VK is consumed on the keycode path, volume/power go to the
system, and a FLAG_FALLBACK BACK is swallowed. So a one-press quit identifies the
button's keycode without knowing which controller was on the couch.
Plenty of pads deliver Select as the plain KEYCODE_BACK a remote's Back uses,
with no BUTTON_SELECT scancode behind it — the Android-TV shape, where every
input device is expected to offer Back, reached whether the vendor prints "Back"
on the button or "Select"/"View". `buttonBit` had no row for KEYCODE_BACK, so the
press fell through unconsumed into StreamScreen's BackHandler, which is the
deliberate-quit exit. One press, session over.
The same gap meant those pads could not produce BTN_BACK at all, so every
shortcut built on Select was unreachable on exactly the devices whose users have
no keyboard: the emergency exit chord StreamScreen's own start banner advertises
("Hold Select + Start + L1 + R1 to leave"), the mic mute, the stats tier.
New `Gamepad.padButtonBit(keyCode, flags)` — buttonBit plus that one row —
resolves a gamepad-sourced BACK to BTN_BACK, and MainActivity's streaming branch
asks it instead. It keys off the keycode, not the vendor, so it covers every pad
with this behaviour; a pad that does carry BUTTON_SELECT is unaffected in both
directions, having never had the bug. FLAG_FALLBACK events stay excluded: those
are the synthetic BACK the framework raises after an unconsumed BUTTON_* press,
and forwarding one would put a phantom Select on the wire (one landing while
Start + L1 + R1 were held would complete the exit chord out of nowhere). A
remote's or keyboard's BACK is neither mouse- nor gamepad-sourced, so it still
leaves the stream — for a device with no pad on it that is the documented way
out, and the banner says so.
The mouse-side-button hook moves above the gamepad branch so a device that can
be a mouse keeps its X1/X2 semantics; it answers null for everything that cannot
be a mouse, so nothing else changes route.
PadButtonBitTest pins the mapping, the fallback exclusion, that the three Select
chords are now reachable from a BACK-only pad, and that no other keycode moved.
Verified: :kit:testDebugUnitTest + :app:testDebugUnitTest green (PadButtonBitTest
4/4), :app:compileDebugKotlin clean. NOT yet verified on-glass — the behaviour
needs a real pad: Select reaches the game, and Back no longer quits.
Closed testing went dry on 2026-08-01 when tags started publishing
straight to production instead of alpha — its testers have been pinned
to the last pre-access build since. Canaries now assign the same
versionCode to beta (open) AND alpha (closed) via play-upload.py's new
repeatable --also-track flag: both PUTs share one Play edit, so one
commit and one review cover both tracks and they can never disagree
about which canary is current. Tags still go to production only.
Main-push canaries now land on the open-testing track: public opt-in
link, no tester-list cap. Trade-off documented in the workflow header:
open testing goes through Google review (hours/days), where internal
was review-free (minutes). android-promote's from_track default follows
the canary to beta.
A new shared drop-in, packaging/linux/50-punktfunk-nice.conf
(user@.service.d, LimitNICE=-15), raises the user-session nice hard
limit so the direct setpriority() path works on rtkit-less boxes — a
limit, not a grant, effective from the next login. Shipped by rpm
(%files + install, flows into the Bazzite sysext via rpm2cpio), Arch,
and deb; the Steam Deck installer writes it to
/etc/systemd/system/user@.service.d instead (SteamOS /usr is
read-only), following its existing sudo-to-/etc pattern.
rpm and deb gain a weak Recommends: rtkit and Arch an optdepends hint —
with rtkit the fix needs no relogin at all. The NixOS module instead
sets security.rtkit.enable = mkDefault true (rtkit is not a given
there; mkDefault keeps it operator-overridable).
It remains true on every channel that the host binary must never carry
a file capability — the spec's no-caps note now names the two fallback
rungs instead of calling the thread nice a best-effort no-op.
Every Linux host to date ran its capture/encode/send threads at nice 0:
boost_thread_priority's setpriority() needs CAP_SYS_NICE or a raised
RLIMIT_NICE, no install channel granted either, and the host binary can
never carry a file capability (a capped process's /proc/<pid>/exe is
unreadable to KWin — the 0.26.0-1 incident). A 2026-08-14 field log
showed the cost end to end: a fresh game launch's shader-compile storm
descheduled the unprioritized threads, 5 ms audio datagrams left late
enough to stutter, the client's OWD signal rose, and ABR cut a
gigabit-Ethernet session to its 5 Mbps floor at zero loss — while the
same box carried 708 Mbps cleanly once the storm passed.
The renice now falls back to RealtimeKit (MakeThreadHighPriorityWithPID,
one blocking system-bus call per boosted thread) — the same unprivileged
broker PipeWire clients use, so nothing enters the permitted set and
KWin identification is untouched. Only the nice verb, never
MakeThreadRealtime: the SCHED_RR reservations apply to rtkit-granted RR
too. zbus rides ashpd's exact backend choice (tokio, no async-io) plus
blocking-api, so the resolved graph gains no second I/O backend.
And the audio plane is boosted for the first time: the 5 ms Opus
capture->encode->send loop (critical — a stall there is directly
audible), the PipeWire capture mainloop thread (its process callbacks
run there; PipeWire's own module-rt only covers data loops we don't
use), and the pad-audio streamer (above-normal, like the session send
thread). The first two had no boost call at all; on Windows the
audio_thread boost also engages, via the SetThreadPriority arm.
Adding the mgmt port beside the fingerprint pushed the inline
`Option<Box<dyn FnMut([u8; 32], u16)>>` over `clippy::type_complexity`, which CI denies. A named
`ConnectedFn` is what the lint asks for, and it gives the two positional arguments somewhere to be
documented.
My local gates ran `cargo check`, not `clippy -D warnings`, which is exactly why this reached CI
instead of dying locally. Re-verified with `cargo clippy --all-targets -- -D warnings` across all
eight crates: exit 0, pf-presenter confirmed genuinely linted, no type_complexity remaining.
Azure signing produces no .cer, so HOST_CER_PATH is deliberately unset. The publish step then built
its alias map as a single hash literal containing $env:HOST_CER_PATH as a KEY, and an unset $env:
var is $null — "A null key is not allowed in a hash literal", which failed the whole step. Canary
run 18256: the installer signed fine and published to its versioned path, then this line killed the
alias refresh, so `canary/punktfunk-host-setup.exe` went stale.
I reasoned about this line while making the .cer optional and concluded an unset variable would give
an empty-string key, which is legal. It does not — that only happens through string interpolation.
The $files guard just above filters the missing .cer correctly; the hash literal ran before anything
could use it.
Build the map incrementally instead, adding the .cer entry only when there is one, so the legacy
.pfx modes still alias it.
windows-client.yml survived the same change only by accident: it writes "$($env:MSIX_CER_PATH)",
and interpolating $null yields an empty string, which IS a legal key. Made that explicit too rather
than leaving correctness resting on quotes someone could reasonably tidy away.
Verified under pwsh 7: the old literal reproduces the exact CI message with the var unset; the new
form yields one entry unset and two entries set, with the .cer alias intact.
Robolectric renders no system UI and zero insets, so every phone capture
was missing the status bar and its content sat where the bar belongs — on
the Pixel store render the app title collided with the camera punch-hole.
ShotStatusFrame draws a plausible bar (time left, radios right, the
CENTRE left empty for the hole) and pushes the scene below it, the same
geometry real insets produce; height mirrors a Pixel's tall bar measured
off a real capture. On for the touch screens, off for the immersive
surfaces (stream, console shell, TV) that hide the real bar too.
ABI 19 -> 20. Wire protocol unchanged (still 2).
Persisting the mgmt port (fe2bfeca) made a moved port survive mDNS going away, but mDNS was still
the only SOURCE: a host that had never been seen on it — VPN-only, a routed subnet, or simply added
by address on a network where multicast has never worked — had nothing to learn from and fell back
to 47990. The `Welcome` now carries the port, so the client learns it over the connection it has
already authenticated and discovery stops being involved at all.
`Welcome.mgmt_port`, a trailing u16 after the cipher block, following the same additive discipline
as the eight fields before it (compositor, gamepad, bitrate_kbps, bit_depth, color, chroma_format,
audio_channels, codec): an older peer stops earlier and gets a documented default, in both
directions, so WIRE_VERSION does not move.
⚠ THE TRAP, and why emitting the port forces the `cipher` placeholder: `cipher` is emitted only
when non-default, so appending the port to an AES Welcome would land its LOW BYTE at offset 68 —
exactly where every shipped 0.28.x client reads `cipher`, whose decode is deliberately fail-closed
on an unknown id. 47991 is 0xBB57, so byte 68 would read 0x57 = 87, and EVERY current client would
fail the handshake against a host that had merely moved its mgmt port. `encode` therefore writes an
explicit cipher byte whenever a port rides along (the placeholder discipline `Hello::encode`
already uses); a current client reads AES, a pre-cipher client stops before 68 regardless. The test
pins the byte, both offsets (69 AES / 101 ChaCha), and that a host advertising no port still emits
exactly 68 bytes — this field costs the common case nothing.
Host: `mgmt::effective_port()` reads the same resolved bind `publish_endpoint` writes, so the wire,
the endpoint file and the mDNS TXT cannot disagree — one lookup, not a fourth place to compute a
port. `0` on the standalone punktfunk1-host binary, which has no management API: advertising 47990
from a host that is not serving it would be worse than saying nothing.
Clients persist it on connect, feeding the store plumbing fe2bfeca already built:
* Rust — `on_connected` grew the port alongside the fingerprint, plus `learn_mgmt_port_by_fp`
(keyed by fingerprint alone, the identity a just-connected client is certain of).
* Apple — `PunktfunkConnection.hostMgmtPort` + `updateMgmtPort` at the existing markConnected site.
* Android — a new `nativeHostMgmtPort` JNI call, persisted where the session is constructed.
Verified: Linux (pf-lxcheck2, amd64) `cargo check --all-targets` clean across punktfunk-core,
pf-host-config, punktfunk-host, pf-client-core, pf-presenter, punktfunk-cli, punktfunk-client-linux
and punktfunk-client-session, each confirmed genuinely compiled (counting `Compiling` as well as
`Checking` — cargo prints the former for bin-only packages, which is what made an earlier gate look
vacuous when it was not). punktfunk-core quic tests 76/76. Android: :kit+:app Kotlin, ParseRecordTest
12/12, and cargoNdkClippy clean for aarch64-linux-android. Apple: xcframework rebuilt at ABI 20,
`swift build` complete. cargo fmt --all --check clean. NOT verified: the Windows client
(192.168.1.133 unreachable).
Moving the mgmt port off 47990 (the fix for sharing a box with a Sunshine fork, whose web UI owns
that port) only ever worked for as long as mDNS did. The real port lived in the advert and nowhere
else: every client read it live and threw it away, so on a VPN, a routed subnet, or any
multicast-dead network the library silently fell back to a port nothing was listening on.
`KnownHost` gains `mgmt_port: Option<u16>` + `effective_mgmt_port()` + `learn_mgmt_port()`, exactly
the shape `mac` and `os` already use ("learned from the advert while online, persisted so it
survives the host going to sleep") — except this one is load-bearing rather than cosmetic, so
`upsert` states the preserve rule explicitly instead of relying on the does-not-mention-it accident
that `clipboard_sync` survives by, and `upsert_trusted` carries it across a re-key.
Wired through all four client families, each of which was wrong in its own way:
* CLI / Windows / Linux reached for `DEFAULT_MGMT_PORT` at the call site — the constant is the
FALLBACK, not the answer. Windows also needed the port on `Target`, which the library screen has
instead of a `KnownHost`.
* The session console read `advert.and_then(mgmt_port)` with NO saved fallback, two lines above an
`os` that gets the three-rung treatment right. It now matches, and learns on every tick.
* Linux's `mgmt_port_for` consulted live adverts only; it now falls back to the store.
* Android never carried the port at all — its native discovery record stopped at 8 fields. Added
`mgmt` as the 9th (the record's own documented "new fields append, never reorder" rule), then
through `DiscoveredHost` -> `KnownHost` -> `LibraryScreen`.
* Apple LOOKED done and was not: `StoredHost.mgmtPort` and `effectiveMgmtPort` have existed all
along, but nothing anywhere wrote the field and the `mgmt` TXT was never parsed — so it was
permanently nil and every Apple client resolved to 47990 regardless. That is worse than the
honest omissions above, because it reads as finished. Now parsed, carried on `DiscoveredHost`,
and written by `HostStore.updateMgmtPort` at the same site that learns MACs and the OS chain.
Also `PUNKTFUNK_NATIVE_PORT` in host.env, finishing the pair with PUNKTFUNK_MGMT_BIND: `--native-port`
was likewise CLI-only and died on a package upgrade. A bad value is a startup ERROR rather than the
silent fall back to 9777 that `PUNKTFUNK_DATA_PORT` still does — the failure that reads as "I moved
the port and the client still can't reach me". The client side of the native port already worked
(`KnownHost.port` is persisted, `--connect HOST:PORT` names it).
Adding the field broke three `KnownHost` literals in tests, which is the `Default` impl's stated
purpose working ("adding a field here can't silently produce records that lack it"). All three now
carry 47991 — deliberately NOT the default, so the assertions cannot pass vacuously against a
hardcode. New coverage: forward-compat decode of a store predating the field, the resolver
fallback, re-key carry-forward, and on Android the 9th-field parse plus 0/non-numeric/out-of-range
all reading as unknown.
What this does NOT fix: a host that moved its mgmt port and has NEVER been seen over mDNS. Nothing
tells the client where to look, and the honest fix is for the host to announce it in-band — the
`Welcome` message has an established "append a trailing field, older peer decodes to the default"
pattern for exactly this, at the cost of a C ABI accessor and a bump. Left for a separate change.
Verified: Linux (punktfunk-rust-ci/pf-lxcheck2, amd64) `cargo check --all-targets` clean for
pf-host-config, punktfunk-host, pf-client-core, punktfunk-cli, punktfunk-client-linux and
punktfunk-client-session — the last confirmed non-vacuous by planting a compile_error! and watching
the gate fail (cargo prints "Compiling", not "Checking", for bin-only packages, so the usual marker
grep lies about it). Android: :kit + :app compileDebugKotlin clean, ParseRecordTest 12/12 with both
new cases named in the XML. Apple: xcframework built, `swift build` complete, SharedFoundationTests
pass. cargo fmt --all --check clean. NOT verified: the Windows client (192.168.1.133 unreachable).
47990 is the management API's port and also Sunshine's (and Apollo's, and Vibeshine's) web UI
port. With the GameStream planes off it is the ONLY port the two still share, so moving it is the
whole of what "run both on one box" needs — except moving it was barely possible:
* `--mgmt-bind` was the sole route, and it lives in a unit file / service registration that a
package upgrade rewrites. There was no `host.env` key, so the change did not survive.
* The literal 47990 appeared in SIX places — mgmt::DEFAULT_PORT, the Windows service's console
launch, scripts/punktfunk-web.service, the NixOS module, web/web-run.cmd, and the console's own
default. Nothing downstream could learn a different port, so moving the listener silently left
the console proxying to a port nothing was listening on.
Now there is one source of truth. `PUNKTFUNK_MGMT_BIND` joins `host.env` (the `--gamestream` /
PUNKTFUNK_GAMESTREAM shape: either source works, the CLI flag wins), and `serve` publishes the port
it ACTUALLY bound to ~/.config/punktfunk/mgmt-endpoint, in the same KEY=VALUE form mgmt-token
already uses so it is sourceable as a systemd EnvironmentFile and readable by the Windows service's
existing read_env_file_value. Every consumer derives from that; the 47990 literals survive only as
the fallback that keeps an OLD host working with a NEW console.
The two unit files drop their hardcoded `Environment=PUNKTFUNK_MGMT_URL=` rather than layering a
default beneath the file: whether Environment= or EnvironmentFile= wins is a directive-ordering
question, and the hand-written unit and the Nix-generated one do not order the same way. No
default, no precedence puzzle — the server's own built-in fallback covers a host that never wrote
the file.
Two robustness details worth naming, because both fail in the same direction:
* mgmt-endpoint is written write-then-rename. A torn read would set PUNKTFUNK_MGMT_URL to EMPTY,
which is worse than a missing file — a built-in default only rescues an *unset* variable.
* mgmtUrl() now treats blank as unset, which `??` alone does not.
The publish happens in parse_serve next to the token persistence, so both files appear together;
the console's unit gates on mgmt-token, and its Restart=always picks up a lost race anyway.
What this does NOT change: a lost 47990 bind is still fatal to the whole host (the bind sits in
tokio::try_join! with the native plane), and running two Moonlight-compatible hosts at once is
still unsupported — on Windows the exclusive display topology is a second, independent conflict.
Both are documented rather than altered.
Verified on Linux in punktfunk-rust-ci (amd64): cargo check --all-targets clean for punktfunk-host
and pf-host-config with the "Checking punktfunk-host" marker confirmed present (a first run exited
0 having compiled nothing — the warm shared target dir judged it fresh), 40/40 mgmt tests pass
including the new one pinning the published line against both parsers that consume it. Console:
tsc --noEmit clean, bun test server/ 9/9. cargo fmt --all --check clean.
The Blender store scenes render whatever screens/ holds, and theirs were
June captures of the pre-console UI. Fresh captures existed for hosts and
pair but the add-host sheet had no scene: AddHostSheet's state is hoisted
(ConnectScreen keeps half-typed values across dismissal), so the scene
passes a filled form straight in.
Two capture-truth fixes with it: dialog scenes advance the frozen clock
1.6 s (a ModalBottomSheet's entrance spring is still mid-rise at 0.8 s),
and the add-host shot uses Pixel-like geometry (411×915dp @ 420 dpi —
same 1080×2400 px, but the dp headroom is what lets the Connect button,
the row carrying the resolution promise, fit in frame).
Verified the whole Azure signing path on the runner (.133) today and it failed twice, for two
reasons that neither error message named. Both are now provisioned here so a rebuild from the
unom/infra Packer template cannot silently un-fix them.
Azure.CodeSigning.Dlib.dll is a mixed-mode C++/CLI assembly: it ships Ijwhost.dll and a
runtimeconfig.json pinning Microsoft.NETCore.App 8.0.0. The runner had NO .NET runtime at all —
pwsh 7 is a self-contained install and brings no shared runtime — so signtool exited 3 having
printed absolutely nothing. Installing the .NET 8 runtime turned that into a clean sign.
The client itself installs machine-wide under C:\trusted-signing rather than a user's .nuget,
because act_runner runs as SYSTEM, whose USERPROFILE is C:\Windows\System32\config\systemprofile.
A per-user install under Administrator is invisible to every job that actually builds. Confirmed by
resolving Find-AzureDlib from a SYSTEM scheduled task, which is also how the earlier SSH-only
attempts misled: over a network logon New-SelfSignedCertificate hits NTE_PERM, so a control test
that "fails" there proves nothing about how CI will behave.
Both downloads are SHA-256 pinned against version-immutable URLs (nuget.org flat-container and the
dotnet builds CDN), so they fail closed on tampering rather than on every Microsoft patch release —
unlike the BtbN `latest` pin above, which re-rolls. The .NET install uses Start-Process -Wait
because the bundle is a GUI PE that returns instantly under `&`, leaving $LASTEXITCODE unset and
racing the completion check (cost one false failure here).
End-to-end result on .133, as SYSTEM: sign rc=0, verify rc=0, chain Microsoft Identity Verification
Root CA 2020 -> ID Verified CS EOC CA 04 -> "unom - Enrico Buhler", leaf thumbprint
DD6A610F242CB5B2078C2A5D628699B6AB0CAC07 (matches the profile Azure reports), timestamped, leaf
expires in 3 days as expected. Signing an unsigned binary and reading the subject back reproduces
pack-msix.ps1's Publisher assertion exactly (match=True) — checked against a NON-catalog-signed
binary on purpose, because Get-AuthenticodeSignature on a catalog-signed system exe returns the
catalog signer and would have read as a false mismatch.
`PUNKTFUNK_AUDIO_GAIN` had two defects that compounded.
It existed only on the GameStream plane, so on native `punktfunk/1` it silently did
nothing — and since WASAPI loopback is tapped UPSTREAM of the endpoint's master volume,
turning the host's speaker slider up does not change the level a client receives either.
Between the two there was no host-side way at all to lift a quiet desktop mix on the
protocol that matters.
And where it did apply it was `(s * gain).clamp(-1.0, 1.0)` — a hard clip. Flat-topping a
waveform is a first-derivative discontinuity, which radiates harsh high-order harmonics, so
any operator who pushed past roughly 1.5x heard gross distortion long before reaching the
level they were chasing. A field report of "+18 dB and everything warbles" is the expected
output of that line, not a fault anywhere downstream of it.
`punktfunk_core::audio::apply_gain` replaces the clamp with a tanh soft knee above 0.7
(~-3.1 dBFS), chosen for three properties: C1-continuous where the branches meet (slope 1
on both sides, so the onset of limiting is not itself an audible event), bounded by
construction (asymptotic to 1.0, and +-inf maps to +-1.0, so nothing leaves out of range),
and odd-symmetric (benign harmonics, no DC). It is a memoryless waveshaper, so it costs
zero latency in the realtime encode path.
Unity is a no-op inside `apply_gain` itself, not merely at the call sites, so the default
wire stays byte-for-byte identical and a future caller that forgets to gate cannot quietly
bend every peak. `capture_gain` is now shared by both planes and rejects the two values
that are always typos: non-positive (would invert or mute) and above 8.0/+18 dB (capped,
and said out loud).
This buys headroom, NOT loudness. It cannot close a peak-to-loudness gap against
already-limited broadcast content; that needs a compressor with a real time constant, which
this deliberately is not, and the docs say so.
`SOFT_LIMIT_KNEE` is excluded from cbindgen: it is host-side capture processing that no C
embedder can act on, and exporting it would add a bare `#define` against the config's own
R21 rule. Verified by regenerating `include/punktfunk_core.h` — byte-identical, ABI 19
untouched.
Releases move from the self-signed CN=unom cert to Azure Artifact Signing (formerly Trusted
Signing): account `unomsigning`, profile `unom-io`, signed by the `punktfunk-ci-signing` service
principal, which holds only the Artifact Signing Certificate Profile Signer role scoped to that one
profile. Both pack scripts gain the backend ahead of the existing .pfx and ephemeral fallbacks, so
canary and fork builds are unaffected.
Three things that are easy to get wrong, and are handled here rather than discovered in the field:
Azure mints a leaf certificate per signing request that expires in about three days. Both scripts
previously retried WITHOUT a timestamp when a timestamped sign failed — under Azure that ships an
artifact which verifies on the runner and goes untrusted days later, on every user's machine at
once. The retry is now gated on the mode: still lenient for a .pfx whose cert outlives the release,
a hard failure for Azure.
The MSIX manifest Publisher must equal the signer subject byte-for-byte, because package identity is
Name + Publisher. The default is now the profile's verified subject, written with `[char]0xFC`
escapes rather than literal umlauts so this UTF-8-without-BOM file cannot silently mojibake the DN
into one that no longer matches. pack-msix.ps1 now also reads the signature back off the packed
.msix and fails on drift — asymmetric on purpose: a subject that disagrees is fatal, a subject that
cannot be read is only a warning, since Get-AuthenticodeSignature's .msix support varies by Windows
version and signtool has already reported success by then. NOTE this changes package identity, so
existing installs need an uninstall, not an upgrade.
The updater's leaf-pinning note was wrong and is corrected: update/windows.rs claimed the
AUTHENTICODE_SHA256 field made Trusted Signing "a manifest edit", but a per-request leaf is exactly
what a leaf pin cannot track — a pin would go stale within days and reject every release after it.
Drivers are deliberately untouched: their catalogs keep the DRIVER_CERT_* cert and the installer
still plants it as a machine root. The two signatures were always independent (SmartScreen/UAC vs
PnP), which is why the installer could move without them. Whether a publicly-trusted catalog would
let us drop that root plant is recorded as an unverified follow-up, not assumed.
Verified: both scripts parse under the PowerShell 7 AST parser, both workflows are valid YAML, the
evaluated Publisher default matches the subject Azure reports for the profile (86 chars, ordinal),
rustfmt clean. NOT verified on Windows — the sign path itself needs an on-glass run on .133.
The whole shot harness is #if DEBUG, and shoot_macos built -c release —
so the binary launched as the NORMAL app, never printed PF_SHOT_WINDOW,
and every scene 'never reported a window' while the script SIGKILLed a
perfectly healthy app. Build debug: SwiftUI has no release-only visuals,
and the harness actually exists there. All eight mac scenes capture now.
compliance/vendored-components.md records, per vendored/bundled component,
where the pin lives, how it updates, and which feed to watch — the CRA
Art. 13(5) due-diligence evidence (S4 in the roadmap). Retention verified
while writing it: Gitea serves the full release history v0.17.x -> current,
stable sysext feeds publish KEEP=0, flatpak rsyncs without --delete.
The manual SBOM fragment gains the bundled Bun 1.3.14 runtime (portable
bun.exe in the Windows installer for the console + plugin runner — it was
in no lockfile and no SBOM) and stops hardcoding the gamescope patch count
at 3 when the series is at 9. SECURITY.md gets the one sentence Annex I
Part II asks for: security fixes are free, prompt, and ride patch releases
— which the stable channel already did, unwritten.
bun update (fumadocs 16.14, tanstack ~1.170, react 19.2) plus @unom/ui 0.8.16
-> 0.9.2 and @unom/app-ui 0.1 -> 0.2.1. Build, tsc --noEmit and a served
smoke test all pass. The audit stays non-blocking: every remaining advisory
is pinned inside @unom/ui's own dependency tree (@payloadcms/* -> fast-uri/
image-size/sharp, next 16.x, sass -> immutable) — nothing bumpable from this
lockfile, and overrides would fork what the CMS actually ships. The comment
in audit.yml now names that blocker instead of the stale dompurify/node-tar
list.
A bare-spawn gamescope session is its own headless compositor, so it was the one
Linux route that never consulted effective_topology(): on a KDE desktop box the
physical panel kept showing the idle desktop for the whole stream while the
policy said exclusive. The KWin route's mechanism (disable the physicals) is
closed on this route — KWin refuses a configuration with zero enabled outputs
and no output on that desktop is ours to leave enabled — so the honest
translation is DPMS: the desktop stays exactly where it is, the panels go dark,
local input wakes them, and stream input never does (it enters gamescope's own
EIS socket, not KWin's libinput).
New kwin_dpms module drives the vendored org_kde_kwin_dpms protocol in-process
over the desktop's own Wayland (the kwin_output_mgmt stack and rationale), with
a kscreen-doctor --dpms fallback on kwin.rs's shared verdict/budget. The darken
is refcounted host-wide rather than floated through the registry's per-group
restore, because every gamescope spawn is its own group — the float alone would
re-light the panel when the first of two concurrent spawns ends. Each exclusive
spawn registers the release as its per-display topology restore, so the
registry still times every release (§6.1) and the last one out re-lights only
what the first darken actually turned off. Crash-safe by construction: DPMS is
non-persistent, so a dead host leaves nothing to journal — the panel re-lights
on the next local input.
Managed and Attach are deliberately untouched: managed's takeover already
stopped the desktop, and attach may be mirroring a gamescope that is itself
driving the physical panel.
The aurora screens read the LIVE uiPalette default, and shot mode never
forced one: the Apple TV Simulator had a sunset palette persisted from
manual use, so every tvOS capture came out pink-on-pale while the iPhone
set stayed violet. ScreenshotHostView now pins the palette (violet, or
PUNKTFUNK_SHOT_PALETTE) before the scene mounts.
Also documents the local tvOS-SIMULATOR wall in screenshots.sh: Xcode
26.6 and the 27 beta plan the macro targets swiftui-navigation-transitions
pulls in for the tvOS triple and never schedule their swift-syntax deps
('unable to resolve module dependency') — prebuilts on or off. Only the
tvOS target links that package, which is why iOS and device builds never
hit it. Local workaround, since HomeView's use is canImport-guarded:
temporarily unlink the product from the tvOS target, capture, restore.
Syncing the Playnite plugin failed outright:
PUT /library/provider/playnite failed: entries[9]: launch.value for kind
launcher_ui names a launcher this host cannot open (playnite)
Two defects, and the second is why it cost every game rather than one tile.
1. The host looked for Playnite in the wrong registry hive and the wrong
profile. `playnite_fullscreen_exe()` read HKEY_CURRENT_USER, then fell back
to %LOCALAPPDATA% — but the Windows host is a LocalSystem service, so its
HKCU is the SYSTEM hive (S-1-5-18) and its %LOCALAPPDATA% is
C:\Windows\System32\config\systemprofile\AppData\Local. Playnite installs
per-user by default, so both lookups miss on a default install. The doc
comment reasoned correctly that Playnite is per-user and then read the one
HKCU that cannot see it.
It also hardcoded `…\Uninstall\Playnite`. Playnite ships an Inno Setup
installer, and Inno registers `<AppId>_is1` — measured on a Windows box
where Git and Inno itself appear as `Git_is1` and `Inno Setup 6_is1` — so
that key matched nothing anywhere.
Now: every loaded hive under HKEY_USERS plus both HKLM views, matched on
DisplayName rather than key name, then `C:\Users\*\AppData\Local\Playnite`
for the conventional install (and for a user whose hive is not loaded).
2. One unopenable tile 400'd the whole reconcile. The Playnite plugin appends
a single launcher tile beside its games, so refusing the payload cost the
operator the entire library — the same shape as the unservable-cover bug
that sanitize_art_paths was introduced to fix, on the launch side this time.
`valid_launcher_ui` conflated two different failures. Split into
`known_launcher_ui` (vocabulary — a plugin bug, still a hard 400, because
the author has no other way to find out) and `resolvable_launcher_ui`
(environment — the launcher just is not installed here, which is a fact
about the box). `sanitize_launcher_entries` drops only the latter, with one
warn, and the games sync.
Portrait captures show a layout nobody streams in. The touch controllers
frame and the library shot now render at landscape phone geometry (the
portrait library variant is gone), a console-controllers-landscape frame
joins the set, and the Apple 12-controllers scene rotates: on the
landscape canvas the two pads sit as side-by-side columns — one
ControllerTestView per pad — so neither story is cut by the short height.
Known wart, deliberate: the console landscape frame's floating legend
overlaps the second pad card mid-scroll; the styled composite crops above
it, and the touch variant carries the uncropped two-card view.
ControllerTestView drew straight from GCController/GCExtendedGamepad, and a
GCController cannot be constructed — the store plan's FEEL THE GAME frame
had no Apple scene. Every card now renders plain values (ShotPad,
InputSnapshot): the live path flattens the active DiscoveredController and
samples the pad into a snapshot on each 30 Hz tick, the screenshot harness
hands the panel pads that were never connected via a default-nil shotPads
parameter (the seam Android's ControllersScreen grew in 0a468c96). Live
behavior is unchanged — same cards, same order, same live feeds.
The 12-controllers scene injects the two pads the listing names — the
DualSense leading with the feedback surface (adaptive-trigger effects,
rumble backend, lightbar + player LEDs), the Xbox pad carrying the input
readout frozen mid-game; transport/battery/player ride in the header's
detail line because the panel has no dedicated battery row. Registered in
the iOS/macOS block and the store set only: ControllerTestView does not
build on tvOS, so the tvOS CI scene list is untouched.
The store plan's PICK & PLAY and FEEL THE GAME shots had no scene on any
platform: the library screen's state comes off the network, and the
controllers screens enumerate InputDevices, of which Robolectric has none
(the old shot honestly said 'no controller detected' — a palette proof
that sells nothing).
- Android library: Coverflow goes internal and LibraryScene rebuilds the
real shell around it (aurora, header, hint bar) with a mock shelf.
Cover art is answered synchronously by coil-test's FakeImageLoaderEngine
with generated gradient posters, so the frozen animation clock never
races an async load. Shot at phone portrait+landscape and TV geometry.
- Android controllers: PadRow renders a PadInfo model instead of a raw
InputDevice (padInfoOf maps real devices; both screens take a
padsOverride). The scenes inject the two pads the listing names —
DualSense (player 1) and Xbox (player 2), real VID:PIDs.
- Apple library: ShotLibrary composes the real LibraryCoverflowView with
a JSON-decoded mock shelf (GameEntry's memberwise init is internal to
PunktfunkKit; Codable is the public construction surface), registered
as cross-platform scene 11-library and added to the store set + the
tvOS CI scene list. Artless entries settle to their deterministic
fallback posters, which is also what keeps the shot offline.
Apple controllers stays a follow-up: ControllerTestView binds to live
GCController hardware and has no injection surface yet.
Verified: all 31 Roborazzi scenes render; the new tv-library,
phone-library and controllers shots reviewed by eye.
Ten more commits landed after the 0.28.1 release commit — the deb image fix, the
two macOS audio ones (#221 + #223) and the TV screenshot automation — so the
release paperwork no longer described the release.
CHANGELOG: 50 -> 60 commits since v0.28.0. Nothing else moves; the version table
is unchanged on every row, re-verified against the tag (`include/`,
`crates/pf-driver-proto`, `plugin-kit/package.json` and `sdk/` are all still
byte-identical to v0.28.0, so the C ABI stays 19). #221 brought its own CHANGELOG
section, so the technical half already covered it.
NOTES: the user-facing file had no mention of the macOS fault at all, and it is
headline-grade — streaming from a Mac with the mic on cut audio AND froze input
on a ~2.5 s metronome, with turning the microphone off as the only workaround. It
now leads the summary paragraph, has a TL;DR line and a full Fixed entry
explaining the loop in plain terms (a mic that cannot run echo cancellation, each
failed attempt knocking out the working path and thereby triggering the next).
The TL;DR was also trimmed from nine multi-line bullets to seven one-liners.
`docs/releases/README.md` asks for 3-6, and this release has an unusual number of
genuinely severe entries — seven is the honest floor without hiding one, and the
long-form detail was already duplicated below in Fixed, which is where it belongs.
The Apple stats-overlay and Apple TV colour bullets lost their TL;DR slots and
keep their Fixed entries.
`SessionAudio.start()` being asynchronous on macOS is added to the notes' `For
developers` paragraph — it is the one embedder-visible edge in #223, and an
embedder who only reads the notes would otherwise meet it at runtime.
Play notes are untouched and still accurate: the only commit to touch
clients/android since is `b6b3c10c`, which is screenshot CI, not app behaviour.
Gates on this tree: fmt clean, `cargo metadata --locked` consistent,
`cargo test -p punktfunk-core` 210 passed, C ABI harness abi_version=19,
`api/openapi.json` and the docs-site copy still byte-identical.
Google Play's Android TV slot needs 16:9 1920x1080 shots and the App Store
needs Apple TV 1920x1080 — neither existed as automation output:
- apple.yml screenshots job now runs the tvos leg. The harness supported it
all along (tools/screenshots.sh tvos); what the job was missing is the
Tier-3 tvOS xcframework slices (nightly + -Zbuild-std, same recipe the
distribute job uses on this runner) and an explicit scene list — the
gamepad-console scenes are compiled out on tvOS, and an UNKNOWN scene
name falls back to a normal app launch, which would silently capture the
real empty app. Still best-effort: a tvOS hiccup warns, never reds.
- TvScreenshotTest renders the console scenes + the stream HUD at Android
TV geometry (w960dp-h540dp-television-xhdpi = native 1920x1080, no
resampling), prefixed tv- so the artifact separates the form factors.
Verified locally: 6 scenes, all 1920x1080.
android-screenshots.yml needs no change — it runs the whole unit-test task
and uploads the whole roborazzi output dir.
An AVAudioEngine start can block on the audio server for seconds (~1.9 s
per attempt in the 2026-08-14 field case), and macOS captures and sends the
stream's input from the main thread — so every device-change rebuild, loop
or no loop, froze the stream's input for the length of the rebuild, and a
mic-on session start stalled the UI at connect.
All engine lifecycle work (start/startEngines and below, teardown, rebuild)
now runs on a per-session serial engineQueue; the main queue keeps only the
trigger bookkeeping — debounce, backoff, and the retry ladder — which is
cheap by construction. The rebuild path splits accordingly: rebuildFire
(main: bookkeeping, reads the config) → performRebuild (engineQueue: the
actual teardown + start) → rebuildFailed (main: ladder scheduling; a fresh
trigger already queued wins over a retry).
Confinement moves with the work: ring, startConfig and enginesAttempted go
under the existing stateLock (start paths write on engineQueue, stats and
the revive gate read elsewhere); combinedGate is engineQueue-confined; the
permission-grant continuation lands on engineQueue instead of main. The
engines were already lock-guarded and stopped cross-thread by stop(), and
every start path already re-checks the stop flag after publishing, so the
in-flight-start-vs-stop race keeps its existing resolution.
Embedder-visible edge: SessionAudio.start() is now asynchronous on macOS
too (it always was on iOS/tvOS) — playback is live shortly after the call,
not on return; stats is safe from any thread.
Gates: swift build + 295 tests 0 failures (macOS), full-package
arm64-apple-ios17.0 typecheck.
The voice-processing engine cannot start on some input devices (field case:
a 6-channel interface — 'combined engine failed to start', every time). The
device-change recovery re-tried it on every rebuild, and the failed attempt's
HAL churn (VPIO builds and tears down an aggregate device) stopped the healthy
fallback engines, which posted the AVAudioEngineConfigurationChange that
scheduled the next rebuild: a self-sustaining ~2.5 s loop for the session's
whole life. Each ~1.9 s rebuild runs on the main thread — where macOS input
capture and sending live — so the stream's INPUT cut out on the same beat,
while video (own socket, own threads) ran untouched; the wire signature
matched network loss and the host's METRONOMIC heuristic pointed at the
display stack, which is what made the field report so misleading.
Three defenses, layered because no single one covers every feedback shape:
a VPIO start failure latches per input device (CombinedTopologyGate — a
rebuild goes straight to the split topology; a different default input earns
exactly one fresh attempt); a configuration change posted by an engine that
is RUNNING is the rebuild's own echo and is ignored (an engine stops itself
before posting, so a live poster was already restarted); and rebuilds that
chain anyway back off exponentially (RebuildBackoff, 0.5 s floor doubling to
a 30 s cap, reset by 10 s of quiet) with a WARN that names the condition.
Both policies extracted to AudioRebuildPolicy.swift where a unit test can
reach them: 7 new tests, the loop test plant-the-defect verified (the shipped
flat floor produces 800 rebuilds in the 10-minute sim; the ladder ≤ 25, and
responsiveness after quiet is asserted). iOS/tvOS semantics untouched.
Gates: swift build + 295 tests 0 failures (macOS), full-package
arm64-apple-ios17.0 typecheck.
The v0.28.1 deb leg failed for real, and the package it costs is the whole
punktfunk-gamescope .deb:
gamescope/layer/meson.build:3:14: ERROR: Dependency "x11-xcb" not found, tried pkgconfig
Not a flake and not the pin. v0.28.1 flipped
`-Denable_gamescope_wsi_layer=true` in build-punktfunk-gamescope.sh (it was off
before, on the recorded and false premise that the layer is version-independent
of the compositor). The layer is a separate meson subdir with its own dependency
set, and it wants x11-xcb — which the compositor never did. So an image that had
been sufficient for every previous release stopped being sufficient the moment
the layer started building, and nothing named the new dep anywhere.
Debian is the only channel that has to name it: Arch's libx11 and Fedora's
libX11-devel both ship x11-xcb.pc themselves, which is why arch.yml and rpm.yml
build the same tree fine and only the trixie image came up short.
Asserted as well as installed. The image already asserts the wayland-server
floor at build time, on the argument that the one version deciding whether the
image can do its job should fail loudly HERE rather than inside a deb.yml run —
and this is the same class, only worse: a missing x11-xcb does not fail the
compositor build, it fails the layer's, and the layer is the only route to an
HDR10 swapchain for a nested game. Losing it silently produces a package that
looks completely healthy and denies every game HDR, which is precisely the
failure v0.28.1 exists to end. The assertion means the next dependency the layer
grows fails at image build instead of mid-release.
ORDERING, for whoever lands this: docker.yml rebuilds the image on a push to
main (its key hashes the ci/ tree, so this change busts it), and deb.yml's
gamescope job consumes `:latest`. Let the image publish before the deb job that
needs it runs — on a release cut that means merging this, letting docker.yml
finish, and only then pushing the tag. The failed job saved no cache, so the tag
run rebuilds against the new image rather than restoring the broken state.
NOT verified locally: no Docker on this machine, so the image was not built and
the layer was not compiled here. The package name is confirmed against Debian's
own package index (libx11-xcb-dev ships x11-xcb.pc, and exists in trixie), and
the assertion added here is what proves it in CI — if the name were wrong the
image build fails loudly instead of the deb leg failing quietly.
50 commits since v0.28.0 (32 non-merge). Cut from origin/main f8361f3e.
THE NUMBER: 0.28.1 is defensible but not free. Three `feat(...)` commits landed
since the tag — the "unpair all" button and its two endpoints, the Apple
gamepad-UI host menu, and the tvOS present-floor levers. That is not the shape
of v0.28.0's cut (17 feats, a packager-visible default flip, an MSRV rise and a
deletion that empties the library grid), and none of the three changes a
contract: every one is additive, and the version table is unchanged on every row
an embedder, packager or driver author reads. `scripts/ci/pf-version.sh` derives
the canary base as latest-stable + one minor, so 0.28.1 and 0.29.0 both leave
canary on 0.29.x and neither collides.
NOTHING BREAKS, and this was measured rather than assumed, twice — before and
after the four late PRs. `include/` is byte-identical to the v0.28.0 tag, so the
C ABI stays 19; `crates/pf-driver-proto`, `plugin-kit/package.json` and `sdk/`
show no diff against the tag at all. The one Rust-visible change is an addition:
`punktfunk_core::client::FLUSH_COOLDOWN` went `pub(crate)` -> `pub`, so the host
can compare against the constant instead of a copy of the number.
ONE DEFECT FOUND AND FIXED WHILE PREPARING:
`docs-site/public/openapi.json` had drifted for the THIRD time in two release
cycles. It was still stamped 0.27.0 and missing both new collection deletes,
while `api/openapi.json` sits at 0.28.0. v0.28.0 fixed this once (it was five
releases stale at 0.21.0) and it drifted again inside that same cycle. Re-synced;
the two files are byte-identical again, and re-checked after the rebase. The copy
is a documented manual step (CONTRIBUTING.md) that nothing in CI enforces — three
drifts is the argument for gating it, and that gate is not in this commit.
CHANGELOG: the in-development section carried four topics and the late PRs
brought four more of their own; the remaining twenty-one commits had none. Added
the version table (every row measured, not copied forward), an explicit empty
breaking-changes verdict, and sections for the management API's two collection
deletes, the Hyprland/Sway cursor-mode negotiation, the gamescope WSI layer we
now ship ourselves, the 203-nit SDR anchor, the Apple stats/colour faults, the
Skia loader-version regression, the AV1 level sentinel, the stats stage-line
partition, the two host warnings that named the wrong subsystem, and the
docs-site openapi drift.
NOTES: `docs/releases/v0.28.1.md` follows the post-v0.25.0 split — user-facing
only, TL;DR first, internals left to the CHANGELOG link, which points at the
v0.28.1 TAG rather than main. The two Windows headliners lead it: the Steam
add-on publishing nothing (a 0.28.0 regression that emptied the grid) and an idle
host wrecking a locally played game. `Before you update` carries the two
Sound-settings changes an operator will see and could read as defects, plus the
0.27-and-older pointer at v0.28.0's action items.
luxus is credited three times: in the lead-in the Discord embed shows, inline on
the fix itself, and in a new `## Thanks` section — the linger crash was his find,
his patch and his on-glass proof, and it ships as he wrote it. The CHANGELOG
keeps its own credit with the overlay#9 link.
Play notes are 436 characters against the 500 cap and cover only what changed in
the Android app, which this release is still one commit of.
GATES, all green on this tree after the rebase: `cargo fmt --all --check` clean;
`cargo metadata --locked` consistent; Cargo.lock diff is versions-only, 36/36
lines, zero non-version lines against the new base; `cargo test -p punktfunk-core`
210 passed; the C ABI harness passes printing abi_version=19 (needs
`LIBRARY_PATH=/opt/homebrew/opt/opus/lib` on macOS — a link path, not a defect);
the repo pre-push hook exits 0.
`systemd.user.*` has no per-user form in NixOS — it installs units into every
user's manager. With `host.autoStart` adding them to `default.target`, that
included root, whose `user@0.service` exists the moment anybody SSHes in as
root. Root's host won the race for the fixed ports and the desktop user's copy
crash-looped forever on `bind RTSP 48010: Address already in use`.
Every other listener binds first and logs success, so the log reads like a
clash with an unrelated program; a second copy of itself running as root is the
last thing you look for. `host.users` did not help — it only granted
input/punktfunk group membership and never scoped the units.
Render `ConditionUser=` on all four user units from `host.users`. Entries are
written `|user`: the pipe makes each a triggering condition, which systemd ORs,
where plain repeated `ConditionUser=` lines are ANDed and would match nobody.
With `host.users` empty, fall back to `!@system` — still keeps root out while
leaving the manual `systemctl --user enable --now` route working for a login.
module-check.nix gains three assertions covering both branches and web-init
keeping its non-triggering ConditionPathExists alongside the new condition.
They run in nix.yml's eval leg, and were confirmed to fail against the unfixed
module (2 of 23) before being committed. Verified on the box that found this:
root force-starting the host now yields ConditionResult=no.
Hyprland and wlroots both hardcoded portal `CursorMode::Metadata` whenever the
session had negotiated the cursor channel, and never asked the backend what it
supports. That is not a soft failure: xdg-desktop-portal's FRONTEND validates the
requested mode against the backend's `AvailableCursorModes` and fails the call
with `"Unavailable cursor mode %x"` before the backend ever sees it.
So a cursor-forward session (desktop mouse mode) died at `select_sources`,
surfacing as "pipeline build failed" and a black client, with
`unavailable cursor mode 4` in the portal log. Field report 2026-08-14.
MEASURED on .21 the same day, and it is worse than the report suggested: against
a LIVE Hyprland 0.56.2 with xdg-desktop-portal-hyprland 1.4.1 and
xdg-desktop-portal 1.22.1 — all current — `AvailableCursorModes` reads **3**
(Hidden|Embedded) on both the backend impl interface and the frontend. xdph does
not offer the metadata cursor at all, so this broke EVERY cursor-forward session
on current Hyprland, not merely on old installs. Updating the portal would not
have helped. xdpw is the same from the other end: its screencast.c refuses
METADATA outright.
pf-capture's own portal path has always negotiated (`choose_cursor_mode`); this
restates that ladder in pf-vdisplay, which may not depend on pf-capture. The
downgrade is graceful rather than merely survivable: with the portal on Embedded
no `SPA_META_Cursor` arrives, so the host feeds the cursor channel nothing and a
cursor-forward client draws nothing of its own — one pointer, not two.
`PUNKTFUNK_PORTAL_CURSOR_MODE=auto|hidden|embedded|metadata` pins the preference
for a backend that advertises a mode it implements badly, which negotiation
cannot detect. It is a preference only: pins run the same ladder, so no value can
re-create the refused request.
The module is declared unconditionally so its ladder tests run on every CI leg
rather than only the one that compiles `mod hyprland` — including a Linux-only
test pinning our bit values against ashpd's enum, verified non-vacuous by
planting a wrong discriminant (ashpd answers 4 for Metadata, the number in the
report). The regression test uses 3, the bitfield measured on glass. Linux: 225
tests pass, clippy --all-targets -D warnings clean.
The per-pad endpoint is stamped to be indistinguishable from a real
DualSense speaker — that is the feature during a pad session (libScePad
titles route haptics audio at it) and a trap the rest of the time: the
endpoint is pre-provisioned at EVERY host start and stayed visible
forever, so an idle Helldivers 2 found it by identity, engaged its
DualSense-haptics path against a device nothing services, and dropped to
2–5 FPS 1% lows — host idle, no controller plugged in, no session ever
run (field-confirmed 2026-08-14: the reporter isolated the 'DualSense
speaker' and disabling it in mmsys.cpl restored full performance).
That manual remedy is now automatic: the endpoint parks HIDDEN
(DEVICE_STATE_DISABLED, IPolicyConfig::SetEndpointVisibility — the call
behind mmsys.cpl's own Disable, vtable slot pinned next to the
SetDefaultEndpoint we already bind) whenever no client pad is attached.
Provisioning hides it at startup, a PUNKTFUNK_PAD_AUDIO=0 host hides
leftovers from earlier runs, and the per-pad streamer shows it for
exactly the pad's lifetime — to a game, a DualSense arriving and
leaving. The devnode, driver binding and stamps stay put (registry-based
resolution finds a disabled endpoint at the next boot), so the flips
raise no PnP traffic and the expensive provisioning still happens once
at boot — the #185 lesson holds.
Devtest: pad-endpoint grew show/hide verbs; tone/capture need a show
first on a parked box.
The wiring pass asserted 'default recording = virtual mic capture' on EVERY
pass — including the mic pump's eager boot pass — so an idle box permanently
held the Windows default recording device (and, since SetDefaultEndpoint
covers eCommunications, every game's voice input) on a virtual microphone
whose render feeder is idle-stopped, with no restore path at all: not at
session end, not at service stop. Field-measured 2026-08-14: Helldivers 2
(Wwise + always-on voice) played LOCALLY on an idle host tanks to 2–5 FPS 1%
lows, and mmsys.cpl's own Recording tab goes unresponsive polling the same
endpoint; the reporter's Sound settings showed 'Punktfunk Microphone —
Dispositivo predefinito' with the host idle.
The recording default now follows the exact discipline the playback default
has always had — parked only while a desktop-audio capture is open, with the
operator's device remembered (in memory + an on-disk crash marker,
audio-default-rec.prev), restored on capture close, recovered after a crash
on the next boot's first wiring pass, and unparked by the uninstaller. A
game launched during a stream still binds the client's mic (the park runs
before the session's game does); one launched before the stream keeps the
operator's own microphone — the honest answer.
Because earlier builds recorded nothing to restore, an upgraded box would
have stayed wedged on the virtual mic forever: an idle-pass hygiene now
moves a default found sitting on the plan's mic capture back to the first
REAL microphone (pure picker wiring_plan::real_capture, unit-tested against
the field box's exact recording-tab inventory). Session passes are exempt,
and a box with no real microphone is left alone.
Also folded in: the mid-idle drift re-assert is gone with the gating, so a
mic-pump reopen no longer stomps a recording device the operator chose
themselves.
CI gate C (unsafe hygiene) failed on the previous commit: `library/art.rs`
went from 4 process-global-API mentions to 10, because the two new tests each
hand-rolled a set/restore pair the way the two existing ones already did.
The gate says fix the call sites rather than raise the baseline, and it is
right to here — the hand-rolled pattern was also leaking. Each test set
`PUNKTFUNK_LIBRARY_ART_ROOTS` and unset it at the end, so any assertion
firing between the two halves left the override installed for every later
test in the process, turning one real failure into a cascade.
`ArtRootsEnv` now holds the lock and the saved values and restores them on
drop, which runs on an unwind too. `write_env` is the single write point, so
the gate has exactly one pair of call sites to judge: the count drops to 2,
below the old baseline of 4, and stays flat however many tests are added.
Baseline lowered to 2 in the same commit, as the ratchet's policy requires.
⚠ The gate greps for the API names in COMMENTS as well as code, so the SAFETY
comments here deliberately describe the calls instead of naming them.
Re-verified after the refactor: .25 493/493 + clippy clean, .133 12/12 art
tests + clippy clean, `check-unsafe-hygiene.sh` clean locally.
A field report: the Steam plugin installed, the grid stayed empty, and the
only clue was one warn per sync — `art.hero: local art must be an image file
… inside an allowed art root`.
Two defects, both here.
The art roots defaulted to the users base (`C:\Users`, from `%PUBLIC%`'s
parent). That covers the launchers that install per-user, but not Steam,
which installs to `C:\Program Files (x86)\Steam` and keeps both the things
the plugin publishes there — `appcache\librarycache\<appid>\<hash>\` and each
account's `userdata\<id>\config\grid\`. So every cover was out of root. It is
a v0.28.0 regression: the built-in scanner the plugin replaced served covers
through the legacy `steam:` art-proxy branch, which never passed through the
H-2 confinement, so deleting the scanner routed that art through a gate it
had never been measured against. `art_roots()` now also carries every Steam
install it can find, from the three Program Files vars and from HKLM
`Valve\Steam\InstallPath` so a Steam on another drive counts too. POSIX needs
no equivalent — native and Flatpak Steam are both already under `$HOME`.
The confinement is not weakened. It exists to stop the host (SYSTEM) reading
what the plugin lane (LocalService) cannot reach itself; the Steam directory
is readable by LocalService already, so nothing there is reachable *because*
the host is privileged, and the extension, regular-file, magic-byte and
config-dir gates still apply on top. Tested: `config.vdf` is not servable
from an art root, nor is a non-image wearing `.png`.
Second, and the reason this cost a whole library rather than a thumbnail: the
provider reconcile validated art per entry and 400'd the WHOLE payload on the
first bad value. A path mismatch therefore deleted every game from that
store, and the plugin — which only ever sees `HostRequestError` — could not
say which. A reconcile now strips unservable local art and syncs the rest,
logging one aggregated warn with the count, an example path and the env var.
The invariant the 400 held is unchanged: no unservable path is persisted. The
operator's own single-entry writes keep the hard 400, because there the path
was typed by hand and silence would be the wrong answer.
Verified on Linux (.25: 493/493, clippy clean) and Windows (.133: 12/12 art
tests, clippy clean). The new Windows test is hermetic — it repoints
`%ProgramFiles(x86)%` at a synthetic Steam tree rather than asserting over
whatever Steam the box happens to have, since the vacuous version of that
test is what would have let this ship. Confirmed non-vacuous by disabling the
fix: it fails on "the DEFAULT art roots must include it".
Clearing a host's trust store meant clicking the row trash icon once per
device and confirming each time — tedious with a handful of clients, and
easy to leave half-done.
The "Paired devices" card header now carries an "Unpair all" action behind
a single confirmation. It is backed by two new endpoints rather than a loop
over the per-fingerprint deletes:
DELETE /api/v1/clients -> {"unpaired": N}
DELETE /api/v1/native/clients -> {"unpaired": N}
one per pairing plane, because the two planes own separate trust stores
with separate persistence and separate revocation duties. Each empties its
store in ONE persisted write. Doing it as N deletes would rewrite (and
atomically rename) the store once per client, and a failure partway would
leave the operator with a half-emptied store and no way to tell which half.
They are collection deletes, so they carry the single delete's revocation
guarantees across the whole set: a live session owned by any removed
certificate is ended, and on the Moonlight side the ENet control port
closes, because no pairing is left to hold it open.
200 with a count rather than the single delete's 204/404: "unpair
everything" is idempotent, an already-empty store satisfies it, and the
count still tells the operator whether that meant three devices or none.
Both gates match on (method, path), so the roster's plugin-readable GET
does not carry over to emptying it — both new routes are admin-token only,
like every other pairing-administration route, with explicit rows in the
route-classification table.
The console calls only the planes that actually have a row: the native
endpoint answers 503 on a host built without that plane, which would
otherwise report a failure for devices that were never there.
Field report: no audio at all on an NVIDIA Shield Android TV, stereo, same
host and settings that play fine on an Apple TV. Video unaffected. Turning
off low-latency mode — which gates the forced HDMI mode switch and the
usage=Game tagging, the two things that toggle controls — changed nothing.
This client opens AAudio directly, where the Apple one goes through
AVAudioEngine and gets route-change handling for free; that is why this was
Android-only. Opening AAudio is a negotiation with a vendor HAL and this
plane treated it as a formality: one Exclusive attempt, one Shared retry,
everything after the open taken on trust. Three separate failures all came
out as "the app has no sound" behind a perfectly ordinary log line:
- a configuration that opens but routes nowhere — nothing ever checked
that the device pulled a single sample, so the decode thread fed Opus
into a dead stream indefinitely;
- request_start failing — we gave up on the spot rather than trying
anything else, so one unhappy config disabled audio for the session;
- a disconnect — by AAudio's contract the stream is then DEAD and the
only recovery is close + open a new one, but the error callback logged
a warning and did nothing. On a TV that is not rare: this client drives
an HDMI mode switch on the video plane, and the platform's own
match-content-frame-rate setting drives more.
The open now walks a ladder, every rung must prove the device is pulling
before it is accepted, and a supervisor owns the plane for the session and
reopens it when the device goes away — with bounded retries across the
settling time of a route change, so a reopen landing mid-switch cannot
permanently disable audio. Granted rate/channels/format are checked rather
than assumed: the realtime callback casts AAudio's buffer to f32 and writes
num_frames * channels of them, so a HAL that disagreed was an out-of-bounds
write on the audio thread, not just a mistuning.
TV boxes now start at Shared. Exclusive is MMAP, the lowest-latency path
AAudio has and the one rung whose routing cannot be verified from inside
the process; the latency it buys was never banked, since the ring depths
are unchanged from the Shared-only era (AAUDIO still primes at 25 ms). On a
mains-powered HDMI box that trade is not worth betting the audio plane on.
Phones keep Exclusive first. If no rung proves itself the first one that
opened and started is used anyway — a watchdog must never be able to turn
working audio into no audio.
nativeStartAudio takes isTv (FEATURE_LEANBACK, the source the video plane
already used) because ro.build.characteristics is not answered by every TV.
debug.punktfunk.audio_sharing / audio_perf / audio_reopen bisect all of it
with setprop, for the device that reports silence and cannot be handed a
custom build. A stream that stops taking samples after it started now says
so at error level instead of looking exactly like an app with no sound.
Not verified on a Shield — no such device here.
Patch 0009, reported, written and proven live by luxus (punktfunk-overlay#9): when the capture
consumer leaves, stream_handle_remove_buffer — and the stale-push path in dispatch_nudge —
destroyed idle buffers on the PipeWire thread. Dropping the last CVulkanTexture reference there
calls into the Vulkan driver (vkDestroyImage / FreeMemory / dmabuf fds) while steamcompmgr can
still be inside vulkan_screenshot on another buffer of the same 4-buffer pool; on NVIDIA the race
lands as a SIGSEGV in CVulkanCmdBuffer::insertBarrier. The timing is what made it selectively
lethal: it fires at stream END — exactly the window where the host keeps the headless display
lingering for a reconnect. So the kept display was already dead (journal: linger line → coredump →
"kept display was dead — recreating") and the "resumed" session was a fresh compositor with the
game lost.
The fix queues the corpses (bury_buffer, mutex-guarded) and steamcompmgr reaps them on every
vblank, including while the stream is only paused — the linger state itself. Field-proven on the
reporter's NVIDIA host: 4 coredumps in one evening of BG3 at 4K60 HDR with --pipewire-composite-
cursor (the heaviest paint path we ship), zero after; disconnect/reconnect confirmed live to reuse
the lingered session (2026-08-13). Three of the four stacks are this race; the fourth
(~CVulkanDevice during exit) is patch 0006's already-fixed static-destruction bug — do not
re-diagnose it as part of this.
Ours differs from the overlay's original only by the meson.build banner hunk: +pfhdr6 → +pfhdr7,
PKGBUILD 3.16.25.pfhdr7-1. No new capability — same rule as pfhdr5/6: "reconnect lost my game"
triage has to read a box's exposure off its banner, and every probe is >=. Known residual,
deliberately untouched: add_buffer's error path still deletes on the PW thread. By the later
`goto error`s a texture may be attached, so the race is reachable there in theory — but only when
an add FAILS mid-renegotiation, which no field coredump shows; the patch stays byte-identical with
what was proven on-glass.
Verified: the full 0001..0009 series applies onto the bare 5fb8dce4 pin with plain `git am` (the
build script's own invocation, no -3, no fuzz) and with `git am -3`; the fc44 CI image
(punktfunk-fedora44-rpm) builds the result with rpm.yml's exact dep recipe to a binary whose
banner reads `3.16.25-20-g40fe8b5+pfhdr7 (gcc 16.1.1)`. After 0009, destroy_buffer has exactly
two callers left —
pipewire_reap_dead_buffers (steamcompmgr vblank) and pipewire_destroy_buffer (steamcompmgr's
copy-completion path) — both on the compositor thread. Nix, deb, sysext and rpm all glob
patches/*.patch and read the level off the banner, so no other packaging file moves.
A 2026-08-13 field report read the OSD's stage line as a breakdown of e2e and
asked why the parts did not add up: `host 5.4 · net 0.3 · decode 6.6 ·
display 1.4` against `e2e 8.1/9.1`. Fair question, and the numbers are all
individually true. They add up without `decode`: 5.4 + 0.3 + 1.4 ≈ 8.1.
The stages ARE a per-frame partition of e2e — pts →(host+net)→ received
→(decode)→ decoded →(display)→ displayed — and that holds for as long as the
`decoded` stamp is a COMPLETION stamp. On the synchronous rungs it is. On the
native-Vulkan rung `receive_frame` returns at SUBMISSION (~0.1 ms) and the
stamp shipped to the presenter is taken there, so `display` is measured from
submit and the GPU decode happens INSIDE it. `host+net` and `display` already
tile e2e between them; the `decode` figure, measured received → fence-complete,
re-counts the GPU work `display` contains. Two figures, one overlap, printed
side by side as though they tiled.
So on that rung `decode` leaves the stage line and gets its own, carrying the
two caveats a reader needs before the number means anything: it is ONE sample
per window there, not the p50 every other figure on that line is, and it is
already inside `display` so adding it double-counts. The synchronous rungs are
untouched — `decode` is a real term there and stays inline.
Deliberately NOT changed: the one-sample-per-window design. `pf_client_core::
session` argues it at length — a per-frame fence wait serialises the decode
pipeline (an APU's 19 ms decode capping a 5120×1440 stream at ~51 fps), and M4
already re-examined and rejected polling, which quantises every sample up by a
frame interval (8.3 ms at 120 Hz against decodes of ~0.1-2 ms). That reasoning
still holds; the reporting around it was the defect. Making `decode` a genuine
per-frame term would need a completion stamp off the hot path — a waiter thread
on the timeline, which that comment already names as the remaining option — and
is a bigger change than this one.
Also not answered here: why the sampled frame read 6.6 ms when the sampling
comment expects 0.1-2 ms. It is a tail frame by construction (a frame that took
6.6 ms to decode also took ≥ 6.6 ms to display, against a 1.4 ms display p50),
but whether the first frame of a window is SYSTEMATICALLY a tail frame needs
instrumenting rather than guessing.
Verified in the linux/amd64 container: pf-presenter 47/47 (incl. the new case,
which pins both shapes and the timed-out-window zero), pf-client-core 188/188,
`clippy --all-targets -D warnings` clean on both, fmt clean. The pf-client-core
leg was proven non-vacuous with a planted compile_error! first.
The previous commit built the layer and taught the host to use it, but only the
Arch PKGBUILD carried the files, so every other channel still landed on the
no-game-HDR fallback. This finishes the job.
The packaging scripts now take `--stage`, the DESTDIR the gamescope build script
wrote, instead of a path to one binary. That is the part worth keeping: the next
file this package needs will not require a new flag in four scripts and two
workflows. CI caches the whole staged tree for the same reason. The gs-cache key
already hashes packaging/gamescope/**, which this commit changes, so stale caches
in the old single-file shape cannot be restored into the new layout.
Channels, all of them:
rpm spec gains Source1/Source2 and %files entries
deb build-gamescope-deb.sh copies the layer into the package root
Arch PKGBUILD (previous commit); the sysext extracts the whole usr tree
sysext bazzite takes --gamescope-stage; arch asserts the layer arrived
nix the derivation keeps, renames and rewrites the layer rather than
deleting it with everything else
A missing layer is fatal in every one of them, not best-effort. A package that
carries the compositor without it looks completely healthy and then silently
denies every game an HDR10 swapchain -- the exact failure this whole change
exists to end, so it must not be possible to ship it again by accident.
Two things needed care:
The layer manifest carries an ABSOLUTE library_path baked in at build time, so
every channel has to install the .so at exactly that path. That means literal
/usr/lib/punktfunk, not %{_libdir} (which is /usr/lib64 on Fedora) and not a
Debian multiarch triplet. Nothing links the .so by soname -- the loader dlopens
it by that path -- so multilib has no claim here. The rpm and nix install checks
now read the path back out of the manifest and fail if it names a file the
package does not install, because a manifest pointing at nothing is the silent
shape of this bug.
NixOS has no /usr, so the layer lives inside the gamescope derivation and the
host's path is overridable via PUNKTFUNK_GAMESCOPE_WSI_LAYER_DIR, which the
module sets -- the same posture as PUNKTFUNK_GAMESCOPE_BIN, and documented.
The manifest rewrite moved out of a heredoc into
packaging/gamescope/rewrite-wsi-layer-manifest.py because the FHS builds and the
Nix store both need it and must rename the layer identically; two copies would
drift into a host looking for a name only one of them produces.
Verified: 214 pf-vdisplay tests pass in a linux container, clippy -D warnings and
rustfmt clean, bash -n on all five changed shell scripts, both workflow YAMLs
parse, and the rewrite script was run against a synthetic FROG manifest to
confirm it renames/repoints/regates while preserving the `functions` block --
which is the field that decides whether the layer loads at all.
NOT verified: no nix on this machine, so gamescope.nix, flake.nix and the module
are unevaluated; no gamescope build, no package build of any kind, and no game
has taken an HDR swapchain on glass.
The card led the About tab with the app icon, and on tvOS that icon is a 400x240
rectangle meeting a layout built for square art. Three passes at framing it —
aspect-correct frame, then dropping the zero-radius clip that was cropping it,
then a max frame so it could shrink instead of overflow — and it was still cut
off on real hardware.
So the card goes. A version string answers the only question anyone opens About
to ask, it has no aspect ratio to get wrong, and it belongs under the rows rather
than over them: quiet and centred, reading as a footer instead of a row you
failed to press. `Row.Kind.footer` draws it.
swift build clean on macOS, arm64-apple-ios17.0 and arm64-apple-tvos17.0;
on glass on an Apple TV as 0.29.0 (100004).
A game nested under gamescope gets an HDR10 swapchain from the FROG WSI layer and
from nothing else -- gamescope advertises no runtime colour-management protocol a
Mesa/NVIDIA WSI could negotiate through. That layer talks `gamescope_swapchain`
to the compositor, and when the two disagree the compositor rejects the client's
swapchain_feedback and every Vulkan client dies on a black screen with sound and
input and no error anywhere.
We ship our own compositor and did NOT ship a layer, on the recorded grounds that
the layer is "version-independent of the compositor binary". It is not, and
wsi_layer_matches_our_gamescope() exists because it is not. So the host was left
guessing from version triples, and that guess is wrong in both directions: a
distro at the same upstream tag that patched the protocol compares EQUAL and
keeps a layer that will kill every game, while a distro at a different tag with a
byte-identical protocol compares unequal and loses HDR for nothing. Since we pin
a rev, the second case is the normal one -- on essentially every box with a
distro gamescope, the layer was disabled and no game could render HDR.
Ship the layer instead. It is built from the same tree at the same rev as the
compositor, so the two cannot drift, and the guess stops being load-bearing. It
is installed under our own name (VK_LAYER_PUNKTFUNK_gamescope_wsi) at our own
path with our own enable/disable variables, so it coexists with the distro's
rather than colliding -- the Vulkan loader keys implicit layers on that name --
and the host switches the two independently in one session.
WsiPlan makes the three states explicit and resolves them once per launch, since
the fallback spawns `--version` probes:
Ours our layer is installed: enable it, force the distro's off
DistroKept no layer of ours, distro's looks compatible: touch nothing
DistroDisabled no layer of ours, distro's untrusted: today's behaviour
That last arm is the fail-safe. A host newer than its gamescope package behaves
exactly as it does today rather than enabling a layer that is not there, so this
can roll out one packaging surface at a time without a flag day.
Only the Arch PKGBUILD carries the new files so far. The rpm path takes a
CI-cached binary rather than the build script's stage dir, so it needs the cache,
build-gamescope-rpm.sh and the spec moved together; the deb, both sysexts and
gamescope.nix need the same two files added. Until each lands, those boxes take
the DistroDisabled arm and are no worse off than before.
Verified: 214 pf-vdisplay tests pass in a linux container (including a new one
pinning that the Ours arm enables ours AND forces the distro's off together --
either half alone is a bug), clippy -D warnings and rustfmt clean, both shell
files pass bash -n, and the manifest rewrite was run against a synthetic FROG
manifest to confirm it renames/repoints/regates while preserving the `functions`
block. NOT verified: an actual gamescope build, any package build, or a game
taking an HDR swapchain on glass.
Field 2026-08-13, Apple TV vs Bazzite VM host, two sessions minutes apart on
the same wire: hostnet_p50 read 17-21 ms, then a physically impossible
4.4 ms (host-side encode alone is ~4.7). Root causes, each its own defect:
- The client consumed the CONNECT-TIME skew offset and froze it: cached in
a Stage2Pipeline field, in a StreamPump let, and in a ContentView closure
CAPTURE LIST feeding the hostnet meter and the host/network splitter.
The core keeps a live estimate (punktfunk_connection_clock_offset_now_ns,
ABI v10, re-synced every 60 s + on suspected wall-clock steps) and its
own doc says the connect-time value 'silently corrupts every
capture-clock comparison' after an NTP step — a VM host steps. Now
PunktfunkConnection.clockOffsetNs IS the live read (an atomic load
behind the FFI) and every consumer reads it at use: per record, per AU,
per enqueue. The Swift audio plane's AvSync observation gets the live
value through the same property.
- LatencyMeter's impossible-sample guard (≤ 0 after offset correction)
dropped samples SILENTLY, so a wrong offset didn't invalidate a window —
it trimmed the impossible half of the shifted distribution and presented
the surviving tail as a plausible small number ('e2e 0-3 ms p50 /
23 ms p95' on a session whose true hostnet was ~18 ms; also the
historical '0 ms network / 0 ms e2e' readings). The refusals are now
counted and drained separately from Stats — deliberately, because a
fully-poisoned window drains to nil and a count inside Stats would
vanish with it. The HUD shows an orange 'clock offset suspect' line and
the stats line grew skew_trim=N; nonzero means disregard e2e/hostnet.
- Every invalid-field fallback in the 1 Hz stats line was a bare -1: in
the variadic CVarArg context the ternary does NOT unify to Double, the
literal goes in as Int, and %f reads Int64(-1)'s all-ones bit pattern —
which is a quiet NaN. Latent since the line existed; stage-1 (the first
rung with invalid fields while frames flow) printed nan for every one.
All fallbacks are now typed -1.0 / Double()-wrapped.
The 2026-08-13 field ladder closed 'the tvOS two-refresh present floor is
immovable' on the strength of a 'link granted latency 1.00 frames' HUD line.
But that line reads back preferredFrameLatency — a plain read-write float
(CAMetalDisplayLink.h carries no doc contract) that echoes whatever we
stored. A readback is not a grant; the measured vend lead (1.95 refresh
periods) was the only truth-teller, and two levers were never actually
pulled. This commit also carries the ladder instrumentation that run used:
the 1 Hz stats mirror to stdout (the only log channel that exists on an
Apple TV), the PresentLinkInfo HUD plumbing, the stage-4 drawable-pool
clamp to 2, and the tvOS fixed-rate range pin.
- PUNKTFUNK_FRAME_LATENCY makes the ask a lever (float 0...4, default 1) so
an on-device ladder can prove whether the property does ANYTHING on tvOS:
ask=2 growing the vend lead to ~3 means it works and the floor is ~ask+1;
a lead pinned at ~2 means it is inert and the compositor regime is fixed.
ask=0.5 is the in-regime win probe (the property is a float for a reason).
Ask + readback go to the HUD line and the stats line (link_ask/
link_readback) so the ladder reads HUD-off over stdout.
- PUNKTFUNK_PRESENTER=stage1 now resolves on Release builds (env only; the
persisted picker stays DEBUG-gated — an env var is never a leftover, it
takes a devicectl/Xcode launch to exist). Stage-1 presents on the hardware
video plane (AVSampleBufferDisplayLayer + DisplayImmediately) instead of
through the GPU compositor — the only rung that can dodge the two-refresh
regime — and the field A/B silently ran stage-4 because the gate keyed on
build config. The pump gains stage-1's only latency instrument:
capture→enqueue into the e2e meter (offset-corrected, displayed frames
only), so cross-rung runs can pin any felt difference on the present tail.
`cornerRadius: 0` reads as "no rounding", but a RoundedRectangle clip is not a
no-op at zero — it still clips to the layout frame, so any art whose aspect ratio
isn't the frame's loses its ends. The TV's 400x240 icon did exactly that as soon
as there was a real icon to draw instead of the square monogram. The mask now
applies only where it is wanted: iOS, whose icon ships unmasked because the
springboard rounds it at draw time.
The frame goes from fixed to MAX for the same failure one step further out: at a
fixed width the image cannot shrink when its row is tight, so it overflows and is
cropped by whatever is above it. `.fit` inside a max frame gives the whole icon
back, just smaller. And the icon takes layout priority in the identity card — the
tagline beside it is happy to wrap, and a 5:3 icon is what suffers first if the
text is given the width it asks for.
swift build clean on macOS, arm64-apple-ios17.0 and arm64-apple-tvos17.0.
Both found on glass on an Apple TV.
The card was laid out against the SCREEN while everything under it is laid out
against a centred column of `rowMaxWidth` — 920pt against a 1920-wide TV. So it
began a few hundred points to the left of every row it introduced and read as a
separate banner rather than the head of the list. It now takes the same column
and the same inner inset as a row's contents, so the icon sits directly above
the row icons.
And it was drawing the "P" monogram, never the app's mark. That fallback exists
because tvOS ships its icon as a parallax image STACK (Back/Circle1/Circle2/
Front) with no single image to load, so `AppIconView.bundleIcon` returned nil
there and always had. `AboutAppIcon` is those four layers flattened into one
asset, generated from the SAME art the stack uses so the two cannot drift into
being subtly different icons. A TV icon is a 400x240 rectangle rather than a
squircle, so `side` means HEIGHT on tvOS and the width follows the real 5:3 art
— framed square it would have sat in a box two thirds empty.
Verified the asset actually survives compilation (`assetutil` finds AboutAppIcon
in the built Assets.car at both scales) — a missing imageset would silently fall
back to the monogram again, which is exactly the bug being fixed.
swift build clean on macOS, arm64-apple-ios17.0 and arm64-apple-tvos17.0.
applyDisplayCriteriaIfNeeded builds a synthetic format description hardcoding
BT.2020 primaries, ST.2084 PQ and the BT.2020 matrix, then hands it to
AVDisplayManager to pick a display mode. Its guard checked only that no criteria
had been set yet and that the user's HDR setting was on -- never that the stream
itself was HDR. Since that setting defaults to true, an ordinary SDR session
drove an HDR-capable TV into PQ output.
That is a standard way to raise the black floor: the Apple TV switches HDMI to
limited-range levels in its HDR modes, and a set configured for full range then
renders code 16 as grey rather than black.
Now gated on connection.isHDR as well. Layout re-runs this, so a session that
flips to HDR mid-stream still picks the mode up on the next pass.
This was NOT the cause of the 2026-08-13 grey-blacks report -- that one had the
client's HDR setting off, so this path never ran (see the SDR layer tagging in
the previous commit for the mechanism that did apply). It is a real bug on its
own, found while investigating it.
Verified: full tvOS compile clean against the AppleTVOS SDK. Not yet verified on
glass.
configure(hdr:) guards on hdr != hdrActive, and hdrActive starts false. A
session that is SDR from its first frame therefore matched the initial state and
fell straight through the guard, so configureColor never ran even once and the
layer kept make()'s bare configuration -- which never assigns a colour space.
An untagged CAMetalLayer gets no colour matching: the BT.709-encoded stream is
drawn in the display's native space. That is mild oversaturation on a P3 Mac or
iPad, and on a tvOS display composited for HDR it also lifts the black floor.
It matches a field report of greys where blacks should be, which arrived with
the client's own HDR switch already OFF -- so nothing else in the pipeline had
tagged those pixels either.
It also meant PUNKTFUNK_SDR_COLORSPACE was dead code on exactly the sessions it
existed to fix: an operator A/B-ing it in the field would have seen no change at
all, because the assignment it feeds was never executed.
So: configureColor now runs once regardless (same-state calls after the first
are still no-ops), and tagging is the default rather than opt-in, since drawing
a BT.709 stream in the panel's native space is not a rendering anyone asked for.
PUNKTFUNK_SDR_COLORSPACE=none restores the untagged look as the A/B lever.
The tvOS HDR tone-map branch gets the same tag -- pf_frag_hdr_tv outputs BT.709,
so it is an SDR layer by the time it is presented.
Verified: full tvOS compile of every PunktfunkKit source clean against the
AppleTVOS SDK. (The build's link step fails on three ABI symbols missing from
the checked-in PunktfunkCore.xcframework, which predates them -- pre-existing,
main fails identically, unrelated to these files.) Not yet verified on glass.
Field report 2026-08-13, Bazzite host in gaming mode to an iPad: Steam's Big
Picture UI looked glaring and over-saturated while HDR game content looked
washed out, both on the same stream.
Those are one error. gamescope maps everything that is not an HDR game -- the
desktop, the Steam overlay, an SDR title -- into the session's PQ container at
--hdr-sdr-content-nits, and we only passed that flag when an operator had set
PUNKTFUNK_GAMESCOPE_SDR_NITS. Unset, gamescope used its own default of 400,
while every first-party client anchors diffuse white at 203 (BT.2408 reference
white; the Apple presenter hands exactly that to CAEDRMetadata.hdr10's
opticalOutputScale). The two ends were nearly a stop apart, so the UI landed
above SDR white and the client's tone-mapper worked from a reference point the
host had never used, flattening the content around it.
The flag is now always passed, defaulting to 203. The knob still overrides it
for anyone who wants a brighter or dimmer desktop.
Separately, and visible in the same log: the two HDR decisions in a gamescope
session are made independently. hdr_args() never consults
wsi_layer_matches_our_gamescope(), so when the WSI-layer version check fires --
which it does on essentially every Bazzite box, since we pin our own gamescope
rev and the check compares version triples -- the session launches advertising
HDR while having made an HDR10 swapchain unreachable for every game in it. That
layer is the only route to one, so a title told to render HDR renders it into an
SDR swapchain and looks washed out, with nothing anywhere saying why. It now
warns. The behaviour of the check itself is deliberately left alone: re-enabling
a genuinely mismatched layer black-screens every Vulkan client, which is worse
than losing HDR, and that trade needs a real box to retest.
Verified: scripts/xcheck.sh linux clippy clean on pf-vdisplay (-D warnings),
rustfmt clean. Not yet verified on glass.
Reachable, but wrong: About sat at the bottom of the Interface tab, under the
palette and the overlay position — a page about the app filed among the settings
that change how it looks, found only by scrolling past them.
It is a tab now, trailing the strip beside Profiles. Both are built from
something other than the settings store, and About is where the strip ends
because it is the one section that changes nothing.
The standalone GamepadAboutView goes away with it. Its content is the tab's rows,
its two reading surfaces (shortcuts, licences) are in-place layers like the pin
picker, and the identity card — icon, name, version, tagline — rides in the
header under the tab strip. In the header rather than as a first row so the list
holds no focus stop that does nothing when pressed; laid out sideways rather than
centred like the touch page, because this header already carries a title and a
strip and a centred icon-name-version-tagline stack would leave no room for the
rows under it.
Row grows a `kind`, so the About tab can draw a heading and a block of prose
without every other tab's rows pretending to be one.
swift build clean on macOS, arm64-apple-ios17.0 and arm64-apple-tvos17.0;
288 tests pass.
The gamepad UI could add a host and connect to one, and that was all: a renamed
machine or a fat-fingered address stayed wrong forever, because the only surface
that could edit or remove one was the touch UI. The desktop console and the
Android console have both had a host menu on UP for a while — this is the Apple
port of it, so the three consoles are learned once.
UP on a saved tile opens Wake / Copy link / Edit… / Forget pairing / Remove.
Wiring UP takes the whole vertical axis away from scrolling (down goes inert): a
horizontal carousel has no vertical travel to spend, and one meaning per
direction is what makes the gesture learnable. Remove arms on the first press
and only fires on the second, and disarms if focus wanders off the row — the
touch grid gets a system confirmation dialog, and a thumbstick from across a
room is a good reason to be at least as strict. A pinned profile card offers
only Unpin: it is a shortcut, not a second host.
Edit reuses GamepadAddHostView, seeded from the record and writing a COPY back
through HostStore.update, so the fingerprint, MACs, pins and binding the form
never shows survive a rename. It REPLACES the menu rather than stacking on it,
which keeps the shell's "depth <= 1 by construction" true.
This also retires the start-of-stream shortcut banner. Telling someone the
controls for six seconds, over the stream they have just connected to, answers
the question at the one moment nobody is asking it — and it put a composited
overlay above the stream to do it. The words are now ShortcutsCatalog, rendered
by an About page on BOTH surfaces: the new gamepad one (icon, version, licenses,
shortcuts) and the touch AboutView. The touch half is not a bonus — the banner
fired in touch mode on a Mac too, so deleting it without that would have cost
those users the only place the keys were written down.
Verified: swift build clean on macOS, arm64-apple-ios17.0 and arm64-apple-tvos17.0
(the iOS pass is what typechecks the shell-layer code, which is #if os(iOS));
288 tests pass. NOT verified on glass — screen capture is unavailable in this
environment, so the new screens have been compiled and reasoned about but not
seen.
The host's recovery-cadence detector warns that "client keyframe recoveries are
METRONOMIC — a periodic host/display disturbance (display-topology churn,
display-poller software, virtual-display timing) is the likely cause, not
random network loss". In a 2026-08-13 field log it fired at period_s=2.0 and
sent the investigation at three innocent host subsystems.
2.0 s is `punktfunk_core::client::FLUSH_COOLDOWN`. The client's receive-backlog
guard sheds a standing queue with a flush plus a keyframe request and is
rate-limited to one per cooldown, so a client that cannot sustain the stream
asks for a keyframe at EXACTLY that spacing for as long as it stays behind —
the constant's own doc says it "degrades into a periodic skip + a logged
warning", which is the behaviour the detector then read as physical. Perfect
periodicity argues FOR a fixed software cooldown, not against it.
In the field case the host was blameless and the chain ran the other way: the
client refused the negotiated codec on its Vulkan rung, demoted to a slower
decode path, could not hold 4K120 there, and built the standing queue. Three
layers between the symptom the host reported and the cause.
So the detector now routes: a period on the client's cooldown names the client
and says where to look in ITS log (`receive backlog stopped draining`, and a
demoted decode rung); anything else keeps the display-disturbance wording it
had. The comparison reads FLUSH_COOLDOWN itself — now `pub` for exactly this,
documented as such — rather than a copy of the number, so the two cannot drift.
±10 % absorbs scheduling jitter and the request's trip without being wide
enough to swallow the disturbance cadences the other branch exists to report.
Verified: 18/18 native::stream::tests on linux/amd64 (container), including the
new case, which derives its inputs from FLUSH_COOLDOWN so it survives a retune;
clippy --all-targets -D warnings clean; cargo check clean on the Windows CI
runner.
A 2026-08-13 field host log carried ten "the audio encode thread could not keep
up — captured audio was DROPPED" warnings, the worst reading
dropped_chunks=11251. That reads like catastrophic audio loss. It was not: not
one sample anybody wanted was lost.
PipeWire negotiated a 128-frame quantum, so the plane produces 48000/128 = 375
chunks/s and a 30 s stats window holds exactly 11250 — those windows were a
100 % drop rate, at peak_db=-120.0 (digital silence). Every one of the ten
straddled a session boundary, and across all of them dropped_chunks/375 matches
the seconds with NO live session in that window to within a fraction of a
second (3890/375 = 10.4 s against a 10.5 s gap; 3616/375 = 9.6 s against 9.8 s).
The capturer is host-lifetime: the native and gamestream planes PARK it between
sessions (`AudioCapturer::idle`) rather than dropping it, but the consumer is
the per-session encode thread. The hand-off channel is a bounded
sync_channel(64), so ~170 ms after a session ends it is full and every
try_send fails for as long as the host sits idle — counted as the encode thread
falling behind, and reported with a sentence about a stream that does not
exist. It is the worst kind of false alarm: it names a real failure mode, in a
subsystem with real open audio work, at a volume that demands attention.
So the drop counter now only counts while a session is actually reading, via an
`active` flag shared with the capture thread and toggled by the same
open/drain/idle/Drop transitions that already own the routing claim. A full
channel under a live consumer still means exactly what it used to.
Both backends: the parking call sites are platform-independent, so the WASAPI
half had the identical defect (it had no `idle` at all, and gains one). Only
the Linux half has field evidence.
Verified: punktfunk-host clippy --all-targets -D warnings clean on
linux/amd64 (container) and cargo check clean on the Windows CI runner.
A screen that applies `gamepadPaletteInk()` to its own body sits ABOVE its own copy of the
environment: the modifier covers its descendants, never the body's own `ink.…` references. So
each of these screens read whatever was published above it — and on tvOS, where they are
presented as covers rather than nested in the iOS shell, that is nothing at all. They got the
bare dark default while their CHILD views (the hint bar, the host tiles, the glass) resolved the
real palette, which is why a pale field came out with a white title, white row labels and white
values under correctly-pale glass, with the focus wash still brand violet instead of the
palette's accent. The same trap the `gamepadMetrics` comment already documents, one environment
key over.
Resolve the ink from the stored `ui_palette` instead of the environment in the six screens that
publish it, and in `GamepadScreenBackground` — mounted as their `.background { }`, so it was
reading the parent's ink too and bleaching a pale field's scrim toward white.
Three more of the same family, all tvOS-only:
- the pairing cover drew the system's dark chrome straight over the launcher showing through
it (a tvOS cover has no background of its own): the PIN prompt was white on the bright
aurora. It gets the console field and the palette now, in the launcher's branch only — the
touch route to the same sheet still belongs to the system background.
- the library cover's navigation title is drawn by the NavigationStack, which wraps LibraryView
from outside its own ink, so the shelf's name stayed white over content that had already gone
dark. Fixed on tvOS and on the macOS sheet (gated there — that sheet is both modes').
- the library's loading / error / empty states mounted no backdrop at all; only the coverflow
did. They now take the same field, so the spinner no longer sits on the launcher's own
aurora with the host tiles still visible behind it.
And a contrast bug the same screens made visible: a saved host's badge glyph took `fg`, which is
chosen against the FIELD, while the badge it sits on IS the accent. The two disagree at both ends
of the set — a pale palette put near-black on a deep accent, Graphite (accent luma 0.80) put
white on light grey. It takes `onAccent` now, like the selected settings tab.
Verified on the tvOS 26.5 simulator across Mint, Sunset, Violet and Graphite: launcher, settings,
add-host, pairing and the library's loading state. `swift test` 288 passed / 6 skipped; iOS and
tvOS both build.
The skia-safe 0.87 -> 0.99 move swapped `BackendContext::new` for
`new_builder(..., None)` and recorded the `None` as "byte-for-byte what the
(now removed) `BackendContext::new` did". That is true of the VALUE and false
of the BEHAVIOUR. `None` leaves Skia's `fMaxAPIVersion` at its `0` sentinel,
and the newer Skia acts on that sentinel by falling back to
`vkEnumerateInstanceVersion()` -- the LOADER's ceiling, not ours. The presenter
declares 1.3; a current Mesa answers 1.4 (1.4.321 on SteamOS 3.7, host and
inside the flatpak sandbox alike). Skia then validates a 1.4 function table
against an instance that only ever promised 1.3, `vkGetDeviceProcAddr` returns
null for the entry points in between, validation fails, and `make_vulkan` hands
back `None`. At 0.87 the same sentinel was inert, because that Skia knew nothing
of Vulkan 1.4 -- which is why this surfaced the moment 0.28.0 landed.
`run.rs` makes an overlay that cannot init fatal for `--browse`, so on the Steam
Deck the console home died on update: the Decky panel's button and the
gamepad-UI library shortcut both launch `PF_BROWSE=1`, and neither would open.
In a stream the same failure only warns, so those sessions quietly lost their
stats OSD and capture HUD instead. `pf-presenter`'s `vk` module is
`cfg(any(linux, windows))`, so this was never Deck-specific.
The presenter now publishes the version an overlay may size itself to as
`SharedDevice::api_version`, and `SkiaOverlay::init` passes it instead of `None`.
It is `min(what we declared, what the loader reports)`: taking the loader's
number alone is this bug, and taking ours alone would break the mirror case,
where a 1.1+ loader accepts our 1.3 `apiVersion` as intent even when it cannot
deliver 1.3. Three unit tests pin both directions and the no-answer case. The
three `API_VERSION_1_3` spellings in setup.rs now read the one constant, so the
number the overlay is told can no longer drift from the number we asked for.
Measured on the Deck (RADV VANGOGH, loader 1.4.321) with a standalone repro
against the shipped crate -- the client build is not needed to see it:
vkEnumerateInstanceVersion() -> 1.4.321 ; VkApplicationInfo -> 1.3.0
max_api_version = None => DirectContext NULL
max_api_version = Some(1.3) => DirectContext OK
Verified: cargo fmt --all --check; and in the pf-lxcheck2 x86_64 container,
cargo build + cargo clippy --all-targets -- -D warnings for pf-console-ui and
pf-presenter, plus cargo test -p pf-presenter (46 passed). Note that
`cargo check -p pf-console-ui` on macOS is vacuous -- every mod in that crate is
cfg(linux|windows), so it compiles nothing there.
The branch is based on the commit v0.28.0 points at, so writing the NixOS
runner fix into that section would have credited a released version with
a change it does not contain. Moved to a fresh `v0.28.1 — in development`
section, matching how the v0.28.0 cycle was kept (a `— in development`
heading the release commit renames).
Two tests hardcoded absolute paths into /bin, which on NixOS holds only
`sh` — so `cargo test` failed there for reasons that had nothing to do
with the code under test.
`gamelease` only needs a process that exits quickly and successfully, so
the bare name resolved through PATH is exactly right.
`pyrowave_remote` cannot use a bare name: `spawn_link` pins the binary
with `PinnedExe::open`, so a name that PATH would have resolved fails the
OPEN instead — which takes the spawn-failure rung rather than the
handshake rung the test exists to exercise. It resolves a real path off
PATH first, keeping the test on the rung it names.
On NixOS every plugin PACKAGE op failed with "the plugin runner isn't
installed" on a box where the runner was installed, enabled and running.
`runner_command()` checked FHS locations exclusively — /usr/bin, the
/usr/lib + /usr/share pair behind it, and the ~/.local mirror the SteamOS
installer lays down. Nix ships punktfunk-scripting as a derivation of its
OWN, so its wrapper is neither beside the host binary nor anywhere under
/usr, and no rung could ever match. Service ops go through systemd and
were unaffected, which is what made it read as arbitrary: `plugins
status` said running/enabled while `plugins add` said not installed.
Resolution now matches punktfunk-encode-worker's: PUNKTFUNK_SCRIPTING ->
beside the host binary -> PATH -> /usr -> ~/.local. PATH is the rung Nix
lands on. The /usr rungs stay AFTER it rather than being dropped, because
a systemd unit's PATH need not include /usr/bin. As with the encode
worker the env override is deliberately not existence-checked — a named
path that is wrong should fail naming itself, not fall through to some
other runner. Lifted into a pure injected function so the whole table is
testable, which is also how the regression is pinned: removing the PATH
rung fails the NixOS row specifically.
Second half, and the reason the Rust change alone would not have fixed
the console: the NixOS module now puts the runner on the HOST UNIT's
`path`. The console installs plugins from inside the host service, whose
PATH is exactly that unit list — `environment.systemPackages` only ever
covered an operator's interactive shell. Without it the CLI would have
been fixed and the console would not. module-check.nix gains both the
positive and the negative assertion, so CI's `nix flake check --no-build`
holds the property.
The error text named only apt and SteamOS; it now names NixOS and the
override. The ~/.local/bin symlink workaround is no longer needed.
A 2026-08-13 field report from the same RTX 5060 client as a02014ec: every AV1
session demoted to D3D11VA with "outside device caps: stream level
(seq_level_idx 31) above the device's maxLevel (AV1 Std level 23)" — 4K120,
NVIDIA, the hardware decoding the stream trivially on the D3D11VA rung it fell
through to. a02014ec fixed the H.264/H.265 half of exactly this and left AV1
alone on the premise that "no over-declaration has been seen in the field";
the reporter's own log from that same day already showed otherwise.
seq_level_idx is a 5-bit field. Annex A defines 0…23 (levels 2.0…7.3),
reserves 24…30, and makes 31 the "maximum parameters" level — the spec's own
way of saying the bitstream is NOT constrained to a level. StdVideoAV1Level
stops at 7.3 = 23, so 31 has no Std code point and the index-coded comparison
that holds across 0…23 says nothing here: 31 > 23 is true even of a device
that decodes everything AV1 can name, which is what makes it useless as a
capability test. We write no AV1 level on any host encode path, so whichever
sentinel the vendor's encoder defaults to is what the client must accept.
So the gate warns once and proceeds, like its H.265 sibling. Unlike H.265
there is nothing to clamp: StdVideoAV1SequenceHeader carries no level field,
so the declaration never reaches the driver and cannot be invalid usage. The
stream's real demands stay enforced where they are physical facts — coded
extent and DPB depth, both checked at session build.
Not verified on glass: no RTX 5060 here, and the reporter's box is the only
one that has produced a seq_level_idx 31 stream. The unit test pins the
arithmetic that made the refusal look reasonable.
180 commits since v0.27.0. Cut from origin/main 9c133350.
THE NUMBER: 0.28.0, not 0.27.1. The CHANGELOG's in-development section was
titled "v0.27.1", which the release does not support — 17 `feat(...)` commits,
a packager-visible default flip (GameStream opt-in on every route), the
edition-2024 MSRV rise, and now a genuinely BREAKING host change (the built-in
library scanners are deleted). `scripts/ci/pf-version.sh`'s canary rule agrees
independently: CI already stamps canaries `0.28.<run>`.
TWO DEFECTS FOUND AND FIXED WHILE PREPARING, both pre-existing on main:
1. C ABI_VERSION was stale at 18. Two exported symbols landed since v0.27.0
without a bump — punktfunk_connection_note_frame_index_ex and
punktfunk_reanchor_gate_arm_expecting_drops (72 -> 74 declarations in
include/punktfunk_core.h). The constant's own doc history makes the rule
explicit: v17 and v18 each bumped for adding exactly one symbol. Bumped to 19
with its doc entry; the header is regenerated (cbindgen, CI-gated) and the
C ABI harness passes printing abi_version=19.
2. docs-site/public/openapi.json had drifted to 0.21.0 against api/openapi.json,
missing five endpoints. The copy is a documented manual step that nothing in
CI enforces (CONTRIBUTING.md says so outright). Re-synced — and then it
DRIFTED AGAIN inside this same cycle when the scanner-removal regen updated
api/openapi.json alone, so it is re-synced a second time and the CHANGELOG
now says to treat the copy as part of regenerating, not a follow-up.
⭐ The final docs batch also invalidated a line in this CHANGELOG: the identity
section still said the P-256 key was "generated by ring via rcgen", which contradicted
this same document's "ring is gone from the tree entirely". Corrected to "rcgen on the
workspace's aws-lc-rs backend", matching 92db6651.
api/openapi.json stays stamped 0.27.0: it cannot be regenerated here
(punktfunk-host does not compile on macOS) and does not need to be — the drift
test normalizes info.version, so only the SURFACE is gated, and the surface is
current.
CHANGELOG: retitled to v0.28.0, gained the version table (wire 2 unchanged; C
ABI 18->19; edition 2021->2024 and MSRV 1.82->1.85; driver protocol 6 and
gamepad channel 3 unchanged; plugin-kit 0.4.0->0.4.1), a breaking-changes
section, and ~29 topics the in-development text predated — including the four
that landed last: the scanner->plugin migration, the Mutter rebuild
serialization, the KWin <=60 Hz readback, and the Apple/Android de-prime fuse.
⭐ THE BREAKING ONE, stated plainly in both halves: the six built-in library
scanners are DELETED and the library is assembled entirely by plugins. There is
deliberately no migration — a plugin claims its store and republishes each title
under the same `<store>:<external_id>` id, so entry ids, GameStream app ids, art
caches, Moonlight pins, per-source toggles and per-entry hides all keep working.
The one visible consequence, and the whole upgrade note: a host with NO library
plugins installed has an empty grid.
⭐⭐ The Mutter two-client segfault this release now fixes (a5c9b7b8) is the one
found during THIS release's on-glass validation: chaining two clients through a
kept display killed gnome-shell in meta_monitor_manager_rebuild. It was A/B'd on
.21 against the released 0.27.0 and shown byte-identical there, so it was never
a 0.28.0 regression — and the fix's own commit message cites that A/B.
GATES RUN, all green on this commit (re-run after the rebase onto 86cbbea0):
cargo fmt --all --check clean
cargo metadata --locked OK against the new dependency tree
Cargo.lock versions-only vs origin/main, 36/36 lines
cargo test -p punktfunk-core 210 passed
c_abi harness PASS, abi_version=19 (needs LIBRARY_PATH
for opus on macOS; a link path, not a defect)
docs-site build exit 0 (bun install --frozen-lockfile + build)
Play notes gate 440/500 CHARACTERS, not byte-identical to
any other release (`•` is 3 bytes — count
characters, as the gate does)
notes voice check 0 hits above `## For developers`; TL;DR at
6 bullets (README caps it at six)
ON-GLASS (against the canary of 14425716, code-identical bar ABI_VERSION):
Windows .173 0.28.13309 + Android and iPad, Linux .21 0.28.0-0.00013300 +
iPhone — both PASS. The idle sleep-blocker fix is proven before/after on .173
(`powercfg /requests` SYSTEM: the mic devnode -> "Keine."), and the GameStream
flip is proven at the socket level on .21 (47984/47989/47999 absent by default,
restored by PUNKTFUNK_GAMESTREAM=1). Old-client compat holds: Android 0.26.0
streams against the 0.28.0 host.
⏳ NOT re-validated: the Mutter fix itself. .21 (VM 103) is stopped — it and
home-bazzite-2 (VM 119, currently running) share one passed-through GPU, so
bringing .21 up would stop the other VM. Owed once .21 is free; the repro is
iPhone 2868x1320 -> SIGTERM -> Android 2800x1260, and the marker to confirm the
build carries the fix is the string "mutter: waited out a monitor-topology
rebuild before releasing the lock".
NOT INCLUDED: the 14 unpushed pf-capture/pf-vdisplay sweep commits on the local
main. Never through CI; pushing them is the user's call.
PR #192 (79d755cd) moved rustls, quinn, rcgen and tokio-rustls to aws-lc-rs,
but two comments in identity.rs still credited ring:
* the module doc credited the P-256 key to "ring via rcgen" — rcgen now
selects `aws_lc_rs` (punktfunk-host/Cargo.toml:135, punktfunk-core/Cargo.toml:80);
* the legacy-RSA fallback claimed "rustls/ring can SERVE an existing RSA cert".
The substance still holds under aws-lc-rs; only the provider name was wrong.
4903c9d3 fixed the `generate()` doc but missed the module doc, whose phrase wraps
across two lines ("generated by" / "ring via rcgen"), so a line-based grep never
matched it.
Comment-only: every changed line is a comment, cargo fmt clean.
Three defects behind the residual Apple audio jitter, found while chasing a field
report that survived both the PLC fix (#82) and the jitter-policy fix (#111).
1. `JitterTuning::deprime_after` counted CALLBACKS, and a callback is not a unit of
time. The same `4` was ~44 ms of starvation slack on a Mac's ~11 ms quantum and
20 ms on iOS, whose session asks for a short IO buffer — the shortest fuse of any
client, on the one with the burstiest transport. A 100 ms Wi-Fi delivery stall
therefore de-primed the Apple ring on every bunching cycle while the identical
policy rode it out everywhere else. It is now `deprime_ms`, measured in starved
audio, with a `MIN_DEPRIME_CALLBACKS` floor so a large-quantum device keeps real
hysteresis instead of de-priming on the first short read. Android was latently
exposed too (AAudio's low-latency burst is ~4-5 ms, so its `5` was also ~20 ms).
Driving the real policy through a simulated link (100 ms stall / 5 s, -30 ppm,
10 min) at a 5 ms quantum: 120 audible gaps and 690 ms of dead air before, 2 gaps
and 60 ms after.
2. iOS asked for a 5 ms IO buffer that bought the uplink nothing. The mic tap
installs with `bufferSize: 480` and the encoder consumes whole 10 ms
`framesPerPacket` chunks, so at 5 ms the tap simply fired twice per packet for the
same packet latency — while halving the render callback's deadline and, through
(1), the ring's starvation hysteresis. Now 10 ms, matching the framing we already
use. On the harsh link above that takes the residual from 2 gaps to 1.
The granted `ioBufferDuration`/sample rate/route are now logged at activation:
both asks are best-effort, and without the granted value an audio-jitter report
arrives with no way to tell a 10 ms session from a 5 ms one.
3. The hard-cap trim spliced RAW, on the reasoning that a ring which blew its ceiling
"is already a discontinuity". That describes the arrivals, not the samples either
side of the seam, which are ordinary continuous audio — and it is the drop that
actually fires: the same link above trims 120 times per 10 minutes where drift
sheds a handful. The gentle path that almost never runs was the one being faded.
Both kinds fade now, told apart by a new `JitterStep::hard_trim` rather than by
the fade length. `crossfade_drop` lost its `Vec` in the process — it blends in
place in one ascending pass, which it must, now that it runs on every trim inside
a realtime callback.
Fixes 1 and 3 live in the shared `JitterPolicy`, so Windows, Linux and Android get
them without change (all three already pass `step.crossfade` into `crossfade_drop`).
The Swift mirror in `AudioRing` is kept in step, including the generalised
`dropFront(_:)` the cap trim now shares with the drift shed.
Gates: 210 core tests, 288 Swift tests, clippy --all-features --all-targets, fmt,
plus an iOS-triple typecheck for the `#if os(iOS)` session change. Both new fuse
tests were plant-the-defect verified: restoring a fixed count reproduces
20/32/40/64/84 ms across the quanta (a 4.2x spread) and fails them loudly.
Not fixed here: drift correction is still one-directional, so a host clock running
SLOW is corrected only by starving and re-priming. That is the remaining periodic
gap on a clean link and it needs rate adaptation — designed separately.
`mgmt::tests::openapi_document_is_complete_and_checked_in` compares the served
document against the checked-in snapshot, so the endpoint doc edits in the
scanner removal made it stale and failed `ci / rust`.
Regenerated with `cargo run -p punktfunk-host -- openapi > api/openapi.json`.
The diff is 11 lines, all descriptions — no path, operationId or schema shape
moved. In particular `SourceOrigin` still enumerates ["builtin", "plugin"]:
the variant was kept deliberately so the console, which ships as its own
package and drives an N-1 host that still reports builtin sources, does not
have its generated union narrowed out from under that pairing.
A single failed tarball kills `bun install` and takes the whole image build with
it. Seen in CI as:
error: Fail extracting tarball for "@rolldown/binding-linux-x64-musl"
— a 7.7 MB optional binding that bun fetches on any linux-x64 host (the lockfile
records `os`/`cpu` but no libc, so the musl and glibc bindings are equally
eligible) and that had arrived truncated.
The lockfile is NOT at fault, which is worth recording because it is the obvious
suspect: `bun install --frozen-lockfile` accepts it, regenerating it with bun
1.3.14 — the version in the failing log — is byte-identical, the tarball
downloads and extracts cleanly, and this exact layer builds green for
`--platform linux/amd64` with `--no-cache`.
So this is a transient-download guard, not a lockfile fix: two attempts with a
pause, then fail for real. It recovers a truncated download and deliberately does
NOT paper over a runner that is out of disk, which fails identically every time.
The host no longer scans any launcher itself. `library/{steam,epic,gog,heroic,
lutris,xbox}.rs` and the `scanner_defs()` table are gone; `GET /library/scanners`
now lists exactly what the operator installed, every row `origin: "plugin"`.
This is the end of the migration whose bridge half shipped in v0.26.0. The
plugins have been published and index-pinned since 2026-08-08, so the
replacement has been in the field for the whole bridge window.
A host with no library plugins installed has an empty grid — that is the upgrade
note. The console's one-click install per source (the D9 nudge) is unchanged and
still never auto-installs.
Nothing about a title changes when its plugin takes over, and that is why this
could be a deletion rather than a rewrite: a plugin CLAIMS its store (D2), and a
claimed entry surfaces under the deterministic `<store>:<external_id>` id the
scanner used to produce. Entry ids, GameStream FNV-1a app ids, client art
caches, Moonlight pins, the per-source toggles and the per-entry hides all key on
that id and none of them move. `library-scanners.json` keeps its name, shape and
contents: an operator who had `steam` off still has it off, with no migration.
Kept deliberately:
* `launch.rs` in full. Launch is host-owned by design D1 — a plugin publishes a
validated value, the host builds the command — so every typed kind survives.
`xbox_pfn()` MOVED here out of the deleted `xbox.rs`: resolving a package
Identity to its PackageFamilyName needs `AppRepository` enumeration, readable
by the host (LocalSystem) and denied to the plugin runner (LocalService). That
measured asymmetry is the whole reason the `xbox` launch kind exists, so the
resolver is launch vocabulary, not scanner vocabulary.
* `SourceOrigin::Builtin`. No host build emits it, but the console ships as its
own package and drives an N-1 host that still does, so the variant stays in the
schema and the console keeps its `builtin` handling.
* A store-label table, so a source row does not rename itself from "Steam" to
`steam` the day its plugin takes over.
Removed with the scanners: the background cover-art warmer and its on-disk cache
(they existed only for GOG and Xbox, the two sources that had to ask a network
catalog what a cover was — a plugin resolves art while it scans), the legacy
`steam:` branch of the art proxy, and `GameMeta::pc()`. The host now makes no
outbound HTTP request to build a library at all.
Dependency audit, as WP6.4 required: `rusqlite` (with its bundled, cc-compiled
SQLite) and `roxmltree` leave the graph — verified no other users. `winreg`
stays: `launch.rs`, `procscan/windows.rs` and two `audio/windows/` modules need
it. `base64`/`ureq` stay, exactly as the plan predicted.
A stale `library-art-cache.json` from an older host is ignored, not migrated.
The Installed tab could only update one plugin at a time, one dialog and one
watched job each. This adds the bulk action beside the list it acts on — the
same place Sources keeps "Refresh all" — plus a count badge on the Installed
tab trigger, because Browse is the tab the page opens on and a control nobody
passes is a control nobody finds.
The host takes ONE package operation at a time (409 otherwise: bun operations
share a lockfile and a node_modules tree), so this is a queue the console works
through job by job, driven by each job settling rather than by a timer. The run
carries its own copy of what is left: every finished install invalidates the
installed list, and a queue that re-derived itself would change shape underneath
a run the operator already confirmed.
Trust rules are unchanged, only taken once instead of N times. If any entry in
the run comes from an operator-added source the whole dialog wears the external
treatment and names those catalogs — a bulk action must not be a way to wave
through, in one click, a warning each package would have shown on its own. The
dialog lists every version change rather than a count, and names what it will
not attempt: an update with no catalog entry, or one this host would refuse
(400 on incompatible, blocked entries) never enters the queue, so the button's
count still adds up on screen.
A failure ends the run. The failed job's card is the only record of what went
wrong, and starting the next install would replace it with a fresh spinner; the
toast says what was applied and what was not, and the rows are still there to
retry from.
Also fixed, because this change leans on it: disabled buttons were invisible.
AnimatedButton is a motion element and its mount animation settles as an inline
`opacity: 1`, which outranks the `disabled:opacity-50` class the library also
ships — measured `opacity: 1` on a disabled button, console-wide. Only
`pointer-events: none` landed, so every disabled control in the app looked live
and silently ignored the click. Corrected in the components/ui wrapper layer
like the other @unom/ui adaptations.
Verified: tsc, biome, `bun test server/`, production build, i18n check (650
messages, en + de). Storybook stories added for the list header and the confirm
dialog; both rendered headless in light and dark, with the disabled states
asserted on the DOM rather than by eye.
The mic element sat in the top-right corner of every stream that opened a
capture — a standing button on touch, a Muted badge on TV. It goes for now;
the on-screen overlay UI being built will carry mute as one of its controls,
and re-introducing it there is the right moment to decide how it looks.
Mute itself is untouched: `micRunning`, `micMuted` and `setMicMuted` still
back the Select + Y chord, which is now the whole of the control, and
`MicChordHint` is now its only on-screen feedback (its doc updated to say so
rather than pointing at the badge that no longer exists).
The dependency currency wave took skia-safe/skia-bindings 0.87.0 -> 0.99.0 in
crates/pf-console-ui/Cargo.toml, but packaging/flatpak/io.unom.Punktfunk.yml still
pinned the 0.87.0 prebuilt archive, so every flatpak leg since the merge dies with
error[E0599]: no variant, associated function, or constant named `Default`
found for enum `SkPathFillType` (and `SkPathDirection`)
--> cargo/vendor/skia-bindings-0.99.0/src/defaults.rs:57
Nothing about that message points at the manifest, so it reads like a crate bug. It
isn't. `SKIA_BINARIES_URL: file://…` makes skia-bindings unpack the pinned tarball
verbatim into target/…/build/skia-bindings-*/out/skia/ — *including the bindings.rs
it was generated with*. Those two `Default`s are associated consts emitted INTO
bindings.rs, so they travel with the archive, not with the crate: 0.99.0's
src/defaults.rs was compiling against 0.87.0-era bindings. Verified directly — the
0.99.0 archive carries `impl SkPathFillType { pub const Default = Winding }` and
`impl SkPathDirection { pub const Default = CW }` on both x86_64 and aarch64.
Because the URL is file://, the fetch can never fail, so there is no download error
to notice — the only symptom is a compile error deep in a vendored crate.
The asset name changed across the bump: `jpeg` entered skia-safe's defaults at 0.99,
so the resolved-feature key went `pdf-textlayout-vulkan` -> `jpegd-jpege-pdf-textlayout-vulkan`.
Confirmed against each archive's own key.txt/tag.txt (tag 0.99.0, key
a25a0fdb7d90429aa2d1-<target>-jpegd-jpege-pdf-textlayout-vulkan), and libskparagraph.a
plus the Vulkan backend symbols are present, so the feature set still matches what
pf-console-ui resolves.
Everything else in the offline chain (Cargo.lock, cargo-sources.json) is regenerated
from the lock and self-corrects; this tarball is the single hand-maintained pin, which
is exactly why it was the thing left behind. Both bump sites now carry a pointer to
the other so the next one can't split-brain the same way.
Chaining two clients through a kept (keep-alive) Mutter display segfaults
gnome-shell in meta_monitor_manager_rebuild (libmutter-18) and takes the whole
desktop down; every later session then fails RemoteDesktop.CreateSession:
ServiceUnknown until GDM restarts. A/B'd on .21: byte-identical on released
0.27.0 and the 0.28.0 RC, so it was never a regression — the trigger has been
there all along.
TOPOLOGY_LOCK already serialized every topology-mutating D-Bus call, but two
gaps still let Mutter's REBUILDS overlap:
- Teardown was fire-and-forget: StopGuard::drop set a flag and returned, and
the session thread only noticed on its ≤200 ms park tick. The A2 dead-reuse
path (reused kept display dead on first frame → mark_failed → re-create)
therefore issued its fresh RecordVirtual with the doomed monitor's removal
still pending — the fresh session could even win the lock BEFORE the old
thread had woken to take it, adding a monitor while the dead one still stood.
The drop now waits (bounded, 20 s) for the session thread to finish.
- The lock was released while the shell was still rebuilding: Stop /
RecordVirtual / ApplyMonitorsConfig all return mid-rebuild, and a temporary
(APPLY_TEMPORARY) config auto-reverts asynchronously on top. Every locked
mutation section now ends with settle_topology() — poll GetCurrentState
until a removed connector is actually gone and the config serial holds still
across two consecutive reads — before the guard drops. Bounded at 4 s and
best-effort (a read error means the shell is gone; a hotplug storm must not
park sessions), degrading to exactly the old behavior.
Cost when Mutter is already quiet: one confirming read plus one 150 ms recheck
per setup/teardown. The live_mutter_create_drop harness sheds its grace sleep —
the synchronous drop IS the teardown confirmation now.
Not fixed here, documented on TOPOLOGY_LOCK: the mid-stream mode-switch rebuild
is create-before-drop by design (H2), so its RecordVirtual still lands while
the superseded monitor exists; the settle makes Mutter quiescent at that point
but cannot remove the coexistence itself.
A 4K60 GameStream session captured 1920x1080. `create()` asked KWin for
3840x2160, KWin built something else, and nothing compared the two: only the
>60 Hz arm read anything back, and it gets that for free because it installs a
custom mode. The ≤60 Hz arm installs nothing, which is exactly why it never
noticed.
The line that should have caught it was the one that hid it. `spawn_vout`
returns a node id, never a size, so
tracing::info!(node_id, width, height, "KWin virtual output ready")
was echoing the REQUEST — the field log stated 3840x2160 while the output was
1080p, and the first pass at diagnosing this was done against that number. It
now logs `requested_w`/`requested_h`, and the readback sits under it.
Unverified, the mismatch was silent and total. `final_dims` carried the request
forward, so `apply_topology`, `clear_replication_source` and
`resolve_kscreen_addr` — all of which resolve by dims — quietly missed their own
output, leaving the stream neither primary nor de-mirrored; and the encoder
opened at the captured size, handing the client a bitstream that disagreed with
the resolution it had configured its decoder from.
Suspected trigger is KWin restoring per-output mode/scale from
kwinoutputconfig.json, which is keyed by output NAME — and ours is deliberately
stable across sessions so KDE reapplies that client's scaling (Stage 3). The
feature and the failure are the same mechanism.
- `kwin_output_mgmt::actual_dims()` reads the output's real mode + scale.
Resolution is by name alone, so it declines unless EXACTLY one output carries
our prefix: two means a supersede is in flight, and the dims filter is the
only thing that can tell the replacement from the predecessor whose name it
reuses. Failing closed keeps this a pure addition.
- On a mismatch, re-assert the requested mode through the same
`set_custom_mode` install+select the sacrificial birth already uses (an output
at a size we don't want, moved to one we do) and arm `expect_exact_dims` so
the capturer holds frames until the screencast renegotiates. 60 Hz is
requested, not `mode.refresh_hz`: only the size is wrong here, and asking for
the client's rate would install a 30 Hz mode for a 30 fps client.
- If KWin refuses the correction, report the size that is REALLY there rather
than the request, so the dims-keyed resolves and the encoder key on reality,
and say in the log how to clear the stored entry.
- Scale is logged, never corrected — a non-unity scale here is the Stage 3
feature working, not a fault.
- `mode_satisfies()` extracts the acceptance predicate both arms now share, so
they cannot drift into disagreeing about what "we got what we asked for"
means. Tested: a restored 1080p does not pass for a 4K request, a CVT-aligned
width does, and the slack is bounded, one-sided and width-only.
The stream-side warning is reworded but deliberately still NOT fatal: mirroring
a pinned monitor streams a size the client never negotiated BY DESIGN (§7.3 — a
panel runs at the mode its owner set and the client scales), so refusing the
mismatch would break every mirror session. It now names both causes and states
what the client actually does with the stream.
Does not claim to close the Xbox Moonlight disconnect it was found through: that
client's IDR storm begins ~4.6 s after the first frame, which a decoder simply
unable to handle the size would not do. The 1080p-instead-of-4K is a real defect
on its own terms and is what this fixes.
#192 moved rcgen to aws-lc-rs and removed ring from the tree, but this comment
still explained the P-256 path in terms of "rcgen's ring backend". It also
cross-references gamestream::cert's note, which this branch already corrected —
so leaving it made the two contradict each other.
The substance is unchanged and still load-bearing: rcgen generates EC keys
directly, while RSA has to be generated by the `rsa` crate and handed to rcgen
to self-sign, because no rcgen backend will generate an RSA key.
Covers all five generated files, not just the root one: the four per-client
copies are scoped to the binaries their package installs, so they move
independently of the workspace-wide file.
Root: 571 -> 575 crates, reflecting this wave (skia-safe 0.99, the RustCrypto
digest-0.11 family, jni 0.22, x11rb 0.14, reis 0.7, xkbcommon 0.9, wasapi 0.24,
windows-service 0.8.1, x509-parser 0.18, rand 0.9, base64 0.23, libloading 0.9,
mdns-sd 0.21 + if-addrs 0.15, rcgen 0.14, criterion 0.8, android_logger 0.15).
The per-client diffs are much larger than the wave alone explains, because they
were never regenerated after #192: all four still attributed `ring` and named no
aws-lc-rs at all. Since #192 removed ring from the tree entirely, the shipped
Acknowledgements screens have been crediting a crypto library the clients do not
carry while omitting the one they do. They now catch up on both changes at once.
(`ring` still appears via the generator's deliberate `--all-features`
over-approximation, which sees quinn-proto's wasm-only edge; that is by design —
listing an unlinked crate is untidy, omitting a linked one is the failure the
file exists to prevent.)
Also stops gen-third-party-notices.sh preferring `cargo about` for the root file.
That preference was silently destructive: cargo-about only sees CARGO
dependencies, so it drops every VENDORED_TREES entry -- pyrowave, the Granite
subset, volk, Vulkan-Headers, the Font Awesome brand icons, Simple Icons -- which
are third-party sources shipped inside first-party crates under their own
licences. Measured today: cargo-about emitted 7,274 lines / ~514 crates with zero
mentions of volk, Vulkan-Headers or Font Awesome, against the python generator's
17,324 / 575 with all of them. Merely having cargo-about on PATH was enough to
degrade the file, so anyone regenerating after this commit would have undone it.
cargo-about remains what the CI licence gate runs -- that job asks a different
question (is every licence in the about.toml allowlist) and writes to /dev/null.
Both licence-gate legs pass: `cargo about generate about.hbs --fail` and the
drivers-workspace leg, RC=0.
Dev-dependency of punktfunk-core only — it ships in nothing. `default-features =
false, features = ["cargo_bench_support"]` carries over unchanged; that feature
still exists in 0.8 and still keeps plotters/rayon out of a headless CI run.
One source change, and it is a lint issue rather than an API one.
`criterion::black_box` survives in 0.8 but is `#[deprecated]` — it now just
forwards to `std::hint::black_box` — and benches ARE compiled by
`cargo clippy --workspace --all-targets -- -D warnings`, so keeping the criterion
import would have turned a deprecation warning into a failed lint gate. The
bench imports the std one directly.
What CI actually consumes from criterion is the on-disk result layout, so that
was checked rather than assumed: 0.8 still writes
`target/criterion/<group>/<id>/new/estimates.json`, and the key
scripts/bench/compare.py reads — `median.point_estimate` — is still there:
$ cargo bench -p punktfunk-core --bench pipeline -- --warm-up-time 1 --measurement-time 3
... 12/12 benchmarks reported, e.g. pipeline/gf16/1048576 thrpt: [537 MiB/s 540 MiB/s 542 MiB/s]
$ find target/criterion -name estimates.json | wc -l
24
$ python3 -c 'import json; print(json.load(open(".../crypto/seal/new/estimates.json"))["median"]["point_estimate"])'
817.96
Verified on CachyOS (rustc 1.96.0):
cargo clippy -p punktfunk-core --all-targets --locked -- -D warnings OK (this is what compiles the bench)
cargo clippy --workspace --all-targets --locked -- -D warnings OK
cargo bench -p punktfunk-core --bench pipeline --locked -- --test 12/12 Success
cargo bench -p punktfunk-core --bench pipeline --locked -- --warm-up-time 1 --measurement-time 3 OK (CI's exact line)
cargo test -p punktfunk-core --locked 210 + 8 + 1 passed, 0 failed
cargo fmt --all --check clean
Both declarations keep `default-features = false, features = ["aws_lc_rs",
"pem"]`, which stays load-bearing in 0.14: `ring` is still in rcgen's DEFAULT
feature set, so dropping `default-features = false` would drag the backend this
tree deliberately left back in. Verified after the bump — `cargo tree -i ring`
finds nothing on x86_64-unknown-linux-gnu, x86_64-pc-windows-msvc,
aarch64-apple-darwin or aarch64-linux-android.
One breaking change reached us. `CertifiedKey`'s key field was renamed
`key_pair` -> `signing_key` (the struct is now generic, `CertifiedKey<S:
SigningKey>`), which hits the two `generate_simple_self_signed` callers in
core's quic/endpoint.rs — the dev/test server endpoint and `generate_identity`.
Both are a field rename; `KeyPair` still provides `serialize_der`/`serialize_pem`.
Everything the GAMESTREAM identity depends on survives unchanged, which is what
this bump's precondition was about: `KeyPair::from_pkcs8_pem_and_sign_algo`,
`PKCS_RSA_SHA256`, `CertificateParams::new`, `date_time_ymd`, `self_signed`,
`Certificate::pem`. The split in gamestream/cert.rs is therefore untouched — the
RSA-2048 key is still minted by the `rsa` crate and handed to rcgen only to load
and self-sign, because rcgen cannot generate an RSA key on the backend we use.
That path is asserted, not assumed:
cargo test -p punktfunk-host --bins --locked gamestream::cert 3 passed, 0 failed
generate_mints_a_loadable_rsa2048_identity ok (fresh-install keygen)
moonlight_shaped_peer_completes_a_tls12_mutual_handshake ok
tls13_negotiates_the_post_quantum_group ok (X25519MLKEM768 by name)
Verified on CachyOS (rustc 1.96.0):
cargo clippy --workspace --all-targets --locked -- -D warnings OK
cargo clippy -p punktfunk-host -p punktfunk-core --locked -- -D warnings OK (shipping build)
cargo test --workspace --locked 78 test binaries, all ok, 0 failed
cargo fmt --all --check clean
One commit because splitting them accomplishes nothing: mdns-sd 0.20 ALREADY
depends on if-addrs 0.15, so while our own five crates declared 0.13 the tree
carried both copies no matter which of the two moved first. Moving them together
is what collapses it:
$ cargo tree -d | grep '^if-addrs'
(no output)
Neither needed a source change. mdns-sd 0.21's public API is purely additive
over 0.20.3 — the sole new item is `ServiceDaemon::set_max_packet_size`, and
`ServiceInfo`'s surface is byte-identical — so `ServiceDaemon`/`ServiceInfo`/
`ServiceEvent`/`ResolvedService` behave as before at all six call sites
(host discovery + gamestream mdns, pf-client-core, and the Android, Windows and
probe clients). if-addrs 0.15 keeps 0.13's `Interface`/`IfAddr` shape, and we
only ever read those.
The one real change is a FEATURE, not a version. if-addrs has `link-local`, and
mdns-sd declares if-addrs with it on. Once our crates share that single copy,
unification turns it on for our calls too — meaning `get_if_addrs()` now also
reports fe80:: interfaces (and, on Windows, 169.254.x.x). Rather than inherit
that silently, punktfunk-core and punktfunk-host now NAME the feature. Two
reasons: it is what every real build gets anyway, so a standalone `cargo test -p
punktfunk-core` should not enumerate a different set of NICs than the host does;
and for the consumer here — Wake-on-LAN — it is the behaviour we want, since a
NIC is wake-capable whether or not it currently holds a routable address.
Verified on CachyOS (rustc 1.96.0):
cargo clippy --workspace --all-targets --locked -- -D warnings OK
cargo test --workspace --locked OK, 0 failed
cargo test -p punktfunk-host --bins --locked 501 passed, 0 failed, 2 ignored
cargo test ... gamestream::cert 3 passed
cargo fmt --all --check clean
(One `cargo test --workspace` attempt failed with E0463 "can't find crate for
pf_frame" in a doc-test. That is the target dir having only clippy's .rmeta for
a crate a doc-test wants to LINK, not anything in this change; a plain re-run
after cargo test built the rlibs was green.)
All five declarations move together (pf-encode twice — Linux and Windows —
plus pf-client-core, pf-zerocopy and punktfunk-host), because a split would
have compiled two copies of a crate whose whole job is holding a process-wide
dlopen handle.
No source changes. 0.9 replaces the concrete parameter types with sealed traits
— `Library::new(impl AsFilename)` and `Library::get(impl AsSymbolName)` — and
both cover what our 16 call sites already pass: `&str` literals for the sonames
(`libnvidia-encode.so.1`, `libva.so.2`, `libnvidia-ml.so.1`, `libcuda.so.1`) and
`&[u8; N]` NUL-terminated byte literals for the symbols, which 0.9 implements
explicitly alongside `&[u8]`. MSRV rises to 1.88; the workspace pins 1.96.
libloading 0.8 does not leave the lock, and shouldn't: what remains is
`clang-sys` under `bindgen`, reached only as a BUILD-dependency of
ffmpeg-sys-next / libspa-sys / pyrowave-sys. That copy runs at build time and is
linked into nothing we ship.
Verified on CachyOS (rustc 1.96.0):
cargo clippy --workspace --all-targets --locked -- -D warnings OK
cargo clippy -p pf-encode --all-targets --locked --features nvenc,vulkan-encode,pyrowave -- -D warnings OK
(the only leg that compiles enc/linux/nvenc_cuda.rs, where the `lib.get(b"…\0")` calls live)
cargo clippy -p punktfunk-host -p pf-encode -p pf-zerocopy -p pf-client-core --locked -- -D warnings OK (shipping build)
cargo test -p punktfunk-host --bins --locked 501 passed, 0 failed, 2 ignored
cargo test -p pf-encode --locked 33 passed, 5 ignored
cargo test -p pf-zerocopy --locked 40 passed
cargo fmt --all --check clean
ureq 3 already pulls 0.23, so the tree compiled both minors purely because our
two direct declarations named the older one. The API we use — `Engine`,
`engine::general_purpose::STANDARD`, `encode`/`decode` — is unchanged in 0.23;
no source edits.
Both are declared `default-features = false, features = ["std"]` rather than
taking the defaults. 0.23 added `simd-unsafe` (hand-written AVX2/NEON engines)
as a DEFAULT-ON feature, and ureq declares base64 with default features off, so
today that code is not in the tree. Accepting the defaults here would unify the
feature on and quietly add an unsafe SIMD engine to every artifact as a side
effect of a currency bump. Whether to enable it is a perf question deserving a
measurement and its own commit; this one changes versions, not exposure. `std`
covers every call site (encode to `String`, decode to `Vec`).
base64 0.22 does NOT leave the tree: `rcgen` -> `pem` 3.0.6 is now its sole
remaining consumer, and it stays one after the rcgen 0.14 bump later in this
batch — 0.14 still depends on `pem` "3.0.2", which resolves to the same 3.0.6,
which still uses base64 0.22. Clearing that last copy is upstream's move (a
`pem` release on 0.23), not ours.
Verified on CachyOS (rustc 1.96.0):
cargo clippy -p punktfunk-host -p pf-update-check --locked --all-targets -- -D warnings OK
cargo clippy -p punktfunk-host -p pf-update-check --locked -- -D warnings OK (shipping build)
cargo test -p punktfunk-host --bins --locked 501 passed, 0 failed, 2 ignored
cargo test -p pf-update-check --locked 32 passed, 0 failed
cargo fmt --all --check clean
punktfunk-core and pf-client-core were already on 0.9; the host sat on 0.8 by
drift, not by decision, so every build compiled two rand majors to satisfy six
call sites. Mechanical at five of them (`thread_rng()` -> `rng()`,
`gen_range` -> `random_range`); the sixth is the interesting one.
`RsaPrivateKey::new` is bounded on rand_core **0.6**'s `CryptoRngCore`. rand
0.9's `ThreadRng` implements rand_core 0.9's traits — same names, different
crate — so the RSA-2048 keygen in gamestream/cert.rs stopped satisfying the
bound the moment the version moved. It now draws from `rsa::rand_core::OsRng`:
rsa's own re-export, which is by construction the traits rsa compiled against,
so the two rand_core majors never have to meet in our source. That needs
`features = ["getrandom"]` on rsa (not one of its defaults) — and it must be
declared HERE rather than left to feature unification, because dropping our own
rand 0.8 also dropped the `std` feature that used to switch `rand_core/getrandom`
on as a side effect.
What this does and does not clear. The GAMESTREAM host keeps rand 0.8 +
rand_chacha 0.3 — `rsa` drags them in through `num-bigint-dig`, which is not
ours to move:
$ cargo tree -i rand@0.8.7
rand v0.8.7
└── num-bigint-dig v0.8.6
└── rsa v0.9.10
└── punktfunk-host
The NATIVE-ONLY host (--no-default-features, no rsa) now sheds both entirely —
`cargo tree -p punktfunk-host --no-default-features --features pyrowave -i
rand@0.8.7` and the same for rand_chacha@0.3.1 both report no match. rand_core
0.6 stays in every flavour regardless: `crypto-common` (under aes-gcm) needs it,
which no rand bump can change.
`windows/install.rs`'s `random_password` is the one site a Linux box cannot
type-check; the edit there is the identical `thread_rng()` -> `rng()` the five
compiled sites took.
Verified on CachyOS (rustc 1.96.0):
cargo clippy -p punktfunk-host --locked --all-targets -- -D warnings OK
cargo clippy -p punktfunk-host --locked -- -D warnings OK (shipping build: no --all-targets)
cargo clippy -p punktfunk-host --no-default-features --features pyrowave --all-targets --locked -- -D warnings OK
cargo test -p punktfunk-host --bins --locked 501 passed, 0 failed, 2 ignored
cargo test ... gamestream::cert 3 passed (incl. the fresh-install RSA-2048 keygen)
cargo fmt --all --check clean
x509-parser 0.16 pinned the old asn1-rs 0.6 / der-parser 9 / oid-registry 0.7
chain, and every one of those still depended on thiserror 1.0. It was the ONLY
thing doing so — so the host compiled two thiserror majors (and two
thiserror-impl proc macros) for one crate's error types. 0.18 moves the chain to
asn1-rs 0.7 + thiserror 2, which is the same major the rest of the tree already
uses, and the 1.0 half disappears — for the host, on every target and including
dev-dependencies:
$ cargo tree -p punktfunk-host --target all -e normal,build,dev -i thiserror@1
error: package ID specification `thiserror@1` did not match any packages
Scope that claim honestly: this clears the HOST graph, not the workspace.
thiserror 1.0 is still built, reached by `jni` 0.21 AND by the vendored
`ndk` 0.9.0, both under punktfunk-client-android. That is a different graph
and a different bump.
No source change was needed. The one API shift that could have reached us —
asn1-rs 0.7 making `BitString::data` a `Cow<[u8]>` instead of a `&[u8]` — lands
on `x509.signature_value.data.to_vec()` in gamestream/cert.rs and
gamestream/pairing.rs, and `to_vec()` goes through Deref either way. Both are
Moonlight pairing-hash inputs, so they are covered by the gamestream::cert
tests rather than taken on faith. nom 7 and nom 8 were already both in the lock;
this adds no new duplicate.
Verified on CachyOS (rustc 1.96.0):
cargo clippy -p punktfunk-host --locked --all-targets -- -D warnings OK
cargo clippy -p punktfunk-host --locked -- -D warnings OK (shipping build: no --all-targets)
cargo clippy -p punktfunk-host --no-default-features --features pyrowave --all-targets --locked -- -D warnings OK
cargo test -p punktfunk-host --bins --locked 501 passed, 0 failed, 2 ignored
cargo test ... gamestream::cert 3 passed
cargo tree -i ring nothing to print
Version currency for the virtual-keyboard keymap in pf-inject's wlroots path
(`inject/linux/wlr.rs`), the crate's only consumer.
Additive on the Rust side: one new wrapper, `Keymap::key_get_mods_for_level`,
and the `extern` declaration behind it. Nothing we call changed shape -- we use
`Context::new`, `Keymap::new_from_names`, `get_as_string`, `State::new`,
`Keycode::new`, `KeyDirection` and the `serialize_mods`/`serialize_layout` pair,
all untouched. The feature set is unchanged too: `default = ["wayland"]` in both
releases, so `x11` -- the feature that would pull `as-raw-xcb-connection` -- stays
off and this links only `libxkbcommon`, exactly as before.
The one thing worth writing down is the new symbol's floor. On .25's
libxkbcommon 1.13.1 it reads
`xkb_keymap_key_get_mods_for_level@@V_1.0.0`, so the declaration wants
libxkbcommon >= 1.0.0 (2020) if it is ever referenced. Nothing in the workspace
calls the wrapper, so no relocation for it reaches our binaries -- and every ship
target clears 1.0 by years regardless (Ubuntu 22.04 is 1.4, Debian 12 is 1.5,
Debian 13 is 1.7). pf-inject's test binary, which unlike the rlib actually links,
builds and runs clean.
Two internal fixes come along, both in constructors we do not use:
`new_from_string` passes the Rust string's pointer + length to
`xkb_keymap_new_from_buffer` instead of round-tripping through a `CString`
(whose `unwrap()` panicked on an interior NUL), and `new_from_fd` passes the
full mapped `size` rather than `size - 1`.
Verified on .25 (Ubuntu 26.04, `CARGO_BUILD_JOBS=2`), all rc=0:
cargo build -p pf-inject --locked
cargo clippy -p pf-inject --locked -- -D warnings
cargo clippy -p pf-inject --all-targets --locked -- -D warnings
cargo test -p pf-inject --locked 127 passed, 0 failed, 8 ignored
+ motion_contract: 7 passed, 0 failed
cargo check -p punktfunk-host --locked
cargo clippy -p punktfunk-host --locked -- -D warnings
cargo fmt --all --check
Clippy is run BOTH ways because host CI lints without `--all-targets`.
`cargo tree -i ring` stays empty.
Version currency for the libei path. pf-inject is the only consumer -- reis is a
pure-Rust implementation of the EI wire protocol, so this links nothing new and
still needs no libei on the host. The release tracks libei protocol 1.5.0 ->
1.6.0.
**Nothing in our API surface moved.** `ei::Context::new`, `handshake_tokio`,
`reis::tokio::EiConvertEventStream` and `reis::event::{Device, DeviceCapability,
EiEvent, Region}` all keep their shape; `Region`'s six fields are byte-identical.
The two enums grew variants for libei 1.6's `ei_text` (`EiEvent::TextKeysym` /
`TextUtf8`, `DeviceCapability::Text`), which compiles because `handle_ei`'s match
ends in `_ => {}` and the capability set we bind is written out explicitly. The
handshake now ADVERTISES `ei_text` as a supported interface, so a 1.6 EIS may
offer one; we never bind it and never emit on it.
**Behaviour we inherit, all of it upstream bug fixes:**
- Frames now commit per device. 0.6 held one global pending queue, so an
`ei_device.frame` from device A committed device B's timestamped events under
A's timestamp. Inert for us -- we are an EI *sender*, and the events we
receive are device lifecycle plus `KeyboardModifiers`, none of which are the
timestamped kind that queue -- but strictly more correct.
- `Device::interface()` now forgets an interface when the server sends its
`destroyed` event, and `DeviceRemoved` un-registers the device's interfaces
from the converter's reverse map. Our `slot.interface::<ei::Button>()` and
friends therefore stop handing back a proxy for a torn-down interface instead
of emitting into a dead object.
- `Device`, `Seat` and `Object` now hash consistently with their `PartialEq`.
Both were already `Arc::ptr_eq`; `Hash` used the protocol object id, which is
a broken pair. We keep devices in a `Vec` and compare with `==`/`!=`, so this
changes nothing here.
- The wire backend rejects a header length above libei's 1 MiB `max_msglen`
BEFORE waiting for that many bytes, so a malformed length no longer parks the
reader until the connection dies.
**The host graph loses the `futures` facade crate.** reis's `tokio` feature
depended on `futures`; 0.7 depends on `futures-util` directly, which pf-inject
already declares. `cargo tree --target all -i futures` now names only relm4 (the
GTK Linux client), and `futures` + `futures-executor` are gone from
punktfunk-host's Linux tree (`futures-task` stays, under futures-util). Lockfile
delta is one package: `reis` 0.6.1 -> 0.7.1 with `futures` -> `futures-util` in
its dep list; the workspace package SET is unchanged, since relm4 and glib still
need those crates for the GTK client.
Verified on .25 (Ubuntu 26.04, `CARGO_BUILD_JOBS=2`), all rc=0:
cargo build -p pf-inject --locked
cargo clippy -p pf-inject --locked -- -D warnings
cargo clippy -p pf-inject --all-targets --locked -- -D warnings
cargo test -p pf-inject --locked 127 passed, 0 failed, 8 ignored
+ motion_contract: 7 passed, 0 failed
cargo check -p punktfunk-host --locked
cargo clippy -p punktfunk-host --locked -- -D warnings
cargo fmt --all --check
Clippy is run BOTH ways because host CI lints without `--all-targets`.
`cargo tree -i ring` stays empty.
Version currency, but a real API migration rather than a version-number edit — and the
payoff is bigger than "one crate is newer", because jni 0.22 was ALREADY in this .so:
`rustls-platform-verifier` (via quinn-proto, for Android cert verification) depends on it,
so pinning 0.21 here compiled two complete jni copies into one library. Matching the
version collapses them.
Eliminated, measured as the delta in THIRD-PARTY-NOTICES.txt (582 → 571 crates, nothing
added): jni 0.21.1, its `cesu8`, and — because jni 0.21 was the SOLE consumer of
windows-sys 0.45.0, the oldest crate in the tree — that whole windows-rs 0.42 generation:
windows-sys 0.45.0, windows-targets 0.42.2 and its seven per-arch import libraries. Eleven
crates, carried for a `cfg(windows)` dependency of an Android-only bridge.
NOT eliminated, contrary to what the sweep expected — recorded in the manifest so the next
person does not re-derive it. thiserror 1.0 and the jni-sys 0.3/0.4 split both survive,
because jni was never their only source:
thiserror 1.0.69 ← vendor/ndk 0.9.0 (+ asn1-rs/x509-parser, host side)
jni-sys 0.3.1 ← vendor/ndk 0.9.0 AND crates.io ndk-sys 0.6
jni's share of both is gone; the remainder is the ndk stack. jni-sys 0.3.1 is itself a
facade crate over 0.4.1, so the split cannot close until ndk and ndk-sys move, and `ndk` is
vendored for a one-line visibility patch — changing its dependency versions would mean
rewriting the vendored snapshot instead of reading it as a diff against upstream. Left
alone deliberately.
The migration itself, across 66 native methods in 10 files:
* `JNIEnv` split into `EnvUnowned` (the FFI-safe native-method argument) and `Env` (where
the JNI calls live). The 41 methods that never touched the environment are a type
rename; the 22 that do now acquire an `Env` inside `EnvUnowned::with_env` and map the
outcome with an `ErrorPolicy`.
* `LogErrorAndDefault` everywhere, chosen to PRESERVE behaviour: the old code swallowed
JNI errors and returned a default, and this logs and returns the same default. The
throwing policy would have been a behaviour change (new Java exceptions out of methods
that previously failed quietly), which is not what a currency bump should do.
* `with_env` also catches panics, which is exactly what the crate's own `jni_guard` did,
so the guard is folded into it where the two would have nested. It stays on the methods
that take no `Env`. ONE exception, marked at the call site: `nativeNextHidout` returns
-1 as its failure sentinel, and every error policy resolves to `T::default()` — which
for `jint` is 0, a *valid* byte count. That method keeps `jni_guard(-1, …)` outside and
uses `with_env_no_catch` inside so the sentinel survives the panic path unchanged.
* `jboolean` is `bool` in jni-sys 0.4, not `u8` — a type change, not a rename, and the
reason for most of the mechanical diff (`down != 0` → `down`, `return 0` → `false`).
* `Env::get_string` is deprecated in favour of `JString::try_to_string`, and CI runs
clippy with `-D warnings`, so the call sites moved rather than being left to warn.
Likewise `set_/get_*_array_region` → `JPrimitiveArray::set_region`/`get_region`.
* `Env::get_native_interface()` is now `Env::get_raw()` — the raw pointer handed to
`ndk::NativeWindow::from_surface`. The `as *mut _` cast next to it was already commented
as bridging jni-sys skew between `jni` and `ndk`; that skew is now real (0.4 vs 0.3)
rather than hypothetical, so the comment says so.
* Return types moved from raw `jni::sys::jstring`/`jdoubleArray`/`jintArray` to
`JString`/`JDoubleArray`/`JIntArray`, because `resolve()` requires `T: Default` and raw
pointers have no `Default`. All three are `#[repr(transparent)]` over the same
`jobject`, so the exported ABI is unchanged and `Default` IS the null reference the old
code returned explicitly.
Kotlin and Gradle needed NO changes, checked rather than assumed: every affected
`external fun` in NativeBridge.kt already declares `Boolean` / `String?` / `IntArray?` /
`DoubleArray?`, which is what these signatures still present to the JVM, and the Gradle
side only shells out to cargo-ndk without naming a jni version.
Verified on NDK 30.0.14904198, both shipping ABIs, using the environment
clients/android/kit/build.gradle.kts hands cargo-ndk:
cargo clippy -p punktfunk-client-android --all-targets -- -D warnings → ok (host)
cargo test -p punktfunk-client-android → 20 passed
cargo ndk -t arm64-v8a --platform 28 clippy … -- -D warnings → ok
cargo ndk -t armeabi-v7a --platform 28 clippy … -- -D warnings → ok
cargo ndk -t arm64-v8a -t armeabi-v7a --platform 28 build → both .so LINKED
scripts/ci/check-android-jni-imports.sh … 28 → 2 ABI(s) clean at the API-28 floor
That last one matters for this change specifically: a cdylib links with dangling undefined
symbols, so the floor check is the only thing that would catch jni 0.22 hard-importing an
NDK entry point above minSdk 28 — the shape of the 0.9.0 `System.loadLibrary` regression.
It is clean.
`cargo tree -i ring` stays empty on host and on aarch64-linux-android.
Version currency for the three crates that speak core X11: pf-capture's XFixes
cursor source, pf-vdisplay's gamescope splash client, and pf-client-core's
gamescope overlay watcher. Nothing outside the workspace pulls x11rb, so all
three move together and no two versions coexist in the lock.
**The no-libxcb property survives, which is the whole reason those manifests
say `default-features = false`.** 0.14 declares no `default` feature either, so
that flag is still the belt to the braces; `libc` and `as-raw-xcb-connection`
are still optional and still reachable only through `allow-unsafe-code`, and
`dl-libxcb` still requires it too. `cargo tree -e features -i x11rb -p
pf-capture` resolves to exactly `xfixes` -> `render` + `shape` and nothing else,
and neither `as-raw-xcb-connection` nor any other libxcb-linking crate appears
in Cargo.lock. `RustConnection` remains the only connection type, so no host or
client package gains a C dependency.
**One upstream behaviour change does land**, and it is why the `SessionBind` doc
in gamescope.rs moved. 0.14 removed the abstract-unix-socket attempt from
`rust_connection::stream` -- `ConnectAddress::Socket` is now documented as
"Connect to this Unix socket by path" -- so `@/tmp/.X11-unix/X<n>` is no longer
tried ahead of the filesystem path. That doc asserted the ATTACH route's XFixes
cursor reader reached the display over the abstract socket; it now records what
is actually load-bearing. On ATTACH the session belongs to
`gamescope-session-plus`, we arm no bind, its `/tmp` is the real one, and
`punktfunk-host.service` sets no `PrivateTmp` (nor does the NixOS host unit), so
`/tmp/.X11-unix/X<n>` is exactly where `DISPLAY` says it is. The two conditions
that would have needed the abstract fallback still cannot coincide: the bind
only arms for a resolved `punktfunk-gamescope`, whose patch level 2+ makes
`SessionPlan::gamescope_cursor` false and the reader is never spawned. The
splash client is gamescope's own nested child, inside the namespace, and reads
the bound directory directly. If those ever do have to coexist the reader logs
and retries forever and the stream runs without a composited pointer -- the doc
now says so instead of promising a fallback that no longer exists.
The rest of the 0.13.2 -> 0.14.0 delta is inert here: `AtomEnum::CUT_BUFFE_Rn`
was respelled `CUT_BUFFERn` (unused), the optional `raw-window-handle` went
0.5 -> 0.6 and `libloading`'s range widened (both features off), and the MSRV
moved 1.64 -> 1.68 against a 1.96 toolchain.
Verified on .25 (Ubuntu 26.04, `CARGO_BUILD_JOBS=2`), all rc=0:
cargo build -p pf-capture -p pf-client-core -p pf-vdisplay --locked
cargo clippy -p pf-capture -p pf-client-core -p pf-vdisplay --locked -- -D warnings
cargo clippy -p pf-capture -p pf-client-core -p pf-vdisplay --all-targets --locked -- -D warnings
cargo test -p pf-capture --locked 68 passed, 0 failed
cargo test -p pf-vdisplay --locked 210 passed, 0 failed, 3 ignored
cargo check -p punktfunk-host --locked
cargo fmt --all --check
Clippy is run BOTH ways on purpose: host CI lints without `--all-targets`, so a
`#[cfg(test)]`-only import would pass the local run and fail the shipping build.
`cargo tree -i ring` stays empty.
aes 0.9 runtime-detects the ARMv8-Crypto backend on aarch64 via `cpufeatures` and polyval 0.7
picks its armv8 PMULL intrinsics by target_arch, so neither cfg exists any more — passing them
is inert. That retires a real footgun rather than tidying a file: a RUSTFLAGS env var overrides
config rustflags ENTIRELY, so every aarch64 lane that set its own (cargo-ndk does so internally
for every Android arm64-v8a build) silently dropped both and ran SOFTWARE AES on the per-packet
decrypt path.
Measured before deleting, `crypto/open_in_place` (1408-byte MTU shard, AES-128-GCM, single core,
Mac15,14 M3 Ultra, four runs back to back under identical background load):
aes 0.8 + both cfgs 2.19 GiB/s
aes 0.8, cfgs stripped 225 MiB/s ~10x cliff — reproduces the recorded ~240 MiB/s
aes 0.9 + both cfgs 5.28 GiB/s
aes 0.9, cfgs stripped 5.28 GiB/s identical to 4 s.f.
The ChaCha20-Poly1305 series of the same bench was the control and moved 0.07% across the cfg
toggle at both versions, so the toggle demonstrably reached only the AES path. A final run with
the flags actually deleted (not merely RUSTFLAGS-overridden) reproduced 5.29 GiB/s.
.cargo/config.toml is kept as a tombstone carrying that table so the flags are not reintroduced.
The two CI comments that warned about losing these cfgs to a RUSTFLAGS override are updated —
mold in ci/cargo-config-mold.toml is now the only thing such an override can cost.
Twelve skia-safe releases (0.88 … 0.99), carrying Skia milestones 140 through
150, every one of them breaking under 0.x semver. Only three of those changes
actually reach this crate — the Vulkan surface/backend-texture path, the
textlayout/paragraph typography and RuntimeEffect all came through untouched:
* m143 (skia-safe 0.91) DELETED SkPath's mutating API. `Path::new()` followed by
`move_to`/`line_to`/`arc_to`/`close` no longer compiles at all — geometry is
built through `PathBuilder` now and frozen with `snapshot()`/`detach()`. That
is the entire error list: 34 E0599s over eight call sites (the hint-bar
triangles and the PlayStation triangle in `glyphs.rs`, the chevron / space /
backspace / check icons in `widgets.rs`, the padlock shackle in
`screens/home.rs`). Each becomes a `PathBuilder` detached at the draw call, so
the path is still built and thrown away once per draw exactly as before.
* 0.93 deprecated the `gradient_shader` module in favour of `gradient`. Only a
warning, but the Format/clippy gate runs `-D warnings`, so it is a hard break
for us. The three gradients — the panel stroke in `theme.rs`, the
connect-overlay vignette in `shell/overlays.rs`, the host monogram in
`screens/home.rs` — now build a `gradient::Gradient` from
`gradient::Colors::new_evenly_spaced` plus `Interpolation::default()`. That
default (unpremul interpolation, destination colour space, shorter hue) is
what the old `flags: None` argument mapped to, so the pixels do not move. The
new API takes `Color4f` directly, which drops the `.to_color()` 8-bit
round-trip the old signature forced.
* 0.98 deprecated `vk::BackendContext::new` in favour of a builder (upstream
#1292). `skia_overlay.rs` now calls
`BackendContext::new_builder(...)` + `build()`, passing `max_api_version:
None` so Skia keeps deriving its cap from `vkEnumerateInstanceVersion()` —
bit-for-bit what `new()` passed. `build()` is the unsafe half, so the SAFETY
proof moved down onto it.
`ash` is untouched and stays on the workspace's exact `=0.38.0+1.3.281` pin:
skia-safe lists ash only as a DEV dependency, so the bump cannot reach it.
The prebuilt-binary assumption still holds — verified from the build log, not
from the release page: skia-bindings printed `DOWNLOAD AND INSTALL SUCCEEDED`
for
`skia-binaries-a25a0fdb7d90429aa2d1-x86_64-unknown-linux-gnu-jpegd-jpege-pdf-textlayout-vulkan`,
so no CI leg compiles Skia from source. The asset name DID change: `jpeg` joined
skia-safe's default feature set between 0.87 and 0.99, so `jpegd-jpege` is now
in the name. We take defaults, so the JPEG codecs came along — which is a fix in
disguise, since `screens/library.rs` hands host poster art straight to
`Image::from_encoded`, and JPEG posters used to fall out as "undecodable". The
Cargo.toml comment now records the verified asset names and the silent-source-
build trap for the next bump.
Verified on 192.168.1.21, x86_64-unknown-linux-gnu, toolchain 1.96.0:
cargo build -p pf-console-ui exit 0
cargo clippy -p pf-console-ui --all-targets -- -D warnings exit 0
cargo test -p pf-console-ui 82 passed, 1 ignored
cargo fmt --all --check exit 0
These six share the `crypto-common` and `digest` traits, so they move as ONE change — a
partial bump leaves crates on incompatible trait generations that cannot interoperate.
The point is to delete a footgun, not for version hygiene. `aes` 0.8 only enabled ARMv8
hardware AES on aarch64 behind `--cfg aes_armv8`, and `polyval` 0.6 gated its PMULL GHASH
path behind `--cfg polyval_armv8`. A RUSTFLAGS env var OVERRIDES config rustflags
ENTIRELY, so any aarch64 lane that sets its own (cargo-ndk does this internally for every
Android build) silently dropped both and fell back to software AES on the per-packet
decrypt path. `aes` 0.9 runtime-detects via `cpufeatures` on aarch64 and `polyval` 0.7
selects its armv8 intrinsics backend by target_arch, so neither cfg exists any more.
API changes this generation forces:
- `AeadInPlace` -> `AeadInOut`; `{encrypt,decrypt}_in_place_detached` ->
`{encrypt,decrypt}_inout_detached` taking an `InOutBuf`.
- `generic-array` -> `hybrid-array`: `Array::from_slice` is deprecated in favour of the
infallible `&[u8; N] -> &Array<u8, UN>` reference cast, or `TryFrom` for runtime slices.
- `Mac::new_from_slice` moved to `KeyInit::new_from_slice`.
- `BlockEncrypt`/`BlockDecrypt` -> `BlockCipherEncrypt`/`BlockCipherDecrypt`;
`BlockEncryptMut` -> `BlockModeEncrypt`; `encrypt_padded_vec_mut` -> `encrypt_padded_vec`.
`rsa` 0.9 is the one crate that cannot come along: it is built on `digest` 0.10 and its
0.10 line is still release-candidate only, which is not something the Moonlight pairing
ceremony should ride. Its `sha2` feature re-exports the digest its own traits speak, so the
three sites where a digest appears as an `rsa` TYPE PARAMETER (cert.rs, pairing.rs, tls.rs)
now take `rsa::sha2::Sha256` explicitly; everything else in the crate is on sha2 0.11.
The GameStream wire formats are untouched — AES-128-ECB no-padding, the CBC audio path, and
the GCM control-stream seal all keep their exact byte behaviour; only the type plumbing moved.
Version currency for the SCM plumbing behind `punktfunk-host service` (the
dispatcher, control handler and ServiceManager install) and the tray's
unprivileged QUERY_STATUS probe. No code changed in either crate.
The payoff is dependency unification, not the API. `windows-service 0.7` was the
ONLY crate in the workspace still pulling `windows-sys 0.52`, so it alone kept a
fourth windows-sys major compiling. It resolves to 0.8.1, which moves to
`windows-sys 0.61` — a version the tree already builds — and the duplicate
disappears:
cargo tree -d --target x86_64-pc-windows-msvc | grep '^windows-sys v'
before: 0.45.0, 0.52.0, 0.59.0, 0.61.2
after: 0.45.0, 0.59.0, 0.61.2
Note 0.8.0 would NOT have been enough — it lands on windows-sys 0.59. 0.8.1 is
the release that reaches 0.61, hence the `"0.8"` caret plus the comment pinning
the reasoning to the manifest.
The 0.7 -> 0.8 delta is tiny and touches nothing this tree calls: `ServiceAccess`
gains READ_CONTROL / WRITE_DAC / WRITE_OWNER (additive), and `Service::raw_handle`
changes return type from `Security::SC_HANDLE` to `Services::SC_HANDLE` as a
consequence of the windows-sys bump — we never call it. `ScHandle` is crate-private
upstream. No enum gained variants, and the service control handler's match already
ends in a `_ =>` arm, so the `#[non_exhaustive]` types stay safe.
What remains duplicated (deliberately out of scope here): windows-sys 0.45 via
`jni`, and 0.59 via `punktfunk-core` + `if-addrs`.
Version currency for the Android client's only `log` backend. No code change: 0.15 is
almost entirely an internal refactor (the single `lib.rs` split into `config`/`arrays`/
`id`/`platform_log_writer`/`tests`), and the surface this crate uses — `init_once`,
`Config::default`, `with_max_level`, `with_tag` — is untouched. The lockfile delta is the
version and checksum alone: no dependency was added, removed or re-resolved, and the
third-party crate count stays at 582.
The one thing 0.15 adds that we must NOT take is recorded next to the dependency: the new
opt-in `android-api-30` feature filters levels through `__android_log_is_loggable_len` so
logcat's `setprop log.tag.*` overrides are honoured, but it HARD-LINKS that API-30 symbol.
Against our minSdk-28 floor that is a `System.loadLibrary` failure on Android 9/10 — the
identical shape of the ndk 0.9.0 `AMediaCodec_setOnFrameRenderedCallback` break the manifest
already warns about a few lines further down. Default features keep it off; the comment
explains why so nobody "completes" the upgrade by enabling it.
Verified with cargo-ndk (NDK 30.0.14904198), which is the only way to exercise this crate at
all — `android_logger` sits behind `cfg(target_os = "android")`, so the host workspace build
never compiles it:
cargo ndk -t arm64-v8a check -p punktfunk-client-android → ok
cargo ndk -t armeabi-v7a check -p punktfunk-client-android → ok
Checked, not built: these are `cargo check` runs, not a linked `.so` and not an APK.
Version currency for the crate behind the host's WASAPI loopback capture and
virtual mic, and the Windows client's render/capture path. No behavior change.
The 0.23 -> 0.24 API delta is almost entirely additive (device-change
notification callbacks, `AudioMeterInformation`, `HardwareSupport`, `DeviceState:
Clone + Copy`). The single removal is `AudioClient::get_bufferframecount`,
deprecated since 0.17 in favour of `get_buffer_size` — this tree never called it,
so no call site moved.
0.24 also fixes upstream the dangling-`PCWSTR` bug this tree routes around in
five places: `DeviceEnumerator::get_device` used to build its argument as
`PCWSTR::from_raw(HSTRING::from(id).as_ptr())`, dropping the `HSTRING` at the end
of that statement so `GetDevice` read freed memory. Those five comments asserted
the bug in the PRESENT tense and are now wrong, so they are corrected here rather
than left to mislead. The workarounds themselves STAY: `open_wasapi_device` is
still the one resolution path whose errors name the endpoint id and whose
`IMMDevice` `probe_activation` needs, and `device_by_id` additionally filters to
ACTIVE endpoints (`EnumAudioEndpoints(dir, DEVICE_STATE_ACTIVE)`), which the
crate's `get_device` does not. Removing them is a behavior change, not currency.
⚠ This does NOT collapse the duplicate windows-rs. wasapi 0.24 still depends on
`windows ^0.62` / `windows-core ^0.62` exactly as 0.23 did, so the crates.io
`windows 0.62.2` still sits alongside the pinned git copy that `clients/windows`
uses. That duplicate costs build time and binary size, not correctness, and the
blanket `[patch.crates-io] windows` that would collapse it stays ruled out — the
pinned rev uses header-named features while a dozen manifests still use the old
`Win32_*` namespace features.
PR #192 moved the rustls backend to aws-lc-rs and merged before CI reported.
Two of the things it changed here shipped with no assertion behind them.
`generate()` mints the RSA-2048 host identity and runs ONLY when no cert
exists, so no upgraded box ever re-executes it — a fresh install is the
only thing that would have found a regression. It was reached by other
tests via `ServerIdentity::ephemeral()`, but purely as a fixture: nothing
checked that what came back was still RSA-2048, which is the one property
Moonlight requires. The handshake behaviour had no coverage at all, and
the GameStream TLS path is the single place a legacy peer meets the new
backend.
Three tests:
- generate_mints_a_loadable_rsa2048_identity — the fresh-install path,
asserting the cert signature is 256 bytes (RSA-2048) rather than
depending on an `rsa` accessor that could change shape.
- moonlight_shaped_peer_completes_a_tls12_mutual_handshake — a peer that
pins out of band, as Moonlight does, presenting an RSA-2048 client cert
against the real `tls::server_config`.
- tls13_negotiates_the_post_quantum_group — pins X25519MLKEM768 by name,
so a provider or feature regression that silently drops ML-KEM back to
a classical curve fails here instead of in the field.
Also corrects the comment on `generate()`. It opened by asserting the
workspace is ring-only because aws-lc-sys breaks Windows CI, and explained
that rcgen's *ring* backend is what loads the RSA key. Both are now false:
rcgen is on aws_lc_rs and loads and self-signs the key fine — verified, not
assumed. rcgen still cannot GENERATE an RSA key on either backend, which is
the part of the comment that remains true and load-bearing.
Verified on Linux (Ubuntu 26.04, x86_64): 3/3 pass, clippy clean both with
and without --all-targets (host CI lints without it), and the native-only
`--no-default-features --features pyrowave` build still checks clean — the
whole module is gamestream-gated, so it compiles out there.
THIRD-PARTY-NOTICES regenerated after the dependency changes (582 crates).
audit.yml's header claimed to cover "EVERY dependency tree the project ships"; it now
actually does, so the note spells out that each Rust lockfile needs its own `--file` —
a bare `cargo audit` reads only the root one, which is how the drivers lock stayed
unscanned while already sitting in this job's `paths:` filter. Also corrected "BOTH
Rust workspaces" for the licence gate, which covers the host + driver workspaces.
Both cargo-about legs re-run after the dependency removals: RC=0.
Acting on the 2026-08-13 dependency sweep. Every claim below was re-verified against
the tree before acting on it (greps carry a positive control; the advisories were
re-checked with cargo audit 0.22.2).
SECURITY
- event-listener 5.4.1 -> 5.4.2 (RUSTSEC-2026-0221, unsound Send/Sync on StackSlot;
reaches the tray via zbus and the host via ashpd). This sat unnoticed because
`cargo audit` reports unsoundness as a WARNING and the job fails only on
vulnerabilities — audit.toml now says so out loud.
- spin 0.9.8 -> 0.9.9. 0.9.8 is YANKED and was genuinely compiled (flume via mdns-sd
and relm4, plus lazy_static).
- wayland-scanner 0.31.10 -> 0.31.11, which moves quick-xml 0.39 -> 0.41. That is the
exact trigger audit.toml documented for RUSTSEC-2026-0194/0195, so both ignores are
deleted rather than left as permanent exceptions. Only RUSTSEC-2023-0071 (rsa
Marvin, still unfixed upstream) remains.
- Corrected audit.toml's claim that `paste` arrives "via utoipa-axum": rav1d pulls it
too, so every client has it through the decode path and dropping utoipa-axum would
not have cleared it.
TWO CI GATES THAT SCANNED NOTHING
- `cargo audit` only ever reads the ROOT Cargo.lock. The drivers lock was already in
this job's `paths:` filter, so edits to it triggered a run that then ignored them.
All four secondary workspaces now get an explicit `--file` (verified: clean, bar the
known `paste` warning in drivers).
- packaging/windows/pf-vkhdr-layer had NO lockfile at all while shipping as a DLL in
the host installer, so every build resolved fresh and neither cargo-audit nor
cargo-about ever saw it. Lockfile generated and committed, and added to `paths:`.
UNUSED / DUPLICATE DECLARATIONS
- punktfunk-host: removed 13 dependencies it never references — the Wayland stack
(client, protocols{,-wlr,-misc}, scanner, backend), xkbcommon, reis, khronos-egl,
ash, usbip-sim, parking_lot, bytemuck. The code moved to pf-inject and pf-zerocopy
in the subsystem extraction and those crates declare them; only the manifest entries
and their now-false comments stayed. Also dropped four redundant re-declarations
(tokio/serde_json/futures-util in the Linux block, tower in dev-deps).
- Removed genuinely unused: bytes (punktfunk-core), anyhow (pf-win-display),
tracing (clients/cli), anyhow (clients/session), serde (clients/windows).
- Removed the high-level `wdk` crate from all five driver crates and the drivers
workspace: none of them ever referenced `wdk::` (62 `wdk_sys::` uses; pf-umdf-util
is a full WDF crate that never declared it). `tracing`/`tracing-subscriber` remain
in that lock afterwards but ONLY as wdk-sys build-dependencies, not in the DLLs.
- pf-win-display took punktfunk-core with `quic` for one type (`Mode`) that lives in
the ungated `config` module; now `default-features = false`, which keeps
quinn/tokio/rcgen/opus out of a leaf crate's declared closure.
- pf-encode declared the windows-rs feature `Wdk_Graphics_Direct3D` for a call that
lives in pf-frame and is resolved via GetProcAddress on gdi32.
LATENT BREAKAGE (compiled only by feature unification)
- pf-inject uses `tokio::select!` without declaring `macros` (borrowed from
punktfunk-core's quic feature); pf-capture uses `tokio::sync::oneshot` without
declaring `sync` (borrowed from ashpd->zbus); pf-client-core uses the `minwindef`
and `winnt` windows-rs headers without declaring them (borrowed from
clients/windows). Each now declares what it uses, so an unrelated crate changing its
features cannot break them.
- pf-console-ui took pf-client-core WITHOUT `default-features = false`, unlike every
other consumer. That default is `pyrowave`, which compiles the vendored PyroWave C++
— "fatal on Windows ARM64". Only safe today because the ARM64 leg passes
--no-default-features (which also drops `ui`).
CORRECTED A FALSE INVARIANT
- clients/windows claimed "the workspace builds ONE windows-rs". It does not: wasapi
pulls the crates.io windows 0.62.2 beside the git-rev copy. The invariant that DOES
hold is narrower (reactor and that crate share one rev, which is what makes the
IDXGISwapChain1 hand-off type-check). Comment rewritten, with a warning against
"fixing" it via a blanket [patch.crates-io] — this rev uses header-named features
while a dozen other manifests use the old Win32_* namespace ones.
Plus the safe in-compat `cargo update` sweep (no manifest edits).
Verified on macOS: punktfunk-core 385, pf-update-check 32, c_abi 1 (with
LIBRARY_PATH=/opt/homebrew/opt/opus/lib), cargo audit clean bar the two known
unmaintained warnings. Linux and Windows legs follow.
Both failures found running the store + plugin-launch tests on the Windows runner
after the ureq 3 port. Neither is a production defect — the request/response round
trip and the 304 semantics both hold — but both tests were resting on assumptions
that ureq 2 happened to tolerate.
catalog::ureq_returns_304_as_ok: the stub answered without ever reading the request.
Closing a socket that still holds unread received data makes Windows send an RST
rather than a FIN, which discards the response already written, so the client saw a
transport error (os error 10053) instead of the 304 the test exists to pin. The stub
now drains the request first. The pinned behaviour is unchanged and still true:
ureq 3 turns only `is_client_error() || is_server_error()` into Err, so 304 arrives
as Ok exactly as before.
plugin_launch::asks_the_registered_plugin_and_takes_its_answer: hardcoded a cwd of
`/opt/emu`, which has no drive letter and is therefore NOT `Path::is_absolute` on
Windows, so `validate_reply` refused the recipe. This test could never have passed
on Windows, with either ureq version — its sibling
`a_working_directory_must_be_absolute` already had the `cfg!(windows)` split and this
one was simply missed. Confirmed by diagnostic before touching it: the body came back
over ureq 3 byte-perfect, so everything up to validation was working.
`about.toml` carried `OpenSSL` in the global accepted list and a `[ring]` per-crate
acceptance, both there solely because ring's licence is an AND that includes the
OpenSSL terms. The ureq 2 -> 3 upgrade removed ring from every target we build, and
aws-lc-sys 0.44's SPDX (ISC AND (Apache-2.0 OR ISC) AND Apache-2.0 AND MIT AND
BSD-3-Clause AND ... MIT-0) carries no OpenSSL clause, so neither entry has anything
left to permit.
Note about.toml sets no `targets`, so cargo-about still walks quinn-proto's wasm-only
ring edge — removing the exception is safe not because ring is invisible but because
ring 0.17.14 declares `Apache-2.0 AND ISC`, and both are globally accepted already.
Verified by running the gate itself, both legs of what audit.yml runs:
cargo about generate about.hbs --fail -> 0
cargo about generate -m packaging/windows/drivers/Cargo.toml -c ... -> 0
and proved non-vacuous with a negative control: dropping "ISC" from the accepted
list makes the first leg exit 1.
THIRD-PARTY-NOTICES regenerated: 601 -> 580 crates (ureq 3 pulls a smaller tree than
ureq 2 + ring), now listing ureq 3.4.0.
The aws-lc-rs move left `ring` compiled in for one reason: ureq 2 names
`features = ["ring", ...]` inside its own `[dependencies.rustls]` block, and cargo
features are additive, so no dependent could switch it off. ureq 3 declares rustls
with `default-features = false` and selects no backend, which finally makes the
choice ours.
`cargo tree -i ring` is now empty for macOS, Windows and Linux. The one remaining
hit under `--target all` is quinn-proto's wasm-only dependency
(`cfg(all(target_family = "wasm", target_os = "unknown"))`), a target we never build.
⚠ The dependency must be spelled `features = ["rustls-no-provider",
"rustls-webpki-roots"]`. ureq 3's convenience `rustls` feature expands to include
`_ring` — the same shape of trap as rustls's own `features = ["ring"]`, and the
reason punktfunk-webos still carries both backends today.
Ported 9 files. The two pinning call sites (the desktop client's library fetch and
the tray's status poll) needed a custom ureq Connector, because ureq 3's `TlsConfig`
exposes roots/client-cert/off-switch but no hook for a custom ServerCertVerifier.
That glue lives once in punktfunk-core behind a new off-by-default `ureq-tls`
feature rather than being hand-rolled twice on a trust boundary; the Apple/Android
cdylib embedders do not enable it and pull no HTTP stack. The connector is modelled
on ureq's own crate-private RustlsConnector and is transport glue only.
Two behaviour changes worth noting, both improvements:
- Body caps are enforced by the reader, so an over-cap response is now an Err rather
than ureq 2's silent truncation — which used to surface as a signature failure
pointing at the wrong thing.
- A pin mismatch matches ureq 3's typed `Error::Rustls(..)` instead of sniffing a
substring out of a transport message, which could also fire on unrelated cert
errors.
Conditional requests are unchanged: 304 still arrives as Ok (only 4xx/5xx are Err),
which the existing `ureq_returns_304_as_ok` socket test still pins.
Also removed four now-dead `std::io::Read` imports. One of them (plugin_launch) is
still needed by its test module, so it moved there rather than being deleted: host
CI lints without `--all-targets`, so a top-level import used only under cfg(test)
fails the shipping build.
Verified on macOS: punktfunk-core (quic + ureq-tls) checks clean, pf-update-check
32/32, cargo fmt clean.
The workspace pinned `ring` everywhere because aws-lc-sys 0.41.0 failed to C-compile
on the Windows CI runner. Re-tested on that runner (.133) with aws-lc-sys 0.44.0: the
`params.c` cl.exe failure does not reproduce under MSVC 14.44, and rustls's `aws_lc_rs`
feature turns on `aws-lc-rs/prebuilt-nasm`, so no NASM is needed on the box either.
That unblocks post-quantum TLS: `prefer-post-quantum` offers X25519MLKEM768 first on
every TLS 1.3 handshake (mgmt API, native control plane, QUIC), which ring cannot do —
it has no ML-KEM. Classical curves stay in the list, so older clients still connect.
rustls, quinn, rcgen and tokio-rustls each select a backend independently, so all four
had to move together; a single dissenter pulls a second crypto stack in via feature
unification. The direct `ring` users (ed25519 in pf-update-check, SHA-256 in the Windows
updater) moved to aws-lc-rs, whose API is ring-compatible.
`ring` does NOT leave the tree: ureq 2 names `features = ["ring"]` in its own rustls
dependency line and cargo features are additive, so no dependent can switch it off. Two
backends compiled in means rustls refuses to infer one, and anything built via
`ClientConfig::builder()` panics instead of picking — which is what ureq's default agent
does on its first HTTPS request. `tls::install_default_provider()` makes the choice
explicit; it runs at each binary's entry point and defensively in pf-client-core, which
several binaries link. Dropping ring entirely needs the ureq 2 -> 3 upgrade (36 call
sites), deliberately left out of this change.
Verified on macOS: pf-update-check 32, punktfunk-core 385, c_abi 1 (the last with
LIBRARY_PATH=/opt/homebrew/opt/opus/lib) — aws-lc-sys links into the C ABI harness, so
the Swift/Kotlin embedders keep working. cargo fmt --all --check clean.
Two merges, both of which exist to express an ordering Gitea cannot express across
files, and both of which delete a duplicated build.
release.yml -> apple.yml (as the `distribute` job)
The name described neither what it did (Apple only — every other platform's release
is its own packaging workflow attaching to the same Gitea release on a v* tag, with
announce.yml as the manual "go") nor anything a reader would guess. The name was the
smaller problem. Gitea has no cross-workflow `needs`, so nothing sequenced it against
apple.yml's tests: a canary main push uploaded iOS, macOS and tvOS builds to
TestFlight even when `swift test` had just failed on that same commit. It is now
`needs: swift`, which is only expressible in one file.
The two files' paths: filters had also drifted — apple.yml watched crates/**,
release.yml watched crates/punktfunk-core/**. The merged filter takes the NARROW one,
because that is the correct one: everything on this runner is built from
punktfunk-core via build-xcframework.sh, and punktfunk-core's only path dependency is
its own vendored fec-rs. That is checkable in one command, and the header says so, and
says to widen it if that ever stops being true. Net effect on the shared mac mini:
pushes that touch host-side crates no longer build or upload anything Apple.
windows.yml + windows-msix.yml -> windows-client.yml
The pair built the same three crates FOUR times per client push on ONE runner: debug
x64 + arm64 for lint/test, release x64 + arm64 for packaging. windows-host.yml already
records why a second (debug) dep tree on this machine is a liability rather than a
cost — it re-runs openh264-sys2's vendored C++ through cc-rs's cl.exe fan-out and tips
the runner into C1069, which is disk exhaustion wearing a compiler error's clothes. So
there is one release build per arch now and clippy/fmt/test run against it, exactly as
windows-host.yml does. The paths list went from three copies to one; PRs get the
build/lint/test signal and stop before packaging.
The rename is safe, and this is worth recording because the GitHub instinct is wrong
here: `github.run_number` is REPO-WIDE in Gitea, not per-workflow — consecutive runs of
DIFFERENT workflows get consecutive numbers (verified against the API: android 13226,
apple 13227, arch 13228, ci 13229, deb 13230). The canary MSIX version <minor>.<run>.0
and Apple's CURRENT_PROJECT_VERSION therefore keep climbing across a rename. On GitHub
the same rename would reset both to 1, sorting every new canary below the published ones
and getting the TestFlight uploads rejected outright.
25 workflows, down from 27, and every `name:` now matches its filename. Cross-references
in windows-host.yml, windows-drivers.yml, android.yml, flatpak.yml, sbom.yml, the
provisioning scripts, gitea-release.sh and clients/windows/packaging/README.md updated.
The Nitro console bundle is a pure function of web/ and sdk/, and it was being built
six times on every push: ci.yml, deb, both RPM legs (f43 + f44), arch, and the docker
app image, at roughly 2.5 min each. windows-host.yml has cached it on exactly this
shape for a while — this extends the same arrangement to the Linux packaging legs,
sharing one key family so whichever job builds it first warms the others.
The bun version is part of the key. Each builder image runs the bun.sh installer at
image-build time, so rust-ci, fedora-rpm and arch-ci can drift apart; keying on it
means they share while they agree and simply stop sharing when they do not, rather
than one image's bun silently producing the bundle another image ships.
Each packaging path needed a different hand-off:
* deb — build-web-deb.sh already builds only if web/.output is missing, so the
restore alone is enough; the workflow's build+smoke step is now gated on
the miss.
* arch — makepkg builds with PF_SRCDIR pointing at the workspace, so a restored
bundle is already where it needs to be. PKGBUILD gains the same
build-if-missing guard the deb script has.
* rpm — neither direction works by default. build-rpm.sh packages a `git archive`
tarball and web/.output is gitignored, so a bundle in the workspace is
invisible to rpmbuild; and the spec's own build lands in rpmbuild's
%{_topdir}, which build-rpm.sh mktemps and removes on EXIT, so a console
built there is gone before the cache's post step and the cache would never
populate — every run a miss that quietly rebuilt. So the workflow builds it,
and hands it over by absolute path through a new optional `pf_prebuilt_web`
macro. Undefined (plain rpmbuild, COPR) takes the original build path.
Every path asserts the bundle exists and carries the Bun.serve marker, on cache hits
too. A cache is one more place a wrong artifact can come from, and the packaging
scripts' build-if-missing behaviour — correct for a local build — would otherwise turn
a broken restore into either a silent rebuild or, with the build step skipped, a
package with no console in it. That is not hypothetical: windows-host.yml shipped
0.22.1 and 0.22.2 with no console because an unset path variable was handled by a
single Write-Host, which is why its equivalent step throws.
Three independent reasons Rust CI stayed slow despite sccache, fixed together because
they share the same measurement.
1. sccache only ever covered RUSTC. Every C/C++ dependency in the tree — aws-lc-sys,
openh264-sys2's vendored C++, the CMake-built libopus behind audiopus_sys — was
compiled from scratch on every job of every workflow. CMAKE_{C,CXX}_COMPILER_LAUNCHER
plus CC_/CXX_x86_64_unknown_linux_gnu route both build-script styles (cc-rs and
cmake-rs) through the same shared cache.
The CC_* vars are JOB-scoped in ci.yml and deb.yml, never workflow-scoped: the
arm64 cross image sets its own CC_x86_64_unknown_linux_gnu=pf-host-cc, the wrapper
that keeps ffmpeg-sys-next's host probe off the arm64 include dirs. Overwriting it
would surface as a header mismatch rather than as a CI config error.
2. Linking is cacheable by nothing, and these jobs relink the host, client, session,
cli, worker and tray on every run — twice per push for rpm (f43 + f44). The four
Linux builder images now install mold and carry a $CARGO_HOME/config.toml that uses
it for x86_64. aarch64 is deliberately left alone (cross driver, already-fast legs).
Each image asserts `mold --version` in its build, so an image can never ship the
flag without the linker: docker.yml goes red and :latest stays on the last good one.
3. THE EXPENSIVE ONE. ci.yml (debug) and deb.yml (release) named a byte-identical
target-cache key, under a comment claiming the release build reused ci.yml's
artifacts. It never could. actions/cache is first-saver-wins on an exact key and
ci.yml is the faster job, so the shared key always held a debug-only target/ — and,
worse, deb.yml could then never save its own, because the key was taken. Every
canary .deb has been a from-scratch release build for as long as both keys existed.
Same collision on the arm64 pair, and a third participant in
linux-client-screenshots.yml. Split into -debug-/-release- key families; that job
reads deb's tree via restore-keys but keeps its own exact key so it can never win
the save race and replace a full tree with its single-crate one.
Also: one scripts/ci/ensure-sccache.sh replaces ten copy-pasted bootstrap blocks that
had already drifted into two dialects (GNU tar --wildcards vs bsdtar), every Rust job
now ends with --show-stats so a cache regression is visible instead of just "CI got
slower", and deb.yml's web install joins every other CI install on --ignore-scripts.
No behaviour change to any artifact: same compilers, same flags, same outputs.
Field report: "on Bazzite when using gaming mode it is mirroring the main display
instead of giving the client its own." It is our own template that does it.
`packaging/bazzite/host.env` set `PUNKTFUNK_GAMESCOPE_ATTACH=1`, and every install
path — rpm, deb, Arch, nix — ships that file as `/usr/share/punktfunk/host.env.bazzite`
with the docs telling people to copy it verbatim. So the recommended Bazzite setup
turned the attach override ON for everyone.
That override is rung 2 of `pick_gamescope_mode`, ABOVE `dedicated_launch` at rung 3.
The rung comment calls the operator overrides a debug/CI escape hatch, which is right —
but we were shipping one as a distro default, so on a Bazzite box the managed takeover
and the dedicated game session were both unreachable. A game launched from a client's
library could not get a session of its own either, which is the case the dedicated
route exists for. With a physical display connected, attach then takes the
`physical_display_connected()` arm and streams the box's own head at the box's own
mode: the mirror the reporter saw.
The template now forces nothing and lets the per-connect detection answer, which on a
box with `gamescope-session-plus` is MANAGED. Attach stays available, documented as the
opt-in it is, with the mirror and the dedicated-session cost stated. Because managed
depends on the `punktfunk` group to stop the display manager, the template now says so
where someone choosing a model will read it, rather than only in the distro guide.
Also fixes the off-switch. Both overrides were read with `var_os(..).is_some()`, so
`PUNKTFUNK_GAMESCOPE_ATTACH=0` meant ATTACH ON — the opposite of what the line says,
and of every other knob on this host. They now use the shared `env_on` grammar, so
`0|false|off|no` disable and a bare `=1` keeps working. Anyone who "turned attach off"
in an older host.env had it on the whole time.
Note an upgrade never rewrites an existing `~/.config/punktfunk/host.env`, so boxes set
up from an older template keep the pin until the line is deleted by hand; the Bazzite
and HDR pages now say that.
Verified: `scripts/xcheck.sh linux` check + clippy `-D warnings` clean, pf-vdisplay
206/0 under rust:1.96, `cargo fmt --all --check` clean. Gate proved non-vacuous against
a planted `compile_error!` in routing.rs.
The raw-dmabuf passthrough handed the SPA buffer back to gamescope at
.process return while the encode thread had not yet imported - let alone
read - its dmabuf, and nothing ordered the producer's writes against the
consumer's read (no explicit sync; the implicit-fence wait measures
NoFence on every compositor x vendor pairing we have). On the direct-VCN
arms (native NV12, RGB-direct EFC) the captured buffer IS the encode
source for the whole ring-2-deep encode plus the phase-lock hold, so at
120 fps gamescope cycles back into the buffer mid-encode and the stream
ships torn frames: luma/chroma desync (magenta tint) plus block
corruption propagating through the P-chain until the next intra. Field
report: Nobara, gamescope mode at 120 fps - KDE sessions were clean
because cursor_blend routes them to the compute-CSC copy arm whose read
window is microseconds.
The fix defers the requeue: a published passthrough frame carries a
FrameHold (new on DmabufFrame), and the buffer rejoins the producer's
pool only when the last clone drops. The Vulkan encoder clones the hold
into the ring slot at submit and releases it when the slot's fence
retires (poll/backpressure/reset), extending "the producer must not
rewrite this" across exactly the GPU read. The host loop's repeat path
is fixed by the same mechanism: a re-submitted frame now aliases a
buffer the producer never got back, instead of whatever gamescope last
composited into it.
Bookkeeping lives in a per-stream HoldBook (loop-thread mutations only):
holds release through a pw channel onto the loop thread, a generation
tag keeps a stale release from requeueing a renegotiated pool's reused
address, and at most pool_depth - HOLD_POOL_RESERVE buffers are ever
withheld - a pool at the old floor of 2 cannot spare any and falls back
(with one warn) to the previous racy contract. PUNKTFUNK_ZEROCOPY_HOLD=0
restores the old behavior outright for field bisects.
Gates (.25): cargo check + clippy --all-targets -D warnings on
pf-frame/pf-capture/pf-encode/punktfunk-host; pf-capture 68/68 (4 new
HoldBook tests), pf-encode 75/75 (+15 ignored, host-feature set);
workspace cargo fmt --check clean. punktfunk-host's
hooks::prep_runs_do_in_order_and_undo_in_reverse fails on that box on
pristine main too (pre-existing; crate untouched here).
2026-08-13 09:00:23 +02:00
423 changed files with 40672 additions and 13586 deletions
# Single project version: a `vX.Y.Z` tag is THE release (publishes to Play `production` at
# 100% + attaches the .aab/.apk to the unified Gitea Release). A main push is canary
# (Play `internal`). Production access was granted 2026-08-01; before that a tag could only
# reach `alpha` and someone had to promote it by hand in the Console.
# (Play `beta` = open testing: public opt-in, no tester list — but unlike the previous
# `internal` target, every canary now passes Google review before testers see it, so a
# canary lands in hours/days, not minutes). The same canary versionCode is also assigned
# to `alpha` (closed testing) in the same Play edit, so the pre-production-access closed
# testers keep receiving builds without re-opting-in. Production access was granted
# 2026-08-01; before that a tag could only reach `alpha` and someone had to promote it
# by hand in the Console.
tags:['v*']
pull_request:
paths:
@@ -51,7 +56,21 @@ on:
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/android.yml'
# Manual runs are BUILD-ONLY by default. The escape hatch below exists because a push run can
# go missing entirely: merge two PRs seconds apart and Gitea attributes the window's runs to the
# newer head, so the older merge sha gets no run at all — its android change then sits on main
# having never been built, let alone published (2026-08-14: `1e5dca4c`, PR #235, lost its run to
# `b5cace3a` 12 s later). Re-running the PR run does NOT recover it: a re-run replays the original
# `pull_request` event, so every gate below stays false. Only a dispatch with publish=true can
# ship that commit without inventing a filler push.
workflow_dispatch:
inputs:
publish:
# String, not a boolean: matches apple.yml's `testflight` input, which is the form proven
# to evaluate correctly on this Gitea. Compared as `inputs.publish == 'true'` below.
description:"Also publish this build (registry + Google Play). main -> beta+alpha, vX.Y.Z tag -> production at 100%. Default false: a stray click must not reach testers."
echo "embedded Developer ID profile: $PROFILE_SRC"
else
# Fallback so a missing/expired profile NEVER reships the errno-163 brick: drop the
# managed entitlement and let ClientIdentityStore fall back to the legacy file keychain
# (its errSecMissingEntitlement path). Degraded (one Keychain prompt) but launchable.
echo "::warning::Developer ID profile '$DEVID_PROFILE_NAME' not installed on the runner — stripping keychain-access-groups so the DMG still launches (legacy file keychain). Create it in the Apple portal + install it on the runner to restore the no-prompt data-protection keychain."
echo "embedded Developer ID profile: $PROFILE_SRC"
else
# Fallback so a missing/expired profile NEVER reships the errno-163 brick: drop the
# managed entitlement and let ClientIdentityStore fall back to the legacy file keychain
# (its errSecMissingEntitlement path). Degraded (one Keychain prompt) but launchable.
echo "::warning::Developer ID profile '$DEVID_PROFILE_NAME' not installed on the runner — stripping keychain-access-groups so the DMG still launches (legacy file keychain). Create it in the Apple portal + install it on the runner to restore the no-prompt data-protection keychain."
"description":"The collection form of [`unpair_client`]: empties the pairing store in ONE persisted write,\ncarrying the same revocation guarantees across the whole set. A LIVE GameStream session is\nended (its owning certificate is necessarily one of those just removed), and the ENet control\nport (UDP 47999) closes, because no pairing is left to hold it open.\n\nIdempotent, and so a 200 rather than the single unpair's 204/404 pair: \"unpair everything\" is\nsatisfied by an already-empty store, and the operator still wants to know whether that meant\nthree devices or none.",
"description":"Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries agiven external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).\n\n**The operator's own lane additionally sees the titles they have HIDDEN**, each carrying\n`hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The\nconsole needs them to offer \"un-hide\", and it is the only surface that does.",
"description":"Every title this host knows about, sorted by title: the entries each installed library plugin\nhas synced (Steam, Lutris, Heroic, Epic, GOG, Xbox, Playnite, ROM managers, …) plus the user's\nown custom entries. Artwork fields are URLs the clientfetches directly, except local files on\nthe host, which are rewritten to this API's own art proxy. `?provider=` narrows to theentries a\ngiven external provider owns; `?platform=` to one platform (case-insensitive — whatever the\nsource authored, conventionally `PC` for desktop stores).\n\n**The operator's own lane additionally sees the titles they have HIDDEN**, each carrying\n`hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The\nconsole needs them to offer \"un-hide\", and it is the only surface that does.",
"operationId":"getLibrary",
"parameters":[
{
@@ -1052,7 +1082,7 @@
"library"
],
"summary":"Fetch one cover-art image for a library entry",
"description":"Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,\nand a library plugin's claimed-store entries) serves its local art file. A Steam title falls back\nto the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the\nuser's Steam client already shows for it), the public Steam CDN's flat URL convention second\n(newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case\nthis 404s and the client falls through to its next art candidate).",
"description":"Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,\nand a library plugin's claimed-store entries) serves its local art file; anything else 404s and\nthe client falls through to its next art candidate.\n\nThe host fetches nothing here. Art a plugin published as an `http(s)` URL is fetched by the\nclient directly — this proxy exists for the *local* files a plugin finds on the host's own disk\n(a launcher's cover cache), which a client has no way to read.",
"operationId":"getLibraryArt",
"parameters":[
{
@@ -1380,7 +1410,7 @@
"library"
],
"summary":"Replace a provider's library entries (declarative reconcile)",
"description":"Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.\n\n`?store=` additionally **claims** that store for the provider: its entries then surface with\ndeterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque\n`custom:<id>` ones — which is what lets a library plugin reproduce the entries an in-host scanner\nused to produce, right down to the GameStream app ids and client-side art caches. One provider\nper store; a second claimant gets 409. While a claim is held the matching built-in scanner is\nsuppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty\nreconcile (a store can legitimatelyhave zero installed titles).",
"description":"Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.\n\n`?store=` additionally **claims** that store for the provider: its entries then surface with\ndeterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque\n`custom:<id>` ones — which is what let a library plugin reproduce the entries the in-host scanner\nused to produce, right down to the GameStream app ids and client-side art caches, and is why\nremoving those scanners changed nothing downstream. One provider per store; a second claimant\ngets 409. The claim is released by `DELETE`, not by an emptyreconcile (a store can legitimately\nhave zero installed titles).",
"operationId":"reconcileProviderEntries",
"parameters":[
{
@@ -1538,8 +1568,8 @@
"tags":[
"library"
],
"summary":"List the library scanners",
"description":"The installed-store scanners this host supports — the list is platform-dependent (Steam\neverywhere; Lutris + Heroic on Linux; Epic, GOG, and Xbox/Game Pass on Windows), so the console\nrenders a toggle only for scanners that can do anything here. Scanners default to enabled;\ndisabling one hides its titles from every library surface from the nextread. The user-curated\ncustom store is not a scanner and is always on.",
"summary":"List the library sources",
"description":"Every game source on this host with its enable state — one row per installed library plugin\n(Steam, Lutris, Heroic, Epic, GOG, Xbox, Playnite, ROM managers, …), so the list reflects what\nthe operator has actually installed rather than what this build happens to support. Sources\ndefault to enabled;disabling one hides its titles from every library surface from the next\nread. The user-curatedcustom store is not a source and is always on.\n\nOlder hosts (≤ v0.27.x) also listed the six scanners built into the host binary, with\n`origin: \"builtin\"`. Those are gone; every row now reports `origin: \"plugin\"`.",
"operationId":"listLibraryScanners",
"responses":{
"200":{
@@ -1573,8 +1603,8 @@
"tags":[
"library"
],
"summary":"Enable or disable a library scanner",
"description":"Persists the toggle and applies it from the next library read (no restart). Disabling a scanner\nhides its titles everywhere — the console grid, native clients, and the GameStream app list —\nand re-enabling brings them straight back (nothing is deleted; the scan just runs again). Emits\n`library.changed` withthe scanner id as `source` when the state changed.",
"summary":"Enable or disable a library source",
"description":"Persists the toggle and applies it from the next library read (no restart). Disabling a source\nhides its titles everywhere — the console grid, native clients, and the GameStream app list —\nand re-enabling brings them straight back. Nothing is deleted: the plugin may keep reconciling\nwhile its source is off, and those entries simply aren't surfaced. Emits`library.changed` with\nthe source id as `source` when the state changed.",
"operationId":"setLibraryScanner",
"parameters":[
{
@@ -1767,6 +1797,56 @@
}
}
}
},
"delete":{
"tags":[
"native"
],
"summary":"Unpair every native client",
"description":"The collection form of [`unpair_native_client`]: empties the punktfunk/1 trust store in ONE\npersisted write (not a loop of them — a failure partway would leave a half-emptied store), and\nends every live native session the removed clients own.\n\nIdempotent, hence a 200 rather than the single unpair's 204/404: an already-empty store\nsatisfies the request, and the count still tells the operator what it meant.",
"description":"Could not persist the trust store",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ApiError"
}
}
}
},
"503":{
"description":"Native host not enabled",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/native/clients/{fingerprint}":{
@@ -1823,6 +1903,97 @@
}
}
}
},
"patch":{
"tags":[
"native"
],
"summary":"Update a native client's access",
"description":"Partial edit of a paired device's grants/expiry (the console edit sheet: preset change,\nextend, \"expire now\", make permanent). Omitted fields keep their current value; the edit\nreaches the device's live sessions immediately. Not a way to pair a device (404 when the\nfingerprint isn't in the trust store).",
"operationId":"updateNativeClientAccess",
"parameters":[
{
"name":"fingerprint",
"in":"path",
"description":"Hex SHA-256 of the client certificate (case-insensitive)",
"required":true,
"schema":{
"type":"string"
}
}
],
"requestBody":{
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/UpdateNativeAccess"
}
}
},
"required":true
},
"responses":{
"200":{
"description":"Access updated; the stored record as now in force",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/NativeClient"
}
}
}
},
"400":{
"description":"Reserved grant bits set, or expires_in_secs together with clear_expiry",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ApiError"
}
}
}
},
"401":{
"description":"Missing or invalid bearer token",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ApiError"
}
}
}
},
"404":{
"description":"No paired native client with that fingerprint",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ApiError"
}
}
}
},
"500":{
"description":"Could not persist the trust store",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ApiError"
}
}
}
},
"503":{
"description":"Native host not enabled",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/native/pair":{
@@ -1896,7 +2067,7 @@
"native"
],
"summary":"Arm native pairing",
"description":"Opens a pairing window and mints a fresh PIN to display. The user enters it on their device\nwithin `ttl_secs`; the device then appears in the native client list.",
"description":"Opens a pairing window and mints a fresh PIN to display. The user enters it on their device\nwithin `ttl_secs`; the device then appears in the native client list. An access choice\n(`grants` / `expires_in_secs`) applies to whichever device completes this window's ceremony.",
"operationId":"armNativePairing",
"requestBody":{
"content":{
@@ -1919,6 +2090,16 @@
}
}
},
"400":{
"description":"Reserved grant bits set",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ApiError"
}
}
}
},
"401":{
"description":"Missing or invalid bearer token",
"content":{
@@ -1983,7 +2164,7 @@
"native"
],
"summary":"Approve a pending device",
"description":"Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally\nrelabel it via the body; send `{}` to keep the name it knocked with.",
"description":"Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally\nrelabel it and/or choose its access via the body; send `{}` to keep the name it knocked with\nand its existing access (full/permanent for a first pairing). The response is the stored\nrecord — what is actually in force, not necessarily this request's inputs.",
"operationId":"approvePendingDevice",
"parameters":[
{
@@ -2010,7 +2191,7 @@
},
"responses":{
"200":{
"description":"Device paired",
"description":"Device paired; the stored record as now in force",
"content":{
"application/json":{
"schema":{
@@ -2019,6 +2200,16 @@
}
}
},
"400":{
"description":"Reserved grant bits set",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ApiError"
}
}
}
},
"401":{
"description":"Missing or invalid bearer token",
"content":{
@@ -4048,8 +4239,28 @@
},
"ApprovePending":{
"type":"object",
"description":"Approve-pending-device request body. Send `{}` to keep the device's own name.",
"description":"Approve-pending-device request body. Send `{}` to keep the device's own name and — for a\nre-approved device — its existing access (the full/permanent default for a first pairing).",
"properties":{
"expires_in_secs":{
"type":[
"integer",
"null"
],
"format":"int64",
"description":"Access expiry in seconds **from now** (relative — the host stores the absolute deadline\nand stamps the grant time). Alone, it means full control until then.",
"example":14400,
"minimum":0
},
"grants":{
"type":[
"integer",
"null"
],
"format":"int32",
"description":"Access choice: grant bitmask (`GRANT_*` bits 0–5). Reserved bits are a 400. Omitting BOTH\naccess fields keeps a re-approved device's stored access; `grants` without\n`expires_in_secs` grants permanently.",
"example":1,
"minimum":0
},
"name":{
"type":[
"string",
@@ -4064,6 +4275,16 @@
"type":"object",
"description":"Arm-native-pairing request body.",
"properties":{
"expires_in_secs":{
"type":[
"integer",
"null"
],
"format":"int64",
"description":"Optional access expiry for the pairing device, in seconds **from now** (relative — the\nhost stores the absolute deadline). NOT the pairing window's length; that is `ttl_secs`.\nOmit for permanent access (when `grants` is set) or preserved access (when neither is).",
"example":14400,
"minimum":0
},
"fingerprint":{
"type":[
"string",
@@ -4072,6 +4293,16 @@
"description":"Optional: bind the window to ONE device fingerprint (hex SHA-256, e.g. from a pending knock).\nWhen set, only a pairing attempt from that fingerprint consumes the window — so an unpaired\nLAN peer can neither pair nor burn a window armed for a specific device (security-review #9).\nOmit for an unbound window (any device may use the PIN — trusted-LAN only).",
"description":"Optional access choice for whichever device completes this window's ceremony: a grant\nbitmask (`GRANT_*` bits 0–5). Reserved bits are a 400. Omit (with `expires_in_secs`) for\ntoday's behavior — a new device gets full control, a re-pairing device keeps what it has.",
"example":1,
"minimum":0
},
"ttl_secs":{
"type":[
"integer",
@@ -5151,6 +5382,91 @@
}
}
},
{
"type":"object",
"description":"A device was granted access with an explicit operator choice — the approve dialog, the\narm window's carried choice, or any other `add_with_access(Some)` path\n(design/per-client-access.md §6). A plain pairing with no choice emits only\n`pairing.completed` (its access is the preserved/default record, nothing was *chosen*).",
"description":"The granted mask (the `GRANT_*` bit vocabulary), reserved bits already cleared.",
"minimum":0
},
"kind":{
"type":"string",
"enum":[
"access.granted"
]
}
}
},
{
"type":"object",
"description":"A paired device's access was edited after the fact (the console edit sheet / extend /\n\"expire now\") — the owner's hook can say \"the TV is view-only now\".",
"required":[
"device",
"grants",
"kind"
],
"properties":{
"device":{
"$ref":"#/components/schemas/DeviceRef"
},
"expires_unix":{
"type":[
"integer",
"null"
],
"format":"int64"
},
"grants":{
"type":"integer",
"format":"int32",
"minimum":0
},
"kind":{
"type":"string",
"enum":[
"access.changed"
]
}
}
},
{
"type":"object",
"description":"A device's temporary access reached its deadline and its live session was closed — \"guest\naccess ended\". Emitted at deadline fire by the expiring session (a device with no live\nsession expires silently; the console row flips to \"Expired\" either way).",
"required":[
"device",
"kind"
],
"properties":{
"device":{
"$ref":"#/components/schemas/DeviceRef"
},
"kind":{
"type":"string",
"enum":[
"access.expired"
]
}
}
},
{
"type":"object",
"required":[
@@ -5397,7 +5713,7 @@
"string",
"null"
],
"description":"The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) —`None` for installed-store titles and manual custom entries. The\nconsole uses it forattribution; `GET /library?provider=` filters on it."
"description":"The external provider owning this entry (entries synced by a providerplugin, RFC §8) —\n`None` only for the manual entries the operator typed in. Theconsole uses it for\nattribution; `GET /library?provider=` filters on it."
},
"role":{
"$ref":"#/components/schemas/GameRole",
@@ -6417,10 +6733,44 @@
"fingerprint"
],
"properties":{
"access_level":{
"type":[
"string",
"null"
],
"description":"The preset this device's mask amounts to, for display: `full` | `controller` | `view` |\n`custom`. Derived from `grants` on the host; absent only on hosts older than the field.",
"example":"controller"
},
"expires_unix":{
"type":[
"integer",
"null"
],
"format":"int64",
"description":"Absolute access expiry, unix seconds on the host's wall clock. `null` = permanent. Whether\nit has already passed is the reader's arithmetic — an expired device stays listed (shown\nas \"Expired\"), it just isn't authorized."
},
"fingerprint":{
"type":"string",
"description":"Hex SHA-256 of the client certificate — its stable id here."
},
"granted_unix":{
"type":[
"integer",
"null"
],
"format":"int64",
"description":"When access was last granted, unix seconds — display/audit only, never enforced."
},
"grants":{
"type":[
"integer",
"null"
],
"format":"int32",
"description":"Grant bitmask (`GRANT_*` bits 0–5). `null` = a record from before grants existed, which\nmeans full control.",
"example":1,
"minimum":0
},
"name":{
"type":"string",
"description":"The name the client supplied when pairing.",
@@ -6547,16 +6897,49 @@
"age_secs"
],
"properties":{
"access_level":{
"type":[
"string",
"null"
],
"description":"The stored mask's preset name (`full` | `controller` | `view` | `custom`) — `null` for a\ndevice with no stored record, unlike [`NativeClient`] where it is always derivable.",
"example":"controller"
},
"age_secs":{
"type":"integer",
"format":"int64",
"description":"Seconds since the device last knocked.",
"minimum":0
},
"expires_unix":{
"type":[
"integer",
"null"
],
"format":"int64",
"description":"The stored record's absolute expiry (unix seconds; likely in the past — that's why it's\nknocking). `null` when unknown or permanent."
},
"fingerprint":{
"type":"string",
"description":"Hex SHA-256 of the device's certificate — what approval pins."
},
"granted_unix":{
"type":[
"integer",
"null"
],
"format":"int64",
"description":"When the stored record's access was granted (unix seconds). `null` when unknown."
},
"grants":{
"type":[
"integer",
"null"
],
"format":"int32",
"description":"The grant mask this fingerprint is ALREADY stored with, if it was paired before (the\nexpired-guest re-knock: the approve dialog can offer \"re-grant what they had\"). `null`\nwhen the device is unknown, or known with a pre-grants record (= full).",
"minimum":0
},
"id":{
"type":"integer",
"format":"int32",
@@ -7143,7 +7526,7 @@
},
"origin":{
"$ref":"#/components/schemas/SourceOrigin",
"description":"Where the source comes from: `builtin` (a scanner in this host build) or `plugin`."
"description":"Where the source comes from. Always `plugin` from this host build onward — see\n[`SourceOrigin`]."
},
"provider":{
"type":[
@@ -7163,7 +7546,7 @@
"properties":{
"enabled":{
"type":"boolean",
"description":"Whether the scanner should run on this host."
"description":"Whether this source should contribute titles on this host."
}
}
},
@@ -7687,6 +8070,22 @@
}
}
},
"UnpairAllResult":{
"type":"object",
"description":"What a bulk unpair removed. Shared by the two collection DELETEs (`/clients` and\n`/native/clients`) so the console sees one schema across both pairing planes.\n\nA count rather than 204: \"unpair everything\" is idempotent, so an empty store is a success, and\nthe operator still wants to be told whether that meant three devices or none.",
"required":[
"unpaired"
],
"properties":{
"unpaired":{
"type":"integer",
"format":"int32",
"description":"Clients removed from the trust store — 0 when nothing was paired.",
"example":3,
"minimum":0
}
}
},
"UpdateJobInfo":{
"type":"object",
"description":"A running apply job (or a spawned installer that hasn't resolved yet).",
@@ -7760,6 +8159,39 @@
}
}
},
"UpdateNativeAccess":{
"type":"object",
"description":"PATCH body for a paired device's access (the console edit sheet: change the preset, extend,\n\"expire now\", make permanent). **Partial**: an omitted `grants` keeps the current grants, and\nomitted expiry fields keep the current expiry — send only what changes.",
"description":"New expiry in seconds **from now** (relative; the host stores the absolute deadline).\n`0` expires the device now. Omit to keep the current expiry. Mutually exclusive with\n`clear_expiry` (400).",
"example":14400,
"minimum":0
},
"grants":{
"type":[
"integer",
"null"
],
"format":"int32",
"description":"New grant bitmask (`GRANT_*` bits 0–5); reserved bits are a 400. Omit to keep the\ndevice's current grants.",
"example":1,
"minimum":0
}
}
},
"UpdateResultInfo":{
"type":"object",
"description":"Durable outcome of the most recent apply attempt (survives the host's own restart).",
@@ -7958,7 +8390,7 @@
},
{
"name":"library",
"description":"Game library: installed-store titles (Steam) plus user-curated custom entries"
"description":"Game library: the titles each installed library plugin syncs, plus user-curated custom entries"
# host, client, worker and tray on every arch.yml run. Wired via cargo-config-mold.toml
# below. It does NOT affect the gamescope companion leg — that is meson + its own linker,
# and its `-static-libstdc++` link is untouched.
mold \
&& pacman -Scc --noconfirm
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored
@@ -64,3 +69,16 @@ ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz"\
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'\
&& sccache --version
# CARGO_HOME is declared here only so this image agrees with what arch.yml already sets at job
# level (and so `cargo` finds the config below when the image is used by hand). The workflow still
# passes CARGO_HOME explicitly across the `sudo -u builder env …` boundary, which strips ambient
# env — that is why the C/C++ sccache wiring has to be re-exported there by name while THIS file,
# being a file, crosses the boundary for free.
ENVCARGO_HOME=/usr/local/cargo
RUN mkdir -p /usr/local/cargo && chmod -R a+w /usr/local/cargo
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
# rust-ci.Dockerfile for why the `mold --version` assertion sits next to the COPY.
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz"\
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'\
&& sccache --version
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
# rust-ci.Dockerfile for why the `mold --version` assertion sits next to the COPY.
# mold: link-phase accelerator (sccache cannot cache linking). This image links the release
# host + encode worker on every deb.yml run. Wired via cargo-config-mold.toml below.
mold \
# .deb assembly: dpkg-shlibdeps/dpkg-deb; patchelf repoints the binary's rpath at the bundled FFmpeg
dpkg-dev patchelf \
# FFmpeg 8 build deps: nasm (asm), VAAPI (libva/libdrm) so the built libav* keep the AMD/Intel
@@ -99,3 +102,10 @@ ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz"\
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'\
&& sccache --version
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
# rust-ci.Dockerfile for why the `mold --version` assertion sits next to the COPY.
# ⚠ This does NOT touch the from-source FFmpeg built above: that is a plain ./configure && make in
# an earlier layer, linked by GNU ld exactly as before. Only cargo's links move to mold.
# mold: the link-phase accelerator. Linking is the one thing sccache cannot cache, and this
# image relinks the whole workspace on every job. Wired via cargo-config-mold.toml below.
mold \
# ffmpeg-next 9, built against whatever libav* 26.04 ships (FFmpeg 8 / libavcodec 62 today).
# The crate major is a CEILING — ffmpeg-sys-next 9 spans libavcodec 56..63 — so this image does
# not need to move in lockstep with Arch's FFmpeg 9; it just links what the distro has.
@@ -61,3 +64,12 @@ ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz"\
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'\
&& sccache --version
# Link x86_64 with mold (see the file's own header for the rustflags-precedence traps).
#
# The assertion is the point: an image carrying the flag but NOT the linker would fail every cargo
# invocation in every consuming job, which is a catastrophic way to find out that a base image
# renamed the package. `mold --version` fails the docker build instead, so nothing is pushed and
# `:latest` keeps pointing at the previous working image — consumers never see it.
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.