Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79114891df | ||
|
|
ea5afbaa8c | ||
|
|
832a5ffd8d | ||
|
|
76c677a8f8 | ||
|
|
7cb70bf6ea | ||
|
|
b6b3c10cb5 | ||
|
|
1a8fa2282f | ||
|
|
d669064dc0 | ||
|
|
d4ad8be6bf | ||
|
|
e0c10bad85 | ||
|
|
1b28a7f7f1 | ||
|
|
ceb081f045 | ||
|
|
0870f81148 | ||
|
|
f8361f3e6f | ||
|
|
e8bc10bf0c | ||
|
|
4499313749 | ||
|
|
784f880fbf | ||
|
|
8ca4c6eb0e | ||
|
|
13aa59c575 | ||
|
|
652de8b5e0 | ||
|
|
ec36597058 | ||
|
|
e5c0d6b4eb | ||
|
|
0bba8d7f8c | ||
|
|
0ead084838 | ||
|
|
0f9ccfa8b6 | ||
|
|
8d60f1cec0 | ||
|
|
6dd4add11b | ||
|
|
b6cc76c472 | ||
|
|
e2239fd964 | ||
|
|
f17fe1fc2b | ||
|
|
a3a6444e6e | ||
|
|
38631d43df | ||
|
|
4caf2b76e8 | ||
|
|
0f64551c56 | ||
|
|
8fe5acf7f2 | ||
|
|
fb33555059 | ||
|
|
81022bcc80 | ||
|
|
1b167f8e35 | ||
|
|
082c65755f | ||
|
|
9c2c8d1643 | ||
|
|
c591b7b4af | ||
|
|
3ac4548cf8 | ||
|
|
90d13de81e | ||
|
|
1abf5c91b9 | ||
|
|
6fd5769b3b | ||
|
|
ed8c080603 | ||
|
|
a190fdb1ae | ||
|
|
94f049ba70 | ||
|
|
c29e720324 | ||
|
|
e057bd60f4 | ||
|
|
96278eebb5 | ||
|
|
fdf48fcaa1 | ||
|
|
05a08b9804 | ||
|
|
99c245520c | ||
|
|
4ab6a399e6 | ||
|
|
8216f1d92d | ||
|
|
1677d1c0c2 | ||
|
|
9a52d725d5 | ||
|
|
fbbfce9b0e | ||
|
|
c12476736d | ||
|
|
5bcee83c34 | ||
|
|
4ebe7d1185 | ||
|
|
3f738a9989 | ||
|
|
7df321f459 |
+21
-10
@@ -676,20 +676,23 @@ jobs:
|
||||
# Skipped on PRs (cost); runs on main pushes + manual dispatch. Needs the build/test job green
|
||||
# first, and is a separate job so a capture hiccup can never red the core signal.
|
||||
#
|
||||
# Scope = the two REQUIRED iOS sizes (iPhone 6.9" + iPad 13"), captured on the Simulator
|
||||
# (`simctl io screenshot`, no Screen Recording grant needed). macOS and tvOS are deliberately
|
||||
# NOT in CI: the self-hosted runner is headless (no window-server session), so the mac window
|
||||
# capture can't run there; tvOS needs the Tier-3 build-std slice. Generate those two locally on
|
||||
# a GUI Mac with `clients/apple/tools/screenshots.sh macos tvos`.
|
||||
# Scope = the two REQUIRED iOS sizes (iPhone 6.9" + iPad 13") + Apple TV (1920×1080), captured
|
||||
# on the Simulator (`simctl io screenshot`, no Screen Recording grant needed). The tvOS slice is
|
||||
# Tier-3 (nightly -Zbuild-std, same as the distribute job — slow cold, cached on the self-hosted
|
||||
# runner). The tvOS scene list is explicit: the gamepad-console scenes are iOS/macOS-only, and an
|
||||
# unknown scene name falls back to a NORMAL app launch — the capture would silently be of the
|
||||
# real empty app. macOS stays deliberately NOT in CI: the runner is headless (no window-server
|
||||
# session), so the mac window capture can't run there — generate it locally on a GUI Mac with
|
||||
# `clients/apple/tools/screenshots.sh macos`.
|
||||
screenshots:
|
||||
needs: swift
|
||||
if: gitea.event_name != 'pull_request'
|
||||
runs-on: macos-arm64
|
||||
timeout-minutes: 75
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Rust toolchain + iOS Simulator targets
|
||||
- name: Rust toolchain + iOS Simulator targets (+ nightly for the tvOS slices)
|
||||
run: |
|
||||
if ! command -v rustup >/dev/null && [ ! -x "$HOME/.cargo/bin/rustup" ]; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
@@ -699,6 +702,10 @@ jobs:
|
||||
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
||||
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
|
||||
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
|
||||
# tvOS targets are tier-3 (no prebuilt std) — build-xcframework.sh compiles them with
|
||||
# nightly + -Zbuild-std, so ensure nightly + rust-src are present (see the swift job).
|
||||
"$RUSTUP" toolchain install nightly --profile minimal
|
||||
"$RUSTUP" component add rust-src --toolchain nightly
|
||||
|
||||
# Shared compile cache. The script handles the macOS side (user-prefix install +
|
||||
# GITHUB_PATH, bsdtar globbing) — see scripts/ci/ensure-sccache.sh.
|
||||
@@ -735,10 +742,10 @@ jobs:
|
||||
-mtime +7 -exec rm -rf {} + 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Build PunktfunkCore.xcframework (mac + iOS slices)
|
||||
run: BUILD_IOS=1 bash scripts/build-xcframework.sh
|
||||
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS slices)
|
||||
run: BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh
|
||||
|
||||
- name: Capture screenshots (iPhone 6.9" + iPad 13"; auto-creates the Simulators)
|
||||
- name: Capture screenshots (iPhone 6.9" + iPad 13" + Apple TV; auto-creates the Simulators)
|
||||
working-directory: clients/apple
|
||||
env:
|
||||
SETTLE: "8" # Simulators settle slower than a local run
|
||||
@@ -746,6 +753,10 @@ jobs:
|
||||
# Independent invocations: one platform failing skips it, not the other.
|
||||
bash tools/screenshots.sh ios || echo "::warning::iOS (iPhone 6.9\") screenshots skipped"
|
||||
bash tools/screenshots.sh ipad || echo "::warning::iPad 13\" screenshots skipped"
|
||||
# tvOS shoots only the scenes that exist there — the 06–09 gamepad-console scenes are
|
||||
# compiled out on tvOS (native focus engine), and an unknown name = a normal app launch.
|
||||
SCENES="01-stream 02-hosts 05-settings 03-pair" \
|
||||
bash tools/screenshots.sh tvos || echo "::warning::Apple TV screenshots skipped"
|
||||
echo "Produced:"; ls -la screenshots || true
|
||||
|
||||
- name: Shut the Simulators down (leaked booted sims once piled up 846 deep)
|
||||
|
||||
@@ -517,15 +517,17 @@ jobs:
|
||||
bash packaging/gamescope/build-punktfunk-gamescope.sh \
|
||||
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)" \
|
||||
--extra-fallback libdisplay-info
|
||||
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
|
||||
# The WHOLE staged tree, not just the binary: it also carries the Vulkan WSI layer built
|
||||
# beside the compositor, which is the only route to an HDR10 swapchain for a nested game.
|
||||
mkdir -p gs-cache && cp -a gs-stage/. gs-cache/
|
||||
|
||||
# The binary must RUN, not merely link: `--version` is what the old job used as its ship
|
||||
# gate, and it is the cheapest proof that the static-libstdc++ trick and the vendored wlroots
|
||||
# actually produced a working compositor.
|
||||
- name: Build the .deb
|
||||
run: |
|
||||
gs-cache/punktfunk-gamescope --version
|
||||
bash packaging/debian/build-gamescope-deb.sh --binary gs-cache/punktfunk-gamescope
|
||||
gs-cache/usr/bin/punktfunk-gamescope --version
|
||||
bash packaging/debian/build-gamescope-deb.sh --stage gs-cache
|
||||
|
||||
- name: Publish to the Gitea apt registry
|
||||
env:
|
||||
|
||||
@@ -293,7 +293,11 @@ jobs:
|
||||
dnf -y install wayland-protocols-devel glm-devel cmake libXcursor-devel || true
|
||||
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
|
||||
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
|
||||
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
|
||||
# The WHOLE staged tree, not just the binary: it also carries the Vulkan WSI layer built
|
||||
# beside the compositor, which is the only thing that can give a nested game an HDR10
|
||||
# swapchain. Caching the tree rather than a file per artifact is what keeps the next
|
||||
# addition from needing a change here as well as in every packaging script.
|
||||
mkdir -p gs-cache && cp -a gs-stage/. gs-cache/
|
||||
else
|
||||
# Warn only, even on a tag — the hard gate is the LAST step of this job. Failing here
|
||||
# would skip the sysext build, the sysext feed, AND the release attach below, so a
|
||||
@@ -311,9 +315,9 @@ jobs:
|
||||
# existing SDR/host-composited path. The spec re-checks the +pfhdr marker itself.
|
||||
- name: Package punktfunk-gamescope as an RPM
|
||||
run: |
|
||||
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
|
||||
if [ -x gs-cache/usr/bin/punktfunk-gamescope ] && gs-cache/usr/bin/punktfunk-gamescope --version >/dev/null 2>&1; then
|
||||
bash packaging/gamescope/build-gamescope-rpm.sh \
|
||||
--binary gs-cache/punktfunk-gamescope \
|
||||
--stage gs-cache \
|
||||
--release "$PF_RELEASE"
|
||||
else
|
||||
# Warn only — see the note on the build step. The gate is the last step of this job.
|
||||
@@ -372,9 +376,9 @@ jobs:
|
||||
# whose runtime libs are missing from this container must cost the image its HDR, not the
|
||||
# image itself.
|
||||
gs=()
|
||||
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
|
||||
gs=(--gamescope gs-cache/punktfunk-gamescope)
|
||||
echo "folding in $(gs-cache/punktfunk-gamescope --version 2>&1 | head -1)"
|
||||
if [ -x gs-cache/usr/bin/punktfunk-gamescope ] && gs-cache/usr/bin/punktfunk-gamescope --version >/dev/null 2>&1; then
|
||||
gs=(--gamescope-stage gs-cache)
|
||||
echo "folding in $(gs-cache/usr/bin/punktfunk-gamescope --version 2>&1 | head -1)"
|
||||
else
|
||||
echo "::warning::no usable punktfunk-gamescope for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
|
||||
fi
|
||||
|
||||
+632
@@ -12,6 +12,638 @@ with the version table of the release you are moving to, then read **Breaking ch
|
||||
|
||||
---
|
||||
|
||||
## v0.28.1
|
||||
|
||||
60 commits since v0.28.0.
|
||||
|
||||
A patch release in the strict sense: **nothing on the wire, in the C ABI, in the driver protocol or
|
||||
in the plugin contract moves.** Every host, client, driver and plugin built against v0.28.0 keeps
|
||||
working against v0.28.1 and vice versa, in both directions and with no re-pairing.
|
||||
|
||||
### Versions
|
||||
|
||||
| | v0.28.0 | v0.28.1 | Notes |
|
||||
|---|---|---|---|
|
||||
| Wire protocol | 2 | **2** | unchanged |
|
||||
| C ABI | 19 | **19** | unchanged — `include/punktfunk_core.h` is byte-identical to the v0.28.0 tag |
|
||||
| Rust edition | 2024 | **2024** | unchanged |
|
||||
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
|
||||
| Workspace crate dirs | 27 | **27** | unchanged |
|
||||
| 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.27.0 | **0.28.0** | the management API **did** change (two collection deletes, below); the file carries the stamp it was regenerated under, not `0.28.1` |
|
||||
| gamescope patch level (`+pfhdrN`) | 6 | **7** | 8 patches → 9 (the linger crash); no new capability |
|
||||
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.4** | unchanged |
|
||||
| `@punktfunk/plugin-kit` | 0.4.1 | **0.4.1** | unchanged |
|
||||
|
||||
⚠ **The `api/openapi.json` stamp is not a per-release counter** and should not be read as one. The
|
||||
drift test (`openapi_document_is_complete_and_checked_in`) normalizes `info.version` on both sides,
|
||||
so only the *surface* is gated and a version bump alone never invalidates the snapshot. The table
|
||||
row says what the file actually says. Regenerating it needs a Linux or Windows host build —
|
||||
`punktfunk-host` does not compile on macOS.
|
||||
|
||||
### ⚠ Breaking changes
|
||||
|
||||
**None.** No wire change, no C ABI change, no driver-protocol change, no plugin-contract change.
|
||||
Three things are worth an embedder's or packager's attention anyway, none of which break a build:
|
||||
|
||||
- **The Rust crate gained one public constant.** `punktfunk_core::client::FLUSH_COOLDOWN` was
|
||||
`pub(crate)`; the host now compares against it rather than against a copy of the number (see the
|
||||
keyframe-cadence fix below). Addition only.
|
||||
- **`NativeBridge.nativeStartAudio` takes a third argument** on Android — `isTv`. Detail in the
|
||||
Android section; this is a JNI signature change, so an out-of-tree caller must pass it.
|
||||
- **Every Linux packaging channel now ships a second gamescope artifact**, the Vulkan WSI layer,
|
||||
and a package that carries the compositor without it is *fatal* rather than degraded. If you
|
||||
repackage `punktfunk-gamescope` downstream, read the gamescope section before rebuilding.
|
||||
|
||||
### The management API gains two collection deletes — "unpair all"
|
||||
|
||||
Clearing a host's trust store meant one row-level delete per device, each with its own
|
||||
confirmation. Two new endpoints, one per pairing plane:
|
||||
|
||||
```
|
||||
DELETE /api/v1/clients -> {"unpaired": N}
|
||||
DELETE /api/v1/native/clients -> {"unpaired": N}
|
||||
```
|
||||
|
||||
They are **not** a loop over the per-fingerprint deletes. Each empties its store in ONE persisted
|
||||
write, because N deletes would rewrite and atomically rename the store N times and a failure
|
||||
partway leaves a half-emptied store with nothing saying which half. The two planes are separate
|
||||
endpoints because they own separate trust stores with separate persistence and separate revocation
|
||||
duties.
|
||||
|
||||
Being collection deletes, they carry the single delete's revocation guarantees across the whole
|
||||
set: a live session owned by any removed certificate is ended, and on the GameStream side the ENet
|
||||
control port (UDP 47999) closes, because no pairing is left to hold it open.
|
||||
|
||||
**200 with a count, not the single delete's 204/404.** "Unpair everything" is idempotent — an
|
||||
already-empty store satisfies it — and the count still distinguishes three devices from none.
|
||||
|
||||
⚠ **Both are admin-token only.** The route-classification gates match on (method, path), so the
|
||||
roster's plugin-readable `GET` does not carry over to emptying it; both new routes have explicit
|
||||
rows in the table, like every other pairing-administration route. The native endpoint answers
|
||||
**503** on a host built without that plane, which is why the console calls only the planes that
|
||||
actually have a row.
|
||||
|
||||
`UnpairAllResult` is the one new schema. `api/openapi.json` is regenerated;
|
||||
`docs-site/public/openapi.json` is re-synced from it (see **Documentation** at the end).
|
||||
|
||||
### The pad-audio "Wireless Controller" speaker hides while no client pad is attached
|
||||
|
||||
Field-confirmed (2026-08-14, the same Helldivers 2 reports as below): the per-pad audio endpoint
|
||||
the Windows host mints — a Steam-Streaming-Speakers instance stamped with a DualSense's name,
|
||||
container and 4 ch/48 kHz formats, **pre-provisioned at every host start** — is deliberately
|
||||
indistinguishable from a real DualSense speaker. That disguise is the feature during a pad
|
||||
session (libScePad titles route haptics audio at it) and a trap the rest of the time: an idle
|
||||
Helldivers 2 finds the endpoint by identity, engages its DualSense-haptics path against a device
|
||||
nothing services, and drops to 2–5 FPS 1% lows — with the host completely idle, no controller
|
||||
plugged in, and no session ever run. The reporter isolating "the DualSense speaker" and disabling
|
||||
it in mmsys.cpl restored full performance; that manual remedy is now automatic.
|
||||
|
||||
The endpoint now parks **hidden** (`DEVICE_STATE_DISABLED`, via `IPolicyConfig::
|
||||
SetEndpointVisibility` — the exact call behind mmsys.cpl's Disable) whenever no client pad is
|
||||
attached: provisioning hides it at startup (and a `PUNKTFUNK_PAD_AUDIO=0` host hides leftovers
|
||||
from earlier runs), the per-pad streamer shows it for exactly the pad's lifetime — to a game,
|
||||
indistinguishable from a DualSense arriving and leaving. The devnode, driver binding and stamps
|
||||
stay put, so the flips raise no PnP traffic and the expensive provisioning still happens once at
|
||||
boot.
|
||||
|
||||
⚠ **Operator-visible:** "Speakers (Wireless Controller)" now shows as *disabled* in the Sound
|
||||
control panel while no client pad is connected — that is the parked state, not a defect. The
|
||||
`pad-endpoint` devtest grew `show`/`hide` verbs; `tone`/`capture` need a `show` first.
|
||||
|
||||
### An idle Windows host no longer owns the box's default microphone
|
||||
|
||||
Field report (the second Helldivers 2 one — the first led to v0.28.0's mint-retry fix): with the
|
||||
host **idle**, a locally played Helldivers 2 tanks to 2–5 FPS 1% lows, and Windows' own Sound
|
||||
settings Recording tab goes unresponsive. Root cause: the audio wiring pass asserted *default
|
||||
recording = the virtual mic's capture side* on **every** pass, including the mic pump's eager
|
||||
boot pass — and `SetDefaultEndpoint` covers eCommunications, so every game's voice input bound a
|
||||
virtual microphone whose feeder only runs during a stream. Nothing ever restored it: not session
|
||||
end, not service stop. Games that hold an always-open voice capture (Helldivers 2 is Wwise +
|
||||
in-game voice — its own wiki calls the game "finicky with audio devices") stall on that dead
|
||||
endpoint.
|
||||
|
||||
The recording default is now **session-scoped**, exactly like the playback default has always
|
||||
been: parked on the virtual mic only while a desktop-audio capture is open, the operator's device
|
||||
remembered (plus an on-disk crash marker, `audio-default-rec.prev`), restored when the capture
|
||||
closes, recovered at next boot after a crash, and unparked by the uninstaller. A game launched
|
||||
*during* a stream still records the client's mic; one launched before the stream keeps the
|
||||
operator's own microphone.
|
||||
|
||||
Boxes wedged by earlier builds (which recorded nothing to restore) heal themselves: an idle
|
||||
wiring pass that finds the default recording sitting on the plan's mic capture moves it back to
|
||||
the first real microphone.
|
||||
|
||||
⚠ **Operator-visible:** outside a stream, the default recording device is now whatever you set —
|
||||
Punktfunk only takes it for the duration of a stream. If you *want* apps to record the client mic
|
||||
while idle, select "Punktfunk Microphone" manually; the host no longer re-asserts it (idle
|
||||
re-assertion used to stomp a manual choice within one mic-pump reopen).
|
||||
|
||||
### The NixOS module started a second host in root's systemd, which stole the ports from the real one
|
||||
|
||||
Found on the first real deployment of `packaging/nix/nixos-module.nix` (NixOS 26.05, punktfunk
|
||||
0.28.0-nix). The host crash-looped forever on one line:
|
||||
|
||||
```
|
||||
ERROR punktfunk_host: start RTSP server: bind RTSP 48010: Address already in use (os error 98)
|
||||
```
|
||||
|
||||
`systemd.user.*` has no per-user form in NixOS: it installs units into **every** user's systemd
|
||||
manager. `host.autoStart` then adds them to `default.target` — for every user, including **root**,
|
||||
whose `user@0.service` springs into existence the moment anybody so much as SSHes in as root. Root's
|
||||
copy of the host won the race for the fixed ports, and the desktop user's copy could never bind.
|
||||
|
||||
The failure is nastier than it sounds because every *other* listener binds first and logs success —
|
||||
the version banner, mDNS on 47989, the GameStream warning all print normally — so the log reads like
|
||||
a conflict with some unrelated program. A second copy of *itself*, running as root, is the last
|
||||
thing anyone looks for. `host.users` did not help: that option only granted `input`/`punktfunk`
|
||||
group membership and never scoped the units.
|
||||
|
||||
Fixed by rendering `ConditionUser=` on all four user units (`punktfunk-host`, `punktfunk-web`,
|
||||
`punktfunk-web-init`, `punktfunk-scripting`) from `host.users`. Each entry is written `|user` — the
|
||||
pipe makes it a *triggering* condition, which systemd ORs; plain repeated `ConditionUser=` lines are
|
||||
ANDed and would have matched nobody. With `host.users` empty the units fall back to
|
||||
`ConditionUser=!@system`, which still keeps root out while leaving a normal login free to run the
|
||||
host by hand, as the module header documents.
|
||||
|
||||
`packaging/nix/module-check.nix` gained three assertions covering both branches and the fact that
|
||||
`punktfunk-web-init` keeps its pre-existing (non-triggering) `ConditionPathExists` alongside the new
|
||||
condition. They run in the `eval` leg of `nix.yml`, and were verified to fail against the unfixed
|
||||
module before being committed.
|
||||
|
||||
### The Steam plugin synced nothing on Windows: its art is in Program Files, the art roots were not
|
||||
|
||||
Field report — the plugin installed, the grid stayed empty, and the only clue was one host warn per
|
||||
sync:
|
||||
|
||||
```
|
||||
plugin:steam sync (fs-change) failed: HostRequestError: PUT /library/provider/steam?store=steam
|
||||
failed: art.hero: local art must be an image file (…) inside an allowed art root
|
||||
```
|
||||
|
||||
Two independent defects, both fixed here.
|
||||
|
||||
**1. Steam's art was never inside an allowed root on Windows.** `art_roots()` defaulted to the users
|
||||
base (`C:\Users`, from `%PUBLIC%`'s parent), which covers the launchers that install per-user —
|
||||
Playnite under `%APPDATA%`, Heroic under `%APPDATA%` — but *not* Steam, which installs to
|
||||
`C:\Program Files (x86)\Steam` and keeps both the art the plugin publishes there:
|
||||
`appcache\librarycache\<appid>\<hash>\` and each account's `userdata\<id>\config\grid\` overrides.
|
||||
Every cover the plugin emitted was out of root. This is a v0.28.0 regression: the built-in scanner
|
||||
the plugin replaced served its covers through the legacy `steam:` art-proxy branch, which never
|
||||
passed through the H-2 confinement — deleting the scanner routed that art through a gate it had
|
||||
never been measured against. `art_roots()` now also includes every Steam install root it can find,
|
||||
from `%ProgramFiles(x86)%` / `%ProgramFiles%` / `%ProgramW6432%` and from HKLM
|
||||
`Valve\Steam\InstallPath` (so a Steam on another drive is covered too). POSIX needed no equivalent —
|
||||
every Steam layout there, native and Flatpak, is already under `$HOME`.
|
||||
|
||||
This does not weaken the confinement. It exists to stop the host (SYSTEM) reading files the plugin
|
||||
lane (LocalService) cannot reach itself; the Steam directory is readable by LocalService already, so
|
||||
nothing there is reachable *because* the host is privileged. The extension, regular-file, magic-byte
|
||||
and config-dir gates all still apply, so Steam's own `config.vdf` and `ssfn*` credential blobs are
|
||||
not servable from it — there is a test.
|
||||
|
||||
**2. One unservable cover threw away the entire library.** `PUT /library/provider/{p}` validated art
|
||||
per entry and returned 400 for the whole payload on the first bad value, so a path mismatch cost the
|
||||
operator *every game from that store*, not a thumbnail — and the plugin, which only ever sees
|
||||
`HostRequestError`, could not say which. A provider reconcile now **strips** unservable local art and
|
||||
syncs the rest (`sanitize_art_paths`), logging one aggregated warn naming the count, an example path
|
||||
and the env var. The invariant the 400 held is unchanged: no unservable path is ever persisted. The
|
||||
operator's own single-entry custom writes keep the hard 400 — there the path was typed by hand, and
|
||||
silence would be the wrong answer.
|
||||
|
||||
⚠ **Operator-visible:** an art-root mismatch no longer fails a sync. If covers are blank where you
|
||||
expect art, the cue is the host log's `dropped local art the proxy may not serve` line, and the knob
|
||||
is `PUNKTFUNK_LIBRARY_ART_ROOTS` (which **replaces** the defaults — list every root you need).
|
||||
|
||||
### Hyprland/Sway — the wlr-family backends asserted a cursor mode instead of negotiating it
|
||||
|
||||
🛑 **Every cursor-forward session on current Hyprland died at `select_sources`** — "pipeline build
|
||||
failed" and a black client, with `unavailable cursor mode 4` in the portal log.
|
||||
|
||||
Hyprland and wlroots both hardcoded portal `CursorMode::Metadata` whenever the session had
|
||||
negotiated the cursor channel, and never asked the backend what it supports. That is **not** a soft
|
||||
failure: xdg-desktop-portal's **frontend** validates the requested mode against the backend's
|
||||
`AvailableCursorModes` and fails the call with `"Unavailable cursor mode %x"` before the backend
|
||||
ever sees it.
|
||||
|
||||
⭐ **Measured on glass 2026-08-14, and worse than the report suggested.** Against a live Hyprland
|
||||
0.56.2 with xdg-desktop-portal-hyprland 1.4.1 and xdg-desktop-portal 1.22.1 — all current —
|
||||
`AvailableCursorModes` reads **3** (`Hidden|Embedded`) on both the backend impl interface and the
|
||||
frontend. **xdph does not offer the metadata cursor at all**, so this broke every cursor-forward
|
||||
session on current Hyprland, not merely on old installs, and **updating the portal would not have
|
||||
helped.** xdpw is the same from the other end: its `screencast.c` refuses `METADATA` outright.
|
||||
|
||||
`pf-capture`'s own portal path has always negotiated (`choose_cursor_mode`); this restates that
|
||||
ladder in `pf-vdisplay`, which may not depend on `pf-capture`. The downgrade is graceful rather than
|
||||
merely survivable: with the portal on `Embedded` no `SPA_META_Cursor` arrives, so the host feeds the
|
||||
cursor channel nothing and a cursor-forward client draws nothing of its own — **one pointer, not
|
||||
two.**
|
||||
|
||||
**`PUNKTFUNK_PORTAL_CURSOR_MODE=auto|hidden|embedded|metadata`** pins the preference for a backend
|
||||
that advertises a mode it implements badly, which negotiation cannot detect. It is a preference
|
||||
only: a pin runs the same ladder, so no value can re-create the refused request.
|
||||
|
||||
⚠ The module is declared **unconditionally**, so its ladder tests run on every CI leg rather than
|
||||
only the one that compiles `mod hyprland` — including a Linux-only test pinning our bit values
|
||||
against ashpd's enum (ashpd answers 4 for `Metadata`, the number in the report), verified
|
||||
non-vacuous by planting a wrong discriminant.
|
||||
|
||||
### Android — the audio plane trusted AAudio, and a TV box that opened a stream it never played was silent for the session
|
||||
|
||||
🛑 **Reported from the field: no audio at all on an NVIDIA Shield Android TV, stereo, with the same
|
||||
host and settings that play fine on an Apple TV.** Video unaffected. Turning off the client's
|
||||
low-latency mode — which is what gates the forced HDMI mode switch and the `usage=Game` tagging —
|
||||
changed nothing.
|
||||
|
||||
The Android client opens AAudio directly (the Apple client goes through AVAudioEngine, which
|
||||
reconfigures itself on a route change; that difference is why this was Android-only). Opening
|
||||
AAudio is a negotiation with a vendor HAL, and this plane treated it as a formality: one Exclusive
|
||||
attempt, one Shared retry, and everything after the open taken on trust. **Three distinct failures
|
||||
all presented as "the app has no sound" behind a healthy-looking log**, and none of them was
|
||||
detected:
|
||||
|
||||
- **A configuration that opens but routes nowhere.** Nothing ever checked that the device actually
|
||||
pulled a sample, so the decode thread would happily decode Opus into a dead stream forever.
|
||||
- **`request_start` failing.** The old code gave up on the spot instead of trying anything else, so
|
||||
one unhappy configuration disabled audio for the whole session.
|
||||
- **A disconnect.** By AAudio's contract a disconnected stream is dead and the only recovery is
|
||||
close + open a new one. The error callback logged a warning and did nothing else — so an HDMI
|
||||
mode switch, an AVR re-handshake or any route change meant silence for the rest of the session.
|
||||
On a TV that is not a rare event: the client itself drives an HDMI mode switch on the video
|
||||
plane, and the platform's own match-content-frame-rate setting drives more.
|
||||
|
||||
The open now walks a **ladder**, every rung has to **prove the device is pulling** before it is
|
||||
accepted, and a **supervisor** owns the plane for the session and reopens it when the device goes
|
||||
away (bounded retries across the settling time of a route change, so a reopen landing mid-switch
|
||||
does not permanently disable audio). The granted rate/channel-count/format are checked against what
|
||||
was asked for rather than assumed — the realtime callback casts AAudio's buffer to `f32` and writes
|
||||
`num_frames × channels` of them, so a HAL that disagreed was an out-of-bounds write on the audio
|
||||
thread, not merely a mistuning.
|
||||
|
||||
⚠ **Behaviour change on TV boxes: they now start at Shared instead of Exclusive.** Exclusive is
|
||||
MMAP, the lowest-latency path AAudio has, and the one rung whose routing cannot be verified from
|
||||
inside the process. The latency it buys here was never actually banked — the jitter-ring depths are
|
||||
unchanged from the Shared-only era (`JitterTuning::AAUDIO` still primes at 25 ms) — so on a
|
||||
mains-powered HDMI box the few ms are worth less than not betting the audio plane on it. Phones,
|
||||
tablets and handhelds are unchanged and still try Exclusive first. If no rung proves itself, the
|
||||
first one that opened and started is used anyway: the watchdog must never be able to turn working
|
||||
audio into no audio.
|
||||
|
||||
⚠ **Embedder-visible:** `NativeBridge.nativeStartAudio` takes a third argument, `isTv`
|
||||
(`FEATURE_LEANBACK`, the same source the video plane already used).
|
||||
|
||||
Three new sysprops bisect all of it on a device that cannot be handed a custom build, alongside the
|
||||
existing `debug.punktfunk.no_av_sync`: `debug.punktfunk.audio_sharing` (`exclusive`|`shared`),
|
||||
`debug.punktfunk.audio_perf` (`lowlatency`|`none`) and `debug.punktfunk.audio_reopen` (`0` pins the
|
||||
old give-up-on-disconnect behaviour). A stream that stops taking samples after it started now says
|
||||
so at `error` level instead of looking exactly like an app with no sound.
|
||||
|
||||
### gamescope — we ship our own Vulkan WSI layer, so a game can reach an HDR10 swapchain (⚠ packager-visible)
|
||||
|
||||
🛑 **On essentially every box running a distro gamescope, no game could render HDR at all** — and
|
||||
nothing said so.
|
||||
|
||||
A game nested under gamescope gets an HDR10 swapchain from the FROG WSI layer and from nothing
|
||||
else: gamescope advertises no runtime colour-management protocol a Mesa/NVIDIA WSI could negotiate
|
||||
through. That layer speaks `gamescope_swapchain` to the compositor, and when the two disagree the
|
||||
compositor rejects the client's `swapchain_feedback` and **every Vulkan client dies on a black
|
||||
screen** with sound and input intact and no error anywhere.
|
||||
|
||||
We shipped our own compositor and *not* a layer, on the recorded grounds that the layer is
|
||||
"version-independent of the compositor binary". It is not — `wsi_layer_matches_our_gamescope()`
|
||||
exists precisely because it is not — so the host was left guessing from version triples, and that
|
||||
guess is wrong in both directions. A distro at the same upstream tag that patched the protocol
|
||||
compares EQUAL and keeps a layer that will black-screen every game; a distro at a different tag
|
||||
with a byte-identical protocol compares unequal and loses HDR for nothing. **Since we pin a rev,
|
||||
the second case is the normal one.**
|
||||
|
||||
We now build the layer from the same tree at the same rev as the compositor and ship it, so the two
|
||||
cannot drift and the guess stops being load-bearing. It installs under **our own** name
|
||||
(`VK_LAYER_PUNKTFUNK_gamescope_wsi`), at our own path, with our own enable/disable variables, so it
|
||||
coexists with the distro's rather than colliding — the Vulkan loader keys implicit layers on that
|
||||
name — and the host switches the two independently within one session.
|
||||
|
||||
`WsiPlan` resolves three states once per launch (the fallback spawns `--version` probes):
|
||||
|
||||
| state | condition | action |
|
||||
|---|---|---|
|
||||
| `Ours` | our layer is installed | enable ours, force the distro's off — **both halves, or it is a bug** |
|
||||
| `DistroKept` | no layer of ours, distro's looks compatible | touch nothing |
|
||||
| `DistroDisabled` | no layer of ours, distro's untrusted | v0.28.0's behaviour |
|
||||
|
||||
That last arm is the fail-safe: a host newer than its gamescope package behaves exactly as it did,
|
||||
rather than enabling a layer that is not there.
|
||||
|
||||
⚠ **What packagers must know.** The layer manifest carries an **absolute** `library_path` baked in
|
||||
at build time, so every channel installs the `.so` at exactly that path: literal
|
||||
`/usr/lib/punktfunk` — **not** `%{_libdir}` (which is `/usr/lib64` on Fedora) and not a Debian
|
||||
multiarch triplet. Nothing links it by soname (the loader `dlopen`s it by that path), so multilib
|
||||
has no claim. rpm and nix read the path back **out of the manifest** and fail if it names a file the
|
||||
package does not install, because a manifest pointing at nothing is the silent shape of this bug.
|
||||
A missing layer is **fatal in every channel**, not best-effort: a package carrying the compositor
|
||||
without it looks completely healthy and then silently denies every game an HDR10 swapchain.
|
||||
|
||||
The packaging scripts now take `--stage` (the DESTDIR the gamescope build script wrote) instead of
|
||||
a path to one binary, and CI caches the whole staged tree; the `gs-cache` key already hashes
|
||||
`packaging/gamescope/**`, so stale caches in the old single-file shape cannot be restored into the
|
||||
new layout. The manifest rewrite lives in `packaging/gamescope/rewrite-wsi-layer-manifest.py`
|
||||
rather than a heredoc, because the FHS builds and the Nix store both need it and must rename the
|
||||
layer identically. **NixOS has no `/usr`**, so the layer lives inside the gamescope derivation and
|
||||
the host's path is overridable with **`PUNKTFUNK_GAMESCOPE_WSI_LAYER_DIR`**, which the module sets
|
||||
— the same posture as `PUNKTFUNK_GAMESCOPE_BIN`.
|
||||
|
||||
### gamescope — HDR sessions anchored SDR white a stop bright, and never said game HDR was unreachable
|
||||
|
||||
🛑 **Field report: Steam's Big Picture UI glaring and over-saturated while HDR game content looked
|
||||
washed out, on the same stream.** Those are one error.
|
||||
|
||||
gamescope maps everything that is not an HDR game — the desktop, the Steam overlay, an SDR title —
|
||||
into the session's PQ container at `--hdr-sdr-content-nits`, and we passed that flag **only** when
|
||||
an operator had set `PUNKTFUNK_GAMESCOPE_SDR_NITS`. Unset, gamescope used its own default of
|
||||
**400**, while every first-party client anchors diffuse white at **203** (BT.2408 reference white;
|
||||
the Apple presenter hands exactly that to `CAEDRMetadata.hdr10`'s `opticalOutputScale`). The two
|
||||
ends sat nearly a stop apart, so the UI landed above SDR white and the client's tone-mapper worked
|
||||
from a reference point the host had never used, flattening the content around it.
|
||||
|
||||
**The flag is now always passed, defaulting to 203.** `PUNKTFUNK_GAMESCOPE_SDR_NITS` still
|
||||
overrides it for anyone who wants a brighter or dimmer desktop — it is the anchor, not a taste
|
||||
knob. ⭐ Because it is an env var, a field A/B needs **no rebuild**.
|
||||
|
||||
Separately, and visible in the same log: the two HDR decisions in a gamescope session were made
|
||||
independently. `hdr_args()` never consulted `wsi_layer_matches_our_gamescope()`, so when the layer
|
||||
check fired the session launched **advertising HDR while having made an HDR10 swapchain
|
||||
unreachable for every game in it** — a title told to render HDR rendered it into an SDR swapchain
|
||||
and looked washed out, with nothing anywhere saying why. It now warns. The behaviour of the check
|
||||
itself is deliberately unchanged; the section above is the real fix.
|
||||
|
||||
### punktfunk-gamescope `+pfhdr7` — a lingered session no longer dies of its own capture teardown
|
||||
|
||||
🛑 **On client disconnect the host keeps the headless gamescope alive so a reconnect resumes the
|
||||
same session — and gamescope could SIGSEGV in exactly that window, so the kept display was dead and
|
||||
reconnect silently got a fresh compositor with the game lost.** When the capture consumer leaves,
|
||||
PipeWire's `remove_buffer` (and the stale-push path in `dispatch_nudge`) destroyed idle buffers on
|
||||
the **PipeWire thread**; dropping the last `CVulkanTexture` reference there calls into the Vulkan
|
||||
driver (`vkDestroyImage`/`FreeMemory`/dmabuf fds) while steamcompmgr can still be inside
|
||||
`vulkan_screenshot` on another buffer of the same 4-buffer pool. On NVIDIA that races to a SIGSEGV
|
||||
in `CVulkanCmdBuffer::insertBarrier` — timed at stream end, which is why it selectively killed
|
||||
linger. The journal signature: linger line → coredump → `kept display was dead — recreating`.
|
||||
|
||||
Patch 0009 queues those corpses on the PipeWire thread and has steamcompmgr reap them on every
|
||||
vblank — including while the stream is paused, which is precisely the linger state. Found, fixed
|
||||
and proven live by **luxus** ([punktfunk-overlay#9](https://github.com/luxus/punktfunk-overlay/issues/9)):
|
||||
four coredumps on 4K60 HDR + composited cursor, zero after; disconnect/reconnect now reuses the
|
||||
lingered session. Banner `+pfhdr6` → `+pfhdr7` (no new capability — but "reconnect lost my game"
|
||||
triage must be able to read a box's exposure off its banner, the same rule as `+pfhdr5`/`6`).
|
||||
|
||||
### Apple — the stats overlay lied three ways, and every host-anchored number with it
|
||||
|
||||
🛑 **Two sessions minutes apart on the same wire read `hostnet_p50` 17–21 ms, then a physically
|
||||
impossible 4.4 ms** — host-side encode alone is ~4.7. Three independent defects, all of which
|
||||
corrupt any measurement taken against a host clock:
|
||||
|
||||
- **A frozen clock-offset.** The client consumed the **connect-time** skew offset and cached it —
|
||||
in a `Stage2Pipeline` field, in a `StreamPump` `let`, and in a `ContentView` closure **capture
|
||||
list** feeding the hostnet meter and the host/network splitter. The core keeps a *live* estimate
|
||||
(`punktfunk_connection_clock_offset_now_ns`, ABI v10, re-synced every 60 s and on suspected
|
||||
wall-clock steps) whose own doc says the connect-time value "silently corrupts every
|
||||
capture-clock comparison" after an NTP step — **and a VM host steps.**
|
||||
`PunktfunkConnection.clockOffsetNs` is now the live read (an atomic load behind the FFI), read at
|
||||
use: per record, per AU, per enqueue. The Swift audio plane's AvSync observation takes the same
|
||||
live value.
|
||||
- **Silently trimmed impossible samples.** `LatencyMeter`'s guard (≤ 0 after offset correction)
|
||||
dropped samples without counting them, so a wrong offset did not invalidate a window — it trimmed
|
||||
the impossible half of the shifted distribution and presented the surviving tail as a plausible
|
||||
small number. That is the origin of the historical "0 ms network / 0 ms e2e" readings. Refusals
|
||||
are now counted and drained **separately from `Stats`** — deliberately, because a fully-poisoned
|
||||
window drains to `nil` and a count inside `Stats` would vanish with it. The HUD shows an orange
|
||||
**`clock offset suspect`** line and the stats line grew **`skew_trim=N`**; nonzero means
|
||||
disregard `e2e`/`hostnet` for that window.
|
||||
- **`-1` fallbacks printing as `NaN`.** In a `CVarArg` context `cond ? someDouble : -1` does **not**
|
||||
unify to `Double` — the literal goes in as `Int`, and `%f` reads `Int64(-1)`'s all-ones bit
|
||||
pattern, which is a quiet NaN. Latent since the 1 Hz stats line existed. All fallbacks are now
|
||||
typed `-1.0`.
|
||||
|
||||
⚠ **Any client-side e2e or hostnet figure recorded before this release is suspect** and worth
|
||||
re-measuring rather than trusted as a baseline.
|
||||
|
||||
Two new levers ship with the tvOS present-floor investigation, both env-only:
|
||||
**`PUNKTFUNK_FRAME_LATENCY`** (float 0…4, default 1) makes the `preferredFrameLatency` ask
|
||||
adjustable, so an on-device ladder can establish whether the property does anything on tvOS — the
|
||||
previous "immovable two-refresh floor" verdict rested on a **readback** of a plain read-write
|
||||
float, which is not a grant. **`PUNKTFUNK_PRESENTER=stage1` now resolves on Release builds** (the
|
||||
persisted picker stays DEBUG-gated; an env var takes a `devicectl`/Xcode launch to exist, so it is
|
||||
never a leftover). Stage-1 presents on the hardware video plane rather than through the GPU
|
||||
compositor — the one rung that can dodge the two-refresh regime — and the field A/B that concluded
|
||||
otherwise had silently run stage-4, because the gate keyed on build config.
|
||||
|
||||
### Apple — two colour faults: an SDR stream shipped untagged, and it forced the TV into HDR10
|
||||
|
||||
- **The SDR layer was never tagged.** `configure(hdr:)` guards on `hdr != hdrActive` and
|
||||
`hdrActive` starts `false`, so a session that is SDR from its first frame matched the initial
|
||||
state, fell through the guard, and `configureColor` never ran once — the layer kept `make()`'s
|
||||
bare configuration, which assigns no colour space. An untagged `CAMetalLayer` gets no colour
|
||||
matching: a BT.709 stream is drawn in the display's native space. Mild oversaturation on a P3 Mac
|
||||
or iPad; on a tvOS display composited for HDR it also lifts the black floor. ⚠ It also made
|
||||
`PUNKTFUNK_SDR_COLORSPACE` **dead code on exactly the sessions it exists to fix**, so a field A/B
|
||||
of that knob would have shown no change.
|
||||
- **An SDR stream drove an HDR-capable TV into PQ output.** `applyDisplayCriteriaIfNeeded` builds a
|
||||
synthetic format description hardcoding BT.2020 primaries, ST.2084 and the BT.2020 matrix, then
|
||||
hands it to `AVDisplayManager` — and its guard checked only that no criteria had been set and that
|
||||
the user's HDR *setting* was on, never that **the stream** was HDR. That setting defaults to true.
|
||||
The Apple TV switches HDMI to limited range in its HDR modes, so a set configured for full range
|
||||
renders code 16 as grey rather than black. Now gated on `connection.isHDR` as well; layout re-runs
|
||||
it, so a session that flips to HDR mid-stream still picks the mode up.
|
||||
|
||||
### Apple — the macOS device-change recovery could answer itself forever (mic on)
|
||||
|
||||
**Streaming from a Mac with the microphone enabled cut audio AND input on a ~2.5 s metronome
|
||||
while video ran untouched** (field, 2026-08-14: a Mac Studio whose default input is a 6-channel
|
||||
device). The chain: the voice-processing engine cannot start on that mic, every rebuild re-tried
|
||||
it, and the failed attempt's HAL churn (VPIO builds and tears down an aggregate device) stopped
|
||||
the healthy fallback engines — which posted the `AVAudioEngineConfigurationChange` that scheduled
|
||||
the next rebuild. Each ~1.9 s rebuild runs on the main thread, where macOS input capture and
|
||||
sending live, so input froze on the same beat — and since audio, input and mic share the QUIC
|
||||
datagram plane while video rides its own socket, the wire signature read as a network fault and
|
||||
the host's METRONOMIC heuristic pointed at the display stack. Three defenses, layered because no
|
||||
single one covers every feedback shape:
|
||||
|
||||
- **A voice-processing start failure latches per input device** (`CombinedTopologyGate`): a
|
||||
rebuild goes straight to the split topology instead of re-running a failure that is a property
|
||||
of the device. A different default input earns exactly one fresh attempt.
|
||||
- **A configuration change posted by an engine that is RUNNING is the rebuild's own echo, and is
|
||||
ignored**: an engine stops itself before posting, so a live poster was already restarted.
|
||||
- **Rebuilds that chain anyway back off exponentially** (`RebuildBackoff`: 0.5 s floor doubling
|
||||
to a 30 s cap, reset by 10 s of quiet) — an unforeseen loop costs one blip per half-minute
|
||||
instead of a metronome, and the chaining itself logs a WARN that names the condition.
|
||||
|
||||
iOS/tvOS behaviour is untouched (routes are session-managed there; nothing is latched). Until a
|
||||
client carries this, the field workaround is turning the client microphone off.
|
||||
|
||||
**And the engines no longer start on the main thread at all.** An engine start can block on the
|
||||
audio server for seconds (~1.9 s per attempt in the field case) and macOS captures and sends the
|
||||
stream's input from the main thread — so even a single legitimate device switch froze input for
|
||||
the length of the rebuild, loop or no loop. All engine build/start/teardown now runs on a
|
||||
per-session serial `engineQueue`; the main queue keeps only the trigger bookkeeping (debounce,
|
||||
backoff, retry ladder), which is cheap by construction. ⚠ Embedder-visible edge:
|
||||
`SessionAudio.start()` is now asynchronous on macOS too (it always was on iOS/tvOS) — playback is
|
||||
live shortly after the call, not on return, and `stats` is safe from any thread.
|
||||
|
||||
### Apple gamepad UI — a host menu, and About becomes a page
|
||||
|
||||
**UP on a saved tile opens Wake / Copy link / Edit… / Forget pairing / Remove.** The desktop and
|
||||
Android consoles have had this for a while; this is the Apple port, so the three consoles are
|
||||
learned once. Wiring UP takes the whole vertical axis away from scrolling (down goes inert) — a
|
||||
horizontal carousel has no vertical travel to spend, and one meaning per direction is what makes
|
||||
the gesture learnable. **Remove arms on the first press and fires on the second**, disarming if
|
||||
focus wanders off the row: the touch grid gets a system confirmation dialog, and a thumbstick from
|
||||
across a room deserves at least as much. Edit reuses `GamepadAddHostView` seeded from the record and
|
||||
writes a **copy** back through `HostStore.update`, so the fingerprint, MACs, pins and binding the
|
||||
form never shows survive a rename; it **replaces** the menu rather than stacking on it, keeping the
|
||||
shell's "depth ≤ 1 by construction" true. A pinned profile card offers only Unpin — it is a
|
||||
shortcut, not a second host.
|
||||
|
||||
**The start-of-stream shortcut banner is retired.** Telling someone the controls for six seconds,
|
||||
over the stream they just connected to, answers the question at the one moment nobody is asking it
|
||||
— and it put a composited overlay above the stream to do it. The words are now a catalogue rendered
|
||||
in an About page you can open, which is also its own section rather than the last row of Interface.
|
||||
Its remaining fixes: the identity card became a version line under the rows, a zero-radius clip is
|
||||
still a clip (it cropped the TV's wide icon), and the card ignored the row column.
|
||||
|
||||
⚠ **Apple console screens read the ink they publish.** A SwiftUI screen cannot read the environment
|
||||
value it publishes in the same view — so a pale palette stayed white-on-white on Apple TV. Fixed
|
||||
across every console screen.
|
||||
|
||||
### Console UI — Skia sized its function table to the loader, not to what we promised
|
||||
|
||||
🛑 **On a Steam Deck the console home died on update**, and in a stream the same failure quietly
|
||||
cost the stats OSD and capture HUD.
|
||||
|
||||
The skia-safe 0.87 → 0.99 move swapped `BackendContext::new` for `new_builder(…, None)` and
|
||||
recorded the `None` as "byte-for-byte what the removed constructor did". True of the **value**,
|
||||
false of the **behaviour**: `None` leaves Skia's `fMaxAPIVersion` at its `0` sentinel, and the newer
|
||||
Skia acts on that sentinel by falling back to **`vkEnumerateInstanceVersion()` — the loader's
|
||||
ceiling, not ours.** The presenter declares 1.3; a current Mesa answers 1.4 (1.4.321 on SteamOS
|
||||
3.7, host and inside the flatpak sandbox alike). Skia then validates a 1.4 function table against an
|
||||
instance that only promised 1.3, `vkGetDeviceProcAddr` returns null for the entry points in
|
||||
between, and `make_vulkan` hands back `None`. At 0.87 the sentinel was inert because that Skia knew
|
||||
nothing of Vulkan 1.4 — **which is why this surfaced the moment v0.28.0 landed.**
|
||||
|
||||
`run.rs` makes an overlay that cannot init fatal for `--browse`, so the Decky panel's button and the
|
||||
gamepad-UI library shortcut both failed to open. The presenter now publishes
|
||||
`SharedDevice::api_version` — `min(what we declared, what the loader reports)` — and
|
||||
`SkiaOverlay::init` passes it instead of `None`. ⚠ `pf-presenter`'s `vk` module is
|
||||
`cfg(any(linux, windows))`, so this was never Deck-specific.
|
||||
|
||||
### pf-vkdecode — AV1's "maximum parameters" level is not a level above the ceiling
|
||||
|
||||
🛑 **Every AV1 session demoted to D3D11VA** with `stream level (seq_level_idx 31) above the device's
|
||||
maxLevel (AV1 Std level 23)` — on hardware decoding the stream trivially on the rung it fell
|
||||
through to.
|
||||
|
||||
`seq_level_idx` is a 5-bit field: Annex A defines 0…23 (levels 2.0…7.3), reserves 24…30, and makes
|
||||
**31 the "maximum parameters" level — the spec's own way of saying the bitstream is not constrained
|
||||
to a level.** `StdVideoAV1Level` stops at 7.3 = 23, so 31 has no Std code point and the index-coded
|
||||
comparison that holds across 0…23 says nothing: `31 > 23` is true even of a device that decodes
|
||||
everything AV1 can name, which is what makes it useless as a capability test. We write no AV1 level
|
||||
on any host encode path, so whichever sentinel the vendor's encoder defaults to is what the client
|
||||
must accept. This is the AV1 half of the same defect fixed for H.264/H.265 in v0.28.0, which was
|
||||
left alone on the premise that no over-declaration had been seen in the field — the reporter's log
|
||||
from that same day already showed otherwise.
|
||||
|
||||
### Client stats — the stage line is a partition again
|
||||
|
||||
A field reader added up `host 5.4 · net 0.3 · decode 6.6 · display 1.4` against `e2e 8.1` and asked
|
||||
why the parts did not sum. Fair question: they sum **without** `decode`.
|
||||
|
||||
The stages *are* a per-frame partition of e2e — pts →(host+net)→ received →(decode)→ decoded
|
||||
→(display)→ displayed — for as long as the `decoded` stamp is a **completion** stamp. On the
|
||||
synchronous rungs it is. On the **native-Vulkan** rung `receive_frame` returns at *submission*
|
||||
(~0.1 ms) and the stamp is taken there, so `display` is measured from submit and the GPU decode
|
||||
happens **inside** it. `host+net` and `display` already tile e2e; the `decode` figure (received →
|
||||
fence-complete) re-counts the GPU work `display` contains — two figures with one overlap, printed
|
||||
as though they tiled.
|
||||
|
||||
On that rung `decode` now leaves the stage line and gets its own, carrying the two caveats a reader
|
||||
needs: it is **one sample per window** there, not the p50 every other figure on that line is, and it
|
||||
is already inside `display`, so adding it double-counts. The synchronous rungs are untouched.
|
||||
⚠ **Deliberately not changed:** the one-sample-per-window design. A per-frame fence wait serialises
|
||||
the decode pipeline (an APU's 19 ms decode capping a 5120×1440 stream at ~51 fps) and polling
|
||||
quantises every sample up by a frame interval. The reporting was the defect, not the sampling.
|
||||
|
||||
### Host — two warnings that named the wrong subsystem
|
||||
|
||||
Both fired in the same 2026-08-13 field log, and both sent an investigation somewhere innocent:
|
||||
|
||||
- **"Client keyframe recoveries are METRONOMIC — a periodic host/display disturbance … is the
|
||||
likely cause"**, at `period_s=2.0`, naming three host subsystems. **2.0 s is the *client's*
|
||||
`FLUSH_COOLDOWN`.** The receive-backlog guard sheds a standing queue with a flush plus a keyframe
|
||||
request, rate-limited to one per cooldown, so a client that cannot sustain the stream asks for a
|
||||
keyframe at exactly that spacing for as long as it stays behind. **Perfect periodicity is the
|
||||
signature of a fixed software cooldown, not of a physical disturbance.** The host now compares
|
||||
against `punktfunk_core::client::FLUSH_COOLDOWN` itself rather than a copy of the number, so the
|
||||
two cannot drift.
|
||||
- **"The audio encode thread could not keep up — captured audio was DROPPED"**, worst case
|
||||
`dropped_chunks=11251`. Not one sample anybody wanted was lost. PipeWire negotiated a 128-frame
|
||||
quantum, so the plane produces 48000/128 = 375 chunks/s and a 30 s window holds exactly 11250 —
|
||||
a 100 % drop rate at `peak_db=-120.0`, digital silence. Every one of the ten warnings straddled a
|
||||
**session boundary**, and `dropped_chunks/375` matches the seconds with *no live session* in that
|
||||
window to within a fraction of a second. The warning no longer fires for idle seconds.
|
||||
|
||||
### NixOS — the plugin runner was installed, running, and reported missing
|
||||
|
||||
🛑 **On NixOS every plugin *package* op failed with "the plugin runner isn't installed", on a box
|
||||
where the runner was installed, enabled and running.** `punktfunk-host plugins status` said so, and
|
||||
the console's Plugins screen still refused to install anything.
|
||||
|
||||
The host resolved `punktfunk-scripting` by checking FHS locations exclusively —
|
||||
`/usr/bin/punktfunk-scripting`, the `/usr/lib` + `/usr/share` pair behind it, and the `~/.local`
|
||||
mirror the SteamOS installer lays down. Nix installs a wrapper at `$out/bin/punktfunk-scripting` in
|
||||
a **derivation of its own**, so it is neither beside the host binary nor anywhere under `/usr`, and
|
||||
nothing the resolver looked at could ever match. Service ops (`enable`/`disable`/`status`) go
|
||||
through systemd and were unaffected, which is what made the failure read as arbitrary: the runner
|
||||
demonstrably worked, and only the half that had to *locate the executable* was blind.
|
||||
|
||||
Resolution now matches `punktfunk-encode-worker`'s: **`PUNKTFUNK_SCRIPTING` → beside the host
|
||||
binary → `PATH` → the `/usr` layout → the `~/.local` layout.** `PATH` is the rung Nix lands on. The
|
||||
`/usr` rungs are kept after it rather than dropped, because a systemd unit's `PATH` need not include
|
||||
`/usr/bin`. As with the encode worker, an explicit `PUNKTFUNK_SCRIPTING` is deliberately *not*
|
||||
existence-checked — a named path that is wrong should fail naming itself, not fall through to some
|
||||
other runner. The "not installed" text now also names NixOS and the override, instead of pointing
|
||||
every operator at `apt`.
|
||||
|
||||
⚠ **Packager-visible, and the other half of the fix:** the NixOS module now puts
|
||||
`services.punktfunk.scripting.package` on the **host unit's** `path`. `environment.systemPackages`
|
||||
only ever covered an operator's interactive shell, and the console installs plugins from *inside*
|
||||
the host service — whose `PATH` is exactly that unit list. Without it the CLI would have been fixed
|
||||
and the console would not. Anyone packaging the host separately wants the same property: the runner
|
||||
must be on the service's `PATH`, or `PUNKTFUNK_SCRIPTING` set for it.
|
||||
|
||||
The `ln -s "$(command -v punktfunk-scripting)" ~/.local/bin/punktfunk-scripting` workaround is no
|
||||
longer needed and can be removed.
|
||||
|
||||
### `/bin/true` and `/bin/false` are not portable — two tests failed on NixOS
|
||||
|
||||
NixOS ships only `sh` in `/bin`, so `gamelease`'s hand-off test and `pyrowave_remote`'s
|
||||
handshake-rung test failed there for reasons unrelated to the code under test. Both now resolve a
|
||||
real binary rather than assuming an FHS path.
|
||||
|
||||
### Documentation
|
||||
|
||||
**`docs-site/public/openapi.json` was stale again, and by the same mechanism as last release.**
|
||||
v0.28.0 fixed it once (it was five releases behind at `0.21.0`); the scanner-removal regen then
|
||||
updated `api/openapi.json` alone and it drifted a second time inside that same cycle. It has now
|
||||
drifted a third time, across the unpair-all endpoints — the docs-site copy was still stamped
|
||||
`0.27.0` and missing both collection deletes. Re-synced; the two files are byte-identical again.
|
||||
|
||||
⚠ **The copy is a documented manual step (`cp api/openapi.json docs-site/public/openapi.json`,
|
||||
CONTRIBUTING.md) and nothing in CI enforces it.** Three drifts in two release cycles is the
|
||||
argument for gating it; until something does, **treat the copy as part of regenerating, not as a
|
||||
follow-up.**
|
||||
|
||||
---
|
||||
|
||||
## v0.28.0
|
||||
|
||||
180 commits since v0.27.0.
|
||||
|
||||
Generated
+36
-36
@@ -1090,7 +1090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1222,7 +1222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"pf-win-display",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
@@ -2343,7 +2343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2446,7 +2446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-bitstream"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"tracing",
|
||||
@@ -2975,7 +2975,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2996,7 +2996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3031,7 +3031,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3049,7 +3049,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3071,7 +3071,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-dxvadec"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3081,7 +3081,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3107,7 +3107,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -3119,7 +3119,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -3133,11 +3133,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3166,14 +3166,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3188,7 +3188,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3196,7 +3196,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-lc-rs",
|
||||
@@ -3208,7 +3208,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3217,7 +3217,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3250,7 +3250,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
@@ -3261,7 +3261,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"pf-paths",
|
||||
"punktfunk-core",
|
||||
@@ -3272,7 +3272,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3484,7 +3484,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3494,7 +3494,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3512,7 +3512,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3529,7 +3529,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"pf-console-ui",
|
||||
@@ -3543,7 +3543,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"mdns-sd",
|
||||
@@ -3561,7 +3561,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"cbindgen",
|
||||
@@ -3593,7 +3593,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-encode-worker"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"pf-encode",
|
||||
"tracing",
|
||||
@@ -3602,7 +3602,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3672,7 +3672,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3686,7 +3686,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3709,7 +3709,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.28.0"
|
||||
version = "0.28.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+97
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.27.0"
|
||||
"version": "0.28.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -45,6 +45,36 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"clients"
|
||||
],
|
||||
"summary": "Unpair every client",
|
||||
"description": "The collection form of [`unpair_client`]: empties the pairing store in ONE persisted write,\ncarrying the same revocation guarantees across the whole set. A LIVE GameStream session is\nended (its owning certificate is necessarily one of those just removed), and the ENet control\nport (UDP 47999) closes, because no pairing is left to hold it open.\n\nIdempotent, and so a 200 rather than the single unpair's 204/404 pair: \"unpair everything\" is\nsatisfied by an already-empty store, and the operator still wants to know whether that meant\nthree devices or none.",
|
||||
"operationId": "unpairAllClients",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Every client unpaired (possibly none)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnpairAllResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/clients/{fingerprint}": {
|
||||
@@ -1767,6 +1797,56 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"native"
|
||||
],
|
||||
"summary": "Unpair every native client",
|
||||
"description": "The collection form of [`unpair_native_client`]: empties the punktfunk/1 trust store in ONE\npersisted write (not a loop of them — a failure partway would leave a half-emptied store), and\nends every live native session the removed clients own.\n\nIdempotent, hence a 200 rather than the single unpair's 204/404: an already-empty store\nsatisfies the request, and the count still tells the operator what it meant.",
|
||||
"operationId": "unpairAllNativeClients",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Every native client unpaired (possibly none)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnpairAllResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Could not persist the trust store",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Native host not enabled",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/native/clients/{fingerprint}": {
|
||||
@@ -7687,6 +7767,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"UnpairAllResult": {
|
||||
"type": "object",
|
||||
"description": "What a bulk unpair removed. Shared by the two collection DELETEs (`/clients` and\n`/native/clients`) so the console sees one schema across both pairing planes.\n\nA count rather than 204: \"unpair everything\" is idempotent, so an empty store is a success, and\nthe operator still wants to be told whether that meant three devices or none.",
|
||||
"required": [
|
||||
"unpaired"
|
||||
],
|
||||
"properties": {
|
||||
"unpaired": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Clients removed from the trust store — 0 when nothing was paired.",
|
||||
"example": 3,
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateJobInfo": {
|
||||
"type": "object",
|
||||
"description": "A running apply job (or a spawned installer that hasn't resolved yet).",
|
||||
|
||||
@@ -51,6 +51,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libxdamage-dev libxcomposite-dev libxrender-dev libxext-dev libxxf86vm-dev \
|
||||
libxtst-dev libx11-dev libxres-dev libxmu-dev libxcursor-dev libxi-dev \
|
||||
libxfixes-dev libxkbcommon-dev libxkbcommon-x11-dev libcap-dev libdrm-dev \
|
||||
# x11-xcb is needed by the VULKAN WSI LAYER (layer/meson.build), not by the compositor — so it
|
||||
# was not missed until v0.28.1 started building the layer beside the binary. Debian is the only
|
||||
# channel that needs it named: Arch's libx11 and Fedora's libX11-devel both carry x11-xcb.pc
|
||||
# themselves, while Debian splits it into its own -dev package.
|
||||
libx11-xcb-dev \
|
||||
libinput-dev libudev-dev libpipewire-0.3-dev libseat-dev libsdl2-dev \
|
||||
libluajit-5.1-dev libavif-dev libdecor-0-dev hwdata libglm-dev libbenchmark-dev \
|
||||
libvulkan-dev libxcb1-dev libxcb-composite0-dev libxcb-xfixes0-dev libxcb-res0-dev \
|
||||
@@ -66,3 +71,13 @@ RUN set -eux; \
|
||||
pkg-config --atleast-version=1.23.1 wayland-server \
|
||||
|| { echo "wayland-server $have < 1.23.1 — the vendored wlroots will not configure" >&2; exit 1; }; \
|
||||
echo "wayland-server $have — OK"
|
||||
|
||||
# The layer's own floor, asserted for the same reason: a missing x11-xcb does not fail the
|
||||
# COMPOSITOR build, it fails `layer/meson.build` — and the layer is the only route to an HDR10
|
||||
# swapchain for a nested game, so losing it silently ships a package that looks healthy and denies
|
||||
# every game HDR. This is exactly how v0.28.1's deb leg broke, one release after the layer was
|
||||
# added; assert it here so the next dep the layer grows fails at image build, not mid-release.
|
||||
RUN set -eux; \
|
||||
pkg-config --exists x11-xcb \
|
||||
|| { echo "x11-xcb absent — the Vulkan WSI layer will not configure (need libx11-xcb-dev)" >&2; exit 1; }; \
|
||||
echo "x11-xcb $(pkg-config --modversion x11-xcb) — OK"
|
||||
|
||||
@@ -816,7 +816,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
?: (runCatching { context.display }.getOrNull()?.refreshRate ?: 0f)
|
||||
.roundToInt(),
|
||||
)
|
||||
NativeBridge.nativeStartAudio(handle, lowLatencyMode)
|
||||
NativeBridge.nativeStartAudio(handle, lowLatencyMode, isTv)
|
||||
if (micWanted) {
|
||||
val sessionId =
|
||||
NativeBridge.nativeStartMic(handle, initialSettings.echoCancel)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package io.unom.punktfunk.screenshots
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* The same Roborazzi harness as ScreenshotTest, at Android TV geometry: 960×540dp in the
|
||||
* `television` UI mode at xhdpi (2.0×) = 1920×1080 px — the Play Store's 16:9 TV screenshot size,
|
||||
* captured 1:1 with no resampling. Only the screens that exist on a TV are shot here: the
|
||||
* gamepad-console shell (what LEANBACK_LAUNCHER opens into) and the in-stream view. Files are
|
||||
* prefixed `tv-` so the artifact separates the form factors.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class TvScreenshotTest {
|
||||
@get:Rule
|
||||
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val out = "build/outputs/roborazzi"
|
||||
|
||||
private fun shootRoot(name: String, content: @androidx.compose.runtime.Composable () -> Unit) {
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent { ShotTheme(content) }
|
||||
compose.mainClock.advanceTimeBy(800)
|
||||
compose.onRoot().captureRoboImage("$out/tv-$name.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stream() = shootRoot("stream") { StreamScene(io.unom.punktfunk.StatsVerbosity.COMPACT) }
|
||||
|
||||
@Test
|
||||
fun streamDetailed() =
|
||||
shootRoot("stream-detailed") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
|
||||
|
||||
@Test
|
||||
fun consoleHome() = shootRoot("console-home") { ConsoleHomeScene() }
|
||||
|
||||
@Test
|
||||
fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() }
|
||||
|
||||
@Test
|
||||
fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() }
|
||||
|
||||
@Test
|
||||
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
|
||||
}
|
||||
@@ -302,12 +302,17 @@ object NativeBridge {
|
||||
external fun nativeSetVideoStatsEnabled(handle: Long, enabled: Boolean)
|
||||
|
||||
/**
|
||||
* Start host→client audio: Opus decode → jitter ring → AAudio (LowLatency), all in Rust.
|
||||
* Start host→client audio: Opus decode → jitter ring → AAudio, all in Rust.
|
||||
* [lowLatencyMode] (the experimental toggle) additionally tags the stream usage=Game for the
|
||||
* HAL's game-audio routing. No-op if already started. Best-effort — a failure leaves video
|
||||
* streaming.
|
||||
*
|
||||
* [isTv] steers the AAudio open ladder: a TV box starts at Shared rather than betting the
|
||||
* audio plane on an Exclusive/MMAP path whose routing we cannot verify from inside the
|
||||
* process. Passed from `FEATURE_LEANBACK` (same source as [nativeStartVideo]) because the
|
||||
* native side's own `ro.build.characteristics` check is not answered by every TV device.
|
||||
*/
|
||||
external fun nativeStartAudio(handle: Long, lowLatencyMode: Boolean)
|
||||
external fun nativeStartAudio(handle: Long, lowLatencyMode: Boolean, isTv: Boolean)
|
||||
|
||||
/** Stop + join the audio thread and close AAudio, without closing the session. No-op on `0`. */
|
||||
external fun nativeStopAudio(handle: Long)
|
||||
|
||||
+692
-236
File diff suppressed because it is too large
Load Diff
@@ -357,10 +357,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoSta
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStartAudio(handle, lowLatencyMode)` — start the Opus→AAudio playback thread.
|
||||
/// `lowLatencyMode` (the experimental toggle) tags the stream usage=Game for the HAL's game-audio
|
||||
/// routing. No-op if already started or on a `0` handle. Best-effort: a failure leaves video
|
||||
/// streaming.
|
||||
/// `NativeBridge.nativeStartAudio(handle, lowLatencyMode, isTv)` — start the Opus→AAudio playback
|
||||
/// supervisor. `lowLatencyMode` (the experimental toggle) tags the stream usage=Game for the HAL's
|
||||
/// game-audio routing; `isTv` steers the AAudio open ladder (see `crate::audio::open_ladder`).
|
||||
/// No-op if already started or on a `0` handle. Best-effort: a failure leaves video streaming.
|
||||
#[cfg(target_os = "android")]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
||||
@@ -368,6 +368,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
low_latency_mode: jboolean,
|
||||
is_tv: jboolean,
|
||||
) {
|
||||
if handle == 0 {
|
||||
return;
|
||||
@@ -378,7 +379,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
||||
if guard.is_some() {
|
||||
return; // already playing
|
||||
}
|
||||
match crate::audio::AudioPlayback::start(h.client.clone(), low_latency_mode) {
|
||||
match crate::audio::AudioPlayback::start(h.client.clone(), low_latency_mode, is_tv) {
|
||||
Some(p) => *guard = Some(p),
|
||||
None => log::error!("nativeStartAudio: playback init failed (video unaffected)"),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "about-icon@1x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "about-icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
@@ -83,14 +83,6 @@ struct ContentView: View {
|
||||
/// never covers the video.
|
||||
@State private var isFullscreen = false
|
||||
#endif
|
||||
#if os(macOS) || os(tvOS)
|
||||
/// Shows the start-of-stream shortcut banner (the Windows client's discoverability
|
||||
/// pattern): raised on every transition to `.streaming`, dropped by the banner's own
|
||||
/// 6-second task. Independent of the stats HUD so the keys are discoverable even with
|
||||
/// statistics off. On tvOS it carries the ONLY exits (hold Back / the pad chord) plus
|
||||
/// the remote-as-pointer controls, so it must be seen at least once per session.
|
||||
@State private var showShortcutHint = false
|
||||
#endif
|
||||
#if os(iOS)
|
||||
/// The stats-OFF tier's touch-exit disc window (see the overlay in `stream(captureEnabled:)`
|
||||
/// — the disc must LEAVE the hierarchy so nothing composites over the metal layer).
|
||||
@@ -347,9 +339,6 @@ struct ContentView: View {
|
||||
.onChange(of: model.phase) { _, phase in
|
||||
switch phase {
|
||||
case .streaming:
|
||||
#if os(macOS) || os(tvOS)
|
||||
showShortcutHint = true // the 6 s shortcut banner, per session start
|
||||
#endif
|
||||
#if os(iOS)
|
||||
showTouchExit = true // the off-tier exit disc's 8 s window, per session start
|
||||
#endif
|
||||
@@ -453,6 +442,10 @@ struct ContentView: View {
|
||||
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
|
||||
}
|
||||
.frame(minWidth: 940, minHeight: 620)
|
||||
// The stack draws the title, and it sits outside LibraryView's own ink — see the tvOS
|
||||
// cover. Gated, because this sheet is BOTH modes' library on macOS and the touch
|
||||
// grid's title belongs to the system background.
|
||||
.gamepadPaletteInk(gamepadUIActive)
|
||||
}
|
||||
#else
|
||||
// iOS: the cover is the TOUCH UI's presentation only. In gamepad mode the library is one
|
||||
@@ -822,6 +815,7 @@ struct ContentView: View {
|
||||
onPaired: handlePaired, waker: waker,
|
||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
||||
launchTitle: launchTitle,
|
||||
wakeOnly: { wakeOnly($0) },
|
||||
promptActive: consolePromptShowing)
|
||||
} else {
|
||||
HomeView(
|
||||
@@ -841,6 +835,7 @@ struct ContentView: View {
|
||||
onPaired: handlePaired, waker: waker,
|
||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
||||
launchTitle: launchTitle,
|
||||
wakeOnly: { wakeOnly($0) },
|
||||
promptActive: consolePromptShowing)
|
||||
// On tvOS pairing/library normally present from HomeView's navigationDestinations
|
||||
// — which aren't mounted while the gamepad launcher is up. Give the launcher its
|
||||
@@ -851,12 +846,29 @@ struct ContentView: View {
|
||||
.fullScreenCover(item: $pairingTarget) { host in
|
||||
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
|
||||
.onExitCommand { pairingTarget = nil }
|
||||
// A tvOS cover draws NO background of its own, and this one is attached
|
||||
// outside the launcher's `gamepadPaletteInk` — so the pairing screen used
|
||||
// to render the system's dark chrome directly over the launcher showing
|
||||
// through it, which under a pale palette is white text on a bright field
|
||||
// (the PIN prompt was all but invisible). Give it the console's own field
|
||||
// and the palette's ink, like every other screen the launcher opens. Only
|
||||
// this branch: `HomeView`'s route to the same sheet is the TOUCH UI, which
|
||||
// sits on the system background and has no palette.
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background { GamepadFormBackground() }
|
||||
.gamepadPaletteInk()
|
||||
}
|
||||
.fullScreenCover(item: $libraryTarget) { shelf in
|
||||
NavigationStack {
|
||||
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
|
||||
}
|
||||
.onExitCommand { libraryTarget = nil }
|
||||
// On the STACK, not just inside LibraryView: the navigation title is drawn by
|
||||
// the stack, which wraps that view from outside its own `gamepadPaletteInk` —
|
||||
// so the shelf's name stayed white over a pale field while the content below
|
||||
// it had already gone dark. Unconditional here because this cover only exists
|
||||
// in the launcher's branch, where the console UI is by definition drawing.
|
||||
.gamepadPaletteInk()
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
@@ -973,9 +985,15 @@ struct ContentView: View {
|
||||
model?.disconnect() // the captured-state ⌃⌥⇧D combo
|
||||
},
|
||||
onFrame: { [meter = model.meter, latency = model.latency,
|
||||
split = model.latencySplit, queue = model.clientQueue,
|
||||
offset = conn.clockOffsetNs] au in
|
||||
split = model.latencySplit, queue = model.clientQueue] au in
|
||||
meter.note(byteCount: au.data.count)
|
||||
// Read the offset PER AU (an atomic load), never in the capture list: a
|
||||
// capture-list `offset =` froze the connect-time estimate for the whole
|
||||
// session, and on a host whose wall clock steps (VM + NTP) that frozen
|
||||
// value shifted hostnet/e2e by ~15 ms between sessions while the meter's
|
||||
// impossible-sample guard hid the damage (field 2026-08-13). See
|
||||
// `PunktfunkConnection.clockOffsetNs`.
|
||||
let offset = conn.clockOffsetNs
|
||||
latency.record(ptsNs: au.ptsNs, offsetNs: offset)
|
||||
// The same receipt, keyed by pts, awaiting its 0xCF host timing (the
|
||||
// host/network split — drained by the 1 s stats tick). receivedNs is
|
||||
@@ -1048,31 +1066,15 @@ struct ContentView: View {
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||
}
|
||||
#endif
|
||||
#if os(macOS) || os(tvOS)
|
||||
// The start-of-stream shortcut banner (Windows-client parity): the
|
||||
// The start-of-stream shortcut banner used to sit here (macOS/tvOS): the
|
||||
// platform's reserved controls on a glass pill for the first 6 seconds of
|
||||
// every session — independent of the stats HUD, so the keys are
|
||||
// discoverable even with statistics off. The banner's own task drops it
|
||||
// (cancelled cleanly if the session view goes away first). On tvOS it
|
||||
// carries the ONLY exits — Menu/B is swallowed during a session (the
|
||||
// `.onExitCommand {}` in the tvOS session branch), so the hold gestures
|
||||
// must be told to the user.
|
||||
if captureEnabled && showShortcutHint {
|
||||
Text(shortcutHintText)
|
||||
.font(.geist(Self.shortcutHintFont, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.glassBackground(Capsule())
|
||||
.transition(.opacity)
|
||||
.task {
|
||||
try? await Task.sleep(for: .seconds(6))
|
||||
withAnimation(.easeOut(duration: 0.6)) {
|
||||
showShortcutHint = false
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// every session. It is now a page you can OPEN — About ▸ Shortcuts, on
|
||||
// both the touch and the controller surface (ShortcutsCatalog) — because
|
||||
// a message that shows once, over the stream you have just connected to,
|
||||
// is unavailable at the moment the question is actually asked. It also
|
||||
// put a composited overlay above the stream for those 6 seconds, which on
|
||||
// this path costs a refresh of display latency (see the iOS exit disc's
|
||||
// note below); the reference page costs nothing during a session.
|
||||
}
|
||||
.padding(.bottom, 24)
|
||||
.animation(.easeOut(duration: 0.2), value: model.micMuted)
|
||||
@@ -1139,23 +1141,10 @@ struct ContentView: View {
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
/// The reserved combos, told once per session. The mute segment appears only when the session
|
||||
/// actually sends a microphone — teaching a shortcut for a mic that isn't on would be a lie.
|
||||
private var shortcutHintText: String {
|
||||
let base =
|
||||
"Click the stream to capture · ⌃⌥⇧Q releases the mouse · ⌃⌥⇧D disconnects · ⌃⌥⇧S stats"
|
||||
return model.micAvailable ? base + " · ⌃⌥⇧A mutes the mic" : base
|
||||
}
|
||||
private static let shortcutHintFont: CGFloat = 12
|
||||
#elseif os(tvOS)
|
||||
private var shortcutHintText: String {
|
||||
"Hold the remote's Back button — or L1+R1+Start+Select on a controller — to disconnect"
|
||||
+ " · Touch surface moves the pointer · press clicks · Play/Pause right-clicks"
|
||||
+ " · Hold Play/Pause, or Select+X on a controller, for statistics"
|
||||
}
|
||||
private static let shortcutHintFont: CGFloat = 22 // read from the couch
|
||||
#endif
|
||||
// The two `shortcutHintText` strings that used to live here — one per platform, told once per
|
||||
// session by the banner above — are now `ShortcutsCatalog.groups`, which both About pages
|
||||
// render. The mic line is still conditional there for the same reason it was here: teaching a
|
||||
// shortcut for a microphone that isn't on would be a lie.
|
||||
|
||||
// MARK: - Connect
|
||||
|
||||
|
||||
@@ -12,7 +12,10 @@ import SwiftUI
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
|
||||
struct GamepadAddHostView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
/// Resolved from the stored palette, NOT from `\.gamepadInk` — this screen publishes that
|
||||
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
private var ink: GamepadInk { .stored(paletteID) }
|
||||
@Environment(\.gamepadMetrics) private var metrics
|
||||
@Environment(\.displayBottomInset) private var displayBottomInset
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@@ -25,6 +28,18 @@ struct GamepadAddHostView: View {
|
||||
/// Whether this screen owns the controller — false while the shell is mid-transition or the
|
||||
/// connect takeover is up (see GamepadSettingsView's twin).
|
||||
var controllerActive = true
|
||||
/// Non-nil ⇒ this screen is EDITING that saved host rather than registering a new one: the
|
||||
/// fields start on its values and `onAdd` receives it back with only name/address/port
|
||||
/// changed, so the fingerprint, pins, binding and MACs it carries survive the edit. A
|
||||
/// re-typed address is the whole point of the screen (a host that moved), so nothing here
|
||||
/// re-derives identity from it — that is the trust store's job, not this form's.
|
||||
///
|
||||
/// Declared after the closures for the same trailing-closure reason as `close`, and it is a
|
||||
/// plain value besides, so it can never capture one.
|
||||
var editingHost: StoredHost?
|
||||
/// One-shot seed guard: `@State` cannot be initialised from a property without a custom init,
|
||||
/// and a custom init would break every existing trailing-closure call site.
|
||||
@State private var seeded = false
|
||||
|
||||
#if os(iOS)
|
||||
/// `.compact` in a landscape phone window — tighter chrome so the keyboard tray still fits.
|
||||
@@ -57,12 +72,15 @@ struct GamepadAddHostView: View {
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) {
|
||||
// Leading, like every gamepad heading — and no close chrome (B is the exit).
|
||||
Text("Add Host")
|
||||
Text(editingHost == nil ? "Add Host" : "Edit Host")
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(ink.fg)
|
||||
if !compact {
|
||||
Text("Hosts on this network appear automatically — add one by address "
|
||||
+ "for everything else.")
|
||||
Text(editingHost == nil
|
||||
? "Hosts on this network appear automatically — add one by address "
|
||||
+ "for everything else."
|
||||
: "Rename this host, or point it at a new address — its pairing and "
|
||||
+ "pinned cards are kept.")
|
||||
.font(.geist(metrics.detailFont, relativeTo: .caption))
|
||||
.foregroundStyle(ink.fg(0.55))
|
||||
.multilineTextAlignment(.leading)
|
||||
@@ -98,6 +116,17 @@ struct GamepadAddHostView: View {
|
||||
.onChange(of: port) { _, value in
|
||||
if value.count > 5 { port = String(value.prefix(5)) }
|
||||
}
|
||||
// Seed the fields from the host being edited, exactly once: re-seeding on a later appear
|
||||
// (the shell re-mounts a layer when the app returns from the background) would silently
|
||||
// throw away whatever had been typed.
|
||||
.onAppear {
|
||||
guard !seeded else { return }
|
||||
seeded = true
|
||||
guard let host = editingHost else { return }
|
||||
name = host.name
|
||||
address = host.address
|
||||
port = String(host.port)
|
||||
}
|
||||
#if !os(tvOS)
|
||||
// The visible close ✕ is gone (a gamepad UI exits with B) — this keeps a hardware
|
||||
// keyboard's Esc and the macOS sheet's cancel working without chrome.
|
||||
@@ -202,7 +231,9 @@ struct GamepadAddHostView: View {
|
||||
Row(id: "name", label: "Name", value: name, placeholder: "Optional — e.g. Living Room"),
|
||||
Row(id: "address", label: "Address", value: address, placeholder: "IP or hostname"),
|
||||
Row(id: "port", label: "Port", value: port, placeholder: "9777"),
|
||||
Row(id: "add", label: "Add Host", isAction: true),
|
||||
Row(
|
||||
id: "add", label: editingHost == nil ? "Add Host" : "Save Changes",
|
||||
isAction: true),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -261,10 +292,21 @@ struct GamepadAddHostView: View {
|
||||
openKeyboard("address")
|
||||
return
|
||||
}
|
||||
onAdd(StoredHost(
|
||||
name: name.trimmingCharacters(in: .whitespaces),
|
||||
address: address.trimmingCharacters(in: .whitespaces),
|
||||
port: UInt16(port) ?? 9777))
|
||||
let typedName = name.trimmingCharacters(in: .whitespaces)
|
||||
let typedAddress = address.trimmingCharacters(in: .whitespaces)
|
||||
let typedPort = UInt16(port) ?? 9777
|
||||
if var host = editingHost {
|
||||
// Mutate a COPY of the stored record rather than building a fresh one: everything
|
||||
// this form does not show — the pinned fingerprint, WoL MACs, pinned profile
|
||||
// cards, the default binding, `addedAt` — has to survive a rename.
|
||||
host.name = typedName
|
||||
host.address = typedAddress
|
||||
host.port = typedPort
|
||||
onAdd(host)
|
||||
} else {
|
||||
onAdd(StoredHost(
|
||||
name: typedName, address: typedAddress, port: typedPort))
|
||||
}
|
||||
performClose()
|
||||
default:
|
||||
openKeyboard(id)
|
||||
|
||||
@@ -48,6 +48,13 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
var onTertiary: (() -> Void)?
|
||||
/// B → back/dismiss; nil disables it (e.g. the root launcher has nowhere to go back to).
|
||||
var onBack: (() -> Void)?
|
||||
/// UP → the focused item's own menu (the launcher's host options). Wiring it takes the whole
|
||||
/// VERTICAL axis away from scrolling: up opens the menu and down goes inert, rather than up
|
||||
/// meaning "menu" while down still stepped the strip. A horizontal carousel has no vertical
|
||||
/// travel to spend, and the desktop and Android consoles both read the axis this way — one
|
||||
/// meaning per direction is what makes the gesture learnable across the three of them.
|
||||
/// nil leaves up/down as a second way to step (what every carousel without a menu still does).
|
||||
var onUp: (() -> Void)?
|
||||
/// L1/R1 → jump this many items at once (clamped to the ends); 0 disables the shoulders.
|
||||
var shoulderJump: Int = 0
|
||||
/// Whether this carousel currently owns controller input. A presenting screen (e.g. the host
|
||||
@@ -301,6 +308,17 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
// The poll carries only the buttons focus has no concept of: Y/X, the screen actions.
|
||||
input.onSecondary = onSecondary
|
||||
input.onTertiary = onTertiary
|
||||
// UP is the one direction the poll may also read here, and ONLY to open the menu — it
|
||||
// never calls `step`, so it cannot double-move against the focus engine. Routing it
|
||||
// through `.onMoveCommand` instead was the obvious alternative and the wrong one: that
|
||||
// stream is 4-way and its interception is input-source-dependent on real hardware (see
|
||||
// GamepadMenuList's tvOS note), so claiming up there risks left/right focus with it.
|
||||
// Nothing sits above the strip for the engine to move to, so this direction is free.
|
||||
if let onUp {
|
||||
input.onMove = { direction in
|
||||
if direction == .up { onUp() }
|
||||
}
|
||||
}
|
||||
#else
|
||||
input.onMove = { move($0) }
|
||||
input.onConfirm = { activate() }
|
||||
@@ -312,6 +330,14 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
}
|
||||
|
||||
private func move(_ direction: GamepadMenuInput.Direction) {
|
||||
// With a menu wired, vertical is the menu's axis, not a second scroll axis — see `onUp`.
|
||||
if let onUp {
|
||||
switch direction {
|
||||
case .up: return onUp()
|
||||
case .down: return
|
||||
case .left, .right: break
|
||||
}
|
||||
}
|
||||
let forward = direction == .right || direction == .down
|
||||
step(by: forward ? 1 : -1, clampAtEnds: false)
|
||||
}
|
||||
|
||||
@@ -430,7 +430,12 @@ private struct HintCellStyle: ButtonStyle {
|
||||
/// can't inflate the caller's layout past the safe area (see the layout note in GamepadHomeView's
|
||||
/// header). Honors Reduce Motion by freezing the field at a fixed phase.
|
||||
struct GamepadScreenBackground: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
/// Resolved from `paletteID` below rather than `\.gamepadInk`: this is mounted as a screen's
|
||||
/// `.background { }`, which the screen attaches BEFORE its own `gamepadPaletteInk()`, so the
|
||||
/// environment here is the screen's parent's — the dark default under a cover or a sheet. It
|
||||
/// only feeds a pale palette's scrim, so the symptom was subtle: the field bleached toward
|
||||
/// white instead of settling onto its own ink. (see `GamepadInk.stored`)
|
||||
private var ink: GamepadInk { .stored(paletteID) }
|
||||
/// How far toward the form screens' quiet the field sits: 0 = the launcher's full aurora,
|
||||
/// 1 = calm, fractional mid-chase. Continuous (not a Bool) so the in-place shell can CHASE
|
||||
/// it during a push/pop — the console does the same with its `bg_mix` — and every
|
||||
|
||||
@@ -64,7 +64,10 @@ private struct HomeTile: Identifiable {
|
||||
}
|
||||
|
||||
struct GamepadHomeView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
/// Resolved from the stored palette, NOT from `\.gamepadInk` — this screen publishes that
|
||||
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
private var ink: GamepadInk { .stored(paletteID) }
|
||||
/// Published by ContentView at the app ROOT, so this reads its own window's tier — this screen
|
||||
/// applies `gamepadPaletteInk` itself and so sits above its own copy of the environment.
|
||||
@Environment(\.gamepadMetrics) private var metrics
|
||||
@@ -91,6 +94,10 @@ struct GamepadHomeView: View {
|
||||
/// Launch a library title on a host — the in-place library layer's activate path (iOS; the
|
||||
/// cover/sheet presentations wire ContentView's `launchTitle` into LibraryView themselves).
|
||||
let launchTitle: (LibraryTarget, String) -> Void
|
||||
/// Wake a host WITHOUT connecting (ContentView's `wakeOnly`) — the host menu's Wake row. The
|
||||
/// tile's own A already wakes-and-connects; this is the other half, for bringing a machine up
|
||||
/// to look at it rather than to stream from it right now.
|
||||
let wakeOnly: (StoredHost) -> Void
|
||||
/// A console prompt (GamepadPromptView) is up over the home — it polls the same controller, so
|
||||
/// this screen must stand down for as long as it is. Same handoff contract as the connect
|
||||
/// takeover and the shell's own layers; without it the carousel keeps scrolling underneath the
|
||||
@@ -119,6 +126,11 @@ struct GamepadHomeView: View {
|
||||
@State private var selection: GamepadHomeTarget?
|
||||
@State private var showSettings = false
|
||||
@State private var showAddHost = false
|
||||
/// The card whose options menu is up (UP on a saved tile) — see GamepadHostOptionsView.
|
||||
@State private var hostOptionsTarget: HostOptionsTarget?
|
||||
/// The host being edited. Set from the options menu, which closes itself as it opens this so
|
||||
/// the two are never stacked — depth stays ≤ 1, which is what `GamepadScreen` assumes.
|
||||
@State private var editTarget: StoredHost?
|
||||
/// The console's input drop: true for the transition's 0.26 s, during which NO layer polls
|
||||
/// the controller — a double-tapped A can't push two screens, and the held button that
|
||||
/// caused the change is long released before the next poller starts (whose own
|
||||
@@ -201,19 +213,37 @@ struct GamepadHomeView: View {
|
||||
// shell's layers above ARE the presentation.
|
||||
#if os(macOS)
|
||||
.sheet(isPresented: $showSettings) {
|
||||
GamepadSettingsView(store: store)
|
||||
GamepadSettingsView(store: store, micAvailable: model.micAvailable)
|
||||
.frame(width: 720, height: 640)
|
||||
}
|
||||
.sheet(isPresented: $showAddHost) {
|
||||
GamepadAddHostView { store.add($0) }
|
||||
.frame(width: 660, height: 620)
|
||||
}
|
||||
// Shorter than the forms above: a menu is five rows, and a sheet sized for a settings
|
||||
// screen would be mostly empty field under them.
|
||||
.sheet(item: $hostOptionsTarget) { target in
|
||||
hostOptionsView(target, active: true)
|
||||
.frame(width: 620, height: 460)
|
||||
}
|
||||
.sheet(item: $editTarget) { host in
|
||||
editHostView(host, active: true)
|
||||
.frame(width: 660, height: 620)
|
||||
}
|
||||
.frame(minWidth: 640, minHeight: 420)
|
||||
#elseif os(tvOS)
|
||||
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView(store: store) }
|
||||
.fullScreenCover(isPresented: $showSettings) {
|
||||
GamepadSettingsView(store: store, micAvailable: model.micAvailable)
|
||||
}
|
||||
.fullScreenCover(isPresented: $showAddHost) {
|
||||
GamepadAddHostView { store.add($0) }
|
||||
}
|
||||
.fullScreenCover(item: $hostOptionsTarget) { target in
|
||||
hostOptionsView(target, active: true)
|
||||
}
|
||||
.fullScreenCover(item: $editTarget) { host in
|
||||
editHostView(host, active: true)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -261,6 +291,10 @@ struct GamepadHomeView: View {
|
||||
// can be raised from ON TOP of the library (launching a title on an unpaired host), where
|
||||
// it has to win. Backing out of it reveals whatever it interrupted.
|
||||
if let host = pairingTarget { return .pair(host) }
|
||||
// Editing leads the menu that raised it: the menu clears itself on the way, so the two are
|
||||
// never both set, and if they somehow were, the screen the user asked for last should win.
|
||||
if let host = editTarget { return .editHost(host) }
|
||||
if let target = hostOptionsTarget { return .hostOptions(target) }
|
||||
if showSettings { return .settings }
|
||||
if showAddHost { return .addHost }
|
||||
if let shelf = libraryTarget { return .library(shelf) }
|
||||
@@ -277,12 +311,17 @@ struct GamepadHomeView: View {
|
||||
GamepadSettingsView(
|
||||
store: store,
|
||||
close: { if !transitioning { showSettings = false } },
|
||||
controllerActive: active)
|
||||
controllerActive: active,
|
||||
micAvailable: model.micAvailable)
|
||||
case .addHost:
|
||||
GamepadAddHostView(
|
||||
onAdd: { store.add($0) },
|
||||
close: { if !transitioning { showAddHost = false } },
|
||||
controllerActive: active)
|
||||
case .hostOptions(let target):
|
||||
hostOptionsView(target, active: active)
|
||||
case .editHost(let host):
|
||||
editHostView(host, active: active)
|
||||
case .pair(let host):
|
||||
GamepadPairView(
|
||||
host: host,
|
||||
@@ -414,6 +453,7 @@ struct GamepadHomeView: View {
|
||||
onActivate: { $0.activate() },
|
||||
onSecondary: { openLibraryForSelected() },
|
||||
onTertiary: { showSettings = true },
|
||||
onUp: { openOptionsForSelected() },
|
||||
isActive: homeOwnsController
|
||||
) { tile, entrance in
|
||||
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight), entrance: entrance)
|
||||
@@ -469,6 +509,14 @@ struct GamepadHomeView: View {
|
||||
glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library",
|
||||
action: { openLibraryForSelected() }))
|
||||
}
|
||||
// Only a saved card has a menu, so the cell appears only where the press does something —
|
||||
// the same honesty rule the Library cell above follows. A direction, not a button, so it
|
||||
// is a plain arrow rather than a `buttonGlyph` (see the settings screen's "Adjust").
|
||||
if case .saved = selected?.id {
|
||||
hints.append(.init(
|
||||
glyph: "arrow.up", text: "Options",
|
||||
action: { openOptionsForSelected() }))
|
||||
}
|
||||
hints.append(.init(
|
||||
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Settings",
|
||||
action: { showSettings = true }))
|
||||
@@ -543,6 +591,60 @@ struct GamepadHomeView: View {
|
||||
/// `HostCardView`-only action never offered on `DiscoveredCardView`. A pinned card opens its
|
||||
/// own shelf: the selection already names which card Y was pressed on, and that card's profile
|
||||
/// is what its launches run with.
|
||||
/// The host menu, built once for all three presentations (the iOS shell layer, the macOS
|
||||
/// sheet, the tvOS cover) so the actions can't drift between them.
|
||||
///
|
||||
/// Edit REPLACES this menu rather than stacking on it — `hostOptionsTarget` is cleared as
|
||||
/// `editTarget` is set — which is the desktop console's `Nav::Replace` and what keeps the
|
||||
/// shell's "depth ≤ 1 by construction" claim true.
|
||||
@ViewBuilder
|
||||
private func hostOptionsView(_ target: HostOptionsTarget, active: Bool) -> some View {
|
||||
let host = target.host
|
||||
GamepadHostOptionsView(
|
||||
host: host,
|
||||
pinnedProfile: target.profile,
|
||||
isOnline: discovery.advertises(host) || store.probedOnline.contains(host.id),
|
||||
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
|
||||
&& !host.wakeMacs.isEmpty,
|
||||
onEdit: {
|
||||
guard !transitioning else { return }
|
||||
hostOptionsTarget = nil
|
||||
editTarget = host
|
||||
},
|
||||
onWake: { wakeOnly(host) },
|
||||
onForgetPairing: { store.forgetIdentity(host) },
|
||||
onRemove: { store.remove(host) },
|
||||
onUnpin: {
|
||||
guard let profile = target.profile else { return }
|
||||
store.setPinned(host.id, profileID: profile.id, pinned: false)
|
||||
},
|
||||
close: { if !transitioning { hostOptionsTarget = nil } },
|
||||
controllerActive: active)
|
||||
}
|
||||
|
||||
/// The add-host form in edit mode. `store.update` writes the record back by id, so the
|
||||
/// fingerprint, MACs, pins and binding the form never shows are preserved.
|
||||
@ViewBuilder
|
||||
private func editHostView(_ host: StoredHost, active: Bool) -> some View {
|
||||
GamepadAddHostView(
|
||||
onAdd: { store.update($0) },
|
||||
close: { if !transitioning { editTarget = nil } },
|
||||
controllerActive: active,
|
||||
editingHost: host)
|
||||
}
|
||||
|
||||
/// UP on a saved tile opens that card's menu. Only SAVED hosts have one: a discovered-but-
|
||||
/// unsaved host is not ours to rename or remove, and the two action tiles have nothing to
|
||||
/// offer — the same `HostOptionsScreen::available` gate the desktop console applies.
|
||||
private func openOptionsForSelected() {
|
||||
guard case .saved(let id, let profileID) = selection,
|
||||
let host = store.hosts.first(where: { $0.id == id })
|
||||
else { return }
|
||||
hostOptionsTarget = HostOptionsTarget(
|
||||
host: host,
|
||||
profile: profileID.flatMap { pid in profiles.profiles.first { $0.id == pid } })
|
||||
}
|
||||
|
||||
private func openLibraryForSelected() {
|
||||
guard libraryEnabled, case .saved(let id, let profileID) = selection,
|
||||
let host = store.hosts.first(where: { $0.id == id })
|
||||
@@ -657,6 +759,12 @@ private struct GamepadHostTile: View {
|
||||
|
||||
private var monogramBadge: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: Self.badgeCorner, style: .continuous)
|
||||
// What the glyph is drawn ON: a filled badge IS the accent, so its mark takes `onAccent`
|
||||
// — the colour picked by the accent's own luminance — exactly as the settings screen's
|
||||
// selected tab pill does. It used to take `fg`, which is chosen against the FIELD, and the
|
||||
// two disagree at both ends of the set: a pale palette put near-black on a deep accent, and
|
||||
// Graphite (accent luma ≈ 0.80) put white on a light grey.
|
||||
let glyph = tile.filled ? ink.onAccent : ink.accent
|
||||
return ZStack {
|
||||
shape.fill(tile.filled
|
||||
? AnyShapeStyle(LinearGradient(
|
||||
@@ -664,7 +772,7 @@ private struct GamepadHostTile: View {
|
||||
startPoint: .top, endPoint: .bottom))
|
||||
: AnyShapeStyle(ink.accent(0.16)))
|
||||
if tile.isConnecting {
|
||||
ProgressView().tint(ink.fg)
|
||||
ProgressView().tint(glyph)
|
||||
} else if let icon = tile.icon {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: Self.iconFont, weight: .semibold))
|
||||
@@ -676,12 +784,12 @@ private struct GamepadHostTile: View {
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: Self.monogramFont, height: Self.monogramFont)
|
||||
.foregroundStyle(tile.filled ? ink.fg : ink.accent)
|
||||
.foregroundStyle(glyph)
|
||||
.accessibilityLabel(tile.osChain ?? "")
|
||||
} else {
|
||||
Text(monogram(tile.title))
|
||||
.font(.geistFixed(Self.monogramFont, .bold))
|
||||
.foregroundStyle(tile.filled ? ink.fg : ink.accent)
|
||||
.foregroundStyle(glyph)
|
||||
}
|
||||
}
|
||||
.frame(width: Self.badgeSide, height: Self.badgeSide)
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
// A saved host's own actions — Wake, Copy link, Edit…, Forget pairing, Remove — reached with UP on
|
||||
// its carousel tile. The console's answer to the overflow menu the touch grid hangs off every host
|
||||
// card (HostCardView's context menu), and the Apple port of `pf-console-ui`'s HostOptionsScreen.
|
||||
//
|
||||
// Until now the gamepad UI could add a host and connect to one, and that was all: a renamed machine
|
||||
// or a host typed in with a fat-fingered address stayed wrong forever, because the only surface
|
||||
// that could edit or remove one was the touch UI. The tile is where a host is, so the tile is where
|
||||
// its actions belong.
|
||||
//
|
||||
// UP is the gesture because the carousel is horizontal — left/right are spoken for and up is free —
|
||||
// and because the desktop console and the Android console already do exactly this, so the three are
|
||||
// learned once. A pinned profile card offers only Unpin: it is a shortcut, not a second host, and
|
||||
// offering to remove the host from it would blur precisely the distinction a pin exists to draw.
|
||||
//
|
||||
// Vocabulary note: this screen says "Forget pairing" and "Remove host" where the desktop console
|
||||
// says one word, "Forget". The console has only the one action; Apple has both (HostCardView calls
|
||||
// them `onForget` = drop the pinned fingerprint and `onRemove` = delete the record), and two
|
||||
// different actions cannot share a name on the surface that offers both. The touch card's words
|
||||
// win over the other consoles' here — a user meets both Apple surfaces, and only one of them is
|
||||
// cross-platform.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
|
||||
/// Which card the menu was opened on. Carries the host BY VALUE for the same reason the screen
|
||||
/// does — the carousel is rebuilt on every discovery pass, and a target that re-resolved itself
|
||||
/// could hand "Remove" a different host than the one the user was looking at.
|
||||
struct HostOptionsTarget: Identifiable {
|
||||
let host: StoredHost
|
||||
/// Non-nil ⇒ a pinned profile card rather than the host's own tile.
|
||||
var profile: StreamProfile?
|
||||
|
||||
/// Keyed on the CARD, not the host: a host and each of its pinned cards open different menus,
|
||||
/// and sharing an id would let one stand in for another mid-transition (the same rule
|
||||
/// `GamepadScreen.library` follows).
|
||||
var id: String { "\(host.id.uuidString)-\(profile?.id ?? "")" }
|
||||
}
|
||||
|
||||
struct GamepadHostOptionsView: View {
|
||||
/// Resolved from the stored palette, NOT from `\.gamepadInk` — this screen publishes that
|
||||
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
private var ink: GamepadInk { .stored(paletteID) }
|
||||
@Environment(\.gamepadMetrics) private var metrics
|
||||
@Environment(\.displayBottomInset) private var displayBottomInset
|
||||
@Environment(\.gamepadHostedInShell) private var hostedInShell
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
/// The host this menu was opened on, BY VALUE. Discovery rewrites the carousel on every
|
||||
/// service pass; holding an index or a live lookup would let the menu retarget itself onto
|
||||
/// whichever host slid into that slot, and "Remove" must never be able to do that.
|
||||
let host: StoredHost
|
||||
/// Non-nil ⇒ opened on a pinned profile card rather than the host's own tile.
|
||||
var pinnedProfile: StreamProfile?
|
||||
/// Whether the host is reachable right now — decides whether Wake is worth offering.
|
||||
var isOnline = false
|
||||
/// Whether waking is possible at all (the setting is on, WoL is available, a MAC is known).
|
||||
var canWake = false
|
||||
let onEdit: () -> Void
|
||||
let onWake: () -> Void
|
||||
/// Drop the pinned fingerprint — the host stays saved, and the next connect re-pairs.
|
||||
let onForgetPairing: () -> Void
|
||||
/// Delete the saved record outright.
|
||||
let onRemove: () -> Void
|
||||
let onUnpin: () -> Void
|
||||
var close: (() -> Void)?
|
||||
var controllerActive = true
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
|
||||
private var compact: Bool { vSizeClass == .compact }
|
||||
#else
|
||||
private let compact = false
|
||||
#endif
|
||||
|
||||
/// Removing is the one action here with no undo, so its row ARMS on the first press and only
|
||||
/// fires on the second. The touch grid removes behind a system confirmation dialog; a console
|
||||
/// is driven by a thumbstick from across a room, which is a good reason to be at least as
|
||||
/// strict as it is, and none at all to be looser.
|
||||
@State private var armed = false
|
||||
@State private var copied = false
|
||||
@State private var focusID: String?
|
||||
|
||||
private enum Action: String {
|
||||
case wake
|
||||
case copyLink
|
||||
case edit
|
||||
case forgetPairing
|
||||
case remove
|
||||
case unpin
|
||||
case cancel
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GamepadMenuList(
|
||||
items: rows,
|
||||
focusID: $focusID,
|
||||
onActivate: { run($0.action) },
|
||||
onBack: { performClose() },
|
||||
isActive: controllerActive
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: metrics.rowMaxWidth)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) {
|
||||
Text(title)
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(ink.fg)
|
||||
.lineLimit(1)
|
||||
if !compact {
|
||||
Text("\(host.address):\(String(host.port))")
|
||||
.font(.geistFixed(metrics.detailFont, .medium))
|
||||
.foregroundStyle(ink.fg(0.55))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background { GamepadTrayBlur(edge: .top) }
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(detail)
|
||||
.font(.geist(metrics.detailFont, relativeTo: .caption))
|
||||
.foregroundStyle(ink.fg(0.55))
|
||||
.lineLimit(2, reservesSpace: true)
|
||||
.animation(.smooth(duration: 0.2), value: focusID)
|
||||
GamepadHintBar(hints: hints)
|
||||
}
|
||||
.padding(.leading, compact ? 12 : 18)
|
||||
.padding(.trailing, 22)
|
||||
.padding(
|
||||
.bottom,
|
||||
gamepadLegendBottomPadding(
|
||||
compact ? 12 : 18, tier: metrics.tier, displayBottom: displayBottomInset))
|
||||
.padding(.top, compact ? 6 : 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background { GamepadTrayBlur(edge: .bottom) }
|
||||
}
|
||||
.background {
|
||||
if !hostedInShell { GamepadFormBackground() }
|
||||
}
|
||||
.gamepadPaletteInk()
|
||||
// Moving the focus off the armed Remove row disarms it: an arming that outlives the row it
|
||||
// was made on is a trap, and the thumb that wandered away is exactly the hesitation the
|
||||
// two-press rule exists to catch.
|
||||
.onChange(of: focusID) { _, id in
|
||||
if id != Action.remove.rawValue { armed = false }
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.background {
|
||||
Button("Cancel") { performClose() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 0, height: 0)
|
||||
.opacity(0)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
pinnedProfile.map { "\(host.displayName) · \($0.name)" } ?? host.displayName
|
||||
}
|
||||
|
||||
// MARK: - Rows
|
||||
|
||||
private struct Row: Identifiable {
|
||||
let action: Action
|
||||
let label: String
|
||||
var icon: String
|
||||
var isDestructive = false
|
||||
var id: String { action.rawValue }
|
||||
}
|
||||
|
||||
private var rows: [Row] {
|
||||
// A pinned card is a shortcut, not a host: everything host-level is deliberately absent.
|
||||
if pinnedProfile != nil {
|
||||
return [
|
||||
Row(action: .unpin, label: "Unpin card", icon: "pin.slash"),
|
||||
Row(action: .copyLink, label: copied ? "Copied" : "Copy link", icon: "link"),
|
||||
Row(action: .cancel, label: "Cancel", icon: "xmark"),
|
||||
]
|
||||
}
|
||||
var list: [Row] = []
|
||||
// Waking a host that is already answering would just sit there counting seconds.
|
||||
if canWake, !isOnline {
|
||||
list.append(Row(action: .wake, label: "Wake host", icon: "power"))
|
||||
}
|
||||
list.append(Row(action: .copyLink, label: copied ? "Copied" : "Copy link", icon: "link"))
|
||||
list.append(Row(action: .edit, label: "Edit\u{2026}", icon: "pencil"))
|
||||
// Only a paired host has a pairing to drop.
|
||||
if host.pinnedSHA256 != nil {
|
||||
list.append(Row(
|
||||
action: .forgetPairing, label: "Forget pairing", icon: "lock.open"))
|
||||
}
|
||||
list.append(Row(
|
||||
action: .remove,
|
||||
label: armed ? "Remove host \u{2014} press again" : "Remove host",
|
||||
icon: "trash", isDestructive: true))
|
||||
list.append(Row(action: .cancel, label: "Cancel", icon: "xmark"))
|
||||
return list
|
||||
}
|
||||
|
||||
/// The explainer under the list — the same band the settings screen uses, and the only place a
|
||||
/// destructive action can say what it will actually do before it is pressed.
|
||||
private var detail: String {
|
||||
switch rows.first(where: { $0.id == focusID })?.action {
|
||||
case .wake:
|
||||
return "Send a Wake-on-LAN packet and wait for this host to answer."
|
||||
case .copyLink:
|
||||
return "Copy a punktfunk:// link to this host — paste it anywhere to connect."
|
||||
case .edit:
|
||||
return "Rename this host or change its address. Pairing and pinned cards are kept."
|
||||
case .forgetPairing:
|
||||
return "Drop the stored fingerprint. The host stays saved and the next connect "
|
||||
+ "pairs again."
|
||||
case .remove:
|
||||
return armed
|
||||
? "Press again to remove — this cannot be undone."
|
||||
: "Delete this host, its pairing and its pinned cards from this device."
|
||||
case .unpin:
|
||||
return "Remove this profile's card. The profile itself and the host are untouched."
|
||||
case .cancel, .none:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
private var hints: [GamepadHint] {
|
||||
[
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select",
|
||||
action: { if let id = focusID, let row = rows.first(where: { $0.id == id }) {
|
||||
run(row.action)
|
||||
} }),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back",
|
||||
action: { performClose() }),
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func run(_ action: Action) {
|
||||
switch action {
|
||||
case .wake:
|
||||
onWake()
|
||||
performClose()
|
||||
case .copyLink:
|
||||
LinkClipboard.copy(
|
||||
DeepLink.forHost(host, profile: pinnedProfile?.id).urlString)
|
||||
// No toast machinery on this surface — the row says so itself, which is the same
|
||||
// acknowledgement in the place the user is already looking.
|
||||
withAnimation(.smooth(duration: 0.2)) { copied = true }
|
||||
case .edit:
|
||||
onEdit()
|
||||
case .forgetPairing:
|
||||
onForgetPairing()
|
||||
performClose()
|
||||
case .remove:
|
||||
guard armed else {
|
||||
withAnimation(.smooth(duration: 0.2)) { armed = true }
|
||||
return
|
||||
}
|
||||
onRemove()
|
||||
performClose()
|
||||
case .unpin:
|
||||
onUnpin()
|
||||
performClose()
|
||||
case .cancel:
|
||||
performClose()
|
||||
}
|
||||
}
|
||||
|
||||
private func performClose() {
|
||||
if let close { close() } else { dismiss() }
|
||||
}
|
||||
|
||||
// MARK: - Row rendering
|
||||
|
||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||
let m = metrics
|
||||
// The destructive row wears the warning colour only once ARMED: red on a row that still
|
||||
// needs a second press reads as "this already happened".
|
||||
let danger = row.isDestructive && armed
|
||||
return HStack(spacing: 14) {
|
||||
Image(systemName: row.icon)
|
||||
.font(.system(size: m.iconFont))
|
||||
.foregroundStyle(
|
||||
danger ? GamepadInk.warningRed : (focused ? ink.accent : ink.fg(0.55)))
|
||||
.frame(width: m.iconWidth)
|
||||
Text(row.label)
|
||||
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
|
||||
.foregroundStyle(danger ? GamepadInk.warningRed : ink.fg)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 12)
|
||||
}
|
||||
.padding(.horizontal, m.rowHPad)
|
||||
.padding(.vertical, m.rowVPad)
|
||||
.consoleGlass(
|
||||
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
|
||||
tint: focused ? (danger ? GamepadInk.warningRed.opacity(0.3) : ink.accent(0.30)) : nil,
|
||||
interactive: focused)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
|
||||
.strokeBorder(
|
||||
danger ? GamepadInk.warningRed.opacity(0.7) : ink.fg(focused ? 0.28 : 0.06),
|
||||
lineWidth: 1)
|
||||
}
|
||||
.scaleEffect(focused ? 1.0 : 0.98)
|
||||
.animation(.smooth(duration: 0.18), value: focused)
|
||||
.animation(.smooth(duration: 0.18), value: armed)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -67,9 +67,32 @@ struct GamepadInk: Equatable, Sendable {
|
||||
/// The shipped dark look — what a preview or a test composition gets.
|
||||
static let dark = GamepadInk.of(GamepadPalette.named("violet"))
|
||||
|
||||
/// The ink for a stored `ui_palette` id, resolved WITHOUT the environment.
|
||||
///
|
||||
/// For the screens that publish their own ink with `gamepadPaletteInk()`. A view's
|
||||
/// `@Environment` resolves against its PARENT — the modifier a screen applies to its own body
|
||||
/// covers its descendants, never the body's own `ink.…` references — so such a screen reads
|
||||
/// whatever was published ABOVE it. Nested inside another gamepad screen (the iOS shell's
|
||||
/// layers) that happens to be right; presented as a cover or a sheet (tvOS, macOS) there is
|
||||
/// nothing above it and it gets the bare dark default. That is precisely how a pale palette
|
||||
/// came out with a WHITE title, white row labels and a violet focus wash on an Apple TV, while
|
||||
/// the child views in the same screen — the hint bar, the host tiles, the glass — were
|
||||
/// correctly dark-on-pale.
|
||||
///
|
||||
/// Declare it beside an `@AppStorage(DefaultsKey.uiPalette)`, which is what re-renders the
|
||||
/// screen when the setting changes (`GamepadInkModifier` reads the same key).
|
||||
static func stored(_ paletteID: String) -> GamepadInk {
|
||||
.of(GamepadPalette.named(paletteID))
|
||||
}
|
||||
|
||||
/// The online pip — deliberately NOT palette-derived: a status colour must not change
|
||||
/// meaning with the wallpaper (the console's rule; this is its `ONLINE_GREEN` verbatim).
|
||||
static let onlineGreen = Color(red: 0.20, green: 0.84, blue: 0.29)
|
||||
/// An armed destructive action (the host menu's Remove). Palette-independent for exactly the
|
||||
/// same reason as the pip above, and the more strongly so: the one colour on this UI that
|
||||
/// means "this does not come back" cannot be allowed to drift toward the wallpaper on a warm
|
||||
/// palette, or read as a highlight on a red one.
|
||||
static let warningRed = Color(red: 0.94, green: 0.28, blue: 0.26)
|
||||
}
|
||||
|
||||
private struct GamepadInkKey: EnvironmentKey {
|
||||
|
||||
@@ -10,7 +10,10 @@ import SwiftUI
|
||||
#if os(iOS)
|
||||
|
||||
struct GamepadLibraryScreen: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
/// Resolved from the stored palette, NOT from `\.gamepadInk` — this screen publishes that
|
||||
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
private var ink: GamepadInk { .stored(paletteID) }
|
||||
@ObservedObject var store: HostStore
|
||||
let target: LibraryTarget
|
||||
let onLaunch: (String) -> Void
|
||||
|
||||
@@ -21,6 +21,8 @@ import SwiftUI
|
||||
enum GamepadScreen: Identifiable {
|
||||
case settings
|
||||
case addHost
|
||||
case hostOptions(HostOptionsTarget)
|
||||
case editHost(StoredHost)
|
||||
case pair(StoredHost)
|
||||
case library(LibraryTarget)
|
||||
|
||||
@@ -28,6 +30,10 @@ enum GamepadScreen: Identifiable {
|
||||
switch self {
|
||||
case .settings: return "settings"
|
||||
case .addHost: return "addHost"
|
||||
// Keyed on the CARD (host + pinned profile), for the same reason the library is keyed on
|
||||
// the shelf — see `HostOptionsTarget.id`.
|
||||
case .hostOptions(let target): return "hostOptions-\(target.id)"
|
||||
case .editHost(let host): return "editHost-\(host.id.uuidString)"
|
||||
case .pair(let host): return "pair-\(host.id.uuidString)"
|
||||
// Keyed on the SHELF, not the host: a host and each of its pinned cards open different
|
||||
// libraries, and sharing an id would let one stand in for another mid-transition.
|
||||
@@ -39,7 +45,7 @@ enum GamepadScreen: Identifiable {
|
||||
/// (`Bg::Form` in the console); the library keeps the launcher's full aurora.
|
||||
var isForm: Bool {
|
||||
switch self {
|
||||
case .settings, .addHost, .pair: return true
|
||||
case .settings, .addHost, .hostOptions, .editHost, .pair: return true
|
||||
case .library: return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,10 @@ import SwiftUI
|
||||
import GameController
|
||||
|
||||
struct LibraryCoverflowView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
/// Resolved from the stored palette, NOT from `\.gamepadInk` — this screen publishes that
|
||||
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
private var ink: GamepadInk { .stored(paletteID) }
|
||||
let games: [GameEntry]
|
||||
let artLoader: LibraryArtLoader?
|
||||
var onLaunch: ((String) -> Void)?
|
||||
|
||||
@@ -94,6 +94,9 @@ struct LibraryView: View {
|
||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
|
||||
mode: gamepadUIMode)
|
||||
}
|
||||
/// True when the iOS shell already draws one persistent field behind its layers — mounting a
|
||||
/// second would double the mesh (the same rule the coverflow and the settings screen follow).
|
||||
@Environment(\.gamepadHostedInShell) private var hostedInShell
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
@@ -150,12 +153,13 @@ struct LibraryView: View {
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
if loading && games.isEmpty {
|
||||
ProgressView("Loading library…")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
consoleField(
|
||||
ProgressView("Loading library…")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity))
|
||||
} else if let errorText, games.isEmpty {
|
||||
errorState(errorText)
|
||||
consoleField(errorState(errorText))
|
||||
} else if games.isEmpty {
|
||||
emptyState
|
||||
consoleField(emptyState)
|
||||
} else {
|
||||
if gamepadUIActive {
|
||||
LibraryCoverflowView(
|
||||
@@ -168,6 +172,24 @@ struct LibraryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The console field behind the three states that are NOT the coverflow — loading, error,
|
||||
/// empty. The coverflow mounts its own backdrop; these mounted nothing, so wherever this view
|
||||
/// is a COVER over the launcher (tvOS, macOS) they drew straight onto it: the spinner and its
|
||||
/// label sat on the launcher's own aurora with the host tiles still showing through. The same
|
||||
/// field as the coverflow's (not the calmed form one), so nothing shifts under the content when
|
||||
/// the titles land and the coverflow takes over.
|
||||
///
|
||||
/// Only in gamepad mode: the plain grid's states belong on the system background, as before.
|
||||
@ViewBuilder private func consoleField(_ view: some View) -> some View {
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
view.background {
|
||||
if gamepadUIActive, !hostedInShell { GamepadScreenBackground() }
|
||||
}
|
||||
#else
|
||||
view
|
||||
#endif
|
||||
}
|
||||
|
||||
private var grid: some View {
|
||||
// Design D4: launcher entries get their own section above the titles, never interleaved.
|
||||
// Both headers appear only when both groups exist, so a library without launcher entries
|
||||
|
||||
@@ -244,7 +244,8 @@ private struct ShotGamepadHome: View {
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: .constant(nil), pairingTarget: .constant(nil),
|
||||
onPaired: { _, _ in }, waker: waker,
|
||||
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
|
||||
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in },
|
||||
wakeOnly: { _ in })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,7 +304,8 @@ private struct ShotConnect: View {
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: .constant(nil), pairingTarget: .constant(nil),
|
||||
onPaired: { _, _ in }, waker: waker,
|
||||
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
|
||||
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in },
|
||||
wakeOnly: { _ in })
|
||||
} else {
|
||||
ShotHome()
|
||||
}
|
||||
|
||||
@@ -20,6 +20,18 @@ import SwiftUI
|
||||
/// instrument: any visible overlay forces the metal layer through the compositor, which costs a
|
||||
/// refresh period on the vsync-latched platforms — this is how to measure with it off.
|
||||
private let statsLog = Logger(subsystem: "io.unom.punktfunk", category: "stats")
|
||||
/// Mirror the 1 Hz vitals line to STDOUT as well as the unified log.
|
||||
///
|
||||
/// Exists for **tvOS, where the unified log is unreachable**: `log stream --device` is gone from
|
||||
/// modern macOS, `log collect --device-name` needs root and then fails "Device not configured"
|
||||
/// (an Apple TV has no USB to fall back to), and libimobiledevice pairs against a different
|
||||
/// database than Xcode. Stdout, however, IS bridged — `xcrun devicectl device process launch
|
||||
/// --console -e '{"PUNKTFUNK_STATS_STDOUT":"1"}' io.unom.punktfunk` streams these lines straight
|
||||
/// to the Mac. That is the only way to read a session's numbers with the **stats overlay OFF**,
|
||||
/// which matters because the overlay is itself a composited layer over the Metal one — i.e. a
|
||||
/// plausible cause of the very present-floor inflation the overlay is used to measure.
|
||||
/// Env-gated: no cost, and no stdout noise, unless someone is deliberately measuring.
|
||||
private let statsToStdout = ProcessInfo.processInfo.environment["PUNKTFUNK_STATS_STDOUT"] == "1"
|
||||
|
||||
/// Pump-thread-side frame counters; a 1 Hz main-actor timer drains them into @Published
|
||||
/// values. NSLock instead of an actor — the writer is the (non-async) pump thread.
|
||||
@@ -137,6 +149,25 @@ final class SessionModel: ObservableObject {
|
||||
/// and under stage-1.
|
||||
@Published var osFloorP50Ms = 0.0
|
||||
@Published var osFloorValid = false
|
||||
/// The deadline link's `preferredFrameLatency` ASK beside its property READBACK (see
|
||||
/// `PresentLinkInfo` — it exists because tvOS has no reachable log). ⚠ The readback is NOT
|
||||
/// a grant: it is a plain float property, so it echoes whatever was stored unless the
|
||||
/// system clamps the setter. readback ≠ ask ⇒ a visible clamp (the one signal the API can
|
||||
/// give); readback == ask proves nothing — `osFloorP50Ms` (the measured vend lead) is the
|
||||
/// truth-teller (field 2026-08-13: readback 1.00 beside a 32.5 ms floor).
|
||||
@Published var linkLatencyAskFrames: Float = 0
|
||||
@Published var linkLatencyFrames: Float = 0
|
||||
@Published var linkRangeMinHz: Float = 0
|
||||
@Published var linkRangeMaxHz: Float = 0
|
||||
@Published var linkDrawables = 0
|
||||
@Published var linkInfoValid = false
|
||||
/// Impossible samples the HOST-ANCHORED meters (host+network, end-to-end) refused this
|
||||
/// second (`LatencyMeter.drainTrimmed`). Nonzero means the clock offset is lying and every
|
||||
/// host-anchored p50/p95 this window is a TRUNCATED distribution — the HUD marks the window
|
||||
/// suspect instead of letting a trimmed tail pose as a healthy small number (the field
|
||||
/// "e2e 0–3 ms" reading, 2026-08-13). Client-local stages can't go negative, so they carry
|
||||
/// no such term.
|
||||
@Published var skewTrimPerS = 0
|
||||
/// The AUDIO plane's latency, from the playback ring (`SessionAudio.Stats`): how much decoded
|
||||
/// audio is queued ahead of the speaker, and where that PUTS it relative to the picture
|
||||
/// (positive = audio behind). `audioValid` is false until playback runs.
|
||||
@@ -683,6 +714,10 @@ final class SessionModel: ObservableObject {
|
||||
displayValid = false
|
||||
clientQueueValid = false
|
||||
osFloorValid = false
|
||||
linkInfoValid = false
|
||||
// Drop the previous session's grant too — the shared box outlives the session, and a new
|
||||
// link may never come up (a non-deadline rung has none at all).
|
||||
PresentLinkInfo.shared.clear()
|
||||
audioValid = false
|
||||
lostFrames = 0
|
||||
lostPct = 0
|
||||
@@ -904,6 +939,10 @@ final class SessionModel: ObservableObject {
|
||||
} else {
|
||||
self.endToEndValid = false
|
||||
}
|
||||
// Drained even when the stats drains came back empty — with a badly wrong offset
|
||||
// an entire window is refused and only this counter still tells the story.
|
||||
self.skewTrimPerS =
|
||||
self.latency.drainTrimmed() + self.endToEnd.drainTrimmed()
|
||||
if let d = self.decodeStage.drain() {
|
||||
self.decodeP50Ms = d.p50Ms
|
||||
self.decodeValid = true
|
||||
@@ -923,6 +962,18 @@ final class SessionModel: ObservableObject {
|
||||
} else {
|
||||
self.osFloorValid = false
|
||||
}
|
||||
// The display link's latency ask + property readback (deadline rung only) — a
|
||||
// LEVEL, not a window, so it is read rather than drained.
|
||||
if let l = PresentLinkInfo.shared.snapshot() {
|
||||
self.linkLatencyAskFrames = l.ask
|
||||
self.linkLatencyFrames = l.latency
|
||||
self.linkRangeMinHz = l.rangeMin
|
||||
self.linkRangeMaxHz = l.rangeMax
|
||||
self.linkDrawables = l.drawables
|
||||
self.linkInfoValid = true
|
||||
} else {
|
||||
self.linkInfoValid = false
|
||||
}
|
||||
if let q = self.clientQueue.drain() {
|
||||
self.clientQueueP50Ms = q.p50Ms
|
||||
self.clientQueueValid = true
|
||||
@@ -951,6 +1002,14 @@ final class SessionModel: ObservableObject {
|
||||
// Swift Int is 64-bit → %lld, NOT %d (which is a 32-bit C int); macOS 26's
|
||||
// strict String(format:) validator rejects the %d/Int mismatch and drops
|
||||
// the whole line (a cascade error that also mis-blames the float args).
|
||||
//
|
||||
// ⚠ Every invalid-field fallback below MUST be a typed `-1.0` (or a
|
||||
// `Double(...)`-wrapped value), never a bare `-1`: in this variadic
|
||||
// `CVarArg` context the ternary does NOT unify to Double — the untyped
|
||||
// literal goes in as Int, and `%f` then reads Int64(-1)'s all-ones bit
|
||||
// pattern, which IS a quiet NaN. Field 2026-08-13 (tvOS, stage-1, the
|
||||
// first session ever to have invalid fields while frames flowed): every
|
||||
// fallback printed `nan`. Latent since the line was added.
|
||||
format: "fps=%lld presents=%lld e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
|
||||
+ "decode_p50=%.1f display_p50=%.1f lost=%lld "
|
||||
+ "floor_p50=%.1f display_adj=%.1f e2e_adj=%.1f queue_p50=%.1f "
|
||||
@@ -958,22 +1017,35 @@ final class SessionModel: ObservableObject {
|
||||
// In the log as well as on the HUD because the overlay is only up when
|
||||
// someone thought to turn it on, and the reports that need these
|
||||
// numbers arrive after the fact.
|
||||
+ "audio_buffer=%lld audio_av_offset=%lld",
|
||||
+ "audio_buffer=%lld audio_av_offset=%lld "
|
||||
// The deadline link's latency ask + property readback (both -1 on
|
||||
// non-deadline rungs) — appended so the PUNKTFUNK_FRAME_LATENCY
|
||||
// ladder is readable over the stdout channel with the HUD off,
|
||||
// which is the only honest way to run it on a tvOS device.
|
||||
+ "link_ask=%.2f link_readback=%.2f "
|
||||
// Impossible samples the host-anchored meters refused this window:
|
||||
// nonzero ⇒ the clock offset is lying and e2e/hostnet above are
|
||||
// truncated distributions — disregard their p50/p95.
|
||||
+ "skew_trim=%lld",
|
||||
frames,
|
||||
displayWindow?.count ?? 0,
|
||||
self.endToEndValid ? self.endToEndP50Ms : -1,
|
||||
self.endToEndValid ? self.endToEndP95Ms : -1,
|
||||
self.hostNetworkValid ? self.hostNetworkP50Ms : -1,
|
||||
self.decodeValid ? self.decodeP50Ms : -1,
|
||||
self.displayValid ? self.displayP50Ms : -1,
|
||||
self.endToEndValid ? self.endToEndP50Ms : -1.0,
|
||||
self.endToEndValid ? self.endToEndP95Ms : -1.0,
|
||||
self.hostNetworkValid ? self.hostNetworkP50Ms : -1.0,
|
||||
self.decodeValid ? self.decodeP50Ms : -1.0,
|
||||
self.displayValid ? self.displayP50Ms : -1.0,
|
||||
lost,
|
||||
self.osFloorValid ? self.osFloorP50Ms : -1,
|
||||
self.displayValid ? self.displayAdjP50Ms : -1,
|
||||
self.endToEndValid ? self.endToEndAdjP50Ms : -1,
|
||||
self.clientQueueValid ? self.clientQueueP50Ms : -1,
|
||||
self.osFloorValid ? self.osFloorP50Ms : -1.0,
|
||||
self.displayValid ? self.displayAdjP50Ms : -1.0,
|
||||
self.endToEndValid ? self.endToEndAdjP50Ms : -1.0,
|
||||
self.clientQueueValid ? self.clientQueueP50Ms : -1.0,
|
||||
self.audioValid ? self.audioBufferMs : -1,
|
||||
self.audioValid ? self.audioAvOffsetMs : 0)
|
||||
self.audioValid ? self.audioAvOffsetMs : 0,
|
||||
self.linkInfoValid ? Double(self.linkLatencyAskFrames) : -1.0,
|
||||
self.linkInfoValid ? Double(self.linkLatencyFrames) : -1.0,
|
||||
self.skewTrimPerS)
|
||||
statsLog.info("\(line, privacy: .public)")
|
||||
if statsToStdout { print("pf.stats \(line)") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,29 @@ struct StreamHUDView: View {
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
// The deadline link's frame-latency ASK beside its property READBACK. ⚠ The
|
||||
// readback is NOT a grant — the property echoes whatever we stored (field
|
||||
// 2026-08-13: 1.00 beside a 32.5 ms `os present` floor). The line earns its
|
||||
// place because a readback that DIFFERS from the ask is the one clamp signal
|
||||
// the API can give, and on tvOS the screen is the only place to read either
|
||||
// (no log is reachable on an Apple TV; see PresentLinkInfo).
|
||||
if model.linkInfoValid {
|
||||
Text("link latency ask \(model.linkLatencyAskFrames, specifier: "%.2f") readback \(model.linkLatencyFrames, specifier: "%.2f") · range \(model.linkRangeMinHz, specifier: "%.0f")-\(model.linkRangeMaxHz, specifier: "%.0f") Hz · drawables \(model.linkDrawables)")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
// The clock-offset tripwire: host-anchored meters refused samples as
|
||||
// impossible (≤ 0 after offset correction) this second. When this shows,
|
||||
// e2e and host+network above are TRUNCATED distributions — a wrong skew
|
||||
// offset shifted them and the impossible half was trimmed — so their
|
||||
// p50/p95 flatter the stream (the field "e2e 0–3 ms" reading). Orange on
|
||||
// purpose: every other stat here stays legible-quiet, but a number that
|
||||
// has stopped meaning anything must not.
|
||||
if model.skewTrimPerS > 0 {
|
||||
Text("clock offset suspect — \(model.skewTrimPerS)/s impossible samples trimmed; e2e & host+network unreliable")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
// Client-queue wait (reassembly receipt → decode pull, ABI v9 split): ~0 on
|
||||
// a healthy stream and hidden as noise; shown from 2 ms — a persistent value
|
||||
// is a client-side standing backlog that pre-split builds displayed as
|
||||
|
||||
@@ -28,6 +28,10 @@ struct AboutView: View {
|
||||
|
||||
#if !os(tvOS)
|
||||
@State private var showAcknowledgements = false
|
||||
/// The in-session controls. They used to announce themselves in a 6-second banner at the start
|
||||
/// of every stream; that banner is gone, so this page is where they live now — including for
|
||||
/// touch users on a Mac, who saw it too.
|
||||
@State private var showShortcuts = false
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
@@ -44,6 +48,9 @@ struct AboutView: View {
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
Section {
|
||||
shortcutsRow
|
||||
}
|
||||
Section {
|
||||
linkRow("Documentation", systemImage: "book", url: Destination.docs)
|
||||
linkRow("Community", systemImage: "bubble.left.and.bubble.right",
|
||||
@@ -63,6 +70,21 @@ struct AboutView: View {
|
||||
// A SHEET, not a push — on iPad the settings detail column is deliberately not a
|
||||
// NavigationStack (an inner one doubles the title bar), so a NavigationLink from here
|
||||
// pushed into a context with no back button and stranded the licenses on screen.
|
||||
// A sheet for the same reason Acknowledgements is one — see that modifier's note on the
|
||||
// iPad detail column not being a NavigationStack.
|
||||
.sheet(isPresented: $showShortcuts) {
|
||||
NavigationStack {
|
||||
ShortcutsView(micAvailable: ShortcutsCatalog.micPlausible)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { showShortcuts = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(width: 560, height: 460)
|
||||
#endif
|
||||
}
|
||||
.sheet(isPresented: $showAcknowledgements) {
|
||||
NavigationStack {
|
||||
AcknowledgementsView()
|
||||
@@ -135,6 +157,24 @@ struct AboutView: View {
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
|
||||
private var shortcutsRow: some View {
|
||||
Button {
|
||||
showShortcuts = true
|
||||
} label: {
|
||||
HStack {
|
||||
Label("Shortcuts", systemImage: "command")
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
|
||||
private var acknowledgementsRow: some View {
|
||||
Button {
|
||||
showAcknowledgements = true
|
||||
@@ -168,6 +208,11 @@ struct AboutView: View {
|
||||
tvAddress("Community", Destination.community)
|
||||
tvAddress("Source code", Destination.source)
|
||||
}
|
||||
// Both push here: this page really is inside a navigation stack on tvOS, which is
|
||||
// the case the sheets above exist to work around elsewhere.
|
||||
NavigationLink("Shortcuts") {
|
||||
ShortcutsView(micAvailable: false) // tvOS has no app-accessible mic
|
||||
}
|
||||
NavigationLink("Acknowledgements") { AcknowledgementsView() }
|
||||
Text("Punktfunk's source is open under MIT or Apache-2.0.")
|
||||
.font(.geist(20, relativeTo: .caption))
|
||||
@@ -219,21 +264,39 @@ struct AppIconView: View {
|
||||
var body: some View {
|
||||
Group {
|
||||
if let icon = Self.bundleIcon {
|
||||
icon.image
|
||||
.resizable()
|
||||
.interpolation(.high)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
// iOS ships the icon UNMASKED — the springboard applies the rounded shape at
|
||||
// draw time, so used raw it is a hard-cornered square. macOS bakes its own
|
||||
// shape (and margins) into the image, and clipping that would cut into it.
|
||||
.clipShape(RoundedRectangle(
|
||||
cornerRadius: icon.needsMask ? side * Self.iOSCornerRatio : 0,
|
||||
style: .continuous))
|
||||
// The mask is applied ONLY where it is wanted. A `cornerRadius: 0` RoundedRectangle
|
||||
// is not a no-op — it still clips to the layout frame, which crops any art whose
|
||||
// aspect ratio isn't the frame's (the TV's 400x240 icon lost its ends to it).
|
||||
// iOS ships the icon UNMASKED — the springboard applies the rounded shape at draw
|
||||
// time, so used raw it is a hard-cornered square. macOS bakes its own shape (and
|
||||
// margins) into the image, and clipping that would cut into it.
|
||||
if icon.needsMask {
|
||||
icon.image
|
||||
.resizable()
|
||||
.interpolation(.high)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(
|
||||
cornerRadius: side * Self.iOSCornerRatio, style: .continuous))
|
||||
} else {
|
||||
icon.image
|
||||
.resizable()
|
||||
.interpolation(.high)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
}
|
||||
} else {
|
||||
monogram
|
||||
}
|
||||
}
|
||||
// tvOS's icon is a 400×240 rectangle, not a squircle — framing it square would letterbox
|
||||
// it inside a box two thirds empty. `side` means HEIGHT there, and the width follows the
|
||||
// real 5:3 art. A MAX frame rather than a fixed one: with a fixed width the image cannot
|
||||
// shrink when its row is tight, so it overflows and is clipped by whatever is above it
|
||||
// instead — `.fit` inside a max frame gives back the whole icon, just smaller.
|
||||
#if os(tvOS)
|
||||
.frame(maxWidth: side * (400.0 / 240.0), maxHeight: side)
|
||||
#else
|
||||
.frame(width: side, height: side)
|
||||
#endif
|
||||
.accessibilityHidden(true) // the app's name is the next line
|
||||
}
|
||||
|
||||
@@ -267,7 +330,14 @@ struct AppIconView: View {
|
||||
else { return nil }
|
||||
return (Image(uiImage: image), true)
|
||||
#else
|
||||
return nil // tvOS: layered icons have no single image to load
|
||||
// tvOS ships the icon as a parallax image STACK (Back/Circle1/Circle2/Front), which has
|
||||
// no single image to load — which is why this used to return nil and every About page on
|
||||
// the TV drew the "P" monogram instead of the app's own mark. `AboutAppIcon` is those
|
||||
// four layers flattened into one asset, generated from the SAME art the stack uses so it
|
||||
// cannot drift into being a second, subtly different icon. Already masked and composited,
|
||||
// so it needs no rounding of ours.
|
||||
guard let image = UIImage(named: "AboutAppIcon") else { return nil }
|
||||
return (Image(uiImage: image), false)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,14 +42,22 @@ enum GpSettingsTab: String, CaseIterable, Hashable {
|
||||
case controller = "Controller"
|
||||
case interface = "Interface"
|
||||
case profiles = "Profiles"
|
||||
/// Trailing, like Profiles: both are built from something other than the settings store, and
|
||||
/// About is where the strip ends because it is the one section that changes nothing.
|
||||
case about = "About"
|
||||
}
|
||||
|
||||
struct GamepadSettingsView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
/// Resolved from `paletteID` below, NOT from `\.gamepadInk` — this screen publishes that value
|
||||
/// itself and so sits above its own copy (see `GamepadInk.stored`). Reading the environment
|
||||
/// here is what left the title, the tab pills and every row label white-on-pale on tvOS.
|
||||
private var ink: GamepadInk { .stored(paletteID) }
|
||||
@Environment(\.gamepadMetrics) private var metrics
|
||||
@Environment(\.displayBottomInset) private var displayBottomInset
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.gamepadHostedInShell) private var hostedInShell
|
||||
/// The About section's link rows (never used on tvOS, which has no browser).
|
||||
@Environment(\.openURL) private var openURL
|
||||
/// The saved-host store — the pin picker writes `setPinned` through it and the profile rows
|
||||
/// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen
|
||||
/// itself (ContentView owns the instance).
|
||||
@@ -61,6 +69,9 @@ struct GamepadSettingsView: View {
|
||||
/// console's input drop) and while the connect takeover is up; a system presentation never
|
||||
/// needs the gate and keeps the default.
|
||||
var controllerActive = true
|
||||
/// Whether this device has a microphone at all — passed through to the About page's shortcuts
|
||||
/// reference, which must not list a mute key on a device that can't mute anything.
|
||||
var micAvailable = true
|
||||
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
||||
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
||||
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
||||
@@ -132,6 +143,14 @@ struct GamepadSettingsView: View {
|
||||
/// The direction of the last value step (+1 right/forward, -1 left) — picks which edge the
|
||||
/// changed value slides in from, so the animation follows the user's motion.
|
||||
@State private var lastAdjustDelta = 1
|
||||
/// A reading surface opened from the About tab, replacing the row list the way the pin picker
|
||||
/// does. Depth is 1: neither page opens anything further.
|
||||
private enum AboutPage: Equatable {
|
||||
case shortcuts
|
||||
case licenses
|
||||
}
|
||||
|
||||
@State private var aboutPage: AboutPage?
|
||||
|
||||
var body: some View {
|
||||
GamepadMenuList(
|
||||
@@ -157,9 +176,9 @@ struct GamepadSettingsView: View {
|
||||
.foregroundStyle(ink.fg)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
// The picker is one layer deeper — its rows aren't sections of anything, so the
|
||||
// strip would be a control that does nothing while it's up.
|
||||
if pinTarget == nil { tabStrip }
|
||||
// The picker and the About reading pages are one layer deeper — their rows aren't
|
||||
// sections of anything, so the strip would be a control that does nothing.
|
||||
if pinTarget == nil, aboutPage == nil { tabStrip }
|
||||
}
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
@@ -326,16 +345,62 @@ struct GamepadSettingsView: View {
|
||||
if let close { close() } else { dismiss() }
|
||||
}
|
||||
|
||||
/// Where the product actually lives — kept together so the three can be checked against the
|
||||
/// README in one glance (the touch `AboutView` holds the same three).
|
||||
private enum Destination {
|
||||
static let docs = URL(string: "https://docs.punktfunk.unom.io")!
|
||||
static let community = URL(string: "https://discord.gg/kaPNvzMuGU")!
|
||||
static let source = URL(string: "https://git.unom.io/unom/punktfunk")!
|
||||
}
|
||||
|
||||
/// "Version 0.29.0 (100000)" — the build number only when it says something the version does
|
||||
/// not. Mirrors `AboutView.versionLine`; a bug report is worth more with it.
|
||||
private static var versionLine: String {
|
||||
let info = Bundle.main.infoDictionary
|
||||
let short = info?["CFBundleShortVersionString"] as? String ?? "—"
|
||||
let build = info?["CFBundleVersion"] as? String
|
||||
guard let build, !build.isEmpty, build != short else { return "Version \(short)" }
|
||||
return "Version \(short) (\(build))"
|
||||
}
|
||||
|
||||
/// "Settings", or "Pin “Work”" while the pin picker is up — the title is what says which
|
||||
/// layer the row list currently is.
|
||||
private var title: String {
|
||||
pinTarget.map { "Pin “\($0.name)”" } ?? "Settings"
|
||||
if let profile = pinTarget { return "Pin “\(profile.name)”" }
|
||||
switch aboutPage {
|
||||
case .shortcuts: return "Shortcuts"
|
||||
case .licenses: return "Acknowledgements"
|
||||
case nil: return "Settings"
|
||||
}
|
||||
}
|
||||
|
||||
/// The legend follows the layer: value-editing hints on the settings rows, pin/unpin on the
|
||||
/// picker — where B reads "Back" (it peels to the settings rows, GamepadAddHostView's "one
|
||||
/// layer" rule), and a hostless picker has nothing to pin, so only Back remains.
|
||||
private var hints: [GamepadHint] {
|
||||
// A reading page is scrolled, not operated: offering A would be the same lie a dimmed row
|
||||
// used to tell. Only Back remains.
|
||||
if aboutPage != nil {
|
||||
return [.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back",
|
||||
action: { back() })]
|
||||
}
|
||||
// The About rows open things rather than change them, so A reads "Open" and there is no
|
||||
// Adjust cell — left/right genuinely does nothing there.
|
||||
if pinTarget == nil, tab == .about {
|
||||
let sections: [GamepadHint] = showsSectionHint
|
||||
? [.init(glyph: buttonGlyph(\.leftShoulder, fallback: "l1.rectangle.roundedbottom"),
|
||||
text: "Section", action: { step(tabBy: 1) })]
|
||||
: []
|
||||
return sections + [
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Open",
|
||||
action: { if let focusID { activate(id: focusID) } }),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
|
||||
action: { back() }),
|
||||
]
|
||||
}
|
||||
guard pinTarget != nil else {
|
||||
// The shoulders change section, so that cell leads — where it fits and where the
|
||||
// shoulders exist at all (see `showsSectionHint`).
|
||||
@@ -383,6 +448,9 @@ struct GamepadSettingsView: View {
|
||||
if let profile = pinTarget {
|
||||
pinTarget = nil
|
||||
focusID = "profile-\(profile.id)"
|
||||
} else if let page = aboutPage {
|
||||
aboutPage = nil
|
||||
focusID = page == .shortcuts ? "shortcuts" : "licenses"
|
||||
} else {
|
||||
performClose()
|
||||
}
|
||||
@@ -390,7 +458,53 @@ struct GamepadSettingsView: View {
|
||||
|
||||
// MARK: - Row rendering
|
||||
|
||||
@ViewBuilder
|
||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||
switch row.kind {
|
||||
case .control: controlRow(row, focused: focused)
|
||||
case .footer:
|
||||
Text(row.label)
|
||||
.font(.geist(metrics.detailFont, .medium, relativeTo: .caption))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(ink.fg(focused ? 0.7 : 0.45))
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.top, 18)
|
||||
.animation(.smooth(duration: 0.18), value: focused)
|
||||
case .heading:
|
||||
Text(row.label)
|
||||
.font(.geist(metrics.labelFont, .bold, relativeTo: .headline))
|
||||
.foregroundStyle(ink.fg(0.75))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, metrics.rowHPad)
|
||||
.padding(.top, 14)
|
||||
.padding(.bottom, 2)
|
||||
case .prose:
|
||||
// Focus here means "this is the part you are scrolled to", not "press A" — so it is a
|
||||
// quiet wash rather than the control rows' full glass.
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(row.label)
|
||||
.font(.geistFixed(metrics.valueFont, .medium))
|
||||
.foregroundStyle(ink.fg(0.95))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if !row.value.isEmpty {
|
||||
Text(row.value)
|
||||
.font(.geist(metrics.detailFont, relativeTo: .caption))
|
||||
.foregroundStyle(ink.fg(0.6))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, metrics.rowHPad)
|
||||
.padding(.vertical, metrics.rowVPad * 0.7)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: metrics.rowCorner, style: .continuous)
|
||||
.fill(ink.fg(focused ? 0.08 : 0))
|
||||
}
|
||||
.animation(.smooth(duration: 0.18), value: focused)
|
||||
}
|
||||
}
|
||||
|
||||
private func controlRow(_ row: Row, focused: Bool) -> some View {
|
||||
let m = metrics
|
||||
// No section header: the tab strip names the section now, and repeating it above the
|
||||
// first row of every tab was just a second label saying the same word.
|
||||
@@ -502,10 +616,23 @@ struct GamepadSettingsView: View {
|
||||
/// `activate(id:)`, not per closure, so no row builder can forget it.
|
||||
/// (Android's `GpRow.enabled` and `pf-console-ui`'s `RowSpec.enabled` are the twins.)
|
||||
var enabled = true
|
||||
/// How this row DRAWS. Every tab but About is `.control` — the glass row with a label and
|
||||
/// a value. About is a reading surface as much as a menu, so it also has a heading and a
|
||||
/// block of prose, which are rows only so the focus list can scroll them (the same trick
|
||||
/// `Licenses.chunked` plays for tvOS focus).
|
||||
var kind: Kind = .control
|
||||
/// Left/right step; returns whether the value actually changed (false ⇒ boundary thud).
|
||||
let adjust: (Int) -> Bool
|
||||
/// A — cycle forward (wrapping) / flip.
|
||||
let activate: () -> Void
|
||||
|
||||
enum Kind {
|
||||
case control
|
||||
case heading
|
||||
case prose
|
||||
/// Quiet, centred trailing text — the About tab's version line.
|
||||
case footer
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch by id so the focus list's stored input callbacks always act on freshly built rows
|
||||
@@ -527,9 +654,133 @@ struct GamepadSettingsView: View {
|
||||
/// controller wiring and the tvOS focus engine carry over as is).
|
||||
private var rows: [Row] {
|
||||
if let profile = pinTarget { return pinRows(for: profile) }
|
||||
if let page = aboutPage {
|
||||
switch page {
|
||||
case .shortcuts: return shortcutRows
|
||||
case .licenses: return licenseRows
|
||||
}
|
||||
}
|
||||
if tab == .about { return aboutRows }
|
||||
return allRows.filter { $0.tab == tab }
|
||||
}
|
||||
|
||||
// MARK: - About
|
||||
|
||||
/// The About section: the ways out, plus the two reading surfaces. The identity itself (icon,
|
||||
/// name, version, tagline) is the HEADER while this tab is up — see `aboutIdentity` — not a
|
||||
/// row, so the list holds no focus stop that does nothing when pressed.
|
||||
private var aboutRows: [Row] {
|
||||
var list: [Row] = [
|
||||
aboutAction(
|
||||
id: "shortcuts", icon: "command", label: "Shortcuts", value: "While streaming",
|
||||
detail: "What to press during a session on this device — and on a controller.",
|
||||
open: .shortcuts),
|
||||
aboutAction(
|
||||
id: "licenses", icon: "text.document", label: "Acknowledgements",
|
||||
value: "MIT or Apache-2.0",
|
||||
detail: "Punktfunk's own licence and the third-party components it uses.",
|
||||
open: .licenses),
|
||||
]
|
||||
list.append(contentsOf: [
|
||||
aboutLink(id: "docs", icon: "book", label: "Documentation", url: Destination.docs),
|
||||
aboutLink(
|
||||
id: "community", icon: "bubble.left.and.bubble.right", label: "Community",
|
||||
url: Destination.community),
|
||||
aboutLink(
|
||||
id: "source", icon: "chevron.left.forwardslash.chevron.right",
|
||||
label: "Source code", url: Destination.source),
|
||||
])
|
||||
// The version sits UNDER the rows rather than in a header card above them. The card that
|
||||
// used to head this tab carried the app icon, and on tvOS that icon is a 400x240
|
||||
// rectangle that would not survive contact with a layout built for square art — three
|
||||
// attempts at framing it were still cropping it on the real TV. A version string answers
|
||||
// the only question anyone actually opens About to ask, and has no aspect ratio to get
|
||||
// wrong. `.footer` draws it quiet and centred, so it reads as a footer and not a row you
|
||||
// failed to press.
|
||||
list.append(Row(
|
||||
id: "version", tab: .about, icon: "", label: Self.versionLine, value: "",
|
||||
detail: "", adjustable: false, enabled: true, kind: .footer,
|
||||
adjust: { _ in false }, activate: {}))
|
||||
return list
|
||||
}
|
||||
|
||||
private func aboutAction(
|
||||
id: String, icon: String, label: String, value: String, detail: String, open: AboutPage
|
||||
) -> Row {
|
||||
Row(
|
||||
id: id, tab: .about, icon: icon, label: label, value: value, detail: detail,
|
||||
adjustable: false,
|
||||
adjust: { _ in false },
|
||||
activate: {
|
||||
// Focus lands on the page's first row — the focus list's reconcile follows this
|
||||
// id when the row set swaps underneath it (the pin picker's pattern).
|
||||
focusID = open == .shortcuts ? shortcutRows.first?.id : licenseRows.first?.id
|
||||
aboutPage = open
|
||||
})
|
||||
}
|
||||
|
||||
/// tvOS has no browser and no `openURL`, so an address there is text to read off the screen
|
||||
/// rather than a link to nowhere — the same call the touch About page makes.
|
||||
private func aboutLink(id: String, icon: String, label: String, url: URL) -> Row {
|
||||
let shown = url.absoluteString.replacingOccurrences(of: "https://", with: "")
|
||||
#if os(tvOS)
|
||||
return Row(
|
||||
id: id, tab: .about, icon: icon, label: label, value: shown,
|
||||
detail: "Open this address on a phone or computer.",
|
||||
adjustable: false, adjust: { _ in false }, activate: {})
|
||||
#else
|
||||
return Row(
|
||||
id: id, tab: .about, icon: icon, label: label, value: shown,
|
||||
detail: "Opens in your browser.",
|
||||
adjustable: false, adjust: { _ in false }, activate: { openURL(url) })
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The shortcuts reference — the same `ShortcutsCatalog` the touch About page renders, so the
|
||||
/// two can never drift.
|
||||
private var shortcutRows: [Row] {
|
||||
ShortcutsCatalog.groups(micAvailable: micAvailable).flatMap { group -> [Row] in
|
||||
[aboutText(id: "group-\(group.title)", label: group.title, kind: .heading)]
|
||||
+ group.items.map { item in
|
||||
aboutText(
|
||||
id: "sc-\(group.title)-\(item.keys)", label: item.keys, value: item.text,
|
||||
kind: .prose)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The licence wall, one row per pre-chunked page (`Licenses.chunked`, which exists so tvOS
|
||||
/// can page it by focus steps) — so it scrolls with the stick and needs no machinery here.
|
||||
private var licenseRows: [Row] {
|
||||
var list: [Row] = [
|
||||
aboutText(id: "lic-heading", label: "Punktfunk", kind: .heading),
|
||||
aboutText(
|
||||
id: "lic-summary",
|
||||
label: "Punktfunk's source is open under MIT or Apache-2.0. It ships the Geist "
|
||||
+ "typeface under the SIL Open Font License 1.1, and uses the third-party "
|
||||
+ "components below, each under its own license.",
|
||||
kind: .prose),
|
||||
]
|
||||
for (i, chunk) in Licenses.chunked(Licenses.appLicense).enumerated() {
|
||||
list.append(aboutText(id: "lic-app-\(i)", label: chunk, kind: .prose))
|
||||
}
|
||||
list.append(aboutText(
|
||||
id: "lic-third-heading", label: "Third-party software", kind: .heading))
|
||||
for (i, chunk) in Licenses.thirdPartyNoticesChunks.enumerated() {
|
||||
list.append(aboutText(id: "lic-third-\(i)", label: chunk, kind: .prose))
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
private func aboutText(
|
||||
id: String, label: String, value: String = "", kind: Row.Kind
|
||||
) -> Row {
|
||||
Row(
|
||||
id: id, tab: .about, icon: "", label: label, value: value, detail: "",
|
||||
adjustable: false, enabled: true, kind: kind,
|
||||
adjust: { _ in false }, activate: {})
|
||||
}
|
||||
|
||||
/// Every row on the screen, tagged with its section. Built as one list (not per tab) so the
|
||||
/// platform-conditional insertions below can still place a row RELATIVE to another by id.
|
||||
private var allRows: [Row] {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// The in-session controls, written down once and read by every surface that shows them.
|
||||
//
|
||||
// This replaced the start-of-stream banner (ContentView's `showShortcutHint`): a 6-second pill
|
||||
// that told you the controls exactly once, while you were busy looking at the thing you had just
|
||||
// connected to, and then never again. A reference you can OPEN answers the question at the moment
|
||||
// it is actually asked — which is the second session, not the first.
|
||||
//
|
||||
// The catalog is data rather than a view so both About pages render the same words: the touch
|
||||
// `AboutView` (a Form) and the controller-first `GamepadAboutView` (a console list). The banner
|
||||
// was macOS/tvOS-only, so deleting it would have cost Mac TOUCH users the one place those keys
|
||||
// were written down — hence the touch surface gets this too, not just the gamepad UI.
|
||||
//
|
||||
// Per-platform by `#if`, because the honest answer really is different: tvOS has no keyboard and
|
||||
// no menu bar, iOS has a touch gesture nothing else has, and macOS is the only one that has to
|
||||
// explain mouse capture. A controller's chords are the one section common to all three — they are
|
||||
// the same buttons on every client (`GamepadCapture.escapeChord` / `.statsChord`), which is the
|
||||
// whole point of a cross-client chord.
|
||||
|
||||
import AVFoundation
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
/// One line of the reference: what you press, and what it does.
|
||||
struct ShortcutItem: Identifiable {
|
||||
/// Stable within its group — the keys are unique per group by construction.
|
||||
var id: String { keys }
|
||||
/// The chord itself, rendered monospaced so ⌃⌥⇧-style runs stay legible.
|
||||
let keys: String
|
||||
let text: String
|
||||
}
|
||||
|
||||
struct ShortcutGroup: Identifiable {
|
||||
var id: String { title }
|
||||
let title: String
|
||||
let items: [ShortcutItem]
|
||||
}
|
||||
|
||||
enum ShortcutsCatalog {
|
||||
/// Whether a mute key is worth listing when no session is running, for the About page reached
|
||||
/// from settings. `SessionModel.micAvailable` is the authority DURING a session — it also
|
||||
/// consults the profile the session actually resolved — but a reference page opened between
|
||||
/// sessions has no session to ask, so it answers the device-level half of the same question:
|
||||
/// a platform with an app-accessible input, the mic setting on, and the OS not refusing.
|
||||
/// `.notDetermined` counts, exactly as it does there: the prompt is simply still pending.
|
||||
static var micPlausible: Bool {
|
||||
#if os(tvOS)
|
||||
return false // no app-accessible microphone
|
||||
#else
|
||||
guard UserDefaults.standard.object(forKey: DefaultsKey.micEnabled) as? Bool ?? true
|
||||
else { return false }
|
||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||
case .authorized, .notDetermined: return true
|
||||
default: return false
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// `micAvailable` gates the mute row — a device with no microphone would otherwise be told
|
||||
/// about a key that does nothing, which is the failure the old banner already avoided.
|
||||
static func groups(micAvailable: Bool) -> [ShortcutGroup] {
|
||||
var groups: [ShortcutGroup] = []
|
||||
#if os(macOS)
|
||||
var keyboard: [ShortcutItem] = [
|
||||
.init(keys: "Click", text: "Capture the mouse and keyboard for the stream"),
|
||||
.init(keys: "⌃⌥⇧Q", text: "Release the mouse and keyboard back to this Mac"),
|
||||
.init(keys: "⌃⌥⇧D", text: "Disconnect"),
|
||||
.init(keys: "⌃⌥⇧S", text: "Cycle the statistics overlay"),
|
||||
]
|
||||
if micAvailable {
|
||||
keyboard.append(.init(keys: "⌃⌥⇧A", text: "Mute or unmute the microphone"))
|
||||
}
|
||||
groups.append(.init(title: "Keyboard", items: keyboard))
|
||||
#elseif os(iOS)
|
||||
// iPad with a hardware keyboard gets the same cross-client set as the Mac (StreamCommands
|
||||
// publishes it either way); a phone simply never sees a keyboard to press it on.
|
||||
var keyboard: [ShortcutItem] = [
|
||||
.init(keys: "⌃⌥⇧Q", text: "Release the pointer back to this device"),
|
||||
.init(keys: "⌃⌥⇧D", text: "Disconnect"),
|
||||
.init(keys: "⌃⌥⇧S", text: "Cycle the statistics overlay"),
|
||||
]
|
||||
if micAvailable {
|
||||
keyboard.append(.init(keys: "⌃⌥⇧A", text: "Mute or unmute the microphone"))
|
||||
}
|
||||
groups.append(.init(title: "Hardware keyboard", items: keyboard))
|
||||
groups.append(.init(title: "Touch", items: [
|
||||
.init(keys: "Three-finger tap", text: "Cycle the statistics overlay"),
|
||||
]))
|
||||
#elseif os(tvOS)
|
||||
// The remote section leads on tvOS: it carries the ONLY exits. Menu/B is swallowed during
|
||||
// a session (ContentView's `.onExitCommand {}`), so a user who does not know the hold
|
||||
// gesture is genuinely stuck — which is why this was the one banner that could not simply
|
||||
// be deleted without putting the words somewhere findable first.
|
||||
groups.append(.init(title: "Siri Remote", items: [
|
||||
.init(keys: "Hold Back", text: "Disconnect"),
|
||||
.init(keys: "Touch surface", text: "Move the pointer"),
|
||||
.init(keys: "Press", text: "Click"),
|
||||
.init(keys: "Play/Pause", text: "Right-click"),
|
||||
.init(keys: "Hold Play/Pause", text: "Cycle the statistics overlay"),
|
||||
]))
|
||||
#endif
|
||||
// Every client's controller speaks these two chords — see GamepadCapture.escapeChord and
|
||||
// .statsChord, which a test pins against their GameController element lists.
|
||||
groups.append(.init(title: "Controller", items: [
|
||||
.init(keys: "L1 + R1 + Start + Select", text: "Hold to disconnect"),
|
||||
.init(keys: "Select + X", text: "Cycle the statistics overlay"),
|
||||
.init(keys: "Hold Select", text: "Press the host's guide button"),
|
||||
]))
|
||||
return groups
|
||||
}
|
||||
}
|
||||
|
||||
/// The standard-interface reference — a sheet from `AboutView` on iOS/macOS, a pushed page on
|
||||
/// tvOS — so the keys the start-of-stream banner used to carry are still one press away.
|
||||
/// (The controller-first surface renders the same catalog itself; see `GamepadAboutView`.)
|
||||
struct ShortcutsView: View {
|
||||
let micAvailable: Bool
|
||||
|
||||
var body: some View {
|
||||
#if os(tvOS)
|
||||
// No `Form`/`.formStyle(.grouped)` worth using at 10 feet, and the rows are read, not
|
||||
// operated — a plain scrolling column at TV sizes says the same thing with less chrome.
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 30) {
|
||||
ForEach(ShortcutsCatalog.groups(micAvailable: micAvailable)) { group in
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(group.title)
|
||||
.font(.geist(28, .semibold, relativeTo: .headline))
|
||||
ForEach(group.items) { item in
|
||||
HStack(alignment: .firstTextBaseline, spacing: 20) {
|
||||
Text(item.keys)
|
||||
.font(.geistFixed(22, .medium))
|
||||
.frame(minWidth: 300, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(item.text)
|
||||
.font(.geist(22, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 1000, alignment: .leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(60)
|
||||
}
|
||||
.navigationTitle("Shortcuts")
|
||||
#else
|
||||
form
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
private var form: some View {
|
||||
Form {
|
||||
ForEach(ShortcutsCatalog.groups(micAvailable: micAvailable)) { group in
|
||||
Section(group.title) {
|
||||
ForEach(group.items) { item in
|
||||
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
||||
Text(item.keys)
|
||||
.font(.geistFixed(13, .medium))
|
||||
.foregroundStyle(.primary)
|
||||
// A fixed column keeps the descriptions aligned; the chords vary
|
||||
// from "Click" to "L1 + R1 + Start + Select".
|
||||
.frame(minWidth: 132, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(item.text)
|
||||
.font(.geist(13, relativeTo: .footnote))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Shortcuts")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -18,7 +18,10 @@ import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
|
||||
struct GamepadPairView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
/// Resolved from the stored palette, NOT from `\.gamepadInk` — this screen publishes that
|
||||
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
private var ink: GamepadInk { .stored(paletteID) }
|
||||
@Environment(\.gamepadMetrics) private var metrics
|
||||
@Environment(\.displayBottomInset) private var displayBottomInset
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@@ -32,8 +32,11 @@ final class AudioDeviceWatcher {
|
||||
/// posts one last change as it is torn down, and other AVAudioEngines in the process are not
|
||||
/// ours to restart.
|
||||
private let isOurs: (AnyObject?) -> Bool
|
||||
/// Delivered on the main queue.
|
||||
private let onChange: (Reason) -> Void
|
||||
/// Delivered on the main queue. The second argument is the engine that posted the change
|
||||
/// (`.engineConfiguration` only; nil for the HAL listener) — the owner needs the OBJECT, not
|
||||
/// just the reason, because an engine that is RUNNING when the notification lands is one the
|
||||
/// owner already restarted: acting on that echo is how a rebuild loop starts.
|
||||
private let onChange: (Reason, AnyObject?) -> Void
|
||||
|
||||
private let lock = NSLock()
|
||||
private var configObserver: NSObjectProtocol?
|
||||
@@ -41,7 +44,7 @@ final class AudioDeviceWatcher {
|
||||
private var defaultOutputListener: AudioObjectPropertyListenerBlock?
|
||||
#endif
|
||||
|
||||
init(isOurs: @escaping (AnyObject?) -> Bool, onChange: @escaping (Reason) -> Void) {
|
||||
init(isOurs: @escaping (AnyObject?) -> Bool, onChange: @escaping (Reason, AnyObject?) -> Void) {
|
||||
self.isOurs = isOurs
|
||||
self.onChange = onChange
|
||||
}
|
||||
@@ -63,7 +66,7 @@ final class AudioDeviceWatcher {
|
||||
let posted = note.object as AnyObject?
|
||||
DispatchQueue.main.async {
|
||||
guard let self, self.isOurs(posted) else { return }
|
||||
self.onChange(.engineConfiguration)
|
||||
self.onChange(.engineConfiguration, posted)
|
||||
}
|
||||
}
|
||||
lock.lock()
|
||||
@@ -77,7 +80,8 @@ final class AudioDeviceWatcher {
|
||||
// (the voice-processing engine, which is the DEFAULT macOS configuration and which no Mac
|
||||
// here can even initialize). The HAL is told either way.
|
||||
let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in
|
||||
self?.onChange(.defaultOutputDevice) // on the main queue — registered against it below
|
||||
// On the main queue — registered against it below. No engine posted this, so nil.
|
||||
self?.onChange(.defaultOutputDevice, nil)
|
||||
}
|
||||
var address = Self.defaultOutputAddress()
|
||||
let status = AudioObjectAddPropertyListenerBlock(
|
||||
|
||||
@@ -42,7 +42,10 @@ public enum AudioDevices {
|
||||
return channelCount(id, scope: kAudioObjectPropertyScopeInput)
|
||||
}
|
||||
|
||||
private static func defaultInputDevice() -> AudioDeviceID? {
|
||||
/// The device the system is currently capturing from — the key `SessionAudio`'s
|
||||
/// voice-processing gate latches a start failure against (the failure is a property of the
|
||||
/// input device, so a new device earns a fresh attempt).
|
||||
static func defaultInputDevice() -> AudioDeviceID? {
|
||||
systemDevice(kAudioHardwarePropertyDefaultInputDevice)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// The two policy decisions of the device-change recovery, extracted where a unit test can reach
|
||||
// them. Both exist because of one field incident (2026-08-14, Mac Studio): the voice-processing
|
||||
// engine could not start on a 6-channel input device, every rebuild re-tried it, and the failed
|
||||
// attempt's HAL churn (VPIO builds and tears down an aggregate device) re-stopped the fallback
|
||||
// engines — which posted the configuration change that scheduled the next rebuild. A ~2.5 s
|
||||
// metronome of audio gaps, forever, with each rebuild also stalling the main thread (where macOS
|
||||
// input capture lives), so the stream's INPUT cut out on the same beat. The session-side wiring
|
||||
// lives in `SessionAudio`; the decisions live here because the loop shipped precisely because
|
||||
// they could not be tested without a mic and a session.
|
||||
|
||||
#if os(macOS)
|
||||
import CoreAudio
|
||||
#endif
|
||||
import Foundation
|
||||
|
||||
#if os(macOS)
|
||||
/// Should a rebuild try the combined (voice-processing) topology again?
|
||||
///
|
||||
/// A VPIO start failure is a property of the INPUT DEVICE (its channel count and format), not of
|
||||
/// the moment: retrying it on the same device fails the same way, and the attempt is not free —
|
||||
/// engaging and abandoning the voice processor churns the HAL hard enough to stop the healthy
|
||||
/// fallback engines. So a failure latches until the default input actually changes; a new device
|
||||
/// earns exactly one fresh attempt (it may well support VPIO), and its own failure latches again.
|
||||
struct CombinedTopologyGate {
|
||||
private var failed = false
|
||||
/// The default input device the failure was observed on — nil is a real value here ("failed
|
||||
/// with no resolvable input device"), which is why `failed` is tracked separately.
|
||||
private var failedInput: AudioDeviceID?
|
||||
|
||||
/// The combined topology failed with `input` as the default input device.
|
||||
mutating func noteFailure(input: AudioDeviceID?) {
|
||||
failed = true
|
||||
failedInput = input
|
||||
}
|
||||
|
||||
/// True when the combined topology is worth attempting with `input` as the default input
|
||||
/// device. A device change clears the latch — the answer is about the CURRENT hardware, and
|
||||
/// coming back to a device that failed before earns a fresh attempt too (the failure may have
|
||||
/// been the mid-transition kind, and one attempt per device change cannot loop).
|
||||
mutating func shouldTry(input: AudioDeviceID?) -> Bool {
|
||||
guard failed else { return true }
|
||||
guard input == failedInput else {
|
||||
failed = false
|
||||
failedInput = nil
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// The delay before the next engine rebuild — the base debounce/floor behaviour, plus an
|
||||
/// escalating floor when rebuilds CHAIN (each one retriggered by its predecessor's own fallout).
|
||||
///
|
||||
/// One device switch produces one rebuild: its trigger burst is coalesced upstream, so the next
|
||||
/// trigger normally arrives minutes later and gets the base floor. A trigger that arrives hard on
|
||||
/// the heels of the last rebuild, again and again, is a rebuild answering itself — and since the
|
||||
/// recovery cannot always identify its own echo, the backstop is to keep answering but at a
|
||||
/// doubling floor, so an unforeseen feedback shape costs one audio blip per half-minute instead
|
||||
/// of a metronome. A quiet stretch resets the ladder to full responsiveness.
|
||||
struct RebuildBackoff {
|
||||
/// Let the burst of triggers from one switch land before rebuilding.
|
||||
static let debounce: TimeInterval = 0.15
|
||||
/// Floor between two rebuilds.
|
||||
static let floor: TimeInterval = 0.5
|
||||
/// The escalated floor's cap: looping recoveries settle at one attempt per this interval.
|
||||
static let floorCap: TimeInterval = 30
|
||||
/// A trigger this long after the last rebuild is unrelated to it — the chain resets.
|
||||
static let chainWindow: TimeInterval = 10
|
||||
|
||||
/// Consecutive rebuilds whose trigger arrived within `chainWindow` of the previous rebuild.
|
||||
private(set) var chain = 0
|
||||
private var lastRebuildAt: TimeInterval = -.infinity
|
||||
|
||||
/// The delay to schedule the next rebuild with, for a trigger arriving at `now`
|
||||
/// (`systemUptime`). Mutates the chain accounting: call once per SCHEDULED rebuild, not per
|
||||
/// coalesced trigger.
|
||||
mutating func delay(now: TimeInterval) -> TimeInterval {
|
||||
let since = now - lastRebuildAt
|
||||
chain = since < Self.chainWindow ? chain + 1 : 0
|
||||
let floor = min(Self.floor * pow(2, Double(min(chain, 6))), Self.floorCap)
|
||||
return max(Self.debounce, floor - since)
|
||||
}
|
||||
|
||||
/// The rebuild actually ran at `now` — the reference the next trigger's `delay` measures from.
|
||||
mutating func noteRebuild(at now: TimeInterval) {
|
||||
lastRebuildAt = now
|
||||
}
|
||||
}
|
||||
@@ -63,15 +63,24 @@ public final class SessionAudio {
|
||||
private var micMuted = false
|
||||
/// The playback jitter ring — created by whichever engine starts playback first and KEPT
|
||||
/// across an engine rebuild (the permission-grant upgrade in `startEngines` swaps engines,
|
||||
/// not the ring, so the drain thread never has to be re-pointed). Main-thread confined,
|
||||
/// like every start path.
|
||||
/// not the ring, so the drain thread never has to be re-pointed). Guarded by `stateLock`:
|
||||
/// the start paths run on `engineQueue`, while `stats` reads from the main thread.
|
||||
private var ring: AudioRing?
|
||||
/// Every engine build, start, stop and rebuild runs here, serially — and NOT on the main
|
||||
/// thread. macOS captures and sends input from the main thread, so the seconds a
|
||||
/// voice-processing start can take (~1.9 s measured in the 2026-08-14 field loop) would
|
||||
/// freeze the stream's input for exactly that long — the recovery must never make the main
|
||||
/// thread wait on the audio server. The main queue keeps only the trigger bookkeeping
|
||||
/// (debounce, backoff, retry ladder), which is cheap by construction.
|
||||
private let engineQueue = DispatchQueue(
|
||||
label: "io.unom.punktfunk.audio.engines", qos: .userInitiated)
|
||||
/// The video plane's end-to-end meter (capture→on-glass), if the owner wired one — the
|
||||
/// reference the A/V sync loop steers the ring against. `nil` leaves the loop inert and the
|
||||
/// ring exactly as it was before sync existed, which is also what the stage-1 fallback
|
||||
/// presenter gets: it decodes and presents inside the layer with no per-frame stamp, so it can
|
||||
/// offer no reference, and a loop with no reference must not invent one. Main-thread confined,
|
||||
/// like `ring`; the meter itself is internally locked and read from the drain thread.
|
||||
/// offer no reference, and a loop with no reference must not invent one. Written ONCE in
|
||||
/// `start()` before anything is dispatched (the queue hop orders it for `startDrain`); the
|
||||
/// meter itself is internally locked and read from the drain thread.
|
||||
private var videoLatency: LatencyMeter?
|
||||
#if !os(macOS)
|
||||
/// AVAudioSession `setCategory`/`setActive` are synchronous and block on the audio server, so
|
||||
@@ -99,7 +108,8 @@ public final class SessionAudio {
|
||||
// MARK: - Device changes (see `installDeviceChangeRecovery`)
|
||||
|
||||
/// What `start()` was asked for, so a rebuild can put back the SAME topology the session was
|
||||
/// started with. Main-thread confined, like the start paths that read it.
|
||||
/// started with. Guarded by `stateLock` (written on the caller's thread, read when a rebuild
|
||||
/// fires on the main queue).
|
||||
private var startConfig: StartConfig?
|
||||
private struct StartConfig {
|
||||
let speakerUID: String
|
||||
@@ -110,20 +120,23 @@ public final class SessionAudio {
|
||||
}
|
||||
/// Watches the hardware for us (see `AudioDeviceWatcher`). Guarded by `stateLock`.
|
||||
private var deviceWatcher: AudioDeviceWatcher?
|
||||
/// Whether the engines have been built at least once. Distinguishes "not started yet" (iOS
|
||||
/// starts asynchronously) from "started and dead", which is what the recovery may act on.
|
||||
/// Main-thread confined.
|
||||
/// Whether the engines have been built at least once. Distinguishes "not started yet" (every
|
||||
/// platform starts asynchronously now) from "started and dead", which is what the recovery
|
||||
/// may act on. Guarded by `stateLock` (set on `engineQueue`, read on the main queue).
|
||||
private var enginesAttempted = false
|
||||
/// A rebuild is already on the main queue — one device switch produces a burst of triggers
|
||||
/// and they must collapse into one restart. Main-thread confined.
|
||||
private var rebuildQueued = false
|
||||
/// `systemUptime` of the last rebuild, so a device that renegotiates in a loop cannot spin
|
||||
/// the session. Main-thread confined.
|
||||
private var lastRebuildAt: TimeInterval = 0
|
||||
/// Let the burst of triggers from one switch land before rebuilding.
|
||||
private static let rebuildDebounce: TimeInterval = 0.15
|
||||
/// Floor between two rebuilds.
|
||||
private static let rebuildFloor: TimeInterval = 0.5
|
||||
/// Debounce/floor for the next rebuild, with an escalating floor when rebuilds chain (each
|
||||
/// retriggered by its predecessor — see `RebuildBackoff`). Main-thread confined.
|
||||
private var rebuildBackoff = RebuildBackoff()
|
||||
#if os(macOS)
|
||||
/// Latches a voice-processing start failure per input device, so a rebuild never re-attempts
|
||||
/// a topology that deterministically fails — the retry is what turned one failure into a
|
||||
/// rebuild loop (see `CombinedTopologyGate` and the note on `installDeviceChangeRecovery`).
|
||||
/// `engineQueue`-confined, like the start paths that consult and feed it.
|
||||
private var combinedGate = CombinedTopologyGate()
|
||||
#endif
|
||||
/// Retries when a rebuild's `start()` loses the race with a device that is still going away
|
||||
/// (0.3 s, 0.6 s, 1.2 s). A failed rebuild leaves no engine to post the next notification,
|
||||
/// so this ladder — and, on macOS, the HAL listener — is all that stands between a mistimed
|
||||
@@ -151,11 +164,12 @@ public final class SessionAudio {
|
||||
}
|
||||
|
||||
/// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system default
|
||||
/// device; on iOS the UIDs are ignored entirely (routes are AVAudioSession-managed). On macOS
|
||||
/// the engines start synchronously on the caller's (main) thread. On iOS/tvOS start() is
|
||||
/// ASYNCHRONOUS: it activates the AVAudioSession off the main thread, then starts the engines on
|
||||
/// a later main-queue hop (gated by `!flag.isStopped`) — so playback is live shortly after, not
|
||||
/// on return. The mic may start later still if the permission prompt is pending.
|
||||
/// device; on iOS the UIDs are ignored entirely (routes are AVAudioSession-managed).
|
||||
/// ASYNCHRONOUS on every platform: the engines start on `engineQueue` (iOS/tvOS activate the
|
||||
/// AVAudioSession off the main thread first), gated by `!flag.isStopped` — so playback is
|
||||
/// live shortly after, not on return. An engine start can block on the audio server for
|
||||
/// seconds, and the caller's (main) thread is where macOS input capture lives — it must
|
||||
/// never wait. The mic may start later still if the permission prompt is pending.
|
||||
/// `echoCancel` picks the engine topology — see the header note and `wantsCombined`.
|
||||
///
|
||||
/// `videoLatency` is the session's END-TO-END latency meter (capture→on-glass). Pass it to arm
|
||||
@@ -166,26 +180,33 @@ public final class SessionAudio {
|
||||
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool,
|
||||
videoLatency: LatencyMeter? = nil
|
||||
) {
|
||||
self.videoLatency = videoLatency
|
||||
self.videoLatency = videoLatency // before any dispatch below — startDrain reads it
|
||||
// Before any engine exists: the recovery watches the hardware, not the engines, and the
|
||||
// config it rebuilds from has to be recorded whether or not this start succeeds.
|
||||
stateLock.lock()
|
||||
startConfig = StartConfig(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||
micEnabled: micEnabled, echoCancel: echoCancel)
|
||||
stateLock.unlock()
|
||||
installDeviceChangeRecovery(micEnabled: micEnabled)
|
||||
#if os(macOS)
|
||||
// No AVAudioSession on macOS — start the engines directly (caller's thread, as before).
|
||||
startEngines(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||
micEnabled: micEnabled, echoCancel: echoCancel)
|
||||
// No AVAudioSession on macOS — but the engines start on `engineQueue`, never the
|
||||
// caller's (main) thread: a voice-processing start can block on the audio server for
|
||||
// seconds, and the main thread is where input capture lives.
|
||||
engineQueue.async { [weak self] in
|
||||
guard let self, !self.flag.isStopped else { return }
|
||||
self.startEngines(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||
micEnabled: micEnabled, echoCancel: echoCancel)
|
||||
}
|
||||
#else
|
||||
// Configure + activate the session OFF the main thread (it blocks on the audio server),
|
||||
// then start the engines back on the main thread once it's active — engine routing/format
|
||||
// then start the engines on `engineQueue` once it's active — engine routing/format
|
||||
// depend on the active session. A stop() racing in between is caught by the flag guard.
|
||||
Self.sessionQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.activateAudioSession(micEnabled: micEnabled)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self.engineQueue.async { [weak self] in
|
||||
guard let self, !self.flag.isStopped else { return }
|
||||
self.startEngines(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||
@@ -342,12 +363,15 @@ public final class SessionAudio {
|
||||
#endif
|
||||
|
||||
/// Build + start the engines — combined (voice-processed) or split, per `wantsCombined` —
|
||||
/// with the mic uplink only when enabled + authorized. Main thread (engine setup); on
|
||||
/// iOS/tvOS the session is already active by the time this runs.
|
||||
/// with the mic uplink only when enabled + authorized. Runs on `engineQueue` (a start can
|
||||
/// block on the audio server for seconds — never the main thread); on iOS/tvOS the session
|
||||
/// is already active by the time this runs.
|
||||
private func startEngines(
|
||||
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool
|
||||
) {
|
||||
stateLock.lock()
|
||||
enginesAttempted = true // even if every path below fails — see `reviveStoppedEngines`
|
||||
stateLock.unlock()
|
||||
#if os(tvOS)
|
||||
// No app-accessible microphone input on tvOS — playback only.
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
@@ -356,9 +380,25 @@ public final class SessionAudio {
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
return
|
||||
}
|
||||
#if os(macOS)
|
||||
// A rebuild must not re-attempt a voice-processing start that already failed on this
|
||||
// input device: the failure repeats, and the failed attempt's HAL churn stops the healthy
|
||||
// fallback engines — the 2026-08-14 rebuild loop (see `CombinedTopologyGate`).
|
||||
var combined = wantsCombined(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||
echoCancel: echoCancel)
|
||||
if combined, !combinedGate.shouldTry(input: AudioDevices.defaultInputDevice()) {
|
||||
log.info("""
|
||||
voice processing already failed on this input device — split engines, no echo \
|
||||
cancellation
|
||||
""")
|
||||
combined = false
|
||||
}
|
||||
#else
|
||||
let combined = wantsCombined(
|
||||
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||
echoCancel: echoCancel)
|
||||
#endif
|
||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||
case .authorized:
|
||||
if combined {
|
||||
@@ -374,7 +414,8 @@ public final class SessionAudio {
|
||||
// drain thread carry over — see `makePlaybackChain`).
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
self.engineQueue.async { [weak self] in
|
||||
guard let self, granted, !self.flag.isStopped else { return }
|
||||
if combined {
|
||||
self.stateLock.lock()
|
||||
@@ -513,6 +554,17 @@ public final class SessionAudio {
|
||||
/// - the route-change and media-services-reset notifications, iOS/tvOS, where the session and
|
||||
/// not the device is what moves.
|
||||
///
|
||||
/// And three defenses keep the recovery from ANSWERING ITSELF — a rebuild is not a silent
|
||||
/// act (a voice-processing start builds and tears down HAL aggregates, and every fresh engine
|
||||
/// renegotiates its IO), so its own fallout can retrigger it. The 2026-08-14 field loop was
|
||||
/// exactly that: VPIO failed on a 6-channel mic, every rebuild re-tried it, and the failure's
|
||||
/// churn stopped the fallback engines — audio and (via the main thread) INPUT cutting out
|
||||
/// every ~2.5 s for the whole session. The defenses: a configuration change from an engine
|
||||
/// that is RUNNING is a rebuild's echo and is ignored (`hardwareMoved`); a VPIO failure is
|
||||
/// latched per input device and never re-attempted on it (`CombinedTopologyGate`); and
|
||||
/// rebuilds that chain anyway back off exponentially instead of metronoming
|
||||
/// (`RebuildBackoff`).
|
||||
///
|
||||
/// `micEnabled` only decides whether the mic-bearing session observers are worth installing.
|
||||
/// Main thread.
|
||||
private func installDeviceChangeRecovery(micEnabled: Bool) {
|
||||
@@ -523,7 +575,7 @@ public final class SessionAudio {
|
||||
|
||||
let watcher = AudioDeviceWatcher(
|
||||
isOurs: { [weak self] posted in self?.ownsEngine(posted) ?? false },
|
||||
onChange: { [weak self] reason in self?.hardwareMoved(reason) })
|
||||
onChange: { [weak self] reason, posted in self?.hardwareMoved(reason, posted: posted) })
|
||||
stateLock.lock()
|
||||
deviceWatcher = watcher
|
||||
stateLock.unlock()
|
||||
@@ -549,10 +601,17 @@ public final class SessionAudio {
|
||||
/// question — is playback still where it should be — but they answer it differently: an engine
|
||||
/// that told us it stopped is definitive, while the default device moving might not concern us
|
||||
/// at all.
|
||||
private func hardwareMoved(_ reason: AudioDeviceWatcher.Reason) {
|
||||
private func hardwareMoved(_ reason: AudioDeviceWatcher.Reason, posted: AnyObject?) {
|
||||
guard !flag.isStopped else { return }
|
||||
switch reason {
|
||||
case .engineConfiguration:
|
||||
// The engine stops itself BEFORE posting this — so an engine that is RUNNING when the
|
||||
// notification lands on the main queue is one a rebuild already replaced or restarted:
|
||||
// the notification is the rebuild's own echo, and answering it is how the recovery
|
||||
// loops. A change that stops the engine again after this posts again, and the HAL
|
||||
// backstop checks placement independently, so ignoring a live engine's echo can never
|
||||
// strand a stopped one.
|
||||
if let engine = posted as? AVAudioEngine, engine.isRunning { return }
|
||||
scheduleEngineRebuild(reason: reason.rawValue)
|
||||
case .defaultOutputDevice:
|
||||
#if os(macOS)
|
||||
@@ -572,7 +631,10 @@ public final class SessionAudio {
|
||||
/// output device at the moment it connected — and leaving it silent for good. On iOS the same
|
||||
/// flag keeps this from racing the asynchronous start, where no engine yet is normal.
|
||||
private func reviveStoppedEngines(_ reason: String) {
|
||||
guard !flag.isStopped, enginesAttempted, !playbackIsLive else { return }
|
||||
stateLock.lock()
|
||||
let attempted = enginesAttempted
|
||||
stateLock.unlock()
|
||||
guard !flag.isStopped, attempted, !playbackIsLive else { return }
|
||||
scheduleEngineRebuild(reason: "playback is stopped and \(reason)")
|
||||
}
|
||||
|
||||
@@ -594,15 +656,43 @@ public final class SessionAudio {
|
||||
private func scheduleEngineRebuild(reason: String) {
|
||||
guard !rebuildQueued else { return }
|
||||
rebuildQueued = true
|
||||
let since = ProcessInfo.processInfo.systemUptime - lastRebuildAt
|
||||
let delay = max(Self.rebuildDebounce, Self.rebuildFloor - since)
|
||||
log.info("\(reason) — restarting the audio engines in \(Int(delay * 1000)) ms")
|
||||
let delay = rebuildBackoff.delay(now: ProcessInfo.processInfo.systemUptime)
|
||||
if rebuildBackoff.chain >= 2 {
|
||||
// Each rebuild is retriggering the next — a feedback shape the echo guard and the
|
||||
// topology gate did not identify. Keep answering (a real recovery must not be
|
||||
// abandoned), but say what is happening: this line repeating IS the diagnosis.
|
||||
log.warning("""
|
||||
audio engine rebuilds are chaining (\(self.rebuildBackoff.chain) in a row — \
|
||||
\(reason)); backing off \(Int(delay * 1000)) ms
|
||||
""")
|
||||
} else {
|
||||
log.info("\(reason) — restarting the audio engines in \(Int(delay * 1000)) ms")
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
self?.rebuildEngines(attempt: 0)
|
||||
self?.rebuildFire(attempt: 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The scheduled rebuild came due (main queue): close out the bookkeeping and hand the
|
||||
/// actual engine work to `engineQueue` — the teardown + start can block on the audio server
|
||||
/// for seconds, and the main thread is where macOS captures and sends the stream's input.
|
||||
/// A trigger arriving while the work is in flight schedules a fresh rebuild rather than
|
||||
/// being swallowed; `engineQueue` is serial, so the two never interleave.
|
||||
private func rebuildFire(attempt: Int) {
|
||||
rebuildQueued = false
|
||||
guard !flag.isStopped else { return }
|
||||
stateLock.lock()
|
||||
let config = startConfig
|
||||
stateLock.unlock()
|
||||
guard let config else { return }
|
||||
rebuildBackoff.noteRebuild(at: ProcessInfo.processInfo.systemUptime)
|
||||
engineQueue.async { [weak self] in
|
||||
self?.performRebuild(config: config, attempt: attempt)
|
||||
}
|
||||
}
|
||||
|
||||
/// Put back the topology this session was started with, on whatever hardware is there now.
|
||||
/// Runs on `engineQueue`.
|
||||
///
|
||||
/// A full rebuild rather than a `start()` on the stopped engine, because the mic side has to
|
||||
/// follow too: `installMicTap` reads the input's live format, and the voice processor
|
||||
@@ -610,10 +700,8 @@ public final class SessionAudio {
|
||||
/// across (`makePlaybackChain` reuses it, `startDrain` is idempotent), so the drain thread
|
||||
/// keeps decoding right through the switch and its overflow policy has already dropped
|
||||
/// everything that went stale while the engine was down.
|
||||
private func rebuildEngines(attempt: Int) {
|
||||
rebuildQueued = false
|
||||
guard !flag.isStopped, let config = startConfig else { return }
|
||||
lastRebuildAt = ProcessInfo.processInfo.systemUptime
|
||||
private func performRebuild(config: StartConfig, attempt: Int) {
|
||||
guard !flag.isStopped else { return }
|
||||
tearDownEngines()
|
||||
startEngines(
|
||||
speakerUID: config.speakerUID, micUID: config.micUID, micChannel: config.micChannel,
|
||||
@@ -626,6 +714,18 @@ public final class SessionAudio {
|
||||
log.info("audio engines restarted on the current device")
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.rebuildFailed(attempt: attempt)
|
||||
}
|
||||
}
|
||||
|
||||
/// A rebuild's playback did not come back (main queue) — walk the retry ladder. Retries
|
||||
/// when a rebuild's `start()` loses the race with a device that is still going away
|
||||
/// (0.3 s, 0.6 s, 1.2 s): a failed rebuild leaves no engine to post the next notification,
|
||||
/// so this ladder — and, on macOS, the HAL listener — is all that stands between a mistimed
|
||||
/// switch and a silent session.
|
||||
private func rebuildFailed(attempt: Int) {
|
||||
guard !flag.isStopped else { return }
|
||||
guard attempt < Self.rebuildAttempts else {
|
||||
#if os(macOS)
|
||||
log.error("""
|
||||
@@ -637,10 +737,11 @@ public final class SessionAudio {
|
||||
#endif
|
||||
return
|
||||
}
|
||||
guard !rebuildQueued else { return } // a fresh trigger already queued a full rebuild
|
||||
rebuildQueued = true // holds off a trigger that would only race this ladder
|
||||
let delay = Self.rebuildDebounce * Double(1 << (attempt + 1))
|
||||
let delay = RebuildBackoff.debounce * Double(1 << (attempt + 1))
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
self?.rebuildEngines(attempt: attempt + 1)
|
||||
self?.rebuildFire(attempt: attempt + 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -786,9 +887,13 @@ public final class SessionAudio {
|
||||
public let avOffsetMS: Int
|
||||
}
|
||||
|
||||
/// A snapshot of `Stats`, or nil before playback starts. Main thread (`ring` is main-confined;
|
||||
/// the ring's own numbers are taken under its lock, so they describe one instant).
|
||||
/// A snapshot of `Stats`, or nil before playback starts. Safe from any thread (the handle is
|
||||
/// taken under `stateLock`; the ring's own numbers are taken under its lock, so they
|
||||
/// describe one instant).
|
||||
public var stats: Stats? {
|
||||
stateLock.lock()
|
||||
let ring = self.ring
|
||||
stateLock.unlock()
|
||||
guard let s = ring?.stats else { return nil }
|
||||
return Stats(bufferMS: s.bufferedMS, avOffsetMS: s.avOffsetMS)
|
||||
}
|
||||
@@ -813,7 +918,7 @@ public final class SessionAudio {
|
||||
/// The playback jitter ring + the source node draining it — shared by the plain playback
|
||||
/// engine and the combined voice-processing engine, and REUSED across an engine rebuild
|
||||
/// (same session, same ring: the drain thread keeps writing right through the swap). nil
|
||||
/// when the host's channel layout can't be expressed (already logged). Main thread.
|
||||
/// when the host's channel layout can't be expressed (already logged). Runs on `engineQueue`.
|
||||
private func makePlaybackChain()
|
||||
-> (ring: AudioRing, source: AVAudioSourceNode, format: AVAudioFormat)?
|
||||
{
|
||||
@@ -823,8 +928,10 @@ public final class SessionAudio {
|
||||
// 1 s interleaved capacity, scaled by the channel count. The de-jitter depth itself is
|
||||
// the ring's own business now (`AudioRing.targetMS`, mirroring `JitterTuning::COREAUDIO`)
|
||||
// rather than a prefill passed in here.
|
||||
stateLock.lock()
|
||||
let ring = self.ring ?? AudioRing(capacity: 48_000 * channels, channels: channels)
|
||||
self.ring = ring
|
||||
stateLock.unlock()
|
||||
|
||||
// Engine-native deinterleaved float; the render block deinterleaves from the ring. Surround
|
||||
// uses an explicit wire-order channel layout; the mixer downmixes to the output device when
|
||||
@@ -983,6 +1090,17 @@ public final class SessionAudio {
|
||||
// MARK: - Mic (mic → host)
|
||||
|
||||
#if !os(tvOS)
|
||||
/// The combined topology failed to come up. On macOS, latch the input device it failed on so
|
||||
/// a rebuild goes straight to the split topology instead of re-running the failure — the
|
||||
/// failed attempt is what churns the HAL and retriggers the recovery (see
|
||||
/// `CombinedTopologyGate`). On iOS routes are session-managed and a VPIO failure is the
|
||||
/// transient route-transition kind, so nothing is latched there.
|
||||
private func noteCombinedFailure() {
|
||||
#if os(macOS)
|
||||
combinedGate.noteFailure(input: AudioDevices.defaultInputDevice())
|
||||
#endif
|
||||
}
|
||||
|
||||
/// One engine, both directions: engage the system voice processor on the shared IO unit
|
||||
/// (AEC + noise suppression + AGC), hang the playback source off its render side and the
|
||||
/// mic tap off its capture side. Every failure falls back to a WORKING configuration —
|
||||
@@ -1001,6 +1119,7 @@ public final class SessionAudio {
|
||||
voice processing unavailable (\(error.localizedDescription)) — separate \
|
||||
engines, no echo cancellation
|
||||
""")
|
||||
noteCombinedFailure()
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
startCapture(micUID: micUID, micChannel: micChannel)
|
||||
return
|
||||
@@ -1054,6 +1173,7 @@ public final class SessionAudio {
|
||||
// processor won't engage at all, already does exactly this; this arm used to give up
|
||||
// on the mic instead, which is how a whole session could go silent uplink-only.)
|
||||
engine.stop()
|
||||
noteCombinedFailure()
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
startCapture(micUID: micUID, micChannel: micChannel)
|
||||
return
|
||||
@@ -1064,6 +1184,7 @@ public final class SessionAudio {
|
||||
log.error("combined engine failed to start: \(error.localizedDescription)")
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
noteCombinedFailure()
|
||||
// Same rule: a working mic without echo cancellation beats no mic at all.
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
startCapture(micUID: micUID, micChannel: micChannel)
|
||||
|
||||
@@ -382,12 +382,29 @@ public final class PunktfunkConnection {
|
||||
/// the client draws its own (a visible system cursor over the stream).
|
||||
public private(set) var resolvedCompositor: Compositor = .auto
|
||||
|
||||
/// Host clock minus client clock (nanoseconds), from the connect-time wall-clock skew handshake
|
||||
/// (`punktfunk_connection_clock_offset_ns`). Add it to a local `CLOCK_REALTIME` instant to
|
||||
/// express that instant in the host's capture clock — the clock each `AccessUnit.ptsNs` is
|
||||
/// stamped in — so a glass-to-glass latency (present/enqueue time minus `ptsNs`) is valid across
|
||||
/// machines. `0` = no correction (an older host that didn't answer, or synchronized clocks).
|
||||
public private(set) var clockOffsetNs: Int64 = 0
|
||||
/// Host clock minus client clock (nanoseconds) — LIVE: the connect-time skew handshake's
|
||||
/// estimate, kept fresh by the core's mid-stream re-syncs (every 60 s plus immediately on a
|
||||
/// suspected wall-clock step; `punktfunk_connection_clock_offset_now_ns`, ABI v10). Add it to
|
||||
/// a local `CLOCK_REALTIME` instant to express that instant in the host's capture clock — the
|
||||
/// clock each `AccessUnit.ptsNs` is stamped in — so a glass-to-glass latency (present/enqueue
|
||||
/// time minus `ptsNs`) is valid across machines. `0` = no correction (an older host that
|
||||
/// didn't answer, synchronized clocks, or a closed connection).
|
||||
///
|
||||
/// ⚠ LIVE means DO NOT CACHE. Until 2026-08-13 this was a connect-time snapshot, and the
|
||||
/// core's own doc names the failure: "after an NTP step or slow drift the connect-time value
|
||||
/// silently corrupts every capture-clock comparison." The field evidence was stark — two
|
||||
/// sessions minutes apart against the same wired host read hostnet 17–21 ms, then a
|
||||
/// physically impossible 4.4 ms (the host is a VM; VM wall clocks step), and LatencyMeter's
|
||||
/// impossible-sample guard silently trimmed the shifted-negative half, so the HUD showed a
|
||||
/// plausible small number instead of an alarm. Read this property at each use — it is an
|
||||
/// atomic load behind the FFI — and never park it in a `let` or a closure capture list.
|
||||
/// Cross-thread reads follow the `framesDropped()` precedent.
|
||||
public var clockOffsetNs: Int64 {
|
||||
guard let handle else { return 0 }
|
||||
var offset: Int64 = 0
|
||||
_ = punktfunk_connection_clock_offset_now_ns(handle, &offset)
|
||||
return offset
|
||||
}
|
||||
|
||||
/// The video encoder bitrate (kbps) the host actually configured — the requested
|
||||
/// `bitrateKbps` clamped to the host's range ([500, 2 000 000] kbps), or its default
|
||||
@@ -635,9 +652,6 @@ public final class PunktfunkConnection {
|
||||
var comp: UInt32 = 0
|
||||
_ = punktfunk_connection_compositor(handle, &comp)
|
||||
resolvedCompositor = Compositor(rawValue: comp) ?? .auto
|
||||
var offset: Int64 = 0
|
||||
_ = punktfunk_connection_clock_offset_ns(handle, &offset)
|
||||
clockOffsetNs = offset
|
||||
var br: UInt32 = 0
|
||||
_ = punktfunk_connection_bitrate(handle, &br)
|
||||
resolvedBitrateKbps = br
|
||||
|
||||
@@ -15,8 +15,9 @@ import Foundation
|
||||
/// `record(ptsNs:atNs:offsetNs:)` at present.
|
||||
///
|
||||
/// For the host-anchored intervals (capture→…) the sample is `end + offset - pts_ns`, where
|
||||
/// `pts_ns` is the host's capture wall clock (the AU's pts) and the connect-time **clock-skew
|
||||
/// offset** (`PunktfunkConnection.clockOffsetNs`, host minus client) makes the difference valid
|
||||
/// `pts_ns` is the host's capture wall clock (the AU's pts) and the LIVE **clock-skew
|
||||
/// offset** (`PunktfunkConnection.clockOffsetNs`, host minus client, mid-stream re-synced —
|
||||
/// read it per record, never cached) makes the difference valid
|
||||
/// across machines. `offsetNs == 0` means an old host that didn't answer the skew handshake (or
|
||||
/// genuinely synced clocks) — the number is then only meaningful same-host, and the HUD tags the
|
||||
/// end-to-end line `(same-host clock)`.
|
||||
@@ -24,6 +25,8 @@ public final class LatencyMeter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var samplesUs: [Int64] = []
|
||||
private var skewCorrected = false
|
||||
/// Samples `record` refused as impossible since the last `drainTrimmed` (see the guard).
|
||||
private var trimmed = 0
|
||||
/// The most recent sample and the instant it ended, for `latestSample(asOfNs:maxAgeMs:)` —
|
||||
/// a LEVEL, not a window, so `drain` deliberately leaves both alone.
|
||||
private var latestNs: Int64 = 0
|
||||
@@ -49,8 +52,19 @@ public final class LatencyMeter: @unchecked Sendable {
|
||||
public func record(ptsNs: UInt64, atNs: Int64, offsetNs: Int64) {
|
||||
let latNs = atNs &+ offsetNs &- Int64(bitPattern: ptsNs)
|
||||
// Drop absurd values (a clock step, a wildly wrong offset, garbage pts, or a stage whose
|
||||
// start stamp is missing/after its end) — samples are clamped to (0, 10 s).
|
||||
guard latNs > 0, latNs < 10_000_000_000 else { return }
|
||||
// start stamp is missing/after its end) — samples are clamped to (0, 10 s). COUNTED, not
|
||||
// silent: a cluster of non-positive samples is the signature of a wrong clock offset
|
||||
// (client-local stages can't go negative), and a meter that quietly trims the impossible
|
||||
// half of a shifted distribution presents the surviving tail as a plausible small number
|
||||
// — field 2026-08-13: "e2e 0–3 ms p50 / 23 ms p95" on a session whose true hostnet was
|
||||
// ~18 ms. `drainTrimmed` surfaces the count so the window can be MARKED suspect instead
|
||||
// of looking healthy.
|
||||
guard latNs > 0, latNs < 10_000_000_000 else {
|
||||
lock.lock()
|
||||
trimmed += 1
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
lock.lock()
|
||||
samplesUs.append(latNs / 1000)
|
||||
latestNs = latNs
|
||||
@@ -99,6 +113,18 @@ public final class LatencyMeter: @unchecked Sendable {
|
||||
public let skewCorrected: Bool
|
||||
}
|
||||
|
||||
/// Take-and-reset the count of impossible samples `record` refused (see its guard). Drained
|
||||
/// SEPARATELY from `drain()` on purpose: with a badly wrong offset EVERY sample of a window
|
||||
/// can be non-positive, `drain()` then returns `nil` — and a count folded into `Stats` would
|
||||
/// vanish with it, hiding the very windows that scream loudest. This survives an empty window.
|
||||
public func drainTrimmed() -> Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let n = trimmed
|
||||
trimmed = 0
|
||||
return n
|
||||
}
|
||||
|
||||
/// Percentiles over the samples accumulated since the last drain, then reset the window. `nil`
|
||||
/// when no samples arrived in the interval.
|
||||
public func drain() -> Stats? {
|
||||
|
||||
@@ -56,17 +56,27 @@ enum WindowedPresentMode: String, Sendable {
|
||||
/// 203-nit diffuse white at EDR 1.0 (the display's SDR-white level) and lets the system tone-map the
|
||||
/// brighter highlights into the panel's headroom. This is the missing anchor that made the old HDR path
|
||||
/// render "way too bright" (no `edrMetadata` → no reference-white anchoring); a LARGER value renders
|
||||
/// dimmer. Matches the host's standard PQ reference white.
|
||||
/// dimmer.
|
||||
///
|
||||
/// ⚠️ This is one half of a pair: the host has to map SDR content into the PQ container at the SAME
|
||||
/// luminance, and pins it to 203 in `pf-vdisplay`'s `SDR_REFERENCE_WHITE_NITS`. When they disagree
|
||||
/// every pixel is off by the ratio — a gamescope host left on gamescope's own 400-nit default put
|
||||
/// the stream nearly a stop bright, which read as a glaring, over-saturated Steam UI and washed-out
|
||||
/// HDR game content at the same time. Change one end without the other and that gap re-opens.
|
||||
private let hdrReferenceWhiteNits: Float = 203.0
|
||||
|
||||
/// PUNKTFUNK_SDR_COLORSPACE=srgb — A/B hatch for the SDR layer's colour tag. Today the SDR layer
|
||||
/// ships with `colorspace = nil`, which on macOS means NO colour matching: the BT.709/sRGB-encoded
|
||||
/// stream is displayed with the panel's native primaries — mild oversaturation on every P3 Mac.
|
||||
/// `srgb` tags the layer so CoreAnimation colour-matches it into the panel's gamut (the strictly
|
||||
/// correct rendering). Kept OFF by default until the on-glass A/B confirms it (the nil path is the
|
||||
/// long-proven look, and some users may prefer the vivid rendition); flip the default once verified.
|
||||
private let sdrColorspaceOverride: CGColorSpace? = {
|
||||
guard ProcessInfo.processInfo.environment["PUNKTFUNK_SDR_COLORSPACE"] == "srgb" else {
|
||||
/// The SDR layer's colour tag. `colorspace = nil` means NO colour matching: the BT.709-encoded
|
||||
/// stream is handed to the compositor untagged and drawn in the display's native space. That is
|
||||
/// mild oversaturation on a P3 Mac or iPad, and on a tvOS display composited for HDR it also lifts
|
||||
/// the black floor — the 2026-08-13 field report of greys where blacks should be, which arrived
|
||||
/// with the client's own HDR switch already OFF, so no other stage had tagged those pixels either.
|
||||
/// Tagging lets CoreAnimation colour-match into whatever the output actually is, which is the
|
||||
/// strictly correct rendering, so it is now the default.
|
||||
///
|
||||
/// `PUNKTFUNK_SDR_COLORSPACE=none` restores the old untagged look — the A/B lever if a panel
|
||||
/// regresses, or for anyone who preferred the more vivid rendition.
|
||||
private let sdrColorspace: CGColorSpace? = {
|
||||
guard ProcessInfo.processInfo.environment["PUNKTFUNK_SDR_COLORSPACE"] != "none" else {
|
||||
return nil
|
||||
}
|
||||
return CGColorSpace(name: CGColorSpace.sRGB)
|
||||
@@ -425,6 +435,14 @@ public final class MetalVideoPresenter {
|
||||
/// Render-thread confined once the pipeline runs (Stage2Pipeline.start's one pre-thread
|
||||
/// `configure` call is ordered before the thread starts, so it doesn't race).
|
||||
private var hdrActive = false
|
||||
/// Has `configureColor` run even once? `hdrActive` starts `false`, so a session that is SDR from
|
||||
/// the first frame matches the initial state and used to fall straight through `configure`'s
|
||||
/// guard — the layer then kept `make()`'s bare config, which never assigns a colour space, and
|
||||
/// the SDR stream presented untagged for the whole session. That also made
|
||||
/// `PUNKTFUNK_SDR_COLORSPACE` dead code on exactly the sessions it was meant to fix, so an
|
||||
/// operator A/B-ing it in the field saw nothing change. Same-state calls after the first are
|
||||
/// still no-ops, which is what the guard is for.
|
||||
private var didConfigureColor = false
|
||||
/// tvOS only: whether HDR frames currently present as PQ PASSTHROUGH (display has HDR headroom
|
||||
/// — its own tone-map applies) vs the in-shader tone-map fallback. Render-thread confined;
|
||||
/// derived from the staged display headroom at the top of every `render`.
|
||||
@@ -549,6 +567,11 @@ public final class MetalVideoPresenter {
|
||||
layer.contentsGravity = .resizeAspect
|
||||
// Triple-buffer: more in-flight drawables before `nextDrawable()` (called on the display-link /
|
||||
// MAIN thread) has to block waiting for one to free.
|
||||
// ⚠ This is the STAGE-2/3 depth. Stage-4 (deadline pacing, the iOS/tvOS default) never
|
||||
// calls `nextDrawable()` — the link vends every drawable — so the third slot only gives
|
||||
// the compositor room to queue a second present ahead of scanout, i.e. the two-refresh
|
||||
// present floor. `Stage2Pipeline.startDeadlinePresenter` clamps it to 2 for that pacing;
|
||||
// keep the two in step if this number ever changes.
|
||||
layer.maximumDrawableCount = 3
|
||||
|
||||
return MetalVideoPresenter(
|
||||
@@ -592,13 +615,16 @@ public final class MetalVideoPresenter {
|
||||
stagingLock.lock()
|
||||
let passthrough = stagedDisplayHeadroom > 1.0
|
||||
stagingLock.unlock()
|
||||
guard hdr != hdrActive || (hdr && passthrough != hdrPassthroughActive) else { return }
|
||||
guard !didConfigureColor || hdr != hdrActive
|
||||
|| (hdr && passthrough != hdrPassthroughActive)
|
||||
else { return }
|
||||
hdrActive = hdr
|
||||
hdrPassthroughActive = passthrough
|
||||
#else
|
||||
guard hdr != hdrActive else { return }
|
||||
guard !didConfigureColor || hdr != hdrActive else { return }
|
||||
hdrActive = hdr
|
||||
#endif
|
||||
didConfigureColor = true
|
||||
configureColor(hdr: hdr)
|
||||
}
|
||||
|
||||
@@ -628,9 +654,10 @@ public final class MetalVideoPresenter {
|
||||
layer.colorspace = CGColorSpace(name: CGColorSpace.itur_2100_PQ)
|
||||
} else {
|
||||
// SDR-composited display: PQ would render untone-mapped (blown out) — the
|
||||
// pf_frag_hdr_tv shader tone-maps to SDR instead.
|
||||
// pf_frag_hdr_tv shader tone-maps to SDR instead. Its output is BT.709, so it
|
||||
// carries the same SDR tag as a genuinely SDR session.
|
||||
layer.pixelFormat = .bgra8Unorm
|
||||
layer.colorspace = nil
|
||||
layer.colorspace = sdrColorspace
|
||||
}
|
||||
#else
|
||||
layer.pixelFormat = .rgba16Float
|
||||
@@ -641,12 +668,11 @@ public final class MetalVideoPresenter {
|
||||
layer.edrMetadata = makeEDR(lastHdrMeta)
|
||||
#endif
|
||||
} else {
|
||||
// SDR: gamma-encoded BT.709 [0,1] in an 8-bit drawable. Default: nil colorspace = NO
|
||||
// colour matching on macOS (the panel's native primaries — the long-proven look,
|
||||
// slightly oversaturated on P3 panels); PUNKTFUNK_SDR_COLORSPACE=srgb tags the layer
|
||||
// for correct colour matching instead (A/B pending — see sdrColorspaceOverride).
|
||||
// SDR: gamma-encoded BT.709 [0,1] in an 8-bit drawable, tagged so CoreAnimation
|
||||
// colour-matches it into the output rather than drawing it in the panel's native
|
||||
// space (see sdrColorspace; PUNKTFUNK_SDR_COLORSPACE=none restores untagged).
|
||||
layer.pixelFormat = .bgra8Unorm
|
||||
layer.colorspace = sdrColorspaceOverride
|
||||
layer.colorspace = sdrColorspace
|
||||
#if !os(tvOS)
|
||||
layer.wantsExtendedDynamicRangeContent = false
|
||||
layer.edrMetadata = nil
|
||||
|
||||
@@ -260,13 +260,22 @@ final class SessionPresenter {
|
||||
// value is deliberately ignored). The user-facing choice is the INTENT
|
||||
// (PresentPriority): latency (newest-wins zero-queue store) vs smoothness (a FIFO jitter
|
||||
// buffer; on macOS it additionally paces presents onto the vsync grid so the buffer
|
||||
// drains on display cadence). Stage-1 is reachable only via env in DEBUG; release maps
|
||||
// it back to the default (the stage-1 pump below stays the automatic Metal-missing
|
||||
// fallback).
|
||||
// drains on display cadence). Stage-1 resolves from the persisted picker only in DEBUG;
|
||||
// in release the ENV alone reaches it (the stage-1 pump below stays the automatic
|
||||
// Metal-missing fallback either way).
|
||||
#if DEBUG
|
||||
let allowStage1 = true
|
||||
#else
|
||||
let allowStage1 = false
|
||||
// The gate exists so a LEFTOVER value can't revive the freeze-prone fallback — but the
|
||||
// persisted picker is no longer read at all (setting: nil below), so the only channel
|
||||
// left is the env, and an env var is never leftover: it takes a devicectl/Xcode launch
|
||||
// to exist. It must stay openable on Release because Release is the only build that
|
||||
// measures presentation honestly, and stage-1 is the one rung that presents on the
|
||||
// hardware video plane instead of through the GPU compositor — the A/B for the tvOS
|
||||
// two-refresh present floor (field 2026-08-13: PUNKTFUNK_PRESENTER=stage1 on a Release
|
||||
// build silently ran stage-4, which would have false-negatived that A/B).
|
||||
let allowStage1 =
|
||||
ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"] == "stage1"
|
||||
#endif
|
||||
let explicit = PresenterChoice.explicit(
|
||||
setting: nil, // the legacy DefaultsKey.presenter picker value is no longer read
|
||||
@@ -336,7 +345,7 @@ final class SessionPresenter {
|
||||
} else {
|
||||
let pump = StreamPump()
|
||||
pump.start(
|
||||
connection: connection, layer: baseLayer,
|
||||
connection: connection, layer: baseLayer, endToEndMeter: endToEndMeter,
|
||||
onFrame: onFrame, onSessionEnd: onSessionEnd, onDecodedSize: onDecodedSize)
|
||||
self.pump = pump
|
||||
}
|
||||
|
||||
@@ -271,6 +271,64 @@ final class LatestBox<T>: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// The deadline link's frame-latency ASK and property READBACK, published for the HUD to render.
|
||||
///
|
||||
/// ⚠ A readback is NOT a grant. `preferredFrameLatency` is a plain read-write float
|
||||
/// (CAMetalDisplayLink.h carries no doc contract), so reading it returns whatever we last
|
||||
/// stored unless the system actively clamps the setter — and the 2026-08-13 field run proved
|
||||
/// how misleading that is: it read 1.00 while the measured vend lead sat at 1.95 refresh
|
||||
/// periods. The number that tells the truth about scheduling is the vend lead (the HUD's
|
||||
/// `os present` floor), never this property. The line still earns its place twice over: a
|
||||
/// readback that DIFFERS from the ask is the one clamp signal the API can give, and the ask
|
||||
/// must be visible on screen because **on tvOS no log is reachable** — `log stream --device`
|
||||
/// is gone from modern macOS, `log collect --device-name` needs root and then fails "Device
|
||||
/// not configured" because an Apple TV has no USB to fall back to, and the libimobiledevice
|
||||
/// pairing is a different database from Xcode's. Console.app is a GUI.
|
||||
///
|
||||
/// A process-global rather than a sixth parameter threaded through SessionModel → StreamView →
|
||||
/// controller → SessionPresenter → Stage2Pipeline → delegate: it is write-once-per-session
|
||||
/// diagnostics, and this file already keeps `presentDebug`/`presentLog` at file scope. Reset by
|
||||
/// `clear()` at session start so a stale session's answer can never be read as this one's.
|
||||
public final class PresentLinkInfo: @unchecked Sendable {
|
||||
public static let shared = PresentLinkInfo()
|
||||
private let lock = NSLock()
|
||||
private var ask: Float = 0
|
||||
private var latency: Float = 0
|
||||
private var rangeMin: Float = 0
|
||||
private var rangeMax: Float = 0
|
||||
private var drawables: Int = 0
|
||||
private var present = false
|
||||
|
||||
private init() {}
|
||||
|
||||
func publish(ask: Float, latency: Float, rangeMin: Float, rangeMax: Float, drawables: Int) {
|
||||
lock.lock()
|
||||
self.ask = ask
|
||||
self.latency = latency
|
||||
self.rangeMin = rangeMin
|
||||
self.rangeMax = rangeMax
|
||||
self.drawables = drawables
|
||||
present = true
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Session start — a link that never comes up must not leave the previous one's answer up.
|
||||
public func clear() {
|
||||
lock.lock()
|
||||
present = false
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// `nil` until the link's first update (or on a non-deadline rung, which has no link).
|
||||
public func snapshot()
|
||||
-> (ask: Float, latency: Float, rangeMin: Float, rangeMax: Float, drawables: Int)?
|
||||
{
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return present ? (ask, latency, rangeMin, rangeMax, drawables) : nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Deadline pacing's staged frame-rate hint. SessionPresenter pushes the stream rate from the
|
||||
/// MAIN thread (session start + every layout/Reconfigure); the link's own thread drains and
|
||||
/// applies it, so the CAMetalDisplayLink is only ever touched from the thread that runs it. The
|
||||
@@ -312,9 +370,24 @@ private final class FrameRateHint: @unchecked Sendable {
|
||||
return p
|
||||
}
|
||||
private static func range(hz: Float, boosted: Bool) -> CAFrameRateRange {
|
||||
#if os(tvOS)
|
||||
// A TV is a FIXED-rate display: there is no ProMotion panel to lift and no Pencil to
|
||||
// sample for, so the `max(hz, 120)` ceiling below asks a 60 Hz Apple TV to accept
|
||||
// anything up to 120. A range is a promise about how variable our cadence may be, and a
|
||||
// scheduler handed 60…120 on a fixed 60 Hz display has every reason to keep a frame of
|
||||
// slack in hand — which is what a two-refresh `targetPresentationTimestamp` IS. Pin all
|
||||
// three bounds to the stream rate so the deadline has nothing to hedge against.
|
||||
// (Field 2026-08-13, Apple TV 4K / tvOS 27: `os present` stuck at ~2 × 16.67 with
|
||||
// `preferredFrameLatency = 1` asked for and re-asserted every update; shrinking the
|
||||
// drawable pool to 2 moved it not at all.) `boosted` is deliberately ignored — it exists
|
||||
// for pen proximity, which tvOS does not have.
|
||||
_ = boosted
|
||||
return CAFrameRateRange(minimum: hz, maximum: hz, preferred: hz)
|
||||
#else
|
||||
let cap = max(hz, 120)
|
||||
let preferred = boosted ? cap : hz
|
||||
return CAFrameRateRange(minimum: preferred, maximum: cap, preferred: preferred)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,18 +508,28 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
|
||||
private let phase: PhaseReporter?
|
||||
/// The OS-floor sampler (design/apple-presentation-rebuild.md): every update's vend→glass
|
||||
/// lead is recorded so its p50 becomes the "OS present floor" the HUD subtracts from the
|
||||
/// shown display/e2e numbers. Self-adapting — reads ~2 refresh periods composited today,
|
||||
/// would read ~1 under direct-to-display, tracks VRR rate changes.
|
||||
/// shown display/e2e numbers. Self-adapting: ~1 refresh period is the goal, ~2 means the
|
||||
/// compositor is running a frame ahead of us (what a 3-slot drawable pool bought it before
|
||||
/// `startDeadlinePresenter` clamped stage-4 to 2). Tracks VRR rate changes.
|
||||
private let floorMeter: LatencyMeter?
|
||||
/// One-shot: log the link's EFFECTIVE preferredFrameLatency after the first re-assert —
|
||||
/// reads 1 while vendLeadMs sits at ~2 periods ⇒ the scheduler ignores the request while
|
||||
/// the layer is composited (the promotion hunt); reads 2 ⇒ the system clamped it outright.
|
||||
/// The pool depth this session vends from (`startDeadlinePresenter` sets it on the layer).
|
||||
/// Carried only so the one-shot line below reports the two halves of the depth question
|
||||
/// together — a `preferredFrameLatency` of 1 against a 3-slot pool is the configuration that
|
||||
/// measured a two-refresh floor in the field, and reading either number alone hides that.
|
||||
private let drawableCount: Int
|
||||
/// The `preferredFrameLatency` this session asks for — 1 by default, PUNKTFUNK_FRAME_LATENCY
|
||||
/// for the on-device ladder (see `startDeadlinePresenter` for the ladder's design).
|
||||
private let latencyAsk: Float
|
||||
/// One-shot: log the link's preferredFrameLatency READBACK after the first re-assert. A
|
||||
/// readback differing from the ask ⇒ the system clamps the property (the one clamp signal
|
||||
/// it can give); a readback EQUAL to the ask proves nothing — only vendLeadMs does (see
|
||||
/// PresentLinkInfo's doc for the field lesson).
|
||||
private var loggedEffective = false
|
||||
|
||||
init(
|
||||
stash: LatestBox<CAMetalDrawable>, renderSignal: DispatchSemaphore,
|
||||
hint: FrameRateHint, stats: PresentDebugStats?, floorMeter: LatencyMeter?,
|
||||
phase: PhaseReporter?
|
||||
phase: PhaseReporter?, drawableCount: Int, latencyAsk: Float
|
||||
) {
|
||||
self.stash = stash
|
||||
self.renderSignal = renderSignal
|
||||
@@ -454,23 +537,34 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
|
||||
self.stats = stats
|
||||
self.floorMeter = floorMeter
|
||||
self.phase = phase
|
||||
self.drawableCount = drawableCount
|
||||
self.latencyAsk = latencyAsk
|
||||
}
|
||||
|
||||
func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) {
|
||||
if let range = hint.drain(), link.preferredFrameRateRange != range {
|
||||
link.preferredFrameRateRange = range
|
||||
}
|
||||
// Re-assert the minimum-latency request every update (cheap compare): it was set once
|
||||
// before add(to:), and whether a pre-add set survives scheduling is exactly the kind of
|
||||
// Re-assert the latency ask every update (cheap compare): it was set once before
|
||||
// add(to:), and whether a pre-add set survives scheduling is exactly the kind of
|
||||
// thing the vendLeadMs stat exists to catch — belt and braces.
|
||||
if link.preferredFrameLatency != 1 { link.preferredFrameLatency = 1 }
|
||||
if link.preferredFrameLatency != latencyAsk { link.preferredFrameLatency = latencyAsk }
|
||||
// Publish every update, not just the first: the range is re-applied from the staged hint
|
||||
// above (mode switch / rate change), and `preferredFrameLatency` is re-asserted right
|
||||
// here — so the readback can change mid-session, and a write-once snapshot would keep
|
||||
// showing the answer to a question we have since asked again. Cheap: five stores under
|
||||
// an uncontended lock, once per refresh.
|
||||
let range = link.preferredFrameRateRange
|
||||
PresentLinkInfo.shared.publish(
|
||||
ask: latencyAsk, latency: link.preferredFrameLatency, rangeMin: range.minimum,
|
||||
rangeMax: range.maximum, drawables: drawableCount)
|
||||
if !loggedEffective {
|
||||
loggedEffective = true
|
||||
let range = link.preferredFrameRateRange
|
||||
let msg = String(
|
||||
format: "deadline link up: effective preferredFrameLatency=%.2f "
|
||||
+ "range=%.0f-%.0f preferred=%.0f",
|
||||
link.preferredFrameLatency, range.minimum, range.maximum, range.preferred ?? 0)
|
||||
format: "deadline link up: preferredFrameLatency ask=%.2f readback=%.2f "
|
||||
+ "maxDrawables=%d range=%.0f-%.0f preferred=%.0f",
|
||||
latencyAsk, link.preferredFrameLatency, drawableCount,
|
||||
range.minimum, range.maximum, range.preferred ?? 0)
|
||||
presentLog.info("\(msg, privacy: .public)")
|
||||
}
|
||||
// The link's own pipeline depth, measured: how far ahead of glass this vend runs.
|
||||
@@ -729,7 +823,13 @@ public final class Stage2Pipeline {
|
||||
/// (which withhold concealed frames) and driven by the pump (arm on a gap, poll per iteration).
|
||||
private let gate = ReanchorGate(framesDropped: 0)
|
||||
private var token = StopFlag()
|
||||
private var offsetNs: Int64 = 0
|
||||
/// LIVE host↔client clock offset, read AT EACH RECORD — never cached per session. Until
|
||||
/// 2026-08-13 this was a `let` snapshot of the connect-time handshake, and on a host whose
|
||||
/// wall clock steps (a VM under NTP) the frozen value silently shifted every host-anchored
|
||||
/// stat — field evidence: hostnet 17–21 ms one session, a physically impossible 4.4 ms the
|
||||
/// next, same wired host. The core re-syncs the estimate mid-stream (60 s + step detection);
|
||||
/// each call is an atomic load behind the FFI.
|
||||
private var clockOffset: () -> Int64 = { 0 }
|
||||
/// Signalled when the pump thread exits, so `stop()` can join it (bounded) before `decoder.reset()`
|
||||
/// — otherwise a pump iteration already past its `token.isStopped` check can rebuild a decode session
|
||||
/// right after the reset (a brief orphan session). `pumpJoinable` is armed by `start`, consumed by
|
||||
@@ -831,7 +931,7 @@ public final class Stage2Pipeline {
|
||||
onSessionEnd: (@Sendable () -> Void)?,
|
||||
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
|
||||
) {
|
||||
offsetNs = connection.clockOffsetNs
|
||||
clockOffset = { connection.clockOffsetNs } // live (re-synced) — see the field doc
|
||||
recovery.bind(connection) // arm host-keyframe recovery for this session
|
||||
decodeReport.bind(connection) // arm the Automatic-bitrate decode signal for this session
|
||||
phaseReporter.bind(connection) // arm phase reports (flushed only by the deadline link)
|
||||
@@ -1001,7 +1101,7 @@ public final class Stage2Pipeline {
|
||||
let ring = ring
|
||||
let endToEndMeter = endToEndMeter
|
||||
let displayMeter = displayMeter
|
||||
let offsetNs = offsetNs
|
||||
let clockOffset = clockOffset
|
||||
let renderSignal = renderSignal
|
||||
let renderStopped = renderStopped
|
||||
// Present policy — the user's V-Sync setting (default OFF = immediate, the long-proven
|
||||
@@ -1075,7 +1175,7 @@ public final class Stage2Pipeline {
|
||||
?? Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime())
|
||||
// End-to-end = capture→on-glass, measured directly (skew-corrected via the
|
||||
// connect-time clock offset) — the HUD headline.
|
||||
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: offsetNs)
|
||||
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: clockOffset())
|
||||
// Display stage = decoded → on-glass. Both instants are client CLOCK_REALTIME,
|
||||
// so no skew offset applies.
|
||||
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
|
||||
@@ -1134,11 +1234,52 @@ public final class Stage2Pipeline {
|
||||
let presenter = presenter
|
||||
let endToEndMeter = endToEndMeter
|
||||
let displayMeter = displayMeter
|
||||
let offsetNs = offsetNs
|
||||
let clockOffset = clockOffset
|
||||
let hint = frameRateHint
|
||||
let layer = presenter.layer
|
||||
let stash = LatestBox<CAMetalDrawable>()
|
||||
|
||||
// ⭐ Shrink the drawable pool to 2 for THIS pacing — the measured fix for a present floor
|
||||
// stuck at two refreshes (field 2026-08-13, Apple TV 4K / tvOS 27: `os present +32.5` at
|
||||
// 60 Hz = 1.95 × 16.67, i.e. the system running a whole frame ahead of us).
|
||||
//
|
||||
// `maximumDrawableCount` is 3 from MetalVideoPresenter.make(), and its rationale there —
|
||||
// "more in-flight drawables before nextDrawable() has to block" — is a STAGE-2 concern.
|
||||
// Stage-4 never calls nextDrawable(): every drawable is vended by the link
|
||||
// (`update.drawable` → stash → `render(into:)`), so the third slot buys this path nothing
|
||||
// and costs it a refresh — a pool of 3 is exactly the room the compositor needs to keep
|
||||
// two presents queued ahead of scanout, which is what `preferredFrameLatency = 1` is
|
||||
// asking it not to do. Two slots is the shallowest pool that still double-buffers: one
|
||||
// vended (stashed or being rendered), one being scanned out.
|
||||
//
|
||||
// Set HERE, not on the link thread: this runs before either the render thread or the link
|
||||
// thread exists, so the layer still has a single writer (the render thread owns
|
||||
// drawableSize/format afterwards — see MetalVideoPresenter's threading notes).
|
||||
// PUNKTFUNK_DRAWABLE_COUNT=3 restores the old depth for an on-glass A/B without a
|
||||
// rebuild; values outside 2...3 are ignored (CAMetalLayer's own accepted range).
|
||||
let drawableCount =
|
||||
ProcessInfo.processInfo.environment["PUNKTFUNK_DRAWABLE_COUNT"]
|
||||
.flatMap(Int.init)
|
||||
.flatMap { (2...3).contains($0) ? $0 : nil } ?? 2
|
||||
layer.maximumDrawableCount = drawableCount
|
||||
|
||||
// The frame-latency ASK (default 1 — wake as late as fits: latch the NEXT refresh).
|
||||
// PUNKTFUNK_FRAME_LATENCY overrides it for the on-device ladder. The property is a
|
||||
// FLOAT, so sub-frame asks (0.5) are expressible; whether the scheduler honours them —
|
||||
// or reacts to the property at all — is exactly what the ladder measures. Field
|
||||
// 2026-08-13 (Apple TV 4K, tvOS 27): ask 1 → vend lead 1.95 refresh periods, and the
|
||||
// readback echoed the ask throughout (it is a plain property — see PresentLinkInfo).
|
||||
// The discriminating runs, watching `os present` (the vend lead), are:
|
||||
// ask=2 → lead grows to ~3 ⇒ the property WORKS and the tvOS floor is ~ask+1;
|
||||
// lead stays ~2 ⇒ the property is INERT here — stop pulling this lever.
|
||||
// ask=0.5 → any lead below ~1.9 ⇒ a real in-regime win to then tune.
|
||||
// Clamped to 0...4: negatives/NaN are meaningless, and beyond 4 asked-for frames of
|
||||
// latency nothing is being measured.
|
||||
let latencyAsk =
|
||||
ProcessInfo.processInfo.environment["PUNKTFUNK_FRAME_LATENCY"]
|
||||
.flatMap(Float.init)
|
||||
.flatMap { $0.isFinite ? min(max($0, 0), 4) : nil } ?? 1
|
||||
|
||||
let floorMeter = presentFloorMeter
|
||||
let phaseReporter = phaseReporter
|
||||
// The link starts LAZILY — the render thread triggers this after the FIRST decoded
|
||||
@@ -1151,9 +1292,10 @@ public final class Stage2Pipeline {
|
||||
let linkThread = Thread {
|
||||
let delegate = DeadlineLinkDelegate(
|
||||
stash: stash, renderSignal: renderSignal, hint: hint, stats: debugStats,
|
||||
floorMeter: floorMeter, phase: phaseReporter)
|
||||
floorMeter: floorMeter, phase: phaseReporter,
|
||||
drawableCount: drawableCount, latencyAsk: latencyAsk)
|
||||
let link = CAMetalDisplayLink(metalLayer: layer)
|
||||
link.preferredFrameLatency = 1 // wake as late as fits: latch the NEXT refresh
|
||||
link.preferredFrameLatency = latencyAsk // see the ladder note above
|
||||
if let range = hint.drain() { link.preferredFrameRateRange = range }
|
||||
link.delegate = delegate // weak — this closure is the strong ref
|
||||
link.add(to: RunLoop.current, forMode: .default)
|
||||
@@ -1223,7 +1365,7 @@ public final class Stage2Pipeline {
|
||||
let onGlass: (Int64?) -> Void = { presentedNs in
|
||||
let atNs = presentedNs
|
||||
?? Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime())
|
||||
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: offsetNs)
|
||||
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: clockOffset())
|
||||
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
|
||||
debugStats?.presented(atNs: presentedNs, issuedNs: issuedNs)
|
||||
}
|
||||
|
||||
@@ -17,9 +17,18 @@ final class StreamPump {
|
||||
|
||||
/// Pump thread: pull AUs, wrap, enqueue. Non-IDR AUs before the first format
|
||||
/// description are dropped. `onFrame`/`onSessionEnd` fire on the pump thread.
|
||||
///
|
||||
/// `endToEndMeter` is stage-1's ONLY latency instrument, and it measures capture→ENQUEUE —
|
||||
/// not capture→glass like the Metal rungs: the layer decodes AND presents after our hand-off,
|
||||
/// and AVSampleBufferDisplayLayer has no presented callback, so the tail past enqueue (its
|
||||
/// internal decode + the video-plane flip) is unmeasurable from the app. Cross-rung
|
||||
/// comparisons must read this as e2e MINUS decode+display and settle the remainder on
|
||||
/// camera. It is still worth wiring: matching pre-tail halves between rungs pins any felt
|
||||
/// difference on the present tail — the video-plane-vs-compositor question itself.
|
||||
func start(
|
||||
connection: PunktfunkConnection,
|
||||
layer: AVSampleBufferDisplayLayer,
|
||||
endToEndMeter: LatencyMeter? = nil,
|
||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||
onSessionEnd: (@Sendable () -> Void)?,
|
||||
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
|
||||
@@ -158,7 +167,14 @@ final class StreamPump {
|
||||
// flagging it DoNotDisplay — the layer still decodes it (keeping the reference
|
||||
// chain fed) but shows the last GOOD picture until a clean re-anchor lifts the
|
||||
// gate. Folded from the AU's wire flags (stage-1 has no decode callback).
|
||||
if !gate.onDecoded(flags: au.flags) {
|
||||
if gate.onDecoded(flags: au.flags) {
|
||||
// Capture→enqueue (see start's doc). Only frames that will DISPLAY:
|
||||
// a withheld frame never reaches glass, so its enqueue instant would
|
||||
// dilute the population the Metal rungs are compared against. The
|
||||
// offset is read PER ENQUEUE — it is live (mid-stream re-synced) and
|
||||
// caching it rebuilds the stale-offset corruption (see clockOffsetNs).
|
||||
endToEndMeter?.record(ptsNs: au.ptsNs, offsetNs: connection.clockOffsetNs)
|
||||
} else {
|
||||
StreamPump.setDoNotDisplay(sample)
|
||||
}
|
||||
layer.enqueue(sample)
|
||||
|
||||
@@ -758,10 +758,18 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
/// the switch never lands, so an SDR-composited display can't show blown-out PQ either way.
|
||||
/// Applied once per session, as soon as the window and the negotiated mode both exist; the
|
||||
/// stop() teardown clears it.
|
||||
///
|
||||
/// ⚠️ Gated on the STREAM being HDR (`connection.isHDR`), not just on the user's HDR setting.
|
||||
/// The criteria below hardcode BT.2020 + ST.2084 PQ, so without that check an ordinary SDR
|
||||
/// session drove an HDR-capable TV into PQ output — which is a standard way to raise the black
|
||||
/// floor, since the Apple TV switches HDMI to limited-range levels in its HDR modes and a set
|
||||
/// configured for full-range then renders code 16 as grey. Layout re-runs this, so a session
|
||||
/// that flips to HDR mid-stream still picks the mode up on the next pass.
|
||||
private func applyDisplayCriteriaIfNeeded() {
|
||||
guard let manager = view.window?.avDisplayManager, let connection,
|
||||
manager.preferredDisplayCriteria == nil,
|
||||
SessionSettings.current.hdrEnabled
|
||||
SessionSettings.current.hdrEnabled,
|
||||
connection.isHDR
|
||||
else { return }
|
||||
let mode = connection.currentMode()
|
||||
guard mode.width > 0, mode.height > 0, mode.refreshHz > 0 else { return }
|
||||
|
||||
@@ -32,7 +32,7 @@ final class AudioDeviceWatcherTests: XCTestCase {
|
||||
let engine = AVAudioEngine()
|
||||
var reasons: [AudioDeviceWatcher.Reason] = []
|
||||
let watcher = AudioDeviceWatcher(
|
||||
isOurs: { $0 === engine }, onChange: { reasons.append($0) })
|
||||
isOurs: { $0 === engine }, onChange: { reason, _ in reasons.append(reason) })
|
||||
watcher.start()
|
||||
defer { watcher.stop() }
|
||||
|
||||
@@ -51,7 +51,7 @@ final class AudioDeviceWatcherTests: XCTestCase {
|
||||
let stranger = AVAudioEngine()
|
||||
var reasons: [AudioDeviceWatcher.Reason] = []
|
||||
let watcher = AudioDeviceWatcher(
|
||||
isOurs: { $0 === ours }, onChange: { reasons.append($0) })
|
||||
isOurs: { $0 === ours }, onChange: { reason, _ in reasons.append(reason) })
|
||||
watcher.start()
|
||||
defer { watcher.stop() }
|
||||
|
||||
@@ -66,7 +66,7 @@ final class AudioDeviceWatcherTests: XCTestCase {
|
||||
let engine = AVAudioEngine()
|
||||
var reasons: [AudioDeviceWatcher.Reason] = []
|
||||
let watcher = AudioDeviceWatcher(
|
||||
isOurs: { $0 === engine }, onChange: { reasons.append($0) })
|
||||
isOurs: { $0 === engine }, onChange: { reason, _ in reasons.append(reason) })
|
||||
watcher.start()
|
||||
watcher.stop()
|
||||
|
||||
@@ -93,7 +93,7 @@ final class AudioDeviceWatcherTests: XCTestCase {
|
||||
}
|
||||
|
||||
var reasons: [AudioDeviceWatcher.Reason] = []
|
||||
let watcher = AudioDeviceWatcher(isOurs: { _ in false }, onChange: { reasons.append($0) })
|
||||
let watcher = AudioDeviceWatcher(isOurs: { _ in false }, onChange: { reason, _ in reasons.append(reason) })
|
||||
watcher.start()
|
||||
defer {
|
||||
_ = Self.setDefaultOutput(original)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// The two decisions that ended the 2026-08-14 rebuild loop, driven with a synthetic clock.
|
||||
//
|
||||
// The loop's shape, for the plant-the-defect cases below: the voice-processing engine fails to
|
||||
// start (~1.9 s spent trying), the fallback comes up, and its own HAL fallout retriggers the
|
||||
// recovery ~0.6 s later — forever. Restore either defect (retry the failed topology, or keep the
|
||||
// flat 0.5 s floor) and the session pays an audio gap every ~2.5 s for as long as it lives.
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class AudioRebuildPolicyTests: XCTestCase {
|
||||
// MARK: - RebuildBackoff
|
||||
|
||||
/// The first trigger of a session keeps the old behaviour: the burst-coalescing debounce.
|
||||
func testFirstTriggerWaitsOnlyTheDebounce() {
|
||||
var backoff = RebuildBackoff()
|
||||
XCTAssertEqual(backoff.delay(now: 1000), RebuildBackoff.debounce)
|
||||
}
|
||||
|
||||
/// One rebuild, then quiet: the next real device switch minutes later is answered at full
|
||||
/// responsiveness — the ladder must never make a HEALTHY recovery sluggish.
|
||||
func testAnIsolatedSwitchLongAfterTheLastRebuildResetsTheChain() {
|
||||
var backoff = RebuildBackoff()
|
||||
_ = backoff.delay(now: 1000)
|
||||
backoff.noteRebuild(at: 1000.2)
|
||||
// Chained once (a second switch soon after — legitimate, e.g. AirPods out then back in).
|
||||
_ = backoff.delay(now: 1001)
|
||||
backoff.noteRebuild(at: 1002)
|
||||
// Minutes of quiet, then a fresh switch: base debounce again, chain forgotten.
|
||||
XCTAssertEqual(backoff.delay(now: 1300), RebuildBackoff.debounce)
|
||||
XCTAssertEqual(backoff.chain, 0)
|
||||
}
|
||||
|
||||
/// THE FIELD LOOP, against the real constants: a trigger 0.6 s after every rebuild, ten
|
||||
/// minutes long. The flat 0.5 s floor produced a rebuild every ~2.5 s — ~240 audio gaps.
|
||||
/// The ladder must cut that by an order of magnitude and settle at the floor cap.
|
||||
func testAChainedLoopBacksOffToTheFloorCap() {
|
||||
var backoff = RebuildBackoff()
|
||||
var now: TimeInterval = 0
|
||||
var rebuilds = 0
|
||||
var lastDelay: TimeInterval = 0
|
||||
let end: TimeInterval = 600
|
||||
while now < end {
|
||||
lastDelay = backoff.delay(now: now)
|
||||
now += lastDelay // the scheduled rebuild fires...
|
||||
backoff.noteRebuild(at: now)
|
||||
rebuilds += 1
|
||||
now += 0.6 // ...and its fallout retriggers the recovery 0.6 s later.
|
||||
}
|
||||
XCTAssertEqual(
|
||||
lastDelay, RebuildBackoff.floorCap - 0.6, accuracy: 0.01,
|
||||
"a persistent loop should settle at one rebuild per floorCap")
|
||||
XCTAssertLessThanOrEqual(
|
||||
rebuilds, 30,
|
||||
"\(rebuilds) rebuilds in 10 min — the ladder is not escalating (the shipped flat "
|
||||
+ "floor produced ~240)")
|
||||
// And the loop's END must restore responsiveness: quiet, then a real switch.
|
||||
XCTAssertEqual(backoff.delay(now: now + 120), RebuildBackoff.debounce)
|
||||
}
|
||||
|
||||
/// The ladder's exponent is clamped — a loop that runs for hours must neither overflow nor
|
||||
/// push the interval past the cap.
|
||||
func testTheFloorNeverExceedsTheCap() {
|
||||
var backoff = RebuildBackoff()
|
||||
var now: TimeInterval = 0
|
||||
for _ in 0..<1000 {
|
||||
let delay = backoff.delay(now: now)
|
||||
XCTAssertLessThanOrEqual(delay, RebuildBackoff.floorCap)
|
||||
now += delay
|
||||
backoff.noteRebuild(at: now)
|
||||
now += 0.1
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
// MARK: - CombinedTopologyGate
|
||||
|
||||
/// The loop's fuel: re-attempting the voice-processing start that just failed. Same input
|
||||
/// device ⇒ never again.
|
||||
func testAFailureLatchesForTheDeviceItFailedOn() {
|
||||
var gate = CombinedTopologyGate()
|
||||
XCTAssertTrue(gate.shouldTry(input: 42), "an unfailed gate must allow the attempt")
|
||||
gate.noteFailure(input: 42)
|
||||
XCTAssertFalse(gate.shouldTry(input: 42))
|
||||
XCTAssertFalse(gate.shouldTry(input: 42), "the latch must hold across rebuilds")
|
||||
}
|
||||
|
||||
/// The failure is a property of the DEVICE: a different default input earns a fresh attempt,
|
||||
/// and its own failure latches again — one attempt per device change can never loop.
|
||||
func testADifferentInputDeviceEarnsOneFreshAttempt() {
|
||||
var gate = CombinedTopologyGate()
|
||||
gate.noteFailure(input: 42)
|
||||
XCTAssertTrue(gate.shouldTry(input: 7))
|
||||
gate.noteFailure(input: 7)
|
||||
XCTAssertFalse(gate.shouldTry(input: 7))
|
||||
// Back to the first device: the earlier failure may have been mid-transition — one fresh
|
||||
// attempt again, not a permanent ban.
|
||||
XCTAssertTrue(gate.shouldTry(input: 42))
|
||||
}
|
||||
|
||||
/// "No resolvable input device" is a real failure key too, distinct from "never failed".
|
||||
func testFailingWithNoInputDeviceLatchesForNoInputDevice() {
|
||||
var gate = CombinedTopologyGate()
|
||||
gate.noteFailure(input: nil)
|
||||
XCTAssertFalse(gate.shouldTry(input: nil))
|
||||
XCTAssertTrue(gate.shouldTry(input: 42), "a device appearing is a device change")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -180,6 +180,23 @@ pub struct Stats {
|
||||
/// decoder's submission returning in ~0.1 ms is not "decoded"); software measures
|
||||
/// the synchronous CPU decode.
|
||||
pub decode_ms: f32,
|
||||
/// Whether `decode_ms` OVERLAPS the presenter's `display` stage instead of tiling
|
||||
/// with it — true on the asynchronous native-Vulkan rung, false everywhere else.
|
||||
///
|
||||
/// The other stages are a per-frame partition of `e2e`: `pts →(host+net)→ received
|
||||
/// →(decode)→ decoded →(display)→ displayed`. That holds while `decoded` is a
|
||||
/// COMPLETION stamp, which it is on the synchronous rungs. On the native-Vulkan rung
|
||||
/// `receive_frame` returns at SUBMISSION (~0.1 ms) and the stamp shipped to the
|
||||
/// presenter is taken there, so the GPU decode happens INSIDE the `display` stage —
|
||||
/// `host+net` and `display` already tile `e2e` between them, and `decode` (measured
|
||||
/// received → fence-complete) re-counts the GPU work that `display` contains.
|
||||
///
|
||||
/// A 2026-08-13 field report read the row as a breakdown and asked why the parts did
|
||||
/// not add up: `host 5.4 · net 0.3 · decode 6.6 · display 1.4` against `e2e 8.1`. They
|
||||
/// do add up — without `decode` (5.4 + 0.3 + 1.4 ≈ 8.1). The figure is a true reading
|
||||
/// of a real quantity sitting in a row that reads like a partition, so the OSD renders
|
||||
/// it off that line rather than beside stages it does not tile with.
|
||||
pub decode_overlaps_display: bool,
|
||||
/// Unrecoverable network frame drops this window, and their share of
|
||||
/// received+lost (%). The OSD renders the counter line only when nonzero.
|
||||
pub lost: u32,
|
||||
@@ -770,6 +787,10 @@ fn pump(
|
||||
// corrected), `decode` = received→decoded (client-local). p50 per 1 s window.
|
||||
let mut hostnet_us: Vec<u64> = Vec::with_capacity(256);
|
||||
let mut decode_us: Vec<u64> = Vec::with_capacity(256);
|
||||
// Whether this window's decode samples came from the async (submission-stamped) rung, so
|
||||
// the OSD keeps them off the partition line. Latches per window alongside the samples,
|
||||
// rather than being read off the rung name — a demote mid-window changes both together.
|
||||
let mut decode_overlaps = false;
|
||||
// Adaptive bitrate: report the decode stage back to the core controller only when it's armed
|
||||
// (Automatic, non-PyroWave). Constant for the session — resolve once, gate the per-frame call.
|
||||
let wants_decode = connector.wants_decode_latency();
|
||||
@@ -1118,6 +1139,12 @@ fn pump(
|
||||
// `decode` stage: received→decode COMPLETE, single clock.
|
||||
match hw_fence {
|
||||
Some((sem, value)) => {
|
||||
// A fence means `decoded_ns` above was stamped at SUBMISSION, so
|
||||
// the GPU decode lands inside the presenter's `display` stage and
|
||||
// this figure re-counts it: it does NOT tile with the others.
|
||||
// Recorded so the OSD can render it off the partition line
|
||||
// (`Stats::decode_overlaps_display`).
|
||||
decode_overlaps = true;
|
||||
if decode_us.is_empty()
|
||||
&& decoder.wait_hw_decoded(sem, value, 50_000_000)
|
||||
{
|
||||
@@ -1433,6 +1460,7 @@ fn pump(
|
||||
host_pace_ms: pace_p50 as f32 / 1000.0,
|
||||
staged,
|
||||
decode_ms: dec_p50 as f32 / 1000.0,
|
||||
decode_overlaps_display: decode_overlaps,
|
||||
lost,
|
||||
lost_pct: if lost > 0 {
|
||||
lost as f32 * 100.0 / (frames_n + lost) as f32
|
||||
@@ -1461,6 +1489,7 @@ fn pump(
|
||||
bytes_n = 0;
|
||||
hostnet_us.clear();
|
||||
decode_us.clear();
|
||||
decode_overlaps = false;
|
||||
host_us_win.clear();
|
||||
net_us_win.clear();
|
||||
queue_us_win.clear();
|
||||
|
||||
@@ -245,11 +245,22 @@ impl Overlay for SkiaOverlay {
|
||||
shared.queue_family_index as usize,
|
||||
),
|
||||
&get_proc,
|
||||
// `None` leaves Skia's `fMaxAPIVersion` at its `0` sentinel, so it caps entry-point
|
||||
// validation at whatever `vkEnumerateInstanceVersion()` reports — byte-for-byte what
|
||||
// the (now removed) `BackendContext::new` did. The presenter owns the instance and its
|
||||
// `VkApplicationInfo`, so pinning a version here would just duplicate its choice.
|
||||
None,
|
||||
// 🛑 MUST be the presenter's declared version, never `None`.
|
||||
//
|
||||
// `None` leaves Skia's `fMaxAPIVersion` at its `0` sentinel, which makes Skia fall
|
||||
// back to `vkEnumerateInstanceVersion()` — the LOADER's ceiling, not ours. Those are
|
||||
// not the same number: the presenter asks for 1.3, while a current Mesa loader answers
|
||||
// 1.4 (1.4.321 on SteamOS 3.7). Skia then validates a 1.4 function table against an
|
||||
// instance that only ever promised 1.3, `vkGetDeviceProcAddr` returns null for the
|
||||
// entry points above 1.3, validation fails, and `make_vulkan` hands back `None` — so
|
||||
// the console UI refuses to start and `--browse` dies with it.
|
||||
//
|
||||
// ⚠ The `0` sentinel was harmless at skia-safe 0.87 (that Skia knew nothing of 1.4, so
|
||||
// clamping to the loader was a no-op) and the 0.99 migration preserved it as
|
||||
// "byte-for-byte what `BackendContext::new` did" — true of the VALUE, false of the
|
||||
// BEHAVIOUR. It shipped in 0.28.0 and took the Deck's launcher out. 0.99's own doc for
|
||||
// this parameter says it should match `VkApplicationInfo::apiVersion`; this is that.
|
||||
Some(skvk::Version::from(shared.api_version)),
|
||||
);
|
||||
// SAFETY: the instance/physical-device/device handles come from `shared`, which owns them
|
||||
// and outlives this backend context, and `get_proc` above resolves through those same
|
||||
|
||||
@@ -1016,11 +1016,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Ladder rung: the binary exists and runs but is not a worker. `/bin/false` exits at once, so
|
||||
/// the handshake reads EOF — the same rung a worker that dies during Vulkan bring-up takes.
|
||||
/// A binary that exists, execs, and exits at once. Resolved off `PATH` rather than hardcoded
|
||||
/// to `/bin/false`: NixOS ships only `/bin/sh` in `/bin`, and `PinnedExe::open` needs a real
|
||||
/// path (so a bare name cannot stand in for one — it would fail the OPEN and take the
|
||||
/// spawn-failure rung instead of the handshake rung this exercises).
|
||||
fn a_binary_that_exits_immediately() -> PathBuf {
|
||||
std::env::var_os("PATH")
|
||||
.as_deref()
|
||||
.map(std::env::split_paths)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|d| d.join("false"))
|
||||
.find(|p| p.is_file())
|
||||
.expect("a `false` binary on PATH")
|
||||
}
|
||||
|
||||
/// Ladder rung: the binary exists and runs but is not a worker. It exits at once, so the
|
||||
/// handshake reads EOF — the same rung a worker that dies during Vulkan bring-up takes.
|
||||
#[test]
|
||||
fn a_worker_that_exits_immediately_is_a_handshake_failure() {
|
||||
let err = spawn_link(Path::new("/bin/false"), ¶ms(), 40_000_000).unwrap_err();
|
||||
let err =
|
||||
spawn_link(&a_binary_that_exits_immediately(), ¶ms(), 40_000_000).unwrap_err();
|
||||
let text = format!("{err:#}");
|
||||
assert!(
|
||||
text.contains("handshake"),
|
||||
|
||||
@@ -214,6 +214,15 @@ pub struct HostConfig {
|
||||
/// showing the wrong monitor is worse than showing none). Linux-only today; see
|
||||
/// `design/per-monitor-portal-capture.md`.
|
||||
pub capture_monitor: Option<String>,
|
||||
/// `PUNKTFUNK_PORTAL_CURSOR_MODE` — `auto` (default) · `hidden` · `embedded` · `metadata`.
|
||||
/// Pin the ScreenCast cursor mode the Linux portal backends PREFER, instead of the one the
|
||||
/// session negotiates (`metadata` when the client draws the pointer itself, `embedded`
|
||||
/// otherwise). The pin is a preference, not a command: it still runs through
|
||||
/// `portal_cursor::pick`, so it can never ask a backend for a mode the backend does not
|
||||
/// advertise — that closes the session rather than degrading, which is the failure this knob
|
||||
/// sits next to. Exists for the backend that advertises a mode it implements badly, where
|
||||
/// negotiation has nothing to go on; `embedded` is the safe answer there.
|
||||
pub portal_cursor_mode: Option<String>,
|
||||
/// `PUNKTFUNK_COMPOSITOR` — explicit compositor override (operator/CI/test). NOT the runtime-detected
|
||||
/// session — this one is a constant operator knob; `apply_session_env` never writes it.
|
||||
pub compositor: Option<String>,
|
||||
@@ -262,10 +271,12 @@ pub struct HostConfig {
|
||||
/// spawn flags included.
|
||||
pub gamescope_hdr: bool,
|
||||
/// `PUNKTFUNK_GAMESCOPE_SDR_NITS` — the luminance SDR content is mapped to inside the PQ
|
||||
/// container of an HDR gamescope session (gamescope's `--hdr-sdr-content-nits`, default 400).
|
||||
/// container of an HDR gamescope session (gamescope's `--hdr-sdr-content-nits`).
|
||||
/// An HDR stream carries the desktop, the Steam overlay and any SDR game through the same PQ
|
||||
/// encode, so this is the knob that decides how bright "white" looks on the client's panel.
|
||||
/// `None` = leave gamescope's own default.
|
||||
/// `None` = 203 nits, BT.2408 reference white, which is what our clients decode against —
|
||||
/// NOT gamescope's own default of 400, which sits nearly a stop above it. See `pf-vdisplay`'s
|
||||
/// `SDR_REFERENCE_WHITE_NITS` for why the host pins this rather than letting it float.
|
||||
pub gamescope_sdr_nits: Option<u32>,
|
||||
/// `PUNKTFUNK_GAMESCOPE_BIND` — may the host bind the patched gamescope over
|
||||
/// `/usr/bin/gamescope` inside the session unit's mount namespace? That redirect is the ONLY
|
||||
@@ -399,6 +410,12 @@ impl HostConfig {
|
||||
capture_monitor: val("PUNKTFUNK_CAPTURE_MONITOR")
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
// Same emptied-to-None rule: a bare `PUNKTFUNK_PORTAL_CURSOR_MODE=` left in a host.env
|
||||
// means "not set", not an unrecognised value to warn about. The spellings are parsed
|
||||
// (and warned about) at the use site, `pf-vdisplay`'s `portal_cursor::want`.
|
||||
portal_cursor_mode: val("PUNKTFUNK_PORTAL_CURSOR_MODE")
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
compositor: val("PUNKTFUNK_COMPOSITOR"),
|
||||
gamepad: val("PUNKTFUNK_GAMEPAD"),
|
||||
vdisplay: val("PUNKTFUNK_VDISPLAY"),
|
||||
|
||||
@@ -26,6 +26,17 @@ pub struct SharedDevice {
|
||||
/// with [`pf_client_core::video::QueueLock::guard`], whose RAII form is what every
|
||||
/// Rust caller wants.
|
||||
pub queue_lock: std::sync::Arc<pf_client_core::video::QueueLock>,
|
||||
/// The Vulkan version an overlay renderer may size its function table to — the lower of
|
||||
/// [`crate::vk::INSTANCE_API_VERSION`] (what `VkApplicationInfo::apiVersion` declared for
|
||||
/// `instance`) and what the loader provides.
|
||||
///
|
||||
/// **Cap yourself here; do not ask the loader yourself.** Entry points above this version
|
||||
/// were never promised to us — `vkGetDeviceProcAddr` returns null for them — so a renderer
|
||||
/// that probes `vkEnumerateInstanceVersion` instead (a current Mesa answers 1.4 where we
|
||||
/// asked for 1.3) validates a function table it can never fill and refuses to start. That
|
||||
/// is exactly how the Skia console UI died in 0.28.0; see the note in `pf-console-ui`'s
|
||||
/// `SkiaOverlay::init`.
|
||||
pub api_version: u32,
|
||||
}
|
||||
|
||||
/// What the overlay may draw this frame — composed by the run loop from session state.
|
||||
|
||||
@@ -2847,10 +2847,21 @@ fn stats_text(
|
||||
} else {
|
||||
text.push_str(&format!(" · host+net {:.1}", s.host_net_ms));
|
||||
}
|
||||
text.push_str(&format!(
|
||||
" · decode {:.1} · display {:.1} ms",
|
||||
s.decode_ms, p.display_ms
|
||||
));
|
||||
// `decode` joins the partition line ONLY where it is one. The stages tile `e2e`
|
||||
// per frame — pts →(host+net)→ received →(decode)→ decoded →(display)→ displayed —
|
||||
// and that holds while `decoded` is a completion stamp. On the async native-Vulkan
|
||||
// rung it is a SUBMISSION stamp, so the GPU decode sits inside `display` and this
|
||||
// figure re-counts it; printing the two side by side invited exactly the reading a
|
||||
// 2026-08-13 field report made ("decode 6.6 next to display 1.4 and e2e 8.1 — the
|
||||
// parts don't add up"). They add up without it. See `Stats::decode_overlaps_display`.
|
||||
if s.decode_overlaps_display {
|
||||
text.push_str(&format!(" · display {:.1} ms", p.display_ms));
|
||||
} else {
|
||||
text.push_str(&format!(
|
||||
" · decode {:.1} · display {:.1} ms",
|
||||
s.decode_ms, p.display_ms
|
||||
));
|
||||
}
|
||||
// The display split (WP4). Only with true on-glass stamps — without them the
|
||||
// two halves are not separable and the unsplit figure stands alone rather than
|
||||
// implying a zero latch.
|
||||
@@ -2860,6 +2871,19 @@ fn stats_text(
|
||||
p.pace_ms, p.latch_ms
|
||||
));
|
||||
}
|
||||
// …and gets its own line there, qualified. Two things a reader has to know before
|
||||
// the number means anything: it is ONE frame per window on this rung (a per-frame
|
||||
// fence wait would serialise the decode pipeline — see the sampling comment in
|
||||
// `pf_client_core::session`), so it is a single sample rather than the p50 every
|
||||
// other figure here is; and it is already inside `display`, so adding it double-
|
||||
// counts. Suppressed at 0, which is the "every fence wait timed out" case rather
|
||||
// than a real zero.
|
||||
if s.decode_overlaps_display && s.decode_ms > 0.0 {
|
||||
text.push_str(&format!(
|
||||
"\ndecode {:.1} ms (1 sample, inside display — not additive)",
|
||||
s.decode_ms
|
||||
));
|
||||
}
|
||||
// Extended 0xCF host-stage split (T0.1): its own line so the per-stage attribution
|
||||
// (queue → encode → seal/xfer → pace) reads as the host pipeline in order.
|
||||
if s.staged {
|
||||
@@ -3275,6 +3299,10 @@ mod tests {
|
||||
host_pace_ms: 0.3,
|
||||
staged: true,
|
||||
decode_ms: 1.8,
|
||||
// The fixture is the SYNCHRONOUS shape, so `decode` stays on the partition
|
||||
// line and the existing assertions keep their meaning; the async rung's
|
||||
// split-out rendering is exercised separately below.
|
||||
decode_overlaps_display: false,
|
||||
lost: 3,
|
||||
lost_pct: 0.4,
|
||||
mic_sent: 0,
|
||||
@@ -3414,6 +3442,70 @@ mod tests {
|
||||
assert!(!normal.contains("present:") && !normal.contains("pace"));
|
||||
}
|
||||
|
||||
/// The stage line must stay a PARTITION of `e2e`. On the synchronous rungs `decode` is
|
||||
/// one of its terms; on the asynchronous native-Vulkan rung the shipped `decoded` stamp
|
||||
/// is taken at submission, so the GPU decode is inside `display` and `decode` re-counts
|
||||
/// it. A 2026-08-13 field report read `host 5.4 · net 0.3 · decode 6.6 · display 1.4`
|
||||
/// against `e2e 8.1` as a breakdown and asked why it did not add up — it adds up without
|
||||
/// `decode`. So the figure leaves that line and says what it is instead of sitting beside
|
||||
/// stages it does not tile with.
|
||||
#[test]
|
||||
fn an_overlapping_decode_figure_leaves_the_stage_line_and_says_so() {
|
||||
let (mut s, p) = sample();
|
||||
|
||||
// Synchronous: unchanged, and specifically still INLINE on the stage line.
|
||||
assert!(!s.decode_overlaps_display, "the fixture is the sync shape");
|
||||
let sync = stats_text(
|
||||
StatsVerbosity::Detailed,
|
||||
"m",
|
||||
&s,
|
||||
&p,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
assert!(sync.contains("host 1.2 · net 0.9 · decode 1.8 · display 1.1 ms"));
|
||||
assert!(!sync.contains("not additive"));
|
||||
|
||||
// Asynchronous: off the stage line, which still reads as a partition…
|
||||
s.decode_overlaps_display = true;
|
||||
let async_ = stats_text(
|
||||
StatsVerbosity::Detailed,
|
||||
"m",
|
||||
&s,
|
||||
&p,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
async_.contains("host 1.2 · net 0.9 · display 1.1 ms"),
|
||||
"the stage line keeps only terms that tile e2e: {async_}"
|
||||
);
|
||||
// …and the number survives, qualified by BOTH caveats a reader needs.
|
||||
assert!(async_.contains("\ndecode 1.8 ms (1 sample, inside display — not additive)"));
|
||||
|
||||
// A window whose every fence wait timed out reports 0, which is an absence of
|
||||
// measurement rather than an instant decode — it must not render as either.
|
||||
s.decode_ms = 0.0;
|
||||
let none = stats_text(
|
||||
StatsVerbosity::Detailed,
|
||||
"m",
|
||||
&s,
|
||||
&p,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
!none.contains("decode"),
|
||||
"a 0 sample renders nothing: {none}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The decode-integrity line (M4) — the whole point of which is that it can tell
|
||||
/// three states apart that all look identical as "no complaints today":
|
||||
///
|
||||
|
||||
@@ -43,6 +43,24 @@ mod setup;
|
||||
|
||||
pub use setup::{list_adapters, probe_decode, AdapterDecode, PresentPref};
|
||||
|
||||
/// The Vulkan version every instance this crate creates declares in
|
||||
/// `VkApplicationInfo::apiVersion`.
|
||||
///
|
||||
/// 1.3 because Vulkan Video decode and PyroWave's compute kernels both need a 1.3 device.
|
||||
/// It is deliberately a CEILING as well as a floor: the loader is routinely newer (Mesa 26
|
||||
/// answers `vkEnumerateInstanceVersion` with 1.4), but we only ever promised 1.3, so the
|
||||
/// entry points above it are not ours to call. Anything that must know how far the device
|
||||
/// side reaches — notably an overlay renderer sizing its own function table — reads this
|
||||
/// through [`crate::overlay::SharedDevice::api_version`] rather than asking the loader.
|
||||
pub const INSTANCE_API_VERSION: u32 = vk::API_VERSION_1_3;
|
||||
|
||||
/// The clamp behind [`Presenter::overlay_api_version`], split out so the decision is provable
|
||||
/// without a device: the answer is the lower of what we declared and what the loader reports,
|
||||
/// and a loader too old to answer at all (`None`) can only be a 1.0 one.
|
||||
fn overlay_api_version_of(declared: u32, loader: Option<u32>) -> u32 {
|
||||
declared.min(loader.unwrap_or(vk::API_VERSION_1_0))
|
||||
}
|
||||
|
||||
/// The video-format probe behind [`AdapterDecode::formats`], re-exported so a caller
|
||||
/// that prints the report does not need its own `pf-vkdecode` dependency (and cannot
|
||||
/// end up printing a DIFFERENT crate version's idea of the flag names).
|
||||
@@ -387,8 +405,30 @@ impl Presenter {
|
||||
queue: self.queue,
|
||||
queue_family_index: self.qfi,
|
||||
queue_lock: self.queue_lock.clone(),
|
||||
api_version: self.overlay_api_version(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Vulkan version an overlay renderer may size its function table to: the LOWER of
|
||||
/// what our instance declared ([`INSTANCE_API_VERSION`]) and what the loader actually
|
||||
/// provides.
|
||||
///
|
||||
/// Both halves are load-bearing, in opposite directions. Taking only the loader's number
|
||||
/// is the bug that killed the console UI in 0.28.0 — Mesa answers 1.4 where we asked for
|
||||
/// 1.3, and the entry points in between resolve to null. Taking only ours would break the
|
||||
/// mirror case: a loader older than 1.3 still accepts our 1.3 instance (1.1+ loaders treat
|
||||
/// `apiVersion` as intent, not a contract), and claiming 1.3 to a renderer there promises
|
||||
/// functions the loader has never heard of. The minimum is the only number that is true on
|
||||
/// both sides.
|
||||
fn overlay_api_version(&self) -> u32 {
|
||||
// SAFETY: per the Vulkan contract above - `vkEnumerateInstanceVersion` is a global
|
||||
// command taking no handles, resolved through the loaded entry that owns it; it writes
|
||||
// one `u32` local. Absent (a 1.0 loader) it reports `None` rather than failing.
|
||||
let loader = unsafe { self.entry.try_enumerate_instance_version() }
|
||||
.ok()
|
||||
.flatten();
|
||||
overlay_api_version_of(INSTANCE_API_VERSION, loader)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Presenter {
|
||||
@@ -449,3 +489,42 @@ impl Drop for Presenter {
|
||||
let _ = &self.entry;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The 0.28.0 regression, as an assertion: a loader NEWER than the version we declared
|
||||
/// must not raise the cap. Skia sized its function table to the loader's 1.4 here, then
|
||||
/// could not resolve the entry points our 1.3 instance never exposed, and the console UI
|
||||
/// refused to start (Steam Deck, Mesa loader 1.4.321).
|
||||
#[test]
|
||||
fn a_newer_loader_never_raises_the_cap() {
|
||||
let loader = vk::make_api_version(0, 1, 4, 321);
|
||||
assert_eq!(
|
||||
overlay_api_version_of(INSTANCE_API_VERSION, Some(loader)),
|
||||
INSTANCE_API_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
/// The mirror case, which is why this is a `min` and not "just use ours": a 1.1+ loader
|
||||
/// accepts our 1.3 `apiVersion` as intent even when it cannot deliver 1.3, so promising
|
||||
/// 1.3 to the overlay there would name functions the loader has never heard of.
|
||||
#[test]
|
||||
fn an_older_loader_lowers_the_cap() {
|
||||
let loader = vk::make_api_version(0, 1, 2, 198);
|
||||
assert_eq!(
|
||||
overlay_api_version_of(INSTANCE_API_VERSION, Some(loader)),
|
||||
loader
|
||||
);
|
||||
}
|
||||
|
||||
/// No `vkEnumerateInstanceVersion` at all is the one thing it can mean: a 1.0 loader.
|
||||
#[test]
|
||||
fn a_loader_that_cannot_answer_is_1_0() {
|
||||
assert_eq!(
|
||||
overlay_api_version_of(INSTANCE_API_VERSION, None),
|
||||
vk::API_VERSION_1_0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,9 +155,11 @@ impl Presenter {
|
||||
// 1.3: Vulkan Video decode and PyroWave's compute kernels both need a 1.3
|
||||
// device, and the instance version caps what the device can report (any current
|
||||
// loader accepts 1.3 regardless of device support; device-level gating is below).
|
||||
// `SharedDevice::api_version` republishes this constant to the overlay — keep the
|
||||
// two the same by construction rather than by two spellings of `API_VERSION_1_3`.
|
||||
let app_info = vk::ApplicationInfo::default()
|
||||
.application_name(&app_name)
|
||||
.api_version(vk::API_VERSION_1_3);
|
||||
.api_version(super::INSTANCE_API_VERSION);
|
||||
// HDR10 presentation needs the extended colorspaces at the INSTANCE level.
|
||||
let mut instance_extensions: Vec<String> = instance_extensions.to_vec();
|
||||
let inst_available =
|
||||
@@ -749,7 +751,7 @@ pub fn probe_decode() -> Result<Vec<AdapterDecode>> {
|
||||
let app_name = CString::new("punktfunk-session").unwrap();
|
||||
let app_info = vk::ApplicationInfo::default()
|
||||
.application_name(&app_name)
|
||||
.api_version(vk::API_VERSION_1_3);
|
||||
.api_version(super::INSTANCE_API_VERSION);
|
||||
// SAFETY: per the Vulkan contract above - a create/allocate call on the live device, over
|
||||
// builder structs that are locals outliving the call; the handle it returns is owned by the
|
||||
// value being built here.
|
||||
@@ -902,7 +904,7 @@ pub fn list_adapters() -> Result<Vec<String>> {
|
||||
let app_name = CString::new("punktfunk-session").unwrap();
|
||||
let app_info = vk::ApplicationInfo::default()
|
||||
.application_name(&app_name)
|
||||
.api_version(vk::API_VERSION_1_3);
|
||||
.api_version(super::INSTANCE_API_VERSION);
|
||||
// SAFETY: per the Vulkan contract above - the Vulkan handles used here are owned by this type
|
||||
// and live for the call, and every builder struct is a local that outlives it.
|
||||
let instance = unsafe {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<protocol name="dpms">
|
||||
<copyright><![CDATA[
|
||||
SPDX-FileCopyrightText: 2015 Martin Gräßlin
|
||||
|
||||
SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
]]></copyright>
|
||||
<interface name="org_kde_kwin_dpms_manager" version="1">
|
||||
<description summary="Output dpms manager">
|
||||
The Dpms manager allows to get a org_kde_kwin_dpms for a given wl_output.
|
||||
The org_kde_kwin_dpms provides the currently used VESA Display Power Management
|
||||
Signaling state (see https://en.wikipedia.org/wiki/VESA_Display_Power_Management_Signaling ).
|
||||
In addition it allows to request a state change. A compositor is not obliged to honor it
|
||||
and will normally automatically switch back to on state.
|
||||
|
||||
Warning! The protocol described in this file is a desktop environment
|
||||
implementation detail. Regular clients must not use this protocol.
|
||||
Backward incompatible changes may be added without bumping the major
|
||||
version of the extension.
|
||||
</description>
|
||||
<request name="get">
|
||||
<description summary="Get org_kde_kwin_dpms for wl_output">
|
||||
Factory request to get the org_kde_kwin_dpms for a given wl_output.
|
||||
</description>
|
||||
<arg name="id" type="new_id" interface="org_kde_kwin_dpms"/>
|
||||
<arg name="output" type="object" interface="wl_output"/>
|
||||
</request>
|
||||
</interface>
|
||||
<interface name="org_kde_kwin_dpms" version="1">
|
||||
<description summary="Dpms for a wl_output">
|
||||
This interface provides information about the VESA DPMS state for a wl_output.
|
||||
It gets created through the request get on the org_kde_kwin_dpms_manager interface.
|
||||
|
||||
On creating the resource the server will push whether DPSM is supported for the output,
|
||||
the currently used DPMS state and notifies the client through the done event once all
|
||||
states are pushed. Whenever a state changes the set of changes is committed with the
|
||||
done event.
|
||||
</description>
|
||||
<event name="supported">
|
||||
<description summary="Event indicating whether DPMS is supported on the wl_output">
|
||||
This event gets pushed on binding the resource and indicates whether the wl_output
|
||||
supports DPMS. There are operation modes of a Wayland server where DPMS might not
|
||||
make sense (e.g. nested compositors).
|
||||
</description>
|
||||
<arg name="supported" type="uint" summary="Boolean value whether DPMS is supported (1) for the wl_output or not (0)"/>
|
||||
</event>
|
||||
<enum name="mode">
|
||||
<entry name="On" value="0"/>
|
||||
<entry name="Standby" value="1"/>
|
||||
<entry name="Suspend" value="2"/>
|
||||
<entry name="Off" value="3"/>
|
||||
</enum>
|
||||
<event name="mode">
|
||||
<description summary="Event indicating used DPMS mode">
|
||||
This mode gets pushed on binding the resource and provides the currently used
|
||||
DPMS mode. It also gets pushed if DPMS is not supported for the wl_output, in that
|
||||
case the value will be On.
|
||||
|
||||
The event is also pushed whenever the state changes.
|
||||
</description>
|
||||
<arg name="mode" type="uint" summary="The new currently used mode"/>
|
||||
</event>
|
||||
<event name="done">
|
||||
<description summary="All changes are pushed">
|
||||
This event gets pushed on binding the resource once all other states are pushed.
|
||||
|
||||
In addition it gets pushed whenever a state changes to tell the client that all
|
||||
state changes have been pushed.
|
||||
</description>
|
||||
</event>
|
||||
<request name="set">
|
||||
<description summary="Request DPMS state change for the wl_output">
|
||||
Requests that the compositor puts the wl_output into the passed mode. The compositor
|
||||
is not obliged to change the state. In addition the compositor might leave the mode
|
||||
whenever it seems suitable. E.g. the compositor might return to On state on user input.
|
||||
|
||||
The client should not assume that the mode changed after requesting a new mode.
|
||||
Instead the client should listen for the mode event.
|
||||
</description>
|
||||
<arg name="mode" type="uint" summary="Requested mode"/>
|
||||
</request>
|
||||
<request name="release" type="destructor">
|
||||
<description summary="release the dpms object"/>
|
||||
</request>
|
||||
</interface>
|
||||
</protocol>
|
||||
|
||||
@@ -824,6 +824,15 @@ pub mod admission;
|
||||
#[path = "vdisplay/linux/portal_config.rs"]
|
||||
mod portal_config;
|
||||
|
||||
/// Which ScreenCast cursor mode to REQUEST — negotiated against `AvailableCursorModes` instead of
|
||||
/// hardcoded, because a mode the backend does not advertise closes the session outright.
|
||||
///
|
||||
/// Declared unconditionally for the same reason as `portal_config` above: the ladder is pure
|
||||
/// integer work whose tests are the only place its behaviour is observable without a compositor,
|
||||
/// so they should run on every platform's CI rather than only where the callers compile.
|
||||
#[path = "vdisplay/linux/portal_cursor.rs"]
|
||||
mod portal_cursor;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/hyprland.rs"]
|
||||
mod hyprland;
|
||||
@@ -839,6 +848,15 @@ mod kwin;
|
||||
#[path = "vdisplay/linux/kwin_output_mgmt.rs"]
|
||||
mod kwin_output_mgmt;
|
||||
|
||||
// DPMS control of the box's live KDE desktop (org_kde_kwin_dpms) — how a bare-spawn gamescope
|
||||
// session honors `Topology::Exclusive`: the spawn is its own headless compositor, so the desktop's
|
||||
// physical outputs can't be *disabled* (KWin refuses zero enabled outputs and no output there is
|
||||
// ours) — they are put to DPMS-off for the stream instead, refcounted across concurrent spawns.
|
||||
// Consumed by `gamescope` (best-effort, with kscreen fallback).
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/kwin_dpms.rs"]
|
||||
mod kwin_dpms;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "vdisplay/windows/manager.rs"]
|
||||
pub mod manager;
|
||||
|
||||
@@ -69,6 +69,11 @@ pub struct GamescopeDisplay {
|
||||
/// the decision and this session's `create`. `None` = nothing resolved it (a caller that never
|
||||
/// ran `apply_input_env`); `create` then falls through to the bare spawn, the safe default.
|
||||
route: Option<crate::GamescopeRoute>,
|
||||
/// The topology-restore action the bare-spawn `create` prepared under `Topology::Exclusive` —
|
||||
/// the release of this display's [`crate::kwin_dpms`] darken hold — pending pickup by the
|
||||
/// registry via [`VirtualDisplay::take_topology_restore`], so it runs at the display's
|
||||
/// teardown (§6.1) and never before.
|
||||
pending_restore: Option<Box<dyn FnOnce() + Send>>,
|
||||
}
|
||||
|
||||
/// A running host-managed session (its transient systemd --user unit) + the mode it was launched at.
|
||||
@@ -441,6 +446,14 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
self.route = route;
|
||||
}
|
||||
|
||||
fn take_topology_restore(&mut self) -> Option<Box<dyn FnOnce() + Send>> {
|
||||
// The DPMS darken-hold release the bare-spawn `create` registered (Exclusive topology
|
||||
// only). The registry stores it on this display's entry and runs it at teardown — which,
|
||||
// for gamescope, is the display's OWN teardown: every spawn is its own group, and the
|
||||
// cross-session ordering lives in `kwin_dpms`'s refcount, not in the group float.
|
||||
self.pending_restore.take()
|
||||
}
|
||||
|
||||
fn poolable_now(&self) -> bool {
|
||||
// Only a bare SPAWN is registry-poolable (its `create` reports `Owned`); Managed and
|
||||
// Attach report `SessionManaged`/`External`, so the registry must not reuse a kept spawn
|
||||
@@ -576,6 +589,23 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
hz = mode.refresh_hz,
|
||||
"gamescope virtual output ready"
|
||||
);
|
||||
// `Topology::Exclusive`, bare-spawn edition: this spawn is its OWN headless compositor —
|
||||
// nothing above touched the box's live desktop (KWin), which would otherwise keep driving
|
||||
// the physical panel with the idle desktop for the whole stream. The KWin route disables
|
||||
// the physicals outright, but that door is closed here (KWin refuses zero enabled outputs,
|
||||
// and no output on that desktop is ours to leave enabled) — so the desktop's panels go to
|
||||
// DPMS-off instead, best-effort and self-gating (a box with no KDE desktop declines
|
||||
// quietly inside `kwin_dpms`). Placed AFTER the spawn succeeded, so a failed create never
|
||||
// blanks the user's screen. The hold is refcounted in `kwin_dpms` rather than floated
|
||||
// through the registry's group restore, because every gamescope spawn is its own group
|
||||
// (`registry::group_key`) — the float alone would re-light the panel when the FIRST of two
|
||||
// concurrent spawns ends, under the second's still-live stream. Skipped for Managed (its
|
||||
// takeover already stopped the desktop) and Attach (it mirrors a gamescope that may itself
|
||||
// be driving the physical panel) — both returned earlier in this function.
|
||||
if crate::effective_topology() == crate::policy::Topology::Exclusive {
|
||||
crate::kwin_dpms::acquire_stream_darken();
|
||||
self.pending_restore = Some(Box::new(crate::kwin_dpms::release_stream_darken));
|
||||
}
|
||||
// Bare SPAWN: we own the nested gamescope process → registry-poolable (keep-alive-able).
|
||||
Ok(VirtualOutput::owned(
|
||||
node_id,
|
||||
@@ -1237,7 +1267,7 @@ fn write_session_plus_dropin(
|
||||
wrapper: &std::path::Path,
|
||||
mode: Mode,
|
||||
hdr: bool,
|
||||
wsi_ok: bool,
|
||||
wsi: WsiPlan,
|
||||
) -> Result<bool> {
|
||||
let Some(bind) = arm_session_bind(wrapper) else {
|
||||
remove_session_plus_dropin();
|
||||
@@ -1260,11 +1290,7 @@ fn write_session_plus_dropin(
|
||||
.chain(cursor_args())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
wsi = if wsi_ok {
|
||||
String::new()
|
||||
} else {
|
||||
wsi_off_unit_lines()
|
||||
},
|
||||
wsi = wsi.unit_lines(),
|
||||
);
|
||||
std::fs::write(&path, body).with_context(|| format!("write drop-in {}", path.display()))?;
|
||||
Ok(true)
|
||||
@@ -1579,7 +1605,7 @@ fn ensure_box_gamescope_mode(mode: Mode, hdr: bool) -> Result<u32> {
|
||||
// Same two fixes the transient path gets, but this unit is the BOX's own — they have to arrive
|
||||
// as a drop-in, and `daemon-reload` before the restart or systemd runs the old unit.
|
||||
let mut bound = match write_gamescope_bin_wrapper()
|
||||
.and_then(|w| write_session_plus_dropin(&w, mode, hdr, wsi_layer_matches_our_gamescope()))
|
||||
.and_then(|w| write_session_plus_dropin(&w, mode, hdr, WsiPlan::resolve()))
|
||||
{
|
||||
Ok(true) => {
|
||||
// Record it BEFORE the restart, and persist it: from this instant the box's OWN
|
||||
@@ -4215,25 +4241,103 @@ const WSI_OFF_ENV: [(&str, &str); 2] = [
|
||||
("ENABLE_GAMESCOPE_WSI", "0"),
|
||||
];
|
||||
|
||||
/// [`WSI_OFF_ENV`] as `systemd-run` arguments, for the transient unit.
|
||||
fn wsi_off_setenv_args() -> Vec<String> {
|
||||
WSI_OFF_ENV
|
||||
.iter()
|
||||
.map(|(name, value)| format!("--setenv={name}={value}"))
|
||||
.collect()
|
||||
/// Our own WSI layer's implicit-layer manifest, laid down beside the compositor by
|
||||
/// `packaging/gamescope/build-punktfunk-gamescope.sh`.
|
||||
///
|
||||
/// It is built from the SAME source tree at the SAME rev as `punktfunk-gamescope`, so the layer and
|
||||
/// the compositor cannot disagree about `gamescope_swapchain` — which is what makes every "is the
|
||||
/// distro's layer close enough to ours?" guess unnecessary. It carries its own layer name and its
|
||||
/// own `enable_environment`, so it coexists with the distro's rather than replacing it.
|
||||
const OUR_WSI_LAYER_DIR_DEFAULT: &str = "/usr/lib/punktfunk/vulkan/implicit_layer.d";
|
||||
const OUR_WSI_LAYER_MANIFEST_NAME: &str = "punktfunk_gamescope_wsi.json";
|
||||
|
||||
/// Where our layer's manifest directory is. FHS by default, because that is where every distro
|
||||
/// package puts it; `PUNKTFUNK_GAMESCOPE_WSI_LAYER_DIR` overrides for a store with no `/usr` to
|
||||
/// speak of — on NixOS the layer lives inside the gamescope derivation and the module points this
|
||||
/// at it, the same posture as `PUNKTFUNK_GAMESCOPE_BIN`.
|
||||
fn our_wsi_layer_dir() -> String {
|
||||
std::env::var("PUNKTFUNK_GAMESCOPE_WSI_LAYER_DIR")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.unwrap_or_else(|| OUR_WSI_LAYER_DIR_DEFAULT.to_string())
|
||||
}
|
||||
|
||||
/// [`WSI_OFF_ENV`] as unit-file lines, for the box-session drop-in. Trailing newline included, so
|
||||
/// whatever the body puts after it still parses — same contract as [`SessionBind::unit_lines`].
|
||||
fn wsi_off_unit_lines() -> String {
|
||||
WSI_OFF_ENV
|
||||
.iter()
|
||||
.map(|(name, value)| format!("Environment={name}={value}\n"))
|
||||
.collect()
|
||||
/// Which Vulkan WSI layer a session we spawn should run with. Three states, decided ONCE per
|
||||
/// launch because [`WsiPlan::resolve`] can spawn `--version` probes.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum WsiPlan {
|
||||
/// Our own matching layer is installed: enable it, suppress the distro's. Games get HDR.
|
||||
Ours,
|
||||
/// No layer of ours, and the distro's version triple matches the gamescope we run, so it is
|
||||
/// probably built against the same protocol. Leave the box exactly as it is.
|
||||
DistroKept,
|
||||
/// No layer of ours, and the distro's cannot be trusted. Disable it — a mismatched layer kills
|
||||
/// every Vulkan client — and accept that no game in this session can get an HDR10 swapchain.
|
||||
DistroDisabled,
|
||||
}
|
||||
|
||||
impl WsiPlan {
|
||||
/// ⚠️ Spawns up to two `gamescope --version` probes in the fallback arms, so resolve once and
|
||||
/// pass the result around rather than calling this per use site.
|
||||
fn resolve() -> Self {
|
||||
let manifest = std::path::Path::new(&our_wsi_layer_dir()).join(OUR_WSI_LAYER_MANIFEST_NAME);
|
||||
if manifest.is_file() {
|
||||
Self::Ours
|
||||
} else if wsi_layer_matches_our_gamescope() {
|
||||
Self::DistroKept
|
||||
} else {
|
||||
Self::DistroDisabled
|
||||
}
|
||||
}
|
||||
|
||||
/// The environment this plan needs, as `(name, value)` pairs.
|
||||
fn env(self) -> Vec<(&'static str, String)> {
|
||||
match self {
|
||||
// `VK_ADD_IMPLICIT_LAYER_PATH` ADDS to the loader's implicit-layer search (loader
|
||||
// 1.3.234+), so the box's own layer directories keep working; the distro's gamescope
|
||||
// layer is then switched off by name through its own variables, leaving exactly one
|
||||
// gamescope WSI layer live — ours.
|
||||
Self::Ours => vec![
|
||||
("VK_ADD_IMPLICIT_LAYER_PATH", our_wsi_layer_dir()),
|
||||
("PUNKTFUNK_GAMESCOPE_WSI", "1".to_string()),
|
||||
("DISABLE_GAMESCOPE_WSI", "1".to_string()),
|
||||
("ENABLE_GAMESCOPE_WSI", "0".to_string()),
|
||||
],
|
||||
Self::DistroKept => Vec::new(),
|
||||
Self::DistroDisabled => WSI_OFF_ENV
|
||||
.iter()
|
||||
.map(|(name, value)| (*name, (*value).to_string()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// As `systemd-run` arguments, for the transient unit.
|
||||
fn setenv_args(self) -> Vec<String> {
|
||||
self.env()
|
||||
.iter()
|
||||
.map(|(name, value)| format!("--setenv={name}={value}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// As unit-file lines, for the box-session drop-in. Trailing newline included, so whatever the
|
||||
/// body puts after it still parses — same contract as [`SessionBind::unit_lines`].
|
||||
fn unit_lines(self) -> String {
|
||||
self.env()
|
||||
.iter()
|
||||
.map(|(name, value)| format!("Environment={name}={value}\n"))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the box's `VkLayer_FROG_gamescope_wsi` can be trusted against the gamescope we run.
|
||||
///
|
||||
/// ⚠️ **Fallback only** — reached from [`WsiPlan::resolve`] just when our own layer is absent (a
|
||||
/// `punktfunk-gamescope` package older than the one that started shipping it). It is a guess, and a
|
||||
/// guess in BOTH directions: a distro at the same upstream tag that patched the protocol compares
|
||||
/// EQUAL and keeps a layer that will kill every Vulkan client, while a distro at a different tag
|
||||
/// with a byte-identical protocol compares unequal and loses HDR for nothing. Do not build anything
|
||||
/// new on it; ship the layer instead, which is what [`WsiPlan::Ours`] does.
|
||||
///
|
||||
/// The layer ships with the DISTRO's gamescope and speaks its `gamescope_swapchain` protocol; we
|
||||
/// run our own build. When the two disagree the compositor rejects the client's
|
||||
/// `swapchain_feedback` ("message too short") and **kills every Vulkan client** — Steam never
|
||||
@@ -4244,8 +4348,13 @@ fn wsi_off_unit_lines() -> String {
|
||||
/// byte-identical between those commits, so this is the distro PATCHING gamescope, not a version
|
||||
/// bump — which is why the check is "do the version triples differ", not a floor.
|
||||
///
|
||||
/// Disabling it costs only the layer's extras (XWayland bypass, present-mode control, client HDR
|
||||
/// metadata) — far cheaper than a client that cannot start.
|
||||
/// Disabling it costs the layer's extras (XWayland bypass, present-mode control) and, on an HDR
|
||||
/// session, **HDR for games**. The layer is the ONLY route to an HDR10 swapchain under gamescope:
|
||||
/// gamescope advertises no runtime colour-management protocol for a Mesa/NVIDIA WSI to negotiate
|
||||
/// through, so with the layer inactive a client never gets a Surface-state block and lands on
|
||||
/// `hdr10_format=None` (measured on a Deck OLED — see the Gamescope WSI notes in the flatpak
|
||||
/// manifest). Still cheaper than a client that cannot start at all, but not free, which is why
|
||||
/// [`launch_session`] says so out loud when it fires on an HDR session.
|
||||
///
|
||||
/// ⚠️ **`ENABLE_GAMESCOPE_WSI=0` is NOT enough on its own**, which is what [`WSI_OFF_ENV`] is for.
|
||||
fn wsi_layer_matches_our_gamescope() -> bool {
|
||||
@@ -4304,16 +4413,30 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
|
||||
// The distro's Vulkan WSI layer speaks the distro gamescope's protocol; ours may differ, and a
|
||||
// mismatch kills every Vulkan client with no error but a black screen. Steam Big Picture is not
|
||||
// one of them, so the casualty is the GAMES — see [`WSI_OFF_ENV`] for why both variables go.
|
||||
let wsi_ok = wsi_layer_matches_our_gamescope();
|
||||
if !wsi_ok {
|
||||
let wsi = WsiPlan::resolve();
|
||||
if wsi == WsiPlan::DistroDisabled {
|
||||
tracing::warn!(
|
||||
"gamescope: this box's VkLayer_FROG_gamescope_wsi was built for a different gamescope \
|
||||
than the one we run — disabling it for this session (DISABLE_GAMESCOPE_WSI=1, which \
|
||||
the session script cannot clobber the way it clobbers ENABLE_GAMESCOPE_WSI). Left \
|
||||
enabled it rejects the client's swapchain_feedback and every Vulkan client dies; \
|
||||
Steam's own UI is not one, so what you see is a game that runs with sound and input \
|
||||
on a black screen, with no other symptom."
|
||||
than the one we run, and no punktfunk layer is installed to use instead — disabling \
|
||||
it for this session (DISABLE_GAMESCOPE_WSI=1, which the session script cannot clobber \
|
||||
the way it clobbers ENABLE_GAMESCOPE_WSI). Left enabled it rejects the client's \
|
||||
swapchain_feedback and every Vulkan client dies; Steam's own UI is not one, so what \
|
||||
you see is a game that runs with sound and input on a black screen, with no other \
|
||||
symptom. Upgrading the punktfunk-gamescope package fixes this properly — it ships a \
|
||||
layer built from the same tree as the compositor."
|
||||
);
|
||||
// The HDR decisions are made independently — `hdr_args` never consults the layer plan — so
|
||||
// without this an HDR session launches advertising HDR while having made game HDR
|
||||
// unreachable in the same breath, and nothing anywhere says so.
|
||||
if hdr {
|
||||
tracing::warn!(
|
||||
"gamescope: this session negotiated HDR, but with the WSI layer disabled no game \
|
||||
in it can get an HDR10 swapchain — that layer is the only route to one. The \
|
||||
stream itself stays HDR (the capture really is PQ/BT.2020, and Steam's UI and the \
|
||||
desktop ride the same container), so what breaks is GAME HDR specifically: a \
|
||||
title told to render HDR renders it into an SDR swapchain and looks washed out."
|
||||
);
|
||||
}
|
||||
}
|
||||
let start_unit = |bind: Option<&SessionBind>| -> Result<()> {
|
||||
let mut cmd = Command::new("systemd-run");
|
||||
@@ -4321,10 +4444,8 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
|
||||
for arg in bind.map(SessionBind::run_args).unwrap_or_default() {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
if !wsi_ok {
|
||||
for arg in wsi_off_setenv_args() {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
for arg in wsi.setenv_args() {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
// Same headless-must-not-attach rule as [`spawn`]: the transient unit inherits the
|
||||
// user manager env, which can carry a (possibly stale) desktop DISPLAY/WAYLAND_DISPLAY
|
||||
@@ -4559,23 +4680,38 @@ fn add_bare_gamescope_args(
|
||||
/// fine meanwhile.)
|
||||
/// * `--hdr-sdr-content-nits` maps SDR content into the PQ container. Everything that is not an
|
||||
/// HDR game — the desktop, the Steam overlay, an SDR title — rides through it, so it decides
|
||||
/// how bright "white" lands on the client's panel. Only passed when the operator set the knob;
|
||||
/// otherwise gamescope's own default (400) applies.
|
||||
/// how bright "white" lands on the client's panel. We always pass it, because the two ends have
|
||||
/// to agree on where diffuse white sits and gamescope's own default does not match ours — see
|
||||
/// [`SDR_REFERENCE_WHITE_NITS`].
|
||||
fn hdr_args(hdr: bool) -> Vec<String> {
|
||||
if !hdr {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut args = vec![
|
||||
let nits = pf_host_config::config()
|
||||
.gamescope_sdr_nits
|
||||
.unwrap_or(SDR_REFERENCE_WHITE_NITS);
|
||||
vec![
|
||||
"--hdr-enabled".to_string(),
|
||||
"--hdr-debug-force-support".to_string(),
|
||||
];
|
||||
if let Some(nits) = pf_host_config::config().gamescope_sdr_nits {
|
||||
args.push("--hdr-sdr-content-nits".to_string());
|
||||
args.push(nits.to_string());
|
||||
}
|
||||
args
|
||||
"--hdr-sdr-content-nits".to_string(),
|
||||
nits.to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
/// Where diffuse white sits, in nits, for SDR content carried inside an HDR session's PQ container.
|
||||
///
|
||||
/// 203 is BT.2408 "HDR Reference White", and it is the value every first-party client anchors to:
|
||||
/// the Apple presenter hands exactly 203 to `CAEDRMetadata.hdr10(opticalOutputScale:)`. gamescope's
|
||||
/// own default is 400, so leaving the flag off put the host nearly a stop (400/203 ≈ 1.97×) above
|
||||
/// what the client decodes against — the 2026-08-13 field report where Steam's Big Picture UI read
|
||||
/// as glaring and over-saturated on an iPad while HDR game content came out washed out. Both are
|
||||
/// the same error: the UI lands above SDR white, and the client's tone-mapper then works from a
|
||||
/// reference point the host never used, flattening the content around it.
|
||||
///
|
||||
/// This is the anchor, not a taste knob — `PUNKTFUNK_GAMESCOPE_SDR_NITS` is still there for an
|
||||
/// operator who wants a brighter or dimmer desktop, and moving it away from 203 re-opens the gap.
|
||||
const SDR_REFERENCE_WHITE_NITS: u32 = 203;
|
||||
|
||||
/// `--pipewire-composite-cursor` when the resolved gamescope has it (patch level 2+). Paired with
|
||||
/// [`crate::gamescope_composites_cursor`], which is what tells the host to STOP compositing the
|
||||
/// pointer itself — the two must agree, so both read the same probe.
|
||||
@@ -4778,12 +4914,12 @@ mod tests {
|
||||
any_output_size_is, cgroup_is_punktfunk_owned, cgroup_under_user_manager,
|
||||
classify_output_size, connected_connector_under, display_manager_unit_under, dm_plan,
|
||||
dm_survives_masked_unit, game_hz, gamescope_output_size, hdr_args, is_steam_launch,
|
||||
mask_unit, missing_flags, mode_mismatch, nested_wrapper_script, plan_bind,
|
||||
release_autologin_mask, script_hardcodes_gamescope, sentinel_advanced,
|
||||
mask_unit, missing_flags, mode_mismatch, nested_wrapper_script, our_wsi_layer_dir,
|
||||
plan_bind, release_autologin_mask, script_hardcodes_gamescope, sentinel_advanced,
|
||||
shape_dedicated_command, switch_ends_mask_window, takeover_state_is_live, unmask_unit,
|
||||
wsi_off_setenv_args, wsi_off_unit_lines, xwayland_refusal_marker, BindOff, BindPlan,
|
||||
BoxOutputSize, DmHelperError, SessionBind, TakeoverState, AUTOLOGIN_MASKED,
|
||||
DISTRO_GAMESCOPE_PATH, STOPPED_AUTOLOGIN, WSI_OFF_ENV, X11_SOCKET_DIR,
|
||||
xwayland_refusal_marker, BindOff, BindPlan, BoxOutputSize, DmHelperError, SessionBind,
|
||||
TakeoverState, WsiPlan, AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, STOPPED_AUTOLOGIN,
|
||||
WSI_OFF_ENV, X11_SOCKET_DIR,
|
||||
};
|
||||
|
||||
fn argv(s: &str) -> Vec<String> {
|
||||
@@ -5611,8 +5747,8 @@ mod tests {
|
||||
);
|
||||
|
||||
// Both spellings reach both launch paths, and neither may lose the other.
|
||||
let args = wsi_off_setenv_args();
|
||||
let lines = wsi_off_unit_lines();
|
||||
let args = WsiPlan::DistroDisabled.setenv_args();
|
||||
let lines = WsiPlan::DistroDisabled.unit_lines();
|
||||
for (name, value) in WSI_OFF_ENV {
|
||||
assert!(args.contains(&format!("--setenv={name}={value}")), "{name}");
|
||||
assert!(
|
||||
@@ -5625,4 +5761,31 @@ mod tests {
|
||||
// lines above it rely on the same contract and the order has changed before.
|
||||
assert!(lines.ends_with('\n'));
|
||||
}
|
||||
|
||||
/// The whole point of shipping our own layer is that BOTH halves happen in one session: ours is
|
||||
/// switched on AND the distro's is forced off. Enabling ours while leaving theirs live would
|
||||
/// put two gamescope WSI layers in the loader's implicit set, and dropping ours while forcing
|
||||
/// theirs off is just the old no-game-HDR behaviour wearing a new name — so assert the pair,
|
||||
/// not either half.
|
||||
#[test]
|
||||
fn our_own_layer_is_enabled_and_the_distro_one_forced_off_together() {
|
||||
let env = WsiPlan::Ours.env();
|
||||
let get = |k: &str| {
|
||||
env.iter()
|
||||
.find(|(name, _)| *name == k)
|
||||
.map(|(_, v)| v.clone())
|
||||
.unwrap_or_else(|| panic!("{k} missing from the Ours plan"))
|
||||
};
|
||||
|
||||
assert_eq!(get("VK_ADD_IMPLICIT_LAYER_PATH"), our_wsi_layer_dir());
|
||||
assert_eq!(get("PUNKTFUNK_GAMESCOPE_WSI"), "1");
|
||||
// The clobber-proof one, for exactly the reason the test above states.
|
||||
assert_eq!(get("DISABLE_GAMESCOPE_WSI"), "1");
|
||||
assert_eq!(get("ENABLE_GAMESCOPE_WSI"), "0");
|
||||
|
||||
// `DistroKept` must stay genuinely inert: it is the arm that runs on a box we decided not
|
||||
// to touch, so a stray variable there would change behaviour we promised not to change.
|
||||
assert!(WsiPlan::DistroKept.env().is_empty());
|
||||
assert!(WsiPlan::DistroKept.unit_lines().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,12 +115,21 @@ fn output_owner_pid(name: &str) -> Option<u32> {
|
||||
/// The Hyprland virtual-display driver. Stateless — each [`create`](VirtualDisplay::create) adds one
|
||||
/// named headless output and spins up a portal thread owning the cast on it.
|
||||
pub struct HyprlandDisplay {
|
||||
/// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): portal
|
||||
/// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): PREFER portal
|
||||
/// `CursorMode::Metadata` — shapes/positions ride `SPA_META_Cursor` for the channel + the
|
||||
/// composite blend. Off (every non-channel session): `Embedded` — the compositor paints the
|
||||
/// pointer into frames, zero host-side cursor work (the pre-channel default this backend
|
||||
/// always had). ⚠️ Metadata is UNTESTED on-glass for this backend (Phase B wired it so the
|
||||
/// channel isn't silently dead here; KWin/Mutter are the validated legs).
|
||||
/// composite blend. Off (every non-channel session): prefer `Embedded` — the compositor paints
|
||||
/// the pointer into frames, zero host-side cursor work (the pre-channel default this backend
|
||||
/// always had).
|
||||
///
|
||||
/// Both are only a PREFERENCE: [`crate::portal_cursor`] settles it against what xdph actually
|
||||
/// advertises, because requesting an unadvertised mode makes xdg-desktop-portal fail the call.
|
||||
/// This used to be asserted instead, which is exactly how a cursor-forward session here became
|
||||
/// a black client.
|
||||
///
|
||||
/// ⚠️ On current xdph the metadata arm is UNREACHABLE, not merely untested: measured on .21
|
||||
/// 2026-08-14 (Hyprland 0.56.2, xdph 1.4.1) `AvailableCursorModes` = 3 — `Hidden|Embedded`
|
||||
/// only. Every session on this backend therefore resolves to `Embedded` today; KWin/Mutter
|
||||
/// remain the legs where the metadata channel is actually exercised.
|
||||
hw_cursor: bool,
|
||||
}
|
||||
|
||||
@@ -788,13 +797,7 @@ fn portal_thread(
|
||||
stop: Arc<AtomicBool>,
|
||||
hw_cursor: bool,
|
||||
) {
|
||||
// Portal cursor mode per the session's channel negotiation (see the struct doc).
|
||||
let cursor_mode = if hw_cursor {
|
||||
CursorMode::Metadata
|
||||
} else {
|
||||
CursorMode::Embedded
|
||||
};
|
||||
use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
|
||||
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
|
||||
use ashpd::desktop::PersistMode;
|
||||
use ashpd::enumflags2::BitFlags;
|
||||
|
||||
@@ -818,6 +821,14 @@ fn portal_thread(
|
||||
let proxy = Screencast::new().await.context(
|
||||
"connect ScreenCast portal (is xdg-desktop-portal running with the hyprland backend/xdph?)",
|
||||
)?;
|
||||
// NEGOTIATED against what xdph advertises, never asserted from `hw_cursor` alone: a
|
||||
// cursor mode the backend does not offer does not degrade — xdg-desktop-portal's
|
||||
// FRONTEND fails the call ("Unavailable cursor mode %x") before xdph sees it.
|
||||
// MEASURED on .21 2026-08-14, Hyprland 0.56.2 + xdph 1.4.1 (both current):
|
||||
// `AvailableCursorModes` = 3 (Hidden|Embedded) — metadata is NOT offered. So the old
|
||||
// hardcode killed EVERY cursor-forward session here, on today's packages, not just on
|
||||
// old installs: `unavailable cursor mode 4`, "pipeline build failed", black client.
|
||||
let cursor_mode = crate::portal_cursor::negotiate(&proxy, hw_cursor, "xdph").await;
|
||||
let session = proxy
|
||||
.create_session(Default::default())
|
||||
.await
|
||||
|
||||
@@ -704,7 +704,7 @@ fn kscreen_ok(args: &[String]) -> bool {
|
||||
/// before exiting, so a slow-but-working KWin gives us a kill on a request that already landed;
|
||||
/// any caller that treats `None` as "it failed" is asserting something it does not know, and for
|
||||
/// the restore path that assertion costs a monitor its refresh rate.
|
||||
fn kscreen_verdict(args: &[String]) -> Option<bool> {
|
||||
pub(crate) fn kscreen_verdict(args: &[String]) -> Option<bool> {
|
||||
match crate::proc::status_within(
|
||||
std::process::Command::new("kscreen-doctor").args(args),
|
||||
KSCREEN_BUDGET,
|
||||
|
||||
@@ -0,0 +1,675 @@
|
||||
//! DPMS control of the box's live KDE desktop (`org_kde_kwin_dpms`) — how a bare-spawn gamescope
|
||||
//! session honors [`Topology::Exclusive`](crate::policy::Topology::Exclusive).
|
||||
//!
|
||||
//! A bare spawn is its OWN headless compositor: nothing on that route touches the desktop the box
|
||||
//! is showing, so on a KDE machine the physical panel keeps displaying the (idle) desktop for the
|
||||
//! whole stream — while the same `exclusive` policy on the KWin route turns the physicals off
|
||||
//! outright. The KWin route's mechanism is closed to us here: KWin refuses an output configuration
|
||||
//! with ZERO enabled outputs, and a gamescope session has no KWin output of its own to leave
|
||||
//! enabled. DPMS is the honest translation of `exclusive` for this route — the desktop stays
|
||||
//! exactly where it is (no topology churn, no window re-homing), the panels go dark, and any
|
||||
//! LOCAL input wakes them, which is the right answer for a desktop someone can walk up to.
|
||||
//! Stream input never wakes them: it is injected into the nested gamescope's own EIS socket and
|
||||
//! does not pass through KWin.
|
||||
//!
|
||||
//! Driven in-process over the compositor's own Wayland (`Connection::connect_to_env`, the same
|
||||
//! stack as [`crate::kwin_output_mgmt`] and for the same reason: `kscreen-doctor` rides a separate
|
||||
//! libkscreen/KDED layer that can be wedged while KWin itself answers fine), with a
|
||||
//! `kscreen-doctor --dpms` shell-out fallback. Best-effort everywhere — a box with no Wayland
|
||||
//! session, or a non-KDE desktop, declines quietly and the stream proceeds with the panel lit,
|
||||
//! exactly as before this module existed.
|
||||
//!
|
||||
//! **The hold is refcounted here, NOT floated through the registry's per-group restore.** Every
|
||||
//! gamescope spawn is its own display group (`registry::group_key` — deliberately, they are
|
||||
//! independent nested sessions), so the §6.1 group machinery alone would run the FIRST session's
|
||||
//! restore at that session's teardown and re-light the panel under a second, still-streaming
|
||||
//! session. Instead each exclusive spawn takes one [`acquire_stream_darken`] hold (the 0→1 edge
|
||||
//! darkens) and registers [`release_stream_darken`] as its per-display topology restore (the 1→0
|
||||
//! edge re-lights) — the same shape as `sleep_inhibit`'s refcount, riding the registry only for
|
||||
//! the *timing* of each release.
|
||||
//!
|
||||
//! Crash safety comes free: DPMS is non-persistent, so a host that dies holding the panel dark
|
||||
//! leaves nothing to journal — the screen re-lights on the next local input or compositor
|
||||
//! restart. (Contrast the Windows `pnp_disable_monitors` path, which needs a recovery journal
|
||||
//! precisely because its disable survives everything.)
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::os::fd::{AsFd, AsRawFd};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
use wayland_client::protocol::wl_callback::{self, WlCallback};
|
||||
use wayland_client::protocol::wl_output::{self, WlOutput};
|
||||
use wayland_client::protocol::wl_registry::{self, WlRegistry};
|
||||
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle};
|
||||
|
||||
// Client bindings for the vendored KDE dpms protocol (`protocols/dpms.xml`), generated inline like
|
||||
// the two in `kwin_output_mgmt`. Self-contained: its only foreign object type is the core
|
||||
// `wl_output`, which `wayland_client::protocol` already provides.
|
||||
#[allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
|
||||
pub mod protocol {
|
||||
use wayland_client;
|
||||
use wayland_client::protocol::*;
|
||||
|
||||
pub mod __interfaces {
|
||||
use wayland_client::protocol::__interfaces::*;
|
||||
wayland_scanner::generate_interfaces!("protocols/dpms.xml");
|
||||
}
|
||||
use self::__interfaces::*;
|
||||
|
||||
wayland_scanner::generate_client_code!("protocols/dpms.xml");
|
||||
}
|
||||
|
||||
use protocol::org_kde_kwin_dpms::{Event as DpmsEvent, OrgKdeKwinDpms as Dpms};
|
||||
use protocol::org_kde_kwin_dpms_manager::OrgKdeKwinDpmsManager as DpmsManager;
|
||||
|
||||
// The wire enum `org_kde_kwin_dpms.mode`. The XML types the `mode` request/event args as plain
|
||||
// `uint` (no `enum=` attribute), so the generated signatures take/deliver `u32` — these constants
|
||||
// are the protocol's values, kept in sync with the vendored `dpms.xml`.
|
||||
const DPMS_MODE_ON: u32 = 0;
|
||||
const DPMS_MODE_OFF: u32 = 3;
|
||||
|
||||
/// `org_kde_kwin_dpms_manager` is a frozen v1 protocol (its own header warns it may change
|
||||
/// without a version bump, but no v2 has appeared since 2015); bind `min(advertised, 1)`.
|
||||
const MANAGER_MAX: u32 = 1;
|
||||
/// `wl_output.name` — the connector name used for logging — arrived in v4. Everything else we do
|
||||
/// works at v1, so a lower advert just costs the log its names.
|
||||
const WL_OUTPUT_MAX: u32 = 4;
|
||||
|
||||
/// Overall budget for one darken/re-light operation (mirrors `kwin_output_mgmt::OP_BUDGET`):
|
||||
/// generous next to a healthy roundtrip, and only there so a wedged compositor can't pin the
|
||||
/// session-create (or group-teardown) thread.
|
||||
const OP_BUDGET: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Poll slice while waiting on the Wayland fd (matches `kwin_output_mgmt`).
|
||||
const POLL_MS: i32 = 100;
|
||||
|
||||
/// One output's accumulated state on this connection, keyed by its `wl_output` global name.
|
||||
#[derive(Default)]
|
||||
struct OutputState {
|
||||
proxy: Option<WlOutput>,
|
||||
/// Connector name (`DP-1`) from `wl_output.name` (v4) — logging only; the global number is
|
||||
/// the address everything operates on.
|
||||
connector: Option<String>,
|
||||
dpms: Option<Dpms>,
|
||||
/// `org_kde_kwin_dpms.supported` — `None` until the bind burst arrives.
|
||||
supported: Option<bool>,
|
||||
/// The last `org_kde_kwin_dpms.mode` seen — kept current, so the post-`set` wait can watch it
|
||||
/// flip.
|
||||
mode: Option<u32>,
|
||||
}
|
||||
|
||||
/// Everything one connection's queue accumulates.
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
manager: Option<DpmsManager>,
|
||||
/// Keyed by the `wl_output` GLOBAL NAME — a stable address for the compositor's lifetime, and
|
||||
/// the identity the darken records so the re-light (a separate, later connection) can find the
|
||||
/// same outputs again.
|
||||
outputs: HashMap<u32, OutputState>,
|
||||
/// Highest `wl_callback` serial whose `done` has arrived — the barrier the pump waits on.
|
||||
sync_done: u32,
|
||||
}
|
||||
|
||||
impl Dispatch<WlRegistry, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
registry: &WlRegistry,
|
||||
event: wl_registry::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
wl_registry::Event::Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} => {
|
||||
if interface == DpmsManager::interface().name {
|
||||
let v = version.min(MANAGER_MAX);
|
||||
state.manager = Some(registry.bind::<DpmsManager, _, _>(name, v, qh, ()));
|
||||
} else if interface == WlOutput::interface().name {
|
||||
let v = version.min(WL_OUTPUT_MAX);
|
||||
// The global name rides in the UserData so the output's own events (and the
|
||||
// dpms object's, which gets the same stamp) can find this entry.
|
||||
let out = registry.bind::<WlOutput, _, _>(name, v, qh, name);
|
||||
state.outputs.entry(name).or_default().proxy = Some(out);
|
||||
}
|
||||
}
|
||||
// An output unplugged mid-operation: drop the entry so we never `set` on its corpse.
|
||||
wl_registry::Event::GlobalRemove { name } => {
|
||||
state.outputs.remove(&name);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlOutput, u32> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &WlOutput,
|
||||
event: wl_output::Event,
|
||||
global: &u32,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_output::Event::Name { name } = event {
|
||||
if let Some(o) = state.outputs.get_mut(global) {
|
||||
o.connector = Some(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<Dpms, u32> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &Dpms,
|
||||
event: DpmsEvent,
|
||||
global: &u32,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
let Some(o) = state.outputs.get_mut(global) else {
|
||||
return;
|
||||
};
|
||||
match event {
|
||||
DpmsEvent::Supported { supported } => o.supported = Some(supported != 0),
|
||||
DpmsEvent::Mode { mode } => o.mode = Some(mode),
|
||||
DpmsEvent::Done => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The manager has no events; the impl exists because `WlRegistry::bind` demands one.
|
||||
impl Dispatch<DpmsManager, ()> for State {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
_: &DpmsManager,
|
||||
_: protocol::org_kde_kwin_dpms_manager::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlCallback, u32> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &WlCallback,
|
||||
event: wl_callback::Event,
|
||||
serial: &u32,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_callback::Event::Done { .. } = event {
|
||||
state.sync_done = state.sync_done.max(*serial);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why [`Session::open`] declined — the same honest-decline discipline as
|
||||
/// `kwin_output_mgmt::OpenFailure`: which rung said no decides both the log level and whether the
|
||||
/// `kscreen-doctor` fallback is worth attempting.
|
||||
enum OpenFailure {
|
||||
/// No Wayland connection at all (`WAYLAND_DISPLAY` unset/stale). The common case for the bare
|
||||
/// spawn's natural habitat — a headless plain-distro box with no desktop to darken.
|
||||
Connect(String),
|
||||
/// The compositor accepted the connection but did not answer the registry barrier in budget:
|
||||
/// a live but wedged session — the case the shell-out fallback exists for.
|
||||
RegistryBarrier,
|
||||
/// Connected and answering, but `org_kde_kwin_dpms_manager` is not advertised — not KWin. A
|
||||
/// definitive answer: no fallback can succeed here either (`kscreen-doctor` drives the same
|
||||
/// KDE-only machinery), so this rung declines without one.
|
||||
NoDpmsGlobal,
|
||||
/// The manager is there but the per-output DPMS state bursts never completed in budget.
|
||||
StateBarrier,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for OpenFailure {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
OpenFailure::Connect(e) => write!(f, "no Wayland connection ({e})"),
|
||||
OpenFailure::RegistryBarrier => {
|
||||
write!(
|
||||
f,
|
||||
"the compositor did not answer the registry roundtrip in budget"
|
||||
)
|
||||
}
|
||||
OpenFailure::NoDpmsGlobal => {
|
||||
write!(f, "org_kde_kwin_dpms_manager is not advertised (not KWin)")
|
||||
}
|
||||
OpenFailure::StateBarrier => {
|
||||
write!(
|
||||
f,
|
||||
"the outputs' DPMS state never finished announcing in budget"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A connected session with the manager bound and every output's DPMS state read.
|
||||
struct Session {
|
||||
conn: Connection,
|
||||
queue: wayland_client::EventQueue<State>,
|
||||
state: State,
|
||||
next_sync: u32,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// [`Session::connect`] for the operation named by `op`, logging the decline at a level that
|
||||
/// matches what it means: `Connect`/`NoDpmsGlobal` are the everyday non-KDE answers (most
|
||||
/// bare-spawn boxes have no desktop at all) and log at debug; the two barrier failures mean a
|
||||
/// LIVE session stopped answering — on a KDE box that is a panel left lit, so they warn.
|
||||
fn open(op: &'static str) -> Result<Session, OpenFailure> {
|
||||
let opened = Session::connect();
|
||||
if let Err(reason) = &opened {
|
||||
match reason {
|
||||
OpenFailure::Connect(_) | OpenFailure::NoDpmsGlobal => {
|
||||
tracing::debug!(op, %reason, "KWin DPMS unavailable");
|
||||
}
|
||||
OpenFailure::RegistryBarrier | OpenFailure::StateBarrier => {
|
||||
tracing::warn!(
|
||||
op,
|
||||
%reason,
|
||||
"KWin DPMS: in-process path unavailable — falling back to kscreen-doctor"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
opened
|
||||
}
|
||||
|
||||
/// Connect to the desktop's Wayland socket, bind the dpms manager + every `wl_output`, create
|
||||
/// a dpms status object per output and drain their state bursts — all bounded by [`OP_BUDGET`].
|
||||
fn connect() -> Result<Session, OpenFailure> {
|
||||
let conn = Connection::connect_to_env().map_err(|e| OpenFailure::Connect(e.to_string()))?;
|
||||
let queue = conn.new_event_queue();
|
||||
let qh = queue.handle();
|
||||
let _registry = conn.display().get_registry(&qh, ());
|
||||
let mut s = Session {
|
||||
conn,
|
||||
queue,
|
||||
state: State::default(),
|
||||
next_sync: 0,
|
||||
};
|
||||
let deadline = Instant::now() + OP_BUDGET;
|
||||
// Phase 1: process the registry globals (binds the manager + every wl_output).
|
||||
if !s.sync_barrier(deadline) {
|
||||
return Err(OpenFailure::RegistryBarrier);
|
||||
}
|
||||
let Some(mgr) = s.state.manager.clone() else {
|
||||
return Err(OpenFailure::NoDpmsGlobal);
|
||||
};
|
||||
// Phase 2: one dpms status object per output (stamped with the output's global name so its
|
||||
// events land on the right entry), then a barrier that drains both the outputs' `name`
|
||||
// events and the dpms objects' supported/mode/done bursts.
|
||||
let qh = s.queue.handle();
|
||||
let bound: Vec<(u32, WlOutput)> = s
|
||||
.state
|
||||
.outputs
|
||||
.iter()
|
||||
.filter_map(|(g, o)| o.proxy.clone().map(|p| (*g, p)))
|
||||
.collect();
|
||||
for (global, out) in bound {
|
||||
let d = mgr.get(&out, &qh, global);
|
||||
if let Some(o) = s.state.outputs.get_mut(&global) {
|
||||
o.dpms = Some(d);
|
||||
}
|
||||
}
|
||||
if !s.sync_barrier(deadline) {
|
||||
return Err(OpenFailure::StateBarrier);
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Send a `wl_display.sync` and pump the queue until its `done` arrives or `deadline` passes.
|
||||
fn sync_barrier(&mut self, deadline: Instant) -> bool {
|
||||
self.next_sync += 1;
|
||||
let serial = self.next_sync;
|
||||
let qh = self.queue.handle();
|
||||
let _cb = self.conn.display().sync(&qh, serial);
|
||||
self.pump_until(deadline, |st| st.sync_done >= serial)
|
||||
}
|
||||
|
||||
/// Bounded manual event loop — flush, dispatch, poll the fd. Mirrors
|
||||
/// `kwin_output_mgmt::Session::pump_until` (same rationale: `blocking_dispatch` can't be
|
||||
/// interrupted, so the fd is polled in [`POLL_MS`] slices against `deadline`).
|
||||
fn pump_until(&mut self, deadline: Instant, done: impl Fn(&State) -> bool) -> bool {
|
||||
loop {
|
||||
if done(&self.state) {
|
||||
return true;
|
||||
}
|
||||
if self.queue.dispatch_pending(&mut self.state).is_err() {
|
||||
return false;
|
||||
}
|
||||
if done(&self.state) {
|
||||
return true;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
if self.conn.flush().is_err() {
|
||||
return false;
|
||||
}
|
||||
let Some(guard) = self.conn.prepare_read() else {
|
||||
continue; // events already queued — loop dispatches them
|
||||
};
|
||||
let mut pfd = libc::pollfd {
|
||||
fd: self.conn.as_fd().as_raw_fd(),
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
let timeout = (remaining.as_millis() as i32).clamp(0, POLL_MS);
|
||||
// SAFETY: `&mut pfd` points at one live, fully-initialized `libc::pollfd` on the stack
|
||||
// and the count `1` matches that single element, so `poll` reads `fd`/`events` and
|
||||
// writes `revents` strictly within `pfd`. `pfd.fd` is the Wayland connection's fd,
|
||||
// valid because `self.conn` (and the `prepare_read` guard) outlive the call. `poll`
|
||||
// blocks up to `timeout` ms and writes only `revents`; `pfd` is a fresh local that
|
||||
// aliases nothing.
|
||||
let r = unsafe { libc::poll(&mut pfd, 1, timeout) };
|
||||
if r > 0 && (pfd.revents & libc::POLLIN) != 0 {
|
||||
let _ = guard.read();
|
||||
} // else: timeout/signal — drop the guard, re-check the deadline
|
||||
}
|
||||
}
|
||||
|
||||
/// Request `target` on every DPMS-supporting output not already there — restricted to the
|
||||
/// globals in `only` when given (the re-light path, which must touch ONLY what the darken
|
||||
/// touched: a panel the USER had put to sleep before the stream is theirs to keep dark).
|
||||
/// Returns the outputs actually asked to change, `(global, connector)`, then waits (within
|
||||
/// budget) for each one's `mode` event to confirm — the protocol is explicit that `set` is a
|
||||
/// request the compositor may decline, so the confirmation is watched and its absence logged
|
||||
/// rather than assumed.
|
||||
fn set_mode(&mut self, target: u32, only: Option<&[u32]>) -> Vec<(u32, Option<String>)> {
|
||||
let deadline = Instant::now() + OP_BUDGET;
|
||||
let mut touched: Vec<(u32, Option<String>)> = Vec::new();
|
||||
for (global, o) in &self.state.outputs {
|
||||
if only.is_some_and(|list| !list.contains(global)) {
|
||||
continue;
|
||||
}
|
||||
if o.supported != Some(true) || o.mode == Some(target) {
|
||||
continue;
|
||||
}
|
||||
if let Some(dpms) = &o.dpms {
|
||||
dpms.set(target);
|
||||
touched.push((*global, o.connector.clone()));
|
||||
}
|
||||
}
|
||||
if touched.is_empty() {
|
||||
return touched;
|
||||
}
|
||||
let want: Vec<u32> = touched.iter().map(|(g, _)| *g).collect();
|
||||
// An output that vanished mid-wait (GlobalRemove pruned it) counts as settled — there is
|
||||
// nothing left to flip.
|
||||
let confirmed = self.pump_until(deadline, |st| {
|
||||
want.iter()
|
||||
.all(|g| st.outputs.get(g).is_none_or(|o| o.mode == Some(target)))
|
||||
});
|
||||
if !confirmed {
|
||||
tracing::warn!(
|
||||
outputs = ?touched,
|
||||
target,
|
||||
"KWin DPMS: the compositor did not confirm the mode change in budget (the \
|
||||
requests are flushed; it may still land, or KWin may have declined)"
|
||||
);
|
||||
}
|
||||
touched
|
||||
}
|
||||
}
|
||||
|
||||
/// What the 0→1 darken actually achieved — the record the 1→0 re-light undoes. Which arm did the
|
||||
/// work matters: the two are undone through different doors.
|
||||
enum Darkened {
|
||||
/// The in-process path turned these outputs off — `(wl_output global, connector)`. Global
|
||||
/// names are stable for the compositor's lifetime, so a later connection re-lights exactly
|
||||
/// these. If KWin restarted in between the names match nothing — and that is the CORRECT
|
||||
/// no-op, because a fresh KWin brings its outputs up lit anyway.
|
||||
Wayland(Vec<(u32, Option<String>)>),
|
||||
/// The `kscreen-doctor --dpms off` fallback ran (it takes no per-output address, so the
|
||||
/// re-light is the symmetric `--dpms on`).
|
||||
Kscreen,
|
||||
}
|
||||
|
||||
/// The host-wide darken hold — refcounted like `sleep_inhibit`: the 0→1 edge darkens, the 1→0
|
||||
/// edge re-lights, and everything between is bookkeeping. See the module docs for why the
|
||||
/// registry's per-group restore float can't provide this (every gamescope spawn is its own group).
|
||||
struct Holds {
|
||||
count: u32,
|
||||
/// What the 0→1 darken achieved, held until the 1→0 release undoes it. `None` while count > 0
|
||||
/// means the darken found nothing to do (no KDE, panels already dark) — the release then has
|
||||
/// nothing to undo, which is exactly right.
|
||||
darkened: Option<Darkened>,
|
||||
}
|
||||
|
||||
impl Holds {
|
||||
/// Take a hold; `true` on the 0→1 edge — the caller darkens and [`record`](Self::record)s.
|
||||
fn acquire_edge(&mut self) -> bool {
|
||||
self.count += 1;
|
||||
self.count == 1
|
||||
}
|
||||
|
||||
/// Store the 0→1 darken's outcome.
|
||||
fn record(&mut self, d: Option<Darkened>) {
|
||||
self.darkened = d;
|
||||
}
|
||||
|
||||
/// Drop a hold; `Some` on the 1→0 edge hands the caller the record to undo. A release with no
|
||||
/// hold outstanding is a caller bug (an unbalanced restore) — logged, never underflowed.
|
||||
fn release_edge(&mut self) -> Option<Darkened> {
|
||||
if self.count == 0 {
|
||||
tracing::warn!("KWin DPMS: release without a matching acquire (unbalanced restore)");
|
||||
return None;
|
||||
}
|
||||
self.count -= 1;
|
||||
if self.count == 0 {
|
||||
self.darkened.take()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static HOLDS: Mutex<Holds> = Mutex::new(Holds {
|
||||
count: 0,
|
||||
darkened: None,
|
||||
});
|
||||
|
||||
/// Take one darken hold for an exclusive-topology stream. The first hold turns the live KDE
|
||||
/// desktop's panels off (best-effort, bounded); later holds just count. Callers MUST balance each
|
||||
/// call with [`release_stream_darken`] — the gamescope backend does it by registering the release
|
||||
/// as the display's topology restore, so the registry runs it exactly once per display at
|
||||
/// teardown (§6.1).
|
||||
///
|
||||
/// The lock is deliberately held across the darken itself: a racing second acquire must queue
|
||||
/// behind it (and then see the recorded outcome), not observe a count of 2 with nothing darkened.
|
||||
/// Same discipline on the release side, which keeps a teardown-overlapping-connect sequence
|
||||
/// strictly ordered: re-light completes, then the new stream's darken runs.
|
||||
pub fn acquire_stream_darken() {
|
||||
let mut h = HOLDS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if h.acquire_edge() {
|
||||
let d = darken();
|
||||
h.record(d);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop one darken hold; the last one out re-lights whatever the first hold's darken achieved.
|
||||
pub fn release_stream_darken() {
|
||||
let mut h = HOLDS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(d) = h.release_edge() {
|
||||
relight(d);
|
||||
}
|
||||
}
|
||||
|
||||
/// The 0→1 darken: in-process over `org_kde_kwin_dpms` first, `kscreen-doctor --dpms off` as the
|
||||
/// wedged-compositor fallback. `None` = nothing was darkened (no desktop, not KDE, panels already
|
||||
/// off, or every arm declined) — and therefore nothing to restore.
|
||||
fn darken() -> Option<Darkened> {
|
||||
match Session::open("darken") {
|
||||
Ok(mut s) => {
|
||||
let touched = s.set_mode(DPMS_MODE_OFF, None);
|
||||
if touched.is_empty() {
|
||||
tracing::debug!(
|
||||
"KWin DPMS: no output to darken (none supported, or all already off)"
|
||||
);
|
||||
None
|
||||
} else {
|
||||
tracing::info!(
|
||||
outputs = ?touched,
|
||||
"KWin DPMS: desktop outputs off for the exclusive gamescope stream"
|
||||
);
|
||||
Some(Darkened::Wayland(touched))
|
||||
}
|
||||
}
|
||||
// Definitive "not KDE" / "no desktop": no fallback can do better (kscreen-doctor drives
|
||||
// the same KDE-only machinery), so decline quietly — already logged by `open`.
|
||||
Err(OpenFailure::NoDpmsGlobal) | Err(OpenFailure::Connect(_)) => None,
|
||||
// A live session that stopped answering: the standalone tool rides a different stack
|
||||
// (libkscreen/KDED) and may still get through — the same rationale as `kwin.rs`'s
|
||||
// kscreen fallbacks, honest-verdict discipline included.
|
||||
Err(_) => match kscreen_dpms("off") {
|
||||
Some(true) => {
|
||||
tracing::info!(
|
||||
"KWin DPMS: desktop outputs off for the exclusive gamescope stream \
|
||||
(kscreen-doctor fallback)"
|
||||
);
|
||||
Some(Darkened::Kscreen)
|
||||
}
|
||||
// Killed at its budget — NOT a refusal: kscreen-doctor applies first and then waits
|
||||
// on the compositor, so a loaded KWin routinely lands the change and still gets
|
||||
// killed. Record the darken so the teardown re-light runs either way; a `--dpms on`
|
||||
// against a lit panel is a no-op.
|
||||
None => Some(Darkened::Kscreen),
|
||||
Some(false) => {
|
||||
tracing::warn!(
|
||||
"KWin DPMS: could not darken the desktop outputs for the exclusive topology \
|
||||
(in-process path and kscreen-doctor both declined) — the panel stays lit"
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The 1→0 re-light. **This is the last line of defence for a dark monitor**, so every arm that
|
||||
/// gives up says so loudly (the same discipline as `kwin.rs::reenable_outputs_kscreen`) — a dark
|
||||
/// panel with no line in the log is the failure mode this chain exists to prevent. The worst case
|
||||
/// stays self-healing regardless: DPMS is non-persistent, and any local input wakes the panel.
|
||||
fn relight(d: Darkened) {
|
||||
match d {
|
||||
Darkened::Wayland(outputs) => {
|
||||
let globals: Vec<u32> = outputs.iter().map(|(g, _)| *g).collect();
|
||||
match Session::open("re-light") {
|
||||
Ok(mut s) => {
|
||||
s.set_mode(DPMS_MODE_ON, Some(&globals));
|
||||
tracing::info!(outputs = ?outputs, "KWin DPMS: desktop outputs back on");
|
||||
}
|
||||
Err(_) => match kscreen_dpms("on") {
|
||||
Some(true) | None => {
|
||||
tracing::info!(
|
||||
"KWin DPMS: desktop outputs back on (kscreen-doctor fallback)"
|
||||
);
|
||||
}
|
||||
Some(false) => {
|
||||
tracing::error!(
|
||||
outputs = ?outputs,
|
||||
"KWin DPMS: could NOT re-light the desktop outputs (in-process \
|
||||
restore and kscreen-doctor both declined) — the panel stays dark \
|
||||
until local input wakes it"
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Darkened::Kscreen => {
|
||||
if kscreen_dpms("on") == Some(false) {
|
||||
tracing::error!(
|
||||
"KWin DPMS: could NOT re-light the desktop outputs (kscreen-doctor refused \
|
||||
the --dpms on it earlier accepted the off for) — the panel stays dark until \
|
||||
local input wakes it"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `kscreen-doctor --dpms <on|off>` for its verdict, on `kwin.rs`'s shared budget and three-state
|
||||
/// convention (`Some(true)` ran and succeeded, `Some(false)` refused or unrunnable, `None` killed
|
||||
/// at the budget — which, for a tool that applies first and waits after, usually means it landed).
|
||||
fn kscreen_dpms(mode: &'static str) -> Option<bool> {
|
||||
crate::kwin::kscreen_verdict(&["--dpms".to_string(), mode.to_string()])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Darkened, Holds};
|
||||
|
||||
fn fresh() -> Holds {
|
||||
Holds {
|
||||
count: 0,
|
||||
darkened: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_acquire_darkens_later_ones_count() {
|
||||
let mut h = fresh();
|
||||
assert!(h.acquire_edge(), "0→1 must darken");
|
||||
h.record(Some(Darkened::Kscreen));
|
||||
assert!(
|
||||
!h.acquire_edge(),
|
||||
"a second concurrent stream must not re-darken"
|
||||
);
|
||||
assert!(!h.acquire_edge());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_last_release_relights() {
|
||||
let mut h = fresh();
|
||||
assert!(h.acquire_edge());
|
||||
h.record(Some(Darkened::Wayland(vec![(7, Some("DP-1".into()))])));
|
||||
assert!(!h.acquire_edge());
|
||||
// First release: a sibling still streams — the panel must stay dark.
|
||||
assert!(h.release_edge().is_none());
|
||||
// Last release hands back the record to undo.
|
||||
let d = h.release_edge();
|
||||
assert!(matches!(d, Some(Darkened::Wayland(v)) if v == vec![(7, Some("DP-1".into()))]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_darken_that_did_nothing_restores_nothing() {
|
||||
let mut h = fresh();
|
||||
assert!(h.acquire_edge());
|
||||
h.record(None); // no KDE / already dark: nothing was changed
|
||||
assert!(h.release_edge().is_none(), "nothing to undo");
|
||||
assert_eq!(h.count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbalanced_release_never_underflows() {
|
||||
let mut h = fresh();
|
||||
assert!(h.release_edge().is_none());
|
||||
assert_eq!(h.count, 0, "count must not wrap");
|
||||
// And the state machine still works afterwards.
|
||||
assert!(h.acquire_edge());
|
||||
h.record(Some(Darkened::Kscreen));
|
||||
assert!(matches!(h.release_edge(), Some(Darkened::Kscreen)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_cycle_rearms_the_darken() {
|
||||
let mut h = fresh();
|
||||
assert!(h.acquire_edge());
|
||||
h.record(Some(Darkened::Kscreen));
|
||||
assert!(h.release_edge().is_some());
|
||||
// A later stream on the same host lifetime darkens again.
|
||||
assert!(
|
||||
h.acquire_edge(),
|
||||
"the 0→1 edge must re-arm after a full cycle"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
//! Which ScreenCast cursor mode to ASK the portal for — negotiated against what the backend
|
||||
//! advertises, rather than asserted.
|
||||
//!
|
||||
//! The portal spec is unforgiving here: `SelectSources` with a cursor mode that is absent from
|
||||
//! `AvailableCursorModes` does not quietly degrade — **xdg-desktop-portal itself rejects the call**
|
||||
//! (`"Unavailable cursor mode %x"`, an `INVALID_ARGUMENT` from the FRONTEND, which validates the
|
||||
//! request against the backend's advertised bitfield before the backend ever sees it). Both
|
||||
//! wlr-family backends used to hardcode `Metadata` whenever the session had negotiated the cursor
|
||||
//! channel, so every cursor-forward session died at `select_sources` — `unavailable cursor mode 4`
|
||||
//! (4 being `Metadata`'s bit) and a client left on a black screen behind "pipeline build failed".
|
||||
//! Field report 2026-08-14.
|
||||
//!
|
||||
//! ⚠️ This is NOT a stale-portal problem, and not Hyprland-specific. MEASURED on .21 2026-08-14 on
|
||||
//! fully current packages — Hyprland **0.56.2**, xdg-desktop-portal-hyprland **1.4.1**,
|
||||
//! xdg-desktop-portal **1.22.1** — with a live session and xdph attached (`[screencopy] init
|
||||
//! successful`): `AvailableCursorModes` reads **3** (`Hidden|Embedded`) on both the backend impl
|
||||
//! interface and the frontend. **Metadata is simply not offered by xdph today.** xdpw is the same
|
||||
//! story from the other end: its `screencast.c` refuses `METADATA` outright. So the hardcode broke
|
||||
//! every cursor-forward session on the entire wlr family, on current software — not only on old
|
||||
//! installs. (xdph 1.4.1 would itself fall back — its binary carries
|
||||
//! `"[screencopy] unsupported cursor_mode {}, fallback to {}"` — but it never gets the chance,
|
||||
//! because the frontend fails the call first.)
|
||||
//!
|
||||
//! `pf-capture`'s own portal path has always negotiated (`portal::choose_cursor_mode`) — this is
|
||||
//! that ladder, restated in the crate that owns the virtual-display backends. pf-vdisplay must not
|
||||
//! depend on pf-capture (see this crate's Cargo.toml: "never on capture/inject or the
|
||||
//! orchestrator"), so the two copies are deliberate; keep the ladders in step.
|
||||
//!
|
||||
//! Declared unconditionally although only the Linux backends call it: the ladder is pure integer
|
||||
//! work, and its tests are the whole point of the module — this is a decision that leaves no trace
|
||||
//! anyone can check without a compositor in front of them — so they run on every platform's CI
|
||||
//! rather than on the one leg that compiles `mod hyprland`.
|
||||
|
||||
/// A ScreenCast cursor mode, valued as the portal's own wire bits — which is what a backend prints
|
||||
/// when it rejects one, so `Metadata`'s `4` is literally the number in the field report.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Mode {
|
||||
/// No pointer in the cast at all.
|
||||
Hidden = 1,
|
||||
/// The compositor paints the pointer into the frames it hands us.
|
||||
Embedded = 2,
|
||||
/// The pointer rides `SPA_META_Cursor` metadata beside the frames: the compositor keeps its
|
||||
/// cheap hardware cursor plane, and the consumer either composites the shape itself or
|
||||
/// forwards it to a client that draws its own.
|
||||
Metadata = 4,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
/// The portal's bit for this mode.
|
||||
pub(crate) const fn bit(self) -> u32 {
|
||||
self as u32
|
||||
}
|
||||
|
||||
/// The spelling used in logs and in `PUNKTFUNK_PORTAL_CURSOR_MODE`.
|
||||
pub(crate) const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Mode::Hidden => "hidden",
|
||||
Mode::Embedded => "embedded",
|
||||
Mode::Metadata => "metadata",
|
||||
}
|
||||
}
|
||||
|
||||
/// What to ask for instead, best first, when this mode is not advertised.
|
||||
const fn fallbacks(self) -> [Mode; 2] {
|
||||
match self {
|
||||
// The session wanted out-of-band shapes and cannot have them. `Embedded` still puts a
|
||||
// pointer on the client's screen (the compositor's, burnt in) — and because no
|
||||
// `SPA_META_Cursor` then arrives, the host feeds the cursor channel nothing and a
|
||||
// cursor-forward client draws nothing of its own, so this is one pointer, not two.
|
||||
// `Hidden` is last: it streams a desktop nobody can point at.
|
||||
Mode::Metadata => [Mode::Embedded, Mode::Hidden],
|
||||
// Embedded wanted but not offered. Metadata still beats Hidden: the CPU capture path
|
||||
// composites `SPA_META_Cursor` inline, so part of the matrix keeps a pointer.
|
||||
Mode::Embedded => [Mode::Metadata, Mode::Hidden],
|
||||
// A deliberate request for no pointer that the backend will not honour. Either
|
||||
// remaining mode shows one; prefer the cheap burnt-in pointer over metadata nothing on
|
||||
// this path is set up to draw.
|
||||
Mode::Hidden => [Mode::Embedded, Mode::Metadata],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcome of the ladder: what to request, and what the session actually wanted if those
|
||||
/// differ (the caller logs the gap — a silently downgraded cursor is how this class of bug hides).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Choice {
|
||||
/// The mode to put in `SelectSources`. Advertised, unless the backend advertised nothing.
|
||||
pub(crate) mode: Mode,
|
||||
/// Set only when `mode` is a downgrade: the mode the session asked for and could not have.
|
||||
pub(crate) wanted: Option<Mode>,
|
||||
}
|
||||
|
||||
/// Pick the cursor mode to request, given the backend's `AvailableCursorModes` bitfield.
|
||||
///
|
||||
/// Never returns a mode outside `advertised` unless `advertised` names none we know — see the tail
|
||||
/// comment, which is the one case with no right answer.
|
||||
pub(crate) fn pick(advertised: u32, want: Mode) -> Choice {
|
||||
if advertised & want.bit() != 0 {
|
||||
return Choice {
|
||||
mode: want,
|
||||
wanted: None,
|
||||
};
|
||||
}
|
||||
for alt in want.fallbacks() {
|
||||
if advertised & alt.bit() != 0 {
|
||||
return Choice {
|
||||
mode: alt,
|
||||
wanted: Some(want),
|
||||
};
|
||||
}
|
||||
}
|
||||
// The backend advertised no mode this build knows — 0, or only bits from a spec revision newer
|
||||
// than us. Every request is then a coin flip against a session-closing rejection; `Hidden` is
|
||||
// both the most universally implemented and the only one that cannot end up drawing two
|
||||
// pointers. The caller warns: whatever this backend is doing, we are guessing.
|
||||
Choice {
|
||||
mode: Mode::Hidden,
|
||||
wanted: Some(want),
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed `PUNKTFUNK_PORTAL_CURSOR_MODE`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Pin {
|
||||
/// Unset or `auto` — the session's own negotiation decides.
|
||||
Auto,
|
||||
/// Prefer this mode instead of what the session negotiated. Still runs the ladder, so a pin
|
||||
/// can never re-create the session-killing request this module exists to prevent.
|
||||
Mode(Mode),
|
||||
/// Set to something we do not recognise. Treated as `Auto`, but the caller says so out loud —
|
||||
/// a typo'd escape hatch that silently does nothing is worse than no escape hatch.
|
||||
Unrecognised,
|
||||
}
|
||||
|
||||
/// Parse the `PUNKTFUNK_PORTAL_CURSOR_MODE` value.
|
||||
pub(crate) fn parse_pin(raw: &str) -> Pin {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"" | "auto" => Pin::Auto,
|
||||
"hidden" | "none" => Pin::Mode(Mode::Hidden),
|
||||
"embedded" | "composited" => Pin::Mode(Mode::Embedded),
|
||||
"metadata" | "meta" => Pin::Mode(Mode::Metadata),
|
||||
_ => Pin::Unrecognised,
|
||||
}
|
||||
}
|
||||
|
||||
/// The mode this session wants before the backend gets a say: `Metadata` when the cursor channel
|
||||
/// was negotiated (`set_hw_cursor` — the client draws the pointer, so the compositor must not burn
|
||||
/// it in), `Embedded` otherwise. `PUNKTFUNK_PORTAL_CURSOR_MODE` overrides both.
|
||||
///
|
||||
/// `backend` names the portal implementation for the log line only (`xdph`, `xdpw`).
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
pub(crate) fn want(hw_cursor: bool, backend: &str) -> Mode {
|
||||
let negotiated = if hw_cursor {
|
||||
Mode::Metadata
|
||||
} else {
|
||||
Mode::Embedded
|
||||
};
|
||||
let raw = match pf_host_config::config().portal_cursor_mode.as_deref() {
|
||||
Some(raw) => raw,
|
||||
None => return negotiated,
|
||||
};
|
||||
match parse_pin(raw) {
|
||||
Pin::Auto => negotiated,
|
||||
Pin::Mode(pinned) => {
|
||||
tracing::info!(
|
||||
backend,
|
||||
pinned = pinned.name(),
|
||||
negotiated = negotiated.name(),
|
||||
"ScreenCast: cursor mode pinned by PUNKTFUNK_PORTAL_CURSOR_MODE"
|
||||
);
|
||||
pinned
|
||||
}
|
||||
Pin::Unrecognised => {
|
||||
tracing::warn!(
|
||||
backend,
|
||||
value = raw,
|
||||
negotiated = negotiated.name(),
|
||||
"ScreenCast: unrecognised PUNKTFUNK_PORTAL_CURSOR_MODE (want auto|hidden|embedded|\
|
||||
metadata) — ignoring"
|
||||
);
|
||||
negotiated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Mode {
|
||||
fn to_ashpd(self) -> ashpd::desktop::screencast::CursorMode {
|
||||
use ashpd::desktop::screencast::CursorMode;
|
||||
match self {
|
||||
Mode::Hidden => CursorMode::Hidden,
|
||||
Mode::Embedded => CursorMode::Embedded,
|
||||
Mode::Metadata => CursorMode::Metadata,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the portal what it supports, run the ladder, and hand back the mode to put in
|
||||
/// `SelectSources`. Infallible by construction: a backend we cannot interrogate gets `Embedded`,
|
||||
/// the mode that predates the property and that every implementation has always had.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) async fn negotiate(
|
||||
proxy: &ashpd::desktop::screencast::Screencast,
|
||||
hw_cursor: bool,
|
||||
backend: &str,
|
||||
) -> ashpd::desktop::screencast::CursorMode {
|
||||
let want = want(hw_cursor, backend);
|
||||
let advertised = match proxy.available_cursor_modes().await {
|
||||
Ok(avail) => avail.bits(),
|
||||
Err(e) => {
|
||||
// `AvailableCursorModes` is a versioned property (ScreenCast v2); a portal too old to
|
||||
// publish it is also too old to have metadata, and `Embedded` is what this backend
|
||||
// requested for its whole life before the cursor channel existed.
|
||||
tracing::warn!(
|
||||
backend,
|
||||
error = %e,
|
||||
"ScreenCast: AvailableCursorModes query failed — requesting Embedded cursor"
|
||||
);
|
||||
return Mode::Embedded.to_ashpd();
|
||||
}
|
||||
};
|
||||
let choice = pick(advertised, want);
|
||||
match choice.wanted {
|
||||
None => tracing::info!(
|
||||
backend,
|
||||
advertised = format_args!("{advertised:#05b}"),
|
||||
mode = choice.mode.name(),
|
||||
"ScreenCast: cursor mode negotiated"
|
||||
),
|
||||
// The downgrade path — and the one that used to be a dead session. Loud, because a stream
|
||||
// whose pointer quietly changed hands is exactly what nobody thinks to check.
|
||||
Some(wanted) => tracing::warn!(
|
||||
backend,
|
||||
advertised = format_args!("{advertised:#05b}"),
|
||||
wanted = wanted.name(),
|
||||
mode = choice.mode.name(),
|
||||
"ScreenCast: requested cursor mode is not advertised by this portal — downgrading \
|
||||
(requesting it anyway would close the session)"
|
||||
),
|
||||
}
|
||||
choice.mode.to_ashpd()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The portal's wire values. These are ABI — a backend rejecting our request prints the
|
||||
/// number, and `4` is the one in the field report that started this module.
|
||||
#[test]
|
||||
fn mode_bits_are_the_portal_wire_values() {
|
||||
assert_eq!(Mode::Hidden.bit(), 1);
|
||||
assert_eq!(Mode::Embedded.bit(), 2);
|
||||
assert_eq!(Mode::Metadata.bit(), 4);
|
||||
}
|
||||
|
||||
/// Our `Mode` is a restatement of ashpd's `CursorMode`, whose bits enumflags2 assigns from
|
||||
/// declaration order — so a reordering upstream would silently repoint every mode. Pin it
|
||||
/// where ashpd is actually compiled.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn mode_bits_match_ashpd() {
|
||||
use ashpd::desktop::screencast::CursorMode;
|
||||
use ashpd::enumflags2::BitFlags;
|
||||
for m in [Mode::Hidden, Mode::Embedded, Mode::Metadata] {
|
||||
assert_eq!(
|
||||
BitFlags::from_flag(m.to_ashpd()).bits(),
|
||||
m.bit(),
|
||||
"{} drifted from ashpd",
|
||||
m.name()
|
||||
);
|
||||
}
|
||||
assert_eq!(BitFlags::from_flag(CursorMode::Metadata).bits(), 4);
|
||||
}
|
||||
|
||||
/// THE REGRESSION, with the real number: `3` is what xdph actually advertises — measured on
|
||||
/// .21 2026-08-14 against a live Hyprland 0.56.2 + xdph 1.4.1, both current. A cursor-forward
|
||||
/// session wants metadata; asking for it made xdg-desktop-portal fail the call, and the client
|
||||
/// got a black screen behind "pipeline build failed" / "unavailable cursor mode 4".
|
||||
#[test]
|
||||
fn metadata_wanted_but_unadvertised_downgrades_to_embedded() {
|
||||
// Exactly the bitfield the portal reported on glass.
|
||||
assert_eq!(Mode::Hidden.bit() | Mode::Embedded.bit(), 3);
|
||||
let c = pick(3, Mode::Metadata);
|
||||
assert_eq!(c.mode, Mode::Embedded);
|
||||
assert_eq!(c.wanted, Some(Mode::Metadata));
|
||||
}
|
||||
|
||||
/// The same portal, a session with no cursor channel: already asking for what exists, so the
|
||||
/// fix must not perturb it.
|
||||
#[test]
|
||||
fn embedded_wanted_and_advertised_is_untouched() {
|
||||
let c = pick(Mode::Hidden.bit() | Mode::Embedded.bit(), Mode::Embedded);
|
||||
assert_eq!(c.mode, Mode::Embedded);
|
||||
assert_eq!(c.wanted, None);
|
||||
}
|
||||
|
||||
/// A portal that does support metadata (KWin, Mutter, xdph ≥ #366) still gets it — the point
|
||||
/// is to stop asserting, not to stop using it.
|
||||
#[test]
|
||||
fn metadata_is_used_where_advertised() {
|
||||
let all = Mode::Hidden.bit() | Mode::Embedded.bit() | Mode::Metadata.bit();
|
||||
let c = pick(all, Mode::Metadata);
|
||||
assert_eq!(c.mode, Mode::Metadata);
|
||||
assert_eq!(c.wanted, None);
|
||||
}
|
||||
|
||||
/// Embedded wanted, only metadata offered: the CPU capture path composites it, so a pointer
|
||||
/// survives. (Mirrors `pf-capture`'s ladder.)
|
||||
#[test]
|
||||
fn embedded_unadvertised_falls_to_metadata_not_hidden() {
|
||||
let c = pick(Mode::Hidden.bit() | Mode::Metadata.bit(), Mode::Embedded);
|
||||
assert_eq!(c.mode, Mode::Metadata);
|
||||
assert_eq!(c.wanted, Some(Mode::Embedded));
|
||||
}
|
||||
|
||||
/// A backend offering only `Hidden`: a cursorless stream beats a closed session.
|
||||
#[test]
|
||||
fn hidden_only_backend_yields_hidden() {
|
||||
let c = pick(Mode::Hidden.bit(), Mode::Metadata);
|
||||
assert_eq!(c.mode, Mode::Hidden);
|
||||
assert_eq!(c.wanted, Some(Mode::Metadata));
|
||||
}
|
||||
|
||||
/// Advertises nothing we know — no right answer, but it must still be a legal enum and flagged
|
||||
/// as a downgrade so the warn fires.
|
||||
#[test]
|
||||
fn unknown_advertisement_guesses_hidden_and_reports_a_downgrade() {
|
||||
for advertised in [0, 0b1000_0000] {
|
||||
let c = pick(advertised, Mode::Metadata);
|
||||
assert_eq!(c.mode, Mode::Hidden);
|
||||
assert_eq!(c.wanted, Some(Mode::Metadata));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whatever the ladder returns must be a mode the backend named — the invariant the old
|
||||
/// hardcode broke. Exhaustive over every advertisement × every want.
|
||||
#[test]
|
||||
fn never_requests_an_unadvertised_mode() {
|
||||
let modes = [Mode::Hidden, Mode::Embedded, Mode::Metadata];
|
||||
for advertised in 1u32..=0b111 {
|
||||
for want in modes {
|
||||
let c = pick(advertised, want);
|
||||
assert!(
|
||||
advertised & c.mode.bit() != 0,
|
||||
"picked {} from advertised {advertised:#05b} (want {})",
|
||||
c.mode.name(),
|
||||
want.name()
|
||||
);
|
||||
// A downgrade is reported exactly when one happened.
|
||||
assert_eq!(c.wanted.is_some(), c.mode != want);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pin_parses_the_spellings_we_document() {
|
||||
assert_eq!(parse_pin(""), Pin::Auto);
|
||||
assert_eq!(parse_pin("auto"), Pin::Auto);
|
||||
assert_eq!(parse_pin(" AUTO "), Pin::Auto);
|
||||
assert_eq!(parse_pin("embedded"), Pin::Mode(Mode::Embedded));
|
||||
assert_eq!(parse_pin("Embedded"), Pin::Mode(Mode::Embedded));
|
||||
assert_eq!(parse_pin("metadata"), Pin::Mode(Mode::Metadata));
|
||||
assert_eq!(parse_pin("hidden"), Pin::Mode(Mode::Hidden));
|
||||
assert_eq!(parse_pin("2"), Pin::Unrecognised);
|
||||
assert_eq!(parse_pin("yes"), Pin::Unrecognised);
|
||||
}
|
||||
|
||||
/// The hatch pins a PREFERENCE, not the request: pinning metadata at a portal without it must
|
||||
/// still come out embedded rather than re-closing the session.
|
||||
#[test]
|
||||
fn a_pin_still_runs_the_ladder() {
|
||||
let c = pick(Mode::Hidden.bit() | Mode::Embedded.bit(), Mode::Metadata);
|
||||
assert_eq!(c.mode, Mode::Embedded);
|
||||
}
|
||||
}
|
||||
@@ -55,12 +55,17 @@ fn chooser_cmd() -> String {
|
||||
/// The wlroots/Sway virtual-display driver. Stateless — each [`create`](VirtualDisplay::create)
|
||||
/// adds one headless output and spins up a portal thread owning the cast on it.
|
||||
pub struct WlrootsDisplay {
|
||||
/// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): portal
|
||||
/// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): PREFER portal
|
||||
/// `CursorMode::Metadata` — shapes/positions ride `SPA_META_Cursor` for the channel + the
|
||||
/// composite blend. Off (every non-channel session): `Embedded` — the compositor paints the
|
||||
/// pointer into frames, zero host-side cursor work (the pre-channel default this backend
|
||||
/// always had). ⚠️ Metadata is UNTESTED on-glass for this backend (Phase B wired it so the
|
||||
/// channel isn't silently dead here; KWin/Mutter are the validated legs).
|
||||
/// composite blend. Off (every non-channel session): prefer `Embedded` — the compositor paints
|
||||
/// the pointer into frames, zero host-side cursor work (the pre-channel default this backend
|
||||
/// always had).
|
||||
///
|
||||
/// Both are only a PREFERENCE: [`crate::portal_cursor`] settles it against what xdpw actually
|
||||
/// advertises, because requesting an unadvertised mode closes the session outright. xdpw
|
||||
/// refuses metadata by construction (see the portal thread), so on this backend the channel can
|
||||
/// never be served out-of-band: it now degrades to `Embedded` and streams, where it used to
|
||||
/// cancel the cast and hand the client a black screen.
|
||||
hw_cursor: bool,
|
||||
}
|
||||
|
||||
@@ -512,13 +517,7 @@ fn portal_thread(
|
||||
stop: Arc<AtomicBool>,
|
||||
hw_cursor: bool,
|
||||
) {
|
||||
// Portal cursor mode per the session's channel negotiation (see the struct doc).
|
||||
let cursor_mode = if hw_cursor {
|
||||
CursorMode::Metadata
|
||||
} else {
|
||||
CursorMode::Embedded
|
||||
};
|
||||
use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
|
||||
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
|
||||
use ashpd::desktop::PersistMode;
|
||||
use ashpd::enumflags2::BitFlags;
|
||||
|
||||
@@ -542,6 +541,14 @@ fn portal_thread(
|
||||
let proxy = Screencast::new().await.context(
|
||||
"connect ScreenCast portal (is xdg-desktop-portal running with the wlr backend?)",
|
||||
)?;
|
||||
// NEGOTIATED against what xdpw advertises, never asserted from `hw_cursor` alone — see
|
||||
// the xdph copy in `hyprland.rs` for the incident. xdpw is the sharper case: its
|
||||
// screencast.c refuses the mode outright —
|
||||
// if (sess->screencast_data.cursor_mode & METADATA) {
|
||||
// logprint(ERROR, "dbus: unsupported cursor mode requested, cancelling");
|
||||
// — so EVERY cursor-forward session on this backend asked for a mode that cancelled the
|
||||
// cast. Different wording from xdph's "unavailable cursor mode 4", same dead session.
|
||||
let cursor_mode = crate::portal_cursor::negotiate(&proxy, hw_cursor, "xdpw").await;
|
||||
let session = proxy
|
||||
.create_session(Default::default())
|
||||
.await
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -26,13 +26,22 @@
|
||||
//! mixing mono or at 24 kHz) loses to real hardware; see [`super::wiring_plan`]. **Never** the
|
||||
//! Steam Streaming Speakers, whose loopback is silent — validated live;
|
||||
//! * default **RECORDING** → the mic target's capture endpoint (VB-Cable "CABLE Output") so host apps
|
||||
//! record the client's mic by default.
|
||||
//! record the client's mic by default — applied, like the playback default, ONLY while a
|
||||
//! desktop-audio capture is open. It used to be asserted on EVERY wiring pass, mic pump at boot
|
||||
//! included, which left an IDLE box's default recording/communication device parked on a virtual
|
||||
//! microphone nothing feeds — and games bind the default microphone at launch (`SetDefaultEndpoint`
|
||||
//! covers eCommunications, so in-game voice binds it too). The 2026-08 Helldivers 2 field reports
|
||||
//! measured that as 1% lows of 2–5 FPS in a LOCALLY played game while the host sat idle (HD2 is
|
||||
//! Wwise + always-on voice, exactly the "finicky with audio devices" case its own wiki warns
|
||||
//! about). An idle host must leave the box's audio defaults exactly as the operator set them.
|
||||
//!
|
||||
//! Because the playback default is *parked* on a silent sink during a stream, it is remembered
|
||||
//! ([`park_default_playback`], plus an on-disk crash marker) and put back when the capture closes
|
||||
//! ([`restore_default_playback`]) or, after a crash, on the next process's first wiring pass — an
|
||||
//! operator must never be stranded with silent speakers. A default the operator changed themselves
|
||||
//! mid-stream is respected (no restore over their choice).
|
||||
//! Because both defaults are *parked* during a stream — playback on a silent sink, recording on the
|
||||
//! virtual mic — the operator's devices are remembered ([`park_default_playback`] /
|
||||
//! [`park_default_recording`], plus on-disk crash markers) and put back when the capture closes
|
||||
//! ([`restore_default_playback`] / [`restore_default_recording`]) or, after a crash, on the next
|
||||
//! process's first wiring pass — an operator must never be stranded with silent speakers or a dead
|
||||
//! mic. A default the operator changed themselves mid-stream is respected (no restore over their
|
||||
//! choice).
|
||||
//!
|
||||
//! The assignment rules are the PURE [`wiring_plan`](super::wiring_plan) module (unit-tested on every
|
||||
//! platform); this module only enumerates endpoints, applies the plan, and logs. [`wire_now`] runs on
|
||||
@@ -142,8 +151,8 @@ pub(crate) fn endpoint_fingerprint() -> u64 {
|
||||
}
|
||||
|
||||
/// [`wire_now_full`] for callers that only need the assignment (the mic paths).
|
||||
pub(crate) fn wire_now(set_playback: bool) -> Wiring {
|
||||
wire_now_full(set_playback).wiring
|
||||
pub(crate) fn wire_now(park_defaults: bool) -> Wiring {
|
||||
wire_now_full(park_defaults).wiring
|
||||
}
|
||||
|
||||
/// The most recent wiring verdict, as the LAST wiring pass computed it (the mic pump wires
|
||||
@@ -170,13 +179,15 @@ fn pad_render_ids(renders: &[Endpoint]) -> Vec<String> {
|
||||
|
||||
/// Enumerate endpoints, compute the assignment, apply the default-device changes (unless
|
||||
/// `PUNKTFUNK_KEEP_DEFAULT`), and return the plan for the caller to act on (mic target / loopback
|
||||
/// echo guard). `set_playback` — true only from the desktop-audio capture open — additionally
|
||||
/// parks the default PLAYBACK device on the plan's loopback endpoint for the capture's lifetime
|
||||
/// (the mic pump passes false: it runs while the host is idle and must not silence the box).
|
||||
/// Must run on a COM-initialized thread (the WASAPI worker threads all `initialize_mta` first).
|
||||
/// Logged only when the assignment changes, so per-open recomputation stays quiet in the steady
|
||||
/// state.
|
||||
pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
||||
/// echo guard). `park_defaults` — true only from the desktop-audio capture open — additionally
|
||||
/// parks the default PLAYBACK device on the plan's loopback endpoint and the default RECORDING
|
||||
/// device on the virtual mic's capture side, both for the capture's lifetime (the mic pump passes
|
||||
/// false: it runs while the host is idle and must neither silence the box nor hold its default
|
||||
/// microphone — the idle-parked recording default is the 2026-08 Helldivers 2 tank, see the
|
||||
/// module docs). Must run on a COM-initialized thread (the WASAPI worker threads all
|
||||
/// `initialize_mta` first). Logged only when the assignment changes, so per-open recomputation
|
||||
/// stays quiet in the steady state.
|
||||
pub(crate) fn wire_now_full(park_defaults: bool) -> WiredPlan {
|
||||
recover_orphaned_default();
|
||||
let renders = list_endpoints(Direction::Render);
|
||||
let captures = list_endpoints(Direction::Capture);
|
||||
@@ -188,11 +199,11 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
||||
// them out of every role. Identity is platform data (stamped container / devnode marker),
|
||||
// so it is collected HERE and passed in, like the candidate lists themselves.
|
||||
let pad_ids = pad_render_ids(&renders);
|
||||
// Mix formats are read only when we are actually going to park the playback default (i.e. a
|
||||
// Mix formats are read only when we are actually going to park the defaults (i.e. a
|
||||
// desktop-audio capture is opening). The mic pump wires on every open while the host is idle
|
||||
// and does not care which loopback endpoint wins, so it must not pay an IAudioClient
|
||||
// activation per render endpoint on every pass.
|
||||
let probe: &dyn Fn(&Endpoint) -> Option<MixFormat> = if set_playback {
|
||||
let probe: &dyn Fn(&Endpoint) -> Option<MixFormat> = if park_defaults {
|
||||
&mix_format_of
|
||||
} else {
|
||||
&wiring_plan::no_formats
|
||||
@@ -311,30 +322,44 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
||||
}
|
||||
}
|
||||
}
|
||||
if set_playback {
|
||||
// Recording-default hygiene, IDLE passes only: builds before 2026-08-14 parked the default
|
||||
// recording on the virtual mic on EVERY wiring pass (boot included) and recorded nothing to
|
||||
// restore — so an upgraded box would otherwise sit wedged on a microphone nothing feeds
|
||||
// until the operator noticed (the Helldivers 2 idle tank; the session-scoped park below
|
||||
// can't heal it either: it remembers a previous default only when the default isn't already
|
||||
// ours). While nothing is parked, a default found sitting on the plan's mic capture moves to
|
||||
// the first real microphone. Session passes own the default and are exempt; a box with no
|
||||
// real microphone is left alone.
|
||||
if !park_defaults && PARKED_REC.lock().unwrap().is_none() {
|
||||
if let Some((mic_name, mic_id)) = &wiring.mic_capture {
|
||||
if default_capture_id().as_deref() == Some(mic_id.as_str()) {
|
||||
if let Some((name, id)) =
|
||||
wiring_plan::real_capture(&captures, Some(mic_id.as_str()))
|
||||
{
|
||||
match set_default_endpoint(id) {
|
||||
Ok(()) => tracing::info!(from = %mic_name, device = %name,
|
||||
"default recording was left on the virtual mic outside a stream — \
|
||||
moved it back to a real microphone"),
|
||||
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
|
||||
"failed to move the default recording off the virtual mic"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if park_defaults {
|
||||
if let Some((name, id)) = &wiring.loopback_render {
|
||||
let mic_id = wiring.mic_render.as_ref().map(|(_, m)| m.as_str());
|
||||
park_default_playback(name, id, changed, mic_id);
|
||||
}
|
||||
}
|
||||
if let Some((name, id)) = &wiring.mic_capture {
|
||||
// `set_default_endpoint` is NOT a no-op on an unchanged default: it unconditionally
|
||||
// fires SetDefaultEndpoint for all three roles (an audio-policy write plus a
|
||||
// device-graph notification, each). Re-asserting on every wiring pass therefore both
|
||||
// churned the policy store AND silently stomped an operator's own recording-device
|
||||
// choice within one reopen cycle — write only when the plan changed or the default
|
||||
// actually drifted off the target.
|
||||
if changed || default_capture_id().as_deref() != Some(id.as_str()) {
|
||||
match set_default_endpoint(id) {
|
||||
Ok(()) => {
|
||||
if changed {
|
||||
tracing::info!(device = %name,
|
||||
"audio wiring: default recording = virtual mic (apps record the client's mic)");
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
|
||||
"audio wiring: failed to set the default recording device"),
|
||||
}
|
||||
// The recording default is SESSION-SCOPED like the playback default, and for the same
|
||||
// reason inverted: parking it while idle handed the box's default microphone (and, via
|
||||
// eCommunications, every game's voice input) to a virtual mic nothing feeds — the
|
||||
// 2026-08 Helldivers 2 idle tank (see the module docs). A game launched DURING the
|
||||
// stream still binds the client's mic (this runs before the session's game does);
|
||||
// one launched before the stream keeps the operator's mic, which is the honest answer.
|
||||
if let Some((name, id)) = &wiring.mic_capture {
|
||||
park_default_recording(name, id, changed);
|
||||
}
|
||||
}
|
||||
done(wiring)
|
||||
@@ -350,6 +375,26 @@ fn park_marker_path() -> std::path::PathBuf {
|
||||
pf_paths::config_dir().join("audio-default.prev")
|
||||
}
|
||||
|
||||
/// The operator's default recording endpoint while we have it parked on the virtual mic:
|
||||
/// `(previous_id, id_we_set)` — the recording-side twin of [`PARKED`].
|
||||
static PARKED_REC: Mutex<Option<(String, String)>> = Mutex::new(None);
|
||||
|
||||
/// On-disk crash marker mirroring [`PARKED_REC`] (two lines: previous id, set id).
|
||||
fn rec_marker_path() -> std::path::PathBuf {
|
||||
pf_paths::config_dir().join("audio-default-rec.prev")
|
||||
}
|
||||
|
||||
/// Consume a park marker file: returns the PREVIOUS default's id when the marker existed AND the
|
||||
/// current default still is the endpoint we set — a default the operator changed since wins, like
|
||||
/// on every other restore path. The file is removed either way (it describes a park that is over).
|
||||
fn take_marker(path: &std::path::Path, current_default: Option<String>) -> Option<String> {
|
||||
let s = std::fs::read_to_string(path).ok()?;
|
||||
let _ = std::fs::remove_file(path);
|
||||
let mut lines = s.lines();
|
||||
let (prev, set) = (lines.next()?, lines.next()?);
|
||||
(current_default.as_deref() == Some(set)).then(|| prev.to_string())
|
||||
}
|
||||
|
||||
/// The current default RENDER endpoint id, if any. pub(crate): the pad-endpoint provisioning
|
||||
/// uses it for its default-device guard (a freshly minted pad endpoint must never stay the
|
||||
/// default playback device).
|
||||
@@ -374,31 +419,28 @@ pub(crate) fn default_capture_id() -> Option<String> {
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Once per process: if a crash marker from a previous run exists, the host died while the
|
||||
/// playback default was parked — put the operator's device back, but only if the default still
|
||||
/// IS the endpoint we set (a manual change since the crash wins). Runs on the first wiring pass
|
||||
/// (the mic pump wires eagerly at host start, so this fires at boot, not at the first stream).
|
||||
/// Once per process: if a crash marker from a previous run exists, the host died while a default
|
||||
/// (playback and/or recording) was parked — put the operator's device back, but only if the
|
||||
/// default still IS the endpoint we set (a manual change since the crash wins). Runs on the first
|
||||
/// wiring pass (the mic pump wires eagerly at host start, so this fires at boot, not at the first
|
||||
/// stream).
|
||||
fn recover_orphaned_default() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| {
|
||||
let path = park_marker_path();
|
||||
let Ok(s) = std::fs::read_to_string(&path) else {
|
||||
return;
|
||||
};
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let mut lines = s.lines();
|
||||
let (Some(prev), Some(set)) = (lines.next(), lines.next()) else {
|
||||
return;
|
||||
};
|
||||
if default_render_id().as_deref() != Some(set) {
|
||||
return;
|
||||
}
|
||||
match set_default_endpoint(prev) {
|
||||
Ok(()) => tracing::info!(
|
||||
"restored the default playback device a previous host run left parked"
|
||||
),
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"),
|
||||
"failed to restore the default playback device left by a previous run"),
|
||||
for (path, current, what) in [
|
||||
(park_marker_path(), default_render_id(), "playback"),
|
||||
(rec_marker_path(), default_capture_id(), "recording"),
|
||||
] {
|
||||
let Some(prev) = take_marker(&path, current) else {
|
||||
continue;
|
||||
};
|
||||
match set_default_endpoint(&prev) {
|
||||
Ok(()) => tracing::info!(
|
||||
"restored the default {what} device a previous host run left parked"
|
||||
),
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"),
|
||||
"failed to restore the default {what} device left by a previous run"),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -415,20 +457,18 @@ fn recover_orphaned_default() {
|
||||
///
|
||||
/// Returns whether a device was actually put back — the caller only logs it.
|
||||
pub(crate) fn unpark_default_for_uninstall() -> bool {
|
||||
let path = park_marker_path();
|
||||
let Ok(s) = std::fs::read_to_string(&path) else {
|
||||
return false;
|
||||
};
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let mut lines = s.lines();
|
||||
let (Some(prev), Some(set)) = (lines.next(), lines.next()) else {
|
||||
return false;
|
||||
};
|
||||
// A default the operator changed by hand since the park wins, exactly as on the recovery path.
|
||||
if default_render_id().as_deref() != Some(set) {
|
||||
return false;
|
||||
let mut restored = false;
|
||||
for (path, current) in [
|
||||
(park_marker_path(), default_render_id()),
|
||||
(rec_marker_path(), default_capture_id()),
|
||||
] {
|
||||
// A default the operator changed by hand since the park wins, exactly as on the
|
||||
// recovery path (`take_marker` answers None then).
|
||||
if let Some(prev) = take_marker(&path, current) {
|
||||
restored |= set_default_endpoint(&prev).is_ok();
|
||||
}
|
||||
}
|
||||
set_default_endpoint(prev).is_ok()
|
||||
restored
|
||||
}
|
||||
|
||||
/// Make `id` the default playback device for the duration of the desktop-audio capture,
|
||||
@@ -469,6 +509,48 @@ fn park_default_playback(name: &str, id: &str, changed: bool, mic_id: Option<&st
|
||||
}
|
||||
}
|
||||
|
||||
/// Make `id` the default recording device for the duration of the desktop-audio capture —
|
||||
/// [`park_default_playback`]'s recording twin, remembering the operator's current default (in
|
||||
/// memory + the crash marker) the FIRST time so [`restore_default_recording`] can put it back.
|
||||
/// Nothing is remembered when `id` already is the default — there is nothing to restore.
|
||||
fn park_default_recording(name: &str, id: &str, changed: bool) {
|
||||
let cur = default_capture_id();
|
||||
if cur.as_deref() != Some(id) {
|
||||
let mut parked = PARKED_REC.lock().unwrap();
|
||||
match parked.as_mut() {
|
||||
None => {
|
||||
if let Some(prev) = cur.clone() {
|
||||
let _ = std::fs::write(rec_marker_path(), format!("{prev}\n{id}"));
|
||||
*parked = Some((prev, id.to_string()));
|
||||
}
|
||||
}
|
||||
// Re-park onto a different endpoint mid-stream (plan changed): keep the ORIGINAL
|
||||
// previous default, update what we set.
|
||||
Some((prev, set)) if set != id => {
|
||||
let _ = std::fs::write(rec_marker_path(), format!("{prev}\n{id}"));
|
||||
*set = id.to_string();
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
// `set_default_endpoint` is NOT a no-op on an unchanged default: it unconditionally fires
|
||||
// SetDefaultEndpoint for all three roles (an audio-policy write plus a device-graph
|
||||
// notification, each) — write only when the plan changed or the default actually drifted
|
||||
// off the target, or the policy store churns on every reopen.
|
||||
if changed || cur.as_deref() != Some(id) {
|
||||
match set_default_endpoint(id) {
|
||||
Ok(()) => {
|
||||
if changed {
|
||||
tracing::info!(device = %name,
|
||||
"audio wiring: default recording = virtual mic (apps record the client's mic)");
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
|
||||
"audio wiring: failed to set the default recording device"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Put the default playback device back on the endpoint we are already capturing, WITHOUT a
|
||||
/// wiring pass (WP2.4).
|
||||
///
|
||||
@@ -507,6 +589,25 @@ pub(crate) fn restore_default_playback() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Put the operator's default recording device back after streaming — the inverse of
|
||||
/// [`park_default_recording`], with [`restore_default_playback`]'s exact rules: no-op if we never
|
||||
/// parked it, and a default the operator changed themselves mid-stream is left alone. Must run on
|
||||
/// a COM-initialized thread (called from the capture thread's exit path).
|
||||
pub(crate) fn restore_default_recording() {
|
||||
let Some((prev, set)) = PARKED_REC.lock().unwrap().take() else {
|
||||
return;
|
||||
};
|
||||
let _ = std::fs::remove_file(rec_marker_path());
|
||||
if default_capture_id().as_deref() != Some(set.as_str()) {
|
||||
return;
|
||||
}
|
||||
match set_default_endpoint(&prev) {
|
||||
Ok(()) => tracing::info!("default recording device restored after streaming"),
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"),
|
||||
"failed to restore the default recording device after streaming"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a device by endpoint id, with a name for error context.
|
||||
///
|
||||
/// Resolves through [`super::pad_endpoint::open_wasapi_device`] rather than the `wasapi` crate's
|
||||
@@ -518,10 +619,11 @@ pub(crate) fn open_endpoint(ep: &Endpoint) -> Result<wasapi::Device> {
|
||||
.map_err(|e| anyhow!("open endpoint {:?}: {e:#}", ep.0))
|
||||
}
|
||||
|
||||
// --- IPolicyConfig (undocumented): set a default audio endpoint by id, for all three roles. ---
|
||||
// --- IPolicyConfig (undocumented): default-endpoint and endpoint-visibility writes. ---
|
||||
|
||||
/// The `IPolicyConfig` vtable. Only `SetDefaultEndpoint` is called; the 10 methods between `Release`
|
||||
/// and it (`GetMixFormat` … `SetPropertyValue`) are placeholders so the slot offset is correct.
|
||||
/// The `IPolicyConfig` vtable. Only `SetDefaultEndpoint` and `SetEndpointVisibility` are called;
|
||||
/// the 10 methods between `Release` and them (`GetMixFormat` … `SetPropertyValue`) are
|
||||
/// placeholders so the slot offsets are correct.
|
||||
#[repr(C)]
|
||||
struct IPolicyConfigVtbl {
|
||||
query_interface: unsafe extern "system" fn(
|
||||
@@ -537,7 +639,11 @@ struct IPolicyConfigVtbl {
|
||||
windows::core::PCWSTR,
|
||||
u32,
|
||||
) -> windows::core::HRESULT,
|
||||
// SetEndpointVisibility follows — unused.
|
||||
set_endpoint_visibility: unsafe extern "system" fn(
|
||||
*mut c_void,
|
||||
windows::core::PCWSTR,
|
||||
i32,
|
||||
) -> windows::core::HRESULT,
|
||||
}
|
||||
|
||||
// This mirrors the vtable of the UNDOCUMENTED `IPolicyConfig` COM interface, so there is no header
|
||||
@@ -546,18 +652,21 @@ struct IPolicyConfigVtbl {
|
||||
// table" — so a field added, removed or resized above it does not fail to compile: it silently calls
|
||||
// a DIFFERENT function through a mismatched signature, which is arbitrary-code territory rather
|
||||
// than a wrong answer. The `_reserved` gap is what makes that easy to get wrong, since its ten slots
|
||||
// carry no names to anchor a review. These assertions pin the two things the call actually depends
|
||||
// on: the slot index of `set_default_endpoint`, and the size of the table up to it.
|
||||
// carry no names to anchor a review. These assertions pin the things the calls actually depend
|
||||
// on: the slot indexes of `set_default_endpoint` and `set_endpoint_visibility`, and the size of
|
||||
// the table up to them.
|
||||
const _: () = {
|
||||
use std::mem::{offset_of, size_of};
|
||||
type P = *const c_void;
|
||||
// 3 IUnknown slots + 10 reserved = `set_default_endpoint` is slot 13 (0-based).
|
||||
// 3 IUnknown slots + 10 reserved = `set_default_endpoint` is slot 13 (0-based),
|
||||
// `set_endpoint_visibility` the slot after.
|
||||
assert!(offset_of!(IPolicyConfigVtbl, query_interface) == 0);
|
||||
assert!(offset_of!(IPolicyConfigVtbl, add_ref) == size_of::<P>());
|
||||
assert!(offset_of!(IPolicyConfigVtbl, release) == 2 * size_of::<P>());
|
||||
assert!(offset_of!(IPolicyConfigVtbl, _reserved) == 3 * size_of::<P>());
|
||||
assert!(offset_of!(IPolicyConfigVtbl, set_default_endpoint) == 13 * size_of::<P>());
|
||||
assert!(size_of::<IPolicyConfigVtbl>() == 14 * size_of::<P>());
|
||||
assert!(offset_of!(IPolicyConfigVtbl, set_endpoint_visibility) == 14 * size_of::<P>());
|
||||
assert!(size_of::<IPolicyConfigVtbl>() == 15 * size_of::<P>());
|
||||
};
|
||||
|
||||
/// Set `device_id` as the default audio endpoint for eConsole/eMultimedia/eCommunications via the
|
||||
@@ -603,3 +712,41 @@ pub(crate) fn set_default_endpoint(device_id: &str) -> Result<()> {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Show or hide an audio endpoint via the undocumented `IPolicyConfig::SetEndpointVisibility` —
|
||||
/// the exact call behind mmsys.cpl's "Disable"/"Enable" device menu. A hidden endpoint drops to
|
||||
/// `DEVICE_STATE_DISABLED`: it vanishes from every ACTIVE enumeration and cannot be opened, but
|
||||
/// its devnode, driver binding and stamped identity all stay put — showing it again is instant
|
||||
/// and raises no PnP traffic. pub(crate): the pad-endpoint provider parks its "Wireless
|
||||
/// Controller" speaker hidden while no client pad is attached (a visible idle pad speaker makes
|
||||
/// libScePad titles engage their DualSense-haptics path against an endpoint nothing services —
|
||||
/// the 2026-08-14 Helldivers 2 field confirmation).
|
||||
pub(crate) fn set_endpoint_visibility(device_id: &str, visible: bool) -> Result<()> {
|
||||
use windows::core::{IUnknown, Interface, GUID, PCWSTR};
|
||||
use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_ALL};
|
||||
|
||||
const CLSID_POLICY_CONFIG: GUID = GUID::from_u128(0x870af99c_171d_4f9e_af0d_e63df40c2bc9);
|
||||
const IID_IPOLICY_CONFIG: GUID = GUID::from_u128(0xf8679f50_850a_41cf_9c72_430f290290c8);
|
||||
|
||||
let wide: Vec<u16> = device_id.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
|
||||
// SAFETY: same contract as `set_default_endpoint` — owned IUnknown from CoCreateInstance,
|
||||
// QI'd pointer checked non-null, the call goes through the assertion-pinned vtable slot with
|
||||
// a NUL-terminated UTF-16 id and an INT bool, and the QI'd pointer is Released before return.
|
||||
unsafe {
|
||||
let unk: IUnknown = CoCreateInstance(&CLSID_POLICY_CONFIG, None, CLSCTX_ALL)
|
||||
.map_err(|e| anyhow!("CoCreateInstance(PolicyConfig): {e}"))?;
|
||||
let mut raw: *mut c_void = std::ptr::null_mut();
|
||||
unk.query(&IID_IPOLICY_CONFIG, &mut raw)
|
||||
.ok()
|
||||
.map_err(|e| anyhow!("QueryInterface(IPolicyConfig): {e}"))?;
|
||||
if raw.is_null() {
|
||||
bail!("IPolicyConfig QueryInterface returned null");
|
||||
}
|
||||
let vtbl = *(raw as *const *const IPolicyConfigVtbl);
|
||||
let hr = ((*vtbl).set_endpoint_visibility)(raw, PCWSTR(wide.as_ptr()), visible as i32);
|
||||
((*vtbl).release)(raw);
|
||||
hr.ok()
|
||||
.map_err(|e| anyhow!("SetEndpointVisibility({visible}): {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,8 @@ pub(crate) struct Removed {
|
||||
pub endpoint_records: usize,
|
||||
}
|
||||
|
||||
/// Restore the default playback device if we left it parked, then remove every audio devnode
|
||||
/// this product minted, newest registry record and all.
|
||||
/// Restore the default playback/recording devices if we left them parked, then remove every
|
||||
/// audio devnode this product minted, newest registry record and all.
|
||||
///
|
||||
/// Best-effort throughout, like the rest of the (un)install path: a devnode that refuses to go
|
||||
/// is counted and reported, never fatal — a non-zero exit here would abort the whole uninstaller
|
||||
@@ -59,7 +59,7 @@ pub(crate) fn purge() -> Result<Removed> {
|
||||
// what the operator had. Putting it back is the difference between "the box works again"
|
||||
// and "the box works again, on the device it started with".
|
||||
if audio_control::unpark_default_for_uninstall() {
|
||||
println!("restored the default playback device this host had parked");
|
||||
println!("restored the default audio device(s) this host had parked");
|
||||
}
|
||||
|
||||
let mut out = Removed::default();
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
//! behind the measured MMDevices ACL repair (see [`grant_system_full_control`]).
|
||||
//! 3. **Capture**: sessions loopback-capture the endpoint ([`PadLoopbackCapturer`], 4 ch f32
|
||||
//! interleaved) and ship the PCM to the client's pad speaker/haptics.
|
||||
//! 4. **Visibility** ([`set_visibility`]): the endpoint parks HIDDEN (`DEVICE_STATE_DISABLED`)
|
||||
//! whenever no client pad is attached — provisioning hides it at startup, the per-pad
|
||||
//! streamer shows it for exactly the pad's lifetime. The DualSense disguise that makes games
|
||||
//! route haptics at it during a session makes idle libScePad titles STALL on it otherwise
|
||||
//! (Helldivers 2, field-confirmed 2026-08-14: 2–5 FPS 1% lows with the host idle). The
|
||||
//! devnode, driver binding and stamps stay put, so flips raise no PnP traffic.
|
||||
//!
|
||||
//! The wiring plan must never route desktop audio or the virtual mic onto these endpoints —
|
||||
//! [`audio_control`](super::audio_control) collects the exclusion ids via
|
||||
@@ -1484,6 +1490,10 @@ static PROVISIONING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBo
|
||||
pub(crate) fn provision_at_startup() {
|
||||
if !pad_audio_enabled() {
|
||||
tracing::info!("pad audio disabled (PUNKTFUNK_PAD_AUDIO=0)");
|
||||
// Endpoints a previous run provisioned persist and stay VISIBLE — and a visible idle
|
||||
// pad speaker is exactly what libScePad titles stall on (see [`set_visibility`]).
|
||||
// Turning the feature off must also park the leftovers.
|
||||
hide_leftover_endpoints();
|
||||
return;
|
||||
}
|
||||
if PROVISIONED.get().is_some() {
|
||||
@@ -1531,6 +1541,17 @@ pub(crate) fn provision_at_startup() {
|
||||
stored-but-not-served until the next reboot"),
|
||||
}
|
||||
}
|
||||
// Park every provisioned endpoint HIDDEN until a client pad actually attaches. The
|
||||
// expensive work (devnode, driver bind, stamps, the AEB kick above) stays at boot —
|
||||
// the #185 lesson: no PnP traffic at session boundaries — but the ENDPOINT must not
|
||||
// sit visible on an idle box: libScePad titles (Helldivers 2, field-confirmed
|
||||
// 2026-08-14) find the "Wireless Controller" speaker BY IDENTITY, engage their
|
||||
// DualSense-haptics path against it, and stall on an endpoint nothing services —
|
||||
// 1% lows of 2–5 FPS with the host completely idle. The per-pad streamer shows it
|
||||
// for exactly the pad's lifetime, like a real DualSense arriving.
|
||||
for pe in &eps {
|
||||
set_visibility(&pe.endpoint_id, pe.pad_index, false);
|
||||
}
|
||||
// R5: latch the result ONLY if we actually provisioned something. This used to store
|
||||
// whatever `eps` held even when the loop broke on the first error — an empty vec —
|
||||
// and `OnceLock` made that permanent: one transient failure (a busy audio stack, a
|
||||
@@ -1570,6 +1591,54 @@ pub(crate) fn ensure_provisioned() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show or hide a pad endpoint (best-effort, logged). Hidden = `DEVICE_STATE_DISABLED` via
|
||||
/// [`audio_control::set_endpoint_visibility`] — the endpoint keeps its devnode, driver binding
|
||||
/// and DualSense stamps, but vanishes from every ACTIVE enumeration and cannot be opened.
|
||||
///
|
||||
/// WHY pad endpoints park hidden: the stamp set exists so libScePad titles read the endpoint as
|
||||
/// a real DualSense speaker and route haptics audio at it — during a pad session that is the
|
||||
/// feature, on an idle box it is a trap. Helldivers 2 (field-confirmed 2026-08-14) finds the
|
||||
/// idle "Wireless Controller" speaker, engages its DualSense-haptics path against an endpoint
|
||||
/// nothing services, and drops to 2–5 FPS 1% lows with the host completely idle; the manual
|
||||
/// community remedy is disabling the device in mmsys.cpl — this is that remedy, automated and
|
||||
/// scoped to "no pad attached". Visibility flips raise no PnP traffic (the #185 lesson), only
|
||||
/// an endpoint state notification — the same event a real pad's arrival/departure raises.
|
||||
pub(crate) fn set_visibility(endpoint_id: &str, pad_index: u8, visible: bool) {
|
||||
match audio_control::set_endpoint_visibility(endpoint_id, visible) {
|
||||
Ok(()) => tracing::info!(pad = pad_index, endpoint = %endpoint_id,
|
||||
state = if visible { "shown (client pad attached)" } else { "hidden (no pad attached)" },
|
||||
"pad-audio endpoint visibility"),
|
||||
Err(e) => tracing::warn!(pad = pad_index, endpoint = %endpoint_id, visible,
|
||||
error = %format!("{e:#}"),
|
||||
"pad-audio endpoint visibility change failed — an idle visible pad speaker can \
|
||||
stall libScePad titles (disable it in mmsys.cpl as a manual fallback)"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hide any pad endpoints a previous run left behind — the `PUNKTFUNK_PAD_AUDIO=0` path, where
|
||||
/// the provisioning worker never runs but persisted endpoints would otherwise stay visible (and
|
||||
/// stall idle libScePad titles) forever.
|
||||
fn hide_leftover_endpoints() {
|
||||
let spawned = thread::Builder::new()
|
||||
.name("punktfunk-pad-audio-hide".into())
|
||||
.spawn(|| {
|
||||
if wasapi::initialize_mta().ok().is_err() {
|
||||
return;
|
||||
}
|
||||
for idx in 0..4u8 {
|
||||
match find(idx) {
|
||||
Ok(Some(pe)) if !pe.endpoint_id.is_empty() => {
|
||||
set_visibility(&pe.endpoint_id, idx, false);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
tracing::warn!(error = %e, "could not spawn the pad-endpoint hide sweep");
|
||||
}
|
||||
}
|
||||
|
||||
/// The provisioned endpoint for one pad slot — what a session queries when a client pad with
|
||||
/// speaker support arrives, to attach a [`PadLoopbackCapturer`].
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
//! the set changes — the thread says why once, then parks on a cheap fingerprint poll and
|
||||
//! re-plans the instant the set moves (the 2026-08 field case hammered a full wiring pass —
|
||||
//! IPolicyConfig writes included — every 2 s for 8+ minutes without ever being able to
|
||||
//! succeed). On thread exit (capturer dropped at stream end) the parked default playback
|
||||
//! device is restored.
|
||||
//! succeed). On thread exit (capturer dropped at stream end) the parked default playback AND
|
||||
//! recording devices are restored — both defaults are strictly session-scoped.
|
||||
|
||||
use super::capture_policy::{CaptureStats, FightDamper, FIGHT_BACKOFF, STATS_EVERY};
|
||||
use super::{audio_control, wiring_plan, AudioCapturer, SAMPLE_RATE};
|
||||
@@ -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;
|
||||
@@ -265,9 +290,13 @@ fn capture_thread(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Hand the default playback device back to the operator (no-op if we never parked it, or if
|
||||
// they changed it themselves mid-stream). COM is initialized on this thread.
|
||||
// Hand the default playback AND recording devices back to the operator (no-ops if we never
|
||||
// parked them, or if they changed them themselves mid-stream). COM is initialized on this
|
||||
// thread. The recording restore is what keeps the parked default session-scoped — an idle
|
||||
// box holding the default microphone on a virtual mic nothing feeds is the 2026-08
|
||||
// Helldivers 2 tank (see `audio_control`'s module docs).
|
||||
audio_control::restore_default_playback();
|
||||
audio_control::restore_default_recording();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -357,6 +386,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 +641,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,8 +261,10 @@ fn resolve_target() -> Result<(wasapi::Device, String)> {
|
||||
// on the cable while later plans paired the default recording with the minted microphone
|
||||
// nothing wrote into (see `minted::ensure_blocking`). Instant once latched.
|
||||
super::minted::ensure_blocking();
|
||||
// set_playback=false: the mic pump runs while the host is idle — only the desktop-audio
|
||||
// capture may park the playback default (on the silent sink) for a stream's lifetime.
|
||||
// park_defaults=false: the mic pump runs while the host is idle — only the desktop-audio
|
||||
// capture may park the box's defaults (playback on the silent sink, recording on the virtual
|
||||
// mic) for a stream's lifetime. An idle box must keep the operator's own devices default —
|
||||
// an idle-parked recording default is the 2026-08 Helldivers 2 tank (`audio_control` docs).
|
||||
let mut wiring = audio_control::wire_now(false);
|
||||
if wiring.mic_render.is_none() && !wiring.mic_withheld {
|
||||
// A WITHHELD mic skips the install attempt: the Streaming Microphone exists — the plan
|
||||
|
||||
@@ -241,6 +241,30 @@ pub(crate) fn silent_sink(lname: &str) -> bool {
|
||||
lname.contains("steam streaming microphone")
|
||||
}
|
||||
|
||||
/// A capture endpoint that surfaces a VIRTUAL device's audio (cables, streaming mics, mixer
|
||||
/// strips, the host's own minted "Punktfunk" microphone) rather than a real microphone. The
|
||||
/// recording-default hygiene pass must never move the box's default onto one of these.
|
||||
pub(crate) fn virtual_capture(lname: &str) -> bool {
|
||||
lname.contains("cable output")
|
||||
|| lname.contains("steam streaming")
|
||||
|| lname.contains("voicemeeter")
|
||||
|| lname.contains("virtual")
|
||||
|| lname.contains("punktfunk")
|
||||
}
|
||||
|
||||
/// The first REAL capture endpoint (skipping `avoid_id` and every [`virtual_capture`]) — where
|
||||
/// the recording-default hygiene sends a default an earlier build left parked on the virtual mic
|
||||
/// while the host is idle. `None` on a box with no real microphone: nothing sane to move to, so
|
||||
/// the default is left alone.
|
||||
pub(crate) fn real_capture<'a>(
|
||||
captures: &'a [Endpoint],
|
||||
avoid_id: Option<&str>,
|
||||
) -> Option<&'a Endpoint> {
|
||||
captures
|
||||
.iter()
|
||||
.find(|(n, id)| Some(id.as_str()) != avoid_id && !virtual_capture(&n.to_lowercase()))
|
||||
}
|
||||
|
||||
/// A known-virtual device (cables/streaming endpoints). A render WITHOUT these markers is real
|
||||
/// hardware — the best loopback source (apps render there by default and the operator can also
|
||||
/// hear it).
|
||||
@@ -1137,6 +1161,29 @@ mod tests {
|
||||
assert!(both.contains("16000") && both.contains("channel"), "{both}");
|
||||
}
|
||||
|
||||
/// The recording-default hygiene picker: skips every virtual capture (cable, streaming mic,
|
||||
/// the minted "Punktfunk" pair, VoiceMeeter) and lands on the real microphone — the exact
|
||||
/// recording-tab zoo of the 2026-08-14 Helldivers 2 field box.
|
||||
#[test]
|
||||
fn recording_hygiene_picks_the_real_microphone() {
|
||||
let captures = [
|
||||
ep("Microphone (2- Punktfunk)"),
|
||||
ep("CABLE Output (VB-Audio Virtual Cable)"),
|
||||
ep("Microphone (Steam Streaming Microphone)"),
|
||||
ep("VoiceMeeter Output (VB-Audio VoiceMeeter VAIO)"),
|
||||
ep("Desktop Microphone (2- Microsoft LifeCam HD-3000)"),
|
||||
];
|
||||
assert_eq!(
|
||||
real_capture(&captures, None).unwrap().0,
|
||||
"Desktop Microphone (2- Microsoft LifeCam HD-3000)"
|
||||
);
|
||||
// `avoid_id` guards the plan's own mic capture even when its name would pass the
|
||||
// virtual test; with nothing else real, the answer is honestly None.
|
||||
let only = [ep("Desk Mic (USB)")];
|
||||
assert!(real_capture(&only, Some("id-desk mic (usb)")).is_none());
|
||||
assert!(real_capture(&[], None).is_none());
|
||||
}
|
||||
|
||||
/// Operator override beats the candidate order.
|
||||
#[test]
|
||||
fn env_override_wins() {
|
||||
|
||||
@@ -623,12 +623,15 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Windows: pad-audio endpoint provisioning — `pad-endpoint ensure|remove|status [--index N]`.
|
||||
/// Windows: pad-audio endpoint provisioning — `pad-endpoint
|
||||
/// ensure|remove|status|tone|capture|show|hide [--index N]`.
|
||||
/// `ensure` runs the idempotent startup path (reuse-or-create the devnode, bind the Steam
|
||||
/// Streaming Speakers driver, stamp the DualSense identity + 4ch/48k formats, report whether
|
||||
/// the stamps are SERVED); `status` prints the devnode/endpoint and per-stamp stored vs served
|
||||
/// state without changing anything; `remove` deletes the devnode via pnputil — the escape
|
||||
/// hatch only, endpoints are persistent by design. Stamping needs SYSTEM (the MMDevices ACL);
|
||||
/// hatch only, endpoints are persistent by design; `show`/`hide` flip the endpoint's
|
||||
/// visibility (the host parks it hidden while no client pad is attached — show it before
|
||||
/// `tone`/`capture`). Stamping needs SYSTEM (the MMDevices ACL);
|
||||
/// run `ensure` under the service account or PsExec when the property-store route is denied.
|
||||
/// Windows: the audio-substrate toolbox (`windows-audio-endpoints-and-vbcable.md`) —
|
||||
/// `audio-probe ssm|sink|sss-primary|mint|plan|cleanup [--keep]`. The S1–S3 spikes (`ssm` =
|
||||
@@ -744,7 +747,29 @@ pub fn pad_endpoint(args: &[String]) -> Result<()> {
|
||||
pe::capture_probe(&endpoint_id, secs)
|
||||
}
|
||||
Some("status") => pe::print_status(idx),
|
||||
_ => anyhow::bail!("usage: punktfunk-host pad-endpoint <ensure|remove|status> [--index N]"),
|
||||
// `show`/`hide` — flip the endpoint's visibility (DEVICE_STATE_DISABLED). The host parks
|
||||
// pad endpoints hidden while no client pad is attached (idle libScePad titles stall on a
|
||||
// visible one — the 2026-08-14 Helldivers 2 field case); `tone`/`capture` need the
|
||||
// endpoint SHOWN first, and `hide` puts the box back to the idle-safe state after.
|
||||
Some(verb @ ("show" | "hide")) => {
|
||||
let endpoint_id = match endpoint_override {
|
||||
Some(id) => id,
|
||||
None => match pe::find(idx)? {
|
||||
Some(ep) if !ep.endpoint_id.is_empty() => ep.endpoint_id,
|
||||
_ => {
|
||||
println!("pad-endpoint {verb}: pad {idx} has no endpoint — run `ensure`");
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
};
|
||||
pe::set_visibility(&endpoint_id, idx, verb == "show");
|
||||
println!("pad-endpoint {verb}: {endpoint_id}");
|
||||
Ok(())
|
||||
}
|
||||
_ => anyhow::bail!(
|
||||
"usage: punktfunk-host pad-endpoint \
|
||||
<ensure|remove|status|tone|capture|show|hide> [--index N]"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1410,7 +1410,9 @@ mod tests {
|
||||
|
||||
static EXITS: AtomicUsize = AtomicUsize::new(0);
|
||||
EXITS.store(0, Ordering::SeqCst);
|
||||
let child = std::process::Command::new("/bin/true")
|
||||
// Resolved through PATH, not `/bin/true`: NixOS ships only `/bin/sh` in `/bin`, so the
|
||||
// absolute path made this test — and nothing else about the code under test — fail there.
|
||||
let child = std::process::Command::new("true")
|
||||
.spawn()
|
||||
.expect("spawn the fake launcher");
|
||||
let lease = open(
|
||||
|
||||
@@ -150,12 +150,13 @@ fn percent_decode(s: &str) -> String {
|
||||
/// H-2): `mgmt-token`, `key.pem`, the SAM hive. So the value is confined here, at the one place
|
||||
/// bytes are read, rather than trusted because of where it was written.
|
||||
///
|
||||
/// Default: the users base (`C:\Users`), which is where every launcher keeps its art cache —
|
||||
/// Playnite, the only local-art provider, stores covers under `%APPDATA%\Playnite`. Derived from
|
||||
/// Default: the users base (`C:\Users`), where the launchers that install per-user keep their art —
|
||||
/// Playnite stores covers under `%APPDATA%\Playnite`, Heroic under `%APPDATA%\heroic`. Derived from
|
||||
/// `%PUBLIC%`'s parent because the host runs as SYSTEM, whose own `%USERPROFILE%` is
|
||||
/// `…\config\systemprofile` and tells us nothing about where the operator's launchers live.
|
||||
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` (`;`-separated) replaces the default for an operator whose library
|
||||
/// is on another drive.
|
||||
/// `…\config\systemprofile` and tells us nothing about where the operator's launchers live. Plus
|
||||
/// the Steam install root ([`steam_art_roots`]), which is the one launcher that does NOT live under
|
||||
/// the users base. `PUNKTFUNK_LIBRARY_ART_ROOTS` (`;`-separated) replaces the whole default for an
|
||||
/// operator whose library is somewhere else again.
|
||||
fn art_roots() -> Vec<PathBuf> {
|
||||
if let Some(configured) = std::env::var_os("PUNKTFUNK_LIBRARY_ART_ROOTS") {
|
||||
return std::env::split_paths(&configured)
|
||||
@@ -174,6 +175,8 @@ fn art_roots() -> Vec<PathBuf> {
|
||||
roots.push(PathBuf::from(drive).join("Users"));
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
roots.extend(steam_art_roots());
|
||||
// POSIX: the user's home, which is the exact analogue of the Windows users base above — and
|
||||
// where every launcher this host reads art from actually keeps it. Steam's
|
||||
// `appcache/librarycache` and `userdata/<id>/config/grid`, Lutris's `coverart`/`banners` (both
|
||||
@@ -200,6 +203,54 @@ fn art_roots() -> Vec<PathBuf> {
|
||||
roots
|
||||
}
|
||||
|
||||
/// Windows: every Steam install root that exists on this box.
|
||||
///
|
||||
/// Steam is the one launcher whose art is NOT under the users base: it installs to
|
||||
/// `C:\Program Files (x86)\Steam`, and both places the `steam` library plugin publishes covers from
|
||||
/// — `appcache\librarycache\<appid>\…` and each account's `userdata\<id>\config\grid\` overrides —
|
||||
/// live under that root. Without this the users base rejected every one of them, and because an
|
||||
/// unservable path used to fail the WHOLE reconcile payload the plugin synced NO GAMES AT ALL, not
|
||||
/// merely no art. That is a v0.28.0 regression: the built-in scanner this plugin replaced served its
|
||||
/// covers through the legacy `steam:` art-proxy branch, which never passed through this confinement.
|
||||
/// (POSIX needs no equivalent — every Steam layout there, native and Flatpak, is already under
|
||||
/// `$HOME`.)
|
||||
///
|
||||
/// This does not widen what the host can be *tricked* into reading. The confinement exists to close
|
||||
/// one asymmetry: the host reads as SYSTEM, while the plugin lane that supplies the path is the far
|
||||
/// weaker LocalService (2026-08-05 review H-2). The Steam directory is readable by LocalService
|
||||
/// already, so nothing reachable through it is reachable *because* the host is privileged. The
|
||||
/// extension, regular-file, magic-byte and config-dir gates all still apply on top, so Steam's own
|
||||
/// `config.vdf` and `ssfn*` credential blobs are not servable from it either.
|
||||
#[cfg(windows)]
|
||||
fn steam_art_roots() -> Vec<PathBuf> {
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
let mut push = |p: PathBuf| {
|
||||
// `is_dir` before dedup: `%ProgramFiles%` and `%ProgramW6432%` are the same directory on a
|
||||
// 64-bit host, and the registry commonly repeats whichever of the two Steam sits in.
|
||||
if p.is_dir() && !out.contains(&p) {
|
||||
out.push(p);
|
||||
}
|
||||
};
|
||||
for var in ["ProgramFiles(x86)", "ProgramFiles", "ProgramW6432"] {
|
||||
if let Some(pf) = std::env::var_os(var) {
|
||||
push(PathBuf::from(pf).join("Steam"));
|
||||
}
|
||||
}
|
||||
// A Steam installed off the default path — a second drive is common — is only discoverable from
|
||||
// the registry. HKLM and not HKCU, for the same reason the plugin reads HKLM: the host is
|
||||
// SYSTEM, whose own hive knows nothing about where the operator installed anything.
|
||||
for key in [r"SOFTWARE\WOW6432Node\Valve\Steam", r"SOFTWARE\Valve\Steam"] {
|
||||
if let Some(p) = winreg::RegKey::predef(winreg::enums::HKEY_LOCAL_MACHINE)
|
||||
.open_subkey(key)
|
||||
.ok()
|
||||
.and_then(|k| k.get_value::<String, _>("InstallPath").ok())
|
||||
{
|
||||
push(PathBuf::from(p));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `path` resolves inside one of [`art_roots`] and outside the host config dir.
|
||||
///
|
||||
/// Canonicalizes first, so a junction/symlink pointing out of the root is resolved before the
|
||||
@@ -317,6 +368,43 @@ pub fn validate_art_paths(art: &Artwork) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Strip every **local-file** art value the proxy would refuse to serve, returning the
|
||||
/// `(field, value)` pairs dropped. URLs and already-proxied paths are left alone.
|
||||
///
|
||||
/// The provider-reconcile counterpart to [`validate_art_paths`]. Both enforce the same invariant —
|
||||
/// an unservable path never reaches `library.json` — and differ only on what the REST of the payload
|
||||
/// is worth. An operator writing one custom entry typed that path by hand, so a hard 400 is the
|
||||
/// feedback they need. A plugin reconciling its whole entry set did not: it publishes hundreds of
|
||||
/// covers it resolved from disk, and refusing the payload over one of them costs the operator their
|
||||
/// entire library for that store.
|
||||
///
|
||||
/// That is not hypothetical. A default Windows Steam install put every cover outside the art roots,
|
||||
/// so `PUT /library/provider/steam` 400'd, the plugin could only report `HostRequestError`, and the
|
||||
/// grid stayed empty with no indication that the games themselves were fine. [`steam_art_roots`]
|
||||
/// fixes that specific mismatch; this makes the NEXT one cost a cover instead of a library.
|
||||
///
|
||||
/// Dropping rather than rewriting is deliberate: `None` is exactly what an entry with no art
|
||||
/// carries, and every client already renders that.
|
||||
pub fn sanitize_art_paths(art: &mut Artwork) -> Vec<(&'static str, String)> {
|
||||
let mut dropped = Vec::new();
|
||||
for (field, value) in [
|
||||
("portrait", &mut art.portrait),
|
||||
("hero", &mut art.hero),
|
||||
("logo", &mut art.logo),
|
||||
("header", &mut art.header),
|
||||
] {
|
||||
let unservable = value
|
||||
.as_deref()
|
||||
.is_some_and(|v| is_local_art_path(v) && !art_path_is_servable(v));
|
||||
if unservable {
|
||||
if let Some(v) = value.take() {
|
||||
dropped.push((field, v));
|
||||
}
|
||||
}
|
||||
}
|
||||
dropped
|
||||
}
|
||||
|
||||
/// Read a local image file into `(bytes, content-type)` for the art proxy. `None` if it isn't an
|
||||
/// existing regular file, is empty, exceeds 16 MiB (a cover never approaches that; the cap bounds
|
||||
/// host memory), resolves outside the allowed art roots ([`art_path_is_confined`]), or does not
|
||||
@@ -542,15 +630,67 @@ mod tests {
|
||||
|
||||
const PNG: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13];
|
||||
|
||||
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` is process-global while cargo runs tests as threads, so the
|
||||
/// tests that repoint it must not overlap — one clearing the variable mid-flight makes the
|
||||
/// other's temp root stop being a root, which fails as a confinement bug that isn't there.
|
||||
/// The variables the art roots derive from are process-global while cargo runs tests as threads,
|
||||
/// so the tests that repoint them must not overlap — one clearing a variable mid-flight makes
|
||||
/// another's temp root stop being a root, which fails as a confinement bug that isn't there.
|
||||
/// Poisoning is recovered rather than propagated: a panic in one test should report ITS
|
||||
/// failure, not cascade into an unrelated `PoisonError`.
|
||||
static ART_ROOTS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn lock_art_roots() -> std::sync::MutexGuard<'static, ()> {
|
||||
ART_ROOTS_LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
/// Holds `ART_ROOTS_LOCK` and the overrides one test needs, restoring the previous values on
|
||||
/// drop. **The only place these tests touch the process environment** — which is what keeps the
|
||||
/// unsafe-hygiene gate's count flat as tests are added, and what makes the restore run on an
|
||||
/// unwind (the hand-rolled set/restore this replaced leaked its override to every later test
|
||||
/// whenever an assertion fired between the two halves).
|
||||
struct ArtRootsEnv {
|
||||
_lock: std::sync::MutexGuard<'static, ()>,
|
||||
saved: Vec<(&'static str, Option<std::ffi::OsString>)>,
|
||||
}
|
||||
|
||||
impl ArtRootsEnv {
|
||||
/// `None` unsets the variable for the test's duration.
|
||||
fn set(vars: &[(&'static str, Option<&Path>)]) -> Self {
|
||||
let _lock = ART_ROOTS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut saved = Vec::new();
|
||||
for (key, value) in vars {
|
||||
saved.push((*key, std::env::var_os(key)));
|
||||
// SAFETY: `_lock` is held for this guard's whole lifetime, and this type is the
|
||||
// only writer of these variables in the binary — so no other thread is reading
|
||||
// them while they change.
|
||||
unsafe { write_env(key, value.map(|p| p.as_os_str())) };
|
||||
}
|
||||
Self { _lock, saved }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ArtRootsEnv {
|
||||
fn drop(&mut self) {
|
||||
for (key, value) in &self.saved {
|
||||
// SAFETY: still under `_lock`, which outlives this loop — same argument as `set`.
|
||||
unsafe { write_env(key, value.as_deref()) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The single write point, so the hygiene gate has exactly one pair of call sites to judge.
|
||||
///
|
||||
/// # Safety
|
||||
/// The caller must hold `ART_ROOTS_LOCK`; the process environment is global and unsound to
|
||||
/// mutate while another thread reads it.
|
||||
unsafe fn write_env(key: &str, value: Option<&std::ffi::OsStr>) {
|
||||
match value {
|
||||
// SAFETY: the caller holds `ART_ROOTS_LOCK` (this function's documented contract), and
|
||||
// `ArtRootsEnv` is the only writer in the binary — so no other thread is reading the
|
||||
// environment while it changes.
|
||||
Some(v) => unsafe { std::env::set_var(key, v) },
|
||||
// SAFETY: as above — the caller's lock is what makes this sound.
|
||||
None => unsafe { std::env::remove_var(key) },
|
||||
}
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` pointed at one directory — what most of these tests want.
|
||||
fn confine_art_to(dir: &Path) -> ArtRootsEnv {
|
||||
ArtRootsEnv::set(&[("PUNKTFUNK_LIBRARY_ART_ROOTS", Some(dir))])
|
||||
}
|
||||
|
||||
/// The art proxy reads bytes in the HOST process (LocalSystem on Windows) from a path the
|
||||
@@ -558,15 +698,12 @@ mod tests {
|
||||
/// (2026-08-05 review H-2). Confinement, extension, and content are all load-bearing.
|
||||
#[test]
|
||||
fn local_art_bytes_is_confined_and_image_only() {
|
||||
let _guard = lock_art_roots();
|
||||
let dir = std::env::temp_dir().join(format!("pf-art-test-{}", std::process::id()));
|
||||
let outside = std::env::temp_dir().join(format!("pf-art-out-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
// Confine the proxy to `dir` for the duration of this test.
|
||||
// SAFETY: `_guard` holds ART_ROOTS_LOCK (`lock_art_roots`), which serializes every test
|
||||
// that writes or reads this variable in the binary.
|
||||
unsafe { std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir) };
|
||||
let _env = confine_art_to(&dir);
|
||||
|
||||
// A real image inside the root: served, with the content type SNIFFED from the bytes.
|
||||
let cover = dir.join("cover.png");
|
||||
@@ -642,8 +779,6 @@ mod tests {
|
||||
// A UNC path is refused outright (outbound SMB auth coercion), before any filesystem hit.
|
||||
assert!(!art_path_is_servable(r"\\attacker\share\a.png"));
|
||||
|
||||
// SAFETY: still under `_guard` — the same ART_ROOTS_LOCK serialization as the set.
|
||||
unsafe { std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS") };
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _ = std::fs::remove_dir_all(&outside);
|
||||
}
|
||||
@@ -706,14 +841,11 @@ mod tests {
|
||||
/// readable together is the point: either alone passes with the bug present.
|
||||
#[test]
|
||||
fn file_url_art_is_accepted_at_write_time_exactly_as_at_read_time() {
|
||||
let _guard = lock_art_roots();
|
||||
let dir = std::env::temp_dir().join(format!("pf-art-wr-{}", std::process::id()));
|
||||
let outside = std::env::temp_dir().join(format!("pf-art-wr-out-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
// SAFETY: `_guard` holds ART_ROOTS_LOCK (`lock_art_roots`), which serializes every test
|
||||
// that writes or reads this variable in the binary.
|
||||
unsafe { std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir) };
|
||||
let _env = confine_art_to(&dir);
|
||||
|
||||
let cover = dir.join("cover.png");
|
||||
std::fs::write(&cover, PNG).unwrap();
|
||||
@@ -765,12 +897,149 @@ mod tests {
|
||||
"an out-of-root file:// cover is still refused"
|
||||
);
|
||||
|
||||
// SAFETY: still under `_guard` — the same ART_ROOTS_LOCK serialization as the set.
|
||||
unsafe { std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS") };
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _ = std::fs::remove_dir_all(&outside);
|
||||
}
|
||||
|
||||
/// A reconcile keeps its entries when a cover is unservable — it drops the cover.
|
||||
///
|
||||
/// Regression for the report that opened this: on a default Windows Steam install every
|
||||
/// `appcache\librarycache` path fell outside the users base, `validate_art_paths` refused the
|
||||
/// whole `PUT /library/provider/steam` payload, and the operator's grid stayed EMPTY. The games
|
||||
/// were never the problem. Asserting the survivors matters as much as the drop: a sanitizer that
|
||||
/// cleared the whole struct would also "pass" a drop-only test.
|
||||
#[test]
|
||||
fn sanitize_drops_only_the_unservable_local_art() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-art-san-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let _env = confine_art_to(&dir);
|
||||
|
||||
let cover = dir.join("cover.png");
|
||||
std::fs::write(&cover, PNG).unwrap();
|
||||
let cover_url = file_url(&cover);
|
||||
let outside = if cfg!(windows) {
|
||||
r"C:\Program Files (x86)\Steam\appcache\librarycache\570\a\library_hero.jpg".to_string()
|
||||
} else {
|
||||
"/opt/steam/appcache/librarycache/570/a/library_hero.jpg".to_string()
|
||||
};
|
||||
|
||||
let mut art = Artwork {
|
||||
portrait: Some(cover_url.clone()),
|
||||
hero: Some(outside.clone()),
|
||||
logo: Some("https://cdn/l.png".into()),
|
||||
header: Some("/api/v1/library/art/steam:570/header".into()),
|
||||
};
|
||||
let dropped = sanitize_art_paths(&mut art);
|
||||
assert_eq!(
|
||||
dropped,
|
||||
vec![("hero", outside)],
|
||||
"only the out-of-root local path is dropped, and it is reported"
|
||||
);
|
||||
assert!(art.hero.is_none(), "the unservable value is gone, not kept");
|
||||
// A servable local cover, a remote URL and an already-proxied path all survive untouched —
|
||||
// the entry still renders everything it legitimately can.
|
||||
assert_eq!(art.portrait.as_deref(), Some(cover_url.as_str()));
|
||||
assert_eq!(art.logo.as_deref(), Some("https://cdn/l.png"));
|
||||
assert_eq!(
|
||||
art.header.as_deref(),
|
||||
Some("/api/v1/library/art/steam:570/header")
|
||||
);
|
||||
// Idempotent: what survived one pass survives the next, and nothing new is reported.
|
||||
assert!(sanitize_art_paths(&mut art).is_empty());
|
||||
|
||||
// The invariant the hard 400 used to hold is still held — nothing the write gate would
|
||||
// refuse comes out the other side.
|
||||
assert!(validate_art_paths(&art).is_ok());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Windows only, and the actual bug report: a Steam cover under Program Files is servable with
|
||||
/// NO `PUNKTFUNK_LIBRARY_ART_ROOTS` set.
|
||||
///
|
||||
/// Drives the whole chain the `steam` plugin's payload traverses — Program Files probe →
|
||||
/// [`steam_art_roots`] → [`art_roots`] → confinement → [`art_path_is_servable`] →
|
||||
/// [`local_art_bytes`] — against a synthetic Steam tree, by repointing `%ProgramFiles(x86)%` at
|
||||
/// a temp dir. Hermetic on purpose: asserting over whatever Steam this box happens to have would
|
||||
/// pass vacuously on every CI runner, which is exactly the shape of test that let this ship.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn steam_librarycache_cover_is_servable_without_configuration() {
|
||||
let base = std::env::temp_dir().join(format!("pf-art-steam-{}", std::process::id()));
|
||||
// `appcache\librarycache\<appid>\<hash>\library_hero.jpg` — the exact shape the plugin
|
||||
// publishes, and the exact field the reported failure named.
|
||||
let hero = base
|
||||
.join("Steam")
|
||||
.join("appcache")
|
||||
.join("librarycache")
|
||||
.join("570")
|
||||
.join("abcdef")
|
||||
.join("library_hero.jpg");
|
||||
std::fs::create_dir_all(hero.parent().unwrap()).unwrap();
|
||||
std::fs::write(&hero, PNG).unwrap();
|
||||
|
||||
// No configured roots (that is the claim under test), and the Program Files probe pointed
|
||||
// at the synthetic tree. Both restored on drop — `%ProgramFiles(x86)%` is a real variable
|
||||
// on this box that later tests in the same process may legitimately read.
|
||||
let _env = ArtRootsEnv::set(&[
|
||||
("PUNKTFUNK_LIBRARY_ART_ROOTS", None),
|
||||
("ProgramFiles(x86)", Some(&base)),
|
||||
]);
|
||||
|
||||
let steam_root = base.join("Steam");
|
||||
assert!(
|
||||
steam_art_roots().contains(&steam_root),
|
||||
"the Program Files probe must find the Steam install"
|
||||
);
|
||||
assert!(
|
||||
art_roots().contains(&steam_root),
|
||||
"the DEFAULT art roots must include it — the whole point is that no env var is needed"
|
||||
);
|
||||
|
||||
// The plugin sends `file://`, so that is what has to be accepted; before the fix this was
|
||||
// false and `validate_art_paths` 400'd the entire reconcile.
|
||||
let url = file_url(&hero);
|
||||
assert!(art_path_is_servable(&url), "{url} must be servable");
|
||||
assert!(
|
||||
validate_art_paths(&Artwork {
|
||||
hero: Some(url.clone()),
|
||||
..Default::default()
|
||||
})
|
||||
.is_ok(),
|
||||
"a Steam-shaped payload must reconcile"
|
||||
);
|
||||
assert!(
|
||||
sanitize_art_paths(&mut Artwork {
|
||||
hero: Some(url.clone()),
|
||||
..Default::default()
|
||||
})
|
||||
.is_empty(),
|
||||
"and nothing about it is dropped"
|
||||
);
|
||||
assert_eq!(
|
||||
local_art_bytes(&url).expect("read time serves it too").0,
|
||||
PNG
|
||||
);
|
||||
|
||||
// The confinement did not go slack on the way: a secret next door is still not servable,
|
||||
// and neither is a non-image that merely wears the extension.
|
||||
let secret = base.join("Steam").join("config").join("config.vdf");
|
||||
std::fs::create_dir_all(secret.parent().unwrap()).unwrap();
|
||||
std::fs::write(&secret, b"\"Accounts\"\n{\n\"user\" \"token\"\n}\n").unwrap();
|
||||
assert!(
|
||||
local_art_bytes(secret.to_str().unwrap()).is_none(),
|
||||
"Steam's own credential blob must not be servable from an art root"
|
||||
);
|
||||
let disguised = base.join("Steam").join("config.png");
|
||||
std::fs::write(&disguised, b"\"Accounts\" { \"user\" \"token\" }").unwrap();
|
||||
assert!(
|
||||
local_art_bytes(disguised.to_str().unwrap()).is_none(),
|
||||
"an image extension is still not enough — the bytes must BE an image"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniff_image_type_recognizes_containers_and_rejects_secrets() {
|
||||
assert_eq!(sniff_image_type(PNG), Some("image/png"));
|
||||
|
||||
@@ -214,7 +214,12 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
||||
))
|
||||
.routes(routes!(host::get_status))
|
||||
.routes(routes!(host::get_local_summary))
|
||||
.routes(routes!(clients::list_paired_clients))
|
||||
// GET and DELETE share the `/clients` path, so they must be ONE `routes!` — utoipa-axum
|
||||
// merges the methods of a single call into one route; two calls collide on the path.
|
||||
.routes(routes!(
|
||||
clients::list_paired_clients,
|
||||
clients::unpair_all_clients
|
||||
))
|
||||
.routes(routes!(clients::unpair_client));
|
||||
// The GameStream PIN flow exists only when the compat planes do (WP19) — a native-only
|
||||
// build's API (and its OpenAPI document) simply has no such endpoints.
|
||||
@@ -226,7 +231,11 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
||||
.routes(routes!(native::get_native_pairing))
|
||||
.routes(routes!(native::arm_native_pairing))
|
||||
.routes(routes!(native::disarm_native_pairing))
|
||||
.routes(routes!(native::list_native_clients))
|
||||
// Same-path pair as `/clients` above — one `routes!` for both methods.
|
||||
.routes(routes!(
|
||||
native::list_native_clients,
|
||||
native::unpair_all_native_clients
|
||||
))
|
||||
.routes(routes!(native::unpair_native_client))
|
||||
.routes(routes!(native::list_pending_devices))
|
||||
.routes(routes!(native::approve_pending_device))
|
||||
|
||||
@@ -153,6 +153,62 @@ pub(crate) async fn unpair_client(
|
||||
}
|
||||
}
|
||||
|
||||
/// Unpair every client
|
||||
///
|
||||
/// The collection form of [`unpair_client`]: empties the pairing store in ONE persisted write,
|
||||
/// carrying the same revocation guarantees across the whole set. A LIVE GameStream session is
|
||||
/// ended (its owning certificate is necessarily one of those just removed), and the ENet control
|
||||
/// port (UDP 47999) closes, because no pairing is left to hold it open.
|
||||
///
|
||||
/// Idempotent, and so a 200 rather than the single unpair's 204/404 pair: "unpair everything" is
|
||||
/// satisfied by an already-empty store, and the operator still wants to know whether that meant
|
||||
/// three devices or none.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/clients",
|
||||
tag = "clients",
|
||||
operation_id = "unpairAllClients",
|
||||
responses(
|
||||
(status = OK, description = "Every client unpaired (possibly none)", body = UnpairAllResult),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn unpair_all_clients(State(st): State<Arc<MgmtState>>) -> Response {
|
||||
let mut paired = st.app.paired.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if paired.is_empty() {
|
||||
// Nothing to persist, no port to sync — an empty store is already the requested state.
|
||||
return Json(UnpairAllResult { unpaired: 0 }).into_response();
|
||||
}
|
||||
let removed: Vec<[u8; 32]> = paired
|
||||
.iter()
|
||||
.map(|der| Sha256::digest(der).into())
|
||||
.collect();
|
||||
paired.clear();
|
||||
// Persist under the lock, as the single unpair does: a pairing resurrected by a restart would
|
||||
// silently re-open the control port.
|
||||
crate::gamestream::save_paired(&paired);
|
||||
drop(paired);
|
||||
// A mid-stream client must not keep streaming once its pairing is gone. Clearing the launch
|
||||
// makes the ENet control thread send the standard TERMINATION+disconnect. (An owner-less
|
||||
// launch — the cert was unreadable at /launch — cannot be attributed, and is left to the port
|
||||
// teardown below, which here always fires: no pairing remains.)
|
||||
let live_owner = st
|
||||
.app
|
||||
.launch
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.and_then(|l| l.owner_fp);
|
||||
if live_owner.is_some_and(|fp| removed.contains(&fp)) {
|
||||
st.app.quit_session("client unpaired");
|
||||
}
|
||||
if let Err(e) = crate::gamestream::sync_control(&st.app) {
|
||||
tracing::warn!(error = %format!("{e:#}"), "control port sync after unpair-all failed");
|
||||
}
|
||||
let unpaired = removed.len() as u32;
|
||||
tracing::info!(unpaired, "management API: all clients unpaired");
|
||||
Json(UnpairAllResult { unpaired }).into_response()
|
||||
}
|
||||
|
||||
/// Pairing-flow status
|
||||
///
|
||||
/// Poll this to know when to prompt the user for the PIN Moonlight displays.
|
||||
|
||||
@@ -9,6 +9,10 @@ use axum::Extension;
|
||||
/// Refuse a write whose payload carries an operator-privileged field to a lane that may not set one
|
||||
/// (2026-08-05 review H-1), and refuse any local art path the proxy would not serve back (H-2).
|
||||
///
|
||||
/// The **single-entry writes** — the operator creating or editing one custom entry. The provider
|
||||
/// reconcile takes [`check_privileged_fields`] and sanitizes art instead; the split is the whole
|
||||
/// point, and [`crate::library::sanitize_art_paths`] carries the reasoning.
|
||||
///
|
||||
/// Both checks belong here rather than in the route gate: `PUT /library/provider/{p}` is a route a
|
||||
/// provider plugin must be able to call — reconciling its own entry set is the whole point of a
|
||||
/// scanner plugin — while `prep` / `launch.kind = "command"` inside that payload are the operator's
|
||||
@@ -22,14 +26,32 @@ use axum::Extension;
|
||||
/// `reason` is the caller's log line. It exists because these are TWO different refusals — an
|
||||
/// operator-privileged field (403) and an unservable art path (400) — and logging both as "carries
|
||||
/// a field this lane may not set" sent the Lutris/Steam `file://` art rejection looking like an
|
||||
/// auth problem. The plugin only ever sees `HostRequestError`, so this log line is the sole
|
||||
/// diagnosis surface for whoever has to explain why a scanner syncs nothing.
|
||||
/// auth problem.
|
||||
fn check_entry_fields(
|
||||
lane: AuthLane,
|
||||
art: &crate::library::Artwork,
|
||||
launch: Option<&crate::library::LaunchSpec>,
|
||||
prep: &[crate::hooks::PrepCmd],
|
||||
icon: Option<&str>,
|
||||
) -> Option<(String, Response)> {
|
||||
check_privileged_fields(lane, launch, prep, icon).or_else(|| {
|
||||
crate::library::validate_art_paths(art)
|
||||
.err()
|
||||
.map(|e| (e.clone(), api_error(StatusCode::BAD_REQUEST, &e)))
|
||||
})
|
||||
}
|
||||
|
||||
/// The half of [`check_entry_fields`] that is about *authority* rather than about art: an
|
||||
/// operator-privileged field this lane may not set (403), or an unrepresentable icon token (400).
|
||||
///
|
||||
/// Split out for the provider reconcile, which must apply exactly these two and NOT the art check —
|
||||
/// it sanitizes unservable covers instead of refusing the payload
|
||||
/// ([`crate::library::sanitize_art_paths`] explains why the two callers want different answers).
|
||||
fn check_privileged_fields(
|
||||
lane: AuthLane,
|
||||
launch: Option<&crate::library::LaunchSpec>,
|
||||
prep: &[crate::hooks::PrepCmd],
|
||||
icon: Option<&str>,
|
||||
) -> Option<(String, Response)> {
|
||||
if !lane.may_set_privileged_fields() {
|
||||
if let Some(field) = crate::library::privileged_field(launch, prep) {
|
||||
@@ -55,9 +77,7 @@ fn check_entry_fields(
|
||||
if let Err(e) = crate::library::validate_icon(icon) {
|
||||
return Some((e.clone(), api_error(StatusCode::BAD_REQUEST, &e)));
|
||||
}
|
||||
crate::library::validate_art_paths(art)
|
||||
.err()
|
||||
.map(|e| (e.clone(), api_error(StatusCode::BAD_REQUEST, &e)))
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -468,7 +488,7 @@ pub(crate) async fn reconcile_provider_entries(
|
||||
Extension(lane): Extension<AuthLane>,
|
||||
Path(provider): Path<String>,
|
||||
Query(q): Query<ReconcileQuery>,
|
||||
ApiJson(inputs): ApiJson<Vec<crate::library::ProviderEntryInput>>,
|
||||
ApiJson(mut inputs): ApiJson<Vec<crate::library::ProviderEntryInput>>,
|
||||
) -> Response {
|
||||
if let Err(e) = crate::library::validate_provider_name(&provider) {
|
||||
return api_error(StatusCode::BAD_REQUEST, &e);
|
||||
@@ -484,9 +504,15 @@ pub(crate) async fn reconcile_provider_entries(
|
||||
}
|
||||
// Every entry in the payload, not just the first — a reconcile replaces a whole entry set, so
|
||||
// one privileged field anywhere in it is one command execution.
|
||||
//
|
||||
// Art is deliberately NOT part of this refusal. A privileged field is the plugin overreaching
|
||||
// and must fail the write; an unservable cover is a path mismatch between where a launcher keeps
|
||||
// its art and where the host is allowed to read, and failing the payload over one of those threw
|
||||
// away a working library to save a thumbnail. Those covers are stripped below instead, which
|
||||
// holds the same "no unservable path is ever persisted" invariant.
|
||||
for (i, e) in inputs.iter().enumerate() {
|
||||
if let Some((reason, denied)) =
|
||||
check_entry_fields(lane, &e.art, e.launch.as_ref(), &e.prep, e.icon.as_deref())
|
||||
check_privileged_fields(lane, e.launch.as_ref(), &e.prep, e.icon.as_deref())
|
||||
{
|
||||
tracing::warn!(
|
||||
provider,
|
||||
@@ -498,6 +524,29 @@ pub(crate) async fn reconcile_provider_entries(
|
||||
return denied;
|
||||
}
|
||||
}
|
||||
// One aggregated line, not one per entry: a root mismatch misses EVERY cover in the payload, and
|
||||
// a per-entry warn would bury the rest of the log under a thousand copies of one fact.
|
||||
let mut dropped_art = 0usize;
|
||||
let mut first_dropped: Option<(String, &'static str, String)> = None;
|
||||
for e in inputs.iter_mut() {
|
||||
for (field, value) in crate::library::sanitize_art_paths(&mut e.art) {
|
||||
dropped_art += 1;
|
||||
first_dropped.get_or_insert_with(|| (e.title.clone(), field, value));
|
||||
}
|
||||
}
|
||||
if let Some((title, field, path)) = first_dropped {
|
||||
tracing::warn!(
|
||||
provider,
|
||||
dropped = dropped_art,
|
||||
example_title = %title,
|
||||
example_field = field,
|
||||
example_path = %path,
|
||||
"library reconcile: dropped local art the proxy may not serve — these entries still \
|
||||
sync, but their covers will be blank. The path must be an image file (jpg/png/webp/\
|
||||
gif/bmp/ico/tga) inside an allowed art root; set PUNKTFUNK_LIBRARY_ART_ROOTS if this \
|
||||
library's art lives outside the defaults"
|
||||
);
|
||||
}
|
||||
match crate::library::reconcile_provider(&provider, store.as_deref(), inputs) {
|
||||
Ok(crate::library::MutateOutcome::Done(entries)) => {
|
||||
tracing::info!(
|
||||
|
||||
@@ -256,6 +256,52 @@ pub(crate) async fn unpair_native_client(
|
||||
}
|
||||
}
|
||||
|
||||
/// Unpair every native client
|
||||
///
|
||||
/// The collection form of [`unpair_native_client`]: empties the punktfunk/1 trust store in ONE
|
||||
/// persisted write (not a loop of them — a failure partway would leave a half-emptied store), and
|
||||
/// ends every live native session the removed clients own.
|
||||
///
|
||||
/// Idempotent, hence a 200 rather than the single unpair's 204/404: an already-empty store
|
||||
/// satisfies the request, and the count still tells the operator what it meant.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/native/clients",
|
||||
tag = "native",
|
||||
operation_id = "unpairAllNativeClients",
|
||||
responses(
|
||||
(status = OK, description = "Every native client unpaired (possibly none)", body = UnpairAllResult),
|
||||
(status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the trust store", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn unpair_all_native_clients(State(st): State<Arc<MgmtState>>) -> Response {
|
||||
let Some(np) = &st.native else {
|
||||
return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
|
||||
};
|
||||
match np.remove_all() {
|
||||
Ok(removed) => {
|
||||
// Revocation reaches LIVE sessions too — the same guarantee the single unpair gives,
|
||||
// applied across the set.
|
||||
let stopped: usize = removed
|
||||
.iter()
|
||||
.map(|fp| crate::session_status::stop_by_fingerprint(&fp.to_ascii_lowercase()))
|
||||
.sum();
|
||||
if stopped > 0 {
|
||||
tracing::info!(stopped, "unpair-all: live native session(s) stopped");
|
||||
}
|
||||
let unpaired = removed.len() as u32;
|
||||
tracing::info!(unpaired, "management API: all native clients unpaired");
|
||||
Json(UnpairAllResult { unpaired }).into_response()
|
||||
}
|
||||
Err(e) => api_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("could not persist trust store: {e}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// List devices awaiting pairing approval
|
||||
///
|
||||
/// Unpaired devices that tried to connect while the host requires pairing. Approve one to pair
|
||||
|
||||
@@ -21,6 +21,18 @@ pub(crate) struct ApiError {
|
||||
error: String,
|
||||
}
|
||||
|
||||
/// What a bulk unpair removed. Shared by the two collection DELETEs (`/clients` and
|
||||
/// `/native/clients`) so the console sees one schema across both pairing planes.
|
||||
///
|
||||
/// A count rather than 204: "unpair everything" is idempotent, so an empty store is a success, and
|
||||
/// the operator still wants to be told whether that meant three devices or none.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct UnpairAllResult {
|
||||
/// Clients removed from the trust store — 0 when nothing was paired.
|
||||
#[schema(example = 3)]
|
||||
pub(crate) unpaired: u32,
|
||||
}
|
||||
|
||||
pub(crate) fn api_error(status: StatusCode, message: &str) -> Response {
|
||||
(
|
||||
status,
|
||||
|
||||
@@ -819,7 +819,8 @@ async fn paired_clients_list_and_unpair() {
|
||||
{
|
||||
let mut p = state.paired.lock().unwrap();
|
||||
p.clear();
|
||||
p.push(der);
|
||||
// Cloned, not moved: the unpair-all section at the end of this test re-seeds it.
|
||||
p.push(der.clone());
|
||||
}
|
||||
|
||||
let (status, body) = send(&app, get_req("/api/v1/clients")).await;
|
||||
@@ -888,6 +889,71 @@ async fn paired_clients_list_and_unpair() {
|
||||
serde_json::from_slice::<Vec<Vec<u8>>>(&disk).unwrap(),
|
||||
Vec::<Vec<u8>>::new()
|
||||
);
|
||||
|
||||
// ---- the COLLECTION delete: unpair everything at once -----------------------------------
|
||||
//
|
||||
// Re-seed two clients (the store was just emptied) and clear the teardown flags, so what the
|
||||
// bulk delete does to a live session is attributable to IT and not left over from above.
|
||||
let second = crate::identity::ephemeral().unwrap();
|
||||
let (_, second_pem) = x509_parser::pem::parse_x509_pem(second.cert_pem.as_bytes()).unwrap();
|
||||
let second_der = second_pem.contents.clone();
|
||||
let second_fp = hex::encode(Sha256::digest(&second_der));
|
||||
{
|
||||
use std::sync::atomic::Ordering;
|
||||
let mut p = state.paired.lock().unwrap();
|
||||
p.clear();
|
||||
p.push(der.clone());
|
||||
p.push(second_der);
|
||||
state.quit.store(false, Ordering::SeqCst);
|
||||
state.streaming.store(true, Ordering::SeqCst);
|
||||
// A live session owned by the SECOND client — the bulk delete must end whichever of the
|
||||
// removed certs owns it, not just the first one it happens to walk past.
|
||||
let mut owner = [0u8; 32];
|
||||
owner.copy_from_slice(&hex::decode(&second_fp).unwrap());
|
||||
*state.launch.lock().unwrap() = Some(LaunchSession {
|
||||
gcm_key: [0; 16],
|
||||
rikeyid: 0,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: 60,
|
||||
appid: 1,
|
||||
peer_ip: None,
|
||||
owner_fp: Some(owner),
|
||||
});
|
||||
}
|
||||
|
||||
let del_all = || {
|
||||
axum::http::Request::delete("/api/v1/clients")
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
};
|
||||
let (status, body) = send(&app, del_all()).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["unpaired"], 2, "both clients must be reported removed");
|
||||
|
||||
let (_, body) = send(&app, get_req("/api/v1/clients")).await;
|
||||
assert_eq!(body, serde_json::json!([]));
|
||||
{
|
||||
use std::sync::atomic::Ordering;
|
||||
assert!(
|
||||
state.launch.lock().unwrap().is_none(),
|
||||
"unpair-all must end the live session of any client it revokes"
|
||||
);
|
||||
assert!(state.quit.load(Ordering::SeqCst));
|
||||
}
|
||||
// Persisted, for the same reason the single unpair is: a resurrected pairing would re-open
|
||||
// the control port on the next boot.
|
||||
let disk = std::fs::read(tmp.path().join("paired.json")).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Vec<Vec<u8>>>(&disk).unwrap(),
|
||||
Vec::<Vec<u8>>::new()
|
||||
);
|
||||
|
||||
// Idempotent: emptying an empty store is a 200 with a zero count, NOT the single delete's 404.
|
||||
// ("unpair everything" is already satisfied — there is no missing resource to report.)
|
||||
let (status, body) = send(&app, del_all()).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["unpaired"], 0);
|
||||
}
|
||||
|
||||
#[cfg(feature = "gamestream")]
|
||||
@@ -1248,8 +1314,13 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
|
||||
// ---- paired-device rosters: readable by a plugin, never by another paired client, and
|
||||
// removal is pairing administration in both lanes.
|
||||
("GET", "/api/v1/clients", true, false),
|
||||
// The bulk form is the same authority as the single one — and, sharing its path with a
|
||||
// plugin-readable GET, worth an explicit row: both gates match on (method, path), so the
|
||||
// roster's read permission must never carry over to emptying it.
|
||||
("DELETE", "/api/v1/clients", false, false),
|
||||
("DELETE", "/api/v1/clients/{fingerprint}", false, false),
|
||||
("GET", "/api/v1/native/clients", true, false),
|
||||
("DELETE", "/api/v1/native/clients", false, false),
|
||||
(
|
||||
"DELETE",
|
||||
"/api/v1/native/clients/{fingerprint}",
|
||||
@@ -1667,6 +1738,56 @@ async fn native_pairing_arm_show_and_unpair() {
|
||||
assert_eq!(b["armed"], false);
|
||||
}
|
||||
|
||||
/// The collection delete on the native plane: one call empties the trust store, and repeating it
|
||||
/// is a zero-count success rather than an error.
|
||||
#[tokio::test]
|
||||
async fn native_unpair_all_empties_the_trust_store() {
|
||||
let np = Arc::new(
|
||||
crate::native_pairing::NativePairing::load_with(
|
||||
Some(std::env::temp_dir().join(format!("pf-mgmt-np-all-{}.json", std::process::id()))),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let app = test_app_native(test_state(), np.clone());
|
||||
|
||||
np.add("Living room TV", "aa11").unwrap();
|
||||
np.add("Studio Deck", "bb22").unwrap();
|
||||
assert_eq!(np.list().len(), 2);
|
||||
|
||||
let del_all = || {
|
||||
axum::http::Request::delete("/api/v1/native/clients")
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
};
|
||||
let (status, body) = send(&app, del_all()).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["unpaired"], 2);
|
||||
|
||||
// Gone from both the API and the store behind it (one persisted write, not two).
|
||||
let (_, body) = send(&app, get_req("/api/v1/native/clients")).await;
|
||||
assert_eq!(body, serde_json::json!([]));
|
||||
assert!(np.list().is_empty());
|
||||
assert!(!np.is_paired("aa11") && !np.is_paired("bb22"));
|
||||
|
||||
// Idempotent — unlike the single delete, which 404s on a fingerprint it cannot find.
|
||||
let (status, body) = send(&app, del_all()).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["unpaired"], 0);
|
||||
}
|
||||
|
||||
/// Without a native plane there is no trust store to empty — 503, matching every other
|
||||
/// `/native/*` route (and NOT a silent 200 that would tell the console it had unpaired something).
|
||||
#[tokio::test]
|
||||
async fn native_unpair_all_without_a_native_host_is_unavailable() {
|
||||
let app = test_app(test_state(), None);
|
||||
let req = axum::http::Request::delete("/api/v1/native/clients")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(send(&app, req).await.0, StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_devices_approve_and_deny() {
|
||||
let np = Arc::new(
|
||||
|
||||
@@ -321,16 +321,26 @@ pub(super) fn spawn(
|
||||
}
|
||||
let stop_t = stop.clone();
|
||||
let endpoint_id = ep.endpoint_id;
|
||||
let vis_id = endpoint_id.clone();
|
||||
match std::thread::Builder::new()
|
||||
.name(format!("punktfunk1-pad{pad}"))
|
||||
.spawn(move || {
|
||||
// COM for the visibility flips (the capturer's opens run on their own thread).
|
||||
let _ = wasapi::initialize_mta();
|
||||
// The endpoint parks HIDDEN while no pad is attached — an idle visible "Wireless
|
||||
// Controller" speaker makes libScePad titles engage their DualSense-haptics path
|
||||
// against an endpoint nothing services (the 2026-08-14 Helldivers 2 field tank).
|
||||
// Show it for exactly this pad's lifetime, like a real DualSense arriving; the
|
||||
// capturer's open/backoff loop absorbs the moment audiosrv takes to re-activate.
|
||||
crate::audio::pad_endpoint::set_visibility(&vis_id, pad, true);
|
||||
pad_audio_thread(
|
||||
conn,
|
||||
pad,
|
||||
kinds,
|
||||
move || crate::audio::pad_endpoint::PadLoopbackCapturer::open(&endpoint_id),
|
||||
stop_t,
|
||||
)
|
||||
);
|
||||
crate::audio::pad_endpoint::set_visibility(&vis_id, pad, false);
|
||||
}) {
|
||||
Ok(join) => Some(PadAudioHandle {
|
||||
stop,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -159,6 +159,12 @@ impl NativePairing {
|
||||
self.store.remove(fp_hex)
|
||||
}
|
||||
|
||||
/// Remove EVERY paired client in one persisted write. Returns the fingerprints removed, so the
|
||||
/// caller can end the sessions they own. On a persist failure nothing is removed.
|
||||
pub fn remove_all(&self) -> Result<Vec<String>> {
|
||||
self.store.remove_all()
|
||||
}
|
||||
|
||||
// -- Delegated approval (roadmap §8b-1) ---------------------------------
|
||||
|
||||
/// Record an unpaired device's knock for delegated approval. Re-knocks from the same fingerprint
|
||||
|
||||
@@ -130,6 +130,28 @@ impl TrustStore {
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// Remove EVERY paired client, in ONE persisted write. Returns the fingerprints removed, so
|
||||
/// the caller can tear down the live sessions they own. On a persist failure the in-memory
|
||||
/// store is rolled back (it never diverges from disk), exactly like [`Self::remove`].
|
||||
///
|
||||
/// Not a loop over [`Self::remove`]: that would rewrite (and fsync-rename) the store once per
|
||||
/// client, and a failure partway would leave the operator with a half-emptied trust store and
|
||||
/// no way to tell which half.
|
||||
pub(super) fn remove_all(&self) -> Result<Vec<String>> {
|
||||
let mut p = self.paired.lock().unwrap();
|
||||
if p.clients.clients.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// `take` leaves the empty list in place to be persisted, and hands us the snapshot that
|
||||
// doubles as both the rollback value and the removed-fingerprint report.
|
||||
let snapshot = std::mem::take(&mut p.clients.clients);
|
||||
if let Err(e) = save(&p) {
|
||||
p.clients.clients = snapshot;
|
||||
return Err(e);
|
||||
}
|
||||
Ok(snapshot.into_iter().map(|c| c.fingerprint).collect())
|
||||
}
|
||||
|
||||
/// The number of paired clients (for the status snapshot).
|
||||
pub(super) fn count(&self) -> u32 {
|
||||
self.paired.lock().unwrap().clients.clients.len() as u32
|
||||
|
||||
@@ -35,6 +35,12 @@ const UNIT: &str = "punktfunk-scripting";
|
||||
#[cfg(target_os = "windows")]
|
||||
const TASK: &str = "PunktfunkScripting";
|
||||
|
||||
/// The runner executable's name. Every non-Windows package installs a wrapper under exactly this
|
||||
/// name — the deb/rpm at `/usr/bin`, the SteamOS installer at `~/.local/bin`, Nix at
|
||||
/// `$out/bin` — so one name covers every layout the resolver walks.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const RUNNER_BIN: &str = "punktfunk-scripting";
|
||||
|
||||
pub fn main(args: &[String]) -> Result<()> {
|
||||
match args.first().map(String::as_str) {
|
||||
Some("add") | Some("remove") | Some("rm") | Some("uninstall") | Some("list")
|
||||
@@ -161,41 +167,103 @@ pub(crate) fn runner_command() -> Result<(std::path::PathBuf, Vec<String>)> {
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// The scripting package ships /usr/bin/punktfunk-scripting, a wrapper that runs the bundled
|
||||
// bun on the runner bundle and forwards "$@" (packaging/debian/build-scripting-deb.sh).
|
||||
let wrapper = std::path::PathBuf::from("/usr/bin/punktfunk-scripting");
|
||||
if wrapper.exists() {
|
||||
return Ok((wrapper, Vec::new()));
|
||||
}
|
||||
// Fall back to the package's private layout in case the wrapper is absent.
|
||||
let bun = std::path::PathBuf::from("/usr/lib/punktfunk-scripting/bun");
|
||||
let runner = std::path::PathBuf::from("/usr/share/punktfunk-scripting/runner-cli.js");
|
||||
if bun.exists() && runner.exists() {
|
||||
return Ok((bun, vec![runner.to_string_lossy().into_owned()]));
|
||||
}
|
||||
// Immutable-/usr distros (SteamOS): scripts/steamdeck/install.sh lays the SAME payload
|
||||
// out user-scoped under ~/.local — wrapper, private bun, and bundle mirroring the deb's
|
||||
// /usr layout — because a system package can't exist there.
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
let home = std::path::Path::new(&home);
|
||||
let wrapper = home.join(".local/bin/punktfunk-scripting");
|
||||
if wrapper.exists() {
|
||||
return Ok((wrapper, Vec::new()));
|
||||
}
|
||||
let bun = home.join(".local/lib/punktfunk-scripting/bun");
|
||||
let runner = home.join(".local/share/punktfunk-scripting/runner-cli.js");
|
||||
if bun.exists() && runner.exists() {
|
||||
return Ok((bun, vec![runner.to_string_lossy().into_owned()]));
|
||||
}
|
||||
}
|
||||
bail!(
|
||||
"the plugin runner isn't installed — install it first (Debian/Ubuntu: \
|
||||
`sudo apt install punktfunk-scripting`; SteamOS: re-run \
|
||||
scripts/steamdeck/install.sh)"
|
||||
let exe = std::env::current_exe().ok();
|
||||
let path_var = std::env::var("PATH").ok();
|
||||
let home = std::env::var("HOME").ok();
|
||||
resolve_runner_in(
|
||||
std::env::var("PUNKTFUNK_SCRIPTING").ok().as_deref(),
|
||||
exe.as_deref().and_then(std::path::Path::parent),
|
||||
path_var.as_deref(),
|
||||
home.as_deref().map(std::path::Path::new),
|
||||
&|p| p.is_file(),
|
||||
)
|
||||
.ok_or_else(|| anyhow::anyhow!("{RUNNER_MISSING}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// What to say when no rung matched. Shared with [`runtime_status`], so the CLI and the console
|
||||
/// tell an operator the same thing.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) const RUNNER_MISSING: &str =
|
||||
"the plugin runner isn't installed — install it first (Debian/Ubuntu: `sudo apt install \
|
||||
punktfunk-scripting`; SteamOS: re-run scripts/steamdeck/install.sh; NixOS: enable \
|
||||
`services.punktfunk.scripting`). If it is installed somewhere else, point PUNKTFUNK_SCRIPTING \
|
||||
at the punktfunk-scripting executable.";
|
||||
|
||||
/// The rungs, in order: `PUNKTFUNK_SCRIPTING` → beside the host binary → `PATH` → the packaged
|
||||
/// `/usr` layout → the user-scoped SteamOS layout. Pure and fully injected so the table can be
|
||||
/// tested without mutating process env, which races `getenv` in parallel tests.
|
||||
///
|
||||
/// `PATH` is load-bearing rather than a nicety: it is the ONLY rung a Nix install can land on.
|
||||
/// `punktfunk-scripting` is a derivation of its own there (packaging/nix/packages.nix), so its
|
||||
/// wrapper is neither beside the host binary nor anywhere under `/usr` — the layouts this
|
||||
/// resolver used to check exclusively, which is why a fully working NixOS box reported the runner
|
||||
/// as not installed.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn resolve_runner_in(
|
||||
env: Option<&str>,
|
||||
exe_dir: Option<&std::path::Path>,
|
||||
path_var: Option<&str>,
|
||||
home: Option<&std::path::Path>,
|
||||
exists: &dyn Fn(&std::path::Path) -> bool,
|
||||
) -> Option<(std::path::PathBuf, Vec<String>)> {
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
// The two-file layout: a private bun plus the runner bundle, which the deb/rpm and the SteamOS
|
||||
// installer both lay down beside their wrapper. Only a rung when BOTH halves are present.
|
||||
let pair = |bun: PathBuf, runner: PathBuf| -> Option<(PathBuf, Vec<String>)> {
|
||||
(exists(&bun) && exists(&runner))
|
||||
.then(|| (bun, vec![runner.to_string_lossy().into_owned()]))
|
||||
};
|
||||
|
||||
// The operator's own override, and deliberately NOT existence-checked: whoever names a path is
|
||||
// entitled to a failure that names it back, where falling through to a runner that happens to
|
||||
// be installed would hide the typo behind a working install.
|
||||
if let Some(v) = env.map(str::trim).filter(|v| !v.is_empty()) {
|
||||
return Some((PathBuf::from(v), Vec::new()));
|
||||
}
|
||||
// Beside the host binary — a source tree or any relocatable layout shipping both in one prefix.
|
||||
if let Some(p) = exe_dir.map(|d| d.join(RUNNER_BIN)).filter(|p| exists(p)) {
|
||||
return Some((p, Vec::new()));
|
||||
}
|
||||
if let Some(p) = path_var
|
||||
.into_iter()
|
||||
.flat_map(|v| v.split(':'))
|
||||
.filter(|d| !d.is_empty())
|
||||
.map(|d| Path::new(d).join(RUNNER_BIN))
|
||||
.find(|p| exists(p))
|
||||
{
|
||||
return Some((p, Vec::new()));
|
||||
}
|
||||
// The packaged /usr layout (packaging/debian/build-scripting-deb.sh). Still checked explicitly
|
||||
// after `PATH` because a systemd unit can carry a PATH that does not include /usr/bin.
|
||||
let wrapper = Path::new("/usr/bin").join(RUNNER_BIN);
|
||||
if exists(&wrapper) {
|
||||
return Some((wrapper, Vec::new()));
|
||||
}
|
||||
if let Some(cmd) = pair(
|
||||
Path::new("/usr/lib").join(RUNNER_BIN).join("bun"),
|
||||
Path::new("/usr/share")
|
||||
.join(RUNNER_BIN)
|
||||
.join("runner-cli.js"),
|
||||
) {
|
||||
return Some(cmd);
|
||||
}
|
||||
// Immutable-/usr distros (SteamOS): scripts/steamdeck/install.sh lays the SAME payload out
|
||||
// user-scoped under ~/.local, because a system package can't exist there.
|
||||
let home = home?;
|
||||
let wrapper = home.join(".local/bin").join(RUNNER_BIN);
|
||||
if exists(&wrapper) {
|
||||
return Some((wrapper, Vec::new()));
|
||||
}
|
||||
pair(
|
||||
home.join(".local/lib").join(RUNNER_BIN).join("bun"),
|
||||
home.join(".local/share")
|
||||
.join(RUNNER_BIN)
|
||||
.join("runner-cli.js"),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- service ops ------------------------------------------------------------------------------
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -277,9 +345,7 @@ pub(crate) fn runtime_status() -> RuntimeStatus {
|
||||
detail: if installed {
|
||||
String::new()
|
||||
} else {
|
||||
"the plugin runner package isn't installed (Debian/Ubuntu: `sudo apt install \
|
||||
punktfunk-scripting`)"
|
||||
.into()
|
||||
RUNNER_MISSING.into()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -807,3 +873,151 @@ fn enable() -> Result<()> {
|
||||
fn disable() -> Result<()> {
|
||||
bail!("the plugin runner is only available on Linux and Windows hosts")
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_os = "windows")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Build an `exists` probe over a fixed set of paths.
|
||||
fn present(ps: Vec<PathBuf>) -> impl Fn(&Path) -> bool {
|
||||
move |p: &Path| ps.iter().any(|q| q == p)
|
||||
}
|
||||
|
||||
/// Every layout the resolver has to serve, in one table — the regression guard for the NixOS
|
||||
/// report (a runner on `PATH` and nowhere else read as "not installed").
|
||||
#[test]
|
||||
fn runner_resolution_table() {
|
||||
let beside = Path::new("/opt/punktfunk/bin");
|
||||
let nix = Path::new("/run/current-system/sw/bin");
|
||||
let home = Path::new("/home/deck");
|
||||
|
||||
// The rung a Nix install lands on: NOT beside the host binary, NOT under /usr — `PATH`
|
||||
// only. This is the whole bug.
|
||||
let exists = present(vec![nix.join(RUNNER_BIN)]);
|
||||
assert_eq!(
|
||||
resolve_runner_in(
|
||||
None,
|
||||
Some(beside),
|
||||
Some(nix.to_str().unwrap()),
|
||||
None,
|
||||
&exists
|
||||
),
|
||||
Some((nix.join(RUNNER_BIN), Vec::new()))
|
||||
);
|
||||
|
||||
// An explicit override wins over every discovery…
|
||||
let exists = present(vec![beside.join(RUNNER_BIN)]);
|
||||
assert_eq!(
|
||||
resolve_runner_in(
|
||||
Some("/nix/store/abc/bin/punktfunk-scripting"),
|
||||
Some(beside),
|
||||
Some("/usr/bin"),
|
||||
Some(home),
|
||||
&exists
|
||||
),
|
||||
Some(("/nix/store/abc/bin/punktfunk-scripting".into(), Vec::new()))
|
||||
);
|
||||
// …and is not existence-checked, so a typo surfaces as a spawn failure naming the path
|
||||
// rather than silently running some other runner.
|
||||
assert_eq!(
|
||||
resolve_runner_in(Some("/nope/pf"), Some(beside), None, None, &exists),
|
||||
Some(("/nope/pf".into(), Vec::new()))
|
||||
);
|
||||
// Empty/whitespace reads as unset, not as a path.
|
||||
assert_eq!(
|
||||
resolve_runner_in(Some(" "), Some(beside), None, None, &exists),
|
||||
Some((beside.join(RUNNER_BIN), Vec::new()))
|
||||
);
|
||||
// Beside the host binary beats PATH.
|
||||
let exists = present(vec![beside.join(RUNNER_BIN), nix.join(RUNNER_BIN)]);
|
||||
assert_eq!(
|
||||
resolve_runner_in(
|
||||
None,
|
||||
Some(beside),
|
||||
Some(nix.to_str().unwrap()),
|
||||
None,
|
||||
&exists
|
||||
),
|
||||
Some((beside.join(RUNNER_BIN), Vec::new()))
|
||||
);
|
||||
// PATH is walked entry by entry, skipping empties.
|
||||
let exists = present(vec![nix.join(RUNNER_BIN)]);
|
||||
assert_eq!(
|
||||
resolve_runner_in(
|
||||
None,
|
||||
Some(Path::new("/nowhere")),
|
||||
Some(":/nope:/run/current-system/sw/bin"),
|
||||
None,
|
||||
&exists
|
||||
),
|
||||
Some((nix.join(RUNNER_BIN), Vec::new()))
|
||||
);
|
||||
|
||||
// The deb/rpm wrapper, found even when the unit's PATH omits /usr/bin.
|
||||
let exists = present(vec![PathBuf::from("/usr/bin").join(RUNNER_BIN)]);
|
||||
assert_eq!(
|
||||
resolve_runner_in(
|
||||
None,
|
||||
Some(Path::new("/nowhere")),
|
||||
Some("/nope"),
|
||||
None,
|
||||
&exists
|
||||
),
|
||||
Some((PathBuf::from("/usr/bin").join(RUNNER_BIN), Vec::new()))
|
||||
);
|
||||
// …and its private two-file layout when the wrapper is absent.
|
||||
let bun = PathBuf::from("/usr/lib").join(RUNNER_BIN).join("bun");
|
||||
let cli = PathBuf::from("/usr/share")
|
||||
.join(RUNNER_BIN)
|
||||
.join("runner-cli.js");
|
||||
let exists = present(vec![bun.clone(), cli.clone()]);
|
||||
assert_eq!(
|
||||
resolve_runner_in(None, None, None, None, &exists),
|
||||
Some((bun, vec![cli.to_string_lossy().into_owned()]))
|
||||
);
|
||||
// Half of that layout is not a rung — a partial install must fall through, not spawn a
|
||||
// bun with no script.
|
||||
let exists = present(vec![PathBuf::from("/usr/lib").join(RUNNER_BIN).join("bun")]);
|
||||
assert_eq!(resolve_runner_in(None, None, None, None, &exists), None);
|
||||
|
||||
// SteamOS: the same payload, user-scoped. Reached only via HOME.
|
||||
let exists = present(vec![home.join(".local/bin").join(RUNNER_BIN)]);
|
||||
assert_eq!(
|
||||
resolve_runner_in(None, None, None, Some(home), &exists),
|
||||
Some((home.join(".local/bin").join(RUNNER_BIN), Vec::new()))
|
||||
);
|
||||
assert_eq!(resolve_runner_in(None, None, None, None, &exists), None);
|
||||
let bun = home.join(".local/lib").join(RUNNER_BIN).join("bun");
|
||||
let cli = home
|
||||
.join(".local/share")
|
||||
.join(RUNNER_BIN)
|
||||
.join("runner-cli.js");
|
||||
let exists = present(vec![bun.clone(), cli.clone()]);
|
||||
assert_eq!(
|
||||
resolve_runner_in(None, None, None, Some(home), &exists),
|
||||
Some((bun, vec![cli.to_string_lossy().into_owned()]))
|
||||
);
|
||||
|
||||
// Nothing anywhere: the "not installed" rung the error text speaks for.
|
||||
let exists = present(vec![]);
|
||||
assert_eq!(
|
||||
resolve_runner_in(None, Some(beside), Some("/nope"), Some(home), &exists),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// The operator-facing miss must name NixOS — the report's second half was that the error
|
||||
/// pointed a NixOS operator at `apt`.
|
||||
#[test]
|
||||
fn the_missing_runner_error_names_every_platform_it_can_be_installed_on() {
|
||||
for hint in [
|
||||
"apt install",
|
||||
"steamdeck/install.sh",
|
||||
"NixOS",
|
||||
"PUNKTFUNK_SCRIPTING",
|
||||
] {
|
||||
assert!(RUNNER_MISSING.contains(hint), "missing hint: {hint}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,8 +93,9 @@ the full picture (and [Bazzite](/docs/bazzite) for that distro's specifics).
|
||||
| `PUNKTFUNK_GAMESCOPE_NODE` | `auto` · node id | Discover + capture a **running** gamescope's PipeWire node at a fixed mode. Do **not** combine with `SESSION`. |
|
||||
| `PUNKTFUNK_GAMESCOPE_APP` | command | For an ad-hoc bare-gamescope session, the nested command to run (e.g. `vkcube`). |
|
||||
| `PUNKTFUNK_GAMESCOPE_HDR` | `1` · `0` *(default on)* | Allow HDR (10-bit BT.2020 PQ) sessions on the gamescope backend. Needs the `punktfunk-gamescope` build — see [HDR on gamescope](/docs/gamescope#hdr-on-gamescope); without the build, sessions stream SDR. Set `0` to force SDR. |
|
||||
| `PUNKTFUNK_GAMESCOPE_SDR_NITS` | e.g. `400` | On an HDR gamescope session, the luminance SDR content (desktop, Steam overlay, SDR games) is mapped to inside the PQ container. Unset = gamescope's own default of 400. |
|
||||
| `PUNKTFUNK_GAMESCOPE_SDR_NITS` | e.g. `400` | On an HDR gamescope session, the luminance SDR content (desktop, Steam overlay, SDR games) is mapped to inside the PQ container. Unset = 203, BT.2408 reference white, which is what our clients decode against (gamescope's own default of 400 sits nearly a stop above it). |
|
||||
| `PUNKTFUNK_GAMESCOPE_BIN` | path | Force a specific gamescope binary for the sessions the host spawns. Unset = prefer `punktfunk-gamescope` on `PATH`, then `gamescope`. |
|
||||
| `PUNKTFUNK_GAMESCOPE_WSI_LAYER_DIR` | path | Directory holding our Vulkan WSI layer's manifest — the layer that lets a game nested under gamescope get an HDR10 swapchain. Unset = `/usr/lib/punktfunk/vulkan/implicit_layer.d`, where every distro package installs it. The NixOS module sets this for you, since the layer lives inside the gamescope derivation there. If no manifest is found the host leaves the system's own layer alone and games stay SDR. |
|
||||
| `PUNKTFUNK_SESSION_WATCH` | `1` · `0` | Follow a Gaming ↔ Desktop switch **mid-stream** (rebuild the backend in place, no reconnect). **On by default** on Bazzite/SteamOS; set `0` to disable. |
|
||||
| `PUNKTFUNK_GAMESCOPE_GRAB_CURSOR` | `1` | Add `--force-grab-cursor` to a bare gamescope session the host spawns **to run an app or game** (never the empty keep-alive session), forcing relative-mouse capture so FPS mouselook works over the injected pointer. **Off by default** — relative mode breaks absolute-pointer titles and menus, so turn it on per host. |
|
||||
| `PUNKTFUNK_GAMESCOPE_SPLASH` | `1` · `0` *(default on)* | Run the built-in splash client inside each bare gamescope session the host spawns. **Leave it on**: gamescope only produces capture buffers once something paints, and a Steam launch paints nothing for its whole bootstrap — without the splash a fresh session starves and times out. `0` is a debugging escape hatch. |
|
||||
@@ -114,6 +115,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|
||||
|---|---|---|
|
||||
| `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` | `1` | Make the streamed per-session output the sole desktop so plasmashell + windows render on it (not on the headless bootstrap output). Set by the KDE appliance `host.env`. Superseded by the console's **Topology** setting. |
|
||||
| `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` | `1` | GNOME/Mutter equivalent of the above. |
|
||||
| `PUNKTFUNK_PORTAL_CURSOR_MODE` | `auto` *(default)* · `embedded` · `metadata` · `hidden` | **Hyprland / wlroots only, and a troubleshooting knob** — which ScreenCast cursor mode the host asks the portal for. Unset, the host asks for `metadata` when the client draws the pointer itself and `embedded` otherwise, then settles that against the modes your portal advertises; it never requests one your portal lacks. Set `embedded` if the pointer misbehaves on a portal that *claims* metadata support but implements it poorly — that is the one case the automatic negotiation cannot detect. A pin is still only a preference: it is checked against the advertised modes like any other. |
|
||||
|
||||
## Session recovery (Linux)
|
||||
|
||||
@@ -217,7 +219,7 @@ it — leave it or delete it, it makes no difference.
|
||||
| `PUNKTFUNK_PLUGIN_TOKEN` | token | The scoped token the [plugin/scripting runner](/docs/plugins) uses — a narrower credential than `PUNKTFUNK_MGMT_TOKEN`, never full admin. Same precedence: if unset it's generated and persisted to `~/.config/punktfunk/plugin-token`. Set only to pin a specific token. |
|
||||
| `PUNKTFUNK_CONFIG_DIR` | path | Override the config directory (default `~/.config/punktfunk`) — pairing state, certs, apps.json, captures. |
|
||||
| `PUNKTFUNK_UI_PLUGIN_PORT` | port *(default: console port + 1)* | The separate port [plugin](/docs/plugins) UIs are served from. They get their own origin on purpose — a plugin page can never act as *you* on the console. If the console log says this port couldn't be opened (plugin UIs then stay disabled rather than sharing the console's origin), point it at a free port and restart. |
|
||||
| `PUNKTFUNK_LIBRARY_ART_ROOTS` | directories, `;`-separated | Where the host is allowed to read game artwork from when serving your library. Defaults to sensible platform roots (your home directory on Linux/macOS); set it when box art lives elsewhere — a second drive, a network mount. The host log's "not under an allowed art root" line is this knob's cue. |
|
||||
| `PUNKTFUNK_LIBRARY_ART_ROOTS` | directories, separated like `PATH` (`;` on Windows, `:` on Linux/macOS) | Where the host is allowed to read game artwork from when serving your library. Defaults to sensible platform roots: your home directory on Linux/macOS, and on Windows the users base (`C:\Users`) plus your Steam install, wherever it is. Set it when box art lives somewhere else again — a second drive, a network mount, or a launcher installed outside all of those. Setting it **replaces** the defaults, so list every root you need. The host log's "dropped local art the proxy may not serve" line is this knob's cue: those entries still appear in your library, but their covers stay blank until the root is allowed. |
|
||||
|
||||
## Updates
|
||||
|
||||
@@ -244,6 +246,7 @@ notes for context.
|
||||
| `PUNKTFUNK_IDD_DEPTH` | `N` (default `2`) | **(Windows)** IDD-push pipeline depth. `1` cuts latency once GPU priority is raised; higher smooths a contended GPU. |
|
||||
| `PYROWAVE_QUEUE_PRIORITY` | `realtime` *(default)* · `high` · `off` | [PyroWave](/docs/pyrowave) sessions only — the *intent*, forwarded to whichever process does the encode. PyroWave encodes on the same GPU shader cores a game uses, so a demanding game can starve it and the frame rate drops. This asks the driver to schedule the encode ahead of the game. `realtime` tries the strongest class and falls back to `high`; `high` asks only for the middle one; `off` disables the request. A driver that refuses simply encodes at normal priority — it can never stop a session starting. Granting the request needs the `CAP_SYS_NICE` capability, which the Linux packages give to `punktfunk-encode-worker` and **never** to `punktfunk-host` — a host holding any capability cannot be identified by KWin and loses desktop streaming entirely. Do not `setcap` the host to "make this work"; see [Running as a service](/docs/running-as-a-service#gpu-scheduling-priority). Set `off` if you see the desktop stutter while streaming. |
|
||||
| `PUNKTFUNK_ENCODE_WORKER` | path · `off` | Where the host looks for `punktfunk-encode-worker`, the small capability-carrying helper that owns the priority-elevated [PyroWave](/docs/pyrowave) encode (previous row). Unset, the host looks beside its own binary and then on `PATH`, which is right for every package — set it only when the worker lives somewhere unusual. **NixOS needs it and the module sets it for you:** a file capability cannot live on a read-only nix store path, so the worker is exposed through `security.wrappers` and this points the host at that wrapper. `off` forces the encode back into the host process at default priority — a debug escape hatch, not a tuning knob. Every failure short of that is already handled: a missing binary, a worker that will not start, or one that dies mid-session falls back to encoding in-process with one line in the log, and never drops the session. |
|
||||
| `PUNKTFUNK_SCRIPTING` | path | Where the host looks for `punktfunk-scripting`, the runner that performs every [plugin](/docs/plugins) package op (`plugins add`/`remove`/`list`, and the console's store installs). Unset, the host looks beside its own binary, then on `PATH`, then in the packaged `/usr` and `~/.local` layouts — right for every package, so set it only when the runner lives somewhere unusual. Like the row above it is **not** existence-checked: a path you name is a path you get, so a typo fails naming itself instead of quietly running a different runner. Worth knowing: the console runs installs inside the host *service*, whose `PATH` is normally much shorter than your login shell's — if `punktfunk-host plugins add` works and the console says the runner isn't installed, that gap is why, and this is the fix. |
|
||||
|
||||
## Diagnostics
|
||||
|
||||
|
||||
@@ -85,8 +85,11 @@ happens. Staying on attach also leaves the stream with no cursor;
|
||||
[HDR on gamescope](/docs/gamescope#hdr-on-gamescope) has the fix for that half.
|
||||
|
||||
SDR content rides the same PQ container — the desktop, the Steam overlay, an SDR game — mapped in at
|
||||
`PUNKTFUNK_GAMESCOPE_SDR_NITS` (gamescope's own default is 400). That is the knob when white looks
|
||||
too bright or too dim on your TV.
|
||||
`PUNKTFUNK_GAMESCOPE_SDR_NITS`, which defaults to **203 nits**. That is BT.2408 reference white, and
|
||||
it is the level our clients decode against, so the two ends agree out of the box. gamescope's own
|
||||
default is 400, nearly a stop brighter; hosts that let it float showed a glaring, over-saturated
|
||||
Steam UI and washed-out HDR game content on the same stream. Move the knob if you want a brighter or
|
||||
dimmer desktop, but be aware that moving it re-opens that gap.
|
||||
|
||||
### Linux + GNOME
|
||||
|
||||
|
||||
@@ -84,6 +84,31 @@ and fails the session with a clear error rather than streaming a blank surface.
|
||||
capture the Hyprland log (`hyprctl` instance dir → `hyprland.log`) and check your GPU's GBM support;
|
||||
running Hyprland as a real session (not nested) is the supported configuration.
|
||||
|
||||
## Troubleshooting: black client + "unavailable cursor mode 4"
|
||||
|
||||
A black client, `pipeline build failed` in the host log, and **`unavailable cursor mode 4`** from
|
||||
xdph are one failure, not three.
|
||||
|
||||
`4` is the ScreenCast portal's *metadata* cursor mode, which the host prefers when the client draws
|
||||
the pointer locally (desktop mouse mode). xdg-desktop-portal-hyprland **does not offer that mode** —
|
||||
on a current stack (Hyprland 0.56.2, xdph 1.4.1) its `AvailableCursorModes` is `3`, meaning hidden
|
||||
and embedded only. Asking for a mode the backend does not advertise is not a soft failure:
|
||||
`xdg-desktop-portal` rejects the call outright, so the cast died during setup and the client had
|
||||
nothing to show.
|
||||
|
||||
Updating xdph does **not** fix this — the mode is absent on current versions, not just old ones.
|
||||
Hosts from this release check what your portal advertises and use an embedded cursor instead, so the
|
||||
session streams. If you are on an older host, switch the client to **game mouse mode**: that stops
|
||||
it asking for the metadata cursor at all.
|
||||
|
||||
If the pointer misbehaves on an xdph that *does* advertise metadata support, pin the mode:
|
||||
|
||||
```sh
|
||||
PUNKTFUNK_PORTAL_CURSOR_MODE=embedded
|
||||
```
|
||||
|
||||
See [Configuration](/docs/configuration#compositor-specific-linux).
|
||||
|
||||
## Permission system
|
||||
|
||||
Hyprland's permission system (`ecosystem.enforce_permissions`, 0.49+, **off by default**) can deny
|
||||
|
||||
@@ -305,7 +305,16 @@ installer's `PATH` change, or call the exe by full path. On Linux the host packa
|
||||
[same RPM repo you installed the host from](/docs/fedora). On Arch:
|
||||
`sudo pacman -Syu punktfunk-scripting` (a full `-Syu`, like every other install from that repo).
|
||||
On SteamOS, re-run `scripts/steamdeck/install.sh` (or
|
||||
`scripts/steamdeck/update.sh`). On Windows, re-run the installer and keep the scripting component.
|
||||
`scripts/steamdeck/update.sh`). On NixOS it comes with `services.punktfunk.scripting.enable` (on by
|
||||
default whenever the host is). On Windows, re-run the installer and keep the scripting component.
|
||||
|
||||
If the runner *is* installed and the host still says it isn't, the host could not find the
|
||||
`punktfunk-scripting` executable. It looks beside its own binary, then on `PATH`, then in the
|
||||
packaged `/usr` and `~/.local` layouts — so a runner installed somewhere else needs
|
||||
`PUNKTFUNK_SCRIPTING` pointed at it (see [Configuration](/docs/configuration)). Note that the
|
||||
console installs plugins from inside the host *service*, whose `PATH` is usually much shorter than
|
||||
your shell's: a runner that `punktfunk-host plugins add` finds and the console does not is that
|
||||
difference, and the env var is the fix.
|
||||
|
||||
**Where a plugin's log output goes** — the console's **Logs** page, under the **Plugins** filter.
|
||||
The runner ships everything your plugins print to the host, so a plugin's own lines sit next to the
|
||||
|
||||
@@ -82,6 +82,20 @@ For how long the virtual output lives, and extend-vs-exclusive topology, see
|
||||
Then `systemctl --user restart xdg-desktop-portal`. On a box with only xdpw installed there is
|
||||
nothing to choose between, so you can skip this.
|
||||
|
||||
## Troubleshooting: black client + "unsupported cursor mode requested"
|
||||
|
||||
A black client with `pipeline build failed` in the host log and **`dbus: unsupported cursor mode
|
||||
requested, cancelling`** from xdpw is one failure, not two.
|
||||
|
||||
xdpw refuses the ScreenCast *metadata* cursor mode and cancels the cast, and the portal spec makes
|
||||
that fatal rather than a fallback. Hosts before this release asked for it whenever the client drew
|
||||
the pointer itself (desktop mouse mode), so those sessions never produced a frame. Hosts from this
|
||||
release check what xdpw advertises first and use an embedded cursor instead, so the session streams.
|
||||
|
||||
On an older host, switch the client to **game mouse mode** — it stops asking for the metadata cursor
|
||||
and the stream comes up. The same failure on Hyprland reads `unavailable cursor mode 4`; see
|
||||
[Hyprland](/docs/hyprland).
|
||||
|
||||
## Start the host
|
||||
|
||||
With the backend selected, start the host from **inside your Sway session**:
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.27.0"
|
||||
"version": "0.28.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -45,6 +45,36 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"clients"
|
||||
],
|
||||
"summary": "Unpair every client",
|
||||
"description": "The collection form of [`unpair_client`]: empties the pairing store in ONE persisted write,\ncarrying the same revocation guarantees across the whole set. A LIVE GameStream session is\nended (its owning certificate is necessarily one of those just removed), and the ENet control\nport (UDP 47999) closes, because no pairing is left to hold it open.\n\nIdempotent, and so a 200 rather than the single unpair's 204/404 pair: \"unpair everything\" is\nsatisfied by an already-empty store, and the operator still wants to know whether that meant\nthree devices or none.",
|
||||
"operationId": "unpairAllClients",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Every client unpaired (possibly none)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnpairAllResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/clients/{fingerprint}": {
|
||||
@@ -1767,6 +1797,56 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"native"
|
||||
],
|
||||
"summary": "Unpair every native client",
|
||||
"description": "The collection form of [`unpair_native_client`]: empties the punktfunk/1 trust store in ONE\npersisted write (not a loop of them — a failure partway would leave a half-emptied store), and\nends every live native session the removed clients own.\n\nIdempotent, hence a 200 rather than the single unpair's 204/404: an already-empty store\nsatisfies the request, and the count still tells the operator what it meant.",
|
||||
"operationId": "unpairAllNativeClients",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Every native client unpaired (possibly none)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnpairAllResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Could not persist the trust store",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Native host not enabled",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/native/clients/{fingerprint}": {
|
||||
@@ -7687,6 +7767,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"UnpairAllResult": {
|
||||
"type": "object",
|
||||
"description": "What a bulk unpair removed. Shared by the two collection DELETEs (`/clients` and\n`/native/clients`) so the console sees one schema across both pairing planes.\n\nA count rather than 204: \"unpair everything\" is idempotent, so an empty store is a success, and\nthe operator still wants to be told whether that meant three devices or none.",
|
||||
"required": [
|
||||
"unpaired"
|
||||
],
|
||||
"properties": {
|
||||
"unpaired": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Clients removed from the trust store — 0 when nothing was paired.",
|
||||
"example": 3,
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateJobInfo": {
|
||||
"type": "object",
|
||||
"description": "A running apply job (or a spawned installer that hasn't resolved yet).",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
Wire-compatible with 0.28.0 — 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 is a fix release, and most of it is about things that were wrong in ways nothing announced. On Windows, your Steam library could stay completely empty after the last update, and a Punktfunk host sitting idle — nothing connected, nothing streaming — could wreck the frame rate of a game you were playing on that same machine. Streaming from a Mac with the microphone on cut the sound and froze the controls every couple of seconds. An Android TV box could be silent for a whole session; a Steam Deck's Punktfunk home would not open; games could not reach high dynamic range (HDR) at all on Linux machines running our own compositor, while the Steam interface in front of them glared; and an Apple TV was being pushed into HDR mode by streams that were not HDR at all, lifting blacks to grey.
|
||||
|
||||
Several of these came from people who reported them carefully enough to be found. One of them, the crash that lost your game on reconnect, was tracked down, fixed and proven on his own hardware by **luxus** — that fix is in this release as he wrote it.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **On Windows, your Steam games went missing after 0.28.0** — the add-on could publish nothing, because Steam keeps its artwork somewhere the host was not allowed to read.
|
||||
- **An idle host could ruin a game running on that same Windows machine**, down to 2–5 frames per second, with nothing connected and no stream running.
|
||||
- **Streaming from a Mac with your microphone on cut the sound and froze your controls every couple of seconds** — and turning the mic off was the only way round it.
|
||||
- **No sound at all on some Android TV boxes**, an NVIDIA Shield in particular — and sound now returns by itself when your TV changes mode mid-stream.
|
||||
- **The Punktfunk home would not open on a Steam Deck after 0.28.0**, and streams elsewhere lost their statistics overlay to the same fault.
|
||||
- **Streaming your desktop with the pointer forwarded gave a black picture on Hyprland and Sway** — every current install, not just old ones.
|
||||
- **High dynamic range was broken in both directions on Linux hosts:** games could not reach it at all, and everything else was mapped a stop too bright. Both fixed.
|
||||
|
||||
## Before you update
|
||||
|
||||
- **On Windows, two things in your Sound settings will look different, and both are deliberate.** "Speakers (Wireless Controller)" now shows as *disabled* whenever no controller is connected through Punktfunk — that is it parked out of the way of your local games, not a fault, and it comes back on its own. And Punktfunk no longer keeps your default microphone: it borrows it for the length of a stream and gives it back. If you actually want apps to record the client's microphone while nothing is streaming, select "Punktfunk Microphone" yourself — your choice will now be left alone.
|
||||
- **Only if you are coming from 0.27 or older:** read [the 0.28.0 notes](https://git.unom.io/unom/punktfunk/releases/tag/v0.28.0) first. That release has two things that need you to act — your game library now comes from add-ons you install per store (with none installed, your library is empty), and Moonlight-compatible connections became something you switch on. Neither applies if you are already on 0.28.0.
|
||||
|
||||
## New
|
||||
|
||||
- **Your saved hosts have a menu when you use a controller on Apple devices.** Press up on a host tile for Wake, Copy link, Edit, Forget pairing and Remove — the same menu the desktop and Android consoles already had, so all three work the same way. A renamed machine or a mistyped address used to stay wrong forever unless you switched to touch. Removing asks twice, deliberately, because a thumbstick from across the room deserves at least as much care as a dialog.
|
||||
- **The shortcut list is now a page you can open.** It used to appear as a banner over the stream for six seconds right after connecting — which is the one moment nobody is asking what the controls are. It lives in an About section you can visit whenever you want it.
|
||||
- **Unpair every device at once.** Clearing a host's paired devices meant deleting them one at a time, confirming each. There is now a single action for it on the Paired devices card, and it ends any live session belonging to a device it removes.
|
||||
|
||||
## Improved
|
||||
|
||||
- **High dynamic range brightness matches between host and client.** Anything that is not an HDR game — your desktop, the Steam overlay, an ordinary title — was being mapped nearly a stop brighter by the host than every Punktfunk client expects. That is what made the Steam interface look glaring and over-saturated while actual HDR game content looked flat and washed out on the same stream. Both come from the same mistake and both are fixed.
|
||||
- **Your host tells you when high dynamic range cannot work.** A session could start advertising HDR while having made it unreachable for every game inside it, with nothing anywhere explaining the washed-out result. It now says so.
|
||||
- **The on-screen statistics no longer double-count decoding.** On machines using the fastest decoding path, the stage breakdown listed a figure that was already included in another one, so the parts did not add up to the total and a reader who checked was right to be confused. It is now reported separately, labelled as the single sample it is.
|
||||
|
||||
## Fixed
|
||||
|
||||
- **On Windows, the Steam add-on synced nothing and your grid stayed empty.** Steam installs to Program Files and keeps its artwork there, and the host's list of places it is allowed to read art from covered the launchers that install per-user — but not Steam. Every cover the add-on offered was rejected, and because one bad cover failed the whole submission, that meant every Steam game rather than one thumbnail. Both halves are fixed: Steam's own install locations are now allowed (including on a second drive), and a cover that cannot be served is dropped so the rest of your library still arrives. This only affected Windows — every Steam layout on Linux was already covered — and it arrived with 0.28.0, when the built-in Steam scanner was replaced by the add-on.
|
||||
- **A Punktfunk host that was doing nothing at all could wreck a game running on the same Windows machine** — 2–5 frames per second at worst, with no client connected and no stream ever started. Two independent causes, both from the same set of reports: the host presents a controller speaker that looks exactly like a real DualSense's, and a game that finds it engages its DualSense audio path against a device nothing is feeding; and the host was claiming your default microphone permanently, so any game holding an open voice input stalled on a virtual mic that only runs during a stream. The controller speaker is now hidden except while a controller is actually attached, and your microphone is only borrowed for the length of a stream. Machines already stuck repair themselves on the next start.
|
||||
- **Streaming your desktop with the pointer forwarded gave a black picture on Hyprland and Sway.** Punktfunk asked the desktop's screen-sharing service for the pointer in a form neither compositor offers, and the request was refused before it ever reached them — so the whole capture failed rather than losing just the pointer. It now asks what is actually available and takes the best on offer. Measured against fully up-to-date Hyprland and portal versions: this was every current install, and updating them would not have helped. You will now see a single pointer drawn by the host instead of your client drawing its own.
|
||||
- **Streaming from a Mac with the microphone on cut the audio and froze the controls on a two-and-a-half second beat**, while the picture carried on untouched. Some microphones — a multi-channel audio interface set as your Mac's input, for instance — cannot run the echo-cancelling mode Punktfunk asks for. Each failed attempt knocked out the working audio path, and the knock-out was itself the signal that triggered the next attempt, so it went round forever. Your controls froze on the same beat because that rebuild happened on the part of the app that also sends your keyboard and mouse. All three links in that loop are broken now, and audio setup no longer happens where your input lives — so even a genuine change of sound device no longer costs you control of the stream. Until now the only workaround was turning the client's microphone off.
|
||||
- **An Android TV box could have no audio for an entire session.** Reported on an NVIDIA Shield with a host and settings that played fine everywhere else, and video unaffected. The app asked Android for an audio device and then trusted the answer: a device that accepted the request but played nothing was never noticed, a device that refused to start disabled sound for the rest of the session, and — most common on a television — a device that went away during an HDMI mode change was never reopened. It now proves that sound is actually being taken before it accepts a device, and reopens automatically when your TV or receiver changes mode. TV boxes now use the ordinary shared audio path rather than the exclusive low-latency one, which was never buying them the latency it cost.
|
||||
- **The Punktfunk home would not open on a Steam Deck after updating to 0.28.0.** Both the Decky button and the library shortcut failed. The same fault silently removed the statistics and capture overlays inside streams on other machines. It came from a graphics library sizing itself to what the machine's graphics driver supported rather than to what Punktfunk had asked for, which only became a problem once drivers moved ahead of us.
|
||||
- **Games could not use high dynamic range on Linux hosts running Punktfunk's compositor** — on essentially every machine, not a rare configuration. Reaching HDR requires a small graphics component that has to match the compositor exactly, and we shipped the compositor without it, leaving the host guessing whether the one your distribution provided was compatible. Because we build our own compositor, that guess was almost always wrong in the direction of switching HDR off. We now ship the matching component ourselves, on every Linux packaging channel.
|
||||
- **Reconnecting after a disconnect could lose your game** on Linux hosts with an NVIDIA card. The host keeps your session alive when a client leaves so reconnecting resumes it, and the compositor could crash in exactly that window — so the session you came back to was a brand-new one with your game gone. Found, fixed and proven in the field by **luxus**, over an evening of Baldur's Gate 3 at 4K60 HDR that had produced four crashes before the fix and none after.
|
||||
- **A stream that was not HDR pushed an Apple TV into HDR mode anyway**, which switches the HDMI output to limited range — so on a television set for full range, black came out grey. The switch was triggered by your HDR *setting* being on rather than by the stream actually being HDR, and that setting is on by default.
|
||||
- **Ordinary streams on Apple devices were drawn with no colour information at all**, leaving them mildly over-saturated on a Mac or iPad with a wide-colour screen, and lifting blacks on an Apple TV. A stream that was not HDR from its very first frame skipped colour setup entirely.
|
||||
- **The statistics overlay on Apple devices reported numbers that could not be true** — a network figure lower than the host's own encoding time, and historically readings of zero. Three faults: the clock difference with the host was measured once at connect and never updated (it drifts, and on a virtual-machine host it jumps), impossible samples were being discarded silently rather than invalidating the reading, and several unavailable values printed as "NaN". The overlay now warns you when a reading is suspect instead of showing a plausible wrong number.
|
||||
- **AV1 streams fell back to slower decoding** on hardware that decodes them perfectly well. Some encoders mark a stream as "not restricted to any quality level", and that marker was being read as a level so high that no device could claim to support it.
|
||||
- **On Apple TV, console text could be white on a pale background**, making screens unreadable with the light appearance.
|
||||
- **Punktfunk add-ons could not be installed on NixOS**, where the component that manages them was reported missing on machines where it was installed, enabled and running.
|
||||
- **On NixOS the host could crash-loop forever, unable to claim its own ports.** A second copy was being started under the root account — which springs into life the moment anyone signs in as root — and it won the race for the ports the real one needed. The log blamed some other program, because a second copy of itself is the last thing anyone looks for. The service is now scoped to the accounts you list.
|
||||
- **Two host warnings blamed the wrong thing.** One announced a "metronomic" disturbance from your display hardware when the regular timing was in fact a client's own recovery interval; the other reported catastrophic audio loss between sessions, when what it had counted was silence during the seconds no one was connected. Neither ever indicated a real fault.
|
||||
|
||||
## Thanks
|
||||
|
||||
**luxus** found the crash that lost your game on reconnect, wrote the fix, and proved it on his own machine — an evening of Baldur's Gate 3 at 4K60 HDR that had produced four crashes before it and none after. It ships here as he wrote it.
|
||||
|
||||
Most of the rest of this release exists because people took the trouble to report a fault precisely: the silent Android TV box, the empty Steam library, the game stuttering next to an idle host, the washed-out HDR, the black picture on Hyprland. Thank you — that detail is what makes a fault findable.
|
||||
|
||||
## For developers
|
||||
|
||||
Protocol, ABI, driver and embedder detail — including the version table and the (empty) breaking-changes list — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/v0.28.1/CHANGELOG.md).
|
||||
|
||||
Nothing breaks: the wire protocol, the C ABI, the virtual-display driver protocol and the plugin contract are all unchanged from v0.28.0. Three things are worth reading if they apply to you: every Linux packaging channel now ships a second gamescope artifact and a package without it fails to build rather than degrading quietly, Android's `nativeStartAudio` takes one more argument, and `SessionAudio.start()` is now asynchronous on macOS as it already was on iOS and tvOS — playback is live shortly after the call rather than on return.
|
||||
@@ -0,0 +1,3 @@
|
||||
• Fixes having no sound at all on some Android TV boxes, the NVIDIA Shield among them. The app now checks that your TV or receiver is really taking the audio, instead of trusting that it will.
|
||||
• Sound returns on its own when your TV or receiver changes mode during a stream. Before, it could stay silent for the rest of the session.
|
||||
• A stream that loses audio now says so, rather than looking exactly like an app with nothing to play.
|
||||
@@ -69,6 +69,10 @@
|
||||
# from source and would make `nix flake check` an hour long.
|
||||
punktfunk-gamescope = pkgs.callPackage ./packaging/nix/gamescope.nix {
|
||||
patchDir = ./packaging/gamescope/patches;
|
||||
# Shared verbatim with build-punktfunk-gamescope.sh, which is the whole reason it is a
|
||||
# file: the FHS packages and the Nix store must rename the WSI layer identically, or
|
||||
# the host looks for a layer name that only one of them produces.
|
||||
manifestRewriter = ./packaging/gamescope/rewrite-wsi-layer-manifest.py;
|
||||
};
|
||||
};
|
||||
in
|
||||
|
||||
@@ -77,7 +77,15 @@ if [ -n "$GAMESCOPE" ]; then
|
||||
[ -x "$GS_BIN" ] || { echo "$GAMESCOPE did not provide usr/bin/punktfunk-gamescope" >&2; exit 1; }
|
||||
"$GS_BIN" --version 2>&1 | grep -q '+pfhdr' || {
|
||||
echo "$GAMESCOPE's binary has no +pfhdr marker — it is not a punktfunk HDR build" >&2; exit 1; }
|
||||
echo "folded in $("$GS_BIN" --version 2>&1 | head -1)"
|
||||
# The package carries the Vulkan WSI layer alongside the compositor and the extraction above takes
|
||||
# the whole `usr`, so this is an assertion rather than a step — but a silent one is exactly how
|
||||
# this went wrong before: an image with the compositor and no layer streams HDR while every game
|
||||
# inside it renders SDR, and nothing anywhere says why.
|
||||
for f in usr/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so \
|
||||
usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json; do
|
||||
[ -f "$STAGE/$f" ] || { echo "$GAMESCOPE has no $f — no game HDR without it" >&2; exit 1; }
|
||||
done
|
||||
echo "folded in $("$GS_BIN" --version 2>&1 | head -1) + its WSI layer"
|
||||
fi
|
||||
|
||||
# The marker systemd-sysext requires to merge the image. ID=_any merges onto ANY host os-release
|
||||
|
||||
@@ -24,15 +24,20 @@
|
||||
#
|
||||
# Usage:
|
||||
# bash build-sysext.sh --version-id 43 --out dist/punktfunk-0.7.1-1-x86-64.raw \
|
||||
# [--gamescope path/to/punktfunk-gamescope] \
|
||||
# [--gamescope-stage path/to/gamescope-destdir] \
|
||||
# dist/punktfunk-0.7.1-1.fc43.x86_64.rpm dist/punktfunk-web-0.7.1-1.fc43.noarch.rpm
|
||||
#
|
||||
# --gamescope folds in a prebuilt HDR-capable gamescope (packaging/gamescope) as
|
||||
# --gamescope-stage folds in a prebuilt HDR-capable gamescope (packaging/gamescope) as
|
||||
# /usr/bin/punktfunk-gamescope, which is what lets the gamescope backend stream 10-bit BT.2020 PQ.
|
||||
# It is NOT built here: it is a C++ meson build with gamescope's whole dependency set, so CI builds
|
||||
# it in the same Fedora container beforehand (`bash packaging/gamescope/build-punktfunk-gamescope.sh
|
||||
# --destdir stage --prefix /usr`) and passes the resulting binary in. Omit it and the image is
|
||||
# exactly what it was — the host then stays SDR on that backend, by design.
|
||||
# --destdir stage --prefix /usr`) and passes that DESTDIR in. Omit it and the image is exactly what
|
||||
# it was — the host then stays SDR on that backend, by design.
|
||||
#
|
||||
# A directory rather than the binary, because the tree also carries the Vulkan WSI layer built beside
|
||||
# the compositor. That layer is the only route to an HDR10 swapchain for a game nested under
|
||||
# gamescope, so an image with the compositor and without it would stream HDR while every game in it
|
||||
# rendered SDR.
|
||||
#
|
||||
# The installed image MUST be named punktfunk.raw (the embedded extension-release marker is
|
||||
# extension-release.punktfunk; systemd-sysext requires marker == image name) — the feed carries
|
||||
@@ -44,7 +49,7 @@ while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--version-id) VERSION_ID="${2:?}"; shift 2 ;;
|
||||
--out) OUT="${2:?}"; shift 2 ;;
|
||||
--gamescope) GAMESCOPE="${2:?}"; shift 2 ;;
|
||||
--gamescope-stage) GAMESCOPE="${2:?}"; shift 2 ;;
|
||||
*) RPMS+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
@@ -87,15 +92,27 @@ if [ -d "$STAGE/etc" ]; then
|
||||
fi
|
||||
rm -rf "${STAGE:?}/var" # rpm ghosts etc. — nothing outside /usr may remain
|
||||
|
||||
# The HDR-capable gamescope, when one was built (see --gamescope in the header). Verified by its
|
||||
# The HDR-capable gamescope, when one was built (see --gamescope-stage in the header). Verified by its
|
||||
# banner marker rather than trusted by filename: an unpatched gamescope shipped under this name
|
||||
# would make the host promise HDR it cannot deliver, and the punktfunk/1 Welcome cannot take that
|
||||
# back mid-session.
|
||||
if [ -n "$GAMESCOPE" ]; then
|
||||
[ -x "$GAMESCOPE" ] || { echo "no such executable: $GAMESCOPE" >&2; exit 1; }
|
||||
"$GAMESCOPE" --version 2>&1 | grep -q '+pfhdr' || {
|
||||
echo "$GAMESCOPE has no +pfhdr marker — it is not a punktfunk HDR build" >&2; exit 1; }
|
||||
install -Dm0755 "$GAMESCOPE" "$STAGE/usr/bin/punktfunk-gamescope"
|
||||
GS_BIN="$GAMESCOPE/usr/bin/punktfunk-gamescope"
|
||||
GS_LAYER_SO="$GAMESCOPE/usr/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so"
|
||||
GS_LAYER_JSON="$GAMESCOPE/usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json"
|
||||
[ -x "$GS_BIN" ] || { echo "no such executable: $GS_BIN" >&2; exit 1; }
|
||||
"$GS_BIN" --version 2>&1 | grep -q '+pfhdr' || {
|
||||
echo "$GS_BIN has no +pfhdr marker — it is not a punktfunk HDR build" >&2; exit 1; }
|
||||
# Fatal for the same reason the marker check is: an image carrying the compositor without its
|
||||
# layer streams HDR while every game inside it renders SDR, and says nothing about why.
|
||||
for f in "$GS_LAYER_SO" "$GS_LAYER_JSON"; do
|
||||
[ -f "$f" ] || { echo "$f missing — the gamescope stage has no WSI layer" >&2; exit 1; }
|
||||
done
|
||||
install -Dm0755 "$GS_BIN" "$STAGE/usr/bin/punktfunk-gamescope"
|
||||
install -Dm0755 "$GS_LAYER_SO" \
|
||||
"$STAGE/usr/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so"
|
||||
install -Dm0644 "$GS_LAYER_JSON" \
|
||||
"$STAGE/usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json"
|
||||
fi
|
||||
|
||||
# Enable the plugin/script runner for every user, by baking its `[Install] WantedBy=default.target`
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user