2bfd1cd2d50715eee079ef274283cd6e1ce47bb2
1651
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2bfd1cd2d5 |
chore(safety): three unsafe-hygiene grep gates, blocking in ci.yml (WP2c gates)
scripts/ci/check-unsafe-hygiene.sh — textual gates for three classes no lint covers: A. unsafe fn markers carrying no contract. unsafe_op_in_unsafe_fn forces real ops into blocks, so an unsafe fn with no `unsafe` in its body is a marker with no contract ( |
||
|
|
dfebb9dfbb |
chore(safety): hoist the unsafe lints into the workspace tables (WP2c hoist)
undocumented_unsafe_blocks joins unsafe_op_in_unsafe_fn in
[workspace.lints], and the ~100 scattered per-file #![deny(...)] attributes
(85 files) are deleted — a new crate, or a new module in an old one, is now
covered on creation rather than on remembering. The per-file form is how
pf-vkhdr-layer, wdk-probe and half of pf-clipboard stayed uncovered.
There are THREE workspaces, so the claim is made three times: the main
Cargo.toml, packaging/windows/drivers (workspace table + [lints]
workspace = true in all seven members), and packaging/windows/pf-vkhdr-layer
(its [lints] table, previous commit). pf-update now opts into workspace
lints; the two vendored member snapshots (cros-codecs, usbip-sim) stay out
deliberately and now both say so.
Newly-covered fallout was two link-sanity tests (pyrowave-sys, libvpl-sys)
— proofs written. Stale prose that claimed the workspace held
unsafe_op_in_unsafe_fn at "warn" (it has been deny) or pointed at the
deleted attributes is corrected.
nvenc_core.rs is carved OUT of the unsafe_op_in_unsafe_fn fence: its
exemption rationale ("raw entry-table calls almost line for line") was
false — the file makes zero FFI calls. Its unsafe surface is C-union writes
whose soundness hangs on which codec arm is active, and its own 4:4:4 note
records the shipped bug (hevcConfig bytes stamped onto an AV1 config) that
per-operation blocks make visible. It now runs the strictest discipline in
the crate: clippy::multiple_unsafe_ops_per_block at deny, one union access
per block, each naming its codec guard.
Verified here: cargo fmt clean in all three workspaces; native clippy
-D warnings clean for everything that compiles on macOS (the three
pre-existing mac-native failures — pf-client-core wol.rs, pf-encode
dead-code/closure-call, probe mic_burst — reproduce on the clean tree).
Linux/Windows legs ride the .25/.133 gate.
|
||
|
|
23fa03b051 |
chore(safety): close the three crate-level lint gaps (WP2b)
pf-vkhdr-layer — the sharpest gap: an implicit layer injected into every Vulkan game process, 32 unsafe usages, zero SAFETY comments, own workspace so no lint table reached it, and an explicit missing_safety_doc allow. Now: a [lints] table (unsafe_op_in_unsafe_fn + clippy::undocumented_unsafe_blocks, both deny), the allow removed, every unsafe operation in an explicit block with a real proof (loader layer protocol / Vulkan valid-usage), # Safety docs on the contract-carrying fns, const layout asserts for the SurfaceFormat2Raw mirror, the five helpers with no caller-facing contract demoted to safe fns, and the two redundant `unsafe impl Send` deleted (fn pointers and vk handles are Send intrinsically — the type-check proves it). wdk-probe — 21 unsafe blocks, 12 proofs: the 9 missing SAFETY comments are written (the iddcx_rt.rs DDI slot-dispatch ones are about table population and PFN/index pairing, not pattern fill), the sibling denies added at the crate root, missing_safety_doc allow dropped, # Safety on DriverEntry, and the crate joins windows-drivers.yml's clippy list — it was the only driver crate not in it. pf-clipboard — the undocumented_unsafe_blocks deny moves from host/windows.rs to the crate root so host/wayland.rs (4 blocks), host/mutter.rs (2) and any future backend under host/ are covered on creation. All existing blocks already carry proofs; free today, structural tomorrow. Verified here: pf-vkhdr-layer cargo fmt --check + clippy --release -D warnings at x86_64-pc-windows-msvc. wdk-probe and pf-clipboard compile checks need the WDK/Linux boxes and ride the .133/.25 gate. |
||
|
|
bc70a58fb1 |
Merge main into chore/rust-safety-programme
windows-drivers / probe-and-proto (pull_request) Successful in 22s
ci / bun-nix (pull_request) Successful in 36s
ci / web (pull_request) Successful in 1m17s
apple / swift (pull_request) Successful in 1m39s
apple / screenshots (pull_request) Skipped
windows-drivers / driver-build (pull_request) Successful in 1m58s
ci / rust-arm64 (pull_request) Successful in 3m18s
ci / docs-site (pull_request) Successful in 3m55s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m29s
android / android (pull_request) Successful in 4m46s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m30s
ci / rust (pull_request) Failing after 10m11s
nix / flake (pull_request) Successful in 15m6s
Two conflicts: the test-module import list in gamescope.rs (union — the branch's takeover-state tests and main's WSI opt-out tests both stay), and next_frame_timed_out in pf-capture, where the branch still carried the pre-#168 else-if chain — resolved to main's match-based refactor, which already embeds the same arm semantics plus the provisional-budget latch gate. |
||
|
|
ce25aca7bd |
Merge pull request 'Two black screens from the .41 field session — a NO_FOCUS window stole the composite, and one truncated timeout downgraded the host forever' (#168) from worktree-blackscreen-fixes into main
apple / swift (push) Successful in 1m42s
ci / web (push) Successful in 1m14s
ci / docs-site (push) Successful in 1m20s
ci / bun-nix (push) Successful in 19s
deb / build-publish-client-arm64 (push) Successful in 1m46s
deb / build-publish (push) Successful in 5m9s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
apple / screenshots (push) Canceled after 1m18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
deb / build-publish-host (push) Successful in 7m18s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m28s
arch / build-publish (push) Successful in 11m28s
android / android (push) Canceled after 6m31s
ci / rust (push) Canceled after 2m21s
ci / rust-arm64 (push) Canceled after 1m13s
docker / builders-arm64cross (push) Successful in 11s
docker / deploy-docs (push) Canceled after 5s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 3m2s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
|
||
|
|
fcf4c9fd63 |
fix(mgmt): unpair now revokes a LIVE session on both planes
ci / bun-nix (pull_request) Successful in 37s
ci / web (pull_request) Successful in 1m38s
apple / swift (pull_request) Successful in 1m50s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m38s
ci / docs-site (pull_request) Successful in 2m32s
windows-drivers / driver-build (pull_request) Successful in 1m50s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m29s
android / android (pull_request) Successful in 6m31s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m54s
ci / rust (pull_request) Failing after 10m42s
nix / flake (pull_request) Canceled after 8m26s
windows-drivers / probe-and-proto (pull_request) Canceled after 0s
An unpair removed the certificate but left the revoked client's running
session streaming until the client chose to leave. Now it is a complete
revocation:
- GameStream: when the removed certificate owns the active launch, the
session is quit_session'd — the ENet control thread's ended-session arm
gives the client the standard TERMINATION+disconnect. (An owner-less
launch cannot be attributed and is left to the WP0 port teardown when the
last pairing goes.) The endpoint docstring's long-standing caveat
('removes the client from the listing without severing its ability to
reconnect') is retired: TLS handshakes complete by design, authorization
is per-request, and a live session no longer survives its own revocation.
- Native: session_status::stop_by_fingerprint signals the unpaired
client's live session(s) to tear down deliberately (quit+stop), matched
by the registry's client label — the fingerprint's 12-hex-char prefix for
every pairable client; anonymous/TOFU sessions carry IP labels and are
never touched (they have no pairing to revoke).
(The unpair-didn't-PERSIST half of 'unpairing was broken' was already fixed
in
|
||
|
|
022ede651f |
fix(pf-capture): the truncated first attempt no longer latches the sticky downgrades
The pipeline retry loop deliberately shortens its first attempt's first-frame wait to 2.5s so a stream bound during a gamescope re-init fails over quickly. But the portal capturer's timeout diagnosis treated EVERY expiry as a verdict: it latched whichever offer it implicated — HDR capture off for the source, the raw-dmabuf offer off, the EGL→CUDA offer off — process-wide and permanently, when the attempt was truncated by design and a gamescope cold start routinely delivers nothing inside that window while accepting every offer a few seconds later (observed on .41: pid 1962 hit the expiry at connect and every later session in that process ran silently degraded). This is bug #6 from the pf-capture sweep, verified then and unfixed until now. The truncated attempt is now declared PROVISIONAL end to end: a new `Capturer::next_frame_within_provisional` (default: delegates) lets the retry loop say "this budget is the schedule, not a verdict", and the portal capturer's timeout classification — split out as the pure `classify_first_frame_timeout` + `timeout_convicts`, with tests — names the same suspect in the error text but latches nothing unless the expired budget was full-length. |
||
|
|
9c6e06d3b9 |
feat(host): GameStream is now a cargo feature — WP19, compile-time isolation
A new 'gamestream' feature (default ON — every stock package is behaviorally identical, and GameStream stays runtime-opt-in via --gamestream / PUNKTFUNK_GAMESTREAM) gates the whole Moonlight-protocol surface: control (the ENet plane), rtsp, nvhttp, pairing, serverinfo, the _nvstream mDNS advert, the compat media path (stream/video/audio), pen/gamepad/input decode, apps, crypto, cert (the RSA identity), and tls's Moonlight-client-cert leniency. AppState keeps the shared vocabulary unconditional and cfg-gates the Moonlight-only fields; the mgmt API's PIN endpoints (routes, handlers, OpenAPI entries, lane classifications, tests) exist only under the feature. Building --no-default-features --features pyrowave yields the hardened NATIVE-ONLY host: no rusty_enet (the c2rust-transpiled C ENet stack, 158 unsafe sites) and no rsa (the identity split's legacy fallback became a pem-only read — rustls/ring serves an existing RSA cert without the crate — so the accepted Marvin advisory no longer applies to native-only builds). Both claims are ASSERTED, not assumed: a new CI leg keeps the native-only flavor clippy-clean and fails if cargo tree finds either crate in its graph. serve --gamestream (or the env knob) against such a binary refuses to start with a clear error rather than serving less than the operator configured. En route: the logs-paging test assumed a quiet process-global log ring between its cursors and raced other tests' legitimate log lines (the identity tests added new emitters) — it now asserts on its own markers within the page. Gates: Linux amd64 — BOTH flavors clippy --all-targets -D warnings clean; default tests identity 3/3, mgmt 37/37, gamestream 59/59; native-only tests identity 3/3, mgmt 35/35, residue 4/4; rusty_enet+rsa absent native-only, present default. .133 Windows — both flavors clippy clean (clean-first, sentinel-checked), tree claims hold, and the WP0 port-lifecycle functional gate PASSES on the default build. |
||
|
|
f5fa9649b7 |
fix(console): a pinned card's library launches with that card's profile
Pinning a profile onto a host gives it its own card on the console home, and pressing A on that card has always connected with the pinned profile as the one-off the resolver prefers over the host's binding. Y on the same card opens a library — it is paired and saved, which is the only thing the hint bar asks — and every title launched off that shelf went out with no profile at all, so the host's default binding won. Connecting straight from the card honoured the profile; going through its library did not, which is the shape a user reads as "the pin works until I pick a game". The screen was the leak: LibraryScreen copied the row's address, port, fingerprint and mgmt port, and dropped `pin` — so its launch had nothing to send and hardcoded `profile: None`, under a comment asserting that game launches follow the binding. They should follow the card: a launch off a pinned card's shelf is that card's connect with a title attached. The screen now carries the row's pin and sends its id as the one-off. It also says so, in the card's own `host · profile` shape: the shelf's title names the profile, and so does the connecting card, so which of a host's cards you came in on is legible from the screen rather than inferred from the tile you pressed two screens ago. Off the host's primary tile there is no pin and nothing changes — `None`, and the binding decides. Console-only. The Apple and Android consoles keep Y off pinned cards outright (`hasLibrary: profile == nil`), the GTK client clones the card's whole ConnectRequest — profile included — into its library, and the Windows client offers "Browse library" from the primary tile only. The console's own copy-link and wake-then-connect paths already carried the pin. Both directions are tested, and the pinned one was checked against a reverted fix: it fails with exactly the reported symptom (left: None, right: "hdr"). |
||
|
|
e658ad726b |
feat(host): the identity split — the native planes get their own P-256 identity
One RSA-2048 identity served every plane, because Moonlight mandates RSA and the planes grew out of the GameStream host. The native punktfunk/1 QUIC plane and the management API now share a separate ECDSA P-256 identity (native-cert.pem/native-key.pem, src/identity.rs): ring-generated via rcgen (no rsa crate on the native path — the accepted Marvin advisory stops applying once WP19 gates the compat planes), real SANs (localhost, loopback, machine hostname — the legacy cert had none), and browser-compatible on purpose: Ed25519 was rejected because no mainstream browser accepts an Ed25519 server cert and /api/docs is opened in one. GameStream keeps the RSA identity untouched (Moonlight pins it; its pairing hashes bind its X.509 signature bytes). Migration is pin-preserving by construction. Clients TOFU-pin ONE leaf-DER SHA-256 for both QUIC and the mgmt/library API, so the identity is resolved ONCE in serve (the planes cannot race the first-run mint) under the rule: identity files exist → use them; else the native trust store is EMPTY → mint P-256 (fresh installs); else keep presenting the legacy RSA cert the paired clients pinned, and log the migration path (unpair all, restart, re-pair). Fingerprint pinning is algorithm-agnostic — existing shipped clients pair against P-256 hosts unchanged. Followers updated: the tray's loopback pin and the plugin SDK's mgmt CA prefer native-cert.pem → cert.pem; the Windows runner ACL grant lists both (the grant loop tolerates absent files). The in-process native tests now run on an EPHEMERAL identity — they previously read, and would newly have MINTED, identity files in the real config dir, which on a dev box that is also a live host would have switched its identity and stranded every pinned client. Gates: Linux amd64 clippy --all-targets -D warnings clean (host+tray); identity 2/2, mgmt 37/37, control 6/6, native 68/68 (C-ABI roundtrips over the ephemeral identity). .133 Windows clippy clean; the port-lifecycle gate re-run PASSES with the split live — the fresh host minted P-256 and served mgmt over it (curl 200/204), ports tracked the paired list as before. |
||
|
|
21f43d7f48 |
Merge pull request 'Games ran black in Nobara's Game Mode — the session script re-exports the WSI variable #144 cleared' (#165) from worktree-nobara-wsi-layer-clobber into main
apple / swift (push) Successful in 1m39s
ci / bun-nix (push) Successful in 55s
ci / web (push) Successful in 1m13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
ci / rust-arm64 (push) Successful in 1m43s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
ci / docs-site (push) Successful in 2m22s
deb / build-publish-client-arm64 (push) Successful in 2m8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m1s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m22s
ci / rust (push) Successful in 4m57s
android / android (push) Successful in 8m21s
deb / build-publish-host (push) Successful in 10m37s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 16s
arch / build-publish (push) Successful in 9m36s
docker / builders-arm64cross (push) Successful in 6s
docker / deploy-docs (push) Failing after 2m39s
apple / screenshots (push) Successful in 6m30s
deb / build-publish (push) Successful in 7m34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m28s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m35s
windows-host / package (push) Successful in 15m1s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 19s
Reviewed-on: #165 |
||
|
|
23d0452157 |
feat(host): GameStream opt-in on every route; the native plane is deny(unsafe_code)-enforced
The user direction after WP0: ENet exists only for Moonlight, so the native
plane must be provably safe and the compat planes a deliberate choice.
Opt-in, everywhere. Windows already was (unchecked installer task). The three
opt-out surfaces are flipped: the shipped systemd user unit (deb/RPM/Arch/
sysext) no longer bakes --gamestream into ExecStart — a new
PUNKTFUNK_GAMESTREAM=1 host.env knob (pf-host-config, OR-ed with the CLI
flag) is the packaged opt-in; the NixOS module default goes true→false, with
a module-check assertion that unset = native-only; the Deck installer takes
--gamestream to opt in (--no-gamestream kept as explicit-off). Docs
(quickstart, running-as-a-service, moonlight, ubuntu/fedora/arch firewall
sections, gnome/sway, how-it-works) rewritten to the opt-in shape; the
CHANGELOG carries the upgrade note.
Enforced-safe. punktfunk-core is #![deny(unsafe_code)] crate-wide — every
module that parses network bytes is safe Rust as a compile error, not a
census result. Carve-outs are exactly two documented classes, neither of
which interprets attacker bytes: the client surface (abi, client) and the
transport syscall-batching shims (udp/{apple,linux,windows}, qos_windows).
In punktfunk-host, the modules a secure-default host exposes — native
(cfg-not-test: its tests exercise the client C ABI on purpose),
native_pairing, mgmt, mgmt_token, discovery, wol — are #[forbid(unsafe_code)].
Gates: Linux amd64 container clippy --all-targets -D warnings clean over
core+host-config+host; core 204 tests green under the deny; mgmt 46/46,
control 6/6. .133 Windows clippy (shipped features, clean-first,
sentinel-checked) clean — covers the qos_windows/udp-windows carve-outs.
macOS + iOS cargo check green (the apple.rs carve-out compiles for real).
|
||
|
|
9e28cd101c |
fix(pf-vdisplay): the WSI opt-out never reached the games — the session script re-exports it
ci / docs-site (pull_request) Successful in 1m23s
ci / web (pull_request) Successful in 1m24s
apple / swift (pull_request) Successful in 1m46s
ci / bun-nix (pull_request) Successful in 1m25s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m27s
android / android (pull_request) Successful in 5m53s
ci / rust (pull_request) Successful in 11m10s
A Nobara 44 field box on 0.27.0 got a working Game Mode takeover — right mode, right refresh, perf overlay in the stream — and then every game it launched played sound and took input over a BLACK PICTURE, with no error anywhere. #144 disabled the distro's `VkLayer_FROG_gamescope_wsi` with `--setenv=ENABLE_GAMESCOPE_WSI=0` on the transient unit, because that layer speaks the distro gamescope's `gamescope_swapchain` protocol and ours rejects its `swapchain_feedback`, killing the client. `gamescope-session-plus` then runs an unconditional `export ENABLE_GAMESCOPE_WSI=1` near the top of the script, before it launches anything — so the opt-out lived exactly as long as it took the script to start, and gamescope, Steam and every game got the layer back. The host logged that it had disabled it, which is what made this cost a field round-trip: the claim was true of the unit and false of everything in it. Nothing else looked wrong because the casualty is Vulkan clients specifically. Steam's Big Picture UI is not one, so the session came up looking perfectly healthy right up until a game started. Send `DISABLE_GAMESCOPE_WSI=1` as well. The Vulkan loader resolves an implicit layer's two manifest knobs in a fixed order (`loader_implicit_layer_is_enabled`): `enable_environment` must equal `"1"` to switch the layer on, and `disable_environment` is consulted last — "has priority over everything else" — where the mere PRESENCE of the variable, at any value, forces it off. The session script never mentions that one, so it is the only one that survives it. Both spellings now go out through one place, so the transient unit (`launch_session`) and the box's own session drop-in (`write_session_plus_dropin`) cannot drift apart — the same shape `SessionBind` already uses for the bind. `ENABLE_GAMESCOPE_WSI=0` stays alongside for a layer built without a `disable_environment`, and because it is what an operator reads the unit for. |
||
|
|
13d5721049 |
feat(gamestream): the ENet control port exists only while a pairing does (WP0)
rusty_enet — a c2rust-style transpile of C ENet, 158 unsafe sites — parsed unauthenticated UDP on 47999 from GameStream startup, before any client had ever paired: the host's entire pre-auth-reachable unsafe surface. Pairing itself is HTTPS on nvhttp and never touches the port, so it now binds only while the paired-client list is non-empty: a Gate in control.rs reconciles the port to the list (armed only under --gamestream), pairing phase 4 brings it up before the new client can /launch, and removing the last pairing tears it down — a live client gets the same termination+disconnect farewell as a host-side session end. A never-paired host on a hostile LAN exposes no ENet. En route: the management API's unpair never called save_paired, so a restart resurrected the client — and would now have silently re-opened the port; it persists (the test now runs against a throwaway PUNKTFUNK_CONFIG_DIR so it can't clobber a real paired.json). rusty_enet is pinned =0.4.0 per the WP, left to the cargo-audit job to flag advisories against it. Gate (amd64 container): clippy --all-targets -D warnings clean; gamestream::control 6/6; mgmt::tests 37/37 incl. the regenerated api/openapi.json. On-box .133 verification (ports/pair/stream) still owed. |
||
|
|
d4366e7464 |
fix(pf-encode): the Vulkan extension probe walked a driver-filled array with no bound
ci / web (pull_request) Successful in 1m20s
apple / swift (pull_request) Successful in 1m38s
apple / screenshots (pull_request) Skipped
windows-drivers / driver-build (pull_request) Successful in 1m49s
windows-drivers / probe-and-proto (pull_request) Successful in 27s
ci / docs-site (pull_request) Successful in 1m22s
ci / rust-arm64 (pull_request) Successful in 2m50s
ci / bun-nix (pull_request) Successful in 24s
android / android (pull_request) Successful in 4m37s
ci / rust (pull_request) Successful in 10m25s
`ext_advertised` did `CStr::from_ptr(e.extension_name.as_ptr())` over a
driver-filled `[c_char; VK_MAX_EXTENSION_NAME_SIZE]`, and `vk_build.rs` open-coded
the identical call a second time. Neither had an in-Rust bound: a driver that
fills all 256 bytes without a NUL runs the walk into the NEXT
`ExtensionProperties`, and on the LAST element past the allocation.
The SAFETY comment asserted the spec guarantee ("a spec-guaranteed NUL-terminated
byte array") instead of enforcing it. That is the defect class this programme
keeps finding: a proof that restates what the other side promised rather than
checking it. Vulkan drivers are exactly the other side.
The bounded answer already shipped in the same crate — `pyrowave.rs:210` uses
ash's `extension_name_as_c_str()` for the identical job. It stops at
VK_MAX_EXTENSION_NAME_SIZE and returns Err when there is no terminator, so a
malformed entry is a non-match instead of an overrun. Both sites now route
through the one helper, which is no longer unsafe at all.
Deletes 2 unsafe operations and one duplicated walk.
⚠ The pre-existing test could not have caught this: it only ever built
well-formed, NUL-terminated entries. Added a case whose LAST element is 256
non-NUL bytes — the exact shape that used to leave the array — and a
prefix-match case, so the bound is now asserted rather than assumed.
Verified on 192.168.1.25 (Ubuntu, cargo 1.96.0 — the pinned toolchain):
cargo check -p pf-encode --features vulkan-encode,pyrowave --locked ok
cargo test -p pf-encode --features vulkan-encode,pyrowave ext_advertised
2 passed / 0 failed
cargo clippy -p pf-encode --all-targets --locked
--features vulkan-encode,pyrowave -- -D warnings clean
Linux-only code (`enc/linux/`), so the Windows leg is unaffected.
|
||
|
|
cd72f77a3c |
fix(pf-encode): the AMF layout guards broke Windows clippy — 0*SLOT and 1*SLOT
windows-drivers / probe-and-proto (pull_request) Successful in 29s
apple / swift (pull_request) Successful in 1m39s
apple / screenshots (pull_request) Skipped
windows-drivers / driver-build (pull_request) Successful in 2m10s
ci / rust-arm64 (pull_request) Successful in 2m53s
ci / web (pull_request) Successful in 1m15s
android / android (pull_request) Successful in 4m44s
ci / bun-nix (pull_request) Successful in 26s
ci / rust (pull_request) Canceled after 5m25s
ci / docs-site (pull_request) Canceled after 1m7s
`27f08340` wrote every vtable offset assertion as `offset_of!(T, f) == N * SLOT`
so the slot INDEX stays visible in the assertion. For N=0 and N=1 that is
`0 * SLOT` and `1 * SLOT`, which clippy rejects as `erasing_op` and
`identity_op` — six errors, and windows-host.yml runs clippy with `-D warnings`,
so the branch as pushed would have turned the Windows leg red.
This is the blind spot the programme document names in §1.5, demonstrated on the
programme's own first code commit: 44% of the host's unsafe is `#[cfg(windows)]`,
no Linux or macOS check compiles it, and `cargo fmt`/`cargo check` on a Mac are
all clean. Only the .133 gate sees it.
Fixed with a `const fn slot(i: usize) -> usize` rather than by writing the two
offending cases as bare `0` and `SLOT`: that would have made those two the only
assertions where the slot index is invisible, and the index is the entire point.
Also records the cheap local gate that would have caught this without a Windows
round-trip: `amf_sys.rs` depends on nothing but `c_void`, so copying it into a
throwaway one-file crate and running `cargo clippy -- -D warnings` reproduces the
exact error on any host. Verified by reintroducing `0 * SLOT` and watching the
harness fail with the same message the runner gave.
Verified on 192.168.1.133 (Windows CI runner, the box with the WDK), after a
`cargo clean -p pf-encode` that reported `Removed 47 files, 135.5MiB` so the
recompile is real and not a cached green:
cargo check -p pf-encode ok
cargo check -p pf-encode --all-targets --features nvenc,amf-qsv,qsv ok
cargo clippy -p pf-encode --all-targets --features nvenc,amf-qsv,qsv
-- -D warnings exit 0 (was 101)
cargo clippy -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings
exit 0 (was 101)
The gate also greps the extracted tree for the assertions before building, so a
stale upload cannot produce a passing run.
|
||
|
|
cd3f5474bf |
fix(pf-driver-proto): a layout test read an align-8 struct out of an align-1 buffer
ci / web (pull_request) Successful in 1m11s
ci / docs-site (pull_request) Successful in 1m18s
apple / swift (pull_request) Successful in 1m48s
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 20s
windows-drivers / driver-build (pull_request) Successful in 2m14s
windows-drivers / probe-and-proto (pull_request) Successful in 40s
android / android (pull_request) Successful in 4m11s
ci / rust-arm64 (pull_request) Successful in 3m7s
ci / rust (pull_request) Successful in 7m4s
`control_structs_roundtrip_through_bytes` built the legacy-size wire form in a
stack `let mut legacy = [0u8; 40]` (align 1) and then called
`bytemuck::from_bytes::<control::AddRequest>`. `AddRequest` opens with
`session_id: u64`, so it is align 8, and `from_bytes` hands back a REFERENCE
into the buffer — it panics unless the buffer happens to be 8-aligned.
A stack `[u8; 40]` usually is, which is why this passed on every machine and
every CI leg since it was written. Under Miri it fails outright: Miri does not
let an accidentally-favourable stack slot stand in for a guarantee.
Switched to `pod_read_unaligned`, which reads by value and has no alignment
precondition. That is not a new idea here — `ChannelProof::parse` at lib.rs:1013
already carries a comment saying "`pod_read_unaligned`, NOT `from_bytes`" for
exactly this reason. This site is the only other one in the crate that reads a
POD out of a stack byte array; every other `from_bytes` call in the tests reads
from `bytes_of(&x)`, which is aligned by construction.
Test-only, so no shipped defect — but the crate is `#![forbid(unsafe_code)]` and
is path-dep'd by BOTH the main workspace and the driver workspace, so it is the
layout oracle for every frame and IOCTL that crosses that boundary. A test that
cannot be trusted to fail is worth fixing there more than anywhere else.
Found by the first Miri run ever performed against this repo.
Verified on 192.168.1.25 (Ubuntu, cargo 1.96.0):
cargo +nightly miri test -p pf-driver-proto 21/21
cargo +nightly miri test -p pf-driver-proto --target x86_64-pc-windows-msvc
21/21
cargo test -p pf-driver-proto --locked ok
cargo clippy -p pf-driver-proto --all-targets --locked -- -D warnings clean
The cross-target run is the interesting one: it interprets the crate at MSVC
layout on a Linux box with no Windows anywhere. Nothing else in CI does that.
|
||
|
|
972af2992f |
fix(pf-capture): the gamescope cursor fallback rewrote environ under a live multithreaded host
`connect_via_env_swap` did set_var("XAUTHORITY", …) / connect / restore, guarded
by a mutex that serialised this source against itself and against nothing else.
`getenv` takes no lock. setenv/unsetenv rewrite the process-global `environ`, and
glibc REALLOCATES that array when a variable is added — while, at that exact
moment, the PipeWire thread is inside pw_init()'s dlopen making bare getenv()
calls and EGL/CUDA init is running alongside. The file's own doc already called
the pattern "unsound from a live multithreaded host"; it stayed as a fallback.
Three things made it worse than the comment suggested:
- The damaging branch is the one where XAUTHORITY is ABSENT and therefore gets
ADDED (the realloc case). scripts/punktfunk-host.service deliberately does not
import the login shell's environment, so absent is the DOCUMENTED NORMAL
configuration for the shipped unit, not an edge case.
- `rediscover` re-runs this every 2 s for the whole session. A display whose
connect fails is never pushed into `displays`, so the dead-display skip never
covers it — the race is not once at startup, it repeats forever.
- It is unfixable in place. Sharing pf_vdisplay's ENV_LOCK is the wrong layer: it
cannot make C `getenv` take a lock.
The fix is to stop writing `environ` at all. Connecting with an explicitly empty
auth token is what the swap actually achieved: we only reach the fallback when
our own lookup found no usable MIT-MAGIC-COOKIE-1 entry, and x11rb's internal
lookup reads the same file with a STRICTER matcher (it matches family/address
too, which we deliberately do not), so where we find nothing it finds nothing
either and connects unauthenticated. That is exactly why the swap "worked"
against a nested Xwayland started without -auth.
Gives up one case: an .Xauthority using an auth family we decline to guess at but
x11rb would have handled. A gamescope Xwayland writes a single-entry
MIT-MAGIC-COOKIE-1 file, so it is not reachable here, and declining to attach a
cursor overlay beats tearing `environ` out from under a live session.
Also removes XAUTH_LOCK, whose only user this was.
Verified on 192.168.1.25 (Ubuntu, cargo 1.96.0 — the pinned toolchain, pipewire
dev headers present): `cargo check -p pf-capture --locked` and
`cargo clippy -p pf-capture --all-targets --locked -- -D warnings` both clean.
Not verified on glass: the fallback is only reached when the cookie parse fails,
so a normal gamescope session does not enter it. Forcing it needs a nested
Xwayland started without -auth, or a mangled cookie file, on .181/.136.
|
||
|
|
df6f270e7b |
chore(safety): forbid unsafe on the crates that are already at zero
Five permanent ratchets, all free today — the point is that they cannot regress
tomorrow. Each crate was re-measured at the commit, not taken from a survey.
`forbid(unsafe_code)`:
punktfunk-encode-worker the binary that carries cap_sys_nice. Its header
claims "no Wayland, no D-Bus, no network, no
plugins"; this makes the memory-safety half of that
claim mechanical. `forbid`, not `deny`, so it cannot
be re-opened by an #[allow] further down.
pf-update-check parses a signed, network-fetched manifest and its own
header says it "owns the part where being wrong is a
security bug". Signature checking is worthless if the
parser around it can be walked out of bounds.
pf-vaadec its header states the design constraint outright — it
links no libva and compiles on macOS, "which is the
point". The crate is full of hand-declared libva
repr(C) mirrors; one raw deref and it stops being the
CPU-testable half.
tools/cursor-probe free, and a probe is where "just deref it to see" is
most tempting.
`deny(unsafe_code)` + one localized allow:
pf-update root runs this. Its single unsafe operation, a bare
geteuid, moves into a named `effective_uid()` helper
carrying the crate's one #[allow(unsafe_code)].
Deliberately NOT rewritten to rustix, contrary to the programme document's first
draft: pf-update's Cargo.toml states that its zero-dependency posture IS a
security invariant of a root helper ("no HTTP client, no TLS, no argument
parsing"), and the extern block says the same. Pulling a general-purpose syscall
crate into a root helper to delete one `unsafe` would trade a real property for
a cosmetic one. The localized allow keeps the ratchet: any NEW unsafe anywhere
in the crate is a build error.
Verified: `cargo check -p pf-vaadec -p pf-update-check` and
`cargo check -p pf-update -p cursor-probe` clean on macOS, plus
`cargo check -p pf-update --target x86_64-unknown-linux-gnu` — pf-update's whole
body is behind `cfg(target_os = "linux")`, so the macOS check does not reach the
line that changed. punktfunk-encode-worker is not built here (pf-encode's C
dependencies do not cross-compile from macOS) and needs the Linux CI leg.
|
||
|
|
27f0834025 |
fix(pf-encode): const-assert the AMF vtable and POD layouts
amf_sys.rs mirrors five AMF COM vtables by hand and amf.rs dispatches through them BY SLOT POSITION — 18 distinct slots across the five tables. The mirrors carried 118 `Slot` placeholders whose only job is to hold the following slots at their C offsets, and not one layout assertion of any kind. A slot inserted, removed or reordered in an AMF header bump calls an arbitrary function pointer through a mismatched signature: no compile error, no runtime signal. `AMF_MIN_VERSION` does not defend against this. It checks a version NUMBER, not a layout, and it is a floor with no ceiling. The three POD checks that did exist (`AmfVariant`, `AmfGuid`, `AmfHdrMetadata`) lived in amf.rs's `#[cfg(test)]` module, so they were verified only when someone ran pf-encode's tests, on Windows, with AMF enabled — and NEVER in a release build, which is exactly where a mis-mirrored `AMFVariantStruct` does its damage: it crosses the FFI BY VALUE on every SetProperty. This is the same hole `a8dd348b` closed for the cuda.h mirrors and missed here. Adds ~40 `const _: () = assert!(...)` guards next to the mirrors: size of each of the five vtables, the byte offset of every slot amf.rs actually calls, the three POD layouts promoted out of the test module, and the AMFData/AMFBuffer shared-prefix agreement that `create_surface_from_dx11_native`'s AMFSurface-through-AMFData reinterpretation silently depends on. Verified by compiling amf_sys.rs standalone (it needs only `c_void`, and a repr(C) struct of code pointers has the same layout on any 64-bit target, so a macOS const-eval proves the Windows arithmetic), and by deliberately breaking one offset to confirm the guard actually fires rather than silently passing. That check earned its keep immediately: `alloc_buffer` sits at slot 43, not 42. Counting AMFInterface(3) + AMFPropertyStorage(10) + the AMFContext block by hand is exactly the error these assertions exist to catch. Zero runtime behaviour change. The `AMF_MIN_VERSION` ceiling is deliberately NOT part of this commit: a ceiling would make the next AMF driver release refuse encode on every AMD box, so it needs a warn-and-continue policy plus an env override and a real AMF session to gate it. |
||
|
|
4b686f026a |
Merge branch 'main' into worktree-vd-sweep-2
# Conflicts: # api/openapi.json |
||
|
|
dc4d8d6832 |
fix(pf-vdisplay): correct the regressions this sweep introduced
An adversarial review of the sweep's own diff raised 39 claims; 23 survived independent verification. This commit fixes them. Several are cases where the sweep traded one bug for another. **The display budget was enforced in the wrong place.** The new Linux `max_displays` ceiling sat in `registry::acquire` — which runs again on every mid-stream rebuild. All three create-before-drop paths hold the old lease while acquiring the new display, and only the mode-switch path passes `supersedes`, so a session at the ceiling counted itself against the budget and could never recover from capture loss or a Game↔Desktop switch. At `max_displays = 1` that is a single streaming client. Moved to `admission::admit`, which is where Windows has always applied it and which is reached once per connect — so a rebuild cannot hit it. **"Cannot tell" was collapsed into "wrong mode".** `unanimous_output_size` returning `None` for two disagreeing gamescopes was compared with `== Some(target)`, so ambiguity took the destructive branch: a nested per-title gamescope — the normal Game Mode shape — made every connect restart the box's session and kill the running game. Now a three-state `BoxOutputSize`, where `Ambiguous` mirrors the live node instead of re-moding, and the post-restart wait asks "did what we asked for come up" rather than demanding unanimity. **Decide-then-act lost its mutual exclusion.** Re-scoping the `MANAGED_SESSION` guard fixed the shutdown restore but let two concurrent creates at the same mode both relaunch, the second stopping the unit the first was polling. A separate `MANAGED_LAUNCH` mutex restores the exclusion without putting launch progress back into the lock the restore samples. **Per-axis policy salvage was applied to a selector.** `preset` chooses the other axes, so salvaging it to the default silently re-pointed the whole document; it now refuses the document instead. A file whose every axis is unreadable also reported `configured() == Some(default)` — flipping Linux identity from Shared to PerClient — and now correctly reports unconfigured. **The six `#[serde(default)]` on `EffectivePolicy` are reverted**: they loosened `POST/PUT /display/presets` (an omitted axis defaulted where it used to 400), which nobody asked for. The catalog salvage they were added for now lives in a private Deserialize-only mirror type, so the read path stays lenient and the wire contract stays strict. Also: the Windows create path stored the OS-committed refresh in the field `acquire` uses as its resize discriminator, so a same-mode re-acquire looked like a hotplug — the requested and committed modes are now separate fields; `output_within`'s timeout arm detached both reader threads (now bounded by a drain grace, capped at 16 MiB, and logged honestly — a `systemd-run --pipe` unit escapes the process group and cannot be reached); `reenable_outputs_kscreen` abandoned the mode restore whenever kscreen-doctor hit its budget even though the enable may have landed (now tri-state); `write_atomic` replaced a symlinked portal config with a regular file, severing dotfiles management; several new budgets were too short for the helper they bound (`steam -shutdown` was being killed before it could deliver the request; `linger_enabled` read a 300 ms timeout as "not lingering" and hard-failed a correctly configured box); and a restore logged an operator-facing error for a `systemctl` call that had merely outlived its budget while systemd still owned the queued job. Verified: 107 tests on macOS, 202 on Linux (executed in a container, not merely type-checked), Linux and Windows clippy clean at `-D warnings`, fmt clean. |
||
|
|
8b98d0b3ec |
fix(pf-capture): a sweep found nine real defects behind comments that asserted the opposite
Reviewed the whole crate (15.6 kloc) for bugs, safety, structure and comment truth. Both compile gates are green: `scripts/xcheck.sh windows clippy` and `cargo clippy -p pf-capture --all-targets --locked -- -D warnings` in the amd64 CI image (the Linux half needs libpipewire, so it cannot ride xcheck). Code defects, each one contradicted by a comment sitting next to it: * `pipeline_depth` clamped to `OUT_RING` (3) while both `repeat_last` and `OUT_RING` state the safe maximum is 2. `d` frames in flight need `d + 1` textures, so `PUNKTFUNK_IDD_DEPTH=3` rotated onto the slot NVENC was still reading and the convert overwrote it in place — torn frames, silently. Now `OUT_RING - 1`. * The GDI cursor poller published `visible: true` for a NULL `hCursor` carrying `CURSOR_SHOWING` — how an app hides the pointer for its own window. The last rasterised arrow was then blended into a game that had hidden its cursor. Every rasterise gate already tested `handle != 0`; the published verdict now agrees. * The ETW event callback did `RING.lock().unwrap()`. That is an `extern "system"` fn, so a poisoned lock panicked across an FFI boundary and ABORTED the host — a diagnostic taking down capture. Poison-tolerant now, which also makes the poison unreachable. * `ChannelBroker::send` bounded the ring with `debug_assert`, so a release build instead panicked mid-`duplicate_and_deliver`, unwinding past the reap and leaking every handle already planted in the driver's WUDFHost. Refuses before the first duplication. * `set_active(false)` did not clear `stall_since`, so a pooled capturer carried a stale stall clock into its next stream and reported capture loss microseconds in. * `attach_gamescope_cursor` evaluated `spawn` before dropping the old source: two readers published into one slot, and a failed spawn destroyed a working reader. Idempotent now. * `PUNKTFUNK_FORCE_SHM` used a bare `== "1"` compare, silently ignoring `=true`/`=on`. * `spa_meta_bitmap.offset == 0` is SPA's "no image data" signal, distinct from the `bitmap_offset == 0` position-only case. Unhandled, it decoded the header's own words as cursor pixels and cached them. * A `VideoInfoRaw::parse` failure was swallowed, so a malformed Format pod surfaced as the generic "no acceptable format" timeout. It is logged, and parsed once, not twice. Comment corrections, all verified against the code they describe: four claims that a failed open falls back to DDA (removed — the caller drops the keepalive under "no fallback"); three comparisons to the removed WGC path; "we do NOT gate HDR on the client's VIDEO_CAP_10BIT" (it does, in three places); the P010 sampler's "4 explicit taps / 2x2 box" (two taps, left-cosited — the box was the bug it replaced); the cursor meta cap quoted as 256x256 (1024, and 256 is the value that cost the whole Linux cursor channel on-glass); the poller's "~60 Hz" (4 ms, ~250 Hz); "several minutes of coverage" (~26 s); "8 frames in 400 ms >= 20 fps" (7 intervals, so 17.5); three "process-wide" HDR latch claims (per-source, which is why HdrSource exists); a SAFETY proof claiming a view is "unmapped never" (Drop unmaps it); the Linux module header describing a bounded channel and BGRx-only frames (one-deep overwriting slot, several formats); and a doc line stranded on `DisplayDescriptor` by an earlier split, restored to `IddPushCapturer`, which had none. |
||
|
|
6b33750edc |
fix(pf-vdisplay): one non-UTF-8 byte in a portal config destroyed the whole file — in the module written to prevent exactly that
`portal_config::ensure_key` folded EVERY read failure into an empty string
(`read_to_string(path).unwrap_or_default()`). `upsert("", …)` then produced a file containing only
our block, the one-time backup was skipped because `!existing.is_empty()` was false, and the write
replaced the user's config — returning `Ok(true)`.
So a single Latin-1 character in a comment in `~/.config/hypr/xdph.conf` or
`~/.config/xdg-desktop-portal-wlr/config` destroyed the operator's entire portal configuration, with
no backup and no warning. The module doc says flat-writing these files "destroyed [everything else]
on first connect, silently and permanently" and that this module exists so it cannot happen; that
one line re-opened the door. The same shape hit a transient EIO on an NFS or overlay config dir.
Now: bytes are read with an explicit match, only `NotFound` may mean "empty", a non-UTF-8 config is
refused by name rather than replaced, the backup is taken by BYTES, and the write is atomic
(temp + `sync_all` + rename in the same directory, permissions carried over). Five new tests, all
running on macOS — `a_non_utf8_config_is_refused_not_replaced` fails against the old code.
Also in the wlr/Mutter family:
* **Mutter's `Primary` rebuilt kept physicals from scratch** — scale forced to 1.0, transform to 0,
disabled heads re-enabled — so a rotated, 2x-scaled or deliberately-disabled monitor came back
wrong, while the code went to real trouble to preserve refresh. Each head now carries its
pre-connect scale and transform, and x advances by the LOGICAL width.
* Three availability probes read session env (`SWAYSOCK`, `XDG_CURRENT_DESKTOP`,
`HYPRLAND_INSTANCE_SIGNATURE`) with no `ENV_LOCK` while `apply_session_env` `set_var`s the same
keys from another thread — the glibc setenv/getenv race this crate's own lib.rs documents as UB.
* `wlroots::create_output` ran a statement before its `OutputGuard` existed, so a raced
`wait_new_output` orphaned the output permanently — hyprland takes the guard first. The
before/after name diff also ran outside any lock, so two concurrent creates could adopt each
other's output. Both now run under a create lock, with a stray sweep on the failure path.
* `select_and_cast`'s timeout arm dropped the portal thread's `stop` flag un-set — the same leak
Mutter was already fixed for. The guard is now built before the wait, in both copies.
* The xdpw chooser file was written per session and never removed, permanently shadowing the
config's fallback with the name of an already-unplugged output. Its lifetime is now the handshake,
not the session — scoped deliberately, because tying removal to the keepalive would let one
session delete another's selection hours later.
* Hyprland's headless outputs are now named `PF-<pid>-<n>` and reconciled at startup, so a crashed
host's leftovers are reclaimed while a live sibling host's outputs cannot be pulled out from under
it. `set_monitor_rule` no longer discards hyprctl's rejection text and then hard-codes a
GBM/dmabuf diagnosis it never verified.
* Both wlr backends silently dropped the `topology` policy axis: `Primary`/`Exclusive` was accepted,
echoed by the mgmt API, applied on three backends and a no-op on two. They now say so.
Item 8.1: `swaymsg`, `hyprctl` and the portal `systemctl --user try-restart` calls are bounded
through `proc` with named budgets.
|
||
|
|
ef72d102b6 |
fix(pf-vdisplay): KWin's re-enable reported success when it matched no outputs at all, leaving a physical monitor dark
* **`reenable_outputs` returned `true` when it matched NONE of the requested outputs.** Unresolvable outputs were `continue`d and the return was the apply verdict alone — but an empty `kde_output_configuration_v2` still gets an `applied` event. So a total no-op suppressed the `reenable_outputs_kscreen` backstop and the operator's physical monitor stayed dark. Now counts staged outputs and returns `ok && matched == outputs.len()`, and refuses to apply an empty configuration at all. * **The kscreen restore logged "restored the physical/bootstrap outputs" unconditionally**, with both call results discarded — including when `kscreen_ok` returned false on its 5 s budget, which is exactly the wedged state that fallback exists for. * **`Session::open` swallowed every failure reason** — connect error, barrier timeout, missing global — and three of four callers degraded to kscreen-doctor with zero log. This is the class that hid the KWin >= 6.7 registry regression: a shipped fallback firing silently on every machine. It now logs at warn with the reason and the caller's operation name. * `last_name` was seeded with a name kscreen-doctor can never resolve (KWin's address is `Virtual-punktfunk…`), so the intended default was guarded by an `is_none()` that could never hold and `apply_position` ran against no output. `our_uuid` was never reset per `create` and only assigned under `outcome.handled`, so a supersede positioned the *previous* output and never fell back. * `probe()`'s `roundtrip` was the only unbudgeted compositor wait in the crate — every sibling path is budgeted — and it is reached from an async mgmt handler. Now bounded at 3 s. The pre-`created` dispatch loops gained deadlines and now set `stop` on the timeout arm. * Every `wl_output` global was bound for the session's life with no `GlobalRemove` arm and no `release()`, on the virtual-output path too, which never reads them: unbounded growth on a hotplugging session. * `monitors::list` was the one KWin call site with no kscreen fallback at all, despite `list_monitors` failing on exactly the condition the other four fall back for. It has one now. * `CVT_H_GRANULARITY` and `MANAGED_PREFIX` existed as two literals under prose asserting they match; the second copy now imports the first. The wider facade extraction (item 9.1) is deliberately not in this commit, but its two prerequisites are — a comment at the restore seam records why they had to come first: a fallback arm that returns a value the helper never checked re-introduces the silent success, behind a seam whose selling point is one honest log per decline. Also corrects the `PhysicalMonitor` type doc, which claimed "logical geometry throughout" while `width`/`height` are the mode's PIXELS and `x`/`y` are logical, and adds the `logical_size()` helper that is the only correct way to compare an extent against a position. |
||
|
|
b2c03f1904 |
fix(pf-vdisplay): a managed launch blocked the shutdown restore that was meant to rescue it, and re-moding could flip the operator's own screen
The gamescope subsystem — the crate's largest and fastest-churning area, and the one the 2026-07-28
sweep predates most of.
* **`MANAGED_SESSION` was held across the ~90 s managed launch**, and the shutdown/idle restore
blocks on that same lock — *after* it has already stopped our unit. So the display-manager restore
never ran and the box was left with no session at all. `create_managed_session` now decides under
the guard and acts outside it, re-acquiring only to store the result; `do_restore_tv_session`
consumes the record in a short scope at the top. Same shape the SteamOS twin already used.
* **The physical-display guard was bypassed whenever no gamescope node happened to be published.**
`if physical_display_connected() { if let Some(node) = find_gamescope_node() { … } }` fell through
to `set-environment SCREEN_WIDTH/HEIGHT/CUSTOM_REFRESH_RATES` + `restart` when the node was
momentarily absent — gamescope restarting between titles, or built without PipeWire — flipping the
operator's own screen to the client's resolution and bouncing a DM-driven login session. The guard
now refuses instead of falling through, and the forced `SCREEN_*` values (which were never unset,
so every later session on the box inherited them) are tracked and `unset-environment`ed on restore.
* **`current_gamescope_output_size()` reported an arbitrary gamescope's `-W`/`-H`** — whichever
`/proc` enumerated first — and four consumers treated it as this session's output size. It now
answers only when every gamescope on the box agrees, and `None` ("cannot tell") when they differ.
`heads.rs` no longer takes it at all: it reads the size off the DRM-backed argv it already
selected. Its test previously passed `None`, which is why the hazard was invisible.
Resource and honesty fixes: the ATTACH path armed the box's own session-unit bind drop-in and no
in-process path ever removed it (now tracked and disarmed on both restore arms); `wait_for_node`
never called `try_wait`, so a gamescope that died at `vkCreateDevice` was polled for the full 15 s
and the error then blamed headless capture support; `do_restore_tv_session` deleted its crash-recovery
state *before* the unbounded work that state records, so a grace-period expiry in that window left
the DM down with nothing on disk to heal it; the SteamOS takeover's two failure arms never armed the
TV restore though the session-plus twin does; the TV-session restore logged success with the
`systemctl` status discarded; the `steam -shutdown` child was dropped un-reaped; and a managed
session that took nothing over was never persisted, so a host crash orphaned the transient unit.
Item 8.1: the unbounded `pw-dump`, `systemctl`, `loginctl` and `pkexec` calls in this subsystem now
go through `proc::{status_within, output_within}` with per-call budgets. `pw-dump` is polled from
three separate 45 s loops against the very daemon this file documents gamescope as head-blocking,
and until now a hang there pinned the session's stream thread forever.
|
||
|
|
db65980979 |
fix(pf-vdisplay): the ghost-monitor reap fed live devices to pnputil, and two unsafe fns had no unsafe in them
Windows half of the sweep — the reap bug, a panic that poisons two locks, and a round of unsafe reduction. * **The ghost reap selected the wrong devices.** It filtered `Status -ne 'OK'`, a HEALTH field: that matches devices that are PRESENT but in Error/Degraded/Unknown, not the ABSENT ones the reap is for — and it handed them to `pnputil /remove-device`, contradicting its own documented contract. It runs from `add_monitor`'s mid-session slot-exhaustion recovery, so the blast radius is a live session. Now filters on `-not $_.Present`. * **`ensure_pinger` still used the panicking `thread::spawn` while holding two locks**, poisoning both — the un-fixed twin of a fix that already landed for `ensure_exclusive_watch`. Same shape applied. Unsafe reduction, continuing the program that made pf-win-display's CCD helpers safe fns: * `resolve_target_gdi` and `reisolate_after_swap` were `unsafe fn`s containing zero unsafe operations, and the three call-site SAFETY proofs described FFI they no longer perform. Both are now safe fns and those blocks are gone. * `VdisplayDriver::open`'s `# Safety` section named no caller obligation — the same empty shape an earlier phase already removed from `open_device`. * `(*detail).DevicePath.as_ptr()` derived a pointer from a `[u16; 1]` field and handed it to `CreateFileW`, which reads the whole flexible-array path beyond it. Now taken with `&raw const` from the full struct, so the pointer carries the provenance of the bytes actually read — the same correction already made for `MONITORINFOEXW` in ddc.rs. Comment fixes, all verified against the code: three intra-doc links to a type this crate does not have; a doc-comment run merged so that `shrink_action` — the gate that keeps a `Primary` group's physical panels lit — read as undocumented while its rationale sat on an unrelated polling helper; and the backend module header, which documented itself against a `sudovda` module that does not exist and a fallback the crate says was removed. Adds the first tests for `knobs.rs`, `instance.rs` and `driver.rs` — including `is_privileged_sid`, the security-relevant predicate that decides whether an existing single-instance name is another host or a squat, which had no coverage on any platform. |
||
|
|
a1ff0dde0c |
fix(pf-vdisplay): the host promised HDR and cursor forwarding for gamescope sessions it did not start
`gamescope_ours_and` answered "did WE spawn this gamescope?" by reading `PUNKTFUNK_GAMESCOPE_NODE`. Phase 2.3 deleted the code that published that key — routing.rs's own doc says "Nothing is written back to the two knobs" — but this consumer was never migrated, so the read now returns "not attaching" for every attach. Both consumers then answer for a session this host has no flags on. On a plain box with a foreign gamescope already running, `pick_gamescope_mode` resolves Attach at its fifth rung while the env key stays unset, and the probe half only inspects the resolved BINARY, which is our patched build: * `gamescope_composites_cursor()` returns true, so the host attaches no XFixes reader and blends nothing — while the stock gamescope actually running was never given `--pipewire-composite-cursor`, so the stream carries no pointer at all. * `gamescope_hdr_available()` returns true, so the Welcome fixes `bit_depth` at 10 and the session negotiates BT.2020/PQ over an 8-bit SDR composite. The Welcome cannot take that back. The same two failures hit the `capture_monitor` mirror route on any Bazzite or SteamOS box, where the running Game Mode gamescope is by definition not one this host spawned. The question is now asked of the resolved route rather than the environment, via a pure `session_is_a_foreign_gamescope` that runs — and is tested — on every platform. The residual gap is named in the doc rather than papered over: `create_managed_session`'s create-time degrade to a foreign attach is still invisible to a ladder re-run. Also in this commit: * Two unguarded session-env reads now take `ENV_LOCK` (`detect()`'s `XDG_CURRENT_DESKTOP` fallback and `effective_topology()`'s legacy pins). `apply_session_env` `set_var`s those same keys from another thread, which is the glibc setenv/getenv race this crate's own lib.rs documents as UB. * `mirror.rs`'s `names_ours_conclusively` was a `matches!` whose omitted default was the UNSAFE direction — a new backend would silently get its own virtual displays mirrored. Now exhaustive, so adding a `Compositor` is a compile error at the one site where the answer is a safety decision. * `MirrorDisplay` overrides `poolable_now() -> false`; its `create` always reports `External`, so the trait's `true` default was a pre-create claim contradicting the post-create fact. The trait doc now says plainly that the default is a default and not a fact. * The crate front-door doc listed 3 of 7 backends and quoted line counts half the size of the current crate; `routing.rs`'s summary was attached to the wrong item and described a published env channel that no longer exists; `available()` is no longer documented as cheap when it forks `gamescope --version` and does an unbudgeted Wayland roundtrip per call. |
||
|
|
9d58f4c170 |
fix(pf-vdisplay): one unreadable byte reverted the host to built-in display defaults, and one bad preset dropped the whole catalog
The policy layer folded every failure into "unconfigured", then wrote that emptiness back. * **Any parse error reverted the WHOLE policy.** An unknown enum variant, a mistyped scalar, an EACCES or EIO — all became `Err(_) => None`, i.e. the host silently ran on built-in defaults with the operator's `display-settings.json` still sitting on disk. Parsing is now layered: strict first, then per-axis salvage so one unreadable axis costs only that axis, and only `NotFound` is quiet — EACCES/EIO warn loudly that the host is on defaults. `version` is read instead of being blindly rewritten to 1. * **One malformed entry dropped the entire custom-preset catalog**, and the next CRUD atomically renamed the empty vector over the file. Entries are parsed one at a time now; a lossy load is flagged and refuses to overwrite. * `sanitized()` clamped `max_displays` but never `KeepAlive::Duration.seconds`, so a PUT could pin a display for ~136 years — a deadline the reaper never reaches and a nonsense `expires_in_ms` in `/display/state`. Clamped to a day, in both `sanitized()` and `sanitize_preset_fields`, and sanitization now runs on LOAD as well as on write. * The two stores' temp files had fixed names and no write lock, so concurrent saves could interleave serialize -> rename -> in-memory update. Unique suffixes, a lock, and the in-memory update ordered after the rename. * `new_preset_id` never consulted the loaded entries for collisions. * **Manual layout could place an unpinned display exactly on top of a pinned one**: the fallback was the unconditional auto-row prefix sum, blind to where prior members were pinned. Unpinned members now pack clear of the pins. Layout keys are canonicalized and unusable ones dropped at write time rather than persisted-and-ignored. Adds 20 tests, all running on macOS: a 20k-round randomized property test asserting no unpinned member ever overlaps a sibling (verified to fail against the pre-fix `arrange_manual`), the salvage and quarantine paths, the clamps, and a field-count guard that fails the moment a 13th policy axis appears without being wired into the merge path. Note: `partial_json_fills_defaults` was renamed to `serde_defaults_fill_a_partial_document` with no assertion weakened — it pins the FILE contract (an old settings file must still load), which is not the mgmt PUT contract that sweep item 11.1 is about. |
||
|
|
61ff543acc |
fix(pf-vdisplay): a new client could be handed a streaming client's display, and a blind /proc scan tore every backend down
Five defects in the registry/identity half, plus the restructure that finally makes them testable.
* **A new client could be assigned a LIVE client's identity slot.** `DisplayIdentityMap::resolve`
LRU-evicted purely on its `seen` stamp, with no knowledge of which ids are streaming. On Windows
that id keys the manager's slot map, so the newcomer took the plain-JOIN branch and inherited the
other client's monitor, capture target and stop flag. `resolve` now takes the live set, never
evicts a live id, and REFUSES rather than hand one over — degrading to the shared/auto identity.
* **A transient `ActiveKind::None` invalidated every backend entry, including live streaming ones.**
A `read_dir("/proc")` that happened to fail satisfied the change test and bumped the session
epoch. A `None` observation is no longer evidence a desktop went away, and no longer overwrites
the baseline (which would have bumped the epoch on the next poll anyway).
* **The Linux pool had no display ceiling at all** — `max_displays` was enforced only on Windows,
while the pool keys on the CLIENT-SUPPLIED mode, so each distinct requested resolution minted a
new display. Now capped in `linux::acquire`, gated on `poolable_now` so a gamescope attach or
managed session (which consumes no pool slot) is not refused.
* **Two different definitions of "display group"** — `group_key` and a bare backend-name compare —
and only one separated gamescope spawns. Unified as `pool::in_group`. The `position_for_new`
collection also lacked the supersede exclusion the topology check 70 lines earlier had, so a
mid-stream resize auto-rowed the replacement past its own dying predecessor, walking the display
one width to the right on every mode switch.
* **Lifecycle events were wrong in both directions**: `Created` fired on keep-alive reuse, and
`Released` fired only from the mgmt endpoint — never from a lease drop, the linger reaper,
`mark_failed`, `retire` or `invalidate_backend`. All six now emit.
Also: `Release::Noop` no longer runs a full teardown (the one outcome the state machine defines as
"do nothing"); a failed linger-reaper spawn logs and retries instead of consuming its `Once` and
never tearing a kept display down again; group ids are a monotonic per-key counter instead of an
index into the currently-live sorted set, so an unrelated group appearing no longer renumbers a
display; and a corrupt `display-identity.json` is renamed to `.bad` with a warning rather than
silently overwritten, which used to reset every client's stable id and its saved DPI.
The pure half of the pool (`Entry`, `group_key`, `epoch_matches`, `take_expired`, `at_display_budget`,
`position_for_new`, `assign_group_ids`, `assemble_displays`) is now a non-cfg'd `mod pool`, so the
registry's decisions are exercised on every platform's CI instead of only on a Linux box. Crate test
count 53 -> 94.
|
||
|
|
dd9bbaf1c5 |
fix(pf-vdisplay): a helper that outran the pipe buffer had its output thrown away as a timeout
`output_within` read stdout/stderr only after the child exited, and its doc justified that with "these helpers emit at most a few hundred KiB, well under any real pipe pressure". A pipe holds 64 KiB. Anything past that blocks the helper in `write()`, so it never exits, the budget kills it, and a successful query is reported to the caller as `TimedOut` with its answer discarded. The busiest caller is the one that trips it: `pw-dump` on a populated PipeWire graph clears 64 KiB routinely and is polled from the 45 s gamescope loops. Confirmed empirically — a child writing 1 MiB into an undrained pipe never exits. Both pipes are now drained on their own threads, concurrently with the wait. That makes the joins load-bearing, which exposed the second half: the Unix `tree::Guard` was an empty stub whose doc claimed `Child::kill` "already ends the only process there is". It never did for this crate's Linux helpers — `pkexec`, `systemd-run`, `systemctl --user` and the `sh -c` wrappers all fork — and a surviving grandchild holds the pipes' write ends, so a reader would wait for an EOF that never arrives. The child is now the leader of its own process group and the guard `killpg`s it, which is the Unix shape of the Job object the Windows half already used. Also gates `pf-frame`, `pf-gpu` and `pf-encode` to Windows: every use site of all three is `cfg(windows)`, and between them they dragged FFmpeg, ash and openh264 into the Linux build for nothing (sweep item 13.19). |
||
|
|
332a81b077 |
Merge remote-tracking branch 'origin/main' into worktree-android-console-visual-refresh
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m28s
apple / swift (pull_request) Successful in 1m41s
apple / screenshots (pull_request) Skipped
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m30s
ci / rust-arm64 (pull_request) Successful in 6m15s
android / android (pull_request) Successful in 6m58s
ci / web (pull_request) Successful in 1m16s
ci / bun-nix (pull_request) Successful in 21s
ci / docs-site (pull_request) Successful in 1m35s
ci / rust (pull_request) Successful in 15m56s
nix / flake (pull_request) Successful in 14m8s
|
||
|
|
d715225960 |
Merge pull request 'Launcher tiles get their launcher's logo — a brand token on the wire, the vector in every client' (#162) from worktree-launcher-icons into main
apple / swift (push) Successful in 1m33s
android / android (push) Successful in 7m5s
ci / rust (push) Successful in 7m54s
arch / build-publish (push) Successful in 8m5s
ci / rust-arm64 (push) Successful in 1m43s
ci / web (push) Successful in 58s
ci / bun-nix (push) Successful in 16s
ci / docs-site (push) Successful in 1m13s
release / apple (push) Successful in 10m37s
deb / build-publish (push) Successful in 3m57s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5s
deb / build-publish-client-arm64 (push) Successful in 3m46s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 56s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m27s
deb / build-publish-host (push) Successful in 7m0s
docker / builders-arm64cross (push) Successful in 30s
docker / deploy-docs (push) Successful in 51s
apple / screenshots (push) Successful in 5m48s
windows-host / package (push) Successful in 17m50s
windows-host / winget-source (push) Skipped
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m5s
flatpak / build-publish (push) Successful in 9m30s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m0s
windows-host / canary-manifest (push) Successful in 22s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m23s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m23s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m4s
Reviewed-on: #162 |
||
|
|
f62a48d4a9 |
feat(library): launcher tiles get their launcher's logo — a brand token on the wire, the vector in every client
apple / swift (pull_request) Successful in 1m42s
apple / screenshots (pull_request) Skipped
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m12s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m32s
ci / rust-arm64 (pull_request) Successful in 1m52s
ci / web (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m15s
ci / bun-nix (pull_request) Successful in 18s
android / android (pull_request) Successful in 3m56s
ci / rust (pull_request) Successful in 5m40s
A launcher tile (role: "launcher", design D4) shipped no art on purpose:
a launcher's own icon is square, every client cover-crops a 2:3 poster,
and the crop turns a mark into a strip. So the tiles were the launcher's
name on a flat accent face — legible, and the blandest thing in the grid.
Entries now carry an optional `icon`: the NAME of a brand mark, never
image bytes and never a URL. `[a-z][a-z0-9-]{0,31}`, shape-validated by
the host on every lane (a client interpolates the value into a resource
name or an asset lookup, so the guard belongs upstream of all of them,
and each client re-checks rather than trusting the peer).
A token rather than art because the alternative is closed by
construction, and deliberately: the art proxy serves what the bytes ARE
(sniff_image_type) and SVG is not on that list — it is script-capable
XML and the console renders library art in a browser. Widening that
sniff would trade a rendering nicety for a stored-XSS surface. Naming
the mark keeps the refusal intact, keeps the glyph vector at whatever
size a tile happens to be, lets it take the tile's ink, and adds nothing
to a reconcile payload that is already body-limited. The cost is that a
third-party plugin cannot ship a mark no client bundles; its tile falls
back to the launcher's name, exactly as before, and the fix is a PR
adding the master.
assets/launcher-icons/ holds seven monochrome masters with per-mark
provenance and licensing (Simple Icons CC0: lutris, heroic, epic, gog;
Font Awesome CC BY: steam, xbox; Playnite's own logo, MIT). steam is
generated FROM assets/os-icons/steam.svg so the SteamOS host badge and
the Steam launcher tile can never drift.
scripts/gen-launcher-icons.sh bakes the three derivatives that cannot
consume a master (GTK symbolic SVG, Windows PNG, Apple template PDF)
and — unlike gen-os-icons.sh, which prints path data for a human to
paste — GENERATES the three inline registries (web console, Android
ImageVector, pf-console-ui Skia). Three clients x seven paths of up to
3 kB is a transcription error waiting to happen, and a mangled character
is a silently wrong logo rather than a build failure. The generated Rust
goes through rustfmt, since `cargo fmt --all --check` is a CI gate and a
generated file that fails it would fail every regeneration.
All six renderers draw the mark CONTAINED, never cover-cropped: the
masters' viewports are not square (steam 496x512, playnite 1024x1024)
and filling a 2:3 frame would reproduce the strip this exists to avoid.
Every one keeps its old fallback for a token it has no art for.
Epic, GOG and Xbox marks ship dormant. Those plugins' launcher switches
are off by default and emit nothing, because the host has no verified
launcher_ui activation for them yet — shipping the art now keeps turning
one on the one-line plugin change those plugins promise, instead of also
needing a release of all six clients.
api/openapi.json and the SDK are regenerated (the spec's version field
was stale at 0.25.0 and now reads 0.26.0, which is the crate's actual
version — an unrelated line that regeneration necessarily corrects).
Verified: host cargo check, clippy -D warnings across pf-client-core /
pf-console-ui / punktfunk-client-session / punktfunk-client-linux, plain
build, pf-console-ui tests (77, including a new one asserting all seven
masters parse under Skia and one asserting the letterbox stays inside
its box), pf-client-core tests (188), cargo fmt --all --check, Apple
swift build, Android compileDebugKotlin, web tsc + vite build,
plugin-kit tsc, biome. The Windows client is NOT compile-verified — it
cannot be built from a Mac (scripts/xcheck.sh covers only the capture
stack by design) and CI does not build it either; its tile change needs
a real box before it ships.
|
||
|
|
c817e4bec0 |
fix(pf-vdisplay): a CAPPED compositor refuses /proc/exe — NixOS+KDE still undetectable
apple / swift (pull_request) Successful in 1m55s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Failing after 3m34s
android / android (pull_request) Successful in 4m27s
ci / rust-arm64 (pull_request) Successful in 1m57s
ci / web (pull_request) Successful in 1m4s
ci / bun-nix (pull_request) Successful in 19s
ci / docs-site (pull_request) Successful in 1m18s
v0.27.0 resolved the nixpkgs wrapper decoration (`.kwin_wayland-w`) through `/proc/<pid>/exe`.
On the box that fix was written for, the kernel refuses that link.
Reading `/proc/<pid>/exe` is not gated on owning the process: `cap_ptrace_access_check` requires
the reader's effective set to be a superset of the target's PERMITTED set. NixOS's own Plasma
module ships `security.wrappers.kwin_wayland = { capabilities = "cap_sys_nice+ep"; }`, and our
host must stay uncapped — a capability is precisely what makes it unidentifiable to KWin (#136).
So the two traps compose: the name NEEDS `exe` because nixpkgs wrapped the binary, and `exe` is
DENIED because NixOS capped it. `detect_active_session` went back to `ActiveKind::None`, and every
connect died `no usable compositor` with `wayland="-"` — the identical signature to the bug #158
fixed, which is why a box worked around with a decoy process broke again when the decoy went away.
`match_name` now falls through to `argv[0]` (`/proc/<pid>/cmdline`) when `exe` is unreadable. It
reads correctly for the same reason `ps` does: make-wrapper's wrapper `exec -a "$0"`s the hidden
binary, so `argv[0]` survives the decoration `comm` does not. It is consulted LAST and never
overrides a readable `exe` — it is the process's own claim about itself, and a same-uid process can
set it to anything; the worst a spoof achieves is aiming detection at a backend that then fails its
own availability probe.
MEASURED (Linux 6.x, same-uid reader, target holding cap_sys_nice), for a file capability and for
the ambient form `security.wrappers` actually uses — identically:
/proc/<pid> owner ✅ real uid (so the uid filter upstream was never the problem)
comm ✅ readable (decorated + truncated, unusable alone)
exe ❌ EACCES
cmdline (argv[0]) ✅ readable
End-to-end against a LIVE capped, `.kwin_wayland-wrapped` process, old resolver vs new, both
extracted verbatim from their own sources: v0.27.0 answers `.kwin_wayland-w` (no match) in both
capped scenarios and `kwin_wayland` uncapped; the fixed one answers `kwin_wayland` in all three.
Also reached by the same rung: gamescope carries `cap_sys_nice` on a number of distros, so a
wrapped-and-capped gamescope was equally invisible to the foreign-gamescope probe.
Tests are fixture-driven (a temp dir with `comm`/`exe`/`cmdline`), for the reason #158 documented:
a renamed `/bin/sleep` stand-in dies instantly on multi-call coreutils and reads exactly like the
resolver being broken. 11 tests extracted verbatim and executed on Linux as a non-root user —
0 failures. `cargo fmt --check` and `scripts/xcheck.sh linux clippy` (`--all-targets`) clean.
Refs #158, #136.
|
||
|
|
61dfc3dadc |
Merge remote-tracking branch 'origin/main' into worktree-nixos-wrapped-comm-detection
ci / bun-nix (pull_request) Successful in 40s
ci / web (pull_request) Successful in 1m19s
ci / docs-site (pull_request) Successful in 1m30s
ci / rust-arm64 (pull_request) Successful in 1m46s
android / android (pull_request) Successful in 5m22s
ci / rust (pull_request) Successful in 6m47s
apple / swift (pull_request) Failing after 13m41s
apple / screenshots (pull_request) Skipped
nix / flake (pull_request) Successful in 13m48s
|
||
|
|
0b550dad84 |
feat(android): the console tables stop drifting in silence, and the stats overlay gets a pad route
WP8 and WP9 of `punktfunk-planning/design/android-console-ui-visual-refresh.md`, in part. **WP9.3 — shared parity vectors.** The console's background palettes, its settings section names and its screen-transition motion each existed in three hand-written copies (`pf-console-ui`, this client, the Apple client) held together by a comment asking the next person to keep them in step. `clients/shared/console-vectors.json` now holds them, read the way `deeplink-vectors.json` already is: `include_str!` in Rust, a relative path in Kotlin, `#filePath` in Swift — never a copy, because a copy is a fourth contract free to go stale. It carries the DERIVED tables too, the 16-cell mesh and the 4 blob colours per palette, which is the half that reaches the screen and the half Android never checked: `GamepadPaletteTest` only ever measured the `stops` they are computed from. Two drifts it immediately caught, both now closed: * **The easing was the wrong curve.** `ConsoleMotion.EaseOutCubic` shipped as `cubic-bezier(0.215, 0.61, 0.355, 1)` while claiming to be the desktop's `ease_out_cubic`. It is not: that is the Penner/Ceaser table's curve, ~0.80 at the midpoint where `1 − (1−t)³` is 0.875 — visibly slacker over a 260 ms transition. Compose's `Easing` is a plain function, so it now evaluates the real thing analytically rather than approximating it at all. (Apple approximates with a different bezier only because SwiftUI's `timingCurve` cannot take a closure; the vectors sample the curve with a tolerance so all three can meet it.) * **The desktop has a seventh tab.** Input — touch mode, mouse, invert-scroll, shortcuts — with nothing to set on a phone or a TV. `settings.rs` claims in prose that a setting is found under the same word on every client; that was true modulo an omission nobody could see. The vectors model it with `desktop_only` rather than picking a side, so neither client has to be wrong. Rust reads it from three tests placed in the files that own the constants, so nothing had to be made `pub` to be checkable. Verified green under Linux (the crate is `cfg(linux|windows)` throughout — `cargo test` on a Mac compiles nothing and passes vacuously): 77 passed, 0 failed. Android's side gates in CI as a FILTERED task; a plain `:app:testDebugUnitTest` would drag the ~20 Roborazzi screenshot scenes into every push, and those are a release-artifact job. **WP8.1 — a pad route to the stats overlay.** The tier could only be cycled by a three-finger tap, which does not exist on a TV, on a gamepad-only session, or under touch passthrough — while the settings row promised a live cycle. `Select + X` now cycles it, byte-identical to the Apple client's `GamepadWire.back | GamepadWire.x`, implemented as the mic chord's twin in `GamepadRouter` and edge-triggered on the button that completes the mask. The buttons still reach the game, as both existing chords do. `GamepadChordTest` pins eight cases the kit had no cover for at all, including that the three chords intersect only on Select and that none is reachable through another. **WP8.5 — a start-of-stream banner.** The desktop's `skia_overlay` banner, ported with its timing (opaque 5.4 s, then a 0.6 s fade) and its rule of naming only shortcuts that exist: pad chords when a pad is present, the touch gesture when there is a touchscreen and the mode can use it. Nothing `Ctrl+Alt+Shift` is advertised, because Android has none of it. It yields to the motion-unreachable notice rather than stacking with it — that one reports something broken about *this* session. **WP8.6 — the home card says which profile it connects with.** `HomeTile` carried a `pinnedProfileId` the card never drew, so a pinned host+profile card was distinguishable from the host's own only by a subtitle that had been quietly repurposed to hold the profile name. Both now show the address like every other card and wear a tinted profile chip — the touch grid's own convention and the Apple client's, inked from the console palette. Unsaved tiles (discovered, Add Host) take a dashed edge, which is what the other two surfaces already use to say "not yours yet". ⚠ Not a detail panel: the Apple client REMOVED its own and moved the status onto the card, which is where the lock and the online pip already were here. **WP8.7 — accessibility, in part.** The console screens carried three `contentDescription`s and no `semantics`, `Role` or `stateDescription` at all. A settings row now announces once, merged — label, value, and the description that lives in the floating band far from it — with `Role.Switch` and a real toggle state, because a toggle row's on/off string was drawn by nothing at all: the switch replaces the value text, and the switch was two undescribed `Box`es. Decoration is silenced rather than labelled (the chevrons were read aloud as punctuation on every focused row). The hint bar's glyphs, the tab strip and the home tiles are done; `GamepadAddHostScreen` and `LibraryScreen` are not yet. |
||
|
|
002702bcec |
fix(pf-vdisplay): NixOS sessions were undetectable — comm is the WRAPPER's name
The session probe decided "is a desktop live?" by reading /proc/<pid>/comm for
every process of our uid and exact-matching it against "kwin_wayland" /
"gamescope" / "gnome-shell" / "Hyprland". comm is the kernel's name for the
executed FILE, truncated to 15 bytes — not argv[0].
nixpkgs wraps essentially every graphical binary: wrapProgram moves the real
ELF aside to `.<name>-wrapped` and installs a wrapper under the original name,
which then `exec -a "$0"`s the hidden file. So on NixOS the kernel reports
`.kwin_wayland-w` (15 bytes of `.kwin_wayland-wrapped`) while ps/pgrep -a show
a perfectly ordinary `kwin_wayland`, because they read argv. Measured against a
live kernel: `.kwin_wayland-w`, `.kwin_wayland_w` (KWin's own
kwin_wayland_wrapper), `.gamescope-wrap`, all 15 bytes.
Nothing downstream could recover from that one string comparison:
- detect_active_session returned ActiveKind::None on a *running* KDE desktop;
- wayland_display is only resolved for a detected kind, so the connect log
reported wayland="-" even though WAYLAND_DISPLAY was correct;
- pick_compositor's Auto arm returns the DETECTED backend, so a live, fully
working KWin sitting in available() was never chosen — every connect died
"no usable compositor";
- and PUNKTFUNK_COMPOSITOR could not rescue it: pinned_at_a_dead_session
consults the same probe, turning the miss into a hard error instead.
No environment variable reached the comparison — the XDG_CURRENT_DESKTOP
fallback in detect() is only on the pinned path. Capture itself was never at
fault: a decoy process merely NAMED kwin_wayland satisfied the probe and the
stream came up against the real KWin.
Resolve the name through /proc/<pid>/exe (the full, untruncated file name) and
strip the nixpkgs decoration. Both the leading `.` and the trailing `-wrapped`
are required before anything is stripped, so KWin's own real
`kwin_wayland_wrapper` binary keeps its name rather than collapsing into
`kwin_wayland` and handing the probe the parent's PID. The comm fast path is
kept for every ordinary distro — one read, no readlink, and no name that
matched before can stop matching.
Also applied to foreign_gamescope_running, which had the same defect: nixpkgs
wraps gamescope too, so the attach-vs-spawn ladder saw no foreign session.
Tests are fixture-driven rather than spawn-driven on purpose: a stand-in has to
be a real ELF that tolerates being renamed, and /bin/sleep is not one — modern
coreutils is a multi-call binary that dispatches on the executable's own name,
so a copy called `.kwin_wayland-wrapped` exits instantly and /proc/<pid>/exe is
gone before it can be read. That failure looks exactly like this resolver being
broken; it cost one debugging round here and the same trap is already recorded
in punktfunk-host's /proc matcher.
|
||
|
|
657e82cd29 |
fix(gamescope): a takeover's mask no longer bars the box's own way back into Game Mode
ci / bun-nix (pull_request) Successful in 17s
ci / docs-site (pull_request) Successful in 1m20s
ci / web (pull_request) Successful in 1m29s
apple / swift (pull_request) Successful in 1m44s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m47s
android / android (pull_request) Successful in 4m24s
ci / rust (pull_request) Successful in 6m24s
A managed takeover runtime-masks the box's `gamescope-session-plus@*` unit so its
session supervisor cannot restart it underneath our Steam. The only unmask ran in
`do_restore_tv_session`, on client disconnect — so for the whole stream the mask
stayed on, and it silently barred the door the user was most likely to walk
through next.
`mask_unit`'s own doc said the mask "blocks nothing" on images whose sddm helper
execs the session script directly. That is half right, and the half it gets wrong
is this bug: on f43 bazzite-deck the script's last act is
systemctl --user --wait start gamescope-session-plus@${CLIENT}.service
(verified on the .41 VM). What the mask fails to stop is the RELOGIN LOOP — sddm
keeps trying regardless, which is why stopping the DM is the real defense. What it
very much does stop is the unit, and with it every entry into game mode, including
the user's own deliberate "Return to Gaming Mode" after a mid-stream switch to the
desktop. Steam then sits on its "Switch to Desktop…" modal forever. `--runtime`
lives in tmpfs, so a reboot cleared it — hence "it works right after a reboot" —
and a plain `unmask` does not (measured: still `masked-runtime`).
So the mask's sound lifetime is shorter than the takeover's: it ends the moment the
box stops being ours. The mid-stream session watcher already detects exactly that,
so it now lifts the mask on a confirmed switch to a desktop session — ahead of the
`compositor_for_kind` arm, because a switch we cannot follow still has to unbar the
return. `Gaming` and `None` deliberately do not lift: a takeover's own managed
session reads as `Gaming` and one momentarily down between relaunches reads as
`None`, and lifting on either would void the mask for the whole stream, in exactly
the SDDM-storm window it exists for.
Fixes a second, worse leak on the way: `honor_session_select_switch` consumed
`STOPPED_AUTOLOGIN` — the only record of what carries a mask — without unmasking,
so under a DM-stop takeover the disconnect restore found an empty list and lifted
nothing. That mask outlived not just the stream but the boot. It is also what let
that path's own step 1 work at all, since the DM's autologin heads back into game
mode through precisely this unit.
The lift is idempotent, keeps the restart list intact (the disconnect restore still
owes those units a `start`), and every hand-back path now routes through it.
Verified on Linux: `switch_ends_mask_window` decision table, plus an ignored
end-to-end test driving real `systemctl --user` (masked → survives Gaming/None →
lifted by a desktop switch → restart list intact → idempotent). Proven non-vacuous
by planting "Gaming also lifts", which fails it on the during-stream assert.
|
||
|
|
7a8f63e906 |
Merge pull request 'The console UI answers the iPad field test — and the screen that would never open again was a UIKit read mid-render' (#154) from worktree-apple-gamepad-relanded into main
apple / swift (push) Successful in 1m37s
release / apple (push) Successful in 4m9s
ci / rust-arm64 (push) Successful in 4m48s
ci / web (push) Successful in 1m2s
ci / bun-nix (push) Successful in 17s
ci / docs-site (push) Successful in 2m6s
apple / screenshots (push) Successful in 3m2s
android / android (push) Successful in 11m1s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 52s
deb / build-publish-client-arm64 (push) Successful in 3m48s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m41s
docker / builders-arm64cross (push) Successful in 6s
deb / build-publish-host (push) Successful in 8m48s
ci / rust (push) Successful in 14m25s
docker / deploy-docs (push) Successful in 31s
arch / build-publish (push) Successful in 15m35s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m43s
flatpak / build-publish (push) Successful in 7m12s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m54s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m14s
deb / build-publish (push) Successful in 15m17s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m26s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m49s
Reviewed-on: #154 |
||
|
|
35b5ee6a36 |
Merge pull request 'punktfunk-encode-worker: GPU priority via a capability-carrying worker, with WP3 on-glass complete' (#153) from worktree-worktree-encode-worker into main
audit / bun-audit (plugin-kit) (push) Successful in 20s
audit / bun-audit (web) (push) Failing after 20s
audit / bun-audit (sdk) (push) Successful in 20s
audit / pnpm-audit (push) Successful in 9s
audit / docs-site-audit (push) Successful in 20s
audit / cargo-audit (push) Successful in 1m9s
apple / swift (push) Successful in 1m42s
ci / web (push) Successful in 1m21s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m48s
ci / docs-site (push) Successful in 1m19s
ci / bun-nix (push) Successful in 17s
android / android (push) Canceled after 5m0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 5m1s
ci / rust (push) Canceled after 4m17s
ci / rust-arm64 (push) Canceled after 4m8s
deb / build-publish (push) Canceled after 54s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 3s
release / apple (push) Canceled after 3m58s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 2m10s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
decky / build-publish (push) Successful in 26s
audit / license-gate (push) Successful in 6m39s
windows-host / package (push) Successful in 13m21s
windows-host / winget-source (push) Skipped
nix / flake (push) Successful in 15m53s
windows-host / canary-manifest (push) Successful in 25s
Reviewed-on: #153 |
||
|
|
84faeb1bf1 |
docs(pf-encode): Linux Main10 is live — the 'inert until Phase 5.1' comment outlived the code
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m8s
apple / swift (pull_request) Successful in 1m36s
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 1m15s
ci / web (pull_request) Successful in 1m52s
ci / docs-site (pull_request) Successful in 1m58s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m26s
ci / rust (pull_request) Failing after 4m39s
ci / rust-arm64 (pull_request) Successful in 5m35s
android / android (pull_request) Successful in 5m44s
nix / flake (pull_request) Failing after 18m37s
The bit_depth field said '8 on Linux until Phase 5.1 lands a P010 capture path'. The code outran it: the gamescope HDR capture patches offer 10-bit BT.2020/PQ, nvenc_fmt maps X2Rgb10/X2Bgr10 to ARGB10/ABGR10, and is_ten_bit_input flips bit_depth and hdr from the negotiated input. Verified on home-nobara-1: 'resolved session plan ... bit_depth: 10, hdr: true' on the direct backend. A 10-bit frame deliberately takes neither the NV12 nor the YUV444 convert (both compute CSCs write 8-bit planes) and rides packed RGB to the encoder, which does its own BT.2020 CSC — pf-capture/src/linux/pipewire.rs owns that gate. So Main10 needed no P010 path to arrive, and P010 is now a perf follow-up (skip NVENC's internal CSC, as NV12 does for SDR), not the thing that makes 10-bit work. |
||
|
|
ad63994cb9 |
fix(pf-encode): the 10-bit probe was the last ffmpeg NVENC open on a direct-SDK host
ci / bun-nix (pull_request) Successful in 24s
ci / web (pull_request) Successful in 1m3s
apple / swift (pull_request) Successful in 1m43s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m14s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m59s
android / android (pull_request) Canceled after 3m11s
ci / rust (pull_request) Canceled after 3m13s
ci / rust-arm64 (pull_request) Canceled after 3m13s
nix / flake (pull_request) Canceled after 2m4s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 1m9s
`can_encode_10bit`'s Linux NVIDIA arm answered "can this GPU encode 10-bit?" by
opening an ffmpeg `hevc_nvenc` encoder. On a host that then streams over the
direct SDK, that is the LOG-3 field bug: one ffmpeg NVENC open in a direct-SDK
process wedges every later open process-wide with `NV_ENC_ERR_INVALID_VERSION`
until the host restarts.
`can_encode_444` was moved off the ffmpeg probe for exactly this reason on
2026-07-27. The 10-bit one was deliberately left behind, on the reading that
"Linux HDR genuinely rides the libav P010 path". `open_video` contradicts that:
if cuda && nvenc_direct_enabled() { // no 10-bit exclusion
… NvencCudaEncoder::open(…, bit_depth, …)
A CUDA capture goes to the direct backend at whatever depth was resolved, and
`is_ten_bit_input` already accepts the packed 10-bit RGB (`X2Bgr10`) that a
gamescope HDR capture negotiates. So on a default NVIDIA host the probe was
loading ffmpeg's NVENC client for a session that never uses it.
Observed on home-nobara-1 2026-08-10, gamescope + RTX 5070 Ti, client HDR on:
resolved session plan … bit_depth: 10, hdr: true
pipewire format negotiated … xBGR_210LE mapped=Some(X2Bgr10) modifier=0 hdr=true
encoder submit failed — encoder rebuilt in place … NV_ENC_ERR_INVALID_VERSION
encoder did not recover after repeated in-place rebuilds — ending the video session
and with `PUNKTFUNK_NVENC_DIRECT=0` (nothing mixes, libav serves everything) the
same HDR session streams clean: 0 errors, bit_depth=10, hdr: true.
The 10-bit cap now rides `nvenc_cuda::probe_support()`'s existing throwaway
session — the same place the 4:4:4 cap already rides, queried per listed GUID
with `NV_ENC_CAPS_SUPPORT_10BIT_ENCODE`, which is what the Windows NVENC arm has
always done (`enc/windows/nvenc.rs`). Unanswered fails CLOSED: an 8-bit session
beats a wedged one. A host that will really serve over libav
(`PUNKTFUNK_NVENC_DIRECT=0`, or a build without `--features nvenc`) keeps the
ffmpeg probe, where it validates the actual path and ffmpeg's client is loaded
anyway.
⚠ NOT YET VALIDATED ON GLASS. Gates are green — clippy `-D warnings` with
`--features nvenc,vulkan-encode,pyrowave` on linux/amd64, 67 pf-encode tests,
fmt — but the end-to-end HDR run is still owed. This branch is 42 commits behind
main and its build cannot complete a punktfunk/1 handshake on home-nobara-1 at
all (it stalls between "audio channels resolved" and "encode bit depth" and
times out at 10 s, on EVERY attempt). That stall is NOT this change: a control
build with only the routing reverted stalls identically, and the released
0.27.0 RPM on the same box handshakes fine and reaches `bit_depth=10`. Rebase
onto main before re-testing.
|
||
|
|
a23c028492 |
fix(host): the console reported the resolution the client asked for, not the one it got
`/api/v1/local/summary` (and the console card behind it) read the live-stats mode
slot, which bring-up seeded from the NEGOTIATED mode:
let live_mode = Arc::new(AtomicU64::new(pack_mode(
mode.width, mode.height, interval_hz(interval))));
The refresh was already corrected there — the comment says so, because KWin caps
a virtual output's rate — but the SIZE was still the request. Only a mid-stream
resize ever fixed it: the rebuild path below publishes `delivered_mode(frame..)`,
and bring-up never did.
Attach is what makes this matter rather than being pedantry. On a box with a
physical display the gamescope backend logs
gamescope: box drives a physical display — attaching at its own mode (no
re-mode) client_w=5120 client_h=1440
and streams the panel's size. Measured on home-nobara-1 with a 1080p HDMI panel
attached: the capture negotiated 1920x1080 and NVENC opened 1920x1080@240, while
the summary reported 5120x1440 — the console confidently naming a resolution
nobody was watching, which is exactly the shape of the stale attach-path report
noted on .41 in July ("reusing w=5120 h=1440" while the session was really 1080p).
Seeding the slot from `delivered_mode(frame.width, frame.height, interval)` uses
the same helper the rebuild path already trusts, and changes only the two fields
that were wrong — its refresh term IS `interval_hz(interval)`, so that half is
bit-for-bit what it was.
This publishes the STATS slot only. It deliberately does not send the client a
corrective `Reconfigured`: that remains owed exactly where it already was, under
`adopted_at_bringup`, because an ordinary connect's mode came from the Welcome
rather than from an accept the client has already acted on.
Verified on home-nobara-1, attach session against a 1080p panel:
summary session: {"width":1920,"height":1080,"fps":240}
actually captured: pipewire format negotiated width=1920 height=1080
Before the change the same session reported 5120x1440.
|
||
|
|
5b3ea6e8db |
fix(pf-encode): one NVENC open failure could kill every session on the box
`punktfunk-host` died twice on home-nobara-1 with the same stack:
__strlen_evex <- av_vbprintf <- format_line <- av_log_default_callback
<- ff_cuda_check <- ff_nvenc_encode_init <- avcodec_open2
<- NvencEncoder::open <- NvencEncoder::reset <- virtual_stream
once as an outright SIGSEGV mid-session, and once as a thread wedged in that
stack so the service never answered SIGTERM and systemd escalated to SIGABRT
("State 'stop-sigterm' timed out. Aborting."). Both times a client's session was
rebuilding its encoder. The blast radius is the whole host process — every other
client's session goes with it.
The fault is in libav, not here. `ff_cuda_check` logs the failing CUDA call as
`"%s failed -> %s: %s"` using an `err_name`/`err_string` pair the error lookup
does not always fill, and glibc then walks whatever was on the stack. We cannot
patch the distro's FFmpeg, so the fix denies it the chance to format: the guard
already used by the 4:4:4 probe drops the level to AV_LOG_FATAL across the open,
and `av_log_default_callback` returns on the level check before `format_line` —
these messages are AV_LOG_ERROR. The failure is not swallowed; it still comes
back as `Err(e)` and is reported with our own context, which now says the libav
text was deliberately silenced so nobody hunts for a message that will not come.
Scoped to the `open_with` call ALONE. The ENOSYS arm immediately below recurses
into `Self::open`, and `QuietLibavLog` holds a non-reentrant global mutex —
wrapping the whole `match` would have deadlocked the intra-refresh retry.
Verified on home-nobara-1 (fc44, libavcodec 62). With CUDA made unavailable so
the open fails inside the CUDA layer, the old binary prints
[hevc_nvenc @ ..] cuInit(0) failed -> CUDA_ERROR_NO_DEVICE: no CUDA-capable
device is detected
— that line IS `ff_cuda_check` formatting the two `%s` — and the fixed binary
does not; both exit 1 with our error instead. A successful open is unaffected on
both the direct-SDK and the libav paths (90/90 frames, identical output size).
What this does NOT claim: the uninitialized-pointer condition itself was not
reproduced on demand — it depends on the CUDA error lookup failing to fill the
strings, and in the forced case above it filled them fine. What is demonstrated
is that the formatting call which faulted is no longer reached during the open.
|
||
|
|
beb639f067 |
fix(ui): the OLED palette is called Eclipse now
Every other background reads as a place or a material — Violet, Nebula, Abyss, Ember, Moss, Graphite, Holo, Sunset, Bloom, Dawn, Mint, Opal — and "OLED" was a spec sheet sitting among them, naming the panel it suits rather than what it looks like. It looks like black with a thin violet corona at one corner, which is an eclipse, and that puts it beside Nebula and Abyss. The ID stays "oled" in all three clients: it is the stored ui_palette value AND the cross-client key, so renaming it would orphan every saved choice and silently desync Apple, Android and the console UI. Only the label moved. No test or doc pinned the old string. Palette tests pass; macOS builds. |
||
|
|
6695300b67 |
fix(pf-vdisplay): the gamescope bind took a user namespace that broke Xwayland, and left a drop-in that bricked the next boot
ci / bun-nix (pull_request) Successful in 24s
ci / docs-site (pull_request) Successful in 1m5s
ci / web (pull_request) Successful in 1m24s
ci / rust-arm64 (pull_request) Successful in 1m28s
apple / swift (pull_request) Successful in 1m37s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Canceled after 4m4s
ci / rust (pull_request) Canceled after 4m13s
Field-diagnosed on Nobara (fc44, canary g13179011), where Game Mode became unstartable and the box was handed to plasma. #144's bind works — the patched build genuinely reaches a session script that hardcodes /usr/bin/gamescope — but a mount namespace in a systemd USER unit is also a USER namespace, and only this uid is mapped in it. Measured on the box: on disk / in a unit without the bind : drwxrwxrwt 2 0 0 /tmp/.X11-unix in a unit WITH the bind : drwxrwxrwt 2 65534 65534 /tmp/.X11-unix uid_map inside : 1000 1000 1 wlroots checks that /tmp/.X11-unix is "owned by root or us", sees nobody, and refuses: wlserver: [xwayland/sockets.c:100] /tmp/.X11-unix not owned by root or us wlserver: [xwayland/sockets.c:217] No display available in the first 33 -> SIGSEGV in run_pipewire Three ~10 s failures then feed chimeraos' short-session tracker, session-plus stops even trying, and steamos-session-select rewrites the user's session to plasma. So the symptom an operator reports is "thrown onto KDE and I can't get back" — two removes from the cause. Two further bugs found while fixing it, each worse than the one reported: * THE BIND WAS ARMED EVERYWHERE. The condition was only `gamescope_bin() != /usr/bin/gamescope`, so every box with punktfunk-gamescope installed took a namespace it has no use for — Bazzite, SteamOS-likes, the Deck. The blast radius was every gamescope box, not just the hardcoded-path ones the mechanism exists for. Now the host READS the session script and arms only where it never mentions GAMESCOPE_BIN and names /usr/bin/gamescope outright; everything else is bit-for-bit pre-#144, no namespace at all. An unreadable script does not arm. * THE DROP-IN OUTLIVED ITS SOURCES. It was written to ~/.config/systemd/user/ on the TEMPLATE, so it also applied to the box's OWN autologin unit at every boot — while both paths it binds live in tmpfs. After a reboot the drop-in survives and its sources do not, and BindReadOnlyPaths= with a missing source fails the unit outright. THAT is why the field symptom survived a reboot. It now lives in $XDG_RUNTIME_DIR (dies with the login session), removal covers both the runtime and the legacy $HOME path, and restore_takeover_on_startup does that removal unconditionally at host start — which is the upgrade path for every box already running canary g13179011. Without it, updating the host would not un-brick them. * A bind was armed even when gamescope_bin() fell back to the bare name "gamescope". The wrapper execs `gamescope` through PATH inside the unit — onto the path we just bound the wrapper over. Fork bomb. Refused ahead of even the operator's force. Where the bind IS armed it now carries its own compensation: a user-owned $XDG_RUNTIME_DIR/punktfunk-x11 bound read-WRITE over /tmp/.X11-unix (Xwayland creates the socket there), so the ownership check sees "us". Skipped when that directory is already ours or absent — neither is the hazard. Stale sockets are pruned by connect-test so a SIGKILLed session cannot walk the 33 display slots away. And rather than trust that reasoning, the host now ASKS THE BOX before arming: it runs the field reproduction with the real property set — `systemd-run --user --wait --collect --property=<the same args> -- stat -c %u /tmp/.X11-unix` — and arms only if the answer is our uid. Anything else (65534, a rejected property, no user manager, a blown 10 s budget) means no bind, and the session runs stock gamescope: no HDR, no in-node cursor, but it STARTS. A runtime backstop disarms and relaunches if a session launched with the bind armed produces no node in its window, latching one-way per process. The XFixes-cursor concern that argued against relocating the socket does not hold: the only host-side X client is spawned under `plan.gamescope_cursor`, which is `gamescope && !gamescope_composites_cursor()`, and our shipped +pfhdr4 build is patch level 4 — so on the very route where the bind arms, that reader is never constructed. |
||
|
|
5d7091bf87 |
Merge pull request 'The Windows Xbox pad: make games actually see it' (#149) from worktree-xbox-pad-wgi-visibility into main
apple / swift (push) Successful in 1m35s
windows-drivers / probe-and-proto (push) Successful in 26s
windows-drivers / driver-build (push) Failing after 1m40s
android / android (push) Successful in 6m12s
ci / rust-arm64 (push) Successful in 5m5s
ci / bun-nix (push) Successful in 21s
ci / web (push) Successful in 5m31s
arch / build-publish (push) Successful in 8m28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
ci / docs-site (push) Successful in 4m14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
release / apple (push) Successful in 10m1s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 45s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 3m4s
docker / deploy-docs (push) Skipped
ci / rust (push) Successful in 12m22s
deb / build-publish-client-arm64 (push) Successful in 4m27s
deb / build-publish-host (push) Successful in 7m16s
docker / builders-arm64cross (push) Successful in 12s
deb / build-publish (push) Successful in 9m58s
apple / screenshots (push) Successful in 6m11s
windows-host / package (push) Canceled after 12m58s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
flatpak / build-publish (push) Successful in 9m46s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m48s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m50s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m23s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m31s
nix / flake (push) Failing after 21m34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 24m43s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 25m26s
Reviewed-on: #149 |
||
|
|
e19f11bb0d |
feat(abi/apple): carry the trigger motors to non-Rust clients — ABI 18, next_rumble_cmd2
windows-drivers / probe-and-proto (pull_request) Successful in 25s
apple / swift (pull_request) Successful in 1m37s
apple / screenshots (pull_request) Skipped
windows-drivers / driver-build (pull_request) Failing after 1m43s
ci / web (pull_request) Successful in 2m57s
ci / bun-nix (pull_request) Successful in 18s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m36s
android / android (pull_request) Successful in 4m0s
ci / rust-arm64 (pull_request) Successful in 4m19s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m32s
ci / docs-site (pull_request) Successful in 4m36s
ci / rust (pull_request) Failing after 9m15s
nix / flake (pull_request) Successful in 15m3s
The `0xCA` wire already carries the two Xbox impulse-trigger motors (v3), and the Rust decode path
already parses them; `datagram_task.rs` dropped them on the floor with a comment naming exactly this
work as what remained. The blocker was the C ABI: every non-Rust client pulls rumble through
`punktfunk_connection_next_rumble_cmd`, whose out-params cannot carry two more channels.
PunktfunkStatus punktfunk_connection_next_rumble_cmd2(
PunktfunkConnection *c, uint16_t *pad, uint16_t *low, uint16_t *high,
uint16_t *left_trigger, uint16_t *right_trigger,
uint32_t *backstop_ms, uint32_t timeout_ms);
⚠️ ADDED, not widened. `_cmd` keeps its signature and its values bit-identical for handle-only
traffic — out-of-tree embedders depend on it and `docs/embedding-the-c-abi.md` documents it, so
silently changing an exported symbol would break every consumer at once. `nm` on the staticlib shows
all four rumble entry points still exported. `ABI_VERSION` 17 → 18; every other site reads it
dynamically, so there are no hardcoded mirrors to drift.
⚠️ ONE HONEST BEHAVIOURAL DELTA, documented in `abi.rs` and pinned by a test: against a
trigger-driving host a `_cmd` caller now receives commands with `low == high == 0` where the demux
previously dropped the update entirely. They are idempotent handle stops, and the redundant-stop
suppression cannot fold them because the command as a whole is not silent. Zero cost today —
nothing sources non-zero triggers.
The dedupe-jitter proof was RE-DERIVED rather than widened, which is the kind of thing that quietly
rots when a tuple grows: the nudge touches only `low` by ±1 LSB and `emit` is only reached with a
non-silent level, so the nudged tuple can collide with the four-field stop sentinel only at
`(1,0,0,0)`. A test pins both directions — refuse at `(1,0,0,0)`, flip freely at `(1,0,lt,0)`.
Apple renders them: `RumbleRenderer` gains `Motor?` slots at `GCHapticsLocality.leftTrigger` /
`.rightTrigger` beside the existing handles. A controller without trigger actuators degrades
silently — a nil engine yields a nil slot and `reconcile` no-ops — and absent localities are never
logged, because on most pads that is the normal case rather than a fault. The macOS DualSense
raw-HID branch stays a deliberate no-op: a DualSense has ADAPTIVE triggers, not trigger rumble
motors, and inventing a mapping there would buzz the wrong thing.
🛑 BUILT AHEAD OF A PRODUCER, DELIBERATELY, AND NOTHING HERE CLAIMS OTHERWISE. Nothing can currently
source trigger rumble on Windows and that is measured, not assumed: `XINPUT_VIBRATION` has two
members, and GameInput — the only four-motor API — does not enumerate an xinputhid-promoted Xbox pad
at all, verified against a REAL Microsoft Elite which is equally invisible to it while classic
XInput reads it live. So this path has never been exercised end to end and the comments say so.
VERIFIED
* `cargo test -p punktfunk-core --features quic --lib` 378 passed on macOS, 203 on Windows;
clippy `-D warnings` clean with and without default features; `cargo fmt --all --check` clean.
* The generated header is regenerated and idempotent on re-run (CI diffs it).
* SWIFT ACTUALLY COMPILES AND RUNS: `swift build` clean and `swift test` 262 passed / 0 failures
in `clients/apple`, against a locally built xcframework. (Editor SourceKit errors about
`PunktfunkCore`/`DualSenseHID` are index noise from that gitignored artifact — a real build
resolves both, and the `DualSenseHID` references are untouched by this change.)
* `cargo build -p punktfunk-host` clean on Windows.
NOT VERIFIED
* End to end — see above; there is no producer.
* Whether a real Xbox pad on Apple actually reports the two trigger localities. The degrade needs
no code, but the positive case is untested.
* `pf-client-core` (the SDL renderer) does not build on macOS at baseline and is unbuilt here. It
only reads `RumbleCommand` fields and never constructs one, so added fields cannot break it, but
it still calls `_cmd`; wiring `SDL_RumbleGamepadTriggers` is separate work.
ANDROID: NOT DONE, and it should stay that way for now. `pack_rumble` packs pad/backstop/low/high
into bits 0..52 of a `jlong` with `-1` reserved as a sentinel — two more `u16` do not fit. The right
fix if ever wanted is the direct-`ByteBuffer` shape `nativeNextHidout` already uses in the same file
(zero-allocation, caller-owned, the established idiom), not a second `jlong` (racy across two calls)
nor `long[]` (an allocation per pull). But no Android device exposes trigger actuators at all, so
there is nothing to render. Separately stale and also not fixed: `NativeBridge.kt`'s KDoc still
documents the v2 `ttl_ms` layout rather than `backstop_ms`.
|
||
|
|
7f1f7ba87c |
fix(pads/windows): say WHY a pad index is taken, and stop the devtest lying when it is
Debugging the on-glass session, a devtest run died with
error=create gamepad bootstrap mailbox Global\pfds-boot-0: Zugriff verweigert (0x80070005)
(install/repair: punktfunk-host.exe driver install --gamepad)
and then — this is the part that cost real time — kept printing "virtual Xbox One S Controller up",
streamed frames into nothing, and let the operator measure the INCUMBENT pad on that index. The
XInput packet count sat frozen and read as "the pad is dead", which was a wrong conclusion drawn
from a harness that had already failed and not said so.
WHAT IT ACTUALLY WAS. Pad lifetime is deliberately tied to the SESSION (native/input.rs: "the
gamepads are created and torn down with the session"), and a live session's pad legitimately owns
`Global\pfds-boot-0`. The mailbox's SDDL is `D:P(A;;GA;;;SY)(A;;GA;;;LS)` — SYSTEM and LocalService
only — and the host service runs as LocalSystem while a hand-run devtest runs as an elevated
Administrator, which is in neither ACE. `CreateFileMappingW` over an existing name is really an
OPEN, access-checked against the incumbent's DACL, so it returned ACCESS_DENIED and bailed at the
`?` BEFORE reaching the `ERROR_ALREADY_EXISTS` branch that already had the right sentence. That
branch only ever fires when both processes run as the same account.
The name is per-index on purpose and stays that way: `Global\pfds-boot-{index}` is the rendezvous
the driver polls, and its existence doubles as host-liveness. Making it per-process would let two
hosts build two devices on one wire index — the "the game sees two controllers" bug. The collision
is correct; only the diagnosis was wrong.
* `gamepad_raii.rs` classifies the failure: on ACCESS_DENIED it probes with `OpenFileMappingW`,
which separates what the OS collapsed — object-manager lookup precedes the access check, so
absent gives FILE_NOT_FOUND and present-but-forbidden gives ACCESS_DENIED. It now says the
mailbox belongs to a live session's pad and that nothing is wrong with the drivers.
* `pad_slots.rs` carries that as a typed `PadCreateFault` through the anyhow chain, so `ensure`
prints the fault's remedy instead of the per-backend reinstall hint, plus the pad index.
* `devtest.rs` now BAILS when no pad was actually built, instead of announcing success. This is
the fix that matters: every probe an operator runs next will still find a device on that index.
* `native.rs` names what a detached input thread still holds, since that is one of the ways a pad
can outlive its session.
DELIBERATELY NOT CHANGED, with reasons: the session-scoped pad lifetime (intentional and
documented); the mailbox naming (load-bearing, above); the retry/backoff (latching would resurrect
the `broken` flag `PadGate` exists to kill); the 10 s thread-detach in `serve_session` and the
service's `TerminateProcess` shutdown — both are real ways a devnode can outlive its owner, but
neither is evidenced in the field case and inventing a fix for an unobserved path is how you get a
regression instead of a bugfix.
`pf-inject/lib.rs` drops the `cfg(any(linux, windows))` gate on `pad_gate`/`pad_slots`. Neither
touches an OS pad API, and the gate meant a classification whose entire subject is a `cfg(windows)`
failure could not be tested on a dev machine at all.
VERIFIED
* ON WINDOWS (.173): `cargo test -p pf-inject --lib` 109/109; `cargo build -p punktfunk-host`
clean. Both agents' Windows code was compile-UNVERIFIED before this run.
* macOS: 5 new tests, including one that pins the anyhow downcast through the exact three-layer
context chain the Windows code builds — the assumption that could not otherwise be checked.
* `cargo fmt --all --check` clean.
NOT VERIFIED
* That a LocalSystem-owned mailbox really answers `OpenFileMappingW` with ACCESS_DENIED rather
than FILE_NOT_FOUND from an Administrator token. That is reasoned from the object manager's
lookup-then-access-check order, not measured. Repro on .173: hold a session pad on index 0, run
the devtest from an elevated console, and check the new sentence appears.
|