Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8216f1d92d | ||
|
|
1677d1c0c2 | ||
|
|
3f738a9989 | ||
|
|
7df321f459 | ||
|
|
c7d0fd2e03 | ||
|
|
9c13335089 | ||
|
|
92db66514b | ||
|
|
86cbbea020 | ||
|
|
3d5d8e2d76 | ||
|
|
8f6eb1494d | ||
|
|
362d532d25 | ||
|
|
b2a9b281f0 | ||
|
|
e568513f74 | ||
|
|
7403450a8d | ||
|
|
0a53457cb7 | ||
|
|
e7af5a5274 |
+568
-2
@@ -12,7 +12,109 @@ 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)
|
||||
|
||||
The host no longer scans any launcher itself. `library/{steam,epic,gog,heroic,lutris,xbox}.rs` and
|
||||
the `scanner_defs()` table are deleted; `GET /library/scanners` now lists exactly what the operator
|
||||
has installed, and every row reports `origin: "plugin"`. This is M6/WP6.4, the end of the migration
|
||||
whose bridge half shipped in v0.26.0 — the plugins have been published and index-pinned since
|
||||
2026-08-08.
|
||||
|
||||
**A host with no library plugins installed has an empty grid.** That is the upgrade note: the
|
||||
console's Library page offers one-click install per source (the D9 nudge, still there and still
|
||||
never auto-installing), and nothing about a title changes when its plugin takes over.
|
||||
|
||||
Why that last part is true, and why this was safe to do as a deletion rather than a rewrite: a
|
||||
plugin **claims** its store (D2), and a claimed entry surfaces under the deterministic
|
||||
`<store>:<external_id>` id the scanner used to produce. Entry ids, GameStream FNV-1a app ids,
|
||||
client-side art caches, Moonlight pins, the operator's per-source toggles and their per-entry hides
|
||||
are all keyed on that id and none of them move. `library-scanners.json` keeps its name, its shape
|
||||
and its contents — an operator who had `steam` switched off still has it switched off, with no
|
||||
migration step.
|
||||
|
||||
What survives the scanners, deliberately:
|
||||
|
||||
- **`launch.rs` in full.** Launch is host-owned by design D1 — a plugin publishes a validated
|
||||
*value* and the host builds the command — so every typed kind (`steam_appid`, `steam_ui`,
|
||||
`launcher_ui`, `epic`, `gog`, `aumid`, `xbox`, `lutris_id`, `playnite`) stays exactly as it was.
|
||||
`xbox_pfn()` moved here from the deleted `xbox.rs`: resolving a package Identity to its
|
||||
PackageFamilyName needs `AppRepository` enumeration, which is readable by the host (LocalSystem)
|
||||
and denied to the plugin runner (LocalService), and that measured asymmetry is the entire reason
|
||||
the `xbox` launch kind exists.
|
||||
- **`SourceOrigin::Builtin`.** No host build emits it, but the web console ships as its own package
|
||||
and is expected to drive an N-1 host that still does, so the variant stays in the schema and the
|
||||
console keeps its `builtin` handling.
|
||||
- **The store-label table.** Six ids keep their display names (`steam` → "Steam", …) so a source row
|
||||
does not rename itself to a bare id the day its plugin takes over.
|
||||
|
||||
Removed with them: the background cover-art warmer and its on-disk cache (they existed only for the
|
||||
GOG and Xbox scanners, 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 the
|
||||
`GameMeta::pc()` helper. **The host now makes no outbound HTTP request to build a library at all.**
|
||||
|
||||
⚠ **Dependency drop (packager-visible):** `rusqlite` (with its bundled, `cc`-compiled SQLite) and
|
||||
`roxmltree` leave the host's dependency graph — they had no other users. `winreg` stays: `launch.rs`,
|
||||
`procscan/windows.rs` and the two `audio/windows/` modules still need it. `base64`/`ureq` stay, as
|
||||
the M6 plan predicted.
|
||||
|
||||
A stale `library-art-cache.json` from an older host is ignored, not migrated.
|
||||
|
||||
### GameStream is now opt-in on EVERY route (⚠ packager-visible default change)
|
||||
|
||||
@@ -120,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.
|
||||
|
||||
@@ -137,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=<config>/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
|
||||
@@ -297,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 `<store>:<external_id>` id the scanner used to produce. Entry ids, GameStream FNV-1a
|
||||
app ids, client art caches, Moonlight pins, the per-source toggles and the per-entry hides all key
|
||||
on that id and **none of them move**. `library-scanners.json` keeps its name, shape and contents —
|
||||
an operator who had `steam` off still has it off.
|
||||
|
||||
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/<i>/phy80211`) is now asked `iw phy <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.
|
||||
|
||||
Generated
+37
-129
@@ -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)",
|
||||
@@ -1353,18 +1353,6 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastbloom"
|
||||
version = "0.17.0"
|
||||
@@ -1999,32 +1987,11 @@ dependencies = [
|
||||
"zerocopy 0.8.56",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@@ -2195,7 +2162,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.1",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
@@ -2376,7 +2343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2477,20 +2444,9 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.38.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2519,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -3011,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-bitstream"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"tracing",
|
||||
@@ -3019,7 +2975,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3040,7 +2996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3075,7 +3031,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3093,7 +3049,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3115,7 +3071,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-dxvadec"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3125,7 +3081,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3151,7 +3107,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -3163,7 +3119,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -3177,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",
|
||||
@@ -3210,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",
|
||||
@@ -3232,7 +3188,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3240,7 +3196,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-lc-rs",
|
||||
@@ -3252,7 +3208,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3261,7 +3217,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3294,7 +3250,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
@@ -3305,7 +3261,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"pf-paths",
|
||||
"punktfunk-core",
|
||||
@@ -3316,7 +3272,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3528,7 +3484,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3538,7 +3494,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3556,7 +3512,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3573,7 +3529,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"pf-console-ui",
|
||||
@@ -3587,7 +3543,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"mdns-sd",
|
||||
@@ -3605,7 +3561,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"cbindgen",
|
||||
@@ -3637,7 +3593,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-encode-worker"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"pf-encode",
|
||||
"tracing",
|
||||
@@ -3646,7 +3602,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3688,9 +3644,7 @@ dependencies = [
|
||||
"quinn",
|
||||
"rand 0.9.5",
|
||||
"rcgen",
|
||||
"roxmltree",
|
||||
"rsa",
|
||||
"rusqlite",
|
||||
"rustls",
|
||||
"rusty_enet",
|
||||
"semver",
|
||||
@@ -3718,7 +3672,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3732,7 +3686,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3755,7 +3709,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -4137,15 +4091,6 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpkg-config"
|
||||
version = "0.1.2"
|
||||
@@ -4173,31 +4118,6 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsqlite-vfs"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.40.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
"sqlite-wasm-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
@@ -4769,18 +4689,6 @@ dependencies = [
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-wasm-rs"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"js-sys",
|
||||
"rsqlite-vfs",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+11
-11
@@ -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).\n\n**The operator's own lane additionally sees the titles they have HIDDEN**, each carrying\n`hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The\nconsole needs them to offer \"un-hide\", and it is the only surface that does.",
|
||||
"description": "Every title this host knows about, sorted by title: the entries each installed library plugin\nhas synced (Steam, Lutris, Heroic, Epic, GOG, Xbox, Playnite, ROM managers, …) plus the user's\nown custom entries. Artwork fields are URLs the 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": [
|
||||
{
|
||||
@@ -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. Any id stored in the host's catalog (manual entries, provider-synced entries,\nand a library plugin's claimed-store entries) serves its local art file. A Steam title falls back\nto the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the\nuser's Steam client already shows for it), the public Steam CDN's flat URL convention second\n(newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case\nthis 404s and the client falls through to its next art candidate).",
|
||||
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,\nand a library plugin's claimed-store entries) serves its local art file; anything else 404s and\nthe client falls through to its next art candidate.\n\nThe host fetches nothing here. Art a plugin published as an `http(s)` URL is fetched by the\nclient directly — this proxy exists for the *local* files a plugin finds on the host's own disk\n(a launcher's cover cache), which a client has no way to read.",
|
||||
"operationId": "getLibraryArt",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1380,7 +1380,7 @@
|
||||
"library"
|
||||
],
|
||||
"summary": "Replace a provider's library entries (declarative reconcile)",
|
||||
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.\n\n`?store=` additionally **claims** that store for the provider: its entries then surface with\ndeterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque\n`custom:<id>` ones — which is what lets a library plugin reproduce the entries an in-host scanner\nused to produce, right down to the GameStream app ids and client-side art caches. One provider\nper store; a second claimant gets 409. While a claim is held the matching built-in scanner is\nsuppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty\nreconcile (a store can legitimately have zero installed titles).",
|
||||
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.\n\n`?store=` additionally **claims** that store for the provider: its entries then surface with\ndeterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque\n`custom:<id>` ones — which is what let a library plugin reproduce the entries the in-host scanner\nused to produce, right down to the GameStream app ids and client-side art caches, and is why\nremoving those scanners changed nothing downstream. One provider per store; a second claimant\ngets 409. The claim is released by `DELETE`, not by an empty reconcile (a store can legitimately\nhave zero installed titles).",
|
||||
"operationId": "reconcileProviderEntries",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1538,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": {
|
||||
@@ -1573,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": [
|
||||
{
|
||||
@@ -5397,7 +5397,7 @@
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it 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",
|
||||
@@ -7143,7 +7143,7 @@
|
||||
},
|
||||
"origin": {
|
||||
"$ref": "#/components/schemas/SourceOrigin",
|
||||
"description": "Where the source comes from: `builtin` (a scanner in this host build) or `plugin`."
|
||||
"description": "Where the source comes from. Always `plugin` from this host build onward — see\n[`SourceOrigin`]."
|
||||
},
|
||||
"provider": {
|
||||
"type": [
|
||||
@@ -7163,7 +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."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7958,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",
|
||||
|
||||
@@ -32,20 +32,12 @@ import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.MicOff
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -57,7 +49,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -130,12 +121,12 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// it, and survives the same recreate because the composition outlives the surface.
|
||||
var micMuted by remember(handle) { mutableStateOf(false) }
|
||||
// Whether a capture is actually RUNNING, not merely wanted — set from surfaceCreated on what
|
||||
// nativeMicActive reports. A device that refused every AAudio input rung gets no mute control
|
||||
// rather than one that lies about a mic being heard.
|
||||
// nativeMicActive reports. A device that refused every AAudio input rung gets no mute chord and
|
||||
// no chord line in the start banner, rather than an offer to mute a mic nobody is hearing.
|
||||
var micRunning by remember(handle) { mutableStateOf(false) }
|
||||
// Transient confirmation of a mic-chord toggle (null = nothing showing). Only the gamepad path
|
||||
// needs it: the touch button confirms itself by changing under the finger, but a chord has no
|
||||
// on-screen state of its own, and "did that register?" is exactly the doubt to answer.
|
||||
// Transient confirmation of a mic-chord toggle (null = nothing showing). With no standing mic
|
||||
// element on screen, this is mute's only feedback: a chord has no on-screen state of its own,
|
||||
// and "did that register?" is exactly the doubt to answer.
|
||||
var micHint by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(micHint) {
|
||||
if (micHint != null) {
|
||||
@@ -413,9 +404,9 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// Show a "hold to quit" hint the moment the chord completes (the router debounces the actual
|
||||
// exit); it clears when the buttons release early or the hold elapses. Runs on the main thread.
|
||||
router.onExitArmed = { armed -> exitArming = armed }
|
||||
// Select + Y toggles the mic — the couch reach for the on-screen mute button, which a
|
||||
// gamepad/TV user has no pointer for. Ignored when no capture is running (there is nothing
|
||||
// to mute, and claiming otherwise would be the lie the control exists to avoid).
|
||||
// Select + Y toggles the mic — with no on-screen mute element, this chord is the whole of
|
||||
// the control. Ignored when no capture is running (there is nothing to mute, and a hint
|
||||
// saying "Microphone muted" over a mic nobody opened would be a lie).
|
||||
// A captured Sony pad whose motion this session cannot carry. Fires once per pad, at the
|
||||
// moment it is claimed, on the main thread.
|
||||
router.onMotionUnreachable = { motionHint = true }
|
||||
@@ -981,19 +972,12 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
}
|
||||
},
|
||||
)
|
||||
// Mic mute, LAST in the stack — the one in-stream control, so unlike the purely visual
|
||||
// overlays above it has to sit on top of the gesture layer to receive its own taps (it
|
||||
// costs the stream that small corner of touch area, which is why it exists only while a
|
||||
// capture actually runs). On TV it is the indicator alone: the Select + Y chord is the
|
||||
// control there, and a focusable button would fight the game for the D-pad.
|
||||
if (micRunning && (micMuted || !isTv)) {
|
||||
MicMuteControl(
|
||||
muted = micMuted,
|
||||
onToggle = if (isTv) null else ({ setMicMuted(!micMuted) }),
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(12.dp),
|
||||
)
|
||||
}
|
||||
// Chord confirmation (gamepad/TV) — the counterpart to the button changing under a finger.
|
||||
// No standing mic element here: the in-stream mute control is deliberately absent until the
|
||||
// on-screen overlay UI lands and can carry it as one of its controls. Mute itself is intact
|
||||
// — the Select + Y chord toggles it, and the hint below is what confirms the toggle.
|
||||
// Chord confirmation (gamepad/TV) — mute has no standing indicator, so this is the whole
|
||||
// of its feedback: a toggle that showed nothing at all would be indistinguishable from one
|
||||
// that never registered.
|
||||
micHint?.let { MicChordHint(it, Modifier.align(Alignment.TopCenter).padding(top = 16.dp)) }
|
||||
// Bottom, not top: this can coincide with a mic-chord confirmation or the exit cue, and a
|
||||
// notice landing on top of one of those would cost the user both.
|
||||
@@ -1030,47 +1014,8 @@ private fun releaseMicEffects(effects: MutableList<AudioEffect>) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The in-stream mic control and its muted indicator, in one element: a dim mic glyph while the
|
||||
* uplink is live, a red **Muted** badge while it isn't — so the state that matters is the loud one,
|
||||
* readable at couch distance and impossible to mistake for the stream's own picture.
|
||||
*
|
||||
* [onToggle] `null` makes it a pure indicator (the TV/gamepad surface, where the Select + Y chord
|
||||
* is the control); non-null makes the badge itself the touch target. Rendering it at all is the
|
||||
* caller's decision — it means a capture is genuinely running.
|
||||
*/
|
||||
@Composable
|
||||
private fun MicMuteControl(muted: Boolean, onToggle: (() -> Unit)?, modifier: Modifier = Modifier) {
|
||||
val shape = RoundedCornerShape(10.dp)
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(if (muted) Color(0xE0B3261E) else Color.Black.copy(alpha = 0.45f))
|
||||
.then(if (onToggle != null) Modifier.clickable(onClick = onToggle) else Modifier)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (muted) Icons.Filled.MicOff else Icons.Filled.Mic,
|
||||
// Spoken state first, then the action — a talkback user needs to know they are muted
|
||||
// before they need to know how to stop being muted.
|
||||
contentDescription = if (muted) {
|
||||
"Microphone muted. Activate to unmute."
|
||||
} else {
|
||||
"Microphone live. Activate to mute."
|
||||
},
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
if (muted) {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Muted", color = Color.White, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transient confirmation that the mic chord (Select + Y) registered. The badge above already says
|
||||
* *muted*, but nothing on screen says *un*muted — and "did that press do anything?" is the whole
|
||||
* Transient confirmation that the mic chord (Select + Y) registered. Nothing else on screen says
|
||||
* *muted* or *un*muted, so this pill carries both — "did that press do anything?" is the whole
|
||||
* doubt a chord with no button under the finger creates. Same pill vocabulary as the other
|
||||
* in-stream cues; the caller clears it after a beat.
|
||||
*/
|
||||
|
||||
@@ -39,7 +39,18 @@ final class AudioRing: @unchecked Sendable {
|
||||
private static let maxTargetMS = 70
|
||||
private static let headroomMS = 30
|
||||
private static let hardCapMS = 90
|
||||
private static let deprimeAfter = 4
|
||||
/// How long the ring may run short before it goes back to priming, in MILLISECONDS of
|
||||
/// starvation — not a count of callbacks. As a count (it was 4) the hysteresis meant a
|
||||
/// different span of time on every device, because a callback is not a unit of time: 4 of them
|
||||
/// is ~44 ms on a Mac's ~11 ms quantum and **20 ms on iOS**, whose session asks for a short IO
|
||||
/// buffer. A Wi-Fi delivery stall therefore de-primed this ring on every bunching cycle where
|
||||
/// the same policy rode it out elsewhere — measured on the shared Rust policy at 120 audible
|
||||
/// gaps per 10 minutes at a 5 ms quantum, against 3 at 8 ms and 1 at 16 ms on an identical
|
||||
/// link. Mirrors `JitterTuning::COREAUDIO.deprime_ms`.
|
||||
private static let deprimeMS = 60
|
||||
/// Floor in callbacks under `deprimeMS`, so a large-quantum device keeps real hysteresis
|
||||
/// instead of de-priming on the first short read. Mirrors `MIN_DEPRIME_CALLBACKS`.
|
||||
private static let minDeprimeCallbacks = 2
|
||||
/// The protocol's frame: the shed unit, and the slack added over a large device quantum.
|
||||
private static let frameMS = 5
|
||||
/// Depth average must exceed target by this before drift correction fires — the middle of the
|
||||
@@ -93,7 +104,12 @@ final class AudioRing: @unchecked Sendable {
|
||||
private var writeIdx = 0
|
||||
private var primed = false
|
||||
private var renderQuantum = 0
|
||||
/// Consecutive short reads, and the audio they starved for in interleaved samples. BOTH gate
|
||||
/// the de-prime (see `deprimeMS`): the run must be at least that long AND at least
|
||||
/// `minDeprimeCallbacks` callbacks, so the fuse is the same span of time whatever the device's
|
||||
/// quantum without collapsing to a hair trigger on a large-quantum device.
|
||||
private var emptyReads = 0
|
||||
private var emptyRun = 0
|
||||
private var depthAvg: Double = 0
|
||||
private var overRun = 0
|
||||
/// The live target in interleaved samples — `targetMS` grown by underrun pressure
|
||||
@@ -240,8 +256,10 @@ final class AudioRing: @unchecked Sendable {
|
||||
min(target + Self.headroomMS * perMS, Self.hardCapMS * perMS),
|
||||
target + renderQuantum)
|
||||
if writeIdx - readIdx > cap {
|
||||
readIdx = writeIdx - cap
|
||||
depthAvg = Double(cap)
|
||||
// Crossfaded, like the smooth shed — see `dropFront`. This is the correction a
|
||||
// bunching link actually pays, so it is the one that most needs not to click.
|
||||
dropFront(writeIdx - readIdx - cap)
|
||||
depthAvg = Double(writeIdx - readIdx)
|
||||
overRun = 0
|
||||
}
|
||||
}
|
||||
@@ -262,6 +280,7 @@ final class AudioRing: @unchecked Sendable {
|
||||
if available >= target {
|
||||
primed = true
|
||||
emptyReads = 0
|
||||
emptyRun = 0
|
||||
// The refill just banked this much: seed the average with it rather than letting
|
||||
// it climb from wherever the drought left it — a freshly-primed ring would
|
||||
// otherwise read as hollow for the EWMA's whole settling time, and the FIRST
|
||||
@@ -348,15 +367,23 @@ final class AudioRing: @unchecked Sendable {
|
||||
if ranShort {
|
||||
quietRun = 0
|
||||
emptyReads += 1
|
||||
emptyRun += count
|
||||
underrunCount += 1
|
||||
if emptyReads >= Self.deprimeAfter || hollow {
|
||||
// The consecutive-empties hysteresis protects a FULL ring from one late packet.
|
||||
// Starved for `deprimeMS` of audio, over at least `minDeprimeCallbacks` callbacks.
|
||||
// Both, because either alone is wrong at one end of the quantum range: time alone is a
|
||||
// hair trigger on a device whose single quantum already exceeds the window, and a
|
||||
// callback count alone is the device-dependent fuse this replaced.
|
||||
let starved = emptyRun >= Self.deprimeMS * perMS
|
||||
&& emptyReads >= Self.minDeprimeCallbacks
|
||||
if starved || hollow {
|
||||
// The starvation hysteresis protects a FULL ring from one late packet.
|
||||
// A hollow ring is the opposite case: the target has been raised but the depth
|
||||
// never re-banked (growth is a promise; only a re-prime cashes it), and riding
|
||||
// that out is a click per bunching period, forever. The click just heard has
|
||||
// already paid for the refill — take it now.
|
||||
primed = false
|
||||
emptyReads = 0
|
||||
emptyRun = 0
|
||||
}
|
||||
if !restored {
|
||||
underrunsInWindow += 1
|
||||
@@ -375,12 +402,14 @@ final class AudioRing: @unchecked Sendable {
|
||||
// the path above takes over. A near-miss is pressure, not quiet.
|
||||
quietRun = 0
|
||||
emptyReads = 0
|
||||
emptyRun = 0
|
||||
if !nearMissGrown, !restored {
|
||||
nearMissGrown = true
|
||||
targetLive = min(targetLive + Self.growStepMS * perMS, Self.maxTargetMS * perMS)
|
||||
}
|
||||
} else {
|
||||
emptyReads = 0
|
||||
emptyRun = 0
|
||||
quietRun += count
|
||||
// Without a sync request, time is the only evidence that hard-won slack is no longer
|
||||
// needed, so a grown target waits out the long window. A request for less IS evidence,
|
||||
@@ -402,13 +431,21 @@ final class AudioRing: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop one protocol frame from the front, linearly crossfading the seam so the correction is
|
||||
/// inaudible rather than a click. Mirrors `punktfunk_core::audio::crossfade_drop`; caller holds
|
||||
/// the lock.
|
||||
private func shedOneFrame() {
|
||||
let drop = Self.frameMS * perMS
|
||||
/// Drop one protocol frame from the front — the smooth drift correction.
|
||||
private func shedOneFrame() { dropFront(Self.frameMS * perMS) }
|
||||
|
||||
/// Drop `drop` interleaved samples from the front, linearly crossfading the seam so the
|
||||
/// correction is inaudible rather than a click. Mirrors `punktfunk_core::audio::crossfade_drop`;
|
||||
/// caller holds the lock.
|
||||
///
|
||||
/// Used by BOTH corrections. The hard-cap trim in `write` used to splice raw, on the reasoning
|
||||
/// that a ring which blew its ceiling is already a discontinuity — but that describes the
|
||||
/// ARRIVALS, not the samples either side of the seam, which are ordinary continuous audio. It
|
||||
/// is also the drop that actually fires here: a bunching Wi-Fi link trims far more often than
|
||||
/// drift sheds, so the one path left unfaded was the audible one.
|
||||
private func dropFront(_ drop: Int) {
|
||||
let available = writeIdx - readIdx
|
||||
guard available > drop else { return }
|
||||
guard drop > 0, available > drop else { return }
|
||||
let fade = min(Self.crossfadeMS * perMS, min(drop, available - drop))
|
||||
let capacity = buf.count
|
||||
if fade > 0 {
|
||||
|
||||
@@ -234,11 +234,23 @@ public final class SessionAudio {
|
||||
try session.setCategory(
|
||||
.playAndRecord, mode: .default,
|
||||
options: [.allowBluetoothA2DP, .mixWithOthers])
|
||||
// Uplink latency: ask for 5 ms IO quanta at the wire rate (the default ~10-23 ms
|
||||
// Uplink latency: ask for 10 ms IO quanta at the wire rate (the default ~23 ms
|
||||
// quantum is most of the mic path's burst latency). Best-effort — the hardware
|
||||
// has the final word (a Bluetooth route will ignore both), and whatever quantum
|
||||
// is actually granted, the capture tap handles the buffers it gets.
|
||||
try? session.setPreferredIOBufferDuration(0.005)
|
||||
//
|
||||
// 10 ms, NOT the 5 ms this used to ask for. The IO buffer duration is a property
|
||||
// of the whole IO unit, so a shorter quantum is not free to the PLAYBACK side —
|
||||
// and it bought the uplink nothing, because the encoder frames at 10 ms
|
||||
// (`installMicTap` installs with `bufferSize: 480` and `OpusEncoder` consumes
|
||||
// whole `framesPerPacket` chunks): at a 5 ms quantum the tap simply fired twice
|
||||
// per packet, for the same packet latency. What it did buy was a halved deadline
|
||||
// for the render callback and — because the de-prime fuse used to be a callback
|
||||
// COUNT — half the starvation hysteresis in the jitter ring, on the one platform
|
||||
// whose transport bunches hardest. Both ends of that are fixed now (`AudioRing`
|
||||
// measures the fuse in ms), but there is still no reason to ask for a quantum
|
||||
// finer than the packets we send.
|
||||
try? session.setPreferredIOBufferDuration(0.010)
|
||||
try? session.setPreferredSampleRate(48_000)
|
||||
} else {
|
||||
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
|
||||
@@ -247,6 +259,16 @@ public final class SessionAudio {
|
||||
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
|
||||
#endif
|
||||
try session.setActive(true)
|
||||
// What we were actually GRANTED, not what we asked for. Both are best-effort, and the
|
||||
// ring's behaviour depends on the quantum it really gets — without this, a report of
|
||||
// audio jitter arrives with no way to tell a 10 ms session from a 5 ms or a 23 ms one,
|
||||
// which is exactly the gap that made the last round of this take a simulation to close.
|
||||
log.info("""
|
||||
AVAudioSession active: io_buffer_ms=\
|
||||
\(session.ioBufferDuration * 1000, format: .fixed(precision: 2)) \
|
||||
sample_rate=\(Int(session.sampleRate)) \
|
||||
route=\(session.currentRoute.outputs.first?.portType.rawValue ?? "none")
|
||||
""")
|
||||
#if os(iOS)
|
||||
// Only the `.playAndRecord` session can land on the earpiece, and only it accepts an
|
||||
// output override — so the mic-off (`.playback`) path deliberately does neither.
|
||||
|
||||
@@ -106,6 +106,77 @@ final class AudioRingDriftTests: XCTestCase {
|
||||
"a single short read must not force a full re-prime")
|
||||
}
|
||||
|
||||
/// THE regression that made an iPad crackle where a Mac did not: the de-prime fuse must be the
|
||||
/// same SPAN OF TIME whatever the device's IO quantum. It used to be a callback COUNT (4), and
|
||||
/// a callback is not a unit of time — the same 4 was ~44 ms on a Mac's ~11 ms quantum and 20 ms
|
||||
/// on iOS, whose session asked for a 5 ms IO buffer. A Wi-Fi delivery stall therefore de-primed
|
||||
/// this ring on every bunching cycle where the identical policy rode it out elsewhere (measured
|
||||
/// on the shared Rust policy: 120 audible gaps per 10 min at a 5 ms quantum against 3 at 8 ms).
|
||||
/// Plant the defect by restoring a fixed count and the quanta below stop agreeing.
|
||||
///
|
||||
/// Mirrors `deprime_fuse_is_a_duration_not_a_callback_count` in `punktfunk_core::audio`.
|
||||
func testDeprimeFuseIsADurationNotACallbackCount() {
|
||||
let deprimeMS = 60 // AudioRing.deprimeMS / JitterTuning::COREAUDIO.deprime_ms
|
||||
let quanta = [5, 8, 10, 16, 21]
|
||||
var deprimedAt: [Int: Int] = [:]
|
||||
for quantumMS in quanta {
|
||||
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
let want = quantumMS * perMS
|
||||
var scratch = [Float](repeating: 0, count: want)
|
||||
// Prime DEEP: the depth average is seeded with the refill, so `hollow` stays false for
|
||||
// the EWMA's whole settling second and the starvation fuse — not the hollow shortcut —
|
||||
// is what this measures.
|
||||
let big = [Float](repeating: 0.5, count: 80 * perMS)
|
||||
big.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: big.count) }
|
||||
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
|
||||
XCTAssertTrue(
|
||||
scratch.contains { $0 != 0 }, "q=\(quantumMS)ms: must play after priming")
|
||||
|
||||
// Starve on a trickle far under what the device takes: every read runs short but still
|
||||
// carries audio, so an all-zero read can only mean the ring gave up and re-primed.
|
||||
let trickle = [Float](repeating: 0.5, count: max(perMS, want / 4))
|
||||
var starvedMS = 0
|
||||
var deprimedAfterMS: Int?
|
||||
for _ in 0..<2_000 {
|
||||
trickle.withUnsafeBufferPointer {
|
||||
ring.write($0.baseAddress!, count: trickle.count)
|
||||
}
|
||||
let short = ring.bufferedSamples < want
|
||||
scratch.withUnsafeMutableBufferPointer {
|
||||
ring.read(into: $0.baseAddress!, count: want)
|
||||
}
|
||||
if scratch.allSatisfy({ $0 == 0 }) {
|
||||
deprimedAfterMS = starvedMS
|
||||
break
|
||||
}
|
||||
if short { starvedMS += quantumMS }
|
||||
}
|
||||
guard let deprimedAfterMS else {
|
||||
return XCTFail("q=\(quantumMS)ms: never de-primed at all")
|
||||
}
|
||||
deprimedAt[quantumMS] = deprimedAfterMS
|
||||
}
|
||||
|
||||
// Each quantum must give up somewhere around the fuse. The band is wide on purpose: at a
|
||||
// short quantum the HOLLOW shortcut legitimately fires a little before the fuse does (the
|
||||
// target has grown, the depth was never re-banked, so the click is taken early and spent
|
||||
// on a full refill — see `deprimeDebtMS`), and that is the policy working, not drift.
|
||||
for (q, ms) in deprimedAt.sorted(by: { $0.key < $1.key }) {
|
||||
XCTAssertTrue(
|
||||
(deprimeMS - 20...deprimeMS + 25).contains(ms),
|
||||
"q=\(q)ms de-primed after \(ms) ms, nowhere near the \(deprimeMS) ms fuse — "
|
||||
+ "\(deprimedAt.sorted { $0.key < $1.key })")
|
||||
}
|
||||
// ...and THE property: the fuse must not SCALE with the quantum. As a callback count these
|
||||
// same devices de-primed after 20/32/40/64/84 ms — a 4.2x spread, which is exactly why an
|
||||
// iPad crackled where a Mac did not. Measured in time the spread collapses to ~1.3x.
|
||||
let spread = Double(deprimedAt.values.max()!) / Double(deprimedAt.values.min()!)
|
||||
XCTAssertLessThan(
|
||||
spread, 1.6,
|
||||
"de-prime time still scales with the IO quantum (\(String(format: "%.2f", spread))x "
|
||||
+ "across \(deprimedAt.sorted { $0.key < $1.key })) — the fuse is a count again")
|
||||
}
|
||||
|
||||
/// Mirror of the Rust `target_grows_on_underruns_and_relaxes_when_quiet`, updated for
|
||||
/// near-miss growth: the drain's LAST full read (less than a frame left over) already grows
|
||||
/// the floor before anything was audible, clustered genuine underruns raise it further, and
|
||||
|
||||
@@ -185,8 +185,12 @@ pub enum MaxLevelIdc {
|
||||
H265(hh::StdVideoH265LevelIdc),
|
||||
/// `VkVideoDecodeAV1CapabilitiesKHR::maxLevel`. Unlike the other two this code
|
||||
/// space is the BITSTREAM's own: `StdVideoAV1Level` is index-coded exactly like
|
||||
/// AV1's `seq_level_idx` (2.0 = 0, 2.1 = 1, … 7.3 = 23), so the decoder's gate
|
||||
/// compares the sequence header's value against it directly.
|
||||
/// AV1's `seq_level_idx` (2.0 = 0, 2.1 = 1, … 7.3 = 23).
|
||||
///
|
||||
/// ⚠ Only over 0…23. `seq_level_idx` is 5 bits, and 31 is Annex A's "maximum
|
||||
/// parameters" sentinel — no level constraint — which outranks even a device
|
||||
/// reporting the enum's top value. The AV1 gate therefore treats a stream above
|
||||
/// this ceiling as advisory instead of refusing it (`VkAv1Decoder::ensure_state`).
|
||||
Av1(hh::StdVideoAV1Level),
|
||||
}
|
||||
|
||||
|
||||
@@ -211,9 +211,16 @@ pub struct RawAv1Caps {
|
||||
pub max_coded_extent: vk::Extent2D,
|
||||
pub max_dpb_slots: u32,
|
||||
pub max_active_reference_pictures: u32,
|
||||
/// `VkVideoDecodeAV1CapabilitiesKHR::maxLevel` (index-coded Std level — the
|
||||
/// SAME numbering as the bitstream's `seq_level_idx`, which is what makes the
|
||||
/// decoder's level gate a plain comparison).
|
||||
/// `VkVideoDecodeAV1CapabilitiesKHR::maxLevel` (index-coded Std level — the same
|
||||
/// numbering as the bitstream's `seq_level_idx` OVER 0…23, which is the whole
|
||||
/// range `StdVideoAV1Level` enumerates).
|
||||
///
|
||||
/// ⚠ That correspondence does not extend to the rest of the bitstream field.
|
||||
/// `seq_level_idx` is 5 bits: 24…30 are reserved and 31 is Annex A's "maximum
|
||||
/// parameters" sentinel — "not constrained to a level" — which has no Std code
|
||||
/// point and is NOT an ordering above 7.3. The decoder's gate therefore treats
|
||||
/// a stream above this ceiling as advisory rather than comparing it as a level
|
||||
/// (`VkAv1Decoder::ensure_state`).
|
||||
pub max_level: hh::StdVideoAV1Level,
|
||||
/// `VkVideoCapabilitiesKHR::stdHeaderVersion` — session creation echoes it back.
|
||||
pub std_header_version: vk::ExtensionProperties,
|
||||
|
||||
@@ -100,6 +100,7 @@ use pf_bitstream::av1::NUM_REF_SLOTS;
|
||||
use pf_bitstream::h264::DisplayCrop;
|
||||
use tracing::debug;
|
||||
use tracing::trace;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::caps::DecodeCaps;
|
||||
use crate::caps::DecodeProfile;
|
||||
@@ -688,6 +689,10 @@ pub struct VkAv1Decoder {
|
||||
/// through a temporal unit, which is why the skip is per FRAME while the error
|
||||
/// is per ACCESS UNIT.
|
||||
awaiting_key: bool,
|
||||
/// One-shot latch for the over-declared-level warning, so a stream whose
|
||||
/// sequence header sits above the device ceiling says so once per decoder
|
||||
/// rather than once per access unit (`ensure_state` runs per AU).
|
||||
level_advisory_warned: bool,
|
||||
}
|
||||
|
||||
impl VkAv1Decoder {
|
||||
@@ -728,6 +733,7 @@ impl VkAv1Decoder {
|
||||
device_lost: false,
|
||||
recovery: RecoveryLatch::default(),
|
||||
awaiting_key: false,
|
||||
level_advisory_warned: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -745,8 +751,10 @@ impl VkAv1Decoder {
|
||||
///
|
||||
/// The negotiated facts are a HINT (the in-band sequence header is
|
||||
/// authoritative), so this is deliberately not a promise that decode will
|
||||
/// succeed: the level ceiling and a sequence header that disagrees with the
|
||||
/// Welcome still surface at the first AU.
|
||||
/// succeed: a coded extent outside the caps, a DPB deeper than the device
|
||||
/// allows, and a sequence header that disagrees with the Welcome all still
|
||||
/// surface at the first AU. The declared LEVEL is not among them — it is
|
||||
/// advisory, and `ensure_state` only warns on it.
|
||||
pub fn probe_stream_support(
|
||||
&self,
|
||||
chroma_format_idc: u8,
|
||||
@@ -1478,8 +1486,9 @@ impl VkAv1Decoder {
|
||||
self.flush();
|
||||
}
|
||||
|
||||
/// Session/caps for THIS plan exist and match its extent + profile, and the
|
||||
/// stream sits inside the device's level ceiling.
|
||||
/// Session/caps for THIS plan exist and match its extent + profile. A declared
|
||||
/// level above the device ceiling warns once and proceeds — see the gate below
|
||||
/// for why an AV1 `seq_level_idx` is advisory and 31 is not even a level.
|
||||
fn ensure_state(&mut self, plan: &AuPlan) -> Result<(), VkDecodeError> {
|
||||
let key = profile_key_for(plan)?;
|
||||
if self.caps.as_ref().map(|(k, _)| *k) != Some(key) {
|
||||
@@ -1491,17 +1500,39 @@ impl VkAv1Decoder {
|
||||
unsafe { query_av1_caps(&self.dev, key) }.map_err(|r| caps_query_error(r, key))?;
|
||||
self.caps = Some((key, derive_caps_av1(&raw, wanted)?));
|
||||
}
|
||||
// The level gate. AV1's `StdVideoAV1Level` is index-coded exactly like the
|
||||
// bitstream's `seq_level_idx` (2.0 = 0 … 7.3 = 23) and ascends with the
|
||||
// level, so this is a plain comparison — of AV1 code points against an AV1
|
||||
// ceiling, the pairing `MaxLevelIdc`'s tag exists to keep honest.
|
||||
// The declared level vs the device ceiling: a DECLARED level above `maxLevel`
|
||||
// is NOT a refusal, for the reason `VkH265Decoder::ensure_state` spells out —
|
||||
// the level is a CLAIM, and the stream's real demands are enforced where they
|
||||
// are physical facts (coded extent and DPB depth, checked in `rebuild_state`).
|
||||
//
|
||||
// AV1 makes the point sharper than H.265 did. `seq_level_idx` is a 5-bit
|
||||
// field; Annex A defines 0…23 (levels 2.0…7.3) and reserves 24…30, but **31 is
|
||||
// the "maximum parameters" level — the spec's own way of saying the bitstream
|
||||
// is not constrained to any level at all**. `StdVideoAV1Level` has no code
|
||||
// point for it (it stops at 7.3 = 23), so the index-coded comparison that
|
||||
// holds across 0…23 is meaningless against 31: the sentinel is not a level
|
||||
// and 31 > 23 is not "too demanding". Real-time encoders emit it as a matter
|
||||
// of course — a 2026-08-13 field report (RTX 5060 client, 4K120) had EVERY
|
||||
// AV1 session demote to D3D11VA on "stream level (seq_level_idx 31) above the
|
||||
// device's maxLevel (AV1 Std level 23)" while the same hardware decoded the
|
||||
// stream trivially. We never write an AV1 level on any host encode path, so
|
||||
// whatever the vendor defaults to is what the client must accept.
|
||||
//
|
||||
// Unlike H.265 there is nothing to clamp: `StdVideoAV1SequenceHeader` carries
|
||||
// no level field (see `params_av1`), so the declaration never reaches the
|
||||
// driver and cannot be invalid usage. Warn once, proceed.
|
||||
let caps_max_level = self.caps.as_ref().expect("queried above").1.max_level_idc;
|
||||
let stream_level = u32::from(stream_level_idx(plan));
|
||||
if stream_level > caps_max_level.code_point() {
|
||||
return Err(VkDecodeError::Unsupported(format!(
|
||||
"stream level (seq_level_idx {stream_level}) above the device's \
|
||||
maxLevel ({caps_max_level})"
|
||||
)));
|
||||
if stream_level > caps_max_level.code_point() && !self.level_advisory_warned {
|
||||
self.level_advisory_warned = true;
|
||||
warn!(
|
||||
stream_level,
|
||||
ceiling = %caps_max_level,
|
||||
"stream declares an AV1 level above the device ceiling — the declared \
|
||||
level is advisory (seq_level_idx 31 means \"maximum parameters\", and \
|
||||
encoders over-declare); proceeding, since the level never reaches the \
|
||||
driver"
|
||||
);
|
||||
}
|
||||
let coded = coded_extent(plan);
|
||||
match &self.state {
|
||||
@@ -2907,10 +2938,45 @@ mod tests {
|
||||
assert_eq!(key.output_format(), Some(crate::caps::NV12));
|
||||
assert!(!key.film_grain);
|
||||
|
||||
// The level gate reads operating point 0 and stays inside the Std range.
|
||||
// The level gate reads operating point 0. This vector declares a real level,
|
||||
// inside the Std range — the sentinel case is pinned separately below.
|
||||
assert!(stream_level_idx(&plan) <= 23);
|
||||
}
|
||||
|
||||
/// `seq_level_idx` 31 is Annex A's "maximum parameters" — "not constrained to a
|
||||
/// level" — not a level above 7.3, and `StdVideoAV1Level` has no code point for
|
||||
/// it. Comparing it as an ordinary level is what demoted every AV1 session on a
|
||||
/// 2026-08-13 field report (RTX 5060, 4K120): `maxLevel` came back 23 (7.3, the
|
||||
/// device's own maximum) and 31 > 23 refused a stream the hardware decodes fine.
|
||||
///
|
||||
/// This pins the ARITHMETIC that made the refusal look reasonable, so nobody
|
||||
/// restores the gate by reading `31 > 23` as "too demanding":
|
||||
#[test]
|
||||
fn the_av1_max_parameters_sentinel_is_not_a_level_above_the_ceiling() {
|
||||
// The ceiling as the gate reads it, on a device that decodes everything the
|
||||
// Std enum can name — 7.3, the top code point there is.
|
||||
let ceiling = crate::caps::MaxLevelIdc::Av1(hh::StdVideoAV1Level_STD_VIDEO_AV1_LEVEL_7_3);
|
||||
assert_eq!(ceiling.code_point(), 23, "the Std enum's top code point");
|
||||
|
||||
// Every `seq_level_idx` the Std enum names compares sanely against it…
|
||||
for idx in 0..=ceiling.code_point() {
|
||||
assert!(idx <= ceiling.code_point());
|
||||
}
|
||||
// …and everything above is OUTSIDE that code space, not above the ceiling:
|
||||
// 24…30 are reserved and 31 is "maximum parameters". A maxed-out device
|
||||
// cannot satisfy the comparison, which is why it is not a capability test.
|
||||
for idx in (ceiling.code_point() + 1)..=31 {
|
||||
assert!(
|
||||
idx > ceiling.code_point(),
|
||||
"seq_level_idx {idx} is outside the Std range, not a more demanding level"
|
||||
);
|
||||
}
|
||||
|
||||
// The field report's exact pairing, kept legible: 31 against a ceiling of 23.
|
||||
assert!(31 > ceiling.code_point());
|
||||
assert_eq!(format!("{ceiling}"), "AV1 Std level 23");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_decoded_key_frame_ends_the_wait_for_one() {
|
||||
let mut planner = Av1Planner::new();
|
||||
@@ -2951,7 +3017,7 @@ mod tests {
|
||||
/// `PlanError::AwaitingIdr`, and the reason [`VkAv1Decoder::awaiting_key`]'s
|
||||
/// docs carry: a clean `Ok(None)` resets the consumer's demotion streak once
|
||||
/// per frame, so a rung whose every key frame fails (film grain on a device
|
||||
/// without the grain profile; a level above `maxLevelIdc`; a sequence header
|
||||
/// without the grain profile; a coded extent outside the caps; a sequence header
|
||||
/// disagreeing with the negotiation) would never demote and the session would
|
||||
/// hold a frozen screen with a clean bill of health.
|
||||
///
|
||||
|
||||
@@ -407,10 +407,23 @@ pub struct JitterTuning {
|
||||
pub headroom_ms: u32,
|
||||
/// Absolute bound on buffered audio — the only hard guarantee on added latency.
|
||||
pub hard_cap_ms: u32,
|
||||
/// Consecutive short reads before the ring goes back to priming. `1` reproduces the old
|
||||
/// `if ring.is_empty() { primed = false }`, where a single transient drain manufactured a
|
||||
/// whole target's worth of fresh silence; every platform now uses hysteresis.
|
||||
pub deprime_after: u32,
|
||||
/// How long the ring may run short before it gives up and goes back to priming, in
|
||||
/// MILLISECONDS of starvation — not a count of callbacks.
|
||||
///
|
||||
/// It used to be a callback count, and that made the hysteresis mean something different on
|
||||
/// every platform, because a callback is not a unit of time: the same `4` was ~40 ms of slack
|
||||
/// on a 10 ms WASAPI quantum and **20 ms on iOS**, whose session asks for a 5 ms IO buffer —
|
||||
/// the shortest fuse of any client, on the one with the burstiest transport. A 100 ms Wi-Fi
|
||||
/// delivery stall then de-primed the Apple ring on every single bunching cycle (measured: 120
|
||||
/// audible gaps in 10 minutes at a 5 ms quantum, versus 3 at 8 ms and 1 at 16 ms, on an
|
||||
/// otherwise identical link) while the same policy rode it out everywhere else. Expressed in
|
||||
/// time, one number means one thing on all four clients and a device's buffer size stops
|
||||
/// silently re-tuning the de-prime behaviour.
|
||||
///
|
||||
/// A floor of `MIN_DEPRIME_CALLBACKS` callbacks still applies, so a large-quantum device
|
||||
/// keeps real hysteresis: `1` reproduces the old `if ring.is_empty() { primed = false }`, where
|
||||
/// a single transient drain manufactured a whole target's worth of fresh silence.
|
||||
pub deprime_ms: u32,
|
||||
}
|
||||
|
||||
impl JitterTuning {
|
||||
@@ -421,7 +434,7 @@ impl JitterTuning {
|
||||
max_target_ms: 60,
|
||||
headroom_ms: 25,
|
||||
hard_cap_ms: 80,
|
||||
deprime_after: 4,
|
||||
deprime_ms: 40,
|
||||
};
|
||||
/// WASAPI shared-mode event-driven render: the engine buffers for us, but nothing rate-matches.
|
||||
pub const WASAPI: JitterTuning = JitterTuning {
|
||||
@@ -429,15 +442,21 @@ impl JitterTuning {
|
||||
max_target_ms: 70,
|
||||
headroom_ms: 30,
|
||||
hard_cap_ms: 90,
|
||||
deprime_after: 4,
|
||||
deprime_ms: 50,
|
||||
};
|
||||
/// CoreAudio via AVAudioEngine — comparable to WASAPI; the iOS IO buffer is already 5 ms.
|
||||
/// CoreAudio via AVAudioEngine — comparable to WASAPI, but the transport is not: this is the
|
||||
/// preset an iPad on Wi-Fi runs, so it gets the longer fuse for the same reason [`AAUDIO`]
|
||||
/// does. (The old comment here read "the iOS IO buffer is already 5 ms" as grounds for using
|
||||
/// WASAPI's callback count unchanged; that quantum is precisely why a count was the wrong unit
|
||||
/// — see [`JitterTuning::deprime_ms`].)
|
||||
///
|
||||
/// [`AAUDIO`]: JitterTuning::AAUDIO
|
||||
pub const COREAUDIO: JitterTuning = JitterTuning {
|
||||
base_target_ms: 20,
|
||||
max_target_ms: 70,
|
||||
headroom_ms: 30,
|
||||
hard_cap_ms: 90,
|
||||
deprime_after: 4,
|
||||
deprime_ms: 60,
|
||||
};
|
||||
/// AAudio hands us a raw realtime callback and makes us own the buffer, and Wi-Fi power-save
|
||||
/// bunching lands as underruns = crackle. Android therefore starts DEEPER — but at 25 ms, not
|
||||
@@ -448,7 +467,7 @@ impl JitterTuning {
|
||||
max_target_ms: 90,
|
||||
headroom_ms: 40,
|
||||
hard_cap_ms: 120,
|
||||
deprime_after: 5,
|
||||
deprime_ms: 60,
|
||||
};
|
||||
|
||||
/// How far above the live target the depth average must sit before drift correction sheds:
|
||||
@@ -471,11 +490,21 @@ impl JitterTuning {
|
||||
pub struct JitterStep {
|
||||
/// Interleaved samples to discard from the FRONT of the ring before reading.
|
||||
pub drop_front: usize,
|
||||
/// When non-zero, `drop_front` is a smooth drift correction and this many interleaved samples
|
||||
/// of linear crossfade should be applied across the seam ([`crossfade_drop`] does it for a
|
||||
/// `VecDeque<f32>` ring). Zero means discard hard — either nothing is being dropped, or the
|
||||
/// ring blew the hard cap and is already a discontinuity.
|
||||
/// Interleaved samples of linear crossfade to apply across the seam left by `drop_front`
|
||||
/// ([`crossfade_drop`] does it for a `VecDeque<f32>` ring). Zero only when nothing is dropped.
|
||||
///
|
||||
/// BOTH kinds of drop are faded. The hard-cap trim used to splice raw, on the reasoning that a
|
||||
/// ring which blew its ceiling "is already a discontinuity" — but that is a statement about the
|
||||
/// ARRIVALS, not about the samples either side of the seam, which are ordinary continuous
|
||||
/// audio. It is also the drop that actually fires in the field: a bunching Wi-Fi link trimmed
|
||||
/// 120 times in 10 simulated minutes where the smooth shed fired for drift a handful of times.
|
||||
/// The gentle path that almost never runs was the one being faded.
|
||||
pub crossfade: usize,
|
||||
/// `drop_front` was the hard-cap backstop (a burst blew the ceiling) rather than the smooth
|
||||
/// drift shed. Both fade now, so the fade length no longer distinguishes them — and the two
|
||||
/// mean very different things to anyone reading logs or a test: sheds are the policy working,
|
||||
/// trims are the link outrunning the headroom.
|
||||
pub hard_trim: bool,
|
||||
/// Emit silence this callback: still priming, or re-priming after a sustained drain.
|
||||
pub silence: bool,
|
||||
}
|
||||
@@ -515,6 +544,19 @@ const SHRINK_PROBE_MS: u32 = 5_000;
|
||||
/// consecutive-empties hysteresis alone converges to. A full ring's underrun (one packet a few
|
||||
/// ms late) is nowhere near hollow and keeps the hysteresis.
|
||||
const DEPRIME_DEBT_MS: u32 = GROW_STEP_MS;
|
||||
/// Floor, in callbacks, under `JitterTuning::deprime_ms`: however short the starvation window works
|
||||
/// out to in time, a de-prime always needs at least this many consecutive short reads. A device
|
||||
/// with a quantum at or above `deprime_ms` would otherwise de-prime on the FIRST short read —
|
||||
/// exactly the "a single transient drain manufactures a whole target of fresh silence" defect the
|
||||
/// hysteresis exists to prevent, reintroduced at the other end of the quantum range.
|
||||
///
|
||||
/// Deliberately NOT `pub`: it is an internal detail of the policy, and cbindgen exports every
|
||||
/// public const into the C header, where this one would land unprefixed next to
|
||||
/// `PUNKTFUNK_AUDIO_*` and pollute every embedder's macro namespace.
|
||||
const MIN_DEPRIME_CALLBACKS: u32 = 2;
|
||||
// A de-prime on the FIRST short read is the defect the hysteresis exists to prevent, so hold the
|
||||
// floor at build time rather than in a test: tuning it to 1 should not compile.
|
||||
const _: () = assert!(MIN_DEPRIME_CALLBACKS >= 2);
|
||||
/// How long a failed probe keeps the sync loop from driving another shrink. Without this the
|
||||
/// loop pays an audible starvation event every [`SHRINK_QUIET_SYNC_MS`] on any link whose jitter
|
||||
/// genuinely needs the depth — sync asks for less, the ring shrinks, the link answers, the ring
|
||||
@@ -545,8 +587,12 @@ pub struct JitterPolicy {
|
||||
/// The live target, in interleaved samples — `base_target_ms` grown by underrun pressure.
|
||||
target: usize,
|
||||
primed: bool,
|
||||
/// Consecutive short reads (de-prime hysteresis).
|
||||
/// Consecutive short reads, and the audio they starved for in interleaved samples. BOTH gate
|
||||
/// the de-prime: the run must be at least [`JitterTuning::deprime_ms`] long AND at least
|
||||
/// [`MIN_DEPRIME_CALLBACKS`] callbacks, so the hysteresis means the same span of time whatever
|
||||
/// the device's quantum, without collapsing to a hair trigger on a large-quantum device.
|
||||
empties: u32,
|
||||
empties_run: usize,
|
||||
/// EWMA of ring depth, interleaved samples.
|
||||
depth_avg: f32,
|
||||
/// Consumed samples for which the EWMA has stayed above the shed threshold.
|
||||
@@ -594,6 +640,7 @@ impl JitterPolicy {
|
||||
target: tuning.base_target_ms as usize * per_ms,
|
||||
primed: false,
|
||||
empties: 0,
|
||||
empties_run: 0,
|
||||
depth_avg: 0.0,
|
||||
over_run: 0,
|
||||
underruns: 0,
|
||||
@@ -693,9 +740,14 @@ impl JitterPolicy {
|
||||
|
||||
let mut out = JitterStep::default();
|
||||
if depth > cap {
|
||||
// Blew the ceiling: a burst arrived, or we were wedged. Already a discontinuity —
|
||||
// discard hard, and reset the drift timer so the trim isn't double-counted as drift.
|
||||
// Blew the ceiling: a burst arrived, or we were wedged. Discard down to the cap and
|
||||
// reset the drift timer so the trim isn't double-counted as drift. Faded like any
|
||||
// other drop — see `JitterStep::crossfade` for why this used to splice raw and why
|
||||
// that was backwards.
|
||||
out.drop_front = depth - cap;
|
||||
out.hard_trim = true;
|
||||
out.crossfade = (SHED_CROSSFADE_MS as usize * self.per_ms)
|
||||
.min(depth.saturating_sub(out.drop_front));
|
||||
self.over_run = 0;
|
||||
} else if self.depth_avg
|
||||
> (target + self.tuning.shed_excess_ms() as usize * self.per_ms) as f32
|
||||
@@ -717,6 +769,7 @@ impl JitterPolicy {
|
||||
if !self.primed && depth.saturating_sub(out.drop_front) >= target {
|
||||
self.primed = true;
|
||||
self.empties = 0;
|
||||
self.empties_run = 0;
|
||||
// The refill just banked this much: seed the average with it rather than letting it
|
||||
// climb from wherever the drought left it — a freshly-primed ring would otherwise
|
||||
// read as hollow for the EWMA's whole settling time, and the FIRST late packet
|
||||
@@ -784,14 +837,22 @@ impl JitterPolicy {
|
||||
if ran_short {
|
||||
self.quiet_run = 0;
|
||||
self.empties += 1;
|
||||
if self.empties >= self.tuning.deprime_after || self.hollow {
|
||||
// The consecutive-empties hysteresis protects a FULL ring from one late packet.
|
||||
// A hollow ring is the opposite case: the target has been raised but the depth
|
||||
// never re-banked (growth is a promise; only a re-prime cashes it), and riding
|
||||
// that out is a click per bunching period, forever. The click just heard has
|
||||
// already paid for the refill — take it now.
|
||||
self.empties_run += want;
|
||||
// Starved for `deprime_ms` of audio, over at least MIN_DEPRIME_CALLBACKS callbacks.
|
||||
// Both, because either alone is wrong at one end of the quantum range: time alone is a
|
||||
// hair trigger on a device whose single quantum already exceeds the window, and a
|
||||
// callback count alone is the platform-dependent fuse this replaced.
|
||||
let starved = self.empties_run >= self.tuning.deprime_ms as usize * self.per_ms
|
||||
&& self.empties >= MIN_DEPRIME_CALLBACKS;
|
||||
if starved || self.hollow {
|
||||
// The starvation hysteresis protects a FULL ring from one late packet. A hollow
|
||||
// ring is the opposite case: the target has been raised but the depth never
|
||||
// re-banked (growth is a promise; only a re-prime cashes it), and riding that out
|
||||
// is a click per bunching period, forever. The click just heard has already paid
|
||||
// for the refill — take it now.
|
||||
self.primed = false;
|
||||
self.empties = 0;
|
||||
self.empties_run = 0;
|
||||
}
|
||||
if !restored {
|
||||
self.underruns += 1;
|
||||
@@ -814,6 +875,7 @@ impl JitterPolicy {
|
||||
// the path above takes over. A near-miss is pressure, not quiet.
|
||||
self.quiet_run = 0;
|
||||
self.empties = 0;
|
||||
self.empties_run = 0;
|
||||
if !self.near_miss_grown && !restored {
|
||||
self.near_miss_grown = true;
|
||||
let grown = self.target + GROW_STEP_MS as usize * self.per_ms;
|
||||
@@ -821,6 +883,7 @@ impl JitterPolicy {
|
||||
}
|
||||
} else {
|
||||
self.empties = 0;
|
||||
self.empties_run = 0;
|
||||
self.quiet_run += want;
|
||||
// A grown target normally relaxes only after a long quiet spell, because without other
|
||||
// evidence the only thing that can justify giving up hard-won slack is time. When the
|
||||
@@ -862,9 +925,10 @@ pub const SAMPLE_RATE_HZ: u32 = 48_000;
|
||||
/// `fade` samples so a drift correction is inaudible rather than a click.
|
||||
///
|
||||
/// The dropped region's tail fades out while the surviving head fades in, so the waveform is
|
||||
/// continuous across the splice. `fade == 0` discards hard (what a hard-cap trim wants — that
|
||||
/// backlog is already a discontinuity). Shared by the three `VecDeque<f32>` rings; the Apple ring
|
||||
/// is index-based and mirrors this in Swift.
|
||||
/// continuous across the splice. `fade == 0` discards hard; no caller in the policy asks for that
|
||||
/// any more (see [`JitterStep::crossfade`]), but it stays honoured for callers that splice at a
|
||||
/// point they know is already discontinuous. Shared by the three `VecDeque<f32>` rings; the Apple
|
||||
/// ring is index-based and mirrors this in Swift.
|
||||
pub fn crossfade_drop(ring: &mut std::collections::VecDeque<f32>, drop: usize, fade: usize) {
|
||||
if drop == 0 || ring.len() < drop {
|
||||
return;
|
||||
@@ -876,17 +940,19 @@ pub fn crossfade_drop(ring: &mut std::collections::VecDeque<f32>, drop: usize, f
|
||||
}
|
||||
// The last `fade` samples of what we are about to discard are the fade-OUT source; they blend
|
||||
// into the first `fade` samples of what survives.
|
||||
let mut faded = Vec::with_capacity(fade);
|
||||
//
|
||||
// Blended in place and BEFORE the drain, with no scratch buffer: a value written at `drop + i`
|
||||
// can never be read again as a fade-OUT source, because those sources are `drop - fade + j` for
|
||||
// `j < fade`, i.e. strictly below `drop`. One ascending pass is therefore safe — and this runs
|
||||
// inside realtime audio callbacks, where the `Vec` this used to allocate had no business being.
|
||||
// It now runs on every hard-cap trim too, which is the common case on a bunching link.
|
||||
for i in 0..fade {
|
||||
let old = ring[drop - fade + i];
|
||||
let new = ring[drop + i];
|
||||
let t = (i + 1) as f32 / (fade + 1) as f32;
|
||||
faded.push(old * (1.0 - t) + new * t);
|
||||
ring[drop + i] = old * (1.0 - t) + new * t;
|
||||
}
|
||||
ring.drain(..drop);
|
||||
for (i, v) in faded.into_iter().enumerate() {
|
||||
ring[i] = v;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- per-platform channel-layout helpers (pure data; no platform deps) --------------------
|
||||
@@ -1460,11 +1526,17 @@ mod tests {
|
||||
|
||||
let s = p.step(depth, want);
|
||||
if s.drop_front > 0 {
|
||||
if s.crossfade > 0 {
|
||||
out.soft_sheds += 1;
|
||||
} else {
|
||||
// Told apart by `hard_trim`, not by the fade length — both kinds fade now.
|
||||
if s.hard_trim {
|
||||
out.hard_trims += 1;
|
||||
} else {
|
||||
out.soft_sheds += 1;
|
||||
}
|
||||
assert!(
|
||||
s.crossfade > 0,
|
||||
"every drop must be faded: dropped {} with no crossfade",
|
||||
s.drop_front
|
||||
);
|
||||
depth -= s.drop_front.min(depth);
|
||||
}
|
||||
if s.silence {
|
||||
@@ -1508,7 +1580,22 @@ mod tests {
|
||||
"{name}: the headroom band is cut short by the hard cap"
|
||||
);
|
||||
assert!(t.max_target_ms >= t.base_target_ms, "{name}");
|
||||
assert!(t.deprime_after >= 2, "{name}: needs real hysteresis");
|
||||
// Real hysteresis, in time: a drought has to outlast several protocol frames before
|
||||
// the ring gives up, or one late packet manufactures a whole target of fresh silence.
|
||||
assert!(
|
||||
t.deprime_ms >= 4 * FRAME_MS,
|
||||
"{name}: de-primes after {} ms — a single late packet would trip it",
|
||||
t.deprime_ms
|
||||
);
|
||||
// ...and never longer than the deepest buffer this preset would ever hold: past that
|
||||
// point the drought has already cost more than the re-prime it is trying to avoid, and
|
||||
// every callback in between is dribbling partial reads at the listener.
|
||||
assert!(
|
||||
t.deprime_ms <= t.max_target_ms,
|
||||
"{name}: waits {} ms to de-prime but never buffers more than {} ms",
|
||||
t.deprime_ms,
|
||||
t.max_target_ms
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1619,7 +1706,20 @@ mod tests {
|
||||
s.drop_front > 0,
|
||||
"a 500 ms backlog must be trimmed on the spot"
|
||||
);
|
||||
assert_eq!(s.crossfade, 0, "a blown cap is already a discontinuity");
|
||||
assert!(s.hard_trim, "a cap trim must announce itself as one");
|
||||
// ...and it is FADED. This used to assert the opposite ("a blown cap is already a
|
||||
// discontinuity"), which confused the arrivals with the audio: the samples either side of
|
||||
// the splice are ordinary continuous sound, and a raw seam through them is a click. It is
|
||||
// also the drop that actually fires in the field — a bunching Wi-Fi link trims far more
|
||||
// often than drift sheds — so the one path that was left unfaded was the audible one.
|
||||
assert!(
|
||||
s.crossfade > 0,
|
||||
"a cap trim splices real audio and must be faded"
|
||||
);
|
||||
assert!(
|
||||
s.crossfade <= s.drop_front,
|
||||
"the fade cannot outrun what is being dropped"
|
||||
);
|
||||
let left = 500 * pm - s.drop_front;
|
||||
assert!(
|
||||
left <= JitterTuning::AAUDIO.hard_cap_ms as usize * pm,
|
||||
@@ -1647,12 +1747,46 @@ mod tests {
|
||||
assert!(p.is_primed());
|
||||
p.note_read(true); // one short read
|
||||
assert!(p.is_primed(), "a single short read must not de-prime");
|
||||
for _ in 1..JitterTuning::PIPEWIRE.deprime_after {
|
||||
let deprime = JitterTuning::PIPEWIRE.deprime_ms as usize;
|
||||
for _ in 1..(deprime / 5) {
|
||||
p.note_read(true);
|
||||
}
|
||||
assert!(!p.is_primed(), "a sustained drain must re-prime");
|
||||
}
|
||||
|
||||
/// THE regression this replaced a callback count for: the de-prime fuse must be the same
|
||||
/// SPAN OF TIME whatever the device's IO quantum. As a count it was not — the same `4` was
|
||||
/// ~40 ms on a 10 ms WASAPI quantum and 20 ms on iOS, whose session asks for a 5 ms IO buffer.
|
||||
/// A Wi-Fi delivery stall therefore de-primed the Apple ring on every bunching cycle while the
|
||||
/// identical policy rode it out everywhere else. Plant the defect by restoring a fixed count
|
||||
/// and the two quanta below stop agreeing.
|
||||
#[test]
|
||||
fn deprime_fuse_is_a_duration_not_a_callback_count() {
|
||||
for quantum_ms in [5usize, 8, 10, 16, 21] {
|
||||
let t = JitterTuning::COREAUDIO;
|
||||
let pm = per_ms(2);
|
||||
let want = quantum_ms * pm;
|
||||
let mut p = JitterPolicy::new(t, 2);
|
||||
// Prime well above target so the hysteresis path is what we measure, not `hollow`.
|
||||
assert!(!p.step(80 * pm, want).silence);
|
||||
assert!(p.is_primed());
|
||||
let mut starved_ms = 0;
|
||||
while p.is_primed() && starved_ms < 10 * t.deprime_ms as usize {
|
||||
p.note_read(true);
|
||||
starved_ms += quantum_ms;
|
||||
}
|
||||
assert!(!p.is_primed(), "q={quantum_ms}ms: never de-primed at all");
|
||||
// One quantum of granularity either side — the fuse can only be checked per callback.
|
||||
let floor = (t.deprime_ms as usize).min(quantum_ms * MIN_DEPRIME_CALLBACKS as usize);
|
||||
assert!(
|
||||
starved_ms >= floor && starved_ms < t.deprime_ms as usize + quantum_ms,
|
||||
"q={quantum_ms}ms de-primed after {starved_ms} ms, not ~{} ms — the fuse is still \
|
||||
scaling with the quantum",
|
||||
t.deprime_ms
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A device that pulls a big quantum cannot sustain a target below it: the effective target
|
||||
/// must lift, or the ring oscillates prime → dropout → re-prime forever.
|
||||
#[test]
|
||||
|
||||
@@ -47,7 +47,15 @@ pub(crate) const FLUSH_AFTER: Duration = Duration::from_millis(250);
|
||||
/// Minimum spacing between jump-to-live events, so a bottleneck that instantly rebuilds the queue (a
|
||||
/// link/consumer that can't sustain the bitrate at all) degrades into a periodic skip + a logged
|
||||
/// warning instead of a continuous flush/keyframe storm.
|
||||
pub(crate) const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
|
||||
///
|
||||
/// **Public because the HOST needs it to read its own logs.** Each jump-to-live sends a keyframe
|
||||
/// request, so a client that cannot sustain the rate asks for one at exactly this spacing,
|
||||
/// forever — and the host's recovery-cadence detector saw that perfect periodicity and blamed a
|
||||
/// periodic *display* disturbance (2026-08-13 field log: `period_s=2.0`, three subsystems named,
|
||||
/// none of them the cause). Perfect periodicity is the signature of a fixed software cooldown,
|
||||
/// not of a physical disturbance. The host compares against this constant rather than a copy of
|
||||
/// the number, so the two can never drift apart.
|
||||
pub const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
|
||||
|
||||
/// A clock-triggered jump-to-live that discarded fewer datagrams than this (and no queued AUs)
|
||||
/// found NO local backlog: the frames read as late, but nothing here was actually behind. Two
|
||||
|
||||
@@ -42,6 +42,7 @@ mod recovery;
|
||||
mod rumble;
|
||||
mod worker;
|
||||
|
||||
pub use self::frame_channel::FLUSH_COOLDOWN;
|
||||
pub use self::planes::AudioPacket;
|
||||
pub use self::probe::ProbeOutcome;
|
||||
pub use self::rumble::{ActuatorQuirks, RumbleCommand};
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -219,10 +219,6 @@ pipewire = "0.9"
|
||||
# in the subsystem extraction, and each of those crates declares them itself — only the manifest
|
||||
# entries were left behind, along with comments describing code this crate no longer contains.
|
||||
# Verified unused before removal: zero `use`/path references across src/ + build.rs.
|
||||
# Read the Lutris library DB (`pga.db`) for the Lutris store provider. `bundled` vendors + compiles
|
||||
# SQLite (cc, already needed for ffmpeg/opus) so there's no system libsqlite3 runtime dependency —
|
||||
# clean for the deb/rpm/flatpak packaging. Opened read-only/immutable (Lutris may hold it open).
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
# `libcuda.so.1` is dlopen'd at runtime (NOT link-time) so one Linux binary runs on NVIDIA
|
||||
# (zero-copy via CUDA) AND on AMD/Intel (VAAPI, no NVIDIA driver present) — see `zerocopy::cuda`.
|
||||
libloading = "0.9"
|
||||
@@ -319,12 +315,12 @@ windows = { version = "0.62", features = [
|
||||
# kept a fourth windows-sys major compiling. 0.8.1 moves to `windows-sys 0.61`, which the tree
|
||||
# already builds, and that duplicate is gone. (0.8.0 is NOT enough — it lands on 0.59.)
|
||||
windows-service = "0.8"
|
||||
# Read the GOG.com install registry (HKLM\SOFTWARE\WOW6432Node\GOG.com\Games) for the GOG store
|
||||
# provider — ergonomic + correct-by-construction vs. hand-rolled Reg* FFI for subkey enumeration.
|
||||
# Registry reads the host does on Windows — ergonomic + correct-by-construction vs. hand-rolled
|
||||
# Reg* FFI for subkey enumeration. Survived the removal of the built-in store scanners (which is
|
||||
# what first pulled it in): `library/launch.rs` resolves launcher install paths for the typed launch
|
||||
# kinds a plugin publishes, `procscan/windows.rs` maps processes, and the two `audio/windows/`
|
||||
# modules read and clean up device nodes.
|
||||
winreg = "0.56"
|
||||
# Parse each Xbox/Game-Pass game's MicrosoftGame.config (GDK manifest XML) for the Xbox store
|
||||
# provider — a small read-only DOM is all we need (Identity/Executable/ShellVisuals/StoreId).
|
||||
roxmltree = "0.21"
|
||||
# WASAPI loopback audio capture (default render endpoint -> 48 kHz stereo f32 for the Opus path).
|
||||
wasapi = "0.24"
|
||||
# Shared host<->driver wire contract for the pf-vdisplay IddCx virtual-display backend: the
|
||||
|
||||
@@ -62,6 +62,17 @@ pub struct PwAudioCapturer {
|
||||
/// active). Toggled by open/[`drain`](AudioCapturer::drain) (claim) and
|
||||
/// [`idle`](AudioCapturer::idle)/Drop (release).
|
||||
claimed: bool,
|
||||
/// Whether a session is currently CONSUMING this capturer, shared with the PipeWire
|
||||
/// thread so the drop counter can tell "the encode thread fell behind" from "nobody is
|
||||
/// reading". The capturer is host-lifetime and merely PARKED between sessions
|
||||
/// ([`idle`](AudioCapturer::idle)), so without this the producer keeps filling the bounded
|
||||
/// hand-off channel, every `try_send` fails once it is full, and the plane reports a 100 %
|
||||
/// drop rate — warning that "the stream will click" when there is no stream. A 2026-08-13
|
||||
/// field host log carried ten such warnings, up to `dropped_chunks=11251` (= 30 s × 375
|
||||
/// chunks/s, i.e. every single chunk), each one straddling a session boundary and each one
|
||||
/// meaningless. Distinct from `claimed`, which tracks the sink-routing claim and only
|
||||
/// exists when the stream sink is enabled at all.
|
||||
active: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl PwAudioCapturer {
|
||||
@@ -90,10 +101,21 @@ impl PwAudioCapturer {
|
||||
// mode the sink node must exist before we claim the default to its name.
|
||||
let (ready_tx, ready_rx) = sync_channel::<Result<()>>(1);
|
||||
let thread_sink_name = sink_name.clone();
|
||||
// Opens at session start (see the routing claim below), so the consumer is live from
|
||||
// the first chunk.
|
||||
let active = Arc::new(AtomicBool::new(true));
|
||||
let thread_active = Arc::clone(&active);
|
||||
thread::Builder::new()
|
||||
.name("punktfunk-pw-audio".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = pw_thread(tx, quit_rx, channels, thread_sink_name, ready_tx) {
|
||||
if let Err(e) = pw_thread(
|
||||
tx,
|
||||
quit_rx,
|
||||
channels,
|
||||
thread_sink_name,
|
||||
ready_tx,
|
||||
thread_active,
|
||||
) {
|
||||
tracing::error!(error = %format!("{e:#}"), "pipewire audio thread failed");
|
||||
}
|
||||
})
|
||||
@@ -118,12 +140,16 @@ impl PwAudioCapturer {
|
||||
quit: quit_tx,
|
||||
sink_name,
|
||||
claimed,
|
||||
active,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PwAudioCapturer {
|
||||
fn drop(&mut self) {
|
||||
// The receiver dies with us; anything the producer still pushes is unwanted by
|
||||
// definition, and it must not be reported as the encode thread falling behind.
|
||||
self.active.store(false, Ordering::Relaxed);
|
||||
if self.claimed {
|
||||
self.claimed = false;
|
||||
stream_sink::release();
|
||||
@@ -157,9 +183,15 @@ impl AudioCapturer for PwAudioCapturer {
|
||||
stream_sink::claim(name);
|
||||
self.claimed = true;
|
||||
}
|
||||
// Ordered AFTER the backlog drain, so the producer never counts a drop against a
|
||||
// channel this call is still emptying.
|
||||
self.active.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn idle(&mut self) {
|
||||
// Parked: from here the channel fills and stays full, and those drops are nobody's
|
||||
// fault. See `PwAudioCapturer::active`.
|
||||
self.active.store(false, Ordering::Relaxed);
|
||||
if self.claimed {
|
||||
self.claimed = false;
|
||||
stream_sink::release();
|
||||
@@ -644,6 +676,7 @@ fn pw_thread(
|
||||
channels: u32,
|
||||
sink_name: Option<String>,
|
||||
ready: std::sync::mpsc::SyncSender<Result<()>>,
|
||||
active: Arc<AtomicBool>,
|
||||
) -> Result<()> {
|
||||
use pipewire as pw;
|
||||
use pw::{properties::properties, spa};
|
||||
@@ -735,6 +768,9 @@ fn pw_thread(
|
||||
/// never again — the one number that identifies a clamped quantum, invisible on every
|
||||
/// subsequent open (including every reopen after a device change).
|
||||
reported_quantum: bool,
|
||||
/// Shared with the capturer — see [`PwAudioCapturer::active`]. Read on every
|
||||
/// failed hand-off to keep parked-capturer backpressure out of the drop count.
|
||||
active: Arc<AtomicBool>,
|
||||
}
|
||||
let ud = CapUd {
|
||||
tx,
|
||||
@@ -742,6 +778,7 @@ fn pw_thread(
|
||||
stats: Default::default(),
|
||||
last_stats: std::time::Instant::now(),
|
||||
reported_quantum: false,
|
||||
active,
|
||||
};
|
||||
let _listener = stream
|
||||
.add_local_listener_with_user_data(ud)
|
||||
@@ -844,11 +881,15 @@ fn pw_thread(
|
||||
samples.push(f32::from_le_bytes(b));
|
||||
}
|
||||
ud.stats.observe(&samples, ud.channels);
|
||||
// Non-blocking and lossy, as before — but COUNTED. A full channel means the
|
||||
// encode thread is not keeping up, and because the encoder simply
|
||||
// concatenates across the hole every dropped chunk is a click AND a
|
||||
// permanent shift of everything after it.
|
||||
if ud.tx.try_send(samples).is_err() {
|
||||
// Non-blocking and lossy, as before — but COUNTED, and only while a session
|
||||
// is actually reading. A full channel under a LIVE consumer means the encode
|
||||
// thread is not keeping up, and because the encoder simply concatenates
|
||||
// across the hole every dropped chunk is a click AND a permanent shift of
|
||||
// everything after it. A full channel under a PARKED capturer means nothing
|
||||
// at all: the capturer is host-lifetime, so between sessions the channel
|
||||
// fills once and then refuses everything, which counted as a 100 % drop rate
|
||||
// and warned about a stream that did not exist (`PwAudioCapturer::active`).
|
||||
if ud.tx.try_send(samples).is_err() && ud.active.load(Ordering::Relaxed) {
|
||||
ud.stats.dropped_chunks += 1;
|
||||
}
|
||||
if ud.last_stats.elapsed() >= crate::audio::capture_policy::STATS_EVERY {
|
||||
|
||||
@@ -43,6 +43,15 @@ pub struct WasapiLoopbackCapturer {
|
||||
channels: u32,
|
||||
stop: Arc<AtomicBool>,
|
||||
join: Option<JoinHandle<()>>,
|
||||
/// Whether a session is currently CONSUMING this capturer, shared with the capture thread
|
||||
/// so the drop counter can tell "the encode thread fell behind" from "nobody is reading".
|
||||
/// The native/gamestream planes park a capturer between sessions
|
||||
/// ([`idle`](AudioCapturer::idle)) instead of dropping it, and the hand-off channel is
|
||||
/// bounded — so without this the thread fills it once, then counts every subsequent chunk
|
||||
/// as a drop and warns that "the stream will click" with no stream to click. Proven on the
|
||||
/// Linux twin by a 2026-08-13 field log (100 % drop rate across session gaps); the parking
|
||||
/// call sites are platform-independent, so this half had the same defect.
|
||||
active: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl WasapiLoopbackCapturer {
|
||||
@@ -58,10 +67,13 @@ impl WasapiLoopbackCapturer {
|
||||
// rather than a silent dead thread.
|
||||
let (ready_tx, ready_rx) = sync_channel::<Result<()>>(1);
|
||||
let stop_t = stop.clone();
|
||||
// Opens at session start, so the consumer is live from the first chunk.
|
||||
let active = Arc::new(AtomicBool::new(true));
|
||||
let active_t = active.clone();
|
||||
let join = thread::Builder::new()
|
||||
.name("punktfunk-wasapi-audio".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = capture_thread(tx, stop_t, ready_tx, channels) {
|
||||
if let Err(e) = capture_thread(tx, stop_t, ready_tx, channels, active_t) {
|
||||
tracing::error!(error = %format!("{e:#}"), "wasapi loopback thread failed");
|
||||
}
|
||||
})
|
||||
@@ -76,6 +88,7 @@ impl WasapiLoopbackCapturer {
|
||||
channels,
|
||||
stop,
|
||||
join: Some(join),
|
||||
active,
|
||||
})
|
||||
}
|
||||
Ok(Err(e)) => Err(e),
|
||||
@@ -92,6 +105,9 @@ impl WasapiLoopbackCapturer {
|
||||
|
||||
impl Drop for WasapiLoopbackCapturer {
|
||||
fn drop(&mut self) {
|
||||
// The receiver dies with us; anything the thread still pushes is unwanted by
|
||||
// definition, and must not be reported as the encode thread falling behind.
|
||||
self.active.store(false, Ordering::Relaxed);
|
||||
self.stop.store(true, Ordering::SeqCst);
|
||||
if let Some(j) = self.join.take() {
|
||||
let _ = j.join();
|
||||
@@ -114,6 +130,14 @@ impl AudioCapturer for WasapiLoopbackCapturer {
|
||||
}
|
||||
fn drain(&mut self) {
|
||||
while self.chunks.try_recv().is_ok() {}
|
||||
// Ordered AFTER the backlog drain, so the capture thread never counts a drop against a
|
||||
// channel this call is still emptying.
|
||||
self.active.store(true, Ordering::Relaxed);
|
||||
}
|
||||
fn idle(&mut self) {
|
||||
// Parked: from here the channel fills and stays full, and those drops are nobody's
|
||||
// fault. See [`WasapiLoopbackCapturer::active`].
|
||||
self.active.store(false, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +191,7 @@ fn capture_thread(
|
||||
stop: Arc<AtomicBool>,
|
||||
ready: SyncSender<Result<()>>,
|
||||
channels: u32,
|
||||
active: Arc<AtomicBool>,
|
||||
) -> Result<()> {
|
||||
// COM must be initialized on THIS thread (MTA), before any device call.
|
||||
if let Err(e) = wasapi::initialize_mta()
|
||||
@@ -192,7 +217,7 @@ fn capture_thread(
|
||||
// is said once per topology — the field log drowned in 256+ copies of the same line.
|
||||
let mut unsat_logged: Option<u64> = None;
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
match capture_once(&tx, &stop, &mut ready, channels, mode) {
|
||||
match capture_once(&tx, &stop, &mut ready, channels, mode, &active) {
|
||||
Ok(Next::Stopped) => break,
|
||||
Ok(Next::Reopen(m)) => {
|
||||
mode = m;
|
||||
@@ -357,6 +382,7 @@ fn capture_once(
|
||||
ready: &mut Option<SyncSender<Result<()>>>,
|
||||
channels: u32,
|
||||
mode: TargetMode,
|
||||
active: &AtomicBool,
|
||||
) -> Result<Next> {
|
||||
// Interleaved f32: channels * 4 bytes per frame.
|
||||
let block_align = channels as usize * 4;
|
||||
@@ -611,10 +637,14 @@ fn capture_once(
|
||||
samples.push(f32::from_le_bytes([c[0], c[1], c[2], c[3]]));
|
||||
}
|
||||
stats.observe(&samples, channels);
|
||||
// Non-blocking, lossy — same discipline as PipeWire. Now COUNTED: a full channel
|
||||
// means the encode thread is not keeping up, and every dropped chunk is a click plus
|
||||
// a permanent shift of everything after it.
|
||||
if tx.try_send(samples).is_err() {
|
||||
// Non-blocking, lossy — same discipline as PipeWire. COUNTED, and only while a
|
||||
// session is actually reading: a full channel under a LIVE consumer means the encode
|
||||
// thread is not keeping up, and every dropped chunk is a click plus a permanent
|
||||
// shift of everything after it. A full channel under a PARKED capturer means nothing
|
||||
// — the planes park capturers between sessions rather than dropping them, so the
|
||||
// channel fills once and then refuses everything
|
||||
// ([`WasapiLoopbackCapturer::active`]).
|
||||
if tx.try_send(samples).is_err() && active.load(Ordering::Relaxed) {
|
||||
stats.dropped_chunks += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ fn parse_compositor(s: &str) -> Option<crate::vdisplay::Compositor> {
|
||||
}
|
||||
|
||||
/// The GameStream catalog Moonlight sees in `/applist`: the operator base ([`base_catalog`] — Desktop +
|
||||
/// apps.json) with the host's auto-detected game library ([`append_library`]) layered on top, so a
|
||||
/// Moonlight client sees the same Steam/Epic/GOG/Xbox titles the native clients do instead of just Desktop.
|
||||
/// apps.json) with the host's game library ([`append_library`]) layered on top, so a Moonlight client
|
||||
/// sees the same titles the native clients do instead of just Desktop.
|
||||
pub fn catalog() -> Vec<AppEntry> {
|
||||
let mut apps = base_catalog();
|
||||
append_library(&mut apps);
|
||||
@@ -128,7 +128,7 @@ fn base_catalog() -> Vec<AppEntry> {
|
||||
/// the small Desktop/apps.json ids so the two never collide.
|
||||
const LIBRARY_ID_BASE: u32 = 0x4000_0000;
|
||||
|
||||
/// Append the host's installed game library ([`crate::library::all_games`] — Steam/Epic/GOG/Xbox/custom)
|
||||
/// Append the host's game library ([`crate::library::all_games`] — every enabled source's titles)
|
||||
/// to `apps`. Each title gets a STABLE GameStream `<ID>` derived from its store-qualified library id
|
||||
/// (Moonlight caches appids, so a title keeps its id across host restarts), carries that library id so
|
||||
/// the launch path resolves it against the host's own library, and is de-duplicated (by id) against the
|
||||
@@ -251,7 +251,7 @@ mod tests {
|
||||
/// pins that the claimed shape is that shape, and that an unclaimed one would NOT have been.
|
||||
#[test]
|
||||
fn a_claimed_plugin_entry_keeps_the_scanners_gamestream_id() {
|
||||
// What the built-in scanner produced, and what the steam plugin produces once it claims.
|
||||
// What the built-in scanner produced, and what the steam plugin produces now that it claims.
|
||||
assert_eq!(stable_app_id("steam:440"), stable_app_id("steam:440"));
|
||||
// The same title reconciled WITHOUT a claim gets an opaque `custom:` id — a different app
|
||||
// id, i.e. exactly the breakage the claim prevents.
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
//! History: ONE RSA-2048 identity (`gamestream::cert`) 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 THIS identity instead: **ECDSA P-256** — generated by
|
||||
//! ring via rcgen (no `rsa` crate on the native path, so the accepted Marvin advisory
|
||||
//! RUSTSEC-2023-0071 stops applying to native-only hosts once WP19 gates the compat planes),
|
||||
//! browser-compatible (an operator opens `/api/docs` in one, and no mainstream browser accepts an
|
||||
//! Ed25519 server cert), and carrying real SANs (localhost, loopback, the machine hostname) —
|
||||
//! which the legacy cert never had. The GameStream plane keeps the RSA identity untouched:
|
||||
//! Moonlight pins it and its pairing hashes bind its X.509 signature bytes.
|
||||
//! rcgen on the workspace's aws-lc-rs backend (no `rsa` crate on the native path, so the accepted
|
||||
//! Marvin advisory RUSTSEC-2023-0071 stops applying to native-only hosts once WP19 gates the
|
||||
//! compat planes), browser-compatible (an operator opens `/api/docs` in one, and no mainstream
|
||||
//! browser accepts an Ed25519 server cert), and carrying real SANs (localhost, loopback, the
|
||||
//! machine hostname) — which the legacy cert never had. The GameStream plane keeps the RSA
|
||||
//! identity untouched: Moonlight pins it and its pairing hashes bind its X.509 signature bytes.
|
||||
//!
|
||||
//! ## Migration — the rule that keeps every pinned fingerprint valid
|
||||
//!
|
||||
@@ -73,9 +73,9 @@ pub fn load_or_adopt(np: &crate::native_pairing::NativePairing) -> Result<Native
|
||||
}
|
||||
// Live native pairings pinned the legacy RSA cert — switching identities now would strand
|
||||
// every one of them (the pin is the SHA-256 of the leaf DER). Keep serving what they pinned.
|
||||
// A pem-only read on purpose (WP19): rustls/ring can SERVE an existing RSA cert without the
|
||||
// `rsa` crate, so the native-only build never links it — the crate exists solely behind the
|
||||
// `gamestream` feature (generation + the pairing signer).
|
||||
// A pem-only read on purpose (WP19): rustls/aws-lc-rs can SERVE an existing RSA cert without
|
||||
// the `rsa` crate, so the native-only build never links it — the crate exists solely behind
|
||||
// the `gamestream` feature (generation + the pairing signer).
|
||||
if let (Ok(c), Ok(k)) = (
|
||||
fs::read_to_string(dir.join("cert.pem")),
|
||||
fs::read_to_string(dir.join("key.pem")),
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
//! Game library (plan: "surface the user's games"). A small adapter layer over the *stores*
|
||||
//! installed on the host — today **Steam** (read from local files, no API key) and a
|
||||
//! user-curated **custom** store (CRUD'd via the management API / web console). Every store
|
||||
//! produces the same [`GameEntry`], so a client renders one uniform grid and never has to know
|
||||
//! which launcher a title came from. Future stores (Heroic/Epic, GOG, Lutris, EmuDeck) are just
|
||||
//! more [`LibraryProvider`]s.
|
||||
//! Game library (plan: "surface the user's games"). One uniform [`GameEntry`] grid over every
|
||||
//! source of titles on this host, so a client never has to know which launcher a title came from.
|
||||
//!
|
||||
//! Artwork is keyed only by Steam appid against the public Steam CDN (no auth) — the client
|
||||
//! fetches the posters directly. Custom entries carry user-supplied art URLs.
|
||||
//! **Every source is a plugin.** The host itself scans nothing: library plugins (Steam, Lutris,
|
||||
//! Heroic, Epic, GOG, Xbox, Playnite, ROM managers, …) reconcile their titles into the stored
|
||||
//! catalog over the provider API, each claiming its store so its entries keep the stable
|
||||
//! `<store>:<external_id>` ids everything downstream already pins (`custom.rs`, design D2). The
|
||||
//! user-curated **custom** store — entries the operator typed in via the management API / web
|
||||
//! console — lives in the same catalog and is the one source that is not a plugin.
|
||||
//!
|
||||
//! Until v0.28.0 the host also carried six built-in scanners that read the launchers' local files
|
||||
//! directly. They were the bridge while the plugins were written; the plugins are the product now,
|
||||
//! and the scanners are gone. What survives them is deliberate: the entry model here, the whole of
|
||||
//! `launch.rs` (a plugin publishes a validated *value*, the host builds the command — design D1),
|
||||
//! and the source toggles in `scanners.rs`, whose ids match the claims by construction so an
|
||||
//! operator's disabled state carried across the extraction untouched.
|
||||
//!
|
||||
//! Artwork rides on the entries themselves — a plugin supplies URLs or local files, and the host's
|
||||
//! art proxy serves the local ones ([`art`]) so a client never receives an unreachable `C:\…` path.
|
||||
//!
|
||||
//! This module is read-mostly metadata; *launching* a chosen title (mapping [`LaunchSpec`] onto a
|
||||
//! gamescope session) is a later step — the launch hint is carried here so that wiring is trivial.
|
||||
//! gamescope session) is `launch.rs`.
|
||||
|
||||
// Shared vocabulary re-exported to the submodules (each is `use super::*`).
|
||||
pub(crate) use anyhow::{Context, Result};
|
||||
@@ -23,40 +33,18 @@ pub(crate) use utoipa::ToSchema;
|
||||
mod art;
|
||||
mod custom;
|
||||
mod detect;
|
||||
#[cfg(windows)]
|
||||
mod epic;
|
||||
#[cfg(windows)]
|
||||
mod gog;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod heroic;
|
||||
mod hidden;
|
||||
mod launch;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod lutris;
|
||||
mod plugin_launch;
|
||||
mod scanners;
|
||||
mod steam;
|
||||
#[cfg(windows)]
|
||||
mod xbox;
|
||||
|
||||
pub use art::*;
|
||||
pub use custom::*;
|
||||
pub use detect::*;
|
||||
#[cfg(windows)]
|
||||
pub use epic::*;
|
||||
#[cfg(windows)]
|
||||
pub use gog::*;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use heroic::*;
|
||||
pub use hidden::*;
|
||||
pub use launch::*;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use lutris::*;
|
||||
pub use plugin_launch::*;
|
||||
pub use scanners::*;
|
||||
pub use steam::*;
|
||||
#[cfg(windows)]
|
||||
pub use xbox::*;
|
||||
|
||||
/// Cover art for a title. All fields are URLs (the Steam CDN for Steam titles, user-supplied for
|
||||
/// custom). The client prefers `portrait` for a grid and falls back to `header` when a title has
|
||||
@@ -129,17 +117,6 @@ pub struct GameMeta {
|
||||
pub players: Option<u8>,
|
||||
}
|
||||
|
||||
impl GameMeta {
|
||||
/// The one field an installed-store scanner can assert about its own titles: they run on this
|
||||
/// host, i.e. on a PC. Everything else stays absent (the launchers' local files don't carry it).
|
||||
pub(crate) fn pc() -> Self {
|
||||
GameMeta {
|
||||
platform: Some("PC".into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a library entry *is* — an ordinary title, or the launcher application itself (Steam Big
|
||||
/// Picture, Heroic, Playnite fullscreen). Purely a presentation hint: a launcher entry launches,
|
||||
/// leases and lists exactly like a game (design D4), and clients that don't know the field render it
|
||||
@@ -233,9 +210,9 @@ pub struct GameEntry {
|
||||
/// How the host would launch it, when known.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub launch: Option<LaunchSpec>,
|
||||
/// The external provider owning this entry (custom-store entries synced by a provider
|
||||
/// plugin, RFC §8) — `None` for installed-store titles and manual custom entries. The
|
||||
/// console uses it for attribution; `GET /library?provider=` filters on it.
|
||||
/// The external provider owning this entry (entries synced by a provider plugin, RFC §8) —
|
||||
/// `None` only for the manual entries the operator typed in. The console uses it for
|
||||
/// attribution; `GET /library?provider=` filters on it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provider: Option<String>,
|
||||
/// How to recognize this title's process(es) once it is running ([`DetectSpec`]) — filled in by
|
||||
@@ -278,21 +255,8 @@ fn is_not_hidden(hidden: &bool) -> bool {
|
||||
!*hidden
|
||||
}
|
||||
|
||||
/// A store that contributes titles to the library. The trait is the extension point for future
|
||||
/// launchers; today only [`SteamProvider`] implements it.
|
||||
pub trait LibraryProvider {
|
||||
/// Stable store id (`"steam"`, …).
|
||||
fn store(&self) -> &'static str;
|
||||
/// Enumerate installed/owned titles. Best-effort: returns empty (not an error) when the store
|
||||
/// isn't present, so one missing launcher never fails the whole library.
|
||||
fn list(&self) -> Vec<GameEntry>;
|
||||
}
|
||||
|
||||
/// Steam art, keyed to one of the four [`Artwork`] fields. Newer/recently-updated titles serve
|
||||
/// their CDN assets from a per-asset-hash path the client can't predict (e.g.
|
||||
/// `.../apps/<id>/<hash>/header.jpg`), so the flat legacy URL [`steam_art`] guesses 404s for them —
|
||||
/// [`steam_art_bytes`] is the robust resolver: local Steam cache (exact, no guessing) first, the
|
||||
/// flat CDN URL as a fallback (still correct for the many titles that haven't been re-hashed).
|
||||
/// Which of the four [`Artwork`] fields an art request names — the `<kind>` in
|
||||
/// `GET /library/art/<id>/<kind>`, and the preference order the GameStream cover proxy walks.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ArtKind {
|
||||
Portrait,
|
||||
@@ -311,51 +275,26 @@ impl ArtKind {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Filenames Steam itself caches this kind under in `appcache/librarycache/<appid>/<hash>/`,
|
||||
/// tried in order (the 2x portrait, when present, is the sharper asset).
|
||||
fn local_filenames(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::Portrait => &["library_600x900_2x.jpg", "library_600x900.jpg"],
|
||||
Self::Hero => &["library_hero.jpg"],
|
||||
Self::Logo => &["logo.png"],
|
||||
// Steam's local cache names the header asset differently from the store CDN's
|
||||
// `header.jpg` (see `cdn_filename`).
|
||||
Self::Header => &["library_header.jpg"],
|
||||
}
|
||||
}
|
||||
|
||||
/// The legacy flat-URL filename on the public Steam CDN (works for any title the CDN hasn't
|
||||
/// migrated to a per-asset hash path).
|
||||
fn cdn_filename(self) -> &'static str {
|
||||
match self {
|
||||
Self::Portrait => "library_600x900.jpg",
|
||||
Self::Hero => "library_hero.jpg",
|
||||
Self::Logo => "logo.png",
|
||||
Self::Header => "header.jpg",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The full library: every *enabled* source's titles merged + the custom entries, sorted by title.
|
||||
/// The full library: every *enabled* source's titles, sorted by title.
|
||||
///
|
||||
/// Two independent gates run here, both at READ time so neither ever mutates stored state:
|
||||
/// Two gates run here, both at READ time so neither ever mutates stored state:
|
||||
///
|
||||
/// * **The operator's source toggles** (`scanners.rs`, persisted as a disabled-set in
|
||||
/// `library-scanners.json`) hide a source's titles from every surface — this grid, native clients,
|
||||
/// `/applist`, and launch resolution. They apply to built-in scanners *and* to plugin sources,
|
||||
/// which is what lets one toggle keep working verbatim across the whole migration: the ids match
|
||||
/// (provider id = claimed store id = old scanner id).
|
||||
/// * **Store claims** (D2): while a library plugin holds a store's claim, the matching built-in
|
||||
/// scanner is skipped so the two never double-list the same titles during the bridge releases.
|
||||
/// Removing the plugin releases the claim and the built-in comes straight back.
|
||||
/// `/applist`, and launch resolution. The plugin may keep reconciling while its source is off; the
|
||||
/// entries stay stored and simply aren't surfaced.
|
||||
/// * **The operator's per-entry hides** (`hidden.rs`), applied here rather than at each call site so
|
||||
/// a hidden title is gone from every surface by construction. [`all_games_for_operator`] is the
|
||||
/// single deliberate exception.
|
||||
///
|
||||
/// The user-curated custom store is not a source and always contributes.
|
||||
/// Manual custom entries — the ones the operator typed in — carry no source and always contribute.
|
||||
///
|
||||
/// A **third** gate rides on top of these two: the operator's per-entry hides (`hidden.rs`). It is
|
||||
/// applied here rather than at each call site so a hidden title is gone from every surface by
|
||||
/// construction — the grid, native clients, `/applist`, and launch resolution — exactly as a
|
||||
/// disabled source's titles are. [`all_games_for_operator`] is the single deliberate exception.
|
||||
/// There is no longer a third gate. Store claims used to suppress the built-in scanner a plugin had
|
||||
/// taken over; with the built-ins gone there is nothing left to suppress, so a claim now only fixes
|
||||
/// the ids a provider's entries surface under (`custom.rs::library_id_for`) and names its row in the
|
||||
/// sources list.
|
||||
pub fn all_games() -> Vec<GameEntry> {
|
||||
let hidden = hidden_ids();
|
||||
let mut games = collect_games();
|
||||
@@ -385,47 +324,12 @@ pub fn all_games_for_operator() -> Vec<OperatorGameEntry> {
|
||||
/// the hidden set, never in what they collect.
|
||||
fn collect_games() -> Vec<GameEntry> {
|
||||
let off = disabled_scanners();
|
||||
let claimed = claimed_stores();
|
||||
// A built-in scanner runs when the operator hasn't disabled it AND no plugin has claimed its
|
||||
// store out from under it.
|
||||
let on = |id: &str| !off.contains(id) && !claimed.contains_key(id);
|
||||
let mut games = Vec::new();
|
||||
if on("steam") {
|
||||
games.extend(SteamProvider.list());
|
||||
}
|
||||
// The Lutris + Heroic providers are Linux-only (their launchers are); on other hosts the library
|
||||
// is Steam + custom. Each provider is best-effort (empty when its store isn't present).
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if on("lutris") {
|
||||
games.extend(LutrisProvider.list());
|
||||
}
|
||||
if on("heroic") {
|
||||
games.extend(HeroicProvider.list());
|
||||
}
|
||||
}
|
||||
// Windows store providers (their launchers are Windows-only): Epic + GOG + Xbox/Game Pass.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if on("epic") {
|
||||
games.extend(EpicProvider.list());
|
||||
}
|
||||
if on("gog") {
|
||||
games.extend(GogProvider.list());
|
||||
}
|
||||
if on("xbox") {
|
||||
games.extend(XboxProvider.list());
|
||||
}
|
||||
}
|
||||
// Stored entries: manual ones always contribute; a provider's are subject to the same source
|
||||
// toggle a built-in scanner is (WP2.6). The plugin may keep reconciling while it is off — the
|
||||
// entries stay stored and simply aren't surfaced, exactly like a disabled scanner's titles.
|
||||
games.extend(
|
||||
load_custom()
|
||||
.into_iter()
|
||||
.filter(|e| !source_id_for(e).is_some_and(|src| off.contains(src)))
|
||||
.map(GameEntry::from),
|
||||
);
|
||||
// Manual entries always contribute; a provider's are subject to the operator's source toggle.
|
||||
let mut games: Vec<GameEntry> = load_custom()
|
||||
.into_iter()
|
||||
.filter(|e| !source_id_for(e).is_some_and(|src| off.contains(src)))
|
||||
.map(GameEntry::from)
|
||||
.collect();
|
||||
games.sort_by_key(|g| g.title.to_lowercase());
|
||||
games
|
||||
}
|
||||
|
||||
@@ -1,109 +1,15 @@
|
||||
//! Artwork cache + background warmer: the on-disk poster cache, the per-store fetchers, and the
|
||||
//! Artwork serving: the local-file confinement rules, the art-proxy rewrite, and the
|
||||
//! `fetch_box_art` dispatch the management art proxy serves from. Split out of the `library` facade (plan §W5).
|
||||
//!
|
||||
//! There is no art *cache* or background *warmer* here any more. Both existed for the built-in GOG
|
||||
//! and Xbox scanners, the only two sources that had to reach a network catalog to learn what a
|
||||
//! title's cover was; every other source carried its own art. Those scanners were removed in
|
||||
//! v0.28.0, and the library plugins that replaced them resolve art while they scan and publish it on
|
||||
//! the entry — so the host now only ever *serves* art it was handed, and never fetches any on its
|
||||
//! own schedule. A stale `library-art-cache.json` left by an older host is simply ignored.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The persisted art cache: GameEntry id → resolved [`Artwork`]. An entry's PRESENCE means "already
|
||||
/// resolved" (even an empty Artwork = fetched, none found) so the warmer never re-fetches it.
|
||||
fn art_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, Artwork>> {
|
||||
static CACHE: std::sync::OnceLock<
|
||||
std::sync::Mutex<std::collections::HashMap<String, Artwork>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
CACHE.get_or_init(|| {
|
||||
let loaded = std::fs::read_to_string(art_cache_path())
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
std::sync::Mutex::new(loaded)
|
||||
})
|
||||
}
|
||||
|
||||
/// The art cache lives in the canonical HOST config dir (`%ProgramData%\punktfunk` on Windows /
|
||||
/// `~/.config/punktfunk` on Linux — `pf_paths::config_dir`, NOT the legacy XDG/HOME `config_dir`
|
||||
/// below that the custom store still uses).
|
||||
fn art_cache_path() -> PathBuf {
|
||||
pf_paths::config_dir().join("library-art-cache.json")
|
||||
}
|
||||
|
||||
/// The cached art for a library id, if it has been resolved (positive or negative). `None` = not yet
|
||||
/// warmed → the provider shows title-only until the warmer fills it in.
|
||||
pub(crate) fn cached_art(id: &str) -> Option<Artwork> {
|
||||
art_cache().lock().unwrap().get(id).cloned()
|
||||
}
|
||||
|
||||
/// Record resolved art for a library id + persist the cache (write-then-rename; best-effort).
|
||||
fn store_art(id: &str, art: Artwork) {
|
||||
let mut cache = art_cache().lock().unwrap();
|
||||
cache.insert(id.to_string(), art);
|
||||
if let Ok(json) = serde_json::to_string(&*cache) {
|
||||
let path = art_cache_path();
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
if std::fs::write(&tmp, json).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, &path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the host-lifetime cover-art warmer: every few minutes, fetch + cache art for any library
|
||||
/// entry whose store needs a network lookup (GOG / Xbox) and isn't cached yet. Idempotent — once
|
||||
/// everything is cached a pass makes no network calls (and a host with only self-art stores never
|
||||
/// fetches at all). Call once from `serve()`; the returned handle can be dropped to detach it.
|
||||
pub fn start_art_warmer() -> std::thread::JoinHandle<()> {
|
||||
std::thread::Builder::new()
|
||||
.name("pf-art-warmer".into())
|
||||
.spawn(|| loop {
|
||||
warm_art_once();
|
||||
std::thread::sleep(std::time::Duration::from_secs(300));
|
||||
})
|
||||
.expect("spawn art warmer thread")
|
||||
}
|
||||
|
||||
/// One warming pass: resolve uncached GOG/Xbox art. Other stores carry their own art (Steam CDN
|
||||
/// template, Heroic CDN URLs, Lutris data: URLs, custom user URLs) and are skipped.
|
||||
fn warm_art_once() {
|
||||
for g in all_games() {
|
||||
if cached_art(&g.id).is_some() {
|
||||
continue;
|
||||
}
|
||||
let Some((store, localid)) = g.id.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let art = match store {
|
||||
"gog" => fetch_gog_art(localid),
|
||||
// The xbox id is the StoreId when present, else the PFN (contains '_', no displaycatalog
|
||||
// entry) → cache empty for those so they aren't retried every pass.
|
||||
"xbox" if !localid.contains('_') => fetch_xbox_art(localid),
|
||||
"xbox" => Artwork::default(),
|
||||
_ => continue, // steam/heroic/lutris/custom resolve their own art
|
||||
};
|
||||
store_art(&g.id, art);
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP GET + parse JSON with a bounded timeout. `None` on any network/parse failure (best-effort —
|
||||
/// art is non-essential, so a failure just leaves the title-only card).
|
||||
fn fetch_json(url: &str) -> Option<serde_json::Value> {
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(std::time::Duration::from_secs(10)))
|
||||
// Don't follow redirects — a redirect target (`3xx` → `http://169.254.169.254/…` or an
|
||||
// internal host) would be an SSRF pivot from the privileged host. Matches the webhook path
|
||||
// (security-review 2026-07-17). A rare legitimately-redirecting CDN just yields no art.
|
||||
.max_redirects(0)
|
||||
.build()
|
||||
.into();
|
||||
let body = agent
|
||||
.get(url)
|
||||
.call()
|
||||
.ok()?
|
||||
.body_mut()
|
||||
.read_to_string()
|
||||
.ok()?;
|
||||
serde_json::from_str(&body).ok()
|
||||
}
|
||||
|
||||
/// Fetch one image URL for the GameStream `/appasset` cover proxy, as `(bytes, content-type)`. Handles
|
||||
/// `data:` URLs (Lutris inlines art that way) by decoding inline, and `http(s)` URLs by a bounded GET
|
||||
/// (8 MiB cap so a hostile/huge art URL can't balloon host memory). `None` on any non-image scheme,
|
||||
@@ -169,11 +75,11 @@ pub(crate) fn fetch_image(url: &str) -> Option<(Vec<u8>, String)> {
|
||||
/// Playnite back-compat (it predates the `file://` contract).
|
||||
/// * POSIX absolute (`/home/u/covers/x.jpg`) — Lutris covers and Steam's `librarycache`.
|
||||
///
|
||||
/// The POSIX widening is why the two `/`-leading shapes the **host itself emits** must be excluded
|
||||
/// explicitly: its own art-proxy path (`/api/v1/library/art/…`, which [`proxy_local_art`] writes and
|
||||
/// which must survive a second pass unchanged) and a protocol-relative URL (`//cdn/…`, what GOG's and
|
||||
/// Microsoft's catalogs return — see [`abs_url`]). Mistaking either for a file would break the proxy
|
||||
/// round-trip or silently drop CDN art.
|
||||
/// The POSIX widening is why the two `/`-leading shapes must be excluded explicitly: the host's own
|
||||
/// art-proxy path (`/api/v1/library/art/…`, which [`proxy_local_art`] writes and which must survive a
|
||||
/// second pass unchanged) and a protocol-relative URL (`//cdn/…`, which GOG's and Microsoft's
|
||||
/// catalogs return and a plugin may pass straight through). Mistaking either for a file would break
|
||||
/// the proxy round-trip or silently drop CDN art.
|
||||
pub fn is_local_art_path(v: &str) -> bool {
|
||||
if v.starts_with("http://") || v.starts_with("https://") || v.starts_with("data:") {
|
||||
return false;
|
||||
@@ -475,107 +381,20 @@ pub fn proxy_local_art(id: &str, art: &mut Artwork) {
|
||||
/// `(bytes, content-type)`. Resolves the id against the host's OWN library. Blocking — call off the
|
||||
/// async runtime (e.g. `spawn_blocking`).
|
||||
pub fn fetch_box_art(id: &str) -> Option<(Vec<u8>, String)> {
|
||||
// Same resolution order as the management art proxy (WP1.2): the stored catalog first, for ANY
|
||||
// id, so a library plugin's entries resolve without the warmer knowing its store.
|
||||
if let Some(entry) = entry_for_library_id(id) {
|
||||
return [
|
||||
ArtKind::Portrait,
|
||||
ArtKind::Header,
|
||||
ArtKind::Hero,
|
||||
ArtKind::Logo,
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|kind| art_field(&entry.art, kind))
|
||||
.find_map(|v| resolve_art_bytes(&v));
|
||||
}
|
||||
// Legacy in-host Steam scanner: its `Artwork` fields are relative proxy paths (see `steam_art`)
|
||||
// the *client* resolves against the host — meaningless to `fetch_image`, which expects an
|
||||
// absolute URL. Resolve those kinds directly instead of going through the URL fields.
|
||||
if let Some(appid) = id
|
||||
.strip_prefix("steam:")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
{
|
||||
return [
|
||||
ArtKind::Portrait,
|
||||
ArtKind::Header,
|
||||
ArtKind::Hero,
|
||||
ArtKind::Logo,
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|kind| steam_art_bytes(appid, kind));
|
||||
}
|
||||
// The remaining in-host scanners (heroic/lutris/epic/gog/xbox) carry absolute CDN URLs.
|
||||
let g = all_games().into_iter().find(|g| g.id == id)?;
|
||||
[g.art.portrait, g.art.header, g.art.hero, g.art.logo]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find_map(|url| resolve_art_bytes(&url))
|
||||
}
|
||||
|
||||
/// Make a protocol-relative URL (`//host/...`, common in GOG + MS catalog responses) absolute https.
|
||||
fn abs_url(u: &str) -> String {
|
||||
u.strip_prefix("//")
|
||||
.map(|rest| format!("https://{rest}"))
|
||||
.unwrap_or_else(|| u.to_string())
|
||||
}
|
||||
|
||||
/// GOG cover art via the public (no-auth) product API. Field names / URL shapes are GOG-specific and
|
||||
/// best-effort (worth on-box confirmation); a wrong URL just degrades to the title card client-side.
|
||||
fn fetch_gog_art(product_id: &str) -> Artwork {
|
||||
let Some(v) = fetch_json(&format!(
|
||||
"https://api.gog.com/products/{product_id}?expand=images"
|
||||
)) else {
|
||||
return Artwork::default();
|
||||
};
|
||||
let img = |k: &str| {
|
||||
v.get("images")
|
||||
.and_then(|i| i.get(k))
|
||||
.and_then(|u| u.as_str())
|
||||
.map(abs_url)
|
||||
};
|
||||
Artwork {
|
||||
portrait: img("verticalCover"),
|
||||
hero: img("background"),
|
||||
logo: img("logo2x"),
|
||||
header: img("logo"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Xbox cover art via the (unofficial, no-auth) Microsoft display catalog, keyed by StoreId. Best-
|
||||
/// effort: the endpoint is internal/unstable, so on drift this just yields no art (title-only).
|
||||
fn fetch_xbox_art(store_id: &str) -> Artwork {
|
||||
let Some(v) = fetch_json(&format!(
|
||||
"https://displaycatalog.mp.microsoft.com/v7.0/products/{store_id}?market=US&languages=en-us&fieldsTemplate=Details"
|
||||
)) else {
|
||||
return Artwork::default();
|
||||
};
|
||||
let images = v
|
||||
.get("Products")
|
||||
.and_then(|p| p.as_array())
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|p| p.get("LocalizedProperties"))
|
||||
.and_then(|l| l.as_array())
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|lp| lp.get("Images"))
|
||||
.and_then(|i| i.as_array());
|
||||
let mut art = Artwork::default();
|
||||
for img in images.into_iter().flatten() {
|
||||
let (Some(purpose), Some(uri)) = (
|
||||
img.get("ImagePurpose").and_then(|v| v.as_str()),
|
||||
img.get("Uri").and_then(|v| v.as_str()),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let url = abs_url(uri);
|
||||
match purpose {
|
||||
"Poster" => art.portrait = Some(url),
|
||||
"SuperHeroArt" | "Hero" => art.hero = Some(url),
|
||||
"Logo" => art.logo = Some(url),
|
||||
"BoxArt" => art.header = Some(url),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
art
|
||||
// Same resolution as the management art proxy (WP1.2): the stored catalog, for ANY id, so a
|
||||
// library plugin's entries resolve without this ever knowing which store they came from. That
|
||||
// used to be the first of three branches — the other two served the built-in scanners (a
|
||||
// `steam:` id whose art was a relative proxy path, and the CDN-URL scanners) and went with them.
|
||||
let entry = entry_for_library_id(id)?;
|
||||
[
|
||||
ArtKind::Portrait,
|
||||
ArtKind::Header,
|
||||
ArtKind::Hero,
|
||||
ArtKind::Logo,
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|kind| art_field(&entry.art, kind))
|
||||
.find_map(|v| resolve_art_bytes(&v))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -627,7 +446,7 @@ mod tests {
|
||||
"/api/v1/library/art/custom:abc/portrait"
|
||||
));
|
||||
assert!(!is_local_art_path("/api/v1/library/art/steam:570/hero"));
|
||||
// …nor a protocol-relative CDN URL (what GOG / the MS catalog return — see `abs_url`).
|
||||
// …nor a protocol-relative CDN URL (what GOG / the MS catalog return).
|
||||
assert!(!is_local_art_path("//images.gog.com/abc_vertical.jpg"));
|
||||
// A relative path is not absolute — nothing to serve.
|
||||
assert!(!is_local_art_path("covers/x.jpg"));
|
||||
|
||||
@@ -166,7 +166,7 @@ pub struct Catalog {
|
||||
///
|
||||
/// The map — not the entries — is the authority for a claim, which is exactly why it survives an
|
||||
/// **empty reconcile**: a store the plugin legitimately owns can have zero installed titles, and
|
||||
/// the built-in scanner it suppresses must stay suppressed anyway. Releasing is explicit
|
||||
/// it must keep owning the store's id space regardless. Releasing is explicit
|
||||
/// (`DELETE /library/provider/{p}`, or the plugin claiming a different store).
|
||||
#[serde(default)]
|
||||
pub claims: BTreeMap<String, String>,
|
||||
@@ -573,7 +573,7 @@ fn reconcile_entries(
|
||||
///
|
||||
/// Claiming is idempotent for the holder and refused for anyone else. A provider holds at most one
|
||||
/// store, so claiming a new one releases whatever it held before — otherwise an abandoned claim would
|
||||
/// go on suppressing a built-in scanner with nothing to replace it.
|
||||
/// go on holding a store id that no plugin is filling any more, locking out the next claimant.
|
||||
pub fn reconcile_provider(
|
||||
provider: &str,
|
||||
store: Option<&str>,
|
||||
@@ -712,8 +712,9 @@ mod tests {
|
||||
assert_eq!(g.meta.platform.as_deref(), Some("PS2"));
|
||||
}
|
||||
|
||||
/// D2's core promise: a **claimed** entry is indistinguishable from what the built-in scanner
|
||||
/// produced. Same id, same store badge — plus the provider attribution the scanner never had.
|
||||
/// D2's core promise, and the reason removing the built-in scanners was invisible downstream: a
|
||||
/// **claimed** entry is indistinguishable from what the scanner produced. Same id, same store
|
||||
/// badge — plus the provider attribution the scanner never had.
|
||||
#[test]
|
||||
fn a_claimed_entry_reproduces_the_scanner_identity() {
|
||||
let mut e = manual("host-assigned", "Portal 2");
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
//! Epic Games Store provider: installed manifests + the catalog-cache art index + launch URIs. Split out of the `library` facade (plan §W5).
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Reads the Epic Games Launcher's local install manifests. Windows-only. Best-effort: empty when
|
||||
/// the launcher (or its manifest dir) isn't present.
|
||||
#[cfg(windows)]
|
||||
pub struct EpicProvider;
|
||||
|
||||
#[cfg(windows)]
|
||||
impl LibraryProvider for EpicProvider {
|
||||
fn store(&self) -> &'static str {
|
||||
"epic"
|
||||
}
|
||||
|
||||
fn list(&self) -> Vec<GameEntry> {
|
||||
let data = epic_data_dir();
|
||||
let Ok(rd) = std::fs::read_dir(data.join("Manifests")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
// Parse the (best-effort) artwork cache ONCE: catalogItemId -> Artwork.
|
||||
let art = epic_art_index(&data.join("Catalog").join("catcache.bin"));
|
||||
let mut games = Vec::new();
|
||||
for entry in rd.flatten() {
|
||||
let p = entry.path();
|
||||
if p.extension().and_then(|e| e.to_str()) != Some("item") {
|
||||
continue;
|
||||
}
|
||||
// `.item` manifests are small JSON; cap the read so a planted giant can't OOM the host.
|
||||
let Some(bytes) = read_capped(&p, 1024 * 1024) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(g) = epic_entry(&v, &art) {
|
||||
games.push(g);
|
||||
}
|
||||
}
|
||||
games
|
||||
}
|
||||
}
|
||||
|
||||
/// `%ProgramData%\Epic\EpicGamesLauncher\Data` (machine-wide, SYSTEM-readable).
|
||||
#[cfg(windows)]
|
||||
fn epic_data_dir() -> PathBuf {
|
||||
std::env::var_os("ProgramData")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData"))
|
||||
.join("Epic")
|
||||
.join("EpicGamesLauncher")
|
||||
.join("Data")
|
||||
}
|
||||
|
||||
/// Map one `.item` manifest to a [`GameEntry`], or `None` if it isn't a launchable game. Uses
|
||||
/// Playnite's proven EXCLUSION filter (skip `UE_*` Unreal components; skip a DLC/addon unless it is
|
||||
/// `addons/launchable`) rather than a positive `games`-category match, which can drop legit titles.
|
||||
#[cfg(windows)]
|
||||
fn epic_entry(
|
||||
v: &serde_json::Value,
|
||||
art: &std::collections::HashMap<String, Artwork>,
|
||||
) -> Option<GameEntry> {
|
||||
let s = |k: &str| v.get(k).and_then(|x| x.as_str());
|
||||
let app_name = s("AppName")?.to_string();
|
||||
if app_name.starts_with("UE_") {
|
||||
return None; // Unreal Engine component, not a game
|
||||
}
|
||||
let cats: Vec<&str> = v
|
||||
.get("AppCategories")
|
||||
.and_then(|c| c.as_array())
|
||||
.map(|a| a.iter().filter_map(|x| x.as_str()).collect())
|
||||
.unwrap_or_default();
|
||||
if cats.contains(&"addons") && !cats.contains(&"addons/launchable") {
|
||||
return None; // non-launchable DLC/addon
|
||||
}
|
||||
// Drop stale records whose install dir is gone.
|
||||
let install = s("InstallLocation")?;
|
||||
if !Path::new(install).is_dir() {
|
||||
return None;
|
||||
}
|
||||
let title = s("DisplayName").unwrap_or(&app_name).to_string();
|
||||
let namespace = s("CatalogNamespace").unwrap_or("");
|
||||
let catalog = s("CatalogItemId").unwrap_or("");
|
||||
// The robust launch form is the namespace:catalogItemId:appName triple; fall back to the bare
|
||||
// appName when those ids are absent (some manifests lack them) — never drop the launch entirely.
|
||||
let value = if !namespace.is_empty() && !catalog.is_empty() {
|
||||
format!("{namespace}:{catalog}:{app_name}")
|
||||
} else {
|
||||
app_name.clone()
|
||||
};
|
||||
// Detect signals: the manifest's own `LaunchExecutable` (relative to the install dir) is exact
|
||||
// when present; the install dir covers the rest (Epic hands off to its launcher, so the host
|
||||
// never owns the game's process).
|
||||
let detect = match s("LaunchExecutable")
|
||||
.map(|rel| Path::new(install).join(rel))
|
||||
.filter(|p| p.is_file())
|
||||
{
|
||||
Some(exe) => DetectSpec::exe(exe).with_dir(install),
|
||||
None => DetectSpec::dir(install),
|
||||
};
|
||||
Some(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
icon: None,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("epic:{app_name}"),
|
||||
store: "epic".into(),
|
||||
title,
|
||||
art: art.get(catalog).cloned().unwrap_or_default(),
|
||||
launch: Some(LaunchSpec {
|
||||
kind: "epic".into(),
|
||||
value,
|
||||
}),
|
||||
detect,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a launcher cache/manifest with a hard size cap, so a local unprivileged user can't plant a
|
||||
/// multi-GB file under the launcher's (Users-writable) data dir that OOMs the privileged host when
|
||||
/// it's loaded — then base64/JSON-decoded into further copies — during library enumeration
|
||||
/// (security-review 2026-06-28 S4). Returns `None` if missing, empty, or over `max`. Mirrors the
|
||||
/// Linux lutris-art reader's 1 MiB cap.
|
||||
#[cfg(windows)]
|
||||
fn read_capped(path: &Path, max: u64) -> Option<Vec<u8>> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
if meta.len() == 0 || meta.len() > max {
|
||||
if meta.len() > max {
|
||||
tracing::warn!(path = %path.display(), len = meta.len(), max, "launcher cache exceeds size cap — skipping");
|
||||
}
|
||||
return None;
|
||||
}
|
||||
std::fs::read(path).ok()
|
||||
}
|
||||
|
||||
/// Best-effort parse of `catcache.bin` (base64-encoded JSON array of catalog items) into
|
||||
/// catalogItemId → [`Artwork`] from each item's `keyImages`. Empty map on any read/decode failure
|
||||
/// (the format is community-reverse-engineered + can lag a fresh install → titles just show no art).
|
||||
#[cfg(windows)]
|
||||
fn epic_art_index(catcache: &Path) -> std::collections::HashMap<String, Artwork> {
|
||||
use base64::Engine as _;
|
||||
let mut map = std::collections::HashMap::new();
|
||||
// 32 MiB cap: comfortably fits a real catalog cache, blocks a planted giant (S4).
|
||||
let Some(raw) = read_capped(catcache, 32 * 1024 * 1024) else {
|
||||
return map;
|
||||
};
|
||||
let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(raw) else {
|
||||
return map;
|
||||
};
|
||||
let Ok(items) = serde_json::from_slice::<serde_json::Value>(&decoded) else {
|
||||
return map;
|
||||
};
|
||||
let Some(arr) = items.as_array() else {
|
||||
return map;
|
||||
};
|
||||
for item in arr {
|
||||
let Some(cat) = item
|
||||
.get("id")
|
||||
.or_else(|| item.get("catalogItemId"))
|
||||
.and_then(|v| v.as_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(images) = item.get("keyImages").and_then(|v| v.as_array()) else {
|
||||
continue;
|
||||
};
|
||||
let mut art = Artwork::default();
|
||||
for img in images {
|
||||
let (Some(ty), Some(url)) = (
|
||||
img.get("type").and_then(|v| v.as_str()),
|
||||
img.get("url").and_then(|v| v.as_str()),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
if !(url.starts_with("http://") || url.starts_with("https://")) {
|
||||
continue;
|
||||
}
|
||||
match ty {
|
||||
"DieselGameBoxTall" => art.portrait = Some(url.to_string()),
|
||||
"DieselGameBox" => art.hero = Some(url.to_string()),
|
||||
"DieselGameBoxLogo" => art.logo = Some(url.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if art.portrait.is_some() || art.hero.is_some() || art.logo.is_some() {
|
||||
map.insert(cat.to_string(), art);
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
// The `epic` launch mapping (`epic_launch_uri`) lives in `launch.rs` (WP1.1) — this module
|
||||
// enumerates, it does not launch.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn epic_filters_and_builds_launch() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-epic-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let inst = dir.to_string_lossy().into_owned();
|
||||
let empty = std::collections::HashMap::new();
|
||||
// Normal game with the full triple → kept, triple launch value.
|
||||
let game = serde_json::json!({
|
||||
"AppName": "Fortnite", "DisplayName": "Fortnite", "CatalogNamespace": "fn",
|
||||
"CatalogItemId": "abc123", "InstallLocation": inst.clone(),
|
||||
"AppCategories": ["public", "games", "applications"]
|
||||
});
|
||||
let e = epic_entry(&game, &empty).expect("game kept");
|
||||
assert_eq!(e.id, "epic:Fortnite");
|
||||
assert_eq!(e.launch.as_ref().unwrap().value, "fn:abc123:Fortnite");
|
||||
// UE component, non-launchable addon, and a missing install dir are all skipped.
|
||||
let ue = serde_json::json!({"AppName":"UE_5.3","InstallLocation":inst.clone(),"AppCategories":["engines"]});
|
||||
assert!(epic_entry(&ue, &empty).is_none());
|
||||
let dlc =
|
||||
serde_json::json!({"AppName":"DLC","InstallLocation":inst,"AppCategories":["addons"]});
|
||||
assert!(epic_entry(&dlc, &empty).is_none());
|
||||
let gone = serde_json::json!({"AppName":"Gone","InstallLocation":"C:\\nope-xyz","AppCategories":["games"]});
|
||||
assert!(epic_entry(&gone, &empty).is_none());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
//! GOG Galaxy store provider: installed games from the Galaxy DB + play-task launch resolution. Split out of the `library` facade (plan §W5).
|
||||
|
||||
use super::art::cached_art;
|
||||
use super::*;
|
||||
|
||||
/// Reads the GOG.com install registry + per-game `.info` files. Windows-only. Best-effort: empty
|
||||
/// when GOG isn't installed.
|
||||
#[cfg(windows)]
|
||||
pub struct GogProvider;
|
||||
|
||||
#[cfg(windows)]
|
||||
impl LibraryProvider for GogProvider {
|
||||
fn store(&self) -> &'static str {
|
||||
"gog"
|
||||
}
|
||||
|
||||
fn list(&self) -> Vec<GameEntry> {
|
||||
gog_games()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn gog_games() -> Vec<GameEntry> {
|
||||
use winreg::enums::HKEY_LOCAL_MACHINE;
|
||||
use winreg::RegKey;
|
||||
// 32-bit GOG writes under WOW6432Node; a 64-bit process reads the explicit path directly.
|
||||
let Ok(games_key) =
|
||||
RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey("SOFTWARE\\WOW6432Node\\GOG.com\\Games")
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for sub in games_key.enum_keys().flatten() {
|
||||
// The subkey name IS the GOG product id.
|
||||
let Ok(k) = games_key.open_subkey(&sub) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(path) = k.get_value::<String, _>("PATH") else {
|
||||
continue;
|
||||
};
|
||||
if !Path::new(&path).is_dir() {
|
||||
continue;
|
||||
}
|
||||
let title = k
|
||||
.get_value::<String, _>("GAMENAME")
|
||||
.unwrap_or_else(|_| sub.clone());
|
||||
// Resolve the primary play task (exe + args + workdir) from goggame-<id>.info; skip if absent.
|
||||
let Some((exe, args, workdir)) = gog_play_task(&path, &sub) else {
|
||||
continue;
|
||||
};
|
||||
let id = format!("gog:{sub}");
|
||||
// Art (public api.gog.com) is resolved off the hot path by the background warmer; read
|
||||
// whatever it has cached (title-only until warmed).
|
||||
let art = cached_art(&id).unwrap_or_default();
|
||||
// GOG launches the game's exe directly (no Galaxy), so the host owns the process and the
|
||||
// spec is only the fallback for a stub launcher that hands off; both signals are exact here.
|
||||
let detect = DetectSpec::exe(&exe).with_dir(&path);
|
||||
out.push(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
icon: None,
|
||||
meta: GameMeta::pc(),
|
||||
id,
|
||||
store: "gog".into(),
|
||||
title,
|
||||
art,
|
||||
launch: Some(LaunchSpec {
|
||||
kind: "gog".into(),
|
||||
value: format!("{exe}\t{args}\t{workdir}"),
|
||||
}),
|
||||
detect,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Join a manifest-supplied relative path onto `install`, rejecting anything that could escape (or
|
||||
/// replace) it: a drive prefix (`C:`), a root (`\`), or a `..` component — any of which `Path::join`
|
||||
/// lets REPLACE or climb out of `install` on Windows. Keeps a crafted `goggame-*.info` from pointing
|
||||
/// the play task's exe or working dir at an arbitrary program (security-review 2026-07-17). `None`
|
||||
/// ⇒ the path is out of bounds and the caller refuses it.
|
||||
#[cfg(windows)]
|
||||
fn confined_join(install: &str, rel: &str) -> Option<PathBuf> {
|
||||
use std::path::Component;
|
||||
let rp = Path::new(rel);
|
||||
if rp.components().any(|c| {
|
||||
matches!(
|
||||
c,
|
||||
Component::Prefix(_) | Component::RootDir | Component::ParentDir
|
||||
)
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
Some(Path::new(install).join(rp))
|
||||
}
|
||||
|
||||
/// The primary play task from `<install>\goggame-<id>.info`: `(absolute exe, args, working dir)`.
|
||||
/// Prefers `isPrimary` + `FileTask`, else the first `FileTask`. Paths are resolved against `install`
|
||||
/// and confined to it ([`confined_join`]).
|
||||
#[cfg(windows)]
|
||||
fn gog_play_task(install: &str, id: &str) -> Option<(String, String, String)> {
|
||||
let text =
|
||||
std::fs::read_to_string(Path::new(install).join(format!("goggame-{id}.info"))).ok()?;
|
||||
let v: serde_json::Value = serde_json::from_str(&text).ok()?;
|
||||
let tasks = v.get("playTasks")?.as_array()?;
|
||||
let is_file =
|
||||
|t: &serde_json::Value| t.get("type").and_then(|s| s.as_str()) == Some("FileTask");
|
||||
let pick = tasks
|
||||
.iter()
|
||||
.find(|t| {
|
||||
t.get("isPrimary")
|
||||
.and_then(|b| b.as_bool())
|
||||
.unwrap_or(false)
|
||||
&& is_file(t)
|
||||
})
|
||||
.or_else(|| tasks.iter().find(|t| is_file(t)))?;
|
||||
let rel = pick.get("path").and_then(|s| s.as_str())?;
|
||||
// Refuse the launch outright if the manifest's exe path escapes the install dir.
|
||||
let exe = confined_join(install, rel)?;
|
||||
let args = pick
|
||||
.get("arguments")
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let workdir = pick
|
||||
.get("workingDir")
|
||||
.and_then(|s| s.as_str())
|
||||
// A working dir that escapes falls back to the install root (safe) rather than failing.
|
||||
.and_then(|w| confined_join(install, w))
|
||||
.unwrap_or_else(|| Path::new(install).to_path_buf());
|
||||
Some((
|
||||
exe.to_string_lossy().into_owned(),
|
||||
args,
|
||||
workdir.to_string_lossy().into_owned(),
|
||||
))
|
||||
}
|
||||
|
||||
// The `gog` launch mapping (`gog_spawn`) lives in `launch.rs` (WP1.1) — this module enumerates and
|
||||
// resolves the spawn triple off disk, but turning that triple into a command line is launch-side.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn gog_play_task_picks_primary_filetask() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-gog-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let id = "1207658924";
|
||||
std::fs::write(
|
||||
dir.join(format!("goggame-{id}.info")),
|
||||
r#"{"playTasks":[
|
||||
{"isPrimary":false,"type":"FileTask","path":"other.exe"},
|
||||
{"isPrimary":true,"type":"FileTask","path":"bin\\game.exe","arguments":"-w","workingDir":"bin"}
|
||||
]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let (exe, args, wd) = gog_play_task(&dir.to_string_lossy(), id).unwrap();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
assert!(exe.ends_with("bin\\game.exe"), "exe={exe}");
|
||||
assert_eq!(args, "-w");
|
||||
assert!(wd.ends_with("bin"), "wd={wd}");
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
//! Heroic (Epic/GOG) store provider: installed games from Heroic's JSON stores + CDN art. Split out of the `library` facade (plan §W5).
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Reads Heroic Games Launcher's local library cache. One provider surfaces all three of Heroic's
|
||||
/// backends (legendary=Epic, gog=GOG, nile=Amazon). Linux-only for now (Heroic on Windows uses a
|
||||
/// different config path and the launch path isn't wired there yet).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub struct HeroicProvider;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl LibraryProvider for HeroicProvider {
|
||||
fn store(&self) -> &'static str {
|
||||
"heroic"
|
||||
}
|
||||
|
||||
fn list(&self) -> Vec<GameEntry> {
|
||||
let Some(root) = heroic_root() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut games = Vec::new();
|
||||
// (cache file, runner id, the electron-store data key holding the games array)
|
||||
for (file, runner, key) in [
|
||||
("legendary_library.json", "legendary", "library"),
|
||||
("gog_library.json", "gog", "games"),
|
||||
("nile_library.json", "nile", "library"),
|
||||
] {
|
||||
let path = root.join("store_cache").join(file);
|
||||
match heroic_games(&path, runner, key) {
|
||||
Ok(mut g) => games.append(&mut g),
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, file, "heroic store_cache not read (store unused?)")
|
||||
}
|
||||
}
|
||||
}
|
||||
games
|
||||
}
|
||||
}
|
||||
|
||||
/// The first existing Heroic config root: `$XDG_CONFIG_HOME/heroic`, classic `~/.config/heroic`, or
|
||||
/// the Flatpak path.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn heroic_root() -> Option<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(d) = std::env::var_os("XDG_CONFIG_HOME") {
|
||||
candidates.push(PathBuf::from(d).join("heroic"));
|
||||
}
|
||||
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
|
||||
candidates.push(home.join(".config/heroic"));
|
||||
candidates.push(home.join(".var/app/com.heroicgameslauncher.hgl/config/heroic"));
|
||||
}
|
||||
candidates.into_iter().find(|p| p.is_dir())
|
||||
}
|
||||
|
||||
/// Parse one runner's `store_cache/*_library.json` (an electron-store object whose `key` holds the
|
||||
/// games array). Keeps only installed titles whose install dir still exists (the latter works around
|
||||
/// Heroic's gog `is_installed` bug, #2691). Art comes straight from the cached public CDN URLs.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<GameEntry>> {
|
||||
let raw = std::fs::read_to_string(path)?;
|
||||
let root: serde_json::Value = serde_json::from_str(&raw)?;
|
||||
let arr = root
|
||||
.get(key)
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| anyhow::anyhow!("no '{key}' array in {}", path.display()))?;
|
||||
let mut games = Vec::new();
|
||||
for g in arr {
|
||||
if !g
|
||||
.get("is_installed")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue; // the cache also lists owned-but-not-installed titles
|
||||
}
|
||||
// The install dir doubles as this title's detect signal (Heroic hands off to
|
||||
// legendary/gogdl/nile, so the host never sees the game's own process any other way).
|
||||
let install_path = g
|
||||
.get("install")
|
||||
.and_then(|i| i.get("install_path"))
|
||||
.and_then(|p| p.as_str())
|
||||
.filter(|p| Path::new(p).is_dir());
|
||||
let Some(install_path) = install_path else {
|
||||
continue;
|
||||
};
|
||||
let Some(app_name) = g
|
||||
.get("app_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let title = g
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(app_name)
|
||||
.to_string();
|
||||
// Only emit http(s) art (sideloaded titles can carry local file:// paths the client can't fetch).
|
||||
let http = |k: &str| {
|
||||
g.get(k)
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| s.starts_with("http://") || s.starts_with("https://"))
|
||||
.map(String::from)
|
||||
};
|
||||
let art = Artwork {
|
||||
portrait: http("art_square"),
|
||||
header: http("art_cover"),
|
||||
hero: http("art_background").or_else(|| http("art_cover")),
|
||||
logo: http("art_logo"),
|
||||
};
|
||||
games.push(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
icon: None,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("heroic:{runner}:{app_name}"),
|
||||
store: "heroic".into(),
|
||||
title,
|
||||
art,
|
||||
launch: Some(LaunchSpec {
|
||||
kind: "heroic".into(),
|
||||
value: format!("{runner}:{app_name}"),
|
||||
}),
|
||||
// The install dir is the reliable signal. `HEROIC_APP_NAME` is also stamped on the game's
|
||||
// env by Heroic's launch path; it is carried as a second, cheap signal (a union — if a
|
||||
// Heroic version doesn't set it, the install dir still matches).
|
||||
detect: DetectSpec::dir(install_path)
|
||||
.with_env("HEROIC_APP_NAME", Some(app_name.to_string())),
|
||||
});
|
||||
}
|
||||
Ok(games)
|
||||
}
|
||||
|
||||
// The `heroic` launch mapping (`heroic_command` + its launcher-prefix probe) lives in `launch.rs`
|
||||
// (WP1.1) — this module enumerates, it does not launch.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn heroic_games_parses_installed_with_cdn_art() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-heroic-test-{}", std::process::id()));
|
||||
let install = dir.join("game-install");
|
||||
std::fs::create_dir_all(&install).unwrap();
|
||||
let path = dir.join("legendary_library.json");
|
||||
let json = format!(
|
||||
r#"{{"library":[
|
||||
{{"app_name":"Quail","title":"Quail","is_installed":true,
|
||||
"install":{{"install_path":"{inst}"}},
|
||||
"art_square":"https://cdn/quail_tall.jpg","art_cover":"https://cdn/quail_wide.jpg",
|
||||
"art_logo":"file:///local/logo.png"}},
|
||||
{{"app_name":"Owned","title":"Owned Only","is_installed":false,
|
||||
"install":{{"install_path":"{inst}"}}}}
|
||||
]}}"#,
|
||||
inst = install.display()
|
||||
);
|
||||
std::fs::write(&path, json).unwrap();
|
||||
let games = heroic_games(&path, "legendary", "library").unwrap();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
assert_eq!(games.len(), 1); // the uninstalled title is filtered out
|
||||
assert_eq!(games[0].id, "heroic:legendary:Quail");
|
||||
assert_eq!(games[0].title, "Quail");
|
||||
assert_eq!(
|
||||
games[0].art.portrait.as_deref(),
|
||||
Some("https://cdn/quail_tall.jpg")
|
||||
);
|
||||
assert_eq!(
|
||||
games[0].art.header.as_deref(),
|
||||
Some("https://cdn/quail_wide.jpg")
|
||||
);
|
||||
assert!(games[0].art.logo.is_none()); // file:// art is dropped (client can't fetch it)
|
||||
let l = games[0].launch.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
(l.kind.as_str(), l.value.as_str()),
|
||||
("heroic", "legendary:Quail")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -325,7 +325,7 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option<std::path::Pa
|
||||
|
||||
/// Windows: the default Steam install's `steam.exe`, if present. A non-default Steam install dir
|
||||
/// (registry `Valve\Steam\InstallPath`) isn't covered — the explorer.exe protocol fallback handles
|
||||
/// that case. Mirrors [`steam_roots`]' "default Program Files dirs" approach.
|
||||
/// that case. Probes the default Program Files dirs, in `ProgramFiles(x86)`-first order.
|
||||
#[cfg(windows)]
|
||||
fn steam_exe() -> Option<std::path::PathBuf> {
|
||||
for var in ["ProgramFiles(x86)", "ProgramFiles", "ProgramW6432"] {
|
||||
@@ -339,6 +339,47 @@ fn steam_exe() -> Option<std::path::PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve a package's PackageFamilyName by finding its
|
||||
/// `AppRepository\Packages\<PackageFullName>` dir (machine-wide, SYSTEM-readable) and reducing the
|
||||
/// full name to `Name_PublisherHash`. This READS the authoritative PFN — never compute the hash.
|
||||
///
|
||||
/// **Readable by the host, NOT by the plugin runner.** Measured on 2026-08-06: that directory is
|
||||
/// `UnauthorizedAccessException` for `NT AUTHORITY\LocalService` (which the runner is), while the
|
||||
/// host service runs as LocalSystem and enumerates all 348 entries. That asymmetry is the entire
|
||||
/// reason the `xbox` launch kind exists — a library plugin sends the package Identity it CAN read
|
||||
/// out of `MicrosoftGame.config`, and this resolves the rest at launch time.
|
||||
///
|
||||
/// It lives here rather than beside a scanner because it is **launch** vocabulary: the in-host Xbox
|
||||
/// scanner that used to share it was removed with the rest of the built-ins, and the plugin that
|
||||
/// replaced it depends on exactly this resolution step.
|
||||
#[cfg(windows)]
|
||||
fn xbox_pfn(identity: &str) -> Option<String> {
|
||||
let pkgs = std::path::PathBuf::from(std::env::var_os("ProgramData")?)
|
||||
.join("Microsoft")
|
||||
.join("Windows")
|
||||
.join("AppRepository")
|
||||
.join("Packages");
|
||||
let prefix = format!("{identity}_");
|
||||
for e in std::fs::read_dir(&pkgs).ok()?.flatten() {
|
||||
let dn = e.file_name().to_string_lossy().into_owned();
|
||||
if dn.starts_with(&prefix) {
|
||||
if let Some(pfn) = pfn_from_full(&dn, identity) {
|
||||
return Some(pfn);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// PackageFamilyName from a PackageFullName dir name
|
||||
/// (`Name_Version_Arch_ResourceId_PublisherHash`) → `Name_PublisherHash`. The hash is the last
|
||||
/// `_`-segment; `Name` is the caller's identity.
|
||||
#[cfg(windows)]
|
||||
fn pfn_from_full(dir_name: &str, identity: &str) -> Option<String> {
|
||||
let hash = dir_name.rsplit('_').next()?;
|
||||
(!hash.is_empty() && hash != dir_name).then(|| format!("{identity}_{hash}"))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- per-kind launch values (host-owned ABI)
|
||||
//
|
||||
// Each helper below turns a store's launch VALUE — the only part a scanner (or, after extraction, a
|
||||
@@ -941,6 +982,24 @@ mod tests {
|
||||
assert!(gog_spawn("").is_none());
|
||||
}
|
||||
|
||||
/// Moved here with `xbox_pfn` when the built-in scanners were removed: reducing a
|
||||
/// PackageFullName to its family name is what the `xbox` launch kind does with the Identity a
|
||||
/// de-privileged plugin sends it, so the guard belongs to the launch path now.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn pfn_reduces_a_package_full_name_to_its_family() {
|
||||
assert_eq!(
|
||||
pfn_from_full(
|
||||
"Microsoft.624F8B84B80_1.0.0.0_x64__8wekyb3d8bbwe",
|
||||
"Microsoft.624F8B84B80"
|
||||
)
|
||||
.as_deref(),
|
||||
Some("Microsoft.624F8B84B80_8wekyb3d8bbwe")
|
||||
);
|
||||
// No `_` at all → nothing to reduce, and we must not invent a hash.
|
||||
assert!(pfn_from_full("NoUnderscore", "NoUnderscore").is_none());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_launch_for_maps_and_guards() {
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
//! Lutris store provider: installed games from the Lutris SQLite DB + lutris.net CDN art. Split out of the `library` facade (plan §W5).
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Reads the **local** Lutris library DB (`pga.db`) — no network. Installed titles only; cover art
|
||||
/// from Lutris's on-disk cache, inlined as `data:` URLs. Linux-only (Lutris is Linux-only).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub struct LutrisProvider;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl LibraryProvider for LutrisProvider {
|
||||
fn store(&self) -> &'static str {
|
||||
"lutris"
|
||||
}
|
||||
|
||||
fn list(&self) -> Vec<GameEntry> {
|
||||
let Some(db) = lutris_db() else {
|
||||
return Vec::new();
|
||||
};
|
||||
lutris_games(&db).unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, db = %db.display(), "lutris pga.db read failed — skipping");
|
||||
Vec::new()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The first existing Lutris `pga.db`: XDG data dir, the classic `~/.local/share`, or Flatpak.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn lutris_db() -> Option<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(d) = std::env::var_os("XDG_DATA_HOME") {
|
||||
candidates.push(PathBuf::from(d).join("lutris/pga.db"));
|
||||
}
|
||||
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
|
||||
candidates.push(home.join(".local/share/lutris/pga.db"));
|
||||
candidates.push(home.join(".var/app/net.lutris.Lutris/data/lutris/pga.db"));
|
||||
}
|
||||
candidates.into_iter().find(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// Installed games from a Lutris `pga.db`. Opened **read-only + immutable** (via a SQLite URI) so a
|
||||
/// running Lutris holding the file can't make us block or fail, and we never write to it.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
|
||||
use rusqlite::OpenFlags;
|
||||
// `immutable=1` treats the DB as read-only-and-unchanging → no locking against a live Lutris. The
|
||||
// path goes into the URI literally; a `?`/`#` in it (vanishingly rare on Linux) would mis-parse,
|
||||
// so fall back to a plain read-only open in that case.
|
||||
let path = db.to_string_lossy();
|
||||
let conn = if path.contains('?') || path.contains('#') {
|
||||
rusqlite::Connection::open_with_flags(db, OpenFlags::SQLITE_OPEN_READ_ONLY)?
|
||||
} else {
|
||||
rusqlite::Connection::open_with_flags(
|
||||
format!("file:{path}?immutable=1"),
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
|
||||
)?
|
||||
};
|
||||
// `directory` (the game's install dir — our detect signal) is not load-bearing for the library, so
|
||||
// a pga.db schema without it must not cost the whole Lutris store: try the richer query first and
|
||||
// fall back to the historical one on any prepare error.
|
||||
const SELECT_WITH_DIR: &str = "SELECT id, slug, name, directory FROM games \
|
||||
WHERE installed = 1 AND name IS NOT NULL AND name <> '' \
|
||||
ORDER BY name COLLATE NOCASE";
|
||||
const SELECT_PLAIN: &str = "SELECT id, slug, name, NULL FROM games \
|
||||
WHERE installed = 1 AND name IS NOT NULL AND name <> '' \
|
||||
ORDER BY name COLLATE NOCASE";
|
||||
let mut stmt = match conn.prepare(SELECT_WITH_DIR) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "lutris pga.db has no `directory` column — listing without \
|
||||
install dirs (game-exit detection unavailable for Lutris titles)");
|
||||
conn.prepare(SELECT_PLAIN)?
|
||||
}
|
||||
};
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, Option<String>>(3)?,
|
||||
))
|
||||
})?;
|
||||
let mut games = Vec::new();
|
||||
for (id, slug, name, directory) in rows.flatten() {
|
||||
games.push(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
icon: None,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("lutris:{id}"),
|
||||
store: "lutris".into(),
|
||||
title: name,
|
||||
art: slug.as_deref().map(lutris_art).unwrap_or_default(),
|
||||
launch: Some(LaunchSpec {
|
||||
kind: "lutris_id".into(),
|
||||
value: id.to_string(),
|
||||
}),
|
||||
// Lutris stamps no per-game env marker we can rely on, so the install dir is the whole
|
||||
// recipe; a game with none (an emulator entry pointing at a bare ROM) stays untracked.
|
||||
detect: directory
|
||||
.filter(|d| !d.trim().is_empty())
|
||||
.map(DetectSpec::dir)
|
||||
.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
Ok(games)
|
||||
}
|
||||
|
||||
/// Lutris cover art (local files keyed by slug) inlined as `data:` URLs — Lutris has no public CDN
|
||||
/// keyed by a stable id (unlike Steam/Heroic), and `Artwork` fields are URLs the client fetches, so a
|
||||
/// self-contained `data:` URL needs no host-served endpoint. `coverart` → portrait, `banners` → header.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn lutris_art(slug: &str) -> Artwork {
|
||||
Artwork {
|
||||
portrait: lutris_image("coverart", slug),
|
||||
header: lutris_image("banners", slug),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Find `<kind>/<slug>.jpg` across the current (0.5.18+), legacy (`~/.cache`), and Flatpak Lutris
|
||||
/// dirs and inline it as `data:image/jpeg;base64,…`. Skips a missing or implausibly large file (a
|
||||
/// 1 MiB cap bounds the catalog JSON so a few big files can't bloat it).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn lutris_image(kind: &str, slug: &str) -> Option<String> {
|
||||
use base64::Engine as _;
|
||||
// `slug` comes verbatim from Lutris's `pga.db` (untrusted at this layer). Reject any path
|
||||
// separator, parent ref, or NUL so a crafted slug can't escape the art roots and read an
|
||||
// arbitrary `<slug>.jpg` off disk — the bytes are base64-inlined into the `/api/v1/library`
|
||||
// JSON a paired client can GET, so an escape is an arbitrary-file-read exfil primitive
|
||||
// (security-review 2026-07-17). Real Lutris slugs are `[a-z0-9-]`.
|
||||
if slug.is_empty()
|
||||
|| slug.contains('/')
|
||||
|| slug.contains('\\')
|
||||
|| slug.contains("..")
|
||||
|| slug.contains('\0')
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let home = std::env::var_os("HOME").map(PathBuf::from)?;
|
||||
let roots = [
|
||||
home.join(".local/share/lutris"),
|
||||
home.join(".cache/lutris"),
|
||||
home.join(".var/app/net.lutris.Lutris/data/lutris"),
|
||||
home.join(".var/app/net.lutris.Lutris/cache/lutris"),
|
||||
];
|
||||
for root in roots {
|
||||
let p = root.join(kind).join(format!("{slug}.jpg"));
|
||||
let Ok(meta) = std::fs::metadata(&p) else {
|
||||
continue;
|
||||
};
|
||||
if meta.len() == 0 || meta.len() > 1024 * 1024 {
|
||||
continue;
|
||||
}
|
||||
if let Ok(bytes) = std::fs::read(&p) {
|
||||
let enc = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
return Some(format!("data:image/jpeg;base64,{enc}"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn lutris_games_reads_installed_only() {
|
||||
use rusqlite::Connection;
|
||||
let dir = std::env::temp_dir().join(format!("pf-lutris-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let db = dir.join("pga.db");
|
||||
{
|
||||
let c = Connection::open(&db).unwrap();
|
||||
c.execute_batch(
|
||||
"CREATE TABLE games (id INTEGER PRIMARY KEY, slug TEXT, name TEXT, installed INTEGER);
|
||||
INSERT INTO games (id,slug,name,installed) VALUES (42,'elden-ring','ELDEN RING',1);
|
||||
INSERT INTO games (id,slug,name,installed) VALUES (7,'owned','Owned Only',0);
|
||||
INSERT INTO games (id,slug,name,installed) VALUES (9,'noname',NULL,1);",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let games = lutris_games(&db).unwrap();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
// Only the installed, named row; the uninstalled + NULL-name rows are filtered out.
|
||||
assert_eq!(games.len(), 1);
|
||||
assert_eq!(games[0].id, "lutris:42");
|
||||
assert_eq!(games[0].store, "lutris");
|
||||
assert_eq!(games[0].title, "ELDEN RING");
|
||||
let l = games[0].launch.as_ref().unwrap();
|
||||
assert_eq!((l.kind.as_str(), l.value.as_str()), ("lutris_id", "42"));
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
//! Library-scanner settings: which installed-store scanners run on this host. Every scanner is
|
||||
//! **on by default** (the shipped behavior before this existed); the operator can turn one off in
|
||||
//! the web console, which hides its titles from every library surface (console grid, native
|
||||
//! clients, the GameStream app list, launch resolution) from the next read. Only the *disabled*
|
||||
//! set is persisted, so a scanner added in a future build starts enabled without a migration.
|
||||
//! Game-source settings: which of this host's library sources contribute titles. Every source is
|
||||
//! **on by default**; the operator can turn one off in the web console, which hides its titles from
|
||||
//! every library surface (console grid, native clients, the GameStream app list, launch resolution)
|
||||
//! from the next read. Only the *disabled* set is persisted, so a source that appears later starts
|
||||
//! enabled without a migration.
|
||||
//!
|
||||
//! The user-curated **custom** store is not a scanner (nothing is scanned — the operator typed the
|
||||
//! entries in) and cannot be disabled here; provider plugins (RFC §8) likewise own their entries
|
||||
//! through the reconcile API. Down the road the scanners themselves are slated to become plugins —
|
||||
//! the stable per-scanner ids this module fixes (`steam`, `lutris`, …, matching each entry's
|
||||
//! `store` field) are the forward seam for that migration.
|
||||
//! **Every source here is a plugin now.** Through v0.27.x this module also enumerated the six
|
||||
//! scanners compiled into the host; that list is gone with the scanners themselves. The forward seam
|
||||
//! it was built for did its job exactly as designed — the ids never changed (provider id = claimed
|
||||
//! store id = old scanner id), so an operator who had `steam` switched off before the migration
|
||||
//! still has it switched off after, with nothing to carry over and no migration step. That property
|
||||
//! is the reason `library-scanners.json` keeps its name and its shape.
|
||||
//!
|
||||
//! The user-curated **custom** store is not a source (nothing is scanned — the operator typed the
|
||||
//! entries in) and cannot be disabled here.
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -27,8 +31,9 @@ pub struct ScannerInfo {
|
||||
pub label: String,
|
||||
/// Whether this host runs the source (default true).
|
||||
pub enabled: bool,
|
||||
/// Where the source comes from: `builtin` (a scanner in this host build) or `plugin`.
|
||||
#[schema(example = "builtin")]
|
||||
/// Where the source comes from. Always `plugin` from this host build onward — see
|
||||
/// [`SourceOrigin`].
|
||||
#[schema(example = "plugin")]
|
||||
pub origin: SourceOrigin,
|
||||
/// The provider id backing a `plugin` source — absent for a built-in scanner.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -43,30 +48,31 @@ pub struct ScannerInfo {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SourceOrigin {
|
||||
/// A scanner compiled into this host build.
|
||||
/// A scanner compiled into the host build.
|
||||
///
|
||||
/// **No host build emits this any more** — the built-in scanners were removed in v0.28.0. The
|
||||
/// variant is kept deliberately, because it is still part of the API's vocabulary: the web
|
||||
/// console ships as its own package and is expected to drive an N-1 host, which does still
|
||||
/// report `builtin` sources. Deleting it here would drop `builtin` from the OpenAPI enum and
|
||||
/// narrow the console's generated union out from under that pairing.
|
||||
#[allow(dead_code)]
|
||||
Builtin,
|
||||
/// A plugin reconciling entries over the provider API.
|
||||
Plugin,
|
||||
}
|
||||
|
||||
/// The scanners compiled into THIS host build: (id, label). Steam is cross-platform; the rest are
|
||||
/// platform-gated exactly like their provider modules in `library.rs` — keep the two in sync when
|
||||
/// adding a store.
|
||||
fn scanner_defs() -> Vec<(&'static str, &'static str)> {
|
||||
let mut defs = vec![("steam", "Steam")];
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
defs.push(("lutris", "Lutris"));
|
||||
defs.push(("heroic", "Heroic (Epic / GOG / Amazon)"));
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
defs.push(("epic", "Epic Games Launcher"));
|
||||
defs.push(("gog", "GOG Galaxy"));
|
||||
defs.push(("xbox", "Xbox / Game Pass"));
|
||||
}
|
||||
defs
|
||||
}
|
||||
/// Display names for the stores that used to have a built-in scanner, so a source keeps the label
|
||||
/// the operator has been toggling for releases instead of renaming itself to a bare id the day its
|
||||
/// plugin takes over. Anything not listed (rom-manager, playnite, a third-party provider) falls back
|
||||
/// to its own id, which is what those sources have always shown.
|
||||
const STORE_LABELS: &[(&str, &str)] = &[
|
||||
("steam", "Steam"),
|
||||
("lutris", "Lutris"),
|
||||
("heroic", "Heroic (Epic / GOG / Amazon)"),
|
||||
("epic", "Epic Games Launcher"),
|
||||
("gog", "GOG Galaxy"),
|
||||
("xbox", "Xbox / Game Pass"),
|
||||
];
|
||||
|
||||
/// Persisted shape (`library-scanners.json`): only the ids the operator turned OFF. Absent file =
|
||||
/// nothing disabled = the pre-existing all-scanners-on behavior.
|
||||
@@ -104,49 +110,26 @@ fn save_settings(settings: &ScannerSettings) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The disabled-scanner ids, loaded once per library read ([`all_games`] consults it per store).
|
||||
/// The disabled source ids, loaded once per library read ([`all_games`] filters each entry on it).
|
||||
pub(crate) fn disabled_scanners() -> HashSet<String> {
|
||||
load_settings().disabled.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Every game source on this host with its current enable state (WP2.6):
|
||||
///
|
||||
/// 1. the built-in scanners this build compiled in, **minus** any whose store a plugin has claimed
|
||||
/// (the plugin replaces it, so showing both would offer two toggles for one thing);
|
||||
/// 2. the claimed stores themselves, as plugin sources;
|
||||
/// 3. any other provider that has entries — the *emergent* case (rom-manager, playnite), which has
|
||||
/// never had a toggle before and gets one for free here.
|
||||
/// 1. every **claimed store** — a library plugin that took a store's id, so its entries surface as
|
||||
/// `steam:570` rather than `custom:<opaque>`;
|
||||
/// 2. every other provider that has entries — the *emergent* case (rom-manager, playnite), which
|
||||
/// never claimed a store but still owns a set of titles the operator may want to switch off.
|
||||
///
|
||||
/// Built-ins keep their fixed definition order (stable for the console); plugin sources follow,
|
||||
/// sorted by id.
|
||||
/// Sorted by id, which is a stable order for the console. This used to lead with the built-in
|
||||
/// scanners compiled into the host, minus any store a plugin had claimed out from under them; with
|
||||
/// the scanners gone the subtraction has nothing left to subtract and the list is plugins only.
|
||||
pub fn list_scanners() -> Vec<ScannerInfo> {
|
||||
let off = disabled_scanners();
|
||||
let claims = crate::library::claimed_stores();
|
||||
let entries = crate::library::load_custom();
|
||||
|
||||
let mut out: Vec<ScannerInfo> = scanner_defs()
|
||||
.into_iter()
|
||||
.filter(|(id, _)| !claims.contains_key(*id))
|
||||
.map(|(id, label)| ScannerInfo {
|
||||
id: id.to_string(),
|
||||
label: label.to_string(),
|
||||
enabled: !off.contains(id),
|
||||
origin: SourceOrigin::Builtin,
|
||||
provider: None,
|
||||
entries: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// A claimed store shows under the SCANNER's label where we know one, so the row a user has been
|
||||
// toggling for releases doesn't rename itself out from under them mid-migration.
|
||||
let label_for = |id: &str| {
|
||||
scanner_defs()
|
||||
.into_iter()
|
||||
.find(|(sid, _)| *sid == id)
|
||||
.map(|(_, label)| label.to_string())
|
||||
.unwrap_or_else(|| id.to_string())
|
||||
};
|
||||
|
||||
let mut plugin_ids: Vec<(String, String)> = claims
|
||||
.iter()
|
||||
.map(|(store, provider)| (store.clone(), provider.clone()))
|
||||
@@ -163,27 +146,43 @@ pub fn list_scanners() -> Vec<ScannerInfo> {
|
||||
plugin_ids.sort();
|
||||
plugin_ids.dedup();
|
||||
|
||||
out.extend(plugin_ids.into_iter().map(|(id, provider)| {
|
||||
let count = entries
|
||||
.iter()
|
||||
.filter(|e| crate::library::source_id_for(e) == Some(id.as_str()))
|
||||
.count();
|
||||
ScannerInfo {
|
||||
label: label_for(&id),
|
||||
enabled: !off.contains(&id),
|
||||
origin: SourceOrigin::Plugin,
|
||||
provider: Some(provider),
|
||||
entries: Some(count),
|
||||
id,
|
||||
}
|
||||
}));
|
||||
out
|
||||
plugin_ids
|
||||
.into_iter()
|
||||
.map(|(id, provider)| {
|
||||
let count = entries
|
||||
.iter()
|
||||
.filter(|e| crate::library::source_id_for(e) == Some(id.as_str()))
|
||||
.count();
|
||||
ScannerInfo {
|
||||
label: store_label(&id),
|
||||
enabled: !off.contains(&id),
|
||||
origin: SourceOrigin::Plugin,
|
||||
provider: Some(provider),
|
||||
entries: Some(count),
|
||||
id,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether `id` names a source that exists on this host right now — a compiled-in scanner, a claimed
|
||||
/// store, or a provider with entries. The toggle accepts exactly these (an unknown id still 404s).
|
||||
/// A source's display name — see [`STORE_LABELS`]; its own id when we know no nicer name.
|
||||
fn store_label(id: &str) -> String {
|
||||
STORE_LABELS
|
||||
.iter()
|
||||
.find(|(sid, _)| *sid == id)
|
||||
.map(|(_, label)| (*label).to_string())
|
||||
.unwrap_or_else(|| id.to_string())
|
||||
}
|
||||
|
||||
/// Whether `id` names a source that exists on this host right now — a claimed store or a provider
|
||||
/// with entries. The toggle accepts exactly these (an unknown id still 404s).
|
||||
///
|
||||
/// Note this is now strictly "a source that is really here". While the built-ins existed it also
|
||||
/// accepted any compiled-in scanner id, which was the same thing for them; a plugin that has never
|
||||
/// reconciled has no entries and no claim, so there is nothing to toggle and 404 is the honest
|
||||
/// answer.
|
||||
fn is_known_source(id: &str) -> bool {
|
||||
scanner_defs().iter().any(|(sid, _)| *sid == id) || list_scanners().iter().any(|s| s.id == id)
|
||||
list_scanners().iter().any(|s| s.id == id)
|
||||
}
|
||||
|
||||
/// Enable/disable one source. `None` when `id` names no source on this host (the mgmt layer maps
|
||||
@@ -221,14 +220,19 @@ pub fn set_scanner_enabled(id: &str, enabled: bool) -> Result<Option<Vec<Scanner
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The label table is what keeps a source row named "Steam" instead of "steam" now that the
|
||||
/// scanner which used to supply that name is gone. Pin both halves: the ids are unique, and an
|
||||
/// id we know nothing about degrades to itself rather than to an empty or panicking label.
|
||||
#[test]
|
||||
fn steam_is_always_a_scanner_and_ids_are_unique() {
|
||||
let defs = scanner_defs();
|
||||
assert!(defs.iter().any(|(id, _)| *id == "steam"));
|
||||
let ids: HashSet<_> = defs.iter().map(|(id, _)| *id).collect();
|
||||
assert_eq!(ids.len(), defs.len(), "scanner ids must be unique");
|
||||
// `custom` is a store but never a scanner — the toggle surface must not offer it.
|
||||
fn store_labels_are_unique_and_unknown_ids_degrade_to_themselves() {
|
||||
let ids: HashSet<_> = STORE_LABELS.iter().map(|(id, _)| *id).collect();
|
||||
assert_eq!(ids.len(), STORE_LABELS.len(), "source ids must be unique");
|
||||
// `custom` is a store but never a source — the toggle surface must not offer it.
|
||||
assert!(!ids.contains("custom"));
|
||||
|
||||
assert_eq!(store_label("steam"), "Steam");
|
||||
assert_eq!(store_label("rom-manager"), "rom-manager");
|
||||
assert_eq!(store_label(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,812 +0,0 @@
|
||||
//! Steam store provider: installed-title scan (local `libraryfolders.vdf` + app manifests, no
|
||||
//! API key) and Steam-CDN / local-`librarycache` artwork. Split out of the `library` facade (plan §W5).
|
||||
|
||||
use super::art::fetch_image;
|
||||
use super::*;
|
||||
|
||||
/// Reads the **local** Steam install — no Steam Web API key, no network. Installed titles come
|
||||
/// from `steamapps/appmanifest_<appid>.acf`; extra library folders from
|
||||
/// `steamapps/libraryfolders.vdf`; the user's own non-Steam shortcuts ("Add a Non-Steam Game to My
|
||||
/// Library") from each account's binary `userdata/<id>/config/shortcuts.vdf`; artwork from the
|
||||
/// public Steam CDN by appid, or the user's per-account `grid/` overrides (all a shortcut ever has).
|
||||
pub struct SteamProvider;
|
||||
|
||||
impl LibraryProvider for SteamProvider {
|
||||
fn store(&self) -> &'static str {
|
||||
"steam"
|
||||
}
|
||||
|
||||
fn list(&self) -> Vec<GameEntry> {
|
||||
let mut by_appid: std::collections::BTreeMap<u32, Installed> = Default::default();
|
||||
for steamapps in steam_library_dirs() {
|
||||
for app in scan_manifests(&steamapps) {
|
||||
// First library wins; dedups appids present in several libraries.
|
||||
by_appid.entry(app.appid).or_insert(app);
|
||||
}
|
||||
}
|
||||
let mut games: Vec<GameEntry> = by_appid
|
||||
.into_values()
|
||||
.filter(|app| !is_steam_tool(app.appid, &app.name))
|
||||
.map(|app| GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
icon: None,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("steam:{}", app.appid),
|
||||
store: "steam".into(),
|
||||
art: steam_art(app.appid),
|
||||
launch: Some(LaunchSpec {
|
||||
kind: "steam_appid".into(),
|
||||
value: app.appid.to_string(),
|
||||
}),
|
||||
// The appid alone is authoritative on Linux (Steam's launch reaper); the install dir
|
||||
// is what the Windows matcher — which has no reaper to watch — keys off instead.
|
||||
detect: match app.install_dir {
|
||||
Some(dir) => DetectSpec::steam(app.appid).with_dir(dir),
|
||||
None => DetectSpec::steam(app.appid),
|
||||
},
|
||||
title: app.name,
|
||||
})
|
||||
.collect();
|
||||
// Non-Steam shortcuts have no `appmanifest` — [`scan_manifests`] can't see them, so the
|
||||
// user's own custom entries are gathered separately from `shortcuts.vdf`.
|
||||
games.extend(steam_shortcuts());
|
||||
games
|
||||
}
|
||||
}
|
||||
|
||||
/// The Steam CDN poster/hero/logo/header for an appid — relative proxy paths the *client* resolves
|
||||
/// against the host it just talked to (so they work the same whichever interface/port the client
|
||||
/// reached the host on), backed by [`steam_art_bytes`] on the way out. Not every appid has a
|
||||
/// 600×900 capsule, but `header.jpg` is effectively universal — the client falls back to it.
|
||||
fn steam_art(appid: u32) -> Artwork {
|
||||
let url = |kind: &str| Some(format!("/api/v1/library/art/steam:{appid}/{kind}"));
|
||||
Artwork {
|
||||
portrait: url("portrait"),
|
||||
hero: url("hero"),
|
||||
logo: url("logo"),
|
||||
header: url("header"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve one Steam cover-art kind to bytes: the host's own local Steam cache first (exact — it's
|
||||
/// literally what the user's Steam client already shows for this title), then the user's per-account
|
||||
/// `grid/` overrides (the *only* art a non-Steam shortcut ever has), then the legacy flat CDN URL.
|
||||
/// `None` when none has it (the client then falls through to its next art candidate). Blocking
|
||||
/// (disk + network) — call off the async runtime.
|
||||
pub fn steam_art_bytes(appid: u32, kind: ArtKind) -> Option<(Vec<u8>, String)> {
|
||||
if let Some(local) =
|
||||
steam_local_art_bytes(appid, kind).or_else(|| steam_grid_art_bytes(appid, kind))
|
||||
{
|
||||
return Some(local);
|
||||
}
|
||||
// A non-Steam shortcut's appid has the high bit set (see [`shortcut_appid`]) and is never a real
|
||||
// store appid, so the CDN would only 404 — skip the wasted request and fall through cleanly.
|
||||
if appid & 0x8000_0000 != 0 {
|
||||
return None;
|
||||
}
|
||||
let url = format!(
|
||||
"https://cdn.cloudflare.steamstatic.com/steam/apps/{appid}/{}",
|
||||
kind.cdn_filename()
|
||||
);
|
||||
fetch_image(&url)
|
||||
}
|
||||
|
||||
/// Cap on a local librarycache file we'll read into memory — generous for a Steam-quality JPEG/PNG
|
||||
/// (these run well under 2 MiB in practice) while bounding a pathological file.
|
||||
const LOCAL_ART_MAX_BYTES: u64 = 8 * 1024 * 1024;
|
||||
|
||||
/// `appcache/librarycache/<appid>/<hash>/<filename>` across every Steam root, for whichever
|
||||
/// `<hash>` subdirectory actually has this kind's file (Steam reuses one hash dir per asset
|
||||
/// version, so there's normally exactly one candidate per kind).
|
||||
fn steam_local_art_bytes(appid: u32, kind: ArtKind) -> Option<(Vec<u8>, String)> {
|
||||
steam_roots()
|
||||
.into_iter()
|
||||
.find_map(|root| find_local_art_file(&root, appid, kind))
|
||||
.and_then(|path| {
|
||||
let bytes = std::fs::read(&path).ok()?;
|
||||
let ctype = if path.extension().is_some_and(|e| e == "png") {
|
||||
"image/png"
|
||||
} else {
|
||||
"image/jpeg"
|
||||
};
|
||||
Some((bytes, ctype.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Find this kind's cached file under one Steam root's `appcache/librarycache/<appid>/<hash>/`,
|
||||
/// trying each hash subdirectory (normally just one) and each candidate filename in priority
|
||||
/// order. Pure path lookup — no env/HOME dependency — so it's unit-testable against a plain
|
||||
/// directory fixture.
|
||||
fn find_local_art_file(root: &Path, appid: u32, kind: ArtKind) -> Option<PathBuf> {
|
||||
let cache_dir = root
|
||||
.join("appcache")
|
||||
.join("librarycache")
|
||||
.join(appid.to_string());
|
||||
let hash_dirs = std::fs::read_dir(&cache_dir).ok()?;
|
||||
for hash_dir in hash_dirs.flatten() {
|
||||
for name in kind.local_filenames() {
|
||||
let path = hash_dir.path().join(name);
|
||||
let Ok(meta) = std::fs::metadata(&path) else {
|
||||
continue;
|
||||
};
|
||||
if meta.len() > 0 && meta.len() <= LOCAL_ART_MAX_BYTES {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Artwork a user set in Steam itself for a **non-Steam shortcut** (or a `grid/` override for a real
|
||||
/// title), stored per-account under `userdata/<id>/config/grid/`, keyed by the same 32-bit appid the
|
||||
/// shortcut carries. Tried before the CDN — a shortcut has no CDN art at all, so this is where its
|
||||
/// poster lives.
|
||||
fn steam_grid_art_bytes(appid: u32, kind: ArtKind) -> Option<(Vec<u8>, String)> {
|
||||
for root in steam_roots() {
|
||||
let Ok(users) = std::fs::read_dir(root.join("userdata")) else {
|
||||
continue;
|
||||
};
|
||||
for user in users.flatten() {
|
||||
let grid = user.path().join("config").join("grid");
|
||||
if let Some(path) = find_grid_art_file(&grid, appid, kind) {
|
||||
if let Ok(bytes) = std::fs::read(&path) {
|
||||
let ctype = if path.extension().is_some_and(|e| e == "png") {
|
||||
"image/png"
|
||||
} else {
|
||||
"image/jpeg"
|
||||
};
|
||||
return Some((bytes, ctype.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The `grid/` filenames Steam stores this art kind under for appid `<A>`, tried in order (PNG then
|
||||
/// JPG — Steam accepts either). Pure path logic, so it's unit-testable against a directory fixture.
|
||||
fn find_grid_art_file(grid: &Path, appid: u32, kind: ArtKind) -> Option<PathBuf> {
|
||||
for name in grid_filenames(kind, appid) {
|
||||
let path = grid.join(&name);
|
||||
if let Ok(meta) = std::fs::metadata(&path) {
|
||||
if meta.len() > 0 && meta.len() <= LOCAL_ART_MAX_BYTES {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The `userdata/<id>/config/grid/` basenames Steam names each art kind under for appid `<A>`:
|
||||
/// portrait `<A>p`, hero `<A>_hero`, logo `<A>_logo`, and the wide capsule `<A>` — each as `.png`
|
||||
/// then `.jpg`.
|
||||
fn grid_filenames(kind: ArtKind, appid: u32) -> Vec<String> {
|
||||
let both = |base: String| vec![format!("{base}.png"), format!("{base}.jpg")];
|
||||
match kind {
|
||||
ArtKind::Portrait => both(format!("{appid}p")),
|
||||
ArtKind::Hero => both(format!("{appid}_hero")),
|
||||
ArtKind::Logo => both(format!("{appid}_logo")),
|
||||
ArtKind::Header => both(format!("{appid}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Candidate Steam roots (classic, Flatpak, Deck) that actually exist, canonicalized + deduped.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn steam_roots() -> Vec<PathBuf> {
|
||||
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let candidates = [
|
||||
home.join(".local/share/Steam"),
|
||||
home.join(".steam/steam"),
|
||||
home.join(".steam/root"),
|
||||
home.join(".var/app/com.valvesoftware.Steam/.local/share/Steam"), // Flatpak Steam
|
||||
];
|
||||
steam_roots_existing(candidates)
|
||||
}
|
||||
|
||||
/// Windows Steam roots: the default install dirs under Program Files. Games installed on other
|
||||
/// drives are still found via each root's `libraryfolders.vdf` (see [`steam_library_dirs`]). A
|
||||
/// non-default Steam install dir (registry `Valve\Steam\InstallPath`) isn't covered yet.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn steam_roots() -> Vec<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
for var in ["ProgramFiles(x86)", "ProgramFiles", "ProgramW6432"] {
|
||||
if let Some(pf) = std::env::var_os(var) {
|
||||
candidates.push(PathBuf::from(pf).join("Steam"));
|
||||
}
|
||||
}
|
||||
steam_roots_existing(candidates)
|
||||
}
|
||||
|
||||
/// Keep only the candidate roots that exist (have a `steamapps` dir), canonicalized + deduped.
|
||||
fn steam_roots_existing(candidates: impl IntoIterator<Item = PathBuf>) -> Vec<PathBuf> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut roots = Vec::new();
|
||||
for c in candidates {
|
||||
if let Ok(canon) = c.canonicalize() {
|
||||
if canon.join("steamapps").is_dir() && seen.insert(canon.clone()) {
|
||||
roots.push(canon);
|
||||
}
|
||||
}
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
/// Every `steamapps` dir holding installed titles: each root's own, plus the extra library
|
||||
/// folders listed in `libraryfolders.vdf` (Steam lets you install games on other drives).
|
||||
fn steam_library_dirs() -> Vec<PathBuf> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut dirs = Vec::new();
|
||||
let mut push = |steamapps: PathBuf, dirs: &mut Vec<PathBuf>| {
|
||||
if let Ok(canon) = steamapps.canonicalize() {
|
||||
if canon.is_dir() && seen.insert(canon.clone()) {
|
||||
dirs.push(canon);
|
||||
}
|
||||
}
|
||||
};
|
||||
for root in steam_roots() {
|
||||
let steamapps = root.join("steamapps");
|
||||
if let Ok(text) = std::fs::read_to_string(steamapps.join("libraryfolders.vdf")) {
|
||||
for path in vdf_paths(&text) {
|
||||
push(PathBuf::from(path).join("steamapps"), &mut dirs);
|
||||
}
|
||||
}
|
||||
push(steamapps, &mut dirs);
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Pull every `"path" "<dir>"` value out of a `libraryfolders.vdf`. We don't need a full VDF
|
||||
/// parser for the two flat fields we read. On Windows the values are backslash-escaped
|
||||
/// (`D:\\SteamLibrary`), so unescape `\\` → `\`; Linux paths need no unescaping.
|
||||
fn vdf_paths(text: &str) -> Vec<String> {
|
||||
text.lines()
|
||||
.filter_map(|l| vdf_value(l.trim(), "path"))
|
||||
.map(|p| {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
p.replace("\\\\", "\\")
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
p.to_string()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `"<key>" "<value>"` on a single line → `<value>`. Used for both VDF and ACF flat fields.
|
||||
fn vdf_value<'a>(line: &'a str, key: &str) -> Option<&'a str> {
|
||||
let rest = line.strip_prefix(&format!("\"{key}\""))?;
|
||||
let after = &rest[rest.find('"')? + 1..];
|
||||
Some(&after[..after.find('"')?])
|
||||
}
|
||||
|
||||
/// One installed Steam title, as read from its `appmanifest_<appid>.acf`.
|
||||
struct Installed {
|
||||
appid: u32,
|
||||
name: String,
|
||||
/// `<steamapps>/common/<installdir>`, when the manifest names one and it exists on disk — the
|
||||
/// game's own files, used to recognize its processes ([`DetectSpec::install_dir`]).
|
||||
install_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Scan a `steamapps` dir for `appmanifest_*.acf` files → the installed titles it describes.
|
||||
fn scan_manifests(steamapps: &Path) -> Vec<Installed> {
|
||||
let Ok(rd) = std::fs::read_dir(steamapps) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for entry in rd.flatten() {
|
||||
let fname = entry.file_name();
|
||||
let fname = fname.to_string_lossy();
|
||||
if !(fname.starts_with("appmanifest_") && fname.ends_with(".acf")) {
|
||||
continue;
|
||||
}
|
||||
if let Ok(text) = std::fs::read_to_string(entry.path()) {
|
||||
let appid = text.lines().find_map(|l| vdf_value(l.trim(), "appid"));
|
||||
let name = text.lines().find_map(|l| vdf_value(l.trim(), "name"));
|
||||
if let (Some(Ok(appid)), Some(name)) = (appid.map(str::parse::<u32>), name) {
|
||||
// `installdir` is a bare folder name relative to this library's `common/`.
|
||||
let install_dir = text
|
||||
.lines()
|
||||
.find_map(|l| vdf_value(l.trim(), "installdir"))
|
||||
.map(|d| steamapps.join("common").join(d))
|
||||
.filter(|p| p.is_dir());
|
||||
out.push(Installed {
|
||||
appid,
|
||||
name: name.to_string(),
|
||||
install_dir,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Steam installs runtimes/redistributables as "apps" too — keep them out of a *game* library.
|
||||
fn is_steam_tool(appid: u32, name: &str) -> bool {
|
||||
// Steamworks Common Redistributables; Steam Linux Runtime 1.0/2.0/3.0 (Sniper/Soldier).
|
||||
const TOOL_IDS: &[u32] = &[228980, 1070560, 1391110, 1628350, 1493710];
|
||||
if TOOL_IDS.contains(&appid) {
|
||||
return true;
|
||||
}
|
||||
let n = name.to_ascii_lowercase();
|
||||
n.contains("proton")
|
||||
|| n.starts_with("steam linux runtime")
|
||||
|| n.contains("steamworks common")
|
||||
|| n.contains("steamvr")
|
||||
}
|
||||
|
||||
/// One non-Steam shortcut ("Add a Non-Steam Game to My Library"), as read from `shortcuts.vdf`.
|
||||
struct Shortcut {
|
||||
/// The 32-bit shortcut appid Steam assigns it (high bit set — see [`shortcut_appid`]). Keys the
|
||||
/// entry id and its `grid/` artwork; the 64-bit launch id derives from it ([`shortcut_gameid`]).
|
||||
appid: u32,
|
||||
/// Display name (`AppName`).
|
||||
name: String,
|
||||
/// The shortcut's target (`Exe`), as stored — Steam quotes it. This *is* the game (a shortcut
|
||||
/// points straight at it, with no launcher in between), so it doubles as the detect signal.
|
||||
exe: String,
|
||||
/// Whether Steam has this shortcut hidden from the library (`IsHidden`) — we honor that.
|
||||
hidden: bool,
|
||||
}
|
||||
|
||||
/// Every non-Steam shortcut across all Steam accounts on this host, as launchable [`GameEntry`]s.
|
||||
/// These carry no `appmanifest`, so [`scan_manifests`] never sees them — this is the only path that
|
||||
/// surfaces a user's custom Steam entries. Best-effort: an unreadable/absent `shortcuts.vdf`
|
||||
/// contributes nothing; hidden shortcuts and duplicate appids are dropped.
|
||||
fn steam_shortcuts() -> Vec<GameEntry> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
for path in shortcuts_files() {
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
continue;
|
||||
};
|
||||
for sc in parse_shortcuts(&bytes) {
|
||||
if seen.insert(sc.appid) {
|
||||
if let Some(entry) = shortcut_entry(sc) {
|
||||
out.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Map one parsed [`Shortcut`] to a library entry, or `None` if Steam has it hidden. Launch reuses
|
||||
/// the `steam_appid` recipe (`steam steam://rungameid/<id>`) — the value is the 64-bit shortcut
|
||||
/// game id, not the 32-bit appid, because the plain appid won't launch a non-Steam shortcut.
|
||||
fn shortcut_entry(sc: Shortcut) -> Option<GameEntry> {
|
||||
if sc.hidden {
|
||||
return None;
|
||||
}
|
||||
Some(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
icon: None,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("steam:{}", sc.appid),
|
||||
store: "steam".into(),
|
||||
title: sc.name,
|
||||
art: steam_art(sc.appid),
|
||||
launch: Some(LaunchSpec {
|
||||
kind: "steam_appid".into(),
|
||||
value: shortcut_gameid(sc.appid).to_string(),
|
||||
}),
|
||||
detect: shortcut_detect(&sc.exe),
|
||||
})
|
||||
}
|
||||
|
||||
/// Detect signals for a non-Steam shortcut: its `Exe` target is the game itself, so the executable
|
||||
/// (and its folder, which catches a launcher script that execs a sibling binary) identifies it. Steam
|
||||
/// stores the target quoted and may include trailing arguments; only an existing absolute path is
|
||||
/// asserted — a guess would be worse than no tracking at all.
|
||||
fn shortcut_detect(exe: &str) -> DetectSpec {
|
||||
let mut spec = crate::library::spec_from_command(exe);
|
||||
if let Some(dir) = spec.exe.as_deref().and_then(Path::parent) {
|
||||
spec.install_dir = Some(dir.to_path_buf());
|
||||
}
|
||||
spec
|
||||
}
|
||||
|
||||
/// Every `userdata/<id>/config/shortcuts.vdf` under each Steam root — one file per Steam account
|
||||
/// that has signed in on this host.
|
||||
fn shortcuts_files() -> Vec<PathBuf> {
|
||||
let mut files = Vec::new();
|
||||
for root in steam_roots() {
|
||||
let Ok(users) = std::fs::read_dir(root.join("userdata")) else {
|
||||
continue;
|
||||
};
|
||||
for user in users.flatten() {
|
||||
let path = user.path().join("config").join("shortcuts.vdf");
|
||||
if path.is_file() {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
// `shortcut_gameid` (the 64-bit `rungameid` composition) moved to `launch.rs` (WP1.1) — it is launch
|
||||
// vocabulary; this module only reads the 32-bit appid out of `shortcuts.vdf`.
|
||||
|
||||
/// The 32-bit appid Steam derives for a shortcut from its target+name — `crc32(exe + name)` with the
|
||||
/// high bit set. Only used when `shortcuts.vdf` omits the stored `appid` (very old Steam); modern
|
||||
/// Steam writes it, and we prefer the stored value.
|
||||
fn shortcut_appid(exe: &str, name: &str) -> u32 {
|
||||
crc32(format!("{exe}{name}").as_bytes()) | 0x8000_0000
|
||||
}
|
||||
|
||||
/// Standard reflected (IEEE) CRC-32 — a few short strings' worth per scan, so a table-free bitwise
|
||||
/// loop is plenty. Matches what Steam uses to hash a shortcut's `exe + name`.
|
||||
fn crc32(data: &[u8]) -> u32 {
|
||||
let mut crc: u32 = 0xFFFF_FFFF;
|
||||
for &byte in data {
|
||||
crc ^= byte as u32;
|
||||
for _ in 0..8 {
|
||||
let mask = (crc & 1).wrapping_neg();
|
||||
crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
|
||||
}
|
||||
}
|
||||
!crc
|
||||
}
|
||||
|
||||
/// Parse a **binary** `shortcuts.vdf` into its shortcuts. The format is Steam's binary KeyValues: a
|
||||
/// 1-byte type tag (`0x00` nested map, `0x01` string, `0x02` int32), a NUL-terminated key, then a
|
||||
/// type-specific payload; `0x08` closes the current map. The whole file is one `shortcuts` map whose
|
||||
/// children (keyed `"0"`, `"1"`, …) are the individual shortcuts. Lenient and panic-free: a
|
||||
/// truncated file or an unrecognized tag stops the walk and returns whatever parsed so far.
|
||||
fn parse_shortcuts(buf: &[u8]) -> Vec<Shortcut> {
|
||||
let mut out = Vec::new();
|
||||
let mut pos = 0usize;
|
||||
// Enter the top-level map (`<0x00> "shortcuts" <NUL>`); tolerate any key name.
|
||||
if buf.first() != Some(&0x00) {
|
||||
return out;
|
||||
}
|
||||
pos += 1;
|
||||
if read_cstr(buf, &mut pos).is_none() {
|
||||
return out;
|
||||
}
|
||||
// Each child is a map describing one shortcut, until the map-closing `0x08`.
|
||||
while let Some(&tag) = buf.get(pos) {
|
||||
pos += 1;
|
||||
if tag != 0x00 {
|
||||
break; // `0x08` (end of shortcuts) or anything unexpected
|
||||
}
|
||||
if read_cstr(buf, &mut pos).is_none() {
|
||||
break; // the index key ("0", "1", …)
|
||||
}
|
||||
match parse_one_shortcut(buf, &mut pos) {
|
||||
Some(sc) => out.push(sc),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse one shortcut's fields (positioned just after its index key) up to the map-closing `0x08`,
|
||||
/// pulling the ones we surface. `None` on a truncated/garbled entry.
|
||||
fn parse_one_shortcut(buf: &[u8], pos: &mut usize) -> Option<Shortcut> {
|
||||
let mut appid: Option<u32> = None;
|
||||
let mut name = String::new();
|
||||
let mut exe = String::new();
|
||||
let mut hidden = false;
|
||||
loop {
|
||||
let tag = *buf.get(*pos)?;
|
||||
*pos += 1;
|
||||
if tag == 0x08 {
|
||||
break; // end of this shortcut
|
||||
}
|
||||
let key = read_cstr(buf, pos)?.to_ascii_lowercase();
|
||||
match tag {
|
||||
0x00 => skip_map(buf, pos)?, // nested map (e.g. `tags`) — not needed
|
||||
0x01 => {
|
||||
let val = read_cstr(buf, pos)?;
|
||||
match key.as_str() {
|
||||
"appname" => name = val,
|
||||
"exe" => exe = val,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
0x02 => {
|
||||
let val = read_i32(buf, pos)?;
|
||||
match key.as_str() {
|
||||
"appid" => appid = Some(val as u32),
|
||||
"ishidden" => hidden = val != 0,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
0x07 => *pos += 8, // uint64 — skip
|
||||
_ => return None, // unknown tag: payload size unknown, can't continue safely
|
||||
}
|
||||
}
|
||||
if name.trim().is_empty() {
|
||||
return None; // nothing worth showing
|
||||
}
|
||||
// Prefer the stored appid; fall back to Steam's derivation when it's absent (0 / missing).
|
||||
let appid = appid
|
||||
.filter(|a| *a != 0)
|
||||
.unwrap_or_else(|| shortcut_appid(&exe, &name));
|
||||
Some(Shortcut {
|
||||
appid,
|
||||
name,
|
||||
exe,
|
||||
hidden,
|
||||
})
|
||||
}
|
||||
|
||||
/// Skip a nested map's contents (positioned just after its key) up to and including its `0x08`.
|
||||
fn skip_map(buf: &[u8], pos: &mut usize) -> Option<()> {
|
||||
loop {
|
||||
let tag = *buf.get(*pos)?;
|
||||
*pos += 1;
|
||||
if tag == 0x08 {
|
||||
return Some(());
|
||||
}
|
||||
read_cstr(buf, pos)?; // key
|
||||
match tag {
|
||||
0x00 => skip_map(buf, pos)?,
|
||||
0x01 => {
|
||||
read_cstr(buf, pos)?;
|
||||
}
|
||||
0x02 => *pos += 4,
|
||||
0x07 => *pos += 8,
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a NUL-terminated UTF-8 string (lossy) starting at `pos`, advancing past the terminator.
|
||||
/// `None` if the buffer ends before a NUL.
|
||||
fn read_cstr(buf: &[u8], pos: &mut usize) -> Option<String> {
|
||||
let start = *pos;
|
||||
let end = buf.get(start..)?.iter().position(|&b| b == 0)? + start;
|
||||
let s = String::from_utf8_lossy(&buf[start..end]).into_owned();
|
||||
*pos = end + 1;
|
||||
Some(s)
|
||||
}
|
||||
|
||||
/// Read a little-endian int32 at `pos`, advancing 4 bytes. `None` if fewer than 4 bytes remain.
|
||||
fn read_i32(buf: &[u8], pos: &mut usize) -> Option<i32> {
|
||||
let bytes: [u8; 4] = buf.get(*pos..*pos + 4)?.try_into().ok()?;
|
||||
*pos += 4;
|
||||
Some(i32::from_le_bytes(bytes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn vdf_value_extracts_quoted_field() {
|
||||
assert_eq!(
|
||||
vdf_value("\"path\"\t\t\"/mnt/games/SteamLibrary\"", "path"),
|
||||
Some("/mnt/games/SteamLibrary")
|
||||
);
|
||||
assert_eq!(vdf_value("\"appid\"\t\t\"570\"", "appid"), Some("570"));
|
||||
assert_eq!(vdf_value("\"name\"\t\t\"Dota 2\"", "name"), Some("Dota 2"));
|
||||
assert_eq!(vdf_value("\"installdir\"\t\t\"x\"", "appid"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vdf_paths_pulls_all_library_folders() {
|
||||
let vdf = r#"
|
||||
"libraryfolders"
|
||||
{
|
||||
"0"
|
||||
{
|
||||
"path" "/home/u/.local/share/Steam"
|
||||
"apps" { "570" "123" }
|
||||
}
|
||||
"1"
|
||||
{
|
||||
"path" "/mnt/ssd/SteamLibrary"
|
||||
}
|
||||
}
|
||||
"#;
|
||||
assert_eq!(
|
||||
vdf_paths(vdf),
|
||||
vec![
|
||||
"/home/u/.local/share/Steam".to_string(),
|
||||
"/mnt/ssd/SteamLibrary".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_are_filtered_but_games_kept() {
|
||||
assert!(is_steam_tool(228980, "Steamworks Common Redistributables"));
|
||||
assert!(is_steam_tool(1493710, "Proton Experimental"));
|
||||
assert!(is_steam_tool(0, "Steam Linux Runtime 3.0 (sniper)"));
|
||||
assert!(!is_steam_tool(570, "Dota 2"));
|
||||
assert!(!is_steam_tool(1245620, "ELDEN RING"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steam_art_points_at_the_host_art_proxy() {
|
||||
let art = steam_art(570);
|
||||
assert_eq!(
|
||||
art.portrait.as_deref(),
|
||||
Some("/api/v1/library/art/steam:570/portrait")
|
||||
);
|
||||
assert_eq!(
|
||||
art.header.as_deref(),
|
||||
Some("/api/v1/library/art/steam:570/header")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_local_art_file_matches_the_hashed_librarycache_layout() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = dir
|
||||
.path()
|
||||
.join("appcache/librarycache/3527290/480bd879ac737921bfa2529a6fea15961267ad21");
|
||||
std::fs::create_dir_all(&cache).unwrap();
|
||||
std::fs::write(cache.join("library_600x900.jpg"), b"not really a jpeg").unwrap();
|
||||
|
||||
let found = find_local_art_file(dir.path(), 3527290, ArtKind::Portrait).unwrap();
|
||||
assert_eq!(found, cache.join("library_600x900.jpg"));
|
||||
// A kind with no cached file, and an appid with no cache dir at all, both miss cleanly.
|
||||
assert_eq!(
|
||||
find_local_art_file(dir.path(), 3527290, ArtKind::Hero),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
find_local_art_file(dir.path(), 570, ArtKind::Portrait),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_local_art_file_prefers_the_2x_portrait() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = dir.path().join("appcache/librarycache/570/somehash");
|
||||
std::fs::create_dir_all(&cache).unwrap();
|
||||
std::fs::write(cache.join("library_600x900.jpg"), b"1x").unwrap();
|
||||
std::fs::write(cache.join("library_600x900_2x.jpg"), b"2x").unwrap();
|
||||
|
||||
let found = find_local_art_file(dir.path(), 570, ArtKind::Portrait).unwrap();
|
||||
assert_eq!(found, cache.join("library_600x900_2x.jpg"));
|
||||
}
|
||||
|
||||
// --- Non-Steam shortcuts (custom Steam entries) ---
|
||||
|
||||
/// Build one binary-VDF field for a test `shortcuts.vdf`.
|
||||
fn field_str(key: &str, val: &str) -> Vec<u8> {
|
||||
let mut v = vec![0x01u8];
|
||||
v.extend_from_slice(key.as_bytes());
|
||||
v.push(0);
|
||||
v.extend_from_slice(val.as_bytes());
|
||||
v.push(0);
|
||||
v
|
||||
}
|
||||
fn field_i32(key: &str, val: i32) -> Vec<u8> {
|
||||
let mut v = vec![0x02u8];
|
||||
v.extend_from_slice(key.as_bytes());
|
||||
v.push(0);
|
||||
v.extend_from_slice(&val.to_le_bytes());
|
||||
v
|
||||
}
|
||||
fn map_open(key: &str) -> Vec<u8> {
|
||||
let mut v = vec![0x00u8];
|
||||
v.extend_from_slice(key.as_bytes());
|
||||
v.push(0);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_shortcuts_reads_entries_honors_hidden_and_key_case() {
|
||||
let mut buf = Vec::new();
|
||||
buf.extend(map_open("shortcuts"));
|
||||
// Entry 0: a normal shortcut, with a nested `tags` map to exercise skip_map, and mixed-case
|
||||
// keys (Steam has shipped both `AppName` and `appname`). appid stored as a negative i32.
|
||||
buf.extend(map_open("0"));
|
||||
buf.extend(field_i32("appid", -1838178284)); // == 2456789012 as u32
|
||||
buf.extend(field_str("AppName", "My Emulator"));
|
||||
buf.extend(field_str("Exe", "\"/usr/bin/foo\""));
|
||||
buf.extend(field_i32("IsHidden", 0));
|
||||
buf.extend(map_open("tags"));
|
||||
buf.extend(field_str("0", "emulator"));
|
||||
buf.push(0x08); // end tags
|
||||
buf.push(0x08); // end entry 0
|
||||
// Entry 1: hidden, lowercase key variant.
|
||||
buf.extend(map_open("1"));
|
||||
buf.extend(field_str("appname", "Hidden Game"));
|
||||
buf.extend(field_i32("ishidden", 1));
|
||||
buf.push(0x08); // end entry 1
|
||||
buf.push(0x08); // end shortcuts
|
||||
|
||||
let scs = parse_shortcuts(&buf);
|
||||
assert_eq!(scs.len(), 2);
|
||||
assert_eq!(scs[0].appid, 2_456_789_012);
|
||||
assert_eq!(scs[0].name, "My Emulator");
|
||||
assert!(!scs[0].hidden);
|
||||
assert_eq!(scs[1].name, "Hidden Game");
|
||||
assert!(scs[1].hidden);
|
||||
|
||||
// A hidden shortcut is dropped from the surfaced library; a visible one launches via its
|
||||
// 64-bit game id (not the bare appid).
|
||||
assert!(shortcut_entry(scs.into_iter().nth(1).unwrap()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_shortcuts_is_lenient() {
|
||||
assert!(parse_shortcuts(b"").is_empty()); // not even a top-level map
|
||||
assert!(parse_shortcuts(b"{not binary vdf}").is_empty());
|
||||
// A truncated entry (buffer ends mid-int) yields what parsed cleanly before it — here, none.
|
||||
let mut buf = Vec::new();
|
||||
buf.extend(map_open("shortcuts"));
|
||||
buf.extend(map_open("0"));
|
||||
buf.extend_from_slice(b"\x02appid\x00\x01\x02"); // 2 of 4 int bytes, then EOF
|
||||
assert!(parse_shortcuts(&buf).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcut_entry_launches_via_rungameid() {
|
||||
let sc = Shortcut {
|
||||
appid: 2_456_789_012,
|
||||
name: "My Emulator".into(),
|
||||
exe: "\"/opt/emu/run.sh\"".into(),
|
||||
hidden: false,
|
||||
};
|
||||
let entry = shortcut_entry(sc).unwrap();
|
||||
assert_eq!(entry.id, "steam:2456789012");
|
||||
assert_eq!(entry.store, "steam");
|
||||
let launch = entry.launch.unwrap();
|
||||
assert_eq!(launch.kind, "steam_appid");
|
||||
// Value is the 64-bit game id — digits only, so it passes the shared appid guard.
|
||||
assert_eq!(launch.value, shortcut_gameid(2_456_789_012).to_string());
|
||||
assert!(launch.value.bytes().all(|b| b.is_ascii_digit()));
|
||||
}
|
||||
|
||||
// `shortcut_gameid_composes_appid_and_marker` moved with the function to `launch.rs` (WP1.1).
|
||||
|
||||
#[test]
|
||||
fn crc32_matches_the_known_check_value_and_derives_a_high_bit_appid() {
|
||||
assert_eq!(crc32(b"123456789"), 0xCBF4_3926); // IEEE CRC-32 check value
|
||||
// A derived shortcut appid always has the high bit set (so it never collides with a real
|
||||
// store appid, and its CDN art fetch is skipped).
|
||||
assert_ne!(
|
||||
shortcut_appid("\"/usr/bin/foo\"", "My Emulator") & 0x8000_0000,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_filenames_follow_steams_naming() {
|
||||
assert_eq!(
|
||||
grid_filenames(ArtKind::Portrait, 42),
|
||||
vec!["42p.png", "42p.jpg"]
|
||||
);
|
||||
assert_eq!(
|
||||
grid_filenames(ArtKind::Hero, 42),
|
||||
vec!["42_hero.png", "42_hero.jpg"]
|
||||
);
|
||||
assert_eq!(
|
||||
grid_filenames(ArtKind::Logo, 42),
|
||||
vec!["42_logo.png", "42_logo.jpg"]
|
||||
);
|
||||
assert_eq!(
|
||||
grid_filenames(ArtKind::Header, 42),
|
||||
vec!["42.png", "42.jpg"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_grid_art_file_matches_the_userdata_grid_layout() {
|
||||
let grid = tempfile::tempdir().unwrap();
|
||||
std::fs::write(grid.path().join("2456789012p.jpg"), b"poster").unwrap();
|
||||
let found = find_grid_art_file(grid.path(), 2_456_789_012, ArtKind::Portrait).unwrap();
|
||||
assert_eq!(found, grid.path().join("2456789012p.jpg"));
|
||||
// Missing kinds and a zero-byte file both miss cleanly.
|
||||
assert_eq!(
|
||||
find_grid_art_file(grid.path(), 2_456_789_012, ArtKind::Hero),
|
||||
None
|
||||
);
|
||||
std::fs::write(grid.path().join("42p.png"), b"").unwrap();
|
||||
assert_eq!(find_grid_art_file(grid.path(), 42, ArtKind::Portrait), None);
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
//! Xbox / Microsoft Store (UWP) provider: installed packages, PFN resolution, and store art. Split out of the `library` facade (plan §W5).
|
||||
|
||||
use super::art::cached_art;
|
||||
use super::*;
|
||||
|
||||
/// Reads installed Xbox / Game Pass / Store GDK games from the flat-file install dirs. Windows-only.
|
||||
/// Best-effort: empty when no `XboxGames` dir exists.
|
||||
#[cfg(windows)]
|
||||
pub struct XboxProvider;
|
||||
|
||||
#[cfg(windows)]
|
||||
impl LibraryProvider for XboxProvider {
|
||||
fn store(&self) -> &'static str {
|
||||
"xbox"
|
||||
}
|
||||
|
||||
fn list(&self) -> Vec<GameEntry> {
|
||||
xbox_games()
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan each fixed drive's default `<drive>:\XboxGames` for GDK games — the presence of
|
||||
/// `Content\MicrosoftGame.config` is the game marker (so we list games, not ordinary UWP apps). A
|
||||
/// custom install folder (set via the undocumented `.GamingRoot`) isn't covered; the default folder
|
||||
/// is the common case. Non-GDK pure-UWP Store games (under the ACL-locked WindowsApps) are missed too.
|
||||
#[cfg(windows)]
|
||||
fn xbox_games() -> Vec<GameEntry> {
|
||||
let mut games = Vec::new();
|
||||
for letter in b'C'..=b'Z' {
|
||||
let root = PathBuf::from(format!("{}:\\XboxGames", letter as char));
|
||||
let Ok(rd) = std::fs::read_dir(&root) else {
|
||||
continue;
|
||||
};
|
||||
for entry in rd.flatten() {
|
||||
let title_dir = entry.path();
|
||||
let cfg = title_dir.join("Content").join("MicrosoftGame.config");
|
||||
if !cfg.is_file() {
|
||||
continue;
|
||||
}
|
||||
// Cap the read like the other untrusted on-disk manifests (Epic `read_capped`, Lutris
|
||||
// art) — a planted multi-GB MicrosoftGame.config under `<drive>:\XboxGames\…\Content\`
|
||||
// must not OOM the privileged host during enumeration (security-review 2026-07-17). A
|
||||
// real GDK manifest is a few KB.
|
||||
match cfg.metadata() {
|
||||
Ok(m) if m.len() <= 1024 * 1024 => {}
|
||||
_ => continue,
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(&cfg) else {
|
||||
continue;
|
||||
};
|
||||
let folder = title_dir
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().into_owned());
|
||||
let Some((name, app_id, title, store_id)) = xbox_parse_config(&text, folder.as_deref())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(pfn) = xbox_pfn(&name) else {
|
||||
tracing::debug!(package = %name, "xbox: no AppRepository entry → can't resolve PFN, skipping");
|
||||
continue;
|
||||
};
|
||||
let id_key = if store_id.is_empty() {
|
||||
pfn.clone()
|
||||
} else {
|
||||
store_id
|
||||
};
|
||||
let id = format!("xbox:{id_key}");
|
||||
// Art (unofficial displaycatalog, keyed by StoreId) is resolved off the hot path by the
|
||||
// background warmer; read whatever it has cached (title-only until warmed / if no StoreId).
|
||||
let art = cached_art(&id).unwrap_or_default();
|
||||
games.push(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
icon: None,
|
||||
meta: GameMeta::pc(),
|
||||
id,
|
||||
store: "xbox".into(),
|
||||
title,
|
||||
art,
|
||||
launch: Some(LaunchSpec {
|
||||
kind: "aumid".into(),
|
||||
value: format!("{pfn}!{app_id}"),
|
||||
}),
|
||||
// AUMID activation goes through the shell, so the host never owns the process: the
|
||||
// title's `Content` dir (which holds the game's binaries) is the detect signal.
|
||||
detect: DetectSpec::dir(title_dir.join("Content")),
|
||||
});
|
||||
}
|
||||
}
|
||||
games.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
games.dedup_by(|a, b| a.id == b.id); // same game on two drives → one entry
|
||||
games
|
||||
}
|
||||
|
||||
/// Parse the fields we need from a `MicrosoftGame.config`: `(Identity Name, AppId, title, StoreId)`.
|
||||
/// AppId is the `<Executable>`'s `Id` (the AUMID app id, typically "Game"). The title prefers
|
||||
/// `ShellVisuals@DefaultDisplayName`, but that can be an unresolved `ms-resource:` ref → fall back to
|
||||
/// the install folder name, then the package name.
|
||||
#[cfg(windows)]
|
||||
fn xbox_parse_config(text: &str, folder: Option<&str>) -> Option<(String, String, String, String)> {
|
||||
let doc = roxmltree::Document::parse(text).ok()?;
|
||||
let root = doc.root_element();
|
||||
let name = root
|
||||
.children()
|
||||
.find(|n| n.has_tag_name("Identity"))?
|
||||
.attribute("Name")?
|
||||
.to_string();
|
||||
let app_id = root
|
||||
.children()
|
||||
.find(|n| n.has_tag_name("ExecutableList"))
|
||||
.and_then(|el| {
|
||||
el.children()
|
||||
.filter(|n| n.has_tag_name("Executable"))
|
||||
.find_map(|e| e.attribute("Id"))
|
||||
})?
|
||||
.to_string();
|
||||
let ddn = root
|
||||
.children()
|
||||
.find(|n| n.has_tag_name("ShellVisuals"))
|
||||
.and_then(|sv| sv.attribute("DefaultDisplayName"))
|
||||
.filter(|s| !s.is_empty() && !s.starts_with("ms-resource"));
|
||||
let title = ddn
|
||||
.map(String::from)
|
||||
.or_else(|| folder.map(String::from))
|
||||
.unwrap_or_else(|| name.clone());
|
||||
let store_id = root
|
||||
.children()
|
||||
.find(|n| n.has_tag_name("StoreId"))
|
||||
.and_then(|n| n.text())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Some((name, app_id, title, store_id))
|
||||
}
|
||||
|
||||
/// Resolve a package's PackageFamilyName by finding its
|
||||
/// `AppRepository\Packages\<PackageFullName>` dir (machine-wide, SYSTEM-readable) and reducing the
|
||||
/// full name to `Name_PublisherHash`. This READS the authoritative PFN — never compute the hash.
|
||||
///
|
||||
/// **Readable by the host, NOT by the plugin runner.** Measured on 2026-08-06: that directory is
|
||||
/// `UnauthorizedAccessException` for `NT AUTHORITY\LocalService` (which the runner is), while the
|
||||
/// host service runs as LocalSystem and enumerates all 348 entries. That asymmetry is why the
|
||||
/// `xbox` launch kind exists — a library plugin sends the package Identity it CAN read out of
|
||||
/// `MicrosoftGame.config`, and this resolves the rest at launch time (see `launch.rs`).
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn xbox_pfn(identity: &str) -> Option<String> {
|
||||
let pkgs = PathBuf::from(std::env::var_os("ProgramData")?)
|
||||
.join("Microsoft")
|
||||
.join("Windows")
|
||||
.join("AppRepository")
|
||||
.join("Packages");
|
||||
let prefix = format!("{identity}_");
|
||||
for e in std::fs::read_dir(&pkgs).ok()?.flatten() {
|
||||
let dn = e.file_name().to_string_lossy().into_owned();
|
||||
if dn.starts_with(&prefix) {
|
||||
if let Some(pfn) = pfn_from_full(&dn, identity) {
|
||||
return Some(pfn);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// PackageFamilyName from a PackageFullName dir name
|
||||
/// (`Name_Version_Arch_ResourceId_PublisherHash`) → `Name_PublisherHash`. The hash is the last
|
||||
/// `_`-segment; `Name` is the caller's identity.
|
||||
#[cfg(windows)]
|
||||
fn pfn_from_full(dir_name: &str, identity: &str) -> Option<String> {
|
||||
let hash = dir_name.rsplit('_').next()?;
|
||||
(!hash.is_empty() && hash != dir_name).then(|| format!("{identity}_{hash}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn xbox_parse_config_and_pfn() {
|
||||
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<Game configVersion="1">
|
||||
<Identity Name="Microsoft.624F8B84B80" Publisher="CN=Microsoft" Version="1.0.0.0" />
|
||||
<ExecutableList>
|
||||
<Executable Name="gamelaunchhelper.exe" Id="Game" />
|
||||
</ExecutableList>
|
||||
<StoreId>9NBLGGH4R315</StoreId>
|
||||
<ShellVisuals DefaultDisplayName="Halo Infinite" Square150x150Logo="x.png" />
|
||||
</Game>"#;
|
||||
let (name, app_id, title, store_id) = xbox_parse_config(xml, Some("HaloInfinite")).unwrap();
|
||||
assert_eq!(name, "Microsoft.624F8B84B80");
|
||||
assert_eq!(app_id, "Game");
|
||||
assert_eq!(title, "Halo Infinite");
|
||||
assert_eq!(store_id, "9NBLGGH4R315");
|
||||
// An ms-resource DefaultDisplayName is unresolvable → fall back to the install folder name.
|
||||
let xml2 = r#"<Game><Identity Name="Pkg.Name"/>
|
||||
<ExecutableList><Executable Id="App"/></ExecutableList>
|
||||
<ShellVisuals DefaultDisplayName="ms-resource:DisplayName"/></Game>"#;
|
||||
let (_, app2, title2, sid2) = xbox_parse_config(xml2, Some("MyGameFolder")).unwrap();
|
||||
assert_eq!(app2, "App");
|
||||
assert_eq!(title2, "MyGameFolder");
|
||||
assert_eq!(sid2, "");
|
||||
// PackageFamilyName reduced from a PackageFullName dir name (the hash is the last segment).
|
||||
assert_eq!(
|
||||
pfn_from_full(
|
||||
"Microsoft.624F8B84B80_1.0.0.0_x64__8wekyb3d8bbwe",
|
||||
"Microsoft.624F8B84B80"
|
||||
)
|
||||
.as_deref(),
|
||||
Some("Microsoft.624F8B84B80_8wekyb3d8bbwe")
|
||||
);
|
||||
assert!(pfn_from_full("NoUnderscore", "NoUnderscore").is_none());
|
||||
}
|
||||
}
|
||||
@@ -314,7 +314,7 @@ pub fn openapi_json() -> String {
|
||||
(name = "pairing", description = "Pairing PIN delivery (the out-of-band half of the GameStream pairing handshake)"),
|
||||
(name = "native", description = "Native punktfunk/1 pairing: arm a window, display the host PIN, manage paired devices"),
|
||||
(name = "session", description = "Active streaming session control"),
|
||||
(name = "library", description = "Game library: installed-store titles (Steam) plus user-curated custom entries"),
|
||||
(name = "library", description = "Game library: the titles each installed library plugin syncs, plus user-curated custom entries"),
|
||||
(name = "stats", description = "Streaming performance-stats capture: arm/stop a recording, read the live + saved time-series for graphing"),
|
||||
(name = "logs", description = "Host log stream: the newest in-memory log entries, cursor-paged for live following"),
|
||||
(name = "events", description = "Host lifecycle events: an SSE stream (client/session/stream lifecycle, pairing, displays, library, host) with Last-Event-ID resume and server-side kind filters"),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Library-tagged management endpoints: installed-store + custom game entries and box art.
|
||||
//! Split out of the `mgmt` facade (plan §W5).
|
||||
//! Library-tagged management endpoints: the game catalog (plugin-synced + custom entries), the
|
||||
//! source toggles, the provider reconcile API and box art. Split out of the `mgmt` facade (plan §W5).
|
||||
|
||||
use super::auth::AuthLane;
|
||||
use super::shared::*;
|
||||
@@ -70,11 +70,12 @@ pub(crate) struct LibraryQuery {
|
||||
|
||||
/// List the game library
|
||||
///
|
||||
/// Every installed-store title (Steam, read from the host's local files — no Steam API key)
|
||||
/// merged with the user's custom entries, sorted by title. Artwork fields are URLs the client
|
||||
/// fetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the
|
||||
/// entries a given external provider owns; `?platform=` to one platform (case-insensitive —
|
||||
/// installed-store titles are `PC`, custom/provider entries carry whatever was authored).
|
||||
/// Every title this host knows about, sorted by title: the entries each installed library plugin
|
||||
/// has synced (Steam, Lutris, Heroic, Epic, GOG, Xbox, Playnite, ROM managers, …) plus the user's
|
||||
/// own custom entries. Artwork fields are URLs the client fetches directly, except local files on
|
||||
/// the host, which are rewritten to this API's own art proxy. `?provider=` narrows to the entries a
|
||||
/// given external provider owns; `?platform=` to one platform (case-insensitive — whatever the
|
||||
/// source authored, conventionally `PC` for desktop stores).
|
||||
///
|
||||
/// **The operator's own lane additionally sees the titles they have HIDDEN**, each carrying
|
||||
/// `hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The
|
||||
@@ -222,17 +223,20 @@ pub(crate) async fn set_library_entry_hidden(
|
||||
/// Request body for `setLibraryScanner`.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct ScannerToggle {
|
||||
/// Whether the scanner should run on this host.
|
||||
/// Whether this source should contribute titles on this host.
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
/// List the library scanners
|
||||
/// List the library sources
|
||||
///
|
||||
/// The installed-store scanners this host supports — the list is platform-dependent (Steam
|
||||
/// everywhere; Lutris + Heroic on Linux; Epic, GOG, and Xbox/Game Pass on Windows), so the console
|
||||
/// renders a toggle only for scanners that can do anything here. Scanners default to enabled;
|
||||
/// disabling one hides its titles from every library surface from the next read. The user-curated
|
||||
/// custom store is not a scanner and is always on.
|
||||
/// Every game source on this host with its enable state — one row per installed library plugin
|
||||
/// (Steam, Lutris, Heroic, Epic, GOG, Xbox, Playnite, ROM managers, …), so the list reflects what
|
||||
/// the operator has actually installed rather than what this build happens to support. Sources
|
||||
/// default to enabled; disabling one hides its titles from every library surface from the next
|
||||
/// read. The user-curated custom store is not a source and is always on.
|
||||
///
|
||||
/// Older hosts (≤ v0.27.x) also listed the six scanners built into the host binary, with
|
||||
/// `origin: "builtin"`. Those are gone; every row now reports `origin: "plugin"`.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/library/scanners",
|
||||
@@ -247,12 +251,13 @@ pub(crate) async fn list_library_scanners() -> Json<Vec<crate::library::ScannerI
|
||||
Json(crate::library::list_scanners())
|
||||
}
|
||||
|
||||
/// Enable or disable a library scanner
|
||||
/// Enable or disable a library source
|
||||
///
|
||||
/// Persists the toggle and applies it from the next library read (no restart). Disabling a scanner
|
||||
/// Persists the toggle and applies it from the next library read (no restart). Disabling a source
|
||||
/// hides its titles everywhere — the console grid, native clients, and the GameStream app list —
|
||||
/// and re-enabling brings them straight back (nothing is deleted; the scan just runs again). Emits
|
||||
/// `library.changed` with the scanner id as `source` when the state changed.
|
||||
/// and re-enabling brings them straight back. Nothing is deleted: the plugin may keep reconciling
|
||||
/// while its source is off, and those entries simply aren't surfaced. Emits `library.changed` with
|
||||
/// the source id as `source` when the state changed.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/library/scanners/{id}",
|
||||
@@ -436,11 +441,11 @@ pub(crate) struct ReconcileQuery {
|
||||
///
|
||||
/// `?store=` additionally **claims** that store for the provider: its entries then surface with
|
||||
/// deterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque
|
||||
/// `custom:<id>` ones — which is what lets a library plugin reproduce the entries an in-host scanner
|
||||
/// used to produce, right down to the GameStream app ids and client-side art caches. One provider
|
||||
/// per store; a second claimant gets 409. While a claim is held the matching built-in scanner is
|
||||
/// suppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty
|
||||
/// reconcile (a store can legitimately have zero installed titles).
|
||||
/// `custom:<id>` ones — which is what let a library plugin reproduce the entries the in-host scanner
|
||||
/// used to produce, right down to the GameStream app ids and client-side art caches, and is why
|
||||
/// removing those scanners changed nothing downstream. One provider per store; a second claimant
|
||||
/// gets 409. The claim is released by `DELETE`, not by an empty reconcile (a store can legitimately
|
||||
/// have zero installed titles).
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/library/provider/{provider}",
|
||||
@@ -551,11 +556,12 @@ pub(crate) async fn delete_provider_entries(Path(provider): Path<String>) -> Res
|
||||
///
|
||||
/// Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams
|
||||
/// the image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,
|
||||
/// and a library plugin's claimed-store entries) serves its local art file. A Steam title falls back
|
||||
/// to the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the
|
||||
/// user's Steam client already shows for it), the public Steam CDN's flat URL convention second
|
||||
/// (newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case
|
||||
/// this 404s and the client falls through to its next art candidate).
|
||||
/// and a library plugin's claimed-store entries) serves its local art file; anything else 404s and
|
||||
/// the client falls through to its next art candidate.
|
||||
///
|
||||
/// The host fetches nothing here. Art a plugin published as an `http(s)` URL is fetched by the
|
||||
/// client directly — this proxy exists for the *local* files a plugin finds on the host's own disk
|
||||
/// (a launcher's cover cache), which a client has no way to read.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/library/art/{id}/{kind}",
|
||||
@@ -575,10 +581,11 @@ pub(crate) async fn get_library_art(Path((id, kind)): Path<(String, String)>) ->
|
||||
let Some(kind) = crate::library::ArtKind::parse(&kind) else {
|
||||
return api_error(StatusCode::NOT_FOUND, "unknown art kind");
|
||||
};
|
||||
// `library.json` FIRST, for ANY id (WP1.2). Stored entries — manual, provider-synced, and (once
|
||||
// store claims land) a scanner plugin's `steam:570` — all serve their local art file from here,
|
||||
// so the proxy never has to know which store an id belongs to. Steam ids aren't stored today, so
|
||||
// this misses and the legacy branch below still answers them.
|
||||
// `library.json`, for ANY id (WP1.2): manual entries, provider-synced entries and a library
|
||||
// plugin's claimed-store `steam:570` all serve their local art file from here, so the proxy never
|
||||
// has to know which store an id belongs to. This was one of two branches — the second resolved a
|
||||
// `steam:` id through the in-host Steam scanner's own cache/CDN ladder, and was retired with that
|
||||
// scanner (M6). Steam ids now arrive here like every other claimed store's.
|
||||
let stored = {
|
||||
let id = id.clone();
|
||||
tokio::task::spawn_blocking(move || crate::library::library_local_art_bytes(&id, kind))
|
||||
@@ -587,20 +594,5 @@ pub(crate) async fn get_library_art(Path((id, kind)): Path<(String, String)>) ->
|
||||
if let Ok(Some((bytes, ctype))) = stored {
|
||||
return ([(header::CONTENT_TYPE, ctype)], bytes).into_response();
|
||||
}
|
||||
// Legacy in-host Steam scanner: local Steam cache, then the flat CDN URL. Retired with the
|
||||
// scanner itself once the steam plugin claims the store (M6).
|
||||
if let Some(appid) = id
|
||||
.strip_prefix("steam:")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
{
|
||||
return match tokio::task::spawn_blocking(move || {
|
||||
crate::library::steam_art_bytes(appid, kind)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Some((bytes, ctype))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(),
|
||||
_ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"),
|
||||
};
|
||||
}
|
||||
api_error(StatusCode::NOT_FOUND, "no art of that kind for this title")
|
||||
}
|
||||
|
||||
@@ -2136,11 +2136,16 @@ async fn hooks_get_shape_and_put_validation() {
|
||||
|
||||
// ------------------------------------------------------------------ library scanners
|
||||
|
||||
/// The scanner list is platform-shaped and read-only-safe; the toggle rejects unknown ids
|
||||
/// The source list is plugin-shaped and read-only-safe; the toggle rejects unknown ids
|
||||
/// with 404. (A successful toggle PUT would write the developer's real
|
||||
/// `library-scanners.json`, so the write path is exercised only through the unknown-id
|
||||
/// rejection here — the settings round-trip itself is unit-tested in `library::scanners`
|
||||
/// against pure shapes.)
|
||||
///
|
||||
/// This used to assert that `steam` is present on every platform, which was the defining property
|
||||
/// while the scanners were compiled in. It is deliberately gone: the list is now derived entirely
|
||||
/// from what the operator has installed, so on a host with no library plugins it is legitimately
|
||||
/// empty. What replaces it is the invariant that outlives the built-ins — **every** row is a plugin.
|
||||
#[tokio::test]
|
||||
async fn library_scanner_list_and_unknown_toggle() {
|
||||
let app = test_app(test_state(), None);
|
||||
@@ -2149,12 +2154,16 @@ async fn library_scanner_list_and_unknown_toggle() {
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let scanners = json.as_array().expect("a scanner array");
|
||||
assert!(
|
||||
scanners
|
||||
.iter()
|
||||
.any(|sc| sc["id"] == "steam" && sc["label"].is_string() && sc["enabled"].is_boolean()),
|
||||
"steam must be a scanner on every platform: {json}"
|
||||
scanners.iter().all(|sc| sc["origin"] == "plugin"),
|
||||
"no host build reports a builtin source any more: {json}"
|
||||
);
|
||||
// Only platform-available scanners appear (`custom` is a store, never a scanner).
|
||||
assert!(
|
||||
scanners.iter().all(|sc| sc["id"].is_string()
|
||||
&& sc["label"].is_string()
|
||||
&& sc["enabled"].is_boolean()),
|
||||
"every source row must carry the shape the console renders: {json}"
|
||||
);
|
||||
// `custom` is a store, never a source — the toggle surface must not offer it.
|
||||
assert!(scanners.iter().all(|sc| sc["id"] != "custom"));
|
||||
|
||||
let (s, json) = send(
|
||||
@@ -2170,7 +2179,7 @@ async fn library_scanner_list_and_unknown_toggle() {
|
||||
assert_eq!(
|
||||
s,
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown scanner id must 404: {json}"
|
||||
"unknown source id must 404: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -404,10 +404,10 @@ pub(crate) async fn serve(
|
||||
crate::vdisplay::preflight_takeover_privilege();
|
||||
// …and the other end of that: give the box its session back when WE are the ones going away.
|
||||
install_shutdown_restore();
|
||||
// Host-lifetime cover-art warmer: fetches + caches GOG/Xbox cover art (no-auth api.gog.com /
|
||||
// displaycatalog) off the hot path so `all_games()` (the library list + launch resolve) never
|
||||
// blocks on the network. A no-op on a host whose stores all carry their own art.
|
||||
let _art_warmer = crate::library::start_art_warmer();
|
||||
// (No cover-art warmer any more: it existed to fetch GOG/Xbox art off the hot path for the two
|
||||
// built-in scanners that had to ask a network catalog what a cover was. Those scanners are gone,
|
||||
// and a library plugin resolves art while it scans and publishes it on the entry — so `all_games()`
|
||||
// never touches the network to begin with.)
|
||||
// Pairing state (arming PIN + trust store) is shared with the management API. If it was armed
|
||||
// at startup (the CLI flags), surface the PIN the headless operator reads from the log; the
|
||||
// web console arms it on demand instead (a fresh, time-limited PIN).
|
||||
|
||||
@@ -2722,14 +2722,35 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
last_forced_idr = Some(now);
|
||||
rfi_echo_swallowed = 0; // the IDR resets the episode — echoes of IT coalesce via the cooldown
|
||||
if let Some(period) = recovery_cadence.note(now) {
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.1}", period.as_secs_f64()),
|
||||
"client keyframe recoveries are METRONOMIC — a periodic host/display \
|
||||
disturbance (display-topology churn, display-poller software, \
|
||||
virtual-display timing) is the likely cause, not random network loss; \
|
||||
correlate with 'slow display-descriptor poll' / 'display descriptor \
|
||||
changed' / 'IDD-push capture stall' lines"
|
||||
);
|
||||
// A period that lands on the CLIENT's jump-to-live cooldown is not evidence
|
||||
// of a periodic disturbance here at all — it is the client shedding a
|
||||
// standing receive queue, which it is rate-limited to do exactly this often
|
||||
// (`punktfunk_core::client::FLUSH_COOLDOWN`), so the cadence is a property of
|
||||
// our own backpressure code rather than of anything physical. Naming display
|
||||
// churn there sent a 2026-08-13 field investigation at three innocent
|
||||
// subsystems while the real chain was: client refused the codec → demoted to
|
||||
// a slower decode rung → could not sustain the rate → standing queue.
|
||||
// Perfect periodicity argues FOR a software cooldown, not against it.
|
||||
if matches_client_flush_cadence(period) {
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.1}", period.as_secs_f64()),
|
||||
"client keyframe recoveries match the client's jump-to-live cooldown \
|
||||
— the CLIENT cannot sustain the stream and is shedding a standing \
|
||||
receive queue (check its log for 'receive backlog stopped draining' \
|
||||
with queue_depth, and for a decode rung that demoted); a slower \
|
||||
decode path or a link below the bitrate does this, and it is NOT a \
|
||||
host display disturbance"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.1}", period.as_secs_f64()),
|
||||
"client keyframe recoveries are METRONOMIC — a periodic host/display \
|
||||
disturbance (display-topology churn, display-poller software, \
|
||||
virtual-display timing) is the likely cause, not random network \
|
||||
loss; correlate with 'slow display-descriptor poll' / 'display \
|
||||
descriptor changed' / 'IDD-push capture stall' lines"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3785,6 +3806,23 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a measured keyframe-recovery period is the CLIENT's jump-to-live cooldown rather
|
||||
/// than anything happening on this host.
|
||||
///
|
||||
/// Every jump-to-live sends a keyframe request and is rate-limited to one per
|
||||
/// [`punktfunk_core::client::FLUSH_COOLDOWN`], so a client that simply cannot sustain the
|
||||
/// stream asks at exactly that spacing for as long as it stays behind. The recovery-cadence
|
||||
/// detector reads perfect periodicity as evidence of a periodic *disturbance*, which is
|
||||
/// backwards here: a fixed software cooldown is the most periodic thing in the system.
|
||||
///
|
||||
/// ±10 % — wide enough for scheduling jitter and the request's network trip, narrow enough that
|
||||
/// it cannot swallow the disturbance cadences the other branch exists to report (display-mode
|
||||
/// churn and descriptor polls run at their own, unrelated periods).
|
||||
fn matches_client_flush_cadence(period: std::time::Duration) -> bool {
|
||||
let flush = punktfunk_core::client::FLUSH_COOLDOWN;
|
||||
period.abs_diff(flush) < flush / 10
|
||||
}
|
||||
|
||||
/// One mode's capture/encode pipeline: (capturer, encoder, first frame, frame interval).
|
||||
/// Dropping the capturer tears down the PipeWire stream and the virtual output with it.
|
||||
type Pipeline = (
|
||||
@@ -4597,6 +4635,26 @@ fn build_pipeline(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The 2026-08-13 field log's exact reading — `period_s=2.0` — must be attributed to the
|
||||
/// client's backlog shedding, not to a host display disturbance. The whole point of routing
|
||||
/// on the shared constant is that this stays true if the cooldown is ever retuned, so the
|
||||
/// test derives its cases from `FLUSH_COOLDOWN` instead of hardcoding two seconds.
|
||||
#[test]
|
||||
fn a_recovery_cadence_on_the_clients_cooldown_is_not_blamed_on_the_display() {
|
||||
let flush = punktfunk_core::client::FLUSH_COOLDOWN;
|
||||
assert!(matches_client_flush_cadence(flush), "the field reading");
|
||||
// Scheduling jitter and the request's trip across the link stay inside the band.
|
||||
assert!(matches_client_flush_cadence(flush + flush / 20));
|
||||
assert!(matches_client_flush_cadence(flush - flush / 20));
|
||||
|
||||
// Cadences that are NOT the cooldown still reach the display-disturbance branch — the
|
||||
// band must not be so wide that it swallows them.
|
||||
assert!(!matches_client_flush_cadence(flush / 2));
|
||||
assert!(!matches_client_flush_cadence(flush * 2));
|
||||
assert!(!matches_client_flush_cadence(flush + flush / 5));
|
||||
assert!(!matches_client_flush_cadence(std::time::Duration::ZERO));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_escalated_but_caught_up_encoder_stops_refusing_climbs() {
|
||||
const DEGRADE: u32 = 10;
|
||||
|
||||
@@ -4,37 +4,42 @@ description: How Punktfunk finds your installed games, how to add one by hand, a
|
||||
---
|
||||
|
||||
Every Punktfunk host keeps one **game library** — a single list of titles that every surface reads
|
||||
from. It has three sources: the launchers the host scans on disk, entries you add by hand in the
|
||||
[web console](/docs/web-console), and titles a [plugin](/docs/plugins) syncs in.
|
||||
from. It has two sources: the [plugins](/docs/plugins) you install for the launchers you actually
|
||||
use, and entries you add by hand in the [web console](/docs/web-console).
|
||||
|
||||
Whichever source a title came from, it looks the same everywhere: a poster, a name, and a stable id
|
||||
like `steam:570` or `custom:9f2a1c…`. Pick one on a client and the host launches it into the stream.
|
||||
|
||||
## Where your games come from
|
||||
|
||||
The host reads your launchers' **own local files**. There are no accounts to connect and no API
|
||||
keys — nothing leaves the machine to build the list. Each scanner is best-effort: a launcher that
|
||||
isn't installed simply contributes nothing. Cover art is the one exception, and it needs no account
|
||||
either — see [Cover art](#cover-art).
|
||||
**Install a plugin for each launcher you want in the library.** A fresh host holds no games until
|
||||
you do — go to the console's **Library** page, open **Game sources**, and install the ones you use.
|
||||
It takes a click each.
|
||||
|
||||
Which scanners exist depends on the host's operating system:
|
||||
Each plugin reads that launcher's **own local files** on the host. There are no accounts to connect
|
||||
and no API keys — nothing leaves the machine to build the list. A launcher that isn't installed
|
||||
contributes nothing, so installing a plugin you turn out not to need costs you an empty source and
|
||||
nothing else.
|
||||
|
||||
| Source | Linux host | Windows host | What it reads |
|
||||
| Plugin | Linux host | Windows host | What it reads |
|
||||
|---|---|---|---|
|
||||
| **Steam** | ✅ | ✅ | Installed titles from `appmanifest_<appid>.acf` in every Steam library folder, plus your own **non-Steam shortcuts** |
|
||||
| **Lutris** | ✅ | — | The local Lutris database (`pga.db`) |
|
||||
| **Heroic (Epic / GOG / Amazon)** | ✅ | — | Heroic Games Launcher's local library cache, all three of its backends |
|
||||
| **Epic Games Launcher** | — | ✅ | The launcher's install manifests |
|
||||
| **GOG Galaxy** | — | ✅ | The GOG install registry and each game's `.info` file |
|
||||
| **Xbox / Game Pass** | — | ✅ | GDK games in each drive's default `XboxGames` folder |
|
||||
| **GOG** | — | ✅ | The GOG install registry and each game's `.info` file |
|
||||
| **Playnite** | — | ✅ | Your Playnite library, whichever stores it aggregates |
|
||||
| **ROM Manager** | ✅ | ✅ | Your ROM folders, matched against a metadata source |
|
||||
|
||||
Every scanner is **on by default**.
|
||||
> Through v0.27.x six of these scanners were built into the host itself and ran whether you wanted
|
||||
> them or not. From **v0.28.0** they are plugins like any other. If you were already running the
|
||||
> plugin for a launcher, nothing changes — the ids, art and app ids are identical by design. If you
|
||||
> were relying on the built-in scanner, install that launcher's plugin once and your grid comes back
|
||||
> exactly as it was, including anything you had switched off or hidden.
|
||||
|
||||
A few things are deliberately left out. Steam's tooling — Proton, the Steam Linux Runtimes, Steamworks
|
||||
Common Redistributables, SteamVR — is filtered out, so your grid holds games rather than plumbing. A
|
||||
non-Steam shortcut you have hidden inside Steam stays hidden here too. On Windows, an Xbox game
|
||||
installed outside the default `XboxGames` folder isn't found, and a Microsoft Store game that isn't a
|
||||
GDK title isn't listed at all.
|
||||
non-Steam shortcut you have hidden inside Steam stays hidden here too.
|
||||
|
||||
To see exactly what the host resolved, run [`punktfunk-host library`](/docs/host-cli) on the host: it
|
||||
prints the whole library as JSON. That answers "does the host see my games?" without involving a
|
||||
@@ -42,19 +47,19 @@ client.
|
||||
|
||||
## Turning a source off
|
||||
|
||||
The console's **Library** page has a **Game sources** card with one chip per scanner this host
|
||||
supports. A chip is highlighted when the host scans that launcher; click it to turn the scanner off.
|
||||
The console's **Library** page has a **Game sources** card with one chip per source this host has.
|
||||
A chip is highlighted when that source is contributing titles; click it to turn the source off.
|
||||
|
||||
Turning a source off hides its titles from **everywhere at once** — the console grid, every native
|
||||
client, the Moonlight app list, and launching. Nothing is deleted, the change needs no restart, and
|
||||
turning the source back on brings the titles straight back on the next read.
|
||||
client, the Moonlight app list, and launching. Nothing is deleted and the change needs no restart:
|
||||
the plugin keeps its titles, they simply stop being shown, and turning the source back on brings
|
||||
them straight back on the next read. (To remove a source's titles for good, uninstall its plugin.)
|
||||
|
||||
The list of chips is built from what the host can actually do, so a Windows host never offers you a
|
||||
Lutris toggle. Your hand-added entries are not a scanner and have no chip — they are always shown.
|
||||
Your hand-added entries are not a source and have no chip — they are always shown.
|
||||
|
||||
The choice is stored per host in `library-scanners.json`, next to the rest of the host config
|
||||
(`~/.config/punktfunk/` on Linux, `%ProgramData%\punktfunk\` on Windows). Only the sources you turned
|
||||
*off* are written down, so a scanner added by a future release starts enabled.
|
||||
*off* are written down, so a source added later starts enabled.
|
||||
|
||||
## Adding a game by hand
|
||||
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
---
|
||||
title: Plugins
|
||||
description: First-party plugins — sync your ROM collection or Playnite library into the game library, or hand a real USB device on the couch to the host — and how to install them.
|
||||
description: First-party plugins — bring each of your game launchers into the library, sync a ROM collection or Playnite library, or hand a real USB device on the couch to the host — and how to install them.
|
||||
---
|
||||
|
||||
Plugins extend the host through the **scripting runner** (see [Events & hooks](/docs/automation)). A
|
||||
plugin runs alongside the host, reconciles titles into your [game library](/docs/game-library) as a
|
||||
provider — so they appear in the grid on every client — and can add its own page to the [web console](/docs/web-console).
|
||||
|
||||
Three first-party plugins today:
|
||||
**Your game library is built from plugins.** From v0.28.0 the host scans no launcher on its own, so
|
||||
install the one for each launcher you use — that is what puts games in the grid.
|
||||
|
||||
| Plugin | What it does |
|
||||
|---|---|
|
||||
| **ROM Manager** | Scans your ROM directories, matches each platform to an installed emulator, and syncs them into the library with box art. |
|
||||
| **Playnite** | Mirrors your [Playnite](https://playnite.link) library — every store and emulator it manages — into the library, launched back through Playnite. |
|
||||
| **VirtualHere** | Hands a real USB device on the couch — wheel, HOTAS, pad — to the host while you play, and gives it back after. Needs [VirtualHere](https://www.virtualhere.com/), sold separately. |
|
||||
| Plugin | Host | What it does |
|
||||
|---|---|---|
|
||||
| **Steam** | Linux, Windows | Your installed Steam titles and non-Steam shortcuts, with Steam's own art, plus a Big Picture tile. |
|
||||
| **Lutris** | Linux | Your Lutris games, with art, and a tile that opens Lutris itself. |
|
||||
| **Heroic** | Linux | Everything Heroic manages — Epic, GOG and Amazon — plus a tile that opens Heroic. |
|
||||
| **Epic Games Store** | Windows | Your installed Epic titles, from the launcher's own manifests. |
|
||||
| **GOG** | Windows | Your installed GOG games, launched directly — Galaxy is never involved. |
|
||||
| **Playnite** | Windows | Mirrors your [Playnite](https://playnite.link) library — every store and emulator it manages — into the library, launched back through Playnite. |
|
||||
| **ROM Manager** | Linux, Windows | Scans your ROM directories, matches each platform to an installed emulator, and syncs them into the library with box art. |
|
||||
| **VirtualHere** | Linux, Windows | Hands a real USB device on the couch — wheel, HOTAS, pad — to the host while you play, and gives it back after. Needs [VirtualHere](https://www.virtualhere.com/), sold separately. |
|
||||
|
||||
## Installing from the console
|
||||
|
||||
|
||||
+904
-27
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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**
|
||||
|
||||
+11
-1
@@ -19,7 +19,17 @@ WORKDIR /repo/web
|
||||
# --ignore-scripts: the root `prepare` script runs codegen, which needs sources that
|
||||
# aren't copied yet — `bun run build` regenerates everything below.
|
||||
COPY web/package.json web/bun.lock ./
|
||||
RUN bun install --frozen-lockfile --ignore-scripts
|
||||
# Retried, because a single failed tarball kills the whole install and takes the image build with
|
||||
# it. Seen in CI as `error: Fail extracting tarball for "@rolldown/binding-linux-x64-musl"` — a
|
||||
# 7.7 MB optional binding that bun fetches on any linux-x64 host (the lockfile records `os`/`cpu`
|
||||
# but no libc, so the musl and glibc bindings are equally eligible) and that had arrived truncated.
|
||||
# The lockfile is not at fault: `bun install --frozen-lockfile` accepts it, regenerating it with the
|
||||
# same bun is byte-identical, and this exact layer builds clean for --platform linux/amd64.
|
||||
# Two attempts with a pause, then fail for real — this recovers a truncated download and does NOT
|
||||
# paper over a runner that is out of disk, which fails identically on every attempt.
|
||||
RUN bun install --frozen-lockfile --ignore-scripts \
|
||||
|| { echo "bun install failed — retrying once"; sleep 5; \
|
||||
bun install --frozen-lockfile --ignore-scripts; }
|
||||
|
||||
COPY api/openapi.json /repo/api/openapi.json
|
||||
COPY web/ ./
|
||||
|
||||
@@ -33,6 +33,10 @@ import { SourceSettingsDialog } from "./SourceSettings";
|
||||
* one list of sources whose ids match whether they came from a built-in scanner or the plugin that
|
||||
* replaced it — so one surface is both simpler and the only honest presentation (design D6).
|
||||
*
|
||||
* A host from v0.28.0 on has no built-in scanners left and reports every source as `plugin`. The
|
||||
* `builtin` handling below is kept deliberately: the console ships as its own package and is
|
||||
* expected to drive an N-1 host, which still reports them.
|
||||
*
|
||||
* Deliberately kept under the existing "Game sources" label rather than a new "Plugins" heading:
|
||||
* `store_title` and `nav_plugins` are both already "Plugins", and a third would be worse than the
|
||||
* merge is good.
|
||||
@@ -117,6 +121,10 @@ export const SourcesSection: FC<{
|
||||
// The bridge-release nudge (design D9): a built-in scanner still doing the work, with its
|
||||
// replacement plugin sitting uninstalled in the catalog. One click per scanner, and NEVER a
|
||||
// silent auto-install — installing code stays an explicit operator act.
|
||||
//
|
||||
// Against a v0.28.0+ host this is always empty (no source reports `builtin` any more) and the
|
||||
// banner never renders. It stays for the N-1 host this console may be driving, where it is still
|
||||
// the migration path.
|
||||
const migratable = scanners.data
|
||||
.filter((s) => s.origin === "builtin" && s.enabled)
|
||||
.map((s) => ({
|
||||
|
||||
Reference in New Issue
Block a user