From c7d0fd2e036715c86ad0fc8276ed9e5e757c833f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 13 Aug 2026 13:26:41 +0200 Subject: [PATCH] =?UTF-8?q?release:=200.28.0=20=E2=80=94=20version=20bump,?= =?UTF-8?q?=20notes,=20CHANGELOG,=20Play=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 180 commits since v0.27.0. Cut from origin/main 9c133350. THE NUMBER: 0.28.0, not 0.27.1. The CHANGELOG's in-development section was titled "v0.27.1", which the release does not support — 17 `feat(...)` commits, a packager-visible default flip (GameStream opt-in on every route), the edition-2024 MSRV rise, and now a genuinely BREAKING host change (the built-in library scanners are deleted). `scripts/ci/pf-version.sh`'s canary rule agrees independently: CI already stamps canaries `0.28.`. TWO DEFECTS FOUND AND FIXED WHILE PREPARING, both pre-existing on main: 1. C ABI_VERSION was stale at 18. Two exported symbols landed since v0.27.0 without a bump — punktfunk_connection_note_frame_index_ex and punktfunk_reanchor_gate_arm_expecting_drops (72 -> 74 declarations in include/punktfunk_core.h). The constant's own doc history makes the rule explicit: v17 and v18 each bumped for adding exactly one symbol. Bumped to 19 with its doc entry; the header is regenerated (cbindgen, CI-gated) and the C ABI harness passes printing abi_version=19. 2. docs-site/public/openapi.json had drifted to 0.21.0 against api/openapi.json, missing five endpoints. The copy is a documented manual step that nothing in CI enforces (CONTRIBUTING.md says so outright). Re-synced — and then it DRIFTED AGAIN inside this same cycle when the scanner-removal regen updated api/openapi.json alone, so it is re-synced a second time and the CHANGELOG now says to treat the copy as part of regenerating, not a follow-up. ⭐ The final docs batch also invalidated a line in this CHANGELOG: the identity section still said the P-256 key was "generated by ring via rcgen", which contradicted this same document's "ring is gone from the tree entirely". Corrected to "rcgen on the workspace's aws-lc-rs backend", matching 92db6651. api/openapi.json stays stamped 0.27.0: it cannot be regenerated here (punktfunk-host does not compile on macOS) and does not need to be — the drift test normalizes info.version, so only the SURFACE is gated, and the surface is current. CHANGELOG: retitled to v0.28.0, gained the version table (wire 2 unchanged; C ABI 18->19; edition 2021->2024 and MSRV 1.82->1.85; driver protocol 6 and gamepad channel 3 unchanged; plugin-kit 0.4.0->0.4.1), a breaking-changes section, and ~29 topics the in-development text predated — including the four that landed last: the scanner->plugin migration, the Mutter rebuild serialization, the KWin <=60 Hz readback, and the Apple/Android de-prime fuse. ⭐ THE BREAKING ONE, stated plainly in both halves: the six built-in library scanners are DELETED and the library is assembled entirely by plugins. There is deliberately no migration — a plugin claims its store and republishes each title under the same `:` id, so entry ids, GameStream app ids, art caches, Moonlight pins, per-source toggles and per-entry hides all keep working. The one visible consequence, and the whole upgrade note: a host with NO library plugins installed has an empty grid. ⭐⭐ The Mutter two-client segfault this release now fixes (a5c9b7b8) is the one found during THIS release's on-glass validation: chaining two clients through a kept display killed gnome-shell in meta_monitor_manager_rebuild. It was A/B'd on .21 against the released 0.27.0 and shown byte-identical there, so it was never a 0.28.0 regression — and the fix's own commit message cites that A/B. GATES RUN, all green on this commit (re-run after the rebase onto 86cbbea0): cargo fmt --all --check clean cargo metadata --locked OK against the new dependency tree Cargo.lock versions-only vs origin/main, 36/36 lines cargo test -p punktfunk-core 210 passed c_abi harness PASS, abi_version=19 (needs LIBRARY_PATH for opus on macOS; a link path, not a defect) docs-site build exit 0 (bun install --frozen-lockfile + build) Play notes gate 440/500 CHARACTERS, not byte-identical to any other release (`•` is 3 bytes — count characters, as the gate does) notes voice check 0 hits above `## For developers`; TL;DR at 6 bullets (README caps it at six) ON-GLASS (against the canary of 14425716, code-identical bar ABI_VERSION): Windows .173 0.28.13309 + Android and iPad, Linux .21 0.28.0-0.00013300 + iPhone — both PASS. The idle sleep-blocker fix is proven before/after on .173 (`powercfg /requests` SYSTEM: the mic devnode -> "Keine."), and the GameStream flip is proven at the socket level on .21 (47984/47989/47999 absent by default, restored by PUNKTFUNK_GAMESTREAM=1). Old-client compat holds: Android 0.26.0 streams against the 0.28.0 host. ⏳ NOT re-validated: the Mutter fix itself. .21 (VM 103) is stopped — it and home-bazzite-2 (VM 119, currently running) share one passed-through GPU, so bringing .21 up would stop the other VM. Owed once .21 is free; the repro is iPhone 2868x1320 -> SIGTERM -> Android 2800x1260, and the marker to confirm the build carries the fix is the string "mutter: waited out a monitor-topology rebuild before releasing the lock". NOT INCLUDED: the 14 unpushed pf-capture/pf-vdisplay sweep commits on the local main. Never through CI; pushing them is the user's call. --- CHANGELOG.md | 523 +++++++++++++++- Cargo.lock | 72 +-- Cargo.toml | 2 +- crates/punktfunk-core/src/lib.rs | 13 +- docs-site/public/openapi.json | 931 ++++++++++++++++++++++++++++- docs/releases/v0.28.0.md | 74 +++ docs/releases/whatsnew/v0.28.0.txt | 4 + include/punktfunk_core.h | 13 +- 8 files changed, 1564 insertions(+), 68 deletions(-) create mode 100644 docs/releases/v0.28.0.md create mode 100644 docs/releases/whatsnew/v0.28.0.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index aa04b7af..554963ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,62 @@ with the version table of the release you are moving to, then read **Breaking ch --- -## v0.27.1 — in development +## v0.28.0 + +180 commits since v0.27.0. + +### Versions + +| | v0.27.0 | v0.28.0 | Notes | +|---|---|---|---| +| Wire protocol | 2 | **2** | unchanged | +| C ABI | 18 | **19** | `punktfunk_connection_note_frame_index_ex` + `punktfunk_reanchor_gate_arm_expecting_drops` **added**; nothing removed, nothing widened | +| Rust edition | 2021 | **2024** | the whole tree bar four vendored crates | +| MSRV (`rust-version`) | 1.82 | **1.85** | the *declared floor* only — the pinned toolchain is unchanged | +| Workspace crate dirs | 27 | **27** | unchanged (39 members; two `tools/` crates still deliberately *excluded*) | +| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3) | +| Windows virtual-gamepad channel | 3 | **3** | unchanged | +| Plugin index schema | 1 | **1** | unchanged | +| `api/openapi.json` | 0.25.0 | **0.27.0** | the management API **did** change this release (below); the file was regenerated mid-cycle, so it carries the then-current stamp, not `0.28.0` | +| gamescope patch level (`+pfhdrN`) | 5 | **6** | 7 patches → 8 (`GAMESCOPE_NO_FOCUS`); no new capability | +| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.4** | unchanged | +| `@punktfunk/plugin-kit` | 0.4.0 | **0.4.1** | publishes the `icon` field | + +⚠ **`crates/pf-driver-proto` changed again**, as it did in v0.27.0 — but *not* in its contract. The +wire bytes, `PROTOCOL_VERSION` (6) and `MIN_DRIVER_PROTOCOL_VERSION` (3) are all untouched; what +moved is the manifest (`edition`/`rust-version` now inherit from the workspace) and one test that +was reading a `[u8; 40]` through `bytemuck::from_bytes` — an alignment assumption a favourable +stack slot had been hiding, and the kind of thing Miri exists to catch (below). If you ship the +driver or the gamepad channel, this release needs no re-integration. + +⚠ **`api/openapi.json` is still not gated by CI** — nothing regenerates or diffs it in a workflow. +A unit test (`openapi_document_is_complete_and_checked_in`) does compare the checked-in copy against +the served document, with `info.version` normalized on both sides, so the *surface* is protected +even though the stamp drifts. The docs-site copy is a plain file copy and was **not** protected: +see the note under **Documentation** below. + +### ⚠ Breaking changes + +**None on the wire, and none that break an embedder at runtime.** Wire protocol 2 is unchanged, so +existing pairings and every shipped client keep working; the C ABI moves by addition only. What +follows changes what the **host itself does**, how you **build**, and what a **stock package does by +default**. + +- 🛑 **The host no longer scans any launcher itself — the six built-in library scanners are + deleted and replaced by plugins.** This is the only change here that can leave a working install + visibly emptier: **a host with no library plugins installed has an empty grid.** Full detail and + the (deliberately absent) migration below. +- **Rust edition 2024, MSRV floor 1.85.** If you vendor or patch any workspace crate, your toolchain + must be ≥ 1.85. Our pinned toolchain did not move — only the declared floor. +- **Building from source now needs a working C compiler**, because `aws-lc-sys` compiles AWS-LC. + No CMake, Go or NASM for the default (non-FIPS) build. Detail under the TLS section below. +- **GameStream is opt-in on every route.** A packaged host that served Moonlight by default becomes + native-only until the operator sets `PUNKTFUNK_GAMESTREAM=1`. Full detail below. +- **No punktfunk process holds REALTIME GPU priority any more.** Both levers (the driver's + `IddCxSetRealtimeGPUPriority` raise and the host's `HIGH → REALTIME` auto-upgrade) default OFF; + the ladders that re-enable them are new opt-ins. This is a field-convicted stall fix, below. +- **The shipped Bazzite `host.env` template no longer pins `PUNKTFUNK_GAMESCOPE_ATTACH=1`.** If you + copied it verbatim — which the docs told you to — Game Mode was mirroring the box's screen. Below. ### The six built-in library scanners are gone — every game source is a plugin (⚠ operator-visible) @@ -167,7 +222,8 @@ The checked-in `api/openapi.json` remains the default-features document. One RSA-2048 identity historically 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`): generated by -ring via rcgen, browser-compatible (Ed25519 server certs are not), carrying real SANs +rcgen on the workspace's aws-lc-rs backend, browser-compatible (Ed25519 server certs are not), +carrying real SANs (localhost, loopback, the machine hostname — the legacy cert had none), and free of the accepted `rsa`-crate Marvin advisory. The GameStream plane keeps the RSA identity untouched. @@ -184,6 +240,34 @@ grant covers both. ⚠ A plugin bundling an **older** `@punktfunk/host` SDK on a (P-256) host trusts the wrong cert — set `PUNKTFUNK_MGMT_CA=/native-cert.pem` in its environment or rebuild against the current SDK. +⚠ **It is ECDSA P-256, not Ed25519 — deliberately.** rcgen can generate either, and Ed25519 would +be the obvious modern pick, but **no mainstream browser accepts an Ed25519 server certificate** and +an operator opens `/api/docs` in one. P-256 is the strongest curve that keeps the management API +reachable from a browser. + +#### 🗓 Deprecation: the legacy-identity fallback goes away on **1 October 2026** + +The fallback in `load_or_adopt` — "an upgraded host with live native pairings keeps presenting the +legacy RSA cert those clients pinned" — is a **migration aid, not a permanent branch**. From +**2026-10-01** the host stops taking it: a host that still holds only `cert.pem`/`key.pem` will mint +the P-256 identity and its native clients will have to re-pair once. + +**Scope, precisely** — this affects the **native punktfunk/1 plane and the management API only**: + +- **The GameStream/Moonlight plane is NOT deprecated and keeps its RSA identity permanently.** + Moonlight mandates RSA and its pairing hashes bind the cert's X.509 signature bytes, so that + identity cannot move without breaking every Moonlight client. Nothing about that changes on any + date. +- Operators who want the split **today** need no new release: unpair all native clients, restart the + host, re-pair. The host already logs exactly this. +- Fresh installs since v0.28.0 are already on P-256 and are unaffected. + +⚠ **This date is a published commitment**, tracked as +[#201](https://git.unom.io/unom/punktfunk/issues/201) (due 2026-10-01), which carries the arm to +delete, the three identity-following consumers to re-check, and the test that has to invert. Without +it the notes would have promised something that silently never happens — the same shape as the +v0.22.3 notes describing a feature that release never contained. + ### Memory-safety, compiler-enforced (embedder-visible lint tightening) `punktfunk-core` now carries `#![deny(unsafe_code)]` crate-wide: everything that parses network @@ -344,6 +428,441 @@ New CI job **`smoke-install`** installs every published package from the registr the run just built. Nothing in `deb.yml` had ever installed a package it produced, which is how both facts above survived for so long. +### 🛑 The six built-in library scanners become plugins (M6/WP6.4 — breaking) + +The host no longer scans any launcher. `library/{steam,epic,gog,heroic,lutris,xbox}.rs` and the +`scanner_defs()` table are **gone**; `GET /library/scanners` now lists exactly what the operator +installed, every row `origin: "plugin"`. This ends the migration whose bridge half shipped in +v0.26.0 — the plugins have been published and index-pinned since 2026-08-08, so the replacement has +been in the field for the whole bridge window. + +⚠ **The upgrade note is the whole of it: a host with no library plugins installed has an empty +grid.** The console's one-click install per source is unchanged and still never auto-installs. + +⭐ **There is no migration, by construction, and that is why this could be a deletion rather than a +rewrite.** A plugin *claims* its store (D2), and a claimed entry surfaces under the same +deterministic `:` id the scanner used to produce. Entry ids, GameStream FNV-1a +app ids, client art caches, Moonlight pins, the per-source toggles and the per-entry hides all key +on that id and **none of them move**. `library-scanners.json` keeps its name, shape and contents — +an operator who had `steam` off still has it off. + +Kept deliberately: + +- **`launch.rs` in full.** Launch is host-owned by design (D1): a plugin publishes a validated + value, the host builds the command, so every typed kind survives. `xbox_pfn()` **moved here** out + of the deleted `xbox.rs` — resolving a package Identity to its PackageFamilyName needs + `AppRepository` enumeration, readable by the host (LocalSystem) and **denied to the plugin runner** + (LocalService). That measured asymmetry is the entire reason the `xbox` launch kind exists, so the + resolver is launch vocabulary, not scanner vocabulary. +- **`SourceOrigin::Builtin`.** No host build emits it any more, but the console ships as its own + package and drives an N-1 host that still does, so the variant stays in the schema. +- **A store-label table**, so a source row does not rename itself from "Steam" to `steam` the day + its plugin takes over. + +Removed with the scanners: the background cover-art warmer and its on-disk cache (they existed only +for GOG and Xbox, the two sources that had to ask a network catalog what a cover was — a plugin +resolves art while it scans), the legacy `steam:` branch of the art proxy, and `GameMeta::pc()`. + +### Mutter monitor rebuilds are serialized end to end — the two-client chain no longer kills GNOME + +🛑 **Chaining two clients through a kept (keep-alive) Mutter display segfaulted gnome-shell in +`meta_monitor_manager_rebuild` (libmutter-18) and took the whole desktop down**; every later session +then failed `RemoteDesktop.CreateSession: ServiceUnknown` until GDM restarted, so the client just sat +black. ⭐ **A/B'd on .21 during this release's validation: byte-identical on the released 0.27.0 and +on the 0.28.0 RC — it was never a regression, the trigger had been there all along.** + +`TOPOLOGY_LOCK` already serialized every topology-mutating D-Bus call, but two gaps let Mutter's +*rebuilds* overlap: + +- **Teardown was fire-and-forget.** `StopGuard::drop` set a flag and returned; the session thread + only noticed on its ≤200 ms park tick. The dead-reuse path (reused kept display dead on first + frame → `mark_failed` → re-create) therefore issued its fresh `RecordVirtual` with the doomed + monitor's removal still pending — the fresh session could even win the lock *before* the old + thread had woken to take it, adding a monitor while the dead one still stood. The drop now waits + (bounded, 20 s) for the session thread to finish. +- **The lock was released while the shell was still rebuilding.** `Stop` / `RecordVirtual` / + `ApplyMonitorsConfig` all return mid-rebuild, and an `APPLY_TEMPORARY` config auto-reverts + asynchronously on top. Every locked mutation now ends with `settle_topology()` — poll + `GetCurrentState` until a removed connector is actually gone and the config serial holds still + across two consecutive reads — before the guard drops. Bounded at 4 s and best-effort (a read + error means the shell is gone; a hotplug storm must not park sessions), degrading to exactly the + old behaviour. + +Cost when Mutter is already quiet: one confirming read plus one 150 ms recheck per setup/teardown. + +### KWin ≤60 Hz — the virtual output's real size is finally read back + +🛑 **A 4K60 GameStream session captured 1920×1080.** `create()` asked KWin for 3840×2160, KWin built +something else, and nothing compared the two: only the >60 Hz arm read anything back, and it gets +that for free because it installs a custom mode. The ≤60 Hz arm installs nothing, which is exactly +why it never noticed. + +⚠ **The line that should have caught it was the one that hid it.** `spawn_vout` returns a node id, +never a size, so `tracing::info!(node_id, width, height, "KWin virtual output ready")` was echoing +the **request** — the field log stated 3840×2160 while the output was 1080p, and the first pass at +diagnosing this was done against that number. It now logs `requested_w`/`requested_h` with the +readback beneath it. + +### Apple/Android audio — the de-prime fuse counted callbacks, not time + +🛑 **An iPad gave up on its audio ring three times sooner than a Mac**, which is the residual Apple +jitter that survived both the PLC fix (#82) and the jitter-policy fix (#111). +`JitterTuning::deprime_after` counted **callbacks**, and a callback is not a unit of time: the same +`4` was ~44 ms of starvation slack on a Mac's ~11 ms quantum and **20 ms on iOS**, whose session asks +for a short IO buffer — the shortest fuse of any client, on the one with the burstiest transport. A +100 ms Wi-Fi delivery stall therefore de-primed the Apple ring on every bunching cycle while the +identical policy rode it out everywhere else. It is now **`deprime_ms`**, measured in starved audio, +with a `MIN_DEPRIME_CALLBACKS` floor so a large-quantum device keeps real hysteresis instead of +de-priming on the first short read. ⚠ **Android was latently exposed too** — AAudio's low-latency +burst is ~4–5 ms, so its `5` was also ~20 ms. + +Measured by driving the real policy through a simulated link (100 ms stall / 5 s, −30 ppm, 10 min) +at a 5 ms quantum: **120 audible gaps and 690 ms of dead air before, 2 gaps and 60 ms after.** + +### Console — "Update all" on the plugins screen + +The Installed tab could only update one plugin at a time, one dialog and one watched job each. The +bulk action now sits beside the list it acts on, plus a count badge on the Installed tab trigger +(Browse is the tab the page opens on, and a control nobody passes is a control nobody finds). +⚠ **The host takes ONE package operation at a time** — 409 otherwise, because bun operations share a +lockfile and a `node_modules` tree — so this is a queue the console works through job by job, driven +by each job settling rather than by a timer, carrying its own copy of what is left. + +### Android — the in-stream mic control leaves the stream overlay + +The mic element sat in the top-right of every stream that opened a capture (a standing button on +touch, a Muted badge on TV). It is gone for now; the on-screen overlay UI being built will carry +mute as one of its controls. **Mute itself is untouched** — `micRunning`/`micMuted`/`setMicMuted` +still back the Select + Y chord, which is now the whole of the control, and `MicChordHint` is its +only on-screen feedback. + +### ⚠ Flatpak — the currency wave's one loose end + +🛑 **Every flatpak leg died after #193.** The dependency currency wave took skia-safe/skia-bindings +0.87.0 → 0.99.0 in `crates/pf-console-ui/Cargo.toml`, but `packaging/flatpak/io.unom.Punktfunk.yml` +still pinned the **0.87.0** prebuilt archive, so the build failed with +`no variant … named 'Default' found for enum 'SkPathFillType'` inside +`skia-bindings-0.99.0/src/defaults.rs`. Nothing in that message points at the manifest, so it reads +like a crate bug — it is not: `SKIA_BINARIES_URL: file://…` makes skia-bindings unpack the pinned +tarball verbatim, **including its `bindings.rs`**. Archive pinned to 0.99.0. +⇒ **If you bump `skia-safe`, bump the flatpak archive in the same commit.** + +### Rust edition 2024 across the tree (MSRV floor 1.85) + +The whole main workspace and `pf-vkhdr-layer` move to **edition 2024**; `[workspace.package]` +declares `edition = "2024"` and `rust-version = "1.85"`. The pinned toolchain did not move — only +the declared floor — but if you vendor or patch a workspace crate, 1.85 is now the minimum. + +This is the safety half of the rust-safety programme's §8.4, not a tidy-up: in edition 2024 +`std::env::set_var`/`remove_var` are **`unsafe fn`**, which converts an entire bug class from +invisible to counted. The environ data race the programme found the hard way lived in a file +containing zero occurrences of the word `unsafe`; every one of the 20 files that mutate the +environment now carries an `unsafe` block with a SAFETY comment naming the actual serialization +argument (a named lock, or a `--test-threads=1` contract, or single-threaded startup). + +What a downstream integrator sees: + +- The 13 crates that pinned `edition = "2021"` **literally** now inherit from the workspace. A root + bump alone would have reached only the `edition.workspace = true` crates and left `pf-encode`, + `pf-capture`, `pf-inject` and friends on 2021 while reading as complete. +- 148 `#[no_mangle]` → `#[unsafe(no_mangle)]` (83 of them in `abi.rs`), and 12 bare `extern` blocks + → `unsafe extern`. Done textually across **all** `cfg` branches, because 44% of the host's unsafe + is Windows-only and a one-platform `cargo fix` silently misses it. +- `gen` is a reserved keyword in 2024, so `pf-vdisplay`'s generation stamps and the WinUI shell's + animation counters rename `gen` → `generation`. **Internal identifiers only — no serde field, no + wire name and no API surface changed.** +- The four **vendored** crates (`fec-rs`, `cros-codecs`, `usbip-sim`, the patched `ndk`) stay on + 2021 deliberately: upstream code stays pristine. + +### No punktfunk process holds REALTIME GPU priority by default (⚠ default change) + +🛑 **Both of our REALTIME GPU-scheduling levers were convicted of *generating* the metronomic +capture-stall class the stall program has chased for weeks** — compose-silence holes of 150–800 ms +in which ETW shows no process presenting while the GPU stays responsive. From the RX 9070 XT field +A/B: the virtual-display driver's `IddCxSetRealtimeGPUPriority` raise beat at ~1.75–1.78 s, and the +host's `HIGH → REALTIME` auto-upgrade beat at ~3.58 s in the sessions where it promoted. Disabling +each removed its own metronome; pinning both left the stall rate at the clean-run baseline. + +Neither period matches **any** punktfunk clock — the full periodic-actor census (driver drain, +16 ms `E_PENDING` wait, 33 ms cursor poll, 3 s watchdog; host descriptor poll, VRAM gate, exclusive +re-assert, pinger, stats, phase-lock, LTR marks) has nothing in the 1.69–2.29 s band, and the period +even differs by *which* of our processes holds REALTIME. The periodicity is emergent from holding an +unreachable-priority queue against the WDDM scheduler on this AMD family. There is therefore no +punktfunk cadence to fix; the fix is to stop holding REALTIME, which is also canonical parity — no +shipping IDD raises it, and HIGH is the class that delivered the original encode win. + +- **Driver:** the old `PFVD_NO_RT_GPU` opt-**out** (default ON) becomes the **`PFVD_RT_GPU` ladder, + default OFF on every vendor**. Unset = no raise = canonical IDD behaviour. +- **Host:** the `pf-frame` auto-gate no longer upgrades to REALTIME. `PUNKTFUNK_GPU_PRIORITY_CLASS` + still pins a class explicitly. + +### The reanchor gate learns gap WIDTH — two new C ABI exports (ABI 19) + +🛑 **Every unrecoverable loss armed the client's freeze gate twice**, and on AMD hosts the second arm +re-froze a stream that had already healed. The two signals are the frame-index gap (instant, and what +fires the RFI) and the reassembler ageing the lost frame into `frames_dropped` (~120 ms later, which +re-armed unconditionally). An LTR-RFI recovery anchor lands in ~60 ms — *between* them — so the stale +climb re-froze a bit-exact-healed picture, the host swallowed the re-ask as an RFI echo, and the +stream stayed frozen until the overdue backstop extracted a full IDR. This is the field +"H.265 freezes on every loss, AV1 fine" signature: AMF is the only LTR-RFI backend, and the slower +IDR path usually lands after the climb and dodged the race. + +The gap-arm now **pre-credits** the climb it knows is coming (`ReanchorGate::arm_expecting_drops`; +the credit expires after `DROP_CREDIT_WINDOW` so a straggler-filled gap cannot mask a later real +loss), and `poll()` consumes credited climbs instead of re-arming. Plumbed through every embedder: +`pf-client-core`'s session pump, Android's sync and async loops (`note_frame_index` now returns the +gap width), and the Swift client via the two new exports — +**`punktfunk_connection_note_frame_index_ex`** and **`punktfunk_reanchor_gate_arm_expecting_drops`**. +Both originals keep their signatures and their behaviour, so an embedder that adopts neither is +unchanged; it simply keeps the race. Nothing new goes on the wire. + +### ⚠ `punktfunk_send_input` now rejects an unrecognized event kind + +`punktfunk_send_input` and `punktfunk_connection_send_input` **validate `ev->kind` before forming a +reference** and return `InvalidArg` for a value that is not a recognized `InputKind`. Previously the +byte was transmuted into an enum, which is UB for an out-of-range discriminant — a caller passing an +uninitialized or garbage `kind` had undefined behaviour rather than an error return. The safety +contract in the header relaxes correspondingly: `ev` need only point to *a readable +`InputEvent`-sized allocation*, not to an already-valid `InputEvent`. **If you build an event by +zeroing a struct and setting fields, nothing changes.** If you relied on an unknown kind being +silently forwarded, it is now an error. + +### Linux hosts stream pad audio — the per-pad PipeWire sink (WP3) + +The 0xD1 per-gamepad audio plane (DualSense haptics + speaker) was **Windows-host-only**: +`host_cap()` answered false everywhere else and `spawn()` was a stub, so a tier-A Android client +against a Linux host negotiated the capability off and fell back to wire rumble. The downstream +machinery — framer, silence gate, lanes, 0xD1 send — was already capture-agnostic; only the capturer +was WASAPI. + +Linux hosts now mint **one PipeWire Audio/Sink node per DualSense-family pad**, carrying the identity +the game-side matchers read (ALSA-style `node.name` with the pad's pairing MAC, description +"Wireless Controller", bus/vendor/product/form-factor proplist, per-pad serial), 4-channel F32 +48 kHz FL/FR/RL/RR, claiming no default sink, `priority.session 50`. The `process()` callback *is* +the capture. `host_cap()` on Linux = client asked **and** `PUNKTFUNK_PAD_AUDIO` **and** a reachable +PipeWire socket; the sink is minted lazily in the streamer thread. `PUNKTFUNK_PAD_SINK_NAME` / +`_DESC` override the strings for field debugging (`{pad}`/`{mac}` expand). +`PUNKTFUNK_PAD_AUDIO{,_SLOTS}` are no longer documented as Windows-only. Verified on a Bazzite 44 +host: identity served through `pipewire-pulse`, rear-pair voice-coil tone captured bit-exact over +both the native and Pulse legs. The Linux sink speaks GE-Proton's AUX0–3 channel shape. + +### Wake-on-LAN now works over Wi-Fi (WoWLAN) + +The host's arming check asked **`ethtool`** about every NIC, which is the wrong question for +wireless: the magic-packet trigger lives in nl80211's WoWLAN state, and most Wi-Fi drivers print +`Wake-on: d` whether or not it is armed. An armed Wi-Fi host was therefore reported as *not* armed +and handed an `ethtool -s wlan0 wol g` its driver rejects. A NIC with an nl80211 phy +(`/sys/class/net//phy80211`) is now asked `iw phy wowlan show`, and the warning carries +WoWLAN-correct guidance (`iw … wowlan enable magic-packet`, plus the NetworkManager +`802-11-wireless.wake-on-wlan magic` that survives a reconnect). Two fallbacks for when `iw` cannot +answer: a **positive** ethtool reading counts (brcmfmac and friends do report there), a negative one +never does, and sysfs `device/power/wakeup` reading `disabled` is conclusive in the negative. + +The **client sender** now emits from a socket bound to each non-loopback interface's own address +instead of leaving the choice to the routing table. A station in WoWLAN sleep stays associated and +its AP buffers broadcast frames until the next DTIM beacon — but only if the datagram reaches the +wireless segment at all, and with a VPN or mesh interface holding the default route +`255.255.255.255` never did. A failed bind falls back to the routed socket, so no segment is lost. + +### Zero-copy capture withholds buffers until the encoder has finished reading + +🛑 **Gamescope streams could tear pink at 120 fps.** The raw-dmabuf passthrough handed the SPA buffer +back to gamescope at `.process` return while the encode thread had not yet imported — let alone read +— its dmabuf, and nothing ordered the producer's writes against the consumer's read (there is no +explicit sync, and the implicit-fence wait measures `NoFence` on every compositor × vendor pairing we +have). On the direct-VCN arms (native NV12, RGB-direct EFC) the captured buffer *is* the encode +source for the whole 2-deep encode ring plus the phase-lock hold, so at 120 fps gamescope cycles back +into the buffer mid-encode: luma/chroma desync (the magenta tint) plus block corruption propagating +through the P-chain until the next intra. KDE sessions were clean because `cursor_blend` routes them +to the compute-CSC copy arm, whose read window is microseconds. + +A published passthrough frame now carries a **`FrameHold`**, and the buffer rejoins the producer's +pool only when the last clone drops. The Vulkan encoder clones the hold into the ring slot at submit +and releases it when that slot's fence retires, extending "the producer must not rewrite this" across +exactly the GPU read. The host loop's repeat path is fixed by the same mechanism. + +### Bazzite Game Mode no longer mirrors the box's screen (⚠ shipped-template default) + +🛑 **Our own template caused it.** `packaging/bazzite/host.env` set +`PUNKTFUNK_GAMESCOPE_ATTACH=1`, and every install path — rpm, deb, Arch, nix — ships that file as +`/usr/share/punktfunk/host.env.bazzite` with the docs telling people to copy it verbatim. So the +*recommended* Bazzite setup turned the attach override on for everyone. + +That override is **rung 2** of `pick_gamescope_mode`, above `dedicated_launch` at rung 3. The rung +comment calls the operator overrides a debug/CI escape hatch — correct, but we were shipping one as +a distro default, so on a Bazzite box the managed takeover and the dedicated game session were both +unreachable, and a game launched from a client's library could not get a session of its own. With a +physical display connected, attach then takes the `physical_display_connected()` arm and streams the +box's own head at the box's own mode: the mirror the field report described. + +The template now forces nothing and lets per-connect detection answer, which on a box with +`gamescope-session-plus` is MANAGED. Attach stays available, documented as the opt-in it is, with the +mirror and the dedicated-session cost stated. + +### `edid_lock` — pin AMD connector EDID emulation while streaming (EXPERIMENTAL) + +A new display-policy axis beside `ddc_power_off` / `pnp_disable_monitors`, orthogonal to presets and +**off by default**. At the first Exclusive isolate the host pins each occupied AMD connector's live +EDID plus `ADL_EMUL_MODE_ALWAYS` — the software equivalent of an HPD-holding dummy plug — **before** +the physicals deactivate, so an awake sink answers its own live-EDID read; last-member teardown +unlocks. It targets the standby-sink stall class at its source: with emulation pinned the kernel-mode +driver stops servicing the sleeping sink's HPD/DDC/link. + +Pinned emulation outlives the process, so a crash journal (`edid-lock-active.json`) unlocks on the +next host start, mirroring the `pnp_disable_monitors` recovery. Inert without an AMD driver +(`atiadlxx.dll` absent) and on non-Windows. The ADL FFI lives once in `pf_win_display::adl_emul`, so +the new **`display-disturb adl-emul`** probe and the host exercise byte-identical driver calls. The +console shows the toggle **only** when the GPU inventory lists an AMD adapter — a toggle that can +never act is exactly the "saved, then did nothing" trap the enforced-axes list exists to prevent. + +### An over-declared stream level no longer demotes native Vulkan decode + +A HEVC stream whose declared level exceeds what the device advertises is now treated as a **clamp** +rather than a refusal, so native Vulkan decode survives an encoder that over-declares. The Windows +client legs also build again: the edition-2024 `clients/session` binary could not compile on Windows, +and `pf-presenter` now spells `MAKEINTRESOURCE(1)` as `ptr::without_provenance` — clippy 1.96's +`manual_dangling_ptr` reads the integer-ordinal cast as a dangling pointer and fails the Windows +`-D warnings` gate, which was masked on main by the client bins failing to build first. + +### Library, launcher marks and plugin-kit 0.4.1 + +- **Launcher tiles carry their launcher's mark.** A brand **token** goes on the wire (`steam`, + `heroic` — never bytes, never a URL) and each client draws the vector it already ships. `icon` + joins `GameEntry` and `CustomEntry` in the management API, and is hand-settable for the same + reason `role` is: an operator's own "Steam" tile should be able to look like one. +- **`@punktfunk/plugin-kit` 0.4.1 publishes the `icon` field.** The kit had shipped the field + without a version bump, so no plugin could name its mark. +- **Every pinned card gets a library, and it launches with that card's profile.** + +### Decky: one library shortcut, not one per boot + +🛑 A boot race minted a **new** Steam library shortcut on every plugin load, so the library +accumulated duplicates indefinitely. Fixed, and the plugin no longer toasts on every launch and every +failed panel refresh. + +### A stats tier picked between streams now applies without a restart + +The console latched the stats tier at stream start, so a tier chosen between two streams reached +nothing until the app was restarted. + +### Miri, sanitizers, and the lint ratchets + +- **Miri** now interprets the FFI-free leaf crates, one of them at **MSVC layout**. It immediately + earned its place: `pf-driver-proto`'s legacy-`AddRequest` test read a `[u8; 40]` (align 1) through + `bytemuck::from_bytes`, which takes a *reference into* the buffer and panics unless that buffer + happens to be 8-aligned — as a stack array usually is. Now `pod_read_unaligned`. +- **ASAN + LSAN over the C ABI boundary**: a `c-abi-asan` job in `audit.yml` runs the harness under + both, weekly and on demand, behind a `PF_SAN` sanitizer gate. +- **Two soundness fixes**: `InputKind` is validated before a `&InputEvent` is formed (above), and the + Windows `TOKEN_USER` buffer is properly aligned with `EqualSid` made to fail closed. +- **WP4**: `AvFrame`/`AvSwsContext` are RAII across all three libav backends in `pf-encode`. +- **The lint ratchets (WP2b + WP2c)**: crate-level gaps closed, the unsafe lints hoisted into the + workspace tables across all three workspaces, and three blocking unsafe-hygiene grep gates in + `ci.yml`. The two bindings-only `sys` crates are explicitly exempted from the hoisted deny. + +### Dependencies, audit and licences + +The 2026-08-13 dependency sweep, acted on in full: + +- **Security:** `event-listener` 5.4.1 → 5.4.2 (RUSTSEC-2026-0221, unsound `Send`/`Sync`); + `spin` 0.9.8 → 0.9.9 (0.9.8 is **yanked** and was genuinely compiled); `wayland-scanner` + 0.31.10 → 0.31.11, which moves `quick-xml` 0.39 → 0.41 and lets **both** RUSTSEC-2026-0194/0195 + ignores be deleted rather than left as permanent exceptions. Only RUSTSEC-2023-0071 (`rsa` Marvin, + still unfixed upstream) remains. +- ⚠ **Two CI gates that scanned nothing.** `cargo audit` only ever reads the **root** `Cargo.lock`, + so the drivers lock was in the job's `paths:` filter while being ignored; all four secondary + workspaces now get an explicit `--file`. And `packaging/windows/pf-vkhdr-layer` had **no lockfile + at all** while shipping as a DLL in the host installer, so neither cargo-audit nor cargo-about had + ever seen it — lockfile generated, committed, and added to `paths:`. `audit.toml` now also says out + loud that `cargo audit` reports unsoundness as a *warning* and the job fails only on + vulnerabilities, which is why the `event-listener` advisory sat unnoticed. +- **13 unused dependencies removed from `punktfunk-host`** (the Wayland stack, xkbcommon, reis, + khronos-egl, ash, usbip-sim, parking_lot, bytemuck) — the code moved to `pf-inject`/`pf-zerocopy` + in the subsystem extraction and those crates declare them; only the manifest entries and their + now-false comments stayed. Plus unused `bytes`, `anyhow`, `tracing`, `serde` in five other crates, + and the high-level `wdk` crate from all five driver crates. +- **Latent breakage fixed** — crates that compiled only through feature unification now declare what + they use: `pf-inject` (`tokio` `macros`), `pf-capture` (`tokio` `sync`), `pf-client-core` (two + windows-rs headers). `pf-console-ui` took `pf-client-core` **without** `default-features = false`, + unlike every other consumer; that default compiles the vendored PyroWave C++, which is fatal on + Windows ARM64 and only safe today because that leg passes `--no-default-features`. +- **Licences:** `ring`'s `OpenSSL` exception and its per-crate acceptance are retired now that ring + is gone. THIRD-PARTY-NOTICES regenerated — 601 → 580 → 582 crates across the sweep. + +### The dependency currency wave — thirteen majors, and a silently-disabled AES path + +The currency half the sweep above deferred, landed as one wave. Most of it is version hygiene, but +one item is a real defect and one changes a build flag you may be carrying. + +🛑 **Hardware AES was silently off on every Android build.** `aes` 0.8 enabled the ARMv8 AES +instructions on aarch64 only behind `--cfg aes_armv8`, and `polyval` 0.6 gated its PMULL GHASH path +behind `--cfg polyval_armv8` — both set in `.cargo/config.toml`. A `RUSTFLAGS` environment variable +**overrides config rustflags entirely**, and `cargo-ndk` sets its own for every Android build, so +those two cfgs vanished and the per-packet decrypt path fell back to **software AES**. `aes` 0.9 +runtime-detects through `cpufeatures` and `polyval` 0.7 selects its armv8 backend by `target_arch`, +so neither cfg exists any more and the flags are **deleted** from `.cargo/config.toml`. If you carry +a fork of that file, drop them: they are dead, and keeping them costs nothing but confusion. + +- **The RustCrypto family moves as ONE change** — `aes` 0.9, `aes-gcm` 0.11, `sha2` 0.11, `hmac` 0.13, + `cbc` 0.2, `chacha20poly1305` 0.11. They share the `crypto-common`/`digest` traits, so a partial + bump strands crates on trait generations that cannot interoperate. The API generation forces + `AeadInPlace` → `AeadInOut` (`{encrypt,decrypt}_inout_detached` over `InOutBuf`), `generic-array` → + `hybrid-array`, `Mac::new_from_slice` → `KeyInit::new_from_slice`, and the `BlockCipher*`/ + `BlockMode*` renames. ⚠ **The GameStream wire formats are untouched** — AES-128-ECB no-padding, the + CBC audio path and the GCM control-stream seal all keep their exact byte behaviour; only type + plumbing moved. +- ⚠ **`rsa` 0.9 cannot come along**: it is built on `digest` 0.10, whose 0.11 line is release-candidate + only — not something the Moonlight pairing ceremony should ride. The three sites where a digest is + an `rsa` *type parameter* now name `rsa::sha2::Sha256` explicitly; everything else is on sha2 0.11. +- **`skia-safe` 0.87 → 0.99** in `pf-console-ui` — twelve releases carrying Skia milestones 140–150. + Only three reach us: m143 **deleted `SkPath`'s mutating API** (geometry is built through + `PathBuilder` and frozen with `snapshot()`/`detach()`; 34 errors over eight call sites), 0.93 + deprecated `gradient_shader` for `gradient` (a warning, but the gate runs `-D warnings`), and the + Vulkan surface path came through untouched. +- **`wasapi` 0.23 → 0.24.** ⭐ 0.24 fixes upstream the dangling-`PCWSTR` bug this tree routes around + in five places — `DeviceEnumerator::get_device` built its argument as + `PCWSTR::from_raw(HSTRING::from(id).as_ptr())`, dropping the `HSTRING` at the end of that statement + so `GetDevice` read freed memory. The five comments asserting that bug in the present tense are + corrected. ⚠ **The workarounds stay** — `open_wasapi_device` is still the one resolution path whose + errors name the endpoint id, and `device_by_id` additionally filters to ACTIVE endpoints, which the + crate's `get_device` does not. Removing them would be a behaviour change, not currency. +- **Ten more**: `jni` 0.21 → 0.22 (the Android bridge), `rcgen` 0.13 → 0.14, `rand` 0.8 → 0.9 (the + host was the last crate on the old major), `base64` 0.22 → 0.23, `x509-parser` 0.16 → 0.18 — which + takes `thiserror` 1.0 out of the host graph entirely — `libloading` 0.8 → 0.9 across the five crates + that `dlopen`, `mdns-sd` 0.20 → 0.21 with `if-addrs` 0.13 → 0.15 (together, they share types), + `x11rb` 0.13 → 0.14, `xkbcommon` 0.8 → 0.9, `reis` 0.6.1 → 0.7.1, `windows-service` 0.7 → 0.8 + (removing the last `windows-sys` 0.52 in the tree), `android_logger` 0.14 → 0.15, and `criterion` + 0.5 → 0.8 (dev-only, benches). +- **New test coverage**: the TLS 1.2 Moonlight handshake, and the post-quantum group is pinned by a + test so a backend change cannot silently drop it. +- THIRD-PARTY-NOTICES regenerated across every client and the host for the wave. + +### Documentation and the docs site + +⚠ **`docs-site/public/openapi.json` had drifted far behind `api/openapi.json`** — it was stamped +`0.21.0` against the checked-in spec's `0.27.0`, and was missing five endpoints (`/library/hidden/{id}`, `/plugins/logs`, and all +three `/update/*` routes), so the published API reference described a host nobody was running. The +copy is a documented manual step (`cp api/openapi.json docs-site/public/openapi.json`) that nothing +in CI enforces, and it had simply been skipped. Re-synced for this release; the two files are now +byte-identical. + +⚠ **It drifted again within the same release cycle** — the scanner-removal regen updated +`api/openapi.json` and not the docs-site copy, which is the failure mode repeating in miniature. +Re-synced a second time. **Until something gates it, treat `cp api/openapi.json +docs-site/public/openapi.json` as part of regenerating the spec, not a follow-up.** + +### CI + +- The C/C++ half of the build is cached and links with **mold**; the debug/release target caches no + longer collide. +- `release.yml` folds into `apple.yml`, and the two Windows-client workflows consolidate into one. +- The web console builds **once per push** instead of once per packaging job. +- The `smoke-install` job (see the Debian section) installs every published package from the registry + in pristine `ubuntu:24.04`, `ubuntu:26.04` and `debian:trixie` images and asserts the served + version is the one the run just built. +- ⚠ Gate C counted **comments**: a comment that named the env mutators verbatim satisfied the gate it + was documenting. + ## v0.27.0 87 commits since v0.26.0. diff --git a/Cargo.lock b/Cargo.lock index 648e968e..14e9dd4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1090,7 +1090,7 @@ dependencies = [ [[package]] name = "cursor-probe" -version = "0.27.0" +version = "0.28.0" dependencies = [ "anyhow", "pf-capture", @@ -1222,7 +1222,7 @@ dependencies = [ [[package]] name = "display-disturb" -version = "0.27.0" +version = "0.28.0" dependencies = [ "pf-win-display", "windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", @@ -2343,7 +2343,7 @@ dependencies = [ [[package]] name = "latency-probe" -version = "0.27.0" +version = "0.28.0" [[package]] name = "lazy_static" @@ -2446,7 +2446,7 @@ dependencies = [ [[package]] name = "libvpl-sys" -version = "0.27.0" +version = "0.28.0" dependencies = [ "bindgen", "cmake", @@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "loss-harness" -version = "0.27.0" +version = "0.28.0" dependencies = [ "punktfunk-core", ] @@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pf-bitstream" -version = "0.27.0" +version = "0.28.0" dependencies = [ "cros-codecs", "tracing", @@ -2975,7 +2975,7 @@ dependencies = [ [[package]] name = "pf-capture" -version = "0.27.0" +version = "0.28.0" dependencies = [ "anyhow", "ashpd", @@ -2996,7 +2996,7 @@ dependencies = [ [[package]] name = "pf-client-core" -version = "0.27.0" +version = "0.28.0" dependencies = [ "anyhow", "ash", @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "pf-clipboard" -version = "0.27.0" +version = "0.28.0" dependencies = [ "anyhow", "ashpd", @@ -3049,7 +3049,7 @@ dependencies = [ [[package]] name = "pf-console-ui" -version = "0.27.0" +version = "0.28.0" dependencies = [ "anyhow", "ash", @@ -3071,7 +3071,7 @@ dependencies = [ [[package]] name = "pf-dxvadec" -version = "0.27.0" +version = "0.28.0" dependencies = [ "cros-codecs", "pf-bitstream", @@ -3081,7 +3081,7 @@ dependencies = [ [[package]] name = "pf-encode" -version = "0.27.0" +version = "0.28.0" dependencies = [ "anyhow", "ash", @@ -3107,7 +3107,7 @@ dependencies = [ [[package]] name = "pf-frame" -version = "0.27.0" +version = "0.28.0" dependencies = [ "anyhow", "libc", @@ -3119,7 +3119,7 @@ dependencies = [ [[package]] name = "pf-gpu" -version = "0.27.0" +version = "0.28.0" dependencies = [ "anyhow", "pf-host-config", @@ -3133,11 +3133,11 @@ dependencies = [ [[package]] name = "pf-host-config" -version = "0.27.0" +version = "0.28.0" [[package]] name = "pf-inject" -version = "0.27.0" +version = "0.28.0" dependencies = [ "anyhow", "ashpd", @@ -3166,14 +3166,14 @@ dependencies = [ [[package]] name = "pf-paths" -version = "0.27.0" +version = "0.28.0" dependencies = [ "tracing", ] [[package]] name = "pf-presenter" -version = "0.27.0" +version = "0.28.0" dependencies = [ "anyhow", "ash", @@ -3188,7 +3188,7 @@ dependencies = [ [[package]] name = "pf-update" -version = "0.27.0" +version = "0.28.0" dependencies = [ "serde", "serde_json", @@ -3196,7 +3196,7 @@ dependencies = [ [[package]] name = "pf-update-check" -version = "0.27.0" +version = "0.28.0" dependencies = [ "anyhow", "aws-lc-rs", @@ -3208,7 +3208,7 @@ dependencies = [ [[package]] name = "pf-vaadec" -version = "0.27.0" +version = "0.28.0" dependencies = [ "cros-codecs", "pf-bitstream", @@ -3217,7 +3217,7 @@ dependencies = [ [[package]] name = "pf-vdisplay" -version = "0.27.0" +version = "0.28.0" dependencies = [ "anyhow", "ashpd", @@ -3250,7 +3250,7 @@ dependencies = [ [[package]] name = "pf-vkdecode" -version = "0.27.0" +version = "0.28.0" dependencies = [ "ash", "cros-codecs", @@ -3261,7 +3261,7 @@ dependencies = [ [[package]] name = "pf-win-display" -version = "0.27.0" +version = "0.28.0" dependencies = [ "pf-paths", "punktfunk-core", @@ -3272,7 +3272,7 @@ dependencies = [ [[package]] name = "pf-zerocopy" -version = "0.27.0" +version = "0.28.0" dependencies = [ "anyhow", "ash", @@ -3484,7 +3484,7 @@ dependencies = [ [[package]] name = "punktfunk-cli" -version = "0.27.0" +version = "0.28.0" dependencies = [ "pf-client-core", "punktfunk-core", @@ -3494,7 +3494,7 @@ dependencies = [ [[package]] name = "punktfunk-client-android" -version = "0.27.0" +version = "0.28.0" dependencies = [ "android_logger", "jni", @@ -3512,7 +3512,7 @@ dependencies = [ [[package]] name = "punktfunk-client-linux" -version = "0.27.0" +version = "0.28.0" dependencies = [ "anyhow", "async-channel", @@ -3529,7 +3529,7 @@ dependencies = [ [[package]] name = "punktfunk-client-session" -version = "0.27.0" +version = "0.28.0" dependencies = [ "pf-client-core", "pf-console-ui", @@ -3543,7 +3543,7 @@ dependencies = [ [[package]] name = "punktfunk-client-windows" -version = "0.27.0" +version = "0.28.0" dependencies = [ "async-channel", "mdns-sd", @@ -3561,7 +3561,7 @@ dependencies = [ [[package]] name = "punktfunk-core" -version = "0.27.0" +version = "0.28.0" dependencies = [ "aes-gcm", "cbindgen", @@ -3593,7 +3593,7 @@ dependencies = [ [[package]] name = "punktfunk-encode-worker" -version = "0.27.0" +version = "0.28.0" dependencies = [ "pf-encode", "tracing", @@ -3602,7 +3602,7 @@ dependencies = [ [[package]] name = "punktfunk-host" -version = "0.27.0" +version = "0.28.0" dependencies = [ "aes", "aes-gcm", @@ -3672,7 +3672,7 @@ dependencies = [ [[package]] name = "punktfunk-probe" -version = "0.27.0" +version = "0.28.0" dependencies = [ "anyhow", "mdns-sd", @@ -3686,7 +3686,7 @@ dependencies = [ [[package]] name = "punktfunk-tray" -version = "0.27.0" +version = "0.28.0" dependencies = [ "anyhow", "ksni", @@ -3709,7 +3709,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] name = "pyrowave-sys" -version = "0.27.0" +version = "0.28.0" dependencies = [ "bindgen", "cmake", diff --git a/Cargo.toml b/Cargo.toml index e31ab52c..3dd9b098 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,7 +65,7 @@ exclude = [ ndk = { path = "clients/android/native/vendor/ndk" } [workspace.package] -version = "0.27.0" +version = "0.28.0" edition = "2024" rust-version = "1.85" license = "MIT OR Apache-2.0" diff --git a/crates/punktfunk-core/src/lib.rs b/crates/punktfunk-core/src/lib.rs index cfd6b7e5..6b840601 100644 --- a/crates/punktfunk-core/src/lib.rs +++ b/crates/punktfunk-core/src/lib.rs @@ -165,7 +165,18 @@ pub use stats::Stats; /// Additive and client-local: the v3 tail has been on the wire (and length-tolerant in both /// decoders) since it landed, and the host sends the same bytes either way, so [`WIRE_VERSION`] is /// unchanged. -pub const ABI_VERSION: u32 = 18; +/// v19: added `punktfunk_connection_note_frame_index_ex` and +/// `punktfunk_reanchor_gate_arm_expecting_drops` — the width-carrying half of the reanchor gate. +/// `note_frame_index_ex` reports how MANY frames an arrival revealed as missing where +/// `punktfunk_connection_note_frame_index` reports only whether any were; passing that width to +/// `arm_expecting_drops` pre-credits the reassembler's `frames_dropped` climb that the same loss +/// produces up to ~120 ms later, so the gate does not read one loss as two and re-freeze a stream a +/// fast LTR-RFI anchor has already healed. NEW symbols, not widened ones — the same rule v18 states: +/// both originals keep their signatures and their behaviour, so an embedder that never adopts either +/// is unchanged (it simply keeps the double-arm race the pair exists to close). Additive and +/// client-local: nothing new goes on the wire — the width is computed from frame indices the client +/// already receives — so [`WIRE_VERSION`] is unchanged. +pub const ABI_VERSION: u32 = 19; /// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. /// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** diff --git a/docs-site/public/openapi.json b/docs-site/public/openapi.json index b4a9c39f..9b27d5c8 100644 --- a/docs-site/public/openapi.json +++ b/docs-site/public/openapi.json @@ -10,7 +10,7 @@ "name": "MIT OR Apache-2.0", "identifier": "MIT OR Apache-2.0" }, - "version": "0.21.0" + "version": "0.27.0" }, "paths": { "/api/v1/clients": { @@ -53,7 +53,7 @@ "clients" ], "summary": "Unpair a client", - "description": "Removes the client's certificate from the pairing store. Caveat: the nvhttp TLS layer\ndoes not yet reject unlisted certificates (`gamestream/tls.rs` accepts any well-formed\nclient cert — a planned hardening step), so until that lands this removes the client\nfrom the listing without severing its ability to reconnect.", + "description": "Removes the client's certificate from the pairing store (persisted — the removal survives a\nhost restart). Revocation is complete: a LIVE GameStream session owned by this certificate is\nended (the client gets the standard TERMINATION+disconnect), and removing the last pairing\nalso closes the ENet control port (UDP 47999), which is only bound while at least one pairing\nexists. The nvhttp TLS layer still completes a handshake with any well-formed client cert BY\nDESIGN (authorization is per-request via the paired-fingerprint check) — an unpaired client\nthat reconnects is rejected at every post-pair endpoint.", "operationId": "unpairClient", "parameters": [ { @@ -997,7 +997,7 @@ "library" ], "summary": "List the game library", - "description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).", + "description": "Every title this host knows about, sorted by title: the entries each installed library plugin\nhas synced (Steam, Lutris, Heroic, Epic, GOG, Xbox, Playnite, ROM managers, …) plus the user's\nown custom entries. Artwork fields are URLs the client fetches directly, except local files on\nthe host, which are rewritten to this API's own art proxy. `?provider=` narrows to the entries a\ngiven external provider owns; `?platform=` to one platform (case-insensitive — whatever the\nsource authored, conventionally `PC` for desktop stores).\n\n**The operator's own lane additionally sees the titles they have HIDDEN**, each carrying\n`hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The\nconsole needs them to offer \"un-hide\", and it is the only surface that does.", "operationId": "getLibrary", "parameters": [ { @@ -1021,13 +1021,13 @@ ], "responses": { "200": { - "description": "Unified library across all stores", + "description": "Unified library across all stores (the operator's lane also gets hidden entries, flagged)", "content": { "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/GameEntry" + "$ref": "#/components/schemas/OperatorGameEntry" } } } @@ -1052,7 +1052,7 @@ "library" ], "summary": "Fetch one cover-art image for a library entry", - "description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —\nit's what the user's Steam client already shows for it), the public Steam CDN's flat URL\nconvention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host\ncan't predict, in which case this 404s and the client falls through to its next art candidate).\nOnly Steam ids are backed today; any other store 404s.", + "description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,\nand a library plugin's claimed-store entries) serves its local art file; anything else 404s and\nthe client falls through to its next art candidate.\n\nThe host fetches nothing here. Art a plugin published as an `http(s)` URL is fetched by the\nclient directly — this proxy exists for the *local* files a plugin finds on the host's own disk\n(a launcher's cover cache), which a client has no way to read.", "operationId": "getLibraryArt", "parameters": [ { @@ -1301,13 +1301,86 @@ } } }, + "/api/v1/library/hidden/{id}": { + "put": { + "tags": [ + "library" + ], + "summary": "Hide or un-hide one library title", + "description": "Curation, not access control: a hidden title disappears from every play surface — the console\ngrid on a client, native clients, the GameStream app list, and launch resolution — while nothing\nis deleted and un-hiding restores it immediately. The operator's own console still lists it\n(flagged `hidden`) so it can be brought back.\n\nKeyed by the entry's stable `:` id, which survives re-scans and reconciles by\nconstruction (D2). The id is **not** validated against the current library on purpose: a title\ncan be legitimately absent at this moment (launcher closed, plugin mid-sync, drive unmounted),\nand refusing the operator's choice in that window would be worse than storing an id that\ncurrently matches nothing. Emits `library.changed` (source = the store) only on a real change.", + "operationId": "setLibraryEntryHidden", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The library entry id (e.g. `steam:70`)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HiddenToggle" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Stored; the entry's visibility after the call", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HiddenState" + } + } + } + }, + "400": { + "description": "Empty entry id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not persist the settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/library/provider/{provider}": { "put": { "tags": [ "library" ], "summary": "Replace a provider's library entries (declarative reconcile)", - "description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.", + "description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.\n\n`?store=` additionally **claims** that store for the provider: its entries then surface with\ndeterministic `:` ids and the store's own badge, instead of opaque\n`custom:` ones — which is what let a library plugin reproduce the entries the in-host scanner\nused to produce, right down to the GameStream app ids and client-side art caches, and is why\nremoving those scanners changed nothing downstream. One provider per store; a second claimant\ngets 409. The claim is released by `DELETE`, not by an empty reconcile (a store can legitimately\nhave zero installed titles).", "operationId": "reconcileProviderEntries", "parameters": [ { @@ -1318,6 +1391,15 @@ "schema": { "type": "string" } + }, + { + "name": "store", + "in": "query", + "description": "Claim this store for the provider ([a-z0-9_-], `custom`/`manual` reserved)", + "required": false, + "schema": { + "type": "string" + } } ], "requestBody": { @@ -1348,7 +1430,7 @@ } }, "400": { - "description": "Invalid provider id or payload", + "description": "Invalid provider id, store id, or payload", "content": { "application/json": { "schema": { @@ -1367,6 +1449,16 @@ } } }, + "409": { + "description": "That store is already claimed by another provider", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, "500": { "description": "Could not persist the catalog", "content": { @@ -1446,8 +1538,8 @@ "tags": [ "library" ], - "summary": "List the library scanners", - "description": "The installed-store scanners this host supports — the list is platform-dependent (Steam\neverywhere; Lutris + Heroic on Linux; Epic, GOG, and Xbox/Game Pass on Windows), so the console\nrenders a toggle only for scanners that can do anything here. Scanners default to enabled;\ndisabling one hides its titles from every library surface from the next read. The user-curated\ncustom store is not a scanner and is always on.", + "summary": "List the library sources", + "description": "Every game source on this host with its enable state — one row per installed library plugin\n(Steam, Lutris, Heroic, Epic, GOG, Xbox, Playnite, ROM managers, …), so the list reflects what\nthe operator has actually installed rather than what this build happens to support. Sources\ndefault to enabled; disabling one hides its titles from every library surface from the next\nread. The user-curated custom store is not a source and is always on.\n\nOlder hosts (≤ v0.27.x) also listed the six scanners built into the host binary, with\n`origin: \"builtin\"`. Those are gone; every row now reports `origin: \"plugin\"`.", "operationId": "listLibraryScanners", "responses": { "200": { @@ -1481,8 +1573,8 @@ "tags": [ "library" ], - "summary": "Enable or disable a library scanner", - "description": "Persists the toggle and applies it from the next library read (no restart). Disabling a scanner\nhides its titles everywhere — the console grid, native clients, and the GameStream app list —\nand re-enabling brings them straight back (nothing is deleted; the scan just runs again). Emits\n`library.changed` with the scanner id as `source` when the state changed.", + "summary": "Enable or disable a library source", + "description": "Persists the toggle and applies it from the next library read (no restart). Disabling a source\nhides its titles everywhere — the console grid, native clients, and the GameStream app list —\nand re-enabling brings them straight back. Nothing is deleted: the plugin may keep reconciling\nwhile its source is off, and those entries simply aren't surfaced. Emits `library.changed` with\nthe source id as `source` when the state changed.", "operationId": "setLibraryScanner", "parameters": [ { @@ -2170,6 +2262,51 @@ } } }, + "/api/v1/plugins/logs": { + "post": { + "tags": [ + "plugins" + ], + "summary": "Ingest runner log lines", + "description": "The plugin/script runner ships its output here so the console's **Logs** page can show it.\n\nPlugins are not host child processes — the runner is a separate `bun` process that `import()`s\neach plugin in-process — so nothing a plugin logs passes through the host's own `tracing`, and\nbefore this endpoint the console's log page could not show a single plugin line. On Linux the\nfallback was `journalctl --user -u punktfunk-scripting`; on Windows the runner task writes no\nlog file at all, so a failing plugin was diagnosable only by stopping the scheduled task and\nre-running the runner by hand. Both are shell access on the host box, which is exactly what the\nconsole exists to avoid.\n\nLines land in the same ring as the host's own, sharing one `seq` cursor, targeted\n`plugin:` — so `GET /logs` needs no second cursor and the console needs no second poll.", + "operationId": "ingestPluginLogs", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginLogBatch" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Lines ingested" + }, + "400": { + "description": "Batch too large", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/plugins/{id}": { "put": { "tags": [ @@ -3432,6 +3569,142 @@ } } } + }, + "/api/v1/update/apply": { + "post": { + "tags": [ + "update" + ], + "summary": "Apply the available update", + "description": "Starts the one-click apply for install kinds that support it (Windows installer). The\nrequest carries no version or URL — the host installs exactly what its verified manifest\nannounced. Progress is polled via `GET /update/status` (`job`); the host restarts as part\nof the apply, and the outcome lands in `last_result` after it comes back.", + "operationId": "applyUpdate", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplyRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Apply started — poll `GET /update/status`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateStatus" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "409": { + "description": "Refused: unsupported install kind, apply disabled (PUNKTFUNK_UPDATE_APPLY=0), a job already running, an active streaming session without `force`, or nothing newer to apply", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/update/check": { + "post": { + "tags": [ + "update" + ], + "summary": "Check for updates now", + "description": "Forces a manifest fetch + verification and returns the refreshed state. Rate-limited to\none forced check per 30 s.", + "operationId": "forceUpdateCheck", + "responses": { + "200": { + "description": "Refreshed update-check state (`last_error` carries a failed check; `not_published` an empty channel, which is not one)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateStatus" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "409": { + "description": "Update checks are disabled on this host", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "429": { + "description": "A forced check ran less than 30 s ago", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/update/status": { + "get": { + "tags": [ + "update" + ], + "summary": "Update-check status", + "description": "How this host was installed, which channel it follows, whether a newer release is known,\nand how to update. Reading this may kick a background refresh when the cached check is\nolder than 6 h; the response never blocks on the network.", + "operationId": "getUpdateStatus", + "responses": { + "200": { + "description": "Current update-check state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateStatus" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } } }, "components": { @@ -3764,6 +4037,15 @@ } } }, + "ApplyRequest": { + "type": "object", + "properties": { + "force": { + "type": "boolean", + "description": "Proceed even while a streaming session is live (the stream will drop when the host\nrestarts — the console warns before sending this)." + } + } + }, "ApprovePending": { "type": "object", "description": "Approve-pending-device request body. Send `{}` to keep the device's own name.", @@ -3836,6 +4118,51 @@ } } }, + "AudioWiring": { + "type": "object", + "description": "The Windows host's audio wiring verdict — which endpoint carries each role. The names are\nthe endpoints' friendly names as the Sound settings show them (on current hosts the minted\n\"Punktfunk\" instances of Steam's streaming drivers).", + "required": [ + "readiness", + "mic_withheld", + "last_resort" + ], + "properties": { + "last_resort": { + "type": "boolean", + "description": "The loopback is the known-degraded last resort — desktop audio may be silent until the\nendpoint set changes." + }, + "loopback": { + "type": [ + "string", + "null" + ], + "description": "Friendly name of the desktop-audio loopback source; absent = desktop audio unavailable." + }, + "mic": { + "type": [ + "string", + "null" + ], + "description": "Friendly name of the virtual-mic write target; absent = mic passthrough unavailable." + }, + "mic_withheld": { + "type": "boolean", + "description": "The mic was WITHHELD so game audio could keep the only working sink — mic passthrough\nneeds Steam installed (the host mints its own microphone) or a virtual cable." + }, + "narrowing": { + "type": [ + "string", + "null" + ], + "description": "Why the chosen loopback endpoint NARROWS the desktop mix (rate/channels), when it does." + }, + "readiness": { + "type": "string", + "description": "`full` | `audio_only` | `mic_only` | `none` — whether desktop audio and mic passthrough\neach have an endpoint at all.", + "example": "full" + } + } + }, "AvailableCompositor": { "type": "object", "description": "A compositor backend the host can drive a virtual output on, and whether it's usable now.", @@ -3969,7 +4296,8 @@ "tier", "platforms", "compatible", - "update_available" + "update_available", + "categories" ], "properties": { "author": { @@ -3982,6 +4310,13 @@ ], "description": "A revocation covering the catalogued version — do not offer this without shouting." }, + "categories": { + "type": "array", + "items": { + "type": "string" + }, + "description": "What kind of plugin this is — the console filters Browse by these, and the Game sources\nsurface's \"Add a source\" rail shows exactly the `library` ones (design D5/D6)." + }, "compatible": { "type": "boolean", "description": "Can this host install it?" @@ -3989,6 +4324,13 @@ "description": { "type": "string" }, + "detected": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the launcher this plugin scans looks **installed on this host** (design D8), from the\nindex's own existence probes. `null` = the entry declares no probes for this platform, which\nthe console renders as \"unknown\" rather than \"not installed\"." + }, "homepage": { "type": [ "string", @@ -4147,6 +4489,13 @@ ], "description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is." }, + "icon": { + "type": [ + "string", + "null" + ], + "description": "Which brand mark a client should draw for this entry — see [`GameEntry::icon`]. A token\n(`steam`, `heroic`), never bytes and never a URL." + }, "id": { "type": "string", "description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)." @@ -4175,6 +4524,17 @@ ], "description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)." }, + "role": { + "$ref": "#/components/schemas/GameRole", + "description": "Whether this entry is a game or the launcher itself — see [`GameRole`]." + }, + "store": { + "type": [ + "string", + "null" + ], + "description": "The **store this entry was claimed under** (D2), stamped by a `?store=`-qualified reconcile.\n`None` = an unclaimed provider entry or a manual one, both of which surface as `custom`.\n\nMaterialized onto the entry rather than looked up in [`Catalog::claims`] on every read so an\nentry is self-describing: its id and its `store` badge derive from the entry alone, and stay\ncorrect even while the claim map is being rewritten." + }, "title": { "type": "string" } @@ -4202,6 +4562,13 @@ "$ref": "#/components/schemas/DetectHint", "description": "How to recognize this title's process — see [`CustomEntry::detect`]." }, + "icon": { + "type": [ + "string", + "null" + ], + "description": "Which brand mark to draw — see [`GameEntry::icon`]. Hand-settable for the same reason `role`\nis: an operator's own \"Steam\" tile should be able to look like one." + }, "launch": { "oneOf": [ { @@ -4219,6 +4586,10 @@ }, "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." }, + "role": { + "$ref": "#/components/schemas/GameRole", + "description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A hand-added launcher\nentry is legal (an operator may want a \"Steam\" tile without installing the steam plugin)." + }, "title": { "type": "string" } @@ -4277,6 +4648,17 @@ "type": "object", "description": "What an operator (or a provider plugin) can tell the host about recognizing a title — the wire\nhalf of [`DetectSpec`], and the only part of it that is ever accepted from outside.\n\nDeliberately a **subset**: the store-derived signals (a Steam appid, a launcher's environment\nmarker) are things the host discovers for itself and would be meaningless — or dangerous — to take\non someone's word. What is left is what a provider genuinely knows and the host cannot guess: where\nthe title is installed, which executable is the game, what the process is called. All three are\noptional; supplying none is the same as supplying no hint at all.\n\nNever returned by the catalog API — see the module docs on why detect data does not cross the wire\noutbound.", "properties": { + "env_marker": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/EnvMarker", + "description": "A launcher-stamped environment marker (D3) — see [`EnvMarker`]." + } + ] + }, "exe": { "type": [ "string", @@ -4297,6 +4679,15 @@ "null" ], "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." + }, + "steam_appid": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "The Steam appid, for a title Steam itself installed (D3). On Linux this is the **sharpest**\nsignal that exists — Steam wraps every launch, native or Proton, in\n`reaper SteamLaunch AppId=`, whose lifetime is exactly the game's — so without it a\nsteam plugin's lease tracking would degrade from reaper-exact to install-dir prefix matching.", + "minimum": 0 } } }, @@ -4362,6 +4753,10 @@ "type": "boolean", "description": "EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan / DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched." }, + "edid_lock": { + "type": "boolean", + "description": "**EXPERIMENTAL, AMD-only in effect: pin connector EDID emulation while streaming** — the\nsoftware equivalent of an HPD-holding dummy plug (`pf_win_display::adl_emul`). Locked at\nthe first Exclusive isolate BEFORE the physicals deactivate (an awake sink answers its\nlive-EDID read), unlocked at last-member teardown, crash-journaled so a dead host unlocks\non its next start. Targets the standby-sink stall class at its SOURCE: with emulation\npinned the KMD stops servicing the sleeping sink's HPD/DDC/link. Inert without an AMD\ndriver (`atiadlxx.dll` absent) and on non-Windows. Orthogonal to `preset` (like\n`game_session`); `#[serde(default)]` = off." + }, "game_session": { "$ref": "#/components/schemas/GameSession", "description": "How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched." @@ -4397,7 +4792,7 @@ "version": { "type": "integer", "format": "int32", - "description": "Schema version (currently 1) — lets a future field addition migrate rather than reject.", + "description": "Schema version (currently 1) — lets a future field addition migrate rather than reject. Read\nat load time ([`DisplayPolicyStore::load_from`] warns when a file claims a version this host\ndoes not know, then reads it best-effort) and pinned back to the current version on write.", "minimum": 0 } } @@ -4466,7 +4861,7 @@ }, "EffectivePolicy": { "type": "object", - "description": "The six resolved fields after preset expansion — what the lifecycle/registry and the Stage-0 call\nsites read, and what the mgmt API echoes as the \"currently in force\" policy. Pure output of\n[`DisplayPolicy::effective`].", + "description": "The six resolved fields after preset expansion — what the lifecycle/registry and the policy call\nsites read, and what the mgmt API echoes as the \"currently in force\" policy. Pure output of\n[`DisplayPolicy::effective`].\n\n**Every field is required on the wire, deliberately.** Unlike [`DisplayPolicy`] — which is only\never a *file* — this shape is also the `fields` member of [`CustomPresetInput`], i.e. the request\nbody of `POST /display/presets` and `PUT /display/presets/{id}`, and a *response* member three\ntimes over (`DisplaySettingsState.effective`, `PresetInfo.fields`, `CustomPreset.fields`).\n`#[serde(default)]` here would (a) turn `{\"name\":\"Kiosk\",\"fields\":{}}` — or any camelCase typo —\nfrom a serde rejection into a 201 storing a preset that expands to six axes nobody chose, and\n(b) make all six OPTIONAL in the generated OpenAPI schema, so every codegen'd client has to\nnull-check them. The *persisted* catalog's tolerance for an entry written before an axis existed\nis bought where it belongs, on the read path only: see [`StoredEffectivePolicy`].", "required": [ "keep_alive", "topology", @@ -4525,6 +4920,27 @@ } } }, + "EnvMarker": { + "type": "object", + "description": "An environment variable a launcher stamps onto the game's process, identifying it.\n\nSerializable because it is now half of the inbound [`DetectHint`] too (D3) — a library plugin\nthat knows its launcher's marker (Heroic's `HEROIC_APP_NAME`, load-bearing under Proton) has to\nbe able to say so, since after extraction the host no longer reads that launcher's files itself.", + "required": [ + "key" + ], + "properties": { + "key": { + "type": "string", + "description": "The variable name (e.g. `HEROIC_GAME_ID`).", + "example": "HEROIC_APP_NAME" + }, + "value": { + "type": [ + "string", + "null" + ], + "description": "The exact value to require, when the launcher's value identifies *this* title. `None` matches\nthe key's mere presence — only safe for launchers that run one game at a time." + } + } + }, "EventKind": { "oneOf": [ { @@ -4799,6 +5215,59 @@ } } }, + { + "type": "object", + "description": "A verified update manifest announced a release newer than the running host. Emitted\nonce per discovered version (a steady-state \"newer exists\" doesn't re-fire on every\nrefresh).", + "required": [ + "version", + "channel", + "install_kind", + "kind" + ], + "properties": { + "channel": { + "type": "string", + "description": "The channel it was announced on (`stable` | `canary`)." + }, + "install_kind": { + "type": "string", + "description": "This host's install kind (`apt`, `windows-installer`, …) — lets a hook or the\ntray render the right \"how to update\" hint without a second call." + }, + "kind": { + "type": "string", + "enum": [ + "update.available" + ] + }, + "version": { + "type": "string", + "description": "The newer release's version string." + } + } + }, + { + "type": "object", + "description": "A host update completed: emitted by boot-time reconciliation, i.e. by the NEW binary's\nfirst start after a successful apply.", + "required": [ + "from", + "to", + "kind" + ], + "properties": { + "from": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "update.applied" + ] + }, + "to": { + "type": "string" + } + } + }, { "type": "object", "required": [ @@ -4899,6 +5368,14 @@ "art": { "$ref": "#/components/schemas/Artwork" }, + "icon": { + "type": [ + "string", + "null" + ], + "description": "Which brand mark to draw for this entry, as a **token** — `steam`, `heroic`, `playnite` —\nnever image bytes and never a URL. See [`is_icon_token`].\n\nIt exists for launcher tiles, which by design ship no cover art: a launcher's own icon is\nsquare, every client cover-crops a 2:3 poster, and the crop turns a mark into a strip — so\nuntil now those tiles were the launcher's name on a flat accent face. The token lets a client\ndraw the real mark from art it already ships, at whatever size its tile happens to be.\n\nA token rather than art on the wire because the host's art proxy serves *raster* bytes only\n([`art::local_art_bytes`] sniffs the container and refuses anything else, SVG very much\nincluded — it is script-capable XML and the console renders art in a browser). Sending the\nname of a mark instead of the mark keeps that refusal intact, keeps the glyph vector at every\ntile size, and lets it take the tile's ink.\n\nOrdinary titles may carry one too — nothing here is launcher-specific — but nothing sets it\nfor them: a game has real cover art, which is strictly better than a brand mark.", + "example": "steam" + }, "id": { "type": "string", "description": "Stable, store-qualified id: `steam:` or `custom:`.", @@ -4920,7 +5397,11 @@ "string", "null" ], - "description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it." + "description": "The external provider owning this entry (entries synced by a provider plugin, RFC §8) —\n`None` only for the manual entries the operator typed in. The console uses it for\nattribution; `GET /library?provider=` filters on it." + }, + "role": { + "$ref": "#/components/schemas/GameRole", + "description": "Whether this entry is a game or the launcher itself — see [`GameRole`]." }, "store": { "type": "string", @@ -5053,6 +5534,14 @@ } } }, + "GameRole": { + "type": "string", + "description": "What a library entry *is* — an ordinary title, or the launcher application itself (Steam Big\nPicture, Heroic, Playnite fullscreen). Purely a presentation hint: a launcher entry launches,\nleases and lists exactly like a game (design D4), and clients that don't know the field render it\nas a plain tile. Serde-default `game` and skip-serialized when default, so the wire is unchanged\nfor every entry that doesn't opt in.", + "enum": [ + "game", + "launcher" + ] + }, "GameSession": { "type": "string", "description": "How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is\nserved (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes\n— a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers\nit. Linux-only in effect (a launching Windows session opens into the one desktop).", @@ -5163,6 +5652,37 @@ } } }, + "HiddenState": { + "type": "object", + "description": "What `setLibraryEntryHidden` echoes back.", + "required": [ + "id", + "hidden" + ], + "properties": { + "hidden": { + "type": "boolean", + "description": "Its visibility after the call." + }, + "id": { + "type": "string", + "description": "The entry id the call addressed." + } + } + }, + "HiddenToggle": { + "type": "object", + "description": "Request body for `setLibraryEntryHidden`.", + "required": [ + "hidden" + ], + "properties": { + "hidden": { + "type": "boolean", + "description": "Whether this title should be hidden from every play surface." + } + } + }, "HookEntry": { "type": "object", "description": "One hook: fire `run` and/or `webhook` when an event matching `on` (+ `filter`) occurs.", @@ -5399,7 +5919,7 @@ }, "Identity": { "type": "string", - "description": "Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.", + "description": "Stable display identity, so desktop environments persist per-display config (KDE scaling). The\nslot this resolves to is carried per backend: the Windows EDID serial + IddCx connector index,\nKWin's per-slot output name, and the host-persisted Mutter scale map.", "enum": [ "shared", "per-client", @@ -5616,14 +6136,14 @@ "seconds": { "type": "integer", "format": "int32", - "description": "Linger window in seconds.", + "description": "Linger window in seconds, clamped to `0..=86400` on write (see\n[`DisplayPolicy::sanitized`]): a window longer than a day is `forever` by any honest\nreading, and `u32` seconds is ~136 years — a deadline the reaper would never reach and a\nnonsense `expires_in_ms` in `/display/state`.", "minimum": 0 } } }, { "type": "object", - "description": "Keep the display until host shutdown or an explicit release (the `Pinned` lifecycle state).\n**Not honored until the display-lifecycle stage** — rejected by the mgmt PUT at Stage 0.", + "description": "Keep the display until host shutdown or an explicit release (the `Pinned` lifecycle state).\nHonored end-to-end: the registry resolves it to `Release::Pin`, so the display survives every\ndisconnect — free it with `POST /display/release` (which force-releases `Pinned` exactly like\na `Lingering` display). This is what the `gaming-rig` preset selects.", "required": [ "mode" ], @@ -5667,6 +6187,7 @@ }, "positions": { "type": "object", + "description": "Keys are the **canonical decimal** identity-slot id (`\"1\"`..`\"15\"`) — the exact string\n`arrange` looks a member up by. [`DisplayPolicy::sanitized`] re-canonicalizes them on write\n(`\"01\"` → `\"1\"`) and drops anything that is not a slot id, because a key that never matches is\na pin the operator can see in the console and in `GET /display/settings` while every session\nsilently auto-rows past it.", "additionalProperties": { "$ref": "#/components/schemas/Position" }, @@ -5678,7 +6199,7 @@ }, "LayoutMode": { "type": "string", - "description": "How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.", + "description": "How group members are arranged in the desktop coordinate space, resolved by `layout::arrange` —\nwhich both the `/display/state` readout and (on Linux, KWin only) the per-backend position apply\nconsume, so the answer is computed in exactly one place.", "enum": [ "auto-row", "manual" @@ -5838,7 +6359,7 @@ }, "ModeConflict": { "type": "string", - "description": "Admission when a *different* client connects while a display/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.", + "description": "Admission when a *different* client connects while a display/session is already live and asks for\na different mode. Enforced by [`super::admission`] before the Welcome is sent, so a `reject` is a\nclean handshake error rather than a half-built session.", "enum": [ "separate", "steal", @@ -5949,6 +6470,23 @@ } } }, + "OperatorGameEntry": { + "allOf": [ + { + "$ref": "#/components/schemas/GameEntry" + }, + { + "type": "object", + "properties": { + "hidden": { + "type": "boolean", + "description": "The operator hid this title ([`set_entry_hidden`]) — omitted when false, so the shape only\ngrows for entries that actually are hidden." + } + } + } + ], + "description": "A library entry plus the operator's own view of it — today, whether they hid it.\n\nA separate type rather than a field on [`GameEntry`] for two reasons. It keeps the visibility\nanswer out of the providers entirely: a store parser has no opinion on what the operator hid, and\nadding `hidden: false` to all eight construction sites would imply it does. More importantly it\nmakes the lane rule a TYPE guarantee instead of a discipline — `GET /library` answers\n`Vec` on every lane but the operator's, so a hidden entry cannot leak to a paired\nclient by someone forgetting a filter; there is no field there to leak.\n\n`flatten` keeps the wire shape identical to a plain entry with one extra key, so the console\nparses one model either way." + }, "PairedClient": { "type": "object", "description": "A paired (certificate-pinned) Moonlight client.", @@ -6040,6 +6578,50 @@ "gamestream" ] }, + "PluginLogBatch": { + "type": "object", + "description": "A batch of runner log lines.", + "required": [ + "entries" + ], + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PluginLogLine" + } + } + } + }, + "PluginLogLine": { + "type": "object", + "description": "One log line produced by the runner or a plugin inside it (`POST /plugins/logs`).", + "required": [ + "ts_ms", + "level", + "source", + "msg" + ], + "properties": { + "level": { + "type": "string", + "description": "`ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`. Anything else is coerced to `INFO`." + }, + "msg": { + "type": "string" + }, + "source": { + "type": "string", + "description": "Which unit emitted it — a plugin's `definePlugin` name, a package name, or `runner`.\nSurfaced in the console's target column as `plugin:`." + }, + "ts_ms": { + "type": "integer", + "format": "int64", + "description": "When the line was produced, unix milliseconds. Kept verbatim — see\n[`crate::log_capture::LogRing::push_remote`].", + "minimum": 0 + } + } + }, "PluginRegistration": { "type": "object", "description": "Register/renew body for `PUT /plugins/{id}`.", @@ -6047,6 +6629,13 @@ "title" ], "properties": { + "category": { + "type": [ + "string", + "null" + ], + "description": "What KIND of plugin this is (`^[a-z][a-z0-9-]{0,31}$`), top-level rather than under `ui`\nbecause it describes the plugin, not its surface. The console knows one value today —\n`library` — which it filters **out of the nav**: six installed scanner plugins would otherwise\nflood the sidebar, and their real entry point is the Game sources surface (design D5). A\nlibrary plugin that genuinely wants its own page (rom-manager, which is much more than a\nscanner) simply omits the category." + }, "title": { "type": "string", "description": "Human-readable title for the console nav entry (1–64 chars; control chars stripped)." @@ -6079,6 +6668,13 @@ "title" ], "properties": { + "category": { + "type": [ + "string", + "null" + ], + "description": "The plugin's kind — see [`PluginRegistration::category`]." + }, "id": { "type": "string" }, @@ -6300,6 +6896,13 @@ "type": "string", "description": "The provider's stable id for this title (the reconcile diff key)." }, + "icon": { + "type": [ + "string", + "null" + ], + "description": "Which brand mark to draw — see [`GameEntry::icon`]. This is the field a library plugin sets\non its `launchers(cfg)` tiles, and the whole reason the token exists." + }, "launch": { "oneOf": [ { @@ -6317,6 +6920,10 @@ }, "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." }, + "role": { + "$ref": "#/components/schemas/GameRole", + "description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A library plugin\nemits its `launchers(cfg)` entries with `role: \"launcher\"`." + }, "title": { "type": "string" } @@ -6398,6 +7005,17 @@ "description": "Number of live streaming sessions across BOTH planes (GameStream + native punktfunk/1). The\nnative server admits concurrent sessions, so this can exceed 1; `session`/`stream` below\ndescribe a single representative session for the detail card.", "minimum": 0 }, + "audio": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/AudioWiring", + "description": "The audio wiring verdict (Windows hosts; absent on other platforms and before the first\nwiring pass). Present even while idle — the wiring exists for the host's lifetime." + } + ] + }, "audio_streaming": { "type": "boolean", "description": "True while the audio stream thread is running." @@ -6493,26 +7111,46 @@ }, "ScannerInfo": { "type": "object", - "description": "One installed-store scanner this host build supports, with its enable state — the unit the\nconsole renders a toggle for. The list is platform-gated at compile time (the scanners are),\nso the console never shows a toggle that cannot do anything on this host.", + "description": "One **game source** on this host, with its enable state — the unit the console renders a toggle\nfor. A source is either a scanner compiled into this build or a plugin that reconciles entries in\n(WP2.6); the console treats them identically, which is what makes the extraction invisible.", "required": [ "id", "label", - "enabled" + "enabled", + "origin" ], "properties": { "enabled": { "type": "boolean", - "description": "Whether this host runs the scanner (default true)." + "description": "Whether this host runs the source (default true)." + }, + "entries": { + "type": [ + "integer", + "null" + ], + "description": "How many entries this source currently contributes. `None` for a built-in scanner, whose\ncount would mean walking every launcher's files just to render a toggle.", + "minimum": 0 }, "id": { "type": "string", - "description": "Stable scanner id — the same string the scanner's entries carry in their `store` field.", + "description": "Stable source id — the same string this source's entries carry in their `store` field. For a\nplugin source it is also its provider id and its store claim: one string, by construction, so\na user's disabled state survives a built-in scanner being replaced by its plugin.", "example": "steam" }, "label": { "type": "string", "description": "Human-facing name for the console toggle.", "example": "Steam" + }, + "origin": { + "$ref": "#/components/schemas/SourceOrigin", + "description": "Where the source comes from. Always `plugin` from this host build onward — see\n[`SourceOrigin`]." + }, + "provider": { + "type": [ + "string", + "null" + ], + "description": "The provider id backing a `plugin` source — absent for a built-in scanner." } } }, @@ -6525,7 +7163,7 @@ "properties": { "enabled": { "type": "boolean", - "description": "Whether the scanner should run on this host." + "description": "Whether this source should contribute titles on this host." } } }, @@ -6675,6 +7313,14 @@ } } }, + "SourceOrigin": { + "type": "string", + "description": "Where a [`ScannerInfo`] comes from.", + "enum": [ + "builtin", + "plugin" + ] + }, "SourceView": { "type": "object", "description": "A configured catalog source and how its last refresh went.", @@ -7040,6 +7686,233 @@ "type": "string" } } + }, + "UpdateJobInfo": { + "type": "object", + "description": "A running apply job (or a spawned installer that hasn't resolved yet).", + "required": [ + "target_version", + "stage", + "received_bytes", + "started_unix" + ], + "properties": { + "received_bytes": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "stage": { + "type": "string", + "description": "`downloading` | `verifying` | `applying` | `restarting`." + }, + "started_unix": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "target_version": { + "type": "string", + "description": "The version being installed." + }, + "total_bytes": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + } + } + }, + "UpdateManifestInfo": { + "type": "object", + "description": "One channel's manifest facts, as much as the console renders.", + "required": [ + "version", + "serial", + "published_at", + "notes_url", + "stale" + ], + "properties": { + "notes_url": { + "type": "string", + "description": "Release-notes link (pinned to our forge by the manifest validator)." + }, + "published_at": { + "type": "string", + "description": "RFC-3339 publish time (display only)." + }, + "serial": { + "type": "integer", + "format": "int64", + "description": "Publish serial (unix seconds) — monotonic per channel.", + "minimum": 0 + }, + "stale": { + "type": "boolean", + "description": "The last verified manifest is suspiciously old (>45 days) — the freeze/stale hint." + }, + "version": { + "type": "string", + "description": "The released version this manifest announces." + } + } + }, + "UpdateResultInfo": { + "type": "object", + "description": "Durable outcome of the most recent apply attempt (survives the host's own restart).", + "required": [ + "ok", + "from", + "to", + "finished_unix" + ], + "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, + "finished_unix": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "from": { + "type": "string" + }, + "log_path": { + "type": [ + "string", + "null" + ], + "description": "The installer's own log file on this host, for diagnosis." + }, + "ok": { + "type": "boolean" + }, + "stage": { + "type": [ + "string", + "null" + ], + "description": "The stage that failed; absent on success." + }, + "staged": { + "type": "boolean", + "description": "Applied but activates on the next reboot (rpm-ostree)." + }, + "to": { + "type": "string" + } + } + }, + "UpdateStatus": { + "type": "object", + "description": "The full update-check state for this host.", + "required": [ + "install_kind", + "channel", + "current_version", + "apply", + "channel_hint", + "check_disabled", + "available", + "not_published" + ], + "properties": { + "apply": { + "type": "string", + "description": "What the console may offer for this install: `notify` (show the command) — later\nphases add `full` (one-click apply) and `staged` (apply + reboot to finish)." + }, + "available": { + "type": "boolean", + "description": "A newer release than `current_version` exists for this channel (definitive\ncomparisons only — an unparseable version pair never flags)." + }, + "channel": { + "type": "string", + "description": "Release channel this install follows: `stable` | `canary`." + }, + "channel_hint": { + "type": "string", + "description": "The copy-pastable update command for this install kind." + }, + "check_disabled": { + "type": "boolean", + "description": "Update checks are disabled on this host (`PUNKTFUNK_UPDATE_CHECK=0`)." + }, + "current_version": { + "type": "string", + "description": "The running host version." + }, + "install_kind": { + "type": "string", + "description": "How this host was installed: `windows-installer` | `sysext` | `rpm-ostree` | `apt` |\n`dnf` | `pacman` | `steamos-source` | `nix` | `source`." + }, + "job": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/UpdateJobInfo", + "description": "The apply in flight, if any." + } + ] + }, + "last_checked_unix": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "When the last successful check happened (unix seconds).", + "minimum": 0 + }, + "last_error": { + "type": [ + "string", + "null" + ], + "description": "Why the last check failed, verbatim, if it did." + }, + "last_result": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/UpdateResultInfo", + "description": "Outcome of the most recent apply attempt." + } + ] + }, + "manifest": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/UpdateManifestInfo", + "description": "The last verified manifest, if any check has succeeded." + } + ] + }, + "not_published": { + "type": "boolean", + "description": "The check reached the feed and found this channel has **no release published yet** —\nan expected state (a channel nobody has announced to answers with a 404), not a\nfailure. Mutually exclusive with `last_error`, so a UI can say \"nothing published yet\"\ninstead of painting an empty feed as a broken host. Never set once a manifest has been\nseen for this channel: a feed that loses a document it used to serve stays an error." + }, + "opt_in_hint": { + "type": [ + "string", + "null" + ], + "description": "This install could one-click apply, but the operator hasn't opted in yet — the\ncommand to run (Linux: join the `punktfunk-update` group)." + } + } } }, "securitySchemes": { @@ -7085,7 +7958,7 @@ }, { "name": "library", - "description": "Game library: installed-store titles (Steam) plus user-curated custom entries" + "description": "Game library: the titles each installed library plugin syncs, plus user-curated custom entries" }, { "name": "stats", @@ -7110,6 +7983,10 @@ { "name": "store", "description": "Plugin store: browse signed catalogs (verified first-party entries, attributed third-party sources), install/uninstall as tracked jobs, and switch the plugin runner on" + }, + { + "name": "update", + "description": "Host update check: install kind + channel, the last verified release manifest, and whether a newer host exists (admin lane only)" } ] } diff --git a/docs/releases/v0.28.0.md b/docs/releases/v0.28.0.md new file mode 100644 index 00000000..47b483fb --- /dev/null +++ b/docs/releases/v0.28.0.md @@ -0,0 +1,74 @@ +Wire-compatible with 0.27.x — everything you have already paired keeps working, and you can update one side at a time. Nothing here changes how a host and a client agree on what to send each other, so an old client on a new host, or the other way round, streams exactly as it does today. + +This release is mostly about a host that behaves itself on the machine it lives on. A Windows host no longer keeps your computer awake around the clock, and no longer costs you frames in the game you are playing locally — both were real, both were reported from the field, and neither had anything to do with streaming. On Linux, the setting that was supposed to make streaming smoother turned out to be *causing* the stutter people kept reporting, and it is now off. Controllers get their speaker and touch feedback on Linux hosts for the first time, Wi-Fi machines can finally be woken remotely, and Debian is a supported system at last. There is one change you may need to act on: the Moonlight-compatible half of the host is now something you switch on rather than something that is always there. + +## TL;DR + +- **Your game library now comes from add-ons, not from the host itself** — and if you have none installed, your library will be empty after updating. One click per store puts it back. See *Before you update*. +- **If you connect with Moonlight, you must switch it on after updating.** The host now speaks only its own protocol unless you tell it otherwise. Punktfunk's own clients are unaffected. See *Before you update*. +- **A Windows host misbehaved on the machine it was installed on**, whether or not anyone was streaming: it stopped the computer from ever going to sleep, and it could wreck the frame rate of a game you were playing on that same machine — badly enough that one report cured it by uninstalling. Both fixed. +- **Two long-standing Linux streaming faults are gone.** The "make it smoother" setting was in fact *causing* the stutter people kept reporting — both places we asked the graphics card for priority are now off by default, nothing to switch on — and streams no longer tear with pink, blocky corruption at high frame rates on gamescope machines. +- **Linux hosts gain two things:** your controller's speaker and touch feedback work on them for the first time, not just on Windows ones, and **Debian 13 is now supported** with the patched compositor finally installable via `apt` — it had never once been published, on any release. +- **Your host's identity is being modernised, and there is a date on it.** Existing setups keep working exactly as they are; from **1 October 2026** a host still using the old identity will ask its clients to pair once more. You can switch early in one go — see *Before you update*. + +## Before you update + +- **Your library comes from add-ons now — check yours after updating.** Punktfunk used to look for your Steam, Epic, GOG, Heroic, Lutris and Xbox games itself. That job has moved to add-ons you install per store, which has been the supported way since the version before last. The built-in half is now gone, so **if you have no library add-ons installed, your library will be empty**. Putting it back is one click per store on the Library page — nothing else changes: your games keep the same entries, the same artwork, the same pins, and any store you had switched off stays switched off. Nothing to migrate and nothing to re-pair. +- **Your host has a new identity for Punktfunk's own connections — and you can switch to it today if you want to.** Until now one key proved your host's identity to everything, inherited from the Moonlight-compatible half and older than the security work around it. Punktfunk's own connections now get their own, freshly generated, on a modern elliptic-curve key. **A brand-new install uses it immediately.** An existing install deliberately keeps the old one: your clients remember the exact key they trusted the day you paired, and swapping it underneath them would lock every one of them out at once. Nothing is weakened in the meantime, and nothing breaks. If you want the new identity now, unpair every Punktfunk client on that host, restart the host, and pair them again — that is the whole procedure, and the host prints the same instruction in its log. **From 1 October 2026 the host will stop falling back to the old identity**, so any host that has not migrated by then will ask its clients to pair once more. Moonlight-compatible connections are untouched by all of this and keep their own separate identity, which cannot change. +- **If you use Moonlight, or any Moonlight-compatible client, read this.** The host used to answer both its own clients and Moonlight's out of the box. It now answers only its own unless you ask for the other, because the Moonlight-compatible half predates Punktfunk's own security work and is the older, weaker way in. Nothing about your setup is lost — set `PUNKTFUNK_GAMESTREAM=1` in the host's configuration file and it works exactly as before. Punktfunk's own clients need no change at all. On NixOS the corresponding setting is now off by default; on a Steam Deck the installer takes a flag to opt in. Windows was already like this and does not change. +- **Bazzite, if Game Mode has been showing you the machine's own screen instead of giving you your own display:** that was our fault, in the configuration file we told you to copy. Copy the new one and the problem goes away. If you edited yours by hand, remove the line that forces the attach mode. +- **Linux Mint, or plain Ubuntu 24.04:** these cannot host. The desktop underneath them has no way to create the extra display Punktfunk needs, and Ubuntu 24.04 is too old to run the compositor that would work around it. The host will now tell you this plainly instead of suggesting a setting that cannot help. Mint 22.x is supported by its makers until December 2026, so this is not a short wait — a supported system is the only fix. +- **Debian or Ubuntu, if you were ever told to install `punktfunk-gamescope` and it was not there:** it genuinely was not. It had never been published for `apt` in any release, despite the notes and documentation saying otherwise. It is there now, for Debian 13 and Ubuntu 26.04. Ubuntu 24.04 cannot run it at all. +- **If you build Punktfunk from source**, you now need a working C compiler. Nothing else was added. + +## New + +- **Your controller's speaker and touch feedback work on Linux hosts.** The per-controller audio that drives a DualSense's speaker and its finer feedback existed only on Windows hosts; on a Linux host your controller quietly fell back to plain rumble and nothing said why. Linux hosts now present each controller to games as its own audio device, exactly as Windows ones do. +- **A machine on Wi-Fi can be woken remotely.** Waking a sleeping host over the network only ever worked on wired machines. On Wi-Fi the host asked the wrong part of the system whether waking was switched on, so it told you it was not even when it was, and then offered a command the wireless driver rejects. It now asks the right question, gives advice that works, and the client aims the wake-up at every network it can see rather than trusting the default route — which, with a VPN running, never reached the wireless network at all. +- **Debian 13 is a supported system for hosting**, tested on every release from now on. It had quietly worked for a long time while the documentation said it was unsupported. +- **Launcher tiles show the launcher's mark**, so a Steam title looks like one at a glance. +- **Every pinned card has a library of its own**, and launching from it uses that card's settings. +- **Experimental: hold a display steady while streaming.** On machines with an AMD graphics card, Punktfunk can hold the connection to your monitor open while your own screens are switched off for a stream. This targets a specific stutter that comes from a sleeping monitor being woken and asked to identify itself over and over. It is off by default, and the switch only appears if your machine has the hardware it applies to. + +## Improved + +- **Streaming stopped asking your graphics card for emergency priority** — and everything got smoother. Two places asked for it, and a long field investigation into a stutter nobody could explain convicted both. The stall it produced was regular, arriving every few seconds, and turned out to be the graphics scheduler reacting to a demand it could not honour — not anything Punktfunk was doing on a timer. Neither request is made now, which is also what every comparable product does. Nothing to switch on, and the frame pacing this was supposed to protect is unaffected. +- **The connection recovers from a lost frame without freezing.** After a dropped frame the picture would freeze and stay frozen far longer than it needed to, most visibly on machines with an AMD graphics card and only when streaming in H.265 — the same loss was being counted twice, so a picture that had already repaired itself was frozen again by the stale second count. Streams recover as soon as they are actually whole. +- **The Android console got a proper going-over** — the real desktop backdrop behind it, an interface that stops shifting under your cursor, a controller route for the statistics overlay, and tables that no longer drift out of step with the other clients without saying so. +- **"Update all" for your add-ons.** The plugins page could only update one at a time, one dialog each. There is now a single button beside the list, and a count on the tab so you can see there is something to update without going looking. They install one after another, because the host only accepts one at a time. +- **The microphone button has left the stream overlay on Android.** It sat in the corner of every stream that used your mic. Muting is unchanged — the Select + Y button chord still does it, and the on-screen hint for that chord is now the only reminder. A proper on-screen control set is being built, and mute will come back as part of it. +- **A statistics setting chosen between two streams now takes effect.** It used to be read once at the start of a stream, so a change made in between did nothing until the app was restarted. +- **Connections are protected against future code-breaking computers.** Every encrypted connection now offers a post-quantum key exchange first, falling back to the classical one for anything that does not understand it. Nothing to configure, and nothing about pairing or your existing setup changes. +- **A stream no longer refuses the fast decoder over a mislabelled quality setting.** Some encoders claim a higher level than they use; that claim is now trimmed rather than treated as a reason to fall back to slower decoding. + +## Fixed + +- **A Windows host stopped the computer from ever sleeping.** Installing it was enough — connected or not, streaming or not. The microphone it provides to games was left running around the clock writing silence, and Windows treats any running audio stream as a reason to keep the machine awake. It now stops when nothing is speaking and starts again the instant something is, fast enough that you will not hear the difference. Streams still keep the machine awake while they are running, as they should. +- **A Windows host could ruin the frame rate of a game running on the same machine.** One report measured a game's worst frames at 2–5 per second and cured it by uninstalling Punktfunk. Two separate faults: on a machine where the audio devices could not be created properly, the host retried the full creation every minute forever, and each attempt made every game on the machine rebuild its audio; and the first stream ever run permanently raised the host's own priority above everything else, because the settings meant to last for one stream were never put back. Both are fixed, and the priority now goes back down when the stream ends. +- **Streams tore with pink, blocky corruption at high frame rates** on gamescope machines. The picture was being handed back to the compositor to be drawn over while it was still being read for sending, so parts of two different frames arrived in one. Reported at 120 frames per second; ordinary desktop sessions were never affected, which is why it took so long to place. +- **Game Mode on Bazzite mirrored the machine's own screen** instead of giving the client its own display, and a game launched from a client's library could not get a session of its own. The configuration file we ship — and tell people to copy exactly — forced a setting that overrode all of that. +- **Games ran on a black screen in Nobara's Game Mode.** The fix that shipped last release was being undone by the system's own start-up script moments after it was applied, so sound and controls worked over a black picture while the log said the problem had been handled. +- **A crash-looping overlay could turn the whole stream black** while every health indicator stayed green, on Bazzite handhelds. An invisible window was winning the contest for what gets shown, and the stream faithfully sent that. +- **Streaming a desktop still did not work on NixOS with KDE.** Last release's fix depended on reading something the system refuses to reveal about a program holding a special permission — and on NixOS the desktop itself holds one. The host now falls back to a way of asking that works, and the same fix covers other systems where the compositor holds that permission. +- **One slow start could downgrade a host until it was restarted.** The first attempt to get a picture is deliberately given a short deadline, but when that deadline passed the host concluded the machine had *refused* whatever it had asked for — high dynamic range, or the fast capture path — and stopped asking for the rest of its life. A gamescope machine starting up cold routinely needs longer than that deadline, so one lost race at connect pinned every later session to the slower, dimmer path. +- **Steam gained a duplicate Punktfunk entry on every restart** on Steam Deck, accumulating without limit. The plugin also stopped announcing itself with a pop-up every single launch. +- **Removing a paired device now actually removes it.** It was not being saved, so the device reappeared at the next restart, and a device that was already connected kept streaming until it chose to leave. Removing a pairing now ends that device's live session too. +- **Connecting a second device could kill the desktop on a Linux host running GNOME.** Connecting one device, leaving, then connecting a different one at a different screen size crashed the desktop outright — and every stream afterwards failed with a black picture until someone signed back in at the machine. Two pieces of the host's own display handling could overlap the desktop's rebuild of its screen list; they are now made to wait for each other. This was never new — it behaved exactly the same on the previous release — but it is fixed now. +- **Streaming a 4K screen at 60 Hz on a KDE host gave you a 1080p picture.** The host asked for the full size, KDE built something smaller, and nothing checked — while the log confidently printed the size that had been *asked for*, which is why the first look at this went the wrong way. The real size is now read back and reported. +- **Audio on an iPad broke up where a Mac was fine.** The rule that decides when to give up on a stalled audio buffer was counting audio deliveries rather than time, and an iPad asks for much shorter ones — so the same setting gave an iPad about 20 ms of slack where a Mac got 44 ms, on the device with the burstiest connection. It is measured in time now. On a simulated connection with a 100 ms hiccup every 5 seconds, ten minutes went from 120 audible gaps to 2. +- **Android was unscrambling every stream the slow way.** Phones and Android TV boxes have dedicated hardware for the encryption Punktfunk uses, and the app was supposed to use it — but the setting that switched it on was being wiped out by the Android build tooling before the app was ever compiled, on every build we have ever shipped. Every packet was unscrambled in software instead, which costs processor time and battery for nothing. The app now detects the hardware at runtime, so there is no setting left to lose. +- **The published interface documentation described a version of the host nobody was running.** It had drifted several releases behind, so five newer parts of the host's interface were missing from it entirely. + +## For developers + +Protocol, ABI, driver and add-on detail — the version table, the new environment variables, the build-time changes and what did *not* move — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/v0.28.0/CHANGELOG.md). + +The short version: **nothing breaks on the wire.** The wire protocol stays at 2, so every pairing and every shipped client keeps working. The C ABI moves 18 → 19 by *addition* — two new entry points for the loss-recovery fix above — and both existing entry points keep their signatures and their behaviour, so an embedder that ignores the new symbols behaves exactly as it does today. + +The new host identity is **ECDSA P-256**, not Ed25519 — deliberately: no mainstream browser accepts an Ed25519 server certificate, and an operator opens `/api/docs` in one. The Moonlight-compat plane keeps its RSA identity **permanently**, because Moonlight mandates RSA and its pairing hashes bind the certificate's signature bytes; only the native plane's legacy fallback is dated, and it is removed on **1 October 2026**. + +The one genuinely breaking change is on the host: the six built-in library scanners are **deleted**, and the library is now assembled entirely by plugins. There is deliberately no migration — a plugin claims its store and re-publishes each title under the same identifier the scanner used, so entry ids, app ids, artwork caches, pins, per-source toggles and per-entry hides all keep working untouched. The one visible consequence is an empty grid on a host with no library plugins installed. + +Three things change how you **build** rather than how anything runs: the tree moves to Rust edition 2024 with a declared minimum of 1.85, building from source now needs a C compiler, and the Moonlight-compatible half is a compile-time feature as well as a runtime switch — building without it produces a host with the entire legacy protocol stack, and its transpiled C networking code, absent from the binary. + +If you package Punktfunk: the Moonlight default flip and the Bazzite configuration template are the two changes your users will notice. Neither needs a packaging change. diff --git a/docs/releases/whatsnew/v0.28.0.txt b/docs/releases/whatsnew/v0.28.0.txt new file mode 100644 index 00000000..e4b30334 --- /dev/null +++ b/docs/releases/whatsnew/v0.28.0.txt @@ -0,0 +1,4 @@ +• Streams now use your device's built-in encryption hardware, instead of falling back to software as every previous build did. +• The mic button has left the stream overlay. Muting is unchanged — the Select + Y chord still does it. +• The console gets a proper going-over: the host's real desktop behind it, and an interface that stops shifting under your cursor. +• A dropped frame no longer leaves the picture frozen longer than it needs to. diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index ff123533..00858c79 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -94,7 +94,18 @@ // Additive and client-local: the v3 tail has been on the wire (and length-tolerant in both // decoders) since it landed, and the host sends the same bytes either way, so [`WIRE_VERSION`] is // unchanged. -#define PUNKTFUNK_ABI_VERSION 18 +// v19: added `punktfunk_connection_note_frame_index_ex` and +// `punktfunk_reanchor_gate_arm_expecting_drops` — the width-carrying half of the reanchor gate. +// `note_frame_index_ex` reports how MANY frames an arrival revealed as missing where +// `punktfunk_connection_note_frame_index` reports only whether any were; passing that width to +// `arm_expecting_drops` pre-credits the reassembler's `frames_dropped` climb that the same loss +// produces up to ~120 ms later, so the gate does not read one loss as two and re-freeze a stream a +// fast LTR-RFI anchor has already healed. NEW symbols, not widened ones — the same rule v18 states: +// both originals keep their signatures and their behaviour, so an embedder that never adopts either +// is unchanged (it simply keeps the double-arm race the pair exists to close). Additive and +// client-local: nothing new goes on the wire — the width is computed from frame indices the client +// already receives — so [`WIRE_VERSION`] is unchanged. +#define PUNKTFUNK_ABI_VERSION 19 // The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. // Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** -- 2.54.0